@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.
- package/CHANGELOG.md +40 -0
- package/adapters/sandbox.js +2 -1
- package/adapters/sandbox.ts +2 -1
- package/lib/endpoint.d.ts +18 -2
- package/lib/endpoint.js +86 -12
- package/lib/protocol.d.ts +3 -0
- package/lib/protocol.js +18 -2
- package/lib/run-config.d.ts +10 -0
- package/lib/run-config.js +30 -0
- package/package.json +16 -16
- package/pages/RichTextEditor.js +23 -8
- package/pages/RichTextEditor.tsx +20 -8
- package/pages/SandboxChat.js +315 -63
- package/pages/SandboxChat.tsx +693 -179
- package/pages/agentTrace.js +559 -0
- package/pages/agentTrace.test.js +235 -0
- package/pages/agentTrace.test.ts +265 -0
- package/pages/agentTrace.ts +646 -0
- package/pages/index.js +2 -2
- package/pages/index.tsx +2 -2
- package/pages/playgroundState.js +126 -0
- package/pages/playgroundState.test.js +92 -0
- package/pages/playgroundState.test.ts +105 -0
- package/pages/playgroundState.ts +172 -0
- package/src/endpoint.ts +98 -14
- package/src/protocol.ts +25 -2
- package/src/run-config.ts +41 -0
package/pages/SandboxChat.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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 [
|
|
27
|
-
const [
|
|
28
|
-
|
|
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>(
|
|
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('
|
|
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: '
|
|
48
|
-
{ id: '
|
|
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 [
|
|
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;
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 = {
|
|
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 ||
|
|
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({
|
|
105
|
-
|
|
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 = (
|
|
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 <
|
|
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
|
-
|
|
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="
|
|
505
|
+
<div key={index} className="agent-playground-approval-actions">
|
|
330
506
|
{rows.map((row, ri) => (
|
|
331
|
-
<div key={ri}
|
|
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
|
-
|
|
340
|
-
btn.
|
|
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({
|
|
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 = () => {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
<
|
|
402
|
-
<
|
|
403
|
-
<MessageSquare size={
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
<
|
|
412
|
-
<
|
|
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("
|
|
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="
|
|
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 =
|
|
718
|
+
const isActive = activeChannel.id === channel.id && activeChannel.type === channel.type
|
|
424
719
|
return (
|
|
425
|
-
<
|
|
426
|
-
<span className="
|
|
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' ? '
|
|
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
|
-
</
|
|
727
|
+
</button>
|
|
433
728
|
)
|
|
434
729
|
})}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
748
|
+
) : (
|
|
749
|
+
<button type="button" className="agent-playground-add" onClick={() => setShowNewSession(true)}><Plus size={15} />新建测试会话</button>
|
|
750
|
+
)}
|
|
451
751
|
</div>
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
<
|
|
752
|
+
<div className="agent-playground-endpoint">
|
|
753
|
+
<span>{persistenceStatus === 'saved' ? '会话已持久化' : '会话保存失败'}</span>
|
|
754
|
+
<strong>{endpointId}</strong>
|
|
455
755
|
</div>
|
|
456
|
-
</
|
|
756
|
+
</nav>
|
|
457
757
|
|
|
458
|
-
{showChannelList && <div className="channel-overlay
|
|
758
|
+
{showChannelList && <div className="channel-overlay" onClick={() => setShowChannelList(false)} />}
|
|
459
759
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
<div
|
|
465
|
-
<
|
|
466
|
-
|
|
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
|
-
{
|
|
478
|
-
<div className="
|
|
479
|
-
<div
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
{
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
523
|
-
<div className="
|
|
855
|
+
<section className="agent-playground-conversation" aria-label="Agent 对话">
|
|
856
|
+
<div className="agent-playground-message-scroll">
|
|
524
857
|
{channelMessages.length === 0 ? (
|
|
525
|
-
<div className="
|
|
526
|
-
<
|
|
527
|
-
<
|
|
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="
|
|
531
|
-
{channelMessages.map((msg) =>
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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="
|
|
881
|
+
<div className="agent-playground-message-content">{renderMessageSegments(msg.content, msg.type === 'sent', msg.id, msg.interactionResolved === true)}</div>
|
|
541
882
|
</div>
|
|
542
|
-
</
|
|
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
|
-
</
|
|
893
|
+
</section>
|
|
549
894
|
|
|
550
895
|
{/* Input area */}
|
|
551
|
-
<
|
|
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
|
-
<
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
670
|
-
|
|
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
|
+
}
|