openclaw-xiaoyou 1.2.5 → 1.3.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.
@@ -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.5",
3
+ "version": "1.3.1",
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"] },
@@ -180,7 +180,21 @@ export const xiayouPlugin = {
180
180
  Timestamp: Date.now(),
181
181
  });
182
182
 
183
- // 4. 分发并获取回复
183
+ // 4. 分发并获取回复(流式 — block 级)
184
+ //
185
+ // OpenClaw 2026.3.x 的 block streaming 机制:
186
+ // capabilities.blockStreaming = true 告诉 Gateway 按 block 逐个调用 deliver,
187
+ // 而非等全部生成完毕。每个 block(段落/代码块等)生成完成后立即 deliver。
188
+ // 统一使用 type="reply",通过 streamStatus 字段区分:
189
+ // streamStatus="chunk" — 增量片段
190
+ // streamStatus="end" — 流结束(text 为完整文本)
191
+ // 无 streamStatus — 一次性完整回复(向后兼容)
192
+ //
193
+ const replyMessageId = `xiaoyou-${Date.now()}`;
194
+ let chunkSeq = 0;
195
+ let fullText = "";
196
+ let replyEndSent = false;
197
+
184
198
  await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
185
199
  ctx: inboundCtx,
186
200
  cfg,
@@ -189,20 +203,56 @@ export const xiayouPlugin = {
189
203
  deliver: async (payload: any) => {
190
204
  const textToSend = payload.markdown || payload.text;
191
205
  if (!textToSend) return;
206
+
207
+ fullText += (fullText ? "\n" : "") + textToSend;
208
+
192
209
  if (_client && _client.isConnected()) {
193
210
  _client.sendReply({
194
211
  type: "reply",
195
212
  conversationId,
196
- messageId: `xiaoyou-${Date.now()}`,
213
+ messageId: replyMessageId,
197
214
  replyToMessageId: inboundMessageId,
198
215
  text: textToSend,
216
+ streamStatus: "chunk",
217
+ seq: chunkSeq++,
218
+ timestamp: Date.now(),
219
+ });
220
+ logger.info(`[xiaoyou] chunk #${chunkSeq} sent to ${conversationId}`);
221
+ }
222
+ },
223
+ onComplete: async () => {
224
+ replyEndSent = true;
225
+ if (_client && _client.isConnected()) {
226
+ _client.sendReply({
227
+ type: "reply",
228
+ conversationId,
229
+ messageId: replyMessageId,
230
+ replyToMessageId: inboundMessageId,
231
+ text: fullText,
232
+ streamStatus: "end",
199
233
  timestamp: Date.now(),
200
234
  });
201
- logger.info(`[xiaoyou] reply sent to ${conversationId}${inboundMessageId ? ` (replyTo=${inboundMessageId})` : ""}`);
235
+ logger.info(`[xiaoyou] stream end sent to ${conversationId} (${chunkSeq} chunks)${inboundMessageId ? ` (replyTo=${inboundMessageId})` : ""}`);
202
236
  }
203
237
  },
204
238
  },
205
239
  });
240
+
241
+ // dispatch resolve 后,如果 onComplete 未被调用(旧版本兼容),手动发 end
242
+ if (!replyEndSent && chunkSeq > 0 && _client && _client.isConnected()) {
243
+ _client.sendReply({
244
+ type: "reply",
245
+ conversationId,
246
+ messageId: replyMessageId,
247
+ replyToMessageId: inboundMessageId,
248
+ text: fullText,
249
+ streamStatus: "end",
250
+ timestamp: Date.now(),
251
+ });
252
+ logger.info(`[xiaoyou] stream end (fallback) sent to ${conversationId} (${chunkSeq} chunks)${inboundMessageId ? ` (replyTo=${inboundMessageId})` : ""}`);
253
+ } else if (chunkSeq === 0) {
254
+ logger.warn(`[xiaoyou] no reply generated for ${conversationId}`);
255
+ }
206
256
  },
207
257
  });
208
258
 
package/src/types.ts CHANGED
@@ -47,6 +47,14 @@ export type Attachment = {
47
47
 
48
48
  // ─── 出站回复(插件 → 企业服务)─────────────────────────
49
49
 
50
+ /**
51
+ * 流式状态标记:
52
+ * - "chunk" — 流式片段,text 为增量内容
53
+ * - "end" — 流结束,text 为完整文本
54
+ * - undefined — 非流式,一次性完整回复(向后兼容)
55
+ */
56
+ export type StreamStatus = "chunk" | "end";
57
+
50
58
  export type OutboundReply = {
51
59
  type: "reply";
52
60
  conversationId: string;
@@ -56,6 +64,10 @@ export type OutboundReply = {
56
64
  mediaUrls?: string[];
57
65
  agentId?: string;
58
66
  timestamp: number;
67
+ /** 流式标记:chunk=增量片段, end=流结束, 不传=一次性完整回复 */
68
+ streamStatus?: StreamStatus;
69
+ /** 流式片段序号(仅 streamStatus="chunk" 时有值,从 0 开始) */
70
+ seq?: number;
59
71
  };
60
72
 
61
73
  // ─── 控制帧 ─────────────────────────────────────────────