初始提交:识流 AI 助手项目
微信自动回复机器人,基于截图+OCR识别消息,支持关键词规则和 AI(OpenAI/DeepSeek/Dify)自动回复。 技术栈:PySide6 + Flask + Vue3 + RapidOCR + SQLite 注:OCR大模型文件(.onnx / .pdiparams)不纳入版本控制,需单独下载。 🤖 Generated with [Qoder][https://qoder.com]
@@ -0,0 +1,15 @@
|
||||
ocr_debug_images/*
|
||||
logs/*
|
||||
frontend/node_modules/*
|
||||
frontend/src-tauri/resources/*
|
||||
!frontend/src-tauri/resources/.gitkeep
|
||||
app/resources/ocr_models/variants/*
|
||||
# 大型模型文件(需单独下载)
|
||||
app/resources/ocr_models/*.onnx
|
||||
vendor/paddleocr/official_models/**/*.pdiparams
|
||||
.build/*
|
||||
|
||||
.venv/*
|
||||
**/__pycache__/*
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import os
|
||||
import threading
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
|
||||
from app.infrastructure.wechat_multi_chat_bot import WechatMultiChatBot
|
||||
|
||||
|
||||
class BotController:
|
||||
def __init__(self):
|
||||
self._lock = threading.RLock()
|
||||
self._thread = None
|
||||
self._bot = None
|
||||
self._status = "stopped"
|
||||
self._last_error = ""
|
||||
self._started_at = ""
|
||||
self._stopped_at = ""
|
||||
self._listeners = {}
|
||||
self._listener_seq = 0
|
||||
|
||||
def start(self, backend_url):
|
||||
with self._lock:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return self._status_payload_locked()
|
||||
self._status = "starting"
|
||||
self._last_error = ""
|
||||
self._stopped_at = ""
|
||||
os.environ["BACKEND_URL"] = backend_url
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name="WechatBotThread")
|
||||
self._thread.start()
|
||||
status = self._status_payload_locked()
|
||||
listeners = list(self._listeners.values())
|
||||
self._notify_status_listeners(listeners, status)
|
||||
return status
|
||||
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
if not self._thread or not self._thread.is_alive():
|
||||
self._status = "stopped"
|
||||
self._bot = None
|
||||
self._stopped_at = self._now()
|
||||
status = self._status_payload_locked()
|
||||
listeners = list(self._listeners.values())
|
||||
self._notify_status_listeners(listeners, status)
|
||||
return status
|
||||
self._status = "stopping"
|
||||
bot = self._bot
|
||||
status = self._status_payload_locked()
|
||||
listeners = list(self._listeners.values())
|
||||
self._notify_status_listeners(listeners, status)
|
||||
if bot:
|
||||
bot.stop()
|
||||
return status
|
||||
|
||||
def status(self):
|
||||
listeners = None
|
||||
with self._lock:
|
||||
if self._status in {"running", "starting", "stopping"} and self._thread and not self._thread.is_alive():
|
||||
if self._status != "error":
|
||||
self._status = "stopped"
|
||||
self._bot = None
|
||||
self._stopped_at = self._stopped_at or self._now()
|
||||
listeners = list(self._listeners.values())
|
||||
status = self._status_payload_locked()
|
||||
if listeners is not None:
|
||||
self._notify_status_listeners(listeners, status)
|
||||
return status
|
||||
|
||||
def add_status_listener(self, callback, emit_initial=False):
|
||||
with self._lock:
|
||||
self._listener_seq += 1
|
||||
listener_id = self._listener_seq
|
||||
self._listeners[listener_id] = callback
|
||||
status = self._status_payload_locked()
|
||||
if emit_initial:
|
||||
try:
|
||||
callback(status)
|
||||
except Exception:
|
||||
pass
|
||||
return listener_id
|
||||
|
||||
def remove_status_listener(self, listener_id):
|
||||
with self._lock:
|
||||
self._listeners.pop(listener_id, None)
|
||||
|
||||
def _run(self):
|
||||
try:
|
||||
bot = WechatMultiChatBot()
|
||||
with self._lock:
|
||||
self._bot = bot
|
||||
self._status = "running"
|
||||
self._started_at = self._now()
|
||||
status = self._status_payload_locked()
|
||||
listeners = list(self._listeners.values())
|
||||
self._notify_status_listeners(listeners, status)
|
||||
bot.run_forever()
|
||||
with self._lock:
|
||||
self._status = "stopped"
|
||||
self._bot = None
|
||||
self._stopped_at = self._now()
|
||||
status = self._status_payload_locked()
|
||||
listeners = list(self._listeners.values())
|
||||
self._notify_status_listeners(listeners, status)
|
||||
except Exception:
|
||||
with self._lock:
|
||||
self._status = "error"
|
||||
self._bot = None
|
||||
self._last_error = traceback.format_exc()
|
||||
self._stopped_at = self._now()
|
||||
status = self._status_payload_locked()
|
||||
listeners = list(self._listeners.values())
|
||||
self._notify_status_listeners(listeners, status)
|
||||
|
||||
def _status_payload_locked(self):
|
||||
return {
|
||||
"status": self._status,
|
||||
"running": self._status == "running",
|
||||
"last_error": self._last_error,
|
||||
"started_at": self._started_at,
|
||||
"stopped_at": self._stopped_at,
|
||||
}
|
||||
|
||||
def _notify_status_listeners(self, listeners, status):
|
||||
for callback in listeners:
|
||||
try:
|
||||
callback(status)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _now(self):
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
bot_controller = BotController()
|
||||
@@ -0,0 +1,157 @@
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class AppConfig:
|
||||
def __init__(self):
|
||||
self.project_root = Path(__file__).resolve().parents[2]
|
||||
self.runtime_mode = self._normalize_runtime_mode(os.getenv("OPENCLAW_RUNTIME_MODE") or os.getenv("OPENCLAW_BACKEND_MODE"))
|
||||
self.venv_root = self.project_root / ".venv"
|
||||
self.venv_python = self.venv_root / "Scripts" / "python.exe"
|
||||
self.venv_pythonw = self.venv_root / "Scripts" / "pythonw.exe"
|
||||
self.host = os.getenv("APP_HOST", "127.0.0.1")
|
||||
self.port = int(os.getenv("APP_PORT", "5000"))
|
||||
|
||||
def _normalize_runtime_mode(self, value):
|
||||
mode = (value or "").strip().lower()
|
||||
if mode in {"release", "prod", "production", "bundle", "packaged"}:
|
||||
return "release"
|
||||
return "dev"
|
||||
|
||||
@property
|
||||
def base_url(self):
|
||||
return f"http://{self.host}:{self.port}"
|
||||
|
||||
@property
|
||||
def python_executable(self):
|
||||
return Path(sys.executable).resolve()
|
||||
|
||||
@property
|
||||
def python_prefix(self):
|
||||
return Path(sys.prefix).resolve()
|
||||
|
||||
@property
|
||||
def is_release_mode(self):
|
||||
return self.runtime_mode == "release"
|
||||
|
||||
@property
|
||||
def backend_entry(self):
|
||||
return self.project_root / "backend_main.py"
|
||||
|
||||
@property
|
||||
def is_running_in_project_venv(self):
|
||||
if self.is_release_mode:
|
||||
return False
|
||||
try:
|
||||
if self.python_prefix == self.venv_root.resolve():
|
||||
return True
|
||||
allowed_targets = {
|
||||
self.venv_python.resolve(),
|
||||
self.venv_pythonw.resolve(),
|
||||
}
|
||||
return self.python_executable in allowed_targets
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class BackendRuntime:
|
||||
def __init__(self, config=None):
|
||||
self.config = config or AppConfig()
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
return self.config.host
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
return self.config.port
|
||||
|
||||
@property
|
||||
def base_url(self):
|
||||
return self.config.base_url
|
||||
|
||||
@property
|
||||
def backend_entry(self):
|
||||
return self.config.backend_entry
|
||||
|
||||
def is_backend_alive(self, host=None, port=None, timeout=0.4):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
return sock.connect_ex((host or self.host, port or self.port)) == 0
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
def launch_backend_process(self):
|
||||
if self.config.is_release_mode:
|
||||
raise RuntimeError("当前运行模式为发布态,不能从 Python 壳层再启动 backend_main.py")
|
||||
backend_python = self.config.venv_python
|
||||
if not backend_python.exists():
|
||||
raise RuntimeError(f"未找到后端解释器: {backend_python}")
|
||||
backend_entry = self.backend_entry
|
||||
if not backend_entry.exists():
|
||||
raise RuntimeError(f"未找到后端入口: {backend_entry}")
|
||||
env = os.environ.copy()
|
||||
env["OPENCLAW_PROJECT_VENV_REEXEC"] = "1"
|
||||
env.setdefault("OPENCLAW_BACKEND_MODE", "dev")
|
||||
env.setdefault("OPENCLAW_RUNTIME_MODE", "dev")
|
||||
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
return subprocess.Popen(
|
||||
[str(backend_python), str(backend_entry)],
|
||||
cwd=str(self.config.project_root),
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
|
||||
def terminate_backend_process(self, process, timeout=3):
|
||||
if process is None or process.poll() is not None:
|
||||
return
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=timeout)
|
||||
except Exception:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
|
||||
|
||||
class FrontendRuntime:
|
||||
def __init__(self, config=None, backend_runtime=None):
|
||||
self.config = config or AppConfig()
|
||||
self.backend_runtime = backend_runtime or BackendRuntime(self.config)
|
||||
self.frontend_dev_url = os.getenv("FRONTEND_DEV_URL", "http://127.0.0.1:5173").strip().rstrip("/")
|
||||
self.frontend_dist_index = self.config.project_root / "frontend" / "dist" / "index.html"
|
||||
self.backend_wait_timeout_ms = 15000
|
||||
self.backend_wait_interval_ms = 200
|
||||
|
||||
def resolve_frontend_entry_url(self, backend_url):
|
||||
if self.frontend_dev_url:
|
||||
try:
|
||||
requests.get(self.frontend_dev_url, timeout=1.2)
|
||||
return self.frontend_dev_url
|
||||
except Exception:
|
||||
pass
|
||||
if self.frontend_dist_index.exists():
|
||||
return backend_url.rstrip("/") + "/index.html"
|
||||
return backend_url.rstrip("/") + "/admin.html"
|
||||
|
||||
def wait_for_backend(self, process=None, timeout_ms=None, interval_ms=None):
|
||||
timeout_ms = timeout_ms or self.backend_wait_timeout_ms
|
||||
interval_ms = interval_ms or self.backend_wait_interval_ms
|
||||
started_at = time.time() * 1000
|
||||
while True:
|
||||
if self.backend_runtime.is_backend_alive():
|
||||
return True, ""
|
||||
if process is not None and process.poll() is not None:
|
||||
return False, f"独立后台进程已退出,退出码: {process.returncode}"
|
||||
elapsed = time.time() * 1000 - started_at
|
||||
if elapsed >= timeout_ms:
|
||||
return False, f"后台启动超时,{int(elapsed)}ms 内未监听 {self.backend_runtime.host}:{self.backend_runtime.port}"
|
||||
time.sleep(interval_ms / 1000.0)
|
||||
@@ -0,0 +1,187 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
# 应用展示名称,同时作为 AppData 默认目录名的一部分
|
||||
"APP_NAME": "AiShiliu",
|
||||
# Flask 后端监听地址
|
||||
"APP_HOST": "127.0.0.1",
|
||||
# Flask 后端监听端口
|
||||
"APP_PORT": 5000,
|
||||
# 应用默认时区
|
||||
"APP_TIMEZONE": "Asia/Shanghai",
|
||||
# 已构建前端静态资源目录,留空时自动回退到项目默认目录
|
||||
"FRONTEND_STATIC_DIR": "",
|
||||
|
||||
# 日志总开关
|
||||
"LOG_ENABLED": True,
|
||||
# 日志级别
|
||||
"LOG_LEVEL": "INFO",
|
||||
# 是否写文本日志
|
||||
"LOG_TEXT_ENABLED": True,
|
||||
# 是否写JSON日志
|
||||
"LOG_JSON_ENABLED": True,
|
||||
# 单文件滚动大小(MB)
|
||||
"LOG_ROTATE_MB": 5,
|
||||
# 滚动保留份数
|
||||
"LOG_BACKUP_COUNT": 7,
|
||||
|
||||
# MySQL 主机地址(预留给后续鉴权等在线能力)
|
||||
"DB_HOST": "127.0.0.1",
|
||||
# MySQL 端口(预留)
|
||||
"DB_PORT": 3306,
|
||||
# MySQL 数据库名(预留)
|
||||
"DB_NAME": "ai_shiliu",
|
||||
# MySQL 用户名(预留)
|
||||
"DB_USER": "ai_shiliu",
|
||||
# MySQL 密码(预留)
|
||||
"DB_PASS": "",
|
||||
# MySQL 字符集(预留)
|
||||
"DB_CHARSET": "utf8mb4",
|
||||
|
||||
# AI 提供方(openai/deepseek/dify)
|
||||
"AI_PROVIDER": "dify",
|
||||
# OpenAI 接口密钥
|
||||
"OPENAI_API_KEY": "YOUR_OPENAI_API_KEY_HERE",
|
||||
# OpenAI API 基础地址
|
||||
"OPENAI_API_BASE": "https://api.openai.com/v1",
|
||||
# OpenAI 默认模型
|
||||
"OPENAI_MODEL": "gpt-4.1-mini",
|
||||
# DeepSeek 接口密钥
|
||||
"DEEPSEEK_API_KEY": "",
|
||||
# DeepSeek API 基础地址
|
||||
"DEEPSEEK_API_BASE": "https://api.deepseek.com",
|
||||
# DeepSeek 默认模型
|
||||
"DEEPSEEK_MODEL": "deepseek-chat",
|
||||
# Dify 接口密钥
|
||||
"DIFY_API_KEY": "app-a9dofsiQi4e157uDYTx8Lrja",
|
||||
# Dify API 基础地址
|
||||
"DIFY_API_BASE": "http://47.92.48.126/v1",
|
||||
# Dify 用户标识
|
||||
"DIFY_USER": "wechat_user",
|
||||
|
||||
|
||||
|
||||
# 百度 OCR API Key
|
||||
"BAIDU_API_KEY": "ElIQN30iAqpEGi9zv0VlrtQX",
|
||||
# 百度 OCR Secret Key
|
||||
"BAIDU_SECRET_KEY": "7wrO2wDTx7FehuelgG0NCBDFOklnqSz0",
|
||||
# OCR 引擎(baidu/rapid)
|
||||
"OCR_PROVIDER": "rapid",
|
||||
|
||||
# 微信机器人上报后端消息接口
|
||||
"BACKEND_URL": "http://127.0.0.1:5000/api/messages/receive",
|
||||
# 轮询主循环间隔(秒)
|
||||
"BOT_LOOP_INTERVAL": 3,
|
||||
# 点击联系人后等待(秒)
|
||||
"BOT_CLICK_AFTER_DELAY": 1.2,
|
||||
# 切换会话后读取标题等待(秒)
|
||||
"BOT_TITLE_AFTER_DELAY": 1.0,
|
||||
# 联系人切换节流等待(秒)
|
||||
"BOT_CONTACT_SWITCH_DELAY": 1.0,
|
||||
# 主循环异常后的重试等待(秒)
|
||||
"BOT_LOOP_ERROR_DELAY": 3.0,
|
||||
|
||||
# 微信窗口目标宽度
|
||||
"WECHAT_WINDOW_TARGET_WIDTH": 1080,
|
||||
# 微信窗口目标高度
|
||||
"WECHAT_WINDOW_TARGET_HEIGHT": 820,
|
||||
# 微信窗口目标左侧坐标
|
||||
"WECHAT_WINDOW_TARGET_LEFT": 120,
|
||||
# 微信窗口目标顶部坐标
|
||||
"WECHAT_WINDOW_TARGET_TOP": 80,
|
||||
|
||||
# 是否保存 OCR 调试截图
|
||||
"OCR_SAVE_IMAGES": True,
|
||||
|
||||
# 联系人行高(用于定位)
|
||||
"CONTACT_ROW_HEIGHT": 64,
|
||||
# 联系人列宽(用于定位)
|
||||
"CONTACT_ROW_WIDTH": 240,
|
||||
# 联系人列表左偏移
|
||||
"CONTACT_LIST_LEFT_OFFSET": 68,
|
||||
# 联系人列表上偏移
|
||||
"CONTACT_LIST_TOP_OFFSET": 82,
|
||||
# 联系人列表下偏移
|
||||
"CONTACT_LIST_BOTTOM_OFFSET": 0,
|
||||
|
||||
# 会话名区域左偏移
|
||||
"SESSION_NAME_LEFT_OFFSET": 48,
|
||||
# 会话名区域上偏移
|
||||
"SESSION_NAME_TOP_OFFSET": 8,
|
||||
# 会话名区域宽度
|
||||
"SESSION_NAME_WIDTH": 140,
|
||||
# 会话名区域高度
|
||||
"SESSION_NAME_HEIGHT": 24,
|
||||
# 会话名 OCR 放大倍数
|
||||
"SESSION_NAME_OCR_SCALE": 4,
|
||||
# 会话名 OCR 额外放大倍数
|
||||
"SESSION_NAME_OCR_EXTRA_SCALE": 6,
|
||||
|
||||
# 聊天区截图左偏移
|
||||
"CHAT_CAPTURE_LEFT_OFFSET": 310,
|
||||
# 聊天区截图上偏移
|
||||
"CHAT_CAPTURE_TOP_OFFSET": 70,
|
||||
# 聊天区截图宽度
|
||||
"CHAT_CAPTURE_WIDTH": 750,
|
||||
# 聊天区截图高度
|
||||
"CHAT_CAPTURE_HEIGHT": 550,
|
||||
|
||||
# 顶部惩罚权重比例
|
||||
"OCR_TOP_PENALTY_RATIO": 0.18,
|
||||
# 顶部惩罚二值化系数
|
||||
"OCR_TOP_PENALTY_BIN_FACTOR": 2.0,
|
||||
# 顶部惩罚颜色系数
|
||||
"OCR_TOP_PENALTY_COLOR_FACTOR": 2.2,
|
||||
|
||||
# 标题 OCR 区域左偏移(保留较大的方案)
|
||||
"TITLE_OCR_AREA_LEFT_OFFSET": 310,
|
||||
# 标题 OCR 区域上偏移(保留较大的方案)
|
||||
"TITLE_OCR_AREA_TOP_OFFSET": 0,
|
||||
# 标题 OCR 区域宽度(保留较大的方案)
|
||||
"TITLE_OCR_AREA_WIDTH": 600,
|
||||
# 标题 OCR 区域高度(保留较大的方案)
|
||||
"TITLE_OCR_AREA_HEIGHT": 70,
|
||||
}
|
||||
|
||||
|
||||
def _raw_value(key: str) -> Any:
|
||||
env_value = os.getenv(key)
|
||||
if env_value is not None:
|
||||
return env_value
|
||||
return DEFAULT_CONFIG.get(key)
|
||||
|
||||
|
||||
def get_str(key: str, default: str = "") -> str:
|
||||
value = _raw_value(key)
|
||||
if value is None:
|
||||
return default
|
||||
return str(value)
|
||||
|
||||
|
||||
def get_int(key: str, default: int = 0) -> int:
|
||||
value = _raw_value(key)
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return int(value)
|
||||
|
||||
|
||||
def get_float(key: str, default: float = 0.0) -> float:
|
||||
value = _raw_value(key)
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return float(value)
|
||||
|
||||
|
||||
def get_bool(key: str, default: bool = False) -> bool:
|
||||
value = _raw_value(key)
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
from app.infrastructure.service.backend.config import app
|
||||
from app.infrastructure.service.backend.db import init_db
|
||||
from app.infrastructure.router.backend_routes import register_routes
|
||||
from app.infrastructure.service.logging.log_service import init_logging, log_event, new_trace_id
|
||||
|
||||
|
||||
init_logging()
|
||||
register_routes(app)
|
||||
|
||||
|
||||
def init_backend():
|
||||
trace_id = new_trace_id("backend")
|
||||
try:
|
||||
init_db()
|
||||
log_event("INFO", "db", "db.init", trace_id, "boot", "ok", "数据库初始化成功")
|
||||
except Exception as err:
|
||||
log_event("ERROR", "db", "db.error", trace_id, "boot", "fail", "数据库初始化失败", reason=type(err).__name__)
|
||||
raise
|
||||
return app
|
||||
|
||||
|
||||
def start_backend(host="127.0.0.1", port=5000, threaded=True):
|
||||
backend_app = init_backend()
|
||||
backend_app.run(host=host, port=port, threaded=threaded)
|
||||
@@ -0,0 +1,137 @@
|
||||
import json
|
||||
import queue
|
||||
|
||||
from flask import Response, jsonify, request, stream_with_context
|
||||
|
||||
from app.application.bot_controller import bot_controller
|
||||
from app.infrastructure.service.backend.db import set_setting
|
||||
from app.infrastructure.service.logging.log_query_service import clear_logs, query_event_json, query_events, query_summary, query_trace
|
||||
from app.infrastructure.service.logging.log_service import log_event, new_trace_id
|
||||
|
||||
|
||||
def _sync_runtime_settings(status):
|
||||
current = (status.get("status") or "stopped").strip().lower()
|
||||
running = current == "running"
|
||||
transitional = current in {"starting", "stopping"}
|
||||
errored = current == "error"
|
||||
if running:
|
||||
set_setting("listener_enabled", "1")
|
||||
set_setting("listener_runtime_status", "running")
|
||||
elif transitional:
|
||||
set_setting("listener_runtime_status", current)
|
||||
elif errored:
|
||||
set_setting("listener_enabled", "0")
|
||||
set_setting("listener_runtime_status", "error")
|
||||
else:
|
||||
set_setting("listener_enabled", "0")
|
||||
set_setting("listener_runtime_status", "stopped")
|
||||
|
||||
|
||||
def register_bot_routes(app):
|
||||
def _stream_payload(status):
|
||||
return f"event: status\ndata: {json.dumps({'success': True, **status}, ensure_ascii=False)}\n\n"
|
||||
|
||||
@app.route("/api/bot/status", methods=["GET"])
|
||||
def api_bot_status():
|
||||
trace_id = new_trace_id("api")
|
||||
status = bot_controller.status()
|
||||
_sync_runtime_settings(status)
|
||||
log_event("INFO", "api", "api.bot.status", trace_id, "status", "ok", "查询监听状态成功", extra={"status": status.get("status")})
|
||||
return jsonify({"success": True, **status})
|
||||
|
||||
@app.route("/api/bot/status/stream", methods=["GET"])
|
||||
def api_bot_status_stream():
|
||||
status_queue = queue.Queue(maxsize=8)
|
||||
|
||||
def on_status(status):
|
||||
_sync_runtime_settings(status)
|
||||
try:
|
||||
status_queue.put_nowait(status)
|
||||
except queue.Full:
|
||||
try:
|
||||
status_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
status_queue.put_nowait(status)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
listener_id = bot_controller.add_status_listener(on_status, emit_initial=True)
|
||||
|
||||
@stream_with_context
|
||||
def generate():
|
||||
try:
|
||||
yield "retry: 2000\n\n"
|
||||
while True:
|
||||
try:
|
||||
status = status_queue.get(timeout=15)
|
||||
yield _stream_payload(status)
|
||||
except queue.Empty:
|
||||
yield "event: ping\ndata: {}\n\n"
|
||||
finally:
|
||||
bot_controller.remove_status_listener(listener_id)
|
||||
|
||||
return Response(generate(), mimetype="text/event-stream", headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
})
|
||||
|
||||
@app.route("/api/logs/v2/events", methods=["GET"])
|
||||
def api_logs_v2_events():
|
||||
trace_id = request.args.get("trace_id") or ""
|
||||
module = request.args.get("module") or ""
|
||||
level = request.args.get("level") or ""
|
||||
event = request.args.get("event") or ""
|
||||
start_ts = request.args.get("start_ts") or ""
|
||||
end_ts = request.args.get("end_ts") or ""
|
||||
keyword = request.args.get("keyword") or ""
|
||||
page = request.args.get("page", 1)
|
||||
size = request.args.get("size", 50)
|
||||
payload = query_events(module=module or None, level=level or None, event=event or None, trace_id=trace_id or None, start_ts=start_ts or None, end_ts=end_ts or None, keyword=keyword or None, page=page, size=size)
|
||||
return jsonify({"success": True, **payload})
|
||||
|
||||
@app.route("/api/logs/v2/trace/<trace_id>", methods=["GET"])
|
||||
def api_logs_v2_trace(trace_id):
|
||||
items = query_trace(trace_id)
|
||||
return jsonify({"success": True, "trace_id": trace_id, "items": items})
|
||||
|
||||
@app.route("/api/logs/v2/summary", methods=["GET"])
|
||||
def api_logs_v2_summary():
|
||||
limit = request.args.get("limit", 300)
|
||||
payload = query_summary(limit=limit)
|
||||
return jsonify({"success": True, **payload})
|
||||
|
||||
@app.route("/api/logs/v2/event/<event_id>", methods=["GET"])
|
||||
def api_logs_v2_event(event_id):
|
||||
item = query_event_json(event_id)
|
||||
if not item:
|
||||
return jsonify({"success": False, "error": "event_not_found"}), 404
|
||||
return jsonify({"success": True, "item": item})
|
||||
|
||||
@app.route("/api/logs/v2/clear", methods=["POST"])
|
||||
def api_logs_v2_clear():
|
||||
body = request.get_json(silent=True) or {}
|
||||
module = (body.get("module") or request.values.get("module") or request.args.get("module") or "").strip()
|
||||
payload = clear_logs(module=module or None)
|
||||
return jsonify({"success": True, **payload})
|
||||
|
||||
@app.route("/api/bot/start", methods=["POST"])
|
||||
def api_bot_start():
|
||||
trace_id = new_trace_id("api")
|
||||
backend_url = (request.values.get("backend_url") or "").strip()
|
||||
if not backend_url:
|
||||
backend_url = request.host_url.rstrip("/") + "/api/messages/receive"
|
||||
status = bot_controller.start(backend_url)
|
||||
_sync_runtime_settings(status)
|
||||
log_event("INFO", "api", "api.bot.start", trace_id, "start", "ok", "启动监听请求已处理", extra={"status": status.get("status"), "backend_url": backend_url})
|
||||
return jsonify({"success": True, **status})
|
||||
|
||||
@app.route("/api/bot/stop", methods=["POST"])
|
||||
def api_bot_stop():
|
||||
trace_id = new_trace_id("api")
|
||||
status = bot_controller.stop()
|
||||
_sync_runtime_settings(status)
|
||||
log_event("INFO", "api", "api.bot.stop", trace_id, "stop", "ok", "停止监听请求已处理", extra={"status": status.get("status")})
|
||||
return jsonify({"success": True, **status})
|
||||
@@ -0,0 +1,72 @@
|
||||
from datetime import datetime
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from app.infrastructure.service.backend.ai import call_ai
|
||||
from app.infrastructure.service.backend.db import find_rule_reply, get_conn, get_setting
|
||||
from app.infrastructure.service.logging.log_service import log_event, new_trace_id
|
||||
|
||||
|
||||
def register_message_routes(app):
|
||||
@app.route("/api/messages/receive", methods=["POST"])
|
||||
def api_receive_message():
|
||||
trace_id = new_trace_id("api")
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
content = (data.get("content") or "").strip()
|
||||
wx_user_id = (data.get("wx_user_id") or "").strip()
|
||||
wx_nickname = (data.get("wx_nickname") or "").strip()
|
||||
is_friend_request = int(data.get("is_friend_request") or 0)
|
||||
ocr_confidence = str(data.get("ocr_confidence") or "").strip()
|
||||
ocr_bubble_side = str(data.get("ocr_bubble_side") or "").strip()
|
||||
if not content and not is_friend_request:
|
||||
log_event("WARNING", "api", "api.messages.receive", trace_id, "validate", "failed", "消息内容为空且非好友请求", reason="content_empty")
|
||||
return jsonify({"error": "content is empty"}), 400
|
||||
|
||||
conn = get_conn()
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("INSERT INTO messages (wx_user_id, wx_nickname, direction, content, is_friend_request, reply_strategy, reply_reason, ocr_confidence, ocr_bubble_side, created_at) VALUES (%s, %s, 'in', %s, %s, %s, %s, %s, %s, %s)", (wx_user_id, wx_nickname, content, is_friend_request, "none", "received", ocr_confidence, ocr_bubble_side, now))
|
||||
in_msg_id = cur.lastrowid
|
||||
|
||||
auto_on = str(get_setting("auto_reply_enabled", "1") or "1").strip().lower() in ["1", "true", "yes", "on"]
|
||||
full_auto_on = str(get_setting("full_auto_reply_enabled", "0") or "0").strip().lower() in ["1", "true", "yes", "on"]
|
||||
reply_text = ""
|
||||
used_rule_id = None
|
||||
reply_strategy = "none"
|
||||
reply_reason = ""
|
||||
|
||||
if not auto_on:
|
||||
reply_reason = "auto_reply_disabled"
|
||||
else:
|
||||
rule = find_rule_reply(content)
|
||||
if rule:
|
||||
reply_text = (rule.get("reply_text") or "").strip()
|
||||
used_rule_id = rule.get("id")
|
||||
reply_strategy = "rule"
|
||||
reply_reason = f"rule_hit:{used_rule_id}"
|
||||
elif full_auto_on:
|
||||
reply_strategy = "full_auto"
|
||||
reply_reason = "full_auto_enabled"
|
||||
reply_text = call_ai(content, wx_user_id)
|
||||
else:
|
||||
reply_reason = "rule_miss"
|
||||
|
||||
should_reply = auto_on and bool(reply_text)
|
||||
|
||||
reply_msg_id = None
|
||||
if should_reply:
|
||||
is_ai_reply = 0 if used_rule_id else 1
|
||||
cur.execute("INSERT INTO messages (wx_user_id, wx_nickname, direction, content, is_ai_reply, rule_id, reply_strategy, reply_reason, created_at) VALUES (%s, %s, 'out', %s, %s, %s, %s, %s, %s)", (wx_user_id, wx_nickname, reply_text, is_ai_reply, used_rule_id, reply_strategy, reply_reason, now))
|
||||
reply_msg_id = cur.lastrowid
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
log_event("ERROR", "api", "api.messages.receive", trace_id, "persist", "failed", "消息入库或回复处理失败", reason="db_error", extra={"error": str(exc)})
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_event("INFO", "audit", "audit.decision", trace_id, "decision", "ok", "回复决策完成", reason=reply_reason or "none", extra={"wx_user_id": wx_user_id, "strategy": reply_strategy, "should_reply": should_reply, "rule_id": used_rule_id or ""})
|
||||
log_event("INFO", "api", "api.messages.receive", trace_id, "done", "ok", "消息处理完成", extra={"in_message_id": in_msg_id, "reply_message_id": reply_msg_id or "", "should_reply": should_reply})
|
||||
return jsonify({"success": True, "should_reply": should_reply, "reply_text": reply_text, "in_message_id": in_msg_id, "reply_message_id": reply_msg_id})
|
||||
@@ -0,0 +1,149 @@
|
||||
from datetime import datetime
|
||||
|
||||
from flask import jsonify, request, send_from_directory
|
||||
|
||||
from app.application.bot_controller import bot_controller
|
||||
from app.infrastructure.service.backend.config import ASSETS_DIR, STATIC_DIR
|
||||
from app.infrastructure.service.backend.db import SQLITE_DB_PATH, get_conn, get_setting, set_setting
|
||||
from app.infrastructure.service.logging.log_service import log_event, new_trace_id
|
||||
|
||||
LOCAL_SETTING_DEFAULTS = {
|
||||
"auto_reply_enabled": "1",
|
||||
"listener_enabled": "0",
|
||||
"listener_runtime_status": "stopped",
|
||||
"full_auto_reply_enabled": "0",
|
||||
"reply_fallback_mode": "ai",
|
||||
}
|
||||
LOCAL_SETTING_CACHE = {}
|
||||
|
||||
|
||||
def _load_local_settings(force=False):
|
||||
if LOCAL_SETTING_CACHE and not force:
|
||||
return
|
||||
for key, default in LOCAL_SETTING_DEFAULTS.items():
|
||||
LOCAL_SETTING_CACHE[key] = str(get_setting(key, default))
|
||||
|
||||
|
||||
|
||||
def _get_local_setting(key, default=None):
|
||||
_load_local_settings(force=True)
|
||||
return LOCAL_SETTING_CACHE.get(key, default)
|
||||
|
||||
|
||||
|
||||
def _set_local_setting(key, value):
|
||||
v = str(value)
|
||||
LOCAL_SETTING_CACHE[key] = v
|
||||
set_setting(key, v)
|
||||
|
||||
|
||||
|
||||
def _frontend_index_available():
|
||||
return STATIC_DIR != ASSETS_DIR and (STATIC_DIR / "index.html").exists()
|
||||
|
||||
|
||||
|
||||
def register_rule_routes(app):
|
||||
@app.route("/")
|
||||
@app.route("/index.html")
|
||||
def frontend_page():
|
||||
if _frontend_index_available():
|
||||
return send_from_directory(STATIC_DIR, "index.html")
|
||||
return send_from_directory(ASSETS_DIR, "admin.html")
|
||||
|
||||
@app.route("/admin.html")
|
||||
def admin_page():
|
||||
return send_from_directory(ASSETS_DIR, "admin.html")
|
||||
|
||||
@app.route("/api/rules", methods=["GET", "POST"])
|
||||
def api_rules():
|
||||
action = request.values.get("action", "list")
|
||||
conn = get_conn()
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if action == "list":
|
||||
cur.execute("SELECT * FROM auto_reply_rules ORDER BY id DESC")
|
||||
rows = cur.fetchall()
|
||||
log_event("INFO", "api", "api.rules.list", new_trace_id("rules"), "query", "ok", "查询回复匹配配置", extra={"db_path": str(SQLITE_DB_PATH), "count": len(rows)})
|
||||
return jsonify({"success": True, "data": rows, "db_path": str(SQLITE_DB_PATH)})
|
||||
|
||||
if action == "create":
|
||||
keyword = (request.values.get("keyword") or "").strip()
|
||||
match_type = request.values.get("match_type", "contain")
|
||||
reply_text = (request.values.get("reply_text") or "").strip()
|
||||
is_active = int(request.values.get("is_active", 1))
|
||||
if not keyword or not reply_text:
|
||||
return jsonify({"success": False, "message": "关键词和回复内容不能为空"})
|
||||
if match_type not in ["contain", "equal"]:
|
||||
match_type = "contain"
|
||||
cur.execute("INSERT INTO auto_reply_rules(keyword, match_type, reply_text, is_active, created_at, updated_at) VALUES (%s, %s, %s, %s, %s, %s)", (keyword, match_type, reply_text, is_active, now, now))
|
||||
conn.commit()
|
||||
return jsonify({"success": True})
|
||||
|
||||
if action == "toggle":
|
||||
rule_id = int(request.values.get("id", 0))
|
||||
is_active = int(request.values.get("is_active", 0))
|
||||
if rule_id <= 0:
|
||||
return jsonify({"success": False, "message": "参数错误"})
|
||||
cur.execute("UPDATE auto_reply_rules SET is_active = %s, updated_at = %s WHERE id = %s", (is_active, now, rule_id))
|
||||
conn.commit()
|
||||
return jsonify({"success": True})
|
||||
|
||||
if action == "delete":
|
||||
rule_id = int(request.values.get("id", 0))
|
||||
if rule_id <= 0:
|
||||
return jsonify({"success": False, "message": "参数错误"})
|
||||
cur.execute("DELETE FROM auto_reply_rules WHERE id = %s", (rule_id,))
|
||||
conn.commit()
|
||||
return jsonify({"success": True})
|
||||
|
||||
if action == "settings_get":
|
||||
auto_on = _get_local_setting("auto_reply_enabled", "1") == "1"
|
||||
full_auto_on = _get_local_setting("full_auto_reply_enabled", "0") == "1"
|
||||
reply_fallback_mode = (_get_local_setting("reply_fallback_mode", "ai") or "ai").strip() or "ai"
|
||||
bot_status = bot_controller.status()
|
||||
runtime_status = (bot_status.get("status") or "stopped").strip().lower()
|
||||
if runtime_status not in ["running", "starting", "stopping", "stopped", "error"]:
|
||||
runtime_status = "stopped"
|
||||
listener_on = runtime_status in ["running", "starting"]
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"auto_reply_enabled": auto_on,
|
||||
"listener_enabled": listener_on,
|
||||
"listener_runtime_status": runtime_status,
|
||||
"listener_intent_enabled": _get_local_setting("listener_enabled", "0") == "1",
|
||||
"full_auto_reply_enabled": full_auto_on,
|
||||
"reply_fallback_mode": reply_fallback_mode,
|
||||
})
|
||||
|
||||
if action == "settings_set":
|
||||
if "auto_reply_enabled" in request.values:
|
||||
auto_on = "1" if request.values.get("auto_reply_enabled", "1") == "1" else "0"
|
||||
_set_local_setting("auto_reply_enabled", auto_on)
|
||||
if "listener_enabled" in request.values:
|
||||
listener_on = "1" if request.values.get("listener_enabled", "0") == "1" else "0"
|
||||
_set_local_setting("listener_enabled", listener_on)
|
||||
if "listener_runtime_status" in request.values:
|
||||
runtime_status = (request.values.get("listener_runtime_status") or "stopped").strip().lower()
|
||||
if runtime_status not in ["running", "starting", "stopping", "stopped", "error"]:
|
||||
runtime_status = "stopped"
|
||||
_set_local_setting("listener_runtime_status", runtime_status)
|
||||
if "full_auto_reply_enabled" in request.values:
|
||||
full_auto_on = "1" if request.values.get("full_auto_reply_enabled", "0") == "1" else "0"
|
||||
_set_local_setting("full_auto_reply_enabled", full_auto_on)
|
||||
if "reply_fallback_mode" in request.values:
|
||||
reply_fallback_mode = (request.values.get("reply_fallback_mode") or "ai").strip() or "ai"
|
||||
_set_local_setting("reply_fallback_mode", reply_fallback_mode)
|
||||
return jsonify({"success": True})
|
||||
|
||||
if action == "messages_recent":
|
||||
limit = int(request.values.get("limit", 50))
|
||||
limit = max(1, min(100, limit))
|
||||
cur.execute("SELECT * FROM messages ORDER BY id DESC LIMIT %s", (limit,))
|
||||
rows = cur.fetchall()
|
||||
return jsonify({"success": True, "data": rows})
|
||||
|
||||
return jsonify({"success": False, "message": "未知操作"})
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,9 @@
|
||||
from app.infrastructure.router.backend.rules import register_rule_routes
|
||||
from app.infrastructure.router.backend.messages import register_message_routes
|
||||
from app.infrastructure.router.backend.bot import register_bot_routes
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
register_rule_routes(app)
|
||||
register_message_routes(app)
|
||||
register_bot_routes(app)
|
||||
@@ -0,0 +1,81 @@
|
||||
import requests
|
||||
|
||||
from app.infrastructure.service.backend.config import AI_PROVIDER, DEEPSEEK_API_BASE, DEEPSEEK_API_KEY, DEEPSEEK_MODEL, DIFY_API_BASE, DIFY_API_KEY, DIFY_USER, OPENAI_API_BASE, OPENAI_API_KEY, OPENAI_MODEL
|
||||
from app.infrastructure.service.logging.log_service import log_event, new_trace_id
|
||||
|
||||
|
||||
def do_openai_like(url, headers, payload):
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
data = resp.json() if resp.text else {}
|
||||
if resp.status_code >= 400:
|
||||
return f"抱歉,AI 服务请求失败({resp.status_code})"
|
||||
content = (((data or {}).get("choices") or [{}])[0].get("message") or {}).get("content", "")
|
||||
return content.strip() if content else "抱歉,AI 暂时没有合理的回复。"
|
||||
except Exception:
|
||||
return "抱歉,AI 服务暂时不可用,请稍后再试。"
|
||||
|
||||
|
||||
def do_dify(url, headers, payload):
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
text = resp.text or ""
|
||||
if "data:" in text:
|
||||
answer = ""
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
chunk = line[5:].strip()
|
||||
if not chunk or chunk == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
j = requests.models.complexjson.loads(chunk)
|
||||
except Exception:
|
||||
continue
|
||||
if "answer" in j:
|
||||
answer += j["answer"]
|
||||
if answer.strip():
|
||||
return answer.strip()
|
||||
data = resp.json() if resp.text else {}
|
||||
if resp.status_code >= 400:
|
||||
return f"抱歉,Dify 服务请求失败({resp.status_code})"
|
||||
return (data.get("answer") or "抱歉,Dify 暂时没有合理的回复。").strip()
|
||||
except Exception:
|
||||
return "抱歉,Dify 服务暂时不可用,请稍后再试。"
|
||||
|
||||
|
||||
def call_ai(prompt, user_id=""):
|
||||
trace_id = new_trace_id("ai")
|
||||
provider = AI_PROVIDER
|
||||
log_event("INFO", "ai", "ai.request", trace_id, "request", "ok", "发起AI请求", extra={"provider": provider, "user_id": user_id or "", "prompt_len": len(prompt or "")})
|
||||
if provider == "mock":
|
||||
result = "【自动回复】你刚才说了:" + (prompt or "")[:100]
|
||||
log_event("INFO", "ai", "ai.response", trace_id, "response", "ok", "AI回复完成", extra={"provider": provider, "reply_len": len(result)})
|
||||
return result
|
||||
if provider == "openai":
|
||||
result = do_openai_like(
|
||||
OPENAI_API_BASE.rstrip("/") + "/chat/completions",
|
||||
{"Content-Type": "application/json", "Authorization": f"Bearer {OPENAI_API_KEY}"},
|
||||
{"model": OPENAI_MODEL, "messages": [{"role": "system", "content": "你是一个专业的微信私域运营助手,用简洁自然的中文回复用户。"}, {"role": "user", "content": prompt}], "temperature": 0.7, "user": user_id or None},
|
||||
)
|
||||
elif provider == "deepseek":
|
||||
result = do_openai_like(
|
||||
DEEPSEEK_API_BASE.rstrip("/") + "/chat/completions",
|
||||
{"Content-Type": "application/json", "Authorization": f"Bearer {DEEPSEEK_API_KEY}"},
|
||||
{"model": DEEPSEEK_MODEL, "messages": [{"role": "system", "content": "你是一个简洁高效的微信助手。回复要求:一句话,不超过50字。"}, {"role": "user", "content": prompt}], "temperature": 0.7, "max_tokens": 100, "user": user_id or None},
|
||||
)
|
||||
elif provider == "dify":
|
||||
result = do_dify(
|
||||
DIFY_API_BASE.rstrip("/") + "/chat-messages",
|
||||
{"Content-Type": "application/json", "Authorization": f"Bearer {DIFY_API_KEY}"},
|
||||
{"inputs": {}, "query": prompt, "response_mode": "streaming", "user": user_id or DIFY_USER, "conversation_id": ""},
|
||||
)
|
||||
else:
|
||||
result = "AI_PROVIDER 未配置正确,请检查环境变量。"
|
||||
|
||||
if result.startswith("抱歉") or "未配置正确" in result:
|
||||
log_event("WARNING", "ai", "ai.response", trace_id, "response", "failed", "AI回复异常或降级", reason="provider_error", extra={"provider": provider, "reply": result[:120]})
|
||||
else:
|
||||
log_event("INFO", "ai", "ai.response", trace_id, "response", "ok", "AI回复完成", extra={"provider": provider, "reply_len": len(result)})
|
||||
return result
|
||||
@@ -0,0 +1,45 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, request
|
||||
|
||||
from app.configs.runtime_config import get_int, get_str
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
ASSETS_DIR = PROJECT_ROOT / "assets"
|
||||
FRONTEND_DIST_DIR = PROJECT_ROOT / "frontend" / "dist"
|
||||
_frontend_static_dir = get_str("FRONTEND_STATIC_DIR", "").strip()
|
||||
FRONTEND_STATIC_DIR = Path(_frontend_static_dir).resolve() if _frontend_static_dir else None
|
||||
STATIC_DIR = FRONTEND_STATIC_DIR or (FRONTEND_DIST_DIR if FRONTEND_DIST_DIR.exists() else ASSETS_DIR)
|
||||
|
||||
DB_HOST = get_str("DB_HOST", "127.0.0.1")
|
||||
DB_PORT = get_int("DB_PORT", 3306)
|
||||
DB_NAME = get_str("DB_NAME", "ai_shiliu")
|
||||
DB_USER = get_str("DB_USER", "ai_shiliu")
|
||||
DB_PASS = get_str("DB_PASS", "")
|
||||
DB_CHARSET = get_str("DB_CHARSET", "utf8mb4")
|
||||
|
||||
AI_PROVIDER = get_str("AI_PROVIDER", "")
|
||||
OPENAI_API_KEY = get_str("OPENAI_API_KEY", "")
|
||||
OPENAI_API_BASE = get_str("OPENAI_API_BASE", "")
|
||||
OPENAI_MODEL = get_str("OPENAI_MODEL", "")
|
||||
DEEPSEEK_API_KEY = get_str("DEEPSEEK_API_KEY", "")
|
||||
DEEPSEEK_API_BASE = get_str("DEEPSEEK_API_BASE", "")
|
||||
DEEPSEEK_MODEL = get_str("DEEPSEEK_MODEL", "")
|
||||
DIFY_API_KEY = get_str("DIFY_API_KEY", "")
|
||||
DIFY_API_BASE = get_str("DIFY_API_BASE", "")
|
||||
DIFY_USER = get_str("DIFY_USER", "")
|
||||
|
||||
app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="")
|
||||
|
||||
|
||||
@app.after_request
|
||||
def add_cors_headers(response):
|
||||
origin = (request.headers.get("Origin") or "").strip()
|
||||
allow_origin = origin or "*"
|
||||
response.headers["Access-Control-Allow-Origin"] = allow_origin
|
||||
response.headers["Vary"] = "Origin"
|
||||
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||
return response
|
||||
@@ -0,0 +1,218 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from app.infrastructure.service.backend.config import PROJECT_ROOT
|
||||
from app.infrastructure.service.logging.log_service import log_event, new_trace_id
|
||||
|
||||
_SETTING_CACHE = {}
|
||||
SETTINGS_FILE = PROJECT_ROOT / "logs" / "state" / "local_settings.json"
|
||||
LOCAL_APPDATA_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")))
|
||||
SQLITE_DB_PATH = LOCAL_APPDATA_DIR / "com.shiliu.aiassistant" / "ai_shiliu.sqlite3"
|
||||
|
||||
|
||||
class _SQLiteCursor:
|
||||
def __init__(self, cursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self._cursor.close()
|
||||
|
||||
@staticmethod
|
||||
def _adapt_sql(sql: str) -> str:
|
||||
return sql.replace("%s", "?")
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
sql = self._adapt_sql(sql)
|
||||
if params is None:
|
||||
self._cursor.execute(sql)
|
||||
else:
|
||||
self._cursor.execute(sql, params)
|
||||
return self
|
||||
|
||||
def fetchone(self):
|
||||
row = self._cursor.fetchone()
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
def fetchall(self):
|
||||
return [dict(row) for row in self._cursor.fetchall()]
|
||||
|
||||
@property
|
||||
def lastrowid(self):
|
||||
return self._cursor.lastrowid
|
||||
|
||||
|
||||
class _SQLiteConn:
|
||||
def __init__(self, conn):
|
||||
self._conn = conn
|
||||
|
||||
def cursor(self):
|
||||
return _SQLiteCursor(self._conn.cursor())
|
||||
|
||||
def commit(self):
|
||||
self._conn.commit()
|
||||
|
||||
def rollback(self):
|
||||
self._conn.rollback()
|
||||
|
||||
def close(self):
|
||||
self._conn.close()
|
||||
|
||||
|
||||
def _bootstrap_sqlite_file():
|
||||
SQLITE_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_conn(db_name=None):
|
||||
_bootstrap_sqlite_file()
|
||||
conn = sqlite3.connect(str(SQLITE_DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return _SQLiteConn(conn)
|
||||
|
||||
|
||||
def init_db():
|
||||
trace_id = new_trace_id("db")
|
||||
log_event("INFO", "db", "db.init", trace_id, "start", "ok", "初始化数据库开始", extra={"path": str(SQLITE_DB_PATH)})
|
||||
conn = get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wx_user_id TEXT NOT NULL DEFAULT '',
|
||||
wx_nickname TEXT NOT NULL DEFAULT '',
|
||||
direction TEXT NOT NULL DEFAULT 'in',
|
||||
content TEXT NOT NULL,
|
||||
is_ai_reply INTEGER NOT NULL DEFAULT 0,
|
||||
rule_id INTEGER NULL,
|
||||
is_friend_request INTEGER NOT NULL DEFAULT 0,
|
||||
reply_strategy TEXT NOT NULL DEFAULT '',
|
||||
reply_reason TEXT NOT NULL DEFAULT '',
|
||||
ocr_confidence TEXT NOT NULL DEFAULT '',
|
||||
ocr_bubble_side TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_messages_user_time ON messages(wx_user_id, created_at)")
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS auto_reply_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
match_type TEXT NOT NULL DEFAULT 'contain',
|
||||
reply_text TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
cur.execute("PRAGMA table_info(messages)")
|
||||
cols = {str(x.get('name') or '') for x in (cur.fetchall() or [])}
|
||||
if "reply_strategy" not in cols:
|
||||
cur.execute("ALTER TABLE messages ADD COLUMN reply_strategy TEXT NOT NULL DEFAULT ''")
|
||||
if "reply_reason" not in cols:
|
||||
cur.execute("ALTER TABLE messages ADD COLUMN reply_reason TEXT NOT NULL DEFAULT ''")
|
||||
if "ocr_confidence" not in cols:
|
||||
cur.execute("ALTER TABLE messages ADD COLUMN ocr_confidence TEXT NOT NULL DEFAULT ''")
|
||||
if "ocr_bubble_side" not in cols:
|
||||
cur.execute("ALTER TABLE messages ADD COLUMN ocr_bubble_side TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
conn.commit()
|
||||
log_event("INFO", "db", "db.init", trace_id, "done", "ok", "初始化数据库完成")
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
log_event("ERROR", "db", "db.init", trace_id, "done", "failed", "初始化数据库失败", reason="db_error", extra={"error": str(exc)})
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _load_settings_file():
|
||||
if _SETTING_CACHE:
|
||||
return
|
||||
try:
|
||||
if SETTINGS_FILE.exists():
|
||||
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
for k, v in data.items():
|
||||
_SETTING_CACHE[str(k)] = str(v)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _save_settings_file():
|
||||
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
SETTINGS_FILE.write_text(json.dumps(_SETTING_CACHE, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def get_setting(key, default=None):
|
||||
_load_settings_file()
|
||||
if key in _SETTING_CACHE:
|
||||
return _SETTING_CACHE[key]
|
||||
if default is None:
|
||||
return None
|
||||
val = str(default)
|
||||
_SETTING_CACHE[key] = val
|
||||
_save_settings_file()
|
||||
return val
|
||||
|
||||
|
||||
def set_setting(key, value):
|
||||
_load_settings_file()
|
||||
_SETTING_CACHE[str(key)] = str(value)
|
||||
_save_settings_file()
|
||||
|
||||
|
||||
def normalize_text(text):
|
||||
t = (text or "").strip().lower()
|
||||
t = re.sub(r"\s+", "", t)
|
||||
t = t.replace(":", ":")
|
||||
return t
|
||||
|
||||
|
||||
def find_rule_reply(content):
|
||||
trace_id = new_trace_id("db")
|
||||
conn = get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM auto_reply_rules WHERE is_active = 1 ORDER BY id ASC")
|
||||
rules = cur.fetchall()
|
||||
except Exception as exc:
|
||||
log_event("ERROR", "db", "db.rule.query", trace_id, "query", "failed", "查询规则失败", reason="db_error", extra={"error": str(exc)})
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
raw_content = (content or "").strip()
|
||||
content_lower = raw_content.lower()
|
||||
content_norm = normalize_text(raw_content)
|
||||
|
||||
for rule in rules:
|
||||
kw = (rule.get("keyword") or "").strip()
|
||||
if not kw:
|
||||
continue
|
||||
|
||||
kw_lower = kw.lower()
|
||||
kw_norm = normalize_text(kw)
|
||||
match_type = rule.get("match_type")
|
||||
|
||||
if match_type == "equal":
|
||||
if content_lower == kw_lower or content_norm == kw_norm:
|
||||
log_event("INFO", "db", "db.rule.match", trace_id, "match", "ok", "命中规则", reason="rule_hit", extra={"rule_id": rule.get("id"), "match_type": match_type})
|
||||
return rule
|
||||
else:
|
||||
if kw_lower in content_lower or kw_norm in content_norm:
|
||||
log_event("INFO", "db", "db.rule.match", trace_id, "match", "ok", "命中规则", reason="rule_hit", extra={"rule_id": rule.get("id"), "match_type": match_type or "contain"})
|
||||
return rule
|
||||
log_event("INFO", "db", "db.rule.match", trace_id, "match", "ok", "未命中规则", reason="rule_miss", extra={"rule_count": len(rules)})
|
||||
return None
|
||||
@@ -0,0 +1,141 @@
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.infrastructure.service.wechat.config import LOG_ROOT_DIR
|
||||
|
||||
|
||||
ALLOWED_MODULES = {"api", "bot", "ocr", "ai", "db", "capture", "audit", "error"}
|
||||
DOMAIN_MODULES = {"api", "bot", "ocr", "ai", "db", "capture"}
|
||||
|
||||
|
||||
def _infer_domain(row: dict) -> str:
|
||||
module = str(row.get("module") or "").strip().lower()
|
||||
if module in DOMAIN_MODULES:
|
||||
return module
|
||||
if module == "error":
|
||||
event = str(row.get("event") or "")
|
||||
prefix = event.split(".", 1)[0].strip().lower()
|
||||
if prefix in DOMAIN_MODULES:
|
||||
return prefix
|
||||
return "api"
|
||||
|
||||
|
||||
def _event_id(row: dict) -> str:
|
||||
raw = json.dumps(row or {}, ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _enrich_row(row: dict) -> dict:
|
||||
x = dict(row or {})
|
||||
x["domain"] = _infer_domain(x)
|
||||
x["event_id"] = _event_id(x)
|
||||
return x
|
||||
|
||||
|
||||
def _read_jsonl(module: str):
|
||||
p = Path(LOG_ROOT_DIR) / f"{module}.jsonl"
|
||||
if not p.exists():
|
||||
return []
|
||||
rows = []
|
||||
with p.open("r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except Exception:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def query_events(module=None, level=None, event=None, trace_id=None, start_ts=None, end_ts=None, keyword=None, page=1, size=50):
|
||||
modules = [module] if module in ALLOWED_MODULES else sorted(ALLOWED_MODULES)
|
||||
all_rows = []
|
||||
for m in modules:
|
||||
all_rows.extend(_read_jsonl(m))
|
||||
def ok(row):
|
||||
if level and str(row.get("level", "")).upper() != str(level).upper():
|
||||
return False
|
||||
if event and str(row.get("event", "")) != str(event):
|
||||
return False
|
||||
if trace_id and str(row.get("trace_id", "")) != str(trace_id):
|
||||
return False
|
||||
if keyword and keyword not in json.dumps(row, ensure_ascii=False):
|
||||
return False
|
||||
ts = str(row.get("ts") or "")
|
||||
if start_ts and ts < start_ts:
|
||||
return False
|
||||
if end_ts and ts > end_ts:
|
||||
return False
|
||||
return True
|
||||
rows = [_enrich_row(r) for r in all_rows if ok(r)]
|
||||
rows.sort(key=lambda x: str(x.get("ts") or ""), reverse=True)
|
||||
page = max(1, int(page or 1))
|
||||
size = max(1, min(200, int(size or 50)))
|
||||
start = (page - 1) * size
|
||||
end = start + size
|
||||
return {"total": len(rows), "page": page, "size": size, "items": rows[start:end]}
|
||||
|
||||
|
||||
def query_trace(trace_id: str):
|
||||
if not trace_id:
|
||||
return []
|
||||
result = query_events(trace_id=trace_id, size=500)
|
||||
items = result.get("items") or []
|
||||
items.sort(key=lambda x: str(x.get("ts") or ""))
|
||||
return items
|
||||
|
||||
|
||||
def query_event_json(event_id: str):
|
||||
event_id = str(event_id or "").strip().lower()
|
||||
if not event_id:
|
||||
return None
|
||||
for m in sorted(ALLOWED_MODULES):
|
||||
rows = _read_jsonl(m)
|
||||
for row in rows:
|
||||
x = _enrich_row(row)
|
||||
if str(x.get("event_id") or "") == event_id:
|
||||
return x
|
||||
return None
|
||||
|
||||
|
||||
def clear_logs(module=None):
|
||||
modules = [module] if module in ALLOWED_MODULES else sorted(ALLOWED_MODULES)
|
||||
root = Path(LOG_ROOT_DIR)
|
||||
deleted = []
|
||||
for m in modules:
|
||||
for p in root.glob(f"{m}.log*"):
|
||||
p.write_text("", encoding="utf-8")
|
||||
deleted.append(str(p.name))
|
||||
for p in root.glob(f"{m}.jsonl*"):
|
||||
p.write_text("", encoding="utf-8")
|
||||
deleted.append(str(p.name))
|
||||
return {"modules": modules, "files": deleted}
|
||||
|
||||
|
||||
def query_summary(limit=300):
|
||||
events = query_events(size=limit).get("items") or []
|
||||
error_count = sum(1 for e in events if str(e.get("level", "")).upper() in {"ERROR"})
|
||||
fallback_count = sum(1 for e in events if str(e.get("event", "")).endswith("fallback"))
|
||||
reasons = {}
|
||||
domain_counts = {"api": 0, "bot": 0, "ocr": 0, "ai": 0, "db": 0, "capture": 0}
|
||||
for e in events:
|
||||
reason = str(e.get("reason") or "").strip()
|
||||
if reason:
|
||||
reasons[reason] = reasons.get(reason, 0) + 1
|
||||
domain = str(e.get("domain") or _infer_domain(e))
|
||||
if domain in domain_counts:
|
||||
domain_counts[domain] += 1
|
||||
reason_top = sorted(reasons.items(), key=lambda x: x[1], reverse=True)[:20]
|
||||
domain_top = sorted(domain_counts.items(), key=lambda x: x[1], reverse=True)
|
||||
return {
|
||||
"window": len(events),
|
||||
"error_count": error_count,
|
||||
"fallback_count": fallback_count,
|
||||
"top_reasons": [{"reason": k, "count": v} for k, v in reason_top],
|
||||
"domain_counts": [{"domain": k, "count": v} for k, v in domain_top],
|
||||
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
MODULES = {"api", "bot", "ocr", "ai", "db", "capture", "audit", "error"}
|
||||
DOMAIN_MODULES = {"api", "bot", "ocr", "ai", "db", "capture"}
|
||||
|
||||
ALLOWED_EVENTS = {
|
||||
"api.bot.status",
|
||||
"api.bot.start",
|
||||
"api.bot.stop",
|
||||
"api.messages.receive",
|
||||
"audit.decision",
|
||||
"bot.chat_analyze",
|
||||
"bot.chat_snapshot",
|
||||
"bot.loop",
|
||||
"bot.session_scan",
|
||||
"bot.session_service.init",
|
||||
"bot.session_title",
|
||||
"bot.submit",
|
||||
"bot.unread.detect",
|
||||
"bot.unread.scan",
|
||||
"ocr.baidu.token",
|
||||
"ocr.baidu.recognize",
|
||||
"ocr.rapid.init",
|
||||
"ocr.rapid.recognize",
|
||||
"ocr.fallback",
|
||||
"ocr.session_name",
|
||||
"ocr.session_title",
|
||||
"ocr.generic",
|
||||
"capture.contact_list",
|
||||
"capture.session_title",
|
||||
"capture.chat_area",
|
||||
"ai.request",
|
||||
"ai.response",
|
||||
"db.init",
|
||||
"db.error",
|
||||
"db.rule.query",
|
||||
"db.rule.match",
|
||||
}
|
||||
|
||||
|
||||
def normalize_event(module: str, event: str) -> str:
|
||||
e = str(event or "").strip().lower()
|
||||
m = str(module or "").strip().lower()
|
||||
if e in ALLOWED_EVENTS:
|
||||
return e
|
||||
if "." in e:
|
||||
prefix = e.split(".", 1)[0]
|
||||
if prefix in DOMAIN_MODULES:
|
||||
return e
|
||||
if m in DOMAIN_MODULES:
|
||||
return f"{m}.unknown"
|
||||
return "api.unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogEvent:
|
||||
level: str
|
||||
module: str
|
||||
event: str
|
||||
trace_id: str
|
||||
stage: str
|
||||
status: str
|
||||
message: str
|
||||
reason: str = ""
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
ts: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
ts = self.ts or datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
module = self.module if self.module in MODULES else "api"
|
||||
event = normalize_event(module, self.event)
|
||||
return {
|
||||
"ts": ts,
|
||||
"level": (self.level or "INFO").upper(),
|
||||
"module": module,
|
||||
"event": event,
|
||||
"trace_id": self.trace_id or "-",
|
||||
"stage": self.stage or "-",
|
||||
"status": self.status or "ok",
|
||||
"reason": self.reason or "",
|
||||
"message": self.message or "",
|
||||
"extra": self.extra or {},
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from app.configs.runtime_config import get_bool, get_int, get_str
|
||||
from app.infrastructure.service.logging.log_schema import ALLOWED_EVENTS, LogEvent, MODULES, normalize_event
|
||||
from app.infrastructure.service.wechat.config import LOG_ROOT_DIR
|
||||
|
||||
_INITIALIZED = False
|
||||
_LOGGERS: dict[str, logging.Logger] = {}
|
||||
_JSON_LOGGERS: dict[str, logging.Logger] = {}
|
||||
|
||||
|
||||
class _JsonFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
if isinstance(record.msg, dict):
|
||||
return json.dumps(record.msg, ensure_ascii=False)
|
||||
return json.dumps({"message": str(record.msg)}, ensure_ascii=False)
|
||||
|
||||
|
||||
class _TextFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
if isinstance(record.msg, dict):
|
||||
data = record.msg
|
||||
extra = data.get("extra") or {}
|
||||
kv = " ".join([f"{k}={v}" for k, v in extra.items()])
|
||||
suffix = f" | {kv}" if kv else ""
|
||||
return f"[{data.get('ts')}][{data.get('level')}][{data.get('module')}][{data.get('event')}][{data.get('trace_id')}] {data.get('message')}{suffix}"
|
||||
return str(record.msg)
|
||||
|
||||
|
||||
def _build_logger(name: str, file_path: Path, formatter: logging.Formatter, level: int, rotate_mb: int, backup_count: int):
|
||||
logger = logging.getLogger(name)
|
||||
logger.handlers.clear()
|
||||
logger.propagate = False
|
||||
logger.setLevel(level)
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = RotatingFileHandler(str(file_path), maxBytes=rotate_mb * 1024 * 1024, backupCount=backup_count, encoding="utf-8")
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
|
||||
def init_logging():
|
||||
global _INITIALIZED
|
||||
if _INITIALIZED:
|
||||
return
|
||||
enabled = get_bool("LOG_ENABLED", True)
|
||||
if not enabled:
|
||||
logging.disable(logging.CRITICAL)
|
||||
_INITIALIZED = True
|
||||
return
|
||||
level_name = (get_str("LOG_LEVEL", "INFO") or "INFO").upper()
|
||||
level = getattr(logging, level_name, logging.INFO)
|
||||
rotate_mb = max(1, get_int("LOG_ROTATE_MB", 5))
|
||||
backup_count = max(1, get_int("LOG_BACKUP_COUNT", 7))
|
||||
root = Path(LOG_ROOT_DIR)
|
||||
for module in MODULES:
|
||||
text_logger = _build_logger(f"solo.{module}.text", root / f"{module}.log", _TextFormatter(), level, rotate_mb, backup_count)
|
||||
json_logger = _build_logger(f"solo.{module}.json", root / f"{module}.jsonl", _JsonFormatter(), level, rotate_mb, backup_count)
|
||||
_LOGGERS[module] = text_logger
|
||||
_JSON_LOGGERS[module] = json_logger
|
||||
_INITIALIZED = True
|
||||
|
||||
|
||||
def log_event(level: str, module: str, event: str, trace_id: str, stage: str, status: str, message: str, reason: str = "", extra: dict | None = None):
|
||||
if not _INITIALIZED:
|
||||
init_logging()
|
||||
if logging.root.manager.disable >= logging.CRITICAL:
|
||||
return
|
||||
normalized_event = normalize_event(module, event)
|
||||
payload_extra = dict(extra or {})
|
||||
if normalized_event != str(event or "").strip().lower() and normalized_event not in ALLOWED_EVENTS:
|
||||
payload_extra["event_raw"] = event
|
||||
payload_extra["event_normalized"] = normalized_event
|
||||
payload = LogEvent(
|
||||
level=level,
|
||||
module=module,
|
||||
event=normalized_event,
|
||||
trace_id=trace_id,
|
||||
stage=stage,
|
||||
status=status,
|
||||
reason=reason,
|
||||
message=message,
|
||||
extra=payload_extra,
|
||||
).to_dict()
|
||||
module_name = payload["module"]
|
||||
lvl = getattr(logging, payload["level"], logging.INFO)
|
||||
_LOGGERS[module_name].log(lvl, payload)
|
||||
_JSON_LOGGERS[module_name].log(lvl, payload)
|
||||
if lvl >= logging.ERROR and module_name != "error":
|
||||
_LOGGERS["error"].log(lvl, payload)
|
||||
_JSON_LOGGERS["error"].log(lvl, payload)
|
||||
|
||||
|
||||
def new_trace_id(prefix: str = "trace") -> str:
|
||||
import uuid
|
||||
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||
@@ -0,0 +1,124 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from app.configs.runtime_config import get_bool, get_float, get_int, get_str
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def _local_appdata_root() -> Path:
|
||||
local_appdata = (os.getenv("LOCALAPPDATA") or "").strip()
|
||||
if local_appdata:
|
||||
return Path(local_appdata)
|
||||
return Path.home() / "AppData" / "Local"
|
||||
|
||||
|
||||
def _resolve_path(path_value: str, base_dir: Path) -> str:
|
||||
path = Path(path_value)
|
||||
if not path.is_absolute():
|
||||
path = base_dir / path
|
||||
return str(path.resolve())
|
||||
|
||||
|
||||
APP_NAME = (get_str("APP_NAME", "AiShiliu") or "AiShiliu").strip() or "AiShiliu"
|
||||
APP_DATA_DIR = _resolve_path(
|
||||
get_str("APP_DATA_DIR", "") or get_str("OPENCLAW_APP_DATA_DIR", "") or str(_local_appdata_root() / APP_NAME),
|
||||
PROJECT_ROOT,
|
||||
)
|
||||
LOG_ROOT_DIR = _resolve_path(get_str("LOG_ROOT_DIR", os.path.join(APP_DATA_DIR, "logs")), PROJECT_ROOT)
|
||||
BACKEND_LOG_DIR = _resolve_path(get_str("BACKEND_LOG_DIR", os.path.join(LOG_ROOT_DIR, "backend")), PROJECT_ROOT)
|
||||
FRONTEND_LOG_DIR = _resolve_path(get_str("FRONTEND_LOG_DIR", os.path.join(LOG_ROOT_DIR, "frontend")), PROJECT_ROOT)
|
||||
BACKEND_PYTHON_LOG_FILE = _resolve_path(
|
||||
get_str("BACKEND_PYTHON_LOG_FILE", os.path.join(BACKEND_LOG_DIR, "python", "backend.log")),
|
||||
PROJECT_ROOT,
|
||||
)
|
||||
BOT_LOG_FILE = _resolve_path(
|
||||
get_str("BOT_LOG_FILE", os.path.join(BACKEND_LOG_DIR, "bot", "bot.log")),
|
||||
PROJECT_ROOT,
|
||||
)
|
||||
BOT_SESSION_LIST_LOG_FILE = _resolve_path(
|
||||
get_str("BOT_SESSION_LIST_LOG_FILE", os.path.join(BACKEND_LOG_DIR, "bot", "session_list.log")),
|
||||
PROJECT_ROOT,
|
||||
)
|
||||
BOT_SESSION_DETAIL_LOG_FILE = _resolve_path(
|
||||
get_str("BOT_SESSION_DETAIL_LOG_FILE", os.path.join(BACKEND_LOG_DIR, "bot", "session_detail.log")),
|
||||
PROJECT_ROOT,
|
||||
)
|
||||
OCR_LOG_FILE = _resolve_path(
|
||||
get_str("OCR_LOG_FILE", os.path.join(BACKEND_LOG_DIR, "ocr", "ocr.log")),
|
||||
PROJECT_ROOT,
|
||||
)
|
||||
OCR_BAIDU_LOG_FILE = _resolve_path(
|
||||
get_str("OCR_BAIDU_LOG_FILE", os.path.join(BACKEND_LOG_DIR, "ocr", "baidu.log")),
|
||||
PROJECT_ROOT,
|
||||
)
|
||||
OCR_RAPID_LOG_FILE = _resolve_path(
|
||||
get_str("OCR_RAPID_LOG_FILE", os.path.join(BACKEND_LOG_DIR, "ocr", "rapid.log")),
|
||||
PROJECT_ROOT,
|
||||
)
|
||||
TAURI_LOG_FILE = _resolve_path(
|
||||
get_str("TAURI_LOG_FILE", os.path.join(FRONTEND_LOG_DIR, "tauri", "tauri.log")),
|
||||
PROJECT_ROOT,
|
||||
)
|
||||
VUE_LOG_FILE = _resolve_path(
|
||||
get_str("VUE_LOG_FILE", os.path.join(FRONTEND_LOG_DIR, "vue", "vue.log")),
|
||||
PROJECT_ROOT,
|
||||
)
|
||||
OCR_SAVE_DIR = _resolve_path(get_str("OCR_SAVE_DIR", os.path.join(BACKEND_LOG_DIR, "ocr_debug_images")), PROJECT_ROOT)
|
||||
BLOCKED_ROW_CACHE_FILE = _resolve_path(
|
||||
get_str("BLOCKED_ROW_CACHE_FILE", os.path.join(BACKEND_LOG_DIR, "state", "blocked_rows.json")),
|
||||
PROJECT_ROOT,
|
||||
)
|
||||
|
||||
BAIDU_API_KEY = get_str("BAIDU_API_KEY", "")
|
||||
BAIDU_SECRET_KEY = get_str("BAIDU_SECRET_KEY", "")
|
||||
OCR_PROVIDER = get_str("OCR_PROVIDER", "baidu").strip().lower()
|
||||
RAPID_OCR_DET_MODEL_PATH = get_str("RAPID_OCR_DET_MODEL_PATH", "app/resources/ocr_models/ch_PP-OCRv4_det.onnx").strip()
|
||||
RAPID_OCR_REC_MODEL_PATH = get_str("RAPID_OCR_REC_MODEL_PATH", "app/resources/ocr_models/ch_PP-OCRv4_rec.onnx").strip()
|
||||
RAPID_OCR_CLS_MODEL_PATH = get_str("RAPID_OCR_CLS_MODEL_PATH", "app/resources/ocr_models/ch_ppocr_mobile_v2.0_cls.onnx").strip()
|
||||
BACKEND_URL = get_str("BACKEND_URL", "http://127.0.0.1:5000/api/messages/receive")
|
||||
LOOP_INTERVAL = get_int("BOT_LOOP_INTERVAL", 3)
|
||||
CLICK_AFTER_DELAY = get_float("BOT_CLICK_AFTER_DELAY", 1.2)
|
||||
TITLE_AFTER_DELAY = get_float("BOT_TITLE_AFTER_DELAY", 1.0)
|
||||
CONTACT_SWITCH_DELAY = get_float("BOT_CONTACT_SWITCH_DELAY", 1.0)
|
||||
LOOP_ERROR_DELAY = get_float("BOT_LOOP_ERROR_DELAY", 3)
|
||||
WECHAT_WINDOW_TARGET_WIDTH = get_int("WECHAT_WINDOW_TARGET_WIDTH", 1080)
|
||||
WECHAT_WINDOW_TARGET_HEIGHT = get_int("WECHAT_WINDOW_TARGET_HEIGHT", 820)
|
||||
WECHAT_WINDOW_TARGET_LEFT = get_int("WECHAT_WINDOW_TARGET_LEFT", 120)
|
||||
WECHAT_WINDOW_TARGET_TOP = get_int("WECHAT_WINDOW_TARGET_TOP", 80)
|
||||
OCR_SAVE_IMAGES = get_bool("OCR_SAVE_IMAGES", True)
|
||||
CONTACT_ROW_HEIGHT = get_int("CONTACT_ROW_HEIGHT", 64)
|
||||
CONTACT_ROW_WIDTH = get_int("CONTACT_ROW_WIDTH", 240)
|
||||
CONTACT_LIST_LEFT_OFFSET = get_int("CONTACT_LIST_LEFT_OFFSET", 68)
|
||||
CONTACT_LIST_TOP_OFFSET = get_int("CONTACT_LIST_TOP_OFFSET", 82)
|
||||
CONTACT_LIST_BOTTOM_OFFSET = get_int("CONTACT_LIST_BOTTOM_OFFSET", 0)
|
||||
SESSION_NAME_LEFT_OFFSET = get_int("SESSION_NAME_LEFT_OFFSET", 56)
|
||||
SESSION_NAME_TOP_OFFSET = get_int("SESSION_NAME_TOP_OFFSET", 8)
|
||||
SESSION_NAME_WIDTH = get_int("SESSION_NAME_WIDTH", 134)
|
||||
SESSION_NAME_HEIGHT = get_int("SESSION_NAME_HEIGHT", 24)
|
||||
SESSION_NAME_OCR_SCALE = get_int("SESSION_NAME_OCR_SCALE", 4)
|
||||
SESSION_NAME_OCR_EXTRA_SCALE = get_int("SESSION_NAME_OCR_EXTRA_SCALE", 6)
|
||||
CHAT_CAPTURE_LEFT_OFFSET = get_int("CHAT_CAPTURE_LEFT_OFFSET", 310)
|
||||
CHAT_CAPTURE_TOP_OFFSET = get_int("CHAT_CAPTURE_TOP_OFFSET", 70)
|
||||
CHAT_CAPTURE_WIDTH = get_int("CHAT_CAPTURE_WIDTH", 750)
|
||||
CHAT_CAPTURE_HEIGHT = get_int("CHAT_CAPTURE_HEIGHT", 550)
|
||||
OCR_TOP_PENALTY_RATIO = get_float("OCR_TOP_PENALTY_RATIO", 0.18)
|
||||
OCR_TOP_PENALTY_BIN_FACTOR = get_float("OCR_TOP_PENALTY_BIN_FACTOR", 2.0)
|
||||
OCR_TOP_PENALTY_COLOR_FACTOR = get_float("OCR_TOP_PENALTY_COLOR_FACTOR", 2.2)
|
||||
TITLE_OCR_AREA_LEFT_OFFSET = get_int("TITLE_OCR_AREA_LEFT_OFFSET", 240)
|
||||
TITLE_OCR_AREA_TOP_OFFSET = get_int("TITLE_OCR_AREA_TOP_OFFSET", 4)
|
||||
TITLE_OCR_AREA_WIDTH = get_int("TITLE_OCR_AREA_WIDTH", 600)
|
||||
TITLE_OCR_AREA_HEIGHT = get_int("TITLE_OCR_AREA_HEIGHT", 64)
|
||||
|
||||
NO_REPLY_KEYWORDS = [
|
||||
"谢谢", "好的", "嗯", "哦", "ok", "收到",
|
||||
"[图片]", "[语音]", "[视频]", "[文件]"
|
||||
]
|
||||
|
||||
BLOCKED_SESSION_KEYWORDS = [
|
||||
"服务号", "公众号", "微信公众平台", "文件传输助手"
|
||||
]
|
||||
|
||||
UI_NOISE_KEYWORDS = [
|
||||
"微信", "Weixin", "WeChat", "聊天信息", "搜索", "更多", "表情", "发送", "Message", "Messages"
|
||||
]
|
||||
@@ -0,0 +1,346 @@
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from app.infrastructure.service.logging.log_service import log_event, new_trace_id
|
||||
from app.infrastructure.service.wechat.config import (
|
||||
BAIDU_API_KEY,
|
||||
BAIDU_SECRET_KEY,
|
||||
OCR_PROVIDER,
|
||||
RAPID_OCR_CLS_MODEL_PATH,
|
||||
RAPID_OCR_DET_MODEL_PATH,
|
||||
RAPID_OCR_REC_MODEL_PATH,
|
||||
SESSION_NAME_OCR_EXTRA_SCALE,
|
||||
SESSION_NAME_OCR_SCALE,
|
||||
)
|
||||
|
||||
|
||||
BAIDU_FALLBACK_ERROR_CODES = {17, 18, 110, 111}
|
||||
|
||||
|
||||
def _runtime_roots() -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
|
||||
meipass = getattr(__import__("sys"), "_MEIPASS", None)
|
||||
if meipass:
|
||||
roots.append(Path(meipass))
|
||||
|
||||
file_root = Path(__file__).resolve().parents[4]
|
||||
roots.append(file_root)
|
||||
|
||||
cwd = Path.cwd().resolve()
|
||||
roots.append(cwd)
|
||||
roots.append(cwd / "resources")
|
||||
roots.append(cwd / "app")
|
||||
|
||||
try:
|
||||
exe_parent = Path(__import__("sys").executable).resolve().parent
|
||||
roots.append(exe_parent)
|
||||
roots.append(exe_parent / "resources")
|
||||
roots.append(exe_parent / "app")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
unique_roots: list[Path] = []
|
||||
seen = set()
|
||||
for root in roots:
|
||||
key = str(root)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique_roots.append(root)
|
||||
return unique_roots
|
||||
|
||||
|
||||
def _resolve_project_path(path_str: str) -> str:
|
||||
path = Path(path_str)
|
||||
if path.is_absolute():
|
||||
return str(path)
|
||||
|
||||
candidates = [(root / path).resolve() for root in _runtime_roots()]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
return str(candidates[0])
|
||||
|
||||
|
||||
class OCRBase:
|
||||
provider_name = "base"
|
||||
|
||||
def recognize(self, image_data, scene="generic", mode="generic"):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class BaiduOCR(OCRBase):
|
||||
provider_name = "baidu"
|
||||
|
||||
def __init__(self, api_key, secret_key):
|
||||
self.api_key = api_key
|
||||
self.secret_key = secret_key
|
||||
self.access_token = None
|
||||
self.last_error_code = None
|
||||
self.last_error_msg = ""
|
||||
self.get_access_token()
|
||||
|
||||
def get_access_token(self):
|
||||
trace_id = new_trace_id("ocr")
|
||||
if not self.api_key or not self.secret_key:
|
||||
log_event("WARNING", "ocr", "ocr.baidu.token", trace_id, "token", "failed", "百度OCR凭据缺失", reason="credential_missing")
|
||||
return
|
||||
url = "https://aip.baidubce.com/oauth/2.0/token"
|
||||
params = {"grant_type": "client_credentials", "client_id": self.api_key, "client_secret": self.secret_key}
|
||||
try:
|
||||
response = requests.post(url, params=params, timeout=10)
|
||||
if response.status_code == 200:
|
||||
self.access_token = response.json().get("access_token")
|
||||
if self.access_token:
|
||||
log_event("INFO", "ocr", "ocr.baidu.token", trace_id, "token", "ok", "百度OCR token获取成功")
|
||||
else:
|
||||
log_event("WARNING", "ocr", "ocr.baidu.token", trace_id, "token", "failed", "百度OCR token为空", reason="token_empty")
|
||||
else:
|
||||
log_event("WARNING", "ocr", "ocr.baidu.token", trace_id, "token", "failed", "百度OCR token获取失败", reason="http_error", extra={"status_code": response.status_code})
|
||||
except Exception as e:
|
||||
log_event("ERROR", "ocr", "ocr.baidu.token", trace_id, "token", "failed", "百度OCR token请求异常", reason="request_error", extra={"error": str(e)})
|
||||
|
||||
def _reset_last_error(self):
|
||||
self.last_error_code = None
|
||||
self.last_error_msg = ""
|
||||
|
||||
def should_fallback_to_rapid(self):
|
||||
return self.last_error_code in BAIDU_FALLBACK_ERROR_CODES
|
||||
|
||||
def recognize(self, image_data, scene="generic", mode="generic"):
|
||||
trace_id = new_trace_id("ocr")
|
||||
self._reset_last_error()
|
||||
if not self.access_token:
|
||||
self.last_error_msg = "no_access_token"
|
||||
log_event("WARNING", "ocr", "ocr.baidu.recognize", trace_id, "recognize", "failed", "百度OCR无可用token", reason="no_access_token", extra={"scene": scene})
|
||||
return []
|
||||
url = f"https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic?access_token={self.access_token}"
|
||||
payload = {"image": base64.b64encode(image_data).decode(), "language_type": "CHN_ENG", "detect_direction": "true", "probability": "true"}
|
||||
try:
|
||||
response = requests.post(url, data=payload, timeout=10)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if "error_code" in result:
|
||||
self.last_error_code = result.get("error_code")
|
||||
self.last_error_msg = result.get("error_msg") or ""
|
||||
log_event("WARNING", "ocr", "ocr.baidu.recognize", trace_id, "recognize", "failed", "百度OCR返回错误码", reason="baidu_error", extra={"scene": scene, "error_code": self.last_error_code, "error_msg": self.last_error_msg})
|
||||
return []
|
||||
if "words_result" in result:
|
||||
lines = []
|
||||
for item in result["words_result"]:
|
||||
text = item.get("words", "")
|
||||
prob = item.get("probability", {}).get("average", 0.9)
|
||||
if text and prob > 0.6:
|
||||
lines.append(text)
|
||||
log_event("INFO", "ocr", "ocr.baidu.recognize", trace_id, "recognize", "ok", "百度OCR识别完成", extra={"scene": scene, "line_count": len(lines)})
|
||||
return lines
|
||||
else:
|
||||
self.last_error_msg = f"http_{response.status_code}"
|
||||
log_event("WARNING", "ocr", "ocr.baidu.recognize", trace_id, "recognize", "failed", "百度OCR请求失败", reason="http_error", extra={"scene": scene, "status_code": response.status_code})
|
||||
except Exception as e:
|
||||
self.last_error_msg = str(e)
|
||||
log_event("ERROR", "ocr", "ocr.baidu.recognize", trace_id, "recognize", "failed", "百度OCR请求异常", reason="request_error", extra={"scene": scene, "error": str(e)})
|
||||
return []
|
||||
|
||||
|
||||
class RapidLocalOCR(OCRBase):
|
||||
provider_name = "rapid"
|
||||
|
||||
def __init__(self):
|
||||
self.ready = False
|
||||
self.engine = None
|
||||
self._init_engine()
|
||||
|
||||
def ensure_ready(self):
|
||||
return self.ready and self.engine is not None
|
||||
|
||||
def _init_engine(self):
|
||||
trace_id = new_trace_id("ocr")
|
||||
try:
|
||||
from rapidocr_onnxruntime import RapidOCR
|
||||
|
||||
model_paths = {
|
||||
"det_model_path": _resolve_project_path(RAPID_OCR_DET_MODEL_PATH),
|
||||
"rec_model_path": _resolve_project_path(RAPID_OCR_REC_MODEL_PATH),
|
||||
"cls_model_path": _resolve_project_path(RAPID_OCR_CLS_MODEL_PATH),
|
||||
}
|
||||
existing_model_paths = {key: value for key, value in model_paths.items() if Path(value).exists()}
|
||||
if len(existing_model_paths) == len(model_paths):
|
||||
self.engine = RapidOCR(**existing_model_paths)
|
||||
log_extra = existing_model_paths
|
||||
else:
|
||||
self.engine = RapidOCR()
|
||||
log_extra = {**model_paths, "missing_models": [value for value in model_paths.values() if not Path(value).exists()]}
|
||||
self.ready = True
|
||||
log_event("INFO", "ocr", "ocr.rapid.init", trace_id, "init", "ok", "RapidOCR初始化成功", extra=log_extra)
|
||||
except Exception as e:
|
||||
self.ready = False
|
||||
log_event("WARNING", "ocr", "ocr.rapid.init", trace_id, "init", "failed", "RapidOCR初始化失败", reason="init_error", extra={"error": str(e)})
|
||||
|
||||
def recognize(self, image_data, scene="generic", mode="generic"):
|
||||
trace_id = new_trace_id("ocr")
|
||||
if not self.ready or self.engine is None:
|
||||
log_event("WARNING", "ocr", "ocr.rapid.recognize", trace_id, "recognize", "failed", "RapidOCR未就绪", reason="not_ready", extra={"scene": scene})
|
||||
return []
|
||||
try:
|
||||
img_np = np.frombuffer(image_data, dtype=np.uint8)
|
||||
img = cv2.imdecode(img_np, cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
log_event("WARNING", "ocr", "ocr.rapid.recognize", trace_id, "recognize", "failed", "RapidOCR图像解码失败", reason="decode_failed", extra={"scene": scene})
|
||||
return []
|
||||
result = self.engine(img)
|
||||
if not result or len(result) < 1:
|
||||
log_event("INFO", "ocr", "ocr.rapid.recognize", trace_id, "recognize", "ok", "RapidOCR识别结果为空", reason="empty_result", extra={"scene": scene})
|
||||
return []
|
||||
rec_res = result[0] or []
|
||||
lines = []
|
||||
for item in rec_res:
|
||||
if not item or len(item) < 2:
|
||||
continue
|
||||
text = str(item[1]).strip()
|
||||
if text:
|
||||
lines.append(text)
|
||||
log_event("INFO", "ocr", "ocr.rapid.recognize", trace_id, "recognize", "ok", "RapidOCR识别完成", extra={"scene": scene, "line_count": len(lines)})
|
||||
return lines
|
||||
except Exception as e:
|
||||
log_event("ERROR", "ocr", "ocr.rapid.recognize", trace_id, "recognize", "failed", "RapidOCR识别异常", reason="recognize_error", extra={"scene": scene, "error": str(e)})
|
||||
return []
|
||||
|
||||
|
||||
class OCRService(OCRBase):
|
||||
provider_name = "service"
|
||||
|
||||
def __init__(self, provider=None):
|
||||
self.provider_requested = (provider or OCR_PROVIDER or "baidu").strip().lower()
|
||||
self.baidu_provider = BaiduOCR(BAIDU_API_KEY, BAIDU_SECRET_KEY)
|
||||
self.rapid_provider = RapidLocalOCR()
|
||||
self.provider = self._build_provider(self.provider_requested)
|
||||
|
||||
def _build_provider(self, provider_name: str):
|
||||
if provider_name in {"rapid", "rapidocr"}:
|
||||
return self.rapid_provider
|
||||
if provider_name in {"baidu", "baiduocr"}:
|
||||
return self.baidu_provider
|
||||
if provider_name == "auto":
|
||||
if self.baidu_provider.access_token:
|
||||
return self.baidu_provider
|
||||
if self.rapid_provider.ensure_ready():
|
||||
return self.rapid_provider
|
||||
return self.baidu_provider
|
||||
return self.baidu_provider
|
||||
|
||||
def _provider_recognize(self, image_data, scene):
|
||||
trace_id = new_trace_id("ocr")
|
||||
lines = self.provider.recognize(image_data, scene=scene)
|
||||
if self.provider.provider_name != "baidu":
|
||||
return lines
|
||||
if lines:
|
||||
return lines
|
||||
no_token_fallback = self.baidu_provider.last_error_msg == "no_access_token"
|
||||
should_fallback = self.baidu_provider.should_fallback_to_rapid() or no_token_fallback
|
||||
if not should_fallback:
|
||||
return lines
|
||||
if not self.rapid_provider.ensure_ready():
|
||||
log_event("WARNING", "ocr", "ocr.fallback", trace_id, "fallback", "failed", "触发Rapid回退但引擎未就绪", reason="rapid_not_ready", extra={"scene": scene, "baidu_error": self.baidu_provider.last_error_msg or ""})
|
||||
return lines
|
||||
rapid_lines = self.rapid_provider.recognize(image_data, scene=f"{scene}_rapid_fallback")
|
||||
if rapid_lines:
|
||||
log_event("INFO", "ocr", "ocr.fallback", trace_id, "fallback", "ok", "百度OCR回退Rapid成功", reason="fallback_success", extra={"scene": scene, "line_count": len(rapid_lines)})
|
||||
else:
|
||||
log_event("WARNING", "ocr", "ocr.fallback", trace_id, "fallback", "failed", "百度OCR回退Rapid失败", reason="fallback_empty", extra={"scene": scene})
|
||||
return rapid_lines
|
||||
|
||||
def _encode_image(self, image_obj):
|
||||
buf = BytesIO()
|
||||
image_obj.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
def _normalize_lines(self, lines, min_len=1, exclude=None):
|
||||
exclude = set(exclude or [])
|
||||
normalized = []
|
||||
for line in lines or []:
|
||||
text = str(line).strip()
|
||||
if not text:
|
||||
continue
|
||||
if len(text) < min_len:
|
||||
continue
|
||||
if text in exclude:
|
||||
continue
|
||||
normalized.append(text)
|
||||
return normalized
|
||||
|
||||
def _build_session_name_variants(self, image_data):
|
||||
image = Image.open(BytesIO(image_data)).convert("RGB")
|
||||
gray = image.convert("L")
|
||||
base_scale = max(2, int(SESSION_NAME_OCR_SCALE))
|
||||
extra_scale = max(base_scale, int(SESSION_NAME_OCR_EXTRA_SCALE))
|
||||
|
||||
enlarged = gray.resize(
|
||||
(gray.width * base_scale, gray.height * base_scale),
|
||||
resample=Image.Resampling.LANCZOS,
|
||||
)
|
||||
contrast = cv2.equalizeHist(np.array(enlarged))
|
||||
binary = cv2.threshold(contrast, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]
|
||||
binary_inv = cv2.threshold(contrast, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
|
||||
|
||||
extra_enlarged = gray.resize(
|
||||
(gray.width * extra_scale, gray.height * extra_scale),
|
||||
resample=Image.Resampling.LANCZOS,
|
||||
)
|
||||
extra_contrast = cv2.equalizeHist(np.array(extra_enlarged))
|
||||
|
||||
return [
|
||||
("name_crop", image_data),
|
||||
(f"name_orig_{base_scale}x", self._encode_image(enlarged)),
|
||||
(f"name_eq_{base_scale}x", self._encode_image(Image.fromarray(contrast))),
|
||||
(f"name_bin_{base_scale}x", self._encode_image(Image.fromarray(binary))),
|
||||
(f"name_bin_inv_{base_scale}x", self._encode_image(Image.fromarray(binary_inv))),
|
||||
(f"name_orig_{extra_scale}x", self._encode_image(extra_enlarged)),
|
||||
(f"name_eq_{extra_scale}x", self._encode_image(Image.fromarray(extra_contrast))),
|
||||
]
|
||||
|
||||
def _recognize_session_name(self, image_data, scene):
|
||||
trace_id = new_trace_id("ocr")
|
||||
for variant_name, variant_bytes in self._build_session_name_variants(image_data):
|
||||
lines = self._normalize_lines(
|
||||
self._provider_recognize(variant_bytes, scene=f"{scene}_{variant_name}"),
|
||||
min_len=1,
|
||||
)
|
||||
if lines:
|
||||
log_event("INFO", "ocr", "ocr.session_name", trace_id, "recognize", "ok", "会话名识别成功", extra={"scene": scene, "variant": variant_name, "line_count": len(lines)})
|
||||
return lines
|
||||
log_event("INFO", "ocr", "ocr.session_name", trace_id, "recognize", "failed", "会话名识别为空", reason="empty_result", extra={"scene": scene})
|
||||
return []
|
||||
|
||||
def _recognize_session_title(self, image_data, scene):
|
||||
trace_id = new_trace_id("ocr")
|
||||
lines = self._provider_recognize(image_data, scene=scene)
|
||||
normalized = self._normalize_lines(lines, min_len=1)
|
||||
if normalized:
|
||||
log_event("INFO", "ocr", "ocr.session_title", trace_id, "recognize", "ok", "会话标题识别成功", extra={"scene": scene, "line_count": len(normalized)})
|
||||
else:
|
||||
log_event("INFO", "ocr", "ocr.session_title", trace_id, "recognize", "failed", "会话标题识别为空", reason="empty_result", extra={"scene": scene})
|
||||
return normalized
|
||||
|
||||
def recognize_session_name(self, image_data, scene="session_name"):
|
||||
return self._recognize_session_name(image_data, scene=scene)
|
||||
|
||||
def recognize_session_title(self, image_data, scene="session_title"):
|
||||
return self._recognize_session_title(image_data, scene=scene)
|
||||
|
||||
def recognize(self, image_data, scene="generic", mode="generic"):
|
||||
trace_id = new_trace_id("ocr")
|
||||
if mode == "session_name":
|
||||
return self.recognize_session_name(image_data, scene=scene)
|
||||
if mode == "session_title":
|
||||
return self.recognize_session_title(image_data, scene=scene)
|
||||
lines = self._normalize_lines(self._provider_recognize(image_data, scene=scene), min_len=1)
|
||||
log_event("INFO", "ocr", "ocr.generic", trace_id, "recognize", "ok", "通用OCR识别完成", extra={"scene": scene, "line_count": len(lines)})
|
||||
return lines
|
||||
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import ImageGrab
|
||||
|
||||
from app.infrastructure.service.logging.log_service import log_event, new_trace_id
|
||||
from app.infrastructure.service.wechat.config import (
|
||||
CHAT_CAPTURE_HEIGHT,
|
||||
CHAT_CAPTURE_LEFT_OFFSET,
|
||||
CHAT_CAPTURE_TOP_OFFSET,
|
||||
CHAT_CAPTURE_WIDTH,
|
||||
CONTACT_LIST_BOTTOM_OFFSET,
|
||||
CONTACT_LIST_LEFT_OFFSET,
|
||||
CONTACT_LIST_TOP_OFFSET,
|
||||
CONTACT_ROW_WIDTH,
|
||||
SESSION_NAME_HEIGHT,
|
||||
SESSION_NAME_LEFT_OFFSET,
|
||||
SESSION_NAME_TOP_OFFSET,
|
||||
SESSION_NAME_WIDTH,
|
||||
TITLE_OCR_AREA_HEIGHT,
|
||||
TITLE_OCR_AREA_LEFT_OFFSET,
|
||||
TITLE_OCR_AREA_TOP_OFFSET,
|
||||
TITLE_OCR_AREA_WIDTH,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptureBox:
|
||||
left: int
|
||||
top: int
|
||||
right: int
|
||||
bottom: int
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return self.right - self.left
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return self.bottom - self.top
|
||||
|
||||
def as_tuple(self):
|
||||
return (self.left, self.top, self.right, self.bottom)
|
||||
|
||||
def as_dict(self) -> Dict[str, int]:
|
||||
return {
|
||||
"left": self.left,
|
||||
"top": self.top,
|
||||
"right": self.right,
|
||||
"bottom": self.bottom,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
}
|
||||
|
||||
|
||||
class ScreenshotService:
|
||||
def build_box(self, left: int, top: int, width: int, height: int) -> CaptureBox:
|
||||
return CaptureBox(
|
||||
left=int(left),
|
||||
top=int(top),
|
||||
right=int(left + width),
|
||||
bottom=int(top + height),
|
||||
)
|
||||
|
||||
def build_box_from_window(self, window_rect: dict, left_offset: int, top_offset: int, width: int, height: int) -> CaptureBox:
|
||||
return self.build_box(
|
||||
left=window_rect["left"] + int(left_offset),
|
||||
top=window_rect["top"] + int(top_offset),
|
||||
width=int(width),
|
||||
height=int(height),
|
||||
)
|
||||
|
||||
def build_contact_list_box(self, window_rect: dict, left_offset: int, top_offset: int, width: int, bottom_offset: int) -> CaptureBox:
|
||||
left = window_rect["left"] + int(left_offset)
|
||||
top = window_rect["top"] + int(top_offset)
|
||||
right = left + int(width)
|
||||
bottom = window_rect["bottom"] - int(bottom_offset)
|
||||
return CaptureBox(left=left, top=top, right=right, bottom=bottom)
|
||||
|
||||
def is_valid_box(self, box: CaptureBox) -> bool:
|
||||
return box.right > box.left and box.bottom > box.top
|
||||
|
||||
def capture_box(self, left: int, top: int, width: int, height: int):
|
||||
box = self.build_box(left, top, width, height)
|
||||
if not self.is_valid_box(box):
|
||||
raise ValueError(f"invalid capture box: {box.as_dict()}")
|
||||
return ImageGrab.grab(bbox=box.as_tuple())
|
||||
|
||||
def capture_from_window(self, window_rect: dict, left_offset: int, top_offset: int, width: int, height: int):
|
||||
box = self.build_box_from_window(window_rect, left_offset, top_offset, width, height)
|
||||
if not self.is_valid_box(box):
|
||||
raise ValueError(f"invalid window capture box: {box.as_dict()}")
|
||||
return ImageGrab.grab(bbox=box.as_tuple())
|
||||
|
||||
def capture_contact_list(self, window_rect: dict, left_offset: int, top_offset: int, width: int, bottom_offset: int):
|
||||
box = self.build_contact_list_box(window_rect, left_offset, top_offset, width, bottom_offset)
|
||||
if not self.is_valid_box(box):
|
||||
raise ValueError(f"invalid contact list box: {box.as_dict()}")
|
||||
return ImageGrab.grab(bbox=box.as_tuple())
|
||||
|
||||
def get_contact_list_box(self, window_rect: dict) -> CaptureBox:
|
||||
return self.build_contact_list_box(
|
||||
window_rect,
|
||||
left_offset=CONTACT_LIST_LEFT_OFFSET,
|
||||
top_offset=CONTACT_LIST_TOP_OFFSET,
|
||||
width=CONTACT_ROW_WIDTH,
|
||||
bottom_offset=CONTACT_LIST_BOTTOM_OFFSET,
|
||||
)
|
||||
|
||||
def capture_contact_list_default(self, window_rect: dict):
|
||||
trace_id = new_trace_id("capture")
|
||||
box = self.get_contact_list_box(window_rect)
|
||||
log_event("INFO", "capture", "capture.contact_list", trace_id, "capture", "ok", "截图会话列表区域", extra=box.as_dict())
|
||||
return self.capture_contact_list(
|
||||
window_rect,
|
||||
left_offset=CONTACT_LIST_LEFT_OFFSET,
|
||||
top_offset=CONTACT_LIST_TOP_OFFSET,
|
||||
width=CONTACT_ROW_WIDTH,
|
||||
bottom_offset=CONTACT_LIST_BOTTOM_OFFSET,
|
||||
)
|
||||
|
||||
def get_session_title_box(self, window_rect: dict) -> CaptureBox:
|
||||
return self.build_box_from_window(
|
||||
window_rect,
|
||||
left_offset=TITLE_OCR_AREA_LEFT_OFFSET,
|
||||
top_offset=TITLE_OCR_AREA_TOP_OFFSET,
|
||||
width=TITLE_OCR_AREA_WIDTH,
|
||||
height=TITLE_OCR_AREA_HEIGHT,
|
||||
)
|
||||
|
||||
def capture_session_title(self, window_rect: dict):
|
||||
trace_id = new_trace_id("capture")
|
||||
box = self.get_session_title_box(window_rect)
|
||||
log_event("INFO", "capture", "capture.session_title", trace_id, "capture", "ok", "截图会话标题区域", extra=box.as_dict())
|
||||
return self.capture_area_from_box(box)
|
||||
|
||||
def get_chat_capture_box(self, window_rect: dict) -> CaptureBox:
|
||||
base_height = max(120, CHAT_CAPTURE_HEIGHT)
|
||||
max_height = max(base_height, window_rect["height"] - CHAT_CAPTURE_TOP_OFFSET)
|
||||
return self.build_box_from_window(
|
||||
window_rect,
|
||||
left_offset=CHAT_CAPTURE_LEFT_OFFSET,
|
||||
top_offset=CHAT_CAPTURE_TOP_OFFSET,
|
||||
width=CHAT_CAPTURE_WIDTH,
|
||||
height=max_height,
|
||||
)
|
||||
|
||||
def capture_chat_area(self, window_rect: dict):
|
||||
trace_id = new_trace_id("capture")
|
||||
box = self.get_chat_capture_box(window_rect)
|
||||
image = self.capture_area_from_box(box)
|
||||
chat_bottom = self._detect_chat_bottom_by_binary_merge(image)
|
||||
if chat_bottom is not None:
|
||||
image = image.crop((0, 0, image.size[0], chat_bottom))
|
||||
extra = box.as_dict()
|
||||
extra["dynamic_bottom"] = chat_bottom or ""
|
||||
extra["final_width"] = image.size[0]
|
||||
extra["final_height"] = image.size[1]
|
||||
log_event("INFO", "capture", "capture.chat_area", trace_id, "capture", "ok", "截图聊天区域", extra=extra)
|
||||
return image
|
||||
|
||||
def crop_session_name(self, row_img):
|
||||
return self.crop_from_image(
|
||||
row_img,
|
||||
left=SESSION_NAME_LEFT_OFFSET,
|
||||
top=SESSION_NAME_TOP_OFFSET,
|
||||
width=SESSION_NAME_WIDTH,
|
||||
height=SESSION_NAME_HEIGHT,
|
||||
)
|
||||
|
||||
def capture_area_from_box(self, box: CaptureBox):
|
||||
if not self.is_valid_box(box):
|
||||
raise ValueError(f"invalid capture box: {box.as_dict()}")
|
||||
return ImageGrab.grab(bbox=box.as_tuple())
|
||||
|
||||
def _build_merged_binary_array(self, image_obj):
|
||||
arr = np.array(image_obj.convert("RGB"))
|
||||
gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
|
||||
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||
_, binary_inv = cv2.threshold(blurred, 248, 255, cv2.THRESH_BINARY_INV)
|
||||
adaptive_inv = cv2.adaptiveThreshold(
|
||||
blurred,
|
||||
255,
|
||||
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||
cv2.THRESH_BINARY_INV,
|
||||
13,
|
||||
1,
|
||||
)
|
||||
merged = cv2.bitwise_or(binary_inv, adaptive_inv)
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 1))
|
||||
merged = cv2.morphologyEx(merged, cv2.MORPH_CLOSE, kernel, iterations=1)
|
||||
merged = cv2.morphologyEx(merged, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)))
|
||||
return merged
|
||||
|
||||
def _detect_chat_bottom_by_binary_merge(self, image_obj) -> int | None:
|
||||
if image_obj is None:
|
||||
return None
|
||||
merged = self._build_merged_binary_array(image_obj)
|
||||
img_h, img_w = merged.shape[:2]
|
||||
start_y = max(0, int(img_h * 0.55))
|
||||
roi = merged[start_y:, :]
|
||||
if roi.size == 0:
|
||||
return None
|
||||
row_density = (roi > 0).mean(axis=1)
|
||||
min_run = max(28, int(img_h * 0.045))
|
||||
dense_limit = 0.018
|
||||
run_start = None
|
||||
candidates = []
|
||||
for idx, density in enumerate(row_density.tolist() + [1.0]):
|
||||
is_blank = density <= dense_limit
|
||||
if is_blank and run_start is None:
|
||||
run_start = idx
|
||||
continue
|
||||
if is_blank:
|
||||
continue
|
||||
if run_start is not None:
|
||||
run_end = idx
|
||||
if run_end - run_start >= min_run:
|
||||
top = start_y + run_start
|
||||
bottom = start_y + run_end
|
||||
if top >= img_h * 0.58 and bottom <= img_h - 8:
|
||||
candidates.append((top, bottom))
|
||||
run_start = None
|
||||
if not candidates:
|
||||
return self._detect_chat_bottom(image_obj)
|
||||
top, _ = candidates[-1]
|
||||
bottom = max(120, int(top - 4))
|
||||
if bottom >= img_h - 20:
|
||||
return None
|
||||
return bottom
|
||||
|
||||
def crop_from_image(self, image_obj, left: int, top: int, width: int, height: int):
|
||||
if image_obj is None:
|
||||
return None
|
||||
img_w, img_h = image_obj.size
|
||||
crop_left = min(max(0, int(left)), img_w)
|
||||
crop_top = min(max(0, int(top)), img_h)
|
||||
crop_right = min(img_w, crop_left + max(1, int(width)))
|
||||
crop_bottom = min(img_h, crop_top + max(1, int(height)))
|
||||
if crop_right <= crop_left or crop_bottom <= crop_top:
|
||||
return None
|
||||
return image_obj.crop((crop_left, crop_top, crop_right, crop_bottom))
|
||||
|
||||
def _detect_chat_bottom(self, image_obj) -> int | None:
|
||||
if image_obj is None:
|
||||
return None
|
||||
img_rgb = np.array(image_obj.convert("RGB"))
|
||||
if img_rgb.size == 0:
|
||||
return None
|
||||
gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
|
||||
img_h, img_w = gray.shape[:2]
|
||||
start_y = max(0, int(img_h * 0.55))
|
||||
focus = gray[start_y:, :]
|
||||
if focus.size == 0:
|
||||
return None
|
||||
row_mean = focus.mean(axis=1)
|
||||
row_std = focus.std(axis=1)
|
||||
bright_mask = (row_mean >= 242) & (row_std <= 18)
|
||||
run = self._find_last_run(bright_mask, min_len=max(18, int(img_h * 0.035)))
|
||||
candidate_y = None
|
||||
if run is not None:
|
||||
run_top, run_bottom = run
|
||||
candidate_y = start_y + run_top
|
||||
edge_img = cv2.Canny(focus, 40, 120)
|
||||
edge_strength = edge_img.mean(axis=1)
|
||||
if len(row_mean) >= 2:
|
||||
transition = np.abs(np.diff(row_mean, prepend=row_mean[0]))
|
||||
else:
|
||||
transition = np.zeros_like(row_mean)
|
||||
score = edge_strength * 1.8 + transition * 2.4
|
||||
score[: max(8, int(len(score) * 0.15))] = 0
|
||||
if candidate_y is not None:
|
||||
local_limit = max(0, candidate_y - start_y + 4)
|
||||
score[local_limit:] = 0
|
||||
best_idx = int(np.argmax(score)) if score.size else -1
|
||||
best_score = float(score[best_idx]) if best_idx >= 0 else 0.0
|
||||
edge_candidate = None
|
||||
if best_idx >= 0 and best_score >= 12.0:
|
||||
edge_candidate = start_y + best_idx
|
||||
final_y = None
|
||||
if candidate_y is not None and edge_candidate is not None:
|
||||
if abs(candidate_y - edge_candidate) <= 28:
|
||||
final_y = min(candidate_y, edge_candidate)
|
||||
else:
|
||||
final_y = candidate_y
|
||||
else:
|
||||
final_y = candidate_y if candidate_y is not None else edge_candidate
|
||||
if final_y is None:
|
||||
return None
|
||||
final_y = max(120, min(img_h, int(final_y - 6)))
|
||||
if final_y >= img_h - 20:
|
||||
return None
|
||||
return final_y
|
||||
|
||||
def _find_last_run(self, mask: np.ndarray, min_len: int) -> tuple[int, int] | None:
|
||||
run_start = None
|
||||
best = None
|
||||
for idx, flag in enumerate(mask.tolist() + [False]):
|
||||
if flag and run_start is None:
|
||||
run_start = idx
|
||||
continue
|
||||
if flag:
|
||||
continue
|
||||
if run_start is None:
|
||||
continue
|
||||
run_len = idx - run_start
|
||||
if run_len >= min_len:
|
||||
best = (run_start, idx)
|
||||
run_start = None
|
||||
return best
|
||||
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
import json
|
||||
import os
|
||||
from typing import Callable
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from app.infrastructure.service.logging.log_service import log_event, new_trace_id
|
||||
from app.infrastructure.service.wechat.chat_snapshot_analyzer import analyze_pil_image
|
||||
from app.infrastructure.service.wechat.unread_session_analyzer import UnreadSessionAnalyzer
|
||||
from app.infrastructure.service.wechat.config import (
|
||||
BLOCKED_SESSION_KEYWORDS,
|
||||
CONTACT_ROW_HEIGHT,
|
||||
OCR_SAVE_DIR,
|
||||
OCR_SAVE_IMAGES,
|
||||
SESSION_NAME_HEIGHT,
|
||||
SESSION_NAME_LEFT_OFFSET,
|
||||
SESSION_NAME_TOP_OFFSET,
|
||||
SESSION_NAME_WIDTH,
|
||||
UI_NOISE_KEYWORDS,
|
||||
)
|
||||
|
||||
|
||||
|
||||
# 会话扫描结果数据类,包含所有会话列表和未读会话列表
|
||||
@dataclass
|
||||
class SessionScanResult:
|
||||
sessions: list[dict]
|
||||
unread_sessions: list[dict]
|
||||
|
||||
|
||||
# 聊天快照分析结果数据类
|
||||
@dataclass
|
||||
class ChatAnalyzeResult:
|
||||
ok: bool
|
||||
file_name: str
|
||||
latest_text: str
|
||||
confidence: str | float
|
||||
bubble_side: str
|
||||
screenshot_path: str
|
||||
|
||||
|
||||
# 微信会话服务类,处理会话列表扫描、红点检测和聊天截图分析
|
||||
class WechatSessionService:
|
||||
def __init__(self, screenshot_service, ocr_service, save_debug_image: Callable | None = None):
|
||||
self.screenshot = screenshot_service
|
||||
self.ocr = ocr_service
|
||||
self.save_debug_image = save_debug_image
|
||||
self._session_title_cache = {"value": "", "ts": 0.0}
|
||||
self.unread_analyzer = UnreadSessionAnalyzer()
|
||||
log_event("INFO", "bot", "bot.session_service.init", new_trace_id("bot"), "init", "ok", "会话服务初始化完成")
|
||||
|
||||
# 根据窗口矩形计算会话列表区域的位置
|
||||
def get_contact_list_rect(self, window_rect):
|
||||
box = self.screenshot.get_contact_list_box(window_rect)
|
||||
return {
|
||||
'left': box.left,
|
||||
'top': box.top,
|
||||
'right': box.right,
|
||||
'bottom': box.bottom,
|
||||
}
|
||||
|
||||
# 从会话行图片中裁剪出会话名称区域
|
||||
def extract_session_name_image(self, row_img):
|
||||
return self.screenshot.crop_session_name(row_img)
|
||||
|
||||
# 检测会话列表中的所有红点位置(红色圆点表示未读消息)
|
||||
def detect_red_dots(self, window_rect):
|
||||
contact_rect = self.get_contact_list_rect(window_rect)
|
||||
screenshot = self.screenshot.capture_contact_list_default(window_rect)
|
||||
return self.unread_analyzer.detect_red_dots(contact_rect, screenshot)
|
||||
|
||||
# 检测单行会话图片中是否有未读红点标记(严格模式)
|
||||
def row_has_red_dot(self, row_img, relaxed=False):
|
||||
return self.unread_analyzer.row_has_red_dot(row_img, relaxed=relaxed)
|
||||
|
||||
# 检测单行会话图片中是否有未读红点标记(宽松模式)
|
||||
def row_has_red_dot_weak(self, row_img):
|
||||
return self.unread_analyzer.row_has_red_dot_weak(row_img)
|
||||
|
||||
# 扫描所有会话行,识别哪些有未读消息标记
|
||||
def get_all_sessions_with_unread(self, window_rect, round_count):
|
||||
trace_id = new_trace_id("bot")
|
||||
contact_rect = self.get_contact_list_rect(window_rect)
|
||||
screenshot = self.screenshot.capture_contact_list_default(window_rect)
|
||||
|
||||
sessions, unread_sessions = self.unread_analyzer.get_all_sessions_with_unread(
|
||||
contact_rect=contact_rect,
|
||||
screenshot=screenshot,
|
||||
round_count=round_count,
|
||||
save_debug_image=lambda image_obj, filename: self._save_debug_image(image_obj, filename),
|
||||
)
|
||||
self._save_session_scan_debug(round_count=round_count, sessions=sessions, unread_sessions=unread_sessions, contact_rect=contact_rect)
|
||||
log_event("INFO", "bot", "bot.session_scan", trace_id, "scan", "ok", "会话扫描完成", extra={"round": int(round_count), "total": len(sessions), "unread": len(unread_sessions)})
|
||||
return SessionScanResult(sessions=sessions, unread_sessions=unread_sessions)
|
||||
|
||||
# 标准化文本用于匹配:去除空格并转为小写
|
||||
def normalize_match_text(self, text):
|
||||
if not text:
|
||||
return ""
|
||||
text = str(text).strip().lower()
|
||||
return "".join(ch for ch in text if not ch.isspace())
|
||||
|
||||
# 生成会话屏蔽关键字的唯一标识key,用于缓存比对
|
||||
def make_block_key(self, text):
|
||||
normalized = self.normalize_match_text(text)
|
||||
if not normalized:
|
||||
return ""
|
||||
return f"title:{normalized}"
|
||||
|
||||
# 重置当前会话标题缓存
|
||||
def reset_session_title_cache(self):
|
||||
self._session_title_cache = {"value": "", "ts": 0.0}
|
||||
|
||||
# 通过OCR识别当前会话窗口的标题文字
|
||||
def get_session_title_by_ocr(self, window_rect):
|
||||
trace_id = new_trace_id("bot")
|
||||
try:
|
||||
if not window_rect:
|
||||
return ""
|
||||
area_name = "main"
|
||||
screenshot = self.screenshot.capture_session_title(window_rect)
|
||||
img_bytes = BytesIO()
|
||||
screenshot.save(img_bytes, format='PNG')
|
||||
valid = self.ocr.recognize_session_title(img_bytes.getvalue(), scene=f"session_title_{area_name}")
|
||||
if valid:
|
||||
title = valid[0]
|
||||
log_event("INFO", "bot", "bot.session_title", trace_id, "ocr", "ok", "会话标题识别成功", extra={"title": title})
|
||||
return title
|
||||
log_event("INFO", "bot", "bot.session_title", trace_id, "ocr", "failed", "会话标题识别为空", reason="empty_result")
|
||||
return ""
|
||||
except Exception as e:
|
||||
log_event("ERROR", "bot", "bot.session_title", trace_id, "ocr", "failed", "会话标题识别异常", reason="ocr_error", extra={"error": str(e)})
|
||||
return ""
|
||||
|
||||
# 获取当前会话标题,优先使用缓存避免频繁OCR调用
|
||||
def get_current_session_title(self, window_rect):
|
||||
try:
|
||||
import time
|
||||
|
||||
now_ts = time.time()
|
||||
cached_title = (self._session_title_cache.get("value") or "").strip()
|
||||
cached_ts = float(self._session_title_cache.get("ts") or 0.0)
|
||||
if cached_title and now_ts - cached_ts <= 1.2:
|
||||
return cached_title
|
||||
|
||||
title = (self.get_session_title_by_ocr(window_rect) or "").strip()
|
||||
if title and title not in UI_NOISE_KEYWORDS:
|
||||
self._session_title_cache = {"value": title, "ts": now_ts}
|
||||
return title
|
||||
except Exception as e:
|
||||
return ""
|
||||
|
||||
# 判断当前选中的会话是否应被跳过(点击后标题检查阶段)
|
||||
def should_skip_current_session(self, window_rect, session, blocked_row_cache, save_blocked_row_cache: Callable):
|
||||
title = self.get_current_session_title(window_rect)
|
||||
block_key = self.make_block_key(title)
|
||||
if block_key and block_key in blocked_row_cache:
|
||||
return True
|
||||
normalized_title = self.normalize_match_text(title)
|
||||
for keyword in BLOCKED_SESSION_KEYWORDS:
|
||||
if self.normalize_match_text(keyword) in normalized_title:
|
||||
if block_key:
|
||||
blocked_row_cache[block_key] = title or keyword
|
||||
save_blocked_row_cache()
|
||||
return True
|
||||
return False
|
||||
|
||||
# 比较两个会话名称是否匹配(考虑模糊匹配和大小写)
|
||||
def is_same_session(self, expected_session, current_session):
|
||||
expected = self.normalize_match_text(expected_session)
|
||||
current = self.normalize_match_text(current_session)
|
||||
if not expected or not current:
|
||||
return False
|
||||
return expected in current or current in expected
|
||||
|
||||
# 根据OCR识别结果判断会话列表中的会话是否应被跳过
|
||||
def should_skip_session_by_ocr(self, session, blocked_row_cache, save_blocked_row_cache: Callable):
|
||||
image_obj = session.get('row_img')
|
||||
if image_obj is None:
|
||||
return False
|
||||
try:
|
||||
name_img = self.extract_session_name_image(image_obj)
|
||||
if name_img is None:
|
||||
return False
|
||||
|
||||
crop_box = {
|
||||
'left': SESSION_NAME_LEFT_OFFSET,
|
||||
'top': SESSION_NAME_TOP_OFFSET,
|
||||
'width': SESSION_NAME_WIDTH,
|
||||
'height': SESSION_NAME_HEIGHT,
|
||||
'row_w': image_obj.size[0],
|
||||
'row_h': image_obj.size[1],
|
||||
'crop_w': name_img.size[0],
|
||||
'crop_h': name_img.size[1],
|
||||
}
|
||||
if OCR_SAVE_IMAGES:
|
||||
file_name = f"row_{int(session.get('row_idx', 0)):03d}_name_raw.png"
|
||||
self._save_debug_image(name_img, os.path.join('sessions', 'name_ocr', file_name))
|
||||
|
||||
img_bytes = BytesIO()
|
||||
name_img.save(img_bytes, format='PNG')
|
||||
lines = self.ocr.recognize_session_name(img_bytes.getvalue(), scene=f"session_row_{session.get('row_idx')}")
|
||||
line_text = ' '.join(lines)
|
||||
session['list_ocr_title'] = line_text
|
||||
normalized_text = self.normalize_match_text(line_text)
|
||||
block_key = self.make_block_key(line_text)
|
||||
if block_key and block_key in blocked_row_cache:
|
||||
return True
|
||||
for keyword in BLOCKED_SESSION_KEYWORDS:
|
||||
if self.normalize_match_text(keyword) in normalized_text:
|
||||
if block_key:
|
||||
blocked_row_cache[block_key] = line_text or keyword
|
||||
save_blocked_row_cache()
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
# 分析点击后的聊天区域截图,提取最新消息文本并返回分析结果
|
||||
def analyze_clicked_session(self, window_rect, round_count, row_idx):
|
||||
trace_id = new_trace_id("bot")
|
||||
chat_box = self.screenshot.get_chat_capture_box(window_rect)
|
||||
if not self.screenshot.is_valid_box(chat_box):
|
||||
log_event("WARNING", "bot", "bot.chat_analyze", trace_id, "capture", "failed", "聊天区截图区域无效", reason="invalid_box")
|
||||
return ChatAnalyzeResult(ok=False, file_name='', latest_text='', confidence='', bubble_side='', screenshot_path='')
|
||||
|
||||
screenshot = self.screenshot.capture_chat_area(window_rect)
|
||||
file_name = f"round_{round_count:04d}_row_{row_idx:03d}_chat.png"
|
||||
rel_path = os.path.join('sessions', 'clicked', file_name)
|
||||
self._save_debug_image(screenshot, rel_path)
|
||||
result = analyze_pil_image(screenshot, stem=os.path.splitext(file_name)[0], file_name=file_name)
|
||||
latest_text = (getattr(result, 'latest_text', None) or '').strip()
|
||||
confidence = getattr(result, 'confidence', '')
|
||||
bubble_side = getattr(result, 'bubble_side', '')
|
||||
log_event("INFO", "bot", "bot.chat_analyze", trace_id, "analyze", "ok", "聊天截图分析完成", extra={"round": int(round_count), "row_idx": int(row_idx), "has_text": bool(latest_text), "bubble_side": bubble_side or "", "confidence": confidence})
|
||||
return ChatAnalyzeResult(
|
||||
ok=bool(latest_text),
|
||||
file_name=file_name,
|
||||
latest_text=latest_text,
|
||||
confidence=confidence,
|
||||
bubble_side=bubble_side,
|
||||
screenshot_path=rel_path,
|
||||
)
|
||||
|
||||
# 保存会话扫描调试数据(类似聊天分析输出 result.json)
|
||||
def _save_session_scan_debug(self, round_count: int, sessions: list[dict], unread_sessions: list[dict], contact_rect: dict):
|
||||
if not OCR_SAVE_IMAGES:
|
||||
return
|
||||
try:
|
||||
debug_dir = os.path.join(OCR_SAVE_DIR, 'sessions', 'scan_debug')
|
||||
os.makedirs(debug_dir, exist_ok=True)
|
||||
file_name = f"round_{round_count:04d}_scan.json"
|
||||
file_path = os.path.join(debug_dir, file_name)
|
||||
rows = []
|
||||
for session in sessions:
|
||||
rows.append({
|
||||
'row_idx': session.get('row_idx'),
|
||||
'has_red_dot': bool(session.get('has_red_dot')),
|
||||
'has_red_by_global': bool(session.get('has_red_by_global')),
|
||||
'has_red_by_row': bool(session.get('has_red_by_row')),
|
||||
'has_red_by_row_weak': bool(session.get('has_red_by_row_weak')),
|
||||
'click_x': session.get('click_x'),
|
||||
'click_y': session.get('click_y'),
|
||||
'list_ocr_title': session.get('list_ocr_title', ''),
|
||||
})
|
||||
payload = {
|
||||
'round': int(round_count),
|
||||
'contact_rect': contact_rect,
|
||||
'total_sessions': len(sessions),
|
||||
'unread_count': len(unread_sessions),
|
||||
'unread_rows': [s.get('row_idx') for s in unread_sessions],
|
||||
'rows': rows,
|
||||
}
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# 保存调试图片的内部方法
|
||||
def _save_debug_image(self, image_obj, filename):
|
||||
if not self.save_debug_image:
|
||||
return
|
||||
self.save_debug_image(image_obj, filename)
|
||||
@@ -0,0 +1,258 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from app.infrastructure.service.logging.log_service import log_event, new_trace_id
|
||||
from app.infrastructure.service.wechat.config import CONTACT_ROW_HEIGHT
|
||||
|
||||
|
||||
|
||||
class UnreadSessionAnalyzer:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def detect_red_dots(self, contact_rect: dict, screenshot) -> list[dict]:
|
||||
trace_id = new_trace_id("bot")
|
||||
try:
|
||||
img_np = np.array(screenshot)
|
||||
hsv = cv2.cvtColor(cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR), cv2.COLOR_BGR2HSV)
|
||||
mask = cv2.inRange(hsv, np.array([0, 80, 80]), np.array([12, 255, 255])) + cv2.inRange(hsv, np.array([168, 80, 80]), np.array([180, 255, 255]))
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
contact_width = contact_rect['right'] - contact_rect['left']
|
||||
red_dots_raw = []
|
||||
for contour in contours:
|
||||
area = cv2.contourArea(contour)
|
||||
if 12 < area < 220:
|
||||
perimeter = cv2.arcLength(contour, True)
|
||||
if perimeter <= 0:
|
||||
continue
|
||||
circularity = 4 * np.pi * area / (perimeter * perimeter)
|
||||
if circularity <= 0.45:
|
||||
continue
|
||||
moments = cv2.moments(contour)
|
||||
if moments['m00'] == 0:
|
||||
continue
|
||||
cx = int(moments['m10'] / moments['m00'])
|
||||
cy = int(moments['m01'] / moments['m00'])
|
||||
if cx > contact_width * 0.1:
|
||||
red_dots_raw.append({'x': contact_rect['left'] + cx, 'y': contact_rect['top'] + cy, 'rel_y': cy})
|
||||
|
||||
snapped_map = {}
|
||||
for dot in red_dots_raw:
|
||||
row_idx = int(round((dot['y'] - contact_rect['top']) / max(1, CONTACT_ROW_HEIGHT)))
|
||||
snapped_y = int(contact_rect['top'] + row_idx * CONTACT_ROW_HEIGHT + CONTACT_ROW_HEIGHT // 2)
|
||||
if row_idx not in snapped_map:
|
||||
snapped_map[row_idx] = {'x': dot['x'], 'y': snapped_y, 'row_idx': row_idx}
|
||||
|
||||
red_dots_final = sorted(snapped_map.values(), key=lambda d: d['y'])
|
||||
log_event("INFO", "bot", "bot.unread.detect", trace_id, "detect", "ok", "红点检测完成", extra={"dot_count": len(red_dots_final)})
|
||||
return red_dots_final
|
||||
except Exception as e:
|
||||
log_event("ERROR", "bot", "bot.unread.detect", trace_id, "detect", "failed", "红点检测异常", reason="detect_error", extra={"error": str(e)})
|
||||
return []
|
||||
|
||||
def row_has_red_dot(self, row_img, relaxed: bool = False) -> bool:
|
||||
try:
|
||||
row_np = np.array(row_img)
|
||||
h, w = row_np.shape[:2]
|
||||
if h < 30 or w < 100:
|
||||
return False
|
||||
|
||||
margin_left = max(6, int(w * 0.012))
|
||||
avatar_size = int(h * 0.72)
|
||||
avatar_y = (h - avatar_size) // 2
|
||||
avatar_x = margin_left
|
||||
|
||||
avatar_cx = avatar_x + avatar_size / 2.0
|
||||
avatar_cy = avatar_y + avatar_size / 2.0
|
||||
avatar_r = avatar_size * 0.50
|
||||
|
||||
probe_x1 = avatar_x + int(avatar_size * 0.42)
|
||||
probe_y1 = max(0, avatar_y - int(avatar_size * 0.10))
|
||||
probe_x2 = min(w, avatar_x + int(avatar_size * 1.00))
|
||||
probe_y2 = min(h, avatar_y + int(avatar_size * 0.36))
|
||||
|
||||
if probe_x2 <= probe_x1 or probe_y2 <= probe_y1:
|
||||
return False
|
||||
|
||||
probe = row_np[probe_y1:probe_y2, probe_x1:probe_x2]
|
||||
if probe.size == 0:
|
||||
return False
|
||||
|
||||
probe_hsv = cv2.cvtColor(probe, cv2.COLOR_RGB2HSV)
|
||||
mask1 = cv2.inRange(probe_hsv, np.array([0, 115, 125]), np.array([12, 255, 255]))
|
||||
mask2 = cv2.inRange(probe_hsv, np.array([168, 115, 125]), np.array([180, 255, 255]))
|
||||
mask = cv2.bitwise_or(mask1, mask2)
|
||||
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
|
||||
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
if not contours:
|
||||
self.debug_log(f"row_red w={w} h={h} candidates=0")
|
||||
return False
|
||||
|
||||
candidates = []
|
||||
pw = probe_x2 - probe_x1
|
||||
ph = probe_y2 - probe_y1
|
||||
|
||||
for cnt in contours:
|
||||
area = cv2.contourArea(cnt)
|
||||
if not (8 <= area <= 260):
|
||||
continue
|
||||
|
||||
x, y, cw, ch = cv2.boundingRect(cnt)
|
||||
peri = cv2.arcLength(cnt, True)
|
||||
if peri <= 0:
|
||||
continue
|
||||
|
||||
circ = 4 * np.pi * area / (peri * peri)
|
||||
ar = max(cw, ch) / max(1, min(cw, ch))
|
||||
|
||||
cx = x + cw / 2.0
|
||||
cy = y + ch / 2.0
|
||||
gx = probe_x1 + cx
|
||||
gy = probe_y1 + cy
|
||||
|
||||
in_upper_right = cx > pw * 0.30 and cx < pw * 0.90 and cy < ph * 0.68
|
||||
near_avatar_corner = (
|
||||
gx >= avatar_x + avatar_size * 0.66 and
|
||||
gx <= avatar_x + avatar_size * 1.00 and
|
||||
gy >= avatar_y - avatar_size * 0.06 and
|
||||
gy <= avatar_y + avatar_size * 0.24
|
||||
)
|
||||
if not (in_upper_right and near_avatar_corner):
|
||||
continue
|
||||
|
||||
comp_mask = np.zeros(mask.shape, dtype=np.uint8)
|
||||
cv2.drawContours(comp_mask, [cnt], -1, 255, thickness=-1)
|
||||
ys, xs = np.where(comp_mask > 0)
|
||||
if len(xs) == 0:
|
||||
continue
|
||||
|
||||
global_xs = xs + probe_x1
|
||||
global_ys = ys + probe_y1
|
||||
d2 = (global_xs - avatar_cx) ** 2 + (global_ys - avatar_cy) ** 2
|
||||
outside_ratio = float(np.count_nonzero(d2 > (avatar_r * 0.92) ** 2)) / len(d2)
|
||||
|
||||
min_area = 10 if relaxed else 14
|
||||
min_small_outside = 0.18 if relaxed else 0.25
|
||||
min_small_circ = 0.72 if relaxed else 0.82
|
||||
min_match_score = 7 if relaxed else 8
|
||||
min_match_outside = 0.10 if relaxed else 0.15
|
||||
|
||||
if area < min_area:
|
||||
continue
|
||||
if area < 20 and outside_ratio < min_small_outside:
|
||||
continue
|
||||
if gy > avatar_y + avatar_size * 0.24:
|
||||
continue
|
||||
if area < 20 and circ < min_small_circ:
|
||||
continue
|
||||
if area < 20 and ar > 1.20:
|
||||
continue
|
||||
|
||||
if area >= 120:
|
||||
shape_ok = circ > 0.26 and ar < 2.6
|
||||
elif area >= 28:
|
||||
shape_ok = circ > 0.45 and ar < 1.9
|
||||
else:
|
||||
shape_ok = circ > 0.82 and ar <= 1.20 and outside_ratio >= 0.25
|
||||
if not shape_ok:
|
||||
continue
|
||||
|
||||
white_ratio = 0.0
|
||||
if cw >= 7 and ch >= 7:
|
||||
inner = probe[max(0, y):min(probe.shape[0], y + ch), max(0, x):min(probe.shape[1], x + cw)]
|
||||
if inner.size > 0:
|
||||
gray = cv2.cvtColor(inner, cv2.COLOR_RGB2GRAY)
|
||||
white_ratio = np.count_nonzero(gray > 190) / gray.size
|
||||
|
||||
score = 3
|
||||
if area >= 14:
|
||||
score += 2
|
||||
if circ > 0.85:
|
||||
score += 2
|
||||
elif circ > 0.70:
|
||||
score += 1
|
||||
if ar <= 1.15:
|
||||
score += 2
|
||||
elif ar <= 1.35:
|
||||
score += 1
|
||||
if outside_ratio >= 0.35:
|
||||
score += 4
|
||||
elif outside_ratio >= 0.25:
|
||||
score += 3
|
||||
elif outside_ratio >= 0.15:
|
||||
score += 1
|
||||
if 0.05 <= white_ratio <= 0.60:
|
||||
score += 1
|
||||
|
||||
candidates.append({
|
||||
'score': score,
|
||||
'area': area,
|
||||
'circ': circ,
|
||||
'ar': ar,
|
||||
'outside_ratio': outside_ratio,
|
||||
'white_ratio': white_ratio,
|
||||
'center': (gx, gy),
|
||||
'bbox': (probe_x1 + x, probe_y1 + y, cw, ch),
|
||||
'min_match_score': min_match_score,
|
||||
'min_match_outside': min_match_outside,
|
||||
})
|
||||
|
||||
if not candidates:
|
||||
return False
|
||||
|
||||
best = max(candidates, key=lambda x: x['score'])
|
||||
matched = best['score'] >= best['min_match_score'] and best['outside_ratio'] >= best['min_match_outside']
|
||||
return matched
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
def row_has_red_dot_weak(self, row_img) -> bool:
|
||||
return self.row_has_red_dot(row_img, relaxed=True)
|
||||
|
||||
def get_all_sessions_with_unread(self, contact_rect: dict, screenshot, round_count: int, save_debug_image: Callable | None = None) -> tuple[list[dict], list[dict]]:
|
||||
trace_id = new_trace_id("bot")
|
||||
red_dots = self.detect_red_dots(contact_rect, screenshot)
|
||||
red_y_list = [dot['y'] for dot in red_dots]
|
||||
row_count = max(1, int((contact_rect['bottom'] - contact_rect['top']) / max(1, CONTACT_ROW_HEIGHT)))
|
||||
sessions = []
|
||||
|
||||
for row_idx in range(row_count):
|
||||
top = int(row_idx * CONTACT_ROW_HEIGHT)
|
||||
bottom = int(min((row_idx + 1) * CONTACT_ROW_HEIGHT, screenshot.height))
|
||||
if bottom <= top:
|
||||
continue
|
||||
row_img = screenshot.crop((0, top, screenshot.width, bottom))
|
||||
center_y = int(contact_rect['top'] + row_idx * CONTACT_ROW_HEIGHT + CONTACT_ROW_HEIGHT // 2)
|
||||
has_red_by_global = any(abs(center_y - y) <= max(7, CONTACT_ROW_HEIGHT // 4) for y in red_y_list)
|
||||
has_red_by_row = self.row_has_red_dot(row_img)
|
||||
has_red_by_row_weak = self.row_has_red_dot_weak(row_img) if has_red_by_global and not has_red_by_row else has_red_by_row
|
||||
has_red = has_red_by_row or (has_red_by_global and has_red_by_row_weak)
|
||||
row_name = f"round_{round_count:04d}_row_{row_idx:03d}.png"
|
||||
if save_debug_image:
|
||||
save_debug_image(row_img, f"sessions/all/{row_name}")
|
||||
if has_red:
|
||||
save_debug_image(row_img, f"sessions/unread/{row_name}")
|
||||
sessions.append({
|
||||
'row_idx': row_idx,
|
||||
'has_red_dot': has_red,
|
||||
'has_red_by_global': has_red_by_global,
|
||||
'has_red_by_row': has_red_by_row,
|
||||
'has_red_by_row_weak': has_red_by_row_weak,
|
||||
'click_x': int((contact_rect['left'] + contact_rect['right']) // 2),
|
||||
'click_y': center_y,
|
||||
'row_img': row_img.copy(),
|
||||
})
|
||||
|
||||
unread_sessions = [s for s in sessions if s['has_red_dot']]
|
||||
global_hits = sum(1 for s in sessions if s['has_red_by_global'])
|
||||
row_hits = sum(1 for s in sessions if s['has_red_by_row'])
|
||||
row_weak_hits = sum(1 for s in sessions if s['has_red_by_row_weak'])
|
||||
log_event("INFO", "bot", "bot.unread.scan", trace_id, "scan", "ok", "未读会话扫描完成", extra={"round": int(round_count), "rows": len(sessions), "unread": len(unread_sessions), "global_hits": global_hits, "row_hits": row_hits, "row_weak_hits": row_weak_hits})
|
||||
return sessions, unread_sessions
|
||||
@@ -0,0 +1,504 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
import ctypes # Windows窗口句柄相关API
|
||||
import hashlib # 文本去重哈希
|
||||
import logging # 日志模块
|
||||
import os # 文件目录操作
|
||||
import re # 正则过滤文本
|
||||
import time # 休眠/节流
|
||||
from datetime import datetime # 时间戳命名与日志辅助
|
||||
from io import BytesIO # 图片转字节流给OCR
|
||||
|
||||
import cv2 # 图像处理
|
||||
import numpy as np # 数组/图像矩阵
|
||||
import pyautogui # 鼠标键盘自动化
|
||||
import pyperclip # 剪贴板粘贴发送文本
|
||||
import requests # 调用后端接口
|
||||
import uiautomation as auto # Windows UI 自动化
|
||||
from PIL import ImageGrab, Image # 截图与图像对象
|
||||
|
||||
from app.infrastructure.service.wechat.config import BAIDU_API_KEY, BAIDU_SECRET_KEY, BACKEND_URL, LOOP_INTERVAL, NO_REPLY_KEYWORDS, BLOCKED_SESSION_KEYWORDS, UI_NOISE_KEYWORDS, BOT_DEBUG_LOG, WECHAT_WINDOW_TARGET_WIDTH, WECHAT_WINDOW_TARGET_HEIGHT, WECHAT_WINDOW_TARGET_LEFT, WECHAT_WINDOW_TARGET_TOP, OCR_SAVE_IMAGES, OCR_SAVE_DIR # 机器人配置
|
||||
from app.infrastructure.service.wechat.ocr import BaiduOCR # 百度OCR封装
|
||||
|
||||
logging.basicConfig( # 配置全局日志
|
||||
level=logging.INFO, # 默认输出INFO及以上
|
||||
format="%(asctime)s - %(levelname)s - %(message)s", # 日志格式
|
||||
handlers=[
|
||||
logging.FileHandler("wechat_multi_chat_bot.log", encoding="utf-8"), # 写文件
|
||||
logging.StreamHandler() # 打终端
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger(__name__) # 当前模块日志器
|
||||
|
||||
|
||||
def debug_log(message):
|
||||
if BOT_DEBUG_LOG: # 仅在调试开关开启时输出
|
||||
logger.info(f"[DEBUG] {message}")
|
||||
|
||||
|
||||
class WechatMultiChatBot:
|
||||
"""微信自动回复机器人(UI自动化 + OCR + 规则/AI回复)。"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化OCR、微信窗口句柄和运行期状态。"""
|
||||
logger.info("[BOT] 初始化开始") # 生命周期日志
|
||||
self.ocr = BaiduOCR(BAIDU_API_KEY, BAIDU_SECRET_KEY) # OCR客户端
|
||||
self.wechat_window = None # 微信窗口对象
|
||||
self.find_wechat_window() # 启动即查找窗口
|
||||
self.seen_messages = {} # 去重缓存:contact_key -> hash set
|
||||
self.processed_contacts = set() # 预留:已处理联系人集合
|
||||
self.running = False # 主循环运行状态
|
||||
logger.info("[BOT] 初始化完成")
|
||||
|
||||
def find_wechat_window(self):
|
||||
"""查找微信主窗口;成功后执行窗口标准化。"""
|
||||
logger.info("[WIN] 正在查找微信窗口")
|
||||
self.wechat_window = auto.WindowControl(searchDepth=1, Name='微信') # 按窗口名查找
|
||||
if self.wechat_window.Exists(0, 0): # 立即探测是否存在
|
||||
logger.info("[WIN] 找到微信窗口")
|
||||
self.normalize_wechat_window() # 找到后标准化尺寸
|
||||
return
|
||||
logger.error("[WIN] 未找到微信窗口")
|
||||
raise Exception("未找到微信窗口")
|
||||
|
||||
def normalize_wechat_window(self):
|
||||
"""将微信窗口调整到预设位置和尺寸,保证识别区域稳定。"""
|
||||
logger.info("[WIN] 开始标准化窗口大小和位置")
|
||||
try:
|
||||
self.wechat_window.SetActive() # 激活窗口
|
||||
time.sleep(0.25) # 等待激活稳定
|
||||
|
||||
hwnd = getattr(self.wechat_window, "NativeWindowHandle", 0) # 尝试取窗口句柄
|
||||
if hwnd:
|
||||
user32 = ctypes.windll.user32 # Win32接口
|
||||
SWP_NOZORDER = 0x0004 # 不改变Z序
|
||||
SWP_NOACTIVATE = 0x0010 # 不抢前台焦点
|
||||
user32.SetWindowPos(
|
||||
int(hwnd), # 目标窗口句柄
|
||||
0, # hWndInsertAfter
|
||||
int(WECHAT_WINDOW_TARGET_LEFT), # 目标左上x
|
||||
int(WECHAT_WINDOW_TARGET_TOP), # 目标左上y
|
||||
int(WECHAT_WINDOW_TARGET_WIDTH), # 目标宽度
|
||||
int(WECHAT_WINDOW_TARGET_HEIGHT), # 目标高度
|
||||
SWP_NOZORDER | SWP_NOACTIVATE, # 标志位
|
||||
)
|
||||
logger.info(f"[WIN] 句柄模式窗口标准化成功 hwnd={hwnd}")
|
||||
else:
|
||||
if hasattr(self.wechat_window, "MoveTo") and hasattr(self.wechat_window, "Resize"): # 兼容旧接口
|
||||
self.wechat_window.MoveTo(WECHAT_WINDOW_TARGET_LEFT, WECHAT_WINDOW_TARGET_TOP) # 移动窗口
|
||||
self.wechat_window.Resize(WECHAT_WINDOW_TARGET_WIDTH, WECHAT_WINDOW_TARGET_HEIGHT) # 调整尺寸
|
||||
logger.info("[WIN] MoveTo/Resize 模式窗口标准化成功")
|
||||
else:
|
||||
raise Exception("未获取到窗口句柄且不支持 MoveTo/Resize")
|
||||
|
||||
time.sleep(0.35) # 等待窗口重排完成
|
||||
logger.info(f"[WIN] 标准化参数 left={WECHAT_WINDOW_TARGET_LEFT} top={WECHAT_WINDOW_TARGET_TOP} width={WECHAT_WINDOW_TARGET_WIDTH} height={WECHAT_WINDOW_TARGET_HEIGHT}")
|
||||
debug_log(f"window_normalized=({WECHAT_WINDOW_TARGET_LEFT},{WECHAT_WINDOW_TARGET_TOP},{WECHAT_WINDOW_TARGET_WIDTH},{WECHAT_WINDOW_TARGET_HEIGHT})")
|
||||
except Exception as e:
|
||||
logger.warning(f"[WIN] 窗口标准化失败,继续按当前窗口运行: {e}")
|
||||
|
||||
def get_window_rect(self):
|
||||
try:
|
||||
rect = self.wechat_window.BoundingRectangle # UIA矩形
|
||||
result = {'left': rect.left, 'top': rect.top, 'right': rect.right, 'bottom': rect.bottom, 'width': rect.right - rect.left, 'height': rect.bottom - rect.top} # 转字典
|
||||
debug_log(f"window_rect={result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"[WIN] 获取窗口位置失败: {e}")
|
||||
return None
|
||||
|
||||
def get_contact_list_rect(self, window_rect):
|
||||
rect = {'left': window_rect['left'] + 10, 'top': window_rect['top'] + 50, 'right': window_rect['left'] + int(window_rect['width'] * 0.25) - 10, 'bottom': window_rect['bottom'] - 50} # 左侧会话列表区域
|
||||
debug_log(f"contact_rect={rect}")
|
||||
return rect
|
||||
|
||||
def detect_red_dots(self, window_rect):
|
||||
logger.info("[DETECT] 开始检测红点")
|
||||
contact_rect = self.get_contact_list_rect(window_rect) # 获取列表区域
|
||||
try:
|
||||
screenshot = ImageGrab.grab(bbox=(contact_rect['left'], contact_rect['top'], contact_rect['right'], contact_rect['bottom'])) # 列表截图
|
||||
img_np = np.array(screenshot) # PIL -> numpy
|
||||
img_bgr = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR) # RGB -> BGR
|
||||
hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) # BGR -> HSV
|
||||
mask = cv2.inRange(hsv, np.array([0, 80, 80]), np.array([12, 255, 255])) + cv2.inRange(hsv, np.array([168, 80, 80]), np.array([180, 255, 255])) # 红色双区间
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 查找连通域
|
||||
contact_width = contact_rect['right'] - contact_rect['left'] # 列表宽度
|
||||
red_dots_raw = [] # 原始红点
|
||||
for contour in contours:
|
||||
area = cv2.contourArea(contour) # 面积过滤
|
||||
if 12 < area < 220:
|
||||
perimeter = cv2.arcLength(contour, True) # 周长
|
||||
if perimeter > 0:
|
||||
circularity = 4 * np.pi * area / (perimeter * perimeter) # 圆度
|
||||
if circularity > 0.45:
|
||||
M = cv2.moments(contour) # 矩中心
|
||||
if M["m00"] != 0:
|
||||
cx = int(M["m10"] / M["m00"]) # 中心x
|
||||
cy = int(M["m01"] / M["m00"]) # 中心y
|
||||
if cx > contact_width * 0.1: # 过滤左边缘噪声
|
||||
red_dots_raw.append({'x': contact_rect['left'] + cx, 'y': contact_rect['top'] + cy, 'rel_y': cy}) # 记录绝对坐标
|
||||
red_dots_grouped = [] # 按行合并后的红点
|
||||
debug_log(f"red_dots_raw={len(red_dots_raw)}")
|
||||
used = set() # 已分组索引
|
||||
for i, dot in enumerate(red_dots_raw):
|
||||
if i in used:
|
||||
continue
|
||||
group = [dot] # 当前分组
|
||||
for j, other in enumerate(red_dots_raw):
|
||||
if j != i and j not in used and abs(dot['rel_y'] - other['rel_y']) < 50: # 同一行
|
||||
group.append(other)
|
||||
used.add(j)
|
||||
red_dots_grouped.append({'x': sum(d['x'] for d in group) // len(group), 'y': sum(d['y'] for d in group) // len(group)}) # 行中心
|
||||
used.add(i)
|
||||
logger.info(f"[DETECT] 红点检测完成 grouped={len(red_dots_grouped)}")
|
||||
debug_log(f"red_dots_grouped={len(red_dots_grouped)}")
|
||||
return red_dots_grouped
|
||||
except Exception as e:
|
||||
logger.error(f"[DETECT] 检测红点失败: {e}")
|
||||
return []
|
||||
|
||||
def click_contact_by_red_dot(self, red_dot, window_rect):
|
||||
contact_rect = self.get_contact_list_rect(window_rect) # 计算左侧联系人列表区域
|
||||
click_x = (contact_rect['left'] + contact_rect['right']) // 2 # 点击联系人列表中线,避免点到红点本身
|
||||
click_y = red_dot['y'] # 使用红点纵坐标对应会话行
|
||||
logger.info(f"[ACTION] 点击联系人 x={click_x} y={click_y}") # 记录点击行为
|
||||
pyautogui.click(click_x, click_y) # 执行点击
|
||||
time.sleep(1.2) # 等待会话内容加载
|
||||
session_title = self.get_current_session_title() # 读取当前会话标题
|
||||
logger.info(f"[ACTION] 当前会话标题 title={session_title or '未知'}") # 输出标题用于排查
|
||||
time.sleep(1.0) # 再等待短时间,保证后续OCR稳定
|
||||
|
||||
def get_session_title_by_ocr(self):
|
||||
try:
|
||||
window_rect = self.get_window_rect() # 获取微信窗口坐标
|
||||
if not window_rect:
|
||||
return "" # 窗口坐标不可用
|
||||
|
||||
title_areas = [ # 多个标题区域兜底,适配不同微信版本/缩放
|
||||
(
|
||||
window_rect['left'] + int(window_rect['width'] * 0.32),
|
||||
window_rect['top'] + 6,
|
||||
window_rect['right'] - int(window_rect['width'] * 0.30),
|
||||
window_rect['top'] + max(40, int(window_rect['height'] * 0.085)),
|
||||
),
|
||||
(
|
||||
window_rect['left'] + int(window_rect['width'] * 0.28),
|
||||
window_rect['top'] + 4,
|
||||
window_rect['right'] - int(window_rect['width'] * 0.24),
|
||||
window_rect['top'] + max(46, int(window_rect['height'] * 0.095)),
|
||||
),
|
||||
]
|
||||
|
||||
for idx, (left, top, right, bottom) in enumerate(title_areas, 1): # 逐区域尝试
|
||||
screenshot = ImageGrab.grab(bbox=(left, top, right, bottom)) # 截标题图
|
||||
self.save_ocr_debug_image(screenshot, f"title_ocr_{idx}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.png") # 落盘调试
|
||||
img_bytes = BytesIO() # 内存字节流
|
||||
screenshot.save(img_bytes, format='PNG') # 转png字节
|
||||
lines = self.ocr.recognize(img_bytes.getvalue()) # OCR识别
|
||||
valid = [x.strip() for x in lines if x and x.strip() and len(x.strip()) >= 2] # 清洗文本
|
||||
valid = [x for x in valid if x not in UI_NOISE_KEYWORDS] # 去噪
|
||||
if valid:
|
||||
title = valid[0] # 取首个有效标题
|
||||
debug_log(f"session_title_ocr={title} area={idx} lines={valid[:5]}")
|
||||
return title
|
||||
|
||||
return "" # 全部区域失败
|
||||
except Exception as e:
|
||||
debug_log(f"session_title_ocr_error={e}")
|
||||
return "" # OCR异常返回空
|
||||
|
||||
def get_current_session_title(self):
|
||||
try:
|
||||
title_ctrl = self.wechat_window.TextControl(foundIndex=1) # 优先UIA读标题
|
||||
if hasattr(title_ctrl, "Exists") and not title_ctrl.Exists(0.3, 0.05): # 快速存在性检查
|
||||
debug_log("session_title_not_found_fast")
|
||||
title = self.get_session_title_by_ocr() # UIA失败转OCR
|
||||
return title
|
||||
title = (getattr(title_ctrl, "Name", "") or "").strip() # 读取控件Name
|
||||
if not title or title in UI_NOISE_KEYWORDS: # 噪声/空值
|
||||
title = self.get_session_title_by_ocr() # OCR兜底
|
||||
debug_log(f"session_title={title}")
|
||||
return title
|
||||
except Exception as e:
|
||||
debug_log(f"session_title_error={e}")
|
||||
return self.get_session_title_by_ocr() # 异常也兜底
|
||||
|
||||
def is_blocked_session(self):
|
||||
title = self.get_current_session_title() # 取会话标题
|
||||
if not title:
|
||||
return False # 标题未知默认不拦截
|
||||
for keyword in BLOCKED_SESSION_KEYWORDS: # 命中过滤词则跳过
|
||||
if keyword in title:
|
||||
logger.info(f"[SESSION] 命中过滤会话 title={title} keyword={keyword}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_latest_message_areas(self, window_rect):
|
||||
"""计算多段消息识别区域(靠近底部,适配不同窗口尺寸)。"""
|
||||
chat_left = window_rect['left'] + int(window_rect['width'] * 0.30) # 聊天区左边界
|
||||
chat_right = window_rect['right'] - 20 # 聊天区右边界
|
||||
chat_top = window_rect['top'] + int(window_rect['height'] * 0.16) # 聊天区上边界
|
||||
chat_bottom = window_rect['bottom'] - int(window_rect['height'] * 0.20) # 聊天区下边界(排除输入区)
|
||||
chat_height = max(420, int((chat_bottom - chat_top) * 0.90)) # 有效聊天高度
|
||||
|
||||
area1_h = max(220, int(chat_height * 0.46)) # 近底部区域
|
||||
area2_h = max(320, int(chat_height * 0.72)) # 中大区域
|
||||
area3_h = max(420, int(chat_height * 0.98)) # 大区域兜底
|
||||
|
||||
areas = [
|
||||
{'left': chat_left, 'top': max(chat_top, chat_bottom - area1_h), 'right': chat_right, 'bottom': chat_bottom}, # 区域1
|
||||
{'left': chat_left, 'top': max(chat_top, chat_bottom - area2_h), 'right': chat_right, 'bottom': chat_bottom - max(35, int(area1_h * 0.12))}, # 区域2
|
||||
{'left': chat_left, 'top': max(chat_top, chat_bottom - area3_h), 'right': chat_right, 'bottom': chat_bottom - max(80, int(area2_h * 0.20))}, # 区域3
|
||||
]
|
||||
debug_log(f"message_areas={areas}")
|
||||
return areas
|
||||
|
||||
def should_reply(self, text):
|
||||
"""基础过滤:过短、无意义、无需回复文本直接跳过。"""
|
||||
if not text or len(text) < 2: # 文本太短不回复
|
||||
return False
|
||||
text_lower = text.lower() # 统一小写比较
|
||||
for keyword in NO_REPLY_KEYWORDS: # 命中免回复词
|
||||
if keyword.lower() in text_lower:
|
||||
return False
|
||||
return not text.isdigit() # 纯数字通常无语义
|
||||
|
||||
def is_new_message(self, text, contact_key):
|
||||
"""消息去重:同一会话内已处理过的文本不重复回复。"""
|
||||
if contact_key not in self.seen_messages:
|
||||
self.seen_messages[contact_key] = set() # 首次初始化会话集合
|
||||
h = hashlib.md5(text.encode("utf-8")).hexdigest() # 文本哈希
|
||||
if h in self.seen_messages[contact_key]:
|
||||
logger.info(f"[FILTER] 重复消息 contact_key={contact_key}")
|
||||
return False
|
||||
self.seen_messages[contact_key].add(h) # 记录已处理
|
||||
if len(self.seen_messages[contact_key]) > 50: # 限制缓存规模
|
||||
self.seen_messages[contact_key] = set(list(self.seen_messages[contact_key])[-50:])
|
||||
return True
|
||||
|
||||
def extract_text_by_uia(self):
|
||||
logger.info("[UIA] 尝试控件树文本提取")
|
||||
lines = [] # 候选文本
|
||||
try:
|
||||
all_text_controls = self.wechat_window.GetChildren() # 取一级子控件
|
||||
for ctrl in all_text_controls:
|
||||
name = (getattr(ctrl, 'Name', '') or '').strip() # 控件文本
|
||||
if not name:
|
||||
continue
|
||||
if len(name) < 2:
|
||||
continue
|
||||
if re.fullmatch(r"[0-9:\-\s]+", name): # 过滤时间/数字
|
||||
continue
|
||||
if name in UI_NOISE_KEYWORDS: # 过滤UI噪声
|
||||
continue
|
||||
lines.append(name) # 保留候选
|
||||
except Exception as e:
|
||||
logger.warning(f"[UIA] 控件树提取失败: {e}")
|
||||
|
||||
dedup = [] # 去重结果
|
||||
seen = set() # 去重集合
|
||||
for x in lines:
|
||||
if x not in seen:
|
||||
dedup.append(x)
|
||||
seen.add(x)
|
||||
|
||||
logger.info(f"[UIA] 控件树提取文本数={len(dedup)}")
|
||||
debug_log(f"uia_lines={dedup[-8:] if dedup else []}")
|
||||
return dedup
|
||||
|
||||
def save_ocr_debug_image(self, image_obj, filename):
|
||||
"""保存OCR调试截图,便于核对截取区域和图像质量。"""
|
||||
if not OCR_SAVE_IMAGES: # 开关关闭则不落盘
|
||||
return
|
||||
try:
|
||||
os.makedirs(OCR_SAVE_DIR, exist_ok=True) # 创建目录
|
||||
file_path = os.path.join(OCR_SAVE_DIR, filename) # 文件路径
|
||||
image_obj.save(file_path) # 保存图片
|
||||
debug_log(f"saved_ocr_image={file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[OCR] 保存调试截图失败: {e}")
|
||||
|
||||
def get_ai_reply(self, message):
|
||||
"""调用后端接口获取最终回复(规则优先,AI兜底)。"""
|
||||
logger.info(f"[API] 请求后端生成回复 content={message}")
|
||||
try:
|
||||
resp = requests.post(BACKEND_URL, json={"content": message, "wx_user_id": "multi_chat_bot", "wx_nickname": "用户"}, timeout=10) # 请求后端
|
||||
logger.info(f"[API] 后端响应 status={resp.status_code}")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json() # 解析json
|
||||
if data.get("success") and data.get("should_reply"):
|
||||
reply = (data.get("reply_text") or "").strip() # 取回复文本
|
||||
logger.info(f"[API] 生成回复成功 reply={reply}")
|
||||
return reply
|
||||
logger.info("[API] 后端未返回可发送回复")
|
||||
except Exception as e:
|
||||
logger.error(f"[API] 调用接口失败: {e}")
|
||||
return None
|
||||
|
||||
def send_message(self, text, window_rect):
|
||||
logger.info(f"[SEND] 开始发送 reply={text}")
|
||||
try:
|
||||
self.wechat_window.SetActive() # 激活微信窗口
|
||||
time.sleep(0.3)
|
||||
pyautogui.click(window_rect['left'] + window_rect['width'] // 2, window_rect['bottom'] - 100) # 点击输入框
|
||||
time.sleep(0.3)
|
||||
pyautogui.hotkey('ctrl', 'a') # 全选旧文本
|
||||
pyautogui.press('delete') # 清空输入框
|
||||
pyperclip.copy(text) # 文本放入剪贴板
|
||||
pyautogui.hotkey('ctrl', 'v') # 粘贴内容
|
||||
pyautogui.press('enter') # 回车发送
|
||||
logger.info("[SEND] 发送成功")
|
||||
except Exception as e:
|
||||
logger.error(f"[SEND] 发送失败: {e}")
|
||||
|
||||
def process_current_chat(self, window_rect, contact_key):
|
||||
logger.info(f"[CHAT] 开始处理会话 contact_key={contact_key}")
|
||||
if self.is_blocked_session(): # 系统会话直接跳过
|
||||
logger.info("[CHAT] 当前会话在过滤名单,跳过")
|
||||
return False
|
||||
try:
|
||||
all_lines = [] # OCR/UIA汇总文本
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") # 调试图时间戳
|
||||
for idx, msg_rect in enumerate(self.get_latest_message_areas(window_rect), 1): # 分区识别
|
||||
screenshot = ImageGrab.grab(bbox=(msg_rect['left'], msg_rect['top'], msg_rect['right'], msg_rect['bottom'])) # 区域截图
|
||||
img_np = np.array(screenshot) # 转numpy
|
||||
|
||||
raw_pil = Image.fromarray(img_np) # 原图
|
||||
self.save_ocr_debug_image(raw_pil, f"{timestamp}_{contact_key}_area{idx}_raw.png") # 保存原图
|
||||
raw_bytes = BytesIO() # 原图字节流
|
||||
raw_pil.save(raw_bytes, format='PNG')
|
||||
lines_raw = self.ocr.recognize(raw_bytes.getvalue()) # 原图OCR
|
||||
|
||||
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) # 灰度化
|
||||
enhanced = cv2.convertScaleAbs(gray, alpha=1.35, beta=8) # 提升对比度
|
||||
_, binary = cv2.threshold(enhanced, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 二值化
|
||||
pil_bin = Image.fromarray(binary) # 二值图
|
||||
self.save_ocr_debug_image(pil_bin, f"{timestamp}_{contact_key}_area{idx}_bin.png") # 保存二值图
|
||||
bin_bytes = BytesIO() # 二值图字节流
|
||||
pil_bin.save(bin_bytes, format='PNG')
|
||||
lines_bin = self.ocr.recognize(bin_bytes.getvalue()) # 二值图OCR
|
||||
|
||||
lines = lines_raw + lines_bin # 合并两路识别
|
||||
logger.info(f"[OCR] area={idx} raw={len(lines_raw)} bin={len(lines_bin)} total={len(lines)}")
|
||||
debug_log(f"ocr_lines_count={len(lines)} contact_key={contact_key} area={idx}")
|
||||
all_lines.extend(lines) # 汇总
|
||||
|
||||
if not all_lines: # 分区都失败时走整块兜底
|
||||
logger.info("[CHAT] 分区OCR无结果,尝试整块聊天区兜底")
|
||||
chat_left = window_rect['left'] + int(window_rect['width'] * 0.30)
|
||||
chat_right = window_rect['right'] - 20
|
||||
chat_top = window_rect['top'] + int(window_rect['height'] * 0.16)
|
||||
chat_bottom = window_rect['bottom'] - int(window_rect['height'] * 0.20)
|
||||
full_shot = ImageGrab.grab(bbox=(chat_left, chat_top, chat_right, chat_bottom)) # 整块截图
|
||||
full_np = np.array(full_shot)
|
||||
full_gray = cv2.cvtColor(full_np, cv2.COLOR_RGB2GRAY)
|
||||
full_enh = cv2.convertScaleAbs(full_gray, alpha=1.25, beta=6)
|
||||
_, full_bin = cv2.threshold(full_enh, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||
full_pil = Image.fromarray(full_bin)
|
||||
self.save_ocr_debug_image(full_pil, f"{timestamp}_{contact_key}_fallback_full_bin.png") # 保存兜底图
|
||||
full_bytes = BytesIO()
|
||||
full_pil.save(full_bytes, format='PNG')
|
||||
full_lines = self.ocr.recognize(full_bytes.getvalue()) # 兜底OCR
|
||||
logger.info(f"[OCR] fallback_full_chat lines={len(full_lines)}")
|
||||
all_lines.extend(full_lines)
|
||||
|
||||
if not all_lines: # OCR全失败时走UIA
|
||||
logger.info("[CHAT] OCR 无结果,尝试 UIA 文本兜底")
|
||||
all_lines = self.extract_text_by_uia()
|
||||
|
||||
if not all_lines: # UIA也失败
|
||||
logger.info("[CHAT] OCR+UIA 均无结果,结束")
|
||||
return False
|
||||
|
||||
valid_lines = [line.strip() for line in all_lines if len(line.strip()) >= 2] # 过滤无效行
|
||||
if not valid_lines:
|
||||
logger.info("[CHAT] 过滤后无有效文本,结束")
|
||||
return False
|
||||
|
||||
latest = None # 最终待处理消息
|
||||
prioritized = [line for line in reversed(valid_lines) if ("报名" in line or "课程" in line or "价格" in line)] # 规则关键词优先
|
||||
if prioritized:
|
||||
latest = prioritized[0]
|
||||
logger.info(f"[CHAT] 规则关键词优先命中 latest={latest}")
|
||||
else:
|
||||
for line in reversed(valid_lines): # 从最新往前找可回复文本
|
||||
if self.should_reply(line):
|
||||
latest = line
|
||||
break
|
||||
|
||||
if not latest:
|
||||
logger.info("[CHAT] 没有命中可回复文本,结束")
|
||||
debug_log("skip_all_should_reply=false")
|
||||
return False
|
||||
|
||||
logger.info(f"[CHAT] 识别目标消息 latest={latest}")
|
||||
debug_log(f"latest_text={latest}")
|
||||
if not self.is_new_message(latest, contact_key): # 重复消息不再回
|
||||
debug_log("skip_duplicate_message")
|
||||
return False
|
||||
|
||||
reply = self.get_ai_reply(latest) # 请求后端生成回复
|
||||
if reply:
|
||||
debug_log(f"ai_reply={reply}")
|
||||
self.send_message(reply, window_rect) # 发送回复
|
||||
logger.info("[CHAT] 本次会话处理完成(已发送)")
|
||||
return True
|
||||
|
||||
debug_log("no_ai_reply")
|
||||
logger.info("[CHAT] 本次会话处理完成(无可发送回复)")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[CHAT] 处理聊天失败: {e}")
|
||||
return False
|
||||
|
||||
def run_forever(self):
|
||||
"""主循环:红点检测 -> 点击会话 -> 处理消息 -> 间隔轮询。"""
|
||||
logger.info("[LOOP] 监听循环启动")
|
||||
self.running = True # 标记运行中
|
||||
round_count = 0 # 轮次计数
|
||||
while self.running:
|
||||
try:
|
||||
round_count += 1 # 新一轮
|
||||
logger.info(f"[LOOP] 开始第 {round_count} 轮")
|
||||
debug_log(f"round={round_count}")
|
||||
window_rect = self.get_window_rect() # 获取窗口坐标
|
||||
if not window_rect:
|
||||
logger.info("[LOOP] 未取到窗口位置,等待下一轮")
|
||||
time.sleep(LOOP_INTERVAL)
|
||||
continue
|
||||
red_dots = self.detect_red_dots(window_rect) # 红点检测
|
||||
logger.info(f"[LOOP] 本轮红点数={len(red_dots)}")
|
||||
debug_log(f"red_dots_detected={len(red_dots)}")
|
||||
if not red_dots:
|
||||
logger.info("[LOOP] 未检测到红点,走当前会话兜底")
|
||||
debug_log("fallback_current_chat")
|
||||
self.process_current_chat(window_rect, "current_chat_fallback") # 无红点也扫当前会话
|
||||
time.sleep(LOOP_INTERVAL)
|
||||
continue
|
||||
for idx, red_dot in enumerate(red_dots, 1): # 逐个红点处理
|
||||
logger.info(f"[LOOP] 处理红点 {idx}/{len(red_dots)}")
|
||||
self.click_contact_by_red_dot(red_dot, window_rect) # 点击会话
|
||||
contact_key = f"{red_dot['x']}_{red_dot['y']}" # 联系人key
|
||||
self.process_current_chat(window_rect, contact_key) # 处理会话
|
||||
time.sleep(1) # 短暂节流
|
||||
logger.info(f"[LOOP] 第 {round_count} 轮结束,休眠 {LOOP_INTERVAL} 秒")
|
||||
time.sleep(LOOP_INTERVAL)
|
||||
except Exception as e:
|
||||
logger.error(f"[LOOP] 循环出错: {e}")
|
||||
time.sleep(3) # 异常后稍等再继续
|
||||
|
||||
def stop(self):
|
||||
logger.info("[LOOP] 收到停止信号")
|
||||
self.running = False # 置位退出
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
bot = WechatMultiChatBot() # 构造机器人
|
||||
bot.run_forever() # 进入主循环
|
||||
except KeyboardInterrupt:
|
||||
bot.stop() # Ctrl+C优雅停止
|
||||
except Exception as e:
|
||||
print(f"错误: {e}") # 启动异常输出
|
||||
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from io import BytesIO
|
||||
|
||||
import pyautogui
|
||||
import pyperclip
|
||||
import requests
|
||||
from app.infrastructure.service.logging.log_service import log_event, new_trace_id
|
||||
from app.infrastructure.service.wechat.ocr import OCRService
|
||||
from app.infrastructure.service.wechat.screenshot import ScreenshotService
|
||||
from app.infrastructure.service.wechat.session_service import WechatSessionService
|
||||
|
||||
from app.infrastructure.service.wechat.config import (
|
||||
LOOP_INTERVAL, LOOP_ERROR_DELAY, CLICK_AFTER_DELAY, TITLE_AFTER_DELAY,
|
||||
CONTACT_SWITCH_DELAY, BOT_LOG_FILE, BOT_SESSION_LIST_LOG_FILE, BOT_SESSION_DETAIL_LOG_FILE,
|
||||
WECHAT_WINDOW_TARGET_WIDTH, WECHAT_WINDOW_TARGET_HEIGHT,
|
||||
WECHAT_WINDOW_TARGET_LEFT, WECHAT_WINDOW_TARGET_TOP,
|
||||
OCR_SAVE_IMAGES, OCR_SAVE_DIR, BLOCKED_ROW_CACHE_FILE,
|
||||
CONTACT_ROW_HEIGHT, CONTACT_ROW_WIDTH,
|
||||
CONTACT_LIST_LEFT_OFFSET, CONTACT_LIST_TOP_OFFSET, CONTACT_LIST_BOTTOM_OFFSET,
|
||||
SESSION_NAME_LEFT_OFFSET, SESSION_NAME_TOP_OFFSET, SESSION_NAME_WIDTH, SESSION_NAME_HEIGHT,
|
||||
CHAT_CAPTURE_LEFT_OFFSET, CHAT_CAPTURE_TOP_OFFSET, CHAT_CAPTURE_WIDTH, CHAT_CAPTURE_HEIGHT,
|
||||
TITLE_OCR_AREA_LEFT_OFFSET, TITLE_OCR_AREA_TOP_OFFSET, TITLE_OCR_AREA_WIDTH, TITLE_OCR_AREA_HEIGHT,
|
||||
)
|
||||
|
||||
|
||||
class WechatMultiChatBot:
|
||||
|
||||
def _sleep_interruptible(self, seconds):
|
||||
end_ts = time.time() + max(0.0, float(seconds or 0))
|
||||
while self.running and time.time() < end_ts:
|
||||
time.sleep(min(0.1, end_ts - time.time()))
|
||||
|
||||
def submit_message(self, session_name, content, confidence="", bubble_side=""):
|
||||
trace_id = new_trace_id("bot")
|
||||
backend_url = (os.getenv("BACKEND_URL") or "").strip()
|
||||
if not backend_url or not content:
|
||||
log_event("WARNING", "bot", "bot.submit", trace_id, "submit", "failed", "消息上报参数不完整", reason="invalid_input")
|
||||
return {"success": False, "should_reply": False, "reply_text": ""}
|
||||
try:
|
||||
payload = {
|
||||
"wx_user_id": session_name or "",
|
||||
"wx_nickname": session_name or "",
|
||||
"content": content,
|
||||
"is_friend_request": 0,
|
||||
"ocr_confidence": confidence,
|
||||
"ocr_bubble_side": bubble_side,
|
||||
}
|
||||
resp = requests.post(backend_url, json=payload, timeout=5)
|
||||
if resp.status_code != 200:
|
||||
log_event("WARNING", "bot", "bot.submit", trace_id, "submit", "failed", "消息上报失败", reason="http_error", extra={"status_code": resp.status_code})
|
||||
return {"success": False, "should_reply": False, "reply_text": ""}
|
||||
data = resp.json() if resp.text else {}
|
||||
if not isinstance(data, dict):
|
||||
log_event("WARNING", "bot", "bot.submit", trace_id, "submit", "failed", "消息上报返回格式异常", reason="invalid_response")
|
||||
return {"success": False, "should_reply": False, "reply_text": ""}
|
||||
result = {
|
||||
"success": bool(data.get("success")),
|
||||
"should_reply": bool(data.get("should_reply")),
|
||||
"reply_text": (data.get("reply_text") or "").strip(),
|
||||
}
|
||||
log_event("INFO", "bot", "bot.submit", trace_id, "submit", "ok", "消息上报成功", extra={"should_reply": result["should_reply"]})
|
||||
return result
|
||||
except Exception as e:
|
||||
log_event("ERROR", "bot", "bot.submit", trace_id, "submit", "failed", "消息上报异常", reason="request_error", extra={"error": str(e)})
|
||||
return {"success": False, "should_reply": False, "reply_text": ""}
|
||||
|
||||
def __init__(self):
|
||||
self.wechat_hwnd = 0
|
||||
self.ocr = OCRService()
|
||||
self.screenshot = ScreenshotService()
|
||||
self.session_service = WechatSessionService(
|
||||
screenshot_service=self.screenshot,
|
||||
ocr_service=self.ocr,
|
||||
save_debug_image=self.save_debug_image,
|
||||
)
|
||||
self.running = False
|
||||
self.blocked_row_cache = self.load_blocked_row_cache()
|
||||
self.init_debug_dirs()
|
||||
self.find_wechat_window()
|
||||
|
||||
def load_blocked_row_cache(self):
|
||||
try:
|
||||
if not os.path.exists(BLOCKED_ROW_CACHE_FILE):
|
||||
return {}
|
||||
with open(BLOCKED_ROW_CACHE_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
filtered = {k: v for k, v in data.items() if isinstance(k, str) and k.startswith("title:")}
|
||||
dropped = len(data) - len(filtered)
|
||||
return filtered
|
||||
except Exception as e:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def save_blocked_row_cache(self):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(BLOCKED_ROW_CACHE_FILE), exist_ok=True)
|
||||
with open(BLOCKED_ROW_CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(self.blocked_row_cache, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def init_debug_dirs(self):
|
||||
if not OCR_SAVE_IMAGES:
|
||||
return
|
||||
try:
|
||||
if os.path.exists(OCR_SAVE_DIR):
|
||||
shutil.rmtree(OCR_SAVE_DIR)
|
||||
os.makedirs(os.path.join(OCR_SAVE_DIR, "sessions", "all"), exist_ok=True)
|
||||
os.makedirs(os.path.join(OCR_SAVE_DIR, "sessions", "unread"), exist_ok=True)
|
||||
os.makedirs(os.path.join(OCR_SAVE_DIR, "sessions", "clicked"), exist_ok=True)
|
||||
os.makedirs(os.path.join(OCR_SAVE_DIR, "sessions", "name_ocr"), exist_ok=True)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def find_wechat_window(self):
|
||||
user32 = ctypes.windll.user32
|
||||
found_hwnd = []
|
||||
EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
|
||||
|
||||
def _callback(hwnd, lparam):
|
||||
if not user32.IsWindowVisible(hwnd):
|
||||
return True
|
||||
length = user32.GetWindowTextLengthW(hwnd)
|
||||
if length <= 0:
|
||||
return True
|
||||
buf = ctypes.create_unicode_buffer(length + 1)
|
||||
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||||
title = (buf.value or "").strip()
|
||||
if "微信" in title:
|
||||
found_hwnd.append(int(hwnd))
|
||||
return False
|
||||
return True
|
||||
|
||||
user32.EnumWindows(EnumWindowsProc(_callback), 0)
|
||||
if found_hwnd:
|
||||
self.wechat_hwnd = found_hwnd[0]
|
||||
self.normalize_wechat_window()
|
||||
return
|
||||
raise Exception("未找到微信窗口")
|
||||
|
||||
def normalize_wechat_window(self):
|
||||
try:
|
||||
user32 = ctypes.windll.user32
|
||||
hwnd = int(self.wechat_hwnd or 0)
|
||||
if not hwnd:
|
||||
return
|
||||
user32.ShowWindow(hwnd, 9)
|
||||
user32.SetForegroundWindow(hwnd)
|
||||
time.sleep(0.25)
|
||||
user32.SetWindowPos(
|
||||
hwnd,
|
||||
0,
|
||||
int(WECHAT_WINDOW_TARGET_LEFT),
|
||||
int(WECHAT_WINDOW_TARGET_TOP),
|
||||
int(WECHAT_WINDOW_TARGET_WIDTH),
|
||||
int(WECHAT_WINDOW_TARGET_HEIGHT),
|
||||
0x0004 | 0x0010,
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def get_window_rect(self):
|
||||
try:
|
||||
user32 = ctypes.windll.user32
|
||||
hwnd = int(self.wechat_hwnd or 0)
|
||||
if not hwnd:
|
||||
return None
|
||||
client_rect = ctypes.wintypes.RECT()
|
||||
ok = user32.GetClientRect(hwnd, ctypes.byref(client_rect))
|
||||
if not ok:
|
||||
return None
|
||||
top_left = ctypes.wintypes.POINT(0, 0)
|
||||
bottom_right = ctypes.wintypes.POINT(int(client_rect.right), int(client_rect.bottom))
|
||||
if not user32.ClientToScreen(hwnd, ctypes.byref(top_left)):
|
||||
return None
|
||||
if not user32.ClientToScreen(hwnd, ctypes.byref(bottom_right)):
|
||||
return None
|
||||
return {
|
||||
'left': int(top_left.x),
|
||||
'top': int(top_left.y),
|
||||
'right': int(bottom_right.x),
|
||||
'bottom': int(bottom_right.y),
|
||||
'width': int(bottom_right.x - top_left.x),
|
||||
'height': int(bottom_right.y - top_left.y),
|
||||
}
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def save_debug_image(self, image_obj, filename):
|
||||
if not OCR_SAVE_IMAGES:
|
||||
return
|
||||
try:
|
||||
file_path = os.path.join(OCR_SAVE_DIR, filename)
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
image_obj.save(file_path)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def click_unread_session(self, session):
|
||||
self.session_service.reset_session_title_cache()
|
||||
click_x = session.get('click_x', session.get('center_x'))
|
||||
click_y = session.get('click_y', session.get('center_y'))
|
||||
if click_x is None or click_y is None:
|
||||
return
|
||||
pyautogui.click(int(click_x), int(click_y))
|
||||
self._sleep_interruptible(CLICK_AFTER_DELAY)
|
||||
self._sleep_interruptible(TITLE_AFTER_DELAY)
|
||||
|
||||
def send_reply_to_wechat(self, reply_text, expected_session_name=""):
|
||||
text = (reply_text or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
window_rect = self.get_window_rect()
|
||||
if not window_rect:
|
||||
return False
|
||||
try:
|
||||
if expected_session_name:
|
||||
current_title = self.session_service.get_current_session_title(window_rect)
|
||||
if not self.session_service.is_same_session(expected_session_name, current_title):
|
||||
return False
|
||||
hwnd = int(self.wechat_hwnd or 0)
|
||||
if hwnd:
|
||||
ctypes.windll.user32.ShowWindow(hwnd, 9)
|
||||
ctypes.windll.user32.SetForegroundWindow(hwnd)
|
||||
self._sleep_interruptible(0.2)
|
||||
input_x = int(window_rect["left"] + window_rect["width"] * 0.62)
|
||||
input_y = int(window_rect["bottom"] - 88)
|
||||
pyautogui.click(input_x, input_y)
|
||||
self._sleep_interruptible(0.12)
|
||||
pyautogui.hotkey("ctrl", "a")
|
||||
self._sleep_interruptible(0.06)
|
||||
pyautogui.press("delete")
|
||||
self._sleep_interruptible(0.06)
|
||||
pyperclip.copy(text)
|
||||
self._sleep_interruptible(0.06)
|
||||
pyautogui.hotkey("ctrl", "v")
|
||||
self._sleep_interruptible(0.08)
|
||||
pyautogui.press("enter")
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
def save_current_chat_snapshot(self, window_rect, round_count, row_idx, expected_session_name="", expected_list_title=""):
|
||||
try:
|
||||
analyze_result = self.session_service.analyze_clicked_session(window_rect, round_count, row_idx)
|
||||
if not analyze_result.file_name:
|
||||
return False
|
||||
current_session_name = ""
|
||||
need_verify_current = bool(expected_session_name)
|
||||
if need_verify_current:
|
||||
current_session_name = self.session_service.get_current_session_title(window_rect)
|
||||
if expected_session_name and current_session_name and not self.session_service.is_same_session(expected_session_name, current_session_name):
|
||||
return False
|
||||
session_name = (expected_session_name or expected_list_title or current_session_name or '').strip()
|
||||
if analyze_result.ok:
|
||||
submit_result = self.submit_message(session_name, analyze_result.latest_text, analyze_result.confidence, analyze_result.bubble_side)
|
||||
if submit_result.get("should_reply"):
|
||||
reply_text = (submit_result.get("reply_text") or "").strip()
|
||||
if reply_text:
|
||||
sent_ok = self.send_reply_to_wechat(reply_text, expected_session_name=session_name)
|
||||
if not sent_ok:
|
||||
log_event("WARNING", "bot", "bot.submit", new_trace_id("bot"), "reply", "failed", "自动回复发送失败", reason="send_reply_failed", extra={"session_name": session_name})
|
||||
return True
|
||||
log_event("WARNING", "bot", "bot.chat_analyze", new_trace_id("bot"), "analyze", "failed", "聊天截图未提取到可入库文本", reason="latest_text_empty", extra={"session_name": session_name, "file_name": analyze_result.file_name, "confidence": analyze_result.confidence, "bubble_side": analyze_result.bubble_side})
|
||||
return False
|
||||
except Exception as e:
|
||||
log_event("ERROR", "bot", "bot.chat_analyze", new_trace_id("bot"), "snapshot", "failed", "保存聊天快照失败", reason="snapshot_error", extra={"error": str(e)})
|
||||
return False
|
||||
|
||||
def run_forever(self):
|
||||
trace_id = new_trace_id("bot")
|
||||
self.running = True
|
||||
log_event("INFO", "bot", "bot.loop", trace_id, "start", "ok", "微信机器人主循环启动")
|
||||
round_count = 0
|
||||
while self.running:
|
||||
try:
|
||||
round_count += 1
|
||||
window_rect = self.get_window_rect()
|
||||
if not window_rect:
|
||||
self._sleep_interruptible(LOOP_INTERVAL)
|
||||
continue
|
||||
scan_result = self.session_service.get_all_sessions_with_unread(window_rect, round_count)
|
||||
sessions, unread_sessions = scan_result.sessions, scan_result.unread_sessions
|
||||
if unread_sessions:
|
||||
pass
|
||||
if not unread_sessions:
|
||||
self._sleep_interruptible(LOOP_INTERVAL)
|
||||
continue
|
||||
session = unread_sessions[0]
|
||||
if not self.running:
|
||||
self._sleep_interruptible(LOOP_INTERVAL)
|
||||
continue
|
||||
if self.session_service.should_skip_session_by_ocr(
|
||||
session=session,
|
||||
blocked_row_cache=self.blocked_row_cache,
|
||||
save_blocked_row_cache=self.save_blocked_row_cache,
|
||||
):
|
||||
self._sleep_interruptible(LOOP_INTERVAL)
|
||||
continue
|
||||
expected_list_title = (session.get('list_ocr_title') or '').strip()
|
||||
expected_row_idx = session.get('row_idx')
|
||||
precheck_scan = self.session_service.get_all_sessions_with_unread(window_rect, round_count)
|
||||
precheck_unread = precheck_scan.unread_sessions
|
||||
if not precheck_unread:
|
||||
self._sleep_interruptible(LOOP_INTERVAL)
|
||||
continue
|
||||
precheck_first = precheck_unread[0]
|
||||
self.session_service.should_skip_session_by_ocr(
|
||||
session=precheck_first,
|
||||
blocked_row_cache=self.blocked_row_cache,
|
||||
save_blocked_row_cache=self.save_blocked_row_cache,
|
||||
)
|
||||
current_list_title = (precheck_first.get('list_ocr_title') or '').strip()
|
||||
current_row_idx = precheck_first.get('row_idx')
|
||||
same_list_session = False
|
||||
if expected_list_title and current_list_title:
|
||||
same_list_session = self.session_service.is_same_session(expected_list_title, current_list_title)
|
||||
elif expected_row_idx is not None and current_row_idx is not None:
|
||||
same_list_session = int(expected_row_idx) == int(current_row_idx)
|
||||
if not same_list_session:
|
||||
self._sleep_interruptible(LOOP_INTERVAL)
|
||||
continue
|
||||
session = precheck_first
|
||||
self.click_unread_session(session)
|
||||
if not self.running:
|
||||
self._sleep_interruptible(LOOP_INTERVAL)
|
||||
continue
|
||||
if self.session_service.should_skip_current_session(
|
||||
window_rect=window_rect,
|
||||
session=session,
|
||||
blocked_row_cache=self.blocked_row_cache,
|
||||
save_blocked_row_cache=self.save_blocked_row_cache,
|
||||
):
|
||||
self._sleep_interruptible(LOOP_INTERVAL)
|
||||
continue
|
||||
snapshot_ok = self.save_current_chat_snapshot(
|
||||
window_rect,
|
||||
round_count,
|
||||
session['row_idx'],
|
||||
expected_session_name=expected_list_title,
|
||||
expected_list_title=expected_list_title,
|
||||
)
|
||||
if not snapshot_ok:
|
||||
pass
|
||||
else:
|
||||
self._sleep_interruptible(CONTACT_SWITCH_DELAY)
|
||||
self._sleep_interruptible(LOOP_INTERVAL)
|
||||
except Exception as e:
|
||||
log_event("ERROR", "bot", "bot.loop", trace_id, "loop", "failed", "微信机器人主循环异常", reason="loop_error", extra={"error": str(e), "round": round_count})
|
||||
self._sleep_interruptible(LOOP_ERROR_DELAY)
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
log_event("INFO", "bot", "bot.loop", new_trace_id("bot"), "stop", "ok", "微信机器人主循环停止")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
bot = WechatMultiChatBot()
|
||||
bot.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
bot.stop()
|
||||
except Exception as e:
|
||||
print(f"错误: {e}")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.application.services import AppConfig
|
||||
from app.infrastructure.backend import start_backend
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
VENV_ROOT = PROJECT_ROOT / ".venv"
|
||||
VENV_DIR = VENV_ROOT / "Scripts"
|
||||
VENV_PYTHON = VENV_DIR / "python.exe"
|
||||
|
||||
|
||||
def _resolve(path):
|
||||
try:
|
||||
return Path(path).resolve()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _running_in_project_venv():
|
||||
current_prefix = _resolve(sys.prefix)
|
||||
if current_prefix == _resolve(VENV_ROOT):
|
||||
return True
|
||||
current_executable = _resolve(sys.executable)
|
||||
return current_executable == _resolve(VENV_PYTHON)
|
||||
|
||||
|
||||
def ensure_project_venv():
|
||||
if _running_in_project_venv():
|
||||
return
|
||||
if os.environ.get("OPENCLAW_PROJECT_VENV_REEXEC") == "1":
|
||||
raise RuntimeError("重启后仍未进入项目 .venv,请检查 .venv 是否损坏")
|
||||
if not VENV_PYTHON.exists():
|
||||
raise RuntimeError(f"未找到项目 .venv 解释器: {VENV_PYTHON}")
|
||||
os.environ["OPENCLAW_PROJECT_VENV_REEXEC"] = "1"
|
||||
os.chdir(str(PROJECT_ROOT))
|
||||
os.execv(str(VENV_PYTHON), [str(VENV_PYTHON), str(PROJECT_ROOT / "backend_main.py")])
|
||||
|
||||
|
||||
ensure_project_venv()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
config = AppConfig()
|
||||
start_backend(host=config.host, port=config.port)
|
||||
@@ -0,0 +1 @@
|
||||
function t(n,e,i,s){if(typeof e=="function"?n!==e||!s:!e.has(n))throw new TypeError("Cannot read private member from an object whose class did not declare it");return i==="m"?s:i==="a"?s.call(n):s?s.value:e.get(n)}function c(n,e,i,s,_){if(typeof e=="function"?n!==e||!0:!e.has(n))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(n,i),i}var a,r,o,l,u;const d="__TAURI_TO_IPC_KEY__";function w(n,e=!1){return window.__TAURI_INTERNALS__.transformCallback(n,e)}class g{constructor(e){a.set(this,void 0),r.set(this,0),o.set(this,[]),l.set(this,void 0),c(this,a,e||(()=>{})),this.id=w(i=>{const s=i.index;if("end"in i){s==t(this,r,"f")?this.cleanupCallback():c(this,l,s);return}const _=i.message;if(s==t(this,r,"f")){for(t(this,a,"f").call(this,_),c(this,r,t(this,r,"f")+1);t(this,r,"f")in t(this,o,"f");){const p=t(this,o,"f")[t(this,r,"f")];t(this,a,"f").call(this,p),delete t(this,o,"f")[t(this,r,"f")],c(this,r,t(this,r,"f")+1)}t(this,r,"f")===t(this,l,"f")&&this.cleanupCallback()}else t(this,o,"f")[s]=_})}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(e){c(this,a,e)}get onmessage(){return t(this,a,"f")}[(a=new WeakMap,r=new WeakMap,o=new WeakMap,l=new WeakMap,d)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[d]()}}class f{constructor(e,i,s){this.plugin=e,this.event=i,this.channelId=s}async unregister(){return h(`plugin:${this.plugin}|remove_listener`,{event:this.event,channelId:this.channelId})}}async function m(n,e,i){const s=new g(i);try{return await h(`plugin:${n}|register_listener`,{event:e,handler:s}),new f(n,e,s.id)}catch{return await h(`plugin:${n}|registerListener`,{event:e,handler:s}),new f(n,e,s.id)}}async function I(n){return h(`plugin:${n}|check_permissions`)}async function C(n){return h(`plugin:${n}|request_permissions`)}async function h(n,e={},i){return window.__TAURI_INTERNALS__.invoke(n,e,i)}function T(n,e="asset"){return window.__TAURI_INTERNALS__.convertFileSrc(n,e)}class k{get rid(){return t(this,u,"f")}constructor(e){u.set(this,void 0),c(this,u,e)}async close(){return h("plugin:resources|close",{rid:this.rid})}}u=new WeakMap;function E(){return!!(globalThis||window).isTauri}export{g as Channel,f as PluginListener,k as Resource,d as SERIALIZE_TO_IPC_FN,m as addPluginListener,I as checkPermissions,T as convertFileSrc,h as invoke,E as isTauri,C as requestPermissions,w as transformCallback};
|
||||
@@ -0,0 +1 @@
|
||||
:root{--bg: #f2f4f7;--panel: #ffffff;--line: #e5e7eb;--text: #111827;--muted: #6b7280;--brand: #22c1c3}*{box-sizing:border-box}html,body,#app{height:100%}body{margin:0;background:linear-gradient(180deg,#f5f7fb,#eef2f7);color:var(--text);font-family:Inter,PingFang SC,Microsoft YaHei,sans-serif;overflow:hidden}button,input,select,textarea{font:inherit}.page-shell{height:100vh;overflow:hidden}.content-root{height:calc(100vh - 48px);overflow:hidden;display:flex;flex-direction:column}.content-scroll{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column}.topbar{height:48px;background:#ffffffeb;border-bottom:1px solid var(--line);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);position:sticky;top:0;z-index:20;-webkit-user-select:none;user-select:none}.drag-region{height:100%;width:100%;display:flex;align-items:center;justify-content:space-between;cursor:default}.inner{width:min(1320px,calc(100vw - 24px));margin:0 auto}.window-actions{display:flex;align-items:center;gap:4px}.win-btn{width:32px;height:28px;border-radius:8px;border:1px solid #e7ecf2;background:#fff;color:#4b5563;display:inline-flex;align-items:center;justify-content:center;font-size:14px;line-height:1;cursor:pointer}.win-btn:hover{background:#f5f7fb}.win-btn.close:hover{background:#fee2e2;color:#991b1b;border-color:#fecaca}.panel{background:var(--panel);border:1px solid #edf0f4;border-radius:14px;box-shadow:0 6px 18px #0f172a0a}.hero{background:linear-gradient(120deg,#22c1c326,#7c3aed14);border:1px solid #dff4f5}.tab-wrap{flex:1;min-height:0;display:flex;flex-direction:column;background:#fff;border:1px solid #e8ecf2;border-radius:12px;padding:8px 12px 0;box-shadow:0 6px 14px #0f172a08;overflow:hidden}.app-tabs,.app-tabs .n-tabs-nav-scroll-content,.app-tabs .n-tabs-pane-wrapper,.app-tabs .n-tab-pane,.app-tab-pane{min-height:0}.app-tabs{flex:1;min-height:0;display:flex;flex-direction:column}.app-tabs .n-tabs-nav{flex:0 0 auto}.app-tabs .n-tabs-pane-wrapper{flex:1;min-height:0}.app-tabs .n-tab-pane,.app-tab-pane{height:100%;display:flex;flex-direction:column}.app-tab-pane>*{flex:1;min-height:0}.label-title{font-weight:600;font-size:14px;color:#1f2937}.label-desc{font-size:12px;color:#6b7280;margin-top:2px}.row-line{border-bottom:1px dashed #edf1f5;padding:14px 0}.row-line:last-child{border-bottom:0}.compact-card .n-card-header{padding-bottom:8px!important}.compact-card .n-card__content{padding-top:0!important}.panel-view-shell{flex:1;min-height:0;display:flex;flex-direction:column;gap:16px}.logs-view-shell{flex:1;min-height:0;padding:12px 0}.log-panel,.log-panel .n-card__content{flex:1;min-height:0;height:100%;display:flex;flex-direction:column}.log-panel .n-card-header{flex:0 0 auto}.log-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.log-toolbar-right{display:flex;align-items:center;gap:12px;min-width:0}.log-path{color:#6b7280;font-size:12px;max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.log-stream-wrap{height:clamp(320px,calc(100vh - 320px),680px);min-height:320px;max-height:680px;border:1px solid #e6ebf2;border-radius:12px;background:#0f172a;overflow-y:auto;overflow-x:hidden}.log-stream{margin:0;padding:14px 16px;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;font-family:Consolas,SFMono-Regular,Monaco,monospace}.log-line{white-space:pre-wrap}.log-line--error{color:#fca5a5}.log-line--warning{color:#fde68a}.log-line--info{color:#bfdbfe}.log-line--debug{color:#cbd5e1}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>识流 AI 助手</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script type="module" crossorigin src="/assets/index-C4rM3269.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Avv0D5es.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>识流 AI 助手</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "ai-shiliu-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"backend:build": "scripts\\backend-build.bat",
|
||||
"release:prepare": "npm run build && npm run backend:build",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "npm run release:prepare && tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"naive-ui": "^2.43.1",
|
||||
"vue": "^3.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set "ROOT=%~dp0..\.."
|
||||
for %%I in ("%ROOT%") do set "ROOT=%%~fI"
|
||||
set "PYTHON=%ROOT%\.venv\Scripts\python.exe"
|
||||
|
||||
if not exist "%PYTHON%" (
|
||||
echo Missing .venv\Scripts\python.exe
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
"%PYTHON%" -m pip show rapidocr-onnxruntime >nul 2>nul
|
||||
set "HAS_RAPIDOCR=%ERRORLEVEL%"
|
||||
"%PYTHON%" -m pip show onnxruntime >nul 2>nul
|
||||
set "HAS_ONNXRUNTIME=%ERRORLEVEL%"
|
||||
if not "%HAS_RAPIDOCR%"=="0" goto install_ocr
|
||||
if not "%HAS_ONNXRUNTIME%"=="0" goto install_ocr
|
||||
goto after_install
|
||||
|
||||
:install_ocr
|
||||
"%PYTHON%" -m pip install -r "%ROOT%\requirements.txt"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
:after_install
|
||||
"%PYTHON%" -m PyInstaller --version >nul
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
set "RESOURCES=%ROOT%\frontend\src-tauri\resources"
|
||||
set "BACKEND_OUT=%RESOURCES%\backend"
|
||||
set "BUILD_ROOT=%ROOT%\.build\pyinstaller"
|
||||
set "STALE_ROOT=%ROOT%\.build\stale-resources"
|
||||
|
||||
if exist "%BACKEND_OUT%" (
|
||||
rmdir /s /q "%BACKEND_OUT%" 2>nul
|
||||
if exist "%BACKEND_OUT%" (
|
||||
if not exist "%STALE_ROOT%" mkdir "%STALE_ROOT%"
|
||||
set "STALE_NAME=backend-%DATE:~0,4%%DATE:~5,2%%DATE:~8,2%%TIME:~0,2%%TIME:~3,2%%TIME:~6,2%"
|
||||
set "STALE_NAME=!STALE_NAME: =0!"
|
||||
move "%BACKEND_OUT%" "%STALE_ROOT%\!STALE_NAME!" >nul
|
||||
)
|
||||
)
|
||||
|
||||
if not exist "%RESOURCES%" mkdir "%RESOURCES%"
|
||||
|
||||
"%PYTHON%" -m PyInstaller --noconfirm --clean --onedir --name backend --distpath "%RESOURCES%" --workpath "%BUILD_ROOT%\work" --specpath "%BUILD_ROOT%\spec" --paths "%ROOT%" --collect-all rapidocr_onnxruntime --collect-all onnxruntime --copy-metadata rapidocr-onnxruntime --copy-metadata onnxruntime --add-data "%ROOT%\app;app" "%ROOT%\backend_main.py"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
exit /b 0
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.11.2", features = ["devtools"] }
|
||||
tauri-plugin-log = "2"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,356 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::TcpStream,
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::Mutex,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tauri::{AppHandle, Manager, RunEvent};
|
||||
|
||||
struct AppState {
|
||||
backend_child: Mutex<Option<Child>>,
|
||||
backend_bootstrap_error: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
struct RuntimePaths {
|
||||
app_data_dir: PathBuf,
|
||||
log_root_dir: PathBuf,
|
||||
runtime_log_dir: PathBuf,
|
||||
bot_log_file: PathBuf,
|
||||
ocr_log_file: PathBuf,
|
||||
ocr_save_dir: PathBuf,
|
||||
blocked_row_cache_file: PathBuf,
|
||||
}
|
||||
|
||||
struct BackendLaunchPlan {
|
||||
program: PathBuf,
|
||||
args: Vec<PathBuf>,
|
||||
working_dir: PathBuf,
|
||||
envs: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn project_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf())
|
||||
.expect("project root not found")
|
||||
}
|
||||
|
||||
fn backend_python() -> PathBuf {
|
||||
project_root().join(".venv").join("Scripts").join("python.exe")
|
||||
}
|
||||
|
||||
fn backend_entry() -> PathBuf {
|
||||
project_root().join("backend_main.py")
|
||||
}
|
||||
|
||||
fn normalize_mode(value: &str) -> String {
|
||||
let mode = value.trim().to_ascii_lowercase();
|
||||
match mode.as_str() {
|
||||
"release" | "prod" | "production" | "bundle" | "packaged" => "release".to_string(),
|
||||
_ => "dev".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn backend_mode() -> String {
|
||||
std::env::var("OPENCLAW_BACKEND_MODE")
|
||||
.map(|value| normalize_mode(&value))
|
||||
.unwrap_or_else(|_| {
|
||||
if cfg!(debug_assertions) {
|
||||
"dev".to_string()
|
||||
} else {
|
||||
"release".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn backend_addr() -> String {
|
||||
std::env::var("APP_HOST")
|
||||
.map(|host| {
|
||||
let port = std::env::var("APP_PORT").unwrap_or_else(|_| "5000".to_string());
|
||||
format!("{}:{}", host, port)
|
||||
})
|
||||
.unwrap_or_else(|_| "127.0.0.1:5000".to_string())
|
||||
}
|
||||
|
||||
fn is_backend_alive() -> bool {
|
||||
TcpStream::connect(backend_addr()).is_ok()
|
||||
}
|
||||
|
||||
fn wait_for_backend(child: &mut Child, timeout: Duration) -> Result<(), String> {
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < timeout {
|
||||
if is_backend_alive() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Err(format!("独立后台进程已退出,退出码: {}", status));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
Err(format!("后台启动超时,未监听 {}", backend_addr()))
|
||||
}
|
||||
|
||||
fn ensure_dir(path: &Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(path).map_err(|err| format!("创建目录失败 {}: {}", path.display(), err))
|
||||
}
|
||||
|
||||
fn resolve_runtime_paths(app: &AppHandle) -> Result<RuntimePaths, String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_local_data_dir()
|
||||
.map_err(|err| format!("获取应用数据目录失败: {}", err))?;
|
||||
let log_root_dir = app_data_dir.join("logs");
|
||||
let runtime_log_dir = log_root_dir.join("runtime");
|
||||
let bot_log_file = runtime_log_dir.join("bot").join("wechat_multi_chat_bot.log");
|
||||
let ocr_log_file = runtime_log_dir.join("ocr").join("wechat_ocr.log");
|
||||
let ocr_save_dir = log_root_dir.join("ocr_debug_images");
|
||||
let blocked_row_cache_file = log_root_dir.join("state").join("blocked_rows.json");
|
||||
|
||||
ensure_dir(&app_data_dir)?;
|
||||
ensure_dir(&runtime_log_dir.join("bot"))?;
|
||||
ensure_dir(&runtime_log_dir.join("ocr"))?;
|
||||
ensure_dir(&ocr_save_dir)?;
|
||||
ensure_dir(
|
||||
blocked_row_cache_file
|
||||
.parent()
|
||||
.ok_or_else(|| "状态缓存目录无效".to_string())?,
|
||||
)?;
|
||||
|
||||
Ok(RuntimePaths {
|
||||
app_data_dir,
|
||||
log_root_dir,
|
||||
runtime_log_dir,
|
||||
bot_log_file,
|
||||
ocr_log_file,
|
||||
ocr_save_dir,
|
||||
blocked_row_cache_file,
|
||||
})
|
||||
}
|
||||
|
||||
fn runtime_envs(app: &AppHandle, mode: &str) -> Result<HashMap<String, String>, String> {
|
||||
let paths = resolve_runtime_paths(app)?;
|
||||
let mut envs = HashMap::new();
|
||||
envs.insert("APP_NAME".to_string(), "AiShiliu".to_string());
|
||||
envs.insert("APP_DATA_DIR".to_string(), paths.app_data_dir.display().to_string());
|
||||
envs.insert("LOG_ROOT_DIR".to_string(), paths.log_root_dir.display().to_string());
|
||||
envs.insert(
|
||||
"RUNTIME_LOG_DIR".to_string(),
|
||||
paths.runtime_log_dir.display().to_string(),
|
||||
);
|
||||
envs.insert("BOT_LOG_FILE".to_string(), paths.bot_log_file.display().to_string());
|
||||
envs.insert("OCR_LOG_FILE".to_string(), paths.ocr_log_file.display().to_string());
|
||||
envs.insert("OCR_SAVE_DIR".to_string(), paths.ocr_save_dir.display().to_string());
|
||||
envs.insert(
|
||||
"BLOCKED_ROW_CACHE_FILE".to_string(),
|
||||
paths.blocked_row_cache_file.display().to_string(),
|
||||
);
|
||||
|
||||
envs.insert("OPENCLAW_BACKEND_MODE".to_string(), mode.to_string());
|
||||
envs.insert("OPENCLAW_RUNTIME_MODE".to_string(), mode.to_string());
|
||||
Ok(envs)
|
||||
}
|
||||
|
||||
fn release_backend_candidates(app: &AppHandle) -> Result<Vec<PathBuf>, String> {
|
||||
let resource_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|err| format!("获取资源目录失败: {}", err))?;
|
||||
let exe_dir = std::env::current_exe()
|
||||
.map_err(|err| format!("获取主程序路径失败: {}", err))?
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| "无法解析主程序目录".to_string())?;
|
||||
|
||||
Ok(vec![
|
||||
resource_dir.join("backend").join("backend.exe"),
|
||||
resource_dir.join("backend.exe"),
|
||||
exe_dir.join("resources").join("backend").join("backend.exe"),
|
||||
exe_dir.join("resources").join("backend.exe"),
|
||||
exe_dir.join("backend").join("backend.exe"),
|
||||
exe_dir.join("backend.exe"),
|
||||
])
|
||||
}
|
||||
|
||||
fn release_working_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let resource_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|err| format!("获取资源目录失败: {}", err))?;
|
||||
Ok(resource_dir)
|
||||
}
|
||||
|
||||
fn resolve_backend_launch_plan(app: &AppHandle) -> Result<BackendLaunchPlan, String> {
|
||||
let mode = backend_mode();
|
||||
let envs = runtime_envs(app, &mode)?;
|
||||
|
||||
if mode == "release" {
|
||||
let candidates = release_backend_candidates(app)?;
|
||||
let program = candidates
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.exists())
|
||||
.ok_or_else(|| "未找到发布态后端程序 backend.exe".to_string())?;
|
||||
let working_dir = release_working_dir(app)?;
|
||||
return Ok(BackendLaunchPlan {
|
||||
program,
|
||||
args: Vec::new(),
|
||||
working_dir,
|
||||
envs,
|
||||
});
|
||||
}
|
||||
|
||||
let python = backend_python();
|
||||
if !python.exists() {
|
||||
return Err(format!("未找到后端解释器: {}", python.display()));
|
||||
}
|
||||
let entry = backend_entry();
|
||||
if !entry.exists() {
|
||||
return Err(format!("未找到后端入口: {}", entry.display()));
|
||||
}
|
||||
Ok(BackendLaunchPlan {
|
||||
program: python,
|
||||
args: vec![entry],
|
||||
working_dir: project_root(),
|
||||
envs,
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_backend(app: &AppHandle) -> Result<(), String> {
|
||||
if is_backend_alive() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let plan = resolve_backend_launch_plan(app)?;
|
||||
let mut command = Command::new(&plan.program);
|
||||
command.current_dir(&plan.working_dir);
|
||||
for arg in &plan.args {
|
||||
command.arg(arg);
|
||||
}
|
||||
for (key, value) in &plan.envs {
|
||||
command.env(key, value);
|
||||
}
|
||||
command
|
||||
.env("OPENCLAW_PROJECT_VENV_REEXEC", "1")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|err| format!("启动后台失败: {}", err))?;
|
||||
if let Err(err) = wait_for_backend(&mut child, Duration::from_secs(20)) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(err);
|
||||
}
|
||||
let state = app.state::<AppState>();
|
||||
let mut guard = state
|
||||
.backend_child
|
||||
.lock()
|
||||
.map_err(|_| "后台状态锁定失败".to_string())?;
|
||||
*guard = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ps_single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn cleanup_orphan_backend_processes(app: &AppHandle, mode: &str) {
|
||||
let script = if mode == "release" {
|
||||
let targets = release_backend_candidates(app)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|path| ps_single_quote(&path.display().to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
format!("$targets=@({}); Get-CimInstance Win32_Process | Where-Object {{ $_.ExecutablePath -and ($targets -contains $_.ExecutablePath) }} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}", targets)
|
||||
} else {
|
||||
"Get-CimInstance Win32_Process | Where-Object { $_.Name -ieq 'python.exe' -and $_.CommandLine -match 'backend_main.py' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }".to_string()
|
||||
};
|
||||
let _ = Command::new("powershell")
|
||||
.arg("-NoProfile")
|
||||
.arg("-ExecutionPolicy")
|
||||
.arg("Bypass")
|
||||
.arg("-Command")
|
||||
.arg(script)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
fn terminate_backend(app: &AppHandle) {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
if let Ok(mut guard) = state.backend_child.lock() {
|
||||
if let Some(mut child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanup_orphan_backend_processes(app, &backend_mode());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_devtools(app: AppHandle) -> Result<(), String> {
|
||||
let webview = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "未找到主窗口".to_string())?;
|
||||
webview.open_devtools();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_backend_bootstrap_error(app: AppHandle) -> Result<Option<String>, String> {
|
||||
let state = app.state::<AppState>();
|
||||
let guard = state
|
||||
.backend_bootstrap_error
|
||||
.lock()
|
||||
.map_err(|_| "后台启动错误状态锁定失败".to_string())?;
|
||||
Ok(guard.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restart_backend(app: AppHandle) -> Result<(), String> {
|
||||
terminate_backend(&app);
|
||||
spawn_backend(&app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(AppState {
|
||||
backend_child: Mutex::new(None),
|
||||
backend_bootstrap_error: Mutex::new(None),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![open_devtools, get_backend_bootstrap_error, restart_backend])
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
if let Err(err) = spawn_backend(app.handle()) {
|
||||
if let Ok(mut guard) = app.state::<AppState>().backend_bootstrap_error.lock() {
|
||||
*guard = Some(err.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) {
|
||||
terminate_backend(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "识流 AI 助手",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.shiliu.aiassistant",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": ""
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "识流 AI 助手",
|
||||
"width": 1280,
|
||||
"height": 920,
|
||||
"minWidth": 1100,
|
||||
"minHeight": 760,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": [
|
||||
"resources/**/*"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"android": {
|
||||
"debugApplicationIdSuffix": ".debug"
|
||||
},
|
||||
"windows": {
|
||||
"wix": {
|
||||
"language": "zh-CN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
NConfigProvider,
|
||||
NLayout,
|
||||
NLayoutContent,
|
||||
NLayoutHeader,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
} from 'naive-ui'
|
||||
import AppTopBar from './components/AppTopBar.vue'
|
||||
import { messageColumns } from './constants/messageColumns'
|
||||
import { useBotStatus } from './composables/useBotStatus'
|
||||
import { useMessages } from './composables/useMessages'
|
||||
import { useSettings } from './composables/useSettings'
|
||||
import { useWindowBridge } from './composables/useWindowBridge'
|
||||
import MessagesView from './views/MessagesView.vue'
|
||||
import PanelView from './views/PanelView.vue'
|
||||
import RulesView from './views/RulesView.vue'
|
||||
import LogsView from './views/LogsView.vue'
|
||||
|
||||
|
||||
const message = window.$message
|
||||
const activeTab = ref('panel')
|
||||
const settingsTimer = ref(null)
|
||||
const messagesTimer = ref(null)
|
||||
const rulesViewRef = ref(null)
|
||||
|
||||
const { initWindowBridge, startMove, minimizeWindow, maximizeOrRestore, closeWindow, openDevtools, restartApp } = useWindowBridge()
|
||||
const { settings, loadSettings, saveSettings } = useSettings(message)
|
||||
const { loadingMessages, messages, loadMessages } = useMessages(message)
|
||||
|
||||
const {
|
||||
runtimeText,
|
||||
runtimeTagType,
|
||||
refreshBotStatus,
|
||||
connectBotStatusStream,
|
||||
startStatusHealthCheck,
|
||||
onListenerChange,
|
||||
closeStatusStream,
|
||||
applyBotStatus,
|
||||
} = useBotStatus(settings, () => loadSettings(applyBotStatus), message)
|
||||
|
||||
function clearPageTimers() {
|
||||
if (settingsTimer.value) {
|
||||
clearInterval(settingsTimer.value)
|
||||
settingsTimer.value = null
|
||||
}
|
||||
if (messagesTimer.value) {
|
||||
clearInterval(messagesTimer.value)
|
||||
messagesTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function checkBackendBootstrapError() {
|
||||
if (!window.__TAURI_INTERNALS__) return
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const err = await invoke('get_backend_bootstrap_error')
|
||||
if (err) {
|
||||
message.error(`后端启动失败:${err}`)
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange(value) {
|
||||
activeTab.value = value
|
||||
if (value === 'rules') {
|
||||
rulesViewRef.value?.loadRules?.()
|
||||
}
|
||||
if (value === 'messages') {
|
||||
loadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
initWindowBridge()
|
||||
await checkBackendBootstrapError()
|
||||
await loadSettings(applyBotStatus)
|
||||
await refreshBotStatus().catch(() => {})
|
||||
connectBotStatusStream()
|
||||
startStatusHealthCheck()
|
||||
await loadMessages()
|
||||
settingsTimer.value = setInterval(() => loadSettings(applyBotStatus), 30000)
|
||||
messagesTimer.value = setInterval(() => {
|
||||
if (activeTab.value === 'messages') loadMessages()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPageTimers()
|
||||
closeStatusStream()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :theme-overrides="{
|
||||
common: { primaryColor: '#22c1c3', primaryColorHover: '#1ba8aa', primaryColorPressed: '#169295', borderRadius: '10px' }
|
||||
}">
|
||||
<n-layout class="page-shell">
|
||||
<n-layout-header class="topbar">
|
||||
<AppTopBar
|
||||
:runtime-text="runtimeText"
|
||||
:runtime-tag-type="runtimeTagType"
|
||||
:on-start-move="startMove"
|
||||
:on-minimize="minimizeWindow"
|
||||
:on-maximize-or-restore="maximizeOrRestore"
|
||||
:on-close="closeWindow"
|
||||
:on-open-devtools="openDevtools"
|
||||
:on-restart-app="restartApp"
|
||||
/>
|
||||
</n-layout-header>
|
||||
|
||||
<n-layout-content class="content-root" content-style="padding:16px 0 16px;height:100%;overflow:hidden;">
|
||||
<div class="inner content-scroll">
|
||||
<div class="panel hero p-4 mb-3">
|
||||
<div class="text-[15px] font-semibold text-slate-800 mb-1">管理后台</div>
|
||||
<div class="text-[13px] text-slate-600">组件化管理页面(已移除系统原生窗口栏)。</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-wrap">
|
||||
<n-tabs class="app-tabs" type="segment" animated :value="activeTab" @update:value="onTabChange">
|
||||
<n-tab-pane class="app-tab-pane" name="panel" tab="面板">
|
||||
<PanelView
|
||||
:settings="settings"
|
||||
:on-listener-change="onListenerChange"
|
||||
:on-save-settings="payload => saveSettings(payload, applyBotStatus)"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane class="app-tab-pane" name="rules" tab="回复匹配配置">
|
||||
<RulesView ref="rulesViewRef" :message="message" />
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane class="app-tab-pane" name="logs" tab="运行日志">
|
||||
<LogsView />
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane class="app-tab-pane" name="messages" tab="历史消息">
|
||||
<MessagesView :columns="messageColumns" :messages="messages" :loading="loadingMessages" :on-refresh="loadMessages" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getJson, postForm, postJson } from './http'
|
||||
|
||||
export function getBotStatus() {
|
||||
return getJson('/api/bot/status')
|
||||
}
|
||||
|
||||
export function getLogEvents(params = {}) {
|
||||
const query = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return
|
||||
query.set(key, String(value))
|
||||
})
|
||||
const suffix = query.toString() ? `?${query.toString()}` : ''
|
||||
return getJson(`/api/logs/v2/events${suffix}`)
|
||||
}
|
||||
|
||||
export function getLogSummary(limit = 300) {
|
||||
return getJson(`/api/logs/v2/summary?limit=${encodeURIComponent(limit)}`)
|
||||
}
|
||||
|
||||
export function getLogTrace(traceId) {
|
||||
return getJson(`/api/logs/v2/trace/${encodeURIComponent(traceId)}`)
|
||||
}
|
||||
|
||||
export function getBotLogs(kind = 'bot', limit = 80) {
|
||||
return getLogEvents({ module: kind, size: limit, page: 1 })
|
||||
}
|
||||
|
||||
export function getLogEventJson(eventId) {
|
||||
return getJson(`/api/logs/v2/event/${encodeURIComponent(eventId)}`)
|
||||
}
|
||||
|
||||
export function clearLogs(module = '') {
|
||||
return postJson('/api/logs/v2/clear', { module })
|
||||
}
|
||||
|
||||
export function startBot() {
|
||||
return postForm('/api/bot/start', {})
|
||||
}
|
||||
|
||||
export function stopBot() {
|
||||
return postForm('/api/bot/stop', {})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { getJson, postForm } from './http'
|
||||
@@ -0,0 +1,57 @@
|
||||
const DEFAULT_BACKEND_ORIGIN = 'http://127.0.0.1:5000'
|
||||
|
||||
function normalizePath(path) {
|
||||
if (!path) return '/'
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
function isAbsoluteUrl(url) {
|
||||
return /^https?:\/\//i.test(String(url || ''))
|
||||
}
|
||||
|
||||
function getConfiguredBackendOrigin() {
|
||||
const raw = String(window.__APP_BACKEND_URL || '').trim()
|
||||
if (!raw) return ''
|
||||
return raw.endsWith('/') ? raw.slice(0, -1) : raw
|
||||
}
|
||||
|
||||
export function isTauriRuntime() {
|
||||
return !!window.__TAURI_INTERNALS__
|
||||
}
|
||||
|
||||
export function resolveApiUrl(path) {
|
||||
if (isAbsoluteUrl(path)) return path
|
||||
const normalizedPath = normalizePath(path)
|
||||
const backendOrigin = getConfiguredBackendOrigin()
|
||||
if (backendOrigin) return `${backendOrigin}${normalizedPath}`
|
||||
if (isTauriRuntime()) return `${DEFAULT_BACKEND_ORIGIN}${normalizedPath}`
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
export async function getJson(url) {
|
||||
const resp = await fetch(resolveApiUrl(url))
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export async function postForm(url, payload) {
|
||||
const body = new URLSearchParams(payload)
|
||||
const resp = await fetch(resolveApiUrl(url), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export async function postJson(url, payload) {
|
||||
const resp = await fetch(resolveApiUrl(url), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload || {}),
|
||||
})
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export function createEventSource(url) {
|
||||
return new EventSource(resolveApiUrl(url))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { getJson } from './http'
|
||||
|
||||
export function getRecentMessages(limit = 50) {
|
||||
return getJson(`/api/rules?action=messages_recent&limit=${limit}`)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getJson, postForm } from './http'
|
||||
|
||||
export function getRules() {
|
||||
return getJson('/api/rules?action=list')
|
||||
}
|
||||
|
||||
export function createRule(payload) {
|
||||
return postForm('/api/rules', { action: 'create', ...payload })
|
||||
}
|
||||
|
||||
export function toggleRule(payload) {
|
||||
return postForm('/api/rules', { action: 'toggle', ...payload })
|
||||
}
|
||||
|
||||
export function deleteRule(id) {
|
||||
return postForm('/api/rules', { action: 'delete', id })
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getJson, postForm } from './http'
|
||||
|
||||
export function getSettings() {
|
||||
return getJson('/api/rules?action=settings_get')
|
||||
}
|
||||
|
||||
export function saveSettings(payload) {
|
||||
return postForm('/api/rules', { action: 'settings_set', ...payload })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import RuntimeBadge from './RuntimeBadge.vue'
|
||||
|
||||
defineProps({
|
||||
runtimeText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
runtimeTagType: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
onStartMove: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onMinimize: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onMaximizeOrRestore: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onOpenDevtools: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onRestartApp: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inner drag-region" data-tauri-drag-region @mousedown="onStartMove" @dblclick="onMaximizeOrRestore">
|
||||
<div class="flex items-center gap-3" data-tauri-drag-region>
|
||||
<div class="w-8 h-8 rounded-lg bg-cyan-100 text-cyan-700 flex items-center justify-center font-bold">识</div>
|
||||
<div class="text-[16px] font-semibold text-slate-800">识流 AI 助手</div>
|
||||
<RuntimeBadge :type="runtimeTagType" :text="runtimeText" />
|
||||
</div>
|
||||
<div class="window-actions" @mousedown.stop>
|
||||
<button class="win-btn" type="button" @click.stop="onOpenDevtools">调</button>
|
||||
<button class="win-btn" type="button" @click.stop="onRestartApp">重</button>
|
||||
<button class="win-btn" type="button" @click.stop="onMinimize">—</button>
|
||||
<button class="win-btn" type="button" @click.stop="onMaximizeOrRestore">□</button>
|
||||
<button class="win-btn close" type="button" @click.stop="onClose">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { NTag } from 'naive-ui'
|
||||
|
||||
defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-tag :type="type" :bordered="false" round>{{ text }}</n-tag>
|
||||
</template>
|
||||
@@ -0,0 +1,177 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { getBotStatus, startBot, stopBot } from '../api/bot'
|
||||
import { createEventSource } from '../api/http'
|
||||
|
||||
export function useBotStatus(settings, loadSettings, message) {
|
||||
const statusEventSource = ref(null)
|
||||
const statusFallbackTimer = ref(null)
|
||||
const statusHealthTimer = ref(null)
|
||||
const statusConnecting = ref(false)
|
||||
const lastStatusStreamAt = ref(0)
|
||||
|
||||
const runtimeText = computed(() => {
|
||||
if (settings.listener_runtime_status === 'running') return '运行中'
|
||||
if (settings.listener_runtime_status === 'starting') return '启动中'
|
||||
if (settings.listener_runtime_status === 'stopping') return '停止中'
|
||||
if (settings.listener_runtime_status === 'error') return '异常'
|
||||
return '已停止'
|
||||
})
|
||||
|
||||
const runtimeTagType = computed(() => {
|
||||
if (settings.listener_runtime_status === 'running') return 'success'
|
||||
if (settings.listener_runtime_status === 'starting' || settings.listener_runtime_status === 'stopping') return 'warning'
|
||||
if (settings.listener_runtime_status === 'error') return 'error'
|
||||
return 'default'
|
||||
})
|
||||
|
||||
function applyBotStatus(bot, fallbackEnabled) {
|
||||
const nextStatus = String(bot?.status || 'stopped').toLowerCase()
|
||||
settings.listener_runtime_status = nextStatus
|
||||
if (nextStatus === 'running' || nextStatus === 'starting') settings.listener_enabled = true
|
||||
else if (nextStatus === 'stopped' || nextStatus === 'error') settings.listener_enabled = false
|
||||
else settings.listener_enabled = !!fallbackEnabled
|
||||
|
||||
if (nextStatus === 'error') {
|
||||
const detail = String(bot?.last_error || '').trim().split('\n').slice(-1)[0] || ''
|
||||
if (detail && detail !== lastNotifiedError.value) {
|
||||
lastNotifiedError.value = detail
|
||||
message.error(`监听启动异常: ${detail}`)
|
||||
} else if (!detail && lastNotifiedError.value !== '__unknown__') {
|
||||
lastNotifiedError.value = '__unknown__'
|
||||
message.error('监听启动异常,请检查微信环境、日志与配置')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBotStatus() {
|
||||
const bot = await getBotStatus()
|
||||
if (bot.success) applyBotStatus(bot, settings.listener_enabled)
|
||||
}
|
||||
|
||||
function touchStatusStream() {
|
||||
lastStatusStreamAt.value = Date.now()
|
||||
}
|
||||
|
||||
function clearStatusFallbackTimer() {
|
||||
if (statusFallbackTimer.value) {
|
||||
clearInterval(statusFallbackTimer.value)
|
||||
statusFallbackTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearStatusHealthTimer() {
|
||||
if (statusHealthTimer.value) {
|
||||
clearInterval(statusHealthTimer.value)
|
||||
statusHealthTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function closeStatusStream({ reconnect = false } = {}) {
|
||||
clearStatusFallbackTimer()
|
||||
clearStatusHealthTimer()
|
||||
if (statusEventSource.value) {
|
||||
const source = statusEventSource.value
|
||||
statusEventSource.value = null
|
||||
source.onopen = null
|
||||
source.onerror = null
|
||||
source.close()
|
||||
}
|
||||
statusConnecting.value = false
|
||||
if (reconnect) {
|
||||
connectBotStatusStream(true)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureStatusFallbackPolling() {
|
||||
if (statusFallbackTimer.value) return
|
||||
statusFallbackTimer.value = setInterval(() => {
|
||||
refreshBotStatus().catch(() => {})
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
function startStatusHealthCheck() {
|
||||
if (statusHealthTimer.value) return
|
||||
statusHealthTimer.value = setInterval(() => {
|
||||
const lastAt = lastStatusStreamAt.value || 0
|
||||
if (!statusEventSource.value) {
|
||||
connectBotStatusStream()
|
||||
return
|
||||
}
|
||||
if (lastAt && Date.now() - lastAt <= 45000) {
|
||||
return
|
||||
}
|
||||
ensureStatusFallbackPolling()
|
||||
closeStatusStream({ reconnect: true })
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
function connectBotStatusStream(force = false) {
|
||||
if (statusConnecting.value) return
|
||||
if (statusEventSource.value && !force) return
|
||||
if (statusEventSource.value && force) {
|
||||
closeStatusStream()
|
||||
}
|
||||
statusConnecting.value = true
|
||||
try {
|
||||
const source = createEventSource('/api/bot/status/stream')
|
||||
statusEventSource.value = source
|
||||
source.addEventListener('status', (event) => {
|
||||
touchStatusStream()
|
||||
try {
|
||||
const payload = JSON.parse(event.data || '{}')
|
||||
if (payload.success) {
|
||||
applyBotStatus(payload, settings.listener_enabled)
|
||||
}
|
||||
clearStatusFallbackTimer()
|
||||
} catch (_) {
|
||||
}
|
||||
})
|
||||
source.addEventListener('ping', () => {
|
||||
touchStatusStream()
|
||||
clearStatusFallbackTimer()
|
||||
})
|
||||
source.onopen = () => {
|
||||
statusConnecting.value = false
|
||||
touchStatusStream()
|
||||
clearStatusFallbackTimer()
|
||||
}
|
||||
source.onerror = () => {
|
||||
statusConnecting.value = false
|
||||
ensureStatusFallbackPolling()
|
||||
}
|
||||
} catch (_) {
|
||||
statusConnecting.value = false
|
||||
ensureStatusFallbackPolling()
|
||||
}
|
||||
}
|
||||
|
||||
async function onListenerChange(value) {
|
||||
try {
|
||||
settings.listener_enabled = !!value
|
||||
settings.listener_runtime_status = value ? 'starting' : 'stopping'
|
||||
const data = await (value ? startBot() : stopBot())
|
||||
if (!data.success) throw new Error(data.error || '操作失败')
|
||||
settings.listener_runtime_status = data.status || (value ? 'running' : 'stopped')
|
||||
settings.listener_enabled = data.status === 'running' || data.status === 'starting'
|
||||
await loadSettings()
|
||||
} catch (error) {
|
||||
message.error(`监听切换失败: ${error}`)
|
||||
await loadSettings()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
closeStatusStream()
|
||||
})
|
||||
|
||||
return {
|
||||
runtimeText,
|
||||
runtimeTagType,
|
||||
refreshBotStatus,
|
||||
connectBotStatusStream,
|
||||
startStatusHealthCheck,
|
||||
onListenerChange,
|
||||
closeStatusStream,
|
||||
applyBotStatus,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ref } from 'vue'
|
||||
import { getRecentMessages } from '../api/messages'
|
||||
|
||||
export function useMessages(message) {
|
||||
const loadingMessages = ref(false)
|
||||
const messages = ref([])
|
||||
|
||||
async function loadMessages(limit = 50) {
|
||||
loadingMessages.value = true
|
||||
try {
|
||||
const data = await getRecentMessages(limit)
|
||||
messages.value = data.data || []
|
||||
} catch (error) {
|
||||
message.error(`加载消息失败: ${error}`)
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadingMessages,
|
||||
messages,
|
||||
loadMessages,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
import { NButton, NSpace, NTag } from 'naive-ui'
|
||||
import { createRule, deleteRule, getRules, toggleRule } from '../api/rules'
|
||||
|
||||
export function useRulesManager(message) {
|
||||
const loadingRules = ref(false)
|
||||
const creatingRule = ref(false)
|
||||
const showCreateRuleModal = ref(false)
|
||||
const rules = ref([])
|
||||
const filters = reactive({ keyword: '', match_type: '', status: '' })
|
||||
const createForm = reactive({ keyword: '', match_type: 'contain', reply_text: '' })
|
||||
|
||||
const filteredRules = computed(() => {
|
||||
return rules.value.filter((row) => {
|
||||
if (filters.keyword && !String(row.keyword || '').toLowerCase().includes(filters.keyword.toLowerCase())) return false
|
||||
if (filters.match_type && row.match_type !== filters.match_type) return false
|
||||
if (filters.status !== '' && Number(row.is_active) !== Number(filters.status)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
async function loadRules() {
|
||||
loadingRules.value = true
|
||||
try {
|
||||
const data = await getRules()
|
||||
rules.value = data.data || []
|
||||
} catch (error) {
|
||||
message.error(`加载规则失败: ${error}`)
|
||||
} finally {
|
||||
loadingRules.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreateRule() {
|
||||
if (!createForm.keyword.trim() || !createForm.reply_text.trim()) {
|
||||
message.warning('请填写关键词和回复内容')
|
||||
return
|
||||
}
|
||||
creatingRule.value = true
|
||||
try {
|
||||
const data = await createRule({
|
||||
keyword: createForm.keyword.trim(),
|
||||
match_type: createForm.match_type,
|
||||
reply_text: createForm.reply_text.trim(),
|
||||
})
|
||||
if (!data.success) {
|
||||
message.error(data.message || '新增失败')
|
||||
return
|
||||
}
|
||||
message.success('新增成功')
|
||||
createForm.keyword = ''
|
||||
createForm.reply_text = ''
|
||||
showCreateRuleModal.value = false
|
||||
await loadRules()
|
||||
} catch (error) {
|
||||
message.error(`新增失败: ${error}`)
|
||||
} finally {
|
||||
creatingRule.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleRule(row) {
|
||||
try {
|
||||
const data = await toggleRule({
|
||||
id: String(row.id),
|
||||
is_active: Number(row.is_active) === 1 ? '0' : '1',
|
||||
})
|
||||
if (!data.success) {
|
||||
message.error(data.message || '切换失败')
|
||||
return
|
||||
}
|
||||
await loadRules()
|
||||
} catch (error) {
|
||||
message.error(`切换失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRule(row) {
|
||||
try {
|
||||
const data = await deleteRule(String(row.id))
|
||||
if (!data.success) {
|
||||
message.error(data.message || '删除失败')
|
||||
return
|
||||
}
|
||||
message.success('删除成功')
|
||||
await loadRules()
|
||||
} catch (error) {
|
||||
message.error(`删除失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.match_type = ''
|
||||
filters.status = ''
|
||||
loadRules()
|
||||
}
|
||||
|
||||
const ruleColumns = [
|
||||
{ title: '关键词', key: 'keyword', minWidth: 140 },
|
||||
{ title: '匹配方式', key: 'match_type', width: 120, render: (row) => row.match_type === 'equal' ? '完全匹配' : '包含' },
|
||||
{ title: '回复内容', key: 'reply_text', minWidth: 220 },
|
||||
{
|
||||
title: '状态', key: 'is_active', width: 110,
|
||||
render: (row) => h(NTag, { type: Number(row.is_active) === 1 ? 'success' : 'default', bordered: false, round: true }, {
|
||||
default: () => Number(row.is_active) === 1 ? '启用' : '暂停',
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 190,
|
||||
render: (row) => h(NSpace, { size: 8 }, {
|
||||
default: () => [
|
||||
h(NButton, { size: 'small', secondary: true, type: Number(row.is_active) === 1 ? 'warning' : 'success', onClick: () => handleToggleRule(row) }, { default: () => Number(row.is_active) === 1 ? '停用' : '启用' }),
|
||||
h(NButton, { size: 'small', secondary: true, type: 'error', onClick: () => handleDeleteRule(row) }, { default: () => '删除' }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
loadingRules,
|
||||
creatingRule,
|
||||
showCreateRuleModal,
|
||||
filters,
|
||||
createForm,
|
||||
filteredRules,
|
||||
ruleColumns,
|
||||
loadRules,
|
||||
submitCreateRule,
|
||||
resetFilters,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { reactive } from 'vue'
|
||||
import { getSettings, saveSettings as saveSettingsApi } from '../api/settings'
|
||||
|
||||
export function useSettings(message) {
|
||||
const settings = reactive({
|
||||
auto_reply_enabled: false,
|
||||
listener_enabled: false,
|
||||
listener_runtime_status: 'stopped',
|
||||
full_auto_reply_enabled: false,
|
||||
reply_fallback_mode: 'ai',
|
||||
})
|
||||
|
||||
async function loadSettings(applyBotStatus) {
|
||||
try {
|
||||
const data = await getSettings()
|
||||
if (!data.success) return
|
||||
settings.auto_reply_enabled = !!data.auto_reply_enabled
|
||||
settings.full_auto_reply_enabled = !!data.full_auto_reply_enabled
|
||||
settings.reply_fallback_mode = data.reply_fallback_mode || 'ai'
|
||||
applyBotStatus({ status: data.listener_runtime_status || 'stopped' }, data.listener_enabled)
|
||||
} catch (error) {
|
||||
message.error(`加载设置失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(payload, applyBotStatus) {
|
||||
try {
|
||||
const data = await saveSettingsApi(payload)
|
||||
if (!data.success) {
|
||||
message.error(data.message || '保存失败')
|
||||
return
|
||||
}
|
||||
await loadSettings(applyBotStatus)
|
||||
} catch (error) {
|
||||
message.error(`保存失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
let tauriWindow = null
|
||||
let tauriCore = null
|
||||
|
||||
async function ensureTauriModules() {
|
||||
if (tauriWindow && tauriCore) return true
|
||||
if (!window.__TAURI_INTERNALS__) return false
|
||||
try {
|
||||
const [{ getCurrentWindow }, { invoke }] = await Promise.all([
|
||||
import('@tauri-apps/api/window'),
|
||||
import('@tauri-apps/api/core'),
|
||||
])
|
||||
tauriWindow = getCurrentWindow()
|
||||
tauriCore = { invoke }
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function useWindowBridge() {
|
||||
const windowBridge = ref(null)
|
||||
const bridgeMode = ref('browser')
|
||||
|
||||
async function initWindowBridge() {
|
||||
if (window.qt && window.QWebChannel) {
|
||||
bridgeMode.value = 'qt'
|
||||
new window.QWebChannel(window.qt.webChannelTransport, (channel) => {
|
||||
windowBridge.value = channel.objects.windowBridge || null
|
||||
})
|
||||
return
|
||||
}
|
||||
if (await ensureTauriModules()) {
|
||||
bridgeMode.value = 'tauri'
|
||||
return
|
||||
}
|
||||
bridgeMode.value = 'browser'
|
||||
}
|
||||
|
||||
async function invoke(methodName) {
|
||||
const bridge = windowBridge.value
|
||||
if (bridge && typeof bridge[methodName] === 'function') {
|
||||
bridge[methodName]()
|
||||
return
|
||||
}
|
||||
if (!(await ensureTauriModules())) return
|
||||
if (methodName === 'start_move') {
|
||||
await tauriWindow.startDragging()
|
||||
return
|
||||
}
|
||||
if (methodName === 'minimize') {
|
||||
await tauriWindow.minimize()
|
||||
return
|
||||
}
|
||||
if (methodName === 'maximize_or_restore') {
|
||||
const maximized = await tauriWindow.isMaximized()
|
||||
if (maximized) {
|
||||
await tauriWindow.unmaximize()
|
||||
} else {
|
||||
await tauriWindow.maximize()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (methodName === 'close_window') {
|
||||
await tauriWindow.close()
|
||||
return
|
||||
}
|
||||
if (methodName === 'open_devtools') {
|
||||
try {
|
||||
await tauriCore.invoke('open_devtools')
|
||||
} catch {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (methodName === 'restart_app') {
|
||||
try {
|
||||
await tauriCore.invoke('restart_backend')
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bridgeMode,
|
||||
initWindowBridge,
|
||||
startMove: () => invoke('start_move'),
|
||||
minimizeWindow: () => invoke('minimize'),
|
||||
maximizeOrRestore: () => invoke('maximize_or_restore'),
|
||||
closeWindow: () => invoke('close_window'),
|
||||
openDevtools: () => invoke('open_devtools'),
|
||||
restartApp: () => invoke('restart_app'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { formatDateTime } from '../utils/datetime'
|
||||
|
||||
export const messageColumns = [
|
||||
{ title: '时间', key: 'created_at', width: 180, render: (row) => formatDateTime(row.created_at) },
|
||||
{ title: '微信用户', key: 'wx_nickname', width: 180 },
|
||||
{ title: '方向', key: 'direction', width: 90, render: (row) => row.direction === 'out' ? '发送' : '接收' },
|
||||
{ title: '内容', key: 'content', minWidth: 320 },
|
||||
{ title: '气泡方向', key: 'ocr_bubble_side', width: 100 },
|
||||
{ title: '置信度', key: 'ocr_confidence', width: 90 },
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
export const fallbackModeOptions = [
|
||||
{ label: 'AI兜底回复', value: 'ai' },
|
||||
{ label: '不回复', value: 'none' },
|
||||
]
|
||||
|
||||
export const matchTypeOptions = [
|
||||
{ label: '包含', value: 'contain' },
|
||||
{ label: '完全匹配', value: 'equal' },
|
||||
]
|
||||
|
||||
export const matchTypeFilterOptions = [
|
||||
{ label: '全部匹配方式', value: '' },
|
||||
{ label: '包含', value: 'contain' },
|
||||
{ label: '完全匹配', value: 'equal' },
|
||||
]
|
||||
|
||||
export const ruleStatusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '暂停', value: 0 },
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createDiscreteApi } from 'naive-ui'
|
||||
import App from './App.vue'
|
||||
import './styles.css'
|
||||
|
||||
function loadQtWebChannelScript() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.QWebChannel) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = 'qrc:///qtwebchannel/qwebchannel.js'
|
||||
script.onload = () => resolve()
|
||||
script.onerror = reject
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (window.qt && !window.QWebChannel) {
|
||||
try {
|
||||
await loadQtWebChannelScript()
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
|
||||
const { message } = createDiscreteApi(['message'])
|
||||
window.$message = message
|
||||
createApp(App).mount('#app')
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
@@ -0,0 +1,299 @@
|
||||
:root {
|
||||
--bg: #f2f4f7;
|
||||
--panel: #ffffff;
|
||||
--line: #e5e7eb;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--brand: #22c1c3;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg, #f5f7fb 0%, #eef2f7 100%);
|
||||
color: var(--text);
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-root {
|
||||
height: calc(100vh - 48px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-region {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.inner {
|
||||
width: min(1320px, calc(100vw - 24px));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.window-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.win-btn {
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e7ecf2;
|
||||
background: #fff;
|
||||
color: #4b5563;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.win-btn:hover {
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.win-btn.close:hover {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid #edf0f4;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(120deg, rgba(34, 193, 195, 0.15), rgba(124, 58, 237, 0.08));
|
||||
border: 1px solid #dff4f5;
|
||||
}
|
||||
|
||||
.tab-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border: 1px solid #e8ecf2;
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px 0;
|
||||
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-tabs,
|
||||
.app-tabs .n-tabs-nav-scroll-content,
|
||||
.app-tabs .n-tabs-pane-wrapper,
|
||||
.app-tabs .n-tab-pane,
|
||||
.app-tab-pane {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-tabs .n-tabs-nav {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-tabs .n-tabs-pane-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-tabs .n-tab-pane,
|
||||
.app-tab-pane {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-tab-pane > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.label-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.label-desc {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.row-line {
|
||||
border-bottom: 1px dashed #edf1f5;
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.row-line:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.compact-card .n-card-header {
|
||||
padding-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.compact-card .n-card__content {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.panel-view-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.logs-view-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-panel .n-card__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-panel .n-card-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.log-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.log-path {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
max-width: 520px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-stream-wrap {
|
||||
height: clamp(320px, calc(100vh - 320px), 680px);
|
||||
min-height: 320px;
|
||||
max-height: 680px;
|
||||
border: 1px solid #e6ebf2;
|
||||
border-radius: 12px;
|
||||
background: #0f172a;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.log-stream {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: Consolas, "SFMono-Regular", Monaco, monospace;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.log-line--error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.log-line--warning {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.log-line--info {
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.log-line--debug {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function formatDateTime(value) {
|
||||
if (!value) return ''
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return String(value)
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup>
|
||||
import { h, computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { NButton, NCard, NDataTable, NGi, NGrid, NInput, NModal, NSelect, NSpace } from 'naive-ui'
|
||||
import { clearLogs, getLogEventJson, getLogEvents, getLogSummary } from '../api/bot'
|
||||
|
||||
const pollTimer = ref(null)
|
||||
const loading = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const detailJson = ref('')
|
||||
const keyword = ref('')
|
||||
const moduleValue = ref('')
|
||||
const domainValue = ref('')
|
||||
const levelValue = ref('')
|
||||
const onlyAbnormal = ref(false)
|
||||
const events = ref([])
|
||||
const summary = ref({ error_count: 0, fallback_count: 0, top_reasons: [], domain_counts: [] })
|
||||
|
||||
const domainOptions = [
|
||||
{ label: '全部功能域', value: '' },
|
||||
{ label: '接口', value: 'api' },
|
||||
{ label: '机器人', value: 'bot' },
|
||||
{ label: 'OCR', value: 'ocr' },
|
||||
{ label: 'AI', value: 'ai' },
|
||||
{ label: '数据库', value: 'db' },
|
||||
{ label: '截图', value: 'capture' },
|
||||
]
|
||||
|
||||
const moduleOptions = [
|
||||
{ label: '全部模块', value: '' },
|
||||
{ label: '接口(api)', value: 'api' },
|
||||
{ label: '机器人(bot)', value: 'bot' },
|
||||
{ label: 'OCR(ocr)', value: 'ocr' },
|
||||
{ label: 'AI(ai)', value: 'ai' },
|
||||
{ label: '数据库(db)', value: 'db' },
|
||||
{ label: '截图(capture)', value: 'capture' },
|
||||
{ label: '审计(audit)', value: 'audit' },
|
||||
{ label: '错误(error)', value: 'error' },
|
||||
]
|
||||
|
||||
const levelOptions = [
|
||||
{ label: '全部级别', value: '' },
|
||||
{ label: 'INFO', value: 'INFO' },
|
||||
{ label: 'WARNING', value: 'WARNING' },
|
||||
{ label: 'ERROR', value: 'ERROR' },
|
||||
]
|
||||
|
||||
const domainLabelMap = {
|
||||
api: '接口',
|
||||
bot: '机器人',
|
||||
ocr: 'OCR',
|
||||
ai: 'AI',
|
||||
db: '数据库',
|
||||
capture: '截图',
|
||||
}
|
||||
|
||||
const renderedDomainStats = computed(() => {
|
||||
const items = Array.isArray(summary.value?.domain_counts) ? summary.value.domain_counts : []
|
||||
return items
|
||||
.filter((x) => Number(x?.count || 0) > 0)
|
||||
.map((x) => `${domainLabelMap[x.domain] || x.domain}: ${x.count}`)
|
||||
.join(' | ')
|
||||
})
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
let rows = events.value
|
||||
if (domainValue.value) {
|
||||
rows = rows.filter((e) => String(e.domain || '') === domainValue.value)
|
||||
}
|
||||
if (onlyAbnormal.value) {
|
||||
rows = rows.filter((e) => {
|
||||
const lv = String(e.level || '').toUpperCase()
|
||||
const st = String(e.status || '').toLowerCase()
|
||||
return lv === 'ERROR' || lv === 'WARNING' || st === 'failed' || st === 'fail'
|
||||
})
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
function formatLogTs(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return '-'
|
||||
const d = new Date(raw)
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return raw.replace('T', ' ').replace(/\.\d+([+-]\d{2}:?\d{2}|Z)?$/, '')
|
||||
}
|
||||
const yyyy = d.getFullYear()
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mi = String(d.getMinutes()).padStart(2, '0')
|
||||
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`
|
||||
}
|
||||
|
||||
async function onViewJson(row) {
|
||||
detailVisible.value = true
|
||||
detailJson.value = '加载中...'
|
||||
const eventId = String(row?.event_id || '').trim()
|
||||
if (!eventId) {
|
||||
detailJson.value = JSON.stringify(row || {}, null, 2)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getLogEventJson(eventId)
|
||||
detailJson.value = JSON.stringify(res?.item || row || {}, null, 2)
|
||||
} catch {
|
||||
detailJson.value = JSON.stringify(row || {}, null, 2)
|
||||
}
|
||||
}
|
||||
|
||||
async function onClearLogs() {
|
||||
await clearLogs(moduleValue.value || '')
|
||||
await refreshLogs()
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '时间', key: 'ts', width: 170, render: (row) => formatLogTs(row.ts) },
|
||||
{ title: '功能域', key: 'domain', width: 90 },
|
||||
{ title: '级别', key: 'level', width: 90 },
|
||||
{ title: '模块', key: 'module', width: 90 },
|
||||
{ title: '事件', key: 'event', width: 220 },
|
||||
{ title: '阶段', key: 'stage', width: 90 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: 'trace_id', key: 'trace_id', width: 180 },
|
||||
{ title: '原因', key: 'reason', width: 160 },
|
||||
{ title: '消息', key: 'message', minWidth: 260 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (row) => h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => onViewJson(row) }, { default: () => 'JSON' }),
|
||||
},
|
||||
]
|
||||
|
||||
async function refreshLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [eventRes, summaryRes] = await Promise.all([
|
||||
getLogEvents({ module: moduleValue.value, level: levelValue.value, keyword: keyword.value, page: 1, size: 200 }),
|
||||
getLogSummary(300),
|
||||
])
|
||||
events.value = Array.isArray(eventRes?.items) ? eventRes.items : []
|
||||
summary.value = summaryRes || { error_count: 0, fallback_count: 0, top_reasons: [], domain_counts: [] }
|
||||
} catch {
|
||||
events.value = []
|
||||
summary.value = { error_count: 0, fallback_count: 0, top_reasons: [], domain_counts: [] }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
domainValue.value = ''
|
||||
moduleValue.value = ''
|
||||
levelValue.value = ''
|
||||
keyword.value = ''
|
||||
onlyAbnormal.value = false
|
||||
refreshLogs()
|
||||
}
|
||||
|
||||
function toggleAbnormal() {
|
||||
onlyAbnormal.value = !onlyAbnormal.value
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer.value) clearInterval(pollTimer.value)
|
||||
pollTimer.value = setInterval(() => {
|
||||
refreshLogs()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshLogs()
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pollTimer.value) {
|
||||
clearInterval(pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-3">
|
||||
<n-card class="panel compact-card mb-3" title="筛选" size="small" :bordered="false">
|
||||
<n-grid :cols="24" :x-gap="12" :y-gap="12">
|
||||
<n-gi :span="4"><n-select v-model:value="domainValue" :options="domainOptions" placeholder="功能域" /></n-gi>
|
||||
<n-gi :span="4"><n-select v-model:value="moduleValue" :options="moduleOptions" placeholder="模块" /></n-gi>
|
||||
<n-gi :span="4"><n-select v-model:value="levelValue" :options="levelOptions" placeholder="级别" /></n-gi>
|
||||
<n-gi :span="6"><n-input v-model:value="keyword" placeholder="关键词过滤" @keydown.enter="refreshLogs" /></n-gi>
|
||||
<n-gi :span="6" class="flex justify-end">
|
||||
<n-space>
|
||||
<n-button :type="onlyAbnormal ? 'error' : 'default'" @click="toggleAbnormal">{{ onlyAbnormal ? '仅异常:开' : '仅异常:关' }}</n-button>
|
||||
<n-button type="warning" @click="onClearLogs">清除日志</n-button>
|
||||
<n-button type="primary" :loading="loading" @click="refreshLogs">搜索</n-button>
|
||||
<n-button @click="resetFilters">重置</n-button>
|
||||
</n-space>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<n-card class="panel compact-card mb-3" title="统计" size="small" :bordered="false">
|
||||
<div style="color: #8c8c8c; font-size: 12px;">
|
||||
<span>错误数: {{ summary.error_count || 0 }}</span>
|
||||
<span style="margin-left: 12px;">回退数: {{ summary.fallback_count || 0 }}</span>
|
||||
<span style="margin-left: 12px;">{{ renderedDomainStats || '域分布: 无' }}</span>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card class="panel" size="small" :bordered="false">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="filteredEvents"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
:max-height="560"
|
||||
size="small"
|
||||
striped
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<n-modal v-model:show="detailVisible" preset="card" title="日志JSON" style="width: 760px">
|
||||
<pre style="max-height: 520px; overflow: auto; margin: 0; font-size: 12px; white-space: pre-wrap; word-break: break-all;">{{ detailJson }}</pre>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { NButton, NCard, NDataTable } from 'naive-ui'
|
||||
|
||||
defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
messages: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
onRefresh: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-3">
|
||||
<n-card class="panel" size="small" :bordered="false">
|
||||
<template #header-extra><n-button @click="onRefresh">刷新</n-button></template>
|
||||
<n-data-table :columns="columns" :data="messages" :loading="loading" :pagination="{ pageSize: 10 }" :max-height="520" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { NButton, NCard, NRadioButton, NRadioGroup, NSelect, NSwitch } from 'naive-ui'
|
||||
import { getBotLogs } from '../api/bot'
|
||||
import { fallbackModeOptions } from '../constants/options'
|
||||
|
||||
const props = defineProps({
|
||||
settings: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
onListenerChange: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onSaveSettings: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const logKind = ref('bot')
|
||||
const logLines = ref([])
|
||||
const logPath = ref('')
|
||||
const logLoading = ref(false)
|
||||
const logError = ref('')
|
||||
const logTimer = ref(null)
|
||||
|
||||
const renderedLogLines = computed(() => {
|
||||
if (logLoading.value && !logLines.value.length) {
|
||||
return [{ text: '日志加载中...', level: 'info' }]
|
||||
}
|
||||
if (logError.value) {
|
||||
return [{ text: `日志加载失败:${logError.value}`, level: 'error' }]
|
||||
}
|
||||
if (!logLines.value.length) {
|
||||
return [{ text: '暂无日志输出', level: 'debug' }]
|
||||
}
|
||||
return logLines.value.map((line) => ({
|
||||
text: line,
|
||||
level: getLogLevel(line),
|
||||
}))
|
||||
})
|
||||
|
||||
function getLogLevel(line) {
|
||||
const text = String(line || '').toUpperCase()
|
||||
if (text.includes('ERROR')) return 'error'
|
||||
if (text.includes('WARNING') || text.includes('WARN')) return 'warning'
|
||||
if (text.includes('INFO')) return 'info'
|
||||
return 'debug'
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
logLoading.value = true
|
||||
try {
|
||||
const data = await getBotLogs(logKind.value, 120)
|
||||
if (!data?.success) throw new Error(data?.message || '接口返回失败')
|
||||
logLines.value = Array.isArray(data.lines) ? data.lines : []
|
||||
logPath.value = data.path || ''
|
||||
logError.value = ''
|
||||
} catch (error) {
|
||||
logError.value = error?.message || String(error)
|
||||
} finally {
|
||||
logLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setLogKind(value) {
|
||||
logKind.value = value
|
||||
}
|
||||
|
||||
function startLogPolling() {
|
||||
if (logTimer.value) clearInterval(logTimer.value)
|
||||
logTimer.value = setInterval(loadLogs, 2000)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLogs()
|
||||
startLogPolling()
|
||||
})
|
||||
|
||||
watch(logKind, () => {
|
||||
loadLogs()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (logTimer.value) {
|
||||
clearInterval(logTimer.value)
|
||||
logTimer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-3 panel-view-shell">
|
||||
<n-card class="panel compact-card" title="监听与回复设置" size="small" :bordered="false">
|
||||
<div class="row-line flex items-center justify-between">
|
||||
<div><div class="label-title">监听</div><div class="label-desc">执行监听开始监听微信</div></div>
|
||||
<n-switch :value="props.settings.listener_enabled" @update:value="props.onListenerChange" />
|
||||
</div>
|
||||
<div class="row-line flex items-center justify-between">
|
||||
<div><div class="label-title">自动回复</div><div class="label-desc">开启后自动回复微信收到的信息</div></div>
|
||||
<n-switch :value="props.settings.auto_reply_enabled" @update:value="value => props.onSaveSettings({ auto_reply_enabled: value ? '1' : '0' })" />
|
||||
</div>
|
||||
<div class="row-line flex items-center justify-between">
|
||||
<div><div class="label-title">全量回复</div><div class="label-desc">开启后回复所有消息</div></div>
|
||||
<n-switch :value="props.settings.full_auto_reply_enabled" @update:value="value => props.onSaveSettings({ full_auto_reply_enabled: value ? '1' : '0' })" />
|
||||
</div>
|
||||
<div class="pt-3 max-w-[240px]">
|
||||
<n-select :options="fallbackModeOptions" :value="props.settings.reply_fallback_mode" @update:value="value => props.onSaveSettings({ reply_fallback_mode: value || 'ai' })" />
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { NButton, NCard, NDataTable, NGi, NGrid, NInput, NModal, NSelect, NSpace } from 'naive-ui'
|
||||
import { matchTypeFilterOptions, matchTypeOptions, ruleStatusOptions } from '../constants/options'
|
||||
import { useRulesManager } from '../composables/useRulesManager'
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
loadingRules,
|
||||
creatingRule,
|
||||
showCreateRuleModal,
|
||||
filters,
|
||||
createForm,
|
||||
filteredRules,
|
||||
ruleColumns,
|
||||
loadRules,
|
||||
submitCreateRule,
|
||||
resetFilters,
|
||||
} = useRulesManager(props.message)
|
||||
|
||||
onMounted(() => {
|
||||
loadRules()
|
||||
})
|
||||
|
||||
defineExpose({ loadRules })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-3">
|
||||
<n-card class="panel compact-card mb-3" title="筛选" size="small" :bordered="false">
|
||||
<n-grid :cols="24" :x-gap="12" :y-gap="12">
|
||||
<n-gi :span="6"><n-input v-model:value="filters.keyword" placeholder="关键词" /></n-gi>
|
||||
<n-gi :span="5"><n-select v-model:value="filters.match_type" :options="matchTypeFilterOptions" /></n-gi>
|
||||
<n-gi :span="5"><n-select v-model:value="filters.status" :options="ruleStatusOptions" /></n-gi>
|
||||
<n-gi :span="8" class="flex justify-end"><n-space><n-button type="primary" @click="loadRules">搜索</n-button><n-button @click="resetFilters">重置</n-button></n-space></n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<n-card class="panel compact-card mb-3" title="新增规则" size="small" :bordered="false">
|
||||
<div class="flex justify-end">
|
||||
<n-button type="primary" @click="showCreateRuleModal = true">新增回复规则</n-button>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-modal
|
||||
:show="showCreateRuleModal"
|
||||
preset="card"
|
||||
title="新增回复规则"
|
||||
class="w-[640px]"
|
||||
:mask-closable="false"
|
||||
@update:show="value => showCreateRuleModal = value"
|
||||
>
|
||||
<n-grid :cols="24" :x-gap="12" :y-gap="12">
|
||||
<n-gi :span="24"><n-input v-model:value="createForm.keyword" placeholder="关键词" /></n-gi>
|
||||
<n-gi :span="24"><n-select v-model:value="createForm.match_type" :options="matchTypeOptions" /></n-gi>
|
||||
<n-gi :span="24"><n-input v-model:value="createForm.reply_text" type="textarea" :autosize="{ minRows: 3, maxRows: 6 }" placeholder="回复内容" /></n-gi>
|
||||
</n-grid>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<n-button @click="showCreateRuleModal = false">取消</n-button>
|
||||
<n-button type="primary" :loading="creatingRule" @click="submitCreateRule">确认新增</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-card class="panel" size="small" :bordered="false">
|
||||
<n-data-table :columns="ruleColumns" :data="filteredRules" :loading="loadingRules" :pagination="{ pageSize: 10 }" :max-height="360" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: 'localhost',
|
||||
port: 1420,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:5000',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
# 微信 AI 自动回复机器人 - 安装使用指南
|
||||
|
||||
## 📦 安装依赖
|
||||
|
||||
```bash
|
||||
pip install wcferry requests
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 启动 PHP 后端服务
|
||||
确保 phpstudy 已启动,访问 http://127.0.0.1/shiliu_ai/admin.html 确认后端正常
|
||||
|
||||
### 2. 登录微信
|
||||
在电脑上打开微信并登录(必须是 Windows 微信客户端)
|
||||
|
||||
### 3. 运行机器人
|
||||
```bash
|
||||
python wechat_bot.py
|
||||
```
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
### 新版本 (wechat_bot.py) - 推荐使用 ✅
|
||||
- ✅ 基于 WeChatFerry 框架
|
||||
- ✅ 无需 OCR,100% 准确识别消息
|
||||
- ✅ 不需要固定窗口位置
|
||||
- ✅ 自动回复私聊消息
|
||||
- ✅ 可选开启群聊回复
|
||||
- ✅ 支持 DeepSeek AI 智能回复
|
||||
- ✅ 支持关键词规则匹配
|
||||
- ✅ 所有消息记录到数据库
|
||||
|
||||
### 旧版本 (wechat_auto.py) - 已保留
|
||||
- 基于 OCR 识别
|
||||
- 需要固定窗口位置
|
||||
- 识别准确率较低
|
||||
- 仅供参考学习
|
||||
|
||||
### 手动测试版 (wechat_manual.py)
|
||||
- 手动输入消息测试 AI 回复
|
||||
- 用于调试和测试
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### 修改 wechat_bot.py 中的配置:
|
||||
|
||||
```python
|
||||
# PHP 后端接口地址
|
||||
BACKEND_URL = "http://127.0.0.1/shiliu_ai/api_receive_message.php"
|
||||
|
||||
# 是否自动回复群聊(默认只回复私聊)
|
||||
ENABLE_GROUP_REPLY = False # 改为 True 可开启群聊回复
|
||||
```
|
||||
|
||||
### 修改 config.php 配置 AI:
|
||||
|
||||
```php
|
||||
// 选择 AI 提供商:mock / openai / deepseek
|
||||
define('AI_PROVIDER', 'deepseek');
|
||||
|
||||
// DeepSeek API 配置
|
||||
define('DEEPSEEK_API_KEY', '你的API密钥');
|
||||
define('DEEPSEEK_API_BASE', 'https://api.deepseek.com');
|
||||
define('DEEPSEEK_MODEL', 'deepseek-chat');
|
||||
```
|
||||
|
||||
## 📝 使用流程
|
||||
|
||||
1. **接收消息** → 机器人自动监听微信消息
|
||||
2. **规则匹配** → 先检查是否有关键词规则
|
||||
3. **AI 回复** → 没有规则则调用 DeepSeek 生成回复
|
||||
4. **自动发送** → 将回复发送给用户
|
||||
5. **记录保存** → 所有消息保存到数据库
|
||||
|
||||
## 🎯 管理后台
|
||||
|
||||
访问 http://127.0.0.1/shiliu_ai/admin.html 可以:
|
||||
- 查看消息记录
|
||||
- 管理自动回复规则
|
||||
- 配置系统设置
|
||||
|
||||
## 🔧 常见问题
|
||||
|
||||
### Q: 提示 "WeChatFerry 初始化失败"
|
||||
**A:** 确保:
|
||||
1. 微信已经登录
|
||||
2. 已安装 wcferry: `pip install wcferry`
|
||||
3. 使用的是 Windows 微信客户端
|
||||
|
||||
### Q: 机器人没有回复
|
||||
**A:** 检查:
|
||||
1. PHP 后端是否正常运行
|
||||
2. 查看日志文件 `wechat_bot.log`
|
||||
3. 确认 DeepSeek API Key 是否正确
|
||||
|
||||
### Q: 想要回复群聊消息
|
||||
**A:** 修改 `wechat_bot.py` 中的配置:
|
||||
```python
|
||||
ENABLE_GROUP_REPLY = True
|
||||
```
|
||||
|
||||
### Q: 如何添加关键词规则
|
||||
**A:** 访问管理后台 admin.html,在"自动回复规则"中添加
|
||||
|
||||
## 📂 文件说明
|
||||
|
||||
```
|
||||
shiliu_ai/
|
||||
├── wechat_bot.py # 新版机器人(推荐使用)⭐
|
||||
├── wechat_auto.py # 旧版 OCR 机器人(已保留)
|
||||
├── wechat_manual.py # 手动测试工具
|
||||
├── config.php # 配置文件
|
||||
├── ai_helper.php # AI 调用逻辑
|
||||
├── api_receive_message.php # 消息接收接口
|
||||
├── admin.html # 管理后台
|
||||
├── database.sql # 数据库结构
|
||||
└── wechat_bot.log # 运行日志
|
||||
```
|
||||
|
||||
## 🎉 开始使用
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
pip install wcferry requests
|
||||
|
||||
# 2. 确保微信已登录
|
||||
|
||||
# 3. 启动机器人
|
||||
python wechat_bot.py
|
||||
|
||||
# 4. 发送消息测试
|
||||
```
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如有问题,请查看日志文件:
|
||||
- `wechat_bot.log` - 机器人运行日志
|
||||
- `wechat_auto.log` - 旧版机器人日志(如果使用)
|
||||
|
||||
---
|
||||
|
||||
**祝使用愉快!🎊**
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
/**
|
||||
* 从规则表中查找是否有匹配的关键词回复
|
||||
*/
|
||||
function find_rule_reply(string $content): ?array
|
||||
{
|
||||
$pdo = get_pdo();
|
||||
|
||||
// 先查完全匹配,再查包含匹配,简单 MVP 版本
|
||||
$sql = "SELECT * FROM auto_reply_rules WHERE is_active = 1 ORDER BY id ASC";
|
||||
$stmt = $pdo->query($sql);
|
||||
$rules = $stmt->fetchAll();
|
||||
|
||||
$contentLower = mb_strtolower($content, 'UTF-8');
|
||||
error_log("=== 规则匹配开始 ===");
|
||||
error_log("用户消息原文: '{$content}'");
|
||||
error_log("转小写后: '{$contentLower}'");
|
||||
error_log("规则总数: " . count($rules));
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$keyword = trim((string)$rule['keyword']);
|
||||
if ($keyword === '') {
|
||||
error_log("规则ID {$rule['id']}: 关键词为空,跳过");
|
||||
continue;
|
||||
}
|
||||
$kwLower = mb_strtolower($keyword, 'UTF-8');
|
||||
error_log("规则ID {$rule['id']}: 关键词='{$keyword}', 小写='{$kwLower}', 类型={$rule['match_type']}");
|
||||
|
||||
if ($rule['match_type'] === 'equal') {
|
||||
if ($contentLower === $kwLower) {
|
||||
error_log("✓ 完全匹配成功!返回规则ID {$rule['id']}");
|
||||
return $rule;
|
||||
} else {
|
||||
error_log("✗ 完全匹配失败: '{$contentLower}' !== '{$kwLower}'");
|
||||
}
|
||||
} else { // contain
|
||||
if (mb_strpos($contentLower, $kwLower, 0, 'UTF-8') !== false) {
|
||||
error_log("✓ 包含匹配成功!返回规则ID {$rule['id']}");
|
||||
return $rule;
|
||||
} else {
|
||||
error_log("✗ 包含匹配失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error_log("未找到任何匹配规则");
|
||||
error_log("=== 规则匹配结束 ===");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单系统配置读取 / 写入
|
||||
*/
|
||||
function get_setting(string $key, $default = null)
|
||||
{
|
||||
$pdo = get_pdo();
|
||||
$stmt = $pdo->prepare("SELECT `value` FROM settings WHERE `key` = :k LIMIT 1");
|
||||
$stmt->execute([':k' => $key]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
return $default;
|
||||
}
|
||||
return $row['value'];
|
||||
}
|
||||
|
||||
function set_setting(string $key, string $value): void
|
||||
{
|
||||
$pdo = get_pdo();
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO settings(`key`, `value`, updated_at)
|
||||
VALUES(:k, :v, NOW())
|
||||
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), updated_at = NOW()
|
||||
");
|
||||
$stmt->execute([':k' => $key, ':v' => $value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用大模型 API(这里以 OpenAI 为例)
|
||||
* 如你用国内模型,可在此处替换调用逻辑。
|
||||
*/
|
||||
function call_ai(string $prompt, string $userId = ''): string
|
||||
{
|
||||
error_log("=== 调用AI开始 ===");
|
||||
error_log("用户消息: '{$prompt}'");
|
||||
error_log("AI提供商: " . AI_PROVIDER);
|
||||
|
||||
if (AI_PROVIDER === 'mock') {
|
||||
return '【自动回复】你刚才说了:' . mb_substr($prompt, 0, 100, 'UTF-8');
|
||||
}
|
||||
|
||||
// OpenAI 兼容接口
|
||||
if (AI_PROVIDER === 'openai') {
|
||||
$url = rtrim(OPENAI_API_BASE, '/') . '/chat/completions';
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: ' . 'Bearer ' . OPENAI_API_KEY,
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'model' => OPENAI_MODEL,
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => '你是一个专业的微信私域运营助手,用简洁自然的中文回复用户。',
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $prompt,
|
||||
],
|
||||
],
|
||||
'temperature' => 0.7,
|
||||
'user' => $userId ?: null,
|
||||
];
|
||||
|
||||
return do_llm_request($url, $headers, $payload);
|
||||
}
|
||||
|
||||
// DeepSeek(OpenAI 兼容风格)
|
||||
if (AI_PROVIDER === 'deepseek') {
|
||||
$url = rtrim(DEEPSEEK_API_BASE, '/') . '/chat/completions';
|
||||
error_log("请求URL: {$url}");
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . DEEPSEEK_API_KEY,
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'model' => DEEPSEEK_MODEL,
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => '你是一个简洁高效的微信助手。回复要求:1.一句话回答,不超过50字 2.不要啰嗦重复 3.直接回答问题,不要客套话 4.不要使用emoji表情',
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $prompt,
|
||||
],
|
||||
],
|
||||
'temperature' => 0.7,
|
||||
'max_tokens' => 100, // 限制回复长度
|
||||
'user' => $userId ?: null,
|
||||
];
|
||||
|
||||
return do_llm_request($url, $headers, $payload);
|
||||
}
|
||||
|
||||
// Dify(对话型应用)
|
||||
if (AI_PROVIDER === 'dify') {
|
||||
$url = rtrim(DIFY_API_BASE, '/') . '/chat-messages';
|
||||
error_log("请求URL: {$url}");
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . DIFY_API_KEY,
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'inputs' => (object)[],
|
||||
'query' => $prompt,
|
||||
'response_mode' => 'streaming',
|
||||
'user' => $userId ?: DIFY_USER,
|
||||
'conversation_id' => '',
|
||||
];
|
||||
|
||||
error_log("Dify payload: " . json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
return do_dify_request($url, $headers, $payload);
|
||||
}
|
||||
|
||||
// 其他厂商可在此扩展
|
||||
return 'AI_PROVIDER 未配置正确,请检查 config.php。';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dify 专用请求封装
|
||||
*/
|
||||
function do_dify_request(string $url, array $headers, array $payload): string
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60); // 增加超时时间,支持streaming
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_BUFFERSIZE, 128); // 小缓冲区,支持流式读取
|
||||
curl_setopt($ch, CURLOPT_NOPROGRESS, false); // 允许进度回调
|
||||
|
||||
$response = curl_exec($ch);
|
||||
if ($response === false) {
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
error_log("cURL错误: {$err}");
|
||||
return '抱歉,Dify 服务暂时不可用,请稍后再试~(网络错误:' . $err . ')';
|
||||
}
|
||||
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
error_log("HTTP状态码: {$statusCode}");
|
||||
error_log("响应内容长度: " . strlen($response));
|
||||
error_log("响应内容: {$response}");
|
||||
|
||||
// 处理空响应
|
||||
if (empty($response)) {
|
||||
error_log("Dify返回空响应");
|
||||
return '抱歉,Dify 服务返回空响应,请检查API配置。';
|
||||
}
|
||||
|
||||
// 处理streaming模式的响应(SSE格式)
|
||||
if (strpos($response, 'data:') !== false || strpos($response, 'event:') !== false) {
|
||||
error_log("检测到streaming模式响应");
|
||||
$lines = explode("\n", $response);
|
||||
$fullAnswer = '';
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (strpos($line, 'data:') === 0) {
|
||||
$jsonStr = trim(substr($line, 5));
|
||||
if (empty($jsonStr) || $jsonStr === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = json_decode($jsonStr, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
// Dify streaming格式:{"event":"message","answer":"内容"}
|
||||
if (isset($data['answer'])) {
|
||||
$fullAnswer .= $data['answer'];
|
||||
}
|
||||
// 或者 {"event":"agent_message","answer":"内容"}
|
||||
if (isset($data['event']) && $data['event'] === 'agent_message' && isset($data['answer'])) {
|
||||
$fullAnswer .= $data['answer'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($fullAnswer)) {
|
||||
error_log("Dify回复(streaming): {$fullAnswer}");
|
||||
error_log("=== 调用AI结束 ===");
|
||||
return trim($fullAnswer);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理blocking模式的响应(JSON格式)
|
||||
$data = json_decode($response, true);
|
||||
if ($statusCode >= 400 || !is_array($data)) {
|
||||
$msg = $data['message'] ?? '未知错误';
|
||||
error_log("Dify API错误: {$msg}");
|
||||
return '抱歉,Dify 服务请求失败,请稍后再试~(状态码 ' . $statusCode . ':' . $msg . ')';
|
||||
}
|
||||
|
||||
// Dify 返回格式:{"answer": "回复内容", "conversation_id": "xxx"}
|
||||
$content = $data['answer'] ?? '';
|
||||
if (!$content) {
|
||||
error_log("Dify返回内容为空");
|
||||
error_log("完整响应: " . print_r($data, true));
|
||||
return '抱歉,Dify 暂时没有合理的回复。';
|
||||
}
|
||||
error_log("Dify回复(blocking): {$content}");
|
||||
error_log("=== 调用AI结束 ===");
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用大模型 HTTP 请求封装
|
||||
*/
|
||||
function do_llm_request(string $url, array $headers, array $payload): string
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60); // 增加超时时间,支持streaming
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_BUFFERSIZE, 128); // 小缓冲区,支持流式读取
|
||||
curl_setopt($ch, CURLOPT_NOPROGRESS, false); // 允许进度回调
|
||||
|
||||
$response = curl_exec($ch);
|
||||
if ($response === false) {
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
error_log("cURL错误: {$err}");
|
||||
return '抱歉,AI 服务暂时不可用,请稍后再试~(网络错误:' . $err . ')';
|
||||
}
|
||||
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
error_log("HTTP状态码: {$statusCode}");
|
||||
error_log("响应内容: {$response}");
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if ($statusCode >= 400 || !is_array($data)) {
|
||||
$msg = $data['error']['message'] ?? '未知错误';
|
||||
error_log("API错误: {$msg}");
|
||||
return '抱歉,AI 服务请求失败,请稍后再试~(状态码 ' . $statusCode . ':' . $msg . ')';
|
||||
}
|
||||
|
||||
$content = $data['choices'][0]['message']['content'] ?? '';
|
||||
if (!$content) {
|
||||
error_log("AI返回内容为空");
|
||||
return '抱歉,AI 暂时没有合理的回复。';
|
||||
}
|
||||
error_log("AI回复: {$content}");
|
||||
error_log("=== 调用AI结束 ===");
|
||||
return trim($content);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
// Python 客户端调用此接口,将微信新消息传进来
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/ai_helper.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
json_response(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
$data = $_POST; // 兼容表单
|
||||
}
|
||||
|
||||
$content = trim((string)($data['content'] ?? ''));
|
||||
$wxUserId = trim((string)($data['wx_user_id'] ?? ''));
|
||||
$wxNickname = trim((string)($data['wx_nickname'] ?? ''));
|
||||
$isFriendRequest = (int)($data['is_friend_request'] ?? 0);
|
||||
|
||||
if ($content === '' && !$isFriendRequest) {
|
||||
json_response(['error' => 'content is empty'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = get_pdo();
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// 记录收到的消息
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO messages (wx_user_id, wx_nickname, direction, content, is_friend_request, created_at)
|
||||
VALUES (:uid, :nick, 'in', :content, :fr, NOW())
|
||||
");
|
||||
$stmt->execute([
|
||||
':uid' => $wxUserId,
|
||||
':nick' => $wxNickname,
|
||||
':content' => $content,
|
||||
':fr' => $isFriendRequest,
|
||||
]);
|
||||
$inMsgId = (int)$pdo->lastInsertId();
|
||||
|
||||
$autoOn = get_setting('auto_reply_enabled', '1') === '1';
|
||||
$replyText = '';
|
||||
$usedRuleId = null;
|
||||
|
||||
if ($autoOn) {
|
||||
// 先规则匹配
|
||||
$rule = find_rule_reply($content);
|
||||
if ($rule) {
|
||||
$replyText = (string)$rule['reply_text'];
|
||||
$usedRuleId = (int)$rule['id'];
|
||||
// 调试信息
|
||||
error_log("匹配到规则ID: {$usedRuleId}, 关键词: {$rule['keyword']}, 回复: {$replyText}");
|
||||
} else {
|
||||
// 没有规则就走 AI
|
||||
error_log("未匹配到规则,调用AI,用户消息: {$content}");
|
||||
$replyText = call_ai($content, $wxUserId);
|
||||
error_log("AI返回: {$replyText}");
|
||||
}
|
||||
}
|
||||
|
||||
$shouldReply = $autoOn && $replyText !== '';
|
||||
$replyMsgId = null;
|
||||
|
||||
if ($shouldReply) {
|
||||
$stmt2 = $pdo->prepare("
|
||||
INSERT INTO messages (wx_user_id, wx_nickname, direction, content, is_ai_reply, rule_id, created_at)
|
||||
VALUES (:uid, :nick, 'out', :content, :is_ai, :rule_id, NOW())
|
||||
");
|
||||
$stmt2->execute([
|
||||
':uid' => $wxUserId,
|
||||
':nick' => $wxNickname,
|
||||
':content' => $replyText,
|
||||
':is_ai' => 1,
|
||||
':rule_id' => $usedRuleId,
|
||||
]);
|
||||
$replyMsgId = (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
json_response([
|
||||
'success' => true,
|
||||
'should_reply' => $shouldReply,
|
||||
'reply_text' => $replyText,
|
||||
'in_message_id' => $inMsgId,
|
||||
'reply_message_id' => $replyMsgId,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($pdo) && $pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
json_response(['error' => 'server_error', 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
// 简单规则配置接口:被 Web 后台用 Ajax 调用
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/ai_helper.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$action = $_GET['action'] ?? $_POST['action'] ?? 'list';
|
||||
$pdo = get_pdo();
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
$stmt = $pdo->query("SELECT * FROM auto_reply_rules ORDER BY id DESC");
|
||||
$rules = $stmt->fetchAll();
|
||||
json_response(['success' => true, 'data' => $rules]);
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$keyword = trim((string)($_POST['keyword'] ?? ''));
|
||||
$matchType = $_POST['match_type'] ?? 'contain';
|
||||
$replyText = trim((string)($_POST['reply_text'] ?? ''));
|
||||
$isActive = (int)($_POST['is_active'] ?? 1);
|
||||
|
||||
if ($keyword === '' || $replyText === '') {
|
||||
json_response(['success' => false, 'message' => '关键词和回复内容不能为空']);
|
||||
}
|
||||
|
||||
if (!in_array($matchType, ['contain', 'equal'], true)) {
|
||||
$matchType = 'contain';
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO auto_reply_rules(keyword, match_type, reply_text, is_active, created_at, updated_at)
|
||||
VALUES(:kw, :mt, :rt, :act, NOW(), NOW())
|
||||
");
|
||||
$stmt->execute([
|
||||
':kw' => $keyword,
|
||||
':mt' => $matchType,
|
||||
':rt' => $replyText,
|
||||
':act' => $isActive,
|
||||
]);
|
||||
json_response(['success' => true]);
|
||||
break;
|
||||
|
||||
case 'toggle':
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$isActive = (int)($_POST['is_active'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
json_response(['success' => false, 'message' => '参数错误']);
|
||||
}
|
||||
$stmt = $pdo->prepare("UPDATE auto_reply_rules SET is_active = :act, updated_at = NOW() WHERE id = :id");
|
||||
$stmt->execute([':act' => $isActive, ':id' => $id]);
|
||||
json_response(['success' => true]);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
json_response(['success' => false, 'message' => '参数错误']);
|
||||
}
|
||||
$stmt = $pdo->prepare("DELETE FROM auto_reply_rules WHERE id = :id");
|
||||
$stmt->execute([':id' => $id]);
|
||||
json_response(['success' => true]);
|
||||
break;
|
||||
|
||||
case 'settings_get':
|
||||
$autoOn = get_setting('auto_reply_enabled', '1');
|
||||
json_response(['success' => true, 'auto_reply_enabled' => $autoOn === '1']);
|
||||
break;
|
||||
|
||||
case 'settings_set':
|
||||
$autoOn = ($_POST['auto_reply_enabled'] ?? '1') === '1' ? '1' : '0';
|
||||
set_setting('auto_reply_enabled', $autoOn);
|
||||
json_response(['success' => true]);
|
||||
break;
|
||||
|
||||
case 'messages_recent':
|
||||
$limit = max(1, min(100, (int)($_GET['limit'] ?? 50)));
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT * FROM messages
|
||||
ORDER BY id DESC
|
||||
LIMIT :lim
|
||||
");
|
||||
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll();
|
||||
json_response(['success' => true, 'data' => $rows]);
|
||||
break;
|
||||
|
||||
default:
|
||||
json_response(['success' => false, 'message' => '未知操作']);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
json_response(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
// 基础配置文件,请根据你的环境修改
|
||||
|
||||
// 数据库配置
|
||||
define('DB_HOST', '127.0.0.1');
|
||||
define('DB_PORT', '3306');
|
||||
define('DB_NAME', 'shiliu_ai');
|
||||
define('DB_USER', 'root');
|
||||
define('DB_PASS', 'root');
|
||||
define('DB_CHARSET', 'utf8mb4');
|
||||
|
||||
// AI 大模型配置(以 OpenAI / DeepSeek / Dify 为例,可自行替换为其他厂商)
|
||||
// 可选值:mock / openai / deepseek / dify
|
||||
define('AI_PROVIDER', 'dify'); // 先用deepseek,Dify有401错误
|
||||
|
||||
// OpenAI 兼容接口配置
|
||||
define('OPENAI_API_KEY', 'YOUR_OPENAI_API_KEY_HERE');
|
||||
define('OPENAI_API_BASE', 'https://api.openai.com/v1');
|
||||
define('OPENAI_MODEL', 'gpt-4.1-mini');
|
||||
|
||||
// DeepSeek 兼容接口配置(请在这里填入你自己的 key)
|
||||
define('DEEPSEEK_API_KEY', 'sk-012531a0108d4fe086fcba34e1c758fe');
|
||||
define('DEEPSEEK_API_BASE', 'https://api.deepseek.com');
|
||||
define('DEEPSEEK_MODEL', 'deepseek-chat');
|
||||
|
||||
// Dify 配置(请填入你的 Dify API Key 和 URL)
|
||||
define('DIFY_API_KEY', 'app-a9dofsiQi4e157uDYTx8Lrja'); // 在Dify后台获取
|
||||
define('DIFY_API_BASE', 'http://47.92.48.126/v1'); // 修改:v1 → api
|
||||
define('DIFY_USER', 'wechat_user'); // 用户标识
|
||||
|
||||
// 系统基础配置
|
||||
define('APP_TIMEZONE', 'Asia/Shanghai');
|
||||
date_default_timezone_set(APP_TIMEZONE);
|
||||
|
||||
// 简单的 JSON 输出工具
|
||||
function json_response($data, int $code = 200)
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||