Compare commits
6 Commits
refactor/ltm
...
v4.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 79e2743aac | |||
| 5e9c7cdd91 | |||
| 6f73e5087d | |||
| 8c120b020e | |||
| 12fc6f9d38 | |||
| a6e8483b4c |
@@ -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.2"
|
VERSION = "4.3.3"
|
||||||
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,6 +1056,7 @@ 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",
|
||||||
@@ -1435,11 +1436,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"description": "服务订阅密钥",
|
"description": "服务订阅密钥",
|
||||||
"hint": "Azure_TTS 服务的订阅密钥(注意不是令牌)",
|
"hint": "Azure_TTS 服务的订阅密钥(注意不是令牌)",
|
||||||
},
|
},
|
||||||
"dashscope_tts_voice": {
|
"dashscope_tts_voice": {"description": "音色", "type": "string"},
|
||||||
"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",
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ class Provider(AbstractProvider):
|
|||||||
|
|
||||||
def get_keys(self) -> List[str]:
|
def get_keys(self) -> List[str]:
|
||||||
"""获得提供商 Key"""
|
"""获得提供商 Key"""
|
||||||
return self.provider_config.get("key", [])
|
keys = 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 = provider_config.get("key", [])
|
self.api_keys: List = super().get_keys()
|
||||||
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,9 +70,13 @@ class ProviderAnthropic(Provider):
|
|||||||
{
|
{
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"name": tool_call["function"]["name"],
|
"name": tool_call["function"]["name"],
|
||||||
"input": json.loads(tool_call["function"]["arguments"])
|
"input": (
|
||||||
if isinstance(tool_call["function"]["arguments"], str)
|
json.loads(tool_call["function"]["arguments"])
|
||||||
else tool_call["function"]["arguments"],
|
if isinstance(
|
||||||
|
tool_call["function"]["arguments"], str
|
||||||
|
)
|
||||||
|
else tool_call["function"]["arguments"]
|
||||||
|
),
|
||||||
"id": tool_call["id"],
|
"id": tool_call["id"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -355,9 +359,11 @@ class ProviderAnthropic(Provider):
|
|||||||
"source": {
|
"source": {
|
||||||
"type": "base64",
|
"type": "base64",
|
||||||
"media_type": mime_type,
|
"media_type": mime_type,
|
||||||
"data": image_data.split("base64,")[1]
|
"data": (
|
||||||
if "base64," in image_data
|
image_data.split("base64,")[1]
|
||||||
else image_data,
|
if "base64," in image_data
|
||||||
|
else image_data
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import os
|
|
||||||
import dashscope
|
|
||||||
import uuid
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from dashscope.audio.tts_v2 import *
|
import base64
|
||||||
from ..provider import TTSProvider
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
import aiohttp
|
||||||
|
import dashscope
|
||||||
|
from dashscope.audio.tts_v2 import AudioFormat, SpeechSynthesizer
|
||||||
|
|
||||||
|
try:
|
||||||
|
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 ..entities import ProviderType
|
||||||
|
from ..provider import TTSProvider
|
||||||
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
|
||||||
|
|
||||||
@@ -26,16 +38,112 @@ 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")
|
||||||
path = os.path.join(temp_dir, f"dashscope_tts_{uuid.uuid4()}.wav")
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
self.synthesizer = SpeechSynthesizer(
|
|
||||||
model=self.get_model(),
|
if self._is_qwen_tts_model(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,
|
||||||
)
|
)
|
||||||
audio = await asyncio.get_event_loop().run_in_executor(
|
loop = asyncio.get_event_loop()
|
||||||
None, self.synthesizer.call, text, self.timeout_ms
|
audio_bytes = await loop.run_in_executor(
|
||||||
|
None, synthesizer.call, text, self.timeout_ms
|
||||||
)
|
)
|
||||||
with open(path, "wb") as f:
|
if not audio_bytes:
|
||||||
f.write(audio)
|
resp = synthesizer.get_response()
|
||||||
return path
|
if resp and isinstance(resp, dict):
|
||||||
|
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")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
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 = provider_config.get("key", [])
|
self.api_keys: List = super().get_keys()
|
||||||
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,19 +218,21 @@ 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=types.ThinkingConfig(
|
thinking_config=(
|
||||||
thinking_budget=min(
|
types.ThinkingConfig(
|
||||||
int(
|
thinking_budget=min(
|
||||||
self.provider_config.get("gm_thinking_config", {}).get(
|
int(
|
||||||
"budget", 0
|
self.provider_config.get("gm_thinking_config", {}).get(
|
||||||
)
|
"budget", 0
|
||||||
|
)
|
||||||
|
),
|
||||||
|
24576,
|
||||||
),
|
),
|
||||||
24576,
|
)
|
||||||
),
|
if "gemini-2.5-flash" in self.get_model()
|
||||||
)
|
and hasattr(types.ThinkingConfig, "thinking_budget")
|
||||||
if "gemini-2.5-flash" in self.get_model()
|
else None
|
||||||
and hasattr(types.ThinkingConfig, "thinking_budget")
|
),
|
||||||
else None,
|
|
||||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(
|
automatic_function_calling=types.AutomaticFunctionCallingConfig(
|
||||||
disable=True
|
disable=True
|
||||||
),
|
),
|
||||||
@@ -274,9 +276,11 @@ 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 " ")
|
(
|
||||||
if item["type"] == "text"
|
types.Part.from_text(text=item["text"] or " ")
|
||||||
else process_image_url(item["image_url"])
|
if item["type"] == "text"
|
||||||
|
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 = provider_config.get("key", [])
|
self.api_keys: List = super().get_keys()
|
||||||
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 conv_persona_id == "[%None]":
|
if persona_name is None:
|
||||||
persona_name = "无人格"
|
if conv_persona_id is None:
|
||||||
else:
|
if default_persona := persona_mgr.selected_default_persona_v3:
|
||||||
default_persona = persona_mgr.selected_default_persona_v3
|
persona_name = default_persona["name"]
|
||||||
if default_persona:
|
else:
|
||||||
persona_name = default_persona["name"]
|
persona_name = "[%None]"
|
||||||
|
|
||||||
session_info = {
|
session_info = {
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# 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))
|
||||||
@@ -41,6 +41,17 @@ 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
|
||||||
@@ -255,8 +266,9 @@ 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()
|
message.unified_msg_origin, message.get_platform_id(), persona_id=cpersona
|
||||||
)
|
)
|
||||||
|
|
||||||
# 长期记忆
|
# 长期记忆
|
||||||
@@ -290,8 +302,10 @@ 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()
|
session, message.get_platform_id(), persona_id=cpersona
|
||||||
)
|
)
|
||||||
message.set_result(
|
message.set_result(
|
||||||
MessageEventResult().message(
|
MessageEventResult().message(
|
||||||
@@ -434,8 +448,9 @@ 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(
|
|
||||||
MessageEventResult().message(
|
ret = "删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
|
||||||
"删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
|
if self.ltm and self.ltm_enabled(message):
|
||||||
)
|
cnt = await self.ltm.remove_session(event=message)
|
||||||
)
|
ret += f"\n聊天增强: 已清除 {cnt} 条聊天记录。"
|
||||||
|
message.set_result(MessageEventResult().message(ret))
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "AstrBot"
|
name = "AstrBot"
|
||||||
version = "4.3.2"
|
version = "4.3.3"
|
||||||
description = "易上手的多平台 LLM 聊天机器人及开发框架"
|
description = "易上手的多平台 LLM 聊天机器人及开发框架"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
Reference in New Issue
Block a user