@qiov/tsmai-api-openclaw 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +306 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +38 -0
- package/src/channel.ts +212 -0
- package/src/config-schema.ts +10 -0
- package/src/http-server.ts +456 -0
- package/src/onboarding.ts +138 -0
- package/src/runtime.ts +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# tsmai-api-openclaw
|
|
2
|
+
|
|
3
|
+
OpenClaw 的 HTTP API 频道插件,通过 REST API 暴露 OpenClaw 上的 Agent 能力。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- 通过标准 HTTP 接口调用 OpenClaw 上任意已注册的 Agent
|
|
8
|
+
- 支持同步调用(`/api/chat`)和 SSE 流式调用(`/api/chat/stream`)
|
|
9
|
+
- 支持多轮对话(通过 `conversationId` 维持上下文)
|
|
10
|
+
- 支持指定目标 Agent 或使用默认 main Agent
|
|
11
|
+
- 可选 API Key 认证
|
|
12
|
+
- 跨域(CORS)支持
|
|
13
|
+
|
|
14
|
+
## 目录结构
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
tsmai-api-openclaw/
|
|
18
|
+
├── index.ts # 插件入口,注册 channel
|
|
19
|
+
├── package.json # 包配置 & OpenClaw 插件声明
|
|
20
|
+
├── openclaw.plugin.json # OpenClaw 插件元数据
|
|
21
|
+
├── README.md
|
|
22
|
+
└── src/
|
|
23
|
+
├── channel.ts # ChannelPlugin 定义(配置、状态、网关启动)
|
|
24
|
+
├── config-schema.ts # Zod 配置 schema
|
|
25
|
+
├── http-server.ts # HTTP 服务核心(路由、同步/流式处理)
|
|
26
|
+
├── onboarding.ts # 配置向导(openclaw onboard 时交互式配置)
|
|
27
|
+
└── runtime.ts # PluginRuntime 管理
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## 安装
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
openclaw plugins install @qiov/tsmai-api-openclaw
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
安装后插件会自动注册到 `openclaw.json`,只需添加 channel 配置即可:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# 编辑 ~/.openclaw/openclaw.json,在 channels 中添加:
|
|
40
|
+
# "api": { "enabled": true, "port": 3100, "host": "0.0.0.0" }
|
|
41
|
+
|
|
42
|
+
# 重启 gateway
|
|
43
|
+
openclaw gateway restart
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 配置
|
|
47
|
+
|
|
48
|
+
在 `~/.openclaw/openclaw.json` 中配置:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"channels": {
|
|
53
|
+
"api": {
|
|
54
|
+
"enabled": true,
|
|
55
|
+
"port": 3100,
|
|
56
|
+
"host": "0.0.0.0",
|
|
57
|
+
"apiKey": ""
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"plugins": {
|
|
61
|
+
"entries": {
|
|
62
|
+
"tsmai-api-openclaw": {
|
|
63
|
+
"enabled": true
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
71
|
+
|--------|------|--------|------|
|
|
72
|
+
| `enabled` | boolean | `true` | 是否启用 |
|
|
73
|
+
| `port` | number | `3100` | HTTP 服务监听端口 |
|
|
74
|
+
| `host` | string | `0.0.0.0` | HTTP 服务监听地址 |
|
|
75
|
+
| `apiKey` | string | `""` | API Key(为空则不启用认证) |
|
|
76
|
+
|
|
77
|
+
也支持通过环境变量配置:`API_OPENCLAW_PORT`、`API_OPENCLAW_HOST`、`API_OPENCLAW_API_KEY`。
|
|
78
|
+
|
|
79
|
+
## API 接口
|
|
80
|
+
|
|
81
|
+
### 健康检查
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
GET /health
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**响应**:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{ "ok": true, "status": "running" }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
### 同步调用
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
POST /api/chat
|
|
99
|
+
Content-Type: application/json
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
等待 Agent 处理完成后返回完整回复,适用于简单快速的查询场景。
|
|
103
|
+
|
|
104
|
+
**请求体**:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"prompt": "分析一下最近的服务器告警",
|
|
109
|
+
"agent": "ops-lead",
|
|
110
|
+
"conversationId": "session-123"
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
| 字段 | 必填 | 类型 | 说明 |
|
|
115
|
+
|------|------|------|------|
|
|
116
|
+
| `prompt` | 是 | string | 用户提示词 |
|
|
117
|
+
| `agent` | 否 | string | 目标 Agent ID,不传则使用 main agent |
|
|
118
|
+
| `conversationId` | 否 | string | 会话 ID,用于多轮对话,不传自动生成 |
|
|
119
|
+
|
|
120
|
+
**成功响应**:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"ok": true,
|
|
125
|
+
"text": "根据分析,最近的告警主要集中在...",
|
|
126
|
+
"conversationId": "session-123"
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**错误响应**:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"ok": false,
|
|
135
|
+
"error": "Missing required field: prompt"
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**curl 示例**:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# 使用默认 main agent
|
|
143
|
+
curl -X POST http://localhost:3100/api/chat \
|
|
144
|
+
-H 'Content-Type: application/json' \
|
|
145
|
+
-d '{"prompt": "你好"}'
|
|
146
|
+
|
|
147
|
+
# 指定 agent
|
|
148
|
+
curl -X POST http://localhost:3100/api/chat \
|
|
149
|
+
-H 'Content-Type: application/json' \
|
|
150
|
+
-d '{"prompt": "分析告警", "agent": "ops-lead"}'
|
|
151
|
+
|
|
152
|
+
# 多轮对话
|
|
153
|
+
curl -X POST http://localhost:3100/api/chat \
|
|
154
|
+
-H 'Content-Type: application/json' \
|
|
155
|
+
-d '{"prompt": "继续分析", "agent": "ops-lead", "conversationId": "session-123"}'
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
### SSE 流式调用
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
POST /api/chat/stream
|
|
164
|
+
Content-Type: application/json
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
以 Server-Sent Events (SSE) 格式实时推送 Agent 处理进度,适用于复杂任务、长时间处理的场景。
|
|
168
|
+
|
|
169
|
+
**请求体**:与 `/api/chat` 完全一致。
|
|
170
|
+
|
|
171
|
+
**响应**:`Content-Type: text/event-stream`
|
|
172
|
+
|
|
173
|
+
#### SSE 事件类型
|
|
174
|
+
|
|
175
|
+
| 事件 | 触发时机 | data 字段 |
|
|
176
|
+
|------|---------|----------|
|
|
177
|
+
| `start` | 请求被接受,开始处理 | `{ "conversationId": "...", "agent": "..." }` |
|
|
178
|
+
| `chunk` | AI 生成了一段增量文本 | `{ "text": "增量文本", "index": 0 }` |
|
|
179
|
+
| `tool` | 工具/技能调用完成 | `{ "name": "tool-name", "status": "done" }` |
|
|
180
|
+
| `error` | 处理出错 | `{ "error": "错误信息" }` |
|
|
181
|
+
| `done` | 处理完成 | `{ "text": "完整文本", "conversationId": "..." }` |
|
|
182
|
+
|
|
183
|
+
**响应示例**:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
event: start
|
|
187
|
+
data: {"conversationId":"session-123","agent":"ops-lead"}
|
|
188
|
+
|
|
189
|
+
event: chunk
|
|
190
|
+
data: {"text":"我是","index":0}
|
|
191
|
+
|
|
192
|
+
event: chunk
|
|
193
|
+
data: {"text":" IT 运维","index":1}
|
|
194
|
+
|
|
195
|
+
event: chunk
|
|
196
|
+
data: {"text":"团队的运维主管","index":2}
|
|
197
|
+
|
|
198
|
+
event: done
|
|
199
|
+
data: {"text":"我是 IT 运维团队的运维主管...","conversationId":"session-123"}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**curl 示例**:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
curl -N -X POST http://localhost:3100/api/chat/stream \
|
|
206
|
+
-H 'Content-Type: application/json' \
|
|
207
|
+
-d '{"prompt": "用3段话介绍你自己", "agent": "ops-lead"}'
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**JavaScript 客户端示例**:
|
|
211
|
+
|
|
212
|
+
```javascript
|
|
213
|
+
async function chatStream(prompt, agent) {
|
|
214
|
+
const resp = await fetch('http://localhost:3100/api/chat/stream', {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: { 'Content-Type': 'application/json' },
|
|
217
|
+
body: JSON.stringify({ prompt, agent }),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const reader = resp.body.getReader();
|
|
221
|
+
const decoder = new TextDecoder();
|
|
222
|
+
let buffer = '';
|
|
223
|
+
|
|
224
|
+
while (true) {
|
|
225
|
+
const { done, value } = await reader.read();
|
|
226
|
+
if (done) break;
|
|
227
|
+
|
|
228
|
+
buffer += decoder.decode(value, { stream: true });
|
|
229
|
+
const lines = buffer.split('\n');
|
|
230
|
+
buffer = lines.pop() || '';
|
|
231
|
+
|
|
232
|
+
let eventType = '';
|
|
233
|
+
for (const line of lines) {
|
|
234
|
+
if (line.startsWith('event: ')) {
|
|
235
|
+
eventType = line.slice(7);
|
|
236
|
+
} else if (line.startsWith('data: ')) {
|
|
237
|
+
const data = JSON.parse(line.slice(6));
|
|
238
|
+
switch (eventType) {
|
|
239
|
+
case 'start':
|
|
240
|
+
console.log('Started:', data.agent);
|
|
241
|
+
break;
|
|
242
|
+
case 'chunk':
|
|
243
|
+
process.stdout.write(data.text);
|
|
244
|
+
break;
|
|
245
|
+
case 'done':
|
|
246
|
+
console.log('\n\nDone. Full text:', data.text.length, 'chars');
|
|
247
|
+
break;
|
|
248
|
+
case 'error':
|
|
249
|
+
console.error('Error:', data.error);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
chatStream('介绍你自己', 'ops-lead');
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
### 认证
|
|
263
|
+
|
|
264
|
+
如果配置了 `apiKey`,所有 API 请求需要携带认证信息,支持两种方式:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
# 方式一:X-API-Key header
|
|
268
|
+
curl -H 'X-API-Key: your-api-key' ...
|
|
269
|
+
|
|
270
|
+
# 方式二:Bearer token
|
|
271
|
+
curl -H 'Authorization: Bearer your-api-key' ...
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
未认证请求返回:
|
|
275
|
+
|
|
276
|
+
```json
|
|
277
|
+
{ "ok": false, "error": "Unauthorized: invalid API key" }
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Agent 路由
|
|
281
|
+
|
|
282
|
+
- **指定 `agent` 参数**:直接路由到对应的 Agent(需要在 `openclaw.json` 的 `agents.list` 中已注册)
|
|
283
|
+
- **不指定 `agent`**:使用 `main` Agent(OpenClaw 默认 Agent)
|
|
284
|
+
|
|
285
|
+
每个 Agent 可以独立配置不同的模型,例如:
|
|
286
|
+
|
|
287
|
+
```json
|
|
288
|
+
{
|
|
289
|
+
"agents": {
|
|
290
|
+
"list": [
|
|
291
|
+
{ "id": "ops-lead", "model": "tione/kimi-k2.5" },
|
|
292
|
+
{ "id": "ops-l1", "model": "minimax/MiniMax-M2.5" }
|
|
293
|
+
]
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## 错误码
|
|
299
|
+
|
|
300
|
+
| HTTP 状态码 | 说明 |
|
|
301
|
+
|-------------|------|
|
|
302
|
+
| 200 | 成功 |
|
|
303
|
+
| 400 | 请求参数错误(JSON 格式错误、缺少 prompt) |
|
|
304
|
+
| 401 | 认证失败(API Key 无效) |
|
|
305
|
+
| 404 | 路由不存在 |
|
|
306
|
+
| 500 | 服务端内部错误 |
|
package/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
+
import { apiPlugin } from "./src/channel.js";
|
|
4
|
+
import { setApiRuntime } from "./src/runtime.js";
|
|
5
|
+
|
|
6
|
+
const plugin = {
|
|
7
|
+
id: "tsmai-api-openclaw",
|
|
8
|
+
name: "HTTP API",
|
|
9
|
+
description: "HTTP API channel plugin — exposes OpenClaw agent capabilities via REST API",
|
|
10
|
+
configSchema: emptyPluginConfigSchema(),
|
|
11
|
+
register(api: OpenClawPluginApi) {
|
|
12
|
+
setApiRuntime(api.runtime);
|
|
13
|
+
api.registerChannel({ plugin: apiPlugin });
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qiov/tsmai-api-openclaw",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "HTTP API channel plugin for OpenClaw — exposes agent capabilities via REST API (sync + SSE streaming)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.ts",
|
|
8
|
+
"src",
|
|
9
|
+
"openclaw.plugin.json",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"openclaw",
|
|
14
|
+
"openclaw-plugin",
|
|
15
|
+
"channel",
|
|
16
|
+
"http-api",
|
|
17
|
+
"sse",
|
|
18
|
+
"agent"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"zod": "^3.22.4"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {},
|
|
25
|
+
"openclaw": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./index.ts"
|
|
28
|
+
],
|
|
29
|
+
"channel": {
|
|
30
|
+
"id": "api",
|
|
31
|
+
"label": "HTTP API",
|
|
32
|
+
"selectionLabel": "HTTP API Channel",
|
|
33
|
+
"docsPath": "/channels/api",
|
|
34
|
+
"blurb": "HTTP API channel — exposes OpenClaw agent capabilities via REST API.",
|
|
35
|
+
"order": 999
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChannelPlugin,
|
|
3
|
+
type ClawdbotConfig,
|
|
4
|
+
DEFAULT_ACCOUNT_ID,
|
|
5
|
+
} from "openclaw/plugin-sdk";
|
|
6
|
+
import { apiOnboardingAdapter } from "./onboarding.js";
|
|
7
|
+
|
|
8
|
+
export type ApiChannelConfig = {
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
port?: number;
|
|
11
|
+
host?: string;
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ResolvedApiAccount = {
|
|
16
|
+
accountId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
configured: boolean;
|
|
20
|
+
port: number;
|
|
21
|
+
host: string;
|
|
22
|
+
apiKey: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const DEFAULT_PORT = 3100;
|
|
26
|
+
const DEFAULT_HOST = "0.0.0.0";
|
|
27
|
+
|
|
28
|
+
function resolveCredentials(channelCfg?: ApiChannelConfig): {
|
|
29
|
+
port: number;
|
|
30
|
+
host: string;
|
|
31
|
+
apiKey: string;
|
|
32
|
+
} | null {
|
|
33
|
+
const port = channelCfg?.port ?? (process.env.API_OPENCLAW_PORT ? parseInt(process.env.API_OPENCLAW_PORT, 10) : DEFAULT_PORT);
|
|
34
|
+
const host = channelCfg?.host?.trim() || process.env.API_OPENCLAW_HOST || DEFAULT_HOST;
|
|
35
|
+
const apiKey = channelCfg?.apiKey?.trim() || process.env.API_OPENCLAW_API_KEY || "";
|
|
36
|
+
|
|
37
|
+
return { port, host, apiKey };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveAccount(cfg: ClawdbotConfig, accountId?: string): ResolvedApiAccount {
|
|
41
|
+
const channelCfg = cfg.channels?.["api"] as ApiChannelConfig | undefined;
|
|
42
|
+
const enabled = channelCfg?.enabled !== false;
|
|
43
|
+
const creds = resolveCredentials(channelCfg);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
accountId: accountId?.trim() || DEFAULT_ACCOUNT_ID,
|
|
47
|
+
name: "HTTP API",
|
|
48
|
+
enabled,
|
|
49
|
+
configured: Boolean(creds),
|
|
50
|
+
port: creds?.port ?? DEFAULT_PORT,
|
|
51
|
+
host: creds?.host ?? DEFAULT_HOST,
|
|
52
|
+
apiKey: creds?.apiKey ?? "",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const apiPlugin: ChannelPlugin<ResolvedApiAccount> = {
|
|
57
|
+
id: "api",
|
|
58
|
+
meta: {
|
|
59
|
+
id: "api",
|
|
60
|
+
label: "HTTP API",
|
|
61
|
+
selectionLabel: "HTTP API Channel",
|
|
62
|
+
docsPath: "/channels/api",
|
|
63
|
+
blurb: "HTTP API channel — exposes OpenClaw agent capabilities via REST API.",
|
|
64
|
+
order: 999,
|
|
65
|
+
},
|
|
66
|
+
onboarding: apiOnboardingAdapter,
|
|
67
|
+
capabilities: {
|
|
68
|
+
chatTypes: ["direct"],
|
|
69
|
+
polls: false,
|
|
70
|
+
reactions: false,
|
|
71
|
+
threads: false,
|
|
72
|
+
media: false,
|
|
73
|
+
blockStreaming: false,
|
|
74
|
+
},
|
|
75
|
+
reload: { configPrefixes: ["channels.api"] },
|
|
76
|
+
configSchema: {
|
|
77
|
+
schema: {
|
|
78
|
+
type: "object",
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
properties: {
|
|
81
|
+
enabled: { type: "boolean" },
|
|
82
|
+
port: { type: "number" },
|
|
83
|
+
host: { type: "string" },
|
|
84
|
+
apiKey: { type: "string" },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
config: {
|
|
89
|
+
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
90
|
+
resolveAccount: (cfg) => resolveAccount(cfg),
|
|
91
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
92
|
+
setAccountEnabled: ({ cfg, enabled }) => ({
|
|
93
|
+
...cfg,
|
|
94
|
+
channels: {
|
|
95
|
+
...cfg.channels,
|
|
96
|
+
api: {
|
|
97
|
+
...cfg.channels?.["api"],
|
|
98
|
+
enabled,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
deleteAccount: ({ cfg }) => {
|
|
103
|
+
const next = { ...cfg } as ClawdbotConfig;
|
|
104
|
+
const nextChannels = { ...cfg.channels };
|
|
105
|
+
delete (nextChannels as Record<string, unknown>)["api"];
|
|
106
|
+
if (Object.keys(nextChannels).length > 0) {
|
|
107
|
+
next.channels = nextChannels;
|
|
108
|
+
} else {
|
|
109
|
+
delete next.channels;
|
|
110
|
+
}
|
|
111
|
+
return next;
|
|
112
|
+
},
|
|
113
|
+
isConfigured: (_account, _cfg) => true, // HTTP API is always configurable
|
|
114
|
+
describeAccount: (account) => ({
|
|
115
|
+
accountId: account.accountId,
|
|
116
|
+
name: account.name,
|
|
117
|
+
enabled: account.enabled,
|
|
118
|
+
configured: account.configured,
|
|
119
|
+
port: account.port,
|
|
120
|
+
host: account.host,
|
|
121
|
+
}),
|
|
122
|
+
resolveAllowFrom: () => [],
|
|
123
|
+
formatAllowFrom: ({ allowFrom }) => allowFrom,
|
|
124
|
+
},
|
|
125
|
+
setup: {
|
|
126
|
+
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
127
|
+
applyAccountConfig: ({ cfg }) => ({
|
|
128
|
+
...cfg,
|
|
129
|
+
channels: {
|
|
130
|
+
...cfg.channels,
|
|
131
|
+
api: {
|
|
132
|
+
...cfg.channels?.["api"],
|
|
133
|
+
enabled: true,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
138
|
+
status: {
|
|
139
|
+
defaultRuntime: {
|
|
140
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
141
|
+
running: false,
|
|
142
|
+
lastStartAt: null,
|
|
143
|
+
lastStopAt: null,
|
|
144
|
+
lastError: null,
|
|
145
|
+
},
|
|
146
|
+
collectStatusIssues: () => [],
|
|
147
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
148
|
+
configured: snapshot.configured ?? false,
|
|
149
|
+
port: snapshot.port ?? null,
|
|
150
|
+
host: snapshot.host ?? null,
|
|
151
|
+
running: snapshot.running ?? false,
|
|
152
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
153
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
154
|
+
lastError: snapshot.lastError ?? null,
|
|
155
|
+
lastEventAt: snapshot.lastEventAt ?? null,
|
|
156
|
+
lastInboundAt: snapshot.lastInboundAt ?? null,
|
|
157
|
+
}),
|
|
158
|
+
probeAccount: async ({ cfg }) => {
|
|
159
|
+
const account = resolveAccount(cfg);
|
|
160
|
+
const start = Date.now();
|
|
161
|
+
return {
|
|
162
|
+
ok: account.configured && account.enabled,
|
|
163
|
+
elapsedMs: Date.now() - start,
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
167
|
+
accountId: account.accountId,
|
|
168
|
+
name: account.name,
|
|
169
|
+
enabled: account.enabled,
|
|
170
|
+
configured: account.configured,
|
|
171
|
+
port: account.port,
|
|
172
|
+
host: account.host,
|
|
173
|
+
running: runtime?.running ?? false,
|
|
174
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
175
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
176
|
+
lastError: runtime?.lastError ?? null,
|
|
177
|
+
lastEventAt: runtime?.lastEventAt ?? null,
|
|
178
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
179
|
+
probe,
|
|
180
|
+
}),
|
|
181
|
+
},
|
|
182
|
+
outbound: {
|
|
183
|
+
deliveryMode: "direct",
|
|
184
|
+
sendText: async (_ctx) => {
|
|
185
|
+
// HTTP API is request-response, outbound is handled within the request lifecycle
|
|
186
|
+
return { ok: true };
|
|
187
|
+
},
|
|
188
|
+
sendMedia: async (_ctx) => {
|
|
189
|
+
return { ok: true };
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
gateway: {
|
|
193
|
+
startAccount: async (ctx) => {
|
|
194
|
+
const account = ctx.account;
|
|
195
|
+
ctx.setStatus({ accountId: account.accountId, port: account.port, host: account.host, running: false });
|
|
196
|
+
ctx.log?.info(`[api] Starting HTTP API server on ${account.host}:${account.port}`);
|
|
197
|
+
|
|
198
|
+
const { startHttpServer } = await import("./http-server.js");
|
|
199
|
+
return startHttpServer({
|
|
200
|
+
port: account.port,
|
|
201
|
+
host: account.host,
|
|
202
|
+
apiKey: account.apiKey,
|
|
203
|
+
abortSignal: ctx.abortSignal,
|
|
204
|
+
log: ctx.log,
|
|
205
|
+
cfg: ctx.cfg,
|
|
206
|
+
onStatusChange: (status) => {
|
|
207
|
+
ctx.setStatus({ ...status });
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const ApiConfigSchema = z.object({
|
|
4
|
+
enabled: z.boolean().optional(),
|
|
5
|
+
port: z.number().optional(),
|
|
6
|
+
host: z.string().optional(),
|
|
7
|
+
apiKey: z.string().optional(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export type ApiConfig = z.infer<typeof ApiConfigSchema>;
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Server for the API channel plugin.
|
|
3
|
+
*
|
|
4
|
+
* Exposes two REST endpoints:
|
|
5
|
+
*
|
|
6
|
+
* 1. POST /api/chat — Synchronous (returns full reply)
|
|
7
|
+
*
|
|
8
|
+
* Request body (JSON):
|
|
9
|
+
* { "prompt": string, "agent": string?, "conversationId": string? }
|
|
10
|
+
*
|
|
11
|
+
* Response body (JSON):
|
|
12
|
+
* { "ok": true, "text": "...", "conversationId": "..." }
|
|
13
|
+
*
|
|
14
|
+
* 2. POST /api/chat/stream — SSE stream (real-time progress)
|
|
15
|
+
*
|
|
16
|
+
* Request body: same as /api/chat
|
|
17
|
+
*
|
|
18
|
+
* Response: text/event-stream with events:
|
|
19
|
+
* event: start — { conversationId, agent }
|
|
20
|
+
* event: chunk — { text, index }
|
|
21
|
+
* event: tool — { name, status }
|
|
22
|
+
* event: error — { error }
|
|
23
|
+
* event: done — { text, conversationId }
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
27
|
+
import type { PluginLogger, ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
28
|
+
import { getApiRuntime } from "./runtime.js";
|
|
29
|
+
|
|
30
|
+
export type HttpServerParams = {
|
|
31
|
+
port: number;
|
|
32
|
+
host: string;
|
|
33
|
+
apiKey?: string;
|
|
34
|
+
abortSignal?: AbortSignal;
|
|
35
|
+
log?: PluginLogger;
|
|
36
|
+
cfg?: ClawdbotConfig;
|
|
37
|
+
onStatusChange?: (status: { running: boolean; port?: number; lastEventAt?: number; lastInboundAt?: number }) => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function generateId(): string {
|
|
41
|
+
return `api-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read the full request body as a string.
|
|
46
|
+
*/
|
|
47
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const chunks: Buffer[] = [];
|
|
50
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
51
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
52
|
+
req.on("error", reject);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Send a JSON response.
|
|
58
|
+
*/
|
|
59
|
+
function sendJson(res: ServerResponse, statusCode: number, body: unknown): void {
|
|
60
|
+
const data = JSON.stringify(body);
|
|
61
|
+
res.writeHead(statusCode, {
|
|
62
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
63
|
+
"Content-Length": Buffer.byteLength(data),
|
|
64
|
+
});
|
|
65
|
+
res.end(data);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Strip OpenClaw internal protocol markers that may leak into plugin text.
|
|
70
|
+
*/
|
|
71
|
+
function stripProtocolTags(t: string): string {
|
|
72
|
+
return t.replace(/<\/?(?:final|block|tool|media)[^>]*>/gi, "").trimEnd();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve agent route — shared by sync and stream endpoints.
|
|
77
|
+
*/
|
|
78
|
+
function resolveRoute(
|
|
79
|
+
agentId: string | undefined,
|
|
80
|
+
conversationId: string,
|
|
81
|
+
log?: PluginLogger,
|
|
82
|
+
): { agentId: string; sessionKey: string; accountId: string; mainSessionKey: string } {
|
|
83
|
+
if (agentId) {
|
|
84
|
+
const normalizedAgentId = agentId.toLowerCase();
|
|
85
|
+
const normalizedConvId = conversationId.toLowerCase();
|
|
86
|
+
const sessionKey = `agent:${normalizedAgentId}:direct:${normalizedConvId}`;
|
|
87
|
+
const mainSessionKey = `agent:${normalizedAgentId}:main`;
|
|
88
|
+
log?.info(`[api] Direct agent route: agentId=${normalizedAgentId}, sessionKey=${sessionKey}`);
|
|
89
|
+
return { agentId: normalizedAgentId, sessionKey, mainSessionKey, accountId: "default" };
|
|
90
|
+
}
|
|
91
|
+
const normalizedConvId = conversationId.toLowerCase();
|
|
92
|
+
const sessionKey = `agent:main:direct:${normalizedConvId}`;
|
|
93
|
+
const mainSessionKey = "agent:main:main";
|
|
94
|
+
log?.info(`[api] Default main agent route: sessionKey=${sessionKey}`);
|
|
95
|
+
return { agentId: "main", sessionKey, mainSessionKey, accountId: "default" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build inbound context — shared by sync and stream endpoints.
|
|
100
|
+
*/
|
|
101
|
+
function buildInboundContext(
|
|
102
|
+
prompt: string,
|
|
103
|
+
route: { agentId: string; sessionKey: string; accountId: string },
|
|
104
|
+
cfg: ClawdbotConfig | undefined,
|
|
105
|
+
) {
|
|
106
|
+
const runtime = getApiRuntime();
|
|
107
|
+
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg ?? {});
|
|
108
|
+
|
|
109
|
+
const formattedBody = runtime.channel.reply.formatInboundEnvelope({
|
|
110
|
+
channel: "API",
|
|
111
|
+
from: "api-user",
|
|
112
|
+
timestamp: Date.now(),
|
|
113
|
+
body: prompt,
|
|
114
|
+
chatType: "direct",
|
|
115
|
+
sender: { id: "api-user", name: "API User" },
|
|
116
|
+
envelope: envelopeOptions,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const messageId = generateId();
|
|
120
|
+
|
|
121
|
+
return runtime.channel.reply.finalizeInboundContext({
|
|
122
|
+
Body: formattedBody,
|
|
123
|
+
RawBody: prompt,
|
|
124
|
+
CommandBody: prompt,
|
|
125
|
+
From: "api:api-user",
|
|
126
|
+
To: "api:bot",
|
|
127
|
+
SessionKey: route.sessionKey,
|
|
128
|
+
AccountId: route.accountId,
|
|
129
|
+
ChatType: "direct",
|
|
130
|
+
SenderId: "api-user",
|
|
131
|
+
SenderName: "API User",
|
|
132
|
+
Provider: "api",
|
|
133
|
+
Surface: "api-http",
|
|
134
|
+
MessageSid: messageId,
|
|
135
|
+
MessageSidFull: messageId,
|
|
136
|
+
Timestamp: Date.now(),
|
|
137
|
+
CommandAuthorized: true,
|
|
138
|
+
OriginatingChannel: "api",
|
|
139
|
+
OriginatingTo: "api:bot",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Process user prompt through OpenClaw's agent pipeline and collect the full reply.
|
|
145
|
+
*/
|
|
146
|
+
async function processPrompt(
|
|
147
|
+
prompt: string,
|
|
148
|
+
agentId: string | undefined,
|
|
149
|
+
conversationId: string,
|
|
150
|
+
cfg: ClawdbotConfig | undefined,
|
|
151
|
+
log?: PluginLogger,
|
|
152
|
+
): Promise<{ text: string; conversationId: string }> {
|
|
153
|
+
const runtime = getApiRuntime();
|
|
154
|
+
const route = resolveRoute(agentId, conversationId, log);
|
|
155
|
+
const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(cfg ?? {}, route.agentId);
|
|
156
|
+
const ctx = buildInboundContext(prompt, route, cfg);
|
|
157
|
+
|
|
158
|
+
let fullText = "";
|
|
159
|
+
|
|
160
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
161
|
+
ctx,
|
|
162
|
+
cfg: cfg ?? {},
|
|
163
|
+
replyOptions: {
|
|
164
|
+
disableBlockStreaming: true,
|
|
165
|
+
onPartialReply: async (payload: { text?: string }) => {
|
|
166
|
+
const rawText = payload.text || "";
|
|
167
|
+
if (rawText) {
|
|
168
|
+
fullText = stripProtocolTags(rawText);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
dispatcherOptions: {
|
|
173
|
+
responsePrefix: messagesConfig.responsePrefix,
|
|
174
|
+
deliver: async (payload: { text?: string }, info?: { kind?: string }) => {
|
|
175
|
+
const rawText = payload.text || "";
|
|
176
|
+
const kind = info?.kind;
|
|
177
|
+
const text = stripProtocolTags(rawText);
|
|
178
|
+
|
|
179
|
+
if (kind === "tool") return;
|
|
180
|
+
if (kind === "block" && text) {
|
|
181
|
+
fullText = fullText ? fullText + "\n" + text : text;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (kind === "final" || kind === undefined) {
|
|
185
|
+
if (text) fullText = text;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
onError: (err: Error) => {
|
|
189
|
+
log?.error(`[api] Reply error: ${err.message}`);
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return { text: fullText, conversationId };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Send a single SSE event to the response stream.
|
|
199
|
+
*/
|
|
200
|
+
function sendSSE(res: ServerResponse, event: string, data: unknown): void {
|
|
201
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Process user prompt with SSE streaming — pushes events in real-time.
|
|
206
|
+
*/
|
|
207
|
+
async function processPromptStream(
|
|
208
|
+
res: ServerResponse,
|
|
209
|
+
prompt: string,
|
|
210
|
+
agentId: string | undefined,
|
|
211
|
+
conversationId: string,
|
|
212
|
+
cfg: ClawdbotConfig | undefined,
|
|
213
|
+
log?: PluginLogger,
|
|
214
|
+
): Promise<void> {
|
|
215
|
+
const runtime = getApiRuntime();
|
|
216
|
+
const route = resolveRoute(agentId, conversationId, log);
|
|
217
|
+
const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(cfg ?? {}, route.agentId);
|
|
218
|
+
const ctx = buildInboundContext(prompt, route, cfg);
|
|
219
|
+
|
|
220
|
+
// Send start event
|
|
221
|
+
sendSSE(res, "start", { conversationId, agent: route.agentId });
|
|
222
|
+
|
|
223
|
+
let chunkIndex = 0;
|
|
224
|
+
let fullText = "";
|
|
225
|
+
let streamedCleanText = "";
|
|
226
|
+
|
|
227
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
228
|
+
ctx,
|
|
229
|
+
cfg: cfg ?? {},
|
|
230
|
+
replyOptions: {
|
|
231
|
+
disableBlockStreaming: false, // Enable streaming for SSE
|
|
232
|
+
onPartialReply: async (payload: { text?: string }) => {
|
|
233
|
+
const rawText = payload.text || "";
|
|
234
|
+
if (!rawText) return;
|
|
235
|
+
|
|
236
|
+
const cleanText = stripProtocolTags(rawText);
|
|
237
|
+
if (!cleanText) return;
|
|
238
|
+
|
|
239
|
+
// Compute delta (incremental text)
|
|
240
|
+
let delta = cleanText;
|
|
241
|
+
if (cleanText.startsWith(streamedCleanText)) {
|
|
242
|
+
delta = cleanText.slice(streamedCleanText.length);
|
|
243
|
+
}
|
|
244
|
+
if (!delta) return;
|
|
245
|
+
|
|
246
|
+
streamedCleanText = cleanText;
|
|
247
|
+
fullText = cleanText;
|
|
248
|
+
|
|
249
|
+
sendSSE(res, "chunk", { text: delta, index: chunkIndex });
|
|
250
|
+
chunkIndex++;
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
dispatcherOptions: {
|
|
254
|
+
responsePrefix: messagesConfig.responsePrefix,
|
|
255
|
+
deliver: async (payload: { text?: string }, info?: { kind?: string }) => {
|
|
256
|
+
const rawText = payload.text || "";
|
|
257
|
+
const kind = info?.kind;
|
|
258
|
+
const text = stripProtocolTags(rawText);
|
|
259
|
+
|
|
260
|
+
if (kind === "tool") {
|
|
261
|
+
// Extract tool name from payload if available
|
|
262
|
+
const toolName = (payload as any).toolName || (payload as any).name || "tool";
|
|
263
|
+
sendSSE(res, "tool", { name: toolName, status: "done" });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (kind === "block" && text) {
|
|
268
|
+
// Skip if already streamed via onPartialReply
|
|
269
|
+
if (streamedCleanText && streamedCleanText.includes(text.trim())) return;
|
|
270
|
+
|
|
271
|
+
sendSSE(res, "chunk", { text, index: chunkIndex });
|
|
272
|
+
chunkIndex++;
|
|
273
|
+
fullText = fullText ? fullText + "\n" + text : text;
|
|
274
|
+
streamedCleanText = fullText;
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (kind === "final" || kind === undefined) {
|
|
279
|
+
if (text) fullText = text;
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
onError: (err: Error) => {
|
|
283
|
+
log?.error(`[api/stream] Reply error: ${err.message}`);
|
|
284
|
+
sendSSE(res, "error", { error: err.message });
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Send done event with full text
|
|
290
|
+
const finalText = fullText || streamedCleanText;
|
|
291
|
+
sendSSE(res, "done", { text: finalText, conversationId });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Start the HTTP server for the API channel.
|
|
296
|
+
*/
|
|
297
|
+
export async function startHttpServer(params: HttpServerParams): Promise<void> {
|
|
298
|
+
const { port, host, apiKey, abortSignal, log, cfg, onStatusChange } = params;
|
|
299
|
+
|
|
300
|
+
return new Promise((resolve, reject) => {
|
|
301
|
+
const server = createServer(async (req, res) => {
|
|
302
|
+
// CORS headers
|
|
303
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
304
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
305
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key");
|
|
306
|
+
|
|
307
|
+
// Handle preflight
|
|
308
|
+
if (req.method === "OPTIONS") {
|
|
309
|
+
res.writeHead(204);
|
|
310
|
+
res.end();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Health check
|
|
315
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
316
|
+
sendJson(res, 200, { ok: true, status: "running" });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Route: POST /api/chat/stream (SSE) — must check before /api/chat
|
|
321
|
+
if (req.method === "POST" && req.url?.startsWith("/api/chat/stream")) {
|
|
322
|
+
// API key auth
|
|
323
|
+
if (apiKey) {
|
|
324
|
+
const reqKey =
|
|
325
|
+
req.headers["x-api-key"] as string ||
|
|
326
|
+
req.headers["authorization"]?.replace(/^Bearer\s+/i, "");
|
|
327
|
+
if (reqKey !== apiKey) {
|
|
328
|
+
sendJson(res, 401, { ok: false, error: "Unauthorized: invalid API key" });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const body = await readBody(req);
|
|
335
|
+
let parsed: { prompt?: string; agent?: string; conversationId?: string };
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
parsed = JSON.parse(body);
|
|
339
|
+
} catch {
|
|
340
|
+
sendJson(res, 400, { ok: false, error: "Invalid JSON body" });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const prompt = parsed.prompt?.trim();
|
|
345
|
+
if (!prompt) {
|
|
346
|
+
sendJson(res, 400, { ok: false, error: "Missing required field: prompt" });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const agentId = parsed.agent?.trim() || undefined;
|
|
351
|
+
const conversationId = parsed.conversationId?.trim() || generateId();
|
|
352
|
+
|
|
353
|
+
log?.info(`[api/stream] Received request: prompt="${prompt.slice(0, 80)}...", agent=${agentId || "default"}`);
|
|
354
|
+
onStatusChange?.({ running: true, port, lastEventAt: Date.now(), lastInboundAt: Date.now() });
|
|
355
|
+
|
|
356
|
+
// Set SSE headers
|
|
357
|
+
res.writeHead(200, {
|
|
358
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
359
|
+
"Cache-Control": "no-cache",
|
|
360
|
+
"Connection": "keep-alive",
|
|
361
|
+
"Access-Control-Allow-Origin": "*",
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
await processPromptStream(res, prompt, agentId, conversationId, cfg, log);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
log?.error(`[api/stream] Request processing error: ${err}`);
|
|
367
|
+
// If headers already sent, push error event; otherwise send JSON error
|
|
368
|
+
if (res.headersSent) {
|
|
369
|
+
sendSSE(res, "error", { error: `Internal error: ${(err as Error).message}` });
|
|
370
|
+
} else {
|
|
371
|
+
sendJson(res, 500, { ok: false, error: `Internal error: ${(err as Error).message}` });
|
|
372
|
+
}
|
|
373
|
+
} finally {
|
|
374
|
+
if (!res.writableEnded) res.end();
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Route: POST /api/chat (sync)
|
|
380
|
+
if (req.method !== "POST" || !req.url?.startsWith("/api/chat")) {
|
|
381
|
+
sendJson(res, 404, { ok: false, error: "Not found. Use POST /api/chat or /api/chat/stream" });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// API key auth (if configured)
|
|
386
|
+
if (apiKey) {
|
|
387
|
+
const reqKey =
|
|
388
|
+
req.headers["x-api-key"] as string ||
|
|
389
|
+
req.headers["authorization"]?.replace(/^Bearer\s+/i, "");
|
|
390
|
+
if (reqKey !== apiKey) {
|
|
391
|
+
sendJson(res, 401, { ok: false, error: "Unauthorized: invalid API key" });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
const body = await readBody(req);
|
|
398
|
+
let parsed: { prompt?: string; agent?: string; conversationId?: string };
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
parsed = JSON.parse(body);
|
|
402
|
+
} catch {
|
|
403
|
+
sendJson(res, 400, { ok: false, error: "Invalid JSON body" });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const prompt = parsed.prompt?.trim();
|
|
408
|
+
if (!prompt) {
|
|
409
|
+
sendJson(res, 400, { ok: false, error: "Missing required field: prompt" });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const agentId = parsed.agent?.trim() || undefined;
|
|
414
|
+
const conversationId = parsed.conversationId?.trim() || generateId();
|
|
415
|
+
|
|
416
|
+
log?.info(`[api] Received request: prompt="${prompt.slice(0, 80)}...", agent=${agentId || "default"}`);
|
|
417
|
+
onStatusChange?.({ running: true, port, lastEventAt: Date.now(), lastInboundAt: Date.now() });
|
|
418
|
+
|
|
419
|
+
const result = await processPrompt(prompt, agentId, conversationId, cfg, log);
|
|
420
|
+
|
|
421
|
+
sendJson(res, 200, {
|
|
422
|
+
ok: true,
|
|
423
|
+
text: result.text,
|
|
424
|
+
conversationId: result.conversationId,
|
|
425
|
+
});
|
|
426
|
+
} catch (err) {
|
|
427
|
+
log?.error(`[api] Request processing error: ${err}`);
|
|
428
|
+
sendJson(res, 500, {
|
|
429
|
+
ok: false,
|
|
430
|
+
error: `Internal error: ${(err as Error).message}`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// Handle abort signal
|
|
436
|
+
if (abortSignal) {
|
|
437
|
+
const abortHandler = () => {
|
|
438
|
+
server.close();
|
|
439
|
+
onStatusChange?.({ running: false });
|
|
440
|
+
resolve();
|
|
441
|
+
};
|
|
442
|
+
abortSignal.addEventListener("abort", abortHandler);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
server.on("error", (err) => {
|
|
446
|
+
log?.error(`[api] HTTP server error: ${err}`);
|
|
447
|
+
onStatusChange?.({ running: false });
|
|
448
|
+
reject(err);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
server.listen(port, host, () => {
|
|
452
|
+
log?.info(`[api] HTTP server listening on http://${host}:${port}`);
|
|
453
|
+
onStatusChange?.({ running: true, port, lastEventAt: Date.now() });
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelOnboardingAdapter,
|
|
3
|
+
OpenClawConfig,
|
|
4
|
+
WizardPrompter,
|
|
5
|
+
} from "openclaw/plugin-sdk";
|
|
6
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
|
7
|
+
|
|
8
|
+
const channel = "api" as const;
|
|
9
|
+
|
|
10
|
+
type ApiChannelConfig = {
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
port?: number;
|
|
13
|
+
host?: string;
|
|
14
|
+
apiKey?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function getChannelConfig(cfg: OpenClawConfig): ApiChannelConfig | undefined {
|
|
18
|
+
return cfg.channels?.["api"] as ApiChannelConfig | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isConfigured(channelCfg?: ApiChannelConfig): boolean {
|
|
22
|
+
// API channel is always "configured" as long as it has a port
|
|
23
|
+
const port = channelCfg?.port ?? (process.env.API_OPENCLAW_PORT ? parseInt(process.env.API_OPENCLAW_PORT, 10) : 3100);
|
|
24
|
+
return Boolean(port);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function updateConfig(
|
|
28
|
+
cfg: OpenClawConfig,
|
|
29
|
+
updates: { port?: number; host?: string; apiKey?: string; enabled?: boolean },
|
|
30
|
+
): OpenClawConfig {
|
|
31
|
+
return {
|
|
32
|
+
...cfg,
|
|
33
|
+
channels: {
|
|
34
|
+
...cfg.channels,
|
|
35
|
+
api: {
|
|
36
|
+
...cfg.channels?.["api"],
|
|
37
|
+
...updates,
|
|
38
|
+
enabled: updates.enabled ?? true,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function noteSetup(prompter: WizardPrompter): Promise<void> {
|
|
45
|
+
await prompter.note(
|
|
46
|
+
[
|
|
47
|
+
"HTTP API channel exposes OpenClaw agent capabilities via a REST endpoint.",
|
|
48
|
+
"You can configure the port, host, and an optional API key for authentication.",
|
|
49
|
+
"",
|
|
50
|
+
"Once running, send POST requests to /api/chat with:",
|
|
51
|
+
' { "prompt": "your question", "agent": "optional-agent-id" }',
|
|
52
|
+
].join("\n"),
|
|
53
|
+
"HTTP API setup",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const apiOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
58
|
+
channel,
|
|
59
|
+
getStatus: async ({ cfg }) => {
|
|
60
|
+
const channelCfg = getChannelConfig(cfg);
|
|
61
|
+
const configured = isConfigured(channelCfg);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
channel,
|
|
65
|
+
configured,
|
|
66
|
+
statusLines: [`HTTP API: ${configured ? "configured" : "needs configuration"}`],
|
|
67
|
+
selectionHint: configured ? "configured" : "requires configuration",
|
|
68
|
+
quickstartScore: configured ? 1 : 10,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
configure: async ({ cfg, prompter }) => {
|
|
72
|
+
let next = cfg;
|
|
73
|
+
const accountId = DEFAULT_ACCOUNT_ID;
|
|
74
|
+
|
|
75
|
+
await noteSetup(prompter);
|
|
76
|
+
|
|
77
|
+
const channelCfg = getChannelConfig(next);
|
|
78
|
+
|
|
79
|
+
// Check for env vars
|
|
80
|
+
const envPort = process.env.API_OPENCLAW_PORT ? parseInt(process.env.API_OPENCLAW_PORT, 10) : undefined;
|
|
81
|
+
const envHost = process.env.API_OPENCLAW_HOST?.trim();
|
|
82
|
+
const envApiKey = process.env.API_OPENCLAW_API_KEY?.trim();
|
|
83
|
+
|
|
84
|
+
if (envPort) {
|
|
85
|
+
const useEnv = await prompter.confirm({
|
|
86
|
+
message: `API_OPENCLAW_PORT=${envPort} detected in env. Use environment variables?`,
|
|
87
|
+
initialValue: true,
|
|
88
|
+
});
|
|
89
|
+
if (useEnv) {
|
|
90
|
+
next = updateConfig(next, { enabled: true });
|
|
91
|
+
return { cfg: next, accountId };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Prompt for port
|
|
96
|
+
const portInput = await prompter.text({
|
|
97
|
+
message: "HTTP server port",
|
|
98
|
+
placeholder: "3100",
|
|
99
|
+
initialValue: channelCfg?.port?.toString() || envPort?.toString() || "3100",
|
|
100
|
+
validate: (value) => {
|
|
101
|
+
const n = parseInt(String(value), 10);
|
|
102
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? undefined : "Must be a valid port (1-65535)";
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const port = parseInt(String(portInput), 10);
|
|
106
|
+
|
|
107
|
+
// Prompt for host
|
|
108
|
+
const hostInput = await prompter.text({
|
|
109
|
+
message: "HTTP server host",
|
|
110
|
+
placeholder: "0.0.0.0",
|
|
111
|
+
initialValue: channelCfg?.host?.trim() || envHost || "0.0.0.0",
|
|
112
|
+
});
|
|
113
|
+
const host = String(hostInput).trim() || "0.0.0.0";
|
|
114
|
+
|
|
115
|
+
// Prompt for API key (optional)
|
|
116
|
+
const apiKeyInput = await prompter.text({
|
|
117
|
+
message: "API Key (optional, leave empty for no authentication)",
|
|
118
|
+
placeholder: "",
|
|
119
|
+
initialValue: channelCfg?.apiKey?.trim() || envApiKey || "",
|
|
120
|
+
});
|
|
121
|
+
const apiKey = String(apiKeyInput).trim();
|
|
122
|
+
|
|
123
|
+
next = updateConfig(next, { port, host, apiKey, enabled: true });
|
|
124
|
+
return { cfg: next, accountId };
|
|
125
|
+
},
|
|
126
|
+
disable: (cfg) => {
|
|
127
|
+
return {
|
|
128
|
+
...cfg,
|
|
129
|
+
channels: {
|
|
130
|
+
...cfg.channels,
|
|
131
|
+
api: {
|
|
132
|
+
...cfg.channels?.["api"],
|
|
133
|
+
enabled: false,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
};
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let apiRuntime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setApiRuntime(runtime: PluginRuntime): void {
|
|
6
|
+
apiRuntime = runtime;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getApiRuntime(): PluginRuntime {
|
|
10
|
+
if (!apiRuntime) {
|
|
11
|
+
throw new Error("API runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return apiRuntime;
|
|
14
|
+
}
|