Compare commits

..

1 Commits

Author SHA1 Message Date
Soulter 54340cca18 stage 2025-10-10 19:41:18 +08:00
16 changed files with 291 additions and 201 deletions
+6 -3
View File
@@ -6,7 +6,7 @@ import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.3.3" VERSION = "4.3.2"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
# 默认配置 # 默认配置
@@ -1056,7 +1056,6 @@ CONFIG_METADATA_2 = {
"timeout": "20", "timeout": "20",
}, },
"阿里云百炼 TTS(API)": { "阿里云百炼 TTS(API)": {
"hint": "API Key 从 https://bailian.console.aliyun.com/?tab=model#/api-key 获取。模型和音色的选择文档请参考: 阿里云百炼语音合成音色名称。具体可参考 https://help.aliyun.com/zh/model-studio/speech-synthesis-and-speech-recognition",
"id": "dashscope_tts", "id": "dashscope_tts",
"provider": "dashscope", "provider": "dashscope",
"type": "dashscope_tts", "type": "dashscope_tts",
@@ -1436,7 +1435,11 @@ CONFIG_METADATA_2 = {
"description": "服务订阅密钥", "description": "服务订阅密钥",
"hint": "Azure_TTS 服务的订阅密钥(注意不是令牌)", "hint": "Azure_TTS 服务的订阅密钥(注意不是令牌)",
}, },
"dashscope_tts_voice": {"description": "音色", "type": "string"}, "dashscope_tts_voice": {
"description": "语音合成模型",
"type": "string",
"hint": "阿里云百炼语音合成模型名称。具体可参考 https://help.aliyun.com/zh/model-studio/developer-reference/cosyvoice-python-api 等内容",
},
"gm_resp_image_modal": { "gm_resp_image_modal": {
"description": "启用图片模态", "description": "启用图片模态",
"type": "bool", "type": "bool",
+1 -2
View File
@@ -68,8 +68,7 @@ class Provider(AbstractProvider):
def get_keys(self) -> List[str]: def get_keys(self) -> List[str]:
"""获得提供商 Key""" """获得提供商 Key"""
keys = self.provider_config.get("key", [""]) return self.provider_config.get("key", [])
return keys or [""]
@abc.abstractmethod @abc.abstractmethod
def set_key(self, key: str): def set_key(self, key: str):
@@ -33,7 +33,7 @@ class ProviderAnthropic(Provider):
) )
self.chosen_api_key: str = "" self.chosen_api_key: str = ""
self.api_keys: List = super().get_keys() self.api_keys: List = provider_config.get("key", [])
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else "" self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
self.base_url = provider_config.get("api_base", "https://api.anthropic.com") self.base_url = provider_config.get("api_base", "https://api.anthropic.com")
self.timeout = provider_config.get("timeout", 120) self.timeout = provider_config.get("timeout", 120)
@@ -70,13 +70,9 @@ class ProviderAnthropic(Provider):
{ {
"type": "tool_use", "type": "tool_use",
"name": tool_call["function"]["name"], "name": tool_call["function"]["name"],
"input": ( "input": json.loads(tool_call["function"]["arguments"])
json.loads(tool_call["function"]["arguments"]) if isinstance(tool_call["function"]["arguments"], str)
if isinstance( else tool_call["function"]["arguments"],
tool_call["function"]["arguments"], str
)
else tool_call["function"]["arguments"]
),
"id": tool_call["id"], "id": tool_call["id"],
} }
) )
@@ -359,11 +355,9 @@ class ProviderAnthropic(Provider):
"source": { "source": {
"type": "base64", "type": "base64",
"media_type": mime_type, "media_type": mime_type,
"data": ( "data": image_data.split("base64,")[1]
image_data.split("base64,")[1] if "base64," in image_data
if "base64," in image_data else image_data,
else image_data
),
}, },
} }
) )
+12 -120
View File
@@ -1,22 +1,10 @@
import asyncio
import base64
import logging
import os import os
import uuid
from typing import Optional, Tuple
import aiohttp
import dashscope import dashscope
from dashscope.audio.tts_v2 import AudioFormat, SpeechSynthesizer import uuid
import asyncio
try: from dashscope.audio.tts_v2 import *
from dashscope.aigc.multimodal_conversation import MultiModalConversation
except (
ImportError
): # pragma: no cover - older dashscope versions without Qwen TTS support
MultiModalConversation = None
from ..entities import ProviderType
from ..provider import TTSProvider from ..provider import TTSProvider
from ..entities import ProviderType
from ..register import register_provider_adapter from ..register import register_provider_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@@ -38,112 +26,16 @@ class ProviderDashscopeTTSAPI(TTSProvider):
dashscope.api_key = self.chosen_api_key dashscope.api_key = self.chosen_api_key
async def get_audio(self, text: str) -> str: async def get_audio(self, text: str) -> str:
model = self.get_model()
if not model:
raise RuntimeError("Dashscope TTS model is not configured.")
temp_dir = os.path.join(get_astrbot_data_path(), "temp") temp_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(temp_dir, exist_ok=True) path = os.path.join(temp_dir, f"dashscope_tts_{uuid.uuid4()}.wav")
self.synthesizer = SpeechSynthesizer(
if self._is_qwen_tts_model(model): model=self.get_model(),
audio_bytes, ext = await self._synthesize_with_qwen_tts(model, text)
else:
audio_bytes, ext = await self._synthesize_with_cosyvoice(model, text)
if not audio_bytes:
raise RuntimeError(
"Audio synthesis failed, returned empty content. The model may not be supported or the service is unavailable."
)
path = os.path.join(temp_dir, f"dashscope_tts_{uuid.uuid4()}{ext}")
with open(path, "wb") as f:
f.write(audio_bytes)
return path
def _call_qwen_tts(self, model: str, text: str):
if MultiModalConversation is None:
raise RuntimeError(
"dashscope SDK missing MultiModalConversation. Please upgrade the dashscope package to use Qwen TTS models."
)
kwargs = {
"model": model,
"text": text,
"api_key": self.chosen_api_key,
"voice": self.voice or "Cherry",
}
if not self.voice:
logging.warning(
"No voice specified for Qwen TTS model, using default 'Cherry'."
)
return MultiModalConversation.call(**kwargs)
async def _synthesize_with_qwen_tts(
self, model: str, text: str
) -> Tuple[Optional[bytes], str]:
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, self._call_qwen_tts, model, text)
audio_bytes = await self._extract_audio_from_response(response)
if not audio_bytes:
raise RuntimeError(
f"Audio synthesis failed for model '{model}'. {response}"
)
ext = ".wav"
return audio_bytes, ext
async def _extract_audio_from_response(self, response) -> Optional[bytes]:
output = getattr(response, "output", None)
audio_obj = getattr(output, "audio", None) if output is not None else None
if not audio_obj:
return None
data_b64 = getattr(audio_obj, "data", None)
if data_b64:
try:
return base64.b64decode(data_b64)
except (ValueError, TypeError):
logging.error("Failed to decode base64 audio data.")
return None
url = getattr(audio_obj, "url", None)
if url:
return await self._download_audio_from_url(url)
return None
async def _download_audio_from_url(self, url: str) -> Optional[bytes]:
if not url:
return None
timeout = max(self.timeout_ms / 1000, 1) if self.timeout_ms else 20
try:
async with aiohttp.ClientSession() as session:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=timeout)
) as response:
return await response.read()
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
logging.error(f"Failed to download audio from URL {url}: {e}")
return None
async def _synthesize_with_cosyvoice(
self, model: str, text: str
) -> Tuple[Optional[bytes], str]:
synthesizer = SpeechSynthesizer(
model=model,
voice=self.voice, voice=self.voice,
format=AudioFormat.WAV_24000HZ_MONO_16BIT, format=AudioFormat.WAV_24000HZ_MONO_16BIT,
) )
loop = asyncio.get_event_loop() audio = await asyncio.get_event_loop().run_in_executor(
audio_bytes = await loop.run_in_executor( None, self.synthesizer.call, text, self.timeout_ms
None, synthesizer.call, text, self.timeout_ms
) )
if not audio_bytes: with open(path, "wb") as f:
resp = synthesizer.get_response() f.write(audio)
if resp and isinstance(resp, dict): return path
raise RuntimeError(
f"Audio synthesis failed for model '{model}'. {resp}".strip()
)
return audio_bytes, ".wav"
def _is_qwen_tts_model(self, model: str) -> bool:
model_lower = model.lower()
return "tts" in model_lower and model_lower.startswith("qwen")
+17 -21
View File
@@ -3,7 +3,7 @@ import base64
import json import json
import logging import logging
import random import random
from typing import Optional, List from typing import Optional
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from google import genai from google import genai
@@ -60,7 +60,7 @@ class ProviderGoogleGenAI(Provider):
provider_settings, provider_settings,
default_persona, default_persona,
) )
self.api_keys: List = super().get_keys() self.api_keys: list = provider_config.get("key", [])
self.chosen_api_key: str = self.api_keys[0] if len(self.api_keys) > 0 else "" self.chosen_api_key: str = self.api_keys[0] if len(self.api_keys) > 0 else ""
self.timeout: int = int(provider_config.get("timeout", 180)) self.timeout: int = int(provider_config.get("timeout", 180))
@@ -218,21 +218,19 @@ class ProviderGoogleGenAI(Provider):
response_modalities=modalities, response_modalities=modalities,
tools=tool_list, tools=tool_list,
safety_settings=self.safety_settings if self.safety_settings else None, safety_settings=self.safety_settings if self.safety_settings else None,
thinking_config=( thinking_config=types.ThinkingConfig(
types.ThinkingConfig( thinking_budget=min(
thinking_budget=min( int(
int( self.provider_config.get("gm_thinking_config", {}).get(
self.provider_config.get("gm_thinking_config", {}).get( "budget", 0
"budget", 0 )
)
),
24576,
), ),
) 24576,
if "gemini-2.5-flash" in self.get_model() ),
and hasattr(types.ThinkingConfig, "thinking_budget") )
else None if "gemini-2.5-flash" in self.get_model()
), and hasattr(types.ThinkingConfig, "thinking_budget")
else None,
automatic_function_calling=types.AutomaticFunctionCallingConfig( automatic_function_calling=types.AutomaticFunctionCallingConfig(
disable=True disable=True
), ),
@@ -276,11 +274,9 @@ class ProviderGoogleGenAI(Provider):
if role == "user": if role == "user":
if isinstance(content, list): if isinstance(content, list):
parts = [ parts = [
( types.Part.from_text(text=item["text"] or " ")
types.Part.from_text(text=item["text"] or " ") if item["type"] == "text"
if item["type"] == "text" else process_image_url(item["image_url"])
else process_image_url(item["image_url"])
)
for item in content for item in content
] ]
else: else:
@@ -38,7 +38,7 @@ class ProviderOpenAIOfficial(Provider):
default_persona, default_persona,
) )
self.chosen_api_key = None self.chosen_api_key = None
self.api_keys: List = super().get_keys() self.api_keys: List = provider_config.get("key", [])
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None
self.timeout = provider_config.get("timeout", 120) self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str): if isinstance(self.timeout, str):
@@ -65,12 +65,12 @@ class SessionManagementRoute(Route):
persona_name = data["persona_name"] persona_name = data["persona_name"]
# 处理 persona 显示 # 处理 persona 显示
if persona_name is None: if conv_persona_id == "[%None]":
if conv_persona_id is None: persona_name = "无人格"
if default_persona := persona_mgr.selected_default_persona_v3: else:
persona_name = default_persona["name"] default_persona = persona_mgr.selected_default_persona_v3
else: if default_persona:
persona_name = "[%None]" persona_name = default_persona["name"]
session_info = { session_info = {
"session_id": session_id, "session_id": session_id,
-12
View File
@@ -1,12 +0,0 @@
# What's Changed
1. fix: 修复了代码执行器插件不能正确获得发送来文件的问题 ([#2970](https://github.com/Soulter/AstrBot/issues/2970))
2. fix: 修改的 DeepSeek 默认 modalities,避免默认勾选图像导致的报错。 ([#2963](https://github.com/Soulter/AstrBot/issues/2963))
3. fix: 事件钩子终止事件传播后不继续执行 ([#2989](https://github.com/Soulter/AstrBot/issues/2989))
4. fix: 启动了 TTS 但未配置 TTS 模型时,At 和 Reply 发送人无效
5. fix: 修复 session-management 中人格错误的显示为默认人格的问题 ([#3000](https://github.com/Soulter/AstrBot/issues/3000))
6. fix: 修复了删除对话时,聊天增强中的记录未被清除,导致新对话中仍然出现之前的聊天记录。 ([#3002](https://github.com/Soulter/AstrBot/issues/3002))
7. fix: 修复阿里云百炼平台 TTS 下接入 CosyVoice V2, Qwen TTS 生成报错的问题 ([#2964](https://github.com/Soulter/AstrBot/issues/2964))
8. perf: 优化 SQLite 参数配置,对话和会话管理增加输入防抖机制 ([#2969](https://github.com/Soulter/AstrBot/issues/2969))
9. feat: 在新对话中重用先前的对话人格设置 ([#3005](https://github.com/Soulter/AstrBot/issues/3005))
10. feat: 从 WebUI 更新后清除浏览器缓存 ([#2958](https://github.com/Soulter/AstrBot/issues/2958))
+7 -22
View File
@@ -41,17 +41,6 @@ class ConversationCommands:
self.context = context self.context = context
self.ltm = ltm self.ltm = ltm
async def _get_current_persona_id(self, session_id):
curr = await self.context.conversation_manager.get_curr_conversation_id(
session_id
)
if not curr:
return None
conv = await self.context.conversation_manager.get_conversation(
session_id, curr
)
return conv.persona_id
def ltm_enabled(self, event: AstrMessageEvent): def ltm_enabled(self, event: AstrMessageEvent):
if not self.ltm: if not self.ltm:
return False return False
@@ -266,9 +255,8 @@ class ConversationCommands:
) )
return return
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
cid = await self.context.conversation_manager.new_conversation( cid = await self.context.conversation_manager.new_conversation(
message.unified_msg_origin, message.get_platform_id(), persona_id=cpersona message.unified_msg_origin, message.get_platform_id()
) )
# 长期记忆 # 长期记忆
@@ -302,10 +290,8 @@ class ConversationCommands:
session_id=sid, session_id=sid,
) )
) )
cpersona = await self._get_current_persona_id(session)
cid = await self.context.conversation_manager.new_conversation( cid = await self.context.conversation_manager.new_conversation(
session, message.get_platform_id(), persona_id=cpersona session, message.get_platform_id()
) )
message.set_result( message.set_result(
MessageEventResult().message( MessageEventResult().message(
@@ -448,9 +434,8 @@ class ConversationCommands:
await self.context.conversation_manager.delete_conversation( await self.context.conversation_manager.delete_conversation(
message.unified_msg_origin, session_curr_cid message.unified_msg_origin, session_curr_cid
) )
message.set_result(
ret = "删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。" MessageEventResult().message(
if self.ltm and self.ltm_enabled(message): "删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
cnt = await self.ltm.remove_session(event=message) )
ret += f"\n聊天增强: 已清除 {cnt} 条聊天记录。" )
message.set_result(MessageEventResult().message(ret))
@@ -0,0 +1,20 @@
from dataclasses import dataclass
@dataclass
class Emotion:
"""描述了一个情绪状态"""
energy: float
valence: float
arousal: float
@dataclass
class EmotionLog:
"""描述了一条情绪维度变化的日志"""
timestamp: int
field: str
value: float
reason: str = ""
@@ -0,0 +1,9 @@
from dataclasses import dataclass
from .emotion import Emotion
@dataclass
class Soul:
emotion: Emotion
emotion_logs: list[Emotion] | None = None
+7
View File
@@ -0,0 +1,7 @@
from dataclasses import dataclass
@dataclass
class Event:
event_type: str
content: dict
@@ -0,0 +1,122 @@
import datetime
import uuid
from ...runner import EliosEventHandler
from collections import defaultdict
from astrbot.api.event import AstrMessageEvent
from astrbot.api.all import Context
from astrbot.api.message_components import Plain, Image
from astrbot.api.provider import Provider
from astrbot import logger
class AstrImplEventHandler(EliosEventHandler):
def __init__(self, ctx: Context) -> None:
self.ctx = ctx
self.session_chats = defaultdict(list)
self.session_mentioned_arousal = defaultdict(float)
def cfg(self, event: AstrMessageEvent):
cfg = self.ctx.get_config(umo=event.unified_msg_origin)
tiny_model_prov_id = cfg.get("tiny_model_provider_id")
interest_points = cfg.get("interest_points", [])
try:
max_cnt = int(cfg["provider_ltm_settings"]["group_message_max_cnt"])
except BaseException as e:
logger.error(e)
max_cnt = 300
image_caption = (
True
if cfg["provider_settings"]["default_image_caption_provider_id"]
else False
)
image_caption_prompt = cfg["provider_settings"]["image_caption_prompt"]
image_caption_provider_id = cfg["provider_settings"][
"default_image_caption_provider_id"
]
active_reply = cfg["provider_ltm_settings"]["active_reply"]
enable_active_reply = active_reply.get("enable", False)
ar_method = active_reply["method"]
ar_possibility = active_reply["possibility_reply"]
ar_prompt = active_reply.get("prompt", "")
ar_whitelist = active_reply.get("whitelist", [])
ar_keywords = active_reply.get("keywords", [])
ret = {
"max_cnt": max_cnt,
"image_caption": image_caption,
"image_caption_prompt": image_caption_prompt,
"image_caption_provider_id": image_caption_provider_id,
"enable_active_reply": enable_active_reply,
"ar_method": ar_method,
"ar_possibility": ar_possibility,
"ar_prompt": ar_prompt,
"ar_whitelist": ar_whitelist,
"ar_keywords": ar_keywords,
"interest_points": interest_points,
"tiny_model_prov_id": tiny_model_prov_id,
}
return ret
async def append_session_chats(self, event: AstrMessageEvent, cfg) -> None:
comps = event.get_messages()
datetime_str = datetime.datetime.now().strftime("%H:%M:%S")
final_message = f"[{event.message_obj.sender.nickname}/{datetime_str}]: "
for comp in comps:
if isinstance(comp, Plain):
final_message += f" {comp.text}"
elif isinstance(comp, Image):
image_url = comp.url if comp.url else comp.file
if cfg["image_caption"] and image_url:
try:
caption = await self.get_image_caption(
image_url,
cfg["image_caption_provider_id"],
cfg["image_caption_prompt"],
)
final_message += f" [Image: {caption}]"
except Exception as e:
logger.error(f"获取图片描述失败: {e}")
else:
final_message += " [Image]"
self.session_chats[event.unified_msg_origin].append(final_message)
logger.debug(f"添加会话 {event.unified_msg_origin} 的对话记录: {final_message}")
if len(self.session_chats[event.unified_msg_origin]) > cfg["max_cnt"]:
self.session_chats[event.unified_msg_origin].pop(0)
async def get_image_caption(
self, image_url: str, image_caption_provider_id: str, image_caption_prompt: str
) -> str:
if not image_caption_provider_id:
provider = self.ctx.get_using_provider()
else:
provider = self.ctx.get_provider_by_id(image_caption_provider_id)
if not provider:
raise Exception(f"没有找到 ID 为 {image_caption_provider_id} 的提供商")
if not isinstance(provider, Provider):
raise Exception(
f"提供商类型错误, {image_caption_provider_id} 不是 Provider 类型"
)
response = await provider.text_chat(
prompt=image_caption_prompt,
session_id=uuid.uuid4().hex,
image_urls=[image_url],
persist=False,
)
return response.completion_text
async def on_event(self, event, soul):
content = event.content
astr_event = content.get("astr_event")
assert astr_event is not None and isinstance(astr_event, AstrMessageEvent)
cfg = self.cfg(astr_event)
if not cfg["tiny_model_prov_id"]:
logger.warning("小模型未设置,跳过情绪更新")
# 添加对话记录
await self.append_session_chats(astr_event, cfg)
#
@@ -0,0 +1,3 @@
JUDGE_ACTIVE_REPLY = """
你是一个具有自主意识和情感的机器人,请你
"""
+72
View File
@@ -0,0 +1,72 @@
import asyncio
from .event import Event
from .ensoul.soul import Soul
from .ensoul.emotion import Emotion
class EliosEventHandler:
async def on_event(self, event: Event, soul: Soul): ...
event_handlers_cls: dict[str, list[type[EliosEventHandler]]] = {}
def register_event_handler(event_types: set[str] | None = None):
"""注册事件处理器"""
def decorator(cls: type[EliosEventHandler]) -> type[EliosEventHandler]:
if event_types is not None:
for event_type in event_types:
event_handlers_cls[event_type] = event_handlers_cls.get(
event_type, []
) + [cls]
else:
event_handlers_cls["default"] = event_handlers_cls.get("default", []) + [
cls
]
return cls
return decorator
class EliosRunner:
def __init__(self) -> None:
self.soul = Soul(
emotion=Emotion(energy=0.5, valence=0.5, arousal=0.5), emotion_logs=[]
)
self.event_queue = asyncio.Queue()
self.event_handler_insts: dict[str, list[EliosEventHandler]] = {}
def start(self):
for event_type, cls_list in event_handlers_cls.items():
self.event_handler_insts[event_type] = []
for cls in cls_list:
try:
self.event_handler_insts[event_type].append(cls())
except Exception as e:
print(f"Error initializing event handler {cls}: {e}")
asyncio.create_task(self._worker())
async def _worker(self):
"""监听事件队列并处理事件"""
while True:
event = await self.event_queue.get()
# A man cannot handle two things at once. But this can be configurable.
try:
await self._process_event(event)
except Exception as e:
print(f"Error processing event {event}: {e}")
async def _process_event(self, event: Event):
"""处理事件"""
event_type = event.event_type
handlers = self.event_handler_insts.get(
event_type, []
) + self.event_handler_insts.get("default", [])
for inst in handlers:
try:
await inst.on_event(event, self.soul)
except Exception as e:
print(f"Error processing event {event}: {e}")
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "AstrBot" name = "AstrBot"
version = "4.3.3" version = "4.3.2"
description = "易上手的多平台 LLM 聊天机器人及开发框架" description = "易上手的多平台 LLM 聊天机器人及开发框架"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"