Merge branch 'master' of github.com:AraragiEro/AstrBot

This commit is contained in:
Alero
2025-02-12 03:06:23 +08:00
15 changed files with 287 additions and 41 deletions

View File

@@ -2,7 +2,7 @@
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
"""
VERSION = "3.4.25"
VERSION = "3.4.26"
DB_PATH = "data/data_v3.db"
# 默认配置
@@ -28,7 +28,10 @@ DEFAULT_CONFIG = {
"segmented_reply": {
"enable": False,
"only_llm_result": True,
"interval_method": "random",
"interval": "1.5,3.5",
"log_base": 2.6,
"words_count_threshold": 150,
"regex": ".*?[。?!~…]+|.+$"
},
"no_permission_reply": True,
@@ -66,6 +69,7 @@ DEFAULT_CONFIG = {
}
},
"content_safety": {
"also_use_in_response": False,
"internal_keywords": {"enable": True, "extra_keywords": []},
"baidu_aip": {"enable": False, "app_id": "", "api_key": "", "secret_key": ""},
},
@@ -79,6 +83,7 @@ DEFAULT_CONFIG = {
"enable": True,
"username": "astrbot",
"password": "77b90590a8945a7d36c963981a307dc9",
"port": 6185
},
"platform": [],
"wake_prefix": ["/"],
@@ -115,9 +120,7 @@ CONFIG_METADATA_2 = {
"enable": False,
"appid": "",
"secret": "",
"port": 6196,
"enable_group_c2c": True,
"enable_guild_direct_message": True,
"port": 6196
},
"aiocqhtp(QQ)": {
"id": "default",
@@ -240,10 +243,26 @@ CONFIG_METADATA_2 = {
"description": "仅对 LLM 结果分段",
"type": "bool",
},
"interval_method": {
"description": "间隔时间计算方法",
"type": "string",
"options": ["random", "log"],
"hint": "分段回复的间隔时间计算方法。random 为随机时间log 为根据消息长度计算,$y=log_{log\_base}(x)$x为字数y的单位为秒。",
},
"interval": {
"description": "随机间隔时间(秒)",
"type": "string",
"hint": "每一段回复的间隔时间,格式为 `最小时间,最大时间`。如 `0.75,2.5`",
"hint": "`random` 方法用。每一段回复的间隔时间,格式为 `最小时间,最大时间`。如 `0.75,2.5`",
},
"log_base": {
"description": "对数函数底数",
"type": "float",
"hint": "`log` 方法用。对数函数的底数。默认为 2.6",
},
"words_count_threshold": {
"description": "字数阈值",
"type": "int",
"hint": "超过这个字数的消息不会被分段回复。默认为 150",
},
"regex": {
"description": "正则表达式",
@@ -310,6 +329,11 @@ CONFIG_METADATA_2 = {
"description": "内容安全",
"type": "object",
"items": {
"also_use_in_response": {
"description": "对大模型响应安全审核",
"type": "bool",
"hint": "启用后,大模型的响应也会通过内容安全审核。",
},
"baidu_aip": {
"description": "百度内容审核配置",
"type": "object",

View File

@@ -17,11 +17,13 @@ class ContentSafetyCheckStage(Stage):
config = ctx.astrbot_config['content_safety']
self.strategy_selector = StrategySelector(config)
async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]:
async def process(self, event: AstrMessageEvent, check_text: str = None) -> Union[None, AsyncGenerator[None, None]]:
'''检查内容安全'''
ok, info = self.strategy_selector.check(event.get_message_str())
text = check_text if check_text else event.get_message_str()
ok, info = self.strategy_selector.check(text)
if not ok:
event.set_result(MessageEventResult().message("你的消息中包含不适当的内容,已被屏蔽。"))
event.set_result(MessageEventResult().message("你的消息或者大模型的响应中包含不适当的内容,已被屏蔽。"))
yield
event.stop_event()
logger.info(f"内容安全检查不通过,原因:{info}")
return

View File

@@ -46,6 +46,8 @@ class DifyRequestSubStage(Stage):
if not req.prompt:
return
req.session_id = event.unified_msg_origin
try:
logger.debug(f"Dify 请求 Payload: {req.__dict__}")

View File

@@ -111,10 +111,10 @@ class LLMRequestSubStage(Stage):
# 尝试调用工具函数
wrapper = self._call_handler(self.ctx, event, func_tool.handler, **func_tool_args)
async for resp in wrapper:
if resp is not None:
if resp is not None: # 有 return 返回
function_calling_result[func_tool_name] = resp
else:
yield
yield # 有生成器返回
event.clear_result() # 清除上一个 handler 的结果
except BaseException as e:
logger.warning(traceback.format_exc())

View File

@@ -1,11 +1,13 @@
import random
import asyncio
import math
from typing import Union, AsyncGenerator
from ..stage import register_stage, Stage
from ..context import PipelineContext
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core import logger
from astrbot.core.message.message_event_result import BaseMessageComponent, Plain
from astrbot.core.star.star_handler import star_handlers_registry, EventType
@register_stage
@@ -16,6 +18,9 @@ class RespondStage(Stage):
# 分段回复
self.enable_seg: bool = ctx.astrbot_config['platform_settings']['segmented_reply']['enable']
self.only_llm_result = ctx.astrbot_config['platform_settings']['segmented_reply']['only_llm_result']
self.interval_method = ctx.astrbot_config['platform_settings']['segmented_reply']['interval_method']
self.log_base = float(ctx.astrbot_config['platform_settings']['segmented_reply']['log_base'])
interval_str: str = ctx.astrbot_config['platform_settings']['segmented_reply']['interval']
interval_str_ls = interval_str.replace(" ", "").split(",")
try:
@@ -24,7 +29,27 @@ class RespondStage(Stage):
logger.error(f'解析分段回复的间隔时间失败。{e}')
self.interval = [1.5, 3.5]
logger.info(f"分段回复间隔时间:{self.interval}")
async def _word_cnt(self, text: str) -> int:
'''分段回复 统计字数'''
if all(ord(c) < 128 for c in text):
word_count = len(text.split())
else:
word_count = len([c for c in text if c.isalnum()])
return word_count
async def _calc_comp_interval(self, comp: BaseMessageComponent) -> float:
'''分段回复 计算间隔时间'''
if self.interval_method == 'log':
if isinstance(comp, Plain):
wc = await self._word_cnt(comp.text)
i = math.log(wc + 1, self.log_base)
return random.uniform(i, i + 0.5)
else:
return random.uniform(1, 1.75)
else:
# random
return random.uniform(self.interval[0], self.interval[1])
async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]:
result = event.get_result()
@@ -37,8 +62,9 @@ class RespondStage(Stage):
if self.enable_seg and ((self.only_llm_result and result.is_llm_result()) or not self.only_llm_result):
# 分段回复
for comp in result.chain:
i = await self._calc_comp_interval(comp)
await asyncio.sleep(i)
await event.send(MessageChain([comp]))
await asyncio.sleep(random.uniform(self.interval[0], self.interval[1]))
else:
await event.send(result)
await event._post_send()

View File

@@ -2,7 +2,7 @@ import time
import re
import traceback
from typing import Union, AsyncGenerator
from ..stage import register_stage
from ..stage import Stage, register_stage, registered_stages
from ..context import PipelineContext
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.platform.message_type import MessageType
@@ -12,7 +12,7 @@ from astrbot.core import html_renderer
from astrbot.core.star.star_handler import star_handlers_registry, EventType
@register_stage
class ResultDecorateStage:
class ResultDecorateStage(Stage):
async def initialize(self, ctx: PipelineContext):
self.ctx = ctx
self.reply_prefix = ctx.astrbot_config['platform_settings']['reply_prefix']
@@ -27,19 +27,43 @@ class ResultDecorateStage:
self.t2i_word_threshold = 150
# 分段回复
self.words_count_threshold = int(ctx.astrbot_config['platform_settings']['segmented_reply']['words_count_threshold'])
self.enable_segmented_reply = ctx.astrbot_config['platform_settings']['segmented_reply']['enable']
self.only_llm_result = ctx.astrbot_config['platform_settings']['segmented_reply']['only_llm_result']
self.regex = ctx.astrbot_config['platform_settings']['segmented_reply']['regex']
# exception
self.content_safe_check_reply = ctx.astrbot_config['content_safety']['also_use_in_response']
self.content_safe_check_stage = None
if self.content_safe_check_reply:
for stage in registered_stages:
if stage.__class__.__name__ == "ContentSafetyCheckStage":
self.content_safe_check_stage = stage
async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]:
result = event.get_result()
if result is None:
return
# 回复时检查内容安全
if self.content_safe_check_reply and self.content_safe_check_stage and result.is_llm_result():
text = ""
for comp in result.chain:
if isinstance(comp, Plain):
text += comp.text
async for _ in self.content_safe_check_stage.process(event, check_text=text):
yield
handlers = star_handlers_registry.get_handlers_by_event_type(EventType.OnDecoratingResultEvent)
for handler in handlers:
await handler.handler(event)
# 需要再获取一次。插件可能直接对 chain 进行了替换。
result = event.get_result()
if result is None:
return
if len(result.chain) > 0:
# 回复前缀
if self.reply_prefix:
@@ -54,6 +78,10 @@ class ResultDecorateStage:
new_chain = []
for comp in result.chain:
if isinstance(comp, Plain):
if len(comp.text) > self.words_count_threshold:
# 不分段回复
new_chain.append(comp)
continue
split_response = re.findall(self.regex, comp.text)
if not split_response:
new_chain.append(comp)

View File

@@ -17,7 +17,7 @@ class QQOfficialWebhook():
def __init__(self, config: dict, event_queue: asyncio.Queue, botpy_client: Client):
self.appid = config['appid']
self.secret = config['secret']
self.port = config.get("port", 6194)
self.port = config.get("port", 6196)
if isinstance(self.port, str):
self.port = int(self.port)

View File

@@ -35,6 +35,7 @@ class ProviderDify(Provider):
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.conversation_ids = {}
'''记录当前 session id 的对话 ID'''
async def text_chat(

View File

@@ -219,7 +219,7 @@ def register_llm_tool(name: str = None):
```
@llm_tool(name="get_weather") # 如果 name 不填,将使用函数名
async def get_weather(event: AstrMessageEvent, location: str) -> MessageEventResult:
async def get_weather(event: AstrMessageEvent, location: str):
\'\'\'获取天气信息。
Args:
@@ -229,7 +229,22 @@ def register_llm_tool(name: str = None):
```
可接受的参数类型有string, number, object, array, boolean。
返回值:
- 返回 str结果会被加入下一次 LLM 请求的 prompt 中,用于让 LLM 总结工具返回的结果
- 返回 None结果不会被加入下一次 LLM 请求的 prompt 中。
可以使用 yield 发送消息、终止事件。
发送消息:请参考文档。
终止事件:
```
event.stop_event()
yield
```
'''
name_ = name
def decorator(awaitable: Awaitable):

View File

@@ -119,4 +119,55 @@ class DifyAPIClient:
return await resp.json() # {"id": "xxx", ...}
async def close(self):
await self.session.close()
await self.session.close()
async def get_chat_convs(
self,
user: str,
limit: int = 20
):
# conversations. GET
url = f"{self.api_base}/conversations"
payload = {
"user": user,
"limit": limit,
}
async with self.session.get(
url, params=payload, headers=self.headers
) as resp:
return await resp.json()
async def delete_chat_conv(
self,
user: str,
conversation_id: str
):
# conversation. DELETE
url = f"{self.api_base}/conversations/{conversation_id}"
payload = {
"user": user,
}
async with self.session.delete(
url, json=payload, headers=self.headers
) as resp:
return await resp.json()
async def rename(
self,
conversation_id: str,
name: str,
user: str,
auto_generate: bool = False
):
# /conversations/:conversation_id/name
url = f"{self.api_base}/conversations/{conversation_id}/name"
payload = {
"user": user,
"name": name,
"auto_generate": auto_generate,
}
async with self.session.post(
url, json=payload, headers=self.headers
) as resp:
return await resp.json()

View File

@@ -7,6 +7,7 @@ import aiohttp
import base64
import zipfile
import uuid
import psutil
from typing import Union
from PIL import Image
@@ -160,17 +161,17 @@ def file_to_base64(file_path: str) -> str:
base64_str = base64.b64encode(data_bytes).decode()
return "base64://" + base64_str
def get_local_ip_addresses():
ip = ''
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
except BaseException:
pass
finally:
s.close()
return ip
net_interfaces = psutil.net_if_addrs()
network_ips = []
for interface, addrs in net_interfaces.items():
for addr in addrs:
if addr.family == socket.AF_INET: # 使用 socket.AF_INET 代替 psutil.AF_INET
network_ips.append(addr.address)
return network_ips
async def get_dashboard_version():
if os.path.exists("data/dist"):

View File

@@ -2,6 +2,7 @@ import logging
import jwt
import asyncio
import os
from astrbot.core.config.default import VERSION
from quart import Quart, request, jsonify, g
from quart.logging import default_handler
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -67,15 +68,21 @@ class AstrBotDashboard():
logger.info("管理面板已关闭。")
def run(self):
ip_addr = get_local_ip_addresses()
logger.info(f"""
✨✨✨
AstrBot 管理面板已启动,可访问
try:
ip_addr = get_local_ip_addresses()
except Exception as e:
ip_addr = []
port = self.core_lifecycle.astrbot_config['dashboard'].get("port", 6185)
if isinstance(port, str):
port = int(port)
display = f"\n ✨✨✨\n AstrBot v{VERSION} 管理面板已启动,可访问\n\n"
display += f" ➜ 本地: http://localhost:{port}\n"
for ip in ip_addr:
display += f" ➜ 网络: http://{ip}:{port}\n"
display += " ➜ 默认用户名和密码: astrbot\n ✨✨✨\n"
logger.info(display)
1. http://{ip_addr}:6185
2. http://localhost:6185
默认用户名和密码是 astrbot。
✨✨✨
""")
return self.app.run_task(host="0.0.0.0", port=6185, shutdown_trigger=self.shutdown_trigger_placeholder)
return self.app.run_task(host="0.0.0.0", port=port, shutdown_trigger=self.shutdown_trigger_placeholder)

12
changelogs/v3.4.26.md Normal file
View File

@@ -0,0 +1,12 @@
# What's Changed
1. ✨ 新增: 支持 Webhook 方式接入 QQ 官方机器人接口
2. ✨ 新增: 支持完善的 Dify Chat 模式对话管理,包括 /new /switch /del /ls /reset 均已适配 Dify Chat 模式。
3. ✨ 新增: 支持基于对数函数的分段回复延时时间计算 #414
4. ✨ 新增: 支持设置管理面板的端口号
5. ✨ 新增: 支持对大模型的响应进行内容审查 #474
6. 🐛 修复: gewechat 不能发送主动消息 #402
7. 🐛 修复: dify Chat 模式无法重置会话 #469
8. 🐛 修复: ensure result is retrieved again to handle potential plugin chain replacements
9. 🐛 优化: 将 Gewechat 所有事件下发到流水线供插件开发
10. 🐛 修复: correct dashboard update tooltip typo by @Akuma-real

View File

@@ -28,7 +28,7 @@ const sidebarMenu = shallowRef(sidebarItems);
</v-list-item>
<small style="display: block;" v-if="buildVer">构建: {{ buildVer }}</small>
<small style="display: block;" v-else>构建: embedded</small>
<v-tooltip text="使用 /dashbord_update 指令更新管理面板">
<v-tooltip text="使用 /dashboard_update 指令更新管理面板">
<template v-slot:activator="{ props }">
<small v-bind="props" v-if="hasWebUIUpdate" style="display: block; margin-top: 4px;">面板有更新</small>
</template>

View File

@@ -8,6 +8,7 @@ from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.api import sp
from astrbot.api.platform import MessageType
from astrbot.api.provider import Personality, ProviderRequest, LLMResponse
from astrbot.core.provider.sources.dify_source import ProviderDify
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
from astrbot.core.star.star_handler import star_handlers_registry, StarHandlerMetadata
from astrbot.core.star.star import star_map
@@ -348,6 +349,14 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
message.set_result(MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"))
return
provider = self.context.get_using_provider()
print(provider.meta())
if provider and provider.meta().type == 'dify':
assert isinstance(provider, ProviderDify)
await provider.forget(message.unified_msg_origin)
message.set_result(MessageEventResult().message("已重置当前 Dify 会话,新聊天将更换到新的会话。"))
return
cid = await self.context.conversation_manager.get_curr_conversation_id(message.unified_msg_origin)
if not cid:
@@ -447,6 +456,24 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.command("ls")
async def convs(self, message: AstrMessageEvent, page: int = 1):
'''查看对话列表'''
provider = self.context.get_using_provider()
if provider and provider.meta().type == 'dify':
ret = "Dify 对话列表:\n"
assert isinstance(provider, ProviderDify)
data = await provider.api_client.get_chat_convs(message.unified_msg_origin)
idx = 1
for conv in data['data']:
ts_h = datetime.datetime.fromtimestamp(conv['updated_at']).strftime('%m-%d %H:%M')
ret += f"{idx}. {conv['name']}({conv['id'][:4]})\n 上次更新:{ts_h}\n"
idx += 1
if idx == 1:
ret += "没有找到任何对话。"
dify_cid = provider.conversation_ids.get(message.unified_msg_origin, None)
ret += f"\n\n用户: {message.unified_msg_origin}\n当前对话: {dify_cid}\n使用 /switch <序号> 切换对话。"
message.set_result(MessageEventResult().message(ret))
return
size_per_page = 6
conversations = await self.context.conversation_manager.get_conversations(message.unified_msg_origin)
total_pages = len(conversations) // size_per_page
@@ -491,12 +518,41 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.command("new")
async def new_conv(self, message: AstrMessageEvent):
'''创建新对话'''
provider = self.context.get_using_provider()
if provider and provider.meta().type == 'dify':
assert isinstance(provider, ProviderDify)
await provider.forget(message.unified_msg_origin)
message.set_result(MessageEventResult().message("成功,下次聊天将是新对话。"))
return
cid = await self.context.conversation_manager.new_conversation(message.unified_msg_origin)
message.set_result(MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"))
@filter.command("switch")
async def switch_conv(self, message: AstrMessageEvent, index: int = None):
'''通过 /ls 前面的序号切换对话'''
provider = self.context.get_using_provider()
if provider and provider.meta().type == 'dify':
assert isinstance(provider, ProviderDify)
data = await provider.api_client.get_chat_convs(message.unified_msg_origin)
if not data['data']:
message.set_result(MessageEventResult().message("未找到任何对话。"))
return
selected_conv = None
if index is not None:
try:
selected_conv = data['data'][index-1]
except IndexError:
message.set_result(MessageEventResult().message("对话序号错误,请使用 /ls 查看"))
return
else:
selected_conv = data['data'][0]
ret = f"Dify 切换到对话: {selected_conv['name']}({selected_conv['id'][:4]})。"
provider.conversation_ids[message.unified_msg_origin] = selected_conv['id']
message.set_result(MessageEventResult().message(ret))
return
if index is None:
message.set_result(MessageEventResult().message("请输入对话序号。/switch 对话序号。/ls 查看对话 /new 新建对话"))
return
@@ -512,6 +568,18 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.command("rename")
async def rename_conv(self, message: AstrMessageEvent, new_name: str):
'''重命名对话'''
provider = self.context.get_using_provider()
if provider and provider.meta().type == 'dify':
assert isinstance(provider, ProviderDify)
cid = provider.conversation_ids.get(message.unified_msg_origin, None)
if not cid:
message.set_result(MessageEventResult().message("未找到当前对话。"))
return
await provider.api_client.rename(cid, new_name, message.unified_msg_origin)
message.set_result(MessageEventResult().message("重命名对话成功。"))
return
await self.context.conversation_manager.update_conversation_title(message.unified_msg_origin, new_name)
message.set_result(MessageEventResult().message("重命名对话成功。"))
@@ -519,6 +587,15 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.command("del")
async def del_conv(self, message: AstrMessageEvent):
'''删除当前对话'''
provider = self.context.get_using_provider()
if provider and provider.meta().type == 'dify':
assert isinstance(provider, ProviderDify)
await provider.api_client.delete_chat_conv(message.unified_msg_origin)
provider.conversation_ids.pop(message.unified_msg_origin, None)
message.set_result(MessageEventResult().message("删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"))
return
session_curr_cid = await self.context.conversation_manager.get_curr_conversation_id(message.unified_msg_origin)
if not session_curr_cid: