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

bug:修复照片不显示问题

刘文武 1 час назад
Родитель
Сommit
48c9aa512a

+ 2 - 0
frontend/react-template/index.html

@@ -4,6 +4,8 @@
     <meta charset="UTF-8" />
     <link rel="icon" type="image/svg+xml" href="/vite.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <!-- 防止外部图床 (mmbiz.qpic.cn 等) 因 Referer 检查拒绝加载 -->
+    <meta name="referrer" content="no-referrer" />
     <title>流程图可视化系统</title>
   </head>
   <body>

+ 61 - 87
frontend/react-template/src/components/DetailPanel/DetailPanel.tsx

@@ -15,13 +15,7 @@ interface DetailPanelProps {
   traceId?: string;
 }
 
-export const DetailPanel = ({
-  node,
-  edge,
-  messages = [],
-  onClose,
-  traceId,
-}: DetailPanelProps) => {
+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);
@@ -30,8 +24,19 @@ export const DetailPanel = ({
   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">
@@ -41,33 +46,17 @@ export const DetailPanel = ({
             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)}
-          />
-        ))}
-      </div>
-    );
-  };
-
-  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}`}
-            referrerPolicy="no-referrer"
-            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";
+              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;
+              }
             }}
           />
         ))}
@@ -75,21 +64,11 @@ export const DetailPanel = ({
     );
   };
 
-  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"], msgKey?: string) => {
     if (!content) return "";
-    if (typeof content === "string")
-      return <ReactMarkdown>{content}</ReactMarkdown>;
+    if (typeof content === "string") return <ReactMarkdown>{content}</ReactMarkdown>;
 
     const hasText = !!content.text;
     const hasReasoning = !!content.reasoning_content;
@@ -116,9 +95,7 @@ export const DetailPanel = ({
               >
                 <span className={styles.reasoningIcon}>{isExpanded ? "▼" : "▶"}</span>
                 <span>思考过程</span>
-                <span className={styles.reasoningLen}>
-                  ({content.reasoning_content!.length} 字)
-                </span>
+                <span className={styles.reasoningLen}>({content.reasoning_content!.length} 字)</span>
               </div>
               {isExpanded && (
                 <div className={styles.reasoningContent}>
@@ -132,14 +109,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) ||
@@ -154,11 +128,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>
                 );
               })}
@@ -202,9 +177,7 @@ export const DetailPanel = ({
       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);
+        const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
         lines.push(`[${role}#${seq}] ${content}`);
       }
       return lines.join("\n");
@@ -212,9 +185,7 @@ export const DetailPanel = ({
       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);
+      const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
       return `[${role}#${seq}] ${content}`;
     }
   };
@@ -240,34 +211,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>
                 ))}
