@zhin.js/adapter-sandbox 1.0.70 → 1.1.2
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 +1037 -50
- package/README.md +59 -36
- package/adapters/sandbox/index.js +25 -0
- package/adapters/sandbox/index.ts +30 -0
- package/lib/client.d.ts +27 -0
- package/lib/client.js +27 -0
- package/lib/endpoint.d.ts +42 -0
- package/lib/endpoint.js +357 -0
- package/lib/index.d.ts +3 -55
- package/lib/index.js +3 -176
- package/lib/protocol.d.ts +71 -0
- package/lib/protocol.js +295 -0
- package/lib/run-config.d.ts +10 -0
- package/lib/run-config.js +30 -0
- package/package.json +64 -23
- package/pages/index/RichTextEditor.js +366 -0
- package/{client → pages/index}/RichTextEditor.tsx +59 -13
- package/pages/index/SandboxChat.js +615 -0
- package/pages/index/SandboxChat.tsx +1186 -0
- package/pages/index/agentTrace.js +559 -0
- package/pages/index/agentTrace.ts +646 -0
- package/pages/index/index.js +18 -0
- package/pages/index/index.tsx +18 -0
- package/pages/index/playgroundState.js +126 -0
- package/pages/index/playgroundState.ts +172 -0
- package/pages/index/sandboxTransport.js +45 -0
- package/pages/index/sandboxTransport.ts +45 -0
- package/plugin.js +8 -0
- package/schema.json +75 -0
- package/src/client.ts +47 -0
- package/src/endpoint.ts +420 -0
- package/src/index.ts +26 -238
- package/src/protocol.ts +385 -0
- package/src/run-config.ts +41 -0
- package/LICENSE +0 -21
- package/client/Sandbox.tsx +0 -493
- package/client/index.tsx +0 -11
- package/client/tsconfig.json +0 -7
- package/dist/index.js +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
|
@@ -0,0 +1,1186 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
cn,
|
|
4
|
+
CodeBlock,
|
|
5
|
+
MarkdownContent,
|
|
6
|
+
resolveMediaSrc,
|
|
7
|
+
pickMediaRawUrl,
|
|
8
|
+
type MessageSegment,
|
|
9
|
+
} from '@zhin.js/client';
|
|
10
|
+
import { buildSandboxWebSocketUrl } from './sandboxTransport';
|
|
11
|
+
import {
|
|
12
|
+
User, Bot, Users, Trash2, Send, Hash, MessageSquare,
|
|
13
|
+
Wifi, WifiOff, Smile, Image, X, Check, Info, Search,
|
|
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,
|
|
19
|
+
} from 'lucide-react';
|
|
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';
|
|
46
|
+
|
|
47
|
+
type Message = PlaygroundMessage
|
|
48
|
+
type Channel = PlaygroundSession
|
|
49
|
+
interface Face { id: number; emojiId: number; stickerId: number; emojiType: string; name: string; describe: string; png: boolean; apng: boolean; lottie: boolean; }
|
|
50
|
+
|
|
51
|
+
export default function Sandbox() {
|
|
52
|
+
const [initialState] = useState(() => loadPlaygroundState())
|
|
53
|
+
const [messages, setMessages] = useState<Message[]>(() => [...initialState.messages])
|
|
54
|
+
const [channels, setChannels] = useState<Channel[]>(() => [...initialState.sessions])
|
|
55
|
+
const [faceList, setFaceList] = useState<Face[]>([])
|
|
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
|
+
))
|
|
63
|
+
const [inputText, setInputText] = useState('')
|
|
64
|
+
const [endpointId, setBotName] = useState('sandbox-bot')
|
|
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)
|
|
74
|
+
const [showFacePicker, setShowFacePicker] = useState(false)
|
|
75
|
+
/** 输入区:插入图片 / 视频 / 音频 URL */
|
|
76
|
+
const [mediaPanel, setMediaPanel] = useState<null | 'image' | 'video' | 'audio'>(null)
|
|
77
|
+
const [mediaUrl, setMediaUrl] = useState('')
|
|
78
|
+
const [atPopoverPosition, setAtPopoverPosition] = useState<{ top: number; left: number } | null>(null)
|
|
79
|
+
const [atSearchQuery, setAtSearchQuery] = useState('')
|
|
80
|
+
const [faceSearchQuery, setFaceSearchQuery] = useState('')
|
|
81
|
+
const [atSuggestions] = useState([
|
|
82
|
+
{ id: 'actor-owner', name: '当前用户' }, { id: 'actor-reviewer', name: '审阅者' },
|
|
83
|
+
{ id: 'actor-operator', name: '协作者' }, { id: 'actor-bot', name: 'Sandbox Agent' }
|
|
84
|
+
])
|
|
85
|
+
const [previewSegments, setPreviewSegments] = useState<MessageSegment[]>([])
|
|
86
|
+
const [composerMode, setComposerMode] = useState<'write' | 'preview'>('write')
|
|
87
|
+
const [showChannelList, setShowChannelList] = useState(false)
|
|
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)
|
|
97
|
+
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
98
|
+
const wsRef = useRef<WebSocket | null>(null)
|
|
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])
|
|
131
|
+
|
|
132
|
+
const fetchFaceList = async () => {
|
|
133
|
+
try { const res = await fetch('https://face.viki.moe/metadata.json'); setFaceList(await res.json()) }
|
|
134
|
+
catch (err) { console.error('[Sandbox] Failed to fetch face list:', err) }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
useEffect(() => { fetchFaceList() }, [])
|
|
138
|
+
|
|
139
|
+
const handleInboundPayload = (data: {
|
|
140
|
+
type: string; id: string; content?: unknown; endpoint?: string; workingDirectory?: string; canExecute?: boolean;
|
|
141
|
+
shellIsolation?: { available?: boolean; provider?: string; message?: string }; timestamp: number;
|
|
142
|
+
messageId?: string; bot?: string;
|
|
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
|
+
}
|
|
173
|
+
if (data.type === 'edit' && data.messageId) {
|
|
174
|
+
const content: MessageSegment[] = Array.isArray(data.content)
|
|
175
|
+
? data.content as MessageSegment[]
|
|
176
|
+
: parseTextToSegments(String(data.content ?? ''))
|
|
177
|
+
setMessages((prev) => prev.map((m) => (m.id === data.messageId ? { ...m, content } : m)))
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const content: MessageSegment[] = typeof data.content === 'string'
|
|
182
|
+
? parseTextToSegments(data.content)
|
|
183
|
+
: Array.isArray(data.content) ? data.content as MessageSegment[] : parseTextToSegments(String(data.content ?? ''))
|
|
184
|
+
|
|
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}`
|
|
191
|
+
|
|
192
|
+
setChannels((prev) => {
|
|
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
|
+
}
|
|
198
|
+
setActiveChannel(created)
|
|
199
|
+
return [...prev, created]
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
setMessages((prev) => [...prev, {
|
|
203
|
+
id: data.messageId ?? `bot_${data.timestamp}`, type: 'received', channelType,
|
|
204
|
+
channelId: data.id, channelName, senderId: 'endpoint',
|
|
205
|
+
senderName: data.bot || endpointIdRef.current, content, timestamp: data.timestamp,
|
|
206
|
+
}])
|
|
207
|
+
}
|
|
208
|
+
|
|
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
|
+
}
|
|
215
|
+
const segments: MessageSegment[] = [{ type: 'action', data: { id: payload, payload } }]
|
|
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
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
let closed = false
|
|
237
|
+
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
|
238
|
+
let attempt = 0
|
|
239
|
+
/** Ignore close events from a socket we intentionally replaced (login/base change). */
|
|
240
|
+
let replaceInFlight = false
|
|
241
|
+
|
|
242
|
+
const connect = () => {
|
|
243
|
+
if (closed) return
|
|
244
|
+
if (retryTimer) {
|
|
245
|
+
clearTimeout(retryTimer)
|
|
246
|
+
retryTimer = undefined
|
|
247
|
+
}
|
|
248
|
+
const wsUrl = buildSandboxWebSocketUrl()
|
|
249
|
+
// Tear down previous socket before opening a new one so we don't
|
|
250
|
+
// leave two concurrent /sandbox sessions for fixed-name endpoints.
|
|
251
|
+
const previous = wsRef.current
|
|
252
|
+
if (previous) {
|
|
253
|
+
replaceInFlight = true
|
|
254
|
+
previous.onclose = null
|
|
255
|
+
previous.onerror = null
|
|
256
|
+
previous.onmessage = null
|
|
257
|
+
previous.onopen = null
|
|
258
|
+
try { previous.close() } catch { /* already closed */ }
|
|
259
|
+
replaceInFlight = false
|
|
260
|
+
}
|
|
261
|
+
const ws = new WebSocket(wsUrl)
|
|
262
|
+
wsRef.current = ws
|
|
263
|
+
ws.onopen = () => {
|
|
264
|
+
attempt = 0
|
|
265
|
+
setConnected(true)
|
|
266
|
+
}
|
|
267
|
+
ws.onmessage = (event) => {
|
|
268
|
+
try { handleInboundPayload(JSON.parse(String(event.data))) }
|
|
269
|
+
catch (err) { console.error('[Sandbox] Failed to parse message:', err) }
|
|
270
|
+
}
|
|
271
|
+
ws.onclose = () => {
|
|
272
|
+
if (wsRef.current !== ws) return
|
|
273
|
+
setConnected(false)
|
|
274
|
+
wsRef.current = null
|
|
275
|
+
if (closed || replaceInFlight) return
|
|
276
|
+
const delay = Math.min(8_000, 500 * 2 ** attempt)
|
|
277
|
+
attempt += 1
|
|
278
|
+
retryTimer = setTimeout(connect, delay)
|
|
279
|
+
}
|
|
280
|
+
ws.onerror = () => {
|
|
281
|
+
/* close handler reconnects */
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const onAuthOrStorage = (event?: Event) => {
|
|
286
|
+
// storage fires for other tabs; same-tab login sets localStorage then
|
|
287
|
+
// dispatches zhin:auth-required / custom login events.
|
|
288
|
+
if (event && event.type === 'storage') {
|
|
289
|
+
const key = (event as StorageEvent).key
|
|
290
|
+
if (
|
|
291
|
+
key != null
|
|
292
|
+
&& key !== 'zhin_api_token'
|
|
293
|
+
&& key !== 'zhin_api_base'
|
|
294
|
+
&& key !== 'HTTP_TOKEN'
|
|
295
|
+
&& key !== 'zhin_http_token'
|
|
296
|
+
) {
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
attempt = 0
|
|
301
|
+
connect()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
connect()
|
|
305
|
+
if (typeof window !== 'undefined') {
|
|
306
|
+
window.addEventListener('storage', onAuthOrStorage)
|
|
307
|
+
window.addEventListener('zhin:auth-required', onAuthOrStorage)
|
|
308
|
+
// Remote Console may fire this after successful login (token written).
|
|
309
|
+
window.addEventListener('zhin:auth-changed', onAuthOrStorage)
|
|
310
|
+
window.addEventListener('zhin:api-base-changed', onAuthOrStorage)
|
|
311
|
+
}
|
|
312
|
+
return () => {
|
|
313
|
+
closed = true
|
|
314
|
+
if (retryTimer) clearTimeout(retryTimer)
|
|
315
|
+
if (typeof window !== 'undefined') {
|
|
316
|
+
window.removeEventListener('storage', onAuthOrStorage)
|
|
317
|
+
window.removeEventListener('zhin:auth-required', onAuthOrStorage)
|
|
318
|
+
window.removeEventListener('zhin:auth-changed', onAuthOrStorage)
|
|
319
|
+
window.removeEventListener('zhin:api-base-changed', onAuthOrStorage)
|
|
320
|
+
}
|
|
321
|
+
wsRef.current?.close()
|
|
322
|
+
wsRef.current = null
|
|
323
|
+
setConnected(false)
|
|
324
|
+
}
|
|
325
|
+
}, [])
|
|
326
|
+
|
|
327
|
+
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
|
|
328
|
+
useEffect(() => { setPreviewSegments(inputText.trim() ? parseTextToSegments(inputText) : []) }, [inputText])
|
|
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
|
+
|
|
362
|
+
const parseTextToSegments = (text: string): MessageSegment[] => {
|
|
363
|
+
const segments: MessageSegment[] = []
|
|
364
|
+
const regex = /\[@([^\]]+)\]|\[face:(\d+)\]|\[image:([^\]]+)\]|\[video:([^\]]+)\]|\[audio:([^\]]+)\]/g
|
|
365
|
+
let lastIndex = 0
|
|
366
|
+
let match: RegExpExecArray | null
|
|
367
|
+
while ((match = regex.exec(text)) !== null) {
|
|
368
|
+
if (match.index > lastIndex) {
|
|
369
|
+
const t = text.substring(lastIndex, match.index)
|
|
370
|
+
if (t) segments.push({ type: 'text', data: { text: t } })
|
|
371
|
+
}
|
|
372
|
+
if (match[1]) segments.push({ type: 'mention', data: { target: match[1], name: match[1] } })
|
|
373
|
+
else if (match[2]) segments.push({ type: 'face', data: { id: parseInt(match[2], 10) } })
|
|
374
|
+
else if (match[3]) segments.push({ type: 'image', data: { media: { kind: 'url', value: match[3] } } })
|
|
375
|
+
else if (match[4]) segments.push({ type: 'video', data: { media: { kind: 'url', value: match[4] } } })
|
|
376
|
+
else if (match[5]) segments.push({ type: 'audio', data: { media: { kind: 'url', value: match[5] } } })
|
|
377
|
+
lastIndex = regex.lastIndex
|
|
378
|
+
}
|
|
379
|
+
if (lastIndex < text.length) {
|
|
380
|
+
const r = text.substring(lastIndex)
|
|
381
|
+
if (r) segments.push({ type: 'text', data: { text: r } })
|
|
382
|
+
}
|
|
383
|
+
return segments.length > 0 ? segments : [{ type: 'text', data: { text } }]
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const hasRenderableSegments = (segments: MessageSegment[]) => {
|
|
387
|
+
if (segments.length === 0) return false
|
|
388
|
+
return segments.some((s) => {
|
|
389
|
+
if (s.type === 'text') return Boolean(String(s.data?.text ?? '').trim())
|
|
390
|
+
if (s.type === 'keyboard') return true
|
|
391
|
+
return true
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const renderMessageSegments = (
|
|
396
|
+
segments: (MessageSegment | string)[],
|
|
397
|
+
isSent: boolean,
|
|
398
|
+
messageId?: string,
|
|
399
|
+
interactionResolved = false,
|
|
400
|
+
) => {
|
|
401
|
+
const ring = isSent ? 'ring-1 ring-primary-foreground/25' : 'ring-1 ring-border/60'
|
|
402
|
+
return segments.map((segment, index) => {
|
|
403
|
+
if (typeof segment === 'string') {
|
|
404
|
+
return <MarkdownContent key={index} text={segment} className={isSent ? 'zhin-markdown--inverse' : undefined} />
|
|
405
|
+
}
|
|
406
|
+
const d = segment.data as Record<string, unknown>
|
|
407
|
+
switch (segment.type) {
|
|
408
|
+
case 'text':
|
|
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 ?? '')} />
|
|
414
|
+
case 'mention':
|
|
415
|
+
case 'at':
|
|
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>
|
|
417
|
+
case 'face':
|
|
418
|
+
return <img key={index} src={`https://face.viki.moe/apng/${d.id}.png`} alt={String(d.name ?? '')} className="w-6 h-6 inline-block align-middle mx-0.5" title={String(d.name ?? d.id ?? '')} />
|
|
419
|
+
case 'dice':
|
|
420
|
+
return <span key={index} className="inline-flex items-center px-1.5 py-0.5 rounded bg-secondary text-xs mx-0.5">🎲 {d.result != null ? `点数 ${String(d.result)}` : '骰子'}</span>
|
|
421
|
+
case 'rps':
|
|
422
|
+
return <span key={index} className="inline-flex items-center px-1.5 py-0.5 rounded bg-secondary text-xs mx-0.5">✊ {d.result != null ? `结果 ${String(d.result)}` : '猜拳'}</span>
|
|
423
|
+
case 'image': {
|
|
424
|
+
const raw = pickMediaRawUrl(d)
|
|
425
|
+
const src = resolveMediaSrc(raw, 'image')
|
|
426
|
+
if (!src) return <span key={index} className="text-xs opacity-70">[图片]</span>
|
|
427
|
+
return (
|
|
428
|
+
// resolveMediaSrc allowlists non-executable URL schemes and image data MIME types.
|
|
429
|
+
// lgtm[js/xss,js/client-side-unvalidated-url-redirection]
|
|
430
|
+
<a key={index} href={src} target="_blank" rel="noreferrer" className="block my-1">
|
|
431
|
+
{/* lgtm[js/xss,js/client-side-unvalidated-url-redirection] */}
|
|
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' }} />
|
|
433
|
+
</a>
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
case 'video': {
|
|
437
|
+
const raw = pickMediaRawUrl(d)
|
|
438
|
+
const src = resolveMediaSrc(raw, 'video')
|
|
439
|
+
if (!src) return <span key={index} className="text-xs opacity-70">[视频无地址]</span>
|
|
440
|
+
return (
|
|
441
|
+
// resolveMediaSrc allowlists non-executable URL schemes and video data MIME types.
|
|
442
|
+
// lgtm[js/xss,js/client-side-unvalidated-url-redirection]
|
|
443
|
+
<video
|
|
444
|
+
key={index}
|
|
445
|
+
src={src}
|
|
446
|
+
controls
|
|
447
|
+
playsInline
|
|
448
|
+
preload="metadata"
|
|
449
|
+
className={cn('max-w-[min(360px,92vw)] max-h-72 rounded-lg my-1 bg-black/10', ring)}
|
|
450
|
+
/>
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
case 'audio':
|
|
454
|
+
case 'record': {
|
|
455
|
+
const raw = pickMediaRawUrl(d)
|
|
456
|
+
const src = resolveMediaSrc(raw, 'audio')
|
|
457
|
+
if (!src) return <span key={index} className="text-xs opacity-70">[音频无地址]</span>
|
|
458
|
+
return (
|
|
459
|
+
// resolveMediaSrc allowlists non-executable URL schemes and audio data MIME types.
|
|
460
|
+
// lgtm[js/xss,js/client-side-unvalidated-url-redirection]
|
|
461
|
+
<audio
|
|
462
|
+
key={index}
|
|
463
|
+
src={src}
|
|
464
|
+
controls
|
|
465
|
+
preload="metadata"
|
|
466
|
+
className={cn('w-full max-w-sm my-2 h-10', isSent && 'opacity-95')}
|
|
467
|
+
/>
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
case 'reply':
|
|
471
|
+
return (
|
|
472
|
+
<div key={index} className="mb-1 rounded-md border border-dashed px-2 py-1 text-xs opacity-90">
|
|
473
|
+
↩ 引用消息 #{String(d.message_id ?? d.id ?? '')}
|
|
474
|
+
</div>
|
|
475
|
+
)
|
|
476
|
+
case 'forward': {
|
|
477
|
+
const messages = d.messages as Array<Array<{ type?: string; data?: Record<string, unknown> }>> | undefined
|
|
478
|
+
const title = String(d.title ?? '聊天记录')
|
|
479
|
+
return (
|
|
480
|
+
<div key={index} className="my-1 rounded-md border bg-background/40 px-2 py-2 text-xs space-y-1">
|
|
481
|
+
<div className="font-medium">📨 {title}</div>
|
|
482
|
+
{Array.isArray(messages) && messages.length > 0 ? (
|
|
483
|
+
<div className="space-y-1 pl-2 border-l-2 border-muted">
|
|
484
|
+
{messages.slice(0, 3).map((batch, bi) => (
|
|
485
|
+
<div key={bi} className="opacity-90">
|
|
486
|
+
{batch.map((s, si) => (
|
|
487
|
+
<span key={si}>
|
|
488
|
+
{s.type === 'text' ? String(s.data?.text ?? '') : `[${s.type ?? 'seg'}]`}
|
|
489
|
+
</span>
|
|
490
|
+
))}
|
|
491
|
+
</div>
|
|
492
|
+
))}
|
|
493
|
+
{messages.length > 3 && <div className="opacity-60">…共 {messages.length} 条</div>}
|
|
494
|
+
</div>
|
|
495
|
+
) : (
|
|
496
|
+
<div className="opacity-70">[合并转发]</div>
|
|
497
|
+
)}
|
|
498
|
+
</div>
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
case 'keyboard': {
|
|
502
|
+
const rows = (d.rows as Array<Array<{ label: string; payload: string; disabled?: boolean; style?: string }>>) ?? []
|
|
503
|
+
const resolved = Boolean(messageId && interactionResolved)
|
|
504
|
+
return (
|
|
505
|
+
<div key={index} className="agent-playground-approval-actions">
|
|
506
|
+
{rows.map((row, ri) => (
|
|
507
|
+
<div key={ri}>
|
|
508
|
+
{row.map((btn) => (
|
|
509
|
+
<button
|
|
510
|
+
key={btn.payload}
|
|
511
|
+
type="button"
|
|
512
|
+
disabled={btn.disabled || isSent || resolved}
|
|
513
|
+
onClick={() => sendInteractiveAction(btn.payload, messageId)}
|
|
514
|
+
className={cn(
|
|
515
|
+
(btn.style === 'primary' || /^允许/u.test(btn.label)) && 'is-primary',
|
|
516
|
+
(btn.style === 'danger' || /拒绝|取消/u.test(btn.label)) && 'is-danger',
|
|
517
|
+
)}
|
|
518
|
+
>
|
|
519
|
+
{btn.label}
|
|
520
|
+
</button>
|
|
521
|
+
))}
|
|
522
|
+
</div>
|
|
523
|
+
))}
|
|
524
|
+
{resolved && <small><Check size={12} />已提交本次选择</small>}
|
|
525
|
+
</div>
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
default:
|
|
529
|
+
return <span key={index} className="text-xs opacity-70">[{segment.type}]</span>
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const handleSendMessage = (text: string, segments: MessageSegment[]) => {
|
|
535
|
+
if (!canExecute || !hasRenderableSegments(segments)) return
|
|
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() }
|
|
537
|
+
setMessages((prev) => [...prev, newMessage]); setInputText(''); setPreviewSegments([]); setComposerMode('write')
|
|
538
|
+
editorRef.current?.clear()
|
|
539
|
+
// Stamp type+id so Host sandbox endpoint preserves channel context for outbound replies.
|
|
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
|
+
})
|
|
548
|
+
wsRef.current?.send(payload)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
|
|
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)
|
|
575
|
+
}
|
|
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} /> } }
|
|
577
|
+
const insertFace = (faceId: number) => { editorRef.current?.insertFace(faceId); setShowFacePicker(false) }
|
|
578
|
+
const commitMediaUrl = () => {
|
|
579
|
+
const u = mediaUrl.trim()
|
|
580
|
+
if (!u || !mediaPanel) return
|
|
581
|
+
if (mediaPanel === 'image') editorRef.current?.insertImage(u)
|
|
582
|
+
else if (mediaPanel === 'video') editorRef.current?.insertVideo(u)
|
|
583
|
+
else editorRef.current?.insertAudio(u)
|
|
584
|
+
setMediaUrl('')
|
|
585
|
+
setMediaPanel(null)
|
|
586
|
+
}
|
|
587
|
+
const selectAtUser = (user: { id: string; name: string }) => { editorRef.current?.replaceAtTrigger(user.name, user.id); setAtPopoverPosition(null); setAtSearchQuery('') }
|
|
588
|
+
const handleAtTrigger = (show: boolean, searchQuery: string, position?: { top: number; left: number }) => {
|
|
589
|
+
if (activeChannel.type === 'private') { setAtPopoverPosition(null); setAtSearchQuery(''); return }
|
|
590
|
+
if (show && position) { setAtPopoverPosition(position); setAtSearchQuery(searchQuery) } else { setAtPopoverPosition(null); setAtSearchQuery('') }
|
|
591
|
+
}
|
|
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) })
|
|
593
|
+
const handleEditorChange = (text: string, segments: MessageSegment[]) => { setInputText(text); setPreviewSegments(segments) }
|
|
594
|
+
const filteredFaces = faceList.filter(face => face.name.toLowerCase().includes(faceSearchQuery.toLowerCase()) || face.describe.toLowerCase().includes(faceSearchQuery.toLowerCase()))
|
|
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
|
|
693
|
+
|
|
694
|
+
return (
|
|
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">
|
|
704
|
+
<div className="flex justify-between items-center">
|
|
705
|
+
<div className="flex items-center gap-2">
|
|
706
|
+
<span className="agent-playground-mark"><Sparkles size={16} /></span>
|
|
707
|
+
<div><h2>Agent 试验台</h2><small>Sandbox playground</small></div>
|
|
708
|
+
</div>
|
|
709
|
+
<span className={cn("agent-playground-connection", connected && "is-online")} title={connected ? 'Sandbox WebSocket 已连接' : '正在重连 Sandbox WebSocket'}>
|
|
710
|
+
{connected ? <Wifi size={12} /> : <WifiOff size={12} />}
|
|
711
|
+
</span>
|
|
712
|
+
</div>
|
|
713
|
+
</div>
|
|
714
|
+
|
|
715
|
+
<div className="agent-playground-section-label"><span>测试会话</span><span>{channels.length}</span></div>
|
|
716
|
+
<div className="agent-playground-session-list">
|
|
717
|
+
{channels.map((channel) => {
|
|
718
|
+
const isActive = activeChannel.id === channel.id && activeChannel.type === channel.type
|
|
719
|
+
return (
|
|
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>
|
|
722
|
+
<div className="flex-1 min-w-0">
|
|
723
|
+
<div className="text-sm font-medium truncate">{channel.name}</div>
|
|
724
|
+
<div className="text-xs text-muted-foreground">{channel.type === 'private' ? '单用户作用域' : channel.type === 'group' ? '群组作用域' : '频道作用域'}</div>
|
|
725
|
+
</div>
|
|
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>}
|
|
727
|
+
</button>
|
|
728
|
+
)
|
|
729
|
+
})}
|
|
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
|
+
))}
|
|
742
|
+
</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>
|
|
746
|
+
</div>
|
|
747
|
+
</div>
|
|
748
|
+
) : (
|
|
749
|
+
<button type="button" className="agent-playground-add" onClick={() => setShowNewSession(true)}><Plus size={15} />新建测试会话</button>
|
|
750
|
+
)}
|
|
751
|
+
</div>
|
|
752
|
+
<div className="agent-playground-endpoint">
|
|
753
|
+
<span>{persistenceStatus === 'saved' ? '会话已持久化' : '会话保存失败'}</span>
|
|
754
|
+
<strong>{endpointId}</strong>
|
|
755
|
+
</div>
|
|
756
|
+
</nav>
|
|
757
|
+
|
|
758
|
+
{showChannelList && <div className="channel-overlay" onClick={() => setShowChannelList(false)} />}
|
|
759
|
+
|
|
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>
|
|
767
|
+
</div>
|
|
768
|
+
</div>
|
|
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>
|
|
785
|
+
|
|
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>
|
|
791
|
+
</div>
|
|
792
|
+
)}
|
|
793
|
+
|
|
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>
|
|
798
|
+
</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' ? 'auto' : activeChannel.runConfig.safetyMode === 'danger-full-access' ? 'bypass' : 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="auto">审核 Agent 自动判断</option>
|
|
832
|
+
<option value="bypass">绕过审批</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
|
+
)}
|
|
853
|
+
|
|
854
|
+
{/* Messages */}
|
|
855
|
+
<section className="agent-playground-conversation" aria-label="Agent 对话">
|
|
856
|
+
<div className="agent-playground-message-scroll">
|
|
857
|
+
{channelMessages.length === 0 ? (
|
|
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>
|
|
866
|
+
</div>
|
|
867
|
+
) : (
|
|
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>
|
|
880
|
+
</div>
|
|
881
|
+
<div className="agent-playground-message-content">{renderMessageSegments(msg.content, msg.type === 'sent', msg.id, msg.interactionResolved === true)}</div>
|
|
882
|
+
</div>
|
|
883
|
+
</article>
|
|
884
|
+
{currentTaskMessage?.id === msg.id && inlineRunCard}
|
|
885
|
+
</React.Fragment>
|
|
886
|
+
)
|
|
887
|
+
})}
|
|
888
|
+
{!currentTaskMessage && inlineRunCard}
|
|
889
|
+
<div ref={messagesEndRef} />
|
|
890
|
+
</div>
|
|
891
|
+
)}
|
|
892
|
+
</div>
|
|
893
|
+
</section>
|
|
894
|
+
|
|
895
|
+
{/* Input area */}
|
|
896
|
+
<section className="agent-playground-composer" aria-label="任务输入">
|
|
897
|
+
{/* Toolbar */}
|
|
898
|
+
<div className="flex gap-2 items-center flex-wrap">
|
|
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")}
|
|
900
|
+
onClick={() => { setShowFacePicker(!showFacePicker); setMediaPanel(null) }} title="插入表情">
|
|
901
|
+
<Smile size={16} />
|
|
902
|
+
</button>
|
|
903
|
+
<button type="button" className={cn("h-8 w-8 rounded-md flex items-center justify-center border transition-colors", mediaPanel === 'image' ? "bg-primary text-primary-foreground" : "hover:bg-accent")}
|
|
904
|
+
onClick={() => { setMediaPanel((p) => (p === 'image' ? null : 'image')); setShowFacePicker(false) }} title="插入图片 URL">
|
|
905
|
+
<Image size={16} />
|
|
906
|
+
</button>
|
|
907
|
+
<button type="button" className={cn("h-8 w-8 rounded-md flex items-center justify-center border transition-colors", mediaPanel === 'video' ? "bg-primary text-primary-foreground" : "hover:bg-accent")}
|
|
908
|
+
onClick={() => { setMediaPanel((p) => (p === 'video' ? null : 'video')); setShowFacePicker(false) }} title="插入视频 URL">
|
|
909
|
+
<Video size={16} />
|
|
910
|
+
</button>
|
|
911
|
+
<button type="button" className={cn("h-8 w-8 rounded-md flex items-center justify-center border transition-colors", mediaPanel === 'audio' ? "bg-primary text-primary-foreground" : "hover:bg-accent")}
|
|
912
|
+
onClick={() => { setMediaPanel((p) => (p === 'audio' ? null : 'audio')); setShowFacePicker(false) }} title="插入音频 URL">
|
|
913
|
+
<Music size={16} />
|
|
914
|
+
</button>
|
|
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>
|
|
920
|
+
{inputText && (
|
|
921
|
+
<button className="h-8 w-8 rounded-md flex items-center justify-center hover:bg-accent transition-colors"
|
|
922
|
+
onClick={() => { editorRef.current?.clear(); setInputText(''); setPreviewSegments([]); setComposerMode('write') }} aria-label="清空输入"><X size={16} /></button>
|
|
923
|
+
)}
|
|
924
|
+
</div>
|
|
925
|
+
|
|
926
|
+
{/* Face picker */}
|
|
927
|
+
{showFacePicker && (
|
|
928
|
+
<div className="p-3 rounded-md border bg-muted/30 max-h-64 overflow-y-auto space-y-2">
|
|
929
|
+
<input value={faceSearchQuery} onChange={(e) => setFaceSearchQuery(e.target.value)}
|
|
930
|
+
placeholder="搜索表情..." className="w-full h-8 rounded-md border bg-transparent px-2 text-sm" />
|
|
931
|
+
<div className="grid grid-cols-8 gap-1">
|
|
932
|
+
{filteredFaces.slice(0, 80).map((face) => (
|
|
933
|
+
<button key={face.id} onClick={() => insertFace(face.id)} title={face.name}
|
|
934
|
+
className="w-10 h-10 rounded-md border flex items-center justify-center hover:bg-accent transition-colors">
|
|
935
|
+
<img src={`https://face.viki.moe/apng/${face.id}.png`} alt={face.name} className="w-8 h-8" />
|
|
936
|
+
</button>
|
|
937
|
+
))}
|
|
938
|
+
</div>
|
|
939
|
+
{filteredFaces.length === 0 && (
|
|
940
|
+
<div className="flex flex-col items-center gap-2 py-4">
|
|
941
|
+
<Search size={32} className="text-muted-foreground/30" />
|
|
942
|
+
<span className="text-sm text-muted-foreground">未找到匹配的表情</span>
|
|
943
|
+
</div>
|
|
944
|
+
)}
|
|
945
|
+
</div>
|
|
946
|
+
)}
|
|
947
|
+
|
|
948
|
+
{mediaPanel && (
|
|
949
|
+
<div className="p-3 rounded-md border bg-muted/30 space-y-2">
|
|
950
|
+
<p className="text-xs text-muted-foreground">
|
|
951
|
+
{mediaPanel === 'image' && '支持 http(s) 图片链接或 data URL'}
|
|
952
|
+
{mediaPanel === 'video' && '支持浏览器可解码的视频直链(如 .mp4、.webm)'}
|
|
953
|
+
{mediaPanel === 'audio' && '支持 .mp3、.ogg、.wav 等音频直链'}
|
|
954
|
+
</p>
|
|
955
|
+
<input
|
|
956
|
+
value={mediaUrl}
|
|
957
|
+
onChange={(e) => setMediaUrl(e.target.value)}
|
|
958
|
+
placeholder={mediaPanel === 'image' ? '图片 URL…' : mediaPanel === 'video' ? '视频 URL…' : '音频 URL…'}
|
|
959
|
+
className="w-full h-8 rounded-md border border-input bg-background px-2 text-sm"
|
|
960
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitMediaUrl() } }}
|
|
961
|
+
/>
|
|
962
|
+
<button
|
|
963
|
+
type="button"
|
|
964
|
+
className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm disabled:opacity-50"
|
|
965
|
+
onClick={commitMediaUrl}
|
|
966
|
+
disabled={!mediaUrl.trim()}
|
|
967
|
+
>
|
|
968
|
+
<Check size={14} /> 插入到输入框
|
|
969
|
+
</button>
|
|
970
|
+
</div>
|
|
971
|
+
)}
|
|
972
|
+
|
|
973
|
+
{/* Editor + send */}
|
|
974
|
+
<div className="flex gap-2 items-start">
|
|
975
|
+
<div className="flex-1 relative">
|
|
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
|
+
)}
|
|
990
|
+
{atPopoverPosition && (
|
|
991
|
+
<div className="absolute z-50 rounded-lg border bg-popover shadow-md min-w-60 max-h-72 overflow-y-auto p-1"
|
|
992
|
+
style={{ top: `${atPopoverPosition.top}px`, left: `${atPopoverPosition.left}px` }}>
|
|
993
|
+
{filteredAtSuggestions.length > 0 ? filteredAtSuggestions.map((user) => (
|
|
994
|
+
<div key={user.id} className="flex items-center gap-2 p-2 rounded-md cursor-pointer hover:bg-accent transition-colors" onClick={() => selectAtUser(user)}>
|
|
995
|
+
<User size={16} className="text-muted-foreground" />
|
|
996
|
+
<div className="flex-1"><div className="text-sm font-medium">{user.name}</div><div className="text-xs text-muted-foreground">ID: {user.id}</div></div>
|
|
997
|
+
</div>
|
|
998
|
+
)) : (
|
|
999
|
+
<div className="flex flex-col items-center gap-2 p-4">
|
|
1000
|
+
<Search size={20} className="text-muted-foreground/50" />
|
|
1001
|
+
<span className="text-xs text-muted-foreground">未找到匹配的用户</span>
|
|
1002
|
+
</div>
|
|
1003
|
+
)}
|
|
1004
|
+
</div>
|
|
1005
|
+
)}
|
|
1006
|
+
</div>
|
|
1007
|
+
<button
|
|
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"
|
|
1009
|
+
onClick={() => { const c = editorRef.current?.getContent(); if (c) handleSendMessage(c.text, c.segments) }}
|
|
1010
|
+
disabled={!canExecute || !hasRenderableSegments(previewSegments)}>
|
|
1011
|
+
<Send size={16} /> 发送
|
|
1012
|
+
</button>
|
|
1013
|
+
</div>
|
|
1014
|
+
|
|
1015
|
+
{/* Hints */}
|
|
1016
|
+
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
|
1017
|
+
<Info size={12} /> 快捷操作:
|
|
1018
|
+
<span className="px-1 py-0.5 rounded border text-[10px]">Enter</span> 发送
|
|
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> 加粗
|
|
1022
|
+
<span className="px-1 py-0.5 rounded border text-[10px]">[@名称]</span> @某人
|
|
1023
|
+
<span className="px-1 py-0.5 rounded border text-[10px]">[video:URL]</span>
|
|
1024
|
+
<span className="px-1 py-0.5 rounded border text-[10px]">[audio:URL]</span>
|
|
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>
|
|
1036
|
+
</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' ? 'auto' : activeChannel.runConfig.safetyMode === 'danger-full-access' ? 'bypass' : 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>
|
|
1149
|
+
)
|
|
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
|
+
}
|