@xgjktech/xg_cwork_im 1.0.0 → 1.0.3

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/src/connection.ts CHANGED
@@ -4,18 +4,45 @@
4
4
  * 功能:
5
5
  * - 建立 wss://<baseUrl>/ws-notify/websocket?accessToken=<token> 长连接
6
6
  * - 接收 robotMention 消息并回调
7
+ * - 支持基于 WebSocket 的 AI 回复流式推送(START/CHUNK/END 协议)
7
8
  * - 指数退避 + 抖动的自动重连机制
8
9
  * - 主动发送 WebSocket ping 保活,超时自动重连
9
10
  */
10
11
 
12
+ import { randomUUID } from "node:crypto";
11
13
  import WebSocket from "ws";
12
14
  import type { Log } from "./auth.js";
13
- import type { WsMessage, WsMessageParams, XgImConfig } from "./types.js";
15
+ import type { WsMessage, XgImConfig } from "./types.js";
14
16
 
15
17
  export type OnMessageCallback = (msg: WsMessage) => void;
16
18
 
17
- /** 心跳间隔(ms):每 30 秒发一次 ping,防止连接被后台剔除 */
18
- const PING_INTERVAL_MS = 30_000;
19
+ /**
20
+ * CHUNK 载荷:发送端带 seq,便于集群/跨服务按序重排。
21
+ * - isThinking=true 为思考阶段,seq 从 0 递增(thinkSeq);
22
+ * - isThinking=false 为回答阶段,seq 从 0 递增(answerSeq)。
23
+ */
24
+ export interface ImStreamChunkData {
25
+ isThinking: boolean;
26
+ content: string;
27
+ /** 当前阶段内的序号,think 与 answer 各自从 0 递增 */
28
+ seq: number;
29
+ }
30
+
31
+ /**
32
+ * 基于 WebSocket 的流式消息客户端。
33
+ *
34
+ * - start(): 发送 START,等待 ack,返回 msgId。
35
+ * - chunk(): 基于 msgId 发送增量文本,data 中带 seq(think/answer 阶段分开)。
36
+ * - end(): 发送 END,通知服务端结束本次流式消息。
37
+ */
38
+ export interface ImStreamClient {
39
+ start: (opts: { groupId?: string; toUserId?: string }) => Promise<{ msgId: string }>;
40
+ chunk: (msgId: string, data: ImStreamChunkData) => Promise<void>;
41
+ end: (msgId: string, reason: string) => Promise<void>;
42
+ }
43
+
44
+ /** 心跳间隔(ms):每 20 秒发一次 ping,防止连接被后台剔除 */
45
+ const PING_INTERVAL_MS = 20_000;
19
46
 
20
47
  /** 计算指数退避延迟(带随机抖动) */
21
48
  function calcDelay(
@@ -39,8 +66,8 @@ export function startWebSocket(
39
66
  token: string,
40
67
  onMessage: OnMessageCallback,
41
68
  log: Log,
42
- ): { stop: () => void } {
43
- const maxAttempts = config.maxConnectionAttempts ?? 10;
69
+ ): { stop: () => void; streamClient: ImStreamClient } {
70
+ const maxAttempts = config.maxConnectionAttempts ?? 200;
44
71
  const initialDelay = config.initialReconnectDelay ?? 1_000;
45
72
  const maxDelay = config.maxReconnectDelay ?? 60_000;
46
73
  const jitter = config.reconnectJitter ?? 0.3;
@@ -64,6 +91,14 @@ export function startWebSocket(
64
91
  let pingTimer: ReturnType<typeof setInterval> | null = null;
65
92
  let attempt = 0;
66
93
 
94
+ /**
95
+ * clientMsgId -> resolver(msgId)
96
+ *
97
+ * - 当发送 START 时,会生成一个 clientMsgId,并在此处挂一个 resolver。
98
+ * - 收到 im_stream_msg_ack 后,根据 clientMsgId 触发 resolver,返回服务端生成的 msgId。
99
+ */
100
+ const pendingAcks = new Map<string, (msgId: string) => void>();
101
+
67
102
  /** 清除心跳定时器 */
68
103
  function clearHeartbeat(): void {
69
104
  if (pingTimer !== null) {
@@ -97,6 +132,17 @@ export function startWebSocket(
97
132
  }, PING_INTERVAL_MS);
98
133
  }
99
134
 
135
+ /**
136
+ * 确保当前存在可用的 WebSocket 连接。
137
+ * 若不存在或未处于 OPEN 状态,则抛出错误,由上层决定是否回退为非流式模式。
138
+ */
139
+ function ensureSocketReady(): WebSocket {
140
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
141
+ throw new Error(`${logPrefix} WebSocket is not ready for sending stream message`);
142
+ }
143
+ return ws;
144
+ }
145
+
100
146
  function connect(): void {
101
147
  if (stopped) return;
102
148
 
@@ -119,9 +165,9 @@ export function startWebSocket(
119
165
  return;
120
166
  }
121
167
 
122
- let parsed: WsMessage;
168
+ let parsed: any;
123
169
  try {
124
- parsed = JSON.parse(rawString) as WsMessage;
170
+ parsed = JSON.parse(rawString) as unknown;
125
171
  } catch {
126
172
  // 如果不是 JSON,说明是心跳或者其他内容,忽略即可
127
173
  // 如果开启 debug 还是可以打印日志
@@ -133,17 +179,33 @@ export function startWebSocket(
133
179
 
134
180
  log.info(`${logPrefix} ⇐ WS message received: cmd=${parsed.cmd}`);
135
181
 
136
- // 这块不需要了,因为上面已经处理了纯文本 pong
137
-
182
+ // 处理流式消息 ack:im_stream_msg_ack
183
+ if (parsed.cmd === "im_stream_msg_ack" && parsed.params) {
184
+ const clientMsgId: string | undefined = parsed.params.clientMsgId;
185
+ const msgId: string | undefined = parsed.params.msgId;
186
+ if (clientMsgId && msgId) {
187
+ const resolver = pendingAcks.get(clientMsgId);
188
+ if (resolver) {
189
+ pendingAcks.delete(clientMsgId);
190
+ resolver(msgId);
191
+ log.info(`${logPrefix} ⇐ im_stream_msg_ack received: clientMsgId=${clientMsgId} msgId=${msgId}`);
192
+ } else if (config.debug) {
193
+ log.debug?.(`${logPrefix} im_stream_msg_ack with unknown clientMsgId=${clientMsgId}`);
194
+ }
195
+ } else if (config.debug) {
196
+ log.debug?.(`${logPrefix} im_stream_msg_ack missing clientMsgId or msgId`);
197
+ }
198
+ return;
199
+ }
138
200
 
139
- // 处理包含 params 的事件(无论是 robotMention 还是普通 message
201
+ // 处理包含 params 的事件(robotMention / groupMessage
140
202
  if (parsed.params && (parsed.cmd === "robotMention" || parsed.cmd === "groupMessage")) {
141
203
  if (parsed.cmd === "robotMention") {
142
204
  log.info(`${logPrefix} 📨 robotMention received: groupId=${parsed.params.groupId} msgId=${parsed.params.msgId}`);
143
205
  }
144
206
  // onMessage 是 async 函数,必须用 .catch() 捕获异步错误
145
207
  // 否则 await 之后的错误会变成未处理的 Promise rejection 被静默丢弃
146
- Promise.resolve(onMessage(parsed)).catch((err: unknown) => {
208
+ Promise.resolve(onMessage(parsed as WsMessage)).catch((err: unknown) => {
147
209
  log.error(`${logPrefix} onMessage async error: ${String(err)}`);
148
210
  });
149
211
  }
@@ -190,6 +252,82 @@ export function startWebSocket(
190
252
  // 首次连接
191
253
  connect();
192
254
 
255
+ const streamClient: ImStreamClient = {
256
+ async start(opts: { groupId?: string; toUserId?: string }): Promise<{ msgId: string }> {
257
+ const socket = ensureSocketReady();
258
+ const clientMsgId = randomUUID();
259
+
260
+ const payload = {
261
+ cmd: "im_stream_msg",
262
+ params: {
263
+ event: "START",
264
+ clientMsgId,
265
+ data: {
266
+ groupId: opts.groupId,
267
+ toUserId: opts.toUserId,
268
+ },
269
+ },
270
+ };
271
+
272
+ log.info(`${logPrefix} ⇒ WS START stream: clientMsgId=${clientMsgId} groupId=${opts.groupId ?? ""} toUserId=${opts.toUserId ?? ""}`);
273
+
274
+ const msgId = await new Promise<string>((resolve, reject) => {
275
+ pendingAcks.set(clientMsgId, resolve);
276
+
277
+ socket.send(JSON.stringify(payload), (err) => {
278
+ if (err) {
279
+ pendingAcks.delete(clientMsgId);
280
+ reject(err);
281
+ }
282
+ });
283
+
284
+ // 简单超时保护,避免 ack 永远不到导致 Promise 悬挂
285
+ setTimeout(() => {
286
+ if (pendingAcks.has(clientMsgId)) {
287
+ pendingAcks.delete(clientMsgId);
288
+ reject(new Error(`${logPrefix} START stream ack timeout for clientMsgId=${clientMsgId}`));
289
+ }
290
+ }, 10_000);
291
+ });
292
+
293
+ return { msgId };
294
+ },
295
+
296
+ async chunk(msgId: string, data: ImStreamChunkData): Promise<void> {
297
+ const socket = ensureSocketReady();
298
+ const payload = {
299
+ cmd: "im_stream_msg",
300
+ params: {
301
+ event: "CHUNK",
302
+ msgId,
303
+ data: {
304
+ isThinking: data.isThinking,
305
+ content: data.content,
306
+ seq: data.seq,
307
+ },
308
+ },
309
+ };
310
+ if (config.debug) {
311
+ log.debug?.(`${logPrefix} ⇒ WS CHUNK stream: msgId=${msgId} isThinking=${data.isThinking} seq=${data.seq} length=${data.content.length}`);
312
+ }
313
+ socket.send(JSON.stringify(payload));
314
+ },
315
+
316
+ async end(msgId: string, reason: string): Promise<void> {
317
+ const socket = ensureSocketReady();
318
+ const payload = {
319
+ cmd: "im_stream_msg",
320
+ params: {
321
+ event: "END",
322
+ msgId,
323
+ data: { reason },
324
+ },
325
+ };
326
+ log.info(`${logPrefix} ⇒ WS END stream: msgId=${msgId} reason=${reason}`);
327
+ socket.send(JSON.stringify(payload));
328
+ },
329
+ };
330
+
193
331
  return {
194
332
  stop(): void {
195
333
  if (stopped) return;
@@ -208,5 +346,6 @@ export function startWebSocket(
208
346
  }
209
347
  ws = null;
210
348
  },
349
+ streamClient,
211
350
  };
212
351
  }
@@ -18,6 +18,16 @@ const GroupHistoryParams = Type.Object({
18
18
  groupId: Type.String({
19
19
  description: "群聊 ID,即当前消息来自的群组唯一标识符",
20
20
  }),
21
+ userId: Type.Optional(
22
+ Type.String({
23
+ description: "(可选)以哪个用户身份拉取历史消息的 userId,普通对话场景建议传当前发消息用户 ID;若为空则按后台默认身份查询",
24
+ }),
25
+ ),
26
+ accountId: Type.Optional(
27
+ Type.String({
28
+ description: "(可选)要使用的 xg_cwork_im 账户下标(数字字符串,默认 0)",
29
+ }),
30
+ ),
21
31
  limit: Type.Optional(
22
32
  Type.Number({
23
33
  description: "需要获取的消息条数(5~30),默认 10",
@@ -30,17 +40,33 @@ const GroupHistoryParams = Type.Object({
30
40
 
31
41
  // ─── 内部 HTTP 工具 ──────────────────────────────────────────────────────────
32
42
 
33
- /** 从 OpenClaw 配置中提取 xg_cwork_im 配置(支持多账户取第一个) */
43
+ /** 从 OpenClaw 配置中提取 xg_cwork_im 顶层配置(保留 accounts 以便多账户路由) */
34
44
  export function extractXgCworkImConfig(cfg: unknown): XgImConfig | null {
35
45
  const raw = (cfg as Record<string, Record<string, unknown>>)?.channels?.xg_cwork_im as XgImConfig | undefined;
36
46
  if (!raw?.baseUrl) return null;
47
+ return raw;
48
+ }
37
49
 
38
- // 如果有多账户,把 accounts[0] 的 appKey 合并到顶层(用于查询历史)
39
- if (raw.accounts && raw.accounts.length > 0) {
40
- const first = raw.accounts[0];
41
- return { ...raw, ...first };
50
+ /**
51
+ * 根据 accountId 解析具体账号配置。
52
+ *
53
+ * - 若传入 accountId 且能解析为有效下标,则优先使用对应 accounts[accountId]。
54
+ * - 否则:有 accounts 时默认使用 accounts[0];无 accounts 时使用顶层配置。
55
+ */
56
+ export function resolveAccountConfig(base: XgImConfig, accountId?: string | null): XgImConfig {
57
+ const accounts = base.accounts ?? [];
58
+ if (accounts.length === 0) {
59
+ return base;
42
60
  }
43
- return raw;
61
+ let idx = 0;
62
+ if (accountId && accountId !== "default") {
63
+ const parsed = Number.parseInt(accountId, 10);
64
+ if (!Number.isNaN(parsed) && parsed >= 0 && parsed < accounts.length) {
65
+ idx = parsed;
66
+ }
67
+ }
68
+ const acc = accounts[idx] ?? accounts[0];
69
+ return { ...base, ...acc };
44
70
  }
45
71
 
46
72
  /** 调用 IM 接口拉取群聊最近 N 条消息 */
@@ -49,15 +75,24 @@ async function fetchGroupHistory(
49
75
  token: string,
50
76
  groupId: string,
51
77
  limit: number,
78
+ userId?: string,
52
79
  ): Promise<WsMessageParams[]> {
53
80
  const url = `${config.baseUrl}/im/message/getLatestMsgListForAI`;
54
81
 
82
+ const params: Record<string, unknown> = {
83
+ groupId,
84
+ msgCount: limit,
85
+ };
86
+ if (userId) {
87
+ params.userId = userId;
88
+ }
89
+
55
90
  const res = await axios.get<{
56
91
  resultCode: number;
57
92
  message?: string;
58
93
  data?: WsMessageParams[];
59
94
  }>(url, {
60
- params: { groupId, msgCount: limit },
95
+ params,
61
96
  headers: { "access-token": token },
62
97
  timeout: 8_000,
63
98
  });
@@ -78,9 +113,9 @@ async function fetchGroupHistory(
78
113
  export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
79
114
  return {
80
115
  name: "xg_cwork_im_get_group_chat_history",
81
- label: "获取群聊历史记录",
116
+ label: "【xg_cwork_im】获取群聊历史记录",
82
117
  description: [
83
- "【必须遵守】获取此 IM 群聊的最近消息历史,用于理解对话上下文背景。",
118
+ "【仅限 xg_cwork_im 通道】获取当前 IM 群聊的最近消息历史,用于理解对话上下文背景。",
84
119
  "",
85
120
  "以下情况你必须先调用此工具,不得直接向用户反问或要求澄清:",
86
121
  "- 用户使用了指代词却没说清楚指的是什么(如'这'、'那'、'上面说的'、'刚才提到的'、'这两家公司'等)",
@@ -88,20 +123,21 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
88
123
  "- 你不确定用户问题的背景或主语时",
89
124
  "",
90
125
  "调用此工具后你会获得最近 N 条消息,用于还原上下文再给出回答。",
91
- "groupId 从会话元数据 group_channel 字段中解析(格式:agent:main:xgim:group:<groupId>)。",
126
+ "groupId 必须从会话元数据 group_channel 字段中解析,当且仅当其前缀为 agent:main:xg_cwork_im:group:<groupId> 时才可以调用本工具。",
92
127
  "",
93
128
  "【只有在以下情况才可以不调用】:用户的问题明确且自洽,不依赖任何历史对话。",
94
129
  ].join("\n"),
95
130
  parameters: GroupHistoryParams,
96
131
  async execute(_toolCallId: string, params: Static<typeof GroupHistoryParams>) {
97
132
  const limit = params.limit ?? 10;
98
- const { groupId } = params;
133
+ const { groupId, accountId, userId } = params;
99
134
 
100
135
  console.log(`[cwork_im][tool] xg_cwork_im_get_group_chat_history called: groupId=${groupId} limit=${limit}`);
101
136
 
102
137
  let identity: BotIdentity;
103
138
  try {
104
- identity = await getToken(config);
139
+ const accountConfig = resolveAccountConfig(config, accountId);
140
+ identity = await getToken(accountConfig);
105
141
  console.log(`[cwork_im][tool] token acquired for userId=${identity.userId}`);
106
142
  } catch (err: unknown) {
107
143
  return jsonResult({
@@ -113,8 +149,11 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
113
149
 
114
150
  let messages: WsMessageParams[];
115
151
  try {
116
- messages = await fetchGroupHistory(config, identity.token, groupId, limit);
117
- console.log(`[cwork_im][tool] fetched ${messages.length} messages from group ${groupId}`);
152
+ const accountConfig = resolveAccountConfig(config, accountId);
153
+ messages = await fetchGroupHistory(accountConfig, identity.token, groupId, limit, userId);
154
+ console.log(
155
+ `[cwork_im][tool] fetched ${messages.length} messages from group ${groupId} using accountId=${accountId ?? "0"} userId=${userId ?? "N/A"}`,
156
+ );
118
157
  } catch (err: unknown) {
119
158
  return jsonResult({
120
159
  ok: false,
@@ -127,8 +166,10 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
127
166
  const formatted = messages
128
167
  .filter((m) => m.msgContent?.type === "text")
129
168
  .map((m) => {
130
- const isBot = m.fromUserId === identity.userId;
131
- const sender = isBot ? "[AI]" : m.fromUserName || m.fromUserId;
169
+ const senderId = m.userInfo?.id ?? "";
170
+ const senderName = m.userInfo?.name || senderId || "未知用户";
171
+ const isBot = senderId === identity.userId;
172
+ const sender = isBot ? "[AI]" : senderName;
132
173
  const ts = m.timestamp ?? m.msgSendTime ?? 0;
133
174
  const time = new Date(ts).toLocaleTimeString("zh-CN", { hour12: false });
134
175
  return `[${time}] ${sender}: ${m.msgContent.text}`;
@@ -14,6 +14,7 @@ import { jsonResult } from "openclaw/plugin-sdk";
14
14
  import { getToken } from "./auth.js";
15
15
  import { sendTextMessage } from "./send-service.js";
16
16
  import type { BotIdentity, XgImConfig } from "./types.js";
17
+ import { resolveAccountConfig } from "./group-history-tool.js";
17
18
 
18
19
  // ─── 工具参数 Schema ─────────────────────────────────────────────────────────
19
20
 
@@ -21,6 +22,11 @@ const SendGroupMessageParams = Type.Object({
21
22
  groupId: Type.String({
22
23
  description: "目标群聊 ID(IM 系统的群组唯一标识符,即 groupId / gid)",
23
24
  }),
25
+ accountId: Type.Optional(
26
+ Type.String({
27
+ description: "(可选)要使用的 xg_cwork_im 账户下标(数字字符串,默认 0)",
28
+ }),
29
+ ),
24
30
  text: Type.String({
25
31
  description: "要发送的消息内容(纯文本)",
26
32
  }),
@@ -41,23 +47,26 @@ const SendGroupMessageParams = Type.Object({
41
47
  export function buildSendGroupMessageTool(config: XgImConfig): AnyAgentTool {
42
48
  return {
43
49
  name: "xg_cwork_im_send_group_message",
44
- label: "发送群聊消息",
50
+ label: "【xg_cwork_im】发送群聊消息",
45
51
  description: [
46
- "向指定 IM 群聊发送一条消息。",
52
+ "【仅限 xg_cwork_im 通道】向指定 IM 群聊发送一条消息。",
47
53
  "适用于定时任务主动推送通知、AI 分析后发起提醒等场景。",
48
54
  "消息将以机器人身份发出,可选择 @ 特定用户。",
49
- "groupId 为 IM 群组的唯一 ID(不是群名称)。",
55
+ "groupId 为 IM 群组的唯一 ID(不是群名称),通常从当前对话的 group_channel 字段中解析(格式:agent:main:xg_cwork_im:group:<groupId>)。",
50
56
  ].join("\n"),
51
57
  parameters: SendGroupMessageParams,
52
58
  async execute(_toolCallId: string, params: Static<typeof SendGroupMessageParams>) {
53
- const { groupId, text, atUserIds = [] } = params;
59
+ const { groupId, text, atUserIds = [], accountId } = params;
54
60
 
55
61
  console.log(`[cwork_im][tool] xg_cwork_im_send_group_message called: groupId=${groupId} atUsers=${JSON.stringify(atUserIds)}`);
56
62
 
57
63
  let identity: BotIdentity;
58
64
  try {
59
- identity = await getToken(config);
60
- console.log(`[cwork_im][tool] xg_cwork_im_send_group_message token acquired for userId=${identity.userId}`);
65
+ const accountConfig = resolveAccountConfig(config, accountId);
66
+ identity = await getToken(accountConfig);
67
+ console.log(
68
+ `[cwork_im][tool] xg_cwork_im_send_group_message token acquired for userId=${identity.userId} using accountId=${accountId ?? "0"}`,
69
+ );
61
70
  } catch (err: unknown) {
62
71
  return jsonResult({
63
72
  ok: false,
@@ -66,8 +75,11 @@ export function buildSendGroupMessageTool(config: XgImConfig): AnyAgentTool {
66
75
  }
67
76
 
68
77
  try {
69
- await sendTextMessage(config, identity.token, groupId, text, atUserIds);
70
- console.log(`[cwork_im][tool] xg_cwork_im_send_group_message success: groupId=${groupId}`);
78
+ const accountConfig = resolveAccountConfig(config, accountId);
79
+ await sendTextMessage(accountConfig, identity.token, groupId, text, atUserIds);
80
+ console.log(
81
+ `[cwork_im][tool] xg_cwork_im_send_group_message success: groupId=${groupId} using accountId=${accountId ?? "0"}`,
82
+ );
71
83
  } catch (err: unknown) {
72
84
  return jsonResult({
73
85
  ok: false,
@@ -17,6 +17,8 @@ import type { GetLatestMsgListResponse, SendMessageBody, WsMessageParams, XgImCo
17
17
  * @param content 消息内容文本
18
18
  * @param atUserIds 需要 @ 的用户 ID 列表(可为空)
19
19
  * @param log 日志接口
20
+ * @param msgId 可选的业务消息 ID(用于覆盖/更新已有消息)
21
+ * @param reply 可选的被回复消息信息(用于在 IM 中建立“回复某条消息”的关联)
20
22
  */
21
23
  export async function sendTextMessage(
22
24
  config: XgImConfig,
@@ -25,6 +27,8 @@ export async function sendTextMessage(
25
27
  content: string,
26
28
  atUserIds: string[] = [],
27
29
  log?: Log,
30
+ msgId?: string,
31
+ reply?: SendMessageBody["reply"],
28
32
  ): Promise<void> {
29
33
  const url = `${config.baseUrl}/im/message/send`;
30
34
 
@@ -33,6 +37,8 @@ export async function sendTextMessage(
33
37
  groupId,
34
38
  text: content,
35
39
  ...(atUserIds.length > 0 ? { atUserIds } : {}),
40
+ ...(msgId ? { msgId } : {}),
41
+ ...(reply ? { reply } : {}),
36
42
  };
37
43
 
38
44
  log?.info(`[cwork_im:send] POST ${url} groupId=${groupId} atUsers=${JSON.stringify(atUserIds)}`);
package/src/types.ts CHANGED
@@ -110,8 +110,18 @@ export interface WsMessage {
110
110
  export interface WsMessageParams {
111
111
  msgId: string;
112
112
  groupId: string;
113
- fromUserId: string;
114
- fromUserName: string;
113
+ /** 发送人信息 */
114
+ userInfo: {
115
+ /** 发送人 ID */
116
+ id: string;
117
+ /** 发送人显示名 */
118
+ name: string;
119
+ /**
120
+ * 用户背景信息。
121
+ * - 若非空,需要透传给 AI(可能是一段 JSON 字符串)。
122
+ */
123
+ background?: string;
124
+ };
115
125
  msgContent: {
116
126
  text: string;
117
127
  type: string;
@@ -128,12 +138,38 @@ export interface WsMessageParams {
128
138
  // ─── IM 发送消息 ─────────────────────────────────────────────────────────────
129
139
 
130
140
  /** POST /im/message/send 请求体 */
141
+ export interface SendMessageReply {
142
+ /** 被回复消息ID */
143
+ targetMsgId: string;
144
+ /** 被回复消息发送者ID */
145
+ targetUserId: string;
146
+ /** 被回复消息发送者显示名 */
147
+ targetUserName: string;
148
+ /** 被回复消息摘要 */
149
+ previewText: string;
150
+ }
151
+
131
152
  export interface SendMessageBody {
132
153
  type: "TEXT" | "RICH_TEXT" | "VOICE";
133
154
  groupId?: string;
134
155
  toUserId?: string;
135
156
  text: string;
136
157
  atUserIds?: string[];
158
+ /**
159
+ * 业务侧生成的消息 ID。
160
+ *
161
+ * - 若不传:服务端按普通“新消息”处理。
162
+ * - 若传入:服务端可根据 msgId 更新已有的流式占位消息内容。
163
+ */
164
+ msgId?: string;
165
+
166
+ /**
167
+ * 被回复消息信息。
168
+ *
169
+ * - 若不传:视为普通消息。
170
+ * - 若传入:服务端可按 targetMsgId 建立“回复某条消息”的关联。
171
+ */
172
+ reply?: SendMessageReply;
137
173
  }
138
174
 
139
175
  /** GET /im/message/getLatestMsgListForAI 响应 */