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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 20:52:48 +08:00
parent a6987ff996
commit bbf6b921af
10 changed files with 171 additions and 127 deletions
+2
View File
@@ -71,3 +71,5 @@ Desktop.ini
htmlcov/ htmlcov/
officefile officefile
data/1法律
+1
View File
@@ -43,6 +43,7 @@ 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", "opencv-python-headless>=4.13.0.92",
] ]
+4
View File
@@ -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:
"""获取配置实例""" """获取配置实例"""
+77 -85
View File
@@ -2,11 +2,11 @@
LangChain 1.0 文档加载器封装 + PDF图片提取 LangChain 1.0 文档加载器封装 + PDF图片提取
""" """
import logging import logging
import io
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 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,
@@ -19,100 +19,92 @@ logger = logging.getLogger(__name__)
class PDFImageExtractor: class PDFImageExtractor:
"""使用pymupdf从PDF中提取内嵌图片""" """使用pdf2image将每页PDF渲染为图片,用pymupdf提取页面文本"""
DEFAULT_DPI = 250
DEFAULT_TARGET_WIDTH = 2500
JPEG_QUALITY = 90
@staticmethod @staticmethod
def extract_images(file_path: str, output_dir: str) -> List[dict]: def extract_images(
"""提取PDF中所有图片,返回图片元数据列表 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) 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 = [] 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 filename = f"page{page_num + 1}.jpg"
vlm_formats = {"png", "jpg", "jpeg", "webp", "gif"} output_path = Path(output_dir) / filename
dpi = 150 # 页面渲染 DPI pil_img.save(str(output_path), format="JPEG", quality=quality)
for page_num in range(len(doc)): context_text = ""
page = doc[page_num] if page_num < len(page_texts):
page_text = page.get_text("text") context_text = page_texts[page_num][:600].strip()
image_list = page.get_images(full=True)
if len(image_list) > TILE_THRESHOLD: images.append({
# 瓦片式页面:渲染整页为一张完整图片 "path": str(output_path),
try: "filename": filename,
pix = page.get_pixmap(dpi=dpi) "page": page_num + 1,
img_bytes = pix.tobytes("png") "context_text": context_text,
filename = f"page{page_num+1}_full.png" "size": output_path.stat().st_size,
output_path = Path(output_dir) / filename })
output_path.write_bytes(img_bytes) logger.info(
f"[ImageExtractor] 页面渲染完成 page={page_num + 1} "
images.append({ f"size={images[-1]['size']}"
"path": str(output_path), )
"filename": filename, except Exception as e:
"page": page_num + 1, logger.warning(
"context_text": page_text[:600].strip(), f"[ImageExtractor] 页面处理失败 page={page_num + 1}: {e}"
"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}")
continue continue
# 正常页面:提取内嵌图片 logger.info(
for img_idx, img_info in enumerate(image_list): f"[ImageExtractor] 共渲染 {len(images)} 页 from {file_path}"
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()
return images return images
+15 -1
View File
@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 2 revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.13'", "python_full_version >= '3.13'",
@@ -441,6 +441,7 @@ dependencies = [
{ name = "opencv-python-headless" }, { name = "opencv-python-headless" },
{ name = "pandas" }, { name = "pandas" },
{ name = "passlib", extra = ["bcrypt"] }, { name = "passlib", extra = ["bcrypt"] },
{ name = "pdf2image" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
@@ -492,6 +493,7 @@ requires-dist = [
{ name = "opencv-python-headless", specifier = ">=4.13.0.92" }, { name = "opencv-python-headless", specifier = ">=4.13.0.92" },
{ name = "pandas", specifier = ">=2.2.3" }, { name = "pandas", specifier = ">=2.2.3" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "pdf2image", specifier = ">=1.17.0" },
{ 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" },
@@ -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" }, { 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]] [[package]]
name = "pillow" name = "pillow"
version = "11.3.0" version = "11.3.0"
@@ -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>
+23 -15
View File
@@ -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>
+1 -1
View File
@@ -254,7 +254,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
View File
@@ -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>(