@zhin.js/adapter-sandbox 7.0.12 → 7.0.15

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,6 +1,8 @@
1
- import React, { useState, useEffect, useRef } from 'react';
1
+ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
2
2
  import {
3
3
  cn,
4
+ CodeBlock,
5
+ MarkdownContent,
4
6
  resolveMediaSrc,
5
7
  pickMediaRawUrl,
6
8
  type MessageSegment,
@@ -9,51 +11,123 @@ import { buildSandboxWebSocketUrl } from './sandboxTransport';
9
11
  import {
10
12
  User, Bot, Users, Trash2, Send, Hash, MessageSquare,
11
13
  Wifi, WifiOff, Smile, Image, X, Check, Info, Search,
12
- UserPlus, Bell, Video, Music,
14
+ Video, Music, Plus, PanelRight, ExternalLink, RefreshCw,
15
+ Sparkles, Activity, Wrench, Coins, CircleAlert, Gauge,
16
+ SlidersHorizontal, FolderOpen, ShieldCheck, Network,
17
+ Square, RotateCcw, FileDiff, Terminal, FlaskConical, ChevronDown,
18
+ FileDown, ListChecks,
13
19
  } from 'lucide-react';
14
20
  import RichTextEditor, { type RichTextEditorRef } from './RichTextEditor';
21
+ import {
22
+ agentStudioPath,
23
+ buildAgentRunReport,
24
+ buildSandboxSessionKey,
25
+ cancelAgentTask,
26
+ deriveAgentRunSteps,
27
+ deriveTaskRuns,
28
+ deriveWorkbenchArtifacts,
29
+ fetchAgentTrace,
30
+ loadCachedAgentTrace,
31
+ mergeTraceSnapshot,
32
+ presentTraceEvent,
33
+ summarizeTrace,
34
+ saveCachedAgentTrace,
35
+ type AgentTraceSnapshot,
36
+ type SandboxScope,
37
+ } from './agentTrace.js';
38
+ import {
39
+ createDefaultPlaygroundState,
40
+ loadPlaygroundState,
41
+ savePlaygroundState,
42
+ type PlaygroundMessage,
43
+ type PlaygroundSession,
44
+ } from './playgroundState.js';
45
+ import type { SandboxAgentRunConfig } from '../src/run-config.js';
15
46
 
16
- interface Message {
17
- id: string; type: 'sent' | 'received'; channelType: 'private' | 'group' | 'channel';
18
- channelId: string; channelName: string; senderId: string; senderName: string;
19
- content: MessageSegment[]; timestamp: number;
20
- }
21
-
22
- interface Channel { id: string; name: string; type: 'private' | 'group' | 'channel'; unread: number; }
47
+ type Message = PlaygroundMessage
48
+ type Channel = PlaygroundSession
23
49
  interface Face { id: number; emojiId: number; stickerId: number; emojiType: string; name: string; describe: string; png: boolean; apng: boolean; lottie: boolean; }
24
50
 
25
51
  export default function Sandbox() {
26
- const [messages, setMessages] = useState<Message[]>([])
27
- const [channels, setChannels] = useState<Channel[]>([
28
- { id: 'user_1001', name: '测试用户', type: 'private', unread: 0 },
29
- { id: 'group_2001', name: '测试群组', type: 'group', unread: 0 },
30
- { id: 'channel_3001', name: '测试频道', type: 'channel', unread: 0 }
31
- ])
52
+ const [initialState] = useState(() => loadPlaygroundState())
53
+ const [messages, setMessages] = useState<Message[]>(() => [...initialState.messages])
54
+ const [channels, setChannels] = useState<Channel[]>(() => [...initialState.sessions])
32
55
  const [faceList, setFaceList] = useState<Face[]>([])
33
- const [activeChannel, setActiveChannel] = useState<Channel>(channels[0])
56
+ const [activeChannel, setActiveChannel] = useState<Channel>(() => (
57
+ initialState.sessions.find((session) => session.id === initialState.activeSessionId && (
58
+ initialState.activeSessionType === undefined || session.type === initialState.activeSessionType
59
+ ))
60
+ ?? initialState.sessions[0]
61
+ ?? createDefaultPlaygroundState().sessions[0]!
62
+ ))
34
63
  const [inputText, setInputText] = useState('')
35
- const [endpointId, setBotName] = useState('ProcessEndpoint')
64
+ const [endpointId, setBotName] = useState('sandbox-bot')
36
65
  const [connected, setConnected] = useState(false)
66
+ const [canExecute, setCanExecute] = useState(true)
67
+ const [persistenceStatus, setPersistenceStatus] = useState<'saved' | 'error'>('saved')
68
+ const [transportNotice, setTransportNotice] = useState<string | null>(null)
69
+ const [shellIsolation, setShellIsolation] = useState<{ available: boolean; provider: string; message: string } | null>(null)
70
+ const [inspectorView, setInspectorView] = useState<'runs' | 'artifacts'>('runs')
71
+ const [expandedArtifact, setExpandedArtifact] = useState<string | null>(null)
72
+ const [stoppingTask, setStoppingTask] = useState(false)
73
+ const [inlineRunExpanded, setInlineRunExpanded] = useState(false)
37
74
  const [showFacePicker, setShowFacePicker] = useState(false)
38
75
  /** 输入区:插入图片 / 视频 / 音频 URL */
39
76
  const [mediaPanel, setMediaPanel] = useState<null | 'image' | 'video' | 'audio'>(null)
40
77
  const [mediaUrl, setMediaUrl] = useState('')
41
- const [showAtPicker, setShowAtPicker] = useState(false)
42
78
  const [atPopoverPosition, setAtPopoverPosition] = useState<{ top: number; left: number } | null>(null)
43
79
  const [atSearchQuery, setAtSearchQuery] = useState('')
44
80
  const [faceSearchQuery, setFaceSearchQuery] = useState('')
45
- const [atUserName, setAtUserName] = useState('')
46
81
  const [atSuggestions] = useState([
47
- { id: '10001', name: '张三' }, { id: '10002', name: '李四' }, { id: '10003', name: '王五' },
48
- { id: '10004', name: '赵六' }, { id: '10005', name: '测试用户' }, { id: '10086', name: 'Admin' },
49
- { id: '10010', name: 'Test User' }
82
+ { id: 'actor-owner', name: '当前用户' }, { id: 'actor-reviewer', name: '审阅者' },
83
+ { id: 'actor-operator', name: '协作者' }, { id: 'actor-bot', name: 'Sandbox Agent' }
50
84
  ])
51
85
  const [previewSegments, setPreviewSegments] = useState<MessageSegment[]>([])
86
+ const [composerMode, setComposerMode] = useState<'write' | 'preview'>('write')
52
87
  const [showChannelList, setShowChannelList] = useState(false)
53
- const [viewMode, setViewMode] = useState<'chat' | 'requests' | 'notices'>('chat')
88
+ const [showInspector, setShowInspector] = useState(false)
89
+ const [showRunSettings, setShowRunSettings] = useState(false)
90
+ const [showNewSession, setShowNewSession] = useState(false)
91
+ const [newSessionName, setNewSessionName] = useState('')
92
+ const [newSessionScope, setNewSessionScope] = useState<SandboxScope>('private')
93
+ const [confirmClear, setConfirmClear] = useState(false)
94
+ const [trace, setTrace] = useState<AgentTraceSnapshot | null>(null)
95
+ const [traceLoading, setTraceLoading] = useState(true)
96
+ const [traceNotice, setTraceNotice] = useState<string | null>(null)
54
97
  const messagesEndRef = useRef<HTMLDivElement>(null)
55
98
  const wsRef = useRef<WebSocket | null>(null)
56
99
  const editorRef = useRef<RichTextEditorRef>(null)
100
+ const traceRef = useRef<AgentTraceSnapshot | null>(null)
101
+ const activeChannelRef = useRef(activeChannel)
102
+ const endpointIdRef = useRef(endpointId)
103
+ const sessionKey = useMemo(
104
+ () => buildSandboxSessionKey(endpointId, activeChannel.type, activeChannel.id),
105
+ [activeChannel.id, activeChannel.type, endpointId],
106
+ )
107
+ const traceSummary = useMemo(() => summarizeTrace(trace), [trace])
108
+ const taskRuns = useMemo(() => deriveTaskRuns(trace), [trace])
109
+ const workbenchArtifacts = useMemo(() => deriveWorkbenchArtifacts(trace), [trace])
110
+ const currentTask = taskRuns.find((run) => run.status === 'running') ?? taskRuns[0]
111
+ const currentRunSteps = useMemo(() => deriveAgentRunSteps(trace, currentTask), [currentTask, trace])
112
+ const currentRunArtifacts = useMemo(
113
+ () => workbenchArtifacts.filter((artifact) => (
114
+ artifact.turnId === currentTask?.turnId && artifact.runtimeId === currentTask?.runtimeId
115
+ )),
116
+ [currentTask?.runtimeId, currentTask?.turnId, workbenchArtifacts],
117
+ )
118
+
119
+ useEffect(() => { activeChannelRef.current = activeChannel }, [activeChannel])
120
+ useEffect(() => { endpointIdRef.current = endpointId }, [endpointId])
121
+ useEffect(() => { setInlineRunExpanded(currentTask?.status === 'running') }, [currentTask?.id])
122
+ useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [currentTask?.id])
123
+ useEffect(() => {
124
+ setPersistenceStatus(savePlaygroundState({
125
+ activeSessionId: activeChannel.id,
126
+ activeSessionType: activeChannel.type,
127
+ sessions: channels,
128
+ messages,
129
+ }) ? 'saved' : 'error')
130
+ }, [activeChannel.id, channels, messages])
57
131
 
58
132
  const fetchFaceList = async () => {
59
133
  try { const res = await fetch('https://face.viki.moe/metadata.json'); setFaceList(await res.json()) }
@@ -63,9 +137,39 @@ export default function Sandbox() {
63
137
  useEffect(() => { fetchFaceList() }, [])
64
138
 
65
139
  const handleInboundPayload = (data: {
66
- type: string; id: string; content?: unknown; endpoint?: string; timestamp: number;
140
+ type: string; id: string; content?: unknown; endpoint?: string; workingDirectory?: string; canExecute?: boolean;
141
+ shellIsolation?: { available?: boolean; provider?: string; message?: string }; timestamp: number;
67
142
  messageId?: string; bot?: string;
68
143
  }) => {
144
+ if (data.type === 'ready') {
145
+ setBotName(data.endpoint || data.bot || 'sandbox-bot')
146
+ setCanExecute(data.canExecute !== false)
147
+ setTransportNotice(data.canExecute === false ? '当前 Token 只有演示权限,任务运行已禁用。' : null)
148
+ if (data.shellIsolation) {
149
+ setShellIsolation({
150
+ available: data.shellIsolation.available === true,
151
+ provider: data.shellIsolation.provider || 'docker',
152
+ message: data.shellIsolation.message || '未检测到隔离执行环境',
153
+ })
154
+ }
155
+ if (data.workingDirectory?.trim()) {
156
+ const workingDirectory = data.workingDirectory.trim()
157
+ setChannels((current) => current.map((session) => session.runConfig.workingDirectory
158
+ ? session
159
+ : { ...session, runConfig: { ...session.runConfig, workingDirectory } }))
160
+ setActiveChannel((current) => current.runConfig.workingDirectory
161
+ ? current
162
+ : { ...current, runConfig: { ...current.runConfig, workingDirectory } })
163
+ }
164
+ return
165
+ }
166
+ if (data.type === 'error') {
167
+ const notice = Array.isArray(data.content)
168
+ ? String((data.content[0] as { data?: { text?: unknown } } | undefined)?.data?.text ?? 'Sandbox 拒绝了本次操作')
169
+ : String(data.content ?? 'Sandbox 拒绝了本次操作')
170
+ setTransportNotice(notice)
171
+ return
172
+ }
69
173
  if (data.type === 'edit' && data.messageId) {
70
174
  const content: MessageSegment[] = Array.isArray(data.content)
71
175
  ? data.content as MessageSegment[]
@@ -78,16 +182,19 @@ export default function Sandbox() {
78
182
  ? parseTextToSegments(data.content)
79
183
  : Array.isArray(data.content) ? data.content as MessageSegment[] : parseTextToSegments(String(data.content ?? ''))
80
184
 
81
- const channelName = data.type === 'private'
82
- ? `私聊-${data.bot || endpointId}`
83
- : data.type === 'group'
84
- ? `群组-${data.id}`
85
- : `频道-${data.id}`
86
- const channelType = data.type as Channel['type']
185
+ const channelType: Channel['type'] = data.type === 'group' || data.type === 'channel' ? data.type : 'private'
186
+ const channelName = channelType === 'private'
187
+ ? `会话 ${data.id}`
188
+ : channelType === 'group'
189
+ ? `群组场景 ${data.id}`
190
+ : `频道场景 ${data.id}`
87
191
 
88
192
  setChannels((prev) => {
89
- if (prev.some((c) => c.id === data.id)) return prev
90
- const created: Channel = { id: data.id, name: channelName, type: channelType, unread: 0 }
193
+ if (prev.some((c) => c.id === data.id && c.type === channelType)) return prev
194
+ const created: Channel = {
195
+ id: data.id, name: channelName, type: channelType, unread: 0,
196
+ runConfig: { ...activeChannelRef.current.runConfig },
197
+ }
91
198
  setActiveChannel(created)
92
199
  return [...prev, created]
93
200
  })
@@ -95,14 +202,34 @@ export default function Sandbox() {
95
202
  setMessages((prev) => [...prev, {
96
203
  id: data.messageId ?? `bot_${data.timestamp}`, type: 'received', channelType,
97
204
  channelId: data.id, channelName, senderId: 'endpoint',
98
- senderName: data.bot || endpointId, content, timestamp: data.timestamp,
205
+ senderName: data.bot || endpointIdRef.current, content, timestamp: data.timestamp,
99
206
  }])
100
207
  }
101
208
 
102
- const sendInteractiveAction = (payload: string) => {
209
+ const sendInteractiveAction = (payload: string, messageId?: string) => {
210
+ const ws = wsRef.current
211
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
212
+ setTransportNotice('Sandbox 连接尚未就绪,审批选择未发送。')
213
+ return
214
+ }
103
215
  const segments: MessageSegment[] = [{ type: 'action', data: { id: payload, payload } }]
104
- const payloadJson = JSON.stringify({ type: activeChannel.type, id: activeChannel.id, content: segments, timestamp: Date.now() })
105
- wsRef.current?.send(payloadJson)
216
+ const payloadJson = JSON.stringify({
217
+ type: activeChannel.type,
218
+ id: activeChannel.id,
219
+ content: segments,
220
+ agentRun: activeChannel.runConfig,
221
+ timestamp: Date.now(),
222
+ })
223
+ try {
224
+ ws.send(payloadJson)
225
+ if (messageId) {
226
+ setMessages((current) => current.map((message) => message.id === messageId
227
+ ? { ...message, interactionResolved: true }
228
+ : message))
229
+ }
230
+ } catch {
231
+ setTransportNotice('审批选择发送失败,请等待连接恢复后重试。')
232
+ }
106
233
  }
107
234
 
108
235
  useEffect(() => {
@@ -200,6 +327,38 @@ export default function Sandbox() {
200
327
  useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
201
328
  useEffect(() => { setPreviewSegments(inputText.trim() ? parseTextToSegments(inputText) : []) }, [inputText])
202
329
 
330
+ const loadTrace = useCallback(async (quiet = false) => {
331
+ if (!quiet) setTraceLoading(true)
332
+ try {
333
+ const current = traceRef.current?.sessionKey === sessionKey ? traceRef.current : null
334
+ let incoming = await fetchAgentTrace(sessionKey, quiet ? current?.latestSequence ?? 0 : 0)
335
+ if (quiet && current?.runtimeId && incoming.runtimeId && current.runtimeId !== incoming.runtimeId) {
336
+ incoming = await fetchAgentTrace(sessionKey, 0)
337
+ }
338
+ const merged = mergeTraceSnapshot(current, incoming)
339
+ traceRef.current = merged
340
+ setTrace(merged)
341
+ saveCachedAgentTrace(merged)
342
+ setTraceNotice(null)
343
+ } catch (error) {
344
+ setTraceNotice(error instanceof Error ? error.message : 'Agent Trace 暂不可用')
345
+ } finally {
346
+ if (!quiet) setTraceLoading(false)
347
+ }
348
+ }, [sessionKey])
349
+
350
+ useEffect(() => {
351
+ const cached = loadCachedAgentTrace(sessionKey)
352
+ traceRef.current = cached
353
+ setTrace(cached)
354
+ setTraceNotice(null)
355
+ void loadTrace()
356
+ const timer = window.setInterval(() => {
357
+ if (document.visibilityState === 'visible') void loadTrace(true)
358
+ }, 2_000)
359
+ return () => window.clearInterval(timer)
360
+ }, [loadTrace])
361
+
203
362
  const parseTextToSegments = (text: string): MessageSegment[] => {
204
363
  const segments: MessageSegment[] = []
205
364
  const regex = /\[@([^\]]+)\]|\[face:(\d+)\]|\[image:([^\]]+)\]|\[video:([^\]]+)\]|\[audio:([^\]]+)\]/g
@@ -233,16 +392,25 @@ export default function Sandbox() {
233
392
  })
234
393
  }
235
394
 
236
- const renderMessageSegments = (segments: (MessageSegment | string)[], isSent: boolean) => {
395
+ const renderMessageSegments = (
396
+ segments: (MessageSegment | string)[],
397
+ isSent: boolean,
398
+ messageId?: string,
399
+ interactionResolved = false,
400
+ ) => {
237
401
  const ring = isSent ? 'ring-1 ring-primary-foreground/25' : 'ring-1 ring-border/60'
238
402
  return segments.map((segment, index) => {
239
403
  if (typeof segment === 'string') {
240
- 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>
404
+ return <MarkdownContent key={index} text={segment} className={isSent ? 'zhin-markdown--inverse' : undefined} />
241
405
  }
242
406
  const d = segment.data as Record<string, unknown>
243
407
  switch (segment.type) {
244
408
  case 'text':
245
- 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>
409
+ case 'markdown':
410
+ case 'md':
411
+ return <MarkdownContent key={index} text={String(d.text ?? d.content ?? '')} className={isSent ? 'zhin-markdown--inverse' : undefined} />
412
+ case 'code':
413
+ return <CodeBlock key={index} code={String(d.code ?? d.text ?? d.content ?? '')} language={String(d.language ?? d.lang ?? '')} />
246
414
  case 'mention':
247
415
  case 'at':
248
416
  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>
@@ -257,7 +425,10 @@ export default function Sandbox() {
257
425
  const src = resolveMediaSrc(raw, 'image')
258
426
  if (!src) return <span key={index} className="text-xs opacity-70">[图片]</span>
259
427
  return (
428
+ // resolveMediaSrc allowlists non-executable URL schemes and image data MIME types.
429
+ // lgtm[js/xss,js/client-side-unvalidated-url-redirection]
260
430
  <a key={index} href={src} target="_blank" rel="noreferrer" className="block my-1">
431
+ {/* lgtm[js/xss,js/client-side-unvalidated-url-redirection] */}
261
432
  <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' }} />
262
433
  </a>
263
434
  )
@@ -267,6 +438,8 @@ export default function Sandbox() {
267
438
  const src = resolveMediaSrc(raw, 'video')
268
439
  if (!src) return <span key={index} className="text-xs opacity-70">[视频无地址]</span>
269
440
  return (
441
+ // resolveMediaSrc allowlists non-executable URL schemes and video data MIME types.
442
+ // lgtm[js/xss,js/client-side-unvalidated-url-redirection]
270
443
  <video
271
444
  key={index}
272
445
  src={src}
@@ -283,6 +456,8 @@ export default function Sandbox() {
283
456
  const src = resolveMediaSrc(raw, 'audio')
284
457
  if (!src) return <span key={index} className="text-xs opacity-70">[音频无地址]</span>
285
458
  return (
459
+ // resolveMediaSrc allowlists non-executable URL schemes and audio data MIME types.
460
+ // lgtm[js/xss,js/client-side-unvalidated-url-redirection]
286
461
  <audio
287
462
  key={index}
288
463
  src={src}
@@ -324,22 +499,21 @@ export default function Sandbox() {
324
499
  )
325
500
  }
326
501
  case 'keyboard': {
327
- const rows = (d.rows as Array<Array<{ label: string; payload: string; disabled?: boolean }>>) ?? []
502
+ const rows = (d.rows as Array<Array<{ label: string; payload: string; disabled?: boolean; style?: string }>>) ?? []
503
+ const resolved = Boolean(messageId && interactionResolved)
328
504
  return (
329
- <div key={index} className="inline-grid gap-1 my-1">
505
+ <div key={index} className="agent-playground-approval-actions">
330
506
  {rows.map((row, ri) => (
331
- <div key={ri} className="flex gap-1">
507
+ <div key={ri}>
332
508
  {row.map((btn) => (
333
509
  <button
334
510
  key={btn.payload}
335
511
  type="button"
336
- disabled={btn.disabled || isSent}
337
- onClick={() => sendInteractiveAction(btn.payload)}
512
+ disabled={btn.disabled || isSent || resolved}
513
+ onClick={() => sendInteractiveAction(btn.payload, messageId)}
338
514
  className={cn(
339
- 'min-w-9 h-9 rounded-md border text-sm font-medium transition-colors',
340
- btn.disabled
341
- ? 'opacity-50 cursor-not-allowed'
342
- : 'hover:bg-accent active:scale-95',
515
+ (btn.style === 'primary' || /^允许/u.test(btn.label)) && 'is-primary',
516
+ (btn.style === 'danger' || /拒绝|取消/u.test(btn.label)) && 'is-danger',
343
517
  )}
344
518
  >
345
519
  {btn.label}
@@ -347,6 +521,7 @@ export default function Sandbox() {
347
521
  ))}
348
522
  </div>
349
523
  ))}
524
+ {resolved && <small><Check size={12} />已提交本次选择</small>}
350
525
  </div>
351
526
  )
352
527
  }
@@ -357,23 +532,46 @@ export default function Sandbox() {
357
532
  }
358
533
 
359
534
  const handleSendMessage = (text: string, segments: MessageSegment[]) => {
360
- if (!hasRenderableSegments(segments)) return
535
+ if (!canExecute || !hasRenderableSegments(segments)) return
361
536
  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() }
362
- setMessages((prev) => [...prev, newMessage]); setInputText(''); setPreviewSegments([])
537
+ setMessages((prev) => [...prev, newMessage]); setInputText(''); setPreviewSegments([]); setComposerMode('write')
363
538
  editorRef.current?.clear()
364
539
  // Stamp type+id so Host sandbox endpoint preserves channel context for outbound replies.
365
- const payload = JSON.stringify({ type: activeChannel.type, id: activeChannel.id, content: segments, timestamp: Date.now() })
540
+ const payload = JSON.stringify({
541
+ type: activeChannel.type,
542
+ id: activeChannel.id,
543
+ messageId: newMessage.id,
544
+ content: segments,
545
+ agentRun: activeChannel.runConfig,
546
+ timestamp: Date.now(),
547
+ })
366
548
  wsRef.current?.send(payload)
367
549
  }
368
550
 
369
551
 
370
- const clearMessages = () => { if (confirm('确定清空所有消息记录?')) setMessages([]) }
371
- const switchChannel = (channel: Channel) => { setViewMode('chat'); setActiveChannel(channel); setChannels((prev) => prev.map((c) => c.id === channel.id ? { ...c, unread: 0 } : c)); if (window.innerWidth < 768) setShowChannelList(false) }
372
- const addChannel = () => {
373
- const types: Array<'private' | 'group' | 'channel'> = ['private', 'group', 'channel']
374
- const type = types[Math.floor(Math.random() * types.length)]
375
- const name = prompt(`请输入频道名称:`)
376
- if (name) { const nc: Channel = { id: `${type}_${Date.now()}`, name, type, unread: 0 }; setChannels((p) => [...p, nc]); setActiveChannel(nc) }
552
+ const clearMessages = () => {
553
+ setMessages((current) => current.filter((message) => !(
554
+ message.channelId === activeChannel.id && message.channelType === activeChannel.type
555
+ )))
556
+ setConfirmClear(false)
557
+ }
558
+ const switchChannel = (channel: Channel) => { setActiveChannel(channel); setChannels((prev) => prev.map((c) => c.id === channel.id && c.type === channel.type ? { ...c, unread: 0 } : c)); if (window.innerWidth < 900) setShowChannelList(false) }
559
+ const updateRunConfig = (patch: Partial<SandboxAgentRunConfig>) => {
560
+ const next = { ...activeChannel, runConfig: { ...activeChannel.runConfig, ...patch } }
561
+ setActiveChannel(next)
562
+ setChannels((sessions) => sessions.map((session) => session.id === activeChannel.id && session.type === activeChannel.type ? next : session))
563
+ }
564
+ const addSession = () => {
565
+ const label = newSessionName.trim() || '未命名试验'
566
+ const id = `${newSessionScope}-${Date.now().toString(36)}`
567
+ const session: Channel = {
568
+ id, name: label, type: newSessionScope, unread: 0,
569
+ runConfig: { ...activeChannel.runConfig },
570
+ }
571
+ setChannels((current) => [...current, session])
572
+ setActiveChannel(session)
573
+ setNewSessionName('')
574
+ setShowNewSession(false)
377
575
  }
378
576
  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} /> } }
379
577
  const insertFace = (faceId: number) => { editorRef.current?.insertFace(faceId); setShowFacePicker(false) }
@@ -386,7 +584,6 @@ export default function Sandbox() {
386
584
  setMediaUrl('')
387
585
  setMediaPanel(null)
388
586
  }
389
- const insertAtUser = () => { if (!atUserName.trim()) return; editorRef.current?.insertAt(atUserName.trim()); setAtUserName(''); setShowAtPicker(false) }
390
587
  const selectAtUser = (user: { id: string; name: string }) => { editorRef.current?.replaceAtTrigger(user.name, user.id); setAtPopoverPosition(null); setAtSearchQuery('') }
391
588
  const handleAtTrigger = (show: boolean, searchQuery: string, position?: { top: number; left: number }) => {
392
589
  if (activeChannel.type === 'private') { setAtPopoverPosition(null); setAtSearchQuery(''); return }
@@ -395,160 +592,308 @@ export default function Sandbox() {
395
592
  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) })
396
593
  const handleEditorChange = (text: string, segments: MessageSegment[]) => { setInputText(text); setPreviewSegments(segments) }
397
594
  const filteredFaces = faceList.filter(face => face.name.toLowerCase().includes(faceSearchQuery.toLowerCase()) || face.describe.toLowerCase().includes(faceSearchQuery.toLowerCase()))
398
- const channelMessages = messages.filter((msg) => msg.channelId === activeChannel.id)
595
+ const channelMessages = messages.filter((msg) => msg.channelId === activeChannel.id && msg.channelType === activeChannel.type)
596
+ const lastUserMessage = [...channelMessages].reverse().find((message) => message.type === 'sent')
597
+ const currentTaskMessage = currentTask?.sourceMessageId
598
+ ? channelMessages.find((message) => message.id === currentTask.sourceMessageId && message.type === 'sent')
599
+ : undefined
600
+ const scopeLabel = activeChannel.type === 'private' ? '单用户' : activeChannel.type === 'group' ? '群组上下文' : '频道上下文'
601
+ const runPrompt = (prompt: string) => handleSendMessage(prompt, [{ type: 'text', data: { text: prompt } }])
602
+ const recentTraceEvents = trace?.events.slice(-8).reverse() ?? []
603
+ const stopActiveTask = async () => {
604
+ setStoppingTask(true)
605
+ try {
606
+ const cancelled = await cancelAgentTask(sessionKey)
607
+ setTraceNotice(cancelled ? '已发送停止请求,正在等待任务结束。' : '当前会话没有运行中的任务。')
608
+ window.setTimeout(() => void loadTrace(true), 240)
609
+ } catch (error) {
610
+ setTraceNotice(error instanceof Error ? error.message : '无法停止任务')
611
+ setShowInspector(true)
612
+ } finally {
613
+ setStoppingTask(false)
614
+ }
615
+ }
616
+ const retryLastTask = () => {
617
+ if (!lastUserMessage) return
618
+ handleSendMessage(messageText(lastUserMessage.content), [...lastUserMessage.content])
619
+ }
620
+ const retryCurrentTask = () => {
621
+ if (!currentTaskMessage) return
622
+ handleSendMessage(messageText(currentTaskMessage.content), [...currentTaskMessage.content])
623
+ }
624
+ const exportCurrentRun = () => {
625
+ if (!trace || !currentTask) return
626
+ const report = buildAgentRunReport(trace, {
627
+ run: currentTask,
628
+ sessionName: activeChannel.name,
629
+ taskPrompt: currentTaskMessage ? messageText(currentTaskMessage.content) : undefined,
630
+ workingDirectory: activeChannel.runConfig.workingDirectory,
631
+ safetyMode: activeChannel.runConfig.safetyMode,
632
+ approvalMode: activeChannel.runConfig.approvalMode,
633
+ networkAccess: activeChannel.runConfig.safetyMode === 'danger-full-access' || activeChannel.runConfig.networkAccess,
634
+ })
635
+ const url = URL.createObjectURL(new Blob([report], { type: 'text/markdown;charset=utf-8' }))
636
+ const link = document.createElement('a')
637
+ link.href = url
638
+ link.download = `zhin-agent-run-${safeFileName(activeChannel.name)}-${currentTask.turnId.slice(0, 8)}.md`
639
+ document.body.appendChild(link)
640
+ link.click()
641
+ link.remove()
642
+ window.setTimeout(() => URL.revokeObjectURL(url), 0)
643
+ }
644
+ const inlineRunCard = currentTask ? (
645
+ <article className={cn('agent-playground-inline-run', `is-${currentTask.status}`)} aria-live={currentTask.status === 'running' ? 'polite' : undefined}>
646
+ <span className="agent-playground-inline-run-rail" aria-hidden="true" />
647
+ <div className="agent-playground-inline-run-head">
648
+ <span className="agent-playground-inline-run-icon"><ListChecks size={17} /></span>
649
+ <div className="agent-playground-inline-run-copy">
650
+ <span>{currentTask.status === 'running' ? 'Live agent run' : 'Agent run summary'}</span>
651
+ <h3>{taskStatusLabel(currentTask.status)}</h3>
652
+ {currentTaskMessage && <p>{messageText(currentTaskMessage.content)}</p>}
653
+ </div>
654
+ <div className="agent-playground-inline-run-actions">
655
+ {currentTask.status === 'running' ? (
656
+ <button type="button" className="is-stop" onClick={() => void stopActiveTask()} disabled={stoppingTask}><Square size={12} />{stoppingTask ? '停止中' : '停止'}</button>
657
+ ) : currentTaskMessage ? (
658
+ <button type="button" onClick={retryCurrentTask}><RotateCcw size={13} />重试</button>
659
+ ) : null}
660
+ <button type="button" onClick={exportCurrentRun} title="导出 Markdown 运行报告"><FileDown size={13} />导出</button>
661
+ </div>
662
+ </div>
663
+ <dl className="agent-playground-inline-run-metrics">
664
+ <div><dt>耗时</dt><dd>{currentTask.durationMs === undefined ? '进行中' : formatDuration(currentTask.durationMs)}</dd></div>
665
+ <div><dt>步骤</dt><dd>{currentRunSteps.length}</dd></div>
666
+ <div><dt>工具</dt><dd>{currentTask.toolCount}</dd></div>
667
+ <div><dt>Token</dt><dd>{currentTask.tokenCount.toLocaleString()}</dd></div>
668
+ <div className={cn(currentTask.problemCount > 0 && 'has-problem')}><dt>异常</dt><dd>{currentTask.problemCount}</dd></div>
669
+ </dl>
670
+ {inlineRunExpanded && (
671
+ currentRunSteps.length > 0 ? (
672
+ <ol className="agent-playground-inline-run-steps">
673
+ {currentRunSteps.map((step) => (
674
+ <li key={step.id} className={cn(`is-${step.status}`)}>
675
+ <i aria-hidden="true" />
676
+ <div><strong>{step.title}</strong>{step.detail && <small>{step.detail}</small>}</div>
677
+ <span>{step.durationMs === undefined ? runStepStatusLabel(step.status) : formatDuration(step.durationMs)}</span>
678
+ </li>
679
+ ))}
680
+ </ol>
681
+ ) : <div className="agent-playground-inline-run-empty">正在等待第一条运行事件…</div>
682
+ )}
683
+ <footer>
684
+ <button type="button" aria-expanded={inlineRunExpanded} onClick={() => setInlineRunExpanded((expanded) => !expanded)}>
685
+ <ChevronDown size={13} />{inlineRunExpanded ? '收起步骤' : `查看 ${currentRunSteps.length} 个步骤`}
686
+ </button>
687
+ <button type="button" onClick={() => { setInspectorView(currentRunArtifacts.length > 0 ? 'artifacts' : 'runs'); setShowInspector(true) }}>
688
+ {currentRunArtifacts.length > 0 ? `${currentRunArtifacts.length} 个变更与产物` : '打开运行检查器'}<PanelRight size={13} />
689
+ </button>
690
+ </footer>
691
+ </article>
692
+ ) : null
399
693
 
400
694
  return (
401
- <div className="sandbox-container rounded-xl border border-border/70 bg-card/30 shadow-sm">
402
- <button className="mobile-channel-toggle md:hidden" onClick={() => setShowChannelList(!showChannelList)}>
403
- <MessageSquare size={20} /> 频道列表
404
- </button>
405
-
406
- {/* Channel sidebar */}
407
- <div className={cn("channel-sidebar rounded-lg border bg-card", showChannelList && "show")}>
408
- <div className="p-3 border-b">
695
+ <section className="agent-playground-shell">
696
+ <div className="agent-playground-mobilebar">
697
+ <button type="button" aria-expanded={showChannelList} onClick={() => setShowChannelList(!showChannelList)}><MessageSquare size={17} />测试会话</button>
698
+ <strong>Agent 试验台</strong>
699
+ <button type="button" aria-expanded={showInspector} onClick={() => setShowInspector(!showInspector)}><PanelRight size={17} />检查器</button>
700
+ </div>
701
+
702
+ <nav className={cn("channel-sidebar agent-playground-sessions", showChannelList && "show")} aria-label="测试会话">
703
+ <div className="agent-playground-brand">
409
704
  <div className="flex justify-between items-center">
410
705
  <div className="flex items-center gap-2">
411
- <div className="p-1 rounded-md bg-secondary"><MessageSquare size={16} className="text-muted-foreground" /></div>
412
- <h3 className="font-semibold">频道列表</h3>
706
+ <span className="agent-playground-mark"><Sparkles size={16} /></span>
707
+ <div><h2>Agent 试验台</h2><small>Sandbox playground</small></div>
413
708
  </div>
414
- <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")}>
709
+ <span className={cn("agent-playground-connection", connected && "is-online")} title={connected ? 'Sandbox WebSocket 已连接' : '正在重连 Sandbox WebSocket'}>
415
710
  {connected ? <Wifi size={12} /> : <WifiOff size={12} />}
416
- {connected ? '已连接' : '未连接'}
417
711
  </span>
418
712
  </div>
419
713
  </div>
420
714
 
421
- <div className="flex-1 overflow-y-auto p-2 space-y-1">
715
+ <div className="agent-playground-section-label"><span>测试会话</span><span>{channels.length}</span></div>
716
+ <div className="agent-playground-session-list">
422
717
  {channels.map((channel) => {
423
- const isActive = viewMode === 'chat' && activeChannel.id === channel.id
718
+ const isActive = activeChannel.id === channel.id && activeChannel.type === channel.type
424
719
  return (
425
- <div key={channel.id} className={cn("menu-item", isActive && "active")} onClick={() => switchChannel(channel)}>
426
- <span className="shrink-0">{getChannelIcon(channel.type)}</span>
720
+ <button type="button" key={`${channel.type}:${channel.id}`} aria-current={isActive ? 'page' : undefined} className={cn("agent-playground-session", isActive && "active")} onClick={() => switchChannel(channel)}>
721
+ <span className="agent-playground-session-icon">{getChannelIcon(channel.type)}</span>
427
722
  <div className="flex-1 min-w-0">
428
723
  <div className="text-sm font-medium truncate">{channel.name}</div>
429
- <div className="text-xs text-muted-foreground">{channel.type === 'private' ? '私聊' : channel.type === 'group' ? '群聊' : '频道'}</div>
724
+ <div className="text-xs text-muted-foreground">{channel.type === 'private' ? '单用户作用域' : channel.type === 'group' ? '群组作用域' : '频道作用域'}</div>
430
725
  </div>
431
726
  {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>}
432
- </div>
727
+ </button>
433
728
  )
434
729
  })}
435
- <div className="pt-2 mt-2 border-t space-y-1">
436
- <div className={cn("menu-item", viewMode === 'requests' && "active")} onClick={() => { setViewMode('requests'); if (window.innerWidth < 768) setShowChannelList(false) }}>
437
- <UserPlus size={16} className="shrink-0" />
438
- <div className="flex-1 min-w-0">
439
- <div className="text-sm font-medium">请求</div>
440
- <div className="text-xs text-muted-foreground">好友/群邀请等</div>
730
+ </div>
731
+
732
+ <div className="agent-playground-new-session">
733
+ {showNewSession ? (
734
+ <div className="agent-playground-new-form">
735
+ <input value={newSessionName} onChange={(event) => setNewSessionName(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') addSession() }} placeholder="会话名称" autoFocus />
736
+ <div className="agent-playground-scope-picker" role="group" aria-label="消息作用域">
737
+ {(['private', 'group', 'channel'] as SandboxScope[]).map((scope) => (
738
+ <button type="button" key={scope} aria-pressed={newSessionScope === scope} className={cn(newSessionScope === scope && 'active')} onClick={() => setNewSessionScope(scope)}>
739
+ {scope === 'private' ? '单用户' : scope === 'group' ? '群组' : '频道'}
740
+ </button>
741
+ ))}
441
742
  </div>
442
- </div>
443
- <div className={cn("menu-item", viewMode === 'notices' && "active")} onClick={() => { setViewMode('notices'); if (window.innerWidth < 768) setShowChannelList(false) }}>
444
- <Bell size={16} className="shrink-0" />
445
- <div className="flex-1 min-w-0">
446
- <div className="text-sm font-medium">通知</div>
447
- <div className="text-xs text-muted-foreground">群管/撤回等</div>
743
+ <div className="agent-playground-new-actions">
744
+ <button type="button" onClick={() => setShowNewSession(false)}>取消</button>
745
+ <button type="button" className="primary" onClick={addSession}>创建</button>
448
746
  </div>
449
747
  </div>
450
- </div>
748
+ ) : (
749
+ <button type="button" className="agent-playground-add" onClick={() => setShowNewSession(true)}><Plus size={15} />新建测试会话</button>
750
+ )}
451
751
  </div>
452
-
453
- <div className="p-2 border-t">
454
- <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>
752
+ <div className="agent-playground-endpoint">
753
+ <span>{persistenceStatus === 'saved' ? '会话已持久化' : '会话保存失败'}</span>
754
+ <strong>{endpointId}</strong>
455
755
  </div>
456
- </div>
756
+ </nav>
457
757
 
458
- {showChannelList && <div className="channel-overlay md:hidden" onClick={() => setShowChannelList(false)} />}
758
+ {showChannelList && <div className="channel-overlay" onClick={() => setShowChannelList(false)} />}
459
759
 
460
- {/* Main area: Chat / Requests / Notices */}
461
- <div className="chat-area">
462
- {viewMode === 'requests' && (
463
- <div className="rounded-lg border bg-card flex-1 flex flex-col min-h-0 overflow-hidden">
464
- <div className="p-3 border-b flex-shrink-0">
465
- <h2 className="text-lg font-bold flex items-center gap-2">
466
- <UserPlus size={20} /> 请求
467
- </h2>
468
- </div>
469
- <div className="flex-1 overflow-y-auto p-4 flex flex-col items-center justify-center gap-3 text-muted-foreground text-center">
470
- <UserPlus size={48} className="opacity-30" />
471
- <span>沙盒为模拟环境,暂无请求数据</span>
472
- <span className="text-sm">实际好友/群邀请等请求请到侧边栏 <strong>机器人</strong> 页面进入对应机器人管理查看</span>
760
+ <main className="chat-area agent-playground-main">
761
+ <header className="agent-playground-runbar">
762
+ <div className="agent-playground-run-identity">
763
+ <span className="agent-playground-run-icon">{getChannelIcon(activeChannel.type)}</span>
764
+ <div>
765
+ <div className="agent-playground-run-title"><h1>{activeChannel.name}</h1><span>{scopeLabel}</span></div>
766
+ <code>{sessionKey}</code>
473
767
  </div>
474
768
  </div>
475
- )}
769
+ <div className="agent-playground-run-actions">
770
+ <span className={cn("agent-playground-run-state", currentTask?.status === 'running' && "is-running", currentTask?.status === 'failed' && 'has-problem')}>
771
+ {currentTask?.status === 'running' ? <Activity size={14} /> : <Gauge size={14} />}
772
+ {currentTask ? taskStatusLabel(currentTask.status) : `${channelMessages.length} 条消息`}
773
+ </span>
774
+ {currentTask?.status === 'running' ? (
775
+ <button type="button" className="agent-playground-stop" onClick={() => void stopActiveTask()} disabled={stoppingTask} aria-label="停止当前任务"><Square size={13} />{stoppingTask ? '停止中' : '停止'}</button>
776
+ ) : lastUserMessage ? (
777
+ <button type="button" onClick={retryLastTask} aria-label="重新运行上一个任务"><RotateCcw size={14} />重试</button>
778
+ ) : null}
779
+ <a href={agentStudioPath(sessionKey)}><ExternalLink size={14} />Agent Studio</a>
780
+ <button type="button" className={cn(showRunSettings && 'active')} aria-expanded={showRunSettings} onClick={() => setShowRunSettings((visible) => !visible)} aria-label="配置运行环境"><SlidersHorizontal size={16} /></button>
781
+ <button type="button" aria-expanded={showInspector} onClick={() => setShowInspector(!showInspector)} aria-label="切换运行检查器"><PanelRight size={16} /></button>
782
+ <button type="button" onClick={() => setConfirmClear(true)} aria-label="清空当前会话"><Trash2 size={15} /></button>
783
+ </div>
784
+ </header>
476
785
 
477
- {viewMode === 'notices' && (
478
- <div className="rounded-lg border bg-card flex-1 flex flex-col min-h-0 overflow-hidden">
479
- <div className="p-3 border-b flex-shrink-0">
480
- <h2 className="text-lg font-bold flex items-center gap-2">
481
- <Bell size={20} /> 通知
482
- </h2>
483
- </div>
484
- <div className="flex-1 overflow-y-auto p-4 flex flex-col items-center justify-center gap-3 text-muted-foreground text-center">
485
- <Bell size={48} className="opacity-30" />
486
- <span>沙盒为模拟环境,暂无通知数据</span>
487
- <span className="text-sm">实际群管、撤回等通知请到侧边栏 <strong>机器人</strong> 页面进入对应机器人管理查看</span>
488
- </div>
786
+ {confirmClear && (
787
+ <div className="agent-playground-confirm" role="alert">
788
+ <div><strong>清空当前测试记录?</strong><span>只移除“{activeChannel.name}”在浏览器中的消息,不影响 Agent 会话存储。</span></div>
789
+ <button type="button" onClick={() => setConfirmClear(false)}>取消</button>
790
+ <button type="button" className="danger" onClick={clearMessages}>确认清空</button>
489
791
  </div>
490
792
  )}
491
793
 
492
- {viewMode === 'chat' && (
493
- <>
494
- {/* Top bar */}
495
- <div className="rounded-lg border bg-card p-3 flex-shrink-0">
496
- <div className="flex justify-between items-center flex-wrap gap-2">
497
- <div className="flex items-center gap-3">
498
- <div className="p-2 rounded-lg bg-secondary">{getChannelIcon(activeChannel.type)}</div>
499
- <div>
500
- <h2 className="text-lg font-bold">{activeChannel.name}</h2>
501
- <div className="flex items-center gap-2 text-xs text-muted-foreground">
502
- <span>{activeChannel.id}</span>
503
- <span className="inline-flex items-center px-1.5 py-0.5 rounded border text-[10px]">{channelMessages.length}</span>
504
- <span>条消息</span>
505
- </div>
506
- </div>
507
- <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-secondary text-secondary-foreground">
508
- {activeChannel.type === 'private' ? '私聊' : activeChannel.type === 'group' ? '群聊' : '频道'}
509
- </span>
510
- </div>
511
- <div className="flex items-center gap-2">
512
- <input value={endpointId} onChange={(e) => setBotName(e.target.value)} placeholder="机器人名称"
513
- className="h-8 w-28 rounded-md border bg-transparent px-2 text-sm" />
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
- <Trash2 size={14} /> 清空
516
- </button>
517
- </div>
794
+ {transportNotice && (
795
+ <div className="agent-playground-confirm" role="status">
796
+ <div><strong>运行暂不可用</strong><span>{transportNotice}</span></div>
797
+ <button type="button" onClick={() => setTransportNotice(null)}>知道了</button>
518
798
  </div>
519
- </div>
799
+ )}
800
+
801
+ {showRunSettings && (
802
+ <section className="agent-playground-run-settings" aria-label="运行配置">
803
+ <label className="agent-playground-directory-field">
804
+ <span><FolderOpen size={14} />工作目录</span>
805
+ <input
806
+ value={activeChannel.runConfig.workingDirectory}
807
+ onChange={(event) => updateRunConfig({ workingDirectory: event.target.value })}
808
+ placeholder="使用 Host 项目目录"
809
+ spellCheck={false}
810
+ />
811
+ </label>
812
+ <label>
813
+ <span><ShieldCheck size={14} />安全策略</span>
814
+ <select value={activeChannel.runConfig.safetyMode} onChange={(event) => {
815
+ const safetyMode = event.target.value as SandboxAgentRunConfig['safetyMode']
816
+ updateRunConfig({ safetyMode, ...(safetyMode === 'danger-full-access' ? { networkAccess: true } : {}) })
817
+ }}>
818
+ <option value="read-only">只读</option>
819
+ <option value="workspace-write">工作区写入</option>
820
+ <option value="danger-full-access">完全访问</option>
821
+ </select>
822
+ </label>
823
+ <label>
824
+ <span>审批策略</span>
825
+ <select
826
+ value={activeChannel.runConfig.safetyMode === 'read-only' ? 'deny' : activeChannel.runConfig.safetyMode === 'danger-full-access' ? 'allow' : activeChannel.runConfig.approvalMode}
827
+ disabled={activeChannel.runConfig.safetyMode !== 'workspace-write'}
828
+ onChange={(event) => updateRunConfig({ approvalMode: event.target.value as SandboxAgentRunConfig['approvalMode'] })}
829
+ >
830
+ <option value="ask">按需确认</option>
831
+ <option value="deny">自动拒绝</option>
832
+ <option value="allow">自动允许</option>
833
+ </select>
834
+ </label>
835
+ <label className="agent-playground-network-toggle">
836
+ <span><Network size={14} />网络访问</span>
837
+ <input
838
+ type="checkbox"
839
+ checked={activeChannel.runConfig.safetyMode === 'danger-full-access' || activeChannel.runConfig.networkAccess}
840
+ disabled={activeChannel.runConfig.safetyMode === 'danger-full-access'}
841
+ onChange={(event) => updateRunConfig({ networkAccess: event.target.checked })}
842
+ />
843
+ <i aria-hidden="true" />
844
+ </label>
845
+ {activeChannel.runConfig.safetyMode === 'danger-full-access' && (
846
+ <p><CircleAlert size={14} />完全访问允许 Agent 操作工作目录之外的文件并访问网络,请仅用于可信任务。</p>
847
+ )}
848
+ {activeChannel.runConfig.safetyMode !== 'danger-full-access' && shellIsolation?.available === false && (
849
+ <p><CircleAlert size={14} />安全 Shell 需要可用的 Docker daemon;当前仅文件工具可运行。{shellIsolation.message}</p>
850
+ )}
851
+ </section>
852
+ )}
520
853
 
521
854
  {/* Messages */}
522
- <div className="rounded-lg border bg-card flex-1 flex flex-col min-h-0">
523
- <div className="flex-1 overflow-y-auto p-4">
855
+ <section className="agent-playground-conversation" aria-label="Agent 对话">
856
+ <div className="agent-playground-message-scroll">
524
857
  {channelMessages.length === 0 ? (
525
- <div className="flex flex-col items-center justify-center h-full gap-3">
526
- <MessageSquare size={64} className="text-muted-foreground/20" />
527
- <span className="text-muted-foreground">暂无消息,开始对话吧!</span>
858
+ <div className="agent-playground-empty">
859
+ <span className="agent-playground-empty-mark"><Sparkles size={24} /></span>
860
+ <div><h2>开始一次可观察的 Agent 运行</h2><p>发送任务后,回复、工具调用、Token 与异常会在同一会话上下文中关联。</p></div>
861
+ <div className="agent-playground-prompts">
862
+ <button type="button" onClick={() => runPrompt('介绍当前 Agent 可以使用的能力,并给出三个具体示例。')}>探索可用能力</button>
863
+ <button type="button" onClick={() => runPrompt('用 Markdown 表格总结当前运行环境,并附上一段 TypeScript 示例代码。')}>测试富文本输出</button>
864
+ <button type="button" onClick={() => runPrompt('分析一个任务从推理、工具调用到最终回复的完整执行路径。')}>观察执行路径</button>
865
+ </div>
528
866
  </div>
529
867
  ) : (
530
- <div className="space-y-2">
531
- {channelMessages.map((msg) => (
532
- <div key={msg.id} className={cn("flex", msg.type === 'sent' ? "justify-end" : "justify-start")}>
533
- <div className={cn("max-w-[70%] p-3 rounded-2xl", msg.type === 'sent' ? "bg-primary text-primary-foreground" : "bg-muted")}>
534
- <div className="flex items-center gap-2 mb-1">
535
- {msg.type === 'received' && <Bot size={14} />}
536
- {msg.type === 'sent' && <User size={14} />}
537
- <span className="text-xs font-medium opacity-90">{msg.senderName}</span>
538
- <span className="text-xs opacity-70">{new Date(msg.timestamp).toLocaleTimeString()}</span>
868
+ <div className="agent-playground-message-list">
869
+ {channelMessages.map((msg) => {
870
+ const isApproval = msg.type === 'received' && msg.content.some((segment) => segment.type === 'keyboard')
871
+ return (
872
+ <React.Fragment key={msg.id}>
873
+ <article className={cn("agent-playground-message", msg.type === 'sent' ? "is-user" : "is-agent", isApproval && "is-approval")}>
874
+ <span className="agent-playground-message-avatar">{msg.type === 'received' ? <Bot size={16} /> : <User size={16} />}</span>
875
+ <div className="agent-playground-message-main">
876
+ <div className="agent-playground-message-meta">
877
+ <strong>{isApproval ? '需要你的确认' : msg.type === 'received' ? endpointId : '你'}</strong>
878
+ {isApproval && <span>approval required</span>}
879
+ <time>{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</time>
539
880
  </div>
540
- <div className="text-sm space-y-1">{renderMessageSegments(msg.content, msg.type === 'sent')}</div>
881
+ <div className="agent-playground-message-content">{renderMessageSegments(msg.content, msg.type === 'sent', msg.id, msg.interactionResolved === true)}</div>
541
882
  </div>
542
- </div>
543
- ))}
883
+ </article>
884
+ {currentTaskMessage?.id === msg.id && inlineRunCard}
885
+ </React.Fragment>
886
+ )
887
+ })}
888
+ {!currentTaskMessage && inlineRunCard}
544
889
  <div ref={messagesEndRef} />
545
890
  </div>
546
891
  )}
547
892
  </div>
548
- </div>
893
+ </section>
549
894
 
550
895
  {/* Input area */}
551
- <div className="rounded-lg border bg-card p-3 flex-shrink-0 space-y-3">
896
+ <section className="agent-playground-composer" aria-label="任务输入">
552
897
  {/* Toolbar */}
553
898
  <div className="flex gap-2 items-center flex-wrap">
554
899
  <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")}
@@ -568,9 +913,13 @@ export default function Sandbox() {
568
913
  <Music size={16} />
569
914
  </button>
570
915
  <div className="flex-1 min-w-[1rem]" />
916
+ <div className="sandbox-composer-tabs" role="tablist" aria-label="消息编辑模式">
917
+ <button type="button" role="tab" aria-selected={composerMode === 'write'} className={cn(composerMode === 'write' && 'active')} onClick={() => setComposerMode('write')}>编写</button>
918
+ <button type="button" role="tab" aria-selected={composerMode === 'preview'} className={cn(composerMode === 'preview' && 'active')} onClick={() => setComposerMode('preview')}>预览</button>
919
+ </div>
571
920
  {inputText && (
572
921
  <button className="h-8 w-8 rounded-md flex items-center justify-center hover:bg-accent transition-colors"
573
- onClick={() => { setInputText(''); setPreviewSegments([]) }}><X size={16} /></button>
922
+ onClick={() => { editorRef.current?.clear(); setInputText(''); setPreviewSegments([]); setComposerMode('write') }} aria-label="清空输入"><X size={16} /></button>
574
923
  )}
575
924
  </div>
576
925
 
@@ -624,11 +973,20 @@ export default function Sandbox() {
624
973
  {/* Editor + send */}
625
974
  <div className="flex gap-2 items-start">
626
975
  <div className="flex-1 relative">
627
- <RichTextEditor
628
- ref={editorRef} placeholder={`向 ${activeChannel.name} 发送消息...`}
629
- onSend={handleSendMessage} onChange={handleEditorChange} onAtTrigger={handleAtTrigger}
630
- minHeight="44px" maxHeight="200px"
631
- />
976
+ <div className={composerMode === 'write' ? 'block' : 'hidden'}>
977
+ <RichTextEditor
978
+ ref={editorRef} placeholder={`向 ${activeChannel.name} 发送消息,支持 Markdown...`}
979
+ onSend={handleSendMessage} onChange={handleEditorChange} onAtTrigger={handleAtTrigger}
980
+ minHeight="44px" maxHeight="200px"
981
+ />
982
+ </div>
983
+ {composerMode === 'preview' && (
984
+ <div className="sandbox-markdown-preview" role="tabpanel">
985
+ {inputText.trim()
986
+ ? <MarkdownContent text={inputText} />
987
+ : <span className="sandbox-markdown-preview-empty">输入 Markdown 后可在这里检查最终效果</span>}
988
+ </div>
989
+ )}
632
990
  {atPopoverPosition && (
633
991
  <div className="absolute z-50 rounded-lg border bg-popover shadow-md min-w-60 max-h-72 overflow-y-auto p-1"
634
992
  style={{ top: `${atPopoverPosition.top}px`, left: `${atPopoverPosition.left}px` }}>
@@ -649,7 +1007,7 @@ export default function Sandbox() {
649
1007
  <button
650
1008
  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"
651
1009
  onClick={() => { const c = editorRef.current?.getContent(); if (c) handleSendMessage(c.text, c.segments) }}
652
- disabled={!hasRenderableSegments(previewSegments)}>
1010
+ disabled={!canExecute || !hasRenderableSegments(previewSegments)}>
653
1011
  <Send size={16} /> 发送
654
1012
  </button>
655
1013
  </div>
@@ -659,14 +1017,170 @@ export default function Sandbox() {
659
1017
  <Info size={12} /> 快捷操作:
660
1018
  <span className="px-1 py-0.5 rounded border text-[10px]">Enter</span> 发送
661
1019
  <span className="px-1 py-0.5 rounded border text-[10px]">Shift+Enter</span> 换行
1020
+ <span className="px-1 py-0.5 rounded border text-[10px]">```ts</span> 代码块
1021
+ <span className="px-1 py-0.5 rounded border text-[10px]">**文本**</span> 加粗
662
1022
  <span className="px-1 py-0.5 rounded border text-[10px]">[@名称]</span> @某人
663
1023
  <span className="px-1 py-0.5 rounded border text-[10px]">[video:URL]</span>
664
1024
  <span className="px-1 py-0.5 rounded border text-[10px]">[audio:URL]</span>
665
1025
  </div>
1026
+ </section>
1027
+ </main>
1028
+
1029
+ <aside className={cn("agent-playground-inspector", showInspector && "show")} aria-label="运行检查器">
1030
+ <div className="agent-playground-inspector-head">
1031
+ <div><span>Run inspector</span><h2>运行检查器</h2></div>
1032
+ <div>
1033
+ <button type="button" onClick={() => void loadTrace()} aria-label="刷新 Agent Trace" disabled={traceLoading}><RefreshCw size={15} className={cn(traceLoading && 'animate-spin')} /></button>
1034
+ <button type="button" className="agent-playground-inspector-close" onClick={() => setShowInspector(false)} aria-label="关闭运行检查器"><X size={15} /></button>
1035
+ </div>
666
1036
  </div>
667
- </>
668
- )}
669
- </div>
670
- </div>
1037
+
1038
+ <div className="agent-playground-inspector-tabs" role="tablist" aria-label="检查器视图">
1039
+ <button type="button" role="tab" aria-selected={inspectorView === 'runs'} className={cn(inspectorView === 'runs' && 'active')} onClick={() => setInspectorView('runs')}><Activity size={13} />任务</button>
1040
+ <button type="button" role="tab" aria-selected={inspectorView === 'artifacts'} className={cn(inspectorView === 'artifacts' && 'active')} onClick={() => setInspectorView('artifacts')}><FileDiff size={13} />变更与产物 <span>{workbenchArtifacts.length}</span></button>
1041
+ </div>
1042
+
1043
+ <div className={cn("agent-playground-inspector-view", inspectorView !== 'runs' && 'hidden')}>
1044
+
1045
+ <section className="agent-playground-inspector-section">
1046
+ <div className="agent-playground-inspector-title"><span>运行概览</span><small>{traceSummary.activeTurns > 0 ? 'live' : 'idle'}</small></div>
1047
+ <div className="agent-playground-metrics">
1048
+ <div><Activity /><strong>{traceSummary.eventCount.toLocaleString()}</strong><span>事件</span></div>
1049
+ <div><Wrench /><strong>{traceSummary.toolCount.toLocaleString()}</strong><span>工具</span></div>
1050
+ <div><Coins /><strong>{traceSummary.tokenCount.toLocaleString()}</strong><span>Token</span></div>
1051
+ <div className={cn(traceSummary.problemCount > 0 && 'has-problem')}><CircleAlert /><strong>{traceSummary.problemCount}</strong><span>异常</span></div>
1052
+ </div>
1053
+ </section>
1054
+
1055
+ <section className="agent-playground-inspector-section">
1056
+ <div className="agent-playground-inspector-title"><span>上下文</span></div>
1057
+ <dl className="agent-playground-context-list">
1058
+ <div><dt>Endpoint</dt><dd>{endpointId}</dd></div>
1059
+ <div><dt>Scope</dt><dd>{activeChannel.type}</dd></div>
1060
+ <div><dt>Scene</dt><dd>{activeChannel.id}</dd></div>
1061
+ <div><dt>Workdir</dt><dd title={activeChannel.runConfig.workingDirectory}>{activeChannel.runConfig.workingDirectory || 'Host project root'}</dd></div>
1062
+ <div><dt>Security</dt><dd>{activeChannel.runConfig.safetyMode}</dd></div>
1063
+ <div><dt>Approval</dt><dd>{activeChannel.runConfig.safetyMode === 'read-only' ? 'deny' : activeChannel.runConfig.safetyMode === 'danger-full-access' ? 'allow' : activeChannel.runConfig.approvalMode}</dd></div>
1064
+ <div><dt>Network</dt><dd>{activeChannel.runConfig.networkAccess ? 'enabled' : 'disabled'}</dd></div>
1065
+ <div><dt>Isolation</dt><dd className={cn(shellIsolation?.available && 'is-online')}>{shellIsolation ? `${shellIsolation.provider}: ${shellIsolation.available ? 'ready' : 'unavailable'}` : 'checking'}</dd></div>
1066
+ <div><dt>Transport</dt><dd className={cn(connected && 'is-online')}>{connected ? 'WebSocket online' : 'reconnecting'}</dd></div>
1067
+ </dl>
1068
+ </section>
1069
+
1070
+ <section className="agent-playground-inspector-section agent-playground-task-section">
1071
+ <div className="agent-playground-inspector-title"><span>任务历史</span><small>{taskRuns.length} runs</small></div>
1072
+ {taskRuns.length > 0 ? (
1073
+ <div className="agent-playground-task-list">
1074
+ {taskRuns.slice(0, 8).map((run) => (
1075
+ <article key={run.id} className={cn(`is-${run.status}`)}>
1076
+ <i />
1077
+ <div><strong>{taskStatusLabel(run.status)}</strong><code>{run.turnId.slice(0, 10)}</code></div>
1078
+ <dl>
1079
+ <div><dt>耗时</dt><dd>{run.durationMs === undefined ? '进行中' : `${run.durationMs.toLocaleString()} ms`}</dd></div>
1080
+ <div><dt>工具</dt><dd>{run.toolCount}</dd></div>
1081
+ <div><dt>Token</dt><dd>{run.tokenCount.toLocaleString()}</dd></div>
1082
+ </dl>
1083
+ </article>
1084
+ ))}
1085
+ </div>
1086
+ ) : <div className="agent-playground-artifact-empty"><Activity size={18} /><span>运行任务后会在这里形成可回溯记录</span></div>}
1087
+ </section>
1088
+
1089
+ <section className="agent-playground-inspector-section agent-playground-trace-section">
1090
+ <div className="agent-playground-inspector-title"><span>最近执行</span><small>2s sync</small></div>
1091
+ {traceLoading && !trace ? (
1092
+ <div className="agent-playground-trace-loading"><i /><i /><i /></div>
1093
+ ) : traceNotice && !trace?.events.length ? (
1094
+ <div className="agent-playground-trace-notice"><CircleAlert size={16} /><span>{traceNotice}</span></div>
1095
+ ) : recentTraceEvents.length ? (
1096
+ <div className="agent-playground-trace-list">
1097
+ {recentTraceEvents.map((event) => {
1098
+ const item = presentTraceEvent(event)
1099
+ return (
1100
+ <div key={event.sequence} className={cn("agent-playground-trace-item", `is-${item.tone}`)}>
1101
+ <span className="agent-playground-trace-dot" />
1102
+ <div><strong>{item.title}</strong>{item.detail ? <small>{item.detail}</small> : null}</div>
1103
+ <time>{new Date(event.recordedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</time>
1104
+ </div>
1105
+ )
1106
+ })}
1107
+ </div>
1108
+ ) : (
1109
+ <div className="agent-playground-trace-empty"><Activity size={18} /><span>发送任务后显示推理与工具轨迹</span></div>
1110
+ )}
1111
+ </section>
1112
+ </div>
1113
+
1114
+ <div className={cn("agent-playground-inspector-view", inspectorView !== 'artifacts' && 'hidden')}>
1115
+ <section className="agent-playground-inspector-section agent-playground-artifact-section">
1116
+ <div className="agent-playground-inspector-title"><span>文件与命令产物</span><small>{workbenchArtifacts.length} items</small></div>
1117
+ {workbenchArtifacts.length > 0 ? (
1118
+ <div className="agent-playground-artifact-list">
1119
+ {workbenchArtifacts.map((artifact) => {
1120
+ const ArtifactIcon = artifact.kind === 'file-change' ? FileDiff : artifact.kind === 'test' ? FlaskConical : Terminal
1121
+ const expanded = expandedArtifact === artifact.id
1122
+ return (
1123
+ <article key={artifact.id} className={cn(`is-${artifact.status}`, expanded && 'is-expanded')}>
1124
+ <button type="button" onClick={() => setExpandedArtifact(expanded ? null : artifact.id)} aria-expanded={expanded}>
1125
+ <span className="agent-playground-artifact-icon"><ArtifactIcon size={14} /></span>
1126
+ <span><strong>{artifact.title}</strong><small>{artifact.path ?? artifact.detail ?? artifact.kind}</small></span>
1127
+ <em>{artifact.status}</em>
1128
+ <ChevronDown size={14} />
1129
+ </button>
1130
+ {expanded && (
1131
+ <div className="agent-playground-artifact-detail">
1132
+ {artifact.path && <code>{artifact.path}</code>}
1133
+ {artifact.diff ? <pre>{artifact.diff}</pre> : <pre>{artifact.detail || '暂无输出'}</pre>}
1134
+ <footer><span>{artifact.durationMs === undefined ? '等待结果' : `${artifact.durationMs.toLocaleString()} ms`}</span><code>{artifact.turnId.slice(0, 12)}</code></footer>
1135
+ </div>
1136
+ )}
1137
+ </article>
1138
+ )
1139
+ })}
1140
+ </div>
1141
+ ) : <div className="agent-playground-artifact-empty"><FileDiff size={18} /><span>Agent 修改文件、执行命令或测试后,产物会集中出现在这里</span></div>}
1142
+ </section>
1143
+ </div>
1144
+
1145
+ <a className="agent-playground-studio-link" href={agentStudioPath(sessionKey)}><span><ExternalLink size={15} />在 Agent Studio 中完整诊断</span><code>{sessionKey}</code></a>
1146
+ </aside>
1147
+ {showInspector && <div className="agent-playground-inspector-overlay" onClick={() => setShowInspector(false)} />}
1148
+ </section>
671
1149
  )
672
1150
  }
1151
+
1152
+ function taskStatusLabel(status: 'running' | 'completed' | 'failed' | 'cancelled'): string {
1153
+ if (status === 'running') return 'Agent 运行中'
1154
+ if (status === 'completed') return '最近任务已完成'
1155
+ if (status === 'failed') return '最近任务失败'
1156
+ return '最近任务已取消'
1157
+ }
1158
+
1159
+ function messageText(segments: readonly MessageSegment[]): string {
1160
+ return segments.map((segment) => {
1161
+ const data = segment.data as Record<string, unknown>
1162
+ if (segment.type === 'text' || segment.type === 'markdown' || segment.type === 'md') {
1163
+ return String(data.text ?? data.content ?? '')
1164
+ }
1165
+ if (segment.type === 'mention' || segment.type === 'at') return `@${String(data.name ?? data.target ?? '')}`
1166
+ return `[${segment.type}]`
1167
+ }).join(' ').trim()
1168
+ }
1169
+
1170
+ function formatDuration(durationMs: number): string {
1171
+ if (durationMs < 1_000) return `${durationMs.toLocaleString()} ms`
1172
+ if (durationMs < 60_000) return `${(durationMs / 1_000).toFixed(durationMs < 10_000 ? 1 : 0)} s`
1173
+ return `${Math.floor(durationMs / 60_000)}m ${Math.round((durationMs % 60_000) / 1_000)}s`
1174
+ }
1175
+
1176
+ function runStepStatusLabel(status: 'running' | 'completed' | 'failed' | 'denied' | 'cancelled'): string {
1177
+ if (status === 'running') return '进行中'
1178
+ if (status === 'completed') return '完成'
1179
+ if (status === 'denied') return '已拒绝'
1180
+ if (status === 'cancelled') return '已取消'
1181
+ return '失败'
1182
+ }
1183
+
1184
+ function safeFileName(value: string): string {
1185
+ return value.trim().replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 48) || 'session'
1186
+ }