Просмотр исходного кода

feat(frontend): 改进详情面板中工具调用结果的图像展示

添加对工具调用结果中图像数组的检测和展示功能,支持从嵌套JSON中提取图像URL并渲染为可点击的缩略图网格。同时优化代码结构,移除未使用的导入并简化类型声明。
刘文武 12 часов назад
Родитель
Сommit
b0978f5323

+ 2 - 1
.claude/settings.local.json

@@ -15,7 +15,8 @@
       "Bash(xargs grep:*)",
       "Bash(npm run:*)",
       "Bash(sed:*)",
-      "Bash(PYTHONIOENCODING=utf-8 python:*)"
+      "Bash(PYTHONIOENCODING=utf-8 python:*)",
+      "mcp__ide__getDiagnostics"
     ],
     "deny": [],
     "ask": []

+ 165 - 57
frontend/react-template/src/components/DetailPanel/DetailPanel.tsx

@@ -13,12 +13,7 @@ interface DetailPanelProps {
   onClose: () => void;
 }
 
-export const DetailPanel = ({
-  node,
-  edge,
-  messages = [],
-  onClose,
-}: DetailPanelProps) => {
+export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelProps) => {
   const [previewImage, setPreviewImage] = useState<string | null>(null);
 
   console.log("DetailPanel - node:", node);
@@ -43,12 +38,42 @@ export const DetailPanel = ({
     );
   };
 
+  const renderResultImages = (imageUrls: string[]) => {
+    if (!imageUrls || imageUrls.length === 0) return null;
+    const normalized = imageUrls
+      .map((raw) => String(raw))
+      .map((raw) => raw.replace(/^[\s`'"]+|[\s`'"]+$/g, ""))
+      .filter((url) => url.startsWith("http") || url.startsWith("data:"));
+    if (normalized.length === 0) return null;
+    return (
+      <div className="grid grid-cols-4 gap-2 mt-2">
+        {normalized.map((url, idx) => (
+          <img
+            key={`${url}-${idx}`}
+            src={url}
+            alt={`Image ${idx + 1}`}
+            className="w-full h-16 object-cover rounded border border-gray-200 cursor-pointer hover:opacity-80 transition-opacity bg-gray-50"
+            onClick={() => setPreviewImage(url)}
+            onError={(e) => {
+              (e.target as HTMLImageElement).style.display = "none";
+            }}
+          />
+        ))}
+      </div>
+    );
+  };
+
+  const normalizeImageUrl = (raw: unknown) => String(raw).replace(/^[\s`'"]+|[\s`'"]+$/g, "");
+
+  const getValidImageUrls = (values: unknown[]): string[] => {
+    return values.map((v) => normalizeImageUrl(v)).filter((url) => url.startsWith("http") || url.startsWith("data:"));
+  };
+
   const title = node ? "节点详情" : edge ? "连线详情" : "详情";
 
   const renderMessageContent = (content: Message["content"]) => {
     if (!content) return "";
-    if (typeof content === "string")
-      return <ReactMarkdown>{content}</ReactMarkdown>;
+    if (typeof content === "string") return <ReactMarkdown>{content}</ReactMarkdown>;
 
     const hasText = !!content.text;
     const hasToolCalls = content.tool_calls && content.tool_calls.length > 0;
@@ -62,14 +87,11 @@ export const DetailPanel = ({
             <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 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) ||
+                  ((content as unknown as Record<string, unknown>).tool_name as string) ||
                   `tool_${idx}`;
                 let args: unknown =
                   (fn && fn.arguments) ||
@@ -84,11 +106,12 @@ export const DetailPanel = ({
                 }
                 const key = (anyCall.id as string) || `${name}-${idx}`;
                 return (
-                  <div key={key} className={styles.toolCall}>
+                  <div
+                    key={key}
+                    className={styles.toolCall}
+                  >
                     <div className={styles.toolName}>工具调用: {name}</div>
-                    <pre className={styles.toolArgs}>
-                      {JSON.stringify(args, null, 2)}
-                    </pre>
+                    <pre className={styles.toolArgs}>{JSON.stringify(args, null, 2)}</pre>
                   </div>
                 );
               })}
@@ -98,11 +121,100 @@ export const DetailPanel = ({
             <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>
-                )}
+                {(() => {
+                  let resultData: unknown = content.result;
+
+                  const tryParseEmbeddedJson = (text: string): unknown | null => {
+                    const firstArray = text.indexOf("[");
+                    const lastArray = text.lastIndexOf("]");
+                    if (firstArray !== -1 && lastArray !== -1 && lastArray > firstArray) {
+                      const sliced = text.slice(firstArray, lastArray + 1);
+                      try {
+                        return JSON.parse(sliced);
+                      } catch {
+                        return null;
+                      }
+                    }
+                    const firstObj = text.indexOf("{");
+                    const lastObj = text.lastIndexOf("}");
+                    if (firstObj !== -1 && lastObj !== -1 && lastObj > firstObj) {
+                      const sliced = text.slice(firstObj, lastObj + 1);
+                      try {
+                        return JSON.parse(sliced);
+                      } catch {
+                        return null;
+                      }
+                    }
+                    return null;
+                  };
+
+                  if (typeof resultData === "string") {
+                    const raw = resultData;
+                    try {
+                      resultData = JSON.parse(raw);
+                    } catch {
+                      const embedded = tryParseEmbeddedJson(raw);
+                      if (embedded !== null) {
+                        resultData = embedded;
+                      } else {
+                        return <ReactMarkdown>{raw}</ReactMarkdown>;
+                      }
+                    }
+                  }
+
+                  const renderStructuredResult = (value: unknown, field?: string): JSX.Element => {
+                    if (Array.isArray(value)) {
+                      if (field === "images") {
+                        const urls = getValidImageUrls(value);
+                        if (urls.length > 0) {
+                          return <div>{renderResultImages(urls)}</div>;
+                        }
+                      }
+                      return (
+                        <div className="space-y-2">
+                          {value.map((item, idx) => (
+                            <div
+                              key={`${field || "arr"}-${idx}`}
+                              className="pl-2 border-l border-slate-200"
+                            >
+                              <div className="text-xs text-slate-500 mb-1">[{idx}]</div>
+                              {renderStructuredResult(item)}
+                            </div>
+                          ))}
+                        </div>
+                      );
+                    }
+
+                    if (value && typeof value === "object") {
+                      return (
+                        <div className="space-y-2">
+                          {Object.entries(value as Record<string, unknown>).map(([k, v]) => (
+                            <div key={k}>
+                              <div className="text-xs text-slate-500">{k}</div>
+                              <div className="pl-2">{renderStructuredResult(v, k)}</div>
+                            </div>
+                          ))}
+                        </div>
+                      );
+                    }
+
+                    if (typeof value === "string") {
+                      return <span className="whitespace-pre-wrap break-all">{value}</span>;
+                    }
+
+                    if (typeof value === "number" || typeof value === "boolean") {
+                      return <span>{String(value)}</span>;
+                    }
+
+                    if (value === null) {
+                      return <span>null</span>;
+                    }
+
+                    return <span>{String(value)}</span>;
+                  };
+
+                  return renderStructuredResult(resultData);
+                })()}
               </div>
             </div>
           )}
@@ -120,9 +232,7 @@ export const DetailPanel = ({
   };
 
   const isMessageNode = (node: Goal | Message): node is Message =>
-    "message_id" in node ||
-    "role" in node ||
-    "sequence" in node;
+    "message_id" in node || "role" in node || "sequence" in node;
 
   const renderKnowledge = (knowledge: Goal["knowledge"]) => {
     if (!knowledge || knowledge.length === 0) return null;
@@ -130,34 +240,32 @@ export const DetailPanel = ({
     return (
       <div className={styles.knowledgeList}>
         {knowledge.map((item) => (
-          <div key={item.id} className={styles.knowledgeItem}>
+          <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.score !== undefined && <span className={styles.metricScore}>⭐ {item.score}</span>}
                 {item.quality_score !== undefined && (
-                  <span className={styles.metricQuality}>
-                    ✨ {item.quality_score.toFixed(1)}
-                  </span>
+                  <span className={styles.metricQuality}>✨ {item.quality_score.toFixed(1)}</span>
                 )}
                 {item.metrics?.helpful !== undefined && (
-                  <span className={styles.metricHelpful}>
-                    👍 {item.metrics.helpful}
-                  </span>
+                  <span className={styles.metricHelpful}>👍 {item.metrics.helpful}</span>
                 )}
                 {item.metrics?.harmful !== undefined && (
-                  <span className={styles.metricHarmful}>
-                    👎 {item.metrics.harmful}
-                  </span>
+                  <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}>
+                  <span
+                    key={tag}
+                    className={styles.tag}
+                  >
                     {tag}
                   </span>
                 ))}
@@ -179,7 +287,11 @@ export const DetailPanel = ({
     <aside className={styles.panel}>
       <div className={styles.header}>
         <div className={styles.title}>{title}</div>
-        <button className={styles.close} onClick={onClose} aria-label="关闭">
+        <button
+          className={styles.close}
+          onClick={onClose}
+          aria-label="关闭"
+        >
           ×
         </button>
       </div>
@@ -189,17 +301,14 @@ export const DetailPanel = ({
             <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 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>
-              )}
+            {isMessageNode(node) && extractImagesFromMessage(node).length > 0 && (
+              <div className={styles.section}>
+                <div className={styles.label}>图片</div>
+                {renderImages(node)}
+              </div>
+            )}
 
             {isGoal(node) ? (
               <>
@@ -246,9 +355,7 @@ export const DetailPanel = ({
                 )}
                 <div className={styles.section}>
                   <div className={styles.label}>内容</div>
-                  <div className={styles.value}>
-                    {node.content && renderMessageContent(node.content)}
-                  </div>
+                  <div className={styles.value}>{node.content && renderMessageContent(node.content)}</div>
                 </div>
                 {node.goal_id && (
                   <div className={styles.section}>
@@ -270,16 +377,17 @@ export const DetailPanel = ({
           <div className={styles.messages}>
             <div className={styles.sectionTitle}>边</div>
             {messages.map((msg, idx) => (
-              <div key={msg.id || idx} className={styles.messageItem}>
+              <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)}
-                  </div>
+                  <div className={styles.value}>{msg.content && renderMessageContent(msg.content)}</div>
                 </div>
               </div>
             ))}

+ 0 - 1
frontend/react-template/src/components/FlowChart/FlowChart.tsx

@@ -15,7 +15,6 @@ import type { Goal } from "../../types/goal";
 import type { Edge as EdgeType, Message } from "../../types/message";
 import { ArrowMarkers } from "./components/ArrowMarkers";
 import styles from "./styles/FlowChart.module.css";
-import { Tooltip } from "@douyinfe/semi-ui";
 import { ImagePreviewModal } from "../ImagePreview/ImagePreviewModal";
 import { extractImagesFromMessage } from "../../utils/imageExtraction";
 

+ 4 - 0
frontend/react-template/src/global.d.ts

@@ -1,3 +1,5 @@
+import type { HTMLAttributes } from "react";
+
 declare module "*.module.css" {
   const classes: { [key: string]: string };
   export default classes;
@@ -10,3 +12,5 @@ declare module "react" {
     directory?: string;
   }
 }
+
+export {};