mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-25 19:47:18 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd7acd5769 | |||
| 51b9e1cf54 | |||
| 1f07471f81 | |||
| 7f29d51c69 | |||
| 777fe1f20b | |||
| f0ee57c1e0 | |||
| a45e27e76e | |||
| 536fcdf29f | |||
| c87548c0b9 | |||
| 79634772da | |||
| bb366779af | |||
| 1336f47cb4 | |||
| 6b60ad1678 | |||
| 962366c507 | |||
| 23875b240f | |||
| e699358a5a | |||
| 14277d129c | |||
| 6bf1546df2 | |||
| 0bec72a3f9 | |||
| f36542135a | |||
| 693c59b726 | |||
| c3fe312a43 | |||
| c4bad508d2 | |||
| 3d4a726cd8 | |||
| e934f08adf | |||
| 7803d56254 | |||
| 54c96a18e1 | |||
| 579e3556e4 | |||
| a08a177a11 | |||
| c224f61c8c | |||
| b62cc9da45 | |||
| 700104c015 | |||
| 717bd4b8bf | |||
| 0cc0e1b02d | |||
| 7a4fadc375 | |||
| f2ba540ffb | |||
| 97b176aef2 | |||
| 7c387f75e1 | |||
| b0566f4c9d |
@@ -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:
|
||||||
|
|||||||
+7
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.7"
|
version = "4.10.8"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -70,8 +70,7 @@ dependencies = [
|
|||||||
"langchain-text-splitters>=1.1.2",
|
"langchain-text-splitters>=1.1.2",
|
||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"langbot-plugin==0.5.5",
|
||||||
"langbot-plugin==0.5.3",
|
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
@@ -108,6 +107,11 @@ classifiers = [
|
|||||||
"Topic :: Communications :: Chat",
|
"Topic :: Communications :: Chat",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
seekdb = [
|
||||||
|
"pyseekdb==1.1.0.post3",
|
||||||
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://langbot.app"
|
Homepage = "https://langbot.app"
|
||||||
Documentation = "https://docs.langbot.app"
|
Documentation = "https://docs.langbot.app"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import typing
|
import typing
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
|
from ..api.http.context import ExecutionContext
|
||||||
from ..core import app
|
from ..core import app
|
||||||
from . import operator
|
from . import operator
|
||||||
from ..utils import importutil
|
from ..utils import importutil
|
||||||
@@ -66,7 +67,14 @@ class CommandManager:
|
|||||||
|
|
||||||
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
|
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
|
||||||
if require_context is not None:
|
if require_context is not None:
|
||||||
result = require_context(context)
|
result = require_context(
|
||||||
|
ExecutionContext(
|
||||||
|
instance_uuid=context.instance_uuid,
|
||||||
|
workspace_uuid=context.workspace_uuid,
|
||||||
|
placement_generation=context.placement_generation,
|
||||||
|
query_uuid=context.query_uuid,
|
||||||
|
)
|
||||||
|
)
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
|
|
||||||
|
|||||||
@@ -301,11 +301,36 @@ class Application:
|
|||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _initialize_plugin_runtime(self) -> None:
|
||||||
|
try:
|
||||||
|
await self.plugin_connector.initialize()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
||||||
|
self.plugin_connector.schedule_reconnect()
|
||||||
|
|
||||||
|
def _start_plugin_runtime_initialization(self) -> asyncio.Task | None:
|
||||||
|
task = getattr(self, '_plugin_runtime_initialization_task', None)
|
||||||
|
if task is not None and not task.done():
|
||||||
|
return task
|
||||||
|
# This is application lifecycle work, not a request side effect. It must
|
||||||
|
# not wait on PersistenceManager's after-commit gate at boot.
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._initialize_plugin_runtime(),
|
||||||
|
name='plugin-runtime-initialization',
|
||||||
|
)
|
||||||
|
self._plugin_runtime_initialization_task = task
|
||||||
|
return task
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
self.event_loop_monitor.start()
|
self.event_loop_monitor.start()
|
||||||
try:
|
try:
|
||||||
if self.directory_projection_service is not None:
|
if (
|
||||||
self.task_mgr.create_task(
|
self.directory_projection_service is not None
|
||||||
|
and getattr(self, 'directory_projection_task', None) is None
|
||||||
|
):
|
||||||
|
self.directory_projection_task = self.task_mgr.create_task(
|
||||||
self.directory_projection_service.run(),
|
self.directory_projection_service.run(),
|
||||||
name='cloud-directory-projection',
|
name='cloud-directory-projection',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
@@ -322,7 +347,6 @@ class Application:
|
|||||||
name='cloud-manifest-refresh',
|
name='cloud-manifest-refresh',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
await self.plugin_connector.initialize_plugins()
|
|
||||||
|
|
||||||
# 后续可能会允许动态重启其他任务
|
# 后续可能会允许动态重启其他任务
|
||||||
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
|
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
|
||||||
@@ -348,6 +372,7 @@ class Application:
|
|||||||
name='http-api-controller',
|
name='http-api-controller',
|
||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
|
self._start_plugin_runtime_initialization()
|
||||||
|
|
||||||
# Telemetry instance heartbeat (startup + daily); respects
|
# Telemetry instance heartbeat (startup + daily); respects
|
||||||
# space.disable_telemetry via TelemetryManager.send().
|
# space.disable_telemetry via TelemetryManager.send().
|
||||||
@@ -529,6 +554,11 @@ class Application:
|
|||||||
|
|
||||||
if self.task_mgr is not None:
|
if self.task_mgr is not None:
|
||||||
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
|
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
|
||||||
|
plugin_runtime_task = getattr(self, '_plugin_runtime_initialization_task', None)
|
||||||
|
if plugin_runtime_task is not None and not plugin_runtime_task.done():
|
||||||
|
plugin_runtime_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await plugin_runtime_task
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await self.event_loop_monitor.stop()
|
await self.event_loop_monitor.stop()
|
||||||
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
|
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .. import stage, app
|
from .. import stage, app, entities as core_entities
|
||||||
from ...utils import version, proxy, constants
|
from ...utils import version, proxy, constants
|
||||||
from ...pipeline import pool, controller, pipelinemgr
|
from ...pipeline import pool, controller, pipelinemgr
|
||||||
from ...pipeline import aggregator as message_aggregator
|
from ...pipeline import aggregator as message_aggregator
|
||||||
@@ -292,14 +292,17 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||||
connector.schedule_reconnect()
|
connector.schedule_reconnect()
|
||||||
|
|
||||||
|
if ap.directory_projection_service is not None:
|
||||||
|
# Keep the projection fresh while shared Runtime cold restore runs.
|
||||||
|
# BuildApp initializes the connector before Application.run() starts
|
||||||
|
# its long-lived tasks, so start the single refresh task here.
|
||||||
|
ap.directory_projection_task = ap.task_mgr.create_task(
|
||||||
|
ap.directory_projection_service.run(),
|
||||||
|
name='cloud-directory-projection',
|
||||||
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
|
)
|
||||||
|
|
||||||
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
||||||
try:
|
|
||||||
await plugin_connector_inst.initialize()
|
|
||||||
except Exception as exc:
|
|
||||||
# Keep the API/UI available while an external or managed runtime is
|
|
||||||
# starting, then recover in the background with bounded backoff.
|
|
||||||
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
|
||||||
plugin_connector_inst.schedule_reconnect()
|
|
||||||
ap.plugin_connector = plugin_connector_inst
|
ap.plugin_connector = plugin_connector_inst
|
||||||
workspace_service_inst.release_startup_execution_bindings()
|
workspace_service_inst.release_startup_execution_bindings()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ class PersistenceManager:
|
|||||||
await self._validate_cloud_runtime()
|
await self._validate_cloud_runtime()
|
||||||
return
|
return
|
||||||
|
|
||||||
self._enable_sqlite_foreign_keys()
|
|
||||||
if self.mode == PersistenceMode.RELEASE_MIGRATION:
|
if self.mode == PersistenceMode.RELEASE_MIGRATION:
|
||||||
async with self._release_migration_lock():
|
async with self._release_migration_lock():
|
||||||
await self._initialize_managed_schema()
|
await self._initialize_managed_schema()
|
||||||
@@ -185,6 +184,7 @@ class PersistenceManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
await self._initialize_managed_schema()
|
await self._initialize_managed_schema()
|
||||||
|
await self._enable_sqlite_foreign_keys_after_migration()
|
||||||
|
|
||||||
if self.mode == PersistenceMode.OSS_COMPAT:
|
if self.mode == PersistenceMode.OSS_COMPAT:
|
||||||
await self.write_space_model_providers()
|
await self.write_space_model_providers()
|
||||||
@@ -373,6 +373,17 @@ class PersistenceManager:
|
|||||||
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
|
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
|
||||||
self._oss_tenant_scope_listener_installed = True
|
self._oss_tenant_scope_listener_installed = True
|
||||||
|
|
||||||
|
async def _enable_sqlite_foreign_keys_after_migration(self) -> None:
|
||||||
|
"""Enable SQLite FK enforcement only after table-rebuilding migrations."""
|
||||||
|
engine = self.get_db_engine()
|
||||||
|
if engine.dialect.name != 'sqlite':
|
||||||
|
return
|
||||||
|
await engine.dispose()
|
||||||
|
self._enable_sqlite_foreign_keys()
|
||||||
|
# Dispose again so every runtime connection is opened through the new
|
||||||
|
# listener instead of reusing a pre-migration pooled connection.
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
def _enable_sqlite_foreign_keys(self) -> None:
|
def _enable_sqlite_foreign_keys(self) -> None:
|
||||||
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
||||||
engine = self.get_db_engine()
|
engine = self.get_db_engine()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
@@ -117,8 +118,19 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
|
|||||||
temporary_path.unlink(missing_ok=True)
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _fsync_file(path: pathlib.Path) -> None:
|
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
||||||
descriptor = os.open(path, os.O_RDONLY)
|
"""Sync a file, tolerating delayed visibility after replace on bind mounts."""
|
||||||
|
|
||||||
|
descriptor: int | None = None
|
||||||
|
for attempt in range(reopen_attempts):
|
||||||
|
try:
|
||||||
|
descriptor = os.open(path, os.O_RDONLY)
|
||||||
|
break
|
||||||
|
except FileNotFoundError:
|
||||||
|
if attempt + 1 >= reopen_attempts:
|
||||||
|
raise
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert descriptor is not None
|
||||||
try:
|
try:
|
||||||
os.fsync(descriptor)
|
os.fsync(descriptor)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -158,6 +158,18 @@ class ResponseWrapper(stage.PipelineStage):
|
|||||||
result_type=entities.ResultType.CONTINUE,
|
result_type=entities.ResultType.CONTINUE,
|
||||||
new_query=query,
|
new_query=query,
|
||||||
)
|
)
|
||||||
|
elif (
|
||||||
|
isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls
|
||||||
|
):
|
||||||
|
# Final streaming chunk with no text content but
|
||||||
|
# possibly carrying sandbox outbox attachments.
|
||||||
|
reply_chain = platform_message.MessageChain([])
|
||||||
|
await self._append_outbound_attachments(query, reply_chain)
|
||||||
|
query.resp_message_chain.append(reply_chain)
|
||||||
|
yield entities.StageProcessResult(
|
||||||
|
result_type=entities.ResultType.CONTINUE,
|
||||||
|
new_query=query,
|
||||||
|
)
|
||||||
|
|
||||||
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
||||||
function_names = [tc.function.name for tc in result.tool_calls]
|
function_names = [tc.function.name for tc in result.tool_calls]
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
if event.source.type == 'user':
|
if event.source.type == 'user':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
sender=platform_entities.Friend(
|
sender=platform_entities.Friend(
|
||||||
id=event.message.id,
|
id=event.source.user_id,
|
||||||
nickname=event.source.user_id,
|
nickname=event.source.user_id,
|
||||||
remark='',
|
remark='',
|
||||||
),
|
),
|
||||||
@@ -110,13 +110,19 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# 'group' and 'room' sources carry the stable chat id under different
|
||||||
|
# field names; user_id may be absent for some members, so fall back
|
||||||
|
# to the group/room id rather than the per-message id.
|
||||||
|
group_id = event.source.group_id if event.source.type == 'group' else event.source.room_id
|
||||||
|
member_id = event.source.user_id or group_id
|
||||||
|
|
||||||
return platform_events.GroupMessage(
|
return platform_events.GroupMessage(
|
||||||
sender=platform_entities.GroupMember(
|
sender=platform_entities.GroupMember(
|
||||||
id=event.event.sender.sender_id.open_id,
|
id=member_id,
|
||||||
member_name=event.event.sender.sender_id.union_id,
|
member_name=member_id,
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
group=platform_entities.Group(
|
group=platform_entities.Group(
|
||||||
id=event.message.id,
|
id=group_id,
|
||||||
name='',
|
name='',
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -329,17 +329,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
content_type = content.get('type', 'text')
|
content_type = content.get('type', 'text')
|
||||||
|
|
||||||
if content_type == 'text':
|
if content_type == 'text':
|
||||||
if target_type == 'c2c':
|
if target_type in {'c2c', 'group'}:
|
||||||
await self.bot.send_private_text_msg(
|
await self._send_c2c_or_group_text_reply(
|
||||||
|
target_type,
|
||||||
target_id,
|
target_id,
|
||||||
content['content'],
|
content['content'],
|
||||||
qq_official_event.d_id,
|
msg_id=qq_official_event.d_id,
|
||||||
)
|
|
||||||
elif target_type == 'group':
|
|
||||||
await self.bot.send_group_text_msg(
|
|
||||||
target_id,
|
|
||||||
content['content'],
|
|
||||||
qq_official_event.d_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
elif content_type == 'image':
|
elif content_type == 'image':
|
||||||
@@ -383,6 +378,39 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _send_c2c_or_group_text_reply(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
msg_id: typing.Optional[str] = None,
|
||||||
|
event_id: typing.Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a text reply using the configured C2C/group render mode."""
|
||||||
|
use_markdown = self.config.get('enable-markdown-rendering', False)
|
||||||
|
if target_type == 'c2c':
|
||||||
|
send = self.bot.send_private_markdown_msg if use_markdown else self.bot.send_private_text_msg
|
||||||
|
await send(
|
||||||
|
user_openid=target_id,
|
||||||
|
content=content,
|
||||||
|
msg_id=msg_id,
|
||||||
|
event_id=event_id,
|
||||||
|
msg_seq=msg_seq,
|
||||||
|
)
|
||||||
|
elif target_type == 'group':
|
||||||
|
send = self.bot.send_group_markdown_msg if use_markdown else self.bot.send_group_text_msg
|
||||||
|
await send(
|
||||||
|
group_openid=target_id,
|
||||||
|
content=content,
|
||||||
|
msg_id=msg_id,
|
||||||
|
event_id=event_id,
|
||||||
|
msg_seq=msg_seq,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported QQ Official text reply target: {target_type}')
|
||||||
|
|
||||||
def register_listener(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
event_type: typing.Type[platform_events.Event],
|
event_type: typing.Type[platform_events.Event],
|
||||||
@@ -650,13 +678,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
||||||
ctx['session_started'] = True
|
ctx['session_started'] = True
|
||||||
|
|
||||||
# 发送内容 = 全量累积文本
|
# `replace` mode requires every update to contain the previously
|
||||||
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
|
# delivered content as its prefix. `sent_length` only tells us whether
|
||||||
# - 首次:发送全部文本,建立会话
|
# a non-final snapshot has new content; it must not truncate the
|
||||||
# - 后续:只能发送新增部分(append 行为)
|
# content sent to QQ.
|
||||||
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
if len(ctx['accumulated_text']) <= ctx['sent_length'] and not is_final:
|
||||||
if not content_to_send and not is_final:
|
|
||||||
return
|
return
|
||||||
|
content_to_send = ctx['accumulated_text']
|
||||||
|
|
||||||
input_state = 10 if is_final else 1
|
input_state = 10 if is_final else 1
|
||||||
|
|
||||||
@@ -778,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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ import pytest
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
from langbot.pkg.persistence import alembic_runner
|
from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup
|
||||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||||
|
|
||||||
from .resource_migration_support import create_legacy_resource_schema
|
from .resource_migration_support import create_legacy_resource_schema
|
||||||
@@ -105,3 +106,31 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
|||||||
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
|
||||||
|
database_path = tmp_path / 'legacy-bind-mount.db'
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||||
|
real_open = os.open
|
||||||
|
transient_failures = 0
|
||||||
|
|
||||||
|
def transient_open(path, flags, *args, **kwargs):
|
||||||
|
nonlocal transient_failures
|
||||||
|
candidate = pathlib.Path(path)
|
||||||
|
if candidate.suffix == '.sqlite3' and candidate.parent.name == 'migration-backups' and transient_failures == 0:
|
||||||
|
transient_failures += 1
|
||||||
|
raise FileNotFoundError(2, 'simulated delayed bind-mount visibility', str(candidate))
|
||||||
|
return real_open(path, flags, *args, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await create_legacy_resource_schema(engine, instance_uuid='backup-bind-mount')
|
||||||
|
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||||
|
monkeypatch.setattr(sqlite_migration_backup.os, 'open', transient_open)
|
||||||
|
|
||||||
|
await _manager(engine)._run_alembic_migrations()
|
||||||
|
|
||||||
|
assert transient_failures == 1
|
||||||
|
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||||
|
assert len(_manifest_payloads(tmp_path / 'migration-backups')) == 2
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|||||||
@@ -179,13 +179,17 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
|||||||
)
|
)
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(schema.create_all)
|
await conn.run_sync(schema.create_all)
|
||||||
await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id})
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"),
|
sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"
|
||||||
|
),
|
||||||
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
||||||
)
|
)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"),
|
sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'),
|
||||||
{'uuid': old_workspace_uuid},
|
{'uuid': old_workspace_uuid},
|
||||||
)
|
)
|
||||||
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
||||||
@@ -193,8 +197,8 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
|||||||
await run_alembic_upgrade(engine, 'head')
|
await run_alembic_upgrade(engine, 'head')
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid
|
assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid
|
||||||
assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid
|
assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@@ -411,6 +415,45 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_persistence_startup_preserves_legacy_workspace_membership_with_foreign_keys(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
database_path = tmp_path / 'startup-foreign-keys.db'
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||||
|
try:
|
||||||
|
await _create_legacy_schema(engine)
|
||||||
|
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
monkeypatch.setattr(constants, 'instance_id', 'instance_migration_test')
|
||||||
|
application = type('Application', (), {})()
|
||||||
|
application.logger = logging.getLogger('workspace-startup-foreign-keys-test')
|
||||||
|
application.instance_config = type(
|
||||||
|
'InstanceConfig',
|
||||||
|
(),
|
||||||
|
{'data': {'database': {'use': 'sqlite', 'sqlite': {'path': str(database_path)}}}},
|
||||||
|
)()
|
||||||
|
manager = PersistenceManager(application)
|
||||||
|
|
||||||
|
await manager.initialize()
|
||||||
|
try:
|
||||||
|
async with manager.get_db_engine().connect() as conn:
|
||||||
|
workspace = (
|
||||||
|
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
|
||||||
|
)
|
||||||
|
membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
|
||||||
|
foreign_keys = await conn.scalar(sa.text('PRAGMA foreign_keys'))
|
||||||
|
|
||||||
|
assert workspace['created_by_account_uuid'] == membership['account_uuid']
|
||||||
|
assert membership['role'] == 'owner'
|
||||||
|
assert membership['status'] == 'active'
|
||||||
|
assert foreign_keys == 1
|
||||||
|
finally:
|
||||||
|
await manager.shutdown()
|
||||||
|
|
||||||
|
|
||||||
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
||||||
try:
|
try:
|
||||||
@@ -425,7 +468,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|||||||
assert instance_uuid
|
assert instance_uuid
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
"INSERT INTO workspace_metadata (workspace_uuid, key, value) "
|
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
|
||||||
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
||||||
),
|
),
|
||||||
{'workspace_uuid': old_uuid},
|
{'workspace_uuid': old_uuid},
|
||||||
@@ -433,7 +476,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
|
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
|
||||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
),
|
),
|
||||||
{'workspace_uuid': old_uuid},
|
{'workspace_uuid': old_uuid},
|
||||||
)
|
)
|
||||||
@@ -442,12 +485,16 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
|||||||
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
|
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
|
||||||
assert await conn.scalar(
|
assert (
|
||||||
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
await conn.scalar(
|
||||||
) == expected_uuid
|
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
||||||
assert await conn.scalar(
|
)
|
||||||
sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")
|
== expected_uuid
|
||||||
) == expected_uuid
|
)
|
||||||
|
assert (
|
||||||
|
await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
|
||||||
|
== expected_uuid
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock
|
|||||||
|
|
||||||
from langbot.pkg.command import operator
|
from langbot.pkg.command import operator
|
||||||
from langbot.pkg.command.cmdmgr import CommandManager
|
from langbot.pkg.command.cmdmgr import CommandManager
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
from tests.factories import FakeApp, command_query
|
from tests.factories import FakeApp, command_query
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
@@ -393,6 +394,32 @@ class TestCommandManagerInternalExecute:
|
|||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0].text == 'plugin response'
|
assert results[0].text == 'plugin response'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_selects_workspace_with_trusted_context(self):
|
||||||
|
"""Plugin command discovery receives the typed runtime scope."""
|
||||||
|
|
||||||
|
fake_app = FakeApp()
|
||||||
|
mgr = CommandManager(fake_app)
|
||||||
|
mgr.cmd_list = []
|
||||||
|
fake_app.plugin_connector.require_workspace_context = AsyncMock()
|
||||||
|
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
ctx = self._create_context(command='help')
|
||||||
|
ctx.instance_uuid = 'instance-a'
|
||||||
|
ctx.workspace_uuid = 'workspace-a'
|
||||||
|
ctx.placement_generation = 4
|
||||||
|
ctx.query_uuid = 'query-a'
|
||||||
|
|
||||||
|
async for _ in mgr._execute(ctx, mgr.cmd_list):
|
||||||
|
pass
|
||||||
|
|
||||||
|
selected = fake_app.plugin_connector.require_workspace_context.await_args.args[0]
|
||||||
|
assert isinstance(selected, ExecutionContext)
|
||||||
|
assert selected.instance_uuid == 'instance-a'
|
||||||
|
assert selected.workspace_uuid == 'workspace-a'
|
||||||
|
assert selected.placement_generation == 4
|
||||||
|
assert selected.query_uuid == 'query-a'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_execute_with_bound_plugins(self):
|
async def test_execute_with_bound_plugins(self):
|
||||||
"""_execute passes bound_plugins to plugin connector."""
|
"""_execute passes bound_plugins to plugin connector."""
|
||||||
|
|||||||
@@ -144,3 +144,39 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
|
|||||||
assert stats['models']['providers'] == 1
|
assert stats['models']['providers'] == 1
|
||||||
assert stats['runtimes']['plugin_installations'] == 1
|
assert stats['runtimes']['plugin_installations'] == 1
|
||||||
assert stats['runtimes']['plugin_runtime_connected'] is True
|
assert stats['runtimes']['plugin_runtime_connected'] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_plugin_runtime_initialization_bypasses_after_commit_gate() -> None:
|
||||||
|
app = Application()
|
||||||
|
app.plugin_connector = SimpleNamespace(initialize=AsyncMock())
|
||||||
|
app.task_mgr = SimpleNamespace(create_task=AsyncMock())
|
||||||
|
|
||||||
|
task = app._start_plugin_runtime_initialization()
|
||||||
|
await task
|
||||||
|
|
||||||
|
app.plugin_connector.initialize.assert_awaited_once_with()
|
||||||
|
app.task_mgr.create_task.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shutdown_cancels_plugin_runtime_initialization_task() -> None:
|
||||||
|
app = Application()
|
||||||
|
app._plugin_runtime_initialization_task = asyncio.create_task(asyncio.sleep(60))
|
||||||
|
app.task_mgr = SimpleNamespace(cancel_by_scope=lambda *_: None, tasks=[])
|
||||||
|
app.event_loop_monitor = SimpleNamespace(stop=AsyncMock())
|
||||||
|
app.http_ctrl = SimpleNamespace(mcp_mount=None)
|
||||||
|
app.platform_mgr = None
|
||||||
|
app.tool_mgr = None
|
||||||
|
app.model_mgr = None
|
||||||
|
app.box_service = None
|
||||||
|
app.plugin_connector = None
|
||||||
|
app.telemetry = None
|
||||||
|
app.vector_db_mgr = None
|
||||||
|
app.storage_mgr = None
|
||||||
|
app.persistence_mgr = SimpleNamespace(db=SimpleNamespace(engine=SimpleNamespace(dispose=AsyncMock())))
|
||||||
|
app.deployment = None
|
||||||
|
|
||||||
|
await app.shutdown()
|
||||||
|
|
||||||
|
assert app._plugin_runtime_initialization_task.cancelled()
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from linebot.v3.webhooks import TextMessageContent
|
||||||
|
|
||||||
|
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
|
||||||
|
from langbot.pkg.platform.sources import line
|
||||||
|
|
||||||
|
|
||||||
|
def _make_event(*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi'):
|
||||||
|
event = MagicMock()
|
||||||
|
event.timestamp = 1700000000000
|
||||||
|
event.message = MagicMock(spec=TextMessageContent)
|
||||||
|
event.message.id = message_id
|
||||||
|
event.message.text = text
|
||||||
|
event.message.webhook_event_id = f'webhook-{message_id}'
|
||||||
|
event.message.timestamp = event.timestamp
|
||||||
|
|
||||||
|
source = MagicMock()
|
||||||
|
source.type = source_type
|
||||||
|
source.user_id = user_id
|
||||||
|
if group_id is not None:
|
||||||
|
source.group_id = group_id
|
||||||
|
if room_id is not None:
|
||||||
|
source.room_id = room_id
|
||||||
|
event.source = source
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_message_launcher_id_stable_across_messages() -> None:
|
||||||
|
"""Two distinct messages from the same LINE user must resolve to the same
|
||||||
|
sender id, otherwise every message starts a brand new session (context loss).
|
||||||
|
"""
|
||||||
|
event1 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-1')
|
||||||
|
event2 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-2')
|
||||||
|
|
||||||
|
result1 = await line.LINEEventConverter.target2yiri(event1, bot_client=None)
|
||||||
|
result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None)
|
||||||
|
|
||||||
|
assert result1.sender.id == 'U-stable-user'
|
||||||
|
assert result1.sender.id == result2.sender.id
|
||||||
|
assert result1.sender.id != event1.message.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_message_uses_group_id_not_message_id() -> None:
|
||||||
|
event1 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-1')
|
||||||
|
event2 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-2')
|
||||||
|
|
||||||
|
result1 = await line.LINEEventConverter.target2yiri(event1, bot_client=None)
|
||||||
|
result2 = await line.LINEEventConverter.target2yiri(event2, bot_client=None)
|
||||||
|
|
||||||
|
assert result1.sender.group.id == 'G-stable-group'
|
||||||
|
assert result1.sender.group.id == result2.sender.group.id
|
||||||
|
assert result1.sender.id == 'U-member'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_room_message_uses_room_id_and_falls_back_when_user_id_missing() -> None:
|
||||||
|
event = _make_event(source_type='room', user_id=None, room_id='R-stable-room', message_id='msg-1')
|
||||||
|
|
||||||
|
result = await line.LINEEventConverter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
assert result.sender.group.id == 'R-stable-room'
|
||||||
|
assert result.sender.id == 'R-stable-room'
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Tests for QQ Official keyboard payload helpers."""
|
"""Tests for QQ Official message and keyboard payload helpers."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
@@ -99,6 +101,12 @@ def _stream_test_adapter():
|
|||||||
adapter.bot = MagicMock()
|
adapter.bot = MagicMock()
|
||||||
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
|
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
|
||||||
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
|
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
|
||||||
|
adapter.bot.send_private_text_msg = AsyncMock()
|
||||||
|
adapter.bot.send_group_text_msg = AsyncMock()
|
||||||
|
adapter.bot.send_private_markdown_msg = AsyncMock()
|
||||||
|
adapter.bot.send_group_markdown_msg = AsyncMock()
|
||||||
|
adapter.bot.send_channle_group_text_msg = AsyncMock()
|
||||||
|
adapter.bot.send_channle_private_text_msg = AsyncMock()
|
||||||
adapter.ap = None
|
adapter.ap = None
|
||||||
adapter._stream_ctx = {}
|
adapter._stream_ctx = {}
|
||||||
adapter._stream_ctx_ts = {}
|
adapter._stream_ctx_ts = {}
|
||||||
@@ -108,7 +116,7 @@ def _stream_test_adapter():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
|
async def test_qq_stream_replace_mode_sends_complete_snapshots():
|
||||||
adapter = _stream_test_adapter()
|
adapter = _stream_test_adapter()
|
||||||
adapter._stream_ctx['message-1'] = {
|
adapter._stream_ctx['message-1'] = {
|
||||||
'user_openid': 'user-1',
|
'user_openid': 'user-1',
|
||||||
@@ -138,10 +146,109 @@ async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
|
|||||||
|
|
||||||
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
|
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
|
||||||
'<think>one',
|
'<think>one',
|
||||||
' two',
|
'<think>one two',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qq_markdown_messages_use_markdown_payloads():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def capture_request(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append((str(request.url), json.loads(request.content)))
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
|
||||||
|
client = QQOfficialClient('secret', 'token', 'app-id', AsyncMock())
|
||||||
|
client.access_token = 'access-token'
|
||||||
|
client.access_token_expiry_time = time.time() + 3600
|
||||||
|
client._http_clients[None] = httpx.AsyncClient(transport=httpx.MockTransport(capture_request))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.send_private_markdown_msg('user-1', '# Hello', msg_id='message-1', msg_seq=2)
|
||||||
|
await client.send_group_markdown_msg('group-1', '* Hello', event_id='event-1', msg_seq=3)
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
assert requests == [
|
||||||
|
(
|
||||||
|
'https://api.sgroup.qq.com/v2/users/user-1/messages',
|
||||||
|
{'msg_type': 2, 'markdown': {'content': '# Hello'}, 'msg_seq': 2, 'msg_id': 'message-1'},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'https://api.sgroup.qq.com/v2/groups/group-1/messages',
|
||||||
|
{'msg_type': 2, 'markdown': {'content': '* Hello'}, 'msg_seq': 3, 'event_id': 'event-1'},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qq_markdown_rendering_switches_c2c_and_group_text_replies():
|
||||||
|
adapter = _stream_test_adapter()
|
||||||
|
adapter.config = {'enable-markdown-rendering': True}
|
||||||
|
|
||||||
|
await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', '# Hello', msg_id='message-1')
|
||||||
|
await adapter._send_c2c_or_group_text_reply('group', 'group-1', '* Hello', event_id='event-1')
|
||||||
|
|
||||||
|
adapter.bot.send_private_markdown_msg.assert_awaited_once_with(
|
||||||
|
user_openid='user-1',
|
||||||
|
content='# Hello',
|
||||||
|
msg_id='message-1',
|
||||||
|
event_id=None,
|
||||||
|
msg_seq=1,
|
||||||
|
)
|
||||||
|
adapter.bot.send_group_markdown_msg.assert_awaited_once_with(
|
||||||
|
group_openid='group-1',
|
||||||
|
content='* Hello',
|
||||||
|
msg_id=None,
|
||||||
|
event_id='event-1',
|
||||||
|
msg_seq=1,
|
||||||
|
)
|
||||||
|
adapter.bot.send_private_text_msg.assert_not_awaited()
|
||||||
|
adapter.bot.send_group_text_msg.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qq_markdown_rendering_defaults_to_plain_text_replies():
|
||||||
|
adapter = _stream_test_adapter()
|
||||||
|
adapter.config = {}
|
||||||
|
|
||||||
|
await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', 'Hello')
|
||||||
|
await adapter._send_c2c_or_group_text_reply('group', 'group-1', 'Hello')
|
||||||
|
|
||||||
|
adapter.bot.send_private_text_msg.assert_awaited_once()
|
||||||
|
adapter.bot.send_group_text_msg.assert_awaited_once()
|
||||||
|
adapter.bot.send_private_markdown_msg.assert_not_awaited()
|
||||||
|
adapter.bot.send_group_markdown_msg.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qq_markdown_rendering_does_not_affect_channel_messages():
|
||||||
|
adapter = _stream_test_adapter()
|
||||||
|
adapter.config = {'enable-markdown-rendering': True}
|
||||||
|
message = platform_message.MessageChain([platform_message.Plain(text='# Hello')])
|
||||||
|
|
||||||
|
channel_source = MagicMock()
|
||||||
|
channel_source.t = 'AT_MESSAGE_CREATE'
|
||||||
|
channel_source.channel_id = 'channel-1'
|
||||||
|
channel_source.d_id = 'message-1'
|
||||||
|
channel_event = MagicMock()
|
||||||
|
channel_event.source_platform_object = channel_source
|
||||||
|
await adapter.reply_message(channel_event, message)
|
||||||
|
|
||||||
|
dm_source = MagicMock()
|
||||||
|
dm_source.t = 'DIRECT_MESSAGE_CREATE'
|
||||||
|
dm_source.guild_id = 'guild-1'
|
||||||
|
dm_source.d_id = 'message-2'
|
||||||
|
dm_event = MagicMock()
|
||||||
|
dm_event.source_platform_object = dm_source
|
||||||
|
await adapter.reply_message(dm_event, message)
|
||||||
|
|
||||||
|
adapter.bot.send_channle_group_text_msg.assert_awaited_once_with('channel-1', '# Hello', 'message-1')
|
||||||
|
adapter.bot.send_channle_private_text_msg.assert_awaited_once_with('guild-1', '# Hello', 'message-2')
|
||||||
|
adapter.bot.send_private_markdown_msg.assert_not_awaited()
|
||||||
|
adapter.bot.send_group_markdown_msg.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
|
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
|
||||||
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import langbot.pkg.core.app # noqa: F401
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
from langbot.libs.wecom_ai_bot_api.ws_client import _UPLOAD_CHUNK_SIZE, WecomBotWsClient
|
||||||
|
from langbot.pkg.platform.sources.wecombot import WecomBotAdapter, WecomBotMessageConverter
|
||||||
|
|
||||||
|
|
||||||
|
class Logger:
|
||||||
|
def __init__(self):
|
||||||
|
self.warnings = []
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
async def warning(self, message):
|
||||||
|
self.warnings.append(message)
|
||||||
|
|
||||||
|
async def error(self, message):
|
||||||
|
self.errors.append(message)
|
||||||
|
|
||||||
|
async def info(self, message):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UploadClient(WecomBotWsClient):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(bot_id='bot', secret='secret', logger=Logger())
|
||||||
|
self.frames = []
|
||||||
|
|
||||||
|
async def _send_reply(self, req_id: str, body: dict, cmd: str = 'aibot_respond_msg'):
|
||||||
|
self.frames.append((cmd, body))
|
||||||
|
if cmd == 'aibot_upload_media_init':
|
||||||
|
return {'errcode': 0, 'body': {'upload_id': 'upload-1'}}
|
||||||
|
if cmd == 'aibot_upload_media_finish':
|
||||||
|
return {'errcode': 0, 'body': {'media_id': 'media-1'}}
|
||||||
|
return {'errcode': 0}
|
||||||
|
|
||||||
|
|
||||||
|
class Bot:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def upload_media(self, data, filename='attachment', media_type='file'):
|
||||||
|
self.calls.append(('upload_media', media_type, filename, data))
|
||||||
|
return {'media_id': 'media-1'}
|
||||||
|
|
||||||
|
async def reply_text(self, req_id, content):
|
||||||
|
self.calls.append(('reply_text', req_id, content))
|
||||||
|
|
||||||
|
async def reply_image(self, req_id, media_id):
|
||||||
|
self.calls.append(('reply_image', req_id, media_id))
|
||||||
|
|
||||||
|
async def send_message(self, target_id, content):
|
||||||
|
self.calls.append(('send_message', target_id, content))
|
||||||
|
|
||||||
|
|
||||||
|
def make_adapter(bot):
|
||||||
|
return WecomBotAdapter.model_construct(
|
||||||
|
bot=bot,
|
||||||
|
config={'enable-webhook': False},
|
||||||
|
logger=Logger(),
|
||||||
|
message_converter=WecomBotMessageConverter(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ws_client_upload_media_uses_chunk_protocol():
|
||||||
|
client = UploadClient()
|
||||||
|
data = b'a' * (_UPLOAD_CHUNK_SIZE + 1)
|
||||||
|
|
||||||
|
upload_result = await client.upload_media(data, 'image.png', media_type='image')
|
||||||
|
|
||||||
|
assert upload_result['media_id'] == 'media-1'
|
||||||
|
assert [cmd for cmd, _ in client.frames] == [
|
||||||
|
'aibot_upload_media_init',
|
||||||
|
'aibot_upload_media_chunk',
|
||||||
|
'aibot_upload_media_chunk',
|
||||||
|
'aibot_upload_media_finish',
|
||||||
|
]
|
||||||
|
init_body = client.frames[0][1]
|
||||||
|
assert init_body['type'] == 'image'
|
||||||
|
assert init_body['filename'] == 'image.png'
|
||||||
|
assert init_body['total_size'] == len(data)
|
||||||
|
assert init_body['total_chunks'] == 2
|
||||||
|
assert client.frames[1][1]['chunk_index'] == 0
|
||||||
|
assert base64.b64decode(client.frames[1][1]['base64_data']) == b'a' * _UPLOAD_CHUNK_SIZE
|
||||||
|
assert client.frames[2][1]['chunk_index'] == 1
|
||||||
|
assert base64.b64decode(client.frames[2][1]['base64_data']) == b'a'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_message_uploads_and_replies_image_media():
|
||||||
|
bot = Bot()
|
||||||
|
adapter = make_adapter(bot)
|
||||||
|
png_data = b'\x89PNG\r\n\x1a\nimage'
|
||||||
|
image_b64 = base64.b64encode(png_data).decode('utf-8')
|
||||||
|
chain = platform_message.MessageChain([platform_message.Image(base64=f'data:image/png;base64,{image_b64}')])
|
||||||
|
|
||||||
|
items = await WecomBotMessageConverter.yiri2target(chain)
|
||||||
|
await adapter._send_media(bot, 'req-1', items[0])
|
||||||
|
|
||||||
|
assert bot.calls == [
|
||||||
|
('upload_media', 'image', 'attachment.image', png_data),
|
||||||
|
('reply_image', 'req-1', 'media-1'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_sends_text_and_skips_proactive_image():
|
||||||
|
bot = Bot()
|
||||||
|
adapter = make_adapter(bot)
|
||||||
|
jpg_data = b'\xff\xd8\xffimage'
|
||||||
|
image_b64 = base64.b64encode(jpg_data).decode('utf-8')
|
||||||
|
chain = platform_message.MessageChain(
|
||||||
|
[
|
||||||
|
platform_message.Plain(text='before'),
|
||||||
|
platform_message.Image(base64=f'data:image/jpeg;base64,{image_b64}'),
|
||||||
|
platform_message.Plain(text='after'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.send_message('group', 'chat-1', chain)
|
||||||
|
|
||||||
|
assert bot.calls == [
|
||||||
|
('send_message', 'chat-1', 'beforeafter'),
|
||||||
|
]
|
||||||
@@ -107,6 +107,19 @@ def shared_connector(
|
|||||||
return connector
|
return connector
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shared_reconcile_uses_configured_cold_start_timeout():
|
||||||
|
binding = execution_binding("workspace-a")
|
||||||
|
setting = plugin_setting("01", "a" * 64)
|
||||||
|
connector = shared_connector([[binding]], {"workspace-a": [setting]})
|
||||||
|
connector.ap.instance_config.data["plugin"]["connect_timeout_seconds"] = 900
|
||||||
|
connector.handler = runtime_handler()
|
||||||
|
|
||||||
|
await connector._prepare_connected_runtime()
|
||||||
|
|
||||||
|
assert connector.handler.reconcile_plugin_installations.await_args.kwargs["timeout"] == 900
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
|
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
|
||||||
binding_a = execution_binding('workspace-a')
|
binding_a = execution_binding('workspace-a')
|
||||||
@@ -150,7 +163,7 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets():
|
|||||||
|
|
||||||
assert connector._workspace_installations == {}
|
assert connector._workspace_installations == {}
|
||||||
assert connector._known_desired_states == {}
|
assert connector._known_desired_states == {}
|
||||||
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
|
connector.handler.reconcile_plugin_installations.assert_awaited_once_with((), timeout=300.0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -81,6 +81,18 @@ async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish(
|
|||||||
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
|
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reconcile_plugin_installations_accepts_configured_cold_start_timeout():
|
||||||
|
runtime_handler = make_handler(SimpleNamespace())
|
||||||
|
runtime_handler.call_action = AsyncMock(return_value={})
|
||||||
|
binding = next(iter(runtime_handler._installation_bindings.values()))[0]
|
||||||
|
desired = PluginInstallationDesiredState(binding=binding, enabled=True)
|
||||||
|
|
||||||
|
await runtime_handler.reconcile_plugin_installations((desired,), timeout=900)
|
||||||
|
|
||||||
|
assert runtime_handler.call_action.await_args.kwargs["timeout"] == 900
|
||||||
|
|
||||||
|
|
||||||
class TestHandlerQueryVariables:
|
class TestHandlerQueryVariables:
|
||||||
"""Tests for handler query variable logic."""
|
"""Tests for handler query variable logic."""
|
||||||
|
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ class TestSetBinaryStorage:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
mock_app.persistence_mgr = Mock()
|
mock_app.persistence_mgr = Mock()
|
||||||
|
mock_app.persistence_mgr.get_db_engine.return_value = SimpleNamespace(dialect=SimpleNamespace(name='sqlite'))
|
||||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
|
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
|
||||||
mock_app.logger = Mock()
|
mock_app.logger = Mock()
|
||||||
return mock_app
|
return mock_app
|
||||||
@@ -270,8 +271,8 @@ class TestSetBinaryStorage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.code == 0
|
assert response.code == 0
|
||||||
assert app.persistence_mgr.execute_async.await_count == 2
|
assert app.persistence_mgr.execute_async.await_count == 3
|
||||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||||
assert insert_params['workspace_uuid'] == 'workspace-a'
|
assert insert_params['workspace_uuid'] == 'workspace-a'
|
||||||
assert insert_params['unique_key'] == canonical_binary_key(
|
assert insert_params['unique_key'] == canonical_binary_key(
|
||||||
'plugin',
|
'plugin',
|
||||||
@@ -301,6 +302,69 @@ class TestSetBinaryStorage:
|
|||||||
assert expected_key in update_params.values()
|
assert expected_key in update_params.values()
|
||||||
assert update_params['value'] == b'new'
|
assert update_params['value'] == b'new'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_adopts_legacy_storage_before_updating(self, app):
|
||||||
|
"""A migrated pre-tenancy row is updated in place rather than duplicated."""
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||||
|
adopted = SimpleNamespace(rowcount=1)
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(legacy_storage),
|
||||||
|
adopted,
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 3
|
||||||
|
adoption_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||||
|
expected_key = canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
|
||||||
|
assert expected_key in adoption_params.values()
|
||||||
|
assert adoption_params['value'] == b'new'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_adoption_race_updates_winning_canonical_row(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||||
|
lost_race = SimpleNamespace(rowcount=0)
|
||||||
|
canonical_winner = SimpleNamespace(rowcount=1)
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(legacy_storage),
|
||||||
|
lost_race,
|
||||||
|
canonical_winner,
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 4
|
||||||
|
winner_update = compiled_params(app.persistence_mgr.execute_async.await_args_list[3].args[0])
|
||||||
|
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in winner_update.values()
|
||||||
|
assert winner_update['value'] == b'new'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_adoption_lost_to_delete_inserts_new_value(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||||
|
lost_race = SimpleNamespace(rowcount=0)
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(legacy_storage),
|
||||||
|
lost_race,
|
||||||
|
SimpleNamespace(rowcount=0),
|
||||||
|
make_result(),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 5
|
||||||
|
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[4].args[0])
|
||||||
|
assert insert_params['unique_key'] == canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
|
||||||
|
assert insert_params['value'] == b'new'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
||||||
"""Invalid max_value_bytes uses the 10MB default limit."""
|
"""Invalid max_value_bytes uses the 10MB default limit."""
|
||||||
@@ -525,6 +589,46 @@ class TestGetBinaryStorage:
|
|||||||
in statement_params.values()
|
in statement_params.values()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reads_legacy_storage_without_mutating_key(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
legacy_storage = SimpleNamespace(
|
||||||
|
unique_key='plugin:test-author/test-plugin:test-key',
|
||||||
|
value=b'legacy bytes',
|
||||||
|
)
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(legacy_storage),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
||||||
|
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert base64.b64decode(response.data['value_base64']) == b'legacy bytes'
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retries_canonical_after_concurrent_legacy_adoption(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
canonical_storage = SimpleNamespace(value=b'adopted bytes')
|
||||||
|
app.persistence_mgr.execute_async.side_effect = [
|
||||||
|
make_result(),
|
||||||
|
make_result(),
|
||||||
|
make_result(canonical_storage),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
||||||
|
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
assert base64.b64decode(response.data['value_base64']) == b'adopted bytes'
|
||||||
|
assert app.persistence_mgr.execute_async.await_count == 3
|
||||||
|
retry_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||||
|
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in retry_params.values()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_returns_error_when_not_found(self, app):
|
async def test_returns_error_when_not_found(self, app):
|
||||||
"""Missing binary storage rows return an error response."""
|
"""Missing binary storage rows return an error response."""
|
||||||
@@ -567,21 +671,47 @@ class TestDeleteAndListBinaryStorage:
|
|||||||
|
|
||||||
assert response.code == 0
|
assert response.code == 0
|
||||||
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||||
assert 'workspace-a' in statement_params.values()
|
flat_values = [
|
||||||
|
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
|
||||||
|
]
|
||||||
|
assert 'workspace-a' in flat_values
|
||||||
assert (
|
assert (
|
||||||
canonical_binary_key(
|
canonical_binary_key(
|
||||||
'plugin',
|
'plugin',
|
||||||
'test-author/test-plugin',
|
'test-author/test-plugin',
|
||||||
'test-key',
|
'test-key',
|
||||||
)
|
)
|
||||||
in statement_params.values()
|
in flat_values
|
||||||
)
|
)
|
||||||
assert 'forged-owner' not in statement_params.values()
|
assert 'forged-owner' not in flat_values
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_removes_canonical_and_legacy_scoped_keys(self, app):
|
||||||
|
runtime_handler = make_handler(app)
|
||||||
|
|
||||||
|
response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value](
|
||||||
|
{
|
||||||
|
'key': 'test-key',
|
||||||
|
'owner_type': 'plugin',
|
||||||
|
'owner': 'forged-owner',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.code == 0
|
||||||
|
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||||
|
values = [
|
||||||
|
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
|
||||||
|
]
|
||||||
|
assert 'workspace-a' in values
|
||||||
|
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in values
|
||||||
|
assert 'plugin:test-author/test-plugin:test-key' in values
|
||||||
|
assert 'test-author/test-plugin' in values
|
||||||
|
assert 'forged-owner' not in values
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
||||||
result = Mock()
|
result = Mock()
|
||||||
result.scalars.return_value.all.return_value = ['first', 'second']
|
result.scalars.return_value.all.return_value = ['first', 'second', 'first']
|
||||||
app.persistence_mgr.execute_async.return_value = result
|
app.persistence_mgr.execute_async.return_value = result
|
||||||
runtime_handler = make_handler(app)
|
runtime_handler = make_handler(app)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_seekdb_is_only_declared_as_an_optional_dependency() -> None:
|
||||||
|
project_root = Path(__file__).resolve().parents[2]
|
||||||
|
with (project_root / 'pyproject.toml').open('rb') as pyproject_file:
|
||||||
|
pyproject = tomllib.load(pyproject_file)
|
||||||
|
|
||||||
|
project = pyproject['project']
|
||||||
|
base_dependencies = project['dependencies']
|
||||||
|
assert not any(dependency.lower().startswith('pyseekdb') for dependency in base_dependencies)
|
||||||
|
assert project['optional-dependencies']['seekdb'] == ['pyseekdb==1.1.0.post3']
|
||||||
@@ -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" },
|
||||||
@@ -2125,7 +2129,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.5.3" },
|
{ name = "langbot-plugin", specifier = "==0.5.5" },
|
||||||
{ name = "langchain", specifier = ">=1.3.9" },
|
{ name = "langchain", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2153,7 +2157,7 @@ requires-dist = [
|
|||||||
{ name = "pymilvus", specifier = ">=2.6.4" },
|
{ name = "pymilvus", specifier = ">=2.6.4" },
|
||||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||||
{ name = "pypdf2", specifier = ">=3.0.1" },
|
{ name = "pypdf2", specifier = ">=3.0.1" },
|
||||||
{ name = "pyseekdb", specifier = "==1.1.0.post3" },
|
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
|
||||||
{ name = "python-docx", specifier = ">=1.1.0" },
|
{ name = "python-docx", specifier = ">=1.1.0" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.27" },
|
{ name = "python-multipart", specifier = ">=0.0.27" },
|
||||||
{ name = "python-socks", specifier = ">=2.7.1" },
|
{ name = "python-socks", specifier = ">=2.7.1" },
|
||||||
@@ -2178,6 +2182,7 @@ requires-dist = [
|
|||||||
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
|
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
|
||||||
{ name = "websockets", specifier = ">=15.0.1" },
|
{ name = "websockets", specifier = ">=15.0.1" },
|
||||||
]
|
]
|
||||||
|
provides-extras = ["seekdb"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -2191,7 +2196,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.5.3"
|
version = "0.5.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2212,9 +2217,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/55/1d/a54daa3bc699f5186b9970946c2ecf0e9cf219f77738934e04aaf5a0c20a/langbot_plugin-0.5.3.tar.gz", hash = "sha256:2324b1f7e1f55e3692e75c8b1e427ea497474b0150ec6ca83b49b5d77ec224c6", size = 472149, upload-time = "2026-08-13T10:17:45.529Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/be/1bbdf959d8c16b625e3721cde586b3bb22eaa22dd8c22d072c04f9b491ba/langbot_plugin-0.5.5.tar.gz", hash = "sha256:ea31b0ddf64c2ef8fdec012273b2d3dee6f0d140475f07694f31ea685be40695", size = 472639, upload-time = "2026-08-16T17:33:27.783Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/5f/ae6ed59773cc9d941fbb28b4ac07ce2a29e689d693e29ad71c40c5b39aa5/langbot_plugin-0.5.3-py3-none-any.whl", hash = "sha256:75dea1b6fb79ec6087ec3284f6698fb701d5feebf5f0318a7ae51fbd17a2f41f", size = 304559, upload-time = "2026-08-13T10:17:44.385Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/30/72caa601b571542fa4de5f2a3461d6f601f75c52d484d9fc95ebb82ce30c/langbot_plugin-0.5.5-py3-none-any.whl", hash = "sha256:a55d20a0c015414ef85d783b493f83d27b64f1d662887de94330df9d3d4ab64e", size = 304643, upload-time = "2026-08-16T17:33:26.687Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-3xl space-y-5">
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleBack}>
|
||||||
|
<ArrowLeft className="mr-1.5 size-4" />
|
||||||
|
{t('wizard.aiEngine.backToChoices')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{t('wizard.aiEngine.ownModelSetupTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t('wizard.aiEngine.ownModelSetupDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showProviderForm ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{t('wizard.aiEngine.addProviderTitle')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('wizard.aiEngine.addProviderDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProviderForm
|
||||||
|
providerId={providerUuid ?? undefined}
|
||||||
|
onFormSubmit={handleProviderSaved}
|
||||||
|
onFormCancel={() =>
|
||||||
|
providerUuid ? setShowProviderForm(false) : handleBack()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b pb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{t('wizard.aiEngine.selectModelTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('wizard.aiEngine.selectScannedModelDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
title={t('wizard.aiEngine.editProvider')}
|
||||||
|
onClick={handleEditProvider}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={mode} onValueChange={handleModeChange}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="scan">
|
||||||
|
{t('wizard.aiEngine.scanModelMode')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="manual">
|
||||||
|
{t('wizard.aiEngine.manualModelMode')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="scan" className="mt-4">
|
||||||
|
{isScanning ? (
|
||||||
|
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
{t('wizard.aiEngine.scanningModels')}
|
||||||
|
</div>
|
||||||
|
) : models.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{models.map((model) => {
|
||||||
|
const selected = selectedModelId === model.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-20 items-center gap-3 rounded-md border p-3 text-left transition-colors hover:border-primary/60 hover:bg-accent/40',
|
||||||
|
selected &&
|
||||||
|
'border-primary bg-accent ring-1 ring-primary',
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelectModel(model)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex size-5 shrink-0 items-center justify-center rounded-full border',
|
||||||
|
selected &&
|
||||||
|
'border-primary bg-primary text-primary-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected && <Check className="size-3" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-medium">
|
||||||
|
{model.display_name || model.name}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{model.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isScanning || !providerUuid}
|
||||||
|
onClick={() => providerUuid && scanModels(providerUuid)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1.5 size-4" />
|
||||||
|
{t('wizard.aiEngine.rescanModels')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-48 flex-col items-center justify-center gap-3 border border-dashed p-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
scanFallbackReason === 'failed'
|
||||||
|
? 'wizard.aiEngine.scanModelsFailed'
|
||||||
|
: 'wizard.aiEngine.noScannedModels',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!providerUuid}
|
||||||
|
onClick={() => providerUuid && scanModels(providerUuid)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1.5 size-4" />
|
||||||
|
{t('wizard.aiEngine.rescanModels')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="manual" className="mt-4 space-y-5">
|
||||||
|
{scanFallbackReason && (
|
||||||
|
<div className="border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
|
{t(
|
||||||
|
scanFallbackReason === 'failed'
|
||||||
|
? 'wizard.aiEngine.manualFallbackFailed'
|
||||||
|
: 'wizard.aiEngine.manualFallbackEmpty',
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wizard-manual-model-name">
|
||||||
|
{t('wizard.aiEngine.manualModelId')}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="wizard-manual-model-name"
|
||||||
|
value={manualModelName}
|
||||||
|
onChange={(event) => setManualModelName(event.target.value)}
|
||||||
|
placeholder={t('wizard.aiEngine.manualModelIdPlaceholder')}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('wizard.aiEngine.manualModelIdDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 border-t pt-4">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t('wizard.aiEngine.manualModelOptions')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wizard-manual-context-length">
|
||||||
|
{t('models.contextLength')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="wizard-manual-context-length"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={manualContextLength}
|
||||||
|
onChange={(event) =>
|
||||||
|
setManualContextLength(event.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t('models.contextLengthPlaceholder')}
|
||||||
|
/>
|
||||||
|
{parsedManualContextLength === undefined && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{t('models.contextLengthInvalid')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="wizard-manual-vision"
|
||||||
|
checked={manualVision}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setManualVision(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="wizard-manual-vision"
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
{t('models.visionAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="wizard-manual-function-call"
|
||||||
|
checked={manualFunctionCall}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setManualFunctionCall(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="wizard-manual-function-call"
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Wrench className="size-4" />
|
||||||
|
{t('models.functionCallAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+610
-174
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
|||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
|
||||||
|
if (typeof error === 'object' && error !== null && 'msg' in error) {
|
||||||
|
const message = (error as { msg?: unknown }).msg;
|
||||||
|
if (typeof message === 'string') return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSigningSecret(): string {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureHttpBotSigningSecret(
|
||||||
|
adapterName: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (
|
||||||
|
adapterName !== 'http_bot' ||
|
||||||
|
config.signature_required === false ||
|
||||||
|
(typeof config.inbound_secret === 'string' && config.inbound_secret)
|
||||||
|
) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
inbound_secret: createSigningSecret(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDefaultPipeline<
|
||||||
|
T extends { uuid?: string; is_default?: boolean },
|
||||||
|
>(pipelines: T[]): T | undefined {
|
||||||
|
return pipelines.find(
|
||||||
|
(pipeline) =>
|
||||||
|
pipeline.is_default === true &&
|
||||||
|
typeof pipeline.uuid === 'string' &&
|
||||||
|
pipeline.uuid.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookConfigItem {
|
||||||
|
name: string;
|
||||||
|
show_if?: {
|
||||||
|
field: string;
|
||||||
|
operator: 'eq' | 'neq' | 'in';
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebhookModeEnabled(
|
||||||
|
configItems: WebhookConfigItem[],
|
||||||
|
configValues: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
const webhookField = configItems.find((item) => item.name === 'webhook_url');
|
||||||
|
if (!webhookField) return false;
|
||||||
|
if (!webhookField.show_if) return true;
|
||||||
|
|
||||||
|
const condition = webhookField.show_if;
|
||||||
|
const actualValue = configValues[condition.field];
|
||||||
|
if (condition.operator === 'eq') return actualValue === condition.value;
|
||||||
|
if (condition.operator === 'neq') return actualValue !== condition.value;
|
||||||
|
return (
|
||||||
|
Array.isArray(condition.value) && condition.value.includes(actualValue)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureLocalAgentPrimaryModel(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
modelUuid: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const aiConfig = (config.ai ?? {}) as Record<string, unknown>;
|
||||||
|
const runnerConfig = (aiConfig.runner ?? {}) as Record<string, unknown>;
|
||||||
|
const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const modelConfig = (localAgentConfig.model ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
ai: {
|
||||||
|
...aiConfig,
|
||||||
|
runner: { ...runnerConfig, runner: 'local-agent' },
|
||||||
|
'local-agent': {
|
||||||
|
...localAgentConfig,
|
||||||
|
model: {
|
||||||
|
...modelConfig,
|
||||||
|
primary: modelUuid,
|
||||||
|
fallbacks: Array.isArray(modelConfig.fallbacks)
|
||||||
|
? modelConfig.fallbacks
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1827,6 +1827,25 @@ 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.',
|
||||||
|
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.',
|
||||||
@@ -1835,6 +1854,51 @@ const enUS = {
|
|||||||
title: 'Select an AI Engine',
|
title: 'Select an 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, and other capabilities.',
|
||||||
|
runnerDescription:
|
||||||
|
'Select a runner for the external agent and configure its connection.',
|
||||||
|
backToChoices: 'Back to options',
|
||||||
|
createExternal: 'Create and Bind',
|
||||||
|
finishWithModel: 'Use Selected Model & Finish',
|
||||||
|
openWorkbench: 'Open Workbench',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: 'Bot Information',
|
botInfo: 'Bot Information',
|
||||||
|
|||||||
@@ -1744,6 +1744,25 @@ const jaJP = {
|
|||||||
resaveBot: '設定を再保存',
|
resaveBot: '設定を再保存',
|
||||||
botSaved:
|
botSaved:
|
||||||
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
|
'ボット設定が保存され、有効になりました。ログを確認して接続を検証してください。',
|
||||||
|
waitingForMessage:
|
||||||
|
'ボットが有効になりました。続行するには IM からメッセージを送信してください。',
|
||||||
|
messageReceived:
|
||||||
|
'ボットが IM メッセージを受信しました。次のステップに進めます。',
|
||||||
|
pageBotTestPrompt:
|
||||||
|
'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。',
|
||||||
|
pageBotTestNotice:
|
||||||
|
'テスト専用です。実際の外部 Web ページにコードを埋め込んでください。',
|
||||||
|
webhookTestPrompt:
|
||||||
|
'コールバック URL の準備ができました。外部プラットフォームに設定し、ボットへ実際のメッセージを送信してください。',
|
||||||
|
httpTestPrompt:
|
||||||
|
'HTTP Bot が有効になりました。実際の受信メッセージを送信して接続を確認できます。',
|
||||||
|
httpTestDefaultMessage: 'こんにちは。これは接続テストメッセージです。',
|
||||||
|
sendHttpTest: 'テストメッセージを送信',
|
||||||
|
httpTestAccepted:
|
||||||
|
'テストメッセージを受け付けました。まもなくログに表示されます。',
|
||||||
|
httpTestMissingSecret:
|
||||||
|
'受信署名シークレットを入力し、先に設定を保存してください。',
|
||||||
|
httpTestFailed: 'テストメッセージの送信に失敗しました:{{error}}',
|
||||||
logsTitle: 'ボットログ',
|
logsTitle: 'ボットログ',
|
||||||
logsDescription:
|
logsDescription:
|
||||||
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
|
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
|
||||||
@@ -1752,6 +1771,50 @@ const jaJP = {
|
|||||||
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:
|
||||||
|
'ワークベンチを開き、ツールやナレッジなどの機能を追加します。',
|
||||||
|
runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。',
|
||||||
|
backToChoices: '選択肢に戻る',
|
||||||
|
createExternal: '作成して関連付ける',
|
||||||
|
finishWithModel: '選択したモデルを使用して完了',
|
||||||
|
openWorkbench: 'ワークベンチを開く',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
botInfo: 'ボット情報',
|
botInfo: 'ボット情報',
|
||||||
|
|||||||
@@ -1749,12 +1749,62 @@ const zhHans = {
|
|||||||
saveBot: '保存并启用',
|
saveBot: '保存并启用',
|
||||||
resaveBot: '重新保存配置',
|
resaveBot: '重新保存配置',
|
||||||
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
|
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
|
||||||
|
waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。',
|
||||||
|
messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。',
|
||||||
|
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: '机器人信息',
|
||||||
|
|||||||
@@ -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,121 @@
|
|||||||
|
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,
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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 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/);
|
||||||
|
});
|
||||||
+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