@zhin.js/adapter-sandbox 7.0.12 → 7.0.15

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/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # @zhin.js/adapter-process
2
2
 
3
+ ## 7.0.15
4
+
5
+ ### Patch Changes
6
+
7
+ - bcc4c87: Probe Docker readiness asynchronously so Sandbox WebSocket permissions and messages are never blocked by the informational ready frame.
8
+
9
+ ## 7.0.14
10
+
11
+ ### Patch Changes
12
+
13
+ - 9e609f4: Stabilize Sandbox WebSocket regressions by waiting for the server-side connection registration and verifying the demo-scope gate independently from the informational ready frame.
14
+
15
+ ## 7.0.13
16
+
17
+ ### Patch Changes
18
+
19
+ - 974772e: Replace the user-facing `Prompt` vocabulary with the `UserInteraction` authoring surface for input, confirmation, and selection. Commands and handlers now expose `interaction`; IM Runtime exposes `createInteraction`; schema-driven endpoint collection is named `SchemaInteraction`. The old prompt-named interaction types and properties are removed rather than aliased. User interactions render through one canonical Markdown and keyboard/list presentation module shared by commands and Agent `ask_user` turns.
20
+
21
+ Extract the transport-neutral interaction contract into `@zhin.js/interaction`. A discriminated `ask()` API supports text, number, confirmation, single-select, multi-select, and typed lists with structured `title`, `description`, and `tip` content. Typed `sequence()` interactions return one result object keyed by step id, render progress, and retry invalid replies without leaking invalid values to callers.
22
+
23
+ Preserve AI Markdown and card command actions through outbound publishing. QQ delivers Markdown with native command buttons; KOOK, Discord, Telegram, DingTalk, and Lark/Feishu now declare and encode their native Markdown dialects while retaining each adapter's interaction policy. Correct QQ callback button action encoding and button style mapping.
24
+
25
+ - 5969c5b: Add SideEventGateway so adapters forward notice/request/system into HandlerIndex. HandlerContext now exposes only generation-safe capabilities and prompt ports; live Endpoint escape hatches are removed.
26
+ - Updated dependencies [5969c5b]
27
+ - Updated dependencies [d336a3f]
28
+ - Updated dependencies [5969c5b]
29
+ - Updated dependencies [5969c5b]
30
+ - Updated dependencies [974772e]
31
+ - Updated dependencies [5969c5b]
32
+ - Updated dependencies [2f786bd]
33
+ - Updated dependencies [1312ca0]
34
+ - Updated dependencies [985fa22]
35
+ - @zhin.js/im-contract@1.0.4
36
+ - @zhin.js/core@1.5.12
37
+ - @zhin.js/adapter@1.1.11
38
+ - @zhin.js/host-http@1.0.11
39
+ - @zhin.js/client@2.1.10
40
+ - zhin.js@6.0.12
41
+ - @zhin.js/page@1.0.12
42
+
3
43
  ## 7.0.12
4
44
 
5
45
  ### Patch Changes
@@ -3,7 +3,7 @@
3
3
  * Convention entry: discover `adapters/sandbox.ts` → defineAdapter.
4
4
  */
5
5
  import { defineAdapter } from 'zhin.js/adapter';
6
- import { messageGatewayToken } from '@zhin.js/core/runtime';
6
+ import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
7
7
  import { httpHostToken } from '@zhin.js/host-http';
8
8
  import { SandboxWsEndpoint } from "../lib/endpoint.js";
9
9
  import { resolveSandboxEndpoint, } from "../lib/protocol.js";
@@ -20,6 +20,7 @@ export default defineAdapter({
20
20
  return new SandboxWsEndpoint({
21
21
  id: context.id,
22
22
  gateway: context.use(messageGatewayToken),
23
+ sideEvents: context.use(sideEventGatewayToken),
23
24
  http: context.use(httpHostToken),
24
25
  defaults: resolveSandboxEndpoint(context.config),
25
26
  });
@@ -2,7 +2,7 @@
2
2
  * Convention entry: discover `adapters/sandbox.ts` → defineAdapter.
3
3
  */
4
4
  import { defineAdapter } from 'zhin.js/adapter';
5
- import { messageGatewayToken } from '@zhin.js/core/runtime';
5
+ import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
6
6
  import { httpHostToken } from '@zhin.js/host-http';
7
7
  import { SandboxWsEndpoint } from '../src/endpoint.js';
8
8
  import {
@@ -25,6 +25,7 @@ export default defineAdapter<SandboxAdapterConfig>({
25
25
  return new SandboxWsEndpoint({
26
26
  id: context.id,
27
27
  gateway: context.use(messageGatewayToken),
28
+ sideEvents: context.use(sideEventGatewayToken),
28
29
  http: context.use(httpHostToken),
29
30
  defaults: resolveSandboxEndpoint(context.config),
30
31
  });
package/lib/endpoint.d.ts CHANGED
@@ -1,11 +1,26 @@
1
1
  import type { EndpointInstance, EndpointSendRequest } from 'zhin.js/adapter';
2
- import type { MessageGateway } from '@zhin.js/core/runtime';
2
+ import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
3
3
  import type { HttpHost } from '@zhin.js/host-http';
4
4
  import type { CapabilityId } from 'zhin.js';
5
5
  import { type ResolvedSandboxBot } from './protocol.js';
6
+ type ShellIsolationStatus = Readonly<{
7
+ available: boolean;
8
+ provider: 'docker';
9
+ message: string;
10
+ }>;
11
+ type ShellIsolationProbe = () => Promise<ShellIsolationStatus>;
12
+ /**
13
+ * Share an in-flight readiness probe across reconnects while still allowing a
14
+ * later connection to refresh the informational status after it settles.
15
+ * Kept internal to this module's package surface; tests import it directly.
16
+ */
17
+ export declare function createSandboxReadinessGate(probe: ShellIsolationProbe): Readonly<{
18
+ afterProbe: (deliver: (status: ShellIsolationStatus) => void) => void;
19
+ }>;
6
20
  export interface SandboxEndpointOptions {
7
21
  readonly id: CapabilityId;
8
22
  readonly gateway: MessageGateway;
23
+ readonly sideEvents?: SideEventGateway;
9
24
  readonly http: HttpHost;
10
25
  readonly defaults: ResolvedSandboxBot;
11
26
  }
@@ -22,5 +37,6 @@ export declare class SandboxWsEndpoint implements EndpointInstance {
22
37
  open(): void;
23
38
  close(): void;
24
39
  stop(): void;
25
- send({ conversation, payload }: EndpointSendRequest): unknown;
40
+ send({ conversation, payload }: EndpointSendRequest): string;
26
41
  }
42
+ export {};
package/lib/endpoint.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * SandboxWsEndpoint — WebSocket lifecycle and MessageGateway bridge for /sandbox.
3
3
  */
4
4
  import { randomUUID } from 'node:crypto';
5
+ import { execFile } from 'node:child_process';
5
6
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
6
7
  import { bindSandboxWsSocket, formatSandboxOutbound, parseSandboxWsPayload, sandboxInboundConversation, whenWsOpen, } from './protocol.js';
7
8
  /**
@@ -38,6 +39,30 @@ function claimSandboxWsPath(http, name) {
38
39
  },
39
40
  };
40
41
  }
42
+ /**
43
+ * Share an in-flight readiness probe across reconnects while still allowing a
44
+ * later connection to refresh the informational status after it settles.
45
+ * Kept internal to this module's package surface; tests import it directly.
46
+ */
47
+ export function createSandboxReadinessGate(probe) {
48
+ let inFlight;
49
+ const acquire = () => {
50
+ if (inFlight)
51
+ return inFlight;
52
+ const pending = probe();
53
+ const shared = pending.finally(() => {
54
+ if (inFlight === shared)
55
+ inFlight = undefined;
56
+ });
57
+ inFlight = shared;
58
+ return shared;
59
+ };
60
+ return Object.freeze({
61
+ afterProbe: (deliver) => {
62
+ void acquire().then(deliver);
63
+ },
64
+ });
65
+ }
41
66
  /**
42
67
  * Sandbox 是本地开发/测试面,无平台社交图谱(好友/群/频道),
43
68
  * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
@@ -46,6 +71,7 @@ export class SandboxWsEndpoint {
46
71
  #logger;
47
72
  #options;
48
73
  #connections = new Map();
74
+ #readiness = createSandboxReadinessGate(probeShellIsolation);
49
75
  #wsHandleRelease;
50
76
  #wsPathRelease;
51
77
  #wsPath = '/sandbox';
@@ -105,7 +131,7 @@ export class SandboxWsEndpoint {
105
131
  }
106
132
  send({ conversation, payload }) {
107
133
  if (!this.#open)
108
- return undefined;
134
+ throw new Error('Sandbox Endpoint is not open');
109
135
  // Reply targets this endpoint's own live socket (fixed bot name, or the
110
136
  // only live random-name connection); conversation carries kind/id stamp.
111
137
  const connection = this.#connections.get(this.#options.defaults.id)
@@ -115,14 +141,14 @@ export class SandboxWsEndpoint {
115
141
  op: 'sandbox_send_miss',
116
142
  target: `${conversation.kind}:${conversation.id}`,
117
143
  }));
118
- return undefined;
144
+ throw new Error('Sandbox Endpoint has no live connection');
119
145
  }
120
146
  if (connection.placeholder) {
121
147
  this.#logger.debug(formatCompact({
122
148
  op: 'sandbox_send_placeholder',
123
149
  target: `${conversation.kind}:${conversation.id}`,
124
150
  }));
125
- return undefined;
151
+ throw new Error('Sandbox Endpoint has no live connection');
126
152
  }
127
153
  // Console UI filters by type+id; stamp the conversation onto outbound wire.
128
154
  connection.socket.send(formatSandboxOutbound(payload, {
@@ -137,7 +163,7 @@ export class SandboxWsEndpoint {
137
163
  channelType: conversation.kind,
138
164
  channelId: conversation.id,
139
165
  }));
140
- return payload;
166
+ return randomUUID();
141
167
  }
142
168
  /** Prefer a real (non-placeholder) socket when reply target key is wrong/stale. */
143
169
  #findLiveConnection() {
@@ -152,6 +178,7 @@ export class SandboxWsEndpoint {
152
178
  ? `sandbox-${randomUUID().slice(0, 8)}`
153
179
  : this.#options.defaults.id;
154
180
  const owner = this.#options.defaults.owner;
181
+ const canExecute = connection.authScope === 'full';
155
182
  const socket = connection.socket;
156
183
  // Fixed-name mode reuses `target`; dropping the prior entry without
157
184
  // closing its socket leaves a zombie browser tab that still looks
@@ -171,26 +198,37 @@ export class SandboxWsEndpoint {
171
198
  }
172
199
  const release = bindSandboxWsSocket(socket, {
173
200
  onMessage: (raw) => {
201
+ if (!canExecute) {
202
+ socket.send(JSON.stringify({
203
+ type: 'error',
204
+ id: owner,
205
+ endpoint: target,
206
+ content: [{ type: 'text', data: { text: '当前连接是只读演示权限,不能运行 Agent 任务。' } }],
207
+ timestamp: Date.now(),
208
+ }));
209
+ return;
210
+ }
174
211
  const parsed = parseSandboxWsPayload(raw);
175
- const sender = parsed.id || owner;
212
+ const sceneId = parsed.id || owner;
176
213
  const conversation = sandboxInboundConversation(String(this.#options.id), {
177
214
  type: parsed.type,
178
- id: sender,
215
+ id: sceneId,
179
216
  });
180
217
  this.#logger.debug(formatCompact({
181
218
  op: 'sandbox_recv',
182
219
  target,
183
- sender,
220
+ sender: owner,
184
221
  channelType: parsed.type,
185
- channelId: sender,
222
+ channelId: sceneId,
186
223
  text: parsed.text.slice(0, 80),
187
224
  }));
188
225
  // Don't gate on #open — inbound must always reach the gateway so
189
226
  // Command/AI dispatch and outbound replies work.
190
227
  void this.#options.gateway.receive({
191
228
  conversation,
229
+ ...(parsed.messageId ? { message: { conversation, id: parsed.messageId } } : {}),
192
230
  content: parsed.text,
193
- sender: { id: sender },
231
+ sender: { id: owner },
194
232
  endpointId: target,
195
233
  metadata: Object.freeze({
196
234
  type: parsed.type,
@@ -199,6 +237,7 @@ export class SandboxWsEndpoint {
199
237
  elements: parsed.content,
200
238
  timestamp: parsed.timestamp,
201
239
  ...(parsed.action ? { action: parsed.action } : {}),
240
+ ...(parsed.agentRun ? { sandboxAgentRun: parsed.agentRun } : {}),
202
241
  }),
203
242
  }).catch((err) => {
204
243
  this.#logger.warn(formatCompact({
@@ -227,25 +266,31 @@ export class SandboxWsEndpoint {
227
266
  });
228
267
  this.#connections.set(target, { target, owner, socket, release });
229
268
  this.#logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
230
- if (!this.#options.defaults.randomNamePerConnection) {
269
+ this.#readiness.afterProbe((shellIsolation) => {
270
+ const current = this.#connections.get(target);
271
+ if (!current || current.socket !== socket)
272
+ return;
231
273
  const readyPayload = JSON.stringify({
232
274
  type: 'ready',
233
275
  id: owner,
234
276
  endpoint: target,
277
+ workingDirectory: process.cwd(),
278
+ canExecute,
279
+ shellIsolation,
235
280
  content: [{
236
281
  type: 'text',
237
282
  data: {
238
283
  text: [
239
284
  `已连接 Sandbox「${target}」`,
240
285
  `与 Node Host 控制台沙盒协议一致(${this.#wsPath})`,
241
- '命令: help · ping · zt · status',
286
+ 'Agent 试验台会话已启用持久化运行配置。',
242
287
  ].join('\n'),
243
288
  },
244
289
  }],
245
290
  timestamp: Date.now(),
246
291
  });
247
292
  whenWsOpen(socket, () => socket.send(readyPayload));
248
- }
293
+ });
249
294
  }
250
295
  #ensurePlaceholder(name, owner) {
251
296
  if (this.#connections.has(name))
@@ -259,3 +304,32 @@ export class SandboxWsEndpoint {
259
304
  });
260
305
  }
261
306
  }
307
+ function probeShellIsolation() {
308
+ return new Promise((resolve) => {
309
+ execFile('docker', ['info', '--format', '{{.ServerVersion}}'], {
310
+ encoding: 'utf8',
311
+ timeout: 500,
312
+ }, (error, stdout) => {
313
+ if (!error) {
314
+ const version = stdout.trim();
315
+ resolve(Object.freeze({
316
+ available: true,
317
+ provider: 'docker',
318
+ message: version ? `Docker ${version}` : 'Docker ready',
319
+ }));
320
+ return;
321
+ }
322
+ resolve(Object.freeze({
323
+ available: false,
324
+ provider: 'docker',
325
+ message: error.killed || error.code === 'ETIMEDOUT'
326
+ ? 'Docker readiness check timed out'
327
+ : 'Docker daemon is unavailable',
328
+ }));
329
+ });
330
+ }).catch(() => Object.freeze({
331
+ available: false,
332
+ provider: 'docker',
333
+ message: 'Docker is unavailable',
334
+ }));
335
+ }
package/lib/protocol.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Sandbox WebSocket wire protocol helpers (no legacy Adapter/Endpoint). */
2
2
  import type { ConversationRef } from '@zhin.js/im-contract';
3
+ import { type SandboxAgentRunConfig } from './run-config.js';
3
4
  export type MessageType = 'private' | 'group' | 'guild' | 'direct' | 'channel';
4
5
  export interface MessageElement {
5
6
  readonly type: string;
@@ -40,6 +41,7 @@ export declare function bindSandboxWsSocket(ws: SandboxWsSocket, handlers: {
40
41
  export declare function parseSandboxWsPayload(raw: string): {
41
42
  type: MessageType;
42
43
  id: string;
44
+ messageId?: string;
43
45
  content: MessageElement[];
44
46
  timestamp: number;
45
47
  text: string;
@@ -47,6 +49,7 @@ export declare function parseSandboxWsPayload(raw: string): {
47
49
  id: string;
48
50
  payload: string;
49
51
  };
52
+ agentRun?: SandboxAgentRunConfig;
50
53
  };
51
54
  /**
52
55
  * 入站归一化 → ConversationRef。sandbox 无平台社交图谱:
package/lib/protocol.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { isMediaRef } from '@zhin.js/core';
4
4
  import { formatCompact, getLogger } from '@zhin.js/logger';
5
+ import { normalizeSandboxAgentRunConfig, } from './run-config.js';
5
6
  const logger = getLogger('sandbox');
6
7
  export function resolveSandboxEndpoint(appConfig) {
7
8
  const entry = appConfig.endpoints?.find((item) => item.context === 'sandbox');
@@ -19,7 +20,10 @@ export function resolveSandboxEndpoint(appConfig) {
19
20
  context: 'sandbox',
20
21
  id,
21
22
  owner,
22
- randomNamePerConnection: !fixedName,
23
+ // The endpoint id participates in the Agent session key. Keep it stable
24
+ // across browser reconnects and Host restarts so a persisted playground
25
+ // session resumes the same Agent context.
26
+ randomNamePerConnection: false,
23
27
  };
24
28
  }
25
29
  export function bindSandboxWsSocket(ws, handlers) {
@@ -105,7 +109,19 @@ export function parseSandboxWsPayload(raw) {
105
109
  ? payload.text
106
110
  : action?.payload ?? raw;
107
111
  }
108
- return { type, id, content, timestamp: payload.timestamp ?? Date.now(), text, action };
112
+ const agentRun = normalizeSandboxAgentRunConfig(payload.agentRun);
113
+ const rawMessageId = typeof payload.messageId === 'string' ? payload.messageId.trim() : '';
114
+ const messageId = /^[A-Za-z0-9._:-]{1,160}$/u.test(rawMessageId) ? rawMessageId : undefined;
115
+ return {
116
+ type,
117
+ id,
118
+ ...(messageId ? { messageId } : {}),
119
+ content,
120
+ timestamp: payload.timestamp ?? Date.now(),
121
+ text,
122
+ action,
123
+ ...(agentRun ? { agentRun } : {}),
124
+ };
109
125
  }
110
126
  /**
111
127
  * 入站归一化 → ConversationRef。sandbox 无平台社交图谱:
@@ -0,0 +1,10 @@
1
+ export type SandboxSafetyMode = 'read-only' | 'workspace-write' | 'danger-full-access';
2
+ export type SandboxApprovalMode = 'ask' | 'deny' | 'allow';
3
+ export interface SandboxAgentRunConfig {
4
+ readonly workingDirectory: string;
5
+ readonly safetyMode: SandboxSafetyMode;
6
+ readonly approvalMode: SandboxApprovalMode;
7
+ readonly networkAccess: boolean;
8
+ }
9
+ export declare const DEFAULT_SANDBOX_AGENT_RUN_CONFIG: SandboxAgentRunConfig;
10
+ export declare function normalizeSandboxAgentRunConfig(value: unknown): SandboxAgentRunConfig | undefined;
@@ -0,0 +1,30 @@
1
+ export const DEFAULT_SANDBOX_AGENT_RUN_CONFIG = Object.freeze({
2
+ workingDirectory: '',
3
+ safetyMode: 'workspace-write',
4
+ approvalMode: 'ask',
5
+ networkAccess: false,
6
+ });
7
+ const SAFETY_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
8
+ const APPROVAL_MODES = new Set(['ask', 'deny', 'allow']);
9
+ export function normalizeSandboxAgentRunConfig(value) {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value))
11
+ return undefined;
12
+ const input = value;
13
+ const workingDirectory = typeof input.workingDirectory === 'string'
14
+ ? input.workingDirectory.trim().slice(0, 4096)
15
+ : '';
16
+ const safetyMode = SAFETY_MODES.has(input.safetyMode)
17
+ ? input.safetyMode
18
+ : DEFAULT_SANDBOX_AGENT_RUN_CONFIG.safetyMode;
19
+ const approvalMode = APPROVAL_MODES.has(input.approvalMode)
20
+ ? input.approvalMode
21
+ : DEFAULT_SANDBOX_AGENT_RUN_CONFIG.approvalMode;
22
+ return Object.freeze({
23
+ workingDirectory,
24
+ safetyMode,
25
+ approvalMode,
26
+ // Full host access cannot be combined with a portable network namespace.
27
+ // Keep the contract honest: danger mode includes network authority.
28
+ networkAccess: safetyMode === 'danger-full-access' || input.networkAccess === true,
29
+ });
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-sandbox",
3
- "version": "7.0.12",
3
+ "version": "7.0.15",
4
4
  "description": "Zhin.js Sandbox adapter for Plugin Runtime (WebSocket /sandbox)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -48,14 +48,14 @@
48
48
  "dependencies": {
49
49
  "lucide-react": "^0.525.0",
50
50
  "react": "^19.2.8",
51
- "@zhin.js/adapter": "1.1.10",
52
- "@zhin.js/client": "2.1.9",
51
+ "@zhin.js/adapter": "1.1.11",
52
+ "@zhin.js/client": "2.1.10",
53
53
  "@zhin.js/console-contract": "1.0.1",
54
- "@zhin.js/core": "1.5.11",
55
- "@zhin.js/host-http": "1.0.10",
56
- "@zhin.js/im-contract": "1.0.3",
54
+ "@zhin.js/core": "1.5.12",
55
+ "@zhin.js/host-http": "1.0.11",
56
+ "@zhin.js/im-contract": "1.0.4",
57
57
  "@zhin.js/logger": "1.0.76",
58
- "@zhin.js/page": "1.0.11"
58
+ "@zhin.js/page": "1.0.12"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/react": "^19.2.18",
@@ -63,19 +63,19 @@
63
63
  "typescript": "^6.0.3",
64
64
  "vitest": "^4.1.10",
65
65
  "ws": "^8.21.1",
66
- "@zhin.js/pagemanager": "2.0.18",
67
- "@zhin.js/runtime": "1.0.12",
68
- "zhin.js": "6.0.11"
66
+ "@zhin.js/pagemanager": "2.0.19",
67
+ "@zhin.js/runtime": "1.0.13",
68
+ "zhin.js": "6.0.12"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "^19.0.0",
72
- "@zhin.js/adapter": "1.1.10",
73
- "@zhin.js/client": "2.1.9",
72
+ "@zhin.js/adapter": "1.1.11",
73
+ "@zhin.js/client": "2.1.10",
74
74
  "@zhin.js/console-contract": "1.0.1",
75
- "@zhin.js/core": "1.5.11",
76
- "@zhin.js/host-http": "1.0.10",
77
- "@zhin.js/page": "1.0.11",
78
- "zhin.js": "6.0.11"
75
+ "@zhin.js/core": "1.5.12",
76
+ "@zhin.js/host-http": "1.0.11",
77
+ "@zhin.js/page": "1.0.12",
78
+ "zhin.js": "6.0.12"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "react": {
@@ -10,14 +10,22 @@ const RichTextEditor = forwardRef(({ placeholder = '输入消息...', onSend, on
10
10
  return { text: '', segments: [] };
11
11
  let text = '';
12
12
  const segments = [];
13
- const nodes = Array.from(editorRef.current.childNodes);
14
- for (const node of nodes) {
13
+ const appendText = (value) => {
14
+ if (!value)
15
+ return;
16
+ text += value;
17
+ const previous = segments.at(-1);
18
+ if (previous?.type === 'text') {
19
+ previous.data.text = String(previous.data.text ?? '') + value;
20
+ }
21
+ else {
22
+ segments.push({ type: 'text', data: { text: value } });
23
+ }
24
+ };
25
+ const visit = (node) => {
15
26
  if (node.nodeType === Node.TEXT_NODE) {
16
27
  const textContent = node.textContent || '';
17
- if (textContent) {
18
- text += textContent;
19
- segments.push({ type: 'text', data: { text: textContent } });
20
- }
28
+ appendText(textContent);
21
29
  }
22
30
  else if (node.nodeType === Node.ELEMENT_NODE) {
23
31
  const el = node;
@@ -51,10 +59,17 @@ const RichTextEditor = forwardRef(({ placeholder = '输入消息...', onSend, on
51
59
  });
52
60
  }
53
61
  else if (el.tagName === 'BR') {
54
- text += '\n';
62
+ appendText('\n');
63
+ }
64
+ else {
65
+ const isBlock = el.tagName === 'DIV' || el.tagName === 'P';
66
+ if (isBlock && text && !text.endsWith('\n'))
67
+ appendText('\n');
68
+ Array.from(el.childNodes).forEach(visit);
55
69
  }
56
70
  }
57
- }
71
+ };
72
+ Array.from(editorRef.current.childNodes).forEach(visit);
58
73
  return { text, segments };
59
74
  };
60
75
  // 插入表情
@@ -37,15 +37,22 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
37
37
 
38
38
  let text = ''
39
39
  const segments: MessageSegment[] = []
40
- const nodes = Array.from(editorRef.current.childNodes)
41
40
 
42
- for (const node of nodes) {
41
+ const appendText = (value: string) => {
42
+ if (!value) return
43
+ text += value
44
+ const previous = segments.at(-1)
45
+ if (previous?.type === 'text') {
46
+ previous.data.text = String(previous.data.text ?? '') + value
47
+ } else {
48
+ segments.push({ type: 'text', data: { text: value } })
49
+ }
50
+ }
51
+
52
+ const visit = (node: Node) => {
43
53
  if (node.nodeType === Node.TEXT_NODE) {
44
54
  const textContent = node.textContent || ''
45
- if (textContent) {
46
- text += textContent
47
- segments.push({ type: 'text', data: { text: textContent } })
48
- }
55
+ appendText(textContent)
49
56
  } else if (node.nodeType === Node.ELEMENT_NODE) {
50
57
  const el = node as HTMLElement
51
58
 
@@ -74,11 +81,17 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
74
81
  data: id ? { target: id, name } : { target: name, name },
75
82
  })
76
83
  } else if (el.tagName === 'BR') {
77
- text += '\n'
84
+ appendText('\n')
85
+ } else {
86
+ const isBlock = el.tagName === 'DIV' || el.tagName === 'P'
87
+ if (isBlock && text && !text.endsWith('\n')) appendText('\n')
88
+ Array.from(el.childNodes).forEach(visit)
78
89
  }
79
90
  }
80
91
  }
81
92
 
93
+ Array.from(editorRef.current.childNodes).forEach(visit)
94
+
82
95
  return { text, segments }
83
96
  }
84
97
 
@@ -426,4 +439,3 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
426
439
  RichTextEditor.displayName = 'RichTextEditor'
427
440
 
428
441
  export default RichTextEditor
429
-