Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode

This commit is contained in:
Soulter
2025-10-29 15:56:54 +08:00
184 changed files with 15953 additions and 2925 deletions

View File

@@ -6,26 +6,7 @@ from astrbot.core.star.star import star_map
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionTypeFilter
from enum import Enum
class RstScene(Enum):
GROUP_UNIQUE_ON = ("group_unique_on", "群聊+会话隔离开启")
GROUP_UNIQUE_OFF = ("group_unique_off", "群聊+会话隔离关闭")
PRIVATE = ("private", "私聊")
@property
def key(self) -> str:
return self.value[0]
@property
def name(self) -> str:
return self.value[1]
@classmethod
def from_index(cls, index: int) -> "RstScene":
mapping = {1: cls.GROUP_UNIQUE_ON, 2: cls.GROUP_UNIQUE_OFF, 3: cls.PRIVATE}
return mapping[index]
from .utils.rst_scene import RstScene
class AlterCmdCommands(CommandParserMixin):
@@ -58,8 +39,9 @@ class AlterCmdCommands(CommandParserMixin):
)
return
cmd_name = " ".join(token.tokens[1:-1])
cmd_type = token.get(-1)
# 兼容 reset scene 的专门配置
cmd_name = token.get(1)
cmd_type = token.get(2)
if cmd_name == "reset" and cmd_type == "config":
from astrbot.api import sp
@@ -123,6 +105,8 @@ class AlterCmdCommands(CommandParserMixin):
return
# 查找指令
cmd_name = " ".join(token.tokens[1:-1])
cmd_type = token.get(-1)
found_command = None
cmd_group = False
for handler in star_handlers_registry:

View File

