mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-27 20:57:13 +00:00
Compare commits
29 Commits
579e3556e4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| aeff8d7e30 | |||
| be3734ffda | |||
| 855ae2bdba | |||
| b66db86bff | |||
| 95b8736e93 | |||
| cabde423a1 | |||
| 08307790e5 | |||
| 777fe1f20b | |||
| f0ee57c1e0 | |||
| a45e27e76e | |||
| 536fcdf29f | |||
| c87548c0b9 | |||
| 79634772da | |||
| bb366779af | |||
| 1336f47cb4 | |||
| 962366c507 | |||
| 23875b240f | |||
| e699358a5a | |||
| 14277d129c | |||
| 6bf1546df2 | |||
| 0bec72a3f9 | |||
| f36542135a | |||
| 693c59b726 | |||
| c3fe312a43 | |||
| c4bad508d2 | |||
| 3d4a726cd8 | |||
| e934f08adf | |||
| 7803d56254 | |||
| 54c96a18e1 |
@@ -7,23 +7,42 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build-dev-image:
|
build-dev-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# 如果是tag则跳过
|
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Generate Tag
|
- name: Set up Docker Buildx
|
||||||
id: generate_tag
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Generate image metadata
|
||||||
|
id: image
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
# 获取分支名称,把/替换为-
|
set -euo pipefail
|
||||||
echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g'
|
branch_tag="${GITHUB_REF#refs/heads/}"
|
||||||
echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g')
|
branch_tag="${branch_tag//\//-}"
|
||||||
- name: Login to Registry
|
echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT"
|
||||||
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
|
echo "sha_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
- name: Build Docker Image
|
|
||||||
run: |
|
- name: Login to Docker Hub
|
||||||
docker buildx create --name mybuilder --use
|
uses: docker/login-action@v3
|
||||||
docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push immutable Core image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
rockchin/langbot:${{ steps.image.outputs.branch_tag }}
|
||||||
|
rockchin/langbot:${{ steps.image.outputs.sha_tag }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y --no-install-recommends nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& rm -f /tmp/nodesource_setup.sh \
|
&& rm -f /tmp/nodesource_setup.sh \
|
||||||
&& python -m pip install --no-cache-dir uv \
|
&& python -m pip install --no-cache-dir uv \
|
||||||
&& uv sync \
|
&& uv sync --extra seekdb \
|
||||||
&& apt-get purge -y --auto-remove curl git gnupg \
|
&& apt-get purge -y --auto-remove curl git gnupg \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& touch /.dockerenv
|
&& touch /.dockerenv
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ uvx langbot
|
|||||||
|
|
||||||
This will automatically download and run the latest version of LangBot.
|
This will automatically download and run the latest version of LangBot.
|
||||||
|
|
||||||
|
SeekDB support is optional and is not installed by the command above. If you
|
||||||
|
want to use the SeekDB vector database or the built-in SeekDB embedding model,
|
||||||
|
run LangBot with the `seekdb` extra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx --from 'langbot[seekdb]@latest' langbot
|
||||||
|
```
|
||||||
|
|
||||||
|
The extra includes native dependencies whose supported operating systems may
|
||||||
|
be narrower than LangBot's. In particular, the current Apple Silicon wheels
|
||||||
|
require macOS 15 or later. The default Chroma backend does not have this
|
||||||
|
requirement.
|
||||||
|
|
||||||
## Install with pip/uv
|
## Install with pip/uv
|
||||||
|
|
||||||
You can also install LangBot as a regular Python package:
|
You can also install LangBot as a regular Python package:
|
||||||
@@ -20,6 +33,10 @@ pip install langbot
|
|||||||
|
|
||||||
# Using uv
|
# Using uv
|
||||||
uv pip install langbot
|
uv pip install langbot
|
||||||
|
|
||||||
|
# Include optional SeekDB support
|
||||||
|
pip install 'langbot[seekdb]'
|
||||||
|
# or: uv pip install 'langbot[seekdb]'
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run it:
|
Then run it:
|
||||||
@@ -101,7 +118,7 @@ uvx langbot
|
|||||||
|
|
||||||
## System Requirements
|
## System Requirements
|
||||||
|
|
||||||
- Python 3.10.1 or higher
|
- Python 3.11 or higher (lower than Python 4)
|
||||||
- Operating System: Linux, macOS, or Windows
|
- Operating System: Linux, macOS, or Windows
|
||||||
|
|
||||||
## Differences from Source Installation
|
## Differences from Source Installation
|
||||||
|
|||||||
+34
-43
@@ -16,12 +16,20 @@ This document describes how to use OceanBase SeekDB as the vector database backe
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
|
SeekDB is an optional LangBot feature. A normal LangBot installation uses
|
||||||
|
Chroma by default and does not install `pyseekdb` or its native bindings.
|
||||||
|
|
||||||
If you need to install it manually:
|
Choose the command that matches how you run LangBot:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install pyseekdb
|
# PyPI / uvx
|
||||||
|
uvx --from 'langbot[seekdb]@latest' langbot
|
||||||
|
|
||||||
|
# Installed package
|
||||||
|
pip install 'langbot[seekdb]'
|
||||||
|
|
||||||
|
# Source checkout
|
||||||
|
uv sync --extra seekdb
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚠️ Platform Compatibility
|
## ⚠️ Platform Compatibility
|
||||||
@@ -30,31 +38,36 @@ pip install pyseekdb
|
|||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
| Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
||||||
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
| macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel |
|
||||||
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
| macOS 14 or earlier on Apple Silicon | ❌ Not currently supported | The published native wheel requires macOS 15+; follow [oceanbase/seekdb#1324](https://github.com/oceanbase/seekdb/issues/1324) |
|
||||||
|
| macOS on Intel | ❌ Not currently supported | No embedded binding is selected by `pyseekdb` |
|
||||||
|
| Windows | ❌ Not currently supported | No Windows `pylibseekdb` wheel is published |
|
||||||
|
|
||||||
**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
|
**Important**: Embedded mode requires a compatible `pylibseekdb` wheel. Do not
|
||||||
|
force-install or retag a wheel built for a newer macOS release: the bundled
|
||||||
|
binaries also declare macOS 15 as their minimum deployment target.
|
||||||
|
|
||||||
### Server Mode (Docker)
|
### Server Mode (Docker)
|
||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| Linux | ✅ Supported | Full Docker support |
|
| Linux | ✅ Supported | Full Docker support |
|
||||||
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
|
| macOS | ✅ Supported by Docker Desktop | The previous slow-disk startup issue was fixed upstream in [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36) |
|
||||||
| Windows | ⚠️ Untested | Should work but not yet tested |
|
| Windows | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation |
|
||||||
|
|
||||||
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
|
|
||||||
- Using ChromaDB or Qdrant as alternatives
|
|
||||||
- Connecting to a remote SeekDB server on Linux if available
|
|
||||||
|
|
||||||
### Server Mode (Remote Connection)
|
### Server Mode (Remote Connection)
|
||||||
|
|
||||||
| Platform | Status | Notes |
|
| Platform | Status | Notes |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
|
| Linux | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
|
||||||
|
| macOS 15+ on Apple Silicon | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
|
||||||
|
| macOS 14 or earlier on Apple Silicon | ⚠️ Blocked by upstream packaging | `pyseekdb` currently requires the unavailable native wheel even for server-only use; follow [#1324](https://github.com/oceanbase/seekdb/issues/1324) |
|
||||||
|
| macOS on Intel / Windows | ✅ Server mode only | Embedded bindings are not available |
|
||||||
|
|
||||||
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
|
Remote server mode does not use embedded storage at runtime. However, whether
|
||||||
|
the Python client can be installed still depends on `pyseekdb`'s package
|
||||||
|
metadata for the current platform.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -170,22 +183,23 @@ Key methods:
|
|||||||
|
|
||||||
### Import Error
|
### Import Error
|
||||||
|
|
||||||
If you see: `ImportError: pyseekdb is not installed`
|
If you see: `SeekDB support is not installed`
|
||||||
|
|
||||||
Solution:
|
Solution:
|
||||||
```bash
|
```bash
|
||||||
pip install pyseekdb
|
uv sync --extra seekdb
|
||||||
|
# or: uvx --from 'langbot[seekdb]@latest' langbot
|
||||||
```
|
```
|
||||||
|
|
||||||
### Embedded Mode Error on macOS/Windows
|
### Embedded Mode Is Unavailable on the Current Platform
|
||||||
|
|
||||||
**Error**:
|
**Error**:
|
||||||
```
|
```
|
||||||
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
|
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
|
||||||
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Cause**: `pylibseekdb` is only available on Linux platforms.
|
**Cause**: No compatible `pylibseekdb` wheel is installed for the current OS,
|
||||||
|
CPU architecture, Python version, and macOS deployment target.
|
||||||
|
|
||||||
**Solution**: Use server mode instead:
|
**Solution**: Use server mode instead:
|
||||||
1. Deploy SeekDB on a Linux server or VM
|
1. Deploy SeekDB on a Linux server or VM
|
||||||
@@ -208,29 +222,6 @@ vdb:
|
|||||||
use: chroma # or qdrant
|
use: chroma # or qdrant
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Container Fails on macOS
|
|
||||||
|
|
||||||
**Symptoms**:
|
|
||||||
```bash
|
|
||||||
docker run -d -p 2881:2881 oceanbase/seekdb:latest
|
|
||||||
# Container exits immediately with code 30
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error in logs**:
|
|
||||||
```
|
|
||||||
[ERROR] Code: Agent.SeekDB.Not.Exists
|
|
||||||
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
|
|
||||||
|
|
||||||
**Status**: Under investigation by OceanBase team.
|
|
||||||
|
|
||||||
**Workaround Options**:
|
|
||||||
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
|
|
||||||
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
|
|
||||||
3. **Wait for fix**: Monitor the GitHub issue for updates
|
|
||||||
|
|
||||||
### Connection Error (Server Mode)
|
### Connection Error (Server Mode)
|
||||||
|
|
||||||
If SeekDB server is not reachable, check:
|
If SeekDB server is not reachable, check:
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.7"
|
version = "4.10.8"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -70,7 +70,6 @@ dependencies = [
|
|||||||
"langchain-text-splitters>=1.1.2",
|
"langchain-text-splitters>=1.1.2",
|
||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
|
||||||
"langbot-plugin==0.5.5",
|
"langbot-plugin==0.5.5",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
@@ -108,6 +107,11 @@ classifiers = [
|
|||||||
"Topic :: Communications :: Chat",
|
"Topic :: Communications :: Chat",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
seekdb = [
|
||||||
|
"pyseekdb==1.1.0.post3",
|
||||||
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://langbot.app"
|
Homepage = "https://langbot.app"
|
||||||
Documentation = "https://docs.langbot.app"
|
Documentation = "https://docs.langbot.app"
|
||||||
|
|||||||
@@ -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
|
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
|
||||||
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
||||||
the immutable Workspace context authenticated at the MCP transport boundary.
|
the immutable Workspace context authenticated at the MCP transport boundary.
|
||||||
|
Pass `is_default: true` to `create_pipeline` only when the Workspace does not
|
||||||
|
already have a default pipeline.
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
|
|||||||
@@ -422,6 +422,69 @@ class QQOfficialClient:
|
|||||||
await self.logger.error(f'Failed to send private message: {response_data}')
|
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||||
raise ValueError(response)
|
raise ValueError(response)
|
||||||
|
|
||||||
|
async def _send_markdown_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Markdown message to a C2C user or QQ group."""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
if target_type == 'c2c':
|
||||||
|
url = f'{self.base_url}/v2/users/{target_id}/messages'
|
||||||
|
elif target_type == 'group':
|
||||||
|
url = f'{self.base_url}/v2/groups/{target_id}/messages'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported Markdown target type: {target_type}')
|
||||||
|
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
'msg_type': 2,
|
||||||
|
'markdown': {'content': content},
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
}
|
||||||
|
if msg_id:
|
||||||
|
data['msg_id'] = msg_id
|
||||||
|
if event_id:
|
||||||
|
data['event_id'] = event_id
|
||||||
|
|
||||||
|
async with self._http_client_context() as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
response = await client.post(url, headers=headers, json=data)
|
||||||
|
if response.status_code != 200:
|
||||||
|
response_data = await httpclient.parse_json_response(response)
|
||||||
|
await self.logger.error(f'Failed to send Markdown message: {response_data}')
|
||||||
|
raise ValueError(response)
|
||||||
|
|
||||||
|
async def send_private_markdown_msg(
|
||||||
|
self,
|
||||||
|
user_openid: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Markdown C2C message."""
|
||||||
|
await self._send_markdown_msg('c2c', user_openid, content, msg_id, event_id, msg_seq)
|
||||||
|
|
||||||
|
async def send_group_markdown_msg(
|
||||||
|
self,
|
||||||
|
group_openid: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Markdown QQ group message."""
|
||||||
|
await self._send_markdown_msg('group', group_openid, content, msg_id, event_id, msg_seq)
|
||||||
|
|
||||||
async def send_group_text_msg(
|
async def send_group_text_msg(
|
||||||
self,
|
self,
|
||||||
group_openid: str,
|
group_openid: str,
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ CMD_RESPOND_MSG = 'aibot_respond_msg'
|
|||||||
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
|
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
|
||||||
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
|
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
|
||||||
CMD_SEND_MSG = 'aibot_send_msg'
|
CMD_SEND_MSG = 'aibot_send_msg'
|
||||||
|
# Media upload protocol (3 steps: init -> chunk * N -> finish). The
|
||||||
|
# command names below match the WeCom AI Bot long-connection protocol.
|
||||||
|
CMD_UPLOAD_INIT = 'aibot_upload_media_init'
|
||||||
|
CMD_UPLOAD_CHUNK = 'aibot_upload_media_chunk'
|
||||||
|
CMD_UPLOAD_FINISH = 'aibot_upload_media_finish'
|
||||||
|
|
||||||
|
# Default upload chunk size: 512 KB before base64 encoding.
|
||||||
|
_UPLOAD_CHUNK_SIZE = 512 * 1024
|
||||||
|
|
||||||
_DEDUP_CACHE_MAX = 4096
|
_DEDUP_CACHE_MAX = 4096
|
||||||
_STREAM_CACHE_MAX = 1024
|
_STREAM_CACHE_MAX = 1024
|
||||||
@@ -495,6 +503,145 @@ class WecomBotWsClient:
|
|||||||
body['chatid'] = chat_id
|
body['chatid'] = chat_id
|
||||||
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Media upload (image / voice / file)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def upload_media(
|
||||||
|
self,
|
||||||
|
data: bytes,
|
||||||
|
filename: str = 'attachment',
|
||||||
|
media_type: str = 'file',
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Upload *data* to the WeCom AI Bot CDN and return the parsed ACK.
|
||||||
|
|
||||||
|
Implements the three-step protocol documented for the WeCom
|
||||||
|
AI Bot:
|
||||||
|
|
||||||
|
1. ``aibot_upload_media_init`` — declare media type, file name,
|
||||||
|
size, MD5 and chunk count; receive ``upload_id``.
|
||||||
|
2. ``aibot_upload_media_chunk`` — send each chunk (base64-encoded
|
||||||
|
bytes) until done; receive per-chunk ACK.
|
||||||
|
3. ``aibot_upload_media_finish`` — finalize the upload; receive
|
||||||
|
``media_id``.
|
||||||
|
|
||||||
|
Returns a dict with the final ``media_id`` (and the raw
|
||||||
|
``finish`` ACK) on success, or ``None`` on any failure. The
|
||||||
|
caller is expected to ignore the result and continue
|
||||||
|
gracefully — the framework will keep working without media
|
||||||
|
delivery.
|
||||||
|
"""
|
||||||
|
import base64 as _b64
|
||||||
|
import hashlib as _hl
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
file_size = len(data)
|
||||||
|
file_md5 = _hl.md5(data).hexdigest()
|
||||||
|
total_chunks = (file_size + _UPLOAD_CHUNK_SIZE - 1) // _UPLOAD_CHUNK_SIZE
|
||||||
|
if total_chunks == 0:
|
||||||
|
total_chunks = 1
|
||||||
|
|
||||||
|
# Step 1: init.
|
||||||
|
init_req_id = _generate_req_id(CMD_UPLOAD_INIT)
|
||||||
|
init_body = {
|
||||||
|
'type': media_type,
|
||||||
|
'filename': filename,
|
||||||
|
'total_size': file_size,
|
||||||
|
'total_chunks': total_chunks,
|
||||||
|
'md5': file_md5,
|
||||||
|
}
|
||||||
|
init_ack = await self._send_reply(
|
||||||
|
init_req_id,
|
||||||
|
init_body,
|
||||||
|
cmd=CMD_UPLOAD_INIT,
|
||||||
|
)
|
||||||
|
if not init_ack or init_ack.get('errcode', 0) != 0:
|
||||||
|
await self.logger.warning(f'upload_media init failed: ack={init_ack!r}')
|
||||||
|
return None
|
||||||
|
upload_id = (
|
||||||
|
init_ack.get('upload_id')
|
||||||
|
or init_ack.get('body', {}).get('upload_id')
|
||||||
|
or init_ack.get('data', {}).get('upload_id')
|
||||||
|
)
|
||||||
|
if not upload_id:
|
||||||
|
await self.logger.warning(f'upload_media init returned no upload_id: ack={init_ack!r}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 2: chunks.
|
||||||
|
for index in range(total_chunks):
|
||||||
|
start = index * _UPLOAD_CHUNK_SIZE
|
||||||
|
end = min(start + _UPLOAD_CHUNK_SIZE, file_size)
|
||||||
|
chunk_bytes = data[start:end]
|
||||||
|
chunk_req_id = _generate_req_id(CMD_UPLOAD_CHUNK)
|
||||||
|
chunk_body = {
|
||||||
|
'upload_id': upload_id,
|
||||||
|
'chunk_index': index,
|
||||||
|
'base64_data': _b64.b64encode(chunk_bytes).decode('ascii'),
|
||||||
|
}
|
||||||
|
chunk_ack = await self._send_reply(
|
||||||
|
chunk_req_id,
|
||||||
|
chunk_body,
|
||||||
|
cmd=CMD_UPLOAD_CHUNK,
|
||||||
|
)
|
||||||
|
if not chunk_ack or chunk_ack.get('errcode', 0) != 0:
|
||||||
|
await self.logger.warning(f'upload_media chunk {index} failed: ack={chunk_ack!r}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 3: finish.
|
||||||
|
finish_req_id = _generate_req_id(CMD_UPLOAD_FINISH)
|
||||||
|
finish_body = {'upload_id': upload_id}
|
||||||
|
finish_ack = await self._send_reply(
|
||||||
|
finish_req_id,
|
||||||
|
finish_body,
|
||||||
|
cmd=CMD_UPLOAD_FINISH,
|
||||||
|
)
|
||||||
|
if not finish_ack or finish_ack.get('errcode', 0) != 0:
|
||||||
|
await self.logger.warning(f'upload_media finish failed: ack={finish_ack!r}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
media_id = (
|
||||||
|
finish_ack.get('media_id')
|
||||||
|
or finish_ack.get('body', {}).get('media_id')
|
||||||
|
or finish_ack.get('data', {}).get('media_id')
|
||||||
|
)
|
||||||
|
if not media_id:
|
||||||
|
await self.logger.warning(f'upload_media finish returned no media_id: ack={finish_ack!r}')
|
||||||
|
return None
|
||||||
|
return {'media_id': media_id, 'ack': finish_ack}
|
||||||
|
|
||||||
|
async def _reply_media(
|
||||||
|
self,
|
||||||
|
req_id: str,
|
||||||
|
media_id: str,
|
||||||
|
kind: str,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Send a media reply (image / voice / file) referencing *media_id*.
|
||||||
|
|
||||||
|
``kind`` is one of ``'image'``, ``'voice'``, ``'file'``. Uses
|
||||||
|
the standard ``aibot_respond_msg`` command with a per-kind
|
||||||
|
body key (matches the convention documented for the WeCom
|
||||||
|
AI Bot SDK).
|
||||||
|
"""
|
||||||
|
if kind not in {'image', 'voice', 'file'}:
|
||||||
|
await self.logger.warning(f'_reply_media called with unknown kind={kind!r}')
|
||||||
|
return None
|
||||||
|
body = {
|
||||||
|
'msgtype': kind,
|
||||||
|
kind: {'media_id': media_id},
|
||||||
|
}
|
||||||
|
return await self._send_reply(req_id, body, cmd=CMD_RESPOND_MSG)
|
||||||
|
|
||||||
|
async def reply_image(self, req_id: str, media_id: str) -> Optional[dict]:
|
||||||
|
return await self._reply_media(req_id, media_id, 'image')
|
||||||
|
|
||||||
|
async def reply_file(self, req_id: str, media_id: str) -> Optional[dict]:
|
||||||
|
return await self._reply_media(req_id, media_id, 'file')
|
||||||
|
|
||||||
|
async def reply_voice(self, req_id: str, media_id: str) -> Optional[dict]:
|
||||||
|
return await self._reply_media(req_id, media_id, 'voice')
|
||||||
|
|
||||||
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
||||||
"""Push a streaming chunk for a given message ID.
|
"""Push a streaming chunk for a given message ID.
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,34 @@ class WecomCSClient:
|
|||||||
raise Exception('Failed to send message')
|
raise Exception('Failed to send message')
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@_bounded_token_retry
|
||||||
|
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
|
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||||
|
payload = {
|
||||||
|
'touser': external_userid,
|
||||||
|
'open_kfid': open_kfid,
|
||||||
|
'msgid': msgid,
|
||||||
|
'msgtype': 'image',
|
||||||
|
'image': {
|
||||||
|
'media_id': media_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with self._http_client_context() as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
|
||||||
|
data = await httpclient.parse_json_response(response)
|
||||||
|
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
|
||||||
|
if data['errcode'] != 0:
|
||||||
|
await self.logger.error(f'发送图片失败:{data}')
|
||||||
|
raise Exception('Failed to send image message')
|
||||||
|
return data
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||||
return await self._handle_callback_internal(request)
|
return await self._handle_callback_internal(request)
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_MANAGE,
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
)
|
)
|
||||||
async def _(request_context: RequestContext) -> str:
|
async def _(request_context: RequestContext) -> str:
|
||||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
|
pipeline_data = await quart.request.json
|
||||||
|
create_as_default = pipeline_data.get('is_default') is True
|
||||||
|
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
|
||||||
|
request_context,
|
||||||
|
pipeline_data,
|
||||||
|
default=create_as_default,
|
||||||
|
)
|
||||||
return self.success(data={'uuid': pipeline_uuid})
|
return self.success(data={'uuid': pipeline_uuid})
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
|
|||||||
@@ -113,6 +113,24 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
return self.success(data={'sent': True})
|
return self.success(data={'sent': True})
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<bot_uuid>/test-inbound',
|
||||||
|
methods=['POST'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN,
|
||||||
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
|
)
|
||||||
|
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||||
|
json_data = await quart.request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
result = await self.ap.bot_service.send_http_bot_test_message(
|
||||||
|
request_context,
|
||||||
|
bot_uuid,
|
||||||
|
str(json_data.get('message') or ''),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return self.http_status(400, -1, str(exc))
|
||||||
|
return self.success(data=result)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<bot_uuid>/admins',
|
'/<bot_uuid>/admins',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
|
|||||||
@@ -206,6 +206,20 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success(data={})
|
return self.success(data={})
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/wizard/recommended-model',
|
||||||
|
methods=['GET'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN,
|
||||||
|
permission=Permission.RESOURCE_MANAGE,
|
||||||
|
)
|
||||||
|
async def _(request_context: RequestContext) -> str:
|
||||||
|
"""Resolve Space's best available chat model to this Workspace."""
|
||||||
|
try:
|
||||||
|
model = await self.ap.space_service.get_recommended_chat_model(request_context)
|
||||||
|
except ValueError as exc:
|
||||||
|
return self.http_status(503, -1, str(exc))
|
||||||
|
return self.success(data=model)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/tasks',
|
'/tasks',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
if cloud_mode:
|
if cloud_mode:
|
||||||
capabilities['password_login_enabled'] = False
|
capabilities['password_login_enabled'] = False
|
||||||
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
||||||
|
capabilities['invitation_registration_enabled'] = not cloud_mode
|
||||||
return self.success(data={'initialized': True, **capabilities})
|
return self.success(data={'initialized': True, **capabilities})
|
||||||
|
|
||||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
import json
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
@@ -8,6 +9,8 @@ from ....entity.persistence import bot as persistence_bot
|
|||||||
from ....entity.persistence import pipeline as persistence_pipeline
|
from ....entity.persistence import pipeline as persistence_pipeline
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
from ....workspace.errors import WorkspaceNotFoundError
|
||||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||||
|
from ....utils import httpclient
|
||||||
|
from ....platform.sources import http_bot_signing
|
||||||
|
|
||||||
|
|
||||||
class BotService:
|
class BotService:
|
||||||
@@ -80,6 +83,7 @@ class BotService:
|
|||||||
'wecomcs',
|
'wecomcs',
|
||||||
'LINE',
|
'LINE',
|
||||||
'lark',
|
'lark',
|
||||||
|
'http_bot',
|
||||||
]:
|
]:
|
||||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||||
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||||
@@ -216,6 +220,53 @@ class BotService:
|
|||||||
|
|
||||||
return [log.to_json() for log in logs], total_count
|
return [log.to_json() for log in logs], total_count
|
||||||
|
|
||||||
|
async def send_http_bot_test_message(
|
||||||
|
self,
|
||||||
|
context: TenantContext,
|
||||||
|
bot_uuid: str,
|
||||||
|
message: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Send a signed test message through the HTTP Bot public ingress."""
|
||||||
|
bot = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||||
|
if bot is None:
|
||||||
|
raise WorkspaceNotFoundError('Bot not found')
|
||||||
|
if bot.get('adapter') != 'http_bot':
|
||||||
|
raise ValueError('Inbound test is only available for HTTP Bot')
|
||||||
|
if not bot.get('enable'):
|
||||||
|
raise ValueError('Bot must be enabled before sending a test message')
|
||||||
|
|
||||||
|
text = message.strip()
|
||||||
|
if not text or len(text) > 2000:
|
||||||
|
raise ValueError('Test message must contain 1 to 2000 characters')
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'session_id': f'wizard-{uuid.uuid4().hex}',
|
||||||
|
'sender': {'id': 'wizard-user', 'name': 'Wizard Test'},
|
||||||
|
'message': [{'type': 'Plain', 'text': text}],
|
||||||
|
}
|
||||||
|
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode()
|
||||||
|
config = bot.get('adapter_config') or {}
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if config.get('signature_required', True):
|
||||||
|
secret = str(config.get('inbound_secret') or '')
|
||||||
|
if not secret:
|
||||||
|
raise ValueError('HTTP Bot inbound signing secret is required')
|
||||||
|
timestamp, signature = http_bot_signing.sign(secret, body)
|
||||||
|
headers[http_bot_signing.HEADER_TIMESTAMP] = timestamp
|
||||||
|
headers[http_bot_signing.HEADER_SIGNATURE] = signature
|
||||||
|
|
||||||
|
port = int(self.ap.instance_config.data.get('api', {}).get('port', 5300))
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.post(
|
||||||
|
f'http://127.0.0.1:{port}/bots/{bot_uuid}',
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
) as response:
|
||||||
|
result = await httpclient.read_json_limited(response)
|
||||||
|
if response.status not in {200, 202}:
|
||||||
|
raise ValueError(result.get('msg') or f'HTTP Bot test failed with status {response.status}')
|
||||||
|
return result.get('data') or {}
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import sqlalchemy
|
|||||||
from ....core import app
|
from ....core import app
|
||||||
from ....entity.persistence import user
|
from ....entity.persistence import user
|
||||||
from ....entity.dto.space_model import SpaceModel
|
from ....entity.dto.space_model import SpaceModel
|
||||||
|
from ....entity.dto.space_model import SpaceModelSelection
|
||||||
|
from ....entity.persistence import model as persistence_model
|
||||||
|
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||||
|
|
||||||
|
|
||||||
_CREDITS_CACHE_TTL_SECONDS = 60
|
_CREDITS_CACHE_TTL_SECONDS = 60
|
||||||
@@ -238,3 +241,76 @@ class SpaceService:
|
|||||||
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
raise ValueError(f'Failed to get models: {data.get("msg")}')
|
||||||
models_data = data.get('data', {}).get('models', [])
|
models_data = data.get('data', {}).get('models', [])
|
||||||
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
||||||
|
|
||||||
|
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
|
||||||
|
"""Return Space models in the availability-ranked selection order."""
|
||||||
|
space_url = self._get_space_config()['url']
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.get(
|
||||||
|
f'{space_url}/api/v1/models/selection',
|
||||||
|
params={'category': category},
|
||||||
|
) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
error = await httpclient.read_text_limited(response)
|
||||||
|
raise ValueError(f'Failed to get model selection: {error}')
|
||||||
|
payload = await httpclient.read_json_limited(response)
|
||||||
|
if payload.get('code') != 0:
|
||||||
|
raise ValueError(f'Failed to get model selection: {payload.get("msg")}')
|
||||||
|
|
||||||
|
data = payload.get('data', [])
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get('models', data.get('items', []))
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValueError('Failed to get model selection: invalid response')
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for selection in data:
|
||||||
|
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
|
||||||
|
models.append(selection['model'])
|
||||||
|
else:
|
||||||
|
models.append(selection)
|
||||||
|
return [SpaceModelSelection.model_validate(model) for model in models]
|
||||||
|
|
||||||
|
async def get_recommended_chat_model(self, context: typing.Any) -> dict:
|
||||||
|
"""Resolve Space's first ranked chat model to a local Workspace model."""
|
||||||
|
selection = await self.get_model_selection('chat')
|
||||||
|
if not selection:
|
||||||
|
raise ValueError('No recommended chat model is available')
|
||||||
|
recommended = selection[0]
|
||||||
|
|
||||||
|
async def find_local_model():
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_model.LLMModel)
|
||||||
|
.join(
|
||||||
|
persistence_model.ModelProvider,
|
||||||
|
sqlalchemy.and_(
|
||||||
|
persistence_model.ModelProvider.workspace_uuid == persistence_model.LLMModel.workspace_uuid,
|
||||||
|
persistence_model.ModelProvider.uuid == persistence_model.LLMModel.provider_uuid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
persistence_model.LLMModel.workspace_uuid == context.workspace_uuid,
|
||||||
|
persistence_model.ModelProvider.requester == LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
|
sqlalchemy.or_(
|
||||||
|
persistence_model.LLMModel.uuid == recommended.uuid,
|
||||||
|
persistence_model.LLMModel.name == recommended.model_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.first()
|
||||||
|
|
||||||
|
local_model = await find_local_model()
|
||||||
|
if local_model is None:
|
||||||
|
# OSS synchronizes the public catalog locally. Refresh once in case
|
||||||
|
# the recommendation was published after this process started.
|
||||||
|
from ..context import ExecutionContext
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.ap.model_mgr.sync_new_models_from_space(ExecutionContext.from_request(context))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
local_model = await find_local_model()
|
||||||
|
|
||||||
|
if local_model is None:
|
||||||
|
raise ValueError('Recommended chat model is not available in this Workspace')
|
||||||
|
return {'uuid': local_model.uuid, 'name': local_model.name}
|
||||||
|
|||||||
@@ -147,7 +147,16 @@ class LangBotMCPServer:
|
|||||||
)
|
)
|
||||||
async def create_pipeline(pipeline_data: dict) -> str:
|
async def create_pipeline(pipeline_data: dict) -> str:
|
||||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||||
return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
|
create_as_default = pipeline_data.get('is_default') is True
|
||||||
|
return _dump(
|
||||||
|
{
|
||||||
|
'uuid': await ap.pipeline_service.create_pipeline(
|
||||||
|
context,
|
||||||
|
pipeline_data,
|
||||||
|
default=create_as_default,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
|
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
|
||||||
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
|
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
|
||||||
|
|||||||
@@ -1210,8 +1210,9 @@ class BoxService:
|
|||||||
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
|
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
|
||||||
"""Fallback: read the outbox over the exec channel (E2B / remote).
|
"""Fallback: read the outbox over the exec channel (E2B / remote).
|
||||||
|
|
||||||
Note: exec stdout is truncated by ``output_limit_chars``, so this path
|
Uses ``client.execute`` directly (bypassing ``_serialize_result``)
|
||||||
only reliably transfers small files. The host path is preferred.
|
so stdout is NOT truncated by ``output_limit_chars`` - the raw
|
||||||
|
base64 payload can be far larger than the 4000-char display limit.
|
||||||
"""
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
@@ -1265,14 +1266,22 @@ class BoxService:
|
|||||||
' break\n'
|
' break\n'
|
||||||
'print(json.dumps(out))\n'
|
'print(json.dumps(out))\n'
|
||||||
)
|
)
|
||||||
result = await self.execute_tool(
|
spec_payload: dict = {
|
||||||
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120},
|
'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
|
||||||
query,
|
'timeout_sec': 120,
|
||||||
)
|
'session_id': self.resolve_box_session_id(query),
|
||||||
if not result.get('ok'):
|
}
|
||||||
|
if 'extra_mounts' not in spec_payload:
|
||||||
|
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
|
||||||
|
try:
|
||||||
|
spec = self.build_spec(spec_payload)
|
||||||
|
result = await self.client.execute(spec)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if not result.ok:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1])
|
return _json.loads(str(result.stdout or '').strip().splitlines()[-1])
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -301,11 +301,36 @@ class Application:
|
|||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _initialize_plugin_runtime(self) -> None:
|
||||||
|
try:
|
||||||
|
await self.plugin_connector.initialize()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
||||||
|
self.plugin_connector.schedule_reconnect()
|
||||||
|
|
||||||
|
def _start_plugin_runtime_initialization(self) -> asyncio.Task | None:
|
||||||
|
task = getattr(self, '_plugin_runtime_initialization_task', None)
|
||||||
|
if task is not None and not task.done():
|
||||||
|
return task
|
||||||
|
# This is application lifecycle work, not a request side effect. It must
|
||||||
|
# not wait on PersistenceManager's after-commit gate at boot.
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._initialize_plugin_runtime(),
|
||||||
|
name='plugin-runtime-initialization',
|
||||||
|
)
|
||||||
|
self._plugin_runtime_initialization_task = task
|
||||||
|
return task
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
self.event_loop_monitor.start()
|
self.event_loop_monitor.start()
|
||||||
try:
|
try:
|
||||||
if self.directory_projection_service is not None:
|
if (
|
||||||
self.task_mgr.create_task(
|
self.directory_projection_service is not None
|
||||||
|
and getattr(self, 'directory_projection_task', None) is None
|
||||||
|
):
|
||||||
|
self.directory_projection_task = self.task_mgr.create_task(
|
||||||
self.directory_projection_service.run(),
|
self.directory_projection_service.run(),
|
||||||
name='cloud-directory-projection',
|
name='cloud-directory-projection',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
@@ -322,7 +347,6 @@ class Application:
|
|||||||
name='cloud-manifest-refresh',
|
name='cloud-manifest-refresh',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
await self.plugin_connector.initialize_plugins()
|
|
||||||
|
|
||||||
# 后续可能会允许动态重启其他任务
|
# 后续可能会允许动态重启其他任务
|
||||||
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
|
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
|
||||||
@@ -348,6 +372,7 @@ class Application:
|
|||||||
name='http-api-controller',
|
name='http-api-controller',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
|
self._start_plugin_runtime_initialization()
|
||||||
|
|
||||||
# Telemetry instance heartbeat (startup + daily); respects
|
# Telemetry instance heartbeat (startup + daily); respects
|
||||||
# space.disable_telemetry via TelemetryManager.send().
|
# space.disable_telemetry via TelemetryManager.send().
|
||||||
@@ -529,6 +554,11 @@ class Application:
|
|||||||
|
|
||||||
if self.task_mgr is not None:
|
if self.task_mgr is not None:
|
||||||
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
|
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
|
||||||
|
plugin_runtime_task = getattr(self, '_plugin_runtime_initialization_task', None)
|
||||||
|
if plugin_runtime_task is not None and not plugin_runtime_task.done():
|
||||||
|
plugin_runtime_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await plugin_runtime_task
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await self.event_loop_monitor.stop()
|
await self.event_loop_monitor.stop()
|
||||||
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
|
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .. import stage, app
|
from .. import stage, app, entities as core_entities
|
||||||
from ...utils import version, proxy, constants
|
from ...utils import version, proxy, constants
|
||||||
from ...pipeline import pool, controller, pipelinemgr
|
from ...pipeline import pool, controller, pipelinemgr
|
||||||
from ...pipeline import aggregator as message_aggregator
|
from ...pipeline import aggregator as message_aggregator
|
||||||
@@ -292,14 +292,17 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||||
connector.schedule_reconnect()
|
connector.schedule_reconnect()
|
||||||
|
|
||||||
|
if ap.directory_projection_service is not None:
|
||||||
|
# Keep the projection fresh while shared Runtime cold restore runs.
|
||||||
|
# BuildApp initializes the connector before Application.run() starts
|
||||||
|
# its long-lived tasks, so start the single refresh task here.
|
||||||
|
ap.directory_projection_task = ap.task_mgr.create_task(
|
||||||
|
ap.directory_projection_service.run(),
|
||||||
|
name='cloud-directory-projection',
|
||||||
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
|
)
|
||||||
|
|
||||||
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
||||||
try:
|
|
||||||
await plugin_connector_inst.initialize()
|
|
||||||
except Exception as exc:
|
|
||||||
# Keep the API/UI available while an external or managed runtime is
|
|
||||||
# starting, then recover in the background with bounded backoff.
|
|
||||||
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
|
||||||
plugin_connector_inst.schedule_reconnect()
|
|
||||||
ap.plugin_connector = plugin_connector_inst
|
ap.plugin_connector = plugin_connector_inst
|
||||||
workspace_service_inst.release_startup_execution_bindings()
|
workspace_service_inst.release_startup_execution_bindings()
|
||||||
|
|
||||||
|
|||||||
@@ -47,3 +47,10 @@ class SpaceModel(pydantic.BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SpaceModelSelection(pydantic.BaseModel):
|
||||||
|
"""Minimal model identity returned by the ranked selection endpoint."""
|
||||||
|
|
||||||
|
uuid: str
|
||||||
|
model_id: str
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ from .. import entities
|
|||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
||||||
|
|
||||||
|
# Legacy sensitive-words.json files shipped ~70 rules, which exceeds the
|
||||||
|
# default safe_regex per-call cap of 64 and used to fail-close every message.
|
||||||
|
# Keep one 50ms CPU budget for the whole list; only raise the pattern cap.
|
||||||
|
_MAX_SENSITIVE_WORD_PATTERNS = 256
|
||||||
|
|
||||||
|
|
||||||
@filter_model.filter_class('ban-word-filter')
|
@filter_model.filter_class('ban-word-filter')
|
||||||
class BanWordFilter(filter_model.ContentFilter):
|
class BanWordFilter(filter_model.ContentFilter):
|
||||||
@@ -14,12 +19,17 @@ class BanWordFilter(filter_model.ContentFilter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
||||||
|
words = self.ap.sensitive_meta.data.get('words') or []
|
||||||
|
mask = self.ap.sensitive_meta.data['mask']
|
||||||
|
mask_word = self.ap.sensitive_meta.data['mask_word']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
found, message = await mask_patterns(
|
found, current = await mask_patterns(
|
||||||
self.ap.sensitive_meta.data['words'],
|
words,
|
||||||
message,
|
message,
|
||||||
mask=self.ap.sensitive_meta.data['mask'],
|
mask=mask,
|
||||||
mask_word=self.ap.sensitive_meta.data['mask_word'],
|
mask_word=mask_word,
|
||||||
|
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
|
||||||
)
|
)
|
||||||
except SafeRegexError as exc:
|
except SafeRegexError as exc:
|
||||||
return entities.FilterResult(
|
return entities.FilterResult(
|
||||||
@@ -31,7 +41,7 @@ class BanWordFilter(filter_model.ContentFilter):
|
|||||||
|
|
||||||
return entities.FilterResult(
|
return entities.FilterResult(
|
||||||
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
||||||
replacement=message,
|
replacement=current,
|
||||||
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
||||||
console_notice='',
|
console_notice='',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -158,6 +158,18 @@ class ResponseWrapper(stage.PipelineStage):
|
|||||||
result_type=entities.ResultType.CONTINUE,
|
result_type=entities.ResultType.CONTINUE,
|
||||||
new_query=query,
|
new_query=query,
|
||||||
)
|
)
|
||||||
|
elif (
|
||||||
|
isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls
|
||||||
|
):
|
||||||
|
# Final streaming chunk with no text content but
|
||||||
|
# possibly carrying sandbox outbox attachments.
|
||||||
|
reply_chain = platform_message.MessageChain([])
|
||||||
|
await self._append_outbound_attachments(query, reply_chain)
|
||||||
|
query.resp_message_chain.append(reply_chain)
|
||||||
|
yield entities.StageProcessResult(
|
||||||
|
result_type=entities.ResultType.CONTINUE,
|
||||||
|
new_query=query,
|
||||||
|
)
|
||||||
|
|
||||||
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
||||||
function_names = [tc.function.name for tc in result.tool_calls]
|
function_names = [tc.function.name for tc in result.tool_calls]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from linebot.v3.webhooks import (
|
|||||||
ImageMessageContent,
|
ImageMessageContent,
|
||||||
VideoMessageContent,
|
VideoMessageContent,
|
||||||
AudioMessageContent,
|
AudioMessageContent,
|
||||||
|
UserMentionee,
|
||||||
)
|
)
|
||||||
|
|
||||||
# from linebot import WebhookParser
|
# from linebot import WebhookParser
|
||||||
@@ -58,15 +59,19 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
|
|
||||||
return content_list
|
return content_list
|
||||||
|
|
||||||
@staticmethod
|
def __init__(self, bot_account_id: str = ''):
|
||||||
async def target2yiri(message, bot_client) -> platform_message.MessageChain:
|
self.bot_account_id = bot_account_id
|
||||||
|
|
||||||
|
async def target2yiri(self, message, bot_client) -> platform_message.MessageChain:
|
||||||
lb_msg_list = []
|
lb_msg_list = []
|
||||||
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
|
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
|
||||||
|
|
||||||
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
|
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
|
||||||
|
|
||||||
if isinstance(message.message, TextMessageContent):
|
if isinstance(message.message, TextMessageContent):
|
||||||
lb_msg_list.append(platform_message.Plain(text=message.message.text))
|
lb_msg_list.extend(
|
||||||
|
self._build_text_components(message.message.text, getattr(message.message, 'mention', None))
|
||||||
|
)
|
||||||
elif isinstance(message.message, AudioMessageContent):
|
elif isinstance(message.message, AudioMessageContent):
|
||||||
pass
|
pass
|
||||||
elif isinstance(message.message, VideoMessageContent):
|
elif isinstance(message.message, VideoMessageContent):
|
||||||
@@ -86,22 +91,60 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
lb_msg_list.append(platform_message.Image(base64=data_uri))
|
lb_msg_list.append(platform_message.Image(base64=data_uri))
|
||||||
return platform_message.MessageChain(lb_msg_list)
|
return platform_message.MessageChain(lb_msg_list)
|
||||||
|
|
||||||
|
def _build_text_components(self, text: str, mention) -> list:
|
||||||
|
"""Build message components from text, inserting At components for mentions.
|
||||||
|
|
||||||
|
LINE provides mention positions (index/length) and is_self per mentionee in the
|
||||||
|
webhook payload. Mapping the bot mention to At(target=bot_account_id) makes the
|
||||||
|
'at-bot' group respond rule work for LINE, consistent with other adapters.
|
||||||
|
"""
|
||||||
|
components: list = []
|
||||||
|
if not mention or not mention.mentionees:
|
||||||
|
if text:
|
||||||
|
components.append(platform_message.Plain(text=text))
|
||||||
|
return components
|
||||||
|
segments: list[tuple[int, int, object]] = sorted((m.index, m.index + m.length, m) for m in mention.mentionees)
|
||||||
|
cursor = 0
|
||||||
|
for start, end, mentionee in segments:
|
||||||
|
if start < cursor:
|
||||||
|
start, end = cursor, min(end, len(text))
|
||||||
|
if start < cursor or end <= start or end > len(text):
|
||||||
|
continue
|
||||||
|
if start > cursor:
|
||||||
|
components.append(platform_message.Plain(text=text[cursor:start]))
|
||||||
|
if isinstance(mentionee, UserMentionee):
|
||||||
|
target = self.bot_account_id if mentionee.is_self else mentionee.user_id
|
||||||
|
if not target:
|
||||||
|
target = text[start:end]
|
||||||
|
else:
|
||||||
|
target = text[start:end]
|
||||||
|
# At.__str__ already prepends '@', so strip one from the LINE text token.
|
||||||
|
display = text[start:end].lstrip('@')
|
||||||
|
components.append(platform_message.At(target=str(target), display=display))
|
||||||
|
cursor = end
|
||||||
|
if cursor < len(text):
|
||||||
|
components.append(platform_message.Plain(text=text[cursor:]))
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
|
def __init__(self, bot_account_id: str = ''):
|
||||||
|
self.bot_account_id = bot_account_id
|
||||||
|
self.message_converter = LINEMessageConverter(bot_account_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(
|
async def yiri2target(
|
||||||
event: platform_events.MessageEvent,
|
event: platform_events.MessageEvent,
|
||||||
) -> MessageEvent:
|
) -> MessageEvent:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@staticmethod
|
async def target2yiri(self, event, bot_client) -> platform_events.Event:
|
||||||
async def target2yiri(event, bot_client) -> platform_events.Event:
|
message_chain = await self.message_converter.target2yiri(event, bot_client)
|
||||||
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
|
|
||||||
|
|
||||||
if event.source.type == 'user':
|
if event.source.type == 'user':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
sender=platform_entities.Friend(
|
sender=platform_entities.Friend(
|
||||||
id=event.message.id,
|
id=event.source.user_id,
|
||||||
nickname=event.source.user_id,
|
nickname=event.source.user_id,
|
||||||
remark='',
|
remark='',
|
||||||
),
|
),
|
||||||
@@ -110,13 +153,19 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# 'group' and 'room' sources carry the stable chat id under different
|
||||||
|
# field names; user_id may be absent for some members, so fall back
|
||||||
|
# to the group/room id rather than the per-message id.
|
||||||
|
group_id = event.source.group_id if event.source.type == 'group' else event.source.room_id
|
||||||
|
member_id = event.source.user_id or group_id
|
||||||
|
|
||||||
return platform_events.GroupMessage(
|
return platform_events.GroupMessage(
|
||||||
sender=platform_entities.GroupMember(
|
sender=platform_entities.GroupMember(
|
||||||
id=event.event.sender.sender_id.open_id,
|
id=member_id,
|
||||||
member_name=event.event.sender.sender_id.union_id,
|
member_name=member_id,
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
group=platform_entities.Group(
|
group=platform_entities.Group(
|
||||||
id=event.message.id,
|
id=group_id,
|
||||||
name='',
|
name='',
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
),
|
),
|
||||||
@@ -163,8 +212,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
listeners={},
|
listeners={},
|
||||||
card_id_dict={},
|
card_id_dict={},
|
||||||
seq=1,
|
seq=1,
|
||||||
event_converter=LINEEventConverter(),
|
event_converter=LINEEventConverter(bot_account_id),
|
||||||
message_converter=LINEMessageConverter(),
|
message_converter=LINEMessageConverter(bot_account_id),
|
||||||
line_webhook=line_webhook,
|
line_webhook=line_webhook,
|
||||||
parser=parser,
|
parser=parser,
|
||||||
configuration=configuration,
|
configuration=configuration,
|
||||||
|
|||||||
@@ -329,17 +329,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
content_type = content.get('type', 'text')
|
content_type = content.get('type', 'text')
|
||||||
|
|
||||||
if content_type == 'text':
|
if content_type == 'text':
|
||||||
if target_type == 'c2c':
|
if target_type in {'c2c', 'group'}:
|
||||||
await self.bot.send_private_text_msg(
|
await self._send_c2c_or_group_text_reply(
|
||||||
|
target_type,
|
||||||
target_id,
|
target_id,
|
||||||
content['content'],
|
content['content'],
|
||||||
qq_official_event.d_id,
|
msg_id=qq_official_event.d_id,
|
||||||
)
|
|
||||||
elif target_type == 'group':
|
|
||||||
await self.bot.send_group_text_msg(
|
|
||||||
target_id,
|
|
||||||
content['content'],
|
|
||||||
qq_official_event.d_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
elif content_type == 'image':
|
elif content_type == 'image':
|
||||||
@@ -383,6 +378,39 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _send_c2c_or_group_text_reply(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
msg_id: typing.Optional[str] = None,
|
||||||
|
event_id: typing.Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a text reply using the configured C2C/group render mode."""
|
||||||
|
use_markdown = self.config.get('enable-markdown-rendering', False)
|
||||||
|
if target_type == 'c2c':
|
||||||
|
send = self.bot.send_private_markdown_msg if use_markdown else self.bot.send_private_text_msg
|
||||||
|
await send(
|
||||||
|
user_openid=target_id,
|
||||||
|
content=content,
|
||||||
|
msg_id=msg_id,
|
||||||
|
event_id=event_id,
|
||||||
|
msg_seq=msg_seq,
|
||||||
|
)
|
||||||
|
elif target_type == 'group':
|
||||||
|
send = self.bot.send_group_markdown_msg if use_markdown else self.bot.send_group_text_msg
|
||||||
|
await send(
|
||||||
|
group_openid=target_id,
|
||||||
|
content=content,
|
||||||
|
msg_id=msg_id,
|
||||||
|
event_id=event_id,
|
||||||
|
msg_seq=msg_seq,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported QQ Official text reply target: {target_type}')
|
||||||
|
|
||||||
def register_listener(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
event_type: typing.Type[platform_events.Event],
|
event_type: typing.Type[platform_events.Event],
|
||||||
@@ -650,13 +678,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
||||||
ctx['session_started'] = True
|
ctx['session_started'] = True
|
||||||
|
|
||||||
# 发送内容 = 全量累积文本
|
# `replace` mode requires every update to contain the previously
|
||||||
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
|
# delivered content as its prefix. `sent_length` only tells us whether
|
||||||
# - 首次:发送全部文本,建立会话
|
# a non-final snapshot has new content; it must not truncate the
|
||||||
# - 后续:只能发送新增部分(append 行为)
|
# content sent to QQ.
|
||||||
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
if len(ctx['accumulated_text']) <= ctx['sent_length'] and not is_final:
|
||||||
if not content_to_send and not is_final:
|
|
||||||
return
|
return
|
||||||
|
content_to_send = ctx['accumulated_text']
|
||||||
|
|
||||||
input_state = 10 if is_final else 1
|
input_state = 10 if is_final else 1
|
||||||
|
|
||||||
@@ -778,20 +806,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if target_type == 'c2c':
|
await self._send_c2c_or_group_text_reply(
|
||||||
await self.bot.send_private_text_msg(
|
target_type,
|
||||||
user_openid=target_id,
|
target_id,
|
||||||
content=text,
|
text,
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
msg_seq=msg_seq,
|
msg_seq=msg_seq,
|
||||||
)
|
)
|
||||||
elif target_type == 'group':
|
|
||||||
await self.bot.send_group_text_msg(
|
|
||||||
group_openid=target_id,
|
|
||||||
content=text,
|
|
||||||
event_id=event_id,
|
|
||||||
msg_seq=msg_seq,
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
|
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,18 @@ spec:
|
|||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
|
- name: enable-markdown-rendering
|
||||||
|
label:
|
||||||
|
en_US: Enable Markdown Rendering
|
||||||
|
zh_Hans: 启用 Markdown 渲染
|
||||||
|
zh_Hant: 啟用 Markdown 渲染
|
||||||
|
description:
|
||||||
|
en_US: Render non-stream C2C and QQ group text replies as Markdown. Channel messages always use plain text and are not affected by this setting.
|
||||||
|
zh_Hans: 将非流式 C2C 私聊和 QQ 群聊文本回复渲染为 Markdown。频道消息始终以纯文本发送,不受此设置影响。
|
||||||
|
zh_Hant: 將非串流 C2C 私聊與 QQ 群聊文字回覆渲染為 Markdown。頻道訊息一律以純文字傳送,不受此設定影響。
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
en_US: Webhook Callback URL
|
en_US: Webhook Callback URL
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import typing
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
import base64
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
@@ -24,11 +26,24 @@ from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
|
|||||||
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(message_chain: platform_message.MessageChain):
|
async def yiri2target(message_chain: platform_message.MessageChain):
|
||||||
content = ''
|
"""Convert a MessageChain into a list of component dicts.
|
||||||
|
|
||||||
|
Each dict has a ``type`` key (``'text'``, ``'image'``,
|
||||||
|
``'voice'``, ``'file'``). Text items carry ``text``; media
|
||||||
|
items carry ``base64`` (may include a ``data:...;base64,``
|
||||||
|
prefix) and optionally ``name``.
|
||||||
|
"""
|
||||||
|
items: list[dict] = []
|
||||||
for msg in message_chain:
|
for msg in message_chain:
|
||||||
if type(msg) is platform_message.Plain:
|
if type(msg) is platform_message.Plain:
|
||||||
content += msg.text
|
items.append({'type': 'text', 'text': msg.text})
|
||||||
return content
|
elif type(msg) is platform_message.Image:
|
||||||
|
items.append({'type': 'image', 'base64': msg.base64 or ''})
|
||||||
|
elif type(msg) is platform_message.Voice:
|
||||||
|
items.append({'type': 'voice', 'base64': msg.base64 or ''})
|
||||||
|
elif type(msg) is platform_message.File:
|
||||||
|
items.append({'type': 'file', 'base64': msg.base64 or '', 'name': msg.name or ''})
|
||||||
|
return items
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
|
async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
|
||||||
@@ -362,13 +377,76 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _join_text_components(items: list[dict]) -> str:
|
||||||
|
"""Concatenate ``text`` items in order, leaving media items alone."""
|
||||||
|
return ''.join(item['text'] for item in items if item.get('type') == 'text')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _iter_media_components(items: list[dict]):
|
||||||
|
"""Yield non-text items in order."""
|
||||||
|
for item in items:
|
||||||
|
if item.get('type') in {'image', 'voice', 'file'}:
|
||||||
|
yield item
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _send_media(
|
||||||
|
bot,
|
||||||
|
req_id: str,
|
||||||
|
item: dict,
|
||||||
|
) -> bool:
|
||||||
|
"""Upload *item* to the WeCom AI Bot CDN and send it as a media reply.
|
||||||
|
|
||||||
|
Returns True on success. Falls back to a no-op (with a warning log)
|
||||||
|
if the SDK does not yet implement ``upload_media`` /
|
||||||
|
``reply_image`` / ``reply_file`` / ``reply_voice`` — the framework
|
||||||
|
will keep working, just without image delivery.
|
||||||
|
"""
|
||||||
|
kind = item.get('type')
|
||||||
|
upload = getattr(bot, 'upload_media', None)
|
||||||
|
if upload is None:
|
||||||
|
return False
|
||||||
|
b64_text = item.get('base64') or ''
|
||||||
|
if not b64_text:
|
||||||
|
return False
|
||||||
|
if b64_text.startswith('data:') and ',' in b64_text:
|
||||||
|
b64_text = b64_text.split(',', 1)[1]
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(b64_text, validate=False)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if not data:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
upload_result = await upload(data, item.get('name') or f'attachment.{kind}', media_type=kind)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
media_id = getattr(upload_result, 'media_id', None) or (
|
||||||
|
isinstance(upload_result, dict) and upload_result.get('media_id')
|
||||||
|
)
|
||||||
|
if not media_id:
|
||||||
|
return False
|
||||||
|
reply_fn = {
|
||||||
|
'image': getattr(bot, 'reply_image', None),
|
||||||
|
'file': getattr(bot, 'reply_file', None),
|
||||||
|
'voice': getattr(bot, 'reply_voice', None),
|
||||||
|
}.get(kind)
|
||||||
|
if reply_fn is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await reply_fn(req_id, media_id)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
message: platform_message.MessageChain,
|
message: platform_message.MessageChain,
|
||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
):
|
):
|
||||||
content = await self.message_converter.yiri2target(message)
|
items = await self.message_converter.yiri2target(message)
|
||||||
|
text = self._join_text_components(items)
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
|
||||||
event = message_source.source_platform_object
|
event = message_source.source_platform_object
|
||||||
@@ -382,7 +460,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
else:
|
else:
|
||||||
chat_id = str(message_source.sender.id)
|
chat_id = str(message_source.sender.id)
|
||||||
try:
|
try:
|
||||||
await self.bot.send_message(chat_id, content)
|
await self.bot.send_message(chat_id, text)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(
|
await self.logger.error(
|
||||||
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
|
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
|
||||||
@@ -396,12 +474,15 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
||||||
if req_id:
|
if text:
|
||||||
await self.bot.reply_text(req_id, content)
|
if req_id:
|
||||||
else:
|
await self.bot.reply_text(req_id, text)
|
||||||
await self.bot.set_message(event.message_id, content)
|
else:
|
||||||
|
await self.bot.set_message(event.message_id, text)
|
||||||
|
for item in self._iter_media_components(items):
|
||||||
|
await self._send_media(self.bot, req_id, item)
|
||||||
else:
|
else:
|
||||||
await self.bot.set_message(event.message_id, content)
|
await self.bot.set_message(event.message_id, text)
|
||||||
|
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
@@ -411,7 +492,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
is_final: bool = False,
|
is_final: bool = False,
|
||||||
):
|
):
|
||||||
content = await self.message_converter.yiri2target(message)
|
items = await self.message_converter.yiri2target(message)
|
||||||
|
text = self._join_text_components(items)
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
|
||||||
# Synthetic events (e.g. button-click triggered form resume) have
|
# Synthetic events (e.g. button-click triggered form resume) have
|
||||||
@@ -420,7 +502,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
# of the stream/reply path.
|
# of the stream/reply path.
|
||||||
spo = message_source.source_platform_object
|
spo = message_source.source_platform_object
|
||||||
if spo is None:
|
if spo is None:
|
||||||
return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode)
|
return await self._handle_synthetic_chunk(message_source, bot_message, text, is_final, _ws_mode)
|
||||||
|
|
||||||
msg_id = spo.message_id
|
msg_id = spo.message_id
|
||||||
|
|
||||||
@@ -452,7 +534,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
form_data.get('actions', []) or [],
|
form_data.get('actions', []) or [],
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
fallback = content or '(人工输入)'
|
fallback = text or '(人工输入)'
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
event = message_source.source_platform_object
|
event = message_source.source_platform_object
|
||||||
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
||||||
@@ -463,17 +545,22 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
return {'stream': False, 'form': True, 'fallback': True}
|
return {'stream': False, 'form': True, 'fallback': True}
|
||||||
|
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
|
||||||
if not success and is_final:
|
if not success and is_final:
|
||||||
event = message_source.source_platform_object
|
event = message_source.source_platform_object
|
||||||
req_id = event.get('req_id', '')
|
req_id = event.get('req_id', '')
|
||||||
if req_id:
|
if req_id:
|
||||||
await self.bot.reply_text(req_id, content)
|
await self.bot.reply_text(req_id, text)
|
||||||
|
if is_final:
|
||||||
|
event = message_source.source_platform_object
|
||||||
|
req_id = event.get('req_id', '')
|
||||||
|
for item in self._iter_media_components(items):
|
||||||
|
await self._send_media(self.bot, req_id, item)
|
||||||
return {'stream': success}
|
return {'stream': success}
|
||||||
else:
|
else:
|
||||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
|
||||||
if not success and is_final:
|
if not success and is_final:
|
||||||
await self.bot.set_message(msg_id, content)
|
await self.bot.set_message(msg_id, text)
|
||||||
return {'stream': success}
|
return {'stream': success}
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
@@ -627,8 +714,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
async def send_message(self, target_type, target_id, message):
|
async def send_message(self, target_type, target_id, message):
|
||||||
_ws_mode = not self.config.get('enable-webhook', False)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
if _ws_mode:
|
if _ws_mode:
|
||||||
content = await self.message_converter.yiri2target(message)
|
items = await self.message_converter.yiri2target(message)
|
||||||
await self.bot.send_message(target_id, content)
|
text = self._join_text_components(items)
|
||||||
|
await self.bot.send_message(target_id, text)
|
||||||
else:
|
else:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
if event.type == 'text':
|
if event.type == 'text':
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'{event.receiver_id}|u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
@@ -117,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
)
|
)
|
||||||
elif event.type == 'image':
|
elif event.type == 'image':
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'{event.receiver_id}|u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
@@ -197,7 +197,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
msgid = f'langbot_{uuid.uuid4().hex}'
|
msgid = f'{uuid.uuid4().hex}'
|
||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_text_msg(
|
await self.bot.send_text_msg(
|
||||||
open_kfid=open_kfid,
|
open_kfid=open_kfid,
|
||||||
@@ -205,6 +205,13 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
msgid=msgid,
|
msgid=msgid,
|
||||||
content=content['content'],
|
content=content['content'],
|
||||||
)
|
)
|
||||||
|
elif content['type'] == 'image':
|
||||||
|
await self.bot.send_image_msg(
|
||||||
|
open_kfid=open_kfid,
|
||||||
|
external_userid=external_userid,
|
||||||
|
msgid=msgid,
|
||||||
|
media_id=content['media_id'],
|
||||||
|
)
|
||||||
|
|
||||||
def set_bot_uuid(self, bot_uuid: str):
|
def set_bot_uuid(self, bot_uuid: str):
|
||||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||||
|
|||||||
@@ -701,7 +701,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
}
|
}
|
||||||
self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
|
self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
|
||||||
|
|
||||||
result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values()))
|
reconcile_timeout_seconds = max(
|
||||||
|
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
|
||||||
|
)
|
||||||
|
result = await runtime_handler.reconcile_plugin_installations(
|
||||||
|
tuple(self._known_desired_states.values()),
|
||||||
|
timeout=reconcile_timeout_seconds,
|
||||||
|
)
|
||||||
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
|
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
|
||||||
self._record_reconcile_failures(self._known_desired_states, result)
|
self._record_reconcile_failures(self._known_desired_states, result)
|
||||||
|
|
||||||
@@ -736,7 +742,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
if state.binding.installation_uuid in all_states:
|
if state.binding.installation_uuid in all_states:
|
||||||
raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
|
raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
|
||||||
all_states[state.binding.installation_uuid] = state
|
all_states[state.binding.installation_uuid] = state
|
||||||
result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values()))
|
reconcile_timeout_seconds = max(
|
||||||
|
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
|
||||||
|
)
|
||||||
|
result = await runtime_handler.reconcile_plugin_installations(
|
||||||
|
tuple(all_states.values()),
|
||||||
|
timeout=reconcile_timeout_seconds,
|
||||||
|
)
|
||||||
await self._repair_reconcile_missing_artifacts(all_states, result)
|
await self._repair_reconcile_missing_artifacts(all_states, result)
|
||||||
self._record_reconcile_failures(all_states, result)
|
self._record_reconcile_failures(all_states, result)
|
||||||
for installation_uuid, previous in tuple(self._known_desired_states.items()):
|
for installation_uuid, previous in tuple(self._known_desired_states.items()):
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import traceback
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
import sqlalchemy.dialects.postgresql
|
||||||
|
import sqlalchemy.dialects.sqlite
|
||||||
|
|
||||||
from langbot_plugin.runtime.io import handler
|
from langbot_plugin.runtime.io import handler
|
||||||
from langbot_plugin.runtime.io.connection import Connection
|
from langbot_plugin.runtime.io.connection import Connection
|
||||||
@@ -431,6 +433,19 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
return f'{identity.plugin_author}/{identity.plugin_name}'
|
return f'{identity.plugin_author}/{identity.plugin_name}'
|
||||||
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
|
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _legacy_binary_storage_key(
|
||||||
|
action_context: ActionContext,
|
||||||
|
*,
|
||||||
|
owner_type: str,
|
||||||
|
owner: str,
|
||||||
|
key: str,
|
||||||
|
) -> str:
|
||||||
|
"""Return the pre-tenancy key shape for a row already scoped to this Workspace."""
|
||||||
|
|
||||||
|
legacy_owner = action_context.workspace_uuid if owner_type == 'workspace' else owner
|
||||||
|
return f'{owner_type}:{legacy_owner}:{key}'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _binary_storage_key(
|
def _binary_storage_key(
|
||||||
cls,
|
cls,
|
||||||
@@ -896,25 +911,82 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
)
|
)
|
||||||
|
storage = result.first()
|
||||||
|
if storage is None:
|
||||||
|
legacy_key = self._legacy_binary_storage_key(
|
||||||
|
action_context,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner=owner,
|
||||||
|
key=key,
|
||||||
|
)
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
|
)
|
||||||
|
storage = result.first()
|
||||||
|
if storage is not None:
|
||||||
|
update_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
|
.values(unique_key=unique_key, value=value)
|
||||||
|
)
|
||||||
|
if update_result.rowcount:
|
||||||
|
return handler.ActionResponse.success(data={})
|
||||||
|
canonical_update = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
|
.values(value=value)
|
||||||
|
)
|
||||||
|
if canonical_update.rowcount:
|
||||||
|
return handler.ActionResponse.success(data={})
|
||||||
|
storage = None
|
||||||
|
|
||||||
if result.first() is not None:
|
if storage is not None:
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
.values(value=value)
|
.values(value=value)
|
||||||
)
|
)
|
||||||
else:
|
return handler.ActionResponse.success(data={})
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(
|
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
|
||||||
workspace_uuid=action_context.workspace_uuid,
|
insert = {
|
||||||
unique_key=unique_key,
|
'postgresql': sqlalchemy.dialects.postgresql.insert,
|
||||||
key=key,
|
'sqlite': sqlalchemy.dialects.sqlite.insert,
|
||||||
owner_type=owner_type,
|
}.get(dialect_name)
|
||||||
owner=owner,
|
if insert is None:
|
||||||
value=value,
|
return handler.ActionResponse.error(message=f'Unsupported storage database dialect: {dialect_name}')
|
||||||
)
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
insert(persistence_bstorage.BinaryStorage)
|
||||||
|
.values(
|
||||||
|
workspace_uuid=action_context.workspace_uuid,
|
||||||
|
unique_key=unique_key,
|
||||||
|
key=key,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner=owner,
|
||||||
|
value=value,
|
||||||
)
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=['workspace_uuid', 'unique_key'],
|
||||||
|
set_={'value': value},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
data={},
|
data={},
|
||||||
@@ -946,6 +1018,29 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
storage = result.first()
|
storage = result.first()
|
||||||
|
if storage is None:
|
||||||
|
legacy_key = self._legacy_binary_storage_key(
|
||||||
|
action_context,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner=owner,
|
||||||
|
key=key,
|
||||||
|
)
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
|
)
|
||||||
|
storage = result.first()
|
||||||
|
if storage is None:
|
||||||
|
retry_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||||
|
)
|
||||||
|
storage = retry_result.first()
|
||||||
if storage is None:
|
if storage is None:
|
||||||
return handler.ActionResponse.error(
|
return handler.ActionResponse.error(
|
||||||
message=f'Storage with key {key} not found',
|
message=f'Storage with key {key} not found',
|
||||||
@@ -981,10 +1076,19 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
message=str(e),
|
message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
legacy_key = self._legacy_binary_storage_key(
|
||||||
|
action_context,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner=owner,
|
||||||
|
key=key,
|
||||||
|
)
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
|
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
|
||||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
.where(persistence_bstorage.BinaryStorage.unique_key.in_((unique_key, legacy_key)))
|
||||||
|
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||||
|
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||||
)
|
)
|
||||||
|
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
@@ -1012,7 +1116,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
|
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
data={
|
data={
|
||||||
'keys': result.scalars().all(),
|
'keys': list(dict.fromkeys(result.scalars().all())),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1573,13 +1677,15 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
async def reconcile_plugin_installations(
|
async def reconcile_plugin_installations(
|
||||||
self,
|
self,
|
||||||
installations: tuple[PluginInstallationDesiredState, ...],
|
installations: tuple[PluginInstallationDesiredState, ...],
|
||||||
|
*,
|
||||||
|
timeout: float = 300,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
request = ReconcilePluginInstallationsRequest(installations=installations)
|
request = ReconcilePluginInstallationsRequest(installations=installations)
|
||||||
with self.installation_scope(None):
|
with self.installation_scope(None):
|
||||||
return await self.call_action(
|
return await self.call_action(
|
||||||
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
|
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
|
||||||
request.model_dump(),
|
request.model_dump(),
|
||||||
timeout=300,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def apply_plugin_installation(
|
async def apply_plugin_installation(
|
||||||
|
|||||||
@@ -747,9 +747,24 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
converted_parts = []
|
converted_parts = []
|
||||||
for part in content:
|
for part in content:
|
||||||
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
||||||
part['image_url'] = {'url': part['image_base64']}
|
# History trimming (SessionManager) clears image_base64
|
||||||
part['type'] = 'image_url'
|
# on past turns and exclude_none serialization drops
|
||||||
del part['image_base64']
|
# the key entirely, so the replayed part may carry no
|
||||||
|
# payload. Prefer the base64 payload; fall back to an
|
||||||
|
# image_url that survived on the same element; drop
|
||||||
|
# hollow parts instead of raising KeyError (#2469).
|
||||||
|
image_b64 = part.get('image_base64')
|
||||||
|
fallback_url = None
|
||||||
|
if not image_b64:
|
||||||
|
raw_image_url = part.get('image_url')
|
||||||
|
if isinstance(raw_image_url, dict):
|
||||||
|
fallback_url = raw_image_url.get('url')
|
||||||
|
if image_b64 or fallback_url:
|
||||||
|
part['image_url'] = {'url': image_b64 or fallback_url}
|
||||||
|
part['type'] = 'image_url'
|
||||||
|
part.pop('image_base64', None)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
# OpenAI-compatible chat models reject non-image file parts
|
# OpenAI-compatible chat models reject non-image file parts
|
||||||
# (audio/document base64 or url). These originate from Voice /
|
# (audio/document base64 or url). These originate from Voice /
|
||||||
# File attachments — including ones replayed from conversation
|
# File attachments — including ones replayed from conversation
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
|
|||||||
try:
|
try:
|
||||||
import pyseekdb
|
import pyseekdb
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
|
raise ImportError(
|
||||||
|
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
|
||||||
|
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
|
||||||
|
)
|
||||||
|
|
||||||
self._embedding_function = pyseekdb.get_default_embedding_function()
|
self._embedding_function = pyseekdb.get_default_embedding_function()
|
||||||
|
|
||||||
|
|||||||
@@ -619,7 +619,9 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
and len(func_ret) > 0
|
and len(func_ret) > 0
|
||||||
and isinstance(func_ret[0], provider_message.ContentElement)
|
and isinstance(func_ret[0], provider_message.ContentElement)
|
||||||
):
|
):
|
||||||
tool_content = func_ret
|
# OpenAI-compatible APIs require tool-message content to be a
|
||||||
|
# string; a raw list of ContentElement causes HTTP 500 (#2457).
|
||||||
|
tool_content = '\n'.join(str(ce) for ce in func_ret)
|
||||||
else:
|
else:
|
||||||
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,16 @@ class SafeRegexTimeoutError(SafeRegexError):
|
|||||||
"""Raised when the regex engine exhausts the operation CPU budget."""
|
"""Raised when the regex engine exhausts the operation CPU budget."""
|
||||||
|
|
||||||
|
|
||||||
def _validate_patterns(patterns: Sequence[str]) -> tuple[str, ...]:
|
def _validate_patterns(
|
||||||
|
patterns: Sequence[str],
|
||||||
|
*,
|
||||||
|
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if max_pattern_count < 1:
|
||||||
|
raise ValueError('max_pattern_count must be positive')
|
||||||
|
if len(patterns) > max_pattern_count:
|
||||||
|
raise SafeRegexLimitError(f'At most {max_pattern_count} regex patterns are allowed')
|
||||||
normalized = tuple(patterns)
|
normalized = tuple(patterns)
|
||||||
if len(normalized) > MAX_PATTERN_COUNT:
|
|
||||||
raise SafeRegexLimitError(f'At most {MAX_PATTERN_COUNT} regex patterns are allowed')
|
|
||||||
for pattern in normalized:
|
for pattern in normalized:
|
||||||
if not isinstance(pattern, str):
|
if not isinstance(pattern, str):
|
||||||
raise SafeRegexError('Regex patterns must be strings')
|
raise SafeRegexError('Regex patterns must be strings')
|
||||||
@@ -115,8 +121,9 @@ def _mask_patterns_sync(
|
|||||||
mask: str,
|
mask: str,
|
||||||
mask_word: str,
|
mask_word: str,
|
||||||
timeout_seconds: float,
|
timeout_seconds: float,
|
||||||
|
max_pattern_count: int,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
normalized_patterns = _validate_patterns(patterns)
|
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
|
||||||
_validate_input(value)
|
_validate_input(value)
|
||||||
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
||||||
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
||||||
@@ -162,6 +169,7 @@ async def mask_patterns(
|
|||||||
mask: str,
|
mask: str,
|
||||||
mask_word: str,
|
mask_word: str,
|
||||||
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
||||||
|
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
||||||
|
|
||||||
@@ -174,4 +182,5 @@ async def mask_patterns(
|
|||||||
mask=mask,
|
mask=mask,
|
||||||
mask_word=mask_word,
|
mask_word=mask_word,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
|
max_pattern_count=max_pattern_count,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
|
|
||||||
def __init__(self, ap: app.Application):
|
def __init__(self, ap: app.Application):
|
||||||
if not SEEKDB_AVAILABLE:
|
if not SEEKDB_AVAILABLE:
|
||||||
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
|
raise ImportError(
|
||||||
|
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
|
||||||
|
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
|
||||||
|
)
|
||||||
|
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
config = self.ap.instance_config.data['vdb']['seekdb']
|
config = self.ap.instance_config.data['vdb']['seekdb']
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ class InvitationDeliveryService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _plain_text(workspace_name: str, invitation_link: str) -> str:
|
def _plain_text(workspace_name: str, invitation_link: str) -> str:
|
||||||
return (
|
return (
|
||||||
'You have been invited to LangBot Cloud\n\n'
|
'You have been invited to join a Workspace in LangBot\n\n'
|
||||||
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
|
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
|
||||||
f'Accept invitation: {invitation_link}\n\n'
|
f'Accept invitation: {invitation_link}\n\n'
|
||||||
'This secure invitation expires in 7 days and can only be accepted by the email address '
|
'This secure invitation expires in 7 days and can only be accepted by the email address '
|
||||||
@@ -258,30 +258,77 @@ class InvitationDeliveryService:
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Join {escaped_workspace} on LangBot Cloud</title>
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Join {escaped_workspace} in LangBot</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
<body style="margin:0;padding:0;background:#f4f7fb;color:#111827;font-family:Arial,'Helvetica Neue',sans-serif;">
|
||||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} in LangBot.</div>
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f4f7fb;">
|
||||||
<tr><td align="center">
|
<tr>
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e5eaf2;border-radius:16px;overflow:hidden;box-shadow:0 12px 32px rgba(20,49,93,.08);">
|
<td align="center" style="padding:48px 16px;">
|
||||||
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
|
||||||
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
|
<tr>
|
||||||
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">You’re invited</div>
|
<td style="padding:0 4px 20px;">
|
||||||
</td></tr>
|
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
|
||||||
<tr><td style="padding:36px;">
|
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
|
||||||
<p style="margin:0 0 18px;font-size:16px;line-height:1.65;color:#475569;">You have been invited to collaborate in this Workspace:</p>
|
</td>
|
||||||
<div style="margin:0 0 26px;padding:18px 20px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;font-size:18px;font-weight:700;color:#0f172a;">{escaped_workspace}</div>
|
</tr>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
|
<tr>
|
||||||
<a href="{escaped_link}" style="display:inline-block;padding:13px 22px;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Accept invitation</a>
|
<td style="background:#ffffff;border-radius:10px;overflow:hidden;">
|
||||||
</td></tr></table>
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
<p style="margin:26px 0 8px;font-size:14px;line-height:1.6;color:#64748b;">This invitation expires in 7 days and is bound to the email address that received it.</p>
|
<tr>
|
||||||
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#94a3b8;">If the button does not work, copy and paste this URL into your browser:</p>
|
<td style="padding:42px 42px 38px;">
|
||||||
<p style="margin:0;padding:12px;background:#f8fafc;border-radius:8px;word-break:break-all;font-size:12px;line-height:1.55;color:#475569;">{escaped_link}</p>
|
<div style="margin:0 0 12px;font-size:13px;line-height:1.4;font-weight:600;color:#5f6f84;">Workspace invitation</div>
|
||||||
</td></tr>
|
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.25;font-weight:700;letter-spacing:-.025em;color:#111827;">You’re invited to collaborate</h1>
|
||||||
<tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
|
<p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#526173;">Join your team in LangBot and start building together in this Workspace.</p>
|
||||||
</table>
|
|
||||||
</td></tr>
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#f6f8fb;border-radius:8px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 18px;">
|
||||||
|
<div style="margin:0 0 4px;font-size:11px;line-height:1.4;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#5f6f84;">Workspace</div>
|
||||||
|
<div style="font-size:18px;line-height:1.4;font-weight:700;color:#111827;">{escaped_workspace}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr><td height="28" style="height:28px;font-size:0;line-height:0;"> </td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="background:#2563eb;border-radius:8px;">
|
||||||
|
<a href="{escaped_link}" target="_blank" style="display:inline-block;padding:13px 22px;font-size:15px;line-height:1.2;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">Accept invitation</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr><td height="32" style="height:32px;font-size:0;line-height:0;"> </td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border-top:1px solid #e8edf4;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top:22px;">
|
||||||
|
<p style="margin:0 0 10px;font-size:13px;line-height:1.6;color:#5f6f84;">For your security, this invitation expires in 7 days and only works for the email address that received it.</p>
|
||||||
|
<a href="{escaped_link}" target="_blank" style="font-size:13px;line-height:1.6;font-weight:600;color:#2563eb;text-decoration:none;">Open invitation link →</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:20px 24px 0;font-size:12px;line-height:1.6;color:#5f6f84;">
|
||||||
|
Sent by LangBot<br>
|
||||||
|
If you were not expecting this invitation, you can safely ignore this email.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>'''
|
</html>'''
|
||||||
|
|||||||
@@ -181,6 +181,11 @@ vdb:
|
|||||||
host: localhost
|
host: localhost
|
||||||
port: 6333
|
port: 6333
|
||||||
api_key: ''
|
api_key: ''
|
||||||
|
# SeekDB is optional. Native/package installs need the `seekdb` extra:
|
||||||
|
# `uv sync --extra seekdb` (source) or
|
||||||
|
# `uvx --from 'langbot[seekdb]@latest' langbot` (PyPI).
|
||||||
|
# The official Docker image already includes it.
|
||||||
|
# Embedded-mode platform support depends on the native pylibseekdb wheels.
|
||||||
seekdb:
|
seekdb:
|
||||||
mode: embedded # 'embedded' or 'server'
|
mode: embedded # 'embedded' or 'server'
|
||||||
# Embedded mode options:
|
# Embedded mode options:
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
// Read config from script tag data attributes
|
// Read config from script tag data attributes
|
||||||
var scriptEl = document.currentScript;
|
var scriptEl = document.currentScript;
|
||||||
var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null;
|
var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null;
|
||||||
|
var scriptTestNotice = scriptEl
|
||||||
|
? scriptEl.getAttribute("data-test-notice")
|
||||||
|
: null;
|
||||||
|
|
||||||
// ========== i18n ==========
|
// ========== i18n ==========
|
||||||
var I18N = {
|
var I18N = {
|
||||||
@@ -192,6 +195,7 @@
|
|||||||
.lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\
|
.lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\
|
||||||
.lb-header-btn:hover { opacity: 1; }\
|
.lb-header-btn:hover { opacity: 1; }\
|
||||||
.lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\
|
.lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\
|
||||||
|
.lb-test-notice { padding: 8px 16px; border-bottom: 1px solid #fde68a; background: #fffbeb; color: #92400e; font-size: 12px; line-height: 1.5; text-align: center; flex-shrink: 0; }\
|
||||||
.lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\
|
.lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\
|
||||||
.lb-messages::-webkit-scrollbar { width: 6px; }\
|
.lb-messages::-webkit-scrollbar { width: 6px; }\
|
||||||
.lb-messages::-webkit-scrollbar-track { background: transparent; }\
|
.lb-messages::-webkit-scrollbar-track { background: transparent; }\
|
||||||
@@ -1240,6 +1244,14 @@
|
|||||||
// Root container
|
// Root container
|
||||||
var root = document.createElement("div");
|
var root = document.createElement("div");
|
||||||
root.id = "langbot-widget-root";
|
root.id = "langbot-widget-root";
|
||||||
|
root.langbotDestroy = function () {
|
||||||
|
wsDisconnect();
|
||||||
|
if (state.historyReloadTimer) {
|
||||||
|
clearTimeout(state.historyReloadTimer);
|
||||||
|
state.historyReloadTimer = null;
|
||||||
|
}
|
||||||
|
root.remove();
|
||||||
|
};
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
|
|
||||||
var shadow = root.attachShadow({ mode: "open" });
|
var shadow = root.attachShadow({ mode: "open" });
|
||||||
@@ -1328,6 +1340,14 @@
|
|||||||
header.appendChild(headerActions);
|
header.appendChild(headerActions);
|
||||||
panel.appendChild(header);
|
panel.appendChild(header);
|
||||||
|
|
||||||
|
if (scriptTestNotice) {
|
||||||
|
var testNotice = document.createElement("div");
|
||||||
|
testNotice.className = "lb-test-notice";
|
||||||
|
testNotice.setAttribute("role", "note");
|
||||||
|
testNotice.textContent = scriptTestNotice;
|
||||||
|
panel.appendChild(testNotice);
|
||||||
|
}
|
||||||
|
|
||||||
// Messages area
|
// Messages area
|
||||||
var messages = document.createElement("div");
|
var messages = document.createElement("div");
|
||||||
messages.className = "lb-messages";
|
messages.className = "lb-messages";
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ stages:
|
|||||||
zh_Hans: API 密钥
|
zh_Hans: API 密钥
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: 'your-api-key'
|
default: ''
|
||||||
- name: n8n-service-api
|
- name: n8n-service-api
|
||||||
label:
|
label:
|
||||||
en_US: n8n Workflow API
|
en_US: n8n Workflow API
|
||||||
|
|||||||
@@ -254,6 +254,22 @@ class TestPipelinesCRUDEndpoints:
|
|||||||
assert data['code'] == 0
|
assert data['code'] == 0
|
||||||
assert 'uuid' in data['data']
|
assert 'uuid' in data['data']
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
|
||||||
|
"""POST /api/v1/pipelines explicitly creates a default pipeline."""
|
||||||
|
fake_pipeline_app.pipeline_service.create_pipeline.reset_mock()
|
||||||
|
|
||||||
|
response = await quart_test_client.post(
|
||||||
|
'/api/v1/pipelines',
|
||||||
|
headers={'Authorization': 'Bearer test_token'},
|
||||||
|
json={'name': 'Default Pipeline', 'config': {}, 'is_default': True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
call = fake_pipeline_app.pipeline_service.create_pipeline.await_args
|
||||||
|
assert call.kwargs == {'default': True}
|
||||||
|
assert call.args[1]['is_default'] is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_pipeline_success(self, quart_test_client):
|
async def test_update_pipeline_success(self, quart_test_client):
|
||||||
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
|
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ class TestUserInitEndpoint:
|
|||||||
assert data['data'] == {
|
assert data['data'] == {
|
||||||
'initialized': True,
|
'initialized': True,
|
||||||
'authenticated_invitation_acceptance_enabled': False,
|
'authenticated_invitation_acceptance_enabled': False,
|
||||||
|
'invitation_registration_enabled': True,
|
||||||
'password_login_enabled': True,
|
'password_login_enabled': True,
|
||||||
'space_login_enabled': False,
|
'space_login_enabled': False,
|
||||||
}
|
}
|
||||||
@@ -330,6 +331,28 @@ class TestUserInitEndpoint:
|
|||||||
assert data['data'] == {
|
assert data['data'] == {
|
||||||
'initialized': True,
|
'initialized': True,
|
||||||
'authenticated_invitation_acceptance_enabled': True,
|
'authenticated_invitation_acceptance_enabled': True,
|
||||||
|
'invitation_registration_enabled': False,
|
||||||
|
'password_login_enabled': False,
|
||||||
|
'space_login_enabled': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_account_info_enables_local_invitation_registration_for_oauth_only_oss(
|
||||||
|
self, quart_test_client, fake_api_app
|
||||||
|
):
|
||||||
|
fake_api_app.user_service.is_initialized.return_value = True
|
||||||
|
fake_api_app.user_service.get_login_capabilities = AsyncMock(
|
||||||
|
return_value={'password_login_enabled': False, 'space_login_enabled': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await quart_test_client.get('/api/v1/user/account-info')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = await response.get_json()
|
||||||
|
assert data['data'] == {
|
||||||
|
'initialized': True,
|
||||||
|
'authenticated_invitation_acceptance_enabled': False,
|
||||||
|
'invitation_registration_enabled': True,
|
||||||
'password_login_enabled': False,
|
'password_login_enabled': False,
|
||||||
'space_login_enabled': True,
|
'space_login_enabled': True,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,6 +312,29 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
|||||||
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oss_local_only_owner_requires_space_binding_for_langbot_models(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.user_service.get_workspace_owner = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(user='owner@example.com', space_account_uuid=None)
|
||||||
|
)
|
||||||
|
application.space_service.get_credits = AsyncMock()
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
'/api/v1/user/space-credits',
|
||||||
|
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
||||||
|
)
|
||||||
|
payload = await response.get_json()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['data'] == {
|
||||||
|
'credits': None,
|
||||||
|
'owner_space_bound': False,
|
||||||
|
'is_workspace_owner': True,
|
||||||
|
}
|
||||||
|
application.space_service.get_credits.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
|||||||
sa.Column('key', sa.String(255), nullable=False),
|
sa.Column('key', sa.String(255), nullable=False),
|
||||||
sa.Column('owner_type', sa.String(255), nullable=False),
|
sa.Column('owner_type', sa.String(255), nullable=False),
|
||||||
sa.Column('owner', sa.String(255), nullable=False),
|
sa.Column('owner', sa.String(255), nullable=False),
|
||||||
|
sa.Column('value', sa.LargeBinary, nullable=False),
|
||||||
)
|
)
|
||||||
mcp_servers = _uuid_table(
|
mcp_servers = _uuid_table(
|
||||||
metadata,
|
metadata,
|
||||||
@@ -210,7 +211,13 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
|||||||
await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
|
await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
|
||||||
await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
|
await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo')
|
binary_storages.insert().values(
|
||||||
|
unique_key='plugin:demo:key',
|
||||||
|
key='key',
|
||||||
|
owner_type='plugin',
|
||||||
|
owner='demo',
|
||||||
|
value=b'legacy-plugin-value',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
|
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
|
||||||
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
|
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
|
||||||
|
|||||||
@@ -76,6 +76,26 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
|
|||||||
)
|
)
|
||||||
assert legacy_kb['collection_id'] == 'collection-1'
|
assert legacy_kb['collection_id'] == 'collection-1'
|
||||||
assert legacy_kb['legacy_vector_collection'] == 1
|
assert legacy_kb['legacy_vector_collection'] == 1
|
||||||
|
legacy_binary_storage = (
|
||||||
|
(
|
||||||
|
await conn.execute(
|
||||||
|
sa.text(
|
||||||
|
'SELECT workspace_uuid, unique_key, key, owner_type, owner, value '
|
||||||
|
"FROM binary_storages WHERE owner_type = 'plugin' AND owner = 'demo'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
assert legacy_binary_storage == {
|
||||||
|
'workspace_uuid': workspace_uuid,
|
||||||
|
'unique_key': 'plugin:demo:key',
|
||||||
|
'key': 'key',
|
||||||
|
'owner_type': 'plugin',
|
||||||
|
'owner': 'demo',
|
||||||
|
'value': b'legacy-plugin-value',
|
||||||
|
}
|
||||||
assert (
|
assert (
|
||||||
await conn.scalar(
|
await conn.scalar(
|
||||||
sa.text(
|
sa.text(
|
||||||
@@ -209,8 +229,8 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
'INSERT INTO binary_storages '
|
'INSERT INTO binary_storages '
|
||||||
'(workspace_uuid, unique_key, key, owner_type, owner) '
|
'(workspace_uuid, unique_key, key, owner_type, owner, value) '
|
||||||
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')"
|
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo', X'')"
|
||||||
),
|
),
|
||||||
{'workspace_uuid': second_workspace_uuid},
|
{'workspace_uuid': second_workspace_uuid},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ Source: src/langbot/pkg/api/http/service/bot.py
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from langbot.pkg.api.http.service.bot import BotService
|
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_url'] == '/bots/wecom-uuid'
|
||||||
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/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):
|
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
|
||||||
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -605,6 +629,77 @@ class TestBotServiceListEventLogs:
|
|||||||
assert total == 5
|
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:
|
class TestBotServiceSendMessage:
|
||||||
"""Tests for send_message method."""
|
"""Tests for send_message method."""
|
||||||
|
|
||||||
|
|||||||
@@ -820,6 +820,100 @@ class TestSpaceServiceGetModels:
|
|||||||
await service.get_models()
|
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:
|
class TestSpaceServiceCreditsCache:
|
||||||
"""Tests for credits cache behavior."""
|
"""Tests for credits cache behavior."""
|
||||||
|
|
||||||
|
|||||||
@@ -2163,25 +2163,38 @@ class TestInboundOutboundRoundTrip:
|
|||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
async def fake_execute_tool(parameters, q):
|
async def fake_client_execute(spec):
|
||||||
calls.append(parameters['command'])
|
cmd = spec.cmd
|
||||||
if 'os.scandir' in parameters['command']:
|
calls.append(cmd)
|
||||||
return {
|
if 'os.scandir' in cmd:
|
||||||
'ok': True,
|
return BoxExecutionResult(
|
||||||
'stdout': '[{"name": "out.png", "b64": "QUJD"}]',
|
session_id='s',
|
||||||
'stderr': '',
|
backend_name='test',
|
||||||
}
|
status=BoxExecutionStatus.COMPLETED,
|
||||||
|
exit_code=0,
|
||||||
|
stdout='[{"name": "out.png", "b64": "QUJD"}]',
|
||||||
|
duration_ms=10,
|
||||||
|
)
|
||||||
# the rm -rf cleanup call
|
# the rm -rf cleanup call
|
||||||
return {'ok': True, 'stdout': '', 'stderr': ''}
|
return BoxExecutionResult(
|
||||||
|
session_id='s',
|
||||||
|
backend_name='test',
|
||||||
|
status=BoxExecutionStatus.COMPLETED,
|
||||||
|
exit_code=0,
|
||||||
|
stdout='',
|
||||||
|
duration_ms=10,
|
||||||
|
)
|
||||||
|
|
||||||
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
|
service.client.execute = AsyncMock(side_effect=fake_client_execute)
|
||||||
|
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
|
||||||
|
|
||||||
attachments = await service.collect_outbound_attachments(query)
|
attachments = await service.collect_outbound_attachments(query)
|
||||||
assert len(attachments) == 1
|
assert len(attachments) == 1
|
||||||
assert attachments[0]['type'] == 'Image'
|
assert attachments[0]['type'] == 'Image'
|
||||||
assert attachments[0]['name'] == 'out.png'
|
assert attachments[0]['name'] == 'out.png'
|
||||||
# cleanup (rm -rf) must have been issued after a successful collection
|
# cleanup (rm -rf) must have been issued after a successful collection
|
||||||
assert any('rm -rf' in c for c in calls)
|
service.execute_tool.assert_awaited_once()
|
||||||
|
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_collect_outbound_empty_still_clears(self):
|
async def test_collect_outbound_empty_still_clears(self):
|
||||||
@@ -2193,16 +2206,33 @@ class TestInboundOutboundRoundTrip:
|
|||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
async def fake_execute_tool(parameters, q):
|
async def fake_client_execute(spec):
|
||||||
calls.append(parameters['command'])
|
cmd = spec.cmd
|
||||||
if 'os.scandir' in parameters['command']:
|
calls.append(cmd)
|
||||||
return {'ok': True, 'stdout': '[]', 'stderr': ''}
|
if 'os.scandir' in cmd:
|
||||||
return {'ok': True, 'stdout': '', 'stderr': ''}
|
return BoxExecutionResult(
|
||||||
|
session_id='s',
|
||||||
|
backend_name='test',
|
||||||
|
status=BoxExecutionStatus.COMPLETED,
|
||||||
|
exit_code=0,
|
||||||
|
stdout='[]',
|
||||||
|
duration_ms=10,
|
||||||
|
)
|
||||||
|
return BoxExecutionResult(
|
||||||
|
session_id='s',
|
||||||
|
backend_name='test',
|
||||||
|
status=BoxExecutionStatus.COMPLETED,
|
||||||
|
exit_code=0,
|
||||||
|
stdout='',
|
||||||
|
duration_ms=10,
|
||||||
|
)
|
||||||
|
|
||||||
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
|
service.client.execute = AsyncMock(side_effect=fake_client_execute)
|
||||||
|
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
|
||||||
assert await service.collect_outbound_attachments(query) == []
|
assert await service.collect_outbound_attachments(query) == []
|
||||||
# cleanup (rm -rf) is issued unconditionally now
|
# cleanup (rm -rf) is issued unconditionally now
|
||||||
assert any('rm -rf' in c for c in calls)
|
service.execute_tool.assert_awaited_once()
|
||||||
|
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_passthrough_noop_when_unavailable(self):
|
async def test_passthrough_noop_when_unavailable(self):
|
||||||
|
|||||||
@@ -144,3 +144,39 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
|
|||||||
assert stats['models']['providers'] == 1
|
assert stats['models']['providers'] == 1
|
||||||
assert stats['runtimes']['plugin_installations'] == 1
|
assert stats['runtimes']['plugin_installations'] == 1
|
||||||
assert stats['runtimes']['plugin_runtime_connected'] is True
|
assert stats['runtimes']['plugin_runtime_connected'] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_plugin_runtime_initialization_bypasses_after_commit_gate() -> None:
|
||||||
|
app = Application()
|
||||||
|
app.plugin_connector = SimpleNamespace(initialize=AsyncMock())
|
||||||
|
app.task_mgr = SimpleNamespace(create_task=AsyncMock())
|
||||||
|
|
||||||
|
task = app._start_plugin_runtime_initialization()
|
||||||
|
await task
|
||||||
|
|
||||||
|
app.plugin_connector.initialize.assert_awaited_once_with()
|
||||||
|
app.task_mgr.create_task.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shutdown_cancels_plugin_runtime_initialization_task() -> None:
|
||||||
|
app = Application()
|
||||||
|
app._plugin_runtime_initialization_task = asyncio.create_task(asyncio.sleep(60))
|
||||||
|
app.task_mgr = SimpleNamespace(cancel_by_scope=lambda *_: None, tasks=[])
|
||||||
|
app.event_loop_monitor = SimpleNamespace(stop=AsyncMock())
|
||||||
|
app.http_ctrl = SimpleNamespace(mcp_mount=None)
|
||||||
|
app.platform_mgr = None
|
||||||
|
app.tool_mgr = None
|
||||||
|
app.model_mgr = None
|
||||||
|
app.box_service = None
|
||||||
|
app.plugin_connector = None
|
||||||
|
app.telemetry = None
|
||||||
|
app.vector_db_mgr = None
|
||||||
|
app.storage_mgr = None
|
||||||
|
app.persistence_mgr = SimpleNamespace(db=SimpleNamespace(engine=SimpleNamespace(dispose=AsyncMock())))
|
||||||
|
app.deployment = None
|
||||||
|
|
||||||
|
await app.shutdown()
|
||||||
|
|
||||||
|
assert app._plugin_runtime_initialization_task.cancelled()
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""BanWordFilter regression tests for legacy sensitive-word lists.
|
||||||
|
|
||||||
|
v4.10.7 introduced a 64-pattern cap in safe_regex. Older installs still carry
|
||||||
|
the previous default list (~70 patterns). The filter must keep applying those
|
||||||
|
rules instead of blocking every message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.factories import FakeApp
|
||||||
|
|
||||||
|
|
||||||
|
def _load_banwords():
|
||||||
|
import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||||
|
banwords = import_module('langbot.pkg.pipeline.cntfilter.filters.banwords')
|
||||||
|
entities = import_module('langbot.pkg.pipeline.cntfilter.entities')
|
||||||
|
safe_regex = import_module('langbot.pkg.utils.safe_regex')
|
||||||
|
return banwords, entities, safe_regex
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_with_words(words: list[str], *, mask: str = '*', mask_word: str = ''):
|
||||||
|
banwords, entities, _ = _load_banwords()
|
||||||
|
app = FakeApp()
|
||||||
|
app.sensitive_meta = Mock()
|
||||||
|
app.sensitive_meta.data = {
|
||||||
|
'words': words,
|
||||||
|
'mask': mask,
|
||||||
|
'mask_word': mask_word,
|
||||||
|
}
|
||||||
|
return banwords.BanWordFilter(app), entities, app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_word_list_over_pattern_cap_does_not_block_clean_message():
|
||||||
|
"""A pre-v4.10.7 word list must not fail closed on every message."""
|
||||||
|
_, _, safe_regex = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT + 6)]
|
||||||
|
filt, entities, _ = _filter_with_words(words)
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.PASS
|
||||||
|
assert result.replacement == 'hello there, nothing banned'
|
||||||
|
assert result.user_notice == ''
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_word_list_still_masks_match_beyond_first_batch():
|
||||||
|
"""Words past the first 64-pattern batch must still be applied."""
|
||||||
|
_, _, safe_regex = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)] + ['secret-token']
|
||||||
|
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'please hide secret-token now')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.MASKED
|
||||||
|
assert 'secret-token' not in result.replacement
|
||||||
|
assert '[hidden]' in result.replacement
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_word_list_masks_match_in_first_batch():
|
||||||
|
_, _, safe_regex = _load_banwords()
|
||||||
|
words = ['alpha-secret'] + [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)]
|
||||||
|
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'alpha-secret is here')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.MASKED
|
||||||
|
assert result.replacement == '[hidden] is here'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_sensitive_word_regex_still_blocks():
|
||||||
|
filt, entities, _ = _filter_with_words(['(unclosed'])
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'any message')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.BLOCK
|
||||||
|
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||||
|
assert 'rejected' in result.console_notice.lower() or 'invalid' in result.console_notice.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oversized_word_list_is_blocked():
|
||||||
|
"""Configured rules must never be silently skipped when the list is oversized."""
|
||||||
|
banwords, _, _ = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS + 10)]
|
||||||
|
filt, entities, _ = _filter_with_words(words)
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.BLOCK
|
||||||
|
assert result.replacement == ''
|
||||||
|
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||||
|
assert 'at most 256 regex patterns are allowed' in result.console_notice.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_match_beyond_total_cap_cannot_bypass_filter():
|
||||||
|
banwords, _, _ = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS)] + ['late-secret']
|
||||||
|
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'please hide late-secret now')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.BLOCK
|
||||||
|
assert result.replacement == ''
|
||||||
@@ -0,0 +1,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 asyncio
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
@@ -99,6 +101,12 @@ def _stream_test_adapter():
|
|||||||
adapter.bot = MagicMock()
|
adapter.bot = MagicMock()
|
||||||
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
|
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
|
||||||
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
|
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
|
||||||
|
adapter.bot.send_private_text_msg = AsyncMock()
|
||||||
|
adapter.bot.send_group_text_msg = AsyncMock()
|
||||||
|
adapter.bot.send_private_markdown_msg = AsyncMock()
|
||||||
|
adapter.bot.send_group_markdown_msg = AsyncMock()
|
||||||
|
adapter.bot.send_channle_group_text_msg = AsyncMock()
|
||||||
|
adapter.bot.send_channle_private_text_msg = AsyncMock()
|
||||||
adapter.ap = None
|
adapter.ap = None
|
||||||
adapter._stream_ctx = {}
|
adapter._stream_ctx = {}
|
||||||
adapter._stream_ctx_ts = {}
|
adapter._stream_ctx_ts = {}
|
||||||
@@ -108,7 +116,7 @@ def _stream_test_adapter():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
|
async def test_qq_stream_replace_mode_sends_complete_snapshots():
|
||||||
adapter = _stream_test_adapter()
|
adapter = _stream_test_adapter()
|
||||||
adapter._stream_ctx['message-1'] = {
|
adapter._stream_ctx['message-1'] = {
|
||||||
'user_openid': 'user-1',
|
'user_openid': 'user-1',
|
||||||
@@ -138,10 +146,109 @@ async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
|
|||||||
|
|
||||||
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
|
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
|
||||||
'<think>one',
|
'<think>one',
|
||||||
' two',
|
'<think>one two',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qq_markdown_messages_use_markdown_payloads():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def capture_request(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append((str(request.url), json.loads(request.content)))
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
|
||||||
|
client = QQOfficialClient('secret', 'token', 'app-id', AsyncMock())
|
||||||
|
client.access_token = 'access-token'
|
||||||
|
client.access_token_expiry_time = time.time() + 3600
|
||||||
|
client._http_clients[None] = httpx.AsyncClient(transport=httpx.MockTransport(capture_request))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.send_private_markdown_msg('user-1', '# Hello', msg_id='message-1', msg_seq=2)
|
||||||
|
await client.send_group_markdown_msg('group-1', '* Hello', event_id='event-1', msg_seq=3)
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
assert requests == [
|
||||||
|
(
|
||||||
|
'https://api.sgroup.qq.com/v2/users/user-1/messages',
|
||||||
|
{'msg_type': 2, 'markdown': {'content': '# Hello'}, 'msg_seq': 2, 'msg_id': 'message-1'},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'https://api.sgroup.qq.com/v2/groups/group-1/messages',
|
||||||
|
{'msg_type': 2, 'markdown': {'content': '* Hello'}, 'msg_seq': 3, 'event_id': 'event-1'},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qq_markdown_rendering_switches_c2c_and_group_text_replies():
|
||||||
|
adapter = _stream_test_adapter()
|
||||||
|
adapter.config = {'enable-markdown-rendering': True}
|
||||||
|
|
||||||
|
await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', '# Hello', msg_id='message-1')
|
||||||
|
await adapter._send_c2c_or_group_text_reply('group', 'group-1', '* Hello', event_id='event-1')
|
||||||
|
|
||||||
|
adapter.bot.send_private_markdown_msg.assert_awaited_once_with(
|
||||||
|
user_openid='user-1',
|
||||||
|
content='# Hello',
|
||||||
|
msg_id='message-1',
|
||||||
|
event_id=None,
|
||||||
|
msg_seq=1,
|
||||||
|
)
|
||||||
|
adapter.bot.send_group_markdown_msg.assert_awaited_once_with(
|
||||||
|
group_openid='group-1',
|
||||||
|
content='* Hello',
|
||||||
|
msg_id=None,
|
||||||
|
event_id='event-1',
|
||||||
|
msg_seq=1,
|
||||||
|
)
|
||||||
|
adapter.bot.send_private_text_msg.assert_not_awaited()
|
||||||
|
adapter.bot.send_group_text_msg.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qq_markdown_rendering_defaults_to_plain_text_replies():
|
||||||
|
adapter = _stream_test_adapter()
|
||||||
|
adapter.config = {}
|
||||||
|
|
||||||
|
await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', 'Hello')
|
||||||
|
await adapter._send_c2c_or_group_text_reply('group', 'group-1', 'Hello')
|
||||||
|
|
||||||
|
adapter.bot.send_private_text_msg.assert_awaited_once()
|
||||||
|
adapter.bot.send_group_text_msg.assert_awaited_once()
|
||||||
|
adapter.bot.send_private_markdown_msg.assert_not_awaited()
|
||||||
|
adapter.bot.send_group_markdown_msg.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qq_markdown_rendering_does_not_affect_channel_messages():
|
||||||
|
adapter = _stream_test_adapter()
|
||||||
|
adapter.config = {'enable-markdown-rendering': True}
|
||||||
|
message = platform_message.MessageChain([platform_message.Plain(text='# Hello')])
|
||||||
|
|
||||||
|
channel_source = MagicMock()
|
||||||
|
channel_source.t = 'AT_MESSAGE_CREATE'
|
||||||
|
channel_source.channel_id = 'channel-1'
|
||||||
|
channel_source.d_id = 'message-1'
|
||||||
|
channel_event = MagicMock()
|
||||||
|
channel_event.source_platform_object = channel_source
|
||||||
|
await adapter.reply_message(channel_event, message)
|
||||||
|
|
||||||
|
dm_source = MagicMock()
|
||||||
|
dm_source.t = 'DIRECT_MESSAGE_CREATE'
|
||||||
|
dm_source.guild_id = 'guild-1'
|
||||||
|
dm_source.d_id = 'message-2'
|
||||||
|
dm_event = MagicMock()
|
||||||
|
dm_event.source_platform_object = dm_source
|
||||||
|
await adapter.reply_message(dm_event, message)
|
||||||
|
|
||||||
|
adapter.bot.send_channle_group_text_msg.assert_awaited_once_with('channel-1', '# Hello', 'message-1')
|
||||||
|
adapter.bot.send_channle_private_text_msg.assert_awaited_once_with('guild-1', '# Hello', 'message-2')
|
||||||
|
adapter.bot.send_private_markdown_msg.assert_not_awaited()
|
||||||
|
adapter.bot.send_group_markdown_msg.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
|
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
|
||||||
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import langbot.pkg.core.app # noqa: F401
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
from langbot.libs.wecom_ai_bot_api.ws_client import _UPLOAD_CHUNK_SIZE, WecomBotWsClient
|
||||||
|
from langbot.pkg.platform.sources.wecombot import WecomBotAdapter, WecomBotMessageConverter
|
||||||
|
|
||||||
|
|
||||||
|
class Logger:
|
||||||
|
def __init__(self):
|
||||||
|
self.warnings = []
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
async def warning(self, message):
|
||||||
|
self.warnings.append(message)
|
||||||
|
|
||||||
|
async def error(self, message):
|
||||||
|
self.errors.append(message)
|
||||||
|
|
||||||
|
async def info(self, message):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UploadClient(WecomBotWsClient):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(bot_id='bot', secret='secret', logger=Logger())
|
||||||
|
self.frames = []
|
||||||
|
|
||||||
|
async def _send_reply(self, req_id: str, body: dict, cmd: str = 'aibot_respond_msg'):
|
||||||
|
self.frames.append((cmd, body))
|
||||||
|
if cmd == 'aibot_upload_media_init':
|
||||||
|
return {'errcode': 0, 'body': {'upload_id': 'upload-1'}}
|
||||||
|
if cmd == 'aibot_upload_media_finish':
|
||||||
|
return {'errcode': 0, 'body': {'media_id': 'media-1'}}
|
||||||
|
return {'errcode': 0}
|
||||||
|
|
||||||
|
|
||||||
|
class Bot:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def upload_media(self, data, filename='attachment', media_type='file'):
|
||||||
|
self.calls.append(('upload_media', media_type, filename, data))
|
||||||
|
return {'media_id': 'media-1'}
|
||||||
|
|
||||||
|
async def reply_text(self, req_id, content):
|
||||||
|
self.calls.append(('reply_text', req_id, content))
|
||||||
|
|
||||||
|
async def reply_image(self, req_id, media_id):
|
||||||
|
self.calls.append(('reply_image', req_id, media_id))
|
||||||
|
|
||||||
|
async def send_message(self, target_id, content):
|
||||||
|
self.calls.append(('send_message', target_id, content))
|
||||||
|
|
||||||
|
|
||||||
|
def make_adapter(bot):
|
||||||
|
return WecomBotAdapter.model_construct(
|
||||||
|
bot=bot,
|
||||||
|
config={'enable-webhook': False},
|
||||||
|
logger=Logger(),
|
||||||
|
message_converter=WecomBotMessageConverter(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ws_client_upload_media_uses_chunk_protocol():
|
||||||
|
client = UploadClient()
|
||||||
|
data = b'a' * (_UPLOAD_CHUNK_SIZE + 1)
|
||||||
|
|
||||||
|
upload_result = await client.upload_media(data, 'image.png', media_type='image')
|
||||||
|
|
||||||
|
assert upload_result['media_id'] == 'media-1'
|
||||||
|
assert [cmd for cmd, _ in client.frames] == [
|
||||||
|
'aibot_upload_media_init',
|
||||||
|
'aibot_upload_media_chunk',
|
||||||
|
'aibot_upload_media_chunk',
|
||||||
|
'aibot_upload_media_finish',
|
||||||
|
]
|
||||||
|
init_body = client.frames[0][1]
|
||||||
|
assert init_body['type'] == 'image'
|
||||||
|
assert init_body['filename'] == 'image.png'
|
||||||
|
assert init_body['total_size'] == len(data)
|
||||||
|
assert init_body['total_chunks'] == 2
|
||||||
|
assert client.frames[1][1]['chunk_index'] == 0
|
||||||
|
assert base64.b64decode(client.frames[1][1]['base64_data']) == b'a' * _UPLOAD_CHUNK_SIZE
|
||||||
|
assert client.frames[2][1]['chunk_index'] == 1
|
||||||
|
assert base64.b64decode(client.frames[2][1]['base64_data']) == b'a'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_message_uploads_and_replies_image_media():
|
||||||
|
bot = Bot()
|
||||||
|
adapter = make_adapter(bot)
|
||||||
|
png_data = b'\x89PNG\r\n\x1a\nimage'
|
||||||
|
image_b64 = base64.b64encode(png_data).decode('utf-8')
|
||||||
|
chain = platform_message.MessageChain([platform_message.Image(base64=f'data:image/png;base64,{image_b64}')])
|
||||||
|
|
||||||
|
items = await WecomBotMessageConverter.yiri2target(chain)
|
||||||
|
await adapter._send_media(bot, 'req-1', items[0])
|
||||||
|
|
||||||
|
assert bot.calls == [
|
||||||
|
('upload_media', 'image', 'attachment.image', png_data),
|
||||||
|
('reply_image', 'req-1', 'media-1'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_sends_text_and_skips_proactive_image():
|
||||||
|
bot = Bot()
|
||||||
|
adapter = make_adapter(bot)
|
||||||
|
jpg_data = b'\xff\xd8\xffimage'
|
||||||
|
image_b64 = base64.b64encode(jpg_data).decode('utf-8')
|
||||||
|
chain = platform_message.MessageChain(
|
||||||
|
[
|
||||||
|
platform_message.Plain(text='before'),
|
||||||
|
platform_message.Image(base64=f'data:image/jpeg;base64,{image_b64}'),
|
||||||
|
platform_message.Plain(text='after'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.send_message('group', 'chat-1', chain)
|
||||||
|
|
||||||
|
assert bot.calls == [
|
||||||
|
('send_message', 'chat-1', 'beforeafter'),
|
||||||
|
]
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
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['open_kfid'] == 'kf-test'
|
||||||
assert kwargs['external_userid'] == 'external-user'
|
assert kwargs['external_userid'] == 'external-user'
|
||||||
assert kwargs['content'] == 'hello'
|
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
|
@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"}}'
|
||||||
|
)
|
||||||
@@ -107,6 +107,19 @@ def shared_connector(
|
|||||||
return connector
|
return connector
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shared_reconcile_uses_configured_cold_start_timeout():
|
||||||
|
binding = execution_binding("workspace-a")
|
||||||
|
setting = plugin_setting("01", "a" * 64)
|
||||||
|
connector = shared_connector([[binding]], {"workspace-a": [setting]})
|
||||||
|
connector.ap.instance_config.data["plugin"]["connect_timeout_seconds"] = 900
|
||||||
|
connector.handler = runtime_handler()
|
||||||
|
|
||||||
|
await connector._prepare_connected_runtime()
|
||||||
|
|
||||||
|
assert connector.handler.reconcile_plugin_installations.await_args.kwargs["timeout"] == 900
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
|
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
|
||||||
binding_a = execution_binding('workspace-a')
|
binding_a = execution_binding('workspace-a')
|
||||||
@@ -150,7 +163,7 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets():
|
|||||||
|
|
||||||
assert connector._workspace_installations == {}
|
assert connector._workspace_installations == {}
|
||||||
assert connector._known_desired_states == {}
|
assert connector._known_desired_states == {}
|
||||||
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
|
connector.handler.reconcile_plugin_installations.assert_awaited_once_with((), timeout=300.0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -81,6 +81,18 @@ async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish(
|
|||||||
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
|
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reconcile_plugin_installations_accepts_configured_cold_start_timeout():
|
||||||
|
runtime_handler = make_handler(SimpleNamespace())
|
||||||
|
runtime_handler.call_action = AsyncMock(return_value={})
|
||||||
|
binding = next(iter(runtime_handler._installation_bindings.values()))[0]
|
||||||
|
desired = PluginInstallationDesiredState(binding=binding, enabled=True)
|
||||||
|
|
||||||
|
await runtime_handler.reconcile_plugin_installations((desired,), timeout=900)
|
||||||
|
|
||||||
|
assert runtime_handler.call_action.await_args.kwargs["timeout"] == 900
|
||||||
|
|
||||||
|
|
||||||
class TestHandlerQueryVariables:
|
class TestHandlerQueryVariables:
|
||||||
"""Tests for handler query variable logic."""
|
"""Tests for handler query variable logic."""
|
||||||
|
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ class TestSetBinaryStorage:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
mock_app.persistence_mgr = Mock()
|
mock_app.persistence_mgr = Mock()
|
||||||
|
mock_app.persistence_mgr.get_db_engine.return_value = SimpleNamespace(dialect=SimpleNamespace(name='sqlite'))
|
||||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
|
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
|
||||||
mock_app.logger = Mock()
|
mock_app.logger = Mock()
|
||||||
return mock_app
|
return mock_app
|
||||||
@@ -270,8 +271,8 @@ class TestSetBinaryStorage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.code == 0
|
assert response.code == 0
|
||||||
assert app.persistence_mgr.execute_async.await_count == 2
|
assert app.persistence_mgr.execute_async.await_count == 3
|
||||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||||
assert insert_params['workspace_uuid'] == 'workspace-a'
|
assert insert_params['workspace_uuid'] == 'workspace-a'
|
||||||
assert insert_params['unique_key'] == canonical_binary_key(
|
assert insert_params['unique_key'] == canonical_binary_key(
|
||||||
'plugin',
|
'plugin',
|
||||||
@@ -301,6 +302,69 @@ class TestSetBinaryStorage:
|
|||||||
assert expected_key in update_params.values()
|
assert expected_key in update_params.values()
|
||||||
assert update_params['value'] == b'new'
|
assert update_params['value'] == b'new'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_adopts_legacy_storage_before_updating(self, app):
|
||||||
|
"""A migrated pre-tenancy row is updated in place rather than duplicated."""
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||||
|
adopted = SimpleNamespace(rowcount=1)
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(legacy_storage),
|
||||||
|
adopted,
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 3
|
||||||
|
adoption_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||||
|
expected_key = canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
|
||||||
|
assert expected_key in adoption_params.values()
|
||||||
|
assert adoption_params['value'] == b'new'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_adoption_race_updates_winning_canonical_row(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||||
|
lost_race = SimpleNamespace(rowcount=0)
|
||||||
|
canonical_winner = SimpleNamespace(rowcount=1)
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(legacy_storage),
|
||||||
|
lost_race,
|
||||||
|
canonical_winner,
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 4
|
||||||
|
winner_update = compiled_params(app.persistence_mgr.execute_async.await_args_list[3].args[0])
|
||||||
|
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in winner_update.values()
|
||||||
|
assert winner_update['value'] == b'new'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_adoption_lost_to_delete_inserts_new_value(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||||
|
lost_race = SimpleNamespace(rowcount=0)
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(legacy_storage),
|
||||||
|
lost_race,
|
||||||
|
SimpleNamespace(rowcount=0),
|
||||||
|
make_result(),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 5
|
||||||
|
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[4].args[0])
|
||||||
|
assert insert_params['unique_key'] == canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
|
||||||
|
assert insert_params['value'] == b'new'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
||||||
"""Invalid max_value_bytes uses the 10MB default limit."""
|
"""Invalid max_value_bytes uses the 10MB default limit."""
|
||||||
@@ -525,6 +589,46 @@ class TestGetBinaryStorage:
|
|||||||
in statement_params.values()
|
in statement_params.values()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reads_legacy_storage_without_mutating_key(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
legacy_storage = SimpleNamespace(
|
||||||
|
unique_key='plugin:test-author/test-plugin:test-key',
|
||||||
|
value=b'legacy bytes',
|
||||||
|
)
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(legacy_storage),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
||||||
|
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert base64.b64decode(response.data['value_base64']) == b'legacy bytes'
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retries_canonical_after_concurrent_legacy_adoption(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
canonical_storage = SimpleNamespace(value=b'adopted bytes')
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(),
|
||||||
|
make_result(canonical_storage),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
||||||
|
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert base64.b64decode(response.data['value_base64']) == b'adopted bytes'
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 3
|
||||||
|
retry_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||||
|
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in retry_params.values()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_returns_error_when_not_found(self, app):
|
async def test_returns_error_when_not_found(self, app):
|
||||||
"""Missing binary storage rows return an error response."""
|
"""Missing binary storage rows return an error response."""
|
||||||
@@ -567,21 +671,47 @@ class TestDeleteAndListBinaryStorage:
|
|||||||
|
|
||||||
assert response.code == 0
|
assert response.code == 0
|
||||||
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||||
assert 'workspace-a' in statement_params.values()
|
flat_values = [
|
||||||
|
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
|
||||||
|
]
|
||||||
|
assert 'workspace-a' in flat_values
|
||||||
assert (
|
assert (
|
||||||
canonical_binary_key(
|
canonical_binary_key(
|
||||||
'plugin',
|
'plugin',
|
||||||
'test-author/test-plugin',
|
'test-author/test-plugin',
|
||||||
'test-key',
|
'test-key',
|
||||||
)
|
)
|
||||||
in statement_params.values()
|
in flat_values
|
||||||
)
|
)
|
||||||
assert 'forged-owner' not in statement_params.values()
|
assert 'forged-owner' not in flat_values
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_removes_canonical_and_legacy_scoped_keys(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value](
|
||||||
|
{
|
||||||
|
'key': 'test-key',
|
||||||
|
'owner_type': 'plugin',
|
||||||
|
'owner': 'forged-owner',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||||
|
values = [
|
||||||
|
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
|
||||||
|
]
|
||||||
|
assert 'workspace-a' in values
|
||||||
|
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in values
|
||||||
|
assert 'plugin:test-author/test-plugin:test-key' in values
|
||||||
|
assert 'test-author/test-plugin' in values
|
||||||
|
assert 'forged-owner' not in values
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
||||||
result = Mock()
|
result = Mock()
|
||||||
result.scalars.return_value.all.return_value = ['first', 'second']
|
result.scalars.return_value.all.return_value = ['first', 'second', 'first']
|
||||||
app.persistence_mgr.execute_async.return_value = result
|
app.persistence_mgr.execute_async.return_value = result
|
||||||
runtime_handler = make_handler(app)
|
runtime_handler = make_handler(app)
|
||||||
|
|
||||||
|
|||||||
@@ -91,3 +91,42 @@ def test_convert_messages_plain_string_content_untouched():
|
|||||||
msg = provider_message.Message(role='user', content='just text')
|
msg = provider_message.Message(role='user', content='just text')
|
||||||
out = req._convert_messages([msg])
|
out = req._convert_messages([msg])
|
||||||
assert out[0]['content'] == 'just text'
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
||||||
found, masked = await safe_regex.mask_patterns(
|
found, masked = await safe_regex.mask_patterns(
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.utils.import_isolation import isolated_sys_modules
|
||||||
|
|
||||||
|
|
||||||
|
_INSTALL_HINT = "Install LangBot with the 'seekdb' extra"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seekdb_vector_backend_reports_missing_optional_extra() -> None:
|
||||||
|
module_name = 'langbot.pkg.vector.vdbs.seekdb'
|
||||||
|
|
||||||
|
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
|
||||||
|
seekdb_module = importlib.import_module(module_name)
|
||||||
|
|
||||||
|
assert seekdb_module.SEEKDB_AVAILABLE is False
|
||||||
|
with pytest.raises(ImportError, match=_INSTALL_HINT):
|
||||||
|
seekdb_module.SeekDBVectorDatabase(MagicMock())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seekdb_embedding_reports_missing_optional_extra() -> None:
|
||||||
|
module_name = 'langbot.pkg.provider.modelmgr.requesters.seekdbembed'
|
||||||
|
|
||||||
|
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
|
||||||
|
seekdb_embedding_module = importlib.import_module(module_name)
|
||||||
|
requester = seekdb_embedding_module.SeekDBEmbedding.__new__(seekdb_embedding_module.SeekDBEmbedding)
|
||||||
|
|
||||||
|
with pytest.raises(ImportError, match=_INSTALL_HINT):
|
||||||
|
await requester.initialize()
|
||||||
@@ -88,14 +88,15 @@ async def test_environment_mapping_enables_provider_without_leaking_secret(monke
|
|||||||
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
|
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
|
||||||
|
|
||||||
|
|
||||||
async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry_copy():
|
async def test_invitation_email_has_generic_langbot_brand_plain_fallback_and_expiry_copy():
|
||||||
service = InvitationDeliveryService(_app({}))
|
service = InvitationDeliveryService(_app({}))
|
||||||
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=<unsafe>'
|
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=<unsafe>'
|
||||||
|
|
||||||
text = service._plain_text('Research & Development', link)
|
text = service._plain_text('Research & Development', link)
|
||||||
html = service._html('Research & Development', link)
|
html = service._html('Research & Development', link)
|
||||||
|
|
||||||
assert 'LangBot Cloud' in text
|
assert 'LangBot' in text
|
||||||
|
assert 'LangBot Cloud' not in text
|
||||||
assert 'Research & Development' in text
|
assert 'Research & Development' in text
|
||||||
assert '7 days' in text
|
assert '7 days' in text
|
||||||
assert link in text
|
assert link in text
|
||||||
@@ -103,3 +104,55 @@ async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry
|
|||||||
assert 'Research & Development' in html
|
assert 'Research & Development' in html
|
||||||
assert 'expires in 7 days' in html
|
assert 'expires in 7 days' in html
|
||||||
assert 'lbi_secret&next=<unsafe>' in html
|
assert 'lbi_secret&next=<unsafe>' in html
|
||||||
|
assert 'LangBot Cloud' not in html
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invitation_email_uses_quiet_brand_lockup_and_compact_fallback_link():
|
||||||
|
service = InvitationDeliveryService(_app({}))
|
||||||
|
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret'
|
||||||
|
|
||||||
|
html = service._html("RockChinQ's Workspace", link)
|
||||||
|
|
||||||
|
assert 'https://docs.langbot.app/langbot-logo.png' in html
|
||||||
|
assert '>LangBot<' in html
|
||||||
|
assert 'Workspace invitation' in html
|
||||||
|
assert 'Open invitation link' in html
|
||||||
|
assert 'linear-gradient' not in html
|
||||||
|
assert 'box-shadow' not in html
|
||||||
|
assert 'border-top:4px solid' not in html
|
||||||
|
assert 'border:1px solid #dfe6f0' not in html
|
||||||
|
assert 'height="28"' in html
|
||||||
|
assert 'height="32"' in html
|
||||||
|
assert 'margin-top:32px' not in html
|
||||||
|
assert f'>{link}<' not in html
|
||||||
|
|
||||||
|
|
||||||
|
async def test_oss_smtp_configuration_delivers_the_generic_invitation_email():
|
||||||
|
service = InvitationDeliveryService(
|
||||||
|
_app(
|
||||||
|
{
|
||||||
|
'workspace': {
|
||||||
|
'invitations': {
|
||||||
|
'email': {
|
||||||
|
'provider': 'smtp',
|
||||||
|
'from': 'LangBot <noreply@example.com>',
|
||||||
|
'smtp': {'host': 'smtp.example.com'},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service._send_smtp = AsyncMock(return_value=True)
|
||||||
|
link = 'https://self-hosted.example/invitations/accept#token=lbi_secret'
|
||||||
|
|
||||||
|
result = await service.deliver_invitation(
|
||||||
|
recipient_email='member@example.com',
|
||||||
|
workspace_name='Self-hosted Workspace',
|
||||||
|
invitation_link=link,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == InvitationDeliveryResult(status='sent', provider='smtp')
|
||||||
|
service._send_smtp.assert_awaited_once()
|
||||||
|
assert 'LangBot Cloud' not in service._plain_text('Self-hosted Workspace', link)
|
||||||
|
assert 'LangBot Cloud' not in service._html('Self-hosted Workspace', link)
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ resolution-markers = [
|
|||||||
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
|
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
|
||||||
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||||
"python_full_version == '3.12.*' and sys_platform == 'win32'",
|
"python_full_version == '3.12.*' and sys_platform == 'win32'",
|
||||||
"python_full_version < '3.12' and sys_platform == 'win32'",
|
|
||||||
"python_full_version == '3.12.*' and sys_platform == 'emscripten'",
|
"python_full_version == '3.12.*' and sys_platform == 'emscripten'",
|
||||||
"python_full_version < '3.12' and sys_platform == 'emscripten'",
|
|
||||||
"python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
"python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||||
|
"python_full_version < '3.12' and sys_platform == 'win32'",
|
||||||
|
"python_full_version < '3.12' and sys_platform == 'emscripten'",
|
||||||
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2008,7 +2008,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.7"
|
version = "4.10.8"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -2063,7 +2063,6 @@ dependencies = [
|
|||||||
{ name = "pymilvus" },
|
{ name = "pymilvus" },
|
||||||
{ name = "pynacl" },
|
{ name = "pynacl" },
|
||||||
{ name = "pypdf2" },
|
{ name = "pypdf2" },
|
||||||
{ name = "pyseekdb" },
|
|
||||||
{ name = "python-docx" },
|
{ name = "python-docx" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "python-socks" },
|
{ name = "python-socks" },
|
||||||
@@ -2089,6 +2088,11 @@ dependencies = [
|
|||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
seekdb = [
|
||||||
|
{ name = "pyseekdb" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "moto" },
|
{ name = "moto" },
|
||||||
@@ -2153,7 +2157,7 @@ requires-dist = [
|
|||||||
{ name = "pymilvus", specifier = ">=2.6.4" },
|
{ name = "pymilvus", specifier = ">=2.6.4" },
|
||||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||||
{ name = "pypdf2", specifier = ">=3.0.1" },
|
{ name = "pypdf2", specifier = ">=3.0.1" },
|
||||||
{ name = "pyseekdb", specifier = "==1.1.0.post3" },
|
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
|
||||||
{ name = "python-docx", specifier = ">=1.1.0" },
|
{ name = "python-docx", specifier = ">=1.1.0" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.27" },
|
{ name = "python-multipart", specifier = ">=0.0.27" },
|
||||||
{ name = "python-socks", specifier = ">=2.7.1" },
|
{ name = "python-socks", specifier = ">=2.7.1" },
|
||||||
@@ -2178,6 +2182,7 @@ requires-dist = [
|
|||||||
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
|
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
|
||||||
{ name = "websockets", specifier = ">=15.0.1" },
|
{ name = "websockets", specifier = ">=15.0.1" },
|
||||||
]
|
]
|
||||||
|
provides-extras = ["seekdb"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
+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,
|
autoExpandImages = false,
|
||||||
hideDetailedLogsLink = false,
|
hideDetailedLogsLink = false,
|
||||||
hideToolbar = false,
|
hideToolbar = false,
|
||||||
|
onMessageReceived,
|
||||||
}: {
|
}: {
|
||||||
botId: string;
|
botId: string;
|
||||||
/** When true, log entries with images are rendered expanded by default */
|
/** When true, log entries with images are rendered expanded by default */
|
||||||
@@ -28,6 +29,8 @@ export function BotLogListComponent({
|
|||||||
hideDetailedLogsLink?: boolean;
|
hideDetailedLogsLink?: boolean;
|
||||||
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
|
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
|
||||||
hideToolbar?: boolean;
|
hideToolbar?: boolean;
|
||||||
|
/** Called after an inbound person/group message appears in the bot log. */
|
||||||
|
onMessageReceived?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -41,6 +44,8 @@ export function BotLogListComponent({
|
|||||||
]);
|
]);
|
||||||
const listContainerRef = useRef<HTMLDivElement>(null);
|
const listContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const botLogListRef = useRef<BotLog[]>(botLogList);
|
const botLogListRef = useRef<BotLog[]>(botLogList);
|
||||||
|
const onMessageReceivedRef = useRef(onMessageReceived);
|
||||||
|
onMessageReceivedRef.current = onMessageReceived;
|
||||||
|
|
||||||
const logLevels = [
|
const logLevels = [
|
||||||
{ value: 'error', label: 'ERROR' },
|
{ value: 'error', label: 'ERROR' },
|
||||||
@@ -108,6 +113,9 @@ export function BotLogListComponent({
|
|||||||
manager.subscribeLogPush(handleBotLogPush);
|
manager.subscribeLogPush(handleBotLogPush);
|
||||||
manager.loadFirstPage().then((response) => {
|
manager.loadFirstPage().then((response) => {
|
||||||
setBotLogList(response.reverse());
|
setBotLogList(response.reverse());
|
||||||
|
if (response.some((log) => Boolean(log.message_session_id))) {
|
||||||
|
onMessageReceivedRef.current?.();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
listenScroll();
|
listenScroll();
|
||||||
}
|
}
|
||||||
@@ -138,6 +146,9 @@ export function BotLogListComponent({
|
|||||||
|
|
||||||
function handleBotLogPush(response: BotLog[]) {
|
function handleBotLogPush(response: BotLog[]) {
|
||||||
setBotLogList(response.reverse());
|
setBotLogList(response.reverse());
|
||||||
|
if (response.some((log) => Boolean(log.message_session_id))) {
|
||||||
|
onMessageReceivedRef.current?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
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, {
|
import QrCodeLoginDialog, {
|
||||||
QrLoginPlatform,
|
QrLoginPlatform,
|
||||||
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
||||||
@@ -464,61 +467,6 @@ export default function DynamicFormComponent({
|
|||||||
const previousInitialValues = useRef(initialValues);
|
const previousInitialValues = useRef(initialValues);
|
||||||
const { t, i18n } = useTranslation();
|
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
|
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types
|
||||||
// and `__system.*`-named fields) that should not participate in form state,
|
// and `__system.*`-named fields) that should not participate in form state,
|
||||||
// validation, or value emission.
|
// validation, or value emission.
|
||||||
@@ -574,7 +522,7 @@ export default function DynamicFormComponent({
|
|||||||
const rawValue = initialValues?.[item.name] ?? item.default;
|
const rawValue = initialValues?.[item.name] ?? item.default;
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
[item.name]: normalizeFieldValue(item, rawValue),
|
[item.name]: normalizeDynamicFormFieldValue(item, rawValue),
|
||||||
};
|
};
|
||||||
}, {} as FormValues),
|
}, {} as FormValues),
|
||||||
});
|
});
|
||||||
@@ -611,7 +559,10 @@ export default function DynamicFormComponent({
|
|||||||
const mergedValues = editableValueSpecs.reduce(
|
const mergedValues = editableValueSpecs.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
const rawValue = initialValues[item.name] ?? item.default;
|
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;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, object>,
|
{} as Record<string, object>,
|
||||||
|
|||||||
@@ -5,6 +5,80 @@ export type DynamicFormSaveValueSpec = Pick<
|
|||||||
'default' | 'name' | 'type'
|
'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([
|
const reasoningLevels = new Set([
|
||||||
'disabled',
|
'disabled',
|
||||||
'enabled',
|
'enabled',
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const getFormSchema = (t: (key: string) => string) =>
|
|||||||
|
|
||||||
interface ProviderFormProps {
|
interface ProviderFormProps {
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
onFormSubmit: () => void;
|
onFormSubmit: (providerUuid: string) => void | Promise<void>;
|
||||||
onFormCancel: () => void;
|
onFormCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,14 +171,16 @@ export default function ProviderForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let savedProviderUuid = providerId;
|
||||||
if (providerId) {
|
if (providerId) {
|
||||||
await httpClient.updateModelProvider(providerId, data);
|
await httpClient.updateModelProvider(providerId, data);
|
||||||
toast.success(t('models.providerSaved'));
|
toast.success(t('models.providerSaved'));
|
||||||
} else {
|
} else {
|
||||||
await httpClient.createModelProvider(data);
|
const response = await httpClient.createModelProvider(data);
|
||||||
|
savedProviderUuid = response.uuid;
|
||||||
toast.success(t('models.providerCreated'));
|
toast.success(t('models.providerCreated'));
|
||||||
}
|
}
|
||||||
onFormSubmit();
|
await onFormSubmit(savedProviderUuid as string);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
|
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function groupByCategory<T extends { categories?: string[] }>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let placed = false;
|
let placed = false;
|
||||||
for (const cat of cats) {
|
for (const cat of new Set(cats)) {
|
||||||
if (ordered.includes(cat as AdapterCategoryId)) {
|
if (ordered.includes(cat as AdapterCategoryId)) {
|
||||||
buckets.get(cat as AdapterCategoryId)!.push(item);
|
buckets.get(cat as AdapterCategoryId)!.push(item);
|
||||||
placed = true;
|
placed = true;
|
||||||
|
|||||||
@@ -363,7 +363,9 @@ export interface WizardProgress {
|
|||||||
step: number;
|
step: number;
|
||||||
selected_adapter: string | null;
|
selected_adapter: string | null;
|
||||||
created_bot_uuid: string | null;
|
created_bot_uuid: string | null;
|
||||||
|
created_pipeline_uuid?: string | null;
|
||||||
bot_saved: boolean;
|
bot_saved: boolean;
|
||||||
|
message_received?: boolean;
|
||||||
selected_runner: string | null;
|
selected_runner: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,9 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.get(`/api/v1/provider/models/llm/${uuid}`);
|
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);
|
return this.post('/api/v1/provider/models/llm', model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +463,15 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
|
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(
|
public getBotSessions(
|
||||||
botId: string,
|
botId: string,
|
||||||
limit: number = 100,
|
limit: number = 100,
|
||||||
@@ -710,11 +721,32 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAuthenticatedObjectURL(path: string): Promise<string> {
|
private async getAuthenticatedObjectURL(
|
||||||
|
path: string,
|
||||||
|
rewritePluginPageSdk = false,
|
||||||
|
): Promise<string> {
|
||||||
const response = await this.instance.get<Blob>(path, {
|
const response = await this.instance.get<Blob>(path, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
return URL.createObjectURL(response.data);
|
let blob = response.data;
|
||||||
|
if (rewritePluginPageSdk && blob.type.startsWith('text/html')) {
|
||||||
|
const apiBase =
|
||||||
|
this.instance.defaults.baseURL === '/'
|
||||||
|
? window.location.origin
|
||||||
|
: this.instance.defaults.baseURL?.replace(/\/$/, '');
|
||||||
|
const pageSdkUrl = `${apiBase}/api/v1/plugins/_sdk/page-sdk.js`;
|
||||||
|
const html = await blob.text();
|
||||||
|
blob = new Blob(
|
||||||
|
[
|
||||||
|
html.replace(
|
||||||
|
/(<script\b[^>]*\bsrc\s*=\s*)(["'])\/api\/v1\/plugins\/_sdk\/page-sdk\.js\2/gi,
|
||||||
|
`$1$2${pageSdkUrl}$2`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
{ type: blob.type },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAuthenticatedPluginAssetURL(
|
public getAuthenticatedPluginAssetURL(
|
||||||
@@ -724,6 +756,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return this.getAuthenticatedObjectURL(
|
return this.getAuthenticatedObjectURL(
|
||||||
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
|
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1046,12 +1079,21 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
step: number;
|
step: number;
|
||||||
selected_adapter: string | null;
|
selected_adapter: string | null;
|
||||||
created_bot_uuid: string | null;
|
created_bot_uuid: string | null;
|
||||||
|
created_pipeline_uuid?: string | null;
|
||||||
bot_saved: boolean;
|
bot_saved: boolean;
|
||||||
|
message_received?: boolean;
|
||||||
selected_runner: string | null;
|
selected_runner: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
return this.put('/api/v1/system/wizard/progress', progress);
|
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?: {
|
public getAsyncTasks(params?: {
|
||||||
type?: string;
|
type?: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
@@ -1181,6 +1223,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
public getAccountInfo(): Promise<{
|
public getAccountInfo(): Promise<{
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
authenticated_invitation_acceptance_enabled?: boolean;
|
authenticated_invitation_acceptance_enabled?: boolean;
|
||||||
|
invitation_registration_enabled?: boolean;
|
||||||
password_login_enabled?: boolean;
|
password_login_enabled?: boolean;
|
||||||
space_login_enabled?: boolean;
|
space_login_enabled?: boolean;
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export default function AcceptInvitationPage() {
|
|||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
const [invitationRegistrationEnabled, setInvitationRegistrationEnabled] =
|
||||||
|
useState(false);
|
||||||
|
const [invitationCapabilitiesLoaded, setInvitationCapabilitiesLoaded] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [
|
const [
|
||||||
authenticatedInvitationAcceptanceEnabled,
|
authenticatedInvitationAcceptanceEnabled,
|
||||||
@@ -116,12 +118,16 @@ export default function AcceptInvitationPage() {
|
|||||||
backendClient
|
backendClient
|
||||||
.getAccountInfo()
|
.getAccountInfo()
|
||||||
.then((info) => {
|
.then((info) => {
|
||||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
setInvitationRegistrationEnabled(
|
||||||
|
info.invitation_registration_enabled ??
|
||||||
|
info.password_login_enabled !== false,
|
||||||
|
);
|
||||||
setAuthenticatedInvitationAcceptanceEnabled(
|
setAuthenticatedInvitationAcceptanceEnabled(
|
||||||
info.authenticated_invitation_acceptance_enabled === true,
|
info.authenticated_invitation_acceptance_enabled === true,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(() => setPasswordRegistrationEnabled(false));
|
.catch(() => setInvitationRegistrationEnabled(false))
|
||||||
|
.finally(() => setInvitationCapabilitiesLoaded(true));
|
||||||
if (!invitationToken) {
|
if (!invitationToken) {
|
||||||
setErrorMessage(t('workspace.invitationMissing'));
|
setErrorMessage(t('workspace.invitationMissing'));
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -311,7 +317,11 @@ export default function AcceptInvitationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
{!invitationCapabilitiesLoaded ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="size-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={status === 'submitting'}
|
disabled={status === 'submitting'}
|
||||||
@@ -331,7 +341,7 @@ export default function AcceptInvitationPage() {
|
|||||||
{t('workspace.logoutAndReturn')}
|
{t('workspace.logoutAndReturn')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : passwordRegistrationEnabled ? (
|
) : invitationRegistrationEnabled ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
@@ -376,15 +386,19 @@ export default function AcceptInvitationPage() {
|
|||||||
>
|
>
|
||||||
{t('workspace.confirmPassword')}
|
{t('workspace.confirmPassword')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<div className="relative">
|
||||||
id="invite-password-confirm"
|
<Lock className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||||
type="password"
|
<Input
|
||||||
value={confirmPassword}
|
id="invite-password-confirm"
|
||||||
onChange={(event) =>
|
type="password"
|
||||||
setConfirmPassword(event.target.value)
|
value={confirmPassword}
|
||||||
}
|
onChange={(event) =>
|
||||||
autoComplete="new-password"
|
setConfirmPassword(event.target.value)
|
||||||
/>
|
}
|
||||||
|
className="pl-10"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -396,14 +410,6 @@ export default function AcceptInvitationPage() {
|
|||||||
)}
|
)}
|
||||||
{t('workspace.registerAndAccept')}
|
{t('workspace.registerAndAccept')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full"
|
|
||||||
disabled={status === 'submitting'}
|
|
||||||
onClick={() => navigate('/login?invitation=1')}
|
|
||||||
>
|
|
||||||
{t('workspace.alreadyHaveAccount')}
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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',
|
resaveBot: 'Re-save Configuration',
|
||||||
botSaved:
|
botSaved:
|
||||||
'Bot configuration saved and enabled. Check the logs to verify the connection.',
|
'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',
|
logsTitle: 'Bot Logs',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'Monitor bot activity to verify the platform connection is working.',
|
'Monitor bot activity to verify the platform connection is working.',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: 'Select an AI Engine',
|
title: 'Configure AI Engine',
|
||||||
description:
|
description:
|
||||||
"Choose the AI engine that will power your bot's intelligence.",
|
"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: {
|
config: {
|
||||||
botInfo: 'Bot Information',
|
botInfo: 'Bot Information',
|
||||||
|
|||||||
@@ -1697,14 +1697,80 @@ const esES = {
|
|||||||
resaveBot: 'Volver a guardar configuración',
|
resaveBot: 'Volver a guardar configuración',
|
||||||
botSaved:
|
botSaved:
|
||||||
'Configuración del Bot guardada y activada. Consulta los registros para verificar la conexión.',
|
'Configuración del Bot guardada y activada. Consulta los registros para verificar la conexión.',
|
||||||
|
waitingForMessage:
|
||||||
|
'El Bot está activado. Envíale un mensaje desde tu plataforma de mensajería para continuar.',
|
||||||
|
messageReceived:
|
||||||
|
'El Bot recibió un mensaje. Puedes continuar al siguiente paso.',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'La conexión del Bot está configurada correctamente y recibió un mensaje. Como no has iniciado sesión con una cuenta de LangBot, las llamadas al modelo pueden fallar; continúa al siguiente paso para añadir tu propio modelo.',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'El Bot de página está activado. Haz clic en la burbuja de chat de la esquina inferior derecha y envía un mensaje para verificar el flujo completo de la conversación.',
|
||||||
|
pageBotTestNotice:
|
||||||
|
'Solo para pruebas. Inserta el código en una página web externa real.',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'La URL de devolución de llamada está lista. Configúrala en la plataforma externa y envía un mensaje real al Bot.',
|
||||||
|
httpTestPrompt:
|
||||||
|
'El Bot HTTP está activado. Envía aquí un mensaje entrante real para verificar la conexión.',
|
||||||
|
httpTestDefaultMessage: 'Hola, este es un mensaje de prueba de conexión.',
|
||||||
|
sendHttpTest: 'Enviar mensaje de prueba',
|
||||||
|
httpTestAccepted:
|
||||||
|
'El mensaje de prueba fue aceptado. Aparecerá en el registro en breve.',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'Introduce un secreto de firma entrante y guarda primero la configuración.',
|
||||||
|
httpTestFailed: 'No se pudo enviar el mensaje de prueba: {{error}}',
|
||||||
logsTitle: 'Registros del Bot',
|
logsTitle: 'Registros del Bot',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'Monitorea la actividad del Bot para verificar que la conexión con la plataforma funcione.',
|
'Monitorea la actividad del Bot para verificar que la conexión con la plataforma funcione.',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: 'Selecciona un motor de IA',
|
title: 'Configura el motor de IA',
|
||||||
description:
|
description:
|
||||||
'Elige el motor de IA que impulsará la inteligencia de tu Bot.',
|
'Elige el motor de IA que impulsará la inteligencia de tu Bot.',
|
||||||
|
optionalDescription:
|
||||||
|
'Este paso es opcional. Elige cómo quieres continuar con el Agent actual.',
|
||||||
|
externalTitle: 'Conectar un Agent de una plataforma externa',
|
||||||
|
externalDescription:
|
||||||
|
'Conecta Dify, n8n, Coze u otra plataforma y sustituye el Pipeline del Bot.',
|
||||||
|
ownModelTitle: 'Usar mi propio modelo',
|
||||||
|
ownModelDescription:
|
||||||
|
'Añade un proveedor y luego escanea o introduce manualmente un modelo para completar la configuración.',
|
||||||
|
ownModelSetupTitle: 'Añade tu propio modelo',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'Añade un proveedor de modelos. Los modelos de chat se detectan automáticamente, o puedes introducir un ID de modelo manualmente.',
|
||||||
|
addProviderTitle: 'Añadir proveedor',
|
||||||
|
addProviderDescription:
|
||||||
|
'Introduce los datos del proveedor y la clave de API usados para conectar y detectar modelos.',
|
||||||
|
selectModelTitle: 'Elige un modelo',
|
||||||
|
selectScannedModelTitle: 'Elige un modelo',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'El modelo seleccionado será el modelo principal de un nuevo Pipeline y el Bot cambiará a él.',
|
||||||
|
scanModelMode: 'Detectar modelos',
|
||||||
|
manualModelMode: 'Añadir manualmente',
|
||||||
|
scanningModels: 'Detectando modelos disponibles…',
|
||||||
|
noScannedModels:
|
||||||
|
'No se encontraron modelos de chat disponibles. Revisa la configuración del proveedor.',
|
||||||
|
scanModelsFailed:
|
||||||
|
'No se pudieron detectar los modelos. Revisa la URL y la clave de API e inténtalo de nuevo.',
|
||||||
|
manualFallbackFailed:
|
||||||
|
'La detección automática falló. Introduce un ID de modelo compatible con el proveedor.',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'No se encontraron modelos. Introduce un ID de modelo compatible con el proveedor.',
|
||||||
|
manualModelId: 'ID del modelo',
|
||||||
|
manualModelIdPlaceholder: 'Por ejemplo: gpt-4o',
|
||||||
|
manualModelIdDescription:
|
||||||
|
'Introduce el parámetro de modelo utilizado en las solicitudes.',
|
||||||
|
manualModelOptions: 'Capacidades opcionales del modelo',
|
||||||
|
editProvider: 'Editar proveedor',
|
||||||
|
rescanModels: 'Volver a detectar modelos',
|
||||||
|
moreFeaturesTitle: 'Añadir más funciones al Agent',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'Abre el área de trabajo para añadir herramientas, bases de conocimiento y otras capacidades al Agent que se acaba de generar automáticamente.',
|
||||||
|
runnerDescription:
|
||||||
|
'Selecciona un Runner para el Agent externo y configura su conexión.',
|
||||||
|
backToChoices: 'Volver a las opciones',
|
||||||
|
createExternal: 'Crear y vincular',
|
||||||
|
finishWithModel: 'Usar el modelo seleccionado y finalizar',
|
||||||
|
openWorkbench: 'Abrir área de trabajo',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: 'Información del Bot',
|
botInfo: 'Información del Bot',
|
||||||
|
|||||||
@@ -1744,14 +1744,79 @@ const jaJP = {
|
|||||||
resaveBot: '設定を再保存',
|
resaveBot: '設定を再保存',
|
||||||
botSaved:
|
botSaved:
|
||||||
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
|
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
|
||||||
|
waitingForMessage:
|
||||||
|
'ボットが有効になりました。続行するには IM からメッセージを送信してください。',
|
||||||
|
messageReceived:
|
||||||
|
'ボットが IM メッセージを受信しました。次のステップに進めます。',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'ボット側の接続設定は正常で、IM メッセージを受信できています。LangBot Account でログインしていないためモデル呼び出しが失敗する場合がありますが、次のステップで独自のモデルを追加できます。',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。',
|
||||||
|
pageBotTestNotice:
|
||||||
|
'テスト専用です。実際の外部 Web ページにコードを埋め込んでください。',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'コールバック URL の準備ができました。外部プラットフォームに設定し、ボットへ実際のメッセージを送信してください。',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP Bot が有効になりました。実際の受信メッセージを送信して接続を確認できます。',
|
||||||
|
httpTestDefaultMessage: 'こんにちは。これは接続テストメッセージです。',
|
||||||
|
sendHttpTest: 'テストメッセージを送信',
|
||||||
|
httpTestAccepted:
|
||||||
|
'テストメッセージを受け付けました。まもなくログに表示されます。',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'受信署名シークレットを入力し、先に設定を保存してください。',
|
||||||
|
httpTestFailed: 'テストメッセージの送信に失敗しました:{{error}}',
|
||||||
logsTitle: 'ボットログ',
|
logsTitle: 'ボットログ',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
|
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: 'AIエンジンを選択',
|
title: 'AIエンジンを設定',
|
||||||
description:
|
description:
|
||||||
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
|
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
|
||||||
|
optionalDescription:
|
||||||
|
'このステップは任意です。現在の Agent をどのように設定するか選択してください。',
|
||||||
|
externalTitle: '外部プラットフォームの Agent を接続',
|
||||||
|
externalDescription:
|
||||||
|
'Dify、n8n、Coze などを接続し、ボットのパイプラインを置き換えます。',
|
||||||
|
ownModelTitle: '自分のモデルを使用',
|
||||||
|
ownModelDescription:
|
||||||
|
'プロバイダーを追加し、モデルをスキャンまたは手動入力して設定を完了します。',
|
||||||
|
ownModelSetupTitle: '自分のモデルを追加',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'モデルプロバイダーを追加すると自動スキャンされます。モデル ID の手動入力も可能です。',
|
||||||
|
addProviderTitle: 'プロバイダーを追加',
|
||||||
|
addProviderDescription:
|
||||||
|
'接続とモデルスキャンに使用するプロバイダー情報と API キーを入力します。',
|
||||||
|
selectModelTitle: 'モデルを選択',
|
||||||
|
selectScannedModelTitle: 'モデルを選択',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'選択したモデルを新しいパイプラインのメインモデルに設定し、ボットをそのパイプラインへ切り替えます。',
|
||||||
|
scanModelMode: 'モデルをスキャン',
|
||||||
|
manualModelMode: '手動で追加',
|
||||||
|
scanningModels: '利用可能なモデルをスキャン中…',
|
||||||
|
noScannedModels:
|
||||||
|
'利用可能なチャットモデルが見つかりません。プロバイダー設定を確認してください。',
|
||||||
|
scanModelsFailed:
|
||||||
|
'モデルのスキャンに失敗しました。URL と API キーを確認して再試行してください。',
|
||||||
|
manualFallbackFailed:
|
||||||
|
'自動スキャンに失敗しました。プロバイダーが対応するモデル ID を直接入力できます。',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'モデルが見つかりませんでした。プロバイダーが対応するモデル ID を直接入力できます。',
|
||||||
|
manualModelId: 'モデル ID',
|
||||||
|
manualModelIdPlaceholder: '例:gpt-4o',
|
||||||
|
manualModelIdDescription:
|
||||||
|
'モデルリクエストで実際に使用する model パラメーターを入力します。',
|
||||||
|
manualModelOptions: '任意のモデル機能',
|
||||||
|
editProvider: 'プロバイダーを編集',
|
||||||
|
rescanModels: 'モデルを再スキャン',
|
||||||
|
moreFeaturesTitle: 'Agent に機能を追加',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'ワークベンチを開き、自動生成されたばかりの Agent にツール、ナレッジベースなどの機能を追加します。',
|
||||||
|
runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。',
|
||||||
|
backToChoices: '選択肢に戻る',
|
||||||
|
createExternal: '作成して関連付ける',
|
||||||
|
finishWithModel: '選択したモデルを使用して完了',
|
||||||
|
openWorkbench: 'ワークベンチを開く',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: 'ボット情報',
|
botInfo: 'ボット情報',
|
||||||
|
|||||||
@@ -1666,14 +1666,81 @@ const ruRU = {
|
|||||||
resaveBot: 'Пересохранить конфигурацию',
|
resaveBot: 'Пересохранить конфигурацию',
|
||||||
botSaved:
|
botSaved:
|
||||||
'Конфигурация бота сохранена и включена. Проверьте журналы для подтверждения подключения.',
|
'Конфигурация бота сохранена и включена. Проверьте журналы для подтверждения подключения.',
|
||||||
|
waitingForMessage:
|
||||||
|
'Бот включён. Отправьте ему сообщение из мессенджера, чтобы продолжить.',
|
||||||
|
messageReceived:
|
||||||
|
'Бот получил сообщение. Можно перейти к следующему шагу.',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'Подключение бота настроено правильно, и сообщение получено. Поскольку вход выполнен не через аккаунт LangBot, вызовы модели могут завершаться ошибкой; перейдите к следующему шагу, чтобы добавить собственную модель.',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'Бот для веб-страницы включён. Нажмите на значок чата в правом нижнем углу и отправьте сообщение, чтобы проверить полный сценарий диалога.',
|
||||||
|
pageBotTestNotice:
|
||||||
|
'Только для тестирования. Встройте код в настоящую внешнюю веб-страницу.',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'URL обратного вызова готов. Настройте его на внешней платформе, затем отправьте боту настоящее сообщение.',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP-бот включён. Отправьте сюда настоящее входящее сообщение, чтобы проверить подключение.',
|
||||||
|
httpTestDefaultMessage:
|
||||||
|
'Здравствуйте, это тестовое сообщение подключения.',
|
||||||
|
sendHttpTest: 'Отправить тестовое сообщение',
|
||||||
|
httpTestAccepted:
|
||||||
|
'Тестовое сообщение принято. Оно скоро появится в журнале.',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'Введите секрет подписи входящих запросов и сначала сохраните конфигурацию.',
|
||||||
|
httpTestFailed: 'Не удалось отправить тестовое сообщение: {{error}}',
|
||||||
logsTitle: 'Журналы бота',
|
logsTitle: 'Журналы бота',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'Отслеживайте активность бота для проверки подключения к платформе.',
|
'Отслеживайте активность бота для проверки подключения к платформе.',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: 'Выберите ИИ-движок',
|
title: 'Настройте ИИ-движок',
|
||||||
description:
|
description:
|
||||||
'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.',
|
'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.',
|
||||||
|
optionalDescription:
|
||||||
|
'Этот шаг необязателен. Выберите, как продолжить настройку текущего Agent.',
|
||||||
|
externalTitle: 'Подключить Agent внешней платформы',
|
||||||
|
externalDescription:
|
||||||
|
'Подключите Dify, n8n, Coze или другую платформу и замените Pipeline бота.',
|
||||||
|
ownModelTitle: 'Использовать собственную модель',
|
||||||
|
ownModelDescription:
|
||||||
|
'Добавьте провайдера, затем найдите модель автоматически или укажите её вручную, чтобы завершить настройку.',
|
||||||
|
ownModelSetupTitle: 'Добавьте собственную модель',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'Добавьте провайдера моделей. Модели чата будут найдены автоматически, либо можно вручную указать ID модели.',
|
||||||
|
addProviderTitle: 'Добавить провайдера',
|
||||||
|
addProviderDescription:
|
||||||
|
'Введите данные провайдера и API-ключ для подключения и поиска моделей.',
|
||||||
|
selectModelTitle: 'Выберите модель',
|
||||||
|
selectScannedModelTitle: 'Выберите модель',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'Выбранная модель станет основной моделью нового Pipeline, и бот переключится на неё.',
|
||||||
|
scanModelMode: 'Найти модели',
|
||||||
|
manualModelMode: 'Добавить вручную',
|
||||||
|
scanningModels: 'Поиск доступных моделей…',
|
||||||
|
noScannedModels:
|
||||||
|
'Доступные модели чата не найдены. Проверьте конфигурацию провайдера.',
|
||||||
|
scanModelsFailed:
|
||||||
|
'Не удалось найти модели. Проверьте URL и API-ключ, затем повторите попытку.',
|
||||||
|
manualFallbackFailed:
|
||||||
|
'Автоматический поиск не удался. Введите ID модели, поддерживаемой провайдером.',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'Модели не найдены. Введите ID модели, поддерживаемой провайдером.',
|
||||||
|
manualModelId: 'ID модели',
|
||||||
|
manualModelIdPlaceholder: 'Например: gpt-4o',
|
||||||
|
manualModelIdDescription:
|
||||||
|
'Введите параметр модели, используемый в запросах к модели.',
|
||||||
|
manualModelOptions: 'Дополнительные возможности модели',
|
||||||
|
editProvider: 'Изменить провайдера',
|
||||||
|
rescanModels: 'Повторить поиск моделей',
|
||||||
|
moreFeaturesTitle: 'Добавить возможности Agent',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'Откройте рабочую панель, чтобы добавить инструменты, базы знаний и другие возможности только что автоматически созданному Agent.',
|
||||||
|
runnerDescription:
|
||||||
|
'Выберите Runner для внешнего Agent и настройте подключение.',
|
||||||
|
backToChoices: 'Вернуться к вариантам',
|
||||||
|
createExternal: 'Создать и привязать',
|
||||||
|
finishWithModel: 'Использовать выбранную модель и завершить',
|
||||||
|
openWorkbench: 'Открыть рабочую панель',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: 'Информация о боте',
|
botInfo: 'Информация о боте',
|
||||||
|
|||||||
@@ -1634,13 +1634,75 @@ const thTH = {
|
|||||||
resaveBot: 'บันทึกการกำหนดค่าอีกครั้ง',
|
resaveBot: 'บันทึกการกำหนดค่าอีกครั้ง',
|
||||||
botSaved:
|
botSaved:
|
||||||
'บันทึกและเปิดใช้งาน Bot แล้ว ตรวจสอบบันทึกเพื่อยืนยันการเชื่อมต่อ',
|
'บันทึกและเปิดใช้งาน Bot แล้ว ตรวจสอบบันทึกเพื่อยืนยันการเชื่อมต่อ',
|
||||||
|
waitingForMessage:
|
||||||
|
'เปิดใช้งาน Bot แล้ว โปรดส่งข้อความจากแพลตฟอร์มแชตเพื่อดำเนินการต่อ',
|
||||||
|
messageReceived: 'Bot ได้รับข้อความแล้ว คุณสามารถไปยังขั้นตอนถัดไปได้',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'การเชื่อมต่อฝั่ง Bot ได้รับการกำหนดค่าอย่างถูกต้องและได้รับข้อความแล้ว เนื่องจากคุณไม่ได้เข้าสู่ระบบด้วยบัญชี LangBot การเรียกใช้โมเดลอาจล้มเหลว โปรดไปยังขั้นตอนถัดไปเพื่อเพิ่มโมเดลของคุณเอง',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'เปิดใช้งาน Page Bot แล้ว คลิกฟองแชตที่มุมขวาล่างและส่งข้อความเพื่อตรวจสอบขั้นตอนการสนทนาทั้งหมด',
|
||||||
|
pageBotTestNotice:
|
||||||
|
'สำหรับการทดสอบเท่านั้น โปรดฝังโค้ดในหน้าเว็บภายนอกจริง',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'URL Callback พร้อมแล้ว โปรดกำหนดค่าบนแพลตฟอร์มภายนอก แล้วส่งข้อความจริงถึง Bot',
|
||||||
|
httpTestPrompt:
|
||||||
|
'เปิดใช้งาน HTTP Bot แล้ว ส่งข้อความขาเข้าจริงที่นี่เพื่อตรวจสอบการเชื่อมต่อ',
|
||||||
|
httpTestDefaultMessage: 'สวัสดี นี่คือข้อความทดสอบการเชื่อมต่อ',
|
||||||
|
sendHttpTest: 'ส่งข้อความทดสอบ',
|
||||||
|
httpTestAccepted: 'ระบบรับข้อความทดสอบแล้ว และจะแสดงในบันทึกในอีกสักครู่',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'โปรดกรอก Secret สำหรับลงนามข้อความขาเข้าและบันทึกการกำหนดค่าก่อน',
|
||||||
|
httpTestFailed: 'ส่งข้อความทดสอบไม่สำเร็จ: {{error}}',
|
||||||
logsTitle: 'บันทึก Bot',
|
logsTitle: 'บันทึก Bot',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'ตรวจสอบกิจกรรม Bot เพื่อยืนยันว่าการเชื่อมต่อแพลตฟอร์มทำงานอยู่',
|
'ตรวจสอบกิจกรรม Bot เพื่อยืนยันว่าการเชื่อมต่อแพลตฟอร์มทำงานอยู่',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: 'เลือกเครื่องมือ AI',
|
title: 'กำหนดค่าเครื่องมือ AI',
|
||||||
description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot',
|
description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot',
|
||||||
|
optionalDescription:
|
||||||
|
'ขั้นตอนนี้ไม่บังคับ เลือกวิธีที่คุณต้องการดำเนินการต่อกับ Agent ปัจจุบัน',
|
||||||
|
externalTitle: 'เชื่อมต่อ Agent จากแพลตฟอร์มภายนอก',
|
||||||
|
externalDescription:
|
||||||
|
'เชื่อมต่อ Dify, n8n, Coze หรือแพลตฟอร์มอื่น และแทนที่ Pipeline ของ Bot',
|
||||||
|
ownModelTitle: 'ใช้โมเดลของฉันเอง',
|
||||||
|
ownModelDescription:
|
||||||
|
'เพิ่มผู้ให้บริการ แล้วสแกนหรือกรอกโมเดลด้วยตนเองเพื่อเสร็จสิ้นการตั้งค่า',
|
||||||
|
ownModelSetupTitle: 'เพิ่มโมเดลของคุณเอง',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'เพิ่มผู้ให้บริการโมเดล ระบบจะสแกนโมเดลแชตโดยอัตโนมัติ หรือคุณสามารถกรอก ID โมเดลด้วยตนเอง',
|
||||||
|
addProviderTitle: 'เพิ่มผู้ให้บริการ',
|
||||||
|
addProviderDescription:
|
||||||
|
'กรอกรายละเอียดผู้ให้บริการและ API Key ที่ใช้เชื่อมต่อและสแกนโมเดล',
|
||||||
|
selectModelTitle: 'เลือกโมเดล',
|
||||||
|
selectScannedModelTitle: 'เลือกโมเดล',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'โมเดลที่เลือกจะเป็นโมเดลหลักของ Pipeline ใหม่ และ Bot จะเปลี่ยนไปใช้โมเดลนี้',
|
||||||
|
scanModelMode: 'สแกนโมเดล',
|
||||||
|
manualModelMode: 'เพิ่มด้วยตนเอง',
|
||||||
|
scanningModels: 'กำลังสแกนโมเดลที่พร้อมใช้งาน…',
|
||||||
|
noScannedModels:
|
||||||
|
'ไม่พบโมเดลแชตที่พร้อมใช้งาน โปรดตรวจสอบการกำหนดค่าผู้ให้บริการ',
|
||||||
|
scanModelsFailed:
|
||||||
|
'สแกนโมเดลไม่สำเร็จ โปรดตรวจสอบ URL และ API Key แล้วลองอีกครั้ง',
|
||||||
|
manualFallbackFailed:
|
||||||
|
'การสแกนอัตโนมัติไม่สำเร็จ โปรดกรอก ID โมเดลที่ผู้ให้บริการรองรับ',
|
||||||
|
manualFallbackEmpty: 'ไม่พบโมเดล โปรดกรอก ID โมเดลที่ผู้ให้บริการรองรับ',
|
||||||
|
manualModelId: 'ID โมเดล',
|
||||||
|
manualModelIdPlaceholder: 'ตัวอย่าง: gpt-4o',
|
||||||
|
manualModelIdDescription: 'กรอกพารามิเตอร์โมเดลที่ใช้ในคำขอโมเดล',
|
||||||
|
manualModelOptions: 'ความสามารถเพิ่มเติมของโมเดล',
|
||||||
|
editProvider: 'แก้ไขผู้ให้บริการ',
|
||||||
|
rescanModels: 'สแกนโมเดลอีกครั้ง',
|
||||||
|
moreFeaturesTitle: 'เพิ่มความสามารถให้ Agent',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'เปิดหน้าทำงานเพื่อเพิ่มเครื่องมือ ฐานความรู้ และความสามารถอื่น ๆ ให้ Agent ที่เพิ่งสร้างขึ้นโดยอัตโนมัติ',
|
||||||
|
runnerDescription:
|
||||||
|
'เลือก Runner สำหรับ Agent ภายนอกและกำหนดค่าการเชื่อมต่อ',
|
||||||
|
backToChoices: 'กลับไปยังตัวเลือก',
|
||||||
|
createExternal: 'สร้างและผูก',
|
||||||
|
finishWithModel: 'ใช้โมเดลที่เลือกและเสร็จสิ้น',
|
||||||
|
openWorkbench: 'เปิดหน้าทำงาน',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: 'ข้อมูล Bot',
|
botInfo: 'ข้อมูล Bot',
|
||||||
|
|||||||
@@ -1658,13 +1658,78 @@ const viVN = {
|
|||||||
resaveBot: 'Lưu lại cấu hình',
|
resaveBot: 'Lưu lại cấu hình',
|
||||||
botSaved:
|
botSaved:
|
||||||
'Cấu hình Bot đã lưu và bật. Kiểm tra nhật ký để xác minh kết nối.',
|
'Cấu hình Bot đã lưu và bật. Kiểm tra nhật ký để xác minh kết nối.',
|
||||||
|
waitingForMessage:
|
||||||
|
'Bot đã được bật. Hãy gửi cho Bot một tin nhắn từ nền tảng nhắn tin để tiếp tục.',
|
||||||
|
messageReceived:
|
||||||
|
'Bot đã nhận được tin nhắn. Bạn có thể tiếp tục sang bước tiếp theo.',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'Kết nối phía Bot đã được cấu hình đúng và đã nhận được tin nhắn. Vì bạn không đăng nhập bằng tài khoản LangBot, lệnh gọi mô hình có thể thất bại; hãy tiếp tục sang bước tiếp theo để thêm mô hình của riêng bạn.',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'Page Bot đã được bật. Nhấp vào bong bóng trò chuyện ở góc dưới bên phải và gửi tin nhắn để xác minh toàn bộ luồng hội thoại.',
|
||||||
|
pageBotTestNotice:
|
||||||
|
'Chỉ dùng để kiểm thử. Hãy nhúng mã vào một trang web bên ngoài thực tế.',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'URL callback đã sẵn sàng. Hãy cấu hình URL này trên nền tảng bên ngoài, sau đó gửi một tin nhắn thực cho Bot.',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP Bot đã được bật. Gửi một tin nhắn đến thực tế tại đây để xác minh kết nối.',
|
||||||
|
httpTestDefaultMessage: 'Xin chào, đây là tin nhắn kiểm tra kết nối.',
|
||||||
|
sendHttpTest: 'Gửi tin nhắn kiểm tra',
|
||||||
|
httpTestAccepted:
|
||||||
|
'Tin nhắn kiểm tra đã được chấp nhận và sẽ sớm xuất hiện trong nhật ký.',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'Hãy nhập khóa bí mật ký yêu cầu đến và lưu cấu hình trước.',
|
||||||
|
httpTestFailed: 'Không thể gửi tin nhắn kiểm tra: {{error}}',
|
||||||
logsTitle: 'Nhật ký Bot',
|
logsTitle: 'Nhật ký Bot',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'Giám sát hoạt động Bot để xác minh kết nối nền tảng đang hoạt động.',
|
'Giám sát hoạt động Bot để xác minh kết nối nền tảng đang hoạt động.',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: 'Chọn công cụ AI',
|
title: 'Cấu hình công cụ AI',
|
||||||
description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.',
|
description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.',
|
||||||
|
optionalDescription:
|
||||||
|
'Bước này không bắt buộc. Hãy chọn cách bạn muốn tiếp tục với Agent hiện tại.',
|
||||||
|
externalTitle: 'Kết nối Agent từ nền tảng bên ngoài',
|
||||||
|
externalDescription:
|
||||||
|
'Kết nối Dify, n8n, Coze hoặc nền tảng khác và thay thế Pipeline của Bot.',
|
||||||
|
ownModelTitle: 'Sử dụng mô hình của riêng tôi',
|
||||||
|
ownModelDescription:
|
||||||
|
'Thêm nhà cung cấp, sau đó quét hoặc nhập mô hình thủ công để hoàn tất thiết lập.',
|
||||||
|
ownModelSetupTitle: 'Thêm mô hình của riêng bạn',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'Thêm nhà cung cấp mô hình. Các mô hình trò chuyện sẽ được quét tự động, hoặc bạn có thể nhập ID mô hình thủ công.',
|
||||||
|
addProviderTitle: 'Thêm nhà cung cấp',
|
||||||
|
addProviderDescription:
|
||||||
|
'Nhập thông tin nhà cung cấp và API Key dùng để kết nối và quét mô hình.',
|
||||||
|
selectModelTitle: 'Chọn mô hình',
|
||||||
|
selectScannedModelTitle: 'Chọn mô hình',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'Mô hình đã chọn sẽ là mô hình chính của Pipeline mới và Bot sẽ chuyển sang sử dụng mô hình đó.',
|
||||||
|
scanModelMode: 'Quét mô hình',
|
||||||
|
manualModelMode: 'Thêm thủ công',
|
||||||
|
scanningModels: 'Đang quét các mô hình khả dụng…',
|
||||||
|
noScannedModels:
|
||||||
|
'Không tìm thấy mô hình trò chuyện khả dụng. Hãy kiểm tra cấu hình nhà cung cấp.',
|
||||||
|
scanModelsFailed:
|
||||||
|
'Quét mô hình thất bại. Hãy kiểm tra URL và API Key rồi thử lại.',
|
||||||
|
manualFallbackFailed:
|
||||||
|
'Quét tự động thất bại. Hãy nhập ID mô hình được nhà cung cấp hỗ trợ.',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'Không tìm thấy mô hình. Hãy nhập ID mô hình được nhà cung cấp hỗ trợ.',
|
||||||
|
manualModelId: 'ID mô hình',
|
||||||
|
manualModelIdPlaceholder: 'Ví dụ: gpt-4o',
|
||||||
|
manualModelIdDescription:
|
||||||
|
'Nhập tham số mô hình được sử dụng trong các yêu cầu mô hình.',
|
||||||
|
manualModelOptions: 'Khả năng mô hình tùy chọn',
|
||||||
|
editProvider: 'Chỉnh sửa nhà cung cấp',
|
||||||
|
rescanModels: 'Quét lại mô hình',
|
||||||
|
moreFeaturesTitle: 'Thêm khả năng cho Agent',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'Mở bàn làm việc để thêm công cụ, cơ sở tri thức và các khả năng khác cho Agent vừa được tạo tự động.',
|
||||||
|
runnerDescription: 'Chọn Runner cho Agent bên ngoài và cấu hình kết nối.',
|
||||||
|
backToChoices: 'Quay lại các tùy chọn',
|
||||||
|
createExternal: 'Tạo và liên kết',
|
||||||
|
finishWithModel: 'Sử dụng mô hình đã chọn và hoàn tất',
|
||||||
|
openWorkbench: 'Mở bàn làm việc',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: 'Thông tin Bot',
|
botInfo: 'Thông tin Bot',
|
||||||
|
|||||||
@@ -1749,12 +1749,65 @@ const zhHans = {
|
|||||||
saveBot: '保存并启用',
|
saveBot: '保存并启用',
|
||||||
resaveBot: '重新保存配置',
|
resaveBot: '重新保存配置',
|
||||||
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
|
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
|
||||||
|
waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。',
|
||||||
|
messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'机器人侧已配置正常并成功收到 IM 消息。当前未通过 LangBot Account 登录,模型调用可能报错;可以进入下一步添加自己的模型。',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'页面机器人已启用。点击右下角聊天气泡并发送一条消息,验证完整对话链路。',
|
||||||
|
pageBotTestNotice: '仅供测试使用,请嵌入代码到真实外部网页。',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'回调地址已就绪。将它配置到外部平台,然后向机器人发送一条真实消息。',
|
||||||
|
httpTestPrompt: 'HTTP Bot 已启用。可直接发送一条真实入站消息验证连接。',
|
||||||
|
httpTestDefaultMessage: '你好,这是一条连接测试消息。',
|
||||||
|
sendHttpTest: '发送测试消息',
|
||||||
|
httpTestAccepted: '测试消息已被机器人接收,请稍候查看日志。',
|
||||||
|
httpTestMissingSecret: '请先填写入站签名密钥并重新保存。',
|
||||||
|
httpTestFailed: '测试消息发送失败:{{error}}',
|
||||||
logsTitle: '机器人日志',
|
logsTitle: '机器人日志',
|
||||||
logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
|
logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: '选择 AI 引擎',
|
title: '配置 AI 引擎',
|
||||||
description: '选择驱动机器人智能的 AI 引擎。',
|
description: '选择驱动机器人智能的 AI 引擎。',
|
||||||
|
optionalDescription: '这一步可选。选择接下来要如何完善当前 Agent。',
|
||||||
|
externalTitle: '接入外部平台 Agent',
|
||||||
|
externalDescription:
|
||||||
|
'接入 Dify、n8n、Coze 等平台,并替换当前机器人的流水线。',
|
||||||
|
ownModelTitle: '改成使用自己的模型',
|
||||||
|
ownModelDescription:
|
||||||
|
'添加模型供应商,自动扫描或手动填写模型以快速完成引导。',
|
||||||
|
ownModelSetupTitle: '添加你自己的模型',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'先添加模型供应商,保存后会自动扫描,也可以手动填写模型 ID。',
|
||||||
|
addProviderTitle: '添加供应商',
|
||||||
|
addProviderDescription: '填写供应商和 API Key,用于连接并扫描模型。',
|
||||||
|
selectModelTitle: '选择模型',
|
||||||
|
selectScannedModelTitle: '选择一个模型',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'选中的模型将作为新流水线的主模型,机器人会切换到这条流水线。',
|
||||||
|
scanModelMode: '扫描模型',
|
||||||
|
manualModelMode: '手动添加',
|
||||||
|
scanningModels: '正在扫描可用模型…',
|
||||||
|
noScannedModels: '没有扫描到可用的对话模型,请检查供应商配置。',
|
||||||
|
scanModelsFailed: '模型扫描失败,请检查地址和 API Key 后重试。',
|
||||||
|
manualFallbackFailed: '自动扫描失败,你可以直接填写中转站支持的模型 ID。',
|
||||||
|
manualFallbackEmpty:
|
||||||
|
'没有扫描到可用模型,你可以直接填写中转站支持的模型 ID。',
|
||||||
|
manualModelId: '模型 ID',
|
||||||
|
manualModelIdPlaceholder: '例如:gpt-4o',
|
||||||
|
manualModelIdDescription: '填写模型请求中实际使用的 model 参数。',
|
||||||
|
manualModelOptions: '可选模型能力',
|
||||||
|
editProvider: '修改供应商',
|
||||||
|
rescanModels: '重新扫描模型',
|
||||||
|
moreFeaturesTitle: '给现在的 Agent 配置更多功能',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'进入工作台,为刚刚自动生成的 Agent 添加工具、知识库等能力',
|
||||||
|
runnerDescription: '选择外部 Agent 的 Runner 并完成连接配置。',
|
||||||
|
backToChoices: '返回选项',
|
||||||
|
createExternal: '创建并绑定',
|
||||||
|
finishWithModel: '使用所选模型并完成',
|
||||||
|
openWorkbench: '进入工作台',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: '机器人信息',
|
botInfo: '机器人信息',
|
||||||
|
|||||||
@@ -1584,12 +1584,65 @@ const zhHant = {
|
|||||||
saveBot: '儲存並啟用',
|
saveBot: '儲存並啟用',
|
||||||
resaveBot: '重新儲存配置',
|
resaveBot: '重新儲存配置',
|
||||||
botSaved: '機器人配置已儲存並啟用,請查看日誌確認連接正常。',
|
botSaved: '機器人配置已儲存並啟用,請查看日誌確認連接正常。',
|
||||||
|
waitingForMessage: '機器人已啟用。請在 IM 中向機器人傳送一則訊息以繼續。',
|
||||||
|
messageReceived: '機器人已成功收到 IM 訊息,可以進入下一步。',
|
||||||
|
messageReceivedLocalAccountWarning:
|
||||||
|
'機器人側已配置正常並成功收到 IM 訊息。目前未透過 LangBot Account 登入,模型呼叫可能報錯;可以進入下一步新增自己的模型。',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'頁面機器人已啟用。點擊右下角聊天氣泡並傳送一則訊息,驗證完整對話流程。',
|
||||||
|
pageBotTestNotice: '僅供測試使用,請將程式碼嵌入真實的外部網頁。',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'回呼 URL 已準備好。請在外部平台完成配置,然後向機器人傳送一則真實訊息。',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP 機器人已啟用。請在此傳送一則真實的入站訊息以驗證連接。',
|
||||||
|
httpTestDefaultMessage: '你好,這是一則連接測試訊息。',
|
||||||
|
sendHttpTest: '傳送測試訊息',
|
||||||
|
httpTestAccepted: '測試訊息已被接受,稍後將顯示在日誌中。',
|
||||||
|
httpTestMissingSecret: '請先填寫入站簽章密鑰並儲存配置。',
|
||||||
|
httpTestFailed: '傳送測試訊息失敗:{{error}}',
|
||||||
logsTitle: '機器人日誌',
|
logsTitle: '機器人日誌',
|
||||||
logsDescription: '監控機器人活動,確認平台連接是否正常運作。',
|
logsDescription: '監控機器人活動,確認平台連接是否正常運作。',
|
||||||
},
|
},
|
||||||
aiEngine: {
|
aiEngine: {
|
||||||
title: '選擇 AI 引擎',
|
title: '配置 AI 引擎',
|
||||||
description: '選擇驅動機器人智慧的 AI 引擎。',
|
description: '選擇驅動機器人智慧的 AI 引擎。',
|
||||||
|
optionalDescription: '這一步可選。選擇接下來要如何完善目前的 Agent。',
|
||||||
|
externalTitle: '接入外部平台 Agent',
|
||||||
|
externalDescription:
|
||||||
|
'接入 Dify、n8n、Coze 等平台,並替換目前機器人的流水線。',
|
||||||
|
ownModelTitle: '改成使用自己的模型',
|
||||||
|
ownModelDescription:
|
||||||
|
'新增模型供應商,自動掃描或手動填寫模型以快速完成引導。',
|
||||||
|
ownModelSetupTitle: '新增你自己的模型',
|
||||||
|
ownModelSetupDescription:
|
||||||
|
'先新增模型供應商,儲存後會自動掃描,也可以手動填寫模型 ID。',
|
||||||
|
addProviderTitle: '新增供應商',
|
||||||
|
addProviderDescription: '填寫供應商和 API Key,用於連接並掃描模型。',
|
||||||
|
selectModelTitle: '選擇模型',
|
||||||
|
selectScannedModelTitle: '選擇模型',
|
||||||
|
selectScannedModelDescription:
|
||||||
|
'所選模型將成為新流水線的主要模型,機器人也會切換到該流水線。',
|
||||||
|
scanModelMode: '自動掃描',
|
||||||
|
manualModelMode: '手動新增',
|
||||||
|
scanningModels: '正在掃描可用模型…',
|
||||||
|
noScannedModels: '未找到可用的對話模型,請檢查供應商配置。',
|
||||||
|
scanModelsFailed: '掃描模型失敗,請檢查請求 URL 和 API Key 後重試。',
|
||||||
|
manualFallbackFailed: '自動掃描失敗,請填寫該供應商支援的模型 ID。',
|
||||||
|
manualFallbackEmpty: '未掃描到模型,請填寫該供應商支援的模型 ID。',
|
||||||
|
manualModelId: '模型 ID',
|
||||||
|
manualModelIdPlaceholder: '例如:gpt-4o',
|
||||||
|
manualModelIdDescription: '填寫模型請求中使用的 model 參數。',
|
||||||
|
manualModelOptions: '模型能力(可選)',
|
||||||
|
editProvider: '編輯供應商',
|
||||||
|
rescanModels: '重新掃描模型',
|
||||||
|
moreFeaturesTitle: '給目前的 Agent 配置更多功能',
|
||||||
|
moreFeaturesDescription:
|
||||||
|
'進入工作台,為剛剛自動產生的 Agent 新增工具、知識庫等能力',
|
||||||
|
runnerDescription: '選擇外部 Agent 的 Runner 並完成連接配置。',
|
||||||
|
backToChoices: '返回選項',
|
||||||
|
createExternal: '建立並關聯',
|
||||||
|
finishWithModel: '使用所選模型並完成',
|
||||||
|
openWorkbench: '進入工作台',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: '機器人資訊',
|
botInfo: '機器人資訊',
|
||||||
|
|||||||
@@ -503,6 +503,8 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
|||||||
if (path === '/api/v1/user/account-info') {
|
if (path === '/api/v1/user/account-info') {
|
||||||
return fulfillJson(route, {
|
return fulfillJson(route, {
|
||||||
initialized: true,
|
initialized: true,
|
||||||
|
authenticated_invitation_acceptance_enabled: false,
|
||||||
|
invitation_registration_enabled: true,
|
||||||
password_login_enabled: true,
|
password_login_enabled: true,
|
||||||
space_login_enabled: false,
|
space_login_enabled: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ test('login preserves an explicit invitation email mismatch error', async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/invitations/accept#token=mismatch-invitation');
|
await page.goto('/invitations/accept#token=mismatch-invitation');
|
||||||
await page.getByRole('button', { name: 'I already have an account' }).click();
|
await page.goto('/login?invitation=1');
|
||||||
await page.getByPlaceholder('Enter email address').fill('other@example.com');
|
await page.getByPlaceholder('Enter email address').fill('other@example.com');
|
||||||
await page.getByPlaceholder('Enter password').fill('password');
|
await page.getByPlaceholder('Enter password').fill('password');
|
||||||
await page.getByRole('button', { name: 'Login with password' }).click();
|
await page.getByRole('button', { name: 'Login with password' }).click();
|
||||||
@@ -107,6 +107,111 @@ test('login preserves an explicit invitation email mismatch error', async ({
|
|||||||
await expect(page.getByText('Login successful')).toHaveCount(0);
|
await expect(page.getByText('Login successful')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an OAuth-only OSS instance registers the invited email with a local password', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let registration: { email?: string; password?: string } | undefined;
|
||||||
|
await installLangBotApiMocks(page, { authenticated: false });
|
||||||
|
await page.route('**/api/v1/user/account-info', async (route) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
initialized: true,
|
||||||
|
authenticated_invitation_acceptance_enabled: false,
|
||||||
|
invitation_registration_enabled: true,
|
||||||
|
password_login_enabled: false,
|
||||||
|
space_login_enabled: true,
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
invitation: {
|
||||||
|
uuid: 'oss-local-registration',
|
||||||
|
workspace_uuid: 'workspace-playwright',
|
||||||
|
normalized_email: 'invited@example.com',
|
||||||
|
role: 'viewer',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
uuid: 'workspace-playwright',
|
||||||
|
name: 'Playwright Workspace',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||||
|
const body = JSON.parse(route.request().postData() || '{}') as {
|
||||||
|
registration?: { email?: string; password?: string };
|
||||||
|
};
|
||||||
|
registration = body.registration;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
login_required: true,
|
||||||
|
workspace_uuid: 'workspace-playwright',
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/invitations/accept#token=oss-local-registration');
|
||||||
|
|
||||||
|
await expect(page.getByText('Playwright Workspace')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Login with LangBot Account' }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(page.locator('#invite-email')).toHaveValue(
|
||||||
|
'invited@example.com',
|
||||||
|
);
|
||||||
|
await expect(page.locator('#invite-email')).toHaveAttribute('readonly', '');
|
||||||
|
await expect(page.locator('#invite-password')).toBeVisible();
|
||||||
|
await expect(page.locator('#invite-password-confirm')).toBeVisible();
|
||||||
|
for (const inputId of ['invite-password', 'invite-password-confirm']) {
|
||||||
|
const input = page.locator(`#${inputId}`);
|
||||||
|
const field = input.locator('xpath=..');
|
||||||
|
await expect(field).toHaveClass(/relative/);
|
||||||
|
await expect(field.locator('svg')).toBeVisible();
|
||||||
|
await expect(input).toHaveClass(/pl-10/);
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Create account and accept' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'I already have an account' }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Login with LangBot Account' }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('#invite-password').fill('invite-password-123');
|
||||||
|
await page.locator('#invite-password-confirm').fill('invite-password-123');
|
||||||
|
await page.getByRole('button', { name: 'Create account and accept' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login\?invitation=1$/);
|
||||||
|
expect(registration).toEqual({
|
||||||
|
email: 'invited@example.com',
|
||||||
|
password: 'invite-password-123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('an authenticated OSS invitation requires logout before registration', async ({
|
test('an authenticated OSS invitation requires logout before registration', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -185,6 +290,7 @@ test('an authenticated Cloud Account can accept its invitation directly', async
|
|||||||
data: {
|
data: {
|
||||||
initialized: true,
|
initialized: true,
|
||||||
authenticated_invitation_acceptance_enabled: true,
|
authenticated_invitation_acceptance_enabled: true,
|
||||||
|
invitation_registration_enabled: false,
|
||||||
password_login_enabled: false,
|
password_login_enabled: false,
|
||||||
space_login_enabled: true,
|
space_login_enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
test('an OSS local-only owner is prompted to bind before using LangBot Models', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
|
await page.route('**/api/v1/user/space-credits', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
credits: null,
|
||||||
|
owner_space_bound: false,
|
||||||
|
is_workspace_owner: true,
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/provider/providers', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
uuid: 'langbot-models-provider',
|
||||||
|
name: 'LangBot Models',
|
||||||
|
requester: 'space-chat-completions',
|
||||||
|
base_url: '',
|
||||||
|
api_keys: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
msg: 'ok',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/provider/requesters**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ code: 0, data: { requesters: [] }, msg: 'ok' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/provider/models/**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ code: 0, data: { models: [] }, msg: 'ok' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/home?action=showModelSettings');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', {
|
||||||
|
name: 'The Workspace owner must connect a LangBot Account for LangBot Models.',
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
@@ -69,7 +69,55 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
|
|||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'text/html',
|
contentType: 'text/html',
|
||||||
body: '<!doctype html><html><body><h1>LangRAG Observability</h1></body></html>',
|
body: `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>LangRAG Observability</h1>
|
||||||
|
<button id="save">Save</button>
|
||||||
|
<script src="/api/v1/plugins/_sdk/page-sdk.js"></script>
|
||||||
|
<script>
|
||||||
|
document.querySelector('#save').addEventListener('click', async () => {
|
||||||
|
await window.langbot.api('/settings', { enabled: true }, 'POST');
|
||||||
|
document.body.dataset.saved = 'true';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let pageSdkRequests = 0;
|
||||||
|
await page.route('**/api/v1/plugins/_sdk/page-sdk.js', async (route) => {
|
||||||
|
pageSdkRequests += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/javascript',
|
||||||
|
body: `window.langbot = {
|
||||||
|
api(endpoint, body, method) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const requestId = 'request-' + Date.now();
|
||||||
|
const handler = (event) => {
|
||||||
|
if (event.data?.type === 'langbot:api:response' && event.data.requestId === requestId) {
|
||||||
|
window.removeEventListener('message', handler);
|
||||||
|
resolve(event.data.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', handler);
|
||||||
|
window.parent.postMessage({ type: 'langbot:api', requestId, endpoint, body, method }, '*');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let pageApiRequests = 0;
|
||||||
|
await page.route(
|
||||||
|
'**/api/v1/plugins/langbot-team/LangRAG/page-api',
|
||||||
|
async (route) => {
|
||||||
|
pageApiRequests += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: wrapped({ saved: true }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -78,11 +126,17 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
|
|||||||
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
|
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pluginFrame = page.frameLocator('iframe');
|
||||||
await expect(
|
await expect(
|
||||||
page
|
pluginFrame.getByRole('heading', { name: 'LangRAG Observability' }),
|
||||||
.frameLocator('iframe')
|
|
||||||
.getByRole('heading', { name: 'LangRAG Observability' }),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await pluginFrame.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await expect(pluginFrame.locator('body')).toHaveAttribute(
|
||||||
|
'data-saved',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
expect(authenticatedAssetRequests).toBeGreaterThan(0);
|
expect(authenticatedAssetRequests).toBeGreaterThan(0);
|
||||||
|
expect(pageSdkRequests).toBe(1);
|
||||||
|
expect(pageApiRequests).toBe(1);
|
||||||
await expect(page.getByText('Loading...')).toHaveCount(0);
|
await expect(page.getByText('Loading...')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import ts from 'typescript';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sourcePath = path.resolve(
|
||||||
|
currentDirectory,
|
||||||
|
'../../src/app/infra/entities/adapter-categories.ts',
|
||||||
|
);
|
||||||
|
|
||||||
|
function loadCategoryHelpers(language = 'zh-Hans') {
|
||||||
|
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
esModuleInterop: true,
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
target: ts.ScriptTarget.ES2020,
|
||||||
|
},
|
||||||
|
}).outputText;
|
||||||
|
const loadedModule = { exports: {} };
|
||||||
|
new Function('require', 'module', 'exports', compiled)(
|
||||||
|
(name) => {
|
||||||
|
if (name === 'i18next') return { language };
|
||||||
|
throw new Error(`Unexpected runtime import: ${name}`);
|
||||||
|
},
|
||||||
|
loadedModule,
|
||||||
|
loadedModule.exports,
|
||||||
|
);
|
||||||
|
return loadedModule.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('places an adapter only once when metadata repeats a category', () => {
|
||||||
|
const { groupByCategory } = loadCategoryHelpers();
|
||||||
|
const adapter = { name: 'http_bot', categories: ['popular', 'popular'] };
|
||||||
|
|
||||||
|
assert.deepEqual(groupByCategory([adapter]), [
|
||||||
|
{ categoryId: 'popular', items: [adapter] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -24,11 +24,11 @@ function loadNormalizer() {
|
|||||||
loadedModule,
|
loadedModule,
|
||||||
loadedModule.exports,
|
loadedModule.exports,
|
||||||
);
|
);
|
||||||
return loadedModule.exports.normalizeDynamicFormValuesForSave;
|
return loadedModule.exports;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('normalizes only single-line text fields in a dynamic form save snapshot', () => {
|
test('normalizes only single-line text fields in a dynamic form save snapshot', () => {
|
||||||
const normalizeDynamicFormValuesForSave = loadNormalizer();
|
const { normalizeDynamicFormValuesForSave } = loadNormalizer();
|
||||||
const specs = [
|
const specs = [
|
||||||
{ name: 'single-line', type: 'string', default: '' },
|
{ name: 'single-line', type: 'string', default: '' },
|
||||||
{ name: 'multiline', type: 'text', default: '' },
|
{ name: 'multiline', type: 'text', default: '' },
|
||||||
@@ -70,3 +70,29 @@ test('normalizes only single-line text fields in a dynamic form save snapshot',
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes missing dynamic form defaults into controlled values', () => {
|
||||||
|
const { normalizeDynamicFormFieldValue } = loadNormalizer();
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
normalizeDynamicFormFieldValue(
|
||||||
|
{ name: 'api-key', type: 'string', default: undefined },
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
normalizeDynamicFormFieldValue(
|
||||||
|
{ name: 'enabled', type: 'boolean', default: undefined },
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeDynamicFormFieldValue(
|
||||||
|
{ name: 'items', type: 'array[string]', default: undefined },
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import ts from 'typescript';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sourcePath = path.resolve(
|
||||||
|
currentDirectory,
|
||||||
|
'../../src/app/wizard/utils.ts',
|
||||||
|
);
|
||||||
|
|
||||||
|
function loadWizardUtils() {
|
||||||
|
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||||
|
}).outputText;
|
||||||
|
const loadedModule = { exports: {} };
|
||||||
|
new Function('require', 'module', 'exports', compiled)(
|
||||||
|
() => {
|
||||||
|
throw new Error('Wizard utils must not have runtime imports');
|
||||||
|
},
|
||||||
|
loadedModule,
|
||||||
|
loadedModule.exports,
|
||||||
|
);
|
||||||
|
return loadedModule.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
configureLocalAgentPrimaryModel,
|
||||||
|
ensureHttpBotSigningSecret,
|
||||||
|
findDefaultPipeline,
|
||||||
|
getErrorMessage,
|
||||||
|
isRequiredRunnerConfigComplete,
|
||||||
|
isWebhookModeEnabled,
|
||||||
|
} = loadWizardUtils();
|
||||||
|
|
||||||
|
test('generates an HTTP Bot signing secret when signatures are enabled', () => {
|
||||||
|
const config = ensureHttpBotSigningSecret('http_bot', {
|
||||||
|
signature_required: true,
|
||||||
|
inbound_secret: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(config.inbound_secret, /^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves existing or intentionally disabled HTTP Bot signing config', () => {
|
||||||
|
const existing = { signature_required: true, inbound_secret: 'keep-me' };
|
||||||
|
const disabled = { signature_required: false, inbound_secret: '' };
|
||||||
|
|
||||||
|
assert.equal(ensureHttpBotSigningSecret('http_bot', existing), existing);
|
||||||
|
assert.equal(ensureHttpBotSigningSecret('http_bot', disabled), disabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not add signing config to other adapters', () => {
|
||||||
|
const config = {};
|
||||||
|
|
||||||
|
assert.equal(ensureHttpBotSigningSecret('web_page_bot', config), config);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts the backend message from structured API errors', () => {
|
||||||
|
assert.equal(
|
||||||
|
getErrorMessage({ code: 400, msg: 'Signing secret is required' }),
|
||||||
|
'Signing secret is required',
|
||||||
|
);
|
||||||
|
assert.equal(getErrorMessage(new Error('Network failed')), 'Network failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selects only a usable Workspace default pipeline', () => {
|
||||||
|
const pipelines = [
|
||||||
|
{ uuid: 'recent-pipeline', is_default: false },
|
||||||
|
{ uuid: '', is_default: true },
|
||||||
|
{ uuid: 'default-pipeline', is_default: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(findDefaultPipeline(pipelines)?.uuid, 'default-pipeline');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configures the selected model as the Local Agent primary model', () => {
|
||||||
|
const config = {
|
||||||
|
trigger: { prefix: '!' },
|
||||||
|
ai: {
|
||||||
|
runner: { runner: 'plugin:external', timeout: 30 },
|
||||||
|
'local-agent': {
|
||||||
|
model: { primary: 'old-model', fallbacks: ['fallback-model'] },
|
||||||
|
tools: { enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = configureLocalAgentPrimaryModel(config, 'selected-model');
|
||||||
|
|
||||||
|
assert.equal(updated.ai.runner.runner, 'local-agent');
|
||||||
|
assert.equal(updated.ai.runner.timeout, 30);
|
||||||
|
assert.equal(updated.ai['local-agent'].model.primary, 'selected-model');
|
||||||
|
assert.deepEqual(updated.ai['local-agent'].model.fallbacks, [
|
||||||
|
'fallback-model',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(updated.ai['local-agent'].tools, { enabled: true });
|
||||||
|
assert.deepEqual(updated.trigger, { prefix: '!' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows webhook guidance only when the adapter webhook mode is active', () => {
|
||||||
|
const dualModeFields = [
|
||||||
|
{
|
||||||
|
name: 'webhook_url',
|
||||||
|
show_if: { field: 'enable-webhook', operator: 'eq', value: true },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isWebhookModeEnabled(dualModeFields, { 'enable-webhook': false }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isWebhookModeEnabled(dualModeFields, { 'enable-webhook': true }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(isWebhookModeEnabled([{ name: 'webhook_url' }], {}), true);
|
||||||
|
assert.equal(isWebhookModeEnabled([], {}), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires real values for required external runner configuration', () => {
|
||||||
|
const fields = [
|
||||||
|
{ name: 'base-url', required: true, default: 'https://api.dify.ai/v1' },
|
||||||
|
{ name: 'api-key', required: true, default: 'your-api-key' },
|
||||||
|
{ name: 'optional', required: false, default: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isRequiredRunnerConfigComplete(fields, {
|
||||||
|
'base-url': 'https://api.dify.ai/v1',
|
||||||
|
'api-key': 'your-api-key',
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isRequiredRunnerConfigComplete(fields, {
|
||||||
|
'base-url': 'https://api.dify.ai/v1',
|
||||||
|
'api-key': 'app-real-key',
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const wizardSource = fs.readFileSync(
|
||||||
|
path.resolve(currentDirectory, '../../src/app/wizard/page.tsx'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const ownModelSetupSource = fs.readFileSync(
|
||||||
|
path.resolve(
|
||||||
|
currentDirectory,
|
||||||
|
'../../src/app/wizard/components/OwnModelSetup.tsx',
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const widgetSource = fs.readFileSync(
|
||||||
|
path.resolve(
|
||||||
|
currentDirectory,
|
||||||
|
'../../../src/langbot/templates/embed/widget.js',
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('shows the test-only notice only when the wizard opts in', () => {
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/widget\.js\?preview=wizard&v=\$\{Date\.now\(\)\}/,
|
||||||
|
);
|
||||||
|
assert.match(wizardSource, /script\.dataset\.testNotice = testNotice/);
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/testNotice=\{t\('wizard\.botConfig\.pageBotTestNotice'\)\}/,
|
||||||
|
);
|
||||||
|
assert.match(widgetSource, /getAttribute\("data-test-notice"\)/);
|
||||||
|
assert.match(widgetSource, /if \(scriptTestNotice\)/);
|
||||||
|
assert.match(widgetSource, /testNotice\.textContent = scriptTestNotice/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults the AI engine step to the workbench option and lists it first', () => {
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/const \[aiChoice, setAiChoice\] = useState<[\s\S]*?>\('more-features'\);/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const choicesStart = wizardSource.indexOf('const choices = [');
|
||||||
|
const moreFeaturesChoice = wizardSource.indexOf(
|
||||||
|
"id: 'more-features' as const",
|
||||||
|
choicesStart,
|
||||||
|
);
|
||||||
|
const externalChoice = wizardSource.indexOf(
|
||||||
|
"id: 'external' as const",
|
||||||
|
choicesStart,
|
||||||
|
);
|
||||||
|
const ownModelChoice = wizardSource.indexOf(
|
||||||
|
"id: 'own-model' as const",
|
||||||
|
choicesStart,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(choicesStart >= 0);
|
||||||
|
assert.ok(moreFeaturesChoice > choicesStart);
|
||||||
|
assert.ok(moreFeaturesChoice < externalChoice);
|
||||||
|
assert.ok(moreFeaturesChoice < ownModelChoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the external-runner layout only while that configuration is open', () => {
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/currentStep === 2 && aiChoice === 'external' && selectedRunner/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restores the default workbench choice when leaving a nested AI setup', () => {
|
||||||
|
assert.equal(
|
||||||
|
wizardSource.match(/onChoiceChange\('more-features'\)/g)?.length,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warns local-account users after the bot receives an IM message', () => {
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/messageReceived && userInfo\?\.account_type !== 'space'/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/wizard\.botConfig\.messageReceivedLocalAccountWarning/,
|
||||||
|
);
|
||||||
|
assert.match(wizardSource, /<AlertTriangle className="size-3 text-white"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('animates AI engine sub-pages and the return to choices', () => {
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/key="ai-engine-own-model"[\s\S]*?slide-in-from-right-4/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/key="ai-engine-external-picker"[\s\S]*?slide-in-from-right-4/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
wizardSource,
|
||||||
|
/key="ai-engine-choices"[\s\S]*?slide-in-from-left-4/,
|
||||||
|
);
|
||||||
|
assert.match(wizardSource, /motion-reduce:animate-none/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aligns the own-model title and back button with external Agent setup', () => {
|
||||||
|
const ownModelTitle = ownModelSetupSource.indexOf(
|
||||||
|
"t('wizard.aiEngine.ownModelSetupTitle')",
|
||||||
|
);
|
||||||
|
const ownModelBack = ownModelSetupSource.indexOf(
|
||||||
|
"t('wizard.aiEngine.backToChoices')",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(ownModelTitle >= 0);
|
||||||
|
assert.ok(ownModelBack > ownModelTitle);
|
||||||
|
assert.match(ownModelSetupSource, /mx-auto w-full max-w-4xl space-y-6/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('labels both external Agent setup states with their specific title', () => {
|
||||||
|
assert.equal(
|
||||||
|
wizardSource.match(/t\('wizard\.aiEngine\.externalTitle'\)/g)?.length,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
});
|
||||||
+34
-13
@@ -1,18 +1,39 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react()],
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
resolve: {
|
const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:5300';
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, './src'),
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
server: {
|
||||||
server: {
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
proxy: {
|
||||||
build: {
|
'/api': {
|
||||||
outDir: 'dist',
|
target: apiProxyTarget,
|
||||||
},
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
'/mcp': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/bots': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user