mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-27 12:47:13 +00:00
Compare commits
78 Commits
0fbb1f897e
...
dev/4.11.x
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b888ad390 | |||
| 7d9bdf0562 | |||
| 5e144ac7c8 | |||
| 21ffaf9a14 | |||
| f3c1887e92 | |||
| 7671aefed7 | |||
| e6f6789e8b | |||
| 05fe93e108 | |||
| e20a73c83a | |||
| fde31d590e | |||
| af03dbe958 | |||
| bd7aeab53d | |||
| 91f484e0ce | |||
| a9b24e59ec | |||
| 139494cd63 | |||
| b4d9659ffe | |||
| 3f0ca13d6d | |||
| 6b40c2cf66 | |||
| 847bfc8df5 | |||
| 823feac7e0 | |||
| a7badf6258 | |||
| 3d692fa8db | |||
| 7990d36c78 | |||
| 600a173918 | |||
| 8b63cc0281 | |||
| 4787799cd8 | |||
| 47f5515fa9 | |||
| 94fd3d274c | |||
| 8380bfc7d4 | |||
| a936734efa | |||
| 0c5656d0d0 | |||
| 532d1b73d2 | |||
| aaeb9ad178 | |||
| d28b385a9f | |||
| 4d7a333802 | |||
| 114612a984 | |||
| 2aceefce47 | |||
| 80f1790e1d | |||
| 22d9053bf1 | |||
| db2a9155f8 | |||
| 69ca7e21cd | |||
| 792d961d28 | |||
| a8eb265c11 | |||
| e62f8a957a | |||
| ad6b8b3209 | |||
| 68620c4572 | |||
| 6a6a2b865b | |||
| 781d8a9ac8 | |||
| e3150e66a4 | |||
| ac31f1f006 | |||
| f65cca3f40 | |||
| e70a0d3f01 | |||
| 46aea2b499 | |||
| 49d0aac210 | |||
| bb366779af | |||
| 0559d9d441 | |||
| 1336f47cb4 | |||
| 962366c507 | |||
| 23875b240f | |||
| e699358a5a | |||
| 14277d129c | |||
| 6bf1546df2 | |||
| 0bec72a3f9 | |||
| f36542135a | |||
| 693c59b726 | |||
| c3fe312a43 | |||
| c4bad508d2 | |||
| 3d4a726cd8 | |||
| e934f08adf | |||
| 7803d56254 | |||
| 54c96a18e1 | |||
| 579e3556e4 | |||
| a08a177a11 | |||
| c224f61c8c | |||
| b62cc9da45 | |||
| 700104c015 | |||
| 717bd4b8bf | |||
| 0cc0e1b02d |
@@ -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:
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ class Agent(Base):
|
|||||||
kind: str # 固定为 "agent"
|
kind: str # 固定为 "agent"
|
||||||
component_ref: str # AgentRunner id
|
component_ref: str # AgentRunner id
|
||||||
config: dict # runner + runner_config
|
config: dict # runner + runner_config
|
||||||
enabled: bool
|
|
||||||
supported_event_patterns: list[str]
|
supported_event_patterns: list[str]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -113,7 +112,7 @@ Binding 只保存引用与路由条件。它不复制 Pipeline 或 Agent 配置
|
|||||||
|
|
||||||
1. 忽略 `enabled = false` 的 binding。
|
1. 忽略 `enabled = false` 的 binding。
|
||||||
2. 检查 `event_pattern` 与结构化 filters。
|
2. 检查 `event_pattern` 与结构化 filters。
|
||||||
3. 校验目标存在、启用且声明支持该事件。
|
3. 校验目标存在且声明支持该事件。
|
||||||
4. 按 `priority` 从高到低选择;同优先级按稳定列表顺序。
|
4. 按 `priority` 从高到低选择;同优先级按稳定列表顺序。
|
||||||
5. 只执行一个响应目标。
|
5. 只执行一个响应目标。
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ class Agent(Base):
|
|||||||
kind: str # 首版固定为 "agent"
|
kind: str # 首版固定为 "agent"
|
||||||
component_ref: str # runner id / workflow id / future external ref
|
component_ref: str # runner id / workflow id / future external ref
|
||||||
config: dict # runner 与 runner_config
|
config: dict # runner 与 runner_config
|
||||||
enabled: bool
|
|
||||||
supported_event_patterns: list[str]
|
supported_event_patterns: list[str]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
+5
-1
@@ -69,7 +69,6 @@ dependencies = [
|
|||||||
"langchain-text-splitters>=1.1.2",
|
"langchain-text-splitters>=1.1.2",
|
||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
|
||||||
"langbot-plugin==0.5.3",
|
"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",
|
||||||
@@ -107,6 +106,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"
|
||||||
|
|||||||
@@ -188,11 +188,7 @@ try {
|
|||||||
.getByText(/Event Routing|事件路由|イベントルーティング/)
|
.getByText(/Event Routing|事件路由|イベントルーティング/)
|
||||||
.first()
|
.first()
|
||||||
.waitFor({ timeout: 15_000 });
|
.waitFor({ timeout: 15_000 });
|
||||||
await page
|
await page.getByText(/Supported events|支持的事件|対応イベント/).waitFor();
|
||||||
.getByText(
|
|
||||||
/Events this adapter can receive|此适配器可接收的事件|このアダプターが受信できるイベント/,
|
|
||||||
)
|
|
||||||
.waitFor();
|
|
||||||
await page
|
await page
|
||||||
.getByText(/Message received|收到消息|メッセージを受信/)
|
.getByText(/Message received|收到消息|メッセージを受信/)
|
||||||
.first()
|
.first()
|
||||||
@@ -216,11 +212,11 @@ try {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: /Test route|测试路由|ルートをテスト/ })
|
.getByRole("button", { name: /Check route|检查路由|ルートを確認/ })
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("dialog").waitFor();
|
await page.getByRole("dialog").waitFor();
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: /Preview route|预览路由|ルートをプレビュー/ })
|
.getByRole("button", { name: /View match|查看匹配结果|一致結果を確認/ })
|
||||||
.click();
|
.click();
|
||||||
await page
|
await page
|
||||||
.getByText(/Route matched|已命中路由|ルートに一致しました/)
|
.getByText(/Route matched|已命中路由|ルートに一致しました/)
|
||||||
@@ -231,28 +227,67 @@ try {
|
|||||||
.waitFor();
|
.waitFor();
|
||||||
result.visible_signals.push("dry-run-matched", "discard-target");
|
result.visible_signals.push("dry-run-matched", "discard-target");
|
||||||
|
|
||||||
await page
|
|
||||||
.getByRole("button", {
|
|
||||||
name: /Run saved route|运行已保存路由|保存済みルートを実行/,
|
|
||||||
})
|
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.getByText(
|
|
||||||
/saved route ran successfully|已保存路由运行成功|保存済みルートを実行しました/,
|
|
||||||
)
|
|
||||||
.waitFor({ timeout: 20_000 });
|
|
||||||
result.visible_signals.push("test-event-dispatched");
|
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: /Close|关闭|閉じる/ })
|
.getByRole("button", { name: /Close|关闭|閉じる/ })
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||||
await page
|
|
||||||
.getByText(/Discarded|已丢弃|破棄済み/)
|
const adapterConfigCard = page.locator('[data-slot="card"]').filter({
|
||||||
.first()
|
has: page.getByText(/Adapter Configuration|适配器配置|アダプター設定/, {
|
||||||
.waitFor({ timeout: 10_000 });
|
exact: true,
|
||||||
result.visible_signals.push("route-status-discarded");
|
}),
|
||||||
|
});
|
||||||
|
await adapterConfigCard
|
||||||
|
.getByRole("button", {
|
||||||
|
name: /Listen for platform events|监听平台事件|プラットフォームイベントを監視/,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
const adapterDialog = page.getByRole("dialog");
|
||||||
|
await adapterDialog.waitFor();
|
||||||
|
await adapterDialog
|
||||||
|
.getByText(/Listening|正在监听|監視中/, { exact: true })
|
||||||
|
.waitFor({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const inboundText = `adapter event ${paths.runId}`;
|
||||||
|
const inbound = await apiJson(
|
||||||
|
backendUrl,
|
||||||
|
`/bots/${encodeURIComponent(botId)}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
token,
|
||||||
|
body: {
|
||||||
|
session_id: `adapter-debug-${paths.runId}`,
|
||||||
|
session_type: "person",
|
||||||
|
sender: { id: "adapter-debug-user", name: "Adapter QA" },
|
||||||
|
message: [{ type: "Plain", text: inboundText }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
result.api.adapter_event_webhook = {
|
||||||
|
http_status: inbound.status,
|
||||||
|
code: inbound.json.code ?? null,
|
||||||
|
};
|
||||||
|
if (inbound.status >= 400 || inbound.json.code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
inbound.json.msg || "The HTTP Bot adapter rejected the inbound event.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await adapterDialog
|
||||||
|
.getByText(/Message received|收到消息|メッセージ受信/, { exact: true })
|
||||||
|
.waitFor({ timeout: 15_000 });
|
||||||
|
await adapterDialog.getByText("message.received", { exact: true }).waitFor();
|
||||||
|
await adapterDialog.getByText(inboundText, { exact: true }).waitFor();
|
||||||
|
result.visible_signals.push(
|
||||||
|
"adapter-event-listening",
|
||||||
|
"adapter-event-received",
|
||||||
|
"adapter-event-raw-code",
|
||||||
|
);
|
||||||
|
await adapterDialog
|
||||||
|
.getByRole("button", { name: /Close|关闭|閉じる/ })
|
||||||
|
.click();
|
||||||
|
await adapterDialog.waitFor({ state: "hidden" });
|
||||||
|
|
||||||
const text = await bodyText(page);
|
const text = await bodyText(page);
|
||||||
if (/\bEBA event\b/.test(text)) {
|
if (/\bEBA event\b/.test(text)) {
|
||||||
@@ -308,7 +343,7 @@ try {
|
|||||||
}
|
}
|
||||||
result.status = "pass";
|
result.status = "pass";
|
||||||
result.reason =
|
result.reason =
|
||||||
"Bot event routing, dry-run, synthetic dispatch, and visible route status passed in the WebUI.";
|
"Bot event routing, dry-run, real adapter input, and visible route status passed in the WebUI.";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
|
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
|
||||||
result.reason = result.reason || error.message;
|
result.reason = result.reason || error.message;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ The tools wrap the LangBot service layer. Current tools (v1):
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `get_system_info` | Version, edition, instance id |
|
| `get_system_info` | Version, edition, instance id |
|
||||||
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
|
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
|
||||||
| `list_bot_event_route_statuses` / `test_bot_event_route` | Inspect bot event-route runtime status and dispatch a synthetic test event through saved routes without sending real outbound platform messages |
|
| `list_bot_event_route_statuses` | Inspect bot event-route runtime status |
|
||||||
| `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent and Pipeline processor types |
|
| `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent and Pipeline processor types |
|
||||||
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
|
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
|
||||||
| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers |
|
| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers |
|
||||||
@@ -78,12 +78,6 @@ shape as the corresponding HTTP API request body. Discover resources with the
|
|||||||
`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.
|
||||||
|
|
||||||
`test_bot_event_route` uses the bot's saved runtime route table, injects a
|
|
||||||
synthetic event such as `message.received`, and suppresses platform delivery.
|
|
||||||
It still executes the selected processor, so tools and external services may
|
|
||||||
have side effects. Use `payload` for sample event fields, for example
|
|
||||||
`{"message_text": "hello", "chat_type": "private", "chat_id": "u1"}`.
|
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml).
|
1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
id: bot-event-routing-product-flow
|
id: bot-event-routing-product-flow
|
||||||
title: "Bot event routing can be configured and tested from the WebUI"
|
title: "Bot event routing and adapter input can be inspected from the WebUI"
|
||||||
mode: agent-browser
|
mode: agent-browser
|
||||||
area: bot
|
area: bot
|
||||||
type: feature
|
type: feature
|
||||||
@@ -33,16 +33,18 @@ steps:
|
|||||||
- "Confirm the adapter capability summary, friendly event name, target, and route status are visible."
|
- "Confirm the adapter capability summary, friendly event name, target, and route status are visible."
|
||||||
- "Confirm overlapping routes and unmatched-event fallback behavior are explained before save."
|
- "Confirm overlapping routes and unmatched-event fallback behavior are explained before save."
|
||||||
- "Open Test event route and run a dry-run against the current form."
|
- "Open Test event route and run a dry-run against the current form."
|
||||||
- "Run the saved runtime route with a synthetic event."
|
- "Open Platform event debugging from Adapter Configuration and send a real inbound event through the HTTP Bot adapter."
|
||||||
- "Close the dialog and confirm the route card shows the latest discarded status."
|
- "Confirm the normalized event, raw event code, payload, and latest discarded route status are visible."
|
||||||
checks:
|
checks:
|
||||||
- "UI: A user can choose a channel and add a scenario-labeled behavior during initial Bot creation."
|
- "UI: A user can choose a channel and add a scenario-labeled behavior during initial Bot creation."
|
||||||
- "UI: Event routing uses user-facing labels and does not require the raw event name in the primary route card."
|
- "UI: Event routing uses user-facing labels and does not require the raw event name in the primary route card."
|
||||||
- "UI: Definite route shadowing and unmatched-event fallback behavior are visible without opening raw logs."
|
- "UI: Definite route shadowing and unmatched-event fallback behavior are visible without opening raw logs."
|
||||||
- "UI: Dry-run visibly reports that the route matched the discard processor."
|
- "UI: Dry-run visibly reports that the route matched the discard processor."
|
||||||
- "UI: Saved-route execution visibly succeeds, explains its side-effect boundary, and updates route status to discarded."
|
- "UI: Adapter event debugging lives under Adapter Configuration, remains separate from route preview, and starts listening only after the dialog opens."
|
||||||
|
- "UI: A real adapter event shows its friendly name, raw code, and normalized event data."
|
||||||
|
- "UI: The route card updates to discarded after the real inbound event is handled."
|
||||||
- "Console: No unexpected frontend errors appear during the flow."
|
- "Console: No unexpected frontend errors appear during the flow."
|
||||||
- "Network: Bot, dry-run, route-status, and test-event requests return without 5xx responses."
|
- "Network: Bot, dry-run, route-status, log, and HTTP Bot webhook requests return without 5xx responses."
|
||||||
- "Cleanup: The temporary Bot is deleted after evidence is collected."
|
- "Cleanup: The temporary Bot is deleted after evidence is collected."
|
||||||
evidence_required:
|
evidence_required:
|
||||||
- ui
|
- ui
|
||||||
@@ -51,7 +53,8 @@ evidence_required:
|
|||||||
- api_diagnostic
|
- api_diagnostic
|
||||||
diagnostics:
|
diagnostics:
|
||||||
- "The fixture deliberately uses the discard processor so the product-flow test cannot invoke a model, tool, or external callback."
|
- "The fixture deliberately uses the discard processor so the product-flow test cannot invoke a model, tool, or external callback."
|
||||||
- "A passing API call without the visible matched and discarded UI states is not a pass."
|
- "The adapter dialog observes normalized platform events; it does not simulate route matching."
|
||||||
|
- "A passing webhook call without the visible adapter event and discarded UI states is not a pass."
|
||||||
troubleshooting:
|
troubleshooting:
|
||||||
- backend-not-listening
|
- backend-not-listening
|
||||||
- proxy-env-mismatch
|
- proxy-env-mismatch
|
||||||
|
|||||||
@@ -48,6 +48,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
|
||||||
@@ -499,6 +507,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.
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class AgentBindingResolver:
|
|||||||
event: AgentEventEnvelope,
|
event: AgentEventEnvelope,
|
||||||
agents: list[AgentConfig],
|
agents: list[AgentConfig],
|
||||||
) -> AgentBinding:
|
) -> AgentBinding:
|
||||||
"""Resolve exactly one enabled Agent for the event.
|
"""Resolve exactly one Agent for the event.
|
||||||
|
|
||||||
Callers that source agents from bot/workspace/global configuration must
|
Callers that source agents from bot/workspace/global configuration must
|
||||||
pre-filter candidates to the event scope before calling this resolver.
|
pre-filter candidates to the event scope before calling this resolver.
|
||||||
@@ -30,7 +30,7 @@ class AgentBindingResolver:
|
|||||||
Agent and does not carry enough scope metadata to make that decision
|
Agent and does not carry enough scope metadata to make that decision
|
||||||
safely here.
|
safely here.
|
||||||
"""
|
"""
|
||||||
matches = [agent for agent in agents if agent.enabled and event.event_type in agent.event_types]
|
matches = [agent for agent in agents if event.event_type in agent.event_types]
|
||||||
|
|
||||||
if not matches:
|
if not matches:
|
||||||
raise AgentBindingResolutionError(f'No Agent binding matches event_type={event.event_type}')
|
raise AgentBindingResolutionError(f'No Agent binding matches event_type={event.event_type}')
|
||||||
@@ -59,7 +59,6 @@ class AgentBindingResolver:
|
|||||||
resource_policy=agent.resource_policy,
|
resource_policy=agent.resource_policy,
|
||||||
state_policy=agent.state_policy,
|
state_policy=agent.state_policy,
|
||||||
delivery_policy=agent.delivery_policy,
|
delivery_policy=agent.delivery_policy,
|
||||||
enabled=agent.enabled,
|
|
||||||
agent_id=agent.agent_id,
|
agent_id=agent.agent_id,
|
||||||
processor_type=agent.processor_type,
|
processor_type=agent.processor_type,
|
||||||
processor_id=agent.processor_id or agent.agent_id,
|
processor_id=agent.processor_id or agent.agent_id,
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
"""Agent runner errors."""
|
"""Agent runner errors."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
class AgentRunnerError(Exception):
|
class AgentRunnerError(Exception):
|
||||||
"""Base error for agent runner operations."""
|
"""Base error for agent runner operations."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class RunnerNotFoundError(AgentRunnerError):
|
class RunnerNotFoundError(AgentRunnerError):
|
||||||
"""Runner not found in registry."""
|
"""Runner not found in registry."""
|
||||||
|
|
||||||
def __init__(self, runner_id: str):
|
def __init__(self, runner_id: str):
|
||||||
self.runner_id = runner_id
|
self.runner_id = runner_id
|
||||||
super().__init__(f'Agent runner not found: {runner_id}')
|
super().__init__(f'Agent runner not found: {runner_id}')
|
||||||
@@ -16,6 +19,7 @@ class RunnerNotFoundError(AgentRunnerError):
|
|||||||
|
|
||||||
class RunnerNotAuthorizedError(AgentRunnerError):
|
class RunnerNotAuthorizedError(AgentRunnerError):
|
||||||
"""Runner not authorized for this binding."""
|
"""Runner not authorized for this binding."""
|
||||||
|
|
||||||
def __init__(self, runner_id: str, bound_plugins: list[str] | None):
|
def __init__(self, runner_id: str, bound_plugins: list[str] | None):
|
||||||
self.runner_id = runner_id
|
self.runner_id = runner_id
|
||||||
self.bound_plugins = bound_plugins
|
self.bound_plugins = bound_plugins
|
||||||
@@ -24,6 +28,7 @@ class RunnerNotAuthorizedError(AgentRunnerError):
|
|||||||
|
|
||||||
class RunnerProtocolError(AgentRunnerError):
|
class RunnerProtocolError(AgentRunnerError):
|
||||||
"""Runner protocol version mismatch or invalid manifest."""
|
"""Runner protocol version mismatch or invalid manifest."""
|
||||||
|
|
||||||
def __init__(self, runner_id: str, message: str):
|
def __init__(self, runner_id: str, message: str):
|
||||||
self.runner_id = runner_id
|
self.runner_id = runner_id
|
||||||
super().__init__(f'Agent runner protocol error for {runner_id}: {message}')
|
super().__init__(f'Agent runner protocol error for {runner_id}: {message}')
|
||||||
@@ -31,7 +36,16 @@ class RunnerProtocolError(AgentRunnerError):
|
|||||||
|
|
||||||
class RunnerExecutionError(AgentRunnerError):
|
class RunnerExecutionError(AgentRunnerError):
|
||||||
"""Runner execution failed."""
|
"""Runner execution failed."""
|
||||||
def __init__(self, runner_id: str, message: str, retryable: bool = False):
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
runner_id: str,
|
||||||
|
message: str,
|
||||||
|
retryable: bool = False,
|
||||||
|
error_code: str | None = None,
|
||||||
|
):
|
||||||
self.runner_id = runner_id
|
self.runner_id = runner_id
|
||||||
|
self.message = message
|
||||||
self.retryable = retryable
|
self.retryable = retryable
|
||||||
|
self.error_code = error_code
|
||||||
super().__init__(f'Agent runner {runner_id} execution failed: {message}')
|
super().__init__(f'Agent runner {runner_id} execution failed: {message}')
|
||||||
|
|||||||
@@ -181,9 +181,6 @@ class AgentConfig(pydantic.BaseModel):
|
|||||||
event_types: list[str] = pydantic.Field(default_factory=lambda: ['message.received'])
|
event_types: list[str] = pydantic.Field(default_factory=lambda: ['message.received'])
|
||||||
"""Event types this Agent handles."""
|
"""Event types this Agent handles."""
|
||||||
|
|
||||||
enabled: bool = True
|
|
||||||
"""Whether this Agent can be selected by a binding resolver."""
|
|
||||||
|
|
||||||
metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
||||||
"""Non-protocol diagnostic metadata, such as legacy config source."""
|
"""Non-protocol diagnostic metadata, such as legacy config source."""
|
||||||
|
|
||||||
@@ -219,9 +216,6 @@ class AgentBinding(pydantic.BaseModel):
|
|||||||
delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy)
|
delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy)
|
||||||
"""Delivery policy."""
|
"""Delivery policy."""
|
||||||
|
|
||||||
enabled: bool = True
|
|
||||||
"""Whether binding is enabled."""
|
|
||||||
|
|
||||||
agent_id: str | None = None
|
agent_id: str | None = None
|
||||||
"""Host-side Agent/config identifier for this binding."""
|
"""Host-side Agent/config identifier for this binding."""
|
||||||
|
|
||||||
|
|||||||
@@ -58,21 +58,21 @@ class AgentRunnerInvoker:
|
|||||||
except asyncio.TimeoutError as e:
|
except asyncio.TimeoutError as e:
|
||||||
raise RunnerExecutionError(
|
raise RunnerExecutionError(
|
||||||
descriptor.id,
|
descriptor.id,
|
||||||
'Runner timed out (code: runner.timeout)',
|
'Runner timed out',
|
||||||
retryable=True,
|
retryable=True,
|
||||||
|
error_code='runner.timeout',
|
||||||
) from e
|
) from e
|
||||||
except ActionCallTimeoutError as e:
|
except ActionCallTimeoutError as e:
|
||||||
raise RunnerExecutionError(
|
raise RunnerExecutionError(
|
||||||
descriptor.id,
|
descriptor.id,
|
||||||
f'{e} (code: runner.timeout)',
|
str(e),
|
||||||
retryable=True,
|
retryable=True,
|
||||||
|
error_code='runner.timeout',
|
||||||
) from e
|
) from e
|
||||||
except RunnerExecutionError:
|
except RunnerExecutionError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(
|
self.ap.logger.error(f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}')
|
||||||
f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}'
|
|
||||||
)
|
|
||||||
raise RunnerExecutionError(
|
raise RunnerExecutionError(
|
||||||
descriptor.id,
|
descriptor.id,
|
||||||
str(e),
|
str(e),
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ class QueryEntryAdapter:
|
|||||||
state_policy=state_policy,
|
state_policy=state_policy,
|
||||||
delivery_policy=delivery_policy,
|
delivery_policy=delivery_policy,
|
||||||
event_types=[event_type],
|
event_types=[event_type],
|
||||||
enabled=True,
|
|
||||||
metadata={'source': 'pipeline_adapter'},
|
metadata={'source': 'pipeline_adapter'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -152,10 +152,14 @@ class AgentResultNormalizer:
|
|||||||
error_msg = data.get('error', 'Unknown error')
|
error_msg = data.get('error', 'Unknown error')
|
||||||
error_code = data.get('code', 'unknown')
|
error_code = data.get('code', 'unknown')
|
||||||
retryable = data.get('retryable', False)
|
retryable = data.get('retryable', False)
|
||||||
|
normalized_error_code = str(error_code or '').strip()
|
||||||
raise RunnerExecutionError(
|
raise RunnerExecutionError(
|
||||||
descriptor.id,
|
descriptor.id,
|
||||||
f'{error_msg} (code: {error_code})',
|
str(error_msg),
|
||||||
retryable=retryable,
|
retryable=retryable,
|
||||||
|
error_code=(
|
||||||
|
normalized_error_code if normalized_error_code and normalized_error_code != 'unknown' else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
elif result_type == 'action.requested':
|
elif result_type == 'action.requested':
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import quart
|
import quart
|
||||||
|
|
||||||
|
from .....agent.runner.errors import (
|
||||||
|
AgentRunnerError,
|
||||||
|
RunnerExecutionError,
|
||||||
|
RunnerNotAuthorizedError,
|
||||||
|
RunnerNotFoundError,
|
||||||
|
RunnerProtocolError,
|
||||||
|
)
|
||||||
from ...authz import Permission, require_permission
|
from ...authz import Permission, require_permission
|
||||||
from ...context import RequestContext
|
from ...context import RequestContext
|
||||||
from .. import group
|
from .. import group
|
||||||
@@ -63,6 +70,36 @@ class AgentsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
|
except RunnerExecutionError as exc:
|
||||||
|
return self.http_status(
|
||||||
|
422,
|
||||||
|
exc.error_code or 'runner_execution_failed',
|
||||||
|
exc.message,
|
||||||
|
)
|
||||||
|
except RunnerNotFoundError:
|
||||||
|
return self.http_status(
|
||||||
|
409,
|
||||||
|
'runner_not_found',
|
||||||
|
'The configured Agent runner is unavailable',
|
||||||
|
)
|
||||||
|
except RunnerNotAuthorizedError:
|
||||||
|
return self.http_status(
|
||||||
|
403,
|
||||||
|
'runner_not_authorized',
|
||||||
|
'The configured Agent runner is not authorized',
|
||||||
|
)
|
||||||
|
except RunnerProtocolError:
|
||||||
|
return self.http_status(
|
||||||
|
502,
|
||||||
|
'runner_protocol_error',
|
||||||
|
'The Agent runner returned an invalid response',
|
||||||
|
)
|
||||||
|
except AgentRunnerError:
|
||||||
|
return self.http_status(
|
||||||
|
502,
|
||||||
|
'runner_error',
|
||||||
|
'The Agent runner could not complete this test',
|
||||||
|
)
|
||||||
return self.success(data=result)
|
return self.success(data=result)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
|
|||||||
@@ -90,15 +90,9 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||||
return self.success(
|
return self.success(data=await self.ap.bot_service.list_event_route_statuses(request_context, bot_uuid))
|
||||||
data=await self.ap.bot_service.list_event_route_statuses(
|
|
||||||
request_context, bot_uuid
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _dry_run_event_route(
|
async def _dry_run_event_route(bot_uuid: str, request_context: RequestContext) -> str:
|
||||||
bot_uuid: str, request_context: RequestContext
|
|
||||||
) -> str:
|
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
if not isinstance(json_data, dict):
|
if not isinstance(json_data, dict):
|
||||||
return self.http_status(400, -1, 'invalid request body')
|
return self.http_status(400, -1, 'invalid request body')
|
||||||
@@ -128,24 +122,6 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)(_dry_run_event_route)
|
)(_dry_run_event_route)
|
||||||
|
|
||||||
@self.route(
|
|
||||||
'/<bot_uuid>/event-routes/test',
|
|
||||||
methods=['POST'],
|
|
||||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
|
||||||
permission=Permission.RUNTIME_OPERATE,
|
|
||||||
)
|
|
||||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
|
||||||
json_data = await quart.request.json
|
|
||||||
if not isinstance(json_data, dict):
|
|
||||||
return self.http_status(400, -1, 'invalid request body')
|
|
||||||
result = await self.ap.bot_service.dispatch_test_event_route(
|
|
||||||
request_context,
|
|
||||||
bot_uuid=bot_uuid,
|
|
||||||
event_type=json_data.get('event_type'),
|
|
||||||
payload=json_data.get('event_data', json_data.get('payload')),
|
|
||||||
)
|
|
||||||
return self.success(data=result)
|
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<bot_uuid>/send_message',
|
'/<bot_uuid>/send_message',
|
||||||
methods=['POST'],
|
methods=['POST'],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -201,7 +201,6 @@ class AgentService:
|
|||||||
enable_reply=False,
|
enable_reply=False,
|
||||||
enable_interactions=False,
|
enable_interactions=False,
|
||||||
),
|
),
|
||||||
enabled=True,
|
|
||||||
agent_id=agent_uuid,
|
agent_id=agent_uuid,
|
||||||
processor_type='agent',
|
processor_type='agent',
|
||||||
processor_id=agent_uuid,
|
processor_id=agent_uuid,
|
||||||
@@ -293,7 +292,6 @@ class AgentService:
|
|||||||
'kind': AGENT_KIND_AGENT,
|
'kind': AGENT_KIND_AGENT,
|
||||||
'component_ref': runner_id,
|
'component_ref': runner_id,
|
||||||
'config': config,
|
'config': config,
|
||||||
'enabled': agent_data.get('enabled', True),
|
|
||||||
'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
|
'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
|
||||||
}
|
}
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values))
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values))
|
||||||
@@ -308,17 +306,11 @@ class AgentService:
|
|||||||
await self.ap.pipeline_service.update_pipeline(context, agent_uuid, agent_data)
|
await self.ap.pipeline_service.update_pipeline(context, agent_uuid, agent_data)
|
||||||
return
|
return
|
||||||
|
|
||||||
update_data = agent_data.copy()
|
update_data = {
|
||||||
for protected_field in (
|
field: agent_data[field]
|
||||||
'uuid',
|
for field in ('name', 'description', 'emoji', 'config', 'supported_event_patterns')
|
||||||
'workspace_uuid',
|
if field in agent_data
|
||||||
'kind',
|
}
|
||||||
'component_ref',
|
|
||||||
'created_at',
|
|
||||||
'updated_at',
|
|
||||||
'capability',
|
|
||||||
):
|
|
||||||
update_data.pop(protected_field, None)
|
|
||||||
if 'config' in update_data:
|
if 'config' in update_data:
|
||||||
config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(update_data['config'])
|
config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(update_data['config'])
|
||||||
update_data['config'] = config
|
update_data['config'] = config
|
||||||
@@ -425,7 +417,6 @@ class AgentService:
|
|||||||
item = pipeline.copy()
|
item = pipeline.copy()
|
||||||
item['kind'] = AGENT_KIND_PIPELINE
|
item['kind'] = AGENT_KIND_PIPELINE
|
||||||
item['component_ref'] = 'pipeline'
|
item['component_ref'] = 'pipeline'
|
||||||
item['enabled'] = True
|
|
||||||
item['supported_event_patterns'] = PIPELINE_EVENT_PATTERNS
|
item['supported_event_patterns'] = PIPELINE_EVENT_PATTERNS
|
||||||
item['capability'] = {
|
item['capability'] = {
|
||||||
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
|
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ class BotService:
|
|||||||
|
|
||||||
ap: app.Application
|
ap: app.Application
|
||||||
FAILURE_ROUTE_NOT_FOUND = 'route_not_found'
|
FAILURE_ROUTE_NOT_FOUND = 'route_not_found'
|
||||||
FAILURE_PROCESSOR_DISABLED = 'processor_disabled'
|
|
||||||
FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found'
|
FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found'
|
||||||
FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible'
|
FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible'
|
||||||
FAILURE_INVALID_EVENT = 'invalid_event'
|
FAILURE_INVALID_EVENT = 'invalid_event'
|
||||||
@@ -276,14 +275,10 @@ class BotService:
|
|||||||
)
|
)
|
||||||
return result.first()
|
return result.first()
|
||||||
|
|
||||||
async def _get_agent_entity(
|
async def _get_agent_entity(self, context: TenantContext, agent_uuid: str) -> persistence_agent.Agent | None:
|
||||||
self, context: TenantContext, agent_uuid: str
|
|
||||||
) -> persistence_agent.Agent | None:
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.select(persistence_agent.Agent).where(
|
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid),
|
||||||
persistence_agent.Agent.uuid == agent_uuid
|
|
||||||
),
|
|
||||||
persistence_agent.Agent,
|
persistence_agent.Agent,
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
@@ -390,11 +385,7 @@ class BotService:
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
pipeline = (
|
pipeline = await self._get_pipeline_entity(tenant_context, target_uuid) if target_uuid else None
|
||||||
await self._get_pipeline_entity(tenant_context, target_uuid)
|
|
||||||
if target_uuid
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if pipeline is None:
|
if pipeline is None:
|
||||||
return self._diagnostic_result(
|
return self._diagnostic_result(
|
||||||
matched=False,
|
matched=False,
|
||||||
@@ -443,25 +434,6 @@ class BotService:
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
if not getattr(agent, 'enabled', True):
|
|
||||||
return self._diagnostic_result(
|
|
||||||
matched=False,
|
|
||||||
binding=selected_binding,
|
|
||||||
failure_code=self.FAILURE_PROCESSOR_DISABLED,
|
|
||||||
reason='Agent target is disabled',
|
|
||||||
diagnostic_steps=diagnostic_steps
|
|
||||||
+ [
|
|
||||||
{
|
|
||||||
'step': 'validate_processor',
|
|
||||||
'binding_id': selected_binding.get('id'),
|
|
||||||
'target_type': target_type,
|
|
||||||
'target_uuid': target_uuid,
|
|
||||||
'matched': False,
|
|
||||||
'failure_code': self.FAILURE_PROCESSOR_DISABLED,
|
|
||||||
'reason': 'Agent target is disabled',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
if not RuntimeBot._agent_supports_event_type(getattr(agent, 'supported_event_patterns', None), event_type):
|
if not RuntimeBot._agent_supports_event_type(getattr(agent, 'supported_event_patterns', None), event_type):
|
||||||
return self._diagnostic_result(
|
return self._diagnostic_result(
|
||||||
matched=False,
|
matched=False,
|
||||||
@@ -509,9 +481,7 @@ class BotService:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _normalize_event_bindings(
|
async def _normalize_event_bindings(self, context: TenantContext, bindings: list[dict] | None) -> list[dict]:
|
||||||
self, context: TenantContext, bindings: list[dict] | None
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Validate and normalize Bot event bindings."""
|
"""Validate and normalize Bot event bindings."""
|
||||||
if not bindings:
|
if not bindings:
|
||||||
return []
|
return []
|
||||||
@@ -544,9 +514,7 @@ class BotService:
|
|||||||
elif target_type == 'agent':
|
elif target_type == 'agent':
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.select(persistence_agent.Agent).where(
|
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid),
|
||||||
persistence_agent.Agent.uuid == target_uuid
|
|
||||||
),
|
|
||||||
persistence_agent.Agent,
|
persistence_agent.Agent,
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
@@ -577,9 +545,7 @@ class BotService:
|
|||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
async def _prepare_bot_data(
|
async def _prepare_bot_data(self, context: TenantContext, bot_data: dict, *, include_uuid: bool) -> dict:
|
||||||
self, context: TenantContext, bot_data: dict, *, include_uuid: bool
|
|
||||||
) -> dict:
|
|
||||||
"""Normalize Bot write payloads to the current event-routing model."""
|
"""Normalize Bot write payloads to the current event-routing model."""
|
||||||
update_data = bot_data.copy()
|
update_data = bot_data.copy()
|
||||||
if not include_uuid:
|
if not include_uuid:
|
||||||
@@ -705,6 +671,17 @@ class BotService:
|
|||||||
)
|
)
|
||||||
if getattr(result, 'rowcount', None) == 0:
|
if getattr(result, 'rowcount', None) == 0:
|
||||||
raise WorkspaceNotFoundError('Bot not found')
|
raise WorkspaceNotFoundError('Bot not found')
|
||||||
|
|
||||||
|
runtime_fields = {'adapter', 'adapter_config', 'enable', 'event_bindings'}
|
||||||
|
if not runtime_fields.intersection(update_data):
|
||||||
|
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
|
||||||
|
if runtime_bot is not None:
|
||||||
|
if 'name' in update_data:
|
||||||
|
runtime_bot.bot_entity.name = update_data['name']
|
||||||
|
if 'description' in update_data:
|
||||||
|
runtime_bot.bot_entity.description = update_data['description']
|
||||||
|
return
|
||||||
|
|
||||||
await self.ap.platform_mgr.remove_bot(context, bot_uuid)
|
await self.ap.platform_mgr.remove_bot(context, bot_uuid)
|
||||||
|
|
||||||
# select from db
|
# select from db
|
||||||
@@ -750,21 +727,19 @@ class BotService:
|
|||||||
|
|
||||||
return [log.to_json() for log in logs], total_count
|
return [log.to_json() for log in logs], total_count
|
||||||
|
|
||||||
async def list_event_route_statuses(
|
async def list_event_route_statuses(self, context: TenantContext, bot_uuid: str) -> dict[str, typing.Any]:
|
||||||
self, context: TenantContext, bot_uuid: str
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Return recent runtime status for Bot event routes from in-memory Bot logs."""
|
"""Return recent runtime status for Bot event routes from in-memory Bot logs."""
|
||||||
from ....platform.botmgr import RuntimeBot
|
from ....platform.botmgr import RuntimeBot
|
||||||
|
|
||||||
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
|
bot = await self.get_bot(context, bot_uuid, include_secret=False)
|
||||||
|
if bot is None:
|
||||||
raise WorkspaceNotFoundError('Bot not found')
|
raise WorkspaceNotFoundError('Bot not found')
|
||||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
|
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
|
||||||
if runtime_bot is None:
|
|
||||||
raise Exception('Bot not found')
|
|
||||||
|
|
||||||
latest_by_binding: dict[str, dict[str, typing.Any]] = {}
|
latest_by_binding: dict[str, dict[str, typing.Any]] = {}
|
||||||
unmatched_events: list[dict[str, typing.Any]] = []
|
unmatched_events: list[dict[str, typing.Any]] = []
|
||||||
for log in getattr(runtime_bot.logger, 'logs', []):
|
runtime_logs = getattr(getattr(runtime_bot, 'logger', None), 'logs', [])
|
||||||
|
for log in runtime_logs:
|
||||||
status = self._event_route_status_from_log(log)
|
status = self._event_route_status_from_log(log)
|
||||||
if status is None:
|
if status is None:
|
||||||
continue
|
continue
|
||||||
@@ -774,7 +749,10 @@ class BotService:
|
|||||||
else:
|
else:
|
||||||
unmatched_events.append(status)
|
unmatched_events.append(status)
|
||||||
|
|
||||||
raw_bindings = getattr(getattr(runtime_bot, 'bot_entity', None), 'event_bindings', [])
|
runtime_entity = getattr(runtime_bot, 'bot_entity', None)
|
||||||
|
raw_bindings = getattr(runtime_entity, 'event_bindings', None) if runtime_entity is not None else None
|
||||||
|
if raw_bindings is None:
|
||||||
|
raw_bindings = bot.get('event_bindings') or []
|
||||||
bindings = RuntimeBot._get_event_bindings_from_value(raw_bindings)
|
bindings = RuntimeBot._get_event_bindings_from_value(raw_bindings)
|
||||||
routes: list[dict[str, typing.Any]] = []
|
routes: list[dict[str, typing.Any]] = []
|
||||||
current_binding_ids: set[str] = set()
|
current_binding_ids: set[str] = set()
|
||||||
@@ -819,61 +797,6 @@ class BotService:
|
|||||||
'stale_routes': stale_routes,
|
'stale_routes': stale_routes,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def dispatch_test_event_route(
|
|
||||||
self,
|
|
||||||
context: TenantContext,
|
|
||||||
bot_uuid: str,
|
|
||||||
event_type: str,
|
|
||||||
payload: dict[str, typing.Any] | None = None,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Dispatch a synthetic event through the saved Bot runtime route configuration."""
|
|
||||||
event_type = str(event_type or '').strip()
|
|
||||||
if not event_type:
|
|
||||||
return {
|
|
||||||
'dispatched': False,
|
|
||||||
'event_type': '',
|
|
||||||
'failure_code': self.FAILURE_INVALID_EVENT,
|
|
||||||
'reason': 'event_type is required',
|
|
||||||
'suppressed_outputs': [],
|
|
||||||
'route_status': {
|
|
||||||
'routes': [],
|
|
||||||
'unmatched_events': [],
|
|
||||||
'stale_routes': [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if payload is not None and not isinstance(payload, dict):
|
|
||||||
return {
|
|
||||||
'dispatched': False,
|
|
||||||
'event_type': event_type,
|
|
||||||
'failure_code': self.FAILURE_INVALID_EVENT,
|
|
||||||
'reason': 'payload must be an object',
|
|
||||||
'suppressed_outputs': [],
|
|
||||||
'route_status': {
|
|
||||||
'routes': [],
|
|
||||||
'unmatched_events': [],
|
|
||||||
'stale_routes': [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
|
|
||||||
raise WorkspaceNotFoundError('Bot not found')
|
|
||||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
|
|
||||||
if runtime_bot is None:
|
|
||||||
raise Exception('Bot not found')
|
|
||||||
|
|
||||||
dispatch_result = await runtime_bot.dispatch_test_event(event_type, payload or {})
|
|
||||||
route_status = await self.list_event_route_statuses(context, bot_uuid)
|
|
||||||
return {
|
|
||||||
'dispatched': bool(dispatch_result.get('dispatched')),
|
|
||||||
'event_type': event_type,
|
|
||||||
'status': dispatch_result.get('status'),
|
|
||||||
'binding_id': dispatch_result.get('binding_id'),
|
|
||||||
'failure_code': dispatch_result.get('failure_code'),
|
|
||||||
'reason': dispatch_result.get('reason'),
|
|
||||||
'suppressed_outputs': dispatch_result.get('suppressed_outputs', []),
|
|
||||||
'route_status': route_status,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
|
|||||||
@@ -132,25 +132,6 @@ class LangBotMCPServer:
|
|||||||
async def list_bot_event_route_statuses(bot_uuid: str) -> str:
|
async def list_bot_event_route_statuses(bot_uuid: str) -> str:
|
||||||
return _dump(await ap.bot_service.list_event_route_statuses(bot_uuid))
|
return _dump(await ap.bot_service.list_event_route_statuses(bot_uuid))
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
description=(
|
|
||||||
'Dispatch a synthetic event through the saved bot event routes. '
|
|
||||||
'This validates routing without sending real outbound platform messages.'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
async def test_bot_event_route(
|
|
||||||
bot_uuid: str,
|
|
||||||
event_type: str,
|
|
||||||
payload: dict | None = None,
|
|
||||||
) -> str:
|
|
||||||
return _dump(
|
|
||||||
await ap.bot_service.dispatch_test_event_route(
|
|
||||||
bot_uuid=bot_uuid,
|
|
||||||
event_type=event_type,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# ----- Pipelines ----------------------------------------------- #
|
# ----- Pipelines ----------------------------------------------- #
|
||||||
@mcp.tool(description='List all pipelines.')
|
@mcp.tool(description='List all pipelines.')
|
||||||
async def list_pipelines() -> str:
|
async def list_pipelines() -> str:
|
||||||
|
|||||||
@@ -1225,8 +1225,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
|
||||||
|
|
||||||
@@ -1280,14 +1281,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
|
||||||
|
|
||||||
|
|||||||
@@ -315,11 +315,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],
|
||||||
@@ -336,7 +361,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 情况下退出,这里创建一个不会结束的协程
|
||||||
@@ -362,6 +386,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().
|
||||||
@@ -543,6 +568,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
|
||||||
@@ -297,14 +297,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()
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class Agent(Base):
|
|||||||
kind = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='agent')
|
kind = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='agent')
|
||||||
component_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
component_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
|
||||||
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
|
|
||||||
supported_event_patterns = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=['*'])
|
supported_event_patterns = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=['*'])
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Drop the obsolete Agent enabled state.
|
||||||
|
|
||||||
|
Revision ID: 0023_drop_agent_enabled
|
||||||
|
Revises: 0022_merge_agent_reasoning_heads
|
||||||
|
Create Date: 2026-08-25
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = '0023_drop_agent_enabled'
|
||||||
|
down_revision = '0022_merge_agent_reasoning_heads'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||||
|
if table_name not in inspector.get_table_names():
|
||||||
|
return False
|
||||||
|
return any(column['name'] == column_name for column in inspector.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if _column_exists(inspector, 'agents', 'enabled'):
|
||||||
|
with op.batch_alter_table('agents') as batch_op:
|
||||||
|
batch_op.drop_column('enabled')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if 'agents' in inspector.get_table_names() and not _column_exists(inspector, 'agents', 'enabled'):
|
||||||
|
with op.batch_alter_table('agents') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||||
@@ -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()
|
||||||
@@ -328,6 +328,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:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class BanWordFilter(filter_model.ContentFilter):
|
|||||||
return entities.FilterResult(
|
return entities.FilterResult(
|
||||||
level=entities.ResultLevel.BLOCK,
|
level=entities.ResultLevel.BLOCK,
|
||||||
replacement='',
|
replacement='',
|
||||||
user_notice='内容检查规则执行失败,请联系管理员',
|
user_notice='内容安全检查配置有误,请检查敏感词设置',
|
||||||
console_notice=f'Sensitive-word regex rejected: {exc}',
|
console_notice=f'Sensitive-word regex rejected: {exc}',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -51,226 +51,6 @@ from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
|||||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||||
|
|
||||||
|
|
||||||
class SyntheticRouteTestAdapter:
|
|
||||||
"""Adapter wrapper that suppresses outbound platform delivery for test events."""
|
|
||||||
|
|
||||||
SIDE_EFFECT_API_NAMES = {
|
|
||||||
'send_message',
|
|
||||||
'reply_message',
|
|
||||||
'reply_message_chunk',
|
|
||||||
'create_message_card',
|
|
||||||
'edit_message',
|
|
||||||
'delete_message',
|
|
||||||
'add_reaction',
|
|
||||||
'remove_reaction',
|
|
||||||
'forward_message',
|
|
||||||
'set_group_name',
|
|
||||||
'mute_member',
|
|
||||||
'unmute_member',
|
|
||||||
'kick_member',
|
|
||||||
'leave_group',
|
|
||||||
'approve_friend_request',
|
|
||||||
'approve_group_invite',
|
|
||||||
'upload_file',
|
|
||||||
'call_platform_api',
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, source: abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|
||||||
self.source = source
|
|
||||||
self.bot_account_id = getattr(source, 'bot_account_id', '')
|
|
||||||
self.config = getattr(source, 'config', {})
|
|
||||||
self.logger = getattr(source, 'logger', None)
|
|
||||||
self.suppressed_outputs: list[dict[str, typing.Any]] = []
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _message_to_payload(message: platform_message.MessageChain) -> typing.Any:
|
|
||||||
return message.model_dump() if hasattr(message, 'model_dump') else str(message)
|
|
||||||
|
|
||||||
def _suppress(self, method: str, **payload: typing.Any) -> None:
|
|
||||||
self.suppressed_outputs.append({'method': method, **payload})
|
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> typing.Any:
|
|
||||||
return getattr(self.source, name)
|
|
||||||
|
|
||||||
def get_supported_apis(self) -> list[str]:
|
|
||||||
get_supported_apis = getattr(self.source, 'get_supported_apis', None)
|
|
||||||
if not callable(get_supported_apis):
|
|
||||||
return []
|
|
||||||
return [api_name for api_name in get_supported_apis() if api_name not in self.SIDE_EFFECT_API_NAMES]
|
|
||||||
|
|
||||||
async def send_message(
|
|
||||||
self,
|
|
||||||
target_type: str,
|
|
||||||
target_id: str,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
self._suppress(
|
|
||||||
'send_message',
|
|
||||||
target_type=target_type,
|
|
||||||
target_id=target_id,
|
|
||||||
message=self._message_to_payload(message),
|
|
||||||
)
|
|
||||||
return {'suppressed': True}
|
|
||||||
|
|
||||||
async def reply_message(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
quote_origin: bool = False,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
self._suppress(
|
|
||||||
'reply_message',
|
|
||||||
message=self._message_to_payload(message),
|
|
||||||
quote_origin=quote_origin,
|
|
||||||
)
|
|
||||||
return {'suppressed': True}
|
|
||||||
|
|
||||||
async def reply_message_chunk(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
bot_message: dict,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
quote_origin: bool = False,
|
|
||||||
is_final: bool = False,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
self._suppress(
|
|
||||||
'reply_message_chunk',
|
|
||||||
message=self._message_to_payload(message),
|
|
||||||
quote_origin=quote_origin,
|
|
||||||
is_final=is_final,
|
|
||||||
)
|
|
||||||
return {'suppressed': True}
|
|
||||||
|
|
||||||
async def create_message_card(
|
|
||||||
self,
|
|
||||||
message_id: str | int,
|
|
||||||
event: platform_events.MessageEvent,
|
|
||||||
) -> bool:
|
|
||||||
self._suppress('create_message_card', message_id=str(message_id))
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def edit_message(
|
|
||||||
self,
|
|
||||||
chat_type: str,
|
|
||||||
chat_id: typing.Union[int, str],
|
|
||||||
message_id: typing.Union[int, str],
|
|
||||||
new_content: platform_message.MessageChain,
|
|
||||||
) -> None:
|
|
||||||
self._suppress(
|
|
||||||
'edit_message',
|
|
||||||
chat_type=str(chat_type),
|
|
||||||
chat_id=str(chat_id),
|
|
||||||
message_id=str(message_id),
|
|
||||||
new_content=self._message_to_payload(new_content),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def delete_message(
|
|
||||||
self,
|
|
||||||
chat_type: str,
|
|
||||||
chat_id: typing.Union[int, str],
|
|
||||||
message_id: typing.Union[int, str],
|
|
||||||
) -> None:
|
|
||||||
self._suppress(
|
|
||||||
'delete_message',
|
|
||||||
chat_type=str(chat_type),
|
|
||||||
chat_id=str(chat_id),
|
|
||||||
message_id=str(message_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def forward_message(
|
|
||||||
self,
|
|
||||||
from_chat_type: str,
|
|
||||||
from_chat_id: typing.Union[int, str],
|
|
||||||
message_id: typing.Union[int, str],
|
|
||||||
to_chat_type: str,
|
|
||||||
to_chat_id: typing.Union[int, str],
|
|
||||||
) -> platform_events.MessageResult:
|
|
||||||
self._suppress(
|
|
||||||
'forward_message',
|
|
||||||
from_chat_type=str(from_chat_type),
|
|
||||||
from_chat_id=str(from_chat_id),
|
|
||||||
message_id=str(message_id),
|
|
||||||
to_chat_type=str(to_chat_type),
|
|
||||||
to_chat_id=str(to_chat_id),
|
|
||||||
)
|
|
||||||
return platform_events.MessageResult(raw={'suppressed': True})
|
|
||||||
|
|
||||||
async def set_group_name(
|
|
||||||
self,
|
|
||||||
group_id: typing.Union[int, str],
|
|
||||||
name: str,
|
|
||||||
) -> None:
|
|
||||||
self._suppress('set_group_name', group_id=str(group_id), name=name)
|
|
||||||
|
|
||||||
async def mute_member(
|
|
||||||
self,
|
|
||||||
group_id: typing.Union[int, str],
|
|
||||||
user_id: typing.Union[int, str],
|
|
||||||
duration: int = 0,
|
|
||||||
) -> None:
|
|
||||||
self._suppress(
|
|
||||||
'mute_member',
|
|
||||||
group_id=str(group_id),
|
|
||||||
user_id=str(user_id),
|
|
||||||
duration=duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def unmute_member(
|
|
||||||
self,
|
|
||||||
group_id: typing.Union[int, str],
|
|
||||||
user_id: typing.Union[int, str],
|
|
||||||
) -> None:
|
|
||||||
self._suppress('unmute_member', group_id=str(group_id), user_id=str(user_id))
|
|
||||||
|
|
||||||
async def kick_member(
|
|
||||||
self,
|
|
||||||
group_id: typing.Union[int, str],
|
|
||||||
user_id: typing.Union[int, str],
|
|
||||||
) -> None:
|
|
||||||
self._suppress('kick_member', group_id=str(group_id), user_id=str(user_id))
|
|
||||||
|
|
||||||
async def leave_group(
|
|
||||||
self,
|
|
||||||
group_id: typing.Union[int, str],
|
|
||||||
) -> None:
|
|
||||||
self._suppress('leave_group', group_id=str(group_id))
|
|
||||||
|
|
||||||
async def approve_friend_request(
|
|
||||||
self,
|
|
||||||
request_id: typing.Union[int, str],
|
|
||||||
approve: bool = True,
|
|
||||||
remark: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._suppress(
|
|
||||||
'approve_friend_request',
|
|
||||||
request_id=str(request_id),
|
|
||||||
approve=approve,
|
|
||||||
remark=remark,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def approve_group_invite(
|
|
||||||
self,
|
|
||||||
request_id: typing.Union[int, str],
|
|
||||||
approve: bool = True,
|
|
||||||
) -> None:
|
|
||||||
self._suppress(
|
|
||||||
'approve_group_invite',
|
|
||||||
request_id=str(request_id),
|
|
||||||
approve=approve,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
|
||||||
self._suppress('upload_file', filename=filename, size=len(file_data))
|
|
||||||
return f'suppressed:{filename}'
|
|
||||||
|
|
||||||
async def call_platform_api(self, action: str, params: dict | None = None) -> dict:
|
|
||||||
self._suppress('call_platform_api', action=action, params=params or {})
|
|
||||||
return {'suppressed': True}
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeBot:
|
class RuntimeBot:
|
||||||
"""运行时机器人"""
|
"""运行时机器人"""
|
||||||
|
|
||||||
@@ -568,126 +348,6 @@ class RuntimeBot:
|
|||||||
"""Return the selected event binding plus per-binding diagnostic steps."""
|
"""Return the selected event binding plus per-binding diagnostic steps."""
|
||||||
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
|
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_test_platform_event(
|
|
||||||
event_type: str,
|
|
||||||
payload: dict[str, typing.Any] | None = None,
|
|
||||||
) -> platform_events.EBAEvent:
|
|
||||||
"""Build a synthetic platform event for route validation."""
|
|
||||||
payload = payload or {}
|
|
||||||
now = time.time()
|
|
||||||
common = {
|
|
||||||
'type': event_type,
|
|
||||||
'timestamp': payload.get('timestamp') or now,
|
|
||||||
'adapter_name': payload.get('adapter_name') or 'test-event',
|
|
||||||
'source_platform_object': {'synthetic': True, 'payload': payload},
|
|
||||||
}
|
|
||||||
|
|
||||||
user_id = str(payload.get('user_id') or payload.get('sender_id') or 'test-user')
|
|
||||||
user_name = str(payload.get('user_name') or payload.get('sender_name') or 'Test User')
|
|
||||||
group_id = str(payload.get('group_id') or payload.get('chat_id') or 'test-group')
|
|
||||||
group_name = str(payload.get('group_name') or 'Test Group')
|
|
||||||
|
|
||||||
if event_type == 'message.received':
|
|
||||||
chat_type_value = str(payload.get('chat_type') or 'private')
|
|
||||||
chat_type = (
|
|
||||||
platform_entities.ChatType.GROUP
|
|
||||||
if chat_type_value == platform_entities.ChatType.GROUP.value
|
|
||||||
else platform_entities.ChatType.PRIVATE
|
|
||||||
)
|
|
||||||
chat_id = str(
|
|
||||||
payload.get('chat_id') or (group_id if chat_type == platform_entities.ChatType.GROUP else user_id)
|
|
||||||
)
|
|
||||||
message_text = str(payload.get('message_text') or payload.get('text') or '')
|
|
||||||
message_chain_data = payload.get('message_chain')
|
|
||||||
if message_chain_data is None:
|
|
||||||
message_chain = platform_message.MessageChain([platform_message.Plain(text=message_text)])
|
|
||||||
else:
|
|
||||||
message_chain = platform_message.MessageChain.model_validate(message_chain_data)
|
|
||||||
group = (
|
|
||||||
platform_entities.UserGroup(id=chat_id, name=group_name)
|
|
||||||
if chat_type == platform_entities.ChatType.GROUP
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return platform_events.MessageReceivedEvent(
|
|
||||||
**common,
|
|
||||||
message_id=str(payload.get('message_id') or f'test-message:{uuid.uuid4()}'),
|
|
||||||
message_chain=message_chain,
|
|
||||||
sender=platform_entities.User(id=user_id, nickname=user_name),
|
|
||||||
chat_type=chat_type,
|
|
||||||
chat_id=chat_id,
|
|
||||||
group=group,
|
|
||||||
)
|
|
||||||
|
|
||||||
if event_type == 'group.member_joined':
|
|
||||||
return platform_events.MemberJoinedEvent(
|
|
||||||
**common,
|
|
||||||
group=platform_entities.UserGroup(id=group_id, name=group_name),
|
|
||||||
member=platform_entities.User(id=user_id, nickname=user_name),
|
|
||||||
inviter=platform_entities.User(
|
|
||||||
id=str(payload.get('inviter_id')),
|
|
||||||
nickname=str(payload.get('inviter_name') or ''),
|
|
||||||
)
|
|
||||||
if payload.get('inviter_id')
|
|
||||||
else None,
|
|
||||||
join_type=payload.get('join_type'),
|
|
||||||
)
|
|
||||||
|
|
||||||
if event_type == 'group.member_left':
|
|
||||||
return platform_events.MemberLeftEvent(
|
|
||||||
**common,
|
|
||||||
group=platform_entities.UserGroup(id=group_id, name=group_name),
|
|
||||||
member=platform_entities.User(id=user_id, nickname=user_name),
|
|
||||||
is_kicked=bool(payload.get('is_kicked', False)),
|
|
||||||
operator=platform_entities.User(
|
|
||||||
id=str(payload.get('operator_id')),
|
|
||||||
nickname=str(payload.get('operator_name') or ''),
|
|
||||||
)
|
|
||||||
if payload.get('operator_id')
|
|
||||||
else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if event_type == 'platform.specific':
|
|
||||||
return platform_events.PlatformSpecificEvent(
|
|
||||||
**common,
|
|
||||||
action=str(payload.get('action') or 'test'),
|
|
||||||
data=payload.get('data') if isinstance(payload.get('data'), dict) else payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
return platform_events.EBAEvent(**common)
|
|
||||||
|
|
||||||
async def dispatch_test_event(
|
|
||||||
self,
|
|
||||||
event_type: str,
|
|
||||||
payload: dict[str, typing.Any] | None = None,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Dispatch a synthetic event through the real runtime route path."""
|
|
||||||
event_type = str(event_type or '').strip()
|
|
||||||
if not event_type:
|
|
||||||
raise ValueError('event_type is required')
|
|
||||||
|
|
||||||
event = self._build_test_platform_event(event_type, payload)
|
|
||||||
await self._record_event_route_trace(
|
|
||||||
event_type=event_type,
|
|
||||||
status='test_started',
|
|
||||||
reason='Synthetic test event dispatched from control plane',
|
|
||||||
text=f'Test event {event_type} dispatched from control plane',
|
|
||||||
)
|
|
||||||
test_adapter = SyntheticRouteTestAdapter(self.adapter)
|
|
||||||
outcome = await self._dispatch_eba_event_to_processor(
|
|
||||||
event,
|
|
||||||
typing.cast(abstract_platform_adapter.AbstractMessagePlatformAdapter, test_adapter),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
'event_type': event_type,
|
|
||||||
'dispatched': outcome['status'] in {'delivered', 'discarded'},
|
|
||||||
'status': outcome['status'],
|
|
||||||
'binding_id': outcome.get('binding_id'),
|
|
||||||
'failure_code': outcome.get('failure_code'),
|
|
||||||
'reason': outcome.get('reason'),
|
|
||||||
'suppressed_outputs': test_adapter.suppressed_outputs,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _record_event_route_trace(
|
async def _record_event_route_trace(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -784,6 +444,26 @@ class RuntimeBot:
|
|||||||
compact[key] = value
|
compact[key] = value
|
||||||
return compact
|
return compact
|
||||||
|
|
||||||
|
async def _record_adapter_event(
|
||||||
|
self,
|
||||||
|
event: platform_events.EBAEvent,
|
||||||
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
|
) -> dict[str, typing.Any]:
|
||||||
|
"""Record a normalized adapter event for the platform debugging surface."""
|
||||||
|
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
||||||
|
metadata = {
|
||||||
|
'kind': 'adapter_event_received',
|
||||||
|
'event_type': event_type,
|
||||||
|
'event_data': self._compact_event_data(event),
|
||||||
|
'adapter': getattr(self.bot_entity, 'adapter', None) or adapter.__class__.__name__,
|
||||||
|
'bot_uuid': self.bot_entity.uuid,
|
||||||
|
}
|
||||||
|
await self.logger.info(
|
||||||
|
f'Platform adapter received {event_type}',
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return metadata
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_entity_id(entity: typing.Any) -> str | None:
|
def _get_entity_id(entity: typing.Any) -> str | None:
|
||||||
entity_id = getattr(entity, 'id', None)
|
entity_id = getattr(entity, 'id', None)
|
||||||
@@ -1135,7 +815,6 @@ class RuntimeBot:
|
|||||||
enable_reply=True,
|
enable_reply=True,
|
||||||
enable_interactions=True,
|
enable_interactions=True,
|
||||||
),
|
),
|
||||||
enabled=True,
|
|
||||||
agent_id=agent.get('uuid'),
|
agent_id=agent.get('uuid'),
|
||||||
processor_type='agent',
|
processor_type='agent',
|
||||||
processor_id=agent.get('uuid'),
|
processor_id=agent.get('uuid'),
|
||||||
@@ -1171,7 +850,7 @@ class RuntimeBot:
|
|||||||
self,
|
self,
|
||||||
envelope: AgentEventEnvelope,
|
envelope: AgentEventEnvelope,
|
||||||
outputs: list[provider_message.Message | provider_message.MessageChunk],
|
outputs: list[provider_message.Message | provider_message.MessageChunk],
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | SyntheticRouteTestAdapter | None = None,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not outputs or not envelope.delivery.reply_target:
|
if not outputs or not envelope.delivery.reply_target:
|
||||||
return
|
return
|
||||||
@@ -1205,11 +884,13 @@ class RuntimeBot:
|
|||||||
event: platform_events.EBAEvent,
|
event: platform_events.EBAEvent,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
event.bot_uuid = self.bot_entity.uuid
|
||||||
|
await self._record_adapter_event(event, adapter)
|
||||||
|
|
||||||
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted':
|
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted':
|
||||||
await self._handle_interaction_submission(event, adapter)
|
await self._handle_interaction_submission(event, adapter)
|
||||||
return
|
return
|
||||||
|
|
||||||
event.bot_uuid = self.bot_entity.uuid
|
|
||||||
plugin_event = self._eba_event_to_plugin_event(event)
|
plugin_event = self._eba_event_to_plugin_event(event)
|
||||||
|
|
||||||
if plugin_event is not None:
|
if plugin_event is not None:
|
||||||
@@ -1322,17 +1003,6 @@ class RuntimeBot:
|
|||||||
reason='Agent target not found',
|
reason='Agent target not found',
|
||||||
text=f'EBA event {event_type} target agent not found: {target_uuid}',
|
text=f'EBA event {event_type} target agent not found: {target_uuid}',
|
||||||
)
|
)
|
||||||
if not agent.get('enabled', True):
|
|
||||||
return await self._record_event_route_trace(
|
|
||||||
event_type=event_type,
|
|
||||||
status='failed',
|
|
||||||
binding=event_binding,
|
|
||||||
target_type=target_type,
|
|
||||||
target_uuid=target_uuid,
|
|
||||||
failure_code='processor_disabled',
|
|
||||||
reason='Agent target is disabled',
|
|
||||||
text=f'EBA event {event_type} target agent disabled: {target_uuid}',
|
|
||||||
)
|
|
||||||
if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type):
|
if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type):
|
||||||
return await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
@@ -1711,7 +1381,7 @@ class RuntimeBot:
|
|||||||
self.execution_context,
|
self.execution_context,
|
||||||
record['processor_id'],
|
record['processor_id'],
|
||||||
)
|
)
|
||||||
if not agent or agent.get('kind') != 'agent' or not agent.get('enabled', True):
|
if not agent or agent.get('kind') != 'agent':
|
||||||
raise ValueError(f'Interaction target Agent is unavailable: {record["processor_id"]}')
|
raise ValueError(f'Interaction target Agent is unavailable: {record["processor_id"]}')
|
||||||
|
|
||||||
binding = self._agent_product_to_binding(
|
binding = self._agent_product_to_binding(
|
||||||
@@ -1810,9 +1480,7 @@ class RuntimeBot:
|
|||||||
def tenant_scoped_listener(listener):
|
def tenant_scoped_listener(listener):
|
||||||
@functools.wraps(listener)
|
@functools.wraps(listener)
|
||||||
async def wrapped(*args, **kwargs):
|
async def wrapped(*args, **kwargs):
|
||||||
tenant_scope = getattr(
|
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
|
||||||
self.ap.persistence_mgr, 'tenant_scope', None
|
|
||||||
)
|
|
||||||
cloud_runtime = (
|
cloud_runtime = (
|
||||||
getattr(
|
getattr(
|
||||||
getattr(self.ap.persistence_mgr, 'mode', None),
|
getattr(self.ap.persistence_mgr, 'mode', None),
|
||||||
@@ -1823,9 +1491,7 @@ class RuntimeBot:
|
|||||||
)
|
)
|
||||||
if cloud_runtime:
|
if cloud_runtime:
|
||||||
if not callable(tenant_scope):
|
if not callable(tenant_scope):
|
||||||
raise RuntimeError(
|
raise RuntimeError('Cloud platform callbacks require a tenant scope')
|
||||||
'Cloud platform callbacks require a tenant scope'
|
|
||||||
)
|
|
||||||
async with tenant_scope(self.workspace_uuid):
|
async with tenant_scope(self.workspace_uuid):
|
||||||
return await listener(*args, **kwargs)
|
return await listener(*args, **kwargs)
|
||||||
return await listener(*args, **kwargs)
|
return await listener(*args, **kwargs)
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _history_message_chain(message_chain: list[dict]) -> list[dict]:
|
def _history_message_chain(message_chain: list[dict]) -> list[dict]:
|
||||||
"""Remove large transient payloads before retaining browser history."""
|
"""Retain renderable references without storing large inline payloads."""
|
||||||
|
|
||||||
history = []
|
history = []
|
||||||
for component in message_chain:
|
for component in message_chain:
|
||||||
@@ -551,7 +551,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
Image / Voice / File components uploaded from the web client carry a
|
Image / Voice / File components uploaded from the web client carry a
|
||||||
storage key in ``path``. Resolve it to a base64 data URI so downstream
|
storage key in ``path``. Resolve it to a base64 data URI so downstream
|
||||||
stages (multimodal LLM input and the Box sandbox inbox) have a usable
|
stages (multimodal LLM input and the Box sandbox inbox) have a usable
|
||||||
payload, then drop the now-consumed storage object.
|
payload. Keep image objects for the short-lived browser history so the
|
||||||
|
authenticated image endpoint can render them; normal upload retention
|
||||||
|
cleanup removes them later. Other attachment types are consumed
|
||||||
|
immediately because the chat history does not render them by path.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message_chain_obj: 消息链对象列表
|
message_chain_obj: 消息链对象列表
|
||||||
@@ -606,12 +609,13 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream'
|
mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream'
|
||||||
|
|
||||||
component['base64'] = f'data:{mime_type};base64,{base64_str}'
|
component['base64'] = f'data:{mime_type};base64,{base64_str}'
|
||||||
await storage_mgr.delete_scoped_object_key(
|
if comp_type != 'Image':
|
||||||
execution_context,
|
await storage_mgr.delete_scoped_object_key(
|
||||||
comp_path,
|
execution_context,
|
||||||
expected_owner_type='upload_image',
|
comp_path,
|
||||||
)
|
expected_owner_type='upload_image',
|
||||||
component['path'] = ''
|
)
|
||||||
|
component['path'] = ''
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}')
|
await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}')
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -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()):
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
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
|
||||||
@@ -832,6 +834,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,
|
||||||
@@ -1661,25 +1676,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={},
|
||||||
@@ -1722,6 +1794,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',
|
||||||
@@ -1768,10 +1863,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(
|
||||||
@@ -1810,7 +1914,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())),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2477,13 +2581,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()
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ from collections.abc import Sequence
|
|||||||
import regex
|
import regex
|
||||||
|
|
||||||
|
|
||||||
MAX_PATTERN_COUNT = 64
|
# The bundled sensitive-word list already contains more than 64 entries. Keep
|
||||||
|
# the deterministic cap, but leave enough room for the built-in defaults and
|
||||||
|
# reasonable administrator customisation.
|
||||||
|
MAX_PATTERN_COUNT = 256
|
||||||
MAX_PATTERN_CHARS = 1024
|
MAX_PATTERN_CHARS = 1024
|
||||||
MAX_INPUT_CHARS = 1024 * 1024
|
MAX_INPUT_CHARS = 1024 * 1024
|
||||||
MAX_REPLACEMENT_CHARS = 64
|
MAX_REPLACEMENT_CHARS = 64
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -171,18 +171,6 @@ def fake_bot_app():
|
|||||||
'diagnostic_details': [{'step': 'evaluate_binding', 'binding_id': 'binding-1', 'matched': True}],
|
'diagnostic_details': [{'step': 'evaluate_binding', 'binding_id': 'binding-1', 'matched': True}],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
app.bot_service.dispatch_test_event_route = AsyncMock(
|
|
||||||
return_value={
|
|
||||||
'dispatched': True,
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'suppressed_outputs': [],
|
|
||||||
'route_status': {
|
|
||||||
'routes': [],
|
|
||||||
'unmatched_events': [],
|
|
||||||
'stale_routes': [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
app.bot_service.send_message = AsyncMock()
|
app.bot_service.send_message = AsyncMock()
|
||||||
|
|
||||||
# Platform manager
|
# Platform manager
|
||||||
@@ -373,35 +361,6 @@ class TestBotEventRouteStatusEndpoint:
|
|||||||
fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with(ANY, 'test-bot-uuid')
|
fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with(ANY, 'test-bot-uuid')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
|
||||||
class TestBotEventRouteTestEndpoint:
|
|
||||||
"""Tests for bot event route synthetic dispatch endpoint."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dispatch_test_event_route_success(self, quart_test_client, fake_bot_app):
|
|
||||||
"""POST test route dispatches a synthetic event."""
|
|
||||||
response = await quart_test_client.post(
|
|
||||||
'/api/v1/platform/bots/test-bot-uuid/event-routes/test',
|
|
||||||
headers={'Authorization': 'Bearer test_token'},
|
|
||||||
json={
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'payload': {'message_text': 'hello'},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = await response.get_json()
|
|
||||||
assert data['code'] == 0
|
|
||||||
assert data['data']['dispatched'] is True
|
|
||||||
assert data['data']['event_type'] == 'message.received'
|
|
||||||
fake_bot_app.bot_service.dispatch_test_event_route.assert_awaited_with(
|
|
||||||
ANY,
|
|
||||||
bot_uuid='test-bot-uuid',
|
|
||||||
event_type='message.received',
|
|
||||||
payload={'message_text': 'hello'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||||
class TestBotSendMessageEndpoint:
|
class TestBotSendMessageEndpoint:
|
||||||
"""Tests for bot send message endpoint."""
|
"""Tests for bot send message endpoint."""
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class TestSQLiteMigrationUpgrade:
|
|||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||||
assert _get_script_head() == '0022_merge_agent_reasoning_heads'
|
assert _get_script_head() == '0023_drop_agent_enabled'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine):
|
async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine):
|
||||||
@@ -120,7 +120,7 @@ class TestSQLiteMigrationUpgrade:
|
|||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||||
assert _get_script_head() == '0022_merge_agent_reasoning_heads'
|
assert _get_script_head() == '0023_drop_agent_enabled'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
|
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
|
||||||
@@ -131,7 +131,23 @@ class TestSQLiteMigrationUpgrade:
|
|||||||
await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config')
|
await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config')
|
||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
assert await get_alembic_current(sqlite_engine) == '0022_merge_agent_reasoning_heads'
|
assert await get_alembic_current(sqlite_engine) == '0023_drop_agent_enabled'
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upgrade_removes_agent_enabled_column(self, sqlite_engine):
|
||||||
|
async with sqlite_engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await conn.exec_driver_sql('ALTER TABLE agents ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT 1')
|
||||||
|
|
||||||
|
await run_alembic_stamp(sqlite_engine, '0022_merge_agent_reasoning_heads')
|
||||||
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
|
async with sqlite_engine.connect() as conn:
|
||||||
|
columns = await conn.run_sync(
|
||||||
|
lambda sync_conn: {column['name'] for column in sqlalchemy.inspect(sync_conn).get_columns('agents')}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'enabled' not in columns
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -220,13 +220,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')
|
||||||
@@ -234,8 +238,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()
|
||||||
|
|
||||||
|
|
||||||
@@ -452,6 +456,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:
|
||||||
@@ -466,7 +509,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},
|
||||||
@@ -474,7 +517,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},
|
||||||
)
|
)
|
||||||
@@ -483,12 +526,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()
|
||||||
|
|
||||||
|
|||||||
@@ -56,18 +56,6 @@ def build_ap() -> SimpleNamespace:
|
|||||||
ap.bot_service = SimpleNamespace(
|
ap.bot_service = SimpleNamespace(
|
||||||
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}]),
|
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}]),
|
||||||
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
|
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
|
||||||
dispatch_test_event_route=AsyncMock(
|
|
||||||
return_value={
|
|
||||||
'dispatched': True,
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'suppressed_outputs': [],
|
|
||||||
'route_status': {
|
|
||||||
'routes': [],
|
|
||||||
'unmatched_events': [],
|
|
||||||
'stale_routes': [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}]))
|
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}]))
|
||||||
ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[]))
|
ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[]))
|
||||||
@@ -126,7 +114,7 @@ async def main() -> int:
|
|||||||
tools = await session.list_tools()
|
tools = await session.list_tools()
|
||||||
names = [t.name for t in tools.tools]
|
names = [t.name for t in tools.tools]
|
||||||
print(f'PASS: listed {len(names)} tools')
|
print(f'PASS: listed {len(names)} tools')
|
||||||
for required in ('list_bots', 'get_system_info', 'list_skills', 'test_bot_event_route'):
|
for required in ('list_bots', 'get_system_info', 'list_skills'):
|
||||||
if required not in names:
|
if required not in names:
|
||||||
failures.append(f'missing tool {required}')
|
failures.append(f'missing tool {required}')
|
||||||
|
|
||||||
@@ -144,20 +132,6 @@ async def main() -> int:
|
|||||||
else:
|
else:
|
||||||
print('PASS: get_system_info returned version')
|
print('PASS: get_system_info returned version')
|
||||||
|
|
||||||
res3 = await session.call_tool(
|
|
||||||
'test_bot_event_route',
|
|
||||||
{
|
|
||||||
'bot_uuid': 'bot-1',
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'payload': {'message_text': 'hello'},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
text3 = res3.content[0].text if res3.content else ''
|
|
||||||
if '"dispatched": true' not in text3:
|
|
||||||
failures.append(f'test_bot_event_route wrong: {text3!r}')
|
|
||||||
else:
|
|
||||||
print('PASS: test_bot_event_route returned dispatch result')
|
|
||||||
|
|
||||||
shutdown.set()
|
shutdown.set()
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await asyncio.wait_for(server_task, timeout=5)
|
await asyncio.wait_for(server_task, timeout=5)
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ class TestContextValidation:
|
|||||||
runner_id='plugin:test/plugin/runner',
|
runner_id='plugin:test/plugin/runner',
|
||||||
runner_config={'timeout': 300},
|
runner_config={'timeout': 300},
|
||||||
agent_id='pipeline_1',
|
agent_id='pipeline_1',
|
||||||
enabled=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _make_resources(self) -> BuilderResources:
|
def _make_resources(self) -> BuilderResources:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Tests for EventLog, Transcript, and history/event APIs."""
|
"""Tests for EventLog, Transcript, and history/event APIs."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
@@ -24,45 +25,46 @@ from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryCo
|
|||||||
|
|
||||||
|
|
||||||
def make_event_envelope(
|
def make_event_envelope(
|
||||||
event_id: str = "evt_1",
|
event_id: str = 'evt_1',
|
||||||
event_type: str = "message.received",
|
event_type: str = 'message.received',
|
||||||
conversation_id: str | None = "conv_1",
|
conversation_id: str | None = 'conv_1',
|
||||||
actor_id: str | None = "user_1",
|
actor_id: str | None = 'user_1',
|
||||||
input_text: str = "Hello",
|
input_text: str = 'Hello',
|
||||||
) -> AgentEventEnvelope:
|
) -> AgentEventEnvelope:
|
||||||
"""Create a test event envelope."""
|
"""Create a test event envelope."""
|
||||||
return AgentEventEnvelope(
|
return AgentEventEnvelope(
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
event_time=1700000000,
|
event_time=1700000000,
|
||||||
source="platform",
|
source='platform',
|
||||||
bot_id="bot_1",
|
bot_id='bot_1',
|
||||||
workspace_id=None,
|
workspace_id=None,
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
thread_id=None,
|
thread_id=None,
|
||||||
actor=ActorContext(
|
actor=ActorContext(
|
||||||
actor_type="user",
|
actor_type='user',
|
||||||
actor_id=actor_id,
|
actor_id=actor_id,
|
||||||
actor_name="Test User",
|
actor_name='Test User',
|
||||||
) if actor_id else None,
|
)
|
||||||
|
if actor_id
|
||||||
|
else None,
|
||||||
subject=None,
|
subject=None,
|
||||||
input=AgentInput(text=input_text),
|
input=AgentInput(text=input_text),
|
||||||
delivery=DeliveryContext(surface="test"),
|
delivery=DeliveryContext(surface='test'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_binding(runner_id: str = "plugin:test/plugin/runner") -> AgentBinding:
|
def make_binding(runner_id: str = 'plugin:test/plugin/runner') -> AgentBinding:
|
||||||
"""Create a test binding."""
|
"""Create a test binding."""
|
||||||
return AgentBinding(
|
return AgentBinding(
|
||||||
binding_id="binding_1",
|
binding_id='binding_1',
|
||||||
scope=BindingScope(scope_type="agent", scope_id="pipeline_1"),
|
scope=BindingScope(scope_type='agent', scope_id='pipeline_1'),
|
||||||
event_types=["message.received"],
|
event_types=['message.received'],
|
||||||
runner_id=runner_id,
|
runner_id=runner_id,
|
||||||
runner_config={},
|
runner_config={},
|
||||||
resource_policy=ResourcePolicy(),
|
resource_policy=ResourcePolicy(),
|
||||||
state_policy=StatePolicy(),
|
state_policy=StatePolicy(),
|
||||||
delivery_policy=DeliveryPolicy(),
|
delivery_policy=DeliveryPolicy(),
|
||||||
enabled=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -84,19 +86,19 @@ class TestEventLogStore:
|
|||||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||||
|
|
||||||
event_id = await store.append_event(
|
event_id = await store.append_event(
|
||||||
event_id="evt_1",
|
event_id='evt_1',
|
||||||
event_type="message.received",
|
event_type='message.received',
|
||||||
source="platform",
|
source='platform',
|
||||||
bot_id="bot_1",
|
bot_id='bot_1',
|
||||||
conversation_id="conv_1",
|
conversation_id='conv_1',
|
||||||
actor_type="user",
|
actor_type='user',
|
||||||
actor_id="user_1",
|
actor_id='user_1',
|
||||||
input_summary="Hello world",
|
input_summary='Hello world',
|
||||||
run_id="run_1",
|
run_id='run_1',
|
||||||
runner_id="plugin:test/plugin/runner",
|
runner_id='plugin:test/plugin/runner',
|
||||||
)
|
)
|
||||||
|
|
||||||
assert event_id == "evt_1"
|
assert event_id == 'evt_1'
|
||||||
stored_event = mock_session.add.call_args.args[0]
|
stored_event = mock_session.add.call_args.args[0]
|
||||||
assert stored_event.metadata_json is None
|
assert stored_event.metadata_json is None
|
||||||
|
|
||||||
@@ -115,20 +117,20 @@ class TestEventLogStore:
|
|||||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||||
|
|
||||||
event_id = await store.append_event(
|
event_id = await store.append_event(
|
||||||
event_id="evt_steering",
|
event_id='evt_steering',
|
||||||
event_type="message.received",
|
event_type='message.received',
|
||||||
source="platform",
|
source='platform',
|
||||||
run_id="run_1",
|
run_id='run_1',
|
||||||
runner_id="plugin:test/plugin/runner",
|
runner_id='plugin:test/plugin/runner',
|
||||||
metadata={
|
metadata={
|
||||||
"steering": {
|
'steering': {
|
||||||
"status": "queued",
|
'status': 'queued',
|
||||||
"claimed_by_run_id": "run_1",
|
'claimed_by_run_id': 'run_1',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert event_id == "evt_steering"
|
assert event_id == 'evt_steering'
|
||||||
stored_event = mock_session.add.call_args.args[0]
|
stored_event = mock_session.add.call_args.args[0]
|
||||||
assert '"status": "queued"' in stored_event.metadata_json
|
assert '"status": "queued"' in stored_event.metadata_json
|
||||||
assert '"claimed_by_run_id": "run_1"' in stored_event.metadata_json
|
assert '"claimed_by_run_id": "run_1"' in stored_event.metadata_json
|
||||||
@@ -147,15 +149,15 @@ class TestEventLogStore:
|
|||||||
with patch.object(store, '_session_factory') as mock_factory:
|
with patch.object(store, '_session_factory') as mock_factory:
|
||||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||||
|
|
||||||
long_text = "x" * 2000
|
long_text = 'x' * 2000
|
||||||
event_id = await store.append_event(
|
event_id = await store.append_event(
|
||||||
event_id="evt_2",
|
event_id='evt_2',
|
||||||
event_type="message.received",
|
event_type='message.received',
|
||||||
source="platform",
|
source='platform',
|
||||||
input_summary=long_text,
|
input_summary=long_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert event_id == "evt_2"
|
assert event_id == 'evt_2'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_page_events_with_conversation_filter(self, mock_db_engine):
|
async def test_page_events_with_conversation_filter(self, mock_db_engine):
|
||||||
@@ -174,7 +176,7 @@ class TestEventLogStore:
|
|||||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||||
|
|
||||||
items, next_seq, has_more = await store.page_events(
|
items, next_seq, has_more = await store.page_events(
|
||||||
conversation_id="conv_1",
|
conversation_id='conv_1',
|
||||||
limit=10,
|
limit=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -202,10 +204,10 @@ class TestTranscriptStore:
|
|||||||
|
|
||||||
transcript_id = await store.append_transcript(
|
transcript_id = await store.append_transcript(
|
||||||
transcript_id=None, # Auto-generate
|
transcript_id=None, # Auto-generate
|
||||||
event_id="evt_1",
|
event_id='evt_1',
|
||||||
conversation_id="conv_1",
|
conversation_id='conv_1',
|
||||||
role="user",
|
role='user',
|
||||||
content="Hello",
|
content='Hello',
|
||||||
)
|
)
|
||||||
|
|
||||||
assert transcript_id is not None
|
assert transcript_id is not None
|
||||||
@@ -227,13 +229,11 @@ class TestTranscriptStore:
|
|||||||
|
|
||||||
transcript_id = await store.append_transcript(
|
transcript_id = await store.append_transcript(
|
||||||
transcript_id=None, # Auto-generate
|
transcript_id=None, # Auto-generate
|
||||||
event_id="evt_2",
|
event_id='evt_2',
|
||||||
conversation_id="conv_1",
|
conversation_id='conv_1',
|
||||||
role="assistant",
|
role='assistant',
|
||||||
content="Here's an image",
|
content="Here's an image",
|
||||||
attachment_refs=[
|
attachment_refs=[{'id': 'att_1', 'type': 'image', 'url': 'http://example.com/img.png'}],
|
||||||
{"id": "att_1", "type": "image", "url": "http://example.com/img.png"}
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert transcript_id is not None
|
assert transcript_id is not None
|
||||||
@@ -255,9 +255,9 @@ class TestTranscriptStore:
|
|||||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||||
|
|
||||||
items, next_seq, prev_seq, has_more = await store.page_transcript(
|
items, next_seq, prev_seq, has_more = await store.page_transcript(
|
||||||
conversation_id="conv_1",
|
conversation_id='conv_1',
|
||||||
limit=10,
|
limit=10,
|
||||||
direction="backward",
|
direction='backward',
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(items, list)
|
assert isinstance(items, list)
|
||||||
@@ -280,7 +280,7 @@ class TestTranscriptStore:
|
|||||||
|
|
||||||
# Request more than the hard limit
|
# Request more than the hard limit
|
||||||
items, next_seq, prev_seq, has_more = await store.page_transcript(
|
items, next_seq, prev_seq, has_more = await store.page_transcript(
|
||||||
conversation_id="conv_1",
|
conversation_id='conv_1',
|
||||||
limit=200, # Request 200, but hard limit is 100
|
limit=200, # Request 200, but hard limit is 100
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -304,8 +304,8 @@ class TestTranscriptStore:
|
|||||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||||
|
|
||||||
items = await store.search_transcript(
|
items = await store.search_transcript(
|
||||||
conversation_id="conv_1",
|
conversation_id='conv_1',
|
||||||
query_text="database",
|
query_text='database',
|
||||||
top_k=10,
|
top_k=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -323,11 +323,11 @@ class TestHistoryPageAuthorization:
|
|||||||
# Mock call_action to simulate the handler
|
# Mock call_action to simulate the handler
|
||||||
result = await mock_handler.call_action(
|
result = await mock_handler.call_action(
|
||||||
PluginToRuntimeAction.HISTORY_PAGE,
|
PluginToRuntimeAction.HISTORY_PAGE,
|
||||||
{"run_id": None},
|
{'run_id': None},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should return error
|
# Should return error
|
||||||
assert result.get("ok") is False or "error" in str(result).lower()
|
assert result.get('ok') is False or 'error' in str(result).lower()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_history_page_validates_conversation_scope(self, mock_db_engine):
|
async def test_history_page_validates_conversation_scope(self, mock_db_engine):
|
||||||
@@ -337,20 +337,20 @@ class TestHistoryPageAuthorization:
|
|||||||
session_registry = get_session_registry()
|
session_registry = get_session_registry()
|
||||||
|
|
||||||
await session_registry.register(
|
await session_registry.register(
|
||||||
run_id="run_1",
|
run_id='run_1',
|
||||||
runner_id="plugin:test/plugin/runner",
|
runner_id='plugin:test/plugin/runner',
|
||||||
query_id=None,
|
query_id=None,
|
||||||
plugin_identity="test/plugin",
|
plugin_identity='test/plugin',
|
||||||
resources={"models": [], "tools": [], "knowledge_bases": [], "storage": {"plugin_storage": True}},
|
resources={'models': [], 'tools': [], 'knowledge_bases': [], 'storage': {'plugin_storage': True}},
|
||||||
conversation_id="conv_1",
|
conversation_id='conv_1',
|
||||||
)
|
)
|
||||||
|
|
||||||
session = await session_registry.get("run_1")
|
session = await session_registry.get('run_1')
|
||||||
assert session is not None
|
assert session is not None
|
||||||
assert session["authorization"]["conversation_id"] == "conv_1"
|
assert session['authorization']['conversation_id'] == 'conv_1'
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
await session_registry.unregister("run_1")
|
await session_registry.unregister('run_1')
|
||||||
|
|
||||||
|
|
||||||
class TestEventGetAuthorization:
|
class TestEventGetAuthorization:
|
||||||
@@ -363,11 +363,11 @@ class TestEventGetAuthorization:
|
|||||||
|
|
||||||
result = await mock_handler.call_action(
|
result = await mock_handler.call_action(
|
||||||
PluginToRuntimeAction.EVENT_GET,
|
PluginToRuntimeAction.EVENT_GET,
|
||||||
{"run_id": None, "event_id": "evt_1"},
|
{'run_id': None, 'event_id': 'evt_1'},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should return error
|
# Should return error
|
||||||
assert result.get("ok") is False or "error" in str(result).lower()
|
assert result.get('ok') is False or 'error' in str(result).lower()
|
||||||
|
|
||||||
|
|
||||||
class TestContextAccessPopulation:
|
class TestContextAccessPopulation:
|
||||||
@@ -389,7 +389,7 @@ class TestContextAccessPopulation:
|
|||||||
with patch.object(store, '_session_factory') as mock_factory:
|
with patch.object(store, '_session_factory') as mock_factory:
|
||||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||||
|
|
||||||
cursor = await store.get_latest_cursor("conv_1")
|
cursor = await store.get_latest_cursor('conv_1')
|
||||||
# Should return None or a cursor string
|
# Should return None or a cursor string
|
||||||
assert cursor is None or isinstance(cursor, str)
|
assert cursor is None or isinstance(cursor, str)
|
||||||
|
|
||||||
@@ -409,7 +409,7 @@ class TestContextAccessPopulation:
|
|||||||
with patch.object(store, '_session_factory') as mock_factory:
|
with patch.object(store, '_session_factory') as mock_factory:
|
||||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||||
|
|
||||||
has_history = await store.has_history_before("conv_1", 10)
|
has_history = await store.has_history_before('conv_1', 10)
|
||||||
assert isinstance(has_history, bool)
|
assert isinstance(has_history, bool)
|
||||||
|
|
||||||
|
|
||||||
@@ -422,7 +422,7 @@ class TestEventLogStoreRealSQLite:
|
|||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
|
||||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||||
|
|
||||||
# Create tables
|
# Create tables
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
@@ -439,30 +439,30 @@ class TestEventLogStoreRealSQLite:
|
|||||||
|
|
||||||
# Append event
|
# Append event
|
||||||
event_id = await store.append_event(
|
event_id = await store.append_event(
|
||||||
event_id="evt_real_001",
|
event_id='evt_real_001',
|
||||||
event_type="message.received",
|
event_type='message.received',
|
||||||
source="platform",
|
source='platform',
|
||||||
bot_id="bot_001",
|
bot_id='bot_001',
|
||||||
conversation_id="conv_001",
|
conversation_id='conv_001',
|
||||||
actor_type="user",
|
actor_type='user',
|
||||||
actor_id="user_001",
|
actor_id='user_001',
|
||||||
actor_name="Test User",
|
actor_name='Test User',
|
||||||
input_summary="Hello world",
|
input_summary='Hello world',
|
||||||
run_id="run_001",
|
run_id='run_001',
|
||||||
runner_id="plugin:test/plugin/runner",
|
runner_id='plugin:test/plugin/runner',
|
||||||
)
|
)
|
||||||
|
|
||||||
assert event_id == "evt_real_001"
|
assert event_id == 'evt_real_001'
|
||||||
|
|
||||||
# Get event
|
# Get event
|
||||||
event = await store.get_event(event_id)
|
event = await store.get_event(event_id)
|
||||||
assert event is not None
|
assert event is not None
|
||||||
assert event["event_id"] == "evt_real_001"
|
assert event['event_id'] == 'evt_real_001'
|
||||||
assert event["event_type"] == "message.received"
|
assert event['event_type'] == 'message.received'
|
||||||
assert event["source"] == "platform"
|
assert event['source'] == 'platform'
|
||||||
assert event["conversation_id"] == "conv_001"
|
assert event['conversation_id'] == 'conv_001'
|
||||||
assert event["actor_type"] == "user"
|
assert event['actor_type'] == 'user'
|
||||||
assert event["actor_id"] == "user_001"
|
assert event['actor_id'] == 'user_001'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_page_events(self, db_engine):
|
async def test_page_events(self, db_engine):
|
||||||
@@ -472,16 +472,16 @@ class TestEventLogStoreRealSQLite:
|
|||||||
# Append multiple events
|
# Append multiple events
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
await store.append_event(
|
await store.append_event(
|
||||||
event_id=f"evt_real_{i:03d}",
|
event_id=f'evt_real_{i:03d}',
|
||||||
event_type="message.received",
|
event_type='message.received',
|
||||||
source="platform",
|
source='platform',
|
||||||
conversation_id="conv_001",
|
conversation_id='conv_001',
|
||||||
input_summary=f"Message {i}",
|
input_summary=f'Message {i}',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Page events
|
# Page events
|
||||||
items, next_seq, has_more = await store.page_events(
|
items, next_seq, has_more = await store.page_events(
|
||||||
conversation_id="conv_001",
|
conversation_id='conv_001',
|
||||||
limit=3,
|
limit=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -496,14 +496,14 @@ class TestEventLogStoreRealSQLite:
|
|||||||
# Append events
|
# Append events
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
await store.append_event(
|
await store.append_event(
|
||||||
event_id=f"evt_cursor_{i:03d}",
|
event_id=f'evt_cursor_{i:03d}',
|
||||||
event_type="message.received",
|
event_type='message.received',
|
||||||
source="platform",
|
source='platform',
|
||||||
conversation_id="conv_cursor",
|
conversation_id='conv_cursor',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get latest cursor
|
# Get latest cursor
|
||||||
cursor = await store.get_latest_cursor("conv_cursor")
|
cursor = await store.get_latest_cursor('conv_cursor')
|
||||||
assert cursor is not None
|
assert cursor is not None
|
||||||
assert int(cursor) > 0
|
assert int(cursor) > 0
|
||||||
|
|
||||||
@@ -516,26 +516,26 @@ class TestEventLogStoreRealSQLite:
|
|||||||
store = EventLogStore(db_engine)
|
store = EventLogStore(db_engine)
|
||||||
cutoff = datetime.datetime.utcnow()
|
cutoff = datetime.datetime.utcnow()
|
||||||
await store.append_event(
|
await store.append_event(
|
||||||
event_id="evt_cleanup_old",
|
event_id='evt_cleanup_old',
|
||||||
event_type="message.received",
|
event_type='message.received',
|
||||||
source="platform",
|
source='platform',
|
||||||
conversation_id="conv_cleanup",
|
conversation_id='conv_cleanup',
|
||||||
)
|
)
|
||||||
await store.append_event(
|
await store.append_event(
|
||||||
event_id="evt_cleanup_new",
|
event_id='evt_cleanup_new',
|
||||||
event_type="message.received",
|
event_type='message.received',
|
||||||
source="platform",
|
source='platform',
|
||||||
conversation_id="conv_cleanup",
|
conversation_id='conv_cleanup',
|
||||||
)
|
)
|
||||||
async with store._session_factory() as session:
|
async with store._session_factory() as session:
|
||||||
await session.execute(
|
await session.execute(
|
||||||
sqlalchemy.update(EventLog)
|
sqlalchemy.update(EventLog)
|
||||||
.where(EventLog.event_id == "evt_cleanup_old")
|
.where(EventLog.event_id == 'evt_cleanup_old')
|
||||||
.values(created_at=cutoff - datetime.timedelta(days=2))
|
.values(created_at=cutoff - datetime.timedelta(days=2))
|
||||||
)
|
)
|
||||||
await session.execute(
|
await session.execute(
|
||||||
sqlalchemy.update(EventLog)
|
sqlalchemy.update(EventLog)
|
||||||
.where(EventLog.event_id == "evt_cleanup_new")
|
.where(EventLog.event_id == 'evt_cleanup_new')
|
||||||
.values(created_at=cutoff + datetime.timedelta(days=2))
|
.values(created_at=cutoff + datetime.timedelta(days=2))
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -543,8 +543,8 @@ class TestEventLogStoreRealSQLite:
|
|||||||
removed = await store.cleanup_events_older_than(cutoff)
|
removed = await store.cleanup_events_older_than(cutoff)
|
||||||
|
|
||||||
assert removed == 1
|
assert removed == 1
|
||||||
assert await store.get_event("evt_cleanup_old") is None
|
assert await store.get_event('evt_cleanup_old') is None
|
||||||
assert await store.get_event("evt_cleanup_new") is not None
|
assert await store.get_event('evt_cleanup_new') is not None
|
||||||
|
|
||||||
|
|
||||||
class TestTranscriptStoreRealSQLite:
|
class TestTranscriptStoreRealSQLite:
|
||||||
@@ -556,7 +556,7 @@ class TestTranscriptStoreRealSQLite:
|
|||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
from langbot.pkg.entity.persistence.base import Base
|
from langbot.pkg.entity.persistence.base import Base
|
||||||
|
|
||||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||||
|
|
||||||
# Create tables
|
# Create tables
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
@@ -574,21 +574,21 @@ class TestTranscriptStoreRealSQLite:
|
|||||||
# Append transcript items
|
# Append transcript items
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id=f"trans_real_{i:03d}",
|
transcript_id=f'trans_real_{i:03d}',
|
||||||
event_id=f"evt_{i:03d}",
|
event_id=f'evt_{i:03d}',
|
||||||
conversation_id="conv_001",
|
conversation_id='conv_001',
|
||||||
role="user" if i % 2 == 0 else "assistant",
|
role='user' if i % 2 == 0 else 'assistant',
|
||||||
content=f"Message {i}",
|
content=f'Message {i}',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Page transcript
|
# Page transcript
|
||||||
items, next_seq, prev_seq, has_more = await store.page_transcript(
|
items, next_seq, prev_seq, has_more = await store.page_transcript(
|
||||||
conversation_id="conv_001",
|
conversation_id='conv_001',
|
||||||
limit=10,
|
limit=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(items) == 3
|
assert len(items) == 3
|
||||||
assert items[0]["conversation_id"] == "conv_001"
|
assert items[0]['conversation_id'] == 'conv_001'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_legacy_provider_messages_projects_transcript_history(self, db_engine):
|
async def test_get_legacy_provider_messages_projects_transcript_history(self, db_engine):
|
||||||
@@ -596,37 +596,37 @@ class TestTranscriptStoreRealSQLite:
|
|||||||
store = TranscriptStore(db_engine)
|
store = TranscriptStore(db_engine)
|
||||||
|
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id="trans_view_001",
|
transcript_id='trans_view_001',
|
||||||
event_id="evt_view_001",
|
event_id='evt_view_001',
|
||||||
conversation_id="conv_view",
|
conversation_id='conv_view',
|
||||||
role="user",
|
role='user',
|
||||||
content="User text",
|
content='User text',
|
||||||
content_json={
|
content_json={
|
||||||
"role": "user",
|
'role': 'user',
|
||||||
"content": [{"type": "text", "text": "User structured text"}],
|
'content': [{'type': 'text', 'text': 'User structured text'}],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id="trans_view_002",
|
transcript_id='trans_view_002',
|
||||||
event_id="evt_view_002",
|
event_id='evt_view_002',
|
||||||
conversation_id="conv_view",
|
conversation_id='conv_view',
|
||||||
role="tool",
|
role='tool',
|
||||||
item_type="tool_result",
|
item_type='tool_result',
|
||||||
content="ignored tool result",
|
content='ignored tool result',
|
||||||
)
|
)
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id="trans_view_003",
|
transcript_id='trans_view_003',
|
||||||
event_id="evt_view_003",
|
event_id='evt_view_003',
|
||||||
conversation_id="conv_view",
|
conversation_id='conv_view',
|
||||||
role="assistant",
|
role='assistant',
|
||||||
content="Assistant text",
|
content='Assistant text',
|
||||||
)
|
)
|
||||||
|
|
||||||
messages = await store.get_legacy_provider_messages("conv_view")
|
messages = await store.get_legacy_provider_messages('conv_view')
|
||||||
|
|
||||||
assert [message.role for message in messages] == ["user", "assistant"]
|
assert [message.role for message in messages] == ['user', 'assistant']
|
||||||
assert messages[0].content[0].text == "User structured text"
|
assert messages[0].content[0].text == 'User structured text'
|
||||||
assert messages[1].content == "Assistant text"
|
assert messages[1].content == 'Assistant text'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_legacy_provider_messages_filters_scope(self, db_engine):
|
async def test_get_legacy_provider_messages_filters_scope(self, db_engine):
|
||||||
@@ -634,45 +634,45 @@ class TestTranscriptStoreRealSQLite:
|
|||||||
store = TranscriptStore(db_engine)
|
store = TranscriptStore(db_engine)
|
||||||
|
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id="trans_scope_001",
|
transcript_id='trans_scope_001',
|
||||||
event_id="evt_scope_001",
|
event_id='evt_scope_001',
|
||||||
conversation_id="conv_scope",
|
conversation_id='conv_scope',
|
||||||
bot_id="bot_001",
|
bot_id='bot_001',
|
||||||
workspace_id="workspace_001",
|
workspace_id='workspace_001',
|
||||||
thread_id="thread_001",
|
thread_id='thread_001',
|
||||||
role="user",
|
role='user',
|
||||||
content="Current scope text",
|
content='Current scope text',
|
||||||
)
|
)
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id="trans_scope_002",
|
transcript_id='trans_scope_002',
|
||||||
event_id="evt_scope_002",
|
event_id='evt_scope_002',
|
||||||
conversation_id="conv_scope",
|
conversation_id='conv_scope',
|
||||||
bot_id="bot_002",
|
bot_id='bot_002',
|
||||||
workspace_id="workspace_001",
|
workspace_id='workspace_001',
|
||||||
thread_id="thread_001",
|
thread_id='thread_001',
|
||||||
role="assistant",
|
role='assistant',
|
||||||
content="Other bot text",
|
content='Other bot text',
|
||||||
)
|
)
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id="trans_scope_003",
|
transcript_id='trans_scope_003',
|
||||||
event_id="evt_scope_003",
|
event_id='evt_scope_003',
|
||||||
conversation_id="conv_scope",
|
conversation_id='conv_scope',
|
||||||
bot_id="bot_001",
|
bot_id='bot_001',
|
||||||
workspace_id="workspace_001",
|
workspace_id='workspace_001',
|
||||||
thread_id="thread_002",
|
thread_id='thread_002',
|
||||||
role="assistant",
|
role='assistant',
|
||||||
content="Other thread text",
|
content='Other thread text',
|
||||||
)
|
)
|
||||||
|
|
||||||
messages = await store.get_legacy_provider_messages(
|
messages = await store.get_legacy_provider_messages(
|
||||||
"conv_scope",
|
'conv_scope',
|
||||||
bot_id="bot_001",
|
bot_id='bot_001',
|
||||||
workspace_id="workspace_001",
|
workspace_id='workspace_001',
|
||||||
thread_id="thread_001",
|
thread_id='thread_001',
|
||||||
strict_thread=True,
|
strict_thread=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert [message.content for message in messages] == ["Current scope text"]
|
assert [message.content for message in messages] == ['Current scope text']
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_transcript_real_db(self, db_engine):
|
async def test_search_transcript_real_db(self, db_engine):
|
||||||
@@ -681,24 +681,24 @@ class TestTranscriptStoreRealSQLite:
|
|||||||
|
|
||||||
# Append transcript items
|
# Append transcript items
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id="trans_search_001",
|
transcript_id='trans_search_001',
|
||||||
event_id="evt_search_001",
|
event_id='evt_search_001',
|
||||||
conversation_id="conv_search",
|
conversation_id='conv_search',
|
||||||
role="user",
|
role='user',
|
||||||
content="I want to learn about databases",
|
content='I want to learn about databases',
|
||||||
)
|
)
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id="trans_search_002",
|
transcript_id='trans_search_002',
|
||||||
event_id="evt_search_002",
|
event_id='evt_search_002',
|
||||||
conversation_id="conv_search",
|
conversation_id='conv_search',
|
||||||
role="assistant",
|
role='assistant',
|
||||||
content="Here is information about databases",
|
content='Here is information about databases',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Search for "database"
|
# Search for "database"
|
||||||
items = await store.search_transcript(
|
items = await store.search_transcript(
|
||||||
conversation_id="conv_search",
|
conversation_id='conv_search',
|
||||||
query_text="database",
|
query_text='database',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should find at least one match
|
# Should find at least one match
|
||||||
@@ -712,15 +712,15 @@ class TestTranscriptStoreRealSQLite:
|
|||||||
# Append transcript items
|
# Append transcript items
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id=f"trans_cursor_{i:03d}",
|
transcript_id=f'trans_cursor_{i:03d}',
|
||||||
event_id=f"evt_cursor_{i:03d}",
|
event_id=f'evt_cursor_{i:03d}',
|
||||||
conversation_id="conv_cursor",
|
conversation_id='conv_cursor',
|
||||||
role="user",
|
role='user',
|
||||||
content=f"Message {i}",
|
content=f'Message {i}',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get latest cursor
|
# Get latest cursor
|
||||||
cursor = await store.get_latest_cursor("conv_cursor")
|
cursor = await store.get_latest_cursor('conv_cursor')
|
||||||
assert cursor is not None
|
assert cursor is not None
|
||||||
assert int(cursor) > 0
|
assert int(cursor) > 0
|
||||||
|
|
||||||
@@ -733,37 +733,37 @@ class TestTranscriptStoreRealSQLite:
|
|||||||
store = TranscriptStore(db_engine)
|
store = TranscriptStore(db_engine)
|
||||||
cutoff = datetime.datetime.utcnow()
|
cutoff = datetime.datetime.utcnow()
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id="trans_cleanup_old",
|
transcript_id='trans_cleanup_old',
|
||||||
event_id="evt_cleanup_old",
|
event_id='evt_cleanup_old',
|
||||||
conversation_id="conv_cleanup",
|
conversation_id='conv_cleanup',
|
||||||
role="user",
|
role='user',
|
||||||
content="old",
|
content='old',
|
||||||
)
|
)
|
||||||
await store.append_transcript(
|
await store.append_transcript(
|
||||||
transcript_id="trans_cleanup_new",
|
transcript_id='trans_cleanup_new',
|
||||||
event_id="evt_cleanup_new",
|
event_id='evt_cleanup_new',
|
||||||
conversation_id="conv_cleanup",
|
conversation_id='conv_cleanup',
|
||||||
role="assistant",
|
role='assistant',
|
||||||
content="new",
|
content='new',
|
||||||
)
|
)
|
||||||
async with store._session_factory() as session:
|
async with store._session_factory() as session:
|
||||||
await session.execute(
|
await session.execute(
|
||||||
sqlalchemy.update(Transcript)
|
sqlalchemy.update(Transcript)
|
||||||
.where(Transcript.transcript_id == "trans_cleanup_old")
|
.where(Transcript.transcript_id == 'trans_cleanup_old')
|
||||||
.values(created_at=cutoff - datetime.timedelta(days=2))
|
.values(created_at=cutoff - datetime.timedelta(days=2))
|
||||||
)
|
)
|
||||||
await session.execute(
|
await session.execute(
|
||||||
sqlalchemy.update(Transcript)
|
sqlalchemy.update(Transcript)
|
||||||
.where(Transcript.transcript_id == "trans_cleanup_new")
|
.where(Transcript.transcript_id == 'trans_cleanup_new')
|
||||||
.values(created_at=cutoff + datetime.timedelta(days=2))
|
.values(created_at=cutoff + datetime.timedelta(days=2))
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
removed = await store.cleanup_transcripts_older_than(cutoff)
|
removed = await store.cleanup_transcripts_older_than(cutoff)
|
||||||
items, _, _, _ = await store.page_transcript("conv_cleanup", limit=10)
|
items, _, _, _ = await store.page_transcript('conv_cleanup', limit=10)
|
||||||
|
|
||||||
assert removed == 1
|
assert removed == 1
|
||||||
assert [item["content"] for item in items] == ["new"]
|
assert [item['content'] for item in items] == ['new']
|
||||||
|
|
||||||
|
|
||||||
# Fixtures
|
# Fixtures
|
||||||
@@ -788,8 +788,8 @@ def mock_handler():
|
|||||||
|
|
||||||
async def call_action(self, action, data, timeout=30):
|
async def call_action(self, action, data, timeout=30):
|
||||||
# Simulate error response for missing run_id
|
# Simulate error response for missing run_id
|
||||||
if not data.get("run_id"):
|
if not data.get('run_id'):
|
||||||
return {"ok": False, "message": "run_id is required"}
|
return {'ok': False, 'message': 'run_id is required'}
|
||||||
return {"ok": True, "data": {}}
|
return {'ok': True, 'data': {}}
|
||||||
|
|
||||||
return MockHandler()
|
return MockHandler()
|
||||||
|
|||||||
@@ -901,7 +901,7 @@ async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
|
|||||||
[message async for message in orchestrator.run_from_query(query)]
|
[message async for message in orchestrator.run_from_query(query)]
|
||||||
|
|
||||||
assert exc_info.value.retryable is True
|
assert exc_info.value.retryable is True
|
||||||
assert 'runner.timeout' in str(exc_info.value)
|
assert exc_info.value.error_code == 'runner.timeout'
|
||||||
assert await get_session_registry().list_active_runs() == []
|
assert await get_session_registry().list_active_runs() == []
|
||||||
|
|
||||||
|
|
||||||
@@ -1012,6 +1012,7 @@ class TestQueryEntrySessionQueryId:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
ap = FakeApplication(plugin_connector, db_engine)
|
ap = FakeApplication(plugin_connector, db_engine)
|
||||||
|
|
||||||
async def build_resource_context(execution_query):
|
async def build_resource_context(execution_query):
|
||||||
from langbot.pkg.provider.tools.loaders.mcp import (
|
from langbot.pkg.provider.tools.loaders.mcp import (
|
||||||
_execution_context_from_query,
|
_execution_context_from_query,
|
||||||
@@ -1025,9 +1026,7 @@ class TestQueryEntrySessionQueryId:
|
|||||||
return 'Pinned documentation'
|
return 'Pinned documentation'
|
||||||
|
|
||||||
mcp_loader = types.SimpleNamespace(
|
mcp_loader = types.SimpleNamespace(
|
||||||
build_resource_context_for_query=AsyncMock(
|
build_resource_context_for_query=AsyncMock(side_effect=build_resource_context)
|
||||||
side_effect=build_resource_context
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
|
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
|
||||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||||
@@ -1070,7 +1069,6 @@ class TestQueryEntrySessionQueryId:
|
|||||||
resource_policy=ResourcePolicy(),
|
resource_policy=ResourcePolicy(),
|
||||||
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
|
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
|
||||||
delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True),
|
delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True),
|
||||||
enabled=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
@@ -1105,14 +1103,8 @@ class TestQueryEntrySessionQueryId:
|
|||||||
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text']
|
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text']
|
||||||
assert event.input.text == 'hello'
|
assert event.input.text == 'hello'
|
||||||
assert event.input.contents[0].text == 'hello'
|
assert event.input.contents[0].text == 'hello'
|
||||||
assert (
|
assert plugin_connector.contexts[0]['conversation']['workspace_id'] == TEST_CONTEXT.workspace_uuid
|
||||||
plugin_connector.contexts[0]['conversation']['workspace_id']
|
assert plugin_connector.contexts[0]['runtime']['metadata']['workspace_id'] == TEST_CONTEXT.workspace_uuid
|
||||||
== TEST_CONTEXT.workspace_uuid
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
plugin_connector.contexts[0]['runtime']['metadata']['workspace_id']
|
|
||||||
== TEST_CONTEXT.workspace_uuid
|
|
||||||
)
|
|
||||||
assert 'Pinned documentation' not in str(execution_query.user_message.content)
|
assert 'Pinned documentation' not in str(execution_query.user_message.content)
|
||||||
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
|
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Tests for agent runner result normalizer."""
|
"""Tests for agent runner result normalizer."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -12,6 +13,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
|
|||||||
|
|
||||||
class FakeApplication:
|
class FakeApplication:
|
||||||
"""Fake Application for testing."""
|
"""Fake Application for testing."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
class FakeLogger:
|
class FakeLogger:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -19,10 +21,13 @@ class FakeApplication:
|
|||||||
|
|
||||||
def info(self, msg):
|
def info(self, msg):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def debug(self, msg):
|
def debug(self, msg):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def warning(self, msg):
|
def warning(self, msg):
|
||||||
self.warnings.append(msg)
|
self.warnings.append(msg)
|
||||||
|
|
||||||
def error(self, msg):
|
def error(self, msg):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -192,6 +197,7 @@ class TestNormalizeRunFailed:
|
|||||||
|
|
||||||
assert exc_info.value.runner_id == 'plugin:langbot-team/LocalAgent/default'
|
assert exc_info.value.runner_id == 'plugin:langbot-team/LocalAgent/default'
|
||||||
assert exc_info.value.retryable is True
|
assert exc_info.value.retryable is True
|
||||||
|
assert exc_info.value.error_code == 'upstream.timeout'
|
||||||
assert 'timeout' in str(exc_info.value)
|
assert 'timeout' in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
@@ -290,6 +296,7 @@ class TestNormalizeNonMessageResults:
|
|||||||
assert result is None
|
assert result is None
|
||||||
assert app.logger.warnings
|
assert app.logger.warnings
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeInvalidResults:
|
class TestNormalizeInvalidResults:
|
||||||
"""Tests for handling invalid results."""
|
"""Tests for handling invalid results."""
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ def _agent_row(
|
|||||||
'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0},
|
'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0},
|
||||||
'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}},
|
'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}},
|
||||||
},
|
},
|
||||||
enabled=True,
|
|
||||||
supported_event_patterns=supported_event_patterns or ['*'],
|
supported_event_patterns=supported_event_patterns or ['*'],
|
||||||
created_at=dt.datetime(2026, 1, 1, 9, 0, 0),
|
created_at=dt.datetime(2026, 1, 1, 9, 0, 0),
|
||||||
updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0),
|
updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0),
|
||||||
@@ -63,7 +62,6 @@ def _serialize_agent(model_cls, entity, masked_columns=None):
|
|||||||
'kind': entity.kind,
|
'kind': entity.kind,
|
||||||
'component_ref': entity.component_ref,
|
'component_ref': entity.component_ref,
|
||||||
'config': entity.config,
|
'config': entity.config,
|
||||||
'enabled': entity.enabled,
|
|
||||||
'supported_event_patterns': entity.supported_event_patterns,
|
'supported_event_patterns': entity.supported_event_patterns,
|
||||||
'created_at': entity.created_at,
|
'created_at': entity.created_at,
|
||||||
'updated_at': entity.updated_at,
|
'updated_at': entity.updated_at,
|
||||||
@@ -145,7 +143,6 @@ class TestAgentServiceDebug:
|
|||||||
return_value={
|
return_value={
|
||||||
'uuid': 'agent-1',
|
'uuid': 'agent-1',
|
||||||
'kind': AGENT_KIND_AGENT,
|
'kind': AGENT_KIND_AGENT,
|
||||||
'enabled': True,
|
|
||||||
'supported_event_patterns': ['*'],
|
'supported_event_patterns': ['*'],
|
||||||
'config': _agent_row().config,
|
'config': _agent_row().config,
|
||||||
}
|
}
|
||||||
@@ -288,7 +285,7 @@ class TestAgentServiceListAndLookup:
|
|||||||
result = await AgentService(app).get_agent(WORKSPACE_UUID, 'pipeline-1')
|
result = await AgentService(app).get_agent(WORKSPACE_UUID, 'pipeline-1')
|
||||||
|
|
||||||
assert result['kind'] == AGENT_KIND_PIPELINE
|
assert result['kind'] == AGENT_KIND_PIPELINE
|
||||||
assert result['enabled'] is True
|
assert 'enabled' not in result
|
||||||
assert result['config'] == {'ai': {'runner': {'id': 'pipeline-runner'}}}
|
assert result['config'] == {'ai': {'runner': {'id': 'pipeline-runner'}}}
|
||||||
assert result['capability']['message_only'] is True
|
assert result['capability']['message_only'] is True
|
||||||
|
|
||||||
@@ -329,7 +326,7 @@ class TestAgentServiceCreateUpdateDelete:
|
|||||||
'runner': {'id': runner.id, 'expire-time': 0},
|
'runner': {'id': runner.id, 'expire-time': 0},
|
||||||
'runner_config': {runner.id: {'model': 'gpt-4.1', 'temperature': 0.2}},
|
'runner_config': {runner.id: {'model': 'gpt-4.1', 'temperature': 0.2}},
|
||||||
}
|
}
|
||||||
assert insert_values['enabled'] is True
|
assert 'enabled' not in insert_values
|
||||||
assert insert_values['supported_event_patterns'] == AGENT_DEFAULT_EVENT_PATTERNS
|
assert insert_values['supported_event_patterns'] == AGENT_DEFAULT_EVENT_PATTERNS
|
||||||
app.pipeline_service._get_default_values_from_schema.assert_called_once_with(runner.config_schema)
|
app.pipeline_service._get_default_values_from_schema.assert_called_once_with(runner.config_schema)
|
||||||
|
|
||||||
|
|||||||
@@ -62,9 +62,7 @@ def _set_discovered_adapters(ap, *webhook_adapters: str) -> None:
|
|||||||
)
|
)
|
||||||
for adapter_name in webhook_adapters
|
for adapter_name in webhook_adapters
|
||||||
]
|
]
|
||||||
ap.discover = SimpleNamespace(
|
ap.discover = SimpleNamespace(get_components_by_kind=Mock(return_value=components))
|
||||||
get_components_by_kind=Mock(return_value=components)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceGetBots:
|
class TestBotServiceGetBots:
|
||||||
@@ -445,6 +443,7 @@ class TestBotServiceUpdateBot:
|
|||||||
ap.persistence_mgr = SimpleNamespace()
|
ap.persistence_mgr = SimpleNamespace()
|
||||||
ap.platform_mgr = SimpleNamespace()
|
ap.platform_mgr = SimpleNamespace()
|
||||||
ap.platform_mgr.remove_bot = AsyncMock()
|
ap.platform_mgr.remove_bot = AsyncMock()
|
||||||
|
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
|
||||||
|
|
||||||
# Mock pipeline query - not updating pipeline
|
# Mock pipeline query - not updating pipeline
|
||||||
ap.persistence_mgr.execute_async = AsyncMock()
|
ap.persistence_mgr.execute_async = AsyncMock()
|
||||||
@@ -475,6 +474,7 @@ class TestBotServiceUpdateBot:
|
|||||||
|
|
||||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
|
||||||
ap.platform_mgr = SimpleNamespace(
|
ap.platform_mgr = SimpleNamespace(
|
||||||
|
get_bot_by_uuid=AsyncMock(return_value=None),
|
||||||
remove_bot=AsyncMock(),
|
remove_bot=AsyncMock(),
|
||||||
load_bot=AsyncMock(return_value=SimpleNamespace(enable=False)),
|
load_bot=AsyncMock(return_value=SimpleNamespace(enable=False)),
|
||||||
)
|
)
|
||||||
@@ -498,6 +498,29 @@ class TestBotServiceUpdateBot:
|
|||||||
assert 'use_pipeline_uuid' not in update_params
|
assert 'use_pipeline_uuid' not in update_params
|
||||||
assert 'use_pipeline_name' not in update_params
|
assert 'use_pipeline_name' not in update_params
|
||||||
|
|
||||||
|
async def test_basic_info_update_does_not_restart_platform_adapter(self):
|
||||||
|
ap = SimpleNamespace()
|
||||||
|
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=SimpleNamespace(rowcount=1)))
|
||||||
|
runtime_entity = SimpleNamespace(name='Old name', description='Old description')
|
||||||
|
runtime_bot = SimpleNamespace(bot_entity=runtime_entity)
|
||||||
|
ap.platform_mgr = SimpleNamespace(
|
||||||
|
get_bot_by_uuid=AsyncMock(return_value=runtime_bot),
|
||||||
|
remove_bot=AsyncMock(),
|
||||||
|
load_bot=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
service = BotService(ap)
|
||||||
|
await service.update_bot(
|
||||||
|
WORKSPACE_UUID,
|
||||||
|
'test-uuid',
|
||||||
|
{'name': 'New name', 'description': 'New description'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert runtime_entity.name == 'New name'
|
||||||
|
assert runtime_entity.description == 'New description'
|
||||||
|
ap.platform_mgr.remove_bot.assert_not_awaited()
|
||||||
|
ap.platform_mgr.load_bot.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceDeleteBot:
|
class TestBotServiceDeleteBot:
|
||||||
"""Tests for delete_bot method."""
|
"""Tests for delete_bot method."""
|
||||||
@@ -583,6 +606,56 @@ class TestBotServiceListEventLogs:
|
|||||||
assert total == 5
|
assert total == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestBotServiceListEventRouteStatuses:
|
||||||
|
"""Tests for event route status when a persisted Bot is not running."""
|
||||||
|
|
||||||
|
async def test_returns_saved_routes_when_runtime_bot_is_unavailable(self):
|
||||||
|
ap = SimpleNamespace()
|
||||||
|
ap.platform_mgr = SimpleNamespace()
|
||||||
|
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
service = BotService(ap)
|
||||||
|
service.get_bot = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
'uuid': 'bot-uuid',
|
||||||
|
'event_bindings': [
|
||||||
|
{
|
||||||
|
'id': 'binding-1',
|
||||||
|
'event_pattern': 'message.received',
|
||||||
|
'target_type': 'agent',
|
||||||
|
'target_uuid': 'agent-1',
|
||||||
|
'enabled': True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.list_event_route_statuses(WORKSPACE_UUID, 'bot-uuid')
|
||||||
|
|
||||||
|
assert result['routes'] == [
|
||||||
|
{
|
||||||
|
'binding_id': 'binding-1',
|
||||||
|
'event_pattern': 'message.received',
|
||||||
|
'event_type': None,
|
||||||
|
'target_type': 'agent',
|
||||||
|
'target_uuid': 'agent-1',
|
||||||
|
'last_status': None,
|
||||||
|
'failure_code': None,
|
||||||
|
'reason': None,
|
||||||
|
'run_id': None,
|
||||||
|
'timestamp': None,
|
||||||
|
'seq_id': None,
|
||||||
|
'level': None,
|
||||||
|
'message': '',
|
||||||
|
'order': 0,
|
||||||
|
'enabled': True,
|
||||||
|
'current': True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert result['unmatched_events'] == []
|
||||||
|
assert result['stale_routes'] == []
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceSendMessage:
|
class TestBotServiceSendMessage:
|
||||||
"""Tests for send_message method."""
|
"""Tests for send_message method."""
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from unittest.mock import ANY, AsyncMock
|
|||||||
import pytest
|
import pytest
|
||||||
import quart
|
import quart
|
||||||
|
|
||||||
|
from langbot.pkg.agent.runner.errors import RunnerExecutionError
|
||||||
|
|
||||||
core_app_module = types.ModuleType('langbot.pkg.core.app')
|
core_app_module = types.ModuleType('langbot.pkg.core.app')
|
||||||
core_app_module.Application = object
|
core_app_module.Application = object
|
||||||
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
|
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
|
||||||
@@ -142,3 +144,28 @@ async def test_debug_agent_returns_bad_request_for_invalid_event():
|
|||||||
'code': -1,
|
'code': -1,
|
||||||
'msg': 'Invalid event_type',
|
'msg': 'Invalid event_type',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_debug_agent_returns_actionable_runner_error():
|
||||||
|
agent_service = SimpleNamespace(
|
||||||
|
debug_agent=AsyncMock(
|
||||||
|
side_effect=RunnerExecutionError(
|
||||||
|
'plugin:langbot-team/DifyAgent/default',
|
||||||
|
'api-key is required',
|
||||||
|
error_code='dify.config_invalid',
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
client = await _create_test_client(agent_service)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/agents/agent-1/debug',
|
||||||
|
json={'event_type': 'message.received', 'text': 'hello'},
|
||||||
|
headers={'Authorization': 'Bearer test-token'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert await response.get_json() == {
|
||||||
|
'code': 'dify.config_invalid',
|
||||||
|
'msg': 'api-key is required',
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
@@ -20,18 +19,6 @@ def _make_app() -> SimpleNamespace:
|
|||||||
update_bot=AsyncMock(),
|
update_bot=AsyncMock(),
|
||||||
delete_bot=AsyncMock(),
|
delete_bot=AsyncMock(),
|
||||||
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
|
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
|
||||||
dispatch_test_event_route=AsyncMock(
|
|
||||||
return_value={
|
|
||||||
'dispatched': True,
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'suppressed_outputs': [],
|
|
||||||
'route_status': {
|
|
||||||
'routes': [],
|
|
||||||
'unmatched_events': [],
|
|
||||||
'stale_routes': [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
app.pipeline_service = SimpleNamespace(
|
app.pipeline_service = SimpleNamespace(
|
||||||
get_pipelines=AsyncMock(return_value=[]),
|
get_pipelines=AsyncMock(return_value=[]),
|
||||||
@@ -75,30 +62,6 @@ async def test_mcp_server_exposes_bot_event_route_tools():
|
|||||||
tool_names = {tool.name for tool in tools}
|
tool_names = {tool.name for tool in tools}
|
||||||
|
|
||||||
assert 'list_bot_event_route_statuses' in tool_names
|
assert 'list_bot_event_route_statuses' in tool_names
|
||||||
assert 'test_bot_event_route' in tool_names
|
assert 'test_bot_event_route' not in tool_names
|
||||||
assert 'list_processors' in tool_names
|
assert 'list_processors' in tool_names
|
||||||
assert 'list_agents' not in tool_names
|
assert 'list_agents' not in tool_names
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mcp_test_bot_event_route_calls_service_layer():
|
|
||||||
app = _make_app()
|
|
||||||
server = LangBotMCPServer(app)
|
|
||||||
|
|
||||||
result_blocks, _ = await server.mcp.call_tool(
|
|
||||||
'test_bot_event_route',
|
|
||||||
{
|
|
||||||
'bot_uuid': 'bot-1',
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'payload': {'message_text': 'hello'},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
app.bot_service.dispatch_test_event_route.assert_awaited_once_with(
|
|
||||||
bot_uuid='bot-1',
|
|
||||||
event_type='message.received',
|
|
||||||
payload={'message_text': 'hello'},
|
|
||||||
)
|
|
||||||
data = json.loads(result_blocks[0].text)
|
|
||||||
assert data['dispatched'] is True
|
|
||||||
assert data['event_type'] == 'message.received'
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -81,9 +81,9 @@ class TestEventRouteTrace:
|
|||||||
'target_type': 'agent',
|
'target_type': 'agent',
|
||||||
'target_uuid': 'agent-1',
|
'target_uuid': 'agent-1',
|
||||||
},
|
},
|
||||||
failure_code='processor_disabled',
|
failure_code='processor_not_found',
|
||||||
reason='Agent target is disabled',
|
reason='Agent target is unavailable',
|
||||||
text='disabled',
|
text='unavailable',
|
||||||
)
|
)
|
||||||
|
|
||||||
bot.logger.warning.assert_awaited_once()
|
bot.logger.warning.assert_awaited_once()
|
||||||
@@ -96,61 +96,34 @@ class TestEventRouteTrace:
|
|||||||
assert metadata['status'] == 'failed'
|
assert metadata['status'] == 'failed'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dispatch_test_event_suppresses_agent_output_delivery(self):
|
async def test_adapter_event_log_exposes_normalized_input_without_platform_object(self):
|
||||||
"""Synthetic test dispatch runs the route but does not call the real adapter."""
|
"""Adapter debugging records the shared event shape without opaque SDK data."""
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
from langbot_plugin.api.entities.builtin.platform import entities, events, message
|
||||||
|
|
||||||
captured_envelopes = []
|
bot = self._make_bot([])
|
||||||
|
bot.bot_entity.adapter = 'test-adapter'
|
||||||
async def fake_run(envelope, binding, adapter_context=None):
|
event = events.MessageReceivedEvent(
|
||||||
captured_envelopes.append(envelope)
|
message_id='message-1',
|
||||||
yield provider_message.Message(role='assistant', content='test response')
|
message_chain=message.MessageChain([message.Plain(text='hello')]),
|
||||||
|
sender=entities.User(id='user-1', nickname='QA User'),
|
||||||
bot = self._make_bot(
|
chat_type=entities.ChatType.PRIVATE,
|
||||||
[
|
chat_id='user-1',
|
||||||
{
|
source_platform_object={'access_token': 'must-not-be-logged'},
|
||||||
'id': 'agent-binding',
|
|
||||||
'enabled': True,
|
|
||||||
'event_pattern': 'message.received',
|
|
||||||
'target_type': 'agent',
|
|
||||||
'target_uuid': 'agent-1',
|
|
||||||
'priority': 0,
|
|
||||||
'order': 0,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
bot.ap = SimpleNamespace(
|
|
||||||
workspace_service=active_workspace_service(),
|
|
||||||
agent_service=SimpleNamespace(
|
|
||||||
get_agent=AsyncMock(
|
|
||||||
return_value={
|
|
||||||
'uuid': 'agent-1',
|
|
||||||
'kind': 'agent',
|
|
||||||
'enabled': True,
|
|
||||||
'supported_event_patterns': ['message.received'],
|
|
||||||
'config': {'runner': {'id': 'runner-1'}, 'runner_config': {'runner-1': {}}},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
),
|
|
||||||
agent_run_orchestrator=SimpleNamespace(run=fake_run),
|
|
||||||
)
|
|
||||||
bot.adapter = SimpleNamespace(
|
|
||||||
bot_account_id='bot-account',
|
|
||||||
config={},
|
|
||||||
logger=bot.logger,
|
|
||||||
send_message=AsyncMock(),
|
|
||||||
get_supported_apis=Mock(return_value=['send_message', 'edit_message', 'add_reaction', 'get_group_info']),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await bot.dispatch_test_event('message.received', {'chat_id': 'user-1', 'message_text': 'hello'})
|
metadata = await bot._record_adapter_event(event, SimpleNamespace())
|
||||||
|
|
||||||
bot.adapter.send_message.assert_not_awaited()
|
assert metadata['kind'] == 'adapter_event_received'
|
||||||
assert result['dispatched'] is True
|
assert metadata['event_type'] == 'message.received'
|
||||||
assert result['status'] == 'delivered'
|
assert metadata['adapter'] == 'test-adapter'
|
||||||
assert result['suppressed_outputs'][0]['method'] == 'send_message'
|
assert metadata['bot_uuid'] == 'bot-1'
|
||||||
assert captured_envelopes[0].delivery.supports_edit is False
|
assert metadata['event_data']['message_chain'] == [{'type': 'Plain', 'text': 'hello'}]
|
||||||
assert captured_envelopes[0].delivery.supports_reaction is False
|
assert metadata['event_data']['sender']['id'] == 'user-1'
|
||||||
assert captured_envelopes[0].delivery.platform_capabilities['supported_apis'] == ['get_group_info']
|
assert 'source_platform_object' not in metadata['event_data']
|
||||||
|
bot.logger.info.assert_awaited_once_with(
|
||||||
|
'Platform adapter received message.received',
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self):
|
async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self):
|
||||||
@@ -208,128 +181,6 @@ class TestEventRouteTrace:
|
|||||||
assert delivered['status'] == 'delivered'
|
assert delivered['status'] == 'delivered'
|
||||||
assert len(runner_calls) == 1
|
assert len(runner_calls) == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dispatch_test_event_pipeline_receives_synthetic_adapter(self):
|
|
||||||
"""Pipeline route tests enqueue queries with the no-op adapter."""
|
|
||||||
bot = self._make_bot(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'id': 'pipeline-binding',
|
|
||||||
'enabled': True,
|
|
||||||
'event_pattern': 'message.received',
|
|
||||||
'target_type': 'pipeline',
|
|
||||||
'target_uuid': 'pipeline-1',
|
|
||||||
'priority': 0,
|
|
||||||
'order': 0,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
bot.ap = SimpleNamespace(
|
|
||||||
workspace_service=active_workspace_service(),
|
|
||||||
msg_aggregator=SimpleNamespace(add_message=AsyncMock()),
|
|
||||||
)
|
|
||||||
bot.adapter = SimpleNamespace(
|
|
||||||
bot_account_id='bot-account',
|
|
||||||
config={},
|
|
||||||
logger=bot.logger,
|
|
||||||
send_message=AsyncMock(),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await bot.dispatch_test_event(
|
|
||||||
'message.received',
|
|
||||||
{'chat_id': 'user-1', 'message_text': 'hello'},
|
|
||||||
)
|
|
||||||
|
|
||||||
bot.adapter.send_message.assert_not_awaited()
|
|
||||||
bot.ap.msg_aggregator.add_message.assert_awaited_once()
|
|
||||||
_, kwargs = bot.ap.msg_aggregator.add_message.await_args
|
|
||||||
query_adapter = kwargs['adapter']
|
|
||||||
assert query_adapter is not bot.adapter
|
|
||||||
assert getattr(query_adapter, 'source') is bot.adapter
|
|
||||||
assert result['dispatched'] is True
|
|
||||||
assert result['status'] == 'delivered'
|
|
||||||
assert result['suppressed_outputs'] == []
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dispatch_test_event_reports_unmatched_route_as_failure(self):
|
|
||||||
"""Synthetic dispatch does not report success when no saved route matches."""
|
|
||||||
bot = self._make_bot([])
|
|
||||||
bot.adapter = SimpleNamespace(
|
|
||||||
bot_account_id='bot-account',
|
|
||||||
config={},
|
|
||||||
logger=bot.logger,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await bot.dispatch_test_event(
|
|
||||||
'message.received',
|
|
||||||
{'chat_id': 'user-1', 'message_text': 'hello'},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result['dispatched'] is False
|
|
||||||
assert result['status'] == 'not_matched'
|
|
||||||
assert result['failure_code'] == 'route_not_found'
|
|
||||||
assert result['reason'] == 'No event route matched'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_synthetic_adapter_suppresses_platform_side_effect_apis(self):
|
|
||||||
"""Synthetic adapter blocks optional platform APIs that mutate external state."""
|
|
||||||
from langbot.pkg.platform.botmgr import SyntheticRouteTestAdapter
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
||||||
|
|
||||||
source = SimpleNamespace(
|
|
||||||
bot_account_id='bot-account',
|
|
||||||
config={},
|
|
||||||
logger=Mock(),
|
|
||||||
get_supported_apis=Mock(
|
|
||||||
return_value=[
|
|
||||||
'send_message',
|
|
||||||
'delete_message',
|
|
||||||
'get_group_info',
|
|
||||||
'call_platform_api',
|
|
||||||
]
|
|
||||||
),
|
|
||||||
delete_message=AsyncMock(),
|
|
||||||
call_platform_api=AsyncMock(),
|
|
||||||
)
|
|
||||||
adapter = SyntheticRouteTestAdapter(source)
|
|
||||||
|
|
||||||
await adapter.delete_message('group', 'group-1', 'message-1')
|
|
||||||
await adapter.call_platform_api('set_title', {'name': 'New Title'})
|
|
||||||
upload_result = await adapter.upload_file(b'data', 'test.txt')
|
|
||||||
|
|
||||||
source.delete_message.assert_not_awaited()
|
|
||||||
source.call_platform_api.assert_not_awaited()
|
|
||||||
assert upload_result == 'suppressed:test.txt'
|
|
||||||
assert [item['method'] for item in adapter.suppressed_outputs] == [
|
|
||||||
'delete_message',
|
|
||||||
'call_platform_api',
|
|
||||||
'upload_file',
|
|
||||||
]
|
|
||||||
assert adapter.get_supported_apis() == ['get_group_info']
|
|
||||||
assert adapter._message_to_payload(platform_message.MessageChain([platform_message.Plain(text='ok')]))
|
|
||||||
|
|
||||||
def test_build_test_platform_event_message_received_uses_payload(self):
|
|
||||||
"""Synthetic message events preserve common route filter fields."""
|
|
||||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
|
||||||
|
|
||||||
event = RuntimeBot._build_test_platform_event(
|
|
||||||
'message.received',
|
|
||||||
{
|
|
||||||
'chat_type': 'group',
|
|
||||||
'chat_id': 'group-1',
|
|
||||||
'group_name': 'QA Group',
|
|
||||||
'user_id': 'user-1',
|
|
||||||
'user_name': 'QA User',
|
|
||||||
'message_text': 'hello',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert event.type == 'message.received'
|
|
||||||
assert str(event.chat_id) == 'group-1'
|
|
||||||
assert event.group.name == 'QA Group'
|
|
||||||
assert event.sender.nickname == 'QA User'
|
|
||||||
assert str(event.message_chain) == 'hello'
|
|
||||||
|
|
||||||
def test_agent_envelope_projects_adapter_delivery_capabilities(self):
|
def test_agent_envelope_projects_adapter_delivery_capabilities(self):
|
||||||
"""Runner delivery context reflects the active adapter's declared APIs."""
|
"""Runner delivery context reflects the active adapter's declared APIs."""
|
||||||
from langbot_plugin.api.entities.builtin.platform import entities, events, message
|
from langbot_plugin.api.entities.builtin.platform import entities, events, message
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
The web debug client uploads Image / Voice / File components carrying a storage
|
The web debug client uploads Image / Voice / File components carrying a storage
|
||||||
key in ``path``. This helper resolves each to a base64 data URI (so multimodal
|
key in ``path``. This helper resolves each to a base64 data URI (so multimodal
|
||||||
LLM input and the Box sandbox inbox have usable bytes), then deletes the
|
LLM input and the Box sandbox inbox have usable bytes). Image uploads remain as
|
||||||
consumed upload. Covers mimetype selection per type and fail-closed error
|
authenticated history references until storage retention cleanup, while other
|
||||||
handling.
|
consumed uploads are deleted. Covers mimetype selection per type and
|
||||||
|
fail-closed error handling.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -52,7 +53,7 @@ def _make_adapter(load_return=b'hello', load_side_effect=None):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_image_jpeg_mimetype_and_consumed_storage_key():
|
async def test_image_jpeg_mimetype_and_retained_history_key():
|
||||||
adapter, storage_mgr, _ = _make_adapter(load_return=b'\xff\xd8\xff')
|
adapter, storage_mgr, _ = _make_adapter(load_return=b'\xff\xd8\xff')
|
||||||
path = f'{_UPLOAD_PREFIX}photo.jpg'
|
path = f'{_UPLOAD_PREFIX}photo.jpg'
|
||||||
chain = [{'type': 'Image', 'path': path}]
|
chain = [{'type': 'Image', 'path': path}]
|
||||||
@@ -61,12 +62,11 @@ async def test_image_jpeg_mimetype_and_consumed_storage_key():
|
|||||||
|
|
||||||
expected_b64 = base64.b64encode(b'\xff\xd8\xff').decode('utf-8')
|
expected_b64 = base64.b64encode(b'\xff\xd8\xff').decode('utf-8')
|
||||||
assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}'
|
assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}'
|
||||||
assert chain[0]['path'] == ''
|
assert chain[0]['path'] == path
|
||||||
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
|
storage_mgr.delete_scoped_object_key.assert_not_awaited()
|
||||||
_CONTEXT,
|
|
||||||
path,
|
history = adapter._history_message_chain(chain)
|
||||||
expected_owner_type='upload_image',
|
assert history == [{'type': 'Image', 'path': path, 'base64': ''}]
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_history_retains_storage_key_without_large_base64_payload():
|
def test_history_retains_storage_key_without_large_base64_payload():
|
||||||
@@ -95,18 +95,22 @@ async def test_image_defaults_to_png():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_voice_uses_guessed_or_wav_mimetype():
|
async def test_voice_uses_guessed_or_wav_mimetype():
|
||||||
adapter, _, _ = _make_adapter()
|
adapter, storage_mgr, _ = _make_adapter()
|
||||||
chain = [{'type': 'Voice', 'path': f'{_UPLOAD_PREFIX}clip.wav'}]
|
chain = [{'type': 'Voice', 'path': f'{_UPLOAD_PREFIX}clip.wav'}]
|
||||||
await adapter._process_image_components(_make_connection(), chain)
|
await adapter._process_image_components(_make_connection(), chain)
|
||||||
assert chain[0]['base64'].startswith('data:audio/')
|
assert chain[0]['base64'].startswith('data:audio/')
|
||||||
|
assert chain[0]['path'] == ''
|
||||||
|
storage_mgr.delete_scoped_object_key.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_file_uses_octet_stream_fallback():
|
async def test_file_uses_octet_stream_fallback():
|
||||||
adapter, _, _ = _make_adapter()
|
adapter, storage_mgr, _ = _make_adapter()
|
||||||
chain = [{'type': 'File', 'path': f'{_UPLOAD_PREFIX}unknownblob'}]
|
chain = [{'type': 'File', 'path': f'{_UPLOAD_PREFIX}unknownblob'}]
|
||||||
await adapter._process_image_components(_make_connection(), chain)
|
await adapter._process_image_components(_make_connection(), chain)
|
||||||
assert chain[0]['base64'].startswith('data:application/octet-stream;base64,')
|
assert chain[0]['base64'].startswith('data:application/octet-stream;base64,')
|
||||||
|
assert chain[0]['path'] == ''
|
||||||
|
storage_mgr.delete_scoped_object_key.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -482,7 +482,7 @@ async def test_attachment_key_must_belong_to_connection_upload_scope():
|
|||||||
await adapter._process_image_components(connection, message_chain)
|
await adapter._process_image_components(connection, message_chain)
|
||||||
|
|
||||||
assert message_chain[0]['base64'].startswith('data:image/png;base64,')
|
assert message_chain[0]['base64'].startswith('data:image/png;base64,')
|
||||||
assert message_chain[0]['path'] == ''
|
assert message_chain[0]['path'] == 'v1/current/upload_image/key.png'
|
||||||
storage_mgr.scoped_prefix.assert_called_once_with(
|
storage_mgr.scoped_prefix.assert_called_once_with(
|
||||||
connection.execution_context,
|
connection.execution_context,
|
||||||
owner_type='upload_image',
|
owner_type='upload_image',
|
||||||
@@ -496,11 +496,7 @@ async def test_attachment_key_must_belong_to_connection_upload_scope():
|
|||||||
'v1/current/upload_image/key.png',
|
'v1/current/upload_image/key.png',
|
||||||
expected_owner_type='upload_image',
|
expected_owner_type='upload_image',
|
||||||
)
|
)
|
||||||
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
|
storage_mgr.delete_scoped_object_key.assert_not_awaited()
|
||||||
connection.execution_context,
|
|
||||||
'v1/current/upload_image/key.png',
|
|
||||||
expected_owner_type='upload_image',
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='does not belong'):
|
with pytest.raises(ValueError, match='does not belong'):
|
||||||
await adapter._process_image_components(
|
await adapter._process_image_components(
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -277,6 +277,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
|
||||||
@@ -313,8 +314,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',
|
||||||
@@ -344,6 +345,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."""
|
||||||
@@ -568,6 +632,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."""
|
||||||
@@ -610,21 +714,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']
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import threading
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -53,6 +55,23 @@ async def test_matches_any_rejects_pattern_and_input_amplification():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bundled_sensitive_words_fit_within_pattern_limit():
|
||||||
|
config_path = Path(__file__).parents[3] / 'src/langbot/templates/metadata/sensitive-words.json'
|
||||||
|
config = json.loads(config_path.read_text())
|
||||||
|
|
||||||
|
assert len(config['words']) <= safe_regex.MAX_PATTERN_COUNT
|
||||||
|
found, masked = await safe_regex.mask_patterns(
|
||||||
|
config['words'],
|
||||||
|
'普通消息',
|
||||||
|
mask=config['mask'],
|
||||||
|
mask_word=config['mask_word'],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert found is False
|
||||||
|
assert masked == '普通消息'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
||||||
found, masked = await safe_regex.mask_patterns(
|
found, masked = await safe_regex.mask_patterns(
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.utils.import_isolation import isolated_sys_modules
|
||||||
|
|
||||||
|
|
||||||
|
_INSTALL_HINT = "Install LangBot with the 'seekdb' extra"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seekdb_vector_backend_reports_missing_optional_extra() -> None:
|
||||||
|
module_name = 'langbot.pkg.vector.vdbs.seekdb'
|
||||||
|
|
||||||
|
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
|
||||||
|
seekdb_module = importlib.import_module(module_name)
|
||||||
|
|
||||||
|
assert seekdb_module.SEEKDB_AVAILABLE is False
|
||||||
|
with pytest.raises(ImportError, match=_INSTALL_HINT):
|
||||||
|
seekdb_module.SeekDBVectorDatabase(MagicMock())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seekdb_embedding_reports_missing_optional_extra() -> None:
|
||||||
|
module_name = 'langbot.pkg.provider.modelmgr.requesters.seekdbembed'
|
||||||
|
|
||||||
|
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
|
||||||
|
seekdb_embedding_module = importlib.import_module(module_name)
|
||||||
|
requester = seekdb_embedding_module.SeekDBEmbedding.__new__(seekdb_embedding_module.SeekDBEmbedding)
|
||||||
|
|
||||||
|
with pytest.raises(ImportError, match=_INSTALL_HINT):
|
||||||
|
await requester.initialize()
|
||||||
@@ -8,10 +8,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'",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2054,7 +2054,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" },
|
||||||
@@ -2079,6 +2078,11 @@ dependencies = [
|
|||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
seekdb = [
|
||||||
|
{ name = "pyseekdb" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "moto" },
|
{ name = "moto" },
|
||||||
@@ -2143,7 +2147,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" },
|
||||||
@@ -2167,6 +2171,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 = [
|
||||||
|
|||||||
@@ -1,23 +1,41 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Bug, Settings } from 'lucide-react';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Trash2 } from 'lucide-react';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||||
import { Agent } from '@/app/infra/entities/api';
|
import { Agent } from '@/app/infra/entities/api';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
|
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
|
||||||
|
import EntityBasicInfoDialog, {
|
||||||
|
EntityBasicInfoValues,
|
||||||
|
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
|
||||||
|
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
|
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
|
||||||
import AgentCreateContent from './components/AgentCreateContent';
|
import AgentCreateContent from './components/AgentCreateContent';
|
||||||
import AgentDebugPanel from './components/AgentDebugPanel';
|
import AgentDebugPanel from './components/AgentDebugPanel';
|
||||||
import AgentFormComponent from './components/AgentFormComponent';
|
import AgentFormComponent, {
|
||||||
|
AgentFormHandle,
|
||||||
|
AgentRunnerStatus,
|
||||||
|
} from './components/AgentFormComponent';
|
||||||
|
|
||||||
export default function AgentDetailContent({ id }: { id: string }) {
|
export default function AgentDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentWorkspace = useCurrentWorkspace();
|
const currentWorkspace = useCurrentWorkspace();
|
||||||
|
const canManage =
|
||||||
|
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||||
const canOperate =
|
const canOperate =
|
||||||
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
||||||
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
||||||
@@ -25,7 +43,19 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
const [loading, setLoading] = useState(!isCreateMode);
|
const [loading, setLoading] = useState(!isCreateMode);
|
||||||
const [formDirty, setFormDirty] = useState(false);
|
const [formDirty, setFormDirty] = useState(false);
|
||||||
const [formSaving, setFormSaving] = useState(false);
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState('config');
|
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [runnerStatus, setRunnerStatus] = useState<AgentRunnerStatus | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [availableEventTypes, setAvailableEventTypes] = useState<string[]>([
|
||||||
|
'message.received',
|
||||||
|
]);
|
||||||
|
const [supportedEventPatterns, setSupportedEventPatterns] = useState<
|
||||||
|
string[]
|
||||||
|
>(['*']);
|
||||||
|
const agentFormRef = useRef<AgentFormHandle>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
@@ -38,14 +68,33 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
return () => setDetailEntityName(null);
|
return () => setDetailEntityName(null);
|
||||||
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRunnerStatus(null);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreateMode) return;
|
if (isCreateMode) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
httpClient
|
Promise.all([
|
||||||
.getAgent(id)
|
httpClient.getAgent(id),
|
||||||
.then((resp) => {
|
httpClient.getAdapters().catch(() => ({ adapters: [] })),
|
||||||
if (!cancelled) setAgent(resp.agent);
|
])
|
||||||
|
.then(([resp, adaptersResp]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const adapterEvents = adaptersResp.adapters.flatMap(
|
||||||
|
(adapter) => adapter.spec.supported_events ?? [],
|
||||||
|
);
|
||||||
|
setAvailableEventTypes(
|
||||||
|
adapterEvents.length > 0
|
||||||
|
? Array.from(new Set(adapterEvents)).sort()
|
||||||
|
: ['message.received'],
|
||||||
|
);
|
||||||
|
setSupportedEventPatterns(
|
||||||
|
resp.agent.supported_event_patterns ??
|
||||||
|
resp.agent.capability?.supported_event_patterns ?? ['*'],
|
||||||
|
);
|
||||||
|
setAgent(resp.agent);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@@ -78,72 +127,150 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
|
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveBasicInfo(values: EntityBasicInfoValues) {
|
||||||
|
try {
|
||||||
|
await httpClient.updateAgent(id, values);
|
||||||
|
setAgent((current) => (current ? { ...current, ...values } : current));
|
||||||
|
agentFormRef.current?.syncBasicInfo(values);
|
||||||
|
await refreshPipelines();
|
||||||
|
toast.success(t('agents.saveSuccess'));
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
typeof error === 'object' && error && 'msg' in error
|
||||||
|
? String((error as { msg?: string }).msg || '')
|
||||||
|
: '';
|
||||||
|
toast.error(t('agents.saveError') + message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAgent() {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await httpClient.deleteAgent(id);
|
||||||
|
toast.success(t('agents.deleteSuccess'));
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
await refreshPipelines();
|
||||||
|
navigate('/home/agents');
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
typeof error === 'object' && error && 'msg' in error
|
||||||
|
? String((error as { msg?: string }).msg || '')
|
||||||
|
: '';
|
||||||
|
toast.error(t('agents.deleteError') + message);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-w-0 flex-col">
|
<>
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
<ProcessorDetailWorkbench
|
||||||
<h1 className="text-xl font-semibold">{t('agents.editAgent')}</h1>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form="agent-form"
|
|
||||||
disabled={!formDirty || formSaving}
|
|
||||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
|
||||||
>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
key={id}
|
key={id}
|
||||||
value={activeTab}
|
title={`${agent.emoji || '🤖'} ${agent.name}`}
|
||||||
onValueChange={setActiveTab}
|
titleAction={
|
||||||
className="flex min-h-0 min-w-0 flex-1 flex-col"
|
canManage ? (
|
||||||
>
|
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
|
||||||
<TabsList className="shrink-0">
|
) : undefined
|
||||||
<TabsTrigger value="config" className="gap-1.5">
|
}
|
||||||
<Settings className="size-3.5" />
|
status={runnerStatus}
|
||||||
{t('pipelines.configuration')}
|
saveLabel={t('common.save')}
|
||||||
</TabsTrigger>
|
saveFormId="agent-form"
|
||||||
{canOperate && (
|
canSave={canManage}
|
||||||
<TabsTrigger value="debug" className="gap-1.5">
|
isDirty={formDirty}
|
||||||
<Bug className="size-3.5" />
|
isSaving={formSaving}
|
||||||
{t('agents.debugTab')}
|
headerActions={
|
||||||
</TabsTrigger>
|
canManage ? (
|
||||||
)}
|
<Button
|
||||||
</TabsList>
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
<TabsContent
|
disabled={formSaving || deleting}
|
||||||
value="config"
|
onClick={() => setDeleteConfirmOpen(true)}
|
||||||
className="mt-4 min-h-0 min-w-0 flex-1 overflow-hidden"
|
>
|
||||||
>
|
<Trash2 className="size-4" />
|
||||||
<AgentFormComponent
|
{t('common.delete')}
|
||||||
agentId={id}
|
</Button>
|
||||||
onFinish={() => {
|
) : undefined
|
||||||
refreshPipelines();
|
}
|
||||||
}}
|
configTitle={t('pipelines.configuration')}
|
||||||
onDeleted={() => {
|
configContent={
|
||||||
refreshPipelines();
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
navigate('/home/agents');
|
<AgentFormComponent
|
||||||
}}
|
ref={agentFormRef}
|
||||||
onDirtyChange={setFormDirty}
|
agentId={id}
|
||||||
onSavingChange={setFormSaving}
|
availableEventTypes={availableEventTypes}
|
||||||
/>
|
onFinish={(updatedAgent) => {
|
||||||
</TabsContent>
|
if (updatedAgent) {
|
||||||
|
setAgent((current) =>
|
||||||
{canOperate && (
|
current ? { ...current, ...updatedAgent } : current,
|
||||||
<TabsContent
|
);
|
||||||
value="debug"
|
}
|
||||||
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
|
refreshPipelines();
|
||||||
>
|
}}
|
||||||
|
onDirtyChange={setFormDirty}
|
||||||
|
onSavingChange={setFormSaving}
|
||||||
|
onRunnerStatusChange={setRunnerStatus}
|
||||||
|
onSupportedEventPatternsChange={setSupportedEventPatterns}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
debugTitle={canOperate ? t('agents.debugTab') : undefined}
|
||||||
|
debugContent={
|
||||||
|
canOperate ? (
|
||||||
<AgentDebugPanel
|
<AgentDebugPanel
|
||||||
agentId={id}
|
agentId={id}
|
||||||
supportedEventPatterns={
|
hasUnsavedChanges={formDirty}
|
||||||
agent.supported_event_patterns ??
|
beforeRun={async () => agentFormRef.current?.save() ?? false}
|
||||||
agent.capability?.supported_event_patterns ?? ['*']
|
onOpenRunnerConfig={() =>
|
||||||
|
agentFormRef.current?.openSection('runner_config')
|
||||||
}
|
}
|
||||||
|
supportedEventPatterns={supportedEventPatterns}
|
||||||
|
availableEventTypes={availableEventTypes}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
) : undefined
|
||||||
)}
|
}
|
||||||
</Tabs>
|
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||||
</div>
|
/>
|
||||||
|
<EntityBasicInfoDialog
|
||||||
|
open={basicInfoOpen}
|
||||||
|
onOpenChange={setBasicInfoOpen}
|
||||||
|
values={{
|
||||||
|
name: agent.name,
|
||||||
|
description: agent.description || '',
|
||||||
|
emoji: agent.emoji || '🤖',
|
||||||
|
}}
|
||||||
|
defaultEmoji="🤖"
|
||||||
|
onSave={saveBasicInfo}
|
||||||
|
/>
|
||||||
|
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('agents.deleteConfirmation')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => setDeleteConfirmOpen(false)}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={deleteAgent}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
{t('common.confirmDelete')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type React from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -8,8 +7,8 @@ import { toast } from 'sonner';
|
|||||||
import { Bot, Workflow } from 'lucide-react';
|
import { Bot, Workflow } from 'lucide-react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { AgentKind } from '@/app/infra/entities/api';
|
import { AgentKind } from '@/app/infra/entities/api';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -27,6 +26,7 @@ import {
|
|||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||||
|
import ProcessorTypeDiagram from './ProcessorTypeDiagram';
|
||||||
|
|
||||||
export default function AgentCreateContent({
|
export default function AgentCreateContent({
|
||||||
onCreated,
|
onCreated,
|
||||||
@@ -51,9 +51,12 @@ export default function AgentCreateContent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleKindChange(nextKind: AgentKind) {
|
function handleKindChange(nextKind: AgentKind) {
|
||||||
|
const previousDefaultEmoji = kind === 'pipeline' ? '⚙️' : '🤖';
|
||||||
|
const nextDefaultEmoji = nextKind === 'pipeline' ? '⚙️' : '🤖';
|
||||||
setKind(nextKind);
|
setKind(nextKind);
|
||||||
if (!form.getValues('emoji')) {
|
const currentEmoji = form.getValues('emoji');
|
||||||
form.setValue('emoji', nextKind === 'pipeline' ? '⚙️' : '🤖');
|
if (!currentEmoji || currentEmoji === previousDefaultEmoji) {
|
||||||
|
form.setValue('emoji', nextDefaultEmoji);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,26 +77,18 @@ export default function AgentCreateContent({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeOptions: Array<{
|
const typeOptions = [
|
||||||
kind: AgentKind;
|
|
||||||
icon: React.ElementType;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
badge: string;
|
|
||||||
}> = [
|
|
||||||
{
|
{
|
||||||
kind: 'agent',
|
kind: 'agent' as const,
|
||||||
icon: Bot,
|
icon: Bot,
|
||||||
title: t('agents.agentType'),
|
title: t('agents.agentType'),
|
||||||
description: t('agents.agentTypeDescription'),
|
description: t('agents.agentTypeDescription'),
|
||||||
badge: t('agents.allEvents'),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'pipeline',
|
kind: 'pipeline' as const,
|
||||||
icon: Workflow,
|
icon: Workflow,
|
||||||
title: t('agents.pipelineType'),
|
title: t('agents.pipelineType'),
|
||||||
description: t('agents.pipelineTypeDescription'),
|
description: t('agents.pipelineTypeDescription'),
|
||||||
badge: t('agents.messageEventsOnly'),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -107,106 +102,136 @@ export default function AgentCreateContent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
<div className="mx-auto max-w-2xl space-y-6">
|
<div className="mx-auto max-w-6xl pb-6">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid items-stretch gap-5 lg:grid-cols-[minmax(340px,0.78fr)_minmax(0,1.22fr)]">
|
||||||
{typeOptions.map((option) => {
|
<div className="space-y-5">
|
||||||
const Icon = option.icon;
|
<section
|
||||||
const selected = kind === option.kind;
|
aria-labelledby="processor-kind-heading"
|
||||||
return (
|
className="space-y-3"
|
||||||
<button
|
>
|
||||||
key={option.kind}
|
<div>
|
||||||
type="button"
|
<h2
|
||||||
onClick={() => handleKindChange(option.kind)}
|
id="processor-kind-heading"
|
||||||
className={cn(
|
className="text-base font-semibold"
|
||||||
'rounded-lg border bg-card p-4 text-left transition-colors',
|
>
|
||||||
selected
|
{t('agents.chooseType')}
|
||||||
? 'border-primary ring-2 ring-primary/20'
|
</h2>
|
||||||
: 'hover:border-primary/60',
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
)}
|
{t('agents.chooseTypeDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={kind}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) handleKindChange(value as AgentKind);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
spacing={3}
|
||||||
|
className="grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-1"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
{typeOptions.map((option) => {
|
||||||
<Icon className="mt-0.5 size-5 text-blue-500" />
|
const Icon = option.icon;
|
||||||
<div className="space-y-1">
|
return (
|
||||||
<div className="font-medium">{option.title}</div>
|
<ToggleGroupItem
|
||||||
<div className="text-xs text-muted-foreground">
|
key={option.kind}
|
||||||
{option.badge}
|
value={option.kind}
|
||||||
|
data-processor-kind={option.kind}
|
||||||
|
aria-label={`${option.title} ${option.description}`}
|
||||||
|
className="h-auto min-h-28 w-full items-start justify-start gap-3 rounded-lg border px-4 py-4 text-left whitespace-normal shadow-none hover:bg-muted/40 data-[state=on]:border-[#2288ee]/50 data-[state=on]:bg-blue-50/60 data-[state=on]:text-foreground data-[state=on]:shadow-none dark:data-[state=on]:border-blue-500/50 dark:data-[state=on]:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-background text-[#2288ee] shadow-xs">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 space-y-1.5">
|
||||||
|
<span className="block text-sm font-medium text-foreground">
|
||||||
|
{option.title}
|
||||||
|
</span>
|
||||||
|
<span className="block text-sm font-normal leading-relaxed text-muted-foreground">
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ToggleGroup>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('agents.basicInfoDescription')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="agent-create-form"
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
{t('common.name')}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="emoji"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('common.icon')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EmojiPicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{option.description}
|
<FormField
|
||||||
</p>
|
control={form.control}
|
||||||
</div>
|
name="description"
|
||||||
</div>
|
render={({ field }) => (
|
||||||
</button>
|
<FormItem>
|
||||||
);
|
<FormLabel>{t('common.description')}</FormLabel>
|
||||||
})}
|
<FormControl>
|
||||||
|
<Input {...field} value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="min-h-[600px] overflow-hidden py-0 dark:border-white/16 lg:min-h-[680px]">
|
||||||
|
<CardContent className="flex h-full items-center p-0">
|
||||||
|
<ProcessorTypeDiagram kind={kind} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t('agents.basicInfoDescription')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="agent-create-form"
|
|
||||||
onSubmit={form.handleSubmit(handleSubmit)}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<div className="flex gap-4 items-start">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
{t('common.name')}
|
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} value={field.value ?? ''} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="emoji"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('common.icon')}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<EmojiPicker
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('common.description')}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} value={field.value ?? ''} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,39 +1,50 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertCircle,
|
||||||
Braces,
|
ChevronDown,
|
||||||
|
CircleHelp,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
MessageSquare,
|
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
|
import {
|
||||||
|
eventGroupLabel,
|
||||||
|
eventPatternDescription,
|
||||||
|
eventPatternLabel,
|
||||||
|
groupEventPatterns,
|
||||||
|
} from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||||
|
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
|
||||||
|
|
||||||
interface AgentDebugPanelProps {
|
interface AgentDebugPanelProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
availableEventTypes: string[];
|
||||||
supportedEventPatterns?: string[];
|
supportedEventPatterns?: string[];
|
||||||
|
beforeRun?: () => Promise<boolean>;
|
||||||
|
hasUnsavedChanges?: boolean;
|
||||||
|
onOpenRunnerConfig?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DebugEntry {
|
interface DebugEntry {
|
||||||
@@ -41,18 +52,19 @@ interface DebugEntry {
|
|||||||
direction: 'input' | 'output' | 'error';
|
direction: 'input' | 'output' | 'error';
|
||||||
eventType: string;
|
eventType: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
errorCode?: string;
|
||||||
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_PRESETS = [
|
const EVENT_PRESET_DATA: Record<
|
||||||
{
|
string,
|
||||||
value: 'message.received',
|
{ text: string; data: Record<string, unknown> }
|
||||||
labelKey: 'agents.debugMessageReceived',
|
> = {
|
||||||
|
'message.received': {
|
||||||
text: '',
|
text: '',
|
||||||
data: {},
|
data: {},
|
||||||
},
|
},
|
||||||
{
|
'group.member_joined': {
|
||||||
value: 'group.member.joined',
|
|
||||||
labelKey: 'agents.debugGroupMemberJoined',
|
|
||||||
text: 'A new member joined the group.',
|
text: 'A new member joined the group.',
|
||||||
data: {
|
data: {
|
||||||
group_id: 'debug-group',
|
group_id: 'debug-group',
|
||||||
@@ -60,9 +72,7 @@ const EVENT_PRESETS = [
|
|||||||
member_name: 'Debug User',
|
member_name: 'Debug User',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
'group.member_left': {
|
||||||
value: 'group.member.left',
|
|
||||||
labelKey: 'agents.debugGroupMemberLeft',
|
|
||||||
text: 'A member left the group.',
|
text: 'A member left the group.',
|
||||||
data: {
|
data: {
|
||||||
group_id: 'debug-group',
|
group_id: 'debug-group',
|
||||||
@@ -70,9 +80,7 @@ const EVENT_PRESETS = [
|
|||||||
member_name: 'Debug User',
|
member_name: 'Debug User',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
'friend.request_received': {
|
||||||
value: 'friend.requested',
|
|
||||||
labelKey: 'agents.debugFriendRequested',
|
|
||||||
text: 'A user sent a friend request.',
|
text: 'A user sent a friend request.',
|
||||||
data: {
|
data: {
|
||||||
requester_id: 'debug-user',
|
requester_id: 'debug-user',
|
||||||
@@ -80,31 +88,32 @@ const EVENT_PRESETS = [
|
|||||||
message: 'Hello',
|
message: 'Hello',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
'feedback.received': {
|
||||||
value: 'feedback.received',
|
|
||||||
labelKey: 'agents.debugFeedbackReceived',
|
|
||||||
text: 'The user submitted feedback.',
|
text: 'The user submitted feedback.',
|
||||||
data: {
|
data: {
|
||||||
rating: 5,
|
rating: 5,
|
||||||
content: 'Debug feedback',
|
content: 'Debug feedback',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
};
|
||||||
value: 'custom',
|
|
||||||
labelKey: 'agents.debugCustomEvent',
|
|
||||||
text: '',
|
|
||||||
data: {},
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function createDebugSessionId(agentId: string) {
|
function createDebugSessionId(agentId: string) {
|
||||||
const nonce = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
|
const nonce = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
|
||||||
return `webui:${agentId}:${nonce}`;
|
return `webui:${agentId}:${nonce}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesEventPattern(pattern: string, eventType: string) {
|
||||||
|
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return new RegExp(`^${escaped.replaceAll('*', '.*')}$`).test(eventType);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AgentDebugPanel({
|
export default function AgentDebugPanel({
|
||||||
agentId,
|
agentId,
|
||||||
|
availableEventTypes,
|
||||||
supportedEventPatterns = ['*'],
|
supportedEventPatterns = ['*'],
|
||||||
|
beforeRun,
|
||||||
|
hasUnsavedChanges = false,
|
||||||
|
onOpenRunnerConfig,
|
||||||
}: AgentDebugPanelProps) {
|
}: AgentDebugPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [preset, setPreset] = useState('message.received');
|
const [preset, setPreset] = useState('message.received');
|
||||||
@@ -121,11 +130,39 @@ export default function AgentDebugPanel({
|
|||||||
() => supportedEventPatterns.join(', '),
|
() => supportedEventPatterns.join(', '),
|
||||||
[supportedEventPatterns],
|
[supportedEventPatterns],
|
||||||
);
|
);
|
||||||
|
const availableEvents = useMemo(() => {
|
||||||
|
const concretePatterns = supportedEventPatterns.filter(
|
||||||
|
(pattern) => pattern !== '*' && !pattern.endsWith('.*'),
|
||||||
|
);
|
||||||
|
return Array.from(new Set([...availableEventTypes, ...concretePatterns]))
|
||||||
|
.filter((candidate) =>
|
||||||
|
supportedEventPatterns.some((pattern) =>
|
||||||
|
matchesEventPattern(pattern, candidate),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.sort();
|
||||||
|
}, [availableEventTypes, supportedEventPatterns]);
|
||||||
|
const eventGroups = useMemo(
|
||||||
|
() => groupEventPatterns(availableEvents),
|
||||||
|
[availableEvents],
|
||||||
|
);
|
||||||
|
const supportsCustomEvent = supportedEventPatterns.some(
|
||||||
|
(pattern) => pattern === '*' || pattern.endsWith('.*'),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
availableEvents.includes(preset) ||
|
||||||
|
(preset === 'custom' && supportsCustomEvent)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectPreset(availableEvents[0] ?? 'custom');
|
||||||
|
}, [availableEvents, preset, supportsCustomEvent]);
|
||||||
|
|
||||||
function selectPreset(value: string) {
|
function selectPreset(value: string) {
|
||||||
setPreset(value);
|
setPreset(value);
|
||||||
const nextPreset = EVENT_PRESETS.find((item) => item.value === value);
|
const nextPreset = EVENT_PRESET_DATA[value] ?? { text: '', data: {} };
|
||||||
if (!nextPreset) return;
|
|
||||||
setInputText(nextPreset.text);
|
setInputText(nextPreset.text);
|
||||||
setEventDataText(JSON.stringify(nextPreset.data, null, 2));
|
setEventDataText(JSON.stringify(nextPreset.data, null, 2));
|
||||||
}
|
}
|
||||||
@@ -144,6 +181,14 @@ export default function AgentDebugPanel({
|
|||||||
toast.error(t('agents.debugInputRequired'));
|
toast.error(t('agents.debugInputRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!supportedEventPatterns.some((pattern) =>
|
||||||
|
matchesEventPattern(pattern, eventType),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
toast.error(t('agents.debugUnsupportedEvent'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let eventData: Record<string, unknown>;
|
let eventData: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
@@ -157,6 +202,12 @@ export default function AgentDebugPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setRunning(true);
|
||||||
|
if (hasUnsavedChanges && beforeRun && !(await beforeRun())) {
|
||||||
|
setRunning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const requestId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
|
const requestId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
|
||||||
setEntries((current) => [
|
setEntries((current) => [
|
||||||
...current,
|
...current,
|
||||||
@@ -167,7 +218,6 @@ export default function AgentDebugPanel({
|
|||||||
text: inputText.trim() || JSON.stringify(eventData, null, 2),
|
text: inputText.trim() || JSON.stringify(eventData, null, 2),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
setRunning(true);
|
|
||||||
try {
|
try {
|
||||||
const result = await httpClient.debugAgent(agentId, {
|
const result = await httpClient.debugAgent(agentId, {
|
||||||
event_type: eventType,
|
event_type: eventType,
|
||||||
@@ -186,17 +236,41 @@ export default function AgentDebugPanel({
|
|||||||
]);
|
]);
|
||||||
if (isMessageEvent) setInputText('');
|
if (isMessageEvent) setInputText('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorCode =
|
||||||
|
typeof error === 'object' && error && 'code' in error
|
||||||
|
? String((error as { code?: string }).code || '')
|
||||||
|
: '';
|
||||||
const message =
|
const message =
|
||||||
typeof error === 'object' && error && 'msg' in error
|
typeof error === 'object' && error && 'msg' in error
|
||||||
? String((error as { msg?: string }).msg || '')
|
? String((error as { msg?: string }).msg || '')
|
||||||
: t('agents.debugRunFailed');
|
: t('agents.debugRunFailed');
|
||||||
|
const isConfigError = errorCode.endsWith('.config_invalid');
|
||||||
|
const isExecutionError = errorCode === 'runner_execution_failed';
|
||||||
|
const isTimeout = errorCode === 'runner.timeout';
|
||||||
|
const friendlyMessage = isConfigError
|
||||||
|
? t('agents.debugRunnerConfigInvalidDescription', {
|
||||||
|
message:
|
||||||
|
message === 'api-key is required'
|
||||||
|
? t('agents.debugApiKeyRequired')
|
||||||
|
: message,
|
||||||
|
})
|
||||||
|
: isExecutionError
|
||||||
|
? t('agents.debugRunnerExecutionFailedDescription')
|
||||||
|
: isTimeout
|
||||||
|
? t('agents.debugRunnerTimeoutDescription')
|
||||||
|
: message || t('agents.debugRunFailed');
|
||||||
setEntries((current) => [
|
setEntries((current) => [
|
||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
id: `error:${requestId}`,
|
id: `error:${requestId}`,
|
||||||
direction: 'error',
|
direction: 'error',
|
||||||
eventType,
|
eventType,
|
||||||
text: message || t('agents.debugRunFailed'),
|
text: friendlyMessage,
|
||||||
|
errorCode,
|
||||||
|
detail:
|
||||||
|
isExecutionError || isTimeout
|
||||||
|
? message || t('agents.debugRunFailed')
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -205,162 +279,213 @@ export default function AgentDebugPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto grid w-full min-w-0 max-w-6xl gap-6 pb-8 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,0.8fr)]">
|
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
||||||
<Card className="min-w-0">
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
<CardHeader>
|
<div className="mb-3">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
|
||||||
<div className="space-y-1">
|
<p className="text-xs text-muted-foreground">
|
||||||
<CardTitle className="flex items-center gap-2">
|
{t('agents.debugTranscriptDescription')}
|
||||||
{isMessageEvent ? (
|
</p>
|
||||||
<MessageSquare className="size-5" />
|
</div>
|
||||||
) : (
|
{entries.length === 0 ? (
|
||||||
<Braces className="size-5" />
|
<Alert className="my-4 bg-muted/20">
|
||||||
)}
|
<CircleHelp className="size-4" />
|
||||||
{t('agents.debugTitle')}
|
<AlertTitle>{t('agents.debugEmptyTitle')}</AlertTitle>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>{t('agents.debugDescription')}</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={resetSession}
|
|
||||||
>
|
|
||||||
<RotateCcw className="size-4" />
|
|
||||||
{t('agents.debugResetSession')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-5">
|
|
||||||
<Alert>
|
|
||||||
<AlertTriangle />
|
|
||||||
<AlertTitle>{t('agents.debugActualRun')}</AlertTitle>
|
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t('agents.debugActualRunDescription')}
|
{t('agents.debugEmptyTranscript')}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
{entries.map((entry) => (
|
||||||
<Label>{t('agents.debugEventType')}</Label>
|
<Alert
|
||||||
<Select value={preset} onValueChange={selectPreset}>
|
key={entry.id}
|
||||||
<SelectTrigger className="w-full">
|
variant={
|
||||||
<SelectValue />
|
entry.direction === 'error' ? 'destructive' : 'default'
|
||||||
</SelectTrigger>
|
}
|
||||||
<SelectContent>
|
className={
|
||||||
{EVENT_PRESETS.map((item) => (
|
entry.direction === 'output'
|
||||||
<SelectItem key={item.value} value={item.value}>
|
? 'border-primary/20 bg-primary/5'
|
||||||
{t(item.labelKey)}
|
: entry.direction === 'input'
|
||||||
</SelectItem>
|
? 'bg-muted/40'
|
||||||
))}
|
: undefined
|
||||||
</SelectContent>
|
}
|
||||||
</Select>
|
>
|
||||||
</div>
|
{entry.direction === 'error' && <AlertCircle />}
|
||||||
{preset === 'custom' && (
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<div className="space-y-2">
|
<Badge variant="outline">{entry.eventType}</Badge>
|
||||||
<Label htmlFor="agent-debug-custom-event">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t('agents.debugCustomEventType')}
|
{entry.direction === 'output'
|
||||||
</Label>
|
? t('agents.debugAgentOutput')
|
||||||
<Input
|
: entry.direction === 'error'
|
||||||
id="agent-debug-custom-event"
|
? t('common.error')
|
||||||
value={customEventType}
|
: t('agents.debugTestInput')}
|
||||||
onChange={(event) => setCustomEventType(event.target.value)}
|
</span>
|
||||||
placeholder="custom.event"
|
</div>
|
||||||
/>
|
<pre className="min-w-0 whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
|
||||||
</div>
|
{entry.text}
|
||||||
)}
|
</pre>
|
||||||
|
{entry.detail && (
|
||||||
|
<Collapsible className="mt-3">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button type="button" variant="ghost" size="sm">
|
||||||
|
{t('agents.debugErrorDetails')}
|
||||||
|
<ChevronDown className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
|
||||||
|
{entry.detail}
|
||||||
|
</pre>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
{(entry.errorCode?.endsWith('.config_invalid') ||
|
||||||
|
entry.errorCode === 'runner_execution_failed' ||
|
||||||
|
entry.errorCode === 'runner.timeout') &&
|
||||||
|
onOpenRunnerConfig && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={onOpenRunnerConfig}
|
||||||
|
>
|
||||||
|
{t('agents.debugReviewRunnerConfig')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="shrink-0 space-y-3 border-t p-3">
|
||||||
<Label htmlFor="agent-debug-input">
|
<div className="flex items-end gap-2">
|
||||||
{isMessageEvent
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
? t('agents.debugMessageInput')
|
<Label>{t('agents.debugEventType')}</Label>
|
||||||
: t('agents.debugEventSummary')}
|
<Select value={preset} onValueChange={selectPreset}>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-full"
|
||||||
|
aria-label={t('agents.debugEventType')}
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
|
||||||
|
{eventGroups.map((group) => (
|
||||||
|
<SelectGroup key={group.namespace}>
|
||||||
|
<SelectLabel>
|
||||||
|
{eventGroupLabel(group.namespace, t)}
|
||||||
|
</SelectLabel>
|
||||||
|
{group.patterns.map((event) => (
|
||||||
|
<SelectItem
|
||||||
|
key={event}
|
||||||
|
value={event}
|
||||||
|
description={eventPatternDescription(event, t)}
|
||||||
|
className="py-2"
|
||||||
|
>
|
||||||
|
<EventSelectOptionContent
|
||||||
|
event={event}
|
||||||
|
label={eventPatternLabel(event, t)}
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
|
{supportsCustomEvent && (
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>{t('agents.debugCustomEvent')}</SelectLabel>
|
||||||
|
<SelectItem
|
||||||
|
value="custom"
|
||||||
|
description={t('bots.eventDescriptions.custom')}
|
||||||
|
className="py-2"
|
||||||
|
>
|
||||||
|
<EventSelectOptionContent
|
||||||
|
event="custom.event"
|
||||||
|
label={t('agents.debugCustomEvent')}
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={resetSession}
|
||||||
|
title={t('agents.debugResetSession')}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preset === 'custom' && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="agent-debug-custom-event">
|
||||||
|
{t('agents.debugCustomEventType')}
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Input
|
||||||
id="agent-debug-input"
|
id="agent-debug-custom-event"
|
||||||
value={inputText}
|
value={customEventType}
|
||||||
onChange={(event) => setInputText(event.target.value)}
|
onChange={(event) => setCustomEventType(event.target.value)}
|
||||||
className="min-h-24 resize-y"
|
placeholder="custom.event"
|
||||||
placeholder={t('agents.debugInputPlaceholder')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<Label htmlFor="agent-debug-input">
|
||||||
<Label htmlFor="agent-debug-payload">
|
{isMessageEvent
|
||||||
{t('agents.debugEventPayload')}
|
? t('agents.debugMessageInput')
|
||||||
</Label>
|
: t('agents.debugEventSummary')}
|
||||||
<span className="text-xs text-muted-foreground">
|
</Label>
|
||||||
{t('agents.debugSupportedEvents')}: {supportedLabel}
|
<Textarea
|
||||||
</span>
|
id="agent-debug-input"
|
||||||
</div>
|
value={inputText}
|
||||||
|
onChange={(event) => setInputText(event.target.value)}
|
||||||
|
className="min-h-20 resize-y"
|
||||||
|
placeholder={t('agents.debugInputPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="rounded-md border bg-muted/20 px-3 py-2">
|
||||||
|
<summary className="cursor-pointer text-xs font-medium">
|
||||||
|
{t('agents.debugEventPayload')}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('agents.debugSupportedEvents')}: {supportedLabel}
|
||||||
|
</p>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="agent-debug-payload"
|
id="agent-debug-payload"
|
||||||
value={eventDataText}
|
value={eventDataText}
|
||||||
onChange={(event) => setEventDataText(event.target.value)}
|
onChange={(event) => setEventDataText(event.target.value)}
|
||||||
className="min-h-40 resize-y font-mono text-xs"
|
className="min-h-28 resize-y font-mono text-xs"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<Button
|
||||||
<Button type="button" disabled={running} onClick={runDebugEvent}>
|
type="button"
|
||||||
{running ? (
|
className="w-full"
|
||||||
<LoaderCircle className="size-4 animate-spin" />
|
disabled={running}
|
||||||
) : (
|
onClick={runDebugEvent}
|
||||||
<Play className="size-4" />
|
>
|
||||||
)}
|
{running ? (
|
||||||
{running ? t('agents.debugRunning') : t('agents.debugRun')}
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="min-h-[28rem] min-w-0">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('agents.debugTranscript')}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t('agents.debugTranscriptDescription')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{entries.length === 0 ? (
|
|
||||||
<div className="flex min-h-72 items-center justify-center rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
|
|
||||||
{t('agents.debugEmptyTranscript')}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-[42rem] space-y-4 overflow-y-auto pr-1">
|
<Play className="size-4" />
|
||||||
{entries.map((entry) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
className={`rounded-lg border p-3 ${
|
|
||||||
entry.direction === 'output'
|
|
||||||
? 'border-primary/20 bg-primary/5'
|
|
||||||
: entry.direction === 'error'
|
|
||||||
? 'border-destructive/30 bg-destructive/5'
|
|
||||||
: 'bg-muted/40'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
|
||||||
<Badge variant="outline">{entry.eventType}</Badge>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{entry.direction === 'output'
|
|
||||||
? t('agents.debugAgentOutput')
|
|
||||||
: entry.direction === 'error'
|
|
||||||
? t('common.error')
|
|
||||||
: t('agents.debugTestInput')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<pre className="min-w-0 whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
|
|
||||||
{entry.text}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
{running
|
||||||
</Card>
|
? t('agents.debugRunning')
|
||||||
|
: hasUnsavedChanges
|
||||||
|
? t('agents.debugSaveAndRun')
|
||||||
|
: t('agents.debugRun')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
eventGroupLabel,
|
||||||
|
eventNamespaces,
|
||||||
|
eventPatternDescription,
|
||||||
|
eventPatternLabel,
|
||||||
|
groupEventPatterns,
|
||||||
|
} from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||||
|
|
||||||
|
const FALLBACK_EVENTS = ['message.received'];
|
||||||
|
|
||||||
|
interface AgentEventPatternPickerProps {
|
||||||
|
events: string[];
|
||||||
|
value: string[];
|
||||||
|
onChange: (patterns: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentEventPatternPicker({
|
||||||
|
events,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: AgentEventPatternPickerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selectedPatterns = useMemo(
|
||||||
|
() => (value.length > 0 ? value : ['*']),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
const options = useMemo(() => {
|
||||||
|
const concreteEvents = Array.from(
|
||||||
|
new Set([
|
||||||
|
...(events.length > 0 ? events : FALLBACK_EVENTS),
|
||||||
|
...selectedPatterns.filter(
|
||||||
|
(pattern) => pattern !== '*' && !pattern.endsWith('.*'),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
).sort();
|
||||||
|
const namespaces = Array.from(
|
||||||
|
new Set([
|
||||||
|
...eventNamespaces(concreteEvents),
|
||||||
|
...selectedPatterns.filter((pattern) => pattern.endsWith('.*')),
|
||||||
|
]),
|
||||||
|
).sort();
|
||||||
|
return ['*', ...namespaces, ...concreteEvents];
|
||||||
|
}, [events, selectedPatterns]);
|
||||||
|
const optionGroups = useMemo(() => groupEventPatterns(options), [options]);
|
||||||
|
|
||||||
|
function togglePattern(pattern: string) {
|
||||||
|
if (pattern === '*') {
|
||||||
|
onChange(['*']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedPatterns.includes(pattern)) {
|
||||||
|
const next = selectedPatterns.filter((item) => item !== pattern);
|
||||||
|
onChange(next.length > 0 ? next : ['*']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = selectedPatterns.filter((item) => item !== '*');
|
||||||
|
const namespace = pattern.split('.')[0];
|
||||||
|
if (pattern.endsWith('.*')) {
|
||||||
|
next = next.filter(
|
||||||
|
(item) => item.split('.')[0] !== namespace || item.endsWith('.*'),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
next = next.filter((item) => item !== `${namespace}.*`);
|
||||||
|
}
|
||||||
|
onChange(Array.from(new Set([...next, pattern])));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={t('agents.supportedEvents')}
|
||||||
|
className="h-auto min-h-10 w-full min-w-0 justify-between gap-2 px-3 py-2 font-normal"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-wrap gap-1.5">
|
||||||
|
{selectedPatterns.slice(0, 3).map((pattern) => (
|
||||||
|
<Badge
|
||||||
|
key={pattern}
|
||||||
|
variant="secondary"
|
||||||
|
className="max-w-full rounded-md font-normal"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{eventPatternLabel(pattern, t)}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{selectedPatterns.length > 3 && (
|
||||||
|
<Badge variant="outline" className="rounded-md font-normal">
|
||||||
|
+{selectedPatterns.length - 3}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={t('agents.searchEvents')} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t('agents.noEventsFound')}</CommandEmpty>
|
||||||
|
{optionGroups.map((group) => (
|
||||||
|
<CommandGroup
|
||||||
|
key={group.namespace}
|
||||||
|
heading={eventGroupLabel(group.namespace, t)}
|
||||||
|
>
|
||||||
|
{group.patterns.map((pattern) => {
|
||||||
|
const selected = selectedPatterns.includes(pattern);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={pattern}
|
||||||
|
value={`${eventPatternLabel(pattern, t)} ${pattern}`}
|
||||||
|
onSelect={() => togglePattern(pattern)}
|
||||||
|
className="items-start gap-2 py-2"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 size-4 shrink-0',
|
||||||
|
selected ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{eventPatternLabel(pattern, t)}
|
||||||
|
</span>
|
||||||
|
<code className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
{pattern}
|
||||||
|
</code>
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||||
|
{eventPatternDescription(pattern, t)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import {
|
||||||
import { Link } from 'react-router-dom';
|
forwardRef,
|
||||||
|
type ForwardedRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import { Bot, SlidersHorizontal, Zap } from 'lucide-react';
|
||||||
CircleAlert,
|
|
||||||
CircleCheck,
|
|
||||||
LoaderCircle,
|
|
||||||
Power,
|
|
||||||
RefreshCw,
|
|
||||||
Trash2,
|
|
||||||
Unplug,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
||||||
import {
|
import {
|
||||||
@@ -22,11 +22,7 @@ import {
|
|||||||
} from '@/app/infra/entities/pipeline';
|
} from '@/app/infra/entities/pipeline';
|
||||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -34,39 +30,85 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import AgentEventPatternPicker from './AgentEventPatternPicker';
|
||||||
|
|
||||||
|
export interface AgentRunnerStatus {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
tone: 'neutral' | 'success' | 'warning' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
interface AgentFormComponentProps {
|
interface AgentFormComponentProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
onFinish: () => void;
|
availableEventTypes: string[];
|
||||||
onDeleted: () => void;
|
onFinish: (agent?: Partial<Agent>) => void;
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
onSavingChange?: (saving: boolean) => void;
|
onSavingChange?: (saving: boolean) => void;
|
||||||
|
onRunnerStatusChange?: (status: AgentRunnerStatus) => void;
|
||||||
|
onSupportedEventPatternsChange?: (patterns: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AgentFormComponent({
|
export type AgentConfigSection = 'events' | 'runner' | 'runner_config';
|
||||||
agentId,
|
|
||||||
onFinish,
|
export interface AgentFormHandle {
|
||||||
onDeleted,
|
openSection: (section: AgentConfigSection) => void;
|
||||||
onDirtyChange,
|
save: () => Promise<boolean>;
|
||||||
onSavingChange,
|
syncBasicInfo: (values: {
|
||||||
}: AgentFormComponentProps) {
|
name: string;
|
||||||
|
description: string;
|
||||||
|
emoji?: string;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRequiredRunnerValueMissing(value: unknown): boolean {
|
||||||
|
if (value === null || value === undefined) return true;
|
||||||
|
if (typeof value === 'string') return value.trim() === '';
|
||||||
|
if (Array.isArray(value)) return value.length === 0;
|
||||||
|
if (typeof value === 'object' && 'primary' in value) {
|
||||||
|
return !String((value as { primary?: unknown }).primary || '').trim();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunnerFieldVisible(
|
||||||
|
field: PipelineConfigStage['config'][number],
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
if (!field.show_if || field.show_if.field.startsWith('__system.')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const dependentValue = values[field.show_if.field];
|
||||||
|
if (field.show_if.operator === 'eq') {
|
||||||
|
return dependentValue === field.show_if.value;
|
||||||
|
}
|
||||||
|
if (field.show_if.operator === 'neq') {
|
||||||
|
return dependentValue !== field.show_if.value;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
Array.isArray(field.show_if.value) &&
|
||||||
|
field.show_if.value.includes(dependentValue)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentFormComponent(
|
||||||
|
{
|
||||||
|
agentId,
|
||||||
|
availableEventTypes,
|
||||||
|
onFinish,
|
||||||
|
onDirtyChange,
|
||||||
|
onSavingChange,
|
||||||
|
onRunnerStatusChange,
|
||||||
|
onSupportedEventPatternsChange,
|
||||||
|
}: AgentFormComponentProps,
|
||||||
|
ref: ForwardedRef<AgentFormHandle>,
|
||||||
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [runnerConfigSchema, setRunnerConfigSchema] =
|
const [runnerConfigSchema, setRunnerConfigSchema] =
|
||||||
useState<PipelineConfigTab | null>(null);
|
useState<PipelineConfigTab | null>(null);
|
||||||
@@ -74,20 +116,20 @@ export default function AgentFormComponent({
|
|||||||
useState<ApiRespPluginSystemStatus | null>(null);
|
useState<ApiRespPluginSystemStatus | null>(null);
|
||||||
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
|
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
|
||||||
const [pluginStatusError, setPluginStatusError] = useState(false);
|
const [pluginStatusError, setPluginStatusError] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [activeSection, setActiveSection] =
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
useState<AgentConfigSection>('runner');
|
||||||
const isSavingRef = useRef(false);
|
const isSavingRef = useRef(false);
|
||||||
|
const hasUnsavedChangesRef = useRef(false);
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
basic: z.object({
|
basic: z.object({
|
||||||
name: z.string().min(1, { message: t('agents.nameRequired') }),
|
name: z.string().min(1, { message: t('agents.nameRequired') }),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
emoji: z.string().optional(),
|
emoji: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
|
||||||
}),
|
}),
|
||||||
runner: z.record(z.string(), z.any()),
|
runner: z.record(z.string(), z.any()),
|
||||||
runner_config: z.record(z.string(), z.any()),
|
runner_config: z.record(z.string(), z.any()),
|
||||||
supported_event_patterns_text: z.string(),
|
supported_event_patterns: z.array(z.string()).min(1),
|
||||||
});
|
});
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
@@ -98,11 +140,10 @@ export default function AgentFormComponent({
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
emoji: '🤖',
|
emoji: '🤖',
|
||||||
enabled: true,
|
|
||||||
},
|
},
|
||||||
runner: {},
|
runner: {},
|
||||||
runner_config: {},
|
runner_config: {},
|
||||||
supported_event_patterns_text: '*',
|
supported_event_patterns: ['*'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,11 +154,17 @@ export default function AgentFormComponent({
|
|||||||
if (!savedSnapshotRef.current) return false;
|
if (!savedSnapshotRef.current) return false;
|
||||||
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
||||||
})();
|
})();
|
||||||
|
hasUnsavedChangesRef.current = hasUnsavedChanges;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDirtyChange?.(hasUnsavedChanges);
|
onDirtyChange?.(hasUnsavedChanges);
|
||||||
}, [hasUnsavedChanges, onDirtyChange]);
|
}, [hasUnsavedChanges, onDirtyChange]);
|
||||||
|
|
||||||
|
const supportedEventPatterns = form.watch('supported_event_patterns');
|
||||||
|
useEffect(() => {
|
||||||
|
onSupportedEventPatternsChange?.(supportedEventPatterns);
|
||||||
|
}, [onSupportedEventPatternsChange, supportedEventPatterns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)])
|
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)])
|
||||||
@@ -131,15 +178,12 @@ export default function AgentFormComponent({
|
|||||||
name: agent.name ?? '',
|
name: agent.name ?? '',
|
||||||
description: agent.description ?? '',
|
description: agent.description ?? '',
|
||||||
emoji: agent.emoji || '🤖',
|
emoji: agent.emoji || '🤖',
|
||||||
enabled: agent.enabled ?? true,
|
|
||||||
},
|
},
|
||||||
runner: (config.runner as Record<string, unknown>) ?? {},
|
runner: (config.runner as Record<string, unknown>) ?? {},
|
||||||
runner_config:
|
runner_config:
|
||||||
(config.runner_config as Record<string, unknown>) ?? {},
|
(config.runner_config as Record<string, unknown>) ?? {},
|
||||||
supported_event_patterns_text: (
|
supported_event_patterns: agent.supported_event_patterns ??
|
||||||
agent.supported_event_patterns ??
|
agent.capability?.supported_event_patterns ?? ['*'],
|
||||||
agent.capability?.supported_event_patterns ?? ['*']
|
|
||||||
).join('\n'),
|
|
||||||
};
|
};
|
||||||
form.reset(loadedValues);
|
form.reset(loadedValues);
|
||||||
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
||||||
@@ -182,117 +226,137 @@ export default function AgentFormComponent({
|
|||||||
const selectedRunnerOption = runnerOptions.find(
|
const selectedRunnerOption = runnerOptions.find(
|
||||||
(option) => option.name === currentRunner,
|
(option) => option.name === currentRunner,
|
||||||
);
|
);
|
||||||
|
const runnerSelectorStage = runnerConfigSchema?.stages.find(
|
||||||
|
(stage) => stage.name === 'runner',
|
||||||
|
);
|
||||||
|
const activeRunnerStage = runnerConfigSchema?.stages.find(
|
||||||
|
(stage) => stage.name === currentRunner,
|
||||||
|
);
|
||||||
|
const runnerConfigValues = form.watch('runner_config') as Record<
|
||||||
|
string,
|
||||||
|
Record<string, unknown>
|
||||||
|
>;
|
||||||
|
const activeRunnerValues = useMemo(
|
||||||
|
() => runnerConfigValues?.[currentRunner] ?? {},
|
||||||
|
[currentRunner, runnerConfigValues],
|
||||||
|
);
|
||||||
|
const missingRunnerFields = useMemo(
|
||||||
|
() =>
|
||||||
|
(activeRunnerStage?.config ?? []).filter(
|
||||||
|
(field) =>
|
||||||
|
field.required &&
|
||||||
|
isRunnerFieldVisible(field, activeRunnerValues) &&
|
||||||
|
isRequiredRunnerValueMissing(activeRunnerValues[field.name]),
|
||||||
|
),
|
||||||
|
[activeRunnerStage, activeRunnerValues],
|
||||||
|
);
|
||||||
|
const primarySections: Array<{
|
||||||
|
name: AgentConfigSection;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'runner',
|
||||||
|
label: t('agents.runnerSettings'),
|
||||||
|
icon: Bot,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'runner_config',
|
||||||
|
label: selectedRunnerOption
|
||||||
|
? extractI18nObject(selectedRunnerOption.label)
|
||||||
|
: t('pipelines.configuration'),
|
||||||
|
icon: SlidersHorizontal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'events',
|
||||||
|
label: t('agents.bindableEvents'),
|
||||||
|
icon: Zap,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function renderRunnerStatusActions(showRetry = true) {
|
const runnerStatus = useMemo<AgentRunnerStatus>(() => {
|
||||||
return (
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{showRetry && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void loadPluginSystemStatus()}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-4" />
|
|
||||||
{t('common.retry')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="outline" size="sm" asChild>
|
|
||||||
<Link to="/home/extensions">{t('plugins.title')}</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRunnerStatus() {
|
|
||||||
if (pluginStatusLoading) {
|
if (pluginStatusLoading) {
|
||||||
return (
|
return {
|
||||||
<Alert>
|
label: t('agents.runnerStatusLoading'),
|
||||||
<LoaderCircle className="animate-spin" />
|
tone: 'neutral',
|
||||||
<AlertTitle>{t('agents.runnerStatusLoading')}</AlertTitle>
|
};
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pluginStatusError || !pluginSystemStatus) {
|
if (pluginStatusError || !pluginSystemStatus) {
|
||||||
return (
|
return {
|
||||||
<Alert variant="destructive">
|
label: t('agents.runnerStatusCheckFailed'),
|
||||||
<CircleAlert />
|
description: t('agents.runnerStatusCheckFailedDescription'),
|
||||||
<AlertTitle>{t('agents.runnerStatusCheckFailed')}</AlertTitle>
|
tone: 'error',
|
||||||
<AlertDescription>
|
};
|
||||||
{t('agents.runnerStatusCheckFailedDescription')}
|
|
||||||
{renderRunnerStatusActions()}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pluginSystemStatus.is_enable) {
|
if (!pluginSystemStatus.is_enable) {
|
||||||
return (
|
return {
|
||||||
<Alert variant="destructive">
|
label: t('plugins.systemDisabled'),
|
||||||
<Power />
|
description: t('plugins.systemDisabledDesc'),
|
||||||
<AlertTitle>{t('plugins.systemDisabled')}</AlertTitle>
|
tone: 'error',
|
||||||
<AlertDescription>
|
};
|
||||||
{t('plugins.systemDisabledDesc')}
|
|
||||||
{renderRunnerStatusActions(false)}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pluginSystemStatus.is_connected) {
|
if (!pluginSystemStatus.is_connected) {
|
||||||
return (
|
return {
|
||||||
<Alert variant="destructive">
|
label: t('plugins.connectionError'),
|
||||||
<Unplug />
|
description: t('plugins.connectionErrorDesc'),
|
||||||
<AlertTitle>{t('plugins.connectionError')}</AlertTitle>
|
tone: 'error',
|
||||||
<AlertDescription>
|
};
|
||||||
{t('plugins.connectionErrorDesc')}
|
|
||||||
{renderRunnerStatusActions()}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runnerOptions.length === 0) {
|
if (runnerOptions.length === 0) {
|
||||||
return (
|
return {
|
||||||
<Alert variant="destructive">
|
label: t('agents.noRunnersAvailable'),
|
||||||
<CircleAlert />
|
description: t('agents.noRunnersAvailableDescription'),
|
||||||
<AlertTitle>{t('agents.noRunnersAvailable')}</AlertTitle>
|
tone: 'error',
|
||||||
<AlertDescription>
|
};
|
||||||
{t('agents.noRunnersAvailableDescription')}
|
|
||||||
{renderRunnerStatusActions()}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentRunner || !selectedRunnerOption) {
|
if (!currentRunner || !selectedRunnerOption) {
|
||||||
return (
|
return {
|
||||||
<Alert variant="destructive">
|
label: t('agents.selectedRunnerUnavailable'),
|
||||||
<CircleAlert />
|
description: t('agents.selectedRunnerUnavailableDescription', {
|
||||||
<AlertTitle>{t('agents.selectedRunnerUnavailable')}</AlertTitle>
|
runner: currentRunner || t('agents.noRunnerSelected'),
|
||||||
<AlertDescription>
|
}),
|
||||||
{t('agents.selectedRunnerUnavailableDescription', {
|
tone: 'warning',
|
||||||
runner: currentRunner || t('agents.noRunnerSelected'),
|
};
|
||||||
})}
|
|
||||||
{renderRunnerStatusActions()}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (missingRunnerFields.length > 0) {
|
||||||
<Alert className="border-emerald-600/40 bg-emerald-500/5 text-emerald-950 dark:text-emerald-100">
|
return {
|
||||||
<CircleCheck className="text-emerald-600" />
|
label: t('agents.runnerConfigIncomplete'),
|
||||||
<AlertTitle>{t('agents.runnerReady')}</AlertTitle>
|
description: t('agents.runnerConfigIncompleteDescription', {
|
||||||
<AlertDescription>
|
fields: missingRunnerFields
|
||||||
{t('agents.runnerReadyDescription', {
|
.map((field) => extractI18nObject(field.label))
|
||||||
runner: extractI18nObject(selectedRunnerOption.label),
|
.join(', '),
|
||||||
})}
|
}),
|
||||||
</AlertDescription>
|
tone: 'warning',
|
||||||
</Alert>
|
};
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
return {
|
||||||
|
label: t('agents.runnerReady'),
|
||||||
|
description: t('agents.runnerReadyDescription', {
|
||||||
|
runner: extractI18nObject(selectedRunnerOption.label),
|
||||||
|
}),
|
||||||
|
tone: 'success',
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
currentRunner,
|
||||||
|
pluginStatusError,
|
||||||
|
pluginStatusLoading,
|
||||||
|
pluginSystemStatus,
|
||||||
|
runnerOptions.length,
|
||||||
|
missingRunnerFields,
|
||||||
|
selectedRunnerOption,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onRunnerStatusChange?.(runnerStatus);
|
||||||
|
}, [onRunnerStatusChange, runnerStatus]);
|
||||||
|
|
||||||
function updateSnapshotIfInitial(stageKey: string) {
|
function updateSnapshotIfInitial(stageKey: string) {
|
||||||
if (!initializedStagesRef.current.has(stageKey)) {
|
if (!initializedStagesRef.current.has(stageKey)) {
|
||||||
@@ -364,208 +428,125 @@ export default function AgentFormComponent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEventPatterns(value: string): string[] {
|
const saveValues = useCallback(
|
||||||
const patterns = value
|
async (values: FormValues) => {
|
||||||
.split(/[\n,]/)
|
if (isSavingRef.current) return false;
|
||||||
.map((item) => item.trim())
|
const submittedSnapshot = JSON.stringify(values);
|
||||||
.filter(Boolean);
|
const runner = values.runner || {};
|
||||||
return patterns.length > 0 ? patterns : ['*'];
|
const agent: Partial<Agent> = {
|
||||||
}
|
name: values.basic.name,
|
||||||
|
description: values.basic.description ?? '',
|
||||||
|
emoji: values.basic.emoji,
|
||||||
|
component_ref: (runner.id as string) || null,
|
||||||
|
supported_event_patterns: values.supported_event_patterns,
|
||||||
|
config: {
|
||||||
|
runner,
|
||||||
|
runner_config: values.runner_config ?? {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
isSavingRef.current = true;
|
||||||
|
onSavingChange?.(true);
|
||||||
|
try {
|
||||||
|
await httpClient.updateAgent(agentId, agent);
|
||||||
|
savedSnapshotRef.current = submittedSnapshot;
|
||||||
|
onFinish(agent);
|
||||||
|
toast.success(t('agents.saveSuccess'));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
typeof err === 'object' && err && 'msg' in err
|
||||||
|
? String((err as { msg?: string }).msg || '')
|
||||||
|
: '';
|
||||||
|
toast.error(t('agents.saveError') + message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isSavingRef.current = false;
|
||||||
|
onSavingChange?.(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[agentId, onFinish, onSavingChange, t],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSubmit(values: FormValues) {
|
function handleSubmit(values: FormValues) {
|
||||||
if (isSavingRef.current) return;
|
void saveValues(values);
|
||||||
const submittedSnapshot = JSON.stringify(values);
|
}
|
||||||
const runner = values.runner || {};
|
|
||||||
const agent: Partial<Agent> = {
|
useImperativeHandle(
|
||||||
name: values.basic.name,
|
ref,
|
||||||
description: values.basic.description ?? '',
|
() => ({
|
||||||
emoji: values.basic.emoji,
|
openSection: setActiveSection,
|
||||||
enabled: values.basic.enabled ?? true,
|
syncBasicInfo(values) {
|
||||||
component_ref: (runner.id as string) || null,
|
form.setValue('basic', {
|
||||||
supported_event_patterns: normalizeEventPatterns(
|
...form.getValues('basic'),
|
||||||
values.supported_event_patterns_text,
|
name: values.name,
|
||||||
),
|
description: values.description,
|
||||||
config: {
|
emoji: values.emoji || '🤖',
|
||||||
runner,
|
});
|
||||||
runner_config: values.runner_config ?? {},
|
if (savedSnapshotRef.current) {
|
||||||
|
const snapshot = JSON.parse(savedSnapshotRef.current) as FormValues;
|
||||||
|
snapshot.basic = {
|
||||||
|
...snapshot.basic,
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
emoji: values.emoji || '🤖',
|
||||||
|
};
|
||||||
|
savedSnapshotRef.current = JSON.stringify(snapshot);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
async save() {
|
||||||
|
if (!hasUnsavedChangesRef.current) return true;
|
||||||
isSavingRef.current = true;
|
if (isSavingRef.current) return false;
|
||||||
setIsSaving(true);
|
const valid = await form.trigger();
|
||||||
onSavingChange?.(true);
|
if (!valid) return false;
|
||||||
httpClient
|
return (await saveValues(form.getValues())) ?? false;
|
||||||
.updateAgent(agentId, agent)
|
},
|
||||||
.then(() => {
|
}),
|
||||||
savedSnapshotRef.current = submittedSnapshot;
|
[form, saveValues],
|
||||||
onFinish();
|
);
|
||||||
toast.success(t('agents.saveSuccess'));
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(t('agents.saveError') + err.msg);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
isSavingRef.current = false;
|
|
||||||
setIsSaving(false);
|
|
||||||
onSavingChange?.(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDelete() {
|
|
||||||
httpClient
|
|
||||||
.deleteAgent(agentId)
|
|
||||||
.then(() => {
|
|
||||||
toast.success(t('agents.deleteSuccess'));
|
|
||||||
setShowDeleteConfirm(false);
|
|
||||||
onDeleted();
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(t('agents.deleteError') + err.msg);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="h-full p-0 flex flex-col">
|
||||||
<div className="h-full p-0 flex flex-col">
|
<Form {...form}>
|
||||||
<Form {...form}>
|
<form
|
||||||
<form
|
id="agent-form"
|
||||||
id="agent-form"
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
onSubmit={form.handleSubmit(handleSubmit)}
|
className="mb-2 flex h-full min-h-0 min-w-0 flex-1 flex-col"
|
||||||
className="mb-2 flex h-full min-h-0 min-w-0 flex-1 flex-col"
|
>
|
||||||
>
|
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
<Tabs
|
||||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
|
value={activeSection}
|
||||||
<div className="mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-6 pb-8">
|
onValueChange={(value) =>
|
||||||
{
|
setActiveSection(value as AgentConfigSection)
|
||||||
<div className="contents">
|
}
|
||||||
<Card className="order-2">
|
>
|
||||||
<CardHeader>
|
<div className="min-w-0">
|
||||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
<TabsList className="grid h-auto w-full min-w-0 grid-cols-3">
|
||||||
<CardDescription>
|
{primarySections.map((section) => {
|
||||||
{t('agents.basicInfoDescription')}
|
const Icon = section.icon;
|
||||||
</CardDescription>
|
return (
|
||||||
</CardHeader>
|
<TabsTrigger
|
||||||
<CardContent className="space-y-4">
|
key={section.name}
|
||||||
<div className="flex gap-4 items-start">
|
value={section.name}
|
||||||
<FormField
|
className="min-w-0 gap-1.5 px-2"
|
||||||
control={form.control}
|
>
|
||||||
name="basic.name"
|
<Icon className="size-4 shrink-0" />
|
||||||
render={({ field }) => (
|
<span className="truncate">{section.label}</span>
|
||||||
<FormItem className="flex-1">
|
</TabsTrigger>
|
||||||
<FormLabel>
|
);
|
||||||
{t('common.name')}
|
})}
|
||||||
<span className="text-destructive">*</span>
|
</TabsList>
|
||||||
</FormLabel>
|
</div>
|
||||||
<FormControl>
|
</Tabs>
|
||||||
<Input
|
</nav>
|
||||||
{...field}
|
|
||||||
value={field.value ?? ''}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="basic.emoji"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('common.icon')}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<EmojiPicker
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
control={form.control}
|
<div className="mx-auto w-full min-w-0 max-w-5xl space-y-6 pb-8">
|
||||||
name="basic.description"
|
{activeSection === 'runner' && (
|
||||||
render={({ field }) => (
|
<div className="space-y-6">
|
||||||
<FormItem>
|
{runnerSelectorStage
|
||||||
<FormLabel>{t('common.description')}</FormLabel>
|
? renderDynamicStage(runnerSelectorStage)
|
||||||
<FormControl>
|
: !runnerConfigSchema && (
|
||||||
<Input {...field} value={field.value ?? ''} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="basic.enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel className="flex items-center gap-2">
|
|
||||||
<Power className="size-4" />
|
|
||||||
{t('agents.enabled')}
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
{t('agents.enabledDescription')}
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value ?? true}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="order-4 border-destructive/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-destructive">
|
|
||||||
{t('agents.dangerZone')}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t('agents.dangerZoneDescription')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{t('agents.deleteAgentAction')}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('agents.deleteAgentHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={isSaving}
|
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 mr-1.5" />
|
|
||||||
{t('common.delete')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
<div className="order-1 space-y-6">
|
|
||||||
{renderRunnerStatus()}
|
|
||||||
{runnerConfigSchema?.stages.map((stage) =>
|
|
||||||
renderDynamicStage(stage),
|
|
||||||
)}
|
|
||||||
{!runnerConfigSchema && (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
|
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
|
||||||
@@ -575,69 +556,61 @@ export default function AgentFormComponent({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
|
|
||||||
{
|
{activeSection === 'runner_config' && (
|
||||||
<Card className="order-3">
|
<div className="space-y-6">
|
||||||
|
{activeRunnerStage ? (
|
||||||
|
renderDynamicStage(activeRunnerStage)
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
|
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t('agents.bindableEventsDescription')}
|
{t('agents.noRunnerMetadata')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="supported_event_patterns_text"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t('agents.supportedEvents')}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
{...field}
|
|
||||||
className="min-h-32 font-mono text-sm"
|
|
||||||
placeholder={'*\nmessage.received\ngroup.*'}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t('agents.supportedEventsDescription')}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
{activeSection === 'events' && (
|
||||||
<DialogContent>
|
<Card>
|
||||||
<DialogHeader>
|
<CardHeader>
|
||||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
|
||||||
</DialogHeader>
|
<CardDescription>
|
||||||
<div className="py-4">{t('agents.deleteConfirmation')}</div>
|
{t('agents.bindableEventsDescription')}
|
||||||
<DialogFooter>
|
</CardDescription>
|
||||||
<Button
|
</CardHeader>
|
||||||
variant="outline"
|
<CardContent>
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
<FormField
|
||||||
>
|
control={form.control}
|
||||||
{t('common.cancel')}
|
name="supported_event_patterns"
|
||||||
</Button>
|
render={({ field }) => (
|
||||||
<Button variant="destructive" onClick={confirmDelete}>
|
<FormItem>
|
||||||
{t('common.confirmDelete')}
|
<AgentEventPatternPicker
|
||||||
</Button>
|
events={availableEventTypes}
|
||||||
</DialogFooter>
|
value={field.value}
|
||||||
</DialogContent>
|
onChange={field.onChange}
|
||||||
</Dialog>
|
/>
|
||||||
</>
|
<FormDescription>
|
||||||
|
{t('agents.supportedEventsDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default forwardRef(AgentFormComponent);
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import { useId } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { AgentKind } from '@/app/infra/entities/api';
|
||||||
|
|
||||||
|
const LANGBOT_BLUE = '#2288ee';
|
||||||
|
const LANGBOT_CYAN = '#19b8c9';
|
||||||
|
|
||||||
|
function DiagramMotionStyles() {
|
||||||
|
return (
|
||||||
|
<style>{`
|
||||||
|
@keyframes processor-line-flow {
|
||||||
|
to { stroke-dashoffset: -30; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes processor-link-breathe {
|
||||||
|
0%, 100% { stroke-opacity: var(--processor-relation-opacity-min); }
|
||||||
|
50% { stroke-opacity: var(--processor-relation-opacity-max); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-diagram {
|
||||||
|
--processor-blue-node-fill: color-mix(in srgb, ${LANGBOT_BLUE} 7%, var(--card));
|
||||||
|
--processor-cyan-node-fill: color-mix(in srgb, ${LANGBOT_CYAN} 7%, var(--card));
|
||||||
|
--processor-node-stroke-opacity: 0.18;
|
||||||
|
--processor-flow-opacity: 0.34;
|
||||||
|
--processor-relation-opacity-min: 0.28;
|
||||||
|
--processor-relation-opacity-max: 0.72;
|
||||||
|
--processor-capability-fill: color-mix(in srgb, ${LANGBOT_CYAN} 4%, var(--card));
|
||||||
|
--processor-capability-stroke-opacity: 0.45;
|
||||||
|
--processor-icon-fill: color-mix(in srgb, ${LANGBOT_CYAN} 10%, var(--card));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .processor-diagram {
|
||||||
|
--processor-blue-node-fill: color-mix(in srgb, ${LANGBOT_BLUE} 17%, var(--card));
|
||||||
|
--processor-cyan-node-fill: color-mix(in srgb, ${LANGBOT_CYAN} 15%, var(--card));
|
||||||
|
--processor-node-stroke-opacity: 0.5;
|
||||||
|
--processor-flow-opacity: 0.72;
|
||||||
|
--processor-relation-opacity-min: 0.58;
|
||||||
|
--processor-relation-opacity-max: 1;
|
||||||
|
--processor-capability-fill: color-mix(in srgb, ${LANGBOT_CYAN} 10%, var(--card));
|
||||||
|
--processor-capability-stroke-opacity: 0.78;
|
||||||
|
--processor-icon-fill: color-mix(in srgb, ${LANGBOT_CYAN} 20%, var(--card));
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-line-flow {
|
||||||
|
animation: processor-line-flow 1.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-link-breathe {
|
||||||
|
animation: processor-link-breathe 2.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.processor-line-flow,
|
||||||
|
.processor-link-breathe {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArrowMarker({ id }: { id: string }) {
|
||||||
|
return (
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id={id}
|
||||||
|
markerWidth="8"
|
||||||
|
markerHeight="8"
|
||||||
|
refX="7"
|
||||||
|
refY="4"
|
||||||
|
orient="auto"
|
||||||
|
>
|
||||||
|
<path d="M0 0 8 4 0 8Z" fill={LANGBOT_BLUE} />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapabilityIcon({
|
||||||
|
kind,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
}: {
|
||||||
|
kind: 'model' | 'tool' | 'action';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
transform={`translate(${x - 9} ${y - 9})`}
|
||||||
|
fill="none"
|
||||||
|
stroke={LANGBOT_CYAN}
|
||||||
|
strokeWidth="1.7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{kind === 'model' && (
|
||||||
|
<>
|
||||||
|
<path d="m7 1 1.4 4.1L12.5 6.5 8.4 8 7 12 5.6 8 1.5 6.5l4.1-1.4L7 1Z" />
|
||||||
|
<path d="m14.5 10 .7 2.3 2.3.7-2.3.8-.7 2.2-.8-2.2-2.2-.8 2.2-.7.8-2.3Z" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{kind === 'tool' && (
|
||||||
|
<path d="M12.8 2.1a4.1 4.1 0 0 0-5.2 5.2L2 12.9a2.1 2.1 0 1 0 3 3l5.6-5.6a4.1 4.1 0 0 0 5.2-5.2l-2.7 2.7-2.8-2.8 2.5-2.9Z" />
|
||||||
|
)}
|
||||||
|
{kind === 'action' && (
|
||||||
|
<path d="M10.5 1 3 10.5h6L8 17l7.5-9.5h-6L10.5 1Z" />
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentDiagram() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const arrowId = `agent-arrow-${useId().replace(/:/g, '')}`;
|
||||||
|
const inputs = [
|
||||||
|
{ label: t('agents.diagramMessages'), y: 155 },
|
||||||
|
{ label: t('agents.diagramMembers'), y: 275 },
|
||||||
|
{ label: t('agents.diagramFeedback'), y: 395 },
|
||||||
|
];
|
||||||
|
const outputs = [
|
||||||
|
{ kind: 'model' as const, label: t('agents.diagramModel'), y: 155 },
|
||||||
|
{ kind: 'tool' as const, label: t('agents.diagramTools'), y: 275 },
|
||||||
|
{ kind: 'action' as const, label: t('agents.diagramActions'), y: 395 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 760 620"
|
||||||
|
role="img"
|
||||||
|
aria-label={t('agents.agentDiagramTitle')}
|
||||||
|
className="processor-diagram h-full w-full"
|
||||||
|
data-testid="agent-diagram"
|
||||||
|
>
|
||||||
|
<desc>{t('agents.agentDiagramDescription')}</desc>
|
||||||
|
<ArrowMarker id={arrowId} />
|
||||||
|
<DiagramMotionStyles />
|
||||||
|
|
||||||
|
<rect width="760" height="620" fill="var(--card)" />
|
||||||
|
|
||||||
|
<text
|
||||||
|
x="74"
|
||||||
|
y="112"
|
||||||
|
fill="var(--muted-foreground)"
|
||||||
|
fontSize="13"
|
||||||
|
fontWeight="600"
|
||||||
|
>
|
||||||
|
{t('agents.diagramEvents')}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{inputs.map((item, index) => (
|
||||||
|
<g key={item.label}>
|
||||||
|
<path
|
||||||
|
d={`M222 ${item.y + 29} C286 ${item.y + 29} 292 310 324 310`}
|
||||||
|
fill="none"
|
||||||
|
stroke={LANGBOT_BLUE}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="4 11"
|
||||||
|
opacity="var(--processor-flow-opacity)"
|
||||||
|
markerEnd={`url(#${arrowId})`}
|
||||||
|
className="processor-line-flow"
|
||||||
|
data-motion="flow"
|
||||||
|
style={{ animationDelay: `${index * -0.35}s` }}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="64"
|
||||||
|
y={item.y}
|
||||||
|
width="158"
|
||||||
|
height="58"
|
||||||
|
rx="14"
|
||||||
|
fill="var(--processor-blue-node-fill)"
|
||||||
|
stroke={LANGBOT_BLUE}
|
||||||
|
strokeOpacity="var(--processor-node-stroke-opacity)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="91"
|
||||||
|
cy={item.y + 29}
|
||||||
|
r="5"
|
||||||
|
fill={index === 1 ? LANGBOT_CYAN : LANGBOT_BLUE}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="110"
|
||||||
|
y={item.y + 34}
|
||||||
|
fill="var(--foreground)"
|
||||||
|
fontSize="14"
|
||||||
|
fontWeight="550"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<rect
|
||||||
|
x="324"
|
||||||
|
y="278"
|
||||||
|
width="112"
|
||||||
|
height="64"
|
||||||
|
rx="16"
|
||||||
|
fill={LANGBOT_BLUE}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="380"
|
||||||
|
y="317"
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="var(--primary-foreground)"
|
||||||
|
fontSize="18"
|
||||||
|
fontWeight="700"
|
||||||
|
>
|
||||||
|
{t('agents.agentType')}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<text
|
||||||
|
x="538"
|
||||||
|
y="112"
|
||||||
|
fill="var(--muted-foreground)"
|
||||||
|
fontSize="13"
|
||||||
|
fontWeight="600"
|
||||||
|
>
|
||||||
|
{t('agents.diagramAgentCanUse')}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{outputs.map((item, index) => (
|
||||||
|
<g key={item.label}>
|
||||||
|
<path
|
||||||
|
d={`M436 310 C468 310 474 ${item.y + 29} 538 ${item.y + 29}`}
|
||||||
|
fill="none"
|
||||||
|
stroke={LANGBOT_CYAN}
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeDasharray="5 6"
|
||||||
|
className="processor-link-breathe"
|
||||||
|
data-motion="relation"
|
||||||
|
style={{ animationDelay: `${index * -0.45}s` }}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="538"
|
||||||
|
y={item.y}
|
||||||
|
width="158"
|
||||||
|
height="58"
|
||||||
|
rx="14"
|
||||||
|
fill="var(--processor-capability-fill)"
|
||||||
|
stroke={LANGBOT_CYAN}
|
||||||
|
strokeOpacity="var(--processor-capability-stroke-opacity)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="565"
|
||||||
|
cy={item.y + 29}
|
||||||
|
r="11"
|
||||||
|
fill="var(--processor-icon-fill)"
|
||||||
|
stroke={LANGBOT_CYAN}
|
||||||
|
strokeOpacity="var(--processor-capability-stroke-opacity)"
|
||||||
|
/>
|
||||||
|
<CapabilityIcon kind={item.kind} x={565} y={item.y + 29} />
|
||||||
|
<text
|
||||||
|
x="584"
|
||||||
|
y={item.y + 34}
|
||||||
|
fill="var(--foreground)"
|
||||||
|
fontSize="14"
|
||||||
|
fontWeight="550"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PipelineDiagram() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const arrowId = `pipeline-arrow-${useId().replace(/:/g, '')}`;
|
||||||
|
const steps = [
|
||||||
|
t('agents.diagramMessage'),
|
||||||
|
t('agents.diagramPreprocess'),
|
||||||
|
t('agents.diagramAI'),
|
||||||
|
t('agents.diagramPostprocess'),
|
||||||
|
t('agents.diagramOutput'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 760 620"
|
||||||
|
role="img"
|
||||||
|
aria-label={t('agents.pipelineDiagramTitle')}
|
||||||
|
className="processor-diagram h-full w-full"
|
||||||
|
data-testid="pipeline-diagram"
|
||||||
|
>
|
||||||
|
<desc>{t('agents.pipelineDiagramDescription')}</desc>
|
||||||
|
<ArrowMarker id={arrowId} />
|
||||||
|
<DiagramMotionStyles />
|
||||||
|
|
||||||
|
<rect width="760" height="620" fill="var(--card)" />
|
||||||
|
<text
|
||||||
|
x="40"
|
||||||
|
y="238"
|
||||||
|
fill="var(--muted-foreground)"
|
||||||
|
fontSize="13"
|
||||||
|
fontWeight="600"
|
||||||
|
>
|
||||||
|
{t('agents.pipelineDiagramFlow')}
|
||||||
|
</text>
|
||||||
|
<path
|
||||||
|
d="M156 310H738"
|
||||||
|
fill="none"
|
||||||
|
stroke={LANGBOT_BLUE}
|
||||||
|
strokeWidth="2"
|
||||||
|
opacity="var(--processor-flow-opacity)"
|
||||||
|
markerEnd={`url(#${arrowId})`}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M156 310H724"
|
||||||
|
fill="none"
|
||||||
|
stroke={LANGBOT_BLUE}
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeDasharray="4 11"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="processor-line-flow"
|
||||||
|
data-motion="flow"
|
||||||
|
data-dash-cycle="15"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const x = 40 + index * 142;
|
||||||
|
const active = index === 2;
|
||||||
|
return (
|
||||||
|
<g key={step}>
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y="281"
|
||||||
|
width="116"
|
||||||
|
height="58"
|
||||||
|
rx="14"
|
||||||
|
fill={
|
||||||
|
active
|
||||||
|
? LANGBOT_BLUE
|
||||||
|
: index === 3
|
||||||
|
? 'var(--processor-cyan-node-fill)'
|
||||||
|
: 'var(--processor-blue-node-fill)'
|
||||||
|
}
|
||||||
|
stroke={
|
||||||
|
active
|
||||||
|
? LANGBOT_BLUE
|
||||||
|
: index === 3
|
||||||
|
? LANGBOT_CYAN
|
||||||
|
: LANGBOT_BLUE
|
||||||
|
}
|
||||||
|
strokeOpacity={
|
||||||
|
active ? 1 : 'var(--processor-node-stroke-opacity)'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!active && (
|
||||||
|
<circle
|
||||||
|
cx={x + 25}
|
||||||
|
cy="310"
|
||||||
|
r="5"
|
||||||
|
fill={index === 3 ? LANGBOT_CYAN : LANGBOT_BLUE}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<text
|
||||||
|
x={active ? x + 58 : x + 44}
|
||||||
|
y="315"
|
||||||
|
textAnchor={active ? 'middle' : 'start'}
|
||||||
|
fill={active ? 'var(--primary-foreground)' : 'var(--foreground)'}
|
||||||
|
fontSize="13"
|
||||||
|
fontWeight="600"
|
||||||
|
>
|
||||||
|
{step}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProcessorTypeDiagram({ kind }: { kind: AgentKind }) {
|
||||||
|
return kind === 'agent' ? <AgentDiagram /> : <PipelineDiagram />;
|
||||||
|
}
|
||||||
@@ -19,7 +19,9 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
|
import BotForm, {
|
||||||
|
BotFormHandle,
|
||||||
|
} from '@/app/home/bots/components/bot-form/BotForm';
|
||||||
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
|
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
|
||||||
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
|
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
|
||||||
import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor';
|
import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor';
|
||||||
@@ -30,6 +32,11 @@ import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||||
|
import { Bot } from '@/app/infra/entities/api';
|
||||||
|
import EntityBasicInfoDialog, {
|
||||||
|
EntityBasicInfoValues,
|
||||||
|
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
|
||||||
|
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
|
||||||
|
|
||||||
export default function BotDetailContent({ id }: { id: string }) {
|
export default function BotDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
@@ -55,8 +62,11 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('config');
|
const [activeTab, setActiveTab] = useState('config');
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||||
|
const [bot, setBot] = useState<Bot | null>(null);
|
||||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
|
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
|
||||||
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
|
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
|
||||||
|
const botFormRef = useRef<BotFormHandle>(null);
|
||||||
|
|
||||||
// Track whether the form has unsaved changes
|
// Track whether the form has unsaved changes
|
||||||
const [formDirty, setFormDirty] = useState(false);
|
const [formDirty, setFormDirty] = useState(false);
|
||||||
@@ -69,6 +79,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCreateMode) {
|
if (!isCreateMode) {
|
||||||
httpClient.getBot(id).then((res) => {
|
httpClient.getBot(id).then((res) => {
|
||||||
|
setBot(res.bot);
|
||||||
setBotEnabled(res.bot.enable ?? true);
|
setBotEnabled(res.bot.enable ?? true);
|
||||||
setEnableLoaded(true);
|
setEnableLoaded(true);
|
||||||
});
|
});
|
||||||
@@ -80,16 +91,10 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
const prev = botEnabled;
|
const prev = botEnabled;
|
||||||
setBotEnabled(checked);
|
setBotEnabled(checked);
|
||||||
try {
|
try {
|
||||||
// Fetch current bot data to send a complete update
|
await httpClient.updateBot(id, { enable: checked });
|
||||||
const res = await httpClient.getBot(id);
|
setBot((current) =>
|
||||||
const bot = res.bot;
|
current ? { ...current, enable: checked } : current,
|
||||||
await httpClient.updateBot(id, {
|
);
|
||||||
name: bot.name,
|
|
||||||
description: bot.description,
|
|
||||||
adapter: bot.adapter,
|
|
||||||
adapter_config: bot.adapter_config,
|
|
||||||
enable: checked,
|
|
||||||
});
|
|
||||||
refreshBots();
|
refreshBots();
|
||||||
} catch {
|
} catch {
|
||||||
setBotEnabled(prev);
|
setBotEnabled(prev);
|
||||||
@@ -102,6 +107,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
function handleFormSubmit() {
|
function handleFormSubmit() {
|
||||||
// Re-sync enable state after form save (form may update enable too)
|
// Re-sync enable state after form save (form may update enable too)
|
||||||
httpClient.getBot(id).then((res) => {
|
httpClient.getBot(id).then((res) => {
|
||||||
|
setBot(res.bot);
|
||||||
setBotEnabled(res.bot.enable ?? true);
|
setBotEnabled(res.bot.enable ?? true);
|
||||||
});
|
});
|
||||||
refreshBots();
|
refreshBots();
|
||||||
@@ -117,6 +123,26 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
|
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveBasicInfo(values: EntityBasicInfoValues) {
|
||||||
|
try {
|
||||||
|
await httpClient.updateBot(id, {
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
});
|
||||||
|
setBot((current) => (current ? { ...current, ...values } : current));
|
||||||
|
botFormRef.current?.syncBasicInfo(values);
|
||||||
|
await refreshBots();
|
||||||
|
toast.success(t('bots.saveSuccess'));
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
typeof error === 'object' && error && 'msg' in error
|
||||||
|
? String((error as { msg?: string }).msg || '')
|
||||||
|
: '';
|
||||||
|
toast.error(t('bots.saveError') + message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
httpClient
|
httpClient
|
||||||
.deleteBot(id)
|
.deleteBot(id)
|
||||||
@@ -166,8 +192,15 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
<div className="flex h-full min-w-0 flex-col">
|
<div className="flex h-full min-w-0 flex-col">
|
||||||
{/* Sticky Header: title + enable switch + save button */}
|
{/* Sticky Header: title + enable switch + save button */}
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
<h1 className="text-xl font-semibold">{t('bots.editBot')}</h1>
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<h1 className="truncate text-xl font-semibold">
|
||||||
|
{bot?.name || t('bots.editBot')}
|
||||||
|
</h1>
|
||||||
|
{canManage && (
|
||||||
|
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{enableLoaded && (
|
{enableLoaded && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
@@ -255,9 +288,10 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
value="config"
|
value="config"
|
||||||
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
|
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full min-w-0 max-w-3xl space-y-6 pb-8">
|
<div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-6 pb-8">
|
||||||
<fieldset className="contents" disabled={!canManage}>
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
<BotForm
|
<BotForm
|
||||||
|
ref={botFormRef}
|
||||||
initBotId={id}
|
initBotId={id}
|
||||||
onFormSubmit={handleFormSubmit}
|
onFormSubmit={handleFormSubmit}
|
||||||
onNewBotCreated={handleNewBotCreated}
|
onNewBotCreated={handleNewBotCreated}
|
||||||
@@ -344,6 +378,17 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<EntityBasicInfoDialog
|
||||||
|
open={basicInfoOpen}
|
||||||
|
onOpenChange={setBasicInfoOpen}
|
||||||
|
values={{
|
||||||
|
name: bot?.name || '',
|
||||||
|
description: bot?.description || '',
|
||||||
|
}}
|
||||||
|
showEmoji={false}
|
||||||
|
onSave={saveBasicInfo}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
RadioTower,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { backendClient } from '@/app/infra/http';
|
||||||
|
import type { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||||
|
import {
|
||||||
|
eventPatternDescription,
|
||||||
|
eventPatternLabel,
|
||||||
|
} from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 1200;
|
||||||
|
const MAX_VISIBLE_EVENTS = 50;
|
||||||
|
|
||||||
|
interface ObservedAdapterEvent {
|
||||||
|
seqId: number;
|
||||||
|
timestamp: number;
|
||||||
|
eventType: string;
|
||||||
|
eventData: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListenerState = 'preparing' | 'listening' | 'error';
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function observedEventFromLog(log: BotLog): ObservedAdapterEvent | null {
|
||||||
|
const metadata = log.metadata;
|
||||||
|
if (!isRecord(metadata) || metadata.kind !== 'adapter_event_received') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType = metadata.event_type;
|
||||||
|
if (typeof eventType !== 'string' || !eventType) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
seqId: log.seq_id,
|
||||||
|
timestamp: log.timestamp,
|
||||||
|
eventType,
|
||||||
|
eventData: isRecord(metadata.event_data) ? metadata.event_data : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEventPreview(value: unknown, depth = 0): string | null {
|
||||||
|
if (depth > 4 || value === null || value === undefined) return null;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
const preview = findEventPreview(item, depth + 1);
|
||||||
|
if (preview) return preview;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
|
||||||
|
for (const key of ['message_text', 'text', 'action']) {
|
||||||
|
const candidate = value[key];
|
||||||
|
if (typeof candidate === 'string' && candidate.trim()) {
|
||||||
|
const trimmed = candidate.trim();
|
||||||
|
return trimmed.length > 160 ? `${trimmed.slice(0, 160)}…` : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const child of Object.values(value)) {
|
||||||
|
const preview = findEventPreview(child, depth + 1);
|
||||||
|
if (preview) return preview;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdapterEventDebugDialog({
|
||||||
|
botId,
|
||||||
|
adapterLabel,
|
||||||
|
}: {
|
||||||
|
botId?: string;
|
||||||
|
adapterLabel: string;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [listenerState, setListenerState] =
|
||||||
|
useState<ListenerState>('preparing');
|
||||||
|
const [events, setEvents] = useState<ObservedAdapterEvent[]>([]);
|
||||||
|
const baselineSeqRef = useRef<number | null>(null);
|
||||||
|
const pollInFlightRef = useRef(false);
|
||||||
|
|
||||||
|
const platformName = adapterLabel || t('bots.adapterEventCurrentPlatform');
|
||||||
|
|
||||||
|
const pollLogs = useCallback(async () => {
|
||||||
|
if (!botId || pollInFlightRef.current) return;
|
||||||
|
pollInFlightRef.current = true;
|
||||||
|
try {
|
||||||
|
const response = await backendClient.getBotLogs(botId, {
|
||||||
|
from_index: -1,
|
||||||
|
max_count: 100,
|
||||||
|
});
|
||||||
|
const latestSeq = response.logs.reduce(
|
||||||
|
(maximum, log) => Math.max(maximum, log.seq_id),
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (baselineSeqRef.current === null) {
|
||||||
|
baselineSeqRef.current = latestSeq;
|
||||||
|
setListenerState('listening');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseline = baselineSeqRef.current;
|
||||||
|
const newlyObserved = response.logs
|
||||||
|
.filter((log) => log.seq_id > baseline)
|
||||||
|
.map(observedEventFromLog)
|
||||||
|
.filter((event): event is ObservedAdapterEvent => event !== null);
|
||||||
|
|
||||||
|
if (newlyObserved.length > 0) {
|
||||||
|
setEvents((current) => {
|
||||||
|
const bySeqId = new Map(
|
||||||
|
[...newlyObserved, ...current].map((event) => [event.seqId, event]),
|
||||||
|
);
|
||||||
|
return Array.from(bySeqId.values())
|
||||||
|
.sort((left, right) => right.seqId - left.seqId)
|
||||||
|
.slice(0, MAX_VISIBLE_EVENTS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
baselineSeqRef.current = Math.max(baseline, latestSeq);
|
||||||
|
setListenerState('listening');
|
||||||
|
} catch {
|
||||||
|
setListenerState('error');
|
||||||
|
} finally {
|
||||||
|
pollInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [botId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !botId) return;
|
||||||
|
|
||||||
|
baselineSeqRef.current = null;
|
||||||
|
pollInFlightRef.current = false;
|
||||||
|
setEvents([]);
|
||||||
|
setListenerState('preparing');
|
||||||
|
void pollLogs();
|
||||||
|
const interval = window.setInterval(
|
||||||
|
() => void pollLogs(),
|
||||||
|
POLL_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [botId, open, pollLogs]);
|
||||||
|
|
||||||
|
const status = useMemo(() => {
|
||||||
|
if (listenerState === 'error') {
|
||||||
|
return {
|
||||||
|
text: t('bots.adapterEventListenerUnavailable'),
|
||||||
|
dot: 'bg-destructive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (listenerState === 'listening') {
|
||||||
|
return {
|
||||||
|
text: t('bots.adapterEventListening'),
|
||||||
|
dot: 'bg-emerald-500',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: t('bots.adapterEventPreparing'),
|
||||||
|
dot: 'bg-amber-500',
|
||||||
|
};
|
||||||
|
}, [listenerState, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!botId}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
title={!botId ? t('bots.adapterEventNeedsSavedBot') : undefined}
|
||||||
|
>
|
||||||
|
<RadioTower className="mr-1 h-4 w-4" />
|
||||||
|
{t('bots.adapterEventDebugAction')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pr-8">
|
||||||
|
<DialogTitle>{t('bots.adapterEventDebugTitle')}</DialogTitle>
|
||||||
|
<Badge variant="outline" className="gap-1.5 font-normal">
|
||||||
|
<span className={`size-2 rounded-full ${status.dot}`} />
|
||||||
|
{status.text}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('bots.adapterEventDebugDescription', {
|
||||||
|
platform: platformName,
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert className="bg-muted/30">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t('bots.adapterEventObserveOnly')}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{listenerState === 'error' && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t('bots.adapterEventLoadFailed')}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t('bots.adapterEventReceivedCount', { count: events.length })}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={events.length === 0}
|
||||||
|
onClick={() => setEvents([])}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-4 w-4" />
|
||||||
|
{t('bots.adapterEventClear')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[min(52vh,420px)] rounded-lg border">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="flex h-full min-h-64 flex-col items-center justify-center px-6 text-center">
|
||||||
|
<RadioTower className="mb-3 h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="font-medium">
|
||||||
|
{t('bots.adapterEventEmptyTitle')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||||
|
{t('bots.adapterEventEmptyDescription', {
|
||||||
|
platform: platformName,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 p-3">
|
||||||
|
{events.map((event) => {
|
||||||
|
const preview = findEventPreview(event.eventData);
|
||||||
|
return (
|
||||||
|
<Card key={event.seqId} className="gap-0 py-0">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">
|
||||||
|
{eventPatternLabel(event.eventType, t)}
|
||||||
|
</p>
|
||||||
|
<code className="mt-1 block truncate text-xs text-muted-foreground">
|
||||||
|
{event.eventType}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<time className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{new Date(
|
||||||
|
event.timestamp * 1000,
|
||||||
|
).toLocaleTimeString(i18n.language, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{preview ||
|
||||||
|
eventPatternDescription(event.eventType, t)}
|
||||||
|
</p>
|
||||||
|
<Collapsible className="mt-3">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t('bots.adapterEventData')}
|
||||||
|
<ChevronDown className="ml-1 h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed">
|
||||||
|
{JSON.stringify(event.eventData, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { IChooseAdapterEntity } from '@/app/home/bots/components/bot-form/ChooseEntity';
|
import { IChooseAdapterEntity } from '@/app/home/bots/components/bot-form/ChooseEntity';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +22,7 @@ import { Agent, Bot } from '@/app/infra/entities/api';
|
|||||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||||
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
|
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import EventBindingsEditor from './EventBindingsEditor';
|
import EventBindingsEditor from './EventBindingsEditor';
|
||||||
|
import AdapterEventDebugDialog from './AdapterEventDebugDialog';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -79,17 +87,21 @@ const getFormSchema = (t: (key: string) => string) =>
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function BotForm({
|
export interface BotFormHandle {
|
||||||
initBotId,
|
syncBasicInfo: (values: { name: string; description: string }) => void;
|
||||||
onFormSubmit,
|
}
|
||||||
onNewBotCreated,
|
|
||||||
onDirtyChange,
|
interface BotFormProps {
|
||||||
}: {
|
|
||||||
initBotId?: string;
|
initBotId?: string;
|
||||||
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
|
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
|
||||||
onNewBotCreated: (botId: string) => void;
|
onNewBotCreated: (botId: string) => void;
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||||
|
{ initBotId, onFormSubmit, onNewBotCreated, onDirtyChange },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const formSchema = getFormSchema(t);
|
const formSchema = getFormSchema(t);
|
||||||
|
|
||||||
@@ -174,6 +186,19 @@ export default function BotForm({
|
|||||||
onDirtyChange?.(isDirty);
|
onDirtyChange?.(isDirty);
|
||||||
}, [isDirty, onDirtyChange]);
|
}, [isDirty, onDirtyChange]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
syncBasicInfo(values) {
|
||||||
|
form.reset(
|
||||||
|
{
|
||||||
|
...form.getValues(),
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
},
|
||||||
|
{ keepDirtyValues: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBotFormValues();
|
setBotFormValues();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -416,46 +441,47 @@ export default function BotForm({
|
|||||||
className="w-full min-w-0 max-w-full space-y-6"
|
className="w-full min-w-0 max-w-full space-y-6"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{/* Card 1: Basic Information */}
|
{!initBotId && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t('bots.basicInfoDescription')}
|
{t('bots.basicInfoDescription')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t('bots.botName')}
|
{t('bots.botName')}
|
||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">*</span>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('bots.botDescription')}</FormLabel>
|
<FormLabel>{t('bots.botDescription')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Card 2: Adapter Configuration */}
|
{/* Card 2: Adapter Configuration */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -662,6 +688,27 @@ export default function BotForm({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentAdapter && initBotId && (
|
||||||
|
<div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t('bots.adapterConfigurationTest')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t('bots.adapterConfigurationTestDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AdapterEventDebugDialog
|
||||||
|
botId={initBotId}
|
||||||
|
adapterLabel={
|
||||||
|
adapterNameList.find(
|
||||||
|
(adapter) => adapter.value === currentAdapter,
|
||||||
|
)?.label ?? currentAdapter
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -688,4 +735,6 @@ export default function BotForm({
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default BotForm;
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import {
|
||||||
|
Fragment,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { UseFormReturn } from 'react-hook-form';
|
import { UseFormReturn } from 'react-hook-form';
|
||||||
@@ -48,7 +55,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
@@ -61,11 +70,20 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
@@ -94,9 +112,14 @@ import {
|
|||||||
Agent,
|
Agent,
|
||||||
BotRouteDryRunResult,
|
BotRouteDryRunResult,
|
||||||
BotEventRouteStatus,
|
BotEventRouteStatus,
|
||||||
BotRouteTestResult,
|
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { backendClient } from '@/app/infra/http';
|
import { backendClient } from '@/app/infra/http';
|
||||||
|
import {
|
||||||
|
eventGroupLabel,
|
||||||
|
eventNamespaces,
|
||||||
|
groupEventPatterns,
|
||||||
|
} from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||||
|
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
|
||||||
|
|
||||||
export const PIPELINE_DISCARD = '__discard__';
|
export const PIPELINE_DISCARD = '__discard__';
|
||||||
|
|
||||||
@@ -297,20 +320,6 @@ function agentSupportsEventPattern(agent: Agent, pattern: string) {
|
|||||||
return patterns.some((p) => eventPatternCovers(p, pattern));
|
return patterns.some((p) => eventPatternCovers(p, pattern));
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventNamespaces(events: string[]) {
|
|
||||||
// Only surface a `ns.*` wildcard when the namespace actually has 2+
|
|
||||||
// concrete events — otherwise the wildcard is redundant with the single event.
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
events.forEach((e) => {
|
|
||||||
const n = e.split('.')[0];
|
|
||||||
if (n) counts.set(n, (counts.get(n) ?? 0) + 1);
|
|
||||||
});
|
|
||||||
return Array.from(counts.entries())
|
|
||||||
.filter(([, c]) => c >= 2)
|
|
||||||
.map(([n]) => `${n}.*`)
|
|
||||||
.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Localized label for an event pattern. Concrete events look up
|
// Localized label for an event pattern. Concrete events look up
|
||||||
// `bots.eventNames.<event_with_underscores>`, falling back to the raw
|
// `bots.eventNames.<event_with_underscores>`, falling back to the raw
|
||||||
// string when no translation exists (e.g. custom/unknown events).
|
// string when no translation exists (e.g. custom/unknown events).
|
||||||
@@ -766,7 +775,8 @@ function AdapterCapabilitySummary({
|
|||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const concreteEvents =
|
const concreteEvents =
|
||||||
supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS;
|
supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS;
|
||||||
const previewEvents = concreteEvents.slice(0, 4);
|
const concreteEventGroups = groupEventPatterns(concreteEvents);
|
||||||
|
const optionGroups = groupEventPatterns(eventOptions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-muted/20 p-3">
|
<div className="rounded-lg border bg-muted/20 p-3">
|
||||||
@@ -788,20 +798,22 @@ function AdapterCapabilitySummary({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{previewEvents.map((event) => (
|
{concreteEventGroups.slice(0, 4).map((group) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={event}
|
key={group.namespace}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="max-w-full rounded-md px-2 py-0.5 font-normal"
|
className="max-w-full rounded-md px-2 py-0.5 font-normal"
|
||||||
title={event}
|
|
||||||
>
|
>
|
||||||
<span className="truncate">{eventLabel(event, t)}</span>
|
<span className="truncate">
|
||||||
|
{eventGroupLabel(group.namespace, t)} ·{' '}
|
||||||
|
{group.patterns.length}
|
||||||
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{concreteEvents.length > previewEvents.length && (
|
{concreteEventGroups.length > 4 && (
|
||||||
<Badge variant="outline" className="rounded-md px-2 py-0.5">
|
<Badge variant="outline" className="rounded-md px-2 py-0.5">
|
||||||
{t('bots.adapterEventsMore', {
|
{t('bots.adapterEventsMore', {
|
||||||
count: concreteEvents.length - previewEvents.length,
|
count: concreteEventGroups.length - 4,
|
||||||
})}
|
})}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -823,25 +835,40 @@ function AdapterCapabilitySummary({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{advancedOpen && (
|
{advancedOpen && (
|
||||||
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
|
<div className="mt-3 space-y-4 border-t pt-3">
|
||||||
{eventOptions.map((event) => (
|
{optionGroups.map((group) => (
|
||||||
<div key={event} className="min-w-0 rounded-md bg-background p-2">
|
<div key={group.namespace} className="space-y-2">
|
||||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
<span className="truncate text-xs font-medium">
|
{eventGroupLabel(group.namespace, t)}
|
||||||
{eventLabel(event, t)}
|
|
||||||
</span>
|
|
||||||
{event.endsWith('.*') && (
|
|
||||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
|
||||||
{t('bots.eventGroup')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
|
|
||||||
{eventDescription(event, t)}
|
|
||||||
</p>
|
</p>
|
||||||
<code className="mt-1 block truncate text-[11px] text-muted-foreground">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{event}
|
{group.patterns.map((event) => (
|
||||||
</code>
|
<div
|
||||||
|
key={event}
|
||||||
|
className="min-w-0 rounded-md bg-background p-2"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-xs font-medium">
|
||||||
|
{eventLabel(event, t)}
|
||||||
|
</span>
|
||||||
|
{event.endsWith('.*') && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 text-[10px]"
|
||||||
|
>
|
||||||
|
{t('bots.eventGroup')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
|
||||||
|
{eventDescription(event, t)}
|
||||||
|
</p>
|
||||||
|
<code className="mt-1 block truncate text-[11px] text-muted-foreground">
|
||||||
|
{event}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -857,13 +884,11 @@ function RouteDryRunDialog({
|
|||||||
bindings,
|
bindings,
|
||||||
eventOptions,
|
eventOptions,
|
||||||
agentOptions,
|
agentOptions,
|
||||||
onRouteStatusUpdate,
|
|
||||||
}: {
|
}: {
|
||||||
botId?: string;
|
botId?: string;
|
||||||
bindings: EventBinding[];
|
bindings: EventBinding[];
|
||||||
eventOptions: string[];
|
eventOptions: string[];
|
||||||
agentOptions: Agent[];
|
agentOptions: Agent[];
|
||||||
onRouteStatusUpdate?: (statuses: BotEventRouteStatus[]) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0];
|
const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0];
|
||||||
@@ -874,12 +899,9 @@ function RouteDryRunDialog({
|
|||||||
);
|
);
|
||||||
const [advancedPayloadOpen, setAdvancedPayloadOpen] = useState(false);
|
const [advancedPayloadOpen, setAdvancedPayloadOpen] = useState(false);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [isDispatching, setIsDispatching] = useState(false);
|
|
||||||
const [payloadError, setPayloadError] = useState<string | null>(null);
|
const [payloadError, setPayloadError] = useState<string | null>(null);
|
||||||
const [runError, setRunError] = useState<string | null>(null);
|
const [runError, setRunError] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<BotRouteDryRunResult | null>(null);
|
const [result, setResult] = useState<BotRouteDryRunResult | null>(null);
|
||||||
const [dispatchResult, setDispatchResult] =
|
|
||||||
useState<BotRouteTestResult | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!eventOptions.includes(eventType)) {
|
if (!eventOptions.includes(eventType)) {
|
||||||
@@ -891,7 +913,6 @@ function RouteDryRunDialog({
|
|||||||
setPayloadText(JSON.stringify(samplePayloadForEvent(eventType), null, 2));
|
setPayloadText(JSON.stringify(samplePayloadForEvent(eventType), null, 2));
|
||||||
setPayloadError(null);
|
setPayloadError(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setDispatchResult(null);
|
|
||||||
}, [eventType]);
|
}, [eventType]);
|
||||||
|
|
||||||
function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) {
|
function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) {
|
||||||
@@ -928,7 +949,6 @@ function RouteDryRunDialog({
|
|||||||
async function runDryRun() {
|
async function runDryRun() {
|
||||||
setRunError(null);
|
setRunError(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setDispatchResult(null);
|
|
||||||
|
|
||||||
const payload = parsePayload();
|
const payload = parsePayload();
|
||||||
if (payload === null) return;
|
if (payload === null) return;
|
||||||
@@ -957,43 +977,6 @@ function RouteDryRunDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchTestEvent() {
|
|
||||||
setRunError(null);
|
|
||||||
setDispatchResult(null);
|
|
||||||
|
|
||||||
const payload = parsePayload();
|
|
||||||
if (payload === null) return;
|
|
||||||
|
|
||||||
if (!botId) {
|
|
||||||
setRunError(t('bots.dryRunNeedsSavedBot'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsDispatching(true);
|
|
||||||
try {
|
|
||||||
const testResult = await backendClient.testBotEventRoute(botId, {
|
|
||||||
event_type: eventType,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
setDispatchResult(testResult);
|
|
||||||
onRouteStatusUpdate?.(testResult.route_status?.routes || []);
|
|
||||||
if (!testResult.dispatched) {
|
|
||||||
setRunError(
|
|
||||||
localizedFailureReason(
|
|
||||||
testResult.failure_code,
|
|
||||||
testResult.reason,
|
|
||||||
t,
|
|
||||||
) || t('bots.routeTestFailed'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as { msg?: string };
|
|
||||||
setRunError(err.msg || t('bots.routeTestFailed'));
|
|
||||||
} finally {
|
|
||||||
setIsDispatching(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetName = result ? resolveTargetName(result.target) : '';
|
const targetName = result ? resolveTargetName(result.target) : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1008,82 +991,88 @@ function RouteDryRunDialog({
|
|||||||
{t('bots.testRoute')}
|
{t('bots.testRoute')}
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('bots.dryRunTitle')}</DialogTitle>
|
<DialogTitle>{t('bots.dryRunTitle')}</DialogTitle>
|
||||||
<DialogDescription>{t('bots.dryRunDescription')}</DialogDescription>
|
<DialogDescription>{t('bots.dryRunDescription')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1.5">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||||
<label className="text-sm font-medium">
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
{t('bots.dryRunEventType')}
|
<label className="text-sm font-medium">
|
||||||
</label>
|
{t('bots.dryRunEventType')}
|
||||||
<Select value={eventType} onValueChange={setEventType}>
|
</label>
|
||||||
<SelectTrigger className="h-9">
|
<Select value={eventType} onValueChange={setEventType}>
|
||||||
<SelectValue />
|
<SelectTrigger
|
||||||
</SelectTrigger>
|
className="h-auto min-h-9 w-full"
|
||||||
<SelectContent>
|
aria-label={t('bots.dryRunEventType')}
|
||||||
{eventOptions.map((event) => (
|
>
|
||||||
<SelectItem key={event} value={event}>
|
<SelectValue />
|
||||||
{eventLabel(event, t)}
|
</SelectTrigger>
|
||||||
</SelectItem>
|
<SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
|
||||||
))}
|
{groupEventPatterns(eventOptions).map((group) => (
|
||||||
</SelectContent>
|
<SelectGroup key={group.namespace}>
|
||||||
</Select>
|
<SelectLabel>
|
||||||
</div>
|
{eventGroupLabel(group.namespace, t)}
|
||||||
<div className="rounded-md border bg-muted/20 px-3 py-2.5">
|
</SelectLabel>
|
||||||
<div className="flex items-start justify-between gap-3">
|
{group.patterns.map((event) => (
|
||||||
<div className="min-w-0">
|
<SelectItem
|
||||||
<p className="text-sm font-medium">
|
key={event}
|
||||||
{t('bots.dryRunSampleReady')}
|
value={event}
|
||||||
</p>
|
description={eventDescription(event, t)}
|
||||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
className="py-2"
|
||||||
{t('bots.dryRunSampleDescription', {
|
>
|
||||||
event: eventLabel(eventType, t),
|
<EventSelectOptionContent
|
||||||
})}
|
event={event}
|
||||||
</p>
|
label={eventLabel(event, t)}
|
||||||
</div>
|
/>
|
||||||
<Button
|
</SelectItem>
|
||||||
type="button"
|
))}
|
||||||
variant="ghost"
|
</SelectGroup>
|
||||||
size="sm"
|
))}
|
||||||
className="h-8 shrink-0 px-2 text-xs"
|
</SelectContent>
|
||||||
onClick={() => setAdvancedPayloadOpen((value) => !value)}
|
</Select>
|
||||||
>
|
|
||||||
{advancedPayloadOpen ? (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
{advancedPayloadOpen
|
|
||||||
? t('bots.dryRunHidePayload')
|
|
||||||
: t('bots.dryRunEditPayload')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{advancedPayloadOpen && (
|
<Button
|
||||||
<div className="mt-3 space-y-1.5 border-t pt-3">
|
type="button"
|
||||||
<label className="text-xs font-medium">
|
variant="ghost"
|
||||||
{t('bots.dryRunPayload')}
|
size="sm"
|
||||||
</label>
|
className="h-9 shrink-0 self-start px-2 text-xs text-muted-foreground sm:self-auto"
|
||||||
<Textarea
|
onClick={() => setAdvancedPayloadOpen((value) => !value)}
|
||||||
value={payloadText}
|
>
|
||||||
onChange={(e) => setPayloadText(e.target.value)}
|
{advancedPayloadOpen ? (
|
||||||
className="min-h-[118px] font-mono text-xs"
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
spellCheck={false}
|
) : (
|
||||||
placeholder='{"message_text": "hello"}'
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
/>
|
)}
|
||||||
{payloadError ? (
|
{advancedPayloadOpen
|
||||||
<p className="text-xs text-destructive">{payloadError}</p>
|
? t('bots.dryRunHidePayload')
|
||||||
) : (
|
: t('bots.dryRunEditPayload')}
|
||||||
<p className="text-xs text-muted-foreground">
|
</Button>
|
||||||
{t('bots.dryRunPayloadHint')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{advancedPayloadOpen && (
|
||||||
|
<div className="space-y-1.5 rounded-md border bg-muted/20 p-3">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
{t('bots.dryRunPayload')}
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={payloadText}
|
||||||
|
onChange={(e) => setPayloadText(e.target.value)}
|
||||||
|
className="min-h-[110px] font-mono text-xs"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder='{"message_text": "hello"}'
|
||||||
|
/>
|
||||||
|
{payloadError ? (
|
||||||
|
<p className="text-xs text-destructive">{payloadError}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('bots.dryRunPayloadHint')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{runError && (
|
{runError && (
|
||||||
@@ -1171,24 +1160,6 @@ function RouteDryRunDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dispatchResult?.dispatched && (
|
|
||||||
<Alert>
|
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
{t('bots.routeTestDispatched', {
|
|
||||||
count: dispatchResult.suppressed_outputs?.length || 0,
|
|
||||||
})}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Alert className="border-amber-200 bg-amber-50/60 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/20 dark:text-amber-200">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
{t('bots.routeTestSideEffectWarning')}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -1199,25 +1170,10 @@ function RouteDryRunDialog({
|
|||||||
>
|
>
|
||||||
{t('common.close')}
|
{t('common.close')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="button" onClick={runDryRun} disabled={isRunning}>
|
||||||
type="button"
|
|
||||||
onClick={runDryRun}
|
|
||||||
disabled={isRunning || isDispatching}
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4 mr-1" />
|
<Play className="h-4 w-4 mr-1" />
|
||||||
{isRunning ? t('bots.dryRunRunning') : t('bots.dryRunAction')}
|
{isRunning ? t('bots.dryRunRunning') : t('bots.dryRunAction')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={dispatchTestEvent}
|
|
||||||
disabled={isRunning || isDispatching}
|
|
||||||
>
|
|
||||||
<Activity className="h-4 w-4 mr-1" />
|
|
||||||
{isDispatching
|
|
||||||
? t('bots.routeTestRunning')
|
|
||||||
: t('bots.routeTestAction')}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -1252,6 +1208,7 @@ function BindingCardContent({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
onRemove,
|
onRemove,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
|
isOverlay = false,
|
||||||
}: BindingCardProps) {
|
}: BindingCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isEnabled = binding.enabled ?? true;
|
const isEnabled = binding.enabled ?? true;
|
||||||
@@ -1263,13 +1220,21 @@ function BindingCardContent({
|
|||||||
const statusDetail = routeStatusDetail(routeStatus, t);
|
const statusDetail = routeStatusDetail(routeStatus, t);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card">
|
<div
|
||||||
|
className={`rounded-lg border bg-card ${
|
||||||
|
isOverlay ? 'pointer-events-none shadow-lg ring-1 ring-primary/20' : ''
|
||||||
|
}`}
|
||||||
|
data-drag-overlay={isOverlay ? 'true' : undefined}
|
||||||
|
>
|
||||||
{/* main row */}
|
{/* main row */}
|
||||||
<div className="flex flex-wrap items-center gap-2 p-2.5">
|
<div className="flex flex-wrap items-center gap-2 p-2.5">
|
||||||
{isEnabled && (
|
{isEnabled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground touch-none"
|
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground touch-none"
|
||||||
|
aria-label={t('bots.dragEventRoute', {
|
||||||
|
index: globalIndex + 1,
|
||||||
|
})}
|
||||||
{...dragHandleProps}
|
{...dragHandleProps}
|
||||||
>
|
>
|
||||||
<GripVertical className="h-4 w-4" />
|
<GripVertical className="h-4 w-4" />
|
||||||
@@ -1300,29 +1265,28 @@ function BindingCardContent({
|
|||||||
onUpdate(globalIndex, patch);
|
onUpdate(globalIndex, patch);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 min-w-[150px] flex-1 text-sm">
|
<SelectTrigger className="h-auto min-h-9 min-w-[220px] flex-1">
|
||||||
{binding.event_pattern ? (
|
<SelectValue placeholder={t('bots.eventPatternPlaceholder')} />
|
||||||
<span className="truncate">
|
|
||||||
{eventLabel(binding.event_pattern, t)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<SelectValue placeholder={t('bots.eventPatternPlaceholder')} />
|
|
||||||
)}
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
|
||||||
{eventOptions.map((event) => {
|
{groupEventPatterns(eventOptions).map((group) => (
|
||||||
const label = eventLabel(event, t);
|
<SelectGroup key={group.namespace}>
|
||||||
return (
|
<SelectLabel>{eventGroupLabel(group.namespace, t)}</SelectLabel>
|
||||||
<SelectItem key={event} value={event}>
|
{group.patterns.map((event) => (
|
||||||
<span className="flex flex-col">
|
<SelectItem
|
||||||
<span>{label}</span>
|
key={event}
|
||||||
<span className="text-[11px] text-muted-foreground">
|
value={event}
|
||||||
{eventDescription(event, t)}
|
description={eventDescription(event, t)}
|
||||||
</span>
|
className="py-2"
|
||||||
</span>
|
>
|
||||||
</SelectItem>
|
<EventSelectOptionContent
|
||||||
);
|
event={event}
|
||||||
})}
|
label={eventLabel(event, t)}
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -1421,15 +1385,32 @@ function BindingCardContent({
|
|||||||
|
|
||||||
// ── sortable wrapper ──────────────────────────────────────────────────────────
|
// ── sortable wrapper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SortableBindingCard(props: BindingCardProps) {
|
interface SortableBindingCardProps extends BindingCardProps {
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
sortableId: string;
|
||||||
useSortable({ id: props.binding.id ?? props.globalIndex });
|
}
|
||||||
|
|
||||||
|
function SortableBindingCard({
|
||||||
|
sortableId,
|
||||||
|
...props
|
||||||
|
}: SortableBindingCardProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: sortableId });
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
|
data-testid={`event-route-${sortableId}`}
|
||||||
style={{
|
style={{
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
opacity: isDragging ? 0.3 : undefined,
|
opacity: isDragging ? 0.3 : undefined,
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: isDragging ? 1 : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BindingCardContent
|
<BindingCardContent
|
||||||
@@ -1503,6 +1484,14 @@ export default function EventBindingsEditor({
|
|||||||
),
|
),
|
||||||
[dryRunEventOptions],
|
[dryRunEventOptions],
|
||||||
);
|
);
|
||||||
|
const otherEventGroups = useMemo(() => {
|
||||||
|
const commonEventTypes = new Set(
|
||||||
|
behaviorPresets.map((preset) => preset.eventType),
|
||||||
|
);
|
||||||
|
return groupEventPatterns(
|
||||||
|
eventOptions.filter((event) => !commonEventTypes.has(event)),
|
||||||
|
);
|
||||||
|
}, [behaviorPresets, eventOptions]);
|
||||||
|
|
||||||
const refreshRouteStatuses = useCallback(async () => {
|
const refreshRouteStatuses = useCallback(async () => {
|
||||||
if (!botId) {
|
if (!botId) {
|
||||||
@@ -1516,8 +1505,8 @@ export default function EventBindingsEditor({
|
|||||||
const response = await backendClient.getBotEventRouteStatuses(botId);
|
const response = await backendClient.getBotEventRouteStatuses(botId);
|
||||||
setRouteStatuses(response.routes || []);
|
setRouteStatuses(response.routes || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as { msg?: string };
|
console.error('Failed to refresh Bot event route status', error);
|
||||||
setRouteStatusError(err.msg || t('bots.routeStatusRefreshFailed'));
|
setRouteStatusError(t('bots.routeStatusRefreshFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setRouteStatusLoading(false);
|
setRouteStatusLoading(false);
|
||||||
}
|
}
|
||||||
@@ -1653,18 +1642,18 @@ export default function EventBindingsEditor({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Alert>
|
{catchAllRouteIndex >= 0 && (
|
||||||
<Info className="h-4 w-4" />
|
<Alert>
|
||||||
<AlertDescription>
|
<Info className="h-4 w-4" />
|
||||||
{catchAllRouteIndex >= 0
|
<AlertDescription>
|
||||||
? t('bots.routeFallbackCatchAll', {
|
{t('bots.routeFallbackCatchAll', {
|
||||||
route: t('bots.dryRunRuleIndex', {
|
route: t('bots.dryRunRuleIndex', {
|
||||||
index: catchAllRouteIndex + 1,
|
index: catchAllRouteIndex + 1,
|
||||||
}),
|
}),
|
||||||
})
|
})}
|
||||||
: t('bots.routeFallbackIgnored')}
|
</AlertDescription>
|
||||||
</AlertDescription>
|
</Alert>
|
||||||
</Alert>
|
)}
|
||||||
|
|
||||||
{/* enabled section */}
|
{/* enabled section */}
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -1672,6 +1661,7 @@ export default function EventBindingsEditor({
|
|||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={() => setActiveId(null)}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={idsRef.current}
|
items={idsRef.current}
|
||||||
@@ -1688,6 +1678,7 @@ export default function EventBindingsEditor({
|
|||||||
return (
|
return (
|
||||||
<SortableBindingCard
|
<SortableBindingCard
|
||||||
key={idsRef.current[sortIdx]}
|
key={idsRef.current[sortIdx]}
|
||||||
|
sortableId={idsRef.current[sortIdx]}
|
||||||
binding={binding}
|
binding={binding}
|
||||||
globalIndex={globalIdx}
|
globalIndex={globalIdx}
|
||||||
routeStatus={
|
routeStatus={
|
||||||
@@ -1706,7 +1697,7 @@ export default function EventBindingsEditor({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
<DragOverlay dropAnimation={null}>
|
<DragOverlay adjustScale={false} dropAnimation={null}>
|
||||||
{activeBinding && activeGlobalIdx >= 0 ? (
|
{activeBinding && activeGlobalIdx >= 0 ? (
|
||||||
<BindingCardContent
|
<BindingCardContent
|
||||||
binding={activeBinding}
|
binding={activeBinding}
|
||||||
@@ -1738,6 +1729,9 @@ export default function EventBindingsEditor({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-[300px] max-w-[90vw]">
|
<DropdownMenuContent align="start" className="w-[300px] max-w-[90vw]">
|
||||||
|
<DropdownMenuLabel className="px-2 pb-1 pt-1.5 text-xs font-normal text-muted-foreground">
|
||||||
|
{t('bots.commonScenarios')}
|
||||||
|
</DropdownMenuLabel>
|
||||||
{behaviorPresets.map((preset) => {
|
{behaviorPresets.map((preset) => {
|
||||||
const Icon = preset.icon;
|
const Icon = preset.icon;
|
||||||
return (
|
return (
|
||||||
@@ -1757,18 +1751,44 @@ export default function EventBindingsEditor({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuSub>
|
||||||
className="items-start gap-2 py-2"
|
<DropdownMenuSubTrigger
|
||||||
onClick={() => addBinding(dryRunEventOptions[0])}
|
className="items-start gap-2 py-2"
|
||||||
>
|
disabled={otherEventGroups.length === 0}
|
||||||
<Workflow className="mt-0.5 h-4 w-4 shrink-0" />
|
>
|
||||||
<span className="flex min-w-0 flex-col gap-0.5">
|
<Workflow className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
<span>{t('bots.behaviorCustom')}</span>
|
<span className="flex min-w-0 flex-col gap-0.5 pr-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span>{t('bots.behaviorCustom')}</span>
|
||||||
{t('bots.behaviorCustomDescription')}
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
|
{t('bots.behaviorCustomDescription')}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</DropdownMenuSubTrigger>
|
||||||
</DropdownMenuItem>
|
<DropdownMenuSubContent className="max-h-[min(70vh,32rem)] w-[320px] max-w-[90vw] overflow-y-auto">
|
||||||
|
{otherEventGroups.map((group, groupIndex) => (
|
||||||
|
<Fragment key={group.namespace}>
|
||||||
|
{groupIndex > 0 && <DropdownMenuSeparator />}
|
||||||
|
<DropdownMenuLabel className="px-2 py-1 text-xs font-normal text-muted-foreground">
|
||||||
|
{eventGroupLabel(group.namespace, t)}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{group.patterns.map((event) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={event}
|
||||||
|
className="items-start py-2"
|
||||||
|
onClick={() => addBinding(event)}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span>{eventLabel(event, t)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{eventDescription(event, t)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<RouteDryRunDialog
|
<RouteDryRunDialog
|
||||||
@@ -1776,24 +1796,28 @@ export default function EventBindingsEditor({
|
|||||||
bindings={bindings}
|
bindings={bindings}
|
||||||
eventOptions={dryRunEventOptions}
|
eventOptions={dryRunEventOptions}
|
||||||
agentOptions={agentOptions}
|
agentOptions={agentOptions}
|
||||||
onRouteStatusUpdate={setRouteStatuses}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Tooltip>
|
||||||
type="button"
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
onClick={refreshRouteStatuses}
|
variant="ghost"
|
||||||
disabled={!botId || routeStatusLoading}
|
size="icon"
|
||||||
>
|
className={`size-8 ${routeStatusError ? 'text-destructive' : 'text-muted-foreground'}`}
|
||||||
<RefreshCw
|
aria-label={t('bots.refreshRouteStatus')}
|
||||||
className={`h-4 w-4 mr-1 ${routeStatusLoading ? 'animate-spin' : ''}`}
|
onClick={refreshRouteStatuses}
|
||||||
/>
|
disabled={!botId || routeStatusLoading}
|
||||||
{t('bots.refreshRouteStatus')}
|
>
|
||||||
</Button>
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${routeStatusLoading ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{routeStatusError || t('bots.refreshRouteStatus')}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{routeStatusError && (
|
|
||||||
<p className="text-xs text-destructive">{routeStatusError}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* disabled section */}
|
{/* disabled section */}
|
||||||
{disabledBindings.length > 0 && (
|
{disabledBindings.length > 0 && (
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
export interface EntityBasicInfoValues {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
emoji?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EntityBasicInfoDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
values: EntityBasicInfoValues;
|
||||||
|
defaultEmoji?: string;
|
||||||
|
showEmoji?: boolean;
|
||||||
|
onSave: (values: EntityBasicInfoValues) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntityBasicInfoDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
values,
|
||||||
|
defaultEmoji,
|
||||||
|
showEmoji = true,
|
||||||
|
onSave,
|
||||||
|
}: EntityBasicInfoDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [draft, setDraft] = useState(values);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [nameError, setNameError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setDraft({
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
emoji: values.emoji || defaultEmoji,
|
||||||
|
});
|
||||||
|
setNameError(false);
|
||||||
|
}, [defaultEmoji, open, values.description, values.emoji, values.name]);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = draft.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
setNameError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
name,
|
||||||
|
description: draft.description.trim(),
|
||||||
|
emoji: showEmoji ? draft.emoji || defaultEmoji : undefined,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch {
|
||||||
|
// The caller presents the entity-specific error message.
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('common.editBasicInfo')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
showEmoji
|
||||||
|
? 'common.editBasicInfoDescription'
|
||||||
|
: 'common.editBasicInfoDescriptionNoIcon',
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-5">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<Label htmlFor="entity-basic-name">{t('common.name')}</Label>
|
||||||
|
<Input
|
||||||
|
id="entity-basic-name"
|
||||||
|
value={draft.name}
|
||||||
|
aria-invalid={nameError}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
name: event.target.value,
|
||||||
|
}));
|
||||||
|
if (event.target.value.trim()) setNameError(false);
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{nameError && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{t('common.fieldRequired')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEmoji && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('common.icon')}</Label>
|
||||||
|
<EmojiPicker
|
||||||
|
value={draft.emoji || defaultEmoji}
|
||||||
|
onChange={(emoji) =>
|
||||||
|
setDraft((current) => ({ ...current, emoji }))
|
||||||
|
}
|
||||||
|
ariaLabel={t('common.icon')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="entity-basic-description">
|
||||||
|
{t('common.description')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="entity-basic-description"
|
||||||
|
value={draft.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
description: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSaving}>
|
||||||
|
{isSaving ? t('common.saving') : t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Pencil } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
export default function EntityTitleEditButton({
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 shrink-0 text-muted-foreground"
|
||||||
|
aria-label={t('common.editBasicInfo')}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t('common.editBasicInfo')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
interface EventSelectOptionContentProps {
|
||||||
|
event: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventSelectOptionContent({
|
||||||
|
event,
|
||||||
|
label,
|
||||||
|
}: EventSelectOptionContentProps) {
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate font-medium">{label}</span>
|
||||||
|
<code className="shrink-0 rounded-sm bg-muted px-1 py-0.5 font-mono text-[10px] font-normal text-muted-foreground">
|
||||||
|
{event}
|
||||||
|
</code>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
|
export interface EventPatternGroup {
|
||||||
|
namespace: string;
|
||||||
|
patterns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventPatternNamespace(pattern: string) {
|
||||||
|
if (pattern === '*') return '*';
|
||||||
|
return pattern.split('.')[0] || pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventNamespaces(events: string[]) {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
events.forEach((event) => {
|
||||||
|
if (event === '*' || event.endsWith('.*')) return;
|
||||||
|
const namespace = eventPatternNamespace(event);
|
||||||
|
counts.set(namespace, (counts.get(namespace) ?? 0) + 1);
|
||||||
|
});
|
||||||
|
return Array.from(counts.entries())
|
||||||
|
.filter(([, count]) => count >= 2)
|
||||||
|
.map(([namespace]) => `${namespace}.*`)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupEventPatterns(patterns: string[]): EventPatternGroup[] {
|
||||||
|
const groups = new Map<string, string[]>();
|
||||||
|
patterns.forEach((pattern) => {
|
||||||
|
const namespace = eventPatternNamespace(pattern);
|
||||||
|
const group = groups.get(namespace) ?? [];
|
||||||
|
if (!group.includes(pattern)) group.push(pattern);
|
||||||
|
groups.set(namespace, group);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(groups.entries())
|
||||||
|
.sort(([left], [right]) => {
|
||||||
|
if (left === '*') return -1;
|
||||||
|
if (right === '*') return 1;
|
||||||
|
return left.localeCompare(right);
|
||||||
|
})
|
||||||
|
.map(([namespace, groupPatterns]) => ({
|
||||||
|
namespace,
|
||||||
|
patterns: groupPatterns.sort((left, right) => {
|
||||||
|
const leftWildcard = left.endsWith('.*');
|
||||||
|
const rightWildcard = right.endsWith('.*');
|
||||||
|
if (leftWildcard !== rightWildcard) return leftWildcard ? -1 : 1;
|
||||||
|
return left.localeCompare(right);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventGroupLabel(namespace: string, t: TFunction) {
|
||||||
|
if (namespace === '*') return t('bots.eventWildcard');
|
||||||
|
const key = `bots.eventGroupNames.${namespace}`;
|
||||||
|
const label = t(key);
|
||||||
|
return label === key ? namespace : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventPatternLabel(pattern: string, t: TFunction) {
|
||||||
|
if (pattern === '*') return t('bots.eventWildcard');
|
||||||
|
if (pattern.endsWith('.*')) {
|
||||||
|
return t('bots.eventNamespaceWildcard', {
|
||||||
|
namespace: pattern.replace('.*', ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const key = `bots.eventNames.${pattern.replace(/\./g, '_')}`;
|
||||||
|
const label = t(key);
|
||||||
|
return label === key ? pattern : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventPatternDescription(pattern: string, t: TFunction) {
|
||||||
|
if (pattern === '*') return t('bots.eventDescriptions.all');
|
||||||
|
if (pattern.endsWith('.*')) {
|
||||||
|
return t('bots.eventDescriptions.namespace');
|
||||||
|
}
|
||||||
|
const key = `bots.eventDescriptions.${pattern.replace(/\./g, '_')}`;
|
||||||
|
const description = t(key);
|
||||||
|
return description === key ? t('bots.eventDescriptions.custom') : description;
|
||||||
|
}
|
||||||
@@ -1752,6 +1752,17 @@ function findSidebarChildForPath(pathname: string): SidebarChildVO | undefined {
|
|||||||
);
|
);
|
||||||
if (matchedChild) return matchedChild;
|
if (matchedChild) return matchedChild;
|
||||||
|
|
||||||
|
// Keep the legacy Pipeline URL usable after Pipelines and Agents were
|
||||||
|
// unified under the Processors section.
|
||||||
|
if (
|
||||||
|
pathname === '/home/pipelines' ||
|
||||||
|
pathname.startsWith('/home/pipelines/')
|
||||||
|
) {
|
||||||
|
return sidebarConfigList.find(
|
||||||
|
(childConfig) => childConfig.id === 'pipelines',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
pathname === '/home/mcp' ||
|
pathname === '/home/mcp' ||
|
||||||
pathname === '/home/skills' ||
|
pathname === '/home/skills' ||
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { ReactNode, useState } from 'react';
|
||||||
|
import { BarChart3, Bug, Settings } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ProcessorMonitoringView {
|
||||||
|
label: string;
|
||||||
|
workbenchLabel: string;
|
||||||
|
content: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessorDetailStatus {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
tone: 'neutral' | 'success' | 'warning' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessorDetailWorkbenchProps {
|
||||||
|
title: string;
|
||||||
|
titleAction?: ReactNode;
|
||||||
|
headerActions?: ReactNode;
|
||||||
|
status?: ProcessorDetailStatus | null;
|
||||||
|
saveLabel: string;
|
||||||
|
saveFormId: string;
|
||||||
|
canSave: boolean;
|
||||||
|
isDirty: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
configTitle: string;
|
||||||
|
configContent: ReactNode;
|
||||||
|
debugTitle?: string;
|
||||||
|
debugContent?: ReactNode;
|
||||||
|
debugConnected?: boolean;
|
||||||
|
debugConnectedLabel?: string;
|
||||||
|
debugDisconnectedLabel?: string;
|
||||||
|
unsavedLabel?: string;
|
||||||
|
monitoring?: ProcessorMonitoringView;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProcessorDetailWorkbench({
|
||||||
|
title,
|
||||||
|
titleAction,
|
||||||
|
headerActions,
|
||||||
|
status,
|
||||||
|
saveLabel,
|
||||||
|
saveFormId,
|
||||||
|
canSave,
|
||||||
|
isDirty,
|
||||||
|
isSaving,
|
||||||
|
configTitle,
|
||||||
|
configContent,
|
||||||
|
debugTitle,
|
||||||
|
debugContent,
|
||||||
|
debugConnected,
|
||||||
|
debugConnectedLabel,
|
||||||
|
debugDisconnectedLabel,
|
||||||
|
unsavedLabel,
|
||||||
|
monitoring,
|
||||||
|
}: ProcessorDetailWorkbenchProps) {
|
||||||
|
const [activeView, setActiveView] = useState<'workbench' | 'monitoring'>(
|
||||||
|
'workbench',
|
||||||
|
);
|
||||||
|
const hasDebug = Boolean(debugTitle && debugContent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
value={activeView}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setActiveView(value as 'workbench' | 'monitoring')
|
||||||
|
}
|
||||||
|
className="flex h-full min-h-0 min-w-0 flex-col gap-0"
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<h1 className="truncate text-xl font-semibold">{title}</h1>
|
||||||
|
{titleAction}
|
||||||
|
{monitoring && (
|
||||||
|
<TabsList
|
||||||
|
aria-label={`${monitoring.workbenchLabel} / ${monitoring.label}`}
|
||||||
|
className="ml-1"
|
||||||
|
>
|
||||||
|
<TabsTrigger value="workbench" className="gap-1.5 px-3">
|
||||||
|
<Settings className="size-4" />
|
||||||
|
{monitoring.workbenchLabel}
|
||||||
|
{isDirty && (
|
||||||
|
<span className="size-1.5 rounded-full bg-amber-500">
|
||||||
|
<span className="sr-only">{unsavedLabel}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="monitoring" className="gap-1.5 px-3">
|
||||||
|
<BarChart3 className="size-4" />
|
||||||
|
{monitoring.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
)}
|
||||||
|
{status && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
role="status"
|
||||||
|
aria-label={status.label}
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full',
|
||||||
|
status.tone === 'success' &&
|
||||||
|
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
|
||||||
|
status.tone === 'warning' &&
|
||||||
|
'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',
|
||||||
|
status.tone === 'error' &&
|
||||||
|
'border-destructive/30 bg-destructive/10 text-destructive',
|
||||||
|
status.tone === 'neutral' &&
|
||||||
|
'border-border bg-muted/50 text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'size-1.5 rounded-full',
|
||||||
|
status.tone === 'success' && 'bg-emerald-500',
|
||||||
|
status.tone === 'warning' && 'bg-amber-500',
|
||||||
|
status.tone === 'error' && 'bg-destructive',
|
||||||
|
status.tone === 'neutral' &&
|
||||||
|
'animate-pulse bg-muted-foreground',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="max-w-72">
|
||||||
|
<p className="font-medium">{status.label}</p>
|
||||||
|
{status.description && (
|
||||||
|
<p className="mt-1 font-normal opacity-80">
|
||||||
|
{status.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{canSave && activeView === 'workbench' && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form={saveFormId}
|
||||||
|
disabled={!isDirty || isSaving}
|
||||||
|
>
|
||||||
|
{saveLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{activeView === 'workbench' && headerActions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{monitoring && (
|
||||||
|
<TabsContent
|
||||||
|
value="monitoring"
|
||||||
|
className="mt-0 min-h-0 flex-1 overflow-hidden"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
aria-label={monitoring.label}
|
||||||
|
className="h-full min-h-0 overflow-y-auto rounded-xl border bg-card p-4"
|
||||||
|
>
|
||||||
|
{monitoring.content}
|
||||||
|
</section>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="workbench"
|
||||||
|
className="mt-0 min-h-0 flex-1 overflow-y-auto lg:overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid min-h-0 gap-3 lg:h-full',
|
||||||
|
hasDebug
|
||||||
|
? 'lg:grid-cols-[minmax(20rem,0.72fr)_minmax(0,1.28fr)]'
|
||||||
|
: 'grid-cols-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasDebug && (
|
||||||
|
<section
|
||||||
|
aria-label={debugTitle}
|
||||||
|
className="flex min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-2 font-medium">
|
||||||
|
<Bug className="size-4 shrink-0" />
|
||||||
|
<span className="truncate">{debugTitle}</span>
|
||||||
|
</div>
|
||||||
|
{debugConnected !== undefined && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'size-2 rounded-full',
|
||||||
|
debugConnected ? 'bg-emerald-500' : 'bg-destructive',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{debugConnected
|
||||||
|
? debugConnectedLabel
|
||||||
|
: debugDisconnectedLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||||
|
{debugContent}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-label={configTitle}
|
||||||
|
className="flex min-h-[36rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4 font-medium">
|
||||||
|
<Settings className="size-4" />
|
||||||
|
<span className="truncate">{configTitle}</span>
|
||||||
|
{isDirty && (
|
||||||
|
<span className="ml-auto flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
<span className="size-1.5 rounded-full bg-amber-500" />
|
||||||
|
{unsavedLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
|
||||||
|
{configContent}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,10 @@ import { CustomApiError } from '@/app/infra/entities/common';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
|
import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
|
||||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||||
|
import EntityBasicInfoDialog, {
|
||||||
|
EntityBasicInfoValues,
|
||||||
|
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
|
||||||
|
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
|
||||||
|
|
||||||
export default function KBDetailContent({ id }: { id: string }) {
|
export default function KBDetailContent({ id }: { id: string }) {
|
||||||
const isCreateMode = id === 'new';
|
const isCreateMode = id === 'new';
|
||||||
@@ -52,8 +56,10 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('metadata');
|
const [activeTab, setActiveTab] = useState('metadata');
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showBasicInfoDialog, setShowBasicInfoDialog] = useState(false);
|
||||||
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
|
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
|
||||||
const [formDirty, setFormDirty] = useState(false);
|
const [formDirty, setFormDirty] = useState(false);
|
||||||
|
const [formVersion, setFormVersion] = useState(0);
|
||||||
|
|
||||||
const loadKbInfo = useCallback(
|
const loadKbInfo = useCallback(
|
||||||
async (kbId: string) => {
|
async (kbId: string) => {
|
||||||
@@ -99,6 +105,34 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
loadKbInfo(id);
|
loadKbInfo(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBasicInfoSave(values: EntityBasicInfoValues) {
|
||||||
|
if (!kbInfo) return;
|
||||||
|
|
||||||
|
const updateData: KnowledgeBase = {
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
emoji: values.emoji || '📚',
|
||||||
|
knowledge_engine_plugin_id: kbInfo.knowledge_engine_plugin_id,
|
||||||
|
creation_settings: kbInfo.creation_settings,
|
||||||
|
retrieval_settings: kbInfo.retrieval_settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await httpClient.updateKnowledgeBase(id, updateData);
|
||||||
|
setKbInfo({ ...kbInfo, ...updateData });
|
||||||
|
setDetailEntityName(values.name);
|
||||||
|
setFormDirty(false);
|
||||||
|
setFormVersion((version) => version + 1);
|
||||||
|
refreshKnowledgeBases();
|
||||||
|
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
t('knowledge.updateKnowledgeBaseFailed') + (err as CustomApiError).msg,
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
try {
|
try {
|
||||||
await httpClient.deleteKnowledgeBase(id);
|
await httpClient.deleteKnowledgeBase(id);
|
||||||
@@ -151,9 +185,18 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Sticky Header: title + save button */}
|
{/* Sticky Header: title + save button */}
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||||
<h1 className="text-xl font-semibold">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
{t('knowledge.editKnowledgeBase')}
|
<h1 className="truncate text-xl font-semibold">
|
||||||
</h1>
|
{kbInfo
|
||||||
|
? `${kbInfo.emoji || '📚'} ${kbInfo.name}`
|
||||||
|
: t('knowledge.editKnowledgeBase')}
|
||||||
|
</h1>
|
||||||
|
{canManage && kbInfo && (
|
||||||
|
<EntityTitleEditButton
|
||||||
|
onClick={() => setShowBasicInfoDialog(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -198,6 +241,7 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||||
<fieldset className="contents" disabled={!canManage}>
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
<KBForm
|
<KBForm
|
||||||
|
key={`${id}-${formVersion}`}
|
||||||
initKbId={id}
|
initKbId={id}
|
||||||
onNewKbCreated={handleNewKbCreated}
|
onNewKbCreated={handleNewKbCreated}
|
||||||
onKbUpdated={handleKbUpdated}
|
onKbUpdated={handleKbUpdated}
|
||||||
@@ -268,6 +312,20 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{kbInfo && (
|
||||||
|
<EntityBasicInfoDialog
|
||||||
|
open={showBasicInfoDialog}
|
||||||
|
onOpenChange={setShowBasicInfoDialog}
|
||||||
|
values={{
|
||||||
|
name: kbInfo.name,
|
||||||
|
description: kbInfo.description,
|
||||||
|
emoji: kbInfo.emoji,
|
||||||
|
}}
|
||||||
|
defaultEmoji="📚"
|
||||||
|
onSave={handleBasicInfoSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation dialog */}
|
{/* Delete confirmation dialog */}
|
||||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';
|
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';
|
||||||
import { CustomApiError } from '@/app/infra/entities/common';
|
import { CustomApiError } from '@/app/infra/entities/common';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -100,7 +101,7 @@ export default function KBForm({
|
|||||||
const [retrievalSettings, setRetrievalSettings] = useState<
|
const [retrievalSettings, setRetrievalSettings] = useState<
|
||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
>({});
|
>({});
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(Boolean(initKbId));
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Dirty tracking: snapshot of saved state for comparison
|
// Dirty tracking: snapshot of saved state for comparison
|
||||||
@@ -341,26 +342,59 @@ export default function KBForm({
|
|||||||
id="kb-form"
|
id="kb-form"
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
{/* Card 1: Basic Information */}
|
{/* Basic information is entered here only during creation. */}
|
||||||
<Card>
|
{!isEditing && (
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>{t('knowledge.basicInfo')}</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>
|
<CardTitle>{t('knowledge.basicInfo')}</CardTitle>
|
||||||
{t('knowledge.basicInfoDescription')}
|
<CardDescription>
|
||||||
</CardDescription>
|
{t('knowledge.basicInfoDescription')}
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent className="space-y-4">
|
</CardHeader>
|
||||||
{/* Name and Emoji in same row */}
|
<CardContent className="space-y-4">
|
||||||
<div className="flex gap-4 items-start">
|
{/* Name and Emoji in same row */}
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
{t('knowledge.kbName')}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="emoji"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('common.icon')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EmojiPicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>{t('knowledge.kbDescription')}</FormLabel>
|
||||||
{t('knowledge.kbName')}
|
|
||||||
<span className="text-destructive">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -368,40 +402,19 @@ export default function KBForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
</CardContent>
|
||||||
control={form.control}
|
</Card>
|
||||||
name="emoji"
|
)}
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('common.icon')}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<EmojiPicker
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
{/* Knowledge engine selection and settings stay together. */}
|
||||||
<FormField
|
<Card>
|
||||||
control={form.control}
|
<CardHeader>
|
||||||
name="description"
|
<CardTitle>{t('knowledge.engineSettings')}</CardTitle>
|
||||||
render={({ field }) => (
|
<CardDescription>
|
||||||
<FormItem>
|
{t('knowledge.engineSettingsDescription')}
|
||||||
<FormLabel>{t('knowledge.kbDescription')}</FormLabel>
|
</CardDescription>
|
||||||
<FormControl>
|
</CardHeader>
|
||||||
<Input {...field} />
|
<CardContent className="space-y-6">
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Knowledge Engine Selector */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="ragEngineId"
|
name="ragEngineId"
|
||||||
@@ -484,36 +497,28 @@ export default function KBForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{configFormItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<DynamicFormComponent
|
||||||
|
itemConfigList={configFormItems}
|
||||||
|
initialValues={configSettings as Record<string, object>}
|
||||||
|
onSubmit={(val) =>
|
||||||
|
setConfigSettings(val as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
isEditing={isEditing}
|
||||||
|
externalDependentValues={retrievalSettings}
|
||||||
|
onValidate={(validateFn) =>
|
||||||
|
(configValidateRef.current = validateFn)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Card 2: Engine Settings (dynamic form from creation_schema) */}
|
{/* Retrieval Settings (dynamic form from retrieval_schema) */}
|
||||||
{configFormItems.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('knowledge.engineSettings')}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t('knowledge.engineSettingsDescription')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<DynamicFormComponent
|
|
||||||
itemConfigList={configFormItems}
|
|
||||||
initialValues={configSettings as Record<string, object>}
|
|
||||||
onSubmit={(val) =>
|
|
||||||
setConfigSettings(val as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
isEditing={isEditing}
|
|
||||||
externalDependentValues={retrievalSettings}
|
|
||||||
onValidate={(validateFn) =>
|
|
||||||
(configValidateRef.current = validateFn)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Card 3: Retrieval Settings (dynamic form from retrieval_schema) */}
|
|
||||||
{retrievalFormItems.length > 0 && (
|
{retrievalFormItems.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 overflow-hidden min-w-0 px-4 pb-4 pt-0">
|
<main className="min-h-0 min-w-0 flex-1 overflow-clip px-4 pb-4 pt-0">
|
||||||
<div
|
<div
|
||||||
className={`mx-auto h-full w-full min-w-0 ${HOME_CONTENT_MAX_WIDTH}`}
|
className={`mx-auto h-full w-full min-w-0 ${HOME_CONTENT_MAX_WIDTH}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
|
import PipelineFormComponent, {
|
||||||
|
PipelineFormHandle,
|
||||||
|
} from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
|
||||||
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
|
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
|
||||||
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
|
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
|
||||||
|
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
|
||||||
|
import EntityBasicInfoDialog, {
|
||||||
|
EntityBasicInfoValues,
|
||||||
|
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
|
||||||
|
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Settings, Bug, BarChart3 } from 'lucide-react';
|
|
||||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { Pipeline } from '@/app/infra/entities/api';
|
||||||
|
|
||||||
export default function PipelineDetailContent({
|
export default function PipelineDetailContent({
|
||||||
id,
|
id,
|
||||||
@@ -40,15 +48,50 @@ export default function PipelineDetailContent({
|
|||||||
return () => setDetailEntityName(null);
|
return () => setDetailEntityName(null);
|
||||||
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('config');
|
|
||||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||||
const [formDirty, setFormDirty] = useState(false);
|
const [formDirty, setFormDirty] = useState(false);
|
||||||
const [formSaving, setFormSaving] = useState(false);
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
|
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||||
|
const [pipelineDetails, setPipelineDetails] = useState<Pipeline | null>(null);
|
||||||
|
const pipelineFormRef = useRef<PipelineFormHandle>(null);
|
||||||
|
const sidebarPipeline = pipelines.find((item) => item.id === id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCreateMode) return;
|
||||||
|
let cancelled = false;
|
||||||
|
httpClient.getPipeline(id).then((response) => {
|
||||||
|
if (!cancelled) setPipelineDetails(response.pipeline);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [id, isCreateMode]);
|
||||||
|
|
||||||
function handleFinish() {
|
function handleFinish() {
|
||||||
refreshPipelines();
|
refreshPipelines();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveBasicInfo(values: EntityBasicInfoValues) {
|
||||||
|
try {
|
||||||
|
await httpClient.updatePipeline(id, values);
|
||||||
|
setPipelineDetails((current) =>
|
||||||
|
current
|
||||||
|
? { ...current, ...values }
|
||||||
|
: ({ ...values, config: {} } as Pipeline),
|
||||||
|
);
|
||||||
|
pipelineFormRef.current?.syncBasicInfo(values);
|
||||||
|
await refreshPipelines();
|
||||||
|
toast.success(t('pipelines.saveSuccess'));
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
typeof error === 'object' && error && 'msg' in error
|
||||||
|
? String((error as { msg?: string }).msg || '')
|
||||||
|
: '';
|
||||||
|
toast.error(t('pipelines.saveError') + message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleNewPipelineCreated(newPipelineId: string) {
|
function handleNewPipelineCreated(newPipelineId: string) {
|
||||||
refreshPipelines();
|
refreshPipelines();
|
||||||
navigate(`${routeBase}?id=${encodeURIComponent(newPipelineId)}`);
|
navigate(`${routeBase}?id=${encodeURIComponent(newPipelineId)}`);
|
||||||
@@ -95,63 +138,33 @@ export default function PipelineDetailContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Edit Mode ====================
|
// ==================== Edit Mode ====================
|
||||||
|
const pipelineName =
|
||||||
|
pipelineDetails?.name ||
|
||||||
|
sidebarPipeline?.name ||
|
||||||
|
t('pipelines.editPipeline');
|
||||||
|
const pipelineEmoji =
|
||||||
|
pipelineDetails?.emoji || sidebarPipeline?.emoji || '⚙️';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<>
|
||||||
{/* Sticky Header: title + save button */}
|
<ProcessorDetailWorkbench
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
|
||||||
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
|
|
||||||
{canManage && (
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form="pipeline-form"
|
|
||||||
disabled={!formDirty || formSaving}
|
|
||||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
|
||||||
>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Horizontal Tabs */}
|
|
||||||
<Tabs
|
|
||||||
key={id}
|
key={id}
|
||||||
value={activeTab}
|
title={`${pipelineEmoji} ${pipelineName}`}
|
||||||
onValueChange={setActiveTab}
|
titleAction={
|
||||||
className="flex flex-1 flex-col min-h-0"
|
canManage ? (
|
||||||
>
|
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
|
||||||
<TabsList className="shrink-0">
|
) : undefined
|
||||||
<TabsTrigger value="config" className="gap-1.5">
|
}
|
||||||
<Settings className="size-3.5" />
|
saveLabel={t('common.save')}
|
||||||
{t('pipelines.configuration')}
|
saveFormId="pipeline-form"
|
||||||
</TabsTrigger>
|
canSave={canManage}
|
||||||
{canOperate && (
|
isDirty={formDirty}
|
||||||
<TabsTrigger value="debug" className="gap-1.5">
|
isSaving={formSaving}
|
||||||
<Bug className="size-3.5" />
|
configTitle={t('pipelines.configuration')}
|
||||||
{t('pipelines.debugChat')}
|
configContent={
|
||||||
{activeTab === 'debug' && (
|
|
||||||
<span
|
|
||||||
className={`inline-block size-2 rounded-full ${
|
|
||||||
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
{canViewMonitoring && (
|
|
||||||
<TabsTrigger value="monitoring" className="gap-1.5">
|
|
||||||
<BarChart3 className="size-3.5" />
|
|
||||||
{t('pipelines.monitoring.title')}
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Tab: Configuration */}
|
|
||||||
<TabsContent
|
|
||||||
value="config"
|
|
||||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
|
||||||
>
|
|
||||||
<fieldset className="contents" disabled={!canManage}>
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
<PipelineFormComponent
|
<PipelineFormComponent
|
||||||
|
ref={pipelineFormRef}
|
||||||
pipelineId={id}
|
pipelineId={id}
|
||||||
isEditMode={true}
|
isEditMode={true}
|
||||||
disableForm={!canManage}
|
disableForm={!canManage}
|
||||||
@@ -164,35 +177,53 @@ export default function PipelineDetailContent({
|
|||||||
onSavingChange={setFormSaving}
|
onSavingChange={setFormSaving}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</TabsContent>
|
}
|
||||||
|
debugTitle={canOperate ? t('pipelines.debugChat') : undefined}
|
||||||
{/* Tab: Debug */}
|
debugConnected={canOperate ? isWebSocketConnected : undefined}
|
||||||
{canOperate && (
|
debugConnectedLabel={t('pipelines.debugDialog.connected')}
|
||||||
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
|
debugDisconnectedLabel={t('pipelines.debugDialog.disconnected')}
|
||||||
|
debugContent={
|
||||||
|
canOperate ? (
|
||||||
<DebugDialog
|
<DebugDialog
|
||||||
open={activeTab === 'debug'}
|
open={true}
|
||||||
pipelineId={id}
|
pipelineId={id}
|
||||||
isEmbedded={true}
|
isEmbedded={true}
|
||||||
|
compact={true}
|
||||||
|
hasUnsavedChanges={formDirty}
|
||||||
|
beforeSend={async () => pipelineFormRef.current?.save() ?? false}
|
||||||
onConnectionStatusChange={setIsWebSocketConnected}
|
onConnectionStatusChange={setIsWebSocketConnected}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
) : undefined
|
||||||
)}
|
}
|
||||||
|
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||||
{/* Tab: Monitoring */}
|
monitoring={
|
||||||
{canViewMonitoring && (
|
canViewMonitoring
|
||||||
<TabsContent
|
? {
|
||||||
value="monitoring"
|
label: t('pipelines.monitoring.title'),
|
||||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
workbenchLabel: t('pipelines.monitoring.workbench'),
|
||||||
>
|
content: (
|
||||||
<PipelineMonitoringTab
|
<PipelineMonitoringTab
|
||||||
pipelineId={id}
|
pipelineId={id}
|
||||||
onNavigateToMonitoring={() => {
|
onNavigateToMonitoring={() => {
|
||||||
navigate('/home/monitoring');
|
navigate('/home/monitoring');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
),
|
||||||
)}
|
}
|
||||||
</Tabs>
|
: undefined
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
<EntityBasicInfoDialog
|
||||||
|
open={basicInfoOpen}
|
||||||
|
onOpenChange={setBasicInfoOpen}
|
||||||
|
values={{
|
||||||
|
name: pipelineName,
|
||||||
|
description: pipelineDetails?.description || '',
|
||||||
|
emoji: pipelineEmoji,
|
||||||
|
}}
|
||||||
|
defaultEmoji="⚙️"
|
||||||
|
onSave={saveBasicInfo}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { DialogContent } from '@/components/ui/dialog';
|
import { DialogContent } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -40,13 +40,17 @@ import {
|
|||||||
Music,
|
Music,
|
||||||
Code,
|
Code,
|
||||||
AlignLeft,
|
AlignLeft,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface DebugDialogProps {
|
interface DebugDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
pipelineId: string;
|
pipelineId: string;
|
||||||
isEmbedded?: boolean;
|
isEmbedded?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
onConnectionStatusChange?: (isConnected: boolean) => void;
|
onConnectionStatusChange?: (isConnected: boolean) => void;
|
||||||
|
beforeSend?: () => Promise<boolean>;
|
||||||
|
hasUnsavedChanges?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuthenticatedMessageImage({
|
function AuthenticatedMessageImage({
|
||||||
@@ -115,7 +119,10 @@ export default function DebugDialog({
|
|||||||
open,
|
open,
|
||||||
pipelineId,
|
pipelineId,
|
||||||
isEmbedded = false,
|
isEmbedded = false,
|
||||||
|
compact = false,
|
||||||
onConnectionStatusChange,
|
onConnectionStatusChange,
|
||||||
|
beforeSend,
|
||||||
|
hasUnsavedChanges = false,
|
||||||
}: DebugDialogProps) {
|
}: DebugDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
|
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
|
||||||
@@ -142,43 +149,61 @@ export default function DebugDialog({
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
const [streamOutput, setStreamOutput] = useState(true);
|
const [streamOutput, setStreamOutput] = useState(true);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const wsClientRef = useRef<WebSocketClient | null>(null);
|
const wsClientRef = useRef<WebSocketClient | null>(null);
|
||||||
const isInitializingRef = useRef<boolean>(false);
|
const isInitializingRef = useRef<boolean>(false);
|
||||||
|
const historyRequestGenerationRef = useRef(0);
|
||||||
|
|
||||||
|
const invalidateHistoryRequests = useCallback(() => {
|
||||||
|
historyRequestGenerationRef.current++;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
// Use setTimeout to ensure scroll happens after DOM update
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const scrollArea = document.querySelector('.scroll-area') as HTMLElement;
|
const viewport = scrollAreaRef.current?.querySelector<HTMLElement>(
|
||||||
if (scrollArea) {
|
'[data-slot="scroll-area-viewport"]',
|
||||||
scrollArea.scrollTo({
|
);
|
||||||
top: scrollArea.scrollHeight,
|
viewport?.scrollTo({
|
||||||
behavior: 'smooth',
|
top: viewport.scrollHeight,
|
||||||
});
|
behavior: 'smooth',
|
||||||
}
|
});
|
||||||
// Also ensure messagesEndRef scrolls into view
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}, 0);
|
}, 0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadMessages = useCallback(
|
const loadMessages = useCallback(
|
||||||
async (pipelineId: string) => {
|
async (pipelineId: string) => {
|
||||||
|
const generation = ++historyRequestGenerationRef.current;
|
||||||
try {
|
try {
|
||||||
const response = await httpClient.getWebSocketHistoryMessages(
|
const response = await httpClient.getWebSocketHistoryMessages(
|
||||||
pipelineId,
|
pipelineId,
|
||||||
sessionType,
|
sessionType,
|
||||||
);
|
);
|
||||||
setMessages(response.messages);
|
if (generation !== historyRequestGenerationRef.current) return;
|
||||||
|
setMessages(Array.isArray(response.messages) ? response.messages : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== historyRequestGenerationRef.current) return;
|
||||||
console.error('Failed to load messages:', error);
|
console.error('Failed to load messages:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sessionType],
|
[sessionType],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resetConversation = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await httpClient.resetWebSocketSession(selectedPipelineId, sessionType);
|
||||||
|
invalidateHistoryRequests();
|
||||||
|
setMessages([]);
|
||||||
|
setQuotedMessage(null);
|
||||||
|
toast.success(t('pipelines.debugDialog.resetSuccess'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset Debug Chat session:', error);
|
||||||
|
toast.error(t('pipelines.debugDialog.resetFailed'));
|
||||||
|
}
|
||||||
|
}, [invalidateHistoryRequests, selectedPipelineId, sessionType, t]);
|
||||||
|
|
||||||
// Initialize WebSocket connection
|
// Initialize WebSocket connection
|
||||||
const initWebSocket = useCallback(
|
const initWebSocket = useCallback(
|
||||||
async (pipelineId: string) => {
|
async (pipelineId: string) => {
|
||||||
@@ -187,24 +212,30 @@ export default function DebugDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let wsClient: WebSocketClient | null = null;
|
||||||
|
let errorReported = false;
|
||||||
try {
|
try {
|
||||||
isInitializingRef.current = true;
|
isInitializingRef.current = true;
|
||||||
|
|
||||||
// Disconnect old connection
|
// Disconnect old connection
|
||||||
if (wsClientRef.current) {
|
const previousClient = wsClientRef.current;
|
||||||
wsClientRef.current.disconnect();
|
wsClientRef.current = null;
|
||||||
wsClientRef.current = null;
|
previousClient?.disconnect();
|
||||||
}
|
|
||||||
|
|
||||||
// Create new connection
|
// Create new connection
|
||||||
const wsClient = new WebSocketClient(pipelineId, sessionType);
|
wsClient = new WebSocketClient(pipelineId, sessionType);
|
||||||
|
// Store the client before awaiting connect so effect cleanup can also
|
||||||
|
// cancel sockets that are still authenticating.
|
||||||
|
wsClientRef.current = wsClient;
|
||||||
|
|
||||||
wsClient
|
wsClient
|
||||||
.onConnected(() => {
|
.onConnected(() => {
|
||||||
|
if (wsClientRef.current !== wsClient) return;
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
})
|
})
|
||||||
.onMessage((wsMessage) => {
|
.onMessage((wsMessage) => {
|
||||||
|
if (wsClientRef.current !== wsClient) return;
|
||||||
// Convert WebSocketMessage to Message type
|
// Convert WebSocketMessage to Message type
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
...wsMessage,
|
...wsMessage,
|
||||||
@@ -229,26 +260,32 @@ export default function DebugDialog({
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.onError((error) => {
|
.onError((error) => {
|
||||||
|
if (wsClientRef.current !== wsClient) return;
|
||||||
|
errorReported = true;
|
||||||
console.error('WebSocket error:', error);
|
console.error('WebSocket error:', error);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
toast.error(t('pipelines.debugDialog.connectionError'));
|
toast.error(t('pipelines.debugDialog.connectionError'));
|
||||||
})
|
})
|
||||||
.onClose(() => {
|
.onClose(() => {
|
||||||
|
if (wsClientRef.current !== wsClient) return;
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
})
|
})
|
||||||
.onBroadcast((message) => {
|
.onBroadcast((message) => {
|
||||||
|
if (wsClientRef.current !== wsClient) return;
|
||||||
toast.info(message);
|
toast.info(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
await wsClient.connect();
|
await wsClient.connect();
|
||||||
wsClientRef.current = wsClient;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!wsClient || wsClientRef.current !== wsClient) return;
|
||||||
console.error('WebSocket connection failed:', error);
|
console.error('WebSocket connection failed:', error);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
toast.error(t('pipelines.debugDialog.connectionFailed'));
|
if (!errorReported) {
|
||||||
|
toast.error(t('pipelines.debugDialog.connectionFailed'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sessionType, t],
|
[sessionType, t],
|
||||||
@@ -264,24 +301,28 @@ export default function DebugDialog({
|
|||||||
if (open) {
|
if (open) {
|
||||||
setSelectedPipelineId(pipelineId);
|
setSelectedPipelineId(pipelineId);
|
||||||
} else {
|
} else {
|
||||||
|
invalidateHistoryRequests();
|
||||||
// Disconnect WebSocket immediately when dialog closes
|
// Disconnect WebSocket immediately when dialog closes
|
||||||
if (wsClientRef.current) {
|
if (wsClientRef.current) {
|
||||||
wsClientRef.current.disconnect();
|
const wsClient = wsClientRef.current;
|
||||||
wsClientRef.current = null;
|
wsClientRef.current = null;
|
||||||
|
wsClient.disconnect();
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
invalidateHistoryRequests();
|
||||||
// Disconnect WebSocket on component unmount
|
// Disconnect WebSocket on component unmount
|
||||||
if (wsClientRef.current) {
|
if (wsClientRef.current) {
|
||||||
wsClientRef.current.disconnect();
|
const wsClient = wsClientRef.current;
|
||||||
wsClientRef.current = null;
|
wsClientRef.current = null;
|
||||||
|
wsClient.disconnect();
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [open, pipelineId]);
|
}, [open, pipelineId, invalidateHistoryRequests]);
|
||||||
|
|
||||||
// Reload messages and reconnect when sessionType or selectedPipelineId changes
|
// Reload messages and reconnect when sessionType or selectedPipelineId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -321,7 +362,7 @@ export default function DebugDialog({
|
|||||||
}
|
}
|
||||||
}, [showAtPopover]);
|
}, [showAtPopover]);
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (sessionType === 'group') {
|
if (sessionType === 'group') {
|
||||||
if (value.endsWith('@')) {
|
if (value.endsWith('@')) {
|
||||||
@@ -412,8 +453,11 @@ export default function DebugDialog({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
if (hasUnsavedChanges && beforeSend && !(await beforeSend())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const messageChain = [];
|
const messageChain: MessageChainComponent[] = [];
|
||||||
|
|
||||||
// Add quoted message if present
|
// Add quoted message if present
|
||||||
if (quotedMessage) {
|
if (quotedMessage) {
|
||||||
@@ -467,17 +511,21 @@ export default function DebugDialog({
|
|||||||
type: 'Image',
|
type: 'Image',
|
||||||
path: result.file_key,
|
path: result.file_key,
|
||||||
});
|
});
|
||||||
} else {
|
} else if (attachment.kind === 'voice') {
|
||||||
// Voice / File go through the generic document upload endpoint,
|
// Voice / File go through the generic document upload endpoint,
|
||||||
// which returns a storage key the backend resolves into the
|
// which returns a storage key the backend resolves into the
|
||||||
// sandbox inbox just like images.
|
// sandbox inbox just like images.
|
||||||
const result = await httpClient.uploadDocumentFile(attachment.file);
|
const result = await httpClient.uploadDocumentFile(attachment.file);
|
||||||
messageChain.push({
|
messageChain.push({
|
||||||
type: attachment.kind === 'voice' ? 'Voice' : 'File',
|
type: 'Voice',
|
||||||
path: result.file_id,
|
path: result.file_id,
|
||||||
...(attachment.kind === 'file'
|
});
|
||||||
? { name: attachment.file.name }
|
} else {
|
||||||
: {}),
|
const result = await httpClient.uploadDocumentFile(attachment.file);
|
||||||
|
messageChain.push({
|
||||||
|
type: 'File',
|
||||||
|
path: result.file_id,
|
||||||
|
name: attachment.file.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -804,39 +852,58 @@ export default function DebugDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => (
|
const renderContent = () => (
|
||||||
<div className="flex flex-1 h-full min-h-0">
|
<div className="flex flex-1 h-full min-h-0 flex-col">
|
||||||
<div className="w-14 p-2 pl-0 shrink-0 flex flex-col justify-start gap-2">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 flex-wrap items-center gap-1 border-b px-4 py-2',
|
||||||
|
compact && 'px-3',
|
||||||
|
)}
|
||||||
|
data-debug-session-toolbar="true"
|
||||||
|
>
|
||||||
|
<span className="mr-1 text-xs text-muted-foreground">
|
||||||
|
{t('pipelines.debugDialog.sessionType')}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
|
aria-pressed={sessionType === 'person'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
|
'shadow-none',
|
||||||
sessionType === 'person'
|
sessionType === 'person' &&
|
||||||
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
|
'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary',
|
||||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
|
||||||
)}
|
)}
|
||||||
onClick={() => setSessionType('person')}
|
onClick={() => setSessionType('person')}
|
||||||
>
|
>
|
||||||
<User className="size-5" />
|
<User className="size-4" />
|
||||||
|
{t('pipelines.debugDialog.privateChat')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
|
aria-pressed={sessionType === 'group'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
|
'shadow-none',
|
||||||
sessionType === 'group'
|
sessionType === 'group' &&
|
||||||
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
|
'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary',
|
||||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
|
||||||
)}
|
)}
|
||||||
onClick={() => setSessionType('group')}
|
onClick={() => setSessionType('group')}
|
||||||
>
|
>
|
||||||
<Users className="size-5" />
|
<Users className="size-4" />
|
||||||
|
{t('pipelines.debugDialog.groupChat')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
<div className="flex-1 flex flex-col w-full h-full min-h-0">
|
||||||
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 scroll-area">
|
<ScrollArea
|
||||||
<div className="space-y-6">
|
ref={scrollAreaRef}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 overflow-y-auto min-h-0 scroll-area',
|
||||||
|
compact ? 'p-3' : 'p-6',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={compact ? 'space-y-3' : 'space-y-6'}>
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||||
{t('pipelines.debugDialog.noMessages')}
|
{t('pipelines.debugDialog.noMessages')}
|
||||||
@@ -852,7 +919,10 @@ export default function DebugDialog({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-3xl px-5 py-3 rounded-2xl',
|
'rounded-2xl',
|
||||||
|
compact
|
||||||
|
? 'max-w-[92%] px-3 py-2 text-sm'
|
||||||
|
: 'max-w-3xl px-5 py-3',
|
||||||
message.role === 'user'
|
message.role === 'user'
|
||||||
? 'user-message-bubble bg-primary/10 text-foreground rounded-br-none'
|
? 'user-message-bubble bg-primary/10 text-foreground rounded-br-none'
|
||||||
: 'bg-muted text-foreground rounded-bl-none',
|
: 'bg-muted text-foreground rounded-bl-none',
|
||||||
@@ -919,7 +989,6 @@ export default function DebugDialog({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
@@ -990,8 +1059,11 @@ export default function DebugDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-4 pb-0 flex gap-2">
|
<div
|
||||||
<div className="flex gap-2 items-center">
|
className={cn('shrink-0 border-t p-4', compact && 'p-3')}
|
||||||
|
data-debug-composer="true"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t('pipelines.debugDialog.streamOutput')}
|
{t('pipelines.debugDialog.streamOutput')}
|
||||||
@@ -1020,69 +1092,93 @@ export default function DebugDialog({
|
|||||||
>
|
>
|
||||||
<ImageIcon className="size-5" />
|
<ImageIcon className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto text-muted-foreground"
|
||||||
|
onClick={() => void resetConversation()}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-4" />
|
||||||
|
{t('pipelines.debugDialog.reset')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center gap-2">
|
|
||||||
{hasAt && (
|
<div className="flex min-w-0 items-end gap-2">
|
||||||
<AtBadge targetName="websocketbot" onRemove={handleAtRemove} />
|
<div className="min-w-0 flex-1">
|
||||||
)}
|
{hasAt && (
|
||||||
<div className="relative flex-1">
|
<div className="mb-1">
|
||||||
<Input
|
<AtBadge
|
||||||
ref={inputRef}
|
targetName="websocketbot"
|
||||||
value={inputValue}
|
onRemove={handleAtRemove}
|
||||||
onChange={handleInputChange}
|
/>
|
||||||
onKeyPress={handleKeyPress}
|
|
||||||
placeholder={t('pipelines.debugDialog.inputPlaceholder', {
|
|
||||||
type:
|
|
||||||
sessionType === 'person'
|
|
||||||
? t('pipelines.debugDialog.privateChat')
|
|
||||||
: t('pipelines.debugDialog.groupChat'),
|
|
||||||
})}
|
|
||||||
disabled={!isConnected || isUploading}
|
|
||||||
className="flex-1 rounded-md px-3 py-2 transition-none text-base disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
{showAtPopover && (
|
|
||||||
<div
|
|
||||||
ref={popoverRef}
|
|
||||||
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-popover text-popover-foreground shadow-lg"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
|
|
||||||
isHovering ? 'bg-accent' : '',
|
|
||||||
)}
|
|
||||||
onClick={handleAtSelect}
|
|
||||||
onMouseEnter={() => setIsHovering(true)}
|
|
||||||
onMouseLeave={() => setIsHovering(false)}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
@websocketbot - {t('pipelines.debugDialog.atTips')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyPress}
|
||||||
|
placeholder={t('pipelines.debugDialog.inputPlaceholder', {
|
||||||
|
type:
|
||||||
|
sessionType === 'person'
|
||||||
|
? t('pipelines.debugDialog.privateChat')
|
||||||
|
: t('pipelines.debugDialog.groupChat'),
|
||||||
|
})}
|
||||||
|
disabled={!isConnected || isUploading}
|
||||||
|
rows={1}
|
||||||
|
className="h-11 min-h-11 max-h-32 resize-y rounded-md px-3 py-2 text-sm transition-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{showAtPopover && (
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-popover text-popover-foreground shadow-lg"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
|
||||||
|
isHovering ? 'bg-accent' : '',
|
||||||
|
)}
|
||||||
|
onClick={handleAtSelect}
|
||||||
|
onMouseEnter={() => setIsHovering(true)}
|
||||||
|
onMouseLeave={() => setIsHovering(false)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
@websocketbot - {t('pipelines.debugDialog.atTips')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={sendMessage}
|
||||||
|
disabled={
|
||||||
|
(!inputValue.trim() &&
|
||||||
|
!hasAt &&
|
||||||
|
selectedImages.length === 0 &&
|
||||||
|
!quotedMessage) ||
|
||||||
|
!isConnected ||
|
||||||
|
isUploading
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'h-11 shrink-0 rounded-md px-4 text-sm font-medium transition-none shadow-none disabled:opacity-50',
|
||||||
|
!compact && 'px-6 text-base',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
t('pipelines.debugDialog.uploading')
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="size-4" />
|
||||||
|
{hasUnsavedChanges
|
||||||
|
? t('pipelines.debugDialog.saveAndSend')
|
||||||
|
: t('pipelines.debugDialog.send')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
onClick={sendMessage}
|
|
||||||
disabled={
|
|
||||||
(!inputValue.trim() &&
|
|
||||||
!hasAt &&
|
|
||||||
selectedImages.length === 0 &&
|
|
||||||
!quotedMessage) ||
|
|
||||||
!isConnected ||
|
|
||||||
isUploading
|
|
||||||
}
|
|
||||||
className="rounded-md w-20 px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isUploading ? (
|
|
||||||
t('pipelines.debugDialog.uploading')
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Send className="size-4" />
|
|
||||||
{t('pipelines.debugDialog.send')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { GetPipelineResponseData, Pipeline } from '@/app/infra/entities/api';
|
import { GetPipelineResponseData, Pipeline } from '@/app/infra/entities/api';
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +15,7 @@ import {
|
|||||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||||
import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
|
import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -31,7 +39,6 @@ import {
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -51,17 +58,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
|
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
|
||||||
|
|
||||||
export default function PipelineFormComponent({
|
interface PipelineFormComponentProps {
|
||||||
onFinish,
|
|
||||||
onNewPipelineCreated,
|
|
||||||
isEditMode,
|
|
||||||
pipelineId,
|
|
||||||
showButtons = true,
|
|
||||||
onDeletePipeline,
|
|
||||||
onCancel,
|
|
||||||
onDirtyChange,
|
|
||||||
onSavingChange,
|
|
||||||
}: {
|
|
||||||
pipelineId?: string;
|
pipelineId?: string;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
disableForm: boolean;
|
disableForm: boolean;
|
||||||
@@ -72,7 +69,34 @@ export default function PipelineFormComponent({
|
|||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
onSavingChange?: (saving: boolean) => void;
|
onSavingChange?: (saving: boolean) => void;
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
export interface PipelineFormHandle {
|
||||||
|
save: () => Promise<boolean>;
|
||||||
|
syncBasicInfo: (values: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
emoji?: string;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PipelineFormComponent = forwardRef<
|
||||||
|
PipelineFormHandle,
|
||||||
|
PipelineFormComponentProps
|
||||||
|
>(function PipelineFormComponent(
|
||||||
|
{
|
||||||
|
onFinish,
|
||||||
|
onNewPipelineCreated,
|
||||||
|
isEditMode,
|
||||||
|
pipelineId,
|
||||||
|
showButtons = true,
|
||||||
|
onDeletePipeline,
|
||||||
|
onCancel,
|
||||||
|
onDirtyChange,
|
||||||
|
onSavingChange,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
|
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
|
||||||
@@ -118,7 +142,7 @@ export default function PipelineFormComponent({
|
|||||||
const formLabelList: SectionItem[] = isEditMode
|
const formLabelList: SectionItem[] = isEditMode
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: t('pipelines.basicInfo'),
|
label: t('common.management'),
|
||||||
name: 'basic',
|
name: 'basic',
|
||||||
icon: SECTION_ICONS.basic,
|
icon: SECTION_ICONS.basic,
|
||||||
},
|
},
|
||||||
@@ -156,7 +180,20 @@ export default function PipelineFormComponent({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const [activeSection, setActiveSection] = useState(formLabelList[0].name);
|
const [activeSection, setActiveSection] = useState(
|
||||||
|
isEditMode ? 'trigger' : 'basic',
|
||||||
|
);
|
||||||
|
const primarySectionNames = ['trigger', 'ai', 'output'];
|
||||||
|
const primarySections = primarySectionNames
|
||||||
|
.map((name) => formLabelList.find((section) => section.name === name))
|
||||||
|
.filter((section): section is SectionItem => Boolean(section));
|
||||||
|
const secondarySections = formLabelList
|
||||||
|
.filter((section) => !primarySectionNames.includes(section.name))
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.name === 'basic') return 1;
|
||||||
|
if (right.name === 'basic') return -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
const [aiConfigTabSchema, setAIConfigTabSchema] =
|
const [aiConfigTabSchema, setAIConfigTabSchema] =
|
||||||
useState<PipelineConfigTab>();
|
useState<PipelineConfigTab>();
|
||||||
@@ -259,7 +296,7 @@ export default function PipelineFormComponent({
|
|||||||
|
|
||||||
function handleFormSubmit(values: FormValues) {
|
function handleFormSubmit(values: FormValues) {
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
handleModify(values);
|
void handleModify(values);
|
||||||
} else {
|
} else {
|
||||||
handleCreate(values);
|
handleCreate(values);
|
||||||
}
|
}
|
||||||
@@ -293,8 +330,8 @@ export default function PipelineFormComponent({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleModify(values: FormValues) {
|
async function handleModify(values: FormValues): Promise<boolean> {
|
||||||
if (isSavingRef.current) return;
|
if (isSavingRef.current) return false;
|
||||||
const submittedSnapshot = JSON.stringify(values);
|
const submittedSnapshot = JSON.stringify(values);
|
||||||
const realConfig = {
|
const realConfig = {
|
||||||
ai: values.ai,
|
ai: values.ai,
|
||||||
@@ -318,23 +355,54 @@ export default function PipelineFormComponent({
|
|||||||
isSavingRef.current = true;
|
isSavingRef.current = true;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
onSavingChange?.(true);
|
onSavingChange?.(true);
|
||||||
httpClient
|
try {
|
||||||
.updatePipeline(pipelineId || '', pipeline)
|
await httpClient.updatePipeline(pipelineId || '', pipeline);
|
||||||
.then(() => {
|
savedSnapshotRef.current = submittedSnapshot;
|
||||||
savedSnapshotRef.current = submittedSnapshot;
|
onFinish();
|
||||||
onFinish();
|
toast.success(t('pipelines.saveSuccess'));
|
||||||
toast.success(t('pipelines.saveSuccess'));
|
return true;
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => {
|
const message =
|
||||||
toast.error(t('pipelines.saveError') + err.msg);
|
typeof err === 'object' && err && 'msg' in err
|
||||||
})
|
? String((err as { msg?: string }).msg || '')
|
||||||
.finally(() => {
|
: '';
|
||||||
isSavingRef.current = false;
|
toast.error(t('pipelines.saveError') + message);
|
||||||
setIsSaving(false);
|
return false;
|
||||||
onSavingChange?.(false);
|
} finally {
|
||||||
});
|
isSavingRef.current = false;
|
||||||
|
setIsSaving(false);
|
||||||
|
onSavingChange?.(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
syncBasicInfo(values) {
|
||||||
|
form.setValue('basic', {
|
||||||
|
...form.getValues('basic'),
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
emoji: values.emoji || '⚙️',
|
||||||
|
});
|
||||||
|
if (savedSnapshotRef.current) {
|
||||||
|
const snapshot = JSON.parse(savedSnapshotRef.current) as FormValues;
|
||||||
|
snapshot.basic = {
|
||||||
|
...snapshot.basic,
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
emoji: values.emoji || '⚙️',
|
||||||
|
};
|
||||||
|
savedSnapshotRef.current = JSON.stringify(snapshot);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
if (!hasUnsavedChangesRef.current) return true;
|
||||||
|
if (isSavingRef.current || !isEditMode) return false;
|
||||||
|
const valid = await form.trigger();
|
||||||
|
if (!valid) return false;
|
||||||
|
return handleModify(form.getValues());
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Called from DynamicFormComponent onSubmit callbacks.
|
// Called from DynamicFormComponent onSubmit callbacks.
|
||||||
// On the first emission for a stage (mount-time default filling), the
|
// On the first emission for a stage (mount-time default filling), the
|
||||||
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
|
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
|
||||||
@@ -567,100 +635,133 @@ export default function PipelineFormComponent({
|
|||||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||||
className="h-full flex flex-col flex-1 min-h-0 mb-2"
|
className="h-full flex flex-col flex-1 min-h-0 mb-2"
|
||||||
>
|
>
|
||||||
<div className="flex-1 flex flex-col md:flex-row min-h-0">
|
<div className="flex-1 flex min-h-0 flex-col">
|
||||||
{/* Vertical section navigation (only show when multiple sections) */}
|
{/* Keep the primary pipeline flow visible while editing. */}
|
||||||
{formLabelList.length > 1 && (
|
{formLabelList.length > 1 && (
|
||||||
<nav className="shrink-0 mb-4 md:mb-0 md:w-44 md:pr-4 md:mr-4 md:border-r overflow-x-auto md:overflow-x-visible md:overflow-y-auto">
|
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
|
||||||
<ul className="flex md:flex-col gap-1 md:space-y-1">
|
<Tabs value={activeSection} onValueChange={setActiveSection}>
|
||||||
{formLabelList.map((section) => {
|
<div className="overflow-x-auto">
|
||||||
const Icon = section.icon;
|
<TabsList className="grid min-w-[34rem] w-full grid-cols-3">
|
||||||
return (
|
{primarySections.map((section) => {
|
||||||
<li key={section.name}>
|
const Icon = section.icon;
|
||||||
<button
|
return (
|
||||||
|
<TabsTrigger
|
||||||
|
key={section.name}
|
||||||
|
value={section.name}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
{section.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{secondarySections.map((section) => {
|
||||||
|
const Icon = section.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={section.name}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveSection(section.name)}
|
variant={
|
||||||
className={cn(
|
|
||||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors text-left cursor-pointer whitespace-nowrap',
|
|
||||||
activeSection === section.name
|
activeSection === section.name
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'secondary'
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
: 'ghost'
|
||||||
)}
|
}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveSection(section.name)}
|
||||||
>
|
>
|
||||||
<Icon className="size-4 shrink-0" />
|
<Icon />
|
||||||
{section.label}
|
{section.label}
|
||||||
</button>
|
</Button>
|
||||||
</li>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</ul>
|
</Tabs>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content panel */}
|
{/* Content panel */}
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
{/* Basic info section */}
|
|
||||||
{activeSection === 'basic' && (
|
{activeSection === 'basic' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Basic Information Card */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('pipelines.basicInfo')}</CardTitle>
|
<CardTitle>
|
||||||
|
{isEditMode
|
||||||
|
? t('common.management')
|
||||||
|
: t('pipelines.basicInfo')}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t('pipelines.basicInfoDescription')}
|
{isEditMode
|
||||||
|
? t('pipelines.managementDescription')
|
||||||
|
: t('pipelines.basicInfoDescription')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Name and Emoji in same row */}
|
{!isEditMode && (
|
||||||
<div className="flex gap-4 items-start">
|
<>
|
||||||
<FormField
|
<div className="flex gap-4 items-start">
|
||||||
control={form.control}
|
<FormField
|
||||||
name="basic.name"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="basic.name"
|
||||||
<FormItem className="flex-1">
|
render={({ field }) => (
|
||||||
<FormLabel>
|
<FormItem className="flex-1">
|
||||||
{t('common.name')}
|
<FormLabel>
|
||||||
<span className="text-destructive">*</span>
|
{t('common.name')}
|
||||||
</FormLabel>
|
<span className="text-destructive">
|
||||||
<FormControl>
|
*
|
||||||
<Input {...field} value={field.value ?? ''} />
|
</span>
|
||||||
</FormControl>
|
</FormLabel>
|
||||||
<FormMessage />
|
<FormControl>
|
||||||
</FormItem>
|
<Input
|
||||||
)}
|
{...field}
|
||||||
/>
|
value={field.value ?? ''}
|
||||||
<FormField
|
/>
|
||||||
control={form.control}
|
</FormControl>
|
||||||
name="basic.emoji"
|
<FormMessage />
|
||||||
render={({ field }) => (
|
</FormItem>
|
||||||
<FormItem>
|
)}
|
||||||
<FormLabel>{t('common.icon')}</FormLabel>
|
/>
|
||||||
<FormControl>
|
<FormField
|
||||||
<EmojiPicker
|
control={form.control}
|
||||||
value={field.value}
|
name="basic.emoji"
|
||||||
onChange={field.onChange}
|
render={({ field }) => (
|
||||||
/>
|
<FormItem>
|
||||||
</FormControl>
|
<FormLabel>{t('common.icon')}</FormLabel>
|
||||||
<FormMessage />
|
<FormControl>
|
||||||
</FormItem>
|
<EmojiPicker
|
||||||
)}
|
value={field.value}
|
||||||
/>
|
onChange={field.onChange}
|
||||||
</div>
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="basic.description"
|
name="basic.description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('common.description')}</FormLabel>
|
<FormLabel>
|
||||||
<FormControl>
|
{t('common.description')}
|
||||||
<Input {...field} value={field.value ?? ''} />
|
</FormLabel>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<FormMessage />
|
<Input
|
||||||
</FormItem>
|
{...field}
|
||||||
)}
|
value={field.value ?? ''}
|
||||||
/>
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Copy pipeline (edit mode only) */}
|
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -851,7 +952,9 @@ export default function PipelineFormComponent({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default PipelineFormComponent;
|
||||||
interface SectionItem {
|
interface SectionItem {
|
||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ export interface Agent {
|
|||||||
kind: AgentKind;
|
kind: AgentKind;
|
||||||
component_ref?: string | null;
|
component_ref?: string | null;
|
||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
enabled?: boolean;
|
|
||||||
supported_event_patterns?: string[];
|
supported_event_patterns?: string[];
|
||||||
capability?: AgentCapability;
|
capability?: AgentCapability;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
@@ -276,11 +275,6 @@ export interface BotRouteDryRunRequest {
|
|||||||
event_bindings?: EventBinding[];
|
event_bindings?: EventBinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BotRouteTestRequest {
|
|
||||||
event_type: string;
|
|
||||||
payload?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotRouteDryRunTarget {
|
export interface BotRouteDryRunTarget {
|
||||||
target_type: EventBinding['target_type'];
|
target_type: EventBinding['target_type'];
|
||||||
target_uuid?: string | null;
|
target_uuid?: string | null;
|
||||||
@@ -335,17 +329,6 @@ export interface BotEventRouteStatusResponse {
|
|||||||
stale_routes: BotEventRouteStatus[];
|
stale_routes: BotEventRouteStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BotRouteTestResult {
|
|
||||||
dispatched: boolean;
|
|
||||||
event_type: string;
|
|
||||||
status?: BotEventRouteStatus['last_status'];
|
|
||||||
binding_id?: string | null;
|
|
||||||
failure_code?: string | null;
|
|
||||||
reason?: string | null;
|
|
||||||
suppressed_outputs: Array<Record<string, unknown>>;
|
|
||||||
route_status: BotEventRouteStatusResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiRespKnowledgeBases {
|
export interface ApiRespKnowledgeBases {
|
||||||
bases: KnowledgeBase[];
|
bases: KnowledgeBase[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export interface Plain extends MessageComponent {
|
|||||||
// Quote component
|
// Quote component
|
||||||
export interface Quote extends MessageComponent {
|
export interface Quote extends MessageComponent {
|
||||||
type: 'Quote';
|
type: 'Quote';
|
||||||
id?: number;
|
id?: number | string;
|
||||||
group_id?: number | string;
|
group_id?: number | string;
|
||||||
sender_id?: number | string;
|
sender_id?: number | string;
|
||||||
target_id?: number | string;
|
target_id?: number | string;
|
||||||
|
|||||||
@@ -61,8 +61,6 @@ import {
|
|||||||
ApiRespSkill,
|
ApiRespSkill,
|
||||||
BotRouteDryRunRequest,
|
BotRouteDryRunRequest,
|
||||||
BotRouteDryRunResult,
|
BotRouteDryRunResult,
|
||||||
BotRouteTestRequest,
|
|
||||||
BotRouteTestResult,
|
|
||||||
BotEventRouteStatusResponse,
|
BotEventRouteStatusResponse,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { Plugin } from '@/app/infra/entities/plugin';
|
import { Plugin } from '@/app/infra/entities/plugin';
|
||||||
@@ -329,7 +327,10 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.post('/api/v1/pipelines', pipeline);
|
return this.post('/api/v1/pipelines', pipeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
public updatePipeline(uuid: string, pipeline: Pipeline): Promise<object> {
|
public updatePipeline(
|
||||||
|
uuid: string,
|
||||||
|
pipeline: Partial<Pipeline>,
|
||||||
|
): Promise<object> {
|
||||||
return this.put(`/api/v1/pipelines/${uuid}`, pipeline);
|
return this.put(`/api/v1/pipelines/${uuid}`, pipeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,7 +490,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.post('/api/v1/platform/bots', bot);
|
return this.post('/api/v1/platform/bots', bot);
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateBot(uuid: string, bot: Bot): Promise<object> {
|
public updateBot(uuid: string, bot: Partial<Bot>): Promise<object> {
|
||||||
return this.put(`/api/v1/platform/bots/${uuid}`, bot);
|
return this.put(`/api/v1/platform/bots/${uuid}`, bot);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,16 +510,6 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.get(`/api/v1/platform/bots/${botId}/event-routes/status`);
|
return this.get(`/api/v1/platform/bots/${botId}/event-routes/status`);
|
||||||
}
|
}
|
||||||
|
|
||||||
public testBotEventRoute(
|
|
||||||
botId: string,
|
|
||||||
request: BotRouteTestRequest,
|
|
||||||
): Promise<BotRouteTestResult> {
|
|
||||||
return this.post(
|
|
||||||
`/api/v1/platform/bots/${botId}/event-routes/test`,
|
|
||||||
request,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public deleteBot(uuid: string): Promise<object> {
|
public deleteBot(uuid: string): Promise<object> {
|
||||||
return this.delete(`/api/v1/platform/bots/${uuid}`);
|
return this.delete(`/api/v1/platform/bots/${uuid}`);
|
||||||
}
|
}
|
||||||
@@ -1307,6 +1298,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;
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ export interface GetBotLogsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BotLog {
|
export interface BotLog {
|
||||||
images: [];
|
images: string[];
|
||||||
level: string;
|
level: string;
|
||||||
message_session_id: string;
|
message_session_id: string;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
seq_id: number;
|
seq_id: number;
|
||||||
text: string;
|
text: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
* 用于管理WebSocket连接和消息处理
|
* 用于管理WebSocket连接和消息处理
|
||||||
*/
|
*/
|
||||||
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
|
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
|
||||||
|
import type { MessageChainComponent } from '@/app/infra/entities/message';
|
||||||
|
|
||||||
export interface WebSocketMessage {
|
export interface WebSocketMessage {
|
||||||
id: number;
|
id: number;
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
message_chain: Array<{ type: string; text?: string; target?: string }>;
|
message_chain: MessageChainComponent[];
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
is_final?: boolean;
|
is_final?: boolean;
|
||||||
connection_id?: string;
|
connection_id?: string;
|
||||||
@@ -36,9 +37,12 @@ export class WebSocketClient {
|
|||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0;
|
||||||
private maxReconnectAttempts = 5;
|
private maxReconnectAttempts = 5;
|
||||||
private reconnectDelay = 3000; // 3秒重连间隔
|
private reconnectDelay = 3000; // 3秒重连间隔
|
||||||
|
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||||
private heartbeatIntervalMs = 30000; // 30秒
|
private heartbeatIntervalMs = 30000; // 30秒
|
||||||
private isConnecting = false; // 防止重复连接
|
private isConnecting = false; // 防止重复连接
|
||||||
|
private shouldReconnect = true;
|
||||||
|
private disconnectedByUser = false;
|
||||||
|
|
||||||
// 事件回调
|
// 事件回调
|
||||||
private onConnectedCallback?: (data: WebSocketResponse) => void;
|
private onConnectedCallback?: (data: WebSocketResponse) => void;
|
||||||
@@ -59,6 +63,13 @@ export class WebSocketClient {
|
|||||||
public connect(): Promise<string> {
|
public connect(): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
|
this.disconnectedByUser = false;
|
||||||
|
this.shouldReconnect = true;
|
||||||
|
if (this.reconnectTimeout) {
|
||||||
|
clearTimeout(this.reconnectTimeout);
|
||||||
|
this.reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
// 防止重复连接
|
// 防止重复连接
|
||||||
if (
|
if (
|
||||||
this.isConnecting ||
|
this.isConnecting ||
|
||||||
@@ -87,22 +98,27 @@ export class WebSocketClient {
|
|||||||
window.location.host;
|
window.location.host;
|
||||||
const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`;
|
const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`;
|
||||||
|
|
||||||
this.ws = new WebSocket(url);
|
const socket = new WebSocket(url);
|
||||||
|
this.ws = socket;
|
||||||
|
|
||||||
// 连接打开
|
// 连接打开
|
||||||
this.ws.onopen = () => {
|
socket.onopen = () => {
|
||||||
this.reconnectAttempts = 0;
|
if (this.disconnectedByUser || this.ws !== socket) {
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
const token = this.token || localStorage.getItem('token');
|
const token = this.token || localStorage.getItem('token');
|
||||||
const workspaceUuid = getActiveWorkspaceUuid();
|
const workspaceUuid = getActiveWorkspaceUuid();
|
||||||
if (!token || !workspaceUuid) {
|
if (!token || !workspaceUuid) {
|
||||||
const error = new Error('WebSocket认证信息缺失');
|
const error = new Error('WebSocket认证信息缺失');
|
||||||
|
this.shouldReconnect = false;
|
||||||
this.onErrorCallback?.(error);
|
this.onErrorCallback?.(error);
|
||||||
this.ws?.close();
|
socket.close();
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.ws?.send(
|
socket.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'authenticate',
|
type: 'authenticate',
|
||||||
token,
|
token,
|
||||||
@@ -112,13 +128,23 @@ export class WebSocketClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 接收消息
|
// 接收消息
|
||||||
this.ws.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
|
if (this.disconnectedByUser || this.ws !== socket) return;
|
||||||
try {
|
try {
|
||||||
const data: WebSocketResponse = JSON.parse(event.data);
|
const data: WebSocketResponse = JSON.parse(event.data);
|
||||||
this.handleMessage(data);
|
this.handleMessage(data);
|
||||||
|
|
||||||
|
if (data.type === 'error' && !this.connectionId) {
|
||||||
|
reject(new Error(data.message || 'WebSocket连接失败'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 第一次连接成功
|
// 第一次连接成功
|
||||||
if (data.type === 'connected' && data.connection_id) {
|
if (data.type === 'connected' && data.connection_id) {
|
||||||
|
// Only a fully authenticated runtime connection should reset
|
||||||
|
// the retry budget. Resetting on TCP open makes server-side
|
||||||
|
// errors (for example a pipeline still loading) retry forever.
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
this.connectionId = data.connection_id;
|
this.connectionId = data.connection_id;
|
||||||
this.startHeartbeat();
|
this.startHeartbeat();
|
||||||
resolve(data.connection_id);
|
resolve(data.connection_id);
|
||||||
@@ -130,22 +156,36 @@ export class WebSocketClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 连接关闭
|
// 连接关闭
|
||||||
this.ws.onclose = () => {
|
socket.onclose = () => {
|
||||||
|
if (this.ws === socket) {
|
||||||
|
this.ws = null;
|
||||||
|
this.connectionId = null;
|
||||||
|
}
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
|
if (this.disconnectedByUser) return;
|
||||||
this.onCloseCallback?.();
|
this.onCloseCallback?.();
|
||||||
|
|
||||||
// 自动重连
|
// 自动重连
|
||||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
if (
|
||||||
|
this.shouldReconnect &&
|
||||||
|
this.reconnectAttempts < this.maxReconnectAttempts
|
||||||
|
) {
|
||||||
this.reconnectAttempts++;
|
this.reconnectAttempts++;
|
||||||
setTimeout(() => {
|
this.reconnectTimeout = setTimeout(() => {
|
||||||
|
this.reconnectTimeout = null;
|
||||||
|
if (!this.shouldReconnect || this.disconnectedByUser) return;
|
||||||
this.connect().catch(console.error);
|
this.connect().catch(console.error);
|
||||||
}, this.reconnectDelay * this.reconnectAttempts);
|
}, this.reconnectDelay * this.reconnectAttempts);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 连接错误
|
// 连接错误
|
||||||
this.ws.onerror = (event) => {
|
socket.onerror = (event) => {
|
||||||
|
if (this.disconnectedByUser || this.ws !== socket) {
|
||||||
|
reject(new Error('WebSocket连接已取消'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('WebSocket错误:', event);
|
console.error('WebSocket错误:', event);
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
const error = new Error('WebSocket连接失败');
|
const error = new Error('WebSocket连接失败');
|
||||||
@@ -210,6 +250,13 @@ export class WebSocketClient {
|
|||||||
case 'error':
|
case 'error':
|
||||||
const error = new Error(data.message || '未知错误');
|
const error = new Error(data.message || '未知错误');
|
||||||
this.onErrorCallback?.(error);
|
this.onErrorCallback?.(error);
|
||||||
|
// Authentication/resource errors happen before the `connected`
|
||||||
|
// handshake. Retrying them cannot recover and would leak error toasts
|
||||||
|
// after the user leaves the Pipeline page.
|
||||||
|
if (!this.connectionId) {
|
||||||
|
this.shouldReconnect = false;
|
||||||
|
this.ws?.close();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -221,7 +268,7 @@ export class WebSocketClient {
|
|||||||
* 发送消息
|
* 发送消息
|
||||||
*/
|
*/
|
||||||
public sendMessage(
|
public sendMessage(
|
||||||
messageChain: Array<{ type: string; text?: string; target?: string }>,
|
messageChain: MessageChainComponent[],
|
||||||
stream: boolean = true,
|
stream: boolean = true,
|
||||||
) {
|
) {
|
||||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
@@ -272,21 +319,27 @@ export class WebSocketClient {
|
|||||||
* 断开连接
|
* 断开连接
|
||||||
*/
|
*/
|
||||||
public disconnect() {
|
public disconnect() {
|
||||||
|
this.disconnectedByUser = true;
|
||||||
|
this.shouldReconnect = false;
|
||||||
|
this.reconnectAttempts = this.maxReconnectAttempts;
|
||||||
|
if (this.reconnectTimeout) {
|
||||||
|
clearTimeout(this.reconnectTimeout);
|
||||||
|
this.reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
|
const socket = this.ws;
|
||||||
// 停止自动重连
|
|
||||||
this.reconnectAttempts = this.maxReconnectAttempts;
|
|
||||||
|
|
||||||
// 发送断开消息
|
// 发送断开消息
|
||||||
if (this.ws.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
this.ws.send(JSON.stringify({ type: 'disconnect' }));
|
socket.send(JSON.stringify({ type: 'disconnect' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.connectionId = null;
|
this.connectionId = null;
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
|
socket.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user