| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409 |
- 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<string | null>(null);
- const [expandedReasoning, setExpandedReasoning] = useState<Set<string>>(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 等) 对部分浏览器组合 / 扩展会拒绝直链;
- // 直接 <img> 失败时,改走 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 (
- <div className="grid grid-cols-3 gap-2 mt-2">
- {images.map((img, idx) => (
- <img
- key={idx}
- src={img.url}
- alt={img.alt || "Extracted"}
- referrerPolicy="no-referrer"
- loading="lazy"
- className="w-full h-20 object-cover rounded border border-gray-200 cursor-pointer hover:opacity-80 transition-opacity bg-gray-50"
- onClick={() => 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;
- }
- }}
- />
- ))}
- </div>
- );
- };
- const title = node ? "节点详情" : edge ? "连线详情" : "详情";
- const renderMessageContent = (content: Message["content"], msgKey?: string) => {
- if (!content) return "";
- if (typeof content === "string") return <ReactMarkdown>{content}</ReactMarkdown>;
- if (Array.isArray(content)) {
- return (
- <>
- {content.map((part, index) => (
- <div key={`${msgKey || "content"}-${index}`}>
- {renderMessageContent(part, `${msgKey || "content"}-${index}`)}
- </div>
- ))}
- </>
- );
- }
- 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 && (
- <div className={styles.reasoningBlock}>
- <div
- className={styles.reasoningToggle}
- onClick={() => {
- setExpandedReasoning((prev) => {
- const next = new Set(prev);
- if (next.has(reasoningKey)) next.delete(reasoningKey);
- else next.add(reasoningKey);
- return next;
- });
- }}
- >
- <span className={styles.reasoningIcon}>{isExpanded ? "▼" : "▶"}</span>
- <span>思考过程</span>
- <span className={styles.reasoningLen}>({content.reasoning_content!.length} 字)</span>
- </div>
- {isExpanded && (
- <div className={styles.reasoningContent}>
- <ReactMarkdown>{content.reasoning_content!}</ReactMarkdown>
- </div>
- )}
- </div>
- )}
- {hasText && <ReactMarkdown>{content.text!}</ReactMarkdown>}
- {hasToolCalls && (
- <div className={styles.toolCalls}>
- {content.tool_calls!.map((call, idx) => {
- const anyCall = call as unknown as Record<string, unknown>;
- const fn = anyCall.function as Record<string, unknown> | undefined;
- const name =
- (fn && (fn.name as string)) ||
- (anyCall.name as string) ||
- ((content as unknown as Record<string, unknown>).tool_name as string) ||
- `tool_${idx}`;
- let args: unknown =
- (fn && fn.arguments) ||
- anyCall.arguments ||
- (content as unknown as Record<string, unknown>).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 (
- <div
- key={key}
- className={styles.toolCall}
- >
- <div className={styles.toolName}>工具调用: {name}</div>
- <pre className={styles.toolArgs}>{JSON.stringify(args, null, 2)}</pre>
- </div>
- );
- })}
- </div>
- )}
- {hasToolResult && (
- <div className={styles.toolResult}>
- <div className={styles.toolName}>工具: {content.tool_name}</div>
- <div className={styles.resultContent}>
- {typeof content.result === "string" ? (
- <ReactMarkdown>{content.result}</ReactMarkdown>
- ) : (
- <pre>{JSON.stringify(content.result, null, 2)}</pre>
- )}
- </div>
- </div>
- )}
- </>
- );
- }
- return <ReactMarkdown>{JSON.stringify(content)}</ReactMarkdown>;
- };
- 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 (
- <div className={styles.knowledgeList}>
- {knowledge.map((item) => (
- <div
- key={item.id}
- className={styles.knowledgeItem}
- >
- <div className={styles.knowledgeHeader}>
- <span className={styles.knowledgeId}>{item.id}</span>
- <div className={styles.knowledgeMetrics}>
- {item.score !== undefined && <span className={styles.metricScore}>⭐ {item.score}</span>}
- {item.quality_score !== undefined && (
- <span className={styles.metricQuality}>✨ {item.quality_score.toFixed(1)}</span>
- )}
- {item.metrics?.helpful !== undefined && (
- <span className={styles.metricHelpful}>👍 {item.metrics.helpful}</span>
- )}
- {item.metrics?.harmful !== undefined && (
- <span className={styles.metricHarmful}>👎 {item.metrics.harmful}</span>
- )}
- </div>
- </div>
- {item.tags?.type && item.tags.type.length > 0 && (
- <div className={styles.knowledgeTags}>
- {item.tags.type.map((tag) => (
- <span
- key={tag}
- className={styles.tag}
- >
- {tag}
- </span>
- ))}
- </div>
- )}
- <div className={styles.knowledgeScenario}>
- <strong>场景:</strong> {item.scenario}
- </div>
- <div className={styles.knowledgeContent}>
- <ReactMarkdown>{item.content}</ReactMarkdown>
- </div>
- </div>
- ))}
- </div>
- );
- };
- return (
- <aside className={styles.panel}>
- <div className={styles.header}>
- <div className={styles.title}>{title}</div>
- <button
- className={styles.close}
- onClick={onClose}
- aria-label="关闭"
- >
- ×
- </button>
- </div>
- <div className={styles.content}>
- {node && (
- <>
- <div className={styles.sectionTitle}>节点</div>
- <div className={styles.section}>
- <div className={styles.label}>ID</div>
- <div className={styles.value}>{isMessageNode(node) ? node.message_id || node.id : node.id}</div>
- </div>
- {isMessageNode(node) && extractImagesFromMessage(node).length > 0 && (
- <div className={styles.section}>
- <div className={styles.label}>图片</div>
- {renderImages(node)}
- </div>
- )}
- {isGoal(node) ? (
- <>
- <div className={styles.section}>
- <div className={styles.label}>目标描述</div>
- <div className={styles.value}>{node.description}</div>
- </div>
- {node.reason && (
- <div className={styles.section}>
- <div className={styles.label}>创建理由</div>
- <div className={styles.value}>{node.reason}</div>
- </div>
- )}
- {node.summary && (
- <div className={styles.section}>
- <div className={styles.label}>总结</div>
- <div className={styles.value}>{node.summary}</div>
- </div>
- )}
- <div className={styles.section}>
- <div className={styles.label}>状态</div>
- <div className={styles.value}>{node.status}</div>
- </div>
- {node.knowledge && node.knowledge.length > 0 && (
- <div className={styles.section}>
- <div className={styles.sectionTitle}>相关知识</div>
- {renderKnowledge(node.knowledge)}
- </div>
- )}
- </>
- ) : (
- <>
- {node.description && (
- <div className={styles.section}>
- <div className={styles.label}>描述</div>
- <div className={styles.value}>{node.description}</div>
- </div>
- )}
- {node.role && (
- <div className={styles.section}>
- <div className={styles.label}>角色</div>
- <div className={styles.value}>{node.role}</div>
- </div>
- )}
- <div className={styles.section}>
- <div className={styles.label}>内容</div>
- <div className={styles.value}>
- {node.content && renderMessageContent(node.content, node.message_id || node.id)}
- </div>
- </div>
- {node.goal_id && (
- <div className={styles.section}>
- <div className={styles.label}>所属目标</div>
- <div className={styles.value}>{node.goal_id}</div>
- </div>
- )}
- {node.tokens !== undefined && node.tokens !== null && (
- <div className={styles.section}>
- <div className={styles.label}>Token数</div>
- <div className={styles.value}>{node.tokens}</div>
- </div>
- )}
- </>
- )}
- </>
- )}
- {messages && messages.length > 0 && (
- <div className={styles.messages}>
- <div className={styles.sectionTitle}>边</div>
- {messages.map((msg, idx) => (
- <div
- key={msg.id || idx}
- className={styles.messageItem}
- >
- <div className={styles.section}>
- <div className={styles.label}>描述</div>
- <div className={styles.value}>{msg.description || "-"}</div>
- </div>
- <div className={styles.section}>
- <div className={styles.label}>内容</div>
- <div className={styles.value}>
- {msg.content && renderMessageContent(msg.content, msg.message_id || msg.id || `msg-${idx}`)}
- </div>
- </div>
- </div>
- ))}
- </div>
- )}
- </div>
- <ImagePreviewModal
- visible={!!previewImage}
- onClose={() => setPreviewImage(null)}
- src={previewImage || ""}
- />
- {node && (
- <>
- <div className={styles.createKnowledgeBar}>
- <button
- className={styles.createKnowledgeBtn}
- onClick={() => setIsCreateModalOpen(true)}
- >
- 创建知识
- </button>
- </div>
- <CreateKnowledgeModal
- visible={isCreateModalOpen}
- contextText={buildContextText()}
- traceId={traceId}
- {...getNodeIds()}
- onClose={() => setIsCreateModalOpen(false)}
- />
- </>
- )}
- </aside>
- );
- };
|