DetailPanel.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. import ReactMarkdown from "react-markdown";
  2. import { useState } from "react";
  3. import type { Goal } from "../../types/goal";
  4. import type { Edge, Message } from "../../types/message";
  5. import styles from "./DetailPanel.module.css";
  6. import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
  7. import { extractImagesFromMessage } from "../../utils/imageExtraction";
  8. import { CreateKnowledgeModal } from "../CreateKnowledgeModal/CreateKnowledgeModal";
  9. interface DetailPanelProps {
  10. node: Goal | Message | null;
  11. edge: Edge | null;
  12. messages?: Message[];
  13. onClose: () => void;
  14. traceId?: string;
  15. }
  16. export const DetailPanel = ({ node, edge, messages = [], onClose, traceId }: DetailPanelProps) => {
  17. const [previewImage, setPreviewImage] = useState<string | null>(null);
  18. const [expandedReasoning, setExpandedReasoning] = useState<Set<string>>(new Set());
  19. const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
  20. console.log("DetailPanel - node:", node);
  21. console.log("DetailPanel - edge:", edge);
  22. console.log("DetailPanel - messages:", messages);
  23. // 一些图床 (mmbiz.qpic.cn / qlogo.cn 等) 对部分浏览器组合 / 扩展会拒绝直链;
  24. // 直接 <img> 失败时,改走 images.weserv.nl 公共代理 (会以无 referer 重新拉取并转码)。
  25. const PROXY_PREFIX = "https://images.weserv.nl/?url=";
  26. const buildProxyUrl = (url: string) => {
  27. if (!url || url.startsWith("data:")) return url;
  28. // weserv 期望传入不带 protocol 的主机+路径
  29. const stripped = url.replace(/^https?:\/\//, "");
  30. return PROXY_PREFIX + encodeURIComponent(stripped);
  31. };
  32. const renderImages = (msg: Message) => {
  33. const images = extractImagesFromMessage(msg);
  34. console.log("%c [ images ]-29", "font-size:13px; background:pink; color:#bf2c9f;", images);
  35. if (images.length === 0) return null;
  36. return (
  37. <div className="grid grid-cols-3 gap-2 mt-2">
  38. {images.map((img, idx) => (
  39. <img
  40. key={idx}
  41. src={img.url}
  42. alt={img.alt || "Extracted"}
  43. referrerPolicy="no-referrer"
  44. loading="lazy"
  45. className="w-full h-20 object-cover rounded border border-gray-200 cursor-pointer hover:opacity-80 transition-opacity bg-gray-50"
  46. onClick={() => setPreviewImage(img.url)}
  47. onError={(e) => {
  48. const t = e.currentTarget;
  49. if (t.dataset.proxied === "1") return; // 防止无限重试
  50. const proxied = buildProxyUrl(img.url);
  51. if (proxied && proxied !== t.src) {
  52. t.dataset.proxied = "1";
  53. t.src = proxied;
  54. }
  55. }}
  56. />
  57. ))}
  58. </div>
  59. );
  60. };
  61. const title = node ? "节点详情" : edge ? "连线详情" : "详情";
  62. const renderMessageContent = (content: Message["content"], msgKey?: string) => {
  63. if (!content) return "";
  64. if (typeof content === "string") return <ReactMarkdown>{content}</ReactMarkdown>;
  65. if (Array.isArray(content)) {
  66. return (
  67. <>
  68. {content.map((part, index) => (
  69. <div key={`${msgKey || "content"}-${index}`}>
  70. {renderMessageContent(part, `${msgKey || "content"}-${index}`)}
  71. </div>
  72. ))}
  73. </>
  74. );
  75. }
  76. const hasText = !!content.text;
  77. const hasReasoning = !!content.reasoning_content;
  78. const hasToolCalls = content.tool_calls && content.tool_calls.length > 0;
  79. const hasToolResult = !!content.tool_name && !!content.result;
  80. const reasoningKey = msgKey || "default";
  81. const isExpanded = expandedReasoning.has(reasoningKey);
  82. if (hasText || hasReasoning || hasToolCalls || hasToolResult) {
  83. return (
  84. <>
  85. {hasReasoning && (
  86. <div className={styles.reasoningBlock}>
  87. <div
  88. className={styles.reasoningToggle}
  89. onClick={() => {
  90. setExpandedReasoning((prev) => {
  91. const next = new Set(prev);
  92. if (next.has(reasoningKey)) next.delete(reasoningKey);
  93. else next.add(reasoningKey);
  94. return next;
  95. });
  96. }}
  97. >
  98. <span className={styles.reasoningIcon}>{isExpanded ? "▼" : "▶"}</span>
  99. <span>思考过程</span>
  100. <span className={styles.reasoningLen}>({content.reasoning_content!.length} 字)</span>
  101. </div>
  102. {isExpanded && (
  103. <div className={styles.reasoningContent}>
  104. <ReactMarkdown>{content.reasoning_content!}</ReactMarkdown>
  105. </div>
  106. )}
  107. </div>
  108. )}
  109. {hasText && <ReactMarkdown>{content.text!}</ReactMarkdown>}
  110. {hasToolCalls && (
  111. <div className={styles.toolCalls}>
  112. {content.tool_calls!.map((call, idx) => {
  113. const anyCall = call as unknown as Record<string, unknown>;
  114. const fn = anyCall.function as Record<string, unknown> | undefined;
  115. const name =
  116. (fn && (fn.name as string)) ||
  117. (anyCall.name as string) ||
  118. ((content as unknown as Record<string, unknown>).tool_name as string) ||
  119. `tool_${idx}`;
  120. let args: unknown =
  121. (fn && fn.arguments) ||
  122. anyCall.arguments ||
  123. (content as unknown as Record<string, unknown>).arguments;
  124. if (typeof args === "string") {
  125. try {
  126. args = JSON.parse(args);
  127. } catch {
  128. // keep as string if JSON.parse fails
  129. }
  130. }
  131. const key = (anyCall.id as string) || `${name}-${idx}`;
  132. return (
  133. <div
  134. key={key}
  135. className={styles.toolCall}
  136. >
  137. <div className={styles.toolName}>工具调用: {name}</div>
  138. <pre className={styles.toolArgs}>{JSON.stringify(args, null, 2)}</pre>
  139. </div>
  140. );
  141. })}
  142. </div>
  143. )}
  144. {hasToolResult && (
  145. <div className={styles.toolResult}>
  146. <div className={styles.toolName}>工具: {content.tool_name}</div>
  147. <div className={styles.resultContent}>
  148. {typeof content.result === "string" ? (
  149. <ReactMarkdown>{content.result}</ReactMarkdown>
  150. ) : (
  151. <pre>{JSON.stringify(content.result, null, 2)}</pre>
  152. )}
  153. </div>
  154. </div>
  155. )}
  156. </>
  157. );
  158. }
  159. return <ReactMarkdown>{JSON.stringify(content)}</ReactMarkdown>;
  160. };
  161. const isGoal = (node: Goal | Message): node is Goal => {
  162. // Message 有 message_id 或 role 字段,Goal 没有
  163. // 优先判断是否为 Message,排除后才是 Goal
  164. return !("message_id" in node) && !("role" in node) && "status" in node;
  165. };
  166. const isMessageNode = (node: Goal | Message): node is Message =>
  167. "message_id" in node || "role" in node || "sequence" in node;
  168. // 构建传给 LLM 的上下文文本
  169. const buildContextText = (): string => {
  170. if (!node) return "";
  171. if (isGoal(node)) {
  172. const lines: string[] = [];
  173. lines.push(`[Goal] ${node.description}`);
  174. if (node.summary) lines.push(`[Summary] ${node.summary}`);
  175. for (const msg of messages) {
  176. const role = msg.role || "unknown";
  177. const seq = msg.sequence ?? "";
  178. const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
  179. lines.push(`[${role}#${seq}] ${content}`);
  180. }
  181. return lines.join("\n");
  182. } else {
  183. const msg = node as Message;
  184. const role = msg.role || "unknown";
  185. const seq = msg.sequence ?? "";
  186. const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
  187. return `[${role}#${seq}] ${content}`;
  188. }
  189. };
  190. // 提取 Goal/Message 的 ID 信息用于 source 溯源
  191. const getNodeIds = () => {
  192. if (!node) return {};
  193. if (isGoal(node)) {
  194. return { goalId: node.id };
  195. } else {
  196. const msg = node as Message;
  197. return {
  198. goalId: msg.goal_id,
  199. messageId: msg.message_id || msg.id,
  200. sequence: msg.sequence ?? undefined,
  201. };
  202. }
  203. };
  204. const renderKnowledge = (knowledge: Goal["knowledge"]) => {
  205. if (!knowledge || knowledge.length === 0) return null;
  206. return (
  207. <div className={styles.knowledgeList}>
  208. {knowledge.map((item) => (
  209. <div
  210. key={item.id}
  211. className={styles.knowledgeItem}
  212. >
  213. <div className={styles.knowledgeHeader}>
  214. <span className={styles.knowledgeId}>{item.id}</span>
  215. <div className={styles.knowledgeMetrics}>
  216. {item.score !== undefined && <span className={styles.metricScore}>⭐ {item.score}</span>}
  217. {item.quality_score !== undefined && (
  218. <span className={styles.metricQuality}>✨ {item.quality_score.toFixed(1)}</span>
  219. )}
  220. {item.metrics?.helpful !== undefined && (
  221. <span className={styles.metricHelpful}>👍 {item.metrics.helpful}</span>
  222. )}
  223. {item.metrics?.harmful !== undefined && (
  224. <span className={styles.metricHarmful}>👎 {item.metrics.harmful}</span>
  225. )}
  226. </div>
  227. </div>
  228. {item.tags?.type && item.tags.type.length > 0 && (
  229. <div className={styles.knowledgeTags}>
  230. {item.tags.type.map((tag) => (
  231. <span
  232. key={tag}
  233. className={styles.tag}
  234. >
  235. {tag}
  236. </span>
  237. ))}
  238. </div>
  239. )}
  240. <div className={styles.knowledgeScenario}>
  241. <strong>场景:</strong> {item.scenario}
  242. </div>
  243. <div className={styles.knowledgeContent}>
  244. <ReactMarkdown>{item.content}</ReactMarkdown>
  245. </div>
  246. </div>
  247. ))}
  248. </div>
  249. );
  250. };
  251. return (
  252. <aside className={styles.panel}>
  253. <div className={styles.header}>
  254. <div className={styles.title}>{title}</div>
  255. <button
  256. className={styles.close}
  257. onClick={onClose}
  258. aria-label="关闭"
  259. >
  260. ×
  261. </button>
  262. </div>
  263. <div className={styles.content}>
  264. {node && (
  265. <>
  266. <div className={styles.sectionTitle}>节点</div>
  267. <div className={styles.section}>
  268. <div className={styles.label}>ID</div>
  269. <div className={styles.value}>{isMessageNode(node) ? node.message_id || node.id : node.id}</div>
  270. </div>
  271. {isMessageNode(node) && extractImagesFromMessage(node).length > 0 && (
  272. <div className={styles.section}>
  273. <div className={styles.label}>图片</div>
  274. {renderImages(node)}
  275. </div>
  276. )}
  277. {isGoal(node) ? (
  278. <>
  279. <div className={styles.section}>
  280. <div className={styles.label}>目标描述</div>
  281. <div className={styles.value}>{node.description}</div>
  282. </div>
  283. {node.reason && (
  284. <div className={styles.section}>
  285. <div className={styles.label}>创建理由</div>
  286. <div className={styles.value}>{node.reason}</div>
  287. </div>
  288. )}
  289. {node.summary && (
  290. <div className={styles.section}>
  291. <div className={styles.label}>总结</div>
  292. <div className={styles.value}>{node.summary}</div>
  293. </div>
  294. )}
  295. <div className={styles.section}>
  296. <div className={styles.label}>状态</div>
  297. <div className={styles.value}>{node.status}</div>
  298. </div>
  299. {node.knowledge && node.knowledge.length > 0 && (
  300. <div className={styles.section}>
  301. <div className={styles.sectionTitle}>相关知识</div>
  302. {renderKnowledge(node.knowledge)}
  303. </div>
  304. )}
  305. </>
  306. ) : (
  307. <>
  308. {node.description && (
  309. <div className={styles.section}>
  310. <div className={styles.label}>描述</div>
  311. <div className={styles.value}>{node.description}</div>
  312. </div>
  313. )}
  314. {node.role && (
  315. <div className={styles.section}>
  316. <div className={styles.label}>角色</div>
  317. <div className={styles.value}>{node.role}</div>
  318. </div>
  319. )}
  320. <div className={styles.section}>
  321. <div className={styles.label}>内容</div>
  322. <div className={styles.value}>
  323. {node.content && renderMessageContent(node.content, node.message_id || node.id)}
  324. </div>
  325. </div>
  326. {node.goal_id && (
  327. <div className={styles.section}>
  328. <div className={styles.label}>所属目标</div>
  329. <div className={styles.value}>{node.goal_id}</div>
  330. </div>
  331. )}
  332. {node.tokens !== undefined && node.tokens !== null && (
  333. <div className={styles.section}>
  334. <div className={styles.label}>Token数</div>
  335. <div className={styles.value}>{node.tokens}</div>
  336. </div>
  337. )}
  338. </>
  339. )}
  340. </>
  341. )}
  342. {messages && messages.length > 0 && (
  343. <div className={styles.messages}>
  344. <div className={styles.sectionTitle}>边</div>
  345. {messages.map((msg, idx) => (
  346. <div
  347. key={msg.id || idx}
  348. className={styles.messageItem}
  349. >
  350. <div className={styles.section}>
  351. <div className={styles.label}>描述</div>
  352. <div className={styles.value}>{msg.description || "-"}</div>
  353. </div>
  354. <div className={styles.section}>
  355. <div className={styles.label}>内容</div>
  356. <div className={styles.value}>
  357. {msg.content && renderMessageContent(msg.content, msg.message_id || msg.id || `msg-${idx}`)}
  358. </div>
  359. </div>
  360. </div>
  361. ))}
  362. </div>
  363. )}
  364. </div>
  365. <ImagePreviewModal
  366. visible={!!previewImage}
  367. onClose={() => setPreviewImage(null)}
  368. src={previewImage || ""}
  369. />
  370. {node && (
  371. <>
  372. <div className={styles.createKnowledgeBar}>
  373. <button
  374. className={styles.createKnowledgeBtn}
  375. onClick={() => setIsCreateModalOpen(true)}
  376. >
  377. 创建知识
  378. </button>
  379. </div>
  380. <CreateKnowledgeModal
  381. visible={isCreateModalOpen}
  382. contextText={buildContextText()}
  383. traceId={traceId}
  384. {...getNodeIds()}
  385. onClose={() => setIsCreateModalOpen(false)}
  386. />
  387. </>
  388. )}
  389. </aside>
  390. );
  391. };