初始提交:识流 AI 助手项目

微信自动回复机器人,基于截图+OCR识别消息,支持关键词规则和 AI(OpenAI/DeepSeek/Dify)自动回复。
技术栈:PySide6 + Flask + Vue3 + RapidOCR + SQLite

注:OCR大模型文件(.onnx / .pdiparams)不纳入版本控制,需单独下载。

🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
figmar
2026-05-30 14:57:45 +08:00
commit 81115dc23d
129 changed files with 56398 additions and 0 deletions

View File

View File

@@ -0,0 +1,238 @@
import os
import sys
import threading
from urllib import parse, request
import requests
from PySide6.QtCore import QTimer, Qt, QObject, Slot
from PySide6.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QMessageBox
from app.application.services import AppConfig, BackendRuntime, FrontendRuntime
from app.presentation.shell import LoadingWindowController, WebViewShellController
print(f"[GUI] Python executable: {sys.executable}")
try:
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWebChannel import QWebChannel
WEB_ENGINE_AVAILABLE = True
except Exception:
WEB_ENGINE_AVAILABLE = False
QWebEngineView = None
QWebChannel = None
class WindowBridge(QObject):
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
@Slot()
def open_devtools(self):
self.main_window.open_devtools()
@Slot()
def minimize(self):
self.main_window.showMinimized()
@Slot()
def maximize_or_restore(self):
if self.main_window.isMaximized():
self.main_window.showNormal()
else:
self.main_window.showMaximized()
@Slot()
def close_window(self):
self.main_window.close()
@Slot()
def start_move(self):
wh = self.main_window.windowHandle()
if wh is not None:
wh.startSystemMove()
class MainWindow(QMainWindow):
def post_settings(self, **kwargs):
if not self.base_url:
return
try:
payload = {"action": "settings_set"}
for k, v in kwargs.items():
payload[k] = "1" if v is True else "0" if v is False else str(v)
data = parse.urlencode(payload).encode("utf-8")
req = request.Request(self.base_url + "/api/rules", data=data, method="POST")
with request.urlopen(req, timeout=2):
pass
except Exception:
pass
def __init__(self):
super().__init__()
self.setWindowTitle("识流 AI 助手")
self.resize(1280, 920)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.Window)
self.hide()
self.config = AppConfig()
self.backend_runtime = BackendRuntime(self.config)
self.frontend_runtime = FrontendRuntime(self.config, self.backend_runtime)
self.base_url = os.getenv("APP_BASE_URL", self.backend_runtime.base_url)
self.backend_process = None
self.backend_started_by_gui = False
self.backend_boot_thread = None
self.web_channel = None
self.window_bridge = None
self.loading = LoadingWindowController()
self.web_shell = None
self.frontend_entry_url = ""
central = QWidget()
central.setStyleSheet("background:#eef2f7;")
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
self.web = None
self.devtools_view = None
if WEB_ENGINE_AVAILABLE:
self.web = QWebEngineView()
self.web.setStyleSheet("background:#eef2f7;")
root.addWidget(self.web, 1)
self.web_channel = QWebChannel(self.web.page())
self.window_bridge = WindowBridge(self)
self.web_channel.registerObject("windowBridge", self.window_bridge)
self.web.page().setWebChannel(self.web_channel)
self.web.loadFinished.connect(self.on_main_page_loaded)
self.web_shell = WebViewShellController(self.web)
self.listener_sync_timer = QTimer(self)
self.listener_sync_timer.setInterval(30000)
self.listener_sync_timer.timeout.connect(self.sync_listener_state)
self._syncing_listener_state = False
self.backend_ready_timer = QTimer(self)
self.backend_ready_timer.setInterval(100)
self.backend_ready_timer.timeout.connect(self.consume_backend_boot_result)
self._backend_boot_result = None
self.loading.show()
self.start_backend()
def get_settings(self):
if not self.base_url:
return {}
try:
url = self.base_url + "/api/rules?action=settings_get"
with request.urlopen(url, timeout=2) as resp:
data = resp.read().decode("utf-8")
import json
payload = json.loads(data)
return payload if isinstance(payload, dict) else {}
except Exception:
return {}
def sync_listener_state(self):
if self._syncing_listener_state:
return
self._syncing_listener_state = True
try:
if not self.base_url:
return
requests.get(self.base_url + "/api/bot/status", timeout=2)
except Exception:
pass
finally:
self._syncing_listener_state = False
def start_backend(self):
self.base_url = self.backend_runtime.base_url
self._backend_boot_result = None
self.backend_boot_thread = threading.Thread(target=self._boot_backend_worker, daemon=True)
self.backend_boot_thread.start()
self.backend_ready_timer.start()
def _boot_backend_worker(self):
try:
if self.backend_runtime.is_backend_alive():
self.backend_started_by_gui = False
self._backend_boot_result = {"ok": True, "url": self.base_url}
return
process = self.backend_runtime.launch_backend_process()
self.backend_process = process
self.backend_started_by_gui = True
ok, message = self.frontend_runtime.wait_for_backend(process=process)
if ok:
self._backend_boot_result = {"ok": True, "url": self.base_url}
else:
self._backend_boot_result = {"ok": False, "message": message}
except Exception as exc:
self._backend_boot_result = {"ok": False, "message": str(exc)}
def consume_backend_boot_result(self):
if self._backend_boot_result is None:
return
result = self._backend_boot_result
self._backend_boot_result = None
self.backend_ready_timer.stop()
if result.get("ok"):
self.on_backend_started(result["url"])
return
self.on_backend_failed(result.get("message") or "后台启动失败")
def on_backend_started(self, url):
self.base_url = url
self.frontend_entry_url = self.frontend_runtime.resolve_frontend_entry_url(url)
self.reload_admin_page()
self.listener_sync_timer.start()
def on_backend_failed(self, msg):
self.loading.close()
QMessageBox.critical(self, "后台错误", msg)
def reload_admin_page(self):
self.web_shell.load(self.frontend_entry_url)
def on_main_page_loaded(self, ok):
if not self.web_shell.mark_loaded(ok):
return
self.loading.finish_with(self._finish_show_main)
def _finish_show_main(self):
if not self.web_shell.can_finish():
return
self.web_shell.finish()
self.show()
self.raise_()
self.activateWindow()
QTimer.singleShot(60, self.loading.close)
def open_devtools(self):
if self.web is None:
return
if self.devtools_view is None:
self.devtools_view = QWebEngineView()
self.devtools_view.setWindowTitle("页面调试控制台")
self.devtools_view.resize(1100, 760)
self.web.page().setDevToolsPage(self.devtools_view.page())
self.devtools_view.show()
self.devtools_view.raise_()
self.devtools_view.activateWindow()
def closeEvent(self, event):
try:
self.listener_sync_timer.stop()
except Exception:
pass
try:
self.backend_ready_timer.stop()
except Exception:
pass
try:
if self.backend_started_by_gui:
self.backend_runtime.terminate_backend_process(self.backend_process)
except Exception:
pass
super().closeEvent(event)

