feat: v1.2.0 - 图片去重与管理、微信机器人优化、搜索设置可配置

主要功能:
- 图片上传时 OCR 内容去重(3个上传端点统一使用公共函数 _check_ocr_duplicate)
- 图片管理 Tab:展示所有图片、手动删除、一键去重
- 搜索结果详情弹窗增加删除按钮(带确认弹窗)
- 图片管理卡片点击查看详情(复用 showOcrDetailModal)
- 搜索限制和 LLM 批量判断数量可通过网站设置
- MiniMax API 调用添加 reasoning_split=True
- 企业微信机器人:WebSocket 长连接、图片搜索、配置化搜索数量
- 版本号升级至 1.2.0
This commit is contained in:
EduBrain Dev
2026-04-13 22:25:08 +08:00
commit b17786b57b
56 changed files with 9300 additions and 0 deletions

362
app/wework_bot.py Normal file
View File

@@ -0,0 +1,362 @@
"""
企业微信智能机器人服务WebSocket 长连接模式)
使用官方 SDK (wecom-aibot-python-sdk) 的 WSClient 类,
封装了连接管理、心跳、断线重连等能力。
作为独立进程运行python -m app.wework_bot
"""
from __future__ import annotations
import base64
import hashlib
import json
import logging
import math
import os
import re
import httpx
from aibot import WSClient, WSClientOptions, generate_req_id
from dotenv import load_dotenv
# 加载 .env 文件(独立运行时需要)
load_dotenv()
logger = logging.getLogger("wework_bot")
# 分片上传参数(参考官方文档)
_CHUNK_SIZE = 512 * 1024 # 单个分片最大 512KBBase64 编码前)
class WeComBotService:
"""企业微信智能机器人服务WebSocket 长连接模式)"""
def __init__(self):
self.bot_id = os.getenv("WEWORK_BOT_ID", "")
self.bot_secret = os.getenv("WEWORK_BOT_SECRET", "")
self.edubrain_base_url = os.getenv(
"EDUBRAIN_BASE_URL", "http://localhost:8765"
)
self.search_limit = int(os.getenv("WEWORK_BOT_SEARCH_LIMIT", "3"))
self.enabled = os.getenv("WEWORK_BOT_ENABLED", "false").lower() == "true"
self.ws_client: WSClient | None = None
def start(self):
"""启动机器人服务"""
if not self.enabled:
logger.info("企业微信机器人未启用 (WEWORK_BOT_ENABLED=false)")
return
if not self.bot_id or not self.bot_secret:
logger.error("WEWORK_BOT_ID 或 WEWORK_BOT_SECRET 未配置")
return
self.ws_client = WSClient(
WSClientOptions(
bot_id=self.bot_id,
secret=self.bot_secret,
)
)
# 注册事件
self.ws_client.on("authenticated", self._on_authenticated)
self.ws_client.on("message.text", self._on_text_message)
self.ws_client.on("event.enter_chat", self._on_enter_chat)
self.ws_client.on("disconnected", self._on_disconnected)
self.ws_client.on("error", self._on_error)
logger.info("启动企业微信智能机器人...")
self.ws_client.run()
# ──────────────────────────── 事件处理 ────────────────────────────
def _on_authenticated(self):
"""认证成功回调"""
logger.info("企业微信机器人认证成功")
async def _on_enter_chat(self, frame: dict):
"""进入会话事件 -> 回复欢迎语"""
await self.ws_client.reply_welcome(
frame,
{
"msgtype": "text",
"text": {
"content": "你好!我是知识库图片搜索助手,发送关键词即可搜索相关图片。"
},
},
)
async def _on_text_message(self, frame: dict):
"""收到文本消息 -> 搜索图片 -> 流式回复文本 + 逐张发送图片"""
body = frame.get("body", {})
text_content = body.get("text", {}).get("content", "")
# 去掉 @机器人 的前缀
text_content = re.sub(r"@.*?\s*", "", text_content).strip()
if not text_content:
stream_id = generate_req_id("stream")
await self.ws_client.reply_stream(
frame, stream_id, "请输入搜索关键词", True
)
return
# 清理口语化前缀,提取关键词
keyword = re.sub(
r"(帮我|请|找|搜|搜索|查|查一下|来|给我|要|想要|的?图|图片|相关|关于|一下)",
"", text_content,
).strip()
if not keyword:
keyword = text_content # 兜底
stream_id = generate_req_id("stream")
# 立即回复:开始搜索
await self.ws_client.reply_stream(
frame,
stream_id,
f"\U0001f50d 正在搜索「{keyword}」...",
False,
)
# 调用 EduBrain 搜索
try:
results = await self._search_images(keyword)
if not results:
await self.ws_client.reply_stream(
frame,
stream_id,
f"未找到与「{text_content}」相关的图片",
True,
)
return
# 构建文本回复内容
content_parts = [f"找到 {len(results)} 张相关图片:"]
for i, r in enumerate(results, 1):
preview = r.get("ocr_text_preview", "")[:80]
content_parts.append(f"\n{i}. {preview}...")
content = "\n".join(content_parts)
# 完成流式消息(仅文本,不使用 msg_item
await self.ws_client.reply_stream(
frame,
stream_id,
content,
True,
)
# 逐张上传临时素材并发送图片消息
for i, r in enumerate(results, 1):
b64 = r.get("image_base64", "")
if not b64:
continue
try:
# 解码 base64 获取原始图片数据
image_data = base64.b64decode(b64)
file_name = r.get("image_url", f"image_{i}.jpg")
# 上传临时素材(分片上传)
media_id = await self._upload_temp_media(
image_data, file_name
)
if not media_id:
logger.warning("%d 张图片上传失败,跳过", i)
continue
# 发送图片消息(通过 aibot_respond_msg
await self.ws_client.reply(
frame,
{
"msgtype": "image",
"image": {"media_id": media_id},
},
)
logger.info("已发送第 %d 张图片 (media_id: %s)", i, media_id)
except Exception as e:
logger.error("发送第 %d 张图片失败: %s", i, e, exc_info=True)
except Exception as e:
logger.error("搜索失败: %s", e, exc_info=True)
await self.ws_client.reply_stream(
frame,
stream_id,
f"搜索出错:{e}",
True,
)
def _on_disconnected(self, reason: str):
"""连接断开回调"""
logger.warning("企业微信连接断开: %s", reason)
def _on_error(self, error):
"""错误回调"""
logger.error("企业微信机器人错误: %s", error)
# ──────────────────────── 上传临时素材 ────────────────────────
async def _upload_temp_media(
self, file_data: bytes, filename: str
) -> str | None:
"""
通过 WebSocket 通道上传临时素材(三步分片上传)。
流程aibot_upload_media_init → aibot_upload_media_chunk × N → aibot_upload_media_finish
Args:
file_data: 文件原始二进制数据
filename: 文件名
Returns:
成功返回 media_id失败返回 None
"""
total_size = len(file_data)
total_chunks = math.ceil(total_size / _CHUNK_SIZE)
md5 = hashlib.md5(file_data).hexdigest()
ws = self.ws_client._ws_manager
# 第一步:上传初始化
init_req_id = generate_req_id("upload")
try:
init_result = await ws.send_reply(
init_req_id,
{
"type": "image",
"filename": filename,
"total_size": total_size,
"total_chunks": total_chunks,
"md5": md5,
},
cmd="aibot_upload_media_init",
)
except Exception as e:
logger.error("上传初始化失败: %s", e)
return None
upload_id = init_result.get("body", {}).get("upload_id", "")
if not upload_id:
logger.error("上传初始化未返回 upload_id: %s", init_result)
return None
logger.info(
"上传初始化成功: upload_id=%s, total_size=%d, chunks=%d",
upload_id, total_size, total_chunks,
)
# 第二步:分片上传
for chunk_idx in range(total_chunks):
start = chunk_idx * _CHUNK_SIZE
end = min(start + _CHUNK_SIZE, total_size)
chunk_data = file_data[start:end]
chunk_b64 = base64.b64encode(chunk_data).decode("utf-8")
chunk_req_id = generate_req_id("upload_chunk")
try:
await ws.send_reply(
chunk_req_id,
{
"upload_id": upload_id,
"chunk_index": chunk_idx, # 分片索引从 0 开始
"base64_data": chunk_b64,
},
cmd="aibot_upload_media_chunk",
)
except Exception as e:
logger.error(
"分片上传失败 (chunk %d/%d): %s",
chunk_idx + 1, total_chunks, e,
)
return None
logger.info("所有分片上传完成: upload_id=%s", upload_id)
# 第三步:完成上传
finish_req_id = generate_req_id("upload_finish")
try:
finish_result = await ws.send_reply(
finish_req_id,
{"upload_id": upload_id},
cmd="aibot_upload_media_finish",
)
except Exception as e:
logger.error("完成上传失败: %s", e)
return None
media_id = finish_result.get("body", {}).get("media_id", "")
if not media_id:
logger.error("完成上传未返回 media_id: %s", finish_result)
return None
logger.info("上传临时素材成功: media_id=%s", media_id)
return media_id
# ──────────────────────────── 搜索调用 ────────────────────────────
async def _get_search_limit(self) -> int:
"""从后端 API 实时获取当前 search_limit 配置"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{self.edubrain_base_url}/api/v1/settings")
if resp.status_code == 200:
data = resp.json()
return data.get("search_limit", self.search_limit)
except Exception as e:
logger.warning("获取 search_limit 失败,使用默认值: %s", e)
return self.search_limit
async def _search_images(self, keyword: str) -> list:
"""
调用 EduBrain 搜索接口SSE 流式),收集所有匹配结果。
Args:
keyword: 搜索关键词
Returns:
匹配结果列表
"""
results = []
url = f"{self.edubrain_base_url}/api/v1/images/search"
limit = await self._get_search_limit()
async with httpx.AsyncClient(timeout=300.0) as client:
async with client.stream(
"GET",
url,
params={
"keyword": keyword,
"limit": limit,
"sort": "time_desc",
},
) as resp:
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
try:
data = json.loads(line[6:])
if data.get("type") == "result":
results.append(data)
elif data.get("type") == "done":
break
except json.JSONDecodeError:
continue
return results
def run_wework_bot():
"""入口函数"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
service = WeComBotService()
service.start()
if __name__ == "__main__":
run_wework_bot()