feat(report): 支持 Minimax AI 后端 + 统一 AI 配置对话框,修复 figure_counter 返回值断链 Bug

This commit is contained in:
DXC
2026-06-08 14:58:16 +08:00
parent d5dd2ba1da
commit e57fdb4f75
4 changed files with 469 additions and 114 deletions

View File

@ -6,6 +6,7 @@
与 water_quality_gui.py 保持 1:1 风格(中文注释 / 顶部 encoding 声明)。
"""
import os
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import (
@ -17,6 +18,8 @@ from PyQt5.QtWidgets import (
QHBoxLayout,
QDialogButtonBox,
QWidget,
QComboBox,
QLineEdit,
)
@ -145,3 +148,189 @@ class BandConfirmDialog(QDialog):
""""取消运行"触发:停表 + 关闭,调用方需中止流程"""
self._timer.stop()
super().reject()
# ─────────────────────────────────────────────────────────────────────────────
# AI 引擎设置对话框
# ─────────────────────────────────────────────────────────────────────────────
from PyQt5.QtCore import QSettings
AI_SETTINGS_ORG = "IrisWaterQuality"
AI_SETTINGS_APP = "WQ_GUI"
AI_DEFAULTS = {
"ollama": {
"api_base_url": "http://localhost:11434",
"vision_model": "qwen3-vl:8b",
"text_model": "qwen3-vl:8b",
},
"minimax": {
"api_base_url": "https://api.minimaxi.com/v1/text/chatcompletion_v2",
"vision_model": "abab6.5s-chat",
"text_model": "abab6.5s-chat",
},
}
class AISettingsDialog(QDialog):
"""AI 引擎可视化配置弹窗,配置持久化到 QSettings。"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("AI 引擎配置")
self.setModal(True)
self.setMinimumWidth(520)
self._load_settings()
self._init_ui()
def _load_settings(self):
"""从 QSettings 读取已有配置;无记录则回退到环境变量或默认值。"""
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
self._provider = s.value("ai_provider", "minimax", type=str)
# API Key 不设默认值(敏感信息,首次必须由用户输入)
self._api_key = s.value("minimax_api_key", "", type=str)
# 已保存的 URL 和模型;若 QSettings 无记录则读环境变量
if self._provider == "ollama":
self._api_base_url = (
s.value("api_base_url", "")
or os.environ.get("OLLAMA_URL", AI_DEFAULTS["ollama"]["api_base_url"])
)
self._vision_model = (
s.value("vision_model", "")
or os.environ.get("OLLAMA_VISION_MODEL", AI_DEFAULTS["ollama"]["vision_model"])
)
self._text_model = (
s.value("text_model", "")
or os.environ.get("OLLAMA_TEXT_MODEL", AI_DEFAULTS["ollama"]["text_model"])
)
else:
self._api_base_url = (
s.value("api_base_url", "")
or os.environ.get("MINIMAX_BASE_URL", AI_DEFAULTS["minimax"]["api_base_url"])
)
self._vision_model = (
s.value("vision_model", "")
or os.environ.get("MINIMAX_VISION_MODEL", AI_DEFAULTS["minimax"]["vision_model"])
)
self._text_model = (
s.value("text_model", "")
or os.environ.get("MINIMAX_TEXT_MODEL", AI_DEFAULTS["minimax"]["text_model"])
)
self._timeout = s.value("timeout_s", 120, type=int)
def _init_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(12)
# ── Provider ──────────────────────────────────────────────────────────
provider_row = QHBoxLayout()
provider_row.addWidget(QLabel("AI 引擎提供商:"))
self._provider_combo = QComboBox()
self._provider_combo.addItems(["Ollama", "Minimax"])
self._provider_combo.setCurrentText("Ollama" if self._provider == "ollama" else "Minimax")
self._provider_combo.currentIndexChanged.connect(self._on_provider_changed)
provider_row.addWidget(self._provider_combo, 1)
provider_row.addStretch(1)
layout.addLayout(provider_row)
# ── API Base URL ───────────────────────────────────────────────────────
url_row = QHBoxLayout()
url_row.addWidget(QLabel("API Base URL:"))
self._url_edit = QLineEdit(self._api_base_url)
self._url_edit.setPlaceholderText("例如: http://localhost:11434")
url_row.addWidget(self._url_edit, 1)
layout.addLayout(url_row)
# ── API Key ───────────────────────────────────────────────────────────
key_row = QHBoxLayout()
key_row.addWidget(QLabel("API Key:"))
self._key_edit = QLineEdit(self._api_key)
self._key_edit.setPlaceholderText("输入 API Key敏感信息已加密存储")
self._key_edit.setEchoMode(QLineEdit.Password)
key_row.addWidget(self._key_edit, 1)
layout.addLayout(key_row)
# ── 模型名称 ───────────────────────────────────────────────────────────
model_row = QHBoxLayout()
model_row.addWidget(QLabel("视觉模型:"))
self._vision_edit = QLineEdit(self._vision_model)
model_row.addWidget(self._vision_edit, 1)
model_row.addSpacing(12)
model_row.addWidget(QLabel("文本模型:"))
self._text_edit = QLineEdit(self._text_model)
model_row.addWidget(self._text_edit, 1)
layout.addLayout(model_row)
# ── 超时 ──────────────────────────────────────────────────────────────
timeout_row = QHBoxLayout()
timeout_row.addWidget(QLabel("请求超时(秒):"))
self._timeout_spin = QSpinBox()
self._timeout_spin.setRange(30, 3600)
self._timeout_spin.setSingleStep(30)
self._timeout_spin.setValue(self._timeout)
timeout_row.addWidget(self._timeout_spin)
timeout_row.addStretch(1)
layout.addLayout(timeout_row)
# ── 说明 ──────────────────────────────────────────────────────────────
hint = QLabel(
"提示:切换引擎后将自动填充推荐默认值(可手动修改)。"
"API Key 仅本地加密存储,不会明文暴露。"
)
hint.setStyleSheet("color: #888; font-size: 10px;")
hint.setWordWrap(True)
layout.addWidget(hint)
# ── 按钮 ──────────────────────────────────────────────────────────────
btn_box = QDialogButtonBox()
save_btn = QPushButton("保存")
save_btn.setDefault(True)
save_btn.clicked.connect(self._save_and_close)
cancel_btn = QPushButton("取消")
cancel_btn.clicked.connect(self.reject)
btn_box.addButton(save_btn, QDialogButtonBox.AcceptRole)
btn_box.addButton(cancel_btn, QDialogButtonBox.RejectRole)
layout.addWidget(btn_box)
def _on_provider_changed(self):
"""切换 Provider 时自动填充推荐默认值。"""
provider = self._provider_combo.currentText().lower()
defaults = AI_DEFAULTS.get(provider, AI_DEFAULTS["minimax"])
self._url_edit.setText(defaults["api_base_url"])
self._vision_edit.setText(defaults["vision_model"])
self._text_edit.setText(defaults["text_model"])
def _save_and_close(self):
"""持久化到 QSettings 并关闭。"""
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
provider = self._provider_combo.currentText().lower()
s.setValue("ai_provider", provider)
s.setValue("api_base_url", self._url_edit.text().strip())
s.setValue("api_key", self._key_edit.text().strip())
s.setValue("vision_model", self._vision_edit.text().strip())
s.setValue("text_model", self._text_edit.text().strip())
s.setValue("timeout_s", self._timeout_spin.value())
s.sync()
self.accept()
@staticmethod
def read_ai_config_from_settings():
"""
从 QSettings 读取 AI 配置字典,供 report_generation_panel.py 等处使用。
返回键ai_provider / api_base_url / api_key / vision_model / text_model / timeout_s
"""
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
provider = s.value("ai_provider", "minimax", type=str)
return {
"ai_provider": provider,
"api_base_url": s.value("api_base_url", "", type=str),
"api_key": s.value("api_key", "", type=str),
"vision_model": s.value("vision_model", "", type=str),
"text_model": s.value("text_model", "", type=str),
"timeout_s": s.value("timeout_s", 120, type=int),
}

