ソースを参照

Merge remote-tracking branch 'refs/remotes/origin/main'

Talegorithm 9 時間 前
コミット
06e61f1c1c

+ 1 - 1
agent/cli/interactive.py

@@ -291,7 +291,7 @@ class InteractiveController:
         print("\n正在加载消息列表...")
 
         # 1. 获取所有消息
-        messages = await self.store.get_messages(trace_id)
+        messages = await self.store.get_trace_messages(trace_id)
         if not messages:
             print("❌ 没有找到任何消息")
             return

+ 1 - 0
agent/core/runner.py

@@ -163,6 +163,7 @@ BUILTIN_TOOLS = [
 
     # 搜索工具
     "search_posts",
+    "select_post",
     "get_search_suggestions",
 
     # 知识管理工具

+ 2 - 1
agent/skill/skills/core.md

@@ -73,7 +73,8 @@ goal(abandon="方案A需要Redis,环境没有")
 
 ## 信息调研
 
-你可以通过联网搜索工具`search_posts`获取来自Github、小红书、微信公众号、知乎等渠道的信息。对于需要深度交互的网页内容,使用浏览器工具进行操作。
+你可以通过联网搜索工具`search_posts`大概浏览来自Github、小红书、微信公众号、知乎等渠道的信息,并再使用`select_post`工具查看具体信息。
+对于需要深度交互的网页内容,使用浏览器工具进行操作。
 
 调研过程可能需要多次搜索,比如基于搜索结果中获得的启发或信息启动新的搜索,直到得到令人满意的答案。你可以使用`goal`工具管理搜索的过程,或者使用文档记录搜索的中间或最终结果。
 

+ 1 - 1
agent/skill/skills/research.md

@@ -5,7 +5,7 @@ description: 知识调研 - 根据目标和任务自动执行搜索,返回结
 
 ## 信息调研
 
-你可以通过联网搜索工具 `search_posts` 获取来自 Github、小红书、微信公众号、知乎等渠道的信息。对于需要深度交互的网页内容,使用浏览器工具进行操作
+你可以通过联网搜索工具`search_posts`大概浏览来自Github、小红书、微信公众号、知乎等渠道的信息,并再使用`select_post`工具查看具体信息
 
 ## 调研过程可能需要多次搜索,比如基于搜索结果中获得的启发或信息启动新的搜索,直到得到令人满意的答案。你可以使用 `goal` 工具管理搜索的过程,或者使用文档记录搜索的中间或最终结果。(可以着重参考browser的工具来辅助搜索)
 

+ 1 - 0
agent/tools/builtin/__init__.py

@@ -46,6 +46,7 @@ __all__ = [
     "agent",
     "evaluate",
     "search_posts",
+    "select_post",
     "get_search_suggestions",
     "sandbox_create_environment",
     "sandbox_run_shell",

+ 2 - 2
agent/tools/builtin/knowledge.py

@@ -145,8 +145,8 @@ async def knowledge_search(
             eval_data = item.get("eval", {})
             score = eval_data.get("score", 3)
             output_lines.append(f"\n### {idx}. [{item['id']}] (⭐ {score})")
-            output_lines.append(f"**任务**: {item['task'][:150]}...")
-            output_lines.append(f"**内容**: {item['content'][:200]}...")
+            output_lines.append(f"**任务**: {item['task']}")
+            output_lines.append(f"**内容**: {item['content']}")
 
         return ToolResult(
             title="✅ 知识检索成功",

+ 224 - 48
agent/tools/builtin/search.py

@@ -1,18 +1,24 @@
 """
 搜索工具模块
 
-提供帖子搜索和建议词搜索功能,支持多个渠道平台。
+提供帖子搜索、帖子详情查看和建议词搜索功能,支持多个渠道平台。
 
 主要功能:
-1. search_posts - 帖子搜索
-2. get_search_suggestions - 获取平台的搜索补全建议词
+1. search_posts - 帖子搜索(浏览模式:封面图+标题+内容截断)
+2. select_post - 帖子详情(从搜索结果中选取单个帖子的完整内容)
+3. get_search_suggestions - 获取平台的搜索补全建议词
 """
 
+import asyncio
+import base64
+import io
 import json
+import math
 from enum import Enum
-from typing import Any, Dict
+from typing import Any, Dict, List, Optional
 
 import httpx
+from PIL import Image, ImageDraw, ImageFont
 
 from agent.tools import tool, ToolResult
 
@@ -21,6 +27,118 @@ from agent.tools import tool, ToolResult
 BASE_URL = "http://aigc-channel.aiddit.com/aigc/channel"
 DEFAULT_TIMEOUT = 60.0
 
+# 搜索结果缓存,以序号为 key
+_search_cache: Dict[int, Dict[str, Any]] = {}
+
+# 拼接图配置
+THUMB_WIDTH = 250
+THUMB_HEIGHT = 250
+TEXT_HEIGHT = 80
+GRID_COLS = 5
+PADDING = 12
+BG_COLOR = (255, 255, 255)
+TEXT_COLOR = (30, 30, 30)
+INDEX_COLOR = (220, 60, 60)
+
+
+def _truncate_text(text: str, max_len: int = 14) -> str:
+    """截断文本,超出部分用省略号"""
+    return text[:max_len] + "..." if len(text) > max_len else text
+
+
+async def _download_image(client: httpx.AsyncClient, url: str) -> Optional[Image.Image]:
+    """下载单张图片,失败返回 None"""
+    try:
+        resp = await client.get(url, timeout=15.0)
+        resp.raise_for_status()
+        return Image.open(io.BytesIO(resp.content)).convert("RGB")
+    except Exception:
+        return None
+
+
+async def _build_collage(posts: List[Dict[str, Any]]) -> Optional[str]:
+    """
+    将帖子封面图+序号+标题拼接成网格图,返回 base64 编码的 PNG。
+    每个格子:序号 + 封面图 + 标题
+    """
+    if not posts:
+        return None
+
+    # 收集有封面图的帖子,记录原始序号
+    items = []
+    for idx, post in enumerate(posts):
+        imgs = post.get("images", [])
+        cover_url = imgs[0] if imgs else None
+        if cover_url:
+            items.append({
+                "url": cover_url,
+                "title": post.get("title", "") or "",
+                "index": idx + 1,
+            })
+    if not items:
+        return None
+
+    # 并发下载封面图
+    async with httpx.AsyncClient() as client:
+        tasks = [_download_image(client, item["url"]) for item in items]
+        downloaded = await asyncio.gather(*tasks)
+
+    # 过滤下载失败的
+    valid = [(item, img) for item, img in zip(items, downloaded) if img is not None]
+    if not valid:
+        return None
+
+    cols = min(GRID_COLS, len(valid))
+    rows = math.ceil(len(valid) / cols)
+    cell_w = THUMB_WIDTH + PADDING
+    cell_h = THUMB_HEIGHT + TEXT_HEIGHT + PADDING
+    canvas_w = cols * cell_w + PADDING
+    canvas_h = rows * cell_h + PADDING
+
+    canvas = Image.new("RGB", (canvas_w, canvas_h), BG_COLOR)
+    draw = ImageDraw.Draw(canvas)
+
+    # 尝试加载字体
+    try:
+        font_title = ImageFont.truetype("msyh.ttc", 16)
+        font_index = ImageFont.truetype("msyh.ttc", 32)
+    except Exception:
+        try:
+            font_title = ImageFont.truetype("arial.ttf", 16)
+            font_index = ImageFont.truetype("arial.ttf", 32)
+        except Exception:
+            font_title = ImageFont.load_default()
+            font_index = font_title
+
+    for item, img in valid:
+        idx = item["index"]
+        col = (idx - 1) % cols
+        row = (idx - 1) // cols
+        x = PADDING + col * cell_w
+        y = PADDING + row * cell_h
+
+        # 缩放封面图
+        thumb = img.resize((THUMB_WIDTH, THUMB_HEIGHT), Image.LANCZOS)
+        canvas.paste(thumb, (x, y))
+
+        # 左上角写序号(带背景)
+        index_text = f" {idx} "
+        bbox = draw.textbbox((0, 0), index_text, font=font_index)
+        tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
+        # 增加背景块的 padding,确保完全覆盖数字
+        pad_x, pad_y = 8, 6
+        draw.rectangle([x, y, x + tw + pad_x * 2, y + th + pad_y * 2], fill=INDEX_COLOR)
+        draw.text((x + pad_x, y + pad_y), index_text, fill=(255, 255, 255), font=font_index)
+
+        # 写标题
+        title_text = _truncate_text(item["title"], max_len=16)
+        draw.text((x, y + THUMB_HEIGHT + 6), title_text, fill=TEXT_COLOR, font=font_title)
+
+    # 转 base64
+    buf = io.BytesIO()
+    canvas.save(buf, format="PNG")
+    return base64.b64encode(buf.getvalue()).decode("utf-8")
+
 
 class PostSearchChannel(str, Enum):
     """
@@ -59,7 +177,6 @@ class SuggestSearchChannel(str, Enum):
                 "channel": "搜索渠道",
                 "cursor": "分页游标",
                 "max_count": "返回条数",
-                "include_images": "是否包含图片",
                 "content_type": "内容类型-视频/图文"
             }
         },
@@ -70,7 +187,6 @@ class SuggestSearchChannel(str, Enum):
                 "channel": "Search channel",
                 "cursor": "Pagination cursor",
                 "max_count": "Max results",
-                "include_images": "Include images",
                 "content_type": "content type-视频/图文"
             }
         }
@@ -80,14 +196,14 @@ async def search_posts(
     keyword: str,
     channel: str = "xhs",
     cursor: str = "0",
-    max_count: int = 5,
-    include_images: bool = False,
+    max_count: int = 20,
     content_type: str = ""
 ) -> ToolResult:
     """
-    帖子搜索
+    帖子搜索(浏览模式)
 
-    根据关键词在指定渠道平台搜索帖子内容。
+    根据关键词在指定渠道平台搜索帖子,返回封面图+标题+内容摘要,用于快速浏览。
+    如需查看某个帖子的完整内容,请使用 select_post 工具。
 
     Args:
         keyword: 搜索关键词
@@ -102,33 +218,15 @@ async def search_posts(
             - zhihu: 知乎
             - weibo: 微博
         cursor: 分页游标,默认为 "0"(第一页)
-        max_count: 返回的最大条数,默认为 5
-        include_images: 是否将帖子中的图片传给 LLM 查看,默认为 False
+        max_count: 返回的最大条数,默认为 20
         content_type:内容类型-视频/图文,默认不传为不限制类型
 
     Returns:
-        ToolResult 包含搜索结果:
-        {
-            "code": 0,                    # 状态码,0 表示成功
-            "message": "success",         # 状态消息
-            "data": [                     # 帖子列表
-                {
-                    "channel_content_id": "68dd03db000000000303beb2",  # 内容唯一ID
-                    "title": "",                                       # 标题
-                    "content_type": "note",                            # 内容类型
-                    "body_text": "",                                   # 正文内容
-                    "like_count": 127,                                 # 点赞数
-                    "publish_timestamp": 1759314907000,                # 发布时间戳(毫秒)
-                    "images": ["https://xxx.webp"],                    # 图片列表
-                    "videos": [],                                      # 视频列表
-                    "channel": "xhs",                                  # 来源渠道
-                    "link": "xxx"                                      # 原文链接
-                }
-            ]
-        }
+        ToolResult 包含搜索结果摘要列表(封面图+标题+内容截断),
+        可通过 channel_content_id 调用 select_post 查看完整内容。
     """
+    global _search_cache
     try:
-        # 处理 channel 参数,支持枚举和字符串
         channel_value = channel.value if isinstance(channel, PostSearchChannel) else channel
 
         url = f"{BASE_URL}/data"
@@ -149,27 +247,49 @@ async def search_posts(
             response.raise_for_status()
             data = response.json()
 
-        # 计算结果数量
-        result_count = len(data.get("data", []))
+        posts = data.get("data", [])
+
+        # 缓存完整结果(以序号为 key)
+        _search_cache.clear()
+        for idx, post in enumerate(posts):
+            _search_cache[idx + 1] = post
 
-        # 提取图片 URL 并构建 images 列表供 LLM 查看(仅当 include_images=True 时)
+        # 构建摘要列表(带序号)
+        summary_list = []
+        for idx, post in enumerate(posts):
+            body = post.get("body_text", "") or ""
+            summary_list.append({
+                "index": idx + 1,
+                "channel_content_id": post.get("channel_content_id"),
+                "title": post.get("title"),
+                "body_text": body[:100] + ("..." if len(body) > 100 else ""),
+                "like_count": post.get("like_count"),
+                "channel": post.get("channel"),
+                "link": post.get("link"),
+                "content_type": post.get("content_type"),
+                "publish_timestamp": post.get("publish_timestamp"),
+            })
+
+        # 拼接封面图网格
         images = []
-        if include_images:
-            for post in data.get("data", []):
-                for img_url in post.get("images", [])[:3]:  # 每个帖子最多取前3张图
-                    if img_url:
-                        images.append({
-                            "type": "url",
-                            "url": img_url
-                        })
-                # 限制总图片数量,避免过多
-                if len(images) >= 10:
-                    break
+        collage_b64 = await _build_collage(posts)
+        if collage_b64:
+            images.append({
+                "type": "base64",
+                "media_type": "image/png",
+                "data": collage_b64
+            })
+
+        output_data = {
+            "code": data.get("code"),
+            "message": data.get("message"),
+            "data": summary_list
+        }
 
         return ToolResult(
             title=f"搜索结果: {keyword} ({channel_value})",
-            output=json.dumps(data, ensure_ascii=False, indent=2),
-            long_term_memory=f"Searched '{keyword}' on {channel_value}, found {result_count} posts",
+            output=json.dumps(output_data, ensure_ascii=False, indent=2),
+            long_term_memory=f"Searched '{keyword}' on {channel_value}, found {len(posts)} posts. Use select_post(index) to view full details of a specific post.",
             images=images
         )
     except httpx.HTTPStatusError as e:
@@ -186,6 +306,62 @@ async def search_posts(
         )
 
 
+@tool(
+    display={
+        "zh": {
+            "name": "帖子详情",
+            "params": {
+                "index": "帖子序号"
+            }
+        },
+        "en": {
+            "name": "Select Post",
+            "params": {
+                "index": "Post index"
+            }
+        }
+    }
+)
+async def select_post(
+    index: int,
+) -> ToolResult:
+    """
+    查看帖子详情
+
+    从最近一次 search_posts 的搜索结果中,根据序号选取指定帖子并返回完整内容(全部正文、全部图片、视频等)。
+    需要先调用 search_posts 进行搜索。
+
+    Args:
+        index: 帖子序号,来自 search_posts 返回结果中的 index 字段(从 1 开始)
+
+    Returns:
+        ToolResult 包含该帖子的完整信息和所有图片。
+    """
+    post = _search_cache.get(index)
+    if not post:
+        return ToolResult(
+            title="未找到帖子",
+            output="",
+            error=f"未找到序号 {index} 的帖子,请先调用 search_posts 搜索。"
+        )
+
+    # 返回所有图片
+    images = []
+    for img_url in post.get("images", []):
+        if img_url:
+            images.append({
+                "type": "url",
+                "url": img_url
+            })
+
+    return ToolResult(
+        title=f"帖子详情 #{index}: {post.get('title', '')}",
+        output=json.dumps(post, ensure_ascii=False, indent=2),
+        long_term_memory=f"Viewed post detail #{index}: {post.get('title', '')}",
+        images=images
+    )
+
+
 @tool(
     display={
         "zh": {

+ 54 - 6
agent/trace/goal_models.py

@@ -200,6 +200,46 @@ class GoalTree:
             except ValueError:
                 return f"{parent_display}.?"
 
+    def _find_next_pending_goal(self, completed_goal_id: str) -> Optional[Goal]:
+        """
+        完成 goal 后,自动查找下一个应该执行的 pending goal
+
+        查找顺序:
+        1. 同级的下一个 pending goal
+        2. 父级的下一个 pending goal
+        3. 任意顶层 pending goal
+
+        Args:
+            completed_goal_id: 刚完成的 goal ID
+
+        Returns:
+            下一个 pending goal,如果没有则返回 None
+        """
+        completed_goal = self.find(completed_goal_id)
+        if not completed_goal:
+            return None
+
+        # 1. 查找同级的下一个 pending goal
+        siblings = self.get_children(completed_goal.parent_id)
+        found_current = False
+        for sibling in siblings:
+            if sibling.id == completed_goal_id:
+                found_current = True
+                continue
+            if found_current and sibling.status == "pending":
+                return sibling
+
+        # 2. 如果有父级,查找父级的下一个 pending goal
+        if completed_goal.parent_id:
+            return self._find_next_pending_goal(completed_goal.parent_id)
+
+        # 3. 查找任意顶层 pending goal
+        for goal in self.goals:
+            if goal.parent_id is None and goal.status == "pending":
+                return goal
+
+        return None
+
     def add_goals(
         self,
         descriptions: List[str],
@@ -300,7 +340,12 @@ class GoalTree:
 
         # 如果完成的是当前焦点,根据参数决定是否清除焦点
         if clear_focus and self.current_id == goal_id:
-            self.current_id = None
+            # 不直接清空,尝试自动切换到下一个 pending goal
+            next_goal = self._find_next_pending_goal(goal_id)
+            if next_goal:
+                self.current_id = next_goal.id
+            else:
+                self.current_id = None
 
         # 检查是否所有兄弟都完成了,如果是则自动完成父节点
         if goal.parent_id:
@@ -325,9 +370,13 @@ class GoalTree:
         goal.status = "abandoned"
         goal.summary = reason
 
-        # 如果放弃的是当前焦点,清除焦点
+        # 如果放弃的是当前焦点,尝试自动切换到下一个 pending goal
         if self.current_id == goal_id:
-            self.current_id = None
+            next_goal = self._find_next_pending_goal(goal_id)
+            if next_goal:
+                self.current_id = next_goal.id
+            else:
+                self.current_id = None
 
         return goal
 
@@ -405,9 +454,8 @@ class GoalTree:
                 result.append(f"{prefix}    📚 相关知识 ({len(goal.knowledge)} 条):")
                 for idx, k in enumerate(goal.knowledge[:3], 1):
                     k_id = k.get('id', 'N/A')
-                    # 将多行内容压缩为单行摘要
-                    k_content = k.get('content', '').replace('\n', ' ').strip()[:80]
-                    result.append(f"{prefix}       {idx}. [{k_id}] {k_content}...")
+                    k_content = k.get('content', '').strip()
+                    result.append(f"{prefix}       {idx}. [{k_id}] {k_content}")
 
             # 递归处理子目标
             children = self.get_children(goal.id)

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

@@ -13,7 +13,12 @@ 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);
@@ -30,6 +35,7 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
             key={idx}
             src={img.url}
             alt={img.alt || "Extracted"}
+            referrerPolicy="no-referrer"
             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)}
           />
@@ -52,6 +58,7 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
             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) => {
@@ -63,17 +70,21 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
     );
   };
 
-  const normalizeImageUrl = (raw: unknown) => String(raw).replace(/^[\s`'"]+|[\s`'"]+$/g, "");
+  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:"));
+    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;
@@ -87,11 +98,14 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
             <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) ||
@@ -106,12 +120,11 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
                 }
                 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>
                 );
               })}
@@ -121,100 +134,11 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
             <div className={styles.toolResult}>
               <div className={styles.toolName}>工具: {content.tool_name}</div>
               <div className={styles.resultContent}>
-                {(() => {
-                  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);
-                })()}
+                {typeof content.result === "string" ? (
+                  <ReactMarkdown>{content.result}</ReactMarkdown>
+                ) : (
+                  <pre>{JSON.stringify(content.result, null, 2)}</pre>
+                )}
               </div>
             </div>
           )}
@@ -240,32 +164,34 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
     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>
                 ))}
@@ -287,11 +213,7 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
     <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>
@@ -301,14 +223,17 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
             <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 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) ? (
               <>
@@ -355,7 +280,9 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
                 )}
                 <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}>
@@ -377,17 +304,16 @@ export const DetailPanel = ({ node, edge, messages = [], onClose }: DetailPanelP
           <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>
             ))}

+ 1 - 1
frontend/react-template/vite.config.ts

@@ -15,7 +15,7 @@ export default defineConfig({
     port: 3000,
     proxy: {
       "/api": {
-        target: "http://43.106.118.91:8000",
+        target: "http://43.106.118.91:3000",
         changeOrigin: true,
       },
     },