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:
@@ -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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -253,12 +254,17 @@ async def process_document(
|
|||||||
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 = DocumentService(db)
|
||||||
success = await asyncio.to_thread(document_service.process_document, document.id)
|
document_service.delete_document_chunks(document.id)
|
||||||
|
|
||||||
|
# 处理文档(直接 await 异步方法)
|
||||||
|
document_service = DocumentService(db)
|
||||||
|
success = await document_service.process_document(document.id)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return {"message": "文档处理成功"}
|
return {"message": "文档处理成功"}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
"""文档加载器工厂"""
|
"""文档加载器工厂"""
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ RAG_SYSTEM_PROMPT = """你是一个专业的国土空间规划知识问答助手
|
|||||||
要求:
|
要求:
|
||||||
1. 综合评估上下文信息的相关性,优先采用与问题最相关的内容,不强行引用不相关的来源
|
1. 综合评估上下文信息的相关性,优先采用与问题最相关的内容,不强行引用不相关的来源
|
||||||
2. 回答中必须使用 [来源N] 格式(N为上下文中的来源编号)标注所引用的具体来源,例如"根据[来源3],国土空间规划..."
|
2. 回答中必须使用 [来源N] 格式(N为上下文中的来源编号)标注所引用的具体来源,例如"根据[来源3],国土空间规划..."
|
||||||
3. 回答要准确、专业、详细,结构清晰,逻辑性强
|
3. 如果上下文中包含[图片描述]内容且与问题相关,**必须**在回答中展示该图片。上下文中已有"图片URL: /images/..."字段,请直接复制该URL,使用Markdown语法引用:
|
||||||
4. 如果上下文中没有相关信息,请诚实说明
|

|
||||||
5. 在回答末尾,列出所有实际引用的参考来源,格式为:
|
例如:上下文中某来源包含"图片URL: /images/1/page3_img1.png",则插入:
|
||||||
|

|
||||||
|
4. 回答要准确、专业、详细,结构清晰,逻辑性强
|
||||||
|
5. 如果上下文中没有相关信息,请诚实说明
|
||||||
|
6. 在回答末尾,列出所有实际引用的参考来源,格式为:
|
||||||
**参考来源:**
|
**参考来源:**
|
||||||
- [来源N] 文档标题
|
- [来源N] 文档标题
|
||||||
6. 回答长度控制在500-1000字之间
|
7. 回答长度控制在500-1000字之间
|
||||||
|
|
||||||
请基于上述上下文信息回答用户的问题。"""
|
请基于上述上下文信息回答用户的问题。"""
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,8 +10,12 @@ 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:
|
||||||
"""文档处理服务"""
|
"""文档处理服务"""
|
||||||
@@ -21,13 +25,13 @@ class DocumentService:
|
|||||||
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,
|
||||||
@@ -43,12 +47,26 @@ class DocumentService:
|
|||||||
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
|
||||||
@@ -58,6 +76,77 @@ class DocumentService:
|
|||||||
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]]:
|
||||||
"""搜索文档(保留原有接口兼容性)"""
|
"""搜索文档(保留原有接口兼容性)"""
|
||||||
try:
|
try:
|
||||||
@@ -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()
|
||||||
|
|||||||
Generated
+19
-1
@@ -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"
|
||||||
|
|||||||
@@ -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 的错误
|
||||||
|
|||||||
@@ -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))}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user