@zhin.js/adapter-sandbox 7.0.1 → 7.0.4

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,58 @@
1
1
  # @zhin.js/adapter-process
2
2
 
3
+ ## 7.0.4
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [0de46a8]
8
+ - Updated dependencies [c106ecc]
9
+ - Updated dependencies [b0f37ae]
10
+ - Updated dependencies [daffd4c]
11
+ - Updated dependencies [36c7400]
12
+ - Updated dependencies [162fa34]
13
+ - Updated dependencies [e40b048]
14
+ - Updated dependencies [f1708c3]
15
+ - Updated dependencies [e53444f]
16
+ - Updated dependencies [92b0dd7]
17
+ - Updated dependencies [a7df753]
18
+ - @zhin.js/host-http@1.0.8
19
+ - @zhin.js/im-contract@1.0.3
20
+ - @zhin.js/adapter@1.1.7
21
+ - @zhin.js/plugin-runtime@1.1.5
22
+ - @zhin.js/core@1.5.4
23
+ - @zhin.js/page@1.0.8
24
+ - @zhin.js/client@2.1.6
25
+
26
+ ## 7.0.3
27
+
28
+ ### Patch Changes
29
+
30
+ - f8c7a54: fix: im
31
+ - Updated dependencies [f8c7a54]
32
+ - @zhin.js/logger@1.0.76
33
+ - @zhin.js/client@2.1.5
34
+ - @zhin.js/page@1.0.7
35
+ - @zhin.js/console-contract@1.0.1
36
+ - @zhin.js/host-http@1.0.7
37
+ - @zhin.js/adapter@1.1.6
38
+ - @zhin.js/core@1.5.3
39
+ - @zhin.js/im-contract@1.0.2
40
+ - @zhin.js/plugin-runtime@1.1.4
41
+
42
+ ## 7.0.2
43
+
44
+ ### Patch Changes
45
+
46
+ - Updated dependencies [afc0e66]
47
+ - Updated dependencies [2e41ad5]
48
+ - Updated dependencies [9f57124]
49
+ - @zhin.js/core@1.5.2
50
+ - @zhin.js/adapter@1.1.5
51
+ - @zhin.js/im-contract@1.0.1
52
+ - @zhin.js/plugin-runtime@1.1.3
53
+ - @zhin.js/host-http@1.0.6
54
+ - @zhin.js/page@1.0.6
55
+
3
56
  ## 7.0.1
4
57
 
5
58
  ### Patch Changes
package/lib/endpoint.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { EndpointInstance } from '@zhin.js/adapter';
1
+ import type { EndpointInstance, EndpointSendRequest } from '@zhin.js/adapter';
2
2
  import type { MessageGateway } from '@zhin.js/core/runtime';
3
3
  import type { HttpHost } from '@zhin.js/host-http';