@@ -6,40 +6,27 @@ from astrbot.core.platform.message_type import MessageType
from astrbot.core.provider.sources.dify_source import ProviderDify
from astrbot.core.provider.sources.coze_source import ProviderCoze
from astrbot.api import sp, logger
from ..long_term_memory import LongTermMemory
from .utils.rst_scene import RstScene
from typing import Union
from enum import Enum
class RstScene(Enum):
GROUP_UNIQUE_ON = ("group_unique_on", "群聊+会话隔离开启")
GROUP_UNIQUE_OFF = ("group_unique_off", "群聊+会话隔离关闭")
PRIVATE = ("private", "私聊")
@property
def key(self) -> str:
return self.value[0]
@property
def name(self) -> str:
return self.value[1]
@classmethod
def from_index(cls, index: int) -> "RstScene":
mapping = {1: cls.GROUP_UNIQUE_ON, 2: cls.GROUP_UNIQUE_OFF, 3: cls.PRIVATE}
return mapping[index]
@classmethod
def get_scene(cls, is_group: bool, is_unique_session: bool) -> "RstScene":
if is_group:
return cls.GROUP_UNIQUE_ON if is_unique_session else cls.GROUP_UNIQUE_OFF
return cls.PRIVATE
class ConversationCommands:
def __init__(self, context: star.Context, ltm=None):
def __init__(self, context: star.Context, ltm: LongTermMemory | None = None):
self.context = context
self.ltm = ltm
async def _get_current_persona_id(self, session_id):
curr = await self.context.conversation_manager.get_curr_conversation_id(
session_id
)
if not curr:
return None
conv = await self.context.conversation_manager.get_conversation(
session_id, curr
)
return conv.persona_id
def ltm_enabled(self, event: AstrMessageEvent):
if not self.ltm:
return False
@@ -254,8 +241,9 @@ class ConversationCommands:
)
return
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
cid = await self.context.conversation_manager.new_conversation(
message.unified_msg_origin, message.get_platform_id()
message.unified_msg_origin, message.get_platform_id(), persona_id=cpersona
)
# 长期记忆
@@ -289,8 +277,10 @@ class ConversationCommands:
session_id=sid,
)
)
cpersona = await self._get_current_persona_id(session)
cid = await self.context.conversation_manager.new_conversation(
session, message.get_platform_id()
session, message.get_platform_id(), persona_id=cpersona
)
message.set_result(
MessageEventResult().message(
@@ -433,8 +423,9 @@ class ConversationCommands:
await self.context.conversation_manager.delete_conversation(
message.unified_msg_origin, session_curr_cid
)
message.set_result(
MessageEventResult().message(
"删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
)
)
ret = "删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
if self.ltm and self.ltm_enabled(message):
cnt = await self.ltm.remove_session(event=message)
ret += f"\n聊天增强: 已清除 {cnt} 条聊天记录。"
message.set_result(MessageEventResult().message(ret))

View File

@@ -129,7 +129,7 @@ class ProviderCommands:
)
return
i = 1
ret = "下面列出了此服务提供商可用模型:"
ret = "下面列出了此模型提供商可用模型:"
for model in models:
ret += f"\n{i}. {model}"
i += 1
@@ -159,7 +159,11 @@ class ProviderCommands:
message.set_result(
MessageEventResult().message("切换模型未知错误: " + str(e))
)
message.set_result(MessageEventResult().message("切换模型成功。"))
message.set_result(
MessageEventResult().message(
f"切换模型成功。当前提供商: [{prov.meta().id}] 当前模型: [{prov.get_model()}]"
)
)
else:
prov.set_model(idx_or_name)
message.set_result(

View File

@@ -11,19 +11,26 @@ class SIDCommand:
self.context = context
async def sid(self, event: AstrMessageEvent):
"""获取会话 ID 和 管理员 ID"""
"""获取消息来源信息"""
sid = event.unified_msg_origin
user_id = str(event.get_sender_id())
ret = f"""SID: {sid} 此 ID 可用于设置会话白名单。
/wl <SID> 添加白名单, /dwl <SID> 删除白名单。
UID: {user_id} 此 ID 可用于设置管理员。
/op <UID> 授权管理员, /deop <UID> 取消管理员。"""
umo_platform = event.session.platform_id
umo_msg_type = event.session.message_type.value
umo_session_id = event.session.session_id
ret = (
f"UMO: 「{sid}」 此值可用于设置白名单。\n"
f"UID: 「{user_id}」 此值可用于设置管理员。\n"
f"消息会话来源信息:\n"
f" 机器人 ID: 「{umo_platform}\n"
f" 消息类型: 「{umo_msg_type}\n"
f" 会话 ID: 「{umo_session_id}\n"
f"消息来源可用于配置机器人的配置文件路由。"
)
if (
self.context.get_config()["platform_settings"]["unique_session"]
and event.get_group_id()
):
ret += f"\n\n当前处于独立会话模式, 此群 ID: {event.get_group_id()}, 也可将此 ID 加入白名单来放行整个群聊。"
ret += f"\n\n当前处于独立会话模式, 此群 ID: {event.get_group_id()}, 也可将此 ID 加入白名单来放行整个群聊。"
event.set_result(MessageEventResult().message(ret).use_t2i(False))

View File

@@ -0,0 +1,26 @@
from enum import Enum
class RstScene(Enum):
GROUP_UNIQUE_ON = ("group_unique_on", "群聊+会话隔离开启")
GROUP_UNIQUE_OFF = ("group_unique_off", "群聊+会话隔离关闭")
PRIVATE = ("private", "私聊")
@property
def key(self) -> str:
return self.value[0]
@property
def name(self) -> str:
return self.value[1]
@classmethod
def from_index(cls, index: int) -> "RstScene":
mapping = {1: cls.GROUP_UNIQUE_ON, 2: cls.GROUP_UNIQUE_OFF, 3: cls.PRIVATE}
return mapping[index]
@classmethod
def get_scene(cls, is_group: bool, is_unique_session: bool) -> "RstScene":
if is_group:
return cls.GROUP_UNIQUE_ON if is_unique_session else cls.GROUP_UNIQUE_OFF
return cls.PRIVATE

View File

@@ -4,7 +4,7 @@ import random
import astrbot.api.star as star
from astrbot.api.event import AstrMessageEvent
from astrbot.api.platform import MessageType
from astrbot.api.provider import ProviderRequest
from astrbot.api.provider import ProviderRequest, Provider
from astrbot.api.message_components import Plain, Image
from astrbot import logger
from collections import defaultdict
@@ -32,6 +32,7 @@ class LongTermMemory:
image_caption = (
True
if cfg["provider_settings"]["default_image_caption_provider_id"]
and cfg["provider_ltm_settings"]["image_caption"]
else False
)
image_caption_prompt = cfg["provider_settings"]["image_caption_prompt"]
@@ -73,6 +74,8 @@ class LongTermMemory:
provider = self.context.get_provider_by_id(image_caption_provider_id)
if not provider:
raise Exception(f"没有找到 ID 为 {image_caption_provider_id} 的提供商")
if not isinstance(provider, Provider):
raise Exception(f"提供商类型错误({type(provider)}),无法获取图片描述")
response = await provider.text_chat(
prompt=image_caption_prompt,
session_id=uuid.uuid4().hex,
@@ -122,8 +125,11 @@ class LongTermMemory:
elif isinstance(comp, Image):
if cfg["image_caption"]:
try:
url = comp.url if comp.url else comp.file
if not url:
raise Exception("图片 URL 为空")
caption = await self.get_image_caption(
comp.url if comp.url else comp.file,
url,
cfg["image_caption_provider_id"],
cfg["image_caption_prompt"],
)

View File

@@ -41,7 +41,7 @@ class Main(star.Star):
self.tool_c = ToolCommands(self.context)
self.plugin_c = PluginCommands(self.context)
self.admin_c = AdminCommands(self.context)
self.conversation_c = ConversationCommands(self.context)
self.conversation_c = ConversationCommands(self.context, self.ltm)
self.provider_c = ProviderCommands(self.context)
self.persona_c = PersonaCommands(self.context)
self.alter_cmd_c = AlterCmdCommands(self.context)

View File

@@ -1,3 +1,4 @@
import copy
import astrbot.api.star as star
import builtins
import datetime
@@ -41,7 +42,7 @@ class ProcessLLMRequest:
if persona:
if prompt := persona["prompt"]:
req.system_prompt += prompt
if begin_dialogs := persona["_begin_dialogs_processed"]:
if begin_dialogs := copy.deepcopy(persona["_begin_dialogs_processed"]):
req.contexts[:0] = begin_dialogs
# tools select

View File

@@ -1,9 +1,19 @@
import re
import json
import logging
from typing import Any, Tuple
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star
from astrbot.api.provider import LLMResponse
from openai.types.chat.chat_completion import ChatCompletion
try:
# 谨慎引入,避免在未安装 google-genai 的环境下报错
from google.genai.types import GenerateContentResponse
except Exception: # pragma: no cover - 兼容无此依赖的运行环境
GenerateContentResponse = None # type: ignore
class R1Filter(Star):
def __init__(self, context: Context):
@@ -14,7 +24,36 @@ class R1Filter(Star):
cfg = self.context.get_config(umo=event.unified_msg_origin).get(
"provider_settings", {}
)
if cfg.get("display_reasoning_text", False):
show_reasoning = cfg.get("display_reasoning_text", False)
# --- Gemini: 过滤/展示 thought:true 片段 ---
# Gemini 可能在 parts 中注入 {"thought": true, "text": "..."}
# 官方 SDK 默认不会返回此字段。
if GenerateContentResponse is not None and isinstance(
response.raw_completion, GenerateContentResponse
):
thought_text, answer_text = self._extract_gemini_texts(
response.raw_completion
)
if thought_text or answer_text:
# 有明确的思考/正文分离信号,则按配置处理
if show_reasoning:
merged = (
(f"🤔思考:{thought_text}\n\n" if thought_text else "")
+ (answer_text or "")
).strip()
if merged:
response.completion_text = merged
return
else:
# 默认隐藏思考内容,仅保留正文
if answer_text:
response.completion_text = answer_text
return
# --- 非 Gemini 或无明确 thought:true 情况 ---
if show_reasoning:
# 显示推理内容的处理逻辑
if (
response
@@ -60,3 +99,105 @@ class R1Filter(Star):
)
response.completion_text = completion_text
# ------------------------
# helpers
# ------------------------
def _get_part_dict(self, p: Any) -> dict:
"""优先使用 SDK 标准序列化方法获取字典,失败则逐级回退。
顺序: model_dump → model_dump_json → json → to_dict → dict → __dict__。
"""
for getter in ("model_dump", "model_dump_json", "json", "to_dict", "dict"):
fn = getattr(p, getter, None)
if callable(fn):
try:
result = fn()
if isinstance(result, (str, bytes)):
try:
if isinstance(result, bytes):
result = result.decode("utf-8", "ignore")
return json.loads(result) or {}
except json.JSONDecodeError:
continue
if isinstance(result, dict):
return result
except (AttributeError, TypeError):
continue
except Exception as e:
logging.exception(
f"Unexpected error when calling {getter} on {type(p).__name__}: {e}"
)
continue
try:
d = getattr(p, "__dict__", None)
if isinstance(d, dict):
return d
except (AttributeError, TypeError):
pass
except Exception as e:
logging.exception(
f"Unexpected error when accessing __dict__ on {type(p).__name__}: {e}"
)
return {}
def _is_thought_part(self, p: Any) -> bool:
"""判断是否为思考片段。
规则:
1) 直接 thought 属性
2) 字典字段 thought 或 metadata.thought
3) data/raw/extra/_raw 中嵌入的 JSON 串包含 thought: true
"""
try:
if getattr(p, "thought", False):
return True
except Exception:
# best-effort
pass
d = self._get_part_dict(p)
if d.get("thought") is True:
return True
meta = d.get("metadata")
if isinstance(meta, dict) and meta.get("thought") is True:
return True
for k in ("data", "raw", "extra", "_raw"):
v = d.get(k)
if isinstance(v, (str, bytes)):
try:
if isinstance(v, bytes):
v = v.decode("utf-8", "ignore")
parsed = json.loads(v)
if isinstance(parsed, dict) and parsed.get("thought") is True:
return True
except json.JSONDecodeError:
continue
return False
def _extract_gemini_texts(self, resp: Any) -> Tuple[str, str]:
"""从 GenerateContentResponse 中提取 (思考文本, 正文文本)。"""
try:
cand0 = next(iter(getattr(resp, "candidates", []) or []), None)
if not cand0:
return "", ""
content = getattr(cand0, "content", None)
parts = getattr(content, "parts", None) or []
except (AttributeError, TypeError, ValueError):
return "", ""
thought_buf: list[str] = []
answer_buf: list[str] = []
for p in parts:
txt = getattr(p, "text", None)
if txt is None:
continue
txt_str = str(txt).strip()
if not txt_str:
continue
if self._is_thought_part(p):
thought_buf.append(txt_str)
else:
answer_buf.append(txt_str)
return "\n".join(thought_buf).strip(), "\n".join(answer_buf).strip()

View File

@@ -1,30 +0,0 @@
import os
from googlesearch import search
from . import SearchEngine, SearchResult
from typing import List
class Google(SearchEngine):
def __init__(self) -> None:
super().__init__()
self.proxy = os.environ.get("https_proxy")
async def search(self, query: str, num_results: int) -> List[SearchResult]:
results = []
try:
ls = search(
query,
advanced=True,
num_results=num_results,
timeout=3,
proxy=self.proxy,
)
for i in ls:
results.append(
SearchResult(title=i.title, url=i.url, snippet=i.description)
)
except Exception as e:
raise e
return results

View File

@@ -10,7 +10,6 @@ from astrbot.core.provider.func_tool_manager import FunctionToolManager
from .engines import SearchResult
from .engines.bing import Bing
from .engines.sogo import Sogo
from .engines.google import Google
from readability import Document
from bs4 import BeautifulSoup
from .engines import HEADERS, USER_AGENTS
@@ -46,7 +45,7 @@ class Main(star.Star):
self.bing_search = Bing()
self.sogo_search = Sogo()
self.google = Google()
self.baidu_initialized = False
async def _tidy_text(self, text: str) -> str:
"""清理文本,去除空格、换行符等"""
@@ -90,15 +89,9 @@ class Main(star.Star):
) -> list[SearchResult]:
results = []
try:
results = await self.google.search(query, num_results)
results = await self.bing_search.search(query, num_results)
except Exception as e:
logger.error(f"google search error: {e}, try the next one...")
if len(results) == 0:
logger.debug("search google failed")
try:
results = await self.bing_search.search(query, num_results)
except Exception as e:
logger.error(f"bing search error: {e}, try the next one...")
logger.error(f"bing search error: {e}, try the next one...")
if len(results) == 0:
logger.debug("search bing failed")
try:
@@ -220,6 +213,30 @@ class Main(star.Star):
return ret
async def ensure_baidu_ai_search_mcp(self, umo: str | None = None):
if self.baidu_initialized:
return
cfg = self.context.get_config(umo=umo)
key = cfg.get("provider_settings", {}).get(
"websearch_baidu_app_builder_key", ""
)
if not key:
raise ValueError(
"Error: Baidu AI Search API key is not configured in AstrBot."
)
func_tool_mgr = self.context.get_llm_tool_manager()
await func_tool_mgr.enable_mcp_server(
"baidu_ai_search",
config={
"transport": "sse",
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
"headers": {},
"timeout": 30,
},
)
self.baidu_initialized = True
logger.info("Successfully initialized Baidu AI Search MCP server.")
@llm_tool(name="fetch_url")
async def fetch_website_content(self, event: AstrMessageEvent, url: str) -> str:
"""fetch the content of a website with the given web url
@@ -366,6 +383,7 @@ class Main(star.Star):
tool_set.add_tool(fetch_url_t)
tool_set.remove_tool("web_search_tavily")
tool_set.remove_tool("tavily_extract_web_page")
tool_set.remove_tool("AIsearch")
elif provider == "tavily":
web_search_tavily = func_tool_mgr.get_func("web_search_tavily")
tavily_extract_web_page = func_tool_mgr.get_func("tavily_extract_web_page")
@@ -375,3 +393,17 @@ class Main(star.Star):
tool_set.add_tool(tavily_extract_web_page)
tool_set.remove_tool("web_search")
tool_set.remove_tool("fetch_url")
tool_set.remove_tool("AIsearch")
elif provider == "baidu_ai_search":
try:
await self.ensure_baidu_ai_search_mcp(event.unified_msg_origin)
aisearch_tool = func_tool_mgr.get_func("AIsearch")
if not aisearch_tool:
raise ValueError("Cannot get Baidu AI Search MCP tool.")
tool_set.add_tool(aisearch_tool)
tool_set.remove_tool("web_search")
tool_set.remove_tool("fetch_url")
tool_set.remove_tool("web_search_tavily")
tool_set.remove_tool("tavily_extract_web_page")
except Exception as e:
logger.error(f"Cannot Initialize Baidu AI Search MCP Server: {e}")