From b26a3c398dbe18c3e61c6562115cfaa7f6584e20 Mon Sep 17 00:00:00 2001 From: "xinzhu.yin" Date: Thu, 23 Apr 2026 10:07:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=90=8E=E5=8F=B0=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E4=B8=8B=E8=BD=BD=E8=BF=9C=E7=A8=8B=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=B9=B6=E5=86=99=E5=85=A5=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/ai_image.py | 109 +++++++++++++++++++++++++++++ app/views/panels/ai_image_panel.py | 29 +++++--- 2 files changed, 130 insertions(+), 8 deletions(-) diff --git a/app/services/ai_image.py b/app/services/ai_image.py index 72198e7..b13db67 100644 --- a/app/services/ai_image.py +++ b/app/services/ai_image.py @@ -9,11 +9,14 @@ from __future__ import annotations import datetime as _dt import hashlib import json +import mimetypes import os import shutil import threading from dataclasses import dataclass, asdict from typing import Callable, List, Optional +from urllib.parse import urlparse +from urllib.request import Request, urlopen # ---------- 常量 ---------- @@ -189,6 +192,53 @@ def save_image_to_cache( return record +def import_image_from_url( + image_url: str, + prompt: Optional[str] = None, + extra: Optional[dict] = None, + base_dir: Optional[str] = None, + timeout: float = 20.0, +) -> AIImageRecord: + """下载远程图片并写入缓存。""" + url = (image_url or "").strip() + if not url: + raise ValueError("图片地址不能为空") + + request = Request( + url, + headers={ + "User-Agent": "pqAutomationApp/1.0", + "Accept": "image/*,*/*;q=0.8", + }, + ) + with urlopen(request, timeout=timeout) as response: + image_bytes = response.read() + if not image_bytes: + raise ValueError("下载结果为空") + + image_ext = _guess_image_ext( + image_url=url, + content_type=response.headers.get_content_type(), + ) + merged_extra = dict(extra or {}) + merged_extra.update( + { + "source": "remote-url", + "source_url": url, + "content_type": response.headers.get_content_type(), + } + ) + + record_prompt = (prompt or _default_prompt_from_url(url)).strip() + return save_image_to_cache( + prompt=record_prompt, + image_bytes=image_bytes, + image_ext=image_ext, + extra=merged_extra, + base_dir=base_dir, + ) + + def delete_record(record: AIImageRecord) -> bool: """删除一条缓存记录(图片 + 侧车)。返回是否成功。""" ok = True @@ -242,6 +292,35 @@ def request_image_async( return t +def import_image_from_url_async( + image_url: str, + on_success: Callable[[AIImageRecord], None], + on_error: Callable[[Exception], None], + prompt: Optional[str] = None, + extra: Optional[dict] = None, + base_dir: Optional[str] = None, + timeout: float = 20.0, +) -> threading.Thread: + """在后台线程下载远程图片并写入缓存""" + + def _worker(): + try: + record = import_image_from_url( + image_url=image_url, + prompt=prompt, + extra=extra, + base_dir=base_dir, + timeout=timeout, + ) + on_success(record) + except Exception as exc: + on_error(exc) + + t = threading.Thread(target=_worker, daemon=True) + t.start() + return t + + def _normalize_api_result(result): """允许 API 返回 ``bytes`` 或 ``(bytes, ext)`` 或 ``(bytes, ext, extra)``。""" if isinstance(result, (bytes, bytearray)): @@ -252,3 +331,33 @@ def _normalize_api_result(result): if len(result) == 3: return bytes(result[0]), str(result[1]), result[2] raise ValueError("API 返回格式不支持,需为 bytes 或 (bytes, ext[, extra])") + + +def is_remote_image_url(value: str) -> bool: + """判断输入是否为 http/https 图片地址。""" + url = (value or "").strip() + if not url: + return False + parsed = urlparse(url) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + + +def _guess_image_ext(image_url: str, content_type: Optional[str]) -> str: + if content_type: + guessed = mimetypes.guess_extension(content_type) + if guessed == ".jpe": + guessed = ".jpg" + if guessed and guessed.lower() in _SUPPORTED_IMG_EXT: + return guessed.lower() + + url_path = urlparse(image_url).path + ext = os.path.splitext(url_path)[1].lower() + if ext in _SUPPORTED_IMG_EXT: + return ext + return ".png" + + +def _default_prompt_from_url(image_url: str) -> str: + path = urlparse(image_url).path + name = os.path.splitext(os.path.basename(path))[0].strip() + return name or "远程导入图片" diff --git a/app/views/panels/ai_image_panel.py b/app/views/panels/ai_image_panel.py index 7e23166..6d22603 100644 --- a/app/views/panels/ai_image_panel.py +++ b/app/views/panels/ai_image_panel.py @@ -264,16 +264,10 @@ def _send_prompt(self): if not prompt: messagebox.showinfo("提示", "请输入内容") return - if not _svc.has_api(): - messagebox.showerror( - "API 未配置", - "AI 图片 API 尚未接入。\n请在启动时通过 " - "app.services.ai_image.set_api_caller(...) 注入真实实现。", - ) - return _set_requesting(self, True) - self.ai_image_status_var.set("请求中…") + is_remote_url = _svc.is_remote_image_url(prompt) + self.ai_image_status_var.set("下载中…" if is_remote_url else "请求中…") def _success(record): self.root.after(0, lambda: _on_request_done(self, record, None)) @@ -281,6 +275,25 @@ def _send_prompt(self): def _error(exc): self.root.after(0, lambda: _on_request_done(self, None, exc)) + if is_remote_url: + _svc.import_image_from_url_async( + prompt, + on_success=_success, + on_error=_error, + ) + return + + if not _svc.has_api(): + _set_requesting(self, False) + self.ai_image_status_var.set("就绪") + messagebox.showerror( + "API 未配置", + "AI 图片 API 尚未接入。\n" + "可直接输入图片 URL 导入,或在启动时通过 " + "app.services.ai_image.set_api_caller(...) 注入真实实现。", + ) + return + _svc.request_image_async(prompt, on_success=_success, on_error=_error)