feat: multimodal RAG with PDF image extraction and display

Extract images from PDFs using pymupdf, generate descriptions via
Qwen3-VL-8B, store in ChromaDB alongside text chunks, and render
images in chat answers. Includes image proxy rewrite, force re-process
endpoint, and VLM API timeout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 12:14:40 +08:00
parent 3f12e96ea0
commit 4bb50ae9c1
15 changed files with 351 additions and 40 deletions
+2
View File
@@ -19,6 +19,7 @@ build/
node_modules/ node_modules/
.next/ .next/
out/ out/
*.tsbuildinfo
# 运行时数据 # 运行时数据
runtime/ runtime/
@@ -33,6 +34,7 @@ generated_images/
# 数据目录中的运行时文件(保留源文件如 .tex) # 数据目录中的运行时文件(保留源文件如 .tex)
data/database/ data/database/
data/knowledge_base/ data/knowledge_base/
data/images/
# LaTeX 中间文件 # LaTeX 中间文件
*.aux *.aux
+5
View File
@@ -197,6 +197,11 @@ if os.path.exists(settings.upload_dir):
if os.path.exists(settings.generated_images_dir): if os.path.exists(settings.generated_images_dir):
app.mount("/generated_images", StaticFiles(directory=settings.generated_images_dir), name="generated_images") app.mount("/generated_images", StaticFiles(directory=settings.generated_images_dir), name="generated_images")
# 挂载PDF图片提取目录
IMAGES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "images")
os.makedirs(IMAGES_DIR, exist_ok=True)
app.mount("/images", StaticFiles(directory=IMAGES_DIR), name="images")
# 根路径 # 根路径
@app.get("/") @app.get("/")
async def root(): async def root():
+1
View File
@@ -42,6 +42,7 @@ dependencies = [
"duckduckgo-search>=6.0.0", "duckduckgo-search>=6.0.0",
"docx2txt>=0.9", "docx2txt>=0.9",
"pypdf>=6.12.0", "pypdf>=6.12.0",
"pymupdf>=1.27.2.3",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+17 -11
View File
@@ -227,10 +227,11 @@ async def delete_document(
@router.post("/{document_id}/process") @router.post("/{document_id}/process")
async def process_document( async def process_document(
document_id: int, document_id: int,
force: bool = False,
current_user: str = Depends(get_current_user), current_user: str = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""处理文档(向量化)""" """处理文档(向量化)force=true 强制重新处理"""
try: try:
# 获取用户ID # 获取用户ID
from ..models.user import User from ..models.user import User
@@ -240,26 +241,31 @@ async def process_document(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在" detail="用户不存在"
) )
# 获取文档 # 获取文档
document = db.query(Document).filter( document = db.query(Document).filter(
Document.id == document_id, Document.id == document_id,
Document.user_id == user.id Document.user_id == user.id
).first() ).first()
if not document: if not document:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="文档不存在" detail="文档不存在"
) )
if document.is_processed: if document.is_processed and not force:
return {"message": "文档已经处理过了"} return {"message": "文档已经处理过了,使用 force=true 强制重新处理"}
# 处理文档 # 强制重新处理时,先删除已有的向量数据
if force and document.is_processed:
document_service = DocumentService(db)
document_service.delete_document_chunks(document.id)
# 处理文档(直接 await 异步方法)
document_service = DocumentService(db) document_service = DocumentService(db)
success = await asyncio.to_thread(document_service.process_document, document.id) success = await document_service.process_document(document.id)
if success: if success:
return {"message": "文档处理成功"} return {"message": "文档处理成功"}
else: else:
@@ -267,7 +273,7 @@ async def process_document(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="文档处理失败" detail="文档处理失败"
) )
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
+68 -1
View File
@@ -1,7 +1,8 @@
""" """
大模型API集成 — 支持 SiliconFlow 和 DeepSeek 官方 大模型API集成 — 支持 SiliconFlow 和 DeepSeek 官方 + 视觉模型
""" """
import os import os
import base64
from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
@@ -13,6 +14,17 @@ from ..core.config import get_settings
settings = get_settings() settings = get_settings()
# 图片描述提示词
IMAGE_DESCRIPTION_PROMPT = """你是一个国土空间规划专家。请详细描述这张PDF文档中的图片内容。
图片周围文字上下文(来自PDF页面):{context_text}
要求:
1. 说明图片类型(地图/规划图/图表/流程图/示意图/照片等)
2. 描述图片中的关键信息、数据和空间关系
3. 提取图中所有文字标注
4. 描述控制在200-300字"""
# DeepSeek 官方模型 ID 前缀(用于自动路由) # DeepSeek 官方模型 ID 前缀(用于自动路由)
DEEPSEEK_OFFICIAL_MODELS = { DEEPSEEK_OFFICIAL_MODELS = {
"deepseek-chat", "deepseek-chat",
@@ -150,6 +162,61 @@ class SiliconFlowLLM:
return messages return messages
async def describe_image(self, image_path: str, context_text: str = "") -> str:
"""使用VLM模型描述图片内容
Args:
image_path: 图片文件路径
context_text: 图片周围的PDF文本上下文
Returns:
图片的文字描述
"""
import asyncio
import time
# 读取图片并编码为base64
with open(image_path, "rb") as f:
image_data = base64.b64encode(f.read()).decode("utf-8")
# 检测图片格式
ext = os.path.splitext(image_path)[1].lower()
mime_map = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp"}
mime_type = mime_map.get(ext, "image/png")
prompt = IMAGE_DESCRIPTION_PROMPT.format(context_text=context_text[:600])
vision_model = "Qwen/Qwen3-VL-8B-Instruct"
api_key, base_url, _ = _resolve_provider(vision_model)
client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
max_retries = 3
for attempt in range(max_retries):
try:
response = await client.chat.completions.create(
model=vision_model,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{image_data}"}},
],
}],
max_tokens=600,
temperature=0.3,
timeout=90.0,
)
return response.choices[0].message.content or ""
except Exception as e:
print(f"[VLM] 描述失败 attempt={attempt+1}: {e}")
if attempt < max_retries - 1:
await asyncio.sleep(2 ** attempt)
return ""
# 全局LLM实例(使用默认模型) # 全局LLM实例(使用默认模型)
llm_client = SiliconFlowLLM() llm_client = SiliconFlowLLM()
+3 -1
View File
@@ -198,7 +198,9 @@ class RAGChain:
"filename": metadata.get("filename", "未知文件"), "filename": metadata.get("filename", "未知文件"),
"page": metadata.get("chunk_index", 0), "page": metadata.get("chunk_index", 0),
"score": metadata.get("score"), "score": metadata.get("score"),
"preview": doc.page_content "preview": doc.page_content,
"source_type": metadata.get("source_type", "rag"),
"image_url": metadata.get("image_url"),
}) })
return sources return sources
+57 -2
View File
@@ -1,8 +1,9 @@
""" """
LangChain 1.0 文档加载器封装 LangChain 1.0 文档加载器封装 + PDF图片提取
""" """
from typing import List, Optional from typing import List, Optional, Dict
from pathlib import Path from pathlib import Path
import fitz # pymupdf
from langchain_community.document_loaders import ( from langchain_community.document_loaders import (
PyPDFLoader, PyPDFLoader,
Docx2txtLoader, Docx2txtLoader,
@@ -11,6 +12,60 @@ from langchain_community.document_loaders import (
) )
from langchain_core.documents import Document from langchain_core.documents import Document
class PDFImageExtractor:
"""使用pymupdf从PDF中提取内嵌图片"""
@staticmethod
def extract_images(file_path: str, output_dir: str) -> List[dict]:
"""提取PDF中所有图片,返回图片元数据列表"""
Path(output_dir).mkdir(parents=True, exist_ok=True)
images = []
doc = fitz.open(file_path)
for page_num in range(len(doc)):
page = doc[page_num]
# 获取页面文本作为图片上下文
page_text = page.get_text("text")
# 提取页面内嵌图片
image_list = page.get_images(full=True)
for img_idx, img_info in enumerate(image_list):
xref = img_info[0]
try:
base_image = doc.extract_image(xref)
image_bytes = base_image["image"]
ext = base_image["ext"]
# 过滤太小的图片(图标、装饰元素等)
if len(image_bytes) < 2048:
continue
# 图片周围文本(取该页文字前后各300字作为上下文)
context_start = max(0, page_text.find(
page_text[:len(page_text)//2]) if len(page_text) > 600
else 0)
context_text = page_text[context_start:context_start+600].strip()
filename = f"page{page_num+1}_img{img_idx+1}.{ext}"
output_path = Path(output_dir) / filename
output_path.write_bytes(image_bytes)
images.append({
"path": str(output_path),
"filename": filename,
"page": page_num + 1,
"context_text": context_text,
"size": len(image_bytes),
})
except Exception as e:
print(f"[ImageExtractor] 提取图片失败 page={page_num+1} img={img_idx}: {e}")
continue
doc.close()
return images
class DocumentLoaderFactory: class DocumentLoaderFactory:
"""文档加载器工厂""" """文档加载器工厂"""
+8 -4
View File
@@ -13,12 +13,16 @@ RAG_SYSTEM_PROMPT = """你是一个专业的国土空间规划知识问答助手
要求: 要求:
1. 综合评估上下文信息的相关性,优先采用与问题最相关的内容,不强行引用不相关的来源 1. 综合评估上下文信息的相关性,优先采用与问题最相关的内容,不强行引用不相关的来源
2. 回答中必须使用 [来源N] 格式(N为上下文中的来源编号)标注所引用的具体来源,例如"根据[来源3],国土空间规划..." 2. 回答中必须使用 [来源N] 格式(N为上下文中的来源编号)标注所引用的具体来源,例如"根据[来源3],国土空间规划..."
3. 回答要准确、专业、详细,结构清晰,逻辑性强 3. 如果上下文中包含[图片描述]内容且与问题相关,**必须**在回答中展示该图片。上下文中已有"图片URL: /images/..."字段,请直接复制该URL,使用Markdown语法引用:
4. 如果上下文中没有相关信息,请诚实说明 ![图片描述](图片URL)
5. 在回答末尾,列出所有实际引用的参考来源,格式为 例如:上下文中某来源包含"图片URL: /images/1/page3_img1.png",则插入
![该图为XX规划图](/images/1/page3_img1.png)
4. 回答要准确、专业、详细,结构清晰,逻辑性强
5. 如果上下文中没有相关信息,请诚实说明
6. 在回答末尾,列出所有实际引用的参考来源,格式为:
**参考来源:** **参考来源:**
- [来源N] 文档标题 - [来源N] 文档标题
6. 回答长度控制在500-1000字之间 7. 回答长度控制在500-1000字之间
请基于上述上下文信息回答用户的问题。""" 请基于上述上下文信息回答用户的问题。"""
+11
View File
@@ -65,6 +65,17 @@ class VectorStore:
print(f"[DEBUG-VectorStore] 返回结果数量: {len(result)}") print(f"[DEBUG-VectorStore] 返回结果数量: {len(result)}")
return result return result
def delete_by_document_id(self, document_id: int) -> bool:
"""删除指定文档的所有向量数据"""
try:
self.vectorstore._collection.delete(
where={"document_id": document_id}
)
return True
except Exception as e:
print(f"删除向量数据失败: {str(e)}")
return False
def max_marginal_relevance_search( def max_marginal_relevance_search(
self, self,
query: str, query: str,
+108 -16
View File
@@ -1,8 +1,8 @@
""" """
文档处理服务(LangChain 1.0 文档处理服务(LangChain 1.0 + 多模态图片处理
""" """
import os import os
import hashlib import asyncio
from pathlib import Path from pathlib import Path
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,24 +10,28 @@ from langchain_core.documents import Document as LangChainDocument
from ..models.document import Document, DocumentChunk from ..models.document import Document, DocumentChunk
from ..rag.vector_store import get_vector_store from ..rag.vector_store import get_vector_store
from ..rag.document_loaders import DocumentLoaderFactory from ..rag.document_loaders import DocumentLoaderFactory, PDFImageExtractor
from ..rag.text_splitters import get_text_splitter from ..rag.text_splitters import get_text_splitter
from ..llm.siliconflow import get_llm_client
IMAGES_DIR = Path(__file__).parent.parent.parent.parent / "data" / "images"
class DocumentService: class DocumentService:
"""文档处理服务""" """文档处理服务"""
def __init__(self, db: Session): def __init__(self, db: Session):
self.db = db self.db = db
self.vector_store = get_vector_store() self.vector_store = get_vector_store()
async def process_document(self, document_id: int) -> bool: async def process_document(self, document_id: int) -> bool:
"""处理文档(使用LangChain 1.0""" """处理文档(使用LangChain 1.0 + 多模态图片处理"""
try: try:
document = self.db.query(Document).filter(Document.id == document_id).first() document = self.db.query(Document).filter(Document.id == document_id).first()
if not document: if not document:
return False return False
# 1. 使用LangChain加载文档 # 1. 使用LangChain加载文档(文本)
documents = DocumentLoaderFactory.load_document( documents = DocumentLoaderFactory.load_document(
file_path=document.file_path, file_path=document.file_path,
file_type=document.file_type, file_type=document.file_type,
@@ -38,25 +42,110 @@ class DocumentService:
"filename": document.filename "filename": document.filename
} }
) )
# 2. 使用中文优化的文本分割器 # 2. 使用中文优化的文本分割器
text_splitter = get_text_splitter(chunk_size=1000, chunk_overlap=200) text_splitter = get_text_splitter(chunk_size=1000, chunk_overlap=200)
splits = text_splitter.split_documents(documents) splits = text_splitter.split_documents(documents)
# 3. 添加到向量存储 # 3. PDF图片提取和描述(仅PDF文件)
success = self.vector_store.add_documents(splits) image_chunks = []
if document.file_type == ".pdf":
image_chunks = await self._process_pdf_images(
file_path=document.file_path,
document_id=document.id,
knowledge_base_id=document.knowledge_base_id,
title=document.title,
filename=document.filename
)
# 4. 将文本块和图片描述合并添加到向量存储
all_splits = splits + image_chunks
success = self.vector_store.add_documents(all_splits)
if success: if success:
document.is_processed = True document.is_processed = True
self.db.commit() self.db.commit()
print(f"[DocumentService] 文档 {document.filename} 处理完成: "
f"{len(splits)} 个文本块, {len(image_chunks)} 个图片描述块")
return True return True
return False return False
except Exception as e: except Exception as e:
print(f"处理文档失败: {str(e)}") print(f"处理文档失败: {str(e)}")
self.db.rollback() self.db.rollback()
return False return False
async def _process_pdf_images(
self,
file_path: str,
document_id: int,
knowledge_base_id: int,
title: str,
filename: str
) -> List[LangChainDocument]:
"""提取PDF图片并用VLM生成描述"""
image_chunks = []
try:
# 创建图片输出目录
img_output_dir = IMAGES_DIR / str(document_id)
# 提取图片
images = PDFImageExtractor.extract_images(str(file_path), str(img_output_dir))
if not images:
print(f"[DocumentService] 未发现可提取的图片: {filename}")
return []
print(f"[DocumentService] 提取到 {len(images)} 张图片, 开始VLM描述生成...")
# 批量调用VLM生成描述
llm_client = get_llm_client()
for idx, img in enumerate(images):
try:
description = await llm_client.describe_image(
img["path"],
img.get("context_text", "")
)
if description:
# 相对路径用于URL访问
rel_path = f"{document_id}/{img['filename']}"
image_url = f"/images/{rel_path}"
# 构建图片描述文本块(URL写入内容,LLM可直接引用)
chunk_content = (
f"[图片描述 - 第{img['page']}页]\n"
f"图片URL: {image_url}\n"
f"图片内容:{description}"
)
chunk = LangChainDocument(
page_content=chunk_content,
metadata={
"document_id": document_id,
"knowledge_base_id": knowledge_base_id,
"title": title,
"filename": filename,
"source_type": "image",
"image_path": str(img["path"]),
"image_url": image_url,
"page": img["page"],
}
)
image_chunks.append(chunk)
print(f"[DocumentService] 图片描述成功 {idx+1}/{len(images)}: {img['filename']}")
else:
print(f"[DocumentService] 图片描述为空 {idx+1}/{len(images)}: {img['filename']}")
except Exception as e:
print(f"[DocumentService] 图片处理失败 {img['filename']}: {e}")
continue
except Exception as e:
print(f"[DocumentService] PDF图片处理失败: {e}")
return image_chunks
def search_documents(self, query: str, knowledge_base_ids: Optional[List[int]] = None, limit: int = 5) -> List[Dict[str, Any]]: def search_documents(self, query: str, knowledge_base_ids: Optional[List[int]] = None, limit: int = 5) -> List[Dict[str, Any]]:
"""搜索文档(保留原有接口兼容性)""" """搜索文档(保留原有接口兼容性)"""
@@ -114,8 +203,11 @@ class DocumentService:
).order_by(DocumentChunk.chunk_index).all() ).order_by(DocumentChunk.chunk_index).all()
def delete_document_chunks(self, document_id: int) -> bool: def delete_document_chunks(self, document_id: int) -> bool:
"""删除文档的所有块""" """删除文档的所有块(数据库 + 向量存储)"""
try: try:
# 删除向量存储中的文档数据
self.vector_store.delete_by_document_id(document_id)
# 删除数据库中的chunk记录
self.db.query(DocumentChunk).filter( self.db.query(DocumentChunk).filter(
DocumentChunk.document_id == document_id DocumentChunk.document_id == document_id
).delete() ).delete()
+19 -1
View File
@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 2
requires-python = ">=3.12" requires-python = ">=3.12"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.13'", "python_full_version >= '3.13'",
@@ -443,6 +443,7 @@ dependencies = [
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pymupdf" },
{ name = "pypdf" }, { name = "pypdf" },
{ name = "pypdf2" }, { name = "pypdf2" },
{ name = "python-docx" }, { name = "python-docx" },
@@ -492,6 +493,7 @@ requires-dist = [
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.5.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.5.0" },
{ name = "pydantic-settings", specifier = ">=2.1.0" }, { name = "pydantic-settings", specifier = ">=2.1.0" },
{ name = "pymupdf", specifier = ">=1.27.2.3" },
{ name = "pypdf", specifier = ">=6.12.0" }, { name = "pypdf", specifier = ">=6.12.0" },
{ name = "pypdf2", specifier = ">=3.0.0" }, { name = "pypdf2", specifier = ">=3.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" },
@@ -2893,6 +2895,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
] ]
[[package]]
name = "pymupdf"
version = "1.27.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/32/708bedc9dde7b328d45abbc076091769d44f2f24ad151ad92d56a6ec142b/pymupdf-1.27.2.3.tar.gz", hash = "sha256:7a92faa25129e8bbec5e50eeb9214f187665428c31b05c4ef6e36c58c0b1c6d2", size = 85759618, upload-time = "2026-04-24T14:13:14.42Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/09/ddbdfa7ee91fbabd6f63d7d744884cbdfe3e7ff9b8604749fb38bddf5c5d/pymupdf-1.27.2.3-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc1bc3cae6e9e150b0dbb0a9221bdfd411d65f0db2fe359eaa22467d7cc2a05f", size = 24002636, upload-time = "2026-04-24T14:09:17.459Z" },
{ url = "https://files.pythonhosted.org/packages/01/89/3f8edd6c4f50ca370e2a2f2a3011face36f3760728ffe76dffec91c0fca0/pymupdf-1.27.2.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:660d93cb6da5bbddf11d3982ae27745dd3a9902d9f24cdb69adab83962294b5a", size = 23278238, upload-time = "2026-04-24T14:09:32.882Z" },
{ url = "https://files.pythonhosted.org/packages/c3/26/b7e5a70eb83bd189f8b5df87ec442746b992f2f632662839b288170d357d/pymupdf-1.27.2.3-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1dd460a3ae4597a755f00a3bd9771f5ebf1531dc111f6a36bf05dd00a6b84425", size = 24333923, upload-time = "2026-04-24T14:09:47.341Z" },
{ url = "https://files.pythonhosted.org/packages/e4/a0/aa1ee2240f29481a04a827c313333b4ecd8a14d6ac3e15d3f41a30574781/pymupdf-1.27.2.3-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:857842b4888827bd6155a1131341b2822a7ebe9a8c15a975fd7d490d7a64a30c", size = 24963198, upload-time = "2026-04-24T14:10:07.408Z" },
{ url = "https://files.pythonhosted.org/packages/69/49/4f742451f980840829fc00ba158bebb25d389c846d8f4f8c65936ee55de8/pymupdf-1.27.2.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:580983849c64a08d08344ca3d1580e87c01f046a8392421797bc850efd72a5b6", size = 25184609, upload-time = "2026-04-24T14:10:22.911Z" },
{ url = "https://files.pythonhosted.org/packages/f6/3f/3853d6608f394faf6eec2bd4e8ea9f6a00beea329b071abdb29f4164cc3d/pymupdf-1.27.2.3-cp310-abi3-win32.whl", hash = "sha256:a5c1088a87189891a4946ab314a14b7934ac4c5b6077f7e74ebee956f8906d0e", size = 18019286, upload-time = "2026-04-24T14:10:34.239Z" },
{ url = "https://files.pythonhosted.org/packages/44/47/5fb10fe73f96b31253a41647c362ea9e0380920bddf16028414a051247fc/pymupdf-1.27.2.3-cp310-abi3-win_amd64.whl", hash = "sha256:d20f68ef15195e073071dbc4ae7455257c7889af7584e39df490c0a92728526e", size = 19249102, upload-time = "2026-04-24T14:10:46.72Z" },
{ url = "https://files.pythonhosted.org/packages/53/a4/b9e91aac82293f9c954654c85581ee8212b5b05efadc534b581141241e6f/pymupdf-1.27.2.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:77691604c5d1d0233827139bbcdea61fd57879c84712b8e49b1f45520f7ab9c2", size = 25000393, upload-time = "2026-04-24T14:11:01.669Z" },
]
[[package]] [[package]]
name = "pypdf" name = "pypdf"
version = "6.12.0" version = "6.12.0"
+4
View File
@@ -32,6 +32,10 @@ const nextConfig = {
source: '/generated_images/:path*', source: '/generated_images/:path*',
destination: `${backendUrl}/generated_images/:path*`, destination: `${backendUrl}/generated_images/:path*`,
}, },
{
source: '/images/:path*',
destination: `${backendUrl}/images/:path*`,
},
]; ];
}, },
// 禁用静态生成,避免 SSR 时使用浏览器 API 的错误 // 禁用静态生成,避免 SSR 时使用浏览器 API 的错误
+16
View File
@@ -252,6 +252,22 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
td: ({ children }) => ( td: ({ children }) => (
<td className="border border-border px-2 py-1.5">{children}</td> <td className="border border-border px-2 py-1.5">{children}</td>
), ),
img: ({ src, alt }: any) => (
<a href={src} target="_blank" rel="noopener noreferrer" className="block my-3 group">
<img
src={src}
alt={alt || "图片来源"}
loading="lazy"
className="max-w-full max-h-80 rounded-lg border border-border/50 cursor-pointer
hover:border-primary/40 transition-colors object-contain bg-muted/20"
/>
{alt && (
<span className="block text-[11px] text-muted-foreground/70 mt-1 text-center">
{alt}
</span>
)}
</a>
),
}} }}
> >
{preprocessCitations(message.content, String(message.id))} {preprocessCitations(message.content, String(message.id))}
+30 -3
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Database, Globe, ChevronDown, ChevronRight, Star } from "lucide-react"; import { Database, Globe, ChevronDown, ChevronRight, Star, Image } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -13,7 +13,8 @@ interface SourceReference {
score?: number; score?: number;
preview: string; preview: string;
url?: string; url?: string;
source_type?: "web" | "rag"; source_type?: "web" | "rag" | "image";
image_url?: string;
} }
interface SourceReferencesProps { interface SourceReferencesProps {
@@ -188,7 +189,8 @@ export default function SourceReferences({
const showExpandButton = sources.length > maxSources; const showExpandButton = sources.length > maxSources;
const visibleSources = expanded ? sources : displaySources; const visibleSources = expanded ? sources : displaySources;
const ragSources = visibleSources.filter((s) => s.source_type !== "web"); const ragSources = visibleSources.filter((s) => s.source_type !== "web" && s.source_type !== "image");
const imageSources = visibleSources.filter((s) => s.source_type === "image");
const webSources = visibleSources.filter((s) => s.source_type === "web"); const webSources = visibleSources.filter((s) => s.source_type === "web");
const sourceSection = ( const sourceSection = (
@@ -218,6 +220,31 @@ export default function SourceReferences({
</div> </div>
)} )}
{imageSources.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
<Image className="h-3 w-3 text-purple-500" />
<span>
({imageSources.length}
{expanded && showExpandButton
? `/${sources.filter((s) => s.source_type === "image").length}`
: ""}
)
</span>
</div>
<div className="divide-y divide-border/30">
{imageSources.map((source, i) => (
<SourceRow
key={i}
source={source}
answerContent={answerContent}
messageId={messageId}
/>
))}
</div>
</div>
)}
{webSources.length > 0 && ( {webSources.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5"> <div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
+2 -1
View File
@@ -101,7 +101,8 @@ export interface SourceInfo {
score?: number; score?: number;
preview: string; preview: string;
url?: string; url?: string;
source_type?: "web" | "rag"; source_type?: "web" | "rag" | "image";
image_url?: string;
} }
export interface ChatResponse { export interface ChatResponse {