@zhin.js/adapter-sandbox 5.0.6 → 6.0.1

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