@zhin.js/adapter-sandbox 5.0.6 → 6.0.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.
package/pages/sandbox.tsx CHANGED
@@ -1,95 +1,17 @@
1
1
  import { definePage } from '@zhin.js/console-contract';
2
- import { useEffect, useRef, useState, type FormEvent } from 'react';
2
+ import SandboxChat from './SandboxChat';
3
3
 
4
4
  export const meta = definePage({
5
- title: 'Sandbox',
5
+ title: '沙盒',
6
6
  icon: 'Box',
7
7
  order: 10,
8
8
  });
9
9
 
10
10
  /**
11
- * Convention page (ADR 0046). Plugin Runtime Host also serves a vanilla WS shell
12
- * for this localName so local smoke does not depend on a CDN React graph.
11
+ * Convention page entry (ADR 0046).
12
+ * Restores the pre-runtime-migration Sandbox console UI (channels + rich text + faces).
13
+ * WebSocket targets Host `/sandbox` via zhin_api_base + token (see sandboxTransport.ts).
13
14
  */
14
15
  export default function SandboxPage() {
15
- const [connected, setConnected] = useState(false);
16
- const [input, setInput] = useState('');
17
- const [lines, setLines] = useState<readonly { readonly kind: 'in' | 'out'; readonly text: string }[]>([]);
18
- const wsRef = useRef<WebSocket | null>(null);
19
-
20
- useEffect(() => {
21
- const url = new URL('/sandbox', window.location.href);
22
- url.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
23
- const ws = new WebSocket(url);
24
- wsRef.current = ws;
25
- ws.addEventListener('open', () => setConnected(true));
26
- ws.addEventListener('close', () => setConnected(false));
27
- ws.addEventListener('message', (event) => {
28
- let text = String(event.data);
29
- try {
30
- const data = JSON.parse(text) as {
31
- content?: Array<{ data?: { text?: string } }> | string;
32
- };
33
- text = Array.isArray(data.content)
34
- ? data.content.map((segment) => segment?.data?.text ?? '').filter(Boolean).join('\n')
35
- : typeof data.content === 'string'
36
- ? data.content
37
- : text;
38
- } catch {
39
- /* keep raw */
40
- }
41
- setLines((previous) => [...previous, { kind: 'in', text }]);
42
- });
43
- return () => {
44
- ws.close();
45
- wsRef.current = null;
46
- };
47
- }, []);
48
-
49
- const onSubmit = (event: FormEvent) => {
50
- event.preventDefault();
51
- const text = input.trim();
52
- const ws = wsRef.current;
53
- if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
54
- ws.send(JSON.stringify({ text, timestamp: Date.now() }));
55
- setLines((previous) => [...previous, { kind: 'out', text }]);
56
- setInput('');
57
- };
58
-
59
- return (
60
- <div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 3rem)', maxWidth: 720, margin: '0 auto' }}>
61
- <p style={{ padding: '0.75rem 1rem', margin: 0, color: '#71717a' }}>
62
- {connected ? 'WebSocket /sandbox connected' : 'connecting…'}
63
- </p>
64
- <div style={{ flex: 1, overflow: 'auto', padding: '1rem', display: 'flex', flexDirection: 'column', gap: 8 }}>
65
- {lines.map((line, index) => (
66
- <div
67
- key={`${line.kind}-${index}-${line.text.slice(0, 12)}`}
68
- style={{
69
- alignSelf: line.kind === 'out' ? 'flex-end' : 'flex-start',
70
- background: line.kind === 'out' ? '#ccfbf1' : '#fff',
71
- border: '1px solid #e4e4e7',
72
- borderRadius: 10,
73
- padding: '0.6rem 0.8rem',
74
- maxWidth: '85%',
75
- whiteSpace: 'pre-wrap',
76
- }}
77
- >
78
- {line.text}
79
- </div>
80
- ))}
81
- </div>
82
- <form onSubmit={onSubmit} style={{ display: 'flex', gap: 8, padding: 12, borderTop: '1px solid #e4e4e7' }}>
83
- <input
84
- value={input}
85
- onChange={(event) => setInput(event.target.value)}
86
- placeholder="发送到 /sandbox …"
87
- style={{ flex: 1, padding: '0.55rem 0.75rem', borderRadius: 8, border: '1px solid #d4d4d8' }}
88
- />
89
- <button type="submit" style={{ padding: '0.55rem 0.9rem', border: 0, borderRadius: 8, background: '#0f766e', color: '#fff' }}>
90
- 发送
91
- </button>
92
- </form>
93
- </div>
94
- );
16
+ return <SandboxChat />;
95
17
  }
@@ -0,0 +1,45 @@
1
+ /** Same storage keys as `@zhin.js/client` remoteApi. */
2
+
3
+ export function getSandboxApiBase(): string {
4
+ if (typeof localStorage === 'undefined') {
5
+ return typeof window !== 'undefined' ? window.location.origin : '';
6
+ }
7
+ const stored = localStorage.getItem('zhin_api_base')?.trim();
8
+ if (stored) return stored.replace(/\/+$/u, '');
9
+ if (typeof window !== 'undefined') return window.location.origin;
10
+ return '';
11
+ }
12
+
13
+ export function getSandboxBearerToken(): string {
14
+ if (typeof window !== 'undefined') {
15
+ const runtime = (window as unknown as { __ZHIN_API_TOKEN?: string }).__ZHIN_API_TOKEN?.trim();
16
+ if (runtime) return runtime;
17
+ }
18
+ if (typeof localStorage === 'undefined') return '';
19
+ return (
20
+ localStorage.getItem('zhin_api_token')?.trim()
21
+ || localStorage.getItem('HTTP_TOKEN')?.trim()
22
+ || localStorage.getItem('zhin_http_token')?.trim()
23
+ || (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('zhin_api_token')?.trim() : '')
24
+ || ''
25
+ );
26
+ }
27
+
28
+ export function getSandboxAuthHeaders(): Record<string, string> {
29
+ const token = getSandboxBearerToken();
30
+ return token ? { Authorization: `Bearer ${token}` } : {};
31
+ }
32
+
33
+ /**
34
+ * Browser WebSocket cannot set Authorization reliably; pass token in query when needed.
35
+ * Uses stored Console API base so Remote Console (different origin) still hits the bot Host.
36
+ */
37
+ export function buildSandboxWebSocketUrl(base?: string): string {
38
+ const apiBase = (base ?? getSandboxApiBase()).replace(/\/+$/u, '');
39
+ const origin = apiBase || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
40
+ const wsUrl = new URL('/sandbox', `${origin}/`);
41
+ wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
42
+ const token = getSandboxBearerToken();
43
+ if (token) wsUrl.searchParams.set('token', token);
44
+ return wsUrl.href;
45
+ }
package/schema.json CHANGED
@@ -5,16 +5,36 @@
5
5
  "properties": {
6
6
  "endpoints": {
7
7
  "type": "array",
8
- "default": [],
8
+ "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(name 必填,其余覆盖顶层)",
9
9
  "items": {
10
10
  "type": "object",
11
- "additionalProperties": false,
11
+ "additionalProperties": true,
12
12
  "properties": {
13
- "context": { "type": "string" },
14
- "name": { "type": "string" },
15
- "owner": { "type": "string" }
16
- }
13
+ "name": {
14
+ "type": "string",
15
+ "description": "Sandbox bot name"
16
+ },
17
+ "context": {
18
+ "type": "string",
19
+ "description": "Sandbox context identifier"
20
+ },
21
+ "owner": {
22
+ "type": "string",
23
+ "description": "Sandbox owner user ID"
24
+ }
25
+ },
26
+ "required": [
27
+ "name"
28
+ ]
17
29
  }
30
+ },
31
+ "commandPrefix": {
32
+ "type": "string",
33
+ "default": "",
34
+ "description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
18
35
  }
19
- }
36
+ },
37
+ "required": [
38
+ "endpoints"
39
+ ]
20
40
  }
package/src/endpoint.ts CHANGED
@@ -18,6 +18,11 @@ import {
18
18
 
19
19
  const logger = getLogger('sandbox');
20
20
 
21
+ interface SandboxChannel {
22
+ readonly type: string;
23
+ readonly id: string;
24
+ }
25
+
21
26
  interface SandboxConnection {
22
27
  readonly target: string;
23
28
  readonly owner: string;
@@ -25,6 +30,11 @@ interface SandboxConnection {
25
30
  readonly release: () => void;
26
31
  /** true = 占位连接(尚无真实 WS 客户端),send 命中时按 miss 处理。 */
27
32
  readonly placeholder?: boolean;
33
+ /**
34
+ * 最近一条入站频道(Console UI 按 type+id 过滤气泡)。
35
+ * 出站必须带回,否则回复石沉大海。
36
+ */
37
+ lastChannel?: SandboxChannel;
28
38
  }
29
39
 
30
40
  export interface SandboxEndpointOptions {
@@ -79,7 +89,16 @@ export class SandboxWsEndpoint implements EndpointInstance {
79
89
  this.#open = false;
80
90
  this.#wsHandleRelease?.();
81
91
  this.#wsHandleRelease = undefined;
82
- for (const connection of this.#connections.values()) connection.release();
92
+ for (const connection of this.#connections.values()) {
93
+ connection.release();
94
+ if (!connection.placeholder) {
95
+ try {
96
+ connection.socket.close(1001, 'sandbox endpoint stopped');
97
+ } catch {
98
+ /* already closed */
99
+ }
100
+ }
101
+ }
83
102
  this.#connections.clear();
84
103
  this.#started = false;
85
104
  logger.debug(formatCompact({ op: 'sandbox_stopped' }));
@@ -87,7 +106,9 @@ export class SandboxWsEndpoint implements EndpointInstance {
87
106
 
88
107
  send({ target, payload }: { readonly target: string; readonly payload: unknown }): unknown {
89
108
  if (!this.#open) return undefined;
90
- const connection = this.#connections.get(target);
109
+ // Reply target is the connection key (bot name / sandbox-uuid), not private:channelId.
110
+ const connection = this.#connections.get(target)
111
+ ?? this.#findLiveConnection();
91
112
  if (!connection) {
92
113
  logger.debug(formatCompact({ op: 'sandbox_send_miss', target }));
93
114
  return undefined;
@@ -96,31 +117,75 @@ export class SandboxWsEndpoint implements EndpointInstance {
96
117
  logger.debug(formatCompact({ op: 'sandbox_send_placeholder', target }));
97
118
  return undefined;
98
119
  }
99
- connection.socket.send(formatSandboxOutbound(payload));
100
- logger.debug(formatCompact({ op: 'sandbox_send', target }));
120
+ // Console UI filters by type+id; stamp last inbound channel onto outbound wire.
121
+ const channel = connection.lastChannel ?? {
122
+ type: 'private',
123
+ id: connection.owner,
124
+ };
125
+ connection.socket.send(formatSandboxOutbound(payload, {
126
+ type: channel.type,
127
+ id: channel.id,
128
+ bot: this.#options.defaults.name,
129
+ endpoint: connection.target,
130
+ }));
131
+ logger.debug(formatCompact({
132
+ op: 'sandbox_send',
133
+ target: connection.target,
134
+ channelType: channel.type,
135
+ channelId: channel.id,
136
+ }));
101
137
  return payload;
102
138
  }
103
139
 
140
+ /** Prefer a real (non-placeholder) socket when reply target key is wrong/stale. */
141
+ #findLiveConnection(): SandboxConnection | undefined {
142
+ for (const connection of this.#connections.values()) {
143
+ if (!connection.placeholder) return connection;
144
+ }
145
+ return undefined;
146
+ }
147
+
104
148
  #acceptConnection(connection: WsConnection): void {
105
149
  const target = this.#options.defaults.randomNamePerConnection
106
150
  ? `sandbox-${randomUUID().slice(0, 8)}`
107
151
  : this.#options.defaults.name;
108
152
  const owner = this.#options.defaults.owner;
109
153
  const socket = connection.socket as SandboxWsSocket;
110
- if (this.#connections.has(target)) {
111
- this.#connections.get(target)?.release();
154
+ // Fixed-name mode reuses `target`; dropping the prior entry without
155
+ // closing its socket leaves a zombie browser tab that still looks
156
+ // connected but never receives outbound traffic.
157
+ const previous = this.#connections.get(target);
158
+ if (previous) {
159
+ previous.release();
160
+ if (!previous.placeholder) {
161
+ try {
162
+ previous.socket.close(4000, 'replaced by new sandbox client');
163
+ } catch {
164
+ /* already closed */
165
+ }
166
+ }
112
167
  this.#connections.delete(target);
113
168
  }
114
169
  const release = bindSandboxWsSocket(socket, {
115
170
  onMessage: (raw) => {
116
- if (!this.#open) return;
117
171
  const parsed = parseSandboxWsPayload(raw);
172
+ const conn = this.#connections.get(target);
173
+ if (conn && !conn.placeholder) {
174
+ conn.lastChannel = {
175
+ type: parsed.type,
176
+ id: parsed.id || owner,
177
+ };
178
+ }
118
179
  logger.debug(formatCompact({
119
180
  op: 'sandbox_recv',
120
181
  target,
121
182
  sender: parsed.id || owner,
183
+ channelType: parsed.type,
184
+ channelId: parsed.id || owner,
122
185
  text: parsed.text.slice(0, 80),
123
186
  }));
187
+ // Don't gate on #open — inbound must always reach the gateway so
188
+ // Command/AI dispatch and outbound replies work.
124
189
  void this.#options.gateway.receive({
125
190
  adapter: this.#options.id,
126
191
  target,
@@ -128,6 +193,9 @@ export class SandboxWsEndpoint implements EndpointInstance {
128
193
  sender: parsed.id || owner,
129
194
  metadata: Object.freeze({
130
195
  type: parsed.type,
196
+ channelType: parsed.type,
197
+ channelId: parsed.id || owner,
198
+ endpoint: target,
131
199
  elements: parsed.content,
132
200
  timestamp: parsed.timestamp,
133
201
  ...(parsed.action ? { action: parsed.action } : {}),
@@ -141,8 +209,13 @@ export class SandboxWsEndpoint implements EndpointInstance {
141
209
  });
142
210
  },
143
211
  onClose: () => {
144
- this.#connections.delete(target);
145
- logger.debug(formatCompact({ op: 'sandbox_ws_closed', target }));
212
+ // Only drop the map entry if we still own this socket — a replace
213
+ // may have already swapped in a newer connection for the same target.
214
+ const current = this.#connections.get(target);
215
+ if (current && current.socket === socket) {
216
+ this.#connections.delete(target);
217
+ logger.debug(formatCompact({ op: 'sandbox_ws_closed', target }));
218
+ }
146
219
  },
147
220
  onError: (err) => {
148
221
  logger.warn(formatCompact({
@@ -152,7 +225,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
152
225
  }));
153
226
  },
154
227
  });
155
- this.#connections.set(target, Object.freeze({ target, owner, socket, release }));
228
+ this.#connections.set(target, { target, owner, socket, release });
156
229
  logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
157
230
  if (!this.#options.defaults.randomNamePerConnection) {
158
231
  const readyPayload = JSON.stringify({
@@ -177,12 +250,12 @@ export class SandboxWsEndpoint implements EndpointInstance {
177
250
 
178
251
  #ensurePlaceholder(name: string, owner: string): void {
179
252
  if (this.#connections.has(name)) return;
180
- this.#connections.set(name, Object.freeze({
253
+ this.#connections.set(name, {
181
254
  target: name,
182
255
  owner,
183
256
  socket: { send: () => undefined, close: () => undefined },
184
257
  release: () => undefined,
185
258
  placeholder: true,
186
- }));
259
+ });
187
260
  }
188
261
  }
package/src/protocol.ts CHANGED
@@ -161,26 +161,66 @@ export function parseSandboxWsPayload(raw: string): {
161
161
  return { type, id, content, timestamp: payload.timestamp ?? Date.now(), text, action };
162
162
  }
163
163
 
164
+ export type SandboxOutboundChannel = {
165
+ readonly type?: string;
166
+ readonly id?: string;
167
+ readonly bot?: string;
168
+ readonly endpoint?: string;
169
+ readonly messageId?: string;
170
+ };
171
+
164
172
  /**
165
173
  * Wire-encode an already-rendered outbound payload.
166
- * Canonical segment mapping (old `segment-mapper` re-export of `to/fromCanonicalSegments`
167
- * from legacy `zhin.js`) is intentionally not done here — the gateway/core render path
168
- * owns that before `endpoint.send`.
174
+ * Stamps `channel` so Console SandboxChat can filter by type+id (otherwise
175
+ * replies look like they disappeared).
169
176
  */
170
- export function formatSandboxOutbound(payload: unknown): string {
177
+ export function formatSandboxOutbound(
178
+ payload: unknown,
179
+ channel: SandboxOutboundChannel = {},
180
+ ): string {
181
+ const stamp: Record<string, unknown> = {};
182
+ if (channel.type) stamp.type = channel.type;
183
+ if (channel.id) stamp.id = channel.id;
184
+ if (channel.bot) stamp.bot = channel.bot;
185
+ if (channel.endpoint) stamp.endpoint = channel.endpoint;
186
+ if (channel.messageId) stamp.messageId = channel.messageId;
187
+
171
188
  if (typeof payload === 'string') {
172
189
  return JSON.stringify({
190
+ ...stamp,
173
191
  content: [{ type: 'text', data: { text: payload } }],
174
192
  timestamp: Date.now(),
175
193
  });
176
194
  }
177
195
  if (Array.isArray(payload)) {
178
196
  return JSON.stringify({
197
+ ...stamp,
179
198
  content: payload,
180
199
  timestamp: Date.now(),
181
200
  });
182
201
  }
183
- return JSON.stringify({ content: payload, timestamp: Date.now() });
202
+ // Already a wire envelope ({ content, type, … }) — pass through so the
203
+ // Console UI can read `content` / `type` without an extra nesting layer.
204
+ // Bare segment objects ({ type: 'text', data: … }) still need wrapping.
205
+ if (
206
+ payload
207
+ && typeof payload === 'object'
208
+ && !Array.isArray(payload)
209
+ && (
210
+ 'content' in (payload as object)
211
+ || 'type' in (payload as object) && 'timestamp' in (payload as object)
212
+ )
213
+ ) {
214
+ const envelope = payload as Record<string, unknown>;
215
+ return JSON.stringify({
216
+ ...stamp,
217
+ ...envelope,
218
+ type: envelope.type ?? stamp.type,
219
+ id: envelope.id ?? stamp.id,
220
+ timestamp: typeof envelope.timestamp === 'number' ? envelope.timestamp : Date.now(),
221
+ });
222
+ }
223
+ return JSON.stringify({ ...stamp, content: payload, timestamp: Date.now() });
184
224
  }
185
225
 
186
226
  /** WebSocket.OPEN 常量值;Node <22 无全局 WebSocket,不能用 WebSocket.OPEN。 */