Compare commits
25 Commits
6e9e74d1ee
...
channel-ru
| Author | SHA1 | Date | |
|---|---|---|---|
| c3d84b904a | |||
| ee972441f5 | |||
| d335199a64 | |||
| feeaccc0e3 | |||
| cf35edb4ca | |||
| e0a4862af8 | |||
| c3a0aef104 | |||
| b25713a141 | |||
| d74a1c9c12 | |||
| 834d4e1e2f | |||
| 6a6ddc21c0 | |||
| 826db8ec2e | |||
| 33a9845566 | |||
| 55b39563a0 | |||
| 41ac87e322 | |||
| 542b23ef6e | |||
| 9002d1206f | |||
| dd9f40b38c | |||
| 96562877cc | |||
| f58a57e5b8 | |||
| 362aae9b12 | |||
| 29d175222d | |||
| 2e4f8541ee | |||
| a1164dc49a | |||
| 7b638b083a |
16
.env.example
16
.env.example
@ -28,3 +28,19 @@ BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||
|
||||
# Must be reachable from auth-portal and authz-service containers.
|
||||
BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090
|
||||
|
||||
# External connector sidecar
|
||||
EXTERNAL_CONNECTOR_TOKEN=
|
||||
BEAVER_BRIDGE_TOKEN=
|
||||
BEAVER_BRIDGE_BASE_URL=http://app-instance:8080
|
||||
EXTERNAL_CONNECTOR_PORT=8787
|
||||
CONNECTOR_PUBLIC_BASE_URL=http://localhost:8787
|
||||
# fake | vendor_cli | weixin_ilink
|
||||
CONNECTOR_PROVIDER=vendor_cli
|
||||
CONNECTOR_COMMAND_TIMEOUT_SECONDS=120
|
||||
WEIXIN_CONNECT_COMMAND=
|
||||
WEIXIN_STATUS_COMMAND=
|
||||
WEIXIN_SEND_COMMAND=
|
||||
FEISHU_CONNECT_COMMAND=
|
||||
FEISHU_STATUS_COMMAND=
|
||||
FEISHU_SEND_COMMAND=
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -21,6 +21,7 @@ sessions/
|
||||
**/.ruff_cache/
|
||||
**/.mypy_cache/
|
||||
**/.cache/
|
||||
**/.codegraph/
|
||||
**/.venv/
|
||||
**/dist/
|
||||
**/build/
|
||||
|
||||
458
2026-06-01-hermes-gateway-llm-design.md
Normal file
458
2026-06-01-hermes-gateway-llm-design.md
Normal file
@ -0,0 +1,458 @@
|
||||
# Beaver Terminal WebSocket Integration Guide
|
||||
|
||||
Date: 2026-06-01
|
||||
|
||||
Audience: the small-terminal-side Codex agent that will modify terminal firmware or terminal app code.
|
||||
|
||||
## Goal
|
||||
|
||||
Connect the small terminal device to Beaver through a text-only WebSocket channel.
|
||||
|
||||
The first acceptance target is simple:
|
||||
|
||||
1. The terminal opens a WebSocket connection to Beaver.
|
||||
2. The terminal sends a `connect` frame with a stable `peer_id`.
|
||||
3. The terminal sends one text `message` frame.
|
||||
4. The terminal receives an `ack`.
|
||||
5. The terminal receives the final assistant text response from Beaver.
|
||||
6. The terminal can reconnect with the same `peer_id` and keep the same Beaver session.
|
||||
|
||||
This document replaces the earlier Hermes LiveKit LLM adapter design for the terminal-side work. Do not implement a LiveKit LLM adapter from this document.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not implement audio streaming.
|
||||
- Do not implement camera, screen, image, or multimodal frames.
|
||||
- Do not implement token streaming.
|
||||
- Do not implement terminal-side tools.
|
||||
- Do not implement AuthZ, device registration, OAuth, or pairing in the first pass.
|
||||
- Do not call Beaver REST chat endpoints or the existing Web UI `/ws/{session_id}` endpoint.
|
||||
- Do not build an OpenAI-compatible proxy.
|
||||
- Do not implement Hermes Agent or LiveKit changes on the terminal side.
|
||||
|
||||
## Beaver Endpoint
|
||||
|
||||
The terminal connects to:
|
||||
|
||||
```text
|
||||
ws://<beaver-host>/api/channels/<channel_id>/ws
|
||||
```
|
||||
|
||||
For local development through the Beaver app instance nginx port:
|
||||
|
||||
```text
|
||||
ws://127.0.0.1:8080/api/channels/terminal-dev/ws
|
||||
```
|
||||
|
||||
For direct backend development without nginx:
|
||||
|
||||
```text
|
||||
ws://127.0.0.1:18080/api/channels/terminal-dev/ws
|
||||
```
|
||||
|
||||
Use `wss://` when Beaver is deployed behind TLS.
|
||||
|
||||
The expected first channel id is:
|
||||
|
||||
```text
|
||||
terminal-dev
|
||||
```
|
||||
|
||||
The terminal implementation should make the URL configurable, for example:
|
||||
|
||||
```text
|
||||
BEAVER_WS_URL=ws://127.0.0.1:8080/api/channels/terminal-dev/ws
|
||||
TERMINAL_PEER_ID=device-001
|
||||
TERMINAL_DEVICE_NAME=desk-terminal
|
||||
```
|
||||
|
||||
## Protocol Overview
|
||||
|
||||
The transport is JSON over WebSocket.
|
||||
|
||||
All frames are UTF-8 JSON objects. The terminal should ignore unknown fields. Beaver will ignore unknown fields unless the frame type is invalid.
|
||||
|
||||
The protocol is request/reply oriented in this phase. Beaver sends only final assistant messages, not token deltas.
|
||||
|
||||
Required frame flow:
|
||||
|
||||
```text
|
||||
terminal -> Beaver: connect
|
||||
Beaver -> terminal: connected
|
||||
terminal -> Beaver: message
|
||||
Beaver -> terminal: ack
|
||||
Beaver -> terminal: message
|
||||
```
|
||||
|
||||
Optional heartbeat:
|
||||
|
||||
```text
|
||||
terminal -> Beaver: ping
|
||||
Beaver -> terminal: pong
|
||||
```
|
||||
|
||||
## Connect Frame
|
||||
|
||||
The terminal must send `connect` immediately after the WebSocket opens.
|
||||
|
||||
Terminal to Beaver:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "connect",
|
||||
"peer_id": "device-001",
|
||||
"device_name": "desk-terminal",
|
||||
"capabilities": ["text"]
|
||||
}
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
||||
- `type`: must be `"connect"`.
|
||||
- `peer_id`: stable terminal identity. Reuse this value across reconnects.
|
||||
|
||||
Recommended fields:
|
||||
|
||||
- `device_name`: human-readable terminal name.
|
||||
- `capabilities`: include `"text"`.
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `thread_id`: optional sub-session key. Omit it for the first pass.
|
||||
- `user_id`: optional user identity. Omit it unless the terminal already has a stable user id.
|
||||
|
||||
Beaver to terminal:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "connected",
|
||||
"channel_id": "terminal-dev",
|
||||
"session_id": "terminal-dev:local:device-001"
|
||||
}
|
||||
```
|
||||
|
||||
The terminal should store `session_id` for logging and diagnostics. It does not need to send `session_id` back in message frames.
|
||||
|
||||
## Message Frame
|
||||
|
||||
Terminal to Beaver:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"message_id": "m-001",
|
||||
"text": "hello"
|
||||
}
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
||||
- `type`: must be `"message"`.
|
||||
- `message_id`: unique id for this user message.
|
||||
- `text`: non-empty user text.
|
||||
|
||||
Recommended `message_id` format:
|
||||
|
||||
```text
|
||||
<peer_id>-<monotonic-counter>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
device-001-000001
|
||||
device-001-000002
|
||||
```
|
||||
|
||||
The terminal should persist the counter if practical. If persistence is unavailable, generate a UUID or timestamp-based id. Reusing the same `message_id` tells Beaver to treat the frame as a duplicate.
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `thread_id`: use only when the terminal intentionally wants a separate Beaver session.
|
||||
- `user_id`: use only when the terminal has a stable user id.
|
||||
|
||||
## Ack Frame
|
||||
|
||||
Beaver sends an ack after accepting or deduplicating the inbound message.
|
||||
|
||||
Accepted:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ack",
|
||||
"message_id": "device-001-000001",
|
||||
"session_id": "terminal-dev:local:device-001",
|
||||
"accepted": true
|
||||
}
|
||||
```
|
||||
|
||||
Duplicate still processing:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ack",
|
||||
"message_id": "device-001-000001",
|
||||
"session_id": "terminal-dev:local:device-001",
|
||||
"accepted": false,
|
||||
"duplicate": true,
|
||||
"pending": true
|
||||
}
|
||||
```
|
||||
|
||||
Duplicate already completed:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ack",
|
||||
"message_id": "device-001-000001",
|
||||
"session_id": "terminal-dev:local:device-001",
|
||||
"accepted": false,
|
||||
"duplicate": true,
|
||||
"pending": false,
|
||||
"reply": "cached assistant reply"
|
||||
}
|
||||
```
|
||||
|
||||
Terminal behavior:
|
||||
|
||||
- If `accepted` is true, wait for the assistant `message`.
|
||||
- If `duplicate` and `reply` is present, display the cached reply.
|
||||
- If `duplicate` and `pending` is true, keep waiting on the socket.
|
||||
- If `error` is present, display or log the error.
|
||||
|
||||
## Assistant Message Frame
|
||||
|
||||
Beaver to terminal:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"message_id": "device-001-000001",
|
||||
"run_id": "run-id",
|
||||
"text": "assistant reply",
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `type`: `"message"`.
|
||||
- `role`: `"assistant"`.
|
||||
- `message_id`: the user message id this response belongs to.
|
||||
- `run_id`: Beaver run id for diagnostics.
|
||||
- `text`: final assistant response.
|
||||
- `finish_reason`: usually `"stop"`, or `"error"` when the run failed.
|
||||
|
||||
Terminal behavior:
|
||||
|
||||
- Render or speak `text`.
|
||||
- Treat `finish_reason == "error"` as a failed turn.
|
||||
- Do not expect token-level streaming in this phase.
|
||||
|
||||
## Ping And Pong
|
||||
|
||||
Terminal to Beaver:
|
||||
|
||||
```json
|
||||
{"type": "ping"}
|
||||
```
|
||||
|
||||
Beaver to terminal:
|
||||
|
||||
```json
|
||||
{"type": "pong"}
|
||||
```
|
||||
|
||||
Recommended heartbeat interval:
|
||||
|
||||
```text
|
||||
30 seconds
|
||||
```
|
||||
|
||||
If no pong or other frame is received after a reasonable timeout, reconnect.
|
||||
|
||||
## Error Frame
|
||||
|
||||
Beaver may send:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "error",
|
||||
"error": "human readable error"
|
||||
}
|
||||
```
|
||||
|
||||
Terminal behavior:
|
||||
|
||||
- Log the error.
|
||||
- Keep the connection open unless the WebSocket closes.
|
||||
- If the error is for a user message, allow the user to retry with a new `message_id`.
|
||||
|
||||
Common first-pass errors:
|
||||
|
||||
- `connect` is required before `message`.
|
||||
- `peer_id` is required.
|
||||
- `message_id` is required.
|
||||
- `text` is required.
|
||||
- Unsupported websocket frame type.
|
||||
|
||||
## Terminal State Machine
|
||||
|
||||
Implement the terminal client as a small state machine.
|
||||
|
||||
```text
|
||||
DISCONNECTED
|
||||
-> connect websocket
|
||||
CONNECTING
|
||||
-> websocket open, send connect frame
|
||||
WAIT_CONNECTED
|
||||
-> receive connected
|
||||
READY
|
||||
-> send message frame
|
||||
WAIT_ACK
|
||||
-> receive ack
|
||||
WAIT_REPLY
|
||||
-> receive assistant message
|
||||
READY
|
||||
```
|
||||
|
||||
On WebSocket close or network failure, transition to `DISCONNECTED` and reconnect with backoff.
|
||||
|
||||
Recommended reconnect policy:
|
||||
|
||||
- Start at 1 second.
|
||||
- Double up to 30 seconds.
|
||||
- Reset backoff after a successful `connected` frame.
|
||||
|
||||
On reconnect, use the same `peer_id`.
|
||||
|
||||
## Terminal Implementation Requirements
|
||||
|
||||
The terminal-side code should provide:
|
||||
|
||||
- A configurable Beaver WebSocket URL.
|
||||
- A stable `peer_id`.
|
||||
- A configurable `device_name`.
|
||||
- A monotonic or otherwise unique `message_id` generator.
|
||||
- JSON encoding and decoding.
|
||||
- Connect frame on socket open.
|
||||
- Ping/pong heartbeat.
|
||||
- Reconnect with backoff.
|
||||
- A queue or guard so only one user text turn is in flight at a time for the first pass.
|
||||
- Logging for `session_id`, `message_id`, `run_id`, and errors.
|
||||
|
||||
The terminal-side code does not need:
|
||||
|
||||
- Multi-room session logic.
|
||||
- Hermes session management.
|
||||
- LiveKit `AgentSession`.
|
||||
- Audio chunking.
|
||||
- Tool calls.
|
||||
- OAuth or token refresh.
|
||||
|
||||
## Example Client Pseudocode
|
||||
|
||||
```python
|
||||
peer_id = load_or_create_peer_id()
|
||||
counter = load_counter()
|
||||
|
||||
async def run_terminal_client():
|
||||
while True:
|
||||
try:
|
||||
async with connect(BEAVER_WS_URL) as ws:
|
||||
await ws.send_json({
|
||||
"type": "connect",
|
||||
"peer_id": peer_id,
|
||||
"device_name": DEVICE_NAME,
|
||||
"capabilities": ["text"],
|
||||
})
|
||||
|
||||
connected = await ws.receive_json()
|
||||
assert connected["type"] == "connected"
|
||||
log("session_id", connected["session_id"])
|
||||
|
||||
await read_send_receive_loop(ws)
|
||||
except Exception as exc:
|
||||
log("websocket disconnected", exc)
|
||||
await sleep(next_backoff())
|
||||
|
||||
async def send_user_text(ws, text):
|
||||
global counter
|
||||
counter += 1
|
||||
save_counter(counter)
|
||||
message_id = f"{peer_id}-{counter:06d}"
|
||||
|
||||
await ws.send_json({
|
||||
"type": "message",
|
||||
"message_id": message_id,
|
||||
"text": text,
|
||||
})
|
||||
|
||||
while True:
|
||||
frame = await ws.receive_json()
|
||||
if frame["type"] == "ack" and frame.get("message_id") == message_id:
|
||||
if frame.get("reply"):
|
||||
return frame["reply"]
|
||||
continue
|
||||
if frame["type"] == "message" and frame.get("role") == "assistant":
|
||||
if frame.get("message_id") == message_id:
|
||||
return frame.get("text", "")
|
||||
if frame["type"] == "error":
|
||||
raise RuntimeError(frame.get("error", "unknown error"))
|
||||
```
|
||||
|
||||
Adapt the pseudocode to the terminal runtime language and WebSocket library.
|
||||
|
||||
## Manual Test With websocat
|
||||
|
||||
If `websocat` is available, a developer can manually test the protocol:
|
||||
|
||||
```bash
|
||||
websocat ws://127.0.0.1:8080/api/channels/terminal-dev/ws
|
||||
```
|
||||
|
||||
Then paste:
|
||||
|
||||
```json
|
||||
{"type":"connect","peer_id":"device-001","device_name":"desk-terminal","capabilities":["text"]}
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{"type":"connected","channel_id":"terminal-dev","session_id":"terminal-dev:local:device-001"}
|
||||
```
|
||||
|
||||
Then paste:
|
||||
|
||||
```json
|
||||
{"type":"message","message_id":"device-001-000001","text":"hello"}
|
||||
```
|
||||
|
||||
Expected responses:
|
||||
|
||||
```json
|
||||
{"type":"ack","message_id":"device-001-000001","session_id":"terminal-dev:local:device-001","accepted":true}
|
||||
```
|
||||
|
||||
Then, after Beaver finishes the run:
|
||||
|
||||
```json
|
||||
{"type":"message","role":"assistant","message_id":"device-001-000001","run_id":"...","text":"...","finish_reason":"stop"}
|
||||
```
|
||||
|
||||
## Acceptance Checklist For Terminal-Side Codex
|
||||
|
||||
- The terminal opens the configured Beaver WebSocket URL.
|
||||
- The terminal sends `connect` immediately after open.
|
||||
- The terminal receives and logs `connected.session_id`.
|
||||
- The terminal sends text using a unique `message_id`.
|
||||
- The terminal receives `ack`.
|
||||
- The terminal receives and displays assistant `message.text`.
|
||||
- The terminal handles `ping`/`pong`.
|
||||
- The terminal reconnects with the same `peer_id`.
|
||||
- The terminal does not use REST chat or `/ws/{session_id}`.
|
||||
- The terminal implementation remains text-only for the first pass.
|
||||
|
||||
When this checklist passes against Beaver, the first-stage device integration is accepted from the terminal side.
|
||||
@ -47,8 +47,12 @@ ARG NPM_REGISTRY="https://registry.npmmirror.com"
|
||||
ARG NPM_FETCH_RETRIES="5"
|
||||
ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000"
|
||||
ARG NPM_FETCH_RETRY_MAX_TIMEOUT="120000"
|
||||
ARG APT_MIRROR="https://mirrors.tuna.tsinghua.edu.cn/debian"
|
||||
ARG PYPI_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
|
||||
RUN apt-get update && \
|
||||
RUN find /etc/apt -type f \( -name "*.list" -o -name "*.sources" \) -exec \
|
||||
sed -i "s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g; s|http://deb.debian.org/debian|${APT_MIRROR}|g; s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" {} + && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends curl ca-certificates gnupg git nginx dumb-init && \
|
||||
mkdir -p /etc/apt/keyrings && \
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
|
||||
@ -63,7 +67,7 @@ WORKDIR /opt/app/backend
|
||||
|
||||
COPY backend/pyproject.toml backend/README.md ./
|
||||
COPY backend/beaver/ ./beaver/
|
||||
RUN uv pip install --system --no-cache .
|
||||
RUN uv pip install --system --no-cache --index-url "${PYPI_INDEX_URL}" ".[channels]"
|
||||
|
||||
WORKDIR /opt/app/frontend
|
||||
COPY --from=frontend-builder /build/frontend/next.config.js ./
|
||||
|
||||
145
app-instance/backend/agents/registry.json
Normal file
145
app-instance/backend/agents/registry.json
Normal file
@ -0,0 +1,145 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": "researcher",
|
||||
"capabilities": [
|
||||
"research",
|
||||
"analysis",
|
||||
"source review",
|
||||
"requirements"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756341+00:00",
|
||||
"description": "Finds facts, references, constraints, and implementation options.",
|
||||
"display_name": "Researcher",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "researcher",
|
||||
"priority": 50,
|
||||
"provider_name": null,
|
||||
"role": "research",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
|
||||
"tags": [
|
||||
"planning",
|
||||
"research"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756349+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "implementer",
|
||||
"capabilities": [
|
||||
"implementation",
|
||||
"coding",
|
||||
"refactor",
|
||||
"integration"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756351+00:00",
|
||||
"description": "Builds scoped implementation slices and proposes concrete changes.",
|
||||
"display_name": "Implementer",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "implementer",
|
||||
"priority": 45,
|
||||
"provider_name": null,
|
||||
"role": "implementation",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.",
|
||||
"tags": [
|
||||
"coding",
|
||||
"build"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756353+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "reviewer",
|
||||
"capabilities": [
|
||||
"review",
|
||||
"quality",
|
||||
"risk",
|
||||
"verification"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756355+00:00",
|
||||
"description": "Reviews plans, code, outputs, and risks before final synthesis.",
|
||||
"display_name": "Reviewer",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "reviewer",
|
||||
"priority": 45,
|
||||
"provider_name": null,
|
||||
"role": "review",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.",
|
||||
"tags": [
|
||||
"review",
|
||||
"quality"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756356+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "tester",
|
||||
"capabilities": [
|
||||
"testing",
|
||||
"verification",
|
||||
"regression",
|
||||
"qa"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756358+00:00",
|
||||
"description": "Designs and executes verification checks for task outputs.",
|
||||
"display_name": "Tester",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "tester",
|
||||
"priority": 40,
|
||||
"provider_name": null,
|
||||
"role": "testing",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.",
|
||||
"tags": [
|
||||
"test",
|
||||
"quality"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756358+00:00"
|
||||
},
|
||||
{
|
||||
"agent_id": "documenter",
|
||||
"capabilities": [
|
||||
"documentation",
|
||||
"explanation",
|
||||
"migration notes",
|
||||
"release notes"
|
||||
],
|
||||
"created_at": "2026-05-27T05:25:11.756360+00:00",
|
||||
"description": "Writes and reconciles user-facing and internal documentation updates.",
|
||||
"display_name": "Documenter",
|
||||
"metadata": {},
|
||||
"model": null,
|
||||
"name": "documenter",
|
||||
"priority": 35,
|
||||
"provider_name": null,
|
||||
"role": "documentation",
|
||||
"skill_names": [],
|
||||
"source": "builtin",
|
||||
"status": "active",
|
||||
"system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.",
|
||||
"tags": [
|
||||
"docs",
|
||||
"communication"
|
||||
],
|
||||
"tool_hints": [],
|
||||
"updated_at": "2026-05-27T05:25:11.756360+00:00"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
}
|
||||
@ -76,7 +76,12 @@ class SessionContext:
|
||||
model: str | None = None
|
||||
user_id: str | None = None
|
||||
channel: str | None = None
|
||||
channel_kind: str | None = None
|
||||
account_id: str | None = None
|
||||
peer_id: str | None = None
|
||||
peer_type: str | None = None
|
||||
chat_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
parent_session_id: str | None = None
|
||||
|
||||
|
||||
@ -354,8 +359,18 @@ class ContextBuilder:
|
||||
rows.append(f"User ID: {session_context.user_id}")
|
||||
if session_context.channel:
|
||||
rows.append(f"Channel: {session_context.channel}")
|
||||
if session_context.channel_kind:
|
||||
rows.append(f"Channel Kind: {session_context.channel_kind}")
|
||||
if session_context.account_id:
|
||||
rows.append(f"Account ID: {session_context.account_id}")
|
||||
if session_context.peer_id:
|
||||
rows.append(f"Peer ID: {session_context.peer_id}")
|
||||
if session_context.peer_type:
|
||||
rows.append(f"Peer Type: {session_context.peer_type}")
|
||||
if session_context.chat_id:
|
||||
rows.append(f"Chat ID: {session_context.chat_id}")
|
||||
if session_context.thread_id:
|
||||
rows.append(f"Thread ID: {session_context.thread_id}")
|
||||
if session_context.parent_session_id:
|
||||
rows.append(f"Parent Session ID: {session_context.parent_session_id}")
|
||||
|
||||
|
||||
@ -44,6 +44,7 @@ from beaver.tools.builtins import (
|
||||
SpawnTool,
|
||||
SessionSearchTool,
|
||||
SkillManageTool,
|
||||
SkillViewTool,
|
||||
SkillsListTool,
|
||||
TerminalTool,
|
||||
TodoTool,
|
||||
@ -220,16 +221,17 @@ class EngineLoader:
|
||||
ObjectBackedTool(WriteFileTool()),
|
||||
ObjectBackedTool(PatchFileTool()),
|
||||
ObjectBackedTool(WebFetchTool()),
|
||||
ObjectBackedTool(WebSearchTool()),
|
||||
ObjectBackedTool(TerminalTool()),
|
||||
ObjectBackedTool(ProcessTool()),
|
||||
ObjectBackedTool(ExecuteCodeTool()),
|
||||
ObjectBackedTool(TodoTool()),
|
||||
ObjectBackedTool(ClarifyTool()),
|
||||
ObjectBackedTool(SendMessageTool()),
|
||||
ObjectBackedTool(DelegateTool()),
|
||||
ObjectBackedTool(SpawnTool()),
|
||||
SkillsListTool(),
|
||||
ObjectBackedTool(WebSearchTool()),
|
||||
ObjectBackedTool(TerminalTool()),
|
||||
ObjectBackedTool(ProcessTool()),
|
||||
ObjectBackedTool(ExecuteCodeTool()),
|
||||
ObjectBackedTool(TodoTool()),
|
||||
ObjectBackedTool(ClarifyTool()),
|
||||
ObjectBackedTool(SendMessageTool()),
|
||||
ObjectBackedTool(DelegateTool()),
|
||||
ObjectBackedTool(SpawnTool()),
|
||||
SkillsListTool(),
|
||||
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
|
||||
SkillManageTool(),
|
||||
CronTool(),
|
||||
]
|
||||
|
||||
@ -13,6 +13,7 @@ from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from beaver.engine.context import ContextBuildInput, RuntimeContext, SessionContext, SkillContext
|
||||
from beaver.foundation.events import ChannelIdentity
|
||||
from beaver.memory.runs import RunRecord, SkillEffectRecord
|
||||
from beaver.skills.learning import RunReceiptContext
|
||||
from beaver.skills.catalog.utils import strip_frontmatter
|
||||
@ -48,7 +49,7 @@ class AgentProfile:
|
||||
name: str = "default"
|
||||
system_prompt: str = ""
|
||||
default_model: str = "gpt-4.1-mini"
|
||||
max_tokens: int = 4096
|
||||
max_tokens: int | None = None
|
||||
max_context_messages: int = 1000
|
||||
temperature: float = 0.2
|
||||
max_tool_iterations: int = 30
|
||||
@ -89,6 +90,7 @@ class AgentLoop:
|
||||
self.loaded: EngineLoadResult | None = None
|
||||
self.runtime_services: dict[str, Any] = {}
|
||||
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
|
||||
self._active_direct_task: asyncio.Task[Any] | None = None
|
||||
self._running = False
|
||||
self._stop_requested = False
|
||||
|
||||
@ -130,6 +132,8 @@ class AgentLoop:
|
||||
if item.future.cancelled():
|
||||
continue
|
||||
|
||||
previous_direct_task = self._active_direct_task
|
||||
self._active_direct_task = asyncio.current_task()
|
||||
try:
|
||||
result = await self._process_direct_impl(item.task, **item.kwargs)
|
||||
except asyncio.CancelledError:
|
||||
@ -142,6 +146,8 @@ class AgentLoop:
|
||||
else:
|
||||
if not item.future.done():
|
||||
item.future.set_result(result)
|
||||
finally:
|
||||
self._active_direct_task = previous_direct_task
|
||||
finally:
|
||||
if self._run_queue is not None:
|
||||
while True:
|
||||
@ -183,6 +189,9 @@ class AgentLoop:
|
||||
if self._stop_requested:
|
||||
raise RuntimeError("AgentLoop.submit_direct() is not accepting new tasks after stop()")
|
||||
|
||||
if asyncio.current_task() is self._active_direct_task:
|
||||
return await self._process_direct_impl(task, **kwargs)
|
||||
|
||||
future: asyncio.Future[AgentRunResult] = asyncio.get_running_loop().create_future()
|
||||
await self._run_queue.put(_DirectRunRequest(task=task, kwargs=dict(kwargs), future=future))
|
||||
return await future
|
||||
@ -240,6 +249,7 @@ class AgentLoop:
|
||||
pinned_skill_contexts: list[SkillContext] | None = None,
|
||||
allow_candidate_generation: bool = False,
|
||||
intent_agent_decision: dict[str, Any] | None = None,
|
||||
channel_identity: ChannelIdentity | None = None,
|
||||
) -> AgentRunResult:
|
||||
"""跑通最小 direct run 主链。
|
||||
|
||||
@ -289,6 +299,7 @@ class AgentLoop:
|
||||
pinned_skill_contexts=pinned_skill_contexts,
|
||||
allow_candidate_generation=allow_candidate_generation,
|
||||
intent_agent_decision=intent_agent_decision,
|
||||
channel_identity=channel_identity,
|
||||
)
|
||||
|
||||
async def _process_direct_impl(
|
||||
@ -326,6 +337,7 @@ class AgentLoop:
|
||||
pinned_skill_contexts: list[SkillContext] | None = None,
|
||||
allow_candidate_generation: bool = False,
|
||||
intent_agent_decision: dict[str, Any] | None = None,
|
||||
channel_identity: ChannelIdentity | None = None,
|
||||
) -> AgentRunResult:
|
||||
"""真正执行一轮 direct run 的内部实现。
|
||||
|
||||
@ -363,7 +375,7 @@ class AgentLoop:
|
||||
resolved_request_timeout_seconds = configured_provider.get("request_timeout_seconds")
|
||||
resolved_embedding_model = embedding_model or config.default_embedding_model
|
||||
resolved_embedding_target = embedding_target or config.resolve_embedding_target()
|
||||
resolved_max_tokens = max_tokens or self.profile.max_tokens
|
||||
resolved_max_tokens = self.profile.max_tokens if max_tokens is None else max_tokens
|
||||
resolved_temperature = self.profile.temperature if temperature is None else temperature
|
||||
resolved_max_tool_iterations = (
|
||||
self.profile.max_tool_iterations if max_tool_iterations is None else max_tool_iterations
|
||||
@ -568,6 +580,13 @@ class AgentLoop:
|
||||
source=source,
|
||||
model=resolved_model,
|
||||
user_id=user_id,
|
||||
channel=channel_identity.channel_id if channel_identity else None,
|
||||
channel_kind=channel_identity.kind if channel_identity else None,
|
||||
account_id=channel_identity.account_id if channel_identity else None,
|
||||
peer_id=channel_identity.peer_id if channel_identity else None,
|
||||
peer_type=channel_identity.peer_type if channel_identity else None,
|
||||
chat_id=channel_identity.peer_id if channel_identity else None,
|
||||
thread_id=channel_identity.thread_id if channel_identity else None,
|
||||
parent_session_id=parent_session_id,
|
||||
),
|
||||
runtime_context=self._current_runtime_context(),
|
||||
@ -892,7 +911,7 @@ class AgentLoop:
|
||||
provider: Any,
|
||||
messages: list[dict[str, Any]],
|
||||
model: str,
|
||||
max_tokens: int,
|
||||
max_tokens: int | None,
|
||||
temperature: float,
|
||||
thinking_enabled: bool | None,
|
||||
) -> str:
|
||||
|
||||
@ -43,7 +43,7 @@ class AnthropicProvider(LLMProvider):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
@ -57,9 +57,14 @@ class AnthropicProvider(LLMProvider):
|
||||
"model": model or self.default_model,
|
||||
"system": system_prompt or "",
|
||||
"messages": anthropic_messages,
|
||||
"max_tokens": max(1, max_tokens),
|
||||
"temperature": temperature,
|
||||
}
|
||||
resolved_max_tokens = (
|
||||
_default_max_tokens_for_model(model or self.default_model)
|
||||
if max_tokens is None
|
||||
else max(1, max_tokens)
|
||||
)
|
||||
kwargs["max_tokens"] = resolved_max_tokens
|
||||
if tools:
|
||||
kwargs["tools"] = _convert_tools(tools)
|
||||
|
||||
@ -100,6 +105,17 @@ class AnthropicProvider(LLMProvider):
|
||||
return self.default_model
|
||||
|
||||
|
||||
def _default_max_tokens_for_model(model: str) -> int:
|
||||
"""Return a conservative native output ceiling for Anthropic Messages."""
|
||||
|
||||
normalized = model.lower().replace("_", "-")
|
||||
if "sonnet-4" in normalized or "opus-4" in normalized or "3-7" in normalized or "3.7" in normalized:
|
||||
return 64_000
|
||||
if "haiku" in normalized:
|
||||
return 4_096
|
||||
return 8_192
|
||||
|
||||
|
||||
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||
system_prompt = ""
|
||||
converted: list[dict[str, Any]] = []
|
||||
|
||||
@ -88,7 +88,7 @@ class LLMProvider(ABC):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
|
||||
@ -56,7 +56,7 @@ class FallbackProviderChain(LLMProvider):
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
@ -115,7 +115,7 @@ class FallbackProviderChain(LLMProvider):
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None,
|
||||
model: str,
|
||||
max_tokens: int,
|
||||
max_tokens: int | None,
|
||||
temperature: float,
|
||||
thinking_enabled: bool | None,
|
||||
) -> LLMResponse:
|
||||
|
||||
@ -39,7 +39,7 @@ class OpenAICodexProvider(LLMProvider):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
|
||||
@ -47,7 +47,7 @@ class CustomProvider(LLMProvider):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
@ -55,9 +55,10 @@ class CustomProvider(LLMProvider):
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model or self.default_model,
|
||||
"messages": self.sanitize_empty_content(messages),
|
||||
"max_tokens": max(1, max_tokens),
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens is not None:
|
||||
kwargs["max_tokens"] = max(1, max_tokens)
|
||||
if tools:
|
||||
kwargs.update(tools=tools, tool_choice="auto")
|
||||
try:
|
||||
|
||||
@ -197,7 +197,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float = 0.7,
|
||||
thinking_enabled: bool | None = None,
|
||||
) -> LLMResponse:
|
||||
@ -210,10 +210,11 @@ class LiteLLMProvider(LLMProvider):
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": resolved_model,
|
||||
"messages": sanitized_messages,
|
||||
"max_tokens": max(1, max_tokens),
|
||||
"temperature": temperature,
|
||||
"timeout": self.request_timeout_seconds or 45.0,
|
||||
}
|
||||
if max_tokens is not None:
|
||||
kwargs["max_tokens"] = max(1, max_tokens)
|
||||
if self.api_key:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if self.api_base:
|
||||
|
||||
@ -13,6 +13,7 @@ from .schema import (
|
||||
AuthzConfig,
|
||||
BackendIdentityConfig,
|
||||
BeaverConfig,
|
||||
ChannelConfig,
|
||||
EmbeddingConfig,
|
||||
MCPServerConfig,
|
||||
ProviderConfig,
|
||||
@ -73,6 +74,7 @@ def load_config(
|
||||
embedding=_parse_embedding(data),
|
||||
tools=_parse_tools(data.get("tools")),
|
||||
authz=_parse_authz(data.get("authz")),
|
||||
channels=_parse_channels(data.get("channels")),
|
||||
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
|
||||
config_path=path,
|
||||
)
|
||||
@ -86,18 +88,25 @@ def _parse_agent_defaults(data: dict[str, Any]) -> AgentDefaultsConfig:
|
||||
model=_string(defaults.get("model") or data.get("model")),
|
||||
provider=_string(defaults.get("provider") or data.get("provider")),
|
||||
embedding_model=_string(defaults.get("embeddingModel") or defaults.get("embedding_model") or data.get("embeddingModel")),
|
||||
max_tokens=_int(_first_config_value(
|
||||
defaults.get("maxTokens"),
|
||||
defaults.get("max_tokens"),
|
||||
data.get("maxTokens"),
|
||||
data.get("max_tokens"),
|
||||
)),
|
||||
temperature=_float(_first_config_value(defaults.get("temperature"), data.get("temperature"))),
|
||||
max_context_messages=_int(
|
||||
defaults.get("maxContextMessages")
|
||||
or defaults.get("max_context_messages")
|
||||
or data.get("maxContextMessages")
|
||||
or data.get("max_context_messages")
|
||||
),
|
||||
max_tool_iterations=_int(
|
||||
defaults.get("maxToolIterations")
|
||||
or defaults.get("max_tool_iterations")
|
||||
or data.get("maxToolIterations")
|
||||
or data.get("max_tool_iterations")
|
||||
),
|
||||
max_tool_iterations=_int(_first_config_value(
|
||||
defaults.get("maxToolIterations"),
|
||||
defaults.get("max_tool_iterations"),
|
||||
data.get("maxToolIterations"),
|
||||
data.get("max_tool_iterations"),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
@ -189,6 +198,48 @@ def _parse_authz(raw: Any) -> AuthzConfig:
|
||||
)
|
||||
|
||||
|
||||
def _parse_channels(raw: Any) -> dict[str, ChannelConfig]:
|
||||
channels: dict[str, ChannelConfig] = {}
|
||||
for channel_id, payload in _as_dict(raw).items():
|
||||
cleaned_id = str(channel_id).strip()
|
||||
if not cleaned_id:
|
||||
continue
|
||||
channels[cleaned_id] = _parse_channel_config(payload)
|
||||
return channels
|
||||
|
||||
|
||||
def _parse_channel_config(payload: Any) -> ChannelConfig:
|
||||
data = _as_dict(payload)
|
||||
return ChannelConfig(
|
||||
enabled=_bool(data.get("enabled"), default=False),
|
||||
kind=_string(data.get("kind")) or "",
|
||||
mode=_string(data.get("mode")) or "webhook",
|
||||
account_id=_string(data.get("accountId") or data.get("account_id")) or "",
|
||||
display_name=_string(data.get("displayName") or data.get("display_name")) or "",
|
||||
config=_normalize_config_map(data.get("config")),
|
||||
secrets=_string_dict(data.get("secrets")),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_config_map(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
return {
|
||||
_camel_to_snake_key(str(key)): item
|
||||
for key, item in value.items()
|
||||
if str(key).strip()
|
||||
}
|
||||
|
||||
|
||||
def _camel_to_snake_key(value: str) -> str:
|
||||
result: list[str] = []
|
||||
for char in value:
|
||||
if char.isupper() and result:
|
||||
result.append("_")
|
||||
result.append(char.lower())
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
|
||||
data = _as_dict(raw)
|
||||
return BackendIdentityConfig(
|
||||
@ -204,6 +255,13 @@ def _as_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _first_config_value(*values: Any) -> Any:
|
||||
for value in values:
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _string(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
@ -25,6 +25,8 @@ class AgentDefaultsConfig:
|
||||
model: str | None = None
|
||||
provider: str | None = None
|
||||
embedding_model: str | None = None
|
||||
max_tokens: int | None = None
|
||||
temperature: float | None = None
|
||||
max_context_messages: int | None = None
|
||||
max_tool_iterations: int | None = None
|
||||
|
||||
@ -89,6 +91,19 @@ class AuthzConfig:
|
||||
outlook_mcp_url: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChannelConfig:
|
||||
"""One configured channel adapter instance."""
|
||||
|
||||
enabled: bool = False
|
||||
kind: str = ""
|
||||
mode: str = "webhook"
|
||||
account_id: str = ""
|
||||
display_name: str = ""
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
secrets: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BackendIdentityConfig:
|
||||
"""This backend's AuthZ client identity."""
|
||||
@ -109,6 +124,7 @@ class BeaverConfig:
|
||||
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
||||
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
||||
authz: AuthzConfig = field(default_factory=AuthzConfig)
|
||||
channels: dict[str, ChannelConfig] = field(default_factory=dict)
|
||||
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
|
||||
config_path: Path | None = None
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""Event contracts and dispatch helpers."""
|
||||
|
||||
from .message_bus import InboundMessage, MessageBus, OutboundMessage
|
||||
from .message_bus import ChannelIdentity, InboundMessage, MessageBus, OutboundMessage
|
||||
|
||||
__all__ = ["InboundMessage", "MessageBus", "OutboundMessage"]
|
||||
__all__ = ["ChannelIdentity", "InboundMessage", "MessageBus", "OutboundMessage"]
|
||||
|
||||
@ -9,12 +9,58 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChannelIdentity:
|
||||
"""Normalized channel routing identity.
|
||||
|
||||
`channel_id` is the Beaver adapter instance id, not the platform kind.
|
||||
"""
|
||||
|
||||
channel_id: str
|
||||
kind: str
|
||||
account_id: str
|
||||
peer_id: str
|
||||
thread_id: str | None = None
|
||||
peer_type: str = "unknown"
|
||||
user_id: str | None = None
|
||||
message_id: str | None = None
|
||||
|
||||
def validation_error(self) -> str | None:
|
||||
if not self.channel_id.strip():
|
||||
return "channel_id is required"
|
||||
if not self.account_id.strip():
|
||||
return "account_id is required"
|
||||
if not self.peer_id.strip():
|
||||
return "peer_id is required"
|
||||
return None
|
||||
|
||||
def session_id(self) -> str:
|
||||
parts = [self.channel_id, self.account_id, self.peer_id]
|
||||
if self.thread_id:
|
||||
parts.append(self.thread_id)
|
||||
return ":".join(_clean_session_part(part) for part in parts)
|
||||
|
||||
def dedupe_key(self) -> str | None:
|
||||
if not self.message_id:
|
||||
return None
|
||||
return f"{self.session_id()}:{_clean_session_part(self.message_id)}"
|
||||
|
||||
|
||||
def _clean_session_part(value: str) -> str:
|
||||
cleaned = str(value).strip()
|
||||
if not cleaned:
|
||||
return "unknown"
|
||||
return cleaned.replace(":", "_")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InboundMessage:
|
||||
"""A minimal inbound message accepted by the gateway bridge."""
|
||||
|
||||
channel: str
|
||||
content: str
|
||||
content_type: str = "text"
|
||||
channel_identity: ChannelIdentity | None = None
|
||||
session_id: str | None = None
|
||||
user_id: str | None = None
|
||||
title: str | None = None
|
||||
@ -35,6 +81,8 @@ class OutboundMessage:
|
||||
content: str
|
||||
session_id: str | None
|
||||
finish_reason: str
|
||||
content_type: str = "text"
|
||||
channel_identity: ChannelIdentity | None = None
|
||||
message_id: str = field(default_factory=lambda: str(uuid4()))
|
||||
run_id: str | None = None
|
||||
provider_name: str | None = None
|
||||
|
||||
@ -1,7 +1,17 @@
|
||||
"""Channel interfaces."""
|
||||
|
||||
from .base import ChannelAdapter
|
||||
from .base import ChannelInboundSink
|
||||
from .external_connector import ExternalConnectorChannel
|
||||
from .manager import ChannelManager
|
||||
from .memory import MemoryChannelAdapter
|
||||
from .terminal_websocket import TerminalWebSocketAdapter
|
||||
|
||||
__all__ = ["ChannelAdapter", "ChannelManager", "MemoryChannelAdapter"]
|
||||
__all__ = [
|
||||
"ChannelAdapter",
|
||||
"ChannelInboundSink",
|
||||
"ExternalConnectorChannel",
|
||||
"ChannelManager",
|
||||
"MemoryChannelAdapter",
|
||||
"TerminalWebSocketAdapter",
|
||||
]
|
||||
|
||||
@ -2,16 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
from typing import Any, Protocol
|
||||
|
||||
from beaver.foundation.events import MessageBus, OutboundMessage
|
||||
from beaver.foundation.events import InboundMessage, OutboundMessage
|
||||
|
||||
|
||||
class ChannelAdapter(Protocol):
|
||||
"""Minimal contract every gateway channel must implement."""
|
||||
"""Minimal contract every runtime channel adapter must implement."""
|
||||
|
||||
name: str
|
||||
bus: MessageBus
|
||||
channel_id: str
|
||||
kind: str
|
||||
mode: str
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Prepare the channel before messages are routed."""
|
||||
@ -22,3 +23,9 @@ class ChannelAdapter(Protocol):
|
||||
async def send(self, message: OutboundMessage) -> None:
|
||||
"""Deliver an outbound message to the concrete channel."""
|
||||
|
||||
|
||||
class ChannelInboundSink(Protocol):
|
||||
"""Runtime callback used by adapters to submit normalized inbound messages."""
|
||||
|
||||
async def accept_inbound(self, message: InboundMessage) -> Any:
|
||||
"""Accept a normalized inbound message from an adapter."""
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
"""Channel connection setup layer."""
|
||||
|
||||
from .connectors import ChannelConnector, ChannelConnectorRegistry
|
||||
from .dedupe import ConnectorMessageDedupeRecord, DedupeBeginResult, MessageDedupeStore
|
||||
from .external import ExternalConnectorBase, FeishuConnector, WeixinConnector
|
||||
from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult
|
||||
from .sidecar_client import ConnectorSidecarClient
|
||||
from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore
|
||||
from .telegram import TelegramConnector
|
||||
|
||||
__all__ = [
|
||||
"ChannelConnector",
|
||||
"ChannelConnectorRegistry",
|
||||
"ConnectorMessageDedupeRecord",
|
||||
"DedupeBeginResult",
|
||||
"MessageDedupeStore",
|
||||
"ExternalConnectorBase",
|
||||
"FeishuConnector",
|
||||
"WeixinConnector",
|
||||
"ConnectorSidecarClient",
|
||||
"ChannelConnection",
|
||||
"ChannelRuntimeSpec",
|
||||
"PairingSession",
|
||||
"ValidationResult",
|
||||
"ChannelConnectionStore",
|
||||
"CredentialStore",
|
||||
"PairingTokenStore",
|
||||
"TelegramConnector",
|
||||
]
|
||||
@ -0,0 +1,93 @@
|
||||
"""Channel connector registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from beaver.foundation.config.schema import ChannelConfig
|
||||
|
||||
from .models import ChannelRuntimeSpec, ValidationResult
|
||||
from .store import ChannelConnectionStore, CredentialStore
|
||||
|
||||
|
||||
class ChannelConnector(Protocol):
|
||||
kind: str
|
||||
|
||||
async def validate(self, connection_id: str) -> ValidationResult:
|
||||
...
|
||||
|
||||
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
|
||||
...
|
||||
|
||||
async def revoke(self, connection_id: str) -> None:
|
||||
...
|
||||
|
||||
|
||||
class ChannelConnectorRegistry:
|
||||
def __init__(self, *, connection_store: ChannelConnectionStore, credential_store: CredentialStore) -> None:
|
||||
self.connection_store = connection_store
|
||||
self.credential_store = credential_store
|
||||
self._connectors: dict[str, ChannelConnector] = {}
|
||||
|
||||
def register(self, connector: ChannelConnector) -> None:
|
||||
kind = connector.kind.strip()
|
||||
if not kind:
|
||||
raise ValueError("Connector kind is required")
|
||||
if kind in self._connectors:
|
||||
raise ValueError(f"Connector already registered: {kind}")
|
||||
self._connectors[kind] = connector
|
||||
|
||||
def connectors(self) -> list[dict[str, str]]:
|
||||
return [{"kind": kind} for kind in sorted(self._connectors)]
|
||||
|
||||
def connector_for_kind(self, kind: str) -> ChannelConnector:
|
||||
return self._connector(kind)
|
||||
|
||||
async def validate(self, connection_id: str) -> ValidationResult:
|
||||
connection = self.connection_store.get(connection_id)
|
||||
connector = self._connector(connection.kind)
|
||||
result = await connector.validate(connection_id)
|
||||
self.connection_store.update_status(
|
||||
connection_id,
|
||||
status=result.status,
|
||||
last_error=result.error,
|
||||
)
|
||||
return result
|
||||
|
||||
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
|
||||
connection = self.connection_store.get(connection_id)
|
||||
return await self._connector(connection.kind).materialize_runtime(connection_id)
|
||||
|
||||
async def materialize_connected_runtime_specs(self) -> list[ChannelRuntimeSpec]:
|
||||
specs: list[ChannelRuntimeSpec] = []
|
||||
for connection in self.connection_store.list():
|
||||
if connection.status not in {"connected", "running"}:
|
||||
continue
|
||||
specs.append(await self._connector(connection.kind).materialize_runtime(connection.connection_id))
|
||||
return specs
|
||||
|
||||
async def materialize_channel_configs(self) -> dict[str, ChannelConfig]:
|
||||
channels: dict[str, ChannelConfig] = {}
|
||||
for spec in await self.materialize_connected_runtime_specs():
|
||||
secrets = self.credential_store.get(spec.secrets_ref) if spec.secrets_ref else {}
|
||||
channels[spec.channel_id] = ChannelConfig(
|
||||
enabled=True,
|
||||
kind=spec.kind,
|
||||
mode=spec.mode,
|
||||
account_id=spec.account_id,
|
||||
display_name=spec.display_name,
|
||||
config=dict(spec.config),
|
||||
secrets=secrets,
|
||||
)
|
||||
return channels
|
||||
|
||||
async def revoke(self, connection_id: str) -> None:
|
||||
connection = self.connection_store.get(connection_id)
|
||||
await self._connector(connection.kind).revoke(connection_id)
|
||||
self.connection_store.revoke(connection_id)
|
||||
|
||||
def _connector(self, kind: str) -> ChannelConnector:
|
||||
connector = self._connectors.get(kind)
|
||||
if connector is None:
|
||||
raise KeyError(f"Connector not registered: {kind}")
|
||||
return connector
|
||||
@ -0,0 +1,144 @@
|
||||
"""Bridge event dedupe store for external connector retries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _parse_iso(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ConnectorMessageDedupeRecord:
|
||||
dedupe_key: str
|
||||
connection_id: str
|
||||
event_id: str
|
||||
status: str
|
||||
first_seen_at: str
|
||||
updated_at: str
|
||||
delivery_attempts: int
|
||||
message_id: str | None = None
|
||||
last_error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ConnectorMessageDedupeRecord":
|
||||
return cls(
|
||||
dedupe_key=str(data.get("dedupe_key") or ""),
|
||||
connection_id=str(data.get("connection_id") or ""),
|
||||
event_id=str(data.get("event_id") or ""),
|
||||
status=str(data.get("status") or "processing"),
|
||||
first_seen_at=str(data.get("first_seen_at") or _iso_now()),
|
||||
updated_at=str(data.get("updated_at") or _iso_now()),
|
||||
delivery_attempts=int(data.get("delivery_attempts") or 0),
|
||||
message_id=str(data["message_id"]) if data.get("message_id") is not None else None,
|
||||
last_error=str(data["last_error"]) if data.get("last_error") is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DedupeBeginResult:
|
||||
should_process: bool
|
||||
dedupe_key: str
|
||||
status: str
|
||||
http_status: int
|
||||
retry_after_seconds: int | None
|
||||
record: ConnectorMessageDedupeRecord
|
||||
|
||||
|
||||
class MessageDedupeStore:
|
||||
def __init__(self, path: Path, *, processing_ttl_seconds: int = 60) -> None:
|
||||
self.path = Path(path)
|
||||
self.processing_ttl_seconds = int(processing_ttl_seconds)
|
||||
self._lock = Lock()
|
||||
|
||||
def begin(self, *, connection_id: str, event_id: str, delivery_attempt: int) -> DedupeBeginResult:
|
||||
dedupe_key = f"{connection_id}:{event_id}"
|
||||
now = _iso_now()
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
raw = data["records"].get(dedupe_key)
|
||||
if isinstance(raw, dict):
|
||||
record = ConnectorMessageDedupeRecord.from_dict(raw)
|
||||
if record.status == "completed":
|
||||
return DedupeBeginResult(False, dedupe_key, record.status, 200, None, record)
|
||||
if record.status == "processing" and not self._is_stale(record, now):
|
||||
return DedupeBeginResult(False, dedupe_key, record.status, 409, 5, record)
|
||||
record.status = "processing"
|
||||
record.updated_at = now
|
||||
record.delivery_attempts = max(record.delivery_attempts + 1, int(delivery_attempt))
|
||||
record.last_error = None
|
||||
else:
|
||||
record = ConnectorMessageDedupeRecord(
|
||||
dedupe_key=dedupe_key,
|
||||
connection_id=connection_id,
|
||||
event_id=event_id,
|
||||
status="processing",
|
||||
first_seen_at=now,
|
||||
updated_at=now,
|
||||
delivery_attempts=max(1, int(delivery_attempt)),
|
||||
)
|
||||
data["records"][dedupe_key] = record.to_dict()
|
||||
self._save(data)
|
||||
return DedupeBeginResult(True, dedupe_key, record.status, 200, None, record)
|
||||
|
||||
def complete(self, dedupe_key: str, *, message_id: str | None) -> ConnectorMessageDedupeRecord:
|
||||
return self._mark(dedupe_key, status="completed", message_id=message_id, error=None)
|
||||
|
||||
def fail(self, dedupe_key: str, *, error: str) -> ConnectorMessageDedupeRecord:
|
||||
return self._mark(dedupe_key, status="failed", message_id=None, error=error)
|
||||
|
||||
def _mark(
|
||||
self,
|
||||
dedupe_key: str,
|
||||
*,
|
||||
status: str,
|
||||
message_id: str | None,
|
||||
error: str | None,
|
||||
) -> ConnectorMessageDedupeRecord:
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
raw = data["records"].get(dedupe_key)
|
||||
if not isinstance(raw, dict):
|
||||
raise KeyError(dedupe_key)
|
||||
record = ConnectorMessageDedupeRecord.from_dict(raw)
|
||||
record.status = status
|
||||
record.updated_at = _iso_now()
|
||||
record.message_id = message_id or record.message_id
|
||||
record.last_error = error
|
||||
data["records"][dedupe_key] = record.to_dict()
|
||||
self._save(data)
|
||||
return record
|
||||
|
||||
def _is_stale(self, record: ConnectorMessageDedupeRecord, now: str) -> bool:
|
||||
age = (_parse_iso(now) - _parse_iso(record.updated_at)).total_seconds()
|
||||
return age >= self.processing_ttl_seconds
|
||||
|
||||
def _load(self) -> dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
return {"records": {}}
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"records": {}}
|
||||
if not isinstance(data, dict) or not isinstance(data.get("records"), dict):
|
||||
return {"records": {}}
|
||||
return data
|
||||
|
||||
def _save(self, data: dict[str, Any]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
|
||||
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
tmp_path.replace(self.path)
|
||||
@ -0,0 +1,131 @@
|
||||
"""Sidecar-backed channel connectors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .models import ChannelRuntimeSpec, ValidationResult
|
||||
from .sidecar_client import ConnectorSidecarClient
|
||||
from .store import ChannelConnectionStore, CredentialStore
|
||||
|
||||
|
||||
class ExternalConnectorBase:
|
||||
kind = ""
|
||||
capabilities: list[str] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
connection_store: ChannelConnectionStore,
|
||||
credential_store: CredentialStore,
|
||||
sidecar_client: ConnectorSidecarClient | Any,
|
||||
sidecar_base_url: str,
|
||||
) -> None:
|
||||
self.connection_store = connection_store
|
||||
self.credential_store = credential_store
|
||||
self.sidecar_client = sidecar_client
|
||||
self.sidecar_base_url = sidecar_base_url
|
||||
|
||||
async def start_session(
|
||||
self,
|
||||
*,
|
||||
display_name: str,
|
||||
owner_user_id: str | None,
|
||||
options: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
connection = self.connection_store.create(
|
||||
kind=self.kind,
|
||||
mode="sidecar",
|
||||
display_name=display_name or self.kind,
|
||||
account_id="",
|
||||
owner_user_id=owner_user_id,
|
||||
auth_type="connector_session",
|
||||
runtime_config={"sidecarBaseUrl": self.sidecar_base_url},
|
||||
capabilities=list(self.capabilities),
|
||||
)
|
||||
connection = self.connection_store.update_status(connection.connection_id, status="pairing", last_error=None)
|
||||
payload = {
|
||||
"kind": self.kind,
|
||||
"connectionId": connection.connection_id,
|
||||
"channelId": connection.channel_id,
|
||||
"displayName": connection.display_name,
|
||||
"callbackBaseUrl": "",
|
||||
"options": dict(options),
|
||||
}
|
||||
view = dict(await self.sidecar_client.start_session(payload))
|
||||
connection.pairing_session_id = str(view.get("sessionId") or "")
|
||||
self.connection_store.update(connection)
|
||||
view["connectionId"] = connection.connection_id
|
||||
view["channelId"] = connection.channel_id
|
||||
return view
|
||||
|
||||
async def poll_session(self, session_id: str) -> dict[str, Any]:
|
||||
view = dict(await self.sidecar_client.get_session(session_id))
|
||||
connection = self._connection_for_session(session_id)
|
||||
status = str(view.get("status") or "")
|
||||
if status == "connected":
|
||||
connection.account_id = str(view.get("accountId") or connection.account_id)
|
||||
connection.display_name = str(view.get("displayName") or connection.display_name)
|
||||
metadata = view.get("metadata") if isinstance(view.get("metadata"), dict) else {}
|
||||
state_ref = metadata.get("stateRef")
|
||||
if state_ref:
|
||||
connection.credentials_ref = self.credential_store.put(kind=self.kind, values={"stateRef": state_ref})
|
||||
self.connection_store.update(connection)
|
||||
self.connection_store.update_status(connection.connection_id, status="connected", last_error=None)
|
||||
elif status in {"expired", "error", "cancelled"}:
|
||||
self.connection_store.update_status(
|
||||
connection.connection_id,
|
||||
status="error",
|
||||
last_error=str(view.get("error") or status),
|
||||
)
|
||||
view["connectionId"] = connection.connection_id
|
||||
view["channelId"] = connection.channel_id
|
||||
return view
|
||||
|
||||
async def validate(self, connection_id: str) -> ValidationResult:
|
||||
connection = self.connection_store.get(connection_id)
|
||||
if connection.status in {"connected", "running"}:
|
||||
return ValidationResult(
|
||||
ok=True,
|
||||
status="connected",
|
||||
account_id=connection.account_id,
|
||||
display_name=connection.display_name,
|
||||
)
|
||||
return ValidationResult(ok=False, status=connection.status, error=connection.last_error)
|
||||
|
||||
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
|
||||
connection = self.connection_store.get(connection_id)
|
||||
if connection.status not in {"connected", "running"}:
|
||||
raise ValueError(f"Connection is not connected: {connection.connection_id}")
|
||||
return ChannelRuntimeSpec(
|
||||
channel_id=connection.channel_id,
|
||||
kind="external_connector",
|
||||
mode="http",
|
||||
account_id=connection.account_id,
|
||||
display_name=connection.display_name,
|
||||
config={
|
||||
"platformKind": self.kind,
|
||||
"connectionId": connection.connection_id,
|
||||
"sidecarBaseUrl": connection.runtime_config.get("sidecarBaseUrl") or self.sidecar_base_url,
|
||||
},
|
||||
secrets_ref=None,
|
||||
)
|
||||
|
||||
async def revoke(self, connection_id: str) -> None:
|
||||
await self.sidecar_client.logout(connection_id)
|
||||
|
||||
def _connection_for_session(self, session_id: str):
|
||||
for connection in self.connection_store.list():
|
||||
if connection.pairing_session_id == session_id:
|
||||
return connection
|
||||
raise KeyError(session_id)
|
||||
|
||||
|
||||
class WeixinConnector(ExternalConnectorBase):
|
||||
kind = "weixin"
|
||||
capabilities = ["receive_text", "send_text", "receive_media", "direct_messages"]
|
||||
|
||||
|
||||
class FeishuConnector(ExternalConnectorBase):
|
||||
kind = "feishu"
|
||||
capabilities = ["receive_text", "send_text", "receive_media", "groups"]
|
||||
@ -0,0 +1,117 @@
|
||||
"""Channel connection setup models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
CONNECTION_STATUSES = {"draft", "pairing", "connected", "running", "degraded", "error", "revoked"}
|
||||
|
||||
|
||||
def iso_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChannelConnection:
|
||||
connection_id: str
|
||||
owner_user_id: str | None
|
||||
channel_id: str
|
||||
kind: str
|
||||
mode: str
|
||||
display_name: str
|
||||
account_id: str
|
||||
status: str
|
||||
auth_type: str
|
||||
credentials_ref: str | None = None
|
||||
connector_ref: str | None = None
|
||||
pairing_session_id: str | None = None
|
||||
runtime_config: dict[str, Any] = field(default_factory=dict)
|
||||
capabilities: list[str] = field(default_factory=list)
|
||||
created_at: str = field(default_factory=iso_now)
|
||||
updated_at: str = field(default_factory=iso_now)
|
||||
last_seen_at: str | None = None
|
||||
last_error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ChannelConnection":
|
||||
return cls(
|
||||
connection_id=str(data.get("connection_id") or ""),
|
||||
owner_user_id=_optional_string(data.get("owner_user_id")),
|
||||
channel_id=str(data.get("channel_id") or ""),
|
||||
kind=str(data.get("kind") or ""),
|
||||
mode=str(data.get("mode") or ""),
|
||||
display_name=str(data.get("display_name") or ""),
|
||||
account_id=str(data.get("account_id") or ""),
|
||||
status=str(data.get("status") or "draft"),
|
||||
auth_type=str(data.get("auth_type") or ""),
|
||||
credentials_ref=_optional_string(data.get("credentials_ref")),
|
||||
connector_ref=_optional_string(data.get("connector_ref")),
|
||||
pairing_session_id=_optional_string(data.get("pairing_session_id")),
|
||||
runtime_config=dict(data.get("runtime_config") or {}),
|
||||
capabilities=[str(item) for item in data.get("capabilities") or []],
|
||||
created_at=str(data.get("created_at") or iso_now()),
|
||||
updated_at=str(data.get("updated_at") or iso_now()),
|
||||
last_seen_at=_optional_string(data.get("last_seen_at")),
|
||||
last_error=_optional_string(data.get("last_error")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PairingSession:
|
||||
pairing_session_id: str
|
||||
kind: str
|
||||
scope: str
|
||||
token: str
|
||||
status: str
|
||||
expires_at_ms: int
|
||||
created_at_ms: int
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "PairingSession":
|
||||
return cls(
|
||||
pairing_session_id=str(data.get("pairing_session_id") or ""),
|
||||
kind=str(data.get("kind") or ""),
|
||||
scope=str(data.get("scope") or ""),
|
||||
token=str(data.get("token") or ""),
|
||||
status=str(data.get("status") or "pending"),
|
||||
expires_at_ms=int(data.get("expires_at_ms") or 0),
|
||||
created_at_ms=int(data.get("created_at_ms") or 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChannelRuntimeSpec:
|
||||
channel_id: str
|
||||
kind: str
|
||||
mode: str
|
||||
account_id: str
|
||||
display_name: str
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
secrets_ref: str | None = None
|
||||
external_endpoint: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ValidationResult:
|
||||
ok: bool
|
||||
status: str
|
||||
account_id: str | None = None
|
||||
display_name: str | None = None
|
||||
error: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _optional_string(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
@ -0,0 +1,39 @@
|
||||
"""HTTP client for the generic external connector sidecar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class ConnectorSidecarClient:
|
||||
def __init__(self, *, base_url: str, token: str, timeout_seconds: float = 20.0) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
self.timeout_seconds = float(timeout_seconds)
|
||||
|
||||
async def get_connectors(self) -> list[dict[str, Any]]:
|
||||
return await self._request("GET", "/connectors")
|
||||
|
||||
async def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return await self._request("POST", "/connector-sessions", json=payload)
|
||||
|
||||
async def get_session(self, session_id: str) -> dict[str, Any]:
|
||||
return await self._request("GET", f"/connector-sessions/{session_id}")
|
||||
|
||||
async def cancel_session(self, session_id: str) -> dict[str, Any]:
|
||||
return await self._request("POST", f"/connector-sessions/{session_id}/cancel", json={})
|
||||
|
||||
async def logout(self, connection_id: str) -> dict[str, Any]:
|
||||
return await self._request("POST", f"/connections/{connection_id}/logout", json={})
|
||||
|
||||
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return await self._request("POST", "/send", json=payload)
|
||||
|
||||
async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None) -> Any:
|
||||
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
||||
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
|
||||
response = await client.request(method, f"{self.base_url}{path}", json=json, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@ -0,0 +1,222 @@
|
||||
"""Persistent channel connection stores."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from .models import CONNECTION_STATUSES, ChannelConnection, PairingSession, iso_now
|
||||
|
||||
|
||||
class ChannelConnectionStore:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = Lock()
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
mode: str,
|
||||
display_name: str,
|
||||
account_id: str,
|
||||
owner_user_id: str | None,
|
||||
auth_type: str,
|
||||
runtime_config: dict[str, Any] | None = None,
|
||||
capabilities: list[str] | None = None,
|
||||
credentials_ref: str | None = None,
|
||||
) -> ChannelConnection:
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
connection_id = f"conn_{uuid4().hex}"
|
||||
channel_id = f"{_slug(kind)}-{uuid4().hex[:8]}"
|
||||
now = iso_now()
|
||||
connection = ChannelConnection(
|
||||
connection_id=connection_id,
|
||||
owner_user_id=owner_user_id,
|
||||
channel_id=channel_id,
|
||||
kind=kind,
|
||||
mode=mode,
|
||||
display_name=display_name or channel_id,
|
||||
account_id=account_id,
|
||||
status="draft",
|
||||
auth_type=auth_type,
|
||||
credentials_ref=credentials_ref,
|
||||
runtime_config=runtime_config or {},
|
||||
capabilities=capabilities or [],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
data["connections"][connection_id] = connection.to_dict()
|
||||
self._save(data)
|
||||
return connection
|
||||
|
||||
def get(self, connection_id: str) -> ChannelConnection:
|
||||
data = self._load()
|
||||
raw = data["connections"].get(connection_id)
|
||||
if not isinstance(raw, dict):
|
||||
raise KeyError(connection_id)
|
||||
return ChannelConnection.from_dict(raw)
|
||||
|
||||
def list(self) -> list[ChannelConnection]:
|
||||
data = self._load()
|
||||
return [ChannelConnection.from_dict(item) for item in data["connections"].values() if isinstance(item, dict)]
|
||||
|
||||
def update(self, connection: ChannelConnection) -> ChannelConnection:
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
if connection.connection_id not in data["connections"]:
|
||||
raise KeyError(connection.connection_id)
|
||||
connection.updated_at = iso_now()
|
||||
data["connections"][connection.connection_id] = connection.to_dict()
|
||||
self._save(data)
|
||||
return connection
|
||||
|
||||
def update_status(self, connection_id: str, *, status: str, last_error: str | None) -> ChannelConnection:
|
||||
if status not in CONNECTION_STATUSES:
|
||||
raise ValueError(f"Unsupported connection status: {status}")
|
||||
connection = self.get(connection_id)
|
||||
connection.status = status
|
||||
connection.last_error = last_error
|
||||
if status in {"connected", "running"}:
|
||||
connection.last_seen_at = iso_now()
|
||||
return self.update(connection)
|
||||
|
||||
def revoke(self, connection_id: str) -> ChannelConnection:
|
||||
return self.update_status(connection_id, status="revoked", last_error=None)
|
||||
|
||||
def _load(self) -> dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
return {"connections": {}}
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"connections": {}}
|
||||
if not isinstance(data, dict) or not isinstance(data.get("connections"), dict):
|
||||
return {"connections": {}}
|
||||
return data
|
||||
|
||||
def _save(self, data: dict[str, Any]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
|
||||
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
tmp_path.replace(self.path)
|
||||
|
||||
|
||||
class CredentialStore:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = Lock()
|
||||
|
||||
def put(self, *, kind: str, values: dict[str, Any]) -> str:
|
||||
cleaned = {str(key): str(value) for key, value in values.items() if str(key).strip() and str(value).strip()}
|
||||
ref = f"cred_{uuid4().hex}"
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
data["credentials"][ref] = {"kind": kind, "values": cleaned, "created_at": iso_now()}
|
||||
self._save(data)
|
||||
return ref
|
||||
|
||||
def get(self, ref: str) -> dict[str, str]:
|
||||
data = self._load()
|
||||
item = data["credentials"].get(ref)
|
||||
if not isinstance(item, dict):
|
||||
raise KeyError(ref)
|
||||
values = item.get("values")
|
||||
if not isinstance(values, dict):
|
||||
return {}
|
||||
return {str(key): str(value) for key, value in values.items()}
|
||||
|
||||
def redacted(self, ref: str | None) -> dict[str, str]:
|
||||
if not ref:
|
||||
return {}
|
||||
try:
|
||||
values = self.get(ref)
|
||||
except KeyError:
|
||||
return {}
|
||||
return {key: "***" for key in values}
|
||||
|
||||
def _load(self) -> dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
return {"credentials": {}}
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"credentials": {}}
|
||||
if not isinstance(data, dict) or not isinstance(data.get("credentials"), dict):
|
||||
return {"credentials": {}}
|
||||
return data
|
||||
|
||||
def _save(self, data: dict[str, Any]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
|
||||
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
tmp_path.replace(self.path)
|
||||
|
||||
|
||||
class PairingTokenStore:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = Lock()
|
||||
|
||||
def create(self, *, kind: str, ttl_seconds: int, scope: str) -> PairingSession:
|
||||
now_ms = _now_ms()
|
||||
session = PairingSession(
|
||||
pairing_session_id=f"pair_{uuid4().hex}",
|
||||
kind=kind,
|
||||
scope=scope,
|
||||
token=f"pair_{uuid4().hex}",
|
||||
status="pending",
|
||||
expires_at_ms=now_ms + int(ttl_seconds * 1000),
|
||||
created_at_ms=now_ms,
|
||||
)
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
data["sessions"][session.pairing_session_id] = session.to_dict()
|
||||
self._save(data)
|
||||
return session
|
||||
|
||||
def consume(self, token: str, *, expected_kind: str) -> PairingSession | None:
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
for key, raw in data["sessions"].items():
|
||||
session = PairingSession.from_dict(raw)
|
||||
if session.token != token or session.kind != expected_kind:
|
||||
continue
|
||||
if session.status != "pending" or session.expires_at_ms <= _now_ms():
|
||||
return None
|
||||
session.status = "consumed"
|
||||
data["sessions"][key] = session.to_dict()
|
||||
self._save(data)
|
||||
return session
|
||||
return None
|
||||
|
||||
def _load(self) -> dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
return {"sessions": {}}
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"sessions": {}}
|
||||
if not isinstance(data, dict) or not isinstance(data.get("sessions"), dict):
|
||||
return {"sessions": {}}
|
||||
return data
|
||||
|
||||
def _save(self, data: dict[str, Any]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
|
||||
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
tmp_path.replace(self.path)
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _slug(value: str) -> str:
|
||||
text = "".join(char if char.isalnum() else "-" for char in str(value).strip().lower())
|
||||
return "-".join(part for part in text.split("-") if part) or "channel"
|
||||
@ -0,0 +1,92 @@
|
||||
"""Telegram channel connector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from .models import ChannelRuntimeSpec, ValidationResult
|
||||
from .store import ChannelConnectionStore, CredentialStore
|
||||
|
||||
|
||||
class TelegramConnector:
|
||||
kind = "telegram"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
connection_store: ChannelConnectionStore,
|
||||
credential_store: CredentialStore,
|
||||
client_factory: Callable[[str], Any] | None = None,
|
||||
) -> None:
|
||||
self.connection_store = connection_store
|
||||
self.credential_store = credential_store
|
||||
self.client_factory = client_factory or _default_client_factory
|
||||
|
||||
async def validate(self, connection_id: str) -> ValidationResult:
|
||||
connection = self.connection_store.get(connection_id)
|
||||
token = self._bot_token(connection.credentials_ref)
|
||||
try:
|
||||
client = self.client_factory(token)
|
||||
raw = await client.get_me()
|
||||
bot_id = _value(raw, "id")
|
||||
username = _value(raw, "username")
|
||||
first_name = _value(raw, "first_name") or "Telegram Bot"
|
||||
account_id = f"telegram:{bot_id}" if bot_id else connection.account_id
|
||||
display_name = f"{first_name} (@{username})" if username else first_name
|
||||
connection.account_id = account_id
|
||||
connection.display_name = display_name
|
||||
connection.capabilities = ["receive_text", "send_text", "receive_media", "groups"]
|
||||
self.connection_store.update(connection)
|
||||
return ValidationResult(
|
||||
ok=True,
|
||||
status="connected",
|
||||
account_id=account_id,
|
||||
display_name=display_name,
|
||||
metadata={"username": username} if username else {},
|
||||
)
|
||||
except Exception as exc:
|
||||
return ValidationResult(ok=False, status="error", error=str(exc))
|
||||
|
||||
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
|
||||
connection = self.connection_store.get(connection_id)
|
||||
if connection.status not in {"connected", "running"}:
|
||||
raise ValueError(f"Connection is not connected: {connection.connection_id}")
|
||||
return ChannelRuntimeSpec(
|
||||
channel_id=connection.channel_id,
|
||||
kind=connection.kind,
|
||||
mode=connection.mode,
|
||||
account_id=connection.account_id,
|
||||
display_name=connection.display_name,
|
||||
config=dict(connection.runtime_config),
|
||||
secrets_ref=connection.credentials_ref,
|
||||
)
|
||||
|
||||
async def revoke(self, connection_id: str) -> None:
|
||||
# Telegram bot tokens do not have a Beaver-managed platform revoke action.
|
||||
# The registry owns local connection state transitions.
|
||||
return None
|
||||
|
||||
def _bot_token(self, credentials_ref: str | None) -> str:
|
||||
if not credentials_ref:
|
||||
raise ValueError("Telegram credentials are missing")
|
||||
token = self.credential_store.get(credentials_ref).get("botToken")
|
||||
if not token:
|
||||
raise ValueError("botToken is required")
|
||||
return token
|
||||
|
||||
|
||||
def _value(raw: Any, key: str) -> str:
|
||||
if isinstance(raw, dict):
|
||||
value = raw.get(key)
|
||||
else:
|
||||
value = getattr(raw, key, None)
|
||||
return str(value).strip() if value is not None else ""
|
||||
|
||||
|
||||
def _default_client_factory(token: str) -> Any:
|
||||
try:
|
||||
from telegram import Bot
|
||||
except ImportError as exc: # pragma: no cover - optional live dependency
|
||||
raise RuntimeError("Install beaver-backend[telegram] to validate Telegram connections") from exc
|
||||
return Bot(token=token)
|
||||
@ -0,0 +1,97 @@
|
||||
"""Generic runtime channel backed by an external connector sidecar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.events import OutboundMessage
|
||||
from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient
|
||||
|
||||
|
||||
class ExternalConnectorChannel:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
platform_kind: str,
|
||||
connection_id: str,
|
||||
account_id: str,
|
||||
display_name: str,
|
||||
sidecar_client: ConnectorSidecarClient | Any,
|
||||
) -> None:
|
||||
self.channel_id = channel_id
|
||||
self.kind = "external_connector"
|
||||
self.mode = "http"
|
||||
self.platform_kind = platform_kind
|
||||
self.connection_id = connection_id
|
||||
self.account_id = account_id
|
||||
self.display_name = display_name or channel_id
|
||||
self.sidecar_client = sidecar_client
|
||||
self.started = False
|
||||
|
||||
async def start(self) -> None:
|
||||
self.started = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.started = False
|
||||
|
||||
async def send(self, message: OutboundMessage) -> None:
|
||||
identity = message.channel_identity
|
||||
if identity is None:
|
||||
raise ValueError("channel_identity is required for external connector sends")
|
||||
metadata = {
|
||||
"inboundMessageId": identity.message_id,
|
||||
"sessionId": message.session_id,
|
||||
}
|
||||
context_token = _context_token(message)
|
||||
if context_token:
|
||||
metadata["contextToken"] = context_token
|
||||
payload = {
|
||||
"requestId": _request_id(message),
|
||||
"connectionId": self.connection_id,
|
||||
"channelId": self.channel_id,
|
||||
"kind": self.platform_kind,
|
||||
"target": {
|
||||
"peerId": identity.peer_id,
|
||||
"peerType": identity.peer_type,
|
||||
"threadId": identity.thread_id,
|
||||
},
|
||||
"content": message.content,
|
||||
"metadata": metadata,
|
||||
}
|
||||
await self.sidecar_client.send(payload)
|
||||
|
||||
|
||||
def _request_id(message: OutboundMessage) -> str:
|
||||
identity = message.channel_identity
|
||||
channel = message.channel or (identity.channel_id if identity else "unknown")
|
||||
session_id = message.session_id or (identity.session_id() if identity else "unknown")
|
||||
message_id = str(message.message_id or "").strip()
|
||||
if not message_id:
|
||||
basis = "|".join(
|
||||
[
|
||||
message.content,
|
||||
identity.message_id if identity and identity.message_id else "",
|
||||
identity.peer_id if identity else "",
|
||||
message.finish_reason,
|
||||
]
|
||||
)
|
||||
message_id = hashlib.sha256(basis.encode("utf-8")).hexdigest()[:24]
|
||||
return f"out_{channel}:{session_id}:{message_id}"
|
||||
|
||||
|
||||
def _context_token(message: OutboundMessage) -> str | None:
|
||||
inbound_metadata = message.metadata.get("inbound_metadata")
|
||||
if isinstance(inbound_metadata, dict):
|
||||
value = _clean_optional(inbound_metadata.get("contextToken") or inbound_metadata.get("context_token"))
|
||||
if value:
|
||||
return value
|
||||
return _clean_optional(message.metadata.get("contextToken") or message.metadata.get("context_token"))
|
||||
|
||||
|
||||
def _clean_optional(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
@ -0,0 +1,116 @@
|
||||
"""Generic fixed-schema text webhook channel adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
|
||||
from beaver.interfaces.channels.base import ChannelInboundSink
|
||||
|
||||
|
||||
class GenericWebhookAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
kind: str,
|
||||
mode: str,
|
||||
account_id: str,
|
||||
display_name: str = "",
|
||||
inbound_sink: ChannelInboundSink,
|
||||
response_timeout_seconds: float = 1800,
|
||||
) -> None:
|
||||
self.channel_id = channel_id
|
||||
self.kind = kind
|
||||
self.mode = mode
|
||||
self.account_id = account_id
|
||||
self.display_name = display_name or channel_id
|
||||
self.inbound_sink = inbound_sink
|
||||
self.response_timeout_seconds = max(1.0, float(response_timeout_seconds))
|
||||
self.started = False
|
||||
self._pending: dict[str, asyncio.Future[OutboundMessage]] = {}
|
||||
|
||||
async def start(self) -> None:
|
||||
self.started = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.started = False
|
||||
for future in list(self._pending.values()):
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
self._pending.clear()
|
||||
|
||||
async def handle_webhook_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
text = str(payload.get("text") or "").strip()
|
||||
peer_id = str(payload.get("peer_id") or "").strip()
|
||||
message_id = str(payload.get("message_id") or "").strip()
|
||||
thread_id = str(payload.get("thread_id") or "").strip() or None
|
||||
peer_type = str(payload.get("peer_type") or "unknown").strip() or "unknown"
|
||||
user_id = str(payload.get("user_id") or "").strip() or None
|
||||
if not text:
|
||||
return {"ok": False, "error": "text is required"}
|
||||
if not peer_id:
|
||||
return {"ok": False, "error": "peer_id is required"}
|
||||
if not message_id:
|
||||
return {"ok": False, "error": "message_id is required"}
|
||||
|
||||
identity = ChannelIdentity(
|
||||
channel_id=self.channel_id,
|
||||
kind=self.kind,
|
||||
account_id=self.account_id,
|
||||
peer_id=peer_id,
|
||||
thread_id=thread_id,
|
||||
peer_type=peer_type,
|
||||
user_id=user_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
inbound = InboundMessage(
|
||||
channel=self.channel_id,
|
||||
content=text,
|
||||
user_id=user_id,
|
||||
channel_identity=identity,
|
||||
metadata={"webhook": {"peer_type": peer_type}},
|
||||
)
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
self._pending[inbound.message_id] = future
|
||||
accept = await self.inbound_sink.accept_inbound(inbound)
|
||||
if not accept.accepted:
|
||||
self._pending.pop(inbound.message_id, None)
|
||||
record = accept.record or {}
|
||||
return {
|
||||
"ok": accept.error is None,
|
||||
"duplicate": accept.duplicate,
|
||||
"pending": accept.pending,
|
||||
"session_id": accept.session_id,
|
||||
"status": record.get("status"),
|
||||
"run_id": record.get("run_id"),
|
||||
"reply": record.get("reply"),
|
||||
"error": accept.error or record.get("error"),
|
||||
}
|
||||
try:
|
||||
outbound = await asyncio.wait_for(future, timeout=self.response_timeout_seconds)
|
||||
except asyncio.TimeoutError:
|
||||
self._pending.pop(inbound.message_id, None)
|
||||
return {
|
||||
"ok": True,
|
||||
"duplicate": False,
|
||||
"pending": True,
|
||||
"session_id": accept.session_id,
|
||||
}
|
||||
return {
|
||||
"ok": outbound.finish_reason != "error",
|
||||
"duplicate": False,
|
||||
"pending": False,
|
||||
"session_id": outbound.session_id,
|
||||
"run_id": outbound.run_id,
|
||||
"reply": outbound.content,
|
||||
"error": outbound.metadata.get("error"),
|
||||
}
|
||||
|
||||
async def send(self, message: OutboundMessage) -> None:
|
||||
future = self._pending.pop(message.message_id, None)
|
||||
if future is None or future.done():
|
||||
message.metadata["delivery_status"] = "unclaimed"
|
||||
return
|
||||
future.set_result(message)
|
||||
@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
|
||||
from beaver.foundation.events import MessageBus, OutboundMessage
|
||||
@ -20,13 +21,17 @@ class ChannelManager:
|
||||
self.started = False
|
||||
|
||||
def register(self, channel: ChannelAdapter) -> None:
|
||||
if self.started:
|
||||
raise RuntimeError("Cannot register channels after ChannelManager.start()")
|
||||
if channel.name in self.channels:
|
||||
raise ValueError(f"Channel already registered: {channel.name}")
|
||||
if channel.bus is not self.bus:
|
||||
raise ValueError("Channel must share the same MessageBus as ChannelManager")
|
||||
self.channels[channel.name] = channel
|
||||
if channel.channel_id in self.channels:
|
||||
raise ValueError(f"Channel already registered: {channel.channel_id}")
|
||||
self.channels[channel.channel_id] = channel
|
||||
|
||||
def unregister(self, channel_id: str) -> ChannelAdapter | None:
|
||||
return self.channels.pop(channel_id, None)
|
||||
|
||||
def replace_registered(self, channel: ChannelAdapter) -> ChannelAdapter | None:
|
||||
old = self.channels.get(channel.channel_id)
|
||||
self.channels[channel.channel_id] = channel
|
||||
return old
|
||||
|
||||
async def start(self) -> None:
|
||||
started: list[ChannelAdapter] = []
|
||||
@ -53,7 +58,13 @@ class ChannelManager:
|
||||
if errors:
|
||||
raise RuntimeError(f"Failed to stop {len(errors)} channel(s)") from errors[0]
|
||||
|
||||
async def dispatch_outbound(self, stop_event: asyncio.Event) -> None:
|
||||
async def dispatch_outbound(
|
||||
self,
|
||||
stop_event: asyncio.Event,
|
||||
*,
|
||||
on_delivered: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
||||
on_failed: Callable[[OutboundMessage, Exception | None], Awaitable[None]] | None = None,
|
||||
) -> None:
|
||||
"""Route bus outbound messages until stopped and the queue is drained."""
|
||||
|
||||
while True:
|
||||
@ -68,9 +79,16 @@ class ChannelManager:
|
||||
channel = self.channels.get(message.channel)
|
||||
if channel is None:
|
||||
self.undeliverable.append(message)
|
||||
if on_failed is not None:
|
||||
await on_failed(message, None)
|
||||
continue
|
||||
|
||||
try:
|
||||
await channel.send(message)
|
||||
except Exception: # pragma: no cover - defensive channel isolation
|
||||
except Exception as exc: # pragma: no cover - defensive channel isolation
|
||||
self.undeliverable.append(message)
|
||||
if on_failed is not None:
|
||||
await on_failed(message, exc)
|
||||
else:
|
||||
if on_delivered is not None:
|
||||
await on_delivered(message)
|
||||
|
||||
@ -4,15 +4,27 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
|
||||
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
|
||||
from beaver.interfaces.channels.base import ChannelInboundSink
|
||||
|
||||
|
||||
class MemoryChannelAdapter:
|
||||
"""A local channel that stores outbound messages in memory."""
|
||||
|
||||
def __init__(self, bus: MessageBus, *, name: str = "memory") -> None:
|
||||
self.name = name
|
||||
self.bus = bus
|
||||
def __init__(
|
||||
self,
|
||||
inbound_sink: ChannelInboundSink,
|
||||
*,
|
||||
channel_id: str = "memory-dev",
|
||||
kind: str = "memory",
|
||||
mode: str = "webhook",
|
||||
account_id: str = "memory",
|
||||
) -> None:
|
||||
self.channel_id = channel_id
|
||||
self.kind = kind
|
||||
self.mode = mode
|
||||
self.account_id = account_id
|
||||
self.inbound_sink = inbound_sink
|
||||
self.started = False
|
||||
self.sent_messages: list[OutboundMessage] = []
|
||||
|
||||
@ -36,12 +48,24 @@ class MemoryChannelAdapter:
|
||||
model: str | None = None,
|
||||
provider_name: str | None = None,
|
||||
embedding_model: str | None = None,
|
||||
peer_id: str = "default",
|
||||
thread_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> InboundMessage:
|
||||
"""Publish a text message from this channel into the shared bus."""
|
||||
|
||||
identity = ChannelIdentity(
|
||||
channel_id=self.channel_id,
|
||||
kind=self.kind,
|
||||
account_id=self.account_id,
|
||||
peer_id=peer_id,
|
||||
thread_id=thread_id,
|
||||
user_id=user_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
message = InboundMessage(
|
||||
channel=self.name,
|
||||
channel=self.channel_id,
|
||||
content=content,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
@ -50,9 +74,10 @@ class MemoryChannelAdapter:
|
||||
model=model,
|
||||
provider_name=provider_name,
|
||||
embedding_model=embedding_model,
|
||||
channel_identity=identity,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
await self.bus.publish_inbound(message)
|
||||
await self.inbound_sink.accept_inbound(message)
|
||||
return message
|
||||
|
||||
async def publish_external_text(
|
||||
@ -73,9 +98,6 @@ class MemoryChannelAdapter:
|
||||
the shared gateway bus.
|
||||
"""
|
||||
|
||||
session_parts = [self.name, chat_id]
|
||||
if thread_id:
|
||||
session_parts.append(thread_id)
|
||||
metadata = {
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
@ -84,8 +106,10 @@ class MemoryChannelAdapter:
|
||||
}
|
||||
return await self.publish_text(
|
||||
content,
|
||||
session_id=":".join(str(part) for part in session_parts if str(part)),
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
peer_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
message_id=message_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@ -0,0 +1 @@
|
||||
"""Platform channel adapters."""
|
||||
@ -0,0 +1,138 @@
|
||||
"""Shared helpers for platform channel adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OutboundTarget:
|
||||
peer_id: str | None
|
||||
thread_id: str | None = None
|
||||
peer_type: str = "unknown"
|
||||
user_id: str | None = None
|
||||
|
||||
|
||||
class PlatformDeliveryError(RuntimeError):
|
||||
"""Raised when a platform client rejects a delivery."""
|
||||
|
||||
|
||||
def config_bool(config: dict[str, Any], key: str, *, default: bool = False) -> bool:
|
||||
value = config.get(key)
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
text = str(value).strip().lower()
|
||||
if text in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if text in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def config_list(config: dict[str, Any], key: str) -> list[str]:
|
||||
value = config.get(key)
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [part.strip() for part in value.split(",") if part.strip()]
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
text = str(value).strip()
|
||||
return [text] if text else []
|
||||
|
||||
|
||||
def chunk_text(text: str, *, max_chars: int) -> list[str]:
|
||||
if max_chars <= 0:
|
||||
raise ValueError("max_chars must be positive")
|
||||
if not text:
|
||||
return [""]
|
||||
return [text[index : index + max_chars] for index in range(0, len(text), max_chars)]
|
||||
|
||||
|
||||
def compact_media_summary(media_type: str, *, file_name: str | None = None) -> str:
|
||||
label = str(media_type or "attachment").strip() or "attachment"
|
||||
if file_name:
|
||||
return f"[{label}: {file_name}]"
|
||||
return f"[{label}]"
|
||||
|
||||
|
||||
def target_from_session_id(session_id: str | None) -> OutboundTarget:
|
||||
if not session_id:
|
||||
return OutboundTarget(peer_id=None)
|
||||
parts = str(session_id).split(":")
|
||||
if len(parts) < 3:
|
||||
return OutboundTarget(peer_id=None)
|
||||
thread_id = parts[3] if len(parts) > 3 and parts[3] else None
|
||||
return OutboundTarget(peer_id=parts[2] or None, thread_id=thread_id)
|
||||
|
||||
|
||||
def outbound_target(message: OutboundMessage) -> OutboundTarget:
|
||||
identity = message.channel_identity
|
||||
if identity is None:
|
||||
return target_from_session_id(message.session_id)
|
||||
return OutboundTarget(
|
||||
peer_id=identity.peer_id,
|
||||
thread_id=identity.thread_id,
|
||||
peer_type=identity.peer_type,
|
||||
user_id=identity.user_id,
|
||||
)
|
||||
|
||||
|
||||
def mark_unclaimed(message: OutboundMessage) -> None:
|
||||
message.metadata["delivery_status"] = "unclaimed"
|
||||
|
||||
|
||||
def build_inbound_message(
|
||||
*,
|
||||
channel_id: str,
|
||||
kind: str,
|
||||
account_id: str,
|
||||
peer_id: str,
|
||||
content: str,
|
||||
message_id: str | None,
|
||||
peer_type: str,
|
||||
user_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> InboundMessage:
|
||||
identity = ChannelIdentity(
|
||||
channel_id=channel_id,
|
||||
kind=kind,
|
||||
account_id=account_id,
|
||||
peer_id=peer_id,
|
||||
thread_id=thread_id,
|
||||
peer_type=peer_type,
|
||||
user_id=user_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
return InboundMessage(
|
||||
channel=channel_id,
|
||||
content=content,
|
||||
session_id=identity.session_id(),
|
||||
user_id=user_id,
|
||||
message_id=message_id or "",
|
||||
channel_identity=identity,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
def allowed_by_policy(
|
||||
*,
|
||||
policy: str | None,
|
||||
identifier: str | None,
|
||||
allowlist: list[str],
|
||||
default: str = "open",
|
||||
) -> bool:
|
||||
effective = (policy or default).strip().lower()
|
||||
if effective == "disabled":
|
||||
return False
|
||||
if effective == "allowlist":
|
||||
return bool(identifier and identifier in allowlist)
|
||||
return True
|
||||
@ -0,0 +1,207 @@
|
||||
"""Feishu/Lark channel adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.events import InboundMessage, OutboundMessage
|
||||
from beaver.interfaces.channels.base import ChannelInboundSink
|
||||
|
||||
from .base import (
|
||||
build_inbound_message,
|
||||
chunk_text,
|
||||
compact_media_summary,
|
||||
config_bool,
|
||||
config_list,
|
||||
mark_unclaimed,
|
||||
outbound_target,
|
||||
)
|
||||
|
||||
EventRecorder = Callable[..., None]
|
||||
|
||||
|
||||
class FeishuAdapter:
|
||||
"""Feishu/Lark bot adapter with injectable client support."""
|
||||
|
||||
KIND = "feishu"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
kind: str,
|
||||
mode: str,
|
||||
account_id: str,
|
||||
display_name: str | None,
|
||||
inbound_sink: ChannelInboundSink,
|
||||
secrets: dict[str, Any] | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
event_recorder: EventRecorder | None = None,
|
||||
client: Any | None = None,
|
||||
) -> None:
|
||||
self.channel_id = channel_id
|
||||
self.kind = kind
|
||||
self.mode = mode
|
||||
self.account_id = account_id
|
||||
self.display_name = display_name
|
||||
self.inbound_sink = inbound_sink
|
||||
self.secrets = secrets or {}
|
||||
self.config = config or {}
|
||||
self.event_recorder = event_recorder
|
||||
self._client = client
|
||||
self.max_message_chars = int(self.config.get("maxMessageChars") or 4096)
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._client is not None:
|
||||
return
|
||||
if self.mode not in {"websocket", "webhook"}:
|
||||
raise ValueError(f"Unsupported feishu mode: {self.mode}")
|
||||
self._client = self._build_client()
|
||||
|
||||
async def stop(self) -> None:
|
||||
close = getattr(self._client, "close", None)
|
||||
if close is not None:
|
||||
result = close()
|
||||
if hasattr(result, "__await__"):
|
||||
await result
|
||||
|
||||
async def handle_event_payload(self, payload: dict[str, Any]) -> None:
|
||||
message = self._normalize_payload(payload)
|
||||
if message is None:
|
||||
return
|
||||
await self.inbound_sink.accept_inbound(message)
|
||||
|
||||
async def send(self, message: OutboundMessage) -> None:
|
||||
target = outbound_target(message)
|
||||
if not target.peer_id:
|
||||
mark_unclaimed(message)
|
||||
return
|
||||
client = self._require_client()
|
||||
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
|
||||
await client.send_text(receive_id_type="chat_id", receive_id=target.peer_id, text=chunk)
|
||||
|
||||
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
|
||||
event = payload.get("event") if isinstance(payload.get("event"), dict) else payload
|
||||
message = event.get("message") if isinstance(event.get("message"), dict) else {}
|
||||
sender = event.get("sender") if isinstance(event.get("sender"), dict) else {}
|
||||
|
||||
peer_id = _string_or_none(message.get("chat_id"))
|
||||
if not peer_id:
|
||||
return None
|
||||
|
||||
message_id = _string_or_none(message.get("message_id"))
|
||||
message_type = str(message.get("message_type") or "unknown")
|
||||
chat_type = str(message.get("chat_type") or "unknown")
|
||||
peer_type = "dm" if chat_type == "p2p" else "group"
|
||||
user_id = _sender_open_id(sender)
|
||||
|
||||
if peer_type == "dm" and not self._dm_allowed(user_id or peer_id):
|
||||
return None
|
||||
if peer_type == "group" and not self._group_allowed(peer_id, user_id):
|
||||
return None
|
||||
if peer_type == "group" and config_bool(self.config, "requireMentionInGroups", default=False):
|
||||
if not self._message_mentions_bot(message):
|
||||
return None
|
||||
|
||||
content = self._message_content(message_type, message)
|
||||
if not content:
|
||||
return None
|
||||
|
||||
metadata = {
|
||||
"chat_id": peer_id,
|
||||
"message_id": message_id,
|
||||
"chat_type": chat_type,
|
||||
"message_type": message_type,
|
||||
}
|
||||
|
||||
return build_inbound_message(
|
||||
channel_id=self.channel_id,
|
||||
kind=self.kind,
|
||||
account_id=self.account_id,
|
||||
peer_id=peer_id,
|
||||
peer_type=peer_type,
|
||||
user_id=user_id,
|
||||
message_id=message_id,
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _message_content(self, message_type: str, message: dict[str, Any]) -> str:
|
||||
content = _parse_json_object(message.get("content"))
|
||||
if message_type == "text":
|
||||
return str(content.get("text") or "").strip()
|
||||
file_name = _string_or_none(content.get("file_name") or content.get("name"))
|
||||
return compact_media_summary(message_type, file_name=file_name)
|
||||
|
||||
def _message_mentions_bot(self, message: dict[str, Any]) -> bool:
|
||||
bot_open_id = _string_or_none(self.config.get("botOpenId"))
|
||||
if not bot_open_id:
|
||||
return False
|
||||
mentions = message.get("mentions")
|
||||
if not isinstance(mentions, list):
|
||||
return False
|
||||
for mention in mentions:
|
||||
if not isinstance(mention, dict):
|
||||
continue
|
||||
mention_id = mention.get("id") if isinstance(mention.get("id"), dict) else {}
|
||||
if _string_or_none(mention_id.get("open_id")) == bot_open_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _dm_allowed(self, identifier: str | None) -> bool:
|
||||
allowlist = config_list(self.config, "allowFrom")
|
||||
if not allowlist:
|
||||
return True
|
||||
return bool(identifier and identifier in allowlist)
|
||||
|
||||
def _group_allowed(self, peer_id: str | None, user_id: str | None) -> bool:
|
||||
allowlist = config_list(self.config, "groupAllowFrom")
|
||||
if not allowlist:
|
||||
return True
|
||||
return bool((peer_id and peer_id in allowlist) or (user_id and user_id in allowlist))
|
||||
|
||||
def _require_client(self) -> Any:
|
||||
if self._client is None:
|
||||
self._client = self._build_client()
|
||||
return self._client
|
||||
|
||||
def _build_client(self) -> Any:
|
||||
self._require_secret("appId")
|
||||
self._require_secret("appSecret")
|
||||
try:
|
||||
import lark_oapi # noqa: F401
|
||||
except ImportError as exc: # pragma: no cover - optional live dependency
|
||||
raise RuntimeError("Install beaver-backend[feishu] to enable FeishuAdapter") from exc
|
||||
raise RuntimeError("Feishu live client is not configured for direct construction")
|
||||
|
||||
def _require_secret(self, key: str) -> str:
|
||||
value = self.secrets.get(key)
|
||||
if not value:
|
||||
raise ValueError(f"{key} is required")
|
||||
return str(value)
|
||||
|
||||
|
||||
def _parse_json_object(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _sender_open_id(sender: dict[str, Any]) -> str | None:
|
||||
sender_id = sender.get("sender_id") if isinstance(sender.get("sender_id"), dict) else {}
|
||||
return _string_or_none(sender_id.get("open_id"))
|
||||
|
||||
|
||||
def _string_or_none(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
@ -0,0 +1,206 @@
|
||||
"""QQ Bot channel adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.events import InboundMessage, OutboundMessage
|
||||
from beaver.interfaces.channels.base import ChannelInboundSink
|
||||
|
||||
from .base import (
|
||||
allowed_by_policy,
|
||||
build_inbound_message,
|
||||
chunk_text,
|
||||
compact_media_summary,
|
||||
config_list,
|
||||
mark_unclaimed,
|
||||
outbound_target,
|
||||
)
|
||||
|
||||
EventRecorder = Callable[..., None]
|
||||
|
||||
|
||||
class QQBotAdapter:
|
||||
"""QQ Bot API adapter with injectable client support."""
|
||||
|
||||
KIND = "qqbot"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
kind: str,
|
||||
mode: str,
|
||||
account_id: str,
|
||||
display_name: str | None,
|
||||
inbound_sink: ChannelInboundSink,
|
||||
secrets: dict[str, Any] | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
event_recorder: EventRecorder | None = None,
|
||||
client: Any | None = None,
|
||||
) -> None:
|
||||
self.channel_id = channel_id
|
||||
self.kind = kind
|
||||
self.mode = mode
|
||||
self.account_id = account_id
|
||||
self.display_name = display_name
|
||||
self.inbound_sink = inbound_sink
|
||||
self.secrets = secrets or {}
|
||||
self.config = config or {}
|
||||
self.event_recorder = event_recorder
|
||||
self._client = client
|
||||
self.max_message_chars = int(self.config.get("maxMessageChars") or 2000)
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._client is not None:
|
||||
return
|
||||
if self.mode != "websocket":
|
||||
raise ValueError(f"Unsupported qqbot mode: {self.mode}")
|
||||
self._client = self._build_client()
|
||||
|
||||
async def stop(self) -> None:
|
||||
close = getattr(self._client, "close", None)
|
||||
if close is not None:
|
||||
result = close()
|
||||
if hasattr(result, "__await__"):
|
||||
await result
|
||||
|
||||
async def handle_event_payload(self, payload: dict[str, Any]) -> None:
|
||||
message = self._normalize_payload(payload)
|
||||
if message is None:
|
||||
return
|
||||
await self.inbound_sink.accept_inbound(message)
|
||||
|
||||
async def send(self, message: OutboundMessage) -> None:
|
||||
target = outbound_target(message)
|
||||
if not target.peer_id:
|
||||
mark_unclaimed(message)
|
||||
return
|
||||
client = self._require_client()
|
||||
platform_message_id = message.channel_identity.message_id if message.channel_identity else None
|
||||
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
|
||||
await client.send_text(
|
||||
peer_type=target.peer_type,
|
||||
peer_id=target.peer_id,
|
||||
content=chunk,
|
||||
message_id=platform_message_id,
|
||||
)
|
||||
|
||||
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
|
||||
event_type = str(payload.get("t") or payload.get("type") or "")
|
||||
data = payload.get("d") if isinstance(payload.get("d"), dict) else payload
|
||||
author = data.get("author") if isinstance(data.get("author"), dict) else {}
|
||||
|
||||
route = self._route(event_type, data, author)
|
||||
if route is None:
|
||||
return None
|
||||
peer_id, peer_type, user_id, thread_id = route
|
||||
|
||||
if peer_type == "dm":
|
||||
if not allowed_by_policy(
|
||||
policy=self.config.get("dmPolicy"),
|
||||
identifier=user_id or peer_id,
|
||||
allowlist=config_list(self.config, "allowFrom"),
|
||||
default="open",
|
||||
):
|
||||
return None
|
||||
elif peer_type == "group":
|
||||
if not allowed_by_policy(
|
||||
policy=self.config.get("groupPolicy"),
|
||||
identifier=peer_id,
|
||||
allowlist=config_list(self.config, "groupAllowFrom"),
|
||||
default="open",
|
||||
):
|
||||
return None
|
||||
|
||||
message_id = _string_or_none(data.get("id"))
|
||||
content = str(data.get("content") or "").strip()
|
||||
media_entries = self._media_entries(data)
|
||||
if media_entries:
|
||||
content = "\n".join([part for part in [content, *media_entries] if part]).strip()
|
||||
if not content:
|
||||
return None
|
||||
|
||||
metadata = {
|
||||
"event_type": event_type,
|
||||
"message_id": message_id,
|
||||
"peer_type": peer_type,
|
||||
}
|
||||
if media_entries:
|
||||
metadata["media"] = media_entries
|
||||
|
||||
return build_inbound_message(
|
||||
channel_id=self.channel_id,
|
||||
kind=self.kind,
|
||||
account_id=self.account_id,
|
||||
peer_id=peer_id,
|
||||
thread_id=thread_id,
|
||||
peer_type=peer_type,
|
||||
user_id=user_id,
|
||||
message_id=message_id,
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _route(
|
||||
self,
|
||||
event_type: str,
|
||||
data: dict[str, Any],
|
||||
author: dict[str, Any],
|
||||
) -> tuple[str, str, str | None, str | None] | None:
|
||||
if event_type == "C2C_MESSAGE_CREATE":
|
||||
peer_id = _string_or_none(author.get("user_openid"))
|
||||
if not peer_id:
|
||||
return None
|
||||
return peer_id, "dm", peer_id, None
|
||||
if event_type == "GROUP_AT_MESSAGE_CREATE":
|
||||
peer_id = _string_or_none(data.get("group_openid"))
|
||||
if not peer_id:
|
||||
return None
|
||||
return peer_id, "group", _string_or_none(author.get("member_openid")), None
|
||||
if data.get("guild_id") and data.get("channel_id"):
|
||||
peer_id = _string_or_none(data.get("channel_id"))
|
||||
if not peer_id:
|
||||
return None
|
||||
return peer_id, "channel", _string_or_none(author.get("id")), _string_or_none(data.get("guild_id"))
|
||||
return None
|
||||
|
||||
def _media_entries(self, data: dict[str, Any]) -> list[str]:
|
||||
entries: list[str] = []
|
||||
attachments = data.get("attachments")
|
||||
if not isinstance(attachments, list):
|
||||
return entries
|
||||
for attachment in attachments:
|
||||
if not isinstance(attachment, dict):
|
||||
continue
|
||||
media_type = str(attachment.get("content_type") or attachment.get("type") or "attachment")
|
||||
entries.append(compact_media_summary(media_type, file_name=_string_or_none(attachment.get("filename"))))
|
||||
return entries
|
||||
|
||||
def _require_client(self) -> Any:
|
||||
if self._client is None:
|
||||
self._client = self._build_client()
|
||||
return self._client
|
||||
|
||||
def _build_client(self) -> Any:
|
||||
self._require_secret("appId")
|
||||
self._require_secret("clientSecret")
|
||||
try:
|
||||
import aiohttp # noqa: F401
|
||||
except ImportError as exc: # pragma: no cover - optional live dependency
|
||||
raise RuntimeError("Install beaver-backend[qqbot] to enable QQBotAdapter") from exc
|
||||
raise RuntimeError("QQBot live client is not configured for direct construction")
|
||||
|
||||
def _require_secret(self, key: str) -> str:
|
||||
value = self.secrets.get(key)
|
||||
if not value:
|
||||
raise ValueError(f"{key} is required")
|
||||
return str(value)
|
||||
|
||||
|
||||
def _string_or_none(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
@ -0,0 +1,244 @@
|
||||
"""Telegram channel adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.events import InboundMessage, OutboundMessage
|
||||
from beaver.interfaces.channels.base import ChannelInboundSink
|
||||
|
||||
from .base import (
|
||||
build_inbound_message,
|
||||
chunk_text,
|
||||
compact_media_summary,
|
||||
config_bool,
|
||||
config_list,
|
||||
mark_unclaimed,
|
||||
outbound_target,
|
||||
)
|
||||
|
||||
EventRecorder = Callable[..., None]
|
||||
|
||||
|
||||
class TelegramAdapter:
|
||||
"""Telegram Bot API adapter with injectable client support."""
|
||||
|
||||
KIND = "telegram"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
kind: str,
|
||||
mode: str,
|
||||
account_id: str,
|
||||
display_name: str | None,
|
||||
inbound_sink: ChannelInboundSink,
|
||||
secrets: dict[str, Any] | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
event_recorder: EventRecorder | None = None,
|
||||
client: Any | None = None,
|
||||
application_factory: Callable[[], Any] | None = None,
|
||||
) -> None:
|
||||
self.channel_id = channel_id
|
||||
self.kind = kind
|
||||
self.mode = mode
|
||||
self.account_id = account_id
|
||||
self.display_name = display_name
|
||||
self.inbound_sink = inbound_sink
|
||||
self.secrets = secrets or {}
|
||||
self.config = config or {}
|
||||
self.event_recorder = event_recorder
|
||||
self._client = client
|
||||
self._application_factory = application_factory
|
||||
self._application: Any | None = None
|
||||
self.max_message_chars = int(self.config.get("maxMessageChars") or 4096)
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._client is not None:
|
||||
return
|
||||
if self.mode == "polling":
|
||||
self._application = self._build_application()
|
||||
await self._application.initialize()
|
||||
await self._application.start()
|
||||
if getattr(self._application, "updater", None) is not None:
|
||||
await self._application.updater.start_polling()
|
||||
self._client = self._application.bot
|
||||
return
|
||||
if self.mode == "webhook":
|
||||
self._client = self._build_bot()
|
||||
return
|
||||
raise ValueError(f"Unsupported telegram mode: {self.mode}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._application is None:
|
||||
return
|
||||
updater = getattr(self._application, "updater", None)
|
||||
if updater is not None:
|
||||
await updater.stop()
|
||||
await self._application.stop()
|
||||
await self._application.shutdown()
|
||||
self._application = None
|
||||
|
||||
async def handle_update_payload(self, payload: dict[str, Any]) -> None:
|
||||
message = self._normalize_payload(payload)
|
||||
if message is None:
|
||||
return
|
||||
await self.inbound_sink.accept_inbound(message)
|
||||
|
||||
async def send(self, message: OutboundMessage) -> None:
|
||||
target = outbound_target(message)
|
||||
if not target.peer_id:
|
||||
mark_unclaimed(message)
|
||||
return
|
||||
client = self._require_client()
|
||||
kwargs: dict[str, Any] = {"chat_id": target.peer_id}
|
||||
if target.thread_id:
|
||||
kwargs["message_thread_id"] = int(target.thread_id) if str(target.thread_id).isdigit() else target.thread_id
|
||||
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
|
||||
await client.send_message(**kwargs, text=chunk)
|
||||
|
||||
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
|
||||
data = payload.get("message") or payload.get("edited_message")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
chat = data.get("chat") if isinstance(data.get("chat"), dict) else {}
|
||||
sender = data.get("from") if isinstance(data.get("from"), dict) else {}
|
||||
peer_id = _string_or_none(chat.get("id"))
|
||||
if not peer_id:
|
||||
return None
|
||||
|
||||
chat_type = str(chat.get("type") or "unknown")
|
||||
peer_type = self._peer_type(chat_type)
|
||||
user_id = _string_or_none(sender.get("id"))
|
||||
message_id = _string_or_none(data.get("message_id"))
|
||||
thread_id = _string_or_none(data.get("message_thread_id"))
|
||||
|
||||
content = str(data.get("text") or data.get("caption") or "").strip()
|
||||
media_entries = self._media_entries(data)
|
||||
if media_entries:
|
||||
content = "\n".join([part for part in [content, *media_entries] if part]).strip()
|
||||
if not content:
|
||||
return None
|
||||
|
||||
if peer_type in {"group", "channel"} and not self._group_allowed(peer_id, user_id):
|
||||
return None
|
||||
if peer_type == "dm" and not self._dm_allowed(user_id or peer_id):
|
||||
return None
|
||||
|
||||
if peer_type in {"group", "channel"} and config_bool(self.config, "requireMentionInGroups", default=False):
|
||||
gated = self._strip_required_mention(content)
|
||||
if gated is None:
|
||||
return None
|
||||
content = gated
|
||||
|
||||
metadata = {
|
||||
"chat_id": peer_id,
|
||||
"message_id": message_id,
|
||||
"chat_type": chat_type,
|
||||
}
|
||||
if media_entries:
|
||||
metadata["media"] = media_entries
|
||||
|
||||
return build_inbound_message(
|
||||
channel_id=self.channel_id,
|
||||
kind=self.kind,
|
||||
account_id=self.account_id,
|
||||
peer_id=peer_id,
|
||||
thread_id=thread_id,
|
||||
peer_type=peer_type,
|
||||
user_id=user_id,
|
||||
message_id=message_id,
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _media_entries(self, data: dict[str, Any]) -> list[str]:
|
||||
entries: list[str] = []
|
||||
if data.get("photo"):
|
||||
entries.append(compact_media_summary("photo"))
|
||||
for media_type in ("document", "audio", "video"):
|
||||
value = data.get(media_type)
|
||||
if isinstance(value, dict):
|
||||
entries.append(compact_media_summary(media_type, file_name=_string_or_none(value.get("file_name"))))
|
||||
return entries
|
||||
|
||||
def _strip_required_mention(self, content: str) -> str | None:
|
||||
username = str(self.config.get("botUsername") or "").strip().lstrip("@")
|
||||
if not username:
|
||||
return None
|
||||
mention = f"@{username}"
|
||||
if mention not in content:
|
||||
return None
|
||||
return content.replace(mention, "", 1).strip()
|
||||
|
||||
def _dm_allowed(self, identifier: str | None) -> bool:
|
||||
allowlist = config_list(self.config, "allowFrom")
|
||||
if not allowlist:
|
||||
return True
|
||||
return bool(identifier and identifier in allowlist)
|
||||
|
||||
def _group_allowed(self, peer_id: str | None, user_id: str | None) -> bool:
|
||||
allowlist = config_list(self.config, "groupAllowFrom")
|
||||
if not allowlist:
|
||||
return True
|
||||
return bool((peer_id and peer_id in allowlist) or (user_id and user_id in allowlist))
|
||||
|
||||
def _peer_type(self, chat_type: str) -> str:
|
||||
if chat_type == "private":
|
||||
return "dm"
|
||||
if chat_type in {"group", "supergroup"}:
|
||||
return "group"
|
||||
if chat_type == "channel":
|
||||
return "channel"
|
||||
return chat_type or "unknown"
|
||||
|
||||
def _require_client(self) -> Any:
|
||||
if self._client is None:
|
||||
self._client = self._build_bot()
|
||||
return self._client
|
||||
|
||||
def _build_bot(self) -> Any:
|
||||
token = self._require_secret("botToken")
|
||||
try:
|
||||
from telegram import Bot
|
||||
except ImportError as exc: # pragma: no cover - optional live dependency
|
||||
raise RuntimeError("Install beaver-backend[telegram] to enable TelegramAdapter") from exc
|
||||
return Bot(token=token)
|
||||
|
||||
def _build_application(self) -> Any:
|
||||
if self._application_factory is not None:
|
||||
return self._application_factory()
|
||||
token = self._require_secret("botToken")
|
||||
try:
|
||||
from telegram.ext import Application
|
||||
except ImportError as exc: # pragma: no cover - optional live dependency
|
||||
raise RuntimeError("Install beaver-backend[telegram] to enable TelegramAdapter") from exc
|
||||
|
||||
async def handle(update: Any, context: Any) -> None:
|
||||
if hasattr(update, "to_dict"):
|
||||
await self.handle_update_payload(update.to_dict())
|
||||
|
||||
application = Application.builder().token(token).build()
|
||||
try:
|
||||
from telegram.ext import MessageHandler, filters
|
||||
|
||||
application.add_handler(MessageHandler(filters.ALL, handle))
|
||||
except Exception:
|
||||
pass
|
||||
return application
|
||||
|
||||
def _require_secret(self, key: str) -> str:
|
||||
value = self.secrets.get(key)
|
||||
if not value:
|
||||
raise ValueError(f"{key} is required")
|
||||
return str(value)
|
||||
|
||||
|
||||
def _string_or_none(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
@ -0,0 +1,180 @@
|
||||
"""Weixin channel adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.events import InboundMessage, OutboundMessage
|
||||
from beaver.interfaces.channels.base import ChannelInboundSink
|
||||
|
||||
from .base import (
|
||||
allowed_by_policy,
|
||||
build_inbound_message,
|
||||
chunk_text,
|
||||
compact_media_summary,
|
||||
config_list,
|
||||
mark_unclaimed,
|
||||
outbound_target,
|
||||
)
|
||||
|
||||
EventRecorder = Callable[..., None]
|
||||
|
||||
|
||||
class WeixinAdapter:
|
||||
"""Tencent iLink-style Weixin adapter with injectable client support."""
|
||||
|
||||
KIND = "weixin"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
kind: str,
|
||||
mode: str,
|
||||
account_id: str,
|
||||
display_name: str | None,
|
||||
inbound_sink: ChannelInboundSink,
|
||||
secrets: dict[str, Any] | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
event_recorder: EventRecorder | None = None,
|
||||
client: Any | None = None,
|
||||
) -> None:
|
||||
self.channel_id = channel_id
|
||||
self.kind = kind
|
||||
self.mode = mode
|
||||
self.account_id = account_id
|
||||
self.display_name = display_name
|
||||
self.inbound_sink = inbound_sink
|
||||
self.secrets = secrets or {}
|
||||
self.config = config or {}
|
||||
self.event_recorder = event_recorder
|
||||
self._client = client
|
||||
self.max_message_chars = int(self.config.get("maxMessageChars") or 2000)
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._client is not None:
|
||||
return
|
||||
if self.mode != "polling":
|
||||
raise ValueError(f"Unsupported weixin mode: {self.mode}")
|
||||
self._client = self._build_client()
|
||||
|
||||
async def stop(self) -> None:
|
||||
close = getattr(self._client, "close", None)
|
||||
if close is not None:
|
||||
result = close()
|
||||
if hasattr(result, "__await__"):
|
||||
await result
|
||||
|
||||
async def handle_message_payload(self, payload: dict[str, Any]) -> None:
|
||||
message = self._normalize_payload(payload)
|
||||
if message is None:
|
||||
return
|
||||
await self.inbound_sink.accept_inbound(message)
|
||||
|
||||
async def send(self, message: OutboundMessage) -> None:
|
||||
target = outbound_target(message)
|
||||
if not target.peer_id:
|
||||
mark_unclaimed(message)
|
||||
return
|
||||
client = self._require_client()
|
||||
context_token = self._context_token(message)
|
||||
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
|
||||
await client.send_text(peer_id=target.peer_id, text=chunk, context_token=context_token)
|
||||
|
||||
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
|
||||
sender_id = _string_or_none(payload.get("from") or payload.get("from_user"))
|
||||
room_id = _string_or_none(payload.get("room_id") or payload.get("roomId"))
|
||||
message_id = _string_or_none(payload.get("id") or payload.get("message_id"))
|
||||
message_type = str(payload.get("type") or payload.get("message_type") or "text")
|
||||
|
||||
if room_id:
|
||||
peer_id = room_id
|
||||
peer_type = "group"
|
||||
user_id = sender_id
|
||||
if not allowed_by_policy(
|
||||
policy=self.config.get("groupPolicy"),
|
||||
identifier=peer_id,
|
||||
allowlist=config_list(self.config, "groupAllowFrom"),
|
||||
default="disabled",
|
||||
):
|
||||
return None
|
||||
else:
|
||||
peer_id = sender_id
|
||||
peer_type = "dm"
|
||||
user_id = sender_id
|
||||
if not allowed_by_policy(
|
||||
policy=self.config.get("dmPolicy"),
|
||||
identifier=peer_id,
|
||||
allowlist=config_list(self.config, "allowFrom"),
|
||||
default="open",
|
||||
):
|
||||
return None
|
||||
if not peer_id:
|
||||
return None
|
||||
|
||||
content = self._content(message_type, payload)
|
||||
if not content:
|
||||
return None
|
||||
|
||||
metadata = {
|
||||
"message_id": message_id,
|
||||
"message_type": message_type,
|
||||
}
|
||||
context_token = _string_or_none(payload.get("context_token") or payload.get("contextToken"))
|
||||
if context_token:
|
||||
metadata["context_token"] = context_token
|
||||
if room_id:
|
||||
metadata["room_id"] = room_id
|
||||
|
||||
return build_inbound_message(
|
||||
channel_id=self.channel_id,
|
||||
kind=self.kind,
|
||||
account_id=self.account_id,
|
||||
peer_id=peer_id,
|
||||
peer_type=peer_type,
|
||||
user_id=user_id,
|
||||
message_id=message_id,
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _content(self, message_type: str, payload: dict[str, Any]) -> str:
|
||||
if message_type == "text":
|
||||
return str(payload.get("text") or payload.get("content") or "").strip()
|
||||
file_name = _string_or_none(payload.get("file_name") or payload.get("filename"))
|
||||
return compact_media_summary(message_type, file_name=file_name)
|
||||
|
||||
def _context_token(self, message: OutboundMessage) -> str | None:
|
||||
inbound_metadata = message.metadata.get("inbound_metadata")
|
||||
if isinstance(inbound_metadata, dict):
|
||||
value = _string_or_none(inbound_metadata.get("context_token"))
|
||||
if value:
|
||||
return value
|
||||
return _string_or_none(message.metadata.get("context_token"))
|
||||
|
||||
def _require_client(self) -> Any:
|
||||
if self._client is None:
|
||||
self._client = self._build_client()
|
||||
return self._client
|
||||
|
||||
def _build_client(self) -> Any:
|
||||
self._require_secret("token")
|
||||
try:
|
||||
import aiohttp # noqa: F401
|
||||
except ImportError as exc: # pragma: no cover - optional live dependency
|
||||
raise RuntimeError("Install beaver-backend[weixin] to enable WeixinAdapter") from exc
|
||||
raise RuntimeError("Weixin live client is not configured for direct construction")
|
||||
|
||||
def _require_secret(self, key: str) -> str:
|
||||
value = self.secrets.get(key)
|
||||
if not value:
|
||||
raise ValueError(f"{key} is required")
|
||||
return str(value)
|
||||
|
||||
|
||||
def _string_or_none(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
526
app-instance/backend/beaver/interfaces/channels/runtime.py
Normal file
526
app-instance/backend/beaver/interfaces/channels/runtime.py
Normal file
@ -0,0 +1,526 @@
|
||||
"""Channel runtime host for adapter lifecycle and bus-first routing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.config.schema import ChannelConfig
|
||||
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
|
||||
from beaver.interfaces.channels.base import ChannelAdapter
|
||||
from beaver.interfaces.channels.manager import ChannelManager
|
||||
from beaver.interfaces.channels.state import ChannelDedupeStore, ChannelEventLog
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _channel_capabilities(kind: str, mode: str) -> list[str]:
|
||||
if kind == "webhook":
|
||||
return ["receive_text", "send_text", "sync_webhook_response"]
|
||||
if kind == "terminal" and mode == "websocket":
|
||||
return ["receive_text", "send_text", "persistent_connection"]
|
||||
if kind in {"feishu", "qqbot", "telegram"}:
|
||||
return ["receive_text", "send_text", "receive_media", "groups"]
|
||||
if kind == "weixin":
|
||||
return ["receive_text", "send_text", "receive_media", "direct_messages"]
|
||||
return []
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChannelAcceptResult:
|
||||
accepted: bool
|
||||
duplicate: bool = False
|
||||
pending: bool = False
|
||||
rejected: bool = False
|
||||
session_id: str | None = None
|
||||
dedupe_key: str | None = None
|
||||
record: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ChannelRuntime:
|
||||
"""Own channel adapters, state, and the inbound/outbound bus bridge."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
service: AgentService,
|
||||
workspace: Path,
|
||||
channels: dict[str, ChannelConfig],
|
||||
bus: MessageBus | None = None,
|
||||
) -> None:
|
||||
self.service = service
|
||||
self.workspace = Path(workspace)
|
||||
self.bus = bus or MessageBus()
|
||||
self.manager = ChannelManager(self.bus)
|
||||
self.channel_configs = dict(channels)
|
||||
self.adapters: dict[str, ChannelAdapter] = {}
|
||||
self.states: dict[str, dict[str, Any]] = {}
|
||||
state_dir = self.workspace / "state" / "channels"
|
||||
retention = self._default_dedupe_retention_hours()
|
||||
self.dedupe = ChannelDedupeStore(state_dir / "dedupe.json", retention_hours=retention)
|
||||
self.events = ChannelEventLog(state_dir / "events.jsonl")
|
||||
self._bridge_task: asyncio.Task[None] | None = None
|
||||
self._dispatch_task: asyncio.Task[None] | None = None
|
||||
self._stop_event = asyncio.Event()
|
||||
self._dispatch_stop_event = asyncio.Event()
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
|
||||
async def start(self) -> None:
|
||||
self._stop_event.clear()
|
||||
self._dispatch_stop_event.clear()
|
||||
for channel_id, cfg in self.channel_configs.items():
|
||||
if not cfg.enabled:
|
||||
self.states[channel_id] = {"state": "disabled", "last_error": None}
|
||||
continue
|
||||
try:
|
||||
adapter = self._build_adapter(channel_id, cfg)
|
||||
self.adapters[channel_id] = adapter
|
||||
self.manager.register(adapter)
|
||||
await adapter.start()
|
||||
self.states[channel_id] = {
|
||||
"state": "running",
|
||||
"last_error": None,
|
||||
"started_at": _iso_now(),
|
||||
}
|
||||
self.events.record(channel_id=channel_id, kind="adapter_started")
|
||||
except Exception as exc: # pragma: no cover - defensive startup isolation
|
||||
self.states[channel_id] = {"state": "error", "last_error": str(exc)}
|
||||
self.events.record(
|
||||
channel_id=channel_id,
|
||||
kind="adapter_error",
|
||||
status="error",
|
||||
error=str(exc),
|
||||
)
|
||||
self._bridge_task = asyncio.create_task(self._bridge_inbound_to_agent())
|
||||
self._dispatch_task = asyncio.create_task(
|
||||
self.manager.dispatch_outbound(
|
||||
self._dispatch_stop_event,
|
||||
on_delivered=self._record_outbound_delivered,
|
||||
on_failed=self._record_outbound_failed,
|
||||
)
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
if self._bridge_task is not None:
|
||||
self._bridge_task.cancel()
|
||||
try:
|
||||
await self._bridge_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._dispatch_stop_event.set()
|
||||
if self._dispatch_task is not None:
|
||||
try:
|
||||
await asyncio.wait_for(self._dispatch_task, timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
self._dispatch_task.cancel()
|
||||
try:
|
||||
await self._dispatch_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self.manager.stop()
|
||||
for channel_id in self.adapters:
|
||||
self.events.record(channel_id=channel_id, kind="adapter_stopped")
|
||||
|
||||
async def add_channel(self, channel_id: str, config: ChannelConfig) -> None:
|
||||
async with self._lifecycle_lock:
|
||||
current = self.channel_configs.get(channel_id)
|
||||
if current == config and channel_id in self.adapters:
|
||||
return
|
||||
if not config.enabled:
|
||||
await self._remove_channel_locked(channel_id)
|
||||
self.channel_configs[channel_id] = config
|
||||
self.states[channel_id] = {"state": "disabled", "last_error": None}
|
||||
return
|
||||
|
||||
adapter = self._build_adapter(channel_id, config)
|
||||
await adapter.start()
|
||||
old_adapter = self.adapters.get(channel_id)
|
||||
self.manager.replace_registered(adapter)
|
||||
self.adapters[channel_id] = adapter
|
||||
self.channel_configs[channel_id] = config
|
||||
self.states[channel_id] = {"state": "running", "last_error": None, "started_at": _iso_now()}
|
||||
self.events.record(channel_id=channel_id, kind="adapter_started")
|
||||
if old_adapter is not None and old_adapter is not adapter:
|
||||
await old_adapter.stop()
|
||||
|
||||
async def remove_channel(self, channel_id: str) -> None:
|
||||
async with self._lifecycle_lock:
|
||||
await self._remove_channel_locked(channel_id)
|
||||
|
||||
async def _remove_channel_locked(self, channel_id: str) -> None:
|
||||
adapter = self.adapters.pop(channel_id, None)
|
||||
self.manager.unregister(channel_id)
|
||||
self.channel_configs.pop(channel_id, None)
|
||||
if adapter is not None:
|
||||
await adapter.stop()
|
||||
self.events.record(channel_id=channel_id, kind="adapter_stopped")
|
||||
self.states[channel_id] = {"state": "removed", "last_error": None}
|
||||
|
||||
async def accept_inbound(self, message: InboundMessage) -> ChannelAcceptResult:
|
||||
identity = message.channel_identity
|
||||
if identity is None:
|
||||
self.events.record(
|
||||
channel_id=message.channel,
|
||||
kind="inbound_rejected",
|
||||
status="error",
|
||||
error="channel_identity is required",
|
||||
)
|
||||
return ChannelAcceptResult(
|
||||
accepted=False,
|
||||
rejected=True,
|
||||
error="channel_identity is required",
|
||||
)
|
||||
|
||||
validation_error = identity.validation_error()
|
||||
if validation_error:
|
||||
self.events.record(
|
||||
channel_id=identity.channel_id,
|
||||
kind="inbound_rejected",
|
||||
status="error",
|
||||
error=validation_error,
|
||||
)
|
||||
return ChannelAcceptResult(accepted=False, rejected=True, error=validation_error)
|
||||
|
||||
expected_session_id = identity.session_id()
|
||||
if message.session_id != expected_session_id:
|
||||
self.events.record(
|
||||
channel_id=identity.channel_id,
|
||||
kind="session_id_normalized",
|
||||
session_id=expected_session_id,
|
||||
message_id=identity.message_id,
|
||||
)
|
||||
message.session_id = expected_session_id
|
||||
message.channel = identity.channel_id
|
||||
|
||||
dedupe_key = identity.dedupe_key()
|
||||
if dedupe_key:
|
||||
write = self.dedupe.mark_processing(
|
||||
dedupe_key=dedupe_key,
|
||||
session_id=expected_session_id,
|
||||
message_id=identity.message_id or "",
|
||||
)
|
||||
if not write.created:
|
||||
record = write.record or {}
|
||||
self.events.record(
|
||||
channel_id=identity.channel_id,
|
||||
kind="inbound_duplicate",
|
||||
session_id=expected_session_id,
|
||||
message_id=identity.message_id,
|
||||
status=str(record.get("status") or "processing"),
|
||||
)
|
||||
return ChannelAcceptResult(
|
||||
accepted=False,
|
||||
duplicate=True,
|
||||
pending=record.get("status") == "processing",
|
||||
session_id=expected_session_id,
|
||||
dedupe_key=dedupe_key,
|
||||
record=record,
|
||||
)
|
||||
|
||||
self.events.record(
|
||||
channel_id=identity.channel_id,
|
||||
kind="inbound_accepted",
|
||||
session_id=expected_session_id,
|
||||
message_id=identity.message_id,
|
||||
text=message.content,
|
||||
)
|
||||
await self.bus.publish_inbound(message)
|
||||
return ChannelAcceptResult(
|
||||
accepted=True,
|
||||
session_id=expected_session_id,
|
||||
dedupe_key=dedupe_key,
|
||||
)
|
||||
|
||||
def statuses(self) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
recent = self.events.recent(limit=500)
|
||||
last_by_channel = {event["channel_id"]: event for event in recent if event.get("channel_id")}
|
||||
for channel_id, cfg in self.channel_configs.items():
|
||||
state = self.states.get(channel_id, {"state": "configured", "last_error": None})
|
||||
capabilities = _channel_capabilities(cfg.kind, cfg.mode)
|
||||
webhook_url = None
|
||||
websocket_url = None
|
||||
connected_peers = 0
|
||||
if cfg.kind == "webhook":
|
||||
webhook_url = f"/api/channels/{channel_id}/webhook"
|
||||
elif cfg.kind == "terminal" and cfg.mode == "websocket":
|
||||
websocket_url = f"/api/channels/{channel_id}/ws"
|
||||
adapter = self.adapters.get(channel_id)
|
||||
if adapter is not None and hasattr(adapter, "status_extra"):
|
||||
extra = adapter.status_extra() # type: ignore[attr-defined]
|
||||
connected_peers = int(extra.get("connected_peers") or 0)
|
||||
items.append(
|
||||
{
|
||||
"channel_id": channel_id,
|
||||
"name": channel_id,
|
||||
"kind": cfg.kind,
|
||||
"mode": cfg.mode,
|
||||
"display_name": cfg.display_name or channel_id,
|
||||
"enabled": cfg.enabled,
|
||||
"state": state.get("state", "configured"),
|
||||
"account_id": cfg.account_id,
|
||||
"last_error": state.get("last_error"),
|
||||
"started_at": state.get("started_at"),
|
||||
"last_event_at": last_by_channel.get(channel_id, {}).get("created_at"),
|
||||
"capabilities": capabilities,
|
||||
"webhook_url": webhook_url,
|
||||
"websocket_url": websocket_url,
|
||||
"connected_peers": connected_peers,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
def recent_events(self, channel_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
return self.events.recent(channel_id=channel_id, limit=limit)
|
||||
|
||||
def record_event(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
kind: str,
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
status: str = "ok",
|
||||
error: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.events.record(
|
||||
channel_id=channel_id,
|
||||
kind=kind,
|
||||
session_id=session_id,
|
||||
message_id=message_id,
|
||||
run_id=run_id,
|
||||
status=status,
|
||||
error=error,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _build_adapter(self, channel_id: str, cfg: ChannelConfig) -> ChannelAdapter:
|
||||
if cfg.kind == "webhook" and cfg.mode == "webhook":
|
||||
from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter
|
||||
|
||||
return GenericWebhookAdapter(
|
||||
channel_id=channel_id,
|
||||
kind=cfg.kind,
|
||||
mode=cfg.mode,
|
||||
account_id=cfg.account_id,
|
||||
display_name=cfg.display_name,
|
||||
inbound_sink=self,
|
||||
response_timeout_seconds=float(cfg.config.get("response_timeout_seconds") or 1800),
|
||||
)
|
||||
|
||||
if cfg.kind == "terminal" and cfg.mode == "websocket":
|
||||
from beaver.interfaces.channels.terminal_websocket import TerminalWebSocketAdapter
|
||||
|
||||
return TerminalWebSocketAdapter(
|
||||
channel_id=channel_id,
|
||||
kind=cfg.kind,
|
||||
mode=cfg.mode,
|
||||
account_id=cfg.account_id,
|
||||
display_name=cfg.display_name,
|
||||
inbound_sink=self,
|
||||
event_recorder=self.record_event,
|
||||
heartbeat_seconds=float(cfg.config.get("heartbeat_seconds") or 30),
|
||||
max_message_chars=int(cfg.config.get("max_message_chars") or 20000),
|
||||
)
|
||||
|
||||
if cfg.kind == "telegram" and cfg.mode in {"polling", "webhook"}:
|
||||
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
|
||||
|
||||
return TelegramAdapter(
|
||||
channel_id=channel_id,
|
||||
kind=cfg.kind,
|
||||
mode=cfg.mode,
|
||||
account_id=cfg.account_id,
|
||||
display_name=cfg.display_name,
|
||||
inbound_sink=self,
|
||||
secrets=cfg.secrets,
|
||||
config=cfg.config,
|
||||
event_recorder=self.record_event,
|
||||
)
|
||||
|
||||
if cfg.kind == "feishu" and cfg.mode in {"websocket", "webhook"}:
|
||||
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
|
||||
|
||||
return FeishuAdapter(
|
||||
channel_id=channel_id,
|
||||
kind=cfg.kind,
|
||||
mode=cfg.mode,
|
||||
account_id=cfg.account_id,
|
||||
display_name=cfg.display_name,
|
||||
inbound_sink=self,
|
||||
secrets=cfg.secrets,
|
||||
config=cfg.config,
|
||||
event_recorder=self.record_event,
|
||||
)
|
||||
|
||||
if cfg.kind == "qqbot" and cfg.mode == "websocket":
|
||||
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
|
||||
|
||||
return QQBotAdapter(
|
||||
channel_id=channel_id,
|
||||
kind=cfg.kind,
|
||||
mode=cfg.mode,
|
||||
account_id=cfg.account_id,
|
||||
display_name=cfg.display_name,
|
||||
inbound_sink=self,
|
||||
secrets=cfg.secrets,
|
||||
config=cfg.config,
|
||||
event_recorder=self.record_event,
|
||||
)
|
||||
|
||||
if cfg.kind == "weixin" and cfg.mode == "polling":
|
||||
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
|
||||
|
||||
return WeixinAdapter(
|
||||
channel_id=channel_id,
|
||||
kind=cfg.kind,
|
||||
mode=cfg.mode,
|
||||
account_id=cfg.account_id,
|
||||
display_name=cfg.display_name,
|
||||
inbound_sink=self,
|
||||
secrets=cfg.secrets,
|
||||
config=cfg.config,
|
||||
event_recorder=self.record_event,
|
||||
)
|
||||
|
||||
if cfg.kind == "external_connector" and cfg.mode == "http":
|
||||
import os
|
||||
|
||||
from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient
|
||||
from beaver.interfaces.channels.external_connector import ExternalConnectorChannel
|
||||
|
||||
base_url = str(cfg.config.get("sidecarBaseUrl") or os.getenv("EXTERNAL_CONNECTOR_BASE_URL") or "").strip()
|
||||
token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "")
|
||||
platform_kind = str(cfg.config.get("platformKind") or "").strip()
|
||||
connection_id = str(cfg.config.get("connectionId") or "").strip()
|
||||
if not base_url:
|
||||
raise ValueError("external connector sidecarBaseUrl is required")
|
||||
if not platform_kind:
|
||||
raise ValueError("external connector platformKind is required")
|
||||
if not connection_id:
|
||||
raise ValueError("external connector connectionId is required")
|
||||
return ExternalConnectorChannel(
|
||||
channel_id=channel_id,
|
||||
platform_kind=platform_kind,
|
||||
connection_id=connection_id,
|
||||
account_id=cfg.account_id,
|
||||
display_name=cfg.display_name,
|
||||
sidecar_client=ConnectorSidecarClient(base_url=base_url, token=token),
|
||||
)
|
||||
|
||||
raise ValueError(f"Unsupported channel kind/mode: {cfg.kind}/{cfg.mode}")
|
||||
|
||||
async def _bridge_inbound_to_agent(self) -> None:
|
||||
current_inbound: InboundMessage | None = None
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
current_inbound = await asyncio.wait_for(self.bus.consume_inbound(), timeout=0.25)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
inbound = current_inbound
|
||||
identity = inbound.channel_identity
|
||||
try:
|
||||
self.events.record(
|
||||
channel_id=inbound.channel,
|
||||
kind="direct_run_started",
|
||||
session_id=inbound.session_id,
|
||||
message_id=identity.message_id if identity else inbound.message_id,
|
||||
)
|
||||
outbound = await self.service.handle_inbound_message(inbound)
|
||||
except asyncio.CancelledError:
|
||||
outbound = AgentService.build_outbound_error(
|
||||
inbound,
|
||||
detail="Channel runtime stopped before completing the inbound message",
|
||||
finish_reason="cancelled",
|
||||
)
|
||||
self._mark_dedupe_result(inbound, outbound)
|
||||
await self.bus.publish_outbound(outbound)
|
||||
current_inbound = None
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.events.record(
|
||||
channel_id=inbound.channel,
|
||||
kind="direct_run_failed",
|
||||
session_id=inbound.session_id,
|
||||
message_id=identity.message_id if identity else inbound.message_id,
|
||||
status="error",
|
||||
error=str(exc),
|
||||
)
|
||||
outbound = AgentService.build_outbound_error(
|
||||
inbound,
|
||||
detail=str(exc),
|
||||
finish_reason="error",
|
||||
)
|
||||
else:
|
||||
self.events.record(
|
||||
channel_id=outbound.channel,
|
||||
kind="direct_run_finished",
|
||||
session_id=outbound.session_id,
|
||||
message_id=identity.message_id if identity else inbound.message_id,
|
||||
run_id=outbound.run_id,
|
||||
)
|
||||
self._mark_dedupe_result(inbound, outbound)
|
||||
await self.bus.publish_outbound(outbound)
|
||||
current_inbound = None
|
||||
|
||||
def _mark_dedupe_result(self, inbound: InboundMessage, outbound: OutboundMessage) -> None:
|
||||
identity = inbound.channel_identity
|
||||
dedupe_key = identity.dedupe_key() if identity else None
|
||||
if not dedupe_key:
|
||||
return
|
||||
cfg = self.channel_configs.get(identity.channel_id)
|
||||
max_reply_chars = int((cfg.config if cfg else {}).get("max_cached_reply_chars") or 20000)
|
||||
max_error_chars = int((cfg.config if cfg else {}).get("max_cached_error_chars") or 4000)
|
||||
if outbound.finish_reason == "error":
|
||||
self.dedupe.mark_error(
|
||||
dedupe_key=dedupe_key,
|
||||
error=outbound.content,
|
||||
max_error_chars=max_error_chars,
|
||||
)
|
||||
else:
|
||||
self.dedupe.mark_done(
|
||||
dedupe_key=dedupe_key,
|
||||
run_id=outbound.run_id,
|
||||
reply=outbound.content,
|
||||
max_reply_chars=max_reply_chars,
|
||||
)
|
||||
|
||||
async def _record_outbound_delivered(self, message: OutboundMessage) -> None:
|
||||
kind = "outbound_unclaimed" if message.metadata.get("delivery_status") == "unclaimed" else "outbound_delivered"
|
||||
self.events.record(
|
||||
channel_id=message.channel,
|
||||
kind=kind,
|
||||
session_id=message.session_id,
|
||||
message_id=message.channel_identity.message_id if message.channel_identity else message.message_id,
|
||||
run_id=message.run_id,
|
||||
)
|
||||
|
||||
async def _record_outbound_failed(self, message: OutboundMessage, exc: Exception | None) -> None:
|
||||
self.events.record(
|
||||
channel_id=message.channel,
|
||||
kind="outbound_delivery_failed",
|
||||
session_id=message.session_id,
|
||||
message_id=message.channel_identity.message_id if message.channel_identity else message.message_id,
|
||||
run_id=message.run_id,
|
||||
status="error",
|
||||
error=str(exc) if exc else "channel not registered",
|
||||
)
|
||||
|
||||
def _default_dedupe_retention_hours(self) -> int:
|
||||
for cfg in self.channel_configs.values():
|
||||
value = cfg.config.get("dedupe_retention_hours")
|
||||
if value is not None:
|
||||
return int(value)
|
||||
return 48
|
||||
198
app-instance/backend/beaver/interfaces/channels/state.py
Normal file
198
app-instance/backend/beaver/interfaces/channels/state.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""Persistent channel runtime state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DedupeWriteResult:
|
||||
created: bool
|
||||
record: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ChannelDedupeStore:
|
||||
def __init__(self, path: Path, *, retention_hours: int = 48) -> None:
|
||||
self.path = path
|
||||
self.retention_ms = max(1, int(retention_hours)) * 60 * 60 * 1000
|
||||
self._lock = Lock()
|
||||
|
||||
def get(self, dedupe_key: str) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
self._prune_unlocked(data, _now_ms())
|
||||
record = data["records"].get(dedupe_key)
|
||||
self._save(data)
|
||||
return record
|
||||
|
||||
def mark_processing(self, *, dedupe_key: str, session_id: str, message_id: str) -> DedupeWriteResult:
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
now_ms = _now_ms()
|
||||
self._prune_unlocked(data, now_ms)
|
||||
existing = data["records"].get(dedupe_key)
|
||||
if existing is not None:
|
||||
self._save(data)
|
||||
return DedupeWriteResult(created=False, record=existing)
|
||||
|
||||
record = {
|
||||
"dedupe_key": dedupe_key,
|
||||
"status": "processing",
|
||||
"session_id": session_id,
|
||||
"message_id": message_id,
|
||||
"run_id": None,
|
||||
"reply": None,
|
||||
"error": None,
|
||||
"created_at_ms": now_ms,
|
||||
"updated_at_ms": now_ms,
|
||||
}
|
||||
data["records"][dedupe_key] = record
|
||||
self._save(data)
|
||||
return DedupeWriteResult(created=True, record=record)
|
||||
|
||||
def mark_done(
|
||||
self,
|
||||
*,
|
||||
dedupe_key: str,
|
||||
run_id: str | None,
|
||||
reply: str,
|
||||
max_reply_chars: int,
|
||||
) -> None:
|
||||
self._mark_result(
|
||||
dedupe_key=dedupe_key,
|
||||
status="done",
|
||||
run_id=run_id,
|
||||
reply=reply[: max(0, int(max_reply_chars))],
|
||||
error=None,
|
||||
)
|
||||
|
||||
def mark_error(self, *, dedupe_key: str, error: str, max_error_chars: int) -> None:
|
||||
self._mark_result(
|
||||
dedupe_key=dedupe_key,
|
||||
status="error",
|
||||
run_id=None,
|
||||
reply=None,
|
||||
error=error[: max(0, int(max_error_chars))],
|
||||
)
|
||||
|
||||
def _mark_result(
|
||||
self,
|
||||
*,
|
||||
dedupe_key: str,
|
||||
status: str,
|
||||
run_id: str | None,
|
||||
reply: str | None,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
record = data["records"].get(dedupe_key)
|
||||
if record is None:
|
||||
record = {"dedupe_key": dedupe_key, "created_at_ms": _now_ms()}
|
||||
data["records"][dedupe_key] = record
|
||||
record.update(
|
||||
{
|
||||
"status": status,
|
||||
"run_id": run_id,
|
||||
"reply": reply,
|
||||
"error": error,
|
||||
"updated_at_ms": _now_ms(),
|
||||
}
|
||||
)
|
||||
self._save(data)
|
||||
|
||||
def _load(self) -> dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
return {"records": {}}
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"records": {}}
|
||||
if not isinstance(data, dict) or not isinstance(data.get("records"), dict):
|
||||
return {"records": {}}
|
||||
return data
|
||||
|
||||
def _save(self, data: dict[str, Any]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
|
||||
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
tmp_path.replace(self.path)
|
||||
|
||||
def _prune_unlocked(self, data: dict[str, Any], now_ms: int) -> None:
|
||||
records = data.get("records", {})
|
||||
expired_before = now_ms - self.retention_ms
|
||||
for key, record in list(records.items()):
|
||||
updated_at_ms = int(record.get("updated_at_ms") or record.get("created_at_ms") or 0)
|
||||
if updated_at_ms < expired_before:
|
||||
records.pop(key, None)
|
||||
|
||||
|
||||
class ChannelEventLog:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
self._lock = Lock()
|
||||
|
||||
def record(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
kind: str,
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
status: str = "ok",
|
||||
error: str | None = None,
|
||||
text: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
entry = {
|
||||
"event_id": uuid4().hex,
|
||||
"channel_id": channel_id,
|
||||
"kind": kind,
|
||||
"session_id": session_id,
|
||||
"message_id": message_id,
|
||||
"run_id": run_id,
|
||||
"status": status,
|
||||
"error": error,
|
||||
"text_preview": (text or "")[:120] if text else None,
|
||||
"text_length": len(text) if text else 0,
|
||||
"metadata": metadata or {},
|
||||
"created_at": _iso_now(),
|
||||
}
|
||||
with self._lock:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
return entry
|
||||
|
||||
def recent(self, *, channel_id: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
|
||||
if not self.path.exists():
|
||||
return []
|
||||
lines = self.path.read_text(encoding="utf-8").splitlines()
|
||||
items: list[dict[str, Any]] = []
|
||||
for line in reversed(lines):
|
||||
try:
|
||||
item = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if channel_id and item.get("channel_id") != channel_id:
|
||||
continue
|
||||
items.append(item)
|
||||
if len(items) >= max(1, int(limit)):
|
||||
break
|
||||
return list(reversed(items))
|
||||
@ -0,0 +1,301 @@
|
||||
"""Text-only terminal WebSocket channel adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
|
||||
from beaver.interfaces.channels.base import ChannelInboundSink
|
||||
|
||||
try:
|
||||
from fastapi import WebSocket
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
except ModuleNotFoundError: # pragma: no cover - import-only fallback
|
||||
class WebSocketDisconnect(Exception):
|
||||
"""Fallback disconnect exception for skeleton import environments."""
|
||||
|
||||
class WebSocket: # type: ignore[override]
|
||||
"""Fallback websocket annotation shim."""
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TerminalConnection:
|
||||
websocket: WebSocket
|
||||
peer_id: str
|
||||
session_id: str
|
||||
thread_id: str | None = None
|
||||
user_id: str | None = None
|
||||
device_name: str = ""
|
||||
capabilities: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class TerminalWebSocketAdapter:
|
||||
"""Accept text terminal websocket frames and deliver final assistant replies."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
kind: str,
|
||||
mode: str,
|
||||
account_id: str,
|
||||
display_name: str = "",
|
||||
inbound_sink: ChannelInboundSink,
|
||||
event_recorder: Callable[..., None] | None = None,
|
||||
heartbeat_seconds: float = 30,
|
||||
max_message_chars: int = 20000,
|
||||
) -> None:
|
||||
self.channel_id = channel_id
|
||||
self.kind = kind
|
||||
self.mode = mode
|
||||
self.account_id = account_id
|
||||
self.display_name = display_name or channel_id
|
||||
self.inbound_sink = inbound_sink
|
||||
self.event_recorder = event_recorder
|
||||
self.heartbeat_seconds = max(1.0, float(heartbeat_seconds))
|
||||
self.max_message_chars = max(1, int(max_message_chars))
|
||||
self.started = False
|
||||
self._connections_by_session: dict[str, TerminalConnection] = {}
|
||||
self._session_by_peer: dict[str, str] = {}
|
||||
|
||||
async def start(self) -> None:
|
||||
self.started = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.started = False
|
||||
for connection in list(self._connections_by_session.values()):
|
||||
with suppress(Exception):
|
||||
await connection.websocket.close(code=1001)
|
||||
self._connections_by_session.clear()
|
||||
self._session_by_peer.clear()
|
||||
|
||||
def status_extra(self) -> dict[str, Any]:
|
||||
return {"connected_peers": len(self._connections_by_session)}
|
||||
|
||||
async def handle_websocket(self, websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
connection: TerminalConnection | None = None
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
payload = await websocket.receive_json()
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except ValueError:
|
||||
await websocket.send_json({"type": "error", "error": "Invalid websocket JSON payload"})
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
await websocket.send_json({"type": "error", "error": "Websocket payload must be a JSON object"})
|
||||
continue
|
||||
|
||||
frame_type = _clean(payload.get("type")).lower()
|
||||
if frame_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
continue
|
||||
if frame_type == "connect":
|
||||
connection = await self._handle_connect(websocket, payload, current=connection)
|
||||
continue
|
||||
if frame_type == "message":
|
||||
if connection is None:
|
||||
await websocket.send_json({"type": "error", "error": "connect is required before message"})
|
||||
continue
|
||||
await self._handle_message(websocket, connection, payload)
|
||||
continue
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Unsupported websocket frame type: {frame_type or '<empty>'}",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
if connection is not None:
|
||||
self._remove_connection(connection)
|
||||
self._record(
|
||||
kind="terminal_disconnected",
|
||||
session_id=connection.session_id,
|
||||
metadata={"peer_id": connection.peer_id, "device_name": connection.device_name},
|
||||
)
|
||||
|
||||
async def _handle_connect(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
current: TerminalConnection | None,
|
||||
) -> TerminalConnection | None:
|
||||
peer_id = _clean(payload.get("peer_id"))
|
||||
if not peer_id:
|
||||
await websocket.send_json({"type": "error", "error": "peer_id is required"})
|
||||
return current
|
||||
|
||||
thread_id = _clean(payload.get("thread_id")) or None
|
||||
user_id = _clean(payload.get("user_id")) or None
|
||||
device_name = _clean(payload.get("device_name"))
|
||||
capabilities = [str(item) for item in payload.get("capabilities") or [] if item is not None]
|
||||
identity = ChannelIdentity(
|
||||
channel_id=self.channel_id,
|
||||
kind=self.kind,
|
||||
account_id=self.account_id,
|
||||
peer_id=peer_id,
|
||||
thread_id=thread_id,
|
||||
peer_type="terminal",
|
||||
user_id=user_id,
|
||||
)
|
||||
session_id = identity.session_id()
|
||||
connection = TerminalConnection(
|
||||
websocket=websocket,
|
||||
peer_id=peer_id,
|
||||
session_id=session_id,
|
||||
thread_id=thread_id,
|
||||
user_id=user_id,
|
||||
device_name=device_name,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
|
||||
if current is not None and current.session_id != session_id:
|
||||
self._remove_connection(current)
|
||||
old = self._connections_by_session.get(session_id)
|
||||
if old is not None and old.websocket is not websocket:
|
||||
with suppress(Exception):
|
||||
await old.websocket.close(code=1000)
|
||||
self._connections_by_session[session_id] = connection
|
||||
self._session_by_peer[peer_id] = session_id
|
||||
self._record(
|
||||
kind="terminal_connected",
|
||||
session_id=session_id,
|
||||
metadata={"peer_id": peer_id, "device_name": device_name, "capabilities": capabilities},
|
||||
)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "connected",
|
||||
"channel_id": self.channel_id,
|
||||
"session_id": session_id,
|
||||
}
|
||||
)
|
||||
return connection
|
||||
|
||||
async def _handle_message(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
connection: TerminalConnection,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
message_id = _clean(payload.get("message_id"))
|
||||
text = _clean(payload.get("text"))
|
||||
if not message_id:
|
||||
await websocket.send_json({"type": "error", "error": "message_id is required"})
|
||||
return
|
||||
if not text:
|
||||
await websocket.send_json({"type": "error", "error": "text is required"})
|
||||
return
|
||||
if len(text) > self.max_message_chars:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"text exceeds max_message_chars ({self.max_message_chars})",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
thread_id = _clean(payload.get("thread_id")) or connection.thread_id
|
||||
user_id = _clean(payload.get("user_id")) or connection.user_id
|
||||
identity = ChannelIdentity(
|
||||
channel_id=self.channel_id,
|
||||
kind=self.kind,
|
||||
account_id=self.account_id,
|
||||
peer_id=connection.peer_id,
|
||||
thread_id=thread_id,
|
||||
peer_type="terminal",
|
||||
user_id=user_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
inbound = InboundMessage(
|
||||
channel=self.channel_id,
|
||||
content=text,
|
||||
content_type="text",
|
||||
user_id=user_id,
|
||||
channel_identity=identity,
|
||||
metadata={
|
||||
"terminal": {
|
||||
"peer_id": connection.peer_id,
|
||||
"device_name": connection.device_name,
|
||||
"capabilities": connection.capabilities,
|
||||
}
|
||||
},
|
||||
)
|
||||
accept = await self.inbound_sink.accept_inbound(inbound)
|
||||
ack: dict[str, Any] = {
|
||||
"type": "ack",
|
||||
"message_id": message_id,
|
||||
"session_id": accept.session_id or identity.session_id(),
|
||||
"accepted": accept.accepted,
|
||||
}
|
||||
if accept.duplicate:
|
||||
ack["duplicate"] = True
|
||||
ack["pending"] = accept.pending
|
||||
record = accept.record or {}
|
||||
if record.get("reply"):
|
||||
ack["reply"] = record["reply"]
|
||||
if accept.error or record.get("error"):
|
||||
ack["error"] = accept.error or record.get("error")
|
||||
await websocket.send_json(ack)
|
||||
|
||||
async def send(self, message: OutboundMessage) -> None:
|
||||
session_id = message.session_id
|
||||
if not session_id and message.channel_identity is not None:
|
||||
session_id = message.channel_identity.session_id()
|
||||
connection = self._connections_by_session.get(session_id or "")
|
||||
if connection is None:
|
||||
message.metadata["delivery_status"] = "unclaimed"
|
||||
return
|
||||
|
||||
payload = {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"message_id": message.channel_identity.message_id if message.channel_identity else message.message_id,
|
||||
"run_id": message.run_id,
|
||||
"text": message.content,
|
||||
"finish_reason": message.finish_reason,
|
||||
}
|
||||
try:
|
||||
await connection.websocket.send_json(payload)
|
||||
except Exception:
|
||||
message.metadata["delivery_status"] = "unclaimed"
|
||||
self._remove_connection(connection)
|
||||
|
||||
def _remove_connection(self, connection: TerminalConnection) -> None:
|
||||
current = self._connections_by_session.get(connection.session_id)
|
||||
if current is connection:
|
||||
self._connections_by_session.pop(connection.session_id, None)
|
||||
if self._session_by_peer.get(connection.peer_id) == connection.session_id:
|
||||
self._session_by_peer.pop(connection.peer_id, None)
|
||||
|
||||
def _record(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
session_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
status: str = "ok",
|
||||
error: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
if self.event_recorder is None:
|
||||
return
|
||||
self.event_recorder(
|
||||
channel_id=self.channel_id,
|
||||
kind=kind,
|
||||
session_id=session_id,
|
||||
message_id=message_id,
|
||||
status=status,
|
||||
error=error,
|
||||
metadata=metadata,
|
||||
)
|
||||
@ -19,6 +19,18 @@ from typing import Any
|
||||
|
||||
from beaver.engine.providers.registry import PROVIDERS, find_by_name
|
||||
from beaver.foundation.config import default_config_path, load_config
|
||||
from beaver.foundation.events import ChannelIdentity, InboundMessage
|
||||
from beaver.interfaces.channels.runtime import ChannelRuntime
|
||||
from beaver.interfaces.channels.connections import (
|
||||
ChannelConnectionStore,
|
||||
ChannelConnectorRegistry,
|
||||
ConnectorSidecarClient,
|
||||
CredentialStore,
|
||||
FeishuConnector,
|
||||
MessageDedupeStore,
|
||||
TelegramConnector,
|
||||
WeixinConnector,
|
||||
)
|
||||
from beaver.foundation.models import CronExecutionResult, CronRunRecord
|
||||
from beaver.integrations.mcp import MCPConnectionManager
|
||||
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
|
||||
@ -51,6 +63,18 @@ from .schemas import (
|
||||
WebChatRequest,
|
||||
WebChatResponse,
|
||||
WebErrorResponse,
|
||||
WebAgentConfigRequest,
|
||||
WebAgentConfigResponse,
|
||||
WebChannelConfigRequest,
|
||||
WebChannelConfigResponse,
|
||||
WebChannelConnectionCreateRequest,
|
||||
WebChannelConnectionResponse,
|
||||
WebChannelConnectionUpdateRequest,
|
||||
WebChannelValidationResponse,
|
||||
WebConnectorBridgeEventRequest,
|
||||
WebConnectorBridgeEventResponse,
|
||||
WebConnectorSessionCreateRequest,
|
||||
WebConnectorSessionResponse,
|
||||
WebProviderConfigRequest,
|
||||
WebProviderConfigResponse,
|
||||
WebStatusResponse,
|
||||
@ -58,7 +82,7 @@ from .schemas import (
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import Response
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments
|
||||
def File(default: Any = None) -> Any: # type: ignore[override]
|
||||
return default
|
||||
@ -92,6 +116,11 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
|
||||
self.media_type = media_type
|
||||
self.headers = headers or {}
|
||||
|
||||
class JSONResponse(Response): # type: ignore[override]
|
||||
def __init__(self, content: Any, status_code: int = 200) -> None:
|
||||
super().__init__(json.dumps(content).encode("utf-8"), media_type="application/json")
|
||||
self.status_code = status_code
|
||||
|
||||
class WebSocketDisconnect(Exception):
|
||||
"""Fallback websocket disconnect exception."""
|
||||
|
||||
@ -181,7 +210,9 @@ async def _app_lifespan(
|
||||
owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None
|
||||
app.state.agent_service = attached_service
|
||||
app.state.cron_service = _build_cron_service(attached_service) if owns_service else None
|
||||
app.state.channel_runtime = None
|
||||
started = False
|
||||
channel_runtime: ChannelRuntime | None = None
|
||||
if owns_service:
|
||||
try:
|
||||
await attached_service.start()
|
||||
@ -198,6 +229,29 @@ async def _app_lifespan(
|
||||
else:
|
||||
attached_service.close()
|
||||
raise
|
||||
try:
|
||||
loaded = attached_service.create_loop().boot()
|
||||
app.state.channel_connection_workspace = loaded.workspace
|
||||
connector_registry = _build_channel_connector_registry(loaded.workspace)
|
||||
app.state.channel_connector_registry = connector_registry
|
||||
connection_channels = await connector_registry.materialize_channel_configs()
|
||||
runtime_channels = dict(loaded.config.channels)
|
||||
runtime_channels.update(connection_channels)
|
||||
channel_runtime = ChannelRuntime(
|
||||
service=attached_service,
|
||||
workspace=loaded.workspace,
|
||||
channels=runtime_channels,
|
||||
)
|
||||
app.state.channel_runtime = channel_runtime
|
||||
await channel_runtime.start()
|
||||
except BaseException:
|
||||
if owns_service and started:
|
||||
with suppress(BaseException):
|
||||
await attached_service.shutdown(
|
||||
timeout_seconds=shutdown_timeout_seconds,
|
||||
force=shutdown_force,
|
||||
)
|
||||
raise
|
||||
worker: SkillLearningWorker | None = None
|
||||
worker_task = None
|
||||
worker_config = SkillLearningWorkerConfig.from_env()
|
||||
@ -214,6 +268,10 @@ async def _app_lifespan(
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
runtime = getattr(app.state, "channel_runtime", None)
|
||||
if isinstance(runtime, ChannelRuntime):
|
||||
with suppress(BaseException):
|
||||
await runtime.stop()
|
||||
cron_service = getattr(app.state, "cron_service", None)
|
||||
if isinstance(cron_service, CronService):
|
||||
cron_service.stop()
|
||||
@ -281,6 +339,118 @@ def get_cron_service(request: Request) -> CronService:
|
||||
return service
|
||||
|
||||
|
||||
def get_channel_runtime(request: Request) -> ChannelRuntime:
|
||||
runtime = getattr(request.app.state, "channel_runtime", None)
|
||||
if not isinstance(runtime, ChannelRuntime):
|
||||
raise HTTPException(status_code=503, detail="Channel runtime is not running")
|
||||
return runtime
|
||||
|
||||
|
||||
def _connection_state_dir(workspace: Path) -> Path:
|
||||
return Path(workspace) / "state" / "channel_connections"
|
||||
|
||||
|
||||
def _channel_connection_workspace(request: Request) -> Path:
|
||||
workspace = getattr(request.app.state, "channel_connection_workspace", None)
|
||||
if workspace is not None:
|
||||
return Path(workspace)
|
||||
return Path(get_agent_service(request).loader.workspace)
|
||||
|
||||
|
||||
def _message_dedupe_store(workspace: Path) -> MessageDedupeStore:
|
||||
return MessageDedupeStore(_connection_state_dir(workspace) / "message_dedupe.json")
|
||||
|
||||
|
||||
def _bridge_token() -> str:
|
||||
return os.getenv("BEAVER_BRIDGE_TOKEN", "")
|
||||
|
||||
|
||||
def _build_channel_connector_registry(workspace: Path) -> ChannelConnectorRegistry:
|
||||
state_dir = _connection_state_dir(workspace)
|
||||
connection_store = ChannelConnectionStore(state_dir / "connections.json")
|
||||
credential_store = CredentialStore(state_dir / "credentials.json")
|
||||
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
|
||||
registry.register(
|
||||
TelegramConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
)
|
||||
)
|
||||
sidecar_base_url = os.getenv("EXTERNAL_CONNECTOR_BASE_URL", "http://external-connector:8787")
|
||||
sidecar_token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "")
|
||||
sidecar_client = ConnectorSidecarClient(base_url=sidecar_base_url, token=sidecar_token)
|
||||
registry.register(
|
||||
WeixinConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=sidecar_client,
|
||||
sidecar_base_url=sidecar_base_url,
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
FeishuConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=sidecar_client,
|
||||
sidecar_base_url=sidecar_base_url,
|
||||
)
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry:
|
||||
registry = getattr(request.app.state, "channel_connector_registry", None)
|
||||
if isinstance(registry, ChannelConnectorRegistry):
|
||||
return registry
|
||||
workspace = getattr(request.app.state, "channel_connection_workspace", None)
|
||||
if workspace is None:
|
||||
raise RuntimeError("Channel connector registry unavailable before service boot")
|
||||
registry = _build_channel_connector_registry(workspace)
|
||||
request.app.state.channel_connector_registry = registry
|
||||
return registry
|
||||
|
||||
|
||||
def _connection_response_view(connection: Any) -> dict[str, Any]:
|
||||
view = connection.to_dict()
|
||||
view.pop("credentials_ref", None)
|
||||
view.pop("connector_ref", None)
|
||||
view.pop("pairing_session_id", None)
|
||||
return view
|
||||
|
||||
|
||||
def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not isinstance(config, dict):
|
||||
return {}
|
||||
return {
|
||||
_camel_to_snake_text(str(key)): value
|
||||
for key, value in config.items()
|
||||
if str(key).strip()
|
||||
}
|
||||
|
||||
|
||||
def _camel_to_snake_text(value: str) -> str:
|
||||
result: list[str] = []
|
||||
for char in value.strip():
|
||||
if char.isupper() and result:
|
||||
result.append("_")
|
||||
result.append(char.lower())
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def _self_restart_enabled() -> bool:
|
||||
return os.getenv("BEAVER_ENABLE_SELF_RESTART", "1").strip() not in {"0", "false", "False"}
|
||||
|
||||
|
||||
def _schedule_self_restart(delay_seconds: float = 0.75) -> None:
|
||||
import threading
|
||||
|
||||
def _exit_later() -> None:
|
||||
time.sleep(delay_seconds)
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_exit_later, daemon=True).start()
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
workspace: str | Path | None = None,
|
||||
@ -378,10 +548,330 @@ def create_app(
|
||||
"temperature": agent_service.profile.temperature,
|
||||
"max_tool_iterations": agent_service.profile.max_tool_iterations,
|
||||
"providers": providers_status,
|
||||
"channels": [{"name": "web", "enabled": True}],
|
||||
"channels": get_channel_runtime(request).statuses(),
|
||||
"runtime_controls": {"self_restart": _self_restart_enabled()},
|
||||
"cron": cron_service.status(),
|
||||
}
|
||||
|
||||
@app.get("/api/channels")
|
||||
async def list_channels(request: Request) -> list[dict[str, Any]]:
|
||||
return get_channel_runtime(request).statuses()
|
||||
|
||||
@app.get("/api/channel-connectors")
|
||||
async def list_channel_connectors(request: Request) -> list[dict[str, str]]:
|
||||
return get_channel_connector_registry(request).connectors()
|
||||
|
||||
@app.get("/api/channel-connections")
|
||||
async def list_channel_connections(request: Request) -> list[dict[str, Any]]:
|
||||
registry = get_channel_connector_registry(request)
|
||||
return [_connection_response_view(connection) for connection in registry.connection_store.list()]
|
||||
|
||||
@app.post("/api/channel-connections", response_model=WebChannelConnectionResponse)
|
||||
async def create_channel_connection(
|
||||
request: Request,
|
||||
payload: WebChannelConnectionCreateRequest,
|
||||
) -> WebChannelConnectionResponse:
|
||||
registry = get_channel_connector_registry(request)
|
||||
kind = _clean_text(payload.kind)
|
||||
mode = _clean_text(payload.mode)
|
||||
if not kind:
|
||||
raise HTTPException(status_code=400, detail="Connection kind is required")
|
||||
if not mode:
|
||||
raise HTTPException(status_code=400, detail="Connection mode is required")
|
||||
secrets_payload = payload.secrets or {}
|
||||
secrets = {key: value for key, value in secrets_payload.items() if value}
|
||||
credentials_ref = registry.credential_store.put(kind=kind, values=secrets) if secrets else None
|
||||
connection = registry.connection_store.create(
|
||||
kind=kind,
|
||||
mode=mode,
|
||||
display_name=_clean_text(payload.display_name) or kind,
|
||||
account_id=_clean_text(payload.account_id) or "",
|
||||
owner_user_id=_clean_text(payload.owner_user_id) or None,
|
||||
auth_type=_clean_text(payload.auth_type) or "token",
|
||||
credentials_ref=credentials_ref,
|
||||
runtime_config=_normalize_connection_config(payload.config),
|
||||
)
|
||||
return WebChannelConnectionResponse(
|
||||
connection=_connection_response_view(connection),
|
||||
credentials=registry.credential_store.redacted(credentials_ref),
|
||||
)
|
||||
|
||||
@app.patch("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse)
|
||||
async def update_channel_connection(
|
||||
connection_id: str,
|
||||
request: Request,
|
||||
payload: WebChannelConnectionUpdateRequest,
|
||||
) -> WebChannelConnectionResponse:
|
||||
registry = get_channel_connector_registry(request)
|
||||
try:
|
||||
connection = registry.connection_store.get(connection_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||
if payload.display_name is not None:
|
||||
connection.display_name = _clean_text(payload.display_name) or connection.display_name
|
||||
if payload.account_id is not None:
|
||||
connection.account_id = _clean_text(payload.account_id) or connection.account_id
|
||||
if payload.config is not None:
|
||||
connection.runtime_config = _normalize_connection_config(payload.config)
|
||||
if payload.secrets:
|
||||
secrets = {key: value for key, value in payload.secrets.items() if value}
|
||||
if secrets:
|
||||
# TODO: add credential GC when connection updates credentials.
|
||||
connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets)
|
||||
connection = registry.connection_store.update(connection)
|
||||
return WebChannelConnectionResponse(
|
||||
connection=_connection_response_view(connection),
|
||||
credentials=registry.credential_store.redacted(connection.credentials_ref),
|
||||
)
|
||||
|
||||
@app.get("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse)
|
||||
async def get_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse:
|
||||
registry = get_channel_connector_registry(request)
|
||||
try:
|
||||
connection = registry.connection_store.get(connection_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||
return WebChannelConnectionResponse(
|
||||
connection=_connection_response_view(connection),
|
||||
credentials=registry.credential_store.redacted(connection.credentials_ref),
|
||||
)
|
||||
|
||||
@app.post("/api/channel-connections/{connection_id}/validate", response_model=WebChannelValidationResponse)
|
||||
async def validate_channel_connection(connection_id: str, request: Request) -> WebChannelValidationResponse:
|
||||
registry = get_channel_connector_registry(request)
|
||||
try:
|
||||
result = await registry.validate(connection_id)
|
||||
connection = registry.connection_store.get(connection_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||
return WebChannelValidationResponse(
|
||||
ok=result.ok,
|
||||
status=result.status,
|
||||
account_id=result.account_id,
|
||||
display_name=result.display_name,
|
||||
error=result.error,
|
||||
metadata=result.metadata,
|
||||
connection=_connection_response_view(connection),
|
||||
)
|
||||
|
||||
@app.post("/api/channel-connections/{connection_id}/revoke", response_model=WebChannelConnectionResponse)
|
||||
async def revoke_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse:
|
||||
registry = get_channel_connector_registry(request)
|
||||
try:
|
||||
await registry.revoke(connection_id)
|
||||
connection = registry.connection_store.get(connection_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||
return WebChannelConnectionResponse(connection=_connection_response_view(connection), credentials={})
|
||||
|
||||
@app.post("/api/channel-connector-sessions", response_model=WebConnectorSessionResponse)
|
||||
async def start_channel_connector_session(
|
||||
request: Request,
|
||||
payload: WebConnectorSessionCreateRequest,
|
||||
) -> WebConnectorSessionResponse:
|
||||
registry = get_channel_connector_registry(request)
|
||||
kind = _clean_text(payload.kind)
|
||||
try:
|
||||
connector = registry.connector_for_kind(kind)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Connector not found")
|
||||
start_session = getattr(connector, "start_session", None)
|
||||
if start_session is None:
|
||||
raise HTTPException(status_code=400, detail="Connector does not support sessions")
|
||||
view = await start_session(
|
||||
display_name=_clean_text(payload.display_name) or kind,
|
||||
owner_user_id=_clean_text(payload.owner_user_id) or None,
|
||||
options=payload.options,
|
||||
)
|
||||
connection_id = _clean_text(view.get("connectionId"))
|
||||
connection_view = None
|
||||
if connection_id:
|
||||
connection_view = _connection_response_view(registry.connection_store.get(connection_id))
|
||||
return WebConnectorSessionResponse(session=view, connection=connection_view)
|
||||
|
||||
@app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse)
|
||||
async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse:
|
||||
registry = get_channel_connector_registry(request)
|
||||
connection = next(
|
||||
(item for item in registry.connection_store.list() if item.pairing_session_id == session_id),
|
||||
None,
|
||||
)
|
||||
if connection is None:
|
||||
raise HTTPException(status_code=404, detail="Connector session not found")
|
||||
connector = registry.connector_for_kind(connection.kind)
|
||||
poll_session = getattr(connector, "poll_session", None)
|
||||
if poll_session is None:
|
||||
raise HTTPException(status_code=400, detail="Connector does not support sessions")
|
||||
view = await poll_session(session_id)
|
||||
connection = registry.connection_store.get(connection.connection_id)
|
||||
if connection.status == "connected":
|
||||
runtime = get_channel_runtime(request)
|
||||
config = (await registry.materialize_channel_configs())[connection.channel_id]
|
||||
await runtime.add_channel(connection.channel_id, config)
|
||||
return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection))
|
||||
|
||||
@app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse)
|
||||
async def accept_connector_bridge_event(
|
||||
request: Request,
|
||||
payload: WebConnectorBridgeEventRequest,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> Any:
|
||||
expected = _bridge_token()
|
||||
if not expected or authorization != f"Bearer {expected}":
|
||||
raise HTTPException(status_code=401, detail="Invalid connector bridge token")
|
||||
|
||||
registry = get_channel_connector_registry(request)
|
||||
try:
|
||||
connection = registry.connection_store.get(payload.connection_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||
if connection.status == "revoked":
|
||||
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||
|
||||
store = _message_dedupe_store(_channel_connection_workspace(request))
|
||||
begin = store.begin(
|
||||
connection_id=payload.connection_id,
|
||||
event_id=payload.event_id,
|
||||
delivery_attempt=payload.delivery_attempt,
|
||||
)
|
||||
if not begin.should_process:
|
||||
body = WebConnectorBridgeEventResponse(
|
||||
accepted=begin.http_status == 200,
|
||||
duplicate=True,
|
||||
pending=begin.http_status == 409,
|
||||
retryAfterSeconds=begin.retry_after_seconds,
|
||||
).model_dump(by_alias=True)
|
||||
return JSONResponse(status_code=begin.http_status, content=body)
|
||||
|
||||
runtime = get_channel_runtime(request)
|
||||
identity = ChannelIdentity(
|
||||
channel_id=payload.channel_id,
|
||||
kind=payload.kind,
|
||||
account_id=payload.account_id,
|
||||
peer_id=payload.peer_id,
|
||||
thread_id=payload.thread_id,
|
||||
peer_type=payload.peer_type,
|
||||
user_id=payload.user_id,
|
||||
message_id=payload.message_id,
|
||||
)
|
||||
inbound = InboundMessage(
|
||||
channel=payload.channel_id,
|
||||
content=payload.content,
|
||||
content_type=payload.message_type,
|
||||
channel_identity=identity,
|
||||
user_id=payload.user_id,
|
||||
message_id=payload.message_id,
|
||||
metadata=dict(payload.metadata),
|
||||
)
|
||||
result = await runtime.accept_inbound(inbound)
|
||||
if result.accepted or result.duplicate:
|
||||
store.complete(begin.dedupe_key, message_id=payload.message_id)
|
||||
else:
|
||||
store.fail(begin.dedupe_key, error=result.error or "runtime rejected bridge event")
|
||||
return WebConnectorBridgeEventResponse(
|
||||
accepted=result.accepted,
|
||||
duplicate=result.duplicate,
|
||||
pending=result.pending,
|
||||
)
|
||||
|
||||
@app.get("/api/channels/{channel_id}/config")
|
||||
async def get_channel_config(channel_id: str, request: Request) -> dict[str, Any]:
|
||||
agent_service = get_agent_service(request)
|
||||
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
|
||||
raw = _read_config_json(config_path)
|
||||
channel = _ensure_dict(raw, "channels").get(channel_id)
|
||||
if not isinstance(channel, dict):
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return _channel_config_view(channel_id, channel)
|
||||
|
||||
@app.post("/api/channels/{channel_id}/config", response_model=WebChannelConfigResponse)
|
||||
async def update_channel_config(
|
||||
channel_id: str,
|
||||
request: Request,
|
||||
payload: WebChannelConfigRequest,
|
||||
) -> WebChannelConfigResponse:
|
||||
if not _clean_text(channel_id):
|
||||
raise HTTPException(status_code=400, detail="Channel id is required")
|
||||
kind = _clean_text(payload.kind)
|
||||
mode = _clean_text(payload.mode)
|
||||
if not kind:
|
||||
raise HTTPException(status_code=400, detail="Channel kind is required")
|
||||
if not mode:
|
||||
raise HTTPException(status_code=400, detail="Channel mode is required")
|
||||
|
||||
agent_service = get_agent_service(request)
|
||||
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
|
||||
raw = _read_config_json(config_path)
|
||||
channels = _ensure_dict(raw, "channels")
|
||||
current = channels.get(channel_id) if isinstance(channels.get(channel_id), dict) else {}
|
||||
current_secrets = current.get("secrets") if isinstance(current.get("secrets"), dict) else {}
|
||||
next_secrets = dict(current_secrets)
|
||||
for key, value in (payload.secrets or {}).items():
|
||||
cleaned_key = _clean_text(key)
|
||||
cleaned_value = _clean_text(value)
|
||||
if not cleaned_key or not cleaned_value:
|
||||
continue
|
||||
next_secrets[cleaned_key] = cleaned_value
|
||||
|
||||
channel_payload: dict[str, Any] = {
|
||||
"enabled": bool(payload.enabled),
|
||||
"kind": kind,
|
||||
"mode": mode,
|
||||
"accountId": _clean_text(payload.account_id) or "",
|
||||
"displayName": _clean_text(payload.display_name) or channel_id,
|
||||
"config": payload.config or {},
|
||||
"secrets": next_secrets,
|
||||
}
|
||||
channels[channel_id] = channel_payload
|
||||
_write_config_json(config_path, raw)
|
||||
_reload_agent_config(agent_service, config_path)
|
||||
return WebChannelConfigResponse(
|
||||
ok=True,
|
||||
channel_id=channel_id,
|
||||
restart_required=True,
|
||||
channel=_channel_config_view(channel_id, channel_payload),
|
||||
)
|
||||
|
||||
@app.get("/api/channels/{channel_id}/events")
|
||||
async def list_channel_events(channel_id: str, request: Request, limit: int = 100) -> list[dict[str, Any]]:
|
||||
return get_channel_runtime(request).recent_events(channel_id, limit=limit)
|
||||
|
||||
@app.post("/api/channels/{channel_id}/webhook")
|
||||
async def post_channel_webhook(channel_id: str, request: Request) -> JSONResponse:
|
||||
runtime = get_channel_runtime(request)
|
||||
adapter = runtime.adapters.get(channel_id)
|
||||
if adapter is None or not hasattr(adapter, "handle_webhook_payload"):
|
||||
raise HTTPException(status_code=404, detail="Webhook channel not found")
|
||||
payload = await request.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=400, detail="Webhook payload must be a JSON object")
|
||||
result = await adapter.handle_webhook_payload(payload) # type: ignore[attr-defined]
|
||||
status_code = 202 if result.get("pending") else 200
|
||||
return JSONResponse(result, status_code=status_code)
|
||||
|
||||
@app.websocket("/api/channels/{channel_id}/ws")
|
||||
async def channel_websocket(websocket: WebSocket, channel_id: str) -> None:
|
||||
runtime = getattr(websocket.app.state, "channel_runtime", None)
|
||||
if not isinstance(runtime, ChannelRuntime):
|
||||
await websocket.accept()
|
||||
await websocket.send_json({"type": "error", "error": "Channel runtime is not running"})
|
||||
await websocket.close(code=1011)
|
||||
return
|
||||
adapter = runtime.adapters.get(channel_id)
|
||||
if adapter is None or not hasattr(adapter, "handle_websocket"):
|
||||
await websocket.accept()
|
||||
await websocket.send_json({"type": "error", "error": "WebSocket channel not found"})
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await adapter.handle_websocket(websocket) # type: ignore[attr-defined]
|
||||
|
||||
@app.post("/api/runtime/restart")
|
||||
async def restart_runtime() -> JSONResponse:
|
||||
if not _self_restart_enabled():
|
||||
raise HTTPException(status_code=403, detail="Self restart is disabled")
|
||||
_schedule_self_restart()
|
||||
return JSONResponse({"ok": True, "restarting": True}, status_code=202)
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
username = _clean_text(payload.get("username"))
|
||||
@ -595,6 +1085,38 @@ def create_app(
|
||||
_reload_agent_config(agent_service, config_path)
|
||||
return WebProviderConfigResponse(ok=True, provider=spec.name, enabled=payload.enabled)
|
||||
|
||||
@app.post("/api/agent-config", response_model=WebAgentConfigResponse)
|
||||
async def update_agent_config(
|
||||
request: Request,
|
||||
payload: WebAgentConfigRequest,
|
||||
) -> WebAgentConfigResponse:
|
||||
if payload.max_tokens is not None and payload.max_tokens <= 0:
|
||||
raise HTTPException(status_code=400, detail="max_tokens must be a positive integer or null")
|
||||
if payload.temperature < 0 or payload.temperature > 2:
|
||||
raise HTTPException(status_code=400, detail="temperature must be between 0 and 2")
|
||||
if payload.max_tool_iterations < 0:
|
||||
raise HTTPException(status_code=400, detail="max_tool_iterations must be zero or greater")
|
||||
|
||||
agent_service = get_agent_service(request)
|
||||
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
|
||||
raw = _read_config_json(config_path)
|
||||
agents = _ensure_dict(raw, "agents")
|
||||
defaults = _ensure_dict(agents, "defaults")
|
||||
|
||||
if payload.max_tokens is None:
|
||||
defaults.pop("maxTokens", None)
|
||||
defaults.pop("max_tokens", None)
|
||||
else:
|
||||
defaults["maxTokens"] = payload.max_tokens
|
||||
defaults.pop("max_tokens", None)
|
||||
defaults["temperature"] = payload.temperature
|
||||
defaults["maxToolIterations"] = payload.max_tool_iterations
|
||||
defaults.pop("max_tool_iterations", None)
|
||||
|
||||
_write_config_json(config_path, raw)
|
||||
_reload_agent_config(agent_service, config_path)
|
||||
return WebAgentConfigResponse(ok=True)
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def list_sessions(request: Request) -> list[dict[str, Any]]:
|
||||
loaded = get_agent_service(request).create_loop().boot()
|
||||
@ -2977,6 +3499,25 @@ def _mask_secret(value: str | None) -> str:
|
||||
return f"{secret[:4]}••••{secret[-4:]}"
|
||||
|
||||
|
||||
def _channel_config_view(channel_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
secrets_payload = data.get("secrets") if isinstance(data.get("secrets"), dict) else {}
|
||||
config_payload = data.get("config") if isinstance(data.get("config"), dict) else {}
|
||||
return {
|
||||
"channel_id": channel_id,
|
||||
"enabled": bool(data.get("enabled")),
|
||||
"kind": _clean_text(data.get("kind")) or "",
|
||||
"mode": _clean_text(data.get("mode")) or "webhook",
|
||||
"account_id": _clean_text(data.get("accountId") or data.get("account_id")) or "",
|
||||
"display_name": _clean_text(data.get("displayName") or data.get("display_name")) or channel_id,
|
||||
"config": dict(config_payload),
|
||||
"secrets": {
|
||||
str(key): _mask_secret(str(value) if value is not None else None)
|
||||
for key, value in secrets_payload.items()
|
||||
if str(key).strip()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _read_config_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
@ -3048,7 +3589,14 @@ def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None
|
||||
old_manager = getattr(loaded, "mcp_manager", None)
|
||||
if old_manager is not None:
|
||||
async def _close_old_manager() -> None:
|
||||
await old_manager.close()
|
||||
try:
|
||||
await old_manager.close()
|
||||
except Exception:
|
||||
# MCP transports may own anyio cancel scopes created by a
|
||||
# previous request task. Config reload must not leak that
|
||||
# cleanup failure as an unhandled background exception or
|
||||
# knock the app out of running mode.
|
||||
pass
|
||||
|
||||
try:
|
||||
running_loop = asyncio.get_running_loop()
|
||||
|
||||
@ -8,6 +8,18 @@ from .chat import (
|
||||
WebChatRequest,
|
||||
WebChatResponse,
|
||||
WebErrorResponse,
|
||||
WebAgentConfigRequest,
|
||||
WebAgentConfigResponse,
|
||||
WebChannelConfigRequest,
|
||||
WebChannelConfigResponse,
|
||||
WebChannelConnectionCreateRequest,
|
||||
WebChannelConnectionResponse,
|
||||
WebChannelConnectionUpdateRequest,
|
||||
WebChannelValidationResponse,
|
||||
WebConnectorBridgeEventRequest,
|
||||
WebConnectorBridgeEventResponse,
|
||||
WebConnectorSessionCreateRequest,
|
||||
WebConnectorSessionResponse,
|
||||
WebProviderConfigRequest,
|
||||
WebProviderConfigResponse,
|
||||
WebProviderTarget,
|
||||
@ -22,6 +34,18 @@ __all__ = [
|
||||
"WebChatRequest",
|
||||
"WebChatResponse",
|
||||
"WebErrorResponse",
|
||||
"WebAgentConfigRequest",
|
||||
"WebAgentConfigResponse",
|
||||
"WebChannelConfigRequest",
|
||||
"WebChannelConfigResponse",
|
||||
"WebChannelConnectionCreateRequest",
|
||||
"WebChannelConnectionResponse",
|
||||
"WebChannelConnectionUpdateRequest",
|
||||
"WebChannelValidationResponse",
|
||||
"WebConnectorBridgeEventRequest",
|
||||
"WebConnectorBridgeEventResponse",
|
||||
"WebConnectorSessionCreateRequest",
|
||||
"WebConnectorSessionResponse",
|
||||
"WebProviderConfigRequest",
|
||||
"WebProviderConfigResponse",
|
||||
"WebProviderTarget",
|
||||
|
||||
@ -139,6 +139,127 @@ class WebProviderConfigResponse(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class WebChannelConfigRequest(BaseModel):
|
||||
"""Channel config update from the settings page."""
|
||||
|
||||
enabled: bool = False
|
||||
kind: str
|
||||
mode: str
|
||||
account_id: str | None = None
|
||||
display_name: str | None = None
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
secrets: dict[str, str | None] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WebChannelConfigResponse(BaseModel):
|
||||
"""Channel config update result."""
|
||||
|
||||
ok: bool
|
||||
channel_id: str
|
||||
restart_required: bool
|
||||
channel: dict[str, Any]
|
||||
|
||||
|
||||
class WebChannelConnectionCreateRequest(BaseModel):
|
||||
"""Create a channel connection from the setup UI."""
|
||||
|
||||
kind: str
|
||||
mode: str
|
||||
display_name: str | None = Field(default=None, alias="displayName")
|
||||
owner_user_id: str | None = Field(default=None, alias="ownerUserId")
|
||||
auth_type: str = Field(default="token", alias="authType")
|
||||
account_id: str | None = Field(default=None, alias="accountId")
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
secrets: dict[str, str | None] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WebChannelConnectionResponse(BaseModel):
|
||||
"""Channel connection response with redacted credentials."""
|
||||
|
||||
connection: dict[str, Any]
|
||||
credentials: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WebChannelConnectionUpdateRequest(BaseModel):
|
||||
"""Update editable channel connection setup fields."""
|
||||
|
||||
display_name: str | None = Field(default=None, alias="displayName")
|
||||
account_id: str | None = Field(default=None, alias="accountId")
|
||||
config: dict[str, Any] | None = None
|
||||
secrets: dict[str, str | None] | None = None
|
||||
|
||||
|
||||
class WebChannelValidationResponse(BaseModel):
|
||||
"""Connector validation response."""
|
||||
|
||||
ok: bool
|
||||
status: str
|
||||
account_id: str | None = None
|
||||
display_name: str | None = None
|
||||
error: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
connection: dict[str, Any]
|
||||
|
||||
|
||||
class WebConnectorBridgeEventRequest(BaseModel):
|
||||
"""Inbound connector bridge event from the external sidecar."""
|
||||
|
||||
event_id: str = Field(alias="eventId")
|
||||
timestamp: str
|
||||
delivery_attempt: int = Field(default=1, alias="deliveryAttempt")
|
||||
connection_id: str = Field(alias="connectionId")
|
||||
channel_id: str = Field(alias="channelId")
|
||||
kind: str
|
||||
account_id: str = Field(alias="accountId")
|
||||
peer_id: str = Field(alias="peerId")
|
||||
peer_type: str = Field(default="unknown", alias="peerType")
|
||||
user_id: str | None = Field(default=None, alias="userId")
|
||||
thread_id: str | None = Field(default=None, alias="threadId")
|
||||
message_id: str = Field(alias="messageId")
|
||||
message_type: str = Field(default="text", alias="messageType")
|
||||
content: str
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WebConnectorBridgeEventResponse(BaseModel):
|
||||
"""Connector bridge event accept/dedupe response."""
|
||||
|
||||
accepted: bool
|
||||
duplicate: bool = False
|
||||
pending: bool = False
|
||||
retry_after_seconds: int | None = Field(default=None, alias="retryAfterSeconds")
|
||||
|
||||
|
||||
class WebConnectorSessionCreateRequest(BaseModel):
|
||||
"""Start a connector-managed onboarding session."""
|
||||
|
||||
kind: str
|
||||
display_name: str | None = Field(default=None, alias="displayName")
|
||||
owner_user_id: str | None = Field(default=None, alias="ownerUserId")
|
||||
options: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WebConnectorSessionResponse(BaseModel):
|
||||
"""Connector session view plus optional connection view."""
|
||||
|
||||
session: dict[str, Any]
|
||||
connection: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class WebAgentConfigRequest(BaseModel):
|
||||
"""Agent runtime defaults update from the settings page."""
|
||||
|
||||
max_tokens: int | None = None
|
||||
temperature: float
|
||||
max_tool_iterations: int
|
||||
|
||||
|
||||
class WebAgentConfigResponse(BaseModel):
|
||||
"""Agent runtime defaults update result."""
|
||||
|
||||
ok: bool
|
||||
|
||||
|
||||
class WebStatusResponse(BaseModel):
|
||||
"""Web 宿主层状态响应。"""
|
||||
|
||||
|
||||
@ -68,6 +68,14 @@ class AgentService:
|
||||
|
||||
def _apply_configured_profile_defaults(self) -> None:
|
||||
defaults = self.loader.config.agents_defaults
|
||||
self.profile.max_tokens = None
|
||||
self.profile.temperature = 0.2
|
||||
self.profile.max_context_messages = 1000
|
||||
self.profile.max_tool_iterations = 30
|
||||
if defaults.max_tokens is not None:
|
||||
self.profile.max_tokens = max(1, defaults.max_tokens)
|
||||
if defaults.temperature is not None:
|
||||
self.profile.temperature = defaults.temperature
|
||||
if defaults.max_context_messages is not None:
|
||||
self.profile.max_context_messages = max(1, defaults.max_context_messages)
|
||||
if defaults.max_tool_iterations is not None:
|
||||
@ -1229,17 +1237,19 @@ class AgentService:
|
||||
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
|
||||
"""把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。"""
|
||||
|
||||
channel_identity = inbound.channel_identity
|
||||
try:
|
||||
result = await self.submit_direct(
|
||||
inbound.content,
|
||||
session_id=inbound.session_id,
|
||||
source=f"gateway:{inbound.channel}",
|
||||
user_id=inbound.user_id,
|
||||
user_id=inbound.user_id or (channel_identity.user_id if channel_identity else None),
|
||||
title=inbound.title,
|
||||
execution_context=inbound.execution_context,
|
||||
model=inbound.model,
|
||||
provider_name=inbound.provider_name,
|
||||
embedding_model=inbound.embedding_model,
|
||||
channel_identity=channel_identity,
|
||||
)
|
||||
except Exception as exc:
|
||||
return self.build_outbound_error(
|
||||
@ -1275,6 +1285,8 @@ class AgentService:
|
||||
finish_reason=result.finish_reason,
|
||||
provider_name=result.provider_name,
|
||||
model=result.model,
|
||||
content_type=inbound.content_type,
|
||||
channel_identity=inbound.channel_identity,
|
||||
usage=dict(result.usage),
|
||||
metadata={
|
||||
"inbound_metadata": dict(inbound.metadata),
|
||||
@ -1300,6 +1312,8 @@ class AgentService:
|
||||
session_id=inbound.session_id,
|
||||
content=detail,
|
||||
finish_reason=finish_reason,
|
||||
content_type=inbound.content_type,
|
||||
channel_identity=inbound.channel_identity,
|
||||
metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)},
|
||||
)
|
||||
|
||||
|
||||
@ -50,10 +50,11 @@ class SessionProcessProjector:
|
||||
|
||||
for record in records:
|
||||
payload = dict(record.event_payload or {})
|
||||
task_id = payload.get("task_id")
|
||||
run_record_for_event = run_records.get(str(record.run_id)) if record.run_id else None
|
||||
task_id = payload.get("task_id") or getattr(run_record_for_event, "task_id", None)
|
||||
if not task_id:
|
||||
continue
|
||||
attempt_index = int(payload.get("attempt_index") or 1)
|
||||
attempt_index = int(payload.get("attempt_index") or getattr(run_record_for_event, "attempt_index", None) or 1)
|
||||
root_run_id = f"task:{task_id}:attempt:{attempt_index}"
|
||||
created_at = _timestamp(record.timestamp)
|
||||
root = runs.setdefault(
|
||||
@ -73,15 +74,70 @@ class SessionProcessProjector:
|
||||
},
|
||||
)
|
||||
|
||||
if record.event_type == "task_execution_planned":
|
||||
if record.event_type == "assistant_message_added" and record.tool_calls:
|
||||
run_id = record.run_id or root_run_id
|
||||
parent_run_id = root_run_id if run_id != root_run_id else None
|
||||
for index, tool_call in enumerate(record.tool_calls):
|
||||
if not isinstance(tool_call, dict):
|
||||
continue
|
||||
tool_name = _tool_call_name(tool_call)
|
||||
add_event(
|
||||
event_id=f"{_event_id(record, 'tool-call')}:{index}",
|
||||
run_id=run_id,
|
||||
parent_run_id=parent_run_id,
|
||||
kind="tool_call_started",
|
||||
actor_type="tool",
|
||||
actor_id=tool_name,
|
||||
actor_name=tool_name,
|
||||
text=f"Calling tool: {tool_name}.",
|
||||
created_at=created_at,
|
||||
status="running",
|
||||
metadata={
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "tool_call",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tool_call.get("id"),
|
||||
"arguments": _tool_call_arguments(tool_call),
|
||||
},
|
||||
)
|
||||
|
||||
elif record.event_type == "tool_result_recorded":
|
||||
run_id = record.run_id or root_run_id
|
||||
parent_run_id = root_run_id if run_id != root_run_id else None
|
||||
tool_name = str(record.tool_name or payload.get("tool_name") or "tool")
|
||||
add_event(
|
||||
event_id=_event_id(record, "tool-result"),
|
||||
run_id=run_id,
|
||||
parent_run_id=parent_run_id,
|
||||
kind="tool_call_finished",
|
||||
actor_type="tool",
|
||||
actor_id=tool_name,
|
||||
actor_name=tool_name,
|
||||
text=_truncate(str(record.content or payload.get("error") or "")),
|
||||
created_at=created_at,
|
||||
status="done" if payload.get("success", True) else "error",
|
||||
metadata={
|
||||
**dict(payload),
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "tool_result",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": record.tool_call_id,
|
||||
"result_summary": _truncate(str(record.content or payload.get("error") or "")),
|
||||
},
|
||||
)
|
||||
|
||||
elif record.event_type == "task_execution_planned":
|
||||
plan_mode = payload.get("plan_mode") or "single"
|
||||
strategy = payload.get("strategy") or "single"
|
||||
node_ids = payload.get("node_ids") or []
|
||||
root["title"] = f"{payload.get('plan_mode', 'single')} plan: {strategy}"
|
||||
root["title"] = f"{plan_mode} plan: {strategy}"
|
||||
root["summary"] = payload.get("reason") or ""
|
||||
root["metadata"] = {
|
||||
**root.get("metadata", {}),
|
||||
"plan_mode": payload.get("plan_mode"),
|
||||
"strategy": payload.get("strategy"),
|
||||
"plan_mode": plan_mode,
|
||||
"strategy": strategy,
|
||||
"node_ids": node_ids,
|
||||
"skill_queries": payload.get("skill_queries") or [],
|
||||
"selected_skill_names": payload.get("selected_skill_names") or [],
|
||||
@ -92,36 +148,65 @@ class SessionProcessProjector:
|
||||
add_event(
|
||||
event_id=_event_id(record, "planned"),
|
||||
run_id=root_run_id,
|
||||
kind="run_started",
|
||||
kind="task_planned",
|
||||
actor_type="system",
|
||||
actor_id="task",
|
||||
actor_name="Task Planner",
|
||||
text=f"Planned {payload.get('plan_mode')} execution via {strategy}. {payload.get('reason') or ''}".strip(),
|
||||
text=f"Beaver planned {plan_mode} execution via {strategy}. {payload.get('reason') or ''}".strip(),
|
||||
created_at=created_at,
|
||||
status="running",
|
||||
metadata=root["metadata"],
|
||||
metadata={
|
||||
**root["metadata"],
|
||||
"timeline_type": "plan",
|
||||
"user_summary": f"Beaver will use {plan_mode} execution for this task.",
|
||||
},
|
||||
)
|
||||
selected_skill_names = [
|
||||
str(item)
|
||||
for item in payload.get("selected_skill_names") or []
|
||||
if str(item).strip()
|
||||
]
|
||||
if selected_skill_names:
|
||||
add_event(
|
||||
event_id=_event_id(record, "skills"),
|
||||
run_id=root_run_id,
|
||||
kind="skill_selected",
|
||||
actor_type="system",
|
||||
actor_id="skill-selector",
|
||||
actor_name="Skill Selector",
|
||||
text=f"Selected skill guidance: {', '.join(selected_skill_names)}.",
|
||||
created_at=created_at,
|
||||
status="done",
|
||||
metadata={
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "skill",
|
||||
"skill_names": selected_skill_names,
|
||||
"reason": payload.get("reason") or "Selected from task planning context.",
|
||||
},
|
||||
)
|
||||
|
||||
elif record.event_type in {"task_team_run_completed", "task_team_run_failed"}:
|
||||
team_success = bool(payload.get("team_success"))
|
||||
root["status"] = "running"
|
||||
team_run_ids = payload.get("team_run_ids") or []
|
||||
root["metadata"] = {
|
||||
**root.get("metadata", {}),
|
||||
"team_success": team_success,
|
||||
"team_run_ids": payload.get("team_run_ids") or [],
|
||||
"team_run_ids": team_run_ids,
|
||||
"team_error": payload.get("error"),
|
||||
}
|
||||
add_event(
|
||||
event_id=_event_id(record, "team"),
|
||||
run_id=root_run_id,
|
||||
kind="run_status",
|
||||
kind="agent_team_created",
|
||||
actor_type="system",
|
||||
actor_id="team",
|
||||
actor_name="Task Team",
|
||||
text=payload.get("error") or ("Team completed" if team_success else "Team completed with failed nodes"),
|
||||
created_at=created_at,
|
||||
status="done" if team_success else "error",
|
||||
metadata=dict(payload),
|
||||
metadata={**dict(payload), "timeline_type": "agent_team", "team_run_ids": team_run_ids},
|
||||
)
|
||||
node_results = payload.get("node_results") or []
|
||||
for item in node_results:
|
||||
@ -192,20 +277,26 @@ class SessionProcessProjector:
|
||||
event_id=f"{_event_id(record, 'node')}:{item.get('node_id')}",
|
||||
run_id=str(node_run_id),
|
||||
parent_run_id=root_run_id,
|
||||
kind="run_finished",
|
||||
kind="agent_finished",
|
||||
actor_type="agent",
|
||||
actor_id=str(item.get("node_id") or "sub-agent"),
|
||||
actor_name=str(item.get("node_id") or "Sub-agent"),
|
||||
text=_truncate(str(item.get("output_text") or item.get("error") or "")),
|
||||
created_at=created_at,
|
||||
status=status,
|
||||
metadata=dict(item),
|
||||
metadata={
|
||||
**dict(item),
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "agent_progress",
|
||||
},
|
||||
)
|
||||
|
||||
elif record.event_type == "task_synthesis_completed":
|
||||
main_run_id = str(payload.get("main_run_id") or "")
|
||||
if main_run_id:
|
||||
run_record = run_records.get(main_run_id)
|
||||
activated_skill_names = _activated_skill_names(run_record)
|
||||
runs[main_run_id] = {
|
||||
"run_id": main_run_id,
|
||||
"parent_run_id": root_run_id,
|
||||
@ -219,8 +310,32 @@ class SessionProcessProjector:
|
||||
"started_at": run_record.started_at if run_record is not None else created_at,
|
||||
"finished_at": run_record.ended_at if run_record is not None else created_at,
|
||||
"summary": _truncate(run_record.task_text if run_record is not None else ""),
|
||||
"metadata": {"task_id": task_id, "attempt_index": attempt_index},
|
||||
"metadata": {
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"skill_names": activated_skill_names,
|
||||
},
|
||||
}
|
||||
if activated_skill_names:
|
||||
add_event(
|
||||
event_id=_event_id(record, "synthesis-skills"),
|
||||
run_id=main_run_id,
|
||||
parent_run_id=root_run_id,
|
||||
kind="skill_selected",
|
||||
actor_type="system",
|
||||
actor_id="skill-selector",
|
||||
actor_name="Skill Selector",
|
||||
text=f"Selected skill guidance: {', '.join(activated_skill_names)}.",
|
||||
created_at=created_at,
|
||||
status="done",
|
||||
metadata={
|
||||
"task_id": task_id,
|
||||
"attempt_index": attempt_index,
|
||||
"timeline_type": "skill",
|
||||
"skill_names": activated_skill_names,
|
||||
"activation_reasons": _activated_skill_reasons(run_record),
|
||||
},
|
||||
)
|
||||
add_event(
|
||||
event_id=_event_id(record, "synthesis"),
|
||||
run_id=main_run_id,
|
||||
@ -242,14 +357,14 @@ class SessionProcessProjector:
|
||||
event_id=_event_id(record, "evidence"),
|
||||
run_id=record.run_id or root_run_id,
|
||||
parent_run_id=root_run_id if record.run_id else None,
|
||||
kind="run_status",
|
||||
kind="task_result_ready",
|
||||
actor_type="system",
|
||||
actor_id="evidence-recorder",
|
||||
actor_name="Evidence",
|
||||
text="Task evidence was recorded; waiting for user acceptance.",
|
||||
text="The task result is ready for user acceptance.",
|
||||
created_at=created_at,
|
||||
status="done",
|
||||
metadata=dict(payload),
|
||||
metadata={**dict(payload), "timeline_type": "result"},
|
||||
)
|
||||
|
||||
elif record.event_type == "task_acceptance_recorded":
|
||||
@ -267,14 +382,14 @@ class SessionProcessProjector:
|
||||
event_id=_event_id(record, "acceptance"),
|
||||
run_id=record.run_id or root_run_id,
|
||||
parent_run_id=root_run_id if record.run_id else None,
|
||||
kind="run_status",
|
||||
kind="task_acceptance_recorded",
|
||||
actor_type="user",
|
||||
actor_id="user-acceptance",
|
||||
actor_name="User Acceptance",
|
||||
text=f"User acceptance recorded: {acceptance_type or 'unknown'}.",
|
||||
created_at=created_at,
|
||||
status="done",
|
||||
metadata=dict(payload),
|
||||
metadata={**dict(payload), "timeline_type": "acceptance"},
|
||||
)
|
||||
|
||||
return {
|
||||
@ -300,3 +415,49 @@ def _truncate(text: str, limit: int = 800) -> str:
|
||||
if len(cleaned) <= limit:
|
||||
return cleaned
|
||||
return cleaned[: limit - 1] + "..."
|
||||
|
||||
|
||||
def _activated_skill_names(run_record: Any | None) -> list[str]:
|
||||
if run_record is None:
|
||||
return []
|
||||
names = []
|
||||
for receipt in getattr(run_record, "activated_skills", []) or []:
|
||||
skill_name = str(getattr(receipt, "skill_name", "") or "").strip()
|
||||
if skill_name:
|
||||
names.append(skill_name)
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
|
||||
def _activated_skill_reasons(run_record: Any | None) -> list[str]:
|
||||
if run_record is None:
|
||||
return []
|
||||
reasons = []
|
||||
for receipt in getattr(run_record, "activated_skills", []) or []:
|
||||
reason = str(getattr(receipt, "activation_reason", "") or "").strip()
|
||||
if reason:
|
||||
reasons.append(reason)
|
||||
return reasons
|
||||
|
||||
|
||||
def _tool_call_name(tool_call: dict[str, Any]) -> str:
|
||||
function_payload = tool_call.get("function")
|
||||
if isinstance(function_payload, dict):
|
||||
name = function_payload.get("name")
|
||||
if name:
|
||||
return str(name)
|
||||
for key in ("name", "tool_name"):
|
||||
value = tool_call.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return "tool"
|
||||
|
||||
|
||||
def _tool_call_arguments(tool_call: dict[str, Any]) -> Any:
|
||||
function_payload = tool_call.get("function")
|
||||
if isinstance(function_payload, dict) and "arguments" in function_payload:
|
||||
return function_payload.get("arguments")
|
||||
if "arguments" in tool_call:
|
||||
return tool_call.get("arguments")
|
||||
if "args" in tool_call:
|
||||
return tool_call.get("args")
|
||||
return None
|
||||
|
||||
@ -51,7 +51,7 @@ class WebFetchTool:
|
||||
try:
|
||||
safe_url = _safe_url(url)
|
||||
limit = max(1000, min(int(max_chars or 12000), 50000))
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client:
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=True) as client:
|
||||
response = await client.get(
|
||||
safe_url,
|
||||
headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"},
|
||||
@ -96,7 +96,7 @@ class WebSearchTool:
|
||||
raise ValueError("query is required")
|
||||
bounded = max(1, min(int(limit or 5), 10))
|
||||
url = f"https://duckduckgo.com/html/?q={quote_plus(query)}"
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client:
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=True) as client:
|
||||
response = await client.get(url, headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"})
|
||||
response.raise_for_status()
|
||||
html = response.text
|
||||
|
||||
@ -22,6 +22,23 @@ dependencies = [
|
||||
dev = [
|
||||
"pytest>=9.0.0,<10.0.0",
|
||||
]
|
||||
telegram = [
|
||||
"python-telegram-bot>=22.0,<23.0",
|
||||
]
|
||||
feishu = [
|
||||
"lark-oapi>=1.4.22,<2.0.0",
|
||||
]
|
||||
qqbot = [
|
||||
"aiohttp>=3.9.0,<4.0.0",
|
||||
]
|
||||
weixin = [
|
||||
"aiohttp>=3.9.0,<4.0.0",
|
||||
]
|
||||
channels = [
|
||||
"python-telegram-bot>=22.0,<23.0",
|
||||
"lark-oapi>=1.4.22,<2.0.0",
|
||||
"aiohttp>=3.9.0,<4.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
beaver = "beaver.interfaces.cli.main:main"
|
||||
|
||||
47
app-instance/backend/tests/unit/test_agent_loop.py
Normal file
47
app-instance/backend/tests/unit/test_agent_loop.py
Normal file
@ -0,0 +1,47 @@
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from beaver.engine import AgentLoop, AgentRunResult, EngineLoader
|
||||
|
||||
|
||||
def _run_result(run_id: str, output_text: str) -> AgentRunResult:
|
||||
return AgentRunResult(
|
||||
session_id="web:test",
|
||||
run_id=run_id,
|
||||
output_text=output_text,
|
||||
finish_reason="stop",
|
||||
tool_iterations=0,
|
||||
)
|
||||
|
||||
|
||||
def test_running_loop_handles_reentrant_submit_direct(tmp_path) -> None:
|
||||
async def run_case() -> None:
|
||||
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_process_direct(task: str, **kwargs: Any) -> AgentRunResult:
|
||||
calls.append(task)
|
||||
if task == "outer":
|
||||
return await loop.submit_direct("inner", session_id="web:test")
|
||||
return _run_result(task, "inner completed")
|
||||
|
||||
loop._process_direct_impl = fake_process_direct # type: ignore[method-assign]
|
||||
|
||||
loop_task = asyncio.create_task(loop.run())
|
||||
await asyncio.sleep(0)
|
||||
try:
|
||||
result = await asyncio.wait_for(loop.submit_direct("outer", session_id="web:test"), timeout=1)
|
||||
finally:
|
||||
await loop.stop()
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(loop_task, timeout=1)
|
||||
if not loop_task.done():
|
||||
loop_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await loop_task
|
||||
|
||||
assert result.output_text == "inner completed"
|
||||
assert calls == ["outer", "inner"]
|
||||
|
||||
asyncio.run(run_case())
|
||||
@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.interfaces.web.app import create_app
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
|
||||
encoding="utf-8",
|
||||
)
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
created = client.post(
|
||||
"/api/channel-connections",
|
||||
json={
|
||||
"kind": "telegram",
|
||||
"mode": "polling",
|
||||
"displayName": "Telegram Main",
|
||||
"authType": "token",
|
||||
"secrets": {"botToken": "token-1"},
|
||||
"config": {"maxMessageChars": 4096, "requireMentionInGroups": True},
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
body = created.json()
|
||||
connection_id = body["connection"]["connection_id"]
|
||||
assert body["connection"]["kind"] == "telegram"
|
||||
assert body["connection"]["status"] == "draft"
|
||||
assert "credentials_ref" not in body["connection"]
|
||||
assert body["connection"]["runtime_config"] == {
|
||||
"max_message_chars": 4096,
|
||||
"require_mention_in_groups": True,
|
||||
}
|
||||
assert body["credentials"] == {"botToken": "***"}
|
||||
|
||||
patched = client.patch(
|
||||
f"/api/channel-connections/{connection_id}",
|
||||
json={
|
||||
"displayName": "Telegram Ops",
|
||||
"config": {"maxMessageChars": 2048},
|
||||
"secrets": {"botToken": "token-2"},
|
||||
},
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["connection"]["display_name"] == "Telegram Ops"
|
||||
assert patched.json()["connection"]["runtime_config"] == {"max_message_chars": 2048}
|
||||
assert patched.json()["credentials"] == {"botToken": "***"}
|
||||
|
||||
listed = client.get("/api/channel-connections")
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()[0]["connection_id"] == connection_id
|
||||
assert "credentials_ref" not in listed.json()[0]
|
||||
|
||||
revoked = client.post(f"/api/channel-connections/{connection_id}/revoke")
|
||||
assert revoked.status_code == 200
|
||||
assert revoked.json()["connection"]["status"] == "revoked"
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_channel_connectors_api_lists_registered_connectors(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
|
||||
encoding="utf-8",
|
||||
)
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/channel-connectors")
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"kind": "feishu"}, {"kind": "telegram"}, {"kind": "weixin"}]
|
||||
@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from beaver.interfaces.channels.connections import (
|
||||
ChannelConnectionStore,
|
||||
CredentialStore,
|
||||
PairingTokenStore,
|
||||
)
|
||||
|
||||
|
||||
def test_channel_connection_store_creates_updates_lists_and_revokes(tmp_path) -> None:
|
||||
store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
|
||||
created = store.create(
|
||||
kind="telegram",
|
||||
mode="polling",
|
||||
display_name="Telegram Main",
|
||||
account_id="telegram:bot-main",
|
||||
owner_user_id="user-1",
|
||||
auth_type="token",
|
||||
runtime_config={"max_message_chars": 4096},
|
||||
capabilities=["receive_text", "send_text"],
|
||||
)
|
||||
updated = store.update_status(created.connection_id, status="connected", last_error=None)
|
||||
revoked = store.revoke(created.connection_id)
|
||||
|
||||
assert created.connection_id
|
||||
assert created.channel_id.startswith("telegram-")
|
||||
assert created.status == "draft"
|
||||
assert updated.status == "connected"
|
||||
assert revoked.status == "revoked"
|
||||
assert store.get(created.connection_id).status == "revoked"
|
||||
assert [item.connection_id for item in store.list()] == [created.connection_id]
|
||||
|
||||
|
||||
def test_credential_store_saves_values_by_reference_and_redacts_views(tmp_path) -> None:
|
||||
store = CredentialStore(tmp_path / "credentials.json")
|
||||
|
||||
ref = store.put(kind="telegram", values={"botToken": "secret-token", "empty": ""})
|
||||
|
||||
assert ref.startswith("cred_")
|
||||
assert store.get(ref) == {"botToken": "secret-token"}
|
||||
assert store.redacted(ref) == {"botToken": "***"}
|
||||
|
||||
|
||||
def test_pairing_token_store_uses_one_time_expiring_tokens(tmp_path) -> None:
|
||||
store = PairingTokenStore(tmp_path / "pairing.json")
|
||||
|
||||
session = store.create(kind="terminal", ttl_seconds=60, scope="channel:pair")
|
||||
consumed = store.consume(session.token, expected_kind="terminal")
|
||||
reused = store.consume(session.token, expected_kind="terminal")
|
||||
|
||||
assert session.status == "pending"
|
||||
assert consumed is not None
|
||||
assert consumed.status == "consumed"
|
||||
assert reused is None
|
||||
|
||||
|
||||
def test_pairing_token_store_rejects_expired_tokens(tmp_path) -> None:
|
||||
store = PairingTokenStore(tmp_path / "pairing.json")
|
||||
|
||||
session = store.create(kind="weixin", ttl_seconds=-1, scope="channel:pair")
|
||||
|
||||
assert store.consume(session.token, expected_kind="weixin") is None
|
||||
@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from beaver.foundation.config.schema import ChannelConfig
|
||||
from beaver.interfaces.channels.connections import (
|
||||
ChannelConnectionStore,
|
||||
ChannelConnectorRegistry,
|
||||
ChannelRuntimeSpec,
|
||||
CredentialStore,
|
||||
ValidationResult,
|
||||
)
|
||||
|
||||
|
||||
class FakeConnector:
|
||||
kind = "fake"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.validated: list[str] = []
|
||||
self.revoked: list[str] = []
|
||||
|
||||
async def validate(self, connection_id: str) -> ValidationResult:
|
||||
self.validated.append(connection_id)
|
||||
return ValidationResult(ok=True, status="connected", account_id="fake-account", display_name="Fake")
|
||||
|
||||
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
|
||||
return ChannelRuntimeSpec(
|
||||
channel_id="fake-channel",
|
||||
kind="fake",
|
||||
mode="webhook",
|
||||
account_id="fake-account",
|
||||
display_name="Fake",
|
||||
config={"enabled": True},
|
||||
)
|
||||
|
||||
async def revoke(self, connection_id: str) -> None:
|
||||
self.revoked.append(connection_id)
|
||||
return None
|
||||
|
||||
|
||||
def test_connector_registry_dispatches_by_kind(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
connector = FakeConnector()
|
||||
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
|
||||
registry.register(connector)
|
||||
|
||||
connection = connection_store.create(
|
||||
kind="fake",
|
||||
mode="webhook",
|
||||
display_name="Fake",
|
||||
account_id="fake-account",
|
||||
owner_user_id=None,
|
||||
auth_type="token",
|
||||
)
|
||||
result = await registry.validate(connection.connection_id)
|
||||
spec = await registry.materialize_runtime(connection.connection_id)
|
||||
|
||||
assert result.ok is True
|
||||
assert connector.validated == [connection.connection_id]
|
||||
assert spec.channel_id == "fake-channel"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_connector_registry_materializes_channel_configs_with_credentials(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
|
||||
connection = connection_store.create(
|
||||
kind="fake",
|
||||
mode="webhook",
|
||||
display_name="Connected",
|
||||
account_id="connected",
|
||||
owner_user_id=None,
|
||||
auth_type="token",
|
||||
credentials_ref=credentials_ref,
|
||||
)
|
||||
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
|
||||
|
||||
class CredentialAwareConnector(FakeConnector):
|
||||
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
|
||||
stored = connection_store.get(connection_id)
|
||||
return ChannelRuntimeSpec(
|
||||
channel_id="fake-channel",
|
||||
kind="fake",
|
||||
mode="webhook",
|
||||
account_id="fake-account",
|
||||
display_name="Fake",
|
||||
config={"enabled": True},
|
||||
secrets_ref=stored.credentials_ref,
|
||||
)
|
||||
|
||||
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
|
||||
registry.register(CredentialAwareConnector())
|
||||
|
||||
configs = await registry.materialize_channel_configs()
|
||||
|
||||
assert isinstance(configs["fake-channel"], ChannelConfig)
|
||||
assert configs["fake-channel"].enabled is True
|
||||
assert configs["fake-channel"].secrets == {"botToken": "token-1"}
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_connector_registry_materializes_only_connected_connections(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
|
||||
registry.register(FakeConnector())
|
||||
|
||||
draft = connection_store.create(
|
||||
kind="fake",
|
||||
mode="webhook",
|
||||
display_name="Draft",
|
||||
account_id="draft",
|
||||
owner_user_id=None,
|
||||
auth_type="token",
|
||||
)
|
||||
connected = connection_store.create(
|
||||
kind="fake",
|
||||
mode="webhook",
|
||||
display_name="Connected",
|
||||
account_id="connected",
|
||||
owner_user_id=None,
|
||||
auth_type="token",
|
||||
)
|
||||
connection_store.update_status(connected.connection_id, status="connected", last_error=None)
|
||||
|
||||
specs = await registry.materialize_connected_runtime_specs()
|
||||
|
||||
assert [spec.channel_id for spec in specs] == ["fake-channel"]
|
||||
assert connection_store.get(draft.connection_id).status == "draft"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_connector_registry_revoke_calls_connector_and_updates_store(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
connector = FakeConnector()
|
||||
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
|
||||
registry.register(connector)
|
||||
|
||||
connection = connection_store.create(
|
||||
kind="fake",
|
||||
mode="webhook",
|
||||
display_name="Fake",
|
||||
account_id="fake-account",
|
||||
owner_user_id=None,
|
||||
auth_type="token",
|
||||
)
|
||||
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
|
||||
|
||||
await registry.revoke(connection.connection_id)
|
||||
|
||||
assert connector.revoked == [connection.connection_id]
|
||||
assert connection_store.get(connection.connection_id).status == "revoked"
|
||||
|
||||
asyncio.run(run())
|
||||
414
app-instance/backend/tests/unit/test_channel_runtime.py
Normal file
414
app-instance/backend/tests/unit/test_channel_runtime.py
Normal file
@ -0,0 +1,414 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.foundation.config.schema import ChannelConfig
|
||||
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
|
||||
from beaver.foundation.events import MessageBus
|
||||
from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter
|
||||
from beaver.interfaces.channels.runtime import ChannelRuntime
|
||||
from beaver.interfaces.channels.state import ChannelDedupeStore, ChannelEventLog
|
||||
from beaver.interfaces.web.app import _self_restart_enabled, create_app
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
def test_channel_identity_builds_stable_session_id() -> None:
|
||||
identity = ChannelIdentity(
|
||||
channel_id="webhook-dev",
|
||||
kind="webhook",
|
||||
account_id="local",
|
||||
peer_id="demo-user",
|
||||
thread_id="main",
|
||||
peer_type="dm",
|
||||
message_id="msg-1",
|
||||
)
|
||||
|
||||
assert identity.session_id() == "webhook-dev:local:demo-user:main"
|
||||
assert identity.dedupe_key() == "webhook-dev:local:demo-user:main:msg-1"
|
||||
|
||||
|
||||
def test_channel_identity_requires_routing_fields() -> None:
|
||||
identity = ChannelIdentity(channel_id="webhook-dev", kind="webhook", account_id="", peer_id="demo")
|
||||
|
||||
assert identity.validation_error() == "account_id is required"
|
||||
|
||||
|
||||
def test_messages_carry_channel_identity() -> None:
|
||||
identity = ChannelIdentity(
|
||||
channel_id="webhook-dev",
|
||||
kind="webhook",
|
||||
account_id="local",
|
||||
peer_id="demo-user",
|
||||
message_id="msg-1",
|
||||
)
|
||||
|
||||
inbound = InboundMessage(channel="webhook-dev", content="hello", channel_identity=identity)
|
||||
outbound = OutboundMessage(
|
||||
channel="webhook-dev",
|
||||
content="ok",
|
||||
session_id=identity.session_id(),
|
||||
finish_reason="stop",
|
||||
channel_identity=identity,
|
||||
)
|
||||
|
||||
assert inbound.channel_identity is identity
|
||||
assert outbound.channel_identity is identity
|
||||
|
||||
|
||||
def test_dedupe_store_tracks_processing_and_done(tmp_path) -> None:
|
||||
store = ChannelDedupeStore(tmp_path / "dedupe.json", retention_hours=48)
|
||||
|
||||
created = store.mark_processing(
|
||||
dedupe_key="webhook-dev:local:demo:msg-1",
|
||||
session_id="webhook-dev:local:demo",
|
||||
message_id="msg-1",
|
||||
)
|
||||
duplicate = store.mark_processing(
|
||||
dedupe_key="webhook-dev:local:demo:msg-1",
|
||||
session_id="webhook-dev:local:demo",
|
||||
message_id="msg-1",
|
||||
)
|
||||
|
||||
assert created.created is True
|
||||
assert duplicate.created is False
|
||||
assert duplicate.record is not None
|
||||
assert duplicate.record["status"] == "processing"
|
||||
|
||||
store.mark_done(
|
||||
dedupe_key="webhook-dev:local:demo:msg-1",
|
||||
run_id="run-1",
|
||||
reply="hello" * 10000,
|
||||
max_reply_chars=20,
|
||||
)
|
||||
|
||||
done = store.get("webhook-dev:local:demo:msg-1")
|
||||
assert done is not None
|
||||
assert done["status"] == "done"
|
||||
assert done["reply"] == "hellohellohellohello"
|
||||
|
||||
|
||||
def test_channel_event_log_writes_recent_events(tmp_path) -> None:
|
||||
log = ChannelEventLog(tmp_path / "events.jsonl")
|
||||
log.record(
|
||||
channel_id="webhook-dev",
|
||||
kind="inbound_accepted",
|
||||
session_id="webhook-dev:local:demo",
|
||||
message_id="msg-1",
|
||||
status="ok",
|
||||
text="hello world",
|
||||
)
|
||||
|
||||
events = log.recent(channel_id="webhook-dev", limit=10)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0]["kind"] == "inbound_accepted"
|
||||
assert events[0]["text_preview"] == "hello world"
|
||||
assert "raw_channel_payload" not in json.dumps(events[0])
|
||||
|
||||
|
||||
class FakeAgentService:
|
||||
is_running = True
|
||||
|
||||
async def handle_inbound_message(self, inbound):
|
||||
return OutboundMessage(
|
||||
message_id=inbound.message_id,
|
||||
channel=inbound.channel,
|
||||
content=f"echo:{inbound.content}",
|
||||
session_id=inbound.session_id,
|
||||
finish_reason="stop",
|
||||
run_id="run-1",
|
||||
channel_identity=inbound.channel_identity,
|
||||
)
|
||||
|
||||
|
||||
class SlowFakeAgentService(FakeAgentService):
|
||||
async def handle_inbound_message(self, inbound):
|
||||
await asyncio.sleep(0.05)
|
||||
return await super().handle_inbound_message(inbound)
|
||||
|
||||
|
||||
def test_channel_runtime_accept_inbound_normalizes_session_and_dedupes(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
runtime = ChannelRuntime(
|
||||
service=FakeAgentService(),
|
||||
bus=bus,
|
||||
workspace=tmp_path,
|
||||
channels={},
|
||||
)
|
||||
identity = ChannelIdentity(
|
||||
channel_id="webhook-dev",
|
||||
kind="webhook",
|
||||
account_id="local",
|
||||
peer_id="demo",
|
||||
message_id="msg-1",
|
||||
)
|
||||
result = await runtime.accept_inbound(
|
||||
InboundMessage(
|
||||
channel="webhook-dev",
|
||||
content="hello",
|
||||
session_id="wrong",
|
||||
channel_identity=identity,
|
||||
)
|
||||
)
|
||||
duplicate = await runtime.accept_inbound(
|
||||
InboundMessage(
|
||||
channel="webhook-dev",
|
||||
content="hello",
|
||||
channel_identity=identity,
|
||||
)
|
||||
)
|
||||
|
||||
queued = await bus.consume_inbound()
|
||||
assert result.accepted is True
|
||||
assert queued.session_id == "webhook-dev:local:demo"
|
||||
assert duplicate.accepted is False
|
||||
assert duplicate.duplicate is True
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_generic_webhook_adapter_waits_for_outbound_reply(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
runtime = ChannelRuntime(
|
||||
service=FakeAgentService(),
|
||||
bus=bus,
|
||||
workspace=tmp_path,
|
||||
channels={},
|
||||
)
|
||||
adapter = GenericWebhookAdapter(
|
||||
channel_id="webhook-dev",
|
||||
kind="webhook",
|
||||
mode="webhook",
|
||||
account_id="local",
|
||||
display_name="Webhook Dev",
|
||||
inbound_sink=runtime,
|
||||
response_timeout_seconds=1,
|
||||
)
|
||||
runtime.manager.register(adapter)
|
||||
await runtime.start()
|
||||
try:
|
||||
response = await adapter.handle_webhook_payload(
|
||||
{
|
||||
"peer_id": "demo",
|
||||
"message_id": "msg-1",
|
||||
"text": "hello",
|
||||
"peer_type": "dm",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
assert response["ok"] is True
|
||||
assert response["reply"] == "echo:hello"
|
||||
assert response["session_id"] == "webhook-dev:local:demo"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_generic_webhook_records_unclaimed_outbound_after_timeout(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
runtime = ChannelRuntime(
|
||||
service=SlowFakeAgentService(),
|
||||
bus=bus,
|
||||
workspace=tmp_path,
|
||||
channels={},
|
||||
)
|
||||
adapter = GenericWebhookAdapter(
|
||||
channel_id="webhook-dev",
|
||||
kind="webhook",
|
||||
mode="webhook",
|
||||
account_id="local",
|
||||
display_name="Webhook Dev",
|
||||
inbound_sink=runtime,
|
||||
response_timeout_seconds=1,
|
||||
)
|
||||
adapter.response_timeout_seconds = 0.01
|
||||
runtime.manager.register(adapter)
|
||||
await runtime.start()
|
||||
try:
|
||||
response = await adapter.handle_webhook_payload(
|
||||
{
|
||||
"peer_id": "demo",
|
||||
"message_id": "msg-1",
|
||||
"text": "hello",
|
||||
"peer_type": "dm",
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
events = runtime.recent_events("webhook-dev", limit=20)
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
assert response["pending"] is True
|
||||
assert any(event["kind"] == "outbound_unclaimed" for event in events)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_channel_runtime_starts_enabled_generic_webhook_and_reports_status(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
runtime = ChannelRuntime(
|
||||
service=FakeAgentService(),
|
||||
workspace=tmp_path,
|
||||
channels={
|
||||
"webhook-dev": ChannelConfig(
|
||||
enabled=True,
|
||||
kind="webhook",
|
||||
mode="webhook",
|
||||
account_id="local",
|
||||
display_name="Webhook Dev",
|
||||
config={"response_timeout_seconds": 1800},
|
||||
),
|
||||
"off": ChannelConfig(
|
||||
enabled=False,
|
||||
kind="webhook",
|
||||
mode="webhook",
|
||||
account_id="local",
|
||||
),
|
||||
},
|
||||
)
|
||||
await runtime.start()
|
||||
try:
|
||||
statuses = runtime.statuses()
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
by_id = {item["channel_id"]: item for item in statuses}
|
||||
assert by_id["webhook-dev"]["state"] == "running"
|
||||
assert by_id["webhook-dev"]["webhook_url"] == "/api/channels/webhook-dev/webhook"
|
||||
assert by_id["off"]["state"] == "disabled"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_channel_runtime_builds_platform_adapters_without_starting_networks(tmp_path) -> None:
|
||||
runtime = ChannelRuntime(
|
||||
service=FakeAgentService(),
|
||||
workspace=tmp_path,
|
||||
channels={},
|
||||
)
|
||||
|
||||
cases = {
|
||||
"telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"),
|
||||
"feishu-main": ChannelConfig(enabled=True, kind="feishu", mode="websocket", account_id="tenant-main"),
|
||||
"qq-main": ChannelConfig(enabled=True, kind="qqbot", mode="websocket", account_id="qq-main"),
|
||||
"weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"),
|
||||
}
|
||||
|
||||
for channel_id, cfg in cases.items():
|
||||
adapter = runtime._build_adapter(channel_id, cfg)
|
||||
assert adapter.channel_id == channel_id
|
||||
assert adapter.kind == cfg.kind
|
||||
assert adapter.mode == cfg.mode
|
||||
|
||||
|
||||
def test_channel_runtime_reports_platform_capabilities(tmp_path) -> None:
|
||||
runtime = ChannelRuntime(
|
||||
service=FakeAgentService(),
|
||||
workspace=tmp_path,
|
||||
channels={
|
||||
"telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"),
|
||||
"weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"),
|
||||
},
|
||||
)
|
||||
|
||||
by_id = {item["channel_id"]: item for item in runtime.statuses()}
|
||||
|
||||
assert by_id["telegram-main"]["capabilities"] == [
|
||||
"receive_text",
|
||||
"send_text",
|
||||
"receive_media",
|
||||
"groups",
|
||||
]
|
||||
assert by_id["weixin-main"]["capabilities"] == [
|
||||
"receive_text",
|
||||
"send_text",
|
||||
"receive_media",
|
||||
"direct_messages",
|
||||
]
|
||||
|
||||
|
||||
def test_channel_runtime_platform_start_failure_does_not_stop_other_channels(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
runtime = ChannelRuntime(
|
||||
service=FakeAgentService(),
|
||||
workspace=tmp_path,
|
||||
channels={
|
||||
"telegram-main": ChannelConfig(
|
||||
enabled=True,
|
||||
kind="telegram",
|
||||
mode="polling",
|
||||
account_id="bot-main",
|
||||
secrets={},
|
||||
),
|
||||
"off": ChannelConfig(
|
||||
enabled=False,
|
||||
kind="weixin",
|
||||
mode="polling",
|
||||
account_id="wx-main",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
await runtime.start()
|
||||
try:
|
||||
by_id = {item["channel_id"]: item for item in runtime.statuses()}
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
assert by_id["telegram-main"]["state"] == "error"
|
||||
assert "botToken" in by_id["telegram-main"]["last_error"]
|
||||
assert by_id["off"]["state"] == "disabled"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_web_app_status_exposes_configured_channel(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}},
|
||||
"providers": {},
|
||||
"channels": {
|
||||
"webhook-dev": {
|
||||
"enabled": True,
|
||||
"kind": "webhook",
|
||||
"mode": "webhook",
|
||||
"accountId": "local",
|
||||
"displayName": "Webhook Dev",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
payload = client.get("/api/status").json()
|
||||
|
||||
service.close()
|
||||
assert payload["channels"][0]["channel_id"] == "webhook-dev"
|
||||
assert payload["channels"][0]["state"] == "running"
|
||||
assert payload["channels"][0]["webhook_url"] == "/api/channels/webhook-dev/webhook"
|
||||
assert payload["runtime_controls"]["self_restart"] is True
|
||||
|
||||
|
||||
def test_self_restart_env_defaults_enabled(monkeypatch) -> None:
|
||||
monkeypatch.delenv("BEAVER_ENABLE_SELF_RESTART", raising=False)
|
||||
|
||||
assert _self_restart_enabled() is True
|
||||
|
||||
|
||||
def test_self_restart_env_can_disable(monkeypatch) -> None:
|
||||
monkeypatch.setenv("BEAVER_ENABLE_SELF_RESTART", "0")
|
||||
|
||||
assert _self_restart_enabled() is False
|
||||
@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from beaver.foundation.config.schema import ChannelConfig
|
||||
from beaver.foundation.events import MessageBus, OutboundMessage
|
||||
from beaver.interfaces.channels.runtime import ChannelRuntime
|
||||
|
||||
|
||||
class FakeService:
|
||||
async def handle_inbound_message(self, inbound):
|
||||
return OutboundMessage(channel=inbound.channel, content="ok", session_id=inbound.session_id, finish_reason="stop")
|
||||
|
||||
|
||||
def test_runtime_add_channel_starts_new_channel_after_runtime_start(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
|
||||
await runtime.start()
|
||||
try:
|
||||
await runtime.add_channel(
|
||||
"webhook-dev",
|
||||
ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"),
|
||||
)
|
||||
assert "webhook-dev" in runtime.adapters
|
||||
assert runtime.states["webhook-dev"]["state"] == "running"
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_runtime_add_channel_noops_for_same_config(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
cfg = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct")
|
||||
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
|
||||
await runtime.start()
|
||||
try:
|
||||
await runtime.add_channel("webhook-dev", cfg)
|
||||
first = runtime.adapters["webhook-dev"]
|
||||
await runtime.add_channel("webhook-dev", cfg)
|
||||
assert runtime.adapters["webhook-dev"] is first
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_runtime_replacement_failure_keeps_old_channel(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
good = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct")
|
||||
bad = ChannelConfig(enabled=True, kind="missing", mode="http", account_id="acct")
|
||||
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
|
||||
await runtime.start()
|
||||
try:
|
||||
await runtime.add_channel("webhook-dev", good)
|
||||
old = runtime.adapters["webhook-dev"]
|
||||
try:
|
||||
await runtime.add_channel("webhook-dev", bad)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("Expected ValueError")
|
||||
assert runtime.adapters["webhook-dev"] is old
|
||||
assert runtime.channel_configs["webhook-dev"] == good
|
||||
assert runtime.states["webhook-dev"]["state"] == "running"
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_runtime_remove_channel_stops_and_unregisters(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
|
||||
await runtime.start()
|
||||
try:
|
||||
await runtime.add_channel(
|
||||
"webhook-dev",
|
||||
ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"),
|
||||
)
|
||||
await runtime.remove_channel("webhook-dev")
|
||||
assert "webhook-dev" not in runtime.adapters
|
||||
assert "webhook-dev" not in runtime.manager.channels
|
||||
assert runtime.states["webhook-dev"]["state"] == "removed"
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_runtime_builds_external_connector_channel(tmp_path, monkeypatch) -> None:
|
||||
async def run() -> None:
|
||||
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
|
||||
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
|
||||
await runtime.start()
|
||||
try:
|
||||
await runtime.add_channel(
|
||||
"weixin-main",
|
||||
ChannelConfig(
|
||||
enabled=True,
|
||||
kind="external_connector",
|
||||
mode="http",
|
||||
account_id="weixin:me",
|
||||
display_name="Weixin Main",
|
||||
config={
|
||||
"platformKind": "weixin",
|
||||
"connectionId": "conn_1",
|
||||
"sidecarBaseUrl": "http://external-connector:8787",
|
||||
},
|
||||
),
|
||||
)
|
||||
adapter = runtime.adapters["weixin-main"]
|
||||
assert adapter.kind == "external_connector"
|
||||
assert adapter.mode == "http"
|
||||
assert getattr(adapter, "platform_kind") == "weixin"
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
@ -1,10 +1,13 @@
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.engine import AgentLoop, EngineLoader
|
||||
from beaver.engine.providers import make_provider_bundle
|
||||
from beaver.engine.providers.litellm import LiteLLMProvider
|
||||
from beaver.foundation.config import load_config
|
||||
from beaver.interfaces.web.app import _reload_agent_config
|
||||
from beaver.interfaces.web.app import create_app, _reload_agent_config
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
@ -44,6 +47,44 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None:
|
||||
assert target["extra_headers"] == {"X-Test": "1"}
|
||||
|
||||
|
||||
def test_config_loader_reads_channels(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"model": "openai/gpt-5"}},
|
||||
"channels": {
|
||||
"webhook-dev": {
|
||||
"enabled": True,
|
||||
"kind": "webhook",
|
||||
"mode": "webhook",
|
||||
"accountId": "local",
|
||||
"displayName": "Webhook Dev",
|
||||
"config": {
|
||||
"responseTimeoutSeconds": 1800,
|
||||
"dedupeRetentionHours": 48,
|
||||
},
|
||||
"secrets": {"ignored_for_status": "secret-value"},
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_config(config_path=config_path)
|
||||
|
||||
channel = config.channels["webhook-dev"]
|
||||
assert channel.enabled is True
|
||||
assert channel.kind == "webhook"
|
||||
assert channel.mode == "webhook"
|
||||
assert channel.account_id == "local"
|
||||
assert channel.display_name == "Webhook Dev"
|
||||
assert channel.config["response_timeout_seconds"] == 1800
|
||||
assert channel.config["dedupe_retention_hours"] == 48
|
||||
assert channel.secrets == {"ignored_for_status": "secret-value"}
|
||||
|
||||
|
||||
def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
@ -161,6 +202,201 @@ def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_reload_agent_config_keeps_running_service_when_old_mcp_close_fails(tmp_path) -> None:
|
||||
async def run_case() -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": str(workspace), "model": "old-model"}},
|
||||
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://old.example.com/v1"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
service = AgentService(config_path=config_path)
|
||||
await service.start()
|
||||
|
||||
class FailingMCPManager:
|
||||
async def close(self) -> None:
|
||||
raise RuntimeError("Attempted to exit cancel scope in a different task than it was entered in")
|
||||
|
||||
loaded = service.create_loop().boot()
|
||||
loaded.mcp_manager = FailingMCPManager()
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": str(workspace), "model": "new-model"}},
|
||||
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://new.example.com/v1"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
unhandled: list[dict[str, object]] = []
|
||||
previous_handler = loop.get_exception_handler()
|
||||
loop.set_exception_handler(lambda _loop, context: unhandled.append(context))
|
||||
try:
|
||||
_reload_agent_config(service, config_path)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
target = service.create_loop().boot().config.resolve_provider_target()
|
||||
assert service.is_running is True
|
||||
assert target["model"] == "new-model"
|
||||
assert target["api_base"] == "https://new.example.com/v1"
|
||||
assert unhandled == []
|
||||
finally:
|
||||
loop.set_exception_handler(previous_handler)
|
||||
await service.shutdown(force=True)
|
||||
|
||||
asyncio.run(run_case())
|
||||
|
||||
|
||||
def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"maxTokens": 12345,
|
||||
"temperature": 0.4,
|
||||
"maxToolIterations": 9,
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_config(config_path=config_path)
|
||||
service = AgentService(config_path=config_path)
|
||||
|
||||
assert config.agents_defaults.max_tokens == 12345
|
||||
assert config.agents_defaults.temperature == 0.4
|
||||
assert config.agents_defaults.max_tool_iterations == 9
|
||||
assert service.profile.max_tokens == 12345
|
||||
assert service.profile.temperature == 0.4
|
||||
assert service.profile.max_tool_iterations == 9
|
||||
service.close()
|
||||
|
||||
|
||||
def test_agent_config_api_persists_and_reloads_defaults(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(json.dumps({"agents": {"defaults": {}}}), encoding="utf-8")
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/agent-config",
|
||||
json={"max_tokens": 8192, "temperature": 0.6, "max_tool_iterations": 12},
|
||||
)
|
||||
status = client.get("/api/status")
|
||||
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
defaults = saved["agents"]["defaults"]
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
assert defaults["maxTokens"] == 8192
|
||||
assert defaults["temperature"] == 0.6
|
||||
assert defaults["maxToolIterations"] == 12
|
||||
assert service.profile.max_tokens == 8192
|
||||
assert service.profile.temperature == 0.6
|
||||
assert service.profile.max_tool_iterations == 12
|
||||
assert status.json()["max_tokens"] == 8192
|
||||
assert status.json()["temperature"] == 0.6
|
||||
assert status.json()["max_tool_iterations"] == 12
|
||||
service.close()
|
||||
|
||||
|
||||
def test_agent_config_api_accepts_zero_temperature_and_iterations(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/agent-config",
|
||||
json={"max_tokens": None, "temperature": 0, "max_tool_iterations": 0},
|
||||
)
|
||||
|
||||
config = load_config(config_path=config_path)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert config.agents_defaults.max_tokens is None
|
||||
assert config.agents_defaults.temperature == 0
|
||||
assert config.agents_defaults.max_tool_iterations == 0
|
||||
assert service.profile.max_tokens is None
|
||||
assert service.profile.temperature == 0
|
||||
assert service.profile.max_tool_iterations == 0
|
||||
service.close()
|
||||
|
||||
|
||||
def test_channel_config_api_persists_and_masks_secrets(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"model": "openai/gpt-5"}},
|
||||
"channels": {
|
||||
"telegram-main": {
|
||||
"enabled": False,
|
||||
"kind": "telegram",
|
||||
"mode": "polling",
|
||||
"accountId": "bot-main",
|
||||
"displayName": "Telegram Main",
|
||||
"secrets": {"botToken": "1234567890abcdef"},
|
||||
"config": {"requireMentionInGroups": True},
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
before = client.get("/api/channels/telegram-main/config")
|
||||
response = client.post(
|
||||
"/api/channels/telegram-main/config",
|
||||
json={
|
||||
"enabled": True,
|
||||
"kind": "telegram",
|
||||
"mode": "polling",
|
||||
"account_id": "bot-main",
|
||||
"display_name": "Telegram Primary",
|
||||
"secrets": {"botToken": ""},
|
||||
"config": {
|
||||
"requireMentionInGroups": False,
|
||||
"allowFrom": ["1001", "1002"],
|
||||
"maxMessageChars": 3000,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
channel = saved["channels"]["telegram-main"]
|
||||
|
||||
assert before.status_code == 200
|
||||
assert before.json()["secrets"] == {"botToken": "1234••••cdef"}
|
||||
assert response.status_code == 200
|
||||
assert response.json()["ok"] is True
|
||||
assert response.json()["restart_required"] is True
|
||||
assert response.json()["channel"]["display_name"] == "Telegram Primary"
|
||||
assert response.json()["channel"]["secrets"] == {"botToken": "1234••••cdef"}
|
||||
assert channel["enabled"] is True
|
||||
assert channel["displayName"] == "Telegram Primary"
|
||||
assert channel["secrets"]["botToken"] == "1234567890abcdef"
|
||||
assert channel["config"]["allowFrom"] == ["1001", "1002"]
|
||||
assert load_config(config_path=config_path).channels["telegram-main"].enabled is True
|
||||
service.close()
|
||||
|
||||
|
||||
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
|
||||
bundle = make_provider_bundle(
|
||||
model="qwen-plus",
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from beaver.interfaces.channels.connections import MessageDedupeStore
|
||||
|
||||
|
||||
def test_message_dedupe_store_completes_and_dedupes_completed(tmp_path) -> None:
|
||||
store = MessageDedupeStore(tmp_path / "message_dedupe.json")
|
||||
|
||||
first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
|
||||
store.complete(first.dedupe_key, message_id="msg_1")
|
||||
duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
|
||||
|
||||
assert first.should_process is True
|
||||
assert duplicate.should_process is False
|
||||
assert duplicate.status == "completed"
|
||||
assert duplicate.http_status == 200
|
||||
|
||||
|
||||
def test_message_dedupe_store_returns_conflict_for_active_processing(tmp_path) -> None:
|
||||
store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=60)
|
||||
|
||||
store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
|
||||
duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
|
||||
|
||||
assert duplicate.should_process is False
|
||||
assert duplicate.status == "processing"
|
||||
assert duplicate.http_status == 409
|
||||
assert duplicate.retry_after_seconds == 5
|
||||
|
||||
|
||||
def test_message_dedupe_store_reprocesses_stale_processing(tmp_path) -> None:
|
||||
store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=0)
|
||||
|
||||
store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
|
||||
stale = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
|
||||
|
||||
assert stale.should_process is True
|
||||
assert stale.status == "processing"
|
||||
assert stale.record.delivery_attempts == 2
|
||||
|
||||
|
||||
def test_message_dedupe_store_reprocesses_failed_records(tmp_path) -> None:
|
||||
store = MessageDedupeStore(tmp_path / "message_dedupe.json")
|
||||
|
||||
first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
|
||||
store.fail(first.dedupe_key, error="runtime rejected")
|
||||
retry = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
|
||||
|
||||
assert retry.should_process is True
|
||||
assert retry.record.delivery_attempts == 2
|
||||
assert retry.record.last_error is None
|
||||
@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.interfaces.channels.connections import ChannelConnectionStore
|
||||
from beaver.interfaces.web.app import create_app
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
def _app(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("BEAVER_BRIDGE_TOKEN", "bridge-token")
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
|
||||
encoding="utf-8",
|
||||
)
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
return app, service
|
||||
|
||||
|
||||
def _connected_connection(tmp_path):
|
||||
state_dir = tmp_path / "state" / "channel_connections"
|
||||
store = ChannelConnectionStore(state_dir / "connections.json")
|
||||
connection = store.create(
|
||||
kind="weixin",
|
||||
mode="sidecar",
|
||||
display_name="Weixin Main",
|
||||
account_id="weixin:me",
|
||||
owner_user_id=None,
|
||||
auth_type="connector_session",
|
||||
)
|
||||
store.update_status(connection.connection_id, status="connected", last_error=None)
|
||||
return connection
|
||||
|
||||
|
||||
def _payload(connection, *, event_id: str = "evt-1", delivery_attempt: int = 1) -> dict:
|
||||
return {
|
||||
"eventId": event_id,
|
||||
"timestamp": "2026-06-02T09:30:00Z",
|
||||
"deliveryAttempt": delivery_attempt,
|
||||
"connectionId": connection.connection_id,
|
||||
"channelId": connection.channel_id,
|
||||
"kind": "weixin",
|
||||
"accountId": "weixin:me",
|
||||
"peerId": "peer-1",
|
||||
"peerType": "dm",
|
||||
"userId": "sender-1",
|
||||
"threadId": None,
|
||||
"messageId": "msg-1",
|
||||
"messageType": "text",
|
||||
"content": "hello",
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
|
||||
def test_bridge_endpoint_accepts_valid_event(tmp_path, monkeypatch) -> None:
|
||||
app, service = _app(tmp_path, monkeypatch)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
connection = _connected_connection(tmp_path)
|
||||
response = client.post(
|
||||
"/api/channel-connector-bridge/events",
|
||||
headers={"Authorization": "Bearer bridge-token"},
|
||||
json=_payload(connection),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["accepted"] is True
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_bridge_endpoint_rejects_invalid_token(tmp_path, monkeypatch) -> None:
|
||||
app, service = _app(tmp_path, monkeypatch)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
connection = _connected_connection(tmp_path)
|
||||
response = client.post(
|
||||
"/api/channel-connector-bridge/events",
|
||||
headers={"Authorization": "Bearer wrong"},
|
||||
json=_payload(connection),
|
||||
)
|
||||
assert response.status_code == 401
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_bridge_endpoint_dedupes_repeated_event(tmp_path, monkeypatch) -> None:
|
||||
app, service = _app(tmp_path, monkeypatch)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
connection = _connected_connection(tmp_path)
|
||||
first = client.post(
|
||||
"/api/channel-connector-bridge/events",
|
||||
headers={"Authorization": "Bearer bridge-token"},
|
||||
json=_payload(connection),
|
||||
)
|
||||
second = client.post(
|
||||
"/api/channel-connector-bridge/events",
|
||||
headers={"Authorization": "Bearer bridge-token"},
|
||||
json=_payload(connection, delivery_attempt=2),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code in {200, 409}
|
||||
assert second.json()["duplicate"] is True
|
||||
finally:
|
||||
service.close()
|
||||
@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from beaver.foundation.events import ChannelIdentity, OutboundMessage
|
||||
from beaver.interfaces.channels.external_connector import ExternalConnectorChannel, _request_id
|
||||
|
||||
|
||||
class FakeSidecarClient:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[dict] = []
|
||||
|
||||
async def send(self, payload: dict) -> dict:
|
||||
self.sent.append(payload)
|
||||
return {"ok": True, "providerMessageId": "provider-1"}
|
||||
|
||||
|
||||
def test_external_connector_channel_sends_with_target_and_request_id() -> None:
|
||||
async def run() -> None:
|
||||
client = FakeSidecarClient()
|
||||
channel = ExternalConnectorChannel(
|
||||
channel_id="weixin-main",
|
||||
platform_kind="weixin",
|
||||
connection_id="conn_1",
|
||||
account_id="weixin:me",
|
||||
display_name="Weixin Main",
|
||||
sidecar_client=client,
|
||||
)
|
||||
message = OutboundMessage(
|
||||
channel="weixin-main",
|
||||
content="reply",
|
||||
session_id="s1",
|
||||
finish_reason="stop",
|
||||
message_id="out-msg-1",
|
||||
channel_identity=ChannelIdentity(
|
||||
channel_id="weixin-main",
|
||||
kind="weixin",
|
||||
account_id="weixin:me",
|
||||
peer_id="peer-1",
|
||||
peer_type="dm",
|
||||
thread_id=None,
|
||||
user_id="sender-1",
|
||||
message_id="in-msg-1",
|
||||
),
|
||||
metadata={"inbound_metadata": {"contextToken": "ctx-1"}},
|
||||
)
|
||||
|
||||
await channel.send(message)
|
||||
|
||||
assert client.sent == [
|
||||
{
|
||||
"requestId": "out_weixin-main:s1:out-msg-1",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "weixin-main",
|
||||
"kind": "weixin",
|
||||
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
|
||||
"content": "reply",
|
||||
"metadata": {"inboundMessageId": "in-msg-1", "sessionId": "s1", "contextToken": "ctx-1"},
|
||||
}
|
||||
]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_external_connector_request_id_falls_back_when_message_id_is_none_or_blank() -> None:
|
||||
identity = ChannelIdentity(
|
||||
channel_id="weixin-main",
|
||||
kind="weixin",
|
||||
account_id="weixin:me",
|
||||
peer_id="peer-1",
|
||||
peer_type="dm",
|
||||
message_id="in-msg-1",
|
||||
)
|
||||
first = OutboundMessage(
|
||||
channel="weixin-main",
|
||||
content="same reply",
|
||||
session_id="s1",
|
||||
finish_reason="stop",
|
||||
message_id=None, # type: ignore[arg-type]
|
||||
channel_identity=identity,
|
||||
)
|
||||
second = OutboundMessage(
|
||||
channel="weixin-main",
|
||||
content="same reply",
|
||||
session_id="s1",
|
||||
finish_reason="stop",
|
||||
message_id="",
|
||||
channel_identity=identity,
|
||||
)
|
||||
|
||||
assert _request_id(first) == _request_id(second)
|
||||
assert _request_id(first).startswith("out_weixin-main:s1:")
|
||||
|
||||
|
||||
def test_external_connector_channel_requires_identity() -> None:
|
||||
async def run() -> None:
|
||||
channel = ExternalConnectorChannel(
|
||||
channel_id="weixin-main",
|
||||
platform_kind="weixin",
|
||||
connection_id="conn_1",
|
||||
account_id="weixin:me",
|
||||
display_name="Weixin Main",
|
||||
sidecar_client=FakeSidecarClient(),
|
||||
)
|
||||
message = OutboundMessage(channel="weixin-main", content="reply", session_id="s1", finish_reason="stop")
|
||||
|
||||
try:
|
||||
await channel.send(message)
|
||||
except ValueError as exc:
|
||||
assert "channel_identity is required" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected ValueError")
|
||||
|
||||
asyncio.run(run())
|
||||
@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.interfaces.channels.connections import (
|
||||
ChannelConnectionStore,
|
||||
ChannelConnectorRegistry,
|
||||
CredentialStore,
|
||||
FeishuConnector,
|
||||
WeixinConnector,
|
||||
)
|
||||
from beaver.interfaces.web.app import create_app
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
class FakeSidecarClient:
|
||||
def __init__(self) -> None:
|
||||
self.sessions: dict[str, dict] = {}
|
||||
self.started: list[dict] = []
|
||||
self.logged_out: list[str] = []
|
||||
|
||||
async def start_session(self, payload: dict) -> dict:
|
||||
self.started.append(payload)
|
||||
session = {
|
||||
"sessionId": "cs_1",
|
||||
"kind": payload["kind"],
|
||||
"status": "qr_ready",
|
||||
"qrImage": "data:image/png;base64,abc",
|
||||
"accountId": None,
|
||||
"displayName": None,
|
||||
"metadata": {},
|
||||
}
|
||||
self.sessions["cs_1"] = session
|
||||
return session
|
||||
|
||||
async def get_session(self, session_id: str) -> dict:
|
||||
return self.sessions[session_id]
|
||||
|
||||
async def logout(self, connection_id: str) -> dict:
|
||||
self.logged_out.append(connection_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def test_weixin_connector_starts_connector_session(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
client = FakeSidecarClient()
|
||||
connector = WeixinConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=client,
|
||||
sidecar_base_url="http://external-connector:8787",
|
||||
)
|
||||
|
||||
view = await connector.start_session(display_name="Weixin Main", owner_user_id="user-1", options={})
|
||||
|
||||
assert view["sessionId"] == "cs_1"
|
||||
assert view["connectionId"].startswith("conn_")
|
||||
assert client.started[0]["kind"] == "weixin"
|
||||
assert client.started[0]["connectionId"].startswith("conn_")
|
||||
assert connection_store.list()[0].kind == "weixin"
|
||||
assert connection_store.list()[0].status == "pairing"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_weixin_connector_poll_connected_materializes_external_runtime(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
client = FakeSidecarClient()
|
||||
connector = WeixinConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=client,
|
||||
sidecar_base_url="http://external-connector:8787",
|
||||
)
|
||||
await connector.start_session(display_name="Weixin Main", owner_user_id=None, options={})
|
||||
connection = connection_store.list()[0]
|
||||
client.sessions["cs_1"] = {
|
||||
"sessionId": "cs_1",
|
||||
"kind": "weixin",
|
||||
"status": "connected",
|
||||
"accountId": "weixin:me",
|
||||
"displayName": "Me",
|
||||
"metadata": {"stateRef": "state-1"},
|
||||
}
|
||||
|
||||
result = await connector.poll_session("cs_1")
|
||||
updated = connection_store.get(connection.connection_id)
|
||||
spec = await connector.materialize_runtime(connection.connection_id)
|
||||
|
||||
assert result["status"] == "connected"
|
||||
assert updated.status == "connected"
|
||||
assert updated.account_id == "weixin:me"
|
||||
assert spec.kind == "external_connector"
|
||||
assert spec.mode == "http"
|
||||
assert spec.config["platformKind"] == "weixin"
|
||||
assert spec.config["sidecarBaseUrl"] == "http://external-connector:8787"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_feishu_connector_uses_feishu_kind(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
client = FakeSidecarClient()
|
||||
connector = FeishuConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=client,
|
||||
sidecar_base_url="http://external-connector:8787",
|
||||
)
|
||||
|
||||
await connector.start_session(display_name="Feishu Main", owner_user_id=None, options={"domain": "feishu"})
|
||||
|
||||
assert client.started[0]["kind"] == "feishu"
|
||||
assert client.started[0]["options"] == {"domain": "feishu"}
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
|
||||
encoding="utf-8",
|
||||
)
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
client = FakeSidecarClient()
|
||||
|
||||
try:
|
||||
with TestClient(app) as http:
|
||||
state_dir = tmp_path / "state" / "channel_connections"
|
||||
connection_store = ChannelConnectionStore(state_dir / "connections.json")
|
||||
credential_store = CredentialStore(state_dir / "credentials.json")
|
||||
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
|
||||
registry.register(
|
||||
WeixinConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=client,
|
||||
sidecar_base_url="http://external-connector:8787",
|
||||
)
|
||||
)
|
||||
app.state.channel_connector_registry = registry
|
||||
|
||||
started = http.post(
|
||||
"/api/channel-connector-sessions",
|
||||
json={"kind": "weixin", "displayName": "Weixin Main", "options": {}},
|
||||
)
|
||||
session_id = started.json()["session"]["sessionId"]
|
||||
connection_id = started.json()["connection"]["connection_id"]
|
||||
client.sessions[session_id] = {
|
||||
"sessionId": session_id,
|
||||
"kind": "weixin",
|
||||
"status": "connected",
|
||||
"accountId": "weixin:me",
|
||||
"displayName": "Me",
|
||||
"metadata": {},
|
||||
}
|
||||
polled = http.get(f"/api/channel-connector-sessions/{session_id}")
|
||||
|
||||
assert started.status_code == 200
|
||||
assert polled.status_code == 200
|
||||
assert polled.json()["connection"]["status"] == "connected"
|
||||
assert connection_store.get(connection_id).status == "connected"
|
||||
assert polled.json()["connection"]["channel_id"] in app.state.channel_runtime.adapters
|
||||
finally:
|
||||
service.close()
|
||||
154
app-instance/backend/tests/unit/test_feishu_channel_adapter.py
Normal file
154
app-instance/backend/tests/unit/test_feishu_channel_adapter.py
Normal file
@ -0,0 +1,154 @@
|
||||
import asyncio
|
||||
|
||||
from beaver.foundation.events import OutboundMessage
|
||||
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
|
||||
|
||||
|
||||
class FakeSink:
|
||||
def __init__(self) -> None:
|
||||
self.messages = []
|
||||
|
||||
async def accept_inbound(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
|
||||
class FakeFeishuClient:
|
||||
def __init__(self) -> None:
|
||||
self.sent = []
|
||||
|
||||
async def send_text(self, *, receive_id_type: str, receive_id: str, text: str):
|
||||
self.sent.append({"receive_id_type": receive_id_type, "receive_id": receive_id, "text": text})
|
||||
|
||||
|
||||
def test_feishu_normalizes_direct_text_event() -> None:
|
||||
async def run() -> None:
|
||||
sink = FakeSink()
|
||||
adapter = FeishuAdapter(
|
||||
channel_id="feishu-main",
|
||||
kind="feishu",
|
||||
mode="websocket",
|
||||
account_id="tenant-main",
|
||||
display_name=None,
|
||||
inbound_sink=sink,
|
||||
secrets={"appId": "app", "appSecret": "secret"},
|
||||
config={},
|
||||
client=FakeFeishuClient(),
|
||||
)
|
||||
|
||||
await adapter.handle_event_payload(
|
||||
{
|
||||
"event": {
|
||||
"message": {
|
||||
"message_id": "m1",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"hello\"}",
|
||||
},
|
||||
"sender": {"sender_id": {"open_id": "ou_user"}},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
message = sink.messages[0]
|
||||
assert message.content == "hello"
|
||||
assert message.session_id == "feishu-main:tenant-main:oc_chat"
|
||||
assert message.channel_identity.peer_type == "dm"
|
||||
assert message.channel_identity.user_id == "ou_user"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_feishu_group_mention_gate() -> None:
|
||||
async def run() -> None:
|
||||
sink = FakeSink()
|
||||
adapter = FeishuAdapter(
|
||||
channel_id="feishu-main",
|
||||
kind="feishu",
|
||||
mode="websocket",
|
||||
account_id="tenant-main",
|
||||
display_name=None,
|
||||
inbound_sink=sink,
|
||||
secrets={"appId": "app", "appSecret": "secret"},
|
||||
config={"requireMentionInGroups": True, "botOpenId": "ou_bot"},
|
||||
client=FakeFeishuClient(),
|
||||
)
|
||||
|
||||
await adapter.handle_event_payload(
|
||||
{
|
||||
"event": {
|
||||
"message": {
|
||||
"message_id": "m1",
|
||||
"chat_id": "oc_group",
|
||||
"chat_type": "group",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"hello\"}",
|
||||
"mentions": [],
|
||||
},
|
||||
"sender": {"sender_id": {"open_id": "ou_user"}},
|
||||
}
|
||||
}
|
||||
)
|
||||
await adapter.handle_event_payload(
|
||||
{
|
||||
"event": {
|
||||
"message": {
|
||||
"message_id": "m2",
|
||||
"chat_id": "oc_group",
|
||||
"chat_type": "group",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"hello\"}",
|
||||
"mentions": [{"id": {"open_id": "ou_bot"}}],
|
||||
},
|
||||
"sender": {"sender_id": {"open_id": "ou_user"}},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert len(sink.messages) == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_feishu_sends_text_to_chat_id() -> None:
|
||||
async def run() -> None:
|
||||
sink = FakeSink()
|
||||
client = FakeFeishuClient()
|
||||
adapter = FeishuAdapter(
|
||||
channel_id="feishu-main",
|
||||
kind="feishu",
|
||||
mode="websocket",
|
||||
account_id="tenant-main",
|
||||
display_name=None,
|
||||
inbound_sink=sink,
|
||||
secrets={"appId": "app", "appSecret": "secret"},
|
||||
config={},
|
||||
client=client,
|
||||
)
|
||||
await adapter.handle_event_payload(
|
||||
{
|
||||
"event": {
|
||||
"message": {
|
||||
"message_id": "m1",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"hello\"}",
|
||||
},
|
||||
"sender": {"sender_id": {"open_id": "ou_user"}},
|
||||
}
|
||||
}
|
||||
)
|
||||
await adapter.send(
|
||||
OutboundMessage(
|
||||
channel="feishu-main",
|
||||
content="ok",
|
||||
session_id=sink.messages[0].session_id,
|
||||
finish_reason="stop",
|
||||
channel_identity=sink.messages[0].channel_identity,
|
||||
)
|
||||
)
|
||||
|
||||
assert client.sent == [{"receive_id_type": "chat_id", "receive_id": "oc_chat", "text": "ok"}]
|
||||
|
||||
asyncio.run(run())
|
||||
@ -2,9 +2,10 @@ import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from beaver.foundation.events import InboundMessage, MessageBus
|
||||
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
|
||||
from beaver.interfaces.channels import ChannelManager, MemoryChannelAdapter
|
||||
from beaver.interfaces.gateway.main import run_gateway
|
||||
from beaver.interfaces.channels.runtime import ChannelRuntime
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
@ -52,22 +53,15 @@ class InvalidService:
|
||||
is_running = True
|
||||
|
||||
|
||||
def test_gateway_routes_memory_channel_roundtrip() -> None:
|
||||
def test_gateway_routes_memory_channel_roundtrip(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
channel = MemoryChannelAdapter(bus)
|
||||
stop_event = asyncio.Event()
|
||||
task = asyncio.create_task(
|
||||
run_gateway(
|
||||
service=FakeService(),
|
||||
manage_service_lifecycle=False,
|
||||
bus=bus,
|
||||
channels=[channel],
|
||||
stop_event=stop_event,
|
||||
)
|
||||
)
|
||||
runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path)
|
||||
channel = MemoryChannelAdapter(runtime)
|
||||
runtime.manager.register(channel)
|
||||
await runtime.start()
|
||||
|
||||
await channel.publish_text("hello", session_id="s1")
|
||||
await channel.publish_text("hello", peer_id="s1", message_id="m1")
|
||||
for _ in range(40):
|
||||
if channel.sent_messages:
|
||||
break
|
||||
@ -76,38 +70,73 @@ def test_gateway_routes_memory_channel_roundtrip() -> None:
|
||||
assert channel.sent_messages
|
||||
message = channel.sent_messages[0]
|
||||
assert message.content == "echo:hello"
|
||||
assert message.session_id == "s1"
|
||||
assert message.session_id == "memory-dev:memory:s1"
|
||||
assert message.finish_reason == "stop"
|
||||
assert message.metadata["task_id"] == "task-1"
|
||||
assert message.metadata["task_status"] == "awaiting_acceptance"
|
||||
assert message.metadata["evidence_status"] == "recorded"
|
||||
assert message.metadata["validation_result"] is None
|
||||
|
||||
stop_event.set()
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
await runtime.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_gateway_delivers_cancelled_outbound_to_channel() -> None:
|
||||
def test_channel_manager_dispatches_by_channel_id() -> None:
|
||||
class CaptureChannel:
|
||||
channel_id = "webhook-dev"
|
||||
kind = "webhook"
|
||||
mode = "webhook"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent = []
|
||||
|
||||
async def start(self) -> None:
|
||||
pass
|
||||
|
||||
async def stop(self) -> None:
|
||||
pass
|
||||
|
||||
async def send(self, message: Any) -> None:
|
||||
self.sent.append(message)
|
||||
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
channel = MemoryChannelAdapter(bus)
|
||||
stop_event = asyncio.Event()
|
||||
task = asyncio.create_task(
|
||||
run_gateway(
|
||||
service=SlowService(),
|
||||
manage_service_lifecycle=False,
|
||||
bus=bus,
|
||||
channels=[channel],
|
||||
stop_event=stop_event,
|
||||
channel = CaptureChannel()
|
||||
manager = ChannelManager(bus)
|
||||
manager.register(channel)
|
||||
await bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel="webhook-dev",
|
||||
content="ok",
|
||||
session_id="webhook-dev:local:demo",
|
||||
finish_reason="stop",
|
||||
)
|
||||
)
|
||||
|
||||
await channel.publish_text("slow", session_id="s1")
|
||||
await asyncio.sleep(0.05)
|
||||
stop_event = asyncio.Event()
|
||||
stop_event.set()
|
||||
await asyncio.wait_for(task, timeout=3)
|
||||
|
||||
await manager.dispatch_outbound(stop_event)
|
||||
|
||||
assert channel.sent[0].content == "ok"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_gateway_delivers_cancelled_outbound_to_channel(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
runtime = ChannelRuntime(service=SlowService(), bus=bus, channels={}, workspace=tmp_path)
|
||||
channel = MemoryChannelAdapter(runtime)
|
||||
runtime.manager.register(channel)
|
||||
await runtime.start()
|
||||
|
||||
await channel.publish_text("slow", peer_id="s1", message_id="m1")
|
||||
for _ in range(40):
|
||||
if any(event["kind"] == "direct_run_started" for event in runtime.events.recent(limit=20)):
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
await runtime.stop()
|
||||
|
||||
assert channel.sent_messages
|
||||
assert channel.sent_messages[0].finish_reason == "cancelled"
|
||||
@ -118,13 +147,27 @@ def test_gateway_delivers_cancelled_outbound_to_channel() -> None:
|
||||
def test_gateway_rejects_channel_manager_and_channels_together() -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
class CaptureChannel:
|
||||
channel_id = "memory-dev"
|
||||
kind = "memory"
|
||||
mode = "webhook"
|
||||
|
||||
async def start(self) -> None:
|
||||
pass
|
||||
|
||||
async def stop(self) -> None:
|
||||
pass
|
||||
|
||||
async def send(self, message: Any) -> None:
|
||||
pass
|
||||
|
||||
try:
|
||||
await run_gateway(
|
||||
service=FakeService(),
|
||||
manage_service_lifecycle=False,
|
||||
bus=bus,
|
||||
channel_manager=ChannelManager(bus),
|
||||
channels=[MemoryChannelAdapter(bus)],
|
||||
channels=[CaptureChannel()],
|
||||
stop_event=asyncio.Event(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
@ -212,10 +255,16 @@ def test_channel_manager_keeps_unknown_channel_outbound_undeliverable() -> None:
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
|
||||
def test_memory_channel_adapts_payload_to_channel_identity_session_id(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
channel = MemoryChannelAdapter(bus, name="telegram")
|
||||
runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path)
|
||||
channel = MemoryChannelAdapter(
|
||||
runtime,
|
||||
channel_id="telegram-main",
|
||||
kind="telegram",
|
||||
account_id="bot-main",
|
||||
)
|
||||
inbound = await channel.publish_external_text(
|
||||
"hello",
|
||||
chat_id="chat-1",
|
||||
@ -225,8 +274,10 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
|
||||
|
||||
queued = await bus.consume_inbound()
|
||||
assert queued is inbound
|
||||
assert queued.channel == "telegram"
|
||||
assert queued.session_id == "telegram:chat-1"
|
||||
assert queued.channel == "telegram-main"
|
||||
assert queued.session_id == "telegram-main:bot-main:chat-1"
|
||||
assert queued.channel_identity is not None
|
||||
assert queued.channel_identity.kind == "telegram"
|
||||
assert queued.metadata["chat_id"] == "chat-1"
|
||||
assert queued.metadata["message_id"] == "message-1"
|
||||
assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"}
|
||||
@ -236,7 +287,9 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
|
||||
|
||||
def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
|
||||
class StartedChannel:
|
||||
name = "started"
|
||||
channel_id = "started"
|
||||
kind = "memory"
|
||||
mode = "webhook"
|
||||
|
||||
def __init__(self, bus: MessageBus) -> None:
|
||||
self.bus = bus
|
||||
@ -252,7 +305,9 @@ def test_channel_manager_start_cancellation_rolls_back_started_channels() -> Non
|
||||
pass
|
||||
|
||||
class BlockingChannel:
|
||||
name = "blocking"
|
||||
channel_id = "blocking"
|
||||
kind = "memory"
|
||||
mode = "webhook"
|
||||
|
||||
def __init__(self, bus: MessageBus) -> None:
|
||||
self.bus = bus
|
||||
|
||||
@ -6,6 +6,34 @@ from beaver.interfaces.web.app import create_app
|
||||
from beaver.interfaces.web.schemas import WebChatRequest, WebChatResponse
|
||||
|
||||
|
||||
def test_platform_channel_modules_import_without_live_clients() -> None:
|
||||
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
|
||||
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
|
||||
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
|
||||
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
|
||||
|
||||
assert FeishuAdapter.KIND == "feishu"
|
||||
assert QQBotAdapter.KIND == "qqbot"
|
||||
assert TelegramAdapter.KIND == "telegram"
|
||||
assert WeixinAdapter.KIND == "weixin"
|
||||
|
||||
|
||||
def test_platform_channel_optional_extras_are_declared() -> None:
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
||||
extras = data["project"]["optional-dependencies"]
|
||||
|
||||
assert "python-telegram-bot>=22.0,<23.0" in extras["telegram"]
|
||||
assert "lark-oapi>=1.4.22,<2.0.0" in extras["feishu"]
|
||||
assert "aiohttp>=3.9.0,<4.0.0" in extras["qqbot"]
|
||||
assert "aiohttp>=3.9.0,<4.0.0" in extras["weixin"]
|
||||
assert "python-telegram-bot>=22.0,<23.0" in extras["channels"]
|
||||
assert "lark-oapi>=1.4.22,<2.0.0" in extras["channels"]
|
||||
|
||||
|
||||
def test_agent_loop_boots(tmp_path) -> None:
|
||||
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
|
||||
loaded = loop.boot()
|
||||
@ -32,10 +60,14 @@ def test_message_bus_imports() -> None:
|
||||
|
||||
def test_channel_imports() -> None:
|
||||
bus = MessageBus()
|
||||
channel = MemoryChannelAdapter(bus)
|
||||
class Sink:
|
||||
async def accept_inbound(self, message):
|
||||
await bus.publish_inbound(message)
|
||||
|
||||
channel = MemoryChannelAdapter(Sink())
|
||||
manager = ChannelManager(bus)
|
||||
manager.register(channel)
|
||||
assert manager.channels["memory"] is channel
|
||||
assert manager.channels["memory-dev"] is channel
|
||||
|
||||
|
||||
def test_web_schema_imports() -> None:
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from beaver.engine import EngineLoader
|
||||
from beaver.skills.catalog.utils import parse_frontmatter
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
EXPECTED_INITIAL_SKILL_TOOLS = {
|
||||
"cron-scheduler": ["cron"],
|
||||
"filesystem-operation": ["read_file", "write_file", "patch_file", "search_files", "list_directory"],
|
||||
"memory-management": ["memory"],
|
||||
"outlook-mail": [
|
||||
"mcp_outlook_mcp_mail_list_folders",
|
||||
"mcp_outlook_mcp_mail_list_messages",
|
||||
"mcp_outlook_mcp_mail_search_messages",
|
||||
"mcp_outlook_mcp_mail_get_message",
|
||||
"mcp_outlook_mcp_mail_send_email",
|
||||
"mcp_outlook_mcp_mail_reply_to_message",
|
||||
"mcp_outlook_mcp_mail_forward_message",
|
||||
"mcp_outlook_mcp_mail_move_message",
|
||||
"mcp_outlook_mcp_mail_delta_sync",
|
||||
"mcp_outlook_mcp_calendar_list_events",
|
||||
"mcp_outlook_mcp_calendar_create_event",
|
||||
"mcp_outlook_mcp_calendar_update_event",
|
||||
"mcp_outlook_mcp_calendar_get_schedule",
|
||||
"mcp_outlook_mcp_calendar_find_meeting_times",
|
||||
"mcp_outlook_mcp_calendar_delta_sync",
|
||||
],
|
||||
"skills-admin": ["skills_list", "skill_manage", "skill_view"],
|
||||
"terminal-operation": ["terminal", "process", "execute_code"],
|
||||
"utility-tools": ["clarify", "delegate", "send_message", "spawn", "todo"],
|
||||
"web-operation": ["web_fetch", "web_search"],
|
||||
}
|
||||
|
||||
|
||||
def test_initial_skill_tool_hints_match_runtime_tool_names() -> None:
|
||||
for skill_name, expected_tools in EXPECTED_INITIAL_SKILL_TOOLS.items():
|
||||
skill_dir = REPO_ROOT / "skills" / skill_name / "versions" / "v0001"
|
||||
frontmatter, _body = parse_frontmatter((skill_dir / "SKILL.md").read_text(encoding="utf-8"))
|
||||
version = json.loads((skill_dir / "version.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert frontmatter["tools"] == expected_tools
|
||||
assert version["frontmatter"]["tools"] == expected_tools
|
||||
assert version["tool_hints"] == expected_tools
|
||||
|
||||
|
||||
def test_default_runtime_registers_skill_view_tool(tmp_path: Path) -> None:
|
||||
loaded = EngineLoader(workspace=tmp_path).load()
|
||||
try:
|
||||
assert "skill_view" in loaded.tools
|
||||
assert loaded.tool_registry is not None
|
||||
assert loaded.tool_registry.get("skill_view") is not None
|
||||
finally:
|
||||
loaded.close()
|
||||
64
app-instance/backend/tests/unit/test_max_tokens_defaults.py
Normal file
64
app-instance/backend/tests/unit/test_max_tokens_defaults.py
Normal file
@ -0,0 +1,64 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from beaver.engine.loop import AgentProfile
|
||||
from beaver.engine.providers.anthropic import AnthropicProvider
|
||||
from beaver.engine.providers.litellm import LiteLLMProvider
|
||||
|
||||
|
||||
def test_agent_profile_uses_provider_output_default() -> None:
|
||||
assert AgentProfile().max_tokens is None
|
||||
|
||||
|
||||
def test_litellm_omits_max_tokens_when_unset(monkeypatch) -> None:
|
||||
captured_kwargs: dict = {}
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(content="ok", tool_calls=[]),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
|
||||
|
||||
async def run_case():
|
||||
provider = LiteLLMProvider(default_model="openai/gpt-test")
|
||||
return await provider.chat(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
|
||||
|
||||
response = asyncio.run(run_case())
|
||||
|
||||
assert response.content == "ok"
|
||||
assert "max_tokens" not in captured_kwargs
|
||||
|
||||
|
||||
def test_anthropic_uses_model_output_ceiling_when_unset(monkeypatch) -> None:
|
||||
captured_kwargs: dict = {}
|
||||
|
||||
class FakeMessages:
|
||||
async def create(self, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
content=[SimpleNamespace(type="text", text="ok")],
|
||||
usage=None,
|
||||
stop_reason="stop",
|
||||
)
|
||||
|
||||
class FakeClient:
|
||||
messages = FakeMessages()
|
||||
|
||||
monkeypatch.setattr(AnthropicProvider, "_client_or_raise", lambda self: FakeClient())
|
||||
|
||||
async def run_case():
|
||||
provider = AnthropicProvider(default_model="claude-sonnet-4-5")
|
||||
return await provider.chat(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
|
||||
|
||||
response = asyncio.run(run_case())
|
||||
|
||||
assert response.content == "ok"
|
||||
assert captured_kwargs["max_tokens"] == 64_000
|
||||
@ -0,0 +1,66 @@
|
||||
from beaver.foundation.events import ChannelIdentity, OutboundMessage
|
||||
from beaver.interfaces.channels.platforms.base import (
|
||||
chunk_text,
|
||||
compact_media_summary,
|
||||
config_bool,
|
||||
config_list,
|
||||
outbound_target,
|
||||
)
|
||||
|
||||
|
||||
def test_config_helpers_normalize_common_values() -> None:
|
||||
assert config_bool({"enabled": "true"}, "enabled", default=False) is True
|
||||
assert config_bool({"enabled": "0"}, "enabled", default=True) is False
|
||||
assert config_list({"allowFrom": "u1,u2"}, "allowFrom") == ["u1", "u2"]
|
||||
assert config_list({"allowFrom": ["u1", 2]}, "allowFrom") == ["u1", "2"]
|
||||
|
||||
|
||||
def test_chunk_text_preserves_order_and_limit() -> None:
|
||||
chunks = chunk_text("abcdef", max_chars=2)
|
||||
|
||||
assert chunks == ["ab", "cd", "ef"]
|
||||
|
||||
|
||||
def test_outbound_target_prefers_channel_identity() -> None:
|
||||
identity = ChannelIdentity(
|
||||
channel_id="telegram-main",
|
||||
kind="telegram",
|
||||
account_id="bot-main",
|
||||
peer_id="chat-1",
|
||||
thread_id="topic-1",
|
||||
peer_type="group",
|
||||
user_id="user-1",
|
||||
)
|
||||
message = OutboundMessage(
|
||||
channel="telegram-main",
|
||||
content="ok",
|
||||
session_id="ignored",
|
||||
finish_reason="stop",
|
||||
channel_identity=identity,
|
||||
)
|
||||
|
||||
target = outbound_target(message)
|
||||
|
||||
assert target.peer_id == "chat-1"
|
||||
assert target.thread_id == "topic-1"
|
||||
assert target.peer_type == "group"
|
||||
assert target.user_id == "user-1"
|
||||
|
||||
|
||||
def test_outbound_target_falls_back_to_session_id() -> None:
|
||||
message = OutboundMessage(
|
||||
channel="telegram-main",
|
||||
content="ok",
|
||||
session_id="telegram-main:bot-main:chat-1:topic-1",
|
||||
finish_reason="stop",
|
||||
)
|
||||
|
||||
target = outbound_target(message)
|
||||
|
||||
assert target.peer_id == "chat-1"
|
||||
assert target.thread_id == "topic-1"
|
||||
|
||||
|
||||
def test_compact_media_summary_mentions_attachment_type() -> None:
|
||||
assert compact_media_summary("photo", file_name="cat.png") == "[photo: cat.png]"
|
||||
assert compact_media_summary("document") == "[document]"
|
||||
@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from beaver.engine.session import SessionManager
|
||||
from beaver.memory.runs import RunMemoryStore, RunRecord
|
||||
from beaver.services.process_service import SessionProcessProjector
|
||||
from beaver.skills.specs import SkillActivationReceipt
|
||||
|
||||
|
||||
def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
||||
@ -109,6 +110,18 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
run_id="main-run",
|
||||
role="system",
|
||||
event_type="task_acceptance_recorded",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"acceptance_type": "accept",
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
@ -123,6 +136,232 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
|
||||
assert any(event["actor_name"] == "Evidence" for event in projection["events"])
|
||||
assert any(run["session_id"] == "web:test" for run in projection["runs"])
|
||||
|
||||
planned_event = next(event for event in projection["events"] if event["kind"] == "task_planned")
|
||||
assert planned_event["metadata"]["timeline_type"] == "plan"
|
||||
assert planned_event["metadata"]["plan_mode"] == "team"
|
||||
assert planned_event["metadata"]["strategy"] == "sequence"
|
||||
assert planned_event["metadata"]["selected_skill_names"] == ["research-workflow"]
|
||||
|
||||
skill_event = next(event for event in projection["events"] if event["kind"] == "skill_selected")
|
||||
assert skill_event["metadata"]["timeline_type"] == "skill"
|
||||
assert skill_event["metadata"]["skill_names"] == ["research-workflow"]
|
||||
|
||||
team_event = next(event for event in projection["events"] if event["kind"] == "agent_team_created")
|
||||
assert team_event["metadata"]["timeline_type"] == "agent_team"
|
||||
assert team_event["metadata"]["team_run_ids"] == ["sub-run"]
|
||||
|
||||
node_event = next(event for event in projection["events"] if event["kind"] == "agent_finished")
|
||||
assert node_event["metadata"]["timeline_type"] == "agent_progress"
|
||||
assert "node_result" not in node_event["metadata"]
|
||||
|
||||
evidence_event = next(event for event in projection["events"] if event["kind"] == "task_result_ready")
|
||||
assert evidence_event["metadata"]["timeline_type"] == "result"
|
||||
assert evidence_event["status"] == "done"
|
||||
|
||||
acceptance_event = next(event for event in projection["events"] if event["kind"] == "task_acceptance_recorded")
|
||||
assert acceptance_event["metadata"]["timeline_type"] == "acceptance"
|
||||
|
||||
|
||||
def test_process_projection_maps_failed_task_team_events(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="failed-sub-run",
|
||||
session_id="failed-sub-session",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="failed sub task",
|
||||
started_at="2026-01-01T00:00:01+00:00",
|
||||
ended_at="2026-01-01T00:00:02+00:00",
|
||||
success=False,
|
||||
finish_reason="error",
|
||||
)
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_team_run_failed",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"team_success": False,
|
||||
"team_run_ids": ["failed-sub-run"],
|
||||
"error": "research node failed",
|
||||
"node_results": [
|
||||
{
|
||||
"node_id": "research",
|
||||
"success": False,
|
||||
"error": "source unavailable",
|
||||
"run_id": "failed-sub-run",
|
||||
"finish_reason": "error",
|
||||
}
|
||||
],
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
team_event = next(event for event in projection["events"] if event["kind"] == "agent_team_created")
|
||||
assert team_event["status"] == "error"
|
||||
assert team_event["metadata"]["timeline_type"] == "agent_team"
|
||||
assert team_event["metadata"]["team_run_ids"] == ["failed-sub-run"]
|
||||
|
||||
node_event = next(event for event in projection["events"] if event["kind"] == "agent_finished")
|
||||
assert node_event["status"] == "error"
|
||||
assert node_event["metadata"]["timeline_type"] == "agent_progress"
|
||||
|
||||
|
||||
def test_process_projection_uses_normalized_plan_metadata_defaults(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_execution_planned",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"plan_mode": None,
|
||||
"strategy": None,
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
root_run = next(run for run in projection["runs"] if run["run_id"] == "task:task-1:attempt:1")
|
||||
assert root_run["metadata"]["plan_mode"] == "single"
|
||||
assert root_run["metadata"]["strategy"] == "single"
|
||||
planned_event = next(event for event in projection["events"] if event["kind"] == "task_planned")
|
||||
assert planned_event["metadata"]["plan_mode"] == "single"
|
||||
assert planned_event["metadata"]["strategy"] == "single"
|
||||
|
||||
|
||||
def test_process_projection_emits_skill_card_from_main_run_receipts(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="main-run",
|
||||
session_id="web:test",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="main task",
|
||||
started_at="2026-01-01T00:00:03+00:00",
|
||||
ended_at="2026-01-01T00:00:04+00:00",
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
activated_skills=[
|
||||
SkillActivationReceipt(
|
||||
run_id="main-run",
|
||||
session_id="web:test",
|
||||
skill_name="web-operation",
|
||||
skill_version="1",
|
||||
content_hash="hash",
|
||||
activated_at="2026-01-01T00:00:03+00:00",
|
||||
activation_reason="Needs live web lookup.",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_execution_planned",
|
||||
event_payload={
|
||||
"task_id": "task-1",
|
||||
"attempt_index": 1,
|
||||
"plan_mode": "single",
|
||||
"strategy": "single",
|
||||
"selected_skill_names": [],
|
||||
},
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_synthesis_completed",
|
||||
event_payload={"task_id": "task-1", "attempt_index": 1, "main_run_id": "main-run"},
|
||||
context_visible=False,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
skill_events = [
|
||||
event
|
||||
for event in projection["events"]
|
||||
if event["kind"] == "skill_selected" and event["run_id"] == "main-run"
|
||||
]
|
||||
assert skill_events
|
||||
assert skill_events[0]["metadata"]["timeline_type"] == "skill"
|
||||
assert skill_events[0]["metadata"]["skill_names"] == ["web-operation"]
|
||||
|
||||
|
||||
def test_process_projection_emits_tool_cards_from_run_messages(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
|
||||
run_store.append_run_record(
|
||||
RunRecord(
|
||||
run_id="main-run",
|
||||
session_id="web:test",
|
||||
task_id="task-1",
|
||||
attempt_index=1,
|
||||
task_text="main task",
|
||||
started_at="2026-01-01T00:00:03+00:00",
|
||||
ended_at="2026-01-01T00:00:04+00:00",
|
||||
success=True,
|
||||
finish_reason="stop",
|
||||
)
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
role="system",
|
||||
event_type="task_execution_planned",
|
||||
event_payload={"task_id": "task-1", "attempt_index": 1},
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
run_id="main-run",
|
||||
role="assistant",
|
||||
event_type="assistant_message_added",
|
||||
event_payload={"task_id": "task-1"},
|
||||
content="Searching",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call-1",
|
||||
"name": "multi_search",
|
||||
"arguments": {"query": "Macau cafe near Bóvia"},
|
||||
}
|
||||
],
|
||||
context_visible=False,
|
||||
)
|
||||
session.append_message(
|
||||
"web:test",
|
||||
run_id="main-run",
|
||||
role="tool",
|
||||
event_type="tool_result_recorded",
|
||||
event_payload={"success": True, "error": None},
|
||||
content="Found 3 restaurants",
|
||||
tool_name="multi_search",
|
||||
tool_call_id="call-1",
|
||||
context_visible=True,
|
||||
)
|
||||
|
||||
projection = SessionProcessProjector(session, run_store).project("web:test")
|
||||
|
||||
tool_call = next(event for event in projection["events"] if event["kind"] == "tool_call_started")
|
||||
assert tool_call["metadata"]["timeline_type"] == "tool_call"
|
||||
assert tool_call["metadata"]["tool_name"] == "multi_search"
|
||||
assert tool_call["run_id"] == "main-run"
|
||||
|
||||
tool_result = next(event for event in projection["events"] if event["kind"] == "tool_call_finished")
|
||||
assert tool_result["metadata"]["timeline_type"] == "tool_result"
|
||||
assert tool_result["metadata"]["tool_name"] == "multi_search"
|
||||
assert tool_result["metadata"]["success"] is True
|
||||
|
||||
|
||||
def test_process_projection_exposes_ephemeral_guidance_artifacts(tmp_path: Path) -> None:
|
||||
session = SessionManager(tmp_path)
|
||||
|
||||
143
app-instance/backend/tests/unit/test_qqbot_channel_adapter.py
Normal file
143
app-instance/backend/tests/unit/test_qqbot_channel_adapter.py
Normal file
@ -0,0 +1,143 @@
|
||||
import asyncio
|
||||
|
||||
from beaver.foundation.events import OutboundMessage
|
||||
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
|
||||
|
||||
|
||||
class FakeSink:
|
||||
def __init__(self) -> None:
|
||||
self.messages = []
|
||||
|
||||
async def accept_inbound(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
|
||||
class FakeQQBotClient:
|
||||
def __init__(self) -> None:
|
||||
self.sent = []
|
||||
|
||||
async def send_text(self, *, peer_type: str, peer_id: str, content: str, message_id: str | None):
|
||||
self.sent.append(
|
||||
{
|
||||
"peer_type": peer_type,
|
||||
"peer_id": peer_id,
|
||||
"content": content,
|
||||
"message_id": message_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_qqbot_normalizes_private_c2c_message() -> None:
|
||||
async def run() -> None:
|
||||
sink = FakeSink()
|
||||
adapter = QQBotAdapter(
|
||||
channel_id="qq-main",
|
||||
kind="qqbot",
|
||||
mode="websocket",
|
||||
account_id="qq-bot",
|
||||
display_name=None,
|
||||
inbound_sink=sink,
|
||||
secrets={"appId": "app", "clientSecret": "secret"},
|
||||
config={},
|
||||
client=FakeQQBotClient(),
|
||||
)
|
||||
|
||||
await adapter.handle_event_payload(
|
||||
{
|
||||
"t": "C2C_MESSAGE_CREATE",
|
||||
"d": {
|
||||
"id": "m1",
|
||||
"author": {"user_openid": "u1"},
|
||||
"content": "hello",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
message = sink.messages[0]
|
||||
assert message.content == "hello"
|
||||
assert message.session_id == "qq-main:qq-bot:u1"
|
||||
assert message.channel_identity.peer_type == "dm"
|
||||
assert message.channel_identity.user_id == "u1"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_qqbot_normalizes_group_message() -> None:
|
||||
async def run() -> None:
|
||||
sink = FakeSink()
|
||||
adapter = QQBotAdapter(
|
||||
channel_id="qq-main",
|
||||
kind="qqbot",
|
||||
mode="websocket",
|
||||
account_id="qq-bot",
|
||||
display_name=None,
|
||||
inbound_sink=sink,
|
||||
secrets={"appId": "app", "clientSecret": "secret"},
|
||||
config={},
|
||||
client=FakeQQBotClient(),
|
||||
)
|
||||
|
||||
await adapter.handle_event_payload(
|
||||
{
|
||||
"t": "GROUP_AT_MESSAGE_CREATE",
|
||||
"d": {
|
||||
"id": "m2",
|
||||
"group_openid": "g1",
|
||||
"author": {"member_openid": "u1"},
|
||||
"content": "hello group",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
message = sink.messages[0]
|
||||
assert message.session_id == "qq-main:qq-bot:g1"
|
||||
assert message.channel_identity.peer_type == "group"
|
||||
assert message.channel_identity.user_id == "u1"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_qqbot_sends_reply_with_original_message_id() -> None:
|
||||
async def run() -> None:
|
||||
sink = FakeSink()
|
||||
client = FakeQQBotClient()
|
||||
adapter = QQBotAdapter(
|
||||
channel_id="qq-main",
|
||||
kind="qqbot",
|
||||
mode="websocket",
|
||||
account_id="qq-bot",
|
||||
display_name=None,
|
||||
inbound_sink=sink,
|
||||
secrets={"appId": "app", "clientSecret": "secret"},
|
||||
config={},
|
||||
client=client,
|
||||
)
|
||||
await adapter.handle_event_payload(
|
||||
{
|
||||
"t": "GROUP_AT_MESSAGE_CREATE",
|
||||
"d": {
|
||||
"id": "m2",
|
||||
"group_openid": "g1",
|
||||
"author": {"member_openid": "u1"},
|
||||
"content": "hello group",
|
||||
},
|
||||
}
|
||||
)
|
||||
await adapter.send(
|
||||
OutboundMessage(
|
||||
channel="qq-main",
|
||||
content="ok",
|
||||
session_id=sink.messages[0].session_id,
|
||||
finish_reason="stop",
|
||||
channel_identity=sink.messages[0].channel_identity,
|
||||
)
|
||||
)
|
||||
|
||||
assert client.sent[0] == {
|
||||
"peer_type": "group",
|
||||
"peer_id": "g1",
|
||||
"content": "ok",
|
||||
"message_id": "m2",
|
||||
}
|
||||
|
||||
asyncio.run(run())
|
||||
141
app-instance/backend/tests/unit/test_telegram_channel_adapter.py
Normal file
141
app-instance/backend/tests/unit/test_telegram_channel_adapter.py
Normal file
@ -0,0 +1,141 @@
|
||||
import asyncio
|
||||
|
||||
from beaver.foundation.events import OutboundMessage
|
||||
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
|
||||
|
||||
|
||||
class FakeSink:
|
||||
def __init__(self) -> None:
|
||||
self.messages = []
|
||||
|
||||
async def accept_inbound(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
|
||||
class FakeTelegramClient:
|
||||
def __init__(self) -> None:
|
||||
self.sent = []
|
||||
|
||||
async def send_message(self, **kwargs):
|
||||
self.sent.append(kwargs)
|
||||
|
||||
|
||||
def test_telegram_normalizes_private_text_message() -> None:
|
||||
async def run() -> None:
|
||||
sink = FakeSink()
|
||||
adapter = TelegramAdapter(
|
||||
channel_id="telegram-main",
|
||||
kind="telegram",
|
||||
mode="polling",
|
||||
account_id="bot-main",
|
||||
display_name=None,
|
||||
inbound_sink=sink,
|
||||
secrets={"botToken": "x"},
|
||||
config={},
|
||||
client=FakeTelegramClient(),
|
||||
)
|
||||
|
||||
await adapter.handle_update_payload(
|
||||
{
|
||||
"message": {
|
||||
"message_id": 100,
|
||||
"text": "hello",
|
||||
"chat": {"id": 200, "type": "private"},
|
||||
"from": {"id": 300, "username": "ivan"},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
message = sink.messages[0]
|
||||
assert message.channel == "telegram-main"
|
||||
assert message.content == "hello"
|
||||
assert message.session_id == "telegram-main:bot-main:200"
|
||||
assert message.channel_identity.peer_type == "dm"
|
||||
assert message.channel_identity.user_id == "300"
|
||||
assert message.channel_identity.message_id == "100"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_telegram_group_requires_mention_when_configured() -> None:
|
||||
async def run() -> None:
|
||||
sink = FakeSink()
|
||||
adapter = TelegramAdapter(
|
||||
channel_id="telegram-main",
|
||||
kind="telegram",
|
||||
mode="polling",
|
||||
account_id="bot-main",
|
||||
display_name=None,
|
||||
inbound_sink=sink,
|
||||
secrets={"botToken": "x"},
|
||||
config={"requireMentionInGroups": True, "botUsername": "beaver_bot"},
|
||||
client=FakeTelegramClient(),
|
||||
)
|
||||
|
||||
await adapter.handle_update_payload(
|
||||
{
|
||||
"message": {
|
||||
"message_id": 101,
|
||||
"text": "hello group",
|
||||
"chat": {"id": -20, "type": "group"},
|
||||
"from": {"id": 300},
|
||||
}
|
||||
}
|
||||
)
|
||||
await adapter.handle_update_payload(
|
||||
{
|
||||
"message": {
|
||||
"message_id": 102,
|
||||
"text": "@beaver_bot hello",
|
||||
"chat": {"id": -20, "type": "group"},
|
||||
"from": {"id": 300},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert len(sink.messages) == 1
|
||||
assert sink.messages[0].content == "hello"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_telegram_sends_chunked_reply_to_identity_target() -> None:
|
||||
async def run() -> None:
|
||||
sink = FakeSink()
|
||||
client = FakeTelegramClient()
|
||||
adapter = TelegramAdapter(
|
||||
channel_id="telegram-main",
|
||||
kind="telegram",
|
||||
mode="polling",
|
||||
account_id="bot-main",
|
||||
display_name=None,
|
||||
inbound_sink=sink,
|
||||
secrets={"botToken": "x"},
|
||||
config={"maxMessageChars": 3},
|
||||
client=client,
|
||||
)
|
||||
await adapter.handle_update_payload(
|
||||
{
|
||||
"message": {
|
||||
"message_id": 100,
|
||||
"text": "hello",
|
||||
"chat": {"id": 200, "type": "private"},
|
||||
"from": {"id": 300},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await adapter.send(
|
||||
OutboundMessage(
|
||||
channel="telegram-main",
|
||||
content="abcdef",
|
||||
session_id=sink.messages[0].session_id,
|
||||
finish_reason="stop",
|
||||
channel_identity=sink.messages[0].channel_identity,
|
||||
)
|
||||
)
|
||||
|
||||
assert [item["text"] for item in client.sent] == ["abc", "def"]
|
||||
assert client.sent[0]["chat_id"] == "200"
|
||||
|
||||
asyncio.run(run())
|
||||
@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from beaver.interfaces.channels.connections import (
|
||||
ChannelConnectionStore,
|
||||
CredentialStore,
|
||||
TelegramConnector,
|
||||
)
|
||||
|
||||
|
||||
class FakeTelegramClient:
|
||||
async def get_me(self):
|
||||
return {"id": 12345, "username": "beaver_bot", "first_name": "Beaver"}
|
||||
|
||||
|
||||
class BrokenTelegramClient:
|
||||
async def get_me(self):
|
||||
raise RuntimeError("invalid token")
|
||||
|
||||
|
||||
def test_telegram_connector_validates_token_and_updates_connection(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
|
||||
connection = connection_store.create(
|
||||
kind="telegram",
|
||||
mode="polling",
|
||||
display_name="Telegram Main",
|
||||
account_id="",
|
||||
owner_user_id="user-1",
|
||||
auth_type="token",
|
||||
credentials_ref=credentials_ref,
|
||||
runtime_config={"max_message_chars": 4096},
|
||||
)
|
||||
connector = TelegramConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
client_factory=lambda token: FakeTelegramClient(),
|
||||
)
|
||||
|
||||
result = await connector.validate(connection.connection_id)
|
||||
updated = connection_store.get(connection.connection_id)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.status == "connected"
|
||||
assert result.account_id == "telegram:12345"
|
||||
assert updated.account_id == "telegram:12345"
|
||||
assert updated.display_name == "Beaver (@beaver_bot)"
|
||||
assert updated.capabilities == ["receive_text", "send_text", "receive_media", "groups"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_telegram_connector_materializes_runtime_spec(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
|
||||
connection = connection_store.create(
|
||||
kind="telegram",
|
||||
mode="polling",
|
||||
display_name="Telegram Main",
|
||||
account_id="telegram:12345",
|
||||
owner_user_id=None,
|
||||
auth_type="token",
|
||||
credentials_ref=credentials_ref,
|
||||
runtime_config={"max_message_chars": 4096, "require_mention_in_groups": True},
|
||||
)
|
||||
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
|
||||
connector = TelegramConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
client_factory=lambda token: FakeTelegramClient(),
|
||||
)
|
||||
|
||||
spec = await connector.materialize_runtime(connection.connection_id)
|
||||
|
||||
assert spec.channel_id == connection.channel_id
|
||||
assert spec.kind == "telegram"
|
||||
assert spec.mode == "polling"
|
||||
assert spec.account_id == "telegram:12345"
|
||||
assert spec.config["max_message_chars"] == 4096
|
||||
assert spec.config["require_mention_in_groups"] is True
|
||||
assert spec.secrets_ref == credentials_ref
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_telegram_connector_validation_failure_sets_error_status(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "bad-token"})
|
||||
connection = connection_store.create(
|
||||
kind="telegram",
|
||||
mode="polling",
|
||||
display_name="Telegram Main",
|
||||
account_id="",
|
||||
owner_user_id=None,
|
||||
auth_type="token",
|
||||
credentials_ref=credentials_ref,
|
||||
)
|
||||
connector = TelegramConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
client_factory=lambda token: BrokenTelegramClient(),
|
||||
)
|
||||
|
||||
result = await connector.validate(connection.connection_id)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.status == "error"
|
||||
assert "invalid token" in (result.error or "")
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_telegram_connector_revoke_leaves_store_status_to_registry(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
connection = connection_store.create(
|
||||
kind="telegram",
|
||||
mode="polling",
|
||||
display_name="Telegram Main",
|
||||
account_id="telegram:12345",
|
||||
owner_user_id=None,
|
||||
auth_type="token",
|
||||
)
|
||||
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
|
||||
connector = TelegramConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
client_factory=lambda token: FakeTelegramClient(),
|
||||
)
|
||||
|
||||
await connector.revoke(connection.connection_id)
|
||||
|
||||
assert connection_store.get(connection.connection_id).status == "connected"
|
||||
|
||||
asyncio.run(run())
|
||||
@ -0,0 +1,243 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.foundation.events import InboundMessage, OutboundMessage
|
||||
from beaver.interfaces.web.app import create_app
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
class TerminalFakeAgentService(AgentService):
|
||||
def __init__(self, *, config_path: Path, delay_seconds: float = 0.0) -> None:
|
||||
super().__init__(config_path=config_path)
|
||||
self.delay_seconds = delay_seconds
|
||||
self.inbound_calls: list[InboundMessage] = []
|
||||
|
||||
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
|
||||
self.inbound_calls.append(inbound)
|
||||
if self.delay_seconds:
|
||||
await asyncio.sleep(self.delay_seconds)
|
||||
return OutboundMessage(
|
||||
message_id=inbound.message_id,
|
||||
channel=inbound.channel,
|
||||
content=f"echo:{inbound.content}",
|
||||
session_id=inbound.session_id,
|
||||
finish_reason="stop",
|
||||
run_id="run-1",
|
||||
channel_identity=inbound.channel_identity,
|
||||
)
|
||||
|
||||
|
||||
def write_terminal_config(tmp_path: Path) -> Path:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}},
|
||||
"providers": {},
|
||||
"channels": {
|
||||
"terminal-dev": {
|
||||
"enabled": True,
|
||||
"kind": "terminal",
|
||||
"mode": "websocket",
|
||||
"accountId": "local",
|
||||
"displayName": "Terminal Dev",
|
||||
"config": {"heartbeatSeconds": 30, "maxMessageChars": 20000},
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return config_path
|
||||
|
||||
|
||||
def test_terminal_websocket_connect_ping_and_message_roundtrip(tmp_path: Path) -> None:
|
||||
config_path = write_terminal_config(tmp_path)
|
||||
service = TerminalFakeAgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
||||
websocket.send_json(
|
||||
{
|
||||
"type": "connect",
|
||||
"peer_id": "device-001",
|
||||
"device_name": "desk-terminal",
|
||||
"capabilities": ["text"],
|
||||
}
|
||||
)
|
||||
assert websocket.receive_json() == {
|
||||
"type": "connected",
|
||||
"channel_id": "terminal-dev",
|
||||
"session_id": "terminal-dev:local:device-001",
|
||||
}
|
||||
|
||||
websocket.send_json({"type": "ping"})
|
||||
assert websocket.receive_json() == {"type": "pong"}
|
||||
|
||||
websocket.send_json(
|
||||
{
|
||||
"type": "message",
|
||||
"message_id": "device-001-000001",
|
||||
"text": "hello",
|
||||
}
|
||||
)
|
||||
assert websocket.receive_json() == {
|
||||
"type": "ack",
|
||||
"message_id": "device-001-000001",
|
||||
"session_id": "terminal-dev:local:device-001",
|
||||
"accepted": True,
|
||||
}
|
||||
reply = websocket.receive_json()
|
||||
|
||||
service.close()
|
||||
assert reply == {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"message_id": "device-001-000001",
|
||||
"run_id": "run-1",
|
||||
"text": "echo:hello",
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
assert len(service.inbound_calls) == 1
|
||||
inbound = service.inbound_calls[0]
|
||||
assert inbound.channel == "terminal-dev"
|
||||
assert inbound.content == "hello"
|
||||
assert inbound.content_type == "text"
|
||||
assert inbound.session_id == "terminal-dev:local:device-001"
|
||||
assert inbound.channel_identity is not None
|
||||
assert inbound.channel_identity.peer_id == "device-001"
|
||||
assert inbound.channel_identity.peer_type == "terminal"
|
||||
assert inbound.channel_identity.message_id == "device-001-000001"
|
||||
|
||||
|
||||
def test_terminal_websocket_rejects_message_before_connect(tmp_path: Path) -> None:
|
||||
config_path = write_terminal_config(tmp_path)
|
||||
service = TerminalFakeAgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
||||
websocket.send_json({"type": "message", "message_id": "m1", "text": "hello"})
|
||||
assert websocket.receive_json() == {
|
||||
"type": "error",
|
||||
"error": "connect is required before message",
|
||||
}
|
||||
websocket.send_json({"type": "ping"})
|
||||
assert websocket.receive_json() == {"type": "pong"}
|
||||
|
||||
service.close()
|
||||
assert service.inbound_calls == []
|
||||
|
||||
|
||||
def test_terminal_websocket_unknown_frame_keeps_connection_open(tmp_path: Path) -> None:
|
||||
config_path = write_terminal_config(tmp_path)
|
||||
service = TerminalFakeAgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
||||
websocket.send_json({"type": "example"})
|
||||
assert websocket.receive_json() == {
|
||||
"type": "error",
|
||||
"error": "Unsupported websocket frame type: example",
|
||||
}
|
||||
websocket.send_json({"type": "ping"})
|
||||
assert websocket.receive_json() == {"type": "pong"}
|
||||
|
||||
service.close()
|
||||
|
||||
|
||||
def test_terminal_websocket_validates_message_fields(tmp_path: Path) -> None:
|
||||
config_path = write_terminal_config(tmp_path)
|
||||
service = TerminalFakeAgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
||||
websocket.send_json({"type": "connect", "peer_id": "device-001"})
|
||||
assert websocket.receive_json()["type"] == "connected"
|
||||
|
||||
websocket.send_json({"type": "message", "text": "hello"})
|
||||
assert websocket.receive_json() == {"type": "error", "error": "message_id is required"}
|
||||
|
||||
websocket.send_json({"type": "message", "message_id": "m1", "text": " "})
|
||||
assert websocket.receive_json() == {"type": "error", "error": "text is required"}
|
||||
|
||||
service.close()
|
||||
assert service.inbound_calls == []
|
||||
|
||||
|
||||
def test_terminal_websocket_duplicate_message_returns_cached_reply(tmp_path: Path) -> None:
|
||||
config_path = write_terminal_config(tmp_path)
|
||||
service = TerminalFakeAgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
||||
websocket.send_json({"type": "connect", "peer_id": "device-001"})
|
||||
assert websocket.receive_json()["type"] == "connected"
|
||||
|
||||
frame = {"type": "message", "message_id": "device-001-000001", "text": "hello"}
|
||||
websocket.send_json(frame)
|
||||
assert websocket.receive_json()["accepted"] is True
|
||||
assert websocket.receive_json()["text"] == "echo:hello"
|
||||
|
||||
websocket.send_json(frame)
|
||||
duplicate = websocket.receive_json()
|
||||
|
||||
service.close()
|
||||
assert duplicate["type"] == "ack"
|
||||
assert duplicate["accepted"] is False
|
||||
assert duplicate["duplicate"] is True
|
||||
assert duplicate["pending"] is False
|
||||
assert duplicate["reply"] == "echo:hello"
|
||||
assert len(service.inbound_calls) == 1
|
||||
|
||||
|
||||
def test_terminal_websocket_disconnect_before_reply_records_unclaimed(tmp_path: Path) -> None:
|
||||
config_path = write_terminal_config(tmp_path)
|
||||
service = TerminalFakeAgentService(config_path=config_path, delay_seconds=0.05)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
||||
websocket.send_json({"type": "connect", "peer_id": "device-001"})
|
||||
assert websocket.receive_json()["type"] == "connected"
|
||||
websocket.send_json({"type": "message", "message_id": "device-001-000001", "text": "slow"})
|
||||
assert websocket.receive_json()["accepted"] is True
|
||||
|
||||
time.sleep(0.15)
|
||||
events = client.get("/api/channels/terminal-dev/events").json()
|
||||
|
||||
service.close()
|
||||
kinds = [event["kind"] for event in events]
|
||||
assert "terminal_disconnected" in kinds
|
||||
assert "outbound_unclaimed" in kinds
|
||||
|
||||
|
||||
def test_terminal_channel_status_exposes_websocket_url_and_peer_count(tmp_path: Path) -> None:
|
||||
config_path = write_terminal_config(tmp_path)
|
||||
service = TerminalFakeAgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
initial = client.get("/api/status").json()["channels"][0]
|
||||
assert initial["channel_id"] == "terminal-dev"
|
||||
assert initial["websocket_url"] == "/api/channels/terminal-dev/ws"
|
||||
assert initial["connected_peers"] == 0
|
||||
assert "persistent_connection" in initial["capabilities"]
|
||||
|
||||
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
|
||||
websocket.send_json({"type": "connect", "peer_id": "device-001"})
|
||||
assert websocket.receive_json()["type"] == "connected"
|
||||
connected = client.get("/api/status").json()["channels"][0]
|
||||
assert connected["connected_peers"] == 1
|
||||
|
||||
service.close()
|
||||
44
app-instance/backend/tests/unit/test_web_tools.py
Normal file
44
app-instance/backend/tests/unit/test_web_tools.py
Normal file
@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from beaver.tools.builtins import web
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
headers = {"content-type": "text/html"}
|
||||
status_code = 200
|
||||
text = '<a class="result__a" href="https://example.com">Example</a>'
|
||||
url = "https://example.com"
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
self.calls.append(kwargs)
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, *args: object, **kwargs: object) -> _FakeResponse:
|
||||
return _FakeResponse()
|
||||
|
||||
|
||||
def test_web_tools_use_environment_proxy_settings(monkeypatch) -> None:
|
||||
_FakeAsyncClient.calls = []
|
||||
monkeypatch.setattr(web.httpx, "AsyncClient", _FakeAsyncClient)
|
||||
|
||||
async def _run() -> None:
|
||||
await web.WebFetchTool().execute(url="https://example.com")
|
||||
await web.WebSearchTool().execute(query="example")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
assert [call.get("trust_env") for call in _FakeAsyncClient.calls] == [True, True]
|
||||
129
app-instance/backend/tests/unit/test_weixin_channel_adapter.py
Normal file
129
app-instance/backend/tests/unit/test_weixin_channel_adapter.py
Normal file
@ -0,0 +1,129 @@
|
||||
import asyncio
|
||||
|
||||
from beaver.foundation.events import OutboundMessage
|
||||
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
|
||||
|
||||
|
||||
class FakeSink:
|
||||
def __init__(self) -> None:
|
||||
self.messages = []
|
||||
|
||||
async def accept_inbound(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
|
||||
class FakeWeixinClient:
|
||||
def __init__(self) -> None:
|
||||
self.sent = []
|
||||
|
||||
async def send_text(self, *, peer_id: str, text: str, context_token: str | None):
|
||||
self.sent.append({"peer_id": peer_id, "text": text, "context_token": context_token})
|
||||
|
||||
|
||||
def test_weixin_normalizes_direct_text_message() -> None:
|
||||
async def run() -> None:
|
||||
sink = FakeSink()
|
||||
adapter = WeixinAdapter(
|
||||
channel_id="weixin-main",
|
||||
kind="weixin",
|
||||
mode="polling",
|
||||
account_id="wx-main",
|
||||
display_name=None,
|
||||
inbound_sink=sink,
|
||||
secrets={"token": "token"},
|
||||
config={},
|
||||
client=FakeWeixinClient(),
|
||||
)
|
||||
|
||||
await adapter.handle_message_payload(
|
||||
{
|
||||
"id": "m1",
|
||||
"from": "wx_user",
|
||||
"room_id": "",
|
||||
"type": "text",
|
||||
"text": "hello",
|
||||
"context_token": "ctx1",
|
||||
}
|
||||
)
|
||||
|
||||
message = sink.messages[0]
|
||||
assert message.content == "hello"
|
||||
assert message.session_id == "weixin-main:wx-main:wx_user"
|
||||
assert message.channel_identity.peer_type == "dm"
|
||||
assert message.metadata["context_token"] == "ctx1"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_weixin_group_message_is_best_effort() -> None:
|
||||
async def run() -> None:
|
||||
sink = FakeSink()
|
||||
adapter = WeixinAdapter(
|
||||
channel_id="weixin-main",
|
||||
kind="weixin",
|
||||
mode="polling",
|
||||
account_id="wx-main",
|
||||
display_name=None,
|
||||
inbound_sink=sink,
|
||||
secrets={"token": "token"},
|
||||
config={"groupPolicy": "open"},
|
||||
client=FakeWeixinClient(),
|
||||
)
|
||||
|
||||
await adapter.handle_message_payload(
|
||||
{
|
||||
"id": "m2",
|
||||
"from": "wx_user",
|
||||
"room_id": "room1",
|
||||
"type": "text",
|
||||
"text": "hello room",
|
||||
"context_token": "ctx2",
|
||||
}
|
||||
)
|
||||
|
||||
message = sink.messages[0]
|
||||
assert message.session_id == "weixin-main:wx-main:room1"
|
||||
assert message.channel_identity.peer_type == "group"
|
||||
assert message.channel_identity.user_id == "wx_user"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_weixin_sends_text_with_context_token() -> None:
|
||||
async def run() -> None:
|
||||
sink = FakeSink()
|
||||
client = FakeWeixinClient()
|
||||
adapter = WeixinAdapter(
|
||||
channel_id="weixin-main",
|
||||
kind="weixin",
|
||||
mode="polling",
|
||||
account_id="wx-main",
|
||||
display_name=None,
|
||||
inbound_sink=sink,
|
||||
secrets={"token": "token"},
|
||||
config={},
|
||||
client=client,
|
||||
)
|
||||
await adapter.handle_message_payload(
|
||||
{
|
||||
"id": "m1",
|
||||
"from": "wx_user",
|
||||
"type": "text",
|
||||
"text": "hello",
|
||||
"context_token": "ctx1",
|
||||
}
|
||||
)
|
||||
await adapter.send(
|
||||
OutboundMessage(
|
||||
channel="weixin-main",
|
||||
content="ok",
|
||||
session_id=sink.messages[0].session_id,
|
||||
finish_reason="stop",
|
||||
channel_identity=sink.messages[0].channel_identity,
|
||||
metadata={"inbound_metadata": sink.messages[0].metadata},
|
||||
)
|
||||
)
|
||||
|
||||
assert client.sent == [{"peer_id": "wx_user", "text": "ok", "context_token": "ctx1"}]
|
||||
|
||||
asyncio.run(run())
|
||||
185
app-instance/backend/uv.lock
generated
185
app-instance/backend/uv.lock
generated
@ -252,27 +252,51 @@ dependencies = [
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
channels = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "lark-oapi" },
|
||||
{ name = "python-telegram-bot" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
feishu = [
|
||||
{ name = "lark-oapi" },
|
||||
]
|
||||
qqbot = [
|
||||
{ name = "aiohttp" },
|
||||
]
|
||||
telegram = [
|
||||
{ name = "python-telegram-bot" },
|
||||
]
|
||||
weixin = [
|
||||
{ name = "aiohttp" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", marker = "extra == 'channels'", specifier = ">=3.9.0,<4.0.0" },
|
||||
{ name = "aiohttp", marker = "extra == 'qqbot'", specifier = ">=3.9.0,<4.0.0" },
|
||||
{ name = "aiohttp", marker = "extra == 'weixin'", specifier = ">=3.9.0,<4.0.0" },
|
||||
{ name = "anthropic", specifier = ">=0.51.0,<1.0.0" },
|
||||
{ name = "croniter", specifier = ">=6.0.0,<7.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
|
||||
{ name = "fastmcp", specifier = ">=3.0.0,<4.0.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" },
|
||||
{ name = "json-repair", specifier = ">=0.39.0,<1.0.0" },
|
||||
{ name = "lark-oapi", marker = "extra == 'channels'", specifier = ">=1.4.22,<2.0.0" },
|
||||
{ name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.4.22,<2.0.0" },
|
||||
{ name = "litellm", specifier = ">=1.79.0,<2.0.0" },
|
||||
{ name = "openai", specifier = ">=1.79.0,<2.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20,<1.0.0" },
|
||||
{ name = "python-telegram-bot", marker = "extra == 'channels'", specifier = ">=22.0,<23.0" },
|
||||
{ name = "python-telegram-bot", marker = "extra == 'telegram'", specifier = ">=22.0,<23.0" },
|
||||
{ name = "typer", specifier = ">=0.20.0,<1.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
provides-extras = ["dev", "telegram", "feishu", "qqbot", "weixin", "channels"]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
@ -1277,6 +1301,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lark-oapi"
|
||||
version = "1.6.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/54/a3b649b83299606aa7ebfd2391663fde650e934421dfba37af171bfbf456/lark_oapi-1.6.7-py3-none-any.whl", hash = "sha256:df1d44891d266f5c063daa1d37ae6f72c7f166bdc2fb01e607088410e952b92c", size = 7146261, upload-time = "2026-05-28T03:32:21.268Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.80.0"
|
||||
@ -1759,6 +1798,36 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.3"
|
||||
@ -1973,6 +2042,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-telegram-bot"
|
||||
version = "22.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpcore", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/25/2258161b1069e66d6c39c0a602dbe57461d4767dc0012539970ea40bc9d6/python_telegram_bot-22.7.tar.gz", hash = "sha256:784b59ea3852fe4616ad63b4a0264c755637f5d725e87755ecdee28300febf61", size = 1516454, upload-time = "2026-03-16T09:36:03.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/f7/0e2f89dd62f45d46d4ea0d8aec5893ce5b37389638db010c117f46f11450/python_telegram_bot-22.7-py3-none-any.whl", hash = "sha256:d72eed532cf763758cd9331b57a6d790aff0bb4d37d8f4e92149436fe21c6475", size = 745365, upload-time = "2026-03-16T09:36:01.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "311"
|
||||
@ -2189,6 +2271,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests-toolbelt"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
@ -2687,61 +2781,44 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -37,6 +37,8 @@ INSTANCES_ROOT="${INSTANCES_ROOT:-$INSTANCES_ROOT_DEFAULT}"
|
||||
REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
|
||||
NETWORK_NAME="${NETWORK_NAME:-}"
|
||||
HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}"
|
||||
INITIAL_SKILLS_DIR="${INITIAL_SKILLS_DIR:-${SCRIPT_DIR}/../skills}"
|
||||
SEED_INITIAL_SKILLS=1
|
||||
FORCE_BUILD=0
|
||||
REPLACE=0
|
||||
|
||||
@ -78,6 +80,9 @@ Optional:
|
||||
--registry <path> Registry JSON path. Default: ./runtime/registry/instances.json
|
||||
--network <name> Optional docker network name.
|
||||
--host-bind-ip <ip> Host bind IP for published port. Default: 127.0.0.1
|
||||
--initial-skills-dir <path> Directory copied into workspace/skills on first create.
|
||||
Default: ../skills
|
||||
--skip-initial-skills Do not seed initial workspace skills.
|
||||
--build Force rebuild image before running.
|
||||
--replace Remove existing container with same name before running.
|
||||
--help Show this help.
|
||||
@ -225,6 +230,69 @@ data = {
|
||||
"name": os.environ["BACKEND_NAME"].strip(),
|
||||
"publicBaseUrl": os.environ["PUBLIC_URL"].strip(),
|
||||
},
|
||||
"channels": {
|
||||
"telegram-main": {
|
||||
"enabled": False,
|
||||
"kind": "telegram",
|
||||
"mode": "polling",
|
||||
"accountId": "bot-main",
|
||||
"displayName": "Telegram Main",
|
||||
"secrets": {
|
||||
"botToken": "",
|
||||
},
|
||||
"config": {
|
||||
"requireMentionInGroups": True,
|
||||
"maxMessageChars": 4096,
|
||||
},
|
||||
},
|
||||
"feishu-main": {
|
||||
"enabled": False,
|
||||
"kind": "feishu",
|
||||
"mode": "websocket",
|
||||
"accountId": "tenant-main",
|
||||
"displayName": "Feishu Main",
|
||||
"secrets": {
|
||||
"appId": "",
|
||||
"appSecret": "",
|
||||
},
|
||||
"config": {
|
||||
"domain": "feishu",
|
||||
"connectionMode": "websocket",
|
||||
"requireMentionInGroups": True,
|
||||
},
|
||||
},
|
||||
"qqbot-main": {
|
||||
"enabled": False,
|
||||
"kind": "qqbot",
|
||||
"mode": "websocket",
|
||||
"accountId": "qqbot-main",
|
||||
"displayName": "QQ Bot Main",
|
||||
"secrets": {
|
||||
"appId": "",
|
||||
"clientSecret": "",
|
||||
},
|
||||
"config": {
|
||||
"dmPolicy": "open",
|
||||
"groupPolicy": "allowlist",
|
||||
"markdownSupport": False,
|
||||
},
|
||||
},
|
||||
"weixin-main": {
|
||||
"enabled": False,
|
||||
"kind": "weixin",
|
||||
"mode": "polling",
|
||||
"accountId": "wx-main",
|
||||
"displayName": "Weixin Main",
|
||||
"secrets": {
|
||||
"token": "",
|
||||
},
|
||||
"config": {
|
||||
"dmPolicy": "open",
|
||||
"groupPolicy": "disabled",
|
||||
"textBatchDelaySeconds": 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
@ -255,6 +323,66 @@ target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encodin
|
||||
PY
|
||||
}
|
||||
|
||||
seed_initial_skills() {
|
||||
local workspace_path="$1"
|
||||
local initial_skills_dir="$2"
|
||||
local target_dir="${workspace_path}/skills"
|
||||
|
||||
if [[ "$SEED_INITIAL_SKILLS" -ne 1 ]]; then
|
||||
return
|
||||
fi
|
||||
if [[ ! -d "$initial_skills_dir" ]]; then
|
||||
log "initial skills directory not found, skipping: ${initial_skills_dir}"
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$target_dir"
|
||||
INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" python3 - <<'PY'
|
||||
import json
|
||||
import shutil
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve()
|
||||
target = Path(os.environ["TARGET_DIR"]).resolve()
|
||||
|
||||
for child in sorted(initial.iterdir()):
|
||||
if child.name.startswith("."):
|
||||
continue
|
||||
destination = target / child.name
|
||||
if destination.exists():
|
||||
continue
|
||||
if child.is_dir():
|
||||
shutil.copytree(child, destination)
|
||||
elif child.is_file():
|
||||
shutil.copy2(child, destination)
|
||||
|
||||
for index_name in ("published", "disabled"):
|
||||
initial_index = initial / "_index" / f"{index_name}.json"
|
||||
target_index = target / "_index" / f"{index_name}.json"
|
||||
if not initial_index.exists():
|
||||
continue
|
||||
try:
|
||||
initial_items = json.loads(initial_index.read_text(encoding="utf-8")).get("items", [])
|
||||
except json.JSONDecodeError:
|
||||
initial_items = []
|
||||
if target_index.exists():
|
||||
try:
|
||||
target_items = json.loads(target_index.read_text(encoding="utf-8")).get("items", [])
|
||||
except json.JSONDecodeError:
|
||||
target_items = []
|
||||
else:
|
||||
target_items = []
|
||||
merged = []
|
||||
for item in [*target_items, *initial_items]:
|
||||
text = str(item).strip()
|
||||
if text and text not in merged:
|
||||
merged.append(text)
|
||||
target_index.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_index.write_text(json.dumps({"items": merged}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
PY
|
||||
}
|
||||
|
||||
render_runtime_env_file() {
|
||||
local target_path="$1"
|
||||
|
||||
@ -428,6 +556,14 @@ while [[ $# -gt 0 ]]; do
|
||||
HOST_BIND_IP="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--initial-skills-dir)
|
||||
INITIAL_SKILLS_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-initial-skills)
|
||||
SEED_INITIAL_SKILLS=0
|
||||
shift
|
||||
;;
|
||||
--build)
|
||||
FORCE_BUILD=1
|
||||
shift
|
||||
@ -531,6 +667,7 @@ mkdir -p "$BEAVER_HOME" "$WORKSPACE_PATH"
|
||||
render_config_json "$CONFIG_PATH"
|
||||
render_auth_users_json "$AUTH_USERS_PATH"
|
||||
render_runtime_env_file "$RUNTIME_ENV_PATH"
|
||||
seed_initial_skills "$WORKSPACE_PATH" "$INITIAL_SKILLS_DIR"
|
||||
|
||||
if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then
|
||||
log "building image ${IMAGE_NAME}"
|
||||
@ -564,6 +701,7 @@ RUN_ARGS=(
|
||||
-e "APP_PUBLIC_PORT=8080"
|
||||
-e "APP_FRONTEND_PORT=3000"
|
||||
-e "APP_BACKEND_PORT=18080"
|
||||
-e "BEAVER_ENABLE_SELF_RESTART=1"
|
||||
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
|
||||
--label "beaver.instance.id=${INSTANCE_ID}"
|
||||
--label "beaver.instance.slug=${INSTANCE_SLUG}"
|
||||
|
||||
@ -10,6 +10,7 @@ import type { ChatLogEvent, ChatLogSession } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { containedJsonTextClass } from '@/lib/text-wrapping';
|
||||
|
||||
function eventLabel(event: ChatLogEvent): string {
|
||||
return event.event_type || event.role || 'event';
|
||||
@ -175,7 +176,7 @@ export default function LogsPage() {
|
||||
return (
|
||||
<div
|
||||
key={`${event.message_id ?? index}:${event.event_type}`}
|
||||
className="rounded-lg border border-border bg-background"
|
||||
className="min-w-0 max-w-full overflow-hidden rounded-lg border border-border bg-background"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
@ -188,7 +189,7 @@ export default function LogsPage() {
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span>
|
||||
</div>
|
||||
<pre className="max-h-[520px] overflow-auto whitespace-pre-wrap break-words p-3 text-xs leading-5 text-foreground">
|
||||
<pre className={`max-h-[520px] overflow-auto p-3 text-xs leading-5 text-foreground ${containedJsonTextClass}`}>
|
||||
{body || formatPayload(event)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@ -19,7 +19,12 @@ import {
|
||||
uploadFile,
|
||||
wsManager,
|
||||
} from '@/lib/api';
|
||||
import { mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages';
|
||||
import {
|
||||
getSessionRefreshIntervalMs,
|
||||
mergeServerWithPendingUsers,
|
||||
shouldDisplayChatMessage,
|
||||
shouldMergePendingUsers,
|
||||
} from '@/lib/chat-messages';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { buildSessionProgressView } from '@/lib/session-progress';
|
||||
@ -47,6 +52,10 @@ function loadThinkingModePreference(): boolean {
|
||||
return stored == null ? false : stored !== 'false';
|
||||
}
|
||||
|
||||
function isDocumentHidden(): boolean {
|
||||
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const {
|
||||
@ -78,6 +87,7 @@ export default function ChatPage() {
|
||||
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
||||
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
||||
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
|
||||
const [documentHidden, setDocumentHidden] = useState(isDocumentHidden);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messageViewportRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@ -247,14 +257,26 @@ export default function ChatPage() {
|
||||
}, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isThinking) {
|
||||
const intervalMs = getSessionRefreshIntervalMs({ isLoading, isThinking, documentHidden });
|
||||
if (intervalMs == null) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
loadSessionMessages(useChatStore.getState().sessionId);
|
||||
}, 1500);
|
||||
const currentSessionId = useChatStore.getState().sessionId;
|
||||
void loadSessionMessages(currentSessionId);
|
||||
void loadSessions();
|
||||
}, intervalMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [isLoading, isThinking, loadSessionMessages]);
|
||||
}, [documentHidden, isLoading, isThinking, loadSessionMessages, loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const updateVisibility = () => setDocumentHidden(isDocumentHidden());
|
||||
document.addEventListener('visibilitychange', updateVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', updateVisibility);
|
||||
}, []);
|
||||
|
||||
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
|
||||
const viewport = messageViewportRef.current;
|
||||
|
||||
@ -73,6 +73,7 @@ import type {
|
||||
} from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapping';
|
||||
|
||||
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
|
||||
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
|
||||
@ -1094,7 +1095,7 @@ function ReadableFact({
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="break-words text-sm leading-5">{value || '-'}</div>
|
||||
<div className={`text-sm leading-5 ${containedLongTextClass}`}>{value || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1119,12 +1120,12 @@ function MetricTile({
|
||||
|
||||
function RawDetails({ title, payload }: { title: string; payload: unknown }) {
|
||||
return (
|
||||
<details className="mt-3 rounded-md border border-border bg-white">
|
||||
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-white">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{title}
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</summary>
|
||||
<pre className="max-h-72 overflow-auto border-t border-border p-3 text-xs leading-5">
|
||||
<pre className={`max-h-72 overflow-auto border-t border-border p-3 text-xs leading-5 ${containedJsonTextClass}`}>
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,127 +3,135 @@
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { AlertCircle, ArrowLeft, Bot, CheckCircle2, Download, FileText, Loader2, MessageSquare, RefreshCw, ThumbsUp, Trash2, User, XCircle } from 'lucide-react';
|
||||
import { AlertCircle, ArrowLeft, Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime, progressPercent } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
TaskLiveHeader,
|
||||
TaskSideRail,
|
||||
TaskTimeline,
|
||||
type TaskFeedbackItem,
|
||||
type TaskFeedbackType,
|
||||
} from '@/components/task-detail';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { deleteBackendTask, getBackendTask, getFileUrl, submitChatFeedback } from '@/lib/api';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { deleteBackendTask, getBackendTask, submitChatFeedback } from '@/lib/api';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { buildTaskRuntimeView, type TaskRuntimeNodeView } from '@/lib/task-runtime';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
|
||||
import { buildTaskTimelineCards } from '@/lib/task-timeline';
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
type TaskFeedbackType = 'accept' | 'revise' | 'abandon';
|
||||
type TaskFeedbackItem = {
|
||||
acceptance_type?: unknown;
|
||||
feedback_type?: unknown;
|
||||
comment?: unknown;
|
||||
created_at?: unknown;
|
||||
run_id?: unknown;
|
||||
};
|
||||
|
||||
function taskVisibleStatus(task: TaskRuntimeNodeView, locale: 'zh-CN' | 'en-US') {
|
||||
if (task.status === 'error') return pickAppText(locale, '任务失败', 'Task failed');
|
||||
if (task.status === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
||||
return task.stageLabel || task.status;
|
||||
}
|
||||
|
||||
function downloadText(filename: string, content: string) {
|
||||
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
const TERMINAL_TASK_STATUSES = new Set(['closed', 'abandoned', 'cancelled', 'error']);
|
||||
const TASK_RESULT_REVIEW_ID = 'task-result-review';
|
||||
|
||||
export default function TaskDetailPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const router = useRouter();
|
||||
const params = useParams<{ taskId: string }>();
|
||||
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
|
||||
const sessions = useChatStore((state) => state.sessions);
|
||||
const processRuns = useChatStore((state) => state.processRuns);
|
||||
const processEvents = useChatStore((state) => state.processEvents);
|
||||
const processArtifacts = useChatStore((state) => state.processArtifacts);
|
||||
const setSessionProcess = useChatStore((state) => state.setSessionProcess);
|
||||
const updateMessageFeedback = useChatStore((state) => state.updateMessageFeedback);
|
||||
const wsStatus = useChatStore((state) => state.wsStatus);
|
||||
|
||||
const task = useMemo(
|
||||
() => buildTaskRuntimeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale),
|
||||
[locale, processArtifacts, processEvents, processRuns, sessions, taskId]
|
||||
);
|
||||
const [backendTask, setBackendTask] = useState<BackendTask | null>(null);
|
||||
const [backendTaskLoading, setBackendTaskLoading] = useState(false);
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(task?.rootRunId ?? null);
|
||||
const [backendTaskLoading, setBackendTaskLoading] = useState(true);
|
||||
const [revision, setRevision] = useState('');
|
||||
const [runtimeFeedback, setRuntimeFeedback] = useState<TaskFeedbackItem | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
const mountedRef = React.useRef(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedRunId(task?.rootRunId ?? null);
|
||||
setRuntimeFeedback(null);
|
||||
}, [task?.rootRunId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (task || !taskId) {
|
||||
setBackendTask(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
setBackendTaskLoading(true);
|
||||
getBackendTask(taskId)
|
||||
.then((item) => {
|
||||
if (!cancelled) setBackendTask(item);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setBackendTask(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setBackendTaskLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [task, taskId]);
|
||||
}, []);
|
||||
|
||||
const runIds = useMemo(() => new Set(task?.tasks.map((item) => item.runId) ?? []), [task?.tasks]);
|
||||
const artifacts = useMemo(
|
||||
() => processArtifacts.filter((artifact) => runIds.has(artifact.run_id)),
|
||||
[processArtifacts, runIds]
|
||||
const loadBackendTask = React.useCallback(async () => {
|
||||
if (!taskId) return null;
|
||||
setBackendTaskLoading(true);
|
||||
try {
|
||||
const item = await getBackendTask(taskId);
|
||||
if (!mountedRef.current) return item;
|
||||
setBackendTask(item);
|
||||
setSessionProcess(item.session_id, {
|
||||
runs: item.process_runs ?? [],
|
||||
events: item.process_events ?? [],
|
||||
artifacts: item.process_artifacts ?? [],
|
||||
});
|
||||
return item;
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
setBackendTask(null);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBackendTaskLoading(false);
|
||||
}
|
||||
}
|
||||
}, [setSessionProcess, taskId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadBackendTask();
|
||||
}, [loadBackendTask]);
|
||||
|
||||
const isTaskLive = backendTask ? !TERMINAL_TASK_STATUSES.has(backendTask.status) : false;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldPollTaskDetail(backendTask)) return;
|
||||
const id = window.setInterval(() => {
|
||||
void loadBackendTask();
|
||||
}, 4000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [backendTask, loadBackendTask]);
|
||||
|
||||
const taskRunIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const run of backendTask?.process_runs ?? []) ids.add(run.run_id);
|
||||
for (const runId of backendTask?.run_ids ?? []) ids.add(runId);
|
||||
return ids;
|
||||
}, [backendTask]);
|
||||
|
||||
const liveRuns = useMemo(
|
||||
() => processRuns.filter((run) => taskRunIds.has(run.run_id) || run.metadata?.task_id === taskId),
|
||||
[processRuns, taskId, taskRunIds]
|
||||
);
|
||||
const eventsByRun = useMemo(() => {
|
||||
const map = new Map<string, ProcessEvent[]>();
|
||||
for (const event of processEvents) {
|
||||
if (!runIds.has(event.run_id)) continue;
|
||||
map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]);
|
||||
}
|
||||
return map;
|
||||
}, [processEvents, runIds]);
|
||||
const artifactsByRun = useMemo(() => {
|
||||
const map = new Map<string, ProcessArtifact[]>();
|
||||
for (const artifact of artifacts) {
|
||||
map.set(artifact.run_id, [...(map.get(artifact.run_id) ?? []), artifact]);
|
||||
}
|
||||
return map;
|
||||
}, [artifacts]);
|
||||
const phaseGroups = useMemo(() => {
|
||||
const groups = new Map<string, TaskRuntimeNodeView[]>();
|
||||
for (const item of task?.tasks ?? []) {
|
||||
const label = item.stageLabel || taskVisibleStatus(item, locale);
|
||||
groups.set(label, [...(groups.get(label) ?? []), item]);
|
||||
}
|
||||
return Array.from(groups.entries()).map(([label, nodes]) => ({ label, nodes }));
|
||||
}, [locale, task?.tasks]);
|
||||
const selectedNode = task?.tasks.find((item) => item.runId === selectedRunId) ?? task?.tasks[0] ?? null;
|
||||
|
||||
const liveEvents = useMemo(
|
||||
() => processEvents.filter((event) => taskRunIds.has(event.run_id) || event.metadata?.task_id === taskId),
|
||||
[processEvents, taskId, taskRunIds]
|
||||
);
|
||||
|
||||
const liveArtifacts = useMemo(
|
||||
() => processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id) || artifact.metadata?.task_id === taskId),
|
||||
[processArtifacts, taskId, taskRunIds]
|
||||
);
|
||||
|
||||
const renderedRuns = liveRuns.length > 0 ? liveRuns : backendTask?.process_runs ?? [];
|
||||
const renderedEvents = liveEvents.length > 0 ? liveEvents : backendTask?.process_events ?? [];
|
||||
const renderedArtifacts = liveArtifacts.length > 0 ? liveArtifacts : backendTask?.process_artifacts ?? [];
|
||||
|
||||
const timelineCards = useMemo(
|
||||
() =>
|
||||
backendTask
|
||||
? buildTaskTimelineCards({
|
||||
task: backendTask,
|
||||
processRuns: renderedRuns,
|
||||
processEvents: renderedEvents,
|
||||
processArtifacts: renderedArtifacts,
|
||||
})
|
||||
: [],
|
||||
[backendTask, renderedArtifacts, renderedEvents, renderedRuns]
|
||||
);
|
||||
|
||||
const activeLabel =
|
||||
[...timelineCards].reverse().find((card) => !['acceptance', 'task_created'].includes(card.type))?.title ?? '-';
|
||||
const durationMs = backendTask ? taskDetailDurationMs(backendTask) : null;
|
||||
const feedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null;
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<unknown>) => {
|
||||
setActionBusy(key);
|
||||
@ -149,632 +157,97 @@ export default function TaskDetailPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const backendFeedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null;
|
||||
|
||||
if (!task && backendTask) {
|
||||
if (backendTask) {
|
||||
const feedbackItems = backendTask.feedback || [];
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button asChild variant="outline" className="w-fit">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{backendTask.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null}
|
||||
<Badge>{humanTaskStatus(backendTask.status, locale)}</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={Boolean(actionBusy)}
|
||||
onClick={() => void deleteCurrentBackendTask()}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '删除任务', 'Delete task')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h1 className="text-2xl font-semibold">{backendTask.short_title || String(backendTask.metadata?.short_title || '') || backendTask.description || backendTask.goal || backendTask.task_id}</h1>
|
||||
{backendTask.description ? (
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{backendTask.description}</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{pickAppText(locale, '来源会话', 'Session')}: {backendTask.session_id}</span>
|
||||
<span>{pickAppText(locale, '创建者', 'Creator')}: {backendTask.creator}</span>
|
||||
<span>{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(backendTask.updated_at, locale)}</span>
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} reviewTargetId={TASK_RESULT_REVIEW_ID} />
|
||||
|
||||
<main className="mx-auto grid max-w-7xl gap-6 p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={Boolean(actionBusy)}
|
||||
onClick={() => void deleteCurrentBackendTask()}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '删除任务', 'Delete task')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TaskFeedbackPanel
|
||||
sessionId={backendTask.session_id}
|
||||
runId={backendFeedbackRunId}
|
||||
taskStatus={backendTask.status}
|
||||
feedbackItems={feedbackItems}
|
||||
actionBusy={actionBusy}
|
||||
onSubmit={(feedbackType, comment) =>
|
||||
runAction(`backend-feedback-${feedbackType}`, async () => {
|
||||
await submitChatFeedback({
|
||||
{actionError ? (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-center gap-2 p-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{actionError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<TaskTimeline
|
||||
cards={timelineCards}
|
||||
isLive={isTaskLive && wsStatus === 'connected'}
|
||||
reviewTargetId={TASK_RESULT_REVIEW_ID}
|
||||
resultAcceptance={{
|
||||
sessionId: backendTask.session_id,
|
||||
runId: backendFeedbackRunId!,
|
||||
feedbackType,
|
||||
comment,
|
||||
});
|
||||
const refreshed = await getBackendTask(backendTask.task_id);
|
||||
setBackendTask(refreshed);
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<BackendExecutionStages task={backendTask} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{(backendTask.runs ?? []).length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, '暂无可展示的问答过程', 'No readable conversation process yet')}</div>
|
||||
) : (
|
||||
(backendTask.runs ?? []).map((run, index) => <BackendRunConversation key={run.run_id} run={run} index={index} />)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
runId: feedbackRunId,
|
||||
taskStatus: backendTask.status,
|
||||
feedbackItems: feedbackItems as TaskFeedbackItem[],
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange: setRevision,
|
||||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) =>
|
||||
runAction(`backend-feedback-${feedbackType}`, async () => {
|
||||
if (!feedbackRunId) throw new Error(pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.'));
|
||||
await submitChatFeedback({
|
||||
sessionId: backendTask.session_id,
|
||||
runId: feedbackRunId,
|
||||
feedbackType,
|
||||
comment,
|
||||
});
|
||||
updateMessageFeedback(feedbackRunId, feedbackType);
|
||||
setRevision('');
|
||||
await loadBackendTask();
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TaskSideRail task={backendTask} runs={renderedRuns} artifacts={renderedArtifacts} cards={timelineCards} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
|
||||
<Button asChild variant="outline" className="w-fit">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-16 text-center">
|
||||
<h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{backendTaskLoading
|
||||
? pickAppText(locale, '正在从后端任务库加载任务。', 'Loading the task from the backend task store.')
|
||||
: pickAppText(locale, '当前前端状态和后端任务库里都没有这个任务。', 'Neither frontend state nor backend task store contains this task.')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const progressValue = progressPercent(task.progress.value, task.progress.max);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '对话', 'Chat')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="truncate text-2xl font-semibold">{task.title}</h1>
|
||||
<TaskRuntimeStatusBadge status={task.status} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{pickAppText(locale, '来源会话', 'Session')}: {task.sourceSessionLabel}</span>
|
||||
<span>{pickAppText(locale, '主 Agent', 'Lead agent')}: {task.rootActorName}</span>
|
||||
<span>{pickAppText(locale, '开始', 'Started')}: {formatTaskRuntimeTime(task.createdAt, locale)}</span>
|
||||
<span>{pickAppText(locale, '耗时', 'Duration')}: {formatTaskRuntimeDuration(task.durationMs, locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid w-full gap-3 sm:grid-cols-4 lg:w-[520px]">
|
||||
<Metric label={pickAppText(locale, '节点', 'Nodes')} value={String(task.stats.totalRuns)} />
|
||||
<Metric label={pickAppText(locale, '活跃', 'Active')} value={String(task.stats.activeRuns)} />
|
||||
<Metric label={pickAppText(locale, '产物', 'Artifacts')} value={String(task.stats.artifactCount)} />
|
||||
<Metric label={pickAppText(locale, '异常', 'Alerts')} value={String(task.stats.alertCount)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{task.progress.label}</span>
|
||||
<span className="font-medium">{progressValue}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
||||
<div className="h-full bg-primary" style={{ width: `${progressValue}%` }} />
|
||||
</div>
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
|
||||
<Button asChild variant="outline" className="w-fit">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-16 text-center">
|
||||
<div className="flex justify-center">
|
||||
{backendTaskLoading ? <Loader2 className="mb-4 h-5 w-5 animate-spin text-muted-foreground" /> : null}
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{backendTaskLoading
|
||||
? pickAppText(locale, '正在从后端任务库加载任务。', 'Loading the task from the backend task store.')
|
||||
: pickAppText(locale, '后端任务库里没有这个任务。', 'The backend task store does not contain this task.')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{actionError && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{actionError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '阶段链', 'Phase chain')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{phaseGroups.map((phase, index) => (
|
||||
<div key={`${phase.label}:${index}`} className="flex items-center gap-2">
|
||||
<div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm">
|
||||
<div className="font-medium">{phase.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{phase.nodes.length} nodes</div>
|
||||
</div>
|
||||
{index < phaseGroups.length - 1 ? <span className="text-muted-foreground">/</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{phaseGroups.map((phase) => (
|
||||
<Card key={phase.label}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{phase.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2">
|
||||
{phase.nodes.map((node) => (
|
||||
<button
|
||||
key={node.runId}
|
||||
type="button"
|
||||
onClick={() => setSelectedRunId(node.runId)}
|
||||
className={`rounded-md border p-4 text-left transition-colors ${selectedRunId === node.runId ? 'border-primary bg-accent/45' : 'border-border bg-card hover:bg-muted/40'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{node.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{node.actorName}</div>
|
||||
</div>
|
||||
<TaskRuntimeStatusBadge status={node.status} />
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
{node.summary || taskVisibleStatus(node, locale)}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
|
||||
<span>{pickAppText(locale, '子节点', 'Children')}: {node.childTaskIds.length}</span>
|
||||
<span>{pickAppText(locale, '节点结果', 'Node results')}: {(artifactsByRun.get(node.runId) ?? []).length}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '节点详情', 'Node detail')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedNode ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="font-medium">{selectedNode.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{selectedNode.runId}</div>
|
||||
</div>
|
||||
<TaskRuntimeStatusBadge status={selectedNode.status} />
|
||||
<p className="text-sm text-muted-foreground">{selectedNode.summary || '-'}</p>
|
||||
<div className="space-y-2">
|
||||
{(eventsByRun.get(selectedNode.runId) ?? []).slice(-5).map((event) => (
|
||||
<div key={event.event_id} className="rounded-md border border-border bg-muted/30 p-2 text-xs">
|
||||
<div className="font-medium">{event.kind}</div>
|
||||
<div className="mt-1 text-muted-foreground">{event.text || formatTaskRuntimeTime(event.created_at, locale)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">-</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TaskFeedbackPanel
|
||||
sessionId={task.sessionId || 'web:default'}
|
||||
runId={task.rootRunId}
|
||||
taskStatus={task.status}
|
||||
feedbackItems={runtimeFeedback ? [runtimeFeedback] : []}
|
||||
actionBusy={actionBusy}
|
||||
revision={revision}
|
||||
onRevisionChange={setRevision}
|
||||
onSubmit={(feedbackType, comment) =>
|
||||
runAction(`runtime-feedback-${feedbackType}`, async () => {
|
||||
updateMessageFeedback(task.rootRunId, feedbackType);
|
||||
await submitChatFeedback({
|
||||
sessionId: task.sessionId || 'web:default',
|
||||
runId: task.rootRunId,
|
||||
feedbackType,
|
||||
comment,
|
||||
});
|
||||
setRuntimeFeedback({
|
||||
acceptance_type: feedbackType,
|
||||
feedback_type: feedbackType,
|
||||
comment: comment || '',
|
||||
created_at: new Date().toISOString(),
|
||||
run_id: task.rootRunId,
|
||||
});
|
||||
setRevision('');
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">{pickAppText(locale, '产物', 'Artifacts')}</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={artifacts.length === 0}
|
||||
onClick={() => downloadText(`${task.taskId}-artifacts.json`, JSON.stringify(artifacts, null, 2))}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '全部下载', 'Download all')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{artifacts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无产物', 'No artifacts yet')}</p>
|
||||
) : (
|
||||
artifacts.map((artifact) => (
|
||||
<div key={artifact.artifact_id} className="flex items-center justify-between gap-3 rounded-md border border-border p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate">{artifact.title}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{artifact.actor_name || artifact.actor_id}</div>
|
||||
</div>
|
||||
{artifact.url || artifact.file_id ? (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={artifact.url || getFileUrl(artifact.file_id!)} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '下载', 'Download')}
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => downloadText(`${artifact.title || artifact.artifact_id}.txt`, artifact.content || JSON.stringify(artifact.data ?? {}, null, 2))}
|
||||
>
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '下载', 'Download')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendExecutionStages({ task }: { task: BackendTask }) {
|
||||
const { locale } = useAppI18n();
|
||||
const runs = task.process_runs ?? [];
|
||||
const events = task.process_events ?? [];
|
||||
const eventsByRun = React.useMemo(() => {
|
||||
const map = new Map<string, ProcessEvent[]>();
|
||||
for (const event of events) {
|
||||
map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]);
|
||||
}
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '执行阶段', 'Execution stages')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{runs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, '暂无执行阶段记录', 'No execution stage records yet')}</div>
|
||||
) : (
|
||||
runs.map((run) => (
|
||||
<BackendProcessRun key={run.run_id} run={run} events={eventsByRun.get(run.run_id) ?? []} />
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendProcessRun({ run, events }: { run: ProcessRun; events: ProcessEvent[] }) {
|
||||
const { locale } = useAppI18n();
|
||||
const metadata = run.metadata ?? {};
|
||||
const details = [
|
||||
metadata.attempt_index ? `${pickAppText(locale, '尝试', 'Attempt')} ${String(metadata.attempt_index)}` : null,
|
||||
metadata.plan_mode ? `${pickAppText(locale, '模式', 'Mode')}: ${String(metadata.plan_mode)}` : null,
|
||||
metadata.strategy ? `${pickAppText(locale, '策略', 'Strategy')}: ${String(metadata.strategy)}` : null,
|
||||
metadata.node_id ? `${pickAppText(locale, '节点', 'Node')}: ${String(metadata.node_id)}` : null,
|
||||
metadata.finish_reason ? `${pickAppText(locale, '结束原因', 'Finish')}: ${String(metadata.finish_reason)}` : null,
|
||||
].filter(Boolean);
|
||||
const error = typeof metadata.error === 'string' && metadata.error ? metadata.error : null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-background p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{run.title || run.actor_name}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{run.actor_name}
|
||||
{run.started_at ? ` · ${formatTaskRuntimeTime(run.started_at, locale)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<TaskRuntimeStatusBadge status={run.status} />
|
||||
</div>
|
||||
{details.length > 0 ? <div className="mt-2 text-xs text-muted-foreground">{details.join(' · ')}</div> : null}
|
||||
{run.summary ? <p className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{run.summary}</p> : null}
|
||||
{error ? <p className="mt-2 text-sm text-destructive">{error}</p> : null}
|
||||
{events.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{events.map((event) => (
|
||||
<div key={event.event_id} className="rounded-md bg-muted/30 px-3 py-2 text-xs">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium">{event.actor_name}</span>
|
||||
<span className="text-muted-foreground">{formatTaskRuntimeTime(event.created_at, locale)}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">{event.text || event.kind}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskFeedbackPanel({
|
||||
sessionId,
|
||||
runId,
|
||||
taskStatus,
|
||||
feedbackItems,
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
sessionId: string;
|
||||
runId: string | null;
|
||||
taskStatus: string;
|
||||
feedbackItems: TaskFeedbackItem[];
|
||||
actionBusy: string | null;
|
||||
revision?: string;
|
||||
onRevisionChange?: (value: string) => void;
|
||||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const [localComment, setLocalComment] = React.useState('');
|
||||
const comment = revision ?? localComment;
|
||||
const setComment = onRevisionChange ?? setLocalComment;
|
||||
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
|
||||
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
|
||||
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && !actionBusy;
|
||||
|
||||
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
|
||||
if (!runId || !canSubmit) return;
|
||||
void onSubmit(feedbackType, nextComment);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '任务验收', 'Task acceptance')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{recordedFeedback ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
|
||||
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(String(recordedFeedback.acceptance_type || recordedFeedback.feedback_type || ''), locale)}
|
||||
</div>
|
||||
{recordedFeedback.comment ? (
|
||||
<p className="mt-2 text-muted-foreground">{String(recordedFeedback.comment)}</p>
|
||||
) : null}
|
||||
{recordedFeedback.created_at ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : isFinalized ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '任务已结束,不能再提交新的验收。', 'This task is finalized and cannot accept new acceptance.')}
|
||||
</div>
|
||||
) : !runId ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<FeedbackButton
|
||||
type="accept"
|
||||
icon={<ThumbsUp className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '接受', 'Accept')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => submit('accept', comment.trim() || undefined)}
|
||||
/>
|
||||
<FeedbackButton
|
||||
type="revise"
|
||||
icon={<RefreshCw className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '需要修改', 'Needs revision')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit || !comment.trim()}
|
||||
onClick={() => submit('revise', comment.trim())}
|
||||
/>
|
||||
<FeedbackButton
|
||||
type="abandon"
|
||||
icon={<XCircle className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '放弃', 'Abandon')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => submit('abandon', comment.trim() || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
disabled={Boolean(recordedFeedback) || isFinalized || Boolean(actionBusy)}
|
||||
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
|
||||
<span className="font-mono">{runId || '-'}</span>
|
||||
<span className="mx-1">·</span>
|
||||
{pickAppText(locale, '会话:', 'Session: ')}
|
||||
<span className="font-mono">{sessionId}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackButton({
|
||||
type,
|
||||
icon,
|
||||
label,
|
||||
actionBusy,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
type: TaskFeedbackType;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
actionBusy: string | null;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const isBusy = Boolean(actionBusy?.endsWith(type));
|
||||
return (
|
||||
<Button type="button" variant="outline" className="w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
|
||||
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendRunConversation({ run, index }: { run: BackendTaskRun; index: number }) {
|
||||
const { locale } = useAppI18n();
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-background p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium">{run.title || pickAppText(locale, `Agent ${index + 1}`, `Agent ${index + 1}`)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{run.started_at ? formatTaskRuntimeTime(run.started_at, locale) : pickAppText(locale, '时间未知', 'Unknown time')}
|
||||
{run.finish_reason ? ` · ${humanFinishReason(run.finish_reason, locale)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={run.success === false ? 'destructive' : 'secondary'}>
|
||||
{run.success === false ? pickAppText(locale, '失败', 'Failed') : pickAppText(locale, '已完成', 'Done')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{run.messages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{run.task_text || pickAppText(locale, '这次运行没有可见对话消息。', 'This run has no visible conversation messages.')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{run.messages.map((message, messageIndex) => {
|
||||
const isAssistant = message.role === 'assistant';
|
||||
const isTool = message.role === 'tool';
|
||||
const Icon = isAssistant ? Bot : isTool ? FileText : User;
|
||||
return (
|
||||
<div key={`${message.role}:${message.created_at}:${messageIndex}`} className="flex gap-3">
|
||||
<div className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{isAssistant ? run.title || pickAppText(locale, 'Agent 回复', 'Agent reply') : isTool ? message.tool_name || pickAppText(locale, '工具结果', 'Tool result') : pickAppText(locale, '用户要求', 'User request')}</span>
|
||||
{message.created_at ? <span>{formatTaskRuntimeTime(message.created_at, locale)}</span> : null}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap rounded-md border border-border bg-muted/20 px-3 py-2 text-sm leading-6">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const map: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
};
|
||||
const item = map[status];
|
||||
return item ? pickAppText(locale, item[0], item[1]) : status;
|
||||
}
|
||||
|
||||
function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') {
|
||||
if (type === 'accept' || type === 'satisfied') return pickAppText(locale, '接受', 'Accepted');
|
||||
if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested');
|
||||
if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned');
|
||||
return type || pickAppText(locale, '验收', 'Acceptance');
|
||||
}
|
||||
|
||||
function humanFinishReason(reason: string, locale: 'zh-CN' | 'en-US') {
|
||||
if (reason === 'stop') return pickAppText(locale, '正常结束', 'Completed');
|
||||
if (reason === 'error') return pickAppText(locale, '执行出错', 'Error');
|
||||
if (reason === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
||||
return reason;
|
||||
}
|
||||
|
||||
function pickFeedbackRunId(task: BackendTask): string | null {
|
||||
const runIds = task.run_ids.filter(Boolean);
|
||||
if (runIds.length > 0) return runIds[runIds.length - 1];
|
||||
@ -782,13 +255,3 @@ function pickFeedbackRunId(task: BackendTask): string | null {
|
||||
if (runs.length > 0) return runs[runs.length - 1].run_id;
|
||||
return null;
|
||||
}
|
||||
|
||||
function feedbackForRun(items: TaskFeedbackItem[], runId: string | null): TaskFeedbackItem | null {
|
||||
if (!runId) return null;
|
||||
const ordered = [...items].reverse();
|
||||
return ordered.find((item) => String(item.run_id || '') === runId) ?? null;
|
||||
}
|
||||
|
||||
function latestFeedback(items: TaskFeedbackItem[]): TaskFeedbackItem | null {
|
||||
return [...items].reverse()[0] ?? null;
|
||||
}
|
||||
|
||||
@ -88,6 +88,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.contained-long-text {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.contained-preserved-long-text {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.contained-json-text {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
}
|
||||
|
||||
/* Override Tailwind Typography table defaults for markdown rendering */
|
||||
.prose table {
|
||||
margin-top: 0;
|
||||
|
||||
@ -3,9 +3,11 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { containedLongTextClass } from '@/lib/text-wrapping';
|
||||
|
||||
export function MarkdownContent({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<div className={`prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-code:[overflow-wrap:anywhere] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] prose-pre:whitespace-pre-wrap prose-pre:[overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${containedLongTextClass}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
|
||||
@ -12,6 +12,7 @@ import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||
|
||||
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
|
||||
@ -66,7 +67,7 @@ function MessageBubble({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[88%] px-4 py-3 ${
|
||||
className={`min-w-0 max-w-[88%] px-4 py-3 ${
|
||||
isUser
|
||||
? 'rounded-[28px] bg-primary text-primary-foreground'
|
||||
: 'rounded-none bg-transparent text-[#1D1715]'
|
||||
@ -92,14 +93,14 @@ function MessageBubble({
|
||||
key={att.file_id}
|
||||
href={fileUrl}
|
||||
download={att.name}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${
|
||||
className={`flex min-w-0 items-center gap-2 px-3 py-2 rounded-md text-sm ${
|
||||
isUser
|
||||
? 'bg-primary-foreground/10 hover:bg-primary-foreground/20'
|
||||
: 'bg-muted hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
<Paperclip className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">{att.name}</span>
|
||||
<span className="min-w-0 truncate">{att.name}</span>
|
||||
{att.size && (
|
||||
<span className="text-xs opacity-70 flex-shrink-0">
|
||||
{att.size > 1024 * 1024
|
||||
@ -114,7 +115,7 @@ function MessageBubble({
|
||||
)}
|
||||
|
||||
{isUser ? (
|
||||
<p className="text-sm whitespace-pre-wrap">{textContent}</p>
|
||||
<p className={`text-sm ${containedPreservedLongTextClass}`}>{textContent}</p>
|
||||
) : (
|
||||
<MarkdownContent content={textContent} />
|
||||
)}
|
||||
|
||||
@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { CheckCircle2, Loader2, RefreshCw, ThumbsUp, XCircle } from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||
|
||||
export type TaskFeedbackType = 'accept' | 'revise' | 'abandon';
|
||||
|
||||
export type TaskFeedbackItem = {
|
||||
acceptance_type?: unknown;
|
||||
feedback_type?: unknown;
|
||||
comment?: unknown;
|
||||
created_at?: unknown;
|
||||
run_id?: unknown;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
sessionId: string;
|
||||
runId: string | null;
|
||||
taskStatus: string;
|
||||
feedbackItems: TaskFeedbackItem[];
|
||||
actionBusy: string | null;
|
||||
revision?: string;
|
||||
onRevisionChange?: (value: string) => void;
|
||||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
const READY_FOR_ACCEPTANCE_STATUSES = new Set<string>(['awaiting_acceptance', 'needs_revision']);
|
||||
|
||||
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||
return RUNTIME_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function feedbackForRun(items: TaskFeedbackItem[], runId: string | null): TaskFeedbackItem | null {
|
||||
if (!runId) return null;
|
||||
return [...items].reverse().find((item) => String(item.run_id || '') === runId) ?? null;
|
||||
}
|
||||
|
||||
function latestFeedback(items: TaskFeedbackItem[]): TaskFeedbackItem | null {
|
||||
return [...items].reverse()[0] ?? null;
|
||||
}
|
||||
|
||||
function feedbackKind(item: TaskFeedbackItem): string {
|
||||
return String(item.acceptance_type || item.feedback_type || '');
|
||||
}
|
||||
|
||||
function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') {
|
||||
if (type === 'accept' || type === 'satisfied') return pickAppText(locale, '接受', 'Accepted');
|
||||
if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested');
|
||||
if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned');
|
||||
return type || pickAppText(locale, '验收', 'Acceptance');
|
||||
}
|
||||
|
||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const labels: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
accept: ['接受', 'Accepted'],
|
||||
satisfied: ['接受', 'Accepted'],
|
||||
revise: ['请求修改', 'Revision requested'],
|
||||
abandon: ['放弃任务', 'Abandoned'],
|
||||
};
|
||||
const label = labels[status];
|
||||
return label ? pickAppText(locale, label[0], label[1]) : status;
|
||||
}
|
||||
|
||||
function FeedbackButton({
|
||||
type,
|
||||
icon,
|
||||
label,
|
||||
actionBusy,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
type: TaskFeedbackType;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
actionBusy: string | null;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const isBusy = actionBusy === type || Boolean(actionBusy?.endsWith(type));
|
||||
|
||||
return (
|
||||
<Button type="button" variant="outline" className="w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
|
||||
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskAcceptanceCard({
|
||||
sessionId,
|
||||
runId,
|
||||
taskStatus,
|
||||
feedbackItems,
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">{pickAppText(locale, '任务验收', 'Task acceptance')}</CardTitle>
|
||||
{isRuntimeStatus(taskStatus) ? (
|
||||
<TaskRuntimeStatusBadge status={taskStatus} />
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{humanTaskStatus(taskStatus, locale)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TaskAcceptanceControls
|
||||
sessionId={sessionId}
|
||||
runId={runId}
|
||||
taskStatus={taskStatus}
|
||||
feedbackItems={feedbackItems}
|
||||
actionBusy={actionBusy}
|
||||
revision={revision}
|
||||
onRevisionChange={onRevisionChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskAcceptanceControls({
|
||||
sessionId,
|
||||
runId,
|
||||
taskStatus,
|
||||
feedbackItems,
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const [localComment, setLocalComment] = React.useState('');
|
||||
const comment = revision ?? localComment;
|
||||
const setComment = onRevisionChange ?? setLocalComment;
|
||||
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
|
||||
const isReadyForAcceptance = READY_FOR_ACCEPTANCE_STATUSES.has(taskStatus);
|
||||
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
|
||||
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && isReadyForAcceptance && !actionBusy;
|
||||
const trimmedComment = comment.trim();
|
||||
|
||||
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
|
||||
if (!runId || !canSubmit) return;
|
||||
void onSubmit(feedbackType, nextComment);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{recordedFeedback ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
|
||||
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(feedbackKind(recordedFeedback), locale)}
|
||||
</div>
|
||||
{recordedFeedback.comment ? <p className={`mt-2 text-muted-foreground ${containedPreservedLongTextClass}`}>{String(recordedFeedback.comment)}</p> : null}
|
||||
{recordedFeedback.created_at ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : isFinalized ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '任务已结束,不能再提交新的验收。', 'This task is finalized and cannot accept new acceptance.')}
|
||||
</div>
|
||||
) : !isReadyForAcceptance ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '任务还在执行,完成后才能验收。', 'The task is still running. Acceptance becomes available when a result is ready.')}
|
||||
</div>
|
||||
) : !runId ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<FeedbackButton
|
||||
type="accept"
|
||||
icon={<ThumbsUp className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '接受', 'Accept')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => submit('accept', trimmedComment || undefined)}
|
||||
/>
|
||||
<FeedbackButton
|
||||
type="revise"
|
||||
icon={<RefreshCw className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '需要修改', 'Needs revision')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit || !trimmedComment}
|
||||
onClick={() => submit('revise', trimmedComment)}
|
||||
/>
|
||||
<FeedbackButton
|
||||
type="abandon"
|
||||
icon={<XCircle className="mr-2 h-4 w-4" />}
|
||||
label={pickAppText(locale, '放弃', 'Abandon')}
|
||||
actionBusy={actionBusy}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => submit('abandon', trimmedComment || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
|
||||
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
||||
/>
|
||||
<div className={`text-xs text-muted-foreground ${containedPreservedLongTextClass}`}>
|
||||
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
|
||||
<span className="font-mono">{runId || '-'}</span>
|
||||
<span className="mx-1">·</span>
|
||||
{pickAppText(locale, '会话:', 'Session: ')}
|
||||
<span className="font-mono">{sessionId}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
app-instance/frontend/components/task-detail/TaskLiveHeader.tsx
Normal file
102
app-instance/frontend/components/task-detail/TaskLiveHeader.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, CheckCircle2, MessageSquare } from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
type Props = {
|
||||
task: BackendTask;
|
||||
activeLabel: string;
|
||||
durationMs: number | null;
|
||||
reviewTargetId?: string;
|
||||
};
|
||||
|
||||
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
|
||||
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||
return RUNTIME_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const map: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
};
|
||||
const item = map[status];
|
||||
return item ? pickAppText(locale, item[0], item[1]) : status;
|
||||
}
|
||||
|
||||
export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const title = task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
|
||||
const showReviewLink = Boolean(reviewTargetId && ['awaiting_acceptance', 'needs_revision'].includes(task.status));
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '对话', 'Chat')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isRuntimeStatus(task.status) ? (
|
||||
<TaskRuntimeStatusBadge status={task.status} />
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{humanTaskStatus(task.status, locale)}
|
||||
</Badge>
|
||||
)}
|
||||
{activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null}
|
||||
{showReviewLink ? (
|
||||
<Button asChild variant="default" size="sm">
|
||||
<a href={`#${reviewTargetId}`}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '验收', 'Review')}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-semibold leading-tight">{title}</h1>
|
||||
{task.description && task.description !== title ? (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{task.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
|
||||
</span>
|
||||
<span>
|
||||
{pickAppText(locale, '耗时', 'Duration')}: {formatTaskRuntimeDuration(durationMs, locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
253
app-instance/frontend/components/task-detail/TaskSideRail.tsx
Normal file
253
app-instance/frontend/components/task-detail/TaskSideRail.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle, Bot, Download, ExternalLink, FileText, Users } from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { getFileUrl } from '@/lib/api';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
import type { BackendTask, ProcessArtifact, ProcessRun, TaskTimelineCard } from '@/types';
|
||||
|
||||
type Props = {
|
||||
task: BackendTask;
|
||||
runs: ProcessRun[];
|
||||
artifacts: ProcessArtifact[];
|
||||
cards: TaskTimelineCard[];
|
||||
};
|
||||
|
||||
const ACTIVE_RUN_STATUSES = new Set<ProcessRun['status']>(['queued', 'running', 'waiting']);
|
||||
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
|
||||
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||
return RUNTIME_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const map: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
accept: ['已接受', 'Accepted'],
|
||||
satisfied: ['已接受', 'Accepted'],
|
||||
revise: ['已请求修改', 'Revision requested'],
|
||||
abandon: ['已放弃', 'Abandoned'],
|
||||
};
|
||||
const item = map[status];
|
||||
return item ? pickAppText(locale, item[0], item[1]) : status;
|
||||
}
|
||||
|
||||
function latestFeedback(task: BackendTask): Record<string, unknown> | null {
|
||||
return [...(task.feedback ?? [])].reverse()[0] ?? null;
|
||||
}
|
||||
|
||||
function acceptanceState(task: BackendTask, locale: 'zh-CN' | 'en-US'): string {
|
||||
const feedback = latestFeedback(task);
|
||||
const kind = String(feedback?.acceptance_type || feedback?.feedback_type || '');
|
||||
if (kind) return humanTaskStatus(kind, locale);
|
||||
if (task.status === 'awaiting_acceptance') return pickAppText(locale, '等待验收', 'Awaiting acceptance');
|
||||
if (task.status === 'needs_revision') return pickAppText(locale, '等待修改', 'Awaiting revision');
|
||||
return pickAppText(locale, '未验收', 'No acceptance yet');
|
||||
}
|
||||
|
||||
function toTime(value: string): number {
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function isWarningOrError(card: TaskTimelineCard): boolean {
|
||||
const severity = String(card.details?.severity || card.details?.level || '').toLowerCase();
|
||||
return card.type === 'error' || card.status === 'error' || severity === 'warning' || severity === 'error';
|
||||
}
|
||||
|
||||
function artifactHref(artifact: ProcessArtifact): string | null {
|
||||
if (artifact.url) return artifact.url;
|
||||
if (artifact.file_id) return getFileUrl(artifact.file_id);
|
||||
return null;
|
||||
}
|
||||
|
||||
function inlineArtifactPayload(artifact: ProcessArtifact): { content: string; filename: string; mimeType: string } | null {
|
||||
const baseName = (artifact.title || artifact.artifact_id || 'artifact').replace(/[\\/:*?"<>|]+/g, '-');
|
||||
if (artifact.content !== undefined) {
|
||||
const isMarkdown = artifact.artifact_type === 'markdown';
|
||||
return {
|
||||
content: artifact.content,
|
||||
filename: `${baseName}.${isMarkdown ? 'md' : 'txt'}`,
|
||||
mimeType: isMarkdown ? 'text/markdown;charset=utf-8' : 'text/plain;charset=utf-8',
|
||||
};
|
||||
}
|
||||
if (artifact.data !== undefined) {
|
||||
return {
|
||||
content: JSON.stringify(artifact.data, null, 2),
|
||||
filename: `${baseName}.json`,
|
||||
mimeType: 'application/json;charset=utf-8',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function downloadInlineArtifact(artifact: ProcessArtifact): void {
|
||||
const payload = inlineArtifactPayload(artifact);
|
||||
if (!payload) return;
|
||||
|
||||
const url = URL.createObjectURL(new Blob([payload.content], { type: payload.mimeType }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = payload.filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function RunRow({ run }: { run: ProcessRun }) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{run.title || run.actor_name}</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">{run.actor_name}</div>
|
||||
</div>
|
||||
<TaskRuntimeStatusBadge status={run.status} />
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(run.started_at, locale)}</div>
|
||||
{run.summary ? <p className="mt-2 line-clamp-2 text-xs text-muted-foreground">{run.summary}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskSideRail({ task, runs, artifacts, cards }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const activeRuns = runs.filter((run) => ACTIVE_RUN_STATUSES.has(run.status));
|
||||
const childRuns = runs.filter((run) => Boolean(run.parent_run_id));
|
||||
const latestAlert = cards.filter(isWarningOrError).sort((a, b) => toTime(b.createdAt) - toTime(a.createdAt))[0] ?? null;
|
||||
|
||||
return (
|
||||
<aside className="space-y-4">
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '任务状态', 'Task status')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{isRuntimeStatus(task.status) ? (
|
||||
<TaskRuntimeStatusBadge status={task.status} />
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{humanTaskStatus(task.status, locale)}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '活跃运行', 'Active runs')}: <span className="font-medium text-foreground">{activeRuns.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '验收', 'Acceptance')}: {acceptanceState(task, locale)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
{pickAppText(locale, '运行中', 'Active runs')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{activeRuns.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无活跃运行', 'No active runs')}</p>
|
||||
) : (
|
||||
activeRuns.map((run) => <RunRow key={run.run_id} run={run} />)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{latestAlert ? (
|
||||
<Card className="rounded-md border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
{pickAppText(locale, '最新提醒', 'Latest alert')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-sm font-medium">{latestAlert.title}</div>
|
||||
{latestAlert.summary ? <p className="text-sm text-muted-foreground">{latestAlert.summary}</p> : null}
|
||||
<div className="text-xs text-muted-foreground">{formatTaskRuntimeTime(latestAlert.createdAt, locale)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
{pickAppText(locale, 'Agent Team', 'Agent team')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{childRuns.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无子运行', 'No child runs')}</p>
|
||||
) : (
|
||||
childRuns.map((run) => <RunRow key={run.run_id} run={run} />)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
{pickAppText(locale, '产物', 'Artifacts')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{artifacts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无产物', 'No artifacts yet')}</p>
|
||||
) : (
|
||||
artifacts.map((artifact) => {
|
||||
const href = artifactHref(artifact);
|
||||
const inlinePayload = inlineArtifactPayload(artifact);
|
||||
return (
|
||||
<div key={artifact.artifact_id} className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{artifact.title}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{artifact.artifact_type}</div>
|
||||
</div>
|
||||
{href ? (
|
||||
<Button asChild size="sm" variant="outline" className="shrink-0">
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{artifact.url ? <ExternalLink className="mr-2 h-3.5 w-3.5" /> : <Download className="mr-2 h-3.5 w-3.5" />}
|
||||
{artifact.url ? pickAppText(locale, '打开', 'Open') : pickAppText(locale, '下载', 'Download')}
|
||||
</a>
|
||||
</Button>
|
||||
) : inlinePayload ? (
|
||||
<Button size="sm" variant="outline" className="shrink-0" onClick={() => downloadInlineArtifact(artifact)}>
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '下载', 'Download')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { Activity } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskTimelineCard as TaskTimelineCardView } from '@/types';
|
||||
|
||||
import { TaskTimelineCard, type TaskResultAcceptance } from './TaskTimelineCard';
|
||||
|
||||
type Props = {
|
||||
cards: TaskTimelineCardView[];
|
||||
isLive: boolean;
|
||||
resultAcceptance?: TaskResultAcceptance;
|
||||
reviewTargetId?: string;
|
||||
};
|
||||
|
||||
export function TaskTimeline({ cards, isLive, resultAcceptance, reviewTargetId }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold">{pickAppText(locale, '时间线', 'Timeline')}</h2>
|
||||
{isLive ? (
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '实时更新', 'Live')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{cards.length === 0 ? (
|
||||
<Card className="rounded-md border-dashed">
|
||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, 'Beaver 正在准备第一步。', 'Beaver is preparing the first step.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{cards.map((card) => (
|
||||
<TaskTimelineCard key={card.id} card={card} resultAcceptance={resultAcceptance} reviewTargetId={reviewTargetId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,240 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRightCircle,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
GitBranch,
|
||||
History,
|
||||
ListChecks,
|
||||
Sparkles,
|
||||
TerminalSquare,
|
||||
ThumbsUp,
|
||||
Users,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
import { containedJsonTextClass, containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||
import type { TaskTimelineCard as TaskTimelineCardView, TaskTimelineCardType } from '@/types';
|
||||
|
||||
import { TaskAcceptanceControls, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
|
||||
|
||||
type Props = {
|
||||
card: TaskTimelineCardView;
|
||||
resultAcceptance?: TaskResultAcceptance;
|
||||
reviewTargetId?: string;
|
||||
};
|
||||
|
||||
export type TaskResultAcceptance = {
|
||||
sessionId: string;
|
||||
runId: string | null;
|
||||
taskStatus: string;
|
||||
feedbackItems: TaskFeedbackItem[];
|
||||
actionBusy: string | null;
|
||||
revision?: string;
|
||||
onRevisionChange?: (value: string) => void;
|
||||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
|
||||
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||
return RUNTIME_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function iconForType(type: TaskTimelineCardType) {
|
||||
switch (type) {
|
||||
case 'task_created':
|
||||
return ClipboardList;
|
||||
case 'plan':
|
||||
return ListChecks;
|
||||
case 'skill':
|
||||
return Sparkles;
|
||||
case 'tool_call':
|
||||
return Wrench;
|
||||
case 'tool_result':
|
||||
return TerminalSquare;
|
||||
case 'next_step':
|
||||
return ArrowRightCircle;
|
||||
case 'agent_team':
|
||||
return Users;
|
||||
case 'agent_progress':
|
||||
return Bot;
|
||||
case 'agent_handoff':
|
||||
return GitBranch;
|
||||
case 'artifact':
|
||||
return FileText;
|
||||
case 'error':
|
||||
return AlertTriangle;
|
||||
case 'result':
|
||||
return CheckCircle2;
|
||||
case 'result_history':
|
||||
return History;
|
||||
case 'acceptance':
|
||||
return ThumbsUp;
|
||||
}
|
||||
}
|
||||
|
||||
function detailsJson(details: Record<string, unknown>): string {
|
||||
try {
|
||||
return JSON.stringify(details, null, 2);
|
||||
} catch {
|
||||
return String(details);
|
||||
}
|
||||
}
|
||||
|
||||
function cardTypeLabel(type: TaskTimelineCardType, locale: 'zh-CN' | 'en-US') {
|
||||
const labels: Record<TaskTimelineCardType, [string, string]> = {
|
||||
task_created: ['任务', 'Task'],
|
||||
plan: ['计划', 'Plan'],
|
||||
skill: ['Skill', 'Skill'],
|
||||
tool_call: ['工具调用', 'Tool call'],
|
||||
tool_result: ['工具结果', 'Tool result'],
|
||||
next_step: ['下一步', 'Next step'],
|
||||
agent_team: ['Agent Team', 'Agent team'],
|
||||
agent_progress: ['Agent', 'Agent'],
|
||||
agent_handoff: ['交接', 'Handoff'],
|
||||
artifact: ['产物', 'Artifact'],
|
||||
error: ['异常', 'Error'],
|
||||
result: ['结果', 'Result'],
|
||||
result_history: ['历史结果', 'Result history'],
|
||||
acceptance: ['验收', 'Acceptance'],
|
||||
};
|
||||
const label = labels[type];
|
||||
return pickAppText(locale, label[0], label[1]);
|
||||
}
|
||||
|
||||
function humanStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const labels: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
accept: ['接受', 'Accepted'],
|
||||
satisfied: ['接受', 'Accepted'],
|
||||
revise: ['请求修改', 'Revision requested'],
|
||||
abandon: ['放弃任务', 'Abandoned'],
|
||||
warning: ['提醒', 'Warning'],
|
||||
};
|
||||
const label = labels[status];
|
||||
return label ? pickAppText(locale, label[0], label[1]) : status;
|
||||
}
|
||||
|
||||
function historyVersions(details: Record<string, unknown> | undefined): Array<Record<string, unknown>> {
|
||||
const versions = details?.versions;
|
||||
return Array.isArray(versions) ? versions.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object') : [];
|
||||
}
|
||||
|
||||
function renderHistoryStatus(version: Record<string, unknown>, locale: 'zh-CN' | 'en-US') {
|
||||
const status = String(version.acceptanceType || version.status || '');
|
||||
return status ? humanStatus(status, locale) : pickAppText(locale, '历史版本', 'Previous version');
|
||||
}
|
||||
|
||||
function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
|
||||
const { locale } = useAppI18n();
|
||||
const versions = historyVersions(card.details);
|
||||
|
||||
return (
|
||||
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
|
||||
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
|
||||
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{versions.map((version, index) => (
|
||||
<div key={String(version.runId || index)} className="min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-background p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">
|
||||
{pickAppText(locale, `第 ${index + 1} 轮结果`, `Version ${index + 1}`)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{renderHistoryStatus(version, locale)}
|
||||
</Badge>
|
||||
</div>
|
||||
{version.result ? <p className={`mt-2 text-sm leading-6 text-muted-foreground ${containedPreservedLongTextClass}`}>{String(version.result)}</p> : null}
|
||||
{version.comment ? (
|
||||
<div className={`mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground ${containedLongTextClass}`}>
|
||||
{pickAppText(locale, '修改意见', 'Revision note')}: {String(version.comment)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const Icon = iconForType(card.type);
|
||||
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
|
||||
|
||||
return (
|
||||
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="scroll-mt-28 overflow-hidden rounded-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold">{card.title}</h3>
|
||||
<Badge variant="secondary" className="shrink-0 text-[11px]">
|
||||
{cardTypeLabel(card.type, locale)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{card.actorName ? <span className={containedLongTextClass}>{card.actorName}</span> : null}
|
||||
<span>{formatTaskRuntimeTime(card.createdAt, locale)}</span>
|
||||
{card.runId ? <span className="font-mono">{card.runId.slice(0, 8)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{card.status ? (
|
||||
isRuntimeStatus(card.status) ? (
|
||||
<TaskRuntimeStatusBadge status={card.status} />
|
||||
) : (
|
||||
<Badge variant="outline" className="shrink-0 text-[11px]">
|
||||
{humanStatus(card.status, locale)}
|
||||
</Badge>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{card.summary ? <p className={`mt-3 text-sm leading-6 text-muted-foreground ${containedPreservedLongTextClass}`}>{card.summary}</p> : null}
|
||||
|
||||
{shouldRenderResultAcceptance ? (
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
<TaskAcceptanceControls {...resultAcceptance!} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
|
||||
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
|
||||
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
|
||||
{pickAppText(locale, '详情 JSON', 'Details JSON')}
|
||||
</summary>
|
||||
<pre className={`mt-2 max-h-72 overflow-auto text-[11px] leading-5 text-muted-foreground ${containedJsonTextClass}`}>
|
||||
{detailsJson(card.details)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
5
app-instance/frontend/components/task-detail/index.ts
Normal file
5
app-instance/frontend/components/task-detail/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { TaskAcceptanceCard, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
|
||||
export { TaskLiveHeader } from './TaskLiveHeader';
|
||||
export { TaskSideRail } from './TaskSideRail';
|
||||
export { TaskTimeline } from './TaskTimeline';
|
||||
export { TaskTimelineCard } from './TaskTimelineCard';
|
||||
@ -4,9 +4,16 @@ import type {
|
||||
AuthzStatus,
|
||||
AuthUser,
|
||||
ActiveTask,
|
||||
AgentConfigPayload,
|
||||
ChatLogsResponse,
|
||||
BackendTask,
|
||||
ChatMessage,
|
||||
ChannelConfigDetail,
|
||||
ChannelConfigPayload,
|
||||
ChannelConnectorDescriptor,
|
||||
ConnectorSessionResponse,
|
||||
ConnectorSessionStartPayload,
|
||||
ChannelEventRecord,
|
||||
CronJob,
|
||||
FileAttachment,
|
||||
NotificationDetail,
|
||||
@ -620,6 +627,13 @@ export async function getStatus(): Promise<SystemStatus> {
|
||||
return fetchJSON('/api/status');
|
||||
}
|
||||
|
||||
export async function updateAgentConfig(payload: AgentConfigPayload): Promise<{ ok: boolean }> {
|
||||
return fetchJSON('/api/agent-config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProviderConfig(
|
||||
providerId: string,
|
||||
payload: ProviderConfigPayload
|
||||
@ -630,6 +644,53 @@ export async function updateProviderConfig(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChannelConfig(channelId: string): Promise<ChannelConfigDetail> {
|
||||
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/config`);
|
||||
}
|
||||
|
||||
export async function updateChannelConfig(
|
||||
channelId: string,
|
||||
payload: ChannelConfigPayload
|
||||
): Promise<{ ok: boolean; channel_id: string; restart_required: boolean; channel: ChannelConfigDetail }> {
|
||||
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/config`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listChannelEvents(channelId: string, limit: number = 100): Promise<ChannelEventRecord[]> {
|
||||
return fetchJSON(`/api/channels/${encodeURIComponent(channelId)}/events?limit=${limit}`);
|
||||
}
|
||||
|
||||
export async function listChannelConnectors(): Promise<ChannelConnectorDescriptor[]> {
|
||||
return fetchJSON('/api/channel-connectors');
|
||||
}
|
||||
|
||||
export async function startChannelConnectorSession(
|
||||
payload: ConnectorSessionStartPayload
|
||||
): Promise<ConnectorSessionResponse> {
|
||||
return fetchJSON('/api/channel-connector-sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
kind: payload.kind,
|
||||
displayName: payload.displayName,
|
||||
ownerUserId: payload.ownerUserId,
|
||||
options: payload.options || {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChannelConnectorSession(sessionId: string): Promise<ConnectorSessionResponse> {
|
||||
return fetchJSON(`/api/channel-connector-sessions/${encodeURIComponent(sessionId)}`);
|
||||
}
|
||||
|
||||
export async function restartRuntime(): Promise<{ ok: boolean; restarting: boolean }> {
|
||||
return fetchJSON('/api/runtime/restart', {
|
||||
method: 'POST',
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cron (proxied)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
81
app-instance/frontend/lib/channel-connectors.test.ts
Normal file
81
app-instance/frontend/lib/channel-connectors.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getChannelConnectorSession,
|
||||
listChannelConnectors,
|
||||
startChannelConnectorSession,
|
||||
} from '@/lib/api';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.clear();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mockJsonResponse(body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(body),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
function firstFetchCall(fetchMock: any): [unknown, RequestInit] {
|
||||
return fetchMock.mock.calls[0] as [unknown, RequestInit];
|
||||
}
|
||||
|
||||
describe('channel connector api', () => {
|
||||
it('lists available channel connectors', async () => {
|
||||
const fetchMock = vi.fn(() => mockJsonResponse([{ kind: 'weixin', displayName: 'Weixin' }]));
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const connectors = await listChannelConnectors();
|
||||
|
||||
expect(connectors).toEqual([{ kind: 'weixin', displayName: 'Weixin' }]);
|
||||
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connectors$/);
|
||||
});
|
||||
|
||||
it('starts a connector session with options', async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
mockJsonResponse({
|
||||
session: { sessionId: 'cs_1', kind: 'weixin', status: 'qr_ready' },
|
||||
connection: { connection_id: 'conn_1', kind: 'weixin', status: 'pairing' },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const response = await startChannelConnectorSession({
|
||||
kind: 'weixin',
|
||||
displayName: 'Weixin Main',
|
||||
options: { mode: 'qr' },
|
||||
});
|
||||
|
||||
expect(response.session.sessionId).toBe('cs_1');
|
||||
const [, request] = firstFetchCall(fetchMock);
|
||||
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connector-sessions$/);
|
||||
expect(request).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ kind: 'weixin', displayName: 'Weixin Main', options: { mode: 'qr' } }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('polls a connector session by id', async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
mockJsonResponse({
|
||||
session: { sessionId: 'cs_1', kind: 'weixin', status: 'connected' },
|
||||
connection: { connection_id: 'conn_1', kind: 'weixin', status: 'connected' },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const response = await getChannelConnectorSession('cs_1');
|
||||
|
||||
expect(response.connection?.status).toBe('connected');
|
||||
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connector-sessions\/cs_1$/);
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTaskCardMessageIndexes, mergeServerWithPendingUsers, shouldDisplayChatMessage, shouldMergePendingUsers } from '@/lib/chat-messages';
|
||||
import {
|
||||
getSessionRefreshIntervalMs,
|
||||
getTaskCardMessageIndexes,
|
||||
mergeServerWithPendingUsers,
|
||||
shouldDisplayChatMessage,
|
||||
shouldMergePendingUsers,
|
||||
} from '@/lib/chat-messages';
|
||||
import type { ChatMessage } from '@/types';
|
||||
|
||||
describe('chat message helpers', () => {
|
||||
@ -98,4 +104,11 @@ describe('chat message helpers', () => {
|
||||
expect(shouldDisplayChatMessage({ role: 'assistant', content: 'Final answer.', task_id: 'task-1', run_id: 'run-1' })).toBe(true);
|
||||
expect(shouldDisplayChatMessage({ role: 'user', content: '' })).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps polling idle visible chats so external channel messages appear', () => {
|
||||
expect(getSessionRefreshIntervalMs({ isLoading: true, isThinking: false, documentHidden: false })).toBe(1500);
|
||||
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: true, documentHidden: false })).toBe(1500);
|
||||
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: false, documentHidden: false })).toBe(5000);
|
||||
expect(getSessionRefreshIntervalMs({ isLoading: false, isThinking: false, documentHidden: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,26 @@
|
||||
import type { ChatMessage } from '@/types';
|
||||
|
||||
const INVISIBLE_CONTENT_CHARS = /[\u200B-\u200D\uFEFF]/g;
|
||||
export const CHAT_WAITING_REFRESH_INTERVAL_MS = 1500;
|
||||
export const CHAT_IDLE_REFRESH_INTERVAL_MS = 5000;
|
||||
|
||||
export function getSessionRefreshIntervalMs({
|
||||
isLoading,
|
||||
isThinking,
|
||||
documentHidden,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
isThinking: boolean;
|
||||
documentHidden: boolean;
|
||||
}): number | null {
|
||||
if (documentHidden) {
|
||||
return null;
|
||||
}
|
||||
if (isLoading || isThinking) {
|
||||
return CHAT_WAITING_REFRESH_INTERVAL_MS;
|
||||
}
|
||||
return CHAT_IDLE_REFRESH_INTERVAL_MS;
|
||||
}
|
||||
|
||||
export function normalizedMessageText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
|
||||
@ -64,4 +64,29 @@ describe('chat store process event ingestion', () => {
|
||||
expect(useChatStore.getState().getInputDraft('web:alpha')).toBe('');
|
||||
expect(useChatStore.getState().getInputDraft('web:beta')).toBe('message for beta');
|
||||
});
|
||||
|
||||
it('keeps live task events after persisted session projection is merged', () => {
|
||||
const store = useChatStore.getState();
|
||||
store.setSessionId('web:default');
|
||||
store.ingestProcessEvent({
|
||||
type: 'process_run_progress',
|
||||
session_id: 'web:default',
|
||||
run_id: 'run-live',
|
||||
parent_run_id: null,
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
text: '正在调用工具',
|
||||
metadata: { task_id: 'task-live', timeline_type: 'tool_call' },
|
||||
created_at: '2026-05-26T10:00:00.000Z',
|
||||
});
|
||||
|
||||
store.setSessionProcess('web:default', {
|
||||
runs: [],
|
||||
events: [],
|
||||
artifacts: [],
|
||||
});
|
||||
|
||||
expect(useChatStore.getState().processEvents.some((event) => event.run_id === 'run-live')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -117,6 +117,11 @@ function appendEvent(collection: ProcessEvent[], event: ProcessEvent): ProcessEv
|
||||
return [...collection, event];
|
||||
}
|
||||
|
||||
function hasTaskMetadata(item: { metadata?: Record<string, unknown> }): boolean {
|
||||
const taskId = item.metadata?.task_id;
|
||||
return typeof taskId === 'string' && taskId.trim().length > 0;
|
||||
}
|
||||
|
||||
function createEventId(event: ProcessWsEvent): string {
|
||||
if (event.type === 'process_cancel_ack') {
|
||||
return `${event.type}:${event.run_id}`;
|
||||
@ -393,7 +398,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
const incomingArtifacts = projection.artifacts || [];
|
||||
const incomingRunIds = new Set(incomingRuns.map((run) => run.run_id));
|
||||
const nextRuns = [
|
||||
...state.processRuns.filter((run) => run.session_id !== sessionId && !incomingRunIds.has(run.run_id)),
|
||||
...state.processRuns.filter((run) => {
|
||||
if (incomingRunIds.has(run.run_id)) return false;
|
||||
if (run.session_id !== sessionId) return true;
|
||||
return hasTaskMetadata(run);
|
||||
}),
|
||||
...incomingRuns,
|
||||
];
|
||||
const liveRunIds = new Set(nextRuns.map((run) => run.run_id));
|
||||
|
||||
37
app-instance/frontend/lib/task-detail-refresh.test.ts
Normal file
37
app-instance/frontend/lib/task-detail-refresh.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
const baseTask: BackendTask = {
|
||||
task_id: 'task-1',
|
||||
session_id: 'web:test',
|
||||
description: '查找餐厅',
|
||||
goal: '查找餐厅',
|
||||
constraints: [],
|
||||
priority: 0,
|
||||
status: 'running',
|
||||
creator: 'main-agent',
|
||||
created_at: '2026-05-27T02:02:41.000Z',
|
||||
updated_at: '2026-05-27T02:02:41.500Z',
|
||||
run_ids: [],
|
||||
skill_names: [],
|
||||
feedback: [],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
describe('task detail refresh helpers', () => {
|
||||
it('polls executing task details regardless of websocket status', () => {
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'running' })).toBe(true);
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'open' })).toBe(true);
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'awaiting_acceptance' })).toBe(false);
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'closed' })).toBe(false);
|
||||
});
|
||||
|
||||
it('uses current time for active task duration instead of stale updated_at', () => {
|
||||
vi.setSystemTime(new Date('2026-05-27T02:03:41.000Z'));
|
||||
|
||||
expect(taskDetailDurationMs(baseTask)).toBe(60_000);
|
||||
expect(taskDetailDurationMs({ ...baseTask, status: 'awaiting_acceptance', updated_at: '2026-05-27T02:10:55.000Z' })).toBe(494_000);
|
||||
});
|
||||
});
|
||||
18
app-instance/frontend/lib/task-detail-refresh.ts
Normal file
18
app-instance/frontend/lib/task-detail-refresh.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
const EXECUTING_TASK_STATUSES = new Set(['open', 'queued', 'running']);
|
||||
const FINISHED_FOR_DURATION_STATUSES = new Set(['awaiting_acceptance', 'closed', 'abandoned', 'cancelled', 'error']);
|
||||
|
||||
export function shouldPollTaskDetail(task: Pick<BackendTask, 'status'> | null): boolean {
|
||||
return Boolean(task && EXECUTING_TASK_STATUSES.has(task.status));
|
||||
}
|
||||
|
||||
export function taskDetailDurationMs(task: Pick<BackendTask, 'created_at' | 'updated_at' | 'closed_at' | 'status'>): number | null {
|
||||
const start = new Date(task.created_at).getTime();
|
||||
const end = FINISHED_FOR_DURATION_STATUSES.has(task.status)
|
||||
? new Date(task.closed_at || task.updated_at).getTime()
|
||||
: Date.now();
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
return Math.max(0, end - start);
|
||||
}
|
||||
469
app-instance/frontend/lib/task-timeline.test.ts
Normal file
469
app-instance/frontend/lib/task-timeline.test.ts
Normal file
@ -0,0 +1,469 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTaskTimelineCards } from '@/lib/task-timeline';
|
||||
import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
|
||||
function makeTask(overrides: Partial<BackendTask> = {}): BackendTask {
|
||||
return {
|
||||
task_id: 'task-1',
|
||||
session_id: 'web:default',
|
||||
description: 'Research the market',
|
||||
short_title: 'Market research',
|
||||
is_open: true,
|
||||
goal: 'Summarize the market',
|
||||
constraints: [],
|
||||
priority: 1,
|
||||
status: 'running',
|
||||
creator: 'user',
|
||||
created_at: '2026-05-26T10:00:00.000Z',
|
||||
updated_at: '2026-05-26T10:00:00.000Z',
|
||||
run_ids: ['run-main'],
|
||||
skill_names: [],
|
||||
feedback: [],
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildTaskTimelineCards', () => {
|
||||
it('builds ordered timeline cards from task process data', () => {
|
||||
const task = makeTask();
|
||||
const processRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
title: 'Plan and coordinate',
|
||||
status: 'running',
|
||||
started_at: '2026-05-26T10:00:30.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
title: 'Read source documents',
|
||||
status: 'done',
|
||||
started_at: '2026-05-26T10:05:00.000Z',
|
||||
finished_at: '2026-05-26T10:05:30.000Z',
|
||||
summary: 'Finished reading source documents.',
|
||||
},
|
||||
];
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-plan',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_planned',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
text: 'Plan created.',
|
||||
created_at: '2026-05-26T10:01:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-skill',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'skill_selected',
|
||||
actor_type: 'system',
|
||||
actor_id: 'skill-router',
|
||||
actor_name: 'Skill Router',
|
||||
text: 'Research skill selected.',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
metadata: {
|
||||
selected_skill_names: ['research'],
|
||||
reason: 'Need source review.',
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: 'evt-tool-start',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: 'tool_call_started',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'document-reader',
|
||||
actor_name: 'Document Reader',
|
||||
text: 'Reading source documents.',
|
||||
created_at: '2026-05-26T10:03:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-tool-finish',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: 'tool_call_finished',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'document-reader',
|
||||
actor_name: 'Document Reader',
|
||||
text: 'Documents read.',
|
||||
created_at: '2026-05-26T10:04:00.000Z',
|
||||
metadata: {
|
||||
result_summary: '2 documents read successfully.',
|
||||
},
|
||||
},
|
||||
];
|
||||
const processArtifacts: ProcessArtifact[] = [
|
||||
{
|
||||
artifact_id: 'artifact-summary',
|
||||
run_id: 'run-research',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
title: 'Research summary',
|
||||
artifact_type: 'markdown',
|
||||
content: '# Summary',
|
||||
created_at: '2026-05-26T10:06:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({
|
||||
task,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
});
|
||||
|
||||
expect(cards.map((card) => card.type)).toEqual([
|
||||
'task_created',
|
||||
'plan',
|
||||
'skill',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'agent_progress',
|
||||
'artifact',
|
||||
]);
|
||||
expect(cards[1].title).toBe('执行计划');
|
||||
expect(cards[2].title).toBe('选择 Skill');
|
||||
expect(cards[4].summary).toBe('2 documents read successfully.');
|
||||
expect(cards[6].relatedArtifactIds).toEqual(['artifact-summary']);
|
||||
});
|
||||
|
||||
it('appends result and acceptance cards for closed tasks with feedback', () => {
|
||||
const task = makeTask({
|
||||
is_open: false,
|
||||
status: 'closed',
|
||||
updated_at: '2026-05-26T10:04:00.000Z',
|
||||
closed_at: '2026-05-26T10:04:00.000Z',
|
||||
feedback: [
|
||||
{
|
||||
acceptance_type: 'accept',
|
||||
comment: '可以',
|
||||
created_at: '2026-05-26T10:05:00.000Z',
|
||||
run_id: 'run-main',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const cards = buildTaskTimelineCards({ task });
|
||||
|
||||
expect(cards.at(-2)?.type).toBe('result');
|
||||
expect(cards.at(-1)?.type).toBe('acceptance');
|
||||
expect(cards.at(-1)?.summary).toContain('可以');
|
||||
});
|
||||
|
||||
it('uses the latest assistant message from the acceptance run as the result body', () => {
|
||||
const task = makeTask({
|
||||
status: 'awaiting_acceptance',
|
||||
updated_at: '2026-05-26T10:04:00.000Z',
|
||||
run_ids: ['run-main'],
|
||||
runs: [
|
||||
{
|
||||
run_id: 'run-main',
|
||||
title: '主 Agent',
|
||||
session_id: 'web:default',
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'Draft answer', created_at: '2026-05-26T10:03:00.000Z' },
|
||||
{ role: 'assistant', content: 'Final user-visible answer', created_at: '2026-05-26T10:04:00.000Z' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-result-ready',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'system',
|
||||
actor_id: 'evidence',
|
||||
actor_name: 'Evidence',
|
||||
text: 'The task result is ready for user acceptance.',
|
||||
created_at: '2026-05-26T10:04:00.000Z',
|
||||
metadata: {
|
||||
result_summary: 'Summary should not replace the final answer.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
const result = cards.find((card) => card.type === 'result');
|
||||
|
||||
expect(result?.summary).toBe('Final user-visible answer');
|
||||
expect(result?.details?.result_summary).toBe('Summary should not replace the final answer.');
|
||||
});
|
||||
|
||||
it('collapses previous result and acceptance cards into a history pack', () => {
|
||||
const task = makeTask({
|
||||
status: 'awaiting_acceptance',
|
||||
updated_at: '2026-05-26T10:12:00.000Z',
|
||||
run_ids: ['run-1', 'run-2'],
|
||||
feedback: [
|
||||
{
|
||||
acceptance_type: 'revise',
|
||||
comment: 'Add decisions',
|
||||
created_at: '2026-05-26T10:06:00.000Z',
|
||||
run_id: 'run-1',
|
||||
},
|
||||
],
|
||||
runs: [
|
||||
{
|
||||
run_id: 'run-1',
|
||||
title: '主 Agent',
|
||||
session_id: 'web:default',
|
||||
messages: [{ role: 'assistant', content: 'Version one answer', created_at: '2026-05-26T10:05:00.000Z' }],
|
||||
},
|
||||
{
|
||||
run_id: 'run-2',
|
||||
title: '主 Agent',
|
||||
session_id: 'web:default',
|
||||
messages: [{ role: 'assistant', content: 'Version two answer', created_at: '2026-05-26T10:12:00.000Z' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-result-1',
|
||||
run_id: 'run-1',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'system',
|
||||
actor_id: 'evidence',
|
||||
actor_name: 'Evidence',
|
||||
text: 'Result one ready.',
|
||||
created_at: '2026-05-26T10:05:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-plan-2',
|
||||
run_id: 'run-2',
|
||||
parent_run_id: null,
|
||||
kind: 'task_planned',
|
||||
actor_type: 'system',
|
||||
actor_id: 'planner',
|
||||
actor_name: 'Task Planner',
|
||||
text: 'Second attempt planned.',
|
||||
created_at: '2026-05-26T10:08:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-result-2',
|
||||
run_id: 'run-2',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'system',
|
||||
actor_id: 'evidence',
|
||||
actor_name: 'Evidence',
|
||||
text: 'Result two ready.',
|
||||
created_at: '2026-05-26T10:12:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.map((card) => card.type)).toEqual([
|
||||
'task_created',
|
||||
'result_history',
|
||||
'plan',
|
||||
'result',
|
||||
]);
|
||||
const history = cards.find((card) => card.type === 'result_history');
|
||||
expect(history?.summary).toBe('1 历史结果版本');
|
||||
expect(history?.details?.versions).toEqual([
|
||||
expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
result: 'Version one answer',
|
||||
acceptanceType: 'revise',
|
||||
comment: 'Add decisions',
|
||||
}),
|
||||
]);
|
||||
expect(cards.find((card) => card.id === 'evt-plan-2')).toBeTruthy();
|
||||
expect(cards.at(-1)?.summary).toBe('Version two answer');
|
||||
});
|
||||
|
||||
it('does not add fallback progress when a child run already has progress events', () => {
|
||||
const task = makeTask();
|
||||
const processRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
title: 'Read source documents',
|
||||
status: 'running',
|
||||
started_at: '2026-05-26T10:01:00.000Z',
|
||||
},
|
||||
];
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-progress',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: 'run_progress',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
text: 'Reading source documents.',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processRuns, processEvents });
|
||||
|
||||
expect(cards.filter((card) => card.runId === 'run-research' && card.type === 'agent_progress')).toHaveLength(1);
|
||||
expect(cards.map((card) => card.id)).not.toContain('run-research:fallback-progress');
|
||||
});
|
||||
|
||||
it('marks a tool call as finished when a matching tool result exists', () => {
|
||||
const task = makeTask();
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-tool-start',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'tool_call_started',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'web_search',
|
||||
actor_name: 'web_search',
|
||||
text: 'Calling tool: web_search.',
|
||||
status: 'running',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
metadata: {
|
||||
tool_call_id: 'call-1',
|
||||
tool_name: 'web_search',
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: 'evt-tool-finish',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'tool_call_finished',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'web_search',
|
||||
actor_name: 'web_search',
|
||||
text: 'Search failed.',
|
||||
status: 'error',
|
||||
created_at: '2026-05-26T10:03:00.000Z',
|
||||
metadata: {
|
||||
tool_call_id: 'call-1',
|
||||
tool_name: 'web_search',
|
||||
result_summary: 'Search failed.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.find((card) => card.id === 'evt-tool-start')?.status).toBe('error');
|
||||
expect(cards.find((card) => card.id === 'evt-tool-finish')?.type).toBe('tool_result');
|
||||
expect(cards.find((card) => card.id === 'evt-tool-finish')?.summary).toBe('Search failed.');
|
||||
});
|
||||
|
||||
it('maps agent_finished events without timeline metadata to agent progress cards', () => {
|
||||
const task = makeTask();
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-agent-finished',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: 'agent_finished',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
text: 'Finished reading source documents.',
|
||||
status: 'done',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.find((card) => card.id === 'evt-agent-finished')?.type).toBe('agent_progress');
|
||||
});
|
||||
|
||||
it('sorts invalid timestamps after valid timestamps while preserving insertion order', () => {
|
||||
const task = makeTask();
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-invalid-date',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_planned',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
text: 'Plan created.',
|
||||
created_at: 'not-a-date',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.map((card) => card.id)).toEqual(['task-1:created', 'evt-invalid-date']);
|
||||
});
|
||||
|
||||
it('dedupes synthetic result and acceptance milestones when lifecycle events exist', () => {
|
||||
const task = makeTask({
|
||||
is_open: false,
|
||||
status: 'closed',
|
||||
updated_at: '2026-05-26T10:04:00.000Z',
|
||||
closed_at: '2026-05-26T10:04:00.000Z',
|
||||
feedback: [
|
||||
{
|
||||
acceptance_type: 'accept',
|
||||
comment: '可以',
|
||||
created_at: '2026-05-26T10:05:00.000Z',
|
||||
run_id: 'run-main',
|
||||
},
|
||||
],
|
||||
});
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-result-ready',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
text: 'Result is ready.',
|
||||
created_at: '2026-05-26T10:04:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-acceptance-recorded',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_acceptance_recorded',
|
||||
actor_type: 'user',
|
||||
actor_id: 'user-acceptance',
|
||||
actor_name: 'User Acceptance',
|
||||
text: '可以',
|
||||
created_at: '2026-05-26T10:05:02.000Z',
|
||||
metadata: {
|
||||
acceptance_type: 'accept',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.filter((card) => card.type === 'result')).toHaveLength(1);
|
||||
expect(cards.filter((card) => card.type === 'acceptance')).toHaveLength(1);
|
||||
expect(cards.map((card) => card.id)).toContain('evt-result-ready');
|
||||
expect(cards.map((card) => card.id)).toContain('evt-acceptance-recorded');
|
||||
});
|
||||
});
|
||||
490
app-instance/frontend/lib/task-timeline.ts
Normal file
490
app-instance/frontend/lib/task-timeline.ts
Normal file
@ -0,0 +1,490 @@
|
||||
import type {
|
||||
BackendTask,
|
||||
ProcessArtifact,
|
||||
ProcessEvent,
|
||||
ProcessRun,
|
||||
TaskTimelineCard,
|
||||
TaskTimelineCardType,
|
||||
} from '@/types';
|
||||
|
||||
export type BuildTaskTimelineCardsInput = {
|
||||
task: BackendTask;
|
||||
processRuns?: ProcessRun[];
|
||||
processEvents?: ProcessEvent[];
|
||||
processArtifacts?: ProcessArtifact[];
|
||||
};
|
||||
|
||||
const TIMELINE_CARD_TYPES = new Set<TaskTimelineCardType>([
|
||||
'task_created',
|
||||
'plan',
|
||||
'skill',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'next_step',
|
||||
'agent_team',
|
||||
'agent_progress',
|
||||
'agent_handoff',
|
||||
'artifact',
|
||||
'error',
|
||||
'result',
|
||||
'result_history',
|
||||
'acceptance',
|
||||
]);
|
||||
|
||||
const RESULT_STATUSES = new Set(['awaiting_acceptance', 'closed', 'abandoned', 'cancelled', 'error']);
|
||||
|
||||
function isTimelineCardType(value: unknown): value is TaskTimelineCardType {
|
||||
return typeof value === 'string' && TIMELINE_CARD_TYPES.has(value as TaskTimelineCardType);
|
||||
}
|
||||
|
||||
function toTime(value: string): number | null {
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function firstString(...values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return [value.trim()];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeSkillNames(metadata: Record<string, unknown> | undefined): string[] | undefined {
|
||||
if (!metadata || (!('skill_names' in metadata) && !('selected_skill_names' in metadata))) {
|
||||
return undefined;
|
||||
}
|
||||
const names = [
|
||||
...stringList(metadata.skill_names),
|
||||
...stringList(metadata.selected_skill_names),
|
||||
];
|
||||
return Array.from(new Set(names));
|
||||
}
|
||||
|
||||
function cardTypeForEvent(event: ProcessEvent): TaskTimelineCardType | null {
|
||||
const timelineType = event.metadata?.timeline_type;
|
||||
if (isTimelineCardType(timelineType)) {
|
||||
return timelineType;
|
||||
}
|
||||
|
||||
switch (String(event.kind)) {
|
||||
case 'task_planned':
|
||||
case 'run_started':
|
||||
return 'plan';
|
||||
case 'skill_selected':
|
||||
return 'skill';
|
||||
case 'tool_call_started':
|
||||
return 'tool_call';
|
||||
case 'tool_call_finished':
|
||||
return 'tool_result';
|
||||
case 'agent_team_created':
|
||||
return 'agent_team';
|
||||
case 'agent_handoff':
|
||||
return 'agent_handoff';
|
||||
case 'agent_finished':
|
||||
case 'run_progress':
|
||||
case 'run_finished':
|
||||
return 'agent_progress';
|
||||
case 'task_result_ready':
|
||||
return 'result';
|
||||
case 'task_acceptance_recorded':
|
||||
return 'acceptance';
|
||||
case 'task_error':
|
||||
return 'error';
|
||||
default:
|
||||
if (event.status === 'error') {
|
||||
return 'error';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function titleForCard(type: TaskTimelineCardType, actorName?: string): string {
|
||||
switch (type) {
|
||||
case 'task_created':
|
||||
return '任务已创建';
|
||||
case 'plan':
|
||||
return '执行计划';
|
||||
case 'skill':
|
||||
return '选择 Skill';
|
||||
case 'tool_call':
|
||||
return actorName ? `调用工具:${actorName}` : '调用工具';
|
||||
case 'tool_result':
|
||||
return actorName ? `工具结果:${actorName}` : '工具结果';
|
||||
case 'next_step':
|
||||
return '下一步';
|
||||
case 'agent_team':
|
||||
return '启动 Agent Team';
|
||||
case 'agent_progress':
|
||||
return actorName || 'Agent 进展';
|
||||
case 'agent_handoff':
|
||||
return 'Agent 交接';
|
||||
case 'artifact':
|
||||
return '生成产物';
|
||||
case 'error':
|
||||
return '执行遇到问题';
|
||||
case 'result':
|
||||
return '本轮结果';
|
||||
case 'result_history':
|
||||
return '历史结果版本';
|
||||
case 'acceptance':
|
||||
return '任务验收';
|
||||
}
|
||||
}
|
||||
|
||||
function summaryForEvent(event: ProcessEvent): string | undefined {
|
||||
return firstString(
|
||||
event.metadata?.result_summary,
|
||||
event.metadata?.reason,
|
||||
event.metadata?.action_summary,
|
||||
event.text,
|
||||
);
|
||||
}
|
||||
|
||||
function detailsForEvent(event: ProcessEvent): Record<string, unknown> | undefined {
|
||||
const skillNames = normalizeSkillNames(event.metadata);
|
||||
if (!event.metadata && !skillNames) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(event.metadata ?? {}),
|
||||
...(skillNames ? { skill_names: skillNames } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function feedbackCreatedAt(feedback: Record<string, unknown>, task: BackendTask): string {
|
||||
return firstString(feedback.created_at, task.updated_at, task.created_at) ?? task.created_at;
|
||||
}
|
||||
|
||||
function feedbackSummary(feedback: Record<string, unknown>): string | undefined {
|
||||
return firstString(feedback.comment, feedback.summary, feedback.acceptance_type);
|
||||
}
|
||||
|
||||
function acceptanceTypeFromRecord(record: Record<string, unknown> | undefined): string | null {
|
||||
return firstString(record?.acceptance_type, record?.feedback_type)?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function resultSummary(task: BackendTask): string | undefined {
|
||||
return firstString(
|
||||
task.metadata?.result_summary,
|
||||
task.metadata?.summary,
|
||||
task.close_reason,
|
||||
task.validation_result?.summary,
|
||||
);
|
||||
}
|
||||
|
||||
function assistantResultForRun(task: BackendTask, runId: string | null | undefined): string | undefined {
|
||||
if (!runId) return undefined;
|
||||
const run = (task.runs ?? []).find((item) => item.run_id === runId);
|
||||
if (!run) return undefined;
|
||||
const assistantMessages = run.messages.filter((message) => message.role === 'assistant' && message.content.trim());
|
||||
return lastItem(assistantMessages)?.content.trim();
|
||||
}
|
||||
|
||||
function resultSummaryForEvent(task: BackendTask, event: ProcessEvent): string | undefined {
|
||||
return firstString(assistantResultForRun(task, event.run_id), summaryForEvent(event));
|
||||
}
|
||||
|
||||
function fallbackResultSummary(task: BackendTask): string | undefined {
|
||||
return firstString(assistantResultForRun(task, lastItem(task.run_ids)), resultSummary(task));
|
||||
}
|
||||
|
||||
function buildRunMap(processRuns: ProcessRun[]): Map<string, ProcessRun> {
|
||||
const map = new Map<string, ProcessRun>();
|
||||
for (const run of processRuns) {
|
||||
map.set(run.run_id, run);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function lastItem<T>(items: T[]): T | null {
|
||||
return items.length > 0 ? items[items.length - 1] : null;
|
||||
}
|
||||
|
||||
function compareCardsByCreatedAt(
|
||||
a: { card: TaskTimelineCard; index: number },
|
||||
b: { card: TaskTimelineCard; index: number },
|
||||
): number {
|
||||
const aTime = toTime(a.card.createdAt);
|
||||
const bTime = toTime(b.card.createdAt);
|
||||
|
||||
if (aTime === null && bTime === null) {
|
||||
return a.index - b.index;
|
||||
}
|
||||
if (aTime === null) {
|
||||
return 1;
|
||||
}
|
||||
if (bTime === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return aTime - bTime || a.index - b.index;
|
||||
}
|
||||
|
||||
type AcceptanceEventIdentity = {
|
||||
runId: string | null;
|
||||
acceptanceType: string | null;
|
||||
};
|
||||
|
||||
function isCoveredByAcceptanceEvent(
|
||||
feedback: Record<string, unknown>,
|
||||
acceptanceEvents: AcceptanceEventIdentity[],
|
||||
): boolean {
|
||||
const feedbackType = acceptanceTypeFromRecord(feedback);
|
||||
if (!feedbackType) return false;
|
||||
|
||||
const feedbackRunId = firstString(feedback.run_id) ?? null;
|
||||
const matchingTypeEvents = acceptanceEvents.filter((event) => event.acceptanceType === feedbackType);
|
||||
|
||||
if (feedbackRunId) {
|
||||
return (
|
||||
matchingTypeEvents.some((event) => event.runId === feedbackRunId) ||
|
||||
(matchingTypeEvents.length === 1 && !matchingTypeEvents[0].runId)
|
||||
);
|
||||
}
|
||||
|
||||
return matchingTypeEvents.length === 1;
|
||||
}
|
||||
|
||||
function cardTime(card: TaskTimelineCard): number {
|
||||
return toTime(card.createdAt) ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function cardComment(card: TaskTimelineCard): string | undefined {
|
||||
return firstString(card.details?.comment, card.summary);
|
||||
}
|
||||
|
||||
function toolCallKeyFromEvent(event: ProcessEvent): string | null {
|
||||
const toolCallId = firstString(event.metadata?.tool_call_id);
|
||||
if (toolCallId) return `${event.run_id}:${toolCallId}`;
|
||||
|
||||
const toolName = firstString(event.metadata?.tool_name, event.actor_name, event.actor_id);
|
||||
if (toolName) return `${event.run_id}:${toolName}`;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildToolResultStatusByCall(processEvents: ProcessEvent[]): Map<string, string> {
|
||||
const statuses = new Map<string, string>();
|
||||
for (const event of processEvents) {
|
||||
if (cardTypeForEvent(event) !== 'tool_result') continue;
|
||||
const key = toolCallKeyFromEvent(event);
|
||||
if (!key) continue;
|
||||
statuses.set(key, event.status || 'done');
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
function buildResultHistoryCard(task: BackendTask, resultCards: TaskTimelineCard[], acceptanceCards: TaskTimelineCard[]): TaskTimelineCard {
|
||||
const versions = resultCards.map((resultCard) => {
|
||||
const acceptanceCard = acceptanceCards
|
||||
.filter((card) => card.runId === resultCard.runId)
|
||||
.sort((a, b) => cardTime(a) - cardTime(b))
|
||||
.at(-1);
|
||||
return {
|
||||
runId: resultCard.runId ?? null,
|
||||
result: resultCard.summary ?? '',
|
||||
createdAt: resultCard.createdAt,
|
||||
status: acceptanceCard?.status ?? resultCard.status ?? null,
|
||||
acceptanceType: acceptanceCard?.status ?? null,
|
||||
comment: acceptanceCard ? cardComment(acceptanceCard) ?? '' : '',
|
||||
acceptedAt: acceptanceCard?.createdAt ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: `${task.task_id}:result-history`,
|
||||
taskId: task.task_id,
|
||||
type: 'result_history',
|
||||
title: titleForCard('result_history'),
|
||||
summary: `${resultCards.length} 历史结果版本`,
|
||||
createdAt: resultCards[0]?.createdAt ?? task.created_at,
|
||||
details: { versions },
|
||||
};
|
||||
}
|
||||
|
||||
function collapseHistoricalResults(task: BackendTask, cards: TaskTimelineCard[]): TaskTimelineCard[] {
|
||||
const resultCards = cards.filter((card) => card.type === 'result');
|
||||
if (resultCards.length <= 1) return cards;
|
||||
|
||||
const finalAcceptedRunId = firstString(task.metadata?.final_accepted_run_id);
|
||||
const latestResult =
|
||||
(finalAcceptedRunId ? resultCards.find((card) => card.runId === finalAcceptedRunId) : undefined) ??
|
||||
[...resultCards].sort((a, b) => cardTime(a) - cardTime(b)).at(-1);
|
||||
if (!latestResult) return cards;
|
||||
|
||||
const oldResults = resultCards
|
||||
.filter((card) => card.id !== latestResult.id)
|
||||
.sort((a, b) => cardTime(a) - cardTime(b));
|
||||
if (oldResults.length === 0) return cards;
|
||||
|
||||
const oldRunIds = new Set(oldResults.map((card) => card.runId).filter(Boolean));
|
||||
const oldAcceptances = cards
|
||||
.filter((card) => card.type === 'acceptance' && oldRunIds.has(card.runId))
|
||||
.sort((a, b) => cardTime(a) - cardTime(b));
|
||||
const foldedIds = new Set([...oldResults, ...oldAcceptances].map((card) => card.id));
|
||||
const historyCard = buildResultHistoryCard(task, oldResults, oldAcceptances);
|
||||
const firstOldResultIndex = cards.findIndex((card) => card.id === oldResults[0].id);
|
||||
const output: TaskTimelineCard[] = [];
|
||||
|
||||
for (let index = 0; index < cards.length; index += 1) {
|
||||
if (index === firstOldResultIndex) {
|
||||
output.push(historyCard);
|
||||
}
|
||||
if (!foldedIds.has(cards[index].id)) {
|
||||
output.push(cards[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): TaskTimelineCard[] {
|
||||
const { task } = input;
|
||||
const processRuns = input.processRuns ?? task.process_runs ?? [];
|
||||
const processEvents = input.processEvents ?? task.process_events ?? [];
|
||||
const processArtifacts = input.processArtifacts ?? task.process_artifacts ?? [];
|
||||
const runsById = buildRunMap(processRuns);
|
||||
const toolResultStatusByCall = buildToolResultStatusByCall(processEvents);
|
||||
const runsWithProgressEvents = new Set<string>();
|
||||
const acceptanceEvents: AcceptanceEventIdentity[] = [];
|
||||
let hasResultEventCard = false;
|
||||
const cards: TaskTimelineCard[] = [
|
||||
{
|
||||
id: `${task.task_id}:created`,
|
||||
taskId: task.task_id,
|
||||
type: 'task_created',
|
||||
title: titleForCard('task_created'),
|
||||
summary: firstString(task.short_title, task.description, task.goal),
|
||||
actorName: task.creator,
|
||||
status: task.status,
|
||||
createdAt: task.created_at,
|
||||
details: task.metadata,
|
||||
},
|
||||
];
|
||||
|
||||
for (const event of processEvents) {
|
||||
const type = cardTypeForEvent(event);
|
||||
if (!type) continue;
|
||||
if (type === 'agent_progress') {
|
||||
runsWithProgressEvents.add(event.run_id);
|
||||
}
|
||||
if (type === 'result') {
|
||||
hasResultEventCard = true;
|
||||
}
|
||||
if (type === 'acceptance') {
|
||||
acceptanceEvents.push({
|
||||
runId: firstString(event.run_id) ?? null,
|
||||
acceptanceType: acceptanceTypeFromRecord(event.metadata),
|
||||
});
|
||||
}
|
||||
|
||||
cards.push({
|
||||
id: event.event_id,
|
||||
taskId: task.task_id,
|
||||
runId: event.run_id,
|
||||
parentRunId: event.parent_run_id,
|
||||
type,
|
||||
title: titleForCard(type, event.actor_name),
|
||||
summary: type === 'result' ? resultSummaryForEvent(task, event) : summaryForEvent(event),
|
||||
actorName: event.actor_name,
|
||||
status:
|
||||
type === 'tool_call'
|
||||
? toolResultStatusByCall.get(toolCallKeyFromEvent(event) ?? '') ?? event.status
|
||||
: event.status,
|
||||
createdAt: event.created_at,
|
||||
details: detailsForEvent(event),
|
||||
});
|
||||
}
|
||||
|
||||
for (const run of processRuns) {
|
||||
if (!run.parent_run_id) continue;
|
||||
if (runsWithProgressEvents.has(run.run_id)) continue;
|
||||
|
||||
cards.push({
|
||||
id: `${run.run_id}:fallback-progress`,
|
||||
taskId: task.task_id,
|
||||
runId: run.run_id,
|
||||
parentRunId: run.parent_run_id,
|
||||
type: 'agent_progress',
|
||||
title: titleForCard('agent_progress', run.actor_name),
|
||||
summary: firstString(run.summary, run.title),
|
||||
actorName: run.actor_name,
|
||||
status: run.status,
|
||||
createdAt: run.started_at,
|
||||
details: run.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
for (const artifact of processArtifacts) {
|
||||
const run = runsById.get(artifact.run_id);
|
||||
cards.push({
|
||||
id: artifact.artifact_id,
|
||||
taskId: task.task_id,
|
||||
runId: artifact.run_id,
|
||||
parentRunId: run?.parent_run_id,
|
||||
type: 'artifact',
|
||||
title: titleForCard('artifact'),
|
||||
summary: firstString(artifact.title),
|
||||
actorName: artifact.actor_name,
|
||||
createdAt: artifact.created_at,
|
||||
relatedArtifactIds: [artifact.artifact_id],
|
||||
details: {
|
||||
...(artifact.metadata ?? {}),
|
||||
artifact_type: artifact.artifact_type,
|
||||
title: artifact.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (RESULT_STATUSES.has(task.status) && !hasResultEventCard) {
|
||||
cards.push({
|
||||
id: `${task.task_id}:result`,
|
||||
taskId: task.task_id,
|
||||
runId: lastItem(task.run_ids),
|
||||
type: 'result',
|
||||
title: titleForCard('result'),
|
||||
summary: fallbackResultSummary(task),
|
||||
status: task.status,
|
||||
createdAt: task.closed_at ?? task.updated_at ?? task.created_at,
|
||||
details: task.validation_result ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (let index = 0; index < task.feedback.length; index += 1) {
|
||||
const feedback = task.feedback[index];
|
||||
const runId = firstString(feedback.run_id) ?? null;
|
||||
const createdAt = feedbackCreatedAt(feedback, task);
|
||||
if (isCoveredByAcceptanceEvent(feedback, acceptanceEvents)) continue;
|
||||
|
||||
cards.push({
|
||||
id: `${task.task_id}:acceptance:${index}`,
|
||||
taskId: task.task_id,
|
||||
runId,
|
||||
type: 'acceptance',
|
||||
title: titleForCard('acceptance'),
|
||||
summary: feedbackSummary(feedback),
|
||||
status: firstString(feedback.acceptance_type),
|
||||
createdAt,
|
||||
details: feedback,
|
||||
});
|
||||
}
|
||||
|
||||
const sortedCards = cards
|
||||
.map((card, index) => ({ card, index }))
|
||||
.sort(compareCardsByCreatedAt)
|
||||
.map(({ card }) => card);
|
||||
|
||||
return collapseHistoricalResults(task, sortedCards);
|
||||
}
|
||||
22
app-instance/frontend/lib/text-wrapping.test.ts
Normal file
22
app-instance/frontend/lib/text-wrapping.test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { containedJsonTextClass, containedLongTextClass } from './text-wrapping';
|
||||
|
||||
const globalsCss = readFileSync(join(process.cwd(), 'app/globals.css'), 'utf8');
|
||||
|
||||
describe('contained long text classes', () => {
|
||||
it('keeps long plain text inside its container', () => {
|
||||
expect(containedLongTextClass).toBe('contained-long-text');
|
||||
expect(globalsCss).toContain('.contained-long-text');
|
||||
expect(globalsCss).toContain('overflow-wrap: anywhere');
|
||||
expect(globalsCss).toContain('word-break: break-word');
|
||||
});
|
||||
|
||||
it('keeps long JSON and monospace output inside its container', () => {
|
||||
expect(containedJsonTextClass).toBe('contained-json-text');
|
||||
expect(globalsCss).toContain('.contained-json-text');
|
||||
expect(globalsCss).toContain('white-space: pre-wrap');
|
||||
});
|
||||
});
|
||||
5
app-instance/frontend/lib/text-wrapping.ts
Normal file
5
app-instance/frontend/lib/text-wrapping.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const containedLongTextClass = 'contained-long-text';
|
||||
|
||||
export const containedPreservedLongTextClass = 'contained-preserved-long-text';
|
||||
|
||||
export const containedJsonTextClass = 'contained-json-text';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user