openclaw-xiaoyou 1.2.4 → 1.3.0

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.
@@ -138,24 +138,24 @@ xiaoyou 插件采用 **Bridge 模式**:由插件主动向企业服务发起 We
138
138
  │ │──────────────────▶│ │ │
139
139
  │ │ { │ │ │
140
140
  │ │ "type":"message"│ │ │
141
+ │ │ "messageId" │ │ │
141
142
  │ │ "conversationId"│ │ │
142
143
  │ │ "senderId" │ │ │
143
144
  │ │ "text":"帮我查.."│ │ │
144
145
  │ │ } │ │ │
145
146
  │ │ │ │ │
146
- │ │ │ ③ 安全检查 │ │
147
- │ │ │ - 是否已认证? │ │
148
- │ │ │ - senderId 在白名单? │ │
147
+ │ │ │ ③ 认证检查 │ │
148
+ │ │ │ - WebSocket 是否已认证? │ │
149
149
  │ │ │ │ │
150
- │ │ │ ④ 归一化 + 分发 │
150
+ │ │ │ ④ 路由 + 格式化 + 分发│
151
151
  │ │ │─────────────────────▶│ │
152
- │ │ │ runtime.inbound │ │
153
- │ │ │ .dispatch({ │
154
- │ │ │ channelId, │ │
155
- │ │ │ senderId,
156
- │ │ │ conversationId,
157
- │ │ │ text, ... │ │
158
- │ │ │ }) │ │
152
+ │ │ │ rt.channel.routing │ │
153
+ │ │ │ .resolveAgentRoute()
154
+ │ │ │ rt.channel.reply │ │
155
+ │ │ │ .formatInboundEnvelope()
156
+ │ │ │ .finalizeInboundContext()
157
+ │ │ │ .dispatchReplyWith │ │
158
+ │ │ │ BufferedBlock...() │ │
159
159
  │ │ │ │ │
160
160
  │ │ │ │ ⑤ 路由 + 会话 │
161
161
  │ │ │ │ resolveAgentRoute() │
@@ -171,16 +171,16 @@ xiaoyou 插件采用 **Bridge 模式**:由插件主动向企业服务发起 We
171
171
  │ │ │ │◀───────────────────│
172
172
  │ │ │ │ "项目Alpha进度85%" │
173
173
  │ │ │ │ │
174
- │ │ │ ⑨ outbound.send() │ │
174
+ │ │ │ ⑨ deliver() 回调 │ │
175
175
  │ │ │◀─────────────────────│ │
176
- │ │ │ payload={kind:"text",│
177
- │ │ │ text:"项目Alpha.."} │ │
176
+ │ │ │ payload={text:"..."} │
178
177
  │ │ │ │ │
179
178
  │ │ ⑩ WebSocket 帧 │ │ │
180
179
  │ │◀──────────────────│ │ │
181
180
  │ │ { │ │ │
182
181
  │ │ "type":"reply" │ │ │
183
182
  │ │ "conversationId"│ │ │
183
+ │ │ "replyToMessageId" │ │
184
184
  │ │ "text":"项目..."│ │ │
185
185
  │ │ "timestamp":... │ │ │
186
186
  │ │ } │ │ │
@@ -199,12 +199,12 @@ xiaoyou 插件采用 **Bridge 模式**:由插件主动向企业服务发起 We
199
199
  | ① | 企业用户 → 企业服务 | 用户在企业 IM 中发送消息 | 企业侧实现 |
200
200
  | ② | 企业服务 → xiaoyou | 企业服务将消息封装为 `type:"message"` 帧,通过 WebSocket 发送 | 企业侧实现 |
201
201
  | ③ | xiaoyou 内部 | 检查是否已认证;检查 senderId 是否在 allowFrom 白名单 | `enterprise-client.ts` handleFrame() |
202
- | ④ | xiaoyou → Gateway | 调用 `runtime.inbound.dispatch()` 将消息归一化后分发给 Gateway | `channel.ts` gateway.start.onMessage |
202
+ | ④ | xiaoyou → Gateway | 调用 `rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher()` 将消息分发给 Gateway | `channel.ts` gateway.startAccount.onMessage |
203
203
  | ⑤ | Gateway 内部 | `resolveAgentRoute()` 根据 channelId + conversationId 生成 sessionKey | OpenClaw Core |