4
4
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
@@ -16,14 +16,11 @@ export interface SandboxEndpointOptions {
16
16
  export declare class SandboxWsEndpoint implements EndpointInstance {
17
17
  #private;
18
18
  constructor(options: SandboxEndpointOptions);
19
- /** Live endpoint name (config `name`) — Console endpoint.list/resolve uses it. */
19
+ /** Live endpoint id (config `id`) — Console endpoint.list/resolve uses it. */
20
20
  get name(): string;
21
21
  start(): void;
22
22
  open(): void;
23
23
  close(): void;
24
24
  stop(): void;
25
- send({ target, payload }: {
26
- readonly target: string;
27
- readonly payload: unknown;
28
- }): unknown;
25
+ send({ conversation, payload }: EndpointSendRequest): unknown;
29
26
  }
package/lib/endpoint.js CHANGED
@@ -2,9 +2,8 @@
2
2
  * SandboxWsEndpoint — WebSocket lifecycle and MessageGateway bridge for /sandbox.
3
3
  */
4
4
  import { randomUUID } from 'node:crypto';
5
- import { formatCompact, getLogger } from '@zhin.js/logger';
6
- import { bindSandboxWsSocket, formatSandboxOutbound, parseSandboxWsPayload, whenWsOpen, } from './protocol.js';
7
- const logger = getLogger('sandbox');
5
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
6
+ import { bindSandboxWsSocket, formatSandboxOutbound, parseSandboxWsPayload, sandboxInboundConversation, whenWsOpen, } from './protocol.js';
8
7
  /**
9
8
  * 多 sandbox endpoint 共用同一个 HttpHost 时,同 path 的所有 WS listener
10
9
  * 都会被回调(入站重复、出站互窜)。按 endpoint 名隔离挂载路径:
@@ -44,6 +43,7 @@ function claimSandboxWsPath(http, name) {
44
43
  * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
45
44
  */
46
45
  export class SandboxWsEndpoint {
46
+ #logger;
47
47
  #options;
48
48
  #connections = new Map();
49
49
  #wsHandleRelease;
@@ -52,18 +52,19 @@ export class SandboxWsEndpoint {
52
52
  #open = false;
53
53
  #started = false;
54
54
  constructor(options) {
55
+ this.#logger = getAdapterLogger('sandbox', options.defaults.id);
55
56
  this.#options = options;
56
57
  }
57
- /** Live endpoint name (config `name`) — Console endpoint.list/resolve uses it. */
58
+ /** Live endpoint id (config `id`) — Console endpoint.list/resolve uses it. */
58
59
  get name() {
59
- return this.#options.defaults.name;
60
+ return this.#options.defaults.id;
60
61
  }
61
62
  start() {
62
63
  if (this.#started)
63
64
  return;
64
65
  this.#started = true;
65
66
  // 多 endpoint 同 path 会被全部回调(入站重复、出站互窜),按名隔离。
66
- const claim = claimSandboxWsPath(this.#options.http, this.#options.defaults.name);
67
+ const claim = claimSandboxWsPath(this.#options.http, this.#options.defaults.id);
67
68
  this.#wsPathRelease = claim.release;
68
69
  this.#wsPath = claim.path;
69
70
  const handle = this.#options.http.ws(claim.path);
@@ -71,13 +72,9 @@ export class SandboxWsEndpoint {
71
72
  this.#acceptConnection(connection);
72
73
  });
73
74
  if (!this.#options.defaults.randomNamePerConnection) {
74
- this.#ensurePlaceholder(this.#options.defaults.name, this.#options.defaults.owner);
75
+ this.#ensurePlaceholder(this.#options.defaults.id, this.#options.defaults.owner);
75
76
  }
76
- logger.info(formatCompact({
77
- op: 'sandbox_ws_mounted',
78
- path: claim.path,
79
- endpoint: this.#options.defaults.name,
80
- }));
77
+ this.#logger.info(`ws mounted ${claim.path}`);
81
78
  }
82
79
  open() {
83
80
  this.#open = true;
@@ -104,38 +101,41 @@ export class SandboxWsEndpoint {
104
101
  }
105
102
  this.#connections.clear();
106
103
  this.#started = false;
107
- logger.debug(formatCompact({ op: 'sandbox_stopped' }));
104
+ this.#logger.debug(formatCompact({ op: 'sandbox_stopped' }));
108
105
  }
109
- send({ target, payload }) {
106
+ send({ conversation, payload }) {
110
107
  if (!this.#open)
111
108
  return undefined;
112
- // Reply target is the connection key (bot name / sandbox-uuid), not private:channelId.
113
- const connection = this.#connections.get(target)
109
+ // Reply targets this endpoint's own live socket (fixed bot name, or the
110
+ // only live random-name connection); conversation carries kind/id stamp.
111
+ const connection = this.#connections.get(this.#options.defaults.id)
114
112
  ?? this.#findLiveConnection();
115
113
  if (!connection) {
116
- logger.debug(formatCompact({ op: 'sandbox_send_miss', target }));
114
+ this.#logger.debug(formatCompact({
115
+ op: 'sandbox_send_miss',
116
+ target: `${conversation.kind}:${conversation.id}`,
117
+ }));
117
118
  return undefined;
118
119
  }
119
120
  if (connection.placeholder) {
120
- logger.debug(formatCompact({ op: 'sandbox_send_placeholder', target }));
121
+ this.#logger.debug(formatCompact({
122
+ op: 'sandbox_send_placeholder',
123
+ target: `${conversation.kind}:${conversation.id}`,
124
+ }));
121
125
  return undefined;
122
126
  }
123
- // Console UI filters by type+id; stamp last inbound channel onto outbound wire.
124
- const channel = connection.lastChannel ?? {
125
- type: 'private',
126
- id: connection.owner,
127
- };
127
+ // Console UI filters by type+id; stamp the conversation onto outbound wire.
128
128
  connection.socket.send(formatSandboxOutbound(payload, {
129
- type: channel.type,
130
- id: channel.id,
131
- bot: this.#options.defaults.name,
129
+ type: conversation.kind,
130
+ id: conversation.id,
131
+ bot: this.#options.defaults.id,
132
132
  endpoint: connection.target,
133
133
  }));
134
- logger.debug(formatCompact({
134
+ this.#logger.debug(formatCompact({
135
135
  op: 'sandbox_send',
136
136
  target: connection.target,
137
- channelType: channel.type,
138
- channelId: channel.id,
137
+ channelType: conversation.kind,
138
+ channelId: conversation.id,
139
139
  }));
140
140
  return payload;
141
141
  }
@@ -150,7 +150,7 @@ export class SandboxWsEndpoint {
150
150
  #acceptConnection(connection) {
151
151
  const target = this.#options.defaults.randomNamePerConnection
152
152
  ? `sandbox-${randomUUID().slice(0, 8)}`
153
- : this.#options.defaults.name;
153
+ : this.#options.defaults.id;
154
154
  const owner = this.#options.defaults.owner;
155
155
  const socket = connection.socket;
156
156
  // Fixed-name mode reuses `target`; dropping the prior entry without
@@ -172,39 +172,36 @@ export class SandboxWsEndpoint {
172
172
  const release = bindSandboxWsSocket(socket, {
173
173
  onMessage: (raw) => {
174
174
  const parsed = parseSandboxWsPayload(raw);
175
- const conn = this.#connections.get(target);
176
- if (conn && !conn.placeholder) {
177
- conn.lastChannel = {
178
- type: parsed.type,
179
- id: parsed.id || owner,
180
- };
181
- }
182
- logger.debug(formatCompact({
175
+ const sender = parsed.id || owner;
176
+ const conversation = sandboxInboundConversation(String(this.#options.id), {
177
+ type: parsed.type,
178
+ id: sender,
179
+ });
180
+ this.#logger.debug(formatCompact({
183
181
  op: 'sandbox_recv',
184
182
  target,
185
- sender: parsed.id || owner,
183
+ sender,
186
184
  channelType: parsed.type,
187
- channelId: parsed.id || owner,
185
+ channelId: sender,
188
186
  text: parsed.text.slice(0, 80),
189
187
  }));
190
188
  // Don't gate on #open — inbound must always reach the gateway so
191
189
  // Command/AI dispatch and outbound replies work.
192
190
  void this.#options.gateway.receive({
193
- adapter: this.#options.id,
194
- target,
191
+ conversation,
195
192
  content: parsed.text,
196
- sender: parsed.id || owner,
193
+ sender: { id: sender },
194
+ endpointId: target,
197
195
  metadata: Object.freeze({
198
196
  type: parsed.type,
199
197
  channelType: parsed.type,
200
198
  channelId: parsed.id || owner,
201
- endpoint: target,
202
199
  elements: parsed.content,
203
200
  timestamp: parsed.timestamp,
204
201
  ...(parsed.action ? { action: parsed.action } : {}),
205
202
  }),
206
203
  }).catch((err) => {
207
- logger.warn(formatCompact({
204
+ this.#logger.warn(formatCompact({
208
205
  op: 'sandbox_gateway_receive_failed',
209
206
  target,
210
207
  error: err instanceof Error ? err.message : String(err),
@@ -217,11 +214,11 @@ export class SandboxWsEndpoint {
217
214
  const current = this.#connections.get(target);
218
215
  if (current && current.socket === socket) {
219
216
  this.#connections.delete(target);
220
- logger.debug(formatCompact({ op: 'sandbox_ws_closed', target }));
217
+ this.#logger.debug(formatCompact({ op: 'sandbox_ws_closed', target }));
221
218
  }
222
219
  },
223
220
  onError: (err) => {
224
- logger.warn(formatCompact({
221
+ this.#logger.warn(formatCompact({
225
222
  op: 'sandbox_ws_error',
226
223
  target,
227
224
  error: err instanceof Error ? err.message : String(err),
@@ -229,7 +226,7 @@ export class SandboxWsEndpoint {
229
226
  },
230
227
  });
231
228
  this.#connections.set(target, { target, owner, socket, release });
232
- logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
229
+ this.#logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
233
230
  if (!this.#options.defaults.randomNamePerConnection) {
234
231
  const readyPayload = JSON.stringify({
235
232
  type: 'ready',
package/lib/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { bindSandboxWsSocket, formatSandboxOutbound, normalizeSandboxOutboundSegments, parseSandboxWsPayload, resolveSandboxEndpoint, whenWsOpen, type MessageElement, type MessageType, type ResolvedSandboxBot, type SandboxAdapterConfig, type SandboxWsSocket, } from './protocol.js';
1
+ export { bindSandboxWsSocket, formatSandboxOutbound, normalizeSandboxOutboundSegments, parseSandboxWsPayload, resolveSandboxEndpoint, sandboxInboundConversation, whenWsOpen, type MessageElement, type MessageType, type ResolvedSandboxBot, type SandboxAdapterConfig, type SandboxWsSocket, } from './protocol.js';
2
2
  export { SandboxWsEndpoint, type SandboxEndpointOptions, } from './endpoint.js';
package/lib/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { bindSandboxWsSocket, formatSandboxOutbound, normalizeSandboxOutboundSegments, parseSandboxWsPayload, resolveSandboxEndpoint, whenWsOpen, } from './protocol.js';
1
+ export { bindSandboxWsSocket, formatSandboxOutbound, normalizeSandboxOutboundSegments, parseSandboxWsPayload, resolveSandboxEndpoint, sandboxInboundConversation, whenWsOpen, } from './protocol.js';
2
2
  export { SandboxWsEndpoint, } from './endpoint.js';
package/lib/protocol.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Sandbox WebSocket wire protocol helpers (no legacy Adapter/Endpoint). */
2
+ import type { ConversationRef } from '@zhin.js/im-contract';
2
3
  export type MessageType = 'private' | 'group' | 'guild' | 'direct' | 'channel';
3
4
  export interface MessageElement {
4
5
  readonly type: string;
@@ -14,19 +15,19 @@ export interface SandboxWsSocket {
14
15
  }
15
16
  export type ResolvedSandboxBot = {
16
17
  readonly context: 'sandbox';
17
- readonly name: string;
18
+ readonly id: string;
18
19
  readonly owner: string;
19
20
  readonly randomNamePerConnection: boolean;
20
21
  };
21
22
  export interface SandboxAdapterConfig {
22
23
  /** Runtime expands `endpoints[i]` onto the top level — prefer these. */
23
24
  readonly context?: string;
24
- readonly name?: string;
25
+ readonly id?: string;
25
26
  readonly owner?: string;
26
27
  /** Legacy shape: endpoint entries nested under `endpoints[]`. */
27
28
  readonly endpoints?: ReadonlyArray<{
28
29
  readonly context?: string;
29
- readonly name?: string;
30
+ readonly id?: string;
30
31
  readonly owner?: string;
31
32
  }>;
32
33
  }
@@ -47,6 +48,15 @@ export declare function parseSandboxWsPayload(raw: string): {
47
48
  payload: string;
48
49
  };
49
50
  };
51
+ /**
52
+ * 入站归一化 → ConversationRef。sandbox 无平台社交图谱:
53
+ * `private`/`group`/`channel` 直映射;`direct`(私聊)归 'private';
54
+ * `guild`(频道容器语义)归 'channel'。无 guild/temp 容器信息,不产生 parent。
55
+ */
56
+ export declare function sandboxInboundConversation(endpointKey: string, msg: {
57
+ readonly type: MessageType;
58
+ readonly id: string;
59
+ }): ConversationRef;
50
60
  export type SandboxOutboundChannel = {
51
61
  readonly type?: string;
52
62
  readonly id?: string;
package/lib/protocol.js CHANGED
@@ -5,19 +5,19 @@ import { formatCompact, getLogger } from '@zhin.js/logger';
5
5
  const logger = getLogger('sandbox');
6
6
  export function resolveSandboxEndpoint(appConfig) {
7
7
  const entry = appConfig.endpoints?.find((item) => item.context === 'sandbox');
8
- const fixedName = typeof appConfig.name === 'string' && appConfig.name
9
- ? appConfig.name
10
- : typeof entry?.name === 'string' && entry.name
11
- ? entry.name
8
+ const fixedName = typeof appConfig.id === 'string' && appConfig.id
9
+ ? appConfig.id
10
+ : typeof entry?.id === 'string' && entry.id
11
+ ? entry.id
12
12
  : undefined;
13
- const name = fixedName || process.env.SANDBOX_BOT_NAME || 'sandbox-bot';
13
+ const id = fixedName || process.env.SANDBOX_BOT_NAME || 'sandbox-bot';
14
14
  const owner = (typeof appConfig.owner === 'string' && appConfig.owner)
15
15
  || (typeof entry?.owner === 'string' && entry.owner)
16
16
  || process.env.SANDBOX_BOT_OWNER
17
17
  || 'sandbox-user';
18
18
  return {
19
19
  context: 'sandbox',
20
- name,
20
+ id,
21
21
  owner,
22
22
  randomNamePerConnection: !fixedName,
23
23
  };
@@ -107,6 +107,23 @@ export function parseSandboxWsPayload(raw) {
107
107
  }
108
108
  return { type, id, content, timestamp: payload.timestamp ?? Date.now(), text, action };
109
109
  }
110
+ /**
111
+ * 入站归一化 → ConversationRef。sandbox 无平台社交图谱:
112
+ * `private`/`group`/`channel` 直映射;`direct`(私聊)归 'private';
113
+ * `guild`(频道容器语义)归 'channel'。无 guild/temp 容器信息,不产生 parent。
114
+ */
115
+ export function sandboxInboundConversation(endpointKey, msg) {
116
+ const kind = msg.type === 'direct'
117
+ ? 'private'
118
+ : msg.type === 'guild'
119
+ ? 'channel'
120
+ : msg.type;
121
+ return {
122
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
123
+ kind,
124
+ id: msg.id,
125
+ };
126
+ }
110
127
  const MEDIA_SEGMENT_TYPES = new Set(['image', 'audio', 'video', 'file']);
111
128
  /**
112
129
  * base64 内联值:Console UI 的 resolveMediaSrc 只识别 data: / base64://
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-sandbox",
3
- "version": "7.0.1",
3
+ "version": "7.0.4",
4
4
  "description": "Zhin.js Sandbox adapter for Plugin Runtime (WebSocket /sandbox)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -48,14 +48,15 @@
48
48
  "dependencies": {
49
49
  "lucide-react": "^0.525.0",
50
50
  "react": "^19.2.8",
51
- "@zhin.js/adapter": "1.1.4",
52
- "@zhin.js/client": "2.1.4",
53
- "@zhin.js/console-contract": "1.0.0",
54
- "@zhin.js/core": "1.5.1",
55
- "@zhin.js/host-http": "1.0.5",
56
- "@zhin.js/logger": "1.0.75",
57
- "@zhin.js/page": "1.0.5",
58
- "@zhin.js/plugin-runtime": "1.1.2"
51
+ "@zhin.js/adapter": "1.1.7",
52
+ "@zhin.js/client": "2.1.6",
53
+ "@zhin.js/console-contract": "1.0.1",
54
+ "@zhin.js/core": "1.5.4",
55
+ "@zhin.js/host-http": "1.0.8",
56
+ "@zhin.js/im-contract": "1.0.3",
57
+ "@zhin.js/logger": "1.0.76",
58
+ "@zhin.js/page": "1.0.8",
59
+ "@zhin.js/plugin-runtime": "1.1.5"
59
60
  },
60
61
  "devDependencies": {
61
62
  "@types/react": "^19.2.18",
@@ -63,18 +64,18 @@
63
64
  "typescript": "^6.0.3",
64
65
  "vitest": "^4.1.10",
65
66
  "ws": "^8.21.1",
66
- "@zhin.js/pagemanager": "2.0.11",
67
- "@zhin.js/runtime": "1.0.6"
67
+ "@zhin.js/pagemanager": "2.0.14",
68
+ "@zhin.js/runtime": "1.0.9"
68
69
  },
69
70
  "peerDependencies": {
70
71
  "react": "^19.0.0",
71
- "@zhin.js/adapter": "1.1.4",
72
- "@zhin.js/client": "2.1.4",
73
- "@zhin.js/console-contract": "1.0.0",
74
- "@zhin.js/core": "1.5.1",
75
- "@zhin.js/host-http": "1.0.5",
76
- "@zhin.js/page": "1.0.5",
77
- "@zhin.js/plugin-runtime": "1.1.2"
72
+ "@zhin.js/adapter": "1.1.7",
73
+ "@zhin.js/client": "2.1.6",
74
+ "@zhin.js/console-contract": "1.0.1",
75
+ "@zhin.js/core": "1.5.4",
76
+ "@zhin.js/host-http": "1.0.8",
77
+ "@zhin.js/page": "1.0.8",
78
+ "@zhin.js/plugin-runtime": "1.1.5"
78
79
  },
79
80
  "peerDependenciesMeta": {
80
81
  "react": {
@@ -15,7 +15,7 @@ export default function Sandbox() {
15
15
  const [faceList, setFaceList] = useState([]);
16
16
  const [activeChannel, setActiveChannel] = useState(channels[0]);
17
17
  const [inputText, setInputText] = useState('');
18
- const [endpointName, setBotName] = useState('ProcessEndpoint');
18
+ const [endpointId, setBotName] = useState('ProcessEndpoint');
19
19
  const [connected, setConnected] = useState(false);
20
20
  const [showFacePicker, setShowFacePicker] = useState(false);
21
21
  /** 输入区:插入图片 / 视频 / 音频 URL */
@@ -59,7 +59,7 @@ export default function Sandbox() {
59
59
  ? parseTextToSegments(data.content)
60
60
  : Array.isArray(data.content) ? data.content : parseTextToSegments(String(data.content ?? ''));
61
61
  const channelName = data.type === 'private'
62
- ? `私聊-${data.bot || endpointName}`
62
+ ? `私聊-${data.bot || endpointId}`
63
63
  : data.type === 'group'
64
64
  ? `群组-${data.id}`
65
65
  : `频道-${data.id}`;
@@ -74,7 +74,7 @@ export default function Sandbox() {
74
74
  setMessages((prev) => [...prev, {
75
75
  id: data.messageId ?? `bot_${data.timestamp}`, type: 'received', channelType,
76
76
  channelId: data.id, channelName, senderId: 'endpoint',
77
- senderName: data.bot || endpointName, content, timestamp: data.timestamp,
77
+ senderName: data.bot || endpointId, content, timestamp: data.timestamp,
78
78
  }]);
79
79
  };
80
80
  const sendInteractiveAction = (payload) => {
@@ -355,7 +355,7 @@ export default function Sandbox() {
355
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
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
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') {
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: endpointId, 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
359
  e.preventDefault();
360
360
  commitMediaUrl();
361
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)
@@ -32,7 +32,7 @@ export default function Sandbox() {
32
32
  const [faceList, setFaceList] = useState<Face[]>([])
33
33
  const [activeChannel, setActiveChannel] = useState<Channel>(channels[0])
34
34
  const [inputText, setInputText] = useState('')
35
- const [endpointName, setBotName] = useState('ProcessEndpoint')
35
+ const [endpointId, setBotName] = useState('ProcessEndpoint')
36
36
  const [connected, setConnected] = useState(false)
37
37
  const [showFacePicker, setShowFacePicker] = useState(false)
38
38
  /** 输入区:插入图片 / 视频 / 音频 URL */
@@ -79,7 +79,7 @@ export default function Sandbox() {
79
79
  : Array.isArray(data.content) ? data.content as MessageSegment[] : parseTextToSegments(String(data.content ?? ''))
80
80
 
81
81
  const channelName = data.type === 'private'
82
- ? `私聊-${data.bot || endpointName}`
82
+ ? `私聊-${data.bot || endpointId}`
83
83
  : data.type === 'group'
84
84
  ? `群组-${data.id}`
85
85
  : `频道-${data.id}`
@@ -95,7 +95,7 @@ export default function Sandbox() {
95
95
  setMessages((prev) => [...prev, {
96
96
  id: data.messageId ?? `bot_${data.timestamp}`, type: 'received', channelType,
97
97
  channelId: data.id, channelName, senderId: 'endpoint',
98
- senderName: data.bot || endpointName, content, timestamp: data.timestamp,
98
+ senderName: data.bot || endpointId, content, timestamp: data.timestamp,
99
99
  }])
100
100
  }
101
101
 
@@ -509,7 +509,7 @@ export default function Sandbox() {
509
509
  </span>
510
510
  </div>
511
511
  <div className="flex items-center gap-2">
512
- <input value={endpointName} onChange={(e) => setBotName(e.target.value)} placeholder="机器人名称"
512
+ <input value={endpointId} onChange={(e) => setBotName(e.target.value)} placeholder="机器人名称"
513
513
  className="h-8 w-28 rounded-md border bg-transparent px-2 text-sm" />
514
514
  <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}>
515
515
  <Trash2 size={14} /> 清空
package/schema.json CHANGED
@@ -3,16 +3,48 @@
3
3
  "type": "object",
4
4
  "additionalProperties": false,
5
5
  "properties": {
6
+ "master": {
7
+ "type": [
8
+ "string",
9
+ "number"
10
+ ],
11
+ "description": "框架 master(sandbox client id (distinct from endpoints[].owner);AI/工具权限、endpoint 管理)。endpoints[i].master 可逐项覆盖"
12
+ },
13
+ "trusted": {
14
+ "type": "array",
15
+ "items": {
16
+ "type": [
17
+ "string",
18
+ "number"
19
+ ],
20
+ "description": "Trusted sandbox client id (distinct from endpoints[].owner)"
21
+ },
22
+ "description": "框架 trusted 用户列表(弱于 master)。endpoints[i].trusted 可逐项追加"
23
+ },
6
24
  "endpoints": {
7
25
  "type": "array",
8
- "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(name 必填,其余覆盖顶层)",
26
+ "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(id 必填,其余覆盖顶层)",
9
27
  "items": {
10
28
  "type": "object",
11
29
  "additionalProperties": true,
12
30
  "properties": {
13
- "name": {
14
- "type": "string",
15
- "description": "Sandbox bot name"
31
+ "master": {
32
+ "type": [
33
+ "string",
34
+ "number"
35
+ ],
36
+ "description": "本 endpoint 的框架 master(sandbox client id (distinct from endpoints[].owner));覆盖顶层 master"
37
+ },
38
+ "trusted": {
39
+ "type": "array",
40
+ "items": {
41
+ "type": [
42
+ "string",
43
+ "number"
44
+ ],
45
+ "description": "Trusted sandbox client id (distinct from endpoints[].owner)"
46
+ },
47
+ "description": "本 endpoint 的 trusted 列表"
16
48
  },
17
49
  "context": {
18
50
  "type": "string",
@@ -21,10 +53,14 @@
21
53
  "owner": {
22
54
  "type": "string",
23
55
  "description": "Sandbox owner user ID"
56
+ },
57
+ "id": {
58
+ "type": "string",
59
+ "description": "Sandbox bot name"
24
60
  }
25
61
  },
26
62
  "required": [
27
- "name"
63
+ "id"
28
64
  ]
29
65
  }
30
66
  },
package/src/endpoint.ts CHANGED
@@ -2,22 +2,21 @@
2
2
  * SandboxWsEndpoint — WebSocket lifecycle and MessageGateway bridge for /sandbox.
3
3
  */
4
4
  import { randomUUID } from 'node:crypto';
5
- import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import type { EndpointInstance, EndpointSendRequest } from '@zhin.js/adapter';
6
6
  import type { MessageGateway } from '@zhin.js/core/runtime';
7
7
  import type { HttpHost, WsConnection } from '@zhin.js/host-http';
8
- import { formatCompact, getLogger } from '@zhin.js/logger';
8
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
9
9
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
10
10
  import {
11
11
  bindSandboxWsSocket,
12
12
  formatSandboxOutbound,
13
13
  parseSandboxWsPayload,
14
+ sandboxInboundConversation,
14
15
  whenWsOpen,
15
16
  type ResolvedSandboxBot,
16
17
  type SandboxWsSocket,
17
18
  } from './protocol.js';
18
19
 
19
- const logger = getLogger('sandbox');
20
-
21
20
  /**
22
21
  * 多 sandbox endpoint 共用同一个 HttpHost 时,同 path 的所有 WS listener
23
22
  * 都会被回调(入站重复、出站互窜)。按 endpoint 名隔离挂载路径:
@@ -58,11 +57,6 @@ function claimSandboxWsPath(
58
57
  };
59
58
  }
60
59
 
61
- interface SandboxChannel {
62
- readonly type: string;
63
- readonly id: string;
64
- }
65
-
66
60
  interface SandboxConnection {
67
61
  readonly target: string;
68
62
  readonly owner: string;
@@ -70,11 +64,6 @@ interface SandboxConnection {
70
64
  readonly release: () => void;
71
65
  /** true = 占位连接(尚无真实 WS 客户端),send 命中时按 miss 处理。 */
72
66
  readonly placeholder?: boolean;
73
- /**
74
- * 最近一条入站频道(Console UI 按 type+id 过滤气泡)。
75
- * 出站必须带回,否则回复石沉大海。
76
- */
77
- lastChannel?: SandboxChannel;
78
67
  }
79
68
 
80
69
  export interface SandboxEndpointOptions {
@@ -89,6 +78,8 @@ export interface SandboxEndpointOptions {
89
78
  * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
90
79
  */
91
80
  export class SandboxWsEndpoint implements EndpointInstance {
81
+ readonly #logger!: ReturnType<typeof getAdapterLogger>;
82
+
92
83
  readonly #options: SandboxEndpointOptions;
93
84
  readonly #connections = new Map<string, SandboxConnection>();
94
85
  #wsHandleRelease?: () => void;
@@ -98,19 +89,20 @@ export class SandboxWsEndpoint implements EndpointInstance {
98
89
  #started = false;
99
90
 
100
91
  constructor(options: SandboxEndpointOptions) {
92
+ this.#logger = getAdapterLogger('sandbox', options.defaults.id);
101
93
  this.#options = options;
102
94
  }
103
95
 
104
- /** Live endpoint name (config `name`) — Console endpoint.list/resolve uses it. */
96
+ /** Live endpoint id (config `id`) — Console endpoint.list/resolve uses it. */
105
97
  get name(): string {
106
- return this.#options.defaults.name;
98
+ return this.#options.defaults.id;
107
99
  }
108
100
 
109
101
  start(): void {
110
102
  if (this.#started) return;
111
103
  this.#started = true;
112
104
  // 多 endpoint 同 path 会被全部回调(入站重复、出站互窜),按名隔离。
113
- const claim = claimSandboxWsPath(this.#options.http, this.#options.defaults.name);
105
+ const claim = claimSandboxWsPath(this.#options.http, this.#options.defaults.id);
114
106
  this.#wsPathRelease = claim.release;
115
107
  this.#wsPath = claim.path;
116
108
  const handle = this.#options.http.ws(claim.path);
@@ -118,13 +110,9 @@ export class SandboxWsEndpoint implements EndpointInstance {
118
110
  this.#acceptConnection(connection);
119
111
  });
120
112
  if (!this.#options.defaults.randomNamePerConnection) {
121
- this.#ensurePlaceholder(this.#options.defaults.name, this.#options.defaults.owner);
113
+ this.#ensurePlaceholder(this.#options.defaults.id, this.#options.defaults.owner);
122
114
  }
123
- logger.info(formatCompact({
124
- op: 'sandbox_ws_mounted',
125
- path: claim.path,
126
- endpoint: this.#options.defaults.name,
127
- }));
115
+ this.#logger.info(`ws mounted ${claim.path}`);
128
116
  }
129
117
 
130
118
  open(): void {
@@ -153,38 +141,41 @@ export class SandboxWsEndpoint implements EndpointInstance {
153
141
  }
154
142
  this.#connections.clear();
155
143
  this.#started = false;
156
- logger.debug(formatCompact({ op: 'sandbox_stopped' }));
144
+ this.#logger.debug(formatCompact({ op: 'sandbox_stopped' }));
157
145
  }
158
146
 
159
- send({ target, payload }: { readonly target: string; readonly payload: unknown }): unknown {
147
+ send({ conversation, payload }: EndpointSendRequest): unknown {
160
148
  if (!this.#open) return undefined;
161
- // Reply target is the connection key (bot name / sandbox-uuid), not private:channelId.
162
- const connection = this.#connections.get(target)
149
+ // Reply targets this endpoint's own live socket (fixed bot name, or the
150
+ // only live random-name connection); conversation carries kind/id stamp.
151
+ const connection = this.#connections.get(this.#options.defaults.id)
163
152
  ?? this.#findLiveConnection();
164
153
  if (!connection) {
165
- logger.debug(formatCompact({ op: 'sandbox_send_miss', target }));
154
+ this.#logger.debug(formatCompact({
155
+ op: 'sandbox_send_miss',
156
+ target: `${conversation.kind}:${conversation.id}`,
157
+ }));
166
158
  return undefined;
167
159
  }
168
160
  if (connection.placeholder) {
169
- logger.debug(formatCompact({ op: 'sandbox_send_placeholder', target }));
161
+ this.#logger.debug(formatCompact({
162
+ op: 'sandbox_send_placeholder',
163
+ target: `${conversation.kind}:${conversation.id}`,
164
+ }));
170
165
  return undefined;
171
166
  }
172
- // Console UI filters by type+id; stamp last inbound channel onto outbound wire.
173
- const channel = connection.lastChannel ?? {
174
- type: 'private',
175
- id: connection.owner,
176
- };
167
+ // Console UI filters by type+id; stamp the conversation onto outbound wire.
177
168
  connection.socket.send(formatSandboxOutbound(payload, {
178
- type: channel.type,
179
- id: channel.id,
180
- bot: this.#options.defaults.name,
169
+ type: conversation.kind,
170
+ id: conversation.id,
171
+ bot: this.#options.defaults.id,
181
172
  endpoint: connection.target,
182
173
  }));
183
- logger.debug(formatCompact({
174
+ this.#logger.debug(formatCompact({
184
175
  op: 'sandbox_send',
185
176
  target: connection.target,
186
- channelType: channel.type,
187
- channelId: channel.id,
177
+ channelType: conversation.kind,
178
+ channelId: conversation.id,
188
179
  }));
189
180
  return payload;
190
181
  }
@@ -200,7 +191,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
200
191
  #acceptConnection(connection: WsConnection): void {
201
192
  const target = this.#options.defaults.randomNamePerConnection
202
193
  ? `sandbox-${randomUUID().slice(0, 8)}`
203
- : this.#options.defaults.name;
194
+ : this.#options.defaults.id;
204
195
  const owner = this.#options.defaults.owner;
205
196
  const socket = connection.socket as SandboxWsSocket;
206
197
  // Fixed-name mode reuses `target`; dropping the prior entry without
@@ -221,39 +212,36 @@ export class SandboxWsEndpoint implements EndpointInstance {
221
212
  const release = bindSandboxWsSocket(socket, {
222
213
  onMessage: (raw) => {
223
214
  const parsed = parseSandboxWsPayload(raw);
224
- const conn = this.#connections.get(target);
225
- if (conn && !conn.placeholder) {
226
- conn.lastChannel = {
227
- type: parsed.type,
228
- id: parsed.id || owner,
229
- };
230
- }
231
- logger.debug(formatCompact({
215
+ const sender = parsed.id || owner;
216
+ const conversation = sandboxInboundConversation(String(this.#options.id), {
217
+ type: parsed.type,
218
+ id: sender,
219
+ });
220
+ this.#logger.debug(formatCompact({
232
221
  op: 'sandbox_recv',
233
222
  target,
234
- sender: parsed.id || owner,
223
+ sender,
235
224
  channelType: parsed.type,
236
- channelId: parsed.id || owner,
225
+ channelId: sender,
237
226
  text: parsed.text.slice(0, 80),
238
227
  }));
239
228
  // Don't gate on #open — inbound must always reach the gateway so
240
229
  // Command/AI dispatch and outbound replies work.
241
230
  void this.#options.gateway.receive({
242
- adapter: this.#options.id,
243
- target,
231
+ conversation,
244
232
  content: parsed.text,
245
- sender: parsed.id || owner,
233
+ sender: { id: sender },
234
+ endpointId: target,
246
235
  metadata: Object.freeze({
247
236
  type: parsed.type,
248
237
  channelType: parsed.type,
249
238
  channelId: parsed.id || owner,
250
- endpoint: target,
251
239
  elements: parsed.content,
252
240
  timestamp: parsed.timestamp,
253
241
  ...(parsed.action ? { action: parsed.action } : {}),
254
242
  }),
255
243
  }).catch((err) => {
256
- logger.warn(formatCompact({
244
+ this.#logger.warn(formatCompact({
257
245
  op: 'sandbox_gateway_receive_failed',
258
246
  target,
259
247
  error: err instanceof Error ? err.message : String(err),
@@ -266,11 +254,11 @@ export class SandboxWsEndpoint implements EndpointInstance {
266
254
  const current = this.#connections.get(target);
267
255
  if (current && current.socket === socket) {
268
256
  this.#connections.delete(target);
269
- logger.debug(formatCompact({ op: 'sandbox_ws_closed', target }));
257
+ this.#logger.debug(formatCompact({ op: 'sandbox_ws_closed', target }));
270
258
  }
271
259
  },
272
260
  onError: (err) => {
273
- logger.warn(formatCompact({
261
+ this.#logger.warn(formatCompact({
274
262
  op: 'sandbox_ws_error',
275
263
  target,
276
264
  error: err instanceof Error ? err.message : String(err),
@@ -278,7 +266,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
278
266
  },
279
267
  });
280
268
  this.#connections.set(target, { target, owner, socket, release });
281
- logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
269
+ this.#logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
282
270
  if (!this.#options.defaults.randomNamePerConnection) {
283
271
  const readyPayload = JSON.stringify({
284
272
  type: 'ready',
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export {
4
4
  normalizeSandboxOutboundSegments,
5
5
  parseSandboxWsPayload,
6
6
  resolveSandboxEndpoint,
7
+ sandboxInboundConversation,
7
8
  whenWsOpen,
8
9
  type MessageElement,
9
10
  type MessageType,
package/src/protocol.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { isMediaRef } from '@zhin.js/core';
5
+ import type { ConversationKind, ConversationRef } from '@zhin.js/im-contract';
5
6
  import { formatCompact, getLogger } from '@zhin.js/logger';
6
7
 
7
8
  const logger = getLogger('sandbox');
@@ -33,7 +34,7 @@ export interface SandboxWsSocket {
33
34
 
34
35
  export type ResolvedSandboxBot = {
35
36
  readonly context: 'sandbox';
36
- readonly name: string;
37
+ readonly id: string;
37
38
  readonly owner: string;
38
39
  readonly randomNamePerConnection: boolean;
39
40
  };
@@ -41,12 +42,12 @@ export type ResolvedSandboxBot = {
41
42
  export interface SandboxAdapterConfig {
42
43
  /** Runtime expands `endpoints[i]` onto the top level — prefer these. */
43
44
  readonly context?: string;
44
- readonly name?: string;
45
+ readonly id?: string;
45
46
  readonly owner?: string;
46
47
  /** Legacy shape: endpoint entries nested under `endpoints[]`. */
47
48
  readonly endpoints?: ReadonlyArray<{
48
49
  readonly context?: string;
49
- readonly name?: string;
50
+ readonly id?: string;
50
51
  readonly owner?: string;
51
52
  }>;
52
53
  }
@@ -55,19 +56,19 @@ export function resolveSandboxEndpoint(
55
56
  appConfig: SandboxAdapterConfig,
56
57
  ): ResolvedSandboxBot {
57
58
  const entry = appConfig.endpoints?.find((item) => item.context === 'sandbox');
58
- const fixedName = typeof appConfig.name === 'string' && appConfig.name
59
- ? appConfig.name
60
- : typeof entry?.name === 'string' && entry.name
61
- ? entry.name
59
+ const fixedName = typeof appConfig.id === 'string' && appConfig.id
60
+ ? appConfig.id
61
+ : typeof entry?.id === 'string' && entry.id
62
+ ? entry.id
62
63
  : undefined;
63
- const name = fixedName || process.env.SANDBOX_BOT_NAME || 'sandbox-bot';
64
+ const id = fixedName || process.env.SANDBOX_BOT_NAME || 'sandbox-bot';
64
65
  const owner = (typeof appConfig.owner === 'string' && appConfig.owner)
65
66
  || (typeof entry?.owner === 'string' && entry.owner)
66
67
  || process.env.SANDBOX_BOT_OWNER
67
68
  || 'sandbox-user';
68
69
  return {
69
70
  context: 'sandbox',
70
- name,
71
+ id,
71
72
  owner,
72
73
  randomNamePerConnection: !fixedName,
73
74
  };
@@ -177,6 +178,27 @@ export function parseSandboxWsPayload(raw: string): {
177
178
  return { type, id, content, timestamp: payload.timestamp ?? Date.now(), text, action };
178
179
  }
179
180
 
181
+ /**
182
+ * 入站归一化 → ConversationRef。sandbox 无平台社交图谱:
183
+ * `private`/`group`/`channel` 直映射;`direct`(私聊)归 'private';
184
+ * `guild`(频道容器语义)归 'channel'。无 guild/temp 容器信息,不产生 parent。
185
+ */
186
+ export function sandboxInboundConversation(
187
+ endpointKey: string,
188
+ msg: { readonly type: MessageType; readonly id: string },
189
+ ): ConversationRef {
190
+ const kind: ConversationKind = msg.type === 'direct'
191
+ ? 'private'
192
+ : msg.type === 'guild'
193
+ ? 'channel'
194
+ : msg.type;
195
+ return {
196
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
197
+ kind,
198
+ id: msg.id,
199
+ };
200
+ }
201
+
180
202
  export type SandboxOutboundChannel = {
181
203
  readonly type?: string;
182
204
  readonly id?: string;