From 184511f4eced2780dd600cf170d79e373f9da3a4 Mon Sep 17 00:00:00 2001
From: lacerate551 <128470311+lacerate551@users.noreply.github.com>
Date: Sun, 21 Jun 2026 00:52:33 +0800
Subject: [PATCH] =?UTF-8?q?fix(server-release):=20=E4=BB=8E=20main=20?=
=?UTF-8?q?=E5=90=8C=E6=AD=A5=E8=A1=A8=E6=A0=BC=E5=9B=BE=E7=89=87=E4=BF=AE?=
=?UTF-8?q?=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
纯逻辑同步,不涉及 API 格式变更:
1. chat_routes.py:
- 新增 _replace_table_image_placeholders(): 将答案中 [图片]
和 [N张图片] 占位符替换为  markdown 图片语法
- _filter_images_by_answer: 补充 [图片]/![ 图片意图检测
- import re 提升到模块级
2. manager.py:
- 跨页表格 HTML 合并修复: BS 解析后追加
行(原来直接
拼接两个 导致第二表行被 html_table_to_markdown 丢弃)
- 表头跳过逻辑改为对比第一行
- 新增 text_level/bbox/table_type/table_nest_level/sub_type 元数据
---
api/chat_routes.py | 61 ++++++++++++++++++++++++++++++++++++++++++++
knowledge/manager.py | 36 ++++++++++++++++++++++++--
2 files changed, 95 insertions(+), 2 deletions(-)
diff --git a/api/chat_routes.py b/api/chat_routes.py
index 40a4db1..a31a7f3 100644
--- a/api/chat_routes.py
+++ b/api/chat_routes.py
@@ -26,6 +26,7 @@ Example:
import json
import os
import queue
+import re
import threading
import time as _time
from typing import List, Dict, Any, Optional, Tuple
@@ -1244,6 +1245,62 @@ _FIGURE_ANSWER_KEYWORDS = frozenset([
])
+def _replace_table_image_placeholders(answer: str, images: List[Dict]) -> str:
+ """
+ 将回答中 markdown 表格内的图片占位符替换为实际图片语法。
+
+ LLM 看到的上下文里表格单元格图片以 [图片] 或 [N张图片](MinerU 解析产物)表示,
+ LLM 通常会原样输出,有时还会加上  格式。
+
+ 此函数按顺序将占位符映射到 images 列表中的 URL,
+ 生成  语法,前端 markdown-it 可直接渲染为
标签。
+
+ 处理的占位符格式:
+ - [图片] →  (单张)
+ -  →  (LLM 加了 ! 和假 URL)
+ - [3张图片] →   
+ -  → 同上 (LLM 加了前缀和假 URL)
+
+ Args:
+ answer: LLM 生成的回答文本
+ images: select_images + _filter_images_by_answer 后的最终图片列表
+
+ Returns:
+ 替换后的回答文本
+ """
+ if not images or '图片' not in answer:
+ return answer
+
+ img_iter = iter(images)
+ replaced_count = 0
+
+ def _replacer(match):
+ nonlocal replaced_count
+ n_str = match.group(1) # 数字部分,None 表示单张
+ n = int(n_str) if n_str else 1
+
+ parts = []
+ for _ in range(n):
+ try:
+ img = next(img_iter)
+ url = img.get('url', '')
+ img_id = img.get('id', 'img')
+ parts.append(f'')
+ replaced_count += 1
+ except StopIteration:
+ parts.append('[图片]') # 图片用完了
+ return ' '.join(parts)
+
+ # !? — 可选前导 !(LLM 有时加)
+ # \[(\d+)?张?图片\] — [图片] 或 [3张图片](张 可选)
+ # (?:\((?:图片URL|https?://[^)]+)\))? — 可选的 LLM 从上下文复制的假 URL
+ # 匹配 (图片URL) 或 (https://via.placeholder.com/150) 等
+ result = re.sub(r'!?\[(\d+)?张?图片\](?:\((?:图片URL|https?://[^)]+)\))?', _replacer, answer)
+ if replaced_count > 0:
+ logger.info(f"[图片占位符替换] 替换了 {replaced_count}/{len(images)} 个图片为 markdown 图片语法")
+ return result
+
+
def _filter_images_by_answer(selected_images: List[Dict], answer: str, query: str = "",
primary_sections: List[str] = None) -> List[Dict]:
"""
@@ -2623,6 +2680,10 @@ def rag():
# 9. 过滤敏感信息(违禁词等)
filtered_answer = filter_response(citation_result.get("answer_with_refs", clean_answer))
+ # 9.5 替换表格内 [图片] 占位符为 markdown 图片语法
+ if rich_media.get("images"):
+ filtered_answer = _replace_table_image_placeholders(filtered_answer, rich_media["images"])
+
# 9. 保存消息到会话(仅开发环境)
if session_id and session_repo_ref:
try:
diff --git a/knowledge/manager.py b/knowledge/manager.py
index 150d301..b5c8642 100644
--- a/knowledge/manager.py
+++ b/knowledge/manager.py
@@ -39,6 +39,7 @@ from pathlib import Path
import logging
import chromadb
+from bs4 import BeautifulSoup
# 从 base.py 导入基础类和常量
from .base import (
@@ -550,8 +551,39 @@ class KnowledgeBaseManager(
curr_html = getattr(current, 'table_html', '') or ''
next_html = getattr(next_chunk, 'table_html', '') or ''
if curr_html and next_html:
- # 合并两个表格的 HTML
- current.table_html = curr_html + '\n' + next_html
+ # 正确合并两个表格的 HTML:
+ # 将第二个表格的 行追加到第一个表格中
+ # (而非简单拼接两个 ,否则 html_table_to_markdown
+ # 的 soup.find('table') 只能找到第一个表格)
+ try:
+ soup1 = BeautifulSoup(curr_html, 'html.parser')
+ soup2 = BeautifulSoup(next_html, 'html.parser')
+ table1 = soup1.find('table')
+ table2 = soup2.find('table')
+ if table1 and table2:
+ # 从第二个表格提取数据行
+ next_rows = table2.find_all('tr')
+ # 跳过与第一个表格表头重复的行
+ # 对比第一行而非所有 th(find_all('th') 会匹配
+ # 整个表格的 th,无法与单行做列表比较)
+ first_row_t1 = table1.find('tr')
+ if first_row_t1 and next_rows:
+ row1_texts = [c.get_text(strip=True) for c in first_row_t1.find_all(['th', 'td'])]
+ row2_texts = [c.get_text(strip=True) for c in next_rows[0].find_all(['th', 'td'])]
+ if row1_texts and row2_texts and row1_texts == row2_texts:
+ next_rows = next_rows[1:]
+ logger.debug("跨页表格合并: 跳过了重复的表头行")
+ for row in next_rows:
+ table1.append(row)
+ current.table_html = str(soup1)
+ logger.debug(f"跨页表格 HTML 合并成功: 追加了 {len(next_rows)} 行")
+ else:
+ current.table_html = curr_html + '\n' + next_html
+ except Exception as e:
+ logger.warning(f"跨页表格 HTML 合并异常: {e},回退到简单拼接")
+ current.table_html = curr_html + '\n' + next_html
+ elif not curr_html and next_html:
+ current.table_html = next_html
# 合并 image_path 和嵌入图片到 images
curr_img = getattr(current, 'image_path', None)