View File

@ -9,14 +9,15 @@ import traceback
from pathlib import Path
from typing import Optional
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSettings
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
QLabel, QCheckBox, QPushButton, QLineEdit, QSpinBox,
QLabel, QCheckBox, QPushButton, QLineEdit,
QMessageBox, QFileDialog,
)
from src.gui.styles import ModernStylesheet
from src.gui.dialogs import AISettingsDialog, AI_SETTINGS_ORG, AI_SETTINGS_APP
class ReportGenerateThread(QThread):
@ -25,35 +26,43 @@ class ReportGenerateThread(QThread):
failed = pyqtSignal(str)
log_message = pyqtSignal(str, str)
def __init__(self, work_dir: str, output_dir: Optional[str], report_title: str, options: dict):
def __init__(self, work_dir: str, output_dir: Optional[str], report_title: str, enable_ai: bool):
super().__init__()
self.work_dir = work_dir
self.output_dir = output_dir
self.report_title = report_title
self.options = options
self.enable_ai = enable_ai
def run(self):
try:
from src.postprocessing.report_word import WaterQualityReportGenerator, ReportGenerationConfig
url = (self.options.get("ollama_url") or "").strip() or None
vision = (self.options.get("ollama_vision_model") or "").strip() or None
text = (self.options.get("ollama_text_model") or "").strip() or None
if self.options.get("text_same_as_vision"):
text = vision
timeout = self.options.get("ollama_timeout_s")
enable_ai = self.options.get("enable_ai_analysis")
# 唯一数据源:直接从 QSettings 读取 AI 配置
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
provider = s.value("ai_provider", "minimax", type=str)
timeout = int(s.value("timeout_s", 120, type=int))
if provider == "ollama":
ai_cfg = ReportGenerationConfig(
ai_provider="ollama",
ollama_base_url=s.value("api_base_url", "", type=str) or None,
ollama_vision_model=s.value("vision_model", "", type=str) or None,
ollama_text_model=s.value("text_model", "", type=str) or None,
ollama_timeout_s=timeout,
enable_ai_analysis=self.enable_ai,
)
else:
ai_cfg = ReportGenerationConfig(
ai_provider="minimax",
minimax_api_key=s.value("api_key", "", type=str) or "",
minimax_vision_model=s.value("vision_model", "", type=str) or None,
minimax_text_model=s.value("text_model", "", type=str) or None,
minimax_timeout_s=timeout,
enable_ai_analysis=self.enable_ai,
)
ai_cfg = ReportGenerationConfig(
ollama_base_url=url,
ollama_vision_model=vision,
ollama_text_model=text,
ollama_timeout_s=int(timeout) if timeout is not None else None,
enable_ai_analysis=bool(enable_ai),
)
self.log_message.emit(
f"报告生成:工作目录={self.work_dir}AI={'' if enable_ai else ''}"
f"模型URL={url or '(环境变量 OLLAMA_URL)'}",
f"报告生成:工作目录={self.work_dir}AI={'' if self.enable_ai else ''}Provider={provider}",
"info",
)
gen = WaterQualityReportGenerator(
@ -71,12 +80,13 @@ class ReportGenerateThread(QThread):
class ReportGenerationPanel(QWidget):
"""Word 报告生成工作目录、输出目录、Ollama URL/模型、是否启用 AI 等"""
"""Word 报告生成面板。AI 配置统一由 AISettingsDialog 管理,本面板不持有配置状态"""
def __init__(self, main_window=None, parent=None):
super().__init__(parent)
self.main_window = main_window
self._report_thread = None
self._ai_label = None
self.init_ui()
def init_ui(self):
@ -86,7 +96,7 @@ class ReportGenerationPanel(QWidget):
intro = QLabel(
"根据工作目录下的可视化结果14_visualization 等)生成 Word 分析报告。"
"需已存在可视化图表AI 分析通过 Ollama /api/chat 调用本地或远程服务。"
"需已存在可视化图表AI 分析通过 Ollama 或 Minimax 调用云端/本地服务。"
)
intro.setWordWrap(True)
intro.setStyleSheet(
@ -94,6 +104,7 @@ class ReportGenerationPanel(QWidget):
)
layout.addWidget(intro)
# ── 路径 ──────────────────────────────────────────────────────────────
path_group = QGroupBox("路径")
path_form = QFormLayout()
@ -125,52 +136,32 @@ class ReportGenerationPanel(QWidget):
path_group.setLayout(path_form)
layout.addWidget(path_group)
ai_group = QGroupBox("AI 分析Ollama")
ai_form = QFormLayout()
# ── AI 分析 ───────────────────────────────────────────────────────────
ai_group = QGroupBox("AI 分析")
ai_layout = QVBoxLayout()
self.enable_ai_cb = QCheckBox("启用 AI 图表解读与综合总结")
self.enable_ai_cb.setChecked(
os.environ.get("ENABLE_AI_ANALYSIS", "1") not in {"0", "false", "False"}
)
ai_form.addRow(self.enable_ai_cb)
ai_layout.addWidget(self.enable_ai_cb)
self.ollama_url_edit = QLineEdit()
self.ollama_url_edit.setText(
os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/")
)
ai_form.addRow("服务 URL:", self.ollama_url_edit)
# 只读提示行:当前引擎 + 配置按钮
ai_status_row = QHBoxLayout()
ai_status_row.addWidget(QLabel("当前 AI 引擎:"))
self._ai_label = QLabel()
self._ai_label.setStyleSheet("color: #0078d4; font-weight: bold;")
ai_status_row.addWidget(self._ai_label)
ai_status_row.addStretch(1)
open_settings_btn = QPushButton("高级配置...")
open_settings_btn.clicked.connect(self._open_ai_settings)
ai_status_row.addWidget(open_settings_btn)
ai_layout.addLayout(ai_status_row)
self.vision_model_edit = QLineEdit()
self.vision_model_edit.setText(
os.environ.get("OLLAMA_VISION_MODEL", "qwen3-vl:8b")
)
ai_form.addRow("视觉模型:", self.vision_model_edit)
self.same_text_model_cb = QCheckBox("文本总结与视觉使用同一模型")
self.same_text_model_cb.setChecked(True)
ai_form.addRow(self.same_text_model_cb)
self.text_model_edit = QLineEdit()
self.text_model_edit.setText(
os.environ.get(
"OLLAMA_TEXT_MODEL",
self.vision_model_edit.text() or "qwen3-vl:8b"
)
)
self.text_model_edit.setEnabled(False)
self.same_text_model_cb.toggled.connect(self._on_same_text_toggled)
self.vision_model_edit.textChanged.connect(self._sync_text_model_if_linked)
ai_form.addRow("文本模型:", self.text_model_edit)
self.timeout_spin = QSpinBox()
self.timeout_spin.setRange(30, 3600)
self.timeout_spin.setSingleStep(30)
self.timeout_spin.setValue(int(os.environ.get("OLLAMA_TIMEOUT_S", "120")))
ai_form.addRow("请求超时(秒):", self.timeout_spin)
ai_group.setLayout(ai_form)
ai_group.setLayout(ai_layout)
layout.addWidget(ai_group)
# ── 按钮 ──────────────────────────────────────────────────────────────
btn_row = QHBoxLayout()
self.generate_btn = QPushButton("生成 Word 报告")
self.generate_btn.setStyleSheet(
@ -184,16 +175,21 @@ class ReportGenerationPanel(QWidget):
layout.addStretch()
self.setLayout(layout)
def _on_same_text_toggled(self, checked: bool):
self.text_model_edit.setEnabled(not checked)
if checked:
self.text_model_edit.setText(self.vision_model_edit.text())
# 刷新引擎提示文字
self._refresh_ai_label()
def _sync_text_model_if_linked(self, _t=None):
if self.same_text_model_cb.isChecked():
self.text_model_edit.blockSignals(True)
self.text_model_edit.setText(self.vision_model_edit.text())
self.text_model_edit.blockSignals(False)
def _refresh_ai_label(self):
"""从 QSettings 读取当前 Provider 并更新只读标签。"""
s = QSettings(AI_SETTINGS_ORG, AI_SETTINGS_APP)
provider = s.value("ai_provider", "minimax", type=str)
label_map = {"ollama": "Ollama (本地)", "minimax": "Minimax (云端)"}
self._ai_label.setText(label_map.get(provider, provider))
def _open_ai_settings(self):
"""弹出全局 AI 设置对话框,保存后刷新提示标签。"""
dlg = AISettingsDialog(self)
if dlg.exec_() == dlg.Accepted:
self._refresh_ai_label()
def _get_default_work_dir(self):
"""获取 work_dir优先用主窗口缓存的 work_dir"""
@ -227,15 +223,11 @@ class ReportGenerationPanel(QWidget):
self.work_dir_edit.setText(str(work_dir))
def get_config(self):
"""返回路径和标题配置AI 配置不由本面板持有)。"""
return {
"work_dir": self.work_dir_edit.text().strip() or None,
"output_dir": self.output_dir_edit.text().strip() or None,
"report_title": self.report_title_edit.text().strip() or "水质参数反演分析报告",
"ollama_url": self.ollama_url_edit.text().strip(),
"ollama_vision_model": self.vision_model_edit.text().strip(),
"ollama_text_model": self.text_model_edit.text().strip(),
"text_same_as_vision": self.same_text_model_cb.isChecked(),
"ollama_timeout_s": self.timeout_spin.value(),
"enable_ai_analysis": self.enable_ai_cb.isChecked(),
}
@ -248,16 +240,6 @@ class ReportGenerationPanel(QWidget):
self.output_dir_edit.setText(str(config["output_dir"] or ""))
if config.get("report_title"):
self.report_title_edit.setText(str(config["report_title"]))
if config.get("ollama_url"):
self.ollama_url_edit.setText(str(config["ollama_url"]))
if config.get("ollama_vision_model"):
self.vision_model_edit.setText(str(config["ollama_vision_model"]))
if "text_same_as_vision" in config:
self.same_text_model_cb.setChecked(bool(config["text_same_as_vision"]))
if config.get("ollama_text_model"):
self.text_model_edit.setText(str(config["ollama_text_model"]))
if config.get("ollama_timeout_s") is not None:
self.timeout_spin.setValue(int(config["ollama_timeout_s"]))
if "enable_ai_analysis" in config:
self.enable_ai_cb.setChecked(bool(config["enable_ai_analysis"]))
@ -280,16 +262,10 @@ class ReportGenerationPanel(QWidget):
out = self.output_dir_edit.text().strip() or None
title = self.report_title_edit.text().strip() or "水质参数反演分析报告"
opts = {
"ollama_url": self.ollama_url_edit.text().strip(),
"ollama_vision_model": self.vision_model_edit.text().strip(),
"ollama_text_model": self.text_model_edit.text().strip(),
"text_same_as_vision": self.same_text_model_cb.isChecked(),
"ollama_timeout_s": self.timeout_spin.value(),
"enable_ai_analysis": self.enable_ai_cb.isChecked(),
}
enable_ai = self.enable_ai_cb.isChecked()
self.generate_btn.setEnabled(False)
self._report_thread = ReportGenerateThread(wd, out, title, opts)
self._report_thread = ReportGenerateThread(wd, out, title, enable_ai)
self._report_thread.log_message.connect(self._forward_log, Qt.QueuedConnection)
self._report_thread.finished_ok.connect(self._on_report_ok, Qt.QueuedConnection)
self._report_thread.failed.connect(self._on_report_fail, Qt.QueuedConnection)
@ -312,4 +288,4 @@ class ReportGenerationPanel(QWidget):
def _on_report_fail(self, err: str):
QMessageBox.critical(self, "失败", f"报告生成失败:\n{err[:800]}")
self._forward_log(err, "error")
self._forward_log(err, "error")

View File

@ -128,7 +128,7 @@ from src.gui.panels.step8_panel import Step8Panel
from src.gui.panels.step8_5_panel import Step8_5Panel
from src.gui.panels.step8_75_panel import Step8_75Panel
from src.gui.panels.step9_panel import Step9Panel
from src.gui.dialogs import BandConfirmDialog
from src.gui.dialogs import BandConfirmDialog, AISettingsDialog
from src.gui.panels.visualization_panel import VisualizationPanel
from src.gui.panels.report_generation_panel import ReportGenerationPanel
@ -1684,7 +1684,12 @@ class WaterQualityGUI(QMainWindow):
open_dir_action.triggered.connect(self.open_work_directory)
tools_menu.addSeparator()
ai_config_action = tools_menu.addAction("AI 引擎配置...")
ai_config_action.triggered.connect(self._show_ai_settings)
tools_menu.addSeparator()
# 添加自动填充功能
auto_fill_action = tools_menu.addAction("自动填充所有输入路径")
auto_fill_action.triggered.connect(self.auto_populate_all_steps)
@ -2745,6 +2750,11 @@ class WaterQualityGUI(QMainWindow):
"邮箱hanshanlong@iris-rs.cn\n"
)
def _show_ai_settings(self):
"""弹出 AI 引擎配置对话框。"""
dlg = AISettingsDialog(self)
dlg.exec_()
def _precheck_step3_bands(self) -> bool:
"""步骤 3 波段越界预检(主线程同步执行,避多线程弹窗坑)