@zhin.js/adapter-sandbox 5.0.6 → 6.0.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.
- package/CHANGELOG.md +52 -0
- package/README.md +15 -15
- package/adapters/sandbox.ts +5 -0
- package/lib/endpoint.d.ts +4 -0
- package/lib/endpoint.js +125 -16
- package/lib/protocol.d.ts +15 -4
- package/lib/protocol.js +41 -7
- package/package.json +20 -13
- package/pages/RichTextEditor.tsx +429 -0
- package/pages/SandboxChat.tsx +672 -0
- package/pages/index.tsx +18 -0
- package/pages/sandboxTransport.ts +45 -0
- package/schema.json +27 -7
- package/src/endpoint.ts +140 -15
- package/src/protocol.ts +57 -7
- package/pages/sandbox.tsx +0 -95
package/pages/index.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { definePage } from '@zhin.js/console-contract';
|
|
2
|
+
import SandboxChat from './SandboxChat';
|
|
3
|
+
|
|
4
|
+
export const meta = definePage({
|
|
5
|
+
title: '沙盒',
|
|
6
|
+
icon: 'Box',
|
|
7
|
+
order: 10,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convention page entry (ADR 0046).
|
|
12
|
+
* `pages/index.tsx` → `/sandbox` (plugin path; no `/p-` leaf).
|
|
13
|
+
* Restores the pre-runtime-migration Sandbox console UI (channels + rich text + faces).
|
|
14
|
+
* WebSocket targets Host `/sandbox` via zhin_api_base + token (see sandboxTransport.ts).
|
|
15
|
+
*/
|
|
16
|
+
export default function SandboxPage() {
|
|
17
|
+
return <SandboxChat />;
|
|
18
|
+
}
|
|
@@ -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
|
-
"
|
|
8
|
+
"description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(name 必填,其余覆盖顶层)",
|
|
9
9
|
"items": {
|
|
10
10
|
"type": "object",
|
|
11
|
-
"additionalProperties":
|
|
11
|
+
"additionalProperties": true,
|
|
12
12
|
"properties": {
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
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,51 @@ import {
|
|
|
18
18
|
|
|
19
19
|
const logger = getLogger('sandbox');
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* 多 sandbox endpoint 共用同一个 HttpHost 时,同 path 的所有 WS listener
|
|
23
|
+
* 都会被回调(入站重复、出站互窜)。按 endpoint 名隔离挂载路径:
|
|
24
|
+
* 首个占用 `/sandbox`(保持 Console 默认兼容),其余退到 `/sandbox/<name>`。
|
|
25
|
+
* 认领记录按 HttpHost 隔离,endpoint stop() 时必须 release。
|
|
26
|
+
*/
|
|
27
|
+
const claimedWsPaths = new WeakMap<HttpHost, Map<string, string>>();
|
|
28
|
+
|
|
29
|
+
function claimSandboxWsPath(
|
|
30
|
+
http: HttpHost,
|
|
31
|
+
name: string,
|
|
32
|
+
): { readonly path: string; readonly release: () => void } {
|
|
33
|
+
let claims = claimedWsPaths.get(http);
|
|
34
|
+
if (!claims) {
|
|
35
|
+
claims = new Map();
|
|
36
|
+
claimedWsPaths.set(http, claims);
|
|
37
|
+
}
|
|
38
|
+
const candidates = ['/sandbox', `/sandbox/${encodeURIComponent(name)}`];
|
|
39
|
+
let path = candidates.find(
|
|
40
|
+
(candidate) => !claims!.has(candidate) || claims!.get(candidate) === name,
|
|
41
|
+
);
|
|
42
|
+
if (!path) {
|
|
43
|
+
let index = 2;
|
|
44
|
+
path = `/sandbox/${encodeURIComponent(name)}-${index}`;
|
|
45
|
+
while (claims.has(path)) {
|
|
46
|
+
index += 1;
|
|
47
|
+
path = `/sandbox/${encodeURIComponent(name)}-${index}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
claims.set(path, name);
|
|
51
|
+
const claimed = path;
|
|
52
|
+
const registry = claims;
|
|
53
|
+
return {
|
|
54
|
+
path: claimed,
|
|
55
|
+
release: () => {
|
|
56
|
+
if (registry.get(claimed) === name) registry.delete(claimed);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface SandboxChannel {
|
|
62
|
+
readonly type: string;
|
|
63
|
+
readonly id: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
21
66
|
interface SandboxConnection {
|
|
22
67
|
readonly target: string;
|
|
23
68
|
readonly owner: string;
|
|
@@ -25,6 +70,11 @@ interface SandboxConnection {
|
|
|
25
70
|
readonly release: () => void;
|
|
26
71
|
/** true = 占位连接(尚无真实 WS 客户端),send 命中时按 miss 处理。 */
|
|
27
72
|
readonly placeholder?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* 最近一条入站频道(Console UI 按 type+id 过滤气泡)。
|
|
75
|
+
* 出站必须带回,否则回复石沉大海。
|
|
76
|
+
*/
|
|
77
|
+
lastChannel?: SandboxChannel;
|
|
28
78
|
}
|
|
29
79
|
|
|
30
80
|
export interface SandboxEndpointOptions {
|
|
@@ -34,10 +84,16 @@ export interface SandboxEndpointOptions {
|
|
|
34
84
|
readonly defaults: ResolvedSandboxBot;
|
|
35
85
|
}
|
|
36
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Sandbox 是本地开发/测试面,无平台社交图谱(好友/群/频道),
|
|
89
|
+
* 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
|
|
90
|
+
*/
|
|
37
91
|
export class SandboxWsEndpoint implements EndpointInstance {
|
|
38
92
|
readonly #options: SandboxEndpointOptions;
|
|
39
93
|
readonly #connections = new Map<string, SandboxConnection>();
|
|
40
94
|
#wsHandleRelease?: () => void;
|
|
95
|
+
#wsPathRelease?: () => void;
|
|
96
|
+
#wsPath = '/sandbox';
|
|
41
97
|
#open = false;
|
|
42
98
|
#started = false;
|
|
43
99
|
|
|
@@ -53,7 +109,11 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
53
109
|
start(): void {
|
|
54
110
|
if (this.#started) return;
|
|
55
111
|
this.#started = true;
|
|
56
|
-
|
|
112
|
+
// 多 endpoint 同 path 会被全部回调(入站重复、出站互窜),按名隔离。
|
|
113
|
+
const claim = claimSandboxWsPath(this.#options.http, this.#options.defaults.name);
|
|
114
|
+
this.#wsPathRelease = claim.release;
|
|
115
|
+
this.#wsPath = claim.path;
|
|
116
|
+
const handle = this.#options.http.ws(claim.path);
|
|
57
117
|
this.#wsHandleRelease = handle.onConnection((connection) => {
|
|
58
118
|
this.#acceptConnection(connection);
|
|
59
119
|
});
|
|
@@ -62,7 +122,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
62
122
|
}
|
|
63
123
|
logger.info(formatCompact({
|
|
64
124
|
op: 'sandbox_ws_mounted',
|
|
65
|
-
path:
|
|
125
|
+
path: claim.path,
|
|
66
126
|
endpoint: this.#options.defaults.name,
|
|
67
127
|
}));
|
|
68
128
|
}
|
|
@@ -79,7 +139,18 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
79
139
|
this.#open = false;
|
|
80
140
|
this.#wsHandleRelease?.();
|
|
81
141
|
this.#wsHandleRelease = undefined;
|
|
82
|
-
|
|
142
|
+
this.#wsPathRelease?.();
|
|
143
|
+
this.#wsPathRelease = undefined;
|
|
144
|
+
for (const connection of this.#connections.values()) {
|
|
145
|
+
connection.release();
|
|
146
|
+
if (!connection.placeholder) {
|
|
147
|
+
try {
|
|
148
|
+
connection.socket.close(1001, 'sandbox endpoint stopped');
|
|
149
|
+
} catch {
|
|
150
|
+
/* already closed */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
83
154
|
this.#connections.clear();
|
|
84
155
|
this.#started = false;
|
|
85
156
|
logger.debug(formatCompact({ op: 'sandbox_stopped' }));
|
|
@@ -87,7 +158,9 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
87
158
|
|
|
88
159
|
send({ target, payload }: { readonly target: string; readonly payload: unknown }): unknown {
|
|
89
160
|
if (!this.#open) return undefined;
|
|
90
|
-
|
|
161
|
+
// Reply target is the connection key (bot name / sandbox-uuid), not private:channelId.
|
|
162
|
+
const connection = this.#connections.get(target)
|
|
163
|
+
?? this.#findLiveConnection();
|
|
91
164
|
if (!connection) {
|
|
92
165
|
logger.debug(formatCompact({ op: 'sandbox_send_miss', target }));
|
|
93
166
|
return undefined;
|
|
@@ -96,31 +169,75 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
96
169
|
logger.debug(formatCompact({ op: 'sandbox_send_placeholder', target }));
|
|
97
170
|
return undefined;
|
|
98
171
|
}
|
|
99
|
-
|
|
100
|
-
|
|
172
|
+
// Console UI filters by type+id; stamp last inbound channel onto outbound wire.
|
|
173
|
+
const channel = connection.lastChannel ?? {
|
|
174
|
+
type: 'private',
|
|
175
|
+
id: connection.owner,
|
|
176
|
+
};
|
|
177
|
+
connection.socket.send(formatSandboxOutbound(payload, {
|
|
178
|
+
type: channel.type,
|
|
179
|
+
id: channel.id,
|
|
180
|
+
bot: this.#options.defaults.name,
|
|
181
|
+
endpoint: connection.target,
|
|
182
|
+
}));
|
|
183
|
+
logger.debug(formatCompact({
|
|
184
|
+
op: 'sandbox_send',
|
|
185
|
+
target: connection.target,
|
|
186
|
+
channelType: channel.type,
|
|
187
|
+
channelId: channel.id,
|
|
188
|
+
}));
|
|
101
189
|
return payload;
|
|
102
190
|
}
|
|
103
191
|
|
|
192
|
+
/** Prefer a real (non-placeholder) socket when reply target key is wrong/stale. */
|
|
193
|
+
#findLiveConnection(): SandboxConnection | undefined {
|
|
194
|
+
for (const connection of this.#connections.values()) {
|
|
195
|
+
if (!connection.placeholder) return connection;
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
104
200
|
#acceptConnection(connection: WsConnection): void {
|
|
105
201
|
const target = this.#options.defaults.randomNamePerConnection
|
|
106
202
|
? `sandbox-${randomUUID().slice(0, 8)}`
|
|
107
203
|
: this.#options.defaults.name;
|
|
108
204
|
const owner = this.#options.defaults.owner;
|
|
109
205
|
const socket = connection.socket as SandboxWsSocket;
|
|
110
|
-
|
|
111
|
-
|
|
206
|
+
// Fixed-name mode reuses `target`; dropping the prior entry without
|
|
207
|
+
// closing its socket leaves a zombie browser tab that still looks
|
|
208
|
+
// connected but never receives outbound traffic.
|
|
209
|
+
const previous = this.#connections.get(target);
|
|
210
|
+
if (previous) {
|
|
211
|
+
previous.release();
|
|
212
|
+
if (!previous.placeholder) {
|
|
213
|
+
try {
|
|
214
|
+
previous.socket.close(4000, 'replaced by new sandbox client');
|
|
215
|
+
} catch {
|
|
216
|
+
/* already closed */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
112
219
|
this.#connections.delete(target);
|
|
113
220
|
}
|
|
114
221
|
const release = bindSandboxWsSocket(socket, {
|
|
115
222
|
onMessage: (raw) => {
|
|
116
|
-
if (!this.#open) return;
|
|
117
223
|
const parsed = parseSandboxWsPayload(raw);
|
|
224
|
+
const conn = this.#connections.get(target);
|
|
225
|
+
if (conn && !conn.placeholder) {
|
|
226
|
+
conn.lastChannel = {
|
|
227
|
+
type: parsed.type,
|
|
228
|
+
id: parsed.id || owner,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
118
231
|
logger.debug(formatCompact({
|
|
119
232
|
op: 'sandbox_recv',
|
|
120
233
|
target,
|
|
121
234
|
sender: parsed.id || owner,
|
|
235
|
+
channelType: parsed.type,
|
|
236
|
+
channelId: parsed.id || owner,
|
|
122
237
|
text: parsed.text.slice(0, 80),
|
|
123
238
|
}));
|
|
239
|
+
// Don't gate on #open — inbound must always reach the gateway so
|
|
240
|
+
// Command/AI dispatch and outbound replies work.
|
|
124
241
|
void this.#options.gateway.receive({
|
|
125
242
|
adapter: this.#options.id,
|
|
126
243
|
target,
|
|
@@ -128,6 +245,9 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
128
245
|
sender: parsed.id || owner,
|
|
129
246
|
metadata: Object.freeze({
|
|
130
247
|
type: parsed.type,
|
|
248
|
+
channelType: parsed.type,
|
|
249
|
+
channelId: parsed.id || owner,
|
|
250
|
+
endpoint: target,
|
|
131
251
|
elements: parsed.content,
|
|
132
252
|
timestamp: parsed.timestamp,
|
|
133
253
|
...(parsed.action ? { action: parsed.action } : {}),
|
|
@@ -141,8 +261,13 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
141
261
|
});
|
|
142
262
|
},
|
|
143
263
|
onClose: () => {
|
|
144
|
-
this
|
|
145
|
-
|
|
264
|
+
// Only drop the map entry if we still own this socket — a replace
|
|
265
|
+
// may have already swapped in a newer connection for the same target.
|
|
266
|
+
const current = this.#connections.get(target);
|
|
267
|
+
if (current && current.socket === socket) {
|
|
268
|
+
this.#connections.delete(target);
|
|
269
|
+
logger.debug(formatCompact({ op: 'sandbox_ws_closed', target }));
|
|
270
|
+
}
|
|
146
271
|
},
|
|
147
272
|
onError: (err) => {
|
|
148
273
|
logger.warn(formatCompact({
|
|
@@ -152,7 +277,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
152
277
|
}));
|
|
153
278
|
},
|
|
154
279
|
});
|
|
155
|
-
this.#connections.set(target,
|
|
280
|
+
this.#connections.set(target, { target, owner, socket, release });
|
|
156
281
|
logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
|
|
157
282
|
if (!this.#options.defaults.randomNamePerConnection) {
|
|
158
283
|
const readyPayload = JSON.stringify({
|
|
@@ -164,7 +289,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
164
289
|
data: {
|
|
165
290
|
text: [
|
|
166
291
|
`已连接 Sandbox「${target}」`,
|
|
167
|
-
|
|
292
|
+
`与 Node Host 控制台沙盒协议一致(${this.#wsPath})`,
|
|
168
293
|
'命令: help · ping · zt · status',
|
|
169
294
|
].join('\n'),
|
|
170
295
|
},
|
|
@@ -177,12 +302,12 @@ export class SandboxWsEndpoint implements EndpointInstance {
|
|
|
177
302
|
|
|
178
303
|
#ensurePlaceholder(name: string, owner: string): void {
|
|
179
304
|
if (this.#connections.has(name)) return;
|
|
180
|
-
this.#connections.set(name,
|
|
305
|
+
this.#connections.set(name, {
|
|
181
306
|
target: name,
|
|
182
307
|
owner,
|
|
183
308
|
socket: { send: () => undefined, close: () => undefined },
|
|
184
309
|
release: () => undefined,
|
|
185
310
|
placeholder: true,
|
|
186
|
-
})
|
|
311
|
+
});
|
|
187
312
|
}
|
|
188
313
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -33,6 +33,11 @@ export type ResolvedSandboxBot = {
|
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
export interface SandboxAdapterConfig {
|
|
36
|
+
/** Runtime expands `endpoints[i]` onto the top level — prefer these. */
|
|
37
|
+
readonly context?: string;
|
|
38
|
+
readonly name?: string;
|
|
39
|
+
readonly owner?: string;
|
|
40
|
+
/** Legacy shape: endpoint entries nested under `endpoints[]`. */
|
|
36
41
|
readonly endpoints?: ReadonlyArray<{
|
|
37
42
|
readonly context?: string;
|
|
38
43
|
readonly name?: string;
|
|
@@ -44,9 +49,14 @@ export function resolveSandboxEndpoint(
|
|
|
44
49
|
appConfig: SandboxAdapterConfig,
|
|
45
50
|
): ResolvedSandboxBot {
|
|
46
51
|
const entry = appConfig.endpoints?.find((item) => item.context === 'sandbox');
|
|
47
|
-
const fixedName = typeof
|
|
52
|
+
const fixedName = typeof appConfig.name === 'string' && appConfig.name
|
|
53
|
+
? appConfig.name
|
|
54
|
+
: typeof entry?.name === 'string' && entry.name
|
|
55
|
+
? entry.name
|
|
56
|
+
: undefined;
|
|
48
57
|
const name = fixedName || process.env.SANDBOX_BOT_NAME || 'sandbox-bot';
|
|
49
|
-
const owner = (typeof
|
|
58
|
+
const owner = (typeof appConfig.owner === 'string' && appConfig.owner)
|
|
59
|
+
|| (typeof entry?.owner === 'string' && entry.owner)
|
|
50
60
|
|| process.env.SANDBOX_BOT_OWNER
|
|
51
61
|
|| 'sandbox-user';
|
|
52
62
|
return {
|
|
@@ -161,26 +171,66 @@ export function parseSandboxWsPayload(raw: string): {
|
|
|
161
171
|
return { type, id, content, timestamp: payload.timestamp ?? Date.now(), text, action };
|
|
162
172
|
}
|
|
163
173
|
|
|
174
|
+
export type SandboxOutboundChannel = {
|
|
175
|
+
readonly type?: string;
|
|
176
|
+
readonly id?: string;
|
|
177
|
+
readonly bot?: string;
|
|
178
|
+
readonly endpoint?: string;
|
|
179
|
+
readonly messageId?: string;
|
|
180
|
+
};
|
|
181
|
+
|
|
164
182
|
/**
|
|
165
183
|
* Wire-encode an already-rendered outbound payload.
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* owns that before `endpoint.send`.
|
|
184
|
+
* Stamps `channel` so Console SandboxChat can filter by type+id (otherwise
|
|
185
|
+
* replies look like they disappeared).
|
|
169
186
|
*/
|
|
170
|
-
export function formatSandboxOutbound(
|
|
187
|
+
export function formatSandboxOutbound(
|
|
188
|
+
payload: unknown,
|
|
189
|
+
channel: SandboxOutboundChannel = {},
|
|
190
|
+
): string {
|
|
191
|
+
const stamp: Record<string, unknown> = {};
|
|
192
|
+
if (channel.type) stamp.type = channel.type;
|
|
193
|
+
if (channel.id) stamp.id = channel.id;
|
|
194
|
+
if (channel.bot) stamp.bot = channel.bot;
|
|
195
|
+
if (channel.endpoint) stamp.endpoint = channel.endpoint;
|
|
196
|
+
if (channel.messageId) stamp.messageId = channel.messageId;
|
|
197
|
+
|
|
171
198
|
if (typeof payload === 'string') {
|
|
172
199
|
return JSON.stringify({
|
|
200
|
+
...stamp,
|
|
173
201
|
content: [{ type: 'text', data: { text: payload } }],
|
|
174
202
|
timestamp: Date.now(),
|
|
175
203
|
});
|
|
176
204
|
}
|
|
177
205
|
if (Array.isArray(payload)) {
|
|
178
206
|
return JSON.stringify({
|
|
207
|
+
...stamp,
|
|
179
208
|
content: payload,
|
|
180
209
|
timestamp: Date.now(),
|
|
181
210
|
});
|
|
182
211
|
}
|
|
183
|
-
|
|
212
|
+
// Already a wire envelope ({ content, type, … }) — pass through so the
|
|
213
|
+
// Console UI can read `content` / `type` without an extra nesting layer.
|
|
214
|
+
// Bare segment objects ({ type: 'text', data: … }) still need wrapping.
|
|
215
|
+
if (
|
|
216
|
+
payload
|
|
217
|
+
&& typeof payload === 'object'
|
|
218
|
+
&& !Array.isArray(payload)
|
|
219
|
+
&& (
|
|
220
|
+
'content' in (payload as object)
|
|
221
|
+
|| 'type' in (payload as object) && 'timestamp' in (payload as object)
|
|
222
|
+
)
|
|
223
|
+
) {
|
|
224
|
+
const envelope = payload as Record<string, unknown>;
|
|
225
|
+
return JSON.stringify({
|
|
226
|
+
...stamp,
|
|
227
|
+
...envelope,
|
|
228
|
+
type: envelope.type ?? stamp.type,
|
|
229
|
+
id: envelope.id ?? stamp.id,
|
|
230
|
+
timestamp: typeof envelope.timestamp === 'number' ? envelope.timestamp : Date.now(),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return JSON.stringify({ ...stamp, content: payload, timestamp: Date.now() });
|
|
184
234
|
}
|
|
185
235
|
|
|
186
236
|
/** WebSocket.OPEN 常量值;Node <22 无全局 WebSocket,不能用 WebSocket.OPEN。 */
|
package/pages/sandbox.tsx
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { definePage } from '@zhin.js/console-contract';
|
|
2
|
-
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
|
3
|
-
|
|
4
|
-
export const meta = definePage({
|
|
5
|
-
title: 'Sandbox',
|
|
6
|
-
icon: 'Box',
|
|
7
|
-
order: 10,
|
|
8
|
-
});
|
|
9
|
-
|
|
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.
|
|
13
|
-
*/
|
|
14
|
-
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
|
-
);
|
|
95
|
-
}
|