Compare commits

...

2 Commits

Author SHA1 Message Date
Soulter
31944e5424 feat: observer stage 1 2026-07-06 12:46:07 +08:00
Soulter
31e7af82d7 feat: add UMO config overrides 2026-07-05 14:47:58 +08:00
76 changed files with 5019 additions and 1474 deletions

View File

@@ -1,10 +1,22 @@
import os
import uuid
from contextvars import Token
from typing import TypedDict, TypeVar
from astrbot.core import AstrBotConfig, logger
from astrbot.core.config.astrbot_config import ASTRBOT_CONFIG_PATH
from astrbot.core.config.default import DEFAULT_CONFIG
from astrbot.core.config.overrides import (
CORE_CONFIG_OVERRIDE_KEY,
build_effective_core_config,
build_effective_core_config_sync,
get_current_effective_config,
normalize_config_override_payload,
remove_core_config_override_paths,
reset_current_effective_config,
set_current_effective_config,
update_core_config_override_paths,
)
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.umop_config_router import UmopConfigRouter
from astrbot.core.utils.astrbot_path import get_astrbot_config_path
@@ -31,6 +43,8 @@ DEFAULT_CONFIG_CONF_INFO = ConfInfo(
class AstrBotConfigManager:
"""A class to manage the system configuration of AstrBot, aka ACM"""
core_config_override_key = CORE_CONFIG_OVERRIDE_KEY
def __init__(
self,
default_config: AstrBotConfig,
@@ -121,8 +135,15 @@ class AstrBotConfigManager:
self.sp.put("abconf_mapping", abconf_data, scope="global", scope_id="global")
self.abconf_data = abconf_data
def get_conf(self, umo: str | MessageSession | None) -> AstrBotConfig:
"""获取指定 umo 的配置文件。如果不存在,则 fallback 到默认配置文件。"""
def get_base_conf(self, umo: str | MessageSession | None) -> AstrBotConfig:
"""Get the base config profile for a UMO without applying overrides.
Args:
umo: Unified message origin or message session.
Returns:
Shared base config profile selected for the UMO.
"""
if not umo:
return self.confs["default"]
if isinstance(umo, MessageSession):
@@ -136,6 +157,116 @@ class AstrBotConfigManager:
return conf
def get_conf(self, umo: str | MessageSession | None = None) -> AstrBotConfig:
"""获取指定 umo 的配置文件。如果不存在,则 fallback 到默认配置文件。"""
umo_str = None
if umo:
umo_str = (
f"{umo.platform_id}:{umo.message_type}:{umo.session_id}"
if isinstance(umo, MessageSession)
else str(umo)
)
effective_config = get_current_effective_config(umo_str)
if effective_config:
return effective_config
if not umo:
effective_config = get_current_effective_config()
if effective_config:
return effective_config
return self.confs["default"]
base_config = self.get_base_conf(umo)
return build_effective_core_config_sync(
base_config, self.sp, umo_str or str(umo)
)
def get_current_conf(self, default: AstrBotConfig | None = None) -> AstrBotConfig:
"""Get the active task config or a caller-provided default config.
Args:
default: Config returned when no event-scoped config is active.
Returns:
Active effective config, default config, or the global default profile.
"""
return get_current_effective_config() or default or self.confs["default"]
async def build_effective_conf(
self,
umo: str,
base_config: AstrBotConfig | None = None,
) -> AstrBotConfig:
"""Build an effective config for one UMO.
Args:
umo: Unified message origin.
base_config: Optional already-selected base config profile.
Returns:
Config view with UMO overrides applied.
"""
return await build_effective_core_config(
base_config or self.get_base_conf(umo),
self.sp,
umo,
)
def activate_effective_conf(
self,
umo: str,
config: AstrBotConfig,
) -> Token:
"""Bind an effective config to the current async task.
Args:
umo: Unified message origin.
config: Effective config for the current event.
Returns:
Context variable token used to reset the binding.
"""
return set_current_effective_config(umo, config)
def reset_effective_conf(self, token: Token) -> None:
"""Reset the current task's effective config binding.
Args:
token: Token returned by activate_effective_conf.
"""
reset_current_effective_config(token)
async def update_conf_overrides(self, umo: str, paths: dict) -> None:
"""Merge config override paths for one UMO.
Args:
umo: Unified message origin.
paths: Dot-separated config path to override value mapping.
"""
await update_core_config_override_paths(self.sp, umo, paths)
@staticmethod
def normalize_conf_override_payload(payload: object) -> dict:
"""Normalize a config override payload into path-value pairs.
Args:
payload: Raw preference value for a config override.
Returns:
Dot-separated config path to override value mapping.
"""
return normalize_config_override_payload(payload)
async def remove_conf_overrides(self, umo: str, paths: list[str]) -> None:
"""Remove config override paths for one UMO.
Args:
umo: Unified message origin.
paths: Dot-separated config paths to remove.
"""
await remove_core_config_override_paths(self.sp, umo, paths)
@property
def default_conf(self) -> AstrBotConfig:
"""获取默认配置文件"""

View File

@@ -66,6 +66,7 @@ DEFAULT_CONFIG = {
"forward_threshold": 1500,
"enable_id_white_list": True,
"id_whitelist": [],
"id_blacklist": [],
"id_whitelist_log": True,
"wl_ignore_admin_on_group": True,
"wl_ignore_admin_on_friend": True,
@@ -306,6 +307,7 @@ DEFAULT_CONFIG = {
"callback_api_base": "",
"default_kb_collection": "", # 默认知识库名称, 已经过时
"plugin_set": ["*"], # "*" 表示使用所有可用的插件, 空列表表示不使用任何插件
"plugin_disabled_set": [],
"kb_names": [], # 默认知识库名称列表
"kb_fusion_top_k": 20, # 知识库检索融合阶段返回结果数量
"kb_final_top_k": 5, # 知识库检索最终返回结果数量
@@ -1087,6 +1089,11 @@ CONFIG_METADATA_2 = {
"items": {"type": "string"},
"hint": "只处理填写的 ID 发来的消息事件,为空时不启用。可使用 /sid 指令获取在平台上的会话 ID(类似 abc:GroupMessage:123)。管理员可在 WebUI 的平台设置中管理白名单",
},
"id_blacklist": {
"type": "list",
"items": {"type": "string"},
"hint": "拒绝处理填写的 ID 发来的消息事件。命中黑名单时会优先于白名单被拒绝。",
},
"id_whitelist_log": {
"type": "bool",
"hint": "启用后,当一条消息没通过白名单时,会输出 INFO 级别的日志。",
@@ -3924,6 +3931,12 @@ CONFIG_METADATA_3 = {
"items": {"type": "string"},
"hint": "使用 /sid 获取 ID。当白名单列表为空时代表不启用白名单即所有 ID 都在白名单内)。",
},
"platform_settings.id_blacklist": {
"description": "黑名单 ID 列表",
"type": "list",
"items": {"type": "string"},
"hint": "使用 /sid 获取 ID。命中黑名单的会话会被拒绝黑名单优先于白名单。",
},
"platform_settings.id_whitelist_log": {
"description": "输出日志",
"type": "bool",
@@ -4090,6 +4103,12 @@ CONFIG_METADATA_3 = {
"hint": "默认启用全部未被禁用的插件。若插件在插件页面被禁用,则此处的选择不会生效。",
"_special": "select_plugin_set",
},
"plugin_disabled_set": {
"description": "禁用插件",
"type": "bool",
"hint": "明确禁用的插件列表。命中禁用列表的插件不会在会话中触发。",
"_special": "select_plugin_set",
},
},
},
},

View File

@@ -0,0 +1,316 @@
from __future__ import annotations
import copy
from contextvars import ContextVar, Token
from typing import Any, NamedTuple
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.utils.shared_preferences import SharedPreferences
CORE_CONFIG_OVERRIDE_KEY = "config_override:core"
PLUGIN_CONFIG_OVERRIDE_PREFIX = "config_override:plugin:"
CONFIG_OVERRIDE_VERSION = 1
class EffectiveConfigContext(NamedTuple):
umo: str
config: AstrBotConfig
_current_effective_config: ContextVar[EffectiveConfigContext | None] = ContextVar(
"astrbot_current_effective_config",
default=None,
)
class EffectiveAstrBotConfig(dict):
"""Per-event AstrBot config view with no persistent file writes.
Args:
data: Effective config data after applying override paths.
base_config: Config instance used as the immutable base for this view.
umo: Unified message origin this view belongs to.
"""
def __init__(
self,
data: dict[str, Any],
*,
base_config: AstrBotConfig,
umo: str,
) -> None:
super().__init__(data)
object.__setattr__(self, "_base_config", base_config)
object.__setattr__(self, "_umo", umo)
object.__setattr__(self, "config_path", base_config.config_path)
object.__setattr__(self, "default_config", base_config.default_config)
object.__setattr__(self, "schema", base_config.schema)
def save_config(
self,
replace_config: dict | None = None,
*,
indent: int = 2,
) -> None:
"""Keep save-compatible call sites from writing an override view to disk.
Args:
replace_config: Optional replacement data for the in-memory view.
indent: Preserved for API compatibility with AstrBotConfig.
"""
_ = indent
if replace_config:
self.clear()
self.update(copy.deepcopy(replace_config))
def __getattr__(self, item: str) -> Any:
try:
return self[item]
except KeyError:
return None
def __setattr__(self, key: str, value: Any) -> None:
self[key] = value
def get_current_effective_config(umo: str | None = None) -> AstrBotConfig | None:
"""Return the current task's effective config, if it matches the UMO.
Args:
umo: Optional unified message origin to match.
Returns:
The current effective config, or None when outside an event override scope.
"""
current = _current_effective_config.get()
if current is None:
return None
if umo is not None and current.umo != umo:
return None
return current.config
def set_current_effective_config(
umo: str,
config: AstrBotConfig,
) -> Token[EffectiveConfigContext | None]:
"""Bind an effective config to the current async task.
Args:
umo: Unified message origin the config belongs to.
config: Effective config for the current event.
Returns:
Context variable token used to reset the binding.
"""
return _current_effective_config.set(EffectiveConfigContext(umo, config))
def reset_current_effective_config(token: Token[EffectiveConfigContext | None]) -> None:
"""Reset the current task's effective config binding.
Args:
token: Token returned by set_current_effective_config.
"""
_current_effective_config.reset(token)
def normalize_config_override_payload(payload: Any) -> dict[str, Any]:
"""Normalize a stored override payload into path-value pairs.
Args:
payload: Raw preference value for a config override.
Returns:
A mapping from dot-separated config path to override value.
"""
if not isinstance(payload, dict):
return {}
paths = payload.get("paths", payload)
if not isinstance(paths, dict):
return {}
return {
path: copy.deepcopy(value)
for path, value in paths.items()
if isinstance(path, str) and path.strip()
}
async def load_core_config_override_paths(
preferences: SharedPreferences,
umo: str,
) -> dict[str, Any]:
"""Load core config override paths for a UMO.
Args:
preferences: Shared preferences storage.
umo: Unified message origin.
Returns:
Path-value override mapping from the stored core override payload.
"""
override_payload = await preferences.session_get(
umo,
CORE_CONFIG_OVERRIDE_KEY,
default={},
)
return normalize_config_override_payload(override_payload)
def load_core_config_override_paths_sync(
preferences: SharedPreferences,
umo: str,
) -> dict[str, Any]:
"""Synchronously load core config override paths for a UMO.
Args:
preferences: Shared preferences storage.
umo: Unified message origin.
Returns:
Path-value override mapping from the stored core override payload.
"""
override_payload = preferences.get(
CORE_CONFIG_OVERRIDE_KEY,
default={},
scope="umo",
scope_id=umo,
)
return normalize_config_override_payload(override_payload)
async def save_core_config_override_paths(
preferences: SharedPreferences,
umo: str,
paths: dict[str, Any],
) -> None:
"""Persist core config override paths for a UMO.
Args:
preferences: Shared preferences storage.
umo: Unified message origin.
paths: Dot-separated config path to override value mapping.
"""
await preferences.session_put(
umo,
CORE_CONFIG_OVERRIDE_KEY,
{
"version": CONFIG_OVERRIDE_VERSION,
"paths": normalize_config_override_payload(paths),
},
)
async def update_core_config_override_paths(
preferences: SharedPreferences,
umo: str,
paths: dict[str, Any],
) -> None:
"""Merge core config override paths into the stored payload for a UMO.
Args:
preferences: Shared preferences storage.
umo: Unified message origin.
paths: Dot-separated config path to override value mapping.
"""
override_payload = await preferences.session_get(
umo,
CORE_CONFIG_OVERRIDE_KEY,
default={},
)
merged_paths = normalize_config_override_payload(override_payload)
merged_paths.update(normalize_config_override_payload(paths))
await save_core_config_override_paths(preferences, umo, merged_paths)
async def remove_core_config_override_paths(
preferences: SharedPreferences,
umo: str,
paths: list[str],
) -> None:
"""Remove core config override paths from the stored payload for a UMO.
Args:
preferences: Shared preferences storage.
umo: Unified message origin.
paths: Dot-separated config paths to remove.
"""
override_payload = await preferences.session_get(
umo,
CORE_CONFIG_OVERRIDE_KEY,
default={},
)
merged_paths = normalize_config_override_payload(override_payload)
for path in paths:
merged_paths.pop(path, None)
await save_core_config_override_paths(preferences, umo, merged_paths)
def apply_config_override_paths(
config_data: dict[str, Any],
paths: dict[str, Any],
) -> dict[str, Any]:
"""Apply dot-separated override paths to a config dictionary.
Args:
config_data: Config data to mutate.
paths: Dot-separated config path to override value mapping.
Returns:
The mutated config_data object.
"""
for path, value in paths.items():
target = config_data
parts = [part for part in path.split(".") if part]
if not parts:
continue
for part in parts[:-1]:
child = target.get(part)
if not isinstance(child, dict):
child = {}
target[part] = child
target = child
target[parts[-1]] = copy.deepcopy(value)
return config_data
async def build_effective_core_config(
base_config: AstrBotConfig,
preferences: SharedPreferences,
umo: str,
) -> AstrBotConfig:
"""Build a per-event effective config by applying UMO overrides.
Args:
base_config: Shared config profile selected for the event.
preferences: Shared preferences storage.
umo: Unified message origin.
Returns:
A no-write config view for this single event.
"""
config_data = copy.deepcopy(dict(base_config))
override_paths = await load_core_config_override_paths(preferences, umo)
apply_config_override_paths(config_data, override_paths)
return EffectiveAstrBotConfig(config_data, base_config=base_config, umo=umo)
def build_effective_core_config_sync(
base_config: AstrBotConfig,
preferences: SharedPreferences,
umo: str,
) -> AstrBotConfig:
"""Synchronously build a per-UMO effective config.
Args:
base_config: Shared config profile selected for the UMO.
preferences: Shared preferences storage.
umo: Unified message origin.
Returns:
A no-write config view for this UMO.
"""
config_data = copy.deepcopy(dict(base_config))
override_paths = load_core_config_override_paths_sync(preferences, umo)
apply_config_override_paths(config_data, override_paths)
return EffectiveAstrBotConfig(config_data, base_config=base_config, umo=umo)

View File

@@ -432,7 +432,12 @@ class AstrBotCoreLifecycle:
mapping = {}
for conf_id, ab_config in self.astrbot_config_mgr.confs.items():
scheduler = PipelineScheduler(
PipelineContext(ab_config, self.plugin_manager, conf_id),
PipelineContext(
ab_config,
self.plugin_manager,
conf_id,
self.astrbot_config_mgr,
),
)
await scheduler.initialize()
mapping[conf_id] = scheduler
@@ -449,7 +454,12 @@ class AstrBotCoreLifecycle:
if not ab_config:
raise ValueError(f"配置文件 {conf_id} 不存在")
scheduler = PipelineScheduler(
PipelineContext(ab_config, self.plugin_manager, conf_id),
PipelineContext(
ab_config,
self.plugin_manager,
conf_id,
self.astrbot_config_mgr,
),
)
await scheduler.initialize()
self.pipeline_scheduler_mapping[conf_id] = scheduler

View File

@@ -312,6 +312,10 @@ class SQLiteDatabase(BaseDatabase):
base_query = base_query.where(
col(ConversationV2.platform_id).in_(platform_ids),
)
if kwargs.get("user_id"):
base_query = base_query.where(
col(ConversationV2.user_id) == str(kwargs["user_id"]),
)
if search_query:
search_query = search_query.encode("unicode_escape").decode("utf-8")
base_query = base_query.where(

View File

@@ -277,11 +277,15 @@ class KnowledgeBaseManager:
kb_ids = []
kb_id_helper_map = {}
unavailable_kbs = []
for kb_name in kb_names:
if kb_helper := await self.get_kb_by_name(kb_name):
for kb_ref in kb_names:
kb_ref = str(kb_ref)
kb_helper = await self.get_kb(kb_ref)
if not kb_helper:
kb_helper = await self.get_kb_by_name(kb_ref)
if kb_helper:
if kb_helper.init_error:
unavailable_kbs.append((kb_name, kb_helper.init_error))
logger.warning(f"知识库 {kb_name} 不可用: {kb_helper.init_error}")
unavailable_kbs.append((kb_ref, kb_helper.init_error))
logger.warning(f"知识库 {kb_ref} 不可用: {kb_helper.init_error}")
continue
kb_ids.append(kb_helper.kb.kb_id)
kb_id_helper_map[kb_helper.kb.kb_id] = kb_helper

View File

@@ -81,7 +81,6 @@ class RetrievalManager:
query: 查询文本
kb_ids: 知识库 ID 列表
top_m_final: 最终返回数量
enable_rerank: 是否启用 Rerank
Returns:
List[RetrievalResult]: 检索结果列表

View File

@@ -1,5 +1,4 @@
from astrbot import logger
from astrbot.api import sp
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import Persona, PersonaFolder, Personality
@@ -86,28 +85,15 @@ class PersonaManager:
tuple:
- selected persona_id
- selected persona object
- force applied persona_id from session rule
- force applied persona_id, retained for API compatibility
- whether use webchat special default persona
"""
session_service_config = (
await sp.get_async(
scope="umo",
scope_id=str(umo),
key="session_service_config",
default={},
)
or {}
)
force_applied_persona_id = session_service_config.get("persona_id")
persona_id = force_applied_persona_id
if not persona_id:
persona_id = conversation_persona_id
if persona_id == "[%None]":
pass
elif persona_id is None:
persona_id = (provider_settings or {}).get("default_personality")
force_applied_persona_id = None
persona_id = conversation_persona_id
if persona_id == "[%None]":
pass
elif persona_id is None:
persona_id = (provider_settings or {}).get("default_personality")
persona = next(
(item for item in self.personas_v3 if item["name"] == persona_id),

View File

@@ -24,7 +24,6 @@ if TYPE_CHECKING:
from .rate_limit_check.stage import RateLimitStage
from .respond.stage import RespondStage
from .result_decorate.stage import ResultDecorateStage
from .session_status_check.stage import SessionStatusCheckStage
from .waking_check.stage import WakingCheckStage
from .whitelist_check.stage import WhitelistCheckStage
@@ -53,10 +52,6 @@ _LAZY_EXPORTS = {
"astrbot.core.pipeline.result_decorate.stage",
"ResultDecorateStage",
),
"SessionStatusCheckStage": (
"astrbot.core.pipeline.session_status_check.stage",
"SessionStatusCheckStage",
),
"WakingCheckStage": (
"astrbot.core.pipeline.waking_check.stage",
"WakingCheckStage",
@@ -75,7 +70,6 @@ if TYPE_CHECKING:
from .rate_limit_check.stage import RateLimitStage
from .respond.stage import RespondStage
from .result_decorate.stage import ResultDecorateStage
from .session_status_check.stage import SessionStatusCheckStage
from .waking_check.stage import WakingCheckStage
from .whitelist_check.stage import WhitelistCheckStage
@@ -88,7 +82,6 @@ __all__ = [
"RateLimitStage",
"RespondStage",
"ResultDecorateStage",
"SessionStatusCheckStage",
"STAGES_ORDER",
"WakingCheckStage",
"WhitelistCheckStage",

View File

@@ -7,7 +7,6 @@ from .stage import registered_stages
_BUILTIN_STAGE_MODULES = (
"astrbot.core.pipeline.waking_check.stage",
"astrbot.core.pipeline.whitelist_check.stage",
"astrbot.core.pipeline.session_status_check.stage",
"astrbot.core.pipeline.rate_limit_check.stage",
"astrbot.core.pipeline.content_safety_check.stage",
"astrbot.core.pipeline.preprocess_stage.stage",
@@ -19,7 +18,6 @@ _BUILTIN_STAGE_MODULES = (
_EXPECTED_STAGE_NAMES = {
"WakingCheckStage",
"WhitelistCheckStage",
"SessionStatusCheckStage",
"RateLimitStage",
"ContentSafetyCheckStage",
"PreProcessStage",

View File

@@ -17,8 +17,7 @@ class ContentSafetyCheckStage(Stage):
"""
async def initialize(self, ctx: PipelineContext) -> None:
config = ctx.astrbot_config["content_safety"]
self.strategy_selector = StrategySelector(config)
self.ctx = ctx
async def process(
self,
@@ -27,7 +26,8 @@ class ContentSafetyCheckStage(Stage):
) -> AsyncGenerator[None, None]:
"""检查内容安全"""
text = check_text if check_text else event.get_message_str()
ok, info = self.strategy_selector.check(text)
strategy_selector = StrategySelector(self.ctx.astrbot_config["content_safety"])
ok, info = strategy_selector.check(text)
if not ok:
if event.is_at_or_wake_command:
event.set_result(

View File

@@ -8,15 +8,27 @@ from astrbot.core.config import AstrBotConfig
from .context_utils import call_event_hook, call_handler
if TYPE_CHECKING:
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.star import PluginManager
@dataclass
class PipelineContext:
"""上下文对象,包含管道执行所需的上下文信息"""
"""Context object with the state needed to execute a pipeline."""
astrbot_config: AstrBotConfig # AstrBot 配置对象
plugin_manager: PluginManager # 插件管理器对象
base_astrbot_config: AstrBotConfig
plugin_manager: PluginManager
astrbot_config_id: str
astrbot_config_mgr: AstrBotConfigManager
call_handler = call_handler
call_event_hook = call_event_hook
@property
def astrbot_config(self) -> AstrBotConfig:
"""Return the current event config view, or the shared base config.
Returns:
Effective config bound to the current task when a pipeline event is
running; otherwise the shared config profile.
"""
return self.astrbot_config_mgr.get_current_conf(self.base_astrbot_config)

View File

@@ -24,12 +24,8 @@ from ..stage import Stage, register_stage
class PreProcessStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.config = ctx.astrbot_config
self.plugin_manager = ctx.plugin_manager
self.stt_settings: dict = self.config.get("provider_stt_settings", {})
self.platform_settings: dict = self.config.get("platform_settings", {})
@staticmethod
def _track_temp_media(event: AstrMessageEvent, media_path: str) -> None:
"""Track a media file owned by the current event.
@@ -52,11 +48,15 @@ class PreProcessStage(Stage):
event: AstrMessageEvent,
) -> None | AsyncGenerator[None, None]:
"""在处理事件之前的预处理"""
config = self.ctx.astrbot_config
stt_settings: dict = config.get("provider_stt_settings", {})
platform_settings: dict = config.get("platform_settings", {})
# 平台特异配置platform_specific.<platform>.pre_ack_emoji
supported = {"telegram", "lark", "discord"}
platform = event.get_platform_name()
cfg = (
self.config.get("platform_specific", {})
config.get("platform_specific", {})
.get(platform, {})
.get("pre_ack_emoji", {})
) or {}
@@ -73,7 +73,7 @@ class PreProcessStage(Stage):
logger.warning(f"{platform} 预回应表情发送失败: {e}")
# 路径映射
if mappings := self.platform_settings.get("path_mapping", []):
if mappings := platform_settings.get("path_mapping", []):
# 支持 RecordImage 消息段的路径映射。
message_chain = event.get_messages()
@@ -164,7 +164,7 @@ class PreProcessStage(Stage):
)
# STT
if self.stt_settings.get("enable", False):
if stt_settings.get("enable", False):
# TODO: 独立
ctx = self.plugin_manager.context
stt_provider = ctx.get_using_stt_provider(event.unified_msg_origin)

View File

@@ -2,7 +2,6 @@ from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.session_llm_manager import SessionServiceManager
from ...context import PipelineContext
from ..stage import Stage
@@ -13,36 +12,33 @@ from .agent_sub_stages.third_party import ThirdPartyAgentSubStage
class AgentRequestSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.config = ctx.astrbot_config
self.bot_wake_prefixs: list[str] = self.config["wake_prefix"]
self.prov_wake_prefix: str = self.config["provider_settings"]["wake_prefix"]
for bwp in self.bot_wake_prefixs:
if self.prov_wake_prefix.startswith(bwp):
logger.info(
f"识别 LLM 聊天额外唤醒前缀 {self.prov_wake_prefix} 以机器人唤醒前缀 {bwp} 开头,已自动去除。",
)
self.prov_wake_prefix = self.prov_wake_prefix[len(bwp) :]
agent_runner_type = self.config["provider_settings"]["agent_runner_type"]
if agent_runner_type == "local":
self.agent_sub_stage = InternalAgentSubStage()
else:
self.agent_sub_stage = ThirdPartyAgentSubStage()
await self.agent_sub_stage.initialize(ctx)
self.internal_agent_sub_stage = InternalAgentSubStage()
self.third_party_agent_sub_stage = ThirdPartyAgentSubStage()
await self.internal_agent_sub_stage.initialize(ctx)
await self.third_party_agent_sub_stage.initialize(ctx)
async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None, None]:
if not self.ctx.astrbot_config["provider_settings"]["enable"]:
config = self.ctx.astrbot_config
provider_settings = config["provider_settings"]
if not provider_settings["enable"]:
logger.debug(
"This pipeline does not enable AI capability, skip processing."
)
return
if not await SessionServiceManager.should_process_llm_request(event):
logger.debug(
f"The session {event.unified_msg_origin} has disabled AI capability, skipping processing."
)
return
bot_wake_prefixes: list[str] = config["wake_prefix"]
provider_wake_prefix: str = provider_settings["wake_prefix"]
for bot_wake_prefix in bot_wake_prefixes:
if provider_wake_prefix.startswith(bot_wake_prefix):
logger.info(
f"识别 LLM 聊天额外唤醒前缀 {provider_wake_prefix} 以机器人唤醒前缀 {bot_wake_prefix} 开头,已自动去除。",
)
provider_wake_prefix = provider_wake_prefix[len(bot_wake_prefix) :]
async for resp in self.agent_sub_stage.process(event, self.prov_wake_prefix):
agent_sub_stage = (
self.internal_agent_sub_stage
if provider_settings["agent_runner_type"] == "local"
else self.third_party_agent_sub_stage
)
async for resp in agent_sub_stage.process(event, provider_wake_prefix):
yield resp

View File

@@ -53,107 +53,8 @@ from ...follow_up import (
class InternalAgentSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
conf = ctx.astrbot_config
settings = conf["provider_settings"]
self.streaming_response: bool = settings["streaming_response"]
self.unsupported_streaming_strategy: str = settings[
"unsupported_streaming_strategy"
]
self.max_step: int = settings.get("max_agent_step", 30)
self.tool_call_timeout: int = settings.get("tool_call_timeout", 60)
self.tool_schema_mode: str = settings.get("tool_schema_mode", "full")
if self.tool_schema_mode not in ("skills_like", "full"):
logger.warning(
"Unsupported tool_schema_mode: %s, fallback to skills_like",
self.tool_schema_mode,
)
self.tool_schema_mode = "full"
if isinstance(self.max_step, bool): # workaround: #2622
self.max_step = 30
self.show_tool_use: bool = settings.get("show_tool_use_status", True)
self.show_tool_call_result: bool = settings.get("show_tool_call_result", False)
self.buffer_intermediate_messages: bool = settings.get(
"buffer_intermediate_messages",
False,
)
self.show_reasoning = settings.get("display_reasoning_text", False)
self.sanitize_context_by_modalities: bool = settings.get(
"sanitize_context_by_modalities",
False,
)
self.kb_agentic_mode: bool = conf.get("kb_agentic_mode", False)
file_extract_conf: dict = settings.get("file_extract", {})
self.file_extract_enabled: bool = file_extract_conf.get("enable", False)
self.file_extract_prov: str = file_extract_conf.get("provider", "moonshotai")
self.file_extract_msh_api_key: str = file_extract_conf.get(
"moonshotai_api_key", ""
)
# 上下文管理相关
self.context_limit_reached_strategy: str = settings.get(
"context_limit_reached_strategy", "truncate_by_turns"
)
self.llm_compress_instruction: str = settings.get(
"llm_compress_instruction", ""
)
self.llm_compress_keep_recent_ratio: float = settings.get(
"llm_compress_keep_recent_ratio", 0.15
)
self.llm_compress_provider_id: str = settings.get(
"llm_compress_provider_id", ""
)
self.max_context_length = settings["max_context_length"] # int
self.dequeue_context_length: int = min(
max(1, settings["dequeue_context_length"]),
self.max_context_length - 1,
)
if self.dequeue_context_length <= 0:
self.dequeue_context_length = 1
self.fallback_max_context_tokens: int = settings.get(
"fallback_max_context_tokens", 128000
)
self.llm_safety_mode = settings.get("llm_safety_mode", True)
self.safety_mode_strategy = settings.get(
"safety_mode_strategy", "system_prompt"
)
self.computer_use_runtime = settings.get("computer_use_runtime")
self.sandbox_cfg = settings.get("sandbox", {})
# Proactive capability configuration
proactive_cfg = settings.get("proactive_capability", {})
self.add_cron_tools = proactive_cfg.get("add_cron_tools", True)
self.conv_manager = ctx.plugin_manager.context.conversation_manager
self.main_agent_cfg = MainAgentBuildConfig(
tool_call_timeout=self.tool_call_timeout,
tool_schema_mode=self.tool_schema_mode,
sanitize_context_by_modalities=self.sanitize_context_by_modalities,
kb_agentic_mode=self.kb_agentic_mode,
file_extract_enabled=self.file_extract_enabled,
file_extract_prov=self.file_extract_prov,
file_extract_msh_api_key=self.file_extract_msh_api_key,
context_limit_reached_strategy=self.context_limit_reached_strategy,
llm_compress_instruction=self.llm_compress_instruction,
llm_compress_keep_recent_ratio=self.llm_compress_keep_recent_ratio,
llm_compress_provider_id=self.llm_compress_provider_id,
max_context_length=self.max_context_length,
dequeue_context_length=self.dequeue_context_length,
fallback_max_context_tokens=self.fallback_max_context_tokens,
llm_safety_mode=self.llm_safety_mode,
safety_mode_strategy=self.safety_mode_strategy,
computer_use_runtime=self.computer_use_runtime,
sandbox_cfg=self.sandbox_cfg,
add_cron_tools=self.add_cron_tools,
provider_settings=settings,
subagent_orchestrator=conf.get("subagent_orchestrator", {}),
timezone=self.ctx.plugin_manager.context.get_config().get("timezone"),
max_quoted_fallback_images=settings.get("max_quoted_fallback_images", 20),
)
async def _send_llm_error_message(
self, event: AstrMessageEvent, message: object
) -> None:
@@ -162,12 +63,96 @@ class InternalAgentSubStage(Stage):
async def process(
self, event: AstrMessageEvent, provider_wake_prefix: str
) -> AsyncGenerator[None, None]:
conf = self.ctx.astrbot_config
settings = conf["provider_settings"]
streaming_response: bool = settings["streaming_response"]
unsupported_streaming_strategy: str = settings["unsupported_streaming_strategy"]
max_step: int = settings.get("max_agent_step", 30)
tool_call_timeout: int = settings.get("tool_call_timeout", 60)
tool_schema_mode: str = settings.get("tool_schema_mode", "full")
if tool_schema_mode not in ("skills_like", "full"):
logger.warning(
"Unsupported tool_schema_mode: %s, fallback to full",
tool_schema_mode,
)
tool_schema_mode = "full"
if isinstance(max_step, bool): # workaround: #2622
max_step = 30
show_tool_use: bool = settings.get("show_tool_use_status", True)
show_tool_call_result: bool = settings.get("show_tool_call_result", False)
buffer_intermediate_messages: bool = settings.get(
"buffer_intermediate_messages",
False,
)
show_reasoning = settings.get("display_reasoning_text", False)
sanitize_context_by_modalities: bool = settings.get(
"sanitize_context_by_modalities",
False,
)
kb_agentic_mode: bool = conf.get("kb_agentic_mode", False)
file_extract_conf: dict = settings.get("file_extract", {})
file_extract_enabled: bool = file_extract_conf.get("enable", False)
file_extract_prov: str = file_extract_conf.get("provider", "moonshotai")
file_extract_msh_api_key: str = file_extract_conf.get("moonshotai_api_key", "")
context_limit_reached_strategy: str = settings.get(
"context_limit_reached_strategy", "truncate_by_turns"
)
llm_compress_instruction: str = settings.get("llm_compress_instruction", "")
llm_compress_keep_recent_ratio: float = settings.get(
"llm_compress_keep_recent_ratio", 0.15
)
llm_compress_provider_id: str = settings.get("llm_compress_provider_id", "")
max_context_length = settings["max_context_length"]
dequeue_context_length: int = min(
max(1, settings["dequeue_context_length"]),
max_context_length - 1,
)
if dequeue_context_length <= 0:
dequeue_context_length = 1
fallback_max_context_tokens: int = settings.get(
"fallback_max_context_tokens", 128000
)
llm_safety_mode = settings.get("llm_safety_mode", True)
safety_mode_strategy = settings.get("safety_mode_strategy", "system_prompt")
computer_use_runtime = settings.get("computer_use_runtime")
sandbox_cfg = settings.get("sandbox", {})
proactive_cfg = settings.get("proactive_capability", {})
add_cron_tools = proactive_cfg.get("add_cron_tools", True)
main_agent_cfg = MainAgentBuildConfig(
tool_call_timeout=tool_call_timeout,
tool_schema_mode=tool_schema_mode,
sanitize_context_by_modalities=sanitize_context_by_modalities,
kb_agentic_mode=kb_agentic_mode,
file_extract_enabled=file_extract_enabled,
file_extract_prov=file_extract_prov,
file_extract_msh_api_key=file_extract_msh_api_key,
context_limit_reached_strategy=context_limit_reached_strategy,
llm_compress_instruction=llm_compress_instruction,
llm_compress_keep_recent_ratio=llm_compress_keep_recent_ratio,
llm_compress_provider_id=llm_compress_provider_id,
max_context_length=max_context_length,
dequeue_context_length=dequeue_context_length,
fallback_max_context_tokens=fallback_max_context_tokens,
llm_safety_mode=llm_safety_mode,
safety_mode_strategy=safety_mode_strategy,
computer_use_runtime=computer_use_runtime,
sandbox_cfg=sandbox_cfg,
add_cron_tools=add_cron_tools,
provider_settings=settings,
subagent_orchestrator=conf.get("subagent_orchestrator", {}),
timezone=self.ctx.plugin_manager.context.get_config().get("timezone"),
max_quoted_fallback_images=settings.get("max_quoted_fallback_images", 20),
)
follow_up_capture: FollowUpCapture | None = None
follow_up_consumed_marked = False
follow_up_activated = False
typing_requested = False
try:
streaming_response = self.streaming_response
if (enable_streaming := event.get_extra("enable_streaming")) is not None:
streaming_response = bool(enable_streaming)
@@ -219,7 +204,7 @@ class InternalAgentSubStage(Stage):
runner_registered = False
try:
build_cfg = replace(
self.main_agent_cfg,
main_agent_cfg,
provider_wake_prefix=provider_wake_prefix,
streaming_response=streaming_response,
)
@@ -258,7 +243,7 @@ class InternalAgentSubStage(Stage):
return
stream_to_general = (
self.unsupported_streaming_strategy == "turn_off"
unsupported_streaming_strategy == "turn_off"
and not event.platform_meta.support_streaming_message
)
@@ -311,11 +296,11 @@ class InternalAgentSubStage(Stage):
run_live_agent(
agent_runner,
tts_provider,
self.max_step,
self.show_tool_use,
self.show_tool_call_result,
show_reasoning=self.show_reasoning,
buffer_intermediate_messages=self.buffer_intermediate_messages,
max_step,
show_tool_use,
show_tool_call_result,
show_reasoning=show_reasoning,
buffer_intermediate_messages=buffer_intermediate_messages,
),
),
)
@@ -342,11 +327,11 @@ class InternalAgentSubStage(Stage):
.set_async_stream(
run_agent(
agent_runner,
self.max_step,
self.show_tool_use,
self.show_tool_call_result,
show_reasoning=self.show_reasoning,
buffer_intermediate_messages=self.buffer_intermediate_messages,
max_step,
show_tool_use,
show_tool_call_result,
show_reasoning=show_reasoning,
buffer_intermediate_messages=buffer_intermediate_messages,
),
),
)
@@ -372,12 +357,12 @@ class InternalAgentSubStage(Stage):
else:
async for _ in run_agent(
agent_runner,
self.max_step,
self.show_tool_use,
self.show_tool_call_result,
max_step,
show_tool_use,
show_tool_call_result,
stream_to_general,
show_reasoning=self.show_reasoning,
buffer_intermediate_messages=self.buffer_intermediate_messages,
show_reasoning=show_reasoning,
buffer_intermediate_messages=buffer_intermediate_messages,
):
yield

View File

@@ -164,27 +164,6 @@ async def _close_runner_if_supported(runner: "BaseAgentRunner") -> None:
class ThirdPartyAgentSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.conf = ctx.astrbot_config
self.runner_type = self.conf["provider_settings"]["agent_runner_type"]
self.prov_id = self.conf["provider_settings"].get(
AGENT_RUNNER_TYPE_KEY.get(self.runner_type, ""),
"",
)
settings = ctx.astrbot_config["provider_settings"]
self.streaming_response: bool = settings["streaming_response"]
self.unsupported_streaming_strategy: str = settings[
"unsupported_streaming_strategy"
]
self.stream_consumption_close_timeout_sec: int = coerce_int_config(
settings.get(
"third_party_stream_consumption_close_timeout_sec",
STREAM_CONSUMPTION_CLOSE_TIMEOUT_SEC,
),
default=STREAM_CONSUMPTION_CLOSE_TIMEOUT_SEC,
min_value=1,
field_name="third_party_stream_consumption_close_timeout_sec",
source="Third-party runner config",
)
async def _resolve_persona_custom_error_message(
self, event: AstrMessageEvent
@@ -197,7 +176,7 @@ class ThirdPartyAgentSubStage(Stage):
return await resolve_persona_custom_error_message(
event=event,
persona_manager=self.ctx.plugin_manager.context.persona_manager,
provider_settings=self.conf["provider_settings"],
provider_settings=self.ctx.astrbot_config["provider_settings"],
conversation_persona_id=conversation_persona_id,
)
except Exception as e:
@@ -290,22 +269,45 @@ class ThirdPartyAgentSubStage(Stage):
self, event: AstrMessageEvent, provider_wake_prefix: str
) -> AsyncGenerator[None, None]:
req: ProviderRequest | None = None
conf = self.ctx.astrbot_config
settings = conf["provider_settings"]
runner_type = settings["agent_runner_type"]
provider_id = settings.get(
AGENT_RUNNER_TYPE_KEY.get(runner_type, ""),
"",
)
streaming_response: bool = settings["streaming_response"]
unsupported_streaming_strategy: str = settings["unsupported_streaming_strategy"]
stream_consumption_close_timeout_sec: int = coerce_int_config(
settings.get(
"third_party_stream_consumption_close_timeout_sec",
STREAM_CONSUMPTION_CLOSE_TIMEOUT_SEC,
),
default=STREAM_CONSUMPTION_CLOSE_TIMEOUT_SEC,
min_value=1,
field_name="third_party_stream_consumption_close_timeout_sec",
source="Third-party runner config",
)
if provider_wake_prefix and not event.message_str.startswith(
provider_wake_prefix
):
return
self.prov_cfg: dict = next(
(p for p in astrbot_config["provider"] if p["id"] == self.prov_id),
provider_config: dict = next(
(
p
for p in conf.get("provider", astrbot_config["provider"])
if p["id"] == provider_id
),
{},
)
if not self.prov_id:
if not provider_id:
logger.error("没有填写 Agent Runner 提供商 ID请前往配置页面配置。")
return
if not self.prov_cfg:
if not provider_config:
logger.error(
f"Agent Runner 提供商 {self.prov_id} 配置不存在,请前往配置页面修改配置。"
f"Agent Runner 提供商 {provider_id} 配置不存在,请前往配置页面修改配置。"
)
return
@@ -331,17 +333,17 @@ class ThirdPartyAgentSubStage(Stage):
if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
return
if self.runner_type == "dify":
if runner_type == "dify":
runner = DifyAgentRunner[AstrAgentContext]()
elif self.runner_type == "coze":
elif runner_type == "coze":
runner = CozeAgentRunner[AstrAgentContext]()
elif self.runner_type == "dashscope":
elif runner_type == "dashscope":
runner = DashscopeAgentRunner[AstrAgentContext]()
elif self.runner_type == DEERFLOW_PROVIDER_TYPE:
elif runner_type == DEERFLOW_PROVIDER_TYPE:
runner = DeerFlowAgentRunner[AstrAgentContext]()
else:
raise ValueError(
f"Unsupported third party agent runner type: {self.runner_type}",
f"Unsupported third party agent runner type: {runner_type}",
)
astr_agent_ctx = AstrAgentContext(
@@ -349,12 +351,11 @@ class ThirdPartyAgentSubStage(Stage):
event=event,
)
streaming_response = self.streaming_response
if (enable_streaming := event.get_extra("enable_streaming")) is not None:
streaming_response = bool(enable_streaming)
stream_to_general = (
self.unsupported_streaming_strategy == "turn_off"
unsupported_streaming_strategy == "turn_off"
and not event.platform_meta.support_streaming_message
)
streaming_used = streaming_response and not stream_to_general
@@ -384,13 +385,13 @@ class ThirdPartyAgentSubStage(Stage):
tool_call_timeout=120,
),
agent_hooks=MAIN_AGENT_HOOKS,
provider_config=self.prov_cfg,
provider_config=provider_config,
streaming=streaming_response,
)
if streaming_used:
stream_watchdog_task = _start_stream_watchdog(
timeout_sec=self.stream_consumption_close_timeout_sec,
timeout_sec=stream_consumption_close_timeout_sec,
is_stream_consumed=lambda: stream_consumed,
close_runner_once=close_runner_once,
)
@@ -423,7 +424,7 @@ class ThirdPartyAgentSubStage(Stage):
asyncio.create_task(
Metric.upload(
llm_tick=1,
model_name=self.runner_type,
provider_type=self.runner_type,
model_name=runner_type,
provider_type=runner_type,
),
)

View File

@@ -16,8 +16,6 @@ from ..stage import Stage
class StarRequestSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.prompt_prefix = ctx.astrbot_config["provider_settings"]["prompt_prefix"]
self.identifier = ctx.astrbot_config["provider_settings"]["identifier"]
self.ctx = ctx
async def process(

View File

@@ -14,7 +14,6 @@ from .method.star_request import StarRequestSubStage
class ProcessStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.config = ctx.astrbot_config
self.plugin_manager = ctx.plugin_manager
# initialize agent sub stage

View File

@@ -24,21 +24,14 @@ class RateLimitStage(Stage):
self.event_timestamps: defaultdict[str, deque[datetime]] = defaultdict(deque)
# 为每个会话设置一个锁,避免并发冲突
self.locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
# 限流参数
self.rate_limit_count: int = 0
self.rate_limit_time: timedelta = timedelta(0)
async def initialize(self, ctx: PipelineContext) -> None:
"""初始化限流器,根据配置设置限流参数。"""
self.rate_limit_count = ctx.astrbot_config["platform_settings"]["rate_limit"][
"count"
]
self.rate_limit_time = timedelta(
seconds=ctx.astrbot_config["platform_settings"]["rate_limit"]["time"],
)
self.rl_strategy = ctx.astrbot_config["platform_settings"]["rate_limit"][
"strategy"
] # stall or discard
"""Initialize the rate limiter state.
Args:
ctx: Pipeline context used to read config during event processing.
"""
self.ctx = ctx
async def process(
self,
@@ -56,22 +49,26 @@ class RateLimitStage(Stage):
"""
session_id = event.session_id
now = datetime.now()
rate_limit = self.ctx.astrbot_config["platform_settings"]["rate_limit"]
rate_limit_count = rate_limit["count"]
rate_limit_time = timedelta(seconds=rate_limit["time"])
rl_strategy = rate_limit["strategy"]
async with self.locks[session_id]: # 确保同一会话不会并发修改队列
# 检查并处理限流,可能需要多次检查直到满足条件
while True:
timestamps = self.event_timestamps[session_id]
self._remove_expired_timestamps(timestamps, now)
self._remove_expired_timestamps(timestamps, now, rate_limit_time)
if self.rate_limit_count <= 0:
if rate_limit_count <= 0:
break
if len(timestamps) < self.rate_limit_count:
if len(timestamps) < rate_limit_count:
timestamps.append(now)
break
next_window_time = timestamps[0] + self.rate_limit_time
next_window_time = timestamps[0] + rate_limit_time
stall_duration = (next_window_time - now).total_seconds() + 0.3
match self.rl_strategy:
match rl_strategy:
case RateLimitStrategy.STALL.value:
logger.info(
f"会话 {session_id} 被限流。根据限流策略,此会话处理将被暂停 {stall_duration:.2f} 秒。",
@@ -88,14 +85,16 @@ class RateLimitStage(Stage):
self,
timestamps: deque[datetime],
now: datetime,
rate_limit_time: timedelta,
) -> None:
"""移除时间窗口外的时间戳。
Args:
timestamps (Deque[datetime]): 当前会话的时间戳队列。
now (datetime): 当前时间,用于计算过期时间。
rate_limit_time: Current rate-limit window duration.
"""
expiry_threshold: datetime = now - self.rate_limit_time
expiry_threshold: datetime = now - rate_limit_time
while timestamps and timestamps[0] < expiry_threshold:
timestamps.popleft()

View File

@@ -51,41 +51,6 @@ class RespondStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.config = ctx.astrbot_config
self.platform_settings: dict = self.config.get("platform_settings", {})
self.reply_with_mention = ctx.astrbot_config["platform_settings"][
"reply_with_mention"
]
self.reply_with_quote = ctx.astrbot_config["platform_settings"][
"reply_with_quote"
]
# 分段回复
self.enable_seg: bool = ctx.astrbot_config["platform_settings"][
"segmented_reply"
]["enable"]
self.only_llm_result = ctx.astrbot_config["platform_settings"][
"segmented_reply"
]["only_llm_result"]
self.interval_method = ctx.astrbot_config["platform_settings"][
"segmented_reply"
]["interval_method"]
self.log_base = float(
ctx.astrbot_config["platform_settings"]["segmented_reply"]["log_base"],
)
self.interval = [1.5, 3.5]
if self.enable_seg:
interval_str: str = ctx.astrbot_config["platform_settings"][
"segmented_reply"
]["interval"]
interval_str_ls = interval_str.replace(" ", "").split(",")
try:
self.interval = [float(t) for t in interval_str_ls]
except BaseException as e:
logger.error(f"解析分段回复的间隔时间失败。{e}")
logger.info(f"分段回复间隔时间:{self.interval}")
async def _word_cnt(self, text: str) -> int:
"""分段回复 统计字数"""
@@ -95,16 +60,22 @@ class RespondStage(Stage):
word_count = len([c for c in text if c.isalnum()])
return word_count
async def _calc_comp_interval(self, comp: BaseMessageComponent) -> float:
async def _calc_comp_interval(
self,
comp: BaseMessageComponent,
interval_method: str,
log_base: float,
interval: list[float],
) -> float:
"""分段回复 计算间隔时间"""
if self.interval_method == "log":
if interval_method == "log":
if isinstance(comp, Comp.Plain):
wc = await self._word_cnt(comp.text)
i = math.log(wc + 1, self.log_base)
i = math.log(wc + 1, log_base)
return random.uniform(i, i + 0.5)
return random.uniform(1, 1.75)
# random
return random.uniform(self.interval[0], self.interval[1])
return random.uniform(interval[0], interval[1])
async def _is_empty_message_chain(self, chain: list[BaseMessageComponent]) -> bool:
"""检查消息链是否为空
@@ -127,14 +98,19 @@ class RespondStage(Stage):
# 如果所有组件都为空
return True
def is_seg_reply_required(self, event: AstrMessageEvent) -> bool:
def is_seg_reply_required(
self,
event: AstrMessageEvent,
enable_seg: bool,
only_llm_result: bool,
) -> bool:
"""检查是否需要分段回复"""
if not self.enable_seg:
if not enable_seg:
return False
if (result := event.get_result()) is None:
return False
if self.only_llm_result and not result.is_model_result():
if only_llm_result and not result.is_model_result():
return False
if event.get_platform_name() in [
@@ -170,6 +146,23 @@ class RespondStage(Stage):
self,
event: AstrMessageEvent,
) -> None | AsyncGenerator[None, None]:
config = self.ctx.astrbot_config
platform_settings: dict = config.get("platform_settings", {})
segmented_reply = platform_settings.get("segmented_reply", {})
enable_seg: bool = segmented_reply.get("enable", False)
only_llm_result = segmented_reply.get("only_llm_result", True)
interval_method = segmented_reply.get("interval_method", "random")
log_base = float(segmented_reply.get("log_base", 2.6))
interval = [1.5, 3.5]
if enable_seg:
interval_str: str = segmented_reply.get("interval", "1.5,3.5")
interval_str_ls = interval_str.replace(" ", "").split(",")
try:
interval = [float(t) for t in interval_str_ls]
except BaseException as e:
logger.error(f"解析分段回复的间隔时间失败。{e}")
logger.debug(f"分段回复间隔时间:{interval}")
result = event.get_result()
if result is None:
return
@@ -213,7 +206,7 @@ class RespondStage(Stage):
return
# 流式结果直接交付平台适配器处理
realtime_segmenting = (
self.config.get("provider_settings", {}).get(
config.get("provider_settings", {}).get(
"unsupported_streaming_strategy",
"realtime_segmenting",
)
@@ -224,7 +217,7 @@ class RespondStage(Stage):
return
if len(result.chain) > 0:
# 检查路径映射
if mappings := self.platform_settings.get("path_mapping", []):
if mappings := platform_settings.get("path_mapping", []):
for idx, component in enumerate(result.chain):
if isinstance(component, Comp.File) and component.file:
# 支持 File 消息段的路径映射。
@@ -252,7 +245,7 @@ class RespondStage(Stage):
# 发送消息链
# Record 需要强制单独发送
need_separately = {ComponentType.Record}
if self.is_seg_reply_required(event):
if self.is_seg_reply_required(event, enable_seg, only_llm_result):
header_comps = self._extract_comp(
result.chain,
{ComponentType.Reply, ComponentType.At},
@@ -265,7 +258,12 @@ class RespondStage(Stage):
)
return
for comp in result.chain:
i = await self._calc_comp_interval(comp)
i = await self._calc_comp_interval(
comp,
interval_method,
log_base,
interval,
)
await asyncio.sleep(i)
try:
if comp.type in need_separately:

View File

@@ -10,7 +10,6 @@ from astrbot.core.message.message_event_result import ResultContentType
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.platform.message_type import MessageType
from astrbot.core.star.session_llm_manager import SessionServiceManager
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import EventType, star_handlers_registry
@@ -22,98 +21,30 @@ from ..stage import Stage, register_stage, registered_stages
class ResultDecorateStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.reply_prefix = ctx.astrbot_config["platform_settings"]["reply_prefix"]
self.reply_with_mention = ctx.astrbot_config["platform_settings"][
"reply_with_mention"
]
self.reply_with_quote = ctx.astrbot_config["platform_settings"][
"reply_with_quote"
]
self.t2i_word_threshold = ctx.astrbot_config["t2i_word_threshold"]
try:
self.t2i_word_threshold = int(self.t2i_word_threshold)
self.t2i_word_threshold = max(self.t2i_word_threshold, 50)
except BaseException:
self.t2i_word_threshold = 150
self.t2i_strategy = ctx.astrbot_config["t2i_strategy"]
self.t2i_use_network = self.t2i_strategy == "remote"
self.t2i_active_template = ctx.astrbot_config["t2i_active_template"]
self.forward_threshold = ctx.astrbot_config["platform_settings"][
"forward_threshold"
]
trigger_probability = ctx.astrbot_config["provider_tts_settings"].get(
"trigger_probability",
1,
)
try:
self.tts_trigger_probability = max(
0.0,
min(float(trigger_probability), 1.0),
)
except (TypeError, ValueError):
self.tts_trigger_probability = 1.0
# 分段回复
self.words_count_threshold = int(
ctx.astrbot_config["platform_settings"]["segmented_reply"][
"words_count_threshold"
],
)
self.enable_segmented_reply = ctx.astrbot_config["platform_settings"][
"segmented_reply"
]["enable"]
self.only_llm_result = ctx.astrbot_config["platform_settings"][
"segmented_reply"
]["only_llm_result"]
self.split_mode = ctx.astrbot_config["platform_settings"][
"segmented_reply"
].get("split_mode", "regex")
self.regex = ctx.astrbot_config["platform_settings"]["segmented_reply"]["regex"]
self.split_words = ctx.astrbot_config["platform_settings"][
"segmented_reply"
].get("split_words", ["", "", "", "~", ""])
if self.split_words:
escaped_words = sorted(
[re.escape(word) for word in self.split_words], key=len, reverse=True
)
self.split_words_pattern = re.compile(
f"(.*?({'|'.join(escaped_words)})|.+$)", re.DOTALL
)
else:
self.split_words_pattern = None
self.content_cleanup_rule = ctx.astrbot_config["platform_settings"][
"segmented_reply"
]["content_cleanup_rule"]
# exception
self.content_safe_check_reply = ctx.astrbot_config["content_safety"][
"also_use_in_response"
]
self.content_safe_check_stage = None
if self.content_safe_check_reply:
for stage_cls in registered_stages:
if stage_cls.__name__ == "ContentSafetyCheckStage":
self.content_safe_check_stage = stage_cls()
await self.content_safe_check_stage.initialize(ctx)
for stage_cls in registered_stages:
if stage_cls.__name__ == "ContentSafetyCheckStage":
self.content_safe_check_stage = stage_cls()
await self.content_safe_check_stage.initialize(ctx)
provider_cfg = ctx.astrbot_config.get("provider_settings", {})
self.show_reasoning = provider_cfg.get("display_reasoning_text", False)
def _split_text_by_words(self, text: str) -> list[str]:
def _split_text_by_words(
self,
text: str,
split_words_pattern: re.Pattern[str] | None,
split_words: list[str],
) -> list[str]:
"""使用分段词列表分段文本"""
if not self.split_words_pattern:
if not split_words_pattern:
return [text]
segments = self.split_words_pattern.findall(text)
segments = split_words_pattern.findall(text)
result = []
for seg in segments:
if isinstance(seg, tuple):
content = seg[0]
if not isinstance(content, str):
continue
for word in self.split_words:
for word in split_words:
if content.endswith(word):
content = content[: -len(word)]
break
@@ -135,10 +66,64 @@ class ResultDecorateStage(Stage):
return
is_stream = result.result_content_type == ResultContentType.STREAMING_FINISH
config = self.ctx.astrbot_config
platform_settings = config.get("platform_settings", {})
segmented_reply = platform_settings.get("segmented_reply", {})
reply_prefix = platform_settings.get("reply_prefix", "")
reply_with_mention = platform_settings.get("reply_with_mention", False)
reply_with_quote = platform_settings.get("reply_with_quote", False)
forward_threshold = platform_settings.get("forward_threshold", 1500)
t2i_word_threshold = config.get("t2i_word_threshold", 150)
try:
t2i_word_threshold = max(int(t2i_word_threshold), 50)
except BaseException:
t2i_word_threshold = 150
t2i_strategy = config.get("t2i_strategy", "local")
t2i_use_network = t2i_strategy == "remote"
t2i_active_template = config.get("t2i_active_template", "")
tts_settings = config.get("provider_tts_settings", {})
trigger_probability = tts_settings.get("trigger_probability", 1)
try:
tts_trigger_probability = max(
0.0,
min(float(trigger_probability), 1.0),
)
except (TypeError, ValueError):
tts_trigger_probability = 1.0
words_count_threshold = int(segmented_reply.get("words_count_threshold", 150))
enable_segmented_reply = segmented_reply.get("enable", False)
only_llm_result = segmented_reply.get("only_llm_result", True)
split_mode = segmented_reply.get("split_mode", "regex")
split_regex = segmented_reply.get("regex", r".*?[。?!~…]+|.+$")
split_words = segmented_reply.get(
"split_words",
["", "", "", "~", ""],
)
if split_words:
escaped_words = sorted(
[re.escape(word) for word in split_words],
key=len,
reverse=True,
)
split_words_pattern = re.compile(
f"(.*?({'|'.join(escaped_words)})|.+$)",
re.DOTALL,
)
else:
split_words_pattern = None
content_cleanup_rule = segmented_reply.get("content_cleanup_rule", "")
content_safe_check_reply = config.get("content_safety", {}).get(
"also_use_in_response",
False,
)
provider_cfg = config.get("provider_settings", {})
show_reasoning = provider_cfg.get("display_reasoning_text", False)
# 回复时检查内容安全
if (
self.content_safe_check_reply
content_safe_check_reply
and self.content_safe_check_stage
and result.is_llm_result()
and not is_stream # 流式输出不检查内容安全
@@ -195,36 +180,40 @@ class ResultDecorateStage(Stage):
if len(result.chain) > 0:
# 回复前缀
if self.reply_prefix:
if reply_prefix:
for comp in result.chain:
if isinstance(comp, Plain):
comp.text = self.reply_prefix + comp.text
comp.text = reply_prefix + comp.text
break
# 分段回复
if self.enable_segmented_reply and event.get_platform_name() not in [
if enable_segmented_reply and event.get_platform_name() not in [
"qq_official_webhook",
"weixin_official_account",
"dingtalk",
]:
if (
self.only_llm_result and result.is_model_result()
) or not self.only_llm_result:
only_llm_result and result.is_model_result()
) or not only_llm_result:
new_chain = []
for comp in result.chain:
if isinstance(comp, Plain):
if len(comp.text) > self.words_count_threshold:
if len(comp.text) > words_count_threshold:
# 不分段回复
new_chain.append(comp)
continue
# 根据 split_mode 选择分段方式
if self.split_mode == "words":
split_response = self._split_text_by_words(comp.text)
if split_mode == "words":
split_response = self._split_text_by_words(
comp.text,
split_words_pattern,
split_words,
)
else: # regex 模式
try:
split_response = re.findall(
self.regex,
split_regex,
comp.text,
re.DOTALL | re.MULTILINE,
)
@@ -242,8 +231,8 @@ class ResultDecorateStage(Stage):
new_chain.append(comp)
continue
for seg in split_response:
if self.content_cleanup_rule:
seg = re.sub(self.content_cleanup_rule, "", seg)
if content_cleanup_rule:
seg = re.sub(content_cleanup_rule, "", seg)
seg = seg.strip()
if seg:
new_chain.append(Plain(seg))
@@ -258,10 +247,9 @@ class ResultDecorateStage(Stage):
)
should_tts = (
bool(self.ctx.astrbot_config["provider_tts_settings"]["enable"])
bool(tts_settings.get("enable", False))
and result.is_llm_result()
and await SessionServiceManager.should_process_tts_request(event)
and random.random() <= self.tts_trigger_probability
and random.random() <= tts_trigger_probability
and tts_provider
)
if should_tts and not tts_provider:
@@ -271,7 +259,7 @@ class ResultDecorateStage(Stage):
if (
not should_tts
and self.show_reasoning
and show_reasoning
and event.get_extra("_llm_reasoning_content")
):
# inject reasoning content to chain
@@ -310,15 +298,12 @@ class ResultDecorateStage(Stage):
event.track_temporary_local_file(audio_path)
use_file_service = self.ctx.astrbot_config[
"provider_tts_settings"
]["use_file_service"]
callback_api_base = self.ctx.astrbot_config[
"callback_api_base"
]
dual_output = self.ctx.astrbot_config[
"provider_tts_settings"
]["dual_output"]
use_file_service = tts_settings.get(
"use_file_service",
False,
)
callback_api_base = config.get("callback_api_base", "")
dual_output = tts_settings.get("dual_output", False)
url = None
if use_file_service and callback_api_base:
@@ -347,7 +332,7 @@ class ResultDecorateStage(Stage):
# 文本转图片
elif (
result.use_t2i_ is None and self.ctx.astrbot_config["t2i"]
result.use_t2i_ is None and config.get("t2i", False)
) or result.use_t2i_:
parts = []
for comp in result.chain:
@@ -356,14 +341,14 @@ class ResultDecorateStage(Stage):
else:
break
plain_str = "".join(parts)
if plain_str and len(plain_str) > self.t2i_word_threshold:
if plain_str and len(plain_str) > t2i_word_threshold:
render_start = time.time()
try:
url = await html_renderer.render_t2i(
plain_str,
return_url=True,
use_network=self.t2i_use_network,
template_name=self.t2i_active_template,
use_network=t2i_use_network,
template_name=t2i_active_template,
)
except BaseException:
logger.error("文本转图片失败,使用文本发送。")
@@ -375,12 +360,11 @@ class ResultDecorateStage(Stage):
if url:
if url.startswith("http"):
result.chain = [Image.fromURL(url)]
elif (
self.ctx.astrbot_config["t2i_use_file_service"]
and self.ctx.astrbot_config["callback_api_base"]
elif config.get("t2i_use_file_service", False) and config.get(
"callback_api_base", ""
):
token = await file_token_service.register_file(url)
url = f"{self.ctx.astrbot_config['callback_api_base']}/api/file/{token}"
url = f"{config['callback_api_base']}/api/file/{token}"
logger.debug(f"已注册:{url}")
result.chain = [Image.fromURL(url)]
else:
@@ -392,7 +376,7 @@ class ResultDecorateStage(Stage):
for comp in result.chain:
if isinstance(comp, Plain):
word_cnt += len(comp.text)
if word_cnt > self.forward_threshold:
if word_cnt > forward_threshold:
node = Node(
uin=event.get_self_id(),
name="AstrBot",
@@ -407,7 +391,7 @@ class ResultDecorateStage(Stage):
if can_decorate:
# at 回复
if (
self.reply_with_mention
reply_with_mention
and event.get_message_type() != MessageType.FRIEND_MESSAGE
):
result.chain.insert(
@@ -418,5 +402,5 @@ class ResultDecorateStage(Stage):
result.chain[1].text = "\n" + result.chain[1].text
# 引用回复
if self.reply_with_quote:
if reply_with_quote:
result.chain.insert(0, Reply(id=event.message_obj.message_id))

View File

@@ -83,6 +83,14 @@ class PipelineScheduler:
"""
active_event_registry.register(event)
effective_config = await self.ctx.astrbot_config_mgr.build_effective_conf(
event.unified_msg_origin,
self.ctx.base_astrbot_config,
)
config_token = self.ctx.astrbot_config_mgr.activate_effective_conf(
event.unified_msg_origin,
effective_config,
)
try:
await self._process_stages(event)
@@ -92,5 +100,6 @@ class PipelineScheduler:
logger.debug("pipeline execution completed.")
finally:
self.ctx.astrbot_config_mgr.reset_effective_conf(config_token)
event.cleanup_temporary_local_files()
active_event_registry.unregister(event)

View File

@@ -1,37 +0,0 @@
from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.session_llm_manager import SessionServiceManager
from ..context import PipelineContext
from ..stage import Stage, register_stage
@register_stage
class SessionStatusCheckStage(Stage):
"""检查会话是否整体启用"""
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.conv_mgr = ctx.plugin_manager.context.conversation_manager
async def process(
self,
event: AstrMessageEvent,
) -> None | AsyncGenerator[None, None]:
# 检查会话是否整体启用
if not await SessionServiceManager.is_session_enabled(event.unified_msg_origin):
logger.debug(f"会话 {event.unified_msg_origin} 已被关闭,已终止事件传播。")
# workaround for #2309
conv_id = await self.conv_mgr.get_curr_conversation_id(
event.unified_msg_origin,
)
if not conv_id:
await self.conv_mgr.new_conversation(
event.unified_msg_origin,
platform_id=event.get_platform_id(),
)
event.stop_event()

View File

@@ -3,7 +3,6 @@
STAGES_ORDER = [
"WakingCheckStage", # 检查是否需要唤醒
"WhitelistCheckStage", # 检查是否在群聊/私聊白名单
"SessionStatusCheckStage", # 检查会话是否整体启用
"RateLimitStage", # 检查会话是否超过频率限制
"ContentSafetyCheckStage", # 检查内容安全
"PreProcessStage", # 预处理

View File

@@ -7,7 +7,6 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.platform.message_type import MessageType
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionTypeFilter
from astrbot.core.star.session_plugin_manager import SessionPluginManager
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import EventType, star_handlers_registry
@@ -51,56 +50,46 @@ class WakingCheckStage(Stage):
"""
self.ctx = ctx
self.no_permission_reply = self.ctx.astrbot_config["platform_settings"].get(
"no_permission_reply",
True,
)
# 私聊是否需要 wake_prefix 才能唤醒机器人
self.friend_message_needs_wake_prefix = self.ctx.astrbot_config[
"platform_settings"
].get("friend_message_needs_wake_prefix", False)
# 是否忽略机器人自己发送的消息
self.ignore_bot_self_message = self.ctx.astrbot_config["platform_settings"].get(
"ignore_bot_self_message",
False,
)
self.ignore_at_all = self.ctx.astrbot_config["platform_settings"].get(
"ignore_at_all",
False,
)
self.disable_builtin_commands = self.ctx.astrbot_config.get(
"disable_builtin_commands", False
)
platform_settings = self.ctx.astrbot_config.get("platform_settings", {})
self.unique_session = platform_settings.get("unique_session", False)
async def process(
self,
event: AstrMessageEvent,
) -> None | AsyncGenerator[None, None]:
config = self.ctx.astrbot_config
platform_settings = config.get("platform_settings", {})
no_permission_reply = platform_settings.get("no_permission_reply", True)
friend_message_needs_wake_prefix = platform_settings.get(
"friend_message_needs_wake_prefix",
False,
)
ignore_bot_self_message = platform_settings.get(
"ignore_bot_self_message",
False,
)
ignore_at_all = platform_settings.get("ignore_at_all", False)
disable_builtin_commands = config.get("disable_builtin_commands", False)
unique_session = platform_settings.get("unique_session", False)
# apply unique session
if self.unique_session and event.message_obj.type == MessageType.GROUP_MESSAGE:
if unique_session and event.message_obj.type == MessageType.GROUP_MESSAGE:
sid = build_unique_session_id(event)
if sid:
event.session_id = sid
# ignore bot self message
if (
self.ignore_bot_self_message
and event.get_self_id() == event.get_sender_id()
):
if ignore_bot_self_message and event.get_self_id() == event.get_sender_id():
event.stop_event()
return
# 设置 sender 身份
event.message_str = event.message_str.strip()
for admin_id in self.ctx.astrbot_config["admins_id"]:
for admin_id in config["admins_id"]:
if str(event.get_sender_id()) == admin_id:
event.role = "admin"
break
# 检查 wake
wake_prefixes = self.ctx.astrbot_config["wake_prefix"]
wake_prefixes = config["wake_prefix"]
messages = event.get_messages()
is_wake = False
for wake_prefix in wake_prefixes:
@@ -126,7 +115,7 @@ class WakingCheckStage(Stage):
isinstance(message, At)
and (str(message.qq) == str(event.get_self_id()))
)
or (isinstance(message, AtAll) and not self.ignore_at_all)
or (isinstance(message, AtAll) and not ignore_at_all)
or (
isinstance(message, Reply)
and str(message.sender_id) == str(event.get_self_id())
@@ -138,7 +127,7 @@ class WakingCheckStage(Stage):
event.is_at_or_wake_command = True
break
# 检查是否是私聊
if event.is_private_chat() and not self.friend_message_needs_wake_prefix:
if event.is_private_chat() and not friend_message_needs_wake_prefix:
is_wake = True
event.is_wake = True
event.is_at_or_wake_command = True
@@ -149,7 +138,8 @@ class WakingCheckStage(Stage):
handlers_parsed_params = {} # 注册了指令的 handler
# 将 plugins_name 设置到 event 中
enabled_plugins_name = self.ctx.astrbot_config.get("plugin_set", ["*"])
enabled_plugins_name = config.get("plugin_set", ["*"])
disabled_plugins_name = set(config.get("plugin_disabled_set", []))
if enabled_plugins_name == ["*"]:
# 如果是 *,则表示所有插件都启用
event.plugins_name = None
@@ -162,11 +152,25 @@ class WakingCheckStage(Stage):
plugins_name=event.plugins_name,
):
if (
self.disable_builtin_commands
disable_builtin_commands
and handler.handler_module_path
== "astrbot.builtin_stars.builtin_commands.main"
):
continue
plugin = star_map.get(handler.handler_module_path)
if (
plugin
and not plugin.reserved
and plugin.name
and plugin.name in disabled_plugins_name
):
logger.debug(
"Plugin %s is disabled by config for session %s; skipping handler %s",
plugin.name,
event.unified_msg_origin,
handler.handler_name,
)
continue
# filter 需满足 AND 逻辑关系
passed = True
@@ -178,10 +182,10 @@ class WakingCheckStage(Stage):
for filter in handler.event_filters:
try:
if isinstance(filter, PermissionTypeFilter):
if not filter.filter(event, self.ctx.astrbot_config):
if not filter.filter(event, config):
permission_not_pass = True
permission_filter_raise_error = filter.raise_error
elif not filter.filter(event, self.ctx.astrbot_config):
elif not filter.filter(event, config):
passed = False
break
except Exception as e:
@@ -198,7 +202,7 @@ class WakingCheckStage(Stage):
if not permission_filter_raise_error:
# 跳过
continue
if self.no_permission_reply:
if no_permission_reply:
await event.send(
MessageChain().message(
f"您(ID: {event.get_sender_id()})的权限不足以使用此指令。通过 /sid 获取 ID 并请管理员添加。",
@@ -225,12 +229,6 @@ class WakingCheckStage(Stage):
event._extras.pop("parsed_params", None)
# 根据会话配置过滤插件处理器
activated_handlers = await SessionPluginManager.filter_handlers_by_session(
event,
activated_handlers,
)
event.set_extra("activated_handlers", activated_handlers)
event.set_extra("handlers_parsed_params", handlers_parsed_params)

View File

@@ -13,30 +13,38 @@ class WhitelistCheckStage(Stage):
"""检查是否在群聊/私聊白名单"""
async def initialize(self, ctx: PipelineContext) -> None:
self.enable_whitelist_check = ctx.astrbot_config["platform_settings"][
"enable_id_white_list"
]
self.whitelist = ctx.astrbot_config["platform_settings"]["id_whitelist"]
self.whitelist = [
str(i).strip() for i in self.whitelist if str(i).strip() != ""
]
self.wl_ignore_admin_on_group = ctx.astrbot_config["platform_settings"][
"wl_ignore_admin_on_group"
]
self.wl_ignore_admin_on_friend = ctx.astrbot_config["platform_settings"][
"wl_ignore_admin_on_friend"
]
self.wl_log = ctx.astrbot_config["platform_settings"]["id_whitelist_log"]
self.ctx = ctx
async def process(
self,
event: AstrMessageEvent,
) -> None | AsyncGenerator[None, None]:
if not self.enable_whitelist_check:
platform_settings = self.ctx.astrbot_config["platform_settings"]
blacklist = [
str(i).strip()
for i in platform_settings.get("id_blacklist", [])
if str(i).strip()
]
event_group_id = str(event.get_group_id()).strip()
if event.unified_msg_origin in blacklist or event_group_id in blacklist:
logger.info(
"Session %s is in the session blacklist; stopping event propagation.",
event.unified_msg_origin,
)
event.stop_event()
return
enable_whitelist_check = platform_settings["enable_id_white_list"]
if not enable_whitelist_check:
# 白名单检查未启用
return
if len(self.whitelist) == 0:
whitelist = [
str(i).strip()
for i in platform_settings["id_whitelist"]
if str(i).strip() != ""
]
if len(whitelist) == 0:
# 白名单为空,不检查
return
@@ -45,23 +53,23 @@ class WhitelistCheckStage(Stage):
return
# 检查是否在白名单
if self.wl_ignore_admin_on_group:
if platform_settings["wl_ignore_admin_on_group"]:
if (
event.role == "admin"
and event.get_message_type() == MessageType.GROUP_MESSAGE
):
return
if self.wl_ignore_admin_on_friend:
if platform_settings["wl_ignore_admin_on_friend"]:
if (
event.role == "admin"
and event.get_message_type() == MessageType.FRIEND_MESSAGE
):
return
if (
event.unified_msg_origin not in self.whitelist
and str(event.get_group_id()).strip() not in self.whitelist
event.unified_msg_origin not in whitelist
and event_group_id not in whitelist
):
if self.wl_log:
if platform_settings["id_whitelist_log"]:
logger.info(
f"会话 ID {event.unified_msg_origin} 不在会话白名单中,已终止事件传播。请在配置文件中添加该会话 ID 到白名单。",
)

View File

@@ -159,10 +159,14 @@ class ProviderManager:
if provider_id not in self.inst_map:
raise ValueError(f"提供商 {provider_id} 不存在,无法设置。")
if umo:
await sp.session_put(
provider_path_map = {
ProviderType.CHAT_COMPLETION: "provider_settings.default_provider_id",
ProviderType.SPEECH_TO_TEXT: "provider_stt_settings.provider_id",
ProviderType.TEXT_TO_SPEECH: "provider_tts_settings.provider_id",
}
await self.acm.update_conf_overrides(
umo,
f"provider_perf_{provider_type.value}",
provider_id,
{provider_path_map[provider_type]: provider_id},
)
self._notify_provider_changed(provider_id, provider_type, umo)
return
@@ -225,18 +229,9 @@ class ProviderManager:
"""
provider = None
provider_id = None
if umo:
provider_id = sp.get(
f"provider_perf_{provider_type.value}",
None,
scope="umo",
scope_id=umo,
)
if provider_id:
provider = self.inst_map.get(provider_id)
config = self.acm.get_conf(umo)
if not provider:
# default setting
config = self.acm.get_conf(umo)
if provider_type == ProviderType.CHAT_COMPLETION:
provider_id = config["provider_settings"].get("default_provider_id")
provider = self.inst_map.get(provider_id)

View File

@@ -498,9 +498,6 @@ class Context:
Note:
如果不提供 umo 参数,将返回默认配置。
"""
if not umo:
# 使用默认配置
return self._config
return self.astrbot_config_mgr.get_conf(umo)
async def send_message(

View File

@@ -1,185 +0,0 @@
"""会话服务管理器 - 负责管理每个会话的LLM、TTS等服务的启停状态"""
from astrbot.core import logger, sp
from astrbot.core.platform.astr_message_event import AstrMessageEvent
class SessionServiceManager:
"""管理会话级别的服务启停状态包括LLM和TTS"""
# =============================================================================
# LLM 相关方法
# =============================================================================
@staticmethod
async def is_llm_enabled_for_session(session_id: str) -> bool:
"""检查LLM是否在指定会话中启用
Args:
session_id: 会话ID (unified_msg_origin)
Returns:
bool: True表示启用False表示禁用
"""
# 获取会话服务配置
session_services = await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_service_config",
default={},
)
# 如果配置了该会话的LLM状态返回该状态
llm_enabled = session_services.get("llm_enabled")
if llm_enabled is not None:
return llm_enabled
# 如果没有配置,默认为启用(兼容性考虑)
return True
@staticmethod
async def set_llm_status_for_session(session_id: str, enabled: bool) -> None:
"""设置LLM在指定会话中的启停状态
Args:
session_id: 会话ID (unified_msg_origin)
enabled: True表示启用False表示禁用
"""
session_config = (
await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_service_config",
default={},
)
or {}
)
session_config["llm_enabled"] = enabled
await sp.put_async(
scope="umo",
scope_id=session_id,
key="session_service_config",
value=session_config,
)
@staticmethod
async def should_process_llm_request(event: AstrMessageEvent) -> bool:
"""检查是否应该处理LLM请求
Args:
event: 消息事件
Returns:
bool: True表示应该处理False表示跳过
"""
session_id = event.unified_msg_origin
return await SessionServiceManager.is_llm_enabled_for_session(session_id)
# =============================================================================
# TTS 相关方法
# =============================================================================
@staticmethod
async def is_tts_enabled_for_session(session_id: str) -> bool:
"""检查TTS是否在指定会话中启用
Args:
session_id: 会话ID (unified_msg_origin)
Returns:
bool: True表示启用False表示禁用
"""
# 获取会话服务配置
session_services = await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_service_config",
default={},
)
# 如果配置了该会话的TTS状态返回该状态
tts_enabled = session_services.get("tts_enabled")
if tts_enabled is not None:
return tts_enabled
# 如果没有配置,默认为启用(兼容性考虑)
return True
@staticmethod
async def set_tts_status_for_session(session_id: str, enabled: bool) -> None:
"""设置TTS在指定会话中的启停状态
Args:
session_id: 会话ID (unified_msg_origin)
enabled: True表示启用False表示禁用
"""
session_config = (
await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_service_config",
default={},
)
or {}
)
session_config["tts_enabled"] = enabled
await sp.put_async(
scope="umo",
scope_id=session_id,
key="session_service_config",
value=session_config,
)
logger.info(
f"会话 {session_id} 的TTS状态已更新为: {'启用' if enabled else '禁用'}",
)
@staticmethod
async def should_process_tts_request(event: AstrMessageEvent) -> bool:
"""检查是否应该处理TTS请求
Args:
event: 消息事件
Returns:
bool: True表示应该处理False表示跳过
"""
session_id = event.unified_msg_origin
return await SessionServiceManager.is_tts_enabled_for_session(session_id)
# =============================================================================
# 会话整体启停相关方法
# =============================================================================
@staticmethod
async def is_session_enabled(session_id: str) -> bool:
"""检查会话是否整体启用
Args:
session_id: 会话ID (unified_msg_origin)
Returns:
bool: True表示启用False表示禁用
"""
# 获取会话服务配置
session_services = await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_service_config",
default={},
)
# 如果配置了该会话的整体状态,返回该状态
session_enabled = session_services.get("session_enabled")
if session_enabled is not None:
return session_enabled
# 如果没有配置,默认为启用(兼容性考虑)
return True

View File

@@ -1,101 +0,0 @@
"""会话插件管理器 - 负责管理每个会话的插件启停状态"""
from astrbot.core import logger, sp
from astrbot.core.platform.astr_message_event import AstrMessageEvent
class SessionPluginManager:
"""管理会话级别的插件启停状态"""
@staticmethod
async def is_plugin_enabled_for_session(
session_id: str,
plugin_name: str,
) -> bool:
"""检查插件是否在指定会话中启用
Args:
session_id: 会话ID (unified_msg_origin)
plugin_name: 插件名称
Returns:
bool: True表示启用False表示禁用
"""
# 获取会话插件配置
session_plugin_config = await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_plugin_config",
default={},
)
session_config = session_plugin_config.get(session_id, {})
enabled_plugins = session_config.get("enabled_plugins", [])
disabled_plugins = session_config.get("disabled_plugins", [])
# 如果插件在禁用列表中返回False
if plugin_name in disabled_plugins:
return False
# 如果插件在启用列表中返回True
if plugin_name in enabled_plugins:
return True
# 如果都没有配置,默认为启用(兼容性考虑)
return True
@staticmethod
async def filter_handlers_by_session(
event: AstrMessageEvent,
handlers: list,
) -> list:
"""根据会话配置过滤处理器列表
Args:
event: 消息事件
handlers: 原始处理器列表
Returns:
List: 过滤后的处理器列表
"""
from astrbot.core.star.star import star_map
session_id = event.unified_msg_origin
filtered_handlers = []
session_plugin_config = await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_plugin_config",
default={},
)
session_config = session_plugin_config.get(session_id, {})
disabled_plugins = session_config.get("disabled_plugins", [])
for handler in handlers:
# 获取处理器对应的插件
plugin = star_map.get(handler.handler_module_path)
if not plugin:
# 如果找不到插件元数据,允许执行(可能是系统插件)
filtered_handlers.append(handler)
continue
# 跳过保留插件(系统插件)
if plugin.reserved:
filtered_handlers.append(handler)
continue
if plugin.name is None:
continue
# 检查插件是否在当前会话中启用
if plugin.name in disabled_plugins:
logger.debug(
f"插件 {plugin.name} 在会话 {session_id} 中被禁用,跳过处理器 {handler.handler_name}",
)
else:
filtered_handlers.append(handler)
return filtered_handlers

View File

@@ -1,7 +1,7 @@
from pydantic import Field
from pydantic.dataclasses import dataclass
from astrbot.api import logger, sp
from astrbot.api import logger
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
@@ -30,41 +30,21 @@ async def retrieve_knowledge_base(
kb_mgr = context.kb_manager
config = context.get_config(umo=umo)
session_config = await sp.session_get(umo, "kb_config", default={})
if session_config and "kb_ids" in session_config:
kb_ids = session_config.get("kb_ids", [])
if not kb_ids:
logger.info(f"[知识库] 会话 {umo} 已被配置为不使用知识库")
return None
top_k = session_config.get("top_k", 5)
kb_names = []
invalid_kb_ids = []
for kb_id in kb_ids:
kb_helper = await kb_mgr.get_kb(kb_id)
if kb_helper:
kb_names.append(kb_helper.kb.kb_name)
else:
logger.warning(f"[知识库] 知识库不存在或未加载: {kb_id}")
invalid_kb_ids.append(kb_id)
if invalid_kb_ids:
logger.warning(
f"[知识库] 会话 {umo} 配置的以下知识库无效: {invalid_kb_ids}",
)
if not kb_names:
return None
logger.debug(f"[知识库] 使用会话级配置,知识库数量: {len(kb_names)}")
else:
kb_names = config.get("kb_names", [])
top_k = config.get("kb_final_top_k", 5)
logger.debug(f"[知识库] 使用全局配置,知识库数量: {len(kb_names)}")
kb_names = config.get("kb_names", [])
top_k = config.get("kb_final_top_k", 5)
logger.debug(f"[知识库] 使用配置文件,知识库数量: {len(kb_names)}")
top_k_fusion = config.get("kb_fusion_top_k", 20)
if not kb_names:
return None
all_kbs = [await kb_mgr.get_kb_by_name(kb) for kb in kb_names]
all_kbs = []
for kb_ref in kb_names:
kb_ref = str(kb_ref)
kb_helper = await kb_mgr.get_kb(kb_ref)
if not kb_helper:
kb_helper = await kb_mgr.get_kb_by_name(kb_ref)
all_kbs.append(kb_helper)
if check_all_kb(all_kbs):
logger.debug("所配置的所有知识库全为空,跳过检索过程")
return None

View File

@@ -1,6 +1,6 @@
import traceback
from astrbot.core import astrbot_config, logger
from astrbot.core import astrbot_config, logger, sp
from astrbot.core.agent.runners.deerflow.constants import (
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
DEERFLOW_PROVIDER_TYPE,
@@ -9,6 +9,9 @@ from astrbot.core.astrbot_config_mgr import AstrBotConfig, AstrBotConfigManager
from astrbot.core.db.migration.migra_45_to_46 import migrate_45_to_46
from astrbot.core.db.migration.migra_token_usage import migrate_token_usage
from astrbot.core.db.migration.migra_webchat_session import migrate_webchat_session
from astrbot.core.provider.entities import ProviderType
CONFIG_OVERRIDE_MIGRATION_DONE_KEY = "migration_config_overrides_v1_done"
def _migra_agent_runner_configs(conf: AstrBotConfig, ids_map: dict) -> None:
@@ -128,6 +131,98 @@ def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None:
logger.info("Provider-source structure migration completed")
async def _migrate_session_rules_to_config_overrides(
db,
acm: AstrBotConfigManager,
) -> None:
"""Migrate legacy UMO session rules into config override preferences.
Args:
db: Database helper used to migrate custom UMO names into aliases.
acm: Config manager used to write override paths.
"""
migration_done = await sp.global_get(CONFIG_OVERRIDE_MIGRATION_DONE_KEY, False)
if migration_done:
return
provider_path_map = {
f"provider_perf_{ProviderType.CHAT_COMPLETION.value}": (
"provider_settings.default_provider_id"
),
f"provider_perf_{ProviderType.SPEECH_TO_TEXT.value}": (
"provider_stt_settings.provider_id"
),
f"provider_perf_{ProviderType.TEXT_TO_SPEECH.value}": (
"provider_tts_settings.provider_id"
),
}
legacy_keys = {
"session_service_config",
"session_plugin_config",
"kb_config",
*provider_path_map.keys(),
}
prefs = await sp.session_get(None, None)
migrated_count = 0
for pref in prefs:
if pref.key not in legacy_keys:
continue
umo = pref.scope_id
value = pref.value.get("val") if isinstance(pref.value, dict) else None
override_paths = {}
if pref.key == "session_service_config" and isinstance(value, dict):
if "llm_enabled" in value:
override_paths["provider_settings.enable"] = bool(value["llm_enabled"])
if "tts_enabled" in value:
override_paths["provider_tts_settings.enable"] = bool(
value["tts_enabled"]
)
if "persona_id" in value:
override_paths["provider_settings.default_personality"] = (
value.get("persona_id") or ""
)
if value.get("session_enabled") is False:
override_paths["platform_settings.id_blacklist"] = [umo]
custom_name = str(value.get("custom_name") or "").strip()
if custom_name:
alias = await db.get_umo_alias(umo)
if not alias or not alias.user_alias:
await db.upsert_umo_alias(
umo,
alias.creator_sender_id if alias else "",
alias.auto_name if alias else None,
custom_name,
)
elif pref.key == "session_plugin_config" and isinstance(value, dict):
session_config = value.get(umo, value)
if isinstance(session_config, dict):
disabled_plugins = session_config.get("disabled_plugins")
if isinstance(disabled_plugins, list):
override_paths["plugin_disabled_set"] = disabled_plugins
elif pref.key == "kb_config" and isinstance(value, dict):
if "top_k" in value:
override_paths["kb_final_top_k"] = value["top_k"]
kb_ids = value.get("kb_ids")
if isinstance(kb_ids, list):
override_paths["kb_names"] = [
str(kb_id) for kb_id in kb_ids if str(kb_id).strip()
]
elif pref.key in provider_path_map and value:
override_paths[provider_path_map[pref.key]] = value
if override_paths:
await acm.update_conf_overrides(umo, override_paths)
migrated_count += 1
await sp.global_put(CONFIG_OVERRIDE_MIGRATION_DONE_KEY, True)
if migrated_count:
logger.info(
"Migrated %s legacy session rule preferences to config overrides.",
migrated_count,
)
async def migra(
db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager
) -> None:
@@ -156,6 +251,12 @@ async def migra(
logger.error(f"Migration for token_usage column failed: {e!s}")
logger.error(traceback.format_exc())
try:
await _migrate_session_rules_to_config_overrides(db, acm)
except Exception as e:
logger.error(f"Migration for session rule config overrides failed: {e!s}")
logger.error(traceback.format_exc())
# migra third party agent runner configs
_c = False
providers = astrbot_config["provider"]

View File

@@ -93,6 +93,7 @@ async def _list_conversations(
platforms: str,
message_types: str,
search: str,
user_id: str,
exclude_ids: str,
exclude_platforms: str,
):
@@ -103,6 +104,7 @@ async def _list_conversations(
platforms=platforms,
message_types=message_types,
search_query=search,
user_id=user_id,
exclude_ids=exclude_ids,
exclude_platforms=exclude_platforms,
)
@@ -116,6 +118,7 @@ async def list_conversations(
platforms: str = Query(default=""),
message_types: str = Query(default=""),
search: str = Query(default=""),
user_id: str = Query(default=""),
exclude_ids: str = Query(default=""),
exclude_platforms: str = Query(default=""),
_auth: AuthContext = Depends(require_data_scope),
@@ -128,6 +131,7 @@ async def list_conversations(
platforms=platforms,
message_types=message_types,
search=search,
user_id=user_id,
exclude_ids=exclude_ids,
exclude_platforms=exclude_platforms,
)
@@ -222,6 +226,7 @@ async def list_dashboard_conversations(
platforms: str = Query(default=""),
message_types: str = Query(default=""),
search: str = Query(default=""),
user_id: str = Query(default=""),
exclude_ids: str = Query(default=""),
exclude_platforms: str = Query(default=""),
_username: str = Depends(require_dashboard_user),
@@ -234,6 +239,7 @@ async def list_dashboard_conversations(
platforms=platforms,
message_types=message_types,
search=search,
user_id=user_id,
exclude_ids=exclude_ids,
exclude_platforms=exclude_platforms,
)

View File

@@ -28,6 +28,7 @@ from .subagents import router as subagents_router
from .t2i import router as t2i_router
from .tools import router as tools_router
from .updates import router as updates_router
from .workspaces import router as workspaces_router
API_V1_PREFIX = "/api/v1"
@@ -58,6 +59,7 @@ def build_api_router() -> APIRouter:
router.include_router(t2i_router)
router.include_router(personas_router)
router.include_router(updates_router)
router.include_router(workspaces_router)
router.include_router(open_api_router)
router.include_router(live_chat_router)
return router

View File

@@ -8,6 +8,9 @@ from astrbot.dashboard.responses import error, ok
from astrbot.dashboard.schemas import (
BatchSessionProviderRequest,
BatchSessionServiceRequest,
SessionAliasRequest,
SessionConfigOverrideDeleteRequest,
SessionConfigOverrideRequest,
SessionGroupRequest,
SessionRuleRequest,
UmoListRequest,
@@ -165,6 +168,82 @@ async def delete_session_rule(
return _unexpected_error("删除会话规则失败", exc)
@router.get("/sessions/config-overrides")
async def list_session_config_overrides(
page: int = Query(1),
page_size: int = Query(10),
search: str = Query(""),
umo: str = Query(""),
_auth: AuthContext = Depends(require_data_scope),
service: SessionManagementService = Depends(get_service),
):
try:
return ok(
await service.list_session_config_overrides(
page=page,
page_size=page_size,
search=search.strip(),
umo=umo.strip(),
)
)
except SessionManagementServiceError as exc:
return _service_error(exc)
except Exception as exc:
return _unexpected_error("获取会话配置覆盖项列表失败", exc)
@router.post("/sessions/config-overrides")
async def upsert_session_config_override(
payload: SessionConfigOverrideRequest,
_auth: AuthContext = Depends(require_data_scope),
service: SessionManagementService = Depends(get_service),
):
try:
return ok(
await service.update_session_config_override(
payload.model_dump(exclude_unset=True)
)
)
except SessionManagementServiceError as exc:
return _service_error(exc)
except Exception as exc:
return _unexpected_error("更新会话配置覆盖项失败", exc)
@router.post("/sessions/config-overrides/delete")
async def delete_session_config_override(
payload: SessionConfigOverrideDeleteRequest,
_auth: AuthContext = Depends(require_data_scope),
service: SessionManagementService = Depends(get_service),
):
try:
return ok(
await service.delete_session_config_override(
payload.model_dump(exclude_none=True)
)
)
except SessionManagementServiceError as exc:
return _service_error(exc)
except Exception as exc:
return _unexpected_error("删除会话配置覆盖项失败", exc)
@router.post("/sessions/aliases")
async def upsert_session_alias(
payload: SessionAliasRequest,
_auth: AuthContext = Depends(require_data_scope),
service: SessionManagementService = Depends(get_service),
):
try:
return ok(
await service.update_session_alias(payload.model_dump(exclude_unset=True))
)
except SessionManagementServiceError as exc:
return _service_error(exc)
except Exception as exc:
return _unexpected_error("更新会话备注失败", exc)
@router.patch("/sessions/provider")
async def update_session_provider(
payload: BatchSessionProviderRequest,

View File

@@ -0,0 +1,131 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, Query, Request
from astrbot.core.utils.datetime_utils import to_utc_isoformat
from astrbot.core.workspace import resolve_workspace_root_for_umo
from astrbot.dashboard.responses import ApiError, ok
from .auth import AuthContext, require_scope
router = APIRouter(tags=["Workspaces"])
async def require_data_scope(request: Request) -> AuthContext:
return await require_scope(request, "data")
def _format_mtime(path: Path) -> str | None:
"""Format a filesystem modified timestamp for API responses.
Args:
path: File or directory path.
Returns:
UTC ISO timestamp, or None when the timestamp cannot be read.
"""
try:
return to_utc_isoformat(
datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
)
except OSError:
return None
@router.get("/workspaces/by-umo")
async def list_umo_workspace_files(
request: Request,
umo: str = Query(..., min_length=1),
path: str | None = Query(default=None),
_auth: AuthContext = Depends(require_data_scope),
):
"""List files in the workspace resolved for one UMO.
Args:
request: FastAPI request with database state.
umo: Unified message origin.
path: Relative directory path under the workspace root.
_auth: Auth context.
Returns:
Workspace metadata and one directory level of file entries.
Raises:
ApiError: If the requested path escapes the workspace root.
"""
root = await resolve_workspace_root_for_umo(umo, request.app.state.db)
root = root.expanduser().resolve(strict=False)
raw_path = str(path or "").strip()
requested = root if raw_path in {"", "."} else root / raw_path
current = requested.resolve(strict=False)
if current != root and not current.is_relative_to(root):
raise ApiError("Path is outside the workspace", status_code=400)
if not root.exists() or not root.is_dir():
return ok(
{
"umo": umo,
"absolute_path": str(root),
"relative_path": "",
"exists": False,
"is_directory": False,
"items": [],
}
)
if not current.exists() or not current.is_dir():
raise ApiError("Workspace folder not found", status_code=404)
items = []
try:
children = sorted(
current.iterdir(),
key=lambda item: (
not (item.is_dir() and not item.is_symlink()),
item.name.lower(),
),
)
except OSError as exc:
raise ApiError(f"Failed to list workspace files: {exc!s}") from exc
for child in children:
try:
is_symlink = child.is_symlink()
stat_result = child.lstat() if is_symlink else child.stat()
if is_symlink:
item_type = "symlink"
elif child.is_dir():
item_type = "directory"
elif child.is_file():
item_type = "file"
else:
item_type = "other"
items.append(
{
"name": child.name,
"type": item_type,
"relative_path": child.relative_to(root).as_posix(),
"size_bytes": 0
if item_type == "directory"
else stat_result.st_size,
"modified_at": _format_mtime(child),
}
)
except OSError:
continue
return ok(
{
"umo": umo,
"absolute_path": str(root),
"relative_path": ""
if current == root
else current.relative_to(root).as_posix(),
"exists": True,
"is_directory": True,
"items": items,
}
)

View File

@@ -613,6 +613,29 @@ class SessionRuleRequest(OpenModel):
rule_value: Any = None
class SessionConfigOverrideRequest(OpenModel):
umo: str | None = None
path: str | None = None
value: Any = None
class SessionConfigOverrideDeleteRequest(OpenModel):
umo: str | None = None
umos: list[str] | None = None
scope: Literal["all", "group", "private", "custom_group"] | None = None
group_id: str | None = None
path: str | None = None
paths: list[str] | None = None
class SessionAliasRequest(OpenModel):
umo: str | None = None
umos: list[str] | None = None
scope: Literal["all", "group", "private", "custom_group"] | None = None
group_id: str | None = None
custom_name: str | None = None
class UmoListRequest(OpenModel):
umo: str | None = None
umos: list[str] | None = None

View File

@@ -40,6 +40,7 @@ class ConversationService:
platforms: str,
message_types: str,
search_query: str,
user_id: str,
exclude_ids: str,
exclude_platforms: str,
) -> dict:
@@ -62,6 +63,7 @@ class ConversationService:
platforms=platform_list,
message_types=message_type_list,
search_query=search_query,
user_id=user_id,
exclude_ids=exclude_id_list,
exclude_platforms=exclude_platform_list,
)

View File

@@ -110,27 +110,87 @@ class SessionManagementService:
page: int = 1,
page_size: int = 10,
search: str = "",
umo: str = "",
) -> tuple[dict, int]:
umo_rules = {}
config_mgr = self.core_lifecycle.astrbot_config_mgr
async with self.db_helper.get_db() as session:
session: AsyncSession
result = await session.execute(
select(Preference).where(
col(Preference.scope) == "umo",
col(Preference.key).in_(AVAILABLE_SESSION_RULE_KEYS),
col(Preference.key) == config_mgr.core_config_override_key,
)
)
prefs = result.scalars().all()
for pref in prefs:
umo_id = pref.scope_id
if umo_id not in umo_rules:
umo_rules[umo_id] = {}
if pref.key == "session_plugin_config" and umo_id in pref.value["val"]:
umo_rules[umo_id][pref.key] = pref.value["val"][umo_id]
else:
umo_rules[umo_id][pref.key] = pref.value["val"]
paths = config_mgr.normalize_conf_override_payload(
pref.value.get("val") if isinstance(pref.value, dict) else None
)
rules = {}
service_config = {}
if "provider_settings.enable" in paths:
service_config["llm_enabled"] = bool(
paths["provider_settings.enable"]
)
if "provider_tts_settings.enable" in paths:
service_config["tts_enabled"] = bool(
paths["provider_tts_settings.enable"]
)
if "provider_settings.default_personality" in paths:
service_config["persona_id"] = paths[
"provider_settings.default_personality"
]
if "platform_settings.id_blacklist" in paths:
blacklist = [
str(item).strip()
for item in paths["platform_settings.id_blacklist"]
if str(item).strip()
]
service_config["session_enabled"] = umo_id not in blacklist
if service_config:
rules["session_service_config"] = service_config
alias_map = await self.get_umo_alias_map(list(umo_rules.keys()))
if isinstance(paths.get("plugin_disabled_set"), list):
rules["session_plugin_config"] = {
"disabled_plugins": paths["plugin_disabled_set"]
}
kb_config = {}
if "kb_names" in paths:
kb_config["kb_names"] = paths["kb_names"]
if "kb_final_top_k" in paths:
kb_config["top_k"] = paths["kb_final_top_k"]
if kb_config:
rules["kb_config"] = kb_config
provider_rule_map = {
"provider_settings.default_provider_id": (
f"provider_perf_{ProviderType.CHAT_COMPLETION.value}"
),
"provider_stt_settings.provider_id": (
f"provider_perf_{ProviderType.SPEECH_TO_TEXT.value}"
),
"provider_tts_settings.provider_id": (
f"provider_perf_{ProviderType.TEXT_TO_SPEECH.value}"
),
}
for config_path, rule_key in provider_rule_map.items():
if config_path in paths:
rules[rule_key] = paths[config_path]
if rules:
umo_rules[umo_id] = rules
aliases = await self.db_helper.get_umo_aliases()
alias_map = build_umo_alias_map(aliases)
for alias in aliases:
alias_info = serialize_umo_alias(alias, alias.umo)
if alias_info.get("user_alias"):
umo_rules.setdefault(alias.umo, {}).setdefault(
"session_service_config", {}
)["custom_name"] = alias_info["user_alias"]
if search:
search_lower = search.lower()
@@ -155,6 +215,11 @@ class SessionManagementService:
filtered_rules[umo_id] = rules
umo_rules = filtered_rules
if umo:
umo_rules = {
umo_id: rules for umo_id, rules in umo_rules.items() if umo_id == umo
}
total = len(umo_rules)
all_umo_ids = list(umo_rules.keys())
start_idx = (page - 1) * page_size
@@ -169,12 +234,15 @@ class SessionManagementService:
page: int,
page_size: int,
search: str,
include_available_options: bool = True,
umo: str = "",
) -> dict:
page, page_size = self._normalize_page(page, page_size, default_page_size=10)
umo_rules, total = await self.get_umo_rules(
page=page,
page_size=page_size,
search=search,
umo=umo,
)
alias_map = await self.get_umo_alias_map(list(umo_rules.keys()))
@@ -186,6 +254,15 @@ class SessionManagementService:
for umo, rules in umo_rules.items()
]
result = {
"rules": rules_list,
"total": total,
"page": page,
"page_size": page_size,
}
if not include_available_options:
return result
provider_manager = self.core_lifecycle.provider_manager
persona_mgr = getattr(self.core_lifecycle, "persona_mgr", None)
plugin_manager = getattr(self.core_lifecycle, "plugin_manager", None)
@@ -222,24 +299,229 @@ class SessionManagementService:
except Exception as exc:
logger.warning(f"获取知识库列表失败: {exc!s}")
kb_name_to_id = {
kb["kb_name"]: kb["kb_id"]
for kb in available_kbs
if kb.get("kb_name") and kb.get("kb_id")
}
kb_id_set = {kb["kb_id"] for kb in available_kbs if kb.get("kb_id")}
for item in rules_list:
kb_config = item.get("rules", {}).get("kb_config")
if isinstance(kb_config, dict) and "kb_names" in kb_config:
kb_config["kb_ids"] = [
str(kb_ref)
if str(kb_ref) in kb_id_set
else kb_name_to_id[str(kb_ref)]
for kb_ref in kb_config["kb_names"]
if str(kb_ref) in kb_id_set or str(kb_ref) in kb_name_to_id
]
result.update(
{
"available_personas": available_personas,
"available_chat_providers": self._serialize_provider_insts(
getattr(provider_manager, "provider_insts", [])
),
"available_stt_providers": self._serialize_provider_insts(
getattr(provider_manager, "stt_provider_insts", [])
),
"available_tts_providers": self._serialize_provider_insts(
getattr(provider_manager, "tts_provider_insts", [])
),
"available_plugins": available_plugins,
"available_kbs": available_kbs,
"available_rule_keys": AVAILABLE_SESSION_RULE_KEYS,
}
)
return result
async def list_session_config_overrides(
self,
*,
page: int,
page_size: int,
search: str,
umo: str = "",
) -> dict:
"""List UMO config overrides without editor option payloads.
Args:
page: Page number.
page_size: Number of items per page.
search: Search keyword.
Returns:
Paginated config override rows.
"""
return await self.list_session_rules(
page=page,
page_size=page_size,
search=search,
umo=umo,
include_available_options=False,
)
async def update_session_config_override(self, data: object) -> dict:
"""Update one core config override path for a UMO.
Args:
data: Request payload containing umo, path, and value.
Returns:
Operation result with the updated UMO and config path.
Raises:
SessionManagementServiceError: If required payload fields are missing.
"""
payload = self._payload(data)
umo = str(payload.get("umo") or "").strip()
path = str(payload.get("path") or "").strip()
if not umo:
raise SessionManagementServiceError("缺少必要参数: umo")
if not path:
raise SessionManagementServiceError("缺少必要参数: path")
if "value" not in payload:
raise SessionManagementServiceError("缺少必要参数: value")
await self.core_lifecycle.astrbot_config_mgr.update_conf_overrides(
umo,
{path: payload.get("value")},
)
return {"message": f"配置覆盖项 {path} 已更新", "umo": umo, "path": path}
async def delete_session_config_override(self, data: object) -> dict:
"""Delete one or more core config override paths for a UMO.
Args:
data: Request payload containing UMO selection and path or paths.
Returns:
Operation result with updated UMOs and removed paths.
Raises:
SessionManagementServiceError: If required payload fields are missing.
"""
payload = self._payload(data)
umos = [
str(umo).strip()
for umo in (
payload.get("umos") if isinstance(payload.get("umos"), list) else []
)
if str(umo).strip()
]
umo = str(payload.get("umo") or "").strip()
if umo:
umos.append(umo)
scope = str(payload.get("scope") or "").strip()
group_id = str(payload.get("group_id") or "").strip()
if scope and not umos:
umos = await self.get_umos_by_scope(scope, group_id)
umos = list(dict.fromkeys(umos))
raw_paths = payload.get("paths")
paths = [
str(path).strip()
for path in (raw_paths if isinstance(raw_paths, list) else [])
if str(path).strip()
]
path = str(payload.get("path") or "").strip()
if path:
paths.append(path)
paths = list(dict.fromkeys(paths))
if not umos:
raise SessionManagementServiceError(
"缺少必要参数: umo、umos 或有效的 scope"
)
if not paths:
raise SessionManagementServiceError("缺少必要参数: path 或 paths")
success_count = 0
failed_umos = []
for target_umo in umos:
try:
await self.core_lifecycle.astrbot_config_mgr.remove_conf_overrides(
target_umo,
paths,
)
success_count += 1
except Exception as exc:
logger.error(
f"Failed to delete config override for {target_umo}: {exc!s}"
)
failed_umos.append(target_umo)
return {
"rules": rules_list,
"total": total,
"page": page,
"page_size": page_size,
"available_personas": available_personas,
"available_chat_providers": self._serialize_provider_insts(
getattr(provider_manager, "provider_insts", [])
),
"available_stt_providers": self._serialize_provider_insts(
getattr(provider_manager, "stt_provider_insts", [])
),
"available_tts_providers": self._serialize_provider_insts(
getattr(provider_manager, "tts_provider_insts", [])
),
"available_plugins": available_plugins,
"available_kbs": available_kbs,
"available_rule_keys": AVAILABLE_SESSION_RULE_KEYS,
"message": f"已删除 {success_count} 个会话的配置覆盖项",
"success_count": success_count,
"failed_count": len(failed_umos),
"failed_umos": failed_umos,
"paths": paths,
}
async def update_session_alias(self, data: object) -> dict:
"""Update UMO user aliases used as session remarks.
Args:
data: Request payload containing UMO selection and custom_name.
Returns:
Operation result with updated UMO count.
Raises:
SessionManagementServiceError: If required payload fields are missing.
"""
payload = self._payload(data)
if "custom_name" not in payload:
raise SessionManagementServiceError("缺少必要参数: custom_name")
umos = [
str(umo).strip()
for umo in (
payload.get("umos") if isinstance(payload.get("umos"), list) else []
)
if str(umo).strip()
]
umo = str(payload.get("umo") or "").strip()
if umo:
umos.append(umo)
scope = str(payload.get("scope") or "").strip()
group_id = str(payload.get("group_id") or "").strip()
if scope and not umos:
umos = await self.get_umos_by_scope(scope, group_id)
umos = list(dict.fromkeys(umos))
if not umos:
raise SessionManagementServiceError(
"缺少必要参数: umo、umos 或有效的 scope"
)
custom_name = str(payload.get("custom_name") or "").strip()
success_count = 0
failed_umos = []
for target_umo in umos:
try:
alias = await self.db_helper.get_umo_alias(target_umo)
if alias or custom_name:
await self.db_helper.upsert_umo_alias(
target_umo,
alias.creator_sender_id if alias else "",
alias.auto_name if alias else None,
custom_name or None,
)
success_count += 1
except Exception as exc:
logger.error(f"Failed to update alias for {target_umo}: {exc!s}")
failed_umos.append(target_umo)
return {
"message": f"已更新 {success_count} 个会话备注",
"success_count": success_count,
"failed_count": len(failed_umos),
"failed_umos": failed_umos,
}
async def update_session_rule(self, data: object) -> dict:
@@ -255,10 +537,72 @@ class SessionManagementService:
if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
raise SessionManagementServiceError(f"不支持的规则键: {rule_key}")
if rule_key == "session_plugin_config":
rule_value = {umo: rule_value}
override_paths = {}
remove_override_paths = []
if rule_key == "session_service_config" and isinstance(rule_value, dict):
if "llm_enabled" in rule_value:
override_paths["provider_settings.enable"] = bool(
rule_value["llm_enabled"]
)
if "tts_enabled" in rule_value:
override_paths["provider_tts_settings.enable"] = bool(
rule_value["tts_enabled"]
)
if "persona_id" in rule_value:
override_paths["provider_settings.default_personality"] = (
rule_value.get("persona_id") or ""
)
if "session_enabled" in rule_value:
if rule_value["session_enabled"] is False:
override_paths["platform_settings.id_blacklist"] = [umo]
else:
remove_override_paths.append("platform_settings.id_blacklist")
if "custom_name" in rule_value:
custom_name = str(rule_value.get("custom_name") or "").strip()
alias = await self.db_helper.get_umo_alias(umo)
await self.db_helper.upsert_umo_alias(
umo,
alias.creator_sender_id if alias else "",
alias.auto_name if alias else None,
custom_name or None,
)
elif rule_key == "session_plugin_config" and isinstance(rule_value, dict):
disabled_plugins = rule_value.get("disabled_plugins")
if isinstance(disabled_plugins, list):
override_paths["plugin_disabled_set"] = disabled_plugins
elif rule_key == "kb_config" and isinstance(rule_value, dict):
if "top_k" in rule_value:
override_paths["kb_final_top_k"] = rule_value["top_k"]
kb_ids = rule_value.get("kb_ids")
if isinstance(kb_ids, list):
override_paths["kb_names"] = [
str(kb_id) for kb_id in kb_ids if str(kb_id).strip()
]
else:
provider_path_map = {
f"provider_perf_{ProviderType.CHAT_COMPLETION.value}": (
"provider_settings.default_provider_id"
),
f"provider_perf_{ProviderType.SPEECH_TO_TEXT.value}": (
"provider_stt_settings.provider_id"
),
f"provider_perf_{ProviderType.TEXT_TO_SPEECH.value}": (
"provider_tts_settings.provider_id"
),
}
if rule_key in provider_path_map and rule_value:
override_paths[provider_path_map[rule_key]] = rule_value
await sp.session_put(umo, rule_key, rule_value)
if override_paths:
await self.core_lifecycle.astrbot_config_mgr.update_conf_overrides(
umo,
override_paths,
)
if remove_override_paths:
await self.core_lifecycle.astrbot_config_mgr.remove_conf_overrides(
umo,
remove_override_paths,
)
return {"message": f"规则 {rule_key} 已更新", "umo": umo}
async def delete_session_rule(self, data: object) -> dict:
@@ -272,10 +616,62 @@ class SessionManagementService:
if rule_key:
if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
raise SessionManagementServiceError(f"不支持的规则键: {rule_key}")
await sp.session_remove(umo, rule_key)
if rule_key == "session_service_config":
await self.core_lifecycle.astrbot_config_mgr.remove_conf_overrides(
umo,
[
"provider_settings.enable",
"provider_tts_settings.enable",
"provider_settings.default_personality",
"platform_settings.id_blacklist",
],
)
alias = await self.db_helper.get_umo_alias(umo)
if alias:
await self.db_helper.upsert_umo_alias(
umo,
alias.creator_sender_id,
alias.auto_name,
None,
)
elif rule_key == "session_plugin_config":
await self.core_lifecycle.astrbot_config_mgr.remove_conf_overrides(
umo,
["plugin_disabled_set"],
)
elif rule_key == "kb_config":
await self.core_lifecycle.astrbot_config_mgr.remove_conf_overrides(
umo,
["kb_names", "kb_final_top_k"],
)
else:
provider_path_map = {
f"provider_perf_{ProviderType.CHAT_COMPLETION.value}": (
"provider_settings.default_provider_id"
),
f"provider_perf_{ProviderType.SPEECH_TO_TEXT.value}": (
"provider_stt_settings.provider_id"
),
f"provider_perf_{ProviderType.TEXT_TO_SPEECH.value}": (
"provider_tts_settings.provider_id"
),
}
if rule_key in provider_path_map:
await self.core_lifecycle.astrbot_config_mgr.remove_conf_overrides(
umo,
[provider_path_map[rule_key]],
)
return {"message": f"规则 {rule_key} 已删除", "umo": umo}
await sp.clear_async("umo", umo)
alias = await self.db_helper.get_umo_alias(umo)
if alias:
await self.db_helper.upsert_umo_alias(
umo,
alias.creator_sender_id,
alias.auto_name,
None,
)
return {"message": "所有规则已删除", "umo": umo}
async def delete_session_rules(self, data: object) -> dict:
@@ -306,9 +702,9 @@ class SessionManagementService:
for umo in umos:
try:
if rule_key:
await sp.session_remove(umo, rule_key)
await self.delete_session_rule({"umo": umo, "rule_key": rule_key})
else:
await sp.clear_async("umo", umo)
await self.delete_session_rule({"umo": umo})
success_count += 1
except Exception as exc:
logger.error(f"删除 umo {umo} 的规则失败: {exc!s}")
@@ -458,24 +854,24 @@ class SessionManagementService:
for umo in umos:
try:
session_config = (
sp.get("session_service_config", {}, scope="umo", scope_id=umo)
or {}
)
override_paths = {}
if llm_enabled is not None:
session_config["llm_enabled"] = llm_enabled
override_paths["provider_settings.enable"] = bool(llm_enabled)
if tts_enabled is not None:
session_config["tts_enabled"] = tts_enabled
override_paths["provider_tts_settings.enable"] = bool(tts_enabled)
if session_enabled is not None:
session_config["session_enabled"] = session_enabled
sp.put(
"session_service_config",
session_config,
scope="umo",
scope_id=umo,
)
if session_enabled is False:
override_paths["platform_settings.id_blacklist"] = [umo]
else:
await self.core_lifecycle.astrbot_config_mgr.remove_conf_overrides(
umo,
["platform_settings.id_blacklist"],
)
if override_paths:
await self.core_lifecycle.astrbot_config_mgr.update_conf_overrides(
umo,
override_paths,
)
success_count += 1
except Exception as exc:
logger.error(f"更新 {umo} 服务状态失败: {exc!s}")

File diff suppressed because one or more lines are too long

View File

@@ -550,6 +550,34 @@ export type ReorderRequest = {
}>;
};
export type SessionAliasRequest = {
umo?: string;
umos?: Array<(string)>;
scope?: 'all' | 'group' | 'private' | 'custom_group';
group_id?: string;
custom_name: string;
[key: string]: unknown | string;
};
export type scope = 'all' | 'group' | 'private' | 'custom_group';
export type SessionConfigOverrideDeleteRequest = {
umo?: string;
umos?: Array<(string)>;
scope?: 'all' | 'group' | 'private' | 'custom_group';
group_id?: string;
path?: string;
paths?: Array<(string)>;
[key: string]: unknown | string;
};
export type SessionConfigOverrideRequest = {
umo: string;
path: string;
value: unknown;
[key: string]: unknown | string;
};
export type SessionGroupRequest = {
name?: string;
umos?: Array<(string)>;
@@ -623,8 +651,6 @@ export type UmoListRequest = {
rule_key?: string;
};
export type scope = 'all' | 'group' | 'private' | 'custom_group';
export type UpdateAccountRequest = {
password: string;
new_password?: string;
@@ -2952,6 +2978,7 @@ export type ListSessionRulesData = {
page?: number;
page_size?: number;
search?: string;
umo?: string;
};
};
@@ -2975,6 +3002,43 @@ export type DeleteSessionRulesResponse = (SuccessEnvelope);
export type DeleteSessionRulesError = unknown;
export type ListSessionConfigOverridesData = {
query?: {
page?: number;
page_size?: number;
search?: string;
umo?: string;
};
};
export type ListSessionConfigOverridesResponse = (SuccessEnvelope);
export type ListSessionConfigOverridesError = unknown;
export type UpsertSessionConfigOverrideData = {
body: SessionConfigOverrideRequest;
};
export type UpsertSessionConfigOverrideResponse = (SuccessEnvelope);
export type UpsertSessionConfigOverrideError = unknown;
export type DeleteSessionConfigOverrideData = {
body: SessionConfigOverrideDeleteRequest;
};
export type DeleteSessionConfigOverrideResponse = (SuccessEnvelope);
export type DeleteSessionConfigOverrideError = unknown;
export type UpsertSessionAliasData = {
body: SessionAliasRequest;
};
export type UpsertSessionAliasResponse = (SuccessEnvelope);
export type UpsertSessionAliasError = unknown;
export type BatchUpdateSessionProviderData = {
body: BatchSessionProviderRequest;
};
@@ -3124,6 +3188,17 @@ export type ExportConversationsResponse = (unknown);
export type ExportConversationsError = unknown;
export type ListUmoWorkspaceFilesData = {
query: {
path?: string;
umo: string;
};
};
export type ListUmoWorkspaceFilesResponse = (SuccessEnvelope);
export type ListUmoWorkspaceFilesError = unknown;
export type GetStatsData = {
query?: {
offset_sec?: number;

View File

@@ -34,6 +34,7 @@ import {
type GhproxyTestRequest,
type KnowledgeBaseCreateRequest,
type KnowledgeBaseRequest,
type ListUmoWorkspaceFilesData,
type LoginRequest,
type ListConversationsData,
type McpServerConfig,
@@ -46,6 +47,9 @@ import {
type BatchSessionProviderRequest,
type BatchSessionServiceRequest,
type SetupAuthRequest,
type SessionAliasRequest,
type SessionConfigOverrideDeleteRequest,
type SessionConfigOverrideRequest,
type SessionGroupRequest,
type SessionRuleRequest,
type UmoListRequest,
@@ -163,6 +167,7 @@ export interface SessionRuleListParams {
page?: number;
page_size?: number;
search?: string;
umo?: string;
}
export interface ChatSessionListParams {
@@ -942,12 +947,30 @@ export const sessionApi = {
openApiV1.listSessionRules({ query: generatedQuery(params) }),
);
},
listConfigOverrides(params?: SessionRuleListParams) {
return typed<any>(
openApiV1.listSessionConfigOverrides({ query: generatedQuery(params) }),
);
},
upsertRule(payload: SessionRuleRequest) {
return typed<any>(openApiV1.upsertSessionRule({ body: payload }));
},
deleteRules(payload: UmoListRequest) {
return typed<any>(openApiV1.deleteSessionRules({ body: payload }));
},
upsertConfigOverride(payload: SessionConfigOverrideRequest) {
return typed<any>(
openApiV1.upsertSessionConfigOverride({ body: payload }),
);
},
deleteConfigOverride(payload: SessionConfigOverrideDeleteRequest) {
return typed<any>(
openApiV1.deleteSessionConfigOverride({ body: payload }),
);
},
upsertAlias(payload: SessionAliasRequest) {
return typed<any>(openApiV1.upsertSessionAlias({ body: payload }));
},
batchUpdateProvider(payload: BatchSessionProviderRequest) {
return typed<any>(
openApiV1.batchUpdateSessionProvider({ body: payload }),
@@ -1683,6 +1706,16 @@ export const conversationApi = {
},
};
export const workspaceApi = {
byUmo(params: ListUmoWorkspaceFilesData['query']) {
return typed<any>(
openApiV1.listUmoWorkspaceFiles({
query: params,
}),
);
},
};
export const statsApi = {
get(offsetSec?: number) {
return typed<any>(

View File

@@ -323,7 +323,13 @@ function getSpecialSubtype(value) {
<div v-if="createSelectorModel(itemKey).value && createSelectorModel(itemKey).value.length > 0"
class="selected-plugins-full-width">
<div class="plugins-header">
<small class="text-grey">{{ t('core.shared.pluginSetSelector.selectedPluginsLabel') }}</small>
<small class="text-grey">
{{
itemKey.split('.').pop() === 'plugin_disabled_set'
? t('core.shared.pluginSetSelector.disabledPluginsLabel')
: t('core.shared.pluginSetSelector.selectedPluginsLabel')
}}
</small>
</div>
<div class="d-flex flex-wrap ga-2 mt-2">
<v-chip v-for="plugin in (createSelectorModel(itemKey).value || [])" :key="plugin" size="small" label
@@ -410,7 +416,13 @@ function getSpecialSubtype(value) {
<div v-if="createSelectorModel(itemKey).value && createSelectorModel(itemKey).value.length > 0"
class="selected-plugins-full-width">
<div class="plugins-header">
<small class="text-grey">{{ t('core.shared.pluginSetSelector.selectedPluginsLabel') }}</small>
<small class="text-grey">
{{
itemKey.split('.').pop() === 'plugin_disabled_set'
? t('core.shared.pluginSetSelector.disabledPluginsLabel')
: t('core.shared.pluginSetSelector.selectedPluginsLabel')
}}
</small>
</div>
<div class="d-flex flex-wrap ga-2 mt-2">
<v-chip v-for="plugin in (createSelectorModel(itemKey).value || [])" :key="plugin" size="small" label

View File

@@ -40,7 +40,15 @@
<KnowledgeBaseSelector :model-value="modelValue" @update:model-value="emitUpdate" />
</template>
<template v-else-if="itemMeta?._special === 'select_plugin_set'">
<PluginSetSelector :model-value="modelValue" @update:model-value="emitUpdate" />
<PluginSetSelector
:model-value="modelValue"
@update:model-value="emitUpdate"
:show-all-option="!isDisabledPluginSet"
:not-selected-label="isDisabledPluginSet ? t('core.shared.pluginSetSelector.noDisabledPlugins') : ''"
:none-label="isDisabledPluginSet ? t('core.shared.pluginSetSelector.noDisabledPlugins') : ''"
:custom-label="isDisabledPluginSet ? t('core.shared.pluginSetSelector.selectDisabledPlugins') : ''"
:button-text="isDisabledPluginSet ? t('core.shared.pluginSetSelector.selectDisabledPlugins') : ''"
/>
</template>
<template v-else-if="itemMeta?._special === 't2i_template'">
<T2ITemplateEditor />
@@ -307,6 +315,8 @@ const listSelectItems = computed(() =>
: []
)
const isDisabledPluginSet = computed(() => props.configKey?.split('.').pop() === 'plugin_disabled_set')
function toNumber(val) {
const n = parseFloat(val)
return isNaN(n) ? 0 : n

View File

@@ -4,7 +4,7 @@
<div class="d-flex align-center justify-space-between mb-2">
<div class="flex-grow-1">
<span v-if="!modelValue || modelValue.length === 0" style="color: rgb(var(--v-theme-primaryText));">
{{ tm('pluginSetSelector.notSelected') }}
{{ notSelectedLabel || tm('pluginSetSelector.notSelected') }}
</span>
<span v-else-if="isAllPlugins" style="color: rgb(var(--v-theme-primaryText));">
{{ tm('pluginSetSelector.allPlugins') }}
@@ -32,19 +32,20 @@
<div v-if="!loading">
<!-- 预设选项 -->
<v-radio-group v-model="selectionMode" class="mb-4" hide-details>
<v-radio
<v-radio
v-if="showAllOption"
value="all"
:label="tm('pluginSetSelector.enableAll')"
:label="allLabel || tm('pluginSetSelector.enableAll')"
color="primary"
></v-radio>
<v-radio
value="none"
:label="tm('pluginSetSelector.enableNone')"
:label="noneLabel || tm('pluginSetSelector.enableNone')"
color="primary"
></v-radio>
<v-radio
value="custom"
:label="tm('pluginSetSelector.customSelect')"
:label="customLabel || tm('pluginSetSelector.customSelect')"
color="primary"
></v-radio>
</v-radio-group>
@@ -120,6 +121,26 @@ const props = defineProps({
maxDisplayItems: {
type: Number,
default: 3
},
showAllOption: {
type: Boolean,
default: true
},
allLabel: {
type: String,
default: ''
},
noneLabel: {
type: String,
default: ''
},
customLabel: {
type: String,
default: ''
},
notSelectedLabel: {
type: String,
default: ''
}
})
@@ -138,7 +159,7 @@ const pluginDescription = (plugin) => pluginDesc(plugin)
// 判断是否为"所有插件"模式
const isAllPlugins = computed(() => {
return props.modelValue && props.modelValue.length === 1 && props.modelValue[0] === '*'
return props.showAllOption && props.modelValue && props.modelValue.length === 1 && props.modelValue[0] === '*'
})
// 移除插件
@@ -154,7 +175,7 @@ watch(() => props.modelValue, (newValue) => {
if (!newValue || newValue.length === 0) {
selectionMode.value = 'none'
selectedPlugins.value = []
} else if (newValue.length === 1 && newValue[0] === '*') {
} else if (props.showAllOption && newValue.length === 1 && newValue[0] === '*') {
selectionMode.value = 'all'
selectedPlugins.value = []
} else {
@@ -195,7 +216,7 @@ function confirmSelection() {
switch (selectionMode.value) {
case 'all':
newValue = ['*']
newValue = props.showAllOption ? ['*'] : []
break
case 'none':
newValue = []
@@ -215,7 +236,7 @@ function cancelSelection() {
if (currentValue.length === 0) {
selectionMode.value = 'none'
selectedPlugins.value = []
} else if (currentValue.length === 1 && currentValue[0] === '*') {
} else if (props.showAllOption && currentValue.length === 1 && currentValue[0] === '*') {
selectionMode.value = 'all'
selectedPlugins.value = []
} else {

View File

@@ -59,6 +59,7 @@ export class I18nLoader {
{ name: 'features/alkaid/memory', path: 'features/alkaid/memory.json' },
{ name: 'features/persona', path: 'features/persona.json' },
{ name: 'features/welcome', path: 'features/welcome.json' },
{ name: 'features/observer', path: 'features/observer.json' },
// 消息模块
{ name: 'messages/errors', path: 'messages/errors.json' },

View File

@@ -19,6 +19,7 @@
"components": "Handlers"
},
"conversation": "Conversations",
"observer": "Observer",
"sessionManagement": "Custom Rules",
"console": "Console",
"trace": "Trace",

View File

@@ -30,6 +30,9 @@
"notActivated": "Not activated",
"note": "*System plugins and disabled plugins are not shown.",
"selectedPluginsLabel": "Selected Plugins:",
"disabledPluginsLabel": "Disabled Plugins:",
"noDisabledPlugins": "No disabled plugins",
"selectDisabledPlugins": "Select disabled plugins",
"allPluginsLabel": "All Plugins"
},
"providerSelector": {

View File

@@ -807,6 +807,10 @@
"description": "Whitelist ID List",
"hint": "Use /sid to get IDs. If the list is empty, it means whitelist is disabled (all IDs are in the whitelist)."
},
"id_blacklist": {
"description": "Blacklist ID List",
"hint": "Use /sid to get IDs. Sessions in the blacklist are rejected, and blacklist rules take priority over the whitelist."
},
"id_whitelist_log": {
"description": "Output Logs",
"hint": "When enabled, INFO level logs will be output when a message doesn't pass the whitelist."
@@ -933,6 +937,10 @@
"plugin_set": {
"description": "Available Plugins",
"hint": "All non-disabled plugins are enabled by default. If a plugin is disabled on the plugins page, selections here will not take effect."
},
"plugin_disabled_set": {
"description": "Disabled Plugins",
"hint": "Plugins explicitly listed here will not trigger in sessions."
}
}
},

View File

@@ -33,8 +33,9 @@
"onceAt": "One-off · {time}",
"runAt": "Run at: {time}",
"nextRun": "Next run: {time}",
"dailyAt": "Daily at {time}",
"weeklyAt": "Every {day} at {time}",
"dailyAt": "Daily · {time}",
"weeklyAt": "{day} · {time}",
"weekdaySeparator": ", ",
"monthlyAt": "Monthly on day {day} at {time}",
"everyMinutes": "Every {count} minutes",
"everyHours": "Every {count} hours",

View File

@@ -0,0 +1,76 @@
{
"title": "Observer",
"searchPlaceholder": "Search UMO, alias, or session ID",
"emptyTitle": "What do you want to inspect?",
"emptySubtitle": "Search a UMO to inspect its conversations, custom rules, workspace, and trace.",
"knownSources": "Message Sources",
"home": {
"recent": "Recent Sources",
"resources": "More Resources",
"noSources": "No message sources yet",
"noMatches": "No matching UMO",
"resourceCards": {
"rules": "Custom Rules",
"rulesHint": "Manage config overrides",
"conversation": "Conversation Viewer",
"conversationHint": "Browse conversation data",
"trace": "Trace",
"traceHint": "Inspect execution trace",
"stats": "Data Dashboard",
"statsHint": "View runtime metrics"
}
},
"detail": {
"title": "UMO Detail",
"config": "Config Profile",
"route": "Route",
"defaultConfig": "Default Config",
"noRoute": "No dedicated route",
"copy": "Copy UMO",
"openChat": "Open ChatUI",
"openConfig": "View config profile",
"configDrawerTitle": "Config Profile",
"configDrawerIdLabel": "Config ID"
},
"resources": {
"conversation": "Conversation",
"overrides": "Custom Rules",
"workspace": "Workspace",
"trace": "Trace"
},
"conversation": {
"title": "Conversation Preview",
"count": "{count} items",
"empty": "No conversation data",
"select": "Select a conversation to preview",
"latest": "Latest Conversation"
},
"tokens": {
"title": "Token Usage",
"window": "24H",
"unit": "Last 24 hours"
},
"overrides": {
"title": "Config Overrides",
"count": "{count} overrides",
"empty": "No config overrides for this UMO",
"open": "Manage Custom Rules"
},
"workspace": {
"title": "Workspace",
"empty": "Workspace is empty",
"missing": "Workspace is unavailable",
"copyPath": "Copy workspace path",
"parent": "Parent folder",
"root": "Root"
},
"trace": {
"title": "Trace",
"hint": "For now this shows the full trace stream. UMO filtering can be added later."
},
"actions": {
"refresh": "Refresh",
"open": "Open",
"back": "Back"
}
}

View File

@@ -56,9 +56,25 @@
"ruleEditor": {
"title": "Edit Custom Rules",
"description": "Configure custom rules for this session. These rules take priority over global settings.",
"overrideCount": "{count} overrides",
"groups": {
"ai": "AI Settings",
"platform": "Platform Settings"
},
"note": {
"title": "General",
"source": "UMO note"
},
"addOverride": {
"select": "Select a setting to override",
"button": "Add Override",
"empty": "No configuration overrides added",
"remove": "Remove override"
},
"serviceConfig": {
"title": "Service Configuration",
"sessionEnabled": "Enable Session",
"sessionEnabled": "Allow Interaction",
"sessionEnabledHint": "When disabled, this source cannot communicate with the system.",
"llmEnabled": "Enable LLM",
"ttsEnabled": "Enable TTS",
"customName": "Custom Name"
@@ -77,13 +93,14 @@
"pluginConfig": {
"title": "Plugin Configuration",
"disabledPlugins": "Disabled Plugins",
"noneDisabled": "No disabled plugins",
"selectDisabled": "Select disabled plugins",
"hint": "Select plugins to disable for this session. Unselected plugins will remain enabled."
},
"kbConfig": {
"title": "Knowledge Base Configuration",
"selectKbs": "Select Knowledge Bases",
"topK": "Top K Results",
"enableRerank": "Enable Reranking"
"topK": "Top K Results"
}
},
"deleteConfirm": {

View File

@@ -18,8 +18,9 @@
"config": "Конфигурация",
"chat": "Чат",
"cron": "Запланированные задачи",
"conversation": "Данные диалогов",
"sessionManagement": "Пользовательские правила",
"conversation": "Данные диалогов",
"observer": "Observer",
"sessionManagement": "Пользовательские правила",
"console": "Логи платформы",
"trace": "Трассировка",
"alkaid": "Alkaid Lab",
@@ -47,4 +48,4 @@
"system": "Системная конфигурация"
},
"pluginWebui": "Страницы плагинов"
}
}

View File

@@ -30,6 +30,9 @@
"notActivated": "Не активирован",
"note": "*Системные и уже выключенные в настройках плагины не отображаются.",
"selectedPluginsLabel": "Выбранные плагины:",
"disabledPluginsLabel": "Отключенные плагины:",
"noDisabledPlugins": "Не отключать плагины",
"selectDisabledPlugins": "Выбрать отключенные плагины",
"allPluginsLabel": "Все плагины"
},
"providerSelector": {

View File

@@ -808,6 +808,10 @@
"description": "Список разрешенных ID",
"hint": "Используйте /sid для получения ID."
},
"id_blacklist": {
"description": "Список запрещенных ID",
"hint": "Используйте /sid для получения ID. Сессии из этого списка отклоняются до проверки белого списка."
},
"id_whitelist_log": {
"description": "Выводить логи",
"hint": "Логировать попытки доступа от пользователей не из белого списка."
@@ -934,6 +938,10 @@
"plugin_set": {
"description": "Доступные плагины",
"hint": "Все невыключенные плагины включены по умолчанию. Если плагин отключен на странице плагинов, выбор здесь не будет иметь силы."
},
"plugin_disabled_set": {
"description": "Отключенные плагины",
"hint": "Плагины из этого списка не будут срабатывать в сессиях."
}
}
},

View File

@@ -33,8 +33,9 @@
"onceAt": "Один раз · {time}",
"runAt": "Время запуска: {time}",
"nextRun": "Следующий запуск: {time}",
"dailyAt": "Каждый день в {time}",
"weeklyAt": "Каждый {day} в {time}",
"dailyAt": "Каждый день · {time}",
"weeklyAt": "{day} · {time}",
"weekdaySeparator": ", ",
"monthlyAt": "Каждый месяц, день {day}, {time}",
"everyMinutes": "Каждые {count} мин.",
"everyHours": "Каждые {count} ч.",

View File

@@ -0,0 +1,76 @@
{
"title": "Observer",
"searchPlaceholder": "Поиск UMO, заметки или session ID",
"emptyTitle": "Что проверить?",
"emptySubtitle": "Найдите UMO, чтобы посмотреть диалоги, правила, workspace и trace.",
"knownSources": "Источники",
"home": {
"recent": "Недавние источники",
"resources": "Еще ресурсы",
"noSources": "Источников пока нет",
"noMatches": "UMO не найден",
"resourceCards": {
"rules": "Правила",
"rulesHint": "Настроить overrides",
"conversation": "Диалоги",
"conversationHint": "Смотреть историю",
"trace": "Trace",
"traceHint": "Смотреть выполнение",
"stats": "Дашборд",
"statsHint": "Смотреть метрики"
}
},
"detail": {
"title": "UMO Detail",
"config": "Профиль конфигурации",
"route": "Маршрут",
"defaultConfig": "Конфигурация по умолчанию",
"noRoute": "Нет отдельного маршрута",
"copy": "Копировать UMO",
"openChat": "Открыть ChatUI",
"openConfig": "Открыть профиль",
"configDrawerTitle": "Профиль конфигурации",
"configDrawerIdLabel": "ID конфигурации"
},
"resources": {
"conversation": "Диалоги",
"overrides": "Правила",
"workspace": "Workspace",
"trace": "Trace"
},
"conversation": {
"title": "Предпросмотр диалога",
"count": "{count} шт.",
"empty": "Нет данных диалога",
"select": "Выберите диалог для просмотра",
"latest": "Последний диалог"
},
"tokens": {
"title": "Расход токенов",
"window": "24H",
"unit": "За последние 24 часа"
},
"overrides": {
"title": "Overrides конфигурации",
"count": "{count} overrides",
"empty": "Для этого UMO нет overrides",
"open": "Управлять правилами"
},
"workspace": {
"title": "Workspace",
"empty": "Workspace пуст",
"missing": "Workspace недоступен",
"copyPath": "Скопировать путь workspace",
"parent": "Вверх",
"root": "Корень"
},
"trace": {
"title": "Trace",
"hint": "Сейчас отображается полный trace stream. Фильтр UMO можно добавить позже."
},
"actions": {
"refresh": "Обновить",
"open": "Открыть",
"back": "Назад"
}
}

View File

@@ -56,9 +56,25 @@
"ruleEditor": {
"title": "Редактор правил",
"description": "Настройте поведение для этой сессии. Настройки ниже перекроют глобальный конфиг.",
"overrideCount": "Переопределений: {count}",
"groups": {
"ai": "AI-настройки",
"platform": "Платформа"
},
"note": {
"title": "Общие",
"source": "UMO-заметка"
},
"addOverride": {
"select": "Выберите настройку для переопределения",
"button": "Добавить",
"empty": "Переопределения не добавлены",
"remove": "Удалить переопределение"
},
"serviceConfig": {
"title": "Сервисные настройки",
"sessionEnabled": "Обрабатывать сообщения",
"sessionEnabled": "Разрешить взаимодействие",
"sessionEnabledHint": "Если отключено, этот источник не сможет общаться с системой.",
"llmEnabled": "Использовать LLM",
"ttsEnabled": "Использовать TTS",
"customName": "Заметка для сессии"
@@ -77,13 +93,14 @@
"pluginConfig": {
"title": "Плагины",
"disabledPlugins": "Отключенные плагины",
"noneDisabled": "Не отключать плагины",
"selectDisabled": "Выбрать отключенные плагины",
"hint": "Выберите плагины, которые нужно ОТКЛЮЧИТЬ в этой сессии. Остальные останутся активными."
},
"kbConfig": {
"title": "База знаний",
"selectKbs": "Выбор баз знаний",
"topK": "Количество результатов (Top K)",
"enableRerank": "Использовать Rerank"
"topK": "Количество результатов (Top K)"
}
},
"deleteConfirm": {

View File

@@ -19,6 +19,7 @@
"chat": "聊天",
"cron": "未来任务",
"conversation": "对话数据",
"observer": "Observer",
"sessionManagement": "自定义规则",
"console": "平台日志",
"trace": "追踪",

View File

@@ -30,6 +30,9 @@
"notActivated": "未激活",
"note": "*不显示系统插件和已经在插件页禁用的插件。",
"selectedPluginsLabel": "已选择的插件:",
"disabledPluginsLabel": "已禁用的插件:",
"noDisabledPlugins": "不禁用插件",
"selectDisabledPlugins": "选择禁用插件",
"allPluginsLabel": "所有插件"
},
"providerSelector": {

View File

@@ -809,6 +809,10 @@
"description": "白名单 ID 列表",
"hint": "使用 /sid 获取 ID。列表为空时表示该白名单不启用即所有 ID 都在白名单内)。"
},
"id_blacklist": {
"description": "黑名单 ID 列表",
"hint": "使用 /sid 获取 ID。命中黑名单的会话会被拒绝黑名单优先于白名单。"
},
"id_whitelist_log": {
"description": "输出日志",
"hint": "启用后,当一条消息没通过白名单时,会输出 INFO 级别的日志。"
@@ -935,6 +939,10 @@
"plugin_set": {
"description": "可用插件",
"hint": "默认启用全部未被禁用的插件。若插件在插件页面被禁用,则此处的选择不会生效。"
},
"plugin_disabled_set": {
"description": "禁用插件",
"hint": "明确禁用的插件列表。命中禁用列表的插件不会在会话中触发。"
}
}
},

View File

@@ -33,8 +33,9 @@
"onceAt": "一次性 · {time}",
"runAt": "执行时间:{time}",
"nextRun": "下次执行:{time}",
"dailyAt": "每天 {time}",
"weeklyAt": "每周{day} {time}",
"dailyAt": "每天 · {time}",
"weeklyAt": "{day} · {time}",
"weekdaySeparator": "、",
"monthlyAt": "每月 {day} 日 {time}",
"everyMinutes": "每隔 {count} 分钟",
"everyHours": "每隔 {count} 小时",

View File

@@ -0,0 +1,76 @@
{
"title": "Observer",
"searchPlaceholder": "搜索 UMO、备注或会话 ID",
"emptyTitle": "想查什么?",
"emptySubtitle": "搜索一个 UMO查看它关联的对话、自定义规则、工作区与追踪。",
"knownSources": "消息来源",
"home": {
"recent": "最近来源",
"resources": "更多资源",
"noSources": "暂无可用消息来源",
"noMatches": "没有匹配的 UMO",
"resourceCards": {
"rules": "自定义规则",
"rulesHint": "管理配置覆盖",
"conversation": "对话数据查看器",
"conversationHint": "查看历史对话",
"trace": "追踪",
"traceHint": "查看执行链路",
"stats": "数据大盘",
"statsHint": "查看运行指标"
}
},
"detail": {
"title": "UMO Detail",
"config": "配置文件",
"route": "路由",
"defaultConfig": "默认配置",
"noRoute": "未设置专属路由",
"copy": "复制 UMO",
"openChat": "打开 ChatUI",
"openConfig": "查看配置文件",
"configDrawerTitle": "配置文件",
"configDrawerIdLabel": "配置 ID"
},
"resources": {
"conversation": "对话",
"overrides": "自定义规则",
"workspace": "工作区",
"trace": "追踪"
},
"conversation": {
"title": "对话预览",
"count": "{count} 条",
"empty": "暂无对话数据",
"select": "选择对话以预览",
"latest": "最近对话"
},
"tokens": {
"title": "消耗 Token",
"window": "24H",
"unit": "近 24 小时"
},
"overrides": {
"title": "配置覆盖",
"count": "{count} 项覆盖项",
"empty": "该 UMO 暂无配置覆盖项",
"open": "管理自定义规则"
},
"workspace": {
"title": "工作区",
"empty": "工作区为空",
"missing": "工作区暂不可用",
"copyPath": "复制工作区路径",
"parent": "上级目录",
"root": "根目录"
},
"trace": {
"title": "追踪",
"hint": "当前先展示全量追踪流UMO 过滤后续再接入。"
},
"actions": {
"refresh": "刷新",
"open": "打开",
"back": "返回"
}
}

View File

@@ -56,9 +56,25 @@
"ruleEditor": {
"title": "编辑自定义规则",
"description": "为此会话配置自定义规则,这些规则将优先于全局配置生效。",
"overrideCount": "{count} 项覆盖",
"groups": {
"ai": "AI 配置",
"platform": "平台配置"
},
"note": {
"title": "通用",
"source": "UMO 备注"
},
"addOverride": {
"select": "选择要覆盖的配置项",
"button": "添加覆盖",
"empty": "尚未添加配置覆盖项",
"remove": "移除覆盖"
},
"serviceConfig": {
"title": "服务配置",
"sessionEnabled": "启用该消息会话来源的消息处理",
"sessionEnabled": "允许交互",
"sessionEnabledHint": "禁用后将禁止该来源与 AstrBot 交互",
"llmEnabled": "启用 LLM",
"ttsEnabled": "启用 TTS",
"customName": "消息会话来源备注名称"
@@ -77,13 +93,14 @@
"pluginConfig": {
"title": "插件配置",
"disabledPlugins": "禁用的插件",
"noneDisabled": "不禁用插件",
"selectDisabled": "选择禁用插件",
"hint": "选择要在此会话中禁用的插件。未选择的插件将保持启用状态。"
},
"kbConfig": {
"title": "知识库配置",
"selectKbs": "选择知识库",
"topK": "返回结果数量 (Top K)",
"enableRerank": "启用重排序"
"topK": "返回结果数量 (Top K)"
}
},
"deleteConfirm": {

View File

@@ -37,6 +37,7 @@ import zhCNPersona from './locales/zh-CN/features/persona.json';
import zhCNCommand from './locales/zh-CN/features/command.json';
import zhCNSubagent from './locales/zh-CN/features/subagent.json';
import zhCNWelcome from './locales/zh-CN/features/welcome.json';
import zhCNObserver from './locales/zh-CN/features/observer.json';
import zhCNErrors from './locales/zh-CN/messages/errors.json';
import zhCNSuccess from './locales/zh-CN/messages/success.json';
@@ -78,6 +79,7 @@ import enUSPersona from './locales/en-US/features/persona.json';
import enUSCommand from './locales/en-US/features/command.json';
import enUSSubagent from './locales/en-US/features/subagent.json';
import enUSWelcome from './locales/en-US/features/welcome.json';
import enUSObserver from './locales/en-US/features/observer.json';
import enUSErrors from './locales/en-US/messages/errors.json';
import enUSSuccess from './locales/en-US/messages/success.json';
@@ -119,6 +121,7 @@ import ruRUPersona from './locales/ru-RU/features/persona.json';
import ruRUCommand from './locales/ru-RU/features/command.json';
import ruRUSubagent from './locales/ru-RU/features/subagent.json';
import ruRUWelcome from './locales/ru-RU/features/welcome.json';
import ruRUObserver from './locales/ru-RU/features/observer.json';
import ruRUErrors from './locales/ru-RU/messages/errors.json';
import ruRUSuccess from './locales/ru-RU/messages/success.json';
@@ -167,7 +170,8 @@ export const translations = {
persona: zhCNPersona,
command: zhCNCommand,
subagent: zhCNSubagent,
welcome: zhCNWelcome
welcome: zhCNWelcome,
observer: zhCNObserver
},
messages: {
errors: zhCNErrors,
@@ -216,7 +220,8 @@ export const translations = {
persona: enUSPersona,
command: enUSCommand,
subagent: enUSSubagent,
welcome: enUSWelcome
welcome: enUSWelcome,
observer: enUSObserver
},
messages: {
errors: enUSErrors,
@@ -265,7 +270,8 @@ export const translations = {
persona: ruRUPersona,
command: ruRUCommand,
subagent: ruRUSubagent,
welcome: ruRUWelcome
welcome: ruRUWelcome,
observer: ruRUObserver
},
messages: {
errors: ruRUErrors,

View File

@@ -87,6 +87,11 @@ const sidebarItem: menu[] = [
title: 'core.navigation.groups.more',
icon: 'mdi-dots-horizontal',
children: [
{
title: 'core.navigation.observer',
icon: 'mdi-eye-outline',
to: '/observer'
},
{
title: 'core.navigation.conversation',
icon: 'mdi-database',

View File

@@ -79,6 +79,11 @@ const MainRoutes = {
path: '/session-management',
component: () => import('@/views/SessionManagementPage.vue')
},
{
name: 'Observer',
path: '/observer',
component: () => import('@/views/ObserverPage.vue')
},
{
name: 'Persona',
path: '/persona',

View File

@@ -587,6 +587,23 @@ const weekdayOptions = computed(() => [
{ label: tm("form.weekdays.saturday"), value: 6 },
]);
const CRON_WEEKDAY_ALIASES: Record<string, number> = {
sun: 0,
sunday: 0,
mon: 1,
monday: 1,
tue: 2,
tuesday: 2,
wed: 3,
wednesday: 3,
thu: 4,
thursday: 4,
fri: 5,
friday: 5,
sat: 6,
saturday: 6,
};
function toast(
message: string,
color: "success" | "error" | "warning" = "success",
@@ -692,23 +709,17 @@ function scheduleProductLabel(item: any): string {
const minuteNumber = Number(minute);
const hourNumber = Number(hour);
const dayOfMonthNumber = Number(dayOfMonth);
const dayOfWeekNumber = Number(dayOfWeek);
if (!isCronTime(minuteNumber, hourNumber)) {
return tm("card.customCron", { cron });
}
const time = `${padTimePart(hourNumber)}:${padTimePart(minuteNumber)}`;
const time = formatScheduleTime(hourNumber, minuteNumber);
if (dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
return tm("card.dailyAt", { time });
}
if (
dayOfMonth === "*" &&
month === "*" &&
Number.isInteger(dayOfWeekNumber) &&
dayOfWeekNumber >= 0 &&
dayOfWeekNumber <= 6
) {
const weekDays = parseCronWeekdayField(dayOfWeek);
if (dayOfMonth === "*" && month === "*" && weekDays?.length) {
return tm("card.weeklyAt", {
day: weekdayText(dayOfWeekNumber),
day: weekdayListText(weekDays),
time,
});
}
@@ -1005,6 +1016,83 @@ function isCronTime(minute: number, hour: number): boolean {
);
}
function formatScheduleTime(hour: number, minute: number): string {
return `${hour}:${padTimePart(minute)}`;
}
function parseCronWeekdayValue(value: string): number | null {
const normalized = value.trim().toLowerCase();
if (normalized in CRON_WEEKDAY_ALIASES) {
return CRON_WEEKDAY_ALIASES[normalized];
}
if (!/^\d+$/.test(normalized)) {
return null;
}
const day = Number(normalized);
if (!Number.isInteger(day) || day < 0 || day > 7) {
return null;
}
return day === 7 ? 0 : day;
}
function parseCronWeekdayField(field: string): number[] | null {
const normalized = field.trim().toLowerCase();
if (!normalized || normalized === "*" || normalized === "?") {
return null;
}
const days: number[] = [];
for (const token of normalized.split(",")) {
const item = token.trim();
if (!item || item.includes("/")) {
return null;
}
const rangeParts = item.split("-");
if (rangeParts.length > 2) {
return null;
}
if (rangeParts.length === 2) {
const start = parseCronWeekdayValue(rangeParts[0]);
const end = parseCronWeekdayValue(rangeParts[1]);
if (start === null || end === null) {
return null;
}
if (start <= end) {
for (let day = start; day <= end; day += 1) {
days.push(day);
}
} else {
for (let day = start; day <= 6; day += 1) {
days.push(day);
}
for (let day = 0; day <= end; day += 1) {
days.push(day);
}
}
continue;
}
const day = parseCronWeekdayValue(item);
if (day === null) {
return null;
}
days.push(day);
}
const uniqueDays: number[] = [];
for (const day of days) {
if (!uniqueDays.includes(day)) {
uniqueDays.push(day);
}
}
return uniqueDays.length ? uniqueDays : null;
}
function weekdayListText(days: number[]): string {
return days.map((day) => weekdayText(day)).join(tm("card.weekdaySeparator"));
}
function buildCronExpression(): string {
const mode = newJob.value.schedule_mode;
if (mode === "interval") {
@@ -1066,7 +1154,6 @@ function readScheduleFromJob(job: any) {
const minuteNumber = Number(minute);
const hourNumber = Number(hour);
const dayOfMonthNumber = Number(dayOfMonth);
const dayOfWeekNumber = Number(dayOfWeek);
const hasCronTime = isCronTime(minuteNumber, hourNumber);
const time = hasCronTime
? `${padTimePart(hourNumber)}:${padTimePart(minuteNumber)}`
@@ -1128,18 +1215,17 @@ function readScheduleFromJob(job: any) {
};
}
const weekDays = parseCronWeekdayField(dayOfWeek);
if (
hasCronTime &&
dayOfMonth === "*" &&
month === "*" &&
Number.isInteger(dayOfWeekNumber) &&
dayOfWeekNumber >= 0 &&
dayOfWeekNumber <= 6
weekDays?.length === 1
) {
return {
...fallback,
schedule_mode: "weekly" as ScheduleMode,
weekly_day: dayOfWeekNumber,
weekly_day: weekDays[0],
weekly_time: time,
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3862,6 +3862,10 @@ paths:
in: query
schema:
type: string
- name: umo
in: query
schema:
type: string
responses:
"200":
$ref: "#/components/responses/Ok"
@@ -3896,6 +3900,73 @@ paths:
"200":
$ref: "#/components/responses/Ok"
/api/v1/sessions/config-overrides:
get:
tags: [Sessions]
summary: List UMO config overrides
operationId: listSessionConfigOverrides
x-astrbot-scope: data
parameters:
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PageSize"
- name: search
in: query
schema:
type: string
- name: umo
in: query
schema:
type: string
responses:
"200":
$ref: "#/components/responses/Ok"
post:
tags: [Sessions]
summary: Update or create a UMO config override
operationId: upsertSessionConfigOverride
x-astrbot-scope: data
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SessionConfigOverrideRequest"
responses:
"200":
$ref: "#/components/responses/Ok"
/api/v1/sessions/config-overrides/delete:
post:
tags: [Sessions]
summary: Delete one or more UMO config overrides
operationId: deleteSessionConfigOverride
x-astrbot-scope: data
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SessionConfigOverrideDeleteRequest"
responses:
"200":
$ref: "#/components/responses/Ok"
/api/v1/sessions/aliases:
post:
tags: [Sessions]
summary: Update UMO aliases used as session remarks
operationId: upsertSessionAlias
x-astrbot-scope: data
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SessionAliasRequest"
responses:
"200":
$ref: "#/components/responses/Ok"
/api/v1/sessions/provider:
patch:
tags: [Sessions]
@@ -4133,6 +4204,27 @@ paths:
"200":
description: Exported conversation data
/api/v1/workspaces/by-umo:
get:
tags: [Workspaces]
summary: List workspace files for one UMO
operationId: listUmoWorkspaceFiles
x-astrbot-scope: data
parameters:
- name: umo
in: query
required: true
schema:
type: string
minLength: 1
- name: path
in: query
schema:
type: string
responses:
"200":
$ref: "#/components/responses/Ok"
/api/v1/stats:
get:
tags: [Stats]
@@ -5863,6 +5955,58 @@ components:
$ref: "#/components/schemas/DynamicConfig"
additionalProperties: true
SessionConfigOverrideRequest:
type: object
required: [umo, path, value]
properties:
umo:
type: string
path:
type: string
value: {}
additionalProperties: true
SessionConfigOverrideDeleteRequest:
type: object
properties:
umo:
type: string
umos:
type: array
items:
type: string
scope:
type: string
enum: [all, group, private, custom_group]
group_id:
type: string
path:
type: string
paths:
type: array
items:
type: string
additionalProperties: true
SessionAliasRequest:
type: object
required: [custom_name]
properties:
umo:
type: string
umos:
type: array
items:
type: string
scope:
type: string
enum: [all, group, private, custom_group]
group_id:
type: string
custom_name:
type: string
additionalProperties: true
UmoListRequest:
type: object
properties:

View File

@@ -28,6 +28,12 @@ class FakeEvent:
self.temporary_local_files.append(path)
def _make_preprocess_stage(config: dict) -> PreProcessStage:
stage = PreProcessStage()
stage.ctx = SimpleNamespace(astrbot_config=config)
return stage
@pytest.mark.asyncio
async def test_preprocess_preserves_image_formats_and_tracks_temp_files(
tmp_path, monkeypatch
@@ -77,10 +83,7 @@ async def test_preprocess_preserves_image_formats_and_tracks_temp_files(
),
]
)
stage = PreProcessStage()
stage.config = {}
stage.platform_settings = {}
stage.stt_settings = {"enable": False}
stage = _make_preprocess_stage({"provider_stt_settings": {"enable": False}})
await stage.process(event)
@@ -114,10 +117,12 @@ async def test_preprocess_path_mapping_accepts_file_uri(tmp_path):
target_image = target_root / "photo.jpg"
PILImage.new("RGB", (2, 2), (255, 0, 0)).save(target_image)
event = FakeEvent([Image(file="", url=source_image.as_uri())])
stage = PreProcessStage()
stage.config = {}
stage.platform_settings = {"path_mapping": [f"{source_root}:{target_root}"]}
stage.stt_settings = {"enable": False}
stage = _make_preprocess_stage(
{
"platform_settings": {"path_mapping": [f"{source_root}:{target_root}"]},
"provider_stt_settings": {"enable": False},
}
)
await stage.process(event)

View File

@@ -1,5 +1,4 @@
import asyncio
import re
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock
@@ -287,8 +286,6 @@ async def test_webhook_group_send_by_session_without_cached_msg_id_omits_msg_id(
def test_qqofficial_ws_is_not_excluded_from_segmented_reply():
stage = RespondStage()
stage.enable_seg = True
stage.only_llm_result = False
result = MessageEventResult(chain=[Plain("hello")])
event = SimpleNamespace(
@@ -296,13 +293,11 @@ def test_qqofficial_ws_is_not_excluded_from_segmented_reply():
get_platform_name=lambda: "qq_official",
)
assert stage.is_seg_reply_required(cast(Any, event)) is True
assert stage.is_seg_reply_required(cast(Any, event), True, False) is True
def test_qqofficial_webhook_remains_excluded_from_segmented_reply():
stage = RespondStage()
stage.enable_seg = True
stage.only_llm_result = False
result = MessageEventResult(chain=[Plain("hello")])
event = SimpleNamespace(
@@ -310,26 +305,12 @@ def test_qqofficial_webhook_remains_excluded_from_segmented_reply():
get_platform_name=lambda: "qq_official_webhook",
)
assert stage.is_seg_reply_required(cast(Any, event)) is False
assert stage.is_seg_reply_required(cast(Any, event), True, False) is False
@pytest.mark.asyncio
async def test_result_decorate_segments_qqofficial_ws_plain_result():
stage = ResultDecorateStage()
stage.reply_prefix = ""
stage.content_safe_check_reply = False
stage.enable_segmented_reply = True
stage.only_llm_result = False
stage.words_count_threshold = 100
stage.split_mode = "words"
stage.split_words = [""]
stage.split_words_pattern = re.compile(r"(.*?(。)|.+$)", re.DOTALL)
stage.content_cleanup_rule = ""
stage.show_reasoning = False
stage.tts_trigger_probability = 0
stage.reply_with_mention = False
stage.reply_with_quote = False
stage.forward_threshold = 1000
setattr(
stage,
"ctx",
@@ -338,13 +319,33 @@ async def test_result_decorate_segments_qqofficial_ws_plain_result():
context=SimpleNamespace(get_using_tts_provider=lambda _umo: None)
),
astrbot_config={
"platform_settings": {
"reply_prefix": "",
"reply_with_mention": False,
"reply_with_quote": False,
"forward_threshold": 1000,
"segmented_reply": {
"enable": True,
"only_llm_result": False,
"words_count_threshold": 100,
"split_mode": "words",
"split_words": [""],
"content_cleanup_rule": "",
},
},
"provider_tts_settings": {
"enable": False,
"use_file_service": False,
"dual_output": False,
"trigger_probability": 0,
},
"provider_settings": {"display_reasoning_text": False},
"content_safety": {"also_use_in_response": False},
"callback_api_base": "",
"t2i": False,
"t2i_word_threshold": 150,
"t2i_strategy": "local",
"t2i_active_template": "",
},
),
)

View File

@@ -77,7 +77,6 @@ def test_builtin_stage_bootstrap_is_idempotent() -> None:
expected_stage_names = {
"WakingCheckStage",
"WhitelistCheckStage",
"SessionStatusCheckStage",
"RateLimitStage",
"ContentSafetyCheckStage",
"PreProcessStage",

View File

@@ -264,6 +264,37 @@ async def test_ensure_vec_db_clears_stale_init_error(
assert helper.vec_db is mock_vec_db
@pytest.mark.asyncio
async def test_retrieve_accepts_kb_id_in_kb_names(
stub_provider_manager_module,
mock_provider_manager,
mock_knowledge_base,
):
"""Test that retrieve accepts KB IDs through the legacy kb_names field."""
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
kb_helper = MagicMock()
kb_helper.kb = mock_knowledge_base
kb_helper.init_error = None
kb_mgr = KnowledgeBaseManager.__new__(KnowledgeBaseManager)
kb_mgr.provider_manager = mock_provider_manager
kb_mgr.kb_insts = {mock_knowledge_base.kb_id: kb_helper}
kb_mgr.retrieval_manager = MagicMock()
kb_mgr.retrieval_manager.retrieve = AsyncMock(return_value=[])
result = await kb_mgr.retrieve(
query="hello",
kb_names=[mock_knowledge_base.kb_id],
)
assert result is None
kb_mgr.retrieval_manager.retrieve.assert_awaited_once()
assert kb_mgr.retrieval_manager.retrieve.await_args.kwargs["kb_ids"] == [
mock_knowledge_base.kb_id
]
@pytest.mark.asyncio
async def test_ensure_vec_db_sets_init_error_on_failure(
stub_provider_manager_module,

View File

@@ -96,9 +96,15 @@ class _DummyRespondEvent:
def _make_respond_stage() -> RespondStage:
"""Build a minimally initialized RespondStage for unit tests."""
stage = RespondStage()
stage.config = {"provider_settings": {}}
stage.platform_settings = {"path_mapping": []}
stage.enable_seg = False
stage.ctx = SimpleNamespace(
astrbot_config={
"provider_settings": {},
"platform_settings": {
"path_mapping": [],
"segmented_reply": {"enable": False},
},
}
)
return stage