Compare commits

..

1 Commits

Author SHA1 Message Date
Soulter 09b31c460d feat: add ConfigRouteManagerDialog component for managing routing configurations
- Implemented a new dialog component for managing routes associated with configurations, allowing users to view, delete, and save routes.
- Enhanced the ProviderSelector component with improved styling for better readability.
- Updated English and Chinese localization files to include new strings for the route manager and profile sidebar.
- Refactored ConfigPage.vue to integrate the new route management dialog and improve layout responsiveness.
- Added methods for handling route management, including fetching, saving, and removing routes.
2026-03-01 19:37:26 +08:00
16 changed files with 1066 additions and 1290 deletions
@@ -1,262 +1,15 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING
import re
from astrbot import logger
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.provider.entities import ProviderType
from astrbot.core.utils.error_redaction import safe_error
if TYPE_CHECKING:
from astrbot.core.provider.provider import Provider
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT = 30.0
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT = 4
MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND = 16
MODEL_LIST_CACHE_TTL_KEY = "model_list_cache_ttl_seconds"
MODEL_LOOKUP_MAX_CONCURRENCY_KEY = "model_lookup_max_concurrency"
MODEL_CACHE_MAX_ENTRIES = 512
@dataclass(frozen=True)
class _ModelLookupConfig:
umo: str | None
cache_ttl_seconds: float
max_concurrency: int
class _ModelCache:
def __init__(self) -> None:
self._store: dict[tuple[str, str | None], tuple[float, list[str]]] = {}
def get(self, provider_id: str, umo: str | None, ttl: float) -> list[str] | None:
if ttl <= 0:
return None
entry = self._store.get((provider_id, umo))
if not entry:
return None
timestamp, models = entry
if time.monotonic() - timestamp > ttl:
self._store.pop((provider_id, umo), None)
return None
return models
def set(
self, provider_id: str, umo: str | None, models: list[str], ttl: float
) -> None:
if ttl <= 0:
return
self._store[(provider_id, umo)] = (time.monotonic(), list(models))
self._evict_if_needed()
def _evict_if_needed(self) -> None:
if len(self._store) <= MODEL_CACHE_MAX_ENTRIES:
return
# Drop oldest entries first when cache grows too large.
overflow = len(self._store) - MODEL_CACHE_MAX_ENTRIES
for key, _ in sorted(
self._store.items(),
key=lambda item: item[1][0],
)[:overflow]:
self._store.pop(key, None)
def invalidate(
self, provider_id: str | None = None, *, umo: str | None = None
) -> None:
if provider_id is None:
self._store.clear()
return
if umo is not None:
self._store.pop((provider_id, umo), None)
return
stale_keys = [
cache_key for cache_key in self._store if cache_key[0] == provider_id
]
for cache_key in stale_keys:
self._store.pop(cache_key, None)
class ProviderCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
self._model_cache = _ModelCache()
self._register_provider_change_hook()
def _register_provider_change_hook(self) -> None:
set_change_callback = getattr(
self.context.provider_manager,
"set_provider_change_callback",
None,
)
if callable(set_change_callback):
set_change_callback(self._on_provider_manager_changed)
return
register_change_hook = getattr(
self.context.provider_manager,
"register_provider_change_hook",
None,
)
if callable(register_change_hook):
register_change_hook(self._on_provider_manager_changed)
def invalidate_provider_models_cache(
self, provider_id: str | None = None, *, umo: str | None = None
) -> None:
"""Public hook for cache invalidation on external provider config changes."""
self._model_cache.invalidate(provider_id, umo=umo)
def _on_provider_manager_changed(
self,
provider_id: str,
provider_type: ProviderType,
umo: str | None,
) -> None:
if provider_type == ProviderType.CHAT_COMPLETION:
self.invalidate_provider_models_cache(provider_id, umo=umo)
def _get_provider_settings(self, umo: str | None) -> dict:
if not umo:
return {}
try:
return self.context.get_config(umo).get("provider_settings", {}) or {}
except Exception as e:
logger.debug(
"读取 provider_settings 失败,使用默认值: %s",
safe_error("", e),
)
return {}
def _get_model_cache_ttl(self, umo: str | None) -> float:
settings = self._get_provider_settings(umo)
raw = settings.get(
MODEL_LIST_CACHE_TTL_KEY,
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
)
try:
return max(float(raw), 0.0)
except Exception as e:
logger.debug(
"读取 %s 失败,回退默认值 %r: %s",
MODEL_LIST_CACHE_TTL_KEY,
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
safe_error("", e),
)
return MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT
def _get_model_lookup_concurrency(self, umo: str | None) -> int:
settings = self._get_provider_settings(umo)
raw = settings.get(
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
)
try:
value = int(raw)
except Exception as e:
logger.debug(
"读取 %s 失败,回退默认值 %r: %s",
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
safe_error("", e),
)
value = MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT
return min(max(value, 1), MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND)
def _get_model_lookup_config(self, umo: str | None) -> _ModelLookupConfig:
return _ModelLookupConfig(
umo=umo,
cache_ttl_seconds=self._get_model_cache_ttl(umo),
max_concurrency=self._get_model_lookup_concurrency(umo),
)
def _resolve_model_name(
self,
model_name: str,
models: Sequence[str],
) -> str | None:
"""Resolve model name with precedence:
exact > case-insensitive > provider-qualified suffix.
"""
requested = model_name.strip()
if not requested:
return None
requested_norm = requested.casefold()
# exact / case-insensitive match
for candidate in models:
if candidate == requested or candidate.casefold() == requested_norm:
return candidate
# provider-qualified suffix match:
# e.g. candidate `openai/gpt-4o` should match requested `gpt-4o`.
for candidate in models:
cand_norm = candidate.casefold()
if cand_norm.endswith(f"/{requested_norm}") or cand_norm.endswith(
f":{requested_norm}"
):
return candidate
return None
def _apply_model(
self, prov: Provider, model_name: str, *, umo: str | None = None
) -> str:
prov.set_model(model_name)
self.invalidate_provider_models_cache(prov.meta().id, umo=umo)
return f"切换模型成功。当前提供商: [{prov.meta().id}] 当前模型: [{prov.get_model()}]"
async def _get_provider_models(
self,
provider: Provider,
*,
config: _ModelLookupConfig,
use_cache: bool = True,
) -> list[str]:
provider_id = provider.meta().id
ttl_seconds = config.cache_ttl_seconds
umo = config.umo
if use_cache:
cached = self._model_cache.get(provider_id, umo, ttl_seconds)
if cached is not None:
return cached
models = list(await provider.get_models())
if use_cache:
self._model_cache.set(provider_id, umo, models, ttl_seconds)
return models
async def _get_models_or_reply_error(
self,
message: AstrMessageEvent,
prov: Provider,
config: _ModelLookupConfig,
*,
error_prefix: str,
disable_t2i: bool = False,
warning_log: str | None = None,
) -> list[str] | None:
try:
return await self._get_provider_models(prov, config=config)
except asyncio.CancelledError:
raise
except Exception as e:
if warning_log is not None:
logger.warning(
warning_log,
prov.meta().id,
safe_error("", e),
)
result = MessageEventResult().message(safe_error(error_prefix, e))
if disable_t2i:
result = result.use_t2i(False)
message.set_result(result)
return None
def _log_reachability_failure(
self,
@@ -285,96 +38,12 @@ class ProviderCommands:
return True, None, None
except Exception as e:
err_code = "TEST_FAILED"
err_reason = safe_error("", e)
err_reason = str(e)
self._log_reachability_failure(
provider, provider_capability_type, err_code, err_reason
)
return False, err_code, err_reason
async def _find_provider_for_model(
self,
model_name: str,
*,
exclude_provider_id: str | None = None,
config: _ModelLookupConfig,
use_cache: bool = True,
) -> tuple[Provider | None, str | None]:
all_providers = []
for provider in self.context.get_all_providers():
provider_meta = provider.meta()
if provider_meta.provider_type != ProviderType.CHAT_COMPLETION:
continue
if (
exclude_provider_id is not None
and provider_meta.id == exclude_provider_id
):
continue
all_providers.append(provider)
if not all_providers:
return None, None
semaphore = asyncio.Semaphore(config.max_concurrency)
async def fetch_models(
provider: Provider,
) -> tuple[Provider, list[str] | None, str | None]:
async with semaphore:
try:
models = await self._get_provider_models(
provider,
config=config,
use_cache=use_cache,
)
return provider, models, None
except asyncio.CancelledError:
raise
except Exception as e:
err = safe_error("", e)
logger.debug(
"跨提供商查找模型 %s 获取 %s 模型列表失败: %s",
model_name,
provider.meta().id,
err,
)
return provider, None, err
results = await asyncio.gather(
*(fetch_models(provider) for provider in all_providers)
)
failed_provider_errors: list[tuple[str, str]] = []
for provider, models, err in results:
if err is not None:
failed_provider_errors.append((provider.meta().id, err))
continue
if models is None:
continue
matched_model_name = self._resolve_model_name(model_name, models)
if matched_model_name is not None:
return provider, matched_model_name
if failed_provider_errors and len(failed_provider_errors) == len(all_providers):
failed_ids = ",".join(
provider_id for provider_id, _ in failed_provider_errors
)
logger.error(
"跨提供商查找模型 %s 时,所有 %d 个提供商的 get_models() 均失败: %s。请检查配置或网络",
model_name,
len(all_providers),
failed_ids,
)
elif failed_provider_errors:
logger.debug(
"跨提供商查找模型 %s 时有 %d 个提供商获取模型失败: %s",
model_name,
len(failed_provider_errors),
",".join(
f"{provider_id}({error})"
for provider_id, error in failed_provider_errors
),
)
return None, None
async def provider(
self,
event: AstrMessageEvent,
@@ -423,15 +92,13 @@ class ProviderCommands:
id_ = meta.id
error_code = None
if isinstance(reachable, asyncio.CancelledError):
raise reachable
if isinstance(reachable, Exception):
# 异常情况下兜底处理,避免单个 provider 导致列表失败
self._log_reachability_failure(
p,
None,
reachable.__class__.__name__,
safe_error("", reachable),
str(reachable),
)
reachable_flag = False
error_code = reachable.__class__.__name__
@@ -557,73 +224,6 @@ class ProviderCommands:
else:
event.set_result(MessageEventResult().message("无效的参数。"))
async def _switch_model_by_name(
self, message: AstrMessageEvent, model_name: str, prov: Provider
) -> None:
model_name = model_name.strip()
if not model_name:
message.set_result(MessageEventResult().message("模型名不能为空。"))
return
umo = message.unified_msg_origin
config = self._get_model_lookup_config(umo)
curr_provider_id = prov.meta().id
models = await self._get_models_or_reply_error(
message,
prov,
config,
error_prefix="获取当前提供商模型列表失败: ",
warning_log="获取当前提供商 %s 模型列表失败,停止跨提供商查找: %s",
)
if models is None:
return
matched_model_name = self._resolve_model_name(model_name, models)
if matched_model_name is not None:
message.set_result(
MessageEventResult().message(
self._apply_model(prov, matched_model_name, umo=umo)
),
)
return
target_prov, matched_target_model_name = await self._find_provider_for_model(
model_name,
exclude_provider_id=curr_provider_id,
config=config,
)
if target_prov is None or matched_target_model_name is None:
message.set_result(
MessageEventResult().message(
f"模型 [{model_name}] 未在任何已配置的提供商中找到,或所有提供商模型列表获取失败,请检查配置或网络后重试。",
),
)
return
target_id = target_prov.meta().id
try:
await self.context.provider_manager.set_provider(
provider_id=target_id,
provider_type=ProviderType.CHAT_COMPLETION,
umo=umo,
)
self._apply_model(target_prov, matched_target_model_name, umo=umo)
message.set_result(
MessageEventResult().message(
f"检测到模型 [{matched_target_model_name}] 属于提供商 [{target_id}],已自动切换提供商并设置模型。",
),
)
except asyncio.CancelledError:
raise
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("跨提供商切换并设置模型失败: ", e)
),
)
async def model_ls(
self,
message: AstrMessageEvent,
@@ -636,17 +236,20 @@ class ProviderCommands:
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
config = self._get_model_lookup_config(message.unified_msg_origin)
# 定义正则表达式匹配 API 密钥
api_key_pattern = re.compile(r"key=[^&'\" ]+")
if idx_or_name is None:
models = await self._get_models_or_reply_error(
message,
prov,
config,
error_prefix="获取模型列表失败: ",
disable_t2i=True,
)
if models is None:
models = []
try:
models = await prov.get_models()
except BaseException as e:
err_msg = api_key_pattern.sub("key=***", str(e))
message.set_result(
MessageEventResult()
.message("获取模型列表失败: " + err_msg)
.use_t2i(False),
)
return
parts = ["下面列出了此模型提供商可用模型:"]
for i, model in enumerate(models, 1):
@@ -655,43 +258,40 @@ class ProviderCommands:
curr_model = prov.get_model() or ""
parts.append(f"\n当前模型: [{curr_model}]")
parts.append(
"\nTips: 使用 /model <模型名/编号> 切换模型。输入模型名时可自动跨提供商查找并切换;跨提供商也可使用 /provider 切换"
"\nTips: 使用 /model <模型名/编号>,即可实时更换模型。如目标模型不存在于上表,请输入模型名"
)
ret = "".join(parts)
message.set_result(MessageEventResult().message(ret).use_t2i(False))
elif isinstance(idx_or_name, int):
models = await self._get_models_or_reply_error(
message,
prov,
config,
error_prefix="获取模型列表失败: ",
)
if models is None:
models = []
try:
models = await prov.get_models()
except BaseException as e:
message.set_result(
MessageEventResult().message("获取模型列表失败: " + str(e)),
)
return
if idx_or_name > len(models) or idx_or_name < 1:
message.set_result(MessageEventResult().message("模型序号错误。"))
else:
try:
new_model = models[idx_or_name - 1]
prov.set_model(new_model)
except BaseException as e:
message.set_result(
MessageEventResult().message(
self._apply_model(
prov,
new_model,
umo=message.unified_msg_origin,
)
),
MessageEventResult().message("切换模型未知错误: " + str(e)),
)
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("切换模型未知错误: ", e)
),
)
return
message.set_result(
MessageEventResult().message(
f"切换模型成功。当前提供商: [{prov.meta().id}] 当前模型: [{prov.get_model()}]",
),
)
else:
await self._switch_model_by_name(message, idx_or_name, prov)
prov.set_model(idx_or_name)
message.set_result(
MessageEventResult().message(f"切换模型到 {prov.get_model()}"),
)
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
prov = self.context.get_using_provider(message.unified_msg_origin)
@@ -722,15 +322,8 @@ class ProviderCommands:
try:
new_key = keys_data[index - 1]
prov.set_key(new_key)
self.invalidate_provider_models_cache(
prov.meta().id,
umo=message.unified_msg_origin,
)
message.set_result(MessageEventResult().message("切换 Key 成功。"))
except Exception as e:
except BaseException as e:
message.set_result(
MessageEventResult().message(
safe_error("切换 Key 未知错误: ", e)
),
MessageEventResult().message(f"切换 Key 未知错误: {e!s}"),
)
return
message.set_result(MessageEventResult().message("切换 Key 成功。"))
+3 -188
View File
@@ -12,7 +12,7 @@ import os
import shutil
import zipfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -61,69 +61,6 @@ def _get_major_version(version_str: str) -> str:
CMD_CONFIG_FILE_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
KB_PATH = get_astrbot_knowledge_base_path()
DEFAULT_PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT = 5
PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT_ENV = (
"ASTRBOT_PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT"
)
def _load_platform_stats_invalid_count_warn_limit() -> int:
raw_value = os.getenv(PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT_ENV)
if raw_value is None:
return DEFAULT_PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT
try:
value = int(raw_value)
if value < 0:
raise ValueError("negative")
return value
except (TypeError, ValueError):
logger.warning(
"Invalid env %s=%r, fallback to default %d",
PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT_ENV,
raw_value,
DEFAULT_PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT,
)
return DEFAULT_PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT
PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT = (
_load_platform_stats_invalid_count_warn_limit()
)
class _InvalidCountWarnLimiter:
"""Rate-limit warnings for invalid platform_stats count values."""
def __init__(self, limit: int) -> None:
self.limit = limit
self._count = 0
self._suppression_logged = False
def warn_invalid_count(self, value: Any, key_for_log: tuple[Any, ...]) -> None:
if self.limit > 0:
if self._count < self.limit:
logger.warning(
"platform_stats count 非法,已按 0 处理: value=%r, key=%s",
value,
key_for_log,
)
self._count += 1
if self._count == self.limit and not self._suppression_logged:
logger.warning(
"platform_stats 非法 count 告警已达到上限 (%d),后续将抑制",
self.limit,
)
self._suppression_logged = True
return
if not self._suppression_logged:
# limit <= 0: emit only one suppression warning.
logger.warning(
"platform_stats 非法 count 告警已达到上限 (%d),后续将抑制",
self.limit,
)
self._suppression_logged = True
@dataclass
@@ -201,10 +138,6 @@ class ImportResult:
}
class DatabaseClearError(RuntimeError):
"""Raised when clearing the main database in replace mode fails."""
class AstrBotImporter:
"""AstrBot 数据导入器
@@ -409,9 +342,6 @@ class AstrBotImporter:
imported = await self._import_main_database(main_data)
result.imported_tables.update(imported)
except DatabaseClearError as e:
result.add_error(f"清空主数据库失败: {e}")
return result
except Exception as e:
result.add_error(f"导入主数据库失败: {e}")
return result
@@ -522,9 +452,7 @@ class AstrBotImporter:
await session.execute(delete(model_class))
logger.debug(f"已清空表 {table_name}")
except Exception as e:
raise DatabaseClearError(
f"清空表 {table_name} 失败: {e}"
) from e
logger.warning(f"清空表 {table_name} 失败: {e}")
async def _clear_kb_data(self) -> None:
"""清空知识库数据"""
@@ -566,10 +494,9 @@ class AstrBotImporter:
if not model_class:
logger.warning(f"未知的表: {table_name}")
continue
normalized_rows = self._preprocess_main_table_rows(table_name, rows)
count = 0
for row in normalized_rows:
for row in rows:
try:
# 转换 datetime 字符串为 datetime 对象
row = self._convert_datetime_fields(row, model_class)
@@ -584,118 +511,6 @@ class AstrBotImporter:
return imported
def _preprocess_main_table_rows(
self, table_name: str, rows: list[dict[str, Any]]
) -> list[dict[str, Any]]:
if table_name == "platform_stats":
normalized_rows = self._merge_platform_stats_rows(rows)
duplicate_count = len(rows) - len(normalized_rows)
if duplicate_count > 0:
logger.warning(
"检测到 %s 重复键 %d 条,已在导入前聚合",
table_name,
duplicate_count,
)
return normalized_rows
return rows
def _merge_platform_stats_rows(
self, rows: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Merge duplicate platform_stats rows by normalized timestamp/platform key.
Note:
- Invalid/empty timestamps are kept as distinct rows to avoid accidental merging.
- Non-string platform_id/platform_type are kept as distinct rows.
- Invalid count warnings are rate-limited per function invocation.
"""
merged: dict[tuple[str, str, str], dict[str, Any]] = {}
result: list[dict[str, Any]] = []
warn_limiter = _InvalidCountWarnLimiter(PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT)
for row in rows:
normalized_row, normalized_timestamp, count = (
self._normalize_platform_stats_entry(row, warn_limiter)
)
platform_id = normalized_row.get("platform_id")
platform_type = normalized_row.get("platform_type")
if (
normalized_timestamp is None
or not isinstance(platform_id, str)
or not isinstance(platform_type, str)
):
result.append(normalized_row)
continue
merge_key = (normalized_timestamp, platform_id, platform_type)
existing = merged.get(merge_key)
if existing is None:
merged[merge_key] = normalized_row
result.append(normalized_row)
else:
existing["count"] += count
return result
def _normalize_platform_stats_entry(
self,
row: dict[str, Any],
warn_limiter: _InvalidCountWarnLimiter,
) -> tuple[dict[str, Any], str | None, int]:
normalized_row = dict(row)
raw_timestamp = normalized_row.get("timestamp")
normalized_timestamp = self._normalize_platform_stats_timestamp(raw_timestamp)
if normalized_timestamp is not None:
normalized_row["timestamp"] = normalized_timestamp
elif isinstance(raw_timestamp, str):
normalized_row["timestamp"] = raw_timestamp.strip()
elif raw_timestamp is None:
normalized_row["timestamp"] = ""
else:
normalized_row["timestamp"] = str(raw_timestamp)
raw_count = normalized_row.get("count", 0)
try:
count = int(raw_count)
except (TypeError, ValueError):
key_for_log = (
normalized_row.get("timestamp"),
repr(normalized_row.get("platform_id")),
repr(normalized_row.get("platform_type")),
)
warn_limiter.warn_invalid_count(raw_count, key_for_log)
count = 0
normalized_row["count"] = count
return normalized_row, normalized_timestamp, count
def _normalize_platform_stats_timestamp(self, value: Any) -> str | None:
if isinstance(value, datetime):
dt = value
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
return dt.isoformat()
if isinstance(value, str):
timestamp = value.strip()
if not timestamp:
return None
if timestamp.endswith("Z"):
timestamp = f"{timestamp[:-1]}+00:00"
try:
dt = datetime.fromisoformat(timestamp)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
return dt.isoformat()
except ValueError:
return None
return None
async def _import_knowledge_bases(
self,
zf: zipfile.ZipFile,
+21 -14
View File
@@ -48,18 +48,29 @@ class Group:
class AstrBotMessage:
"""AstrBot 的消息对象"""
"""Represents a message received from the platform, after parsing and normalization.
This is the main message object that will be passed to plugins and handlers."""
type: MessageType # 消息类型
self_id: str # 机器人的识别id
session_id: str # 会话id。取决于 unique_session 的设置。
message_id: str # 消息id
group: Group | None # 群组
sender: MessageMember # 发送者
message: list[BaseMessageComponent] # 消息链使用 Nakuru 的消息链格式
message_str: str # 最直观的纯文本消息字符串
type: MessageType
"""GroupMessage, FriendMessage, etc"""
self_id: str
"""Bot's ID"""
session_id: str
"""Session ID, which is the last part of UMO"""
message_id: str
"""Message ID"""
group: Group | None
"""The group info, None if it's a friend message"""
sender: MessageMember
"""The sender info"""
message: list[BaseMessageComponent]
"""Sorted list of message components after parsing"""
message_str: str
"""The parsed message text after parsing, without any formatting or special components"""
raw_message: object
timestamp: int # 消息时间戳
"""The raw message object, the specific type depends on the platform"""
timestamp: int
"""The timestamp when the message is received, in seconds"""
def __init__(self) -> None:
self.timestamp = int(time.time())
@@ -70,16 +81,12 @@ class AstrBotMessage:
@property
def group_id(self) -> str:
"""向后兼容的 group_id 属性
群组id,如果为私聊,则为空
"""
if self.group:
return self.group.group_id
return ""
@group_id.setter
def group_id(self, value: str | None) -> None:
"""设置 group_id"""
if value:
if self.group:
self.group.group_id = value
-56
View File
@@ -2,13 +2,11 @@ import asyncio
import copy
import os
import traceback
from collections.abc import Callable
from typing import Protocol, runtime_checkable
from astrbot.core import astrbot_config, logger, sp
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.error_redaction import safe_error
from ..persona_mgr import PersonaManager
from .entities import ProviderType
@@ -73,56 +71,6 @@ class ProviderManager:
self.curr_tts_provider_inst: TTSProvider | None = None
"""默认的 Text To Speech Provider 实例。已弃用,请使用 get_using_provider() 方法获取当前使用的 Provider 实例。"""
self.db_helper = db_helper
self._provider_change_callback: (
Callable[[str, ProviderType, str | None], None] | None
) = None
self._provider_change_hooks: list[
Callable[[str, ProviderType, str | None], None]
] = []
def set_provider_change_callback(
self,
cb: Callable[[str, ProviderType, str | None], None] | None,
) -> None:
# Backward-compatible single-callback setter.
# This callback coexists with register_provider_change_hook subscriptions.
self._provider_change_callback = cb
def register_provider_change_hook(
self,
hook: Callable[[str, ProviderType, str | None], None],
) -> None:
if hook not in self._provider_change_hooks:
self._provider_change_hooks.append(hook)
def _notify_provider_changed(
self,
provider_id: str,
provider_type: ProviderType,
umo: str | None,
) -> None:
if self._provider_change_callback is not None:
try:
self._provider_change_callback(provider_id, provider_type, umo)
except Exception as e:
logger.warning(
"调用 provider 变更回调失败: provider_id=%s, type=%s, err=%s",
provider_id,
provider_type,
safe_error("", e),
)
for hook in list(self._provider_change_hooks):
if hook is self._provider_change_callback:
continue
try:
hook(provider_id, provider_type, umo)
except Exception as e:
logger.warning(
"调用 provider 变更钩子失败: provider_id=%s, type=%s, err=%s",
provider_id,
provider_type,
safe_error("", e),
)
@property
def persona_configs(self) -> list:
@@ -163,7 +111,6 @@ class ProviderManager:
f"provider_perf_{provider_type.value}",
provider_id,
)
self._notify_provider_changed(provider_id, provider_type, umo)
return
# 不启用提供商会话隔离模式的情况
@@ -179,7 +126,6 @@ class ProviderManager:
scope="global",
scope_id="global",
)
self._notify_provider_changed(provider_id, provider_type, umo)
elif provider_type == ProviderType.SPEECH_TO_TEXT and isinstance(
prov,
STTProvider,
@@ -191,7 +137,6 @@ class ProviderManager:
scope="global",
scope_id="global",
)
self._notify_provider_changed(provider_id, provider_type, umo)
elif provider_type == ProviderType.CHAT_COMPLETION and isinstance(
prov,
Provider,
@@ -203,7 +148,6 @@ class ProviderManager:
scope="global",
scope_id="global",
)
self._notify_provider_changed(provider_id, provider_type, umo)
async def get_provider_by_id(self, provider_id: str) -> Providers | None:
"""根据提供商 ID 获取提供商实例"""
-82
View File
@@ -1,82 +0,0 @@
import re
_SECRET_KEYS = (
r"(?:api_?key|access_?token|auth_?token|refresh_?token|session_?id|secret|password)"
)
_JSON_FIELD_PATTERN = re.compile(
rf"(?i)(?P<prefix>(?P<kq>['\"]){_SECRET_KEYS}(?P=kq)\s*:\s*)(?P<vq>['\"])(?P<value>[^'\"]+)(?P=vq)"
)
_AUTH_JSON_FIELD_PATTERN = re.compile(
r"(?i)(?P<prefix>(?P<kq>['\"])authorization(?P=kq)\s*:\s*)(?P<vq>['\"])bearer\s+[^'\"]+(?P=vq)"
)
_QUERY_FIELD_PATTERN = re.compile(
rf"(?i)(?P<prefix>{_SECRET_KEYS}\s*=\s*)(?P<value>[^&'\" ]+)"
)
_QUERY_PARAM_PATTERN = re.compile(
r"(?i)(?P<prefix>[?&](?:api_?key|key|access_?token|auth_?token)=)(?P<value>[^&'\" ]+)"
)
_AUTH_HEADER_PATTERN = re.compile(
r"(?i)(?P<prefix>\bauthorization\s*:\s*bearer\s+)(?P<token>[A-Za-z0-9._\-]+)"
)
_BEARER_PATTERN = re.compile(r"(?i)(?P<prefix>\bbearer\s+)(?P<token>[A-Za-z0-9._\-]+)")
_SK_PATTERN = re.compile(r"\bsk-[A-Za-z0-9]{16,}\b")
def _redact_json_field(match: re.Match[str]) -> str:
quote = match.group("vq")
return f"{match.group('prefix')}{quote}[REDACTED]{quote}"
def _redact_auth_json_field(match: re.Match[str]) -> str:
quote = match.group("vq")
return f"{match.group('prefix')}{quote}Bearer [REDACTED]{quote}"
def _redact_prefixed_value(match: re.Match[str]) -> str:
return f"{match.group('prefix')}[REDACTED]"
def _redact_bearer_token(match: re.Match[str]) -> str:
return f"{match.group('prefix')}[REDACTED]"
def _redact_json_like(text: str) -> str:
text = _JSON_FIELD_PATTERN.sub(_redact_json_field, text)
return _AUTH_JSON_FIELD_PATTERN.sub(_redact_auth_json_field, text)
def _redact_query_like(text: str) -> str:
text = _QUERY_FIELD_PATTERN.sub(_redact_prefixed_value, text)
return _QUERY_PARAM_PATTERN.sub(_redact_prefixed_value, text)
def _redact_tokens(text: str) -> str:
text = _AUTH_HEADER_PATTERN.sub(_redact_bearer_token, text)
text = _BEARER_PATTERN.sub(_redact_bearer_token, text)
return _SK_PATTERN.sub("[REDACTED]", text)
def redact_sensitive_text(text: str) -> str:
text = _redact_json_like(text)
text = _redact_query_like(text)
text = _redact_tokens(text)
return text
def safe_error(
prefix: str,
error: Exception | BaseException | str,
*,
redact: bool = True,
) -> str:
try:
text = str(error)
except Exception:
try:
text = repr(error)
except Exception:
text = "<unprintable error>"
if redact:
text = redact_sensitive_text(text)
return prefix + text
-1
View File
@@ -65,7 +65,6 @@
"sass-loader": "13.3.2",
"typescript": "5.1.6",
"vite": "4.4.9",
"vite-plugin-monaco-editor": "1.1.0",
"vue-cli-plugin-vuetify": "2.5.8",
"vue-tsc": "1.8.8",
"vuetify-loader": "^2.0.0-alpha.9"
-12
View File
@@ -159,9 +159,6 @@ importers:
vite:
specifier: 4.4.9
version: 4.4.9(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)
vite-plugin-monaco-editor:
specifier: 1.1.0
version: 1.1.0(monaco-editor@0.52.2)
vue-cli-plugin-vuetify:
specifier: 2.5.8
version: 2.5.8(sass-loader@13.3.2(sass@1.66.1)(webpack@5.105.0))(vue@3.3.4)(vuetify-loader@2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0))(webpack@5.105.0)
@@ -2571,11 +2568,6 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite-plugin-monaco-editor@1.1.0:
resolution: {integrity: sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==}
peerDependencies:
monaco-editor: '>=0.33.0'
vite-plugin-vuetify@1.0.2:
resolution: {integrity: sha512-MubIcKD33O8wtgQXlbEXE7ccTEpHZ8nPpe77y9Wy3my2MWw/PgehP9VqTp92BLqr0R1dSL970Lynvisx3UxBFw==}
engines: {node: '>=12'}
@@ -5305,10 +5297,6 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-plugin-monaco-editor@1.1.0(monaco-editor@0.52.2):
dependencies:
monaco-editor: 0.52.2
vite-plugin-vuetify@1.0.2(vite@4.4.9(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11):
dependencies:
'@vuetify/loader-shared': 1.7.1(vue@3.3.4)(vuetify@3.7.11)
@@ -0,0 +1,213 @@
<template>
<div class="config-profile-sidebar">
<div class="d-flex align-center justify-space-between mb-3">
<h3 class="text-subtitle-1 font-weight-bold mb-0">
<v-icon size="18" class="mr-1">mdi-format-list-bulleted-square</v-icon>
{{ tm('profileSidebar.title') }}
</h3>
<v-tooltip :text="tm('configManagement.manageConfigs')" location="top">
<template #activator="{ props: tooltipProps }">
<v-btn v-bind="tooltipProps" size="small" variant="text" icon="mdi-cog" :disabled="disabled"
@click="emit('manage')" />
</template>
</v-tooltip>
</div>
<div class="config-profile-list">
<v-card v-for="config in configs" :key="config.id" class="profile-card" :class="{
'profile-card--active': config.id === selectedConfigId,
'profile-card--disabled': disabled
}" variant="outlined" @click="onSelect(config.id)">
<div class="profile-card__name text-h4 d-flex align-center">
<v-icon size="24" class="mr-2">mdi-file-outline</v-icon>
{{ config.name }}
</div>
<div class="mt-3 d-flex" style="align-items: start; justify-content: center;">
<v-icon size="24" class="mr-1">mdi-routes</v-icon>
<div class="profile-card__bindings">
<template v-if="bindingsForConfig(config.id).length > 0">
<v-tooltip v-for="binding in visibleBindings(bindingsForConfig(config.id))"
:key="`${config.id}-${binding.platformId}`" location="top">
<template #activator="{ props: tooltipProps }">
<button v-bind="tooltipProps" type="button" class="binding-pill"
@click.stop="onManageRoutes(config.id)">
<v-avatar size="22" class="binding-avatar" rounded="sm">
<img v-if="getBindingIcon(binding)" :src="getBindingIcon(binding)" :alt="binding.platformId"
class="binding-avatar__img" />
<v-icon v-else size="14">mdi-robot-outline</v-icon>
</v-avatar>
<span class="binding-pill__label">
{{ binding.platformId }}
</span>
</button>
</template>
<div class="binding-tooltip-content">
<div class="text-caption font-weight-bold mb-1">
{{ tm('profileSidebar.platformId') }}: {{ binding.platformId }}
</div>
<div class="text-caption mb-1">
{{ tm('profileSidebar.umop') }}:
</div>
<div v-for="umop in binding.umops" :key="`${binding.platformId}-${umop}`" class="text-caption">
{{ umop }}
</div>
</div>
</v-tooltip>
<v-chip v-if="bindingsForConfig(config.id).length > maxVisibleBindings" size="x-small" variant="tonal"
color="primary">
+{{ bindingsForConfig(config.id).length - maxVisibleBindings }}
</v-chip>
</template>
<span v-else class="text-caption text-medium-emphasis">
{{ tm('profileSidebar.noBindings') }}
</span>
</div>
</div>
</v-card>
</div>
</div>
</template>
<script setup lang="ts">
import { useModuleI18n } from '@/i18n/composables';
import { getPlatformIcon } from '@/utils/platformUtils';
interface ConfigInfo {
id: string;
name: string;
}
interface ConfigBinding {
platformId: string;
platformType?: string;
umops: string[];
}
const props = withDefaults(defineProps<{
configs: ConfigInfo[];
selectedConfigId: string | null;
bindingsByConfigId: Record<string, ConfigBinding[]>;
disabled?: boolean;
}>(), {
selectedConfigId: null,
bindingsByConfigId: () => ({}),
disabled: false
});
const emit = defineEmits<{
select: [configId: string];
manage: [];
manageRoutes: [payload: { configId: string }];
}>();
const { tm } = useModuleI18n('features/config');
const maxVisibleBindings = 6;
function onSelect(configId: string): void {
if (props.disabled) {
return;
}
emit('select', configId);
}
function onManageRoutes(configId: string): void {
if (props.disabled) {
return;
}
emit('manageRoutes', { configId });
}
function bindingsForConfig(configId: string): ConfigBinding[] {
return props.bindingsByConfigId[configId] || [];
}
function visibleBindings(bindings: ConfigBinding[]): ConfigBinding[] {
return bindings.slice(0, maxVisibleBindings);
}
function getBindingIcon(binding: ConfigBinding): string | undefined {
if (binding.platformType) {
return getPlatformIcon(binding.platformType);
}
return getPlatformIcon(binding.platformId);
}
</script>
<style scoped>
.config-profile-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: calc(100vh - 210px);
overflow-y: auto;
padding-right: 4px;
}
.profile-card {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
border-radius: 12px;
cursor: pointer;
padding: 12px;
transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
}
.profile-card--active {
background: rgba(var(--v-theme-primary), 0.08);
}
.profile-card--disabled {
cursor: not-allowed;
opacity: 0.7;
}
.profile-card__name {
line-height: 1.3;
}
.profile-card__bindings {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
}
.binding-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px 2px 4px;
border-radius: 999px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.14);
background: rgba(var(--v-theme-surface), 1);
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.binding-pill:hover {
border-color: rgba(var(--v-theme-primary), 0.45);
background: rgba(var(--v-theme-primary), 0.06);
}
.binding-pill__label {
font-size: 0.78rem;
line-height: 1.1;
white-space: nowrap;
color: rgba(var(--v-theme-on-surface), 0.8);
}
.binding-avatar__img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 2px;
}
.binding-tooltip-content {
max-width: 380px;
word-break: break-all;
}
</style>
@@ -0,0 +1,236 @@
<template>
<v-dialog v-model="dialogVisible" max-width="800px">
<v-card>
<v-card-title class="d-flex align-center justify-space-between">
<div>
<div class="text-h3 pa-2">{{ props.configName }} {{ tm('routeManager.title') }}</div>
</div>
<v-btn icon="mdi-close" variant="text" @click="dialogVisible = false"></v-btn>
</v-card-title>
<v-card-text>
<div v-if="loading" class="d-flex justify-center py-4">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<div v-else>
<div class="text-caption text-medium-emphasis mb-4">
{{ tm('routeManager.hint') }}
</div>
<div v-if="groupedRoutes.length === 0" class="text-center py-4 text-medium-emphasis">
{{ tm('routeManager.empty') }}
</div>
<div v-for="(group, groupIndex) in groupedRoutes" :key="group.platformId">
<v-divider v-if="groupIndex > 0" class="my-3" />
<div class="route-group">
<div class="route-group-platform">
<v-avatar size="22" rounded="sm" class="route-platform-avatar">
<img
v-if="getRoutePlatformIcon(group.platformId)"
:src="getRoutePlatformIcon(group.platformId)"
:alt="group.platformId"
class="route-platform-avatar__img"
/>
<v-icon v-else size="14">mdi-robot-outline</v-icon>
</v-avatar>
<span class="text-body-2 font-weight-medium">{{ group.platformId }}</span>
<v-chip size="x-small" variant="tonal" color="primary">
{{ group.routes.length }}
</v-chip>
</div>
<div class="route-group-umops">
<div
v-for="route in group.routes"
:key="route.id"
class="route-umop-row"
:class="{ 'route-umop-row--all': isAllSessionsRoute(route.umop) }"
>
<span class="text-body-2 route-umop-row__text">
{{ isAllSessionsRoute(route.umop) ? tm('routeManager.allSessions') : route.umop }}
</span>
<div class="route-umop-row__actions">
<v-tooltip :text="tm('routeManager.delete')" location="top">
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="tooltipProps"
icon="mdi-delete-outline"
variant="text"
color="error"
size="small"
@click="emit('removeRoute', route.id)"
/>
</template>
</v-tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="dialogVisible = false">
{{ tm('buttons.cancel') }}
</v-btn>
<v-btn color="primary" :loading="saving" @click="emit('save')">
{{ tm('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { getPlatformIcon } from '@/utils/platformUtils';
interface RouteItem {
id: string;
platformId: string;
umop: string;
}
const props = withDefaults(defineProps<{
modelValue: boolean;
configId: string;
configName: string;
loading: boolean;
saving: boolean;
items: RouteItem[];
platformTypeMap: Record<string, string>;
}>(), {
modelValue: false,
configId: '',
configName: '',
loading: false,
saving: false,
items: () => [],
platformTypeMap: () => ({})
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
removeRoute: [routeId: string];
save: [];
}>();
const { tm } = useModuleI18n('features/config');
const dialogVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
});
const groupedRoutes = computed(() => {
const groups: Record<string, RouteItem[]> = {};
for (const item of props.items) {
const platformId = String(item.platformId || '').trim();
if (!platformId) {
continue;
}
if (!groups[platformId]) {
groups[platformId] = [];
}
groups[platformId].push(item);
}
return Object.entries(groups)
.map(([platformId, routes]) => ({
platformId,
routes: (() => {
const sortedRoutes = routes.sort((a, b) => a.umop.localeCompare(b.umop));
const allSessionsRoute = sortedRoutes.find((route) => isAllSessionsRoute(route.umop));
if (allSessionsRoute) {
return [allSessionsRoute];
}
return sortedRoutes;
})()
}))
.sort((a, b) => a.platformId.localeCompare(b.platformId));
});
function getRoutePlatformIcon(platformId: string): string | undefined {
const platformType = props.platformTypeMap[platformId] || platformId;
return getPlatformIcon(platformType);
}
function isAllSessionsRoute(umop: string): boolean {
return String(umop || '').endsWith(':*:*');
}
</script>
<style scoped>
.route-group-platform {
display: flex;
align-items: center;
gap: 8px;
min-height: 24px;
}
.route-group-umops {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.route-umop-row {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 6px;
padding: 2px 4px 2px 10px;
gap: 10px;
background: rgba(var(--v-theme-on-surface), 0.03);
}
.route-umop-row--all {
background: rgba(var(--v-theme-primary), 0.08);
}
.route-umop-row__text {
min-width: 0;
word-break: break-all;
}
.route-umop-row__actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.route-platform-avatar {
background: rgba(var(--v-theme-surface), 1);
}
.route-platform-avatar__img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 2px;
}
.route-group {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
@media (max-width: 767px) {
.route-group {
grid-template-columns: minmax(0, 1fr);
}
.route-group-platform {
margin-bottom: 2px;
}
.route-umop-row {
align-items: flex-start;
}
}
</style>
@@ -372,6 +372,7 @@ function closeProviderDrawer() {
white-space: nowrap;
max-width: calc(100% - 80px);
display: inline-block;
font-size: 13px;
}
.selected-preview {
@@ -69,6 +69,26 @@
"normalConfig": "Basic",
"systemConfig": "System"
},
"profileSidebar": {
"title": "Configuration Profiles",
"platformId": "Platform ID",
"umop": "Bound UMOP",
"noBindings": "No platform bindings"
},
"routeManager": {
"title": "Route Manager",
"targetConfig": "Config: {config}",
"hint": "AstrBot supports multiple config files, and routing decides which session uses which config. This dialog shows all routes handled by the current config: platform on the left and UMOP on the right; click Save after deleting routes.",
"empty": "No routes available to manage.",
"platform": "Platform",
"umop": "UMOP",
"allSessions": "All Sessions",
"delete": "Delete Route",
"loadFailed": "Failed to load routes",
"saveSuccess": "Routes saved",
"saveFailed": "Failed to save routes",
"routeOccupied": "This route is already occupied by another config: {umop}"
},
"search": {
"placeholder": "Search config items (key/description/hint)",
"noResult": "No matching config items found"
@@ -69,6 +69,26 @@
"normalConfig": "普通",
"systemConfig": "系统"
},
"profileSidebar": {
"title": "配置文件列表",
"platformId": "平台 ID",
"umop": "绑定 UMOP",
"noBindings": "暂无平台绑定"
},
"routeManager": {
"title": "路由管理",
"targetConfig": "配置:{config}",
"hint": "AstrBot 支持多配置文件,路由用于决定“哪个会话用哪个配置”。这里展示的是当前配置文件接管的全部路由:左侧是机器人 ID、右侧是匹配的消息会话来源。",
"empty": "暂无可管理的路由。",
"platform": "平台",
"umop": "UMOP",
"allSessions": "全部会话",
"delete": "删除路由",
"loadFailed": "加载路由失败",
"saveSuccess": "路由已保存",
"saveFailed": "保存路由失败",
"routeOccupied": "该路由已被其他配置占用:{umop}"
},
"search": {
"placeholder": "搜索配置项(字段名/描述/提示)",
"noResult": "未找到匹配的配置项"
+7 -4
View File
@@ -9,12 +9,9 @@ import '@/scss/style.scss';
import VueApexCharts from 'vue3-apexcharts';
import print from 'vue3-print-nb';
import { loader } from '@guolao/vue-monaco-editor';
import * as monaco from 'monaco-editor';
import { loader } from '@guolao/vue-monaco-editor'
import axios from 'axios';
loader.config({ monaco });
// 初始化新的i18n系统,等待完成后再挂载应用
setupI18n().then(() => {
console.log('🌍 新i18n系统初始化完成');
@@ -111,3 +108,9 @@ window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
}
return _origFetch(input, { ...init, headers });
};
loader.config({
paths: {
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs',
},
})
+505 -161
View File
@@ -1,81 +1,119 @@
<template>
<div style="display: flex; flex-direction: column; align-items: center;">
<div v-if="selectedConfigID || isSystemConfig" class="mt-4 config-panel"
style="display: flex; flex-direction: column; align-items: start;">
<div class="config-toolbar d-flex flex-row pr-4"
style="margin-bottom: 16px; align-items: center; gap: 12px; width: 100%; justify-content: space-between;">
<div class="config-toolbar-controls d-flex flex-row align-center" style="gap: 12px;">
<v-select class="config-select" style="min-width: 130px;" :model-value="selectedConfigID" :items="configSelectItems" item-title="name" :disabled="initialConfigId !== null"
v-if="!isSystemConfig" item-value="id" :label="tm('configSelection.selectConfig')" hide-details density="compact" rounded="md"
variant="outlined" @update:model-value="onConfigSelect">
</v-select>
<v-text-field
class="config-search-input"
v-model="configSearchKeyword"
prepend-inner-icon="mdi-magnify"
:label="tm('search.placeholder')"
hide-details
density="compact"
rounded="md"
variant="outlined"
style="min-width: 280px;"
<div class="config-page-wrap">
<div v-if="selectedConfigID || isSystemConfig" class="mt-4 config-panel">
<div class="config-workbench" :class="{ 'config-workbench--system': isSystemConfig || !!initialConfigId }">
<aside v-if="!isSystemConfig && !initialConfigId" class="config-sidebar">
<ConfigProfileSidebar
:configs="configInfoList"
:selected-config-id="selectedConfigID"
:bindings-by-config-id="configBindingsById"
:disabled="initialConfigId !== null"
@select="onConfigSelect"
@manage="openConfigManageDialog"
@manage-routes="openRouteManageDialog"
/>
<!-- <a style="color: inherit;" href="https://blog.astrbot.app/posts/what-is-changed-in-4.0.0/#%E5%A4%9A%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6" target="_blank"><v-btn icon="mdi-help-circle" size="small" variant="plain"></v-btn></a> -->
</aside>
</div>
<section class="config-main">
<div class="config-toolbar d-flex flex-row">
<div class="config-toolbar-controls d-flex flex-row align-center">
<div v-if="!isSystemConfig" class="config-current-title">
<h2 class="config-current-title__name">
{{ selectedConfigInfo.name || selectedConfigID }}
</h2>
<div class="config-current-title__id text-caption text-medium-emphasis">
ID: {{ selectedConfigID }}
</div>
</div>
<v-select
v-if="!isSystemConfig && !initialConfigId"
class="config-select config-select--mobile"
:model-value="selectedConfigID"
:items="configSelectItems"
item-title="name"
:disabled="initialConfigId !== null"
item-value="id"
:label="tm('configSelection.selectConfig')"
hide-details
density="compact"
rounded="md"
variant="outlined"
@update:model-value="onConfigSelect"
/>
<v-tooltip v-if="!isSystemConfig && !initialConfigId" :text="tm('configManagement.manageConfigs')" location="top">
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="tooltipProps"
class="config-manage-mobile"
variant="text"
icon="mdi-cog"
:disabled="initialConfigId !== null"
@click="openConfigManageDialog"
/>
</template>
</v-tooltip>
<v-text-field
class="config-search-input"
v-model="configSearchKeyword"
prepend-inner-icon="mdi-magnify"
:label="tm('search.placeholder')"
hide-details
density="compact"
rounded="md"
variant="outlined"
/>
</div>
</div>
<v-fade-transition>
<div v-if="fetched && hasUnsavedChanges && !isLoadingConfig" class="unsaved-changes-banner-wrap">
<v-banner
icon="$warning"
lines="one"
class="unsaved-changes-banner my-4"
>
{{ tm('messages.unsavedChangesNotice') }}
</v-banner>
</div>
</v-fade-transition>
<v-fade-transition mode="out-in">
<div v-if="(selectedConfigID || isSystemConfig) && fetched" :key="configContentKey" class="config-content">
<AstrBotCoreConfigWrapper
:metadata="metadata"
:config_data="config_data"
:search-keyword="configSearchKeyword"
/>
<v-tooltip :text="tm('actions.save')" location="left">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-content-save" size="x-large" style="position: fixed; right: 52px; bottom: 52px;"
color="darkprimary" @click="updateConfig">
</v-btn>
</template>
</v-tooltip>
<v-tooltip :text="tm('codeEditor.title')" location="left">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-code-json" size="x-large" style="position: fixed; right: 52px; bottom: 124px;" color="primary"
@click="configToString(); codeEditorDialog = true">
</v-btn>
</template>
</v-tooltip>
<v-tooltip text="测试当前配置" location="left" v-if="!isSystemConfig">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-chat-processing" size="x-large"
style="position: fixed; right: 52px; bottom: 196px;" color="secondary"
@click="openTestChat">
</v-btn>
</template>
</v-tooltip>
</div>
</v-fade-transition>
</section>
</div>
<v-slide-y-transition>
<div v-if="fetched && hasUnsavedChanges" class="unsaved-changes-banner-wrap">
<v-banner
icon="$warning"
lines="one"
class="unsaved-changes-banner my-4"
>
{{ tm('messages.unsavedChangesNotice') }}
</v-banner>
</div>
</v-slide-y-transition>
<!-- <v-progress-linear v-if="!fetched" indeterminate color="primary"></v-progress-linear> -->
<v-slide-y-transition mode="out-in">
<div v-if="(selectedConfigID || isSystemConfig) && fetched" :key="configContentKey" class="config-content" style="width: 100%;">
<!-- 可视化编辑 -->
<AstrBotCoreConfigWrapper
:metadata="metadata"
:config_data="config_data"
:search-keyword="configSearchKeyword"
/>
<v-tooltip :text="tm('actions.save')" location="left">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-content-save" size="x-large" style="position: fixed; right: 52px; bottom: 52px;"
color="darkprimary" @click="updateConfig">
</v-btn>
</template>
</v-tooltip>
<v-tooltip :text="tm('codeEditor.title')" location="left">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-code-json" size="x-large" style="position: fixed; right: 52px; bottom: 124px;" color="primary"
@click="configToString(); codeEditorDialog = true">
</v-btn>
</template>
</v-tooltip>
<v-tooltip text="测试当前配置" location="left" v-if="!isSystemConfig">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-chat-processing" size="x-large"
style="position: fixed; right: 52px; bottom: 196px;" color="secondary"
@click="openTestChat">
</v-btn>
</template>
</v-tooltip>
</div>
</v-slide-y-transition>
</div>
</div>
@@ -158,6 +196,18 @@
</v-card>
</v-dialog>
<ConfigRouteManagerDialog
v-model="routeManageDialog"
:config-id="routeManageConfigId"
:config-name="routeManageConfigName"
:loading="routeManageLoading"
:saving="routeManageSaving"
:items="routeManageItems"
:platform-type-map="routeManagePlatformTypeMap"
@remove-route="removeRouteItem"
@save="saveRouteManageDialog"
/>
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
{{ save_message }}
</v-snackbar>
@@ -201,6 +251,8 @@
<script>
import axios from 'axios';
import AstrBotCoreConfigWrapper from '@/components/config/AstrBotCoreConfigWrapper.vue';
import ConfigProfileSidebar from '@/components/config/ConfigProfileSidebar.vue';
import ConfigRouteManagerDialog from '@/components/config/ConfigRouteManagerDialog.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import StandaloneChat from '@/components/chat/StandaloneChat.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
@@ -216,6 +268,8 @@ export default {
name: 'ConfigPage',
components: {
AstrBotCoreConfigWrapper,
ConfigProfileSidebar,
ConfigRouteManagerDialog,
VueMonacoEditor,
WaitingForRestart,
StandaloneChat,
@@ -295,19 +349,7 @@ export default {
return this.configInfoList.find(info => info.id === this.selectedConfigID) || {};
},
configSelectItems() {
const items = [...this.configInfoList];
items.push({
id: '_%manage%_',
name: this.tm('configManagement.manageConfigs'),
umop: []
});
return items;
},
hasUnsavedChanges() {
if (!this.fetched) {
return false;
}
return this.getConfigSnapshot(this.config_data) !== this.lastSavedConfigSnapshot;
return [...this.configInfoList];
}
},
watch: {
@@ -317,7 +359,7 @@ export default {
config_data: {
deep: true,
handler() {
if (this.fetched) {
if (this.fetched && !this.isLoadingConfig) {
this.hasUnsavedChanges = this.configHasChanges;
}
}
@@ -338,6 +380,13 @@ export default {
return {
codeEditorDialog: false,
configManageDialog: false,
routeManageDialog: false,
routeManageLoading: false,
routeManageSaving: false,
routeManageConfigId: '',
routeManageConfigName: '',
routeManageItems: [],
routeManagePlatformTypeMap: {},
showConfigForm: false,
isEditingConfig: false,
config_data_has_changed: false,
@@ -345,13 +394,13 @@ export default {
config_data: {
config: {}
},
isLoadingConfig: false,
fetched: false,
metadata: {},
save_message_snack: false,
save_message: "",
save_message_success: "",
configContentKey: 0,
lastSavedConfigSnapshot: '',
configContentKey: 0,
// 配置类型切换
configType: 'normal', // 'normal' 或 'system'
@@ -364,6 +413,7 @@ export default {
selectedConfigID: null, // 用于存储当前选中的配置项信息
currentConfigId: null, // 跟踪当前正在编辑的配置id
configInfoList: [],
configBindingsById: {},
configFormData: {
name: '',
},
@@ -409,16 +459,12 @@ export default {
methods: {
// 处理语言切换事件,重新加载配置以获取插件的 i18n 数据
handleLocaleChange() {
// 重新加载当前配置
if (this.selectedConfigID) {
this.getConfig(this.selectedConfigID);
} else if (this.isSystemConfig) {
this.getConfig();
}
},
},
methods: {
extractConfigTypeFromHash(hash) {
const rawHash = String(hash || '');
const lastHashIndex = rawHash.lastIndexOf('#');
@@ -438,10 +484,232 @@ export default {
await this.onConfigTypeToggle();
return true;
},
openConfigManageDialog() {
this.configManageDialog = true;
},
parseUmop(umop) {
const raw = String(umop || '');
const parts = raw.split(':');
if (parts.length < 3) {
return {
platformId: raw || '*',
messageType: '*',
sessionId: '*'
};
}
return {
platformId: parts[0] || '*',
messageType: parts[1] || '*',
sessionId: parts.slice(2).join(':') || '*'
};
},
createRouteItem(umop) {
const parsed = this.parseUmop(umop);
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
platformId: parsed.platformId,
umop
};
},
isRouteEntryForConfig(umop, confId, targetConfigId) {
if (String(confId || '') !== String(targetConfigId || '')) {
return false;
}
const parsed = this.parseUmop(umop);
return parsed.platformId !== 'webchat';
},
async openRouteManageDialog(payload) {
const configId = payload?.configId;
if (!configId) {
return;
}
this.routeManageDialog = true;
this.routeManageLoading = true;
this.routeManageConfigId = configId;
this.routeManageConfigName = this.configInfoList.find((item) => item.id === configId)?.name || configId;
this.routeManageItems = [];
this.routeManagePlatformTypeMap = {};
try {
const [routeRes, platformRes] = await Promise.all([
axios.get('/api/config/umo_abconf_routes'),
axios.get('/api/config/platform/list')
]);
const routing = routeRes?.data?.data?.routing || {};
const platforms = platformRes?.data?.data?.platforms || [];
const typeMap = {};
for (const platform of platforms) {
const pid = String(platform?.id || '').trim();
if (!pid) {
continue;
}
typeMap[pid] = platform.platform_type || platform.type || pid;
}
this.routeManagePlatformTypeMap = typeMap;
const matched = [];
for (const [umop, conf] of Object.entries(routing)) {
if (!this.isRouteEntryForConfig(umop, conf, configId)) {
continue;
}
matched.push(this.createRouteItem(umop));
}
this.routeManageItems = matched.sort((a, b) => {
const platformCompare = a.platformId.localeCompare(b.platformId);
if (platformCompare !== 0) {
return platformCompare;
}
return a.umop.localeCompare(b.umop);
});
} catch (err) {
console.error('Failed to load routes for route manager:', err);
this.save_message = this.tm('routeManager.loadFailed');
this.save_message_snack = true;
this.save_message_success = "error";
this.routeManageItems = [];
} finally {
this.routeManageLoading = false;
}
},
removeRouteItem(entryId) {
this.routeManageItems = this.routeManageItems.filter((item) => item.id !== entryId);
},
async saveRouteManageDialog() {
if (!this.routeManageConfigId) {
return;
}
this.routeManageSaving = true;
try {
const res = await axios.get('/api/config/umo_abconf_routes');
const routing = res?.data?.data?.routing || {};
const entries = Object.entries(routing);
const nonTargetEntries = [];
const nonTargetUmopSet = new Set();
let firstTargetIndex = -1;
entries.forEach(([umop, confId], index) => {
if (this.isRouteEntryForConfig(umop, confId, this.routeManageConfigId)) {
if (firstTargetIndex === -1) {
firstTargetIndex = index;
}
return;
}
nonTargetEntries.push([umop, confId]);
nonTargetUmopSet.add(umop);
});
const targetEntries = [];
for (const item of this.routeManageItems) {
const umop = String(item.umop || '').trim();
if (!umop) {
continue;
}
if (nonTargetUmopSet.has(umop)) {
this.save_message = this.tm('routeManager.routeOccupied', { umop });
this.save_message_snack = true;
this.save_message_success = "error";
this.routeManageSaving = false;
return;
}
targetEntries.push([umop, this.routeManageConfigId]);
}
const insertIndex = firstTargetIndex === -1 ? nonTargetEntries.length : Math.min(firstTargetIndex, nonTargetEntries.length);
const mergedEntries = [
...nonTargetEntries.slice(0, insertIndex),
...targetEntries,
...nonTargetEntries.slice(insertIndex)
];
const mergedRouting = Object.fromEntries(mergedEntries);
await axios.post('/api/config/umo_abconf_route/update_all', {
routing: mergedRouting
});
this.routeManageDialog = false;
this.save_message = this.tm('routeManager.saveSuccess');
this.save_message_snack = true;
this.save_message_success = "success";
await this.refreshConfigBindings();
} catch (err) {
console.error('Failed to save routes for route manager:', err);
this.save_message = this.tm('routeManager.saveFailed');
this.save_message_snack = true;
this.save_message_success = "error";
} finally {
this.routeManageSaving = false;
}
},
buildConfigBindingMap(routingTable, platforms) {
const platformTypeMap = {};
for (const platform of platforms || []) {
if (!platform?.id) {
continue;
}
platformTypeMap[platform.id] = platform.platform_type || platform.type || platform.id;
}
const grouped = {};
for (const [umop, confId] of Object.entries(routingTable || {})) {
const resolvedConfigId = String(confId || 'default');
const parsed = this.parseUmop(umop);
const platformId = parsed.platformId || '*';
if (platformId === 'webchat') {
continue;
}
if (!grouped[resolvedConfigId]) {
grouped[resolvedConfigId] = {};
}
if (!grouped[resolvedConfigId][platformId]) {
grouped[resolvedConfigId][platformId] = {
platformId,
platformType: platformTypeMap[platformId] || platformId,
umops: []
};
}
grouped[resolvedConfigId][platformId].umops.push(umop);
}
const bindingMap = {};
for (const [confId, platformsById] of Object.entries(grouped)) {
bindingMap[confId] = Object.values(platformsById).sort((a, b) => {
return a.platformId.localeCompare(b.platformId);
});
}
return bindingMap;
},
async refreshConfigBindings() {
try {
const [routesRes, platformsRes] = await Promise.all([
axios.get('/api/config/umo_abconf_routes'),
axios.get('/api/config/platform/list')
]);
const routing = routesRes?.data?.data?.routing || {};
const platforms = platformsRes?.data?.data?.platforms || [];
this.configBindingsById = this.buildConfigBindingMap(routing, platforms);
} catch (err) {
console.error('Failed to load config bindings:', err);
this.configBindingsById = {};
}
},
getConfigInfoList(abconf_id) {
// 获取配置列表
axios.get('/api/config/abconfs').then((res) => {
this.configInfoList = res.data.data.info_list;
const infoList = Array.isArray(res.data?.data?.info_list) ? res.data.data.info_list : [];
this.configInfoList = [...infoList].sort((a, b) => {
if (a.id === 'default' && b.id !== 'default') {
return -1;
}
if (a.id !== 'default' && b.id === 'default') {
return 1;
}
return 0;
});
this.refreshConfigBindings();
if (abconf_id) {
let matched = false;
@@ -466,9 +734,12 @@ export default {
this.save_message = this.messages.loadError;
this.save_message_snack = true;
this.save_message_success = "error";
this.configBindingsById = {};
});
},
getConfig(abconf_id) {
this.isLoadingConfig = true;
this.hasUnsavedChanges = false;
this.fetched = false
const params = {};
@@ -482,22 +753,20 @@ export default {
params: params
}).then((res) => {
this.config_data = res.data.data.config;
this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.fetched = true
this.metadata = res.data.data.metadata;
this.originalConfigData = JSON.parse(JSON.stringify(this.config_data));
this.hasUnsavedChanges = false;
this.configContentKey += 1;
// 获取配置后更新
this.$nextTick(() => {
this.originalConfigData = JSON.parse(JSON.stringify(this.config_data));
this.hasUnsavedChanges = false;
if (!this.isSystemConfig) {
this.currentConfigId = abconf_id || this.selectedConfigID;
}
});
if (!this.isSystemConfig) {
this.currentConfigId = abconf_id || this.selectedConfigID;
}
this.fetched = true;
}).catch((err) => {
this.save_message = this.messages.loadError;
this.save_message_snack = true;
this.save_message_success = "error";
}).finally(() => {
this.isLoadingConfig = false;
});
},
updateConfig() {
@@ -515,7 +784,6 @@ export default {
return axios.post('/api/config/astrbot/update', postData).then((res) => {
if (res.data.status === "ok") {
this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.save_message = res.data.message || this.messages.saveSuccess;
this.save_message_snack = true;
this.save_message_success = "success";
@@ -584,52 +852,38 @@ export default {
});
},
async onConfigSelect(value) {
if (value === '_%manage%_') {
this.configManageDialog = true;
// 重置选择到之前的值
this.$nextTick(() => {
this.selectedConfigID = this.selectedConfigInfo.id || 'default';
this.getConfig(this.selectedConfigID);
if (!value || value === this.selectedConfigID) {
return;
}
if (this.hasUnsavedChanges) {
const prevConfigId = this.isSystemConfig ? 'default' : (this.currentConfigId || this.selectedConfigID || 'default');
const message = this.tm('unsavedChangesWarning.switchConfig');
const saveAndSwitch = await this.$refs.unsavedChangesDialog?.open({
title: this.tm('unsavedChangesWarning.dialogTitle'),
message: message,
confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
});
} else {
// 检查是否有未保存的更改
if (this.hasUnsavedChanges) {
// 获取之前正在编辑的配置id
const prevConfigId = this.isSystemConfig ? 'default' : (this.currentConfigId || this.selectedConfigID || 'default');
const message = this.tm('unsavedChangesWarning.switchConfig');
const saveAndSwitch = await this.$refs.unsavedChangesDialog?.open({
title: this.tm('unsavedChangesWarning.dialogTitle'),
message: message,
confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
});
// 关闭弹窗不切换
if (saveAndSwitch === 'close') {
return;
}
if (saveAndSwitch) {
// 设置临时变量保存切换后的id
const currentSelectedId = this.selectedConfigID;
// 把id设置回切换前的用于保存上一次的配置,保存完后恢复id为切换后的
this.selectedConfigID = prevConfigId;
const result = await this.updateConfig();
this.selectedConfigID = currentSelectedId;
if (result?.success) {
this.selectedConfigID = value;
this.getConfig(value);
}
return;
} else {
// 取消保存并切换配置
if (saveAndSwitch === 'close') {
return;
}
if (saveAndSwitch) {
const currentSelectedId = this.selectedConfigID;
this.selectedConfigID = prevConfigId;
const result = await this.updateConfig();
this.selectedConfigID = currentSelectedId;
if (result?.success) {
this.selectedConfigID = value;
this.getConfig(value);
}
} else {
// 无未保存更改直接切换
this.selectedConfigID = value;
this.getConfig(value);
return;
}
this.selectedConfigID = value;
this.getConfig(value);
} else {
this.selectedConfigID = value;
this.getConfig(value);
}
},
startCreateConfig() {
@@ -758,6 +1012,7 @@ export default {
// 切换到系统配置
this.getConfig();
} else {
this.refreshConfigBindings();
// 切换回普通配置,如果有选中的配置文件则加载,否则加载default
if (this.selectedConfigID) {
this.getConfig(this.selectedConfigID);
@@ -785,9 +1040,6 @@ export default {
closeTestChat() {
this.testChatDrawer = false;
this.testConfigId = null;
},
getConfigSnapshot(config) {
return JSON.stringify(config ?? {});
}
},
}
@@ -799,6 +1051,80 @@ export default {
text-transform: none !important;
}
.config-page-wrap {
display: flex;
justify-content: center;
}
.config-panel {
width: min(1160px, calc(100vw - 48px));
}
.config-workbench {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.config-workbench--system {
grid-template-columns: minmax(0, 1fr);
}
.config-sidebar {
position: sticky;
top: calc(var(--v-layout-top, 64px) + 16px);
}
.config-main {
min-width: 0;
}
.config-current-title {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.config-current-title__name {
font-family: inherit;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.2;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.config-current-title__id {
line-height: 1.2;
}
.config-toolbar {
margin-bottom: 16px;
align-items: center;
width: 100%;
}
.config-toolbar-controls {
width: 100%;
gap: 12px;
}
.config-search-input {
min-width: 180px;
max-width: 300px;
width: 100%;
margin-left: auto;
}
.config-select--mobile,
.config-manage-mobile {
display: none;
}
.unsaved-changes-banner {
border-radius: 8px;
}
@@ -852,35 +1178,53 @@ export default {
font-style: italic;
}
@media (min-width: 768px) {
.config-panel {
width: 750px;
@media (max-width: 959px) {
.config-workbench {
grid-template-columns: minmax(0, 1fr);
}
.config-sidebar {
display: none;
}
.config-select--mobile,
.config-manage-mobile {
display: inline-flex;
}
.config-select--mobile {
min-width: 180px;
max-width: 280px;
}
}
@media (max-width: 767px) {
.v-container {
padding: 4px;
}
.config-panel {
width: 100%;
margin-top: 0 !important;
}
.config-toolbar {
padding-right: 0 !important;
.config-page-wrap {
padding: 0 8px;
}
.config-toolbar-controls {
width: 100%;
flex-wrap: wrap;
}
.config-select,
.config-select--mobile,
.config-search-input {
width: 100%;
min-width: 0 !important;
max-width: 100%;
min-width: 0;
}
.config-manage-mobile {
width: auto;
max-width: none;
min-width: auto;
}
}
/* 测试聊天抽屉样式 */
+1 -3
View File
@@ -2,7 +2,6 @@ import { fileURLToPath, URL } from 'url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vuetify from 'vite-plugin-vuetify';
import monacoEditorPlugin from 'vite-plugin-monaco-editor';
// https://vitejs.dev/config/
export default defineConfig({
@@ -16,8 +15,7 @@ export default defineConfig({
}),
vuetify({
autoImport: true
}),
monacoEditorPlugin({})
})
],
resolve: {
alias: {
+1 -324
View File
@@ -5,7 +5,7 @@ import os
import re
import zipfile
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -17,8 +17,6 @@ from astrbot.core.backup import (
)
from astrbot.core.backup.exporter import AstrBotExporter
from astrbot.core.backup.importer import (
DatabaseClearError,
PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT,
AstrBotImporter,
ImportResult,
_get_major_version,
@@ -310,298 +308,6 @@ class TestAstrBotImporter:
assert isinstance(result["created_at"], datetime)
assert isinstance(result["updated_at"], datetime)
def test_merge_platform_stats_rows(self):
"""测试 platform_stats 重复键会在导入前聚合"""
importer = AstrBotImporter(main_db=MagicMock())
rows = [
{
"id": 1,
"timestamp": "2025-12-13T20:00:00Z",
"platform_id": "webchat",
"platform_type": "unknown",
"count": 14,
},
{
"id": 80,
"timestamp": "2025-12-13T20:00:00+00:00",
"platform_id": "webchat",
"platform_type": "unknown",
"count": 3,
},
{
"id": 81,
"timestamp": "2025-12-13T20:00:00",
"platform_id": "webchat",
"platform_type": "unknown",
"count": 2,
},
{
"id": 2,
"timestamp": "2025-12-13T21:00:00",
"platform_id": "aiocqhttp",
"platform_type": "unknown",
"count": 1,
},
]
merged_rows = importer._merge_platform_stats_rows(rows)
duplicate_count = len(rows) - len(merged_rows)
assert duplicate_count == 2
assert len(merged_rows) == 2
webchat_row = next(
(
r
for r in merged_rows
if r.get("timestamp") == "2025-12-13T20:00:00+00:00"
and r.get("platform_id") == "webchat"
and r.get("platform_type") == "unknown"
),
None,
)
assert webchat_row is not None
assert webchat_row["timestamp"] == "2025-12-13T20:00:00+00:00"
assert webchat_row["platform_id"] == "webchat"
assert webchat_row["platform_type"] == "unknown"
assert webchat_row["count"] == 19
aiocq_row = next(
(
r
for r in merged_rows
if r.get("platform_id") == "aiocqhttp"
and r.get("platform_type") == "unknown"
),
None,
)
assert aiocq_row is not None
assert aiocq_row["timestamp"] == "2025-12-13T21:00:00+00:00"
def test_merge_platform_stats_rows_normalizes_naive_timestamp_to_utc(self):
"""测试 platform_stats 合并前会将 naive timestamp 标准化为 UTC 偏移"""
importer = AstrBotImporter(main_db=MagicMock())
rows = [
{
"timestamp": "2025-12-13T21:00:00",
"platform_id": "webchat",
"platform_type": "unknown",
"count": 1,
},
{
"timestamp": datetime(2025, 12, 13, 22, 0, 0),
"platform_id": "telegram",
"platform_type": "unknown",
"count": 1,
},
]
merged_rows = importer._merge_platform_stats_rows(rows)
assert len(merged_rows) == 2
by_platform = {row["platform_id"]: row for row in merged_rows}
assert by_platform["webchat"]["timestamp"] == "2025-12-13T21:00:00+00:00"
assert by_platform["telegram"]["timestamp"] == "2025-12-13T22:00:00+00:00"
def test_merge_platform_stats_rows_warns_on_invalid_count(self):
"""测试 platform_stats count 非法时会告警并按 0 处理(含上限)"""
importer = AstrBotImporter(main_db=MagicMock())
with patch("astrbot.core.backup.importer.logger.warning") as warning_mock:
rows = [
{
"timestamp": "2025-12-13T20:00:00+00:00",
"platform_id": "webchat",
"platform_type": "unknown",
"count": 5,
},
{
"timestamp": "2025-12-13T20:00:00Z",
"platform_id": "webchat",
"platform_type": "unknown",
"count": "bad-count",
},
]
merged_rows = importer._merge_platform_stats_rows(rows)
duplicate_count = len(rows) - len(merged_rows)
assert duplicate_count == 1
assert len(merged_rows) == 1
assert merged_rows[0]["count"] == 5
assert warning_mock.call_count == 1
warning_mock.reset_mock()
rows_existing_invalid = [
{
"timestamp": "2025-12-13T21:00:00+00:00",
"platform_id": "webchat",
"platform_type": "unknown",
"count": "bad-count",
},
{
"timestamp": "2025-12-13T21:00:00Z",
"platform_id": "webchat",
"platform_type": "unknown",
"count": 7,
},
]
merged_rows = importer._merge_platform_stats_rows(rows_existing_invalid)
duplicate_count = len(rows_existing_invalid) - len(merged_rows)
assert duplicate_count == 1
assert len(merged_rows) == 1
assert merged_rows[0]["count"] == 7
assert warning_mock.call_count == 1
warning_mock.reset_mock()
many_invalid_rows = [
{
"timestamp": "2025-12-13T22:00:00+00:00",
"platform_id": "webchat",
"platform_type": "unknown",
"count": 1,
},
*[
{
"timestamp": "2025-12-13T22:00:00Z",
"platform_id": "webchat",
"platform_type": "unknown",
"count": "bad-count",
}
for _ in range(PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT + 5)
],
]
importer._merge_platform_stats_rows(many_invalid_rows)
assert (
warning_mock.call_count == PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT + 1
)
assert any(
"告警已达到上限" in str(call.args[0])
for call in warning_mock.call_args_list
)
warning_mock.reset_mock()
single_invalid_row = [
{
"timestamp": "2025-12-13T23:00:00+00:00",
"platform_id": "telegram",
"platform_type": "unknown",
"count": "still-bad",
},
]
merged_rows = importer._merge_platform_stats_rows(single_invalid_row)
duplicate_count = len(single_invalid_row) - len(merged_rows)
assert duplicate_count == 0
assert len(merged_rows) == 1
assert merged_rows[0]["count"] == 0
assert warning_mock.call_count == 1
def test_merge_platform_stats_rows_keeps_invalid_timestamps_distinct(self):
"""测试空/非法 timestamp 不参与聚合,避免误合并"""
importer = AstrBotImporter(main_db=MagicMock())
rows = [
{
"timestamp": "",
"platform_id": "webchat",
"platform_type": "unknown",
"count": 2,
},
{
"timestamp": "not-a-datetime",
"platform_id": "webchat",
"platform_type": "unknown",
"count": 3,
},
{
"timestamp": "not-a-datetime",
"platform_id": "webchat",
"platform_type": "unknown",
"count": 4,
},
]
merged_rows = importer._merge_platform_stats_rows(rows)
duplicate_count = len(rows) - len(merged_rows)
assert duplicate_count == 0
assert len(merged_rows) == 3
assert [row["count"] for row in merged_rows] == [2, 3, 4]
def test_merge_platform_stats_rows_keeps_non_string_platform_keys_distinct(self):
"""测试非字符串 platform_id/platform_type 不参与聚合"""
importer = AstrBotImporter(main_db=MagicMock())
rows = [
{
"timestamp": "2025-12-13T20:00:00+00:00",
"platform_id": None,
"platform_type": "unknown",
"count": 2,
},
{
"timestamp": "2025-12-13T20:00:00Z",
"platform_id": None,
"platform_type": "unknown",
"count": 3,
},
{
"timestamp": "2025-12-13T20:00:00+00:00",
"platform_id": "webchat",
"platform_type": 1,
"count": 4,
},
{
"timestamp": "2025-12-13T20:00:00Z",
"platform_id": "webchat",
"platform_type": 1,
"count": 5,
},
]
merged_rows = importer._merge_platform_stats_rows(rows)
duplicate_count = len(rows) - len(merged_rows)
assert duplicate_count == 0
assert len(merged_rows) == 4
def test_merge_platform_stats_rows_preserves_input_order(self):
"""测试 platform_stats 聚合后仍保持输入顺序(按首次出现位置)"""
importer = AstrBotImporter(main_db=MagicMock())
rows = [
{
"id": 1,
"timestamp": "2025-12-13T20:00:00Z",
"platform_id": "webchat",
"platform_type": "unknown",
"count": 2,
},
{
"id": 2,
"timestamp": "",
"platform_id": "webchat",
"platform_type": "unknown",
"count": 3,
},
{
"id": 3,
"timestamp": "2025-12-13T20:00:00+00:00",
"platform_id": "webchat",
"platform_type": "unknown",
"count": 5,
},
{
"id": 4,
"timestamp": "2025-12-13T21:00:00+00:00",
"platform_id": "telegram",
"platform_type": "unknown",
"count": 7,
},
]
merged_rows = importer._merge_platform_stats_rows(rows)
assert len(merged_rows) == 3
assert [row["id"] for row in merged_rows] == [1, 2, 4]
assert merged_rows[0]["count"] == 7
@pytest.mark.asyncio
async def test_import_file_not_exists(self, mock_main_db, tmp_path):
"""测试导入不存在的文件"""
@@ -659,35 +365,6 @@ class TestAstrBotImporter:
assert result.success is False
assert any("主版本不兼容" in err for err in result.errors)
@pytest.mark.asyncio
async def test_import_replace_fails_when_clear_main_db_fails(
self, mock_main_db, tmp_path
):
"""测试 replace 模式下主库清空失败会直接终止导入"""
zip_path = tmp_path / "valid_backup.zip"
manifest = {
"version": "1.1",
"astrbot_version": VERSION,
"tables": {"platform_stats": 0},
}
main_data = {"platform_stats": []}
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("manifest.json", json.dumps(manifest))
zf.writestr("databases/main_db.json", json.dumps(main_data))
importer = AstrBotImporter(main_db=mock_main_db)
importer._clear_main_db = AsyncMock(
side_effect=DatabaseClearError("清空表 platform_stats 失败: db locked")
)
importer._import_main_database = AsyncMock(return_value={})
result = await importer.import_all(str(zip_path), mode="replace")
assert result.success is False
assert any("清空主数据库失败" in err for err in result.errors)
assert any("清空表 platform_stats 失败" in err for err in result.errors)
importer._import_main_database.assert_not_awaited()
class TestSecureFilename:
"""安全文件名函数测试"""