openclaw-elys 1.7.7 → 1.8.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 +114 -0
- package/dist/src/monitor.js +94 -4
- package/dist/src/mqtt-client.d.ts +4 -1
- package/dist/src/mqtt-client.js +5 -1
- package/dist/src/types.d.ts +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,6 +52,120 @@ One command to revoke device, clean up config, and remove the plugin:
|
|
|
52
52
|
npx openclaw-elys@latest uninstall
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
## Streaming / 流式输出
|
|
56
|
+
|
|
57
|
+
OpenClaw 支持 block streaming,需要在 `~/.openclaw/openclaw.json` 中配置:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"agents": {
|
|
62
|
+
"defaults": {
|
|
63
|
+
"blockStreamingDefault": "on"
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"channels": {
|
|
67
|
+
"elys": {
|
|
68
|
+
"blockStreaming": true
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
配置后需要**完全重启 OpenClaw**(非热重载)才能生效。
|
|
75
|
+
|
|
76
|
+
发送消息时设置 `stream: true` 可以获取流式输出(多个 chunk),否则只返回最终结果。
|
|
77
|
+
|
|
78
|
+
## MQTT Protocol / 通信协议
|
|
79
|
+
|
|
80
|
+
### Topics
|
|
81
|
+
|
|
82
|
+
| 方向 | Topic | 说明 |
|
|
83
|
+
|------|-------|------|
|
|
84
|
+
| 下行(命令) | `elys/down/{device_id}` | Gateway → Plugin |
|
|
85
|
+
| 上行(响应) | `elys/up/{device_id}` | Plugin → Gateway |
|
|
86
|
+
|
|
87
|
+
### Message Types
|
|
88
|
+
|
|
89
|
+
#### Command(下行)
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"id": "cmd_xxx",
|
|
94
|
+
"type": "command",
|
|
95
|
+
"timestamp": 1709827200,
|
|
96
|
+
"command": "chat",
|
|
97
|
+
"args": { "text": "你好" },
|
|
98
|
+
"stream": true,
|
|
99
|
+
"media_url": "https://example.com/photo.jpg",
|
|
100
|
+
"media_urls": ["https://example.com/1.jpg", "https://example.com/2.jpg"],
|
|
101
|
+
"media_type": "image/jpeg"
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- `stream`: 是否请求流式响应(可选,默认 false)
|
|
106
|
+
- `media_url`: 单个媒体 URL(可选)
|
|
107
|
+
- `media_urls`: 多个媒体 URL(可选)
|
|
108
|
+
- `media_type`: 媒体 MIME 类型(可选,默认 `application/octet-stream`)
|
|
109
|
+
- `args.text` 和 `media_url` 可以同时存在(文字 + 附件)
|
|
110
|
+
|
|
111
|
+
#### Ack(上行)
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{ "id": "cmd_xxx", "type": "ack", "timestamp": 1709827200 }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
#### Stream(上行,流式响应)
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"id": "cmd_xxx",
|
|
122
|
+
"type": "stream",
|
|
123
|
+
"timestamp": 1709827200,
|
|
124
|
+
"chunk": "这是一段回复文字",
|
|
125
|
+
"seq": 1,
|
|
126
|
+
"done": false,
|
|
127
|
+
"media_url": "https://example.com/generated.png",
|
|
128
|
+
"media_urls": ["https://example.com/1.png", "https://example.com/2.png"]
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- `seq`: 序号,从 1 开始递增
|
|
133
|
+
- `done`: 最后一个 chunk 设为 true
|
|
134
|
+
- `media_url`/`media_urls`: AI 生成的媒体(可选)
|
|
135
|
+
|
|
136
|
+
#### Result(上行,最终结果)
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"id": "cmd_xxx",
|
|
141
|
+
"type": "result",
|
|
142
|
+
"timestamp": 1709827200,
|
|
143
|
+
"status": "success",
|
|
144
|
+
"result": { "text": "完整回复内容" },
|
|
145
|
+
"media_url": "https://example.com/output.png",
|
|
146
|
+
"media_urls": ["https://example.com/1.png"]
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Supported Media Types / 支持的媒体类型
|
|
151
|
+
|
|
152
|
+
| 类型 | MIME type | 说明 |
|
|
153
|
+
|------|-----------|------|
|
|
154
|
+
| 图片 | `image/jpeg`, `image/png`, `image/webp` | 照片、截图 |
|
|
155
|
+
| GIF | `image/gif` | 动图 |
|
|
156
|
+
| 视频 | `video/mp4`, `video/quicktime` | 视频 |
|
|
157
|
+
| 音频 | `audio/mpeg`, `audio/ogg`, `audio/wav` | 语音/音频 |
|
|
158
|
+
| 文件 | `application/pdf`, `application/octet-stream` | 通用文件 |
|
|
159
|
+
|
|
160
|
+
不传 `media_type` 时默认为 `application/octet-stream`。
|
|
161
|
+
|
|
162
|
+
### Behavior / 行为
|
|
163
|
+
|
|
164
|
+
- **Debounce**:500ms 内的多条消息会合并为一条发送给 AI
|
|
165
|
+
- **Sequential Queue**:推理中收到新消息会排队等待当前推理完成后再处理(与 Discord/Slack 一致)
|
|
166
|
+
- **Dedup**:QoS 1 重传的消息会自动去重(基于 command ID)
|
|
167
|
+
- **Auto ID**:Gateway 会自动为空 ID 的命令生成 `cmd_` 前缀的唯一 ID
|
|
168
|
+
|
|
55
169
|
## License
|
|
56
170
|
|
|
57
171
|
MIT
|
package/dist/src/monitor.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
1
6
|
import { loadCredentials } from "./config.js";
|
|
2
7
|
import { registerDevice } from "./register.js";
|
|
3
8
|
import { ElysDeviceMQTTClient } from "./mqtt-client.js";
|
|
@@ -39,6 +44,25 @@ export async function monitorElysProvider(opts) {
|
|
|
39
44
|
try {
|
|
40
45
|
let seq = 0;
|
|
41
46
|
let fullText = "";
|
|
47
|
+
// Download inbound media (user-sent) to local temp files
|
|
48
|
+
// OpenClaw expects local file paths in MediaPath/MediaUrl, not remote URLs
|
|
49
|
+
const rawMediaUrls = cmd.mediaUrls?.length
|
|
50
|
+
? cmd.mediaUrls
|
|
51
|
+
: cmd.mediaUrl
|
|
52
|
+
? [cmd.mediaUrl]
|
|
53
|
+
: [];
|
|
54
|
+
const downloadedPaths = [];
|
|
55
|
+
const downloadedTypes = [];
|
|
56
|
+
for (const url of rawMediaUrls) {
|
|
57
|
+
try {
|
|
58
|
+
const localPath = await downloadToTemp(url, log);
|
|
59
|
+
downloadedPaths.push(localPath);
|
|
60
|
+
downloadedTypes.push(cmd.mediaType ?? guessMediaType(url));
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
log(`[elys] failed to download media ${url}:`, err);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
42
66
|
const inboundCtx = finalizeCtx({
|
|
43
67
|
Body: formatCommandAsText(cmd),
|
|
44
68
|
BodyForAgent: formatCommandAsText(cmd),
|
|
@@ -54,19 +78,36 @@ export async function monitorElysProvider(opts) {
|
|
|
54
78
|
CommandAuthorized: true,
|
|
55
79
|
OriginatingChannel: "elys",
|
|
56
80
|
OriginatingTo: credentials.deviceId,
|
|
81
|
+
// Inbound media as local file paths
|
|
82
|
+
...(downloadedPaths.length > 0 && {
|
|
83
|
+
MediaPath: downloadedPaths[0],
|
|
84
|
+
MediaUrl: downloadedPaths[0],
|
|
85
|
+
MediaPaths: downloadedPaths,
|
|
86
|
+
MediaUrls: downloadedPaths,
|
|
87
|
+
MediaType: downloadedTypes[0],
|
|
88
|
+
MediaTypes: downloadedTypes,
|
|
89
|
+
}),
|
|
57
90
|
});
|
|
58
91
|
// Deliver callback: stream chunks back via MQTT
|
|
59
92
|
const wantStream = cmd.stream === true;
|
|
60
93
|
const deliver = async (payload, info) => {
|
|
61
|
-
|
|
62
|
-
|
|
94
|
+
const mediaUrl = payload.mediaUrl?.trim();
|
|
95
|
+
const mediaUrls = payload.mediaUrls?.filter((u) => u?.trim());
|
|
96
|
+
const hasMedia = Boolean(mediaUrl || mediaUrls?.length);
|
|
97
|
+
const media = hasMedia ? { mediaUrl, mediaUrls } : undefined;
|
|
98
|
+
if (payload.text || hasMedia) {
|
|
99
|
+
if (payload.text)
|
|
100
|
+
fullText += payload.text;
|
|
63
101
|
if (wantStream || info.kind === "final") {
|
|
64
102
|
seq++;
|
|
65
103
|
const done = info.kind === "final";
|
|
66
|
-
mqttClient.publishStreamChunk(cmd.id, payload.text, seq, done);
|
|
104
|
+
mqttClient.publishStreamChunk(cmd.id, payload.text ?? "", seq, done, media);
|
|
105
|
+
}
|
|
106
|
+
if (hasMedia) {
|
|
107
|
+
log(`[elys] media: ${mediaUrl ?? mediaUrls?.join(", ")}`);
|
|
67
108
|
}
|
|
68
109
|
if (info.kind === "block") {
|
|
69
|
-
log(`[elys] stream chunk #${seq}: ${payload.text.slice(0, 80)}...`);
|
|
110
|
+
log(`[elys] stream chunk #${seq}: ${(payload.text ?? "").slice(0, 80)}...`);
|
|
70
111
|
}
|
|
71
112
|
else if (info.kind === "final") {
|
|
72
113
|
log(`[elys] final reply delivered`);
|
|
@@ -150,3 +191,52 @@ function formatCommandAsText(cmd) {
|
|
|
150
191
|
}
|
|
151
192
|
return parts.join(" ");
|
|
152
193
|
}
|
|
194
|
+
const MIME_BY_EXT = {
|
|
195
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
|
196
|
+
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
|
197
|
+
".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo",
|
|
198
|
+
".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav",
|
|
199
|
+
".pdf": "application/pdf",
|
|
200
|
+
};
|
|
201
|
+
function guessMediaType(url) {
|
|
202
|
+
const pathname = new URL(url).pathname.toLowerCase();
|
|
203
|
+
for (const [ext, mime] of Object.entries(MIME_BY_EXT)) {
|
|
204
|
+
if (pathname.includes(ext))
|
|
205
|
+
return mime;
|
|
206
|
+
}
|
|
207
|
+
return "application/octet-stream";
|
|
208
|
+
}
|
|
209
|
+
function extFromMimeOrUrl(url, mime) {
|
|
210
|
+
for (const [ext, m] of Object.entries(MIME_BY_EXT)) {
|
|
211
|
+
if (m === mime)
|
|
212
|
+
return ext;
|
|
213
|
+
}
|
|
214
|
+
const pathname = new URL(url).pathname;
|
|
215
|
+
const dot = pathname.lastIndexOf(".");
|
|
216
|
+
if (dot >= 0) {
|
|
217
|
+
const ext = pathname.slice(dot).split(/[?#!/]/)[0];
|
|
218
|
+
if (ext.length <= 6)
|
|
219
|
+
return ext;
|
|
220
|
+
}
|
|
221
|
+
return ".bin";
|
|
222
|
+
}
|
|
223
|
+
let tempDir = null;
|
|
224
|
+
async function downloadToTemp(url, log) {
|
|
225
|
+
if (!tempDir) {
|
|
226
|
+
tempDir = await mkdtemp(join(tmpdir(), "elys-media-"));
|
|
227
|
+
}
|
|
228
|
+
const mime = guessMediaType(url);
|
|
229
|
+
const ext = extFromMimeOrUrl(url, mime);
|
|
230
|
+
const filename = `media_${Date.now()}${ext}`;
|
|
231
|
+
const filePath = join(tempDir, filename);
|
|
232
|
+
log(`[elys] downloading media: ${url} → ${filePath}`);
|
|
233
|
+
const resp = await fetch(url);
|
|
234
|
+
if (!resp.ok || !resp.body) {
|
|
235
|
+
throw new Error(`HTTP ${resp.status} downloading ${url}`);
|
|
236
|
+
}
|
|
237
|
+
const { Readable } = await import("node:stream");
|
|
238
|
+
const nodeStream = Readable.fromWeb(resp.body);
|
|
239
|
+
await pipeline(nodeStream, createWriteStream(filePath));
|
|
240
|
+
log(`[elys] downloaded media: ${filePath}`);
|
|
241
|
+
return filePath;
|
|
242
|
+
}
|
|
@@ -37,7 +37,10 @@ export declare class ElysDeviceMQTTClient {
|
|
|
37
37
|
connect(abortSignal?: AbortSignal): Promise<void>;
|
|
38
38
|
disconnect(): void;
|
|
39
39
|
/** Send a stream chunk (for streaming AI responses) */
|
|
40
|
-
publishStreamChunk(commandId: string, chunk: string, seq: number, done: boolean
|
|
40
|
+
publishStreamChunk(commandId: string, chunk: string, seq: number, done: boolean, media?: {
|
|
41
|
+
mediaUrl?: string;
|
|
42
|
+
mediaUrls?: string[];
|
|
43
|
+
}): void;
|
|
41
44
|
private onMessage;
|
|
42
45
|
private flushDebounce;
|
|
43
46
|
private startCommand;
|
package/dist/src/mqtt-client.js
CHANGED
|
@@ -123,7 +123,7 @@ export class ElysDeviceMQTTClient {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
/** Send a stream chunk (for streaming AI responses) */
|
|
126
|
-
publishStreamChunk(commandId, chunk, seq, done) {
|
|
126
|
+
publishStreamChunk(commandId, chunk, seq, done, media) {
|
|
127
127
|
const msg = {
|
|
128
128
|
id: commandId,
|
|
129
129
|
type: "stream",
|
|
@@ -132,6 +132,10 @@ export class ElysDeviceMQTTClient {
|
|
|
132
132
|
seq,
|
|
133
133
|
done,
|
|
134
134
|
};
|
|
135
|
+
if (media?.mediaUrl)
|
|
136
|
+
msg.mediaUrl = media.mediaUrl;
|
|
137
|
+
if (media?.mediaUrls?.length)
|
|
138
|
+
msg.mediaUrls = media.mediaUrls;
|
|
135
139
|
this.publish(msg);
|
|
136
140
|
}
|
|
137
141
|
// ─── Inbound message pipeline: dedup → ack → debounce → abort → execute ───
|
package/dist/src/types.d.ts
CHANGED
|
@@ -25,6 +25,9 @@ export interface CommandMessage extends MQTTBaseMessage {
|
|
|
25
25
|
command: string;
|
|
26
26
|
args?: Record<string, unknown>;
|
|
27
27
|
stream?: boolean;
|
|
28
|
+
mediaUrl?: string;
|
|
29
|
+
mediaUrls?: string[];
|
|
30
|
+
mediaType?: string;
|
|
28
31
|
}
|
|
29
32
|
export interface AckMessage extends MQTTBaseMessage {
|
|
30
33
|
type: "ack";
|
|
@@ -34,10 +37,14 @@ export interface StreamMessage extends MQTTBaseMessage {
|
|
|
34
37
|
chunk: string;
|
|
35
38
|
done: boolean;
|
|
36
39
|
seq: number;
|
|
40
|
+
mediaUrl?: string;
|
|
41
|
+
mediaUrls?: string[];
|
|
37
42
|
}
|
|
38
43
|
export interface ResultMessage extends MQTTBaseMessage {
|
|
39
44
|
type: "result";
|
|
40
45
|
status: "success" | "error";
|
|
41
46
|
result?: Record<string, unknown>;
|
|
42
47
|
error?: string;
|
|
48
|
+
mediaUrl?: string;
|
|
49
|
+
mediaUrls?: string[];
|
|
43
50
|
}
|