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

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

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

🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
figmar
2026-05-30 14:57:45 +08:00
commit 81115dc23d
129 changed files with 56398 additions and 0 deletions
+15
View File
@@ -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__/*
View File
View File
+134
View File
@@ -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()
+157
View File
@@ -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)
+187
View File
@@ -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
View File
+25
View File
@@ -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)
+137
View File
@@ -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})
+149
View File
@@ -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)
+81
View File
@@ -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
+218
View File
@@ -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]}"
File diff suppressed because it is too large Load Diff
+124
View File
@@ -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"
]
+346
View File
@@ -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}") # 启动异常输出
+373
View File
@@ -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}")
View File
+238
View File
@@ -0,0 +1,238 @@
import os
import sys
import threading
from urllib import parse, request
import requests
from PySide6.QtCore import QTimer, Qt, QObject, Slot
from PySide6.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QMessageBox
from app.application.services import AppConfig, BackendRuntime, FrontendRuntime
from app.presentation.shell import LoadingWindowController, WebViewShellController
print(f"[GUI] Python executable: {sys.executable}")
try:
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWebChannel import QWebChannel
WEB_ENGINE_AVAILABLE = True
except Exception:
WEB_ENGINE_AVAILABLE = False
QWebEngineView = None
QWebChannel = None
class WindowBridge(QObject):
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
@Slot()
def open_devtools(self):
self.main_window.open_devtools()
@Slot()
def minimize(self):
self.main_window.showMinimized()
@Slot()
def maximize_or_restore(self):
if self.main_window.isMaximized():
self.main_window.showNormal()
else:
self.main_window.showMaximized()
@Slot()
def close_window(self):
self.main_window.close()
@Slot()
def start_move(self):
wh = self.main_window.windowHandle()
if wh is not None:
wh.startSystemMove()
class MainWindow(QMainWindow):
def post_settings(self, **kwargs):
if not self.base_url:
return
try:
payload = {"action": "settings_set"}
for k, v in kwargs.items():
payload[k] = "1" if v is True else "0" if v is False else str(v)
data = parse.urlencode(payload).encode("utf-8")
req = request.Request(self.base_url + "/api/rules", data=data, method="POST")
with request.urlopen(req, timeout=2):
pass
except Exception:
pass
def __init__(self):
super().__init__()
self.setWindowTitle("识流 AI 助手")
self.resize(1280, 920)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.Window)
self.hide()
self.config = AppConfig()
self.backend_runtime = BackendRuntime(self.config)
self.frontend_runtime = FrontendRuntime(self.config, self.backend_runtime)
self.base_url = os.getenv("APP_BASE_URL", self.backend_runtime.base_url)
self.backend_process = None
self.backend_started_by_gui = False
self.backend_boot_thread = None
self.web_channel = None
self.window_bridge = None
self.loading = LoadingWindowController()
self.web_shell = None
self.frontend_entry_url = ""
central = QWidget()
central.setStyleSheet("background:#eef2f7;")
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
self.web = None
self.devtools_view = None
if WEB_ENGINE_AVAILABLE:
self.web = QWebEngineView()
self.web.setStyleSheet("background:#eef2f7;")
root.addWidget(self.web, 1)
self.web_channel = QWebChannel(self.web.page())
self.window_bridge = WindowBridge(self)
self.web_channel.registerObject("windowBridge", self.window_bridge)
self.web.page().setWebChannel(self.web_channel)
self.web.loadFinished.connect(self.on_main_page_loaded)
self.web_shell = WebViewShellController(self.web)
self.listener_sync_timer = QTimer(self)
self.listener_sync_timer.setInterval(30000)
self.listener_sync_timer.timeout.connect(self.sync_listener_state)
self._syncing_listener_state = False
self.backend_ready_timer = QTimer(self)
self.backend_ready_timer.setInterval(100)
self.backend_ready_timer.timeout.connect(self.consume_backend_boot_result)
self._backend_boot_result = None
self.loading.show()
self.start_backend()
def get_settings(self):
if not self.base_url:
return {}
try:
url = self.base_url + "/api/rules?action=settings_get"
with request.urlopen(url, timeout=2) as resp:
data = resp.read().decode("utf-8")
import json
payload = json.loads(data)
return payload if isinstance(payload, dict) else {}
except Exception:
return {}
def sync_listener_state(self):
if self._syncing_listener_state:
return
self._syncing_listener_state = True
try:
if not self.base_url:
return
requests.get(self.base_url + "/api/bot/status", timeout=2)
except Exception:
pass
finally:
self._syncing_listener_state = False
def start_backend(self):
self.base_url = self.backend_runtime.base_url
self._backend_boot_result = None
self.backend_boot_thread = threading.Thread(target=self._boot_backend_worker, daemon=True)
self.backend_boot_thread.start()
self.backend_ready_timer.start()
def _boot_backend_worker(self):
try:
if self.backend_runtime.is_backend_alive():
self.backend_started_by_gui = False
self._backend_boot_result = {"ok": True, "url": self.base_url}
return
process = self.backend_runtime.launch_backend_process()
self.backend_process = process
self.backend_started_by_gui = True
ok, message = self.frontend_runtime.wait_for_backend(process=process)
if ok:
self._backend_boot_result = {"ok": True, "url": self.base_url}
else:
self._backend_boot_result = {"ok": False, "message": message}
except Exception as exc:
self._backend_boot_result = {"ok": False, "message": str(exc)}
def consume_backend_boot_result(self):
if self._backend_boot_result is None:
return
result = self._backend_boot_result
self._backend_boot_result = None
self.backend_ready_timer.stop()
if result.get("ok"):
self.on_backend_started(result["url"])
return
self.on_backend_failed(result.get("message") or "后台启动失败")
def on_backend_started(self, url):
self.base_url = url
self.frontend_entry_url = self.frontend_runtime.resolve_frontend_entry_url(url)
self.reload_admin_page()
self.listener_sync_timer.start()
def on_backend_failed(self, msg):
self.loading.close()
QMessageBox.critical(self, "后台错误", msg)
def reload_admin_page(self):
self.web_shell.load(self.frontend_entry_url)
def on_main_page_loaded(self, ok):
if not self.web_shell.mark_loaded(ok):
return
self.loading.finish_with(self._finish_show_main)
def _finish_show_main(self):
if not self.web_shell.can_finish():
return
self.web_shell.finish()
self.show()
self.raise_()
self.activateWindow()
QTimer.singleShot(60, self.loading.close)
def open_devtools(self):
if self.web is None:
return
if self.devtools_view is None:
self.devtools_view = QWebEngineView()
self.devtools_view.setWindowTitle("页面调试控制台")
self.devtools_view.resize(1100, 760)
self.web.page().setDevToolsPage(self.devtools_view.page())
self.devtools_view.show()
self.devtools_view.raise_()
self.devtools_view.activateWindow()
def closeEvent(self, event):
try:
self.listener_sync_timer.stop()
except Exception:
pass
try:
self.backend_ready_timer.stop()
except Exception:
pass
try:
if self.backend_started_by_gui:
self.backend_runtime.terminate_backend_process(self.backend_process)
except Exception:
pass
super().closeEvent(event)
+95
View File
@@ -0,0 +1,95 @@
import time
from PySide6.QtCore import QTimer, Qt, QUrl
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QProgressBar
class LoadingWindowController:
def __init__(self):
self.loading_window = None
self.loading_min_ms = 700
self.loading_started_ms = 0.0
def show(self):
self.loading_started_ms = time.time() * 1000
if self.loading_window is not None:
self.loading_window.show()
self.loading_window.raise_()
self.loading_window.activateWindow()
return self.loading_window
win = QWidget()
win.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
win.setAttribute(Qt.WA_DeleteOnClose, True)
win.resize(560, 220)
screen = QGuiApplication.primaryScreen()
if screen is not None:
geo = screen.availableGeometry()
win.move(geo.center().x() - win.width() // 2, geo.center().y() - win.height() // 2)
win.setStyleSheet("background:qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #f8fbff, stop:1 #eef4fb);border:1px solid #dbe7f5;border-radius:16px;")
lay = QVBoxLayout(win)
lay.setContentsMargins(30, 28, 30, 28)
lay.setSpacing(10)
title = QLabel("识流 AI 助手")
title.setStyleSheet("font-size:18px;font-weight:700;color:#0f172a;")
label = QLabel("正在启动服务与管理台…")
label.setStyleSheet("font-size:14px;color:#334155;")
hint = QLabel("首次启动会稍慢一点,请稍候")
hint.setStyleSheet("font-size:12px;color:#64748b;")
bar = QProgressBar()
bar.setRange(0, 0)
bar.setTextVisible(False)
bar.setFixedHeight(8)
bar.setStyleSheet("QProgressBar{background:#e5edf8;border:0;border-radius:4px;}QProgressBar::chunk{background:qlineargradient(x1:0,y1:0,x2:1,y2:0, stop:0 #22c1c3, stop:1 #3b82f6);border-radius:4px;}")
lay.addWidget(title)
lay.addWidget(label)
lay.addSpacing(4)
lay.addWidget(bar)
lay.addWidget(hint)
lay.addStretch(1)
self.loading_window = win
self.loading_window.show()
self.loading_window.raise_()
self.loading_window.activateWindow()
return self.loading_window
def close(self):
if self.loading_window is not None:
self.loading_window.close()
self.loading_window = None
def finish_with(self, callback):
elapsed = int(time.time() * 1000 - self.loading_started_ms) if self.loading_started_ms else self.loading_min_ms
remain = max(0, self.loading_min_ms - elapsed)
QTimer.singleShot(remain + 120, callback)
class WebViewShellController:
def __init__(self, web_view):
self.web_view = web_view
self.main_page_ready = False
self.expecting_admin_load = False
def load(self, entry_url):
if self.web_view is None:
return
self.main_page_ready = False
self.expecting_admin_load = True
self.web_view.setStyleSheet("background:#eef2f7;")
self.web_view.load(QUrl(entry_url))
def mark_loaded(self, ok):
if not self.expecting_admin_load or not ok:
return False
self.main_page_ready = True
return True
def can_finish(self):
return self.main_page_ready
def finish(self):
self.expecting_admin_load = False
+46
View File
@@ -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)
+1
View File
@@ -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};
+1
View File
@@ -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}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -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>
+13
View File
@@ -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>
+1817
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+49
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+4941
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
+3
View File
@@ -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"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+356
View File
@@ -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);
}
});
}
+6
View File
@@ -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();
}
+51
View File
@@ -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"
}
}
}
}
+149
View File
@@ -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>
+43
View File
@@ -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', {})
}
+1
View File
@@ -0,0 +1 @@
export { getJson, postForm } from './http'
+57
View File
@@ -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))
}
+5
View File
@@ -0,0 +1,5 @@
import { getJson } from './http'
export function getRecentMessages(limit = 50) {
return getJson(`/api/rules?action=messages_recent&limit=${limit}`)
}
+17
View File
@@ -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 })
}
+9
View File
@@ -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 })
}
+55
View File
@@ -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>
+18
View File
@@ -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>
+177
View File
@@ -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,
}
}
+25
View File
@@ -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,
}
}
+132
View File
@@ -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,
}
}
+44
View File
@@ -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'),
}
}
+10
View File
@@ -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 },
]
+21
View File
@@ -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 },
]
+33
View File
@@ -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()
+299
View File
@@ -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;
}
+12
View File
@@ -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}`
}
+228
View File
@@ -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>
+31
View File
@@ -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>
+115
View File
@@ -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>
+76
View File
@@ -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>
+17
View File
@@ -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,
},
})
View File
+143
View File
@@ -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` - 旧版机器人日志(如果使用)
---
**祝使用愉快!🎊**
+310
View File
@@ -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);
}
// DeepSeekOpenAI 兼容风格)
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);
}
Binary file not shown.
+97
View File
@@ -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);
}
+99
View File
@@ -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()]);
}
+44
View File
@@ -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'); // 先用deepseekDify有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;
}

Some files were not shown because too many files have changed in this diff Show More