95
app/presentation/shell.py Normal file
View File

@@ -0,0 +1,95 @@
import time
from PySide6.QtCore import QTimer, Qt, QUrl
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QProgressBar
class LoadingWindowController:
def __init__(self):
self.loading_window = None
self.loading_min_ms = 700
self.loading_started_ms = 0.0
def show(self):
self.loading_started_ms = time.time() * 1000
if self.loading_window is not None:
self.loading_window.show()
self.loading_window.raise_()
self.loading_window.activateWindow()
return self.loading_window
win = QWidget()
win.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
win.setAttribute(Qt.WA_DeleteOnClose, True)
win.resize(560, 220)
screen = QGuiApplication.primaryScreen()
if screen is not None:
geo = screen.availableGeometry()
win.move(geo.center().x() - win.width() // 2, geo.center().y() - win.height() // 2)
win.setStyleSheet("background:qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #f8fbff, stop:1 #eef4fb);border:1px solid #dbe7f5;border-radius:16px;")
lay = QVBoxLayout(win)
lay.setContentsMargins(30, 28, 30, 28)
lay.setSpacing(10)
title = QLabel("识流 AI 助手")
title.setStyleSheet("font-size:18px;font-weight:700;color:#0f172a;")
label = QLabel("正在启动服务与管理台…")
label.setStyleSheet("font-size:14px;color:#334155;")
hint = QLabel("首次启动会稍慢一点,请稍候")
hint.setStyleSheet("font-size:12px;color:#64748b;")
bar = QProgressBar()
bar.setRange(0, 0)
bar.setTextVisible(False)
bar.setFixedHeight(8)
bar.setStyleSheet("QProgressBar{background:#e5edf8;border:0;border-radius:4px;}QProgressBar::chunk{background:qlineargradient(x1:0,y1:0,x2:1,y2:0, stop:0 #22c1c3, stop:1 #3b82f6);border-radius:4px;}")
lay.addWidget(title)
lay.addWidget(label)
lay.addSpacing(4)
lay.addWidget(bar)
lay.addWidget(hint)
lay.addStretch(1)
self.loading_window = win
self.loading_window.show()
self.loading_window.raise_()
self.loading_window.activateWindow()
return self.loading_window
def close(self):
if self.loading_window is not None:
self.loading_window.close()
self.loading_window = None
def finish_with(self, callback):
elapsed = int(time.time() * 1000 - self.loading_started_ms) if self.loading_started_ms else self.loading_min_ms
remain = max(0, self.loading_min_ms - elapsed)
QTimer.singleShot(remain + 120, callback)
class WebViewShellController:
def __init__(self, web_view):
self.web_view = web_view
self.main_page_ready = False
self.expecting_admin_load = False
def load(self, entry_url):
if self.web_view is None:
return
self.main_page_ready = False
self.expecting_admin_load = True
self.web_view.setStyleSheet("background:#eef2f7;")
self.web_view.load(QUrl(entry_url))
def mark_loaded(self, ok):
if not self.expecting_admin_load or not ok:
return False
self.main_page_ready = True
return True
def can_finish(self):
return self.main_page_ready
def finish(self):
self.expecting_admin_load = False