Add HeXi bot codebase: custom plugins, web frontends, tests

- hexi core: message handling, rate limiting, cooldown, plugin manager
- Custom plugins: BF stats, daily check-in, quotes, persona cards, etc.
- Community plugins vendored under hexi/plugins with local fixes
- Web admin frontends (learning-chat, persona-admin), unified hexi/web
- Tests for rate_limit/cooldown/memes/persona; poetry.lock

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 13:13:40 +08:00
co-authored by Claude
parent 1783c60afa
commit b61d09f09f
3201 changed files with 160436 additions and 171 deletions
@@ -0,0 +1,137 @@
from nonebot import require
require("nonebot_plugin_orm")
import asyncio
import random
import time
from nonebot import on_message, require, logger, get_adapter
from nonebot.adapters.onebot.v11 import (
GroupMessageEvent,
GROUP,
Message,
ActionFailed,
Adapter,
)
from nonebot.params import Arg
from nonebot.plugin import PluginMetadata
from nonebot.rule import Rule
from nonebot.typing import T_State
from nonebot_plugin_orm import get_session
from sqlalchemy import insert
from .handler import LearningChat
from .models import ChatMessage
from .config import config_manager, NICKNAME
from . import web_api
from . import web_hub
from hexi.web_plugin_registry import register_web_plugin
require("nonebot_plugin_apscheduler")
from nonebot_plugin_apscheduler import scheduler
# 注册到统一 Web 管理台(/hub):hub 启动时挂载 /api/learning_chat
register_web_plugin(
"learning_chat",
"群聊学习",
"chat",
lambda: web_hub.build_admin_app(),
module_name=__name__,
)
__plugin_meta__ = PluginMetadata(
name="群聊学习",
description="学习群友们的发言、复读以及主动发言",
usage="详见README",
type="application",
homepage="https://github.com/CMHopeSunshine/nonebot-plugin-learning-chat",
supported_adapters={"~onebot.v11"},
extra={"author": "惜月"},
)
async def ChatRule(event: GroupMessageEvent, state: T_State) -> bool:
if answers := await LearningChat(event).answer():
state["answers"] = answers
return True
return False
learning_chat = on_message(
priority=99,
block=False,
rule=Rule(ChatRule),
permission=GROUP,
state={
"pm_name": "群聊学习",
"pm_description": "(被动技能)bot会学习群友们的发言",
"pm_usage": "群聊学习",
"pm_priority": 1,
},
)
@learning_chat.handle()
async def _(event: GroupMessageEvent, answers=Arg("answers")):
for answer in answers:
try:
logger.info(
"群聊学习", f'{NICKNAME}将向群<m>{event.group_id}</m>回复<m>"{answer}"</m>'
)
msg = await learning_chat.send(Message(answer))
async with get_session() as session:
session.add(ChatMessage(
group_id=event.group_id,
user_id=event.self_id,
message_id=msg["message_id"],
message=answer,
raw_message=answer,
time=int(time.time()),
plain_text=Message(answer).extract_plain_text(),
))
await session.commit()
await asyncio.sleep(random.random() + 0.5)
except ActionFailed:
logger.info(
"群聊学习",
f'{NICKNAME}向群<m>{event.group_id}</m>的回复<m>"{answer}"</m>发送<r>失败,可能处于风控中</r>',
)
@scheduler.scheduled_job("interval", minutes=3, misfire_grace_time=5)
async def speak_up():
if not config_manager.config.total_enable:
return
try:
bots = get_adapter(Adapter).bots
if len(bots) == 0:
return
bot = list(bots.values())[0]
except ValueError:
return
if not (speak := await LearningChat.speak(int(bot.self_id))):
return
group_id, messages = speak
for msg in messages:
try:
logger.info("群聊学习", f'{NICKNAME}向群<m>{group_id}</m>主动发言<m>"{msg}"</m>')
send_result = await bot.send_group_msg(
group_id=group_id, message=Message(msg)
)
async with get_session() as session:
session.add(ChatMessage(
group_id=group_id,
user_id=int(bot.self_id),
message_id=send_result["message_id"],
message=msg,
raw_message=msg,
time=int(time.time()),
plain_text=Message(msg).extract_plain_text(),
))
await session.commit()
await asyncio.sleep(random.randint(2, 4))
except ActionFailed:
logger.info(
"群聊学习",
f'{NICKNAME}向群<m>{group_id}</m>主动发言<m>"{msg}"</m><r>发送失败,可能处于风控中</r>',
)
@@ -0,0 +1,115 @@
from typing import List, Dict
from pathlib import Path
from pydantic import BaseModel, Field
from nonebot import get_driver, logger
from nonebot.utils import escape_tag
from ruamel import yaml
CONFIG_PATH = Path(__file__).resolve().parents[2] / "data" / "learning_chat" / "learning_chat.yml"
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
driver = get_driver()
try:
SUPERUSERS: List[int] = [int(s) for s in driver.config.superusers]
except Exception:
SUPERUSERS = []
logger.warning("请在.env.prod文件中中配置超级用户SUPERUSERS")
try:
NICKNAME: str = list(driver.config.nickname)[0]
except Exception:
NICKNAME = "bot"
COMMAND_START = driver.config.command_start.copy()
if "" in COMMAND_START:
COMMAND_START.remove("")
class ChatGroupConfig(BaseModel):
enable: bool = Field(default=True, alias="群聊学习开关")
ban_words: List[str] = Field(default_factory=list, alias="屏蔽词")
ban_users: List[int] = Field(default_factory=list, alias="屏蔽用户")
answer_threshold: int = Field(default=4, alias="回复阈值")
answer_threshold_weights: List[int] = Field(default=[10, 30, 60], alias="回复阈值权重")
repeat_threshold: int = Field(default=3, alias="复读阈值")
break_probability: float = Field(default=0.25, alias="打断复读概率")
speak_enable: bool = Field(default=True, alias="主动发言开关")
speak_threshold: int = Field(default=5, alias="主动发言阈值")
speak_min_interval: int = Field(default=300, alias="主动发言最小间隔")
speak_continuously_probability: float = Field(default=0.5, alias="连续主动发言概率")
speak_continuously_max_len: int = Field(default=3, alias="最大连续主动发言句数")
speak_poke_probability: float = Field(default=0.5, alias="主动发言附带戳一戳概率")
def update(self, **kwargs):
for key, value in kwargs.items():
if key in self.__fields__:
self.__setattr__(key, value)
class ChatConfig(BaseModel):
total_enable: bool = Field(default=True, alias="群聊学习总开关")
enable_web: bool = Field(default=True, alias="启用后台管理")
web_username: str = Field(default="chat", alias="后台管理用户名")
web_password: str = Field(default="admin", alias="后台管理密码")
web_secret_key: str = Field(
default="49c294d32f69b732ef6447c18379451ce1738922a75cd1d4812ef150318a2ed0",
alias="后台管理token密钥",
)
ban_words: List[str] = Field(default_factory=list, alias="全局屏蔽词")
ban_users: List[int] = Field(default_factory=list, alias="全局屏蔽用户")
KEYWORDS_SIZE: int = Field(default=3, alias="单句关键词分词数量")
cross_group_threshold: int = Field(default=3, alias="跨群回复阈值")
learn_max_count: int = Field(default=6, alias="最高学习次数")
dictionary: List[str] = Field(default_factory=list, alias="自定义词典")
group_config: Dict[int, ChatGroupConfig] = Field(default_factory=dict, alias="分群配置")
def update(self, **kwargs):
for key, value in kwargs.items():
if key in self.__fields__:
self.__setattr__(key, value)
class ChatConfigManager:
def __init__(self):
self.file_path = CONFIG_PATH
if self.file_path.exists():
self.config = ChatConfig.parse_obj(
yaml.load(
self.file_path.read_text(encoding="utf-8"), Loader=yaml.Loader
)
)
else:
self.config = ChatConfig()
self.save()
def get_group_config(self, group_id: int) -> ChatGroupConfig:
if group_id not in self.config.group_config:
self.config.group_config[group_id] = ChatGroupConfig()
self.save()
return self.config.group_config[group_id]
@property
def config_list(self) -> List[str]:
return list(self.config.dict(by_alias=True).keys())
def save(self):
with self.file_path.open("w", encoding="utf-8") as f:
yaml.dump(
self.config.dict(by_alias=True),
f,
indent=2,
Dumper=yaml.RoundTripDumper,
allow_unicode=True,
)
config_manager = ChatConfigManager()
def log_debug(command: str, info: str):
logger.opt(colors=True).debug(f"<u><y>[{command}]</y></u>{escape_tag(info)}")
def log_info(command: str, info: str):
logger.opt(colors=True).info(f"<u><y>[{command}]</y></u>{escape_tag(info)}")
@@ -0,0 +1,780 @@
import asyncio
import datetime
import random
import re
import time
from dataclasses import dataclass
from functools import cached_property, cmp_to_key
# from ..nonebot_plugin_hexi_core.MessageUtils import send_poke
try:
import jieba_fast.analyse as jieba_analyse
except ImportError:
import jieba.analyse as jieba_analyse
from typing import List, Union, Optional, Tuple
from enum import IntEnum, auto
from nonebot import get_adapter
from nonebot.adapters.onebot.v11 import GroupMessageEvent, MessageSegment, ActionFailed, Adapter
from sqlalchemy import select, delete, func
from nonebot_plugin_orm import get_session
from .models import ChatBlackList, ChatContext, ChatAnswer, ChatMessage
from .config import (
config_manager,
SUPERUSERS,
NICKNAME,
COMMAND_START,
log_info,
log_info,
)
chat_config = config_manager.config
NO_PERMISSION_WORDS = [f"{NICKNAME}就喜欢说这个,哼!", f"你管得着{NICKNAME}吗!"]
ENABLE_WORDS = [f"{NICKNAME}会尝试学你们说怪话!", f"好的呢,让{NICKNAME}学学你们的说话方式~"]
DISABLE_WORDS = [f"好好好,{NICKNAME}不学说话就是了!", f"果面呐噻,{NICKNAME}以后不学了..."]
SORRY_WORDS = [
f"{NICKNAME}知道错了...达咩!",
f"{NICKNAME}不会再这么说了...",
f"果面呐噻,{NICKNAME}说错话了...",
]
DOUBT_WORDS = [f"{NICKNAME}有说什么奇怪的话吗?"]
BREAK_REPEAT_WORDS = ["打断复读", "打断!"]
ALL_WORDS = (
NO_PERMISSION_WORDS
+ SORRY_WORDS
+ DOUBT_WORDS
+ ENABLE_WORDS
+ DISABLE_WORDS
+ BREAK_REPEAT_WORDS
)
class Result(IntEnum):
Learn = auto()
Pass = auto()
Repeat = auto()
Ban = auto()
SetEnable = auto()
SetDisable = auto()
@dataclass
class MessageData:
"""用于在 session 外安全传递消息数据的普通数据类"""
group_id: int
user_id: int
message_id: int
message: str
raw_message: str
plain_text: str
time: int
@cached_property
def is_plain_text(self) -> bool:
return "[CQ:" not in self.message
@cached_property
def keyword_list(self) -> List[str]:
if not self.is_plain_text and not len(self.plain_text):
return []
return jieba_analyse.extract_tags(self.plain_text, topK=chat_config.KEYWORDS_SIZE)
@cached_property
def keywords(self) -> str:
if not self.is_plain_text and not len(self.plain_text):
return self.message
return (
self.message if len(self.keyword_list) < 2 else " ".join(self.keyword_list)
)
def _db_to_message_data(m: ChatMessage) -> MessageData:
"""将数据库 ChatMessage 对象转为普通 MessageData"""
return MessageData(
group_id=m.group_id,
user_id=m.user_id,
message_id=m.message_id,
message=m.message,
raw_message=m.raw_message,
plain_text=m.plain_text,
time=m.time,
)
class LearningChat:
def __init__(self, event: GroupMessageEvent):
if event.reply:
self.reply = event.reply
self.data = MessageData(
group_id=event.group_id,
user_id=event.user_id,
message_id=event.message_id,
message=re.sub(
r"(\[CQ:at,qq=.+])|(\[CQ:reply,id=.+])",
"",
re.sub(r"(,subType=\d+,url=.+])", r"]", event.raw_message),
).strip(),
raw_message=event.raw_message,
plain_text=event.get_plaintext(),
time=event.time,
)
else:
self.data = MessageData(
group_id=event.group_id,
user_id=event.user_id,
message_id=event.message_id,
message=re.sub(
r"(\[CQ:at,qq=.+])",
"",
re.sub(r"(,subType=\d+,url=.+])", r"]", event.raw_message),
).strip(),
raw_message=event.raw_message,
plain_text=event.get_plaintext(),
time=event.time,
)
self.reply = None
self.bot_id = event.self_id
self.to_me = event.to_me or NICKNAME in self.data.message
self.role = "superuser" if event.user_id in SUPERUSERS else event.sender.role
self.config = config_manager.get_group_config(self.data.group_id)
self.ban_users = set(chat_config.ban_users + self.config.ban_users)
self.ban_words = set(chat_config.ban_words + self.config.ban_words)
async def _learn(self) -> Result:
if self.to_me and any(w in self.data.message for w in {"学说话", "快学", "开启学习"}):
return Result.SetEnable
elif self.to_me and any(w in self.data.message for w in {"闭嘴", "别学", "关闭学习"}):
return Result.SetDisable
elif not chat_config.total_enable or not self.config.enable:
log_info("群聊学习", f"➤该群<m>{self.data.group_id}</m>未开启群聊学习,跳过")
return Result.Pass
elif COMMAND_START and self.data.message.startswith(tuple(COMMAND_START)):
log_info("群聊学习", "➤该消息以命令前缀开头,跳过")
return Result.Pass
elif self.data.user_id in self.ban_users:
log_info("群聊学习", f"➤发言人<m>{self.data.user_id}</m>在屏蔽列表中,跳过")
return Result.Pass
elif self.to_me and any(w in self.data.message for w in {"不可以", "达咩", "不能说这"}):
return Result.Ban
async with get_session() as session:
if not await self._check_allow(session, self.data):
log_info("群聊学习", "➤消息未通过校验,跳过")
return Result.Pass
if self.reply:
result = await session.execute(
select(ChatMessage).where(ChatMessage.message_id == self.reply.message_id).limit(1)
)
db_message = result.scalar_one_or_none()
if not db_message:
log_info("群聊学习", "➤回复的消息不在数据库中,跳过")
return Result.Pass
if db_message.user_id in self.ban_users:
log_info("群聊学习", "➤回复的人在屏蔽列表中,跳过")
return Result.Pass
if not await self._check_allow(session, _db_to_message_data(db_message)):
log_info("群聊学习", "➤回复的消息未通过校验,跳过")
return Result.Pass
await self._set_answer(session, _db_to_message_data(db_message))
return Result.Learn
else:
result = await session.execute(
select(ChatMessage)
.where(
ChatMessage.group_id == self.data.group_id,
ChatMessage.time >= self.data.time - 3600,
)
.order_by(ChatMessage.time.desc())
.limit(5)
)
messages = [_db_to_message_data(m) for m in result.scalars().all()]
if messages:
if messages[0].message == self.data.message:
log_info("群聊学习", "➤复读中,跳过")
return Result.Repeat
for message in messages:
if (
message.user_id not in self.ban_users
and set(self.data.keyword_list) & set(message.keyword_list)
and self.data.keyword_list != message.keyword_list
and await self._check_allow(session, message)
):
await self._set_answer(session, message)
return Result.Learn
if messages[0].user_id in self.ban_users or not await self._check_allow(session, messages[0]):
log_info("群聊学习", "➤最后一条消息未通过校验,跳过")
return Result.Pass
await self._set_answer(session, messages[0])
return Result.Learn
else:
return Result.Pass
async def answer(self) -> Optional[List[Union[MessageSegment, str]]]:
"""获取这句话的回复"""
result = await self._learn()
# 保存本条消息到数据库
async with get_session() as session:
session.add(ChatMessage(
group_id=self.data.group_id,
user_id=self.data.user_id,
message_id=self.data.message_id,
message=self.data.message,
raw_message=self.data.raw_message,
plain_text=self.data.plain_text,
time=self.data.time,
))
await session.commit()
if result == Result.Ban:
if self.role not in {"superuser", "admin", "owner"}:
return [random.choice(NO_PERMISSION_WORDS)]
if self.reply:
ban_result = await self._ban(message_id=self.reply.message_id)
else:
ban_result = await self._ban()
if ban_result:
return [random.choice(SORRY_WORDS)]
else:
return [random.choice(DOUBT_WORDS)]
elif result in [Result.SetEnable, Result.SetDisable]:
if self.role not in {"superuser", "admin", "owner"}:
return [random.choice(NO_PERMISSION_WORDS)]
self.config.update(enable=(result == Result.SetEnable))
config_manager.config.group_config[self.data.group_id] = self.config
config_manager.save()
log_info(
"群聊学习",
f'群<m>{self.data.group_id}</m>{"开启" if result == Result.SetEnable else "关闭"}学习功能',
)
return [random.choice(ENABLE_WORDS if result == Result.SetEnable else DISABLE_WORDS)]
elif result == Result.Pass:
return None
elif result == Result.Repeat:
async with get_session() as session:
already_result = await session.execute(
select(ChatMessage).where(
ChatMessage.group_id == self.data.group_id,
ChatMessage.time >= self.data.time - 3600,
ChatMessage.user_id == self.bot_id,
ChatMessage.message == self.data.message,
).limit(self.config.repeat_threshold + 5)
)
if already_result.scalar_one_or_none():
log_info("群聊学习", "➤➤已经复读过了,跳过")
return None
msgs_result = await session.execute(
select(ChatMessage)
.where(
ChatMessage.group_id == self.data.group_id,
ChatMessage.time >= self.data.time - 3600,
)
.order_by(ChatMessage.time.desc())
.limit(self.config.repeat_threshold)
)
messages = [_db_to_message_data(m) for m in msgs_result.scalars().all()]
if not messages:
return None
if (
len(messages) >= self.config.repeat_threshold
and all(message.message == self.data.message for message in messages)
and any(message.user_id != self.data.user_id for message in messages)
):
if random.random() < self.config.break_probability:
log_info("群聊学习", "➤➤达到复读阈值,打断复读!")
return [random.choice(BREAK_REPEAT_WORDS)]
else:
log_info("群聊学习", f"➤➤达到复读阈值,复读<m>{messages[0].message}</m>")
return [self.data.message]
return None
else:
if self.data.is_plain_text and len(self.data.plain_text) <= 1:
log_info("群聊学习", "➤➤消息过短,不回复")
return None
async with get_session() as session:
ctx_result = await session.execute(
select(ChatContext).where(ChatContext.keywords == self.data.keywords).limit(1)
)
context = ctx_result.scalar_one_or_none()
if not context:
log_info("群聊学习", "➤➤尚未有已学习的回复,不回复")
return None
if not self.to_me:
answer_choices = list(
range(
self.config.answer_threshold - len(self.config.answer_threshold_weights) + 1,
self.config.answer_threshold + 1,
)
)
answer_count_threshold = random.choices(
answer_choices, weights=self.config.answer_threshold_weights
)[0]
if len(self.data.keyword_list) == chat_config.KEYWORDS_SIZE:
answer_count_threshold -= 1
cross_group_threshold = chat_config.cross_group_threshold
else:
answer_count_threshold = 1
cross_group_threshold = 1
log_info(
"群聊学习",
f"➤➤本次回复阈值为<m>{answer_count_threshold}</m>,跨群阈值为<m>{cross_group_threshold}</m>",
)
cross_kw_result = await session.execute(
select(ChatAnswer.keywords)
.where(ChatAnswer.context_id == context.id)
.group_by(ChatAnswer.keywords)
.having(func.count(ChatAnswer.keywords) >= cross_group_threshold)
)
cross_keywords = [row[0] for row in cross_kw_result.all()]
answers_cross_result = await session.execute(
select(ChatAnswer).where(
ChatAnswer.context_id == context.id,
ChatAnswer.count >= answer_count_threshold,
ChatAnswer.keywords.in_(cross_keywords),
)
)
answers_cross = answers_cross_result.scalars().all()
answer_same_result = await session.execute(
select(ChatAnswer).where(
ChatAnswer.context_id == context.id,
ChatAnswer.count >= answer_count_threshold,
ChatAnswer.group_id == self.data.group_id,
)
)
answer_same_group = answer_same_result.scalars().all()
# 提前把所有需要的字段读出来,避免 session 关闭后访问报错
candidate_answers = []
for answer in set(answers_cross) | set(answer_same_group):
a_keywords = answer.keywords
a_group_id = answer.group_id
a_count = answer.count
a_messages = list(answer.messages)
if not await self._check_allow_answer(session, a_keywords, a_group_id, a_messages):
continue
candidate_answers.append({
"count": a_count,
"messages": a_messages,
"keywords": a_keywords,
})
if not candidate_answers:
log_info("群聊学习", "➤➤没有符合条件的候选回复")
return None
sum_count = sum(a["count"] for a in candidate_answers)
per_list = [
a["count"] / sum_count * (1 - 1 / a["count"])
for a in candidate_answers
]
per_list.append(1 - sum(per_list))
result_answer = random.choices(candidate_answers + [None], weights=per_list)[0]
if result_answer is None:
log_info("群聊学习", "➤➤但不进行回复")
return None
result_message = random.choice(result_answer["messages"])
log_info("群聊学习", f"➤➤将回复<m>{result_message}</m>")
await asyncio.sleep(random.random() + 0.5)
return [result_message]
async def _ban(self, message_id: Optional[int] = None) -> bool:
bots = get_adapter(Adapter).bots
if len(bots) == 0:
return False
bot = list(bots.values())[0]
async with get_session() as session:
if message_id:
msg_result = await session.execute(
select(ChatMessage).where(ChatMessage.message_id == message_id).limit(1)
)
db_message = msg_result.scalar_one_or_none()
if not db_message or db_message.message in ALL_WORDS:
return False
keywords = db_message.keywords
try:
await bot.delete_msg(message_id=message_id)
except ActionFailed:
log_info("群聊学习", f"待禁用消息<m>{message_id}</m>尝试撤回<r>失败</r>")
else:
lr_result = await session.execute(
select(ChatMessage)
.where(
ChatMessage.group_id == self.data.group_id,
ChatMessage.user_id == self.bot_id,
)
.order_by(ChatMessage.time.desc()).limit(1)
)
last_reply = lr_result.scalar_one_or_none()
if not last_reply or last_reply.message in ALL_WORDS:
return False
keywords = last_reply.keywords
try:
await bot.delete_msg(message_id=last_reply.message_id)
except ActionFailed:
log_info("群聊学习", f"待禁用消息<m>{last_reply.message_id}</m>尝试撤回<r>失败</r>")
bl_result = await session.execute(
select(ChatBlackList).where(ChatBlackList.keywords == keywords).limit(1)
)
ban_word = bl_result.scalar_one_or_none()
if ban_word:
if self.data.group_id not in ban_word.ban_group_id:
ban_word.ban_group_id = ban_word.ban_group_id + [self.data.group_id]
if len(ban_word.ban_group_id) >= 2:
ban_word.global_ban = True
log_info("群聊学习", f"学习词<m>{keywords}</m>将被全局禁用")
await session.execute(delete(ChatAnswer).where(ChatAnswer.keywords == keywords))
else:
log_info("群聊学习", f"群<m>{self.data.group_id}</m>禁用了学习词<m>{keywords}</m>")
await session.execute(
delete(ChatAnswer).where(
ChatAnswer.keywords == keywords,
ChatAnswer.group_id == self.data.group_id,
)
)
else:
log_info("群聊学习", f"群<m>{self.data.group_id}</m>禁用了学习词<m>{keywords}</m>")
ban_word = ChatBlackList(keywords=keywords, ban_group_id=[self.data.group_id])
session.add(ban_word)
await session.execute(
delete(ChatAnswer).where(
ChatAnswer.keywords == keywords,
ChatAnswer.group_id == self.data.group_id,
)
)
await session.execute(delete(ChatContext).where(ChatContext.keywords == keywords))
await session.commit()
return True
@staticmethod
async def add_ban(data: Union[ChatMessage, ChatContext, ChatAnswer]):
# 提前读出需要的字段
keywords = data.keywords
group_id = getattr(data, "group_id", None)
is_message = isinstance(data, ChatMessage)
async with get_session() as session:
bl_result = await session.execute(
select(ChatBlackList).where(ChatBlackList.keywords == keywords).limit(1)
)
ban_word = bl_result.scalar_one_or_none()
if ban_word:
if is_message and group_id:
if group_id not in ban_word.ban_group_id:
ban_word.ban_group_id = ban_word.ban_group_id + [group_id]
if len(ban_word.ban_group_id) >= 2:
ban_word.global_ban = True
log_info("群聊学习", f"学习词<m>{keywords}</m>将被全局禁用")
await session.execute(delete(ChatAnswer).where(ChatAnswer.keywords == keywords))
else:
log_info("群聊学习", f"群<m>{group_id}</m>禁用了学习词<m>{keywords}</m>")
await session.execute(
delete(ChatAnswer).where(
ChatAnswer.keywords == keywords,
ChatAnswer.group_id == group_id,
)
)
else:
ban_word.global_ban = True
log_info("群聊学习", f"学习词<m>{keywords}</m>将被全局禁用")
await session.execute(delete(ChatAnswer).where(ChatAnswer.keywords == keywords))
else:
if is_message and group_id:
log_info("群聊学习", f"群<m>{group_id}</m>禁用了学习词<m>{keywords}</m>")
ban_word = ChatBlackList(keywords=keywords, ban_group_id=[group_id])
await session.execute(
delete(ChatAnswer).where(
ChatAnswer.keywords == keywords,
ChatAnswer.group_id == group_id,
)
)
else:
log_info("群聊学习", f"学习词<m>{keywords}</m>将被全局禁用")
ban_word = ChatBlackList(keywords=keywords, global_ban=True)
await session.execute(delete(ChatAnswer).where(ChatAnswer.keywords == keywords))
session.add(ban_word)
await session.execute(delete(ChatContext).where(ChatContext.keywords == keywords))
await session.commit()
@staticmethod
async def speak(self_id: int) -> Optional[Tuple[int, List[Union[str, MessageSegment]]]]:
cur_time = int(time.time())
today_time = time.mktime(datetime.date.today().timetuple())
async with get_session() as session:
groups_result = await session.execute(
select(ChatMessage.group_id, func.count(ChatMessage.id).label("count"))
.where(ChatMessage.time >= today_time)
.group_by(ChatMessage.group_id)
.having(func.count(ChatMessage.id) >= 10)
)
groups = [row[0] for row in groups_result.all()]
if not groups:
return None
total_messages = {}
for group_id in groups:
msgs_result = await session.execute(
select(ChatMessage)
.where(ChatMessage.group_id == group_id, ChatMessage.time >= today_time)
.order_by(ChatMessage.time.desc())
)
msgs = [_db_to_message_data(m) for m in msgs_result.scalars().all()]
if msgs:
total_messages[group_id] = msgs
if not total_messages:
return None
def group_popularity_cmp(left_group, right_group):
def cmp(a, b):
return (a > b) - (a < b)
left_group_id, left_messages = left_group
right_group_id, right_messages = right_group
left_duration = left_messages[0].time - left_messages[-1].time
right_duration = right_messages[0].time - right_messages[-1].time
return cmp(
len(left_messages) / left_duration if left_duration else 0,
len(right_messages) / right_duration if right_duration else 0,
)
popularity = sorted(total_messages.items(), key=cmp_to_key(group_popularity_cmp), reverse=True)
log_info("群聊学习", f'主动发言:群热度排行<m>{">>".join([str(g[0]) for g in popularity])}</m>')
for group_id, messages in popularity:
if len(messages) < 30:
log_info("群聊学习", f"主动发言:群<m>{group_id}</m>消息小于30条,不发言")
continue
config = config_manager.get_group_config(group_id)
ban_words = set(
chat_config.ban_words + config.ban_words + [
"[CQ:xml", "[CQ:json", "[CQ:at", "[CQ:video", "[CQ:record", "[CQ:share",
]
)
if not config.speak_enable or not config.enable:
log_info("群聊学习", f"主动发言:群<m>{group_id}</m>未开启,不发言")
continue
lr_result = await session.execute(
select(ChatMessage)
.where(ChatMessage.group_id == group_id, ChatMessage.user_id == self_id)
.order_by(ChatMessage.time.desc()).limit(1)
)
last_reply = lr_result.scalar_one_or_none()
if last_reply:
last_reply_time = last_reply.time
if last_reply_time >= messages[0].time:
log_info("群聊学习", f"主动发言:群<m>{group_id}</m>最后一条消息是{NICKNAME}发的,不发言")
continue
elif cur_time - last_reply_time < config.speak_min_interval:
log_info("群聊学习", f"主动发言:群<m>{group_id}</m>上次主动发言时间小于主动发言最小间隔,不发言")
continue
avg_interval = (messages[0].time - messages[-1].time) / len(messages)
silent_time = cur_time - messages[0].time
threshold = avg_interval * config.speak_threshold
if silent_time < threshold:
log_info("群聊学习", f"主动发言:群<m>{group_id}</m>已沉默时间({silent_time})小于阈值({int(threshold)}),不发言")
continue
ctx_result = await session.execute(
select(ChatContext).where(ChatContext.count >= config.answer_threshold)
)
contexts = ctx_result.scalars().all()
if not contexts:
continue
speak_list = []
contexts_list = list(contexts)
random.shuffle(contexts_list)
for context in contexts_list:
if (
not speak_list or random.random() < config.speak_continuously_probability
) and len(speak_list) < config.speak_continuously_max_len:
ans_result = await session.execute(
select(ChatAnswer).where(
ChatAnswer.context_id == context.id,
ChatAnswer.group_id == group_id,
ChatAnswer.count >= config.answer_threshold,
)
)
answers = ans_result.scalars().all()
if answers:
answer_data = [(a.count, a.time, list(a.messages), a.keywords) for a in answers]
answer = random.choices(
answer_data,
weights=[a[0] + 1 if a[1] >= today_time else a[0] for a in answer_data],
)[0]
message = random.choice(answer[2])
if len(message) < 2:
continue
if message.startswith("&#91;") and message.endswith("&#93;"):
continue
if any(word in message for word in ban_words):
continue
speak_list.append(message)
follow_keywords = answer[3]
while (
random.random() < config.speak_continuously_probability
and len(speak_list) < config.speak_continuously_max_len
):
fc_result = await session.execute(
select(ChatContext).where(ChatContext.keywords == follow_keywords).limit(1)
)
follow_context = fc_result.scalar_one_or_none()
if follow_context:
fa_result = await session.execute(
select(ChatAnswer).where(
ChatAnswer.group_id == group_id,
ChatAnswer.context_id == follow_context.id,
ChatAnswer.count >= config.answer_threshold,
)
)
follow_answers = fa_result.scalars().all()
if follow_answers:
fa_data = [(a.count, a.time, list(a.messages), a.keywords) for a in follow_answers]
follow_answer = random.choices(
fa_data,
weights=[a[0] + 1 if a[1] >= today_time else a[0] for a in fa_data],
)[0]
message = random.choice(follow_answer[2])
follow_keywords = follow_answer[3]
if len(message) < 2:
continue
if message.startswith("&#91;") and message.endswith("&#93;"):
continue
if all(word not in message for word in ban_words):
speak_list.append(message)
else:
break
else:
break
else:
break
if speak_list:
if random.random() < config.speak_poke_probability:
last_speak_users = {m.user_id for m in messages[:5] if m.user_id != self_id}
if last_speak_users:
select_user = random.choice(list(last_speak_users))
speak_list.append(MessageSegment("poke", {"qq": select_user}))
# 手动戳
# await send_poke(group_id=group_id, user_id=select_user)
return group_id, speak_list
else:
log_info("群聊学习", f"主动发言:群<m>{group_id}</m>没有找到符合条件的发言,不发言")
log_info("群聊学习", "主动发言:没有符合条件的群,不主动发言")
return None
async def _set_answer(self, session, message: MessageData):
"""在已有 session 内设置回复,不新开 session"""
ctx_result = await session.execute(
select(ChatContext).where(ChatContext.keywords == message.keywords).limit(1)
)
context = ctx_result.scalar_one_or_none()
if context:
if context.count < chat_config.learn_max_count:
context.count += 1
context.time = self.data.time
ans_result = await session.execute(
select(ChatAnswer).where(
ChatAnswer.keywords == self.data.keywords,
ChatAnswer.group_id == self.data.group_id,
ChatAnswer.context_id == context.id,
).limit(1)
)
answer = ans_result.scalar_one_or_none()
if answer:
if answer.count < chat_config.learn_max_count:
answer.count += 1
answer.time = self.data.time
if self.data.message not in answer.messages:
answer.messages = answer.messages + [self.data.message]
answer_count = answer.count
else:
answer = ChatAnswer(
keywords=self.data.keywords,
group_id=self.data.group_id,
time=self.data.time,
context=context,
messages=[self.data.message],
)
session.add(answer)
answer_count = 1
else:
context = ChatContext(keywords=message.keywords, time=self.data.time)
session.add(context)
await session.flush()
answer = ChatAnswer(
keywords=self.data.keywords,
group_id=self.data.group_id,
time=self.data.time,
context=context,
messages=[self.data.message],
)
session.add(answer)
answer_count = 1
await session.commit()
log_info(
"群聊学习",
f"➤将被学习为<m>{message.message}</m>的回答,已学次数为<m>{answer_count}</m>",
)
async def _check_allow(self, session, message: MessageData) -> bool:
"""检查 MessageData 是否合法"""
raw_message = message.message
if any(
i in raw_message
for i in {"[CQ:xml", "[CQ:json", "[CQ:at", "[CQ:video", "[CQ:record", "[CQ:share"}
):
return False
if any(i in raw_message for i in self.ban_words):
return False
if raw_message.startswith("&#91;") and raw_message.endswith("&#93;"):
return False
bl_result = await session.execute(
select(ChatBlackList).where(ChatBlackList.keywords == message.keywords).limit(1)
)
ban_word = bl_result.scalar_one_or_none()
if ban_word:
if ban_word.global_ban or message.group_id in ban_word.ban_group_id:
return False
return True
async def _check_allow_answer(self, session, keywords: str, group_id: int, messages: List[str]) -> bool:
"""检查 ChatAnswer 内容是否合法(使用原始字段而非 ORM 对象)"""
if not messages:
return False
raw_message = messages[0]
if any(
i in raw_message
for i in {"[CQ:xml", "[CQ:json", "[CQ:at", "[CQ:video", "[CQ:record", "[CQ:share"}
):
return False
if any(i in raw_message for i in self.ban_words):
return False
if raw_message.startswith("&#91;") and raw_message.endswith("&#93;"):
return False
bl_result = await session.execute(
select(ChatBlackList).where(ChatBlackList.keywords == keywords).limit(1)
)
ban_word = bl_result.scalar_one_or_none()
if ban_word:
if ban_word.global_ban or group_id in ban_word.ban_group_id:
return False
return True
@@ -0,0 +1,110 @@
import functools
import json
from functools import cached_property
from typing import List, Optional, TYPE_CHECKING
try:
import ujson as json
except ImportError:
import json
try:
import jieba_fast as jieba
import jieba_fast.analyse as jieba_analyse
except ImportError:
import jieba
import jieba.analyse as jieba_analyse
from nonebot import require
require("nonebot_plugin_orm")
from nonebot_plugin_orm import Model
from sqlalchemy import BigInteger, Boolean, Integer, Text, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from .config import config_manager
config = config_manager.config
JSON_DUMPS = functools.partial(json.dumps, ensure_ascii=False)
jieba.setLogLevel(jieba.logging.INFO)
jieba.load_userdict(config.dictionary)
class ChatMessage(Model):
__tablename__ = "learning_chat_message"
__table_args__ = (
Index("ix_message_group_time", "group_id", "time"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
group_id: Mapped[int] = mapped_column(BigInteger)
user_id: Mapped[int] = mapped_column(BigInteger)
message_id: Mapped[int] = mapped_column(BigInteger)
message: Mapped[str] = mapped_column(Text)
raw_message: Mapped[str] = mapped_column(Text)
plain_text: Mapped[str] = mapped_column(Text)
time: Mapped[int] = mapped_column(Integer)
@cached_property
def is_plain_text(self) -> bool:
return "[CQ:" not in self.message
@cached_property
def keyword_list(self) -> List[str]:
if not self.is_plain_text and not len(self.plain_text):
return []
return jieba_analyse.extract_tags(self.plain_text, topK=config.KEYWORDS_SIZE)
@cached_property
def keywords(self) -> str:
if not self.is_plain_text and not len(self.plain_text):
return self.message
return (
self.message if len(self.keyword_list) < 2 else " ".join(self.keyword_list)
)
class ChatContext(Model):
__tablename__ = "learning_chat_context"
__table_args__ = (
Index("ix_context_keywords_time", "keywords", "time"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
keywords: Mapped[str] = mapped_column(Text)
time: Mapped[int] = mapped_column(Integer)
count: Mapped[int] = mapped_column(Integer, default=1)
answers: Mapped[List["ChatAnswer"]] = relationship(back_populates="context")
class ChatAnswer(Model):
__tablename__ = "learning_chat_answer"
__table_args__ = (
Index("ix_answer_keywords_time", "keywords", "time"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
keywords: Mapped[str] = mapped_column(Text)
group_id: Mapped[int] = mapped_column(BigInteger)
count: Mapped[int] = mapped_column(Integer, default=1)
time: Mapped[int] = mapped_column(Integer)
messages: Mapped[list] = mapped_column(JSON, default=list)
context_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("learning_chat_context.id"), nullable=True
)
context: Mapped[Optional[ChatContext]] = relationship(back_populates="answers")
class ChatBlackList(Model):
__tablename__ = "learning_chat_blacklist"
__table_args__ = (
Index("ix_blacklist_keywords", "keywords"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
keywords: Mapped[str] = mapped_column(Text)
global_ban: Mapped[bool] = mapped_column(Boolean, default=False)
ban_group_id: Mapped[list] = mapped_column(JSON, default=list)
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Nonebot-Plugin-Learning-Chat 控制台" />
<title>Learning-Chat 控制台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
{
"name": "learning-chat-admin",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.5.0",
"element-plus": "^2.14.3",
"pinia": "^2.1.6",
"vue": "^3.3.4",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@vitejs/plugin-vue": "^4.5.0",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vue-tsc": "^1.8.19"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -0,0 +1,146 @@
<template>
<el-config-provider>
<!-- 登录页: 无框架布局 -->
<RouterView v-if="!requiresAuth" />
<!-- 主界面: 侧边栏 + 顶栏 + 内容区 -->
<el-container v-else class="app-container">
<el-aside width="220px" class="app-aside">
<div class="aside-title">Learning-Chat 控制台</div>
<el-menu :default-active="route.path" router class="aside-menu">
<el-menu-item index="/config">
<el-icon><Setting /></el-icon>
<span>配置</span>
</el-menu-item>
<el-menu-item index="/messages">
<el-icon><ChatDotRound /></el-icon>
<span>群聊消息</span>
</el-menu-item>
<el-menu-item index="/contexts">
<el-icon><Collection /></el-icon>
<span>学习内容</span>
</el-menu-item>
<el-menu-item index="/answers">
<el-icon><ChatLineRound /></el-icon>
<span>回复内容</span>
</el-menu-item>
<el-menu-item index="/blacklist">
<el-icon><CircleClose /></el-icon>
<span>禁用列表</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container class="app-body">
<el-header class="app-header" height="60px">
<span class="header-title">Nonebot-Plugin-Learning-Chat</span>
<div class="header-right">
<el-tag size="small" type="primary" effect="plain">{{ authStore.username }}</el-tag>
<el-button size="small" type="danger" plain @click="handleLogout">退出登录</el-button>
</div>
</el-header>
<el-main class="app-main">
<RouterView />
</el-main>
</el-container>
</el-container>
</el-config-provider>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
// 依据路由而非登录态切换布局, 避免登录瞬间闪现后台框架
const requiresAuth = computed(() => route.meta.requiresAuth !== false);
// token 到期自动登出(守卫已拦截"已过期"情况, 这里覆盖"使用中到期")
onMounted(() => {
authStore.scheduleAutoLogout(() => {
ElMessage.warning('登录已过期,请重新登录');
if (router.currentRoute.value.path !== '/login') {
router.push('/login');
}
});
});
const handleLogout = async () => {
try {
await ElMessageBox.confirm('确定要退出登录吗?', '退出登录', {
type: 'warning',
confirmButtonText: '退出',
cancelButtonText: '取消',
});
} catch {
return; // 用户取消
}
authStore.logout();
router.push('/login');
};
</script>
<style scoped>
.app-container {
height: 100vh;
}
.app-aside {
background: #fff;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
}
.aside-title {
height: 60px;
line-height: 60px;
padding: 0 20px;
font-size: 15px;
font-weight: 600;
color: #303133;
border-bottom: 1px solid #f0f2f5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aside-menu {
flex: 1;
border-right: none;
}
.app-body {
min-width: 0;
}
.app-header {
background: #fff;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.app-main {
background: #f5f7fa;
overflow-y: auto;
}
</style>
@@ -0,0 +1,204 @@
import axios, { AxiosError } from 'axios';
import { ElMessage } from 'element-plus';
import router from '@/router';
import { useAuthStore } from '@/stores/auth';
import type {
ApiResponse,
BlacklistItem,
ChatAnswer,
ChatContext,
ChatMessage,
GlobalConfig,
GroupConfig,
GroupInfo,
LoginResult,
PaginatedData,
} from '@/types';
/** 带状态码的请求错误 */
export class ApiError extends Error {
status: number;
/** 认证失效类错误: 已由全局拦截器提示并跳转登录, 视图层不再重复弹窗 */
authHandled = false;
constructor(message: string, status = 0) {
super(message);
this.status = status;
}
}
let redirectingToLogin = false;
/** 认证失效: 清除登录态并跳转登录页(避免并发失败时重复跳转) */
function handleUnauthorized() {
const authStore = useAuthStore();
authStore.logout();
if (router.currentRoute.value.path !== '/login' && !redirectingToLogin) {
redirectingToLogin = true;
ElMessage.warning('登录已失效,请重新登录');
router.push('/login').finally(() => {
redirectingToLogin = false;
});
}
}
class ApiClient {
private axiosInstance;
constructor() {
this.axiosInstance = axios.create({
baseURL: '/learning_chat/api',
timeout: 30000,
});
// 请求: 附加 token 头(后端契约: authentication() 读取 token header)
this.axiosInstance.interceptors.request.use((config) => {
const authStore = useAuthStore();
if (authStore.token) {
config.headers.token = authStore.token;
}
return config;
});
// 响应: 三态归一化
// 1. {"status": 0, "msg", "data"} → 成功, 返回整个信封(data 由调用方取)
// 2. {"status": !=0, "msg"} → 业务失败(HTTP 200 内)
// 3. {"detail": ...}(HTTP 400/422)→ 认证失效/参数错误
// 4. 裸 dict(全局/分群配置 GET)→ 直接返回
this.axiosInstance.interceptors.response.use(
(response) => {
const data = response.data;
if (data && typeof data === 'object' && !Array.isArray(data)) {
if (typeof data.detail !== 'undefined') {
handleUnauthorized();
const err = new ApiError(String(data.detail), response.status);
err.authHandled = true;
throw err;
}
if (typeof data.status === 'number') {
if (data.status === 0) return data;
throw new ApiError(String(data.msg || '操作失败'), data.status);
}
}
return data;
},
(error: AxiosError) => {
const status = error.response?.status;
const data = error.response?.data as { detail?: unknown } | undefined;
const isAuthError = status === 400 || status === 422;
// 后端认证失效契约为 HTTP 400/422 + {"detail"}
if (isAuthError) {
handleUnauthorized();
}
const detail = data?.detail;
const message =
typeof detail === 'string'
? detail
: status === 0
? '无法连接到服务器'
: error.message || '网络请求失败';
const apiError = new ApiError(message, status ?? 0);
apiError.authHandled = isAuthError;
return Promise.reject(apiError);
}
);
}
/** 登录 */
async login(username: string, password: string): Promise<ApiResponse<LoginResult>> {
return this.axiosInstance.post('/login', { username, password });
}
/** 获取群列表 */
async getGroupList(): Promise<ApiResponse<{ group_list: GroupInfo[] }>> {
return this.axiosInstance.get('/get_group_list');
}
/** 获取全局配置(裸 dict) */
async getGlobalConfig(): Promise<GlobalConfig> {
return this.axiosInstance.get('/chat_global_config');
}
/** 保存全局配置 */
async saveGlobalConfig(data: GlobalConfig): Promise<ApiResponse> {
return this.axiosInstance.post('/chat_global_config', data);
}
/** 获取分群配置(裸 dict, 概率为百分比) */
async getGroupConfig(groupId: number | string): Promise<GroupConfig> {
return this.axiosInstance.get(`/chat_group_config?group_id=${groupId}`);
}
/** 保存分群配置(groupId 传 'all' 表示保存至所有群) */
async saveGroupConfig(groupId: number | string, data: GroupConfig): Promise<ApiResponse> {
return this.axiosInstance.post(`/chat_group_config?group_id=${groupId}`, data);
}
/** 获取聊天消息 */
async getMessages(params: {
page?: number;
perPage?: number;
orderBy?: string;
orderDir?: string;
group_id?: string;
user_id?: string;
message?: string;
}): Promise<ApiResponse<PaginatedData<ChatMessage>>> {
return this.axiosInstance.get('/get_chat_messages', { params });
}
/** 获取学习内容 */
async getContexts(params: {
page?: number;
perPage?: number;
orderBy?: string;
orderDir?: string;
keywords?: string;
}): Promise<ApiResponse<PaginatedData<ChatContext>>> {
return this.axiosInstance.get('/get_chat_contexts', { params });
}
/** 获取回复 */
async getAnswers(params: {
page?: number;
perPage?: number;
orderBy?: string;
orderDir?: string;
context_id?: number;
keywords?: string;
}): Promise<ApiResponse<PaginatedData<ChatAnswer>>> {
return this.axiosInstance.get('/get_chat_answers', { params });
}
/** 获取禁用列表 */
async getBlacklist(params: {
page?: number;
perPage?: number;
keywords?: string;
bans?: string;
}): Promise<ApiResponse<PaginatedData<BlacklistItem>>> {
return this.axiosInstance.get('/get_chat_blacklist', { params });
}
/** 删除(type: message | context | answer | blacklist) */
async deleteChat(type: string, id: number): Promise<ApiResponse> {
return this.axiosInstance.delete(`/delete_chat?type=${type}&id=${id}`);
}
/** 禁用(type: message | context | answer) */
async banChat(type: string, id: number): Promise<ApiResponse> {
return this.axiosInstance.put(`/ban_chat?type=${type}&id=${id}`);
}
/** 批量删除/取消禁用(type: answer | blacklist | context | message, id 可选) */
async deleteAll(type: string, id?: number): Promise<ApiResponse> {
let url = `/delete_all?type=${type}`;
if (id !== undefined) {
url += `&id=${id}`;
}
return this.axiosInstance.put(url);
}
}
export default new ApiClient();
@@ -0,0 +1,54 @@
<template>
<el-dialog
:model-value="modelValue"
:title="title"
width="640px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<div class="full-content">{{ content }}</div>
<template #footer>
<el-button type="primary" plain @click="copyContent">复制内容</el-button>
<el-button @click="emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
const props = defineProps<{
modelValue: boolean;
title?: string;
content?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
}>();
const copyContent = async () => {
try {
await navigator.clipboard.writeText(props.content || '');
ElMessage.success('已复制到剪贴板');
} catch {
ElMessage.error('复制失败,请手动选择复制');
}
};
</script>
<style scoped>
.full-content {
max-height: 60vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 13px;
line-height: 1.7;
color: #303133;
background: #f5f7fa;
border-radius: 6px;
padding: 12px;
user-select: text;
}
</style>
@@ -0,0 +1,22 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import router from '@/router';
import App from '@/App.vue';
import '@/style.css';
// 移除旧版「API 地址设置」功能残留(同源架构下该功能已废弃, 写死的地址会导致跨域失败)
localStorage.removeItem('API_BASE_URL');
const app = createApp(App);
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(createPinia());
app.use(router);
app.use(ElementPlus);
app.mount('#app');
@@ -0,0 +1,105 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
// 懒加载视图
const Login = () => import('@/views/Login.vue');
const Config = () => import('@/views/Config.vue');
const Messages = () => import('@/views/Messages.vue');
const Contexts = () => import('@/views/Contexts.vue');
const Answers = () => import('@/views/Answers.vue');
const Blacklist = () => import('@/views/Blacklist.vue');
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: () => {
const authStore = useAuthStore();
return authStore.isAuthenticated ? '/config' : '/login';
},
},
{
path: '/login',
name: 'Login',
component: Login,
meta: { requiresAuth: false },
},
{
path: '/config',
name: 'Config',
component: Config,
meta: { requiresAuth: true },
},
{
path: '/messages',
name: 'Messages',
component: Messages,
meta: { requiresAuth: true },
},
{
path: '/contexts',
name: 'Contexts',
component: Contexts,
meta: { requiresAuth: true },
},
{
path: '/answers',
name: 'Answers',
component: Answers,
meta: { requiresAuth: true },
},
{
path: '/blacklist',
name: 'Blacklist',
component: Blacklist,
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
redirect: '/login',
meta: { requiresAuth: false },
},
];
const router = createRouter({
// base 与 vite.config.ts 的 base 保持一致, 否则 SPA 导航会产生不带前缀的根路径 URL, 刷新即 404
history: createWebHistory('/learning_chat/'),
routes,
});
// 全局路由守卫
router.beforeEach((to, _from) => {
const authStore = useAuthStore();
// token 已过期(按 JWT exp 主动检测) -> 立即登出跳登录页
if (authStore.token) {
const exp = authStore.getTokenExpiry(authStore.token);
if (exp !== null && exp * 1000 <= Date.now()) {
authStore.logout();
if (to.path !== '/login') {
ElMessage.warning('登录已过期,请重新登录');
return { path: '/login', query: { redirect: to.fullPath } };
}
}
}
const requiresAuth = to.meta.requiresAuth;
// 需要登录但未认证 -> 跳登录页
if (requiresAuth && !authStore.isAuthenticated) {
return {
path: '/login',
query: { redirect: to.fullPath },
};
}
// 已登录但访问登录页 -> 跳配置页
if (to.path === '/login' && authStore.isAuthenticated) {
return '/config';
}
// 其他情况放行
return true;
});
export default router;
@@ -0,0 +1,81 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useAuthStore = defineStore('auth', () => {
// 初始化时从 localStorage 读取
const token = ref<string>(localStorage.getItem('token') || '');
const username = ref<string>(localStorage.getItem('username') || '');
const isAuthenticated = computed(() => !!token.value);
let expireTimer: ReturnType<typeof setTimeout> | null = null;
/** 解析 JWT payload 中的 exp(unix 秒); 非 JWT 或解析失败返回 null */
const getTokenExpiry = (t: string): number | null => {
try {
const payload = t.split('.')[1];
if (!payload) return null;
const json = JSON.parse(
atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
);
return typeof json.exp === 'number' ? json.exp : null;
} catch {
return null;
}
};
/**
* 注册到期自动登出:
* - token 已过期 → 立即 logout 并返回 true
* - 未过期 → 在 exp 时刻触发 onExpired 回调
*/
const scheduleAutoLogout = (onExpired?: () => void): boolean => {
if (expireTimer) {
clearTimeout(expireTimer);
expireTimer = null;
}
if (!token.value) return false;
const exp = getTokenExpiry(token.value);
if (exp === null) return false;
const remainingMs = exp * 1000 - Date.now();
if (remainingMs <= 0) {
logout();
return true;
}
expireTimer = setTimeout(() => {
logout();
onExpired?.();
}, remainingMs);
return false;
};
const setAuth = (newToken: string, newUsername: string) => {
token.value = newToken;
username.value = newUsername;
localStorage.setItem('token', newToken);
localStorage.setItem('username', newUsername);
// 登录即武装到期定时器
scheduleAutoLogout();
};
const logout = () => {
if (expireTimer) {
clearTimeout(expireTimer);
expireTimer = null;
}
token.value = '';
username.value = '';
localStorage.removeItem('token');
localStorage.removeItem('username');
};
return {
token,
username,
isAuthenticated,
getTokenExpiry,
scheduleAutoLogout,
setAuth,
logout,
};
});
@@ -0,0 +1,79 @@
/* 全局样式 — Element Plus 自带 reset, 这里只保留主题与少量通用类 */
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background: #f5f7fa;
color: #303133;
}
/* 页面级白色卡片容器(列表页) */
.page-card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
/* 页面顶部说明条 */
.page-tip {
margin-bottom: 16px;
}
/* 搜索栏与操作行 */
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.toolbar .el-input {
width: 160px;
}
.toolbar .toolbar-wide {
width: 240px;
}
/* 分页容器 */
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 16px;
}
/* 表格内可点击查看完整内容的长文本单元格 */
.clickable-cell {
cursor: pointer;
color: #606266;
transition: color 0.2s;
}
.clickable-cell:hover {
color: var(--el-color-primary);
text-decoration: underline;
}
/* 单行省略(配合可点击单元格使用) */
.cell-ellipsis {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 表单辅助说明文字 */
.help-text {
font-size: 12px;
color: #909399;
line-height: 1.6;
margin-top: 4px;
}
@@ -0,0 +1,97 @@
/**
* 与后端 web_api.py 契约一致的类型定义
*/
/** 后端统一信封(列表/操作类接口); 配置 GET 返回裸 dict, 认证错误返回 {"detail"} */
export interface ApiResponse<T = unknown> {
status: number;
msg: string;
data?: T;
}
export interface PaginatedData<T> {
items: T[];
total: number;
}
export interface LoginResult {
token: string;
}
export interface GroupInfo {
label: string;
value: number;
}
export interface ChatMessage {
id: number;
message_id: string | number;
group_id: number;
user_id: number;
raw_message: string;
message?: string;
/** unix 秒整数 */
time: number;
}
export interface ChatContext {
id: number;
keywords: string;
/** unix 秒整数 */
time: number;
count: number;
}
export interface ChatAnswer {
id: number;
group_id: number;
keywords: string;
/** unix 秒整数 */
time: number;
count: number;
context_id: number;
/** 旧数据可能为 null */
messages: Array<{ msg: string }> | null;
}
export interface BlacklistItem {
id: number;
keywords: string;
bans: string;
global_ban?: boolean;
ban_group_id?: number[];
}
export interface GlobalConfig {
total_enable: boolean;
enable_web: boolean;
web_username: string;
web_password: string;
web_secret_key: string;
KEYWORDS_SIZE: number;
cross_group_threshold: number;
learn_max_count: number;
ban_words: string[];
ban_users: string[];
dictionary: string[];
}
export interface GroupConfig {
enable: boolean;
answer_threshold: number;
answer_threshold_weights: number[];
repeat_threshold: number;
/** 0-100 百分比(后端 GET ×100 / POST ÷100 契约) */
break_probability: number;
speak_enable: boolean;
speak_threshold: number;
speak_min_interval: number;
/** 0-100 百分比 */
speak_continuously_probability: number;
speak_continuously_max_len: number;
/** 0-100 百分比 */
speak_poke_probability: number;
ban_words: string[];
ban_users: string[];
member_list: GroupInfo[];
}
@@ -0,0 +1,14 @@
/** 后端 time 为 unix 秒整数, 格式化为本地时间(兼容毫秒/字符串) */
export function formatTime(time: number | string | null | undefined): string {
if (time === null || time === undefined || time === '') return '';
let t = Number(time);
if (Number.isNaN(t)) return String(time);
if (t < 1e12) t *= 1000; // 秒 → 毫秒
return new Date(t).toLocaleString('zh-CN');
}
/** 从错误对象提取可读消息(ApiError/普通 Error/未知值) */
export function errorMessage(err: unknown, fallback: string): string {
if (err instanceof Error && err.message) return err.message;
return fallback;
}
@@ -0,0 +1,12 @@
import { ElMessage } from 'element-plus';
import { ApiError } from '@/api/client';
import { errorMessage } from './format';
/**
* 展示错误提示。
* 认证失效类错误已由全局拦截器统一提示并跳转登录, 这里跳过, 避免重复弹窗。
*/
export function showError(err: unknown, fallback: string): void {
if (err instanceof ApiError && err.authHandled) return;
ElMessage.error(errorMessage(err, fallback));
}
@@ -0,0 +1,264 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人已学习到的所有回复。</p>
<p>点击「禁用」可以将该回复进行禁用;点击「删除」可以删除该回复。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input
v-model="searchKeywords"
placeholder="搜索关键词"
clearable
class="toolbar-wide"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
</div>
<el-table v-loading="loading" :data="answers" stripe border @sort-change="handleSortChange">
<el-table-column type="expand">
<template #default="{ row }">
<div class="answers-detail">
<div v-for="(m, i) in (row.messages || [])" :key="i" class="answer-msg">
{{ m.msg }}
</div>
<el-empty
v-if="!row.messages || row.messages.length === 0"
description="无消息内容"
:image-size="50"
/>
</div>
</template>
</el-table-column>
<el-table-column label="内容/关键词" min-width="180" sortable="custom">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.keywords"
@click="showDetail(`回复 #${row.id}`, row.keywords)"
>
{{ row.keywords }}
</span>
</template>
</el-table-column>
<el-table-column prop="group_id" label="群ID" width="110" />
<el-table-column prop="time" label="最后学习时间" width="180" sortable="custom">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column prop="count" label="次数" width="90" sortable="custom" />
<el-table-column label="消息预览" min-width="240">
<template #default="{ row }">
<div class="msg-preview clickable-cell" @click="showDetail(`完整消息 - ${row.keywords}`, (row.messages || []).map((m) => m.msg).join('\n'))">
<div v-for="(m, i) in (row.messages || []).slice(0, 2)" :key="i" class="msg-preview-line">
{{ m.msg }}
</div>
<div v-if="(row.messages || []).length > 2" class="msg-preview-more">
+{{ row.messages!.length - 2 }} 更多(点击查看全部)
</div>
<span v-if="!row.messages || row.messages.length === 0" class="msg-preview-empty">无消息内容</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无回复数据" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchAnswers"
/>
</div>
</div>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { formatTime } from '@/utils/format';
import { showError } from '@/utils/notify';
import type { ChatAnswer } from '@/types';
const answers = ref<ChatAnswer[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchKeywords = ref('');
const sortColumn = ref<'keywords' | 'time' | 'count'>('count');
const sortOrder = ref<'asc' | 'desc'>('desc');
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchAnswers = async () => {
loading.value = true;
try {
const res = await apiClient.getAnswers({
page: page.value,
perPage,
orderBy: sortColumn.value,
orderDir: sortOrder.value,
keywords: searchKeywords.value,
});
answers.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取回复列表失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchAnswers();
};
const handleSortChange = ({
prop,
order,
}: {
prop: string;
order: 'ascending' | 'descending' | null;
}) => {
if (!prop) return;
sortColumn.value = prop as 'keywords' | 'time' | 'count';
sortOrder.value = order === 'ascending' ? 'asc' : 'desc';
page.value = 1;
fetchAnswers();
};
const handleDelete = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要删除这条回复吗?`, '删除回复', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('answer', row.id);
ElMessage.success('已删除');
fetchAnswers();
} catch (err) {
showError(err, '删除失败');
}
};
const handleBan = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要禁用这条回复吗?`, '禁用回复', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('answer', row.id);
ElMessage.success('已禁用');
fetchAnswers();
} catch (err) {
showError(err, '禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm(
'确定要删除所有回复吗?这是不可逆的操作!',
'删除所有回复',
{
type: 'error',
confirmButtonText: '全部删除',
cancelButtonText: '取消',
}
);
} catch {
return;
}
try {
await apiClient.deleteAll('answer');
ElMessage.success('已删除所有回复');
fetchAnswers();
} catch (err) {
showError(err, '删除失败');
}
};
// 初始加载
fetchAnswers();
</script>
<style scoped>
.answers-detail {
padding: 8px 24px;
}
.answer-msg {
padding: 6px 10px;
background: #f5f7fa;
border-radius: 4px;
border-left: 3px solid #67c23a;
margin-bottom: 6px;
font-size: 13px;
color: #606266;
word-break: break-all;
}
.answer-msg:last-child {
margin-bottom: 0;
}
.msg-preview-line {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.msg-preview-more {
font-size: 12px;
color: #409eff;
}
.msg-preview-empty {
font-size: 12px;
color: #c0c4cc;
}
</style>
@@ -0,0 +1,170 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人被禁用的内容/关键词。</p>
<p>可以取消禁用,使其能够重新继续学习;不能在此添加禁用,只能在配置中添加屏蔽词来达到禁用效果。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input
v-model="searchKeywords"
placeholder="搜索关键词"
clearable
@keyup.enter="handleSearch"
/>
<el-input
v-model="searchBans"
placeholder="搜索禁用群"
clearable
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">取消所有禁用</el-button>
</div>
<el-table v-loading="loading" :data="blacklist" stripe border>
<el-table-column label="内容/关键词" min-width="240">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.keywords"
@click="showDetail(`禁用项 #${row.id}`, row.keywords)"
>
{{ row.keywords }}
</span>
</template>
</el-table-column>
<el-table-column label="已禁用的群" min-width="160">
<template #default="{ row }">
<el-tag v-if="row.global_ban" type="danger" effect="plain">全局禁用</el-tag>
<el-tag v-else-if="row.bans" type="info" effect="plain">{{ row.bans }}</el-tag>
<span v-else class="bans-empty">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button size="small" type="success" plain @click="handleUnban(row)">取消禁用</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无禁用项目" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchBlacklist"
/>
</div>
</div>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { showError } from '@/utils/notify';
import type { BlacklistItem } from '@/types';
const blacklist = ref<BlacklistItem[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchKeywords = ref('');
const searchBans = ref('');
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchBlacklist = async () => {
loading.value = true;
try {
const res = await apiClient.getBlacklist({
page: page.value,
perPage,
keywords: searchKeywords.value,
bans: searchBans.value,
});
blacklist.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取禁用列表失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchBlacklist();
};
const handleUnban = async (row: BlacklistItem) => {
try {
await ElMessageBox.confirm(`确定要取消禁用 "${row.keywords}" 吗?`, '取消禁用', {
type: 'warning',
confirmButtonText: '取消禁用',
cancelButtonText: '返回',
});
} catch {
return;
}
try {
await apiClient.deleteChat('blacklist', row.id);
ElMessage.success('已取消禁用');
fetchBlacklist();
} catch (err) {
showError(err, '取消禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm('确定要取消所有禁用吗?', '取消所有禁用', {
type: 'warning',
confirmButtonText: '全部取消',
cancelButtonText: '返回',
});
} catch {
return;
}
try {
await apiClient.deleteAll('blacklist');
ElMessage.success('已取消所有禁用');
fetchBlacklist();
} catch (err) {
showError(err, '取消禁用失败');
}
};
// 初始加载
fetchBlacklist();
</script>
<style scoped>
.bans-empty {
color: #c0c4cc;
}
</style>
@@ -0,0 +1,586 @@
<template>
<div class="config-container">
<el-tabs v-model="activeTab">
<!-- ==================== 全局配置 ==================== -->
<el-tab-pane label="全局配置" name="global">
<div v-loading="loading" class="config-body">
<template v-if="globalConfig">
<el-card shadow="never" class="config-card">
<template #header>基本设置</template>
<el-form label-position="top">
<el-form-item label="群聊学习总开关">
<el-switch
v-model="globalConfig.total_enable"
active-text="开启"
inactive-text="关闭"
/>
<div class="help-text">关闭后,全局都将不会再学习和回复</div>
</el-form-item>
<el-form-item label="后台管理总开关">
<el-switch
v-model="globalConfig.enable_web"
active-text="开启"
inactive-text="关闭"
/>
<div class="help-text">是否开启本后台管理</div>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="config-card">
<template #header>认证设置</template>
<el-form label-position="top">
<el-form-item label="后台管理用户名">
<el-input v-model="globalConfig.web_username" placeholder="e.g. chat" />
<div class="help-text">登录本后台管理所需要的用户名</div>
</el-form-item>
<el-form-item label="后台管理密码">
<el-input
v-model="globalConfig.web_password"
type="password"
show-password
placeholder="输入密码"
/>
<div class="help-text">登录本后台管理所需要的密码</div>
</el-form-item>
<el-form-item label="后台管理Token密钥">
<el-input v-model="globalConfig.web_secret_key" placeholder="输入密钥" />
<div class="help-text">用于加密验证Token的密钥</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="globalConfig.total_enable" shadow="never" class="config-card">
<template #header>学习参数</template>
<el-form label-position="top">
<el-form-item label="单句关键词数量">
<el-input-number v-model="globalConfig.KEYWORDS_SIZE" :min="2" controls-position="right" />
<div class="help-text">单句语句标签数量,建议保持默认为3</div>
</el-form-item>
<el-form-item label="跨群回复阈值">
<el-input-number v-model="globalConfig.cross_group_threshold" :min="1" controls-position="right" />
<div class="help-text">当学习到的一种回复在N个群都有,就变为全局回复</div>
</el-form-item>
<el-form-item label="最高学习次数">
<el-input-number v-model="globalConfig.learn_max_count" :min="2" controls-position="right" />
<div class="help-text">值越高,这个回复就会越容易进行回复</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="globalConfig.total_enable" shadow="never" class="config-card">
<template #header>屏蔽设置</template>
<el-form label-position="top">
<el-form-item label="全局屏蔽词">
<div class="tag-list">
<el-tag
v-for="(word, i) in globalConfig.ban_words"
:key="i"
type="primary"
effect="plain"
closable
@close="globalConfig.ban_words.splice(i, 1)"
>
{{ word }}
</el-tag>
<el-input
v-model="banWordInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(globalConfig.ban_words, banWordInput)"
/>
</div>
<div class="help-text">含有这些词的消息不会学习和回复</div>
</el-form-item>
<el-form-item label="全局屏蔽用户">
<div class="tag-list">
<el-tag
v-for="(user, i) in globalConfig.ban_users"
:key="i"
type="primary"
effect="plain"
closable
@close="globalConfig.ban_users.splice(i, 1)"
>
{{ user }}
</el-tag>
<el-input
v-model="banUserInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(globalConfig.ban_users, banUserInput)"
/>
</div>
<div class="help-text">和这些用户有关的消息不会学习和回复</div>
</el-form-item>
<el-form-item label="自定义词典">
<div class="tag-list">
<el-tag
v-for="(word, i) in globalConfig.dictionary"
:key="i"
type="primary"
effect="plain"
closable
@close="globalConfig.dictionary.splice(i, 1)"
>
{{ word }}
</el-tag>
<el-input
v-model="dictionaryInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(globalConfig.dictionary, dictionaryInput)"
/>
</div>
<div class="help-text">添加自定义词语,提高学习的准确性</div>
</el-form-item>
</el-form>
</el-card>
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="saveGlobal">保存</el-button>
<el-button @click="resetGlobal">重置</el-button>
</div>
</template>
</div>
</el-tab-pane>
<!-- ==================== 分群配置 ==================== -->
<el-tab-pane label="分群配置" name="group">
<div class="page-card group-select-row">
<span class="group-select-label">选择群:</span>
<el-select
v-model="selectedGroupId"
placeholder="-- 请选择群 --"
clearable
style="flex: 1"
@change="handleGroupChange"
>
<el-option v-for="g in groups" :key="g.value" :label="g.label" :value="g.value" />
</el-select>
</div>
<div v-if="selectedGroupId" v-loading="groupLoading" class="config-body">
<template v-if="groupConfig">
<el-card shadow="never" class="config-card">
<template #header>基本设置</template>
<el-form label-position="top">
<el-form-item label="群聊学习开关">
<el-switch v-model="groupConfig.enable" active-text="开启" inactive-text="关闭" />
<div class="help-text">针对该群的学习开关</div>
</el-form-item>
<el-form-item v-if="groupConfig.enable" label="回复阈值">
<el-input-number v-model="groupConfig.answer_threshold" :min="2" controls-position="right" />
<div class="help-text">学习成功所需要的次数,值越低学得越快</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>回复阈值权重</template>
<el-form label-position="top">
<el-form-item label="设置权重(%)">
<div class="weight-list">
<div v-for="(weight, i) in groupConfig.answer_threshold_weights" :key="i" class="weight-row">
<el-input-number v-model="groupConfig.answer_threshold_weights[i]" :min="1" :max="100" controls-position="right" />
<el-button size="small" type="danger" plain @click="removeWeight(i)">删除</el-button>
</div>
<el-button size="small" type="success" plain @click="addWeight">+ 添加权重</el-button>
</div>
<div class="help-text">影响回复阈值的计算方式</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>复读设置</template>
<el-form label-position="top">
<el-form-item label="复读阈值">
<el-input-number v-model="groupConfig.repeat_threshold" :min="2" controls-position="right" />
<div class="help-text">N个人复读后机器人就会跟着复读</div>
</el-form-item>
<el-form-item label="打断复读概率">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.break_probability" :min="0" :max="100" controls-position="right" />
<span class="suffix-text">%</span>
</div>
<div class="help-text">达到复读阈值时,打断复读的概率</div>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>主动发言设置</template>
<el-form label-position="top">
<el-form-item label="主动发言开关">
<el-switch v-model="groupConfig.speak_enable" active-text="开启" inactive-text="关闭" />
<div class="help-text">是否允许机器人在该群主动发言</div>
</el-form-item>
<template v-if="groupConfig.speak_enable">
<el-form-item label="主动发言阈值">
<el-input-number v-model="groupConfig.speak_threshold" :min="0" controls-position="right" />
<div class="help-text">值越低,主动发言的可能性越高</div>
</el-form-item>
<el-form-item label="主动发言最小间隔">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.speak_min_interval" :min="0" controls-position="right" />
<span class="suffix-text">秒</span>
</div>
<div class="help-text">进行主动发言的最小时间间隔</div>
</el-form-item>
<el-form-item label="连续主动发言概率">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.speak_continuously_probability" :min="0" :max="100" controls-position="right" />
<span class="suffix-text">%</span>
</div>
<div class="help-text">触发主动发言时,连续发言的概率</div>
</el-form-item>
<el-form-item label="最大连续发言句数">
<el-input-number v-model="groupConfig.speak_continuously_max_len" :min="1" controls-position="right" />
<div class="help-text">连续主动发言的最大句数</div>
</el-form-item>
<el-form-item label="戳一戳概率">
<div class="suffix-wrap">
<el-input-number v-model="groupConfig.speak_poke_probability" :min="0" :max="100" controls-position="right" />
<span class="suffix-text">%</span>
</div>
<div class="help-text">主动发言时附带戳一戳的概率</div>
</el-form-item>
</template>
</el-form>
</el-card>
<el-card v-if="groupConfig.enable" shadow="never" class="config-card">
<template #header>屏蔽设置</template>
<el-form label-position="top">
<el-form-item label="屏蔽词">
<div class="tag-list">
<el-tag
v-for="(word, i) in groupConfig.ban_words"
:key="i"
type="primary"
effect="plain"
closable
@close="groupConfig.ban_words.splice(i, 1)"
>
{{ word }}
</el-tag>
<el-input
v-model="groupBanWordInput"
size="small"
class="tag-input"
placeholder="输入后按回车添加"
@keyup.enter="addTag(groupConfig.ban_words, groupBanWordInput)"
/>
</div>
<div class="help-text">含有这些词的消息不会学习</div>
</el-form-item>
<el-form-item label="屏蔽用户">
<div class="tag-list">
<el-tag
v-for="(userId, i) in groupConfig.ban_users"
:key="i"
type="primary"
effect="plain"
closable
@close="groupConfig.ban_users.splice(i, 1)"
>
{{ formatUserId(userId) }}
</el-tag>
<el-select
v-model="pendingMember"
size="small"
class="member-select"
placeholder="-- 选择用户添加 --"
clearable
@change="addMember"
>
<el-option v-for="m in groupConfig.member_list" :key="m.value" :label="m.label" :value="m.value" />
</el-select>
</div>
<div class="help-text">和这些用户有关的消息不会学习</div>
</el-form-item>
</el-form>
</el-card>
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="saveGroup">保存</el-button>
<el-button type="success" plain :loading="saving" @click="saveGroupAll">保存至所有群</el-button>
<el-button @click="resetGroup">重置</el-button>
</div>
</template>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, type Ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import { showError } from '@/utils/notify';
import type { GlobalConfig, GroupConfig, GroupInfo } from '@/types';
const activeTab = ref<'global' | 'group'>('global');
// ==================== 全局配置 ====================
const loading = ref(false);
const saving = ref(false);
const globalConfig = ref<GlobalConfig | null>(null);
let globalBackup: GlobalConfig | null = null;
const banWordInput = ref('');
const banUserInput = ref('');
const dictionaryInput = ref('');
const fetchGlobalConfig = async () => {
loading.value = true;
try {
globalConfig.value = await apiClient.getGlobalConfig();
// 深拷贝快照, 重置时恢复(修复原浅拷贝 bug)
globalBackup = JSON.parse(JSON.stringify(globalConfig.value));
} catch (err) {
showError(err, '获取全局配置失败');
} finally {
loading.value = false;
}
};
const saveGlobal = async () => {
if (!globalConfig.value) return;
saving.value = true;
try {
await apiClient.saveGlobalConfig(globalConfig.value);
globalBackup = JSON.parse(JSON.stringify(globalConfig.value));
ElMessage.success('保存成功');
} catch (err) {
showError(err, '保存失败');
} finally {
saving.value = false;
}
};
const resetGlobal = () => {
if (globalBackup) {
globalConfig.value = JSON.parse(JSON.stringify(globalBackup));
}
};
// ==================== 分群配置 ====================
const groups = ref<GroupInfo[]>([]);
const selectedGroupId = ref<number | null>(null);
const groupLoading = ref(false);
const groupConfig = ref<GroupConfig | null>(null);
let groupBackup: GroupConfig | null = null;
const groupBanWordInput = ref('');
const pendingMember = ref<number | null>(null);
const fetchGroupList = async () => {
try {
const res = await apiClient.getGroupList();
groups.value = res.data!.group_list;
} catch (err) {
showError(err, '获取群列表失败');
}
};
const handleGroupChange = (value: number | null) => {
if (value === null) {
groupConfig.value = null;
return;
}
fetchGroupConfig(value);
};
const fetchGroupConfig = async (groupId: number) => {
groupLoading.value = true;
try {
groupConfig.value = await apiClient.getGroupConfig(groupId);
groupBackup = JSON.parse(JSON.stringify(groupConfig.value));
} catch (err) {
groupConfig.value = null;
showError(err, '获取分群配置失败');
} finally {
groupLoading.value = false;
}
};
const saveGroup = async () => {
if (!groupConfig.value || selectedGroupId.value === null) return;
saving.value = true;
try {
await apiClient.saveGroupConfig(selectedGroupId.value, groupConfig.value);
groupBackup = JSON.parse(JSON.stringify(groupConfig.value));
ElMessage.success('保存成功');
} catch (err) {
showError(err, '保存失败');
} finally {
saving.value = false;
}
};
const saveGroupAll = async () => {
if (!groupConfig.value) return;
try {
await ElMessageBox.confirm('确定要将当前配置保存至所有群吗?', '保存至所有群', {
type: 'warning',
confirmButtonText: '保存',
cancelButtonText: '取消',
});
} catch {
return;
}
saving.value = true;
try {
await apiClient.saveGroupConfig('all', groupConfig.value);
ElMessage.success('已保存至所有群');
} catch (err) {
showError(err, '保存失败');
} finally {
saving.value = false;
}
};
const resetGroup = () => {
if (groupBackup) {
groupConfig.value = JSON.parse(JSON.stringify(groupBackup));
}
};
// ==================== 通用 ====================
/** 动态标签输入: 回车添加, 重复值不添加 */
const addTag = (list: string[], input: Ref<string>) => {
const value = input.value.trim();
if (value && !list.includes(value)) {
list.push(value);
}
input.value = '';
};
const addWeight = () => {
if (!groupConfig.value) return;
groupConfig.value.answer_threshold_weights.push(25);
};
const removeWeight = (index: number) => {
if (!groupConfig.value) return;
if (groupConfig.value.answer_threshold_weights.length > 1) {
groupConfig.value.answer_threshold_weights.splice(index, 1);
}
};
const addMember = (value: number | null) => {
if (!groupConfig.value || value === null) return;
const v = String(value);
if (!groupConfig.value.ban_users.includes(v)) {
groupConfig.value.ban_users.push(v);
}
pendingMember.value = null;
};
const formatUserId = (userId: string | number) => {
const member = groupConfig.value?.member_list.find(
(m) => m.value.toString() === userId.toString()
);
return member?.label || String(userId);
};
onMounted(() => {
fetchGlobalConfig();
fetchGroupList();
});
</script>
<style scoped>
.config-container {
max-width: 1000px;
margin: 0 auto;
}
.config-body {
min-height: 100px;
}
.config-card {
margin-bottom: 16px;
border-radius: 8px;
}
.config-card .el-form {
max-width: 520px;
}
.config-card :deep(.el-input-number) {
width: 100%;
}
.suffix-wrap {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.suffix-text {
color: #909399;
font-size: 13px;
flex-shrink: 0;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.tag-input {
width: 200px;
}
.member-select {
width: 220px;
}
.weight-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.weight-row {
display: flex;
align-items: center;
gap: 8px;
}
.weight-row .el-input-number {
width: 180px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.group-select-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 16px 20px;
}
.group-select-label {
font-weight: 500;
color: #303133;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,280 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人所学习的内容。</p>
<p>点击「回复列表」可以查看该条内容已学习到的回复;点击「禁用」可以禁用该学习;点击「删除」可以删除该学习。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input
v-model="searchKeywords"
placeholder="搜索关键词"
clearable
class="toolbar-wide"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
</div>
<el-table v-loading="loading" :data="contexts" stripe border @sort-change="handleSortChange">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="内容/关键词" min-width="240" sortable="custom">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.keywords"
@click="showDetail(`学习内容 #${row.id}`, row.keywords)"
>
{{ row.keywords }}
</span>
</template>
</el-table-column>
<el-table-column prop="time" label="最后学习时间" width="180" sortable="custom">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column prop="count" label="已学次数" width="110" sortable="custom" />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" plain @click="showAnswers(row)">回复列表</el-button>
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无学习内容" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchContexts"
/>
</div>
</div>
<!-- 回复列表弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="`回复列表 - ${currentContext?.keywords ?? ''}`"
width="640px"
destroy-on-close
@closed="dialogVisible = false; currentContext = null; contextAnswers = []"
>
<div v-loading="answersLoading">
<el-table v-if="contextAnswers.length" :data="contextAnswers" size="small" stripe border>
<el-table-column prop="keywords" label="回复内容" min-width="180" show-overflow-tooltip />
<el-table-column prop="count" label="次数" width="80" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" type="warning" plain @click="handleBanAnswer(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDeleteAnswer(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else-if="!answersLoading" description="暂无回复" :image-size="80" />
</div>
</el-dialog>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { formatTime } from '@/utils/format';
import { showError } from '@/utils/notify';
import type { ChatAnswer, ChatContext } from '@/types';
const contexts = ref<ChatContext[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchKeywords = ref('');
const sortColumn = ref<'keywords' | 'time' | 'count'>('time');
const sortOrder = ref<'asc' | 'desc'>('desc');
const dialogVisible = ref(false);
const currentContext = ref<ChatContext | null>(null);
const contextAnswers = ref<ChatAnswer[]>([]);
const answersLoading = ref(false);
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchContexts = async () => {
loading.value = true;
try {
const res = await apiClient.getContexts({
page: page.value,
perPage,
orderBy: sortColumn.value,
orderDir: sortOrder.value,
keywords: searchKeywords.value,
});
contexts.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取学习内容失败');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchContexts();
};
const handleSortChange = ({
prop,
order,
}: {
prop: string;
order: 'ascending' | 'descending' | null;
}) => {
if (!prop) return;
sortColumn.value = prop as 'keywords' | 'time' | 'count';
sortOrder.value = order === 'ascending' ? 'asc' : 'desc';
page.value = 1;
fetchContexts();
};
const handleDelete = async (row: ChatContext) => {
try {
await ElMessageBox.confirm(`确定要删除学习内容 "${row.keywords}" 吗?`, '删除学习内容', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('context', row.id);
ElMessage.success('已删除');
fetchContexts();
} catch (err) {
showError(err, '删除失败');
}
};
const handleBan = async (row: ChatContext) => {
try {
await ElMessageBox.confirm(`确定要禁用学习内容 "${row.keywords}" 吗?`, '禁用学习内容', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('context', row.id);
ElMessage.success('已禁用');
fetchContexts();
} catch (err) {
showError(err, '禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm(
'确定要删除所有学习内容吗?这是不可逆的操作!',
'删除所有学习内容',
{
type: 'error',
confirmButtonText: '全部删除',
cancelButtonText: '取消',
}
);
} catch {
return;
}
try {
await apiClient.deleteAll('context');
ElMessage.success('已删除所有学习内容');
fetchContexts();
} catch (err) {
showError(err, '删除失败');
}
};
const showAnswers = async (row: ChatContext) => {
currentContext.value = row;
contextAnswers.value = [];
dialogVisible.value = true;
answersLoading.value = true;
try {
const res = await apiClient.getAnswers({ context_id: row.id, perPage: 100 });
contextAnswers.value = res.data!.items;
} catch (err) {
showError(err, '获取回复列表失败');
} finally {
answersLoading.value = false;
}
};
const handleDeleteAnswer = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要删除这条回复吗?`, '删除回复', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('answer', row.id);
ElMessage.success('已删除');
if (currentContext.value) showAnswers(currentContext.value);
} catch (err) {
showError(err, '删除失败');
}
};
const handleBanAnswer = async (row: ChatAnswer) => {
try {
await ElMessageBox.confirm(`确定要禁用这条回复吗?`, '禁用回复', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('answer', row.id);
ElMessage.success('已禁用');
if (currentContext.value) showAnswers(currentContext.value);
} catch (err) {
showError(err, '禁用失败');
}
};
// 初始加载
fetchContexts();
</script>
@@ -0,0 +1,159 @@
<template>
<div class="login-wrapper">
<el-card class="login-card" shadow="always">
<div class="logo-container">
<img
:src="LOGO_URL"
alt="Nonebot-Plugin-Learning-Chat"
class="logo"
@error="logoFailed = true"
/>
</div>
<h1 class="app-title">Nonebot-Plugin-Learning-Chat 控制台</h1>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
size="large"
@submit.prevent
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="form.username"
placeholder="请输入用户名"
:disabled="loading"
autofocus
clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
show-password
:disabled="loading"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-alert
v-if="error"
:title="error"
type="error"
:closable="false"
show-icon
class="login-error"
/>
<el-button
type="primary"
class="login-button"
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { FormInstance, FormRules } from 'element-plus';
import { useAuthStore } from '@/stores/auth';
import apiClient from '@/api/client';
const LOGO_URL =
'https://s3.sansenhoshi.top/s-file-trans/%E5%9B%BE%E7%89%87/icon/123.jpg';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const formRef = ref<FormInstance>();
const loading = ref(false);
const error = ref('');
const logoFailed = ref(false);
const form = reactive({ username: '', password: '' });
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
};
const handleLogin = async () => {
error.value = '';
if (!formRef.value) return;
const valid = await formRef.value.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
const res = await apiClient.login(form.username.trim(), form.password);
authStore.setAuth(res.data!.token, form.username.trim());
// 登录后跳回被拦截前的页面(如 token 过期时重定向来的路径)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
await router.push(redirect);
} catch (err) {
error.value = err instanceof Error ? err.message : '登录失败,请重试';
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-wrapper {
width: 100%;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 100%;
max-width: 420px;
border-radius: 12px;
padding: 12px 8px;
}
.logo-container {
text-align: center;
margin-bottom: 20px;
}
.logo {
width: 90px;
height: 90px;
border-radius: 10px;
object-fit: cover;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.app-title {
text-align: center;
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0 0 28px;
line-height: 1.4;
}
.login-error {
margin-bottom: 16px;
}
.login-button {
width: 100%;
margin-top: 4px;
}
</style>
@@ -0,0 +1,188 @@
<template>
<div>
<el-alert type="info" :closable="false" show-icon class="page-tip">
<template #title>
<p>此数据库记录了机器人收到的聊天记录。</p>
<p>点击「禁用」可以将某条聊天记录进行禁用;点击「删除」可以删除某条记录。</p>
</template>
</el-alert>
<div class="page-card">
<div class="toolbar">
<el-input v-model="searchGroupId" placeholder="群ID" clearable />
<el-input v-model="searchUserId" placeholder="用户ID" clearable />
<el-input
v-model="searchMessage"
placeholder="消息内容"
clearable
class="toolbar-wide"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button type="danger" plain @click="handleDeleteAll">删除所有</el-button>
</div>
<el-table v-loading="loading" :data="messages" stripe border>
<el-table-column prop="message_id" label="消息ID" width="130" show-overflow-tooltip />
<el-table-column prop="group_id" label="群ID" width="110" />
<el-table-column prop="user_id" label="用户ID" width="110" />
<el-table-column label="消息" min-width="240">
<template #default="{ row }">
<span
class="clickable-cell cell-ellipsis"
:title="row.raw_message || row.message"
@click="showDetail(`消息 #${row.message_id}`, row.raw_message || row.message)"
>
{{ row.raw_message || row.message }}
</span>
</template>
</el-table-column>
<el-table-column label="时间" width="180">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" type="warning" plain @click="handleBan(row)">禁用</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无消息数据" :image-size="80" />
</template>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="perPage"
:total="total"
layout="total, prev, pager, next"
background
@current-change="fetchMessages"
/>
</div>
</div>
<FullTextDialog v-model="detailVisible" :title="detailTitle" :content="detailContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import apiClient from '@/api/client';
import FullTextDialog from '@/components/FullTextDialog.vue';
import { formatTime } from '@/utils/format';
import { showError } from '@/utils/notify';
import type { ChatMessage } from '@/types';
const messages = ref<ChatMessage[]>([]);
const total = ref(0);
const page = ref(1);
const perPage = 10;
const loading = ref(false);
const searchGroupId = ref('');
const searchUserId = ref('');
const searchMessage = ref('');
// 完整内容弹窗
const detailVisible = ref(false);
const detailTitle = ref('');
const detailContent = ref('');
const showDetail = (title: string, content: string) => {
detailTitle.value = title;
detailContent.value = content || '(无内容)';
detailVisible.value = true;
};
const fetchMessages = async () => {
loading.value = true;
try {
const res = await apiClient.getMessages({
page: page.value,
perPage,
group_id: searchGroupId.value,
user_id: searchUserId.value,
message: searchMessage.value,
});
messages.value = res.data!.items;
total.value = res.data!.total;
} catch (err) {
showError(err, '获取消息失败');
} finally {
loading.value = false;
}
};
// 搜索必须重置到第 1 页, 否则可能停留在超范围的页码上
const handleSearch = () => {
page.value = 1;
fetchMessages();
};
const handleDelete = async (row: ChatMessage) => {
try {
await ElMessageBox.confirm(`确定要删除消息 "${row.raw_message || row.message}" 吗?`, '删除消息', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.deleteChat('message', row.id);
ElMessage.success('已删除');
fetchMessages();
} catch (err) {
showError(err, '删除失败');
}
};
const handleBan = async (row: ChatMessage) => {
try {
await ElMessageBox.confirm(`确定要禁用这条消息吗?`, '禁用消息', {
type: 'warning',
confirmButtonText: '禁用',
cancelButtonText: '取消',
});
} catch {
return;
}
try {
await apiClient.banChat('message', row.id);
ElMessage.success('已禁用');
fetchMessages();
} catch (err) {
showError(err, '禁用失败');
}
};
const handleDeleteAll = async () => {
try {
await ElMessageBox.confirm(
'确定要删除所有消息吗?这是不可逆的操作!',
'删除所有消息',
{
type: 'error',
confirmButtonText: '全部删除',
cancelButtonText: '取消',
}
);
} catch {
return;
}
try {
await apiClient.deleteAll('message');
ElMessage.success('已删除所有消息');
fetchMessages();
} catch (err) {
showError(err, '删除失败');
}
};
// 初始加载
fetchMessages();
</script>
@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
/* 路径别名 */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Vue支持 */
"types": ["vite/client"]
},
"include": ["src", "src/**/*.ts", "src/**/*.vue", "src/**/*.tsx"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
export default defineConfig({
plugins: [vue()],
// 构建产物由 bot 在 /learning_chat 路径下提供
base: '/learning_chat/',
resolve: {
alias: {
'@': path.resolve(process.cwd(), 'src'),
},
},
server: {
port: 5173,
open: false,
host: '0.0.0.0',
// 开发模式下 API 请求转发到 bot 端口
proxy: {
'/learning_chat/api': {
target: 'http://localhost:39697',
changeOrigin: true,
},
},
},
})
@@ -0,0 +1,369 @@
import datetime
from typing import Optional, Union
from fastapi import FastAPI
from fastapi import Header, HTTPException, Depends
from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse
from jose import jwt
from nonebot import get_app, get_adapter, logger
from nonebot.adapters.onebot.v11 import Adapter
from pydantic import BaseModel
from sqlalchemy import select, delete, update, func
try:
import jieba_fast as jieba
except ImportError:
import jieba
from nonebot_plugin_orm import get_session
from .handler import LearningChat
from .models import ChatMessage, ChatContext, ChatAnswer, ChatBlackList
from .config import config_manager, driver
from .web_frontend import ensure_frontend_ready, mount_frontend
def authentication():
def inner(token: Optional[str] = Header(...)):
try:
payload = jwt.decode(token, config_manager.config.web_secret_key, algorithms="HS256")
if (
not (username := payload.get("username"))
or username != config_manager.config.web_username
):
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
except (jwt.JWTError, jwt.ExpiredSignatureError, AttributeError):
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
return Depends(inner)
class UserModel(BaseModel):
username: str
password: str
@driver.on_startup
async def init_web():
if not config_manager.config.enable_web:
return
if not await ensure_frontend_ready():
logger.warning("群聊学习 | 前端构建失败, 管理页面将不可用")
app: FastAPI = get_app()
@app.post("/learning_chat/api/login", response_class=JSONResponse)
async def login(user: UserModel):
if (
user.username != config_manager.config.web_username
or user.password != config_manager.config.web_password
):
return {"status": -100, "msg": "登录失败,请确认用户ID和密码无误"}
token = jwt.encode(
{
"username": user.username,
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=30),
},
config_manager.config.web_secret_key,
algorithm="HS256",
)
return {"status": 0, "msg": "登录成功", "data": {"token": token}}
@app.options("/learning_chat/api/login")
async def options_login():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/get_group_list", response_class=JSONResponse, dependencies=[authentication()])
async def get_group_list_api():
try:
bots = get_adapter(Adapter).bots
if len(bots) == 0:
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
bot = list(bots.values())[0]
group_list = await bot.get_group_list()
group_list = [
{"label": f'{group["group_name"]}({group["group_id"]})', "value": group["group_id"]}
for group in group_list
]
return {"status": 0, "msg": "ok", "data": {"group_list": group_list}}
except ValueError:
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
@app.options("/learning_chat/api/get_group_list")
async def options_get_group_list():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/chat_global_config", response_class=JSONResponse, dependencies=[authentication()])
async def get_chat_global_config():
# 注意: 不再注入 member_list —— 前端全局配置页不使用(仅分群配置需要,
# 由 chat_group_config 单独拉取单个群成员), 避免串行拉取全部群成员导致接口卡顿
return config_manager.config.dict(exclude={"group_config"})
@app.post("/learning_chat/api/chat_global_config", response_class=JSONResponse, dependencies=[authentication()])
async def post_chat_global_config(data: dict):
config_manager.config.update(**data)
config_manager.save()
async with get_session() as session:
await session.execute(
update(ChatContext)
.where(ChatContext.count > config_manager.config.learn_max_count)
.values(count=config_manager.config.learn_max_count)
)
await session.execute(
update(ChatAnswer)
.where(ChatAnswer.count > config_manager.config.learn_max_count)
.values(count=config_manager.config.learn_max_count)
)
await session.commit()
jieba.load_userdict(config_manager.config.dictionary)
return {"status": 0, "msg": "保存成功"}
@app.options("/learning_chat/api/chat_global_config")
async def options_chat_global_config():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/chat_group_config", response_class=JSONResponse, dependencies=[authentication()])
async def get_chat_group_config(group_id: int):
try:
bots = get_adapter(Adapter).bots
if len(bots) == 0:
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
bot = list(bots.values())[0]
members = await bot.get_group_member_list(group_id=group_id)
member_list = [
{"label": f'{member["nickname"] or member["card"]}({member["user_id"]})', "value": member["user_id"]}
for member in members
]
config = config_manager.get_group_config(group_id).dict()
config["break_probability"] = config["break_probability"] * 100
config["speak_continuously_probability"] = config["speak_continuously_probability"] * 100
config["speak_poke_probability"] = config["speak_poke_probability"] * 100
config["member_list"] = member_list
return config
except ValueError:
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
@app.post("/learning_chat/api/chat_group_config", response_class=JSONResponse, dependencies=[authentication()])
async def post_chat_group_config(group_id: Union[int, str], data: dict):
if not data.get("answer_threshold_weights"):
return {"status": 400, "msg": "回复阈值权重不能为空,必须至少有一个数值"}
data["break_probability"] = data["break_probability"] / 100
data["speak_continuously_probability"] = data["speak_continuously_probability"] / 100
data["speak_poke_probability"] = data["speak_poke_probability"] / 100
bots = get_adapter(Adapter).bots
if len(bots) == 0:
return {"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"}
bot = list(bots.values())[0]
groups = (
[{"group_id": group_id}] if group_id != "all" else await bot.get_group_list()
)
for group in groups:
config = config_manager.get_group_config(int(group["group_id"]))
config.update(**data)
config_manager.config.group_config[int(group["group_id"])] = config
config_manager.save()
return {"status": 0, "msg": "保存成功"}
@app.options("/learning_chat/api/chat_group_config")
async def options_chat_group_config():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/get_chat_messages", response_class=JSONResponse, dependencies=[authentication()])
async def get_chat_messages(
page: int = 1,
perPage: int = 10,
orderBy: str = "time",
orderDir: str = "desc",
group_id: Optional[str] = None,
user_id: Optional[str] = None,
message: Optional[str] = None,
):
async with get_session() as session:
stmt = select(ChatMessage)
if group_id:
stmt = stmt.where(ChatMessage.group_id == int(group_id))
if user_id:
stmt = stmt.where(ChatMessage.user_id == int(user_id))
if message:
stmt = stmt.where(ChatMessage.raw_message.contains(message))
order_col = getattr(ChatMessage, orderBy or "time", ChatMessage.time)
stmt = stmt.order_by(order_col.asc() if (orderDir or "desc") == "asc" else order_col.desc())
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
items_result = await session.execute(stmt.offset((page - 1) * perPage).limit(perPage))
items = [
{c.name: getattr(row, c.name) for c in ChatMessage.__table__.columns}
for row in items_result.scalars().all()
]
return {"status": 0, "msg": "ok", "data": {"items": items, "total": total}}
@app.options("/learning_chat/api/get_chat_messages")
async def options_get_chat_messages():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/get_chat_contexts", response_class=JSONResponse, dependencies=[authentication()])
async def get_chat_context(
page: int = 1,
perPage: int = 10,
orderBy: str = "time",
orderDir: str = "desc",
keywords: Optional[str] = None,
):
async with get_session() as session:
stmt = select(ChatContext)
if keywords:
stmt = stmt.where(ChatContext.keywords.contains(keywords))
order_col = getattr(ChatContext, orderBy or "time", ChatContext.time)
stmt = stmt.order_by(order_col.asc() if (orderDir or "desc") == "asc" else order_col.desc())
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
items_result = await session.execute(stmt.offset((page - 1) * perPage).limit(perPage))
items = [
{c.name: getattr(row, c.name) for c in ChatContext.__table__.columns}
for row in items_result.scalars().all()
]
return {"status": 0, "msg": "ok", "data": {"items": items, "total": total}}
@app.options("/learning_chat/api/get_chat_contexts")
async def options_get_chat_contexts():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/get_chat_answers", response_class=JSONResponse, dependencies=[authentication()])
async def get_chat_answers(
context_id: Optional[int] = None,
page: int = 1,
perPage: int = 10,
orderBy: str = "count",
orderDir: str = "desc",
keywords: Optional[str] = None,
):
async with get_session() as session:
stmt = select(ChatAnswer)
if context_id:
stmt = stmt.where(ChatAnswer.context_id == context_id)
if keywords:
stmt = stmt.where(ChatAnswer.keywords.contains(keywords))
order_col = getattr(ChatAnswer, orderBy or "count", ChatAnswer.count)
stmt = stmt.order_by(order_col.asc() if (orderDir or "desc") == "asc" else order_col.desc())
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
items_result = await session.execute(stmt.offset((page - 1) * perPage).limit(perPage))
items = []
for row in items_result.scalars().all():
item = {c.name: getattr(row, c.name) for c in ChatAnswer.__table__.columns}
item["messages"] = [{"msg": m} for m in item["messages"]]
items.append(item)
return {"status": 0, "msg": "ok", "data": {"items": items, "total": total}}
@app.options("/learning_chat/api/get_chat_answers")
async def options_get_chat_answers():
return JSONResponse(content={}, status_code=200)
@app.get("/learning_chat/api/get_chat_blacklist", response_class=JSONResponse, dependencies=[authentication()])
async def get_chat_blacklist(
page: int = 1,
perPage: int = 10,
keywords: Optional[str] = None,
bans: Optional[str] = None,
):
async with get_session() as session:
stmt = select(ChatBlackList).order_by(ChatBlackList.id.desc())
if keywords:
stmt = stmt.where(ChatBlackList.keywords.contains(keywords))
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
# bans 过滤需要基于全部数据(而非当前页), 故先全量取回再过滤、再分页
items_result = await session.execute(stmt)
items = []
for row in items_result.scalars().all():
item = {c.name: getattr(row, c.name) for c in ChatBlackList.__table__.columns}
ban_ids = item["ban_group_id"] or []
item["bans"] = "全局禁用" if item["global_ban"] else (str(ban_ids[0]) if ban_ids else "")
items.append(item)
if bans:
items = [x for x in items if bans in x["bans"]]
total = len(items)
items = items[(page - 1) * perPage : page * perPage]
return {"status": 0, "msg": "ok", "data": {"items": items, "total": total}}
@app.options("/learning_chat/api/get_chat_blacklist")
async def options_get_chat_blacklist():
return JSONResponse(content={}, status_code=200)
@app.delete("/learning_chat/api/delete_chat", response_class=JSONResponse, dependencies=[authentication()])
async def delete_chat(id: int, type: str):
try:
async with get_session() as session:
if type == "message":
await session.execute(delete(ChatMessage).where(ChatMessage.id == id))
elif type == "context":
await session.execute(delete(ChatAnswer).where(ChatAnswer.context_id == id))
await session.execute(delete(ChatContext).where(ChatContext.id == id))
elif type == "answer":
await session.execute(delete(ChatAnswer).where(ChatAnswer.id == id))
elif type == "blacklist":
await session.execute(delete(ChatBlackList).where(ChatBlackList.id == id))
await session.commit()
return {"status": 0, "msg": "删除成功"}
except Exception as e:
return {"status": 500, "msg": f"删除失败,{e}"}
@app.options("/learning_chat/api/delete_chat")
async def options_delete_chat():
return JSONResponse(content={}, status_code=200)
@app.put("/learning_chat/api/ban_chat", response_class=JSONResponse, dependencies=[authentication()])
async def ban_chat(id: int, type: str):
try:
async with get_session() as session:
if type == "message":
result = await session.execute(select(ChatMessage).where(ChatMessage.id == id))
data = result.scalar_one()
elif type == "context":
result = await session.execute(select(ChatContext).where(ChatContext.id == id))
data = result.scalar_one()
else:
result = await session.execute(select(ChatAnswer).where(ChatAnswer.id == id))
data = result.scalar_one()
await LearningChat.add_ban(data)
return {"status": 0, "msg": "禁用成功"}
except Exception as e:
return {"status": 500, "msg": f"禁用失败: {e}"}
@app.options("/learning_chat/api/ban_chat")
async def options_ban_chat():
return JSONResponse(content={}, status_code=200)
@app.put("/learning_chat/api/delete_all", response_class=JSONResponse, dependencies=[authentication()])
async def delete_all(type: str, id: Optional[int] = None):
try:
async with get_session() as session:
if type == "answer":
if id:
await session.execute(delete(ChatAnswer).where(ChatAnswer.context_id == id))
else:
await session.execute(delete(ChatAnswer))
elif type == "blacklist":
await session.execute(delete(ChatBlackList))
elif type == "context":
await session.execute(delete(ChatContext))
elif type == "message":
await session.execute(delete(ChatMessage))
await session.commit()
return {"status": 0, "msg": "操作成功"}
except Exception as e:
return {"status": 500, "msg": f"操作失败,{e}"}
@app.options("/learning_chat/api/delete_all")
async def options_delete_all():
return JSONResponse(content={}, status_code=200)
# 静态资源挂载放在所有 API 路由之后, 保证 /learning_chat/api/* 优先匹配
mount_frontend(app)
@@ -0,0 +1,99 @@
"""前端管理页面的构建与静态挂载。
方案: 不再由 bot 拉起 vite dev server, 而是把构建产物用 FastAPI StaticFiles
挂载到 bot 端口 /learning_chat 路径, 与 API 同源, 无孤儿进程、无 CORS 问题。
前端开发时手动 `npm run dev` 即可(API 已配置 dev proxy)。
"""
import asyncio
from pathlib import Path
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from nonebot import logger
BASE_DIR = Path(__file__).resolve().parent
ADMIN_DIR = BASE_DIR / "web" / "learning-chat-admin"
DIST_DIR = ADMIN_DIR / "dist"
# 参与构建新鲜度检查的源文件(不在 src/ 下的)
SRC_FILES = ("vite.config.ts", "index.html", "package.json")
def _dist_is_stale() -> bool:
"""dist/index.html 不存在, 或比任何源码旧时视为需要重建"""
dist_index = DIST_DIR / "index.html"
if not dist_index.exists():
return True
dist_mtime = dist_index.stat().st_mtime
for src_file in (ADMIN_DIR / "src").rglob("*"):
if src_file.is_file() and src_file.stat().st_mtime > dist_mtime:
return True
return any(
(ADMIN_DIR / name).stat().st_mtime > dist_mtime
for name in SRC_FILES
if (ADMIN_DIR / name).exists()
)
async def build_frontend() -> bool:
"""执行 vite 构建, 返回是否成功"""
logger.info("群聊学习 | 前端源码已更新, 开始重新构建(约10-40秒)...")
proc = await asyncio.create_subprocess_exec(
"npx.cmd",
"vite",
"build",
cwd=str(ADMIN_DIR),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
output, _ = await proc.communicate()
if proc.returncode != 0:
logger.error(
"群聊学习 | 前端构建失败(%s):\n%s",
proc.returncode,
output.decode(errors="ignore")[-3000:],
)
return False
logger.info("群聊学习 | 前端构建完成")
return True
async def ensure_frontend_ready() -> bool:
"""确保构建产物是最新的, 返回是否可用(最新或已成功重建)"""
if not _dist_is_stale():
return True
return await build_frontend()
def mount_frontend(app) -> None:
"""将构建产物挂载到 bot 端口的 /learning_chat 路径(需在 API 路由注册后调用)。
history 模式 SPA 需要回退: assets/ 静态挂载 + 真实文件直接返回,
其余路径(如 /login /config 等前端路由)一律回退到 index.html。
"""
if not DIST_DIR.exists():
logger.error("群聊学习 | 前端构建产物不存在, 管理页面不可用")
return
assets_dir = DIST_DIR / "assets"
if assets_dir.exists():
app.mount(
"/learning_chat/assets",
StaticFiles(directory=str(assets_dir)),
name="learning_chat_assets",
)
dist_resolved = DIST_DIR.resolve()
@app.get("/learning_chat/{full_path:path}")
async def learning_chat_spa(full_path: str):
target = (DIST_DIR / full_path).resolve()
if full_path and target.is_file() and target.is_relative_to(dist_resolved):
return FileResponse(target)
# SPA 回退: 前端路由(如 /login /config)全部交给 index.html
return FileResponse(DIST_DIR / "index.html")
@app.get("/learning_chat")
async def learning_chat_root():
return FileResponse(DIST_DIR / "index.html")
logger.info("群聊学习 | 管理页面已挂载: http://<host>:<port>/learning_chat/")
@@ -0,0 +1,296 @@
# -*- coding: utf-8 -*-
"""群聊学习 Web API 子应用(挂载到 /api/learning_chat)。
统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
旧版独立后台 /learning_chat(JWT 自鉴权)保留可用,前端由 hub 渲染。
"""
from __future__ import annotations
from typing import Optional, Union
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from sqlalchemy import delete, select, update, func
try:
import jieba_fast as jieba
except ImportError:
import jieba
from nonebot import get_adapter
from nonebot.adapters.onebot.v11 import Adapter
from nonebot_plugin_orm import get_session
from hexi.web_auth import require_admin
from .handler import LearningChat
from .models import ChatMessage, ChatContext, ChatAnswer, ChatBlackList
from .config import config_manager
API = require_admin
def _ok(data=None, msg: str = "ok") -> JSONResponse:
return JSONResponse({"status": 0, "msg": msg, "data": data})
async def _first_bot():
try:
bots = get_adapter(Adapter).bots
return next(iter(bots.values()), None)
except (ValueError, AttributeError):
return None
def build_admin_app() -> FastAPI | None:
"""构建群聊学习管理 API 子应用(挂载到 /api/learning_chat)。"""
if not config_manager.config.enable_web:
return None
app = FastAPI(title="Learning Chat API")
auth = API
@app.get("/get_group_list", response_class=JSONResponse, dependencies=[auth])
async def get_group_list_api():
bot = await _first_bot()
if bot is None:
return JSONResponse({"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"})
group_list = await bot.get_group_list()
group_list = [
{"label": f'{group["group_name"]}({group["group_id"]})', "value": group["group_id"]}
for group in group_list
]
return _ok({"group_list": group_list})
@app.get("/chat_global_config", response_class=JSONResponse, dependencies=[auth])
async def get_chat_global_config():
return config_manager.config.dict(exclude={"group_config"})
@app.post("/chat_global_config", response_class=JSONResponse, dependencies=[auth])
async def post_chat_global_config(data: dict):
config_manager.config.update(**data)
config_manager.save()
async with get_session() as session:
await session.execute(
update(ChatContext)
.where(ChatContext.count > config_manager.config.learn_max_count)
.values(count=config_manager.config.learn_max_count)
)
await session.execute(
update(ChatAnswer)
.where(ChatAnswer.count > config_manager.config.learn_max_count)
.values(count=config_manager.config.learn_max_count)
)
await session.commit()
jieba.load_userdict(config_manager.config.dictionary)
return {"status": 0, "msg": "保存成功"}
@app.get("/chat_group_config", response_class=JSONResponse, dependencies=[auth])
async def get_chat_group_config(group_id: int):
bot = await _first_bot()
if bot is None:
return JSONResponse({"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"})
members = await bot.get_group_member_list(group_id=group_id)
member_list = [
{"label": f'{member["nickname"] or member["card"]}({member["user_id"]})', "value": member["user_id"]}
for member in members
]
config = config_manager.get_group_config(group_id).dict()
config["break_probability"] = config["break_probability"] * 100
config["speak_continuously_probability"] = config["speak_continuously_probability"] * 100
config["speak_poke_probability"] = config["speak_poke_probability"] * 100
config["member_list"] = member_list
return config
@app.post("/chat_group_config", response_class=JSONResponse, dependencies=[auth])
async def post_chat_group_config(group_id: Union[int, str], data: dict):
if not data.get("answer_threshold_weights"):
return JSONResponse({"status": 400, "msg": "回复阈值权重不能为空,必须至少有一个数值"})
data["break_probability"] = data["break_probability"] / 100
data["speak_continuously_probability"] = data["speak_continuously_probability"] / 100
data["speak_poke_probability"] = data["speak_poke_probability"] / 100
bot = await _first_bot()
if bot is None:
return JSONResponse({"status": -100, "msg": "获取群和好友列表失败,请确认已连接GOCQ"})
groups = (
[{"group_id": group_id}] if group_id != "all" else await bot.get_group_list()
)
for group in groups:
config = config_manager.get_group_config(int(group["group_id"]))
config.update(**data)
config_manager.config.group_config[int(group["group_id"])] = config
config_manager.save()
return {"status": 0, "msg": "保存成功"}
@app.get("/get_chat_messages", response_class=JSONResponse, dependencies=[auth])
async def get_chat_messages(
page: int = 1,
perPage: int = 10,
orderBy: str = "time",
orderDir: str = "desc",
group_id: Optional[str] = None,
user_id: Optional[str] = None,
message: Optional[str] = None,
):
async with get_session() as session:
stmt = select(ChatMessage)
if group_id:
stmt = stmt.where(ChatMessage.group_id == int(group_id))
if user_id:
stmt = stmt.where(ChatMessage.user_id == int(user_id))
if message:
stmt = stmt.where(ChatMessage.raw_message.contains(message))
order_col = getattr(ChatMessage, orderBy or "time", ChatMessage.time)
stmt = stmt.order_by(order_col.asc() if (orderDir or "desc") == "asc" else order_col.desc())
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
items_result = await session.execute(stmt.offset((page - 1) * perPage).limit(perPage))
items = [
{c.name: getattr(row, c.name) for c in ChatMessage.__table__.columns}
for row in items_result.scalars().all()
]
return _ok({"items": items, "total": total})
@app.get("/get_chat_contexts", response_class=JSONResponse, dependencies=[auth])
async def get_chat_context(
page: int = 1,
perPage: int = 10,
orderBy: str = "time",
orderDir: str = "desc",
keywords: Optional[str] = None,
):
async with get_session() as session:
stmt = select(ChatContext)
if keywords:
stmt = stmt.where(ChatContext.keywords.contains(keywords))
order_col = getattr(ChatContext, orderBy or "time", ChatContext.time)
stmt = stmt.order_by(order_col.asc() if (orderDir or "desc") == "asc" else order_col.desc())
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
items_result = await session.execute(stmt.offset((page - 1) * perPage).limit(perPage))
items = [
{c.name: getattr(row, c.name) for c in ChatContext.__table__.columns}
for row in items_result.scalars().all()
]
return _ok({"items": items, "total": total})
@app.get("/get_chat_answers", response_class=JSONResponse, dependencies=[auth])
async def get_chat_answers(
context_id: Optional[int] = None,
page: int = 1,
perPage: int = 10,
orderBy: str = "count",
orderDir: str = "desc",
keywords: Optional[str] = None,
):
async with get_session() as session:
stmt = select(ChatAnswer)
if context_id:
stmt = stmt.where(ChatAnswer.context_id == context_id)
if keywords:
stmt = stmt.where(ChatAnswer.keywords.contains(keywords))
order_col = getattr(ChatAnswer, orderBy or "count", ChatAnswer.count)
stmt = stmt.order_by(order_col.asc() if (orderDir or "desc") == "asc" else order_col.desc())
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
items_result = await session.execute(stmt.offset((page - 1) * perPage).limit(perPage))
items = []
for row in items_result.scalars().all():
item = {c.name: getattr(row, c.name) for c in ChatAnswer.__table__.columns}
item["messages"] = [{"msg": m} for m in item["messages"]] if item["messages"] else None
items.append(item)
return _ok({"items": items, "total": total})
@app.get("/get_chat_blacklist", response_class=JSONResponse, dependencies=[auth])
async def get_chat_blacklist(
page: int = 1,
perPage: int = 10,
keywords: Optional[str] = None,
bans: Optional[str] = None,
):
async with get_session() as session:
stmt = select(ChatBlackList).order_by(ChatBlackList.id.desc())
if keywords:
stmt = stmt.where(ChatBlackList.keywords.contains(keywords))
count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
items_result = await session.execute(stmt)
items = []
for row in items_result.scalars().all():
item = {c.name: getattr(row, c.name) for c in ChatBlackList.__table__.columns}
ban_ids = item["ban_group_id"] or []
item["bans"] = "全局禁用" if item["global_ban"] else (str(ban_ids[0]) if ban_ids else "")
items.append(item)
if bans:
items = [x for x in items if bans in x["bans"]]
total = len(items)
items = items[(page - 1) * perPage : page * perPage]
return _ok({"items": items, "total": total})
@app.delete("/delete_chat", response_class=JSONResponse, dependencies=[auth])
async def delete_chat(id: int, type: str):
try:
async with get_session() as session:
if type == "message":
await session.execute(delete(ChatMessage).where(ChatMessage.id == id))
elif type == "context":
await session.execute(delete(ChatAnswer).where(ChatAnswer.context_id == id))
await session.execute(delete(ChatContext).where(ChatContext.id == id))
elif type == "answer":
await session.execute(delete(ChatAnswer).where(ChatAnswer.id == id))
elif type == "blacklist":
await session.execute(delete(ChatBlackList).where(ChatBlackList.id == id))
await session.commit()
return {"status": 0, "msg": "删除成功"}
except Exception as e:
return JSONResponse({"status": 500, "msg": f"删除失败,{e}"})
@app.put("/ban_chat", response_class=JSONResponse, dependencies=[auth])
async def ban_chat(id: int, type: str):
try:
async with get_session() as session:
if type == "message":
result = await session.execute(select(ChatMessage).where(ChatMessage.id == id))
data = result.scalar_one()
elif type == "context":
result = await session.execute(select(ChatContext).where(ChatContext.id == id))
data = result.scalar_one()
else:
result = await session.execute(select(ChatAnswer).where(ChatAnswer.id == id))
data = result.scalar_one()
await LearningChat.add_ban(data)
return {"status": 0, "msg": "禁用成功"}
except Exception as e:
return JSONResponse({"status": 500, "msg": f"禁用失败: {e}"})
@app.put("/delete_all", response_class=JSONResponse, dependencies=[auth])
async def delete_all(type: str, id: Optional[int] = None):
try:
async with get_session() as session:
if type == "answer":
if id:
await session.execute(delete(ChatAnswer).where(ChatAnswer.context_id == id))
else:
await session.execute(delete(ChatAnswer))
elif type == "blacklist":
await session.execute(delete(ChatBlackList))
elif type == "context":
await session.execute(delete(ChatContext))
elif type == "message":
await session.execute(delete(ChatMessage))
await session.commit()
return {"status": 0, "msg": "操作成功"}
except Exception as e:
return JSONResponse({"status": 500, "msg": f"操作失败,{e}"})
return app