204
204
  | ⑥ | Gateway → LLM | 将用户消息 + 上下文 + system prompt 发送给 LLM | OpenClaw Core |
205
205
  | ⑦ | LLM | 推理、可能调用 tools(查数据库、执行命令等) | LLM Provider |
206
206
  | ⑧ | LLM → Gateway | 返回生成的回复文本 | LLM Provider |
207
- | ⑨ | Gateway → xiaoyou | 调用 `outbound.send()` 将回复交给插件 | `channel.ts` outbound.send |
207
+ | ⑨ | Gateway → xiaoyou | 通过 dispatcher deliver 回调将回复交给插件 | `channel.ts` deliver() |
208
208
  | ⑩ | xiaoyou → 企业服务 | 将回复封装为 `type:"reply"` 帧,通过 WebSocket 发送 | `enterprise-client.ts` sendReply() |
209
209
  | ⑪ | 企业服务 → 用户 | 企业服务将回复展示给用户 | 企业侧实现 |
210
210
 
@@ -306,6 +306,7 @@ xiaoyou 插件采用 **Bridge 模式**:由插件主动向企业服务发起 We
306
306
  {
307
307
  "type": "message",
308
308
  "conversationId": "conv-123",
309
+ "messageId": "msg-001",
309
310
  "senderId": "user-001",
310
311
  "senderName": "张三",
311
312
  "text": "帮我查一下项目进度",
@@ -328,6 +329,7 @@ xiaoyou 插件采用 **Bridge 模式**:由插件主动向企业服务发起 We
328
329
  | 字段 | 类型 | 必须 | 说明 |
329
330
  |------|------|------|------|
330
331
  | conversationId | string | ✅ | 会话 ID,用于路由和回复定位 |
332
+ | messageId | string | 否 | 消息唯一 ID,用于请求-回复一一绑定。回复帧会通过 replyToMessageId 回传 |
331
333
  | senderId | string | ✅ | 发送者唯一标识 |
332
334
  | senderName | string | 否 | 发送者显示名 |
333
335
  | text | string | 否 | 文本内容 |
@@ -341,6 +343,7 @@ xiaoyou 插件采用 **Bridge 模式**:由插件主动向企业服务发起 We
341
343
  "type": "reply",
342
344
  "conversationId": "conv-123",
343
345
  "messageId": "xiaoyou-1730000000000",
346
+ "replyToMessageId": "msg-001",
344
347
  "text": "项目 Alpha 当前进度 85%,预计下周三完成。",
345
348
  "mediaUrls": ["https://openclaw-local/media/chart.png"],
346
349
  "agentId": "main",
@@ -351,7 +354,8 @@ xiaoyou 插件采用 **Bridge 模式**:由插件主动向企业服务发起 We
351
354
  | 字段 | 类型 | 说明 |
352
355
  |------|------|------|
353
356
  | conversationId | string | 对应入站消息的会话 ID |
354
- | messageId | string | 回复消息的唯一 ID |
357
+ | messageId | string | 回复消息的唯一 ID(由插件生成) |
358
+ | replyToMessageId | string? | 对应入站 message 的 messageId,用于请求-回复一一绑定 |
355
359
  | text | string | 回复文本 |
356
360
  | mediaUrls | string[]? | 附带的媒体文件 URL |
357
361
  | agentId | string? | 处理该消息的 Agent ID |
@@ -388,10 +392,20 @@ xiaoyou 插件采用 **Bridge 模式**:由插件主动向企业服务发起 We
388
392
  │ ┌──────────────────────────────────────────────────────────┐ │
389
393
  │ │ channel.ts (ChannelPlugin) │ │
390
394
  │ │ │ │
391
- │ │ config ──▶ 解析 wsUrl / authToken / allowFrom │ │
392
- │ │ security ──▶ DM 白名单策略 │ │
393
- │ │ gateway ──▶ 创建 EnterpriseClient,管理连接生命周期 │ │
394
- │ │ outbound ──▶ 将 Agent 回复通过 EnterpriseClient 发出 │ │
395
+ │ │ config ──▶ listAccountIds / resolveAccount / │ │
396
+ │ │ isConfigured / describeAccount │ │
397
+ │ │ security ──▶ DM 策略 (默认 open,允许所有用户) │ │
398
+ │ │ gateway.startAccount(ctx) ──▶ │ │
399
+ │ │ 1. 从 ctx.account.config 读取 wsUrl/authToken │ │
400
+ │ │ 2. 创建 EnterpriseClient 并连接 │ │
401
+ │ │ 3. onMessage 收到消息时: │ │
402
+ │ │ rt.channel.routing.resolveAgentRoute() │ │
403
+ │ │ rt.channel.reply.formatInboundEnvelope() │ │
404
+ │ │ rt.channel.reply.finalizeInboundContext() │ │
405
+ │ │ rt.channel.reply.dispatchReplyWith...() │ │
406
+ │ │ 4. deliver() 回调将 LLM 回复通过 WebSocket 发出 │ │
407
+ │ │ 5. 通过 abortSignal 保持挂起,直到 gateway 停止 │ │
408
+ │ │ outbound ──▶ 将主动推送通过 EnterpriseClient 发出 │ │
395
409
  │ │ status ──▶ 报告连接健康状态 │ │
396
410
  │ └──────────────┬───────────────────────────────────────────┘ │
397
411
  │ │ │
@@ -1,16 +1,56 @@
1
1
 
2
- /aiysnfs/nginx/ssl/aiservice/html/
2
+ # 小优 Channel 安装话术与部署路径
3
3
 
4
+ ## 安装话术
4
5
 
5
- https://aitest.haiersmarthomes.com:11001/skills/upload/emotion/robot_emotion_v1.0.tar.gz
6
+ 按照 https://d10.tfdl.net/public/2026-05-06/14dbf63d-d0aa-4b5d-bdd6-47eddb455f36/install-xiaoyou.sh 文档完成小优channel配置,AccessToken为xiaoyou-025b07e43ae84cd6
7
+
8
+ ## 脚本位置
9
+
10
+ https://d10.tfdl.net/public/2026-05-06/14dbf63d-d0aa-4b5d-bdd6-47eddb455f36/install-xiaoyou.sh
11
+
12
+ ## 卸载+重装命令
13
+
14
+ ```bash
15
+ curl -fsSL https://d10.tfdl.net/public/2026-05-06/14dbf63d-d0aa-4b5d-bdd6-47eddb455f36/install-xiaoyou.sh | bash -s -- uninstall && \
16
+ curl -fsSL https://d10.tfdl.net/public/2026-05-06/14dbf63d-d0aa-4b5d-bdd6-47eddb455f36/install-xiaoyou.sh | bash -s -- \
17
+ --version 1.2.5 \
18
+ --ws-url ws://aiws-sim.haiersmarthomes.com:11055/xiaoyou/claw \
19
+ --token xiaoyou-025b07e43ae84cd6
20
+ ```
21
+
22
+ ## 验证发消息
23
+
24
+ ```bash
25
+ curl -X POST http://172.16.4.35:11480/ai-connection/xiaoyou/message/send \
26
+ --header 'appId: VOMD-AICUSTOM-0000' \
27
+ --header 'clientId: 793C339F7C' \
28
+ --header 'content-type: application/json' \
29
+ --header 'deviceId: 793C339F7C' \
30
+ --header 'sign: 63ce6135d8580230c2729ed39cd50cb0' \
31
+ --header 'timestamp: 2248687025' \
32
+ -d '{
33
+ "userToken": "xiaoyou-025b07e43ae84cd6",
34
+ "conversationId": "conv-123",
35
+ "messageId": "msg-001",
36
+ "senderId": "user-001",
37
+ "text": "你好"
38
+ }'
39
+ ```
40
+
41
+ ## 查看日志
42
+
43
+ ```bash
44
+ journalctl --user -u openclaw-gateway.service --since "2 min ago" --no-pager | grep -E "xiaoyou.*reply|xiaoyou.*error|xiaoyou.*sent"
45
+ ```
46
+
47
+ ## 部署路径
6
48
 
7
49
  验收环境位置:
8
50
  /aiysnfs/nginx/share/skills/upload/claw
51
+
9
52
  仿真环境位置:
10
53
  /ai-sim/export/nginx/share/skills/upload/claw
54
+
11
55
  生产环境位置:
12
56
  /ai-prod/export/nginx/share/skills/upload/claw
13
-
14
- /aiysnfs/nginx/ssl/aiservice/html/
15
-
16
- 按照 https://aitest.haiersmarthomes.com/aiservice/html/claw/SKILL.md 文档完成小优channel配置,AccessToken为xiaoyou-025b07e43ae84cd6
@@ -0,0 +1,93 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 920" font-family="'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif">
3
+ <defs>
4
+ <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
5
+ <polygon points="0 0, 10 3.5, 0 7" fill="#555"/>
6
+ </marker>
7
+ <filter id="shadow" x="-2%" y="-2%" width="104%" height="104%">
8
+ <feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.1"/>
9
+ </filter>
10
+ </defs>
11
+
12
+ <!-- 外框 -->
13
+ <rect x="30" y="20" width="840" height="880" rx="16" fill="#f8fafc" stroke="#94a3b8" stroke-width="2" filter="url(#shadow)"/>
14
+ <text x="450" y="55" text-anchor="middle" font-size="20" font-weight="bold" fill="#1e293b">xiaoyou 插件内部架构</text>
15
+
16
+ <!-- index.ts 模块 -->
17
+ <rect x="60" y="80" width="780" height="180" rx="12" fill="#ffffff" stroke="#3b82f6" stroke-width="2" filter="url(#shadow)"/>
18
+ <rect x="60" y="80" width="780" height="36" rx="12" fill="#3b82f6"/>
19
+ <rect x="60" y="104" width="780" height="12" fill="#3b82f6"/>
20
+ <text x="80" y="104" font-size="14" font-weight="bold" fill="#ffffff">index.ts (入口)</text>
21
+
22
+ <text x="90" y="145" font-size="13" fill="#334155" font-family="'Consolas', 'Courier New', monospace">register(api) {</text>
23
+ <text x="110" y="170" font-size="13" fill="#334155" font-family="'Consolas', 'Courier New', monospace">setRuntime(api.runtime)</text>
24
+ <text x="430" y="170" font-size="12" fill="#6366f1">──▶ 注入 runtime 引用</text>
25
+ <text x="110" y="195" font-size="13" fill="#334155" font-family="'Consolas', 'Courier New', monospace">api.registerChannel()</text>
26
+ <text x="430" y="195" font-size="12" fill="#6366f1">──▶ 注册到 PluginRegistry</text>
27
+ <text x="110" y="220" font-size="13" fill="#334155" font-family="'Consolas', 'Courier New', monospace">api.registerCli()</text>
28
+ <text x="430" y="220" font-size="12" fill="#6366f1">──▶ 注册 CLI 子命令</text>
29
+ <text x="90" y="245" font-size="13" fill="#334155" font-family="'Consolas', 'Courier New', monospace">}</text>
30
+
31
+ <!-- 箭头: index -> channel -->
32
+ <line x1="450" y1="260" x2="450" y2="295" stroke="#555" stroke-width="2" marker-end="url(#arrowhead)"/>
33
+
34
+ <!-- channel.ts 模块 -->
35
+ <rect x="60" y="300" width="780" height="320" rx="12" fill="#ffffff" stroke="#10b981" stroke-width="2" filter="url(#shadow)"/>
36
+ <rect x="60" y="300" width="780" height="36" rx="12" fill="#10b981"/>
37
+ <rect x="60" y="324" width="780" height="12" fill="#10b981"/>
38
+ <text x="80" y="324" font-size="14" font-weight="bold" fill="#ffffff">channel.ts (ChannelPlugin)</text>
39
+
40
+ <!-- config 区域 -->
41
+ <text x="90" y="365" font-size="13" font-weight="bold" fill="#1e293b">config</text>
42
+ <text x="170" y="365" font-size="12" fill="#6366f1">──▶ listAccountIds / resolveAccount / isConfigured / describeAccount</text>
43
+
44
+ <!-- security 区域 -->
45
+ <text x="90" y="395" font-size="13" font-weight="bold" fill="#1e293b">security</text>
46
+ <text x="170" y="395" font-size="12" fill="#6366f1">──▶ DM 策略 (默认 open,允许所有用户)</text>
47
+
48
+ <!-- gateway 区域 -->
49
+ <text x="90" y="430" font-size="13" font-weight="bold" fill="#1e293b">gateway.startAccount(ctx)</text>
50
+ <text x="110" y="455" font-size="12" fill="#475569">1. 从 ctx.account.config 读取 wsUrl / authToken</text>
51
+ <text x="110" y="478" font-size="12" fill="#475569">2. 创建 EnterpriseClient 并连接</text>
52
+ <text x="110" y="501" font-size="12" fill="#475569">3. onMessage 收到消息时:</text>
53
+ <text x="140" y="521" font-size="11" fill="#64748b" font-family="'Consolas', 'Courier New', monospace">rt.channel.routing.resolveAgentRoute()</text>
54
+ <text x="140" y="539" font-size="11" fill="#64748b" font-family="'Consolas', 'Courier New', monospace">rt.channel.reply.formatInboundEnvelope()</text>
55
+ <text x="140" y="557" font-size="11" fill="#64748b" font-family="'Consolas', 'Courier New', monospace">rt.channel.reply.finalizeInboundContext()</text>
56
+ <text x="140" y="575" font-size="11" fill="#64748b" font-family="'Consolas', 'Courier New', monospace">rt.channel.reply.dispatchReplyWith...()</text>
57
+ <text x="110" y="598" font-size="12" fill="#475569">4. deliver() 回调将 LLM 回复通过 WebSocket 发出</text>
58
+
59
+ <!-- outbound & status -->
60
+ <text x="90" y="555" font-size="13" font-weight="bold" fill="#1e293b" transform="translate(520, -195)">outbound</text>
61
+ <text x="170" y="555" font-size="12" fill="#6366f1" transform="translate(520, -195)">──▶ 将主动推送通过</text>
62
+ <text x="170" y="573" font-size="12" fill="#6366f1" transform="translate(520, -195)"> EnterpriseClient 发出</text>
63
+
64
+ <text x="90" y="598" font-size="13" font-weight="bold" fill="#1e293b" transform="translate(520, -195)">status</text>
65
+ <text x="170" y="598" font-size="12" fill="#6366f1" transform="translate(520, -195)">──▶ 报告连接健康状态</text>
66
+
67
+ <!-- 箭头: channel -> enterprise-client -->
68
+ <line x1="450" y1="620" x2="450" y2="655" stroke="#555" stroke-width="2" marker-end="url(#arrowhead)"/>
69
+
70
+ <!-- enterprise-client.ts 模块 -->
71
+ <rect x="60" y="660" width="780" height="220" rx="12" fill="#ffffff" stroke="#f59e0b" stroke-width="2" filter="url(#shadow)"/>
72
+ <rect x="60" y="660" width="780" height="36" rx="12" fill="#f59e0b"/>
73
+ <rect x="60" y="684" width="780" height="12" fill="#f59e0b"/>
74
+ <text x="80" y="684" font-size="14" font-weight="bold" fill="#ffffff">enterprise-client.ts (WebSocket 连接管理)</text>
75
+
76
+ <text x="90" y="725" font-size="13" font-weight="bold" fill="#1e293b">connect()</text>
77
+ <text x="260" y="725" font-size="12" fill="#6366f1">──▶ 建立 WebSocket → 发送 auth</text>
78
+
79
+ <text x="90" y="755" font-size="13" font-weight="bold" fill="#1e293b">handleFrame()</text>
80
+ <text x="260" y="755" font-size="12" fill="#6366f1">──▶ 解析 JSON 帧 → 分发到对应处理器</text>
81
+
82
+ <text x="90" y="785" font-size="13" font-weight="bold" fill="#1e293b">startHeartbeat()</text>
83
+ <text x="260" y="785" font-size="12" fill="#6366f1">──▶ 定时 ping,超时触发重连</text>
84
+
85
+ <text x="90" y="815" font-size="13" font-weight="bold" fill="#1e293b">scheduleReconnect()</text>
86
+ <text x="260" y="815" font-size="12" fill="#6366f1">──▶ 指数退避重连</text>
87
+
88
+ <text x="90" y="845" font-size="13" font-weight="bold" fill="#1e293b">sendReply()</text>
89
+ <text x="260" y="845" font-size="12" fill="#6366f1">──▶ 发送出站 reply 帧</text>
90
+
91
+ <text x="90" y="875" font-size="13" font-weight="bold" fill="#1e293b">disconnect()</text>
92
+ <text x="260" y="875" font-size="12" fill="#6366f1">──▶ 优雅关闭</text>
93
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-xiaoyou",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Xiaoyou channel plugin for OpenClaw — connects enterprise services via persistent outbound WebSocket",
6
6
  "openclaw": {
package/src/channel.ts CHANGED
@@ -67,7 +67,7 @@ export const xiayouPlugin = {
67
67
  reactions: false,
68
68
  threads: false,
69
69
  nativeCommands: false,
70
- blockStreaming: false,
70
+ blockStreaming: true,
71
71
  },
72
72
 
73
73
  reload: { configPrefixes: ["channels.xiaoyou"] },
@@ -136,6 +136,7 @@ export const xiayouPlugin = {
136
136
  const senderId = msg.senderId;
137
137
  const senderName = msg.senderName ?? msg.senderId;
138
138
  const conversationId = msg.conversationId;
139
+ const inboundMessageId = msg.messageId;
139
140
  const text = msg.text ?? "";
140
141
  const accountId = account?.accountId || "default";
141
142
 
@@ -179,7 +180,19 @@ export const xiayouPlugin = {
179
180
  Timestamp: Date.now(),
180
181
  });
181
182
 
182
- // 4. 分发并获取回复
183
+ // 4. 分发并获取回复(流式 — block 级)
184
+ //
185
+ // OpenClaw 2026.3.x 的 block streaming 机制:
186
+ // capabilities.blockStreaming = true 告诉 Gateway 按 block 逐个调用 deliver,
187
+ // 而非等全部生成完毕。每个 block(段落/代码块等)生成完成后立即 deliver。
188
+ // 我们将每次 deliver 作为一个 reply_chunk 推送给企业服务,
189
+ // dispatch promise resolve 后发送 reply_end 标记。
190
+ //
191
+ const replyMessageId = `xiaoyou-${Date.now()}`;
192
+ let chunkSeq = 0;
193
+ let fullText = "";
194
+ let replyEndSent = false;
195
+
183
196
  await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
184
197
  ctx: inboundCtx,
185
198
  cfg,
@@ -188,19 +201,55 @@ export const xiayouPlugin = {
188
201
  deliver: async (payload: any) => {
189
202
  const textToSend = payload.markdown || payload.text;
190
203
  if (!textToSend) return;
204
+
205
+ fullText += (fullText ? "\n" : "") + textToSend;
206
+
191
207
  if (_client && _client.isConnected()) {
192
- _client.sendReply({
193
- type: "reply",
208
+ // 每个 block 作为一个 chunk 推送
209
+ _client.sendChunk({
210
+ type: "reply_chunk",
194
211
  conversationId,
195
- messageId: `xiaoyou-${Date.now()}`,
196
- text: textToSend,
212
+ messageId: replyMessageId,
213
+ replyToMessageId: inboundMessageId,
214
+ delta: textToSend,
215
+ seq: chunkSeq++,
197
216
  timestamp: Date.now(),
198
217
  });
199
- logger.info(`[xiaoyou] reply sent to ${conversationId}`);
218
+ logger.info(`[xiaoyou] chunk #${chunkSeq} sent to ${conversationId}`);
219
+ }
220
+ },
221
+ onComplete: async () => {
222
+ // Gateway 调用 onComplete 表示所有 block 已推送完毕
223
+ replyEndSent = true;
224
+ if (_client && _client.isConnected()) {
225
+ _client.sendReplyEnd({
226
+ type: "reply_end",
227
+ conversationId,
228
+ messageId: replyMessageId,
229
+ replyToMessageId: inboundMessageId,
230
+ fullText,
231
+ timestamp: Date.now(),
232
+ });
233
+ logger.info(`[xiaoyou] streaming completed to ${conversationId} (${chunkSeq} blocks)${inboundMessageId ? ` (replyTo=${inboundMessageId})` : ""}`);
200
234
  }
201
235
  },
202
236
  },
203
237
  });
238
+
239
+ // dispatch resolve 后,如果 onComplete 未被调用(旧版本兼容),手动发 reply_end
240
+ if (!replyEndSent && chunkSeq > 0 && _client && _client.isConnected()) {
241
+ _client.sendReplyEnd({
242
+ type: "reply_end",
243
+ conversationId,
244
+ messageId: replyMessageId,
245
+ replyToMessageId: inboundMessageId,
246
+ fullText,
247
+ timestamp: Date.now(),
248
+ });
249
+ logger.info(`[xiaoyou] streaming completed (fallback) to ${conversationId} (${chunkSeq} blocks)${inboundMessageId ? ` (replyTo=${inboundMessageId})` : ""}`);
250
+ } else if (chunkSeq === 0) {
251
+ logger.warn(`[xiaoyou] no reply generated for ${conversationId}`);
252
+ }
204
253
  },
205
254
  });
206
255
 
@@ -11,6 +11,8 @@ import type {
11
11
  Frame,
12
12
  InboundMessage,
13
13
  OutboundReply,
14
+ OutboundReplyChunk,
15
+ OutboundReplyEnd,
14
16
  PongFrame,
15
17
  } from "./types.js";
16
18
 
@@ -31,6 +33,8 @@ export type EnterpriseClient = {
31
33
  connect: () => void;
32
34
  disconnect: () => void;
33
35
  sendReply: (reply: OutboundReply) => boolean;
36
+ sendChunk: (chunk: OutboundReplyChunk) => boolean;
37
+ sendReplyEnd: (end: OutboundReplyEnd) => boolean;
34
38
  isConnected: () => boolean;
35
39
  };
36
40
 
@@ -182,9 +186,27 @@ export function createEnterpriseClient(opts: EnterpriseClientOptions): Enterpris
182
186
  return true;
183
187
  }
184
188
 
189
+ function sendChunk(chunk: OutboundReplyChunk): boolean {
190
+ if (!ws || ws.readyState !== WebSocket.OPEN || !authenticated) {
191
+ logger.warn("[xiaoyou] cannot send chunk: not connected");
192
+ return false;
193
+ }
194
+ ws.send(JSON.stringify(chunk));
195
+ return true;
196
+ }
197
+
198
+ function sendReplyEnd(end: OutboundReplyEnd): boolean {
199
+ if (!ws || ws.readyState !== WebSocket.OPEN || !authenticated) {
200
+ logger.warn("[xiaoyou] cannot send reply_end: not connected");
201
+ return false;
202
+ }
203
+ ws.send(JSON.stringify(end));
204
+ return true;
205
+ }
206
+
185
207
  function isConnected(): boolean {
186
208
  return ws !== null && ws.readyState === WebSocket.OPEN && authenticated;
187
209
  }