@@ -289,7 +258,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>
@@ -299,17 +272,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) ? (
               <>
@@ -357,7 +327,8 @@ export const DetailPanel = ({
                 <div className={styles.section}>
                   <div className={styles.label}>内容</div>
                   <div className={styles.value}>
-                    {node.content && renderMessageContent(node.content, isMessageNode(node) ? node.message_id || node.id : node.id)}
+                    {node.content &&
+                      renderMessageContent(node.content, isMessageNode(node) ? node.message_id || node.id : node.id)}
                   </div>
                 </div>
                 {node.goal_id && (
@@ -380,7 +351,10 @@ 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>

+ 28 - 6
frontend/react-template/src/utils/imageExtraction.ts

@@ -5,6 +5,27 @@ export interface ExtractedImage {
   alt?: string;
 }
 
+// 从 HTML / Markdown 中提取的 URL 可能带 HTML 实体(&amp;) 或前后空白。
+// 直接塞进 <img src=...> 会让 wx_fmt=webp&amp;from=appmsg 这类 URL 解析出错,
+// WeChat CDN 也会对部分异常请求返回 0 字节响应,看起来就是"图不显示"。
+const HTML_ENTITIES: Record<string, string> = {
+  "&amp;": "&",
+  "&lt;": "<",
+  "&gt;": ">",
+  "&quot;": '"',
+  "&#39;": "'",
+  "&apos;": "'",
+  "&nbsp;": " ",
+};
+function sanitizeUrl(raw: string): string {
+  let url = (raw || "").trim();
+  url = url.replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (m) => HTML_ENTITIES[m] || m);
+  // 数字实体 &#xx; / &#xHH;
+  url = url.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)));
+  url = url.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
+  return url;
+}
+
 /**
  * Extracts images from a message's content or result.
  * Handles both JSON array format (MsgResult[]) and Rich Text (Markdown/HTML).
@@ -27,7 +48,7 @@ export const extractImagesFromResult = (result: unknown): ExtractedImage[] => {
         // 1. Check for standard OpenAI-like image_url
         if (msgItem.image_url && typeof msgItem.image_url === "object" && msgItem.image_url.url) {
           images.push({
-            url: msgItem.image_url.url,
+            url: sanitizeUrl(msgItem.image_url.url),
             alt: "Attached Image",
           });
         }
@@ -43,7 +64,7 @@ export const extractImagesFromResult = (result: unknown): ExtractedImage[] => {
             });
           } else if (source.url) {
             images.push({
-              url: source.url,
+              url: sanitizeUrl(source.url),
               alt: "Image URL",
             });
           }
@@ -60,7 +81,7 @@ export const extractImagesFromResult = (result: unknown): ExtractedImage[] => {
     while ((match = markdownRegex.exec(result)) !== null) {
       images.push({
         alt: match[1] || "Markdown Image",
-        url: match[2],
+        url: sanitizeUrl(match[2]),
       });
     }
 
@@ -69,7 +90,7 @@ export const extractImagesFromResult = (result: unknown): ExtractedImage[] => {
     while ((match = htmlRegex.exec(result)) !== null) {
       images.push({
         alt: "HTML Image",
-        url: match[1],
+        url: sanitizeUrl(match[1]),
       });
     }
 
@@ -78,10 +99,11 @@ export const extractImagesFromResult = (result: unknown): ExtractedImage[] => {
     const jsonRegex = /"image_url"\s*:\s*"([^"]+)"/g;
     while ((match = jsonRegex.exec(result)) !== null) {
       // Basic filtering to avoid matching non-URL strings if the key is reused
-      if (match[1].startsWith("http") || match[1].startsWith("data:")) {
+      const cleaned = sanitizeUrl(match[1]);
+      if (cleaned.startsWith("http") || cleaned.startsWith("data:")) {
         images.push({
           alt: "JSON Image",
-          url: match[1],
+          url: cleaned,
         });
       }
     }

+ 6 - 1
knowhub/frontend/.claude/settings.local.json

@@ -6,7 +6,12 @@
       "Bash(git stash *)",
       "Bash(npx vite *)",
       "Skill(superpowers-chrome:browsing)",
-      "mcp__plugin_superpowers-chrome_chrome__use_browser"
+      "mcp__plugin_superpowers-chrome_chrome__use_browser",
+      "Bash(curl -sI -H \"Referer: \" \"https://mmbiz.qpic.cn/sz_mmbiz_jpg/xicicA5WtbWWWBkYQ7MTZZnV4paD82VtqW1pdHvicNibCA9cic5tSAmJLia8bmcXZrVUKe54qcOw5Nia9wc7w4V3npRbVcbzaIunSpYt5hmm3XS17U/640?wx_fmt=webp&from=appmsg\")",
+      "Bash(curl -sI -H \"Referer: http://localhost:3000/\" \"https://mmbiz.qpic.cn/sz_mmbiz_jpg/xicicA5WtbWWWBkYQ7MTZZnV4paD82VtqW1pdHvicNibCA9cic5tSAmJLia8bmcXZrVUKe54qcOw5Nia9wc7w4V3npRbVcbzaIunSpYt5hmm3XS17U/640?wx_fmt=webp&from=appmsg\")",
+      "Bash(curl -sI http://localhost:3000/)",
+      "Bash(curl -sI http://localhost:5173/)",
+      "Bash(curl -sI \"https://mmbiz.qpic.cn/sz_mmbiz_jpg/xicicA5WtbWWWBkYQ7MTZZnV4paD82VtqW1pdHvicNibCA9cic5tSAmJLia8bmcXZrVUKe54qcOw5Nia9wc7w4V3npRbVcbzaIunSpYt5hmm3XS17U/640?wx_fmt=webp&amp;from=appmsg\")"
     ]
   }
 }