diff --git a/app/services/ai_image.py b/app/services/ai_image.py index 2a7f353..b74e042 100644 --- a/app/services/ai_image.py +++ b/app/services/ai_image.py @@ -136,6 +136,18 @@ def _api_endpoint() -> str: return base + API_PATH.lstrip("/") +def _pretty_json_text(value) -> str: + """把对象或 JSON 字符串格式化为易读文本;失败则回退原始字符串。""" + try: + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False, indent=2) + text = "" if value is None else str(value) + parsed = json.loads(text) + return json.dumps(parsed, ensure_ascii=False, indent=2) + except Exception: + return "" if value is None else str(value) + + def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = API_TIMEOUT) -> str: """调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。""" payload = json.dumps( @@ -154,11 +166,11 @@ def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = A _mask_sid(session_id), len(user_message or ""), _truncate(user_message), ) logger.info( - "[AIImage][REQUEST]\\nendpoint=%s\\nmethod=POST\\ntimeout=%.1fs\\nheaders=%s\\nbody=%s", + "[AIImage][REQUEST]\nendpoint=%s\nmethod=POST\ntimeout=%.1fs\nheaders=%s\nbody=%s", endpoint, timeout, - json.dumps(request_headers, ensure_ascii=False), - payload.decode("utf-8", errors="replace"), + _pretty_json_text(request_headers), + _pretty_json_text(payload.decode("utf-8", errors="replace")), ) request = Request( endpoint, @@ -174,10 +186,10 @@ def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = A response_headers = dict(response.headers.items()) raw_text = raw.decode("utf-8", errors="replace") logger.info( - "[AIImage][RESPONSE]\\nstatus=%s\\nheaders=%s\\nbody=%s", + "[AIImage][RESPONSE]\nstatus=%s\nheaders=%s\nbody=%s", http_status, - json.dumps(response_headers, ensure_ascii=False), - raw_text, + _pretty_json_text(response_headers), + _pretty_json_text(raw_text), ) except HTTPError as exc: elapsed = time.monotonic() - t0 @@ -194,13 +206,13 @@ def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = A except Exception: err_headers = {} logger.error( - "[AIImage][RESPONSE_ERROR] sid=%s elapsed=%.2fs status=%s reason=%s\\nheaders=%s\\nbody=%s", + "[AIImage][RESPONSE_ERROR] sid=%s elapsed=%.2fs status=%s reason=%s\nheaders=%s\nbody=%s", _mask_sid(session_id), elapsed, getattr(exc, "code", "?"), str(exc), - json.dumps(err_headers, ensure_ascii=False), - err_text, + _pretty_json_text(err_headers), + _pretty_json_text(err_text), ) raise except Exception as exc: @@ -484,6 +496,7 @@ def request_image_async( on_error: Callable[[Exception], None], base_dir: Optional[str] = None, session_id: Optional[str] = None, + cancel_event: Optional[threading.Event] = None, ) -> threading.Thread: """在后台线程调用后端 API → 下载图片 → 写入缓存 → 回调。 @@ -494,22 +507,35 @@ def request_image_async( """ sid = session_id or get_session_id() + cancel = cancel_event def _worker(): try: + if cancel is not None and cancel.is_set(): + logger.info("[AIImage] 任务已取消(请求前) sid=%s", _mask_sid(sid)) + return image_url = _call_pqtest_generate(prompt, sid) + if cancel is not None and cancel.is_set(): + logger.info("[AIImage] 任务已取消(生成后) sid=%s", _mask_sid(sid)) + return record = import_image_from_url( image_url=image_url, prompt=prompt, extra={"source": "ai-api", "session_id": sid}, base_dir=base_dir, ) + if cancel is not None and cancel.is_set(): + logger.info("[AIImage] 任务已取消(下载后) sid=%s", _mask_sid(sid)) + return logger.info( "[AIImage] 已写入缓存 sid=%s id=%s path=%s", _mask_sid(sid), record.id, record.image_path, ) on_success(record) except Exception as exc: + if cancel is not None and cancel.is_set(): + logger.info("[AIImage] 任务已取消(异常忽略) sid=%s", _mask_sid(sid)) + return logger.error( "[AIImage] 生成流程失败 sid=%s %s: %s", _mask_sid(sid), type(exc).__name__, exc, @@ -529,11 +555,15 @@ def import_image_from_url_async( extra: Optional[dict] = None, base_dir: Optional[str] = None, timeout: float = 20.0, + cancel_event: Optional[threading.Event] = None, ) -> threading.Thread: """在后台线程下载远程图片并写入缓存""" def _worker(): try: + if cancel_event is not None and cancel_event.is_set(): + logger.info("[AIImage] URL 导入任务已取消(请求前)") + return record = import_image_from_url( image_url=image_url, prompt=prompt, @@ -541,8 +571,14 @@ def import_image_from_url_async( base_dir=base_dir, timeout=timeout, ) + if cancel_event is not None and cancel_event.is_set(): + logger.info("[AIImage] URL 导入任务已取消(下载后)") + return on_success(record) except Exception as exc: + if cancel_event is not None and cancel_event.is_set(): + logger.info("[AIImage] URL 导入任务已取消(异常忽略)") + return on_error(exc) t = threading.Thread(target=_worker, daemon=True) diff --git a/app/views/panels/ai_image_panel.py b/app/views/panels/ai_image_panel.py index 0c9702b..db38029 100644 --- a/app/views/panels/ai_image_panel.py +++ b/app/views/panels/ai_image_panel.py @@ -30,6 +30,9 @@ def create_ai_image_panel(self): self._ai_image_requesting = False self._ai_image_progress_job = None self._ai_image_progress_phase = 0 + self._ai_image_cancel_event = None + self._ai_image_request_seq = 0 + self._ai_image_active_seq = 0 container = ttk.Frame(frame, padding=10) container.pack(fill=tk.BOTH, expand=True) @@ -170,6 +173,12 @@ def create_ai_image_panel(self): command=lambda: _start_new_session(self), ) self.ai_image_new_session_btn.pack(side=tk.RIGHT, padx=(0, 6)) + self.ai_image_stop_btn = ttk.Button( + send_row, text="停止", bootstyle="danger-outline", width=10, + command=lambda: _stop_request(self), + ) + self.ai_image_stop_btn.pack(side=tk.RIGHT, padx=(0, 6)) + self.ai_image_stop_btn.configure(state=tk.DISABLED) # 注册面板 self.register_panel("ai_image", frame, None, "ai_image_visible") @@ -386,25 +395,35 @@ def _send_prompt(self): messagebox.showinfo("提示", "请输入内容") return + self._ai_image_request_seq += 1 + req_seq = self._ai_image_request_seq + self._ai_image_active_seq = req_seq + self._ai_image_cancel_event = threading.Event() _set_requesting(self, True) 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)) + self.root.after(0, lambda: _on_request_done(self, record, None, req_seq)) def _error(exc): - self.root.after(0, lambda: _on_request_done(self, None, exc)) + self.root.after(0, lambda: _on_request_done(self, None, exc, req_seq)) if is_remote_url: _svc.import_image_from_url_async( prompt, on_success=_success, on_error=_error, + cancel_event=self._ai_image_cancel_event, ) return - _svc.request_image_async(prompt, on_success=_success, on_error=_error) + _svc.request_image_async( + prompt, + on_success=_success, + on_error=_error, + cancel_event=self._ai_image_cancel_event, + ) def _set_requesting(self, flag: bool): @@ -412,6 +431,7 @@ def _set_requesting(self, flag: bool): try: self.ai_image_send_btn.configure(state=tk.DISABLED if flag else tk.NORMAL) self.ai_image_new_session_btn.configure(state=tk.DISABLED if flag else tk.NORMAL) + self.ai_image_stop_btn.configure(state=tk.NORMAL if flag else tk.DISABLED) except Exception: pass if flag: @@ -432,7 +452,12 @@ def _set_requesting(self, flag: bool): pass -def _on_request_done(self, record, exc): +def _on_request_done(self, record, exc, req_seq): + # 旧请求回调(例如用户已点击停止后)直接忽略 + if req_seq != getattr(self, "_ai_image_active_seq", 0): + return + self._ai_image_active_seq = 0 + self._ai_image_cancel_event = None _set_requesting(self, False) if exc is not None: self.ai_image_status_var.set(f"失败: {exc}") @@ -455,6 +480,18 @@ def _on_request_done(self, record, exc): break +def _stop_request(self): + """停止当前生成任务(协作取消:屏蔽后续回调并恢复 UI)。""" + if not getattr(self, "_ai_image_requesting", False): + return + event = getattr(self, "_ai_image_cancel_event", None) + if event is not None: + event.set() + self._ai_image_active_seq = 0 + _set_requesting(self, False) + self.ai_image_status_var.set("已停止生成") + + def _save_current(self): rec = getattr(self, "ai_image_current", None) if rec is None: