@zhin.js/adapter-sandbox 5.0.5 → 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/CHANGELOG.md +54 -0
- package/README.md +27 -11
- package/adapters/sandbox.ts +26 -0
- package/lib/endpoint.d.ts +25 -0
- package/lib/endpoint.js +218 -0
- package/lib/index.d.ts +2 -22
- package/lib/index.js +2 -67
- package/lib/protocol.d.ts +58 -0
- package/lib/protocol.js +164 -0
- package/package.json +52 -24
- package/{client/Sandbox.tsx → pages/SandboxChat.tsx} +96 -12
- package/pages/sandbox.tsx +17 -0
- package/pages/sandboxTransport.ts +45 -0
- package/plugin.ts +8 -0
- package/schema.json +40 -0
- package/src/endpoint.ts +261 -0
- package/src/index.ts +12 -101
- package/src/protocol.ts +240 -0
- package/client/index.tsx +0 -11
- package/client/sandboxTransport.ts +0 -28
- package/client/tsconfig.json +0 -7
- package/dist/index.js +0 -45
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/sandbox-ws.d.ts +0 -86
- package/lib/sandbox-ws.d.ts.map +0 -1
- package/lib/sandbox-ws.js +0 -295
- package/lib/sandbox-ws.js.map +0 -1
- package/lib/segment-mapper.d.ts +0 -2
- package/lib/segment-mapper.d.ts.map +0 -1
- package/lib/segment-mapper.js +0 -2
- package/lib/segment-mapper.js.map +0 -1
- package/src/sandbox-ws.ts +0 -404
- package/src/segment-mapper.ts +0 -1
- /package/{client → pages}/RichTextEditor.tsx +0 -0
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** Sandbox WebSocket wire protocol helpers (no legacy Adapter/Endpoint). */
|
|
2
|
+
export function resolveSandboxEndpoint(appConfig) {
|
|
3
|
+
const entry = appConfig.endpoints?.find((item) => item.context === 'sandbox');
|
|
4
|
+
const fixedName = typeof entry?.name === 'string' ? entry.name : undefined;
|
|
5
|
+
const name = fixedName || process.env.SANDBOX_BOT_NAME || 'sandbox-bot';
|
|
6
|
+
const owner = (typeof entry?.owner === 'string' && entry.owner)
|
|
7
|
+
|| process.env.SANDBOX_BOT_OWNER
|
|
8
|
+
|| 'sandbox-user';
|
|
9
|
+
return {
|
|
10
|
+
context: 'sandbox',
|
|
11
|
+
name,
|
|
12
|
+
owner,
|
|
13
|
+
randomNamePerConnection: !fixedName,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function bindSandboxWsSocket(ws, handlers) {
|
|
17
|
+
if (typeof ws.on === 'function') {
|
|
18
|
+
const onMessage = (...args) => {
|
|
19
|
+
const data = args[0];
|
|
20
|
+
const raw = typeof data === 'string'
|
|
21
|
+
? data
|
|
22
|
+
: data instanceof ArrayBuffer
|
|
23
|
+
? new TextDecoder().decode(data)
|
|
24
|
+
: Buffer.isBuffer(data)
|
|
25
|
+
? data.toString()
|
|
26
|
+
: String(data ?? '');
|
|
27
|
+
handlers.onMessage(raw);
|
|
28
|
+
};
|
|
29
|
+
ws.on('message', onMessage);
|
|
30
|
+
ws.on('close', handlers.onClose);
|
|
31
|
+
if (handlers.onError)
|
|
32
|
+
ws.on('error', handlers.onError);
|
|
33
|
+
return () => {
|
|
34
|
+
ws.off?.('message', onMessage);
|
|
35
|
+
ws.off?.('close', handlers.onClose);
|
|
36
|
+
if (handlers.onError)
|
|
37
|
+
ws.off?.('error', handlers.onError);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const onMessage = (ev) => {
|
|
41
|
+
const data = ev.data;
|
|
42
|
+
handlers.onMessage(typeof data === 'string' ? data : '');
|
|
43
|
+
};
|
|
44
|
+
const onClose = () => handlers.onClose();
|
|
45
|
+
const onError = handlers.onError
|
|
46
|
+
? () => handlers.onError?.(new Error('WebSocket error'))
|
|
47
|
+
: undefined;
|
|
48
|
+
ws.addEventListener('message', onMessage);
|
|
49
|
+
ws.addEventListener('close', onClose);
|
|
50
|
+
if (onError)
|
|
51
|
+
ws.addEventListener('error', onError);
|
|
52
|
+
return () => {
|
|
53
|
+
ws.removeEventListener('message', onMessage);
|
|
54
|
+
ws.removeEventListener('close', onClose);
|
|
55
|
+
if (onError)
|
|
56
|
+
ws.removeEventListener('error', onError);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function parseSandboxWsPayload(raw) {
|
|
60
|
+
let payload;
|
|
61
|
+
try {
|
|
62
|
+
payload = JSON.parse(raw);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
payload = { text: raw };
|
|
66
|
+
}
|
|
67
|
+
const type = payload.type ?? 'private';
|
|
68
|
+
const id = payload.id ?? 'sandbox-user';
|
|
69
|
+
const content = typeof payload.content === 'string'
|
|
70
|
+
? [{ type: 'text', data: { text: payload.content } }]
|
|
71
|
+
: Array.isArray(payload.content)
|
|
72
|
+
? payload.content
|
|
73
|
+
: [{ type: 'text', data: { text: payload.text ?? raw } }];
|
|
74
|
+
const actionSegment = content.find((segment) => segment.type === 'action');
|
|
75
|
+
let action;
|
|
76
|
+
if (actionSegment?.data) {
|
|
77
|
+
const actionPayload = typeof actionSegment.data.payload === 'string'
|
|
78
|
+
? actionSegment.data.payload
|
|
79
|
+
: typeof actionSegment.data.id === 'string'
|
|
80
|
+
? actionSegment.data.id
|
|
81
|
+
: '';
|
|
82
|
+
const actionId = typeof actionSegment.data.id === 'string'
|
|
83
|
+
? actionSegment.data.id
|
|
84
|
+
: actionPayload;
|
|
85
|
+
if (actionId || actionPayload) {
|
|
86
|
+
action = { id: actionId || actionPayload, payload: actionPayload || actionId };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let text = content
|
|
90
|
+
.flatMap((segment) => (segment.type === 'text' && typeof segment.data?.text === 'string'
|
|
91
|
+
? [segment.data.text]
|
|
92
|
+
: []))
|
|
93
|
+
.join('\n');
|
|
94
|
+
if (!text.trim()) {
|
|
95
|
+
text = (typeof payload.text === 'string' && payload.text.trim())
|
|
96
|
+
? payload.text
|
|
97
|
+
: action?.payload ?? raw;
|
|
98
|
+
}
|
|
99
|
+
return { type, id, content, timestamp: payload.timestamp ?? Date.now(), text, action };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Wire-encode an already-rendered outbound payload.
|
|
103
|
+
* Stamps `channel` so Console SandboxChat can filter by type+id (otherwise
|
|
104
|
+
* replies look like they disappeared).
|
|
105
|
+
*/
|
|
106
|
+
export function formatSandboxOutbound(payload, channel = {}) {
|
|
107
|
+
const stamp = {};
|
|
108
|
+
if (channel.type)
|
|
109
|
+
stamp.type = channel.type;
|
|
110
|
+
if (channel.id)
|
|
111
|
+
stamp.id = channel.id;
|
|
112
|
+
if (channel.bot)
|
|
113
|
+
stamp.bot = channel.bot;
|
|
114
|
+
if (channel.endpoint)
|
|
115
|
+
stamp.endpoint = channel.endpoint;
|
|
116
|
+
if (channel.messageId)
|
|
117
|
+
stamp.messageId = channel.messageId;
|
|
118
|
+
if (typeof payload === 'string') {
|
|
119
|
+
return JSON.stringify({
|
|
120
|
+
...stamp,
|
|
121
|
+
content: [{ type: 'text', data: { text: payload } }],
|
|
122
|
+
timestamp: Date.now(),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (Array.isArray(payload)) {
|
|
126
|
+
return JSON.stringify({
|
|
127
|
+
...stamp,
|
|
128
|
+
content: payload,
|
|
129
|
+
timestamp: Date.now(),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// Already a wire envelope ({ content, type, … }) — pass through so the
|
|
133
|
+
// Console UI can read `content` / `type` without an extra nesting layer.
|
|
134
|
+
// Bare segment objects ({ type: 'text', data: … }) still need wrapping.
|
|
135
|
+
if (payload
|
|
136
|
+
&& typeof payload === 'object'
|
|
137
|
+
&& !Array.isArray(payload)
|
|
138
|
+
&& ('content' in payload
|
|
139
|
+
|| 'type' in payload && 'timestamp' in payload)) {
|
|
140
|
+
const envelope = payload;
|
|
141
|
+
return JSON.stringify({
|
|
142
|
+
...stamp,
|
|
143
|
+
...envelope,
|
|
144
|
+
type: envelope.type ?? stamp.type,
|
|
145
|
+
id: envelope.id ?? stamp.id,
|
|
146
|
+
timestamp: typeof envelope.timestamp === 'number' ? envelope.timestamp : Date.now(),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return JSON.stringify({ ...stamp, content: payload, timestamp: Date.now() });
|
|
150
|
+
}
|
|
151
|
+
/** WebSocket.OPEN 常量值;Node <22 无全局 WebSocket,不能用 WebSocket.OPEN。 */
|
|
152
|
+
const WS_OPEN = 1;
|
|
153
|
+
export function whenWsOpen(ws, fn) {
|
|
154
|
+
const std = ws;
|
|
155
|
+
if (typeof std.readyState === 'number') {
|
|
156
|
+
if (std.readyState === WS_OPEN) {
|
|
157
|
+
fn();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
std.addEventListener('open', fn, { once: true });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
fn();
|
|
164
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-sandbox",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Zhin.js adapter for
|
|
3
|
+
"version": "6.0.0",
|
|
4
|
+
"description": "Zhin.js Sandbox adapter for Plugin Runtime (WebSocket /sandbox)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"types": "./lib/index.d.ts",
|
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
|
+
"adapters",
|
|
17
|
+
"pages",
|
|
18
|
+
"plugin.ts",
|
|
19
|
+
"schema.json",
|
|
16
20
|
"src",
|
|
17
21
|
"lib",
|
|
18
|
-
"client",
|
|
19
22
|
"dist",
|
|
20
23
|
"agent",
|
|
21
24
|
"README.md",
|
|
@@ -43,32 +46,38 @@
|
|
|
43
46
|
"directory": "plugins/adapters/sandbox"
|
|
44
47
|
},
|
|
45
48
|
"dependencies": {
|
|
46
|
-
"lucide-react": "^
|
|
49
|
+
"lucide-react": "^0.525.0",
|
|
50
|
+
"react": "^19.1.0",
|
|
51
|
+
"@zhin.js/client": "2.1.0",
|
|
52
|
+
"@zhin.js/console-contract": "1.0.0",
|
|
53
|
+
"@zhin.js/adapter": "1.1.0",
|
|
54
|
+
"@zhin.js/core": "1.4.0",
|
|
55
|
+
"@zhin.js/host-http": "1.0.2",
|
|
56
|
+
"@zhin.js/logger": "1.0.75",
|
|
57
|
+
"@zhin.js/page": "1.0.2",
|
|
58
|
+
"@zhin.js/plugin-runtime": "1.1.0"
|
|
47
59
|
},
|
|
48
60
|
"devDependencies": {
|
|
49
|
-
"@types/react": "^19.
|
|
50
|
-
"@types/
|
|
61
|
+
"@types/react": "^19.1.8",
|
|
62
|
+
"@types/ws": "^8.18.1",
|
|
51
63
|
"typescript": "^6.0.3",
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"@zhin.js/
|
|
55
|
-
"@zhin.js/
|
|
56
|
-
"@zhin.js/host-api": "2.0.5",
|
|
57
|
-
"@zhin.js/host-router": "2.0.3",
|
|
58
|
-
"zhin.js": "4.1.2"
|
|
64
|
+
"vitest": "^4.1.10",
|
|
65
|
+
"ws": "^8.21.0",
|
|
66
|
+
"@zhin.js/pagemanager": "2.0.5",
|
|
67
|
+
"@zhin.js/runtime": "1.0.2"
|
|
59
68
|
},
|
|
60
69
|
"peerDependencies": {
|
|
61
|
-
"
|
|
62
|
-
"@zhin.js/
|
|
63
|
-
"@zhin.js/
|
|
64
|
-
"@zhin.js/
|
|
65
|
-
"@zhin.js/
|
|
70
|
+
"react": "^19.0.0",
|
|
71
|
+
"@zhin.js/adapter": "1.1.0",
|
|
72
|
+
"@zhin.js/client": "2.1.0",
|
|
73
|
+
"@zhin.js/console-contract": "1.0.0",
|
|
74
|
+
"@zhin.js/core": "1.4.0",
|
|
75
|
+
"@zhin.js/host-http": "1.0.2",
|
|
76
|
+
"@zhin.js/page": "1.0.2",
|
|
77
|
+
"@zhin.js/plugin-runtime": "1.1.0"
|
|
66
78
|
},
|
|
67
79
|
"peerDependenciesMeta": {
|
|
68
|
-
"
|
|
69
|
-
"optional": true
|
|
70
|
-
},
|
|
71
|
-
"@zhin.js/host-api": {
|
|
80
|
+
"react": {
|
|
72
81
|
"optional": true
|
|
73
82
|
},
|
|
74
83
|
"@zhin.js/client": {
|
|
@@ -82,8 +91,27 @@
|
|
|
82
91
|
"engines": {
|
|
83
92
|
"node": "^20.19.0 || >=22.12.0"
|
|
84
93
|
},
|
|
94
|
+
"zhin": {
|
|
95
|
+
"protocol": 1,
|
|
96
|
+
"type": "plugin",
|
|
97
|
+
"entry": "./plugin.ts",
|
|
98
|
+
"engine": "^1.0.0",
|
|
99
|
+
"runtime": "trusted",
|
|
100
|
+
"features": [
|
|
101
|
+
{
|
|
102
|
+
"package": "@zhin.js/adapter",
|
|
103
|
+
"api": "^1.0.0"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"package": "@zhin.js/page",
|
|
107
|
+
"api": "^1.0.0"
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
"plugins": []
|
|
111
|
+
},
|
|
85
112
|
"scripts": {
|
|
86
|
-
"build": "
|
|
87
|
-
"clean": "rimraf lib"
|
|
113
|
+
"build": "tsc",
|
|
114
|
+
"clean": "rimraf lib",
|
|
115
|
+
"test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/adapters/sandbox/tests"
|
|
88
116
|
}
|
|
89
117
|
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import React, { useState, useEffect, useRef } from 'react';
|
|
2
|
-
import { MessageSegment, cn, resolveMediaSrc, pickMediaRawUrl } from '@zhin.js/client';
|
|
3
2
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
cn,
|
|
4
|
+
resolveMediaSrc,
|
|
5
|
+
pickMediaRawUrl,
|
|
6
|
+
type MessageSegment,
|
|
7
|
+
} from '@zhin.js/client';
|
|
8
|
+
import { buildSandboxWebSocketUrl } from './sandboxTransport';
|
|
9
|
+
import {
|
|
10
|
+
User, Bot, Users, Trash2, Send, Hash, MessageSquare,
|
|
11
|
+
Wifi, WifiOff, Smile, Image, X, Check, Info, Search,
|
|
12
|
+
UserPlus, Bell, Video, Music,
|
|
13
|
+
} from 'lucide-react';
|
|
14
|
+
import RichTextEditor, { type RichTextEditorRef } from './RichTextEditor';
|
|
8
15
|
|
|
9
16
|
interface Message {
|
|
10
17
|
id: string; type: 'sent' | 'received'; channelType: 'private' | 'group' | 'channel';
|
|
@@ -99,16 +106,91 @@ export default function Sandbox() {
|
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
useEffect(() => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
109
|
+
let closed = false
|
|
110
|
+
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
|
111
|
+
let attempt = 0
|
|
112
|
+
/** Ignore close events from a socket we intentionally replaced (login/base change). */
|
|
113
|
+
let replaceInFlight = false
|
|
114
|
+
|
|
115
|
+
const connect = () => {
|
|
116
|
+
if (closed) return
|
|
117
|
+
if (retryTimer) {
|
|
118
|
+
clearTimeout(retryTimer)
|
|
119
|
+
retryTimer = undefined
|
|
120
|
+
}
|
|
121
|
+
const wsUrl = buildSandboxWebSocketUrl()
|
|
122
|
+
// Tear down previous socket before opening a new one so we don't
|
|
123
|
+
// leave two concurrent /sandbox sessions for fixed-name endpoints.
|
|
124
|
+
const previous = wsRef.current
|
|
125
|
+
if (previous) {
|
|
126
|
+
replaceInFlight = true
|
|
127
|
+
previous.onclose = null
|
|
128
|
+
previous.onerror = null
|
|
129
|
+
previous.onmessage = null
|
|
130
|
+
previous.onopen = null
|
|
131
|
+
try { previous.close() } catch { /* already closed */ }
|
|
132
|
+
replaceInFlight = false
|
|
133
|
+
}
|
|
134
|
+
const ws = new WebSocket(wsUrl)
|
|
135
|
+
wsRef.current = ws
|
|
136
|
+
ws.onopen = () => {
|
|
137
|
+
attempt = 0
|
|
138
|
+
setConnected(true)
|
|
139
|
+
}
|
|
140
|
+
ws.onmessage = (event) => {
|
|
141
|
+
try { handleInboundPayload(JSON.parse(String(event.data))) }
|
|
142
|
+
catch (err) { console.error('[Sandbox] Failed to parse message:', err) }
|
|
143
|
+
}
|
|
144
|
+
ws.onclose = () => {
|
|
145
|
+
if (wsRef.current !== ws) return
|
|
146
|
+
setConnected(false)
|
|
147
|
+
wsRef.current = null
|
|
148
|
+
if (closed || replaceInFlight) return
|
|
149
|
+
const delay = Math.min(8_000, 500 * 2 ** attempt)
|
|
150
|
+
attempt += 1
|
|
151
|
+
retryTimer = setTimeout(connect, delay)
|
|
152
|
+
}
|
|
153
|
+
ws.onerror = () => {
|
|
154
|
+
/* close handler reconnects */
|
|
155
|
+
}
|
|
108
156
|
}
|
|
109
|
-
wsRef.current.onclose = () => setConnected(false)
|
|
110
157
|
|
|
158
|
+
const onAuthOrStorage = (event?: Event) => {
|
|
159
|
+
// storage fires for other tabs; same-tab login sets localStorage then
|
|
160
|
+
// dispatches zhin:auth-required / custom login events.
|
|
161
|
+
if (event && event.type === 'storage') {
|
|
162
|
+
const key = (event as StorageEvent).key
|
|
163
|
+
if (
|
|
164
|
+
key != null
|
|
165
|
+
&& key !== 'zhin_api_token'
|
|
166
|
+
&& key !== 'zhin_api_base'
|
|
167
|
+
&& key !== 'HTTP_TOKEN'
|
|
168
|
+
&& key !== 'zhin_http_token'
|
|
169
|
+
) {
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
attempt = 0
|
|
174
|
+
connect()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
connect()
|
|
178
|
+
if (typeof window !== 'undefined') {
|
|
179
|
+
window.addEventListener('storage', onAuthOrStorage)
|
|
180
|
+
window.addEventListener('zhin:auth-required', onAuthOrStorage)
|
|
181
|
+
// Remote Console may fire this after successful login (token written).
|
|
182
|
+
window.addEventListener('zhin:auth-changed', onAuthOrStorage)
|
|
183
|
+
window.addEventListener('zhin:api-base-changed', onAuthOrStorage)
|
|
184
|
+
}
|
|
111
185
|
return () => {
|
|
186
|
+
closed = true
|
|
187
|
+
if (retryTimer) clearTimeout(retryTimer)
|
|
188
|
+
if (typeof window !== 'undefined') {
|
|
189
|
+
window.removeEventListener('storage', onAuthOrStorage)
|
|
190
|
+
window.removeEventListener('zhin:auth-required', onAuthOrStorage)
|
|
191
|
+
window.removeEventListener('zhin:auth-changed', onAuthOrStorage)
|
|
192
|
+
window.removeEventListener('zhin:api-base-changed', onAuthOrStorage)
|
|
193
|
+
}
|
|
112
194
|
wsRef.current?.close()
|
|
113
195
|
wsRef.current = null
|
|
114
196
|
setConnected(false)
|
|
@@ -279,10 +361,12 @@ export default function Sandbox() {
|
|
|
279
361
|
const newMessage: Message = { id: `msg_${Date.now()}`, type: 'sent', channelType: activeChannel.type, channelId: activeChannel.id, channelName: activeChannel.name, senderId: 'test_user', senderName: '测试用户', content: segments, timestamp: Date.now() }
|
|
280
362
|
setMessages((prev) => [...prev, newMessage]); setInputText(''); setPreviewSegments([])
|
|
281
363
|
editorRef.current?.clear()
|
|
364
|
+
// Stamp type+id so Host sandbox endpoint preserves channel context for outbound replies.
|
|
282
365
|
const payload = JSON.stringify({ type: activeChannel.type, id: activeChannel.id, content: segments, timestamp: Date.now() })
|
|
283
366
|
wsRef.current?.send(payload)
|
|
284
367
|
}
|
|
285
368
|
|
|
369
|
+
|
|
286
370
|
const clearMessages = () => { if (confirm('确定清空所有消息记录?')) setMessages([]) }
|
|
287
371
|
const switchChannel = (channel: Channel) => { setViewMode('chat'); setActiveChannel(channel); setChannels((prev) => prev.map((c) => c.id === channel.id ? { ...c, unread: 0 } : c)); if (window.innerWidth < 768) setShowChannelList(false) }
|
|
288
372
|
const addChannel = () => {
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
* 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).
|
|
14
|
+
*/
|
|
15
|
+
export default function SandboxPage() {
|
|
16
|
+
return <SandboxChat />;
|
|
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/plugin.ts
ADDED
package/schema.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"additionalProperties": false,
|
|
5
|
+
"properties": {
|
|
6
|
+
"endpoints": {
|
|
7
|
+
"type": "array",
|
|
8
|
+
"description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(name 必填,其余覆盖顶层)",
|
|
9
|
+
"items": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"additionalProperties": true,
|
|
12
|
+
"properties": {
|
|
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
|
+
]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"commandPrefix": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"default": "",
|
|
34
|
+
"description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"required": [
|
|
38
|
+
"endpoints"
|
|
39
|
+
]
|
|
40
|
+
}
|