feat(web): render the agent transcript in arrival order
Think, answer and persisted rows now interleave as one live ReAct block: tokens append to the segment that is actually open, persisted messages absorb into the block by run id and replace the streaming text they supersede, and a run only ends when the runtime says so (run_started/first token to run_finished), so the thinking row stops being tied to a lagging poll. Sending a message echoes it immediately and the echo is paired away when the real row lands. HTML deliverables render in a sandboxed iframe fed by srcdoc (the artifact URL answers as an attachment, which framed navigation refuses), gain their own file kind, and the thinking card scrolls inside a fixed height while following the tail until the reader scrolls up.
ZhenYi committed
Modified web/src/pages/workspace/ui/agent/agent-chat.tsx web/src/pages/workspace/ui/agent/agent-chat.tsx +175 −40 View web/src/pages/workspace/ui/agent/agent-chat.tsx
@@ -44,6 +44,29 @@
4444
4545const ACTIVE_RUN_STATES = new Set(['pending', 'queued', 'running', 'waiting'])
4646
47/**
48 * 当前 run 的「活块」分段:思考段、正文段与已落库消息按**到达顺序**交错。
49 * 渲染因此严格 ReAct——Thought 永远先于它引发的 Action/正文,流式期间同样
50 * 成立;run 结束后整块留作记录,下一轮 run_started 重建。
51 */
52type LivePart =
53 | { kind: 'think'; text: string }
54 | { kind: 'answer'; text: string }
55 | { kind: 'message'; message: AgentMessageDto }
56
57type LiveChunk =
58 | { kind: 'messages'; items: AgentMessageDto[] }
59 | { kind: 'think'; text: string; live: boolean }
60 | { kind: 'answer'; text: string; live: boolean }
61
62function appendLiveChunk(parts: LivePart[], kind: 'think' | 'answer', chunk: string): LivePart[] {
63 const last = parts[parts.length - 1]
64 if (last && last.kind === kind) {
65 return [...parts.slice(0, -1), { kind, text: last.text + chunk }]
66 }
67 return [...parts, { kind, text: chunk }]
68}
69
4770/** One operator answer: predefined selections, free text, or both. */
4871type QuestionAnswer = { item_id: string; selected: string[]; text?: string }
4972
@@ -132,14 +155,19 @@
132155 }
133156 })
134157 const [shareTarget, setShareTarget] = useState<ShareTarget | null>(null)
135 const [streaming, setStreaming] = useState('')
136 const [streamingThink, setStreamingThink] = useState('')
158 const [liveParts, setLiveParts] = useState<LivePart[]>([])
137159 const [sseLive, setSseLive] = useState(false)
160 // SSE 的精确运行信号:run_started/首个 token → true,run_finished/error/
161 // suspended → false。轮询的 runs 列表有滞后,思考框的「思考中」态不能只靠它。
162 const [runLive, setRunLive] = useState(false)
163 const [liveRunId, setLiveRunId] = useState<string | null>(null)
138164 const lastSequence = useRef(0)
139165 const lastEventId = useRef<string | null>(null)
140166 const lastTokenSeq = useRef<string | null>(null)
141167 const finishedRunIds = useRef<Set<string>>(new Set())
142168 const streamingRunId = useRef<string | null>(null)
169 const liveRunIdRef = useRef<string | null>(null)
170 const absorbedIds = useRef<Set<string>>(new Set())
143171 const pendingText = useRef('')
144172 const pendingThink = useRef('')
145173 const flushFrame = useRef<number | null>(null)
@@ -167,6 +195,34 @@
167195 return map
168196 }, [toolCalls])
169197
198 const liveActive = Boolean(activeRun) || runLive
199 const transcriptActiveRunId = activeRun?.run_id ?? (runLive ? liveRunId ?? undefined : undefined)
200 // 吸收集合是 ref:吸收总会伴随 setLiveParts 触发重渲染,这里每次渲染直接
201 // 过滤即可(消息量级下开销可忽略),避免为 ref 变更制造假依赖。
202 const preludeMessages = messages.filter(m => !absorbedIds.current.has(m.message_id))
203 const liveChunks = useMemo(() => {
204 const chunks: LiveChunk[] = []
205 liveParts.forEach((part, index) => {
206 if (part.kind === 'message') {
207 const last = chunks[chunks.length - 1]
208 if (last && last.kind === 'messages') last.items.push(part.message)
209 else chunks.push({ kind: 'messages', items: [part.message] })
210 return
211 }
212 chunks.push({
213 kind: part.kind,
214 text: part.text,
215 live: liveActive && index === liveParts.length - 1,
216 })
217 })
218 return chunks
219 }, [liveParts, liveActive])
220 const hasLiveOutput = liveParts.some(part => part.kind !== 'message')
221 const liveTextLen = liveParts.reduce(
222 (sum, part) => sum + (part.kind === 'message' ? 0 : part.text.length),
223 0,
224 )
225
170226 const streamGrant = useRef<{ base: string; token: string; expiresAt: number } | null>(null)
171227 /**
172228 * Artifact downloads go through the runtime's redirect endpoint, which signs
@@ -220,12 +276,25 @@
220276 cancelAnimationFrame(flushFrame.current)
221277 flushFrame.current = null
222278 }
223 const text = pendingText.current
224279 const think = pendingThink.current
225 pendingText.current = ''
280 const text = pendingText.current
226281 pendingThink.current = ''
227 if (think) setStreamingThink(prev => prev + think)
228 if (text) setStreaming(prev => prev + text)
282 pendingText.current = ''
283 if (!think && !text) return
284 setLiveParts(prev => {
285 let next = prev
286 if (think) next = appendLiveChunk(next, 'think', think)
287 if (text) next = appendLiveChunk(next, 'answer', text)
288 return next
289 })
290 }, [])
291
292 const resetLiveRun = useCallback((runId: string) => {
293 streamingRunId.current = runId
294 liveRunIdRef.current = runId
295 absorbedIds.current = new Set()
296 setLiveRunId(runId)
297 setLiveParts([])
229298 }, [])
230299
231300 const scheduleFlush = useCallback(() => {
@@ -237,8 +306,7 @@
237306 }, [flushPending])
238307
239308 const refreshStructure = useCallback(async () => {
240 // The buffer holds tokens that are not in `messages` yet; park them in state
241 // first so the clear below cannot drop text that raced the fetch.
309 // Tokens are committed before the fetch so nothing straddles the snapshot.
242310 flushPending()
243311 const after = lastSequence.current > 0 ? lastSequence.current : undefined
244312 const fresh = await fetchAgentMessages(spaceName, collectionId, agent.id, after)
@@ -246,28 +314,60 @@
246314 lastSequence.current = Math.max(lastSequence.current, ...fresh.map(m => m.sequence))
247315 setMessages(prev => {
248316 const seen = new Set(prev.map(m => m.message_id))
249 return [...prev, ...fresh.filter(m => !seen.has(m.message_id))]
317 const merged = [...prev, ...fresh.filter(m => !seen.has(m.message_id))]
318 // 真实用户消息落库时,配对抵消一条同内容的乐观回显行(按内容计数,
319 // 连发两条相同消息也能各自配对)。
320 const matched = new Map<string, number>()
321 for (const message of fresh) {
322 if (message.role === 'user') {
323 matched.set(message.content, (matched.get(message.content) ?? 0) + 1)
324 }
325 }
326 if (matched.size === 0) return merged
327 return merged.filter(message => {
328 if (!message.message_id.startsWith('pending-') || message.role !== 'user') return true
329 const left = matched.get(message.content) ?? 0
330 if (left <= 0) return true
331 matched.set(message.content, left - 1)
332 return false
333 })
250334 })
251 // 正文一旦落库就由消息行接管;思考只在运行时也把它落成消息时才清,
252 // 否则保留到最后(运行时不会持久化思考,它是本轮唯一记录)。
253 // 只认当前流式 run 的落库消息,避免旧 run 的迟到消息清掉新 run 的缓冲。
254 const run = streamingRunId.current
255 if (fresh.some(item => item.role === 'assistant' && (run === null || item.run_id === run))) {
256 flushPending()
257 setStreaming('')
258 streamingRunId.current = null
259 }
260 if (
261 fresh.some(
262 item => item.kind === 'reasoning' && (run === null || item.run_id === run),
263 )
264 ) {
265 setStreamingThink('')
266 }
267335 }
268336 setRuns(await fetchAgentRuns(spaceName, collectionId, agent.id))
269337 }, [spaceName, collectionId, agent.id, flushPending])
270338
339 // 落库消息按 run 吸收进活块:工具行落在产生它的思考段之后,最终回答落库时
340 // 取代流式正文段(持久化思考同理),顺序即到达次序——天然是 ReAct 时序。
341 useEffect(() => {
342 if (!liveRunId) return
343 const fresh = messages.filter(
344 m => m.run_id === liveRunId && !absorbedIds.current.has(m.message_id),
345 )
346 if (fresh.length === 0) return
347 for (const message of fresh) absorbedIds.current.add(message.message_id)
348 setLiveParts(prev => {
349 const next = [...prev]
350 for (const message of fresh) {
351 if (message.role === 'user') {
352 // 用户消息是 run 的第一行:落库拉取可能晚于首批 token,插到块首
353 // 而不是追加,避免「思考框跑到用户消息上面」。
354 const seen = next.some(
355 part => part.kind === 'message' && part.message.message_id === message.message_id,
356 )
357 if (!seen) next.unshift({ kind: 'message', message })
358 continue
359 }
360 if (message.kind === 'reasoning') {
361 while (next.length > 0 && next[next.length - 1].kind === 'think') next.pop()
362 } else if (message.role === 'assistant' && message.content.trim().length > 0) {
363 while (next.length > 0 && next[next.length - 1].kind === 'answer') next.pop()
364 }
365 next.push({ kind: 'message', message })
366 }
367 return next
368 })
369 }, [messages, liveRunId])
370
271371 const refreshPrompts = useCallback(async () => {
272372 const [nextApprovals, nextQuestions] = await Promise.all([
273373 fetchAgentApprovals(spaceName, collectionId, agent.id),
@@ -366,18 +466,17 @@
366466 }
367467 if (event.type === 'run_started' && runId) {
368468 flushPending()
369 setStreaming('')
370 setStreamingThink('')
371 streamingRunId.current = null
469 resetLiveRun(runId)
470 setRunLive(true)
372471 } else if (
373472 event.type === 'run_finished' ||
374473 event.type === 'run_error' ||
375474 event.type === 'run_suspended'
376475 ) {
377476 if (runId) finishedRunIds.current.add(runId)
378 // 结束时只清正文流;思考保留为「思考过程」,下一轮 run_started 再清。
477 // 结束时只提交缓冲;活块整块留作 ReAct 记录,下一轮 run_started 重建。
379478 flushPending()
380 setStreaming('')
479 setRunLive(false)
381480 }
382481 refreshSoon({
383482 prompts: PROMPT_EVENT_KINDS.has(event.type),
@@ -472,7 +571,9 @@
472571 const runId = payload.run_id ?? null
473572 if (runId && finishedRunIds.current.has(runId)) return
474573 // 思维链与正文分开累积:runtime 在 token 上标记 reasoning。
475 if (runId) streamingRunId.current = runId
574 if (runId && liveRunIdRef.current !== runId) resetLiveRun(runId)
575 // 重连/中途打开页面时 run_started 可能已错过:首个 token 兜底置真。
576 setRunLive(true)
476577 if (payload.reasoning) pendingThink.current += payload.token
477578 else pendingText.current += payload.token
478579 scheduleFlush()
@@ -500,6 +601,7 @@
500601 refreshSoon,
501602 refreshStructure,
502603 flushPending,
604 resetLiveRun,
503605 scheduleFlush,
504606 ])
505607
@@ -526,7 +628,7 @@
526628 useLayoutEffect(() => {
527629 const node = scrollRef.current
528630 if (node && atBottomRef.current) node.scrollTop = node.scrollHeight
529 }, [messages.length, activeRun?.run_id, streaming.length])
631 }, [messages.length, activeRun?.run_id, liveParts.length, liveTextLen])
530632
531633 // Late content growth (images, markdown repaint) must not drift the reader
532634 // who is parked at the bottom.
@@ -568,12 +670,28 @@
568670 if (!text || busy) return
569671 setBusy(true)
570672 setError(null)
673 // 乐观回显:发送即上屏。运行时要等 actor 唤醒与沙箱认领之后才落库用户
674 // 消息(实测 ~8s),只靠轮询拉回的话气泡会晚到正文之后。
675 const optimistic: AgentMessageDto = {
676 message_id: `pending-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
677 conversation_id: '',
678 created_at: Math.floor(Date.now() / 1000),
679 kind: 'regular',
680 role: 'user',
681 run_id: null,
682 sequence: Number.MAX_SAFE_INTEGER,
683 status: 'pending',
684 content: text,
685 }
686 setMessages(prev => [...prev, optimistic])
687 setDraft('')
688 scrollToBottom()
571689 try {
572690 await sendAgentMessage(spaceName, collectionId, agent.id, text, canEnableMcp && mcpEnabled, repoBound || null)
573 setDraft('')
574 scrollToBottom()
575691 await refresh()
576692 } catch (err) {
693 setMessages(prev => prev.filter(m => m.message_id !== optimistic.message_id))
694 setDraft(text)
577695 setError(errorMessage(err))
578696 } finally {
579697 setBusy(false)
@@ -709,7 +827,7 @@
709827 <div className="flex h-full items-center justify-center">
710828 <Loader2 className="h-5 w-5 animate-spin text-shell-ink-muted" aria-label={t('ui.loading')} />
711829 </div>
712 ) : messages.length === 0 && !streaming ? (
830 ) : messages.length === 0 && liveParts.length === 0 ? (
713831 <div className="mx-auto mt-10 flex max-w-md flex-col items-center gap-3 text-center">
714832 <p className="text-[13px] leading-6 text-shell-ink-muted">
715833 {t('agent.chat.empty', { name: agent.name })}
@@ -735,14 +853,31 @@
735853 ) : (
736854 <div className="mx-auto flex max-w-3xl flex-col gap-2">
737855 <AgentTranscript
738 messages={messages}
856 messages={preludeMessages}
739857 toolCallByCallId={toolCallByCallId}
740858 artifactAccess={artifactAccess}
741859 actions={messageActions}
860 activeRunId={transcriptActiveRunId}
861 continuedRunId={liveChunks.length > 0 ? liveRunId ?? undefined : undefined}
742862 />
743 {streamingThink ? <AgentThinkingRow text={streamingThink} live={Boolean(activeRun)} /> : null}
744 {streaming ? <AgentStreamingRow text={streaming} /> : null}
745 {activeRun && !streaming && !streamingThink ? <AgentWorkingRow /> : null}
863 {liveChunks.map((chunk, index) =>
864 chunk.kind === 'messages' ? (
865 <AgentTranscript
866 key={`live-m-${index}`}
867 messages={chunk.items}
868 toolCallByCallId={toolCallByCallId}
869 artifactAccess={artifactAccess}
870 actions={messageActions}
871 activeRunId={transcriptActiveRunId}
872 continuedRunId={index < liveChunks.length - 1 ? liveRunId ?? undefined : undefined}
873 />
874 ) : chunk.kind === 'think' ? (
875 <AgentThinkingRow key={`live-t-${index}`} text={chunk.text} live={chunk.live} />
876 ) : (
877 <AgentStreamingRow key={`live-a-${index}`} text={chunk.text} live={chunk.live} />
878 ),
879 )}
880 {liveActive && !hasLiveOutput ? <AgentWorkingRow /> : null}
746881 </div>
747882 )}
748883 </div>
@@ -1094,4 +1229,4 @@
10941229 ) : null}
10951230 </div>
10961231 )
1097}
No newline at end of file
1232}
Modified web/src/pages/workspace/ui/agent/agent-message.tsx web/src/pages/workspace/ui/agent/agent-message.tsx +160 −49 View web/src/pages/workspace/ui/agent/agent-message.tsx
@@ -193,7 +193,6 @@
193193 content: string
194194 artifacts: TranscriptArtifacts
195195 artifactAccess?: ArtifactAccess
196 /** Only the conversation's last message keeps its action row always visible. */
197196 final?: boolean
198197 messageId: string
199198 actions?: MessageActions
@@ -262,43 +261,41 @@
262261 <Markdown>{linkArtifacts(content, artifacts)}</Markdown>
263262 </div>
264263 </div>
265 <div
266 className={`mt-1 flex items-center gap-0.5 text-shell-ink-muted transition-opacity ${
267 final ? '' : 'opacity-0 focus-within:opacity-100 group-hover:opacity-100'
268 }`}
269 >
270 <CopyButton text={content} />
271 <ActionButton
272 label={actions?.canRegenerate ? t('agent.msg.regenerate') : t('agent.msg.regenerateHint')}
273 disabled={!actions?.canRegenerate}
274 onClick={() => actions?.regenerate(messageId)}
275 >
276 <RotateCcw className="h-3.5 w-3.5" />
277 </ActionButton>
278 <ActionButton
279 label={t('agent.msg.like')}
280 active={actions?.feedbackOf(messageId) === 'up'}
281 onClick={() => actions?.feedback(messageId, 'up')}
282 >
283 <ThumbsUp className="h-3.5 w-3.5" />
284 </ActionButton>
285 <ActionButton
286 label={t('agent.msg.dislike')}
287 active={actions?.feedbackOf(messageId) === 'down'}
288 onClick={() => actions?.feedback(messageId, 'down')}
289 >
290 <ThumbsDown className="h-3.5 w-3.5" />
291 </ActionButton>
292 <ActionButton
293 label={t('agent.msg.share')}
294 onClick={() => {
295 const body = bodyRef.current
296 if (body) actions?.share(messageId, body)
297 }}
298 >
299 <Forward className="h-3.5 w-3.5" />
300 </ActionButton>
301 </div>
264 {final ? (
265 <div className="mt-1 flex items-center gap-0.5 text-shell-ink-muted">
266 <CopyButton text={content} />
267 <ActionButton
268 label={actions?.canRegenerate ? t('agent.msg.regenerate') : t('agent.msg.regenerateHint')}
269 disabled={!actions?.canRegenerate}
270 onClick={() => actions?.regenerate(messageId)}
271 >
272 <RotateCcw className="h-3.5 w-3.5" />
273 </ActionButton>
274 <ActionButton
275 label={t('agent.msg.like')}
276 active={actions?.feedbackOf(messageId) === 'up'}
277 onClick={() => actions?.feedback(messageId, 'up')}
278 >
279 <ThumbsUp className="h-3.5 w-3.5" />
280 </ActionButton>
281 <ActionButton
282 label={t('agent.msg.dislike')}
283 active={actions?.feedbackOf(messageId) === 'down'}
284 onClick={() => actions?.feedback(messageId, 'down')}
285 >
286 <ThumbsDown className="h-3.5 w-3.5" />
287 </ActionButton>
288 <ActionButton
289 label={t('agent.msg.share')}
290 onClick={() => {
291 const body = bodyRef.current
292 if (body) actions?.share(messageId, body)
293 }}
294 >
295 <Forward className="h-3.5 w-3.5" />
296 </ActionButton>
297 </div>
298 ) : null}
302299 </div>
303300 )
304301}
@@ -343,32 +340,38 @@
343340
344341/**
345342 * 正文流:与最终消息同排版;Markdown 以约 120ms 节流增量渲染,避免逐帧重解析。
346 * 尾随的块状光标作为文本追加,保证光标始终贴在最后一行内容之后。
343 * 尾随的块状光标作为文本追加,保证光标始终贴在最后一行内容之后;`live` 为
344 * false(后续出现工具行等更晚的分段)时按定稿渲染,不带光标。
347345 */
348export function AgentStreamingRow({ text }: { text: string }) {
346export function AgentStreamingRow({ text, live = true }: { text: string; live?: boolean }) {
349347 const [shown, setShown] = useState(text)
350348 const lastRender = useRef(0)
351349 const overflow = text.length > STREAM_MARKDOWN_LIMIT
352350 useEffect(() => {
353 if (overflow) return
351 if (overflow || !live) {
352 setShown(text)
353 return
354 }
354355 const delay = Math.max(0, STREAM_MARKDOWN_INTERVAL_MS - (performance.now() - lastRender.current))
355356 const timer = setTimeout(() => {
356357 lastRender.current = performance.now()
357358 setShown(text)
358359 }, delay)
359360 return () => clearTimeout(timer)
360 }, [text, overflow])
361 }, [text, overflow, live])
361362 if (overflow) {
362363 return (
363364 <div className="min-w-0 whitespace-pre-wrap break-words text-[13px] leading-6 text-shell-ink">
364365 {text}
365 <span className="ml-0.5 inline-block h-3.5 w-[2px] translate-y-[2px] animate-pulse bg-shell-ink-muted" />
366 {live ? (
367 <span className="ml-0.5 inline-block h-3.5 w-[2px] translate-y-[2px] animate-pulse bg-shell-ink-muted" />
368 ) : null}
366369 </div>
367370 )
368371 }
369372 return (
370373 <div className="min-w-0 text-[13px] leading-6 text-shell-ink">
371 <Markdown>{`${shown}\u258d`}</Markdown>
374 <Markdown>{live ? `${shown}\u258d` : text}</Markdown>
372375 </div>
373376 )
374377}
@@ -377,10 +380,23 @@
377380 * 思维链流:灰字虚线卡片,与正文/工具卡明确区分。流式期间显示「思考中」+
378381 * 转圈,运行结束后保留为「思考过程」(运行时不落库时它就是本轮的唯一记录),
379382 * 直到下一轮开始或出现持久化的思考消息。
383 *
384 * 卡片固定高度、内部滚动并自动贴底:思考再长也不会把转写区一路撑长,读者
385 * 始终看到最新一行;手动向上翻会暂停贴底,滚回底部自动恢复。
380386 */
381387export function AgentThinkingRow({ text, live }: { text: string; live: boolean }) {
382388 const { t } = useT()
383389 const [open, setOpen] = useState(true)
390 const boxRef = useRef<HTMLDivElement>(null)
391 const stick = useRef(true)
392 useLayoutEffect(() => {
393 const box = boxRef.current
394 if (live && open && box && stick.current) box.scrollTop = box.scrollHeight
395 }, [text, live, open])
396 const follow = () => {
397 const box = boxRef.current
398 if (box) stick.current = box.scrollHeight - box.scrollTop - box.clientHeight < 24
399 }
384400 return (
385401 <div className="min-w-0">
386402 <button
@@ -397,9 +413,15 @@
397413 <ChevronDown className={`h-3 w-3 transition-transform ${open ? 'rotate-180' : ''}`} />
398414 </button>
399415 {open ? (
400 <div className="mt-1.5 whitespace-pre-wrap break-words rounded-lg border border-dashed border-shell-hairline px-3 py-2 text-[12.5px] leading-5 text-shell-ink-muted">
416 <div
417 ref={boxRef}
418 onScroll={live ? follow : undefined}
419 className="mt-1.5 max-h-44 overflow-y-auto overscroll-contain whitespace-pre-wrap break-words rounded-lg border border-dashed border-shell-hairline px-3 py-2 text-[12.5px] leading-5 text-shell-ink-muted"
420 >
401421 {text}
402 <span className="ml-0.5 inline-block h-3 w-[2px] translate-y-[2px] animate-pulse bg-shell-ink-muted" />
422 {live ? (
423 <span className="ml-0.5 inline-block h-3 w-[2px] translate-y-[2px] animate-pulse bg-shell-ink-muted" />
424 ) : null}
403425 </div>
404426 ) : null}
405427 </div>
@@ -810,6 +832,15 @@
810832 return isImageName(view.mime, view.name)
811833}
812834
835function isHtmlName(mime: string | null, name: string): boolean {
836 if ((mime ?? '').startsWith('text/html')) return true
837 return /\.html?$/i.test(name)
838}
839
840function isHtmlArtifact(view: Extract<ToolView, { kind: 'artifact' }>): boolean {
841 return isHtmlName(view.mime, view.name)
842}
843
813844/**
814845 * Image artifacts render frameless inside the transcript. Clicking the chart
815846 * opens an enlarged overlay; a standalone download button sits underneath.
@@ -894,6 +925,71 @@
894925 )
895926}
896927
928/**
929 * HTML deliverables render inside a sandboxed iframe: without
930 * `allow-same-origin` the page gets an opaque origin, so its scripts can never
931 * reach the app's DOM, storage or session. The artifact endpoint answers with
932 * `Content-Disposition: attachment` (it is a download URL), and framed
933 * navigations refuse attachment responses — so the source is fetched and fed
934 * to the frame through `srcdoc` instead of pointing the frame at the URL.
935 */
936function ArtifactHtml({
937 artifactId,
938 name,
939 access,
940 width,
941 height,
942}: {
943 artifactId: string
944 name: string
945 access: ArtifactAccess
946 width: number | null
947 height: number | null
948}) {
949 const [html, setHtml] = useState<string | null>(null)
950 const [failed, setFailed] = useState(false)
951 useEffect(() => {
952 let active = true
953 setHtml(null)
954 setFailed(false)
955 void access
956 .resolveUrl(artifactId)
957 .then(url => fetch(url, { headers: { accept: 'text/html' } }))
958 .then(res => (res.ok ? res.text() : Promise.reject(new Error(String(res.status)))))
959 .then(
960 text => {
961 if (active) setHtml(text)
962 },
963 () => {
964 if (active) setFailed(true)
965 },
966 )
967 return () => {
968 active = false
969 }
970 }, [artifactId, access])
971 const ratio = width && height ? `${width} / ${height}` : '3 / 2'
972 if (failed) return null
973 return (
974 <span
975 className={`block w-full overflow-hidden rounded-lg border border-shell-hairline ${
976 html !== null ? 'bg-white' : 'animate-pulse bg-shell-field/40'
977 }`}
978 style={{ aspectRatio: ratio }}
979 >
980 {html !== null ? (
981 <iframe
982 srcDoc={html}
983 title={name}
984 sandbox="allow-scripts"
985 referrerPolicy="no-referrer"
986 className="h-full w-full border-0"
987 />
988 ) : null}
989 </span>
990 )
991}
992
897993function ToolRow({
898994 raw,
899995 time,
@@ -941,6 +1037,17 @@
9411037 />
9421038 ) : null
9431039 }
1040 if (isHtmlArtifact(view)) {
1041 return artifactAccess ? (
1042 <ArtifactHtml
1043 artifactId={view.id}
1044 name={view.name}
1045 access={artifactAccess}
1046 width={view.width}
1047 height={view.height}
1048 />
1049 ) : null
1050 }
9441051 // Non-image deliverables are collected into the end-of-transcript grid.
9451052 return null
9461053 }
@@ -1155,11 +1262,15 @@
11551262 toolCallByCallId,
11561263 artifactAccess,
11571264 actions,
1265 activeRunId,
1266 continuedRunId,
11581267}: {
11591268 messages: AgentMessageDto[]
11601269 toolCallByCallId: Map<string, AgentToolCallDto>
11611270 artifactAccess?: ArtifactAccess
11621271 actions?: MessageActions
1272 activeRunId?: string
1273 continuedRunId?: string
11631274}) {
11641275 const items = useMemo(() => buildTranscript(messages), [messages])
11651276 const artifacts = useMemo(() => collectArtifacts(messages), [messages])
@@ -1168,7 +1279,7 @@
11681279 const deliverablesByRun = useMemo(() => {
11691280 const map = new Map<string, ArtifactItem[]>()
11701281 for (const item of artifacts.list) {
1171 if (isImageName(item.mime, item.name)) continue
1282 if (isImageName(item.mime, item.name) || isHtmlName(item.mime, item.name)) continue
11721283 const key = item.runId ?? ''
11731284 const bucket = map.get(key)
11741285 if (bucket) bucket.push(item)
@@ -1196,7 +1307,7 @@
11961307 <AgentMessageView
11971308 message={item.message}
11981309 artifacts={artifacts}
1199 final={index === items.length - 1}
1310 final={endOfRun && runId !== activeRunId && runId !== continuedRunId}
12001311 toolCall={
12011312 item.message.external_call_id
12021313 ? toolCallByCallId.get(item.message.external_call_id)
@@ -1214,4 +1325,4 @@
12141325 })}
12151326 </>
12161327 )
1217})
No newline at end of file
1328})
Modified web/src/pages/workspace/ui/agent/artifact-kind.tsx web/src/pages/workspace/ui/agent/artifact-kind.tsx +8 −1 View web/src/pages/workspace/ui/agent/artifact-kind.tsx
@@ -1,4 +1,4 @@
1import { File, FileArchive, FileSpreadsheet, FileText } from 'lucide-react'
1import { File, FileArchive, FileCode2, FileSpreadsheet, FileText } from 'lucide-react'
22import type { TranslateFn } from '@/shared/i18n'
33import type { ArtifactItem } from '../../model/agent-artifacts'
44
@@ -30,6 +30,13 @@
3030 accent: 'bg-[#10b981]/10 text-[#047857]',
3131 }
3232 }
33 if (extension === 'html' || extension === 'htm' || mime.includes('html')) {
34 return {
35 label: t('agent.msg.fileKind.html'),
36 icon: <FileCode2 className="h-4 w-4" />,
37 accent: 'bg-[#8b5cf6]/10 text-[#6d28d9]',
38 }
39 }
3340 if (mime.startsWith('text/') || extension === 'txt') {
3441 return {
3542 label: t('agent.msg.fileKind.text'),
Modified web/src/shared/i18n/messages/en/agent.ts web/src/shared/i18n/messages/en/agent.ts +1 −0 View web/src/shared/i18n/messages/en/agent.ts
@@ -103,6 +103,7 @@
103103 'agent.msg.fileKind.archive': 'Archive',
104104 'agent.msg.fileKind.markdown': 'Markdown',
105105 'agent.msg.fileKind.table': 'Table',
106 'agent.msg.fileKind.html': 'Web page',
106107 'agent.msg.fileKind.text': 'Text',
107108 'agent.msg.fileKind.file': 'File',
108109 'agent.msg.truncated': 'Content truncated',
Modified web/src/shared/i18n/messages/zh-CN/agent.ts web/src/shared/i18n/messages/zh-CN/agent.ts +1 −0 View web/src/shared/i18n/messages/zh-CN/agent.ts
@@ -103,6 +103,7 @@
103103 'agent.msg.fileKind.archive': '归档',
104104 'agent.msg.fileKind.markdown': 'Markdown',
105105 'agent.msg.fileKind.table': '表格',
106 'agent.msg.fileKind.html': '网页',
106107 'agent.msg.fileKind.text': '文本',
107108 'agent.msg.fileKind.file': '文件',
108109 'agent.msg.truncated': '内容已截断',