Compare commits

...

78 Commits

Author SHA1 Message Date
RockChinQ 4b888ad390 copy(agents): explain the pipeline message flow 2026-08-27 16:03:56 +08:00
RockChinQ 7d9bdf0562 copy(agents): simplify pipeline type description 2026-08-27 15:59:28 +08:00
RockChinQ 5e144ac7c8 fix(i18n): localize pipeline processor type 2026-08-27 15:57:31 +08:00
RockChinQ 21ffaf9a14 refactor(agents): move debug event picker to input area 2026-08-27 15:52:59 +08:00
RockChinQ f3c1887e92 refactor(pipelines): clarify log view switching 2026-08-27 15:49:55 +08:00
RockChinQ 7671aefed7 fix(agents): improve diagram contrast in dark mode 2026-08-27 15:32:35 +08:00
RockChinQ e6f6789e8b fix(agents): make pipeline flow animation seamless 2026-08-27 15:28:43 +08:00
RockChinQ 05fe93e108 style(agents): animate processor diagram connections 2026-08-27 15:18:34 +08:00
RockChinQ e20a73c83a style(agents): distinguish agent capabilities from events 2026-08-27 15:14:53 +08:00
RockChinQ fde31d590e style(agents): clarify agent event direction 2026-08-27 15:11:13 +08:00
RockChinQ af03dbe958 style(agents): align pipeline diagram with agent 2026-08-27 15:08:33 +08:00
RockChinQ bd7aeab53d copy(agents): clarify processor use cases 2026-08-27 15:03:43 +08:00
RockChinQ 91f484e0ce copy(agents): expand processor type descriptions 2026-08-27 15:02:02 +08:00
RockChinQ a9b24e59ec refactor(agents): use shadcn processor type toggle 2026-08-27 14:55:59 +08:00
RockChinQ 139494cd63 style(agents): apply LangBot colors to processor diagrams 2026-08-27 14:49:04 +08:00
RockChinQ b4d9659ffe refactor(agents): remove decorative agent icon 2026-08-27 14:44:51 +08:00
RockChinQ 3f0ca13d6d refactor(agents): simplify processor diagrams 2026-08-27 14:43:32 +08:00
RockChinQ 6b40c2cf66 refactor(agents): polish processor creation layout 2026-08-27 14:39:56 +08:00
RockChinQ 847bfc8df5 feat(agents): improve processor creation choice 2026-08-27 14:31:37 +08:00
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
119 changed files with 7187 additions and 3110 deletions
+32 -13
View File
@@ -7,23 +7,42 @@ on:
jobs: jobs:
build-dev-image: build-dev-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# 如果是tag则跳过
if: ${{ !startsWith(github.ref, 'refs/tags/') }} if: ${{ !startsWith(github.ref, 'refs/tags/') }}
permissions:
contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v4
with: with:
persist-credentials: false persist-credentials: false
- name: Generate Tag - name: Set up Docker Buildx
id: generate_tag uses: docker/setup-buildx-action@v3
- name: Generate image metadata
id: image
shell: bash
run: | run: |
# 获取分支名称,把/替换为- set -euo pipefail
echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g' branch_tag="${GITHUB_REF#refs/heads/}"
echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g') branch_tag="${branch_tag//\//-}"
- name: Login to Registry echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT"
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }} echo "sha_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Build Docker Image
run: | - name: Login to Docker Hub
docker buildx create --name mybuilder --use uses: docker/login-action@v3
docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push immutable Core image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
rockchin/langbot:${{ steps.image.outputs.branch_tag }}
rockchin/langbot:${{ steps.image.outputs.sha_tag }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
+1 -1
View File
@@ -62,7 +62,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& rm -f /tmp/nodesource_setup.sh \ && rm -f /tmp/nodesource_setup.sh \
&& python -m pip install --no-cache-dir uv \ && python -m pip install --no-cache-dir uv \
&& uv sync \ && uv sync --extra seekdb \
&& apt-get purge -y --auto-remove curl git gnupg \ && apt-get purge -y --auto-remove curl git gnupg \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& touch /.dockerenv && touch /.dockerenv
+18 -1
View File
@@ -10,6 +10,19 @@ uvx langbot
This will automatically download and run the latest version of LangBot. This will automatically download and run the latest version of LangBot.
SeekDB support is optional and is not installed by the command above. If you
want to use the SeekDB vector database or the built-in SeekDB embedding model,
run LangBot with the `seekdb` extra:
```bash
uvx --from 'langbot[seekdb]@latest' langbot
```
The extra includes native dependencies whose supported operating systems may
be narrower than LangBot's. In particular, the current Apple Silicon wheels
require macOS 15 or later. The default Chroma backend does not have this
requirement.
## Install with pip/uv ## Install with pip/uv
You can also install LangBot as a regular Python package: You can also install LangBot as a regular Python package:
@@ -20,6 +33,10 @@ pip install langbot
# Using uv # Using uv
uv pip install langbot uv pip install langbot
# Include optional SeekDB support
pip install 'langbot[seekdb]'
# or: uv pip install 'langbot[seekdb]'
``` ```
Then run it: Then run it:
@@ -101,7 +118,7 @@ uvx langbot
## System Requirements ## System Requirements
- Python 3.10.1 or higher - Python 3.11 or higher (lower than Python 4)
- Operating System: Linux, macOS, or Windows - Operating System: Linux, macOS, or Windows
## Differences from Source Installation ## Differences from Source Installation
+34 -43
View File
@@ -16,12 +16,20 @@ This document describes how to use OceanBase SeekDB as the vector database backe
## Installation ## Installation
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`. SeekDB is an optional LangBot feature. A normal LangBot installation uses
Chroma by default and does not install `pyseekdb` or its native bindings.
If you need to install it manually: Choose the command that matches how you run LangBot:
```bash ```bash
pip install pyseekdb # PyPI / uvx
uvx --from 'langbot[seekdb]@latest' langbot
# Installed package
pip install 'langbot[seekdb]'
# Source checkout
uv sync --extra seekdb
``` ```
## ⚠️ Platform Compatibility ## ⚠️ Platform Compatibility
@@ -30,31 +38,36 @@ pip install pyseekdb
| Platform | Status | Notes | | Platform | Status | Notes |
|----------|--------|-------| |----------|--------|-------|
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` | | Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` |
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead | | macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel |
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead | | macOS 14 or earlier on Apple Silicon | ❌ Not currently supported | The published native wheel requires macOS 15+; follow [oceanbase/seekdb#1324](https://github.com/oceanbase/seekdb/issues/1324) |
| macOS on Intel | ❌ Not currently supported | No embedded binding is selected by `pyseekdb` |
| Windows | ❌ Not currently supported | No Windows `pylibseekdb` wheel is published |
**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode. **Important**: Embedded mode requires a compatible `pylibseekdb` wheel. Do not
force-install or retag a wheel built for a newer macOS release: the bundled
binaries also declare macOS 15 as their minimum deployment target.
### Server Mode (Docker) ### Server Mode (Docker)
| Platform | Status | Notes | | Platform | Status | Notes |
|----------|--------|-------| |----------|--------|-------|
| Linux | ✅ Supported | Full Docker support | | Linux | ✅ Supported | Full Docker support |
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) | | macOS | ✅ Supported by Docker Desktop | The previous slow-disk startup issue was fixed upstream in [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36) |
| Windows | ⚠️ Untested | Should work but not yet tested | | Windows | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation |
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
- Using ChromaDB or Qdrant as alternatives
- Connecting to a remote SeekDB server on Linux if available
### Server Mode (Remote Connection) ### Server Mode (Remote Connection)
| Platform | Status | Notes | | Platform | Status | Notes |
|----------|--------|-------| |----------|--------|-------|
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server | | Linux | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
| macOS 15+ on Apple Silicon | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
| macOS 14 or earlier on Apple Silicon | ⚠️ Blocked by upstream packaging | `pyseekdb` currently requires the unavailable native wheel even for server-only use; follow [#1324](https://github.com/oceanbase/seekdb/issues/1324) |
| macOS on Intel / Windows | ✅ Server mode only | Embedded bindings are not available |
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration. Remote server mode does not use embedded storage at runtime. However, whether
the Python client can be installed still depends on `pyseekdb`'s package
metadata for the current platform.
## Configuration ## Configuration
@@ -170,22 +183,23 @@ Key methods:
### Import Error ### Import Error
If you see: `ImportError: pyseekdb is not installed` If you see: `SeekDB support is not installed`
Solution: Solution:
```bash ```bash
pip install pyseekdb uv sync --extra seekdb
# or: uvx --from 'langbot[seekdb]@latest' langbot
``` ```
### Embedded Mode Error on macOS/Windows ### Embedded Mode Is Unavailable on the Current Platform
**Error**: **Error**:
``` ```
RuntimeError: Embedded Client is not available because pylibseekdb is not available. RuntimeError: Embedded Client is not available because pylibseekdb is not available.
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
``` ```
**Cause**: `pylibseekdb` is only available on Linux platforms. **Cause**: No compatible `pylibseekdb` wheel is installed for the current OS,
CPU architecture, Python version, and macOS deployment target.
**Solution**: Use server mode instead: **Solution**: Use server mode instead:
1. Deploy SeekDB on a Linux server or VM 1. Deploy SeekDB on a Linux server or VM
@@ -208,29 +222,6 @@ vdb:
use: chroma # or qdrant use: chroma # or qdrant
``` ```
### Docker Container Fails on macOS
**Symptoms**:
```bash
docker run -d -p 2881:2881 oceanbase/seekdb:latest
# Container exits immediately with code 30
```
**Error in logs**:
```
[ERROR] Code: Agent.SeekDB.Not.Exists
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
```
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
**Status**: Under investigation by OceanBase team.
**Workaround Options**:
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
3. **Wait for fix**: Monitor the GitHub issue for updates
### Connection Error (Server Mode) ### Connection Error (Server Mode)
If SeekDB server is not reachable, check: If SeekDB server is not reachable, check:
+1 -2
View File
@@ -34,7 +34,6 @@ class Agent(Base):
kind: str # 固定为 "agent" kind: str # 固定为 "agent"
component_ref: str # AgentRunner id component_ref: str # AgentRunner id
config: dict # runner + runner_config config: dict # runner + runner_config
enabled: bool
supported_event_patterns: list[str] supported_event_patterns: list[str]
``` ```
@@ -113,7 +112,7 @@ Binding 只保存引用与路由条件。它不复制 Pipeline 或 Agent 配置
1. 忽略 `enabled = false` 的 binding。 1. 忽略 `enabled = false` 的 binding。
2. 检查 `event_pattern` 与结构化 filters。 2. 检查 `event_pattern` 与结构化 filters。
3. 校验目标存在、启用且声明支持该事件。 3. 校验目标存在且声明支持该事件。
4.`priority` 从高到低选择;同优先级按稳定列表顺序。 4.`priority` 从高到低选择;同优先级按稳定列表顺序。
5. 只执行一个响应目标。 5. 只执行一个响应目标。
@@ -70,7 +70,6 @@ class Agent(Base):
kind: str # 首版固定为 "agent" kind: str # 首版固定为 "agent"
component_ref: str # runner id / workflow id / future external ref component_ref: str # runner id / workflow id / future external ref
config: dict # runner 与 runner_config config: dict # runner 与 runner_config
enabled: bool
supported_event_patterns: list[str] supported_event_patterns: list[str]
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
+5 -1
View File
@@ -69,7 +69,6 @@ dependencies = [
"langchain-text-splitters>=1.1.2", "langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0", "chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.5.3", "langbot-plugin==0.5.3",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
@@ -107,6 +106,11 @@ classifiers = [
"Topic :: Communications :: Chat", "Topic :: Communications :: Chat",
] ]
[project.optional-dependencies]
seekdb = [
"pyseekdb==1.1.0.post3",
]
[project.urls] [project.urls]
Homepage = "https://langbot.app" Homepage = "https://langbot.app"
Documentation = "https://docs.langbot.app" Documentation = "https://docs.langbot.app"
@@ -188,11 +188,7 @@ try {
.getByText(/Event Routing|事件路由|イベントルーティング/) .getByText(/Event Routing|事件路由|イベントルーティング/)
.first() .first()
.waitFor({ timeout: 15_000 }); .waitFor({ timeout: 15_000 });
await page await page.getByText(/Supported events|支持的事件|対応イベント/).waitFor();
.getByText(
/Events this adapter can receive|此适配器可接收的事件|このアダプターが受信できるイベント/,
)
.waitFor();
await page await page
.getByText(/Message received|收到消息|メッセージを受信/) .getByText(/Message received|收到消息|メッセージを受信/)
.first() .first()
@@ -216,11 +212,11 @@ try {
); );
await page await page
.getByRole("button", { name: /Test route|测试路由|ルートをテスト/ }) .getByRole("button", { name: /Check route|检查路由|ルートを確認/ })
.click(); .click();
await page.getByRole("dialog").waitFor(); await page.getByRole("dialog").waitFor();
await page await page
.getByRole("button", { name: /Preview route|预览路由|ルートをプレビュー/ }) .getByRole("button", { name: /View match|查看匹配结果|一致結果を確認/ })
.click(); .click();
await page await page
.getByText(/Route matched|已命中路由|ルートに一致しました/) .getByText(/Route matched|已命中路由|ルートに一致しました/)
@@ -231,28 +227,67 @@ try {
.waitFor(); .waitFor();
result.visible_signals.push("dry-run-matched", "discard-target"); result.visible_signals.push("dry-run-matched", "discard-target");
await page
.getByRole("button", {
name: /Run saved route|运行已保存路由|保存済みルートを実行/,
})
.click();
await page
.getByText(
/saved route ran successfully|已保存路由运行成功|保存済みルートを実行しました/,
)
.waitFor({ timeout: 20_000 });
result.visible_signals.push("test-event-dispatched");
await page await page
.getByRole("button", { name: /Close|关闭|閉じる/ }) .getByRole("button", { name: /Close|关闭|閉じる/ })
.first() .first()
.click(); .click();
await page.getByRole("dialog").waitFor({ state: "hidden" }); await page.getByRole("dialog").waitFor({ state: "hidden" });
await page
.getByText(/Discarded|已丢弃|破棄済み/) const adapterConfigCard = page.locator('[data-slot="card"]').filter({
.first() has: page.getByText(/Adapter Configuration|适配器配置|アダプター設定/, {
.waitFor({ timeout: 10_000 }); exact: true,
result.visible_signals.push("route-status-discarded"); }),
});
await adapterConfigCard
.getByRole("button", {
name: /Listen for platform events|监听平台事件|プラットフォームイベントを監視/,
})
.click();
const adapterDialog = page.getByRole("dialog");
await adapterDialog.waitFor();
await adapterDialog
.getByText(/Listening|正在监听|監視中/, { exact: true })
.waitFor({ timeout: 15_000 });
const inboundText = `adapter event ${paths.runId}`;
const inbound = await apiJson(
backendUrl,
`/bots/${encodeURIComponent(botId)}`,
{
method: "POST",
token,
body: {
session_id: `adapter-debug-${paths.runId}`,
session_type: "person",
sender: { id: "adapter-debug-user", name: "Adapter QA" },
message: [{ type: "Plain", text: inboundText }],
},
},
);
result.api.adapter_event_webhook = {
http_status: inbound.status,
code: inbound.json.code ?? null,
};
if (inbound.status >= 400 || inbound.json.code !== 0) {
throw new Error(
inbound.json.msg || "The HTTP Bot adapter rejected the inbound event.",
);
}
await adapterDialog
.getByText(/Message received|收到消息|メッセージ受信/, { exact: true })
.waitFor({ timeout: 15_000 });
await adapterDialog.getByText("message.received", { exact: true }).waitFor();
await adapterDialog.getByText(inboundText, { exact: true }).waitFor();
result.visible_signals.push(
"adapter-event-listening",
"adapter-event-received",
"adapter-event-raw-code",
);
await adapterDialog
.getByRole("button", { name: /Close|关闭|閉じる/ })
.click();
await adapterDialog.waitFor({ state: "hidden" });
const text = await bodyText(page); const text = await bodyText(page);
if (/\bEBA event\b/.test(text)) { if (/\bEBA event\b/.test(text)) {
@@ -308,7 +343,7 @@ try {
} }
result.status = "pass"; result.status = "pass";
result.reason = result.reason =
"Bot event routing, dry-run, synthetic dispatch, and visible route status passed in the WebUI."; "Bot event routing, dry-run, real adapter input, and visible route status passed in the WebUI.";
} catch (error) { } catch (error) {
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail"; if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
result.reason = result.reason || error.message; result.reason = result.reason || error.message;
+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 | | `get_system_info` | Version, edition, instance id |
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) | | `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
| `list_bot_event_route_statuses` / `test_bot_event_route` | Inspect bot event-route runtime status and dispatch a synthetic test event through saved routes without sending real outbound platform messages | | `list_bot_event_route_statuses` | Inspect bot event-route runtime status |
| `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent and Pipeline processor types | | `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent and Pipeline processor types |
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines | | `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers | | `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers |
@@ -78,12 +78,6 @@ shape as the corresponding HTTP API request body. Discover resources with the
`resource.view`; mutations require `resource.manage`. All service calls inherit `resource.view`; mutations require `resource.manage`. All service calls inherit
the immutable Workspace context authenticated at the MCP transport boundary. the immutable Workspace context authenticated at the MCP transport boundary.
`test_bot_event_route` uses the bot's saved runtime route table, injects a
synthetic event such as `message.received`, and suppresses platform delivery.
It still executes the selected processor, so tools and external services may
have side effects. Use `payload` for sample event fields, for example
`{"message_text": "hello", "chat_type": "private", "chat_id": "u1"}`.
## How to use ## How to use
1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml). 1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml).
@@ -1,5 +1,5 @@
id: bot-event-routing-product-flow id: bot-event-routing-product-flow
title: "Bot event routing can be configured and tested from the WebUI" title: "Bot event routing and adapter input can be inspected from the WebUI"
mode: agent-browser mode: agent-browser
area: bot area: bot
type: feature type: feature
@@ -33,16 +33,18 @@ steps:
- "Confirm the adapter capability summary, friendly event name, target, and route status are visible." - "Confirm the adapter capability summary, friendly event name, target, and route status are visible."
- "Confirm overlapping routes and unmatched-event fallback behavior are explained before save." - "Confirm overlapping routes and unmatched-event fallback behavior are explained before save."
- "Open Test event route and run a dry-run against the current form." - "Open Test event route and run a dry-run against the current form."
- "Run the saved runtime route with a synthetic event." - "Open Platform event debugging from Adapter Configuration and send a real inbound event through the HTTP Bot adapter."
- "Close the dialog and confirm the route card shows the latest discarded status." - "Confirm the normalized event, raw event code, payload, and latest discarded route status are visible."
checks: checks:
- "UI: A user can choose a channel and add a scenario-labeled behavior during initial Bot creation." - "UI: A user can choose a channel and add a scenario-labeled behavior during initial Bot creation."
- "UI: Event routing uses user-facing labels and does not require the raw event name in the primary route card." - "UI: Event routing uses user-facing labels and does not require the raw event name in the primary route card."
- "UI: Definite route shadowing and unmatched-event fallback behavior are visible without opening raw logs." - "UI: Definite route shadowing and unmatched-event fallback behavior are visible without opening raw logs."
- "UI: Dry-run visibly reports that the route matched the discard processor." - "UI: Dry-run visibly reports that the route matched the discard processor."
- "UI: Saved-route execution visibly succeeds, explains its side-effect boundary, and updates route status to discarded." - "UI: Adapter event debugging lives under Adapter Configuration, remains separate from route preview, and starts listening only after the dialog opens."
- "UI: A real adapter event shows its friendly name, raw code, and normalized event data."
- "UI: The route card updates to discarded after the real inbound event is handled."
- "Console: No unexpected frontend errors appear during the flow." - "Console: No unexpected frontend errors appear during the flow."
- "Network: Bot, dry-run, route-status, and test-event requests return without 5xx responses." - "Network: Bot, dry-run, route-status, log, and HTTP Bot webhook requests return without 5xx responses."
- "Cleanup: The temporary Bot is deleted after evidence is collected." - "Cleanup: The temporary Bot is deleted after evidence is collected."
evidence_required: evidence_required:
- ui - ui
@@ -51,7 +53,8 @@ evidence_required:
- api_diagnostic - api_diagnostic
diagnostics: diagnostics:
- "The fixture deliberately uses the discard processor so the product-flow test cannot invoke a model, tool, or external callback." - "The fixture deliberately uses the discard processor so the product-flow test cannot invoke a model, tool, or external callback."
- "A passing API call without the visible matched and discarded UI states is not a pass." - "The adapter dialog observes normalized platform events; it does not simulate route matching."
- "A passing webhook call without the visible adapter event and discarded UI states is not a pass."
troubleshooting: troubleshooting:
- backend-not-listening - backend-not-listening
- proxy-env-mismatch - proxy-env-mismatch
@@ -48,6 +48,14 @@ CMD_RESPOND_MSG = 'aibot_respond_msg'
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg' CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg' CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
CMD_SEND_MSG = 'aibot_send_msg' CMD_SEND_MSG = 'aibot_send_msg'
# Media upload protocol (3 steps: init -> chunk * N -> finish). The
# command names below match the WeCom AI Bot long-connection protocol.
CMD_UPLOAD_INIT = 'aibot_upload_media_init'
CMD_UPLOAD_CHUNK = 'aibot_upload_media_chunk'
CMD_UPLOAD_FINISH = 'aibot_upload_media_finish'
# Default upload chunk size: 512 KB before base64 encoding.
_UPLOAD_CHUNK_SIZE = 512 * 1024
_DEDUP_CACHE_MAX = 4096 _DEDUP_CACHE_MAX = 4096
_STREAM_CACHE_MAX = 1024 _STREAM_CACHE_MAX = 1024
@@ -499,6 +507,145 @@ class WecomBotWsClient:
body['chatid'] = chat_id body['chatid'] = chat_id
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG) return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
# ------------------------------------------------------------------
# Media upload (image / voice / file)
# ------------------------------------------------------------------
async def upload_media(
self,
data: bytes,
filename: str = 'attachment',
media_type: str = 'file',
) -> Optional[dict]:
"""Upload *data* to the WeCom AI Bot CDN and return the parsed ACK.
Implements the three-step protocol documented for the WeCom
AI Bot:
1. ``aibot_upload_media_init`` — declare media type, file name,
size, MD5 and chunk count; receive ``upload_id``.
2. ``aibot_upload_media_chunk`` — send each chunk (base64-encoded
bytes) until done; receive per-chunk ACK.
3. ``aibot_upload_media_finish`` — finalize the upload; receive
``media_id``.
Returns a dict with the final ``media_id`` (and the raw
``finish`` ACK) on success, or ``None`` on any failure. The
caller is expected to ignore the result and continue
gracefully — the framework will keep working without media
delivery.
"""
import base64 as _b64
import hashlib as _hl
if not data:
return None
file_size = len(data)
file_md5 = _hl.md5(data).hexdigest()
total_chunks = (file_size + _UPLOAD_CHUNK_SIZE - 1) // _UPLOAD_CHUNK_SIZE
if total_chunks == 0:
total_chunks = 1
# Step 1: init.
init_req_id = _generate_req_id(CMD_UPLOAD_INIT)
init_body = {
'type': media_type,
'filename': filename,
'total_size': file_size,
'total_chunks': total_chunks,
'md5': file_md5,
}
init_ack = await self._send_reply(
init_req_id,
init_body,
cmd=CMD_UPLOAD_INIT,
)
if not init_ack or init_ack.get('errcode', 0) != 0:
await self.logger.warning(f'upload_media init failed: ack={init_ack!r}')
return None
upload_id = (
init_ack.get('upload_id')
or init_ack.get('body', {}).get('upload_id')
or init_ack.get('data', {}).get('upload_id')
)
if not upload_id:
await self.logger.warning(f'upload_media init returned no upload_id: ack={init_ack!r}')
return None
# Step 2: chunks.
for index in range(total_chunks):
start = index * _UPLOAD_CHUNK_SIZE
end = min(start + _UPLOAD_CHUNK_SIZE, file_size)
chunk_bytes = data[start:end]
chunk_req_id = _generate_req_id(CMD_UPLOAD_CHUNK)
chunk_body = {
'upload_id': upload_id,
'chunk_index': index,
'base64_data': _b64.b64encode(chunk_bytes).decode('ascii'),
}
chunk_ack = await self._send_reply(
chunk_req_id,
chunk_body,
cmd=CMD_UPLOAD_CHUNK,
)
if not chunk_ack or chunk_ack.get('errcode', 0) != 0:
await self.logger.warning(f'upload_media chunk {index} failed: ack={chunk_ack!r}')
return None
# Step 3: finish.
finish_req_id = _generate_req_id(CMD_UPLOAD_FINISH)
finish_body = {'upload_id': upload_id}
finish_ack = await self._send_reply(
finish_req_id,
finish_body,
cmd=CMD_UPLOAD_FINISH,
)
if not finish_ack or finish_ack.get('errcode', 0) != 0:
await self.logger.warning(f'upload_media finish failed: ack={finish_ack!r}')
return None
media_id = (
finish_ack.get('media_id')
or finish_ack.get('body', {}).get('media_id')
or finish_ack.get('data', {}).get('media_id')
)
if not media_id:
await self.logger.warning(f'upload_media finish returned no media_id: ack={finish_ack!r}')
return None
return {'media_id': media_id, 'ack': finish_ack}
async def _reply_media(
self,
req_id: str,
media_id: str,
kind: str,
) -> Optional[dict]:
"""Send a media reply (image / voice / file) referencing *media_id*.
``kind`` is one of ``'image'``, ``'voice'``, ``'file'``. Uses
the standard ``aibot_respond_msg`` command with a per-kind
body key (matches the convention documented for the WeCom
AI Bot SDK).
"""
if kind not in {'image', 'voice', 'file'}:
await self.logger.warning(f'_reply_media called with unknown kind={kind!r}')
return None
body = {
'msgtype': kind,
kind: {'media_id': media_id},
}
return await self._send_reply(req_id, body, cmd=CMD_RESPOND_MSG)
async def reply_image(self, req_id: str, media_id: str) -> Optional[dict]:
return await self._reply_media(req_id, media_id, 'image')
async def reply_file(self, req_id: str, media_id: str) -> Optional[dict]:
return await self._reply_media(req_id, media_id, 'file')
async def reply_voice(self, req_id: str, media_id: str) -> Optional[dict]:
return await self._reply_media(req_id, media_id, 'voice')
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
"""Push a streaming chunk for a given message ID. """Push a streaming chunk for a given message ID.
@@ -22,7 +22,7 @@ class AgentBindingResolver:
event: AgentEventEnvelope, event: AgentEventEnvelope,
agents: list[AgentConfig], agents: list[AgentConfig],
) -> AgentBinding: ) -> AgentBinding:
"""Resolve exactly one enabled Agent for the event. """Resolve exactly one Agent for the event.
Callers that source agents from bot/workspace/global configuration must Callers that source agents from bot/workspace/global configuration must
pre-filter candidates to the event scope before calling this resolver. pre-filter candidates to the event scope before calling this resolver.
@@ -30,7 +30,7 @@ class AgentBindingResolver:
Agent and does not carry enough scope metadata to make that decision Agent and does not carry enough scope metadata to make that decision
safely here. safely here.
""" """
matches = [agent for agent in agents if agent.enabled and event.event_type in agent.event_types] matches = [agent for agent in agents if event.event_type in agent.event_types]
if not matches: if not matches:
raise AgentBindingResolutionError(f'No Agent binding matches event_type={event.event_type}') raise AgentBindingResolutionError(f'No Agent binding matches event_type={event.event_type}')
@@ -59,7 +59,6 @@ class AgentBindingResolver:
resource_policy=agent.resource_policy, resource_policy=agent.resource_policy,
state_policy=agent.state_policy, state_policy=agent.state_policy,
delivery_policy=agent.delivery_policy, delivery_policy=agent.delivery_policy,
enabled=agent.enabled,
agent_id=agent.agent_id, agent_id=agent.agent_id,
processor_type=agent.processor_type, processor_type=agent.processor_type,
processor_id=agent.processor_id or agent.agent_id, processor_id=agent.processor_id or agent.agent_id,
+15 -1
View File
@@ -1,14 +1,17 @@
"""Agent runner errors.""" """Agent runner errors."""
from __future__ import annotations from __future__ import annotations
class AgentRunnerError(Exception): class AgentRunnerError(Exception):
"""Base error for agent runner operations.""" """Base error for agent runner operations."""
pass pass
class RunnerNotFoundError(AgentRunnerError): class RunnerNotFoundError(AgentRunnerError):
"""Runner not found in registry.""" """Runner not found in registry."""
def __init__(self, runner_id: str): def __init__(self, runner_id: str):
self.runner_id = runner_id self.runner_id = runner_id
super().__init__(f'Agent runner not found: {runner_id}') super().__init__(f'Agent runner not found: {runner_id}')
@@ -16,6 +19,7 @@ class RunnerNotFoundError(AgentRunnerError):
class RunnerNotAuthorizedError(AgentRunnerError): class RunnerNotAuthorizedError(AgentRunnerError):
"""Runner not authorized for this binding.""" """Runner not authorized for this binding."""
def __init__(self, runner_id: str, bound_plugins: list[str] | None): def __init__(self, runner_id: str, bound_plugins: list[str] | None):
self.runner_id = runner_id self.runner_id = runner_id
self.bound_plugins = bound_plugins self.bound_plugins = bound_plugins
@@ -24,6 +28,7 @@ class RunnerNotAuthorizedError(AgentRunnerError):
class RunnerProtocolError(AgentRunnerError): class RunnerProtocolError(AgentRunnerError):
"""Runner protocol version mismatch or invalid manifest.""" """Runner protocol version mismatch or invalid manifest."""
def __init__(self, runner_id: str, message: str): def __init__(self, runner_id: str, message: str):
self.runner_id = runner_id self.runner_id = runner_id
super().__init__(f'Agent runner protocol error for {runner_id}: {message}') super().__init__(f'Agent runner protocol error for {runner_id}: {message}')
@@ -31,7 +36,16 @@ class RunnerProtocolError(AgentRunnerError):
class RunnerExecutionError(AgentRunnerError): class RunnerExecutionError(AgentRunnerError):
"""Runner execution failed.""" """Runner execution failed."""
def __init__(self, runner_id: str, message: str, retryable: bool = False):
def __init__(
self,
runner_id: str,
message: str,
retryable: bool = False,
error_code: str | None = None,
):
self.runner_id = runner_id self.runner_id = runner_id
self.message = message
self.retryable = retryable self.retryable = retryable
self.error_code = error_code
super().__init__(f'Agent runner {runner_id} execution failed: {message}') super().__init__(f'Agent runner {runner_id} execution failed: {message}')
@@ -181,9 +181,6 @@ class AgentConfig(pydantic.BaseModel):
event_types: list[str] = pydantic.Field(default_factory=lambda: ['message.received']) event_types: list[str] = pydantic.Field(default_factory=lambda: ['message.received'])
"""Event types this Agent handles.""" """Event types this Agent handles."""
enabled: bool = True
"""Whether this Agent can be selected by a binding resolver."""
metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict) metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
"""Non-protocol diagnostic metadata, such as legacy config source.""" """Non-protocol diagnostic metadata, such as legacy config source."""
@@ -219,9 +216,6 @@ class AgentBinding(pydantic.BaseModel):
delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy) delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy)
"""Delivery policy.""" """Delivery policy."""
enabled: bool = True
"""Whether binding is enabled."""
agent_id: str | None = None agent_id: str | None = None
"""Host-side Agent/config identifier for this binding.""" """Host-side Agent/config identifier for this binding."""
+5 -5
View File
@@ -58,21 +58,21 @@ class AgentRunnerInvoker:
except asyncio.TimeoutError as e: except asyncio.TimeoutError as e:
raise RunnerExecutionError( raise RunnerExecutionError(
descriptor.id, descriptor.id,
'Runner timed out (code: runner.timeout)', 'Runner timed out',
retryable=True, retryable=True,
error_code='runner.timeout',
) from e ) from e
except ActionCallTimeoutError as e: except ActionCallTimeoutError as e:
raise RunnerExecutionError( raise RunnerExecutionError(
descriptor.id, descriptor.id,
f'{e} (code: runner.timeout)', str(e),
retryable=True, retryable=True,
error_code='runner.timeout',
) from e ) from e
except RunnerExecutionError: except RunnerExecutionError:
raise raise
except Exception as e: except Exception as e:
self.ap.logger.error( self.ap.logger.error(f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}')
f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}'
)
raise RunnerExecutionError( raise RunnerExecutionError(
descriptor.id, descriptor.id,
str(e), str(e),
@@ -151,7 +151,6 @@ class QueryEntryAdapter:
state_policy=state_policy, state_policy=state_policy,
delivery_policy=delivery_policy, delivery_policy=delivery_policy,
event_types=[event_type], event_types=[event_type],
enabled=True,
metadata={'source': 'pipeline_adapter'}, metadata={'source': 'pipeline_adapter'},
) )
@@ -152,10 +152,14 @@ class AgentResultNormalizer:
error_msg = data.get('error', 'Unknown error') error_msg = data.get('error', 'Unknown error')
error_code = data.get('code', 'unknown') error_code = data.get('code', 'unknown')
retryable = data.get('retryable', False) retryable = data.get('retryable', False)
normalized_error_code = str(error_code or '').strip()
raise RunnerExecutionError( raise RunnerExecutionError(
descriptor.id, descriptor.id,
f'{error_msg} (code: {error_code})', str(error_msg),
retryable=retryable, retryable=retryable,
error_code=(
normalized_error_code if normalized_error_code and normalized_error_code != 'unknown' else None
),
) )
elif result_type == 'action.requested': elif result_type == 'action.requested':
@@ -2,6 +2,13 @@ from __future__ import annotations
import quart import quart
from .....agent.runner.errors import (
AgentRunnerError,
RunnerExecutionError,
RunnerNotAuthorizedError,
RunnerNotFoundError,
RunnerProtocolError,
)
from ...authz import Permission, require_permission from ...authz import Permission, require_permission
from ...context import RequestContext from ...context import RequestContext
from .. import group from .. import group
@@ -63,6 +70,36 @@ class AgentsRouterGroup(group.RouterGroup):
) )
except ValueError as exc: except ValueError as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
except RunnerExecutionError as exc:
return self.http_status(
422,
exc.error_code or 'runner_execution_failed',
exc.message,
)
except RunnerNotFoundError:
return self.http_status(
409,
'runner_not_found',
'The configured Agent runner is unavailable',
)
except RunnerNotAuthorizedError:
return self.http_status(
403,
'runner_not_authorized',
'The configured Agent runner is not authorized',
)
except RunnerProtocolError:
return self.http_status(
502,
'runner_protocol_error',
'The Agent runner returned an invalid response',
)
except AgentRunnerError:
return self.http_status(
502,
'runner_error',
'The Agent runner could not complete this test',
)
return self.success(data=result) return self.success(data=result)
@self.route( @self.route(
@@ -90,15 +90,9 @@ class BotsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW, permission=Permission.RESOURCE_VIEW,
) )
async def _(bot_uuid: str, request_context: RequestContext) -> str: async def _(bot_uuid: str, request_context: RequestContext) -> str:
return self.success( return self.success(data=await self.ap.bot_service.list_event_route_statuses(request_context, bot_uuid))
data=await self.ap.bot_service.list_event_route_statuses(
request_context, bot_uuid
)
)
async def _dry_run_event_route( async def _dry_run_event_route(bot_uuid: str, request_context: RequestContext) -> str:
bot_uuid: str, request_context: RequestContext
) -> str:
json_data = await quart.request.json json_data = await quart.request.json
if not isinstance(json_data, dict): if not isinstance(json_data, dict):
return self.http_status(400, -1, 'invalid request body') return self.http_status(400, -1, 'invalid request body')
@@ -128,24 +122,6 @@ class BotsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW, permission=Permission.RESOURCE_VIEW,
)(_dry_run_event_route) )(_dry_run_event_route)
@self.route(
'/<bot_uuid>/event-routes/test',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RUNTIME_OPERATE,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
if not isinstance(json_data, dict):
return self.http_status(400, -1, 'invalid request body')
result = await self.ap.bot_service.dispatch_test_event_route(
request_context,
bot_uuid=bot_uuid,
event_type=json_data.get('event_type'),
payload=json_data.get('event_data', json_data.get('payload')),
)
return self.success(data=result)
@self.route( @self.route(
'/<bot_uuid>/send_message', '/<bot_uuid>/send_message',
methods=['POST'], methods=['POST'],
@@ -322,6 +322,7 @@ class UserRouterGroup(group.RouterGroup):
if cloud_mode: if cloud_mode:
capabilities['password_login_enabled'] = False capabilities['password_login_enabled'] = False
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
capabilities['invitation_registration_enabled'] = not cloud_mode
return self.success(data={'initialized': True, **capabilities}) return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
+5 -14
View File
@@ -201,7 +201,6 @@ class AgentService:
enable_reply=False, enable_reply=False,
enable_interactions=False, enable_interactions=False,
), ),
enabled=True,
agent_id=agent_uuid, agent_id=agent_uuid,
processor_type='agent', processor_type='agent',
processor_id=agent_uuid, processor_id=agent_uuid,
@@ -293,7 +292,6 @@ class AgentService:
'kind': AGENT_KIND_AGENT, 'kind': AGENT_KIND_AGENT,
'component_ref': runner_id, 'component_ref': runner_id,
'config': config, 'config': config,
'enabled': agent_data.get('enabled', True),
'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS, 'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
} }
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values)) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values))
@@ -308,17 +306,11 @@ class AgentService:
await self.ap.pipeline_service.update_pipeline(context, agent_uuid, agent_data) await self.ap.pipeline_service.update_pipeline(context, agent_uuid, agent_data)
return return
update_data = agent_data.copy() update_data = {
for protected_field in ( field: agent_data[field]
'uuid', for field in ('name', 'description', 'emoji', 'config', 'supported_event_patterns')
'workspace_uuid', if field in agent_data
'kind', }
'component_ref',
'created_at',
'updated_at',
'capability',
):
update_data.pop(protected_field, None)
if 'config' in update_data: if 'config' in update_data:
config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(update_data['config']) config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(update_data['config'])
update_data['config'] = config update_data['config'] = config
@@ -425,7 +417,6 @@ class AgentService:
item = pipeline.copy() item = pipeline.copy()
item['kind'] = AGENT_KIND_PIPELINE item['kind'] = AGENT_KIND_PIPELINE
item['component_ref'] = 'pipeline' item['component_ref'] = 'pipeline'
item['enabled'] = True
item['supported_event_patterns'] = PIPELINE_EVENT_PATTERNS item['supported_event_patterns'] = PIPELINE_EVENT_PATTERNS
item['capability'] = { item['capability'] = {
'supported_event_patterns': PIPELINE_EVENT_PATTERNS, 'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
+26 -103
View File
@@ -18,7 +18,6 @@ class BotService:
ap: app.Application ap: app.Application
FAILURE_ROUTE_NOT_FOUND = 'route_not_found' FAILURE_ROUTE_NOT_FOUND = 'route_not_found'
FAILURE_PROCESSOR_DISABLED = 'processor_disabled'
FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found' FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found'
FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible' FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible'
FAILURE_INVALID_EVENT = 'invalid_event' FAILURE_INVALID_EVENT = 'invalid_event'
@@ -276,14 +275,10 @@ class BotService:
) )
return result.first() return result.first()
async def _get_agent_entity( async def _get_agent_entity(self, context: TenantContext, agent_uuid: str) -> persistence_agent.Agent | None:
self, context: TenantContext, agent_uuid: str
) -> persistence_agent.Agent | None:
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
scope_statement( scope_statement(
sqlalchemy.select(persistence_agent.Agent).where( sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid),
persistence_agent.Agent.uuid == agent_uuid
),
persistence_agent.Agent, persistence_agent.Agent,
context, context,
) )
@@ -390,11 +385,7 @@ class BotService:
} }
], ],
) )
pipeline = ( pipeline = await self._get_pipeline_entity(tenant_context, target_uuid) if target_uuid else None
await self._get_pipeline_entity(tenant_context, target_uuid)
if target_uuid
else None
)
if pipeline is None: if pipeline is None:
return self._diagnostic_result( return self._diagnostic_result(
matched=False, matched=False,
@@ -443,25 +434,6 @@ class BotService:
} }
], ],
) )
if not getattr(agent, 'enabled', True):
return self._diagnostic_result(
matched=False,
binding=selected_binding,
failure_code=self.FAILURE_PROCESSOR_DISABLED,
reason='Agent target is disabled',
diagnostic_steps=diagnostic_steps
+ [
{
'step': 'validate_processor',
'binding_id': selected_binding.get('id'),
'target_type': target_type,
'target_uuid': target_uuid,
'matched': False,
'failure_code': self.FAILURE_PROCESSOR_DISABLED,
'reason': 'Agent target is disabled',
}
],
)
if not RuntimeBot._agent_supports_event_type(getattr(agent, 'supported_event_patterns', None), event_type): if not RuntimeBot._agent_supports_event_type(getattr(agent, 'supported_event_patterns', None), event_type):
return self._diagnostic_result( return self._diagnostic_result(
matched=False, matched=False,
@@ -509,9 +481,7 @@ class BotService:
], ],
) )
async def _normalize_event_bindings( async def _normalize_event_bindings(self, context: TenantContext, bindings: list[dict] | None) -> list[dict]:
self, context: TenantContext, bindings: list[dict] | None
) -> list[dict]:
"""Validate and normalize Bot event bindings.""" """Validate and normalize Bot event bindings."""
if not bindings: if not bindings:
return [] return []
@@ -544,9 +514,7 @@ class BotService:
elif target_type == 'agent': elif target_type == 'agent':
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
scope_statement( scope_statement(
sqlalchemy.select(persistence_agent.Agent).where( sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid),
persistence_agent.Agent.uuid == target_uuid
),
persistence_agent.Agent, persistence_agent.Agent,
context, context,
) )
@@ -577,9 +545,7 @@ class BotService:
return normalized return normalized
async def _prepare_bot_data( async def _prepare_bot_data(self, context: TenantContext, bot_data: dict, *, include_uuid: bool) -> dict:
self, context: TenantContext, bot_data: dict, *, include_uuid: bool
) -> dict:
"""Normalize Bot write payloads to the current event-routing model.""" """Normalize Bot write payloads to the current event-routing model."""
update_data = bot_data.copy() update_data = bot_data.copy()
if not include_uuid: if not include_uuid:
@@ -705,6 +671,17 @@ class BotService:
) )
if getattr(result, 'rowcount', None) == 0: if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Bot not found') raise WorkspaceNotFoundError('Bot not found')
runtime_fields = {'adapter', 'adapter_config', 'enable', 'event_bindings'}
if not runtime_fields.intersection(update_data):
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is not None:
if 'name' in update_data:
runtime_bot.bot_entity.name = update_data['name']
if 'description' in update_data:
runtime_bot.bot_entity.description = update_data['description']
return
await self.ap.platform_mgr.remove_bot(context, bot_uuid) await self.ap.platform_mgr.remove_bot(context, bot_uuid)
# select from db # select from db
@@ -750,21 +727,19 @@ class BotService:
return [log.to_json() for log in logs], total_count return [log.to_json() for log in logs], total_count
async def list_event_route_statuses( async def list_event_route_statuses(self, context: TenantContext, bot_uuid: str) -> dict[str, typing.Any]:
self, context: TenantContext, bot_uuid: str
) -> dict[str, typing.Any]:
"""Return recent runtime status for Bot event routes from in-memory Bot logs.""" """Return recent runtime status for Bot event routes from in-memory Bot logs."""
from ....platform.botmgr import RuntimeBot from ....platform.botmgr import RuntimeBot
if await self.get_bot(context, bot_uuid, include_secret=False) is None: bot = await self.get_bot(context, bot_uuid, include_secret=False)
if bot is None:
raise WorkspaceNotFoundError('Bot not found') raise WorkspaceNotFoundError('Bot not found')
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid) runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is None:
raise Exception('Bot not found')
latest_by_binding: dict[str, dict[str, typing.Any]] = {} latest_by_binding: dict[str, dict[str, typing.Any]] = {}
unmatched_events: list[dict[str, typing.Any]] = [] unmatched_events: list[dict[str, typing.Any]] = []
for log in getattr(runtime_bot.logger, 'logs', []): runtime_logs = getattr(getattr(runtime_bot, 'logger', None), 'logs', [])
for log in runtime_logs:
status = self._event_route_status_from_log(log) status = self._event_route_status_from_log(log)
if status is None: if status is None:
continue continue
@@ -774,7 +749,10 @@ class BotService:
else: else:
unmatched_events.append(status) unmatched_events.append(status)
raw_bindings = getattr(getattr(runtime_bot, 'bot_entity', None), 'event_bindings', []) runtime_entity = getattr(runtime_bot, 'bot_entity', None)
raw_bindings = getattr(runtime_entity, 'event_bindings', None) if runtime_entity is not None else None
if raw_bindings is None:
raw_bindings = bot.get('event_bindings') or []
bindings = RuntimeBot._get_event_bindings_from_value(raw_bindings) bindings = RuntimeBot._get_event_bindings_from_value(raw_bindings)
routes: list[dict[str, typing.Any]] = [] routes: list[dict[str, typing.Any]] = []
current_binding_ids: set[str] = set() current_binding_ids: set[str] = set()
@@ -819,61 +797,6 @@ class BotService:
'stale_routes': stale_routes, 'stale_routes': stale_routes,
} }
async def dispatch_test_event_route(
self,
context: TenantContext,
bot_uuid: str,
event_type: str,
payload: dict[str, typing.Any] | None = None,
) -> dict[str, typing.Any]:
"""Dispatch a synthetic event through the saved Bot runtime route configuration."""
event_type = str(event_type or '').strip()
if not event_type:
return {
'dispatched': False,
'event_type': '',
'failure_code': self.FAILURE_INVALID_EVENT,
'reason': 'event_type is required',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
if payload is not None and not isinstance(payload, dict):
return {
'dispatched': False,
'event_type': event_type,
'failure_code': self.FAILURE_INVALID_EVENT,
'reason': 'payload must be an object',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
raise WorkspaceNotFoundError('Bot not found')
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is None:
raise Exception('Bot not found')
dispatch_result = await runtime_bot.dispatch_test_event(event_type, payload or {})
route_status = await self.list_event_route_statuses(context, bot_uuid)
return {
'dispatched': bool(dispatch_result.get('dispatched')),
'event_type': event_type,
'status': dispatch_result.get('status'),
'binding_id': dispatch_result.get('binding_id'),
'failure_code': dispatch_result.get('failure_code'),
'reason': dispatch_result.get('reason'),
'suppressed_outputs': dispatch_result.get('suppressed_outputs', []),
'route_status': route_status,
}
async def send_message( async def send_message(
self, self,
context: TenantContext, context: TenantContext,
-19
View File
@@ -132,25 +132,6 @@ class LangBotMCPServer:
async def list_bot_event_route_statuses(bot_uuid: str) -> str: async def list_bot_event_route_statuses(bot_uuid: str) -> str:
return _dump(await ap.bot_service.list_event_route_statuses(bot_uuid)) return _dump(await ap.bot_service.list_event_route_statuses(bot_uuid))
@mcp.tool(
description=(
'Dispatch a synthetic event through the saved bot event routes. '
'This validates routing without sending real outbound platform messages.'
)
)
async def test_bot_event_route(
bot_uuid: str,
event_type: str,
payload: dict | None = None,
) -> str:
return _dump(
await ap.bot_service.dispatch_test_event_route(
bot_uuid=bot_uuid,
event_type=event_type,
payload=payload,
)
)
# ----- Pipelines ----------------------------------------------- # # ----- Pipelines ----------------------------------------------- #
@mcp.tool(description='List all pipelines.') @mcp.tool(description='List all pipelines.')
async def list_pipelines() -> str: async def list_pipelines() -> str:
+17 -8
View File
@@ -1225,8 +1225,9 @@ class BoxService:
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]: async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
"""Fallback: read the outbox over the exec channel (E2B / remote). """Fallback: read the outbox over the exec channel (E2B / remote).
Note: exec stdout is truncated by ``output_limit_chars``, so this path Uses ``client.execute`` directly (bypassing ``_serialize_result``)
only reliably transfers small files. The host path is preferred. so stdout is NOT truncated by ``output_limit_chars`` - the raw
base64 payload can be far larger than the 4000-char display limit.
""" """
import json as _json import json as _json
@@ -1280,14 +1281,22 @@ class BoxService:
' break\n' ' break\n'
'print(json.dumps(out))\n' 'print(json.dumps(out))\n'
) )
result = await self.execute_tool( spec_payload: dict = {
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120}, 'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
query, 'timeout_sec': 120,
) 'session_id': self.resolve_box_session_id(query),
if not result.get('ok'): }
if 'extra_mounts' not in spec_payload:
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
try:
spec = self.build_spec(spec_payload)
result = await self.client.execute(spec)
except Exception:
return []
if not result.ok:
return [] return []
try: try:
return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1]) return _json.loads(str(result.stdout or '').strip().splitlines()[-1])
except Exception: except Exception:
return [] return []
+9 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import typing import typing
import inspect import inspect
from ..api.http.context import ExecutionContext
from ..core import app from ..core import app
from . import operator from . import operator
from ..utils import importutil from ..utils import importutil
@@ -66,7 +67,14 @@ class CommandManager:
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None) require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
if require_context is not None: if require_context is not None:
result = require_context(context) result = require_context(
ExecutionContext(
instance_uuid=context.instance_uuid,
workspace_uuid=context.workspace_uuid,
placement_generation=context.placement_generation,
query_uuid=context.query_uuid,
)
)
if inspect.isawaitable(result): if inspect.isawaitable(result):
await result await result
+33 -3
View File
@@ -315,11 +315,36 @@ class Application:
async def initialize(self): async def initialize(self):
pass pass
async def _initialize_plugin_runtime(self) -> None:
try:
await self.plugin_connector.initialize()
except asyncio.CancelledError:
raise
except Exception as exc:
self.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
self.plugin_connector.schedule_reconnect()
def _start_plugin_runtime_initialization(self) -> asyncio.Task | None:
task = getattr(self, '_plugin_runtime_initialization_task', None)
if task is not None and not task.done():
return task
# This is application lifecycle work, not a request side effect. It must
# not wait on PersistenceManager's after-commit gate at boot.
task = asyncio.create_task(
self._initialize_plugin_runtime(),
name='plugin-runtime-initialization',
)
self._plugin_runtime_initialization_task = task
return task
async def run(self): async def run(self):
self.event_loop_monitor.start() self.event_loop_monitor.start()
try: try:
if self.directory_projection_service is not None: if (
self.task_mgr.create_task( self.directory_projection_service is not None
and getattr(self, 'directory_projection_task', None) is None
):
self.directory_projection_task = self.task_mgr.create_task(
self.directory_projection_service.run(), self.directory_projection_service.run(),
name='cloud-directory-projection', name='cloud-directory-projection',
scopes=[core_entities.LifecycleControlScope.APPLICATION], scopes=[core_entities.LifecycleControlScope.APPLICATION],
@@ -336,7 +361,6 @@ class Application:
name='cloud-manifest-refresh', name='cloud-manifest-refresh',
scopes=[core_entities.LifecycleControlScope.APPLICATION], scopes=[core_entities.LifecycleControlScope.APPLICATION],
) )
await self.plugin_connector.initialize_plugins()
# 后续可能会允许动态重启其他任务 # 后续可能会允许动态重启其他任务
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程 # 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
@@ -362,6 +386,7 @@ class Application:
name='http-api-controller', name='http-api-controller',
scopes=[core_entities.LifecycleControlScope.APPLICATION], scopes=[core_entities.LifecycleControlScope.APPLICATION],
) )
self._start_plugin_runtime_initialization()
# Telemetry instance heartbeat (startup + daily); respects # Telemetry instance heartbeat (startup + daily); respects
# space.disable_telemetry via TelemetryManager.send(). # space.disable_telemetry via TelemetryManager.send().
@@ -543,6 +568,11 @@ class Application:
if self.task_mgr is not None: if self.task_mgr is not None:
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION) self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
plugin_runtime_task = getattr(self, '_plugin_runtime_initialization_task', None)
if plugin_runtime_task is not None and not plugin_runtime_task.done():
plugin_runtime_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await plugin_runtime_task
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await self.event_loop_monitor.stop() await self.event_loop_monitor.stop()
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None) mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
+11 -8
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .. import stage, app from .. import stage, app, entities as core_entities
from ...utils import version, proxy, constants from ...utils import version, proxy, constants
from ...pipeline import pool, controller, pipelinemgr from ...pipeline import pool, controller, pipelinemgr
from ...pipeline import aggregator as message_aggregator from ...pipeline import aggregator as message_aggregator
@@ -297,14 +297,17 @@ class BuildAppStage(stage.BootingStage):
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None: async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
connector.schedule_reconnect() connector.schedule_reconnect()
if ap.directory_projection_service is not None:
# Keep the projection fresh while shared Runtime cold restore runs.
# BuildApp initializes the connector before Application.run() starts
# its long-lived tasks, so start the single refresh task here.
ap.directory_projection_task = ap.task_mgr.create_task(
ap.directory_projection_service.run(),
name='cloud-directory-projection',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback) plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
try:
await plugin_connector_inst.initialize()
except Exception as exc:
# Keep the API/UI available while an external or managed runtime is
# starting, then recover in the background with bounded backoff.
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
plugin_connector_inst.schedule_reconnect()
ap.plugin_connector = plugin_connector_inst ap.plugin_connector = plugin_connector_inst
workspace_service_inst.release_startup_execution_bindings() workspace_service_inst.release_startup_execution_bindings()
@@ -20,7 +20,6 @@ class Agent(Base):
kind = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='agent') kind = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='agent')
component_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) component_ref = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
supported_event_patterns = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=['*']) supported_event_patterns = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=['*'])
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column( updated_at = sqlalchemy.Column(
@@ -0,0 +1,37 @@
"""Drop the obsolete Agent enabled state.
Revision ID: 0023_drop_agent_enabled
Revises: 0022_merge_agent_reasoning_heads
Create Date: 2026-08-25
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '0023_drop_agent_enabled'
down_revision = '0022_merge_agent_reasoning_heads'
branch_labels = None
depends_on = None
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
if table_name not in inspector.get_table_names():
return False
return any(column['name'] == column_name for column in inspector.get_columns(table_name))
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if _column_exists(inspector, 'agents', 'enabled'):
with op.batch_alter_table('agents') as batch_op:
batch_op.drop_column('enabled')
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if 'agents' in inspector.get_table_names() and not _column_exists(inspector, 'agents', 'enabled'):
with op.batch_alter_table('agents') as batch_op:
batch_op.add_column(sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()))
+12 -1
View File
@@ -177,7 +177,6 @@ class PersistenceManager:
await self._validate_cloud_runtime() await self._validate_cloud_runtime()
return return
self._enable_sqlite_foreign_keys()
if self.mode == PersistenceMode.RELEASE_MIGRATION: if self.mode == PersistenceMode.RELEASE_MIGRATION:
async with self._release_migration_lock(): async with self._release_migration_lock():
await self._initialize_managed_schema() await self._initialize_managed_schema()
@@ -185,6 +184,7 @@ class PersistenceManager:
return return
await self._initialize_managed_schema() await self._initialize_managed_schema()
await self._enable_sqlite_foreign_keys_after_migration()
if self.mode == PersistenceMode.OSS_COMPAT: if self.mode == PersistenceMode.OSS_COMPAT:
await self.write_space_model_providers() await self.write_space_model_providers()
@@ -328,6 +328,17 @@ class PersistenceManager:
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope) sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
self._oss_tenant_scope_listener_installed = True self._oss_tenant_scope_listener_installed = True
async def _enable_sqlite_foreign_keys_after_migration(self) -> None:
"""Enable SQLite FK enforcement only after table-rebuilding migrations."""
engine = self.get_db_engine()
if engine.dialect.name != 'sqlite':
return
await engine.dispose()
self._enable_sqlite_foreign_keys()
# Dispose again so every runtime connection is opened through the new
# listener instead of reusing a pre-migration pooled connection.
await engine.dispose()
def _enable_sqlite_foreign_keys(self) -> None: def _enable_sqlite_foreign_keys(self) -> None:
"""Enable SQLite FK enforcement for every pooled runtime connection.""" """Enable SQLite FK enforcement for every pooled runtime connection."""
engine = self.get_db_engine() engine = self.get_db_engine()
@@ -12,6 +12,7 @@ import re
import secrets import secrets
import sqlite3 import sqlite3
import tempfile import tempfile
import time
import typing import typing
from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy.ext.asyncio import AsyncEngine
@@ -117,8 +118,19 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
temporary_path.unlink(missing_ok=True) temporary_path.unlink(missing_ok=True)
def _fsync_file(path: pathlib.Path) -> None: def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
descriptor = os.open(path, os.O_RDONLY) """Sync a file, tolerating delayed visibility after replace on bind mounts."""
descriptor: int | None = None
for attempt in range(reopen_attempts):
try:
descriptor = os.open(path, os.O_RDONLY)
break
except FileNotFoundError:
if attempt + 1 >= reopen_attempts:
raise
time.sleep(0.05)
assert descriptor is not None
try: try:
os.fsync(descriptor) os.fsync(descriptor)
finally: finally:
@@ -25,7 +25,7 @@ class BanWordFilter(filter_model.ContentFilter):
return entities.FilterResult( return entities.FilterResult(
level=entities.ResultLevel.BLOCK, level=entities.ResultLevel.BLOCK,
replacement='', replacement='',
user_notice='内容检查规则执行失败,请联系管理员', user_notice='内容安全检查配置有误,请检查敏感词设置',
console_notice=f'Sensitive-word regex rejected: {exc}', console_notice=f'Sensitive-word regex rejected: {exc}',
) )
@@ -158,6 +158,18 @@ class ResponseWrapper(stage.PipelineStage):
result_type=entities.ResultType.CONTINUE, result_type=entities.ResultType.CONTINUE,
new_query=query, new_query=query,
) )
elif (
isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls
):
# Final streaming chunk with no text content but
# possibly carrying sandbox outbox attachments.
reply_chain = platform_message.MessageChain([])
await self._append_outbound_attachments(query, reply_chain)
query.resp_message_chain.append(reply_chain)
yield entities.StageProcessResult(
result_type=entities.ResultType.CONTINUE,
new_query=query,
)
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用 if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
function_names = [tc.function.name for tc in result.tool_calls] function_names = [tc.function.name for tc in result.tool_calls]
+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 from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
class SyntheticRouteTestAdapter:
"""Adapter wrapper that suppresses outbound platform delivery for test events."""
SIDE_EFFECT_API_NAMES = {
'send_message',
'reply_message',
'reply_message_chunk',
'create_message_card',
'edit_message',
'delete_message',
'add_reaction',
'remove_reaction',
'forward_message',
'set_group_name',
'mute_member',
'unmute_member',
'kick_member',
'leave_group',
'approve_friend_request',
'approve_group_invite',
'upload_file',
'call_platform_api',
}
def __init__(self, source: abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.source = source
self.bot_account_id = getattr(source, 'bot_account_id', '')
self.config = getattr(source, 'config', {})
self.logger = getattr(source, 'logger', None)
self.suppressed_outputs: list[dict[str, typing.Any]] = []
@staticmethod
def _message_to_payload(message: platform_message.MessageChain) -> typing.Any:
return message.model_dump() if hasattr(message, 'model_dump') else str(message)
def _suppress(self, method: str, **payload: typing.Any) -> None:
self.suppressed_outputs.append({'method': method, **payload})
def __getattr__(self, name: str) -> typing.Any:
return getattr(self.source, name)
def get_supported_apis(self) -> list[str]:
get_supported_apis = getattr(self.source, 'get_supported_apis', None)
if not callable(get_supported_apis):
return []
return [api_name for api_name in get_supported_apis() if api_name not in self.SIDE_EFFECT_API_NAMES]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> dict[str, typing.Any]:
self._suppress(
'send_message',
target_type=target_type,
target_id=target_id,
message=self._message_to_payload(message),
)
return {'suppressed': True}
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> dict[str, typing.Any]:
self._suppress(
'reply_message',
message=self._message_to_payload(message),
quote_origin=quote_origin,
)
return {'suppressed': True}
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message: dict,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
) -> dict[str, typing.Any]:
self._suppress(
'reply_message_chunk',
message=self._message_to_payload(message),
quote_origin=quote_origin,
is_final=is_final,
)
return {'suppressed': True}
async def create_message_card(
self,
message_id: str | int,
event: platform_events.MessageEvent,
) -> bool:
self._suppress('create_message_card', message_id=str(message_id))
return False
async def is_stream_output_supported(self) -> bool:
return False
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
self._suppress(
'edit_message',
chat_type=str(chat_type),
chat_id=str(chat_id),
message_id=str(message_id),
new_content=self._message_to_payload(new_content),
)
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
self._suppress(
'delete_message',
chat_type=str(chat_type),
chat_id=str(chat_id),
message_id=str(message_id),
)
async def forward_message(
self,
from_chat_type: str,
from_chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
to_chat_type: str,
to_chat_id: typing.Union[int, str],
) -> platform_events.MessageResult:
self._suppress(
'forward_message',
from_chat_type=str(from_chat_type),
from_chat_id=str(from_chat_id),
message_id=str(message_id),
to_chat_type=str(to_chat_type),
to_chat_id=str(to_chat_id),
)
return platform_events.MessageResult(raw={'suppressed': True})
async def set_group_name(
self,
group_id: typing.Union[int, str],
name: str,
) -> None:
self._suppress('set_group_name', group_id=str(group_id), name=name)
async def mute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
duration: int = 0,
) -> None:
self._suppress(
'mute_member',
group_id=str(group_id),
user_id=str(user_id),
duration=duration,
)
async def unmute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
self._suppress('unmute_member', group_id=str(group_id), user_id=str(user_id))
async def kick_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
self._suppress('kick_member', group_id=str(group_id), user_id=str(user_id))
async def leave_group(
self,
group_id: typing.Union[int, str],
) -> None:
self._suppress('leave_group', group_id=str(group_id))
async def approve_friend_request(
self,
request_id: typing.Union[int, str],
approve: bool = True,
remark: str | None = None,
) -> None:
self._suppress(
'approve_friend_request',
request_id=str(request_id),
approve=approve,
remark=remark,
)
async def approve_group_invite(
self,
request_id: typing.Union[int, str],
approve: bool = True,
) -> None:
self._suppress(
'approve_group_invite',
request_id=str(request_id),
approve=approve,
)
async def upload_file(self, file_data: bytes, filename: str) -> str:
self._suppress('upload_file', filename=filename, size=len(file_data))
return f'suppressed:{filename}'
async def call_platform_api(self, action: str, params: dict | None = None) -> dict:
self._suppress('call_platform_api', action=action, params=params or {})
return {'suppressed': True}
class RuntimeBot: class RuntimeBot:
"""运行时机器人""" """运行时机器人"""
@@ -568,126 +348,6 @@ class RuntimeBot:
"""Return the selected event binding plus per-binding diagnostic steps.""" """Return the selected event binding plus per-binding diagnostic steps."""
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type) return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
@staticmethod
def _build_test_platform_event(
event_type: str,
payload: dict[str, typing.Any] | None = None,
) -> platform_events.EBAEvent:
"""Build a synthetic platform event for route validation."""
payload = payload or {}
now = time.time()
common = {
'type': event_type,
'timestamp': payload.get('timestamp') or now,
'adapter_name': payload.get('adapter_name') or 'test-event',
'source_platform_object': {'synthetic': True, 'payload': payload},
}
user_id = str(payload.get('user_id') or payload.get('sender_id') or 'test-user')
user_name = str(payload.get('user_name') or payload.get('sender_name') or 'Test User')
group_id = str(payload.get('group_id') or payload.get('chat_id') or 'test-group')
group_name = str(payload.get('group_name') or 'Test Group')
if event_type == 'message.received':
chat_type_value = str(payload.get('chat_type') or 'private')
chat_type = (
platform_entities.ChatType.GROUP
if chat_type_value == platform_entities.ChatType.GROUP.value
else platform_entities.ChatType.PRIVATE
)
chat_id = str(
payload.get('chat_id') or (group_id if chat_type == platform_entities.ChatType.GROUP else user_id)
)
message_text = str(payload.get('message_text') or payload.get('text') or '')
message_chain_data = payload.get('message_chain')
if message_chain_data is None:
message_chain = platform_message.MessageChain([platform_message.Plain(text=message_text)])
else:
message_chain = platform_message.MessageChain.model_validate(message_chain_data)
group = (
platform_entities.UserGroup(id=chat_id, name=group_name)
if chat_type == platform_entities.ChatType.GROUP
else None
)
return platform_events.MessageReceivedEvent(
**common,
message_id=str(payload.get('message_id') or f'test-message:{uuid.uuid4()}'),
message_chain=message_chain,
sender=platform_entities.User(id=user_id, nickname=user_name),
chat_type=chat_type,
chat_id=chat_id,
group=group,
)
if event_type == 'group.member_joined':
return platform_events.MemberJoinedEvent(
**common,
group=platform_entities.UserGroup(id=group_id, name=group_name),
member=platform_entities.User(id=user_id, nickname=user_name),
inviter=platform_entities.User(
id=str(payload.get('inviter_id')),
nickname=str(payload.get('inviter_name') or ''),
)
if payload.get('inviter_id')
else None,
join_type=payload.get('join_type'),
)
if event_type == 'group.member_left':
return platform_events.MemberLeftEvent(
**common,
group=platform_entities.UserGroup(id=group_id, name=group_name),
member=platform_entities.User(id=user_id, nickname=user_name),
is_kicked=bool(payload.get('is_kicked', False)),
operator=platform_entities.User(
id=str(payload.get('operator_id')),
nickname=str(payload.get('operator_name') or ''),
)
if payload.get('operator_id')
else None,
)
if event_type == 'platform.specific':
return platform_events.PlatformSpecificEvent(
**common,
action=str(payload.get('action') or 'test'),
data=payload.get('data') if isinstance(payload.get('data'), dict) else payload,
)
return platform_events.EBAEvent(**common)
async def dispatch_test_event(
self,
event_type: str,
payload: dict[str, typing.Any] | None = None,
) -> dict[str, typing.Any]:
"""Dispatch a synthetic event through the real runtime route path."""
event_type = str(event_type or '').strip()
if not event_type:
raise ValueError('event_type is required')
event = self._build_test_platform_event(event_type, payload)
await self._record_event_route_trace(
event_type=event_type,
status='test_started',
reason='Synthetic test event dispatched from control plane',
text=f'Test event {event_type} dispatched from control plane',
)
test_adapter = SyntheticRouteTestAdapter(self.adapter)
outcome = await self._dispatch_eba_event_to_processor(
event,
typing.cast(abstract_platform_adapter.AbstractMessagePlatformAdapter, test_adapter),
)
return {
'event_type': event_type,
'dispatched': outcome['status'] in {'delivered', 'discarded'},
'status': outcome['status'],
'binding_id': outcome.get('binding_id'),
'failure_code': outcome.get('failure_code'),
'reason': outcome.get('reason'),
'suppressed_outputs': test_adapter.suppressed_outputs,
}
async def _record_event_route_trace( async def _record_event_route_trace(
self, self,
*, *,
@@ -784,6 +444,26 @@ class RuntimeBot:
compact[key] = value compact[key] = value
return compact return compact
async def _record_adapter_event(
self,
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> dict[str, typing.Any]:
"""Record a normalized adapter event for the platform debugging surface."""
event_type = getattr(event, 'type', None) or event.__class__.__name__
metadata = {
'kind': 'adapter_event_received',
'event_type': event_type,
'event_data': self._compact_event_data(event),
'adapter': getattr(self.bot_entity, 'adapter', None) or adapter.__class__.__name__,
'bot_uuid': self.bot_entity.uuid,
}
await self.logger.info(
f'Platform adapter received {event_type}',
metadata=metadata,
)
return metadata
@staticmethod @staticmethod
def _get_entity_id(entity: typing.Any) -> str | None: def _get_entity_id(entity: typing.Any) -> str | None:
entity_id = getattr(entity, 'id', None) entity_id = getattr(entity, 'id', None)
@@ -1135,7 +815,6 @@ class RuntimeBot:
enable_reply=True, enable_reply=True,
enable_interactions=True, enable_interactions=True,
), ),
enabled=True,
agent_id=agent.get('uuid'), agent_id=agent.get('uuid'),
processor_type='agent', processor_type='agent',
processor_id=agent.get('uuid'), processor_id=agent.get('uuid'),
@@ -1171,7 +850,7 @@ class RuntimeBot:
self, self,
envelope: AgentEventEnvelope, envelope: AgentEventEnvelope,
outputs: list[provider_message.Message | provider_message.MessageChunk], outputs: list[provider_message.Message | provider_message.MessageChunk],
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | SyntheticRouteTestAdapter | None = None, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | None = None,
) -> None: ) -> None:
if not outputs or not envelope.delivery.reply_target: if not outputs or not envelope.delivery.reply_target:
return return
@@ -1205,11 +884,13 @@ class RuntimeBot:
event: platform_events.EBAEvent, event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> None: ) -> None:
event.bot_uuid = self.bot_entity.uuid
await self._record_adapter_event(event, adapter)
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted': if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted':
await self._handle_interaction_submission(event, adapter) await self._handle_interaction_submission(event, adapter)
return return
event.bot_uuid = self.bot_entity.uuid
plugin_event = self._eba_event_to_plugin_event(event) plugin_event = self._eba_event_to_plugin_event(event)
if plugin_event is not None: if plugin_event is not None:
@@ -1322,17 +1003,6 @@ class RuntimeBot:
reason='Agent target not found', reason='Agent target not found',
text=f'EBA event {event_type} target agent not found: {target_uuid}', text=f'EBA event {event_type} target agent not found: {target_uuid}',
) )
if not agent.get('enabled', True):
return await self._record_event_route_trace(
event_type=event_type,
status='failed',
binding=event_binding,
target_type=target_type,
target_uuid=target_uuid,
failure_code='processor_disabled',
reason='Agent target is disabled',
text=f'EBA event {event_type} target agent disabled: {target_uuid}',
)
if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type): if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type):
return await self._record_event_route_trace( return await self._record_event_route_trace(
event_type=event_type, event_type=event_type,
@@ -1711,7 +1381,7 @@ class RuntimeBot:
self.execution_context, self.execution_context,
record['processor_id'], record['processor_id'],
) )
if not agent or agent.get('kind') != 'agent' or not agent.get('enabled', True): if not agent or agent.get('kind') != 'agent':
raise ValueError(f'Interaction target Agent is unavailable: {record["processor_id"]}') raise ValueError(f'Interaction target Agent is unavailable: {record["processor_id"]}')
binding = self._agent_product_to_binding( binding = self._agent_product_to_binding(
@@ -1810,9 +1480,7 @@ class RuntimeBot:
def tenant_scoped_listener(listener): def tenant_scoped_listener(listener):
@functools.wraps(listener) @functools.wraps(listener)
async def wrapped(*args, **kwargs): async def wrapped(*args, **kwargs):
tenant_scope = getattr( tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
self.ap.persistence_mgr, 'tenant_scope', None
)
cloud_runtime = ( cloud_runtime = (
getattr( getattr(
getattr(self.ap.persistence_mgr, 'mode', None), getattr(self.ap.persistence_mgr, 'mode', None),
@@ -1823,9 +1491,7 @@ class RuntimeBot:
) )
if cloud_runtime: if cloud_runtime:
if not callable(tenant_scope): if not callable(tenant_scope):
raise RuntimeError( raise RuntimeError('Cloud platform callbacks require a tenant scope')
'Cloud platform callbacks require a tenant scope'
)
async with tenant_scope(self.workspace_uuid): async with tenant_scope(self.workspace_uuid):
return await listener(*args, **kwargs) return await listener(*args, **kwargs)
return await listener(*args, **kwargs) return await listener(*args, **kwargs)
@@ -232,7 +232,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
@staticmethod @staticmethod
def _history_message_chain(message_chain: list[dict]) -> list[dict]: def _history_message_chain(message_chain: list[dict]) -> list[dict]:
"""Remove large transient payloads before retaining browser history.""" """Retain renderable references without storing large inline payloads."""
history = [] history = []
for component in message_chain: for component in message_chain:
@@ -551,7 +551,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
Image / Voice / File components uploaded from the web client carry a Image / Voice / File components uploaded from the web client carry a
storage key in ``path``. Resolve it to a base64 data URI so downstream storage key in ``path``. Resolve it to a base64 data URI so downstream
stages (multimodal LLM input and the Box sandbox inbox) have a usable stages (multimodal LLM input and the Box sandbox inbox) have a usable
payload, then drop the now-consumed storage object. payload. Keep image objects for the short-lived browser history so the
authenticated image endpoint can render them; normal upload retention
cleanup removes them later. Other attachment types are consumed
immediately because the chat history does not render them by path.
Args: Args:
message_chain_obj: 消息链对象列表 message_chain_obj: 消息链对象列表
@@ -606,12 +609,13 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream' mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream'
component['base64'] = f'data:{mime_type};base64,{base64_str}' component['base64'] = f'data:{mime_type};base64,{base64_str}'
await storage_mgr.delete_scoped_object_key( if comp_type != 'Image':
execution_context, await storage_mgr.delete_scoped_object_key(
comp_path, execution_context,
expected_owner_type='upload_image', comp_path,
) expected_owner_type='upload_image',
component['path'] = '' )
component['path'] = ''
except Exception as e: except Exception as e:
await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}') await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}')
raise raise
+107 -19
View File
@@ -3,8 +3,10 @@ import typing
import asyncio import asyncio
import time import time
import traceback import traceback
import base64
import datetime import datetime
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.events as platform_events
@@ -24,11 +26,24 @@ from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter): class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod @staticmethod
async def yiri2target(message_chain: platform_message.MessageChain): async def yiri2target(message_chain: platform_message.MessageChain):
content = '' """Convert a MessageChain into a list of component dicts.
Each dict has a ``type`` key (``'text'``, ``'image'``,
``'voice'``, ``'file'``). Text items carry ``text``; media
items carry ``base64`` (may include a ``data:...;base64,``
prefix) and optionally ``name``.
"""
items: list[dict] = []
for msg in message_chain: for msg in message_chain:
if type(msg) is platform_message.Plain: if type(msg) is platform_message.Plain:
content += msg.text items.append({'type': 'text', 'text': msg.text})
return content elif type(msg) is platform_message.Image:
items.append({'type': 'image', 'base64': msg.base64 or ''})
elif type(msg) is platform_message.Voice:
items.append({'type': 'voice', 'base64': msg.base64 or ''})
elif type(msg) is platform_message.File:
items.append({'type': 'file', 'base64': msg.base64 or '', 'name': msg.name or ''})
return items
@staticmethod @staticmethod
async def target2yiri(event: WecomBotEvent, bot_name: str = ''): async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
@@ -362,13 +377,76 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
} }
) )
@staticmethod
def _join_text_components(items: list[dict]) -> str:
"""Concatenate ``text`` items in order, leaving media items alone."""
return ''.join(item['text'] for item in items if item.get('type') == 'text')
@staticmethod
def _iter_media_components(items: list[dict]):
"""Yield non-text items in order."""
for item in items:
if item.get('type') in {'image', 'voice', 'file'}:
yield item
@staticmethod
async def _send_media(
bot,
req_id: str,
item: dict,
) -> bool:
"""Upload *item* to the WeCom AI Bot CDN and send it as a media reply.
Returns True on success. Falls back to a no-op (with a warning log)
if the SDK does not yet implement ``upload_media`` /
``reply_image`` / ``reply_file`` / ``reply_voice`` the framework
will keep working, just without image delivery.
"""
kind = item.get('type')
upload = getattr(bot, 'upload_media', None)
if upload is None:
return False
b64_text = item.get('base64') or ''
if not b64_text:
return False
if b64_text.startswith('data:') and ',' in b64_text:
b64_text = b64_text.split(',', 1)[1]
try:
data = base64.b64decode(b64_text, validate=False)
except Exception:
return False
if not data:
return False
try:
upload_result = await upload(data, item.get('name') or f'attachment.{kind}', media_type=kind)
except Exception:
return False
media_id = getattr(upload_result, 'media_id', None) or (
isinstance(upload_result, dict) and upload_result.get('media_id')
)
if not media_id:
return False
reply_fn = {
'image': getattr(bot, 'reply_image', None),
'file': getattr(bot, 'reply_file', None),
'voice': getattr(bot, 'reply_voice', None),
}.get(kind)
if reply_fn is None:
return False
try:
await reply_fn(req_id, media_id)
return True
except Exception:
return False
async def reply_message( async def reply_message(
self, self,
message_source: platform_events.MessageEvent, message_source: platform_events.MessageEvent,
message: platform_message.MessageChain, message: platform_message.MessageChain,
quote_origin: bool = False, quote_origin: bool = False,
): ):
content = await self.message_converter.yiri2target(message) items = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
_ws_mode = not self.config.get('enable-webhook', False) _ws_mode = not self.config.get('enable-webhook', False)
event = message_source.source_platform_object event = message_source.source_platform_object
@@ -382,7 +460,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else: else:
chat_id = str(message_source.sender.id) chat_id = str(message_source.sender.id)
try: try:
await self.bot.send_message(chat_id, content) await self.bot.send_message(chat_id, text)
except Exception: except Exception:
await self.logger.error( await self.logger.error(
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}' f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
@@ -396,12 +474,15 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if _ws_mode: if _ws_mode:
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '') req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
if req_id: if text:
await self.bot.reply_text(req_id, content) if req_id:
else: await self.bot.reply_text(req_id, text)
await self.bot.set_message(event.message_id, content) else:
await self.bot.set_message(event.message_id, text)
for item in self._iter_media_components(items):
await self._send_media(self.bot, req_id, item)
else: else:
await self.bot.set_message(event.message_id, content) await self.bot.set_message(event.message_id, text)
async def reply_message_chunk( async def reply_message_chunk(
self, self,
@@ -411,7 +492,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
quote_origin: bool = False, quote_origin: bool = False,
is_final: bool = False, is_final: bool = False,
): ):
content = await self.message_converter.yiri2target(message) items = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
_ws_mode = not self.config.get('enable-webhook', False) _ws_mode = not self.config.get('enable-webhook', False)
# Synthetic events (e.g. button-click triggered form resume) have # Synthetic events (e.g. button-click triggered form resume) have
@@ -420,7 +502,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# of the stream/reply path. # of the stream/reply path.
spo = message_source.source_platform_object spo = message_source.source_platform_object
if spo is None: if spo is None:
return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode) return await self._handle_synthetic_chunk(message_source, bot_message, text, is_final, _ws_mode)
msg_id = spo.message_id msg_id = spo.message_id
@@ -452,7 +534,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
form_data.get('actions', []) or [], form_data.get('actions', []) or [],
) )
except Exception: except Exception:
fallback = content or '(人工输入)' fallback = text or '(人工输入)'
if _ws_mode: if _ws_mode:
event = message_source.source_platform_object event = message_source.source_platform_object
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '') req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
@@ -463,17 +545,22 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return {'stream': False, 'form': True, 'fallback': True} return {'stream': False, 'form': True, 'fallback': True}
if _ws_mode: if _ws_mode:
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
if not success and is_final: if not success and is_final:
event = message_source.source_platform_object event = message_source.source_platform_object
req_id = event.get('req_id', '') req_id = event.get('req_id', '')
if req_id: if req_id:
await self.bot.reply_text(req_id, content) await self.bot.reply_text(req_id, text)
if is_final:
event = message_source.source_platform_object
req_id = event.get('req_id', '')
for item in self._iter_media_components(items):
await self._send_media(self.bot, req_id, item)
return {'stream': success} return {'stream': success}
else: else:
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
if not success and is_final: if not success and is_final:
await self.bot.set_message(msg_id, content) await self.bot.set_message(msg_id, text)
return {'stream': success} return {'stream': success}
async def is_stream_output_supported(self) -> bool: async def is_stream_output_supported(self) -> bool:
@@ -627,8 +714,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def send_message(self, target_type, target_id, message): async def send_message(self, target_type, target_id, message):
_ws_mode = not self.config.get('enable-webhook', False) _ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode: if _ws_mode:
content = await self.message_converter.yiri2target(message) items = await self.message_converter.yiri2target(message)
await self.bot.send_message(target_id, content) text = self._join_text_components(items)
await self.bot.send_message(target_id, text)
else: else:
pass pass
+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}) self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values())) reconcile_timeout_seconds = max(
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
)
result = await runtime_handler.reconcile_plugin_installations(
tuple(self._known_desired_states.values()),
timeout=reconcile_timeout_seconds,
)
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result) await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
self._record_reconcile_failures(self._known_desired_states, result) self._record_reconcile_failures(self._known_desired_states, result)
@@ -736,7 +742,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if state.binding.installation_uuid in all_states: if state.binding.installation_uuid in all_states:
raise ValueError('Duplicate plugin installation UUID across projected Workspaces') raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
all_states[state.binding.installation_uuid] = state all_states[state.binding.installation_uuid] = state
result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values())) reconcile_timeout_seconds = max(
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
)
result = await runtime_handler.reconcile_plugin_installations(
tuple(all_states.values()),
timeout=reconcile_timeout_seconds,
)
await self._repair_reconcile_missing_artifacts(all_states, result) await self._repair_reconcile_missing_artifacts(all_states, result)
self._record_reconcile_failures(all_states, result) self._record_reconcile_failures(all_states, result)
for installation_uuid, previous in tuple(self._known_desired_states.items()): for installation_uuid, previous in tuple(self._known_desired_states.items()):
+120 -14
View File
@@ -13,6 +13,8 @@ from dataclasses import dataclass
import pydantic import pydantic
import sqlalchemy import sqlalchemy
import sqlalchemy.dialects.postgresql
import sqlalchemy.dialects.sqlite
from langbot_plugin.runtime.io import handler from langbot_plugin.runtime.io import handler
from langbot_plugin.runtime.io.connection import Connection from langbot_plugin.runtime.io.connection import Connection
@@ -832,6 +834,19 @@ class RuntimeConnectionHandler(handler.Handler):
return f'{identity.plugin_author}/{identity.plugin_name}' return f'{identity.plugin_author}/{identity.plugin_name}'
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}') raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
@staticmethod
def _legacy_binary_storage_key(
action_context: ActionContext,
*,
owner_type: str,
owner: str,
key: str,
) -> str:
"""Return the pre-tenancy key shape for a row already scoped to this Workspace."""
legacy_owner = action_context.workspace_uuid if owner_type == 'workspace' else owner
return f'{owner_type}:{legacy_owner}:{key}'
@classmethod @classmethod
def _binary_storage_key( def _binary_storage_key(
cls, cls,
@@ -1661,25 +1676,82 @@ class RuntimeConnectionHandler(handler.Handler):
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key) .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
) )
storage = result.first()
if storage is None:
legacy_key = self._legacy_binary_storage_key(
action_context,
owner_type=owner_type,
owner=owner,
key=key,
)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
)
storage = result.first()
if storage is not None:
update_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
.values(unique_key=unique_key, value=value)
)
if update_result.rowcount:
return handler.ActionResponse.success(data={})
canonical_update = await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
.values(value=value)
)
if canonical_update.rowcount:
return handler.ActionResponse.success(data={})
storage = None
if result.first() is not None: if storage is not None:
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage) sqlalchemy.update(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key) .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
.values(value=value) .values(value=value)
) )
else: return handler.ActionResponse.success(data={})
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_bstorage.BinaryStorage).values( dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
workspace_uuid=action_context.workspace_uuid, insert = {
unique_key=unique_key, 'postgresql': sqlalchemy.dialects.postgresql.insert,
key=key, 'sqlite': sqlalchemy.dialects.sqlite.insert,
owner_type=owner_type, }.get(dialect_name)
owner=owner, if insert is None:
value=value, return handler.ActionResponse.error(message=f'Unsupported storage database dialect: {dialect_name}')
) await self.ap.persistence_mgr.execute_async(
insert(persistence_bstorage.BinaryStorage)
.values(
workspace_uuid=action_context.workspace_uuid,
unique_key=unique_key,
key=key,
owner_type=owner_type,
owner=owner,
value=value,
) )
.on_conflict_do_update(
index_elements=['workspace_uuid', 'unique_key'],
set_={'value': value},
)
)
return handler.ActionResponse.success( return handler.ActionResponse.success(
data={}, data={},
@@ -1722,6 +1794,29 @@ class RuntimeConnectionHandler(handler.Handler):
) )
storage = result.first() storage = result.first()
if storage is None:
legacy_key = self._legacy_binary_storage_key(
action_context,
owner_type=owner_type,
owner=owner,
key=key,
)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
)
storage = result.first()
if storage is None:
retry_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
storage = retry_result.first()
if storage is None: if storage is None:
return handler.ActionResponse.error( return handler.ActionResponse.error(
message=f'Storage with key {key} not found', message=f'Storage with key {key} not found',
@@ -1768,10 +1863,19 @@ class RuntimeConnectionHandler(handler.Handler):
message=str(e), message=str(e),
) )
legacy_key = self._legacy_binary_storage_key(
action_context,
owner_type=owner_type,
owner=owner,
key=key,
)
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bstorage.BinaryStorage) sqlalchemy.delete(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key) .where(persistence_bstorage.BinaryStorage.unique_key.in_((unique_key, legacy_key)))
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
) )
return handler.ActionResponse.success( return handler.ActionResponse.success(
@@ -1810,7 +1914,7 @@ class RuntimeConnectionHandler(handler.Handler):
return handler.ActionResponse.success( return handler.ActionResponse.success(
data={ data={
'keys': result.scalars().all(), 'keys': list(dict.fromkeys(result.scalars().all())),
}, },
) )
@@ -2477,13 +2581,15 @@ class RuntimeConnectionHandler(handler.Handler):
async def reconcile_plugin_installations( async def reconcile_plugin_installations(
self, self,
installations: tuple[PluginInstallationDesiredState, ...], installations: tuple[PluginInstallationDesiredState, ...],
*,
timeout: float = 300,
) -> dict[str, Any]: ) -> dict[str, Any]:
request = ReconcilePluginInstallationsRequest(installations=installations) request = ReconcilePluginInstallationsRequest(installations=installations)
with self.installation_scope(None): with self.installation_scope(None):
return await self.call_action( return await self.call_action(
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS, LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
request.model_dump(), request.model_dump(),
timeout=300, timeout=timeout,
) )
async def apply_plugin_installation( async def apply_plugin_installation(
@@ -24,7 +24,10 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
try: try:
import pyseekdb import pyseekdb
except ImportError: except ImportError:
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb') raise ImportError(
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
)
self._embedding_function = pyseekdb.get_default_embedding_function() self._embedding_function = pyseekdb.get_default_embedding_function()
+4 -1
View File
@@ -7,7 +7,10 @@ from collections.abc import Sequence
import regex import regex
MAX_PATTERN_COUNT = 64 # The bundled sensitive-word list already contains more than 64 entries. Keep
# the deterministic cap, but leave enough room for the built-in defaults and
# reasonable administrator customisation.
MAX_PATTERN_COUNT = 256
MAX_PATTERN_CHARS = 1024 MAX_PATTERN_CHARS = 1024
MAX_INPUT_CHARS = 1024 * 1024 MAX_INPUT_CHARS = 1024 * 1024
MAX_REPLACEMENT_CHARS = 64 MAX_REPLACEMENT_CHARS = 64
+4 -1
View File
@@ -42,7 +42,10 @@ class SeekDBVectorDatabase(VectorDatabase):
def __init__(self, ap: app.Application): def __init__(self, ap: app.Application):
if not SEEKDB_AVAILABLE: if not SEEKDB_AVAILABLE:
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb') raise ImportError(
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
)
self.ap = ap self.ap = ap
config = self.ap.instance_config.data['vdb']['seekdb'] config = self.ap.instance_config.data['vdb']['seekdb']
+5
View File
@@ -181,6 +181,11 @@ vdb:
host: localhost host: localhost
port: 6333 port: 6333
api_key: '' api_key: ''
# SeekDB is optional. Native/package installs need the `seekdb` extra:
# `uv sync --extra seekdb` (source) or
# `uvx --from 'langbot[seekdb]@latest' langbot` (PyPI).
# The official Docker image already includes it.
# Embedded-mode platform support depends on the native pylibseekdb wheels.
seekdb: seekdb:
mode: embedded # 'embedded' or 'server' mode: embedded # 'embedded' or 'server'
# Embedded mode options: # Embedded mode options:
-41
View File
@@ -171,18 +171,6 @@ def fake_bot_app():
'diagnostic_details': [{'step': 'evaluate_binding', 'binding_id': 'binding-1', 'matched': True}], 'diagnostic_details': [{'step': 'evaluate_binding', 'binding_id': 'binding-1', 'matched': True}],
} }
) )
app.bot_service.dispatch_test_event_route = AsyncMock(
return_value={
'dispatched': True,
'event_type': 'message.received',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
)
app.bot_service.send_message = AsyncMock() app.bot_service.send_message = AsyncMock()
# Platform manager # Platform manager
@@ -373,35 +361,6 @@ class TestBotEventRouteStatusEndpoint:
fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with(ANY, 'test-bot-uuid') fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with(ANY, 'test-bot-uuid')
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotEventRouteTestEndpoint:
"""Tests for bot event route synthetic dispatch endpoint."""
@pytest.mark.asyncio
async def test_dispatch_test_event_route_success(self, quart_test_client, fake_bot_app):
"""POST test route dispatches a synthetic event."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/event-routes/test',
headers={'Authorization': 'Bearer test_token'},
json={
'event_type': 'message.received',
'payload': {'message_text': 'hello'},
},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['dispatched'] is True
assert data['data']['event_type'] == 'message.received'
fake_bot_app.bot_service.dispatch_test_event_route.assert_awaited_with(
ANY,
bot_uuid='test-bot-uuid',
event_type='message.received',
payload={'message_text': 'hello'},
)
@pytest.mark.usefixtures('mock_circular_import_chain') @pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotSendMessageEndpoint: class TestBotSendMessageEndpoint:
"""Tests for bot send message endpoint.""" """Tests for bot send message endpoint."""
+23
View File
@@ -307,6 +307,7 @@ class TestUserInitEndpoint:
assert data['data'] == { assert data['data'] == {
'initialized': True, 'initialized': True,
'authenticated_invitation_acceptance_enabled': False, 'authenticated_invitation_acceptance_enabled': False,
'invitation_registration_enabled': True,
'password_login_enabled': True, 'password_login_enabled': True,
'space_login_enabled': False, 'space_login_enabled': False,
} }
@@ -330,6 +331,28 @@ class TestUserInitEndpoint:
assert data['data'] == { assert data['data'] == {
'initialized': True, 'initialized': True,
'authenticated_invitation_acceptance_enabled': True, 'authenticated_invitation_acceptance_enabled': True,
'invitation_registration_enabled': False,
'password_login_enabled': False,
'space_login_enabled': True,
}
@pytest.mark.asyncio
async def test_account_info_enables_local_invitation_registration_for_oauth_only_oss(
self, quart_test_client, fake_api_app
):
fake_api_app.user_service.is_initialized.return_value = True
fake_api_app.user_service.get_login_capabilities = AsyncMock(
return_value={'password_login_enabled': False, 'space_login_enabled': True}
)
response = await quart_test_client.get('/api/v1/user/account-info')
assert response.status_code == 200
data = await response.get_json()
assert data['data'] == {
'initialized': True,
'authenticated_invitation_acceptance_enabled': False,
'invitation_registration_enabled': True,
'password_login_enabled': False, 'password_login_enabled': False,
'space_login_enabled': True, 'space_login_enabled': True,
} }
@@ -312,6 +312,29 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
application.space_service.get_credits.assert_awaited_once_with('owner@example.com') application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
@pytest.mark.asyncio
async def test_oss_local_only_owner_requires_space_binding_for_langbot_models(space_oauth_api):
application, client = space_oauth_api
application.user_service.get_workspace_owner = AsyncMock(
return_value=SimpleNamespace(user='owner@example.com', space_account_uuid=None)
)
application.space_service.get_credits = AsyncMock()
response = await client.get(
'/api/v1/user/space-credits',
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['data'] == {
'credits': None,
'owner_space_bound': False,
'is_workspace_owner': True,
}
application.space_service.get_credits.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api): async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
application, client = space_oauth_api application, client = space_oauth_api
@@ -81,6 +81,7 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
sa.Column('key', sa.String(255), nullable=False), sa.Column('key', sa.String(255), nullable=False),
sa.Column('owner_type', sa.String(255), nullable=False), sa.Column('owner_type', sa.String(255), nullable=False),
sa.Column('owner', sa.String(255), nullable=False), sa.Column('owner', sa.String(255), nullable=False),
sa.Column('value', sa.LargeBinary, nullable=False),
) )
mcp_servers = _uuid_table( mcp_servers = _uuid_table(
metadata, metadata,
@@ -210,7 +211,13 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now)) await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner')) await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
await conn.execute( await conn.execute(
binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo') binary_storages.insert().values(
unique_key='plugin:demo:key',
key='key',
owner_type='plugin',
owner='demo',
value=b'legacy-plugin-value',
)
) )
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now)) await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai')) await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
@@ -108,7 +108,7 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_upgrade(sqlite_engine, 'head') await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head() assert await get_alembic_current(sqlite_engine) == _get_script_head()
assert _get_script_head() == '0022_merge_agent_reasoning_heads' assert _get_script_head() == '0023_drop_agent_enabled'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine): async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine):
@@ -120,7 +120,7 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_upgrade(sqlite_engine, 'head') await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head() assert await get_alembic_current(sqlite_engine) == _get_script_head()
assert _get_script_head() == '0022_merge_agent_reasoning_heads' assert _get_script_head() == '0023_drop_agent_enabled'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine): async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
@@ -131,7 +131,23 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config') await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config')
await run_alembic_upgrade(sqlite_engine, 'head') await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == '0022_merge_agent_reasoning_heads' assert await get_alembic_current(sqlite_engine) == '0023_drop_agent_enabled'
@pytest.mark.asyncio
async def test_upgrade_removes_agent_enabled_column(self, sqlite_engine):
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.exec_driver_sql('ALTER TABLE agents ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT 1')
await run_alembic_stamp(sqlite_engine, '0022_merge_agent_reasoning_heads')
await run_alembic_upgrade(sqlite_engine, 'head')
async with sqlite_engine.connect() as conn:
columns = await conn.run_sync(
lambda sync_conn: {column['name'] for column in sqlalchemy.inspect(sync_conn).get_columns('agents')}
)
assert 'enabled' not in columns
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upgrade_from_baseline_to_head(self, sqlite_engine): async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
@@ -76,6 +76,26 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
) )
assert legacy_kb['collection_id'] == 'collection-1' assert legacy_kb['collection_id'] == 'collection-1'
assert legacy_kb['legacy_vector_collection'] == 1 assert legacy_kb['legacy_vector_collection'] == 1
legacy_binary_storage = (
(
await conn.execute(
sa.text(
'SELECT workspace_uuid, unique_key, key, owner_type, owner, value '
"FROM binary_storages WHERE owner_type = 'plugin' AND owner = 'demo'"
)
)
)
.mappings()
.one()
)
assert legacy_binary_storage == {
'workspace_uuid': workspace_uuid,
'unique_key': 'plugin:demo:key',
'key': 'key',
'owner_type': 'plugin',
'owner': 'demo',
'value': b'legacy-plugin-value',
}
assert ( assert (
await conn.scalar( await conn.scalar(
sa.text( sa.text(
@@ -209,8 +229,8 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
await conn.execute( await conn.execute(
sa.text( sa.text(
'INSERT INTO binary_storages ' 'INSERT INTO binary_storages '
'(workspace_uuid, unique_key, key, owner_type, owner) ' '(workspace_uuid, unique_key, key, owner_type, owner, value) '
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')" "VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo', X'')"
), ),
{'workspace_uuid': second_workspace_uuid}, {'workspace_uuid': second_workspace_uuid},
) )
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import logging import logging
import os
import pathlib import pathlib
import sqlite3 import sqlite3
@@ -9,7 +10,7 @@ import pytest
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.persistence import alembic_runner from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup
from langbot.pkg.persistence.mgr import PersistenceManager from langbot.pkg.persistence.mgr import PersistenceManager
from .resource_migration_support import create_legacy_resource_schema from .resource_migration_support import create_legacy_resource_schema
@@ -105,3 +106,31 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head() assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
finally: finally:
await engine.dispose() await engine.dispose()
async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
database_path = tmp_path / 'legacy-bind-mount.db'
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
real_open = os.open
transient_failures = 0
def transient_open(path, flags, *args, **kwargs):
nonlocal transient_failures
candidate = pathlib.Path(path)
if candidate.suffix == '.sqlite3' and candidate.parent.name == 'migration-backups' and transient_failures == 0:
transient_failures += 1
raise FileNotFoundError(2, 'simulated delayed bind-mount visibility', str(candidate))
return real_open(path, flags, *args, **kwargs)
try:
await create_legacy_resource_schema(engine, instance_uuid='backup-bind-mount')
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
monkeypatch.setattr(sqlite_migration_backup.os, 'open', transient_open)
await _manager(engine)._run_alembic_migrations()
assert transient_failures == 1
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
assert len(_manifest_payloads(tmp_path / 'migration-backups')) == 2
finally:
await engine.dispose()
@@ -220,13 +220,17 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
) )
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(schema.create_all) await conn.run_sync(schema.create_all)
await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id})
await conn.execute( await conn.execute(
sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"), sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}
)
await conn.execute(
sa.text(
"INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"
),
{'uuid': old_workspace_uuid, 'instance': instance_id}, {'uuid': old_workspace_uuid, 'instance': instance_id},
) )
await conn.execute( await conn.execute(
sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"), sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'),
{'uuid': old_workspace_uuid}, {'uuid': old_workspace_uuid},
) )
await run_alembic_stamp(engine, '0016_support_admin_sessions') await run_alembic_stamp(engine, '0016_support_admin_sessions')
@@ -234,8 +238,8 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
await run_alembic_upgrade(engine, 'head') await run_alembic_upgrade(engine, 'head')
async with engine.connect() as conn: async with engine.connect() as conn:
assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid
assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid
await engine.dispose() await engine.dispose()
@@ -452,6 +456,45 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
await engine.dispose() await engine.dispose()
async def test_persistence_startup_preserves_legacy_workspace_membership_with_foreign_keys(
tmp_path,
monkeypatch,
):
database_path = tmp_path / 'startup-foreign-keys.db'
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
try:
await _create_legacy_schema(engine)
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
finally:
await engine.dispose()
monkeypatch.setattr(constants, 'instance_id', 'instance_migration_test')
application = type('Application', (), {})()
application.logger = logging.getLogger('workspace-startup-foreign-keys-test')
application.instance_config = type(
'InstanceConfig',
(),
{'data': {'database': {'use': 'sqlite', 'sqlite': {'path': str(database_path)}}}},
)()
manager = PersistenceManager(application)
await manager.initialize()
try:
async with manager.get_db_engine().connect() as conn:
workspace = (
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
)
membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
foreign_keys = await conn.scalar(sa.text('PRAGMA foreign_keys'))
assert workspace['created_by_account_uuid'] == membership['account_uuid']
assert membership['role'] == 'owner'
assert membership['status'] == 'active'
assert foreign_keys == 1
finally:
await manager.shutdown()
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path): async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}') engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
try: try:
@@ -466,7 +509,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
assert instance_uuid assert instance_uuid
await conn.execute( await conn.execute(
sa.text( sa.text(
"INSERT INTO workspace_metadata (workspace_uuid, key, value) " 'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
"VALUES (:workspace_uuid, 'migration_probe', 'present')" "VALUES (:workspace_uuid, 'migration_probe', 'present')"
), ),
{'workspace_uuid': old_uuid}, {'workspace_uuid': old_uuid},
@@ -474,7 +517,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
await conn.execute( await conn.execute(
sa.text( sa.text(
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) " "INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value" 'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
), ),
{'workspace_uuid': old_uuid}, {'workspace_uuid': old_uuid},
) )
@@ -483,12 +526,16 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
expected_uuid = workspace_uuid_from_instance_id(instance_uuid) expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
async with engine.connect() as conn: async with engine.connect() as conn:
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
assert await conn.scalar( assert (
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'") await conn.scalar(
) == expected_uuid sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
assert await conn.scalar( )
sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'") == expected_uuid
) == expected_uuid )
assert (
await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
== expected_uuid
)
finally: finally:
await engine.dispose() await engine.dispose()
+1 -27
View File
@@ -56,18 +56,6 @@ def build_ap() -> SimpleNamespace:
ap.bot_service = SimpleNamespace( ap.bot_service = SimpleNamespace(
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}]), get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}]),
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}), list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
dispatch_test_event_route=AsyncMock(
return_value={
'dispatched': True,
'event_type': 'message.received',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
),
) )
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}])) ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}]))
ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[])) ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[]))
@@ -126,7 +114,7 @@ async def main() -> int:
tools = await session.list_tools() tools = await session.list_tools()
names = [t.name for t in tools.tools] names = [t.name for t in tools.tools]
print(f'PASS: listed {len(names)} tools') print(f'PASS: listed {len(names)} tools')
for required in ('list_bots', 'get_system_info', 'list_skills', 'test_bot_event_route'): for required in ('list_bots', 'get_system_info', 'list_skills'):
if required not in names: if required not in names:
failures.append(f'missing tool {required}') failures.append(f'missing tool {required}')
@@ -144,20 +132,6 @@ async def main() -> int:
else: else:
print('PASS: get_system_info returned version') print('PASS: get_system_info returned version')
res3 = await session.call_tool(
'test_bot_event_route',
{
'bot_uuid': 'bot-1',
'event_type': 'message.received',
'payload': {'message_text': 'hello'},
},
)
text3 = res3.content[0].text if res3.content else ''
if '"dispatched": true' not in text3:
failures.append(f'test_bot_event_route wrong: {text3!r}')
else:
print('PASS: test_bot_event_route returned dispatch result')
shutdown.set() shutdown.set()
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await asyncio.wait_for(server_task, timeout=5) await asyncio.wait_for(server_task, timeout=5)
@@ -74,7 +74,6 @@ class TestContextValidation:
runner_id='plugin:test/plugin/runner', runner_id='plugin:test/plugin/runner',
runner_config={'timeout': 300}, runner_config={'timeout': 300},
agent_id='pipeline_1', agent_id='pipeline_1',
enabled=True,
) )
def _make_resources(self) -> BuilderResources: def _make_resources(self) -> BuilderResources:
@@ -1,4 +1,5 @@
"""Tests for EventLog, Transcript, and history/event APIs.""" """Tests for EventLog, Transcript, and history/event APIs."""
from __future__ import annotations from __future__ import annotations
import datetime import datetime
@@ -24,45 +25,46 @@ from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryCo
def make_event_envelope( def make_event_envelope(
event_id: str = "evt_1", event_id: str = 'evt_1',
event_type: str = "message.received", event_type: str = 'message.received',
conversation_id: str | None = "conv_1", conversation_id: str | None = 'conv_1',
actor_id: str | None = "user_1", actor_id: str | None = 'user_1',
input_text: str = "Hello", input_text: str = 'Hello',
) -> AgentEventEnvelope: ) -> AgentEventEnvelope:
"""Create a test event envelope.""" """Create a test event envelope."""
return AgentEventEnvelope( return AgentEventEnvelope(
event_id=event_id, event_id=event_id,
event_type=event_type, event_type=event_type,
event_time=1700000000, event_time=1700000000,
source="platform", source='platform',
bot_id="bot_1", bot_id='bot_1',
workspace_id=None, workspace_id=None,
conversation_id=conversation_id, conversation_id=conversation_id,
thread_id=None, thread_id=None,
actor=ActorContext( actor=ActorContext(
actor_type="user", actor_type='user',
actor_id=actor_id, actor_id=actor_id,
actor_name="Test User", actor_name='Test User',
) if actor_id else None, )
if actor_id
else None,
subject=None, subject=None,
input=AgentInput(text=input_text), input=AgentInput(text=input_text),
delivery=DeliveryContext(surface="test"), delivery=DeliveryContext(surface='test'),
) )
def make_binding(runner_id: str = "plugin:test/plugin/runner") -> AgentBinding: def make_binding(runner_id: str = 'plugin:test/plugin/runner') -> AgentBinding:
"""Create a test binding.""" """Create a test binding."""
return AgentBinding( return AgentBinding(
binding_id="binding_1", binding_id='binding_1',
scope=BindingScope(scope_type="agent", scope_id="pipeline_1"), scope=BindingScope(scope_type='agent', scope_id='pipeline_1'),
event_types=["message.received"], event_types=['message.received'],
runner_id=runner_id, runner_id=runner_id,
runner_config={}, runner_config={},
resource_policy=ResourcePolicy(), resource_policy=ResourcePolicy(),
state_policy=StatePolicy(), state_policy=StatePolicy(),
delivery_policy=DeliveryPolicy(), delivery_policy=DeliveryPolicy(),
enabled=True,
) )
@@ -84,19 +86,19 @@ class TestEventLogStore:
mock_factory.return_value.__aenter__.return_value = mock_session mock_factory.return_value.__aenter__.return_value = mock_session
event_id = await store.append_event( event_id = await store.append_event(
event_id="evt_1", event_id='evt_1',
event_type="message.received", event_type='message.received',
source="platform", source='platform',
bot_id="bot_1", bot_id='bot_1',
conversation_id="conv_1", conversation_id='conv_1',
actor_type="user", actor_type='user',
actor_id="user_1", actor_id='user_1',
input_summary="Hello world", input_summary='Hello world',
run_id="run_1", run_id='run_1',
runner_id="plugin:test/plugin/runner", runner_id='plugin:test/plugin/runner',
) )
assert event_id == "evt_1" assert event_id == 'evt_1'
stored_event = mock_session.add.call_args.args[0] stored_event = mock_session.add.call_args.args[0]
assert stored_event.metadata_json is None assert stored_event.metadata_json is None
@@ -115,20 +117,20 @@ class TestEventLogStore:
mock_factory.return_value.__aenter__.return_value = mock_session mock_factory.return_value.__aenter__.return_value = mock_session
event_id = await store.append_event( event_id = await store.append_event(
event_id="evt_steering", event_id='evt_steering',
event_type="message.received", event_type='message.received',
source="platform", source='platform',
run_id="run_1", run_id='run_1',
runner_id="plugin:test/plugin/runner", runner_id='plugin:test/plugin/runner',
metadata={ metadata={
"steering": { 'steering': {
"status": "queued", 'status': 'queued',
"claimed_by_run_id": "run_1", 'claimed_by_run_id': 'run_1',
} }
}, },
) )
assert event_id == "evt_steering" assert event_id == 'evt_steering'
stored_event = mock_session.add.call_args.args[0] stored_event = mock_session.add.call_args.args[0]
assert '"status": "queued"' in stored_event.metadata_json assert '"status": "queued"' in stored_event.metadata_json
assert '"claimed_by_run_id": "run_1"' in stored_event.metadata_json assert '"claimed_by_run_id": "run_1"' in stored_event.metadata_json
@@ -147,15 +149,15 @@ class TestEventLogStore:
with patch.object(store, '_session_factory') as mock_factory: with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session mock_factory.return_value.__aenter__.return_value = mock_session
long_text = "x" * 2000 long_text = 'x' * 2000
event_id = await store.append_event( event_id = await store.append_event(
event_id="evt_2", event_id='evt_2',
event_type="message.received", event_type='message.received',
source="platform", source='platform',
input_summary=long_text, input_summary=long_text,
) )
assert event_id == "evt_2" assert event_id == 'evt_2'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_page_events_with_conversation_filter(self, mock_db_engine): async def test_page_events_with_conversation_filter(self, mock_db_engine):
@@ -174,7 +176,7 @@ class TestEventLogStore:
mock_factory.return_value.__aenter__.return_value = mock_session mock_factory.return_value.__aenter__.return_value = mock_session
items, next_seq, has_more = await store.page_events( items, next_seq, has_more = await store.page_events(
conversation_id="conv_1", conversation_id='conv_1',
limit=10, limit=10,
) )
@@ -202,10 +204,10 @@ class TestTranscriptStore:
transcript_id = await store.append_transcript( transcript_id = await store.append_transcript(
transcript_id=None, # Auto-generate transcript_id=None, # Auto-generate
event_id="evt_1", event_id='evt_1',
conversation_id="conv_1", conversation_id='conv_1',
role="user", role='user',
content="Hello", content='Hello',
) )
assert transcript_id is not None assert transcript_id is not None
@@ -227,13 +229,11 @@ class TestTranscriptStore:
transcript_id = await store.append_transcript( transcript_id = await store.append_transcript(
transcript_id=None, # Auto-generate transcript_id=None, # Auto-generate
event_id="evt_2", event_id='evt_2',
conversation_id="conv_1", conversation_id='conv_1',
role="assistant", role='assistant',
content="Here's an image", content="Here's an image",
attachment_refs=[ attachment_refs=[{'id': 'att_1', 'type': 'image', 'url': 'http://example.com/img.png'}],
{"id": "att_1", "type": "image", "url": "http://example.com/img.png"}
],
) )
assert transcript_id is not None assert transcript_id is not None
@@ -255,9 +255,9 @@ class TestTranscriptStore:
mock_factory.return_value.__aenter__.return_value = mock_session mock_factory.return_value.__aenter__.return_value = mock_session
items, next_seq, prev_seq, has_more = await store.page_transcript( items, next_seq, prev_seq, has_more = await store.page_transcript(
conversation_id="conv_1", conversation_id='conv_1',
limit=10, limit=10,
direction="backward", direction='backward',
) )
assert isinstance(items, list) assert isinstance(items, list)
@@ -280,7 +280,7 @@ class TestTranscriptStore:
# Request more than the hard limit # Request more than the hard limit
items, next_seq, prev_seq, has_more = await store.page_transcript( items, next_seq, prev_seq, has_more = await store.page_transcript(
conversation_id="conv_1", conversation_id='conv_1',
limit=200, # Request 200, but hard limit is 100 limit=200, # Request 200, but hard limit is 100
) )
@@ -304,8 +304,8 @@ class TestTranscriptStore:
mock_factory.return_value.__aenter__.return_value = mock_session mock_factory.return_value.__aenter__.return_value = mock_session
items = await store.search_transcript( items = await store.search_transcript(
conversation_id="conv_1", conversation_id='conv_1',
query_text="database", query_text='database',
top_k=10, top_k=10,
) )
@@ -323,11 +323,11 @@ class TestHistoryPageAuthorization:
# Mock call_action to simulate the handler # Mock call_action to simulate the handler
result = await mock_handler.call_action( result = await mock_handler.call_action(
PluginToRuntimeAction.HISTORY_PAGE, PluginToRuntimeAction.HISTORY_PAGE,
{"run_id": None}, {'run_id': None},
) )
# Should return error # Should return error
assert result.get("ok") is False or "error" in str(result).lower() assert result.get('ok') is False or 'error' in str(result).lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_history_page_validates_conversation_scope(self, mock_db_engine): async def test_history_page_validates_conversation_scope(self, mock_db_engine):
@@ -337,20 +337,20 @@ class TestHistoryPageAuthorization:
session_registry = get_session_registry() session_registry = get_session_registry()
await session_registry.register( await session_registry.register(
run_id="run_1", run_id='run_1',
runner_id="plugin:test/plugin/runner", runner_id='plugin:test/plugin/runner',
query_id=None, query_id=None,
plugin_identity="test/plugin", plugin_identity='test/plugin',
resources={"models": [], "tools": [], "knowledge_bases": [], "storage": {"plugin_storage": True}}, resources={'models': [], 'tools': [], 'knowledge_bases': [], 'storage': {'plugin_storage': True}},
conversation_id="conv_1", conversation_id='conv_1',
) )
session = await session_registry.get("run_1") session = await session_registry.get('run_1')
assert session is not None assert session is not None
assert session["authorization"]["conversation_id"] == "conv_1" assert session['authorization']['conversation_id'] == 'conv_1'
# Cleanup # Cleanup
await session_registry.unregister("run_1") await session_registry.unregister('run_1')
class TestEventGetAuthorization: class TestEventGetAuthorization:
@@ -363,11 +363,11 @@ class TestEventGetAuthorization:
result = await mock_handler.call_action( result = await mock_handler.call_action(
PluginToRuntimeAction.EVENT_GET, PluginToRuntimeAction.EVENT_GET,
{"run_id": None, "event_id": "evt_1"}, {'run_id': None, 'event_id': 'evt_1'},
) )
# Should return error # Should return error
assert result.get("ok") is False or "error" in str(result).lower() assert result.get('ok') is False or 'error' in str(result).lower()
class TestContextAccessPopulation: class TestContextAccessPopulation:
@@ -389,7 +389,7 @@ class TestContextAccessPopulation:
with patch.object(store, '_session_factory') as mock_factory: with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session mock_factory.return_value.__aenter__.return_value = mock_session
cursor = await store.get_latest_cursor("conv_1") cursor = await store.get_latest_cursor('conv_1')
# Should return None or a cursor string # Should return None or a cursor string
assert cursor is None or isinstance(cursor, str) assert cursor is None or isinstance(cursor, str)
@@ -409,7 +409,7 @@ class TestContextAccessPopulation:
with patch.object(store, '_session_factory') as mock_factory: with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session mock_factory.return_value.__aenter__.return_value = mock_session
has_history = await store.has_history_before("conv_1", 10) has_history = await store.has_history_before('conv_1', 10)
assert isinstance(has_history, bool) assert isinstance(has_history, bool)
@@ -422,7 +422,7 @@ class TestEventLogStoreRealSQLite:
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base from langbot.pkg.entity.persistence.base import Base
engine = create_async_engine("sqlite+aiosqlite:///:memory:") engine = create_async_engine('sqlite+aiosqlite:///:memory:')
# Create tables # Create tables
async with engine.begin() as conn: async with engine.begin() as conn:
@@ -439,30 +439,30 @@ class TestEventLogStoreRealSQLite:
# Append event # Append event
event_id = await store.append_event( event_id = await store.append_event(
event_id="evt_real_001", event_id='evt_real_001',
event_type="message.received", event_type='message.received',
source="platform", source='platform',
bot_id="bot_001", bot_id='bot_001',
conversation_id="conv_001", conversation_id='conv_001',
actor_type="user", actor_type='user',
actor_id="user_001", actor_id='user_001',
actor_name="Test User", actor_name='Test User',
input_summary="Hello world", input_summary='Hello world',
run_id="run_001", run_id='run_001',
runner_id="plugin:test/plugin/runner", runner_id='plugin:test/plugin/runner',
) )
assert event_id == "evt_real_001" assert event_id == 'evt_real_001'
# Get event # Get event
event = await store.get_event(event_id) event = await store.get_event(event_id)
assert event is not None assert event is not None
assert event["event_id"] == "evt_real_001" assert event['event_id'] == 'evt_real_001'
assert event["event_type"] == "message.received" assert event['event_type'] == 'message.received'
assert event["source"] == "platform" assert event['source'] == 'platform'
assert event["conversation_id"] == "conv_001" assert event['conversation_id'] == 'conv_001'
assert event["actor_type"] == "user" assert event['actor_type'] == 'user'
assert event["actor_id"] == "user_001" assert event['actor_id'] == 'user_001'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_page_events(self, db_engine): async def test_page_events(self, db_engine):
@@ -472,16 +472,16 @@ class TestEventLogStoreRealSQLite:
# Append multiple events # Append multiple events
for i in range(5): for i in range(5):
await store.append_event( await store.append_event(
event_id=f"evt_real_{i:03d}", event_id=f'evt_real_{i:03d}',
event_type="message.received", event_type='message.received',
source="platform", source='platform',
conversation_id="conv_001", conversation_id='conv_001',
input_summary=f"Message {i}", input_summary=f'Message {i}',
) )
# Page events # Page events
items, next_seq, has_more = await store.page_events( items, next_seq, has_more = await store.page_events(
conversation_id="conv_001", conversation_id='conv_001',
limit=3, limit=3,
) )
@@ -496,14 +496,14 @@ class TestEventLogStoreRealSQLite:
# Append events # Append events
for i in range(3): for i in range(3):
await store.append_event( await store.append_event(
event_id=f"evt_cursor_{i:03d}", event_id=f'evt_cursor_{i:03d}',
event_type="message.received", event_type='message.received',
source="platform", source='platform',
conversation_id="conv_cursor", conversation_id='conv_cursor',
) )
# Get latest cursor # Get latest cursor
cursor = await store.get_latest_cursor("conv_cursor") cursor = await store.get_latest_cursor('conv_cursor')
assert cursor is not None assert cursor is not None
assert int(cursor) > 0 assert int(cursor) > 0
@@ -516,26 +516,26 @@ class TestEventLogStoreRealSQLite:
store = EventLogStore(db_engine) store = EventLogStore(db_engine)
cutoff = datetime.datetime.utcnow() cutoff = datetime.datetime.utcnow()
await store.append_event( await store.append_event(
event_id="evt_cleanup_old", event_id='evt_cleanup_old',
event_type="message.received", event_type='message.received',
source="platform", source='platform',
conversation_id="conv_cleanup", conversation_id='conv_cleanup',
) )
await store.append_event( await store.append_event(
event_id="evt_cleanup_new", event_id='evt_cleanup_new',
event_type="message.received", event_type='message.received',
source="platform", source='platform',
conversation_id="conv_cleanup", conversation_id='conv_cleanup',
) )
async with store._session_factory() as session: async with store._session_factory() as session:
await session.execute( await session.execute(
sqlalchemy.update(EventLog) sqlalchemy.update(EventLog)
.where(EventLog.event_id == "evt_cleanup_old") .where(EventLog.event_id == 'evt_cleanup_old')
.values(created_at=cutoff - datetime.timedelta(days=2)) .values(created_at=cutoff - datetime.timedelta(days=2))
) )
await session.execute( await session.execute(
sqlalchemy.update(EventLog) sqlalchemy.update(EventLog)
.where(EventLog.event_id == "evt_cleanup_new") .where(EventLog.event_id == 'evt_cleanup_new')
.values(created_at=cutoff + datetime.timedelta(days=2)) .values(created_at=cutoff + datetime.timedelta(days=2))
) )
await session.commit() await session.commit()
@@ -543,8 +543,8 @@ class TestEventLogStoreRealSQLite:
removed = await store.cleanup_events_older_than(cutoff) removed = await store.cleanup_events_older_than(cutoff)
assert removed == 1 assert removed == 1
assert await store.get_event("evt_cleanup_old") is None assert await store.get_event('evt_cleanup_old') is None
assert await store.get_event("evt_cleanup_new") is not None assert await store.get_event('evt_cleanup_new') is not None
class TestTranscriptStoreRealSQLite: class TestTranscriptStoreRealSQLite:
@@ -556,7 +556,7 @@ class TestTranscriptStoreRealSQLite:
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base from langbot.pkg.entity.persistence.base import Base
engine = create_async_engine("sqlite+aiosqlite:///:memory:") engine = create_async_engine('sqlite+aiosqlite:///:memory:')
# Create tables # Create tables
async with engine.begin() as conn: async with engine.begin() as conn:
@@ -574,21 +574,21 @@ class TestTranscriptStoreRealSQLite:
# Append transcript items # Append transcript items
for i in range(3): for i in range(3):
await store.append_transcript( await store.append_transcript(
transcript_id=f"trans_real_{i:03d}", transcript_id=f'trans_real_{i:03d}',
event_id=f"evt_{i:03d}", event_id=f'evt_{i:03d}',
conversation_id="conv_001", conversation_id='conv_001',
role="user" if i % 2 == 0 else "assistant", role='user' if i % 2 == 0 else 'assistant',
content=f"Message {i}", content=f'Message {i}',
) )
# Page transcript # Page transcript
items, next_seq, prev_seq, has_more = await store.page_transcript( items, next_seq, prev_seq, has_more = await store.page_transcript(
conversation_id="conv_001", conversation_id='conv_001',
limit=10, limit=10,
) )
assert len(items) == 3 assert len(items) == 3
assert items[0]["conversation_id"] == "conv_001" assert items[0]['conversation_id'] == 'conv_001'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_legacy_provider_messages_projects_transcript_history(self, db_engine): async def test_get_legacy_provider_messages_projects_transcript_history(self, db_engine):
@@ -596,37 +596,37 @@ class TestTranscriptStoreRealSQLite:
store = TranscriptStore(db_engine) store = TranscriptStore(db_engine)
await store.append_transcript( await store.append_transcript(
transcript_id="trans_view_001", transcript_id='trans_view_001',
event_id="evt_view_001", event_id='evt_view_001',
conversation_id="conv_view", conversation_id='conv_view',
role="user", role='user',
content="User text", content='User text',
content_json={ content_json={
"role": "user", 'role': 'user',
"content": [{"type": "text", "text": "User structured text"}], 'content': [{'type': 'text', 'text': 'User structured text'}],
}, },
) )
await store.append_transcript( await store.append_transcript(
transcript_id="trans_view_002", transcript_id='trans_view_002',
event_id="evt_view_002", event_id='evt_view_002',
conversation_id="conv_view", conversation_id='conv_view',
role="tool", role='tool',
item_type="tool_result", item_type='tool_result',
content="ignored tool result", content='ignored tool result',
) )
await store.append_transcript( await store.append_transcript(
transcript_id="trans_view_003", transcript_id='trans_view_003',
event_id="evt_view_003", event_id='evt_view_003',
conversation_id="conv_view", conversation_id='conv_view',
role="assistant", role='assistant',
content="Assistant text", content='Assistant text',
) )
messages = await store.get_legacy_provider_messages("conv_view") messages = await store.get_legacy_provider_messages('conv_view')
assert [message.role for message in messages] == ["user", "assistant"] assert [message.role for message in messages] == ['user', 'assistant']
assert messages[0].content[0].text == "User structured text" assert messages[0].content[0].text == 'User structured text'
assert messages[1].content == "Assistant text" assert messages[1].content == 'Assistant text'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_legacy_provider_messages_filters_scope(self, db_engine): async def test_get_legacy_provider_messages_filters_scope(self, db_engine):
@@ -634,45 +634,45 @@ class TestTranscriptStoreRealSQLite:
store = TranscriptStore(db_engine) store = TranscriptStore(db_engine)
await store.append_transcript( await store.append_transcript(
transcript_id="trans_scope_001", transcript_id='trans_scope_001',
event_id="evt_scope_001", event_id='evt_scope_001',
conversation_id="conv_scope", conversation_id='conv_scope',
bot_id="bot_001", bot_id='bot_001',
workspace_id="workspace_001", workspace_id='workspace_001',
thread_id="thread_001", thread_id='thread_001',
role="user", role='user',
content="Current scope text", content='Current scope text',
) )
await store.append_transcript( await store.append_transcript(
transcript_id="trans_scope_002", transcript_id='trans_scope_002',
event_id="evt_scope_002", event_id='evt_scope_002',
conversation_id="conv_scope", conversation_id='conv_scope',
bot_id="bot_002", bot_id='bot_002',
workspace_id="workspace_001", workspace_id='workspace_001',
thread_id="thread_001", thread_id='thread_001',
role="assistant", role='assistant',
content="Other bot text", content='Other bot text',
) )
await store.append_transcript( await store.append_transcript(
transcript_id="trans_scope_003", transcript_id='trans_scope_003',
event_id="evt_scope_003", event_id='evt_scope_003',
conversation_id="conv_scope", conversation_id='conv_scope',
bot_id="bot_001", bot_id='bot_001',
workspace_id="workspace_001", workspace_id='workspace_001',
thread_id="thread_002", thread_id='thread_002',
role="assistant", role='assistant',
content="Other thread text", content='Other thread text',
) )
messages = await store.get_legacy_provider_messages( messages = await store.get_legacy_provider_messages(
"conv_scope", 'conv_scope',
bot_id="bot_001", bot_id='bot_001',
workspace_id="workspace_001", workspace_id='workspace_001',
thread_id="thread_001", thread_id='thread_001',
strict_thread=True, strict_thread=True,
) )
assert [message.content for message in messages] == ["Current scope text"] assert [message.content for message in messages] == ['Current scope text']
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_transcript_real_db(self, db_engine): async def test_search_transcript_real_db(self, db_engine):
@@ -681,24 +681,24 @@ class TestTranscriptStoreRealSQLite:
# Append transcript items # Append transcript items
await store.append_transcript( await store.append_transcript(
transcript_id="trans_search_001", transcript_id='trans_search_001',
event_id="evt_search_001", event_id='evt_search_001',
conversation_id="conv_search", conversation_id='conv_search',
role="user", role='user',
content="I want to learn about databases", content='I want to learn about databases',
) )
await store.append_transcript( await store.append_transcript(
transcript_id="trans_search_002", transcript_id='trans_search_002',
event_id="evt_search_002", event_id='evt_search_002',
conversation_id="conv_search", conversation_id='conv_search',
role="assistant", role='assistant',
content="Here is information about databases", content='Here is information about databases',
) )
# Search for "database" # Search for "database"
items = await store.search_transcript( items = await store.search_transcript(
conversation_id="conv_search", conversation_id='conv_search',
query_text="database", query_text='database',
) )
# Should find at least one match # Should find at least one match
@@ -712,15 +712,15 @@ class TestTranscriptStoreRealSQLite:
# Append transcript items # Append transcript items
for i in range(3): for i in range(3):
await store.append_transcript( await store.append_transcript(
transcript_id=f"trans_cursor_{i:03d}", transcript_id=f'trans_cursor_{i:03d}',
event_id=f"evt_cursor_{i:03d}", event_id=f'evt_cursor_{i:03d}',
conversation_id="conv_cursor", conversation_id='conv_cursor',
role="user", role='user',
content=f"Message {i}", content=f'Message {i}',
) )
# Get latest cursor # Get latest cursor
cursor = await store.get_latest_cursor("conv_cursor") cursor = await store.get_latest_cursor('conv_cursor')
assert cursor is not None assert cursor is not None
assert int(cursor) > 0 assert int(cursor) > 0
@@ -733,37 +733,37 @@ class TestTranscriptStoreRealSQLite:
store = TranscriptStore(db_engine) store = TranscriptStore(db_engine)
cutoff = datetime.datetime.utcnow() cutoff = datetime.datetime.utcnow()
await store.append_transcript( await store.append_transcript(
transcript_id="trans_cleanup_old", transcript_id='trans_cleanup_old',
event_id="evt_cleanup_old", event_id='evt_cleanup_old',
conversation_id="conv_cleanup", conversation_id='conv_cleanup',
role="user", role='user',
content="old", content='old',
) )
await store.append_transcript( await store.append_transcript(
transcript_id="trans_cleanup_new", transcript_id='trans_cleanup_new',
event_id="evt_cleanup_new", event_id='evt_cleanup_new',
conversation_id="conv_cleanup", conversation_id='conv_cleanup',
role="assistant", role='assistant',
content="new", content='new',
) )
async with store._session_factory() as session: async with store._session_factory() as session:
await session.execute( await session.execute(
sqlalchemy.update(Transcript) sqlalchemy.update(Transcript)
.where(Transcript.transcript_id == "trans_cleanup_old") .where(Transcript.transcript_id == 'trans_cleanup_old')
.values(created_at=cutoff - datetime.timedelta(days=2)) .values(created_at=cutoff - datetime.timedelta(days=2))
) )
await session.execute( await session.execute(
sqlalchemy.update(Transcript) sqlalchemy.update(Transcript)
.where(Transcript.transcript_id == "trans_cleanup_new") .where(Transcript.transcript_id == 'trans_cleanup_new')
.values(created_at=cutoff + datetime.timedelta(days=2)) .values(created_at=cutoff + datetime.timedelta(days=2))
) )
await session.commit() await session.commit()
removed = await store.cleanup_transcripts_older_than(cutoff) removed = await store.cleanup_transcripts_older_than(cutoff)
items, _, _, _ = await store.page_transcript("conv_cleanup", limit=10) items, _, _, _ = await store.page_transcript('conv_cleanup', limit=10)
assert removed == 1 assert removed == 1
assert [item["content"] for item in items] == ["new"] assert [item['content'] for item in items] == ['new']
# Fixtures # Fixtures
@@ -788,8 +788,8 @@ def mock_handler():
async def call_action(self, action, data, timeout=30): async def call_action(self, action, data, timeout=30):
# Simulate error response for missing run_id # Simulate error response for missing run_id
if not data.get("run_id"): if not data.get('run_id'):
return {"ok": False, "message": "run_id is required"} return {'ok': False, 'message': 'run_id is required'}
return {"ok": True, "data": {}} return {'ok': True, 'data': {}}
return MockHandler() return MockHandler()
@@ -901,7 +901,7 @@ async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
[message async for message in orchestrator.run_from_query(query)] [message async for message in orchestrator.run_from_query(query)]
assert exc_info.value.retryable is True assert exc_info.value.retryable is True
assert 'runner.timeout' in str(exc_info.value) assert exc_info.value.error_code == 'runner.timeout'
assert await get_session_registry().list_active_runs() == [] assert await get_session_registry().list_active_runs() == []
@@ -1012,6 +1012,7 @@ class TestQueryEntrySessionQueryId:
] ]
) )
ap = FakeApplication(plugin_connector, db_engine) ap = FakeApplication(plugin_connector, db_engine)
async def build_resource_context(execution_query): async def build_resource_context(execution_query):
from langbot.pkg.provider.tools.loaders.mcp import ( from langbot.pkg.provider.tools.loaders.mcp import (
_execution_context_from_query, _execution_context_from_query,
@@ -1025,9 +1026,7 @@ class TestQueryEntrySessionQueryId:
return 'Pinned documentation' return 'Pinned documentation'
mcp_loader = types.SimpleNamespace( mcp_loader = types.SimpleNamespace(
build_resource_context_for_query=AsyncMock( build_resource_context_for_query=AsyncMock(side_effect=build_resource_context)
side_effect=build_resource_context
)
) )
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader) ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
@@ -1070,7 +1069,6 @@ class TestQueryEntrySessionQueryId:
resource_policy=ResourcePolicy(), resource_policy=ResourcePolicy(),
state_policy=StatePolicy(enable_state=False, state_scopes=[]), state_policy=StatePolicy(enable_state=False, state_scopes=[]),
delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True), delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True),
enabled=True,
) )
messages = [ messages = [
@@ -1105,14 +1103,8 @@ class TestQueryEntrySessionQueryId:
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text'] assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text']
assert event.input.text == 'hello' assert event.input.text == 'hello'
assert event.input.contents[0].text == 'hello' assert event.input.contents[0].text == 'hello'
assert ( assert plugin_connector.contexts[0]['conversation']['workspace_id'] == TEST_CONTEXT.workspace_uuid
plugin_connector.contexts[0]['conversation']['workspace_id'] assert plugin_connector.contexts[0]['runtime']['metadata']['workspace_id'] == TEST_CONTEXT.workspace_uuid
== TEST_CONTEXT.workspace_uuid
)
assert (
plugin_connector.contexts[0]['runtime']['metadata']['workspace_id']
== TEST_CONTEXT.workspace_uuid
)
assert 'Pinned documentation' not in str(execution_query.user_message.content) assert 'Pinned documentation' not in str(execution_query.user_message.content)
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query) mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
@@ -1,4 +1,5 @@
"""Tests for agent runner result normalizer.""" """Tests for agent runner result normalizer."""
from __future__ import annotations from __future__ import annotations
import pytest import pytest
@@ -12,6 +13,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
class FakeApplication: class FakeApplication:
"""Fake Application for testing.""" """Fake Application for testing."""
def __init__(self): def __init__(self):
class FakeLogger: class FakeLogger:
def __init__(self): def __init__(self):
@@ -19,10 +21,13 @@ class FakeApplication:
def info(self, msg): def info(self, msg):
pass pass
def debug(self, msg): def debug(self, msg):
pass pass
def warning(self, msg): def warning(self, msg):
self.warnings.append(msg) self.warnings.append(msg)
def error(self, msg): def error(self, msg):
pass pass
@@ -192,6 +197,7 @@ class TestNormalizeRunFailed:
assert exc_info.value.runner_id == 'plugin:langbot-team/LocalAgent/default' assert exc_info.value.runner_id == 'plugin:langbot-team/LocalAgent/default'
assert exc_info.value.retryable is True assert exc_info.value.retryable is True
assert exc_info.value.error_code == 'upstream.timeout'
assert 'timeout' in str(exc_info.value) assert 'timeout' in str(exc_info.value)
@@ -290,6 +296,7 @@ class TestNormalizeNonMessageResults:
assert result is None assert result is None
assert app.logger.warnings assert app.logger.warnings
class TestNormalizeInvalidResults: class TestNormalizeInvalidResults:
"""Tests for handling invalid results.""" """Tests for handling invalid results."""
@@ -46,7 +46,6 @@ def _agent_row(
'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0}, 'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0},
'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}}, 'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}},
}, },
enabled=True,
supported_event_patterns=supported_event_patterns or ['*'], supported_event_patterns=supported_event_patterns or ['*'],
created_at=dt.datetime(2026, 1, 1, 9, 0, 0), created_at=dt.datetime(2026, 1, 1, 9, 0, 0),
updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0), updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0),
@@ -63,7 +62,6 @@ def _serialize_agent(model_cls, entity, masked_columns=None):
'kind': entity.kind, 'kind': entity.kind,
'component_ref': entity.component_ref, 'component_ref': entity.component_ref,
'config': entity.config, 'config': entity.config,
'enabled': entity.enabled,
'supported_event_patterns': entity.supported_event_patterns, 'supported_event_patterns': entity.supported_event_patterns,
'created_at': entity.created_at, 'created_at': entity.created_at,
'updated_at': entity.updated_at, 'updated_at': entity.updated_at,
@@ -145,7 +143,6 @@ class TestAgentServiceDebug:
return_value={ return_value={
'uuid': 'agent-1', 'uuid': 'agent-1',
'kind': AGENT_KIND_AGENT, 'kind': AGENT_KIND_AGENT,
'enabled': True,
'supported_event_patterns': ['*'], 'supported_event_patterns': ['*'],
'config': _agent_row().config, 'config': _agent_row().config,
} }
@@ -288,7 +285,7 @@ class TestAgentServiceListAndLookup:
result = await AgentService(app).get_agent(WORKSPACE_UUID, 'pipeline-1') result = await AgentService(app).get_agent(WORKSPACE_UUID, 'pipeline-1')
assert result['kind'] == AGENT_KIND_PIPELINE assert result['kind'] == AGENT_KIND_PIPELINE
assert result['enabled'] is True assert 'enabled' not in result
assert result['config'] == {'ai': {'runner': {'id': 'pipeline-runner'}}} assert result['config'] == {'ai': {'runner': {'id': 'pipeline-runner'}}}
assert result['capability']['message_only'] is True assert result['capability']['message_only'] is True
@@ -329,7 +326,7 @@ class TestAgentServiceCreateUpdateDelete:
'runner': {'id': runner.id, 'expire-time': 0}, 'runner': {'id': runner.id, 'expire-time': 0},
'runner_config': {runner.id: {'model': 'gpt-4.1', 'temperature': 0.2}}, 'runner_config': {runner.id: {'model': 'gpt-4.1', 'temperature': 0.2}},
} }
assert insert_values['enabled'] is True assert 'enabled' not in insert_values
assert insert_values['supported_event_patterns'] == AGENT_DEFAULT_EVENT_PATTERNS assert insert_values['supported_event_patterns'] == AGENT_DEFAULT_EVENT_PATTERNS
app.pipeline_service._get_default_values_from_schema.assert_called_once_with(runner.config_schema) app.pipeline_service._get_default_values_from_schema.assert_called_once_with(runner.config_schema)
@@ -62,9 +62,7 @@ def _set_discovered_adapters(ap, *webhook_adapters: str) -> None:
) )
for adapter_name in webhook_adapters for adapter_name in webhook_adapters
] ]
ap.discover = SimpleNamespace( ap.discover = SimpleNamespace(get_components_by_kind=Mock(return_value=components))
get_components_by_kind=Mock(return_value=components)
)
class TestBotServiceGetBots: class TestBotServiceGetBots:
@@ -445,6 +443,7 @@ class TestBotServiceUpdateBot:
ap.persistence_mgr = SimpleNamespace() ap.persistence_mgr = SimpleNamespace()
ap.platform_mgr = SimpleNamespace() ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.remove_bot = AsyncMock() ap.platform_mgr.remove_bot = AsyncMock()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
# Mock pipeline query - not updating pipeline # Mock pipeline query - not updating pipeline
ap.persistence_mgr.execute_async = AsyncMock() ap.persistence_mgr.execute_async = AsyncMock()
@@ -475,6 +474,7 @@ class TestBotServiceUpdateBot:
ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock()) ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
ap.platform_mgr = SimpleNamespace( ap.platform_mgr = SimpleNamespace(
get_bot_by_uuid=AsyncMock(return_value=None),
remove_bot=AsyncMock(), remove_bot=AsyncMock(),
load_bot=AsyncMock(return_value=SimpleNamespace(enable=False)), load_bot=AsyncMock(return_value=SimpleNamespace(enable=False)),
) )
@@ -498,6 +498,29 @@ class TestBotServiceUpdateBot:
assert 'use_pipeline_uuid' not in update_params assert 'use_pipeline_uuid' not in update_params
assert 'use_pipeline_name' not in update_params assert 'use_pipeline_name' not in update_params
async def test_basic_info_update_does_not_restart_platform_adapter(self):
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=SimpleNamespace(rowcount=1)))
runtime_entity = SimpleNamespace(name='Old name', description='Old description')
runtime_bot = SimpleNamespace(bot_entity=runtime_entity)
ap.platform_mgr = SimpleNamespace(
get_bot_by_uuid=AsyncMock(return_value=runtime_bot),
remove_bot=AsyncMock(),
load_bot=AsyncMock(),
)
service = BotService(ap)
await service.update_bot(
WORKSPACE_UUID,
'test-uuid',
{'name': 'New name', 'description': 'New description'},
)
assert runtime_entity.name == 'New name'
assert runtime_entity.description == 'New description'
ap.platform_mgr.remove_bot.assert_not_awaited()
ap.platform_mgr.load_bot.assert_not_awaited()
class TestBotServiceDeleteBot: class TestBotServiceDeleteBot:
"""Tests for delete_bot method.""" """Tests for delete_bot method."""
@@ -583,6 +606,56 @@ class TestBotServiceListEventLogs:
assert total == 5 assert total == 5
class TestBotServiceListEventRouteStatuses:
"""Tests for event route status when a persisted Bot is not running."""
async def test_returns_saved_routes_when_runtime_bot_is_unavailable(self):
ap = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
service = BotService(ap)
service.get_bot = AsyncMock(
return_value={
'uuid': 'bot-uuid',
'event_bindings': [
{
'id': 'binding-1',
'event_pattern': 'message.received',
'target_type': 'agent',
'target_uuid': 'agent-1',
'enabled': True,
}
],
}
)
result = await service.list_event_route_statuses(WORKSPACE_UUID, 'bot-uuid')
assert result['routes'] == [
{
'binding_id': 'binding-1',
'event_pattern': 'message.received',
'event_type': None,
'target_type': 'agent',
'target_uuid': 'agent-1',
'last_status': None,
'failure_code': None,
'reason': None,
'run_id': None,
'timestamp': None,
'seq_id': None,
'level': None,
'message': '',
'order': 0,
'enabled': True,
'current': True,
}
]
assert result['unmatched_events'] == []
assert result['stale_routes'] == []
class TestBotServiceSendMessage: class TestBotServiceSendMessage:
"""Tests for send_message method.""" """Tests for send_message method."""
@@ -9,6 +9,8 @@ from unittest.mock import ANY, AsyncMock
import pytest import pytest
import quart import quart
from langbot.pkg.agent.runner.errors import RunnerExecutionError
core_app_module = types.ModuleType('langbot.pkg.core.app') core_app_module = types.ModuleType('langbot.pkg.core.app')
core_app_module.Application = object core_app_module.Application = object
sys.modules.setdefault('langbot.pkg.core.app', core_app_module) sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
@@ -142,3 +144,28 @@ async def test_debug_agent_returns_bad_request_for_invalid_event():
'code': -1, 'code': -1,
'msg': 'Invalid event_type', 'msg': 'Invalid event_type',
} }
async def test_debug_agent_returns_actionable_runner_error():
agent_service = SimpleNamespace(
debug_agent=AsyncMock(
side_effect=RunnerExecutionError(
'plugin:langbot-team/DifyAgent/default',
'api-key is required',
error_code='dify.config_invalid',
)
),
)
client = await _create_test_client(agent_service)
response = await client.post(
'/api/v1/agents/agent-1/debug',
json={'event_type': 'message.received', 'text': 'hello'},
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 422
assert await response.get_json() == {
'code': 'dify.config_invalid',
'msg': 'api-key is required',
}
+1 -38
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import json
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@@ -20,18 +19,6 @@ def _make_app() -> SimpleNamespace:
update_bot=AsyncMock(), update_bot=AsyncMock(),
delete_bot=AsyncMock(), delete_bot=AsyncMock(),
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}), list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
dispatch_test_event_route=AsyncMock(
return_value={
'dispatched': True,
'event_type': 'message.received',
'suppressed_outputs': [],
'route_status': {
'routes': [],
'unmatched_events': [],
'stale_routes': [],
},
}
),
) )
app.pipeline_service = SimpleNamespace( app.pipeline_service = SimpleNamespace(
get_pipelines=AsyncMock(return_value=[]), get_pipelines=AsyncMock(return_value=[]),
@@ -75,30 +62,6 @@ async def test_mcp_server_exposes_bot_event_route_tools():
tool_names = {tool.name for tool in tools} tool_names = {tool.name for tool in tools}
assert 'list_bot_event_route_statuses' in tool_names assert 'list_bot_event_route_statuses' in tool_names
assert 'test_bot_event_route' in tool_names assert 'test_bot_event_route' not in tool_names
assert 'list_processors' in tool_names assert 'list_processors' in tool_names
assert 'list_agents' not in tool_names assert 'list_agents' not in tool_names
@pytest.mark.asyncio
async def test_mcp_test_bot_event_route_calls_service_layer():
app = _make_app()
server = LangBotMCPServer(app)
result_blocks, _ = await server.mcp.call_tool(
'test_bot_event_route',
{
'bot_uuid': 'bot-1',
'event_type': 'message.received',
'payload': {'message_text': 'hello'},
},
)
app.bot_service.dispatch_test_event_route.assert_awaited_once_with(
bot_uuid='bot-1',
event_type='message.received',
payload={'message_text': 'hello'},
)
data = json.loads(result_blocks[0].text)
assert data['dispatched'] is True
assert data['event_type'] == 'message.received'
+48 -18
View File
@@ -2163,25 +2163,38 @@ class TestInboundOutboundRoundTrip:
calls = [] calls = []
async def fake_execute_tool(parameters, q): async def fake_client_execute(spec):
calls.append(parameters['command']) cmd = spec.cmd
if 'os.scandir' in parameters['command']: calls.append(cmd)
return { if 'os.scandir' in cmd:
'ok': True, return BoxExecutionResult(
'stdout': '[{"name": "out.png", "b64": "QUJD"}]', session_id='s',
'stderr': '', backend_name='test',
} status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='[{"name": "out.png", "b64": "QUJD"}]',
duration_ms=10,
)
# the rm -rf cleanup call # the rm -rf cleanup call
return {'ok': True, 'stdout': '', 'stderr': ''} return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='',
duration_ms=10,
)
service.execute_tool = AsyncMock(side_effect=fake_execute_tool) service.client.execute = AsyncMock(side_effect=fake_client_execute)
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
attachments = await service.collect_outbound_attachments(query) attachments = await service.collect_outbound_attachments(query)
assert len(attachments) == 1 assert len(attachments) == 1
assert attachments[0]['type'] == 'Image' assert attachments[0]['type'] == 'Image'
assert attachments[0]['name'] == 'out.png' assert attachments[0]['name'] == 'out.png'
# cleanup (rm -rf) must have been issued after a successful collection # cleanup (rm -rf) must have been issued after a successful collection
assert any('rm -rf' in c for c in calls) service.execute_tool.assert_awaited_once()
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_collect_outbound_empty_still_clears(self): async def test_collect_outbound_empty_still_clears(self):
@@ -2193,16 +2206,33 @@ class TestInboundOutboundRoundTrip:
calls = [] calls = []
async def fake_execute_tool(parameters, q): async def fake_client_execute(spec):
calls.append(parameters['command']) cmd = spec.cmd
if 'os.scandir' in parameters['command']: calls.append(cmd)
return {'ok': True, 'stdout': '[]', 'stderr': ''} if 'os.scandir' in cmd:
return {'ok': True, 'stdout': '', 'stderr': ''} return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='[]',
duration_ms=10,
)
return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='',
duration_ms=10,
)
service.execute_tool = AsyncMock(side_effect=fake_execute_tool) service.client.execute = AsyncMock(side_effect=fake_client_execute)
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
assert await service.collect_outbound_attachments(query) == [] assert await service.collect_outbound_attachments(query) == []
# cleanup (rm -rf) is issued unconditionally now # cleanup (rm -rf) is issued unconditionally now
assert any('rm -rf' in c for c in calls) service.execute_tool.assert_awaited_once()
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_passthrough_noop_when_unavailable(self): async def test_passthrough_noop_when_unavailable(self):
+27
View File
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock
from langbot.pkg.command import operator from langbot.pkg.command import operator
from langbot.pkg.command.cmdmgr import CommandManager from langbot.pkg.command.cmdmgr import CommandManager
from langbot.pkg.api.http.context import ExecutionContext
from tests.factories import FakeApp, command_query from tests.factories import FakeApp, command_query
import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.provider.session as provider_session
@@ -393,6 +394,32 @@ class TestCommandManagerInternalExecute:
assert len(results) == 1 assert len(results) == 1
assert results[0].text == 'plugin response' assert results[0].text == 'plugin response'
@pytest.mark.asyncio
async def test_execute_selects_workspace_with_trusted_context(self):
"""Plugin command discovery receives the typed runtime scope."""
fake_app = FakeApp()
mgr = CommandManager(fake_app)
mgr.cmd_list = []
fake_app.plugin_connector.require_workspace_context = AsyncMock()
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
ctx = self._create_context(command='help')
ctx.instance_uuid = 'instance-a'
ctx.workspace_uuid = 'workspace-a'
ctx.placement_generation = 4
ctx.query_uuid = 'query-a'
async for _ in mgr._execute(ctx, mgr.cmd_list):
pass
selected = fake_app.plugin_connector.require_workspace_context.await_args.args[0]
assert isinstance(selected, ExecutionContext)
assert selected.instance_uuid == 'instance-a'
assert selected.workspace_uuid == 'workspace-a'
assert selected.placement_generation == 4
assert selected.query_uuid == 'query-a'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_execute_with_bound_plugins(self): async def test_execute_with_bound_plugins(self):
"""_execute passes bound_plugins to plugin connector.""" """_execute passes bound_plugins to plugin connector."""
@@ -144,3 +144,39 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
assert stats['models']['providers'] == 1 assert stats['models']['providers'] == 1
assert stats['runtimes']['plugin_installations'] == 1 assert stats['runtimes']['plugin_installations'] == 1
assert stats['runtimes']['plugin_runtime_connected'] is True assert stats['runtimes']['plugin_runtime_connected'] is True
@pytest.mark.asyncio
async def test_start_plugin_runtime_initialization_bypasses_after_commit_gate() -> None:
app = Application()
app.plugin_connector = SimpleNamespace(initialize=AsyncMock())
app.task_mgr = SimpleNamespace(create_task=AsyncMock())
task = app._start_plugin_runtime_initialization()
await task
app.plugin_connector.initialize.assert_awaited_once_with()
app.task_mgr.create_task.assert_not_called()
@pytest.mark.asyncio
async def test_shutdown_cancels_plugin_runtime_initialization_task() -> None:
app = Application()
app._plugin_runtime_initialization_task = asyncio.create_task(asyncio.sleep(60))
app.task_mgr = SimpleNamespace(cancel_by_scope=lambda *_: None, tasks=[])
app.event_loop_monitor = SimpleNamespace(stop=AsyncMock())
app.http_ctrl = SimpleNamespace(mcp_mount=None)
app.platform_mgr = None
app.tool_mgr = None
app.model_mgr = None
app.box_service = None
app.plugin_connector = None
app.telemetry = None
app.vector_db_mgr = None
app.storage_mgr = None
app.persistence_mgr = SimpleNamespace(db=SimpleNamespace(engine=SimpleNamespace(dispose=AsyncMock())))
app.deployment = None
await app.shutdown()
assert app._plugin_runtime_initialization_task.cancelled()
+27 -176
View File
@@ -81,9 +81,9 @@ class TestEventRouteTrace:
'target_type': 'agent', 'target_type': 'agent',
'target_uuid': 'agent-1', 'target_uuid': 'agent-1',
}, },
failure_code='processor_disabled', failure_code='processor_not_found',
reason='Agent target is disabled', reason='Agent target is unavailable',
text='disabled', text='unavailable',
) )
bot.logger.warning.assert_awaited_once() bot.logger.warning.assert_awaited_once()
@@ -96,61 +96,34 @@ class TestEventRouteTrace:
assert metadata['status'] == 'failed' assert metadata['status'] == 'failed'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dispatch_test_event_suppresses_agent_output_delivery(self): async def test_adapter_event_log_exposes_normalized_input_without_platform_object(self):
"""Synthetic test dispatch runs the route but does not call the real adapter.""" """Adapter debugging records the shared event shape without opaque SDK data."""
import langbot_plugin.api.entities.builtin.provider.message as provider_message from langbot_plugin.api.entities.builtin.platform import entities, events, message
captured_envelopes = [] bot = self._make_bot([])
bot.bot_entity.adapter = 'test-adapter'
async def fake_run(envelope, binding, adapter_context=None): event = events.MessageReceivedEvent(
captured_envelopes.append(envelope) message_id='message-1',
yield provider_message.Message(role='assistant', content='test response') message_chain=message.MessageChain([message.Plain(text='hello')]),
sender=entities.User(id='user-1', nickname='QA User'),
bot = self._make_bot( chat_type=entities.ChatType.PRIVATE,
[ chat_id='user-1',
{ source_platform_object={'access_token': 'must-not-be-logged'},
'id': 'agent-binding',
'enabled': True,
'event_pattern': 'message.received',
'target_type': 'agent',
'target_uuid': 'agent-1',
'priority': 0,
'order': 0,
}
]
)
bot.ap = SimpleNamespace(
workspace_service=active_workspace_service(),
agent_service=SimpleNamespace(
get_agent=AsyncMock(
return_value={
'uuid': 'agent-1',
'kind': 'agent',
'enabled': True,
'supported_event_patterns': ['message.received'],
'config': {'runner': {'id': 'runner-1'}, 'runner_config': {'runner-1': {}}},
}
)
),
agent_run_orchestrator=SimpleNamespace(run=fake_run),
)
bot.adapter = SimpleNamespace(
bot_account_id='bot-account',
config={},
logger=bot.logger,
send_message=AsyncMock(),
get_supported_apis=Mock(return_value=['send_message', 'edit_message', 'add_reaction', 'get_group_info']),
) )
result = await bot.dispatch_test_event('message.received', {'chat_id': 'user-1', 'message_text': 'hello'}) metadata = await bot._record_adapter_event(event, SimpleNamespace())
bot.adapter.send_message.assert_not_awaited() assert metadata['kind'] == 'adapter_event_received'
assert result['dispatched'] is True assert metadata['event_type'] == 'message.received'
assert result['status'] == 'delivered' assert metadata['adapter'] == 'test-adapter'
assert result['suppressed_outputs'][0]['method'] == 'send_message' assert metadata['bot_uuid'] == 'bot-1'
assert captured_envelopes[0].delivery.supports_edit is False assert metadata['event_data']['message_chain'] == [{'type': 'Plain', 'text': 'hello'}]
assert captured_envelopes[0].delivery.supports_reaction is False assert metadata['event_data']['sender']['id'] == 'user-1'
assert captured_envelopes[0].delivery.platform_capabilities['supported_apis'] == ['get_group_info'] assert 'source_platform_object' not in metadata['event_data']
bot.logger.info.assert_awaited_once_with(
'Platform adapter received message.received',
metadata=metadata,
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self): async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self):
@@ -208,128 +181,6 @@ class TestEventRouteTrace:
assert delivered['status'] == 'delivered' assert delivered['status'] == 'delivered'
assert len(runner_calls) == 1 assert len(runner_calls) == 1
@pytest.mark.asyncio
async def test_dispatch_test_event_pipeline_receives_synthetic_adapter(self):
"""Pipeline route tests enqueue queries with the no-op adapter."""
bot = self._make_bot(
[
{
'id': 'pipeline-binding',
'enabled': True,
'event_pattern': 'message.received',
'target_type': 'pipeline',
'target_uuid': 'pipeline-1',
'priority': 0,
'order': 0,
}
]
)
bot.ap = SimpleNamespace(
workspace_service=active_workspace_service(),
msg_aggregator=SimpleNamespace(add_message=AsyncMock()),
)
bot.adapter = SimpleNamespace(
bot_account_id='bot-account',
config={},
logger=bot.logger,
send_message=AsyncMock(),
)
result = await bot.dispatch_test_event(
'message.received',
{'chat_id': 'user-1', 'message_text': 'hello'},
)
bot.adapter.send_message.assert_not_awaited()
bot.ap.msg_aggregator.add_message.assert_awaited_once()
_, kwargs = bot.ap.msg_aggregator.add_message.await_args
query_adapter = kwargs['adapter']
assert query_adapter is not bot.adapter
assert getattr(query_adapter, 'source') is bot.adapter
assert result['dispatched'] is True
assert result['status'] == 'delivered'
assert result['suppressed_outputs'] == []
@pytest.mark.asyncio
async def test_dispatch_test_event_reports_unmatched_route_as_failure(self):
"""Synthetic dispatch does not report success when no saved route matches."""
bot = self._make_bot([])
bot.adapter = SimpleNamespace(
bot_account_id='bot-account',
config={},
logger=bot.logger,
)
result = await bot.dispatch_test_event(
'message.received',
{'chat_id': 'user-1', 'message_text': 'hello'},
)
assert result['dispatched'] is False
assert result['status'] == 'not_matched'
assert result['failure_code'] == 'route_not_found'
assert result['reason'] == 'No event route matched'
@pytest.mark.asyncio
async def test_synthetic_adapter_suppresses_platform_side_effect_apis(self):
"""Synthetic adapter blocks optional platform APIs that mutate external state."""
from langbot.pkg.platform.botmgr import SyntheticRouteTestAdapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
source = SimpleNamespace(
bot_account_id='bot-account',
config={},
logger=Mock(),
get_supported_apis=Mock(
return_value=[
'send_message',
'delete_message',
'get_group_info',
'call_platform_api',
]
),
delete_message=AsyncMock(),
call_platform_api=AsyncMock(),
)
adapter = SyntheticRouteTestAdapter(source)
await adapter.delete_message('group', 'group-1', 'message-1')
await adapter.call_platform_api('set_title', {'name': 'New Title'})
upload_result = await adapter.upload_file(b'data', 'test.txt')
source.delete_message.assert_not_awaited()
source.call_platform_api.assert_not_awaited()
assert upload_result == 'suppressed:test.txt'
assert [item['method'] for item in adapter.suppressed_outputs] == [
'delete_message',
'call_platform_api',
'upload_file',
]
assert adapter.get_supported_apis() == ['get_group_info']
assert adapter._message_to_payload(platform_message.MessageChain([platform_message.Plain(text='ok')]))
def test_build_test_platform_event_message_received_uses_payload(self):
"""Synthetic message events preserve common route filter fields."""
from langbot.pkg.platform.botmgr import RuntimeBot
event = RuntimeBot._build_test_platform_event(
'message.received',
{
'chat_type': 'group',
'chat_id': 'group-1',
'group_name': 'QA Group',
'user_id': 'user-1',
'user_name': 'QA User',
'message_text': 'hello',
},
)
assert event.type == 'message.received'
assert str(event.chat_id) == 'group-1'
assert event.group.name == 'QA Group'
assert event.sender.nickname == 'QA User'
assert str(event.message_chain) == 'hello'
def test_agent_envelope_projects_adapter_delivery_capabilities(self): def test_agent_envelope_projects_adapter_delivery_capabilities(self):
"""Runner delivery context reflects the active adapter's declared APIs.""" """Runner delivery context reflects the active adapter's declared APIs."""
from langbot_plugin.api.entities.builtin.platform import entities, events, message from langbot_plugin.api.entities.builtin.platform import entities, events, message
@@ -2,9 +2,10 @@
The web debug client uploads Image / Voice / File components carrying a storage The web debug client uploads Image / Voice / File components carrying a storage
key in ``path``. This helper resolves each to a base64 data URI (so multimodal key in ``path``. This helper resolves each to a base64 data URI (so multimodal
LLM input and the Box sandbox inbox have usable bytes), then deletes the LLM input and the Box sandbox inbox have usable bytes). Image uploads remain as
consumed upload. Covers mimetype selection per type and fail-closed error authenticated history references until storage retention cleanup, while other
handling. consumed uploads are deleted. Covers mimetype selection per type and
fail-closed error handling.
""" """
from __future__ import annotations from __future__ import annotations
@@ -52,7 +53,7 @@ def _make_adapter(load_return=b'hello', load_side_effect=None):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_image_jpeg_mimetype_and_consumed_storage_key(): async def test_image_jpeg_mimetype_and_retained_history_key():
adapter, storage_mgr, _ = _make_adapter(load_return=b'\xff\xd8\xff') adapter, storage_mgr, _ = _make_adapter(load_return=b'\xff\xd8\xff')
path = f'{_UPLOAD_PREFIX}photo.jpg' path = f'{_UPLOAD_PREFIX}photo.jpg'
chain = [{'type': 'Image', 'path': path}] chain = [{'type': 'Image', 'path': path}]
@@ -61,12 +62,11 @@ async def test_image_jpeg_mimetype_and_consumed_storage_key():
expected_b64 = base64.b64encode(b'\xff\xd8\xff').decode('utf-8') expected_b64 = base64.b64encode(b'\xff\xd8\xff').decode('utf-8')
assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}' assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}'
assert chain[0]['path'] == '' assert chain[0]['path'] == path
storage_mgr.delete_scoped_object_key.assert_awaited_once_with( storage_mgr.delete_scoped_object_key.assert_not_awaited()
_CONTEXT,
path, history = adapter._history_message_chain(chain)
expected_owner_type='upload_image', assert history == [{'type': 'Image', 'path': path, 'base64': ''}]
)
def test_history_retains_storage_key_without_large_base64_payload(): def test_history_retains_storage_key_without_large_base64_payload():
@@ -95,18 +95,22 @@ async def test_image_defaults_to_png():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_voice_uses_guessed_or_wav_mimetype(): async def test_voice_uses_guessed_or_wav_mimetype():
adapter, _, _ = _make_adapter() adapter, storage_mgr, _ = _make_adapter()
chain = [{'type': 'Voice', 'path': f'{_UPLOAD_PREFIX}clip.wav'}] chain = [{'type': 'Voice', 'path': f'{_UPLOAD_PREFIX}clip.wav'}]
await adapter._process_image_components(_make_connection(), chain) await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:audio/') assert chain[0]['base64'].startswith('data:audio/')
assert chain[0]['path'] == ''
storage_mgr.delete_scoped_object_key.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_file_uses_octet_stream_fallback(): async def test_file_uses_octet_stream_fallback():
adapter, _, _ = _make_adapter() adapter, storage_mgr, _ = _make_adapter()
chain = [{'type': 'File', 'path': f'{_UPLOAD_PREFIX}unknownblob'}] chain = [{'type': 'File', 'path': f'{_UPLOAD_PREFIX}unknownblob'}]
await adapter._process_image_components(_make_connection(), chain) await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:application/octet-stream;base64,') assert chain[0]['base64'].startswith('data:application/octet-stream;base64,')
assert chain[0]['path'] == ''
storage_mgr.delete_scoped_object_key.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -482,7 +482,7 @@ async def test_attachment_key_must_belong_to_connection_upload_scope():
await adapter._process_image_components(connection, message_chain) await adapter._process_image_components(connection, message_chain)
assert message_chain[0]['base64'].startswith('data:image/png;base64,') assert message_chain[0]['base64'].startswith('data:image/png;base64,')
assert message_chain[0]['path'] == '' assert message_chain[0]['path'] == 'v1/current/upload_image/key.png'
storage_mgr.scoped_prefix.assert_called_once_with( storage_mgr.scoped_prefix.assert_called_once_with(
connection.execution_context, connection.execution_context,
owner_type='upload_image', owner_type='upload_image',
@@ -496,11 +496,7 @@ async def test_attachment_key_must_belong_to_connection_upload_scope():
'v1/current/upload_image/key.png', 'v1/current/upload_image/key.png',
expected_owner_type='upload_image', expected_owner_type='upload_image',
) )
storage_mgr.delete_scoped_object_key.assert_awaited_once_with( storage_mgr.delete_scoped_object_key.assert_not_awaited()
connection.execution_context,
'v1/current/upload_image/key.png',
expected_owner_type='upload_image',
)
with pytest.raises(ValueError, match='does not belong'): with pytest.raises(ValueError, match='does not belong'):
await adapter._process_image_components( await adapter._process_image_components(
@@ -0,0 +1,127 @@
import base64
import pytest
import langbot.pkg.core.app # noqa: F401
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.libs.wecom_ai_bot_api.ws_client import _UPLOAD_CHUNK_SIZE, WecomBotWsClient
from langbot.pkg.platform.sources.wecombot import WecomBotAdapter, WecomBotMessageConverter
class Logger:
def __init__(self):
self.warnings = []
self.errors = []
async def warning(self, message):
self.warnings.append(message)
async def error(self, message):
self.errors.append(message)
async def info(self, message):
return None
class UploadClient(WecomBotWsClient):
def __init__(self):
super().__init__(bot_id='bot', secret='secret', logger=Logger())
self.frames = []
async def _send_reply(self, req_id: str, body: dict, cmd: str = 'aibot_respond_msg'):
self.frames.append((cmd, body))
if cmd == 'aibot_upload_media_init':
return {'errcode': 0, 'body': {'upload_id': 'upload-1'}}
if cmd == 'aibot_upload_media_finish':
return {'errcode': 0, 'body': {'media_id': 'media-1'}}
return {'errcode': 0}
class Bot:
def __init__(self):
self.calls = []
async def upload_media(self, data, filename='attachment', media_type='file'):
self.calls.append(('upload_media', media_type, filename, data))
return {'media_id': 'media-1'}
async def reply_text(self, req_id, content):
self.calls.append(('reply_text', req_id, content))
async def reply_image(self, req_id, media_id):
self.calls.append(('reply_image', req_id, media_id))
async def send_message(self, target_id, content):
self.calls.append(('send_message', target_id, content))
def make_adapter(bot):
return WecomBotAdapter.model_construct(
bot=bot,
config={'enable-webhook': False},
logger=Logger(),
message_converter=WecomBotMessageConverter(),
)
@pytest.mark.asyncio
async def test_ws_client_upload_media_uses_chunk_protocol():
client = UploadClient()
data = b'a' * (_UPLOAD_CHUNK_SIZE + 1)
upload_result = await client.upload_media(data, 'image.png', media_type='image')
assert upload_result['media_id'] == 'media-1'
assert [cmd for cmd, _ in client.frames] == [
'aibot_upload_media_init',
'aibot_upload_media_chunk',
'aibot_upload_media_chunk',
'aibot_upload_media_finish',
]
init_body = client.frames[0][1]
assert init_body['type'] == 'image'
assert init_body['filename'] == 'image.png'
assert init_body['total_size'] == len(data)
assert init_body['total_chunks'] == 2
assert client.frames[1][1]['chunk_index'] == 0
assert base64.b64decode(client.frames[1][1]['base64_data']) == b'a' * _UPLOAD_CHUNK_SIZE
assert client.frames[2][1]['chunk_index'] == 1
assert base64.b64decode(client.frames[2][1]['base64_data']) == b'a'
@pytest.mark.asyncio
async def test_reply_message_uploads_and_replies_image_media():
bot = Bot()
adapter = make_adapter(bot)
png_data = b'\x89PNG\r\n\x1a\nimage'
image_b64 = base64.b64encode(png_data).decode('utf-8')
chain = platform_message.MessageChain([platform_message.Image(base64=f'data:image/png;base64,{image_b64}')])
items = await WecomBotMessageConverter.yiri2target(chain)
await adapter._send_media(bot, 'req-1', items[0])
assert bot.calls == [
('upload_media', 'image', 'attachment.image', png_data),
('reply_image', 'req-1', 'media-1'),
]
@pytest.mark.asyncio
async def test_send_message_sends_text_and_skips_proactive_image():
bot = Bot()
adapter = make_adapter(bot)
jpg_data = b'\xff\xd8\xffimage'
image_b64 = base64.b64encode(jpg_data).decode('utf-8')
chain = platform_message.MessageChain(
[
platform_message.Plain(text='before'),
platform_message.Image(base64=f'data:image/jpeg;base64,{image_b64}'),
platform_message.Plain(text='after'),
]
)
await adapter.send_message('group', 'chat-1', chain)
assert bot.calls == [
('send_message', 'chat-1', 'beforeafter'),
]
@@ -107,6 +107,19 @@ def shared_connector(
return connector return connector
@pytest.mark.asyncio
async def test_shared_reconcile_uses_configured_cold_start_timeout():
binding = execution_binding("workspace-a")
setting = plugin_setting("01", "a" * 64)
connector = shared_connector([[binding]], {"workspace-a": [setting]})
connector.ap.instance_config.data["plugin"]["connect_timeout_seconds"] = 900
connector.handler = runtime_handler()
await connector._prepare_connected_runtime()
assert connector.handler.reconcile_plugin_installations.await_args.kwargs["timeout"] == 900
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection(): async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
binding_a = execution_binding('workspace-a') binding_a = execution_binding('workspace-a')
@@ -150,7 +163,7 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets():
assert connector._workspace_installations == {} assert connector._workspace_installations == {}
assert connector._known_desired_states == {} assert connector._known_desired_states == {}
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(()) connector.handler.reconcile_plugin_installations.assert_awaited_once_with((), timeout=300.0)
@pytest.mark.asyncio @pytest.mark.asyncio
+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 assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
@pytest.mark.asyncio
async def test_reconcile_plugin_installations_accepts_configured_cold_start_timeout():
runtime_handler = make_handler(SimpleNamespace())
runtime_handler.call_action = AsyncMock(return_value={})
binding = next(iter(runtime_handler._installation_bindings.values()))[0]
desired = PluginInstallationDesiredState(binding=binding, enabled=True)
await runtime_handler.reconcile_plugin_installations((desired,), timeout=900)
assert runtime_handler.call_action.await_args.kwargs["timeout"] == 900
class TestHandlerQueryVariables: class TestHandlerQueryVariables:
"""Tests for handler query variable logic.""" """Tests for handler query variable logic."""
+136 -6
View File
@@ -277,6 +277,7 @@ class TestSetBinaryStorage:
}, },
} }
mock_app.persistence_mgr = Mock() mock_app.persistence_mgr = Mock()
mock_app.persistence_mgr.get_db_engine.return_value = SimpleNamespace(dialect=SimpleNamespace(name='sqlite'))
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result()) mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
mock_app.logger = Mock() mock_app.logger = Mock()
return mock_app return mock_app
@@ -313,8 +314,8 @@ class TestSetBinaryStorage:
) )
assert response.code == 0 assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 2 assert app.persistence_mgr.execute_async.await_count == 3
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0]) insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
assert insert_params['workspace_uuid'] == 'workspace-a' assert insert_params['workspace_uuid'] == 'workspace-a'
assert insert_params['unique_key'] == canonical_binary_key( assert insert_params['unique_key'] == canonical_binary_key(
'plugin', 'plugin',
@@ -344,6 +345,69 @@ class TestSetBinaryStorage:
assert expected_key in update_params.values() assert expected_key in update_params.values()
assert update_params['value'] == b'new' assert update_params['value'] == b'new'
@pytest.mark.asyncio
async def test_adopts_legacy_storage_before_updating(self, app):
"""A migrated pre-tenancy row is updated in place rather than duplicated."""
runtime_handler = make_handler(app)
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
adopted = SimpleNamespace(rowcount=1)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(legacy_storage),
adopted,
]
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 3
adoption_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
expected_key = canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
assert expected_key in adoption_params.values()
assert adoption_params['value'] == b'new'
@pytest.mark.asyncio
async def test_legacy_adoption_race_updates_winning_canonical_row(self, app):
runtime_handler = make_handler(app)
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
lost_race = SimpleNamespace(rowcount=0)
canonical_winner = SimpleNamespace(rowcount=1)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(legacy_storage),
lost_race,
canonical_winner,
]
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 4
winner_update = compiled_params(app.persistence_mgr.execute_async.await_args_list[3].args[0])
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in winner_update.values()
assert winner_update['value'] == b'new'
@pytest.mark.asyncio
async def test_legacy_adoption_lost_to_delete_inserts_new_value(self, app):
runtime_handler = make_handler(app)
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
lost_race = SimpleNamespace(rowcount=0)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(legacy_storage),
lost_race,
SimpleNamespace(rowcount=0),
make_result(),
]
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 5
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[4].args[0])
assert insert_params['unique_key'] == canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
assert insert_params['value'] == b'new'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app): async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
"""Invalid max_value_bytes uses the 10MB default limit.""" """Invalid max_value_bytes uses the 10MB default limit."""
@@ -568,6 +632,46 @@ class TestGetBinaryStorage:
in statement_params.values() in statement_params.values()
) )
@pytest.mark.asyncio
async def test_reads_legacy_storage_without_mutating_key(self, app):
runtime_handler = make_handler(app)
legacy_storage = SimpleNamespace(
unique_key='plugin:test-author/test-plugin:test-key',
value=b'legacy bytes',
)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(legacy_storage),
]
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
)
assert response.code == 0
assert base64.b64decode(response.data['value_base64']) == b'legacy bytes'
assert app.persistence_mgr.execute_async.await_count == 2
@pytest.mark.asyncio
async def test_retries_canonical_after_concurrent_legacy_adoption(self, app):
runtime_handler = make_handler(app)
canonical_storage = SimpleNamespace(value=b'adopted bytes')
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(),
make_result(canonical_storage),
]
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
)
assert response.code == 0
assert base64.b64decode(response.data['value_base64']) == b'adopted bytes'
assert app.persistence_mgr.execute_async.await_count == 3
retry_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in retry_params.values()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_returns_error_when_not_found(self, app): async def test_returns_error_when_not_found(self, app):
"""Missing binary storage rows return an error response.""" """Missing binary storage rows return an error response."""
@@ -610,21 +714,47 @@ class TestDeleteAndListBinaryStorage:
assert response.code == 0 assert response.code == 0
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0]) statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
assert 'workspace-a' in statement_params.values() flat_values = [
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
]
assert 'workspace-a' in flat_values
assert ( assert (
canonical_binary_key( canonical_binary_key(
'plugin', 'plugin',
'test-author/test-plugin', 'test-author/test-plugin',
'test-key', 'test-key',
) )
in statement_params.values() in flat_values
) )
assert 'forged-owner' not in statement_params.values() assert 'forged-owner' not in flat_values
@pytest.mark.asyncio
async def test_delete_removes_canonical_and_legacy_scoped_keys(self, app):
runtime_handler = make_handler(app)
response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value](
{
'key': 'test-key',
'owner_type': 'plugin',
'owner': 'forged-owner',
}
)
assert response.code == 0
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
values = [
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
]
assert 'workspace-a' in values
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in values
assert 'plugin:test-author/test-plugin:test-key' in values
assert 'test-author/test-plugin' in values
assert 'forged-owner' not in values
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_keys_uses_trusted_plugin_owner(self, app): async def test_list_keys_uses_trusted_plugin_owner(self, app):
result = Mock() result = Mock()
result.scalars.return_value.all.return_value = ['first', 'second'] result.scalars.return_value.all.return_value = ['first', 'second', 'first']
app.persistence_mgr.execute_async.return_value = result app.persistence_mgr.execute_async.return_value = result
runtime_handler = make_handler(app) runtime_handler = make_handler(app)
@@ -0,0 +1,15 @@
from __future__ import annotations
import tomllib
from pathlib import Path
def test_seekdb_is_only_declared_as_an_optional_dependency() -> None:
project_root = Path(__file__).resolve().parents[2]
with (project_root / 'pyproject.toml').open('rb') as pyproject_file:
pyproject = tomllib.load(pyproject_file)
project = pyproject['project']
base_dependencies = project['dependencies']
assert not any(dependency.lower().startswith('pyseekdb') for dependency in base_dependencies)
assert project['optional-dependencies']['seekdb'] == ['pyseekdb==1.1.0.post3']
+19
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json
import threading import threading
from pathlib import Path
import pytest import pytest
@@ -53,6 +55,23 @@ async def test_matches_any_rejects_pattern_and_input_amplification():
) )
@pytest.mark.asyncio
async def test_bundled_sensitive_words_fit_within_pattern_limit():
config_path = Path(__file__).parents[3] / 'src/langbot/templates/metadata/sensitive-words.json'
config = json.loads(config_path.read_text())
assert len(config['words']) <= safe_regex.MAX_PATTERN_COUNT
found, masked = await safe_regex.mask_patterns(
config['words'],
'普通消息',
mask=config['mask'],
mask_word=config['mask_word'],
)
assert found is False
assert masked == '普通消息'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches(): async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
found, masked = await safe_regex.mask_patterns( found, masked = await safe_regex.mask_patterns(
@@ -0,0 +1,34 @@
from __future__ import annotations
import importlib
from unittest.mock import MagicMock
import pytest
from tests.utils.import_isolation import isolated_sys_modules
_INSTALL_HINT = "Install LangBot with the 'seekdb' extra"
def test_seekdb_vector_backend_reports_missing_optional_extra() -> None:
module_name = 'langbot.pkg.vector.vdbs.seekdb'
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
seekdb_module = importlib.import_module(module_name)
assert seekdb_module.SEEKDB_AVAILABLE is False
with pytest.raises(ImportError, match=_INSTALL_HINT):
seekdb_module.SeekDBVectorDatabase(MagicMock())
@pytest.mark.asyncio
async def test_seekdb_embedding_reports_missing_optional_extra() -> None:
module_name = 'langbot.pkg.provider.modelmgr.requesters.seekdbembed'
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
seekdb_embedding_module = importlib.import_module(module_name)
requester = seekdb_embedding_module.SeekDBEmbedding.__new__(seekdb_embedding_module.SeekDBEmbedding)
with pytest.raises(ImportError, match=_INSTALL_HINT):
await requester.initialize()
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'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform == 'win32'",
"python_full_version < '3.12' and sys_platform == 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'",
"python_full_version < '3.12' and sys_platform == 'emscripten'",
"python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version < '3.12' and sys_platform == 'win32'",
"python_full_version < '3.12' and sys_platform == 'emscripten'",
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
] ]
@@ -2054,7 +2054,6 @@ dependencies = [
{ name = "pymilvus" }, { name = "pymilvus" },
{ name = "pynacl" }, { name = "pynacl" },
{ name = "pypdf2" }, { name = "pypdf2" },
{ name = "pyseekdb" },
{ name = "python-docx" }, { name = "python-docx" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "python-socks" }, { name = "python-socks" },
@@ -2079,6 +2078,11 @@ dependencies = [
{ name = "websockets" }, { name = "websockets" },
] ]
[package.optional-dependencies]
seekdb = [
{ name = "pyseekdb" },
]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "moto" }, { name = "moto" },
@@ -2143,7 +2147,7 @@ requires-dist = [
{ name = "pymilvus", specifier = ">=2.6.4" }, { name = "pymilvus", specifier = ">=2.6.4" },
{ name = "pynacl", specifier = ">=1.5.0" }, { name = "pynacl", specifier = ">=1.5.0" },
{ name = "pypdf2", specifier = ">=3.0.1" }, { name = "pypdf2", specifier = ">=3.0.1" },
{ name = "pyseekdb", specifier = "==1.1.0.post3" }, { name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
{ name = "python-docx", specifier = ">=1.1.0" }, { name = "python-docx", specifier = ">=1.1.0" },
{ name = "python-multipart", specifier = ">=0.0.27" }, { name = "python-multipart", specifier = ">=0.0.27" },
{ name = "python-socks", specifier = ">=2.7.1" }, { name = "python-socks", specifier = ">=2.7.1" },
@@ -2167,6 +2171,7 @@ requires-dist = [
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" }, { name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
{ name = "websockets", specifier = ">=15.0.1" }, { name = "websockets", specifier = ">=15.0.1" },
] ]
provides-extras = ["seekdb"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
+198 -71
View File
@@ -1,23 +1,41 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Bug, Settings } from 'lucide-react'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Trash2 } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { useCurrentWorkspace } from '@/app/infra/http'; import { useCurrentWorkspace } from '@/app/infra/http';
import { Agent } from '@/app/infra/entities/api'; import { Agent } from '@/app/infra/entities/api';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent'; import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
import AgentCreateContent from './components/AgentCreateContent'; import AgentCreateContent from './components/AgentCreateContent';
import AgentDebugPanel from './components/AgentDebugPanel'; import AgentDebugPanel from './components/AgentDebugPanel';
import AgentFormComponent from './components/AgentFormComponent'; import AgentFormComponent, {
AgentFormHandle,
AgentRunnerStatus,
} from './components/AgentFormComponent';
export default function AgentDetailContent({ id }: { id: string }) { export default function AgentDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new'; const isCreateMode = id === 'new';
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace(); const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const canOperate = const canOperate =
currentWorkspace?.permissions.includes('runtime.operate') ?? false; currentWorkspace?.permissions.includes('runtime.operate') ?? false;
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData(); const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
@@ -25,7 +43,19 @@ export default function AgentDetailContent({ id }: { id: string }) {
const [loading, setLoading] = useState(!isCreateMode); const [loading, setLoading] = useState(!isCreateMode);
const [formDirty, setFormDirty] = useState(false); const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false); const [formSaving, setFormSaving] = useState(false);
const [activeTab, setActiveTab] = useState('config'); const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [runnerStatus, setRunnerStatus] = useState<AgentRunnerStatus | null>(
null,
);
const [availableEventTypes, setAvailableEventTypes] = useState<string[]>([
'message.received',
]);
const [supportedEventPatterns, setSupportedEventPatterns] = useState<
string[]
>(['*']);
const agentFormRef = useRef<AgentFormHandle>(null);
useEffect(() => { useEffect(() => {
if (isCreateMode) { if (isCreateMode) {
@@ -38,14 +68,33 @@ export default function AgentDetailContent({ id }: { id: string }) {
return () => setDetailEntityName(null); return () => setDetailEntityName(null);
}, [id, isCreateMode, pipelines, setDetailEntityName, t]); }, [id, isCreateMode, pipelines, setDetailEntityName, t]);
useEffect(() => {
setRunnerStatus(null);
}, [id]);
useEffect(() => { useEffect(() => {
if (isCreateMode) return; if (isCreateMode) return;
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
httpClient Promise.all([
.getAgent(id) httpClient.getAgent(id),
.then((resp) => { httpClient.getAdapters().catch(() => ({ adapters: [] })),
if (!cancelled) setAgent(resp.agent); ])
.then(([resp, adaptersResp]) => {
if (cancelled) return;
const adapterEvents = adaptersResp.adapters.flatMap(
(adapter) => adapter.spec.supported_events ?? [],
);
setAvailableEventTypes(
adapterEvents.length > 0
? Array.from(new Set(adapterEvents)).sort()
: ['message.received'],
);
setSupportedEventPatterns(
resp.agent.supported_event_patterns ??
resp.agent.capability?.supported_event_patterns ?? ['*'],
);
setAgent(resp.agent);
}) })
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
@@ -78,72 +127,150 @@ export default function AgentDetailContent({ id }: { id: string }) {
return <PipelineDetailContent id={id} routeBase="/home/agents" />; return <PipelineDetailContent id={id} routeBase="/home/agents" />;
} }
async function saveBasicInfo(values: EntityBasicInfoValues) {
try {
await httpClient.updateAgent(id, values);
setAgent((current) => (current ? { ...current, ...values } : current));
agentFormRef.current?.syncBasicInfo(values);
await refreshPipelines();
toast.success(t('agents.saveSuccess'));
} catch (error) {
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('agents.saveError') + message);
throw error;
}
}
async function deleteAgent() {
setDeleting(true);
try {
await httpClient.deleteAgent(id);
toast.success(t('agents.deleteSuccess'));
setDeleteConfirmOpen(false);
await refreshPipelines();
navigate('/home/agents');
} catch (error) {
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('agents.deleteError') + message);
} finally {
setDeleting(false);
}
}
return ( return (
<div className="flex h-full min-w-0 flex-col"> <>
<div className="flex items-center justify-between pb-4 shrink-0"> <ProcessorDetailWorkbench
<h1 className="text-xl font-semibold">{t('agents.editAgent')}</h1>
<Button
type="submit"
form="agent-form"
disabled={!formDirty || formSaving}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
</div>
<Tabs
key={id} key={id}
value={activeTab} title={`${agent.emoji || '🤖'} ${agent.name}`}
onValueChange={setActiveTab} titleAction={
className="flex min-h-0 min-w-0 flex-1 flex-col" canManage ? (
> <EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
<TabsList className="shrink-0"> ) : undefined
<TabsTrigger value="config" className="gap-1.5"> }
<Settings className="size-3.5" /> status={runnerStatus}
{t('pipelines.configuration')} saveLabel={t('common.save')}
</TabsTrigger> saveFormId="agent-form"
{canOperate && ( canSave={canManage}
<TabsTrigger value="debug" className="gap-1.5"> isDirty={formDirty}
<Bug className="size-3.5" /> isSaving={formSaving}
{t('agents.debugTab')} headerActions={
</TabsTrigger> canManage ? (
)} <Button
</TabsList> type="button"
variant="destructive"
<TabsContent disabled={formSaving || deleting}
value="config" onClick={() => setDeleteConfirmOpen(true)}
className="mt-4 min-h-0 min-w-0 flex-1 overflow-hidden" >
> <Trash2 className="size-4" />
<AgentFormComponent {t('common.delete')}
agentId={id} </Button>
onFinish={() => { ) : undefined
refreshPipelines(); }
}} configTitle={t('pipelines.configuration')}
onDeleted={() => { configContent={
refreshPipelines(); <fieldset className="contents" disabled={!canManage}>
navigate('/home/agents'); <AgentFormComponent
}} ref={agentFormRef}
onDirtyChange={setFormDirty} agentId={id}
onSavingChange={setFormSaving} availableEventTypes={availableEventTypes}
/> onFinish={(updatedAgent) => {
</TabsContent> if (updatedAgent) {
setAgent((current) =>
{canOperate && ( current ? { ...current, ...updatedAgent } : current,
<TabsContent );
value="debug" }
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden" refreshPipelines();
> }}
onDirtyChange={setFormDirty}
onSavingChange={setFormSaving}
onRunnerStatusChange={setRunnerStatus}
onSupportedEventPatternsChange={setSupportedEventPatterns}
/>
</fieldset>
}
debugTitle={canOperate ? t('agents.debugTab') : undefined}
debugContent={
canOperate ? (
<AgentDebugPanel <AgentDebugPanel
agentId={id} agentId={id}
supportedEventPatterns={ hasUnsavedChanges={formDirty}
agent.supported_event_patterns ?? beforeRun={async () => agentFormRef.current?.save() ?? false}
agent.capability?.supported_event_patterns ?? ['*'] onOpenRunnerConfig={() =>
agentFormRef.current?.openSection('runner_config')
} }
supportedEventPatterns={supportedEventPatterns}
availableEventTypes={availableEventTypes}
/> />
</TabsContent> ) : undefined
)} }
</Tabs> unsavedLabel={t('pipelines.unsavedChanges')}
</div> />
<EntityBasicInfoDialog
open={basicInfoOpen}
onOpenChange={setBasicInfoOpen}
values={{
name: agent.name,
description: agent.description || '',
emoji: agent.emoji || '🤖',
}}
defaultEmoji="🤖"
onSave={saveBasicInfo}
/>
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
<DialogDescription>
{t('agents.deleteConfirmation')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={deleting}
onClick={() => setDeleteConfirmOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="destructive"
disabled={deleting}
onClick={deleteAgent}
>
<Trash2 className="size-4" />
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
); );
} }
@@ -1,5 +1,4 @@
import { useState } from 'react'; import { useState } from 'react';
import type React from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
@@ -8,8 +7,8 @@ import { toast } from 'sonner';
import { Bot, Workflow } from 'lucide-react'; import { Bot, Workflow } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { AgentKind } from '@/app/infra/entities/api'; import { AgentKind } from '@/app/infra/entities/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { import {
Card, Card,
CardContent, CardContent,
@@ -27,6 +26,7 @@ import {
} from '@/components/ui/form'; } from '@/components/ui/form';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import EmojiPicker from '@/components/ui/emoji-picker'; import EmojiPicker from '@/components/ui/emoji-picker';
import ProcessorTypeDiagram from './ProcessorTypeDiagram';
export default function AgentCreateContent({ export default function AgentCreateContent({
onCreated, onCreated,
@@ -51,9 +51,12 @@ export default function AgentCreateContent({
}); });
function handleKindChange(nextKind: AgentKind) { function handleKindChange(nextKind: AgentKind) {
const previousDefaultEmoji = kind === 'pipeline' ? '⚙️' : '🤖';
const nextDefaultEmoji = nextKind === 'pipeline' ? '⚙️' : '🤖';
setKind(nextKind); setKind(nextKind);
if (!form.getValues('emoji')) { const currentEmoji = form.getValues('emoji');
form.setValue('emoji', nextKind === 'pipeline' ? '⚙️' : '🤖'); if (!currentEmoji || currentEmoji === previousDefaultEmoji) {
form.setValue('emoji', nextDefaultEmoji);
} }
} }
@@ -74,26 +77,18 @@ export default function AgentCreateContent({
}); });
} }
const typeOptions: Array<{ const typeOptions = [
kind: AgentKind;
icon: React.ElementType;
title: string;
description: string;
badge: string;
}> = [
{ {
kind: 'agent', kind: 'agent' as const,
icon: Bot, icon: Bot,
title: t('agents.agentType'), title: t('agents.agentType'),
description: t('agents.agentTypeDescription'), description: t('agents.agentTypeDescription'),
badge: t('agents.allEvents'),
}, },
{ {
kind: 'pipeline', kind: 'pipeline' as const,
icon: Workflow, icon: Workflow,
title: t('agents.pipelineType'), title: t('agents.pipelineType'),
description: t('agents.pipelineTypeDescription'), description: t('agents.pipelineTypeDescription'),
badge: t('agents.messageEventsOnly'),
}, },
]; ];
@@ -107,106 +102,136 @@ export default function AgentCreateContent({
</div> </div>
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-2xl space-y-6"> <div className="mx-auto max-w-6xl pb-6">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid items-stretch gap-5 lg:grid-cols-[minmax(340px,0.78fr)_minmax(0,1.22fr)]">
{typeOptions.map((option) => { <div className="space-y-5">
const Icon = option.icon; <section
const selected = kind === option.kind; aria-labelledby="processor-kind-heading"
return ( className="space-y-3"
<button >
key={option.kind} <div>
type="button" <h2
onClick={() => handleKindChange(option.kind)} id="processor-kind-heading"
className={cn( className="text-base font-semibold"
'rounded-lg border bg-card p-4 text-left transition-colors', >
selected {t('agents.chooseType')}
? 'border-primary ring-2 ring-primary/20' </h2>
: 'hover:border-primary/60', <p className="mt-1 text-sm text-muted-foreground">
)} {t('agents.chooseTypeDescription')}
</p>
</div>
<ToggleGroup
type="single"
value={kind}
onValueChange={(value) => {
if (value) handleKindChange(value as AgentKind);
}}
variant="outline"
spacing={3}
className="grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-1"
> >
<div className="flex items-start gap-3"> {typeOptions.map((option) => {
<Icon className="mt-0.5 size-5 text-blue-500" /> const Icon = option.icon;
<div className="space-y-1"> return (
<div className="font-medium">{option.title}</div> <ToggleGroupItem
<div className="text-xs text-muted-foreground"> key={option.kind}
{option.badge} value={option.kind}
data-processor-kind={option.kind}
aria-label={`${option.title} ${option.description}`}
className="h-auto min-h-28 w-full items-start justify-start gap-3 rounded-lg border px-4 py-4 text-left whitespace-normal shadow-none hover:bg-muted/40 data-[state=on]:border-[#2288ee]/50 data-[state=on]:bg-blue-50/60 data-[state=on]:text-foreground data-[state=on]:shadow-none dark:data-[state=on]:border-blue-500/50 dark:data-[state=on]:bg-blue-500/10"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-background text-[#2288ee] shadow-xs">
<Icon className="size-4" />
</span>
<span className="min-w-0 space-y-1.5">
<span className="block text-sm font-medium text-foreground">
{option.title}
</span>
<span className="block text-sm font-normal leading-relaxed text-muted-foreground">
{option.description}
</span>
</span>
</ToggleGroupItem>
);
})}
</ToggleGroup>
</section>
<Card>
<CardHeader>
<CardTitle>{t('agents.basicInfo')}</CardTitle>
<CardDescription>
{t('agents.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
id="agent-create-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('common.name')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<p className="text-sm text-muted-foreground">
{option.description} <FormField
</p> control={form.control}
</div> name="description"
</div> render={({ field }) => (
</button> <FormItem>
); <FormLabel>{t('common.description')}</FormLabel>
})} <FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
</Card>
</div>
<Card className="min-h-[600px] overflow-hidden py-0 dark:border-white/16 lg:min-h-[680px]">
<CardContent className="flex h-full items-center p-0">
<ProcessorTypeDiagram kind={kind} />
</CardContent>
</Card>
</div> </div>
<Card>
<CardHeader>
<CardTitle>{t('agents.basicInfo')}</CardTitle>
<CardDescription>
{t('agents.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
id="agent-create-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('common.name')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.description')}</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
</Card>
</div> </div>
</div> </div>
</div> </div>
@@ -1,39 +1,50 @@
import { useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
AlertTriangle, AlertCircle,
Braces, ChevronDown,
CircleHelp,
LoaderCircle, LoaderCircle,
MessageSquare,
Play, Play,
RotateCcw, RotateCcw,
} from 'lucide-react'; } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
eventGroupLabel,
eventPatternDescription,
eventPatternLabel,
groupEventPatterns,
} from '@/app/home/components/event-patterns/event-pattern-groups';
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
interface AgentDebugPanelProps { interface AgentDebugPanelProps {
agentId: string; agentId: string;
availableEventTypes: string[];
supportedEventPatterns?: string[]; supportedEventPatterns?: string[];
beforeRun?: () => Promise<boolean>;
hasUnsavedChanges?: boolean;
onOpenRunnerConfig?: () => void;
} }
interface DebugEntry { interface DebugEntry {
@@ -41,18 +52,19 @@ interface DebugEntry {
direction: 'input' | 'output' | 'error'; direction: 'input' | 'output' | 'error';
eventType: string; eventType: string;
text: string; text: string;
errorCode?: string;
detail?: string;
} }
const EVENT_PRESETS = [ const EVENT_PRESET_DATA: Record<
{ string,
value: 'message.received', { text: string; data: Record<string, unknown> }
labelKey: 'agents.debugMessageReceived', > = {
'message.received': {
text: '', text: '',
data: {}, data: {},
}, },
{ 'group.member_joined': {
value: 'group.member.joined',
labelKey: 'agents.debugGroupMemberJoined',
text: 'A new member joined the group.', text: 'A new member joined the group.',
data: { data: {
group_id: 'debug-group', group_id: 'debug-group',
@@ -60,9 +72,7 @@ const EVENT_PRESETS = [
member_name: 'Debug User', member_name: 'Debug User',
}, },
}, },
{ 'group.member_left': {
value: 'group.member.left',
labelKey: 'agents.debugGroupMemberLeft',
text: 'A member left the group.', text: 'A member left the group.',
data: { data: {
group_id: 'debug-group', group_id: 'debug-group',
@@ -70,9 +80,7 @@ const EVENT_PRESETS = [
member_name: 'Debug User', member_name: 'Debug User',
}, },
}, },
{ 'friend.request_received': {
value: 'friend.requested',
labelKey: 'agents.debugFriendRequested',
text: 'A user sent a friend request.', text: 'A user sent a friend request.',
data: { data: {
requester_id: 'debug-user', requester_id: 'debug-user',
@@ -80,31 +88,32 @@ const EVENT_PRESETS = [
message: 'Hello', message: 'Hello',
}, },
}, },
{ 'feedback.received': {
value: 'feedback.received',
labelKey: 'agents.debugFeedbackReceived',
text: 'The user submitted feedback.', text: 'The user submitted feedback.',
data: { data: {
rating: 5, rating: 5,
content: 'Debug feedback', content: 'Debug feedback',
}, },
}, },
{ };
value: 'custom',
labelKey: 'agents.debugCustomEvent',
text: '',
data: {},
},
] as const;
function createDebugSessionId(agentId: string) { function createDebugSessionId(agentId: string) {
const nonce = globalThis.crypto?.randomUUID?.() ?? String(Date.now()); const nonce = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
return `webui:${agentId}:${nonce}`; return `webui:${agentId}:${nonce}`;
} }
function matchesEventPattern(pattern: string, eventType: string) {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escaped.replaceAll('*', '.*')}$`).test(eventType);
}
export default function AgentDebugPanel({ export default function AgentDebugPanel({
agentId, agentId,
availableEventTypes,
supportedEventPatterns = ['*'], supportedEventPatterns = ['*'],
beforeRun,
hasUnsavedChanges = false,
onOpenRunnerConfig,
}: AgentDebugPanelProps) { }: AgentDebugPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [preset, setPreset] = useState('message.received'); const [preset, setPreset] = useState('message.received');
@@ -121,11 +130,39 @@ export default function AgentDebugPanel({
() => supportedEventPatterns.join(', '), () => supportedEventPatterns.join(', '),
[supportedEventPatterns], [supportedEventPatterns],
); );
const availableEvents = useMemo(() => {
const concretePatterns = supportedEventPatterns.filter(
(pattern) => pattern !== '*' && !pattern.endsWith('.*'),
);
return Array.from(new Set([...availableEventTypes, ...concretePatterns]))
.filter((candidate) =>
supportedEventPatterns.some((pattern) =>
matchesEventPattern(pattern, candidate),
),
)
.sort();
}, [availableEventTypes, supportedEventPatterns]);
const eventGroups = useMemo(
() => groupEventPatterns(availableEvents),
[availableEvents],
);
const supportsCustomEvent = supportedEventPatterns.some(
(pattern) => pattern === '*' || pattern.endsWith('.*'),
);
useEffect(() => {
if (
availableEvents.includes(preset) ||
(preset === 'custom' && supportsCustomEvent)
) {
return;
}
selectPreset(availableEvents[0] ?? 'custom');
}, [availableEvents, preset, supportsCustomEvent]);
function selectPreset(value: string) { function selectPreset(value: string) {
setPreset(value); setPreset(value);
const nextPreset = EVENT_PRESETS.find((item) => item.value === value); const nextPreset = EVENT_PRESET_DATA[value] ?? { text: '', data: {} };
if (!nextPreset) return;
setInputText(nextPreset.text); setInputText(nextPreset.text);
setEventDataText(JSON.stringify(nextPreset.data, null, 2)); setEventDataText(JSON.stringify(nextPreset.data, null, 2));
} }
@@ -144,6 +181,14 @@ export default function AgentDebugPanel({
toast.error(t('agents.debugInputRequired')); toast.error(t('agents.debugInputRequired'));
return; return;
} }
if (
!supportedEventPatterns.some((pattern) =>
matchesEventPattern(pattern, eventType),
)
) {
toast.error(t('agents.debugUnsupportedEvent'));
return;
}
let eventData: Record<string, unknown>; let eventData: Record<string, unknown>;
try { try {
@@ -157,6 +202,12 @@ export default function AgentDebugPanel({
return; return;
} }
setRunning(true);
if (hasUnsavedChanges && beforeRun && !(await beforeRun())) {
setRunning(false);
return;
}
const requestId = globalThis.crypto?.randomUUID?.() ?? String(Date.now()); const requestId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
setEntries((current) => [ setEntries((current) => [
...current, ...current,
@@ -167,7 +218,6 @@ export default function AgentDebugPanel({
text: inputText.trim() || JSON.stringify(eventData, null, 2), text: inputText.trim() || JSON.stringify(eventData, null, 2),
}, },
]); ]);
setRunning(true);
try { try {
const result = await httpClient.debugAgent(agentId, { const result = await httpClient.debugAgent(agentId, {
event_type: eventType, event_type: eventType,
@@ -186,17 +236,41 @@ export default function AgentDebugPanel({
]); ]);
if (isMessageEvent) setInputText(''); if (isMessageEvent) setInputText('');
} catch (error) { } catch (error) {
const errorCode =
typeof error === 'object' && error && 'code' in error
? String((error as { code?: string }).code || '')
: '';
const message = const message =
typeof error === 'object' && error && 'msg' in error typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '') ? String((error as { msg?: string }).msg || '')
: t('agents.debugRunFailed'); : t('agents.debugRunFailed');
const isConfigError = errorCode.endsWith('.config_invalid');
const isExecutionError = errorCode === 'runner_execution_failed';
const isTimeout = errorCode === 'runner.timeout';
const friendlyMessage = isConfigError
? t('agents.debugRunnerConfigInvalidDescription', {
message:
message === 'api-key is required'
? t('agents.debugApiKeyRequired')
: message,
})
: isExecutionError
? t('agents.debugRunnerExecutionFailedDescription')
: isTimeout
? t('agents.debugRunnerTimeoutDescription')
: message || t('agents.debugRunFailed');
setEntries((current) => [ setEntries((current) => [
...current, ...current,
{ {
id: `error:${requestId}`, id: `error:${requestId}`,
direction: 'error', direction: 'error',
eventType, eventType,
text: message || t('agents.debugRunFailed'), text: friendlyMessage,
errorCode,
detail:
isExecutionError || isTimeout
? message || t('agents.debugRunFailed')
: undefined,
}, },
]); ]);
} finally { } finally {
@@ -205,162 +279,213 @@ export default function AgentDebugPanel({
} }
return ( return (
<div className="mx-auto grid w-full min-w-0 max-w-6xl gap-6 pb-8 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,0.8fr)]"> <div className="flex h-full min-h-0 min-w-0 flex-col">
<Card className="min-w-0"> <div className="min-h-0 flex-1 overflow-y-auto p-3">
<CardHeader> <div className="mb-3">
<div className="flex flex-wrap items-start justify-between gap-3"> <p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
<div className="space-y-1"> <p className="text-xs text-muted-foreground">
<CardTitle className="flex items-center gap-2"> {t('agents.debugTranscriptDescription')}
{isMessageEvent ? ( </p>
<MessageSquare className="size-5" /> </div>
) : ( {entries.length === 0 ? (
<Braces className="size-5" /> <Alert className="my-4 bg-muted/20">
)} <CircleHelp className="size-4" />
{t('agents.debugTitle')} <AlertTitle>{t('agents.debugEmptyTitle')}</AlertTitle>
</CardTitle>
<CardDescription>{t('agents.debugDescription')}</CardDescription>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={resetSession}
>
<RotateCcw className="size-4" />
{t('agents.debugResetSession')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-5">
<Alert>
<AlertTriangle />
<AlertTitle>{t('agents.debugActualRun')}</AlertTitle>
<AlertDescription> <AlertDescription>
{t('agents.debugActualRunDescription')} {t('agents.debugEmptyTranscript')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : (
<div className="grid gap-4 sm:grid-cols-2"> <div className="space-y-3">
<div className="space-y-2"> {entries.map((entry) => (
<Label>{t('agents.debugEventType')}</Label> <Alert
<Select value={preset} onValueChange={selectPreset}> key={entry.id}
<SelectTrigger className="w-full"> variant={
<SelectValue /> entry.direction === 'error' ? 'destructive' : 'default'
</SelectTrigger> }
<SelectContent> className={
{EVENT_PRESETS.map((item) => ( entry.direction === 'output'
<SelectItem key={item.value} value={item.value}> ? 'border-primary/20 bg-primary/5'
{t(item.labelKey)} : entry.direction === 'input'
</SelectItem> ? 'bg-muted/40'
))} : undefined
</SelectContent> }
</Select> >
</div> {entry.direction === 'error' && <AlertCircle />}
{preset === 'custom' && ( <div className="mb-2 flex items-center justify-between gap-2">
<div className="space-y-2"> <Badge variant="outline">{entry.eventType}</Badge>
<Label htmlFor="agent-debug-custom-event"> <span className="text-xs text-muted-foreground">
{t('agents.debugCustomEventType')} {entry.direction === 'output'
</Label> ? t('agents.debugAgentOutput')
<Input : entry.direction === 'error'
id="agent-debug-custom-event" ? t('common.error')
value={customEventType} : t('agents.debugTestInput')}
onChange={(event) => setCustomEventType(event.target.value)} </span>
placeholder="custom.event" </div>
/> <pre className="min-w-0 whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
</div> {entry.text}
)} </pre>
{entry.detail && (
<Collapsible className="mt-3">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm">
{t('agents.debugErrorDetails')}
<ChevronDown className="size-3.5" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
{entry.detail}
</pre>
</CollapsibleContent>
</Collapsible>
)}
{(entry.errorCode?.endsWith('.config_invalid') ||
entry.errorCode === 'runner_execution_failed' ||
entry.errorCode === 'runner.timeout') &&
onOpenRunnerConfig && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={onOpenRunnerConfig}
>
{t('agents.debugReviewRunnerConfig')}
</Button>
)}
</Alert>
))}
</div> </div>
)}
</div>
<div className="space-y-2"> <div className="shrink-0 space-y-3 border-t p-3">
<Label htmlFor="agent-debug-input"> <div className="flex items-end gap-2">
{isMessageEvent <div className="min-w-0 flex-1 space-y-1.5">
? t('agents.debugMessageInput') <Label>{t('agents.debugEventType')}</Label>
: t('agents.debugEventSummary')} <Select value={preset} onValueChange={selectPreset}>
<SelectTrigger
className="w-full"
aria-label={t('agents.debugEventType')}
>
<SelectValue />
</SelectTrigger>
<SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
{eventGroups.map((group) => (
<SelectGroup key={group.namespace}>
<SelectLabel>
{eventGroupLabel(group.namespace, t)}
</SelectLabel>
{group.patterns.map((event) => (
<SelectItem
key={event}
value={event}
description={eventPatternDescription(event, t)}
className="py-2"
>
<EventSelectOptionContent
event={event}
label={eventPatternLabel(event, t)}
/>
</SelectItem>
))}
</SelectGroup>
))}
{supportsCustomEvent && (
<SelectGroup>
<SelectLabel>{t('agents.debugCustomEvent')}</SelectLabel>
<SelectItem
value="custom"
description={t('bots.eventDescriptions.custom')}
className="py-2"
>
<EventSelectOptionContent
event="custom.event"
label={t('agents.debugCustomEvent')}
/>
</SelectItem>
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={resetSession}
title={t('agents.debugResetSession')}
>
<RotateCcw className="size-4" />
</Button>
</div>
{preset === 'custom' && (
<div className="space-y-1.5">
<Label htmlFor="agent-debug-custom-event">
{t('agents.debugCustomEventType')}
</Label> </Label>
<Textarea <Input
id="agent-debug-input" id="agent-debug-custom-event"
value={inputText} value={customEventType}
onChange={(event) => setInputText(event.target.value)} onChange={(event) => setCustomEventType(event.target.value)}
className="min-h-24 resize-y" placeholder="custom.event"
placeholder={t('agents.debugInputPlaceholder')}
/> />
</div> </div>
)}
<div className="space-y-2"> <div className="space-y-1.5">
<div className="flex flex-wrap items-center justify-between gap-2"> <Label htmlFor="agent-debug-input">
<Label htmlFor="agent-debug-payload"> {isMessageEvent
{t('agents.debugEventPayload')} ? t('agents.debugMessageInput')
</Label> : t('agents.debugEventSummary')}
<span className="text-xs text-muted-foreground"> </Label>
{t('agents.debugSupportedEvents')}: {supportedLabel} <Textarea
</span> id="agent-debug-input"
</div> value={inputText}
onChange={(event) => setInputText(event.target.value)}
className="min-h-20 resize-y"
placeholder={t('agents.debugInputPlaceholder')}
/>
</div>
<details className="rounded-md border bg-muted/20 px-3 py-2">
<summary className="cursor-pointer text-xs font-medium">
{t('agents.debugEventPayload')}
</summary>
<div className="mt-2 space-y-2">
<p className="text-xs text-muted-foreground">
{t('agents.debugSupportedEvents')}: {supportedLabel}
</p>
<Textarea <Textarea
id="agent-debug-payload" id="agent-debug-payload"
value={eventDataText} value={eventDataText}
onChange={(event) => setEventDataText(event.target.value)} onChange={(event) => setEventDataText(event.target.value)}
className="min-h-40 resize-y font-mono text-xs" className="min-h-28 resize-y font-mono text-xs"
spellCheck={false} spellCheck={false}
/> />
</div> </div>
</details>
<div className="flex justify-end"> <Button
<Button type="button" disabled={running} onClick={runDebugEvent}> type="button"
{running ? ( className="w-full"
<LoaderCircle className="size-4 animate-spin" /> disabled={running}
) : ( onClick={runDebugEvent}
<Play className="size-4" /> >
)} {running ? (
{running ? t('agents.debugRunning') : t('agents.debugRun')} <LoaderCircle className="size-4 animate-spin" />
</Button>
</div>
</CardContent>
</Card>
<Card className="min-h-[28rem] min-w-0">
<CardHeader>
<CardTitle>{t('agents.debugTranscript')}</CardTitle>
<CardDescription>
{t('agents.debugTranscriptDescription')}
</CardDescription>
</CardHeader>
<CardContent>
{entries.length === 0 ? (
<div className="flex min-h-72 items-center justify-center rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
{t('agents.debugEmptyTranscript')}
</div>
) : ( ) : (
<div className="max-h-[42rem] space-y-4 overflow-y-auto pr-1"> <Play className="size-4" />
{entries.map((entry) => (
<div
key={entry.id}
className={`rounded-lg border p-3 ${
entry.direction === 'output'
? 'border-primary/20 bg-primary/5'
: entry.direction === 'error'
? 'border-destructive/30 bg-destructive/5'
: 'bg-muted/40'
}`}
>
<div className="mb-2 flex items-center justify-between gap-2">
<Badge variant="outline">{entry.eventType}</Badge>
<span className="text-xs text-muted-foreground">
{entry.direction === 'output'
? t('agents.debugAgentOutput')
: entry.direction === 'error'
? t('common.error')
: t('agents.debugTestInput')}
</span>
</div>
<pre className="min-w-0 whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
{entry.text}
</pre>
</div>
))}
</div>
)} )}
</CardContent> {running
</Card> ? t('agents.debugRunning')
: hasUnsavedChanges
? t('agents.debugSaveAndRun')
: t('agents.debugRun')}
</Button>
</div>
</div> </div>
); );
} }
@@ -0,0 +1,173 @@
import { useMemo, useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import {
eventGroupLabel,
eventNamespaces,
eventPatternDescription,
eventPatternLabel,
groupEventPatterns,
} from '@/app/home/components/event-patterns/event-pattern-groups';
const FALLBACK_EVENTS = ['message.received'];
interface AgentEventPatternPickerProps {
events: string[];
value: string[];
onChange: (patterns: string[]) => void;
}
export default function AgentEventPatternPicker({
events,
value,
onChange,
}: AgentEventPatternPickerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const selectedPatterns = useMemo(
() => (value.length > 0 ? value : ['*']),
[value],
);
const options = useMemo(() => {
const concreteEvents = Array.from(
new Set([
...(events.length > 0 ? events : FALLBACK_EVENTS),
...selectedPatterns.filter(
(pattern) => pattern !== '*' && !pattern.endsWith('.*'),
),
]),
).sort();
const namespaces = Array.from(
new Set([
...eventNamespaces(concreteEvents),
...selectedPatterns.filter((pattern) => pattern.endsWith('.*')),
]),
).sort();
return ['*', ...namespaces, ...concreteEvents];
}, [events, selectedPatterns]);
const optionGroups = useMemo(() => groupEventPatterns(options), [options]);
function togglePattern(pattern: string) {
if (pattern === '*') {
onChange(['*']);
return;
}
if (selectedPatterns.includes(pattern)) {
const next = selectedPatterns.filter((item) => item !== pattern);
onChange(next.length > 0 ? next : ['*']);
return;
}
let next = selectedPatterns.filter((item) => item !== '*');
const namespace = pattern.split('.')[0];
if (pattern.endsWith('.*')) {
next = next.filter(
(item) => item.split('.')[0] !== namespace || item.endsWith('.*'),
);
} else {
next = next.filter((item) => item !== `${namespace}.*`);
}
onChange(Array.from(new Set([...next, pattern])));
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
aria-label={t('agents.supportedEvents')}
className="h-auto min-h-10 w-full min-w-0 justify-between gap-2 px-3 py-2 font-normal"
>
<span className="flex min-w-0 flex-1 flex-wrap gap-1.5">
{selectedPatterns.slice(0, 3).map((pattern) => (
<Badge
key={pattern}
variant="secondary"
className="max-w-full rounded-md font-normal"
>
<span className="truncate">
{eventPatternLabel(pattern, t)}
</span>
</Badge>
))}
{selectedPatterns.length > 3 && (
<Badge variant="outline" className="rounded-md font-normal">
+{selectedPatterns.length - 3}
</Badge>
)}
</span>
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
>
<Command>
<CommandInput placeholder={t('agents.searchEvents')} />
<CommandList>
<CommandEmpty>{t('agents.noEventsFound')}</CommandEmpty>
{optionGroups.map((group) => (
<CommandGroup
key={group.namespace}
heading={eventGroupLabel(group.namespace, t)}
>
{group.patterns.map((pattern) => {
const selected = selectedPatterns.includes(pattern);
return (
<CommandItem
key={pattern}
value={`${eventPatternLabel(pattern, t)} ${pattern}`}
onSelect={() => togglePattern(pattern)}
className="items-start gap-2 py-2"
>
<Check
className={cn(
'mt-0.5 size-4 shrink-0',
selected ? 'opacity-100' : 'opacity-0',
)}
/>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">
{eventPatternLabel(pattern, t)}
</span>
<code className="shrink-0 text-[10px] text-muted-foreground">
{pattern}
</code>
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{eventPatternDescription(pattern, t)}
</span>
</span>
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -1,19 +1,19 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import {
import { Link } from 'react-router-dom'; forwardRef,
type ForwardedRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import { Bot, SlidersHorizontal, Zap } from 'lucide-react';
CircleAlert,
CircleCheck,
LoaderCircle,
Power,
RefreshCw,
Trash2,
Unplug,
} from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
import { import {
@@ -22,11 +22,7 @@ import {
} from '@/app/infra/entities/pipeline'; } from '@/app/infra/entities/pipeline';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { Button } from '@/components/ui/button'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import EmojiPicker from '@/components/ui/emoji-picker';
import { import {
Card, Card,
CardContent, CardContent,
@@ -34,39 +30,85 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { import {
Form, Form,
FormControl,
FormDescription, FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import AgentEventPatternPicker from './AgentEventPatternPicker';
export interface AgentRunnerStatus {
label: string;
description?: string;
tone: 'neutral' | 'success' | 'warning' | 'error';
}
interface AgentFormComponentProps { interface AgentFormComponentProps {
agentId: string; agentId: string;
onFinish: () => void; availableEventTypes: string[];
onDeleted: () => void; onFinish: (agent?: Partial<Agent>) => void;
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void; onSavingChange?: (saving: boolean) => void;
onRunnerStatusChange?: (status: AgentRunnerStatus) => void;
onSupportedEventPatternsChange?: (patterns: string[]) => void;
} }
export default function AgentFormComponent({ export type AgentConfigSection = 'events' | 'runner' | 'runner_config';
agentId,
onFinish, export interface AgentFormHandle {
onDeleted, openSection: (section: AgentConfigSection) => void;
onDirtyChange, save: () => Promise<boolean>;
onSavingChange, syncBasicInfo: (values: {
}: AgentFormComponentProps) { name: string;
description: string;
emoji?: string;
}) => void;
}
function isRequiredRunnerValueMissing(value: unknown): boolean {
if (value === null || value === undefined) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object' && 'primary' in value) {
return !String((value as { primary?: unknown }).primary || '').trim();
}
return false;
}
function isRunnerFieldVisible(
field: PipelineConfigStage['config'][number],
values: Record<string, unknown>,
) {
if (!field.show_if || field.show_if.field.startsWith('__system.')) {
return true;
}
const dependentValue = values[field.show_if.field];
if (field.show_if.operator === 'eq') {
return dependentValue === field.show_if.value;
}
if (field.show_if.operator === 'neq') {
return dependentValue !== field.show_if.value;
}
return (
Array.isArray(field.show_if.value) &&
field.show_if.value.includes(dependentValue)
);
}
function AgentFormComponent(
{
agentId,
availableEventTypes,
onFinish,
onDirtyChange,
onSavingChange,
onRunnerStatusChange,
onSupportedEventPatternsChange,
}: AgentFormComponentProps,
ref: ForwardedRef<AgentFormHandle>,
) {
const { t } = useTranslation(); const { t } = useTranslation();
const [runnerConfigSchema, setRunnerConfigSchema] = const [runnerConfigSchema, setRunnerConfigSchema] =
useState<PipelineConfigTab | null>(null); useState<PipelineConfigTab | null>(null);
@@ -74,20 +116,20 @@ export default function AgentFormComponent({
useState<ApiRespPluginSystemStatus | null>(null); useState<ApiRespPluginSystemStatus | null>(null);
const [pluginStatusLoading, setPluginStatusLoading] = useState(true); const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
const [pluginStatusError, setPluginStatusError] = useState(false); const [pluginStatusError, setPluginStatusError] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [activeSection, setActiveSection] =
const [isSaving, setIsSaving] = useState(false); useState<AgentConfigSection>('runner');
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const hasUnsavedChangesRef = useRef(false);
const formSchema = z.object({ const formSchema = z.object({
basic: z.object({ basic: z.object({
name: z.string().min(1, { message: t('agents.nameRequired') }), name: z.string().min(1, { message: t('agents.nameRequired') }),
description: z.string().optional(), description: z.string().optional(),
emoji: z.string().optional(), emoji: z.string().optional(),
enabled: z.boolean().optional(),
}), }),
runner: z.record(z.string(), z.any()), runner: z.record(z.string(), z.any()),
runner_config: z.record(z.string(), z.any()), runner_config: z.record(z.string(), z.any()),
supported_event_patterns_text: z.string(), supported_event_patterns: z.array(z.string()).min(1),
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@@ -98,11 +140,10 @@ export default function AgentFormComponent({
name: '', name: '',
description: '', description: '',
emoji: '🤖', emoji: '🤖',
enabled: true,
}, },
runner: {}, runner: {},
runner_config: {}, runner_config: {},
supported_event_patterns_text: '*', supported_event_patterns: ['*'],
}, },
}); });
@@ -113,11 +154,17 @@ export default function AgentFormComponent({
if (!savedSnapshotRef.current) return false; if (!savedSnapshotRef.current) return false;
return JSON.stringify(watchedValues) !== savedSnapshotRef.current; return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
})(); })();
hasUnsavedChangesRef.current = hasUnsavedChanges;
useEffect(() => { useEffect(() => {
onDirtyChange?.(hasUnsavedChanges); onDirtyChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onDirtyChange]); }, [hasUnsavedChanges, onDirtyChange]);
const supportedEventPatterns = form.watch('supported_event_patterns');
useEffect(() => {
onSupportedEventPatternsChange?.(supportedEventPatterns);
}, [onSupportedEventPatternsChange, supportedEventPatterns]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)]) Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)])
@@ -131,15 +178,12 @@ export default function AgentFormComponent({
name: agent.name ?? '', name: agent.name ?? '',
description: agent.description ?? '', description: agent.description ?? '',
emoji: agent.emoji || '🤖', emoji: agent.emoji || '🤖',
enabled: agent.enabled ?? true,
}, },
runner: (config.runner as Record<string, unknown>) ?? {}, runner: (config.runner as Record<string, unknown>) ?? {},
runner_config: runner_config:
(config.runner_config as Record<string, unknown>) ?? {}, (config.runner_config as Record<string, unknown>) ?? {},
supported_event_patterns_text: ( supported_event_patterns: agent.supported_event_patterns ??
agent.supported_event_patterns ?? agent.capability?.supported_event_patterns ?? ['*'],
agent.capability?.supported_event_patterns ?? ['*']
).join('\n'),
}; };
form.reset(loadedValues); form.reset(loadedValues);
savedSnapshotRef.current = JSON.stringify(loadedValues); savedSnapshotRef.current = JSON.stringify(loadedValues);
@@ -182,117 +226,137 @@ export default function AgentFormComponent({
const selectedRunnerOption = runnerOptions.find( const selectedRunnerOption = runnerOptions.find(
(option) => option.name === currentRunner, (option) => option.name === currentRunner,
); );
const runnerSelectorStage = runnerConfigSchema?.stages.find(
(stage) => stage.name === 'runner',
);
const activeRunnerStage = runnerConfigSchema?.stages.find(
(stage) => stage.name === currentRunner,
);
const runnerConfigValues = form.watch('runner_config') as Record<
string,
Record<string, unknown>
>;
const activeRunnerValues = useMemo(
() => runnerConfigValues?.[currentRunner] ?? {},
[currentRunner, runnerConfigValues],
);
const missingRunnerFields = useMemo(
() =>
(activeRunnerStage?.config ?? []).filter(
(field) =>
field.required &&
isRunnerFieldVisible(field, activeRunnerValues) &&
isRequiredRunnerValueMissing(activeRunnerValues[field.name]),
),
[activeRunnerStage, activeRunnerValues],
);
const primarySections: Array<{
name: AgentConfigSection;
label: string;
icon: React.ElementType;
}> = [
{
name: 'runner',
label: t('agents.runnerSettings'),
icon: Bot,
},
{
name: 'runner_config',
label: selectedRunnerOption
? extractI18nObject(selectedRunnerOption.label)
: t('pipelines.configuration'),
icon: SlidersHorizontal,
},
{
name: 'events',
label: t('agents.bindableEvents'),
icon: Zap,
},
];
function renderRunnerStatusActions(showRetry = true) { const runnerStatus = useMemo<AgentRunnerStatus>(() => {
return (
<div className="mt-3 flex flex-wrap gap-2">
{showRetry && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void loadPluginSystemStatus()}
>
<RefreshCw className="size-4" />
{t('common.retry')}
</Button>
)}
<Button type="button" variant="outline" size="sm" asChild>
<Link to="/home/extensions">{t('plugins.title')}</Link>
</Button>
</div>
);
}
function renderRunnerStatus() {
if (pluginStatusLoading) { if (pluginStatusLoading) {
return ( return {
<Alert> label: t('agents.runnerStatusLoading'),
<LoaderCircle className="animate-spin" /> tone: 'neutral',
<AlertTitle>{t('agents.runnerStatusLoading')}</AlertTitle> };
</Alert>
);
} }
if (pluginStatusError || !pluginSystemStatus) { if (pluginStatusError || !pluginSystemStatus) {
return ( return {
<Alert variant="destructive"> label: t('agents.runnerStatusCheckFailed'),
<CircleAlert /> description: t('agents.runnerStatusCheckFailedDescription'),
<AlertTitle>{t('agents.runnerStatusCheckFailed')}</AlertTitle> tone: 'error',
<AlertDescription> };
{t('agents.runnerStatusCheckFailedDescription')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
} }
if (!pluginSystemStatus.is_enable) { if (!pluginSystemStatus.is_enable) {
return ( return {
<Alert variant="destructive"> label: t('plugins.systemDisabled'),
<Power /> description: t('plugins.systemDisabledDesc'),
<AlertTitle>{t('plugins.systemDisabled')}</AlertTitle> tone: 'error',
<AlertDescription> };
{t('plugins.systemDisabledDesc')}
{renderRunnerStatusActions(false)}
</AlertDescription>
</Alert>
);
} }
if (!pluginSystemStatus.is_connected) { if (!pluginSystemStatus.is_connected) {
return ( return {
<Alert variant="destructive"> label: t('plugins.connectionError'),
<Unplug /> description: t('plugins.connectionErrorDesc'),
<AlertTitle>{t('plugins.connectionError')}</AlertTitle> tone: 'error',
<AlertDescription> };
{t('plugins.connectionErrorDesc')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
} }
if (runnerOptions.length === 0) { if (runnerOptions.length === 0) {
return ( return {
<Alert variant="destructive"> label: t('agents.noRunnersAvailable'),
<CircleAlert /> description: t('agents.noRunnersAvailableDescription'),
<AlertTitle>{t('agents.noRunnersAvailable')}</AlertTitle> tone: 'error',
<AlertDescription> };
{t('agents.noRunnersAvailableDescription')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
} }
if (!currentRunner || !selectedRunnerOption) { if (!currentRunner || !selectedRunnerOption) {
return ( return {
<Alert variant="destructive"> label: t('agents.selectedRunnerUnavailable'),
<CircleAlert /> description: t('agents.selectedRunnerUnavailableDescription', {
<AlertTitle>{t('agents.selectedRunnerUnavailable')}</AlertTitle> runner: currentRunner || t('agents.noRunnerSelected'),
<AlertDescription> }),
{t('agents.selectedRunnerUnavailableDescription', { tone: 'warning',
runner: currentRunner || t('agents.noRunnerSelected'), };
})}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
} }
return ( if (missingRunnerFields.length > 0) {
<Alert className="border-emerald-600/40 bg-emerald-500/5 text-emerald-950 dark:text-emerald-100"> return {
<CircleCheck className="text-emerald-600" /> label: t('agents.runnerConfigIncomplete'),
<AlertTitle>{t('agents.runnerReady')}</AlertTitle> description: t('agents.runnerConfigIncompleteDescription', {
<AlertDescription> fields: missingRunnerFields
{t('agents.runnerReadyDescription', { .map((field) => extractI18nObject(field.label))
runner: extractI18nObject(selectedRunnerOption.label), .join(', '),
})} }),
</AlertDescription> tone: 'warning',
</Alert> };
); }
}
return {
label: t('agents.runnerReady'),
description: t('agents.runnerReadyDescription', {
runner: extractI18nObject(selectedRunnerOption.label),
}),
tone: 'success',
};
}, [
currentRunner,
pluginStatusError,
pluginStatusLoading,
pluginSystemStatus,
runnerOptions.length,
missingRunnerFields,
selectedRunnerOption,
t,
]);
useEffect(() => {
onRunnerStatusChange?.(runnerStatus);
}, [onRunnerStatusChange, runnerStatus]);
function updateSnapshotIfInitial(stageKey: string) { function updateSnapshotIfInitial(stageKey: string) {
if (!initializedStagesRef.current.has(stageKey)) { if (!initializedStagesRef.current.has(stageKey)) {
@@ -364,208 +428,125 @@ export default function AgentFormComponent({
); );
} }
function normalizeEventPatterns(value: string): string[] { const saveValues = useCallback(
const patterns = value async (values: FormValues) => {
.split(/[\n,]/) if (isSavingRef.current) return false;
.map((item) => item.trim()) const submittedSnapshot = JSON.stringify(values);
.filter(Boolean); const runner = values.runner || {};
return patterns.length > 0 ? patterns : ['*']; const agent: Partial<Agent> = {
} name: values.basic.name,
description: values.basic.description ?? '',
emoji: values.basic.emoji,
component_ref: (runner.id as string) || null,
supported_event_patterns: values.supported_event_patterns,
config: {
runner,
runner_config: values.runner_config ?? {},
},
};
isSavingRef.current = true;
onSavingChange?.(true);
try {
await httpClient.updateAgent(agentId, agent);
savedSnapshotRef.current = submittedSnapshot;
onFinish(agent);
toast.success(t('agents.saveSuccess'));
return true;
} catch (err) {
const message =
typeof err === 'object' && err && 'msg' in err
? String((err as { msg?: string }).msg || '')
: '';
toast.error(t('agents.saveError') + message);
return false;
} finally {
isSavingRef.current = false;
onSavingChange?.(false);
}
},
[agentId, onFinish, onSavingChange, t],
);
function handleSubmit(values: FormValues) { function handleSubmit(values: FormValues) {
if (isSavingRef.current) return; void saveValues(values);
const submittedSnapshot = JSON.stringify(values); }
const runner = values.runner || {};
const agent: Partial<Agent> = { useImperativeHandle(
name: values.basic.name, ref,
description: values.basic.description ?? '', () => ({
emoji: values.basic.emoji, openSection: setActiveSection,
enabled: values.basic.enabled ?? true, syncBasicInfo(values) {
component_ref: (runner.id as string) || null, form.setValue('basic', {
supported_event_patterns: normalizeEventPatterns( ...form.getValues('basic'),
values.supported_event_patterns_text, name: values.name,
), description: values.description,
config: { emoji: values.emoji || '🤖',
runner, });
runner_config: values.runner_config ?? {}, if (savedSnapshotRef.current) {
const snapshot = JSON.parse(savedSnapshotRef.current) as FormValues;
snapshot.basic = {
...snapshot.basic,
name: values.name,
description: values.description,
emoji: values.emoji || '🤖',
};
savedSnapshotRef.current = JSON.stringify(snapshot);
}
}, },
}; async save() {
if (!hasUnsavedChangesRef.current) return true;
isSavingRef.current = true; if (isSavingRef.current) return false;
setIsSaving(true); const valid = await form.trigger();
onSavingChange?.(true); if (!valid) return false;
httpClient return (await saveValues(form.getValues())) ?? false;
.updateAgent(agentId, agent) },
.then(() => { }),
savedSnapshotRef.current = submittedSnapshot; [form, saveValues],
onFinish(); );
toast.success(t('agents.saveSuccess'));
})
.catch((err) => {
toast.error(t('agents.saveError') + err.msg);
})
.finally(() => {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
});
}
function confirmDelete() {
httpClient
.deleteAgent(agentId)
.then(() => {
toast.success(t('agents.deleteSuccess'));
setShowDeleteConfirm(false);
onDeleted();
})
.catch((err) => {
toast.error(t('agents.deleteError') + err.msg);
});
}
return ( return (
<> <div className="h-full p-0 flex flex-col">
<div className="h-full p-0 flex flex-col"> <Form {...form}>
<Form {...form}> <form
<form id="agent-form"
id="agent-form" onSubmit={form.handleSubmit(handleSubmit)}
onSubmit={form.handleSubmit(handleSubmit)} className="mb-2 flex h-full min-h-0 min-w-0 flex-1 flex-col"
className="mb-2 flex h-full min-h-0 min-w-0 flex-1 flex-col" >
> <nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <Tabs
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"> value={activeSection}
<div className="mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-6 pb-8"> onValueChange={(value) =>
{ setActiveSection(value as AgentConfigSection)
<div className="contents"> }
<Card className="order-2"> >
<CardHeader> <div className="min-w-0">
<CardTitle>{t('agents.basicInfo')}</CardTitle> <TabsList className="grid h-auto w-full min-w-0 grid-cols-3">
<CardDescription> {primarySections.map((section) => {
{t('agents.basicInfoDescription')} const Icon = section.icon;
</CardDescription> return (
</CardHeader> <TabsTrigger
<CardContent className="space-y-4"> key={section.name}
<div className="flex gap-4 items-start"> value={section.name}
<FormField className="min-w-0 gap-1.5 px-2"
control={form.control} >
name="basic.name" <Icon className="size-4 shrink-0" />
render={({ field }) => ( <span className="truncate">{section.label}</span>
<FormItem className="flex-1"> </TabsTrigger>
<FormLabel> );
{t('common.name')} })}
<span className="text-destructive">*</span> </TabsList>
</FormLabel> </div>
<FormControl> </Tabs>
<Input </nav>
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField <div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
control={form.control} <div className="mx-auto w-full min-w-0 max-w-5xl space-y-6 pb-8">
name="basic.description" {activeSection === 'runner' && (
render={({ field }) => ( <div className="space-y-6">
<FormItem> {runnerSelectorStage
<FormLabel>{t('common.description')}</FormLabel> ? renderDynamicStage(runnerSelectorStage)
<FormControl> : !runnerConfigSchema && (
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="flex items-center gap-2">
<Power className="size-4" />
{t('agents.enabled')}
</FormLabel>
<FormDescription>
{t('agents.enabledDescription')}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value ?? true}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
<Card className="order-4 border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('agents.dangerZone')}
</CardTitle>
<CardDescription>
{t('agents.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('agents.deleteAgentAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('agents.deleteAgentHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isSaving}
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</div>
}
{
<div className="order-1 space-y-6">
{renderRunnerStatus()}
{runnerConfigSchema?.stages.map((stage) =>
renderDynamicStage(stage),
)}
{!runnerConfigSchema && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('agents.runnerSettings')}</CardTitle> <CardTitle>{t('agents.runnerSettings')}</CardTitle>
@@ -575,69 +556,61 @@ export default function AgentFormComponent({
</CardHeader> </CardHeader>
</Card> </Card>
)} )}
</div> </div>
} )}
{ {activeSection === 'runner_config' && (
<Card className="order-3"> <div className="space-y-6">
{activeRunnerStage ? (
renderDynamicStage(activeRunnerStage)
) : (
<Card>
<CardHeader> <CardHeader>
<CardTitle>{t('agents.bindableEvents')}</CardTitle> <CardTitle>{t('agents.runnerSettings')}</CardTitle>
<CardDescription> <CardDescription>
{t('agents.bindableEventsDescription')} {t('agents.noRunnerMetadata')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent>
<FormField
control={form.control}
name="supported_event_patterns_text"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('agents.supportedEvents')}
</FormLabel>
<FormControl>
<Textarea
{...field}
className="min-h-32 font-mono text-sm"
placeholder={'*\nmessage.received\ngroup.*'}
/>
</FormControl>
<FormDescription>
{t('agents.supportedEventsDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card> </Card>
} )}
</div> </div>
</div> )}
</div>
</form>
</Form>
</div>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> {activeSection === 'events' && (
<DialogContent> <Card>
<DialogHeader> <CardHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle> <CardTitle>{t('agents.bindableEvents')}</CardTitle>
</DialogHeader> <CardDescription>
<div className="py-4">{t('agents.deleteConfirmation')}</div> {t('agents.bindableEventsDescription')}
<DialogFooter> </CardDescription>
<Button </CardHeader>
variant="outline" <CardContent>
onClick={() => setShowDeleteConfirm(false)} <FormField
> control={form.control}
{t('common.cancel')} name="supported_event_patterns"
</Button> render={({ field }) => (
<Button variant="destructive" onClick={confirmDelete}> <FormItem>
{t('common.confirmDelete')} <AgentEventPatternPicker
</Button> events={availableEventTypes}
</DialogFooter> value={field.value}
</DialogContent> onChange={field.onChange}
</Dialog> />
</> <FormDescription>
{t('agents.supportedEventsDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
)}
</div>
</div>
</form>
</Form>
</div>
); );
} }
export default forwardRef(AgentFormComponent);
@@ -0,0 +1,378 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import type { AgentKind } from '@/app/infra/entities/api';
const LANGBOT_BLUE = '#2288ee';
const LANGBOT_CYAN = '#19b8c9';
function DiagramMotionStyles() {
return (
<style>{`
@keyframes processor-line-flow {
to { stroke-dashoffset: -30; }
}
@keyframes processor-link-breathe {
0%, 100% { stroke-opacity: var(--processor-relation-opacity-min); }
50% { stroke-opacity: var(--processor-relation-opacity-max); }
}
.processor-diagram {
--processor-blue-node-fill: color-mix(in srgb, ${LANGBOT_BLUE} 7%, var(--card));
--processor-cyan-node-fill: color-mix(in srgb, ${LANGBOT_CYAN} 7%, var(--card));
--processor-node-stroke-opacity: 0.18;
--processor-flow-opacity: 0.34;
--processor-relation-opacity-min: 0.28;
--processor-relation-opacity-max: 0.72;
--processor-capability-fill: color-mix(in srgb, ${LANGBOT_CYAN} 4%, var(--card));
--processor-capability-stroke-opacity: 0.45;
--processor-icon-fill: color-mix(in srgb, ${LANGBOT_CYAN} 10%, var(--card));
}
.dark .processor-diagram {
--processor-blue-node-fill: color-mix(in srgb, ${LANGBOT_BLUE} 17%, var(--card));
--processor-cyan-node-fill: color-mix(in srgb, ${LANGBOT_CYAN} 15%, var(--card));
--processor-node-stroke-opacity: 0.5;
--processor-flow-opacity: 0.72;
--processor-relation-opacity-min: 0.58;
--processor-relation-opacity-max: 1;
--processor-capability-fill: color-mix(in srgb, ${LANGBOT_CYAN} 10%, var(--card));
--processor-capability-stroke-opacity: 0.78;
--processor-icon-fill: color-mix(in srgb, ${LANGBOT_CYAN} 20%, var(--card));
}
.processor-line-flow {
animation: processor-line-flow 1.8s linear infinite;
}
.processor-link-breathe {
animation: processor-link-breathe 2.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.processor-line-flow,
.processor-link-breathe {
animation: none;
}
}
`}</style>
);
}
function ArrowMarker({ id }: { id: string }) {
return (
<defs>
<marker
id={id}
markerWidth="8"
markerHeight="8"
refX="7"
refY="4"
orient="auto"
>
<path d="M0 0 8 4 0 8Z" fill={LANGBOT_BLUE} />
</marker>
</defs>
);
}
function CapabilityIcon({
kind,
x,
y,
}: {
kind: 'model' | 'tool' | 'action';
x: number;
y: number;
}) {
return (
<g
transform={`translate(${x - 9} ${y - 9})`}
fill="none"
stroke={LANGBOT_CYAN}
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
{kind === 'model' && (
<>
<path d="m7 1 1.4 4.1L12.5 6.5 8.4 8 7 12 5.6 8 1.5 6.5l4.1-1.4L7 1Z" />
<path d="m14.5 10 .7 2.3 2.3.7-2.3.8-.7 2.2-.8-2.2-2.2-.8 2.2-.7.8-2.3Z" />
</>
)}
{kind === 'tool' && (
<path d="M12.8 2.1a4.1 4.1 0 0 0-5.2 5.2L2 12.9a2.1 2.1 0 1 0 3 3l5.6-5.6a4.1 4.1 0 0 0 5.2-5.2l-2.7 2.7-2.8-2.8 2.5-2.9Z" />
)}
{kind === 'action' && (
<path d="M10.5 1 3 10.5h6L8 17l7.5-9.5h-6L10.5 1Z" />
)}
</g>
);
}
function AgentDiagram() {
const { t } = useTranslation();
const arrowId = `agent-arrow-${useId().replace(/:/g, '')}`;
const inputs = [
{ label: t('agents.diagramMessages'), y: 155 },
{ label: t('agents.diagramMembers'), y: 275 },
{ label: t('agents.diagramFeedback'), y: 395 },
];
const outputs = [
{ kind: 'model' as const, label: t('agents.diagramModel'), y: 155 },
{ kind: 'tool' as const, label: t('agents.diagramTools'), y: 275 },
{ kind: 'action' as const, label: t('agents.diagramActions'), y: 395 },
];
return (
<svg
viewBox="0 0 760 620"
role="img"
aria-label={t('agents.agentDiagramTitle')}
className="processor-diagram h-full w-full"
data-testid="agent-diagram"
>
<desc>{t('agents.agentDiagramDescription')}</desc>
<ArrowMarker id={arrowId} />
<DiagramMotionStyles />
<rect width="760" height="620" fill="var(--card)" />
<text
x="74"
y="112"
fill="var(--muted-foreground)"
fontSize="13"
fontWeight="600"
>
{t('agents.diagramEvents')}
</text>
{inputs.map((item, index) => (
<g key={item.label}>
<path
d={`M222 ${item.y + 29} C286 ${item.y + 29} 292 310 324 310`}
fill="none"
stroke={LANGBOT_BLUE}
strokeWidth="2"
strokeDasharray="4 11"
opacity="var(--processor-flow-opacity)"
markerEnd={`url(#${arrowId})`}
className="processor-line-flow"
data-motion="flow"
style={{ animationDelay: `${index * -0.35}s` }}
/>
<rect
x="64"
y={item.y}
width="158"
height="58"
rx="14"
fill="var(--processor-blue-node-fill)"
stroke={LANGBOT_BLUE}
strokeOpacity="var(--processor-node-stroke-opacity)"
/>
<circle
cx="91"
cy={item.y + 29}
r="5"
fill={index === 1 ? LANGBOT_CYAN : LANGBOT_BLUE}
/>
<text
x="110"
y={item.y + 34}
fill="var(--foreground)"
fontSize="14"
fontWeight="550"
>
{item.label}
</text>
</g>
))}
<rect
x="324"
y="278"
width="112"
height="64"
rx="16"
fill={LANGBOT_BLUE}
/>
<text
x="380"
y="317"
textAnchor="middle"
fill="var(--primary-foreground)"
fontSize="18"
fontWeight="700"
>
{t('agents.agentType')}
</text>
<text
x="538"
y="112"
fill="var(--muted-foreground)"
fontSize="13"
fontWeight="600"
>
{t('agents.diagramAgentCanUse')}
</text>
{outputs.map((item, index) => (
<g key={item.label}>
<path
d={`M436 310 C468 310 474 ${item.y + 29} 538 ${item.y + 29}`}
fill="none"
stroke={LANGBOT_CYAN}
strokeWidth="1.8"
strokeDasharray="5 6"
className="processor-link-breathe"
data-motion="relation"
style={{ animationDelay: `${index * -0.45}s` }}
/>
<rect
x="538"
y={item.y}
width="158"
height="58"
rx="14"
fill="var(--processor-capability-fill)"
stroke={LANGBOT_CYAN}
strokeOpacity="var(--processor-capability-stroke-opacity)"
strokeWidth="1.5"
/>
<circle
cx="565"
cy={item.y + 29}
r="11"
fill="var(--processor-icon-fill)"
stroke={LANGBOT_CYAN}
strokeOpacity="var(--processor-capability-stroke-opacity)"
/>
<CapabilityIcon kind={item.kind} x={565} y={item.y + 29} />
<text
x="584"
y={item.y + 34}
fill="var(--foreground)"
fontSize="14"
fontWeight="550"
>
{item.label}
</text>
</g>
))}
</svg>
);
}
function PipelineDiagram() {
const { t } = useTranslation();
const arrowId = `pipeline-arrow-${useId().replace(/:/g, '')}`;
const steps = [
t('agents.diagramMessage'),
t('agents.diagramPreprocess'),
t('agents.diagramAI'),
t('agents.diagramPostprocess'),
t('agents.diagramOutput'),
];
return (
<svg
viewBox="0 0 760 620"
role="img"
aria-label={t('agents.pipelineDiagramTitle')}
className="processor-diagram h-full w-full"
data-testid="pipeline-diagram"
>
<desc>{t('agents.pipelineDiagramDescription')}</desc>
<ArrowMarker id={arrowId} />
<DiagramMotionStyles />
<rect width="760" height="620" fill="var(--card)" />
<text
x="40"
y="238"
fill="var(--muted-foreground)"
fontSize="13"
fontWeight="600"
>
{t('agents.pipelineDiagramFlow')}
</text>
<path
d="M156 310H738"
fill="none"
stroke={LANGBOT_BLUE}
strokeWidth="2"
opacity="var(--processor-flow-opacity)"
markerEnd={`url(#${arrowId})`}
/>
<path
d="M156 310H724"
fill="none"
stroke={LANGBOT_BLUE}
strokeWidth="3"
strokeDasharray="4 11"
strokeLinecap="round"
className="processor-line-flow"
data-motion="flow"
data-dash-cycle="15"
/>
{steps.map((step, index) => {
const x = 40 + index * 142;
const active = index === 2;
return (
<g key={step}>
<rect
x={x}
y="281"
width="116"
height="58"
rx="14"
fill={
active
? LANGBOT_BLUE
: index === 3
? 'var(--processor-cyan-node-fill)'
: 'var(--processor-blue-node-fill)'
}
stroke={
active
? LANGBOT_BLUE
: index === 3
? LANGBOT_CYAN
: LANGBOT_BLUE
}
strokeOpacity={
active ? 1 : 'var(--processor-node-stroke-opacity)'
}
/>
{!active && (
<circle
cx={x + 25}
cy="310"
r="5"
fill={index === 3 ? LANGBOT_CYAN : LANGBOT_BLUE}
/>
)}
<text
x={active ? x + 58 : x + 44}
y="315"
textAnchor={active ? 'middle' : 'start'}
fill={active ? 'var(--primary-foreground)' : 'var(--foreground)'}
fontSize="13"
fontWeight="600"
>
{step}
</text>
</g>
);
})}
</svg>
);
}
export default function ProcessorTypeDiagram({ kind }: { kind: AgentKind }) {
return kind === 'agent' ? <AgentDiagram /> : <PipelineDiagram />;
}
+59 -14
View File
@@ -19,7 +19,9 @@ import {
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import BotForm from '@/app/home/bots/components/bot-form/BotForm'; import BotForm, {
BotFormHandle,
} from '@/app/home/bots/components/bot-form/BotForm';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent'; import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor'; import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor'; import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor';
@@ -30,6 +32,11 @@ import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useCurrentWorkspace } from '@/app/infra/http'; import { useCurrentWorkspace } from '@/app/infra/http';
import { Bot } from '@/app/infra/entities/api';
import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
export default function BotDetailContent({ id }: { id: string }) { export default function BotDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new'; const isCreateMode = id === 'new';
@@ -55,8 +62,11 @@ export default function BotDetailContent({ id }: { id: string }) {
const [activeTab, setActiveTab] = useState('config'); const [activeTab, setActiveTab] = useState('config');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [bot, setBot] = useState<Bot | null>(null);
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false); const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null); const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
const botFormRef = useRef<BotFormHandle>(null);
// Track whether the form has unsaved changes // Track whether the form has unsaved changes
const [formDirty, setFormDirty] = useState(false); const [formDirty, setFormDirty] = useState(false);
@@ -69,6 +79,7 @@ export default function BotDetailContent({ id }: { id: string }) {
useEffect(() => { useEffect(() => {
if (!isCreateMode) { if (!isCreateMode) {
httpClient.getBot(id).then((res) => { httpClient.getBot(id).then((res) => {
setBot(res.bot);
setBotEnabled(res.bot.enable ?? true); setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true); setEnableLoaded(true);
}); });
@@ -80,16 +91,10 @@ export default function BotDetailContent({ id }: { id: string }) {
const prev = botEnabled; const prev = botEnabled;
setBotEnabled(checked); setBotEnabled(checked);
try { try {
// Fetch current bot data to send a complete update await httpClient.updateBot(id, { enable: checked });
const res = await httpClient.getBot(id); setBot((current) =>
const bot = res.bot; current ? { ...current, enable: checked } : current,
await httpClient.updateBot(id, { );
name: bot.name,
description: bot.description,
adapter: bot.adapter,
adapter_config: bot.adapter_config,
enable: checked,
});
refreshBots(); refreshBots();
} catch { } catch {
setBotEnabled(prev); setBotEnabled(prev);
@@ -102,6 +107,7 @@ export default function BotDetailContent({ id }: { id: string }) {
function handleFormSubmit() { function handleFormSubmit() {
// Re-sync enable state after form save (form may update enable too) // Re-sync enable state after form save (form may update enable too)
httpClient.getBot(id).then((res) => { httpClient.getBot(id).then((res) => {
setBot(res.bot);
setBotEnabled(res.bot.enable ?? true); setBotEnabled(res.bot.enable ?? true);
}); });
refreshBots(); refreshBots();
@@ -117,6 +123,26 @@ export default function BotDetailContent({ id }: { id: string }) {
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`); navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
} }
async function saveBasicInfo(values: EntityBasicInfoValues) {
try {
await httpClient.updateBot(id, {
name: values.name,
description: values.description,
});
setBot((current) => (current ? { ...current, ...values } : current));
botFormRef.current?.syncBasicInfo(values);
await refreshBots();
toast.success(t('bots.saveSuccess'));
} catch (error) {
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('bots.saveError') + message);
throw error;
}
}
function confirmDelete() { function confirmDelete() {
httpClient httpClient
.deleteBot(id) .deleteBot(id)
@@ -166,8 +192,15 @@ export default function BotDetailContent({ id }: { id: string }) {
<div className="flex h-full min-w-0 flex-col"> <div className="flex h-full min-w-0 flex-col">
{/* Sticky Header: title + enable switch + save button */} {/* Sticky Header: title + enable switch + save button */}
<div className="flex items-center justify-between pb-4 shrink-0"> <div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex items-center gap-4"> <div className="flex min-w-0 items-center gap-4">
<h1 className="text-xl font-semibold">{t('bots.editBot')}</h1> <div className="flex min-w-0 items-center gap-1">
<h1 className="truncate text-xl font-semibold">
{bot?.name || t('bots.editBot')}
</h1>
{canManage && (
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
)}
</div>
{enableLoaded && ( {enableLoaded && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
@@ -255,9 +288,10 @@ export default function BotDetailContent({ id }: { id: string }) {
value="config" value="config"
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden" className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
> >
<div className="mx-auto w-full min-w-0 max-w-3xl space-y-6 pb-8"> <div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-6 pb-8">
<fieldset className="contents" disabled={!canManage}> <fieldset className="contents" disabled={!canManage}>
<BotForm <BotForm
ref={botFormRef}
initBotId={id} initBotId={id}
onFormSubmit={handleFormSubmit} onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated} onNewBotCreated={handleNewBotCreated}
@@ -344,6 +378,17 @@ export default function BotDetailContent({ id }: { id: string }) {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<EntityBasicInfoDialog
open={basicInfoOpen}
onOpenChange={setBasicInfoOpen}
values={{
name: bot?.name || '',
description: bot?.description || '',
}}
showEmoji={false}
onSave={saveBasicInfo}
/>
</> </>
); );
} }
@@ -0,0 +1,335 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Activity,
AlertCircle,
ChevronDown,
RadioTower,
Trash2,
} from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { backendClient } from '@/app/infra/http';
import type { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import {
eventPatternDescription,
eventPatternLabel,
} from '@/app/home/components/event-patterns/event-pattern-groups';
const POLL_INTERVAL_MS = 1200;
const MAX_VISIBLE_EVENTS = 50;
interface ObservedAdapterEvent {
seqId: number;
timestamp: number;
eventType: string;
eventData: Record<string, unknown>;
}
type ListenerState = 'preparing' | 'listening' | 'error';
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function observedEventFromLog(log: BotLog): ObservedAdapterEvent | null {
const metadata = log.metadata;
if (!isRecord(metadata) || metadata.kind !== 'adapter_event_received') {
return null;
}
const eventType = metadata.event_type;
if (typeof eventType !== 'string' || !eventType) return null;
return {
seqId: log.seq_id,
timestamp: log.timestamp,
eventType,
eventData: isRecord(metadata.event_data) ? metadata.event_data : {},
};
}
function findEventPreview(value: unknown, depth = 0): string | null {
if (depth > 4 || value === null || value === undefined) return null;
if (Array.isArray(value)) {
for (const item of value) {
const preview = findEventPreview(item, depth + 1);
if (preview) return preview;
}
return null;
}
if (!isRecord(value)) return null;
for (const key of ['message_text', 'text', 'action']) {
const candidate = value[key];
if (typeof candidate === 'string' && candidate.trim()) {
const trimmed = candidate.trim();
return trimmed.length > 160 ? `${trimmed.slice(0, 160)}` : trimmed;
}
}
for (const child of Object.values(value)) {
const preview = findEventPreview(child, depth + 1);
if (preview) return preview;
}
return null;
}
export default function AdapterEventDebugDialog({
botId,
adapterLabel,
}: {
botId?: string;
adapterLabel: string;
}) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const [listenerState, setListenerState] =
useState<ListenerState>('preparing');
const [events, setEvents] = useState<ObservedAdapterEvent[]>([]);
const baselineSeqRef = useRef<number | null>(null);
const pollInFlightRef = useRef(false);
const platformName = adapterLabel || t('bots.adapterEventCurrentPlatform');
const pollLogs = useCallback(async () => {
if (!botId || pollInFlightRef.current) return;
pollInFlightRef.current = true;
try {
const response = await backendClient.getBotLogs(botId, {
from_index: -1,
max_count: 100,
});
const latestSeq = response.logs.reduce(
(maximum, log) => Math.max(maximum, log.seq_id),
-1,
);
if (baselineSeqRef.current === null) {
baselineSeqRef.current = latestSeq;
setListenerState('listening');
return;
}
const baseline = baselineSeqRef.current;
const newlyObserved = response.logs
.filter((log) => log.seq_id > baseline)
.map(observedEventFromLog)
.filter((event): event is ObservedAdapterEvent => event !== null);
if (newlyObserved.length > 0) {
setEvents((current) => {
const bySeqId = new Map(
[...newlyObserved, ...current].map((event) => [event.seqId, event]),
);
return Array.from(bySeqId.values())
.sort((left, right) => right.seqId - left.seqId)
.slice(0, MAX_VISIBLE_EVENTS);
});
}
baselineSeqRef.current = Math.max(baseline, latestSeq);
setListenerState('listening');
} catch {
setListenerState('error');
} finally {
pollInFlightRef.current = false;
}
}, [botId]);
useEffect(() => {
if (!open || !botId) return;
baselineSeqRef.current = null;
pollInFlightRef.current = false;
setEvents([]);
setListenerState('preparing');
void pollLogs();
const interval = window.setInterval(
() => void pollLogs(),
POLL_INTERVAL_MS,
);
return () => window.clearInterval(interval);
}, [botId, open, pollLogs]);
const status = useMemo(() => {
if (listenerState === 'error') {
return {
text: t('bots.adapterEventListenerUnavailable'),
dot: 'bg-destructive',
};
}
if (listenerState === 'listening') {
return {
text: t('bots.adapterEventListening'),
dot: 'bg-emerald-500',
};
}
return {
text: t('bots.adapterEventPreparing'),
dot: 'bg-amber-500',
};
}, [listenerState, t]);
return (
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={!botId}
onClick={() => setOpen(true)}
title={!botId ? t('bots.adapterEventNeedsSavedBot') : undefined}
>
<RadioTower className="mr-1 h-4 w-4" />
{t('bots.adapterEventDebugAction')}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<div className="flex flex-wrap items-center gap-2 pr-8">
<DialogTitle>{t('bots.adapterEventDebugTitle')}</DialogTitle>
<Badge variant="outline" className="gap-1.5 font-normal">
<span className={`size-2 rounded-full ${status.dot}`} />
{status.text}
</Badge>
</div>
<DialogDescription>
{t('bots.adapterEventDebugDescription', {
platform: platformName,
})}
</DialogDescription>
</DialogHeader>
<Alert className="bg-muted/30">
<Activity className="h-4 w-4" />
<AlertDescription>
{t('bots.adapterEventObserveOnly')}
</AlertDescription>
</Alert>
{listenerState === 'error' && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t('bots.adapterEventLoadFailed')}
</AlertDescription>
</Alert>
)}
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">
{t('bots.adapterEventReceivedCount', { count: events.length })}
</p>
<Button
type="button"
variant="ghost"
size="sm"
disabled={events.length === 0}
onClick={() => setEvents([])}
>
<Trash2 className="mr-1 h-4 w-4" />
{t('bots.adapterEventClear')}
</Button>
</div>
<ScrollArea className="h-[min(52vh,420px)] rounded-lg border">
{events.length === 0 ? (
<div className="flex h-full min-h-64 flex-col items-center justify-center px-6 text-center">
<RadioTower className="mb-3 h-8 w-8 text-muted-foreground" />
<p className="font-medium">
{t('bots.adapterEventEmptyTitle')}
</p>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
{t('bots.adapterEventEmptyDescription', {
platform: platformName,
})}
</p>
</div>
) : (
<div className="space-y-3 p-3">
{events.map((event) => {
const preview = findEventPreview(event.eventData);
return (
<Card key={event.seqId} className="gap-0 py-0">
<CardContent className="p-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">
{eventPatternLabel(event.eventType, t)}
</p>
<code className="mt-1 block truncate text-xs text-muted-foreground">
{event.eventType}
</code>
</div>
<time className="shrink-0 text-xs text-muted-foreground">
{new Date(
event.timestamp * 1000,
).toLocaleTimeString(i18n.language, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</time>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{preview ||
eventPatternDescription(event.eventType, t)}
</p>
<Collapsible className="mt-3">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs text-muted-foreground"
>
{t('bots.adapterEventData')}
<ChevronDown className="ml-1 h-3.5 w-3.5" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed">
{JSON.stringify(event.eventData, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
})}
</div>
)}
</ScrollArea>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,4 +1,11 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import i18n from 'i18next'; import i18n from 'i18next';
import { IChooseAdapterEntity } from '@/app/home/bots/components/bot-form/ChooseEntity'; import { IChooseAdapterEntity } from '@/app/home/bots/components/bot-form/ChooseEntity';
import { import {
@@ -15,6 +22,7 @@ import { Agent, Bot } from '@/app/infra/entities/api';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react'; import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
import EventBindingsEditor from './EventBindingsEditor'; import EventBindingsEditor from './EventBindingsEditor';
import AdapterEventDebugDialog from './AdapterEventDebugDialog';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -79,17 +87,21 @@ const getFormSchema = (t: (key: string) => string) =>
.optional(), .optional(),
}); });
export default function BotForm({ export interface BotFormHandle {
initBotId, syncBasicInfo: (values: { name: string; description: string }) => void;
onFormSubmit, }
onNewBotCreated,
onDirtyChange, interface BotFormProps {
}: {
initBotId?: string; initBotId?: string;
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void; onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
onNewBotCreated: (botId: string) => void; onNewBotCreated: (botId: string) => void;
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
}) { }
const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
{ initBotId, onFormSubmit, onNewBotCreated, onDirtyChange },
ref,
) {
const { t } = useTranslation(); const { t } = useTranslation();
const formSchema = getFormSchema(t); const formSchema = getFormSchema(t);
@@ -174,6 +186,19 @@ export default function BotForm({
onDirtyChange?.(isDirty); onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]); }, [isDirty, onDirtyChange]);
useImperativeHandle(ref, () => ({
syncBasicInfo(values) {
form.reset(
{
...form.getValues(),
name: values.name,
description: values.description,
},
{ keepDirtyValues: true },
);
},
}));
useEffect(() => { useEffect(() => {
setBotFormValues(); setBotFormValues();
}, []); }, []);
@@ -416,46 +441,47 @@ export default function BotForm({
className="w-full min-w-0 max-w-full space-y-6" className="w-full min-w-0 max-w-full space-y-6"
disabled={isLoading} disabled={isLoading}
> >
{/* Card 1: Basic Information */} {!initBotId && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('bots.basicInfo')}</CardTitle> <CardTitle>{t('bots.basicInfo')}</CardTitle>
<CardDescription> <CardDescription>
{t('bots.basicInfoDescription')} {t('bots.basicInfoDescription')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t('bots.botName')} {t('bots.botName')}
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('bots.botDescription')}</FormLabel> <FormLabel>{t('bots.botDescription')}</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</CardContent> </CardContent>
</Card> </Card>
)}
{/* Card 2: Adapter Configuration */} {/* Card 2: Adapter Configuration */}
<Card> <Card>
@@ -662,6 +688,27 @@ export default function BotForm({
}} }}
/> />
)} )}
{currentAdapter && initBotId && (
<div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium">
{t('bots.adapterConfigurationTest')}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t('bots.adapterConfigurationTestDescription')}
</p>
</div>
<AdapterEventDebugDialog
botId={initBotId}
adapterLabel={
adapterNameList.find(
(adapter) => adapter.value === currentAdapter,
)?.label ?? currentAdapter
}
/>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -688,4 +735,6 @@ export default function BotForm({
</form> </form>
</Form> </Form>
); );
} });
export default BotForm;
@@ -1,6 +1,13 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { UseFormReturn } from 'react-hook-form'; import { UseFormReturn } from 'react-hook-form';
@@ -48,7 +55,9 @@ import {
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
@@ -61,11 +70,20 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { import {
@@ -94,9 +112,14 @@ import {
Agent, Agent,
BotRouteDryRunResult, BotRouteDryRunResult,
BotEventRouteStatus, BotEventRouteStatus,
BotRouteTestResult,
} from '@/app/infra/entities/api'; } from '@/app/infra/entities/api';
import { backendClient } from '@/app/infra/http'; import { backendClient } from '@/app/infra/http';
import {
eventGroupLabel,
eventNamespaces,
groupEventPatterns,
} from '@/app/home/components/event-patterns/event-pattern-groups';
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
export const PIPELINE_DISCARD = '__discard__'; export const PIPELINE_DISCARD = '__discard__';
@@ -297,20 +320,6 @@ function agentSupportsEventPattern(agent: Agent, pattern: string) {
return patterns.some((p) => eventPatternCovers(p, pattern)); return patterns.some((p) => eventPatternCovers(p, pattern));
} }
function eventNamespaces(events: string[]) {
// Only surface a `ns.*` wildcard when the namespace actually has 2+
// concrete events — otherwise the wildcard is redundant with the single event.
const counts = new Map<string, number>();
events.forEach((e) => {
const n = e.split('.')[0];
if (n) counts.set(n, (counts.get(n) ?? 0) + 1);
});
return Array.from(counts.entries())
.filter(([, c]) => c >= 2)
.map(([n]) => `${n}.*`)
.sort();
}
// Localized label for an event pattern. Concrete events look up // Localized label for an event pattern. Concrete events look up
// `bots.eventNames.<event_with_underscores>`, falling back to the raw // `bots.eventNames.<event_with_underscores>`, falling back to the raw
// string when no translation exists (e.g. custom/unknown events). // string when no translation exists (e.g. custom/unknown events).
@@ -766,7 +775,8 @@ function AdapterCapabilitySummary({
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const concreteEvents = const concreteEvents =
supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS; supportedEvents.length > 0 ? supportedEvents : DEFAULT_EVENTS;
const previewEvents = concreteEvents.slice(0, 4); const concreteEventGroups = groupEventPatterns(concreteEvents);
const optionGroups = groupEventPatterns(eventOptions);
return ( return (
<div className="rounded-lg border bg-muted/20 p-3"> <div className="rounded-lg border bg-muted/20 p-3">
@@ -788,20 +798,22 @@ function AdapterCapabilitySummary({
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{previewEvents.map((event) => ( {concreteEventGroups.slice(0, 4).map((group) => (
<Badge <Badge
key={event} key={group.namespace}
variant="secondary" variant="secondary"
className="max-w-full rounded-md px-2 py-0.5 font-normal" className="max-w-full rounded-md px-2 py-0.5 font-normal"
title={event}
> >
<span className="truncate">{eventLabel(event, t)}</span> <span className="truncate">
{eventGroupLabel(group.namespace, t)} ·{' '}
{group.patterns.length}
</span>
</Badge> </Badge>
))} ))}
{concreteEvents.length > previewEvents.length && ( {concreteEventGroups.length > 4 && (
<Badge variant="outline" className="rounded-md px-2 py-0.5"> <Badge variant="outline" className="rounded-md px-2 py-0.5">
{t('bots.adapterEventsMore', { {t('bots.adapterEventsMore', {
count: concreteEvents.length - previewEvents.length, count: concreteEventGroups.length - 4,
})} })}
</Badge> </Badge>
)} )}
@@ -823,25 +835,40 @@ function AdapterCapabilitySummary({
</Button> </Button>
</div> </div>
{advancedOpen && ( {advancedOpen && (
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2"> <div className="mt-3 space-y-4 border-t pt-3">
{eventOptions.map((event) => ( {optionGroups.map((group) => (
<div key={event} className="min-w-0 rounded-md bg-background p-2"> <div key={group.namespace} className="space-y-2">
<div className="flex min-w-0 items-center justify-between gap-2"> <p className="text-xs font-medium text-muted-foreground">
<span className="truncate text-xs font-medium"> {eventGroupLabel(group.namespace, t)}
{eventLabel(event, t)}
</span>
{event.endsWith('.*') && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{t('bots.eventGroup')}
</Badge>
)}
</div>
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{eventDescription(event, t)}
</p> </p>
<code className="mt-1 block truncate text-[11px] text-muted-foreground"> <div className="grid gap-2 sm:grid-cols-2">
{event} {group.patterns.map((event) => (
</code> <div
key={event}
className="min-w-0 rounded-md bg-background p-2"
>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate text-xs font-medium">
{eventLabel(event, t)}
</span>
{event.endsWith('.*') && (
<Badge
variant="outline"
className="shrink-0 text-[10px]"
>
{t('bots.eventGroup')}
</Badge>
)}
</div>
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{eventDescription(event, t)}
</p>
<code className="mt-1 block truncate text-[11px] text-muted-foreground">
{event}
</code>
</div>
))}
</div>
</div> </div>
))} ))}
</div> </div>
@@ -857,13 +884,11 @@ function RouteDryRunDialog({
bindings, bindings,
eventOptions, eventOptions,
agentOptions, agentOptions,
onRouteStatusUpdate,
}: { }: {
botId?: string; botId?: string;
bindings: EventBinding[]; bindings: EventBinding[];
eventOptions: string[]; eventOptions: string[];
agentOptions: Agent[]; agentOptions: Agent[];
onRouteStatusUpdate?: (statuses: BotEventRouteStatus[]) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0]; const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0];
@@ -874,12 +899,9 @@ function RouteDryRunDialog({
); );
const [advancedPayloadOpen, setAdvancedPayloadOpen] = useState(false); const [advancedPayloadOpen, setAdvancedPayloadOpen] = useState(false);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [isDispatching, setIsDispatching] = useState(false);
const [payloadError, setPayloadError] = useState<string | null>(null); const [payloadError, setPayloadError] = useState<string | null>(null);
const [runError, setRunError] = useState<string | null>(null); const [runError, setRunError] = useState<string | null>(null);
const [result, setResult] = useState<BotRouteDryRunResult | null>(null); const [result, setResult] = useState<BotRouteDryRunResult | null>(null);
const [dispatchResult, setDispatchResult] =
useState<BotRouteTestResult | null>(null);
useEffect(() => { useEffect(() => {
if (!eventOptions.includes(eventType)) { if (!eventOptions.includes(eventType)) {
@@ -891,7 +913,6 @@ function RouteDryRunDialog({
setPayloadText(JSON.stringify(samplePayloadForEvent(eventType), null, 2)); setPayloadText(JSON.stringify(samplePayloadForEvent(eventType), null, 2));
setPayloadError(null); setPayloadError(null);
setResult(null); setResult(null);
setDispatchResult(null);
}, [eventType]); }, [eventType]);
function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) { function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) {
@@ -928,7 +949,6 @@ function RouteDryRunDialog({
async function runDryRun() { async function runDryRun() {
setRunError(null); setRunError(null);
setResult(null); setResult(null);
setDispatchResult(null);
const payload = parsePayload(); const payload = parsePayload();
if (payload === null) return; if (payload === null) return;
@@ -957,43 +977,6 @@ function RouteDryRunDialog({
} }
} }
async function dispatchTestEvent() {
setRunError(null);
setDispatchResult(null);
const payload = parsePayload();
if (payload === null) return;
if (!botId) {
setRunError(t('bots.dryRunNeedsSavedBot'));
return;
}
setIsDispatching(true);
try {
const testResult = await backendClient.testBotEventRoute(botId, {
event_type: eventType,
payload,
});
setDispatchResult(testResult);
onRouteStatusUpdate?.(testResult.route_status?.routes || []);
if (!testResult.dispatched) {
setRunError(
localizedFailureReason(
testResult.failure_code,
testResult.reason,
t,
) || t('bots.routeTestFailed'),
);
}
} catch (error) {
const err = error as { msg?: string };
setRunError(err.msg || t('bots.routeTestFailed'));
} finally {
setIsDispatching(false);
}
}
const targetName = result ? resolveTargetName(result.target) : ''; const targetName = result ? resolveTargetName(result.target) : '';
return ( return (
@@ -1008,82 +991,88 @@ function RouteDryRunDialog({
{t('bots.testRoute')} {t('bots.testRoute')}
</Button> </Button>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{t('bots.dryRunTitle')}</DialogTitle> <DialogTitle>{t('bots.dryRunTitle')}</DialogTitle>
<DialogDescription>{t('bots.dryRunDescription')}</DialogDescription> <DialogDescription>{t('bots.dryRunDescription')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-3"> <div className="space-y-2">
<div className="space-y-1.5"> <div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<label className="text-sm font-medium"> <div className="min-w-0 flex-1 space-y-1.5">
{t('bots.dryRunEventType')} <label className="text-sm font-medium">
</label> {t('bots.dryRunEventType')}
<Select value={eventType} onValueChange={setEventType}> </label>
<SelectTrigger className="h-9"> <Select value={eventType} onValueChange={setEventType}>
<SelectValue /> <SelectTrigger
</SelectTrigger> className="h-auto min-h-9 w-full"
<SelectContent> aria-label={t('bots.dryRunEventType')}
{eventOptions.map((event) => ( >
<SelectItem key={event} value={event}> <SelectValue />
{eventLabel(event, t)} </SelectTrigger>
</SelectItem> <SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
))} {groupEventPatterns(eventOptions).map((group) => (
</SelectContent> <SelectGroup key={group.namespace}>
</Select> <SelectLabel>
</div> {eventGroupLabel(group.namespace, t)}
<div className="rounded-md border bg-muted/20 px-3 py-2.5"> </SelectLabel>
<div className="flex items-start justify-between gap-3"> {group.patterns.map((event) => (
<div className="min-w-0"> <SelectItem
<p className="text-sm font-medium"> key={event}
{t('bots.dryRunSampleReady')} value={event}
</p> description={eventDescription(event, t)}
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground"> className="py-2"
{t('bots.dryRunSampleDescription', { >
event: eventLabel(eventType, t), <EventSelectOptionContent
})} event={event}
</p> label={eventLabel(event, t)}
</div> />
<Button </SelectItem>
type="button" ))}
variant="ghost" </SelectGroup>
size="sm" ))}
className="h-8 shrink-0 px-2 text-xs" </SelectContent>
onClick={() => setAdvancedPayloadOpen((value) => !value)} </Select>
>
{advancedPayloadOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{advancedPayloadOpen
? t('bots.dryRunHidePayload')
: t('bots.dryRunEditPayload')}
</Button>
</div> </div>
{advancedPayloadOpen && ( <Button
<div className="mt-3 space-y-1.5 border-t pt-3"> type="button"
<label className="text-xs font-medium"> variant="ghost"
{t('bots.dryRunPayload')} size="sm"
</label> className="h-9 shrink-0 self-start px-2 text-xs text-muted-foreground sm:self-auto"
<Textarea onClick={() => setAdvancedPayloadOpen((value) => !value)}
value={payloadText} >
onChange={(e) => setPayloadText(e.target.value)} {advancedPayloadOpen ? (
className="min-h-[118px] font-mono text-xs" <ChevronDown className="h-3.5 w-3.5" />
spellCheck={false} ) : (
placeholder='{"message_text": "hello"}' <ChevronRight className="h-3.5 w-3.5" />
/> )}
{payloadError ? ( {advancedPayloadOpen
<p className="text-xs text-destructive">{payloadError}</p> ? t('bots.dryRunHidePayload')
) : ( : t('bots.dryRunEditPayload')}
<p className="text-xs text-muted-foreground"> </Button>
{t('bots.dryRunPayloadHint')}
</p>
)}
</div>
)}
</div> </div>
{advancedPayloadOpen && (
<div className="space-y-1.5 rounded-md border bg-muted/20 p-3">
<label className="text-xs font-medium">
{t('bots.dryRunPayload')}
</label>
<Textarea
value={payloadText}
onChange={(e) => setPayloadText(e.target.value)}
className="min-h-[110px] font-mono text-xs"
spellCheck={false}
placeholder='{"message_text": "hello"}'
/>
{payloadError ? (
<p className="text-xs text-destructive">{payloadError}</p>
) : (
<p className="text-xs text-muted-foreground">
{t('bots.dryRunPayloadHint')}
</p>
)}
</div>
)}
</div> </div>
{runError && ( {runError && (
@@ -1171,24 +1160,6 @@ function RouteDryRunDialog({
)} )}
</div> </div>
)} )}
{dispatchResult?.dispatched && (
<Alert>
<CheckCircle2 className="h-4 w-4" />
<AlertDescription>
{t('bots.routeTestDispatched', {
count: dispatchResult.suppressed_outputs?.length || 0,
})}
</AlertDescription>
</Alert>
)}
<Alert className="border-amber-200 bg-amber-50/60 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/20 dark:text-amber-200">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t('bots.routeTestSideEffectWarning')}
</AlertDescription>
</Alert>
</div> </div>
<DialogFooter> <DialogFooter>
@@ -1199,25 +1170,10 @@ function RouteDryRunDialog({
> >
{t('common.close')} {t('common.close')}
</Button> </Button>
<Button <Button type="button" onClick={runDryRun} disabled={isRunning}>
type="button"
onClick={runDryRun}
disabled={isRunning || isDispatching}
>
<Play className="h-4 w-4 mr-1" /> <Play className="h-4 w-4 mr-1" />
{isRunning ? t('bots.dryRunRunning') : t('bots.dryRunAction')} {isRunning ? t('bots.dryRunRunning') : t('bots.dryRunAction')}
</Button> </Button>
<Button
type="button"
variant="outline"
onClick={dispatchTestEvent}
disabled={isRunning || isDispatching}
>
<Activity className="h-4 w-4 mr-1" />
{isDispatching
? t('bots.routeTestRunning')
: t('bots.routeTestAction')}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1252,6 +1208,7 @@ function BindingCardContent({
onUpdate, onUpdate,
onRemove, onRemove,
dragHandleProps, dragHandleProps,
isOverlay = false,
}: BindingCardProps) { }: BindingCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isEnabled = binding.enabled ?? true; const isEnabled = binding.enabled ?? true;
@@ -1263,13 +1220,21 @@ function BindingCardContent({
const statusDetail = routeStatusDetail(routeStatus, t); const statusDetail = routeStatusDetail(routeStatus, t);
return ( return (
<div className="rounded-lg border bg-card"> <div
className={`rounded-lg border bg-card ${
isOverlay ? 'pointer-events-none shadow-lg ring-1 ring-primary/20' : ''
}`}
data-drag-overlay={isOverlay ? 'true' : undefined}
>
{/* main row */} {/* main row */}
<div className="flex flex-wrap items-center gap-2 p-2.5"> <div className="flex flex-wrap items-center gap-2 p-2.5">
{isEnabled && ( {isEnabled && (
<button <button
type="button" type="button"
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground touch-none" className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground touch-none"
aria-label={t('bots.dragEventRoute', {
index: globalIndex + 1,
})}
{...dragHandleProps} {...dragHandleProps}
> >
<GripVertical className="h-4 w-4" /> <GripVertical className="h-4 w-4" />
@@ -1300,29 +1265,28 @@ function BindingCardContent({
onUpdate(globalIndex, patch); onUpdate(globalIndex, patch);
}} }}
> >
<SelectTrigger className="h-8 min-w-[150px] flex-1 text-sm"> <SelectTrigger className="h-auto min-h-9 min-w-[220px] flex-1">
{binding.event_pattern ? ( <SelectValue placeholder={t('bots.eventPatternPlaceholder')} />
<span className="truncate">
{eventLabel(binding.event_pattern, t)}
</span>
) : (
<SelectValue placeholder={t('bots.eventPatternPlaceholder')} />
)}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
{eventOptions.map((event) => { {groupEventPatterns(eventOptions).map((group) => (
const label = eventLabel(event, t); <SelectGroup key={group.namespace}>
return ( <SelectLabel>{eventGroupLabel(group.namespace, t)}</SelectLabel>
<SelectItem key={event} value={event}> {group.patterns.map((event) => (
<span className="flex flex-col"> <SelectItem
<span>{label}</span> key={event}
<span className="text-[11px] text-muted-foreground"> value={event}
{eventDescription(event, t)} description={eventDescription(event, t)}
</span> className="py-2"
</span> >
</SelectItem> <EventSelectOptionContent
); event={event}
})} label={eventLabel(event, t)}
/>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -1421,15 +1385,32 @@ function BindingCardContent({
// ── sortable wrapper ────────────────────────────────────────────────────────── // ── sortable wrapper ──────────────────────────────────────────────────────────
function SortableBindingCard(props: BindingCardProps) { interface SortableBindingCardProps extends BindingCardProps {
const { attributes, listeners, setNodeRef, transform, isDragging } = sortableId: string;
useSortable({ id: props.binding.id ?? props.globalIndex }); }
function SortableBindingCard({
sortableId,
...props
}: SortableBindingCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: sortableId });
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
data-testid={`event-route-${sortableId}`}
style={{ style={{
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.3 : undefined, opacity: isDragging ? 0.3 : undefined,
position: 'relative',
zIndex: isDragging ? 1 : undefined,
}} }}
> >
<BindingCardContent <BindingCardContent
@@ -1503,6 +1484,14 @@ export default function EventBindingsEditor({
), ),
[dryRunEventOptions], [dryRunEventOptions],
); );
const otherEventGroups = useMemo(() => {
const commonEventTypes = new Set(
behaviorPresets.map((preset) => preset.eventType),
);
return groupEventPatterns(
eventOptions.filter((event) => !commonEventTypes.has(event)),
);
}, [behaviorPresets, eventOptions]);
const refreshRouteStatuses = useCallback(async () => { const refreshRouteStatuses = useCallback(async () => {
if (!botId) { if (!botId) {
@@ -1516,8 +1505,8 @@ export default function EventBindingsEditor({
const response = await backendClient.getBotEventRouteStatuses(botId); const response = await backendClient.getBotEventRouteStatuses(botId);
setRouteStatuses(response.routes || []); setRouteStatuses(response.routes || []);
} catch (error) { } catch (error) {
const err = error as { msg?: string }; console.error('Failed to refresh Bot event route status', error);
setRouteStatusError(err.msg || t('bots.routeStatusRefreshFailed')); setRouteStatusError(t('bots.routeStatusRefreshFailed'));
} finally { } finally {
setRouteStatusLoading(false); setRouteStatusLoading(false);
} }
@@ -1653,18 +1642,18 @@ export default function EventBindingsEditor({
</Alert> </Alert>
)} )}
<Alert> {catchAllRouteIndex >= 0 && (
<Info className="h-4 w-4" /> <Alert>
<AlertDescription> <Info className="h-4 w-4" />
{catchAllRouteIndex >= 0 <AlertDescription>
? t('bots.routeFallbackCatchAll', { {t('bots.routeFallbackCatchAll', {
route: t('bots.dryRunRuleIndex', { route: t('bots.dryRunRuleIndex', {
index: catchAllRouteIndex + 1, index: catchAllRouteIndex + 1,
}), }),
}) })}
: t('bots.routeFallbackIgnored')} </AlertDescription>
</AlertDescription> </Alert>
</Alert> )}
{/* enabled section */} {/* enabled section */}
<DndContext <DndContext
@@ -1672,6 +1661,7 @@ export default function EventBindingsEditor({
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={() => setActiveId(null)}
> >
<SortableContext <SortableContext
items={idsRef.current} items={idsRef.current}
@@ -1688,6 +1678,7 @@ export default function EventBindingsEditor({
return ( return (
<SortableBindingCard <SortableBindingCard
key={idsRef.current[sortIdx]} key={idsRef.current[sortIdx]}
sortableId={idsRef.current[sortIdx]}
binding={binding} binding={binding}
globalIndex={globalIdx} globalIndex={globalIdx}
routeStatus={ routeStatus={
@@ -1706,7 +1697,7 @@ export default function EventBindingsEditor({
})} })}
</div> </div>
</SortableContext> </SortableContext>
<DragOverlay dropAnimation={null}> <DragOverlay adjustScale={false} dropAnimation={null}>
{activeBinding && activeGlobalIdx >= 0 ? ( {activeBinding && activeGlobalIdx >= 0 ? (
<BindingCardContent <BindingCardContent
binding={activeBinding} binding={activeBinding}
@@ -1738,6 +1729,9 @@ export default function EventBindingsEditor({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[300px] max-w-[90vw]"> <DropdownMenuContent align="start" className="w-[300px] max-w-[90vw]">
<DropdownMenuLabel className="px-2 pb-1 pt-1.5 text-xs font-normal text-muted-foreground">
{t('bots.commonScenarios')}
</DropdownMenuLabel>
{behaviorPresets.map((preset) => { {behaviorPresets.map((preset) => {
const Icon = preset.icon; const Icon = preset.icon;
return ( return (
@@ -1757,18 +1751,44 @@ export default function EventBindingsEditor({
); );
})} })}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuSub>
className="items-start gap-2 py-2" <DropdownMenuSubTrigger
onClick={() => addBinding(dryRunEventOptions[0])} className="items-start gap-2 py-2"
> disabled={otherEventGroups.length === 0}
<Workflow className="mt-0.5 h-4 w-4 shrink-0" /> >
<span className="flex min-w-0 flex-col gap-0.5"> <Workflow className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t('bots.behaviorCustom')}</span> <span className="flex min-w-0 flex-col gap-0.5 pr-2">
<span className="text-xs text-muted-foreground"> <span>{t('bots.behaviorCustom')}</span>
{t('bots.behaviorCustomDescription')} <span className="text-xs font-normal text-muted-foreground">
{t('bots.behaviorCustomDescription')}
</span>
</span> </span>
</span> </DropdownMenuSubTrigger>
</DropdownMenuItem> <DropdownMenuSubContent className="max-h-[min(70vh,32rem)] w-[320px] max-w-[90vw] overflow-y-auto">
{otherEventGroups.map((group, groupIndex) => (
<Fragment key={group.namespace}>
{groupIndex > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="px-2 py-1 text-xs font-normal text-muted-foreground">
{eventGroupLabel(group.namespace, t)}
</DropdownMenuLabel>
{group.patterns.map((event) => (
<DropdownMenuItem
key={event}
className="items-start py-2"
onClick={() => addBinding(event)}
>
<span className="flex min-w-0 flex-col gap-0.5">
<span>{eventLabel(event, t)}</span>
<span className="text-xs text-muted-foreground">
{eventDescription(event, t)}
</span>
</span>
</DropdownMenuItem>
))}
</Fragment>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<RouteDryRunDialog <RouteDryRunDialog
@@ -1776,24 +1796,28 @@ export default function EventBindingsEditor({
bindings={bindings} bindings={bindings}
eventOptions={dryRunEventOptions} eventOptions={dryRunEventOptions}
agentOptions={agentOptions} agentOptions={agentOptions}
onRouteStatusUpdate={setRouteStatuses}
/> />
<Button <Tooltip>
type="button" <TooltipTrigger asChild>
variant="ghost" <Button
size="sm" type="button"
onClick={refreshRouteStatuses} variant="ghost"
disabled={!botId || routeStatusLoading} size="icon"
> className={`size-8 ${routeStatusError ? 'text-destructive' : 'text-muted-foreground'}`}
<RefreshCw aria-label={t('bots.refreshRouteStatus')}
className={`h-4 w-4 mr-1 ${routeStatusLoading ? 'animate-spin' : ''}`} onClick={refreshRouteStatuses}
/> disabled={!botId || routeStatusLoading}
{t('bots.refreshRouteStatus')} >
</Button> <RefreshCw
className={`h-4 w-4 ${routeStatusLoading ? 'animate-spin' : ''}`}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
{routeStatusError || t('bots.refreshRouteStatus')}
</TooltipContent>
</Tooltip>
</div> </div>
{routeStatusError && (
<p className="text-xs text-destructive">{routeStatusError}</p>
)}
{/* disabled section */} {/* disabled section */}
{disabledBindings.length > 0 && ( {disabledBindings.length > 0 && (
@@ -0,0 +1,164 @@
import { FormEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import EmojiPicker from '@/components/ui/emoji-picker';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export interface EntityBasicInfoValues {
name: string;
description: string;
emoji?: string;
}
interface EntityBasicInfoDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
values: EntityBasicInfoValues;
defaultEmoji?: string;
showEmoji?: boolean;
onSave: (values: EntityBasicInfoValues) => Promise<void>;
}
export default function EntityBasicInfoDialog({
open,
onOpenChange,
values,
defaultEmoji,
showEmoji = true,
onSave,
}: EntityBasicInfoDialogProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState(values);
const [isSaving, setIsSaving] = useState(false);
const [nameError, setNameError] = useState(false);
useEffect(() => {
if (!open) return;
setDraft({
name: values.name,
description: values.description,
emoji: values.emoji || defaultEmoji,
});
setNameError(false);
}, [defaultEmoji, open, values.description, values.emoji, values.name]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const name = draft.name.trim();
if (!name) {
setNameError(true);
return;
}
setIsSaving(true);
try {
await onSave({
name,
description: draft.description.trim(),
emoji: showEmoji ? draft.emoji || defaultEmoji : undefined,
});
onOpenChange(false);
} catch {
// The caller presents the entity-specific error message.
} finally {
setIsSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{t('common.editBasicInfo')}</DialogTitle>
<DialogDescription>
{t(
showEmoji
? 'common.editBasicInfoDescription'
: 'common.editBasicInfoDescriptionNoIcon',
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-5">
<div className="flex items-start gap-4">
<div className="min-w-0 flex-1 space-y-2">
<Label htmlFor="entity-basic-name">{t('common.name')}</Label>
<Input
id="entity-basic-name"
value={draft.name}
aria-invalid={nameError}
onChange={(event) => {
setDraft((current) => ({
...current,
name: event.target.value,
}));
if (event.target.value.trim()) setNameError(false);
}}
autoFocus
/>
{nameError && (
<p className="text-sm text-destructive">
{t('common.fieldRequired')}
</p>
)}
</div>
{showEmoji && (
<div className="space-y-2">
<Label>{t('common.icon')}</Label>
<EmojiPicker
value={draft.emoji || defaultEmoji}
onChange={(emoji) =>
setDraft((current) => ({ ...current, emoji }))
}
ariaLabel={t('common.icon')}
/>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="entity-basic-description">
{t('common.description')}
</Label>
<Input
id="entity-basic-description"
value={draft.description}
onChange={(event) =>
setDraft((current) => ({
...current,
description: event.target.value,
}))
}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? t('common.saving') : t('common.save')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,34 @@
import { Pencil } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
export default function EntityTitleEditButton({
onClick,
}: {
onClick: () => void;
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-muted-foreground"
aria-label={t('common.editBasicInfo')}
onClick={onClick}
>
<Pencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.editBasicInfo')}</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,18 @@
interface EventSelectOptionContentProps {
event: string;
label: string;
}
export default function EventSelectOptionContent({
event,
label,
}: EventSelectOptionContentProps) {
return (
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{label}</span>
<code className="shrink-0 rounded-sm bg-muted px-1 py-0.5 font-mono text-[10px] font-normal text-muted-foreground">
{event}
</code>
</span>
);
}
@@ -0,0 +1,79 @@
import type { TFunction } from 'i18next';
export interface EventPatternGroup {
namespace: string;
patterns: string[];
}
function eventPatternNamespace(pattern: string) {
if (pattern === '*') return '*';
return pattern.split('.')[0] || pattern;
}
export function eventNamespaces(events: string[]) {
const counts = new Map<string, number>();
events.forEach((event) => {
if (event === '*' || event.endsWith('.*')) return;
const namespace = eventPatternNamespace(event);
counts.set(namespace, (counts.get(namespace) ?? 0) + 1);
});
return Array.from(counts.entries())
.filter(([, count]) => count >= 2)
.map(([namespace]) => `${namespace}.*`)
.sort();
}
export function groupEventPatterns(patterns: string[]): EventPatternGroup[] {
const groups = new Map<string, string[]>();
patterns.forEach((pattern) => {
const namespace = eventPatternNamespace(pattern);
const group = groups.get(namespace) ?? [];
if (!group.includes(pattern)) group.push(pattern);
groups.set(namespace, group);
});
return Array.from(groups.entries())
.sort(([left], [right]) => {
if (left === '*') return -1;
if (right === '*') return 1;
return left.localeCompare(right);
})
.map(([namespace, groupPatterns]) => ({
namespace,
patterns: groupPatterns.sort((left, right) => {
const leftWildcard = left.endsWith('.*');
const rightWildcard = right.endsWith('.*');
if (leftWildcard !== rightWildcard) return leftWildcard ? -1 : 1;
return left.localeCompare(right);
}),
}));
}
export function eventGroupLabel(namespace: string, t: TFunction) {
if (namespace === '*') return t('bots.eventWildcard');
const key = `bots.eventGroupNames.${namespace}`;
const label = t(key);
return label === key ? namespace : label;
}
export function eventPatternLabel(pattern: string, t: TFunction) {
if (pattern === '*') return t('bots.eventWildcard');
if (pattern.endsWith('.*')) {
return t('bots.eventNamespaceWildcard', {
namespace: pattern.replace('.*', ''),
});
}
const key = `bots.eventNames.${pattern.replace(/\./g, '_')}`;
const label = t(key);
return label === key ? pattern : label;
}
export function eventPatternDescription(pattern: string, t: TFunction) {
if (pattern === '*') return t('bots.eventDescriptions.all');
if (pattern.endsWith('.*')) {
return t('bots.eventDescriptions.namespace');
}
const key = `bots.eventDescriptions.${pattern.replace(/\./g, '_')}`;
const description = t(key);
return description === key ? t('bots.eventDescriptions.custom') : description;
}
@@ -1752,6 +1752,17 @@ function findSidebarChildForPath(pathname: string): SidebarChildVO | undefined {
); );
if (matchedChild) return matchedChild; if (matchedChild) return matchedChild;
// Keep the legacy Pipeline URL usable after Pipelines and Agents were
// unified under the Processors section.
if (
pathname === '/home/pipelines' ||
pathname.startsWith('/home/pipelines/')
) {
return sidebarConfigList.find(
(childConfig) => childConfig.id === 'pipelines',
);
}
if ( if (
pathname === '/home/mcp' || pathname === '/home/mcp' ||
pathname === '/home/skills' || pathname === '/home/skills' ||
@@ -0,0 +1,239 @@
import { ReactNode, useState } from 'react';
import { BarChart3, Bug, Settings } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
interface ProcessorMonitoringView {
label: string;
workbenchLabel: string;
content: ReactNode;
}
export interface ProcessorDetailStatus {
label: string;
description?: string;
tone: 'neutral' | 'success' | 'warning' | 'error';
}
interface ProcessorDetailWorkbenchProps {
title: string;
titleAction?: ReactNode;
headerActions?: ReactNode;
status?: ProcessorDetailStatus | null;
saveLabel: string;
saveFormId: string;
canSave: boolean;
isDirty: boolean;
isSaving: boolean;
configTitle: string;
configContent: ReactNode;
debugTitle?: string;
debugContent?: ReactNode;
debugConnected?: boolean;
debugConnectedLabel?: string;
debugDisconnectedLabel?: string;
unsavedLabel?: string;
monitoring?: ProcessorMonitoringView;
}
export default function ProcessorDetailWorkbench({
title,
titleAction,
headerActions,
status,
saveLabel,
saveFormId,
canSave,
isDirty,
isSaving,
configTitle,
configContent,
debugTitle,
debugContent,
debugConnected,
debugConnectedLabel,
debugDisconnectedLabel,
unsavedLabel,
monitoring,
}: ProcessorDetailWorkbenchProps) {
const [activeView, setActiveView] = useState<'workbench' | 'monitoring'>(
'workbench',
);
const hasDebug = Boolean(debugTitle && debugContent);
return (
<Tabs
value={activeView}
onValueChange={(value) =>
setActiveView(value as 'workbench' | 'monitoring')
}
className="flex h-full min-h-0 min-w-0 flex-col gap-0"
>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
<div className="flex min-w-0 items-center gap-2">
<h1 className="truncate text-xl font-semibold">{title}</h1>
{titleAction}
{monitoring && (
<TabsList
aria-label={`${monitoring.workbenchLabel} / ${monitoring.label}`}
className="ml-1"
>
<TabsTrigger value="workbench" className="gap-1.5 px-3">
<Settings className="size-4" />
{monitoring.workbenchLabel}
{isDirty && (
<span className="size-1.5 rounded-full bg-amber-500">
<span className="sr-only">{unsavedLabel}</span>
</span>
)}
</TabsTrigger>
<TabsTrigger value="monitoring" className="gap-1.5 px-3">
<BarChart3 className="size-4" />
{monitoring.label}
</TabsTrigger>
</TabsList>
)}
{status && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
role="status"
aria-label={status.label}
tabIndex={0}
className={cn(
'rounded-full',
status.tone === 'success' &&
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
status.tone === 'warning' &&
'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',
status.tone === 'error' &&
'border-destructive/30 bg-destructive/10 text-destructive',
status.tone === 'neutral' &&
'border-border bg-muted/50 text-muted-foreground',
)}
>
<span
className={cn(
'size-1.5 rounded-full',
status.tone === 'success' && 'bg-emerald-500',
status.tone === 'warning' && 'bg-amber-500',
status.tone === 'error' && 'bg-destructive',
status.tone === 'neutral' &&
'animate-pulse bg-muted-foreground',
)}
/>
{status.label}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-72">
<p className="font-medium">{status.label}</p>
{status.description && (
<p className="mt-1 font-normal opacity-80">
{status.description}
</p>
)}
</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex items-center gap-2">
{canSave && activeView === 'workbench' && (
<Button
type="submit"
form={saveFormId}
disabled={!isDirty || isSaving}
>
{saveLabel}
</Button>
)}
{activeView === 'workbench' && headerActions}
</div>
</div>
{monitoring && (
<TabsContent
value="monitoring"
className="mt-0 min-h-0 flex-1 overflow-hidden"
>
<section
aria-label={monitoring.label}
className="h-full min-h-0 overflow-y-auto rounded-xl border bg-card p-4"
>
{monitoring.content}
</section>
</TabsContent>
)}
<TabsContent
value="workbench"
className="mt-0 min-h-0 flex-1 overflow-y-auto lg:overflow-hidden"
>
<div
className={cn(
'grid min-h-0 gap-3 lg:h-full',
hasDebug
? 'lg:grid-cols-[minmax(20rem,0.72fr)_minmax(0,1.28fr)]'
: 'grid-cols-1',
)}
>
{hasDebug && (
<section
aria-label={debugTitle}
className="flex min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
>
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
<div className="flex min-w-0 items-center gap-2 font-medium">
<Bug className="size-4 shrink-0" />
<span className="truncate">{debugTitle}</span>
</div>
{debugConnected !== undefined && (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span
className={cn(
'size-2 rounded-full',
debugConnected ? 'bg-emerald-500' : 'bg-destructive',
)}
/>
{debugConnected
? debugConnectedLabel
: debugDisconnectedLabel}
</span>
)}
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
{debugContent}
</div>
</section>
)}
<section
aria-label={configTitle}
className="flex min-h-[36rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
>
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4 font-medium">
<Settings className="size-4" />
<span className="truncate">{configTitle}</span>
{isDirty && (
<span className="ml-auto flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<span className="size-1.5 rounded-full bg-amber-500" />
{unsavedLabel}
</span>
)}
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
{configContent}
</div>
</section>
</div>
</TabsContent>
</Tabs>
);
}
+61 -3
View File
@@ -28,6 +28,10 @@ import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react'; import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
import { useCurrentWorkspace } from '@/app/infra/http'; import { useCurrentWorkspace } from '@/app/infra/http';
import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
export default function KBDetailContent({ id }: { id: string }) { export default function KBDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new'; const isCreateMode = id === 'new';
@@ -52,8 +56,10 @@ export default function KBDetailContent({ id }: { id: string }) {
const [activeTab, setActiveTab] = useState('metadata'); const [activeTab, setActiveTab] = useState('metadata');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showBasicInfoDialog, setShowBasicInfoDialog] = useState(false);
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null); const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
const [formDirty, setFormDirty] = useState(false); const [formDirty, setFormDirty] = useState(false);
const [formVersion, setFormVersion] = useState(0);
const loadKbInfo = useCallback( const loadKbInfo = useCallback(
async (kbId: string) => { async (kbId: string) => {
@@ -99,6 +105,34 @@ export default function KBDetailContent({ id }: { id: string }) {
loadKbInfo(id); loadKbInfo(id);
} }
async function handleBasicInfoSave(values: EntityBasicInfoValues) {
if (!kbInfo) return;
const updateData: KnowledgeBase = {
name: values.name,
description: values.description,
emoji: values.emoji || '📚',
knowledge_engine_plugin_id: kbInfo.knowledge_engine_plugin_id,
creation_settings: kbInfo.creation_settings,
retrieval_settings: kbInfo.retrieval_settings,
};
try {
await httpClient.updateKnowledgeBase(id, updateData);
setKbInfo({ ...kbInfo, ...updateData });
setDetailEntityName(values.name);
setFormDirty(false);
setFormVersion((version) => version + 1);
refreshKnowledgeBases();
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
} catch (err) {
toast.error(
t('knowledge.updateKnowledgeBaseFailed') + (err as CustomApiError).msg,
);
throw err;
}
}
async function confirmDelete() { async function confirmDelete() {
try { try {
await httpClient.deleteKnowledgeBase(id); await httpClient.deleteKnowledgeBase(id);
@@ -151,9 +185,18 @@ export default function KBDetailContent({ id }: { id: string }) {
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Sticky Header: title + save button */} {/* Sticky Header: title + save button */}
<div className="flex items-center justify-between pb-4 shrink-0"> <div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold"> <div className="flex min-w-0 items-center gap-1">
{t('knowledge.editKnowledgeBase')} <h1 className="truncate text-xl font-semibold">
</h1> {kbInfo
? `${kbInfo.emoji || '📚'} ${kbInfo.name}`
: t('knowledge.editKnowledgeBase')}
</h1>
{canManage && kbInfo && (
<EntityTitleEditButton
onClick={() => setShowBasicInfoDialog(true)}
/>
)}
</div>
{canManage && ( {canManage && (
<Button <Button
type="submit" type="submit"
@@ -198,6 +241,7 @@ export default function KBDetailContent({ id }: { id: string }) {
<div className="mx-auto max-w-3xl space-y-6 pb-8"> <div className="mx-auto max-w-3xl space-y-6 pb-8">
<fieldset className="contents" disabled={!canManage}> <fieldset className="contents" disabled={!canManage}>
<KBForm <KBForm
key={`${id}-${formVersion}`}
initKbId={id} initKbId={id}
onNewKbCreated={handleNewKbCreated} onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated} onKbUpdated={handleKbUpdated}
@@ -268,6 +312,20 @@ export default function KBDetailContent({ id }: { id: string }) {
</Tabs> </Tabs>
</div> </div>
{kbInfo && (
<EntityBasicInfoDialog
open={showBasicInfoDialog}
onOpenChange={setShowBasicInfoDialog}
values={{
name: kbInfo.name,
description: kbInfo.description,
emoji: kbInfo.emoji,
}}
defaultEmoji="📚"
onSave={handleBasicInfoSave}
/>
)}
{/* Delete confirmation dialog */} {/* Delete confirmation dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent> <DialogContent>
@@ -31,6 +31,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api'; import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common'; import { CustomApiError } from '@/app/infra/entities/common';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -100,7 +101,7 @@ export default function KBForm({
const [retrievalSettings, setRetrievalSettings] = useState< const [retrievalSettings, setRetrievalSettings] = useState<
Record<string, unknown> Record<string, unknown>
>({}); >({});
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(Boolean(initKbId));
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Dirty tracking: snapshot of saved state for comparison // Dirty tracking: snapshot of saved state for comparison
@@ -341,26 +342,59 @@ export default function KBForm({
id="kb-form" id="kb-form"
className="space-y-6" className="space-y-6"
> >
{/* Card 1: Basic Information */} {/* Basic information is entered here only during creation. */}
<Card> {!isEditing && (
<CardHeader> <Card>
<CardTitle>{t('knowledge.basicInfo')}</CardTitle> <CardHeader>
<CardDescription> <CardTitle>{t('knowledge.basicInfo')}</CardTitle>
{t('knowledge.basicInfoDescription')} <CardDescription>
</CardDescription> {t('knowledge.basicInfoDescription')}
</CardHeader> </CardDescription>
<CardContent className="space-y-4"> </CardHeader>
{/* Name and Emoji in same row */} <CardContent className="space-y-4">
<div className="flex gap-4 items-start"> {/* Name and Emoji in same row */}
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('knowledge.kbName')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Description */}
<FormField <FormField
control={form.control} control={form.control}
name="name" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem>
<FormLabel> <FormLabel>{t('knowledge.kbDescription')}</FormLabel>
{t('knowledge.kbName')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
@@ -368,40 +402,19 @@ export default function KBForm({
</FormItem> </FormItem>
)} )}
/> />
<FormField </CardContent>
control={form.control} </Card>
name="emoji" )}
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Description */} {/* Knowledge engine selection and settings stay together. */}
<FormField <Card>
control={form.control} <CardHeader>
name="description" <CardTitle>{t('knowledge.engineSettings')}</CardTitle>
render={({ field }) => ( <CardDescription>
<FormItem> {t('knowledge.engineSettingsDescription')}
<FormLabel>{t('knowledge.kbDescription')}</FormLabel> </CardDescription>
<FormControl> </CardHeader>
<Input {...field} /> <CardContent className="space-y-6">
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Knowledge Engine Selector */}
<FormField <FormField
control={form.control} control={form.control}
name="ragEngineId" name="ragEngineId"
@@ -484,36 +497,28 @@ export default function KBForm({
</FormItem> </FormItem>
)} )}
/> />
{configFormItems.length > 0 && (
<>
<Separator />
<DynamicFormComponent
itemConfigList={configFormItems}
initialValues={configSettings as Record<string, object>}
onSubmit={(val) =>
setConfigSettings(val as Record<string, unknown>)
}
isEditing={isEditing}
externalDependentValues={retrievalSettings}
onValidate={(validateFn) =>
(configValidateRef.current = validateFn)
}
/>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Card 2: Engine Settings (dynamic form from creation_schema) */} {/* Retrieval Settings (dynamic form from retrieval_schema) */}
{configFormItems.length > 0 && (
<Card>
<CardHeader>
<CardTitle>{t('knowledge.engineSettings')}</CardTitle>
<CardDescription>
{t('knowledge.engineSettingsDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<DynamicFormComponent
itemConfigList={configFormItems}
initialValues={configSettings as Record<string, object>}
onSubmit={(val) =>
setConfigSettings(val as Record<string, unknown>)
}
isEditing={isEditing}
externalDependentValues={retrievalSettings}
onValidate={(validateFn) =>
(configValidateRef.current = validateFn)
}
/>
</CardContent>
</Card>
)}
{/* Card 3: Retrieval Settings (dynamic form from retrieval_schema) */}
{retrievalFormItems.length > 0 && ( {retrievalFormItems.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
+1 -1
View File
@@ -269,7 +269,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
</div> </div>
</header> </header>
<main className="flex-1 overflow-hidden min-w-0 px-4 pb-4 pt-0"> <main className="min-h-0 min-w-0 flex-1 overflow-clip px-4 pb-4 pt-0">
<div <div
className={`mx-auto h-full w-full min-w-0 ${HOME_CONTENT_MAX_WIDTH}`} className={`mx-auto h-full w-full min-w-0 ${HOME_CONTENT_MAX_WIDTH}`}
> >
@@ -1,14 +1,22 @@
import { useState, useEffect } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent'; import PipelineFormComponent, {
PipelineFormHandle,
} from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog'; import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab'; import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Settings, Bug, BarChart3 } from 'lucide-react';
import { useCurrentWorkspace } from '@/app/infra/http'; import { useCurrentWorkspace } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Pipeline } from '@/app/infra/entities/api';
export default function PipelineDetailContent({ export default function PipelineDetailContent({
id, id,
@@ -40,15 +48,50 @@ export default function PipelineDetailContent({
return () => setDetailEntityName(null); return () => setDetailEntityName(null);
}, [id, isCreateMode, pipelines, setDetailEntityName, t]); }, [id, isCreateMode, pipelines, setDetailEntityName, t]);
const [activeTab, setActiveTab] = useState('config');
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false); const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [formDirty, setFormDirty] = useState(false); const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false); const [formSaving, setFormSaving] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [pipelineDetails, setPipelineDetails] = useState<Pipeline | null>(null);
const pipelineFormRef = useRef<PipelineFormHandle>(null);
const sidebarPipeline = pipelines.find((item) => item.id === id);
useEffect(() => {
if (isCreateMode) return;
let cancelled = false;
httpClient.getPipeline(id).then((response) => {
if (!cancelled) setPipelineDetails(response.pipeline);
});
return () => {
cancelled = true;
};
}, [id, isCreateMode]);
function handleFinish() { function handleFinish() {
refreshPipelines(); refreshPipelines();
} }
async function saveBasicInfo(values: EntityBasicInfoValues) {
try {
await httpClient.updatePipeline(id, values);
setPipelineDetails((current) =>
current
? { ...current, ...values }
: ({ ...values, config: {} } as Pipeline),
);
pipelineFormRef.current?.syncBasicInfo(values);
await refreshPipelines();
toast.success(t('pipelines.saveSuccess'));
} catch (error) {
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('pipelines.saveError') + message);
throw error;
}
}
function handleNewPipelineCreated(newPipelineId: string) { function handleNewPipelineCreated(newPipelineId: string) {
refreshPipelines(); refreshPipelines();
navigate(`${routeBase}?id=${encodeURIComponent(newPipelineId)}`); navigate(`${routeBase}?id=${encodeURIComponent(newPipelineId)}`);
@@ -95,63 +138,33 @@ export default function PipelineDetailContent({
} }
// ==================== Edit Mode ==================== // ==================== Edit Mode ====================
const pipelineName =
pipelineDetails?.name ||
sidebarPipeline?.name ||
t('pipelines.editPipeline');
const pipelineEmoji =
pipelineDetails?.emoji || sidebarPipeline?.emoji || '⚙️';
return ( return (
<div className="flex h-full flex-col"> <>
{/* Sticky Header: title + save button */} <ProcessorDetailWorkbench
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
{canManage && (
<Button
type="submit"
form="pipeline-form"
disabled={!formDirty || formSaving}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
)}
</div>
{/* Horizontal Tabs */}
<Tabs
key={id} key={id}
value={activeTab} title={`${pipelineEmoji} ${pipelineName}`}
onValueChange={setActiveTab} titleAction={
className="flex flex-1 flex-col min-h-0" canManage ? (
> <EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
<TabsList className="shrink-0"> ) : undefined
<TabsTrigger value="config" className="gap-1.5"> }
<Settings className="size-3.5" /> saveLabel={t('common.save')}
{t('pipelines.configuration')} saveFormId="pipeline-form"
</TabsTrigger> canSave={canManage}
{canOperate && ( isDirty={formDirty}
<TabsTrigger value="debug" className="gap-1.5"> isSaving={formSaving}
<Bug className="size-3.5" /> configTitle={t('pipelines.configuration')}
{t('pipelines.debugChat')} configContent={
{activeTab === 'debug' && (
<span
className={`inline-block size-2 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
)}
</TabsTrigger>
)}
{canViewMonitoring && (
<TabsTrigger value="monitoring" className="gap-1.5">
<BarChart3 className="size-3.5" />
{t('pipelines.monitoring.title')}
</TabsTrigger>
)}
</TabsList>
{/* Tab: Configuration */}
<TabsContent
value="config"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<fieldset className="contents" disabled={!canManage}> <fieldset className="contents" disabled={!canManage}>
<PipelineFormComponent <PipelineFormComponent
ref={pipelineFormRef}
pipelineId={id} pipelineId={id}
isEditMode={true} isEditMode={true}
disableForm={!canManage} disableForm={!canManage}
@@ -164,35 +177,53 @@ export default function PipelineDetailContent({
onSavingChange={setFormSaving} onSavingChange={setFormSaving}
/> />
</fieldset> </fieldset>
</TabsContent> }
debugTitle={canOperate ? t('pipelines.debugChat') : undefined}
{/* Tab: Debug */} debugConnected={canOperate ? isWebSocketConnected : undefined}
{canOperate && ( debugConnectedLabel={t('pipelines.debugDialog.connected')}
<TabsContent value="debug" className="flex-1 min-h-0 mt-4"> debugDisconnectedLabel={t('pipelines.debugDialog.disconnected')}
debugContent={
canOperate ? (
<DebugDialog <DebugDialog
open={activeTab === 'debug'} open={true}
pipelineId={id} pipelineId={id}
isEmbedded={true} isEmbedded={true}
compact={true}
hasUnsavedChanges={formDirty}
beforeSend={async () => pipelineFormRef.current?.save() ?? false}
onConnectionStatusChange={setIsWebSocketConnected} onConnectionStatusChange={setIsWebSocketConnected}
/> />
</TabsContent> ) : undefined
)} }
unsavedLabel={t('pipelines.unsavedChanges')}
{/* Tab: Monitoring */} monitoring={
{canViewMonitoring && ( canViewMonitoring
<TabsContent ? {
value="monitoring" label: t('pipelines.monitoring.title'),
className="flex-1 min-h-0 overflow-y-auto mt-4" workbenchLabel: t('pipelines.monitoring.workbench'),
> content: (
<PipelineMonitoringTab <PipelineMonitoringTab
pipelineId={id} pipelineId={id}
onNavigateToMonitoring={() => { onNavigateToMonitoring={() => {
navigate('/home/monitoring'); navigate('/home/monitoring');
}} }}
/> />
</TabsContent> ),
)} }
</Tabs> : undefined
</div> }
/>
<EntityBasicInfoDialog
open={basicInfoOpen}
onOpenChange={setBasicInfoOpen}
values={{
name: pipelineName,
description: pipelineDetails?.description || '',
emoji: pipelineEmoji,
}}
defaultEmoji="⚙️"
onSave={saveBasicInfo}
/>
</>
); );
} }
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { DialogContent } from '@/components/ui/dialog'; import { DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -40,13 +40,17 @@ import {
Music, Music,
Code, Code,
AlignLeft, AlignLeft,
RotateCcw,
} from 'lucide-react'; } from 'lucide-react';
interface DebugDialogProps { interface DebugDialogProps {
open: boolean; open: boolean;
pipelineId: string; pipelineId: string;
isEmbedded?: boolean; isEmbedded?: boolean;
compact?: boolean;
onConnectionStatusChange?: (isConnected: boolean) => void; onConnectionStatusChange?: (isConnected: boolean) => void;
beforeSend?: () => Promise<boolean>;
hasUnsavedChanges?: boolean;
} }
function AuthenticatedMessageImage({ function AuthenticatedMessageImage({
@@ -115,7 +119,10 @@ export default function DebugDialog({
open, open,
pipelineId, pipelineId,
isEmbedded = false, isEmbedded = false,
compact = false,
onConnectionStatusChange, onConnectionStatusChange,
beforeSend,
hasUnsavedChanges = false,
}: DebugDialogProps) { }: DebugDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId); const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
@@ -142,43 +149,61 @@ export default function DebugDialog({
new Set(), new Set(),
); );
const [streamOutput, setStreamOutput] = useState(true); const [streamOutput, setStreamOutput] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null); const scrollAreaRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const wsClientRef = useRef<WebSocketClient | null>(null); const wsClientRef = useRef<WebSocketClient | null>(null);
const isInitializingRef = useRef<boolean>(false); const isInitializingRef = useRef<boolean>(false);
const historyRequestGenerationRef = useRef(0);
const invalidateHistoryRequests = useCallback(() => {
historyRequestGenerationRef.current++;
}, []);
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
// Use setTimeout to ensure scroll happens after DOM update
setTimeout(() => { setTimeout(() => {
const scrollArea = document.querySelector('.scroll-area') as HTMLElement; const viewport = scrollAreaRef.current?.querySelector<HTMLElement>(
if (scrollArea) { '[data-slot="scroll-area-viewport"]',
scrollArea.scrollTo({ );
top: scrollArea.scrollHeight, viewport?.scrollTo({
behavior: 'smooth', top: viewport.scrollHeight,
}); behavior: 'smooth',
} });
// Also ensure messagesEndRef scrolls into view
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 0); }, 0);
}, []); }, []);
const loadMessages = useCallback( const loadMessages = useCallback(
async (pipelineId: string) => { async (pipelineId: string) => {
const generation = ++historyRequestGenerationRef.current;
try { try {
const response = await httpClient.getWebSocketHistoryMessages( const response = await httpClient.getWebSocketHistoryMessages(
pipelineId, pipelineId,
sessionType, sessionType,
); );
setMessages(response.messages); if (generation !== historyRequestGenerationRef.current) return;
setMessages(Array.isArray(response.messages) ? response.messages : []);
} catch (error) { } catch (error) {
if (generation !== historyRequestGenerationRef.current) return;
console.error('Failed to load messages:', error); console.error('Failed to load messages:', error);
} }
}, },
[sessionType], [sessionType],
); );
const resetConversation = useCallback(async () => {
try {
await httpClient.resetWebSocketSession(selectedPipelineId, sessionType);
invalidateHistoryRequests();
setMessages([]);
setQuotedMessage(null);
toast.success(t('pipelines.debugDialog.resetSuccess'));
} catch (error) {
console.error('Failed to reset Debug Chat session:', error);
toast.error(t('pipelines.debugDialog.resetFailed'));
}
}, [invalidateHistoryRequests, selectedPipelineId, sessionType, t]);
// Initialize WebSocket connection // Initialize WebSocket connection
const initWebSocket = useCallback( const initWebSocket = useCallback(
async (pipelineId: string) => { async (pipelineId: string) => {
@@ -187,24 +212,30 @@ export default function DebugDialog({
return; return;
} }
let wsClient: WebSocketClient | null = null;
let errorReported = false;
try { try {
isInitializingRef.current = true; isInitializingRef.current = true;
// Disconnect old connection // Disconnect old connection
if (wsClientRef.current) { const previousClient = wsClientRef.current;
wsClientRef.current.disconnect(); wsClientRef.current = null;
wsClientRef.current = null; previousClient?.disconnect();
}
// Create new connection // Create new connection
const wsClient = new WebSocketClient(pipelineId, sessionType); wsClient = new WebSocketClient(pipelineId, sessionType);
// Store the client before awaiting connect so effect cleanup can also
// cancel sockets that are still authenticating.
wsClientRef.current = wsClient;
wsClient wsClient
.onConnected(() => { .onConnected(() => {
if (wsClientRef.current !== wsClient) return;
setIsConnected(true); setIsConnected(true);
isInitializingRef.current = false; isInitializingRef.current = false;
}) })
.onMessage((wsMessage) => { .onMessage((wsMessage) => {
if (wsClientRef.current !== wsClient) return;
// Convert WebSocketMessage to Message type // Convert WebSocketMessage to Message type
const message: Message = { const message: Message = {
...wsMessage, ...wsMessage,
@@ -229,26 +260,32 @@ export default function DebugDialog({
}); });
}) })
.onError((error) => { .onError((error) => {
if (wsClientRef.current !== wsClient) return;
errorReported = true;
console.error('WebSocket error:', error); console.error('WebSocket error:', error);
setIsConnected(false); setIsConnected(false);
isInitializingRef.current = false; isInitializingRef.current = false;
toast.error(t('pipelines.debugDialog.connectionError')); toast.error(t('pipelines.debugDialog.connectionError'));
}) })
.onClose(() => { .onClose(() => {
if (wsClientRef.current !== wsClient) return;
setIsConnected(false); setIsConnected(false);
isInitializingRef.current = false; isInitializingRef.current = false;
}) })
.onBroadcast((message) => { .onBroadcast((message) => {
if (wsClientRef.current !== wsClient) return;
toast.info(message); toast.info(message);
}); });
await wsClient.connect(); await wsClient.connect();
wsClientRef.current = wsClient;
} catch (error) { } catch (error) {
if (!wsClient || wsClientRef.current !== wsClient) return;
console.error('WebSocket connection failed:', error); console.error('WebSocket connection failed:', error);
setIsConnected(false); setIsConnected(false);
isInitializingRef.current = false; isInitializingRef.current = false;
toast.error(t('pipelines.debugDialog.connectionFailed')); if (!errorReported) {
toast.error(t('pipelines.debugDialog.connectionFailed'));
}
} }
}, },
[sessionType, t], [sessionType, t],
@@ -264,24 +301,28 @@ export default function DebugDialog({
if (open) { if (open) {
setSelectedPipelineId(pipelineId); setSelectedPipelineId(pipelineId);
} else { } else {
invalidateHistoryRequests();
// Disconnect WebSocket immediately when dialog closes // Disconnect WebSocket immediately when dialog closes
if (wsClientRef.current) { if (wsClientRef.current) {
wsClientRef.current.disconnect(); const wsClient = wsClientRef.current;
wsClientRef.current = null; wsClientRef.current = null;
wsClient.disconnect();
setIsConnected(false); setIsConnected(false);
isInitializingRef.current = false; isInitializingRef.current = false;
} }
} }
return () => { return () => {
invalidateHistoryRequests();
// Disconnect WebSocket on component unmount // Disconnect WebSocket on component unmount
if (wsClientRef.current) { if (wsClientRef.current) {
wsClientRef.current.disconnect(); const wsClient = wsClientRef.current;
wsClientRef.current = null; wsClientRef.current = null;
wsClient.disconnect();
isInitializingRef.current = false; isInitializingRef.current = false;
} }
}; };
}, [open, pipelineId]); }, [open, pipelineId, invalidateHistoryRequests]);
// Reload messages and reconnect when sessionType or selectedPipelineId changes // Reload messages and reconnect when sessionType or selectedPipelineId changes
useEffect(() => { useEffect(() => {
@@ -321,7 +362,7 @@ export default function DebugDialog({
} }
}, [showAtPopover]); }, [showAtPopover]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value; const value = e.target.value;
if (sessionType === 'group') { if (sessionType === 'group') {
if (value.endsWith('@')) { if (value.endsWith('@')) {
@@ -412,8 +453,11 @@ export default function DebugDialog({
try { try {
setIsUploading(true); setIsUploading(true);
if (hasUnsavedChanges && beforeSend && !(await beforeSend())) {
return;
}
const messageChain = []; const messageChain: MessageChainComponent[] = [];
// Add quoted message if present // Add quoted message if present
if (quotedMessage) { if (quotedMessage) {
@@ -467,17 +511,21 @@ export default function DebugDialog({
type: 'Image', type: 'Image',
path: result.file_key, path: result.file_key,
}); });
} else { } else if (attachment.kind === 'voice') {
// Voice / File go through the generic document upload endpoint, // Voice / File go through the generic document upload endpoint,
// which returns a storage key the backend resolves into the // which returns a storage key the backend resolves into the
// sandbox inbox just like images. // sandbox inbox just like images.
const result = await httpClient.uploadDocumentFile(attachment.file); const result = await httpClient.uploadDocumentFile(attachment.file);
messageChain.push({ messageChain.push({
type: attachment.kind === 'voice' ? 'Voice' : 'File', type: 'Voice',
path: result.file_id, path: result.file_id,
...(attachment.kind === 'file' });
? { name: attachment.file.name } } else {
: {}), const result = await httpClient.uploadDocumentFile(attachment.file);
messageChain.push({
type: 'File',
path: result.file_id,
name: attachment.file.name,
}); });
} }
} catch (error) { } catch (error) {
@@ -804,39 +852,58 @@ export default function DebugDialog({
}; };
const renderContent = () => ( const renderContent = () => (
<div className="flex flex-1 h-full min-h-0"> <div className="flex flex-1 h-full min-h-0 flex-col">
<div className="w-14 p-2 pl-0 shrink-0 flex flex-col justify-start gap-2"> <div
className={cn(
'flex shrink-0 flex-wrap items-center gap-1 border-b px-4 py-2',
compact && 'px-3',
)}
data-debug-session-toolbar="true"
>
<span className="mr-1 text-xs text-muted-foreground">
{t('pipelines.debugDialog.sessionType')}
</span>
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon" size="sm"
aria-pressed={sessionType === 'person'}
className={cn( className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none', 'shadow-none',
sessionType === 'person' sessionType === 'person' &&
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground' 'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary',
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)} )}
onClick={() => setSessionType('person')} onClick={() => setSessionType('person')}
> >
<User className="size-5" /> <User className="size-4" />
{t('pipelines.debugDialog.privateChat')}
</Button> </Button>
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon" size="sm"
aria-pressed={sessionType === 'group'}
className={cn( className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none', 'shadow-none',
sessionType === 'group' sessionType === 'group' &&
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground' 'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary',
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)} )}
onClick={() => setSessionType('group')} onClick={() => setSessionType('group')}
> >
<Users className="size-5" /> <Users className="size-4" />
{t('pipelines.debugDialog.groupChat')}
</Button> </Button>
</div> </div>
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0"> <div className="flex-1 flex flex-col w-full h-full min-h-0">
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 scroll-area"> <ScrollArea
<div className="space-y-6"> ref={scrollAreaRef}
className={cn(
'flex-1 overflow-y-auto min-h-0 scroll-area',
compact ? 'p-3' : 'p-6',
)}
>
<div className={compact ? 'space-y-3' : 'space-y-6'}>
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-lg"> <div className="text-center text-muted-foreground py-12 text-lg">
{t('pipelines.debugDialog.noMessages')} {t('pipelines.debugDialog.noMessages')}
@@ -852,7 +919,10 @@ export default function DebugDialog({
> >
<div <div
className={cn( className={cn(
'max-w-3xl px-5 py-3 rounded-2xl', 'rounded-2xl',
compact
? 'max-w-[92%] px-3 py-2 text-sm'
: 'max-w-3xl px-5 py-3',
message.role === 'user' message.role === 'user'
? 'user-message-bubble bg-primary/10 text-foreground rounded-br-none' ? 'user-message-bubble bg-primary/10 text-foreground rounded-br-none'
: 'bg-muted text-foreground rounded-bl-none', : 'bg-muted text-foreground rounded-bl-none',
@@ -919,7 +989,6 @@ export default function DebugDialog({
</div> </div>
)) ))
)} )}
<div ref={messagesEndRef} />
</div> </div>
</ScrollArea> </ScrollArea>
@@ -990,8 +1059,11 @@ export default function DebugDialog({
</div> </div>
)} )}
<div className="p-4 pb-0 flex gap-2"> <div
<div className="flex gap-2 items-center"> className={cn('shrink-0 border-t p-4', compact && 'p-3')}
data-debug-composer="true"
>
<div className="mb-2 flex items-center gap-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t('pipelines.debugDialog.streamOutput')} {t('pipelines.debugDialog.streamOutput')}
@@ -1020,69 +1092,93 @@ export default function DebugDialog({
> >
<ImageIcon className="size-5" /> <ImageIcon className="size-5" />
</Button> </Button>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto text-muted-foreground"
onClick={() => void resetConversation()}
>
<RotateCcw className="size-4" />
{t('pipelines.debugDialog.reset')}
</Button>
</div> </div>
<div className="flex-1 flex items-center gap-2">
{hasAt && ( <div className="flex min-w-0 items-end gap-2">
<AtBadge targetName="websocketbot" onRemove={handleAtRemove} /> <div className="min-w-0 flex-1">
)} {hasAt && (
<div className="relative flex-1"> <div className="mb-1">
<Input <AtBadge
ref={inputRef} targetName="websocketbot"
value={inputValue} onRemove={handleAtRemove}
onChange={handleInputChange} />
onKeyPress={handleKeyPress}
placeholder={t('pipelines.debugDialog.inputPlaceholder', {
type:
sessionType === 'person'
? t('pipelines.debugDialog.privateChat')
: t('pipelines.debugDialog.groupChat'),
})}
disabled={!isConnected || isUploading}
className="flex-1 rounded-md px-3 py-2 transition-none text-base disabled:opacity-50"
/>
{showAtPopover && (
<div
ref={popoverRef}
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-popover text-popover-foreground shadow-lg"
>
<div
className={cn(
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
isHovering ? 'bg-accent' : '',
)}
onClick={handleAtSelect}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<span>
@websocketbot - {t('pipelines.debugDialog.atTips')}
</span>
</div>
</div> </div>
)} )}
<div className="relative">
<Textarea
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyPress}
placeholder={t('pipelines.debugDialog.inputPlaceholder', {
type:
sessionType === 'person'
? t('pipelines.debugDialog.privateChat')
: t('pipelines.debugDialog.groupChat'),
})}
disabled={!isConnected || isUploading}
rows={1}
className="h-11 min-h-11 max-h-32 resize-y rounded-md px-3 py-2 text-sm transition-none disabled:opacity-50"
/>
{showAtPopover && (
<div
ref={popoverRef}
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-popover text-popover-foreground shadow-lg"
>
<div
className={cn(
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
isHovering ? 'bg-accent' : '',
)}
onClick={handleAtSelect}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<span>
@websocketbot - {t('pipelines.debugDialog.atTips')}
</span>
</div>
</div>
)}
</div>
</div> </div>
<Button
onClick={sendMessage}
disabled={
(!inputValue.trim() &&
!hasAt &&
selectedImages.length === 0 &&
!quotedMessage) ||
!isConnected ||
isUploading
}
className={cn(
'h-11 shrink-0 rounded-md px-4 text-sm font-medium transition-none shadow-none disabled:opacity-50',
!compact && 'px-6 text-base',
)}
>
{isUploading ? (
t('pipelines.debugDialog.uploading')
) : (
<>
<Send className="size-4" />
{hasUnsavedChanges
? t('pipelines.debugDialog.saveAndSend')
: t('pipelines.debugDialog.send')}
</>
)}
</Button>
</div> </div>
<Button
onClick={sendMessage}
disabled={
(!inputValue.trim() &&
!hasAt &&
selectedImages.length === 0 &&
!quotedMessage) ||
!isConnected ||
isUploading
}
className="rounded-md w-20 px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50"
>
{isUploading ? (
t('pipelines.debugDialog.uploading')
) : (
<>
<Send className="size-4" />
{t('pipelines.debugDialog.send')}
</>
)}
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -1,4 +1,11 @@
import { useEffect, useRef, useState, useMemo } from 'react'; import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { GetPipelineResponseData, Pipeline } from '@/app/infra/entities/api'; import { GetPipelineResponseData, Pipeline } from '@/app/infra/entities/api';
import { import {
@@ -8,6 +15,7 @@ import {
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig'; import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
@@ -31,7 +39,6 @@ import {
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { cn } from '@/lib/utils';
import { import {
Card, Card,
CardContent, CardContent,
@@ -51,17 +58,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension'; import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
export default function PipelineFormComponent({ interface PipelineFormComponentProps {
onFinish,
onNewPipelineCreated,
isEditMode,
pipelineId,
showButtons = true,
onDeletePipeline,
onCancel,
onDirtyChange,
onSavingChange,
}: {
pipelineId?: string; pipelineId?: string;
isEditMode: boolean; isEditMode: boolean;
disableForm: boolean; disableForm: boolean;
@@ -72,7 +69,34 @@ export default function PipelineFormComponent({
onCancel?: () => void; onCancel?: () => void;
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void; onSavingChange?: (saving: boolean) => void;
}) { }
export interface PipelineFormHandle {
save: () => Promise<boolean>;
syncBasicInfo: (values: {
name: string;
description: string;
emoji?: string;
}) => void;
}
const PipelineFormComponent = forwardRef<
PipelineFormHandle,
PipelineFormComponentProps
>(function PipelineFormComponent(
{
onFinish,
onNewPipelineCreated,
isEditMode,
pipelineId,
showButtons = true,
onDeletePipeline,
onCancel,
onDirtyChange,
onSavingChange,
},
ref,
) {
const { t } = useTranslation(); const { t } = useTranslation();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showCopyConfirm, setShowCopyConfirm] = useState(false); const [showCopyConfirm, setShowCopyConfirm] = useState(false);
@@ -118,7 +142,7 @@ export default function PipelineFormComponent({
const formLabelList: SectionItem[] = isEditMode const formLabelList: SectionItem[] = isEditMode
? [ ? [
{ {
label: t('pipelines.basicInfo'), label: t('common.management'),
name: 'basic', name: 'basic',
icon: SECTION_ICONS.basic, icon: SECTION_ICONS.basic,
}, },
@@ -156,7 +180,20 @@ export default function PipelineFormComponent({
}, },
]; ];
const [activeSection, setActiveSection] = useState(formLabelList[0].name); const [activeSection, setActiveSection] = useState(
isEditMode ? 'trigger' : 'basic',
);
const primarySectionNames = ['trigger', 'ai', 'output'];
const primarySections = primarySectionNames
.map((name) => formLabelList.find((section) => section.name === name))
.filter((section): section is SectionItem => Boolean(section));
const secondarySections = formLabelList
.filter((section) => !primarySectionNames.includes(section.name))
.sort((left, right) => {
if (left.name === 'basic') return 1;
if (right.name === 'basic') return -1;
return 0;
});
const [aiConfigTabSchema, setAIConfigTabSchema] = const [aiConfigTabSchema, setAIConfigTabSchema] =
useState<PipelineConfigTab>(); useState<PipelineConfigTab>();
@@ -259,7 +296,7 @@ export default function PipelineFormComponent({
function handleFormSubmit(values: FormValues) { function handleFormSubmit(values: FormValues) {
if (isEditMode) { if (isEditMode) {
handleModify(values); void handleModify(values);
} else { } else {
handleCreate(values); handleCreate(values);
} }
@@ -293,8 +330,8 @@ export default function PipelineFormComponent({
}); });
} }
function handleModify(values: FormValues) { async function handleModify(values: FormValues): Promise<boolean> {
if (isSavingRef.current) return; if (isSavingRef.current) return false;
const submittedSnapshot = JSON.stringify(values); const submittedSnapshot = JSON.stringify(values);
const realConfig = { const realConfig = {
ai: values.ai, ai: values.ai,
@@ -318,23 +355,54 @@ export default function PipelineFormComponent({
isSavingRef.current = true; isSavingRef.current = true;
setIsSaving(true); setIsSaving(true);
onSavingChange?.(true); onSavingChange?.(true);
httpClient try {
.updatePipeline(pipelineId || '', pipeline) await httpClient.updatePipeline(pipelineId || '', pipeline);
.then(() => { savedSnapshotRef.current = submittedSnapshot;
savedSnapshotRef.current = submittedSnapshot; onFinish();
onFinish(); toast.success(t('pipelines.saveSuccess'));
toast.success(t('pipelines.saveSuccess')); return true;
}) } catch (err) {
.catch((err) => { const message =
toast.error(t('pipelines.saveError') + err.msg); typeof err === 'object' && err && 'msg' in err
}) ? String((err as { msg?: string }).msg || '')
.finally(() => { : '';
isSavingRef.current = false; toast.error(t('pipelines.saveError') + message);
setIsSaving(false); return false;
onSavingChange?.(false); } finally {
}); isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
}
} }
useImperativeHandle(ref, () => ({
syncBasicInfo(values) {
form.setValue('basic', {
...form.getValues('basic'),
name: values.name,
description: values.description,
emoji: values.emoji || '⚙️',
});
if (savedSnapshotRef.current) {
const snapshot = JSON.parse(savedSnapshotRef.current) as FormValues;
snapshot.basic = {
...snapshot.basic,
name: values.name,
description: values.description,
emoji: values.emoji || '⚙️',
};
savedSnapshotRef.current = JSON.stringify(snapshot);
}
},
async save() {
if (!hasUnsavedChangesRef.current) return true;
if (isSavingRef.current || !isEditMode) return false;
const valid = await form.trigger();
if (!valid) return false;
return handleModify(form.getValues());
},
}));
// Called from DynamicFormComponent onSubmit callbacks. // Called from DynamicFormComponent onSubmit callbacks.
// On the first emission for a stage (mount-time default filling), the // On the first emission for a stage (mount-time default filling), the
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false. // snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
@@ -567,100 +635,133 @@ export default function PipelineFormComponent({
onSubmit={form.handleSubmit(handleFormSubmit)} onSubmit={form.handleSubmit(handleFormSubmit)}
className="h-full flex flex-col flex-1 min-h-0 mb-2" className="h-full flex flex-col flex-1 min-h-0 mb-2"
> >
<div className="flex-1 flex flex-col md:flex-row min-h-0"> <div className="flex-1 flex min-h-0 flex-col">
{/* Vertical section navigation (only show when multiple sections) */} {/* Keep the primary pipeline flow visible while editing. */}
{formLabelList.length > 1 && ( {formLabelList.length > 1 && (
<nav className="shrink-0 mb-4 md:mb-0 md:w-44 md:pr-4 md:mr-4 md:border-r overflow-x-auto md:overflow-x-visible md:overflow-y-auto"> <nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
<ul className="flex md:flex-col gap-1 md:space-y-1"> <Tabs value={activeSection} onValueChange={setActiveSection}>
{formLabelList.map((section) => { <div className="overflow-x-auto">
const Icon = section.icon; <TabsList className="grid min-w-[34rem] w-full grid-cols-3">
return ( {primarySections.map((section) => {
<li key={section.name}> const Icon = section.icon;
<button return (
<TabsTrigger
key={section.name}
value={section.name}
>
<Icon />
{section.label}
</TabsTrigger>
);
})}
</TabsList>
</div>
<div className="flex flex-wrap gap-1">
{secondarySections.map((section) => {
const Icon = section.icon;
return (
<Button
key={section.name}
type="button" type="button"
onClick={() => setActiveSection(section.name)} variant={
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors text-left cursor-pointer whitespace-nowrap',
activeSection === section.name activeSection === section.name
? 'bg-accent text-accent-foreground' ? 'secondary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground', : 'ghost'
)} }
size="sm"
onClick={() => setActiveSection(section.name)}
> >
<Icon className="size-4 shrink-0" /> <Icon />
{section.label} {section.label}
</button> </Button>
</li> );
); })}
})} </div>
</ul> </Tabs>
</nav> </nav>
)} )}
{/* Content panel */} {/* Content panel */}
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
{/* Basic info section */}
{activeSection === 'basic' && ( {activeSection === 'basic' && (
<div className="space-y-6"> <div className="space-y-6">
{/* Basic Information Card */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('pipelines.basicInfo')}</CardTitle> <CardTitle>
{isEditMode
? t('common.management')
: t('pipelines.basicInfo')}
</CardTitle>
<CardDescription> <CardDescription>
{t('pipelines.basicInfoDescription')} {isEditMode
? t('pipelines.managementDescription')
: t('pipelines.basicInfoDescription')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Name and Emoji in same row */} {!isEditMode && (
<div className="flex gap-4 items-start"> <>
<FormField <div className="flex gap-4 items-start">
control={form.control} <FormField
name="basic.name" control={form.control}
render={({ field }) => ( name="basic.name"
<FormItem className="flex-1"> render={({ field }) => (
<FormLabel> <FormItem className="flex-1">
{t('common.name')} <FormLabel>
<span className="text-destructive">*</span> {t('common.name')}
</FormLabel> <span className="text-destructive">
<FormControl> *
<Input {...field} value={field.value ?? ''} /> </span>
</FormControl> </FormLabel>
<FormMessage /> <FormControl>
</FormItem> <Input
)} {...field}
/> value={field.value ?? ''}
<FormField />
control={form.control} </FormControl>
name="basic.emoji" <FormMessage />
render={({ field }) => ( </FormItem>
<FormItem> )}
<FormLabel>{t('common.icon')}</FormLabel> />
<FormControl> <FormField
<EmojiPicker control={form.control}
value={field.value} name="basic.emoji"
onChange={field.onChange} render={({ field }) => (
/> <FormItem>
</FormControl> <FormLabel>{t('common.icon')}</FormLabel>
<FormMessage /> <FormControl>
</FormItem> <EmojiPicker
)} value={field.value}
/> onChange={field.onChange}
</div> />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField <FormField
control={form.control} control={form.control}
name="basic.description" name="basic.description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('common.description')}</FormLabel> <FormLabel>
<FormControl> {t('common.description')}
<Input {...field} value={field.value ?? ''} /> </FormLabel>
</FormControl> <FormControl>
<FormMessage /> <Input
</FormItem> {...field}
)} value={field.value ?? ''}
/> />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{/* Copy pipeline (edit mode only) */}
{isEditMode && ( {isEditMode && (
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -851,7 +952,9 @@ export default function PipelineFormComponent({
</Dialog> </Dialog>
</> </>
); );
} });
export default PipelineFormComponent;
interface SectionItem { interface SectionItem {
label: string; label: string;
name: string; name: string;
-17
View File
@@ -177,7 +177,6 @@ export interface Agent {
kind: AgentKind; kind: AgentKind;
component_ref?: string | null; component_ref?: string | null;
config?: Record<string, unknown>; config?: Record<string, unknown>;
enabled?: boolean;
supported_event_patterns?: string[]; supported_event_patterns?: string[];
capability?: AgentCapability; capability?: AgentCapability;
created_at?: string; created_at?: string;
@@ -276,11 +275,6 @@ export interface BotRouteDryRunRequest {
event_bindings?: EventBinding[]; event_bindings?: EventBinding[];
} }
export interface BotRouteTestRequest {
event_type: string;
payload?: Record<string, unknown>;
}
export interface BotRouteDryRunTarget { export interface BotRouteDryRunTarget {
target_type: EventBinding['target_type']; target_type: EventBinding['target_type'];
target_uuid?: string | null; target_uuid?: string | null;
@@ -335,17 +329,6 @@ export interface BotEventRouteStatusResponse {
stale_routes: BotEventRouteStatus[]; stale_routes: BotEventRouteStatus[];
} }
export interface BotRouteTestResult {
dispatched: boolean;
event_type: string;
status?: BotEventRouteStatus['last_status'];
binding_id?: string | null;
failure_code?: string | null;
reason?: string | null;
suppressed_outputs: Array<Record<string, unknown>>;
route_status: BotEventRouteStatusResponse;
}
export interface ApiRespKnowledgeBases { export interface ApiRespKnowledgeBases {
bases: KnowledgeBase[]; bases: KnowledgeBase[];
} }
+1 -1
View File
@@ -19,7 +19,7 @@ export interface Plain extends MessageComponent {
// Quote component // Quote component
export interface Quote extends MessageComponent { export interface Quote extends MessageComponent {
type: 'Quote'; type: 'Quote';
id?: number; id?: number | string;
group_id?: number | string; group_id?: number | string;
sender_id?: number | string; sender_id?: number | string;
target_id?: number | string; target_id?: number | string;
+6 -14
View File
@@ -61,8 +61,6 @@ import {
ApiRespSkill, ApiRespSkill,
BotRouteDryRunRequest, BotRouteDryRunRequest,
BotRouteDryRunResult, BotRouteDryRunResult,
BotRouteTestRequest,
BotRouteTestResult,
BotEventRouteStatusResponse, BotEventRouteStatusResponse,
} from '@/app/infra/entities/api'; } from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin'; import { Plugin } from '@/app/infra/entities/plugin';
@@ -329,7 +327,10 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/pipelines', pipeline); return this.post('/api/v1/pipelines', pipeline);
} }
public updatePipeline(uuid: string, pipeline: Pipeline): Promise<object> { public updatePipeline(
uuid: string,
pipeline: Partial<Pipeline>,
): Promise<object> {
return this.put(`/api/v1/pipelines/${uuid}`, pipeline); return this.put(`/api/v1/pipelines/${uuid}`, pipeline);
} }
@@ -489,7 +490,7 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/platform/bots', bot); return this.post('/api/v1/platform/bots', bot);
} }
public updateBot(uuid: string, bot: Bot): Promise<object> { public updateBot(uuid: string, bot: Partial<Bot>): Promise<object> {
return this.put(`/api/v1/platform/bots/${uuid}`, bot); return this.put(`/api/v1/platform/bots/${uuid}`, bot);
} }
@@ -509,16 +510,6 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/platform/bots/${botId}/event-routes/status`); return this.get(`/api/v1/platform/bots/${botId}/event-routes/status`);
} }
public testBotEventRoute(
botId: string,
request: BotRouteTestRequest,
): Promise<BotRouteTestResult> {
return this.post(
`/api/v1/platform/bots/${botId}/event-routes/test`,
request,
);
}
public deleteBot(uuid: string): Promise<object> { public deleteBot(uuid: string): Promise<object> {
return this.delete(`/api/v1/platform/bots/${uuid}`); return this.delete(`/api/v1/platform/bots/${uuid}`);
} }
@@ -1307,6 +1298,7 @@ export class BackendClient extends BaseHttpClient {
public getAccountInfo(): Promise<{ public getAccountInfo(): Promise<{
initialized: boolean; initialized: boolean;
authenticated_invitation_acceptance_enabled?: boolean; authenticated_invitation_acceptance_enabled?: boolean;
invitation_registration_enabled?: boolean;
password_login_enabled?: boolean; password_login_enabled?: boolean;
space_login_enabled?: boolean; space_login_enabled?: boolean;
}> { }> {
@@ -4,9 +4,10 @@ export interface GetBotLogsResponse {
} }
export interface BotLog { export interface BotLog {
images: []; images: string[];
level: string; level: string;
message_session_id: string; message_session_id: string;
metadata?: Record<string, unknown> | null;
seq_id: number; seq_id: number;
text: string; text: string;
timestamp: number; timestamp: number;
+71 -18
View File
@@ -3,12 +3,13 @@
* WebSocket连接和消息处理 * WebSocket连接和消息处理
*/ */
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext'; import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
import type { MessageChainComponent } from '@/app/infra/entities/message';
export interface WebSocketMessage { export interface WebSocketMessage {
id: number; id: number;
role: 'user' | 'assistant'; role: 'user' | 'assistant';
content: string; content: string;
message_chain: Array<{ type: string; text?: string; target?: string }>; message_chain: MessageChainComponent[];
timestamp: string; timestamp: string;
is_final?: boolean; is_final?: boolean;
connection_id?: string; connection_id?: string;
@@ -36,9 +37,12 @@ export class WebSocketClient {
private reconnectAttempts = 0; private reconnectAttempts = 0;
private maxReconnectAttempts = 5; private maxReconnectAttempts = 5;
private reconnectDelay = 3000; // 3秒重连间隔 private reconnectDelay = 3000; // 3秒重连间隔
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private heartbeatInterval: NodeJS.Timeout | null = null; private heartbeatInterval: NodeJS.Timeout | null = null;
private heartbeatIntervalMs = 30000; // 30秒 private heartbeatIntervalMs = 30000; // 30秒
private isConnecting = false; // 防止重复连接 private isConnecting = false; // 防止重复连接
private shouldReconnect = true;
private disconnectedByUser = false;
// 事件回调 // 事件回调
private onConnectedCallback?: (data: WebSocketResponse) => void; private onConnectedCallback?: (data: WebSocketResponse) => void;
@@ -59,6 +63,13 @@ export class WebSocketClient {
public connect(): Promise<string> { public connect(): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
this.disconnectedByUser = false;
this.shouldReconnect = true;
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
// 防止重复连接 // 防止重复连接
if ( if (
this.isConnecting || this.isConnecting ||
@@ -87,22 +98,27 @@ export class WebSocketClient {
window.location.host; window.location.host;
const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`; const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`;
this.ws = new WebSocket(url); const socket = new WebSocket(url);
this.ws = socket;
// 连接打开 // 连接打开
this.ws.onopen = () => { socket.onopen = () => {
this.reconnectAttempts = 0; if (this.disconnectedByUser || this.ws !== socket) {
socket.close();
return;
}
this.isConnecting = false; this.isConnecting = false;
const token = this.token || localStorage.getItem('token'); const token = this.token || localStorage.getItem('token');
const workspaceUuid = getActiveWorkspaceUuid(); const workspaceUuid = getActiveWorkspaceUuid();
if (!token || !workspaceUuid) { if (!token || !workspaceUuid) {
const error = new Error('WebSocket认证信息缺失'); const error = new Error('WebSocket认证信息缺失');
this.shouldReconnect = false;
this.onErrorCallback?.(error); this.onErrorCallback?.(error);
this.ws?.close(); socket.close();
reject(error); reject(error);
return; return;
} }
this.ws?.send( socket.send(
JSON.stringify({ JSON.stringify({
type: 'authenticate', type: 'authenticate',
token, token,
@@ -112,13 +128,23 @@ export class WebSocketClient {
}; };
// 接收消息 // 接收消息
this.ws.onmessage = (event) => { socket.onmessage = (event) => {
if (this.disconnectedByUser || this.ws !== socket) return;
try { try {
const data: WebSocketResponse = JSON.parse(event.data); const data: WebSocketResponse = JSON.parse(event.data);
this.handleMessage(data); this.handleMessage(data);
if (data.type === 'error' && !this.connectionId) {
reject(new Error(data.message || 'WebSocket连接失败'));
return;
}
// 第一次连接成功 // 第一次连接成功
if (data.type === 'connected' && data.connection_id) { if (data.type === 'connected' && data.connection_id) {
// Only a fully authenticated runtime connection should reset
// the retry budget. Resetting on TCP open makes server-side
// errors (for example a pipeline still loading) retry forever.
this.reconnectAttempts = 0;
this.connectionId = data.connection_id; this.connectionId = data.connection_id;
this.startHeartbeat(); this.startHeartbeat();
resolve(data.connection_id); resolve(data.connection_id);
@@ -130,22 +156,36 @@ export class WebSocketClient {
}; };
// 连接关闭 // 连接关闭
this.ws.onclose = () => { socket.onclose = () => {
if (this.ws === socket) {
this.ws = null;
this.connectionId = null;
}
this.isConnecting = false; this.isConnecting = false;
this.stopHeartbeat(); this.stopHeartbeat();
if (this.disconnectedByUser) return;
this.onCloseCallback?.(); this.onCloseCallback?.();
// 自动重连 // 自动重连
if (this.reconnectAttempts < this.maxReconnectAttempts) { if (
this.shouldReconnect &&
this.reconnectAttempts < this.maxReconnectAttempts
) {
this.reconnectAttempts++; this.reconnectAttempts++;
setTimeout(() => { this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
if (!this.shouldReconnect || this.disconnectedByUser) return;
this.connect().catch(console.error); this.connect().catch(console.error);
}, this.reconnectDelay * this.reconnectAttempts); }, this.reconnectDelay * this.reconnectAttempts);
} }
}; };
// 连接错误 // 连接错误
this.ws.onerror = (event) => { socket.onerror = (event) => {
if (this.disconnectedByUser || this.ws !== socket) {
reject(new Error('WebSocket连接已取消'));
return;
}
console.error('WebSocket错误:', event); console.error('WebSocket错误:', event);
this.isConnecting = false; this.isConnecting = false;
const error = new Error('WebSocket连接失败'); const error = new Error('WebSocket连接失败');
@@ -210,6 +250,13 @@ export class WebSocketClient {
case 'error': case 'error':
const error = new Error(data.message || '未知错误'); const error = new Error(data.message || '未知错误');
this.onErrorCallback?.(error); this.onErrorCallback?.(error);
// Authentication/resource errors happen before the `connected`
// handshake. Retrying them cannot recover and would leak error toasts
// after the user leaves the Pipeline page.
if (!this.connectionId) {
this.shouldReconnect = false;
this.ws?.close();
}
break; break;
default: default:
@@ -221,7 +268,7 @@ export class WebSocketClient {
* *
*/ */
public sendMessage( public sendMessage(
messageChain: Array<{ type: string; text?: string; target?: string }>, messageChain: MessageChainComponent[],
stream: boolean = true, stream: boolean = true,
) { ) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
@@ -272,21 +319,27 @@ export class WebSocketClient {
* *
*/ */
public disconnect() { public disconnect() {
this.disconnectedByUser = true;
this.shouldReconnect = false;
this.reconnectAttempts = this.maxReconnectAttempts;
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) { if (this.ws) {
this.stopHeartbeat(); this.stopHeartbeat();
const socket = this.ws;
// 停止自动重连
this.reconnectAttempts = this.maxReconnectAttempts;
// 发送断开消息 // 发送断开消息
if (this.ws.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'disconnect' })); socket.send(JSON.stringify({ type: 'disconnect' }));
} }
this.ws.close();
this.ws = null; this.ws = null;
this.connectionId = null; this.connectionId = null;
this.isConnecting = false; this.isConnecting = false;
socket.close();
} }
} }

Some files were not shown because too many files have changed in this diff Show More