188
210
 
189
- return { connect, disconnect, sendReply, isConnected };
211
+ return { connect, disconnect, sendReply, sendChunk, sendReplyEnd, isConnected };
190
212
  }
package/src/types.ts CHANGED
@@ -32,6 +32,7 @@ export type InboundMessage = {
32
32
  conversationId: string;
33
33
  senderId: string;
34
34
  senderName?: string;
35
+ messageId?: string;
35
36
  text?: string;
36
37
  attachments?: Attachment[];
37
38
  metadata?: Record<string, unknown>;
@@ -50,12 +51,40 @@ export type OutboundReply = {
50
51
  type: "reply";
51
52
  conversationId: string;
52
53
  messageId: string;
54
+ replyToMessageId?: string;
53
55
  text: string;
54
56
  mediaUrls?: string[];
55
57
  agentId?: string;
56
58
  timestamp: number;
57
59
  };
58
60
 
61
+ /** 流式回复片段(插件 → 企业服务) */
62
+ export type OutboundReplyChunk = {
63
+ type: "reply_chunk";
64
+ conversationId: string;
65
+ messageId: string;
66
+ replyToMessageId?: string;
67
+ /** 本次增量文本 */
68
+ delta: string;
69
+ /** 该 chunk 在整条回复中的序号(从 0 开始) */
70
+ seq: number;
71
+ agentId?: string;
72
+ timestamp: number;
73
+ };
74
+
75
+ /** 流式回复结束标记(插件 → 企业服务) */
76
+ export type OutboundReplyEnd = {
77
+ type: "reply_end";
78
+ conversationId: string;
79
+ messageId: string;
80
+ replyToMessageId?: string;
81
+ /** 完整文本(可选,方便企业侧校验拼接结果) */
82
+ fullText?: string;
83
+ mediaUrls?: string[];
84
+ agentId?: string;
85
+ timestamp: number;
86
+ };
87
+
59
88
  // ─── 控制帧 ─────────────────────────────────────────────
60
89
 
61
90
  export type AuthFrame = {
@@ -79,6 +108,8 @@ export type PongFrame = { type: "pong"; seq: number; ts: number };
79
108
  export type Frame =
80
109
  | InboundMessage
81
110
  | OutboundReply
111
+ | OutboundReplyChunk
112
+ | OutboundReplyEnd
82
113
  | AuthFrame
83
114
  | AuthResultFrame
84
115
  | PingFrame