Merge dev and fix conflicts

This commit is contained in:
LIghtJUNction
2026-04-06 16:39:58 +08:00
45 changed files with 1461 additions and 367 deletions
+67 -11
View File
@@ -21,7 +21,7 @@ from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
from astrbot.core.astr_agent_run_util import AgentRunner
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
from astrbot.core.conversation_mgr import Conversation
from astrbot.core.message.components import File, Image, Reply
from astrbot.core.message.components import File, Image, Record, Reply
from astrbot.core.persona_error_reply import (
extract_persona_custom_error_message_from_persona,
set_persona_custom_error_message_on_event,
@@ -419,6 +419,18 @@ def _append_quoted_image_attachment(req: ProviderRequest, image_path: str) -> No
)
def _append_audio_attachment(req: ProviderRequest, audio_path: str) -> None:
req.extra_user_content_parts.append(
TextPart(text=f"[Audio Attachment: path {audio_path}]")
)
def _append_quoted_audio_attachment(req: ProviderRequest, audio_path: str) -> None:
req.extra_user_content_parts.append(
TextPart(text=f"[Audio Attachment in quoted message: path {audio_path}]")
)
def _get_quoted_message_parser_settings(
provider_settings: dict[str, object] | None,
) -> QuotedMessageParserSettings:
@@ -704,12 +716,25 @@ def _modalities_fix(provider: Provider, req: ProviderRequest) -> None:
"Provider %s does not support image, using placeholder.", provider
)
image_count = len(req.image_urls)
placeholder = " ".join(["[图片]"] * image_count)
placeholder = " ".join(["[Image]"] * image_count)
if req.prompt:
req.prompt = f"{placeholder} {req.prompt}"
else:
req.prompt = placeholder
req.image_urls = []
if req.audio_urls:
provider_cfg = provider.provider_config.get("modalities", ["audio"])
if "audio" not in provider_cfg:
logger.debug(
"Provider %s does not support audio, using placeholder.", provider
)
audio_count = len(req.audio_urls)
placeholder = " ".join(["[Audio]"] * audio_count)
if req.prompt:
req.prompt = f"{placeholder} {req.prompt}"
else:
req.prompt = placeholder
req.audio_urls = []
if req.func_tool:
provider_cfg = provider.provider_config.get("modalities", ["tool_use"])
if "tool_use" not in provider_cfg:
@@ -730,11 +755,13 @@ def _sanitize_context_by_modalities(
if not modalities or not isinstance(modalities, list):
return
supports_image = bool("image" in modalities)
supports_audio = bool("audio" in modalities)
supports_tool_use = bool("tool_use" in modalities)
if supports_image and supports_tool_use:
if supports_image and supports_audio and supports_tool_use:
return
sanitized_contexts: list[dict] = []
removed_image_blocks = 0
removed_audio_blocks = 0
removed_tool_messages = 0
removed_tool_calls = 0
for msg in req.contexts:
@@ -753,20 +780,28 @@ def _sanitize_context_by_modalities(
removed_tool_calls += 1
new_msg.pop("tool_calls", None)
new_msg.pop("tool_call_id", None)
if not supports_image:
if not supports_image or not supports_audio:
content = new_msg.get("content")
if isinstance(content, list):
filtered_parts: list = []
removed_any_image = False
removed_any_multimodal = False
for part in content:
if isinstance(part, dict):
part_type = str(part.get("type", "")).lower()
if part_type in {"image_url", "image"}:
removed_any_image = True
if not supports_image and part_type in {"image_url", "image"}:
removed_any_multimodal = True
removed_image_blocks += 1
continue
if not supports_audio and part_type in {
"audio_url",
"input_audio",
}:
removed_any_multimodal = True
removed_audio_blocks += 1
continue
filtered_parts.append(part)
if removed_any_image:
if removed_any_multimodal:
new_msg["content"] = filtered_parts
if role == "assistant":
content = new_msg.get("content")
@@ -777,10 +812,19 @@ def _sanitize_context_by_modalities(
if isinstance(content, str) and (not content.strip()):
continue
sanitized_contexts.append(new_msg)
if removed_image_blocks or removed_tool_messages or removed_tool_calls:
if (
removed_image_blocks
or removed_audio_blocks
or removed_tool_messages
or removed_tool_calls
):
logger.debug(
"sanitize_context_by_modalities applied: removed_image_blocks=%s, removed_tool_messages=%s, removed_tool_calls=%s",
"sanitize_context_by_modalities applied: "
"removed_image_blocks=%s, removed_audio_blocks=%s, "
"removed_tool_messages=%s, removed_tool_calls=%s",
removed_image_blocks,
removed_audio_blocks,
removed_tool_messages,
removed_tool_calls,
)
@@ -969,6 +1013,7 @@ async def build_main_agent(
req = ProviderRequest()
req.prompt = ""
req.image_urls = []
req.audio_urls = []
if sel_model := event.get_extra("selected_model"):
req.model = sel_model
if config.provider_wake_prefix and (
@@ -988,6 +1033,10 @@ async def build_main_agent(
req.extra_user_content_parts.append(
TextPart(text=f"[Image Attachment: path {image_path}]")
)
elif isinstance(comp, Record):
audio_path = await comp.convert_to_file_path()
req.audio_urls.append(audio_path)
_append_audio_attachment(req, audio_path)
elif isinstance(comp, File):
file_path = await comp.get_file()
file_name = comp.name or os.path.basename(file_path)
@@ -1017,6 +1066,10 @@ async def build_main_agent(
event.track_temporary_local_file(image_path)
req.image_urls.append(image_path)
_append_quoted_image_attachment(req, image_path)
elif isinstance(reply_comp, Record):
audio_path = await reply_comp.convert_to_file_path()
req.audio_urls.append(audio_path)
_append_quoted_audio_attachment(req, audio_path)
elif isinstance(reply_comp, File):
file_path = await reply_comp.get_file()
file_name = reply_comp.name or os.path.basename(file_path)
@@ -1074,12 +1127,15 @@ async def build_main_agent(
if isinstance(req.contexts, str):
req.contexts = json.loads(req.contexts)
req.image_urls = normalize_and_dedupe_strings(req.image_urls)
req.audio_urls = normalize_and_dedupe_strings(req.audio_urls)
if config.file_extract_enabled:
try:
await _apply_file_extract(event, req, config)
except Exception as exc:
logger.error("Error occurred while applying file extract: %s", exc)
if not req.prompt and (not req.image_urls):
if not req.prompt and not req.image_urls and not req.audio_urls:
if not event.get_group_id() and req.extra_user_content_parts:
req.prompt = "<attachment>"
else:
+62 -3
View File
@@ -1247,6 +1247,18 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"LongCat": {
"id": "longcat",
"provider": "longcat",
"type": "longcat_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.longcat.chat/openai",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"AIHubMix": {
"id": "aihubmix",
"provider": "aihubmix",
@@ -1761,6 +1773,7 @@ CONFIG_METADATA_2 = {
"enable": True,
"rerank_api_key": "",
"rerank_api_base": "http://127.0.0.1:8000",
"rerank_api_suffix": "/v1/rerank",
"rerank_model": "BAAI/bge-reranker-base",
"timeout": 20,
},
@@ -1789,6 +1802,19 @@ CONFIG_METADATA_2 = {
"return_documents": False,
"instruct": "",
},
"NVIDIA Rerank": {
"id": "nvidia_rerank",
"type": "nvidia_rerank",
"provider": "nvidia",
"provider_type": "rerank",
"enable": True,
"nvidia_rerank_api_key": "",
"nvidia_rerank_api_base": "https://ai.api.nvidia.com/v1/retrieval",
"nvidia_rerank_model": "nv-rerank-qa-mistral-4b:1",
"nvidia_rerank_model_endpoint": "/reranking",
"timeout": 20,
"nvidia_rerank_truncate": "",
},
"Xinference STT": {
"id": "xinference_stt",
"type": "xinference_stt",
@@ -1826,7 +1852,12 @@ CONFIG_METADATA_2 = {
"rerank_api_base": {
"description": "重排序模型 API Base URL",
"type": "string",
"hint": "AstrBot 会在请求时在末尾加上 /v1/rerank。",
"hint": "最终请求路径由 Base URL 和路径后缀拼接而成(默认为 /v1/rerank)。",
},
"rerank_api_suffix": {
"description": "API URL 路径后缀",
"type": "string",
"hint": "追加到 base_url 后的路径,如 /v1/rerank。留空则不追加。",
},
"rerank_api_key": {
"description": "API Key",
@@ -1852,12 +1883,40 @@ CONFIG_METADATA_2 = {
"type": "bool",
"hint": "如果模型当前未在 Xinference 服务中运行,是否尝试自动启动它。在生产环境中建议关闭。",
},
"nvidia_rerank_api_base": {
"description": "API Base URL",
"type": "string",
},
"nvidia_rerank_api_key": {
"description": "API Key",
"type": "string",
},
"nvidia_rerank_model": {
"description": "重排序模型名称",
"type": "string",
"hint": "请参照NVIDIA Docs中模型名称填写。",
},
"nvidia_rerank_model_endpoint": {
"description": "自定义模型端点",
"type": "string",
"hint": "自定义URL末尾端点,默认为 /reranking",
},
"nvidia_rerank_truncate": {
"description": "文本截断策略",
"type": "string",
"hint": "当输入文本过长时,是否截断输入以适应模型的最大上下文长度。",
"options": [
"",
"NONE",
"END",
],
},
"modalities": {
"description": "模型能力",
"type": "list",
"items": {"type": "string"},
"options": ["text", "image", "tool_use"],
"labels": ["文本", "图像", "工具使用"],
"options": ["text", "image", "audio", "tool_use"],
"labels": ["文本", "图像", "音频", "工具使用"],
"render_type": "checkbox",
"hint": "模型支持的模态。如所填写的模型不支持图像,请取消勾选图像。",
},
-28
View File
@@ -74,12 +74,6 @@ class ComponentType(str, Enum):
Music = "Music"
Json = "Json"
Unknown = "Unknown"
WechatEmoji = "WechatEmoji" # Wechat 下的 emoji 表情包
# Discord-specific component types
DiscordEmbed = "DiscordEmbed"
DiscordButton = "DiscordButton"
DiscordReference = "DiscordReference"
DiscordView = "DiscordView"
class BaseMessageComponent(BaseModel):
@@ -106,7 +100,6 @@ class BaseMessageComponent(BaseModel):
class Plain(BaseMessageComponent):
type: ComponentType = ComponentType.Plain
text: str
convert: bool | None = True
def __init__(self, text: str, convert: bool = True, **_) -> None:
super().__init__(text=text, convert=convert, **_)
@@ -129,11 +122,7 @@ class Face(BaseMessageComponent):
class Record(BaseMessageComponent):
type: ComponentType = ComponentType.Record
file: str | None = ""
magic: bool | None = False
url: str | None = ""
cache: bool | None = True
proxy: bool | None = True
timeout: int | None = 0
# Original text content (e.g. TTS source text), used as caption in fallback scenarios
text: str | None = None
# 额外
@@ -239,7 +228,6 @@ class Video(BaseMessageComponent):
type: ComponentType = ComponentType.Video
file: str
cover: str | None = ""
c: int | None = 2
# 额外
path: str | None = ""
@@ -416,14 +404,9 @@ class Image(BaseMessageComponent):
type: ComponentType = ComponentType.Image
file: str | None = ""
_type: str | None = ""
subType: int | None = 0
url: str | None = ""
cache: bool | None = True
id: int | None = 40000
c: int | None = 2
# 额外
path: str | None = ""
file_unique: str | None = "" # 某些平台可能有图片缓存的唯一标识
def __init__(self, file: str | None, **_) -> None:
super().__init__(file=file, **_)
@@ -854,16 +837,6 @@ class File(BaseMessageComponent):
}
class WechatEmoji(BaseMessageComponent):
type: ComponentType = ComponentType.WechatEmoji
md5: str | None = ""
md5_len: int | None = 0
cdnurl: str | None = ""
def __init__(self, **_) -> None:
super().__init__(**_)
ComponentTypes = {
# Basic Message Segments
"plain": Plain,
@@ -889,5 +862,4 @@ ComponentTypes = {
"nodes": Nodes,
"json": Json,
"unknown": Unknown,
"WechatEmoji": WechatEmoji,
}
@@ -7,6 +7,7 @@ from astrbot.core.message.components import Image, Plain, Record
from astrbot.core.pipeline.context import PipelineContext
from astrbot.core.pipeline.stage import Stage, register_stage
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.utils.media_utils import ensure_wav
@register_stage
@@ -62,6 +63,21 @@ class PreProcessStage(Stage):
logger.debug(f"路径映射: {url} -> {component.url}")
message_chain[idx] = component
# In here, we convert all Record components to wav format and update the file path.
message_chain = event.get_messages()
for idx, component in enumerate(message_chain):
if isinstance(component, Record):
try:
original_path = await component.convert_to_file_path()
record_path = await ensure_wav(original_path)
if record_path != original_path:
event.track_temporary_local_file(record_path)
component.file = record_path
component.path = record_path
message_chain[idx] = component
except Exception as e:
logger.warning(f"Voice processing failed: {e}")
# STT
if self.stt_settings.get("enable", False):
# TODO: 独立
@@ -14,7 +14,7 @@ from astrbot.core.astr_main_agent import (
MainAgentBuildResult,
build_main_agent,
)
from astrbot.core.message.components import File, Image
from astrbot.core.message.components import File, Image, Record, Video
from astrbot.core.message.message_event_result import (
MessageChain,
MessageEventResult,
@@ -147,7 +147,8 @@ class InternalAgentSubStage(Stage):
has_provider_request = event.get_extra("provider_request") is not None
has_valid_message = bool(event.message_str and event.message_str.strip())
has_media_content = any(
isinstance(comp, Image | File) for comp in event.message_obj.message
isinstance(comp, (Image, File, Record, Video))
for comp in event.message_obj.message
)
if (
not has_provider_request
@@ -9,7 +9,7 @@ from astrbot.core.agent.runners.deerflow.constants import (
DEERFLOW_PROVIDER_TYPE,
)
from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
from astrbot.core.message.components import Image
from astrbot.core.message.components import Image, Record
from astrbot.core.message.message_event_result import (
MessageChain,
MessageEventResult,
@@ -313,8 +313,11 @@ class ThirdPartyAgentSubStage(Stage):
if isinstance(comp, Image):
image_path = await comp.convert_to_base64()
req.image_urls.append(image_path)
elif isinstance(comp, Record):
audio_path = await comp.convert_to_file_path()
req.audio_urls.append(audio_path)
if not req.prompt and not req.image_urls:
if not req.prompt and not req.image_urls and not req.audio_urls:
return
custom_error_message = await self._resolve_persona_custom_error_message(event)
+31 -40
View File
@@ -15,46 +15,37 @@ from astrbot.core.utils.path_util import path_Mapping
@register_stage
class RespondStage(Stage):
@staticmethod
def _has_meaningful_content(comp: BaseMessageComponent) -> bool:
if isinstance(comp, Comp.Plain | Comp.Unknown):
return bool(comp.text.strip())
if isinstance(comp, Comp.Face):
return comp.id is not None
if isinstance(comp, Comp.Record | Comp.Video | Comp.Image):
return bool(comp.file)
if isinstance(comp, Comp.At):
return bool(comp.qq) or bool(comp.name)
if isinstance(comp, Comp.Reply):
return bool(comp.id) and comp.sender_id is not None
if isinstance(comp, Comp.Poke):
return comp.target_id() is not None
if isinstance(comp, Comp.Node):
return bool(comp.content)
if isinstance(comp, Comp.Nodes):
return bool(comp.nodes)
if isinstance(comp, Comp.File):
return bool(comp.file_ or comp.url)
if isinstance(comp, Comp.WechatEmoji):
return comp.md5 is not None
if isinstance(comp, Comp.Json):
return bool(comp.data)
if isinstance(comp, Comp.Share):
return bool(comp.url) or bool(comp.title)
if isinstance(comp, Comp.Music):
return bool(
(comp.id and comp._type and comp._type != "custom")
or (comp._type == "custom" and comp.url and comp.audio and comp.title)
)
if isinstance(comp, Comp.Forward):
return bool(comp.id)
if isinstance(comp, Comp.Location):
return comp.lat is not None and comp.lon is not None
if isinstance(comp, Comp.Contact):
return bool(comp._type and comp.id)
if isinstance(comp, Comp.Shake | Comp.Dice | Comp.RPS):
return True
return False
# 组件类型到其非空判断函数的映射
_component_validators = {
Comp.Plain: lambda comp: bool(
comp.text and comp.text.strip(),
), # 纯文本消息需要strip
Comp.Face: lambda comp: comp.id is not None, # QQ表情
Comp.Record: lambda comp: bool(comp.file), # 语音
Comp.Video: lambda comp: bool(comp.file), # 视频
Comp.At: lambda comp: bool(comp.qq) or bool(comp.name), # @
Comp.Image: lambda comp: bool(comp.file), # 图片
Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复
Comp.Poke: lambda comp: comp.target_id() is not None, # 戳一戳
Comp.Node: lambda comp: bool(comp.content), # 转发节点
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
Comp.File: lambda comp: bool(comp.file_ or comp.url),
Comp.Json: lambda comp: bool(comp.data), # Json 卡片
Comp.Share: lambda comp: bool(comp.url) or bool(comp.title),
Comp.Music: lambda comp: (
(comp.id and comp._type and comp._type != "custom")
or (comp._type == "custom" and comp.url and comp.audio and comp.title)
), # 音乐分享
Comp.Forward: lambda comp: bool(comp.id), # 合并转发
Comp.Location: lambda comp: bool(
comp.lat is not None and comp.lon is not None
), # 位置
Comp.Contact: lambda comp: bool(comp._type and comp.id), # 推荐好友 or 群
Comp.Shake: lambda _: True, # 窗口抖动(戳一戳)
Comp.Dice: lambda _: True, # 掷骰子魔法表情
Comp.RPS: lambda _: True, # 猜拳魔法表情
Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()),
}
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
@@ -21,6 +21,7 @@ UNIQUE_SESSION_ID_BUILDERS: dict[str, Callable[[AstrMessageEvent], str | None]]
"qq_official_webhook": lambda e: e.get_sender_id(),
"lark": lambda e: f"{e.get_sender_id()}%{e.get_group_id()}",
"misskey": lambda e: f"{e.get_session_id()}_{e.get_sender_id()}",
"matrix": lambda e: f"{e.get_sender_id()}_{e.get_group_id() or e.get_session_id()}",
}
+7 -1
View File
@@ -419,6 +419,7 @@ class AstrMessageEvent(abc.ABC):
tool_set: ToolSet | None = None,
session_id: str = "",
image_urls: list[str] | None = None,
audio_urls: list[str] | None = None,
contexts: list | None = None,
system_prompt: str = "",
conversation: Conversation | None = None,
@@ -437,7 +438,9 @@ class AstrMessageEvent(abc.ABC):
image_urls: 可以是 base64:// 或者 http:// 开头的图片链接,也可以是本地图片路径。
contexts: 当指定 contexts 时,将会使用 contexts 作为上下文。如果同时传入了 conversation,将会忽略 conversation。
audio_urls: 音频 URL 列表,也支持本地路径。
contexts: 当指定 contexts 时,将会使用 contexts 作为上下文。如果同时传入了 conversation,将会忽略 conversation。
func_tool_manager: [Deprecated] 函数工具管理器,用于调用函数工具。用 self.context.get_llm_tool_manager() 获取。已过时,请使用 tool_set 参数代替。
@@ -446,6 +449,8 @@ class AstrMessageEvent(abc.ABC):
"""
if image_urls is None:
image_urls = []
if audio_urls is None:
audio_urls = []
if contexts is None:
contexts = []
if len(contexts) > 0 and conversation:
@@ -457,6 +462,7 @@ class AstrMessageEvent(abc.ABC):
prompt=prompt,
session_id=session_id,
image_urls=image_urls,
audio_urls=audio_urls,
# func_tool=func_tool_manager,
func_tool=tool_set,
contexts=contexts,
+87 -7
View File
@@ -3,8 +3,11 @@ from __future__ import annotations
import base64
import enum
import json
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import aiofiles
from anthropic.types import Message as AnthropicMessage
@@ -22,7 +25,8 @@ from astrbot.core.agent.message import (
from astrbot.core.agent.tool import ToolSet
from astrbot.core.db.po import Conversation
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file, download_image_by_url
class ProviderType(enum.Enum):
@@ -94,6 +98,8 @@ class ProviderRequest:
"""会话 ID"""
image_urls: list[str] = field(default_factory=list)
"""图片 URL 列表"""
audio_urls: list[str] = field(default_factory=list)
"""音频 URL 列表,也支持本地路径"""
extra_user_content_parts: list[ContentPart] = field(default_factory=list)
"""额外的用户消息内容部分列表,用于在用户消息后添加额外的内容块(如系统提醒、指令等)。支持 dict 或 ContentPart 对象"""
func_tool: ToolSet | None = None
@@ -116,6 +122,7 @@ class ProviderRequest:
return (
f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, "
f"image_count={len(self.image_urls or [])}, "
f"audio_count={len(self.audio_urls or [])}, "
f"func_tool={self.func_tool}, "
f"contexts={self._print_friendly_context()}, "
f"system_prompt={self.system_prompt}, "
@@ -134,9 +141,12 @@ class ProviderRequest:
self.tool_calls_result.append(tool_calls_result)
def _print_friendly_context(self):
"""打印友好的消息上下文。将 image_url 的值替换为 <Image>"""
"""打印友好的消息上下文。将多模态内容折叠为简短标记。"""
if not self.contexts:
return f"prompt: {self.prompt}, image_count: {len(self.image_urls or [])}"
return (
f"prompt: {self.prompt}, image_count: {len(self.image_urls or [])}, "
f"audio_count: {len(self.audio_urls or [])}"
)
result_parts = []
@@ -149,6 +159,7 @@ class ProviderRequest:
elif isinstance(content, list):
msg_parts = []
image_count = 0
audio_count = 0
for item in content:
item_type = item.get("type", "")
@@ -157,19 +168,26 @@ class ProviderRequest:
msg_parts.append(item.get("text", ""))
elif item_type == "image_url":
image_count += 1
elif item_type == "audio_url":
audio_count += 1
if image_count > 0:
if msg_parts:
msg_parts.append(f"[+{image_count} images]")
else:
msg_parts.append(f"[{image_count} images]")
if audio_count > 0:
if msg_parts:
msg_parts.append(f"[+{audio_count} audios]")
else:
msg_parts.append(f"[{audio_count} audios]")
result_parts.append(f"{role}: {''.join(msg_parts)}")
return "\n".join(result_parts)
async def assemble_context(self) -> dict[str, str | list[dict[str, object]]]:
"""将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
async def assemble_context(self) -> dict:
"""将请求(prompt、image_urls 和 audio_urls)包装成统一消息格式。"""
# 构建内容块列表
content_blocks: list[dict[str, object]] = []
@@ -179,6 +197,9 @@ class ProviderRequest:
elif self.image_urls:
# 如果没有文本但有图片,添加占位文本
content_blocks.append({"type": "text", "text": "[图片]"})
elif self.audio_urls:
# 如果没有文本但有音频,添加占位文本
content_blocks.append({"type": "text", "text": "[音频]"})
# 2. 额外的内容块(系统提醒、指令等)
if self.extra_user_content_parts:
@@ -204,11 +225,56 @@ class ProviderRequest:
{"type": "image_url", "image_url": {"url": image_data}},
)
# 只有当只有一个来自 prompt 的文本块且没有额外内容块时,才降级为简单格式以保持向后兼容
# 4. 音频内容
if self.audio_urls:
for audio_url in self.audio_urls:
if audio_url.startswith("http"):
parsed_url = urlparse(audio_url)
suffix = Path(parsed_url.path).suffix
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
temp_audio_path = (
temp_dir / f"provider_request_audio_{uuid.uuid4().hex}{suffix}"
)
try:
await download_file(audio_url, str(temp_audio_path))
audio_data = await self._encode_audio_bs64(
str(temp_audio_path),
source_ref=audio_url,
)
finally:
try:
temp_audio_path.unlink(missing_ok=True)
except Exception as exc:
logger.warning(
"Failed to cleanup %s: %s",
temp_audio_path,
exc,
)
elif audio_url.startswith("file:///"):
audio_path = audio_url.replace("file:///", "")
audio_data = await self._encode_audio_bs64(
audio_path,
source_ref=audio_url,
)
else:
audio_data = await self._encode_audio_bs64(
audio_url,
source_ref=audio_url,
)
if not audio_data:
logger.warning(f"音频 {audio_url} 得到的结果为空,将忽略。")
continue
content_blocks.append(
{"type": "audio_url", "audio_url": {"url": audio_data}},
)
# 只有当只有一个来自 prompt 的文本块且没有额外内容块时,才降级为简单格式以保持向后兼容
if (
len(content_blocks) == 1
and not self.extra_user_content_parts
and not self.image_urls
and not self.audio_urls
):
first_block = content_blocks[0]
if first_block.get("type") == "text":
@@ -226,7 +292,21 @@ class ProviderRequest:
async with aiofiles.open(image_url, "rb") as f:
image_bs64 = base64.b64encode(await f.read()).decode("utf-8")
return "data:image/jpeg;base64," + image_bs64
return ""
async def _encode_audio_bs64(
self,
audio_path: str,
source_ref: str | None = None,
) -> str:
"""将音频转换为 base64"""
mime_type = "audio/wav"
if audio_path.startswith("base64://"):
return audio_path.replace("base64://", f"data:{mime_type};base64,", 1)
with open(audio_path, "rb") as f:
audio_bs64 = base64.b64encode(f.read()).decode("utf-8")
return f"data:{mime_type};base64," + audio_bs64
@dataclass
+10
View File
@@ -256,6 +256,8 @@ class ProviderManager:
provider = self.provider_insts[0] if self.provider_insts else None
elif provider_type == ProviderType.SPEECH_TO_TEXT:
provider_id = config["provider_stt_settings"].get("provider_id")
if not config["provider_stt_settings"].get("enable"):
return None
if not provider_id:
return None
provider = self.inst_map.get(provider_id)
@@ -265,6 +267,8 @@ class ProviderManager:
)
elif provider_type == ProviderType.TEXT_TO_SPEECH:
provider_id = config["provider_tts_settings"].get("provider_id")
if not config["provider_tts_settings"].get("enable"):
return None
if not provider_id:
return None
provider = self.inst_map.get(provider_id)
@@ -369,6 +373,8 @@ class ProviderManager:
from .sources.openai_source import (
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
)
case "longcat_chat_completion":
from .sources.longcat_source import ProviderLongCat as ProviderLongCat
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
@@ -483,6 +489,10 @@ class ProviderManager:
from .sources.bailian_rerank_source import (
BailianRerankProvider as BailianRerankProvider,
)
case "nvidia_rerank":
from .sources.nvidia_rerank_source import (
NvidiaRerankProvider as NvidiaRerankProvider,
)
def get_merged_provider_config(self, provider_config: dict) -> dict:
"""获取 provider 配置和 provider_source 配置合并后的结果
+10 -4
View File
@@ -93,6 +93,7 @@ class Provider(AbstractProvider):
prompt: str | None = None,
session_id: str | None = None,
image_urls: list[str] | None = None,
audio_urls: list[str] | None = None,
func_tool: ToolSet | None = None,
contexts: list[Message] | list[dict] | None = None,
system_prompt: str | None = None,
@@ -108,6 +109,7 @@ class Provider(AbstractProvider):
prompt: 提示词,和 contexts 二选一使用,如果都指定,则会将 prompt(以及可能的 image_urls) 作为最新的一条记录添加到 contexts 中
session_id: 会话 ID(此属性已经被废弃)
image_urls: 图片 URL 列表
audio_urls: 音频 URL 列表,也支持本地路径
tools: tool set
contexts: 上下文,和 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
@@ -116,8 +118,9 @@ class Provider(AbstractProvider):
kwargs: 其他参数
Notes:
- 如果传入了 image_urls,将会在对话时附上图片。如果模型不支持图片输入,将会抛出错误。
- 如果传入了 tools,将会使用 tools 进行 Function-calling。如果模型不支持 Function-calling,将会抛出错误。
- 如果传入了 image_urls,将会在对话时附上图片。如果模型不支持图片输入,将会抛出错误。
- 如果传入了 audio_urls,将会在对话时附上音频。如果模型不支持音频输入,将会抛出错误或降级处理。
- 如果传入了 tools,将会使用 tools 进行 Function-calling。如果模型不支持 Function-calling,将会抛出错误。
"""
...
@@ -127,6 +130,7 @@ class Provider(AbstractProvider):
prompt: str | None = None,
session_id: str | None = None,
image_urls: list[str] | None = None,
audio_urls: list[str] | None = None,
func_tool: ToolSet | None = None,
contexts: list[Message] | list[dict] | None = None,
system_prompt: str | None = None,
@@ -142,6 +146,7 @@ class Provider(AbstractProvider):
prompt: 提示词,和 contexts 二选一使用,如果都指定,则会将 prompt(以及可能的 image_urls) 作为最新的一条记录添加到 contexts 中
session_id: 会话 ID(此属性已经被废弃)
image_urls: 图片 URL 列表
audio_urls: 音频 URL 列表,也支持本地路径
tools: tool set
contexts: 上下文,和 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
@@ -150,8 +155,9 @@ class Provider(AbstractProvider):
kwargs: 其他参数
Notes:
- 如果传入了 image_urls,将会在对话时附上图片。如果模型不支持图片输入,将会抛出错误。
- 如果传入了 tools,将会使用 tools 进行 Function-calling。如果模型不支持 Function-calling,将会抛出错误。
- 如果传入了 image_urls,将会在对话时附上图片。如果模型不支持图片输入,将会抛出错误。
- 如果传入了 audio_urls,将会在对话时附上音频。如果模型不支持音频输入,将会抛出错误或降级处理。
- 如果传入了 tools,将会使用 tools 进行 Function-calling。如果模型不支持 Function-calling,将会抛出错误。
"""
if False:
@@ -13,7 +13,7 @@ from anthropic.types.usage import Usage
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart, ImageURLPart, TextPart
from astrbot.core.agent.message import AudioURLPart, ContentPart, ImageURLPart, TextPart
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet
@@ -253,6 +253,13 @@ class ProviderAnthropic(Provider):
logger.warning(
f"Unsupported image URL format for Anthropic: {url[:50]}..."
)
elif part.get("type") == "audio_url":
converted_content.append(
{
"type": "text",
"text": "[Audio Attachment]",
}
)
else:
converted_content.append(part)
new_messages.append(
@@ -534,6 +541,7 @@ class ProviderAnthropic(Provider):
prompt=None,
session_id=None,
image_urls=None,
audio_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
@@ -548,7 +556,10 @@ class ProviderAnthropic(Provider):
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
prompt or "",
image_urls,
audio_urls,
extra_user_content_parts,
)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
@@ -594,6 +605,7 @@ class ProviderAnthropic(Provider):
prompt=None,
session_id=None,
image_urls=None,
audio_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
@@ -608,7 +620,10 @@ class ProviderAnthropic(Provider):
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
prompt or "",
image_urls,
audio_urls,
extra_user_content_parts,
)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
@@ -659,6 +674,7 @@ class ProviderAnthropic(Provider):
self,
text: str,
image_urls: list[str] | None = None,
audio_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
):
"""组装上下文,支持文本和图片"""
@@ -696,8 +712,10 @@ class ProviderAnthropic(Provider):
if text:
content.append({"type": "text", "text": text})
elif image_urls:
# 如果没有文本但有图片,添加占位文本
content.append({"type": "text", "text": "[图片]"})
# 如果没有文本但有图片,添加占位文本
content.append({"type": "text", "text": "[Image]"})
elif audio_urls:
content.append({"type": "text", "text": "[Audio]"})
elif extra_user_content_parts:
# 如果只有额外内容块,也需要添加占位文本
content.append({"type": "text", "text": " "})
@@ -711,6 +729,8 @@ class ProviderAnthropic(Provider):
image_dict = await resolve_image_url(block.image_url.url)
if image_dict:
content.append(image_dict)
elif isinstance(block, AudioURLPart):
content.append({"type": "text", "text": "[Audio]"})
else:
raise ValueError(f"不支持的额外内容块类型: {type(block)}")
@@ -720,12 +740,16 @@ class ProviderAnthropic(Provider):
image_dict = await resolve_image_url(image_url)
if image_dict:
content.append(image_dict)
if audio_urls:
for _audio_path in audio_urls:
content.append({"type": "text", "text": "[Audio]"})
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
if (
text
and not extra_user_content_parts
and not image_urls
and not audio_urls
and len(content) == 1
and content[0]["type"] == "text"
):
+147 -48
View File
@@ -3,8 +3,11 @@ import base64
import json
import logging
import random
import uuid
from collections.abc import AsyncGenerator
from typing import ClassVar, Literal
from pathlib import Path
from typing import Literal, cast
from urllib.parse import urlparse
import aiofiles
from google import genai
@@ -14,13 +17,14 @@ from google.genai.errors import APIError
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart, ImageURLPart, Message, TextPart
from astrbot.core.agent.tool import ToolSet
from astrbot.core.agent.message import AudioURLPart, ContentPart, ImageURLPart, TextPart
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
from astrbot.core.provider.register import register_provider_adapter
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file, download_image_by_url
from astrbot.core.utils.media_utils import ensure_wav
from astrbot.core.utils.network_utils import is_connection_error, log_connection_failure
@@ -263,6 +267,12 @@ class ProviderGoogleGenAI(Provider):
image_bytes = base64.b64decode(url.split(",", 1)[1])
return types.Part.from_bytes(data=image_bytes, mime_type=mime_type)
def process_audio_url(audio_url_dict: dict) -> types.Part:
url = audio_url_dict["url"]
mime_type = url.split(":")[1].split(";")[0]
audio_bytes = base64.b64decode(url.split(",", 1)[1])
return types.Part.from_bytes(data=audio_bytes, mime_type=mime_type)
def append_or_extend(
contents: list[types.Content],
part: list[types.Part],
@@ -286,9 +296,15 @@ class ProviderGoogleGenAI(Provider):
if role == "user":
if isinstance(content, list):
parts = [
types.Part.from_text(text=item["text"] or " ")
if item["type"] == "text"
else process_image_url(item["image_url"])
(
types.Part.from_text(text=item["text"] or " ")
if item["type"] == "text"
else (
process_image_url(item["image_url"])
if item["type"] == "image_url"
else process_audio_url(item["audio_url"])
)
)
for item in content
]
else:
@@ -395,14 +411,23 @@ class ProviderGoogleGenAI(Provider):
)
def _process_content_parts(
self, candidate: types.Candidate, llm_response: LLMResponse
self,
candidate: types.Candidate,
llm_response: LLMResponse,
*,
validate_output: bool = True,
) -> MessageChain:
"""处理内容部分并构建消息链"""
if not candidate.content:
logger.warning(f"收到的 candidate.content 为空: {candidate}")
raise EmptyModelOutputError(
f"Gemini candidate content is empty. finish_reason={candidate.finish_reason}"
)
if validate_output:
raise EmptyModelOutputError(
"Gemini candidate content is empty. "
f"finish_reason={candidate.finish_reason}"
)
llm_response.result_chain = MessageChain(chain=[])
return llm_response.result_chain
finish_reason = candidate.finish_reason
result_parts: list[types.Part] | None = candidate.content.parts
if finish_reason == types.FinishReason.SAFETY:
@@ -418,9 +443,15 @@ class ProviderGoogleGenAI(Provider):
raise Exception("模型生成内容违反 Gemini 平台政策")
if not result_parts:
logger.warning(f"收到的 candidate.content.parts 为空: {candidate}")
raise EmptyModelOutputError(
f"Gemini candidate content parts are empty. finish_reason={candidate.finish_reason}"
)
if validate_output:
raise EmptyModelOutputError(
"Gemini candidate content parts are empty. "
f"finish_reason={candidate.finish_reason}"
)
llm_response.result_chain = MessageChain(chain=[])
return llm_response.result_chain
# 提取 reasoning content
reasoning = self._extract_reasoning_content(candidate)
if reasoning:
llm_response.reasoning_content = reasoning
@@ -466,11 +497,12 @@ class ProviderGoogleGenAI(Provider):
llm_response.reasoning_signature = base64.b64encode(ts).decode("utf-8")
chain_result = MessageChain(chain=chain)
llm_response.result_chain = chain_result
self._ensure_usable_response(
llm_response,
response_id=None,
finish_reason=str(finish_reason) if finish_reason is not None else None,
)
if validate_output:
self._ensure_usable_response(
llm_response,
response_id=None,
finish_reason=str(finish_reason) if finish_reason is not None else None,
)
return chain_result
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
@@ -600,7 +632,9 @@ class ProviderGoogleGenAI(Provider):
llm_response = LLMResponse("assistant", is_chunk=False)
llm_response.raw_completion = chunk
llm_response.result_chain = self._process_content_parts(
chunk.candidates[0], llm_response
chunk.candidates[0],
llm_response,
validate_output=False,
)
llm_response.id = chunk.response_id
if chunk.usage_metadata:
@@ -624,7 +658,9 @@ class ProviderGoogleGenAI(Provider):
final_response = LLMResponse("assistant", is_chunk=False)
final_response.raw_completion = chunk
final_response.result_chain = self._process_content_parts(
chunk.candidates[0], final_response
chunk.candidates[0],
final_response,
validate_output=False,
)
final_response.id = chunk.response_id
if chunk.usage_metadata:
@@ -647,15 +683,16 @@ class ProviderGoogleGenAI(Provider):
async def text_chat(
self,
prompt: str | None = None,
session_id: str | None = None,
image_urls: list[str] | None = None,
func_tool: ToolSet | None = None,
contexts: list[Message] | list[dict] | None = None,
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
prompt=None,
session_id=None,
image_urls=None,
audio_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
tool_choice: Literal["auto", "required"] = "auto",
**kwargs,
) -> LLMResponse:
@@ -664,7 +701,10 @@ class ProviderGoogleGenAI(Provider):
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
prompt or "",
image_urls,
audio_urls,
extra_user_content_parts,
)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
@@ -698,15 +738,16 @@ class ProviderGoogleGenAI(Provider):
async def text_chat_stream(
self,
prompt: str | None = None,
session_id: str | None = None,
image_urls: list[str] | None = None,
func_tool: ToolSet | None = None,
contexts: list[Message] | list[dict] | None = None,
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
prompt=None,
session_id=None,
image_urls=None,
audio_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
tool_choice: Literal["auto", "required"] = "auto",
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
@@ -715,7 +756,10 @@ class ProviderGoogleGenAI(Provider):
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
prompt or "",
image_urls,
audio_urls,
extra_user_content_parts,
)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
@@ -775,6 +819,7 @@ class ProviderGoogleGenAI(Provider):
self,
text: str,
image_urls: list[str] | None = None,
audio_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
):
"""组装上下文。"""
@@ -793,11 +838,52 @@ class ProviderGoogleGenAI(Provider):
return None
return {"type": "image_url", "image_url": {"url": image_data}}
async def resolve_audio_part(audio_path: str) -> dict | None:
if audio_path.startswith("http"):
suffix = Path(urlparse(audio_path).path).suffix or ".wav"
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
resolved_path = str(
temp_dir / f"provider_audio_{uuid.uuid4().hex}{suffix}"
)
await download_file(audio_path, resolved_path)
elif audio_path.startswith("file:///"):
resolved_path = audio_path.replace("file:///", "")
else:
resolved_path = audio_path
suffix = Path(resolved_path).suffix.lower()
if suffix != ".mp3":
resolved_path = await ensure_wav(resolved_path)
suffix = ".wav"
try:
audio_bytes = Path(resolved_path).read_bytes()
except OSError as exc:
logger.warning(
f"Failed to read audio file {resolved_path}, skipping. Error: {exc}"
)
return None
mime_type = {
".wav": "audio/wav",
".mp3": "audio/mp3",
}.get(suffix, "audio/wav")
audio_data = base64.b64encode(audio_bytes).decode("utf-8")
return {
"type": "audio_url",
"audio_url": {"url": f"data:{mime_type};base64,{audio_data}"},
}
# 构建内容块列表
content_blocks = []
if text:
content_blocks.append({"type": "text", "text": text})
elif image_urls:
content_blocks.append({"type": "text", "text": "[图片]"})
# 如果没有文本但有图片,添加占位文本
content_blocks.append({"type": "text", "text": "[Image]"})
elif audio_urls:
content_blocks.append({"type": "text", "text": "[Audio]"})
elif extra_user_content_parts:
content_blocks.append({"type": "text", "text": " "})
if extra_user_content_parts:
@@ -808,6 +894,10 @@ class ProviderGoogleGenAI(Provider):
image_part = await resolve_image_part(part.image_url.url)
if image_part:
content_blocks.append(image_part)
elif isinstance(part, AudioURLPart):
audio_part = await resolve_audio_part(part.audio_url.url)
if audio_part:
content_blocks.append(audio_part)
else:
raise ValueError(f"不支持的额外内容块类型: {type(part)}")
if image_urls:
@@ -815,12 +905,21 @@ class ProviderGoogleGenAI(Provider):
image_part = await resolve_image_part(image_url)
if image_part:
content_blocks.append(image_part)
if audio_urls:
for audio_path in audio_urls:
audio_part = await resolve_audio_part(audio_path)
if audio_part:
content_blocks.append(audio_part)
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
if (
text
and (not extra_user_content_parts)
and (not image_urls)
and (len(content_blocks) == 1)
and (content_blocks[0]["type"] == "text")
and not extra_user_content_parts
and not image_urls
and not audio_urls
and len(content_blocks) == 1
and content_blocks[0]["type"] == "text"
):
return {"role": "user", "content": content_blocks[0]["text"]}
return {"role": "user", "content": content_blocks}
@@ -0,0 +1,23 @@
from ..register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
@register_provider_adapter(
"longcat_chat_completion", "LongCat Chat Completion Provider Adapter"
)
class ProviderLongCat(ProviderOpenAIOfficial):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
api_base = (provider_config.get("api_base", "") or "").strip()
if not api_base:
provider_config["api_base"] = "https://api.longcat.chat/openai/v1"
else:
normalized_api_base = api_base.rstrip("/")
if normalized_api_base.endswith("/openai"):
normalized_api_base = f"{normalized_api_base}/v1"
provider_config["api_base"] = normalized_api_base
super().__init__(provider_config, provider_settings)
@@ -0,0 +1,164 @@
import aiohttp
from astrbot import logger
from ..entities import ProviderType, RerankResult
from ..provider import RerankProvider
from ..register import register_provider_adapter
@register_provider_adapter(
"nvidia_rerank", "NVIDIA Rerank 适配器", provider_type=ProviderType.RERANK
)
class NvidiaRerankProvider(RerankProvider):
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings)
self.api_key = provider_config.get("nvidia_rerank_api_key", "")
self.base_url = provider_config.get(
"nvidia_rerank_api_base", "https://ai.api.nvidia.com/v1/retrieval"
).rstrip("/")
self.timeout = provider_config.get("timeout", 20)
self.model = provider_config.get(
"nvidia_rerank_model", "nv-rerank-qa-mistral-4b:1"
)
self.model_endpoint = provider_config.get(
"nvidia_rerank_model_endpoint", "/reranking"
)
self.truncate = provider_config.get("nvidia_rerank_truncate", "")
self.client = None
self.set_model(self.model)
async def _get_client(self):
if self.client is None or self.client.closed:
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
self.client = aiohttp.ClientSession(
headers=headers, timeout=aiohttp.ClientTimeout(total=self.timeout)
)
return self.client
def _get_endpoint(self) -> str:
"""
构建完整API URL。
根据 Nvidia Rerank API 文档来看,当前URL存在不同模型格式不一致的问题。
这里针对模型名做一个基础判断用以适配,后续要等Nvidia统一API格式后再做调整。
例:
模型: nv-rerank-qa-mistral-4b:1
URL: .../v1/retrieval/nvidia/reranking
模型: nvidia/llama-nemotron-rerank-1b-v2
URL: .../v1/retrieval/nvidia/llama-nemotron-rerank-1b-v2/reranking
"""
model_path = "nvidia"
logger.debug(f"[NVIDIA Rerank] Building endpoint for model: {self.model}")
if "/" in self.model:
"""遵循NVIDIA API的URL规则,替换模型名中特殊字符"""
model_path = self.model.strip("/").replace(".", "_")
endpoint = self.model_endpoint.lstrip("/")
return f"{self.base_url}/{model_path}/{endpoint}"
def _build_payload(self, query: str, documents: list[str]) -> dict:
"""构建请求载荷"""
payload = {
"model": self.model,
"query": {"text": query},
"passages": [{"text": doc} for doc in documents],
}
if self.truncate:
payload["truncate"] = self.truncate
return payload
def _parse_results(
self, response_data: dict, top_n: int | None
) -> list[RerankResult]:
"""解析响应数据"""
results = response_data.get("rankings", [])
if not results:
logger.warning(f"[NVIDIA Rerank] Empty response: {response_data}")
return []
rerank_results = []
for idx, item in enumerate(results):
try:
index = item.get("index", idx)
score = item.get("relevance_score", item.get("logit", 0.0))
rerank_results.append(
RerankResult(index=index, relevance_score=float(score))
)
except Exception as e:
logger.warning(
f"[NVIDIA Rerank] Result parsing error: {e}, Data={item}"
)
rerank_results.sort(key=lambda x: x.relevance_score, reverse=True)
if top_n is not None and top_n > 0:
return rerank_results[:top_n]
return rerank_results
def _log_usage(self, data: dict) -> None:
usage = data.get("usage", {})
total_tokens = usage.get("total_tokens", 0)
if total_tokens > 0:
logger.debug(f"[NVIDIA Rerank] Token Usage: {total_tokens}")
async def rerank(
self,
query: str,
documents: list[str],
top_n: int | None = None,
) -> list[RerankResult]:
client = await self._get_client()
if not client or client.closed:
logger.error("[NVIDIA Rerank] Client session not initialized or closed")
return []
if not documents or not query.strip():
logger.warning(
"[NVIDIA Rerank] Input data is invalid, query or documents are empty"
)
return []
try:
payload = self._build_payload(query, documents)
request_url = self._get_endpoint()
async with client.post(request_url, json=payload) as response:
if response.status != 200:
try:
response_data = await response.json()
error_detail = response_data.get(
"detail", response_data.get("message", "Unknown Error")
)
except Exception:
error_detail = await response.text()
response_data = {"message": error_detail}
logger.error(f"[NVIDIA Rerank] API Error Response: {response_data}")
raise Exception(f"HTTP {response.status} - {error_detail}")
response_data = await response.json()
logger.debug(f"[NVIDIA Rerank] API Response: {response_data}")
results = self._parse_results(response_data, top_n)
self._log_usage(response_data)
return results
except aiohttp.ClientError as e:
logger.error(f"[NVIDIA Rerank] Network error: {e}")
raise Exception(f"Network error: {e}") from e
except Exception as e:
logger.error(f"[NVIDIA Rerank] Error: {e}")
raise Exception(f"Rerank error: {e}") from e
async def terminate(self) -> None:
if self.client and not self.client.closed:
await self.client.close()
self.client = None
+138 -27
View File
@@ -5,6 +5,7 @@ import inspect
import json
import random
import re
import uuid
from collections.abc import AsyncGenerator
from io import BytesIO
from pathlib import Path
@@ -24,13 +25,20 @@ from PIL import UnidentifiedImageError
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart, ImageURLPart, Message, TextPart
from astrbot.core.agent.message import (
AudioURLPart,
ContentPart,
ImageURLPart,
Message,
TextPart,
)
from astrbot.core.agent.tool import ToolSet
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
from astrbot.core.provider.register import register_provider_adapter
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file, download_image_by_url
from astrbot.core.utils.media_utils import ensure_wav
from astrbot.core.utils.network_utils import (
create_proxy_client,
is_connection_error,
@@ -135,7 +143,10 @@ class ProviderOpenAIOfficial(Provider):
if not isinstance(content, list):
continue
for item in content:
if isinstance(item, dict) and item.get("type") == "image_url":
if isinstance(item, dict) and item.get("type") in {
"image_url",
"audio_url",
}:
return True
return False
@@ -284,24 +295,103 @@ class ProviderOpenAIOfficial(Provider):
image_detail = None
return url, image_detail
async def _transform_content_part(self, part: dict) -> dict:
url, image_detail = self._extract_image_part_info(part)
if not url:
return part
def _extract_audio_part_info(self, part: dict) -> str | None:
if not isinstance(part, dict) or part.get("type") != "audio_url":
return None
audio_url_data = part.get("audio_url")
if not isinstance(audio_url_data, dict):
logger.warning("音频内容块格式无效,将保留原始内容。")
return None
url = audio_url_data.get("url")
if not isinstance(url, str) or not url:
logger.warning("音频内容块缺少有效路径,将保留原始内容。")
return None
return url
async def _audio_ref_to_local_path(self, audio_ref: str) -> tuple[str, list[Path]]:
cleanup_paths: list[Path] = []
if audio_ref.startswith("http"):
suffix = Path(urlparse(audio_ref).path).suffix or ".wav"
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
target_path = temp_dir / f"provider_audio_{uuid.uuid4().hex}{suffix}"
await download_file(audio_ref, str(target_path))
cleanup_paths.append(target_path)
return str(target_path), cleanup_paths
if audio_ref.startswith("file://"):
return self._file_uri_to_path(audio_ref), cleanup_paths
return audio_ref, cleanup_paths
async def _resolve_audio_part(self, audio_ref: str) -> dict | None:
cleanup_paths: list[Path] = []
try:
resolved_part = await self._resolve_image_part(
url, image_detail=image_detail
)
audio_path, cleanup_paths = await self._audio_ref_to_local_path(audio_ref)
suffix = Path(audio_path).suffix.lower()
if suffix == ".mp3":
audio_format = "mp3"
else:
converted_audio_path = await ensure_wav(audio_path)
if converted_audio_path != audio_path:
cleanup_paths.append(Path(converted_audio_path))
audio_path = converted_audio_path
audio_format = "wav"
audio_bytes = Path(audio_path).read_bytes()
except Exception as exc:
logger.warning(
"图片 %s 预处理失败,将保留原始内容。错误: %s",
url,
exc,
)
logger.warning("音频 %s 预处理失败,将忽略。错误: %s", audio_ref, exc)
return None
finally:
for cleanup_path in cleanup_paths:
try:
cleanup_path.unlink(missing_ok=True)
except Exception as cleanup_exc:
logger.warning(
"Failed to cleanup %s: %s",
cleanup_path,
cleanup_exc,
)
return {
"type": "input_audio",
"input_audio": {
"data": base64.b64encode(audio_bytes).decode("utf-8"),
"format": audio_format,
},
}
async def _transform_content_part(self, part: dict) -> dict:
if not isinstance(part, dict):
return part
return resolved_part or part
if part.get("type") == "image_url":
url, image_detail = self._extract_image_part_info(part)
if not url:
return part
try:
resolved_part = await self._resolve_image_part(
url, image_detail=image_detail
)
except Exception as exc:
logger.warning(
"图片 %s 预处理失败,将保留原始内容。错误: %s",
url,
exc,
)
return part
return resolved_part or part
if part.get("type") == "audio_url":
audio_ref = self._extract_audio_part_info(part)
if not audio_ref:
return part
resolved_part = await self._resolve_audio_part(audio_ref)
return resolved_part or part
return part
async def _materialize_message_image_parts(self, message: dict) -> dict:
content = message.get("content")
@@ -532,6 +622,7 @@ class ProviderOpenAIOfficial(Provider):
**payloads,
stream=True,
extra_body=extra_body,
stream_options={"include_usage": True},
)
llm_response = LLMResponse("assistant", is_chunk=True)
@@ -539,12 +630,10 @@ class ProviderOpenAIOfficial(Provider):
state = ChatCompletionStreamState()
async for chunk in stream:
if not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
choice = chunk.choices[0] if chunk.choices else None
delta = choice.delta if choice else None
if dtcs := delta.tool_calls:
if delta and (dtcs := delta.tool_calls):
for idx, tc in enumerate(dtcs):
# siliconflow workaround
if tc.function and tc.function.arguments:
@@ -574,7 +663,7 @@ class ProviderOpenAIOfficial(Provider):
_y = True
if chunk.usage:
llm_response.usage = self._extract_usage(chunk.usage)
elif choice_usage := getattr(choice, "usage", None):
elif choice and (choice_usage := getattr(choice, "usage", None)):
# Workaround for some providers that only return usage in choices[].usage, e.g. MoonshotAI
# See https://github.com/AstrBotDevs/AstrBot/issues/6614
llm_response.usage = self._extract_usage(choice_usage)
@@ -822,6 +911,7 @@ class ProviderOpenAIOfficial(Provider):
self,
prompt: str | None,
image_urls: list[str] | None = None,
audio_urls: list[str] | None = None,
contexts: list[dict] | list[Message] | None = None,
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
@@ -835,7 +925,10 @@ class ProviderOpenAIOfficial(Provider):
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
prompt or "",
image_urls,
audio_urls,
extra_user_content_parts,
)
context_query = copy.deepcopy(self._ensure_message_to_dicts(contexts))
if new_record:
@@ -1022,6 +1115,7 @@ class ProviderOpenAIOfficial(Provider):
prompt=None,
session_id=None,
image_urls=None,
audio_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
@@ -1034,6 +1128,7 @@ class ProviderOpenAIOfficial(Provider):
payloads, context_query = await self._prepare_chat_payload(
prompt,
image_urls,
audio_urls,
contexts,
system_prompt,
tool_calls_result,
@@ -1093,6 +1188,7 @@ class ProviderOpenAIOfficial(Provider):
prompt=None,
session_id=None,
image_urls=None,
audio_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
@@ -1106,6 +1202,7 @@ class ProviderOpenAIOfficial(Provider):
payloads, context_query = await self._prepare_chat_payload(
prompt,
image_urls,
audio_urls,
contexts,
system_prompt,
tool_calls_result,
@@ -1190,6 +1287,7 @@ class ProviderOpenAIOfficial(Provider):
self,
text: str,
image_urls: list[str] | None = None,
audio_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
) -> dict:
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
@@ -1201,8 +1299,10 @@ class ProviderOpenAIOfficial(Provider):
if text:
content_blocks.append({"type": "text", "text": text})
elif image_urls:
# 如果没有文本但有图片,添加占位文本
content_blocks.append({"type": "text", "text": "[图片]"})
# 如果没有文本但有图片,添加占位文本
content_blocks.append({"type": "text", "text": "[Image]"})
elif audio_urls:
content_blocks.append({"type": "text", "text": "[Audio]"})
elif extra_user_content_parts:
# 如果只有额外内容块,也需要添加占位文本
content_blocks.append({"type": "text", "text": " "})
@@ -1218,6 +1318,10 @@ class ProviderOpenAIOfficial(Provider):
)
if image_part:
content_blocks.append(image_part)
elif isinstance(part, AudioURLPart):
audio_part = await self._resolve_audio_part(part.audio_url.url)
if audio_part:
content_blocks.append(audio_part)
else:
raise ValueError(f"不支持的额外内容块类型: {type(part)}")
@@ -1228,11 +1332,18 @@ class ProviderOpenAIOfficial(Provider):
if image_part:
content_blocks.append(image_part)
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
if audio_urls:
for audio_path in audio_urls:
audio_part = await self._resolve_audio_part(audio_path)
if audio_part:
content_blocks.append(audio_part)
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
if (
text
and not extra_user_content_parts
and not image_urls
and not audio_urls
and len(content_blocks) == 1
and content_blocks[0]["type"] == "text"
):
@@ -19,6 +19,11 @@ class VLLMRerankProvider(RerankProvider):
self.auth_key = provider_config.get("rerank_api_key", "")
self.base_url = provider_config.get("rerank_api_base", "http://127.0.0.1:8000")
self.base_url = self.base_url.rstrip("/")
self.api_suffix = provider_config.get("rerank_api_suffix", "/v1/rerank")
if self.api_suffix is None:
self.api_suffix = "/v1/rerank"
if self.api_suffix and not self.api_suffix.startswith("/"):
self.api_suffix = "/" + self.api_suffix
self.timeout = provider_config.get("timeout", 20)
self.model = provider_config.get("rerank_model", "BAAI/bge-reranker-base")
@@ -44,8 +49,9 @@ class VLLMRerankProvider(RerankProvider):
if top_n is not None:
payload["top_n"] = top_n
assert self.client is not None
rerank_url = f"{self.base_url}{self.api_suffix}"
async with self.client.post(
f"{self.base_url}/v1/rerank",
rerank_url,
json=payload,
) as response:
response_data = await response.json()
+6
View File
@@ -124,6 +124,7 @@ class Context:
chat_provider_id: str,
prompt: str | None = None,
image_urls: list[str] | None = None,
audio_urls: list[str] | None = None,
tools: ToolSet | None = None,
system_prompt: str | None = None,
contexts: list[Message] | None = None,
@@ -137,6 +138,7 @@ class Context:
chat_provider_id: The chat provider ID to use.
prompt: The prompt to send to the LLM, if `contexts` and `prompt` are both provided, `prompt` will be appended as the last user message
image_urls: List of image URLs to include in the prompt, if `contexts` and `prompt` are both provided, `image_urls` will be appended to the last user message
audio_urls: List of audio URLs or local paths to include in the prompt, if `contexts` and `prompt` are both provided, `audio_urls` will be appended to the last user message
tools: ToolSet of tools available to the LLM
system_prompt: System prompt to guide the LLM's behavior, if provided, it will always insert as the first system message in the context
contexts: context messages for the LLM
@@ -152,6 +154,7 @@ class Context:
llm_resp = await prov.text_chat(
prompt=prompt,
image_urls=image_urls,
audio_urls=audio_urls,
func_tool=tools,
contexts=contexts,
system_prompt=system_prompt,
@@ -166,6 +169,7 @@ class Context:
chat_provider_id: str,
prompt: str | None = None,
image_urls: list[str] | None = None,
audio_urls: list[str] | None = None,
tools: ToolSet | None = None,
system_prompt: str | None = None,
contexts: list[Message] | None = None,
@@ -185,6 +189,7 @@ class Context:
chat_provider_id: The chat provider ID to use.
prompt: The prompt to send to the LLM, if `contexts` and `prompt` are both provided, `prompt` will be appended as the last user message
image_urls: List of image URLs to include in the prompt, if `contexts` and `prompt` are both provided, `image_urls` will be appended to the last user message
audio_urls: List of audio URLs or local paths to include in the prompt, if `contexts` and `prompt` are both provided, `audio_urls` will be appended to the last user message
tools: ToolSet of tools available to the LLM
system_prompt: System prompt to guide the LLM's behavior, if provided, it will always insert as the first system message in the context
contexts: context messages for the LLM
@@ -221,6 +226,7 @@ class Context:
request = ProviderRequest(
prompt=prompt,
image_urls=image_urls or [],
audio_urls=audio_urls or [],
func_tool=tools,
contexts=context_,
system_prompt=system_prompt or "",
@@ -22,6 +22,7 @@ class PlatformAdapterType(enum.Flag):
SATORI = enum.auto()
MISSKEY = enum.auto()
LINE = enum.auto()
MATRIX = enum.auto()
ALL = (
AIOCQHTTP
| QQOFFICIAL
@@ -38,6 +39,7 @@ class PlatformAdapterType(enum.Flag):
| SATORI
| MISSKEY
| LINE
| MATRIX
)
@@ -57,6 +59,7 @@ ADAPTER_NAME_2_TYPE = {
"satori": PlatformAdapterType.SATORI,
"misskey": PlatformAdapterType.MISSKEY,
"line": PlatformAdapterType.LINE,
"matrix": PlatformAdapterType.MATRIX,
}
+61 -3
View File
@@ -231,8 +231,8 @@ async def convert_audio_format(
Args:
audio_path: 原始音频文件路径
output_format: 目标格式,例如 amr / ogg
output_path: 输出文件路径,如果为None则自动生成
output_format: 目标格式,例如 amr / ogg / opus / wav
output_path: 输出文件路径,如果为None则自动生成
Returns:
转换后的音频文件路径
@@ -250,6 +250,8 @@ async def convert_audio_format(
args.extend(["-ac", "1", "-ar", "8000", "-ab", "12.2k"])
elif output_format == "ogg":
args.extend(["-acodec", "libopus", "-ac", "1", "-ar", "16000"])
elif output_format == "opus":
args.extend(["-acodec", "libopus", "-ac", "1", "-ar", "16000"])
args.append(output_path)
try:
@@ -291,11 +293,67 @@ async def convert_audio_to_wav(audio_path: str, output_path: str | None = None)
)
async def ensure_wav(audio_path: str, output_path: str | None = None) -> str:
"""Ensure the audio path points to wav format by extension/guess and convert when needed.
If the file appears to already be wav, return it directly to avoid extra conversion.
"""
if not audio_path:
return audio_path
if _get_audio_magic_type(audio_path) == "wav":
return audio_path
return await convert_audio_to_wav(audio_path, output_path)
def _get_audio_magic_type(audio_path: str) -> str:
"""Detect common audio formats from magic bytes."""
try:
with open(audio_path, "rb") as f:
header = f.read(64)
except FileNotFoundError:
logger.warning(f"[Media Utils] wav check file not found: {audio_path}")
return ""
except Exception as e:
logger.warning(f"[Media Utils] wav check failed: {audio_path}, error: {e}")
return ""
if len(header) < 12:
return ""
if header[:4] == b"RIFF" and header[8:12] == b"WAVE":
return "wav"
if header[:4] == b"#!AM":
return "amr"
if header[:4] == b"OggS":
if b"OpusHead" in header:
return "opus"
return "ogg"
if header[:3] == b"fLa":
return "flac"
if header[:3] == b"ID3" or header[:2] == b"\xff\xfb":
return "mp3"
if header[:4] == b"ftyp" and b"mp4" in header[:8]:
return "mp4"
if header[:8] == b"#!SILK_V3":
return "silk"
return ""
async def extract_video_cover(
video_path: str,
output_path: str | None = None,
) -> str:
"""从视频中提取封面图(JPG)。"""
"""从视频中提取封面图(JPG)"""
if output_path is None:
temp_dir = anyio.Path(get_astrbot_temp_path())
await temp_dir.mkdir(parents=True, exist_ok=True)
+68
View File
@@ -0,0 +1,68 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 新增
- 新增 `vllm_rerank` 提供商支持可配置的 `rerank_api_suffix`,可灵活适配不同 API 路径。([#7278](https://github.com/AstrBotDevs/AstrBot/pull/7278))
- 适配 llama.cpp 的 token 用量提取支持,增强 token 统计与计费分析能力。([#7358](https://github.com/AstrBotDevs/AstrBot/pull/7358))
- WebUI 捆绑 Monaco Editor 的 worker 文件,防止从 CDN 下载失败。([#7249](https://github.com/AstrBotDevs/AstrBot/pull/7249))
- 全新的模型/平台总览统计面板。([#7152](https://github.com/AstrBotDevs/AstrBot/pull/7152))
### 优化
- 优化 OpenAI 消息处理,若 `content` 列表为空时将其置为 `None`,并降低空内容下的异常率。([#6551](https://github.com/AstrBotDevs/AstrBot/pull/6551))
- 优化 provider page 布局与样式,提升整体设置页面排版一致性。([#7248](https://github.com/AstrBotDevs/AstrBot/pull/7248))
- 用户已登录时将直接跳转欢迎页,减少再次进入登录页的步骤。([commit](https://github.com/AstrBotDevs/AstrBot/commit/9896b48c))
### 修复
- 修复 Windows 下 MCP runner 未继承完整系统环境变量导致启动异常的问题。([#7054](https://github.com/AstrBotDevs/AstrBot/pull/7054))
- 修复 Gemini 思考片段泄露到用户可见消息链路的问题。([#7196](https://github.com/AstrBotDevs/AstrBot/pull/7196))
- 修复 Gemini(OpenAI 兼容 API) 下,`function_response` 格式要求导致的 `400 Invalid argument` 错误。([#7216](https://github.com/AstrBotDevs/AstrBot/pull/7216)
- 修复知识库初始化失败时的错误提示与恢复问题。([#7243](https://github.com/AstrBotDevs/AstrBot/pull/7243))
- 修复 Bailian Rerank 对新旧响应格式兼容性,避免解析失败。([#7217](https://github.com/AstrBotDevs/AstrBot/pull/7217))
- Docker 镜像补充 AMR 编码器依赖,修复部分部署场景下的音频转码缺失问题。([#7247](https://github.com/AstrBotDevs/AstrBot/pull/7247))
- 修复 pip 安装缺少 `pysocks` 依赖导致的 SOCKS5 代理不可用问题。([#7221](https://github.com/AstrBotDevs/AstrBot/pull/7221))
- 修复 `cached_tokens` 在 `_extract_usage` 中的处理逻辑,避免 token 统计错乱。([#6719](https://github.com/AstrBotDevs/AstrBot/pull/6719))
- 增强 SSE 断开保护,压缩传输下主动发送心跳,减少 WebChat 误断连。([#7003](https://github.com/AstrBotDevs/AstrBot/pull/7003))
- 修复严格 API 下空 assistant 消息导致的 400 错误。([#7202](https://github.com/AstrBotDevs/AstrBot/pull/7202))
- 新增二维码功能缺失的 `qrcode` 依赖修复,恢复二维码生成功能。([commit](https://github.com/AstrBotDevs/AstrBot/commit/5886c437))
- 修复配置器数字输入组件的交互体验问题,避免异常输入场景下的处理偏差。([#7153](https://github.com/AstrBotDevs/AstrBot/pull/7153))
- 修复 `Record.path` 在 Pydantic v2 下被错误视为必填字段的问题。([#7048](https://github.com/AstrBotDevs/AstrBot/pull/7048))
<a id="english"></a>
## What's Changed (EN)
### New Features
- Added configurable `rerank_api_suffix` for the `vllm_rerank` provider to support custom API paths.([#7278](https://github.com/AstrBotDevs/AstrBot/pull/7278))
- Added token usage extraction support for llama.cpp to improve token accounting.([#7358](https://github.com/AstrBotDevs/AstrBot/pull/7358))
- Bundled Monaco Editor workers in WebUI so code editing does not depend on external CDN downloads.([#7249](https://github.com/AstrBotDevs/AstrBot/pull/7249))
- Added a new statistics overview for model and platform usage.([#7152](https://github.com/AstrBotDevs/AstrBot/pull/7152))
- Redirected logged-in users to the welcome page instead of the login page.([commit](https://github.com/AstrBotDevs/AstrBot/commit/9896b48c))
### Improvements
- Improved OpenAI message handling by setting message `content` to `None` when content lists are empty, reducing empty-payload edge cases.([#6551](https://github.com/AstrBotDevs/AstrBot/pull/6551))
- Improved provider page layout and styling to keep settings pages consistent.([#7248](https://github.com/AstrBotDevs/AstrBot/pull/7248))
- Refined connection-success handling and messaging when users are already logged in.([commit](https://github.com/AstrBotDevs/AstrBot/commit/2de23184))
### Bug Fixes
- Inherited all system environment variables in Windows MCP runner to fix environment-dependent launch issues.([#7054](https://github.com/AstrBotDevs/AstrBot/pull/7054))
- Filtered Gemini thinking parts from user-facing message chains.([#7196](https://github.com/AstrBotDevs/AstrBot/pull/7196))
- Fixed Gemini `function_response` compatibility to prevent `400 Invalid argument` responses.([#7216](https://github.com/AstrBotDevs/AstrBot/pull/7216))
- Improved knowledge base initialization error handling.([#7243](https://github.com/AstrBotDevs/AstrBot/pull/7243))
- Fixed Bailian rerank parsing for both old and new response formats.([#7217](https://github.com/AstrBotDevs/AstrBot/pull/7217))
- Added AMR encoder support in Docker builds to fix missing audio codec support.([#7247](https://github.com/AstrBotDevs/AstrBot/pull/7247))
- Added the `pysocks` dependency so pip installs can use SOCKS5 proxy correctly.([#7221](https://github.com/AstrBotDevs/AstrBot/pull/7221))
- Fixed `_extract_usage` cached token handling.([#6719](https://github.com/AstrBotDevs/AstrBot/pull/6719))
- Sent SSE heartbeat packets to prevent WebChat disconnection under compression.([#7003](https://github.com/AstrBotDevs/AstrBot/pull/7003))
- Fixed `400` errors caused by empty assistant messages on strict APIs.([#7202](https://github.com/AstrBotDevs/AstrBot/pull/7202))
- Added missing `qrcode` dependency to restore QR code generation.([commit](https://github.com/AstrBotDevs/AstrBot/commit/5886c437))
- Fixed number input UX in config renderer.([#7153](https://github.com/AstrBotDevs/AstrBot/pull/7153))
- Fixed `Record.path` being incorrectly treated as required under Pydantic v2.([#7048](https://github.com/AstrBotDevs/AstrBot/pull/7048))
@@ -0,0 +1 @@
<svg width="75" height="32" xmlns="http://www.w3.org/2000/svg"><g fill="#000" fill-rule="nonzero"><path d="M.936.732V31.25H3.13v.732H.095V0h3.034v.732zm8.45 9.675v1.544h.044a4.461 4.461.0 011.487-1.368c.58-.323 1.245-.485 1.993-.485.72.0 1.377.14 1.972.42.595.279 1.047.771 1.355 1.477.338-.5.796-.941 1.377-1.323.58-.383 1.266-.574 2.06-.574.602.0 1.16.074 1.674.22.514.148.954.383 1.322.707.366.323.653.746.859 1.268.205.522.308 1.15.308 1.887v7.633H20.71v-6.464c0-.383-.015-.743-.044-1.082a2.305 2.305.0 00-.242-.882 1.473 1.473.0 00-.584-.596c-.257-.146-.606-.22-1.047-.22-.44.0-.796.085-1.068.253-.272.17-.485.39-.639.662a2.654 2.654.0 00-.308.927 7.074 7.074.0 00-.078 1.048v6.354h-3.128v-6.398c0-.338-.007-.673-.021-1.004a2.825 2.825.0 00-.188-.916 1.411 1.411.0 00-.55-.673c-.258-.168-.636-.253-1.135-.253a2.33 2.33.0 00-.584.1 1.94 1.94.0 00-.705.374c-.228.184-.422.449-.584.794-.161.346-.242.798-.242 1.357v6.619H6.434V10.407h2.952zm16.456 1.677a3.751 3.751.0 011.233-1.17 5.37 5.37.0 011.685-.629 9.579 9.579.0 011.884-.187c.573.0 1.153.04 1.74.121.588.081 1.124.24 1.609.475.484.235.88.562 1.19.981.308.42.462.975.462 1.666v5.934c0 .516.03 1.008.088 1.478.058.471.161.824.308 1.06H32.87a4.435 4.435.0 01-.22-1.104c-.5.515-1.087.876-1.762 1.081a7.084 7.084.0 01-2.071.31c-.544.0-1.05-.067-1.52-.2a3.472 3.472.0 01-1.234-.617 2.87 2.87.0 01-.826-1.059c-.199-.426-.298-.934-.298-1.522.0-.647.114-1.18.342-1.6.227-.419.52-.753.881-1.004.36-.25.771-.437 1.234-.562.462-.125.929-.224 1.399-.298.47-.073.932-.132 1.387-.176.456-.044.86-.11 1.212-.199.353-.088.631-.217.837-.386.206-.169.301-.415.287-.74.0-.337-.055-.606-.166-.804a1.217 1.217.0 00-.44-.464 1.737 1.737.0 00-.639-.22 5.292 5.292.0 00-.782-.055c-.617.0-1.101.132-1.454.397-.352.264-.558.706-.617 1.323h-3.128c.044-.735.227-1.345.55-1.83zm6.179 4.423a5.095 5.095.0 01-.639.165 9.68 9.68.0 01-.716.11c-.25.03-.5.067-.749.11a5.616 5.616.0 00-.694.177 2.057 2.057.0 00-.594.298c-.17.125-.305.284-.408.474-.103.192-.154.434-.154.728.0.28.051.515.154.706.103.192.242.342.419.453.176.11.381.187.617.231.234.044.477.066.726.066.617.0 1.094-.102 1.432-.309.338-.205.587-.452.75-.739.16-.286.26-.576.297-.87.036-.295.055-.53.055-.707v-1.17a1.4 1.4.0 01-.496.277zm11.863-6.1v2.096h-2.291v5.647c0 .53.088.883.264 1.059.176.177.529.265 1.057.265.177.0.345-.007.507-.022.161-.015.316-.037.463-.066v2.426a7.49 7.49.0 01-.882.089 21.67 21.67.0 01-.947.022c-.484.0-.944-.034-1.377-.1a3.233 3.233.0 01-1.145-.386 2.04 2.04.0 01-.782-.816c-.191-.353-.287-.816-.287-1.39v-6.728H36.57v-2.096h1.894v-3.42h3.129v3.42h2.29zm4.471.0v2.118h.044a3.907 3.907.0 011.454-1.754 4.213 4.213.0 011.036-.497 3.734 3.734.0 011.145-.176c.206.0.433.037.683.11v2.912a5.862 5.862.0 00-.528-.077 5.566 5.566.0 00-.595-.033c-.573.0-1.058.096-1.454.287a2.52 2.52.0 00-.958.783 3.143 3.143.0 00-.518 1.158 6.32 6.32.0 00-.154 1.434v5.14h-3.128V10.407h2.973zM54.039 8.642V6.06h3.128v2.582H54.04zm3.128 1.765v11.405H54.04V10.407h3.128zm1.63.0h3.569l2.005 2.978 1.982-2.978h3.459l-3.745 5.339 4.208 6.067h-3.57l-2.378-3.596-2.38 3.596h-3.502l4.097-6.001zM74.094 31.25V.732H71.9V0h3.035v31.982H71.9v-.732z"></path></g></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset – 255 icons */
/* Auto-generated MDI subset – 256 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -684,6 +684,10 @@
content: "\F0375";
}
.mdi-music-note-outline::before {
content: "\F0F74";
}
.mdi-note-text-outline::before {
content: "\F11D7";
}
@@ -121,12 +121,13 @@
<!-- 模型配置 -->
<ProviderModelsPanel
v-model:model-search="modelSearch"
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
v-model:model-search="modelSearch"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-audio-input="supportsAudioInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
@@ -140,6 +141,51 @@
@delete-provider="deleteProvider"
@add-model-provider="addModelProvider"
/>
>>>>>>> origin =======
<ProviderModelsPanel
v-model:model-search="modelSearch"
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-audio-input="supportsAudioInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
:testing-providers="testingProviders"
:tm="tm"
@fetch-models="fetchAvailableModels"
@open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit"
@toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider"
@delete-provider="deleteProvider"
@add-model-provider="addModelProvider"
/>
=======
<ProviderModelsPanel
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
v-model:model-search="modelSearch"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-audio-input="supportsAudioInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
:testing-providers="testingProviders"
:tm="tm"
@fetch-models="fetchAvailableModels"
@open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit"
@toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider"
@delete-provider="deleteProvider"
@add-model-provider="addModelProvider"
/>
>>>>>>> origin
</div>
</div>
<div
@@ -292,6 +338,7 @@ const {
resolveSourceIcon,
getSourceDisplayName,
supportsImageInput,
supportsAudioInput,
supportsToolCall,
supportsReasoning,
formatContextLimit,
@@ -1,95 +1,67 @@
<template>
<v-menu
v-model="menuOpen"
:close-on-content-click="false"
location="top"
@update:model-value="handleMenuToggle"
>
<template #activator="{ props: menuProps }">
<v-chip
v-bind="menuProps"
class="text-none provider-chip"
variant="tonal"
:size="chipSize"
>
<v-icon start size="14"> mdi-creation </v-icon>
<span v-if="selectedProviderId">
{{ selectedProviderId }}
</span>
<span v-else>Model</span>
</v-chip>
</template>
<v-card class="provider-menu-card" min-width="280" max-width="400">
<v-card-text class="pa-2">
<v-text-field
v-model="searchQuery"
placeholder="Search..."
hide-details
variant="plain"
flat
density="compact"
prepend-inner-icon="mdi-magnify"
class="ml-2 mb-2 mr-2"
clearable
/>
<v-list density="compact" nav class="provider-menu-list">
<v-list-item
v-for="provider in filteredProviders"
:key="provider.id"
:active="selectedProviderId === provider.id"
rounded="lg"
class="provider-menu-item"
@click="selectProvider(provider)"
>
<v-list-item-title class="text-body-2">
{{ provider.id }}
</v-list-item-title>
<v-list-item-subtitle class="provider-subtitle">
<span class="model-name">{{ provider.model }}</span>
<span class="meta-icons">
<v-tooltip
v-if="supportsImageInput(provider)"
text="支持图像输入"
location="top"
>
<template #activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey"
>mdi-eye-outline</v-icon
>
</template>
</v-tooltip>
<v-tooltip
v-if="supportsToolCall(provider)"
text="支持工具调用"
location="top"
>
<template #activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey"
>mdi-wrench</v-icon
>
</template>
</v-tooltip>
<v-tooltip
v-if="supportsReasoning(provider)"
text="支持推理"
location="top"
>
<template #activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey"
>mdi-brain</v-icon
>
</template>
</v-tooltip>
</span>
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-if="providerConfigs.length === 0" class="empty-hint">
No available models
</div>
</v-card-text>
</v-card>
</v-menu>
<v-menu v-model="menuOpen" :close-on-content-click="false" location="top" @update:model-value="handleMenuToggle">
<template v-slot:activator="{ props: menuProps }">
<v-chip v-bind="menuProps" class="text-none provider-chip" variant="tonal" :size="chipSize">
<v-icon start size="14">mdi-creation</v-icon>
<span v-if="selectedProviderId">
{{ selectedProviderId }}
</span>
<span v-else>Model</span>
</v-chip>
</template>
<v-card class="provider-menu-card" min-width="280" max-width="400">
<v-card-text class="pa-2">
<v-text-field
v-model="searchQuery"
placeholder="Search..."
hide-details
variant="plain"
flat
density="compact"
prepend-inner-icon="mdi-magnify"
class="ml-2 mb-2 mr-2"
clearable
/>
<v-list density="compact" nav class="provider-menu-list">
<v-list-item v-for="provider in filteredProviders" :key="provider.id"
:active="selectedProviderId === provider.id" @click="selectProvider(provider)" rounded="lg"
class="provider-menu-item">
<v-list-item-title class="text-body-2">{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle class="provider-subtitle">
<span class="model-name">{{ provider.model }}</span>
<span class="meta-icons">
<v-tooltip text="支持图像输入" location="top" v-if="supportsImageInput(provider)">
<template v-slot:activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey">mdi-eye-outline</v-icon>
</template>
</v-tooltip>
<v-tooltip text="支持音频输入" location="top" v-if="supportsAudioInput(provider)">
<template v-slot:activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey">mdi-music-note-outline</v-icon>
</template>
</v-tooltip>
<v-tooltip text="支持工具调用" location="top" v-if="supportsToolCall(provider)">
<template v-slot:activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey">mdi-wrench</v-icon>
</template>
</v-tooltip>
<v-tooltip text="支持推理" location="top" v-if="supportsReasoning(provider)">
<template v-slot:activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey">mdi-brain</v-icon>
</template>
</v-tooltip>
</span>
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-if="providerConfigs.length === 0" class="empty-hint">
No available models
</div>
</v-card-text>
</v-card>
</v-menu>
</template>
<script setup lang="ts">
@@ -173,6 +145,11 @@ function supportsImageInput(provider: ProviderConfig): boolean {
return inputs.includes("image");
}
function supportsAudioInput(provider: ProviderConfig): boolean {
const inputs = provider.model_metadata?.modalities?.input || [];
return inputs.includes('audio');
}
function supportsToolCall(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.tool_call);
}
@@ -5,23 +5,23 @@
<v-tabs v-model="activeProviderTab" grow>
<v-tab value="agent_runner" class="font-weight-medium px-3">
<v-icon start> mdi-cogs </v-icon>
{{ tm("dialogs.addProvider.tabs.agentRunner") }}
{{ tm('dialogs.addProvider.tabs.agentRunner') }}
</v-tab>
<v-tab value="speech_to_text" class="font-weight-medium px-3">
<v-icon start> mdi-microphone-message </v-icon>
{{ tm("dialogs.addProvider.tabs.speechToText") }}
{{ tm('dialogs.addProvider.tabs.speechToText') }}
</v-tab>
<v-tab value="text_to_speech" class="font-weight-medium px-3">
<v-icon start> mdi-volume-high </v-icon>
{{ tm("dialogs.addProvider.tabs.textToSpeech") }}
{{ tm('dialogs.addProvider.tabs.textToSpeech') }}
</v-tab>
<v-tab value="embedding" class="font-weight-medium px-3">
<v-icon start> mdi-code-json </v-icon>
{{ tm("dialogs.addProvider.tabs.embedding") }}
{{ tm('dialogs.addProvider.tabs.embedding') }}
</v-tab>
<v-tab value="rerank" class="font-weight-medium px-3">
<v-icon start> mdi-compare-vertical </v-icon>
{{ tm("dialogs.addProvider.tabs.rerank") }}
{{ tm('dialogs.addProvider.tabs.rerank') }}
</v-tab>
</v-tabs>
@@ -81,7 +81,7 @@
cols="12"
>
<v-alert type="info" variant="tonal">
{{ tm("dialogs.addProvider.noTemplates") }}
{{ tm('dialogs.addProvider.noTemplates') }}
</v-alert>
</v-col>
</v-row>
@@ -91,7 +91,7 @@
<v-card-actions>
<v-spacer />
<v-btn text @click="closeDialog">
{{ tm("dialogs.config.cancel") }}
{{ tm('dialogs.config.cancel') }}
</v-btn>
</v-card-actions>
</v-card>
@@ -102,6 +102,8 @@
import { useModuleI18n } from "@/i18n/composables";
import { getProviderIcon, getProviderDescription } from "@/utils/providerUtils";
const AVAILABLE_PROVIDER_TABS = ['agent_runner', 'speech_to_text', 'text_to_speech', 'embedding', 'rerank'];
export default {
name: "AddNewProvider",
props: {
@@ -113,6 +115,10 @@ export default {
type: Object,
default: () => ({}),
},
currentProviderType: {
type: String,
default: 'agent_runner'
}
},
emits: ["update:show", "select-template"],
setup() {
@@ -121,7 +127,7 @@ export default {
},
data() {
return {
activeProviderTab: "chat_completion",
activeProviderTab: "agent_runner",
};
},
computed: {
@@ -134,7 +140,25 @@ export default {
},
},
},
watch: {
show(value) {
if (value) {
this.syncActiveProviderTab();
}
},
currentProviderType() {
if (this.showDialog) {
this.syncActiveProviderTab();
}
}
},
methods: {
syncActiveProviderTab() {
this.activeProviderTab = AVAILABLE_PROVIDER_TABS.includes(this.currentProviderType)
? this.currentProviderType
: 'agent_runner';
},
closeDialog() {
this.showDialog = false;
},
@@ -80,6 +80,13 @@
>
mdi-eye-outline
</v-icon>
<v-icon
v-if="supportsAudioInput(entry.metadata)"
size="14"
color="grey"
>
mdi-music-note-outline
</v-icon>
<v-icon
v-if="supportsToolCall(entry.metadata)"
size="14"
@@ -183,6 +190,13 @@
>
mdi-eye-outline
</v-icon>
<v-icon
v-if="supportsAudioInput(entry.metadata)"
size="14"
color="grey"
>
mdi-music-note-outline
</v-icon>
<v-icon
v-if="supportsToolCall(entry.metadata)"
size="14"
@@ -259,6 +273,10 @@ const props = defineProps({
type: Function,
required: true,
},
supportsAudioInput: {
type: Function,
required: true
},
supportsToolCall: {
type: Function,
required: true,
@@ -251,7 +251,7 @@ const emitDeleteSource = (source) => emit("delete-provider-source", source);
}
.provider-sources-mobile {
padding: 14px 18px 0;
padding: 16px;
}
.provider-sources-list-wrap {
@@ -593,10 +593,21 @@ function hasVisibleItemsAfter(items, currentIndex) {
padding: 8px 0;
}
.property-info,
.property-info {
padding: 4px 4px;
}
.property-info :deep(.v-list-item) {
padding-inline: 0;
}
.type-indicator,
.config-input {
padding: 4px;
}
.config-divider {
display: none;
}
}
</style>
@@ -603,14 +603,17 @@ function getSpecialSubtype(value) {
padding: 8px 0;
}
.property-info,
.type-indicator {
padding: 4px 8px;
}
.config-input {
padding-left: 24px;
padding-right: 24px;
padding-left: 16px;
padding-right: 16px;
}
.config-divider {
display: none;
}
}
</style>
@@ -108,7 +108,7 @@
itemMeta?.options &&
itemMeta?.render_type === 'checkbox'
"
class="d-flex flex-wrap gap-20"
class="checkbox-group d-flex flex-wrap gap-20"
>
<v-checkbox
v-for="(option, optionIndex) in itemMeta.options"
@@ -116,8 +116,9 @@
:model-value="modelValue"
:label="getLabel(itemMeta, optionIndex, option)"
:value="option"
class="mr-2"
class="config-checkbox"
color="primary"
density="compact"
hide-details
@update:model-value="emitUpdate"
/>
@@ -400,8 +401,29 @@ function getSpecialSubtype(value) {
background-color: rgba(0, 0, 0, 0.5);
}
.gap-20 {
gap: 20px;
.checkbox-group {
gap: 6px 12px;
}
.config-checkbox {
margin-right: 0;
}
.config-checkbox :deep(.v-selection-control) {
min-height: 28px;
}
.config-checkbox :deep(.v-selection-control__wrapper) {
width: 18px;
height: 18px;
}
.config-checkbox :deep(.v-icon) {
font-size: 18px;
}
.config-checkbox :deep(.v-label) {
font-size: 0.9rem;
}
::v-deep(.v-field__input) {
@@ -363,6 +363,11 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
return inputs.includes("image");
}
function supportsAudioInput(meta: any) {
const inputs = meta?.modalities?.input || [];
return inputs.includes("audio");
}
function supportsToolCall(meta: any) {
return Boolean(meta?.tool_call);
}
@@ -650,12 +655,15 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
let modalities: string[];
if (!metadata) {
modalities = ["text", "image", "tool_use"];
modalities = ["text", "image", "audio", "tool_use"];
} else {
modalities = ["text"];
if (supportsImageInput(metadata)) {
modalities.push("image");
}
if (supportsAudioInput(metadata)) {
modalities.push("audio");
}
if (supportsToolCall(metadata)) {
modalities.push("tool_use");
}
@@ -817,6 +825,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
getSourceDisplayName,
getModelMetadata,
supportsImageInput,
supportsAudioInput,
supportsToolCall,
supportsReasoning,
formatContextLimit,
+86 -68
View File
@@ -1,78 +1,96 @@
import { ref } from "vue";
import axios from "@/utils/request";
import { ref } from 'vue';
import axios from '@/utils/request';
export function useRecording() {
const isRecording = ref(false);
const audioChunks = ref<Blob[]>([]);
const mediaRecorder = ref<MediaRecorder | null>(null);
const isRecording = ref(false);
const audioChunks = ref<Blob[]>([]);
const mediaRecorder = ref<MediaRecorder | null>(null);
async function startRecording(onStart?: (label: string) => void) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.value = new MediaRecorder(stream);
mediaRecorder.value.ondataavailable = (event) => {
audioChunks.value.push(event.data);
};
mediaRecorder.value.start();
isRecording.value = true;
if (onStart) {
onStart("录音中...");
}
} catch (error) {
console.error("Failed to start recording:", error);
function getRecordingMimeType(): string {
const chunkType = audioChunks.value.find(chunk => chunk.type)?.type;
return chunkType || mediaRecorder.value?.mimeType || 'audio/webm';
}
}
async function stopRecording(
onStop?: (label: string) => void,
): Promise<string> {
return new Promise((resolve, reject) => {
if (!mediaRecorder.value) {
reject("No media recorder");
return;
}
isRecording.value = false;
if (onStop) {
onStop("聊天输入框");
}
mediaRecorder.value.stop();
mediaRecorder.value.onstop = async () => {
const audioBlob = new Blob(audioChunks.value, { type: "audio/wav" });
audioChunks.value = [];
mediaRecorder.value?.stream
.getTracks()
.forEach((track) => track.stop());
const formData = new FormData();
formData.append("file", audioBlob);
function getRecordingFilename(mimeType: string): string {
const extensionMap: Record<string, string> = {
'audio/webm': 'webm',
'audio/webm;codecs=opus': 'webm',
'audio/ogg': 'ogg',
'audio/ogg;codecs=opus': 'ogg',
'audio/mp4': 'm4a',
'audio/mpeg': 'mp3',
'audio/wav': 'wav'
};
const normalizedMimeType = mimeType.toLowerCase();
const extension = extensionMap[normalizedMimeType] || normalizedMimeType.split('/')[1]?.split(';')[0] || 'webm';
return `${crypto.randomUUID()}.${extension}`;
}
async function startRecording(onStart?: (label: string) => void) {
try {
const response = await axios.post("/api/chat/post_file", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
const audio = response.data.data.filename;
console.info("Audio uploaded:", audio);
resolve(audio);
} catch (err) {
console.error("Error uploading audio:", err);
reject(err);
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.value = new MediaRecorder(stream);
mediaRecorder.value.ondataavailable = (event) => {
audioChunks.value.push(event.data);
};
mediaRecorder.value.start();
isRecording.value = true;
if (onStart) {
onStart('录音中...');
}
} catch (error) {
console.error('Failed to start recording:', error);
}
};
});
}
}
return {
isRecording,
startRecording,
stopRecording,
};
async function stopRecording(onStop?: (label: string) => void): Promise<string> {
return new Promise((resolve, reject) => {
if (!mediaRecorder.value) {
reject('No media recorder');
return;
}
isRecording.value = false;
if (onStop) {
onStop('聊天输入框');
}
mediaRecorder.value.stop();
mediaRecorder.value.onstop = async () => {
const mimeType = getRecordingMimeType();
const audioBlob = new Blob(audioChunks.value, { type: mimeType });
const filename = getRecordingFilename(mimeType);
audioChunks.value = [];
mediaRecorder.value?.stream.getTracks().forEach(track => track.stop());
const formData = new FormData();
formData.append('file', audioBlob, filename);
try {
const response = await axios.post('/api/chat/post_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
const attachmentId = response.data.data.attachment_id;
console.log('Audio uploaded:', attachmentId);
resolve(attachmentId);
} catch (err) {
console.error('Error uploading audio:', err);
reject(err);
}
};
});
}
return {
isRecording,
startRecording,
stopRecording
};
}
@@ -1087,7 +1087,11 @@
},
"rerank_api_base": {
"description": "Rerank Model API Base URL",
"hint": "AstrBot appends /v1/rerank to the request URL."
"hint": "The full request URL is formed by combining the Base URL and a path suffix (defaults to /v1/rerank)."
},
"rerank_api_suffix": {
"description": "API URL path suffix",
"hint": "Path appended to base_url, e.g. /v1/rerank. Leave empty to disable auto-append."
},
"rerank_api_key": {
"description": "API Key",
@@ -1104,6 +1108,24 @@
"description": "Custom rerank task description",
"hint": "Only effective for qwen3-rerank models. Recommended to write in English."
},
"nvidia_rerank_api_base": {
"description": "API Base URL"
},
"nvidia_rerank_api_key": {
"description": "API Key"
},
"nvidia_rerank_model": {
"description": "Rerank Model Name",
"hint": "Please refer to the NVIDIA Docs for the model name."
},
"nvidia_rerank_model_endpoint": {
"description": "Custom Model Endpoint",
"hint": "Custom URL suffix endpoint, defaults to /reranking."
},
"nvidia_rerank_truncate": {
"description": "Text Truncation Strategy",
"hint": "Whether to truncate the input to fit the model's maximum context length when the input text is too long."
},
"launch_model_if_not_running": {
"description": "Auto-start model if not running",
"hint": "If the model is not running in Xinference, attempt to start it automatically. Recommended to disable in production."
@@ -1114,6 +1136,7 @@
"labels": [
"Text",
"Image",
"Audio",
"Tool use"
]
},
@@ -1606,4 +1629,4 @@
"helpMiddle": "or",
"helpSuffix": "."
}
}
}
@@ -1088,7 +1088,11 @@
},
"rerank_api_base": {
"description": "Base URL API модели Rerank",
"hint": "AstrBot добавляет /v1/rerank к URL запроса."
"hint": "Полный URL запроса формируется путём добавления суффикса к Base URL (по умолчанию /v1/rerank)."
},
"rerank_api_suffix": {
"description": "Суффикс пути API",
"hint": "Суффикс пути, добавляемый к base_url, например /v1/rerank. Оставьте пустым, чтобы не добавлять."
},
"rerank_api_key": {
"description": "API Key",
@@ -1105,6 +1109,24 @@
"description": "Описание задачи для Rerank",
"hint": "Эффективно только для моделей qwen3-rerank. Рекомендуется писать на английском."
},
"nvidia_rerank_api_base": {
"description": "Базовый URL API"
},
"nvidia_rerank_api_key": {
"description": "API-ключ"
},
"nvidia_rerank_model": {
"description": "Название модели Rerank",
"hint": "Укажите название модели в соответствии с документацией NVIDIA."
},
"nvidia_rerank_model_endpoint": {
"description": "Пользовательский endpoint модели",
"hint": "Пользовательский суффикс URL endpoint, по умолчанию /reranking."
},
"nvidia_rerank_truncate": {
"description": "Стратегия усечения текста",
"hint": "Определяет, следует ли усекать входной текст, если он слишком длинный и не помещается в максимальную длину контекста модели."
},
"launch_model_if_not_running": {
"description": "Автозапуск модели",
"hint": "Если модель не запущена в Xinference, попытаться запустить её автоматически. Рекомендуется отключать в продакшене."
@@ -1115,6 +1137,7 @@
"labels": [
"Текст",
"Изображение",
"Аудио",
"Инструменты"
]
},
@@ -1085,7 +1085,11 @@
},
"rerank_api_base": {
"description": "重排序模型 API Base URL",
"hint": "AstrBot 会在请求时在末尾加上 /v1/rerank。"
"hint": "最终请求路径由 Base URL 和路径后缀拼接而成(默认为 /v1/rerank)。"
},
"rerank_api_suffix": {
"description": "API URL 路径后缀",
"hint": "追加到 base_url 后的路径后缀,如 /v1/rerank。留空则不追加。"
},
"rerank_api_key": {
"description": "API Key",
@@ -1102,6 +1106,24 @@
"description": "自定义排序任务类型说明",
"hint": "仅在使用 qwen3-rerank 模型时生效。建议使用英文撰写。"
},
"nvidia_rerank_api_base": {
"description": "API Base URL"
},
"nvidia_rerank_api_key": {
"description": "API Key"
},
"nvidia_rerank_model": {
"description": "重排序模型名称",
"hint": "请参照NVIDIA Docs中模型名称填写。"
},
"nvidia_rerank_model_endpoint": {
"description": "自定义模型端点",
"hint": "自定义URL末尾端点,默认为 /reranking"
},
"nvidia_rerank_truncate": {
"description": "文本截断策略",
"hint": "当输入文本过长时,是否截断输入以适应模型的最大上下文长度。"
},
"launch_model_if_not_running": {
"description": "模型未运行时自动启动",
"hint": "如果模型当前未在 Xinference 服务中运行,是否尝试自动启动它。在生产环境中建议关闭。"
@@ -1112,6 +1134,7 @@
"labels": [
"文本",
"图像",
"音频",
"工具使用"
]
},
+5
View File
@@ -63,6 +63,9 @@ export function getPlatformIcon(name) {
} else if (name === "line") {
return new URL("@/assets/images/platform_logos/line.png", import.meta.url)
.href;
} else if (name === "matrix") {
return new URL("@/assets/images/platform_logos/matrix.svg", import.meta.url)
.href;
}
}
@@ -92,6 +95,7 @@ export function getTutorialLink(platformType) {
satori: "https://docs.astrbot.app/platform/satori/llonebot.html",
misskey: "https://docs.astrbot.app/platform/misskey.html",
line: "https://docs.astrbot.app/platform/line.html",
matrix: "https://docs.astrbot.app/platform/matrix.html",
};
return tutorialMap[platformType] || "https://docs.astrbot.app";
}
@@ -134,6 +138,7 @@ export function getPlatformDisplayName(platformId) {
vocechat: "vocechat (VoceChat)",
satori: "satori (Satori)",
line: "line (LINE)",
matrix: "matrix (Matrix)",
};
return displayNameMap[platformId] || platformId;
}
+8
View File
@@ -63,6 +63,14 @@ export function getProviderIcon(type) {
"https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openrouter.svg",
tokenpony: "https://tokenpony.cn/tokenpony-web/logo.png",
compshare: "https://compshare.cn/favicon.ico",
xinference:
"https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/xinference-color.svg",
bailian:
"https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/bailian-color.svg",
volcengine:
"https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/volcengine-color.svg",
longcat:
"https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/longcat-color.svg",
};
return icons[type] || "";
}
+2
View File
@@ -141,6 +141,7 @@
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-audio-input="supportsAudioInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
@@ -470,6 +471,7 @@ const {
resolveSourceIcon,
getSourceDisplayName,
supportsImageInput,
supportsAudioInput,
supportsToolCall,
supportsReasoning,
formatContextLimit,
+26 -1
View File
@@ -3,6 +3,27 @@
> [!TIP]
> 该平台适配器由社区([stevessr](https://github.com/stevessr)) 维护。如果您觉得有帮助,请支持开发者,给该仓库点一个 Star。❤️
## 部署 Matrix 服务器
Matrix 是一个 IM 协议,有着丰富的服务端实现。
请在 [Matrix Server](https://matrix.org/ecosystem/servers/)查看可用的服务端。
## 支持的基本消息类型
| 消息类型 | 是否支持接收 | 是否支持发送 | 备注 |
| ------------ | ------------ | ------------ | ---------------------------------------------- |
| 文本 | 是 | 是 | |
| 图片* | 是 | 是 | |
| 语音* | 是 | 是 | |
| 视频* | 是 | 是 | |
| 文件* | 是 | 是 | |
| 投票 | 是 | 否 | |
*: 会持久化到本地,插件会按配置清理,在发送前会进行上传操作,超过服务器允许大小的上传将会失败
## 安装 astrbot_plugin_matrix_adapter 插件
进入 AstrBot WebUI 的插件市场,搜索 `astrbot_plugin_matrix_adapter`,点击安装。
@@ -13,7 +34,11 @@
## 配置
请参考该仓库的 [README.md](https://github.com/stevessr/astrbot_plugin_matrix_adapter?tab=readme-ov-file#astrbot-matrix-adapter-%E6%8F%92%E4%BB%B6) 进行配置。
- **`matrix_homeserver` (必填)`**: 你的 matrix 服务器实例的完整URL地址,支持域名委托自动探测。例如官方实例`https://matrix.org`
- **`matrix_user_id`**: 你的 matrix 完整用户名。如 `@username:homeserver.com`
- **`matrix_auth_method` (必填)** : 你的登陆方式,可选`password`,`token`,`oauth2`,`qr`推荐使用`password`或`oauth2/qr`模式 (oauth2/qr 模式下请确保用于认证/扫码的设备回调可以访问到 astrbot 配置的公开地址)
更多请参考该仓库的 [README.md](https://github.com/stevessr/astrbot_plugin_matrix_adapter?tab=readme-ov-file#astrbot-matrix-adapter-%E6%8F%92%E4%BB%B6) 进行配置。
## 问题提交
+88
View File
@@ -2,6 +2,7 @@ from types import SimpleNamespace
import pytest
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from PIL import Image as PILImage
from astrbot.core.exceptions import EmptyModelOutputError
@@ -1197,6 +1198,93 @@ async def test_parse_openai_completion_raises_empty_model_output_error():
await provider.terminate()
@pytest.mark.asyncio
async def test_query_stream_extracts_usage_from_empty_choices_chunk(monkeypatch):
provider = _make_provider()
try:
chunks = [
ChatCompletionChunk.model_validate(
{
"id": "chatcmpl-stream",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"content": "ok",
},
"finish_reason": None,
}
],
}
),
ChatCompletionChunk.model_validate(
{
"id": "chatcmpl-stream",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": "stop",
}
],
}
),
ChatCompletionChunk.model_validate(
{
"id": "chatcmpl-stream",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o-mini",
"choices": [],
"usage": {
"prompt_tokens": 2550,
"completion_tokens": 125,
"total_tokens": 2675,
"prompt_tokens_details": {
"cached_tokens": 2488,
},
},
}
),
]
async def fake_stream():
for chunk in chunks:
yield chunk
async def fake_create(**kwargs):
return fake_stream()
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
responses = [
response
async for response in provider._query_stream(
payloads={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hello"}],
},
tools=None,
)
]
final_response = responses[-1]
assert final_response.completion_text == "ok"
assert final_response.usage is not None
assert final_response.usage.input_other == 62
assert final_response.usage.input_cached == 2488
assert final_response.usage.output == 125
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_filters_empty_assistant_message_without_tool_calls(monkeypatch):
"""Test that empty assistant messages without tool_calls are filtered out."""
+1 -1
View File
@@ -674,7 +674,7 @@ class TestModalitiesFix:
module._modalities_fix(mock_provider, req)
assert "[图片]" in req.prompt
assert "[Image]" in req.prompt
assert req.image_urls == []
def test_modalities_fix_tool_not_supported(self, mock_provider):