Compare commits

..

10 Commits

Author SHA1 Message Date
dadachann 85d8d9304e fix(web): keep feedback dialog interactive 2026-06-24 10:10:19 -04:00
Hyu 76471af179 feat(web): add sidebar feedback popover
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-06-24 16:43:50 +08:00
RockChinQ 59b2a7cd51 fix(monitoring): hide disabled box status on cloud 2026-06-23 06:40:05 -04:00
RockChinQ a43978ff24 chore(release): bump version to 4.10.4 2026-06-22 21:15:53 -04:00
RockChinQ e3417dd20b fix(release): derive package version from metadata 2026-06-22 21:10:33 -04:00
RockChinQ 2982e7c553 chore(release): bump version to 4.10.3 2026-06-22 11:12:15 -04:00
RockChinQ e1e14e9269 chore(deps): bump langbot-plugin to 0.4.6 2026-06-22 11:08:08 -04:00
Junyan Chin 1c128a1524 docs(skills/langbot-plugin-dev): document marketplace README i18n convention (root README.md must be English; other langs in readme/) 2026-06-22 02:39:15 -04:00
RockChinQ 8ad1203fd5 docs(examples): add web-page-bot embed demo, drop stray test-embed.html
Move the Page Bot (web_page_bot) embed test page out of the repo root into
examples/web-page-bot/ as a proper, LangBot-styled demo: a self-contained
index.html that loads the live widget.js against a running instance, plus
bilingual READMEs mirroring examples/http-bot/.
2026-06-22 02:17:26 -04:00
Junyan Chin 144bec371c feat(platform): standalone HTTP Bot adapter (server-to-server) (#2274)
* docs(platform): add HTTP Bot adapter design (RFC)

Standalone server-to-server HTTP adapter for driving a pipeline from external
systems (LangBot Space ticketing et al). Inbound via the existing unified
webhook route; outbound via signed callback POSTs. Preserves pipeline-native
N->1 aggregation and 1->M multi-reply without a long-lived WebSocket.

No core changes required (router/aggregator/pipeline untouched).

* feat(platform): add standalone HTTP Bot adapter

A first-class, vendor-neutral message-platform adapter (http_bot) for
server-to-server integrations (LangBot Space ticketing et al). Drives a
pipeline over plain HTTP with no long-lived connection:

- Inbound: signed POST to the existing unified webhook route /bots/<uuid>,
  carrying a caller-defined session_id mapped to the LangBot launcher id via
  get_launcher_id -> per-session isolation. Preserves pipeline-native N->1
  aggregation for free.
- Outbound: each reply_message / reply_message_chunk becomes one signed
  callback POST to the config-only callback_url, delivered in per-session
  sequence order with retry/backoff -> 1->M multi-reply.
- Sub-paths: /reset (drop a session) and /sync (block for the collapsed reply).
- Auth: symmetric HMAC-SHA256 both directions (timestamp + replay window),
  no JWT/Turnstile, no socket.

Decisions: callback URL is config-only (SSRF closed); reset + sync shipped;
Python + TS reference clients shipped (signing verified byte-identical 3-way).

No core changes: the unified webhook router, aggregator, query pool and
pipeline are untouched. Adapter is auto-discovered from platform/sources/.

Adds:
  src/langbot/pkg/platform/sources/http_bot.{py,yaml,svg}
  src/langbot/pkg/platform/sources/http_bot_signing.py
  docs/platforms/http-bot.md, docs/http-bot-openapi.json
  examples/http-bot/{client.py,client.ts,README.md}
Updates docs/HTTP_BOT_ADAPTER_DESIGN.md (status: implemented).

* docs(examples): add interactive HTTP Bot playground (browser debug console)

A single-file aiohttp web app (examples/http-bot/playground.py) that lets you
chat with a RUNNING http_bot bot from the browser and watch the protocol live:
signed inbound POST -> 202 ack -> 1->M signed callbacks streamed back via SSE,
with a debug panel showing the signature, HTTP status, and per-callback
sequence/verification. Light LangBot-styled UI.

On startup it reads the API key + http_bot bot from data/langbot.db and points
the bot's callback_url + secrets back at itself via the LangBot API (live
reload, no restart). README updated with a playground section.

* docs(examples): add Chinese README for http-bot reference clients

* style(platform): use </> code icon for http_bot adapter logo

* docs(examples): point http-bot guide links to docs.langbot.app

* style(platform): make http_bot icon a transparent monochrome </> so WebUI tints it like other adapters

* Revert to colorful </> badge for http_bot icon (WebUI renders it as-is)
2026-06-22 13:38:00 +08:00
69 changed files with 3603 additions and 2504 deletions
+575
View File
@@ -0,0 +1,575 @@
# HTTP Bot Adapter — Design Document
> Status: **Implemented** · Branch: `feat/http-bot-adapter` · Author: LangBot core
>
> A first-class, **standalone** message-platform adapter (`http_bot`) that lets
> any external system (e.g. LangBot Space ticketing, an internal back-office, a
> CRM, a custom web app) talk to a LangBot pipeline over plain HTTP — **inbound**
> by POSTing messages in, **outbound** by receiving replies on a callback URL —
> with full support for the pipeline's native N→1 aggregation and 1→M
> multi-reply semantics, and **without** holding a long-lived WebSocket
> connection.
>
> **Shipped in this branch:**
> - `src/langbot/pkg/platform/sources/http_bot.yaml` — adapter manifest (auto-discovered)
> - `src/langbot/pkg/platform/sources/http_bot.py` — `HttpBotAdapter`
> - `src/langbot/pkg/platform/sources/http_bot_signing.py` — HMAC helpers
> - `src/langbot/pkg/platform/sources/http_bot.svg` — icon
> - `docs/platforms/http-bot.md` — integration guide
> - `docs/http-bot-openapi.json` — machine-readable contract
> - `examples/http-bot/` — Python + TypeScript reference clients
>
> **Final decisions (resolving the original open questions):**
> 1. Callback URL is **config-only** — never accepted per-message (SSRF closed).
> 2. **Session reset is provided** — `POST /bots/<uuid>/reset` keyed by `session_id`.
> 3. Reference **clients are provided** — `examples/http-bot/client.py` + `client.ts`.
> 4. **Sync convenience mode is included** — `POST /bots/<uuid>/sync` (opt-in, lossy).
---
## 1. Background & Motivation
### 1.1 The concrete need
LangBot Space wants to use a LangBot pipeline as the brain for **ticket
handling**. The integration is **server-to-server**: Space's backend pushes a
user's ticket messages into LangBot and renders LangBot's replies back into the
ticket thread.
This interaction is **not** request/response shaped:
- **N → 1**: a user may fire several messages in a row ("the app crashed" …
"when I click export" … "here's a screenshot"). The pipeline's
**message aggregation** feature should debounce and merge these into one turn.
- **1 → N**: a single turn may yield **multiple** outbound messages — a tool/
function call narrating progress, a plugin emitting several cards, a streamed
answer split into chunks.
### 1.2 Why the existing options don't fit
LangBot today exposes exactly one externally-reachable way to drive a pipeline
that is **not** tied to a specific IM vendor: the **WebSocket** path
(`/api/v1/pipelines/<uuid>/ws/connect` for dashboard debug, and
`/api/v1/embed/<bot_uuid>/ws/connect` for the embeddable web widget).
For a server-to-server integration the WebSocket path has real friction:
| Problem | Detail |
|---|---|
| Long-lived connection | Caller must maintain a socket, heartbeats, and reconnect logic for what is fundamentally a fire-and-collect workload. |
| Session identity | Inbound messages are keyed by the transient `connection_id` (`websocket_{connection_id}`); the caller **cannot supply a stable, business-meaningful session id** (e.g. a ticket number). Multi-ticket isolation is not expressible. |
| Auth mismatch | The debug socket is gated by the **dashboard JWT** (must not be handed to an external service); the embed socket is gated by **Cloudflare Turnstile** (a *browser* human-check that a backend cannot satisfy). Neither is a server-to-server credential. |
| In-memory, single-process state | Session history lives in process memory and is lost on restart. |
> **Key realisation.** The N→1 / 1→M behaviour the caller wants is **not**
> provided by WebSocket — it is provided by the **pipeline** (aggregation +
> the adapter being free to call `reply_message` any number of times). It is
> therefore **transport-independent**. We can deliver the exact same semantics
> over a far lighter HTTP transport.
### 1.3 Why a *new, standalone* adapter (not a refactor of an existing one)
The brief is explicit: **do not reuse / fork an existing vendor adapter.** The
vendor adapters (`lark`, `wecom`, `qqofficial`, `slack`, …) carry vendor-specific
signature schemes, payload shapes, and message-segment mappings. Bending one of
them into a "generic" mode would couple a public integration surface to one
vendor's quirks and make the developer experience worse for everyone.
Instead we ship `http_bot` as a clean, independent adapter whose **entire
contract is LangBot's own** — documented, versioned, and designed front-to-back
around *integrator* developer experience.
---
## 2. Goals & Non-Goals
### Goals
- **G1** A standalone `http_bot` adapter, selectable like any other platform
adapter in the dashboard, with its own config schema and docs.
- **G2** **Inbound**: external systems POST messages to a stable LangBot URL,
carrying a **caller-defined `session_id`** that maps 1:1 to a LangBot session.
- **G3** **Outbound**: LangBot delivers each reply by POSTing to a
caller-configured **callback URL**; one turn may produce **many** callbacks.
- **G4** Preserve pipeline-native **N→1 aggregation** and **1→M multi-reply**.
- **G5** Server-to-server **auth**: shared-secret HMAC request signing both
directions (no JWT, no Turnstile, no long-lived socket).
- **G6** **Great DX**: copy-pasteable curl, a tiny reference client, an OpenAPI
fragment, idempotency, clear error envelope, and a local echo-server recipe.
### Non-Goals
- Not replacing or deprecating the WebSocket / embed widget path (that remains
the right tool for *browser*, real-time, streaming chat UIs).
- Not a synchronous "one request → one response" RPC (explicitly rejected: it
cannot express 1→M; see §9 for the optional sync convenience mode).
- No built-in message **persistence/replay** in v1 (callbacks are at-least-once
best-effort; durability is the caller's responsibility — see §8).
- No multi-tenant API-key management UI in v1 (one secret per bot; see §11).
---
## 3. How LangBot routes a message (the parts we plug into)
Understanding the existing flow is what makes this adapter cheap. A message
flows through these stages (verified against current `master`):
```
INBOUND OUTBOUND
external POST ─┐ ┌─ reply_message()
▼ │ reply_message_chunk()
POST /bots/<bot_uuid> (unified webhook router, AuthType.NONE)
│ webhooks.py → adapter.handle_unified_webhook(bot_uuid, path, request)
▼ │
HttpBotAdapter.handle_unified_webhook │ (called 0..N times
• verify HMAC signature │ per turn by the
• parse {session_id, message[]} │ pipeline / plugins)
• build FriendMessage / GroupMessage │
• fire registered listener ───────────────┐ │
│ │ │
▼ ▼ │
botmgr.on_friend_message / on_group_message │
• (optional) webhook_pusher fan-out │
• msg_aggregator.add_message(...) ── N→1 debounce ──►│
│ │
▼ │
query_pool → pipeline.run() ─── invokes adapter ─────┘
reply methods 1..M times
```
Two framework facts we rely on:
1. **N→1 aggregation is free.** `botmgr` hands every inbound event to
`self.ap.msg_aggregator.add_message(...)`, which debounces per
`session_id` and merges consecutive messages into one pipeline turn
(`pkg/pipeline/aggregator.py`). The adapter does nothing special.
2. **1→M is free.** The pipeline (and any plugin in the chain) calls
`adapter.reply_message()` / `reply_message_chunk()` **as many times as it
wants** per turn. The adapter's only job is to deliver each call outward.
For `http_bot` that means: **one outbound callback POST per call.**
3. **A unified inbound route already exists.** `WebhookRouterGroup`
(`pkg/api/http/controller/groups/webhooks.py`) maps
`POST /bots/<bot_uuid>[/<path>]` (auth `NONE`) to
`adapter.handle_unified_webhook(bot_uuid, path, request)`. `http_bot`
implements that method and is reachable **without registering any new
route** — it does its own signature verification, exactly like the vendor
webhook adapters do.
> Net new code is essentially: one `http_bot.py` adapter, one `http_bot.yaml`
> schema, signing helpers, and docs. No router, aggregator, or pipeline changes.
---
## 4. Architecture Overview
```
┌────────────────────┐ (1) inbound: POST signed message
│ External system │ ──────────────────────────────────────────────► ┌──────────────────────┐
│ (LangBot Space, │ POST /bots/<bot_uuid> │ LangBot │
│ CRM, web app …) │ X-LB-Signature, X-LB-Timestamp │ │
│ │ { session_id, message:[...] } │ HttpBotAdapter │
│ - callback server │ ◄────────────────────────────────────────────── │ (platform/sources) │
│ (receives │ (4) outbound: POST signed reply(s) │ │
│ replies) │ POST <callback_url> │ pipeline + aggregator│
└────────────────────┘ X-LB-Signature, X-LB-Timestamp └──────────────────────┘
{ session_id, sequence, is_final,
message:[...] } (sent 1..M times)
```
- The adapter is **stateless across requests** at the HTTP layer; session
continuity is carried by `session_id` and resolved by LangBot's normal
session manager.
- **Inbound** and **outbound** are **independent HTTP exchanges**. LangBot does
not answer the inbound POST with the pipeline result; it `202 Accepts` it and
later POSTs the reply(s) to the callback URL. This is what makes 1→M natural.
---
## 5. Configuration Schema (`http_bot.yaml`)
Follows the existing `MessagePlatformAdapter` manifest convention (cf.
`slack.yaml`). Fields:
| field | type | required | purpose |
|---|---|---|---|
| `inbound_secret` | string (secret) | yes | HMAC key the **caller** uses to sign inbound POSTs; LangBot verifies. |
| `callback_url` | string (url) | no* | Where LangBot POSTs replies. *Optional if the caller supplies `callback_url` per-message (see §6.1); a static default lives here. |
| `outbound_secret` | string (secret) | no | HMAC key LangBot uses to sign outbound callbacks; caller verifies. Defaults to `inbound_secret` if empty. |
| `default_session_type` | enum `person`/`group` | no | Default when a message omits `session_type`. Default `person`. |
| `signature_required` | bool | no | If `false`, skip inbound signature check (dev only; logs a warning). Default `true`. |
| `callback_timeout` | int (seconds) | no | Per-callback HTTP timeout. Default `15`. |
| `callback_max_retries` | int | no | Retries on 5xx/timeout with backoff. Default `3`. |
| `webhook_url` | webhook-url (display) | — | Read-only field rendering the inbound URL `…/bots/<bot_uuid>` for copy-paste, like other webhook adapters. |
Manifest sketch (i18n labels elided for brevity):
```yaml
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: http_bot
label: { en_US: "HTTP Bot", zh_Hans: "HTTP 通用接入" }
description:
en_US: "Integrate any backend over plain HTTP. Push messages in, receive replies on a callback URL. Server-to-server, no long-lived connection."
zh_Hans: "通过 HTTP 接入任意后端系统。推入消息、在回调地址接收回复。面向服务间集成,无需长连接。"
icon: http_bot.svg
spec:
categories: [popular, global]
help_links:
zh: https://docs.langbot.app/zh/platforms/http-bot
en: https://docs.langbot.app/en/platforms/http-bot
config:
- { name: inbound_secret, type: string, required: true, default: "" }
- { name: callback_url, type: string, required: false, default: "" }
- { name: outbound_secret, type: string, required: false, default: "" }
- { name: default_session_type, type: select, required: false, default: "person",
options: [person, group] }
- { name: signature_required, type: boolean, required: false, default: true }
- { name: callback_timeout, type: integer, required: false, default: 15 }
- { name: callback_max_retries, type: integer, required: false, default: 3 }
- { name: webhook_url, type: webhook-url, required: false, default: "" }
execution:
python:
path: ./http_bot.py
attr: HttpBotAdapter
```
---
## 6. The HTTP Contract (this is the DX surface)
### 6.1 Inbound — push a message into LangBot
```
POST /bots/{bot_uuid}
Content-Type: application/json
X-LB-Timestamp: 1718000000
X-LB-Signature: sha256=<hex hmac>
X-LB-Idempotency-Key: <uuid> # optional, dedup window
```
Body:
```jsonc
{
"session_id": "ticket-10293", // REQUIRED. Caller-defined. Maps 1:1 to a LangBot session.
"session_type": "person", // optional, "person" | "group"; default from config
"sender": { // optional metadata, surfaced to pipeline/plugins
"id": "user-5567",
"name": "Alice"
},
"message": [ // REQUIRED. A LangBot MessageChain (list of segments).
{ "type": "Plain", "text": "Export keeps failing on the dashboard." },
{ "type": "Image", "url": "https://.../screenshot.png" }
]
}
```
Response (LangBot does **not** block on the pipeline):
```jsonc
// 202 Accepted
{
"code": 0,
"msg": "accepted",
"data": {
"session_id": "ticket-10293",
"accepted_message_id": "in_01H....", // server-assigned id for this inbound message
"aggregating": true // true if buffered by the aggregator
}
}
```
**N→1 in practice.** Fire three POSTs with the same `session_id` inside the
aggregation window → the pipeline runs **once** with the three messages merged.
No special flag needed; this is the aggregator's default behaviour when enabled
on the pipeline.
### 6.2 Outbound — LangBot delivers replies to your callback
For each `reply_message` / `reply_message_chunk` the pipeline emits, LangBot
POSTs to `callback_url`:
```
POST {callback_url}
Content-Type: application/json
X-LB-Timestamp: 1718000001
X-LB-Signature: sha256=<hex hmac over body>
```
Body:
```jsonc
{
"session_id": "ticket-10293", // echoes the inbound session
"reply_to": "in_01H....", // the inbound message id this answers
"sequence": 1, // 1-based ordinal within this turn (for 1→M ordering)
"is_final": false, // false for intermediate/streamed parts
"stream": false, // true when this is a streamed chunk
"message": [
{ "type": "Plain", "text": "Looking into it — checking your export logs…" }
],
"timestamp": "2026-06-22T09:00:01Z"
}
```
**1→M in practice.** A turn that fires a function call then a final answer
produces e.g.:
```
POST callback → { sequence: 1, is_final: false, message: ["Checking logs…"] }
POST callback → { sequence: 2, is_final: false, message: ["Found 2 failed exports."] }
POST callback → { sequence: 3, is_final: true, message: ["Fixed. Try again now."] }
```
The caller stitches by `session_id` + `sequence`, and knows the turn is complete
when `is_final: true` arrives.
Your callback endpoint should return `200` quickly. A non-2xx triggers retry
with backoff (`callback_max_retries`).
### 6.3 Error envelope (inbound)
Consistent, machine-readable; never leak internals:
```jsonc
{ "code": 40101, "msg": "invalid signature", "data": null }
```
| HTTP | code | meaning |
|---|---|---|
| 202 | 0 | accepted |
| 400 | 40001 | malformed body / missing `session_id` or `message` |
| 401 | 40101 | bad/expired signature |
| 403 | 40301 | bot disabled |
| 404 | 40401 | bot_uuid not found / not an `http_bot` adapter |
| 409 | 40901 | duplicate idempotency key (already accepted) |
| 413 | 41301 | message too large |
| 500 | 50001 | internal error |
---
## 7. Signing scheme (both directions)
Symmetric, dependency-free HMAC-SHA256 — trivial to implement in any language.
```
signing_string = "{timestamp}.{raw_request_body}"
signature = "sha256=" + hex(HMAC_SHA256(secret, signing_string))
```
Verification rules:
- Reject if `|now - timestamp| > 300s` (replay window).
- Constant-time compare (`hmac.compare_digest`).
- Inbound verified with `inbound_secret`; outbound signed with
`outbound_secret` (falls back to `inbound_secret`).
- `signature_required: false` bypasses verification **and logs a warning**
intended only for local development behind a trusted network.
Reference (Python, ~6 lines):
```python
import hmac, hashlib, time
def sign(secret: str, body: bytes, ts: int | None = None) -> tuple[str, str]:
ts = ts or int(time.time())
mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256)
return str(ts), "sha256=" + mac.hexdigest()
```
---
## 8. Delivery semantics & reliability
- **Inbound**: `202 Accepted` means *queued*, not *processed*. Use
`X-LB-Idempotency-Key` to make client retries safe (dedup window, e.g. 10 min).
- **Outbound**: **at-least-once**, best-effort. Retries on timeout/5xx with
exponential backoff up to `callback_max_retries`. Callbacks for one
`session_id` are delivered **in `sequence` order** (serialised per session);
across sessions they may interleave.
- **No persistence in v1**: if LangBot restarts mid-turn, in-flight callbacks
may be lost. Durable replay is deferred (see §13). Callers needing exactly-once
should dedup on `(session_id, reply_to, sequence)`.
- **Backpressure**: the adapter must not block the pipeline on slow callbacks —
outbound POSTs run on a per-session ordered queue with the configured timeout.
---
## 9. Optional: synchronous convenience mode (v1.1, behind a flag)
Some simple callers genuinely want "POST a message, get the reply in the HTTP
response" and don't care about streaming/multi-part. We can offer an **opt-in**
sync endpoint that internally waits for `is_final` and **collapses** all 1→M
parts into one array:
```
POST /bots/{bot_uuid}/sync → 200 { session_id, message: [ ...all parts concatenated... ] }
```
Implemented by attaching a per-request future that resolves on the final reply,
with a hard timeout. This is a **convenience wrapper** over the same machinery,
explicitly documented as lossy for streaming/ordering. Not in v1 core.
---
## 10. Adapter implementation sketch (`platform/sources/http_bot.py`)
Implements `AbstractMessagePlatformAdapter`. Key methods:
```python
class HttpBotAdapter(AbstractMessagePlatformAdapter):
listeners: dict = pydantic.Field(default_factory=dict, exclude=True)
# --- inbound -------------------------------------------------------
async def handle_unified_webhook(self, bot_uuid, path, request):
body = await request.get_body()
if self.config.get("signature_required", True):
if not self._verify(request, body):
return jsonify({"code": 40101, "msg": "invalid signature"}), 401
data = json.loads(body)
session_id = data["session_id"] # caller-defined identity
session_type = data.get("session_type", self.config.get("default_session_type", "person"))
chain = MessageChain.model_validate(data["message"])
event = self._build_event(session_type, session_id, data.get("sender"), chain)
# remember where to send replies for this session
self._callback_for[session_id] = data.get("callback_url") or self.config.get("callback_url")
# fire the registered listener → botmgr → msg_aggregator (N→1) → pipeline
if type(event) in self.listeners:
asyncio.create_task(self.listeners[type(event)](event, self))
return jsonify({"code": 0, "msg": "accepted",
"data": {"session_id": session_id, "accepted_message_id": event.message_id}}), 202
# --- outbound (called 1..M times per turn by the pipeline) ---------
async def reply_message(self, message_source, message, quote_origin=False):
return await self._post_callback(message_source, message, is_final=True, stream=False)
async def reply_message_chunk(self, message_source, bot_message, message,
quote_origin=False, is_final=False):
return await self._post_callback(message_source, message, is_final=is_final, stream=True)
async def is_stream_output_supported(self) -> bool:
return True
def register_listener(self, event_type, func): self.listeners[event_type] = func
def unregister_listener(self, event_type, func): self.listeners.pop(event_type, None)
async def run_async(self): pass # nothing to poll; purely webhook-driven
async def kill(self): pass
```
`_post_callback` resolves the session's callback URL, assigns the next
`sequence`, signs the body, and enqueues an ordered, retrying POST.
Session→callback mapping is kept in a small in-memory dict keyed by
`session_id` (acceptable for v1; a turn's callback URL is captured at inbound
time so replies always have a destination even if config later changes).
---
## 11. Security considerations
- **Inbound route is `AuthType.NONE`** at the framework level (same as all
webhook adapters) — the adapter **must** enforce HMAC itself. Default
`signature_required: true`.
- **Timestamp window** (±300s) + idempotency key blunt replay.
- **SSRF on callback_url**: validate scheme (`https` in prod), and consider an
allow-list / block of private CIDRs since LangBot initiates the POST. Document
this; enforce in code where feasible.
- **Secret storage**: secrets live in the bot's `adapter_config` like every
other adapter credential; surfaced as `type: string`/secret in the dashboard.
- **One secret per bot** in v1. Per-caller key rotation / multiple keys is a
future enhancement (§13).
---
## 12. Developer Experience (explicit deliverables)
The whole point of a standalone adapter is that **integrating is pleasant**. v1
ships:
1. **`docs/platforms/http-bot.md`** — task-oriented integration guide:
create the bot → copy inbound URL → set secret → stand up a callback
endpoint → send first message → handle 1→M.
2. **Copy-paste curl** for the first message (with a working signing one-liner).
3. **Reference clients** (≤50 LOC each) in `examples/http-bot/`:
`client.py` (push + a Flask/Quart callback receiver) and `client.ts`.
4. **OpenAPI fragment** `docs/http-bot-openapi.json` describing inbound +
callback shapes, so integrators can codegen.
5. **Local echo recipe**: a one-command callback server that prints every
reply, so a developer sees N→1 and 1→M working in under five minutes.
6. **Postman/Hoppscotch collection** (nice-to-have).
DX acceptance check: *a developer who has never seen LangBot can, from the docs
alone, push a message and observe a multi-part reply on their callback within
10 minutes.*
### Quickstart (curl)
```bash
BOT=https://your-langbot/bots/2f1c....
SECRET=supersecret
BODY='{"session_id":"ticket-10293","message":[{"type":"Plain","text":"hello"}]}'
TS=$(date +%s)
SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)"
curl -sS -X POST "$BOT" \
-H "Content-Type: application/json" \
-H "X-LB-Timestamp: $TS" \
-H "X-LB-Signature: $SIG" \
-d "$BODY"
```
---
## 13. Future work
- **Durable outbound queue** (persist + replay across restarts; exactly-once).
- **Per-caller API keys** with rotation and scopes (multi-tenant Space usage).
- **Sync convenience endpoint** (§9) once core is stable.
- **Server-Sent Events outbound option** for callers that *do* want a stream but
not a full duplex socket — single GET, server pushes chunks.
- **Dashboard "test console"** for `http_bot` (send a message, watch callbacks)
mirroring the existing WebSocket debug panel.
---
## 14. Rollout / task breakdown
| # | Task | Touches |
|---|---|---|
| 1 | `http_bot.yaml` manifest + icon | `platform/sources/` |
| 2 | `HttpBotAdapter` (inbound verify, event build, outbound queue) | `platform/sources/http_bot.py` |
| 3 | Signing helper module (shared) | `platform/sources/` or `utils/` |
| 4 | i18n strings (en/zh/ja) | adapter yaml + web locale |
| 5 | Integration docs `docs/platforms/http-bot.md` | `docs/` |
| 6 | OpenAPI fragment + reference clients | `docs/`, `examples/http-bot/` |
| 7 | Tests: signature verify, N→1 aggregation, 1→M ordering, retry | `tests/` |
| 8 | (opt) SSRF guard for callback_url | adapter |
No changes required to: the unified webhook router, the aggregator, the query
pool, or the pipeline. That is the design's main payoff.
---
## 15. Resolved decisions
1. **Callback URL trust****config-only.** The inbound message may not carry a
`callback_url`; replies always go to the bot-config URL. Closes the SSRF
vector where a leaked inbound secret could redirect replies.
2. **Session lifecycle****`POST /bots/<uuid>/reset`** (body `{session_id,
session_type?}`) drops the matching session from the session manager; the
next message starts a fresh conversation. Implemented via sub-path routing in
`handle_unified_webhook`.
3. **Group semantics** — for `session_type: group`, `session_id` is the group/
launcher id; `sender.id` (and optional `sender.group_name`) identify the
member. A Space ticket maps to one `session_id`.
4. **Backpressure** — bounded per-session outbound queue (maxlen 1000); on
overflow the oldest reply is dropped and a warning logged, so a persistently
down callback can never exhaust memory.
### Still open / deferred (see §13)
- Durable outbound queue (persist + replay across restarts).
- Per-caller API keys with rotation/scopes for multi-tenant Space usage.
- SSE outbound option and a dashboard test console.
+198
View File
@@ -0,0 +1,198 @@
{
"openapi": "3.0.3",
"info": {
"title": "LangBot HTTP Bot Adapter",
"version": "1.0.0",
"description": "Server-to-server HTTP integration for a LangBot pipeline. Inbound messages are POSTed to the unified webhook route; replies are delivered to a configured callback URL (one POST per reply part). All requests are HMAC-SHA256 signed. See docs/platforms/http-bot.md."
},
"paths": {
"/bots/{bot_uuid}": {
"post": {
"summary": "Push a message into the pipeline (fire-and-collect)",
"description": "Returns 202 immediately. Replies arrive asynchronously on the configured callback URL. Reuse the same session_id within the aggregation window to merge multiple messages into one turn (N->1).",
"parameters": [
{ "$ref": "#/components/parameters/BotUuid" },
{ "$ref": "#/components/parameters/Timestamp" },
{ "$ref": "#/components/parameters/Signature" },
{ "$ref": "#/components/parameters/Idempotency" }
],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/InboundMessage" } } }
},
"responses": {
"202": {
"description": "Accepted (queued for the pipeline)",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcceptedResponse" } } }
},
"400": { "$ref": "#/components/responses/Error" },
"401": { "$ref": "#/components/responses/Error" },
"409": { "$ref": "#/components/responses/Error" },
"413": { "$ref": "#/components/responses/Error" }
}
}
},
"/bots/{bot_uuid}/sync": {
"post": {
"summary": "Push a message and wait for the collapsed reply",
"description": "Blocking convenience mode. Waits for is_final and returns all reply parts collapsed into one array. Lossy (no sequence/streaming). One in-flight sync per session_id.",
"parameters": [
{ "$ref": "#/components/parameters/BotUuid" },
{ "$ref": "#/components/parameters/Timestamp" },
{ "$ref": "#/components/parameters/Signature" }
],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/InboundMessage" } } }
},
"responses": {
"200": {
"description": "The collapsed reply",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SyncResponse" } } }
},
"400": { "$ref": "#/components/responses/Error" },
"401": { "$ref": "#/components/responses/Error" }
}
}
},
"/bots/{bot_uuid}/reset": {
"post": {
"summary": "Reset a session's conversation",
"parameters": [
{ "$ref": "#/components/parameters/BotUuid" },
{ "$ref": "#/components/parameters/Timestamp" },
{ "$ref": "#/components/parameters/Signature" }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["session_id"],
"properties": {
"session_id": { "type": "string" },
"session_type": { "type": "string", "enum": ["person", "group"] }
}
}
}
}
},
"responses": {
"200": { "description": "Reset done" },
"400": { "$ref": "#/components/responses/Error" },
"401": { "$ref": "#/components/responses/Error" }
}
}
}
},
"components": {
"parameters": {
"BotUuid": {
"name": "bot_uuid", "in": "path", "required": true,
"schema": { "type": "string", "format": "uuid" }
},
"Timestamp": {
"name": "X-LB-Timestamp", "in": "header", "required": true,
"description": "Unix seconds; rejected if more than +/-300s from server time.",
"schema": { "type": "string" }
},
"Signature": {
"name": "X-LB-Signature", "in": "header", "required": true,
"description": "sha256=<hex> of HMAC-SHA256(secret, \"{timestamp}.\" + raw_body).",
"schema": { "type": "string" }
},
"Idempotency": {
"name": "X-LB-Idempotency-Key", "in": "header", "required": false,
"description": "Dedup key; a repeat within the dedup window returns 409.",
"schema": { "type": "string" }
}
},
"schemas": {
"Segment": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "type": "string", "enum": ["Plain", "Image", "Voice", "File", "At", "Quote"] },
"text": { "type": "string", "description": "For type=Plain." },
"url": { "type": "string", "description": "For media types." },
"base64": { "type": "string", "description": "For media types (data URI or raw base64)." }
}
},
"InboundMessage": {
"type": "object",
"required": ["session_id", "message"],
"properties": {
"session_id": { "type": "string", "description": "Caller-defined; maps 1:1 to a LangBot session." },
"session_type": { "type": "string", "enum": ["person", "group"], "default": "person" },
"sender": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"group_name": { "type": "string", "description": "For session_type=group." }
}
},
"message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } }
}
},
"AcceptedResponse": {
"type": "object",
"properties": {
"code": { "type": "integer", "example": 0 },
"msg": { "type": "string", "example": "accepted" },
"data": {
"type": "object",
"properties": {
"session_id": { "type": "string" },
"accepted_message_id": { "type": "string", "example": "in_01H..." },
"aggregating": { "type": "boolean" }
}
}
}
},
"SyncResponse": {
"type": "object",
"properties": {
"code": { "type": "integer", "example": 0 },
"msg": { "type": "string", "example": "ok" },
"data": {
"type": "object",
"properties": {
"session_id": { "type": "string" },
"reply_to": { "type": "string" },
"message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } }
}
}
}
},
"Callback": {
"type": "object",
"description": "Delivered by LangBot to your callback_url, one POST per reply part. Signed with the outbound secret.",
"properties": {
"session_id": { "type": "string" },
"reply_to": { "type": "string", "description": "The accepted_message_id this answers." },
"sequence": { "type": "integer", "description": "1-based ordinal within the turn." },
"is_final": { "type": "boolean", "description": "True on the last part of the turn." },
"stream": { "type": "boolean" },
"message": { "type": "array", "items": { "$ref": "#/components/schemas/Segment" } },
"timestamp": { "type": "string", "format": "date-time" }
}
},
"ErrorEnvelope": {
"type": "object",
"properties": {
"code": { "type": "integer", "example": 40101 },
"msg": { "type": "string", "example": "invalid signature: signature_mismatch" },
"data": { "nullable": true }
}
}
},
"responses": {
"Error": {
"description": "Error envelope",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }
}
}
}
}
+256
View File
@@ -0,0 +1,256 @@
# HTTP Bot Adapter — Integration Guide
Integrate **any backend system** with a LangBot pipeline over plain HTTP. Push
messages in via a signed webhook; receive replies on a callback URL. No
long-lived connection, full support for message **aggregation** (many inbound
messages merged into one turn) and **multi-part replies** (one turn → many
outbound messages).
This is the right adapter for **server-to-server** integrations — ticketing
systems, CRMs, internal tools, custom web backends. (For an in-browser,
real-time chat widget, use the embeddable Web Page Bot instead.)
> **5-minute goal:** stand up a callback receiver, send a message, and watch a
> multi-part reply arrive — using the reference client in
> [`examples/http-bot/`](../../examples/http-bot/).
---
## 1. Mental model
```
Your backend ──(1) POST signed message──► LangBot /bots/<bot_uuid>
(pipeline runs: aggregate → think → reply)
Your callback ◄─(2) POST signed reply(s)── LangBot one POST per reply part
```
- **(1) Inbound** is *fire-and-collect*: LangBot answers `202 Accepted`
immediately and does **not** return the pipeline result on that response.
- **(2) Outbound** replies arrive later as separate signed POSTs to your
`callback_url`. A single turn may produce **several** callbacks (e.g. a tool
call narration followed by the final answer).
- Everything is keyed by a **`session_id` you choose** (e.g. a ticket number).
Each `session_id` maps to one isolated LangBot conversation.
---
## 2. Create the bot
1. In the LangBot dashboard, add a bot and choose the **HTTP Bot** platform.
2. Fill in the config:
| Field | Required | Notes |
|---|---|---|
| **Inbound Signing Secret** | yes | Your backend signs inbound requests with this. |
| **Outbound Callback URL** | yes | Where LangBot POSTs replies. **Config-only** — cannot be overridden per message (SSRF protection). |
| **Outbound Signing Secret** | no | LangBot signs callbacks with this; defaults to the inbound secret. |
| **Default Session Type** | no | `person` (default) or `group`. |
| **Require Inbound Signature** | no | Keep `true` in production. |
| **Callback Timeout / Max Retries** | no | Defaults: 15s, 3 retries. |
3. Bind the bot to a **pipeline** and **enable** it.
4. Copy the **Inbound Webhook URL** shown in the config — it looks like
`https://your-langbot/bots/<bot_uuid>`.
---
## 3. The signature scheme
Both directions use the same dependency-free HMAC-SHA256 scheme:
```
signing_string = "{timestamp}." + raw_body_bytes
signature = "sha256=" + hex(HMAC_SHA256(secret, signing_string))
```
Sent as headers:
| Header | Meaning |
|---|---|
| `X-LB-Timestamp` | Unix seconds. Rejected if more than **±300s** from server time. |
| `X-LB-Signature` | `sha256=<hex>` over `"{timestamp}." + body`. |
| `X-LB-Idempotency-Key` | *(optional, inbound)* dedup key; retries with the same key return `409`. |
Verify outbound callbacks the same way, using the **outbound** secret (or the
inbound secret if you left it blank).
A six-line reference implementation is in `examples/http-bot/client.py`
(`sign()` / `verify()`); a Node/TS version is in `client.ts`.
---
## 4. Send your first message (curl)
```bash
BOT="https://your-langbot/bots/<bot_uuid>"
SECRET="your-inbound-secret"
BODY='{"session_id":"ticket-10293","message":[{"type":"Plain","text":"Export keeps failing on the dashboard."}]}'
TS=$(date +%s)
SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)"
curl -sS -X POST "$BOT" \
-H "Content-Type: application/json" \
-H "X-LB-Timestamp: $TS" \
-H "X-LB-Signature: $SIG" \
-d "$BODY"
# -> 202 {"code":0,"msg":"accepted","data":{"session_id":"ticket-10293","accepted_message_id":"in_...","aggregating":true}}
```
The reply(s) will be POSTed to your configured callback URL shortly after.
---
## 5. Inbound request format
`POST /bots/{bot_uuid}`
```jsonc
{
"session_id": "ticket-10293", // REQUIRED. Your stable id. Maps 1:1 to a LangBot session.
"session_type": "person", // optional: "person" | "group"; default from config
"sender": { // optional metadata, surfaced to the pipeline/plugins
"id": "user-5567",
"name": "Alice"
},
"message": [ // REQUIRED. A LangBot MessageChain (array of segments).
{ "type": "Plain", "text": "Export keeps failing on the dashboard." },
{ "type": "Image", "url": "https://example.com/screenshot.png" }
]
}
```
**Message segments.** Text uses `{"type":"Plain","text":"..."}`. Images use
`{"type":"Image","url":"..."}` (or `base64`). Other supported types: `Voice`,
`File`, `At`, `Quote`.
> Note: the callback URL is **not** accepted in the body — it is taken only from
> bot config. This is deliberate (prevents an attacker who obtains the inbound
> secret from redirecting replies to an arbitrary host).
### Aggregation (N → 1)
If your pipeline has **message aggregation** enabled, send several messages with
the **same `session_id`** within the aggregation window and they are merged into
**one** pipeline turn. No special flag — just reuse the `session_id`.
---
## 6. Outbound callback format
LangBot POSTs each reply part to your `callback_url`:
```jsonc
{
"session_id": "ticket-10293", // echoes the inbound session
"reply_to": "in_01H...", // the accepted_message_id this answers
"sequence": 1, // 1-based ordinal within this turn
"is_final": false, // true on the last part of the turn
"stream": false, // true for streamed chunks
"message": [ { "type": "Plain", "text": "Looking into it…" } ],
"timestamp": "2026-06-22T09:00:01Z"
}
```
Your endpoint should return `2xx` quickly. Non-2xx / timeout → LangBot retries
with exponential backoff (up to `callback_max_retries`).
### Multi-part replies (1 → M)
One turn may emit multiple callbacks, delivered **in `sequence` order** for a
given session:
```
seq=1 is_final=false "Checking your export logs…"
seq=2 is_final=false "Found 2 failed exports."
seq=3 is_final=true "Fixed — please try again."
```
Stitch by `session_id` + `sequence`; the turn is complete when
`is_final: true` arrives.
---
## 7. Reset a session
Start a fresh conversation for a `session_id` (drops history):
```
POST /bots/{bot_uuid}/reset
{ "session_id": "ticket-10293", "session_type": "person" }
→ 200 { "code":0, "msg":"reset", "data": { "session_id":"ticket-10293", "removed": true } }
```
Signed exactly like an inbound message.
---
## 8. Synchronous convenience mode
If you don't need streaming/multi-part and just want one reply back on the same
HTTP call, POST to `/sync`. LangBot waits for the turn to finish and returns all
parts **collapsed** into one array:
```
POST /bots/{bot_uuid}/sync
{ "session_id": "ticket-10293", "message": [ { "type":"Plain", "text":"hi" } ] }
→ 200 { "code":0, "msg":"ok",
"data": { "session_id":"ticket-10293", "reply_to":"in_...",
"message": [ {"type":"Plain","text":"..."}, ... ] } }
```
This is **lossy** (you lose `sequence` / streaming boundaries) and blocks up to
`callback_timeout × 4` seconds. Prefer the callback model for anything
real-time or multi-part. Only one in-flight `/sync` per `session_id`.
---
## 9. Error envelope
```jsonc
{ "code": 40101, "msg": "invalid signature: signature_mismatch", "data": null }
```
| HTTP | code | meaning |
|---|---|---|
| 202 | 0 | accepted |
| 400 | 40001 | malformed body / missing `session_id` or `message` |
| 401 | 40101 | bad/expired signature |
| 409 | 40901 | duplicate idempotency key |
| 413 | 41301 | message too large (>1 MiB) |
| 500 | 50001 | internal error |
---
## 10. Try it end-to-end in 5 minutes
```bash
cd examples/http-bot
pip install flask requests
# Terminal 1 — your callback receiver (point the bot's callback_url here, e.g. via a tunnel):
python client.py serve --port 8900 --secret SHARED_SECRET
# Terminal 2 — push a message:
python client.py push \
--url https://your-langbot/bots/<bot_uuid> \
--secret SHARED_SECRET \
--session ticket-1 \
--text "hello"
```
Watch Terminal 1 print each reply part (`[part ]` / `[FINAL]`) with its
sequence number — that's 1→M working, signatures verified.
A machine-readable contract is in
[`docs/http-bot-openapi.json`](../http-bot-openapi.json).
---
## 11. Security checklist
- Keep **Require Inbound Signature** on in production.
- Use **HTTPS** callback URLs; the URL is config-only (no per-message override).
- Treat the secrets like passwords; rotate via the dashboard.
- The inbound route is unauthenticated at the framework level **by design**
security comes entirely from the HMAC signature, so never disable it on a
public deployment.
+75
View File
@@ -0,0 +1,75 @@
# HTTP Bot Adapter — Reference Clients
> English | [中文](./README.zh.md)
Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter.
They show the whole loop: signing a request, pushing a message, and receiving
multi-part replies on a callback endpoint.
Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot).
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
## Files
| File | What it is |
|---|---|
| `playground.py` | **Interactive browser debug console** — a single-file web app you open in a browser to chat with a running `http_bot` bot and watch signing / 202 / callbacks live. Zero extra deps. |
| `client.py` | Python client + Flask callback receiver (`pip install flask requests`). |
| `client.ts` | TypeScript/Node 18+ client + callback receiver, **zero deps** (`npx tsx client.ts`). |
All three implement the identical HMAC-SHA256 scheme
(`sha256=hex(HMAC(secret, "{timestamp}." + body))`) — verified byte-for-byte
against the adapter.
## Interactive playground (recommended first run)
A self-contained web console: type a message in your browser, it is signed and
POSTed to a **running** `http_bot` bot, and the bot's replies stream back into
the page — with a debug panel showing the signature, the `202` ack, and each
callback's `sequence` / signature-verification.
```bash
# From the LangBot repo root, with the backend already running:
PUBLIC_IP=<your-host-ip> ./.venv/bin/python examples/http-bot/playground.py
# then open http://<your-host-ip>:8920/
```
On startup it reads the LangBot API key + the `http_bot` bot from
`data/langbot.db`, and configures that bot (inbound/outbound secret +
`callback_url`) to point back at itself via the LangBot API — the bot reloads
live, no restart needed. Requirements: an enabled `http_bot` bot bound to a
working pipeline, and port `8920` reachable from your browser.
Env knobs: `PUBLIC_IP` (default `127.0.0.1`), `PLAYGROUND_PORT` (default `8920`).
## Headless clients
```bash
# Python — Terminal 1: callback receiver (your callback_url target)
python client.py serve --port 8900 --secret SHARED_SECRET
# Python — Terminal 2: push a message
python client.py push --url https://your-langbot/bots/<BOT_UUID> \
--secret SHARED_SECRET --session ticket-1 --text "hello"
# blocking sync mode
python client.py sync --url https://your-langbot/bots/<BOT_UUID> \
--secret SHARED_SECRET --session ticket-1 --text "hello"
# reset a session
python client.py reset --url https://your-langbot/bots/<BOT_UUID> \
--secret SHARED_SECRET --session ticket-1
```
```bash
# TypeScript (Node 18+)
npx tsx client.ts serve 8900 SHARED_SECRET
npx tsx client.ts push https://your-langbot/bots/<BOT_UUID> SHARED_SECRET ticket-1 "hello"
```
When the bot replies, the receiver prints each part with its `sequence` and an
`[FINAL]` marker on the last one — that's the 1→M multi-reply model in action.
> The bot's `callback_url` must be reachable from LangBot. For local testing,
> expose your receiver with a tunnel (cloudflared / ngrok) and set that URL in
> the bot config.
+71
View File
@@ -0,0 +1,71 @@
# HTTP Bot 适配器 —— 参考客户端
> [English](./README.md) | 中文
面向 LangBot **HTTP Bot** 平台适配器的极简、低依赖客户端示例。
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
1→M 的多段回复。
完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
## 文件清单
| 文件 | 是什么 |
|---|---|
| `playground.py` | **浏览器交互式调试台** —— 单文件 Web 应用,在浏览器里和一个运行中的 `http_bot` bot 对话,实时观察签名 / 202 / 回调。零额外依赖。 |
| `client.py` | Python 客户端 + Flask 回调接收端(`pip install flask requests`)。 |
| `client.ts` | TypeScript/Node 18+ 客户端 + 回调接收端,**零依赖**(`npx tsx client.ts`)。 |
三者实现完全一致的 HMAC-SHA256 签名方案
(`sha256=hex(HMAC(secret, "{timestamp}." + body))`)—— 已与适配器逐字节比对验证。
## 交互式 playground(推荐先跑这个)
一个自包含的 Web 控制台:在浏览器里输入消息,它会被签名并 POST 给一个
**运行中**的 `http_bot` bot,bot 的回复会流式回到页面上 —— 调试面板会显示
签名、`202` 确认,以及每条回调的 `sequence` / 签名验证结果。
```bash
# 在 LangBot 仓库根目录、后端已启动的前提下:
PUBLIC_IP=<你的主机IP> ./.venv/bin/python examples/http-bot/playground.py
# 然后打开 http://<你的主机IP>:8920/
```
启动时它会从 `data/langbot.db` 读取 LangBot API key 和 `http_bot` bot,
并通过 LangBot API 把该 bot 配好(入站/出站密钥 + `callback_url`)指回自己 ——
bot 会热加载,无需重启。前提:有一个已启用、绑定了可用 pipeline 的
`http_bot` bot,且端口 `8920` 能从你的浏览器访问到。
可调环境变量:`PUBLIC_IP`(默认 `127.0.0.1`)、`PLAYGROUND_PORT`(默认 `8920`)。
## 无头客户端
```bash
# Python —— 终端 1:回调接收端(你的 callback_url 指向它)
python client.py serve --port 8900 --secret SHARED_SECRET
# Python —— 终端 2:推送一条消息
python client.py push --url https://your-langbot/bots/<BOT_UUID> \
--secret SHARED_SECRET --session ticket-1 --text "hello"
# 阻塞式同步模式
python client.py sync --url https://your-langbot/bots/<BOT_UUID> \
--secret SHARED_SECRET --session ticket-1 --text "hello"
# 重置一个会话
python client.py reset --url https://your-langbot/bots/<BOT_UUID> \
--secret SHARED_SECRET --session ticket-1
```
```bash
# TypeScript(Node 18+)
npx tsx client.ts serve 8900 SHARED_SECRET
npx tsx client.ts push https://your-langbot/bots/<BOT_UUID> SHARED_SECRET ticket-1 "hello"
```
当 bot 回复时,接收端会逐条打印,带上各自的 `sequence`,并在最后一条标记
`[FINAL]` —— 这就是 1→M 多段回复模型的实际效果。
> bot 的 `callback_url` 必须能从 LangBot 访问到。本地测试时,可用隧道
> (cloudflared / ngrok)把你的接收端暴露出去,并把那个 URL 填进 bot 配置。
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""LangBot HTTP Bot adapter — reference client (Python).
Two things in one file:
1. ``push()`` / ``push_sync()`` — send a message into a LangBot ``http_bot`` bot.
2. A tiny Flask callback receiver that verifies signatures and prints replies,
so you can watch N->1 aggregation and 1->M multi-reply working live.
Usage
-----
pip install flask requests
# Terminal 1 — start the callback receiver (this is your callback_url):
python client.py serve --port 8900 --secret SHARED_SECRET
# Terminal 2 — push a message (async; reply lands on the receiver):
python client.py push \
--url https://your-langbot/bots/<BOT_UUID> \
--secret SHARED_SECRET \
--session ticket-10293 \
--text "Export keeps failing on the dashboard."
# Or push and block for the collapsed reply (sync convenience mode):
python client.py sync --url https://your-langbot/bots/<BOT_UUID> \
--secret SHARED_SECRET --session ticket-10293 --text "hi"
The signing scheme is HMAC-SHA256 over ``"{timestamp}." + raw_body``; see
``sign()`` below — it is intentionally tiny and easy to port.
"""
from __future__ import annotations
import argparse
import hashlib
import hmac
import json
import sys
import time
import uuid
HEADER_TIMESTAMP = 'X-LB-Timestamp'
HEADER_SIGNATURE = 'X-LB-Signature'
HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key'
REPLAY_WINDOW = 300
def sign(secret: str, body: bytes, timestamp: int | None = None) -> tuple[str, str]:
"""Return (timestamp, signature) for *body*."""
ts = str(timestamp if timestamp is not None else int(time.time()))
mac = hmac.new(secret.encode(), f'{ts}.'.encode() + body, hashlib.sha256)
return ts, 'sha256=' + mac.hexdigest()
def verify(secret: str, body: bytes, timestamp: str | None, signature: str | None) -> bool:
"""Verify an inbound signature (used by the callback receiver)."""
if not timestamp or not signature:
return False
try:
if abs(int(time.time()) - int(float(timestamp))) > REPLAY_WINDOW:
return False
except ValueError:
return False
_, expected = sign(secret, body, int(float(timestamp)))
return hmac.compare_digest(expected, signature)
def _post(url: str, secret: str, payload: dict, idempotency: bool = True):
import requests
body = json.dumps(payload, ensure_ascii=False).encode()
ts, sig = sign(secret, body)
headers = {
'Content-Type': 'application/json',
HEADER_TIMESTAMP: ts,
HEADER_SIGNATURE: sig,
}
if idempotency:
headers[HEADER_IDEMPOTENCY] = uuid.uuid4().hex
resp = requests.post(url, data=body, headers=headers, timeout=30)
print(f'-> {resp.status_code} {resp.text}')
return resp
def push(url: str, secret: str, session: str, text: str, session_type: str = 'person'):
"""Fire-and-collect: returns 202 immediately; reply arrives on your callback."""
payload = {
'session_id': session,
'session_type': session_type,
'message': [{'type': 'Plain', 'text': text}],
}
return _post(url.rstrip('/'), secret, payload)
def push_sync(url: str, secret: str, session: str, text: str, session_type: str = 'person'):
"""Blocking convenience: POST to /sync and get the collapsed reply back."""
payload = {
'session_id': session,
'session_type': session_type,
'message': [{'type': 'Plain', 'text': text}],
}
resp = _post(url.rstrip('/') + '/sync', secret, payload, idempotency=False)
return resp
def reset(url: str, secret: str, session: str, session_type: str = 'person'):
"""Reset a session's conversation (next message starts fresh)."""
payload = {'session_id': session, 'session_type': session_type}
return _post(url.rstrip('/') + '/reset', secret, payload, idempotency=False)
def serve(port: int, secret: str):
"""Run a callback receiver that verifies signatures and prints replies."""
from flask import Flask, request
app = Flask(__name__)
@app.route('/', methods=['POST'])
def recv():
raw = request.get_data()
ok = verify(secret, raw, request.headers.get(HEADER_TIMESTAMP), request.headers.get(HEADER_SIGNATURE))
if not ok:
print('!! signature verification FAILED — rejecting')
return {'error': 'bad signature'}, 401
data = json.loads(raw)
text_parts = [c.get('text', '') for c in data.get('message', []) if c.get('type') == 'Plain']
marker = 'FINAL' if data.get('is_final') else 'part '
print(
f'[{marker}] session={data["session_id"]} seq={data["sequence"]} '
f'reply_to={data.get("reply_to")}: {" ".join(text_parts)}'
)
return {'ok': True}
print(f'callback receiver listening on http://0.0.0.0:{port}/ (Ctrl-C to stop)')
app.run(host='0.0.0.0', port=port)
def main(argv=None):
p = argparse.ArgumentParser(description='LangBot HTTP Bot reference client')
sub = p.add_subparsers(dest='cmd', required=True)
sp = sub.add_parser('serve', help='run the callback receiver')
sp.add_argument('--port', type=int, default=8900)
sp.add_argument('--secret', required=True)
for name in ('push', 'sync', 'reset'):
c = sub.add_parser(name)
c.add_argument('--url', required=True, help='https://host/bots/<BOT_UUID>')
c.add_argument('--secret', required=True)
c.add_argument('--session', required=True)
c.add_argument('--session-type', default='person', choices=['person', 'group'])
if name != 'reset':
c.add_argument('--text', required=True)
args = p.parse_args(argv)
if args.cmd == 'serve':
serve(args.port, args.secret)
elif args.cmd == 'push':
push(args.url, args.secret, args.session, args.text, args.session_type)
elif args.cmd == 'sync':
push_sync(args.url, args.secret, args.session, args.text, args.session_type)
elif args.cmd == 'reset':
reset(args.url, args.secret, args.session, args.session_type)
if __name__ == '__main__':
sys.exit(main())
+123
View File
@@ -0,0 +1,123 @@
/**
* LangBot HTTP Bot adapter — reference client (TypeScript / Node 18+).
*
* Zero runtime dependencies (uses global `fetch`, `crypto`, and `http`).
*
* - `push()` : fire-and-collect; reply lands on your callback URL.
* - `pushSync()` : POST /sync and await the collapsed reply.
* - `reset()` : reset a session's conversation.
* - `startReceiver()` : a callback server that verifies signatures and logs
* replies, so you can watch N->1 and 1->M live.
*
* Run the demos:
* npx tsx client.ts serve 8900 SHARED_SECRET
* npx tsx client.ts push https://host/bots/<UUID> SHARED_SECRET ticket-1 "hello"
* npx tsx client.ts sync https://host/bots/<UUID> SHARED_SECRET ticket-1 "hello"
* npx tsx client.ts reset https://host/bots/<UUID> SHARED_SECRET ticket-1
*/
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const HEADER_TIMESTAMP = 'X-LB-Timestamp';
const HEADER_SIGNATURE = 'X-LB-Signature';
const HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key';
const REPLAY_WINDOW = 300;
/** Compute the `sha256=<hex>` signature over `"{ts}." + body`. */
export function sign(secret: string, body: Buffer | string, timestamp?: number): [string, string] {
const ts = String(timestamp ?? Math.floor(Date.now() / 1000));
const buf = typeof body === 'string' ? Buffer.from(body) : body;
const mac = createHmac('sha256', secret).update(Buffer.concat([Buffer.from(`${ts}.`), buf])).digest('hex');
return [ts, `sha256=${mac}`];
}
/** Verify an inbound signature (used by the callback receiver). */
export function verify(secret: string, body: Buffer, timestamp?: string, signature?: string): boolean {
if (!timestamp || !signature) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > REPLAY_WINDOW) return false;
const [, expected] = sign(secret, body, Number(timestamp));
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
interface Segment { type: string; text?: string; url?: string; [k: string]: unknown }
async function post(url: string, secret: string, payload: object, idempotency = true) {
const body = Buffer.from(JSON.stringify(payload));
const [ts, sig] = sign(secret, body);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
[HEADER_TIMESTAMP]: ts,
[HEADER_SIGNATURE]: sig,
};
if (idempotency) headers[HEADER_IDEMPOTENCY] = randomUUID();
const resp = await fetch(url, { method: 'POST', headers, body });
const text = await resp.text();
console.log(`-> ${resp.status} ${text}`);
return { status: resp.status, text };
}
/** Fire-and-collect: 202 now, reply later on your callback URL. */
export function push(url: string, secret: string, session: string, text: string, sessionType = 'person') {
return post(url.replace(/\/$/, ''), secret, {
session_id: session,
session_type: sessionType,
message: [{ type: 'Plain', text }] as Segment[],
});
}
/** Blocking convenience: POST /sync, get the collapsed reply. */
export function pushSync(url: string, secret: string, session: string, text: string, sessionType = 'person') {
return post(`${url.replace(/\/$/, '')}/sync`, secret, {
session_id: session,
session_type: sessionType,
message: [{ type: 'Plain', text }] as Segment[],
}, false);
}
/** Reset a session's conversation. */
export function reset(url: string, secret: string, session: string, sessionType = 'person') {
return post(`${url.replace(/\/$/, '')}/reset`, secret, { session_id: session, session_type: sessionType }, false);
}
/** Run a callback receiver that verifies signatures and prints replies. */
export function startReceiver(port: number, secret: string) {
const server = createServer((req, res) => {
if (req.method !== 'POST') { res.writeHead(405).end(); return; }
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
const ok = verify(secret, raw, req.headers[HEADER_TIMESTAMP.toLowerCase()] as string,
req.headers[HEADER_SIGNATURE.toLowerCase()] as string);
if (!ok) {
console.log('!! signature verification FAILED — rejecting');
res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'bad signature' }));
return;
}
const data = JSON.parse(raw.toString());
const parts = (data.message as Segment[]).filter((c) => c.type === 'Plain').map((c) => c.text).join(' ');
const marker = data.is_final ? 'FINAL' : 'part ';
console.log(`[${marker}] session=${data.session_id} seq=${data.sequence} reply_to=${data.reply_to}: ${parts}`);
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true }));
});
});
server.listen(port, () => console.log(`callback receiver listening on http://0.0.0.0:${port}/ (Ctrl-C to stop)`));
}
// --- CLI ---
const [cmd, ...rest] = process.argv.slice(2);
if (cmd === 'serve') {
startReceiver(Number(rest[0] ?? 8900), rest[1] ?? 'SHARED_SECRET');
} else if (cmd === 'push') {
push(rest[0], rest[1], rest[2], rest[3]);
} else if (cmd === 'sync') {
pushSync(rest[0], rest[1], rest[2], rest[3]);
} else if (cmd === 'reset') {
reset(rest[0], rest[1], rest[2]);
} else if (cmd) {
console.error(`unknown command: ${cmd}`);
process.exit(1);
}
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env python3
"""LangBot HTTP Bot — interactive playground (public, browser-based).
This is a REAL end-to-end demo against the RUNNING LangBot instance on this
host. It is NOT a mock and NOT an in-process import: every message you type in
the browser is signed and POSTed to the live `http_bot` bot at
http://127.0.0.1:5300/bots/<uuid>, and the bot's replies come back to this
server's /callback endpoint over real HTTP, then stream to your browser via SSE.
What it does on startup:
1. Reads the LangBot API key + the http_bot bot from data/langbot.db.
2. Configures the bot via the LangBot API (PUT /api/v1/platform/bots/<uuid>):
sets inbound_secret + outbound_secret + callback_url to point back here.
(LangBot reloads the bot live — no server restart needed.)
3. Serves a chat page on 0.0.0.0:<PORT> so you can open it from the internet.
Run: ./.venv/bin/python examples/http-bot/playground.py
Then open: http://<this-host-public-ip>:<PORT>/
"""
from __future__ import annotations
import asyncio
import json
import os
import sqlite3
import sys
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
sys.path.insert(0, os.path.join(REPO, 'src'))
from aiohttp import web # noqa: E402
import aiohttp # noqa: E402
from langbot.pkg.platform.sources import http_bot_signing as sg # noqa: E402
# ---- config -----------------------------------------------------------------
LANGBOT_BASE = 'http://127.0.0.1:5300'
DB_PATH = os.path.join(REPO, 'data', 'langbot.db')
PUBLIC_IP = os.environ.get('PUBLIC_IP', '127.0.0.1')
PORT = int(os.environ.get('PLAYGROUND_PORT', '8920'))
SECRET = 'playground-shared-secret'
# SSE subscribers: list of asyncio.Queue
subscribers: list[asyncio.Queue] = []
def db_lookup() -> tuple[str, str]:
"""Return (api_key, http_bot_uuid) from the LangBot DB."""
db = sqlite3.connect(DB_PATH)
db.row_factory = sqlite3.Row
api_key = db.execute('SELECT key FROM api_keys LIMIT 1').fetchone()['key']
bot = db.execute("SELECT uuid FROM bots WHERE adapter='http_bot' LIMIT 1").fetchone()
if not bot:
raise SystemExit('No http_bot bot found. Create one in the WebUI first.')
return api_key, bot['uuid']
async def configure_bot(api_key: str, bot_uuid: str, callback_url: str):
"""Point the live bot at this playground via the LangBot API.
update_bot() runs a raw SQL UPDATE with whatever keys we send, so we send a
MINIMAL payload: only adapter_config (built from scratch, not read back —
the GET masks secrets). LangBot reloads + reruns the bot live.
"""
cfg = {
'inbound_secret': SECRET,
'outbound_secret': SECRET,
'callback_url': callback_url,
'signature_required': True,
'default_session_type': 'person',
'callback_timeout': 15,
'callback_max_retries': 3,
}
async with aiohttp.ClientSession() as s:
async with s.put(
f'{LANGBOT_BASE}/api/v1/platform/bots/{bot_uuid}',
headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
json={'adapter_config': cfg},
) as r:
txt = await r.text()
print(f'[configure] PUT adapter_config -> {r.status} {txt[:200]}')
return r.status < 400
async def broadcast(event: dict):
for q in list(subscribers):
try:
q.put_nowait(event)
except Exception:
pass
# ---- HTTP handlers ----------------------------------------------------------
async def index(request: web.Request):
return web.Response(text=PAGE, content_type='text/html')
async def send(request: web.Request):
"""Browser -> here -> signed POST -> live LangBot bot."""
body_in = await request.json()
session_id = body_in.get('session_id') or 'playground-1'
text = body_in.get('text', '')
bot_uuid = request.app['bot_uuid']
payload = {
'session_id': session_id,
'sender': {'id': 'browser-user', 'name': 'You'},
'message': [{'type': 'Plain', 'text': text}],
}
raw = json.dumps(payload, ensure_ascii=False).encode()
ts, sig = sg.sign(SECRET, raw)
url = f'{LANGBOT_BASE}/bots/{bot_uuid}'
# echo what we send to the browser timeline
await broadcast(
{'dir': 'out', 'kind': 'request', 'session_id': session_id, 'text': text, 'url': url, 'sig': sig[:24] + ''}
)
async with aiohttp.ClientSession() as s:
async with s.post(
url,
data=raw,
headers={
'Content-Type': 'application/json',
sg.HEADER_TIMESTAMP: ts,
sg.HEADER_SIGNATURE: sig,
},
) as r:
status = r.status
try:
jr = await r.json()
except Exception:
jr = {'raw': await r.text()}
await broadcast({'dir': 'in', 'kind': 'ack', 'status': status, 'data': jr})
return web.json_response({'status': status, 'data': jr})
async def callback(request: web.Request):
"""Live LangBot bot -> here. Verify signature, stream to browser."""
raw = await request.read()
ok, why = sg.verify(SECRET, raw, request.headers.get(sg.HEADER_TIMESTAMP), request.headers.get(sg.HEADER_SIGNATURE))
data = json.loads(raw)
text = ' '.join(c.get('text', '') for c in data.get('message', []) if c.get('type') == 'Plain')
await broadcast(
{
'dir': 'in',
'kind': 'reply',
'session_id': data.get('session_id'),
'sequence': data.get('sequence'),
'is_final': data.get('is_final'),
'sig_ok': ok,
'sig_why': why,
'text': text,
}
)
return web.json_response({'ok': True})
async def events(request: web.Request):
"""SSE stream to the browser."""
resp = web.StreamResponse(
headers={
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
}
)
await resp.prepare(request)
q: asyncio.Queue = asyncio.Queue()
subscribers.append(q)
try:
await resp.write(b': connected\n\n')
while True:
try:
ev = await asyncio.wait_for(q.get(), timeout=15)
await resp.write(f'data: {json.dumps(ev, ensure_ascii=False)}\n\n'.encode())
except asyncio.TimeoutError:
await resp.write(b': ping\n\n')
except (asyncio.CancelledError, ConnectionResetError):
pass
finally:
if q in subscribers:
subscribers.remove(q)
return resp
PAGE = r"""<!doctype html>
<html lang="zh"><head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>LangBot HTTP Bot · 调试台</title>
<style>
:root{
--bg:#f7f8fa; --panel:#ffffff; --line:#e8eaed; --ink:#1f2329; --mut:#8a909a;
--brand:#2563eb; --brand-soft:#eef3ff; --ok:#16a34a; --bad:#dc2626; --code:#f3f4f6;
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--bg);color:var(--ink);
font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif}
.top{height:52px;background:var(--panel);border-bottom:1px solid var(--line);
display:flex;align-items:center;gap:10px;padding:0 18px}
.logo{width:26px;height:26px;border-radius:7px;background:var(--brand);display:grid;place-items:center;color:#fff;font-weight:700;font-size:14px}
.top b{font-size:15px} .top .ver{font-size:12px;color:var(--mut)}
.dot{width:8px;height:8px;border-radius:50%;background:#cbd2dc;display:inline-block;margin-right:5px;vertical-align:middle}
.dot.on{background:var(--ok)} .dot.off{background:var(--bad)}
.conn{margin-left:auto;font-size:12px;color:var(--mut)}
.wrap{max-width:1080px;margin:0 auto;padding:18px;display:grid;grid-template-columns:1fr 360px;gap:16px}
@media(max-width:880px){.wrap{grid-template-columns:1fr}}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;min-height:0}
.card h3{margin:0;padding:12px 16px;font-size:13px;font-weight:600;color:#4b5563;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px}
.chat{height:62vh}
.msgs{flex:1;overflow:auto;padding:16px;display:flex;flex-direction:column;gap:12px}
.row{display:flex;flex-direction:column;gap:4px;max-width:82%}
.row.me{align-self:flex-end;align-items:flex-end}
.row.bot{align-self:flex-start}
.bub{padding:9px 13px;border-radius:12px;white-space:pre-wrap;word-break:break-word}
.me .bub{background:var(--brand);color:#fff;border-bottom-right-radius:3px}
.bot .bub{background:#f1f3f6;color:var(--ink);border-bottom-left-radius:3px}
.meta{font-size:11px;color:var(--mut)}
.meta .ok{color:var(--ok)} .meta .bad{color:var(--bad)}
.sys{align-self:center;font-size:12px;color:var(--mut);background:#f1f3f6;border-radius:8px;padding:4px 12px}
.bar{display:flex;gap:8px;padding:12px;border-top:1px solid var(--line)}
.bar input{flex:1;border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-size:14px;outline:none}
.bar input:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-soft)}
.bar button{background:var(--brand);color:#fff;border:0;border-radius:9px;padding:0 18px;font-size:14px;font-weight:500;cursor:pointer}
.bar button:disabled{opacity:.5;cursor:default}
.side{height:62vh}
.kv{padding:12px 16px;border-bottom:1px solid var(--line);font-size:12px}
.kv .k{color:var(--mut)} .kv .v{color:var(--ink);word-break:break-all}
.kv code{background:var(--code);border-radius:5px;padding:1px 5px;font-size:11px}
.sessrow{display:flex;align-items:center;gap:8px;padding:10px 16px;border-bottom:1px solid var(--line);font-size:12px}
.sessrow input{flex:1;border:1px solid var(--line);border-radius:7px;padding:5px 8px;font-size:12px}
.sessrow button{border:1px solid var(--line);background:#fff;border-radius:7px;padding:5px 9px;font-size:12px;cursor:pointer;color:#4b5563}
.trace{flex:1;overflow:auto;padding:10px 12px;font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}
.ev{padding:6px 8px;border-radius:7px;margin-bottom:6px;border:1px solid var(--line)}
.ev .t{font-weight:600;font-size:10px;letter-spacing:.3px;text-transform:uppercase}
.ev.out{background:#f5f8ff;border-color:#dbe6ff}.ev.out .t{color:var(--brand)}
.ev.ack{background:#f4f6f8}.ev.ack .t{color:#6b7280}
.ev.reply{background:#f1faf3;border-color:#cdeed6}.ev.reply .t{color:var(--ok)}
.ev pre{margin:3px 0 0;white-space:pre-wrap;word-break:break-all;color:#374151}
</style></head>
<body>
<div class="top">
<div class="logo">L</div>
<b>HTTP Bot 调试台</b><span class="ver">examples/http-bot</span>
<span class="conn"><span class="dot off" id="cdot"></span><span id="conn">连接中…</span></span>
</div>
<div class="wrap">
<!-- chat -->
<div class="card chat">
<h3>对话 · 真实发往运行中的 http_bot</h3>
<div class="msgs" id="msgs"></div>
<div class="bar">
<input id="msg" placeholder="输入消息,回车发送…" autofocus/>
<button id="send">发送</button>
</div>
</div>
<!-- debug -->
<div class="card side">
<h3>调试信息</h3>
<div class="kv"><span class="k">入站地址</span><br><span class="v"><code id="endpoint">/bots/&lt;uuid&gt;</code></span></div>
<div class="kv"><span class="k">签名</span> <span class="v">HMAC-SHA256 · <code>X-LB-Signature</code></span></div>
<div class="sessrow">
<span class="k">会话</span>
<input id="sid" value="playground-1"/>
<button id="reset">新会话</button>
</div>
<div class="trace" id="trace"></div>
</div>
</div>
<script>
const $=s=>document.querySelector(s);
const msgs=$('#msgs'),trace=$('#trace'),inp=$('#msg'),btn=$('#send'),
conn=$('#conn'),cdot=$('#cdot'),sidIn=$('#sid');
function el(c){const d=document.createElement('div');d.className=c;return d}
function atBottom(n){n.scrollTop=n.scrollHeight}
function bubble(side,text,metaHtml){
const r=el('row '+side),b=el('bub');b.textContent=text;r.appendChild(b);
if(metaHtml){const m=el('meta');m.innerHTML=metaHtml;r.appendChild(m)}
msgs.appendChild(r);atBottom(msgs)}
function sys(t){const d=el('sys');d.textContent=t;msgs.appendChild(d);atBottom(msgs)}
function logEv(kind,title,obj){
const e=el('ev '+kind),t=el('t');t.textContent=title;e.appendChild(t);
if(obj!==undefined){const p=document.createElement('pre');
p.textContent=typeof obj==='string'?obj:JSON.stringify(obj,null,2);e.appendChild(p)}
trace.appendChild(e);atBottom(trace)}
const es=new EventSource('/events');
es.onopen=()=>{conn.textContent='SSE 已连接';cdot.className='dot on'};
es.onerror=()=>{conn.textContent='SSE 断开,重连…';cdot.className='dot off'};
es.onmessage=e=>{const ev=JSON.parse(e.data);
if(ev.kind==='request'){
if(ev.endpoint)$('#endpoint').textContent=ev.url||ev.endpoint;
logEv('out','出站 · 已签名 POST',{url:ev.url,session_id:ev.session_id,'X-LB-Signature':ev.sig});
}else if(ev.kind==='ack'){
const id=ev.data&&ev.data.data&&ev.data.data.accepted_message_id;
sys(`LangBot 已接收 · HTTP ${ev.status}`);
logEv('ack','入站确认 202',{status:ev.status,accepted_message_id:id||'-'});
}else if(ev.kind==='reply'){
const sig=ev.sig_ok?'<span class=ok>验签通过</span>':'<span class=bad>验签失败</span>';
bubble('bot',ev.text,`seq=${ev.sequence} · ${ev.is_final?'<b>FINAL</b>':'中间段'} · ${sig}`);
logEv('reply',`回调 · seq ${ev.sequence}${ev.is_final?' · FINAL':''}`,
{session_id:ev.session_id,sequence:ev.sequence,is_final:ev.is_final,sig_ok:ev.sig_ok,text:ev.text});
}};
async function send(){
const t=inp.value.trim();if(!t)return;inp.value='';btn.disabled=true;
bubble('me',t,'已签名 → POST /bots/&lt;uuid&gt;');
try{await fetch('/send',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({session_id:sidIn.value.trim()||'playground-1',text:t})});}
catch(e){sys('发送失败:'+e)}
btn.disabled=false;inp.focus();}
btn.onclick=send;inp.addEventListener('keydown',e=>{if(e.key==='Enter')send()});
$('#reset').onclick=()=>{sidIn.value='playground-'+Math.random().toString(36).slice(2,7);
sys('已切换到新会话 '+sidIn.value);};
sys('调试台就绪 · 每条消息都会真实发往运行中的 http_bot,右侧可观察签名 / 202 / 回调全过程。');
</script>
</body></html>"""
async def main():
api_key, bot_uuid = db_lookup()
callback_url = f'http://{PUBLIC_IP}:{PORT}/callback'
print(f'[init] http_bot uuid = {bot_uuid}')
print(f'[init] callback_url = {callback_url}')
ok = await configure_bot(api_key, bot_uuid, callback_url)
if not ok:
print('[warn] bot config update failed; check the API key / payload shape')
app = web.Application()
app['bot_uuid'] = bot_uuid
app.router.add_get('/', index)
app.router.add_post('/send', send)
app.router.add_post('/callback', callback)
app.router.add_get('/events', events)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, '0.0.0.0', PORT)
await site.start()
print(f'\n ▶ 打开: http://{PUBLIC_IP}:{PORT}/\n')
while True:
await asyncio.sleep(3600)
if __name__ == '__main__':
asyncio.run(main())
+48
View File
@@ -0,0 +1,48 @@
# Page Bot Adapter — Embed Demo
> English | [中文](./README.zh.md)
A single self-contained HTML page that demos the LangBot **Page Bot**
(`web_page_bot`) embeddable chat widget — the one you drop onto any website with
a single `<script>` tag.
Full guide: [docs.langbot.app — Page Bot](https://docs.langbot.app/en/usage/platforms/webpage).
## Files
| File | What it is |
|---|---|
| `index.html` | **Browser demo** — open it, point it at a running LangBot instance + a Page Bot you created, and it loads the live embed widget so you can chat with the bot exactly as a site visitor would. Zero deps, no build step. |
## How to use
1. In the LangBot WebUI, create a bot with the **Page Bot** (`页面机器人`)
adapter and bind it to a working pipeline. Copy its **bot UUID** from the
generated embed code.
2. Open `index.html` in a browser. Any of these work:
- double-click the file, or
- serve the folder: `python3 -m http.server 8930` then open
`http://localhost:8930/examples/web-page-bot/`.
3. Fill in:
- **LangBot base URL** — where your instance is reachable from the browser
(e.g. `http://localhost:5300`, or your public address).
- **Page Bot UUID** — from step 1.
- **Widget title** — optional, sets the `data-title` attribute.
4. Click **Load widget**. A floating chat bubble appears in the bottom-right
corner — click it and chat.
The page also renders the exact `<script>` snippet you'd paste into your own
site (before `</body>`), and updates it live as you edit the fields.
## What it demonstrates
- The embed contract: `<script data-title="…" src="<base>/api/v1/embed/<uuid>/widget.js"></script>`.
- `widget.js` is served by LangBot pre-configured for that bot UUID — title,
bubble icon, language and optional Cloudflare Turnstile protection all come
from the bot's config, no page changes needed.
- Messages travel over a WebSocket to the bot's bound pipeline; replies stream
back into the bubble.
> The widget loads `widget.js` from your LangBot instance, so the **base URL
> must be reachable from the browser** you open this page in. If LangBot runs on
> a server, use its public address instead of `localhost`.
+44
View File
@@ -0,0 +1,44 @@
# 页面机器人适配器 —— 嵌入演示
> [English](./README.md) | 中文
一个自包含的单文件 HTML 页面,用于演示 LangBot **页面机器人**
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
网站上的那个组件。
完整指南:[docs.langbot.app —— 页面机器人](https://docs.langbot.app/zh/usage/platforms/webpage)。
## 文件清单
| 文件 | 是什么 |
|---|---|
| `index.html` | **浏览器演示页** —— 打开它,填上一个运行中的 LangBot 实例地址 + 你创建的页面机器人,它就会加载真实的嵌入组件,让你像网站访客一样和机器人对话。零依赖,无需构建。 |
## 使用方法
1. 在 LangBot WebUI 中,用 **页面机器人**`web_page_bot`)适配器创建一个机器人,
并绑定一个可用的流水线。从生成的嵌入代码里复制它的 **机器人 UUID**
2. 在浏览器中打开 `index.html`,以下任一方式皆可:
- 直接双击该文件;或
- 起一个静态服务:`python3 -m http.server 8930`,然后打开
`http://localhost:8930/examples/web-page-bot/`
3. 填写:
- **LangBot base URL** —— 你的实例在该浏览器中可访问的地址
(例如 `http://localhost:5300`,或你的公网地址)。
- **页面机器人 UUID** —— 第 1 步里复制的。
- **组件标题** —— 可选,对应 `data-title` 属性。
4. 点击 **Load widget**。页面右下角会出现一个浮动聊天气泡 —— 点开即可对话。
页面还会实时渲染出你需要粘贴到自己网站(放在 `</body>` 前)的那段 `<script>`
代码,并随着你编辑输入框同步更新。
## 它演示了什么
- 嵌入契约:`<script data-title="…" src="<base>/api/v1/embed/<uuid>/widget.js"></script>`
- `widget.js` 由 LangBot 针对该机器人 UUID 预配置后下发 —— 标题、气泡图标、语言
以及可选的 Cloudflare Turnstile 防护,全部来自机器人配置,无需改动页面。
- 消息通过 WebSocket 发往机器人绑定的流水线,回复流式回到气泡中。
> 组件会从你的 LangBot 实例加载 `widget.js`,因此 **base URL 必须能从你打开本页
> 的浏览器访问到**。如果 LangBot 部署在服务器上,请用它的公网地址而非
> `localhost`。
+205
View File
@@ -0,0 +1,205 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LangBot Page Bot · Embed Demo</title>
<style>
:root {
--bg: #f7f8fa; --panel: #ffffff; --line: #e8eaed; --ink: #1f2329;
--mut: #8a909a; --brand: #2563eb; --brand-soft: #eef3ff;
--ok: #16a34a; --bad: #dc2626; --code: #f3f4f6;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"PingFang SC", "Microsoft YaHei", sans-serif;
}
.top {
height: 52px; background: var(--panel); border-bottom: 1px solid var(--line);
display: flex; align-items: center; gap: 10px; padding: 0 18px;
}
.logo {
width: 26px; height: 26px; border-radius: 7px; background: var(--brand);
display: grid; place-items: center; color: #fff; font-weight: 700; font-size: 14px;
}
.top b { font-size: 15px; }
.top .ver { font-size: 12px; color: var(--mut); }
.wrap { max-width: 760px; margin: 0 auto; padding: 28px 18px 80px; }
.hero h1 { margin: 8px 0 6px; font-size: 22px; }
.hero p { margin: 0 0 4px; color: var(--mut); }
.card {
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
padding: 20px; margin-top: 20px;
}
.card h3 {
margin: 0 0 14px; font-size: 14px; font-weight: 600; color: #4b5563;
display: flex; align-items: center; gap: 8px;
}
.card h3 .num {
width: 20px; height: 20px; border-radius: 50%; background: var(--brand-soft);
color: var(--brand); display: grid; place-items: center; font-size: 12px; font-weight: 700;
}
.field { margin-bottom: 14px; }
.field:last-child { margin-bottom: 0; }
.field label { display: block; font-size: 12px; color: var(--mut); margin-bottom: 5px; }
.field input {
width: 100%; border: 1px solid var(--line); border-radius: 9px;
padding: 10px 12px; font-size: 14px; outline: none; font-family: inherit;
}
.field input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
.hint { font-size: 12px; color: var(--mut); margin-top: 5px; }
.hint code { background: var(--code); border-radius: 5px; padding: 1px 5px; font-size: 11px; }
.actions { display: flex; gap: 10px; margin-top: 18px; align-items: center; }
button {
border: 0; border-radius: 9px; padding: 10px 18px; font-size: 14px;
font-weight: 500; cursor: pointer; font-family: inherit;
}
.btn-primary { background: var(--brand); color: #fff; }
.btn-primary:disabled { opacity: .5; cursor: default; }
.btn-ghost { background: #fff; border: 1px solid var(--line); color: #4b5563; }
.status { font-size: 13px; color: var(--mut); }
.status .ok { color: var(--ok); }
.status .bad { color: var(--bad); }
pre {
background: #0f172a; color: #e2e8f0; border-radius: 10px; padding: 14px 16px;
overflow: auto; font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
margin: 0;
}
.snippet-row { position: relative; }
.snippet-row .copy {
position: absolute; top: 10px; right: 10px; background: rgba(255,255,255,.12);
color: #fff; border: 0; border-radius: 7px; padding: 5px 10px; font-size: 12px; cursor: pointer;
}
ul.steps { margin: 0; padding-left: 18px; color: #4b5563; }
ul.steps li { margin-bottom: 6px; }
</style>
</head>
<body>
<div class="top">
<div class="logo">L</div>
<b>Page Bot · Embed Demo</b>
<span class="ver">examples/web-page-bot</span>
</div>
<div class="wrap">
<div class="hero">
<h1>Try the LangBot Page Bot widget</h1>
<p>Point this page at a running LangBot instance and a <strong>Page Bot</strong> you created,</p>
<p>then load the live embed widget below to chat with it — exactly as your site visitors would.</p>
</div>
<div class="card">
<h3><span class="num">1</span> Connect your Page Bot</h3>
<div class="field">
<label for="base">LangBot base URL</label>
<input id="base" placeholder="http://localhost:5300" value="http://localhost:5300" />
<div class="hint">The address where your LangBot instance is reachable from this browser. No trailing slash.</div>
</div>
<div class="field">
<label for="uuid">Page Bot UUID</label>
<input id="uuid" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
<div class="hint">Create a bot with the <code>Page Bot</code> adapter in the WebUI, then copy its UUID from the embed code.</div>
</div>
<div class="field">
<label for="title">Widget title (optional)</label>
<input id="title" placeholder="LangBot" value="LangBot" />
</div>
<div class="actions">
<button id="load" class="btn-primary">Load widget</button>
<button id="unload" class="btn-ghost">Remove widget</button>
<span class="status" id="status">Not loaded.</span>
</div>
</div>
<div class="card">
<h3><span class="num">2</span> The embed snippet</h3>
<p style="margin:0 0 12px;color:var(--mut)">This is exactly what you paste into your own site (before <code>&lt;/body&gt;</code>). It updates as you edit the fields above.</p>
<div class="snippet-row">
<button class="copy" id="copy">Copy</button>
<pre id="snippet">&lt;script data-title="LangBot" src="http://localhost:5300/api/v1/embed/&lt;bot-uuid&gt;/widget.js"&gt;&lt;/script&gt;</pre>
</div>
</div>
<div class="card">
<h3><span class="num">3</span> How it works</h3>
<ul class="steps">
<li>The <code>&lt;script&gt;</code> tag pulls <code>widget.js</code> from your LangBot instance, pre-configured for that bot UUID.</li>
<li>A floating chat bubble appears in the bottom-right corner of the page.</li>
<li>Messages travel over a WebSocket to the bot's bound pipeline; replies stream back into the bubble.</li>
<li>Title, bubble icon, language and optional Cloudflare Turnstile protection are all set in the bot's config — no page changes needed.</li>
</ul>
</div>
</div>
<script>
var $ = function (s) { return document.querySelector(s); };
var baseEl = $("#base"), uuidEl = $("#uuid"), titleEl = $("#title"),
statusEl = $("#status"), snippetEl = $("#snippet");
var WIDGET_ID = "langbot-embed-demo-script";
function clean(v) { return (v || "").trim().replace(/\/+$/, ""); }
function buildSrc() {
var base = clean(baseEl.value) || "http://localhost:5300";
var uuid = uuidEl.value.trim() || "<bot-uuid>";
return base + "/api/v1/embed/" + uuid + "/widget.js";
}
function refreshSnippet() {
var title = titleEl.value.trim() || "LangBot";
var src = buildSrc();
snippetEl.textContent =
'<script data-title="' + title + '" src="' + src + '"><\/script>';
}
function setStatus(html) { statusEl.innerHTML = html; }
function removeWidget() {
var old = document.getElementById(WIDGET_ID);
if (old) old.remove();
// The widget injects its own DOM (bubble + panel). Clear the common containers it creates.
document.querySelectorAll('[id^="langbot-"]').forEach(function (n) {
if (n.id !== WIDGET_ID) n.remove();
});
}
function loadWidget() {
var uuid = uuidEl.value.trim();
var uuidRe = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
if (!uuidRe.test(uuid)) {
setStatus('<span class="bad">Enter a valid bot UUID first.</span>');
return;
}
removeWidget();
var s = document.createElement("script");
s.id = WIDGET_ID;
s.setAttribute("data-title", titleEl.value.trim() || "LangBot");
s.src = buildSrc();
s.onload = function () {
setStatus('<span class="ok">Widget loaded — look bottom-right.</span>');
};
s.onerror = function () {
setStatus('<span class="bad">Failed to load widget.js — check the base URL and that the bot is enabled.</span>');
};
document.body.appendChild(s);
setStatus("Loading…");
}
$("#load").onclick = loadWidget;
$("#unload").onclick = function () {
removeWidget();
setStatus("Widget removed.");
};
$("#copy").onclick = function () {
navigator.clipboard.writeText(snippetEl.textContent).then(function () {
var b = $("#copy"); b.textContent = "Copied"; setTimeout(function () { b.textContent = "Copy"; }, 1200);
});
};
[baseEl, uuidEl, titleEl].forEach(function (el) { el.addEventListener("input", refreshSnippet); });
refreshSnippet();
</script>
</body>
</html>
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.10.2" version = "4.10.4"
description = "Production-grade platform for building agentic IM bots" description = "Production-grade platform for building agentic IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -70,7 +70,7 @@ dependencies = [
"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", "pyseekdb==1.1.0.post3",
"langbot-plugin==0.4.5", "langbot-plugin==0.4.6",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2", "matrix-nio>=0.25.2",
+2 -32
View File
@@ -48,18 +48,7 @@
}, },
"type": { "type": {
"type": "string", "type": "string",
"enum": [ "enum": ["smoke", "regression", "feature", "provider", "exploratory"]
"smoke",
"regression",
"feature",
"provider",
"exploratory",
"contract",
"performance",
"reliability",
"chaos",
"security"
]
}, },
"priority": { "priority": {
"type": "string", "type": "string",
@@ -113,11 +102,7 @@
"backend_log", "backend_log",
"frontend_log", "frontend_log",
"api_diagnostic", "api_diagnostic",
"filesystem", "filesystem"
"metrics",
"trace",
"profile",
"resource_log"
] ]
}, },
"minItems": 1 "minItems": 1
@@ -203,24 +188,9 @@
"type": "string", "type": "string",
"enum": ["person", "group"] "enum": ["person", "group"]
}, },
"automation_debug_chat_response_p95_ms": {
"type": "string"
},
"automation_debug_chat_max_error_rate": {
"type": "string"
},
"automation_filesystem_checks_json": { "automation_filesystem_checks_json": {
"type": "string" "type": "string"
}, },
"metrics_thresholds_json": {
"type": "string"
},
"load_profile_json": {
"type": "string"
},
"fault_model_json": {
"type": "string"
},
"automation_pipeline_url_env": { "automation_pipeline_url_env": {
"type": "string", "type": "string",
"pattern": "^[A-Z][A-Z0-9_]*$" "pattern": "^[A-Z][A-Z0-9_]*$"
+1 -11
View File
@@ -18,17 +18,7 @@
}, },
"type": { "type": {
"type": "string", "type": "string",
"enum": [ "enum": ["smoke", "regression", "release_gate", "exploratory"]
"smoke",
"regression",
"release_gate",
"exploratory",
"contract",
"performance",
"reliability",
"chaos",
"security"
]
}, },
"priority": { "priority": {
"type": "string", "type": "string",
+1 -79
View File
@@ -54,7 +54,6 @@ const debugChatSessionType = env.LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE || "person"
const pipelineConfigDiagnosticPath = resolve(paths.evidenceDir, "pipeline-config-diagnostic.json"); const pipelineConfigDiagnosticPath = resolve(paths.evidenceDir, "pipeline-config-diagnostic.json");
const debugChatResetDiagnosticPath = resolve(paths.evidenceDir, "debug-chat-reset-diagnostic.json"); const debugChatResetDiagnosticPath = resolve(paths.evidenceDir, "debug-chat-reset-diagnostic.json");
const pipelineConfigRestoreDiagnosticPath = resolve(paths.evidenceDir, "pipeline-config-restore-diagnostic.json"); const pipelineConfigRestoreDiagnosticPath = resolve(paths.evidenceDir, "pipeline-config-restore-diagnostic.json");
const metricsPath = resolve(paths.evidenceDir, "metrics.json");
const startedAt = new Date(); const startedAt = new Date();
let browser; let browser;
@@ -81,11 +80,10 @@ let result = {
console_log: paths.consoleLog, console_log: paths.consoleLog,
network_log: paths.networkLog, network_log: paths.networkLog,
screenshot: paths.screenshot, screenshot: paths.screenshot,
metrics_json: metricsPath,
automation_result_json: paths.automationResultJson, automation_result_json: paths.automationResultJson,
result_json: paths.resultJson, result_json: paths.resultJson,
}, },
evidence_collected: ["ui", "screenshot", "console", "network", "metrics"], evidence_collected: ["ui", "screenshot", "console", "network"],
}; };
function boolFromEnv(value, defaultValue) { function boolFromEnv(value, defaultValue) {
@@ -105,29 +103,6 @@ function parseJsonEnv(key, fallback) {
} }
} }
function positiveNumberEnv(key, fallback) {
const value = Number(env[key] || "");
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function percentile(values, percentileValue) {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1);
return Number(sorted[index].toFixed(3));
}
function stats(values) {
if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 };
return {
min: Number(Math.min(...values).toFixed(3)),
p50: percentile(values, 50),
p95: percentile(values, 95),
p99: percentile(values, 99),
max: Number(Math.max(...values).toFixed(3)),
};
}
function promptStepsFromEnv() { function promptStepsFromEnv() {
const rawSteps = parseJsonEnv("LANGBOT_E2E_PROMPTS_JSON", null); const rawSteps = parseJsonEnv("LANGBOT_E2E_PROMPTS_JSON", null);
if (rawSteps === null) { if (rawSteps === null) {
@@ -683,7 +658,6 @@ try {
} else { } else {
for (let index = 0; index < promptSteps.length; index += 1) { for (let index = 0; index < promptSteps.length; index += 1) {
const step = promptSteps[index]; const step = promptSteps[index];
const promptStartedAt = Date.now();
const chatResult = await runDebugChatPrompt(page, { const chatResult = await runDebugChatPrompt(page, {
prompt: step.prompt, prompt: step.prompt,
expectedText: step.expectedText, expectedText: step.expectedText,
@@ -691,13 +665,11 @@ try {
imagePath: index === 0 ? imagePath : "", imagePath: index === 0 ? imagePath : "",
failureSignals: failureSignals.length > 0 ? failureSignals : undefined, failureSignals: failureSignals.length > 0 ? failureSignals : undefined,
}); });
const promptDurationMs = Date.now() - promptStartedAt;
result.chat_results.push({ result.chat_results.push({
index, index,
expected_text: step.expectedText, expected_text: step.expectedText,
status: chatResult.status, status: chatResult.status,
reason: chatResult.reason, reason: chatResult.reason,
response_duration_ms: promptDurationMs,
min_expected_count: chatResult.min_expected_count, min_expected_count: chatResult.min_expected_count,
final_count: chatResult.final_count, final_count: chatResult.final_count,
before_assistant_expected_count: chatResult.before_assistant_expected_count, before_assistant_expected_count: chatResult.before_assistant_expected_count,
@@ -742,56 +714,6 @@ try {
const finishedAt = new Date(); const finishedAt = new Date();
result.finished_at = finishedAt.toISOString(); result.finished_at = finishedAt.toISOString();
result.finished_at_local = localIsoWithOffset(finishedAt); result.finished_at_local = localIsoWithOffset(finishedAt);
result.duration_ms = finishedAt.getTime() - startedAt.getTime();
const responseDurations = result.chat_results
.map((item) => item.response_duration_ms)
.filter((value) => Number.isFinite(value));
const passedPrompts = result.chat_results.filter((item) => item.status === "pass").length;
const attemptedPrompts = result.chat_results.length;
const errorRate = attemptedPrompts === 0 ? 1 : Number(((attemptedPrompts - passedPrompts) / attemptedPrompts).toFixed(4));
const responseStats = stats(responseDurations);
const responseP95BudgetMs = positiveNumberEnv(
"LANGBOT_E2E_DEBUG_CHAT_RESPONSE_P95_MS",
positiveNumberEnv("LANGBOT_DEBUG_CHAT_RESPONSE_P95_MS", safeResponseTimeoutMs),
);
const maxErrorRate = positiveNumberEnv("LANGBOT_E2E_DEBUG_CHAT_MAX_ERROR_RATE", 0);
const metrics = {
probe: caseId,
url: result.url,
prompt_count: result.prompt_count,
attempted_prompt_count: attemptedPrompts,
passed_prompt_count: passedPrompts,
error_rate: errorRate,
response_duration_ms: responseStats,
total_duration_ms: result.duration_ms,
chat_results: result.chat_results,
};
result.metrics_summary = {
prompt_count: metrics.prompt_count,
attempted_prompt_count: metrics.attempted_prompt_count,
passed_prompt_count: metrics.passed_prompt_count,
error_rate: metrics.error_rate,
response_p50_ms: metrics.response_duration_ms.p50,
response_p95_ms: metrics.response_duration_ms.p95,
total_duration_ms: metrics.total_duration_ms,
};
result.thresholds_summary = {
response_p95_ms: {
actual: metrics.response_duration_ms.p95,
max: responseP95BudgetMs,
pass: attemptedPrompts > 0 && metrics.response_duration_ms.p95 <= responseP95BudgetMs,
},
error_rate: {
actual: metrics.error_rate,
max: maxErrorRate,
pass: metrics.error_rate <= maxErrorRate,
},
};
await writeFile(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`, "utf8");
if (result.status === "pass" && !Object.values(result.thresholds_summary).every((item) => item.pass)) {
result.status = "fail";
result.reason = "Debug Chat performance breached response latency or error-rate thresholds.";
}
const existingEvidence = {}; const existingEvidence = {};
for (const [key, value] of Object.entries(result.evidence)) { for (const [key, value] of Object.entries(result.evidence)) {
if (typeof value !== "string") continue; if (typeof value !== "string") continue;
-231
View File
@@ -130,7 +130,6 @@
"references/local-agent-runner.md", "references/local-agent-runner.md",
"references/mcp-stdio-testing.md", "references/mcp-stdio-testing.md",
"references/model-provider-testing.md", "references/model-provider-testing.md",
"references/performance-reliability-testing.md",
"references/pipeline-debug-chat.md", "references/pipeline-debug-chat.md",
"references/plugin-e2e-smoke.md", "references/plugin-e2e-smoke.md",
"references/sandbox-skill-authoring.md", "references/sandbox-skill-authoring.md",
@@ -151,11 +150,6 @@
"agent-runner-release-preflight", "agent-runner-release-preflight",
"agent-runner-runtime-chaos", "agent-runner-runtime-chaos",
"dify-agent-debug-chat", "dify-agent-debug-chat",
"langbot-fault-taxonomy-contract",
"langbot-live-backend-latency",
"langbot-live-backend-log-health",
"langbot-live-control-plane-api",
"langbot-overhead-accounting-contract",
"langrag-kb-retrieve", "langrag-kb-retrieve",
"langrag-parser-golden-e2e", "langrag-parser-golden-e2e",
"langrag-sentinel-kb-discover", "langrag-sentinel-kb-discover",
@@ -171,7 +165,6 @@
"mcp-stdio-register", "mcp-stdio-register",
"mcp-stdio-tool-call", "mcp-stdio-tool-call",
"pipeline-debug-chat", "pipeline-debug-chat",
"pipeline-debug-chat-performance",
"plugin-e2e-smoke", "plugin-e2e-smoke",
"provider-deepseek", "provider-deepseek",
"qa-plugin-smoke-live-install", "qa-plugin-smoke-live-install",
@@ -493,128 +486,6 @@
"backend_log" "backend_log"
] ]
}, },
{
"id": "langbot-fault-taxonomy-contract",
"title": "LangBot fault taxonomy and cleanup contract",
"mode": "probe",
"area": "reliability",
"type": "chaos",
"priority": "p1",
"risk": "medium",
"ci_eligible": true,
"tags": [
"reliability",
"chaos",
"contract",
"synthetic"
],
"automation": "skills/langbot-testing/probes/langbot-fault-taxonomy-contract.mjs",
"setup_automation": [],
"setup_provides_env": [],
"evidence_required": [
"metrics",
"filesystem"
]
},
{
"id": "langbot-live-backend-latency",
"title": "LangBot live backend basic latency probe",
"mode": "probe",
"area": "performance",
"type": "performance",
"priority": "p1",
"risk": "medium",
"ci_eligible": false,
"tags": [
"performance",
"live-backend",
"latency",
"metrics"
],
"automation": "skills/langbot-testing/probes/langbot-live-backend-latency.mjs",
"setup_automation": [],
"setup_provides_env": [],
"evidence_required": [
"metrics",
"network",
"api_diagnostic",
"filesystem"
]
},
{
"id": "langbot-live-backend-log-health",
"title": "LangBot live backend log health probe",
"mode": "probe",
"area": "reliability",
"type": "reliability",
"priority": "p1",
"risk": "medium",
"ci_eligible": false,
"tags": [
"reliability",
"live-backend",
"backend-log",
"metrics"
],
"automation": "skills/langbot-testing/probes/langbot-live-backend-log-health.mjs",
"setup_automation": [],
"setup_provides_env": [],
"evidence_required": [
"metrics",
"backend_log",
"filesystem"
]
},
{
"id": "langbot-live-control-plane-api",
"title": "LangBot live control-plane API probe",
"mode": "probe",
"area": "performance",
"type": "performance",
"priority": "p1",
"risk": "medium",
"ci_eligible": false,
"tags": [
"performance",
"reliability",
"live-backend",
"control-plane",
"metrics"
],
"automation": "skills/langbot-testing/probes/langbot-live-control-plane-api.mjs",
"setup_automation": [],
"setup_provides_env": [],
"evidence_required": [
"metrics",
"network",
"api_diagnostic",
"filesystem"
]
},
{
"id": "langbot-overhead-accounting-contract",
"title": "LangBot overhead accounting metrics contract",
"mode": "probe",
"area": "performance",
"type": "performance",
"priority": "p1",
"risk": "medium",
"ci_eligible": true,
"tags": [
"performance",
"metrics",
"contract",
"synthetic"
],
"automation": "skills/langbot-testing/probes/langbot-overhead-accounting-contract.mjs",
"setup_automation": [],
"setup_provides_env": [],
"evidence_required": [
"metrics",
"resource_log",
"filesystem"
]
},
{ {
"id": "langrag-kb-retrieve", "id": "langrag-kb-retrieve",
"title": "LangRAG knowledge base ingests and retrieves a sentinel document", "title": "LangRAG knowledge base ingests and retrieves a sentinel document",
@@ -1040,33 +911,6 @@
"backend_log" "backend_log"
] ]
}, },
{
"id": "pipeline-debug-chat-performance",
"title": "Pipeline Debug Chat user-path performance probe",
"mode": "agent-browser",
"area": "pipeline",
"type": "performance",
"priority": "p1",
"risk": "medium",
"ci_eligible": false,
"tags": [
"performance",
"pipeline",
"debug-chat",
"user-path",
"metrics"
],
"automation": "scripts/e2e/pipeline-debug-chat.mjs",
"setup_automation": [],
"setup_provides_env": [],
"evidence_required": [
"ui",
"screenshot",
"console",
"network",
"metrics"
]
},
{ {
"id": "plugin-e2e-smoke", "id": "plugin-e2e-smoke",
"title": "Plugin system installs a local plugin and exposes tool/page APIs", "title": "Plugin system installs a local plugin and exposes tool/page APIs",
@@ -1215,10 +1059,6 @@
"suites": [ "suites": [
"agent-runner-release-gate", "agent-runner-release-gate",
"core-smoke", "core-smoke",
"langbot-live-backend-gate",
"langbot-performance-contract-gate",
"langbot-performance-reliability-gate",
"langbot-user-path-performance-gate",
"local-agent-gate" "local-agent-gate"
], ],
"suite_summaries": [ "suite_summaries": [
@@ -1281,77 +1121,6 @@
"local-agent-basic-debug-chat" "local-agent-basic-debug-chat"
] ]
}, },
{
"id": "langbot-live-backend-gate",
"title": "LangBot live backend reliability gate",
"description": "Live backend control-plane responsiveness and runtime log health checks for a locally running LangBot instance.",
"type": "reliability",
"priority": "p1",
"tags": [
"performance",
"reliability",
"live-backend",
"metrics"
],
"cases": [
"langbot-live-backend-latency",
"langbot-live-control-plane-api",
"langbot-live-backend-log-health"
]
},
{
"id": "langbot-performance-contract-gate",
"title": "LangBot performance contract gate",
"description": "Fast synthetic contract checks for performance metric accounting and non-destructive reliability fault taxonomy.",
"type": "contract",
"priority": "p1",
"tags": [
"performance",
"reliability",
"contract",
"metrics"
],
"cases": [
"langbot-overhead-accounting-contract",
"langbot-fault-taxonomy-contract"
]
},
{
"id": "langbot-performance-reliability-gate",
"title": "LangBot performance and reliability starter gate",
"description": "Starter gate for LangBot performance accounting, live backend control-plane latency, and non-destructive fault taxonomy checks.",
"type": "reliability",
"priority": "p1",
"tags": [
"performance",
"reliability",
"metrics",
"chaos"
],
"cases": [
"langbot-overhead-accounting-contract",
"langbot-fault-taxonomy-contract",
"langbot-live-backend-latency",
"langbot-live-control-plane-api",
"langbot-live-backend-log-health"
]
},
{
"id": "langbot-user-path-performance-gate",
"title": "LangBot user-path performance gate",
"description": "Browser-visible performance checks for user-facing LangBot paths such as Pipeline Debug Chat.",
"type": "performance",
"priority": "p1",
"tags": [
"performance",
"browser",
"debug-chat",
"user-path"
],
"cases": [
"pipeline-debug-chat-performance"
]
},
{ {
"id": "local-agent-gate", "id": "local-agent-gate",
"title": "Local Agent runner regression gate", "title": "Local Agent runner regression gate",
+32
View File
@@ -42,6 +42,38 @@ MyPlugin/
Each component has a `.yaml` (metadata) and `.py` (implementation). Each component has a `.yaml` (metadata) and `.py` (implementation).
## README & i18n convention (enforced on the marketplace)
A plugin published to LangBot Space serves a localized README on its detail page.
The resolver (`langbot-space` `PluginService.GetPluginREADME`) works like this:
- **Root `README.md` MUST be in English.** It is the default and the fallback —
when no per-language README matches the viewer's locale, the page serves the
root `README.md`. A non-English root README makes the English/default view show
the wrong language.
- **All other languages live under `readme/README_{lang}.md`** — e.g.
`readme/README_zh_Hans.md`, `readme/README_ja_JP.md`. The 8 supported locales:
`en_US, zh_Hans, zh_Hant, ja_JP, th_TH, vi_VN, es_ES, ru_RU`.
- `manifest.yaml` `metadata.label` / `metadata.description` should carry the same
8-locale i18n set (`repository` must be a real, alive URL).
```
MyPlugin/
├── manifest.yaml
├── README.md # English (default + fallback) — REQUIRED, must be English
└── readme/
├── README_zh_Hans.md
├── README_zh_Hant.md
├── README_ja_JP.md
├── README_th_TH.md
├── README_vi_VN.md
├── README_es_ES.md
└── README_ru_RU.md
```
`manifest.yaml` (incl. `repository`) is the source of truth — the marketplace
syncs from it, so edit the package and re-publish rather than patching live data.
## Critical SDK Pitfalls ## Critical SDK Pitfalls
### 1. MessageChain is a RootModel — iterate directly ### 1. MessageChain is a RootModel — iterate directly
-3
View File
@@ -21,7 +21,6 @@ Use this skill when an agent needs to verify LangBot behavior through the WebUI
- **Sandbox-backed skill authoring**: read `references/sandbox-skill-authoring.md`. - **Sandbox-backed skill authoring**: read `references/sandbox-skill-authoring.md`.
- **LangRAG knowledge bases**: read `references/langrag-knowledge-base.md`. - **LangRAG knowledge bases**: read `references/langrag-knowledge-base.md`.
- **MCP stdio tool testing**: read `references/mcp-stdio-testing.md`. - **MCP stdio tool testing**: read `references/mcp-stdio-testing.md`.
- **Performance, reliability, or chaos probes**: read `references/performance-reliability-testing.md`.
- **Drive a live instance over MCP (not raw HTTP)**: use the `langbot-mcp-ops` skill — the instance exposes an MCP server at `http://<host>:5300/mcp` (reuses API keys). Useful for setting up bots/pipelines/models as test fixtures programmatically. - **Drive a live instance over MCP (not raw HTTP)**: use the `langbot-mcp-ops` skill — the instance exposes an MCP server at `http://<host>:5300/mcp` (reuses API keys). Useful for setting up bots/pipelines/models as test fixtures programmatically.
- **Known failures and fixes**: read `references/troubleshooting.md`. - **Known failures and fixes**: read `references/troubleshooting.md`.
- **Reusable test groups**: run `bin/lbs suite list` and `bin/lbs suite plan <suite-id>` before manually assembling a case set. - **Reusable test groups**: run `bin/lbs suite list` and `bin/lbs suite plan <suite-id>` before manually assembling a case set.
@@ -37,8 +36,6 @@ Use this skill when an agent needs to verify LangBot behavior through the WebUI
- Use an authenticated browser profile prepared by `langbot-env-setup`. - Use an authenticated browser profile prepared by `langbot-env-setup`.
- Do not expose API keys, OAuth secrets, tokens, or localStorage token values in output. - Do not expose API keys, OAuth secrets, tokens, or localStorage token values in output.
- A WebUI test is not complete until the visible UI result is checked against backend logs or network behavior. - A WebUI test is not complete until the visible UI result is checked against backend logs or network behavior.
- A performance result is not complete without `metrics` evidence and a clear split between LangBot overhead and external provider/tool/network time.
- A chaos or reliability result is not complete until the fault scope, cleanup, and recovery checks are recorded.
- For a suite, use `bin/lbs suite start <suite-id>` to create the suite evidence root, per-case directories, and `suite-start.json`/`suite-start.md` handoff files; use `bin/lbs test result <case-id>` to write final per-case `result.json`, then run `bin/lbs suite report <suite-id> --evidence-dir <dir>`. - For a suite, use `bin/lbs suite start <suite-id>` to create the suite evidence root, per-case directories, and `suite-start.json`/`suite-start.md` handoff files; use `bin/lbs test result <case-id>` to write final per-case `result.json`, then run `bin/lbs suite report <suite-id> --evidence-dir <dir>`.
- Do not mark a case `pass` until `test result --evidence` covers every value in the case's `evidence_required`. - Do not mark a case `pass` until `test result --evidence` covers every value in the case's `evidence_required`.
- For runner-specific Debug Chat cases, use the case-specific pipeline env declared by `automation_pipeline_url_env` / `automation_pipeline_name_env`; do not silently reuse a generic `LANGBOT_PIPELINE_URL`. - For runner-specific Debug Chat cases, use the case-specific pipeline env declared by `automation_pipeline_url_env` / `automation_pipeline_name_env`; do not silently reuse a generic `LANGBOT_PIPELINE_URL`.
@@ -1,35 +0,0 @@
id: langbot-fault-taxonomy-contract
title: "LangBot fault taxonomy and cleanup contract"
mode: probe
area: reliability
type: chaos
priority: p1
risk: medium
ci_eligible: true
tags:
- reliability
- chaos
- contract
- synthetic
skills:
- langbot-testing
automation: skills/langbot-testing/probes/langbot-fault-taxonomy-contract.mjs
fault_model_json: '{"kind":"taxonomy-contract","destructive":false,"scenarios":["provider-timeout","plugin-runtime-disconnect","mcp-stdio-server-exit","operator-missing-login","transient-marketplace-timeout"]}'
steps:
- "Run `rtk bin/lbs test run langbot-fault-taxonomy-contract --dry-run` first; remove `--dry-run` after checking the evidence directory."
- "Automation validates that representative fault scenarios declare target, injected fault, expected status, recovery check, and cleanup."
- "Review metrics.json, fault-model.json, and automation-result.json under LBS_EVIDENCE_DIR."
checks:
- "automation-result.json status is pass."
- "Every scenario has an expected status in pass, fail, blocked, env_issue, or flaky."
- "Every scenario declares a cleanup action and recovery check."
evidence_required:
- metrics
- filesystem
diagnostics:
- "This is a non-destructive taxonomy contract probe; it does not inject real runtime faults."
- "Use it as a gate before adding live chaos cases that kill runtimes, route traffic through a proxy, or disrupt a backend dependency."
success_patterns:
- "Fault taxonomy contract declares status"
failure_patterns:
- "missing required scenario fields"
@@ -1,42 +0,0 @@
id: langbot-live-backend-latency
title: "LangBot live backend basic latency probe"
mode: probe
area: performance
type: performance
priority: p1
risk: medium
ci_eligible: false
tags:
- performance
- live-backend
- latency
- metrics
skills:
- langbot-testing
env:
- LANGBOT_BACKEND_URL
automation: skills/langbot-testing/probes/langbot-live-backend-latency.mjs
metrics_thresholds_json: '{"backend_p95_ms":{"max":1000},"error_rate":{"max":0}}'
load_profile_json: '{"requests":12,"concurrency":2,"endpoints":["/healthz"]}'
steps:
- "Confirm the selected LangBot backend is the intended test target."
- "Run `rtk bin/lbs test run langbot-live-backend-latency --dry-run` first; remove `--dry-run` after checking LANGBOT_BACKEND_URL and evidence directory."
- "Automation sends a small request batch to LANGBOT_BACKEND_URL/healthz and records latency, status counts, and network errors."
checks:
- "automation-result.json status is pass when the backend responds and p95/error-rate thresholds pass."
- "automation-result.json status is env_issue when the backend is not reachable."
- "metrics.json and network.log are written under LBS_EVIDENCE_DIR."
evidence_required:
- metrics
- network
- api_diagnostic
- filesystem
diagnostics:
- "This probe measures backend health endpoint reachability latency only; it does not cover model/provider, browser, Debug Chat, RAG, or plugin runtime latency."
success_patterns:
- "Live backend latency probe passed"
failure_patterns:
- "Backend did not respond"
- "breached latency or error-rate thresholds"
troubleshooting:
- socks-proxy-without-socksio
@@ -1,45 +0,0 @@
id: langbot-live-backend-log-health
title: "LangBot live backend log health probe"
mode: probe
area: reliability
type: reliability
priority: p1
risk: medium
ci_eligible: false
tags:
- reliability
- live-backend
- backend-log
- metrics
skills:
- langbot-testing
env:
- LANGBOT_BACKEND_URL
automation: skills/langbot-testing/probes/langbot-live-backend-log-health.mjs
metrics_thresholds_json: '{"fail_count":{"max":0}}'
load_profile_json: '{"lookback_seconds":300,"log_source":"LANGBOT_BACKEND_LOG or latest LANGBOT_REPO/data/logs/langbot-*.log"}'
steps:
- "Confirm the selected LangBot backend log belongs to the intended test target."
- "Run `rtk bin/lbs test run langbot-live-backend-log-health --dry-run` first; remove `--dry-run` after checking evidence directory and log source."
- "Automation scans the recent backend log window for fail-severity runtime findings such as Traceback, ImportError, ERROR, unclosed sessions, and unawaited coroutines."
checks:
- "automation-result.json status is pass only when fail_count is 0."
- "metrics_summary includes scanned_line_count, fail_count, warning_count, and finding_count."
- "findings.json and scanned-backend.log are written under LBS_EVIDENCE_DIR."
evidence_required:
- metrics
- backend_log
- filesystem
diagnostics:
- "Set LANGBOT_BACKEND_LOG to an explicit log path when the latest log file is not the run target."
- "Set LANGBOT_BACKEND_LOG_SINCE or LANGBOT_BACKEND_LOG_LOOKBACK_SECONDS to control the scan window."
- "This probe measures runtime log health; it does not prove user-facing Debug Chat, plugin, model, or RAG behavior."
success_patterns:
- "Live backend log health passed"
failure_patterns:
- "Traceback"
- "ImportError"
- "ERROR"
- "unclosed"
troubleshooting:
- socks-proxy-without-socksio
@@ -1,44 +0,0 @@
id: langbot-live-control-plane-api
title: "LangBot live control-plane API probe"
mode: probe
area: performance
type: performance
priority: p1
risk: medium
ci_eligible: false
tags:
- performance
- reliability
- live-backend
- control-plane
- metrics
skills:
- langbot-testing
env:
- LANGBOT_BACKEND_URL
automation: skills/langbot-testing/probes/langbot-live-control-plane-api.mjs
metrics_thresholds_json: '{"error_rate":{"max":0},"response_shape_failures":{"max":0},"healthz_p95_ms":{"max":500},"system_info_p95_ms":{"max":1000}}'
load_profile_json: '{"requests":20,"concurrency":4,"endpoints":["/healthz","/api/v1/system/info"],"auth_required":false}'
steps:
- "Confirm the selected LangBot backend is the intended test target."
- "Run `rtk bin/lbs test run langbot-live-control-plane-api --dry-run` first; remove `--dry-run` after checking LANGBOT_BACKEND_URL and evidence directory."
- "Automation sends a small request batch to /healthz and /api/v1/system/info, then validates status code, JSON shape, and latency budgets."
checks:
- "automation-result.json status is pass when every control-plane request returns HTTP 200, JSON code 0, and required response fields."
- "metrics_summary includes per-endpoint p50/p95 latency, error rate, status counts, and response_shape_failures."
- "thresholds_summary shows error_rate, response_shape_failures, healthz_p95_ms, and system_info_p95_ms all pass."
evidence_required:
- metrics
- network
- api_diagnostic
- filesystem
diagnostics:
- "This probe measures unauthenticated backend control-plane readiness; it does not cover authenticated UI flows, Debug Chat, model calls, plugins, or RAG."
- "A system_info shape failure usually means the API contract or startup state changed and should be investigated before treating latency as healthy."
success_patterns:
- "Live control-plane API probe passed"
failure_patterns:
- "Backend did not respond"
- "breached shape, latency, or error-rate thresholds"
troubleshooting:
- socks-proxy-without-socksio
@@ -1,37 +0,0 @@
id: langbot-overhead-accounting-contract
title: "LangBot overhead accounting metrics contract"
mode: probe
area: performance
type: performance
priority: p1
risk: medium
ci_eligible: true
tags:
- performance
- metrics
- contract
- synthetic
skills:
- langbot-testing
automation: skills/langbot-testing/probes/langbot-overhead-accounting-contract.mjs
metrics_thresholds_json: '{"sample_count":{"min":50},"langbot_overhead_p95_ms":{"max":25},"accounting_gap_max_ms":{"max":0.001}}'
load_profile_json: '{"kind":"synthetic-overhead-accounting","samples":80,"external_latency_segments":["provider","external_tool","network"]}'
steps:
- "Run `rtk bin/lbs test run langbot-overhead-accounting-contract --dry-run` first; remove `--dry-run` after checking the evidence directory."
- "Automation generates deterministic message-path latency samples and separates LangBot overhead from provider/tool/network latency."
- "Review metrics.json, thresholds.json, resource-log.json, and automation-result.json under LBS_EVIDENCE_DIR."
checks:
- "automation-result.json status is pass."
- "metrics_summary includes sample_count, langbot_overhead_p95_ms, e2e_latency_p95_ms, external_latency_p95_ms, and accounting_gap_max_ms."
- "thresholds_summary shows sample_count, langbot_overhead_p95_ms, and accounting_gap_max_ms all pass."
evidence_required:
- metrics
- resource_log
- filesystem
diagnostics:
- "This is a synthetic contract probe for the QA harness; it is not live product performance."
- "Use it to verify that reports can carry overhead accounting metrics before running live backend or browser performance probes."
success_patterns:
- "Overhead accounting contract passed"
failure_patterns:
- "breached one or more thresholds"
@@ -1,75 +0,0 @@
id: pipeline-debug-chat-performance
title: "Pipeline Debug Chat user-path performance probe"
mode: agent-browser
area: pipeline
type: performance
priority: p1
risk: medium
ci_eligible: false
tags:
- performance
- pipeline
- debug-chat
- user-path
- metrics
skills:
- langbot-env-setup
- langbot-testing
env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
env_any:
- LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME
automation: scripts/e2e/pipeline-debug-chat.mjs
automation_env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_BROWSER_PROFILE
- LANGBOT_CHROMIUM_EXECUTABLE
- LANGBOT_E2E_PROMPT
- LANGBOT_E2E_EXPECTED_TEXT
- LANGBOT_E2E_RESPONSE_TIMEOUT_MS
automation_env_any:
- LANGBOT_PIPELINE_URL|LANGBOT_PIPELINE_NAME
automation_prompt: "请只回复 OK,用于性能测试。"
automation_expected_text: "OK"
automation_response_timeout_ms: "120000"
automation_reset_debug_chat: "true"
automation_debug_chat_response_p95_ms: "120000"
automation_debug_chat_max_error_rate: "0"
metrics_thresholds_json: '{"response_p95_ms":{"max":120000},"error_rate":{"max":0}}'
load_profile_json: '{"prompts":1,"browser":true,"path":"Pipeline Debug Chat","metric":"send-to-visible-completion"}'
preconditions:
- "LANGBOT_PIPELINE_URL or LANGBOT_PIPELINE_NAME points to the pipeline intended for this Debug Chat performance run."
- "The target pipeline is safe to reset Debug Chat history for this run."
- "The target pipeline has a known-good runner/model; provider latency should be interpreted separately from LangBot overhead."
steps:
- "Open LANGBOT_FRONTEND_URL with the prepared browser profile."
- "Open the target pipeline and select Debug Chat."
- "Reset Debug Chat history through the backend API when configured."
- "Send the deterministic prompt and wait for the expected assistant response."
checks:
- "automation-result.json status is pass when the expected assistant response appears."
- "metrics_summary includes response_p50_ms, response_p95_ms, error_rate, and total_duration_ms."
- "thresholds_summary shows response_p95_ms and error_rate pass."
evidence_required:
- ui
- screenshot
- console
- network
- metrics
diagnostics:
- "This case measures browser-visible send-to-completion latency; it does not split provider latency from LangBot overhead."
- "Use backend logs and provider diagnostics to explain slow runs before calling them LangBot regressions."
success_patterns:
- "Processing request from person_websocket"
- "Streaming completed"
failure_patterns:
- "Action invoke_llm_stream call timed out"
- "Task exception was never retrieved"
- "All models failed during streaming setup"
troubleshooting:
- debug-chat-history-contaminates-automation
- local-agent-model-route-unavailable
- plugin-runtime-timeout
- proxy-env-mismatch
@@ -1,3 +1 @@
dist/* dist/
!dist/
!dist/qa-plugin-smoke-0.1.0.lbpkg
@@ -1,159 +0,0 @@
#!/usr/bin/env node
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { env, exit } from "node:process";
function pad(value, size = 2) {
return String(value).padStart(size, "0");
}
function localIsoWithOffset(date = new Date()) {
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absolute = Math.abs(offsetMinutes);
return [
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`,
`${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`,
].join("");
}
function timestampSlug(date = new Date()) {
return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
}
const scenarios = [
{
id: "provider-timeout",
target: "provider",
injected_fault: "fake provider request exceeds the configured timeout",
expected_status: "env_issue",
recovery_check: "provider route is reachable or the case remains outside product pass/fail",
cleanup: "stop fake provider or reset proxy route",
},
{
id: "plugin-runtime-disconnect",
target: "plugin-runtime",
injected_fault: "runtime control channel disconnects during an action",
expected_status: "fail",
recovery_check: "runtime reconnects and a deterministic plugin action succeeds",
cleanup: "restart the local plugin runtime process",
},
{
id: "mcp-stdio-server-exit",
target: "mcp",
injected_fault: "stdio server exits mid-call",
expected_status: "fail",
recovery_check: "server can be registered again and exposes the expected tool",
cleanup: "remove temporary MCP server registration",
},
{
id: "operator-missing-login",
target: "webui",
injected_fault: "browser profile is not authenticated",
expected_status: "blocked",
recovery_check: "authenticated profile can open the same WebUI origin",
cleanup: "no product cleanup; refresh local login state",
},
{
id: "transient-marketplace-timeout",
target: "marketplace",
injected_fault: "marketplace request times out once and then succeeds",
expected_status: "flaky",
recovery_check: "rerun passes with the same product revision and no code change",
cleanup: "clear retry-only evidence and keep the run classified as flaky",
},
];
function validateScenario(scenario) {
const missing = ["id", "target", "injected_fault", "expected_status", "recovery_check", "cleanup"]
.filter((key) => !scenario[key]);
const allowedStatuses = new Set(["pass", "fail", "blocked", "env_issue", "flaky"]);
return {
id: scenario.id,
pass: missing.length === 0 && allowedStatuses.has(scenario.expected_status),
missing,
expected_status: scenario.expected_status,
};
}
async function main() {
const root = resolve(env.LBS_ROOT || process.cwd());
const caseId = "langbot-fault-taxonomy-contract";
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId));
await mkdir(evidenceDir, { recursive: true });
const startedAt = new Date();
const validations = scenarios.map(validateScenario);
const statusCounts = {};
for (const scenario of scenarios) {
statusCounts[scenario.expected_status] = (statusCounts[scenario.expected_status] || 0) + 1;
}
const metrics = {
probe: caseId,
scenario_count: scenarios.length,
status_counts: statusCounts,
scenarios,
validations,
};
const thresholds = {
scenario_count: { actual: scenarios.length, min: 5, pass: scenarios.length >= 5 },
invalid_scenario_count: {
actual: validations.filter((item) => !item.pass).length,
max: 0,
pass: validations.every((item) => item.pass),
},
cleanup_declared_count: {
actual: scenarios.filter((item) => item.cleanup).length,
min: scenarios.length,
pass: scenarios.every((item) => item.cleanup),
},
};
const status = Object.values(thresholds).every((item) => item.pass) ? "pass" : "fail";
const metricsPath = join(evidenceDir, "metrics.json");
const faultModelPath = join(evidenceDir, "fault-model.json");
const automationResultPath = join(evidenceDir, "automation-result.json");
const resultPath = join(evidenceDir, "result.json");
await writeFile(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`, "utf8");
await writeFile(faultModelPath, `${JSON.stringify({ scenarios }, null, 2)}\n`, "utf8");
const finishedAt = new Date();
const result = {
source: "automation",
case_id: caseId,
run_id: runId,
status,
reason: status === "pass"
? "Fault taxonomy contract declares status, recovery, and cleanup for every scenario."
: "Fault taxonomy contract is missing required scenario fields.",
started_at: startedAt.toISOString(),
started_at_local: localIsoWithOffset(startedAt),
finished_at: finishedAt.toISOString(),
finished_at_local: localIsoWithOffset(finishedAt),
duration_ms: finishedAt.getTime() - startedAt.getTime(),
metrics_summary: {
scenario_count: metrics.scenario_count,
status_counts: metrics.status_counts,
invalid_scenario_count: thresholds.invalid_scenario_count.actual,
},
thresholds_summary: thresholds,
artifacts: {
metrics_json: metricsPath,
fault_model_json: faultModelPath,
automation_result_json: automationResultPath,
result_json: resultPath,
},
evidence_collected: ["metrics", "filesystem"],
};
const resultText = `${JSON.stringify(result, null, 2)}\n`;
await writeFile(automationResultPath, resultText, "utf8");
await writeFile(resultPath, resultText, "utf8");
console.log(JSON.stringify(result, null, 2));
exit(status === "pass" ? 0 : 1);
}
await main();
@@ -1,212 +0,0 @@
#!/usr/bin/env node
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { env, exit } from "node:process";
function pad(value, size = 2) {
return String(value).padStart(size, "0");
}
function localIsoWithOffset(date = new Date()) {
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absolute = Math.abs(offsetMinutes);
return [
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`,
`${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`,
].join("");
}
function timestampSlug(date = new Date()) {
return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
}
function percentile(values, percentileValue) {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1);
return Number(sorted[index].toFixed(3));
}
function stats(values) {
if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 };
return {
min: Number(Math.min(...values).toFixed(3)),
p50: percentile(values, 50),
p95: percentile(values, 95),
p99: percentile(values, 99),
max: Number(Math.max(...values).toFixed(3)),
};
}
function parseJsonList(value, fallback) {
if (!value) return fallback;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") ? parsed : fallback;
} catch {
return fallback;
}
}
function joinUrl(baseUrl, path) {
const base = baseUrl.replace(/\/+$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${base}${suffix}`;
}
async function fetchOnce(url, timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const started = performance.now();
try {
const response = await fetch(url, { method: "GET", signal: controller.signal });
await response.arrayBuffer();
const latencyMs = performance.now() - started;
return {
url,
ok: response.status < 500,
status: response.status,
latency_ms: Number(latencyMs.toFixed(3)),
error: "",
};
} catch (error) {
const latencyMs = performance.now() - started;
return {
url,
ok: false,
status: 0,
latency_ms: Number(latencyMs.toFixed(3)),
error: error instanceof Error ? error.message : String(error),
};
} finally {
clearTimeout(timeout);
}
}
async function runBatches(urls, totalRequests, concurrency, timeoutMs) {
const queue = Array.from({ length: totalRequests }, (_, index) => urls[index % urls.length]);
const results = [];
while (queue.length > 0) {
const batch = queue.splice(0, concurrency);
results.push(...await Promise.all(batch.map((url) => fetchOnce(url, timeoutMs))));
}
return results;
}
async function main() {
const root = resolve(env.LBS_ROOT || process.cwd());
const caseId = "langbot-live-backend-latency";
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId));
await mkdir(evidenceDir, { recursive: true });
const startedAt = new Date();
const backendUrl = env.LANGBOT_BACKEND_URL || "";
const endpoints = parseJsonList(env.LANGBOT_PERF_ENDPOINTS_JSON, ["/healthz"]);
const totalRequests = Number(env.LANGBOT_PERF_REQUESTS || "12");
const concurrency = Number(env.LANGBOT_PERF_CONCURRENCY || "2");
const timeoutMs = Number(env.LANGBOT_PERF_TIMEOUT_MS || "5000");
const p95BudgetMs = Number(env.LANGBOT_PERF_BACKEND_P95_MS || "1000");
const maxErrorRate = Number(env.LANGBOT_PERF_MAX_ERROR_RATE || "0");
const metricsPath = join(evidenceDir, "metrics.json");
const networkLogPath = join(evidenceDir, "network.log");
const automationResultPath = join(evidenceDir, "automation-result.json");
const resultPath = join(evidenceDir, "result.json");
let status = "fail";
let reason = "";
let results = [];
if (!backendUrl) {
status = "env_issue";
reason = "LANGBOT_BACKEND_URL is not configured.";
} else {
const urls = endpoints.map((path) => joinUrl(backendUrl, path));
results = await runBatches(urls, totalRequests, concurrency, timeoutMs);
const okCount = results.filter((item) => item.ok).length;
const errorCount = results.length - okCount;
const errorRate = results.length === 0 ? 1 : errorCount / results.length;
const latencies = results.filter((item) => item.ok).map((item) => item.latency_ms);
const latencyStats = stats(latencies);
const allConnectionFailures = results.length > 0 && results.every((item) => item.status === 0);
if (allConnectionFailures) {
status = "env_issue";
reason = `Backend did not respond at ${backendUrl}.`;
} else if (latencyStats.p95 <= p95BudgetMs && errorRate <= maxErrorRate) {
status = "pass";
reason = "Live backend latency probe passed all thresholds.";
} else {
status = "fail";
reason = "Live backend latency probe breached latency or error-rate thresholds.";
}
}
const statusCounts = {};
for (const item of results) {
const key = item.status === 0 ? "network_error" : String(item.status);
statusCounts[key] = (statusCounts[key] || 0) + 1;
}
const okResults = results.filter((item) => item.ok);
const metrics = {
probe: caseId,
backend_url: backendUrl,
endpoints,
total_requests: totalRequests,
concurrency,
timeout_ms: timeoutMs,
ok_count: okResults.length,
error_count: results.length - okResults.length,
error_rate: results.length === 0 ? 1 : Number(((results.length - okResults.length) / results.length).toFixed(4)),
latency_ms: stats(okResults.map((item) => item.latency_ms)),
status_counts: statusCounts,
};
const thresholds = {
backend_p95_ms: { actual: metrics.latency_ms.p95, max: p95BudgetMs, pass: metrics.latency_ms.p95 <= p95BudgetMs },
error_rate: { actual: metrics.error_rate, max: maxErrorRate, pass: metrics.error_rate <= maxErrorRate },
};
await writeFile(metricsPath, `${JSON.stringify({ ...metrics, samples: results }, null, 2)}\n`, "utf8");
await writeFile(networkLogPath, results.map((item) => JSON.stringify(item)).join("\n") + (results.length > 0 ? "\n" : ""), "utf8");
const finishedAt = new Date();
const result = {
source: "automation",
case_id: caseId,
run_id: runId,
status,
reason,
started_at: startedAt.toISOString(),
started_at_local: localIsoWithOffset(startedAt),
finished_at: finishedAt.toISOString(),
finished_at_local: localIsoWithOffset(finishedAt),
duration_ms: finishedAt.getTime() - startedAt.getTime(),
url: backendUrl,
metrics_summary: {
requests: metrics.total_requests,
concurrency: metrics.concurrency,
ok_count: metrics.ok_count,
error_rate: metrics.error_rate,
latency_p50_ms: metrics.latency_ms.p50,
latency_p95_ms: metrics.latency_ms.p95,
status_counts: metrics.status_counts,
},
thresholds_summary: thresholds,
artifacts: {
metrics_json: metricsPath,
network_log: networkLogPath,
automation_result_json: automationResultPath,
result_json: resultPath,
},
evidence_collected: ["metrics", "network", "api_diagnostic", "filesystem"],
};
const resultText = `${JSON.stringify(result, null, 2)}\n`;
await writeFile(automationResultPath, resultText, "utf8");
await writeFile(resultPath, resultText, "utf8");
console.log(JSON.stringify(result, null, 2));
exit(status === "pass" ? 0 : status === "env_issue" ? 2 : 1);
}
await main();
@@ -1,205 +0,0 @@
#!/usr/bin/env node
import { existsSync, readdirSync, statSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { env, exit } from "node:process";
function pad(value, size = 2) {
return String(value).padStart(size, "0");
}
function localIsoWithOffset(date = new Date()) {
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absolute = Math.abs(offsetMinutes);
return [
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`,
`${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`,
].join("");
}
function timestampSlug(date = new Date()) {
return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
}
function repoRootFromEnv(root) {
return env.LANGBOT_REPO ? resolve(env.LANGBOT_REPO) : resolve(root, "..");
}
function latestBackendLog(root) {
const explicit = env.LANGBOT_BACKEND_LOG;
if (explicit) return resolve(explicit);
const logsDir = join(repoRootFromEnv(root), "data", "logs");
if (!existsSync(logsDir)) return "";
const candidates = readdirSync(logsDir)
.filter((name) => /^langbot-.*\.log$/.test(name))
.map((name) => join(logsDir, name))
.filter((path) => {
try {
return statSync(path).isFile();
} catch {
return false;
}
})
.sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs);
return candidates[0] || "";
}
function parseSince(startedAt) {
if (env.LANGBOT_BACKEND_LOG_SINCE) return new Date(env.LANGBOT_BACKEND_LOG_SINCE);
const lookbackSeconds = Number(env.LANGBOT_BACKEND_LOG_LOOKBACK_SECONDS || "300");
return new Date(startedAt.getTime() - lookbackSeconds * 1000);
}
function parseTimestamp(line, year) {
const localMatch = line.match(/^\[(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})\]/);
if (localMatch) {
const [, month, day, hour, minute, second, millisecond] = localMatch;
return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millisecond}+08:00`);
}
const accessMatch = line.match(/^\[(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-]\d{4})\]/);
if (accessMatch) {
const [, fullYear, month, day, hour, minute, second, offset] = accessMatch;
const normalizedOffset = `${offset.slice(0, 3)}:${offset.slice(3)}`;
return new Date(`${fullYear}-${month}-${day}T${hour}:${minute}:${second}${normalizedOffset}`);
}
return null;
}
function findingForLine(line, number) {
const rules = [
{ severity: "fail", kind: "python_traceback", pattern: /\bTraceback(?: \(most recent call last\))?/i },
{ severity: "fail", kind: "unretrieved_task_exception", pattern: /Task exception was never retrieved/i },
{ severity: "fail", kind: "unawaited_coroutine", pattern: /RuntimeWarning:\s+coroutine .* was never awaited/i },
{ severity: "fail", kind: "unclosed_client_session", pattern: /Unclosed client session/i },
{ severity: "fail", kind: "unclosed_connector", pattern: /Unclosed connector/i },
{ severity: "fail", kind: "import_error", pattern: /\bImportError\b/i },
{ severity: "fail", kind: "error_log", pattern: /\b(?:ERROR|CRITICAL)\b/ },
{ severity: "warning", kind: "warning_log", pattern: /\bWARNING\b/ },
];
for (const rule of rules) {
if (rule.pattern.test(line)) {
return {
severity: rule.severity,
kind: rule.kind,
line: number,
excerpt: line,
};
}
}
return null;
}
function scanLines(text, since, year) {
const findings = [];
const scanned = [];
let includeContinuation = false;
const lines = text.split(/\r?\n/);
for (const [index, line] of lines.entries()) {
const number = index + 1;
const timestamp = parseTimestamp(line, year);
if (timestamp) includeContinuation = timestamp >= since;
if (!includeContinuation) continue;
scanned.push({ number, text: line });
const finding = findingForLine(line, number);
if (finding) findings.push(finding);
}
return { findings, scanned, total_lines: lines.length };
}
async function main() {
const root = resolve(env.LBS_ROOT || process.cwd());
const caseId = "langbot-live-backend-log-health";
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId));
await mkdir(evidenceDir, { recursive: true });
const startedAt = new Date();
const since = parseSince(startedAt);
const logPath = latestBackendLog(root);
const metricsPath = join(evidenceDir, "metrics.json");
const findingsPath = join(evidenceDir, "findings.json");
const scannedLogPath = join(evidenceDir, "scanned-backend.log");
const automationResultPath = join(evidenceDir, "automation-result.json");
const resultPath = join(evidenceDir, "result.json");
let status = "fail";
let reason = "";
let scan = { findings: [], scanned: [], total_lines: 0 };
if (!logPath || !existsSync(logPath)) {
status = "env_issue";
reason = "No LangBot backend log file was found. Set LANGBOT_BACKEND_LOG or LANGBOT_REPO.";
} else {
const text = await readFile(logPath, "utf8");
scan = scanLines(text, since, startedAt.getFullYear());
const failCount = scan.findings.filter((item) => item.severity === "fail").length;
status = failCount === 0 ? "pass" : "fail";
reason = status === "pass"
? "Live backend log health passed; no fail-severity findings in the scanned window."
: "Live backend log health found fail-severity backend log findings.";
}
const warningCount = scan.findings.filter((item) => item.severity === "warning").length;
const failCount = scan.findings.filter((item) => item.severity === "fail").length;
const metrics = {
probe: caseId,
backend_log: logPath,
since: since.toISOString(),
scanned_line_count: scan.scanned.length,
total_line_count: scan.total_lines,
fail_count: failCount,
warning_count: warningCount,
finding_count: scan.findings.length,
};
const thresholds = {
fail_count: { actual: failCount, max: 0, pass: failCount === 0 },
};
await writeFile(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`, "utf8");
await writeFile(findingsPath, `${JSON.stringify(scan.findings, null, 2)}\n`, "utf8");
await writeFile(scannedLogPath, scan.scanned.map((item) => `${item.number}: ${item.text}`).join("\n") + (scan.scanned.length > 0 ? "\n" : ""), "utf8");
const finishedAt = new Date();
const result = {
source: "automation",
case_id: caseId,
run_id: runId,
status,
reason,
started_at: startedAt.toISOString(),
started_at_local: localIsoWithOffset(startedAt),
finished_at: finishedAt.toISOString(),
finished_at_local: localIsoWithOffset(finishedAt),
duration_ms: finishedAt.getTime() - startedAt.getTime(),
url: logPath,
metrics_summary: {
scanned_line_count: metrics.scanned_line_count,
fail_count: metrics.fail_count,
warning_count: metrics.warning_count,
finding_count: metrics.finding_count,
},
thresholds_summary: thresholds,
artifacts: {
metrics_json: metricsPath,
findings_json: findingsPath,
scanned_backend_log: scannedLogPath,
automation_result_json: automationResultPath,
result_json: resultPath,
},
evidence_collected: ["metrics", "backend_log", "filesystem"],
};
const resultText = `${JSON.stringify(result, null, 2)}\n`;
await writeFile(automationResultPath, resultText, "utf8");
await writeFile(resultPath, resultText, "utf8");
console.log(JSON.stringify(result, null, 2));
exit(status === "pass" ? 0 : status === "env_issue" ? 2 : 1);
}
await main();
@@ -1,311 +0,0 @@
#!/usr/bin/env node
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { env, exit } from "node:process";
function pad(value, size = 2) {
return String(value).padStart(size, "0");
}
function localIsoWithOffset(date = new Date()) {
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absolute = Math.abs(offsetMinutes);
return [
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`,
`${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`,
].join("");
}
function timestampSlug(date = new Date()) {
return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
}
function percentile(values, percentileValue) {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1);
return Number(sorted[index].toFixed(3));
}
function stats(values) {
if (values.length === 0) return { min: 0, p50: 0, p95: 0, p99: 0, max: 0 };
return {
min: Number(Math.min(...values).toFixed(3)),
p50: percentile(values, 50),
p95: percentile(values, 95),
p99: percentile(values, 99),
max: Number(Math.max(...values).toFixed(3)),
};
}
function joinUrl(baseUrl, path) {
const base = baseUrl.replace(/\/+$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${base}${suffix}`;
}
function parseJsonObject(value, fallback) {
if (!value) return fallback;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback;
} catch {
return fallback;
}
}
function controlPlaneEndpoints() {
return [
{
id: "healthz",
path: "/healthz",
expected_status: 200,
expected_code: 0,
p95_budget_ms: Number(env.LANGBOT_PERF_HEALTHZ_P95_MS || "500"),
required_data_fields: [],
},
{
id: "system_info",
path: "/api/v1/system/info",
expected_status: 200,
expected_code: 0,
p95_budget_ms: Number(env.LANGBOT_PERF_SYSTEM_INFO_P95_MS || "1000"),
required_data_fields: ["version", "edition", "enable_marketplace"],
},
];
}
async function fetchEndpoint(backendUrl, endpoint, timeoutMs) {
const url = joinUrl(backendUrl, endpoint.path);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const started = performance.now();
let bodyText = "";
let json = null;
let jsonValid = false;
let error = "";
try {
const response = await fetch(url, {
method: "GET",
headers: { "accept": "application/json" },
signal: controller.signal,
});
bodyText = await response.text();
try {
json = bodyText ? JSON.parse(bodyText) : null;
jsonValid = json !== null;
} catch (parseError) {
error = parseError instanceof Error ? parseError.message : String(parseError);
}
const data = json && typeof json === "object" && json.data && typeof json.data === "object" ? json.data : {};
const missingFields = endpoint.required_data_fields.filter((field) => !(field in data));
const statusOk = response.status === endpoint.expected_status;
const codeOk = !json || typeof json !== "object" ? false : json.code === endpoint.expected_code;
const shapeOk = jsonValid && missingFields.length === 0;
const latencyMs = performance.now() - started;
return {
endpoint_id: endpoint.id,
path: endpoint.path,
url,
status: response.status,
ok: statusOk && codeOk && shapeOk,
status_ok: statusOk,
code_ok: codeOk,
json_valid: jsonValid,
missing_fields: missingFields,
response_code: json && typeof json === "object" ? json.code : null,
latency_ms: Number(latencyMs.toFixed(3)),
error,
};
} catch (fetchError) {
const latencyMs = performance.now() - started;
return {
endpoint_id: endpoint.id,
path: endpoint.path,
url,
status: 0,
ok: false,
status_ok: false,
code_ok: false,
json_valid: false,
missing_fields: endpoint.required_data_fields,
response_code: null,
latency_ms: Number(latencyMs.toFixed(3)),
error: fetchError instanceof Error ? fetchError.message : String(fetchError),
};
} finally {
clearTimeout(timeout);
}
}
async function runBatches(backendUrl, endpoints, totalRequests, concurrency, timeoutMs) {
const queue = Array.from({ length: totalRequests }, (_, index) => endpoints[index % endpoints.length]);
const results = [];
while (queue.length > 0) {
const batch = queue.splice(0, concurrency);
results.push(...await Promise.all(batch.map((endpoint) => fetchEndpoint(backendUrl, endpoint, timeoutMs))));
}
return results;
}
function endpointMetrics(endpoints, results) {
return Object.fromEntries(endpoints.map((endpoint) => {
const samples = results.filter((item) => item.endpoint_id === endpoint.id);
const okSamples = samples.filter((item) => item.ok);
return [
endpoint.id,
{
path: endpoint.path,
requests: samples.length,
ok_count: okSamples.length,
error_rate: samples.length === 0 ? 1 : Number(((samples.length - okSamples.length) / samples.length).toFixed(4)),
latency_ms: stats(okSamples.map((item) => item.latency_ms)),
p95_budget_ms: endpoint.p95_budget_ms,
},
];
}));
}
async function main() {
const root = resolve(env.LBS_ROOT || process.cwd());
const caseId = "langbot-live-control-plane-api";
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId));
await mkdir(evidenceDir, { recursive: true });
const startedAt = new Date();
const backendUrl = env.LANGBOT_BACKEND_URL || "";
const endpoints = controlPlaneEndpoints();
const configuredBudgets = parseJsonObject(env.LANGBOT_CONTROL_PLANE_P95_BUDGETS_JSON, {});
for (const endpoint of endpoints) {
const budget = configuredBudgets[endpoint.id];
if (typeof budget === "number" && Number.isFinite(budget)) endpoint.p95_budget_ms = budget;
}
const totalRequests = Number(env.LANGBOT_CONTROL_PLANE_REQUESTS || "20");
const concurrency = Number(env.LANGBOT_CONTROL_PLANE_CONCURRENCY || "4");
const timeoutMs = Number(env.LANGBOT_CONTROL_PLANE_TIMEOUT_MS || "5000");
const maxErrorRate = Number(env.LANGBOT_CONTROL_PLANE_MAX_ERROR_RATE || "0");
const metricsPath = join(evidenceDir, "metrics.json");
const endpointsPath = join(evidenceDir, "endpoints.json");
const networkLogPath = join(evidenceDir, "network.log");
const automationResultPath = join(evidenceDir, "automation-result.json");
const resultPath = join(evidenceDir, "result.json");
let status = "fail";
let reason = "";
let results = [];
if (!backendUrl) {
status = "env_issue";
reason = "LANGBOT_BACKEND_URL is not configured.";
} else {
results = await runBatches(backendUrl, endpoints, totalRequests, concurrency, timeoutMs);
const allConnectionFailures = results.length > 0 && results.every((item) => item.status === 0);
if (allConnectionFailures) {
status = "env_issue";
reason = `Backend did not respond at ${backendUrl}.`;
}
}
const okResults = results.filter((item) => item.ok);
const statusCounts = {};
for (const item of results) {
const key = item.status === 0 ? "network_error" : String(item.status);
statusCounts[key] = (statusCounts[key] || 0) + 1;
}
const perEndpoint = endpointMetrics(endpoints, results);
const responseShapeFailures = results.filter((item) => !item.json_valid || item.missing_fields.length > 0 || !item.code_ok).length;
const errorRate = results.length === 0 ? 1 : Number(((results.length - okResults.length) / results.length).toFixed(4));
const thresholds = {
error_rate: { actual: errorRate, max: maxErrorRate, pass: errorRate <= maxErrorRate },
response_shape_failures: { actual: responseShapeFailures, max: 0, pass: responseShapeFailures === 0 },
};
for (const endpoint of endpoints) {
const actual = perEndpoint[endpoint.id].latency_ms.p95;
thresholds[`${endpoint.id}_p95_ms`] = {
actual,
max: endpoint.p95_budget_ms,
pass: actual <= endpoint.p95_budget_ms,
};
}
if (status !== "env_issue") {
const passed = Object.values(thresholds).every((item) => item.pass);
status = passed ? "pass" : "fail";
reason = passed
? "Live control-plane API probe passed all thresholds."
: "Live control-plane API probe breached shape, latency, or error-rate thresholds.";
}
const metrics = {
probe: caseId,
backend_url: backendUrl,
total_requests: totalRequests,
concurrency,
timeout_ms: timeoutMs,
ok_count: okResults.length,
error_count: results.length - okResults.length,
error_rate: errorRate,
status_counts: statusCounts,
response_shape_failures: responseShapeFailures,
endpoints: perEndpoint,
};
await writeFile(metricsPath, `${JSON.stringify({ ...metrics, samples: results }, null, 2)}\n`, "utf8");
await writeFile(endpointsPath, `${JSON.stringify(endpoints, null, 2)}\n`, "utf8");
await writeFile(networkLogPath, results.map((item) => JSON.stringify(item)).join("\n") + (results.length > 0 ? "\n" : ""), "utf8");
const finishedAt = new Date();
const result = {
source: "automation",
case_id: caseId,
run_id: runId,
status,
reason,
started_at: startedAt.toISOString(),
started_at_local: localIsoWithOffset(startedAt),
finished_at: finishedAt.toISOString(),
finished_at_local: localIsoWithOffset(finishedAt),
duration_ms: finishedAt.getTime() - startedAt.getTime(),
url: backendUrl,
metrics_summary: {
requests: metrics.total_requests,
concurrency: metrics.concurrency,
ok_count: metrics.ok_count,
error_rate: metrics.error_rate,
response_shape_failures: metrics.response_shape_failures,
endpoints: Object.fromEntries(Object.entries(metrics.endpoints).map(([id, value]) => [
id,
{
path: value.path,
ok_count: value.ok_count,
error_rate: value.error_rate,
latency_p50_ms: value.latency_ms.p50,
latency_p95_ms: value.latency_ms.p95,
},
])),
status_counts: metrics.status_counts,
},
thresholds_summary: thresholds,
artifacts: {
metrics_json: metricsPath,
endpoints_json: endpointsPath,
network_log: networkLogPath,
automation_result_json: automationResultPath,
result_json: resultPath,
},
evidence_collected: ["metrics", "network", "api_diagnostic", "filesystem"],
};
const resultText = `${JSON.stringify(result, null, 2)}\n`;
await writeFile(automationResultPath, resultText, "utf8");
await writeFile(resultPath, resultText, "utf8");
console.log(JSON.stringify(result, null, 2));
exit(status === "pass" ? 0 : status === "env_issue" ? 2 : 1);
}
await main();
@@ -1,162 +0,0 @@
#!/usr/bin/env node
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { env, exit } from "node:process";
function pad(value, size = 2) {
return String(value).padStart(size, "0");
}
function localIsoWithOffset(date = new Date()) {
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absolute = Math.abs(offsetMinutes);
return [
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`,
`${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`,
].join("");
}
function timestampSlug(date = new Date()) {
return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
}
function percentile(values, percentileValue) {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const index = Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1);
return Number(sorted[index].toFixed(3));
}
function stats(values) {
return {
min: Number(Math.min(...values).toFixed(3)),
p50: percentile(values, 50),
p95: percentile(values, 95),
p99: percentile(values, 99),
max: Number(Math.max(...values).toFixed(3)),
};
}
function threshold(actual, limit, operator) {
const pass = operator === "<=" ? actual <= limit : actual >= limit;
return { actual, [operator === "<=" ? "max" : "min"]: limit, pass };
}
function makeSample(index) {
const ingress = 1 + (index % 5) * 0.22;
const pipeline = 2.8 + (index % 7) * 0.31;
const persistence = 1.1 + (index % 4) * 0.2;
const pluginIpc = 1.9 + (index % 6) * 0.27;
const rag = index % 3 === 0 ? 4.4 : 0.8 + (index % 5) * 0.18;
const streaming = 1.5 + (index % 8) * 0.24;
const provider = 80 + (index % 13) * 11;
const externalTool = index % 4 === 0 ? 25 + (index % 9) * 3 : 0;
const network = 8 + (index % 10) * 1.7;
const overhead = ingress + pipeline + persistence + pluginIpc + rag + streaming;
const external = provider + externalTool + network;
const total = overhead + external;
return {
index,
segments_ms: {
ingress,
pipeline,
persistence,
plugin_ipc: pluginIpc,
rag,
streaming,
provider,
external_tool: externalTool,
network,
},
langbot_overhead_ms: Number(overhead.toFixed(3)),
external_latency_ms: Number(external.toFixed(3)),
e2e_latency_ms: Number(total.toFixed(3)),
accounting_gap_ms: Number((total - external - overhead).toFixed(6)),
};
}
async function main() {
const root = resolve(env.LBS_ROOT || process.cwd());
const caseId = "langbot-overhead-accounting-contract";
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId));
await mkdir(evidenceDir, { recursive: true });
const startedAt = new Date();
const sampleCount = Number(env.LANGBOT_PERF_CONTRACT_SAMPLES || "80");
const overheadP95BudgetMs = Number(env.LANGBOT_PERF_OVERHEAD_P95_MS || "25");
const samples = Array.from({ length: sampleCount }, (_, index) => makeSample(index));
const overheads = samples.map((sample) => sample.langbot_overhead_ms);
const e2e = samples.map((sample) => sample.e2e_latency_ms);
const external = samples.map((sample) => sample.external_latency_ms);
const gaps = samples.map((sample) => Math.abs(sample.accounting_gap_ms));
const memory = process.memoryUsage();
const metrics = {
probe: caseId,
sample_count: sampleCount,
langbot_overhead_ms: stats(overheads),
e2e_latency_ms: stats(e2e),
external_latency_ms: stats(external),
accounting_gap_max_ms: Number(Math.max(...gaps).toFixed(6)),
samples,
};
const thresholds = {
sample_count: threshold(sampleCount, 50, ">="),
langbot_overhead_p95_ms: threshold(metrics.langbot_overhead_ms.p95, overheadP95BudgetMs, "<="),
accounting_gap_max_ms: threshold(metrics.accounting_gap_max_ms, 0.001, "<="),
};
const status = Object.values(thresholds).every((item) => item.pass) ? "pass" : "fail";
const metricsPath = join(evidenceDir, "metrics.json");
const thresholdsPath = join(evidenceDir, "thresholds.json");
const resourceLogPath = join(evidenceDir, "resource-log.json");
const automationResultPath = join(evidenceDir, "automation-result.json");
const resultPath = join(evidenceDir, "result.json");
await writeFile(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`, "utf8");
await writeFile(thresholdsPath, `${JSON.stringify(thresholds, null, 2)}\n`, "utf8");
await writeFile(resourceLogPath, `${JSON.stringify({ memory, pid: process.pid }, null, 2)}\n`, "utf8");
const finishedAt = new Date();
const result = {
source: "automation",
case_id: caseId,
run_id: runId,
status,
reason: status === "pass"
? "Overhead accounting contract passed all thresholds."
: "Overhead accounting contract breached one or more thresholds.",
started_at: startedAt.toISOString(),
started_at_local: localIsoWithOffset(startedAt),
finished_at: finishedAt.toISOString(),
finished_at_local: localIsoWithOffset(finishedAt),
duration_ms: finishedAt.getTime() - startedAt.getTime(),
metrics_summary: {
sample_count: metrics.sample_count,
langbot_overhead_p95_ms: metrics.langbot_overhead_ms.p95,
e2e_latency_p95_ms: metrics.e2e_latency_ms.p95,
external_latency_p95_ms: metrics.external_latency_ms.p95,
accounting_gap_max_ms: metrics.accounting_gap_max_ms,
},
thresholds_summary: thresholds,
artifacts: {
metrics_json: metricsPath,
thresholds_json: thresholdsPath,
resource_log_json: resourceLogPath,
automation_result_json: automationResultPath,
result_json: resultPath,
},
evidence_collected: ["metrics", "resource_log", "filesystem"],
};
const resultText = `${JSON.stringify(result, null, 2)}\n`;
await writeFile(automationResultPath, resultText, "utf8");
await writeFile(resultPath, resultText, "utf8");
console.log(JSON.stringify(result, null, 2));
exit(status === "pass" ? 0 : 1);
}
await main();
@@ -1,173 +0,0 @@
# Performance And Reliability Testing
Use this reference when a QA request asks whether LangBot is fast enough,
stable under load, or resilient to controlled faults.
## Scope
Treat `skills/` as the QA control plane:
- Cases define intent, readiness, thresholds, and required evidence.
- Probe scripts collect metrics, traces, resource logs, and artifacts.
- Reports classify the same run as `pass`, `fail`, `blocked`,
`env_issue`, or `flaky`.
Do not turn `skills/` into a load generator or chaos engine. Call a focused
tool from a `mode: probe` case when the test needs one, for example k6,
Locust, pytest-benchmark, Playwright trace collection, Toxiproxy, Docker, or a
Kubernetes disruption tool.
## LangBot Performance Model
For LangBot, performance is the cost LangBot adds around external systems:
```text
LangBot overhead = end-to-end latency - provider latency - external tool latency - network/fault injection latency
```
Measure user experience and internal composition separately:
- WebUI load and interaction latency.
- Debug Chat send-to-first-visible-token and send-to-completion latency.
- Pipeline, RAG, plugin runtime, MCP, AgentRunner, and persistence segment
latency.
- Queue wait time, concurrency, throughput, timeout rate, and p95/p99 latency.
- Startup, plugin install, knowledge-base ingestion, migration, and recovery
time.
Do not report a single message round-trip time as "LangBot performance" unless
the report also explains external provider/tool/network time.
## Evidence Contract
Performance and reliability cases should declare the evidence they need:
- `metrics`: machine-readable latency, throughput, error-rate, or recovery
metrics, usually `metrics.json`.
- `resource_log`: CPU, memory, process, connection, queue, or file descriptor
samples.
- `trace`: browser, HTTP, database, or runtime trace artifacts.
- `profile`: CPU, memory, or flamegraph profile artifacts.
- `backend_log`, `network`, `api_diagnostic`, and `filesystem` as supporting
evidence when relevant.
Automation should write `automation-result.json` with these fields when
available:
```json
{
"status": "pass",
"reason": "Probe passed all thresholds.",
"metrics_summary": {
"langbot_overhead_p95_ms": 12.4,
"error_rate": 0
},
"thresholds_summary": {
"langbot_overhead_p95_ms": { "actual": 12.4, "max": 50, "pass": true }
},
"artifacts": {
"metrics_json": "/path/to/metrics.json"
},
"evidence_collected": ["metrics", "filesystem"]
}
```
Synthetic contract probes are useful for checking the QA harness, but they are
not live product performance results. Label them as contract probes in the case
title, checks, and report.
## Chaos And Reliability Rules
Chaos tests must be narrow and reversible:
- Declare the fault model in `fault_model_json`.
- Record blast radius, target component, injection method, duration, and abort
conditions.
- Capture recovery checks and cleanup steps in the case.
- Classify unavailable dependencies as `env_issue` unless the target behavior
is LangBot's handling of that dependency failure.
- Do not run destructive fault injection against a shared or production-like
instance without explicit operator approval.
Recommended first fault models:
- Provider timeout or HTTP 429 from a fake provider endpoint.
- Plugin runtime disconnect/reconnect in a local instance.
- MCP stdio server exits mid-call.
- RAG parser fixture fails once and recovers on retry.
- Backend API endpoint returns 5xx from a controlled local proxy.
## Starter Live Probes
The starter gate separates QA-harness contracts from live product checks:
- `langbot-overhead-accounting-contract` verifies that reports can carry
overhead accounting metrics. It uses deterministic synthetic samples and is
not live product performance.
- `langbot-fault-taxonomy-contract` verifies that fault scenarios declare
expected status, recovery, and cleanup before destructive chaos tests are
added.
- `langbot-live-backend-latency` checks the unauthenticated `/healthz`
endpoint for basic backend responsiveness.
- `langbot-live-control-plane-api` checks `/healthz` and
`/api/v1/system/info` for HTTP 200, JSON `code: 0`, response shape, and
per-endpoint p95 latency.
- `langbot-live-backend-log-health` scans the recent backend log window for
fail-severity runtime findings. It is the reliability guard that should fail
the gate when HTTP probes pass but backend logs contain Traceback, ImportError,
ERROR, unclosed sessions, or unawaited coroutine signals.
Do not treat these starter live probes as Debug Chat or model-provider
performance. They are control-plane readiness checks; user-facing performance
needs browser/WebSocket/message-path measurements.
## Gate Layers
Use the smallest gate that answers the quality question:
- `langbot-performance-contract-gate`: fast synthetic checks for report shape,
threshold accounting, and fault taxonomy. Good for PR feedback when no live
service is running.
- `langbot-live-backend-gate`: live backend `/healthz`,
`/api/v1/system/info`, and backend log health. Good after starting a local
LangBot backend.
- `langbot-user-path-performance-gate`: browser-visible user path performance,
starting with Pipeline Debug Chat send-to-visible-completion latency. Run it
only when the browser profile and target pipeline are ready.
- `langbot-performance-reliability-gate`: combined starter gate for synthetic
contracts plus live backend checks.
Keep environment diagnostics separate from product regressions. For example, a
SOCKS proxy without Python `socksio` support should be fixed or clearly
classified by `bin/lbs env doctor`; do not hide the resulting backend
Traceback in reports.
## Debug Chat Performance
`pipeline-debug-chat-performance` reuses the browser Debug Chat automation and
adds `metrics.json`, `metrics_summary`, and `thresholds_summary` to
`automation-result.json`.
Current metric:
```text
response_duration_ms = prompt send -> expected assistant response visible and stable
```
This is a user-path metric, not pure LangBot overhead. If it regresses, inspect
provider latency, model route health, plugin/runtime logs, WebSocket behavior,
and browser console/network evidence before attributing the whole duration to
LangBot.
## Running The First Gate
Start with the reusable suite:
```bash
rtk bin/lbs suite plan langbot-performance-reliability-gate
rtk bin/lbs suite start langbot-performance-reliability-gate --run-id langbot-perf-rel-local
```
Run synthetic contract probes first. Run live probes only after the selected
backend/frontend instance is reachable and the run owner accepts any fault
scope.
@@ -1,14 +0,0 @@
id: langbot-live-backend-gate
title: "LangBot live backend reliability gate"
description: "Live backend control-plane responsiveness and runtime log health checks for a locally running LangBot instance."
type: reliability
priority: p1
tags:
- performance
- reliability
- live-backend
- metrics
cases:
- langbot-live-backend-latency
- langbot-live-control-plane-api
- langbot-live-backend-log-health
@@ -1,13 +0,0 @@
id: langbot-performance-contract-gate
title: "LangBot performance contract gate"
description: "Fast synthetic contract checks for performance metric accounting and non-destructive reliability fault taxonomy."
type: contract
priority: p1
tags:
- performance
- reliability
- contract
- metrics
cases:
- langbot-overhead-accounting-contract
- langbot-fault-taxonomy-contract
@@ -1,16 +0,0 @@
id: langbot-performance-reliability-gate
title: "LangBot performance and reliability starter gate"
description: "Starter gate for LangBot performance accounting, live backend control-plane latency, and non-destructive fault taxonomy checks."
type: reliability
priority: p1
tags:
- performance
- reliability
- metrics
- chaos
cases:
- langbot-overhead-accounting-contract
- langbot-fault-taxonomy-contract
- langbot-live-backend-latency
- langbot-live-control-plane-api
- langbot-live-backend-log-health
@@ -1,12 +0,0 @@
id: langbot-user-path-performance-gate
title: "LangBot user-path performance gate"
description: "Browser-visible performance checks for user-facing LangBot paths such as Pipeline Debug Chat."
type: performance
priority: p1
tags:
- performance
- browser
- debug-chat
- user-path
cases:
- pipeline-debug-chat-performance
-35
View File
@@ -1,7 +1,5 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { Socket } from "node:net"; import { Socket } from "node:net";
import { join } from "node:path";
import type { CommandContext } from "../types.ts"; import type { CommandContext } from "../types.ts";
import { parseOptions } from "../cli.ts"; import { parseOptions } from "../cli.ts";
import { loadEnv } from "../fs.ts"; import { loadEnv } from "../fs.ts";
@@ -90,37 +88,6 @@ function compareProxyPair(env: Record<string, string>, upper: string, lower: str
return null; return null;
} }
function envValue(env: Record<string, string>, key: string): string {
return process.env[key] ?? env[key] ?? "";
}
function activeSocksProxy(env: Record<string, string>): { key: string; value: string } | null {
for (const key of ["ALL_PROXY", "all_proxy", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]) {
const value = envValue(env, key);
if (/^socks/i.test(value)) return { key, value };
}
return null;
}
function checkSocksio(env: Record<string, string>): string | null {
const proxy = activeSocksProxy(env);
if (!proxy) return null;
const repo = env.LANGBOT_REPO;
const python = repo ? join(repo, ".venv", "bin", "python") : "";
if (!python || !existsSync(python)) {
return `SOCKS proxy ${proxy.key} is configured (${redactEnvValue(proxy.key, proxy.value)}), but LangBot venv python was not found; after creating the venv, verify it can import socksio.`;
}
const result = spawnSync(python, ["-c", "import socksio"], {
encoding: "utf8",
timeout: 5000,
});
if (result.status === 0) return null;
return `SOCKS proxy ${proxy.key} is configured (${redactEnvValue(proxy.key, proxy.value)}), but ${python} cannot import socksio; run \`${python} -m pip install socksio\` or start LangBot without SOCKS proxy env.`;
}
export async function commandEnvDoctor(ctx: CommandContext): Promise<number> { export async function commandEnvDoctor(ctx: CommandContext): Promise<number> {
const env = loadEnv(ctx.root); const env = loadEnv(ctx.root);
const failures: string[] = []; const failures: string[] = [];
@@ -150,8 +117,6 @@ export async function commandEnvDoctor(ctx: CommandContext): Promise<number> {
]) { ]) {
if (mismatch) failures.push(mismatch); if (mismatch) failures.push(mismatch);
} }
const socksioFailure = checkSocksio(env);
if (socksioFailure) failures.push(socksioFailure);
for (const [label, result] of await Promise.all([ for (const [label, result] of await Promise.all([
checkUrl("LANGBOT_BACKEND_URL", env.LANGBOT_BACKEND_URL).then((result) => ["LANGBOT_BACKEND_URL", result] as const), checkUrl("LANGBOT_BACKEND_URL", env.LANGBOT_BACKEND_URL).then((result) => ["LANGBOT_BACKEND_URL", result] as const),
+3 -44
View File
@@ -465,41 +465,6 @@ function outputTail(value: string | Buffer | null | undefined): string {
return String(value ?? "").trim().slice(-4000); return String(value ?? "").trim().slice(-4000);
} }
function exitStatusFromResultStatus(status: string): number {
if (status === "pass") return 0;
if (status === "blocked" || status === "env_issue" || status === "flaky") return 2;
return 1;
}
function executionStatusFromExitStatus(status: number): string {
if (status === 0) return "ok";
if (status === 2) return "classified";
return "nonzero";
}
function executionFromCaseResultFile(caseItem: Record<string, unknown>): Record<string, unknown> | null {
const resultPath = join(String(caseItem.evidence_dir), "result.json");
if (!existsSync(resultPath)) return null;
try {
const parsed = JSON.parse(readFileSync(resultPath, "utf8")) as Record<string, unknown>;
if (
parsed.case_id !== caseItem.id ||
parsed.run_id !== caseItem.run_id ||
typeof parsed.status !== "string"
) return null;
const exitStatus = exitStatusFromResultStatus(parsed.status);
return {
status: executionStatusFromExitStatus(exitStatus),
exit_status: exitStatus,
reason: typeof parsed.reason === "string" ? parsed.reason : "result.json completed",
result_status: parsed.status,
result_json: resultPath,
};
} catch {
return null;
}
}
function executionProblemStatus(executions: Array<Record<string, unknown>>): string { function executionProblemStatus(executions: Array<Record<string, unknown>>): string {
const statuses = executions.map((item) => String(item.status)); const statuses = executions.map((item) => String(item.status));
if (statuses.includes("nonzero")) return "fail"; if (statuses.includes("nonzero")) return "fail";
@@ -558,18 +523,12 @@ export function commandSuiteRun(ctx: CommandContext): number {
encoding: "utf8", encoding: "utf8",
stdio: options.json === true ? "pipe" : "inherit", stdio: options.json === true ? "pipe" : "inherit",
}); });
const fileExecution = result.error ? executionFromCaseResultFile(caseItem) : null; const status = result.error ? 1 : result.status ?? 1;
const status = typeof fileExecution?.exit_status === "number"
? fileExecution.exit_status
: result.error ? 1 : result.status ?? 1;
executions.push({ executions.push({
id: caseItem.id, id: caseItem.id,
status: fileExecution?.status ?? executionStatusFromExitStatus(status), status: status === 0 ? "ok" : "nonzero",
exit_status: status, exit_status: status,
reason: fileExecution?.reason ?? result.error?.message ?? "", reason: result.error?.message || "",
result_status: fileExecution?.result_status,
result_json: fileExecution?.result_json,
spawn_error: fileExecution && result.error ? result.error.message : undefined,
stdout: outputTail(result.stdout), stdout: outputTail(result.stdout),
stderr: outputTail(result.stderr), stderr: outputTail(result.stderr),
}); });
+14 -95
View File
@@ -271,7 +271,7 @@ function reportTemplate(mode: string): Record<string, string> {
target_tested: "Probe target, endpoint, file, command, or service actually checked", target_tested: "Probe target, endpoint, file, command, or service actually checked",
execution_path: "automation script | shell command | direct API | other", execution_path: "automation script | shell command | direct API | other",
probe_result: "What the probe observed", probe_result: "What the probe observed",
metrics_or_artifacts: "Metrics, logs, filesystem artifacts, traces, or profiles collected", logs_or_artifacts: "Log, filesystem, API, or other artifact paths collected",
diagnostics: "Extra diagnostics used, if any", diagnostics: "Extra diagnostics used, if any",
matched_troubleshooting: "Troubleshooting ids matched, if any", matched_troubleshooting: "Troubleshooting ids matched, if any",
assets_to_update: "New case/reference/troubleshooting entries to add", assets_to_update: "New case/reference/troubleshooting entries to add",
@@ -320,7 +320,7 @@ function manualEvidenceTemplate(mode: string): ManualEvidenceTemplate {
target_tested: "TODO: probe target, endpoint, file, command, or service actually checked", target_tested: "TODO: probe target, endpoint, file, command, or service actually checked",
execution_path: "TODO: automation script | shell command | direct API | other", execution_path: "TODO: automation script | shell command | direct API | other",
probe_result: "TODO: observed probe result", probe_result: "TODO: observed probe result",
metrics_or_artifacts: "TODO: metrics, logs, filesystem artifacts, traces, or profiles collected", logs_or_artifacts: "TODO: evidence paths or skipped reason",
diagnostics: "TODO: additional diagnostics used, if any", diagnostics: "TODO: additional diagnostics used, if any",
matched_troubleshooting: "TODO: troubleshooting ids matched, if any", matched_troubleshooting: "TODO: troubleshooting ids matched, if any",
assets_to_update: "TODO: case/reference/troubleshooting updates to make", assets_to_update: "TODO: case/reference/troubleshooting updates to make",
@@ -1099,41 +1099,6 @@ function executionTail(value: string | Buffer | null | undefined): string {
return String(value ?? "").trim().slice(-4000); return String(value ?? "").trim().slice(-4000);
} }
function exitStatusFromResultStatus(status: string): number {
if (status === "pass") return 0;
if (status === "blocked" || status === "env_issue" || status === "flaky") return 2;
return 1;
}
function executionStatusFromExitStatus(status: number): string {
if (status === 0) return "ok";
if (status === 2) return "classified";
return "nonzero";
}
function executionFromAutomationResultFile(
evidenceDir: string,
caseId: string,
runId: string,
): { status: string; exit_status: number; reason: string; result_status: string; path: string } | null {
const resultPath = join(evidenceDir, "automation-result.json");
if (!existsSync(resultPath)) return null;
try {
const parsed = JSON.parse(readFileSync(resultPath, "utf8")) as Record<string, unknown>;
if (parsed.case_id !== caseId || parsed.run_id !== runId || typeof parsed.status !== "string") return null;
const exitStatus = exitStatusFromResultStatus(parsed.status);
return {
status: executionStatusFromExitStatus(exitStatus),
exit_status: exitStatus,
reason: typeof parsed.reason === "string" ? parsed.reason : "automation-result.json completed",
result_status: parsed.status,
path: resultPath,
};
} catch {
return null;
}
}
function runSetupAutomation( function runSetupAutomation(
ctx: CommandContext, ctx: CommandContext,
item: StructuredItem, item: StructuredItem,
@@ -1259,30 +1224,6 @@ export function commandTestRun(ctx: CommandContext): number {
}); });
if (result.error) { if (result.error) {
const fileExecution = executionFromAutomationResultFile(
run.automation.evidence_dir,
String(run.case.id),
run.run_id,
);
if (fileExecution) {
if (options.json !== true) {
console.error(`WARN: automation spawn reported an error, but ${fileExecution.path} completed: ${result.error.message}`);
}
if (options.json === true) {
console.log(JSON.stringify({
run,
setup_executions: setupExecutions,
automation_execution: {
...fileExecution,
spawn_error: result.error.message,
stdout: executionTail(result.stdout),
stderr: executionTail(result.stderr),
},
exit_status: fileExecution.exit_status,
}, null, 2));
}
return fileExecution.exit_status;
}
if (options.json !== true) console.error(`ERROR: failed to run automation: ${result.error.message}`); if (options.json !== true) console.error(`ERROR: failed to run automation: ${result.error.message}`);
if (options.json === true) { if (options.json === true) {
console.log(JSON.stringify({ console.log(JSON.stringify({
@@ -1306,7 +1247,7 @@ export function commandTestRun(ctx: CommandContext): number {
run, run,
setup_executions: setupExecutions, setup_executions: setupExecutions,
automation_execution: { automation_execution: {
status: executionStatusFromExitStatus(status), status: status === 0 ? "ok" : "nonzero",
exit_status: status, exit_status: status,
stdout: executionTail(result.stdout), stdout: executionTail(result.stdout),
stderr: executionTail(result.stderr), stderr: executionTail(result.stderr),
@@ -1370,7 +1311,6 @@ function renderMarkdownReport(report: TestReport): string {
const environment = report.environment; const environment = report.environment;
const logGuard = report.log_guard; const logGuard = report.log_guard;
const troubleshooting = report.troubleshooting; const troubleshooting = report.troubleshooting;
const automation = report.automation_result;
const lines: string[] = []; const lines: string[] = [];
lines.push(`# Test Report: ${reportCase.id}`); lines.push(`# Test Report: ${reportCase.id}`);
@@ -1383,41 +1323,20 @@ function renderMarkdownReport(report: TestReport): string {
lines.push(`Type: ${reportCase.type}`); lines.push(`Type: ${reportCase.type}`);
lines.push(""); lines.push("");
lines.push("## Result"); lines.push("## Result");
if (automation.status === "loaded" && automation.result) { lines.push(`- result: ${evidence.result}`);
lines.push(`- result: ${automation.result}`); for (const [key, value] of Object.entries(evidence)) {
if (automation.reason) lines.push(`- reason: ${automation.reason}`); if (key !== "result") lines.push(`- ${key}: ${value}`);
if (automation.url) lines.push(`- target_tested: ${automation.url}`);
if (automation.path) lines.push(`- automation_result: ${automation.path}`);
if (automation.artifacts) lines.push(`- artifacts: ${JSON.stringify(automation.artifacts)}`);
} else {
lines.push(`- result: ${evidence.result}`);
for (const [key, value] of Object.entries(evidence)) {
if (key !== "result") lines.push(`- ${key}: ${value}`);
}
} }
lines.push(""); lines.push("");
lines.push("## Automation Result"); lines.push("## Automation Result");
lines.push(`- status: ${automation.status}`); lines.push(`- status: ${report.automation_result.status}`);
if (automation.path) lines.push(`- path: ${automation.path}`); if (report.automation_result.path) lines.push(`- path: ${report.automation_result.path}`);
if (automation.result) lines.push(`- result: ${automation.result}`); if (report.automation_result.result) lines.push(`- result: ${report.automation_result.result}`);
if (automation.reason) lines.push(`- reason: ${automation.reason}`); if (report.automation_result.reason) lines.push(`- reason: ${report.automation_result.reason}`);
if (automation.duration_ms !== undefined) lines.push(`- duration_ms: ${automation.duration_ms}`); if (report.automation_result.started_at_local) lines.push(`- started_at_local: ${report.automation_result.started_at_local}`);
if (automation.started_at_local) lines.push(`- started_at_local: ${automation.started_at_local}`); if (report.automation_result.finished_at_local) lines.push(`- finished_at_local: ${report.automation_result.finished_at_local}`);
if (automation.finished_at_local) lines.push(`- finished_at_local: ${automation.finished_at_local}`); if (report.automation_result.url) lines.push(`- url: ${report.automation_result.url}`);
if (automation.url) lines.push(`- url: ${automation.url}`); if (report.automation_result.expected_text) lines.push(`- expected_text: ${report.automation_result.expected_text}`);
if (automation.expected_text) lines.push(`- expected_text: ${automation.expected_text}`);
if (automation.metrics_summary) {
lines.push("- metrics_summary:");
lines.push(` ${JSON.stringify(automation.metrics_summary)}`);
}
if (automation.thresholds_summary) {
lines.push("- thresholds_summary:");
lines.push(` ${JSON.stringify(automation.thresholds_summary)}`);
}
if (automation.artifacts) {
lines.push("- artifacts:");
lines.push(` ${JSON.stringify(automation.artifacts)}`);
}
lines.push(""); lines.push("");
lines.push("## Environment"); lines.push("## Environment");
for (const [key, value] of Object.entries(environment)) lines.push(`- ${key}=${value}`); for (const [key, value] of Object.entries(environment)) lines.push(`- ${key}=${value}`);
-3
View File
@@ -126,9 +126,6 @@ function validateCaseItem(root: string, item: StructuredItem, skillNames: Set<st
...validateEnvKeyScalar(item, "automation_pipeline_url_env"), ...validateEnvKeyScalar(item, "automation_pipeline_url_env"),
...validateEnvKeyScalar(item, "automation_pipeline_name_env"), ...validateEnvKeyScalar(item, "automation_pipeline_name_env"),
...validateJsonScalar(item, "automation_filesystem_checks_json"), ...validateJsonScalar(item, "automation_filesystem_checks_json"),
...validateJsonScalar(item, "metrics_thresholds_json"),
...validateJsonScalar(item, "load_profile_json"),
...validateJsonScalar(item, "fault_model_json"),
...listValue(item.fields, "setup_automation").flatMap((entry) => ( ...listValue(item.fields, "setup_automation").flatMap((entry) => (
validateSetupAutomationEntry(root, entry, caseIds).map((error) => `${item.path}: ${error}`) validateSetupAutomationEntry(root, entry, caseIds).map((error) => `${item.path}: ${error}`)
)), )),
+2 -27
View File
@@ -9,18 +9,7 @@ export const requiredEnvKeys = [
]; ];
export const caseModeValues = ["agent-browser", "probe"]; export const caseModeValues = ["agent-browser", "probe"];
export const caseTypeValues = [ export const caseTypeValues = ["smoke", "regression", "feature", "provider", "exploratory"];
"smoke",
"regression",
"feature",
"provider",
"exploratory",
"contract",
"performance",
"reliability",
"chaos",
"security",
];
export const casePriorityValues = ["p0", "p1", "p2"]; export const casePriorityValues = ["p0", "p1", "p2"];
export const caseRiskValues = ["low", "medium", "high"]; export const caseRiskValues = ["low", "medium", "high"];
export const caseEvidenceValues = [ export const caseEvidenceValues = [
@@ -32,24 +21,10 @@ export const caseEvidenceValues = [
"frontend_log", "frontend_log",
"api_diagnostic", "api_diagnostic",
"filesystem", "filesystem",
"metrics",
"trace",
"profile",
"resource_log",
]; ];
export const testResultStatusValues = ["pass", "fail", "blocked", "env_issue", "flaky"]; export const testResultStatusValues = ["pass", "fail", "blocked", "env_issue", "flaky"];
export const troubleshootingCategoryValues = ["product", "env_issue", "external_dependency", "blocked", "flaky"]; export const troubleshootingCategoryValues = ["product", "env_issue", "external_dependency", "blocked", "flaky"];
export const suiteTypeValues = [ export const suiteTypeValues = ["smoke", "regression", "release_gate", "exploratory"];
"smoke",
"regression",
"release_gate",
"exploratory",
"contract",
"performance",
"reliability",
"chaos",
"security",
];
export const suiteRequiredStrings = ["id", "title", "description", "type", "priority"]; export const suiteRequiredStrings = ["id", "title", "description", "type", "priority"];
export const suiteRequiredLists = ["tags", "cases"]; export const suiteRequiredLists = ["tags", "cases"];
-20
View File
@@ -91,7 +91,6 @@ export type AutomationResultEvidence = {
path?: string; path?: string;
result?: string; result?: string;
reason?: string; reason?: string;
duration_ms?: number;
started_at?: string; started_at?: string;
started_at_local?: string; started_at_local?: string;
finished_at?: string; finished_at?: string;
@@ -99,9 +98,6 @@ export type AutomationResultEvidence = {
url?: string; url?: string;
prompt?: string; prompt?: string;
expected_text?: string; expected_text?: string;
metrics_summary?: Record<string, unknown>;
thresholds_summary?: Record<string, unknown>;
artifacts?: Record<string, unknown>;
}; };
type MutableScanState = { type MutableScanState = {
@@ -598,18 +594,6 @@ function stringField(data: Record<string, unknown>, key: string): string | undef
return typeof value === "string" && value.trim() ? value : undefined; return typeof value === "string" && value.trim() ? value : undefined;
} }
function numberField(data: Record<string, unknown>, key: string): number | undefined {
const value = data[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function objectField(data: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
const value = data[key];
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: undefined;
}
function evidenceDirFromOptions(options: Record<string, string | boolean>): string | undefined { function evidenceDirFromOptions(options: Record<string, string | boolean>): string | undefined {
const explicit = typeof options["evidence-dir"] === "string" ? options["evidence-dir"] : undefined; const explicit = typeof options["evidence-dir"] === "string" ? options["evidence-dir"] : undefined;
if (explicit) return resolve(explicit); if (explicit) return resolve(explicit);
@@ -644,7 +628,6 @@ export function readAutomationResultEvidence(options: Record<string, string | bo
path: resultPath, path: resultPath,
result: stringField(result, "status"), result: stringField(result, "status"),
reason: stringField(result, "reason"), reason: stringField(result, "reason"),
duration_ms: numberField(result, "duration_ms"),
started_at: stringField(result, "started_at"), started_at: stringField(result, "started_at"),
started_at_local: stringField(result, "started_at_local"), started_at_local: stringField(result, "started_at_local"),
finished_at: stringField(result, "finished_at"), finished_at: stringField(result, "finished_at"),
@@ -652,9 +635,6 @@ export function readAutomationResultEvidence(options: Record<string, string | bo
url: stringField(result, "url"), url: stringField(result, "url"),
prompt: redactSecrets(stringField(result, "prompt") ?? ""), prompt: redactSecrets(stringField(result, "prompt") ?? ""),
expected_text: stringField(result, "expected_text"), expected_text: stringField(result, "expected_text"),
metrics_summary: objectField(result, "metrics_summary"),
thresholds_summary: objectField(result, "thresholds_summary"),
artifacts: objectField(result, "artifacts"),
}; };
} catch (error) { } catch (error) {
return { status: "invalid", path: resultPath, reason: String(error) }; return { status: "invalid", path: resultPath, reason: String(error) };
-2
View File
@@ -114,8 +114,6 @@ export function automationEnvDefaults(item: StructuredItem, env: EnvSource = pro
["automation_expected_runner_id", "LANGBOT_E2E_EXPECTED_RUNNER_ID"], ["automation_expected_runner_id", "LANGBOT_E2E_EXPECTED_RUNNER_ID"],
["automation_reset_debug_chat", "LANGBOT_E2E_RESET_DEBUG_CHAT"], ["automation_reset_debug_chat", "LANGBOT_E2E_RESET_DEBUG_CHAT"],
["automation_debug_chat_session_type", "LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE"], ["automation_debug_chat_session_type", "LANGBOT_E2E_DEBUG_CHAT_SESSION_TYPE"],
["automation_debug_chat_response_p95_ms", "LANGBOT_E2E_DEBUG_CHAT_RESPONSE_P95_MS"],
["automation_debug_chat_max_error_rate", "LANGBOT_E2E_DEBUG_CHAT_MAX_ERROR_RATE"],
["automation_filesystem_checks_json", "LANGBOT_E2E_FILESYSTEM_CHECKS_JSON"], ["automation_filesystem_checks_json", "LANGBOT_E2E_FILESYSTEM_CHECKS_JSON"],
["automation_plugin_package", "LANGBOT_E2E_PLUGIN_PACKAGE"], ["automation_plugin_package", "LANGBOT_E2E_PLUGIN_PACKAGE"],
["automation_expected_plugin_id", "LANGBOT_E2E_EXPECTED_PLUGIN_ID"], ["automation_expected_plugin_id", "LANGBOT_E2E_EXPECTED_PLUGIN_ID"],
+1 -159
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { appendFileSync, chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { appendFileSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@@ -676,82 +676,6 @@ test("suite run JSON captures failed case output", () => {
} }
}); });
test("suite run preserves classified env_issue automation results", () => {
const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-env-issue-"));
try {
const skillDir = join(tmp, "skills", "langbot-testing");
const casesDir = join(skillDir, "cases");
const suitesDir = join(skillDir, "suites");
const scriptsDir = join(tmp, "scripts");
mkdirSync(casesDir, { recursive: true });
mkdirSync(suitesDir, { recursive: true });
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: langbot-testing\ndescription: Testing.\n---\n\n# Testing\n");
writeFileSync(join(tmp, "skills", ".env"), "");
writeFileSync(
join(casesDir, "env-case.yaml"),
[
"id: env-case",
"title: Env Case",
"mode: probe",
"area: qa",
"type: smoke",
"priority: p2",
"risk: low",
"ci_eligible: true",
"automation: scripts/env-issue.mjs",
"evidence_required:",
" - filesystem",
].join("\n"),
);
writeFileSync(
join(suitesDir, "mini.yaml"),
[
"id: mini",
"title: Mini",
"description: Mini suite.",
"type: smoke",
"priority: p2",
"tags:",
" - qa",
"cases:",
" - env-case",
].join("\n"),
);
writeFileSync(
join(scriptsDir, "env-issue.mjs"),
[
"import { mkdirSync, writeFileSync } from 'node:fs';",
"import { join } from 'node:path';",
"mkdirSync(process.env.LBS_EVIDENCE_DIR, { recursive: true });",
"const result = {",
" case_id: process.env.LBS_CASE_ID,",
" run_id: process.env.LBS_RUN_ID,",
" status: 'env_issue',",
" reason: 'backend not reachable',",
" evidence_collected: ['filesystem']",
"};",
"writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'result.json'), JSON.stringify(result));",
"writeFileSync(join(process.env.LBS_EVIDENCE_DIR, 'automation-result.json'), JSON.stringify({ ...result, source: 'automation' }));",
"process.exit(2);",
].join("\n"),
);
const result = capture(() => commandSuiteRun({
root: tmp,
args: ["suite", "run", "mini", "--run-id", "mini-run", "--evidence-dir", join(tmp, "evidence"), "--json"],
}));
assert.equal(result.code, 2);
const payload = JSON.parse(result.output);
assert.equal(payload.executions[0].status, "classified");
assert.equal(payload.report.status, "env_issue");
assert.equal(payload.report.execution_status, "ok");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("suite run failure cannot be masked by stale pass result", () => { test("suite run failure cannot be masked by stale pass result", () => {
const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-stale-pass-")); const tmp = mkdtempSync(join(tmpdir(), "lbs-suite-run-stale-pass-"));
try { try {
@@ -1445,56 +1369,6 @@ test("env doctor does not require proxy variables", async () => {
} }
}); });
test("env doctor reports missing socksio for active SOCKS proxy", async () => {
const tmp = mkdtempSync(join(tmpdir(), "lbs-env-doctor-socksio-"));
const originalAllProxy = process.env.ALL_PROXY;
const originalAllProxyLower = process.env.all_proxy;
try {
delete process.env.ALL_PROXY;
delete process.env.all_proxy;
const skillsDir = join(tmp, "skills");
const repoDir = join(tmp, "LangBot");
const webDir = join(repoDir, "web");
const venvBin = join(repoDir, ".venv", "bin");
const browserProfile = join(tmp, "browser-profile");
const chromium = join(tmp, "chromium");
mkdirSync(skillsDir, { recursive: true });
mkdirSync(webDir, { recursive: true });
mkdirSync(venvBin, { recursive: true });
mkdirSync(browserProfile, { recursive: true });
writeFileSync(chromium, "");
const python = join(venvBin, "python");
writeFileSync(python, "#!/bin/sh\nexit 1\n");
chmodSync(python, 0o755);
writeFileSync(
join(skillsDir, ".env"),
[
"LANGBOT_BACKEND_URL=http://127.0.0.1:59996",
"LANGBOT_FRONTEND_URL=http://127.0.0.1:59996",
"LANGBOT_DEV_FRONTEND_URL=http://127.0.0.1:59996",
`LANGBOT_REPO=${repoDir}`,
`LANGBOT_WEB_REPO=${webDir}`,
`LANGBOT_BROWSER_PROFILE=${browserProfile}`,
`LANGBOT_CHROMIUM_EXECUTABLE=${chromium}`,
"ALL_PROXY=socks5://127.0.0.1:7890",
].join("\n"),
);
const result = await captureAsync(() => commandEnvDoctor({ root: tmp, args: ["env", "doctor"] }));
assert.equal(result.code, 1);
assert.match(result.output, /FAIL: SOCKS proxy ALL_PROXY is configured/);
assert.match(result.output, /cannot import socksio/);
assert.match(result.output, /-m pip install socksio/);
} finally {
if (originalAllProxy === undefined) delete process.env.ALL_PROXY;
else process.env.ALL_PROXY = originalAllProxy;
if (originalAllProxyLower === undefined) delete process.env.all_proxy;
else process.env.all_proxy = originalAllProxyLower;
rmSync(tmp, { recursive: true, force: true });
}
});
test("env show redacts secret-like values by default", () => { test("env show redacts secret-like values by default", () => {
const tmp = mkdtempSync(join(tmpdir(), "lbs-env-show-redact-")); const tmp = mkdtempSync(join(tmpdir(), "lbs-env-show-redact-"));
try { try {
@@ -2647,38 +2521,6 @@ test("test report renders a reusable evidence template", () => {
assert.match(result.output, /no log files provided/); assert.match(result.output, /no log files provided/);
}); });
test("test report promotes loaded automation evidence into result section", () => {
const tmp = mkdtempSync(join(tmpdir(), "lbs-report-automation-"));
try {
writeFileSync(
join(tmp, "automation-result.json"),
JSON.stringify({
status: "pass",
reason: "latency thresholds passed",
url: "http://127.0.0.1:5300",
artifacts: { metrics_json: join(tmp, "metrics.json") },
}),
);
const result = capture(() => commandTestReport(ctx([
"test",
"report",
"langbot-live-backend-latency",
"--evidence-dir",
tmp,
"--no-auto-log",
])));
assert.equal(result.code, 0);
assert.match(result.output, /## Result\n- result: pass\n- reason: latency thresholds passed/);
assert.match(result.output, /- target_tested: http:\/\/127\.0\.0\.1:5300/);
assert.doesNotMatch(result.output, /target_tested: TODO/);
assert.match(result.output, /## Automation Result/);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("validate rejects dangling case references and missing automation scripts", () => { test("validate rejects dangling case references and missing automation scripts", () => {
const tmp = mkdtempSync(join(tmpdir(), "lbs-validate-strict-")); const tmp = mkdtempSync(join(tmpdir(), "lbs-validate-strict-"));
try { try {
+3 -1
View File
@@ -1,3 +1,5 @@
"""LangBot - Production-grade platform for building agentic IM bots""" """LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.10.2' from importlib.metadata import version
__version__ = version('langbot')
@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from langbot.pkg.utils import constants
from .. import group from .. import group
from .box_visibility import should_hide_box_runtime_status
@group.group_class('box', '/api/v1/box') @group.group_class('box', '/api/v1/box')
@@ -9,6 +12,7 @@ class BoxRouterGroup(group.RouterGroup):
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) @self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str: async def _() -> str:
status = await self.ap.box_service.get_status() status = await self.ap.box_service.get_status()
status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled'))
return self.success(data=status) return self.success(data=status)
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) @self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
@@ -0,0 +1,5 @@
from __future__ import annotations
def should_hide_box_runtime_status(edition: str, box_enabled: bool | None) -> bool:
return edition == 'cloud' and box_enabled is False
@@ -1,3 +1,5 @@
import base64
import quart import quart
from .. import group from .. import group
@@ -30,6 +32,50 @@ class SurveyRouterGroup(group.RouterGroup):
return self.fail(2, 'Failed to submit response') return self.fail(2, 'Failed to submit response')
return self.fail(3, 'Survey not available') return self.fail(3, 'Survey not available')
@self.route('/feedback', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _feedback(user_email: str) -> str:
"""Submit on-demand user feedback from the sidebar."""
json_data = await quart.request.get_json(silent=True) or {}
content = str(json_data.get('content', '')).strip()
attachments = json_data.get('attachments', [])
if not content:
return self.fail(1, 'content required')
if len(content) > 5000:
return self.fail(2, 'content too long')
if not isinstance(attachments, list):
return self.fail(3, 'attachments must be an array')
if len(attachments) > 3:
return self.fail(4, 'too many attachments')
normalized_attachments = []
for item in attachments:
if not isinstance(item, dict):
continue
data_url = str(item.get('data_url', ''))
mime_type = str(item.get('mime_type', ''))[:128]
name = str(item.get('name', ''))[:255]
if not data_url.startswith('data:image/'):
continue
try:
payload = data_url.split(',', 1)[1]
if len(base64.b64decode(payload, validate=True)) > 1024 * 1024:
return self.fail(5, 'attachment too large')
except Exception:
return self.fail(5, 'attachment too large')
normalized_attachments.append({'name': name, 'mime_type': mime_type, 'data_url': data_url})
if self.ap.survey:
ok = await self.ap.survey.submit_feedback(
content=content,
attachments=normalized_attachments,
user_email=user_email,
)
if ok:
return self.success()
return self.fail(6, 'Failed to submit feedback')
return self.fail(7, 'Survey not available')
@self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _dismiss() -> str: async def _dismiss() -> str:
"""Dismiss survey.""" """Dismiss survey."""
@@ -0,0 +1,509 @@
"""HTTP Bot adapter — standalone server-to-server platform adapter.
Lets any external backend drive a LangBot pipeline over plain HTTP:
* **Inbound** — the backend POSTs a signed message to the unified webhook
route ``POST /bots/<bot_uuid>``; this adapter verifies the signature, builds
a platform event carrying the caller-defined ``session_id`` as the launcher
id, and fires it into the normal pipeline (so message aggregation, N->1,
works for free).
* **Outbound** — every ``reply_message`` / ``reply_message_chunk`` the pipeline
emits is delivered as a signed POST to the configured ``callback_url``. A
single turn may emit many replies (1->M); each is one callback, ordered per
session via a small worker queue.
Design notes:
* The callback URL is taken **only** from adapter config (never from the
inbound message) to keep the SSRF surface closed.
* Replies for one ``session_id`` are delivered in ``sequence`` order; the
caller knows a turn is complete when ``is_final: true`` arrives.
* No new HTTP route is registered — the existing unified webhook dispatcher
(``pkg/api/http/controller/groups/webhooks.py``) calls
``handle_unified_webhook`` on this adapter.
See docs/platforms/http-bot.md for the full integration guide.
"""
from __future__ import annotations
import asyncio
import json
import time
import typing
import uuid
from datetime import datetime
import aiohttp
import pydantic
import quart
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from . import http_bot_signing as signing
from ...utils import httpclient
# Error envelope codes (HTTP status -> body code), documented in the design doc.
_ERR = {
'bad_request': (400, 40001),
'bad_signature': (401, 40101),
'duplicate': (409, 40901),
'too_large': (413, 41301),
'internal': (500, 50001),
}
# Max accepted inbound body size (bytes).
_MAX_BODY = 1 * 1024 * 1024
# Idempotency dedup window (seconds) and cap.
_IDEMPOTENCY_TTL = 600
_IDEMPOTENCY_MAX = 4096
class _SessionOutbound:
"""Per-session outbound state: ordered delivery queue + sequence counter."""
def __init__(self) -> None:
self.queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
self.worker: asyncio.Task | None = None
self.sequence: int = 0
self.last_was_final: bool = True # so the first reply of a turn starts at seq 1
class _SyncCollector:
"""Collects reply parts for a /sync request and resolves when the turn ends."""
def __init__(self) -> None:
self.parts: list = []
self.done: asyncio.Event = asyncio.Event()
class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Standalone HTTP adapter (inbound webhook + outbound callbacks)."""
bot_uuid: str = pydantic.Field(default='', exclude=True)
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = pydantic.Field(default_factory=dict, exclude=True)
# session_id -> outbound state
outbound_states: dict[str, _SessionOutbound] = pydantic.Field(default_factory=dict, exclude=True)
# idempotency key -> accepted-at epoch
idempotency_cache: dict[str, float] = pydantic.Field(default_factory=dict, exclude=True)
# session_id -> sync collector (set while a /sync request is awaiting a turn)
sync_waiters: dict[str, '_SyncCollector'] = pydantic.Field(default_factory=dict, exclude=True)
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
super().__init__(config=config, logger=logger, **kwargs)
self.bot_account_id = 'http_bot'
self.outbound_states = {}
self.idempotency_cache = {}
self.sync_waiters = {}
# -- framework hooks ------------------------------------------------------
def set_bot_uuid(self, bot_uuid: str) -> None:
"""Called by the bot manager so the adapter knows its own bot uuid."""
object.__setattr__(self, 'bot_uuid', bot_uuid)
def get_launcher_id(self, event: platform_events.MessageEvent) -> str:
"""Map an inbound event to a LangBot launcher id.
We return the caller-defined ``session_id`` (stashed on the sender /
group id at inbound time) so that each external session maps 1:1 to an
isolated LangBot session.
"""
if isinstance(event, platform_events.GroupMessage):
return str(event.sender.group.id)
return str(event.sender.id)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
func: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
],
):
self.listeners[event_type] = func
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
func: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
],
):
self.listeners.pop(event_type, None)
async def is_muted(self, group_id: int) -> bool:
return False
async def is_stream_output_supported(self) -> bool:
return True
async def run_async(self):
# Purely webhook-driven; nothing to poll. Stay alive.
while True:
await asyncio.sleep(3600)
async def kill(self):
# Cancel any outbound workers.
for state in self.outbound_states.values():
if state.worker and not state.worker.done():
state.worker.cancel()
return True
# -- inbound --------------------------------------------------------------
def _err(self, kind: str, detail: str = ''):
status, code = _ERR[kind]
return quart.jsonify({'code': code, 'msg': detail or kind, 'data': None}), status
def _prune_idempotency(self) -> None:
now = time.time()
if len(self.idempotency_cache) > _IDEMPOTENCY_MAX:
self.idempotency_cache.clear()
return
expired = [k for k, ts in self.idempotency_cache.items() if now - ts > _IDEMPOTENCY_TTL]
for k in expired:
self.idempotency_cache.pop(k, None)
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""Handle an inbound POST from the unified webhook dispatcher.
Sub-path routing:
(no path) -> push a message
"reset" -> reset a session's conversation (body: {session_id, session_type?})
"sync" -> push a message and wait for the final reply (collapses 1->M)
"""
object.__setattr__(self, 'bot_uuid', bot_uuid)
if path == 'reset':
return await self._handle_reset(request)
if path == 'sync':
return await self._handle_inbound(request, sync=True)
if path in ('', None):
return await self._handle_inbound(request, sync=False)
return self._err('bad_request', f'unknown sub-path: {path}')
async def _read_and_verify(self, request) -> tuple[dict | None, typing.Any]:
"""Read body, enforce size + signature. Returns (data, error_response)."""
body = await request.get_data()
if body and len(body) > _MAX_BODY:
return None, self._err('too_large', 'message too large')
if self.config.get('signature_required', True):
ok, reason = signing.verify(
secret=self.config.get('inbound_secret', ''),
body=body,
timestamp=request.headers.get(signing.HEADER_TIMESTAMP),
signature=request.headers.get(signing.HEADER_SIGNATURE),
)
if not ok:
await self.logger.warning(f'http_bot inbound signature rejected: {reason}')
return None, self._err('bad_signature', f'invalid signature: {reason}')
try:
data = json.loads(body)
except (json.JSONDecodeError, ValueError):
return None, self._err('bad_request', 'body is not valid JSON')
if not isinstance(data, dict):
return None, self._err('bad_request', 'body must be a JSON object')
return data, None
def _build_event(self, data: dict) -> tuple[platform_events.MessageEvent, str, str, str]:
"""Build a platform event from inbound data.
Returns (event, session_id, session_type, message_id).
"""
session_id = str(data['session_id'])
session_type = data.get('session_type') or self.config.get('default_session_type', 'person')
sender_meta = data.get('sender') or {}
sender_name = str(sender_meta.get('name', 'User'))
message_id = 'in_' + uuid.uuid4().hex
chain = platform_message.MessageChain.model_validate(data['message'])
# Carry the inbound message id + timestamp as the Source component.
chain.insert(0, platform_message.Source(id=message_id, time=datetime.now()))
if session_type == 'group':
group = platform_entities.Group(
id=session_id,
name=str(sender_meta.get('group_name', session_id)),
permission=platform_entities.Permission.Member,
)
sender = platform_entities.GroupMember(
id=str(sender_meta.get('id', session_id)),
member_name=sender_name,
group=group,
permission=platform_entities.Permission.Member,
)
event = platform_events.GroupMessage(sender=sender, message_chain=chain, time=datetime.now().timestamp())
else:
sender = platform_entities.Friend(id=session_id, nickname=sender_name, remark=sender_name)
event = platform_events.FriendMessage(sender=sender, message_chain=chain, time=datetime.now().timestamp())
return event, session_id, session_type, message_id
async def _handle_inbound(self, request, sync: bool):
data, err = await self._read_and_verify(request)
if err is not None:
return err
if 'session_id' not in data or 'message' not in data:
return self._err('bad_request', 'session_id and message are required')
# Idempotency.
idem = request.headers.get(signing.HEADER_IDEMPOTENCY)
if idem:
self._prune_idempotency()
if idem in self.idempotency_cache:
return self._err('duplicate', 'idempotency key already accepted')
self.idempotency_cache[idem] = time.time()
try:
event, session_id, session_type, message_id = self._build_event(data)
except Exception as e: # noqa: BLE001
return self._err('bad_request', f'failed to parse message: {e}')
listener = self.listeners.get(type(event))
if listener is None:
return self._err('internal', 'no listener registered for event type')
if sync:
return await self._run_sync(event, listener, session_id, message_id)
# Fire-and-collect: kick the pipeline, return 202 immediately.
asyncio.create_task(listener(event, self))
return quart.jsonify(
{
'code': 0,
'msg': 'accepted',
'data': {
'session_id': session_id,
'accepted_message_id': message_id,
'aggregating': True,
},
}
), 202
async def _handle_reset(self, request):
data, err = await self._read_and_verify(request)
if err is not None:
return err
if 'session_id' not in data:
return self._err('bad_request', 'session_id is required')
session_id = str(data['session_id'])
session_type = data.get('session_type') or self.config.get('default_session_type', 'person')
launcher_type = 'group' if session_type == 'group' else 'person'
removed = await self._reset_session(launcher_type, session_id)
return quart.jsonify({'code': 0, 'msg': 'reset', 'data': {'session_id': session_id, 'removed': removed}}), 200
async def _reset_session(self, launcher_type: str, launcher_id: str) -> bool:
"""Drop the matching session so the next message starts a fresh conversation."""
sess_mgr = self.ap.sess_mgr
before = len(sess_mgr.session_list)
sess_mgr.session_list = [
s
for s in sess_mgr.session_list
if not (
str(s.launcher_type.value if hasattr(s.launcher_type, 'value') else s.launcher_type) == launcher_type
and str(s.launcher_id) == launcher_id
)
]
return len(sess_mgr.session_list) < before
# -- outbound -------------------------------------------------------------
@staticmethod
def _extract_session_id(message_source: platform_events.MessageEvent) -> str:
if isinstance(message_source, platform_events.GroupMessage):
return str(message_source.sender.group.id)
return str(message_source.sender.id)
@staticmethod
def _extract_reply_to(message_source: platform_events.MessageEvent) -> str:
for comp in message_source.message_chain:
if isinstance(comp, platform_message.Source):
return str(comp.id)
return ''
def _next_sequence(self, session_id: str, is_final: bool) -> int:
state = self.outbound_states.setdefault(session_id, _SessionOutbound())
if state.last_was_final:
state.sequence = 1
else:
state.sequence += 1
state.last_was_final = is_final
return state.sequence
async def _enqueue_callback(self, session_id: str, payload: dict) -> None:
state = self.outbound_states.setdefault(session_id, _SessionOutbound())
if state.worker is None or state.worker.done():
state.worker = asyncio.create_task(self._outbound_worker(session_id, state))
try:
state.queue.put_nowait(payload)
except asyncio.QueueFull:
# Drop oldest to bound memory, then enqueue (best-effort, at-least-once).
try:
state.queue.get_nowait()
except asyncio.QueueEmpty:
pass
await self.logger.warning(f'http_bot outbound queue full for session {session_id}; dropped oldest')
state.queue.put_nowait(payload)
async def _outbound_worker(self, session_id: str, state: _SessionOutbound) -> None:
while True:
payload = await state.queue.get()
try:
await self._deliver_callback(payload)
except Exception as e: # noqa: BLE001
await self.logger.error(f'http_bot callback delivery failed for {session_id}: {e}')
finally:
state.queue.task_done()
async def _deliver_callback(self, payload: dict) -> None:
callback_url = self.config.get('callback_url', '')
if not callback_url:
await self.logger.warning('http_bot has no callback_url configured; dropping reply')
return
body = json.dumps(payload, ensure_ascii=False).encode()
secret = self.config.get('outbound_secret') or self.config.get('inbound_secret', '')
ts, sig = signing.sign(secret, body)
headers = {
'Content-Type': 'application/json',
signing.HEADER_TIMESTAMP: ts,
signing.HEADER_SIGNATURE: sig,
}
timeout = aiohttp.ClientTimeout(total=int(self.config.get('callback_timeout', 15)))
max_retries = int(self.config.get('callback_max_retries', 3))
session = httpclient.get_session()
attempt = 0
while True:
attempt += 1
try:
async with session.post(callback_url, data=body, headers=headers, timeout=timeout) as resp:
if resp.status < 400:
return
if resp.status < 500 or attempt > max_retries:
await self.logger.warning(f'http_bot callback {callback_url} -> {resp.status}, giving up')
return
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
if attempt > max_retries:
await self.logger.warning(f'http_bot callback {callback_url} failed after {attempt} tries: {e}')
return
await asyncio.sleep(min(2 ** (attempt - 1), 30))
async def _emit_reply(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
is_final: bool,
stream: bool,
) -> dict:
session_id = self._extract_session_id(message_source)
reply_to = self._extract_reply_to(message_source)
sequence = self._next_sequence(session_id, is_final)
parts = [c.model_dump() if hasattr(c, 'model_dump') else c.__dict__ for c in message]
payload = {
'session_id': session_id,
'reply_to': reply_to,
'sequence': sequence,
'is_final': is_final,
'stream': stream,
'message': parts,
'timestamp': datetime.now().isoformat(),
}
# If a /sync request is awaiting this session, collect instead of POSTing.
collector = self.sync_waiters.get(session_id)
if collector is not None:
collector.parts.extend(parts)
if is_final:
collector.done.set()
return payload
await self._enqueue_callback(session_id, payload)
return payload
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain) -> dict:
"""Proactively push a message to a session (target_id == session_id)."""
sequence = self._next_sequence(str(target_id), is_final=True)
payload = {
'session_id': str(target_id),
'reply_to': '',
'sequence': sequence,
'is_final': True,
'stream': False,
'message': [c.model_dump() if hasattr(c, 'model_dump') else c.__dict__ for c in message],
'timestamp': datetime.now().isoformat(),
}
await self._enqueue_callback(str(target_id), payload)
return payload
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> dict:
return await self._emit_reply(message_source, message, is_final=True, stream=False)
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
) -> dict:
message_is_final = is_final and getattr(bot_message, 'tool_calls', None) is None
return await self._emit_reply(message_source, message, is_final=message_is_final, stream=True)
# -- sync convenience mode ------------------------------------------------
async def _run_sync(self, event, listener, session_id: str, message_id: str):
"""Push a message and wait for the final reply, collapsing 1->M parts.
Lossy by design (drops streaming/ordering nuance); documented as such.
Concurrency-safe: routing is via the per-session ``_sync_waiters``
registry that ``_emit_reply`` consults, not by patching methods.
"""
if session_id in self.sync_waiters:
return self._err('duplicate', 'a sync request is already in flight for this session')
collector = _SyncCollector()
self.sync_waiters[session_id] = collector
try:
asyncio.create_task(listener(event, self))
timeout = int(self.config.get('callback_timeout', 15)) * 4
try:
await asyncio.wait_for(collector.done.wait(), timeout=timeout)
except asyncio.TimeoutError:
await self.logger.warning(f'http_bot sync wait timed out for session {session_id}')
finally:
self.sync_waiters.pop(session_id, None)
return quart.jsonify(
{
'code': 0,
'msg': 'ok',
'data': {
'session_id': session_id,
'reply_to': message_id,
'message': collector.parts,
},
}
), 200
@@ -0,0 +1,9 @@
<svg width="800px" height="800px" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="60" height="60" rx="14" fill="#2563EB"/>
<g stroke="#FFFFFF" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round" fill="none">
<!-- </> code icon -->
<path d="M24 22 L14 32 L24 42"/>
<path d="M40 22 L50 32 L40 42"/>
<path d="M36 18 L28 46"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 416 B

@@ -0,0 +1,153 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: http_bot
label:
en_US: HTTP Bot
zh_Hans: HTTP 通用接入
zh_Hant: HTTP 通用接入
ja_JP: HTTP ボット
description:
en_US: Integrate any backend over plain HTTP. Push messages in via a signed webhook, receive replies on a callback URL. Server-to-server, no long-lived connection. Preserves message aggregation (N->1) and multi-part replies (1->M).
zh_Hans: 通过 HTTP 接入任意后端系统。以签名 Webhook 推入消息,在回调地址接收回复。面向服务间集成,无需长连接。完整保留消息聚合(多条合一)与多段回复(一条问、多条回)能力。
zh_Hant: 透過 HTTP 接入任意後端系統。以簽名 Webhook 推入訊息,在回調地址接收回覆。面向服務間整合,無需長連線。完整保留訊息聚合(多條合一)與多段回覆(一條問、多條回)能力。
ja_JP: 任意のバックエンドを HTTP で接続。署名付き Webhook でメッセージを送信し、コールバック URL で返信を受信します。サーバー間連携、長時間接続不要。メッセージ集約(N→1)とマルチパート返信(1→M)に対応。
icon: http_bot.svg
spec:
categories:
- popular
- global
help_links:
zh: https://docs.langbot.app/zh/platforms/http-bot
en: https://docs.langbot.app/en/platforms/http-bot
ja: https://docs.langbot.app/ja/platforms/http-bot
config:
- name: webhook_url
label:
en_US: Inbound Webhook URL
zh_Hans: 入站 Webhook 地址
zh_Hant: 入站 Webhook 地址
ja_JP: 受信 Webhook URL
description:
en_US: Copy this URL. Your backend POSTs messages here (signed with the inbound secret).
zh_Hans: 复制此地址。你的后端将消息以签名方式 POST 到这里。
zh_Hant: 複製此地址。你的後端將訊息以簽名方式 POST 到這裡。
ja_JP: この URL をコピーしてください。バックエンドは署名付きでここにメッセージを POST します。
type: webhook-url
required: false
default: ""
- name: inbound_secret
label:
en_US: Inbound Signing Secret
zh_Hans: 入站签名密钥
zh_Hant: 入站簽名密鑰
ja_JP: 受信署名シークレット
description:
en_US: HMAC-SHA256 secret your backend uses to sign inbound requests. LangBot verifies every inbound POST with it.
zh_Hans: 你的后端用于对入站请求做 HMAC-SHA256 签名的密钥;LangBot 据此校验每个入站 POST。
zh_Hant: 你的後端用於對入站請求做 HMAC-SHA256 簽名的密鑰;LangBot 據此校驗每個入站 POST。
ja_JP: バックエンドが受信リクエストの署名に使う HMAC-SHA256 シークレット。LangBot は受信 POST ごとに検証します。
type: string
required: true
default: ""
- name: callback_url
label:
en_US: Outbound Callback URL
zh_Hans: 出站回调地址
zh_Hant: 出站回調地址
ja_JP: 送信コールバック URL
description:
en_US: Where LangBot POSTs replies. One turn may trigger multiple callbacks (1->M). For security the callback URL is taken ONLY from this config and cannot be overridden per-message.
zh_Hans: LangBot 将回复 POST 到此地址。一轮对话可能触发多次回调(一问多答)。出于安全考虑,回调地址只取自此配置,不允许逐条消息覆盖。
zh_Hant: LangBot 將回覆 POST 到此地址。一輪對話可能觸發多次回調(一問多答)。出於安全考慮,回調地址只取自此配置,不允許逐條訊息覆蓋。
ja_JP: LangBot が返信を POST する先。1 ターンで複数回のコールバック(1→M)が発生し得ます。セキュリティ上、コールバック URL はこの設定からのみ取得し、メッセージ単位で上書きできません。
type: string
required: true
default: ""
- name: outbound_secret
label:
en_US: Outbound Signing Secret
zh_Hans: 出站签名密钥
zh_Hant: 出站簽名密鑰
ja_JP: 送信署名シークレット
description:
en_US: HMAC-SHA256 secret LangBot uses to sign outbound callbacks so your receiver can verify them. Falls back to the inbound secret when empty.
zh_Hans: LangBot 用于对出站回调签名的密钥,供你的接收端校验。留空时回退使用入站密钥。
zh_Hant: LangBot 用於對出站回調簽名的密鑰,供你的接收端校驗。留空時回退使用入站密鑰。
ja_JP: LangBot が送信コールバックの署名に使う HMAC-SHA256 シークレット。受信側で検証できます。空の場合は受信シークレットを使用します。
type: string
required: false
default: ""
- name: default_session_type
label:
en_US: Default Session Type
zh_Hans: 默认会话类型
zh_Hant: 預設會話類型
ja_JP: デフォルトセッションタイプ
description:
en_US: Session type used when an inbound message omits session_type.
zh_Hans: 入站消息未携带 session_type 时使用的会话类型。
zh_Hant: 入站訊息未攜帶 session_type 時使用的會話類型。
ja_JP: 受信メッセージに session_type がない場合に使用するセッションタイプ。
type: select
options:
- name: person
label:
en_US: Person (1-on-1)
zh_Hans: 个人(一对一)
zh_Hant: 個人(一對一)
ja_JP: 個人(1 対 1
- name: group
label:
en_US: Group
zh_Hans: 群组
zh_Hant: 群組
ja_JP: グループ
required: false
default: person
- name: signature_required
label:
en_US: Require Inbound Signature
zh_Hans: 强制入站签名校验
zh_Hant: 強制入站簽名校驗
ja_JP: 受信署名を必須にする
description:
en_US: When enabled (recommended), every inbound POST must carry a valid signature. Disable ONLY for local development behind a trusted network.
zh_Hans: 开启(推荐)后,每个入站 POST 都必须带有效签名。仅在受信任内网的本地开发时关闭。
zh_Hant: 開啟(推薦)後,每個入站 POST 都必須帶有效簽名。僅在受信任內網的本地開發時關閉。
ja_JP: 有効(推奨)にすると、すべての受信 POST に有効な署名が必要です。信頼できるネットワーク内のローカル開発時のみ無効化してください。
type: boolean
required: false
default: true
- name: callback_timeout
label:
en_US: Callback Timeout (seconds)
zh_Hans: 回调超时(秒)
zh_Hant: 回調逾時(秒)
ja_JP: コールバックタイムアウト(秒)
description:
en_US: Per-callback HTTP timeout.
zh_Hans: 单次回调的 HTTP 超时时间。
zh_Hant: 單次回調的 HTTP 逾時時間。
ja_JP: コールバックごとの HTTP タイムアウト。
type: integer
required: false
default: 15
- name: callback_max_retries
label:
en_US: Callback Max Retries
zh_Hans: 回调最大重试次数
zh_Hant: 回調最大重試次數
ja_JP: コールバック最大リトライ回数
description:
en_US: Retries on timeout or 5xx, with exponential backoff.
zh_Hans: 超时或 5xx 时按指数退避重试的次数。
zh_Hant: 逾時或 5xx 時按指數退避重試的次數。
ja_JP: タイムアウトまたは 5xx 時に指数バックオフでリトライする回数。
type: integer
required: false
default: 3
execution:
python:
path: ./http_bot.py
attr: HttpBotAdapter
@@ -0,0 +1,95 @@
"""HMAC signing utilities for the HTTP Bot adapter.
A dependency-free, symmetric HMAC-SHA256 scheme used in *both* directions:
signing_string = "{timestamp}." + raw_body_bytes
signature = "sha256=" + hex(HMAC_SHA256(secret, signing_string))
Inbound requests are signed by the caller and verified here; outbound
callbacks are signed here and verified by the caller. The scheme is trivial to
reproduce in any language (see docs/platforms/http-bot.md for JS/curl).
"""
from __future__ import annotations
import hashlib
import hmac
import time
# Header names (kept here so adapter + clients agree on a single source).
HEADER_TIMESTAMP = 'X-LB-Timestamp'
HEADER_SIGNATURE = 'X-LB-Signature'
HEADER_IDEMPOTENCY = 'X-LB-Idempotency-Key'
# Maximum allowed clock skew between signer and verifier (seconds).
DEFAULT_REPLAY_WINDOW = 300
def compute_signature(secret: str, body: bytes, timestamp: str | int) -> str:
"""Compute the ``sha256=<hex>`` signature for *body* at *timestamp*.
Args:
secret: Shared HMAC secret.
body: Raw request body bytes (exactly as sent on the wire).
timestamp: Unix timestamp (seconds) as str or int.
Returns:
The signature string, e.g. ``sha256=ab12...``.
"""
signing_string = f'{timestamp}.'.encode() + body
digest = hmac.new(secret.encode(), signing_string, hashlib.sha256).hexdigest()
return f'sha256={digest}'
def sign(secret: str, body: bytes, timestamp: int | None = None) -> tuple[str, str]:
"""Produce ``(timestamp, signature)`` for an outbound request.
Args:
secret: Shared HMAC secret.
body: Raw request body bytes.
timestamp: Optional fixed timestamp; defaults to ``int(time.time())``.
Returns:
``(timestamp_str, signature_str)``.
"""
ts = str(timestamp if timestamp is not None else int(time.time()))
return ts, compute_signature(secret, body, ts)
def verify(
secret: str,
body: bytes,
timestamp: str | None,
signature: str | None,
replay_window: int = DEFAULT_REPLAY_WINDOW,
) -> tuple[bool, str]:
"""Verify an inbound signature.
Args:
secret: Shared HMAC secret.
body: Raw request body bytes.
timestamp: Value of the timestamp header.
signature: Value of the signature header.
replay_window: Max allowed skew in seconds.
Returns:
``(ok, reason)``. ``reason`` is empty when ``ok`` is True, otherwise a
short machine-friendly cause (``missing_headers`` / ``bad_timestamp`` /
``expired`` / ``signature_mismatch``).
"""
if not timestamp or not signature:
return False, 'missing_headers'
try:
ts_int = int(float(timestamp))
except (ValueError, TypeError):
return False, 'bad_timestamp'
if abs(int(time.time()) - ts_int) > replay_window:
return False, 'expired'
expected = compute_signature(secret, body, timestamp)
if not hmac.compare_digest(expected, signature):
return False, 'signature_mismatch'
return True, ''
+43 -3
View File
@@ -159,6 +159,21 @@ class SurveyManager:
"""Clear the pending survey (after user responds or dismisses).""" """Clear the pending survey (after user responds or dismisses)."""
self._pending_survey = None self._pending_survey = None
async def _build_base_metadata(self, user_email: str | None = None) -> dict:
metadata = {
'version': constants.semantic_version,
'instance_id': constants.instance_id,
}
if user_email:
metadata['login_account'] = user_email
try:
user_obj = await self.ap.user_service.get_user_by_email(user_email)
metadata['account_type'] = getattr(user_obj, 'account_type', '') or 'local'
metadata['space_account_uuid'] = getattr(user_obj, 'space_account_uuid', '') or ''
except Exception:
pass
return metadata
async def submit_response(self, survey_id: str, answers: dict, completed: bool = True) -> bool: async def submit_response(self, survey_id: str, answers: dict, completed: bool = True) -> bool:
"""Submit a survey response to Space.""" """Submit a survey response to Space."""
if not self._is_space_configured(): if not self._is_space_configured():
@@ -169,9 +184,7 @@ class SurveyManager:
'survey_id': survey_id, 'survey_id': survey_id,
'instance_id': constants.instance_id, 'instance_id': constants.instance_id,
'answers': answers, 'answers': answers,
'metadata': { 'metadata': await self._build_base_metadata(),
'version': constants.semantic_version,
},
'completed': completed, 'completed': completed,
} }
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client: async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
@@ -183,6 +196,33 @@ class SurveyManager:
self.ap.logger.warning(f'Failed to submit survey response: {e}') self.ap.logger.warning(f'Failed to submit survey response: {e}')
return False return False
async def submit_feedback(
self,
content: str,
attachments: list[dict],
user_email: str | None = None,
) -> bool:
"""Submit an on-demand user feedback item to Space."""
if not self._is_space_configured():
return False
try:
url = f'{self._space_url}/api/v1/survey/feedback'
metadata = await self._build_base_metadata(user_email)
payload = {
'instance_id': constants.instance_id,
'content': content,
'attachments': attachments,
'metadata': metadata,
}
async with httpx.AsyncClient(timeout=httpx.Timeout(30)) as client:
resp = await client.post(url, json=payload)
if resp.status_code == 200:
return True
self.ap.logger.warning(f'Failed to submit feedback: {resp.status_code} {resp.text[:200]}')
except Exception as e:
self.ap.logger.warning(f'Failed to submit feedback: {e}')
return False
async def dismiss_survey(self, survey_id: str) -> bool: async def dismiss_survey(self, survey_id: str) -> bool:
"""Dismiss a survey.""" """Dismiss a survey."""
if not self._is_space_configured(): if not self._is_space_configured():
-21
View File
@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>LangBot Embed Widget Test</title>
<style>
body { font-family: sans-serif; padding: 40px; background: #f5f5f5; }
h1 { margin-bottom: 10px; }
p { color: #666; }
code { background: #e0e0e0; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<h1>LangBot Embed Widget Test Page</h1>
<p>If the widget loaded correctly, you should see a blue chat bubble in the bottom-right corner.</p>
<p>Replace the <code>BOT_UUID</code> below with your actual bot UUID.</p>
<!-- Replace BOT_UUID with your real bot UUID -->
<script data-title="LangBot" src="http://localhost:5300/api/v1/embed/a0ab80e7-742a-445f-bd0e-7d9758f1cfa7/widget.js"></script>
</body>
</html>
@@ -0,0 +1,18 @@
from __future__ import annotations
import pytest
from langbot.pkg.api.http.controller.groups.box_visibility import should_hide_box_runtime_status
@pytest.mark.parametrize(
('edition', 'box_enabled', 'expected'),
[
('cloud', False, True),
('cloud', True, False),
('cloud', None, False),
('community', False, False),
],
)
def test_should_hide_box_runtime_status(edition, box_enabled, expected):
assert should_hide_box_runtime_status(edition, box_enabled) is expected
Generated
+5 -5
View File
@@ -2008,7 +2008,7 @@ wheels = [
[[package]] [[package]]
name = "langbot" name = "langbot"
version = "4.10.2" version = "4.10.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocqhttp" }, { name = "aiocqhttp" },
@@ -2123,7 +2123,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.4.5" }, { name = "langbot-plugin", specifier = "==0.4.6" },
{ name = "langchain", specifier = ">=1.3.9" }, { name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2187,7 +2187,7 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.4.5" version = "0.4.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
@@ -2208,9 +2208,9 @@ dependencies = [
{ name = "watchdog" }, { name = "watchdog" },
{ name = "websockets" }, { name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/f3/db/db33ec42b3242ea7de0c93b0523a8d32a3df76b13de177fd31671db0ba3f/langbot_plugin-0.4.5.tar.gz", hash = "sha256:3cafa5694f31e9e4b4a3d870c1bc23ee7ac6e8d271a0140c5198993471f220ec", size = 326504, upload-time = "2026-06-19T14:53:51.687Z" } sdist = { url = "https://files.pythonhosted.org/packages/b4/6a/5fdb5365ad04aaa61344e92578d73eb1577af35783b80767c7d6c51cb8b9/langbot_plugin-0.4.6.tar.gz", hash = "sha256:838e3cd45ed795ed4c3299c73f141b217adfa05f09937a01694e7158619e4f6e", size = 334171, upload-time = "2026-06-22T15:06:56.565Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/92/8a08f8793de479fffa12a1906a25b6ff5b67a018520fa72d981569e1a6e4/langbot_plugin-0.4.5-py3-none-any.whl", hash = "sha256:12ab9aff0fb2459f75a11ba6999d2b5dfc753dcc7d265b078777b24e04b23c83", size = 215602, upload-time = "2026-06-19T14:53:50.021Z" }, { url = "https://files.pythonhosted.org/packages/6b/55/7adc2e180a299ed58613e159c64195477e09c05136f949942c6cec5219e8/langbot_plugin-0.4.6-py3-none-any.whl", hash = "sha256:30eb47efc0b703818ac003a5cd67caf720d9749dd503155eb65cce0c28b194a7", size = 217434, upload-time = "2026-06-22T15:06:55.237Z" },
] ]
[[package]] [[package]]
@@ -0,0 +1,206 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ImagePlus, Loader2, Paperclip, Send, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { httpClient } from '@/app/infra/http/HttpClient';
const MAX_ATTACHMENTS = 3;
const MAX_IMAGE_BYTES = 1024 * 1024;
type FeedbackAttachment = {
name: string;
mime_type: string;
data_url: string;
};
function readImageFile(file: File): Promise<FeedbackAttachment> {
return new Promise((resolve, reject) => {
if (!file.type.startsWith('image/')) {
reject(new Error('not_image'));
return;
}
if (file.size > MAX_IMAGE_BYTES) {
reject(new Error('too_large'));
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = String(reader.result || '');
if (!dataUrl.startsWith('data:image/')) {
reject(new Error('not_image'));
return;
}
resolve({
name: file.name || 'pasted-image.png',
mime_type: file.type || 'image/png',
data_url: dataUrl,
});
};
reader.onerror = () => reject(reader.error || new Error('read_failed'));
reader.readAsDataURL(file);
});
}
const FEEDBACK_I18N_PREFIX = 'monitoring.feedback';
export function FeedbackPopoverContent({
onSubmitted,
}: {
onSubmitted?: () => void;
}) {
const { t } = useTranslation();
const tf = useCallback(
(key: string) => t(`${FEEDBACK_I18N_PREFIX}.${key}`),
[t],
);
const [content, setContent] = useState('');
const [attachments, setAttachments] = useState<FeedbackAttachment[]>([]);
const [submitting, setSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const addFiles = useCallback(
async (files: File[]) => {
const slots = MAX_ATTACHMENTS - attachments.length;
if (slots <= 0) {
toast.error(tf('tooManyImages'));
return;
}
const picked = files.slice(0, slots);
const next: FeedbackAttachment[] = [];
for (const file of picked) {
try {
next.push(await readImageFile(file));
} catch (error) {
const msg = error instanceof Error ? error.message : '';
toast.error(
msg === 'too_large' ? tf('imageTooLarge') : tf('imageOnly'),
);
}
}
if (next.length > 0) {
setAttachments((prev) => [...prev, ...next].slice(0, MAX_ATTACHMENTS));
}
},
[attachments.length, tf],
);
useEffect(() => {
const onPaste = (event: ClipboardEvent) => {
const files = Array.from(event.clipboardData?.files || []).filter(
(file) => file.type.startsWith('image/'),
);
if (files.length > 0) {
event.preventDefault();
void addFiles(files);
}
};
window.addEventListener('paste', onPaste);
return () => window.removeEventListener('paste', onPaste);
}, [addFiles]);
const handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed) {
toast.error(tf('contentRequired'));
return;
}
try {
setSubmitting(true);
await httpClient.submitFeedback({
content: trimmed,
attachments,
});
toast.success(tf('submitSuccess'));
setContent('');
setAttachments([]);
onSubmitted?.();
} catch {
toast.error(tf('submitFailed'));
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-3" onClick={(e) => e.stopPropagation()}>
<div>
<div className="text-sm font-medium">{tf('title')}</div>
<p className="mt-1 text-xs text-muted-foreground">
{tf('description')}
</p>
</div>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={tf('placeholder')}
maxLength={5000}
className="min-h-32 resize-none text-sm"
/>
<div className="flex flex-wrap gap-2">
{attachments.map((item, index) => (
<div
key={`${item.name}-${index}`}
className="relative size-16 overflow-hidden rounded-md border"
>
<img
src={item.data_url}
alt={item.name}
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={() =>
setAttachments((prev) => prev.filter((_, i) => i !== index))
}
className="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white"
aria-label={tf('removeImage')}
>
<X className="size-3" />
</button>
</div>
))}
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
void addFiles(Array.from(e.target.files || []));
e.target.value = '';
}}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus className="mr-1 size-4" />
{tf('attachImage')}
</Button>
</div>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Paperclip className="size-3" />
{attachments.length}/{MAX_ATTACHMENTS}
</span>
</div>
<Button className="w-full" onClick={handleSubmit} disabled={submitting}>
{submitting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Send className="mr-2 size-4" />
)}
{tf('submit')}
</Button>
<p className="text-[11px] leading-relaxed text-muted-foreground">
{tf('privacyHint')}
</p>
</div>
);
}
@@ -100,6 +100,7 @@ import {
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext'; import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
import { FeedbackPopoverContent } from './FeedbackPopover';
// Compare two version strings, returns true if v1 > v2 // Compare two version strings, returns true if v1 > v2
function compareVersions(v1: string, v2: string): boolean { function compareVersions(v1: string, v2: string): boolean {
@@ -1569,6 +1570,7 @@ export default function HomeSidebar({
); );
const [hasNewVersion, setHasNewVersion] = useState(false); const [hasNewVersion, setHasNewVersion] = useState(false);
const [versionDialogOpen, setVersionDialogOpen] = useState(false); const [versionDialogOpen, setVersionDialogOpen] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string>(''); const [userEmail, setUserEmail] = useState<string>('');
const [starCount, setStarCount] = useState<number | null>(null); const [starCount, setStarCount] = useState<number | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false);
@@ -2041,10 +2043,8 @@ export default function HomeSidebar({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
window.open( setUserMenuOpen(false);
'https://github.com/langbot-app/LangBot/issues', setFeedbackOpen(true);
'_blank',
);
}} }}
> >
<Lightbulb /> <Lightbulb />
@@ -2096,6 +2096,18 @@ export default function HomeSidebar({
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
<Dialog open={feedbackOpen} onOpenChange={setFeedbackOpen}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-[380px]">
<DialogHeader className="sr-only">
<DialogTitle>{t('monitoring.feedback.title')}</DialogTitle>
<DialogDescription>
{t('monitoring.feedback.description')}
</DialogDescription>
</DialogHeader>
<FeedbackPopoverContent onSubmitted={() => setFeedbackOpen(false)} />
</DialogContent>
</Dialog>
<SettingsDialog <SettingsDialog
open={settingsOpen} open={settingsOpen}
onOpenChange={handleSettingsOpenChange} onOpenChange={handleSettingsOpenChange}
@@ -65,11 +65,13 @@ export default function SystemStatusCard({
const fetchStatus = useCallback(async () => { const fetchStatus = useCallback(async () => {
try { try {
const [plugin, box, sessions] = await Promise.all([ const [plugin, box] = await Promise.all([
httpClient.getPluginSystemStatus().catch(() => null), httpClient.getPluginSystemStatus().catch(() => null),
httpClient.getBoxStatus().catch(() => null), httpClient.getBoxStatus().catch(() => null),
httpClient.getBoxSessions().catch(() => [] as BoxSessionInfo[]),
]); ]);
const sessions = box?.hidden
? []
: await httpClient.getBoxSessions().catch(() => [] as BoxSessionInfo[]);
setPluginStatus(plugin); setPluginStatus(plugin);
setBoxStatus(box); setBoxStatus(box);
setBoxSessions(sessions); setBoxSessions(sessions);
@@ -95,6 +97,7 @@ export default function SystemStatusCard({
: 'failed' : 'failed'
: null; : null;
const boxOk = boxStatus ? boxStatus.available : null; const boxOk = boxStatus ? boxStatus.available : null;
const hideBoxRuntime = boxStatus?.hidden === true;
// Box has three observable states: connected (ok), disabled by config // Box has three observable states: connected (ok), disabled by config
// (enabled = false → distinct gray dot + "disabled" hint), and configured // (enabled = false → distinct gray dot + "disabled" hint), and configured
// but failed (red dot + connector_error). The dashboard must distinguish // but failed (red dot + connector_error). The dashboard must distinguish
@@ -152,11 +155,13 @@ export default function SystemStatusCard({
<Plug className="w-3.5 h-3.5 text-muted-foreground" /> <Plug className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-sm">{t('monitoring.pluginRuntime')}</span> <span className="text-sm">{t('monitoring.pluginRuntime')}</span>
</div> </div>
<div className="flex items-center gap-2"> {!hideBoxRuntime && (
<StatusDot state={boxState} /> <div className="flex items-center gap-2">
<Box className="w-3.5 h-3.5 text-muted-foreground" /> <StatusDot state={boxState} />
<span className="text-sm">{t('monitoring.boxRuntime')}</span> <Box className="w-3.5 h-3.5 text-muted-foreground" />
</div> <span className="text-sm">{t('monitoring.boxRuntime')}</span>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -214,181 +219,189 @@ export default function SystemStatusCard({
</div> </div>
</div> </div>
<div className="border-t" /> {!hideBoxRuntime && (
<>
<div className="border-t" />
{/* Box Runtime */} {/* Box Runtime */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Box className="w-4 h-4 text-muted-foreground" /> <Box className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold"> <span className="text-sm font-semibold">
{t('monitoring.boxRuntime')} {t('monitoring.boxRuntime')}
</span> </span>
</div>
<div className="ml-6 text-sm space-y-1">
<div className="flex items-center gap-1.5">
{boxState === 'ok' ? (
<CircleCheck className="w-4 h-4 text-green-600" />
) : (
<CircleX
className={
boxState === 'disabled'
? 'w-4 h-4 text-muted-foreground'
: 'w-4 h-4 text-red-500'
}
/>
)}
<span
className={
boxState === 'ok'
? 'text-green-600 font-medium'
: boxState === 'disabled'
? 'text-muted-foreground font-medium'
: 'text-red-500 font-medium'
}
>
{boxState === 'ok'
? t('monitoring.connected')
: boxState === 'disabled'
? t('monitoring.disabled')
: t('monitoring.disconnected')}
</span>
</div>
{boxState === 'disabled' && (
<p className="text-muted-foreground text-xs">
{t('monitoring.boxDisabled')}
</p>
)}
{boxState === 'failed' && boxStatus?.connector_error && (
<p className="text-red-400 text-xs break-all">
{boxStatus.connector_error}
</p>
)}
{boxStatus && (
<div className="text-muted-foreground text-xs space-y-0.5">
{boxStatus.backend && (
<p>
{t('monitoring.boxBackend')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.backend.name}
</span>
</p>
)}
<p>
{t('monitoring.boxProfile')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.profile}
</span>
</p>
{boxOk && boxStatus.active_sessions !== undefined && (
<p>
{t('monitoring.boxSandboxes')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.active_sessions}
</span>
</p>
)}
</div> </div>
)} <div className="ml-6 text-sm space-y-1">
<div className="flex items-center gap-1.5">
{/* Active Sandboxes */} {boxState === 'ok' ? (
{boxSessions.length > 0 && ( <CircleCheck className="w-4 h-4 text-green-600" />
<div className="mt-3 space-y-2"> ) : (
{boxSessions.map((session) => ( <CircleX
<div className={
key={session.session_id} boxState === 'disabled'
className="rounded-lg border p-3 space-y-2" ? 'w-4 h-4 text-muted-foreground'
: 'w-4 h-4 text-red-500'
}
/>
)}
<span
className={
boxState === 'ok'
? 'text-green-600 font-medium'
: boxState === 'disabled'
? 'text-muted-foreground font-medium'
: 'text-red-500 font-medium'
}
> >
<div className="flex items-center gap-1.5 min-w-0"> {boxState === 'ok'
<Container className="w-4 h-4 text-muted-foreground flex-shrink-0" /> ? t('monitoring.connected')
<Tooltip> : boxState === 'disabled'
<TooltipTrigger asChild> ? t('monitoring.disabled')
<span className="font-mono font-semibold text-foreground truncate text-sm"> : t('monitoring.disconnected')}
{session.session_id} </span>
</span> </div>
</TooltipTrigger> {boxState === 'disabled' && (
<TooltipContent> <p className="text-muted-foreground text-xs">
{session.session_id} {t('monitoring.boxDisabled')}
</TooltipContent> </p>
</Tooltip> )}
</div> {boxState === 'failed' && boxStatus?.connector_error && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs"> <p className="text-red-400 text-xs break-all">
<div className="flex items-center gap-1.5 text-muted-foreground min-w-0"> {boxStatus.connector_error}
<Image className="w-3 h-3 flex-shrink-0" /> </p>
<Tooltip> )}
<TooltipTrigger asChild> {boxStatus && (
<span className="text-foreground font-mono truncate"> <div className="text-muted-foreground text-xs space-y-0.5">
{session.image} {boxStatus.backend && (
</span> <p>
</TooltipTrigger> {t('monitoring.boxBackend')}:{' '}
<TooltipContent>{session.image}</TooltipContent> <span className="text-foreground font-mono">
</Tooltip> {boxStatus.backend.name}
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<HardDrive className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.backend_name}
</span> </span>
</div> </p>
<div className="flex items-center gap-1.5 text-muted-foreground"> )}
<Cpu className="w-3 h-3 flex-shrink-0" /> <p>
<span className="text-foreground"> {t('monitoring.boxProfile')}:{' '}
{session.cpus} CPU / {session.memory_mb} MB <span className="text-foreground font-mono">
{boxStatus.profile}
</span>
</p>
{boxOk && boxStatus.active_sessions !== undefined && (
<p>
{t('monitoring.boxSandboxes')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.active_sessions}
</span> </span>
</div> </p>
<div className="flex items-center gap-1.5 text-muted-foreground"> )}
<Network className="w-3 h-3 flex-shrink-0" /> </div>
<span className="text-foreground"> )}
{session.network}
</span> {/* Active Sandboxes */}
</div> {boxSessions.length > 0 && (
{session.host_path && ( <div className="mt-3 space-y-2">
<div className="flex items-center gap-1.5 text-muted-foreground col-span-2 min-w-0"> {boxSessions.map((session) => (
<FolderOpen className="w-3 h-3 flex-shrink-0" /> <div
key={session.session_id}
className="rounded-lg border p-3 space-y-2"
>
<div className="flex items-center gap-1.5 min-w-0">
<Container className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="text-foreground font-mono truncate"> <span className="font-mono font-semibold text-foreground truncate text-sm">
{session.host_path} : {session.mount_path}{' '} {session.session_id}
<span className="text-muted-foreground">
({session.host_path_mode})
</span>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{session.host_path} : {session.mount_path} ( {session.session_id}
{session.host_path_mode})
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
)} <div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground"> <div className="flex items-center gap-1.5 text-muted-foreground min-w-0">
<Clock className="w-3 h-3 flex-shrink-0" /> <Image className="w-3 h-3 flex-shrink-0" />
<span> <Tooltip>
{t('monitoring.boxSessionCreated')}:{' '} <TooltipTrigger asChild>
<span className="text-foreground"> <span className="text-foreground font-mono truncate">
{new Date( {session.image}
session.created_at, </span>
).toLocaleString()} </TooltipTrigger>
</span> <TooltipContent>
</span> {session.image}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<HardDrive className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.backend_name}
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Cpu className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.cpus} CPU / {session.memory_mb} MB
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Network className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.network}
</span>
</div>
{session.host_path && (
<div className="flex items-center gap-1.5 text-muted-foreground col-span-2 min-w-0">
<FolderOpen className="w-3 h-3 flex-shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-foreground font-mono truncate">
{session.host_path} :{' '}
{session.mount_path}{' '}
<span className="text-muted-foreground">
({session.host_path_mode})
</span>
</span>
</TooltipTrigger>
<TooltipContent>
{session.host_path} :{' '}
{session.mount_path} (
{session.host_path_mode})
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="w-3 h-3 flex-shrink-0" />
<span>
{t('monitoring.boxSessionCreated')}:{' '}
<span className="text-foreground">
{new Date(
session.created_at,
).toLocaleString()}
</span>
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="w-3 h-3 flex-shrink-0" />
<span>
{t('monitoring.boxSessionLastUsed')}:{' '}
<span className="text-foreground">
{new Date(
session.last_used_at,
).toLocaleString()}
</span>
</span>
</div>
</div>
</div> </div>
<div className="flex items-center gap-1.5 text-muted-foreground"> ))}
<Clock className="w-3 h-3 flex-shrink-0" />
<span>
{t('monitoring.boxSessionLastUsed')}:{' '}
<span className="text-foreground">
{new Date(
session.last_used_at,
).toLocaleString()}
</span>
</span>
</div>
</div>
</div> </div>
))} )}
</div> </div>
)} </div>
</div> </>
</div> )}
</div> </div>
</TooltipProvider> </TooltipProvider>
</DialogContent> </DialogContent>
+2
View File
@@ -373,6 +373,8 @@ export interface ApiRespPluginSystemStatus {
export interface ApiRespBoxStatus { export interface ApiRespBoxStatus {
available: boolean; available: boolean;
/** UI hint: hide the Box runtime status surface for this deployment. */
hidden?: boolean;
/** Whether ``box.enabled`` is true in config. When false, the sandbox /** Whether ``box.enabled`` is true in config. When false, the sandbox
* is deliberately disabled distinct from "configured but failed". */ * is deliberately disabled distinct from "configured but failed". */
enabled?: boolean; enabled?: boolean;
+11
View File
@@ -1332,6 +1332,17 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/survey/dismiss', { survey_id: surveyId }); return this.post('/api/v1/survey/dismiss', { survey_id: surveyId });
} }
public submitFeedback(data: {
content: string;
attachments?: Array<{
name: string;
mime_type: string;
data_url: string;
}>;
}): Promise<object> {
return this.post('/api/v1/survey/feedback', data);
}
// ============ Skills API ============ // ============ Skills API ============
public getSkills(): Promise<ApiRespSkills> { public getSkills(): Promise<ApiRespSkills> {
+17 -1
View File
@@ -35,7 +35,7 @@ const enUS = {
emptyPassword: 'Please enter your password', emptyPassword: 'Please enter your password',
language: 'Language', language: 'Language',
helpDocs: 'Get Help', helpDocs: 'Get Help',
featureRequest: 'Feature Request', featureRequest: 'Feedback',
starOnGitHub: 'Star on GitHub', starOnGitHub: 'Star on GitHub',
joinDiscord: 'Join our Discord', joinDiscord: 'Join our Discord',
create: 'Create', create: 'Create',
@@ -1362,6 +1362,22 @@ const enUS = {
inaccurateReasons: 'Inaccurate Reasons', inaccurateReasons: 'Inaccurate Reasons',
platform: 'Platform', platform: 'Platform',
exportFeedback: 'Export Feedback', exportFeedback: 'Export Feedback',
description:
'Tell us what went wrong or what could be better. Instance UUID and login account are included for diagnosis.',
placeholder: 'Describe your suggestion, issue, or reproduction steps...',
attachImage: 'Add image',
screenshot: 'Screenshot',
submit: 'Submit feedback',
privacyHint:
'Do not include secrets, passwords, or private chat content.',
contentRequired: 'Please enter feedback first',
imageOnly: 'Only image attachments are supported',
imageTooLarge: 'Each image must be under 1MB',
tooManyImages: 'You can attach up to 3 images',
screenshotFailed: 'Screenshot failed. Try pasting or uploading an image.',
submitSuccess: 'Feedback submitted. Thanks!',
submitFailed: 'Failed to submit feedback. Please try again later.',
removeImage: 'Remove image',
}, },
queries: { queries: {
title: 'Queries', title: 'Queries',
+16
View File
@@ -1395,6 +1395,22 @@ const esES = {
inaccurateReasons: 'Razones de inexactitud', inaccurateReasons: 'Razones de inexactitud',
platform: 'Plataforma', platform: 'Plataforma',
exportFeedback: 'Exportar comentarios', exportFeedback: 'Exportar comentarios',
description:
'Tell us what went wrong or what could be better. Instance UUID and login account are included for diagnosis.',
placeholder: 'Describe your suggestion, issue, or reproduction steps...',
attachImage: 'Add image',
screenshot: 'Screenshot',
submit: 'Submit feedback',
privacyHint:
'Do not include secrets, passwords, or private chat content.',
contentRequired: 'Please enter feedback first',
imageOnly: 'Only image attachments are supported',
imageTooLarge: 'Each image must be under 1MB',
tooManyImages: 'You can attach up to 3 images',
screenshotFailed: 'Screenshot failed. Try pasting or uploading an image.',
submitSuccess: 'Feedback submitted. Thanks!',
submitFailed: 'Failed to submit feedback. Please try again later.',
removeImage: 'Remove image',
}, },
queries: { queries: {
title: 'Consultas', title: 'Consultas',
+17 -1
View File
@@ -36,7 +36,7 @@ const jaJP = {
emptyPassword: 'パスワードを入力してください', emptyPassword: 'パスワードを入力してください',
language: '言語', language: '言語',
helpDocs: 'ヘルプドキュメント', helpDocs: 'ヘルプドキュメント',
featureRequest: '機能リクエスト', featureRequest: 'フィードバック',
starOnGitHub: 'GitHubでStarする', starOnGitHub: 'GitHubでStarする',
joinDiscord: 'Discord に参加', joinDiscord: 'Discord に参加',
create: '作成', create: '作成',
@@ -1368,6 +1368,22 @@ const jaJP = {
inaccurateReasons: '不正確な理由', inaccurateReasons: '不正確な理由',
platform: 'プラットフォーム', platform: 'プラットフォーム',
exportFeedback: 'フィードバックをエクスポート', exportFeedback: 'フィードバックをエクスポート',
description:
'問題点や改善案を教えてください。診断のため、インスタンス UUID、ログインアカウント、ページ情報も送信されます。',
placeholder: '提案、問題、再現手順を入力してください...',
attachImage: '画像を追加',
screenshot: 'スクリーンショット',
submit: '送信',
privacyHint: '秘密鍵、パスワード、個人的な会話内容は含めないでください。',
contentRequired: 'フィードバック内容を入力してください',
imageOnly: '画像のみ添付できます',
imageTooLarge: '画像は 1 枚 2MB 未満にしてください',
tooManyImages: '画像は最大 3 枚まで添付できます',
screenshotFailed:
'スクリーンショットに失敗しました。貼り付けまたはアップロードを試してください。',
submitSuccess: 'フィードバックを送信しました。ありがとうございます!',
submitFailed: '送信に失敗しました。後でもう一度お試しください。',
removeImage: '画像を削除',
}, },
messageDetails: { messageDetails: {
noData: 'このクエリにはLLM呼び出しやエラーがありません', noData: 'このクエリにはLLM呼び出しやエラーがありません',
+16
View File
@@ -1371,6 +1371,22 @@ const ruRU = {
inaccurateReasons: 'Причины неточности', inaccurateReasons: 'Причины неточности',
platform: 'Платформа', platform: 'Платформа',
exportFeedback: 'Экспорт отзывов', exportFeedback: 'Экспорт отзывов',
description:
'Tell us what went wrong or what could be better. Instance UUID and login account are included for diagnosis.',
placeholder: 'Describe your suggestion, issue, or reproduction steps...',
attachImage: 'Add image',
screenshot: 'Screenshot',
submit: 'Submit feedback',
privacyHint:
'Do not include secrets, passwords, or private chat content.',
contentRequired: 'Please enter feedback first',
imageOnly: 'Only image attachments are supported',
imageTooLarge: 'Each image must be under 1MB',
tooManyImages: 'You can attach up to 3 images',
screenshotFailed: 'Screenshot failed. Try pasting or uploading an image.',
submitSuccess: 'Feedback submitted. Thanks!',
submitFailed: 'Failed to submit feedback. Please try again later.',
removeImage: 'Remove image',
}, },
queries: { queries: {
title: 'Запросы', title: 'Запросы',
+16
View File
@@ -1340,6 +1340,22 @@ const thTH = {
inaccurateReasons: 'เหตุผลที่ไม่ถูกต้อง', inaccurateReasons: 'เหตุผลที่ไม่ถูกต้อง',
platform: 'แพลตฟอร์ม', platform: 'แพลตฟอร์ม',
exportFeedback: 'ส่งออกความคิดเห็น', exportFeedback: 'ส่งออกความคิดเห็น',
description:
'Tell us what went wrong or what could be better. Instance UUID and login account are included for diagnosis.',
placeholder: 'Describe your suggestion, issue, or reproduction steps...',
attachImage: 'Add image',
screenshot: 'Screenshot',
submit: 'Submit feedback',
privacyHint:
'Do not include secrets, passwords, or private chat content.',
contentRequired: 'Please enter feedback first',
imageOnly: 'Only image attachments are supported',
imageTooLarge: 'Each image must be under 1MB',
tooManyImages: 'You can attach up to 3 images',
screenshotFailed: 'Screenshot failed. Try pasting or uploading an image.',
submitSuccess: 'Feedback submitted. Thanks!',
submitFailed: 'Failed to submit feedback. Please try again later.',
removeImage: 'Remove image',
}, },
queries: { queries: {
title: 'คำค้นหา', title: 'คำค้นหา',
+16
View File
@@ -1364,6 +1364,22 @@ const viVN = {
inaccurateReasons: 'Lý do không chính xác', inaccurateReasons: 'Lý do không chính xác',
platform: 'Nền tảng', platform: 'Nền tảng',
exportFeedback: 'Xuất phản hồi', exportFeedback: 'Xuất phản hồi',
description:
'Tell us what went wrong or what could be better. Instance UUID and login account are included for diagnosis.',
placeholder: 'Describe your suggestion, issue, or reproduction steps...',
attachImage: 'Add image',
screenshot: 'Screenshot',
submit: 'Submit feedback',
privacyHint:
'Do not include secrets, passwords, or private chat content.',
contentRequired: 'Please enter feedback first',
imageOnly: 'Only image attachments are supported',
imageTooLarge: 'Each image must be under 1MB',
tooManyImages: 'You can attach up to 3 images',
screenshotFailed: 'Screenshot failed. Try pasting or uploading an image.',
submitSuccess: 'Feedback submitted. Thanks!',
submitFailed: 'Failed to submit feedback. Please try again later.',
removeImage: 'Remove image',
}, },
queries: { queries: {
title: 'Truy vấn', title: 'Truy vấn',
+16 -1
View File
@@ -34,7 +34,7 @@ const zhHans = {
emptyPassword: '请输入密码', emptyPassword: '请输入密码',
language: '语言', language: '语言',
helpDocs: '帮助文档', helpDocs: '帮助文档',
featureRequest: '需求建议', featureRequest: '建议反馈',
starOnGitHub: '在 GitHub 上 Star', starOnGitHub: '在 GitHub 上 Star',
joinDiscord: '加入 Discord 社区', joinDiscord: '加入 Discord 社区',
create: '创建', create: '创建',
@@ -1301,6 +1301,21 @@ const zhHans = {
inaccurateReasons: '不准确原因', inaccurateReasons: '不准确原因',
platform: '平台', platform: '平台',
exportFeedback: '导出反馈', exportFeedback: '导出反馈',
description:
'告诉我们遇到的问题或想要的改进。提交时会附带实例 UUID 和登录账号,方便定位。',
placeholder: '请描述你的建议、问题或复现步骤...',
attachImage: '添加图片',
screenshot: '截图',
submit: '提交反馈',
privacyHint: '请勿提交敏感密钥、密码或私人聊天内容。',
contentRequired: '请先填写反馈内容',
imageOnly: '仅支持图片附件',
imageTooLarge: '单张图片不能超过 1MB',
tooManyImages: '最多添加 3 张图片',
screenshotFailed: '截图失败,请尝试粘贴或上传图片',
submitSuccess: '反馈已提交,感谢!',
submitFailed: '反馈提交失败,请稍后重试',
removeImage: '移除图片',
}, },
queries: { queries: {
title: '查询记录', title: '查询记录',
+15
View File
@@ -1300,6 +1300,21 @@ const zhHant = {
inaccurateReasons: '不準確原因', inaccurateReasons: '不準確原因',
platform: '平台', platform: '平台',
exportFeedback: '匯出反饋', exportFeedback: '匯出反饋',
description:
'告訴我們遇到的問題或想要的改進。提交時會附帶實例 UUID 和登入帳號,方便定位。',
placeholder: '請描述你的建議、問題或重現步驟...',
attachImage: '新增圖片',
screenshot: '截圖',
submit: '提交反饋',
privacyHint: '請勿提交敏感金鑰、密碼或私人聊天內容。',
contentRequired: '請先填寫反饋內容',
imageOnly: '僅支援圖片附件',
imageTooLarge: '單張圖片不能超過 1MB',
tooManyImages: '最多新增 3 張圖片',
screenshotFailed: '截圖失敗,請嘗試貼上或上傳圖片',
submitSuccess: '反饋已提交,感謝!',
submitFailed: '反饋提交失敗,請稍後再試',
removeImage: '移除圖片',
}, },
messageDetails: { messageDetails: {
noData: '此查詢沒有LLM調用或錯誤記錄', noData: '此查詢沒有LLM調用或錯誤記錄',