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:
@@ -71,3 +71,5 @@ Desktop.ini
|
|||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
officefile
|
officefile
|
||||||
|
data/1法律
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
"""获取配置实例"""
|
"""获取配置实例"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+15
-1
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>(
|
||||||
|
|||||||
Reference in New Issue
Block a user