From 7ab8d4fac6f2782cd9c302edda93c41c84e3d07e Mon Sep 17 00:00:00 2001 From: mlogclub Date: Tue, 14 Apr 2026 23:26:20 +0800 Subject: [PATCH] feat: enhance user message display with HTML rendering and preview functionality --- web/app/(console)/agent-run-logs/page.tsx | 173 ++++++++++++++++++++-- 1 file changed, 161 insertions(+), 12 deletions(-) diff --git a/web/app/(console)/agent-run-logs/page.tsx b/web/app/(console)/agent-run-logs/page.tsx index 3941d44..0951fc1 100644 --- a/web/app/(console)/agent-run-logs/page.tsx +++ b/web/app/(console)/agent-run-logs/page.tsx @@ -9,6 +9,7 @@ import { } from "lucide-react" import { toast } from "sonner" +import { ImMessageHTML } from "@/components/im-message-html" import { ListPagination } from "@/components/list-pagination" import { OptionCombobox } from "@/components/option-combobox" import { Badge } from "@/components/ui/badge" @@ -410,9 +411,7 @@ export default function DashboardAgentRunLogsPage() { {formatDateTime(item.createdAt)} -
- {item.userMessage || "-"} -
+ {item.errorMessage ? (
{item.errorMessage} @@ -585,6 +584,7 @@ export default function DashboardAgentRunLogsPage() { icon={} title="用户问题" value={activeLog.userMessage} + renderAsHtml /> } @@ -684,27 +684,176 @@ function TextBlock({ value, icon, tone = "default", + renderAsHtml = false, }: { title: string value?: string icon?: ReactNode tone?: "default" | "danger" + renderAsHtml?: boolean }) { + const normalizedValue = value?.trim() || "" + const html = useMemo(() => { + if (!renderAsHtml || !normalizedValue) { + return "" + } + return sanitizeRichHTML(normalizedValue) + }, [normalizedValue, renderAsHtml]) + return (
{icon} {title}
-
- {value?.trim() || "-"} -
+ {renderAsHtml && normalizedValue ? ( + + ) : ( +
+ {normalizedValue || "-"} +
+ )}
) } + +function UserMessagePreview({ value }: { value?: string }) { + const preview = useMemo(() => summarizeUserMessage(value), [value]) + + return ( +
+ {preview} +
+ ) +} + +function summarizeUserMessage(value?: string) { + const normalized = value?.trim() + if (!normalized) { + return "-" + } + const text = extractTextFromHTML(normalized).replace(/\s+/g, " ").trim() + if (text) { + return text + } + if (containsHTML(normalized)) { + if (/]/i.test(normalized)) { + return "[图片]" + } + return "[富文本消息]" + } + return normalized +} + +function containsHTML(value: string) { + return /<[^>]+>/.test(value) +} + +function extractTextFromHTML(value: string) { + if (typeof window === "undefined") { + return value + } + const doc = new DOMParser().parseFromString(value, "text/html") + return doc.body.textContent || "" +} + +function sanitizeRichHTML(value: string) { + if (typeof window === "undefined") { + return value + } + + const doc = new DOMParser().parseFromString(value, "text/html") + const allowedTags = new Set([ + "a", + "b", + "blockquote", + "br", + "code", + "div", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "img", + "li", + "ol", + "p", + "pre", + "span", + "strong", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "u", + "ul", + ]) + const allowedAttrs = new Set(["alt", "class", "colspan", "href", "rel", "rowspan", "src", "target", "title"]) + const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT) + const elements: Element[] = [] + + while (walker.nextNode()) { + elements.push(walker.currentNode as Element) + } + + for (const element of elements) { + const tag = element.tagName.toLowerCase() + if (!allowedTags.has(tag)) { + element.replaceWith(...Array.from(element.childNodes)) + continue + } + + for (const attr of Array.from(element.attributes)) { + const name = attr.name.toLowerCase() + const value = attr.value.trim() + if (name.startsWith("on") || !allowedAttrs.has(name)) { + element.removeAttribute(attr.name) + continue + } + if ((name === "href" || name === "src") && !isSafeURL(value)) { + element.removeAttribute(attr.name) + continue + } + } + + if (tag === "a") { + element.setAttribute("target", "_blank") + element.setAttribute("rel", "noreferrer noopener") + } + } + + return doc.body.innerHTML +} + +function isSafeURL(value: string) { + if (!value) { + return false + } + if (value.startsWith("/")) { + return true + } + if (value.startsWith("data:image/")) { + return true + } + try { + const url = new URL(value, window.location.origin) + return ["http:", "https:"].includes(url.protocol) + } catch { + return false + } +}