Compare commits
4 Commits
5f768e5c83
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6395ee3b49 | |||
| bbf6b921af | |||
| a6987ff996 | |||
| 3250e87b30 |
@@ -71,3 +71,6 @@ Desktop.ini
|
|||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
officefile
|
officefile
|
||||||
|
data/1法律
|
||||||
|
data/data_backup
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ dependencies = [
|
|||||||
"docx2txt>=0.9",
|
"docx2txt>=0.9",
|
||||||
"pypdf>=6.12.0",
|
"pypdf>=6.12.0",
|
||||||
"pymupdf>=1.27.2.3",
|
"pymupdf>=1.27.2.3",
|
||||||
|
"pdf2image>=1.17.0",
|
||||||
|
"opencv-python-headless>=4.13.0.92",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -61,6 +63,10 @@ packages = ["src"]
|
|||||||
[tool.uv]
|
[tool.uv]
|
||||||
required-version = ">=0.6.15"
|
required-version = ">=0.6.15"
|
||||||
|
|
||||||
|
[[tool.uv.index]]
|
||||||
|
url = "https://mirrors.aliyun.com/pypi/simple/"
|
||||||
|
default = true
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 88
|
line-length = 88
|
||||||
indent-width = 4
|
indent-width = 4
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""测试 Heron 版面检测 + 裁剪 + VLM 图片描述流程"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from src.rag.document_loaders import PDFImageExtractor
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent.parent # course-agent-od/
|
||||||
|
TEST_PDF = (
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "data" / "uploads" / "testuser" / "knowledge_bases" / "植物知识图谱"
|
||||||
|
/ "ab810d4a-b030-45c0-94d2-bd6e2a23053b.pdf"
|
||||||
|
)
|
||||||
|
OUTPUT_DIR = PROJECT_ROOT / "data" / "images" / "_test_pdf_extract"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract(max_pages: int | None = None):
|
||||||
|
"""阶段1:Heron 版面检测 + 裁剪"""
|
||||||
|
if not TEST_PDF.exists():
|
||||||
|
logger.error(f"测试PDF不存在: {TEST_PDF}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 清理旧输出
|
||||||
|
if OUTPUT_DIR.exists():
|
||||||
|
for f in OUTPUT_DIR.iterdir():
|
||||||
|
f.unlink()
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[阶段1] Heron 版面检测 + 裁剪测试")
|
||||||
|
print(f" PDF: {TEST_PDF.name}")
|
||||||
|
print(f" 输出: {OUTPUT_DIR}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
images = PDFImageExtractor.extract_images(
|
||||||
|
str(TEST_PDF), str(OUTPUT_DIR),
|
||||||
|
)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
print(" ❌ 未提取到任何图片")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if max_pages:
|
||||||
|
images = [img for img in images if img["page"] <= max_pages]
|
||||||
|
|
||||||
|
# 按页分组统计
|
||||||
|
from collections import defaultdict
|
||||||
|
by_page = defaultdict(list)
|
||||||
|
for img in images:
|
||||||
|
by_page[img["page"]].append(img)
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
fig_count = 0
|
||||||
|
full_count = 0
|
||||||
|
for page_num in sorted(by_page.keys()):
|
||||||
|
page_imgs = by_page[page_num]
|
||||||
|
for img in page_imgs:
|
||||||
|
path = Path(img["path"])
|
||||||
|
with Image.open(path) as pil:
|
||||||
|
w, h = pil.size
|
||||||
|
size_kb = img["size"] / 1024
|
||||||
|
total_size += img["size"]
|
||||||
|
is_full = "_full." in img["filename"]
|
||||||
|
tag = "FULL" if is_full else "FIG"
|
||||||
|
if is_full:
|
||||||
|
full_count += 1
|
||||||
|
else:
|
||||||
|
fig_count += 1
|
||||||
|
print(
|
||||||
|
f" page {img['page']:>3d} [{tag}] {img['filename']:<25s} "
|
||||||
|
f"{w}x{h} {size_kb:>7.1f} KB"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\n 汇总: {len(images)} 张 ({fig_count} 裁剪 + {full_count} 整页兜底), "
|
||||||
|
f"{total_size/1024/1024:.1f} MB, 耗时 {elapsed:.1f}s\n"
|
||||||
|
)
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
async def test_vlm(images: list[dict], max_images: int = 3):
|
||||||
|
"""阶段2:VLM图片描述"""
|
||||||
|
from src.llm.siliconflow import get_llm_client
|
||||||
|
|
||||||
|
# 优先选裁剪图
|
||||||
|
fig_images = [img for img in images if "_full." not in img["filename"]]
|
||||||
|
targets = (fig_images or images)[:max_images]
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"[阶段2] VLM图片描述测试 ({len(targets)} 张)")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
client = get_llm_client()
|
||||||
|
sem = asyncio.Semaphore(3)
|
||||||
|
|
||||||
|
async def describe_one(idx: int, img: dict):
|
||||||
|
async with sem:
|
||||||
|
t0 = time.time()
|
||||||
|
desc = await client.describe_image(img["path"], img.get("context_text", ""))
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
return idx, desc, elapsed
|
||||||
|
|
||||||
|
tasks = [describe_one(i, img) for i, img in enumerate(targets)]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
if isinstance(r, Exception):
|
||||||
|
print(f" ❌ 失败: {r}\n")
|
||||||
|
continue
|
||||||
|
idx, desc, elapsed = r
|
||||||
|
img = targets[idx]
|
||||||
|
print(f" --- page {img['page']} ({img['filename']}) {elapsed:.1f}s ---")
|
||||||
|
print(f" {desc}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="测试PDF图片提取")
|
||||||
|
parser.add_argument("--vlm", action="store_true", help="启用VLM图片描述")
|
||||||
|
parser.add_argument("--max-pages", type=int, default=None, help="限制提取页数")
|
||||||
|
parser.add_argument("--max-vlm", type=int, default=3, help="VLM描述最大图片数")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
images = test_extract(max_pages=args.max_pages)
|
||||||
|
|
||||||
|
if args.vlm and images:
|
||||||
|
asyncio.run(test_vlm(images, max_images=args.max_vlm))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+44
-112
@@ -48,6 +48,18 @@ class DocumentStats(BaseModel):
|
|||||||
file_types: dict
|
file_types: dict
|
||||||
|
|
||||||
|
|
||||||
|
def _check_document_access(document: Document, user) -> None:
|
||||||
|
"""检查用户是否有权访问该文档(自己的文档,或系统知识库且为admin)"""
|
||||||
|
if document.user_id == user.id:
|
||||||
|
return
|
||||||
|
if document.knowledge_base and document.knowledge_base.is_system and user.is_superuser:
|
||||||
|
return
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="文档不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload", response_model=DocumentUploadResponse, deprecated=True)
|
@router.post("/upload", response_model=DocumentUploadResponse, deprecated=True)
|
||||||
async def upload_document(
|
async def upload_document(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -72,29 +84,20 @@ async def get_documents(
|
|||||||
):
|
):
|
||||||
"""获取文档列表"""
|
"""获取文档列表"""
|
||||||
try:
|
try:
|
||||||
# 获取用户ID
|
|
||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
user = db.query(User).filter(User.username == current_user).first()
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="用户不存在"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取用户的文档
|
|
||||||
documents = db.query(Document).filter(
|
documents = db.query(Document).filter(
|
||||||
Document.user_id == user.id
|
Document.user_id == user.id
|
||||||
).offset(skip).limit(limit).all()
|
).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
DocumentResponse(
|
DocumentResponse(
|
||||||
id=doc.id,
|
id=doc.id, filename=doc.filename, title=doc.title,
|
||||||
filename=doc.filename,
|
file_size=doc.file_size, file_type=doc.file_type,
|
||||||
title=doc.title,
|
is_processed=doc.is_processed, is_public=doc.is_public,
|
||||||
file_size=doc.file_size,
|
|
||||||
file_type=doc.file_type,
|
|
||||||
is_processed=doc.is_processed,
|
|
||||||
is_public=doc.is_public,
|
|
||||||
created_at=doc.created_at.isoformat()
|
created_at=doc.created_at.isoformat()
|
||||||
)
|
)
|
||||||
for doc in documents
|
for doc in documents
|
||||||
@@ -103,10 +106,7 @@ async def get_documents(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取文档列表失败: {str(e)}")
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"获取文档列表失败: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{document_id}", response_model=DocumentResponse)
|
@router.get("/{document_id}", response_model=DocumentResponse)
|
||||||
@@ -117,45 +117,27 @@ async def get_document(
|
|||||||
):
|
):
|
||||||
"""获取单个文档信息"""
|
"""获取单个文档信息"""
|
||||||
try:
|
try:
|
||||||
# 获取用户ID
|
|
||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
user = db.query(User).filter(User.username == current_user).first()
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="用户不存在"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取文档
|
|
||||||
document = db.query(Document).filter(
|
|
||||||
Document.id == document_id,
|
|
||||||
Document.user_id == user.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
|
document = db.query(Document).filter(Document.id == document_id).first()
|
||||||
if not document:
|
if not document:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
_check_document_access(document, user)
|
||||||
detail="文档不存在"
|
|
||||||
)
|
|
||||||
|
|
||||||
return DocumentResponse(
|
return DocumentResponse(
|
||||||
id=document.id,
|
id=document.id, filename=document.filename, title=document.title,
|
||||||
filename=document.filename,
|
file_size=document.file_size, file_type=document.file_type,
|
||||||
title=document.title,
|
is_processed=document.is_processed, is_public=document.is_public,
|
||||||
file_size=document.file_size,
|
|
||||||
file_type=document.file_type,
|
|
||||||
is_processed=document.is_processed,
|
|
||||||
is_public=document.is_public,
|
|
||||||
created_at=document.created_at.isoformat()
|
created_at=document.created_at.isoformat()
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取文档信息失败: {str(e)}")
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"获取文档信息失败: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{document_id}")
|
@router.delete("/{document_id}")
|
||||||
@@ -166,36 +148,25 @@ async def delete_document(
|
|||||||
):
|
):
|
||||||
"""删除文档"""
|
"""删除文档"""
|
||||||
try:
|
try:
|
||||||
# 获取用户ID
|
|
||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
user = db.query(User).filter(User.username == current_user).first()
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="用户不存在"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取文档
|
|
||||||
document = db.query(Document).filter(
|
|
||||||
Document.id == document_id,
|
|
||||||
Document.user_id == user.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
|
document = db.query(Document).filter(Document.id == document_id).first()
|
||||||
if not document:
|
if not document:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
_check_document_access(document, user)
|
||||||
detail="文档不存在"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. 先删除向量数据和文档块
|
# 1. 删除向量数据和文档块
|
||||||
try:
|
try:
|
||||||
logger.info(f"开始删除文档向量数据: {document.filename} (ID: {document.id})")
|
logger.info(f"开始删除文档向量数据: {document.filename} (ID={document.id})")
|
||||||
document_service = DocumentService(db)
|
document_service = DocumentService(db)
|
||||||
vector_deleted = document_service.delete_document_chunks(document.id)
|
vector_deleted = document_service.delete_document_chunks(document.id, knowledge_base_id=document.knowledge_base_id)
|
||||||
if vector_deleted:
|
if vector_deleted:
|
||||||
logger.info(f"成功删除文档向量数据: {document.filename}")
|
logger.info(f"成功删除文档向量数据: {document.filename}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"警告:删除文档向量数据失败: {document.filename}")
|
logger.warning(f"删除文档向量数据失败: {document.filename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"删除向量数据时发生错误: {str(e)}", exc_info=True)
|
logger.error(f"删除向量数据时发生错误: {str(e)}", exc_info=True)
|
||||||
|
|
||||||
@@ -219,10 +190,7 @@ async def delete_document(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"删除文档失败: {str(e)}")
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"删除文档失败: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{document_id}/process")
|
@router.post("/{document_id}/process")
|
||||||
@@ -234,54 +202,35 @@ async def process_document(
|
|||||||
):
|
):
|
||||||
"""处理文档(向量化),force=true 强制重新处理"""
|
"""处理文档(向量化),force=true 强制重新处理"""
|
||||||
try:
|
try:
|
||||||
# 获取用户ID
|
|
||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
user = db.query(User).filter(User.username == current_user).first()
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="用户不存在"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取文档
|
|
||||||
document = db.query(Document).filter(
|
|
||||||
Document.id == document_id,
|
|
||||||
Document.user_id == user.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
|
document = db.query(Document).filter(Document.id == document_id).first()
|
||||||
if not document:
|
if not document:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
_check_document_access(document, user)
|
||||||
detail="文档不存在"
|
|
||||||
)
|
|
||||||
|
|
||||||
if document.is_processed and not force:
|
if document.is_processed and not force:
|
||||||
return {"message": "文档已经处理过了,使用 force=true 强制重新处理"}
|
return {"message": "文档已经处理过了,使用 force=true 强制重新处理"}
|
||||||
|
|
||||||
# 强制重新处理时,先删除已有的向量数据
|
|
||||||
if force and document.is_processed:
|
if force and document.is_processed:
|
||||||
document_service = DocumentService(db)
|
document_service = DocumentService(db)
|
||||||
document_service.delete_document_chunks(document.id)
|
document_service.delete_document_chunks(document.id, knowledge_base_id=document.knowledge_base_id)
|
||||||
|
|
||||||
# 处理文档(直接 await 异步方法)
|
|
||||||
document_service = DocumentService(db)
|
document_service = DocumentService(db)
|
||||||
success = await document_service.process_document(document.id)
|
success = await document_service.process_document(document.id)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return {"message": "文档处理成功"}
|
return {"message": "文档处理成功"}
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="文档处理失败")
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="文档处理失败"
|
|
||||||
)
|
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"处理文档失败: {str(e)}")
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"处理文档失败: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats/overview", response_model=DocumentStats)
|
@router.get("/stats/overview", response_model=DocumentStats)
|
||||||
@@ -291,27 +240,19 @@ async def get_document_stats(
|
|||||||
):
|
):
|
||||||
"""获取文档统计信息"""
|
"""获取文档统计信息"""
|
||||||
try:
|
try:
|
||||||
# 获取用户ID
|
|
||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
user = db.query(User).filter(User.username == current_user).first()
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="用户不存在"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 统计信息
|
|
||||||
total_documents = db.query(Document).filter(Document.user_id == user.id).count()
|
total_documents = db.query(Document).filter(Document.user_id == user.id).count()
|
||||||
processed_documents = db.query(Document).filter(
|
processed_documents = db.query(Document).filter(
|
||||||
Document.user_id == user.id,
|
Document.user_id == user.id, Document.is_processed == True
|
||||||
Document.is_processed == True
|
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
# 计算总大小
|
|
||||||
documents = db.query(Document).filter(Document.user_id == user.id).all()
|
documents = db.query(Document).filter(Document.user_id == user.id).all()
|
||||||
total_size = sum(doc.file_size for doc in documents)
|
total_size = sum(doc.file_size for doc in documents)
|
||||||
|
|
||||||
# 文件类型统计
|
|
||||||
file_types = {}
|
file_types = {}
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
file_type = doc.file_type
|
file_type = doc.file_type
|
||||||
@@ -327,13 +268,4 @@ async def get_document_stats(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取统计信息失败: {str(e)}")
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"获取统计信息失败: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import or_, and_
|
from sqlalchemy import or_, and_
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -131,10 +133,20 @@ async def create_knowledge_base(
|
|||||||
detail="用户不存在"
|
detail="用户不存在"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 检查知识库名称是否已存在
|
# 管理员创建的知识库标记为系统知识库
|
||||||
existing_kb = db.query(KnowledgeBase).filter(
|
is_system = user.is_superuser
|
||||||
KnowledgeBase.name == data.name
|
|
||||||
).first()
|
# 检查知识库名称是否在同域内已存在(系统知识库与用户知识库互不冲突)
|
||||||
|
if is_system:
|
||||||
|
existing_kb = db.query(KnowledgeBase).filter(
|
||||||
|
KnowledgeBase.name == data.name,
|
||||||
|
KnowledgeBase.is_system == True
|
||||||
|
).first()
|
||||||
|
else:
|
||||||
|
existing_kb = db.query(KnowledgeBase).filter(
|
||||||
|
KnowledgeBase.name == data.name,
|
||||||
|
KnowledgeBase.user_id == user.id
|
||||||
|
).first()
|
||||||
|
|
||||||
if existing_kb:
|
if existing_kb:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -142,9 +154,6 @@ async def create_knowledge_base(
|
|||||||
detail="知识库名称已存在"
|
detail="知识库名称已存在"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 管理员创建的知识库标记为系统知识库
|
|
||||||
is_system = user.is_superuser
|
|
||||||
|
|
||||||
# 创建知识库
|
# 创建知识库
|
||||||
knowledge_base = KnowledgeBase(
|
knowledge_base = KnowledgeBase(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
@@ -381,7 +390,7 @@ async def delete_knowledge_base(
|
|||||||
for document in documents:
|
for document in documents:
|
||||||
try:
|
try:
|
||||||
# 删除向量数据
|
# 删除向量数据
|
||||||
vector_deleted = document_service.delete_document_chunks(document.id)
|
vector_deleted = document_service.delete_document_chunks(document.id, knowledge_base_id=document.knowledge_base_id)
|
||||||
if vector_deleted:
|
if vector_deleted:
|
||||||
logger.info(f"成功删除文档向量数据: {document.filename}")
|
logger.info(f"成功删除文档向量数据: {document.filename}")
|
||||||
else:
|
else:
|
||||||
@@ -404,6 +413,22 @@ async def delete_knowledge_base(
|
|||||||
|
|
||||||
logger.info(f"文档清理完成: 成功 {success_count} 个,失败 {error_count} 个")
|
logger.info(f"文档清理完成: 成功 {success_count} 个,失败 {error_count} 个")
|
||||||
|
|
||||||
|
# 清理知识库目录
|
||||||
|
if knowledge_base.is_system:
|
||||||
|
kb_dir = Path(settings.knowledge_base_dir) / knowledge_base.name
|
||||||
|
else:
|
||||||
|
kb_dir = Path(settings.upload_dir) / user.username / "knowledge_bases" / knowledge_base.name
|
||||||
|
if kb_dir.exists():
|
||||||
|
shutil.rmtree(kb_dir)
|
||||||
|
logger.info(f"已删除知识库目录: {kb_dir}")
|
||||||
|
|
||||||
|
# 清理知识库对应的图片目录
|
||||||
|
from ..services.document_service import IMAGES_DIR
|
||||||
|
kb_img_dir = IMAGES_DIR / str(knowledge_base.id)
|
||||||
|
if kb_img_dir.exists():
|
||||||
|
shutil.rmtree(kb_img_dir)
|
||||||
|
logger.info(f"已删除知识库图片目录: {kb_img_dir}")
|
||||||
|
|
||||||
# 3. 删除知识库(级联删除文档记录)
|
# 3. 删除知识库(级联删除文档记录)
|
||||||
db.delete(knowledge_base)
|
db.delete(knowledge_base)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -498,8 +523,8 @@ async def upload_document_to_knowledge_base(
|
|||||||
save_dir = Path(settings.knowledge_base_dir) / knowledge_base.name
|
save_dir = Path(settings.knowledge_base_dir) / knowledge_base.name
|
||||||
source_type = "knowledge_base"
|
source_type = "knowledge_base"
|
||||||
else:
|
else:
|
||||||
# 用户知识库:保存到 uploads/{username}/
|
# 用户知识库:保存到 uploads/{username}/knowledge_bases/{kb_name}/
|
||||||
save_dir = Path(settings.upload_dir) / user.username
|
save_dir = Path(settings.upload_dir) / user.username / "knowledge_bases" / knowledge_base.name
|
||||||
source_type = "upload"
|
source_type = "upload"
|
||||||
|
|
||||||
save_dir.mkdir(parents=True, exist_ok=True)
|
save_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -740,6 +765,52 @@ async def reindex_document(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents/{document_id}/download")
|
||||||
|
async def download_document(
|
||||||
|
document_id: int,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""下载/查看知识库文档"""
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.username == current_user).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
|
document = db.query(Document).filter(Document.id == document_id).first()
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
|
||||||
|
|
||||||
|
# 权限检查:系统知识库所有人可访问,用户知识库仅限所有者
|
||||||
|
if not document.knowledge_base:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库不存在")
|
||||||
|
if not document.knowledge_base.is_system and document.knowledge_base.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此文档")
|
||||||
|
|
||||||
|
if not os.path.exists(document.file_path):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在")
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
|
mime_type, _ = mimetypes.guess_type(document.original_filename or document.file_path)
|
||||||
|
if not mime_type:
|
||||||
|
mime_type = "application/octet-stream"
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
document.file_path,
|
||||||
|
filename=document.original_filename,
|
||||||
|
media_type=mime_type,
|
||||||
|
content_disposition_type="inline"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"下载文档失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/documents/{document_id}", response_model=ReindexResponse)
|
@router.delete("/documents/{document_id}", response_model=ReindexResponse)
|
||||||
async def delete_knowledge_base_document(
|
async def delete_knowledge_base_document(
|
||||||
document_id: int,
|
document_id: int,
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ class Settings(BaseSettings):
|
|||||||
# 全局配置实例
|
# 全局配置实例
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
# 将 HF_ENDPOINT 写入环境变量,供 sentence_transformers/huggingface_hub 使用
|
||||||
|
if settings.hf_endpoint and not os.environ.get("HF_ENDPOINT"):
|
||||||
|
os.environ["HF_ENDPOINT"] = settings.hf_endpoint
|
||||||
|
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
"""获取配置实例"""
|
"""获取配置实例"""
|
||||||
|
|||||||
@@ -178,14 +178,24 @@ class SiliconFlowLLM:
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# 读取图片并编码为base64
|
# 读取图片并编码为base64,不支持的格式先转为PNG
|
||||||
with open(image_path, "rb") as f:
|
|
||||||
image_data = base64.b64encode(f.read()).decode("utf-8")
|
|
||||||
|
|
||||||
# 检测图片格式
|
|
||||||
ext = os.path.splitext(image_path)[1].lower()
|
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_map = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp"}
|
||||||
mime_type = mime_map.get(ext, "image/png")
|
|
||||||
|
if ext not in mime_map:
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
img = Image.open(image_path)
|
||||||
|
if img.mode in ("CMYK", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
image_data = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||||
|
mime_type = "image/png"
|
||||||
|
else:
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
image_data = base64.b64encode(f.read()).decode("utf-8")
|
||||||
|
mime_type = mime_map[ext]
|
||||||
|
|
||||||
prompt = IMAGE_DESCRIPTION_PROMPT.format(context_text=context_text[:600])
|
prompt = IMAGE_DESCRIPTION_PROMPT.format(context_text=context_text[:600])
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
LangChain 1.0 文档加载器封装 + PDF图片提取
|
LangChain 1.0 文档加载器封装 + PDF图片提取(Heron 版面检测 + 裁剪)
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import fitz # pymupdf
|
import fitz # pymupdf — 仅用于提取页面文本
|
||||||
|
from PIL import Image
|
||||||
|
from pdf2image import convert_from_path
|
||||||
from langchain_community.document_loaders import (
|
from langchain_community.document_loaders import (
|
||||||
PyPDFLoader,
|
PyPDFLoader,
|
||||||
Docx2txtLoader,
|
Docx2txtLoader,
|
||||||
@@ -17,55 +19,256 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class PDFImageExtractor:
|
class PDFImageExtractor:
|
||||||
"""使用pymupdf从PDF中提取内嵌图片"""
|
"""使用 pdf2image 渲染页面 + Heron RT-DETR 检测 Picture 区域 + 裁剪"""
|
||||||
|
|
||||||
|
# 渲染参数
|
||||||
|
DEFAULT_DPI = 250
|
||||||
|
DEFAULT_TARGET_WIDTH = 2500
|
||||||
|
JPEG_QUALITY = 90
|
||||||
|
CROP_PADDING = 10
|
||||||
|
|
||||||
|
# Heron 检测参数(参考 ZDTL config)
|
||||||
|
HERON_MODEL = "docling-project/docling-layout-heron"
|
||||||
|
PICTURE_THRESHOLD = 0.35
|
||||||
|
NMS_IOU = 0.3
|
||||||
|
MAX_AREA_RATIO = 0.45
|
||||||
|
MIN_SIZE_W = 100
|
||||||
|
MIN_SIZE_H = 80
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract_images(file_path: str, output_dir: str) -> List[dict]:
|
def _nms(boxes: list[dict], iou_threshold: float) -> list[dict]:
|
||||||
"""提取PDF中所有图片,返回图片元数据列表"""
|
"""IoU-based Non-Maximum Suppression"""
|
||||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
if not boxes:
|
||||||
images = []
|
return boxes
|
||||||
doc = fitz.open(file_path)
|
|
||||||
|
|
||||||
for page_num in range(len(doc)):
|
import torch
|
||||||
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):
|
bboxes = torch.tensor([b["bbox"] for b in boxes], dtype=torch.float32)
|
||||||
xref = img_info[0]
|
scores = torch.tensor([b["score"] for b in boxes])
|
||||||
try:
|
|
||||||
base_image = doc.extract_image(xref)
|
|
||||||
image_bytes = base_image["image"]
|
|
||||||
ext = base_image["ext"]
|
|
||||||
|
|
||||||
# 过滤太小的图片(图标、装饰元素等)
|
x1 = bboxes[:, 0]
|
||||||
if len(image_bytes) < 2048:
|
y1 = bboxes[:, 1]
|
||||||
continue
|
x2 = bboxes[:, 2]
|
||||||
|
y2 = bboxes[:, 3]
|
||||||
|
areas = (x2 - x1) * (y2 - y1)
|
||||||
|
|
||||||
# 图片周围文本(取该页文字前后各300字作为上下文)
|
_, order = scores.sort(descending=True)
|
||||||
context_start = max(0, page_text.find(
|
keep = []
|
||||||
page_text[:len(page_text)//2]) if len(page_text) > 600
|
while order.numel() > 0:
|
||||||
else 0)
|
if order.numel() == 1:
|
||||||
context_text = page_text[context_start:context_start+600].strip()
|
keep.append(order.item())
|
||||||
|
break
|
||||||
|
i = order[0].item()
|
||||||
|
keep.append(i)
|
||||||
|
|
||||||
filename = f"page{page_num+1}_img{img_idx+1}.{ext}"
|
xx1 = torch.max(x1[i], x1[order[1:]])
|
||||||
output_path = Path(output_dir) / filename
|
yy1 = torch.max(y1[i], y1[order[1:]])
|
||||||
output_path.write_bytes(image_bytes)
|
xx2 = torch.min(x2[i], x2[order[1:]])
|
||||||
|
yy2 = torch.min(y2[i], y2[order[1:]])
|
||||||
|
|
||||||
images.append({
|
inter = (xx2 - xx1).clamp(min=0) * (yy2 - yy1).clamp(min=0)
|
||||||
"path": str(output_path),
|
union = areas[i] + areas[order[1:]] - inter
|
||||||
"filename": filename,
|
iou = inter / union
|
||||||
"page": page_num + 1,
|
mask = iou <= iou_threshold
|
||||||
"context_text": context_text,
|
order = order[1:][mask]
|
||||||
"size": len(image_bytes),
|
|
||||||
})
|
return [boxes[i] for i in keep]
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"提取图片失败 page={page_num+1} img={img_idx}: {e}")
|
@staticmethod
|
||||||
|
def _detect_pictures(
|
||||||
|
pil_images: list,
|
||||||
|
model_name: str = None,
|
||||||
|
picture_threshold: float = None,
|
||||||
|
nms_iou: float = None,
|
||||||
|
max_area_ratio: float = None,
|
||||||
|
min_size_w: int = None,
|
||||||
|
min_size_h: int = None,
|
||||||
|
) -> dict[int, list[dict]]:
|
||||||
|
"""用 Heron RT-DETR 检测每页中的 Picture 区域
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{page_index: [{bbox: [x1,y1,x2,y2], score: float}, ...]}
|
||||||
|
"""
|
||||||
|
import torch
|
||||||
|
from transformers import RTDetrV2ForObjectDetection, RTDetrImageProcessor
|
||||||
|
|
||||||
|
model_name = model_name or PDFImageExtractor.HERON_MODEL
|
||||||
|
picture_threshold = picture_threshold or PDFImageExtractor.PICTURE_THRESHOLD
|
||||||
|
nms_iou = nms_iou or PDFImageExtractor.NMS_IOU
|
||||||
|
max_area_ratio = max_area_ratio or PDFImageExtractor.MAX_AREA_RATIO
|
||||||
|
min_size_w = min_size_w or PDFImageExtractor.MIN_SIZE_W
|
||||||
|
min_size_h = min_size_h or PDFImageExtractor.MIN_SIZE_H
|
||||||
|
|
||||||
|
logger.info(f"[ImageExtractor] 加载 Heron 模型: {model_name}")
|
||||||
|
processor = RTDetrImageProcessor.from_pretrained(
|
||||||
|
model_name, local_files_only=True
|
||||||
|
)
|
||||||
|
model = RTDetrV2ForObjectDetection.from_pretrained(
|
||||||
|
model_name, local_files_only=True
|
||||||
|
)
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
model.to(device)
|
||||||
|
model.eval()
|
||||||
|
logger.info(f"[ImageExtractor] 模型加载完成, device={device}")
|
||||||
|
|
||||||
|
results: dict[int, list[dict]] = {}
|
||||||
|
|
||||||
|
for idx, pil_img in enumerate(pil_images):
|
||||||
|
img = pil_img.convert("RGB")
|
||||||
|
W, H = img.size
|
||||||
|
inputs = processor(images=img, return_tensors="pt").to(device)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = model(**inputs)
|
||||||
|
|
||||||
|
detections = processor.post_process_object_detection(
|
||||||
|
outputs, threshold=0.1, target_sizes=[(H, W)]
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
pictures = []
|
||||||
|
for score, label, box in zip(
|
||||||
|
detections["scores"], detections["labels"], detections["boxes"]
|
||||||
|
):
|
||||||
|
s = score.item()
|
||||||
|
l = label.item()
|
||||||
|
x1, y1, x2, y2 = box.tolist()
|
||||||
|
area = (x2 - x1) * (y2 - y1) / (W * H)
|
||||||
|
w, h = x2 - x1, y2 - y1
|
||||||
|
|
||||||
|
if area > max_area_ratio or w < min_size_w or h < min_size_h:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
doc.close()
|
cls = model.config.id2label.get(l, str(l))
|
||||||
|
if cls == "picture" and s >= picture_threshold:
|
||||||
|
pictures.append({
|
||||||
|
"bbox": [round(v, 1) for v in [x1, y1, x2, y2]],
|
||||||
|
"score": round(s, 3),
|
||||||
|
})
|
||||||
|
|
||||||
|
# NMS + 按位置排序
|
||||||
|
pictures = PDFImageExtractor._nms(pictures, nms_iou)
|
||||||
|
pictures.sort(key=lambda b: (b["bbox"][1] // 200, b["bbox"][0]))
|
||||||
|
results[idx] = pictures
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[ImageExtractor] page {idx + 1}: "
|
||||||
|
f"{len(pictures)} pictures detected"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_images(
|
||||||
|
file_path: str,
|
||||||
|
output_dir: str,
|
||||||
|
dpi: int = None,
|
||||||
|
target_width: int = None,
|
||||||
|
) -> List[dict]:
|
||||||
|
"""将PDF每页渲染为图片,用 Heron 检测并裁剪出图表区域
|
||||||
|
|
||||||
|
若某页无检测到 Picture,则保存整页图作为兜底。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: PDF文件路径
|
||||||
|
output_dir: 图片输出目录
|
||||||
|
dpi: 渲染DPI(默认250)
|
||||||
|
target_width: 缩放目标宽度像素(默认2500)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
图片元数据列表,每个dict包含 path, filename, page, context_text, size
|
||||||
|
"""
|
||||||
|
dpi = dpi or PDFImageExtractor.DEFAULT_DPI
|
||||||
|
target_width = target_width or PDFImageExtractor.DEFAULT_TARGET_WIDTH
|
||||||
|
quality = PDFImageExtractor.JPEG_QUALITY
|
||||||
|
padding = PDFImageExtractor.CROP_PADDING
|
||||||
|
|
||||||
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Phase 1: pymupdf 提取页面文本
|
||||||
|
page_texts: list[str] = []
|
||||||
|
try:
|
||||||
|
doc = fitz.open(file_path)
|
||||||
|
for page_num in range(len(doc)):
|
||||||
|
page_texts.append(doc[page_num].get_text("text"))
|
||||||
|
doc.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ImageExtractor] pymupdf文本提取失败: {e}")
|
||||||
|
|
||||||
|
# Phase 2: pdf2image 渲染页面
|
||||||
|
try:
|
||||||
|
pil_images = convert_from_path(file_path, dpi=dpi, fmt="jpeg")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[ImageExtractor] pdf2image渲染失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Phase 3: 缩放页面图
|
||||||
|
scaled_images = []
|
||||||
|
for pil_img in pil_images:
|
||||||
|
w, h = pil_img.size
|
||||||
|
if w > target_width:
|
||||||
|
ratio = target_width / w
|
||||||
|
new_h = int(h * ratio)
|
||||||
|
pil_img = pil_img.resize(
|
||||||
|
(target_width, new_h), Image.Resampling.LANCZOS
|
||||||
|
)
|
||||||
|
scaled_images.append(pil_img)
|
||||||
|
|
||||||
|
# Phase 4: Heron 检测 Picture 区域
|
||||||
|
try:
|
||||||
|
detections = PDFImageExtractor._detect_pictures(scaled_images)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[ImageExtractor] Heron检测失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Phase 5: 裁剪 + 保存
|
||||||
|
images = []
|
||||||
|
for page_idx, pil_img in enumerate(scaled_images):
|
||||||
|
page_num = page_idx + 1
|
||||||
|
W, H = pil_img.size
|
||||||
|
context_text = ""
|
||||||
|
if page_idx < len(page_texts):
|
||||||
|
context_text = page_texts[page_idx][:600].strip()
|
||||||
|
|
||||||
|
pics = detections.get(page_idx, [])
|
||||||
|
|
||||||
|
if pics:
|
||||||
|
# 裁剪检测到的 Picture 区域
|
||||||
|
for fig_idx, pic in enumerate(pics):
|
||||||
|
try:
|
||||||
|
x1, y1, x2, y2 = pic["bbox"]
|
||||||
|
x1 = max(0, int(x1) - padding)
|
||||||
|
y1 = max(0, int(y1) - padding)
|
||||||
|
x2 = min(W, int(x2) + padding)
|
||||||
|
y2 = min(H, int(y2) + padding)
|
||||||
|
|
||||||
|
crop = pil_img.crop((x1, y1, x2, y2))
|
||||||
|
filename = f"page{page_num}_fig{fig_idx + 1}.jpg"
|
||||||
|
output_path = Path(output_dir) / filename
|
||||||
|
crop.save(str(output_path), format="JPEG", quality=quality)
|
||||||
|
|
||||||
|
images.append({
|
||||||
|
"path": str(output_path),
|
||||||
|
"filename": filename,
|
||||||
|
"page": page_num,
|
||||||
|
"context_text": context_text,
|
||||||
|
"size": output_path.stat().st_size,
|
||||||
|
})
|
||||||
|
logger.info(
|
||||||
|
f"[ImageExtractor] 裁剪 page={page_num} "
|
||||||
|
f"fig={fig_idx + 1} bbox={pic['bbox']} "
|
||||||
|
f"size={images[-1]['size']}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[ImageExtractor] 裁剪失败 page={page_num} "
|
||||||
|
f"fig={fig_idx + 1}: {e}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[ImageExtractor] 完成: {len(images)} 张图片 from {file_path}"
|
||||||
|
)
|
||||||
return images
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
文档处理服务(LangChain 1.0 + 多模态图片处理)
|
文档处理服务(LangChain 1.0 + 多模态图片处理)
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -91,8 +92,8 @@ class DocumentService:
|
|||||||
"""提取PDF图片并用VLM生成描述"""
|
"""提取PDF图片并用VLM生成描述"""
|
||||||
image_chunks = []
|
image_chunks = []
|
||||||
try:
|
try:
|
||||||
# 创建图片输出目录
|
# 创建图片输出目录: images/{knowledge_base_id}/{document_id}/
|
||||||
img_output_dir = IMAGES_DIR / str(document_id)
|
img_output_dir = IMAGES_DIR / str(knowledge_base_id) / str(document_id)
|
||||||
|
|
||||||
# 提取图片
|
# 提取图片
|
||||||
images = PDFImageExtractor.extract_images(str(file_path), str(img_output_dir))
|
images = PDFImageExtractor.extract_images(str(file_path), str(img_output_dir))
|
||||||
@@ -115,7 +116,7 @@ class DocumentService:
|
|||||||
img.get("context_text", "")
|
img.get("context_text", "")
|
||||||
)
|
)
|
||||||
if description:
|
if description:
|
||||||
rel_path = f"{document_id}/{img['filename']}"
|
rel_path = f"{knowledge_base_id}/{document_id}/{img['filename']}"
|
||||||
image_url = f"/images/{rel_path}"
|
image_url = f"/images/{rel_path}"
|
||||||
chunk_content = (
|
chunk_content = (
|
||||||
f"[图片描述 - 第{img['page']}页]\n"
|
f"[图片描述 - 第{img['page']}页]\n"
|
||||||
@@ -193,8 +194,8 @@ class DocumentService:
|
|||||||
DocumentChunk.document_id == document_id
|
DocumentChunk.document_id == document_id
|
||||||
).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, knowledge_base_id: int = None) -> bool:
|
||||||
"""删除文档的所有块(数据库 + 向量存储)"""
|
"""删除文档的所有块(数据库 + 向量存储 + 图片目录)"""
|
||||||
try:
|
try:
|
||||||
# 删除向量存储中的文档数据
|
# 删除向量存储中的文档数据
|
||||||
self.vector_store.delete_by_document_id(document_id)
|
self.vector_store.delete_by_document_id(document_id)
|
||||||
@@ -203,6 +204,25 @@ class DocumentService:
|
|||||||
DocumentChunk.document_id == document_id
|
DocumentChunk.document_id == document_id
|
||||||
).delete()
|
).delete()
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
|
# 清理提取的图片目录: images/{kb_id}/{doc_id}/
|
||||||
|
if knowledge_base_id:
|
||||||
|
img_dir = IMAGES_DIR / str(knowledge_base_id) / str(document_id)
|
||||||
|
else:
|
||||||
|
# 兼容旧数据:尝试查找
|
||||||
|
for kb_subdir in IMAGES_DIR.iterdir():
|
||||||
|
if not kb_subdir.is_dir():
|
||||||
|
continue
|
||||||
|
candidate = kb_subdir / str(document_id)
|
||||||
|
if candidate.exists():
|
||||||
|
img_dir = candidate
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
img_dir = None
|
||||||
|
if img_dir and img_dir.exists():
|
||||||
|
shutil.rmtree(img_dir)
|
||||||
|
logger.info(f"已清理图片目录: {img_dir}")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"删除文档块失败: {str(e)}")
|
logger.error(f"删除文档块失败: {str(e)}")
|
||||||
|
|||||||
@@ -398,15 +398,34 @@ class KnowledgeBaseService:
|
|||||||
if not document:
|
if not document:
|
||||||
return {"success": False, "message": "文档不存在"}
|
return {"success": False, "message": "文档不存在"}
|
||||||
|
|
||||||
|
# 删除向量数据
|
||||||
|
try:
|
||||||
|
from ..rag.vector_store import get_vector_store
|
||||||
|
vector_store = get_vector_store()
|
||||||
|
vector_store.delete_by_document_id(document_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"删除向量数据失败: {e}")
|
||||||
|
|
||||||
# 删除文档块
|
# 删除文档块
|
||||||
self.db.query(DocumentChunk).filter(DocumentChunk.document_id == document_id).delete()
|
self.db.query(DocumentChunk).filter(DocumentChunk.document_id == document_id).delete()
|
||||||
|
|
||||||
|
# 删除图片目录
|
||||||
|
try:
|
||||||
|
from .document_service import IMAGES_DIR
|
||||||
|
kb_id = document.knowledge_base_id
|
||||||
|
img_dir = IMAGES_DIR / str(kb_id) / str(document_id)
|
||||||
|
if img_dir.exists():
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(img_dir)
|
||||||
|
logger.info(f"已清理图片目录: {img_dir}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"清理图片目录失败: {e}")
|
||||||
|
|
||||||
# 删除文档记录
|
# 删除文档记录
|
||||||
self.db.delete(document)
|
self.db.delete(document)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
return {"success": True, "message": "文档删除成功"}
|
return {"success": True, "message": "文档删除成功"}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
return {"success": False, "message": str(e)}
|
return {"success": False, "message": str(e)}
|
||||||
|
|||||||
Generated
+2610
-2576
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ import {
|
|||||||
Settings
|
Settings
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { formatFileSize, formatDate } from "@/lib/utils";
|
import { formatFileSize, formatDate } from "@/lib/utils";
|
||||||
import { knowledgeBaseAPI } from "@/lib/api";
|
import { knowledgeBaseAPI, resolveImageUrl } from "@/lib/api";
|
||||||
import { KnowledgeBaseDetail, Document } from "@/types";
|
import { KnowledgeBaseDetail, Document } from "@/types";
|
||||||
|
|
||||||
export default function KnowledgeBaseDetailPage() {
|
export default function KnowledgeBaseDetailPage() {
|
||||||
@@ -155,11 +155,28 @@ export default function KnowledgeBaseDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewDocument = (doc: Document) => {
|
const handleViewDocument = async (doc: Document) => {
|
||||||
// Use the file_path from the document to construct the download URL
|
try {
|
||||||
// Since backend serves static files from /uploads, we can use the file_path directly
|
const token = localStorage.getItem("auth_token");
|
||||||
const fileUrl = `/api/uploads/${doc.filename}`;
|
const res = await fetch(resolveImageUrl(`/knowledge-bases/documents/${doc.id}/download`), {
|
||||||
window.open(fileUrl, '_blank');
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("获取文档失败");
|
||||||
|
const blob = await res.blob();
|
||||||
|
|
||||||
|
const disposition = res.headers.get("Content-Disposition");
|
||||||
|
let filename = `${doc.title}${doc.file_type}`;
|
||||||
|
if (disposition) {
|
||||||
|
const match = disposition.match(/filename\*?=(?:UTF-8'')?(.+)/i);
|
||||||
|
if (match) filename = decodeURIComponent(match[1].replace(/["']/g, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
|
} catch {
|
||||||
|
window.open(resolveImageUrl(`/knowledge-bases/documents/${doc.id}/download`), '_blank');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredDocuments = knowledgeBase?.documents.filter(doc =>
|
const filteredDocuments = knowledgeBase?.documents.filter(doc =>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Loader2, Download, Copy, Upload, Edit3, Wand2, Expand, Palette, Image as ImageIcon, X } from "lucide-react";
|
import { Loader2, Download, Copy, Upload, Edit3, Wand2, Expand, Palette, Image as ImageIcon, X } from "lucide-react";
|
||||||
import { imageAPI } from "@/lib/api";
|
import { imageAPI, resolveImageUrl } from "@/lib/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface EditResult {
|
interface EditResult {
|
||||||
@@ -312,14 +312,22 @@ export default function ImageToImagePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = (imageUrl: string, imageId: string) => {
|
const handleDownload = async (imageUrl: string, imageId: string) => {
|
||||||
const link = document.createElement("a");
|
try {
|
||||||
link.href = imageUrl;
|
const res = await fetch(imageUrl);
|
||||||
link.download = `edited-image-${imageId}.png`;
|
const blob = await res.blob();
|
||||||
document.body.appendChild(link);
|
const url = URL.createObjectURL(blob);
|
||||||
link.click();
|
const link = document.createElement("a");
|
||||||
document.body.removeChild(link);
|
link.href = url;
|
||||||
toast.success("图像下载成功");
|
link.download = `edited-image-${imageId}.png`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success("图像下载成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("图像下载失败");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -595,7 +603,7 @@ export default function ImageToImagePage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<img
|
<img
|
||||||
src={editResult.url}
|
src={resolveImageUrl(editResult.url)}
|
||||||
alt="Edited image"
|
alt="Edited image"
|
||||||
className="w-full h-64 object-cover rounded-lg"
|
className="w-full h-64 object-cover rounded-lg"
|
||||||
/>
|
/>
|
||||||
@@ -604,7 +612,7 @@ export default function ImageToImagePage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => handleDownload(editResult.url, editResult.id)}
|
onClick={() => handleDownload(resolveImageUrl(editResult.url), editResult.id)}
|
||||||
>
|
>
|
||||||
<Download className="h-3 w-3" />
|
<Download className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -648,7 +656,7 @@ export default function ImageToImagePage() {
|
|||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img
|
<img
|
||||||
src={variation.url}
|
src={resolveImageUrl(variation.url)}
|
||||||
alt={`Variation ${index + 1}`}
|
alt={`Variation ${index + 1}`}
|
||||||
className="w-full h-48 object-cover rounded-lg"
|
className="w-full h-48 object-cover rounded-lg"
|
||||||
/>
|
/>
|
||||||
@@ -657,7 +665,7 @@ export default function ImageToImagePage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => handleDownload(variation.url, variation.id)}
|
onClick={() => handleDownload(resolveImageUrl(variation.url), variation.id)}
|
||||||
>
|
>
|
||||||
<Download className="h-3 w-3" />
|
<Download className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
X
|
X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import LoadingSpinner from "@/components/ui/loading-spinner";
|
import LoadingSpinner from "@/components/ui/loading-spinner";
|
||||||
import { imageAPI } from "@/lib/api";
|
import { imageAPI, resolveImageUrl } from "@/lib/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
@@ -350,14 +350,22 @@ export default function SpatialPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = (imageUrl: string, imageId: string) => {
|
const handleDownload = async (imageUrl: string, imageId: string) => {
|
||||||
const link = document.createElement("a");
|
try {
|
||||||
link.href = imageUrl;
|
const res = await fetch(imageUrl);
|
||||||
link.download = `image-${imageId}.png`;
|
const blob = await res.blob();
|
||||||
document.body.appendChild(link);
|
const url = URL.createObjectURL(blob);
|
||||||
link.click();
|
const link = document.createElement("a");
|
||||||
document.body.removeChild(link);
|
link.href = url;
|
||||||
toast.success("图像下载成功");
|
link.download = `image-${imageId}.png`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success("图像下载成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("图像下载失败");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
@@ -580,7 +588,7 @@ export default function SpatialPage() {
|
|||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="aspect-square relative bg-muted">
|
<div className="aspect-square relative bg-muted">
|
||||||
<img
|
<img
|
||||||
src={image.url}
|
src={resolveImageUrl(image.url)}
|
||||||
alt={`Generated image ${index + 1}`}
|
alt={`Generated image ${index + 1}`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
@@ -599,7 +607,7 @@ export default function SpatialPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => handleDownload(image.url, image.id)}
|
onClick={() => handleDownload(resolveImageUrl(image.url), image.id)}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
下载图片
|
下载图片
|
||||||
@@ -852,7 +860,7 @@ export default function SpatialPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="relative group bg-muted rounded-lg overflow-hidden">
|
<div className="relative group bg-muted rounded-lg overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={editResult.url}
|
src={resolveImageUrl(editResult.url)}
|
||||||
alt="Edited image"
|
alt="Edited image"
|
||||||
className="w-full h-64 object-contain"
|
className="w-full h-64 object-contain"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
@@ -871,7 +879,7 @@ export default function SpatialPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => handleDownload(editResult.url, editResult.id)}
|
onClick={() => handleDownload(resolveImageUrl(editResult.url), editResult.id)}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
下载图片
|
下载图片
|
||||||
@@ -907,7 +915,7 @@ export default function SpatialPage() {
|
|||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="relative bg-muted">
|
<div className="relative bg-muted">
|
||||||
<img
|
<img
|
||||||
src={variation.url}
|
src={resolveImageUrl(variation.url)}
|
||||||
alt={`Variation ${index + 1}`}
|
alt={`Variation ${index + 1}`}
|
||||||
className="w-full h-48 object-cover"
|
className="w-full h-48 object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
@@ -922,7 +930,7 @@ export default function SpatialPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => handleDownload(variation.url, variation.id)}
|
onClick={() => handleDownload(resolveImageUrl(variation.url), variation.id)}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
下载图片
|
下载图片
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Loader2, Download, Copy, RefreshCw, Sparkles, Image as ImageIcon } from "lucide-react";
|
import { Loader2, Download, Copy, RefreshCw, Sparkles, Image as ImageIcon } from "lucide-react";
|
||||||
import { imageAPI } from "@/lib/api";
|
import { imageAPI, resolveImageUrl } from "@/lib/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface GeneratedImage {
|
interface GeneratedImage {
|
||||||
@@ -95,14 +95,22 @@ export default function TextToImagePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = (imageUrl: string, imageId: string) => {
|
const handleDownload = async (imageUrl: string, imageId: string) => {
|
||||||
const link = document.createElement("a");
|
try {
|
||||||
link.href = imageUrl;
|
const res = await fetch(imageUrl);
|
||||||
link.download = `generated-image-${imageId}.png`;
|
const blob = await res.blob();
|
||||||
document.body.appendChild(link);
|
const url = URL.createObjectURL(blob);
|
||||||
link.click();
|
const link = document.createElement("a");
|
||||||
document.body.removeChild(link);
|
link.href = url;
|
||||||
toast.success("图像下载成功");
|
link.download = `generated-image-${imageId}.png`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success("图像下载成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("图像下载失败");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyPrompt = (prompt: string) => {
|
const handleCopyPrompt = (prompt: string) => {
|
||||||
@@ -316,7 +324,7 @@ export default function TextToImagePage() {
|
|||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="aspect-square relative">
|
<div className="aspect-square relative">
|
||||||
<img
|
<img
|
||||||
src={image.url}
|
src={resolveImageUrl(image.url)}
|
||||||
alt={`Generated image ${index + 1}`}
|
alt={`Generated image ${index + 1}`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -326,7 +334,7 @@ export default function TextToImagePage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => handleDownload(image.url, image.id)}
|
onClick={() => handleDownload(resolveImageUrl(image.url), image.id)}
|
||||||
>
|
>
|
||||||
<Download className="h-3 w-3" />
|
<Download className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -113,7 +113,18 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
|
|||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(message.content);
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(message.content);
|
||||||
|
} else {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = message.content;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
toast.success("已复制");
|
toast.success("已复制");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("复制失败");
|
toast.error("复制失败");
|
||||||
@@ -254,7 +265,7 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
|
|||||||
),
|
),
|
||||||
img: ({ src, alt }: any) => {
|
img: ({ src, alt }: any) => {
|
||||||
const resolvedSrc = src && src.startsWith("/")
|
const resolvedSrc = src && src.startsWith("/")
|
||||||
? `${process.env.NEXT_PUBLIC_API_URL || `${window.location.protocol}//${window.location.hostname}:8000`}${src}`
|
? `${process.env.NEXT_PUBLIC_API_URL || `${window.location.protocol}//${window.location.hostname}:8002`}${src}`
|
||||||
: src;
|
: src;
|
||||||
return (
|
return (
|
||||||
<a href={resolvedSrc} target="_blank" rel="noopener noreferrer" className="block my-3 group">
|
<a href={resolvedSrc} target="_blank" rel="noopener noreferrer" className="block my-3 group">
|
||||||
|
|||||||
+8
-1
@@ -12,7 +12,14 @@ import type {
|
|||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
// API基础配置
|
// API基础配置
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || (typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8000` : "http://127.0.0.1:8000");
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || (typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8002` : "http://127.0.0.1:8002");
|
||||||
|
|
||||||
|
// 将后端返回的相对路径图片URL转为完整的后端地址
|
||||||
|
export function resolveImageUrl(url: string): string {
|
||||||
|
if (!url) return url;
|
||||||
|
if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("data:")) return url;
|
||||||
|
return `${API_BASE_URL}${url.startsWith("/") ? "" : "/"}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 请求拦截器
|
// 请求拦截器
|
||||||
async function apiRequest<T>(
|
async function apiRequest<T>(
|
||||||
|
|||||||
@@ -339,6 +339,15 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
},
|
},
|
||||||
(_sessionId: number, messageId?: number, userMessageId?: number) => {
|
(_sessionId: number, messageId?: number, userMessageId?: number) => {
|
||||||
|
// 刷出缓冲区中剩余的内容
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
if (contentBuffer) {
|
||||||
|
flushBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
if (messageId) {
|
if (messageId) {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messages: state.messages.map(msg => {
|
messages: state.messages.map(msg => {
|
||||||
|
|||||||
Reference in New Issue
Block a user