mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-02 10:40:15 +08:00
Compare commits
5 Commits
fix/8364
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3931c3ca79 | ||
|
|
0e973bd4d4 | ||
|
|
b0bb5c7477 | ||
|
|
0da17485bd | ||
|
|
b8cf2ef552 |
@@ -1 +1 @@
|
||||
__version__ = "4.25.1"
|
||||
__version__ = "4.25.2"
|
||||
|
||||
@@ -5,7 +5,8 @@ import os
|
||||
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.25.1"
|
||||
VERSION = "4.25.2"
|
||||
ASTRBOT_USER_AGENT = f"astrbot/{VERSION.removeprefix('v')}"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
PERSONAL_WECHAT_CONFIG_METADATA = {
|
||||
"weixin_oc_base_url": {
|
||||
@@ -1199,7 +1200,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://api.kimi.com/coding",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {"User-Agent": "claude-code/0.1.0"},
|
||||
"custom_headers": {"User-Agent": ASTRBOT_USER_AGENT},
|
||||
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
|
||||
},
|
||||
"Moonshot": {
|
||||
@@ -1236,7 +1237,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://api.minimaxi.com/anthropic",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {"User-Agent": "claude-code/0.1.0"},
|
||||
"custom_headers": {"User-Agent": ASTRBOT_USER_AGENT},
|
||||
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
|
||||
},
|
||||
"Xiaomi": {
|
||||
@@ -1261,7 +1262,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://token-plan-cn.xiaomimimo.com/anthropic",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {"User-Agent": "claude-code/0.1.0"},
|
||||
"custom_headers": {"User-Agent": ASTRBOT_USER_AGENT},
|
||||
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
|
||||
},
|
||||
"xAI": {
|
||||
|
||||
@@ -13,9 +13,11 @@ from anthropic.types.usage import Usage
|
||||
from astrbot import logger
|
||||
from astrbot.api.provider import Provider
|
||||
from astrbot.core.agent.message import AudioURLPart, ContentPart, ImageURLPart, TextPart
|
||||
from astrbot.core.config.default import ASTRBOT_USER_AGENT
|
||||
from astrbot.core.exceptions import EmptyModelOutputError
|
||||
from astrbot.core.provider.entities import LLMResponse, TokenUsage
|
||||
from astrbot.core.provider.func_tool_manager import ToolSet
|
||||
from astrbot.core.utils.http_headers import apply_default_headers, normalize_headers
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot.core.utils.network_utils import (
|
||||
create_proxy_client,
|
||||
@@ -50,13 +52,12 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
@staticmethod
|
||||
def _normalize_custom_headers(provider_config: dict) -> dict[str, str] | None:
|
||||
custom_headers = provider_config.get("custom_headers", {})
|
||||
if not isinstance(custom_headers, dict) or not custom_headers:
|
||||
normalized_headers = normalize_headers(
|
||||
provider_config.get("custom_headers", {})
|
||||
)
|
||||
if not normalized_headers:
|
||||
return None
|
||||
normalized_headers: dict[str, str] = {}
|
||||
for key, value in custom_headers.items():
|
||||
normalized_headers[str(key)] = str(value)
|
||||
return normalized_headers or None
|
||||
return normalized_headers
|
||||
|
||||
@classmethod
|
||||
def _resolve_custom_headers(
|
||||
@@ -67,9 +68,7 @@ class ProviderAnthropic(Provider):
|
||||
) -> dict[str, str] | None:
|
||||
merged_headers = cls._normalize_custom_headers(provider_config) or {}
|
||||
if required_headers:
|
||||
for header_name, header_value in required_headers.items():
|
||||
if not merged_headers.get(header_name, "").strip():
|
||||
merged_headers[header_name] = header_value
|
||||
merged_headers = apply_default_headers(merged_headers, required_headers)
|
||||
return merged_headers or None
|
||||
|
||||
def __init__(
|
||||
@@ -89,7 +88,10 @@ class ProviderAnthropic(Provider):
|
||||
if isinstance(self.timeout, str):
|
||||
self.timeout = int(self.timeout)
|
||||
self.thinking_config = provider_config.get("anth_thinking_config", {})
|
||||
self.custom_headers = self._resolve_custom_headers(provider_config)
|
||||
self.custom_headers = self._resolve_custom_headers(
|
||||
provider_config,
|
||||
required_headers={"User-Agent": ASTRBOT_USER_AGENT},
|
||||
)
|
||||
|
||||
if use_api_key:
|
||||
self._init_api_key(provider_config)
|
||||
|
||||
@@ -18,11 +18,13 @@ import astrbot.core.message.components as Comp
|
||||
from astrbot import logger
|
||||
from astrbot.api.provider import Provider
|
||||
from astrbot.core.agent.message import AudioURLPart, ContentPart, ImageURLPart, TextPart
|
||||
from astrbot.core.config.default import ASTRBOT_USER_AGENT
|
||||
from astrbot.core.exceptions import EmptyModelOutputError
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
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.http_headers import apply_default_headers, normalize_headers
|
||||
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
|
||||
@@ -76,17 +78,41 @@ class ProviderGoogleGenAI(Provider):
|
||||
if self.api_base and self.api_base.endswith("/"):
|
||||
self.api_base = self.api_base[:-1]
|
||||
|
||||
self.custom_headers = self._resolve_custom_headers(provider_config)
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
self._stale_http_clients: list[httpx.AsyncClient] = []
|
||||
self._init_client()
|
||||
self.set_model(provider_config.get("model", "unknown"))
|
||||
self._init_safety_settings()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_custom_headers(provider_config: dict) -> dict[str, str]:
|
||||
headers = apply_default_headers(
|
||||
normalize_headers(provider_config.get("custom_headers", {})),
|
||||
{"user-agent": ASTRBOT_USER_AGENT},
|
||||
)
|
||||
return {
|
||||
"user-agent" if key.lower() == "user-agent" else key: value
|
||||
for key, value in headers.items()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _set_gemini_user_agent(client: object, user_agent: str) -> None:
|
||||
api_client = getattr(client, "_api_client", None)
|
||||
http_options = getattr(api_client, "_http_options", None)
|
||||
if http_options is None or http_options.headers is None:
|
||||
return
|
||||
for key in list(http_options.headers):
|
||||
if key.lower() == "user-agent":
|
||||
http_options.headers.pop(key)
|
||||
http_options.headers["user-agent"] = user_agent
|
||||
|
||||
def _init_client(self) -> None:
|
||||
"""初始化Gemini客户端"""
|
||||
proxy = self.provider_config.get("proxy", "")
|
||||
http_options = types.HttpOptions(
|
||||
base_url=self.api_base,
|
||||
headers=dict(self.custom_headers),
|
||||
timeout=self.timeout * 1000, # 毫秒
|
||||
)
|
||||
|
||||
@@ -94,6 +120,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
# httpx.AsyncClient 的 timeout 单位为秒(与 HttpOptions 的毫秒不同)
|
||||
async_client_kwargs: dict = {
|
||||
"base_url": self.api_base,
|
||||
"headers": dict(self.custom_headers),
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
if proxy:
|
||||
@@ -112,10 +139,15 @@ class ProviderGoogleGenAI(Provider):
|
||||
self._http_client = httpx.AsyncClient(**async_client_kwargs)
|
||||
http_options.httpx_async_client = self._http_client
|
||||
|
||||
self.client = genai.Client(
|
||||
genai_client = genai.Client(
|
||||
api_key=self.chosen_api_key,
|
||||
http_options=http_options,
|
||||
).aio
|
||||
)
|
||||
self._set_gemini_user_agent(
|
||||
genai_client,
|
||||
self.custom_headers["user-agent"],
|
||||
)
|
||||
self.client = genai_client.aio
|
||||
|
||||
def _init_safety_settings(self) -> None:
|
||||
"""初始化安全设置"""
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from astrbot.core.config.default import ASTRBOT_USER_AGENT
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
from .anthropic_source import ProviderAnthropic
|
||||
|
||||
KIMI_CODE_API_BASE = "https://api.kimi.com/coding"
|
||||
KIMI_CODE_DEFAULT_MODEL = "kimi-for-coding"
|
||||
KIMI_CODE_USER_AGENT = "claude-code/0.1.0"
|
||||
KIMI_CODE_USER_AGENT = ASTRBOT_USER_AGENT
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
|
||||
@@ -34,10 +34,12 @@ from astrbot.core.agent.message import (
|
||||
TextPart,
|
||||
)
|
||||
from astrbot.core.agent.tool import ToolSet
|
||||
from astrbot.core.config.default import ASTRBOT_USER_AGENT
|
||||
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.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.http_headers import apply_default_headers, normalize_headers
|
||||
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 (
|
||||
@@ -68,6 +70,13 @@ class ProviderOpenAIOfficial(Provider):
|
||||
"AVIF": "image/avif",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_custom_headers(provider_config: dict) -> dict[str, str]:
|
||||
return apply_default_headers(
|
||||
normalize_headers(provider_config.get("custom_headers", {})),
|
||||
{"User-Agent": ASTRBOT_USER_AGENT},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _truncate_error_text_candidate(cls, text: str) -> str:
|
||||
if len(text) <= cls._ERROR_TEXT_CANDIDATE_MAX_CHARS:
|
||||
@@ -498,16 +507,10 @@ class ProviderOpenAIOfficial(Provider):
|
||||
self.api_keys: list = super().get_keys()
|
||||
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None
|
||||
self.timeout = provider_config.get("timeout", 120)
|
||||
self.custom_headers = provider_config.get("custom_headers", {})
|
||||
self.custom_headers = self._resolve_custom_headers(provider_config)
|
||||
if isinstance(self.timeout, str):
|
||||
self.timeout = int(self.timeout)
|
||||
|
||||
if not isinstance(self.custom_headers, dict) or not self.custom_headers:
|
||||
self.custom_headers = None
|
||||
else:
|
||||
for key in self.custom_headers:
|
||||
self.custom_headers[key] = str(self.custom_headers[key])
|
||||
|
||||
if "api_version" in provider_config:
|
||||
# Using Azure OpenAI API
|
||||
self.client = AsyncAzureOpenAI(
|
||||
|
||||
@@ -654,12 +654,23 @@ class PluginManager:
|
||||
|
||||
"""
|
||||
prefix = "astrbot.builtin_stars." if is_reserved else "data.plugins."
|
||||
module_prefix = f"{prefix}{plugin_root_dir}"
|
||||
return [
|
||||
key
|
||||
for key in list(sys.modules.keys())
|
||||
if key.startswith(f"{prefix}{plugin_root_dir}")
|
||||
if PluginManager._is_plugin_module_path(key, module_prefix)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _is_plugin_module_path(module_path: str | None, module_prefix: str) -> bool:
|
||||
return bool(
|
||||
module_path
|
||||
and (
|
||||
module_path == module_prefix
|
||||
or module_path.startswith(f"{module_prefix}.")
|
||||
)
|
||||
)
|
||||
|
||||
def _purge_modules(
|
||||
self,
|
||||
module_patterns: list[str] | None = None,
|
||||
@@ -694,33 +705,49 @@ class PluginManager:
|
||||
except KeyError:
|
||||
logger.warning(f"模块 {module_name} 未载入")
|
||||
|
||||
def _cleanup_plugin_state(self, dir_name: str) -> None:
|
||||
plugin_root_name = "data.plugins."
|
||||
def _cleanup_plugin_state(self, dir_name: str, is_reserved: bool = False) -> None:
|
||||
plugin_root_name = "astrbot.builtin_stars." if is_reserved else "data.plugins."
|
||||
module_prefix = f"{plugin_root_name}{dir_name}"
|
||||
|
||||
# 清理 sys.modules
|
||||
for key in list(sys.modules.keys()):
|
||||
if key.startswith(f"{plugin_root_name}{dir_name}"):
|
||||
if self._is_plugin_module_path(key, module_prefix):
|
||||
logger.info(f"清除了插件{dir_name}中的{key}模块")
|
||||
del sys.modules[key]
|
||||
|
||||
possible_paths = [
|
||||
f"{plugin_root_name}{dir_name}.main",
|
||||
f"{plugin_root_name}{dir_name}.{dir_name}",
|
||||
]
|
||||
# Clean plugin metadata registered before a failed load completes.
|
||||
for module_path, metadata in list(star_map.items()):
|
||||
if self._is_plugin_module_path(module_path, module_prefix) or (
|
||||
metadata.root_dir_name == dir_name and metadata.reserved == is_reserved
|
||||
):
|
||||
star_map.pop(module_path, None)
|
||||
if metadata in star_registry:
|
||||
star_registry.remove(metadata)
|
||||
logger.info(f"清理插件元数据: {module_path}")
|
||||
|
||||
for metadata in list(star_registry):
|
||||
if self._is_plugin_module_path(metadata.module_path, module_prefix) or (
|
||||
metadata.root_dir_name == dir_name and metadata.reserved == is_reserved
|
||||
):
|
||||
star_registry.remove(metadata)
|
||||
logger.info(f"清理插件注册项: {metadata.name or dir_name}")
|
||||
|
||||
# 清理 handlers
|
||||
for path in possible_paths:
|
||||
handlers = star_handlers_registry.get_handlers_by_module_name(path)
|
||||
for handler in handlers:
|
||||
for handler in list(star_handlers_registry):
|
||||
if self._is_plugin_module_path(handler.handler_module_path, module_prefix):
|
||||
star_handlers_registry.remove(handler)
|
||||
logger.info(f"清理处理器: {handler.handler_name}")
|
||||
|
||||
# 清理工具
|
||||
for tool in list(llm_tools.func_list):
|
||||
if tool.handler_module_path in possible_paths:
|
||||
handler_module_path = getattr(tool, "handler_module_path", None)
|
||||
if self._is_plugin_module_path(handler_module_path, module_prefix):
|
||||
llm_tools.func_list.remove(tool)
|
||||
logger.info(f"清理工具: {tool.name}")
|
||||
|
||||
for adapter_name in unregister_platform_adapters_by_module(module_prefix):
|
||||
logger.info(f"清理平台适配器: {adapter_name}")
|
||||
|
||||
def _build_failed_plugin_record(
|
||||
self,
|
||||
*,
|
||||
@@ -836,7 +863,7 @@ class PluginManager:
|
||||
# 终止插件
|
||||
if not specified_module_path:
|
||||
# 重载所有插件
|
||||
for smd in star_registry:
|
||||
for smd in list(star_registry):
|
||||
try:
|
||||
await self._terminate_plugin(smd)
|
||||
except Exception as e:
|
||||
@@ -948,11 +975,7 @@ class PluginManager:
|
||||
error_trace=error_trace,
|
||||
)
|
||||
)
|
||||
if path in star_map:
|
||||
logger.info("失败插件依旧在插件列表中,正在清理...")
|
||||
metadata = star_map.pop(path)
|
||||
if metadata in star_registry:
|
||||
star_registry.remove(metadata)
|
||||
self._cleanup_plugin_state(root_dir_name, reserved)
|
||||
continue
|
||||
|
||||
# 检查 _conf_schema.json
|
||||
@@ -1097,21 +1120,29 @@ class PluginManager:
|
||||
f"插件 {path} 未通过装饰器注册。尝试通过旧版本方式载入。",
|
||||
)
|
||||
classes = self._get_classes(module)
|
||||
if not classes:
|
||||
raise Exception(
|
||||
f"插件 {root_dir_name} 未通过 Star 注册,也没有找到旧版插件类。"
|
||||
"请确认插件主类继承 astrbot.api.star.Star,或类名以 Plugin 结尾 / 命名为 Main。",
|
||||
)
|
||||
|
||||
plugin_cls = getattr(module, classes[0])
|
||||
obj = None
|
||||
|
||||
if path not in inactivated_plugins:
|
||||
# 只有没有禁用插件时才实例化插件类
|
||||
if plugin_config:
|
||||
try:
|
||||
obj = getattr(module, classes[0])(
|
||||
obj = plugin_cls(
|
||||
context=self.context,
|
||||
config=plugin_config,
|
||||
) # 实例化插件类
|
||||
except TypeError as _:
|
||||
obj = getattr(module, classes[0])(
|
||||
obj = plugin_cls(
|
||||
context=self.context,
|
||||
) # 实例化插件类
|
||||
else:
|
||||
obj = getattr(module, classes[0])(
|
||||
obj = plugin_cls(
|
||||
context=self.context,
|
||||
) # 实例化插件类
|
||||
|
||||
@@ -1139,7 +1170,7 @@ class PluginManager:
|
||||
metadata.module = module
|
||||
metadata.root_dir_name = root_dir_name
|
||||
metadata.reserved = reserved
|
||||
metadata.star_cls_type = obj.__class__
|
||||
metadata.star_cls_type = plugin_cls
|
||||
metadata.module_path = path
|
||||
star_map[path] = metadata
|
||||
star_registry.append(metadata)
|
||||
@@ -1226,12 +1257,7 @@ class PluginManager:
|
||||
error_trace=errors,
|
||||
)
|
||||
)
|
||||
# 记录注册失败的插件名称,以便后续重载插件
|
||||
if path in star_map:
|
||||
logger.info("失败插件依旧在插件列表中,正在清理...")
|
||||
metadata = star_map.pop(path)
|
||||
if metadata in star_registry:
|
||||
star_registry.remove(metadata)
|
||||
self._cleanup_plugin_state(root_dir_name, reserved)
|
||||
|
||||
# 清除 pip.main 导致的多余的 logging handlers
|
||||
for handler in logging.root.handlers[:]:
|
||||
|
||||
31
astrbot/core/utils/http_headers.py
Normal file
31
astrbot/core/utils/http_headers.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
def normalize_headers(headers: object) -> dict[str, str]:
|
||||
if not isinstance(headers, dict):
|
||||
return {}
|
||||
return {str(key): str(value) for key, value in headers.items()}
|
||||
|
||||
|
||||
def apply_default_headers(
|
||||
headers: dict[str, str],
|
||||
default_headers: Mapping[str, str],
|
||||
) -> dict[str, str]:
|
||||
merged_headers = dict(headers)
|
||||
for default_name, default_value in default_headers.items():
|
||||
existing_name = next(
|
||||
(
|
||||
header_name
|
||||
for header_name in merged_headers
|
||||
if header_name.lower() == default_name.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing_name is None:
|
||||
merged_headers[default_name] = default_value
|
||||
continue
|
||||
if merged_headers[existing_name].strip():
|
||||
continue
|
||||
merged_headers.pop(existing_name)
|
||||
merged_headers[default_name] = default_value
|
||||
return merged_headers
|
||||
110
changelogs/v4.25.2.md
Normal file
110
changelogs/v4.25.2.md
Normal file
@@ -0,0 +1,110 @@
|
||||
- [更新日志(简体中文)](#chinese)
|
||||
- [Changelog(English)](#english)
|
||||
|
||||
<a id="chinese"></a>
|
||||
|
||||
## What's Changed
|
||||
|
||||
### 新增与优化
|
||||
|
||||
- 为知识库加入 Markdown 感知分块器,提升结构化文档导入后的分块质量。([#8151](https://github.com/AstrBotDevs/AstrBot/pull/8151))
|
||||
- 新增长期记忆上下文压缩重构,提高记忆整理与续接体验。([#8226](https://github.com/AstrBotDevs/AstrBot/pull/8226))
|
||||
- 为 ChatUI 添加指令候选能力,支持自定义唤醒词和悬浮提示。([#8279](https://github.com/AstrBotDevs/AstrBot/pull/8279), [#8353](https://github.com/AstrBotDevs/AstrBot/pull/8353))
|
||||
- 为已配置模型增加能力图标,便于识别模型支持的能力。([#8405](https://github.com/AstrBotDevs/AstrBot/pull/8405))
|
||||
- 新增小米和 Xiaomi Token Plan LLM 提供商。([#7744](https://github.com/AstrBotDevs/AstrBot/pull/7744))
|
||||
- 优化 QQ 官方适配器的消息链媒体拆分和发送逻辑,并透传 QQ webhook 扩展字段。([#8376](https://github.com/AstrBotDevs/AstrBot/pull/8376), [#6274](https://github.com/AstrBotDevs/AstrBot/pull/6274))
|
||||
- 为首次通知增加 EULA 提示。([#7955](https://github.com/AstrBotDevs/AstrBot/pull/7955))
|
||||
- WebUI 插件卡片新增直接访问按钮,并改善内嵌页面高度。([#8369](https://github.com/AstrBotDevs/AstrBot/pull/8369))
|
||||
- 启用平滑 Markdown 流式渲染。([#8371](https://github.com/AstrBotDevs/AstrBot/pull/8371))
|
||||
- Dashboard 开发和构建流程自动生成 MDI 图标字体子集。([#8264](https://github.com/AstrBotDevs/AstrBot/pull/8264))
|
||||
- 新增修改 AstrBot Dashboard 密码的命令。([#8272](https://github.com/AstrBotDevs/AstrBot/pull/8272))
|
||||
- 优化默认 LLM 压缩提示词,使上下文续接更自然。([#8424](https://github.com/AstrBotDevs/AstrBot/pull/8424))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 ChatUI 语音输入录制与上传失效的问题。([#8440](https://github.com/AstrBotDevs/AstrBot/pull/8440))
|
||||
- 修复 ChatUI 思考过程标题摘要展示,并让右侧图标保持垂直居中。
|
||||
- 修复特定情况下单个插件加载失败会影响其他插件重载的问题,增强失败插件状态清理。([#8441](https://github.com/AstrBotDevs/AstrBot/pull/8441))
|
||||
- 修复空 LLM 摘要处理问题。([#8195](https://github.com/AstrBotDevs/AstrBot/pull/8195))
|
||||
- 修复 RST 和 AsciiDoc 知识库上传支持。([#8255](https://github.com/AstrBotDevs/AstrBot/pull/8255))
|
||||
- 修复 T2I Shiki 相关问题。([#8013](https://github.com/AstrBotDevs/AstrBot/pull/8013))
|
||||
- 修复流式响应 `delta=None` 分块导致 SDK `to_dict()` 报错的问题。([#8244](https://github.com/AstrBotDevs/AstrBot/pull/8244))
|
||||
- 修复 skills_like 工具重新查询时未保留原始 `completion_text` 的问题。([#8240](https://github.com/AstrBotDevs/AstrBot/pull/8240))
|
||||
- 修复 OpenAI 流式响应末尾 usage 信息丢失的问题。([#8306](https://github.com/AstrBotDevs/AstrBot/pull/8306))
|
||||
- 修复 macOS 上 SQLAlchemy 兼容性问题。([#7724](https://github.com/AstrBotDevs/AstrBot/pull/7724))
|
||||
- 修复 WebUI 移动端 Provider Source 删除能力。([#8321](https://github.com/AstrBotDevs/AstrBot/pull/8321))
|
||||
- 修复 Mimo voice design 模型请求包含无效 voice 参数的问题。([#8326](https://github.com/AstrBotDevs/AstrBot/pull/8326))
|
||||
- 修复 Mimo reasoning content 相关问题。([#8327](https://github.com/AstrBotDevs/AstrBot/pull/8327))
|
||||
- 修复 Anthropic API `tool_choice` schema 转换问题。([#8328](https://github.com/AstrBotDevs/AstrBot/pull/8328))
|
||||
- 修复插件 metadata repo 字段类型保护。([#8207](https://github.com/AstrBotDevs/AstrBot/pull/8207))
|
||||
- 修复核心图片请求路由到视觉 fallback 的问题。([#8089](https://github.com/AstrBotDevs/AstrBot/pull/8089))
|
||||
- 修复文件组件中的文件名清理。([#8318](https://github.com/AstrBotDevs/AstrBot/pull/8318))
|
||||
- 修复 Dashboard 子命令数量标签和插件重复展示问题。([#8388](https://github.com/AstrBotDevs/AstrBot/pull/8388), [#8389](https://github.com/AstrBotDevs/AstrBot/pull/8389))
|
||||
- 修复无专用图片描述 provider 时,多模态主 provider 重复处理引用图片的问题。([#8401](https://github.com/AstrBotDevs/AstrBot/pull/8401))
|
||||
- 修复分段回复中多余空行问题。([#8304](https://github.com/AstrBotDevs/AstrBot/pull/8304))
|
||||
- 修复插件市场插件名称与本地插件名称不一致的问题。([#8276](https://github.com/AstrBotDevs/AstrBot/pull/8276))
|
||||
- 修复 Dashboard 列表配置项无法输入空格的问题。([#8403](https://github.com/AstrBotDevs/AstrBot/pull/8403))
|
||||
- 修复 stale command hints 相关问题。([#8245](https://github.com/AstrBotDevs/AstrBot/pull/8245))
|
||||
- 改善 `SendMessageToUserTool` 的描述。([commit](https://github.com/AstrBotDevs/AstrBot/commit/49036f8f9))
|
||||
|
||||
### 文档与工程
|
||||
|
||||
- 更新 release 版本发布说明。([commit](https://github.com/AstrBotDevs/AstrBot/commit/dceacd5a8))
|
||||
- 更新 FAQ 中 Dashboard 下载链接、强制刷新说明和删除数量说明。([#8359](https://github.com/AstrBotDevs/AstrBot/pull/8359), [#8235](https://github.com/AstrBotDevs/AstrBot/pull/8235), [commit](https://github.com/AstrBotDevs/AstrBot/commit/61b6813dc))
|
||||
- 修正文档中的 QQ 官方 WebSocket 机器人中文说明 typo。([#8351](https://github.com/AstrBotDevs/AstrBot/pull/8351))
|
||||
- 更新 GitHub Actions 依赖。([#8233](https://github.com/AstrBotDevs/AstrBot/pull/8233), [#8335](https://github.com/AstrBotDevs/AstrBot/pull/8335))
|
||||
- 移除无用日志。([commit](https://github.com/AstrBotDevs/AstrBot/commit/9fc03fa95))
|
||||
|
||||
<a id="english"></a>
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### Features and Improvements
|
||||
|
||||
- Added a Markdown-aware knowledge base chunker for better structured document ingestion. ([#8151](https://github.com/AstrBotDevs/AstrBot/pull/8151))
|
||||
- Redesigned long-term memory with context compaction for better memory continuity. ([#8226](https://github.com/AstrBotDevs/AstrBot/pull/8226))
|
||||
- Added ChatUI command suggestions, including custom wake-up words and hover information. ([#8279](https://github.com/AstrBotDevs/AstrBot/pull/8279), [#8353](https://github.com/AstrBotDevs/AstrBot/pull/8353))
|
||||
- Added capability icons for configured models. ([#8405](https://github.com/AstrBotDevs/AstrBot/pull/8405))
|
||||
- Added Xiaomi and Xiaomi Token Plan LLM providers. ([#7744](https://github.com/AstrBotDevs/AstrBot/pull/7744))
|
||||
- Improved QQ Official media-chain splitting and message sending, and passed through QQ webhook extra fields. ([#8376](https://github.com/AstrBotDevs/AstrBot/pull/8376), [#6274](https://github.com/AstrBotDevs/AstrBot/pull/6274))
|
||||
- Added an EULA hint for the first notification. ([#7955](https://github.com/AstrBotDevs/AstrBot/pull/7955))
|
||||
- Added direct access buttons on WebUI plugin cards and improved embedded page height. ([#8369](https://github.com/AstrBotDevs/AstrBot/pull/8369))
|
||||
- Enabled smooth Markdown streaming. ([#8371](https://github.com/AstrBotDevs/AstrBot/pull/8371))
|
||||
- Automated MDI icon font subsetting during Dashboard dev and build workflows. ([#8264](https://github.com/AstrBotDevs/AstrBot/pull/8264))
|
||||
- Added a command to change the AstrBot Dashboard password. ([#8272](https://github.com/AstrBotDevs/AstrBot/pull/8272))
|
||||
- Improved the default LLM compression prompt for better continuity. ([#8424](https://github.com/AstrBotDevs/AstrBot/pull/8424))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed ChatUI voice recording and upload. ([#8440](https://github.com/AstrBotDevs/AstrBot/pull/8440))
|
||||
- Fixed ChatUI reasoning summary labels and centered the right-side icon vertically.
|
||||
- Fixed plugin reload failures where one plugin load failure could leave stale state and break other plugin reloads. ([#8441](https://github.com/AstrBotDevs/AstrBot/pull/8441))
|
||||
- Fixed empty LLM summaries. ([#8195](https://github.com/AstrBotDevs/AstrBot/pull/8195))
|
||||
- Fixed RST and AsciiDoc knowledge uploads. ([#8255](https://github.com/AstrBotDevs/AstrBot/pull/8255))
|
||||
- Fixed T2I Shiki issues. ([#8013](https://github.com/AstrBotDevs/AstrBot/pull/8013))
|
||||
- Fixed `delta=None` streaming chunks that could trigger SDK `to_dict()` errors. ([#8244](https://github.com/AstrBotDevs/AstrBot/pull/8244))
|
||||
- Preserved original `completion_text` in skills_like tool re-query. ([#8240](https://github.com/AstrBotDevs/AstrBot/pull/8240))
|
||||
- Fixed missing final usage metadata in OpenAI streaming responses. ([#8306](https://github.com/AstrBotDevs/AstrBot/pull/8306))
|
||||
- Fixed SQLAlchemy compatibility issues on macOS. ([#7724](https://github.com/AstrBotDevs/AstrBot/pull/7724))
|
||||
- Restored mobile Provider Source deletion in the WebUI. ([#8321](https://github.com/AstrBotDevs/AstrBot/pull/8321))
|
||||
- Fixed invalid voice parameters in Mimo voice design model requests. ([#8326](https://github.com/AstrBotDevs/AstrBot/pull/8326))
|
||||
- Fixed Mimo reasoning content handling. ([#8327](https://github.com/AstrBotDevs/AstrBot/pull/8327))
|
||||
- Fixed Anthropic API `tool_choice` schema conversion. ([#8328](https://github.com/AstrBotDevs/AstrBot/pull/8328))
|
||||
- Added a type guard for plugin metadata repo values. ([#8207](https://github.com/AstrBotDevs/AstrBot/pull/8207))
|
||||
- Fixed core image request routing to vision fallback. ([#8089](https://github.com/AstrBotDevs/AstrBot/pull/8089))
|
||||
- Sanitized file names in file components. ([#8318](https://github.com/AstrBotDevs/AstrBot/pull/8318))
|
||||
- Fixed Dashboard sub-command count labels and duplicate plugin display. ([#8388](https://github.com/AstrBotDevs/AstrBot/pull/8388), [#8389](https://github.com/AstrBotDevs/AstrBot/pull/8389))
|
||||
- Prevented duplicate processing of quoted images by multimodal main providers when no dedicated image caption provider is configured. ([#8401](https://github.com/AstrBotDevs/AstrBot/pull/8401))
|
||||
- Removed extra blank lines in segmented replies. ([#8304](https://github.com/AstrBotDevs/AstrBot/pull/8304))
|
||||
- Fixed plugin name mismatches between the marketplace and local plugins. ([#8276](https://github.com/AstrBotDevs/AstrBot/pull/8276))
|
||||
- Fixed Dashboard list config items being unable to input spaces. ([#8403](https://github.com/AstrBotDevs/AstrBot/pull/8403))
|
||||
- Fixed stale command hints. ([#8245](https://github.com/AstrBotDevs/AstrBot/pull/8245))
|
||||
- Improved the `SendMessageToUserTool` description. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/49036f8f9))
|
||||
|
||||
### Documentation and Maintenance
|
||||
|
||||
- Updated release version instructions. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/dceacd5a8))
|
||||
- Updated FAQ Dashboard download links, hard refresh instructions, and deletion count wording. ([#8359](https://github.com/AstrBotDevs/AstrBot/pull/8359), [#8235](https://github.com/AstrBotDevs/AstrBot/pull/8235), [commit](https://github.com/AstrBotDevs/AstrBot/commit/61b6813dc))
|
||||
- Fixed a typo in the Chinese QQ Official WebSocket bot setup documentation. ([#8351](https://github.com/AstrBotDevs/AstrBot/pull/8351))
|
||||
- Updated GitHub Actions dependencies. ([#8233](https://github.com/AstrBotDevs/AstrBot/pull/8233), [#8335](https://github.com/AstrBotDevs/AstrBot/pull/8335))
|
||||
- Removed unused logs. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/9fc03fa95))
|
||||
@@ -526,6 +526,7 @@ import {
|
||||
type TransportMode,
|
||||
} from "@/composables/useMessages";
|
||||
import { useMediaHandling } from "@/composables/useMediaHandling";
|
||||
import { useRecording } from "@/composables/useRecording";
|
||||
import { useProjects } from "@/composables/useProjects";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import ProviderChatCompletionPanel from "@/components/provider/ProviderChatCompletionPanel.vue";
|
||||
@@ -633,8 +634,12 @@ const threadSelection = reactive<{
|
||||
selectedText: "",
|
||||
});
|
||||
const enableStreaming = ref(true);
|
||||
const isRecording = ref(false);
|
||||
const sendShortcut = ref<"enter" | "shift_enter">("enter");
|
||||
const {
|
||||
isRecording,
|
||||
startRecording: startRecorder,
|
||||
stopRecording: stopRecorder,
|
||||
} = useRecording();
|
||||
const chatSidebarDrawer = computed({
|
||||
get: () => lgAndUp.value || customizer.chatSidebarOpen,
|
||||
set: (value: boolean) => {
|
||||
@@ -1303,12 +1308,26 @@ function toggleStreaming() {
|
||||
enableStreaming.value = !enableStreaming.value;
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
isRecording.value = true;
|
||||
async function startRecording() {
|
||||
try {
|
||||
await startRecorder();
|
||||
} catch (error) {
|
||||
console.error("Failed to start recording:", error);
|
||||
toast.error(tm("voice.error"));
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
isRecording.value = false;
|
||||
async function stopRecording() {
|
||||
try {
|
||||
const audioFile = await stopRecorder();
|
||||
const uploaded = await processAndUploadFile(audioFile);
|
||||
if (!uploaded) {
|
||||
toast.error(tm("voice.error"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to stop recording:", error);
|
||||
toast.error(tm("voice.error"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessagesScroll() {
|
||||
@@ -1520,7 +1539,12 @@ function toggleTheme() {
|
||||
}
|
||||
|
||||
.session-progress {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
flex-shrink: 0;
|
||||
transition: right 0.16s ease;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
@@ -1544,6 +1568,11 @@ function toggleTheme() {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.session-item:hover .session-progress,
|
||||
.session-item:focus-within .session-progress {
|
||||
right: 62px;
|
||||
}
|
||||
|
||||
.session-action-btn {
|
||||
color: var(--chat-muted);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<transition name="slide-left">
|
||||
<aside v-if="modelValue" class="reasoning-sidebar">
|
||||
<div class="reasoning-sidebar-header">
|
||||
<div class="reasoning-sidebar-title">{{ tm("reasoning.thinking") }}</div>
|
||||
<div class="reasoning-sidebar-title">{{ reasoningTitle }}</div>
|
||||
<v-btn icon="mdi-close" size="small" variant="text" @click="close" />
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
:is-dark="isDark"
|
||||
/>
|
||||
<div v-else class="reasoning-sidebar-empty">
|
||||
{{ tm("reasoning.thinking") }}
|
||||
{{ reasoningTitle }}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -22,11 +22,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MessagePart } from "@/composables/useMessages";
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
reasoningActivityCounts,
|
||||
reasoningActivityTitle,
|
||||
type MessagePart,
|
||||
} from "@/composables/useMessages";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import ReasoningTimeline from "@/components/chat/message_list_comps/ReasoningTimeline.vue";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
parts: MessagePart[];
|
||||
reasoning?: string;
|
||||
@@ -39,6 +44,14 @@ const emit = defineEmits<{
|
||||
|
||||
const { tm } = useModuleI18n("features/chat");
|
||||
|
||||
const activityCounts = computed(() =>
|
||||
reasoningActivityCounts(props.parts, props.reasoning || ""),
|
||||
);
|
||||
|
||||
const reasoningTitle = computed(() =>
|
||||
reasoningActivityTitle(activityCounts.value, tm),
|
||||
);
|
||||
|
||||
function close() {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@click="handlePrimaryAction"
|
||||
>
|
||||
<span class="reasoning-title">
|
||||
{{ tm("reasoning.thinking") }}
|
||||
{{ reasoningTitle }}
|
||||
</span>
|
||||
<v-icon
|
||||
size="22"
|
||||
@@ -40,7 +40,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import type { MessagePart } from "@/composables/useMessages";
|
||||
import {
|
||||
reasoningActivityCounts,
|
||||
reasoningActivityTitle,
|
||||
type MessagePart,
|
||||
} from "@/composables/useMessages";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import ReasoningTimeline from "@/components/chat/message_list_comps/ReasoningTimeline.vue";
|
||||
|
||||
@@ -75,6 +79,14 @@ const renderParts = computed<MessagePart[]>(() => {
|
||||
|
||||
const openInSidebar = computed(() => Boolean(props.openInSidebar));
|
||||
|
||||
const activityCounts = computed(() =>
|
||||
reasoningActivityCounts(renderParts.value, props.reasoning || ""),
|
||||
);
|
||||
|
||||
const reasoningTitle = computed(() =>
|
||||
reasoningActivityTitle(activityCounts.value, tm),
|
||||
);
|
||||
|
||||
const thinkingText = computed(() =>
|
||||
renderParts.value
|
||||
.filter((part) => part.type === "think")
|
||||
@@ -214,14 +226,11 @@ onBeforeUnmount(() => {
|
||||
color: rgba(var(--v-theme-on-surface), 0.88);
|
||||
}
|
||||
|
||||
.reasoning-header--trigger {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.reasoning-icon {
|
||||
color: currentcolor;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.reasoning-title {
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface StagedFileInfo {
|
||||
}
|
||||
|
||||
export function useMediaHandling() {
|
||||
const stagedAudioUrl = ref<string>('');
|
||||
const stagedFiles = ref<StagedFileInfo[]>([]);
|
||||
const mediaCache = ref<Record<string, string>>({});
|
||||
const pendingFileSignatures = new Set<string>();
|
||||
@@ -56,9 +55,9 @@ export function useMediaHandling() {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadStagedFile(file: File) {
|
||||
async function uploadStagedFile(file: File): Promise<StagedFileInfo | undefined> {
|
||||
const signature = await getFileSignature(file);
|
||||
if (isDuplicateFile(signature)) return;
|
||||
if (isDuplicateFile(signature)) return undefined;
|
||||
|
||||
pendingFileSignatures.add(signature);
|
||||
const formData = new FormData();
|
||||
@@ -72,27 +71,30 @@ export function useMediaHandling() {
|
||||
});
|
||||
|
||||
const { attachment_id, filename, type } = response.data.data;
|
||||
stagedFiles.value.push({
|
||||
const stagedFile = {
|
||||
attachment_id,
|
||||
filename,
|
||||
original_name: file.name,
|
||||
url: URL.createObjectURL(file),
|
||||
type,
|
||||
signature
|
||||
});
|
||||
};
|
||||
stagedFiles.value.push(stagedFile);
|
||||
return stagedFile;
|
||||
} catch (err) {
|
||||
console.error('Error uploading file:', err);
|
||||
return undefined;
|
||||
} finally {
|
||||
pendingFileSignatures.delete(signature);
|
||||
}
|
||||
}
|
||||
|
||||
async function processAndUploadImage(file: File) {
|
||||
await uploadStagedFile(file);
|
||||
return uploadStagedFile(file);
|
||||
}
|
||||
|
||||
async function processAndUploadFile(file: File) {
|
||||
await uploadStagedFile(file);
|
||||
return uploadStagedFile(file);
|
||||
}
|
||||
|
||||
async function handlePaste(event: ClipboardEvent) {
|
||||
@@ -128,14 +130,25 @@ export function useMediaHandling() {
|
||||
}
|
||||
|
||||
function removeAudio() {
|
||||
stagedAudioUrl.value = '';
|
||||
for (let i = stagedFiles.value.length - 1; i >= 0; i--) {
|
||||
if (stagedFiles.value[i].type !== 'record') continue;
|
||||
|
||||
const fileToRemove = stagedFiles.value[i];
|
||||
if (fileToRemove.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(fileToRemove.url);
|
||||
}
|
||||
stagedFiles.value.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
// 找到第 index 个非图片类型的文件
|
||||
// Find the requested non-image, non-audio attachment.
|
||||
let fileCount = 0;
|
||||
for (let i = 0; i < stagedFiles.value.length; i++) {
|
||||
if (stagedFiles.value[i].type !== 'image') {
|
||||
if (
|
||||
stagedFiles.value[i].type !== 'image' &&
|
||||
stagedFiles.value[i].type !== 'record'
|
||||
) {
|
||||
if (fileCount === index) {
|
||||
const fileToRemove = stagedFiles.value[i];
|
||||
if (fileToRemove.url.startsWith('blob:')) {
|
||||
@@ -151,7 +164,6 @@ export function useMediaHandling() {
|
||||
|
||||
function clearStaged(options: { revokeUrls?: boolean } = {}) {
|
||||
const { revokeUrls = true } = options;
|
||||
stagedAudioUrl.value = '';
|
||||
if (revokeUrls) {
|
||||
// 清理文件的 blob URLs
|
||||
stagedFiles.value.forEach(file => {
|
||||
@@ -177,9 +189,13 @@ export function useMediaHandling() {
|
||||
stagedFiles.value.filter(f => f.type === 'image').map(f => f.url)
|
||||
);
|
||||
|
||||
const stagedAudioUrl = computed(() =>
|
||||
stagedFiles.value.find(f => f.type === 'record')?.url || ''
|
||||
);
|
||||
|
||||
// 计算属性:获取非图片文件列表
|
||||
const stagedNonImageFiles = computed(() =>
|
||||
stagedFiles.value.filter(f => f.type !== 'image')
|
||||
stagedFiles.value.filter(f => f.type !== 'image' && f.type !== 'record')
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -794,6 +794,44 @@ export function extractReasoningText(
|
||||
return text || legacyReasoning;
|
||||
}
|
||||
|
||||
export function reasoningActivityCounts(
|
||||
parts: MessagePart[] | unknown,
|
||||
legacyReasoning = "",
|
||||
) {
|
||||
const normalizedParts = Array.isArray(parts)
|
||||
? parts
|
||||
: normalizeMessageParts(parts, legacyReasoning);
|
||||
let thinkCount = 0;
|
||||
let toolCount = 0;
|
||||
|
||||
for (const part of normalizedParts) {
|
||||
if (part.type === "think" && String(part.think || "").trim()) {
|
||||
thinkCount += 1;
|
||||
}
|
||||
if (part.type === "tool_call" && Array.isArray(part.tool_calls)) {
|
||||
toolCount += part.tool_calls.length;
|
||||
}
|
||||
}
|
||||
|
||||
return { thinkCount, toolCount };
|
||||
}
|
||||
|
||||
export function reasoningActivityTitle(
|
||||
counts: ReturnType<typeof reasoningActivityCounts>,
|
||||
tm: (key: string, params?: Record<string, string | number>) => string,
|
||||
) {
|
||||
return [
|
||||
counts.thinkCount > 0
|
||||
? tm("reasoning.thinkSummary", { count: counts.thinkCount })
|
||||
: "",
|
||||
counts.toolCount > 0
|
||||
? tm("reasoning.toolSummary", { count: counts.toolCount })
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(tm("reasoning.summarySeparator")) || tm("reasoning.thinking");
|
||||
}
|
||||
|
||||
export function thinkingParts(content: ChatContent): MessagePart[] {
|
||||
const firstThinkingBlock = messageBlocks(content).find(
|
||||
(block) => block.kind === "thinking",
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
export function useRecording() {
|
||||
const isRecording = ref(false);
|
||||
const audioChunks = ref<Blob[]>([]);
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||
|
||||
function getSupportedMimeType(): string {
|
||||
const candidates = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/ogg',
|
||||
'audio/mp4',
|
||||
'audio/wav'
|
||||
];
|
||||
|
||||
if (typeof MediaRecorder === 'undefined' || !MediaRecorder.isTypeSupported) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return candidates.find(type => MediaRecorder.isTypeSupported(type)) || '';
|
||||
}
|
||||
|
||||
function getRecordingMimeType(): string {
|
||||
const chunkType = audioChunks.value.find(chunk => chunk.type)?.type;
|
||||
return chunkType || mediaRecorder.value?.mimeType || 'audio/webm';
|
||||
@@ -23,16 +39,30 @@ export function useRecording() {
|
||||
};
|
||||
const normalizedMimeType = mimeType.toLowerCase();
|
||||
const extension = extensionMap[normalizedMimeType] || normalizedMimeType.split('/')[1]?.split(';')[0] || 'webm';
|
||||
return `${crypto.randomUUID()}.${extension}`;
|
||||
const id = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${id}.${extension}`;
|
||||
}
|
||||
|
||||
async function startRecording(onStart?: (label: string) => void) {
|
||||
try {
|
||||
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
throw new Error('Audio recording is not supported in this browser');
|
||||
}
|
||||
|
||||
mediaRecorder.value?.stream.getTracks().forEach(track => track.stop());
|
||||
audioChunks.value = [];
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorder.value = new MediaRecorder(stream);
|
||||
const mimeType = getSupportedMimeType();
|
||||
mediaRecorder.value = new MediaRecorder(
|
||||
stream,
|
||||
mimeType ? { mimeType } : undefined
|
||||
);
|
||||
|
||||
mediaRecorder.value.ondataavailable = (event) => {
|
||||
audioChunks.value.push(event.data);
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.value.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.value.start();
|
||||
@@ -43,13 +73,16 @@ export function useRecording() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start recording:', error);
|
||||
isRecording.value = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording(onStop?: (label: string) => void): Promise<string> {
|
||||
async function stopRecording(onStop?: (label: string) => void): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!mediaRecorder.value) {
|
||||
reject('No media recorder');
|
||||
const recorder = mediaRecorder.value;
|
||||
if (!recorder) {
|
||||
reject(new Error('No media recorder'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,33 +91,45 @@ export function useRecording() {
|
||||
onStop('聊天输入框');
|
||||
}
|
||||
|
||||
mediaRecorder.value.stop();
|
||||
mediaRecorder.value.onstop = async () => {
|
||||
recorder.onstop = () => {
|
||||
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);
|
||||
recorder.stream.getTracks().forEach(track => track.stop());
|
||||
if (mediaRecorder.value === recorder) {
|
||||
mediaRecorder.value = null;
|
||||
}
|
||||
|
||||
if (!audioBlob.size) {
|
||||
reject(new Error('Recording is empty'));
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = getRecordingFilename(mimeType);
|
||||
const audioFile = new File([audioBlob], filename, {
|
||||
type: mimeType,
|
||||
lastModified: Date.now()
|
||||
});
|
||||
resolve(audioFile);
|
||||
};
|
||||
|
||||
recorder.onerror = (event) => {
|
||||
recorder.stream.getTracks().forEach(track => track.stop());
|
||||
if (mediaRecorder.value === recorder) {
|
||||
mediaRecorder.value = null;
|
||||
}
|
||||
reject(event);
|
||||
};
|
||||
|
||||
try {
|
||||
recorder.stop();
|
||||
} catch (error) {
|
||||
recorder.stream.getTracks().forEach(track => track.stop());
|
||||
if (mediaRecorder.value === recorder) {
|
||||
mediaRecorder.value = null;
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,9 @@
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "Thinking Process",
|
||||
"summarySeparator": ", ",
|
||||
"thinkSummary": "Thought {count} times",
|
||||
"toolSummary": "used {count} tools",
|
||||
"think": "Thinking",
|
||||
"toolUsed": "Using Tool"
|
||||
},
|
||||
|
||||
@@ -114,6 +114,9 @@
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "Рассуждение",
|
||||
"summarySeparator": ", ",
|
||||
"thinkSummary": "Размышлений: {count}",
|
||||
"toolSummary": "использований инструментов: {count}",
|
||||
"think": "Размышление",
|
||||
"toolUsed": "Использование инструмента"
|
||||
},
|
||||
|
||||
@@ -114,6 +114,9 @@
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "思考过程",
|
||||
"summarySeparator": ",",
|
||||
"thinkSummary": "思考了 {count} 次",
|
||||
"toolSummary": "使用了 {count} 次工具",
|
||||
"think": "思考",
|
||||
"toolUsed": "使用工具"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.25.1"
|
||||
version = "4.25.2"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
|
||||
import astrbot.core.provider.sources.anthropic_source as anthropic_source
|
||||
import astrbot.core.provider.sources.kimi_code_source as kimi_code_source
|
||||
from astrbot.core.config.default import ASTRBOT_USER_AGENT
|
||||
from astrbot.core.exceptions import EmptyModelOutputError
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
|
||||
@@ -16,6 +17,25 @@ class _FakeAsyncAnthropic:
|
||||
return None
|
||||
|
||||
|
||||
def test_anthropic_provider_uses_astrbot_default_user_agent(monkeypatch):
|
||||
monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic)
|
||||
|
||||
provider = anthropic_source.ProviderAnthropic(
|
||||
provider_config={
|
||||
"id": "anthropic-test",
|
||||
"type": "anthropic_chat_completion",
|
||||
"model": "claude-test",
|
||||
"key": ["test-key"],
|
||||
},
|
||||
provider_settings={},
|
||||
)
|
||||
|
||||
assert provider.custom_headers == {"User-Agent": ASTRBOT_USER_AGENT}
|
||||
assert provider.client.kwargs["default_headers"] == {
|
||||
"User-Agent": ASTRBOT_USER_AGENT,
|
||||
}
|
||||
|
||||
|
||||
def test_anthropic_provider_passes_custom_headers_via_default_headers(monkeypatch):
|
||||
monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic)
|
||||
|
||||
|
||||
@@ -1,10 +1,48 @@
|
||||
import pytest
|
||||
|
||||
from astrbot.core.config.default import ASTRBOT_USER_AGENT
|
||||
from astrbot.core.exceptions import EmptyModelOutputError
|
||||
import astrbot.core.provider.sources.gemini_source as gemini_source
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
from astrbot.core.provider.sources.gemini_source import ProviderGoogleGenAI
|
||||
|
||||
|
||||
class _FakeGenAIClient:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self._api_client = type(
|
||||
"FakeAPIClient",
|
||||
(),
|
||||
{"_http_options": kwargs["http_options"]},
|
||||
)()
|
||||
self.aio = type("FakeAioClient", (), {"_api_client": self._api_client})()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gemini_provider_uses_astrbot_default_user_agent(monkeypatch):
|
||||
monkeypatch.setattr(gemini_source.genai, "Client", _FakeGenAIClient)
|
||||
|
||||
provider = ProviderGoogleGenAI(
|
||||
provider_config={
|
||||
"id": "gemini-test",
|
||||
"type": "googlegenai_chat_completion",
|
||||
"model": "gemini-test",
|
||||
"key": ["test-key"],
|
||||
"api_base": "https://generativelanguage.googleapis.com/",
|
||||
},
|
||||
provider_settings={},
|
||||
)
|
||||
|
||||
try:
|
||||
assert provider.custom_headers["user-agent"] == ASTRBOT_USER_AGENT
|
||||
assert provider.client._api_client._http_options.headers["user-agent"] == (
|
||||
ASTRBOT_USER_AGENT
|
||||
)
|
||||
assert provider._http_client.headers["user-agent"] == ASTRBOT_USER_AGENT
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
def test_gemini_empty_output_raises_empty_model_output_error():
|
||||
llm_response = LLMResponse(role="assistant")
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
||||
from PIL import Image as PILImage
|
||||
|
||||
import astrbot.core.provider.sources.openai_source as openai_source_module
|
||||
from astrbot.core.config.default import ASTRBOT_USER_AGENT
|
||||
from astrbot.core.exceptions import EmptyModelOutputError
|
||||
from astrbot.core.provider.sources.groq_source import ProviderGroq
|
||||
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
|
||||
@@ -26,6 +27,17 @@ class _ErrorWithResponse(Exception):
|
||||
self.response = SimpleNamespace(text=response_text)
|
||||
|
||||
|
||||
class _FakeChatCompletions:
|
||||
def create(self):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeAsyncOpenAI:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.chat = SimpleNamespace(completions=_FakeChatCompletions())
|
||||
|
||||
|
||||
def _make_provider(overrides: dict | None = None) -> ProviderOpenAIOfficial:
|
||||
provider_config = {
|
||||
"id": "test-openai",
|
||||
@@ -56,6 +68,39 @@ def _make_groq_provider(overrides: dict | None = None) -> ProviderGroq:
|
||||
)
|
||||
|
||||
|
||||
def test_openai_provider_uses_astrbot_default_user_agent(monkeypatch):
|
||||
monkeypatch.setattr(openai_source_module, "AsyncOpenAI", _FakeAsyncOpenAI)
|
||||
|
||||
provider = _make_provider()
|
||||
|
||||
assert provider.custom_headers == {"User-Agent": ASTRBOT_USER_AGENT}
|
||||
assert provider.client.kwargs["default_headers"] == {
|
||||
"User-Agent": ASTRBOT_USER_AGENT,
|
||||
}
|
||||
|
||||
|
||||
def test_openai_provider_preserves_custom_user_agent(monkeypatch):
|
||||
monkeypatch.setattr(openai_source_module, "AsyncOpenAI", _FakeAsyncOpenAI)
|
||||
|
||||
provider = _make_provider(
|
||||
{
|
||||
"custom_headers": {
|
||||
"User-Agent": "custom-agent/1.0",
|
||||
"X-Test-Header": 123,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert provider.custom_headers == {
|
||||
"User-Agent": "custom-agent/1.0",
|
||||
"X-Test-Header": "123",
|
||||
}
|
||||
assert provider.client.kwargs["default_headers"] == {
|
||||
"User-Agent": "custom-agent/1.0",
|
||||
"X-Test-Header": "123",
|
||||
}
|
||||
|
||||
|
||||
def test_create_http_client_uses_openai_httpx_module(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
|
||||
@@ -28,9 +28,7 @@ class MockStar:
|
||||
self.info = {"repo": TEST_PLUGIN_REPO, "readme": ""}
|
||||
|
||||
|
||||
def _write_local_test_plugin(
|
||||
plugin_path: Path, repo_url: str, version: str = "1.0.0"
|
||||
):
|
||||
def _write_local_test_plugin(plugin_path: Path, repo_url: str, version: str = "1.0.0"):
|
||||
"""Creates a minimal valid plugin structure."""
|
||||
plugin_path.mkdir(parents=True, exist_ok=True)
|
||||
metadata = {
|
||||
@@ -148,11 +146,22 @@ def _clear_module_cache():
|
||||
"""Clear test-specific modules from sys.modules to allow reloading."""
|
||||
import sys
|
||||
|
||||
to_del = [m for m in sys.modules if m.startswith("data.plugins.helloworld")]
|
||||
to_del = [
|
||||
m
|
||||
for m in sys.modules
|
||||
if m.startswith("data.plugins.helloworld")
|
||||
or m.startswith("data.plugins.broken_plugin")
|
||||
]
|
||||
for m in to_del:
|
||||
del sys.modules[m]
|
||||
|
||||
|
||||
def _clear_star_runtime_state():
|
||||
star_manager_module.star_map.clear()
|
||||
star_manager_module.star_registry.clear()
|
||||
star_manager_module.star_handlers_registry.clear()
|
||||
|
||||
|
||||
def _build_load_mock(events):
|
||||
async def mock_load(specified_dir_name=None, ignore_version_check=False):
|
||||
del ignore_version_check
|
||||
@@ -467,6 +476,107 @@ async def test_reload_failed_plugin_dependency_install_flow(
|
||||
assert events[1] == ("load", TEST_PLUGIN_DIR)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_all_unbinds_every_registered_plugin(
|
||||
plugin_manager_pm: PluginManager, monkeypatch
|
||||
):
|
||||
_clear_star_runtime_state()
|
||||
plugin_names = ["plugin_one", "plugin_two", "plugin_three"]
|
||||
for plugin_name in plugin_names:
|
||||
module_path = f"data.plugins.{plugin_name}.main"
|
||||
metadata = star_manager_module.StarMetadata(
|
||||
name=plugin_name,
|
||||
root_dir_name=plugin_name,
|
||||
module_path=module_path,
|
||||
)
|
||||
star_manager_module.star_map[module_path] = metadata
|
||||
star_manager_module.star_registry.append(metadata)
|
||||
|
||||
terminated = []
|
||||
unbound = []
|
||||
|
||||
async def mock_terminate(plugin):
|
||||
terminated.append(plugin.name)
|
||||
|
||||
async def mock_unbind(plugin_name, plugin_module_path):
|
||||
unbound.append(plugin_name)
|
||||
star_manager_module.star_map.pop(plugin_module_path, None)
|
||||
for index, metadata in enumerate(star_manager_module.star_registry):
|
||||
if metadata.name == plugin_name:
|
||||
del star_manager_module.star_registry[index]
|
||||
break
|
||||
|
||||
async def mock_load(
|
||||
specified_module_path=None,
|
||||
specified_dir_name=None,
|
||||
ignore_version_check=False,
|
||||
):
|
||||
del specified_module_path, specified_dir_name, ignore_version_check
|
||||
return True, None
|
||||
|
||||
monkeypatch.setattr(plugin_manager_pm, "_terminate_plugin", mock_terminate)
|
||||
monkeypatch.setattr(plugin_manager_pm, "_unbind_plugin", mock_unbind)
|
||||
monkeypatch.setattr(plugin_manager_pm, "load", mock_load)
|
||||
|
||||
try:
|
||||
await plugin_manager_pm.reload()
|
||||
finally:
|
||||
_clear_star_runtime_state()
|
||||
|
||||
assert terminated == plugin_names
|
||||
assert unbound == plugin_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_reports_unregistered_plugin_without_index_error(
|
||||
plugin_manager_pm: PluginManager, monkeypatch
|
||||
):
|
||||
_clear_star_runtime_state()
|
||||
plugin_root = Path(plugin_manager_pm.plugin_store_path).parents[1]
|
||||
plugin_name = "broken_plugin"
|
||||
plugin_path = Path(plugin_manager_pm.plugin_store_path) / plugin_name
|
||||
plugin_path.mkdir(parents=True)
|
||||
(plugin_path / "metadata.yaml").write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"name": plugin_name,
|
||||
"author": "AstrBot Team",
|
||||
"desc": "Broken test plugin",
|
||||
"version": "1.0.0",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(plugin_path / "main.py").write_text("VALUE = 1\n", encoding="utf-8")
|
||||
|
||||
async def mock_global_get(key, default=None):
|
||||
del key
|
||||
return default
|
||||
|
||||
async def mock_sync_command_configs():
|
||||
return None
|
||||
|
||||
monkeypatch.syspath_prepend(str(plugin_root))
|
||||
monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get)
|
||||
monkeypatch.setattr(
|
||||
star_manager_module,
|
||||
"sync_command_configs",
|
||||
mock_sync_command_configs,
|
||||
)
|
||||
|
||||
try:
|
||||
success, error = await plugin_manager_pm.load(specified_dir_name=plugin_name)
|
||||
finally:
|
||||
_clear_star_runtime_state()
|
||||
_clear_module_cache()
|
||||
|
||||
assert success is False
|
||||
assert error is not None
|
||||
assert "未通过 Star 注册" in error
|
||||
assert "list index out of range" not in error
|
||||
assert plugin_name in plugin_manager_pm.failed_plugin_dict
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_plugin_requirements_reraises_cancelled_error(
|
||||
plugin_manager_pm: PluginManager, local_updator: Path, monkeypatch
|
||||
|
||||
Reference in New Issue
Block a user