@zhin.js/adapter-sandbox 6.0.0 → 6.0.2

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,50 @@
1
1
  # @zhin.js/adapter-process
2
2
 
3
+ ## 6.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d5cd4aa: Publish Plugin Runtime entry points and convention modules as JavaScript so
8
+ installed npm plugins load on Node without TypeScript stripping. Workspace
9
+ development continues to prefer TypeScript sources for local HMR.
10
+
11
+ Remove the unconsumed legacy game hub APIs from game-kit; game navigation and
12
+ records now use ordinary convention commands owned by the game hub plugin.
13
+
14
+ - @zhin.js/page@1.0.4
15
+ - @zhin.js/adapter@1.1.2
16
+ - @zhin.js/core@1.4.2
17
+
18
+ ## 6.0.1
19
+
20
+ ### Patch Changes
21
+
22
+ - 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
23
+
24
+ - **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
25
+ - **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
26
+ - **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
27
+ - **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
28
+ - **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
29
+ - **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
30
+
31
+ - Updated dependencies [cdf64e7]
32
+ - Updated dependencies [2d0a159]
33
+ - Updated dependencies [5691aba]
34
+ - Updated dependencies [078e3f7]
35
+ - Updated dependencies [9c997b2]
36
+ - Updated dependencies [09d4f25]
37
+ - Updated dependencies [f0ec5ab]
38
+ - Updated dependencies [fa66c4c]
39
+ - Updated dependencies [fa66c4c]
40
+ - Updated dependencies [6cb6152]
41
+ - @zhin.js/plugin-runtime@1.1.1
42
+ - @zhin.js/host-http@1.0.3
43
+ - @zhin.js/client@2.1.1
44
+ - @zhin.js/adapter@1.1.1
45
+ - @zhin.js/core@1.4.1
46
+ - @zhin.js/page@1.0.3
47
+
3
48
  ## 6.0.0
4
49
 
5
50
  ### Patch Changes
package/README.md CHANGED
@@ -23,19 +23,19 @@ pnpm add @zhin.js/adapter-sandbox
23
23
  - `@zhin.js/adapter` — 约定式 `adapters/sandbox.ts`
24
24
  - `@zhin.js/host-http` — Root 提供的 `httpHostToken`(WebSocket `/sandbox` + Console HTTP)
25
25
  - `@zhin.js/core` — `messageGatewayToken` / ImRuntime 入站出站
26
- - `@zhin.js/page` + `pages/sandbox.tsx` — ADR 0046 约定页(`definePage`)
26
+ - `@zhin.js/page` + `pages/index.tsx` — ADR 0046 约定页(`definePage`;路由 `/sandbox`)
27
27
 
28
28
  Root 在 `zhin runtime start` 时装载 `@zhin.js/host-http`、`ConsoleRuntime` 与
29
29
  `ClientBuildModuleRuntime`。打开 `http://<host>:<port>/console` 可浏览页面;Sandbox 页
30
- (路由形如 `/…/p-sandbox`)内置 WebSocket `/sandbox` 聊天壳。
30
+ (路由 `/sandbox`,与 WebSocket `/sandbox` 同 path:GET 开页、Upgrade 走 WS)内置聊天壳。
31
31
 
32
32
  旧 `client/`(`register(api)` / `pageManager.addEntry`)仅保留给 legacy Host 栈参考,
33
33
  **不是** Plugin Runtime 生产入口。
34
34
 
35
- ### 旧 Host 栈(`zhin dev` / full-bot)
35
+ ### 旧 Host 栈(已删除)
36
+
37
+ 原 legacy 插件包 `@zhin.js/host-router`(HTTP 服务)与 `@zhin.js/host-api`(Host 侧 Console API,`addEntry` 注册 Sandbox 扩展)已删除;`zhin dev` 现由 `@zhin.js/cli` 自动装配 Console/HTTP Host(`@zhin.js/host-http` + `@zhin.js/pagemanager`),无需安装任何 Host 插件。
36
38
 
37
- - `@zhin.js/host-router` — HTTP 服务(提供 Router 和 WebSocket)
38
- - `@zhin.js/host-api` — Host 侧 Console API(`addEntry` 注册 Sandbox 扩展)
39
39
  - `@zhin.js/client` — Remote Console 客户端 SDK(UI 在 zhin-console 仓库)
40
40
 
41
41
  出站 wire 只做 JSON 封装;旧 `segment-mapper`(canonical segments)归一化上移到 gateway/core 渲染链。
@@ -0,0 +1,26 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ /**
3
+ * Convention entry: discover `adapters/sandbox.ts` → defineAdapter.
4
+ */
5
+ import { defineAdapter } from '@zhin.js/adapter';
6
+ import { messageGatewayToken } from '@zhin.js/core/runtime';
7
+ import { httpHostToken } from '@zhin.js/host-http';
8
+ import { SandboxWsEndpoint } from "../lib/endpoint.js";
9
+ import { resolveSandboxEndpoint, } from "../lib/protocol.js";
10
+ export { SandboxWsEndpoint } from "../lib/endpoint.js";
11
+ export default defineAdapter({
12
+ capabilities: ['inbound', 'outbound'],
13
+ // Console UI 直接渲染 base64 内联媒体;交互段由 Console 原生承载。
14
+ segments: {
15
+ outboundMedia: ['base64'],
16
+ interactive: 'native',
17
+ },
18
+ create(context) {
19
+ return new SandboxWsEndpoint({
20
+ id: context.id,
21
+ gateway: context.use(messageGatewayToken),
22
+ http: context.use(httpHostToken),
23
+ defaults: resolveSandboxEndpoint(context.config),
24
+ });
25
+ },
26
+ });
@@ -15,6 +15,11 @@ export type { SandboxEndpointOptions } from '../src/endpoint.js';
15
15
 
