mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-27 04:37:13 +00:00
Compare commits
59 Commits
0fbb1f897e
...
dev/4.11.x
| Author | SHA1 | Date | |
|---|---|---|---|
| 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:
|
||||
build-dev-image:
|
||||
runs-on: ubuntu-latest
|
||||
# 如果是tag则跳过
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate Tag
|
||||
id: generate_tag
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate image metadata
|
||||
id: image
|
||||
shell: bash
|
||||
run: |
|
||||
# 获取分支名称,把/替换为-
|
||||
echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g'
|
||||
echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g')
|
||||
- name: Login to Registry
|
||||
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker buildx create --name mybuilder --use
|
||||
docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push
|
||||
set -euo pipefail
|
||||
branch_tag="${GITHUB_REF#refs/heads/}"
|
||||
branch_tag="${branch_tag//\//-}"
|
||||
echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "sha_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push immutable Core image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
rockchin/langbot:${{ steps.image.outputs.branch_tag }}
|
||||
rockchin/langbot:${{ steps.image.outputs.sha_tag }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -f /tmp/nodesource_setup.sh \
|
||||
&& python -m pip install --no-cache-dir uv \
|
||||
&& uv sync \
|
||||
&& uv sync --extra seekdb \
|
||||
&& apt-get purge -y --auto-remove curl git gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& touch /.dockerenv
|
||||
|
||||
@@ -10,6 +10,19 @@ uvx langbot
|
||||
|
||||
This will automatically download and run the latest version of LangBot.
|
||||
|
||||
SeekDB support is optional and is not installed by the command above. If you
|
||||
want to use the SeekDB vector database or the built-in SeekDB embedding model,
|
||||
run LangBot with the `seekdb` extra:
|
||||
|
||||
```bash
|
||||
uvx --from 'langbot[seekdb]@latest' langbot
|
||||
```
|
||||
|
||||
The extra includes native dependencies whose supported operating systems may
|
||||
be narrower than LangBot's. In particular, the current Apple Silicon wheels
|
||||
require macOS 15 or later. The default Chroma backend does not have this
|
||||
requirement.
|
||||
|
||||
## Install with pip/uv
|
||||
|
||||
You can also install LangBot as a regular Python package:
|
||||
@@ -20,6 +33,10 @@ pip install langbot
|
||||
|
||||
# Using uv
|
||||
uv pip install langbot
|
||||
|
||||
# Include optional SeekDB support
|
||||
pip install 'langbot[seekdb]'
|
||||
# or: uv pip install 'langbot[seekdb]'
|
||||
```
|
||||
|
||||
Then run it:
|
||||
@@ -101,7 +118,7 @@ uvx langbot
|
||||
|
||||
## System Requirements
|
||||
|
||||
- Python 3.10.1 or higher
|
||||
- Python 3.11 or higher (lower than Python 4)
|
||||
- Operating System: Linux, macOS, or Windows
|
||||
|
||||
## Differences from Source Installation
|
||||
|
||||
+34
-43
@@ -16,12 +16,20 @@ This document describes how to use OceanBase SeekDB as the vector database backe
|
||||
|
||||
## Installation
|
||||
|
||||
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
|
||||
SeekDB is an optional LangBot feature. A normal LangBot installation uses
|
||||
Chroma by default and does not install `pyseekdb` or its native bindings.
|
||||
|
||||
If you need to install it manually:
|
||||
Choose the command that matches how you run LangBot:
|
||||
|
||||
```bash
|
||||
pip install pyseekdb
|
||||
# PyPI / uvx
|
||||
uvx --from 'langbot[seekdb]@latest' langbot
|
||||
|
||||
# Installed package
|
||||
pip install 'langbot[seekdb]'
|
||||
|
||||
# Source checkout
|
||||
uv sync --extra seekdb
|
||||
```
|
||||
|
||||
## ⚠️ Platform Compatibility
|
||||
@@ -30,31 +38,36 @@ pip install pyseekdb
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
||||
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||
| Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
||||
| macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel |
|
||||
| macOS 14 or earlier on Apple Silicon | ❌ Not currently supported | The published native wheel requires macOS 15+; follow [oceanbase/seekdb#1324](https://github.com/oceanbase/seekdb/issues/1324) |
|
||||
| macOS on Intel | ❌ Not currently supported | No embedded binding is selected by `pyseekdb` |
|
||||
| Windows | ❌ Not currently supported | No Windows `pylibseekdb` wheel is published |
|
||||
|
||||
**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
|
||||
**Important**: Embedded mode requires a compatible `pylibseekdb` wheel. Do not
|
||||
force-install or retag a wheel built for a newer macOS release: the bundled
|
||||
binaries also declare macOS 15 as their minimum deployment target.
|
||||
|
||||
### Server Mode (Docker)
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Linux | ✅ Supported | Full Docker support |
|
||||
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
|
||||
| Windows | ⚠️ Untested | Should work but not yet tested |
|
||||
|
||||
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
|
||||
- Using ChromaDB or Qdrant as alternatives
|
||||
- Connecting to a remote SeekDB server on Linux if available
|
||||
| macOS | ✅ Supported by Docker Desktop | The previous slow-disk startup issue was fixed upstream in [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36) |
|
||||
| Windows | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation |
|
||||
|
||||
### Server Mode (Remote Connection)
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
|
||||
| Linux | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
|
||||
| macOS 15+ on Apple Silicon | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
|
||||
| macOS 14 or earlier on Apple Silicon | ⚠️ Blocked by upstream packaging | `pyseekdb` currently requires the unavailable native wheel even for server-only use; follow [#1324](https://github.com/oceanbase/seekdb/issues/1324) |
|
||||
| macOS on Intel / Windows | ✅ Server mode only | Embedded bindings are not available |
|
||||
|
||||
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
|
||||
Remote server mode does not use embedded storage at runtime. However, whether
|
||||
the Python client can be installed still depends on `pyseekdb`'s package
|
||||
metadata for the current platform.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -170,22 +183,23 @@ Key methods:
|
||||
|
||||
### Import Error
|
||||
|
||||
If you see: `ImportError: pyseekdb is not installed`
|
||||
If you see: `SeekDB support is not installed`
|
||||
|
||||
Solution:
|
||||
```bash
|
||||
pip install pyseekdb
|
||||
uv sync --extra seekdb
|
||||
# or: uvx --from 'langbot[seekdb]@latest' langbot
|
||||
```
|
||||
|
||||
### Embedded Mode Error on macOS/Windows
|
||||
### Embedded Mode Is Unavailable on the Current Platform
|
||||
|
||||
**Error**:
|
||||
```
|
||||
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
|
||||
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
|
||||
```
|
||||
|
||||
**Cause**: `pylibseekdb` is only available on Linux platforms.
|
||||
**Cause**: No compatible `pylibseekdb` wheel is installed for the current OS,
|
||||
CPU architecture, Python version, and macOS deployment target.
|
||||
|
||||
**Solution**: Use server mode instead:
|
||||
1. Deploy SeekDB on a Linux server or VM
|
||||
@@ -208,29 +222,6 @@ vdb:
|
||||
use: chroma # or qdrant
|
||||
```
|
||||
|
||||
### Docker Container Fails on macOS
|
||||
|
||||
**Symptoms**:
|
||||
```bash
|
||||
docker run -d -p 2881:2881 oceanbase/seekdb:latest
|
||||
# Container exits immediately with code 30
|
||||
```
|
||||
|
||||
**Error in logs**:
|
||||
```
|
||||
[ERROR] Code: Agent.SeekDB.Not.Exists
|
||||
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
|
||||
```
|
||||
|
||||
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
|
||||
|
||||
**Status**: Under investigation by OceanBase team.
|
||||
|
||||
**Workaround Options**:
|
||||
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
|
||||
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
|
||||
3. **Wait for fix**: Monitor the GitHub issue for updates
|
||||
|
||||
### Connection Error (Server Mode)
|
||||
|
||||
If SeekDB server is not reachable, check:
|
||||
|
||||
@@ -34,7 +34,6 @@ class Agent(Base):
|
||||
kind: str # 固定为 "agent"
|
||||
component_ref: str # AgentRunner id
|
||||
config: dict # runner + runner_config
|
||||
enabled: bool
|
||||
supported_event_patterns: list[str]
|
||||
```
|
||||
|
||||
@@ -113,7 +112,7 @@ Binding 只保存引用与路由条件。它不复制 Pipeline 或 Agent 配置
|
||||
|
||||
1. 忽略 `enabled = false` 的 binding。
|
||||
2. 检查 `event_pattern` 与结构化 filters。
|
||||
3. 校验目标存在、启用且声明支持该事件。
|
||||
3. 校验目标存在且声明支持该事件。
|
||||
4. 按 `priority` 从高到低选择;同优先级按稳定列表顺序。
|
||||
5. 只执行一个响应目标。
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ class Agent(Base):
|
||||
kind: str # 首版固定为 "agent"
|
||||
component_ref: str # runner id / workflow id / future external ref
|
||||
config: dict # runner 与 runner_config
|
||||
enabled: bool
|
||||
supported_event_patterns: list[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
+5
-1
@@ -69,7 +69,6 @@ dependencies = [
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.5.3",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
@@ -107,6 +106,11 @@ classifiers = [
|
||||
"Topic :: Communications :: Chat",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
seekdb = [
|
||||
"pyseekdb==1.1.0.post3",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://langbot.app"
|
||||
Documentation = "https://docs.langbot.app"
|
||||
|
||||
@@ -188,11 +188,7 @@ try {
|
||||
.getByText(/Event Routing|事件路由|イベントルーティング/)
|
||||
.first()
|
||||
.waitFor({ timeout: 15_000 });
|
||||
await page
|
||||
.getByText(
|
||||
/Events this adapter can receive|此适配器可接收的事件|このアダプターが受信できるイベント/,
|
||||
)
|
||||
.waitFor();
|
||||
await page.getByText(/Supported events|支持的事件|対応イベント/).waitFor();
|
||||
await page
|
||||
.getByText(/Message received|收到消息|メッセージを受信/)
|
||||
.first()
|
||||
@@ -216,11 +212,11 @@ try {
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /Test route|测试路由|ルートをテスト/ })
|
||||
.getByRole("button", { name: /Check route|检查路由|ルートを確認/ })
|
||||
.click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await page
|
||||
.getByRole("button", { name: /Preview route|预览路由|ルートをプレビュー/ })
|
||||
.getByRole("button", { name: /View match|查看匹配结果|一致結果を確認/ })
|
||||
.click();
|
||||
await page
|
||||
.getByText(/Route matched|已命中路由|ルートに一致しました/)
|
||||
@@ -231,28 +227,67 @@ try {
|
||||
.waitFor();
|
||||
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
|
||||
.getByRole("button", { name: /Close|关闭|閉じる/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
await page
|
||||
.getByText(/Discarded|已丢弃|破棄済み/)
|
||||
.first()
|
||||
.waitFor({ timeout: 10_000 });
|
||||
result.visible_signals.push("route-status-discarded");
|
||||
|
||||
const adapterConfigCard = page.locator('[data-slot="card"]').filter({
|
||||
has: page.getByText(/Adapter Configuration|适配器配置|アダプター設定/, {
|
||||
exact: true,
|
||||
}),
|
||||
});
|
||||
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);
|
||||
if (/\bEBA event\b/.test(text)) {
|
||||
@@ -308,7 +343,7 @@ try {
|
||||
}
|
||||
result.status = "pass";
|
||||
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) {
|
||||
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
|
||||
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 |
|
||||
| `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_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 |
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
area: bot
|
||||
type: feature
|
||||
@@ -33,16 +33,18 @@ steps:
|
||||
- "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."
|
||||
- "Open Test event route and run a dry-run against the current form."
|
||||
- "Run the saved runtime route with a synthetic event."
|
||||
- "Close the dialog and confirm the route card shows the latest discarded status."
|
||||
- "Open Platform event debugging from Adapter Configuration and send a real inbound event through the HTTP Bot adapter."
|
||||
- "Confirm the normalized event, raw event code, payload, and latest discarded route status are visible."
|
||||
checks:
|
||||
- "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: 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: 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."
|
||||
- "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."
|
||||
evidence_required:
|
||||
- ui
|
||||
@@ -51,7 +53,8 @@ evidence_required:
|
||||
- api_diagnostic
|
||||
diagnostics:
|
||||
- "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:
|
||||
- backend-not-listening
|
||||
- proxy-env-mismatch
|
||||
|
||||
@@ -48,6 +48,14 @@ CMD_RESPOND_MSG = 'aibot_respond_msg'
|
||||
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
|
||||
CMD_RESPOND_UPDATE = 'aibot_respond_update_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
|
||||
_STREAM_CACHE_MAX = 1024
|
||||
@@ -499,6 +507,145 @@ class WecomBotWsClient:
|
||||
body['chatid'] = chat_id
|
||||
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:
|
||||
"""Push a streaming chunk for a given message ID.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class AgentBindingResolver:
|
||||
event: AgentEventEnvelope,
|
||||
agents: list[AgentConfig],
|
||||
) -> 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
|
||||
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
|
||||
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:
|
||||
raise AgentBindingResolutionError(f'No Agent binding matches event_type={event.event_type}')
|
||||
@@ -59,7 +59,6 @@ class AgentBindingResolver:
|
||||
resource_policy=agent.resource_policy,
|
||||
state_policy=agent.state_policy,
|
||||
delivery_policy=agent.delivery_policy,
|
||||
enabled=agent.enabled,
|
||||
agent_id=agent.agent_id,
|
||||
processor_type=agent.processor_type,
|
||||
processor_id=agent.processor_id or agent.agent_id,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""Agent runner errors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AgentRunnerError(Exception):
|
||||
"""Base error for agent runner operations."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RunnerNotFoundError(AgentRunnerError):
|
||||
"""Runner not found in registry."""
|
||||
|
||||
def __init__(self, runner_id: str):
|
||||
self.runner_id = runner_id
|
||||
super().__init__(f'Agent runner not found: {runner_id}')
|
||||
@@ -16,6 +19,7 @@ class RunnerNotFoundError(AgentRunnerError):
|
||||
|
||||
class RunnerNotAuthorizedError(AgentRunnerError):
|
||||
"""Runner not authorized for this binding."""
|
||||
|
||||
def __init__(self, runner_id: str, bound_plugins: list[str] | None):
|
||||
self.runner_id = runner_id
|
||||
self.bound_plugins = bound_plugins
|
||||
@@ -24,6 +28,7 @@ class RunnerNotAuthorizedError(AgentRunnerError):
|
||||
|
||||
class RunnerProtocolError(AgentRunnerError):
|
||||
"""Runner protocol version mismatch or invalid manifest."""
|
||||
|
||||
def __init__(self, runner_id: str, message: str):
|
||||
self.runner_id = runner_id
|
||||
super().__init__(f'Agent runner protocol error for {runner_id}: {message}')
|
||||
@@ -31,7 +36,16 @@ class RunnerProtocolError(AgentRunnerError):
|
||||
|
||||
class RunnerExecutionError(AgentRunnerError):
|
||||
"""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.message = message
|
||||
self.retryable = retryable
|
||||
self.error_code = error_code
|
||||
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 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)
|
||||
"""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."""
|
||||
|
||||
enabled: bool = True
|
||||
"""Whether binding is enabled."""
|
||||
|
||||
agent_id: str | None = None
|
||||
"""Host-side Agent/config identifier for this binding."""
|
||||
|
||||
|
||||
@@ -58,21 +58,21 @@ class AgentRunnerInvoker:
|
||||
except asyncio.TimeoutError as e:
|
||||
raise RunnerExecutionError(
|
||||
descriptor.id,
|
||||
'Runner timed out (code: runner.timeout)',
|
||||
'Runner timed out',
|
||||
retryable=True,
|
||||
error_code='runner.timeout',
|
||||
) from e
|
||||
except ActionCallTimeoutError as e:
|
||||
raise RunnerExecutionError(
|
||||
descriptor.id,
|
||||
f'{e} (code: runner.timeout)',
|
||||
str(e),
|
||||
retryable=True,
|
||||
error_code='runner.timeout',
|
||||
) from e
|
||||
except RunnerExecutionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.ap.logger.error(
|
||||
f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}'
|
||||
)
|
||||
self.ap.logger.error(f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}')
|
||||
raise RunnerExecutionError(
|
||||
descriptor.id,
|
||||
str(e),
|
||||
|
||||
@@ -151,7 +151,6 @@ class QueryEntryAdapter:
|
||||
state_policy=state_policy,
|
||||
delivery_policy=delivery_policy,
|
||||
event_types=[event_type],
|
||||
enabled=True,
|
||||
metadata={'source': 'pipeline_adapter'},
|
||||
)
|
||||
|
||||
|
||||
@@ -152,10 +152,14 @@ class AgentResultNormalizer:
|
||||
error_msg = data.get('error', 'Unknown error')
|
||||
error_code = data.get('code', 'unknown')
|
||||
retryable = data.get('retryable', False)
|
||||
normalized_error_code = str(error_code or '').strip()
|
||||
raise RunnerExecutionError(
|
||||
descriptor.id,
|
||||
f'{error_msg} (code: {error_code})',
|
||||
str(error_msg),
|
||||
retryable=retryable,
|
||||
error_code=(
|
||||
normalized_error_code if normalized_error_code and normalized_error_code != 'unknown' else None
|
||||
),
|
||||
)
|
||||
|
||||
elif result_type == 'action.requested':
|
||||
|
||||
@@ -2,6 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from .....agent.runner.errors import (
|
||||
AgentRunnerError,
|
||||
RunnerExecutionError,
|
||||
RunnerNotAuthorizedError,
|
||||
RunnerNotFoundError,
|
||||
RunnerProtocolError,
|
||||
)
|
||||
from ...authz import Permission, require_permission
|
||||
from ...context import RequestContext
|
||||
from .. import group
|
||||
@@ -63,6 +70,36 @@ class AgentsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
except ValueError as 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)
|
||||
|
||||
@self.route(
|
||||
|
||||
@@ -90,15 +90,9 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
return self.success(
|
||||
data=await self.ap.bot_service.list_event_route_statuses(
|
||||
request_context, bot_uuid
|
||||
)
|
||||
)
|
||||
return self.success(data=await self.ap.bot_service.list_event_route_statuses(request_context, bot_uuid))
|
||||
|
||||
async def _dry_run_event_route(
|
||||
bot_uuid: str, request_context: RequestContext
|
||||
) -> str:
|
||||
async def _dry_run_event_route(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')
|
||||
@@ -128,24 +122,6 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)(_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(
|
||||
'/<bot_uuid>/send_message',
|
||||
methods=['POST'],
|
||||
|
||||
@@ -322,6 +322,7 @@ class UserRouterGroup(group.RouterGroup):
|
||||
if cloud_mode:
|
||||
capabilities['password_login_enabled'] = False
|
||||
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
|
||||
capabilities['invitation_registration_enabled'] = not cloud_mode
|
||||
return self.success(data={'initialized': True, **capabilities})
|
||||
|
||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
|
||||
@@ -201,7 +201,6 @@ class AgentService:
|
||||
enable_reply=False,
|
||||
enable_interactions=False,
|
||||
),
|
||||
enabled=True,
|
||||
agent_id=agent_uuid,
|
||||
processor_type='agent',
|
||||
processor_id=agent_uuid,
|
||||
@@ -293,7 +292,6 @@ class AgentService:
|
||||
'kind': AGENT_KIND_AGENT,
|
||||
'component_ref': runner_id,
|
||||
'config': config,
|
||||
'enabled': agent_data.get('enabled', True),
|
||||
'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))
|
||||
@@ -308,17 +306,11 @@ class AgentService:
|
||||
await self.ap.pipeline_service.update_pipeline(context, agent_uuid, agent_data)
|
||||
return
|
||||
|
||||
update_data = agent_data.copy()
|
||||
for protected_field in (
|
||||
'uuid',
|
||||
'workspace_uuid',
|
||||
'kind',
|
||||
'component_ref',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'capability',
|
||||
):
|
||||
update_data.pop(protected_field, None)
|
||||
update_data = {
|
||||
field: agent_data[field]
|
||||
for field in ('name', 'description', 'emoji', 'config', 'supported_event_patterns')
|
||||
if field in agent_data
|
||||
}
|
||||
if 'config' in update_data:
|
||||
config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(update_data['config'])
|
||||
update_data['config'] = config
|
||||
@@ -425,7 +417,6 @@ class AgentService:
|
||||
item = pipeline.copy()
|
||||
item['kind'] = AGENT_KIND_PIPELINE
|
||||
item['component_ref'] = 'pipeline'
|
||||
item['enabled'] = True
|
||||
item['supported_event_patterns'] = PIPELINE_EVENT_PATTERNS
|
||||
item['capability'] = {
|
||||
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
|
||||
|
||||
@@ -18,7 +18,6 @@ class BotService:
|
||||
|
||||
ap: app.Application
|
||||
FAILURE_ROUTE_NOT_FOUND = 'route_not_found'
|
||||
FAILURE_PROCESSOR_DISABLED = 'processor_disabled'
|
||||
FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found'
|
||||
FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible'
|
||||
FAILURE_INVALID_EVENT = 'invalid_event'
|
||||
@@ -276,14 +275,10 @@ class BotService:
|
||||
)
|
||||
return result.first()
|
||||
|
||||
async def _get_agent_entity(
|
||||
self, context: TenantContext, agent_uuid: str
|
||||
) -> persistence_agent.Agent | None:
|
||||
async def _get_agent_entity(self, context: TenantContext, agent_uuid: str) -> persistence_agent.Agent | None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_agent.Agent).where(
|
||||
persistence_agent.Agent.uuid == agent_uuid
|
||||
),
|
||||
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid),
|
||||
persistence_agent.Agent,
|
||||
context,
|
||||
)
|
||||
@@ -390,11 +385,7 @@ class BotService:
|
||||
}
|
||||
],
|
||||
)
|
||||
pipeline = (
|
||||
await self._get_pipeline_entity(tenant_context, target_uuid)
|
||||
if target_uuid
|
||||
else None
|
||||
)
|
||||
pipeline = await self._get_pipeline_entity(tenant_context, target_uuid) if target_uuid else None
|
||||
if pipeline is None:
|
||||
return self._diagnostic_result(
|
||||
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):
|
||||
return self._diagnostic_result(
|
||||
matched=False,
|
||||
@@ -509,9 +481,7 @@ class BotService:
|
||||
],
|
||||
)
|
||||
|
||||
async def _normalize_event_bindings(
|
||||
self, context: TenantContext, bindings: list[dict] | None
|
||||
) -> list[dict]:
|
||||
async def _normalize_event_bindings(self, context: TenantContext, bindings: list[dict] | None) -> list[dict]:
|
||||
"""Validate and normalize Bot event bindings."""
|
||||
if not bindings:
|
||||
return []
|
||||
@@ -544,9 +514,7 @@ class BotService:
|
||||
elif target_type == 'agent':
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_agent.Agent).where(
|
||||
persistence_agent.Agent.uuid == target_uuid
|
||||
),
|
||||
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid),
|
||||
persistence_agent.Agent,
|
||||
context,
|
||||
)
|
||||
@@ -577,9 +545,7 @@ class BotService:
|
||||
|
||||
return normalized
|
||||
|
||||
async def _prepare_bot_data(
|
||||
self, context: TenantContext, bot_data: dict, *, include_uuid: bool
|
||||
) -> dict:
|
||||
async def _prepare_bot_data(self, context: TenantContext, bot_data: dict, *, include_uuid: bool) -> dict:
|
||||
"""Normalize Bot write payloads to the current event-routing model."""
|
||||
update_data = bot_data.copy()
|
||||
if not include_uuid:
|
||||
@@ -705,6 +671,17 @@ class BotService:
|
||||
)
|
||||
if getattr(result, 'rowcount', None) == 0:
|
||||
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)
|
||||
|
||||
# select from db
|
||||
@@ -750,21 +727,19 @@ class BotService:
|
||||
|
||||
return [log.to_json() for log in logs], total_count
|
||||
|
||||
async def list_event_route_statuses(
|
||||
self, context: TenantContext, bot_uuid: str
|
||||
) -> dict[str, typing.Any]:
|
||||
async def list_event_route_statuses(self, context: TenantContext, bot_uuid: str) -> dict[str, typing.Any]:
|
||||
"""Return recent runtime status for Bot event routes from in-memory Bot logs."""
|
||||
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')
|
||||
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]] = {}
|
||||
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)
|
||||
if status is None:
|
||||
continue
|
||||
@@ -774,7 +749,10 @@ class BotService:
|
||||
else:
|
||||
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)
|
||||
routes: list[dict[str, typing.Any]] = []
|
||||
current_binding_ids: set[str] = set()
|
||||
@@ -819,61 +797,6 @@ class BotService:
|
||||
'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(
|
||||
self,
|
||||
context: TenantContext,
|
||||
|
||||
@@ -132,25 +132,6 @@ class LangBotMCPServer:
|
||||
async def list_bot_event_route_statuses(bot_uuid: str) -> str:
|
||||
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 ----------------------------------------------- #
|
||||
@mcp.tool(description='List all pipelines.')
|
||||
async def list_pipelines() -> str:
|
||||
|
||||
@@ -1225,8 +1225,9 @@ class BoxService:
|
||||
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Fallback: read the outbox over the exec channel (E2B / remote).
|
||||
|
||||
Note: exec stdout is truncated by ``output_limit_chars``, so this path
|
||||
only reliably transfers small files. The host path is preferred.
|
||||
Uses ``client.execute`` directly (bypassing ``_serialize_result``)
|
||||
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
|
||||
|
||||
@@ -1280,14 +1281,22 @@ class BoxService:
|
||||
' break\n'
|
||||
'print(json.dumps(out))\n'
|
||||
)
|
||||
result = await self.execute_tool(
|
||||
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120},
|
||||
query,
|
||||
)
|
||||
if not result.get('ok'):
|
||||
spec_payload: dict = {
|
||||
'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
|
||||
'timeout_sec': 120,
|
||||
'session_id': self.resolve_box_session_id(query),
|
||||
}
|
||||
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 []
|
||||
try:
|
||||
return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1])
|
||||
return _json.loads(str(result.stdout or '').strip().splitlines()[-1])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import typing
|
||||
import inspect
|
||||
|
||||
from ..api.http.context import ExecutionContext
|
||||
from ..core import app
|
||||
from . import operator
|
||||
from ..utils import importutil
|
||||
@@ -66,7 +67,14 @@ class CommandManager:
|
||||
|
||||
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', 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):
|
||||
await result
|
||||
|
||||
|
||||
@@ -315,11 +315,36 @@ class Application:
|
||||
async def initialize(self):
|
||||
pass
|
||||
|
||||
async def _initialize_plugin_runtime(self) -> None:
|
||||
try:
|
||||
await self.plugin_connector.initialize()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
||||
self.plugin_connector.schedule_reconnect()
|
||||
|
||||
def _start_plugin_runtime_initialization(self) -> asyncio.Task | None:
|
||||
task = getattr(self, '_plugin_runtime_initialization_task', None)
|
||||
if task is not None and not task.done():
|
||||
return task
|
||||
# This is application lifecycle work, not a request side effect. It must
|
||||
# not wait on PersistenceManager's after-commit gate at boot.
|
||||
task = asyncio.create_task(
|
||||
self._initialize_plugin_runtime(),
|
||||
name='plugin-runtime-initialization',
|
||||
)
|
||||
self._plugin_runtime_initialization_task = task
|
||||
return task
|
||||
|
||||
async def run(self):
|
||||
self.event_loop_monitor.start()
|
||||
try:
|
||||
if self.directory_projection_service is not None:
|
||||
self.task_mgr.create_task(
|
||||
if (
|
||||
self.directory_projection_service is not None
|
||||
and getattr(self, 'directory_projection_task', None) is None
|
||||
):
|
||||
self.directory_projection_task = self.task_mgr.create_task(
|
||||
self.directory_projection_service.run(),
|
||||
name='cloud-directory-projection',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
@@ -336,7 +361,6 @@ class Application:
|
||||
name='cloud-manifest-refresh',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
await self.plugin_connector.initialize_plugins()
|
||||
|
||||
# 后续可能会允许动态重启其他任务
|
||||
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
|
||||
@@ -362,6 +386,7 @@ class Application:
|
||||
name='http-api-controller',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
self._start_plugin_runtime_initialization()
|
||||
|
||||
# Telemetry instance heartbeat (startup + daily); respects
|
||||
# space.disable_telemetry via TelemetryManager.send().
|
||||
@@ -543,6 +568,11 @@ class Application:
|
||||
|
||||
if self.task_mgr is not None:
|
||||
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
|
||||
plugin_runtime_task = getattr(self, '_plugin_runtime_initialization_task', None)
|
||||
if plugin_runtime_task is not None and not plugin_runtime_task.done():
|
||||
plugin_runtime_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await plugin_runtime_task
|
||||
with contextlib.suppress(Exception):
|
||||
await self.event_loop_monitor.stop()
|
||||
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import stage, app
|
||||
from .. import stage, app, entities as core_entities
|
||||
from ...utils import version, proxy, constants
|
||||
from ...pipeline import pool, controller, pipelinemgr
|
||||
from ...pipeline import aggregator as message_aggregator
|
||||
@@ -297,14 +297,17 @@ class BuildAppStage(stage.BootingStage):
|
||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||
connector.schedule_reconnect()
|
||||
|
||||
if ap.directory_projection_service is not None:
|
||||
# Keep the projection fresh while shared Runtime cold restore runs.
|
||||
# BuildApp initializes the connector before Application.run() starts
|
||||
# its long-lived tasks, so start the single refresh task here.
|
||||
ap.directory_projection_task = ap.task_mgr.create_task(
|
||||
ap.directory_projection_service.run(),
|
||||
name='cloud-directory-projection',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
|
||||
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
||||
try:
|
||||
await plugin_connector_inst.initialize()
|
||||
except Exception as exc:
|
||||
# Keep the API/UI available while an external or managed runtime is
|
||||
# starting, then recover in the background with bounded backoff.
|
||||
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
||||
plugin_connector_inst.schedule_reconnect()
|
||||
ap.plugin_connector = plugin_connector_inst
|
||||
workspace_service_inst.release_startup_execution_bindings()
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ class Agent(Base):
|
||||
kind = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='agent')
|
||||
component_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
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=['*'])
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
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()
|
||||
return
|
||||
|
||||
self._enable_sqlite_foreign_keys()
|
||||
if self.mode == PersistenceMode.RELEASE_MIGRATION:
|
||||
async with self._release_migration_lock():
|
||||
await self._initialize_managed_schema()
|
||||
@@ -185,6 +184,7 @@ class PersistenceManager:
|
||||
return
|
||||
|
||||
await self._initialize_managed_schema()
|
||||
await self._enable_sqlite_foreign_keys_after_migration()
|
||||
|
||||
if self.mode == PersistenceMode.OSS_COMPAT:
|
||||
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)
|
||||
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:
|
||||
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
||||
engine = self.get_db_engine()
|
||||
|
||||
@@ -12,6 +12,7 @@ import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import time
|
||||
import typing
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _fsync_file(path: pathlib.Path) -> None:
|
||||
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
||||
"""Sync a file, tolerating delayed visibility after replace on bind mounts."""
|
||||
|
||||
descriptor: int | None = None
|
||||
for attempt in range(reopen_attempts):
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
break
|
||||
except FileNotFoundError:
|
||||
if attempt + 1 >= reopen_attempts:
|
||||
raise
|
||||
time.sleep(0.05)
|
||||
assert descriptor is not None
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
|
||||
@@ -25,7 +25,7 @@ class BanWordFilter(filter_model.ContentFilter):
|
||||
return entities.FilterResult(
|
||||
level=entities.ResultLevel.BLOCK,
|
||||
replacement='',
|
||||
user_notice='内容检查规则执行失败,请联系管理员',
|
||||
user_notice='内容安全检查配置有误,请检查敏感词设置',
|
||||
console_notice=f'Sensitive-word regex rejected: {exc}',
|
||||
)
|
||||
|
||||
|
||||
@@ -158,6 +158,18 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
result_type=entities.ResultType.CONTINUE,
|
||||
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: # 有函数调用
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""运行时机器人"""
|
||||
|
||||
@@ -568,126 +348,6 @@ class RuntimeBot:
|
||||
"""Return the selected event binding plus per-binding diagnostic steps."""
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@@ -784,6 +444,26 @@ class RuntimeBot:
|
||||
compact[key] = value
|
||||
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
|
||||
def _get_entity_id(entity: typing.Any) -> str | None:
|
||||
entity_id = getattr(entity, 'id', None)
|
||||
@@ -1135,7 +815,6 @@ class RuntimeBot:
|
||||
enable_reply=True,
|
||||
enable_interactions=True,
|
||||
),
|
||||
enabled=True,
|
||||
agent_id=agent.get('uuid'),
|
||||
processor_type='agent',
|
||||
processor_id=agent.get('uuid'),
|
||||
@@ -1171,7 +850,7 @@ class RuntimeBot:
|
||||
self,
|
||||
envelope: AgentEventEnvelope,
|
||||
outputs: list[provider_message.Message | provider_message.MessageChunk],
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | SyntheticRouteTestAdapter | None = None,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | None = None,
|
||||
) -> None:
|
||||
if not outputs or not envelope.delivery.reply_target:
|
||||
return
|
||||
@@ -1205,11 +884,13 @@ class RuntimeBot:
|
||||
event: platform_events.EBAEvent,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
) -> 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':
|
||||
await self._handle_interaction_submission(event, adapter)
|
||||
return
|
||||
|
||||
event.bot_uuid = self.bot_entity.uuid
|
||||
plugin_event = self._eba_event_to_plugin_event(event)
|
||||
|
||||
if plugin_event is not None:
|
||||
@@ -1322,17 +1003,6 @@ class RuntimeBot:
|
||||
reason='Agent target not found',
|
||||
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):
|
||||
return await self._record_event_route_trace(
|
||||
event_type=event_type,
|
||||
@@ -1711,7 +1381,7 @@ class RuntimeBot:
|
||||
self.execution_context,
|
||||
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"]}')
|
||||
|
||||
binding = self._agent_product_to_binding(
|
||||
@@ -1810,9 +1480,7 @@ class RuntimeBot:
|
||||
def tenant_scoped_listener(listener):
|
||||
@functools.wraps(listener)
|
||||
async def wrapped(*args, **kwargs):
|
||||
tenant_scope = getattr(
|
||||
self.ap.persistence_mgr, 'tenant_scope', None
|
||||
)
|
||||
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
|
||||
cloud_runtime = (
|
||||
getattr(
|
||||
getattr(self.ap.persistence_mgr, 'mode', None),
|
||||
@@ -1823,9 +1491,7 @@ class RuntimeBot:
|
||||
)
|
||||
if cloud_runtime:
|
||||
if not callable(tenant_scope):
|
||||
raise RuntimeError(
|
||||
'Cloud platform callbacks require a tenant scope'
|
||||
)
|
||||
raise RuntimeError('Cloud platform callbacks require a tenant scope')
|
||||
async with tenant_scope(self.workspace_uuid):
|
||||
return await listener(*args, **kwargs)
|
||||
return await listener(*args, **kwargs)
|
||||
|
||||
@@ -232,7 +232,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
@staticmethod
|
||||
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 = []
|
||||
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
|
||||
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
|
||||
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:
|
||||
message_chain_obj: 消息链对象列表
|
||||
@@ -606,6 +609,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream'
|
||||
|
||||
component['base64'] = f'data:{mime_type};base64,{base64_str}'
|
||||
if comp_type != 'Image':
|
||||
await storage_mgr.delete_scoped_object_key(
|
||||
execution_context,
|
||||
comp_path,
|
||||
|
||||
@@ -3,8 +3,10 @@ import typing
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
import base64
|
||||
|
||||
import datetime
|
||||
|
||||
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.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):
|
||||
@staticmethod
|
||||
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:
|
||||
if type(msg) is platform_message.Plain:
|
||||
content += msg.text
|
||||
return content
|
||||
items.append({'type': 'text', 'text': msg.text})
|
||||
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
|
||||
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(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
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)
|
||||
|
||||
event = message_source.source_platform_object
|
||||
@@ -382,7 +460,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
else:
|
||||
chat_id = str(message_source.sender.id)
|
||||
try:
|
||||
await self.bot.send_message(chat_id, content)
|
||||
await self.bot.send_message(chat_id, text)
|
||||
except Exception:
|
||||
await self.logger.error(
|
||||
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
|
||||
@@ -396,12 +474,15 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
if _ws_mode:
|
||||
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
||||
if text:
|
||||
if req_id:
|
||||
await self.bot.reply_text(req_id, content)
|
||||
await self.bot.reply_text(req_id, text)
|
||||
else:
|
||||
await self.bot.set_message(event.message_id, content)
|
||||
await self.bot.set_message(event.message_id, text)
|
||||
for item in self._iter_media_components(items):
|
||||
await self._send_media(self.bot, req_id, item)
|
||||
else:
|
||||
await self.bot.set_message(event.message_id, content)
|
||||
await self.bot.set_message(event.message_id, text)
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
@@ -411,7 +492,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
quote_origin: 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)
|
||||
|
||||
# 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.
|
||||
spo = message_source.source_platform_object
|
||||
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
|
||||
|
||||
@@ -452,7 +534,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
form_data.get('actions', []) or [],
|
||||
)
|
||||
except Exception:
|
||||
fallback = content or '(人工输入)'
|
||||
fallback = text or '(人工输入)'
|
||||
if _ws_mode:
|
||||
event = message_source.source_platform_object
|
||||
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}
|
||||
|
||||
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:
|
||||
event = message_source.source_platform_object
|
||||
req_id = event.get('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}
|
||||
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:
|
||||
await self.bot.set_message(msg_id, content)
|
||||
await self.bot.set_message(msg_id, text)
|
||||
return {'stream': success}
|
||||
|
||||
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):
|
||||
_ws_mode = not self.config.get('enable-webhook', False)
|
||||
if _ws_mode:
|
||||
content = await self.message_converter.yiri2target(message)
|
||||
await self.bot.send_message(target_id, content)
|
||||
items = await self.message_converter.yiri2target(message)
|
||||
text = self._join_text_components(items)
|
||||
await self.bot.send_message(target_id, text)
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
@@ -701,7 +701,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
}
|
||||
self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
|
||||
|
||||
result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values()))
|
||||
reconcile_timeout_seconds = max(
|
||||
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
|
||||
)
|
||||
result = await runtime_handler.reconcile_plugin_installations(
|
||||
tuple(self._known_desired_states.values()),
|
||||
timeout=reconcile_timeout_seconds,
|
||||
)
|
||||
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
|
||||
self._record_reconcile_failures(self._known_desired_states, result)
|
||||
|
||||
@@ -736,7 +742,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
if state.binding.installation_uuid in all_states:
|
||||
raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
|
||||
all_states[state.binding.installation_uuid] = state
|
||||
result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values()))
|
||||
reconcile_timeout_seconds = max(
|
||||
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
|
||||
)
|
||||
result = await runtime_handler.reconcile_plugin_installations(
|
||||
tuple(all_states.values()),
|
||||
timeout=reconcile_timeout_seconds,
|
||||
)
|
||||
await self._repair_reconcile_missing_artifacts(all_states, result)
|
||||
self._record_reconcile_failures(all_states, result)
|
||||
for installation_uuid, previous in tuple(self._known_desired_states.items()):
|
||||
|
||||
@@ -13,6 +13,8 @@ from dataclasses import dataclass
|
||||
|
||||
import pydantic
|
||||
import sqlalchemy
|
||||
import sqlalchemy.dialects.postgresql
|
||||
import sqlalchemy.dialects.sqlite
|
||||
|
||||
from langbot_plugin.runtime.io import handler
|
||||
from langbot_plugin.runtime.io.connection import Connection
|
||||
@@ -832,6 +834,19 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
return f'{identity.plugin_author}/{identity.plugin_name}'
|
||||
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
|
||||
def _binary_storage_key(
|
||||
cls,
|
||||
@@ -1661,17 +1676,70 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||
.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(
|
||||
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)
|
||||
)
|
||||
else:
|
||||
return handler.ActionResponse.success(data={})
|
||||
|
||||
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
|
||||
insert = {
|
||||
'postgresql': sqlalchemy.dialects.postgresql.insert,
|
||||
'sqlite': sqlalchemy.dialects.sqlite.insert,
|
||||
}.get(dialect_name)
|
||||
if insert is None:
|
||||
return handler.ActionResponse.error(message=f'Unsupported storage database dialect: {dialect_name}')
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(
|
||||
insert(persistence_bstorage.BinaryStorage)
|
||||
.values(
|
||||
workspace_uuid=action_context.workspace_uuid,
|
||||
unique_key=unique_key,
|
||||
key=key,
|
||||
@@ -1679,6 +1747,10 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
owner=owner,
|
||||
value=value,
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=['workspace_uuid', 'unique_key'],
|
||||
set_={'value': value},
|
||||
)
|
||||
)
|
||||
|
||||
return handler.ActionResponse.success(
|
||||
@@ -1722,6 +1794,29 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
)
|
||||
|
||||
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:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Storage with key {key} not found',
|
||||
@@ -1768,10 +1863,19 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
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(
|
||||
sqlalchemy.delete(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.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(
|
||||
@@ -1810,7 +1914,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
|
||||
return handler.ActionResponse.success(
|
||||
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(
|
||||
self,
|
||||
installations: tuple[PluginInstallationDesiredState, ...],
|
||||
*,
|
||||
timeout: float = 300,
|
||||
) -> dict[str, Any]:
|
||||
request = ReconcilePluginInstallationsRequest(installations=installations)
|
||||
with self.installation_scope(None):
|
||||
return await self.call_action(
|
||||
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
|
||||
request.model_dump(),
|
||||
timeout=300,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def apply_plugin_installation(
|
||||
|
||||
@@ -24,7 +24,10 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
|
||||
try:
|
||||
import pyseekdb
|
||||
except ImportError:
|
||||
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
|
||||
raise ImportError(
|
||||
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
|
||||
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
|
||||
)
|
||||
|
||||
self._embedding_function = pyseekdb.get_default_embedding_function()
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ from collections.abc import Sequence
|
||||
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_INPUT_CHARS = 1024 * 1024
|
||||
MAX_REPLACEMENT_CHARS = 64
|
||||
|
||||
@@ -42,7 +42,10 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
if not SEEKDB_AVAILABLE:
|
||||
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
|
||||
raise ImportError(
|
||||
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
|
||||
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
|
||||
)
|
||||
|
||||
self.ap = ap
|
||||
config = self.ap.instance_config.data['vdb']['seekdb']
|
||||
|
||||
@@ -181,6 +181,11 @@ vdb:
|
||||
host: localhost
|
||||
port: 6333
|
||||
api_key: ''
|
||||
# SeekDB is optional. Native/package installs need the `seekdb` extra:
|
||||
# `uv sync --extra seekdb` (source) or
|
||||
# `uvx --from 'langbot[seekdb]@latest' langbot` (PyPI).
|
||||
# The official Docker image already includes it.
|
||||
# Embedded-mode platform support depends on the native pylibseekdb wheels.
|
||||
seekdb:
|
||||
mode: embedded # 'embedded' or 'server'
|
||||
# Embedded mode options:
|
||||
|
||||
@@ -171,18 +171,6 @@ def fake_bot_app():
|
||||
'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()
|
||||
|
||||
# Platform manager
|
||||
@@ -373,35 +361,6 @@ class TestBotEventRouteStatusEndpoint:
|
||||
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')
|
||||
class TestBotSendMessageEndpoint:
|
||||
"""Tests for bot send message endpoint."""
|
||||
|
||||
@@ -307,6 +307,7 @@ class TestUserInitEndpoint:
|
||||
assert data['data'] == {
|
||||
'initialized': True,
|
||||
'authenticated_invitation_acceptance_enabled': False,
|
||||
'invitation_registration_enabled': True,
|
||||
'password_login_enabled': True,
|
||||
'space_login_enabled': False,
|
||||
}
|
||||
@@ -330,6 +331,28 @@ class TestUserInitEndpoint:
|
||||
assert data['data'] == {
|
||||
'initialized': True,
|
||||
'authenticated_invitation_acceptance_enabled': True,
|
||||
'invitation_registration_enabled': False,
|
||||
'password_login_enabled': False,
|
||||
'space_login_enabled': True,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_info_enables_local_invitation_registration_for_oauth_only_oss(
|
||||
self, quart_test_client, fake_api_app
|
||||
):
|
||||
fake_api_app.user_service.is_initialized.return_value = True
|
||||
fake_api_app.user_service.get_login_capabilities = AsyncMock(
|
||||
return_value={'password_login_enabled': False, 'space_login_enabled': True}
|
||||
)
|
||||
|
||||
response = await quart_test_client.get('/api/v1/user/account-info')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['data'] == {
|
||||
'initialized': True,
|
||||
'authenticated_invitation_acceptance_enabled': False,
|
||||
'invitation_registration_enabled': True,
|
||||
'password_login_enabled': False,
|
||||
'space_login_enabled': True,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oss_local_only_owner_requires_space_binding_for_langbot_models(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.user_service.get_workspace_owner = AsyncMock(
|
||||
return_value=SimpleNamespace(user='owner@example.com', space_account_uuid=None)
|
||||
)
|
||||
application.space_service.get_credits = AsyncMock()
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/user/space-credits',
|
||||
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
||||
)
|
||||
payload = await response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert payload['data'] == {
|
||||
'credits': None,
|
||||
'owner_space_bound': False,
|
||||
'is_workspace_owner': True,
|
||||
}
|
||||
application.space_service.get_credits.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
|
||||
@@ -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('owner_type', sa.String(255), nullable=False),
|
||||
sa.Column('owner', sa.String(255), nullable=False),
|
||||
sa.Column('value', sa.LargeBinary, nullable=False),
|
||||
)
|
||||
mcp_servers = _uuid_table(
|
||||
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(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
|
||||
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(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
|
||||
|
||||
@@ -108,7 +108,7 @@ class TestSQLiteMigrationUpgrade:
|
||||
await run_alembic_upgrade(sqlite_engine, '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
|
||||
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')
|
||||
|
||||
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
|
||||
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_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
|
||||
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['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 (
|
||||
await conn.scalar(
|
||||
sa.text(
|
||||
@@ -209,8 +229,8 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
'INSERT INTO binary_storages '
|
||||
'(workspace_uuid, unique_key, key, owner_type, owner) '
|
||||
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')"
|
||||
'(workspace_uuid, unique_key, key, owner_type, owner, value) '
|
||||
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo', X'')"
|
||||
),
|
||||
{'workspace_uuid': second_workspace_uuid},
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
|
||||
@@ -9,7 +10,7 @@ import pytest
|
||||
import sqlalchemy as sa
|
||||
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 .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()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
|
||||
database_path = tmp_path / 'legacy-bind-mount.db'
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||
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:
|
||||
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(
|
||||
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},
|
||||
)
|
||||
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},
|
||||
)
|
||||
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')
|
||||
|
||||
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 workspace_uuid FROM tenant_rows"))).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
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@@ -452,6 +456,45 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
|
||||
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):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
||||
try:
|
||||
@@ -466,7 +509,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||
assert instance_uuid
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO workspace_metadata (workspace_uuid, key, value) "
|
||||
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
|
||||
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
||||
),
|
||||
{'workspace_uuid': old_uuid},
|
||||
@@ -474,7 +517,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
"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},
|
||||
)
|
||||
@@ -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)
|
||||
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(
|
||||
assert (
|
||||
await conn.scalar(
|
||||
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
||||
) == expected_uuid
|
||||
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:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@@ -56,18 +56,6 @@ def build_ap() -> SimpleNamespace:
|
||||
ap.bot_service = SimpleNamespace(
|
||||
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': []}),
|
||||
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.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[]))
|
||||
@@ -126,7 +114,7 @@ async def main() -> int:
|
||||
tools = await session.list_tools()
|
||||
names = [t.name for t in tools.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:
|
||||
failures.append(f'missing tool {required}')
|
||||
|
||||
@@ -144,20 +132,6 @@ async def main() -> int:
|
||||
else:
|
||||
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()
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.wait_for(server_task, timeout=5)
|
||||
|
||||
@@ -74,7 +74,6 @@ class TestContextValidation:
|
||||
runner_id='plugin:test/plugin/runner',
|
||||
runner_config={'timeout': 300},
|
||||
agent_id='pipeline_1',
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
def _make_resources(self) -> BuilderResources:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for EventLog, Transcript, and history/event APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
@@ -24,45 +25,46 @@ from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryCo
|
||||
|
||||
|
||||
def make_event_envelope(
|
||||
event_id: str = "evt_1",
|
||||
event_type: str = "message.received",
|
||||
conversation_id: str | None = "conv_1",
|
||||
actor_id: str | None = "user_1",
|
||||
input_text: str = "Hello",
|
||||
event_id: str = 'evt_1',
|
||||
event_type: str = 'message.received',
|
||||
conversation_id: str | None = 'conv_1',
|
||||
actor_id: str | None = 'user_1',
|
||||
input_text: str = 'Hello',
|
||||
) -> AgentEventEnvelope:
|
||||
"""Create a test event envelope."""
|
||||
return AgentEventEnvelope(
|
||||
event_id=event_id,
|
||||
event_type=event_type,
|
||||
event_time=1700000000,
|
||||
source="platform",
|
||||
bot_id="bot_1",
|
||||
source='platform',
|
||||
bot_id='bot_1',
|
||||
workspace_id=None,
|
||||
conversation_id=conversation_id,
|
||||
thread_id=None,
|
||||
actor=ActorContext(
|
||||
actor_type="user",
|
||||
actor_type='user',
|
||||
actor_id=actor_id,
|
||||
actor_name="Test User",
|
||||
) if actor_id else None,
|
||||
actor_name='Test User',
|
||||
)
|
||||
if actor_id
|
||||
else None,
|
||||
subject=None,
|
||||
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."""
|
||||
return AgentBinding(
|
||||
binding_id="binding_1",
|
||||
scope=BindingScope(scope_type="agent", scope_id="pipeline_1"),
|
||||
event_types=["message.received"],
|
||||
binding_id='binding_1',
|
||||
scope=BindingScope(scope_type='agent', scope_id='pipeline_1'),
|
||||
event_types=['message.received'],
|
||||
runner_id=runner_id,
|
||||
runner_config={},
|
||||
resource_policy=ResourcePolicy(),
|
||||
state_policy=StatePolicy(),
|
||||
delivery_policy=DeliveryPolicy(),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -84,19 +86,19 @@ class TestEventLogStore:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
event_id = await store.append_event(
|
||||
event_id="evt_1",
|
||||
event_type="message.received",
|
||||
source="platform",
|
||||
bot_id="bot_1",
|
||||
conversation_id="conv_1",
|
||||
actor_type="user",
|
||||
actor_id="user_1",
|
||||
input_summary="Hello world",
|
||||
run_id="run_1",
|
||||
runner_id="plugin:test/plugin/runner",
|
||||
event_id='evt_1',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
bot_id='bot_1',
|
||||
conversation_id='conv_1',
|
||||
actor_type='user',
|
||||
actor_id='user_1',
|
||||
input_summary='Hello world',
|
||||
run_id='run_1',
|
||||
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]
|
||||
assert stored_event.metadata_json is None
|
||||
|
||||
@@ -115,20 +117,20 @@ class TestEventLogStore:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
event_id = await store.append_event(
|
||||
event_id="evt_steering",
|
||||
event_type="message.received",
|
||||
source="platform",
|
||||
run_id="run_1",
|
||||
runner_id="plugin:test/plugin/runner",
|
||||
event_id='evt_steering',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
run_id='run_1',
|
||||
runner_id='plugin:test/plugin/runner',
|
||||
metadata={
|
||||
"steering": {
|
||||
"status": "queued",
|
||||
"claimed_by_run_id": "run_1",
|
||||
'steering': {
|
||||
'status': 'queued',
|
||||
'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]
|
||||
assert '"status": "queued"' 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:
|
||||
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="evt_2",
|
||||
event_type="message.received",
|
||||
source="platform",
|
||||
event_id='evt_2',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
input_summary=long_text,
|
||||
)
|
||||
|
||||
assert event_id == "evt_2"
|
||||
assert event_id == 'evt_2'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
|
||||
items, next_seq, has_more = await store.page_events(
|
||||
conversation_id="conv_1",
|
||||
conversation_id='conv_1',
|
||||
limit=10,
|
||||
)
|
||||
|
||||
@@ -202,10 +204,10 @@ class TestTranscriptStore:
|
||||
|
||||
transcript_id = await store.append_transcript(
|
||||
transcript_id=None, # Auto-generate
|
||||
event_id="evt_1",
|
||||
conversation_id="conv_1",
|
||||
role="user",
|
||||
content="Hello",
|
||||
event_id='evt_1',
|
||||
conversation_id='conv_1',
|
||||
role='user',
|
||||
content='Hello',
|
||||
)
|
||||
|
||||
assert transcript_id is not None
|
||||
@@ -227,13 +229,11 @@ class TestTranscriptStore:
|
||||
|
||||
transcript_id = await store.append_transcript(
|
||||
transcript_id=None, # Auto-generate
|
||||
event_id="evt_2",
|
||||
conversation_id="conv_1",
|
||||
role="assistant",
|
||||
event_id='evt_2',
|
||||
conversation_id='conv_1',
|
||||
role='assistant',
|
||||
content="Here's an image",
|
||||
attachment_refs=[
|
||||
{"id": "att_1", "type": "image", "url": "http://example.com/img.png"}
|
||||
],
|
||||
attachment_refs=[{'id': 'att_1', 'type': 'image', 'url': 'http://example.com/img.png'}],
|
||||
)
|
||||
|
||||
assert transcript_id is not None
|
||||
@@ -255,9 +255,9 @@ class TestTranscriptStore:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
items, next_seq, prev_seq, has_more = await store.page_transcript(
|
||||
conversation_id="conv_1",
|
||||
conversation_id='conv_1',
|
||||
limit=10,
|
||||
direction="backward",
|
||||
direction='backward',
|
||||
)
|
||||
|
||||
assert isinstance(items, list)
|
||||
@@ -280,7 +280,7 @@ class TestTranscriptStore:
|
||||
|
||||
# Request more than the hard limit
|
||||
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
|
||||
)
|
||||
|
||||
@@ -304,8 +304,8 @@ class TestTranscriptStore:
|
||||
mock_factory.return_value.__aenter__.return_value = mock_session
|
||||
|
||||
items = await store.search_transcript(
|
||||
conversation_id="conv_1",
|
||||
query_text="database",
|
||||
conversation_id='conv_1',
|
||||
query_text='database',
|
||||
top_k=10,
|
||||
)
|
||||
|
||||
@@ -323,11 +323,11 @@ class TestHistoryPageAuthorization:
|
||||
# Mock call_action to simulate the handler
|
||||
result = await mock_handler.call_action(
|
||||
PluginToRuntimeAction.HISTORY_PAGE,
|
||||
{"run_id": None},
|
||||
{'run_id': None},
|
||||
)
|
||||
|
||||
# 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
|
||||
async def test_history_page_validates_conversation_scope(self, mock_db_engine):
|
||||
@@ -337,20 +337,20 @@ class TestHistoryPageAuthorization:
|
||||
session_registry = get_session_registry()
|
||||
|
||||
await session_registry.register(
|
||||
run_id="run_1",
|
||||
runner_id="plugin:test/plugin/runner",
|
||||
run_id='run_1',
|
||||
runner_id='plugin:test/plugin/runner',
|
||||
query_id=None,
|
||||
plugin_identity="test/plugin",
|
||||
resources={"models": [], "tools": [], "knowledge_bases": [], "storage": {"plugin_storage": True}},
|
||||
conversation_id="conv_1",
|
||||
plugin_identity='test/plugin',
|
||||
resources={'models': [], 'tools': [], 'knowledge_bases': [], 'storage': {'plugin_storage': True}},
|
||||
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["authorization"]["conversation_id"] == "conv_1"
|
||||
assert session['authorization']['conversation_id'] == 'conv_1'
|
||||
|
||||
# Cleanup
|
||||
await session_registry.unregister("run_1")
|
||||
await session_registry.unregister('run_1')
|
||||
|
||||
|
||||
class TestEventGetAuthorization:
|
||||
@@ -363,11 +363,11 @@ class TestEventGetAuthorization:
|
||||
|
||||
result = await mock_handler.call_action(
|
||||
PluginToRuntimeAction.EVENT_GET,
|
||||
{"run_id": None, "event_id": "evt_1"},
|
||||
{'run_id': None, 'event_id': 'evt_1'},
|
||||
)
|
||||
|
||||
# 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:
|
||||
@@ -389,7 +389,7 @@ class TestContextAccessPopulation:
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
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
|
||||
assert cursor is None or isinstance(cursor, str)
|
||||
|
||||
@@ -409,7 +409,7 @@ class TestContextAccessPopulation:
|
||||
with patch.object(store, '_session_factory') as mock_factory:
|
||||
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)
|
||||
|
||||
|
||||
@@ -422,7 +422,7 @@ class TestEventLogStoreRealSQLite:
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
|
||||
# Create tables
|
||||
async with engine.begin() as conn:
|
||||
@@ -439,30 +439,30 @@ class TestEventLogStoreRealSQLite:
|
||||
|
||||
# Append event
|
||||
event_id = await store.append_event(
|
||||
event_id="evt_real_001",
|
||||
event_type="message.received",
|
||||
source="platform",
|
||||
bot_id="bot_001",
|
||||
conversation_id="conv_001",
|
||||
actor_type="user",
|
||||
actor_id="user_001",
|
||||
actor_name="Test User",
|
||||
input_summary="Hello world",
|
||||
run_id="run_001",
|
||||
runner_id="plugin:test/plugin/runner",
|
||||
event_id='evt_real_001',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
bot_id='bot_001',
|
||||
conversation_id='conv_001',
|
||||
actor_type='user',
|
||||
actor_id='user_001',
|
||||
actor_name='Test User',
|
||||
input_summary='Hello world',
|
||||
run_id='run_001',
|
||||
runner_id='plugin:test/plugin/runner',
|
||||
)
|
||||
|
||||
assert event_id == "evt_real_001"
|
||||
assert event_id == 'evt_real_001'
|
||||
|
||||
# Get event
|
||||
event = await store.get_event(event_id)
|
||||
assert event is not None
|
||||
assert event["event_id"] == "evt_real_001"
|
||||
assert event["event_type"] == "message.received"
|
||||
assert event["source"] == "platform"
|
||||
assert event["conversation_id"] == "conv_001"
|
||||
assert event["actor_type"] == "user"
|
||||
assert event["actor_id"] == "user_001"
|
||||
assert event['event_id'] == 'evt_real_001'
|
||||
assert event['event_type'] == 'message.received'
|
||||
assert event['source'] == 'platform'
|
||||
assert event['conversation_id'] == 'conv_001'
|
||||
assert event['actor_type'] == 'user'
|
||||
assert event['actor_id'] == 'user_001'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_page_events(self, db_engine):
|
||||
@@ -472,16 +472,16 @@ class TestEventLogStoreRealSQLite:
|
||||
# Append multiple events
|
||||
for i in range(5):
|
||||
await store.append_event(
|
||||
event_id=f"evt_real_{i:03d}",
|
||||
event_type="message.received",
|
||||
source="platform",
|
||||
conversation_id="conv_001",
|
||||
input_summary=f"Message {i}",
|
||||
event_id=f'evt_real_{i:03d}',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
conversation_id='conv_001',
|
||||
input_summary=f'Message {i}',
|
||||
)
|
||||
|
||||
# Page events
|
||||
items, next_seq, has_more = await store.page_events(
|
||||
conversation_id="conv_001",
|
||||
conversation_id='conv_001',
|
||||
limit=3,
|
||||
)
|
||||
|
||||
@@ -496,14 +496,14 @@ class TestEventLogStoreRealSQLite:
|
||||
# Append events
|
||||
for i in range(3):
|
||||
await store.append_event(
|
||||
event_id=f"evt_cursor_{i:03d}",
|
||||
event_type="message.received",
|
||||
source="platform",
|
||||
conversation_id="conv_cursor",
|
||||
event_id=f'evt_cursor_{i:03d}',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
conversation_id='conv_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 int(cursor) > 0
|
||||
|
||||
@@ -516,26 +516,26 @@ class TestEventLogStoreRealSQLite:
|
||||
store = EventLogStore(db_engine)
|
||||
cutoff = datetime.datetime.utcnow()
|
||||
await store.append_event(
|
||||
event_id="evt_cleanup_old",
|
||||
event_type="message.received",
|
||||
source="platform",
|
||||
conversation_id="conv_cleanup",
|
||||
event_id='evt_cleanup_old',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
conversation_id='conv_cleanup',
|
||||
)
|
||||
await store.append_event(
|
||||
event_id="evt_cleanup_new",
|
||||
event_type="message.received",
|
||||
source="platform",
|
||||
conversation_id="conv_cleanup",
|
||||
event_id='evt_cleanup_new',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
conversation_id='conv_cleanup',
|
||||
)
|
||||
async with store._session_factory() as session:
|
||||
await session.execute(
|
||||
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))
|
||||
)
|
||||
await session.execute(
|
||||
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))
|
||||
)
|
||||
await session.commit()
|
||||
@@ -543,8 +543,8 @@ class TestEventLogStoreRealSQLite:
|
||||
removed = await store.cleanup_events_older_than(cutoff)
|
||||
|
||||
assert removed == 1
|
||||
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_old') is None
|
||||
assert await store.get_event('evt_cleanup_new') is not None
|
||||
|
||||
|
||||
class TestTranscriptStoreRealSQLite:
|
||||
@@ -556,7 +556,7 @@ class TestTranscriptStoreRealSQLite:
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
|
||||
# Create tables
|
||||
async with engine.begin() as conn:
|
||||
@@ -574,21 +574,21 @@ class TestTranscriptStoreRealSQLite:
|
||||
# Append transcript items
|
||||
for i in range(3):
|
||||
await store.append_transcript(
|
||||
transcript_id=f"trans_real_{i:03d}",
|
||||
event_id=f"evt_{i:03d}",
|
||||
conversation_id="conv_001",
|
||||
role="user" if i % 2 == 0 else "assistant",
|
||||
content=f"Message {i}",
|
||||
transcript_id=f'trans_real_{i:03d}',
|
||||
event_id=f'evt_{i:03d}',
|
||||
conversation_id='conv_001',
|
||||
role='user' if i % 2 == 0 else 'assistant',
|
||||
content=f'Message {i}',
|
||||
)
|
||||
|
||||
# Page transcript
|
||||
items, next_seq, prev_seq, has_more = await store.page_transcript(
|
||||
conversation_id="conv_001",
|
||||
conversation_id='conv_001',
|
||||
limit=10,
|
||||
)
|
||||
|
||||
assert len(items) == 3
|
||||
assert items[0]["conversation_id"] == "conv_001"
|
||||
assert items[0]['conversation_id'] == 'conv_001'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_legacy_provider_messages_projects_transcript_history(self, db_engine):
|
||||
@@ -596,37 +596,37 @@ class TestTranscriptStoreRealSQLite:
|
||||
store = TranscriptStore(db_engine)
|
||||
|
||||
await store.append_transcript(
|
||||
transcript_id="trans_view_001",
|
||||
event_id="evt_view_001",
|
||||
conversation_id="conv_view",
|
||||
role="user",
|
||||
content="User text",
|
||||
transcript_id='trans_view_001',
|
||||
event_id='evt_view_001',
|
||||
conversation_id='conv_view',
|
||||
role='user',
|
||||
content='User text',
|
||||
content_json={
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "User structured text"}],
|
||||
'role': 'user',
|
||||
'content': [{'type': 'text', 'text': 'User structured text'}],
|
||||
},
|
||||
)
|
||||
await store.append_transcript(
|
||||
transcript_id="trans_view_002",
|
||||
event_id="evt_view_002",
|
||||
conversation_id="conv_view",
|
||||
role="tool",
|
||||
item_type="tool_result",
|
||||
content="ignored tool result",
|
||||
transcript_id='trans_view_002',
|
||||
event_id='evt_view_002',
|
||||
conversation_id='conv_view',
|
||||
role='tool',
|
||||
item_type='tool_result',
|
||||
content='ignored tool result',
|
||||
)
|
||||
await store.append_transcript(
|
||||
transcript_id="trans_view_003",
|
||||
event_id="evt_view_003",
|
||||
conversation_id="conv_view",
|
||||
role="assistant",
|
||||
content="Assistant text",
|
||||
transcript_id='trans_view_003',
|
||||
event_id='evt_view_003',
|
||||
conversation_id='conv_view',
|
||||
role='assistant',
|
||||
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 messages[0].content[0].text == "User structured text"
|
||||
assert messages[1].content == "Assistant text"
|
||||
assert [message.role for message in messages] == ['user', 'assistant']
|
||||
assert messages[0].content[0].text == 'User structured text'
|
||||
assert messages[1].content == 'Assistant text'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_legacy_provider_messages_filters_scope(self, db_engine):
|
||||
@@ -634,45 +634,45 @@ class TestTranscriptStoreRealSQLite:
|
||||
store = TranscriptStore(db_engine)
|
||||
|
||||
await store.append_transcript(
|
||||
transcript_id="trans_scope_001",
|
||||
event_id="evt_scope_001",
|
||||
conversation_id="conv_scope",
|
||||
bot_id="bot_001",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_001",
|
||||
role="user",
|
||||
content="Current scope text",
|
||||
transcript_id='trans_scope_001',
|
||||
event_id='evt_scope_001',
|
||||
conversation_id='conv_scope',
|
||||
bot_id='bot_001',
|
||||
workspace_id='workspace_001',
|
||||
thread_id='thread_001',
|
||||
role='user',
|
||||
content='Current scope text',
|
||||
)
|
||||
await store.append_transcript(
|
||||
transcript_id="trans_scope_002",
|
||||
event_id="evt_scope_002",
|
||||
conversation_id="conv_scope",
|
||||
bot_id="bot_002",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_001",
|
||||
role="assistant",
|
||||
content="Other bot text",
|
||||
transcript_id='trans_scope_002',
|
||||
event_id='evt_scope_002',
|
||||
conversation_id='conv_scope',
|
||||
bot_id='bot_002',
|
||||
workspace_id='workspace_001',
|
||||
thread_id='thread_001',
|
||||
role='assistant',
|
||||
content='Other bot text',
|
||||
)
|
||||
await store.append_transcript(
|
||||
transcript_id="trans_scope_003",
|
||||
event_id="evt_scope_003",
|
||||
conversation_id="conv_scope",
|
||||
bot_id="bot_001",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_002",
|
||||
role="assistant",
|
||||
content="Other thread text",
|
||||
transcript_id='trans_scope_003',
|
||||
event_id='evt_scope_003',
|
||||
conversation_id='conv_scope',
|
||||
bot_id='bot_001',
|
||||
workspace_id='workspace_001',
|
||||
thread_id='thread_002',
|
||||
role='assistant',
|
||||
content='Other thread text',
|
||||
)
|
||||
|
||||
messages = await store.get_legacy_provider_messages(
|
||||
"conv_scope",
|
||||
bot_id="bot_001",
|
||||
workspace_id="workspace_001",
|
||||
thread_id="thread_001",
|
||||
'conv_scope',
|
||||
bot_id='bot_001',
|
||||
workspace_id='workspace_001',
|
||||
thread_id='thread_001',
|
||||
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
|
||||
async def test_search_transcript_real_db(self, db_engine):
|
||||
@@ -681,24 +681,24 @@ class TestTranscriptStoreRealSQLite:
|
||||
|
||||
# Append transcript items
|
||||
await store.append_transcript(
|
||||
transcript_id="trans_search_001",
|
||||
event_id="evt_search_001",
|
||||
conversation_id="conv_search",
|
||||
role="user",
|
||||
content="I want to learn about databases",
|
||||
transcript_id='trans_search_001',
|
||||
event_id='evt_search_001',
|
||||
conversation_id='conv_search',
|
||||
role='user',
|
||||
content='I want to learn about databases',
|
||||
)
|
||||
await store.append_transcript(
|
||||
transcript_id="trans_search_002",
|
||||
event_id="evt_search_002",
|
||||
conversation_id="conv_search",
|
||||
role="assistant",
|
||||
content="Here is information about databases",
|
||||
transcript_id='trans_search_002',
|
||||
event_id='evt_search_002',
|
||||
conversation_id='conv_search',
|
||||
role='assistant',
|
||||
content='Here is information about databases',
|
||||
)
|
||||
|
||||
# Search for "database"
|
||||
items = await store.search_transcript(
|
||||
conversation_id="conv_search",
|
||||
query_text="database",
|
||||
conversation_id='conv_search',
|
||||
query_text='database',
|
||||
)
|
||||
|
||||
# Should find at least one match
|
||||
@@ -712,15 +712,15 @@ class TestTranscriptStoreRealSQLite:
|
||||
# Append transcript items
|
||||
for i in range(3):
|
||||
await store.append_transcript(
|
||||
transcript_id=f"trans_cursor_{i:03d}",
|
||||
event_id=f"evt_cursor_{i:03d}",
|
||||
conversation_id="conv_cursor",
|
||||
role="user",
|
||||
content=f"Message {i}",
|
||||
transcript_id=f'trans_cursor_{i:03d}',
|
||||
event_id=f'evt_cursor_{i:03d}',
|
||||
conversation_id='conv_cursor',
|
||||
role='user',
|
||||
content=f'Message {i}',
|
||||
)
|
||||
|
||||
# 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 int(cursor) > 0
|
||||
|
||||
@@ -733,37 +733,37 @@ class TestTranscriptStoreRealSQLite:
|
||||
store = TranscriptStore(db_engine)
|
||||
cutoff = datetime.datetime.utcnow()
|
||||
await store.append_transcript(
|
||||
transcript_id="trans_cleanup_old",
|
||||
event_id="evt_cleanup_old",
|
||||
conversation_id="conv_cleanup",
|
||||
role="user",
|
||||
content="old",
|
||||
transcript_id='trans_cleanup_old',
|
||||
event_id='evt_cleanup_old',
|
||||
conversation_id='conv_cleanup',
|
||||
role='user',
|
||||
content='old',
|
||||
)
|
||||
await store.append_transcript(
|
||||
transcript_id="trans_cleanup_new",
|
||||
event_id="evt_cleanup_new",
|
||||
conversation_id="conv_cleanup",
|
||||
role="assistant",
|
||||
content="new",
|
||||
transcript_id='trans_cleanup_new',
|
||||
event_id='evt_cleanup_new',
|
||||
conversation_id='conv_cleanup',
|
||||
role='assistant',
|
||||
content='new',
|
||||
)
|
||||
async with store._session_factory() as session:
|
||||
await session.execute(
|
||||
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))
|
||||
)
|
||||
await session.execute(
|
||||
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))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
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 [item["content"] for item in items] == ["new"]
|
||||
assert [item['content'] for item in items] == ['new']
|
||||
|
||||
|
||||
# Fixtures
|
||||
@@ -788,8 +788,8 @@ def mock_handler():
|
||||
|
||||
async def call_action(self, action, data, timeout=30):
|
||||
# Simulate error response for missing run_id
|
||||
if not data.get("run_id"):
|
||||
return {"ok": False, "message": "run_id is required"}
|
||||
return {"ok": True, "data": {}}
|
||||
if not data.get('run_id'):
|
||||
return {'ok': False, 'message': 'run_id is required'}
|
||||
return {'ok': True, 'data': {}}
|
||||
|
||||
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)]
|
||||
|
||||
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() == []
|
||||
|
||||
|
||||
@@ -1012,6 +1012,7 @@ class TestQueryEntrySessionQueryId:
|
||||
]
|
||||
)
|
||||
ap = FakeApplication(plugin_connector, db_engine)
|
||||
|
||||
async def build_resource_context(execution_query):
|
||||
from langbot.pkg.provider.tools.loaders.mcp import (
|
||||
_execution_context_from_query,
|
||||
@@ -1025,9 +1026,7 @@ class TestQueryEntrySessionQueryId:
|
||||
return 'Pinned documentation'
|
||||
|
||||
mcp_loader = types.SimpleNamespace(
|
||||
build_resource_context_for_query=AsyncMock(
|
||||
side_effect=build_resource_context
|
||||
)
|
||||
build_resource_context_for_query=AsyncMock(side_effect=build_resource_context)
|
||||
)
|
||||
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||
@@ -1070,7 +1069,6 @@ class TestQueryEntrySessionQueryId:
|
||||
resource_policy=ResourcePolicy(),
|
||||
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
|
||||
delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
messages = [
|
||||
@@ -1105,14 +1103,8 @@ class TestQueryEntrySessionQueryId:
|
||||
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text']
|
||||
assert event.input.text == 'hello'
|
||||
assert event.input.contents[0].text == 'hello'
|
||||
assert (
|
||||
plugin_connector.contexts[0]['conversation']['workspace_id']
|
||||
== TEST_CONTEXT.workspace_uuid
|
||||
)
|
||||
assert (
|
||||
plugin_connector.contexts[0]['runtime']['metadata']['workspace_id']
|
||||
== TEST_CONTEXT.workspace_uuid
|
||||
)
|
||||
assert plugin_connector.contexts[0]['conversation']['workspace_id'] == 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)
|
||||
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for agent runner result normalizer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -12,6 +13,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
|
||||
|
||||
class FakeApplication:
|
||||
"""Fake Application for testing."""
|
||||
|
||||
def __init__(self):
|
||||
class FakeLogger:
|
||||
def __init__(self):
|
||||
@@ -19,10 +21,13 @@ class FakeApplication:
|
||||
|
||||
def info(self, msg):
|
||||
pass
|
||||
|
||||
def debug(self, msg):
|
||||
pass
|
||||
|
||||
def warning(self, msg):
|
||||
self.warnings.append(msg)
|
||||
|
||||
def error(self, msg):
|
||||
pass
|
||||
|
||||
@@ -192,6 +197,7 @@ class TestNormalizeRunFailed:
|
||||
|
||||
assert exc_info.value.runner_id == 'plugin:langbot-team/LocalAgent/default'
|
||||
assert exc_info.value.retryable is True
|
||||
assert exc_info.value.error_code == 'upstream.timeout'
|
||||
assert 'timeout' in str(exc_info.value)
|
||||
|
||||
|
||||
@@ -290,6 +296,7 @@ class TestNormalizeNonMessageResults:
|
||||
assert result is None
|
||||
assert app.logger.warnings
|
||||
|
||||
|
||||
class TestNormalizeInvalidResults:
|
||||
"""Tests for handling invalid results."""
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ def _agent_row(
|
||||
'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0},
|
||||
'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}},
|
||||
},
|
||||
enabled=True,
|
||||
supported_event_patterns=supported_event_patterns or ['*'],
|
||||
created_at=dt.datetime(2026, 1, 1, 9, 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,
|
||||
'component_ref': entity.component_ref,
|
||||
'config': entity.config,
|
||||
'enabled': entity.enabled,
|
||||
'supported_event_patterns': entity.supported_event_patterns,
|
||||
'created_at': entity.created_at,
|
||||
'updated_at': entity.updated_at,
|
||||
@@ -145,7 +143,6 @@ class TestAgentServiceDebug:
|
||||
return_value={
|
||||
'uuid': 'agent-1',
|
||||
'kind': AGENT_KIND_AGENT,
|
||||
'enabled': True,
|
||||
'supported_event_patterns': ['*'],
|
||||
'config': _agent_row().config,
|
||||
}
|
||||
@@ -288,7 +285,7 @@ class TestAgentServiceListAndLookup:
|
||||
result = await AgentService(app).get_agent(WORKSPACE_UUID, 'pipeline-1')
|
||||
|
||||
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['capability']['message_only'] is True
|
||||
|
||||
@@ -329,7 +326,7 @@ class TestAgentServiceCreateUpdateDelete:
|
||||
'runner': {'id': runner.id, 'expire-time': 0},
|
||||
'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
|
||||
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
|
||||
]
|
||||
ap.discover = SimpleNamespace(
|
||||
get_components_by_kind=Mock(return_value=components)
|
||||
)
|
||||
ap.discover = SimpleNamespace(get_components_by_kind=Mock(return_value=components))
|
||||
|
||||
|
||||
class TestBotServiceGetBots:
|
||||
@@ -445,6 +443,7 @@ class TestBotServiceUpdateBot:
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.platform_mgr = SimpleNamespace()
|
||||
ap.platform_mgr.remove_bot = AsyncMock()
|
||||
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
|
||||
|
||||
# Mock pipeline query - not updating pipeline
|
||||
ap.persistence_mgr.execute_async = AsyncMock()
|
||||
@@ -475,6 +474,7 @@ class TestBotServiceUpdateBot:
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
|
||||
ap.platform_mgr = SimpleNamespace(
|
||||
get_bot_by_uuid=AsyncMock(return_value=None),
|
||||
remove_bot=AsyncMock(),
|
||||
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_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:
|
||||
"""Tests for delete_bot method."""
|
||||
@@ -583,6 +606,56 @@ class TestBotServiceListEventLogs:
|
||||
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:
|
||||
"""Tests for send_message method."""
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ from unittest.mock import ANY, AsyncMock
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.agent.runner.errors import RunnerExecutionError
|
||||
|
||||
core_app_module = types.ModuleType('langbot.pkg.core.app')
|
||||
core_app_module.Application = object
|
||||
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,
|
||||
'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
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
@@ -20,18 +19,6 @@ def _make_app() -> SimpleNamespace:
|
||||
update_bot=AsyncMock(),
|
||||
delete_bot=AsyncMock(),
|
||||
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(
|
||||
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}
|
||||
|
||||
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_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 = []
|
||||
|
||||
async def fake_execute_tool(parameters, q):
|
||||
calls.append(parameters['command'])
|
||||
if 'os.scandir' in parameters['command']:
|
||||
return {
|
||||
'ok': True,
|
||||
'stdout': '[{"name": "out.png", "b64": "QUJD"}]',
|
||||
'stderr': '',
|
||||
}
|
||||
async def fake_client_execute(spec):
|
||||
cmd = spec.cmd
|
||||
calls.append(cmd)
|
||||
if 'os.scandir' in cmd:
|
||||
return BoxExecutionResult(
|
||||
session_id='s',
|
||||
backend_name='test',
|
||||
status=BoxExecutionStatus.COMPLETED,
|
||||
exit_code=0,
|
||||
stdout='[{"name": "out.png", "b64": "QUJD"}]',
|
||||
duration_ms=10,
|
||||
)
|
||||
# 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)
|
||||
assert len(attachments) == 1
|
||||
assert attachments[0]['type'] == 'Image'
|
||||
assert attachments[0]['name'] == 'out.png'
|
||||
# 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
|
||||
async def test_collect_outbound_empty_still_clears(self):
|
||||
@@ -2193,16 +2206,33 @@ class TestInboundOutboundRoundTrip:
|
||||
|
||||
calls = []
|
||||
|
||||
async def fake_execute_tool(parameters, q):
|
||||
calls.append(parameters['command'])
|
||||
if 'os.scandir' in parameters['command']:
|
||||
return {'ok': True, 'stdout': '[]', 'stderr': ''}
|
||||
return {'ok': True, 'stdout': '', 'stderr': ''}
|
||||
async def fake_client_execute(spec):
|
||||
cmd = spec.cmd
|
||||
calls.append(cmd)
|
||||
if 'os.scandir' in cmd:
|
||||
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) == []
|
||||
# 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
|
||||
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.cmdmgr import CommandManager
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from tests.factories import FakeApp, command_query
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
@@ -393,6 +394,32 @@ class TestCommandManagerInternalExecute:
|
||||
assert len(results) == 1
|
||||
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
|
||||
async def test_execute_with_bound_plugins(self):
|
||||
"""_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['runtimes']['plugin_installations'] == 1
|
||||
assert stats['runtimes']['plugin_runtime_connected'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_plugin_runtime_initialization_bypasses_after_commit_gate() -> None:
|
||||
app = Application()
|
||||
app.plugin_connector = SimpleNamespace(initialize=AsyncMock())
|
||||
app.task_mgr = SimpleNamespace(create_task=AsyncMock())
|
||||
|
||||
task = app._start_plugin_runtime_initialization()
|
||||
await task
|
||||
|
||||
app.plugin_connector.initialize.assert_awaited_once_with()
|
||||
app.task_mgr.create_task.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_cancels_plugin_runtime_initialization_task() -> None:
|
||||
app = Application()
|
||||
app._plugin_runtime_initialization_task = asyncio.create_task(asyncio.sleep(60))
|
||||
app.task_mgr = SimpleNamespace(cancel_by_scope=lambda *_: None, tasks=[])
|
||||
app.event_loop_monitor = SimpleNamespace(stop=AsyncMock())
|
||||
app.http_ctrl = SimpleNamespace(mcp_mount=None)
|
||||
app.platform_mgr = None
|
||||
app.tool_mgr = None
|
||||
app.model_mgr = None
|
||||
app.box_service = None
|
||||
app.plugin_connector = None
|
||||
app.telemetry = None
|
||||
app.vector_db_mgr = None
|
||||
app.storage_mgr = None
|
||||
app.persistence_mgr = SimpleNamespace(db=SimpleNamespace(engine=SimpleNamespace(dispose=AsyncMock())))
|
||||
app.deployment = None
|
||||
|
||||
await app.shutdown()
|
||||
|
||||
assert app._plugin_runtime_initialization_task.cancelled()
|
||||
|
||||
@@ -81,9 +81,9 @@ class TestEventRouteTrace:
|
||||
'target_type': 'agent',
|
||||
'target_uuid': 'agent-1',
|
||||
},
|
||||
failure_code='processor_disabled',
|
||||
reason='Agent target is disabled',
|
||||
text='disabled',
|
||||
failure_code='processor_not_found',
|
||||
reason='Agent target is unavailable',
|
||||
text='unavailable',
|
||||
)
|
||||
|
||||
bot.logger.warning.assert_awaited_once()
|
||||
@@ -96,61 +96,34 @@ class TestEventRouteTrace:
|
||||
assert metadata['status'] == 'failed'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_test_event_suppresses_agent_output_delivery(self):
|
||||
"""Synthetic test dispatch runs the route but does not call the real adapter."""
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
async def test_adapter_event_log_exposes_normalized_input_without_platform_object(self):
|
||||
"""Adapter debugging records the shared event shape without opaque SDK data."""
|
||||
from langbot_plugin.api.entities.builtin.platform import entities, events, message
|
||||
|
||||
captured_envelopes = []
|
||||
|
||||
async def fake_run(envelope, binding, adapter_context=None):
|
||||
captured_envelopes.append(envelope)
|
||||
yield provider_message.Message(role='assistant', content='test response')
|
||||
|
||||
bot = self._make_bot(
|
||||
[
|
||||
{
|
||||
'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']),
|
||||
bot = self._make_bot([])
|
||||
bot.bot_entity.adapter = 'test-adapter'
|
||||
event = events.MessageReceivedEvent(
|
||||
message_id='message-1',
|
||||
message_chain=message.MessageChain([message.Plain(text='hello')]),
|
||||
sender=entities.User(id='user-1', nickname='QA User'),
|
||||
chat_type=entities.ChatType.PRIVATE,
|
||||
chat_id='user-1',
|
||||
source_platform_object={'access_token': 'must-not-be-logged'},
|
||||
)
|
||||
|
||||
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 result['dispatched'] is True
|
||||
assert result['status'] == 'delivered'
|
||||
assert result['suppressed_outputs'][0]['method'] == 'send_message'
|
||||
assert captured_envelopes[0].delivery.supports_edit is False
|
||||
assert captured_envelopes[0].delivery.supports_reaction is False
|
||||
assert captured_envelopes[0].delivery.platform_capabilities['supported_apis'] == ['get_group_info']
|
||||
assert metadata['kind'] == 'adapter_event_received'
|
||||
assert metadata['event_type'] == 'message.received'
|
||||
assert metadata['adapter'] == 'test-adapter'
|
||||
assert metadata['bot_uuid'] == 'bot-1'
|
||||
assert metadata['event_data']['message_chain'] == [{'type': 'Plain', 'text': 'hello'}]
|
||||
assert metadata['event_data']['sender']['id'] == 'user-1'
|
||||
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
|
||||
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 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):
|
||||
"""Runner delivery context reflects the active adapter's declared APIs."""
|
||||
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
|
||||
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
|
||||
consumed upload. Covers mimetype selection per type and fail-closed error
|
||||
handling.
|
||||
LLM input and the Box sandbox inbox have usable bytes). Image uploads remain as
|
||||
authenticated history references until storage retention cleanup, while other
|
||||
consumed uploads are deleted. Covers mimetype selection per type and
|
||||
fail-closed error handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -52,7 +53,7 @@ def _make_adapter(load_return=b'hello', load_side_effect=None):
|
||||
|
||||
|
||||
@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')
|
||||
path = f'{_UPLOAD_PREFIX}photo.jpg'
|
||||
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')
|
||||
assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}'
|
||||
assert chain[0]['path'] == ''
|
||||
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
|
||||
_CONTEXT,
|
||||
path,
|
||||
expected_owner_type='upload_image',
|
||||
)
|
||||
assert chain[0]['path'] == path
|
||||
storage_mgr.delete_scoped_object_key.assert_not_awaited()
|
||||
|
||||
history = adapter._history_message_chain(chain)
|
||||
assert history == [{'type': 'Image', 'path': path, 'base64': ''}]
|
||||
|
||||
|
||||
def test_history_retains_storage_key_without_large_base64_payload():
|
||||
@@ -95,18 +95,22 @@ async def test_image_defaults_to_png():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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'}]
|
||||
await adapter._process_image_components(_make_connection(), chain)
|
||||
assert chain[0]['base64'].startswith('data:audio/')
|
||||
assert chain[0]['path'] == ''
|
||||
storage_mgr.delete_scoped_object_key.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_uses_octet_stream_fallback():
|
||||
adapter, _, _ = _make_adapter()
|
||||
adapter, storage_mgr, _ = _make_adapter()
|
||||
chain = [{'type': 'File', 'path': f'{_UPLOAD_PREFIX}unknownblob'}]
|
||||
await adapter._process_image_components(_make_connection(), chain)
|
||||
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
|
||||
|
||||
@@ -482,7 +482,7 @@ async def test_attachment_key_must_belong_to_connection_upload_scope():
|
||||
await adapter._process_image_components(connection, message_chain)
|
||||
|
||||
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(
|
||||
connection.execution_context,
|
||||
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',
|
||||
expected_owner_type='upload_image',
|
||||
)
|
||||
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
|
||||
connection.execution_context,
|
||||
'v1/current/upload_image/key.png',
|
||||
expected_owner_type='upload_image',
|
||||
)
|
||||
storage_mgr.delete_scoped_object_key.assert_not_awaited()
|
||||
|
||||
with pytest.raises(ValueError, match='does not belong'):
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_reconcile_uses_configured_cold_start_timeout():
|
||||
binding = execution_binding("workspace-a")
|
||||
setting = plugin_setting("01", "a" * 64)
|
||||
connector = shared_connector([[binding]], {"workspace-a": [setting]})
|
||||
connector.ap.instance_config.data["plugin"]["connect_timeout_seconds"] = 900
|
||||
connector.handler = runtime_handler()
|
||||
|
||||
await connector._prepare_connected_runtime()
|
||||
|
||||
assert connector.handler.reconcile_plugin_installations.await_args.kwargs["timeout"] == 900
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
|
||||
binding_a = execution_binding('workspace-a')
|
||||
@@ -150,7 +163,7 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets():
|
||||
|
||||
assert connector._workspace_installations == {}
|
||||
assert connector._known_desired_states == {}
|
||||
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
|
||||
connector.handler.reconcile_plugin_installations.assert_awaited_once_with((), timeout=300.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -81,6 +81,18 @@ async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish(
|
||||
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_plugin_installations_accepts_configured_cold_start_timeout():
|
||||
runtime_handler = make_handler(SimpleNamespace())
|
||||
runtime_handler.call_action = AsyncMock(return_value={})
|
||||
binding = next(iter(runtime_handler._installation_bindings.values()))[0]
|
||||
desired = PluginInstallationDesiredState(binding=binding, enabled=True)
|
||||
|
||||
await runtime_handler.reconcile_plugin_installations((desired,), timeout=900)
|
||||
|
||||
assert runtime_handler.call_action.await_args.kwargs["timeout"] == 900
|
||||
|
||||
|
||||
class TestHandlerQueryVariables:
|
||||
"""Tests for handler query variable logic."""
|
||||
|
||||
|
||||
@@ -277,6 +277,7 @@ class TestSetBinaryStorage:
|
||||
},
|
||||
}
|
||||
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.logger = Mock()
|
||||
return mock_app
|
||||
@@ -313,8 +314,8 @@ class TestSetBinaryStorage:
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||
assert app.persistence_mgr.execute_async.await_count == 3
|
||||
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['unique_key'] == canonical_binary_key(
|
||||
'plugin',
|
||||
@@ -344,6 +345,69 @@ class TestSetBinaryStorage:
|
||||
assert expected_key in update_params.values()
|
||||
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
|
||||
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
||||
"""Invalid max_value_bytes uses the 10MB default limit."""
|
||||
@@ -568,6 +632,46 @@ class TestGetBinaryStorage:
|
||||
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
|
||||
async def test_returns_error_when_not_found(self, app):
|
||||
"""Missing binary storage rows return an error response."""
|
||||
@@ -610,21 +714,47 @@ class TestDeleteAndListBinaryStorage:
|
||||
|
||||
assert response.code == 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 (
|
||||
canonical_binary_key(
|
||||
'plugin',
|
||||
'test-author/test-plugin',
|
||||
'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
|
||||
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
||||
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
|
||||
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
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
||||
found, masked = await safe_regex.mask_patterns(
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.utils.import_isolation import isolated_sys_modules
|
||||
|
||||
|
||||
_INSTALL_HINT = "Install LangBot with the 'seekdb' extra"
|
||||
|
||||
|
||||
def test_seekdb_vector_backend_reports_missing_optional_extra() -> None:
|
||||
module_name = 'langbot.pkg.vector.vdbs.seekdb'
|
||||
|
||||
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
|
||||
seekdb_module = importlib.import_module(module_name)
|
||||
|
||||
assert seekdb_module.SEEKDB_AVAILABLE is False
|
||||
with pytest.raises(ImportError, match=_INSTALL_HINT):
|
||||
seekdb_module.SeekDBVectorDatabase(MagicMock())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seekdb_embedding_reports_missing_optional_extra() -> None:
|
||||
module_name = 'langbot.pkg.provider.modelmgr.requesters.seekdbembed'
|
||||
|
||||
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
|
||||
seekdb_embedding_module = importlib.import_module(module_name)
|
||||
requester = seekdb_embedding_module.SeekDBEmbedding.__new__(seekdb_embedding_module.SeekDBEmbedding)
|
||||
|
||||
with pytest.raises(ImportError, match=_INSTALL_HINT):
|
||||
await requester.initialize()
|
||||
@@ -8,10 +8,10 @@ resolution-markers = [
|
||||
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
|
||||
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||
"python_full_version == '3.12.*' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.12' and sys_platform == 'win32'",
|
||||
"python_full_version == '3.12.*' and sys_platform == 'emscripten'",
|
||||
"python_full_version < '3.12' and sys_platform == 'emscripten'",
|
||||
"python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||
"python_full_version < '3.12' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.12' and sys_platform == 'emscripten'",
|
||||
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||
]
|
||||
|
||||
@@ -2054,7 +2054,6 @@ dependencies = [
|
||||
{ name = "pymilvus" },
|
||||
{ name = "pynacl" },
|
||||
{ name = "pypdf2" },
|
||||
{ name = "pyseekdb" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "python-socks" },
|
||||
@@ -2079,6 +2078,11 @@ dependencies = [
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
seekdb = [
|
||||
{ name = "pyseekdb" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "moto" },
|
||||
@@ -2143,7 +2147,7 @@ requires-dist = [
|
||||
{ name = "pymilvus", specifier = ">=2.6.4" },
|
||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||
{ name = "pypdf2", specifier = ">=3.0.1" },
|
||||
{ name = "pyseekdb", specifier = "==1.1.0.post3" },
|
||||
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
|
||||
{ name = "python-docx", specifier = ">=1.1.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.27" },
|
||||
{ name = "python-socks", specifier = ">=2.7.1" },
|
||||
@@ -2167,6 +2171,7 @@ requires-dist = [
|
||||
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
|
||||
{ name = "websockets", specifier = ">=15.0.1" },
|
||||
]
|
||||
provides-extras = ["seekdb"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
|
||||
@@ -1,23 +1,41 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bug, Settings } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { toast } from 'sonner';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { Agent } from '@/app/infra/entities/api';
|
||||
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 AgentCreateContent from './components/AgentCreateContent';
|
||||
import AgentDebugPanel from './components/AgentDebugPanel';
|
||||
import AgentFormComponent from './components/AgentFormComponent';
|
||||
import AgentFormComponent, {
|
||||
AgentFormHandle,
|
||||
AgentRunnerStatus,
|
||||
} from './components/AgentFormComponent';
|
||||
|
||||
export default function AgentDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const canOperate =
|
||||
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
||||
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
||||
@@ -25,7 +43,19 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
const [loading, setLoading] = useState(!isCreateMode);
|
||||
const [formDirty, setFormDirty] = 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(() => {
|
||||
if (isCreateMode) {
|
||||
@@ -38,14 +68,33 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setRunnerStatus(null);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreateMode) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
httpClient
|
||||
.getAgent(id)
|
||||
.then((resp) => {
|
||||
if (!cancelled) setAgent(resp.agent);
|
||||
Promise.all([
|
||||
httpClient.getAgent(id),
|
||||
httpClient.getAdapters().catch(() => ({ adapters: [] })),
|
||||
])
|
||||
.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(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
@@ -78,72 +127,150 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<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>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
<Tabs
|
||||
key={id}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col"
|
||||
>
|
||||
<TabsList className="shrink-0">
|
||||
<TabsTrigger value="config" className="gap-1.5">
|
||||
<Settings className="size-3.5" />
|
||||
{t('pipelines.configuration')}
|
||||
</TabsTrigger>
|
||||
{canOperate && (
|
||||
<TabsTrigger value="debug" className="gap-1.5">
|
||||
<Bug className="size-3.5" />
|
||||
{t('agents.debugTab')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="config"
|
||||
className="mt-4 min-h-0 min-w-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<AgentFormComponent
|
||||
agentId={id}
|
||||
onFinish={() => {
|
||||
refreshPipelines();
|
||||
}}
|
||||
onDeleted={() => {
|
||||
refreshPipelines();
|
||||
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 (
|
||||
<>
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
title={`${agent.emoji || '🤖'} ${agent.name}`}
|
||||
titleAction={
|
||||
canManage ? (
|
||||
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
|
||||
) : undefined
|
||||
}
|
||||
status={runnerStatus}
|
||||
saveLabel={t('common.save')}
|
||||
saveFormId="agent-form"
|
||||
canSave={canManage}
|
||||
isDirty={formDirty}
|
||||
isSaving={formSaving}
|
||||
headerActions={
|
||||
canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={formSaving || deleting}
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
configTitle={t('pipelines.configuration')}
|
||||
configContent={
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<AgentFormComponent
|
||||
ref={agentFormRef}
|
||||
agentId={id}
|
||||
availableEventTypes={availableEventTypes}
|
||||
onFinish={(updatedAgent) => {
|
||||
if (updatedAgent) {
|
||||
setAgent((current) =>
|
||||
current ? { ...current, ...updatedAgent } : current,
|
||||
);
|
||||
}
|
||||
refreshPipelines();
|
||||
}}
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
onRunnerStatusChange={setRunnerStatus}
|
||||
onSupportedEventPatternsChange={setSupportedEventPatterns}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{canOperate && (
|
||||
<TabsContent
|
||||
value="debug"
|
||||
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
</fieldset>
|
||||
}
|
||||
debugTitle={canOperate ? t('agents.debugTab') : undefined}
|
||||
debugContent={
|
||||
canOperate ? (
|
||||
<AgentDebugPanel
|
||||
agentId={id}
|
||||
supportedEventPatterns={
|
||||
agent.supported_event_patterns ??
|
||||
agent.capability?.supported_event_patterns ?? ['*']
|
||||
hasUnsavedChanges={formDirty}
|
||||
beforeRun={async () => agentFormRef.current?.save() ?? false}
|
||||
onOpenRunnerConfig={() =>
|
||||
agentFormRef.current?.openSection('runner_config')
|
||||
}
|
||||
supportedEventPatterns={supportedEventPatterns}
|
||||
availableEventTypes={availableEventTypes}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,9 +51,12 @@ export default function AgentCreateContent({
|
||||
});
|
||||
|
||||
function handleKindChange(nextKind: AgentKind) {
|
||||
const previousDefaultEmoji = kind === 'pipeline' ? '⚙️' : '🤖';
|
||||
const nextDefaultEmoji = nextKind === 'pipeline' ? '⚙️' : '🤖';
|
||||
setKind(nextKind);
|
||||
if (!form.getValues('emoji')) {
|
||||
form.setValue('emoji', nextKind === 'pipeline' ? '⚙️' : '🤖');
|
||||
const currentEmoji = form.getValues('emoji');
|
||||
if (!currentEmoji || currentEmoji === previousDefaultEmoji) {
|
||||
form.setValue('emoji', nextDefaultEmoji);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,50 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Braces,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
LoaderCircle,
|
||||
MessageSquare,
|
||||
Play,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
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 {
|
||||
agentId: string;
|
||||
availableEventTypes: string[];
|
||||
supportedEventPatterns?: string[];
|
||||
beforeRun?: () => Promise<boolean>;
|
||||
hasUnsavedChanges?: boolean;
|
||||
onOpenRunnerConfig?: () => void;
|
||||
}
|
||||
|
||||
interface DebugEntry {
|
||||
@@ -41,18 +52,19 @@ interface DebugEntry {
|
||||
direction: 'input' | 'output' | 'error';
|
||||
eventType: string;
|
||||
text: string;
|
||||
errorCode?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
const EVENT_PRESETS = [
|
||||
{
|
||||
value: 'message.received',
|
||||
labelKey: 'agents.debugMessageReceived',
|
||||
const EVENT_PRESET_DATA: Record<
|
||||
string,
|
||||
{ text: string; data: Record<string, unknown> }
|
||||
> = {
|
||||
'message.received': {
|
||||
text: '',
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
value: 'group.member.joined',
|
||||
labelKey: 'agents.debugGroupMemberJoined',
|
||||
'group.member_joined': {
|
||||
text: 'A new member joined the group.',
|
||||
data: {
|
||||
group_id: 'debug-group',
|
||||
@@ -60,9 +72,7 @@ const EVENT_PRESETS = [
|
||||
member_name: 'Debug User',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'group.member.left',
|
||||
labelKey: 'agents.debugGroupMemberLeft',
|
||||
'group.member_left': {
|
||||
text: 'A member left the group.',
|
||||
data: {
|
||||
group_id: 'debug-group',
|
||||
@@ -70,9 +80,7 @@ const EVENT_PRESETS = [
|
||||
member_name: 'Debug User',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'friend.requested',
|
||||
labelKey: 'agents.debugFriendRequested',
|
||||
'friend.request_received': {
|
||||
text: 'A user sent a friend request.',
|
||||
data: {
|
||||
requester_id: 'debug-user',
|
||||
@@ -80,31 +88,32 @@ const EVENT_PRESETS = [
|
||||
message: 'Hello',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'feedback.received',
|
||||
labelKey: 'agents.debugFeedbackReceived',
|
||||
'feedback.received': {
|
||||
text: 'The user submitted feedback.',
|
||||
data: {
|
||||
rating: 5,
|
||||
content: 'Debug feedback',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'custom',
|
||||
labelKey: 'agents.debugCustomEvent',
|
||||
text: '',
|
||||
data: {},
|
||||
},
|
||||
] as const;
|
||||
};
|
||||
|
||||
function createDebugSessionId(agentId: string) {
|
||||
const nonce = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
|
||||
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({
|
||||
agentId,
|
||||
availableEventTypes,
|
||||
supportedEventPatterns = ['*'],
|
||||
beforeRun,
|
||||
hasUnsavedChanges = false,
|
||||
onOpenRunnerConfig,
|
||||
}: AgentDebugPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [preset, setPreset] = useState('message.received');
|
||||
@@ -121,11 +130,39 @@ export default function AgentDebugPanel({
|
||||
() => supportedEventPatterns.join(', '),
|
||||
[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) {
|
||||
setPreset(value);
|
||||
const nextPreset = EVENT_PRESETS.find((item) => item.value === value);
|
||||
if (!nextPreset) return;
|
||||
const nextPreset = EVENT_PRESET_DATA[value] ?? { text: '', data: {} };
|
||||
setInputText(nextPreset.text);
|
||||
setEventDataText(JSON.stringify(nextPreset.data, null, 2));
|
||||
}
|
||||
@@ -144,6 +181,14 @@ export default function AgentDebugPanel({
|
||||
toast.error(t('agents.debugInputRequired'));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!supportedEventPatterns.some((pattern) =>
|
||||
matchesEventPattern(pattern, eventType),
|
||||
)
|
||||
) {
|
||||
toast.error(t('agents.debugUnsupportedEvent'));
|
||||
return;
|
||||
}
|
||||
|
||||
let eventData: Record<string, unknown>;
|
||||
try {
|
||||
@@ -157,6 +202,12 @@ export default function AgentDebugPanel({
|
||||
return;
|
||||
}
|
||||
|
||||
setRunning(true);
|
||||
if (hasUnsavedChanges && beforeRun && !(await beforeRun())) {
|
||||
setRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
|
||||
setEntries((current) => [
|
||||
...current,
|
||||
@@ -167,7 +218,6 @@ export default function AgentDebugPanel({
|
||||
text: inputText.trim() || JSON.stringify(eventData, null, 2),
|
||||
},
|
||||
]);
|
||||
setRunning(true);
|
||||
try {
|
||||
const result = await httpClient.debugAgent(agentId, {
|
||||
event_type: eventType,
|
||||
@@ -186,17 +236,41 @@ export default function AgentDebugPanel({
|
||||
]);
|
||||
if (isMessageEvent) setInputText('');
|
||||
} catch (error) {
|
||||
const errorCode =
|
||||
typeof error === 'object' && error && 'code' in error
|
||||
? String((error as { code?: string }).code || '')
|
||||
: '';
|
||||
const message =
|
||||
typeof error === 'object' && error && 'msg' in error
|
||||
? String((error as { msg?: string }).msg || '')
|
||||
: 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) => [
|
||||
...current,
|
||||
{
|
||||
id: `error:${requestId}`,
|
||||
direction: 'error',
|
||||
eventType,
|
||||
text: message || t('agents.debugRunFailed'),
|
||||
text: friendlyMessage,
|
||||
errorCode,
|
||||
detail:
|
||||
isExecutionError || isTimeout
|
||||
? message || t('agents.debugRunFailed')
|
||||
: undefined,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
@@ -205,59 +279,70 @@ export default function AgentDebugPanel({
|
||||
}
|
||||
|
||||
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)]">
|
||||
<Card className="min-w-0">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{isMessageEvent ? (
|
||||
<MessageSquare className="size-5" />
|
||||
) : (
|
||||
<Braces className="size-5" />
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<div className="shrink-0 space-y-3 border-b p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<Label>{t('agents.debugEventType')}</Label>
|
||||
<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>
|
||||
)}
|
||||
{t('agents.debugTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('agents.debugDescription')}</CardDescription>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={resetSession}
|
||||
title={t('agents.debugResetSession')}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
{t('agents.debugResetSession')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<Alert>
|
||||
<AlertTriangle />
|
||||
<AlertTitle>{t('agents.debugActualRun')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.debugActualRunDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('agents.debugEventType')}</Label>
|
||||
<Select value={preset} onValueChange={selectPreset}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EVENT_PRESETS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{t(item.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{preset === 'custom' && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="agent-debug-custom-event">
|
||||
{t('agents.debugCustomEventType')}
|
||||
</Label>
|
||||
@@ -271,77 +356,38 @@ export default function AgentDebugPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-debug-input">
|
||||
{isMessageEvent
|
||||
? t('agents.debugMessageInput')
|
||||
: t('agents.debugEventSummary')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="agent-debug-input"
|
||||
value={inputText}
|
||||
onChange={(event) => setInputText(event.target.value)}
|
||||
className="min-h-24 resize-y"
|
||||
placeholder={t('agents.debugInputPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label htmlFor="agent-debug-payload">
|
||||
{t('agents.debugEventPayload')}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('agents.debugSupportedEvents')}: {supportedLabel}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="agent-debug-payload"
|
||||
value={eventDataText}
|
||||
onChange={(event) => setEventDataText(event.target.value)}
|
||||
className="min-h-40 resize-y font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" disabled={running} onClick={runDebugEvent}>
|
||||
{running ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
{running ? t('agents.debugRunning') : t('agents.debugRun')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-h-[28rem] min-w-0">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.debugTranscript')}</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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')}
|
||||
</p>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<Alert className="my-4 bg-muted/20">
|
||||
<CircleHelp className="size-4" />
|
||||
<AlertTitle>{t('agents.debugEmptyTitle')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.debugEmptyTranscript')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="max-h-[42rem] space-y-4 overflow-y-auto pr-1">
|
||||
<div className="space-y-3">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
<Alert
|
||||
key={entry.id}
|
||||
className={`rounded-lg border p-3 ${
|
||||
variant={
|
||||
entry.direction === 'error' ? 'destructive' : 'default'
|
||||
}
|
||||
className={
|
||||
entry.direction === 'output'
|
||||
? 'border-primary/20 bg-primary/5'
|
||||
: entry.direction === 'error'
|
||||
? 'border-destructive/30 bg-destructive/5'
|
||||
: 'bg-muted/40'
|
||||
}`}
|
||||
: entry.direction === 'input'
|
||||
? 'bg-muted/40'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{entry.direction === 'error' && <AlertCircle />}
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<Badge variant="outline">{entry.eventType}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -355,12 +401,93 @@ export default function AgentDebugPanel({
|
||||
<pre className="min-w-0 whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
|
||||
{entry.text}
|
||||
</pre>
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 space-y-3 border-t p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="agent-debug-input">
|
||||
{isMessageEvent
|
||||
? t('agents.debugMessageInput')
|
||||
: t('agents.debugEventSummary')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="agent-debug-input"
|
||||
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
|
||||
id="agent-debug-payload"
|
||||
value={eventDataText}
|
||||
onChange={(event) => setEventDataText(event.target.value)}
|
||||
className="min-h-28 resize-y font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={running}
|
||||
onClick={runDebugEvent}
|
||||
>
|
||||
{running ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
{running
|
||||
? t('agents.debugRunning')
|
||||
: hasUnsavedChanges
|
||||
? t('agents.debugSaveAndRun')
|
||||
: t('agents.debugRun')}
|
||||
</Button>
|
||||
</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 { Link } from 'react-router-dom';
|
||||
import {
|
||||
forwardRef,
|
||||
type ForwardedRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
LoaderCircle,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Unplug,
|
||||
} from 'lucide-react';
|
||||
import { Bot, SlidersHorizontal, Zap } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
||||
import {
|
||||
@@ -22,11 +22,7 @@ import {
|
||||
} from '@/app/infra/entities/pipeline';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -34,39 +30,85 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} 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 {
|
||||
agentId: string;
|
||||
onFinish: () => void;
|
||||
onDeleted: () => void;
|
||||
availableEventTypes: string[];
|
||||
onFinish: (agent?: Partial<Agent>) => void;
|
||||
onDirtyChange?: (dirty: 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';
|
||||
|
||||
export interface AgentFormHandle {
|
||||
openSection: (section: AgentConfigSection) => void;
|
||||
save: () => Promise<boolean>;
|
||||
syncBasicInfo: (values: {
|
||||
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,
|
||||
onDeleted,
|
||||
onDirtyChange,
|
||||
onSavingChange,
|
||||
}: AgentFormComponentProps) {
|
||||
onRunnerStatusChange,
|
||||
onSupportedEventPatternsChange,
|
||||
}: AgentFormComponentProps,
|
||||
ref: ForwardedRef<AgentFormHandle>,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [runnerConfigSchema, setRunnerConfigSchema] =
|
||||
useState<PipelineConfigTab | null>(null);
|
||||
@@ -74,20 +116,20 @@ export default function AgentFormComponent({
|
||||
useState<ApiRespPluginSystemStatus | null>(null);
|
||||
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
|
||||
const [pluginStatusError, setPluginStatusError] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<AgentConfigSection>('runner');
|
||||
const isSavingRef = useRef(false);
|
||||
const hasUnsavedChangesRef = useRef(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
basic: z.object({
|
||||
name: z.string().min(1, { message: t('agents.nameRequired') }),
|
||||
description: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}),
|
||||
runner: 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>;
|
||||
|
||||
@@ -98,11 +140,10 @@ export default function AgentFormComponent({
|
||||
name: '',
|
||||
description: '',
|
||||
emoji: '🤖',
|
||||
enabled: true,
|
||||
},
|
||||
runner: {},
|
||||
runner_config: {},
|
||||
supported_event_patterns_text: '*',
|
||||
supported_event_patterns: ['*'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -113,11 +154,17 @@ export default function AgentFormComponent({
|
||||
if (!savedSnapshotRef.current) return false;
|
||||
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
||||
})();
|
||||
hasUnsavedChangesRef.current = hasUnsavedChanges;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasUnsavedChanges);
|
||||
}, [hasUnsavedChanges, onDirtyChange]);
|
||||
|
||||
const supportedEventPatterns = form.watch('supported_event_patterns');
|
||||
useEffect(() => {
|
||||
onSupportedEventPatternsChange?.(supportedEventPatterns);
|
||||
}, [onSupportedEventPatternsChange, supportedEventPatterns]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)])
|
||||
@@ -131,15 +178,12 @@ export default function AgentFormComponent({
|
||||
name: agent.name ?? '',
|
||||
description: agent.description ?? '',
|
||||
emoji: agent.emoji || '🤖',
|
||||
enabled: agent.enabled ?? true,
|
||||
},
|
||||
runner: (config.runner as Record<string, unknown>) ?? {},
|
||||
runner_config:
|
||||
(config.runner_config as Record<string, unknown>) ?? {},
|
||||
supported_event_patterns_text: (
|
||||
agent.supported_event_patterns ??
|
||||
agent.capability?.supported_event_patterns ?? ['*']
|
||||
).join('\n'),
|
||||
supported_event_patterns: agent.supported_event_patterns ??
|
||||
agent.capability?.supported_event_patterns ?? ['*'],
|
||||
};
|
||||
form.reset(loadedValues);
|
||||
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
||||
@@ -182,118 +226,138 @@ export default function AgentFormComponent({
|
||||
const selectedRunnerOption = runnerOptions.find(
|
||||
(option) => option.name === currentRunner,
|
||||
);
|
||||
|
||||
function renderRunnerStatusActions(showRetry = true) {
|
||||
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>
|
||||
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 renderRunnerStatus() {
|
||||
const runnerStatus = useMemo<AgentRunnerStatus>(() => {
|
||||
if (pluginStatusLoading) {
|
||||
return (
|
||||
<Alert>
|
||||
<LoaderCircle className="animate-spin" />
|
||||
<AlertTitle>{t('agents.runnerStatusLoading')}</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
return {
|
||||
label: t('agents.runnerStatusLoading'),
|
||||
tone: 'neutral',
|
||||
};
|
||||
}
|
||||
|
||||
if (pluginStatusError || !pluginSystemStatus) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>{t('agents.runnerStatusCheckFailed')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.runnerStatusCheckFailedDescription')}
|
||||
{renderRunnerStatusActions()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return {
|
||||
label: t('agents.runnerStatusCheckFailed'),
|
||||
description: t('agents.runnerStatusCheckFailedDescription'),
|
||||
tone: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
if (!pluginSystemStatus.is_enable) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<Power />
|
||||
<AlertTitle>{t('plugins.systemDisabled')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('plugins.systemDisabledDesc')}
|
||||
{renderRunnerStatusActions(false)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return {
|
||||
label: t('plugins.systemDisabled'),
|
||||
description: t('plugins.systemDisabledDesc'),
|
||||
tone: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
if (!pluginSystemStatus.is_connected) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<Unplug />
|
||||
<AlertTitle>{t('plugins.connectionError')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('plugins.connectionErrorDesc')}
|
||||
{renderRunnerStatusActions()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return {
|
||||
label: t('plugins.connectionError'),
|
||||
description: t('plugins.connectionErrorDesc'),
|
||||
tone: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
if (runnerOptions.length === 0) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>{t('agents.noRunnersAvailable')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.noRunnersAvailableDescription')}
|
||||
{renderRunnerStatusActions()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return {
|
||||
label: t('agents.noRunnersAvailable'),
|
||||
description: t('agents.noRunnersAvailableDescription'),
|
||||
tone: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
if (!currentRunner || !selectedRunnerOption) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>{t('agents.selectedRunnerUnavailable')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.selectedRunnerUnavailableDescription', {
|
||||
return {
|
||||
label: t('agents.selectedRunnerUnavailable'),
|
||||
description: t('agents.selectedRunnerUnavailableDescription', {
|
||||
runner: currentRunner || t('agents.noRunnerSelected'),
|
||||
})}
|
||||
{renderRunnerStatusActions()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}),
|
||||
tone: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="border-emerald-600/40 bg-emerald-500/5 text-emerald-950 dark:text-emerald-100">
|
||||
<CircleCheck className="text-emerald-600" />
|
||||
<AlertTitle>{t('agents.runnerReady')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.runnerReadyDescription', {
|
||||
runner: extractI18nObject(selectedRunnerOption.label),
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
if (missingRunnerFields.length > 0) {
|
||||
return {
|
||||
label: t('agents.runnerConfigIncomplete'),
|
||||
description: t('agents.runnerConfigIncompleteDescription', {
|
||||
fields: missingRunnerFields
|
||||
.map((field) => extractI18nObject(field.label))
|
||||
.join(', '),
|
||||
}),
|
||||
tone: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!initializedStagesRef.current.has(stageKey)) {
|
||||
initializedStagesRef.current.add(stageKey);
|
||||
@@ -364,27 +428,17 @@ export default function AgentFormComponent({
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeEventPatterns(value: string): string[] {
|
||||
const patterns = value
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return patterns.length > 0 ? patterns : ['*'];
|
||||
}
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
if (isSavingRef.current) return;
|
||||
const saveValues = useCallback(
|
||||
async (values: FormValues) => {
|
||||
if (isSavingRef.current) return false;
|
||||
const submittedSnapshot = JSON.stringify(values);
|
||||
const runner = values.runner || {};
|
||||
const agent: Partial<Agent> = {
|
||||
name: values.basic.name,
|
||||
description: values.basic.description ?? '',
|
||||
emoji: values.basic.emoji,
|
||||
enabled: values.basic.enabled ?? true,
|
||||
component_ref: (runner.id as string) || null,
|
||||
supported_event_patterns: normalizeEventPatterns(
|
||||
values.supported_event_patterns_text,
|
||||
),
|
||||
supported_event_patterns: values.supported_event_patterns,
|
||||
config: {
|
||||
runner,
|
||||
runner_config: values.runner_config ?? {},
|
||||
@@ -392,40 +446,66 @@ export default function AgentFormComponent({
|
||||
};
|
||||
|
||||
isSavingRef.current = true;
|
||||
setIsSaving(true);
|
||||
onSavingChange?.(true);
|
||||
httpClient
|
||||
.updateAgent(agentId, agent)
|
||||
.then(() => {
|
||||
try {
|
||||
await httpClient.updateAgent(agentId, agent);
|
||||
savedSnapshotRef.current = submittedSnapshot;
|
||||
onFinish();
|
||||
onFinish(agent);
|
||||
toast.success(t('agents.saveSuccess'));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('agents.saveError') + err.msg);
|
||||
})
|
||||
.finally(() => {
|
||||
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;
|
||||
setIsSaving(false);
|
||||
onSavingChange?.(false);
|
||||
});
|
||||
}
|
||||
},
|
||||
[agentId, onFinish, onSavingChange, t],
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
void saveValues(values);
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
httpClient
|
||||
.deleteAgent(agentId)
|
||||
.then(() => {
|
||||
toast.success(t('agents.deleteSuccess'));
|
||||
setShowDeleteConfirm(false);
|
||||
onDeleted();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('agents.deleteError') + err.msg);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
openSection: setActiveSection,
|
||||
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) return false;
|
||||
const valid = await form.trigger();
|
||||
if (!valid) return false;
|
||||
return (await saveValues(form.getValues())) ?? false;
|
||||
},
|
||||
}),
|
||||
[form, saveValues],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full p-0 flex flex-col">
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -433,139 +513,40 @@ export default function AgentFormComponent({
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="mb-2 flex h-full min-h-0 min-w-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-6 pb-8">
|
||||
{
|
||||
<div className="contents">
|
||||
<Card className="order-2">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.basicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.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="basic.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="basic.description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<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>
|
||||
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
|
||||
<Tabs
|
||||
value={activeSection}
|
||||
onValueChange={(value) =>
|
||||
setActiveSection(value as AgentConfigSection)
|
||||
}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<TabsList className="grid h-auto w-full min-w-0 grid-cols-3">
|
||||
{primarySections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={section.name}
|
||||
value={section.name}
|
||||
className="min-w-0 gap-1.5 px-2"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="truncate">{section.label}</span>
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
</div>
|
||||
</Tabs>
|
||||
</nav>
|
||||
|
||||
{
|
||||
<div className="order-1 space-y-6">
|
||||
{renderRunnerStatus()}
|
||||
{runnerConfigSchema?.stages.map((stage) =>
|
||||
renderDynamicStage(stage),
|
||||
)}
|
||||
{!runnerConfigSchema && (
|
||||
<div className="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-5xl space-y-6 pb-8">
|
||||
{activeSection === 'runner' && (
|
||||
<div className="space-y-6">
|
||||
{runnerSelectorStage
|
||||
? renderDynamicStage(runnerSelectorStage)
|
||||
: !runnerConfigSchema && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
|
||||
@@ -576,10 +557,27 @@ export default function AgentFormComponent({
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
|
||||
{
|
||||
<Card className="order-3">
|
||||
{activeSection === 'runner_config' && (
|
||||
<div className="space-y-6">
|
||||
{activeRunnerStage ? (
|
||||
renderDynamicStage(activeRunnerStage)
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.noRunnerMetadata')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'events' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -589,19 +587,14 @@ export default function AgentFormComponent({
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="supported_event_patterns_text"
|
||||
name="supported_event_patterns"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('agents.supportedEvents')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
className="min-h-32 font-mono text-sm"
|
||||
placeholder={'*\nmessage.received\ngroup.*'}
|
||||
<AgentEventPatternPicker
|
||||
events={availableEventTypes}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('agents.supportedEventsDescription')}
|
||||
</FormDescription>
|
||||
@@ -611,33 +604,13 @@ export default function AgentFormComponent({
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{t('agents.deleteConfirmation')}</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(AgentFormComponent);
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} 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 BotSessionMonitor 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 { toast } from 'sonner';
|
||||
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 }) {
|
||||
const isCreateMode = id === 'new';
|
||||
@@ -55,8 +62,11 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
|
||||
const [activeTab, setActiveTab] = useState('config');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
|
||||
const [bot, setBot] = useState<Bot | null>(null);
|
||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
|
||||
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
|
||||
const botFormRef = useRef<BotFormHandle>(null);
|
||||
|
||||
// Track whether the form has unsaved changes
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
@@ -69,6 +79,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
useEffect(() => {
|
||||
if (!isCreateMode) {
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBot(res.bot);
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
setEnableLoaded(true);
|
||||
});
|
||||
@@ -80,16 +91,10 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
const prev = botEnabled;
|
||||
setBotEnabled(checked);
|
||||
try {
|
||||
// Fetch current bot data to send a complete update
|
||||
const res = await httpClient.getBot(id);
|
||||
const bot = res.bot;
|
||||
await httpClient.updateBot(id, {
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
adapter: bot.adapter,
|
||||
adapter_config: bot.adapter_config,
|
||||
enable: checked,
|
||||
});
|
||||
await httpClient.updateBot(id, { enable: checked });
|
||||
setBot((current) =>
|
||||
current ? { ...current, enable: checked } : current,
|
||||
);
|
||||
refreshBots();
|
||||
} catch {
|
||||
setBotEnabled(prev);
|
||||
@@ -102,6 +107,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
function handleFormSubmit() {
|
||||
// Re-sync enable state after form save (form may update enable too)
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBot(res.bot);
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
});
|
||||
refreshBots();
|
||||
@@ -117,6 +123,26 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
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() {
|
||||
httpClient
|
||||
.deleteBot(id)
|
||||
@@ -166,8 +192,15 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
<div className="flex h-full min-w-0 flex-col">
|
||||
{/* Sticky Header: title + enable switch + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-semibold">{t('bots.editBot')}</h1>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<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 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
@@ -255,9 +288,10 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
value="config"
|
||||
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}>
|
||||
<BotForm
|
||||
ref={botFormRef}
|
||||
initBotId={id}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
@@ -344,6 +378,17 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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 { IChooseAdapterEntity } from '@/app/home/bots/components/bot-form/ChooseEntity';
|
||||
import {
|
||||
@@ -15,6 +22,7 @@ import { Agent, Bot } from '@/app/infra/entities/api';
|
||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import EventBindingsEditor from './EventBindingsEditor';
|
||||
import AdapterEventDebugDialog from './AdapterEventDebugDialog';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -79,17 +87,21 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export default function BotForm({
|
||||
initBotId,
|
||||
onFormSubmit,
|
||||
onNewBotCreated,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
export interface BotFormHandle {
|
||||
syncBasicInfo: (values: { name: string; description: string }) => void;
|
||||
}
|
||||
|
||||
interface BotFormProps {
|
||||
initBotId?: string;
|
||||
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
|
||||
onNewBotCreated: (botId: string) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
}
|
||||
|
||||
const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
{ initBotId, onFormSubmit, onNewBotCreated, onDirtyChange },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
|
||||
@@ -174,6 +186,19 @@ export default function BotForm({
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
syncBasicInfo(values) {
|
||||
form.reset(
|
||||
{
|
||||
...form.getValues(),
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
},
|
||||
{ keepDirtyValues: true },
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setBotFormValues();
|
||||
}, []);
|
||||
@@ -416,7 +441,7 @@ export default function BotForm({
|
||||
className="w-full min-w-0 max-w-full space-y-6"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{/* Card 1: Basic Information */}
|
||||
{!initBotId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
||||
@@ -456,6 +481,7 @@ export default function BotForm({
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 2: Adapter Configuration */}
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
@@ -688,4 +735,6 @@ export default function BotForm({
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default BotForm;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'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 type { TFunction } from 'i18next';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
@@ -48,7 +55,9 @@ import {
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
@@ -61,11 +70,20 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
@@ -94,9 +112,14 @@ import {
|
||||
Agent,
|
||||
BotRouteDryRunResult,
|
||||
BotEventRouteStatus,
|
||||
BotRouteTestResult,
|
||||
} from '@/app/infra/entities/api';
|
||||
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__';
|
||||
|
||||
@@ -297,20 +320,6 @@ function agentSupportsEventPattern(agent: Agent, pattern: string) {
|
||||
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
|
||||
// `bots.eventNames.<event_with_underscores>`, falling back to the raw
|
||||
// string when no translation exists (e.g. custom/unknown events).
|
||||
@@ -766,7 +775,8 @@ function AdapterCapabilitySummary({
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const concreteEvents =
|
||||
supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS;
|
||||
const previewEvents = concreteEvents.slice(0, 4);
|
||||
const concreteEventGroups = groupEventPatterns(concreteEvents);
|
||||
const optionGroups = groupEventPatterns(eventOptions);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
@@ -788,20 +798,22 @@ function AdapterCapabilitySummary({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{previewEvents.map((event) => (
|
||||
{concreteEventGroups.slice(0, 4).map((group) => (
|
||||
<Badge
|
||||
key={event}
|
||||
key={group.namespace}
|
||||
variant="secondary"
|
||||
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>
|
||||
))}
|
||||
{concreteEvents.length > previewEvents.length && (
|
||||
{concreteEventGroups.length > 4 && (
|
||||
<Badge variant="outline" className="rounded-md px-2 py-0.5">
|
||||
{t('bots.adapterEventsMore', {
|
||||
count: concreteEvents.length - previewEvents.length,
|
||||
count: concreteEventGroups.length - 4,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -823,15 +835,27 @@ function AdapterCapabilitySummary({
|
||||
</Button>
|
||||
</div>
|
||||
{advancedOpen && (
|
||||
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
|
||||
{eventOptions.map((event) => (
|
||||
<div key={event} className="min-w-0 rounded-md bg-background p-2">
|
||||
<div className="mt-3 space-y-4 border-t pt-3">
|
||||
{optionGroups.map((group) => (
|
||||
<div key={group.namespace} className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{eventGroupLabel(group.namespace, t)}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{group.patterns.map((event) => (
|
||||
<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]">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{t('bots.eventGroup')}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -845,6 +869,9 @@ function AdapterCapabilitySummary({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -857,13 +884,11 @@ function RouteDryRunDialog({
|
||||
bindings,
|
||||
eventOptions,
|
||||
agentOptions,
|
||||
onRouteStatusUpdate,
|
||||
}: {
|
||||
botId?: string;
|
||||
bindings: EventBinding[];
|
||||
eventOptions: string[];
|
||||
agentOptions: Agent[];
|
||||
onRouteStatusUpdate?: (statuses: BotEventRouteStatus[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0];
|
||||
@@ -874,12 +899,9 @@ function RouteDryRunDialog({
|
||||
);
|
||||
const [advancedPayloadOpen, setAdvancedPayloadOpen] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isDispatching, setIsDispatching] = useState(false);
|
||||
const [payloadError, setPayloadError] = useState<string | null>(null);
|
||||
const [runError, setRunError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<BotRouteDryRunResult | null>(null);
|
||||
const [dispatchResult, setDispatchResult] =
|
||||
useState<BotRouteTestResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventOptions.includes(eventType)) {
|
||||
@@ -891,7 +913,6 @@ function RouteDryRunDialog({
|
||||
setPayloadText(JSON.stringify(samplePayloadForEvent(eventType), null, 2));
|
||||
setPayloadError(null);
|
||||
setResult(null);
|
||||
setDispatchResult(null);
|
||||
}, [eventType]);
|
||||
|
||||
function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) {
|
||||
@@ -928,7 +949,6 @@ function RouteDryRunDialog({
|
||||
async function runDryRun() {
|
||||
setRunError(null);
|
||||
setResult(null);
|
||||
setDispatchResult(null);
|
||||
|
||||
const payload = parsePayload();
|
||||
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) : '';
|
||||
|
||||
return (
|
||||
@@ -1008,48 +991,55 @@ function RouteDryRunDialog({
|
||||
{t('bots.testRoute')}
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('bots.dryRunTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('bots.dryRunDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t('bots.dryRunEventType')}
|
||||
</label>
|
||||
<Select value={eventType} onValueChange={setEventType}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectTrigger
|
||||
className="h-auto min-h-9 w-full"
|
||||
aria-label={t('bots.dryRunEventType')}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventOptions.map((event) => (
|
||||
<SelectItem key={event} value={event}>
|
||||
{eventLabel(event, t)}
|
||||
<SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
|
||||
{groupEventPatterns(eventOptions).map((group) => (
|
||||
<SelectGroup key={group.namespace}>
|
||||
<SelectLabel>
|
||||
{eventGroupLabel(group.namespace, t)}
|
||||
</SelectLabel>
|
||||
{group.patterns.map((event) => (
|
||||
<SelectItem
|
||||
key={event}
|
||||
value={event}
|
||||
description={eventDescription(event, t)}
|
||||
className="py-2"
|
||||
>
|
||||
<EventSelectOptionContent
|
||||
event={event}
|
||||
label={eventLabel(event, t)}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{t('bots.dryRunSampleReady')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||
{t('bots.dryRunSampleDescription', {
|
||||
event: eventLabel(eventType, t),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2 text-xs"
|
||||
className="h-9 shrink-0 self-start px-2 text-xs text-muted-foreground sm:self-auto"
|
||||
onClick={() => setAdvancedPayloadOpen((value) => !value)}
|
||||
>
|
||||
{advancedPayloadOpen ? (
|
||||
@@ -1063,14 +1053,14 @@ function RouteDryRunDialog({
|
||||
</Button>
|
||||
</div>
|
||||
{advancedPayloadOpen && (
|
||||
<div className="mt-3 space-y-1.5 border-t pt-3">
|
||||
<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-[118px] font-mono text-xs"
|
||||
className="min-h-[110px] font-mono text-xs"
|
||||
spellCheck={false}
|
||||
placeholder='{"message_text": "hello"}'
|
||||
/>
|
||||
@@ -1084,7 +1074,6 @@ function RouteDryRunDialog({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runError && (
|
||||
<Alert variant="destructive">
|
||||
@@ -1171,24 +1160,6 @@ function RouteDryRunDialog({
|
||||
)}
|
||||
</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>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -1199,25 +1170,10 @@ function RouteDryRunDialog({
|
||||
>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={runDryRun}
|
||||
disabled={isRunning || isDispatching}
|
||||
>
|
||||
<Button type="button" onClick={runDryRun} disabled={isRunning}>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
{isRunning ? t('bots.dryRunRunning') : t('bots.dryRunAction')}
|
||||
</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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -1252,6 +1208,7 @@ function BindingCardContent({
|
||||
onUpdate,
|
||||
onRemove,
|
||||
dragHandleProps,
|
||||
isOverlay = false,
|
||||
}: BindingCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEnabled = binding.enabled ?? true;
|
||||
@@ -1263,13 +1220,21 @@ function BindingCardContent({
|
||||
const statusDetail = routeStatusDetail(routeStatus, t);
|
||||
|
||||
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 */}
|
||||
<div className="flex flex-wrap items-center gap-2 p-2.5">
|
||||
{isEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
@@ -1300,29 +1265,28 @@ function BindingCardContent({
|
||||
onUpdate(globalIndex, patch);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 min-w-[150px] flex-1 text-sm">
|
||||
{binding.event_pattern ? (
|
||||
<span className="truncate">
|
||||
{eventLabel(binding.event_pattern, t)}
|
||||
</span>
|
||||
) : (
|
||||
<SelectTrigger className="h-auto min-h-9 min-w-[220px] flex-1">
|
||||
<SelectValue placeholder={t('bots.eventPatternPlaceholder')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventOptions.map((event) => {
|
||||
const label = eventLabel(event, t);
|
||||
return (
|
||||
<SelectItem key={event} value={event}>
|
||||
<span className="flex flex-col">
|
||||
<span>{label}</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{eventDescription(event, t)}
|
||||
</span>
|
||||
</span>
|
||||
<SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
|
||||
{groupEventPatterns(eventOptions).map((group) => (
|
||||
<SelectGroup key={group.namespace}>
|
||||
<SelectLabel>{eventGroupLabel(group.namespace, t)}</SelectLabel>
|
||||
{group.patterns.map((event) => (
|
||||
<SelectItem
|
||||
key={event}
|
||||
value={event}
|
||||
description={eventDescription(event, t)}
|
||||
className="py-2"
|
||||
>
|
||||
<EventSelectOptionContent
|
||||
event={event}
|
||||
label={eventLabel(event, t)}
|
||||
/>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -1421,15 +1385,32 @@ function BindingCardContent({
|
||||
|
||||
// ── sortable wrapper ──────────────────────────────────────────────────────────
|
||||
|
||||
function SortableBindingCard(props: BindingCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useSortable({ id: props.binding.id ?? props.globalIndex });
|
||||
interface SortableBindingCardProps extends BindingCardProps {
|
||||
sortableId: string;
|
||||
}
|
||||
|
||||
function SortableBindingCard({
|
||||
sortableId,
|
||||
...props
|
||||
}: SortableBindingCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: sortableId });
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
data-testid={`event-route-${sortableId}`}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.3 : undefined,
|
||||
position: 'relative',
|
||||
zIndex: isDragging ? 1 : undefined,
|
||||
}}
|
||||
>
|
||||
<BindingCardContent
|
||||
@@ -1503,6 +1484,14 @@ export default function EventBindingsEditor({
|
||||
),
|
||||
[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 () => {
|
||||
if (!botId) {
|
||||
@@ -1516,8 +1505,8 @@ export default function EventBindingsEditor({
|
||||
const response = await backendClient.getBotEventRouteStatuses(botId);
|
||||
setRouteStatuses(response.routes || []);
|
||||
} catch (error) {
|
||||
const err = error as { msg?: string };
|
||||
setRouteStatusError(err.msg || t('bots.routeStatusRefreshFailed'));
|
||||
console.error('Failed to refresh Bot event route status', error);
|
||||
setRouteStatusError(t('bots.routeStatusRefreshFailed'));
|
||||
} finally {
|
||||
setRouteStatusLoading(false);
|
||||
}
|
||||
@@ -1653,18 +1642,18 @@ export default function EventBindingsEditor({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{catchAllRouteIndex >= 0 && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{catchAllRouteIndex >= 0
|
||||
? t('bots.routeFallbackCatchAll', {
|
||||
{t('bots.routeFallbackCatchAll', {
|
||||
route: t('bots.dryRunRuleIndex', {
|
||||
index: catchAllRouteIndex + 1,
|
||||
}),
|
||||
})
|
||||
: t('bots.routeFallbackIgnored')}
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* enabled section */}
|
||||
<DndContext
|
||||
@@ -1672,6 +1661,7 @@ export default function EventBindingsEditor({
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => setActiveId(null)}
|
||||
>
|
||||
<SortableContext
|
||||
items={idsRef.current}
|
||||
@@ -1688,6 +1678,7 @@ export default function EventBindingsEditor({
|
||||
return (
|
||||
<SortableBindingCard
|
||||
key={idsRef.current[sortIdx]}
|
||||
sortableId={idsRef.current[sortIdx]}
|
||||
binding={binding}
|
||||
globalIndex={globalIdx}
|
||||
routeStatus={
|
||||
@@ -1706,7 +1697,7 @@ export default function EventBindingsEditor({
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<DragOverlay adjustScale={false} dropAnimation={null}>
|
||||
{activeBinding && activeGlobalIdx >= 0 ? (
|
||||
<BindingCardContent
|
||||
binding={activeBinding}
|
||||
@@ -1738,6 +1729,9 @@ export default function EventBindingsEditor({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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) => {
|
||||
const Icon = preset.icon;
|
||||
return (
|
||||
@@ -1757,18 +1751,44 @@ export default function EventBindingsEditor({
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className="items-start gap-2 py-2"
|
||||
onClick={() => addBinding(dryRunEventOptions[0])}
|
||||
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">
|
||||
<span className="flex min-w-0 flex-col gap-0.5 pr-2">
|
||||
<span>{t('bots.behaviorCustom')}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{t('bots.behaviorCustomDescription')}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<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>
|
||||
</DropdownMenu>
|
||||
<RouteDryRunDialog
|
||||
@@ -1776,24 +1796,28 @@ export default function EventBindingsEditor({
|
||||
bindings={bindings}
|
||||
eventOptions={dryRunEventOptions}
|
||||
agentOptions={agentOptions}
|
||||
onRouteStatusUpdate={setRouteStatuses}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
className={`size-8 ${routeStatusError ? 'text-destructive' : 'text-muted-foreground'}`}
|
||||
aria-label={t('bots.refreshRouteStatus')}
|
||||
onClick={refreshRouteStatuses}
|
||||
disabled={!botId || routeStatusLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-1 ${routeStatusLoading ? 'animate-spin' : ''}`}
|
||||
className={`h-4 w-4 ${routeStatusLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('bots.refreshRouteStatus')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{routeStatusError || t('bots.refreshRouteStatus')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{routeStatusError && (
|
||||
<p className="text-xs text-destructive">{routeStatusError}</p>
|
||||
)}
|
||||
|
||||
{/* disabled section */}
|
||||
{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;
|
||||
|
||||
// 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 (
|
||||
pathname === '/home/mcp' ||
|
||||
pathname === '/home/skills' ||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
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 { cn } from '@/lib/utils';
|
||||
|
||||
interface ProcessorMonitoringView {
|
||||
label: 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 (
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<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}
|
||||
{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">
|
||||
{monitoring && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={activeView === 'monitoring' ? 'secondary' : 'outline'}
|
||||
onClick={() =>
|
||||
setActiveView((current) =>
|
||||
current === 'monitoring' ? 'workbench' : 'monitoring',
|
||||
)
|
||||
}
|
||||
>
|
||||
<BarChart3 className="size-4" />
|
||||
{monitoring.label}
|
||||
</Button>
|
||||
)}
|
||||
{canSave && activeView === 'workbench' && (
|
||||
<Button
|
||||
type="submit"
|
||||
form={saveFormId}
|
||||
disabled={!isDirty || isSaving}
|
||||
>
|
||||
{saveLabel}
|
||||
</Button>
|
||||
)}
|
||||
{activeView === 'workbench' && headerActions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeView === 'monitoring' && monitoring ? (
|
||||
<section
|
||||
aria-label={monitoring.label}
|
||||
className="min-h-0 flex-1 overflow-y-auto rounded-xl border bg-card p-4"
|
||||
>
|
||||
{monitoring.content}
|
||||
</section>
|
||||
) : (
|
||||
<div className="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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,10 @@ import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { toast } from 'sonner';
|
||||
import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
|
||||
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 }) {
|
||||
const isCreateMode = id === 'new';
|
||||
@@ -52,8 +56,10 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
|
||||
const [activeTab, setActiveTab] = useState('metadata');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showBasicInfoDialog, setShowBasicInfoDialog] = useState(false);
|
||||
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
const [formVersion, setFormVersion] = useState(0);
|
||||
|
||||
const loadKbInfo = useCallback(
|
||||
async (kbId: string) => {
|
||||
@@ -99,6 +105,34 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
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() {
|
||||
try {
|
||||
await httpClient.deleteKnowledgeBase(id);
|
||||
@@ -151,9 +185,18 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sticky Header: title + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('knowledge.editKnowledgeBase')}
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<h1 className="truncate text-xl font-semibold">
|
||||
{kbInfo
|
||||
? `${kbInfo.emoji || '📚'} ${kbInfo.name}`
|
||||
: t('knowledge.editKnowledgeBase')}
|
||||
</h1>
|
||||
{canManage && kbInfo && (
|
||||
<EntityTitleEditButton
|
||||
onClick={() => setShowBasicInfoDialog(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button
|
||||
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">
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<KBForm
|
||||
key={`${id}-${formVersion}`}
|
||||
initKbId={id}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
@@ -268,6 +312,20 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{kbInfo && (
|
||||
<EntityBasicInfoDialog
|
||||
open={showBasicInfoDialog}
|
||||
onOpenChange={setShowBasicInfoDialog}
|
||||
values={{
|
||||
name: kbInfo.name,
|
||||
description: kbInfo.description,
|
||||
emoji: kbInfo.emoji,
|
||||
}}
|
||||
defaultEmoji="📚"
|
||||
onSave={handleBasicInfoSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { toast } from 'sonner';
|
||||
@@ -100,7 +101,7 @@ export default function KBForm({
|
||||
const [retrievalSettings, setRetrievalSettings] = useState<
|
||||
Record<string, unknown>
|
||||
>({});
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(Boolean(initKbId));
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Dirty tracking: snapshot of saved state for comparison
|
||||
@@ -341,7 +342,8 @@ export default function KBForm({
|
||||
id="kb-form"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Card 1: Basic Information */}
|
||||
{/* Basic information is entered here only during creation. */}
|
||||
{!isEditing && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('knowledge.basicInfo')}</CardTitle>
|
||||
@@ -400,8 +402,19 @@ export default function KBForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Knowledge Engine Selector */}
|
||||
{/* Knowledge engine selection and settings stay together. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('knowledge.engineSettings')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.engineSettingsDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ragEngineId"
|
||||
@@ -484,19 +497,10 @@ export default function KBForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Engine Settings (dynamic form from creation_schema) */}
|
||||
{configFormItems.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('knowledge.engineSettings')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.engineSettingsDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<>
|
||||
<Separator />
|
||||
<DynamicFormComponent
|
||||
itemConfigList={configFormItems}
|
||||
initialValues={configSettings as Record<string, object>}
|
||||
@@ -509,11 +513,12 @@ export default function KBForm({
|
||||
(configValidateRef.current = validateFn)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 3: Retrieval Settings (dynamic form from retrieval_schema) */}
|
||||
{/* Retrieval Settings (dynamic form from retrieval_schema) */}
|
||||
{retrievalFormItems.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -269,7 +269,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</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
|
||||
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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { toast } from 'sonner';
|
||||
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 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 { useTranslation } from 'react-i18next';
|
||||
import { Settings, Bug, BarChart3 } from 'lucide-react';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Pipeline } from '@/app/infra/entities/api';
|
||||
|
||||
export default function PipelineDetailContent({
|
||||
id,
|
||||
@@ -40,15 +48,50 @@ export default function PipelineDetailContent({
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('config');
|
||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||
const [formDirty, setFormDirty] = 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() {
|
||||
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) {
|
||||
refreshPipelines();
|
||||
navigate(`${routeBase}?id=${encodeURIComponent(newPipelineId)}`);
|
||||
@@ -95,63 +138,33 @@ export default function PipelineDetailContent({
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
const pipelineName =
|
||||
pipelineDetails?.name ||
|
||||
sidebarPipeline?.name ||
|
||||
t('pipelines.editPipeline');
|
||||
const pipelineEmoji =
|
||||
pipelineDetails?.emoji || sidebarPipeline?.emoji || '⚙️';
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sticky Header: title + save button */}
|
||||
<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
|
||||
<>
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-1 flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="shrink-0">
|
||||
<TabsTrigger value="config" className="gap-1.5">
|
||||
<Settings className="size-3.5" />
|
||||
{t('pipelines.configuration')}
|
||||
</TabsTrigger>
|
||||
{canOperate && (
|
||||
<TabsTrigger value="debug" className="gap-1.5">
|
||||
<Bug className="size-3.5" />
|
||||
{t('pipelines.debugChat')}
|
||||
{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"
|
||||
>
|
||||
title={`${pipelineEmoji} ${pipelineName}`}
|
||||
titleAction={
|
||||
canManage ? (
|
||||
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
|
||||
) : undefined
|
||||
}
|
||||
saveLabel={t('common.save')}
|
||||
saveFormId="pipeline-form"
|
||||
canSave={canManage}
|
||||
isDirty={formDirty}
|
||||
isSaving={formSaving}
|
||||
configTitle={t('pipelines.configuration')}
|
||||
configContent={
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<PipelineFormComponent
|
||||
ref={pipelineFormRef}
|
||||
pipelineId={id}
|
||||
isEditMode={true}
|
||||
disableForm={!canManage}
|
||||
@@ -164,35 +177,52 @@ export default function PipelineDetailContent({
|
||||
onSavingChange={setFormSaving}
|
||||
/>
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Debug */}
|
||||
{canOperate && (
|
||||
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
|
||||
}
|
||||
debugTitle={canOperate ? t('pipelines.debugChat') : undefined}
|
||||
debugConnected={canOperate ? isWebSocketConnected : undefined}
|
||||
debugConnectedLabel={t('pipelines.debugDialog.connected')}
|
||||
debugDisconnectedLabel={t('pipelines.debugDialog.disconnected')}
|
||||
debugContent={
|
||||
canOperate ? (
|
||||
<DebugDialog
|
||||
open={activeTab === 'debug'}
|
||||
open={true}
|
||||
pipelineId={id}
|
||||
isEmbedded={true}
|
||||
compact={true}
|
||||
hasUnsavedChanges={formDirty}
|
||||
beforeSend={async () => pipelineFormRef.current?.save() ?? false}
|
||||
onConnectionStatusChange={setIsWebSocketConnected}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Tab: Monitoring */}
|
||||
{canViewMonitoring && (
|
||||
<TabsContent
|
||||
value="monitoring"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
monitoring={
|
||||
canViewMonitoring
|
||||
? {
|
||||
label: t('pipelines.monitoring.title'),
|
||||
content: (
|
||||
<PipelineMonitoringTab
|
||||
pipelineId={id}
|
||||
onNavigateToMonitoring={() => {
|
||||
navigate('/home/monitoring');
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<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 { DialogContent } from '@/components/ui/dialog';
|
||||
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 { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -40,13 +40,17 @@ import {
|
||||
Music,
|
||||
Code,
|
||||
AlignLeft,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DebugDialogProps {
|
||||
open: boolean;
|
||||
pipelineId: string;
|
||||
isEmbedded?: boolean;
|
||||
compact?: boolean;
|
||||
onConnectionStatusChange?: (isConnected: boolean) => void;
|
||||
beforeSend?: () => Promise<boolean>;
|
||||
hasUnsavedChanges?: boolean;
|
||||
}
|
||||
|
||||
function AuthenticatedMessageImage({
|
||||
@@ -115,7 +119,10 @@ export default function DebugDialog({
|
||||
open,
|
||||
pipelineId,
|
||||
isEmbedded = false,
|
||||
compact = false,
|
||||
onConnectionStatusChange,
|
||||
beforeSend,
|
||||
hasUnsavedChanges = false,
|
||||
}: DebugDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
|
||||
@@ -142,43 +149,61 @@ export default function DebugDialog({
|
||||
new Set(),
|
||||
);
|
||||
const [streamOutput, setStreamOutput] = useState(true);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const wsClientRef = useRef<WebSocketClient | null>(null);
|
||||
const isInitializingRef = useRef<boolean>(false);
|
||||
const historyRequestGenerationRef = useRef(0);
|
||||
|
||||
const invalidateHistoryRequests = useCallback(() => {
|
||||
historyRequestGenerationRef.current++;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
// Use setTimeout to ensure scroll happens after DOM update
|
||||
setTimeout(() => {
|
||||
const scrollArea = document.querySelector('.scroll-area') as HTMLElement;
|
||||
if (scrollArea) {
|
||||
scrollArea.scrollTo({
|
||||
top: scrollArea.scrollHeight,
|
||||
const viewport = scrollAreaRef.current?.querySelector<HTMLElement>(
|
||||
'[data-slot="scroll-area-viewport"]',
|
||||
);
|
||||
viewport?.scrollTo({
|
||||
top: viewport.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
// Also ensure messagesEndRef scrolls into view
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
const loadMessages = useCallback(
|
||||
async (pipelineId: string) => {
|
||||
const generation = ++historyRequestGenerationRef.current;
|
||||
try {
|
||||
const response = await httpClient.getWebSocketHistoryMessages(
|
||||
pipelineId,
|
||||
sessionType,
|
||||
);
|
||||
setMessages(response.messages);
|
||||
if (generation !== historyRequestGenerationRef.current) return;
|
||||
setMessages(Array.isArray(response.messages) ? response.messages : []);
|
||||
} catch (error) {
|
||||
if (generation !== historyRequestGenerationRef.current) return;
|
||||
console.error('Failed to load messages:', error);
|
||||
}
|
||||
},
|
||||
[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
|
||||
const initWebSocket = useCallback(
|
||||
async (pipelineId: string) => {
|
||||
@@ -187,24 +212,30 @@ export default function DebugDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
let wsClient: WebSocketClient | null = null;
|
||||
let errorReported = false;
|
||||
try {
|
||||
isInitializingRef.current = true;
|
||||
|
||||
// Disconnect old connection
|
||||
if (wsClientRef.current) {
|
||||
wsClientRef.current.disconnect();
|
||||
const previousClient = wsClientRef.current;
|
||||
wsClientRef.current = null;
|
||||
}
|
||||
previousClient?.disconnect();
|
||||
|
||||
// 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
|
||||
.onConnected(() => {
|
||||
if (wsClientRef.current !== wsClient) return;
|
||||
setIsConnected(true);
|
||||
isInitializingRef.current = false;
|
||||
})
|
||||
.onMessage((wsMessage) => {
|
||||
if (wsClientRef.current !== wsClient) return;
|
||||
// Convert WebSocketMessage to Message type
|
||||
const message: Message = {
|
||||
...wsMessage,
|
||||
@@ -229,27 +260,33 @@ export default function DebugDialog({
|
||||
});
|
||||
})
|
||||
.onError((error) => {
|
||||
if (wsClientRef.current !== wsClient) return;
|
||||
errorReported = true;
|
||||
console.error('WebSocket error:', error);
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
toast.error(t('pipelines.debugDialog.connectionError'));
|
||||
})
|
||||
.onClose(() => {
|
||||
if (wsClientRef.current !== wsClient) return;
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
})
|
||||
.onBroadcast((message) => {
|
||||
if (wsClientRef.current !== wsClient) return;
|
||||
toast.info(message);
|
||||
});
|
||||
|
||||
await wsClient.connect();
|
||||
wsClientRef.current = wsClient;
|
||||
} catch (error) {
|
||||
if (!wsClient || wsClientRef.current !== wsClient) return;
|
||||
console.error('WebSocket connection failed:', error);
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
if (!errorReported) {
|
||||
toast.error(t('pipelines.debugDialog.connectionFailed'));
|
||||
}
|
||||
}
|
||||
},
|
||||
[sessionType, t],
|
||||
);
|
||||
@@ -264,24 +301,28 @@ export default function DebugDialog({
|
||||
if (open) {
|
||||
setSelectedPipelineId(pipelineId);
|
||||
} else {
|
||||
invalidateHistoryRequests();
|
||||
// Disconnect WebSocket immediately when dialog closes
|
||||
if (wsClientRef.current) {
|
||||
wsClientRef.current.disconnect();
|
||||
const wsClient = wsClientRef.current;
|
||||
wsClientRef.current = null;
|
||||
wsClient.disconnect();
|
||||
setIsConnected(false);
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
invalidateHistoryRequests();
|
||||
// Disconnect WebSocket on component unmount
|
||||
if (wsClientRef.current) {
|
||||
wsClientRef.current.disconnect();
|
||||
const wsClient = wsClientRef.current;
|
||||
wsClientRef.current = null;
|
||||
wsClient.disconnect();
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
};
|
||||
}, [open, pipelineId]);
|
||||
}, [open, pipelineId, invalidateHistoryRequests]);
|
||||
|
||||
// Reload messages and reconnect when sessionType or selectedPipelineId changes
|
||||
useEffect(() => {
|
||||
@@ -321,7 +362,7 @@ export default function DebugDialog({
|
||||
}
|
||||
}, [showAtPopover]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
if (sessionType === 'group') {
|
||||
if (value.endsWith('@')) {
|
||||
@@ -412,8 +453,11 @@ export default function DebugDialog({
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
if (hasUnsavedChanges && beforeSend && !(await beforeSend())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageChain = [];
|
||||
const messageChain: MessageChainComponent[] = [];
|
||||
|
||||
// Add quoted message if present
|
||||
if (quotedMessage) {
|
||||
@@ -467,17 +511,21 @@ export default function DebugDialog({
|
||||
type: 'Image',
|
||||
path: result.file_key,
|
||||
});
|
||||
} else {
|
||||
} else if (attachment.kind === 'voice') {
|
||||
// Voice / File go through the generic document upload endpoint,
|
||||
// which returns a storage key the backend resolves into the
|
||||
// sandbox inbox just like images.
|
||||
const result = await httpClient.uploadDocumentFile(attachment.file);
|
||||
messageChain.push({
|
||||
type: attachment.kind === 'voice' ? 'Voice' : 'File',
|
||||
type: 'Voice',
|
||||
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) {
|
||||
@@ -804,39 +852,58 @@ export default function DebugDialog({
|
||||
};
|
||||
|
||||
const renderContent = () => (
|
||||
<div className="flex flex-1 h-full min-h-0">
|
||||
<div className="w-14 p-2 pl-0 shrink-0 flex flex-col justify-start gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<div className="flex flex-1 h-full min-h-0 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
|
||||
sessionType === 'person'
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'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
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={sessionType === 'person'}
|
||||
className={cn(
|
||||
'shadow-none',
|
||||
sessionType === 'person' &&
|
||||
'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary',
|
||||
)}
|
||||
onClick={() => setSessionType('person')}
|
||||
>
|
||||
<User className="size-5" />
|
||||
<User className="size-4" />
|
||||
{t('pipelines.debugDialog.privateChat')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="sm"
|
||||
aria-pressed={sessionType === 'group'}
|
||||
className={cn(
|
||||
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
|
||||
sessionType === 'group'
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'shadow-none',
|
||||
sessionType === 'group' &&
|
||||
'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary',
|
||||
)}
|
||||
onClick={() => setSessionType('group')}
|
||||
>
|
||||
<Users className="size-5" />
|
||||
<Users className="size-4" />
|
||||
{t('pipelines.debugDialog.groupChat')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
||||
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 scroll-area">
|
||||
<div className="space-y-6">
|
||||
<div className="flex-1 flex flex-col w-full h-full min-h-0">
|
||||
<ScrollArea
|
||||
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 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||
{t('pipelines.debugDialog.noMessages')}
|
||||
@@ -852,7 +919,10 @@ export default function DebugDialog({
|
||||
>
|
||||
<div
|
||||
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'
|
||||
? 'user-message-bubble bg-primary/10 text-foreground rounded-br-none'
|
||||
: 'bg-muted text-foreground rounded-bl-none',
|
||||
@@ -919,7 +989,6 @@ export default function DebugDialog({
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -990,8 +1059,11 @@ export default function DebugDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 pb-0 flex gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div
|
||||
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">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('pipelines.debugDialog.streamOutput')}
|
||||
@@ -1020,17 +1092,34 @@ export default function DebugDialog({
|
||||
>
|
||||
<ImageIcon className="size-5" />
|
||||
</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 className="flex-1 flex items-center gap-2">
|
||||
|
||||
<div className="flex min-w-0 items-end gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{hasAt && (
|
||||
<AtBadge targetName="websocketbot" onRemove={handleAtRemove} />
|
||||
<div className="mb-1">
|
||||
<AtBadge
|
||||
targetName="websocketbot"
|
||||
onRemove={handleAtRemove}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={t('pipelines.debugDialog.inputPlaceholder', {
|
||||
type:
|
||||
sessionType === 'person'
|
||||
@@ -1038,7 +1127,8 @@ export default function DebugDialog({
|
||||
: t('pipelines.debugDialog.groupChat'),
|
||||
})}
|
||||
disabled={!isConnected || isUploading}
|
||||
className="flex-1 rounded-md px-3 py-2 transition-none text-base disabled:opacity-50"
|
||||
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
|
||||
@@ -1072,20 +1162,26 @@ export default function DebugDialog({
|
||||
!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"
|
||||
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" />
|
||||
{t('pipelines.debugDialog.send')}
|
||||
{hasUnsavedChanges
|
||||
? t('pipelines.debugDialog.saveAndSend')
|
||||
: t('pipelines.debugDialog.send')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Embedded mode: return content directly
|
||||
|
||||
@@ -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 { GetPipelineResponseData, Pipeline } from '@/app/infra/entities/api';
|
||||
import {
|
||||
@@ -8,6 +15,7 @@ import {
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -31,7 +39,6 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -51,17 +58,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
|
||||
|
||||
export default function PipelineFormComponent({
|
||||
onFinish,
|
||||
onNewPipelineCreated,
|
||||
isEditMode,
|
||||
pipelineId,
|
||||
showButtons = true,
|
||||
onDeletePipeline,
|
||||
onCancel,
|
||||
onDirtyChange,
|
||||
onSavingChange,
|
||||
}: {
|
||||
interface PipelineFormComponentProps {
|
||||
pipelineId?: string;
|
||||
isEditMode: boolean;
|
||||
disableForm: boolean;
|
||||
@@ -72,7 +69,34 @@ export default function PipelineFormComponent({
|
||||
onCancel?: () => void;
|
||||
onDirtyChange?: (dirty: 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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
|
||||
@@ -118,7 +142,7 @@ export default function PipelineFormComponent({
|
||||
const formLabelList: SectionItem[] = isEditMode
|
||||
? [
|
||||
{
|
||||
label: t('pipelines.basicInfo'),
|
||||
label: t('common.management'),
|
||||
name: '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] =
|
||||
useState<PipelineConfigTab>();
|
||||
@@ -259,7 +296,7 @@ export default function PipelineFormComponent({
|
||||
|
||||
function handleFormSubmit(values: FormValues) {
|
||||
if (isEditMode) {
|
||||
handleModify(values);
|
||||
void handleModify(values);
|
||||
} else {
|
||||
handleCreate(values);
|
||||
}
|
||||
@@ -293,8 +330,8 @@ export default function PipelineFormComponent({
|
||||
});
|
||||
}
|
||||
|
||||
function handleModify(values: FormValues) {
|
||||
if (isSavingRef.current) return;
|
||||
async function handleModify(values: FormValues): Promise<boolean> {
|
||||
if (isSavingRef.current) return false;
|
||||
const submittedSnapshot = JSON.stringify(values);
|
||||
const realConfig = {
|
||||
ai: values.ai,
|
||||
@@ -318,22 +355,53 @@ export default function PipelineFormComponent({
|
||||
isSavingRef.current = true;
|
||||
setIsSaving(true);
|
||||
onSavingChange?.(true);
|
||||
httpClient
|
||||
.updatePipeline(pipelineId || '', pipeline)
|
||||
.then(() => {
|
||||
try {
|
||||
await httpClient.updatePipeline(pipelineId || '', pipeline);
|
||||
savedSnapshotRef.current = submittedSnapshot;
|
||||
onFinish();
|
||||
toast.success(t('pipelines.saveSuccess'));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('pipelines.saveError') + err.msg);
|
||||
})
|
||||
.finally(() => {
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message =
|
||||
typeof err === 'object' && err && 'msg' in err
|
||||
? String((err as { msg?: string }).msg || '')
|
||||
: '';
|
||||
toast.error(t('pipelines.saveError') + message);
|
||||
return 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.
|
||||
// On the first emission for a stage (mount-time default filling), the
|
||||
@@ -567,50 +635,72 @@ export default function PipelineFormComponent({
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
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">
|
||||
{/* Vertical section navigation (only show when multiple sections) */}
|
||||
<div className="flex-1 flex min-h-0 flex-col">
|
||||
{/* Keep the primary pipeline flow visible while editing. */}
|
||||
{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">
|
||||
<ul className="flex md:flex-col gap-1 md:space-y-1">
|
||||
{formLabelList.map((section) => {
|
||||
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
|
||||
<Tabs value={activeSection} onValueChange={setActiveSection}>
|
||||
<div className="overflow-x-auto">
|
||||
<TabsList className="grid min-w-[34rem] w-full grid-cols-3">
|
||||
{primarySections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<li key={section.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSection(section.name)}
|
||||
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
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
)}
|
||||
<TabsTrigger
|
||||
key={section.name}
|
||||
value={section.name}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<Icon />
|
||||
{section.label}
|
||||
</button>
|
||||
</li>
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{secondarySections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<Button
|
||||
key={section.name}
|
||||
type="button"
|
||||
variant={
|
||||
activeSection === section.name
|
||||
? 'secondary'
|
||||
: 'ghost'
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => setActiveSection(section.name)}
|
||||
>
|
||||
<Icon />
|
||||
{section.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Tabs>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Content panel */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{/* Basic info section */}
|
||||
{activeSection === 'basic' && (
|
||||
<div className="space-y-6">
|
||||
{/* Basic Information Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('pipelines.basicInfo')}</CardTitle>
|
||||
<CardTitle>
|
||||
{isEditMode
|
||||
? t('common.management')
|
||||
: t('pipelines.basicInfo')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('pipelines.basicInfoDescription')}
|
||||
{isEditMode
|
||||
? t('pipelines.managementDescription')
|
||||
: t('pipelines.basicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Name and Emoji in same row */}
|
||||
{!isEditMode && (
|
||||
<>
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -619,10 +709,15 @@ export default function PipelineFormComponent({
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
{t('common.name')}
|
||||
<span className="text-destructive">*</span>
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -651,16 +746,22 @@ export default function PipelineFormComponent({
|
||||
name="basic.description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.description')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t('common.description')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Copy pipeline (edit mode only) */}
|
||||
{isEditMode && (
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
@@ -851,7 +952,9 @@ export default function PipelineFormComponent({
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default PipelineFormComponent;
|
||||
interface SectionItem {
|
||||
label: string;
|
||||
name: string;
|
||||
|
||||
@@ -177,7 +177,6 @@ export interface Agent {
|
||||
kind: AgentKind;
|
||||
component_ref?: string | null;
|
||||
config?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
supported_event_patterns?: string[];
|
||||
capability?: AgentCapability;
|
||||
created_at?: string;
|
||||
@@ -276,11 +275,6 @@ export interface BotRouteDryRunRequest {
|
||||
event_bindings?: EventBinding[];
|
||||
}
|
||||
|
||||
export interface BotRouteTestRequest {
|
||||
event_type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface BotRouteDryRunTarget {
|
||||
target_type: EventBinding['target_type'];
|
||||
target_uuid?: string | null;
|
||||
@@ -335,17 +329,6 @@ export interface BotEventRouteStatusResponse {
|
||||
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 {
|
||||
bases: KnowledgeBase[];
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface Plain extends MessageComponent {
|
||||
// Quote component
|
||||
export interface Quote extends MessageComponent {
|
||||
type: 'Quote';
|
||||
id?: number;
|
||||
id?: number | string;
|
||||
group_id?: number | string;
|
||||
sender_id?: number | string;
|
||||
target_id?: number | string;
|
||||
|
||||
@@ -61,8 +61,6 @@ import {
|
||||
ApiRespSkill,
|
||||
BotRouteDryRunRequest,
|
||||
BotRouteDryRunResult,
|
||||
BotRouteTestRequest,
|
||||
BotRouteTestResult,
|
||||
BotEventRouteStatusResponse,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
@@ -329,7 +327,10 @@ export class BackendClient extends BaseHttpClient {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -489,7 +490,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -509,16 +510,6 @@ export class BackendClient extends BaseHttpClient {
|
||||
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> {
|
||||
return this.delete(`/api/v1/platform/bots/${uuid}`);
|
||||
}
|
||||
@@ -1307,6 +1298,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
public getAccountInfo(): Promise<{
|
||||
initialized: boolean;
|
||||
authenticated_invitation_acceptance_enabled?: boolean;
|
||||
invitation_registration_enabled?: boolean;
|
||||
password_login_enabled?: boolean;
|
||||
space_login_enabled?: boolean;
|
||||
}> {
|
||||
|
||||
@@ -4,9 +4,10 @@ export interface GetBotLogsResponse {
|
||||
}
|
||||
|
||||
export interface BotLog {
|
||||
images: [];
|
||||
images: string[];
|
||||
level: string;
|
||||
message_session_id: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
seq_id: number;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
* 用于管理WebSocket连接和消息处理
|
||||
*/
|
||||
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
|
||||
import type { MessageChainComponent } from '@/app/infra/entities/message';
|
||||
|
||||
export interface WebSocketMessage {
|
||||
id: number;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
message_chain: Array<{ type: string; text?: string; target?: string }>;
|
||||
message_chain: MessageChainComponent[];
|
||||
timestamp: string;
|
||||
is_final?: boolean;
|
||||
connection_id?: string;
|
||||
@@ -36,9 +37,12 @@ export class WebSocketClient {
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private reconnectDelay = 3000; // 3秒重连间隔
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
private heartbeatIntervalMs = 30000; // 30秒
|
||||
private isConnecting = false; // 防止重复连接
|
||||
private shouldReconnect = true;
|
||||
private disconnectedByUser = false;
|
||||
|
||||
// 事件回调
|
||||
private onConnectedCallback?: (data: WebSocketResponse) => void;
|
||||
@@ -59,6 +63,13 @@ export class WebSocketClient {
|
||||
public connect(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.disconnectedByUser = false;
|
||||
this.shouldReconnect = true;
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
// 防止重复连接
|
||||
if (
|
||||
this.isConnecting ||
|
||||
@@ -87,22 +98,27 @@ export class WebSocketClient {
|
||||
window.location.host;
|
||||
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 = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
socket.onopen = () => {
|
||||
if (this.disconnectedByUser || this.ws !== socket) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
this.isConnecting = false;
|
||||
const token = this.token || localStorage.getItem('token');
|
||||
const workspaceUuid = getActiveWorkspaceUuid();
|
||||
if (!token || !workspaceUuid) {
|
||||
const error = new Error('WebSocket认证信息缺失');
|
||||
this.shouldReconnect = false;
|
||||
this.onErrorCallback?.(error);
|
||||
this.ws?.close();
|
||||
socket.close();
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
this.ws?.send(
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'authenticate',
|
||||
token,
|
||||
@@ -112,13 +128,23 @@ export class WebSocketClient {
|
||||
};
|
||||
|
||||
// 接收消息
|
||||
this.ws.onmessage = (event) => {
|
||||
socket.onmessage = (event) => {
|
||||
if (this.disconnectedByUser || this.ws !== socket) return;
|
||||
try {
|
||||
const data: WebSocketResponse = JSON.parse(event.data);
|
||||
this.handleMessage(data);
|
||||
|
||||
if (data.type === 'error' && !this.connectionId) {
|
||||
reject(new Error(data.message || 'WebSocket连接失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 第一次连接成功
|
||||
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.startHeartbeat();
|
||||
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.stopHeartbeat();
|
||||
if (this.disconnectedByUser) return;
|
||||
this.onCloseCallback?.();
|
||||
|
||||
// 自动重连
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
if (
|
||||
this.shouldReconnect &&
|
||||
this.reconnectAttempts < this.maxReconnectAttempts
|
||||
) {
|
||||
this.reconnectAttempts++;
|
||||
setTimeout(() => {
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.reconnectTimeout = null;
|
||||
if (!this.shouldReconnect || this.disconnectedByUser) return;
|
||||
this.connect().catch(console.error);
|
||||
}, 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);
|
||||
this.isConnecting = false;
|
||||
const error = new Error('WebSocket连接失败');
|
||||
@@ -210,6 +250,13 @@ export class WebSocketClient {
|
||||
case 'error':
|
||||
const error = new Error(data.message || '未知错误');
|
||||
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;
|
||||
|
||||
default:
|
||||
@@ -221,7 +268,7 @@ export class WebSocketClient {
|
||||
* 发送消息
|
||||
*/
|
||||
public sendMessage(
|
||||
messageChain: Array<{ type: string; text?: string; target?: string }>,
|
||||
messageChain: MessageChainComponent[],
|
||||
stream: boolean = true,
|
||||
) {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
@@ -272,21 +319,27 @@ export class WebSocketClient {
|
||||
* 断开连接
|
||||
*/
|
||||
public disconnect() {
|
||||
if (this.ws) {
|
||||
this.stopHeartbeat();
|
||||
|
||||
// 停止自动重连
|
||||
this.disconnectedByUser = true;
|
||||
this.shouldReconnect = false;
|
||||
this.reconnectAttempts = this.maxReconnectAttempts;
|
||||
|
||||
// 发送断开消息
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'disconnect' }));
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.stopHeartbeat();
|
||||
const socket = this.ws;
|
||||
|
||||
// 发送断开消息
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'disconnect' }));
|
||||
}
|
||||
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
this.connectionId = null;
|
||||
this.isConnecting = false;
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,9 @@ export default function AcceptInvitationPage() {
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
||||
const [invitationRegistrationEnabled, setInvitationRegistrationEnabled] =
|
||||
useState(false);
|
||||
const [invitationCapabilitiesLoaded, setInvitationCapabilitiesLoaded] =
|
||||
useState(false);
|
||||
const [
|
||||
authenticatedInvitationAcceptanceEnabled,
|
||||
@@ -116,12 +118,16 @@ export default function AcceptInvitationPage() {
|
||||
backendClient
|
||||
.getAccountInfo()
|
||||
.then((info) => {
|
||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
||||
setInvitationRegistrationEnabled(
|
||||
info.invitation_registration_enabled ??
|
||||
info.password_login_enabled !== false,
|
||||
);
|
||||
setAuthenticatedInvitationAcceptanceEnabled(
|
||||
info.authenticated_invitation_acceptance_enabled === true,
|
||||
);
|
||||
})
|
||||
.catch(() => setPasswordRegistrationEnabled(false));
|
||||
.catch(() => setInvitationRegistrationEnabled(false))
|
||||
.finally(() => setInvitationCapabilitiesLoaded(true));
|
||||
if (!invitationToken) {
|
||||
setErrorMessage(t('workspace.invitationMissing'));
|
||||
setStatus('error');
|
||||
@@ -311,7 +317,11 @@ export default function AcceptInvitationPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
||||
{!invitationCapabilitiesLoaded ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
) : hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={status === 'submitting'}
|
||||
@@ -331,7 +341,7 @@ export default function AcceptInvitationPage() {
|
||||
{t('workspace.logoutAndReturn')}
|
||||
</Button>
|
||||
</div>
|
||||
) : passwordRegistrationEnabled ? (
|
||||
) : invitationRegistrationEnabled ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
@@ -376,6 +386,8 @@ export default function AcceptInvitationPage() {
|
||||
>
|
||||
{t('workspace.confirmPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="invite-password-confirm"
|
||||
type="password"
|
||||
@@ -383,9 +395,11 @@ export default function AcceptInvitationPage() {
|
||||
onChange={(event) =>
|
||||
setConfirmPassword(event.target.value)
|
||||
}
|
||||
className="pl-10"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={status === 'submitting'}
|
||||
@@ -396,14 +410,6 @@ export default function AcceptInvitationPage() {
|
||||
)}
|
||||
{t('workspace.registerAndAccept')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
disabled={status === 'submitting'}
|
||||
onClick={() => navigate('/login?invitation=1')}
|
||||
>
|
||||
{t('workspace.alreadyHaveAccount')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user