This commit is contained in:
Your Name
2026-07-29 09:34:02 +08:00
parent 0ff8943ee2
commit f913a57529
54 changed files with 2453 additions and 378 deletions
+175 -232
View File
@@ -59,6 +59,9 @@ from PySide6.QtWebEngineCore import (
)
from PySide6.QtWebEngineWidgets import QWebEngineView
from app_version import APP_VERSION, release_status
from runtime_paths import application_data_dir, resource_path
try:
import __main__ as _legacy
@@ -71,10 +74,11 @@ except ImportError:
BotThread = _legacy.BotThread
LogQueue = _legacy.LogQueue
SCRIPT_DIR = Path(__file__).resolve().parent
SCRIPT_DIR = application_data_dir()
APP_SETTINGS_FILE = SCRIPT_DIR / "app_settings.json"
CUSTOMER_SERVICE_URL = "http://kf.zhenyangtang.com.cn/"
WIN_TITLE = "甄养堂 · 企微客服助手"
WIN_TITLE = f"甄养堂 · 企微客服助手 v{APP_VERSION}"
APP_ICON_PATH = resource_path("assets", "brand", "zhenyangtang-icon.png")
WECOM_WAITING_MESSAGE = (
"企业微信主界面未显示、未在前台或已最小化,正在尝试切到前台;"
"本轮失败时下一轮将自动重试。"
@@ -409,7 +413,7 @@ class PortalPage(QWidget):
)
def _install_light_theme(self) -> None:
css_path = SCRIPT_DIR / "edge_light_theme" / "light-theme.css"
css_path = resource_path("edge_light_theme", "light-theme.css")
try:
css = css_path.read_text(encoding="utf-8")
except OSError:
@@ -561,6 +565,7 @@ class DashboardPage(QScrollArea):
"waiting": COLORS["warning"],
"connecting": COLORS["warning"],
"error": COLORS["danger"],
"verification": COLORS["danger"],
"stopping": COLORS["warning"],
"stopped": COLORS["ink"],
}
@@ -639,7 +644,7 @@ class SettingsPage(QScrollArea):
for index, text in enumerate(
(
"企业微信 PC 端已登录,且账号状态正常。",
"如启用 AI,API 地址、密钥与模型配置已经保存",
"软件启动时会自动获取云端 AI 配置,无需本机填写模型参数",
"双屏环境可以使用不同缩放比例,Qt 会跟随当前屏幕自动缩放。",
),
start=1,
@@ -664,7 +669,7 @@ class SettingsPage(QScrollArea):
def values(self) -> dict:
return {
"auto_reply_text": self.reply.text().strip() or "你好",
"auto_reply_text": self.reply.text().strip() or "在的,您慢慢说,我这边看着呢。",
"poll_interval": self.poll.value(),
"mouse_idle_enabled": self.mouse_idle.isChecked(),
"mouse_idle_seconds": self.idle_seconds.value(),
@@ -1125,7 +1130,7 @@ class BackendLoginDialog(QDialog):
class PersonaPage(QScrollArea):
saved = Signal(bool, str)
backendSyncFinished = Signal(bool, str)
cloudSyncFinished = Signal(bool, str)
def __init__(self):
super().__init__()
@@ -1138,63 +1143,35 @@ class PersonaPage(QScrollArea):
layout = QVBoxLayout(root)
layout.setContentsMargins(28, 25, 28, 28)
layout.setSpacing(18)
layout.addLayout(_page_header("05", "AI 人格与能力", "统一管理模型、客服身份、上下文和 MCP 工具。"))
layout.addLayout(
_page_header(
"05",
"AI 人格与能力",
"模型与能力由云端统一管理,本机仅保留客服身份偏好。",
)
)
import ai_config
remote, remote_layout = _card(
"后台同步",
"在管理后台配置模型后,本机启动及运行期间会自动获取并应用",
identity, identity_layout = _card(
"客服与回复",
"客服名称留空时自动采用云端名称;填写后优先使用本机名称",
)
remote_row = QHBoxLayout()
remote_copy = QVBoxLayout()
self.remote_status = _label("正在读取后台连接…", "WarningText")
self.remote_meta = _label("", "CardSubtitle")
remote_copy.addWidget(self.remote_status)
remote_copy.addWidget(self.remote_meta)
remote_row.addLayout(remote_copy, 1)
self.logout_backend_button = _button("退出后台")
self.logout_backend_button.clicked.connect(self.logout_backend)
remote_row.addWidget(self.logout_backend_button)
self.manage_backend_button = _button("打开管理后台")
self.manage_backend_button.clicked.connect(self.open_backend_page)
remote_row.addWidget(self.manage_backend_button)
self.sync_backend_button = _button("立即同步")
self.sync_backend_button.clicked.connect(lambda: self.sync_from_backend(silent=False))
remote_row.addWidget(self.sync_backend_button)
self.login_backend_button = _button("登录后台", "primary")
self.login_backend_button.clicked.connect(self.open_backend_login)
remote_row.addWidget(self.login_backend_button)
remote_layout.addLayout(remote_row)
layout.addWidget(remote)
overview, overview_layout = _card("能力开关", "关闭 AI 后将使用通用设置中的固定回复。")
switch_grid = QGridLayout()
self.ai_enabled = QCheckBox("启用 AI 回复")
self.ai_enabled.setChecked(bool(ai_config.AI_ENABLED))
self.context_enabled = QCheckBox("启用会话上下文")
self.context_enabled.setChecked(bool(ai_config.AI_CONTEXT_ENABLED))
self.counter_enabled = QCheckBox("启用反辱骂策略")
self.counter_enabled.setChecked(bool(ai_config.AI_COUNTER_INSULT_ENABLED))
self.mcp_enabled = QCheckBox("启用 MCP 工具")
self.mcp_enabled.setChecked(bool(ai_config.AI_MCP_ENABLED))
for index, switch in enumerate(
(self.ai_enabled, self.context_enabled, self.counter_enabled, self.mcp_enabled)
):
switch_grid.addWidget(switch, index // 2, index % 2)
overview_layout.addLayout(switch_grid)
layout.addWidget(overview)
identity, identity_layout = _card("模型与身份", "保存后立即写入 ai_settings.json。")
form = QGridLayout()
form.setHorizontalSpacing(18)
form.setVerticalSpacing(8)
self.api_base = self._field(form, 0, 0, "API 地址", ai_config.AI_API_BASE)
self.model = self._field(form, 0, 1, "模型名称", ai_config.AI_MODEL)
self.api_key = self._field(form, 2, 0, "API Key", ai_config.AI_API_KEY)
self.api_key.setEchoMode(QLineEdit.Password)
self.agent_name = self._field(form, 2, 1, "客服名称", ai_config.AI_AGENT_NAME)
self.hospital = self._field(form, 4, 0, "机构名称", ai_config.AI_HOSPITAL_NAME, 1, 2)
self.agent_name = self._field(
form,
0,
0,
"本机客服名称(可留空)",
ai_config.local_agent_name_override(),
1,
2,
)
self.agent_name.setPlaceholderText(
f"云端客服名称:{ai_config.AI_CLOUD_AGENT_NAME}"
)
self.rounds = QSpinBox()
self.rounds.setRange(1, 50)
self.rounds.setValue(int(ai_config.AI_CONTEXT_MAX_ROUNDS))
@@ -1205,12 +1182,12 @@ class PersonaPage(QScrollArea):
self.temperature.setRange(0.0, 2.0)
self.temperature.setSingleStep(0.05)
self.temperature.setValue(float(ai_config.AI_TEMPERATURE))
form.addWidget(_label("上下文轮数", "CardSubtitle"), 6, 0)
form.addWidget(_label("最大回复 tokens", "CardSubtitle"), 6, 1)
form.addWidget(self.rounds, 7, 0)
form.addWidget(self.max_tokens, 7, 1)
form.addWidget(_label("温度", "CardSubtitle"), 8, 0)
form.addWidget(self.temperature, 9, 0)
form.addWidget(_label("上下文轮数", "CardSubtitle"), 2, 0)
form.addWidget(_label("最大回复 tokens", "CardSubtitle"), 2, 1)
form.addWidget(self.rounds, 3, 0)
form.addWidget(self.max_tokens, 3, 1)
form.addWidget(_label("温度", "CardSubtitle"), 4, 0)
form.addWidget(self.temperature, 5, 0)
form.setColumnStretch(0, 1)
form.setColumnStretch(1, 1)
identity_layout.addLayout(form)
@@ -1236,32 +1213,14 @@ class PersonaPage(QScrollArea):
actions = QHBoxLayout()
self.save_status = _label("", "SuccessText")
actions.addWidget(self.save_status, 1)
self.save_button = _button("保存 AI 配置", "primary")
self.save_button = _button("保存本地偏好", "primary")
self.save_button.clicked.connect(self.save_config)
actions.addWidget(self.save_button)
layout.addLayout(actions)
layout.addStretch(1)
self._backend_sync_running = False
self._backend_sync_silent = False
self.backendSyncFinished.connect(self._backend_sync_finished)
self._managed_widgets = (
self.ai_enabled,
self.context_enabled,
self.counter_enabled,
self.mcp_enabled,
self.api_base,
self.model,
self.api_key,
self.agent_name,
self.hospital,
self.rounds,
self.max_tokens,
self.temperature,
self.mcp_json,
self.mcp_rounds,
self.save_button,
)
self.refresh_backend_status()
self._cloud_sync_running = False
self._cloud_sync_silent = False
self.cloudSyncFinished.connect(self._cloud_sync_finished)
@staticmethod
def _field(
@@ -1279,35 +1238,13 @@ class PersonaPage(QScrollArea):
return field
def save_config(self) -> None:
try:
import backend_client
remote_managed = backend_client.is_configured() and bool(
backend_client.load_settings().get("auto_sync", True)
)
except Exception:
remote_managed = False
if remote_managed:
self.save_status.setText("当前由后台统一管理,请在后台修改后点击“立即同步”")
self.save_status.setObjectName("WarningText")
self.save_status.style().unpolish(self.save_status)
self.save_status.style().polish(self.save_status)
return
try:
servers = json.loads(self.mcp_json.toPlainText().strip() or "[]")
if not isinstance(servers, list):
raise ValueError("MCP JSON 根节点必须是数组")
import ai_config
ai_config.AI_ENABLED = self.ai_enabled.isChecked()
ai_config.AI_CONTEXT_ENABLED = self.context_enabled.isChecked()
ai_config.AI_COUNTER_INSULT_ENABLED = self.counter_enabled.isChecked()
ai_config.AI_MCP_ENABLED = self.mcp_enabled.isChecked()
ai_config.AI_API_BASE = self.api_base.text().strip()
ai_config.AI_API_KEY = self.api_key.text().strip()
ai_config.AI_MODEL = self.model.text().strip()
ai_config.AI_AGENT_NAME = self.agent_name.text().strip()
ai_config.AI_HOSPITAL_NAME = self.hospital.text().strip()
ai_config.set_local_agent_name_override(self.agent_name.text().strip())
ai_config.AI_CONTEXT_MAX_ROUNDS = self.rounds.value()
ai_config.AI_MAX_TOKENS = self.max_tokens.value()
ai_config.AI_TEMPERATURE = self.temperature.value()
@@ -1320,144 +1257,46 @@ class PersonaPage(QScrollArea):
self.save_status.setObjectName("DangerText")
self.saved.emit(False, str(exc))
else:
self.save_status.setText("配置已保存并立即生效")
self.save_status.setText("本地偏好已保存并立即生效")
self.save_status.setObjectName("SuccessText")
self.saved.emit(True, "AI 配置已保存并生效")
self.saved.emit(True, "AI 本地偏好已保存并生效")
self.save_status.style().unpolish(self.save_status)
self.save_status.style().polish(self.save_status)
def open_backend_login(self) -> None:
dialog = BackendLoginDialog(self)
if dialog.exec() == QDialog.Accepted:
self.reload_from_ai_config()
self.refresh_backend_status()
self.saved.emit(True, "后台已登录,模型配置已自动同步")
def open_backend_page(self) -> None:
try:
import backend_client
url = backend_client.load_settings().get(
"server_url", backend_client.DEFAULT_SERVER_URL
)
QDesktopServices.openUrl(QUrl(str(url)))
except Exception as exc:
self.saved.emit(False, f"无法打开管理后台:{exc}")
def logout_backend(self) -> None:
answer = QMessageBox.question(
self,
"退出配置后台",
"退出后将停止自动同步,但已下发到本机的配置会继续保留。确定退出吗?",
)
if answer != QMessageBox.Yes:
def sync_from_cloud(self, *, silent: bool = True) -> None:
if self._cloud_sync_running:
return
try:
import backend_client
backend_client.logout()
except Exception as exc:
self.saved.emit(False, f"退出后台失败:{exc}")
return
self.refresh_backend_status()
self.saved.emit(True, "已退出配置后台,当前配置保留在本机")
def refresh_backend_status(self) -> None:
try:
import backend_client
summary = backend_client.connection_summary()
except Exception as exc:
self.remote_status.setObjectName("DangerText")
self.remote_status.setText(f"后台连接配置读取失败:{exc}")
return
configured = bool(summary["configured"])
authenticated = bool(summary.get("authenticated"))
managed = configured and bool(summary["auto_sync"])
if configured:
last_error = str(summary.get("last_error") or "")
self.remote_status.setObjectName("DangerText" if last_error else "SuccessText")
self.remote_status.setText(
f"最近同步失败 · {last_error}"
if last_error
else (
f"已连接 · {summary['username']} · 配置 v{summary['last_version']}"
if authenticated
else f"已发现本机后台 · 配置 v{summary['last_version']}"
)
)
sync_at = summary.get("last_sync_at") or "尚未同步"
self.remote_meta.setText(f"{summary['server_url']} · 最后同步 {sync_at}")
else:
self.remote_status.setObjectName("WarningText")
self.remote_status.setText("尚未登录配置后台")
self.remote_meta.setText(f"默认地址 {summary['server_url']} · 当前使用本机配置")
self.remote_status.style().unpolish(self.remote_status)
self.remote_status.style().polish(self.remote_status)
self.login_backend_button.setVisible(not authenticated)
self.login_backend_button.setText("账号登录" if configured else "登录后台")
self.logout_backend_button.setVisible(authenticated)
self.sync_backend_button.setVisible(configured)
for widget in self._managed_widgets:
widget.setEnabled(not managed)
self.save_status.setText("后台统一管理中,本地字段为只读" if managed else "")
self.save_status.setObjectName("SuccessText" if managed else "CardSubtitle")
self.save_status.style().unpolish(self.save_status)
self.save_status.style().polish(self.save_status)
def sync_from_backend(self, *, silent: bool = True) -> None:
if self._backend_sync_running:
return
try:
import backend_client
if not backend_client.is_configured():
return
except Exception as exc:
if not silent:
self.saved.emit(False, f"后台连接配置读取失败:{exc}")
return
self._backend_sync_running = True
self._backend_sync_silent = silent
self.sync_backend_button.setEnabled(False)
self.remote_status.setObjectName("WarningText")
self.remote_status.setText("正在从后台同步模型配置…")
self.remote_status.style().unpolish(self.remote_status)
self.remote_status.style().polish(self.remote_status)
self._cloud_sync_running = True
self._cloud_sync_silent = silent
def worker() -> None:
try:
result = backend_client.sync_config(force=not silent)
message = str(result.get("message") or "后台配置同步完成")
import backend_client
result = backend_client.sync_cloud_config()
message = str(result.get("message") or "云端配置同步完成")
except Exception as exc:
self.backendSyncFinished.emit(False, str(exc))
self.cloudSyncFinished.emit(False, str(exc))
else:
self.backendSyncFinished.emit(True, message)
self.cloudSyncFinished.emit(True, message)
threading.Thread(target=worker, daemon=True).start()
def _backend_sync_finished(self, ok: bool, message: str) -> None:
silent = self._backend_sync_silent
self._backend_sync_running = False
self.sync_backend_button.setEnabled(True)
def _cloud_sync_finished(self, ok: bool, message: str) -> None:
silent = self._cloud_sync_silent
self._cloud_sync_running = False
if ok:
self.reload_from_ai_config()
self.refresh_backend_status()
if not silent or not ok:
self.saved.emit(ok, message if ok else f"后台同步失败:{message}")
self.saved.emit(ok, message if ok else f"云端配置同步失败:{message}")
def reload_from_ai_config(self) -> None:
import ai_config
self.ai_enabled.setChecked(bool(ai_config.AI_ENABLED))
self.context_enabled.setChecked(bool(ai_config.AI_CONTEXT_ENABLED))
self.counter_enabled.setChecked(bool(ai_config.AI_COUNTER_INSULT_ENABLED))
self.mcp_enabled.setChecked(bool(ai_config.AI_MCP_ENABLED))
self.api_base.setText(str(ai_config.AI_API_BASE or ""))
self.model.setText(str(ai_config.AI_MODEL or ""))
self.api_key.setText(str(ai_config.AI_API_KEY or ""))
self.agent_name.setText(str(ai_config.AI_AGENT_NAME or ""))
self.hospital.setText(str(ai_config.AI_HOSPITAL_NAME or ""))
self.agent_name.setText(ai_config.local_agent_name_override())
self.agent_name.setPlaceholderText(
f"云端客服名称:{ai_config.AI_CLOUD_AGENT_NAME}"
)
self.rounds.setValue(int(ai_config.AI_CONTEXT_MAX_ROUNDS))
self.max_tokens.setValue(int(ai_config.AI_MAX_TOKENS))
self.temperature.setValue(float(ai_config.AI_TEMPERATURE))
@@ -1630,6 +1469,7 @@ class CapsuleWindow(QWidget):
"connecting": "#B67820",
"stopping": "#B67820",
"error": "#D85260",
"verification": "#D85260",
}
hints = {
"running": "安全运行中",
@@ -1637,6 +1477,7 @@ class CapsuleWindow(QWidget):
"connecting": "正在连接窗口",
"stopping": "正在结束任务",
"error": "请查看运行日志",
"verification": "请先用手机扫码验证",
"stopped": "监听已停止",
}
color = colors.get(state, "#7B8D84")
@@ -1707,6 +1548,7 @@ class Sidebar(QFrame):
"connecting": "#F2BC64",
"stopping": "#F2BC64",
"error": "#FF818E",
"verification": "#FF818E",
"stopped": "#A5B9B0",
}
self.status_title.setText(f"{text}")
@@ -1797,10 +1639,9 @@ class MainWindow(QMainWindow):
self.backend_sync_timer = QTimer(self)
self.backend_sync_timer.setInterval(max(60, sync_seconds) * 1000)
self.backend_sync_timer.timeout.connect(
lambda: self.persona_page.sync_from_backend(silent=True)
lambda: self.persona_page.sync_from_cloud(silent=True)
)
self.backend_sync_timer.start()
QTimer.singleShot(350, lambda: self.persona_page.sync_from_backend(silent=True))
def _set_initial_geometry(self) -> None:
screen = QApplication.primaryScreen()
@@ -1826,7 +1667,7 @@ class MainWindow(QMainWindow):
@staticmethod
def _runtime_defaults() -> dict:
return {
"auto_reply_text": "你好",
"auto_reply_text": "在的,您慢慢说,我这边看着呢。",
"poll_interval": 2.0,
"mouse_idle_enabled": True,
"mouse_idle_seconds": 20.0,
@@ -1844,7 +1685,10 @@ class MainWindow(QMainWindow):
settings["poll_interval"] = max(0.2, float(settings["poll_interval"]))
settings["mouse_idle_seconds"] = max(0.0, float(settings["mouse_idle_seconds"]))
settings["mouse_idle_enabled"] = bool(settings["mouse_idle_enabled"])
settings["auto_reply_text"] = str(settings["auto_reply_text"]).strip() or "你好"
settings["auto_reply_text"] = (
str(settings["auto_reply_text"]).strip()
or "在的,您慢慢说,我这边看着呢。"
)
except (TypeError, ValueError):
return self._runtime_defaults()
return settings
@@ -1910,6 +1754,7 @@ class MainWindow(QMainWindow):
"running": "安全运行中,检测到未读消息后自动处理",
"stopping": "等待后台任务退出",
"error": "连接失败,请查看运行日志",
"verification": "请用手机企业微信扫码验证,完成后重新开始监听",
"stopped": "等待连接企业微信",
}
hint = hints.get(state, text)
@@ -1922,6 +1767,14 @@ class MainWindow(QMainWindow):
return
self._saved_geometry = self.saveGeometry()
self._was_maximized = self.isMaximized()
# QWebEngineView owns a native Chromium surface. Hiding the top-level
# window while that surface is still visible can leave a large black DWM
# window above WeCom on some Windows/GPU combinations. Tear down the
# visible surface first and let Qt flush that state before hiding us.
self._portal_was_visible = self.portal_page.view.isVisible()
if self._portal_was_visible:
self.portal_page.view.hide()
QApplication.processEvents()
self.capsule.show_near(self)
self.hide()
@@ -1935,6 +1788,13 @@ class MainWindow(QMainWindow):
self.showNormal()
self.raise_()
self.activateWindow()
if getattr(self, "_portal_was_visible", False) and self.stack.currentIndex() == 0:
def restore_portal() -> None:
self.portal_page.view.show()
self.portal_page.view.setFocus(Qt.OtherFocusReason)
QTimer.singleShot(80, restore_portal)
self._portal_was_visible = False
def _finish_thread(self, final_state: str) -> None:
self._running = False
@@ -1999,6 +1859,10 @@ class MainWindow(QMainWindow):
self.set_status("waiting", "等待企业微信")
elif data == "error":
self._finish_thread("error")
elif data == "verification":
self._finish_thread("verification")
self.set_status("verification", "需要扫码验证")
self.show_page(5)
elif data == "stopped":
self._finish_thread("stopped")
elif kind == "stats":
@@ -2029,6 +1893,10 @@ class MainWindow(QMainWindow):
def _app_icon() -> QIcon:
icon = QIcon(str(APP_ICON_PATH))
if not icon.isNull():
return icon
pixmap = QPixmap(64, 64)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
@@ -2066,6 +1934,74 @@ def install_console_shutdown_handler(app: QApplication, window: MainWindow):
return request_shutdown
def handle_startup_update(release: object) -> bool:
"""显示启动升级提示;返回 False 表示本次不能进入软件。"""
status = release_status(release)
if not status["update_available"]:
return True
forced = status["force_upgrade"]
message = QMessageBox()
message.setIcon(QMessageBox.Icon.Critical if forced else QMessageBox.Icon.Information)
message.setWindowTitle("必须升级" if forced else "发现新版本")
message.setText(
f"当前版本:v{status['local_version']}\n"
f"云端版本:v{status['latest_version']}"
)
message.setInformativeText(
"管理员已设置强制升级,升级前无法继续使用。"
if forced
else "是否现在前往下载新版本?也可以稍后继续使用。"
)
if status["release_notes"]:
message.setDetailedText(status["release_notes"][:4000])
upgrade_button = message.addButton("立即升级", QMessageBox.ButtonRole.AcceptRole)
message.addButton(
"退出软件" if forced else "稍后使用", QMessageBox.ButtonRole.RejectRole
)
message.setDefaultButton(upgrade_button)
message.exec()
if message.clickedButton() is upgrade_button:
download_url = status["download_url"]
if not download_url or not QDesktopServices.openUrl(QUrl(download_url)):
QMessageBox.warning(
None,
"无法打开下载地址",
"升级下载地址暂时无法打开,请联系管理员。",
)
return not forced
return not forced
def run_packaging_self_check(app: QApplication) -> int:
"""离线验证随 EXE 打包的 Qt WebEngine 能否真正创建并加载页面。"""
probe = QWebEngineView()
state = {"finished": False}
def finish(ok: bool) -> None:
if state["finished"]:
return
state["finished"] = True
probe.close()
app.exit(0 if ok else 3)
def timeout() -> None:
if not state["finished"]:
state["finished"] = True
probe.close()
app.exit(4)
probe.loadFinished.connect(finish)
probe.setGeometry(-32000, -32000, 320, 180)
probe.show()
probe.setHtml(
"<html><meta charset='utf-8'><body>Qt WebEngine packaging check</body></html>"
)
QTimer.singleShot(30_000, timeout)
return app.exec()
def main() -> None:
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
@@ -2080,13 +2016,20 @@ def main() -> None:
font.setHintingPreference(QFont.PreferFullHinting)
app.setFont(font)
if "--packaging-self-check" in sys.argv:
raise SystemExit(run_packaging_self_check(app))
startup_result = {}
try:
import backend_client
backend_client.startup_sync_config(timeout=3.0)
startup_result = backend_client.startup_sync_config(timeout=3.0)
except Exception:
# 启动检测失败不阻止软件打开,具体原因会显示在后台同步卡片中
pass
# 云端暂时不可用时仍执行上次成功同步的强制升级策略
startup_result = {"release": backend_client.cached_release_status()}
if not handle_startup_update(startup_result.get("release")):
return
window = MainWindow()
console_shutdown = install_console_shutdown_handler(app, window)