16
16
  export default defineAdapter<SandboxAdapterConfig>({
17
17
  capabilities: ['inbound', 'outbound'],
18
+ // Console UI 直接渲染 base64 内联媒体;交互段由 Console 原生承载。
19
+ segments: {
20
+ outboundMedia: ['base64'],
21
+ interactive: 'native',
22
+ },
18
23
  create(context) {
19
24
  return new SandboxWsEndpoint({
20
25
  id: context.id,
package/lib/endpoint.d.ts CHANGED
@@ -9,6 +9,10 @@ export interface SandboxEndpointOptions {
9
9
  readonly http: HttpHost;
10
10
  readonly defaults: ResolvedSandboxBot;
11
11
  }
12
+ /**
13
+ * Sandbox 是本地开发/测试面,无平台社交图谱(好友/群/频道),
14
+ * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
15
+ */
12
16
  export declare class SandboxWsEndpoint implements EndpointInstance {
13
17
  #private;
14
18
  constructor(options: SandboxEndpointOptions);
package/lib/endpoint.js CHANGED
@@ -5,10 +5,50 @@ import { randomUUID } from 'node:crypto';
5
5
  import { formatCompact, getLogger } from '@zhin.js/logger';
6
6
  import { bindSandboxWsSocket, formatSandboxOutbound, parseSandboxWsPayload, whenWsOpen, } from './protocol.js';
7
7
  const logger = getLogger('sandbox');
8
+ /**
9
+ * 多 sandbox endpoint 共用同一个 HttpHost 时,同 path 的所有 WS listener
10
+ * 都会被回调(入站重复、出站互窜)。按 endpoint 名隔离挂载路径:
11
+ * 首个占用 `/sandbox`(保持 Console 默认兼容),其余退到 `/sandbox/<name>`。
12
+ * 认领记录按 HttpHost 隔离,endpoint stop() 时必须 release。
13
+ */
14
+ const claimedWsPaths = new WeakMap();
15
+ function claimSandboxWsPath(http, name) {
16
+ let claims = claimedWsPaths.get(http);
17
+ if (!claims) {
18
+ claims = new Map();
19
+ claimedWsPaths.set(http, claims);
20
+ }
21
+ const candidates = ['/sandbox', `/sandbox/${encodeURIComponent(name)}`];
22
+ let path = candidates.find((candidate) => !claims.has(candidate) || claims.get(candidate) === name);
23
+ if (!path) {
24
+ let index = 2;
25
+ path = `/sandbox/${encodeURIComponent(name)}-${index}`;
26
+ while (claims.has(path)) {
27
+ index += 1;
28
+ path = `/sandbox/${encodeURIComponent(name)}-${index}`;
29
+ }
30
+ }
31
+ claims.set(path, name);
32
+ const claimed = path;
33
+ const registry = claims;
34
+ return {
35
+ path: claimed,
36
+ release: () => {
37
+ if (registry.get(claimed) === name)
38
+ registry.delete(claimed);
39
+ },
40
+ };
41
+ }
42
+ /**
43
+ * Sandbox 是本地开发/测试面,无平台社交图谱(好友/群/频道),
44
+ * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
45
+ */
8
46
  export class SandboxWsEndpoint {
9
47
  #options;
10
48
  #connections = new Map();
11
49
  #wsHandleRelease;
50
+ #wsPathRelease;
51
+ #wsPath = '/sandbox';
12
52
  #open = false;
13
53
  #started = false;
14
54
  constructor(options) {
@@ -22,7 +62,11 @@ export class SandboxWsEndpoint {
22
62
  if (this.#started)
23
63
  return;
24
64
  this.#started = true;
25
- const handle = this.#options.http.ws('/sandbox');
65
+ // endpoint 同 path 会被全部回调(入站重复、出站互窜),按名隔离。
66
+ const claim = claimSandboxWsPath(this.#options.http, this.#options.defaults.name);
67
+ this.#wsPathRelease = claim.release;
68
+ this.#wsPath = claim.path;
69
+ const handle = this.#options.http.ws(claim.path);
26
70
  this.#wsHandleRelease = handle.onConnection((connection) => {
27
71
  this.#acceptConnection(connection);
28
72
  });
@@ -31,7 +75,7 @@ export class SandboxWsEndpoint {
31
75
  }
32
76
  logger.info(formatCompact({
33
77
  op: 'sandbox_ws_mounted',
34
- path: '/sandbox',
78
+ path: claim.path,
35
79
  endpoint: this.#options.defaults.name,
36
80
  }));
37
81
  }
@@ -45,6 +89,8 @@ export class SandboxWsEndpoint {
45
89
  this.#open = false;
46
90
  this.#wsHandleRelease?.();
47
91
  this.#wsHandleRelease = undefined;
92
+ this.#wsPathRelease?.();
93
+ this.#wsPathRelease = undefined;
48
94
  for (const connection of this.#connections.values()) {
49
95
  connection.release();
50
96
  if (!connection.placeholder) {
@@ -194,7 +240,7 @@ export class SandboxWsEndpoint {
194
240
  data: {
195
241
  text: [
196
242
  `已连接 Sandbox「${target}」`,
197
- '与 Node Host 控制台沙盒协议一致(/sandbox)',
243
+ `与 Node Host 控制台沙盒协议一致(${this.#wsPath})`,
198
244
  '命令: help · ping · zt · status',
199
245
  ].join('\n'),
200
246
  },
package/lib/protocol.d.ts CHANGED
@@ -19,6 +19,11 @@ export type ResolvedSandboxBot = {
19
19
  readonly randomNamePerConnection: boolean;
20
20
  };
21
21
  export interface SandboxAdapterConfig {
22
+ /** Runtime expands `endpoints[i]` onto the top level — prefer these. */
23
+ readonly context?: string;
24
+ readonly name?: string;
25
+ readonly owner?: string;
26
+ /** Legacy shape: endpoint entries nested under `endpoints[]`. */
22
27
  readonly endpoints?: ReadonlyArray<{
23
28
  readonly context?: string;
24
29
  readonly name?: string;
package/lib/protocol.js CHANGED
@@ -1,9 +1,14 @@
1
1
  /** Sandbox WebSocket wire protocol helpers (no legacy Adapter/Endpoint). */
2
2
  export function resolveSandboxEndpoint(appConfig) {
3
3
  const entry = appConfig.endpoints?.find((item) => item.context === 'sandbox');
4
- const fixedName = typeof entry?.name === 'string' ? entry.name : undefined;
4
+ const fixedName = typeof appConfig.name === 'string' && appConfig.name
5
+ ? appConfig.name
6
+ : typeof entry?.name === 'string' && entry.name
7
+ ? entry.name
8
+ : undefined;
5
9
  const name = fixedName || process.env.SANDBOX_BOT_NAME || 'sandbox-bot';
6
- const owner = (typeof entry?.owner === 'string' && entry.owner)
10
+ const owner = (typeof appConfig.owner === 'string' && appConfig.owner)
11
+ || (typeof entry?.owner === 'string' && entry.owner)
7
12
  || process.env.SANDBOX_BOT_OWNER
8
13
  || 'sandbox-user';
9
14
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-sandbox",
3
- "version": "6.0.0",
3
+ "version": "6.0.2",
4
4
  "description": "Zhin.js Sandbox adapter for Plugin Runtime (WebSocket /sandbox)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -15,7 +15,7 @@
15
15
  "files": [
16
16
  "adapters",
17
17
  "pages",
18
- "plugin.ts",
18
+ "plugin.js",
19
19
  "schema.json",
20
20
  "src",
21
21
  "lib",
@@ -48,14 +48,14 @@
48
48
  "dependencies": {
49
49
  "lucide-react": "^0.525.0",
50
50
  "react": "^19.1.0",
51
- "@zhin.js/client": "2.1.0",
51
+ "@zhin.js/adapter": "1.1.2",
52
+ "@zhin.js/client": "2.1.1",
52
53
  "@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",
54
+ "@zhin.js/core": "1.4.2",
55
+ "@zhin.js/host-http": "1.0.3",
56
56
  "@zhin.js/logger": "1.0.75",
57
- "@zhin.js/page": "1.0.2",
58
- "@zhin.js/plugin-runtime": "1.1.0"
57
+ "@zhin.js/page": "1.0.4",
58
+ "@zhin.js/plugin-runtime": "1.1.1"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/react": "^19.1.8",
@@ -63,18 +63,18 @@
63
63
  "typescript": "^6.0.3",
64
64
  "vitest": "^4.1.10",
65
65
  "ws": "^8.21.0",
66
- "@zhin.js/pagemanager": "2.0.5",
67
- "@zhin.js/runtime": "1.0.2"
66
+ "@zhin.js/pagemanager": "2.0.7",
67
+ "@zhin.js/runtime": "1.0.4"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "react": "^19.0.0",
71
- "@zhin.js/adapter": "1.1.0",
72
- "@zhin.js/client": "2.1.0",
71
+ "@zhin.js/adapter": "1.1.2",
72
+ "@zhin.js/client": "2.1.1",
73
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"
74
+ "@zhin.js/core": "1.4.2",
75
+ "@zhin.js/host-http": "1.0.3",
76
+ "@zhin.js/page": "1.0.4",
77
+ "@zhin.js/plugin-runtime": "1.1.1"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "react": {
@@ -94,7 +94,7 @@
94
94
  "zhin": {
95
95
  "protocol": 1,
96
96
  "type": "plugin",
97
- "entry": "./plugin.ts",
97
+ "entry": "./plugin.js",
98
98
  "engine": "^1.0.0",
99
99
  "runtime": "trusted",
100
100
  "features": [
@@ -0,0 +1,351 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
4
+ const RichTextEditor = forwardRef(({ placeholder = '输入消息...', onSend, onChange, onAtTrigger, minHeight = '44px', maxHeight = '200px' }, ref) => {
5
+ const editorRef = useRef(null);
6
+ const atTriggerTextRef = useRef(null);
7
+ // 解析编辑器内容为文本和消息段
8
+ const parseEditorContent = () => {
9
+ if (!editorRef.current)
10
+ return { text: '', segments: [] };
11
+ let text = '';
12
+ const segments = [];
13
+ const nodes = Array.from(editorRef.current.childNodes);
14
+ for (const node of nodes) {
15
+ if (node.nodeType === Node.TEXT_NODE) {
16
+ const textContent = node.textContent || '';
17
+ if (textContent) {
18
+ text += textContent;
19
+ segments.push({ type: 'text', data: { text: textContent } });
20
+ }
21
+ }
22
+ else if (node.nodeType === Node.ELEMENT_NODE) {
23
+ const el = node;
24
+ if (el.classList.contains('editor-face')) {
25
+ const faceId = el.dataset.id;
26
+ text += `[face:${faceId}]`;
27
+ segments.push({ type: 'face', data: { id: Number(faceId) } });
28
+ }
29
+ else if (el.classList.contains('editor-image')) {
30
+ const imageUrl = el.dataset.url;
31
+ text += `[image:${imageUrl}]`;
32
+ segments.push({ type: 'image', data: { url: imageUrl } });
33
+ }
34
+ else if (el.classList.contains('editor-video')) {
35
+ const u = el.dataset.url || '';
36
+ text += `[video:${u}]`;
37
+ segments.push({ type: 'video', data: { url: u } });
38
+ }
39
+ else if (el.classList.contains('editor-audio')) {
40
+ const u = el.dataset.url || '';
41
+ text += `[audio:${u}]`;
42
+ segments.push({ type: 'audio', data: { url: u } });
43
+ }
44
+ else if (el.classList.contains('editor-at')) {
45
+ const name = el.dataset.name;
46
+ const id = el.dataset.id;
47
+ text += `[@${name}]`;
48
+ segments.push({
49
+ type: 'mention',
50
+ data: id ? { target: id, name } : { target: name, name },
51
+ });
52
+ }
53
+ else if (el.tagName === 'BR') {
54
+ text += '\n';
55
+ }
56
+ }
57
+ }
58
+ return { text, segments };
59
+ };
60
+ // 插入表情
61
+ const insertFace = (faceId) => {
62
+ if (!editorRef.current)
63
+ return;
64
+ const img = document.createElement('img');
65
+ img.src = `https://face.viki.moe/apng/${faceId}.png`;
66
+ img.alt = `[face:${faceId}]`;
67
+ img.dataset.type = 'face';
68
+ img.dataset.id = String(faceId);
69
+ img.className = 'editor-face';
70
+ insertNodeAtCursor(img);
71
+ handleChange();
72
+ };
73
+ // 插入图片
74
+ const insertImage = (url) => {
75
+ if (!editorRef.current || !url.trim())
76
+ return;
77
+ const img = document.createElement('img');
78
+ img.src = url.trim();
79
+ img.alt = `[image:${url.trim()}]`;
80
+ img.dataset.type = 'image';
81
+ img.dataset.url = url.trim();
82
+ img.className = 'editor-image';
83
+ insertNodeAtCursor(img);
84
+ handleChange();
85
+ };
86
+ const insertVideo = (url) => {
87
+ if (!editorRef.current || !url.trim())
88
+ return;
89
+ const u = url.trim();
90
+ const span = document.createElement('span');
91
+ span.className = 'editor-video';
92
+ span.dataset.url = u;
93
+ span.contentEditable = 'false';
94
+ span.textContent = '📹 视频';
95
+ insertNodeAtCursor(span);
96
+ handleChange();
97
+ };
98
+ const insertAudio = (url) => {
99
+ if (!editorRef.current || !url.trim())
100
+ return;
101
+ const u = url.trim();
102
+ const span = document.createElement('span');
103
+ span.className = 'editor-audio';
104
+ span.dataset.url = u;
105
+ span.contentEditable = 'false';
106
+ span.textContent = '🎵 音频';
107
+ insertNodeAtCursor(span);
108
+ handleChange();
109
+ };
110
+ // 插入 @ 提及
111
+ const insertAt = (name, id) => {
112
+ if (!editorRef.current || !name.trim())
113
+ return;
114
+ // 创建 @ 标签容器
115
+ const atBox = document.createElement('span');
116
+ atBox.dataset.type = 'at';
117
+ atBox.dataset.name = name;
118
+ if (id)
119
+ atBox.dataset.id = id;
120
+ atBox.className = 'editor-at';
121
+ atBox.contentEditable = 'false'; // 不可编辑
122
+ // 创建 @ 符号
123
+ const atSymbol = document.createElement('span');
124
+ atSymbol.textContent = '@';
125
+ atSymbol.className = 'editor-at-symbol';
126
+ // 创建名称
127
+ const nameText = document.createElement('span');
128
+ nameText.textContent = name;
129
+ nameText.className = 'editor-at-name';
130
+ atBox.appendChild(atSymbol);
131
+ atBox.appendChild(nameText);
132
+ insertNodeAtCursor(atBox);
133
+ handleChange();
134
+ };
135
+ // 在光标位置插入节点
136
+ const insertNodeAtCursor = (node) => {
137
+ if (!editorRef.current)
138
+ return;
139
+ // 先聚焦编辑器
140
+ editorRef.current.focus();
141
+ const selection = window.getSelection();
142
+ if (selection && selection.rangeCount > 0) {
143
+ const range = selection.getRangeAt(0);
144
+ // 检查光标是否在编辑器内部
145
+ const isInsideEditor = editorRef.current.contains(range.commonAncestorContainer);
146
+ if (isInsideEditor) {
147
+ // 光标在编辑器内,插入到光标位置
148
+ range.deleteContents();
149
+ range.insertNode(node);
150
+ range.collapse(false);
151
+ selection.removeAllRanges();
152
+ selection.addRange(range);
153
+ }
154
+ else {
155
+ // 光标不在编辑器内,追加到末尾
156
+ editorRef.current.appendChild(node);
157
+ // 移动光标到新插入的节点后面
158
+ const newRange = document.createRange();
159
+ newRange.setStartAfter(node);
160
+ newRange.collapse(true);
161
+ selection.removeAllRanges();
162
+ selection.addRange(newRange);
163
+ }
164
+ }
165
+ else {
166
+ // 没有选区,直接追加到末尾
167
+ editorRef.current.appendChild(node);
168
+ // 创建新选区并移动光标到末尾
169
+ const selection = window.getSelection();
170
+ if (selection) {
171
+ const newRange = document.createRange();
172
+ newRange.setStartAfter(node);
173
+ newRange.collapse(true);
174
+ selection.removeAllRanges();
175
+ selection.addRange(newRange);
176
+ }
177
+ }
178
+ };
179
+ // 清空编辑器
180
+ const clear = () => {
181
+ if (editorRef.current) {
182
+ editorRef.current.innerHTML = '';
183
+ handleChange();
184
+ }
185
+ };
186
+ // 聚焦编辑器
187
+ const focus = () => {
188
+ editorRef.current?.focus();
189
+ };
190
+ // 获取内容
191
+ const getContent = () => {
192
+ return parseEditorContent();
193
+ };
194
+ // 检测 @ 输入
195
+ const checkAtTrigger = () => {
196
+ if (!editorRef.current || !onAtTrigger)
197
+ return;
198
+ const selection = window.getSelection();
199
+ if (!selection || selection.rangeCount === 0) {
200
+ onAtTrigger(false, '');
201
+ atTriggerTextRef.current = null;
202
+ return;
203
+ }
204
+ const range = selection.getRangeAt(0);
205
+ // 检查光标是否在编辑器内
206
+ if (!editorRef.current.contains(range.commonAncestorContainer)) {
207
+ onAtTrigger(false, '');
208
+ atTriggerTextRef.current = null;
209
+ return;
210
+ }
211
+ // 获取光标前的文本节点
212
+ const node = range.startContainer;
213
+ if (node.nodeType !== Node.TEXT_NODE) {
214
+ onAtTrigger(false, '');
215
+ atTriggerTextRef.current = null;
216
+ return;
217
+ }
218
+ const textNode = node;
219
+ const textBeforeCursor = textNode.textContent?.substring(0, range.startOffset) || '';
220
+ // 查找最近的 @ 符号位置
221
+ const atIndex = textBeforeCursor.lastIndexOf('@');
222
+ // 检查是否找到 @ 且后面没有空格(表示仍在输入 @提及)
223
+ if (atIndex !== -1) {
224
+ const textAfterAt = textBeforeCursor.substring(atIndex + 1);
225
+ // 如果 @ 后面有空格,则不触发
226
+ if (textAfterAt.includes(' ') || textAfterAt.includes('\n')) {
227
+ onAtTrigger(false, '');
228
+ atTriggerTextRef.current = null;
229
+ return;
230
+ }
231
+ atTriggerTextRef.current = textNode;
232
+ // 计算 @ 位置
233
+ const tempRange = document.createRange();
234
+ tempRange.setStart(textNode, atIndex);
235
+ tempRange.setEnd(textNode, atIndex + 1);
236
+ const rect = tempRange.getBoundingClientRect();
237
+ const editorRect = editorRef.current.getBoundingClientRect();
238
+ // 传递搜索查询(@ 后面的文本)
239
+ onAtTrigger(true, textAfterAt, {
240
+ top: rect.bottom - editorRect.top,
241
+ left: rect.left - editorRect.left
242
+ });
243
+ }
244
+ else {
245
+ onAtTrigger(false, '');
246
+ atTriggerTextRef.current = null;
247
+ }
248
+ };
249
+ // 处理内容变化
250
+ const handleChange = () => {
251
+ checkAtTrigger();
252
+ if (onChange) {
253
+ const { text, segments } = parseEditorContent();
254
+ onChange(text, segments);
255
+ }
256
+ };
257
+ // 删除触发的 @ 符号和搜索文本并插入用户
258
+ const replaceAtTrigger = (name, id) => {
259
+ if (!atTriggerTextRef.current)
260
+ return;
261
+ const textNode = atTriggerTextRef.current;
262
+ const text = textNode.textContent || '';
263
+ const atIndex = text.lastIndexOf('@');
264
+ if (atIndex !== -1) {
265
+ // 找到 @ 后面的内容(搜索文本)
266
+ const textAfter = text.substring(atIndex + 1);
267
+ const endIndex = atIndex + 1 + textAfter.split(/[\s\n]/)[0].length;
268
+ // 删除 @ 符号和搜索文本
269
+ const beforeAt = text.substring(0, atIndex);
270
+ const afterSearch = text.substring(endIndex);
271
+ textNode.textContent = beforeAt + afterSearch;
272
+ // 移动光标到删除位置
273
+ const selection = window.getSelection();
274
+ if (selection) {
275
+ const range = document.createRange();
276
+ range.setStart(textNode, atIndex);
277
+ range.collapse(true);
278
+ selection.removeAllRanges();
279
+ selection.addRange(range);
280
+ }
281
+ }
282
+ atTriggerTextRef.current = null;
283
+ insertAt(name, id);
284
+ };
285
+ // 处理粘贴事件
286
+ const handlePaste = (e) => {
287
+ e.preventDefault();
288
+ const clipboardData = e.clipboardData;
289
+ // 优先处理图片粘贴
290
+ const items = Array.from(clipboardData.items);
291
+ const imageItem = items.find((item) => item.type.startsWith('image/'));
292
+ if (imageItem) {
293
+ const file = imageItem.getAsFile();
294
+ if (file) {
295
+ const reader = new FileReader();
296
+ reader.onload = () => {
297
+ if (typeof reader.result === 'string') {
298
+ insertImage(reader.result);
299
+ }
300
+ };
301
+ reader.readAsDataURL(file);
302
+ }
303
+ return;
304
+ }
305
+ // 纯文本粘贴(去除富文本格式)
306
+ const text = clipboardData.getData('text/plain');
307
+ if (text) {
308
+ document.execCommand('insertText', false, text);
309
+ handleChange();
310
+ }
311
+ };
312
+ // 处理键盘事件
313
+ const handleKeyDown = (e) => {
314
+ if (e.key === 'Enter' && !e.shiftKey) {
315
+ e.preventDefault();
316
+ if (onSend) {
317
+ const { text, segments } = parseEditorContent();
318
+ onSend(text, segments);
319
+ }
320
+ }
321
+ };
322
+ // 暴露方法给父组件
323
+ useImperativeHandle(ref, () => ({
324
+ focus,
325
+ clear,
326
+ insertFace,
327
+ insertImage,
328
+ insertVideo,
329
+ insertAudio,
330
+ insertAt,
331
+ replaceAtTrigger,
332
+ getContent
333
+ }));
334
+ return (_jsx("div", { ref: editorRef, contentEditable: true, suppressContentEditableWarning: true, onInput: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, "data-placeholder": placeholder, className: "rich-text-editor", style: {
335
+ width: '100%',
336
+ minHeight,
337
+ maxHeight,
338
+ padding: '0.5rem 0.75rem',
339
+ border: '1px solid var(--gray-6)',
340
+ borderRadius: '6px',
341
+ backgroundColor: 'var(--gray-1)',
342
+ fontSize: 'var(--font-size-2)',
343
+ outline: 'none',
344
+ overflowY: 'auto',
345
+ lineHeight: '1.5',
346
+ wordWrap: 'break-word',
347
+ color: 'var(--gray-12)'
348
+ } }));
349
+ });
350
+ RichTextEditor.displayName = 'RichTextEditor';
351
+ export default RichTextEditor;
@@ -0,0 +1,363 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import React, { useState, useEffect, useRef } from 'react';
4
+ import { cn, resolveMediaSrc, pickMediaRawUrl, } from '@zhin.js/client';
5
+ import { buildSandboxWebSocketUrl } from './sandboxTransport';
6
+ import { User, Bot, Users, Trash2, Send, Hash, MessageSquare, Wifi, WifiOff, Smile, Image, X, Check, Info, Search, UserPlus, Bell, Video, Music, } from 'lucide-react';
7
+ import RichTextEditor, {} from './RichTextEditor';
8
+ export default function Sandbox() {
9
+ const [messages, setMessages] = useState([]);
10
+ const [channels, setChannels] = useState([
11
+ { id: 'user_1001', name: '测试用户', type: 'private', unread: 0 },
12
+ { id: 'group_2001', name: '测试群组', type: 'group', unread: 0 },
13
+ { id: 'channel_3001', name: '测试频道', type: 'channel', unread: 0 }
14
+ ]);
15
+ const [faceList, setFaceList] = useState([]);
16
+ const [activeChannel, setActiveChannel] = useState(channels[0]);
17
+ const [inputText, setInputText] = useState('');
18
+ const [endpointName, setBotName] = useState('ProcessEndpoint');
19
+ const [connected, setConnected] = useState(false);
20
+ const [showFacePicker, setShowFacePicker] = useState(false);
21
+ /** 输入区:插入图片 / 视频 / 音频 URL */
22
+ const [mediaPanel, setMediaPanel] = useState(null);
23
+ const [mediaUrl, setMediaUrl] = useState('');
24
+ const [showAtPicker, setShowAtPicker] = useState(false);
25
+ const [atPopoverPosition, setAtPopoverPosition] = useState(null);
26
+ const [atSearchQuery, setAtSearchQuery] = useState('');
27
+ const [faceSearchQuery, setFaceSearchQuery] = useState('');
28
+ const [atUserName, setAtUserName] = useState('');
29
+ const [atSuggestions] = useState([
30
+ { id: '10001', name: '张三' }, { id: '10002', name: '李四' }, { id: '10003', name: '王五' },
31
+ { id: '10004', name: '赵六' }, { id: '10005', name: '测试用户' }, { id: '10086', name: 'Admin' },
32
+ { id: '10010', name: 'Test User' }
33
+ ]);
34
+ const [previewSegments, setPreviewSegments] = useState([]);
35
+ const [showChannelList, setShowChannelList] = useState(false);
36
+ const [viewMode, setViewMode] = useState('chat');
37
+ const messagesEndRef = useRef(null);
38
+ const wsRef = useRef(null);
39
+ const editorRef = useRef(null);
40
+ const fetchFaceList = async () => {
41
+ try {
42
+ const res = await fetch('https://face.viki.moe/metadata.json');
43
+ setFaceList(await res.json());
44
+ }
45
+ catch (err) {
46
+ console.error('[Sandbox] Failed to fetch face list:', err);
47
+ }
48
+ };
49
+ useEffect(() => { fetchFaceList(); }, []);
50
+ const handleInboundPayload = (data) => {
51
+ if (data.type === 'edit' && data.messageId) {
52
+ const content = Array.isArray(data.content)
53
+ ? data.content
54
+ : parseTextToSegments(String(data.content ?? ''));
55
+ setMessages((prev) => prev.map((m) => (m.id === data.messageId ? { ...m, content } : m)));
56
+ return;
57
+ }
58
+ const content = typeof data.content === 'string'
59
+ ? parseTextToSegments(data.content)
60
+ : Array.isArray(data.content) ? data.content : parseTextToSegments(String(data.content ?? ''));
61
+ const channelName = data.type === 'private'
62
+ ? `私聊-${data.bot || endpointName}`
63
+ : data.type === 'group'
64
+ ? `群组-${data.id}`
65
+ : `频道-${data.id}`;
66
+ const channelType = data.type;
67
+ setChannels((prev) => {
68
+ if (prev.some((c) => c.id === data.id))
69
+ return prev;
70
+ const created = { id: data.id, name: channelName, type: channelType, unread: 0 };
71
+ setActiveChannel(created);
72
+ return [...prev, created];
73
+ });
74
+ setMessages((prev) => [...prev, {
75
+ id: data.messageId ?? `bot_${data.timestamp}`, type: 'received', channelType,
76
+ channelId: data.id, channelName, senderId: 'endpoint',
77
+ senderName: data.bot || endpointName, content, timestamp: data.timestamp,
78
+ }]);
79
+ };
80
+ const sendInteractiveAction = (payload) => {
81
+ const segments = [{ type: 'action', data: { id: payload, payload } }];
82
+ const payloadJson = JSON.stringify({ type: activeChannel.type, id: activeChannel.id, content: segments, timestamp: Date.now() });
83
+ wsRef.current?.send(payloadJson);
84
+ };
85
+ useEffect(() => {
86
+ let closed = false;
87
+ let retryTimer;
88
+ let attempt = 0;
89
+ /** Ignore close events from a socket we intentionally replaced (login/base change). */
90
+ let replaceInFlight = false;
91
+ const connect = () => {
92
+ if (closed)
93
+ return;
94
+ if (retryTimer) {
95
+ clearTimeout(retryTimer);
96
+ retryTimer = undefined;
97
+ }
98
+ const wsUrl = buildSandboxWebSocketUrl();
99
+ // Tear down previous socket before opening a new one so we don't
100
+ // leave two concurrent /sandbox sessions for fixed-name endpoints.
101
+ const previous = wsRef.current;
102
+ if (previous) {
103
+ replaceInFlight = true;
104
+ previous.onclose = null;
105
+ previous.onerror = null;
106
+ previous.onmessage = null;
107
+ previous.onopen = null;
108
+ try {
109
+ previous.close();
110
+ }
111
+ catch { /* already closed */ }
112
+ replaceInFlight = false;
113
+ }
114
+ const ws = new WebSocket(wsUrl);
115
+ wsRef.current = ws;
116
+ ws.onopen = () => {
117
+ attempt = 0;
118
+ setConnected(true);
119
+ };
120
+ ws.onmessage = (event) => {
121
+ try {
122
+ handleInboundPayload(JSON.parse(String(event.data)));
123
+ }
124
+ catch (err) {
125
+ console.error('[Sandbox] Failed to parse message:', err);
126
+ }
127
+ };
128
+ ws.onclose = () => {
129
+ if (wsRef.current !== ws)
130
+ return;
131
+ setConnected(false);
132
+ wsRef.current = null;
133
+ if (closed || replaceInFlight)
134
+ return;
135
+ const delay = Math.min(8_000, 500 * 2 ** attempt);
136
+ attempt += 1;
137
+ retryTimer = setTimeout(connect, delay);
138
+ };
139
+ ws.onerror = () => {
140
+ /* close handler reconnects */
141
+ };
142
+ };
143
+ const onAuthOrStorage = (event) => {
144
+ // storage fires for other tabs; same-tab login sets localStorage then
145
+ // dispatches zhin:auth-required / custom login events.
146
+ if (event && event.type === 'storage') {
147
+ const key = event.key;
148
+ if (key != null
149
+ && key !== 'zhin_api_token'
150
+ && key !== 'zhin_api_base'
151
+ && key !== 'HTTP_TOKEN'
152
+ && key !== 'zhin_http_token') {
153
+ return;
154
+ }
155
+ }
156
+ attempt = 0;
157
+ connect();
158
+ };
159
+ connect();
160
+ if (typeof window !== 'undefined') {
161
+ window.addEventListener('storage', onAuthOrStorage);
162
+ window.addEventListener('zhin:auth-required', onAuthOrStorage);
163
+ // Remote Console may fire this after successful login (token written).
164
+ window.addEventListener('zhin:auth-changed', onAuthOrStorage);
165
+ window.addEventListener('zhin:api-base-changed', onAuthOrStorage);
166
+ }
167
+ return () => {
168
+ closed = true;
169
+ if (retryTimer)
170
+ clearTimeout(retryTimer);
171
+ if (typeof window !== 'undefined') {
172
+ window.removeEventListener('storage', onAuthOrStorage);
173
+ window.removeEventListener('zhin:auth-required', onAuthOrStorage);
174
+ window.removeEventListener('zhin:auth-changed', onAuthOrStorage);
175
+ window.removeEventListener('zhin:api-base-changed', onAuthOrStorage);
176
+ }
177
+ wsRef.current?.close();
178
+ wsRef.current = null;
179
+ setConnected(false);
180
+ };
181
+ }, []);
182
+ useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
183
+ useEffect(() => { setPreviewSegments(inputText.trim() ? parseTextToSegments(inputText) : []); }, [inputText]);
184
+ const parseTextToSegments = (text) => {
185
+ const segments = [];
186
+ const regex = /\[@([^\]]+)\]|\[face:(\d+)\]|\[image:([^\]]+)\]|\[video:([^\]]+)\]|\[audio:([^\]]+)\]/g;
187
+ let lastIndex = 0;
188
+ let match;
189
+ while ((match = regex.exec(text)) !== null) {
190
+ if (match.index > lastIndex) {
191
+ const t = text.substring(lastIndex, match.index);
192
+ if (t)
193
+ segments.push({ type: 'text', data: { text: t } });
194
+ }
195
+ if (match[1])
196
+ segments.push({ type: 'mention', data: { target: match[1], name: match[1] } });
197
+ else if (match[2])
198
+ segments.push({ type: 'face', data: { id: parseInt(match[2], 10) } });
199
+ else if (match[3])
200
+ segments.push({ type: 'image', data: { url: match[3] } });
201
+ else if (match[4])
202
+ segments.push({ type: 'video', data: { url: match[4] } });
203
+ else if (match[5])
204
+ segments.push({ type: 'audio', data: { url: match[5] } });
205
+ lastIndex = regex.lastIndex;
206
+ }
207
+ if (lastIndex < text.length) {
208
+ const r = text.substring(lastIndex);
209
+ if (r)
210
+ segments.push({ type: 'text', data: { text: r } });
211
+ }
212
+ return segments.length > 0 ? segments : [{ type: 'text', data: { text } }];
213
+ };
214
+ const hasRenderableSegments = (segments) => {
215
+ if (segments.length === 0)
216
+ return false;
217
+ return segments.some((s) => {
218
+ if (s.type === 'text')
219
+ return Boolean(String(s.data?.text ?? '').trim());
220
+ if (s.type === 'keyboard')
221
+ return true;
222
+ return true;
223
+ });
224
+ };
225
+ const renderMessageSegments = (segments, isSent) => {
226
+ const ring = isSent ? 'ring-1 ring-primary-foreground/25' : 'ring-1 ring-border/60';
227
+ return segments.map((segment, index) => {
228
+ if (typeof segment === 'string') {
229
+ return _jsx("span", { children: segment.split('\n').map((part, i) => _jsxs(React.Fragment, { children: [part, i < segment.split('\n').length - 1 && _jsx("br", {})] }, i)) }, index);
230
+ }
231
+ const d = segment.data;
232
+ switch (segment.type) {
233
+ case 'text':
234
+ return _jsx("span", { children: String(d.text ?? '').split('\n').map((part, i) => _jsxs(React.Fragment, { children: [part, i < String(d.text ?? '').split('\n').length - 1 && _jsx("br", {})] }, i)) }, index);
235
+ case 'mention':
236
+ case 'at':
237
+ return _jsxs("span", { className: "inline-flex items-center px-1.5 py-0.5 rounded bg-accent text-accent-foreground text-xs mx-0.5", children: ["@", String(d.name ?? d.target ?? d.qq ?? '')] }, index);
238
+ case 'face':
239
+ return _jsx("img", { src: `https://face.viki.moe/apng/${d.id}.png`, alt: String(d.name ?? ''), className: "w-6 h-6 inline-block align-middle mx-0.5", title: String(d.name ?? d.id ?? '') }, index);
240
+ case 'dice':
241
+ return _jsxs("span", { className: "inline-flex items-center px-1.5 py-0.5 rounded bg-secondary text-xs mx-0.5", children: ["\uD83C\uDFB2 ", d.result != null ? `点数 ${String(d.result)}` : '骰子'] }, index);
242
+ case 'rps':
243
+ return _jsxs("span", { className: "inline-flex items-center px-1.5 py-0.5 rounded bg-secondary text-xs mx-0.5", children: ["\u270A ", d.result != null ? `结果 ${String(d.result)}` : '猜拳'] }, index);
244
+ case 'image': {
245
+ const raw = pickMediaRawUrl(d);
246
+ const src = resolveMediaSrc(raw, 'image');
247
+ if (!src)
248
+ return _jsx("span", { className: "text-xs opacity-70", children: "[\u56FE\u7247]" }, index);
249
+ return (_jsx("a", { href: src, target: "_blank", rel: "noreferrer", className: "block my-1", children: _jsx("img", { src: src, alt: "", className: cn('max-w-[min(320px,88vw)] rounded-lg block', ring, 'ring-offset-0'), onError: (e) => { e.target.style.display = 'none'; } }) }, index));
250
+ }
251
+ case 'video': {
252
+ const raw = pickMediaRawUrl(d);
253
+ const src = resolveMediaSrc(raw, 'video');
254
+ if (!src)
255
+ return _jsx("span", { className: "text-xs opacity-70", children: "[\u89C6\u9891\u65E0\u5730\u5740]" }, index);
256
+ return (_jsx("video", { src: src, controls: true, playsInline: true, preload: "metadata", className: cn('max-w-[min(360px,92vw)] max-h-72 rounded-lg my-1 bg-black/10', ring) }, index));
257
+ }
258
+ case 'audio':
259
+ case 'record': {
260
+ const raw = pickMediaRawUrl(d);
261
+ const src = resolveMediaSrc(raw, 'audio');
262
+ if (!src)
263
+ return _jsx("span", { className: "text-xs opacity-70", children: "[\u97F3\u9891\u65E0\u5730\u5740]" }, index);
264
+ return (_jsx("audio", { src: src, controls: true, preload: "metadata", className: cn('w-full max-w-sm my-2 h-10', isSent && 'opacity-95') }, index));
265
+ }
266
+ case 'reply':
267
+ return (_jsxs("div", { className: "mb-1 rounded-md border border-dashed px-2 py-1 text-xs opacity-90", children: ["\u21A9 \u5F15\u7528\u6D88\u606F #", String(d.message_id ?? d.id ?? '')] }, index));
268
+ case 'forward': {
269
+ const messages = d.messages;
270
+ const title = String(d.title ?? '聊天记录');
271
+ return (_jsxs("div", { className: "my-1 rounded-md border bg-background/40 px-2 py-2 text-xs space-y-1", children: [_jsxs("div", { className: "font-medium", children: ["\uD83D\uDCE8 ", title] }), Array.isArray(messages) && messages.length > 0 ? (_jsxs("div", { className: "space-y-1 pl-2 border-l-2 border-muted", children: [messages.slice(0, 3).map((batch, bi) => (_jsx("div", { className: "opacity-90", children: batch.map((s, si) => (_jsx("span", { children: s.type === 'text' ? String(s.data?.text ?? '') : `[${s.type ?? 'seg'}]` }, si))) }, bi))), messages.length > 3 && _jsxs("div", { className: "opacity-60", children: ["\u2026\u5171 ", messages.length, " \u6761"] })] })) : (_jsx("div", { className: "opacity-70", children: "[\u5408\u5E76\u8F6C\u53D1]" }))] }, index));
272
+ }
273
+ case 'keyboard': {
274
+ const rows = d.rows ?? [];
275
+ return (_jsx("div", { className: "inline-grid gap-1 my-1", children: rows.map((row, ri) => (_jsx("div", { className: "flex gap-1", children: row.map((btn) => (_jsx("button", { type: "button", disabled: btn.disabled || isSent, onClick: () => sendInteractiveAction(btn.payload), className: cn('min-w-9 h-9 rounded-md border text-sm font-medium transition-colors', btn.disabled
276
+ ? 'opacity-50 cursor-not-allowed'
277
+ : 'hover:bg-accent active:scale-95'), children: btn.label }, btn.payload))) }, ri))) }, index));
278
+ }
279
+ default:
280
+ return _jsxs("span", { className: "text-xs opacity-70", children: ["[", segment.type, "]"] }, index);
281
+ }
282
+ });
283
+ };
284
+ const handleSendMessage = (text, segments) => {
285
+ if (!hasRenderableSegments(segments))
286
+ return;
287
+ const newMessage = { id: `msg_${Date.now()}`, type: 'sent', channelType: activeChannel.type, channelId: activeChannel.id, channelName: activeChannel.name, senderId: 'test_user', senderName: '测试用户', content: segments, timestamp: Date.now() };
288
+ setMessages((prev) => [...prev, newMessage]);
289
+ setInputText('');
290
+ setPreviewSegments([]);
291
+ editorRef.current?.clear();
292
+ // Stamp type+id so Host sandbox endpoint preserves channel context for outbound replies.
293
+ const payload = JSON.stringify({ type: activeChannel.type, id: activeChannel.id, content: segments, timestamp: Date.now() });
294
+ wsRef.current?.send(payload);
295
+ };
296
+ const clearMessages = () => { if (confirm('确定清空所有消息记录?'))
297
+ setMessages([]); };
298
+ const switchChannel = (channel) => { setViewMode('chat'); setActiveChannel(channel); setChannels((prev) => prev.map((c) => c.id === channel.id ? { ...c, unread: 0 } : c)); if (window.innerWidth < 768)
299
+ setShowChannelList(false); };
300
+ const addChannel = () => {
301
+ const types = ['private', 'group', 'channel'];
302
+ const type = types[Math.floor(Math.random() * types.length)];
303
+ const name = prompt(`请输入频道名称:`);
304
+ if (name) {
305
+ const nc = { id: `${type}_${Date.now()}`, name, type, unread: 0 };
306
+ setChannels((p) => [...p, nc]);
307
+ setActiveChannel(nc);
308
+ }
309
+ };
310
+ const getChannelIcon = (type) => { switch (type) {
311
+ case 'private': return _jsx(User, { size: 16 });
312
+ case 'group': return _jsx(Users, { size: 16 });
313
+ case 'channel': return _jsx(Hash, { size: 16 });
314
+ default: return _jsx(MessageSquare, { size: 16 });
315
+ } };
316
+ const insertFace = (faceId) => { editorRef.current?.insertFace(faceId); setShowFacePicker(false); };
317
+ const commitMediaUrl = () => {
318
+ const u = mediaUrl.trim();
319
+ if (!u || !mediaPanel)
320
+ return;
321
+ if (mediaPanel === 'image')
322
+ editorRef.current?.insertImage(u);
323
+ else if (mediaPanel === 'video')
324
+ editorRef.current?.insertVideo(u);
325
+ else
326
+ editorRef.current?.insertAudio(u);
327
+ setMediaUrl('');
328
+ setMediaPanel(null);
329
+ };
330
+ const insertAtUser = () => { if (!atUserName.trim())
331
+ return; editorRef.current?.insertAt(atUserName.trim()); setAtUserName(''); setShowAtPicker(false); };
332
+ const selectAtUser = (user) => { editorRef.current?.replaceAtTrigger(user.name, user.id); setAtPopoverPosition(null); setAtSearchQuery(''); };
333
+ const handleAtTrigger = (show, searchQuery, position) => {
334
+ if (activeChannel.type === 'private') {
335
+ setAtPopoverPosition(null);
336
+ setAtSearchQuery('');
337
+ return;
338
+ }
339
+ if (show && position) {
340
+ setAtPopoverPosition(position);
341
+ setAtSearchQuery(searchQuery);
342
+ }
343
+ else {
344
+ setAtPopoverPosition(null);
345
+ setAtSearchQuery('');
346
+ }
347
+ };
348
+ const filteredAtSuggestions = atSuggestions.filter((user) => { if (!atSearchQuery.trim())
349
+ return true; const q = atSearchQuery.toLowerCase(); return user.name.toLowerCase().includes(q) || user.id.toLowerCase().includes(q); });
350
+ const handleEditorChange = (text, segments) => { setInputText(text); setPreviewSegments(segments); };
351
+ const filteredFaces = faceList.filter(face => face.name.toLowerCase().includes(faceSearchQuery.toLowerCase()) || face.describe.toLowerCase().includes(faceSearchQuery.toLowerCase()));
352
+ const channelMessages = messages.filter((msg) => msg.channelId === activeChannel.id);
353
+ return (_jsxs("div", { className: "sandbox-container rounded-xl border border-border/70 bg-card/30 shadow-sm", children: [_jsxs("button", { className: "mobile-channel-toggle md:hidden", onClick: () => setShowChannelList(!showChannelList), children: [_jsx(MessageSquare, { size: 20 }), " \u9891\u9053\u5217\u8868"] }), _jsxs("div", { className: cn("channel-sidebar rounded-lg border bg-card", showChannelList && "show"), children: [_jsx("div", { className: "p-3 border-b", children: _jsxs("div", { className: "flex justify-between items-center", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "p-1 rounded-md bg-secondary", children: _jsx(MessageSquare, { size: 16, className: "text-muted-foreground" }) }), _jsx("h3", { className: "font-semibold", children: "\u9891\u9053\u5217\u8868" })] }), _jsxs("span", { className: cn("inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border", connected ? "bg-emerald-100 text-emerald-800 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800" : "bg-muted text-muted-foreground"), children: [connected ? _jsx(Wifi, { size: 12 }) : _jsx(WifiOff, { size: 12 }), connected ? '已连接' : '未连接'] })] }) }), _jsxs("div", { className: "flex-1 overflow-y-auto p-2 space-y-1", children: [channels.map((channel) => {
354
+ const isActive = viewMode === 'chat' && activeChannel.id === channel.id;
355
+ return (_jsxs("div", { className: cn("menu-item", isActive && "active"), onClick: () => switchChannel(channel), children: [_jsx("span", { className: "shrink-0", children: getChannelIcon(channel.type) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-sm font-medium truncate", children: channel.name }), _jsx("div", { className: "text-xs text-muted-foreground", children: channel.type === 'private' ? '私聊' : channel.type === 'group' ? '群聊' : '频道' })] }), channel.unread > 0 && _jsx("span", { className: "inline-flex items-center justify-center h-5 min-w-5 rounded-full bg-destructive text-destructive-foreground text-[10px] font-medium px-1", children: channel.unread })] }, channel.id));
356
+ }), _jsxs("div", { className: "pt-2 mt-2 border-t space-y-1", children: [_jsxs("div", { className: cn("menu-item", viewMode === 'requests' && "active"), onClick: () => { setViewMode('requests'); if (window.innerWidth < 768)
357
+ setShowChannelList(false); }, children: [_jsx(UserPlus, { size: 16, className: "shrink-0" }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-sm font-medium", children: "\u8BF7\u6C42" }), _jsx("div", { className: "text-xs text-muted-foreground", children: "\u597D\u53CB/\u7FA4\u9080\u8BF7\u7B49" })] })] }), _jsxs("div", { className: cn("menu-item", viewMode === 'notices' && "active"), onClick: () => { setViewMode('notices'); if (window.innerWidth < 768)
358
+ setShowChannelList(false); }, children: [_jsx(Bell, { size: 16, className: "shrink-0" }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-sm font-medium", children: "\u901A\u77E5" }), _jsx("div", { className: "text-xs text-muted-foreground", children: "\u7FA4\u7BA1/\u64A4\u56DE\u7B49" })] })] })] })] }), _jsx("div", { className: "p-2 border-t", children: _jsx("button", { className: "w-full py-2 px-3 rounded-md border border-dashed text-sm text-muted-foreground hover:bg-accent transition-colors", onClick: addChannel, children: "+ \u6DFB\u52A0\u9891\u9053" }) })] }), showChannelList && _jsx("div", { className: "channel-overlay md:hidden", onClick: () => setShowChannelList(false) }), _jsxs("div", { className: "chat-area", children: [viewMode === 'requests' && (_jsxs("div", { className: "rounded-lg border bg-card flex-1 flex flex-col min-h-0 overflow-hidden", children: [_jsx("div", { className: "p-3 border-b flex-shrink-0", children: _jsxs("h2", { className: "text-lg font-bold flex items-center gap-2", children: [_jsx(UserPlus, { size: 20 }), " \u8BF7\u6C42"] }) }), _jsxs("div", { className: "flex-1 overflow-y-auto p-4 flex flex-col items-center justify-center gap-3 text-muted-foreground text-center", children: [_jsx(UserPlus, { size: 48, className: "opacity-30" }), _jsx("span", { children: "\u6C99\u76D2\u4E3A\u6A21\u62DF\u73AF\u5883\uFF0C\u6682\u65E0\u8BF7\u6C42\u6570\u636E" }), _jsxs("span", { className: "text-sm", children: ["\u5B9E\u9645\u597D\u53CB/\u7FA4\u9080\u8BF7\u7B49\u8BF7\u6C42\u8BF7\u5230\u4FA7\u8FB9\u680F ", _jsx("strong", { children: "\u673A\u5668\u4EBA" }), " \u9875\u9762\u8FDB\u5165\u5BF9\u5E94\u673A\u5668\u4EBA\u7BA1\u7406\u67E5\u770B"] })] })] })), viewMode === 'notices' && (_jsxs("div", { className: "rounded-lg border bg-card flex-1 flex flex-col min-h-0 overflow-hidden", children: [_jsx("div", { className: "p-3 border-b flex-shrink-0", children: _jsxs("h2", { className: "text-lg font-bold flex items-center gap-2", children: [_jsx(Bell, { size: 20 }), " \u901A\u77E5"] }) }), _jsxs("div", { className: "flex-1 overflow-y-auto p-4 flex flex-col items-center justify-center gap-3 text-muted-foreground text-center", children: [_jsx(Bell, { size: 48, className: "opacity-30" }), _jsx("span", { children: "\u6C99\u76D2\u4E3A\u6A21\u62DF\u73AF\u5883\uFF0C\u6682\u65E0\u901A\u77E5\u6570\u636E" }), _jsxs("span", { className: "text-sm", children: ["\u5B9E\u9645\u7FA4\u7BA1\u3001\u64A4\u56DE\u7B49\u901A\u77E5\u8BF7\u5230\u4FA7\u8FB9\u680F ", _jsx("strong", { children: "\u673A\u5668\u4EBA" }), " \u9875\u9762\u8FDB\u5165\u5BF9\u5E94\u673A\u5668\u4EBA\u7BA1\u7406\u67E5\u770B"] })] })] })), viewMode === 'chat' && (_jsxs(_Fragment, { children: [_jsx("div", { className: "rounded-lg border bg-card p-3 flex-shrink-0", children: _jsxs("div", { className: "flex justify-between items-center flex-wrap gap-2", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "p-2 rounded-lg bg-secondary", children: getChannelIcon(activeChannel.type) }), _jsxs("div", { children: [_jsx("h2", { className: "text-lg font-bold", children: activeChannel.name }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [_jsx("span", { children: activeChannel.id }), _jsx("span", { className: "inline-flex items-center px-1.5 py-0.5 rounded border text-[10px]", children: channelMessages.length }), _jsx("span", { children: "\u6761\u6D88\u606F" })] })] }), _jsx("span", { className: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-secondary text-secondary-foreground", children: activeChannel.type === 'private' ? '私聊' : activeChannel.type === 'group' ? '群聊' : '频道' })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { value: endpointName, onChange: (e) => setBotName(e.target.value), placeholder: "\u673A\u5668\u4EBA\u540D\u79F0", className: "h-8 w-28 rounded-md border bg-transparent px-2 text-sm" }), _jsxs("button", { className: "inline-flex items-center gap-1 h-8 px-3 rounded-md bg-secondary text-secondary-foreground text-sm hover:bg-secondary/80", onClick: clearMessages, children: [_jsx(Trash2, { size: 14 }), " \u6E05\u7A7A"] })] })] }) }), _jsx("div", { className: "rounded-lg border bg-card flex-1 flex flex-col min-h-0", children: _jsx("div", { className: "flex-1 overflow-y-auto p-4", children: channelMessages.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center h-full gap-3", children: [_jsx(MessageSquare, { size: 64, className: "text-muted-foreground/20" }), _jsx("span", { className: "text-muted-foreground", children: "\u6682\u65E0\u6D88\u606F\uFF0C\u5F00\u59CB\u5BF9\u8BDD\u5427\uFF01" })] })) : (_jsxs("div", { className: "space-y-2", children: [channelMessages.map((msg) => (_jsx("div", { className: cn("flex", msg.type === 'sent' ? "justify-end" : "justify-start"), children: _jsxs("div", { className: cn("max-w-[70%] p-3 rounded-2xl", msg.type === 'sent' ? "bg-primary text-primary-foreground" : "bg-muted"), children: [_jsxs("div", { className: "flex items-center gap-2 mb-1", children: [msg.type === 'received' && _jsx(Bot, { size: 14 }), msg.type === 'sent' && _jsx(User, { size: 14 }), _jsx("span", { className: "text-xs font-medium opacity-90", children: msg.senderName }), _jsx("span", { className: "text-xs opacity-70", children: new Date(msg.timestamp).toLocaleTimeString() })] }), _jsx("div", { className: "text-sm space-y-1", children: renderMessageSegments(msg.content, msg.type === 'sent') })] }) }, msg.id))), _jsx("div", { ref: messagesEndRef })] })) }) }), _jsxs("div", { className: "rounded-lg border bg-card p-3 flex-shrink-0 space-y-3", children: [_jsxs("div", { className: "flex gap-2 items-center flex-wrap", children: [_jsx("button", { type: "button", className: cn("h-8 w-8 rounded-md flex items-center justify-center border transition-colors", showFacePicker ? "bg-primary text-primary-foreground" : "hover:bg-accent"), onClick: () => { setShowFacePicker(!showFacePicker); setMediaPanel(null); }, title: "\u63D2\u5165\u8868\u60C5", children: _jsx(Smile, { size: 16 }) }), _jsx("button", { type: "button", className: cn("h-8 w-8 rounded-md flex items-center justify-center border transition-colors", mediaPanel === 'image' ? "bg-primary text-primary-foreground" : "hover:bg-accent"), onClick: () => { setMediaPanel((p) => (p === 'image' ? null : 'image')); setShowFacePicker(false); }, title: "\u63D2\u5165\u56FE\u7247 URL", children: _jsx(Image, { size: 16 }) }), _jsx("button", { type: "button", className: cn("h-8 w-8 rounded-md flex items-center justify-center border transition-colors", mediaPanel === 'video' ? "bg-primary text-primary-foreground" : "hover:bg-accent"), onClick: () => { setMediaPanel((p) => (p === 'video' ? null : 'video')); setShowFacePicker(false); }, title: "\u63D2\u5165\u89C6\u9891 URL", children: _jsx(Video, { size: 16 }) }), _jsx("button", { type: "button", className: cn("h-8 w-8 rounded-md flex items-center justify-center border transition-colors", mediaPanel === 'audio' ? "bg-primary text-primary-foreground" : "hover:bg-accent"), onClick: () => { setMediaPanel((p) => (p === 'audio' ? null : 'audio')); setShowFacePicker(false); }, title: "\u63D2\u5165\u97F3\u9891 URL", children: _jsx(Music, { size: 16 }) }), _jsx("div", { className: "flex-1 min-w-[1rem]" }), inputText && (_jsx("button", { className: "h-8 w-8 rounded-md flex items-center justify-center hover:bg-accent transition-colors", onClick: () => { setInputText(''); setPreviewSegments([]); }, children: _jsx(X, { size: 16 }) }))] }), showFacePicker && (_jsxs("div", { className: "p-3 rounded-md border bg-muted/30 max-h-64 overflow-y-auto space-y-2", children: [_jsx("input", { value: faceSearchQuery, onChange: (e) => setFaceSearchQuery(e.target.value), placeholder: "\u641C\u7D22\u8868\u60C5...", className: "w-full h-8 rounded-md border bg-transparent px-2 text-sm" }), _jsx("div", { className: "grid grid-cols-8 gap-1", children: filteredFaces.slice(0, 80).map((face) => (_jsx("button", { onClick: () => insertFace(face.id), title: face.name, className: "w-10 h-10 rounded-md border flex items-center justify-center hover:bg-accent transition-colors", children: _jsx("img", { src: `https://face.viki.moe/apng/${face.id}.png`, alt: face.name, className: "w-8 h-8" }) }, face.id))) }), filteredFaces.length === 0 && (_jsxs("div", { className: "flex flex-col items-center gap-2 py-4", children: [_jsx(Search, { size: 32, className: "text-muted-foreground/30" }), _jsx("span", { className: "text-sm text-muted-foreground", children: "\u672A\u627E\u5230\u5339\u914D\u7684\u8868\u60C5" })] }))] })), mediaPanel && (_jsxs("div", { className: "p-3 rounded-md border bg-muted/30 space-y-2", children: [_jsxs("p", { className: "text-xs text-muted-foreground", children: [mediaPanel === 'image' && '支持 http(s) 图片链接或 data URL', mediaPanel === 'video' && '支持浏览器可解码的视频直链(如 .mp4、.webm)', mediaPanel === 'audio' && '支持 .mp3、.ogg、.wav 等音频直链'] }), _jsx("input", { value: mediaUrl, onChange: (e) => setMediaUrl(e.target.value), placeholder: mediaPanel === 'image' ? '图片 URL…' : mediaPanel === 'video' ? '视频 URL…' : '音频 URL…', className: "w-full h-8 rounded-md border border-input bg-background px-2 text-sm", onKeyDown: (e) => { if (e.key === 'Enter') {
359
+ e.preventDefault();
360
+ commitMediaUrl();
361
+ } } }), _jsxs("button", { type: "button", className: "inline-flex items-center gap-1 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm disabled:opacity-50", onClick: commitMediaUrl, disabled: !mediaUrl.trim(), children: [_jsx(Check, { size: 14 }), " \u63D2\u5165\u5230\u8F93\u5165\u6846"] })] })), _jsxs("div", { className: "flex gap-2 items-start", children: [_jsxs("div", { className: "flex-1 relative", children: [_jsx(RichTextEditor, { ref: editorRef, placeholder: `向 ${activeChannel.name} 发送消息...`, onSend: handleSendMessage, onChange: handleEditorChange, onAtTrigger: handleAtTrigger, minHeight: "44px", maxHeight: "200px" }), atPopoverPosition && (_jsx("div", { className: "absolute z-50 rounded-lg border bg-popover shadow-md min-w-60 max-h-72 overflow-y-auto p-1", style: { top: `${atPopoverPosition.top}px`, left: `${atPopoverPosition.left}px` }, children: filteredAtSuggestions.length > 0 ? filteredAtSuggestions.map((user) => (_jsxs("div", { className: "flex items-center gap-2 p-2 rounded-md cursor-pointer hover:bg-accent transition-colors", onClick: () => selectAtUser(user), children: [_jsx(User, { size: 16, className: "text-muted-foreground" }), _jsxs("div", { className: "flex-1", children: [_jsx("div", { className: "text-sm font-medium", children: user.name }), _jsxs("div", { className: "text-xs text-muted-foreground", children: ["ID: ", user.id] })] })] }, user.id))) : (_jsxs("div", { className: "flex flex-col items-center gap-2 p-4", children: [_jsx(Search, { size: 20, className: "text-muted-foreground/50" }), _jsx("span", { className: "text-xs text-muted-foreground", children: "\u672A\u627E\u5230\u5339\u914D\u7684\u7528\u6237" })] })) }))] }), _jsxs("button", { className: "inline-flex items-center gap-1.5 h-10 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50 transition-colors hover:bg-primary/90", onClick: () => { const c = editorRef.current?.getContent(); if (c)
362
+ handleSendMessage(c.text, c.segments); }, disabled: !hasRenderableSegments(previewSegments), children: [_jsx(Send, { size: 16 }), " \u53D1\u9001"] })] }), _jsxs("div", { className: "flex items-center gap-2 flex-wrap text-xs text-muted-foreground", children: [_jsx(Info, { size: 12 }), " \u5FEB\u6377\u64CD\u4F5C:", _jsx("span", { className: "px-1 py-0.5 rounded border text-[10px]", children: "Enter" }), " \u53D1\u9001", _jsx("span", { className: "px-1 py-0.5 rounded border text-[10px]", children: "Shift+Enter" }), " \u6362\u884C", _jsx("span", { className: "px-1 py-0.5 rounded border text-[10px]", children: "[@\u540D\u79F0]" }), " @\u67D0\u4EBA", _jsx("span", { className: "px-1 py-0.5 rounded border text-[10px]", children: "[video:URL]" }), _jsx("span", { className: "px-1 py-0.5 rounded border text-[10px]", children: "[audio:URL]" })] })] })] }))] })] }));
363
+ }
package/pages/index.js ADDED
@@ -0,0 +1,18 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { definePage } from '@zhin.js/console-contract';
4
+ import SandboxChat from './SandboxChat';
5
+ export const meta = definePage({
6
+ title: '沙盒',
7
+ icon: 'Box',
8
+ order: 10,
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 _jsx(SandboxChat, {});
18
+ }
@@ -9,6 +9,7 @@ export const meta = definePage({
9
9
 
10
10
  /**
11
11
  * Convention page entry (ADR 0046).
12
+ * `pages/index.tsx` → `/sandbox` (plugin path; no `/p-` leaf).
12
13
  * Restores the pre-runtime-migration Sandbox console UI (channels + rich text + faces).
13
14
  * WebSocket targets Host `/sandbox` via zhin_api_base + token (see sandboxTransport.ts).
14
15
  */
@@ -0,0 +1,45 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ /** Same storage keys as `@zhin.js/client` remoteApi. */
3
+ export function getSandboxApiBase() {
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)
9
+ return stored.replace(/\/+$/u, '');
10
+ if (typeof window !== 'undefined')
11
+ return window.location.origin;
12
+ return '';
13
+ }
14
+ export function getSandboxBearerToken() {
15
+ if (typeof window !== 'undefined') {
16
+ const runtime = window.__ZHIN_API_TOKEN?.trim();
17
+ if (runtime)
18
+ return runtime;
19
+ }
20
+ if (typeof localStorage === 'undefined')
21
+ return '';
22
+ return (localStorage.getItem('zhin_api_token')?.trim()
23
+ || localStorage.getItem('HTTP_TOKEN')?.trim()
24
+ || localStorage.getItem('zhin_http_token')?.trim()
25
+ || (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('zhin_api_token')?.trim() : '')
26
+ || '');
27
+ }
28
+ export function getSandboxAuthHeaders() {
29
+ const token = getSandboxBearerToken();
30
+ return token ? { Authorization: `Bearer ${token}` } : {};
31
+ }
32
+ /**
33
+ * Browser WebSocket cannot set Authorization reliably; pass token in query when needed.
34
+ * Uses stored Console API base so Remote Console (different origin) still hits the bot Host.
35
+ */
36
+ export function buildSandboxWebSocketUrl(base) {
37
+ const apiBase = (base ?? getSandboxApiBase()).replace(/\/+$/u, '');
38
+ const origin = apiBase || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
39
+ const wsUrl = new URL('/sandbox', `${origin}/`);
40
+ wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
41
+ const token = getSandboxBearerToken();
42
+ if (token)
43
+ wsUrl.searchParams.set('token', token);
44
+ return wsUrl.href;
45
+ }
package/plugin.js ADDED
@@ -0,0 +1,8 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { definePlugin } from '@zhin.js/plugin-runtime';
3
+ export default definePlugin({
4
+ name: 'sandbox',
5
+ metadata: {
6
+ displayName: 'Sandbox Adapter',
7
+ },
8
+ });
package/src/endpoint.ts CHANGED
@@ -18,6 +18,46 @@ 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
+
21
61
  interface SandboxChannel {
22
62
  readonly type: string;
23
63
  readonly id: string;
@@ -44,10 +84,16 @@ export interface SandboxEndpointOptions {
44
84
  readonly defaults: ResolvedSandboxBot;
45
85
  }
46
86
 
87
+ /**
88
+ * Sandbox 是本地开发/测试面,无平台社交图谱(好友/群/频道),
89
+ * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
90
+ */
47
91
  export class SandboxWsEndpoint implements EndpointInstance {
48
92
  readonly #options: SandboxEndpointOptions;
49
93
  readonly #connections = new Map<string, SandboxConnection>();
50
94
  #wsHandleRelease?: () => void;
95
+ #wsPathRelease?: () => void;
96
+ #wsPath = '/sandbox';
51
97
  #open = false;
52
98
  #started = false;
53
99
 
@@ -63,7 +109,11 @@ export class SandboxWsEndpoint implements EndpointInstance {
63
109
  start(): void {
64
110
  if (this.#started) return;
65
111
  this.#started = true;
66
- const handle = this.#options.http.ws('/sandbox');
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);
67
117
  this.#wsHandleRelease = handle.onConnection((connection) => {
68
118
  this.#acceptConnection(connection);
69
119
  });
@@ -72,7 +122,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
72
122
  }
73
123
  logger.info(formatCompact({
74
124
  op: 'sandbox_ws_mounted',
75
- path: '/sandbox',
125
+ path: claim.path,
76
126
  endpoint: this.#options.defaults.name,
77
127
  }));
78
128
  }
@@ -89,6 +139,8 @@ export class SandboxWsEndpoint implements EndpointInstance {
89
139
  this.#open = false;
90
140
  this.#wsHandleRelease?.();
91
141
  this.#wsHandleRelease = undefined;
142
+ this.#wsPathRelease?.();
143
+ this.#wsPathRelease = undefined;
92
144
  for (const connection of this.#connections.values()) {
93
145
  connection.release();
94
146
  if (!connection.placeholder) {
@@ -237,7 +289,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
237
289
  data: {
238
290
  text: [
239
291
  `已连接 Sandbox「${target}」`,
240
- '与 Node Host 控制台沙盒协议一致(/sandbox)',
292
+ `与 Node Host 控制台沙盒协议一致(${this.#wsPath})`,
241
293
  '命令: help · ping · zt · status',
242
294
  ].join('\n'),
243
295
  },
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 entry?.name === 'string' ? entry.name : undefined;
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 entry?.owner === 'string' && entry.owner)
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 {
package/plugin.ts DELETED
@@ -1,8 +0,0 @@
1
- import { definePlugin } from '@zhin.js/plugin-runtime';
2
-
3
- export default definePlugin({
4
- name: 'sandbox',
5
- metadata: {
6
- displayName: 'Sandbox Adapter',
7
- },
8
- });