@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.
@@ -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": "5.0.5",
4
- "description": "Zhin.js adapter for local testing and development",
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": "^1.22.0"
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.2.17",
50
- "@types/react-dom": "^19.2.3",
61
+ "@types/react": "^19.1.8",
62
+ "@types/ws": "^8.18.1",
51
63
  "typescript": "^6.0.3",
52
- "@zhin.js/cli": "1.0.93",
53
- "@zhin.js/client": "2.0.5",
54
- "@zhin.js/contract": "1.0.3",
55
- "@zhin.js/core": "1.3.4",
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
- "@zhin.js/core": "1.3.4",
62
- "@zhin.js/client": "2.0.5",
63
- "@zhin.js/host-router": "2.0.3",
64
- "@zhin.js/host-api": "2.0.5",
65
- "@zhin.js/contract": "1.0.3"
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
- "@zhin.js/host-router": {
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": "zhin 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
- buildSandboxWebSocketUrl,
5
- } from './sandboxTransport';
6
- import { User, Bot, Users, Trash2, Send, Hash, MessageSquare, Wifi, WifiOff, Smile, Image, X, Check, Info, Search, Endpoint, UserPlus, Bell, Video, Music } from 'lucide-react';
7
- import RichTextEditor, { RichTextEditorRef } from './RichTextEditor';
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
- const wsUrl = buildSandboxWebSocketUrl()
103
- wsRef.current = new WebSocket(wsUrl)
104
- wsRef.current.onopen = () => setConnected(true)
105
- wsRef.current.onmessage = (event) => {
106
- try { handleInboundPayload(JSON.parse(event.data)) }
107
- catch (err) { console.error('[Sandbox] Failed to parse message:', err) }
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
@@ -0,0 +1,8 @@
1
+ import { definePlugin } from '@zhin.js/plugin-runtime';
2
+
3
+ export default definePlugin({
4
+ name: 'sandbox',
5
+ metadata: {
6
+ displayName: 'Sandbox Adapter',
7
+ },
8
+ });
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
+ }