From bbf6b921afe79068229b3b45900be673c27c5759 Mon Sep 17 00:00:00 2001 From: pengxiao Date: Tue, 2 Jun 2026 20:52:48 +0800 Subject: [PATCH] fix: adapt frontend image paths for backend port 8002 and use blob download Backend port changed from 8000 to 8002 due to port conflict (frpc on Windows). - Add resolveImageUrl() to convert relative image URLs to full backend URLs - Fix all spatial page image src and download handlers to use backend address - Fix chat message image rendering to use correct port - Switch download to blob-based approach to support cross-origin file saving Co-Authored-By: Claude Opus 4.7 --- .gitignore | 2 + backend/pyproject.toml | 1 + backend/src/core/config.py | 4 + backend/src/rag/document_loaders.py | 162 +++++++++--------- backend/uv.lock | 16 +- .../(main)/spatial/image-to-image/page.tsx | 34 ++-- web/src/app/(main)/spatial/page.tsx | 38 ++-- .../app/(main)/spatial/text-to-image/page.tsx | 30 ++-- web/src/components/chat/message-item.tsx | 2 +- web/src/lib/api.ts | 9 +- 10 files changed, 171 insertions(+), 127 deletions(-) diff --git a/.gitignore b/.gitignore index 5a6c0e5..7e7bca2 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,5 @@ Desktop.ini htmlcov/ officefile +data/1法律 + diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3b28059..dd335bd 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "docx2txt>=0.9", "pypdf>=6.12.0", "pymupdf>=1.27.2.3", + "pdf2image>=1.17.0", "opencv-python-headless>=4.13.0.92", ] diff --git a/backend/src/core/config.py b/backend/src/core/config.py index 665d9d7..2cbc64d 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -128,6 +128,10 @@ class Settings(BaseSettings): # 全局配置实例 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: """获取配置实例""" diff --git a/backend/src/rag/document_loaders.py b/backend/src/rag/document_loaders.py index 015279d..acfee97 100644 --- a/backend/src/rag/document_loaders.py +++ b/backend/src/rag/document_loaders.py @@ -2,11 +2,11 @@ LangChain 1.0 文档加载器封装 + PDF图片提取 """ import logging -import io from typing import List, Optional, Dict 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 ( PyPDFLoader, Docx2txtLoader, @@ -19,100 +19,92 @@ logger = logging.getLogger(__name__) class PDFImageExtractor: - """使用pymupdf从PDF中提取内嵌图片""" + """使用pdf2image将每页PDF渲染为图片,用pymupdf提取页面文本""" + + DEFAULT_DPI = 250 + DEFAULT_TARGET_WIDTH = 2500 + JPEG_QUALITY = 90 @staticmethod - def extract_images(file_path: str, output_dir: str) -> List[dict]: - """提取PDF中所有图片,返回图片元数据列表 + def extract_images( + file_path: str, + output_dir: str, + dpi: int = None, + target_width: int = None, + ) -> List[dict]: + """将PDF每页渲染为一张图片 - 对正常页面提取内嵌图片,对瓦片式页面(>50个小图片)渲染整页截图。 + 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 + Path(output_dir).mkdir(parents=True, exist_ok=True) + + # 用pymupdf提取每页文本(作为VLM上下文) + 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}") + + # 用pdf2image渲染所有页面为图片 + try: + pil_images = convert_from_path(file_path, dpi=dpi, fmt="jpeg") + except Exception as e: + logger.error(f"[ImageExtractor] pdf2image渲染失败: {e}") + return [] + images = [] - doc = fitz.open(file_path) + for page_num, pil_img in enumerate(pil_images): + try: + 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 + ) - TILE_THRESHOLD = 50 - vlm_formats = {"png", "jpg", "jpeg", "webp", "gif"} - dpi = 150 # 页面渲染 DPI + filename = f"page{page_num + 1}.jpg" + output_path = Path(output_dir) / filename + pil_img.save(str(output_path), format="JPEG", quality=quality) - for page_num in range(len(doc)): - page = doc[page_num] - page_text = page.get_text("text") - image_list = page.get_images(full=True) + context_text = "" + if page_num < len(page_texts): + context_text = page_texts[page_num][:600].strip() - if len(image_list) > TILE_THRESHOLD: - # 瓦片式页面:渲染整页为一张完整图片 - try: - pix = page.get_pixmap(dpi=dpi) - img_bytes = pix.tobytes("png") - filename = f"page{page_num+1}_full.png" - output_path = Path(output_dir) / filename - output_path.write_bytes(img_bytes) - - images.append({ - "path": str(output_path), - "filename": filename, - "page": page_num + 1, - "context_text": page_text[:600].strip(), - "size": len(img_bytes), - }) - logger.info(f"[ImageExtractor] 瓦片页面渲染为整图 page={page_num+1} size={len(img_bytes)}") - except Exception as e: - logger.warning(f"[ImageExtractor] 页面渲染失败 page={page_num+1}: {e}") + images.append({ + "path": str(output_path), + "filename": filename, + "page": page_num + 1, + "context_text": context_text, + "size": output_path.stat().st_size, + }) + logger.info( + f"[ImageExtractor] 页面渲染完成 page={page_num + 1} " + f"size={images[-1]['size']}" + ) + except Exception as e: + logger.warning( + f"[ImageExtractor] 页面处理失败 page={page_num + 1}: {e}" + ) continue - # 正常页面:提取内嵌图片 - 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 - w = base_image.get("width", 0) - h = base_image.get("height", 0) - if w < 150 or h < 150: - continue - - # 图片周围文本 - 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() - - # 不支持的格式(jpx/jpeg2000等)转为 PNG - if ext.lower() not in vlm_formats: - try: - img = Image.open(io.BytesIO(image_bytes)) - if img.mode in ("CMYK", "P"): - img = img.convert("RGB") - buf = io.BytesIO() - img.save(buf, format="PNG") - image_bytes = buf.getvalue() - ext = "png" - except Exception as e: - logger.warning(f"[ImageExtractor] 格式转换失败 page={page_num+1} img={img_idx}: {e}") - continue - - 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: - logger.warning(f"[ImageExtractor] 提取图片失败 page={page_num+1} img={img_idx}: {e}") - continue - - doc.close() + logger.info( + f"[ImageExtractor] 共渲染 {len(images)} 页 from {file_path}" + ) return images diff --git a/backend/uv.lock b/backend/uv.lock index 944b51c..f69a2ef 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", @@ -441,6 +441,7 @@ dependencies = [ { name = "opencv-python-headless" }, { name = "pandas" }, { name = "passlib", extra = ["bcrypt"] }, + { name = "pdf2image" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, @@ -492,6 +493,7 @@ requires-dist = [ { name = "opencv-python-headless", specifier = ">=4.13.0.92" }, { name = "pandas", specifier = ">=2.2.3" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, + { name = "pdf2image", specifier = ">=1.17.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.5.0" }, { name = "pydantic-settings", specifier = ">=2.1.0" }, @@ -2396,6 +2398,18 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08" }, ] +[[package]] +name = "pdf2image" +version = "1.17.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/00/d8/b280f01045555dc257b8153c00dee3bc75830f91a744cd5f84ef3a0a64b1/pdf2image-1.17.0.tar.gz", hash = "sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/62/33/61766ae033518957f877ab246f87ca30a85b778ebaad65b7f74fa7e52988/pdf2image-1.17.0-py3-none-any.whl", hash = "sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2" }, +] + [[package]] name = "pillow" version = "11.3.0" diff --git a/web/src/app/(main)/spatial/image-to-image/page.tsx b/web/src/app/(main)/spatial/image-to-image/page.tsx index 84e76ab..6e9a3ac 100644 --- a/web/src/app/(main)/spatial/image-to-image/page.tsx +++ b/web/src/app/(main)/spatial/image-to-image/page.tsx @@ -12,7 +12,7 @@ import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Slider } from "@/components/ui/slider"; 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"; interface EditResult { @@ -312,14 +312,22 @@ export default function ImageToImagePage() { } }; - const handleDownload = (imageUrl: string, imageId: string) => { - const link = document.createElement("a"); - link.href = imageUrl; - link.download = `edited-image-${imageId}.png`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - toast.success("图像下载成功"); + const handleDownload = async (imageUrl: string, imageId: string) => { + try { + const res = await fetch(imageUrl); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + 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 ( @@ -595,7 +603,7 @@ export default function ImageToImagePage() {
Edited image @@ -604,7 +612,7 @@ export default function ImageToImagePage() { @@ -648,7 +656,7 @@ export default function ImageToImagePage() { >
{`Variation @@ -657,7 +665,7 @@ export default function ImageToImagePage() { diff --git a/web/src/app/(main)/spatial/page.tsx b/web/src/app/(main)/spatial/page.tsx index eefdcb6..2d4ed08 100644 --- a/web/src/app/(main)/spatial/page.tsx +++ b/web/src/app/(main)/spatial/page.tsx @@ -32,7 +32,7 @@ import { X } from "lucide-react"; import LoadingSpinner from "@/components/ui/loading-spinner"; -import { imageAPI } from "@/lib/api"; +import { imageAPI, resolveImageUrl } from "@/lib/api"; import { toast } from "sonner"; import { motion } from "framer-motion"; @@ -350,14 +350,22 @@ export default function SpatialPage() { } }; - const handleDownload = (imageUrl: string, imageId: string) => { - const link = document.createElement("a"); - link.href = imageUrl; - link.download = `image-${imageId}.png`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - toast.success("图像下载成功"); + const handleDownload = async (imageUrl: string, imageId: string) => { + try { + const res = await fetch(imageUrl); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + 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) { @@ -580,7 +588,7 @@ export default function SpatialPage() {
{`Generated handleDownload(image.url, image.id)} + onClick={() => handleDownload(resolveImageUrl(image.url), image.id)} > 下载图片 @@ -852,7 +860,7 @@ export default function SpatialPage() {
Edited image handleDownload(editResult.url, editResult.id)} + onClick={() => handleDownload(resolveImageUrl(editResult.url), editResult.id)} > 下载图片 @@ -907,7 +915,7 @@ export default function SpatialPage() {
{`Variation handleDownload(variation.url, variation.id)} + onClick={() => handleDownload(resolveImageUrl(variation.url), variation.id)} > 下载图片 diff --git a/web/src/app/(main)/spatial/text-to-image/page.tsx b/web/src/app/(main)/spatial/text-to-image/page.tsx index 620b778..8cc6cab 100644 --- a/web/src/app/(main)/spatial/text-to-image/page.tsx +++ b/web/src/app/(main)/spatial/text-to-image/page.tsx @@ -11,7 +11,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; 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"; interface GeneratedImage { @@ -95,14 +95,22 @@ export default function TextToImagePage() { } }; - const handleDownload = (imageUrl: string, imageId: string) => { - const link = document.createElement("a"); - link.href = imageUrl; - link.download = `generated-image-${imageId}.png`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - toast.success("图像下载成功"); + const handleDownload = async (imageUrl: string, imageId: string) => { + try { + const res = await fetch(imageUrl); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + 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) => { @@ -316,7 +324,7 @@ export default function TextToImagePage() {
{`Generated @@ -326,7 +334,7 @@ export default function TextToImagePage() { diff --git a/web/src/components/chat/message-item.tsx b/web/src/components/chat/message-item.tsx index 3518310..20f90f6 100644 --- a/web/src/components/chat/message-item.tsx +++ b/web/src/components/chat/message-item.tsx @@ -254,7 +254,7 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps ), img: ({ src, alt }: any) => { 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; return ( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ab44278..b200116 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -12,7 +12,14 @@ import type { } from "@/types"; // 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(