import ReactMarkdown from "react-markdown"; import { useState } from "react"; import type { Goal } from "../../types/goal"; import type { Edge, Message } from "../../types/message"; import styles from "./DetailPanel.module.css"; import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal"; import { extractImagesFromMessage } from "../../utils/imageExtraction"; import { CreateKnowledgeModal } from "../CreateKnowledgeModal/CreateKnowledgeModal"; interface DetailPanelProps { node: Goal | Message | null; edge: Edge | null; messages?: Message[]; onClose: () => void; traceId?: string; } export const DetailPanel = ({ node, edge, messages = [], onClose, traceId }: DetailPanelProps) => { const [previewImage, setPreviewImage] = useState(null); const [expandedReasoning, setExpandedReasoning] = useState>(new Set()); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); console.log("DetailPanel - node:", node); console.log("DetailPanel - edge:", edge); console.log("DetailPanel - messages:", messages); // 一些图床 (mmbiz.qpic.cn / qlogo.cn 等) 对部分浏览器组合 / 扩展会拒绝直链; // 直接 失败时,改走 images.weserv.nl 公共代理 (会以无 referer 重新拉取并转码)。 const PROXY_PREFIX = "https://images.weserv.nl/?url="; const buildProxyUrl = (url: string) => { if (!url || url.startsWith("data:")) return url; // weserv 期望传入不带 protocol 的主机+路径 const stripped = url.replace(/^https?:\/\//, ""); return PROXY_PREFIX + encodeURIComponent(stripped); }; const renderImages = (msg: Message) => { const images = extractImagesFromMessage(msg); console.log("%c [ images ]-29", "font-size:13px; background:pink; color:#bf2c9f;", images); if (images.length === 0) return null; return (
{images.map((img, idx) => ( {img.alt setPreviewImage(img.url)} onError={(e) => { const t = e.currentTarget; if (t.dataset.proxied === "1") return; // 防止无限重试 const proxied = buildProxyUrl(img.url); if (proxied && proxied !== t.src) { t.dataset.proxied = "1"; t.src = proxied; } }} /> ))}
); }; const title = node ? "节点详情" : edge ? "连线详情" : "详情"; const renderMessageContent = (content: Message["content"], msgKey?: string) => { if (!content) return ""; if (typeof content === "string") return {content}; if (Array.isArray(content)) { return ( <> {content.map((part, index) => (
{renderMessageContent(part, `${msgKey || "content"}-${index}`)}
))} ); } const hasText = !!content.text; const hasReasoning = !!content.reasoning_content; const hasToolCalls = content.tool_calls && content.tool_calls.length > 0; const hasToolResult = !!content.tool_name && !!content.result; const reasoningKey = msgKey || "default"; const isExpanded = expandedReasoning.has(reasoningKey); if (hasText || hasReasoning || hasToolCalls || hasToolResult) { return ( <> {hasReasoning && (
{ setExpandedReasoning((prev) => { const next = new Set(prev); if (next.has(reasoningKey)) next.delete(reasoningKey); else next.add(reasoningKey); return next; }); }} > {isExpanded ? "▼" : "▶"} 思考过程 ({content.reasoning_content!.length} 字)
{isExpanded && (
{content.reasoning_content!}
)}
)} {hasText && {content.text!}} {hasToolCalls && (
{content.tool_calls!.map((call, idx) => { const anyCall = call as unknown as Record; const fn = anyCall.function as Record | undefined; const name = (fn && (fn.name as string)) || (anyCall.name as string) || ((content as unknown as Record).tool_name as string) || `tool_${idx}`; let args: unknown = (fn && fn.arguments) || anyCall.arguments || (content as unknown as Record).arguments; if (typeof args === "string") { try { args = JSON.parse(args); } catch { // keep as string if JSON.parse fails } } const key = (anyCall.id as string) || `${name}-${idx}`; return (
工具调用: {name}
{JSON.stringify(args, null, 2)}
); })}
)} {hasToolResult && (
工具: {content.tool_name}
{typeof content.result === "string" ? ( {content.result} ) : (
{JSON.stringify(content.result, null, 2)}
)}
)} ); } return {JSON.stringify(content)}; }; const isGoal = (node: Goal | Message): node is Goal => { // Message 有 message_id 或 role 字段,Goal 没有 // 优先判断是否为 Message,排除后才是 Goal return !("message_id" in node) && !("role" in node) && "status" in node; }; const isMessageNode = (node: Goal | Message): node is Message => "message_id" in node || "role" in node || "sequence" in node; // 构建传给 LLM 的上下文文本 const buildContextText = (): string => { if (!node) return ""; if (isGoal(node)) { const lines: string[] = []; lines.push(`[Goal] ${node.description}`); if (node.summary) lines.push(`[Summary] ${node.summary}`); for (const msg of messages) { const role = msg.role || "unknown"; const seq = msg.sequence ?? ""; const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content); lines.push(`[${role}#${seq}] ${content}`); } return lines.join("\n"); } else { const msg = node as Message; const role = msg.role || "unknown"; const seq = msg.sequence ?? ""; const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content); return `[${role}#${seq}] ${content}`; } }; // 提取 Goal/Message 的 ID 信息用于 source 溯源 const getNodeIds = () => { if (!node) return {}; if (isGoal(node)) { return { goalId: node.id }; } else { const msg = node as Message; return { goalId: msg.goal_id, messageId: msg.message_id || msg.id, sequence: msg.sequence ?? undefined, }; } }; const renderKnowledge = (knowledge: Goal["knowledge"]) => { if (!knowledge || knowledge.length === 0) return null; return (
{knowledge.map((item) => (
{item.id}
{item.score !== undefined && ⭐ {item.score}} {item.quality_score !== undefined && ( ✨ {item.quality_score.toFixed(1)} )} {item.metrics?.helpful !== undefined && ( 👍 {item.metrics.helpful} )} {item.metrics?.harmful !== undefined && ( 👎 {item.metrics.harmful} )}
{item.tags?.type && item.tags.type.length > 0 && (
{item.tags.type.map((tag) => ( {tag} ))}
)}
场景: {item.scenario}
{item.content}
))}
); }; return ( ); };