@zhin.js/adapter-sandbox 5.0.5 → 5.0.6

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.
@@ -1,588 +0,0 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
- import { MessageSegment, cn, resolveMediaSrc, pickMediaRawUrl } from '@zhin.js/client';
3
- import {
4
- buildSandboxWebSocketUrl,
5
- } from './sandboxTransport';
6
- import { User, Bot, Users, Trash2, Send, Hash, MessageSquare, Wifi, WifiOff, Smile, Image, X, Check, Info, Search, Endpoint, UserPlus, Bell, Video, Music } from 'lucide-react';
7
- import RichTextEditor, { RichTextEditorRef } from './RichTextEditor';
8
-
9
- interface Message {
10
- id: string; type: 'sent' | 'received'; channelType: 'private' | 'group' | 'channel';
11
- channelId: string; channelName: string; senderId: string; senderName: string;
12
- content: MessageSegment[]; timestamp: number;
13
- }
14
-
15
- interface Channel { id: string; name: string; type: 'private' | 'group' | 'channel'; unread: number; }
16
- interface Face { id: number; emojiId: number; stickerId: number; emojiType: string; name: string; describe: string; png: boolean; apng: boolean; lottie: boolean; }
17
-
18
- export default function Sandbox() {
19
- const [messages, setMessages] = useState<Message[]>([])
20
- const [channels, setChannels] = useState<Channel[]>([
21
- { id: 'user_1001', name: '测试用户', type: 'private', unread: 0 },
22
- { id: 'group_2001', name: '测试群组', type: 'group', unread: 0 },
23
- { id: 'channel_3001', name: '测试频道', type: 'channel', unread: 0 }
24
- ])
25
- const [faceList, setFaceList] = useState<Face[]>([])
26
- const [activeChannel, setActiveChannel] = useState<Channel>(channels[0])
27
- const [inputText, setInputText] = useState('')
28
- const [endpointName, setBotName] = useState('ProcessEndpoint')
29
- const [connected, setConnected] = useState(false)
30
- const [showFacePicker, setShowFacePicker] = useState(false)
31
- /** 输入区:插入图片 / 视频 / 音频 URL */
32
- const [mediaPanel, setMediaPanel] = useState<null | 'image' | 'video' | 'audio'>(null)
33
- const [mediaUrl, setMediaUrl] = useState('')
34
- const [showAtPicker, setShowAtPicker] = useState(false)
35
- const [atPopoverPosition, setAtPopoverPosition] = useState<{ top: number; left: number } | null>(null)
36
- const [atSearchQuery, setAtSearchQuery] = useState('')
37
- const [faceSearchQuery, setFaceSearchQuery] = useState('')
38
- const [atUserName, setAtUserName] = useState('')
39
- const [atSuggestions] = useState([
40
- { id: '10001', name: '张三' }, { id: '10002', name: '李四' }, { id: '10003', name: '王五' },
41
- { id: '10004', name: '赵六' }, { id: '10005', name: '测试用户' }, { id: '10086', name: 'Admin' },
42
- { id: '10010', name: 'Test User' }
43
- ])
44
- const [previewSegments, setPreviewSegments] = useState<MessageSegment[]>([])
45
- const [showChannelList, setShowChannelList] = useState(false)
46
- const [viewMode, setViewMode] = useState<'chat' | 'requests' | 'notices'>('chat')
47
- const messagesEndRef = useRef<HTMLDivElement>(null)
48
- const wsRef = useRef<WebSocket | null>(null)
49
- const editorRef = useRef<RichTextEditorRef>(null)
50
-
51
- const fetchFaceList = async () => {
52
- try { const res = await fetch('https://face.viki.moe/metadata.json'); setFaceList(await res.json()) }
53
- catch (err) { console.error('[Sandbox] Failed to fetch face list:', err) }
54
- }
55
-
56
- useEffect(() => { fetchFaceList() }, [])
57
-
58
- const handleInboundPayload = (data: {
59
- type: string; id: string; content?: unknown; endpoint?: string; timestamp: number;
60
- messageId?: string; bot?: string;
61
- }) => {
62
- if (data.type === 'edit' && data.messageId) {
63
- const content: MessageSegment[] = Array.isArray(data.content)
64
- ? data.content as MessageSegment[]
65
- : parseTextToSegments(String(data.content ?? ''))
66
- setMessages((prev) => prev.map((m) => (m.id === data.messageId ? { ...m, content } : m)))
67
- return
68
- }
69
-
70
- const content: MessageSegment[] = typeof data.content === 'string'
71
- ? parseTextToSegments(data.content)
72
- : Array.isArray(data.content) ? data.content as MessageSegment[] : parseTextToSegments(String(data.content ?? ''))
73
-
74
- const channelName = data.type === 'private'
75
- ? `私聊-${data.bot || endpointName}`
76
- : data.type === 'group'
77
- ? `群组-${data.id}`
78
- : `频道-${data.id}`
79
- const channelType = data.type as Channel['type']
80
-
81
- setChannels((prev) => {
82
- if (prev.some((c) => c.id === data.id)) return prev
83
- const created: Channel = { id: data.id, name: channelName, type: channelType, unread: 0 }
84
- setActiveChannel(created)
85
- return [...prev, created]
86
- })
87
-
88
- setMessages((prev) => [...prev, {
89
- id: data.messageId ?? `bot_${data.timestamp}`, type: 'received', channelType,
90
- channelId: data.id, channelName, senderId: 'endpoint',
91
- senderName: data.bot || endpointName, content, timestamp: data.timestamp,
92
- }])
93
- }
94
-
95
- const sendInteractiveAction = (payload: string) => {
96
- const segments: MessageSegment[] = [{ type: 'action', data: { id: payload, payload } }]
97
- const payloadJson = JSON.stringify({ type: activeChannel.type, id: activeChannel.id, content: segments, timestamp: Date.now() })
98
- wsRef.current?.send(payloadJson)
99
- }
100
-
101
- useEffect(() => {
102
- const wsUrl = buildSandboxWebSocketUrl()
103
- wsRef.current = new WebSocket(wsUrl)
104
- wsRef.current.onopen = () => setConnected(true)
105
- wsRef.current.onmessage = (event) => {
106
- try { handleInboundPayload(JSON.parse(event.data)) }
107
- catch (err) { console.error('[Sandbox] Failed to parse message:', err) }
108
- }
109
- wsRef.current.onclose = () => setConnected(false)
110
-
111
- return () => {
112
- wsRef.current?.close()
113
- wsRef.current = null
114
- setConnected(false)
115
- }
116
- }, [])
117
-
118
- useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
119
- useEffect(() => { setPreviewSegments(inputText.trim() ? parseTextToSegments(inputText) : []) }, [inputText])
120
-
121
- const parseTextToSegments = (text: string): MessageSegment[] => {
122
- const segments: MessageSegment[] = []
123
- const regex = /\[@([^\]]+)\]|\[face:(\d+)\]|\[image:([^\]]+)\]|\[video:([^\]]+)\]|\[audio:([^\]]+)\]/g
124
- let lastIndex = 0
125
- let match: RegExpExecArray | null
126
- while ((match = regex.exec(text)) !== null) {
127
- if (match.index > lastIndex) {
128
- const t = text.substring(lastIndex, match.index)
129
- if (t) segments.push({ type: 'text', data: { text: t } })
130
- }
131
- if (match[1]) segments.push({ type: 'mention', data: { target: match[1], name: match[1] } })
132
- else if (match[2]) segments.push({ type: 'face', data: { id: parseInt(match[2], 10) } })
133
- else if (match[3]) segments.push({ type: 'image', data: { url: match[3] } })
134
- else if (match[4]) segments.push({ type: 'video', data: { url: match[4] } })
135
- else if (match[5]) segments.push({ type: 'audio', data: { url: match[5] } })
136
- lastIndex = regex.lastIndex
137
- }
138
- if (lastIndex < text.length) {
139
- const r = text.substring(lastIndex)
140
- if (r) segments.push({ type: 'text', data: { text: r } })
141
- }
142
- return segments.length > 0 ? segments : [{ type: 'text', data: { text } }]
143
- }
144
-
145
- const hasRenderableSegments = (segments: MessageSegment[]) => {
146
- if (segments.length === 0) return false
147
- return segments.some((s) => {
148
- if (s.type === 'text') return Boolean(String(s.data?.text ?? '').trim())
149
- if (s.type === 'keyboard') return true
150
- return true
151
- })
152
- }
153
-
154
- const renderMessageSegments = (segments: (MessageSegment | string)[], isSent: boolean) => {
155
- const ring = isSent ? 'ring-1 ring-primary-foreground/25' : 'ring-1 ring-border/60'
156
- return segments.map((segment, index) => {
157
- if (typeof segment === 'string') {
158
- return <span key={index}>{segment.split('\n').map((part, i) => <React.Fragment key={i}>{part}{i < segment.split('\n').length - 1 && <br />}</React.Fragment>)}</span>
159
- }
160
- const d = segment.data as Record<string, unknown>
161
- switch (segment.type) {
162
- case 'text':
163
- return <span key={index}>{String(d.text ?? '').split('\n').map((part: string, i: number) => <React.Fragment key={i}>{part}{i < String(d.text ?? '').split('\n').length - 1 && <br />}</React.Fragment>)}</span>
164
- case 'mention':
165
- case 'at':
166
- return <span key={index} className="inline-flex items-center px-1.5 py-0.5 rounded bg-accent text-accent-foreground text-xs mx-0.5">@{String(d.name ?? d.target ?? d.qq ?? '')}</span>
167
- case 'face':
168
- return <img key={index} 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 ?? '')} />
169
- case 'dice':
170
- return <span key={index} className="inline-flex items-center px-1.5 py-0.5 rounded bg-secondary text-xs mx-0.5">🎲 {d.result != null ? `点数 ${String(d.result)}` : '骰子'}</span>
171
- case 'rps':
172
- return <span key={index} className="inline-flex items-center px-1.5 py-0.5 rounded bg-secondary text-xs mx-0.5">✊ {d.result != null ? `结果 ${String(d.result)}` : '猜拳'}</span>
173
- case 'image': {
174
- const raw = pickMediaRawUrl(d)
175
- const src = resolveMediaSrc(raw, 'image')
176
- if (!src) return <span key={index} className="text-xs opacity-70">[图片]</span>
177
- return (
178
- <a key={index} href={src} target="_blank" rel="noreferrer" className="block my-1">
179
- <img src={src} alt="" className={cn('max-w-[min(320px,88vw)] rounded-lg block', ring, 'ring-offset-0')} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
180
- </a>
181
- )
182
- }
183
- case 'video': {
184
- const raw = pickMediaRawUrl(d)
185
- const src = resolveMediaSrc(raw, 'video')
186
- if (!src) return <span key={index} className="text-xs opacity-70">[视频无地址]</span>
187
- return (
188
- <video
189
- key={index}
190
- src={src}
191
- controls
192
- playsInline
193
- preload="metadata"
194
- className={cn('max-w-[min(360px,92vw)] max-h-72 rounded-lg my-1 bg-black/10', ring)}
195
- />
196
- )
197
- }
198
- case 'audio':
199
- case 'record': {
200
- const raw = pickMediaRawUrl(d)
201
- const src = resolveMediaSrc(raw, 'audio')
202
- if (!src) return <span key={index} className="text-xs opacity-70">[音频无地址]</span>
203
- return (
204
- <audio
205
- key={index}
206
- src={src}
207
- controls
208
- preload="metadata"
209
- className={cn('w-full max-w-sm my-2 h-10', isSent && 'opacity-95')}
210
- />
211
- )
212
- }
213
- case 'reply':
214
- return (
215
- <div key={index} className="mb-1 rounded-md border border-dashed px-2 py-1 text-xs opacity-90">
216
- ↩ 引用消息 #{String(d.message_id ?? d.id ?? '')}
217
- </div>
218
- )
219
- case 'forward': {
220
- const messages = d.messages as Array<Array<{ type?: string; data?: Record<string, unknown> }>> | undefined
221
- const title = String(d.title ?? '聊天记录')
222
- return (
223
- <div key={index} className="my-1 rounded-md border bg-background/40 px-2 py-2 text-xs space-y-1">
224
- <div className="font-medium">📨 {title}</div>
225
- {Array.isArray(messages) && messages.length > 0 ? (
226
- <div className="space-y-1 pl-2 border-l-2 border-muted">
227
- {messages.slice(0, 3).map((batch, bi) => (
228
- <div key={bi} className="opacity-90">
229
- {batch.map((s, si) => (
230
- <span key={si}>
231
- {s.type === 'text' ? String(s.data?.text ?? '') : `[${s.type ?? 'seg'}]`}
232
- </span>
233
- ))}
234
- </div>
235
- ))}
236
- {messages.length > 3 && <div className="opacity-60">…共 {messages.length} 条</div>}
237
- </div>
238
- ) : (
239
- <div className="opacity-70">[合并转发]</div>
240
- )}
241
- </div>
242
- )
243
- }
244
- case 'keyboard': {
245
- const rows = (d.rows as Array<Array<{ label: string; payload: string; disabled?: boolean }>>) ?? []
246
- return (
247
- <div key={index} className="inline-grid gap-1 my-1">
248
- {rows.map((row, ri) => (
249
- <div key={ri} className="flex gap-1">
250
- {row.map((btn) => (
251
- <button
252
- key={btn.payload}
253
- type="button"
254
- disabled={btn.disabled || isSent}
255
- onClick={() => sendInteractiveAction(btn.payload)}
256
- className={cn(
257
- 'min-w-9 h-9 rounded-md border text-sm font-medium transition-colors',
258
- btn.disabled
259
- ? 'opacity-50 cursor-not-allowed'
260
- : 'hover:bg-accent active:scale-95',
261
- )}
262
- >
263
- {btn.label}
264
- </button>
265
- ))}
266
- </div>
267
- ))}
268
- </div>
269
- )
270
- }
271
- default:
272
- return <span key={index} className="text-xs opacity-70">[{segment.type}]</span>
273
- }
274
- })
275
- }
276
-
277
- const handleSendMessage = (text: string, segments: MessageSegment[]) => {
278
- if (!hasRenderableSegments(segments)) return
279
- const newMessage: Message = { id: `msg_${Date.now()}`, type: 'sent', channelType: activeChannel.type, channelId: activeChannel.id, channelName: activeChannel.name, senderId: 'test_user', senderName: '测试用户', content: segments, timestamp: Date.now() }
280
- setMessages((prev) => [...prev, newMessage]); setInputText(''); setPreviewSegments([])
281
- editorRef.current?.clear()
282
- const payload = JSON.stringify({ type: activeChannel.type, id: activeChannel.id, content: segments, timestamp: Date.now() })
283
- wsRef.current?.send(payload)
284
- }
285
-
286
- const clearMessages = () => { if (confirm('确定清空所有消息记录?')) setMessages([]) }
287
- const switchChannel = (channel: Channel) => { setViewMode('chat'); setActiveChannel(channel); setChannels((prev) => prev.map((c) => c.id === channel.id ? { ...c, unread: 0 } : c)); if (window.innerWidth < 768) setShowChannelList(false) }
288
- const addChannel = () => {
289
- const types: Array<'private' | 'group' | 'channel'> = ['private', 'group', 'channel']
290
- const type = types[Math.floor(Math.random() * types.length)]
291
- const name = prompt(`请输入频道名称:`)
292
- if (name) { const nc: Channel = { id: `${type}_${Date.now()}`, name, type, unread: 0 }; setChannels((p) => [...p, nc]); setActiveChannel(nc) }
293
- }
294
- const getChannelIcon = (type: string) => { switch (type) { case 'private': return <User size={16} />; case 'group': return <Users size={16} />; case 'channel': return <Hash size={16} />; default: return <MessageSquare size={16} /> } }
295
- const insertFace = (faceId: number) => { editorRef.current?.insertFace(faceId); setShowFacePicker(false) }
296
- const commitMediaUrl = () => {
297
- const u = mediaUrl.trim()
298
- if (!u || !mediaPanel) return
299
- if (mediaPanel === 'image') editorRef.current?.insertImage(u)
300
- else if (mediaPanel === 'video') editorRef.current?.insertVideo(u)
301
- else editorRef.current?.insertAudio(u)
302
- setMediaUrl('')
303
- setMediaPanel(null)
304
- }
305
- const insertAtUser = () => { if (!atUserName.trim()) return; editorRef.current?.insertAt(atUserName.trim()); setAtUserName(''); setShowAtPicker(false) }
306
- const selectAtUser = (user: { id: string; name: string }) => { editorRef.current?.replaceAtTrigger(user.name, user.id); setAtPopoverPosition(null); setAtSearchQuery('') }
307
- const handleAtTrigger = (show: boolean, searchQuery: string, position?: { top: number; left: number }) => {
308
- if (activeChannel.type === 'private') { setAtPopoverPosition(null); setAtSearchQuery(''); return }
309
- if (show && position) { setAtPopoverPosition(position); setAtSearchQuery(searchQuery) } else { setAtPopoverPosition(null); setAtSearchQuery('') }
310
- }
311
- const filteredAtSuggestions = atSuggestions.filter((user) => { if (!atSearchQuery.trim()) return true; const q = atSearchQuery.toLowerCase(); return user.name.toLowerCase().includes(q) || user.id.toLowerCase().includes(q) })
312
- const handleEditorChange = (text: string, segments: MessageSegment[]) => { setInputText(text); setPreviewSegments(segments) }
313
- const filteredFaces = faceList.filter(face => face.name.toLowerCase().includes(faceSearchQuery.toLowerCase()) || face.describe.toLowerCase().includes(faceSearchQuery.toLowerCase()))
314
- const channelMessages = messages.filter((msg) => msg.channelId === activeChannel.id)
315
-
316
- return (
317
- <div className="sandbox-container rounded-xl border border-border/70 bg-card/30 shadow-sm">
318
- <button className="mobile-channel-toggle md:hidden" onClick={() => setShowChannelList(!showChannelList)}>
319
- <MessageSquare size={20} /> 频道列表
320
- </button>
321
-
322
- {/* Channel sidebar */}
323
- <div className={cn("channel-sidebar rounded-lg border bg-card", showChannelList && "show")}>
324
- <div className="p-3 border-b">
325
- <div className="flex justify-between items-center">
326
- <div className="flex items-center gap-2">
327
- <div className="p-1 rounded-md bg-secondary"><MessageSquare size={16} className="text-muted-foreground" /></div>
328
- <h3 className="font-semibold">频道列表</h3>
329
- </div>
330
- <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")}>
331
- {connected ? <Wifi size={12} /> : <WifiOff size={12} />}
332
- {connected ? '已连接' : '未连接'}
333
- </span>
334
- </div>
335
- </div>
336
-
337
- <div className="flex-1 overflow-y-auto p-2 space-y-1">
338
- {channels.map((channel) => {
339
- const isActive = viewMode === 'chat' && activeChannel.id === channel.id
340
- return (
341
- <div key={channel.id} className={cn("menu-item", isActive && "active")} onClick={() => switchChannel(channel)}>
342
- <span className="shrink-0">{getChannelIcon(channel.type)}</span>
343
- <div className="flex-1 min-w-0">
344
- <div className="text-sm font-medium truncate">{channel.name}</div>
345
- <div className="text-xs text-muted-foreground">{channel.type === 'private' ? '私聊' : channel.type === 'group' ? '群聊' : '频道'}</div>
346
- </div>
347
- {channel.unread > 0 && <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">{channel.unread}</span>}
348
- </div>
349
- )
350
- })}
351
- <div className="pt-2 mt-2 border-t space-y-1">
352
- <div className={cn("menu-item", viewMode === 'requests' && "active")} onClick={() => { setViewMode('requests'); if (window.innerWidth < 768) setShowChannelList(false) }}>
353
- <UserPlus size={16} className="shrink-0" />
354
- <div className="flex-1 min-w-0">
355
- <div className="text-sm font-medium">请求</div>
356
- <div className="text-xs text-muted-foreground">好友/群邀请等</div>
357
- </div>
358
- </div>
359
- <div className={cn("menu-item", viewMode === 'notices' && "active")} onClick={() => { setViewMode('notices'); if (window.innerWidth < 768) setShowChannelList(false) }}>
360
- <Bell size={16} className="shrink-0" />
361
- <div className="flex-1 min-w-0">
362
- <div className="text-sm font-medium">通知</div>
363
- <div className="text-xs text-muted-foreground">群管/撤回等</div>
364
- </div>
365
- </div>
366
- </div>
367
- </div>
368
-
369
- <div className="p-2 border-t">
370
- <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}>+ 添加频道</button>
371
- </div>
372
- </div>
373
-
374
- {showChannelList && <div className="channel-overlay md:hidden" onClick={() => setShowChannelList(false)} />}
375
-
376
- {/* Main area: Chat / Requests / Notices */}
377
- <div className="chat-area">
378
- {viewMode === 'requests' && (
379
- <div className="rounded-lg border bg-card flex-1 flex flex-col min-h-0 overflow-hidden">
380
- <div className="p-3 border-b flex-shrink-0">
381
- <h2 className="text-lg font-bold flex items-center gap-2">
382
- <UserPlus size={20} /> 请求
383
- </h2>
384
- </div>
385
- <div className="flex-1 overflow-y-auto p-4 flex flex-col items-center justify-center gap-3 text-muted-foreground text-center">
386
- <UserPlus size={48} className="opacity-30" />
387
- <span>沙盒为模拟环境,暂无请求数据</span>
388
- <span className="text-sm">实际好友/群邀请等请求请到侧边栏 <strong>机器人</strong> 页面进入对应机器人管理查看</span>
389
- </div>
390
- </div>
391
- )}
392
-
393
- {viewMode === 'notices' && (
394
- <div className="rounded-lg border bg-card flex-1 flex flex-col min-h-0 overflow-hidden">
395
- <div className="p-3 border-b flex-shrink-0">
396
- <h2 className="text-lg font-bold flex items-center gap-2">
397
- <Bell size={20} /> 通知
398
- </h2>
399
- </div>
400
- <div className="flex-1 overflow-y-auto p-4 flex flex-col items-center justify-center gap-3 text-muted-foreground text-center">
401
- <Bell size={48} className="opacity-30" />
402
- <span>沙盒为模拟环境,暂无通知数据</span>
403
- <span className="text-sm">实际群管、撤回等通知请到侧边栏 <strong>机器人</strong> 页面进入对应机器人管理查看</span>
404
- </div>
405
- </div>
406
- )}
407
-
408
- {viewMode === 'chat' && (
409
- <>
410
- {/* Top bar */}
411
- <div className="rounded-lg border bg-card p-3 flex-shrink-0">
412
- <div className="flex justify-between items-center flex-wrap gap-2">
413
- <div className="flex items-center gap-3">
414
- <div className="p-2 rounded-lg bg-secondary">{getChannelIcon(activeChannel.type)}</div>
415
- <div>
416
- <h2 className="text-lg font-bold">{activeChannel.name}</h2>
417
- <div className="flex items-center gap-2 text-xs text-muted-foreground">
418
- <span>{activeChannel.id}</span>
419
- <span className="inline-flex items-center px-1.5 py-0.5 rounded border text-[10px]">{channelMessages.length}</span>
420
- <span>条消息</span>
421
- </div>
422
- </div>
423
- <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-secondary text-secondary-foreground">
424
- {activeChannel.type === 'private' ? '私聊' : activeChannel.type === 'group' ? '群聊' : '频道'}
425
- </span>
426
- </div>
427
- <div className="flex items-center gap-2">
428
- <input value={endpointName} onChange={(e) => setBotName(e.target.value)} placeholder="机器人名称"
429
- className="h-8 w-28 rounded-md border bg-transparent px-2 text-sm" />
430
- <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}>
431
- <Trash2 size={14} /> 清空
432
- </button>
433
- </div>
434
- </div>
435
- </div>
436
-
437
- {/* Messages */}
438
- <div className="rounded-lg border bg-card flex-1 flex flex-col min-h-0">
439
- <div className="flex-1 overflow-y-auto p-4">
440
- {channelMessages.length === 0 ? (
441
- <div className="flex flex-col items-center justify-center h-full gap-3">
442
- <MessageSquare size={64} className="text-muted-foreground/20" />
443
- <span className="text-muted-foreground">暂无消息,开始对话吧!</span>
444
- </div>
445
- ) : (
446
- <div className="space-y-2">
447
- {channelMessages.map((msg) => (
448
- <div key={msg.id} className={cn("flex", msg.type === 'sent' ? "justify-end" : "justify-start")}>
449
- <div className={cn("max-w-[70%] p-3 rounded-2xl", msg.type === 'sent' ? "bg-primary text-primary-foreground" : "bg-muted")}>
450
- <div className="flex items-center gap-2 mb-1">
451
- {msg.type === 'received' && <Bot size={14} />}
452
- {msg.type === 'sent' && <User size={14} />}
453
- <span className="text-xs font-medium opacity-90">{msg.senderName}</span>
454
- <span className="text-xs opacity-70">{new Date(msg.timestamp).toLocaleTimeString()}</span>
455
- </div>
456
- <div className="text-sm space-y-1">{renderMessageSegments(msg.content, msg.type === 'sent')}</div>
457
- </div>
458
- </div>
459
- ))}
460
- <div ref={messagesEndRef} />
461
- </div>
462
- )}
463
- </div>
464
- </div>
465
-
466
- {/* Input area */}
467
- <div className="rounded-lg border bg-card p-3 flex-shrink-0 space-y-3">
468
- {/* Toolbar */}
469
- <div className="flex gap-2 items-center flex-wrap">
470
- <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")}
471
- onClick={() => { setShowFacePicker(!showFacePicker); setMediaPanel(null) }} title="插入表情">
472
- <Smile size={16} />
473
- </button>
474
- <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")}
475
- onClick={() => { setMediaPanel((p) => (p === 'image' ? null : 'image')); setShowFacePicker(false) }} title="插入图片 URL">
476
- <Image size={16} />
477
- </button>
478
- <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")}
479
- onClick={() => { setMediaPanel((p) => (p === 'video' ? null : 'video')); setShowFacePicker(false) }} title="插入视频 URL">
480
- <Video size={16} />
481
- </button>
482
- <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")}
483
- onClick={() => { setMediaPanel((p) => (p === 'audio' ? null : 'audio')); setShowFacePicker(false) }} title="插入音频 URL">
484
- <Music size={16} />
485
- </button>
486
- <div className="flex-1 min-w-[1rem]" />
487
- {inputText && (
488
- <button className="h-8 w-8 rounded-md flex items-center justify-center hover:bg-accent transition-colors"
489
- onClick={() => { setInputText(''); setPreviewSegments([]) }}><X size={16} /></button>
490
- )}
491
- </div>
492
-
493
- {/* Face picker */}
494
- {showFacePicker && (
495
- <div className="p-3 rounded-md border bg-muted/30 max-h-64 overflow-y-auto space-y-2">
496
- <input value={faceSearchQuery} onChange={(e) => setFaceSearchQuery(e.target.value)}
497
- placeholder="搜索表情..." className="w-full h-8 rounded-md border bg-transparent px-2 text-sm" />
498
- <div className="grid grid-cols-8 gap-1">
499
- {filteredFaces.slice(0, 80).map((face) => (
500
- <button key={face.id} onClick={() => insertFace(face.id)} title={face.name}
501
- className="w-10 h-10 rounded-md border flex items-center justify-center hover:bg-accent transition-colors">
502
- <img src={`https://face.viki.moe/apng/${face.id}.png`} alt={face.name} className="w-8 h-8" />
503
- </button>
504
- ))}
505
- </div>
506
- {filteredFaces.length === 0 && (
507
- <div className="flex flex-col items-center gap-2 py-4">
508
- <Search size={32} className="text-muted-foreground/30" />
509
- <span className="text-sm text-muted-foreground">未找到匹配的表情</span>
510
- </div>
511
- )}
512
- </div>
513
- )}
514
-
515
- {mediaPanel && (
516
- <div className="p-3 rounded-md border bg-muted/30 space-y-2">
517
- <p className="text-xs text-muted-foreground">
518
- {mediaPanel === 'image' && '支持 http(s) 图片链接或 data URL'}
519
- {mediaPanel === 'video' && '支持浏览器可解码的视频直链(如 .mp4、.webm)'}
520
- {mediaPanel === 'audio' && '支持 .mp3、.ogg、.wav 等音频直链'}
521
- </p>
522
- <input
523
- value={mediaUrl}
524
- onChange={(e) => setMediaUrl(e.target.value)}
525
- placeholder={mediaPanel === 'image' ? '图片 URL…' : mediaPanel === 'video' ? '视频 URL…' : '音频 URL…'}
526
- className="w-full h-8 rounded-md border border-input bg-background px-2 text-sm"
527
- onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitMediaUrl() } }}
528
- />
529
- <button
530
- type="button"
531
- className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm disabled:opacity-50"
532
- onClick={commitMediaUrl}
533
- disabled={!mediaUrl.trim()}
534
- >
535
- <Check size={14} /> 插入到输入框
536
- </button>
537
- </div>
538
- )}
539
-
540
- {/* Editor + send */}
541
- <div className="flex gap-2 items-start">
542
- <div className="flex-1 relative">
543
- <RichTextEditor
544
- ref={editorRef} placeholder={`向 ${activeChannel.name} 发送消息...`}
545
- onSend={handleSendMessage} onChange={handleEditorChange} onAtTrigger={handleAtTrigger}
546
- minHeight="44px" maxHeight="200px"
547
- />
548
- {atPopoverPosition && (
549
- <div className="absolute z-50 rounded-lg border bg-popover shadow-md min-w-60 max-h-72 overflow-y-auto p-1"
550
- style={{ top: `${atPopoverPosition.top}px`, left: `${atPopoverPosition.left}px` }}>
551
- {filteredAtSuggestions.length > 0 ? filteredAtSuggestions.map((user) => (
552
- <div key={user.id} className="flex items-center gap-2 p-2 rounded-md cursor-pointer hover:bg-accent transition-colors" onClick={() => selectAtUser(user)}>
553
- <User size={16} className="text-muted-foreground" />
554
- <div className="flex-1"><div className="text-sm font-medium">{user.name}</div><div className="text-xs text-muted-foreground">ID: {user.id}</div></div>
555
- </div>
556
- )) : (
557
- <div className="flex flex-col items-center gap-2 p-4">
558
- <Search size={20} className="text-muted-foreground/50" />
559
- <span className="text-xs text-muted-foreground">未找到匹配的用户</span>
560
- </div>
561
- )}
562
- </div>
563
- )}
564
- </div>
565
- <button
566
- 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"
567
- onClick={() => { const c = editorRef.current?.getContent(); if (c) handleSendMessage(c.text, c.segments) }}
568
- disabled={!hasRenderableSegments(previewSegments)}>
569
- <Send size={16} /> 发送
570
- </button>
571
- </div>
572
-
573
- {/* Hints */}
574
- <div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
575
- <Info size={12} /> 快捷操作:
576
- <span className="px-1 py-0.5 rounded border text-[10px]">Enter</span> 发送
577
- <span className="px-1 py-0.5 rounded border text-[10px]">Shift+Enter</span> 换行
578
- <span className="px-1 py-0.5 rounded border text-[10px]">[@名称]</span> @某人
579
- <span className="px-1 py-0.5 rounded border text-[10px]">[video:URL]</span>
580
- <span className="px-1 py-0.5 rounded border text-[10px]">[audio:URL]</span>
581
- </div>
582
- </div>
583
- </>
584
- )}
585
- </div>
586
- </div>
587
- )
588
- }
package/client/index.tsx DELETED
@@ -1,11 +0,0 @@
1
- import type { PluginRegisterHostApi } from '@zhin.js/contract'
2
- import Sandbox from './Sandbox'
3
-
4
- export function register(api: PluginRegisterHostApi) {
5
- api.addRoute({
6
- path: '/console/sandbox',
7
- name: '沙盒',
8
- element: api.React.createElement(Sandbox, { hostReact: api.React }),
9
- })
10
- api.addTool({ id: 'sandbox', name: '沙盒', path: '/console/sandbox' })
11
- }
@@ -1,28 +0,0 @@
1
- export function getSandboxApiBase(): string {
2
- const stored = localStorage.getItem("zhin_api_base")?.trim();
3
- return (stored ? stored.replace(/\/$/, "") : null) ?? window.location.origin;
4
- }
5
-
6
- export function getSandboxBearerToken(): string {
7
- return (
8
- localStorage.getItem("zhin_api_token")?.trim() ||
9
- localStorage.getItem("HTTP_TOKEN")?.trim() ||
10
- localStorage.getItem("zhin_http_token")?.trim() ||
11
- ""
12
- );
13
- }
14
-
15
- export function getSandboxAuthHeaders(): Record<string, string> {
16
- const token = getSandboxBearerToken();
17
- return token ? { Authorization: `Bearer ${token}` } : {};
18
- }
19
-
20
- /** Browser WebSocket cannot set Authorization reliably; pass token in query when needed. */
21
- export function buildSandboxWebSocketUrl(base?: string): string {
22
- const apiBase = (base ?? getSandboxApiBase()).replace(/\/$/, "");
23
- const wsUrl = new URL("/sandbox", `${apiBase}/`);
24
- wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
25
- const token = getSandboxBearerToken();
26
- if (token) wsUrl.searchParams.set("token", token);
27
- return wsUrl.href;
28
- }
@@ -1,7 +0,0 @@
1
- {
2
- "extends": "../node_modules/@zhin.js/host-api/browser.tsconfig.json",
3
- "compilerOptions": {
4
- "outDir": "../dist"
5
- },
6
- "include": ["./**/*"]
7
- }