Compare commits

...

59 Commits

Author SHA1 Message Date
RockChinQ 823feac7e0 fix(bots): clarify adapter test prerequisites 2026-08-26 23:14:42 +08:00
RockChinQ a7badf6258 fix(bots): move adapter debugger to configuration 2026-08-26 23:07:02 +08:00
RockChinQ 3d692fa8db feat(bots): add platform event debugger 2026-08-26 19:46:59 +08:00
RockChinQ 7990d36c78 fix(bots): align route event selector 2026-08-26 19:32:39 +08:00
RockChinQ 600a173918 refactor(bots): remove route execution test 2026-08-26 14:28:30 +08:00
RockChinQ 8b63cc0281 fix(events): show raw codes in test selectors 2026-08-26 14:12:52 +08:00
RockChinQ 4787799cd8 fix(agents): align debug event selection 2026-08-26 14:06:09 +08:00
RockChinQ 47f5515fa9 fix(pipelines): retain debug chat image history 2026-08-26 00:32:03 +08:00
RockChinQ 94fd3d274c refactor(pipelines): simplify connection status copy 2026-08-26 00:24:37 +08:00
RockChinQ 8380bfc7d4 refactor(pipelines): soften chat session selection 2026-08-26 00:22:22 +08:00
RockChinQ a936734efa fix(pipelines): move chat reset action to footer 2026-08-26 00:20:16 +08:00
RockChinQ 0c5656d0d0 refactor(pipelines): streamline debug chat controls 2026-08-26 00:18:46 +08:00
RockChinQ 532d1b73d2 refactor(bots): group event route creation menu 2026-08-26 00:10:51 +08:00
RockChinQ aaeb9ad178 fix(bots): restore event route drag sorting 2026-08-26 00:08:09 +08:00
RockChinQ d28b385a9f refactor(events): group event selection options 2026-08-25 23:59:52 +08:00
RockChinQ 4d7a333802 feat(agent): add visual event range picker 2026-08-25 23:51:24 +08:00
RockChinQ 114612a984 refactor(agent): move delete action to header 2026-08-25 23:34:35 +08:00
RockChinQ 2aceefce47 test(agent): remove obsolete enabled fixture 2026-08-25 23:28:37 +08:00
RockChinQ 80f1790e1d refactor(agent): remove enabled state 2026-08-25 23:27:45 +08:00
RockChinQ 22d9053bf1 feat(web): streamline management and knowledge settings 2026-08-25 23:02:33 +08:00
RockChinQ db2a9155f8 feat(web): edit entity details from page titles 2026-08-25 22:31:23 +08:00
RockChinQ 69ca7e21cd fix(bots): explain unavailable route tests 2026-08-25 22:03:57 +08:00
RockChinQ 792d961d28 fix(web): clarify route test guidance 2026-08-25 21:53:06 +08:00
RockChinQ a8eb265c11 fix(web): clarify route test warning 2026-08-25 21:31:02 +08:00
RockChinQ e62f8a957a fix(web): simplify route test dialog 2026-08-25 21:27:33 +08:00
RockChinQ ad6b8b3209 fix(web): restore bot card spacing 2026-08-25 21:21:36 +08:00
RockChinQ 68620c4572 fix(bots): simplify event routing status 2026-08-25 21:18:01 +08:00
RockChinQ 6a6a2b865b fix(web): make processor debugging reliable 2026-08-25 15:52:59 +08:00
RockChinQ 781d8a9ac8 fix(web): clean up pipeline debug websocket 2026-08-25 12:50:15 +08:00
RockChinQ e3150e66a4 refactor(web): use shadcn processor navigation 2026-08-25 12:36:57 +08:00
RockChinQ ac31f1f006 feat(web): surface agent basics and runner status 2026-08-25 12:17:59 +08:00
RockChinQ f65cca3f40 fix(web): contain pipeline debug scrolling 2026-08-25 12:03:43 +08:00
RockChinQ e70a0d3f01 fix(web): remove agent debug run notice 2026-08-25 11:58:54 +08:00
RockChinQ 46aea2b499 feat(web): unify processor detail workbench 2026-08-25 11:54:59 +08:00
dadachann 49d0aac210 Merge master into dev/4.11.x 2026-08-24 02:55:52 +00:00
Hyu bb366779af fix(auth): align invitation password fields (#2461)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-24 10:53:05 +08:00
dadachann 0559d9d441 Merge master into dev/4.11.x
# Conflicts:
#	pyproject.toml
#	uv.lock
2026-08-24 02:34:14 +00:00
Hyu 1336f47cb4 fix(auth): enable local registration for OSS invitations (#2460)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-24 10:28:45 +08:00
RockChinQ 962366c507 fix(deps): make SeekDB optional for native installs 2026-08-21 12:07:36 +08:00
Hyu 23875b240f Merge pull request #2452 from langbot-app/ci/immutable-core-images
ci: publish immutable Core image tags
2026-08-21 10:19:58 +08:00
dadachann e699358a5a ci: publish immutable Core image tags 2026-08-21 02:16:34 +00:00
Hyu 14277d129c Merge pull request #2451 from langbot-app/fix/release-4.10.8-cloud
fix(cloud): carry runtime readiness fixes into 4.10.8
2026-08-21 03:12:12 +08:00
dadachann 6bf1546df2 style: format Cloud runtime readiness fixes 2026-08-20 19:08:41 +00:00
dadachann 0bec72a3f9 fix(cloud): run runtime initialization outside transaction gate 2026-08-20 18:55:24 +00:00
dadachann f36542135a fix(cloud): start API before runtime reconcile 2026-08-20 18:55:24 +00:00
dadachann 693c59b726 fix(cloud): import lifecycle task scope 2026-08-20 18:55:24 +00:00
dadachann c3fe312a43 fix(cloud): refresh directory during plugin startup 2026-08-20 18:55:24 +00:00
dadachann c4bad508d2 fix(runtime): honor configured cold reconcile timeout 2026-08-20 18:55:24 +00:00
RockChinQ 3d4a726cd8 chore: release v4.10.8 (#2450) 2026-08-21 01:47:39 +08:00
DongXiaoming e934f08adf fix(wecombot): deliver sandbox outbox media through full pipeline chain (#2328)
* fix(wecombot): align media upload protocol

* fix(wecombot): deliver outbox media in reply and fix tool call recording

- Integrate _send_media into reply_message and reply_message_chunk so
  sandbox outbox images/voices/files are uploaded and sent instead of
  being silently dropped.
- Add missing import base64 that caused _send_media to fail with a
  NameError swallowed by its except clause.
- Change yiri2target to return component dicts (text/image/voice/file)
  so callers can distinguish text from media.
- Fix _get_message_for_tool_context using result.first()/row[0] which
  returned a raw string instead of the ORM object, causing
  "'str' object has no attribute 'pipeline_id'" in tool call recording.
  Use result.scalars().first() per SQLAlchemy 2.0 convention.

* fix(pipeline): collect outbox attachments on final chunk with empty content

When the last streaming chunk has is_final=True but empty content
(e.g. the LLM sends all text in earlier chunks), the 'if result.content'
branch is skipped entirely, so _append_outbound_attachments never runs
and sandbox outbox images are silently dropped.

Add an elif branch for _is_final_assistant_message that creates an
empty MessageChain and still collects outbox attachments, so images
are delivered even when the final chunk carries no text.

* fix(box): bypass stdout truncation when reading outbox via exec

_read_outbox_via_exec used execute_tool which returns _serialize_result
where stdout is truncated to output_limit_chars (4000). A 7KB JPEG
encodes to ~9400 base64 chars, so the JSON payload was truncated and
json.loads failed silently, returning an empty list.

Call client.execute directly to get the raw BoxExecutionResult with
untruncated stdout, so base64 file data is preserved.

* fix(tests): adapt box and wrapper tests for client.execute and strict is_final check

- wrapper.py: restrict outbox collection on empty-content chunks to
  actual MessageChunk instances with is_final=True, not generic Mock
  objects that happen to have role='assistant'
- test_box_service.py: update _read_outbox_via_exec tests to mock
  client.execute (returning BoxExecutionResult) instead of
  execute_tool, matching the implementation change

* chore(wecombot): remove temporary upload log

* test(box): preserve direct outbox read and cleanup coverage

---------

Co-authored-by: fdc310 <2213070223@qq.com>
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-20 16:56:20 +08:00
Hyu 7803d56254 fix(plugin): keep pre-tenancy plugin storage readable (#2446)
* fix(plugin): adopt legacy scoped storage rows

* fix(plugin): delete adopted legacy storage rows

* test(plugin): cover legacy storage deletion

* fix(plugin): avoid mutating legacy storage on reads

* fix(plugin): make legacy storage adoption atomic

* fix(plugin): resolve concurrent legacy adoption

* fix(plugin): upsert concurrent storage writes

* fix(plugin): retry reads after legacy adoption

* fix(plugin): deduplicate migrated storage keys

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-17 11:49:26 +08:00
Hyu 54c96a18e1 test(migration): preserve legacy plugin storage payloads (#2444)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-17 10:31:03 +08:00
Hyu 579e3556e4 Merge pull request #2441 from langbot-app/fix/runtime-readonly-sdk-0.5.5
fix(runtime): adopt plugin SDK 0.5.5
2026-08-17 01:40:56 +08:00
dadachann a08a177a11 fix(runtime): adopt plugin SDK 0.5.5 2026-08-16 17:36:34 +00:00
Hyu c224f61c8c Merge pull request #2440 from langbot-app/fix/login-migration-20260817
fix(migrations): preserve legacy workspace ownership
2026-08-17 01:29:58 +08:00
dadachann b62cc9da45 fix(migrations): preserve legacy workspace ownership 2026-08-16 17:15:57 +00:00
Hyu 700104c015 Merge pull request #2439 from langbot-app/fix/recent-issues-20260817
fix recent migration and plugin command regressions
2026-08-17 01:04:41 +08:00
dadachann 717bd4b8bf fix(commands): pass trusted workspace scope to plugins 2026-08-16 17:01:02 +00:00
dadachann 0cc0e1b02d fix(migrations): retry backup reopen on bind mounts 2026-08-16 16:42:20 +00:00
117 changed files with 6471 additions and 2964 deletions
+32 -13
View File
@@ -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
View File
@@ -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
+18 -1
View File
@@ -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
View File
@@ -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:
+1 -2
View File
@@ -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
View File
@@ -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;
+1 -7
View File
@@ -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,
+15 -1
View File
@@ -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."""
+5 -5
View File
@@ -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)
+5 -14
View File
@@ -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,
+26 -103
View File
@@ -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,
-19
View File
@@ -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:
+17 -8
View File
@@ -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 []
+9 -1
View File
@@ -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
+33 -3
View File
@@ -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)
+11 -8
View File
@@ -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()))
+12 -1
View File
@@ -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]
+27 -361
View File
@@ -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,
+105 -17
View File
@@ -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
+14 -2
View File
@@ -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()):
+112 -6
View File
@@ -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()
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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']
+5
View File
@@ -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:
-41
View File
@@ -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."""
+23
View File
@@ -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()
+1 -27
View File
@@ -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 -38
View File
@@ -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'
+48 -18
View File
@@ -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):
+27
View File
@@ -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()
+27 -176
View File
@@ -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
+12
View File
@@ -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."""
+136 -6
View File
@@ -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']
+19
View File
@@ -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()
Generated
+9 -4
View File
@@ -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 = [
+194 -67
View File
@@ -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);
+59 -14
View File
@@ -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>
);
}
+60 -2
View File
@@ -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>
+1 -1
View File
@@ -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;
-17
View File
@@ -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[];
}
+1 -1
View File
@@ -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;
+6 -14
View File
@@ -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;
+74 -21
View File
@@ -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();
}
}
+19 -13
View File
@@ -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