Refactor: 重构配置文件管理,以支持更灵活的、会话粒度的(基于 umo part)配置文件隔离 (#2328)

* refactor: 重构配置文件管理,以支持更灵活的、基于 umo part 的配置文件隔离

* Refactor: 重构配置前端页面,新增数个配置项 (#2331)

* refactor: 重构配置前端页面,新增数个配置项

* feat: 完善多配置文件结构

* perf: 系统配置入口

* fix: normal config item list not display

* fix: 修复 axios 请求中的上下文引用问题
This commit is contained in:
Soulter
2025-08-13 09:18:49 +08:00
committed by GitHub
parent 6c1f540170
commit 369eab18ab
31 changed files with 2611 additions and 786 deletions
+250
View File
@@ -0,0 +1,250 @@
import os
import uuid
from astrbot.core import AstrBotConfig, logger
from astrbot.core.utils.shared_preferences import SharedPreferences
from astrbot.core.config.astrbot_config import ASTRBOT_CONFIG_PATH
from astrbot.core.config.default import DEFAULT_CONFIG
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.utils.astrbot_path import get_astrbot_config_path
from typing import TypeVar, TypedDict
_VT = TypeVar("_VT")
class ConfInfo(TypedDict):
"""Configuration information for a specific session or platform."""
id: str # UUID of the configuration or "default"
umop: list[str] # Unified Message Origin Pattern
name: str
path: str # File name to the configuration file
DEFAULT_CONFIG_CONF_INFO = ConfInfo(
id="default",
umop=["::"],
name="default",
path=ASTRBOT_CONFIG_PATH,
)
class AstrBotConfigManager:
"""A class to manage the system configuration of AstrBot, aka ACM"""
def __init__(self, default_config: AstrBotConfig, sp: SharedPreferences):
self.sp = sp
self.confs: dict[str, AstrBotConfig] = {}
"""uuid / "default" -> AstrBotConfig"""
self.confs["default"] = default_config
self._load_all_configs()
def _load_all_configs(self):
"""Load all configurations from the shared preferences."""
abconf_data = self.sp.get("abconf_mapping", {})
for uuid_, meta in abconf_data.items():
filename = meta["path"]
conf_path = os.path.join(get_astrbot_config_path(), filename)
if os.path.exists(conf_path):
conf = AstrBotConfig(config_path=conf_path)
self.confs[uuid_] = conf
else:
logger.warning(
f"Config file {conf_path} for UUID {uuid_} does not exist, skipping."
)
continue
def _is_umo_match(self, p1: str, p2: str) -> bool:
"""判断 p2 umo 是否逻辑包含于 p1 umo"""
p1 = p1.split(":")
p2 = p2.split(":")
if len(p1) != 3 or len(p2) != 3:
return False # 非法格式
return all(p == "" or p == t for p, t in zip(p1, p2))
def _load_conf_mapping(self, umo: str | MessageSession) -> ConfInfo:
"""获取指定 umo 的配置文件 uuid, 如果不存在则返回默认配置(返回 "default")
Returns:
ConfInfo: 包含配置文件的 uuid, 路径和名称等信息, 是一个 dict 类型
"""
# uuid -> { "umop": list, "path": str, "name": str }
abconf_data = self.sp.get("abconf_mapping", {}) # default is not included here
if isinstance(umo, MessageSession):
umo = str(umo)
else:
umo = str(MessageSession.from_str(umo)) # validate
for uuid_, meta in abconf_data.items():
for pattern in meta["umop"]:
if self._is_umo_match(pattern, umo):
return ConfInfo(**meta, id=uuid_)
return DEFAULT_CONFIG_CONF_INFO
def _save_conf_mapping(
self,
abconf_path: str,
abconf_id: str,
umo_parts: list[str] | list[MessageSession],
abconf_name: str = None,
) -> None:
"""保存配置文件的映射关系"""
for part in umo_parts:
if isinstance(part, MessageSession):
part = str(part)
elif not isinstance(part, str):
raise ValueError(
"umo_parts must be a list of strings or MessageSession instances"
)
abconf_data = self.sp.get("abconf_mapping", {})
random_word = abconf_name or uuid.uuid4().hex[:8]
abconf_data[abconf_id] = {
"umop": umo_parts,
"path": abconf_path,
"name": random_word,
}
self.sp.put("abconf_mapping", abconf_data)
def get_conf(self, umo: str | MessageSession) -> AstrBotConfig:
"""获取指定 umo 的配置文件。如果不存在,则 fallback 到默认配置文件。"""
if isinstance(umo, MessageSession):
umo = f"{umo.platform_id}:{umo.message_type}:{umo.session_id}"
uuid_ = self._load_conf_mapping(umo)["id"]
conf = self.confs.get(uuid_)
if not conf:
conf = self.confs["default"] # default MUST exists
return conf
@property
def default_conf(self) -> AstrBotConfig:
"""获取默认配置文件"""
return self.confs["default"]
def get_conf_info(self, umo: str | MessageSession) -> ConfInfo:
"""获取指定 umo 的配置文件元数据"""
if isinstance(umo, MessageSession):
umo = f"{umo.platform_id}:{umo.message_type}:{umo.session_id}"
return self._load_conf_mapping(umo)
def get_conf_list(self) -> list[ConfInfo]:
"""获取所有配置文件的元数据列表"""
conf_list = []
conf_list.append(DEFAULT_CONFIG_CONF_INFO)
for uuid_, meta in self.sp.get("abconf_mapping", {}).items():
conf_list.append(ConfInfo(**meta, id=uuid_))
return conf_list
def create_conf(
self,
umo_parts: list[str] | list[MessageSession],
config: dict = DEFAULT_CONFIG,
name: str = None,
) -> str:
"""
umo 由三个部分组成 [platform_id]:[message_type]:[session_id]。
umo_parts 可以是 "::" (代表所有), 可以是 "[platform_id]::" (代表指定平台下的所有类型消息和会话)。
"""
conf_uuid = str(uuid.uuid4())
conf_file_name = f"abconf_{conf_uuid}.json"
conf_path = os.path.join(get_astrbot_config_path(), conf_file_name)
conf = AstrBotConfig(config_path=conf_path, default_config=config)
conf.save_config()
self._save_conf_mapping(conf_file_name, conf_uuid, umo_parts, abconf_name=name)
self.confs[conf_uuid] = conf
return conf_uuid
def delete_conf(self, conf_id: str) -> bool:
"""删除指定配置文件
Args:
conf_id: 配置文件的 UUID
Returns:
bool: 删除是否成功
Raises:
ValueError: 如果试图删除默认配置文件
"""
if conf_id == "default":
raise ValueError("不能删除默认配置文件")
# 从映射中移除
abconf_data = self.sp.get("abconf_mapping", {})
if conf_id not in abconf_data:
logger.warning(f"配置文件 {conf_id} 不存在于映射中")
return False
# 获取配置文件路径
conf_path = os.path.join(get_astrbot_config_path(), abconf_data[conf_id]["path"])
# 删除配置文件
try:
if os.path.exists(conf_path):
os.remove(conf_path)
logger.info(f"已删除配置文件: {conf_path}")
except Exception as e:
logger.error(f"删除配置文件 {conf_path} 失败: {e}")
return False
# 从内存中移除
if conf_id in self.confs:
del self.confs[conf_id]
# 从映射中移除
del abconf_data[conf_id]
self.sp.put("abconf_mapping", abconf_data)
logger.info(f"成功删除配置文件 {conf_id}")
return True
def update_conf_info(self, conf_id: str, name: str = None, umo_parts: list[str] = None) -> bool:
"""更新配置文件信息
Args:
conf_id: 配置文件的 UUID
name: 新的配置文件名称 (可选)
umo_parts: 新的 UMO 部分列表 (可选)
Returns:
bool: 更新是否成功
"""
if conf_id == "default":
raise ValueError("不能更新默认配置文件的信息")
abconf_data = self.sp.get("abconf_mapping", {})
if conf_id not in abconf_data:
logger.warning(f"配置文件 {conf_id} 不存在于映射中")
return False
# 更新名称
if name is not None:
abconf_data[conf_id]["name"] = name
# 更新 UMO 部分
if umo_parts is not None:
# 验证 UMO 部分格式
for part in umo_parts:
if isinstance(part, MessageSession):
part = str(part)
elif not isinstance(part, str):
raise ValueError("umo_parts must be a list of strings or MessageSession instances")
abconf_data[conf_id]["umop"] = umo_parts
# 保存更新
self.sp.put("abconf_mapping", abconf_data)
logger.info(f"成功更新配置文件 {conf_id} 的信息")
return True
def g(self, umo: str = None, key: str = None, default: _VT = None) -> _VT:
"""获取配置项。umo 为 None 时使用默认配置"""
if umo is None:
return self.confs["default"].get(key, default)
conf = self.get_conf(umo)
return conf.get(key, default)
File diff suppressed because it is too large Load Diff
+44 -8
View File
@@ -27,10 +27,11 @@ from astrbot.core.provider.manager import ProviderManager
from astrbot.core import LogBroker
from astrbot.core.db import BaseDatabase
from astrbot.core.updator import AstrBotUpdator
from astrbot.core import logger
from astrbot.core import logger, sp
from astrbot.core.config.default import VERSION
from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star_handler import star_map
from astrbot.core.db.migration.helper import do_migration_v4
@@ -76,11 +77,16 @@ class AstrBotCoreLifecycle:
except Exception as e:
logger.error(f"迁移到 v4.0.0 新版本数据格式失败: {e}")
# 初始化 AstrBot 配置管理器
self.astrbot_config_mgr = AstrBotConfigManager(
default_config=self.astrbot_config, sp=sp
)
# 初始化事件队列
self.event_queue = Queue()
# 初始化人格管理器
self.persona_mgr = PersonaManager(self.db, self.astrbot_config)
self.persona_mgr = PersonaManager(self.db, self.astrbot_config_mgr)
await self.persona_mgr.initialize()
# 初始化供应商管理器
@@ -107,6 +113,7 @@ class AstrBotCoreLifecycle:
self.conversation_manager,
self.platform_message_history_manager,
self.persona_mgr,
self.astrbot_config_mgr,
)
# 初始化插件管理器
@@ -119,17 +126,16 @@ class AstrBotCoreLifecycle:
await self.provider_manager.initialize()
# 初始化消息事件流水线调度器
self.pipeline_scheduler = PipelineScheduler(
PipelineContext(self.astrbot_config, self.plugin_manager)
)
await self.pipeline_scheduler.initialize()
self.star_context.pipeline_ctx = self.pipeline_scheduler.ctx
self.pipeline_scheduler_mapping = await self.load_pipeline_scheduler()
# 初始化更新器
self.astrbot_updator = AstrBotUpdator()
# 初始化事件总线
self.event_bus = EventBus(self.event_queue, self.pipeline_scheduler)
self.event_bus = EventBus(
self.event_queue, self.pipeline_scheduler_mapping, self.astrbot_config_mgr
)
# 记录启动时间
self.start_time = int(time.time())
@@ -252,3 +258,33 @@ class AstrBotCoreLifecycle:
)
)
return tasks
async def load_pipeline_scheduler(self) -> dict[str, PipelineScheduler]:
"""加载消息事件流水线调度器
Returns:
dict[str, PipelineScheduler]: 平台 ID 到流水线调度器的映射
"""
mapping = {}
for conf_id, ab_config in self.astrbot_config_mgr.confs.items():
scheduler = PipelineScheduler(
PipelineContext(ab_config, self.plugin_manager, conf_id)
)
await scheduler.initialize()
mapping[conf_id] = scheduler
return mapping
async def reload_pipeline_scheduler(self, conf_id: str):
"""重新加载消息事件流水线调度器
Returns:
dict[str, PipelineScheduler]: 平台 ID 到流水线调度器的映射
"""
ab_config = self.astrbot_config_mgr.confs.get(conf_id)
if not ab_config:
raise ValueError(f"配置文件 {conf_id} 不存在")
scheduler = PipelineScheduler(
PipelineContext(ab_config, self.plugin_manager, conf_id)
)
await scheduler.initialize()
self.pipeline_scheduler_mapping[conf_id] = scheduler
+19 -17
View File
@@ -16,30 +16,32 @@ from asyncio import Queue
from astrbot.core.pipeline.scheduler import PipelineScheduler
from astrbot.core import logger
from .platform import AstrMessageEvent
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
class EventBus:
"""事件总线: 用于处理事件的分发和处理
"""用于处理事件的分发和处理"""
维护一个异步队列, 来接受各种消息事件
"""
def __init__(self, event_queue: Queue, pipeline_scheduler: PipelineScheduler):
def __init__(
self,
event_queue: Queue,
pipeline_scheduler_mapping: dict[str, PipelineScheduler],
astrbot_config_mgr: AstrBotConfigManager = None,
):
self.event_queue = event_queue # 事件队列
self.pipeline_scheduler = pipeline_scheduler # 管道调度器
# abconf uuid -> scheduler
self.pipeline_scheduler_mapping = pipeline_scheduler_mapping
self.astrbot_config_mgr = astrbot_config_mgr
async def dispatch(self):
"""无限循环的调度函数, 从事件队列中获取新的事件, 打印日志并创建一个新的异步任务来执行管道调度器的处理逻辑"""
while True:
event: AstrMessageEvent = (
await self.event_queue.get()
) # 从事件队列中获取新的事件
self._print_event(event) # 打印日志
asyncio.create_task(
self.pipeline_scheduler.execute(event)
) # 创建新的异步任务来执行管道调度器的处理逻辑
event: AstrMessageEvent = await self.event_queue.get()
conf_info = self.astrbot_config_mgr.get_conf_info(event.unified_msg_origin)
self._print_event(event, conf_info["name"])
scheduler = self.pipeline_scheduler_mapping.get(conf_info["id"])
asyncio.create_task(scheduler.execute(event))
def _print_event(self, event: AstrMessageEvent):
def _print_event(self, event: AstrMessageEvent, conf_name: str):
"""用于记录事件信息
Args:
@@ -48,10 +50,10 @@ class EventBus:
# 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要
if event.get_sender_name():
logger.info(
f"[{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_name()}/{event.get_sender_id()}: {event.get_message_outline()}"
f"[{conf_name}] [{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_name()}/{event.get_sender_id()}: {event.get_message_outline()}"
)
# 没有发送者名称: [平台名] 发送者ID: 消息概要
else:
logger.info(
f"[{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_id()}: {event.get_message_outline()}"
f"[{conf_name}] [{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_id()}: {event.get_message_outline()}"
)
+4 -5
View File
@@ -1,15 +1,14 @@
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import Persona, Personality
from astrbot.core.config import AstrBotConfig
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot import logger
class PersonaManager:
def __init__(self, db_helper: BaseDatabase, astrbot_config: AstrBotConfig):
def __init__(self, db_helper: BaseDatabase, acm: AstrBotConfigManager):
self.db = db_helper
self.config = astrbot_config
_ps: dict = astrbot_config["provider_settings"]
self.default_persona: str = _ps.get("default_personality", "default")
default_ps = acm.default_conf.get("provider_settings", {})
self.default_persona: str = default_ps.get("default_personality", "default")
self.personas: list[Persona] = []
self.selected_default_persona: Persona | None = None
+2 -1
View File
@@ -2,7 +2,7 @@ import inspect
import traceback
import typing as T
from dataclasses import dataclass
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config import AstrBotConfig
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star import PluginManager
from astrbot.api import logger
@@ -17,6 +17,7 @@ class PipelineContext:
astrbot_config: AstrBotConfig # AstrBot 配置对象
plugin_manager: PluginManager # 插件管理器对象
astrbot_config_id: str
async def call_event_hook(
self,
@@ -197,7 +197,7 @@ class ToolLoopAgent(BaseAgentRunner):
logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}")
executor = func_tool.execute(
event=self.event,
pipeline_context=self.pipeline_ctx,
call_handler_func=self.pipeline_ctx.call_handler,
**func_tool_args,
)
async for resp in executor:
+2 -27
View File
@@ -3,7 +3,7 @@ import asyncio
import re
import hashlib
import uuid
from dataclasses import dataclass
from typing import List, Union, Optional, AsyncGenerator
from astrbot.core.db.po import Conversation
@@ -23,32 +23,7 @@ from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.utils.metrics import Metric
from .astrbot_message import AstrBotMessage, Group
from .platform_metadata import PlatformMetadata
@dataclass
class MessageSession:
"""描述一条消息在 AstrBot 中对应的会话的唯一标识。
如果您需要实例化 MessageSession请不要给 platform_id 赋值或者同时给 platform_name platform_id 赋值相同值它会在 __post_init__ 中自动设置为 platform_name 的值"""
platform_name: str
"""平台适配器实例的唯一标识符。自 AstrBot v4.0.0 起,该字段实际为 platform_id。"""
message_type: MessageType
session_id: str
platform_id: str = None
def __str__(self):
return f"{self.platform_id}:{self.message_type.value}:{self.session_id}"
def __post_init__(self):
self.platform_id = self.platform_name
@staticmethod
def from_str(session_str: str):
platform_id, message_type, session_id = session_str.split(":")
return MessageSession(platform_id, MessageType(message_type), session_id)
MessageSesion = MessageSession # back compatibility
from .message_session import MessageSession, MessageSesion # noqa
class AstrMessageEvent(abc.ABC):
+3
View File
@@ -18,6 +18,9 @@ class PlatformManager:
self.platforms_config = config["platform"]
self.settings = config["platform_settings"]
"""NOTE: 这里是 default 的配置文件,以保证最大的兼容性;
这个配置中的 unique_session 需要特殊处理
约定整个项目中对 unique_session 的引用都从 default 的配置中获取"""
self.event_queue = event_queue
async def initialize(self):
+28
View File
@@ -0,0 +1,28 @@
from astrbot.core.platform.message_type import MessageType
from dataclasses import dataclass
@dataclass
class MessageSession:
"""描述一条消息在 AstrBot 中对应的会话的唯一标识。
如果您需要实例化 MessageSession请不要给 platform_id 赋值或者同时给 platform_name platform_id 赋值相同值它会在 __post_init__ 中自动设置为 platform_name 的值"""
platform_name: str
"""平台适配器实例的唯一标识符。自 AstrBot v4.0.0 起,该字段实际为 platform_id。"""
message_type: MessageType
session_id: str
platform_id: str = None
def __str__(self):
return f"{self.platform_id}:{self.message_type.value}:{self.session_id}"
def __post_init__(self):
self.platform_id = self.platform_name
@staticmethod
def from_str(session_str: str):
platform_id, message_type, session_id = session_str.split(":")
return MessageSession(platform_id, MessageType(message_type), session_id)
MessageSesion = MessageSession # back compatibility
+1 -1
View File
@@ -5,7 +5,7 @@ from asyncio import Queue
from .platform_metadata import PlatformMetadata
from .astr_message_event import AstrMessageEvent
from astrbot.core.message.message_event_result import MessageChain
from .astr_message_event import MessageSesion
from .message_session import MessageSesion
from astrbot.core.utils.metrics import Metric
+8 -9
View File
@@ -34,7 +34,6 @@ from typing_extensions import TYPE_CHECKING
if TYPE_CHECKING:
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.pipeline.context import PipelineContext
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
@@ -80,14 +79,14 @@ class FunctionTool:
async def execute(
self,
event: AstrMessageEvent = None,
pipeline_context: "PipelineContext" = None,
call_handler_func: Awaitable = None,
**tool_args,
) -> AsyncGenerator[Any | mcp.types.CallToolResult, None]:
"""执行函数调用。
Args:
event (AstrMessageEvent): 事件对象, origin local 时必须提供
pipeline_context (PipelineContext): 流水线调度器上下文, origin local 时必须提供
call_handler_func (AsyncGenerator): 用于调用处理函数的异步生成器, origin mcp 时必须提供
**kwargs: 函数调用的参数
Returns:
@@ -96,7 +95,7 @@ class FunctionTool:
if self.origin == "local":
if not event:
raise ValueError("Event must be provided for local function tools.")
wrapper = pipeline_context.call_handler(
wrapper = call_handler_func(
event=event,
handler=self.handler,
**tool_args,
@@ -207,7 +206,7 @@ class ToolSet:
"""Get all function tools."""
return self.get_tool(name)
def openai_schema(self, omit_empty_parameters: bool = False) -> List[Dict]:
def openai_schema(self, omit_empty_parameter_field: bool = False) -> List[Dict]:
"""Convert tools to OpenAI API function calling schema format."""
result = []
for tool in self.tools:
@@ -219,7 +218,7 @@ class ToolSet:
},
}
if tool.parameters.get("properties") or not omit_empty_parameters:
if tool.parameters.get("properties") or not omit_empty_parameter_field:
func_def["function"]["parameters"] = tool.parameters
result.append(func_def)
@@ -319,8 +318,8 @@ class ToolSet:
return declarations
@deprecated(reason="Use openai_schema() instead", version="4.0.0")
def get_func_desc_openai_style(self, omit_empty_parameters: bool = False):
return self.openai_schema(omit_empty_parameters)
def get_func_desc_openai_style(self, omit_empty_parameter_field: bool = False):
return self.openai_schema(omit_empty_parameter_field)
@deprecated(reason="Use anthropic_schema() instead", version="4.0.0")
def get_func_desc_anthropic_style(self):
@@ -817,7 +816,7 @@ class FunctionToolManager:
"""
tools = [f for f in self.func_list if f.active]
toolset = ToolSet(tools)
return toolset.openai_schema(omit_empty_parameters=omit_empty_parameter_field)
return toolset.openai_schema(omit_empty_parameter_field=omit_empty_parameter_field)
def get_func_desc_anthropic_style(self) -> list:
"""
+1 -1
View File
@@ -82,7 +82,7 @@ class ProviderManager:
"""
if provider_id not in self.inst_map:
raise ValueError(f"提供商 {provider_id} 不存在,无法设置。")
if umo and self.provider_settings["separate_provider"]:
if umo:
perf = sp.get("session_provider_perf", {})
session_perf = perf.get(umo, {})
session_perf[provider_type.value] = provider_id
+12 -15
View File
@@ -18,6 +18,7 @@ from astrbot.core.provider.manager import ProviderManager
from astrbot.core.platform import Platform
from astrbot.core.platform.manager import PlatformManager
from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.persona_mgr import PersonaManager
from .star import star_registry, StarMetadata, star_map
from .star_handler import star_handlers_registry, StarHandlerMetadata, EventType
@@ -31,11 +32,6 @@ from astrbot.core.star.filter.platform_adapter_type import (
)
from deprecated import deprecated
from typing_extensions import TYPE_CHECKING
if TYPE_CHECKING:
from astrbot.core.pipeline.context import PipelineContext
class Context:
"""
@@ -57,8 +53,6 @@ class Context:
registered_web_apis: list = []
pipeline_ctx: "PipelineContext" = None
# back compatibility
_register_tasks: List[Awaitable] = []
_star_manager = None
@@ -73,6 +67,7 @@ class Context:
conversation_manager: ConversationManager = None,
message_history_manager: PlatformMessageHistoryManager = None,
persona_manager: PersonaManager = None,
astrbot_config_mgr: AstrBotConfigManager = None,
):
self._event_queue = event_queue
self._config = config
@@ -82,6 +77,7 @@ class Context:
self.conversation_manager = conversation_manager
self.message_history_manager = message_history_manager
self.persona_manager = persona_manager
self.astrbot_config_mgr = astrbot_config_mgr
def get_registered_star(self, star_name: str) -> StarMetadata:
"""根据插件名获取插件的 Metadata"""
@@ -145,7 +141,7 @@ class Context:
Args:
umo(str): unified_message_origin 如果传入并且用户启用了提供商会话隔离则使用该会话偏好的提供商
"""
if umo and self._config["provider_settings"]["separate_provider"]:
if umo:
perf = sp.get("session_provider_perf", {})
prov_id = perf.get(umo, {}).get(ProviderType.CHAT_COMPLETION.value, None)
if inst := self.provider_manager.inst_map.get(prov_id, None):
@@ -159,7 +155,7 @@ class Context:
Args:
umo(str): unified_message_origin 如果传入则使用该会话偏好的提供商
"""
if umo and self._config["provider_settings"]["separate_provider"]:
if umo:
perf = sp.get("session_provider_perf", {})
prov_id = perf.get(umo, {}).get(ProviderType.TEXT_TO_SPEECH.value, None)
if inst := self.provider_manager.inst_map.get(prov_id, None):
@@ -173,16 +169,20 @@ class Context:
Args:
umo(str): unified_message_origin 如果传入则使用该会话偏好的提供商
"""
if umo and self._config["provider_settings"]["separate_provider"]:
if umo:
perf = sp.get("session_provider_perf", {})
prov_id = perf.get(umo, {}).get(ProviderType.SPEECH_TO_TEXT.value, None)
if inst := self.provider_manager.inst_map.get(prov_id, None):
return inst
return self.provider_manager.curr_stt_provider_inst
def get_config(self) -> AstrBotConfig:
def get_config(self, umo: str = None) -> AstrBotConfig:
"""获取 AstrBot 的配置。"""
return self._config
if not umo:
# using default config
return self._config
else:
return self.astrbot_config_mgr.get_conf(umo)
def get_db(self) -> BaseDatabase:
"""获取 AstrBot 数据库。"""
@@ -257,9 +257,6 @@ class Context:
return True
return False
def get_pipeline_context(self) -> "PipelineContext":
return self.pipeline_ctx
"""
以下的方法已经不推荐使用请从 AstrBot 文档查看更好的注册方式
"""
+212 -62
View File
@@ -4,7 +4,12 @@ import os
from .route import Route, Response, RouteContext
from astrbot.core.provider.entities import ProviderType
from quart import request
from astrbot.core.config.default import CONFIG_METADATA_2, DEFAULT_VALUE_MAP
from astrbot.core.config.default import (
CONFIG_METADATA_2,
DEFAULT_VALUE_MAP,
CONFIG_METADATA_3,
CONFIG_METADATA_3_SYSTEM,
)
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -159,32 +164,119 @@ class ConfigRoute(Route):
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.config: AstrBotConfig = core_lifecycle.astrbot_config
self.acm = core_lifecycle.astrbot_config_mgr
self.routes = {
"/config/abconf/new": ("POST", self.create_abconf),
"/config/abconf": ("GET", self.get_abconf),
"/config/abconfs": ("GET", self.get_abconf_list),
"/config/abconf/delete": ("POST", self.delete_abconf),
"/config/abconf/update": ("POST", self.update_abconf),
"/config/get": ("GET", self.get_configs),
"/config/astrbot/update": ("POST", self.post_astrbot_configs),
"/config/plugin/update": ("POST", self.post_plugin_configs),
"/config/platform/new": ("POST", self.post_new_platform),
"/config/platform/update": ("POST", self.post_update_platform),
"/config/platform/delete": ("POST", self.post_delete_platform),
"/config/platform/list": ("GET", self.get_platform_list),
"/config/provider/new": ("POST", self.post_new_provider),
"/config/provider/update": ("POST", self.post_update_provider),
"/config/provider/delete": ("POST", self.post_delete_provider),
"/config/provider/check_one": ("GET", self.check_one_provider_status),
"/config/provider/list": ("GET", self.get_provider_config_list),
"/config/provider/model_list": ("GET", self.get_provider_model_list),
"/config/provider/get_session_seperate": (
"GET",
lambda: Response()
.ok({"enable": self.config["provider_settings"]["separate_provider"]})
.__dict__,
),
"/config/provider/set_session_seperate": (
"POST",
self.post_session_seperate,
),
}
self.register_routes()
async def get_abconf_list(self):
"""获取所有 AstrBot 配置文件的列表"""
abconf_list = self.acm.get_conf_list()
return Response().ok({"info_list": abconf_list}).__dict__
async def create_abconf(self):
"""创建新的 AstrBot 配置文件"""
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").__dict__
umo_parts = post_data["umo_parts"]
name = post_data.get("name", None)
try:
conf_id = self.acm.create_conf(umo_parts=umo_parts, name=name)
return Response().ok(message="创建成功", data={"conf_id": conf_id}).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
async def get_abconf(self):
"""获取指定 AstrBot 配置文件"""
abconf_id = request.args.get("id")
system_config = request.args.get("system_config", "0").lower() == "1"
if not abconf_id and not system_config:
return Response().error("缺少配置文件 ID").__dict__
try:
if system_config:
abconf = self.acm.confs["default"]
return (
Response()
.ok({"config": abconf, "metadata": CONFIG_METADATA_3_SYSTEM})
.__dict__
)
abconf = self.acm.confs[abconf_id]
return (
Response()
.ok({"config": abconf, "metadata": CONFIG_METADATA_3})
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
async def delete_abconf(self):
"""删除指定 AstrBot 配置文件"""
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").__dict__
conf_id = post_data.get("id")
if not conf_id:
return Response().error("缺少配置文件 ID").__dict__
try:
success = self.acm.delete_conf(conf_id)
if success:
return Response().ok(message="删除成功").__dict__
else:
return Response().error("删除失败").__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"删除配置文件失败: {str(e)}").__dict__
async def update_abconf(self):
"""更新指定 AstrBot 配置文件信息"""
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").__dict__
conf_id = post_data.get("id")
if not conf_id:
return Response().error("缺少配置文件 ID").__dict__
name = post_data.get("name")
umo_parts = post_data.get("umo_parts")
try:
success = self.acm.update_conf_info(conf_id, name=name, umo_parts=umo_parts)
if success:
return Response().ok(message="更新成功").__dict__
else:
return Response().error("更新失败").__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"更新配置文件失败: {str(e)}").__dict__
async def _test_single_provider(self, provider):
"""辅助函数:测试单个 provider 的可用性"""
meta = provider.meta()
@@ -209,11 +301,16 @@ class ConfigRoute(Route):
response = await asyncio.wait_for(
provider.text_chat(prompt="REPLY `PONG` ONLY"), timeout=45.0
)
logger.debug(f"Received response from {status_info['name']}: {response}")
logger.debug(
f"Received response from {status_info['name']}: {response}"
)
if response is not None:
status_info["status"] = "available"
response_text_snippet = ""
if hasattr(response, "completion_text") and response.completion_text:
if (
hasattr(response, "completion_text")
and response.completion_text
):
response_text_snippet = (
response.completion_text[:70] + "..."
if len(response.completion_text) > 70
@@ -232,29 +329,48 @@ class ConfigRoute(Route):
f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{response_text_snippet}'"
)
else:
status_info["error"] = "Test call returned None, but expected an LLMResponse object."
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None.")
status_info["error"] = (
"Test call returned None, but expected an LLMResponse object."
)
logger.warning(
f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None."
)
except asyncio.TimeoutError:
status_info["error"] = "Connection timed out after 45 seconds during test call."
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) timed out.")
status_info["error"] = (
"Connection timed out after 45 seconds during test call."
)
logger.warning(
f"Provider {status_info['name']} (ID: {status_info['id']}) timed out."
)
except Exception as e:
error_message = str(e)
status_info["error"] = error_message
logger.warning(f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}")
logger.debug(f"Traceback for {status_info['name']}:\n{traceback.format_exc()}")
logger.warning(
f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}"
)
logger.debug(
f"Traceback for {status_info['name']}:\n{traceback.format_exc()}"
)
elif provider_capability_type == ProviderType.EMBEDDING:
try:
# For embedding, we can call the get_embedding method with a short prompt.
embedding_result = await provider.get_embedding("health_check")
if isinstance(embedding_result, list) and (not embedding_result or isinstance(embedding_result[0], float)):
if isinstance(embedding_result, list) and (
not embedding_result or isinstance(embedding_result[0], float)
):
status_info["status"] = "available"
else:
status_info["status"] = "unavailable"
status_info["error"] = f"Embedding test failed: unexpected result type {type(embedding_result)}"
status_info["error"] = (
f"Embedding test failed: unexpected result type {type(embedding_result)}"
)
except Exception as e:
logger.error(f"Error testing embedding provider {provider_name}: {e}", exc_info=True)
logger.error(
f"Error testing embedding provider {provider_name}: {e}",
exc_info=True,
)
status_info["status"] = "unavailable"
status_info["error"] = f"Embedding test failed: {str(e)}"
@@ -266,41 +382,71 @@ class ConfigRoute(Route):
status_info["status"] = "available"
else:
status_info["status"] = "unavailable"
status_info["error"] = f"TTS test failed: unexpected result type {type(audio_result)}"
status_info["error"] = (
f"TTS test failed: unexpected result type {type(audio_result)}"
)
except Exception as e:
logger.error(f"Error testing TTS provider {provider_name}: {e}", exc_info=True)
logger.error(
f"Error testing TTS provider {provider_name}: {e}", exc_info=True
)
status_info["status"] = "unavailable"
status_info["error"] = f"TTS test failed: {str(e)}"
elif provider_capability_type == ProviderType.SPEECH_TO_TEXT:
try:
logger.debug(f"Sending health check audio to provider: {status_info['name']}")
sample_audio_path = os.path.join(get_astrbot_path(), "samples", "stt_health_check.wav")
logger.debug(
f"Sending health check audio to provider: {status_info['name']}"
)
sample_audio_path = os.path.join(
get_astrbot_path(), "samples", "stt_health_check.wav"
)
if not os.path.exists(sample_audio_path):
status_info["status"] = "unavailable"
status_info["error"] = "STT test failed: sample audio file not found."
logger.warning(f"STT test for {status_info['name']} failed: sample audio file not found at {sample_audio_path}")
status_info["error"] = (
"STT test failed: sample audio file not found."
)
logger.warning(
f"STT test for {status_info['name']} failed: sample audio file not found at {sample_audio_path}"
)
else:
text_result = await provider.get_text(sample_audio_path)
if isinstance(text_result, str) and text_result:
status_info["status"] = "available"
snippet = text_result[:70] + "..." if len(text_result) > 70 else text_result
logger.info(f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{snippet}'")
snippet = (
text_result[:70] + "..."
if len(text_result) > 70
else text_result
)
logger.info(
f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{snippet}'"
)
else:
status_info["status"] = "unavailable"
status_info["error"] = f"STT test failed: unexpected result type {type(text_result)}"
logger.warning(f"STT test for {status_info['name']} failed: unexpected result type {type(text_result)}")
status_info["error"] = (
f"STT test failed: unexpected result type {type(text_result)}"
)
logger.warning(
f"STT test for {status_info['name']} failed: unexpected result type {type(text_result)}"
)
except Exception as e:
logger.error(f"Error testing STT provider {provider_name}: {e}", exc_info=True)
logger.error(
f"Error testing STT provider {provider_name}: {e}", exc_info=True
)
status_info["status"] = "unavailable"
status_info["error"] = f"STT test failed: {str(e)}"
else:
logger.debug(f"Provider {provider_name} is not a Chat Completion or Embedding provider. Marking as available without test. Meta: {meta}")
logger.debug(
f"Provider {provider_name} is not a Chat Completion or Embedding provider. Marking as available without test. Meta: {meta}"
)
status_info["status"] = "available"
status_info["error"] = "This provider type is not tested and is assumed to be available."
status_info["error"] = (
"This provider type is not tested and is assumed to be available."
)
return status_info
def _error_response(self, message: str, status_code: int = 500, log_fn=logger.error):
def _error_response(
self, message: str, status_code: int = 500, log_fn=logger.error
):
log_fn(message)
# 记录更详细的traceback信息,但只在是严重错误时
if status_code == 500:
@@ -311,7 +457,9 @@ class ConfigRoute(Route):
"""API: check a single LLM Provider's status by id"""
provider_id = request.args.get("id")
if not provider_id:
return self._error_response("Missing provider_id parameter", 400, logger.warning)
return self._error_response(
"Missing provider_id parameter", 400, logger.warning
)
logger.info(f"API call: /config/provider/check_one id={provider_id}")
try:
@@ -319,16 +467,21 @@ class ConfigRoute(Route):
target = prov_mgr.inst_map.get(provider_id)
if not target:
logger.warning(f"Provider with id '{provider_id}' not found in provider_manager.")
return Response().error(f"Provider with id '{provider_id}' not found").__dict__
logger.warning(
f"Provider with id '{provider_id}' not found in provider_manager."
)
return (
Response()
.error(f"Provider with id '{provider_id}' not found")
.__dict__
)
result = await self._test_single_provider(target)
return Response().ok(result).__dict__
except Exception as e:
return self._error_response(
f"Critical error checking provider {provider_id}: {e}",
500
f"Critical error checking provider {provider_id}: {e}", 500
)
async def get_configs(self):
@@ -339,21 +492,6 @@ class ConfigRoute(Route):
return Response().ok(await self._get_astrbot_config()).__dict__
return Response().ok(await self._get_plugin_config(plugin_name)).__dict__
async def post_session_seperate(self):
"""设置提供商会话隔离"""
post_config = await request.json
enable = post_config.get("enable", None)
if enable is None:
return Response().error("缺少参数 enable").__dict__
astrbot_config = self.core_lifecycle.astrbot_config
astrbot_config["provider_settings"]["separate_provider"] = enable
try:
astrbot_config.save_config()
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "设置成功~").__dict__
async def get_provider_config_list(self):
provider_type = request.args.get("provider_type", None)
if not provider_type:
@@ -387,11 +525,21 @@ class ConfigRoute(Route):
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def get_platform_list(self):
"""获取所有平台的列表"""
platform_list = []
for platform in self.config["platform"]:
platform_list.append(platform)
return Response().ok({"platforms": platform_list}).__dict__
async def post_astrbot_configs(self):
post_configs = await request.json
data = await request.json
config = data.get("config", None)
conf_id = data.get("conf_id", None)
try:
await self._save_astrbot_configs(post_configs)
return Response().ok(None, "保存成功~ 机器人正在重载配置。").__dict__
await self._save_astrbot_configs(config, conf_id)
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
return Response().ok(None, "保存成功~").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
@@ -550,10 +698,12 @@ class ConfigRoute(Route):
return ret
async def _save_astrbot_configs(self, post_configs: dict):
async def _save_astrbot_configs(self, post_configs: dict, conf_id: str = None):
try:
save_config(post_configs, self.config, is_core=True)
await self.core_lifecycle.restart()
if conf_id not in self.acm.confs:
raise ValueError(f"配置文件 {conf_id} 不存在")
astrbot_config = self.acm.confs[conf_id]
save_config(post_configs, astrbot_config, is_core=True)
except Exception as e:
raise e
-16
View File
@@ -286,14 +286,6 @@ class PluginRoute(Route):
f"{filter.parent_command_names[0]} {filter.command_name}"
)
info["cmd"] = info["cmd"].strip()
if (
self.core_lifecycle.astrbot_config["wake_prefix"]
and len(self.core_lifecycle.astrbot_config["wake_prefix"])
> 0
):
info["cmd"] = (
f"{self.core_lifecycle.astrbot_config['wake_prefix'][0]}{info['cmd']}"
)
elif isinstance(filter, CommandGroupFilter):
info["type"] = "指令组"
info["cmd"] = filter.get_complete_command_names()[0]
@@ -301,14 +293,6 @@ class PluginRoute(Route):
info["sub_command"] = filter.print_cmd_tree(
filter.sub_command_filters
)
if (
self.core_lifecycle.astrbot_config["wake_prefix"]
and len(self.core_lifecycle.astrbot_config["wake_prefix"])
> 0
):
info["cmd"] = (
f"{self.core_lifecycle.astrbot_config['wake_prefix'][0]}{info['cmd']}"
)
elif isinstance(filter, RegexFilter):
info["type"] = "正则匹配"
info["cmd"] = filter.regex_str
@@ -176,7 +176,7 @@ function saveEditedContent() {
<!-- List item -->
<ListConfigItem
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
:value="iterable[key]"
v-model="iterable[key]"
class="config-field"
/>
</div>
@@ -287,9 +287,9 @@ function saveEditedContent() {
></v-switch>
<!-- List item -->
<ListConfigItem
<ListConfigItem
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
:value="iterable[metadataKey]"
v-model="iterable[metadataKey]"
class="config-field"
/>
</div>
@@ -0,0 +1,396 @@
<script setup>
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { ref, computed } from 'vue'
import ListConfigItem from './ListConfigItem.vue'
import ProviderSelector from './ProviderSelector.vue'
import PersonaSelector from './PersonaSelector.vue'
import { useI18n } from '@/i18n/composables'
const props = defineProps({
metadata: {
type: Object,
required: true
},
iterable: {
type: Object,
required: true
},
metadataKey: {
type: String,
required: true
}
})
const { t } = useI18n()
const dialog = ref(false)
const currentEditingKey = ref('')
const currentEditingLanguage = ref('json')
const currentEditingTheme = ref('vs-light')
let currentEditingKeyIterable = null
function getValueBySelector(obj, selector) {
const keys = selector.split('.')
let current = obj
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
current = current[key]
} else {
return undefined
}
}
return current
}
function setValueBySelector(obj, selector, value) {
const keys = selector.split('.')
let current = obj
//
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {}
}
current = current[key]
}
//
current[keys[keys.length - 1]] = value
}
// JSON selector
function createSelectorModel(selector) {
return computed({
get() {
return getValueBySelector(props.iterable, selector)
},
set(value) {
setValueBySelector(props.iterable, selector, value)
}
})
}
function openEditorDialog(key, value, theme, language) {
currentEditingKey.value = key
currentEditingLanguage.value = language || 'json'
currentEditingTheme.value = theme || 'vs-light'
currentEditingKeyIterable = value
dialog.value = true
}
function saveEditedContent() {
dialog.value = false
}
function shouldShowItem(itemMeta, itemKey) {
if (!itemMeta?.condition) {
return true
}
for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) {
const actualValue = getValueBySelector(props.iterable, conditionKey)
if (actualValue !== expectedValue) {
return false
}
}
return true
}
function hasVisibleItemsAfter(items, currentIndex) {
const itemEntries = Object.entries(items)
//
for (let i = currentIndex + 1; i < itemEntries.length; i++) {
const [itemKey, itemMeta] = itemEntries[i]
if (shouldShowItem(itemMeta, itemKey)) {
return true
}
}
return false
}
</script>
<template>
<v-card style="margin-bottom: 16px; padding-bottom: 8px; background-color: rgb(var(--v-theme-background));" rounded="md" variant="outlined">
<v-card-text class="config-section" v-if="metadata[metadataKey]?.type === 'object'">
<v-list-item-title class="config-title">
{{ metadata[metadataKey]?.description }}
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ metadata[metadataKey]?.hint }}
</v-list-item-subtitle>
</v-card-text>
<!-- Object Type Configuration with JSON Selector Support -->
<div v-if="metadata[metadataKey]?.type === 'object'" class="object-config">
<div v-for="(itemMeta, itemKey, index) in metadata[metadataKey].items" :key="itemKey" class="config-item">
<!-- Check if itemKey is a JSON selector -->
<template v-if="shouldShowItem(itemMeta, itemKey)">
<!-- JSON Selector Property -->
<v-row v-if="!itemMeta?.invisible" class="config-row">
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
{{ itemMeta?.description || itemKey }}
<span class="property-key">({{ itemKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
<span v-if="itemMeta?.obvious_hint && itemMeta?.hint" class="important-hint"></span>
{{ itemMeta?.hint }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="12" sm="6" class="config-input">
<div class="w-100" v-if="!itemMeta?._special">
<!-- Select input for JSON selector -->
<v-select v-if="itemMeta?.options" v-model="createSelectorModel(itemKey).value"
:items="itemMeta?.options" :disabled="itemMeta?.readonly" density="compact" variant="outlined"
class="config-field" hide-details></v-select>
<!-- Code Editor for JSON selector -->
<div v-else-if="itemMeta?.editor_mode" class="editor-container">
<VueMonacoEditor :theme="itemMeta?.editor_theme || 'vs-light'"
:language="itemMeta?.editor_language || 'json'"
style="min-height: 100px; flex-grow: 1; border: 1px solid rgba(0, 0, 0, 0.1);"
v-model:value="createSelectorModel(itemKey).value">
</VueMonacoEditor>
<v-btn icon size="small" variant="text" color="primary" class="editor-fullscreen-btn"
@click="openEditorDialog(itemKey, iterable, itemMeta?.editor_theme, itemMeta?.editor_language)"
:title="t('core.common.editor.fullscreen')">
<v-icon>mdi-fullscreen</v-icon>
</v-btn>
</div>
<!-- String input for JSON selector -->
<v-text-field v-else-if="itemMeta?.type === 'string'" v-model="createSelectorModel(itemKey).value"
density="compact" variant="outlined" class="config-field" hide-details></v-text-field>
<!-- Numeric input for JSON selector -->
<v-text-field v-else-if="itemMeta?.type === 'int' || itemMeta?.type === 'float'"
v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined" class="config-field"
type="number" hide-details></v-text-field>
<!-- Text area for JSON selector -->
<v-textarea v-else-if="itemMeta?.type === 'text'" v-model="createSelectorModel(itemKey).value"
variant="outlined" rows="3" class="config-field" hide-details></v-textarea>
<!-- Boolean switch for JSON selector -->
<v-switch v-else-if="itemMeta?.type === 'bool'" v-model="createSelectorModel(itemKey).value"
color="primary" inset density="compact" hide-details style="display: flex; justify-content: end;"></v-switch>
<!-- List item for JSON selector -->
<ListConfigItem
v-else-if="itemMeta?.type === 'list'"
v-model="createSelectorModel(itemKey).value"
button-text="修改"
class="config-field"
/>
<!-- Fallback for JSON selector -->
<v-text-field v-else v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined"
class="config-field" hide-details></v-text-field>
</div>
<div v-else-if="itemMeta?._special === 'select_provider'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'chat_completion'"
/>
</div>
<div v-else-if="itemMeta?._special === 'select_provider_stt'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'speech_to_text'"
/>
</div>
<div v-else-if="itemMeta?._special === 'select_provider_tts'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'text_to_speech'"
/>
</div>
<div v-else-if="itemMeta?._special === 'provider_pool'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'chat_completion'"
button-text="选择提供商池..."
/>
</div>
<div v-else-if="itemMeta?._special === 'select_persona'">
<PersonaSelector
v-model="createSelectorModel(itemKey).value"
/>
</div>
<div v-else-if="itemMeta?._special === 'persona_pool'">
<PersonaSelector
v-model="createSelectorModel(itemKey).value"
button-text="选择人格池..."
/>
</div>
</v-col>
</v-row>
</template>
<v-divider class="config-divider" v-if="shouldShowItem(itemMeta, itemKey) && hasVisibleItemsAfter(metadata[metadataKey].items, index)"></v-divider>
</div>
</div>
</v-card>
<!-- Full Screen Editor Dialog -->
<v-dialog v-model="dialog" fullscreen transition="dialog-bottom-transition" scrollable>
<v-card>
<v-toolbar color="primary" dark>
<v-btn icon @click="dialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>{{ t('core.common.editor.editingTitle') }} - {{ currentEditingKey }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items>
<v-btn variant="text" @click="saveEditedContent">{{ t('core.common.save') }}</v-btn>
</v-toolbar-items>
</v-toolbar>
<v-card-text class="pa-0">
<VueMonacoEditor :theme="currentEditingTheme" :language="currentEditingLanguage"
style="height: calc(100vh - 64px);" v-model:value="currentEditingKeyIterable[currentEditingKey]">
</VueMonacoEditor>
</v-card-text>
</v-card>
</v-dialog>
</template>
<style scoped>
.config-section {
margin-bottom: 4px;
}
.config-title {
/* font-weight: 600; */
font-size: 1.3rem;
color: var(--v-theme-primaryText);
}
.config-hint {
font-size: 0.75rem;
color: var(--v-theme-secondaryText);
margin-top: 2px;
}
.metadata-key,
.property-key {
font-size: 0.85em;
opacity: 0.7;
font-weight: normal;
display: none;
}
.important-hint {
opacity: 1;
margin-right: 4px;
}
.object-config,
.simple-config {
width: 100%;
}
.nested-object {
padding-left: 16px;
}
.nested-container {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 12px;
margin: 12px 0;
background-color: rgba(0, 0, 0, 0.02);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.config-row {
margin: 0;
align-items: center;
padding: 10px 8px;
border-radius: 4px;
}
.config-row:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.property-info {
padding: 0;
}
.property-name {
font-size: 0.875rem;
/* font-weight: 600; */
color: var(--v-theme-primaryText);
}
.property-hint {
font-size: 0.75rem;
color: var(--v-theme-secondaryText);
margin-top: 2px;
}
.type-indicator {
display: flex;
justify-content: center;
}
.config-input {
padding: 4px 8px;
}
.config-field {
margin-bottom: 0;
}
.config-divider {
border-color: rgba(0, 0, 0, 0.1);
margin-left: 24px;
}
.editor-container {
position: relative;
display: flex;
width: 100%;
}
.editor-fullscreen-btn {
position: absolute;
top: 4px;
right: 4px;
z-index: 10;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 4px;
}
.editor-fullscreen-btn:hover {
background-color: rgba(0, 0, 0, 0.5);
}
@media (max-width: 600px) {
.nested-object {
padding-left: 8px;
}
.config-row {
padding: 8px 0;
}
.property-info,
.type-indicator,
.config-input {
padding: 4px;
}
}
</style>
+194 -113
View File
@@ -1,135 +1,216 @@
<template>
<div class="list-config-item">
<v-list dense style="background-color: transparent;max-height: 300px; overflow-y: auto;">
<v-list-item v-for="(item, index) in items" :key="index">
<v-list-item-content style="display: flex; justify-content: space-between;">
<v-list-item-title v-if="editIndex !== index">
<v-chip size="small" label color="primary">{{ item }}</v-chip>
</v-list-item-title>
<div class="d-flex align-center justify-space-between">
<div>
<span v-if="!modelValue || modelValue.length === 0" style="color: rgb(var(--v-theme-primaryText));">
暂无项目
</span>
<div v-else class="d-flex flex-wrap ga-2">
<v-chip v-for="item in displayItems" :key="item" size="x-small" label color="primary">
{{ item.length > 20 ? item.slice(0, 20) + '...' : item }}
</v-chip>
<v-chip v-if="modelValue.length > maxDisplayItems" size="x-small" label color="grey-lighten-1">
+{{ modelValue.length - maxDisplayItems }}
</v-chip>
</div>
</div>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
{{ buttonText }}
</v-btn>
</div>
<!-- List Management Dialog -->
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
{{ dialogTitle }}
</v-card-title>
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
<v-list v-if="localItems.length > 0" density="compact">
<v-list-item
v-for="(item, index) in localItems"
:key="index"
rounded="md"
class="ma-1">
<v-list-item-title v-if="editIndex !== index">
{{ item }}
</v-list-item-title>
<v-text-field
v-else
v-model="editItem"
hide-details
variant="outlined"
density="compact"
@keyup.enter="saveEdit"
@keyup.esc="cancelEdit"
autofocus
></v-text-field>
<template v-slot:append>
<div v-if="editIndex !== index" class="d-flex">
<v-btn @click="startEdit(index, item)" variant="plain" icon size="small">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn @click="removeItem(index)" variant="plain" icon size="small">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div v-else class="d-flex">
<v-btn @click="saveEdit" variant="plain" color="success" icon size="small">
<v-icon>mdi-check</v-icon>
</v-btn>
<v-btn @click="cancelEdit" variant="plain" color="error" icon size="small">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
</template>
</v-list-item>
</v-list>
<div v-else class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-format-list-bulleted</v-icon>
<p class="text-grey mt-4">暂无项目</p>
</div>
</v-card-text>
<!-- Add new item section -->
<v-card-text class="pa-4">
<div class="d-flex align-center ga-2">
<v-text-field
v-else
v-model="editItem"
dense
hide-details
v-model="newItem"
:label="t('core.common.list.addItemPlaceholder')"
@keyup.enter="addItem"
clearable
hide-details
variant="outlined"
density="compact"
@keyup.enter="saveEdit"
@keyup.esc="cancelEdit"
autofocus
></v-text-field>
<div v-if="editIndex !== index">
<v-btn @click="startEdit(index, item)" variant="plain" class="edit-btn" icon size="small">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn @click="removeItem(index)" variant="plain" icon size="small">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div v-else>
<v-btn @click="saveEdit" variant="plain" color="success" icon size="small">
<v-icon>mdi-check</v-icon>
</v-btn>
<v-btn @click="cancelEdit" variant="plain" color="error" icon size="small">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
</v-list-item-content>
</v-list-item>
</v-list>
<div style="display: flex; align-items: center;">
<v-text-field v-model="newItem" :label="t('core.common.list.addItemPlaceholder')" @keyup.enter="addItem" clearable dense hide-details
variant="outlined" density="compact"></v-text-field>
<v-btn @click="addItem" text variant="tonal">
<v-icon>mdi-plus</v-icon>
{{ t('core.common.list.addButton') }}
</v-btn>
</div>
class="flex-grow-1">
</v-text-field>
<v-btn @click="addItem" variant="tonal" color="primary">
<v-icon>mdi-plus</v-icon>
{{ t('core.common.list.addButton') }}
</v-btn>
</div>
</v-card-text>
</div>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelDialog">取消</v-btn>
<v-btn color="primary" @click="confirmDialog">确认</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import { useI18n } from '@/i18n/composables';
<script setup>
import { ref, computed, watch } from 'vue'
import { useI18n } from '@/i18n/composables'
export default {
name: 'ListConfigItem',
setup() {
const { t } = useI18n();
return { t };
const { t } = useI18n()
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
props: {
value: {
type: Array,
default: () => [],
},
label: {
type: String,
default: '',
},
label: {
type: String,
default: ''
},
data() {
return {
newItem: '',
items: this.value,
editIndex: -1,
editItem: '',
};
buttonText: {
type: String,
default: '修改'
},
watch: {
items(newVal) {
this.$emit('input', newVal);
},
dialogTitle: {
type: String,
default: '修改列表项'
},
methods: {
addItem() {
if (this.newItem.trim() !== '') {
this.items.push(this.newItem.trim());
this.newItem = '';
}
},
removeItem(index) {
this.items.splice(index, 1);
},
startEdit(index, item) {
this.editIndex = index;
this.editItem = item;
},
saveEdit() {
if (this.editItem.trim() !== '') {
this.items[this.editIndex] = this.editItem.trim();
this.cancelEdit();
}
},
cancelEdit() {
this.editIndex = -1;
this.editItem = '';
},
},
};
maxDisplayItems: {
type: Number,
default: 1
}
})
const emit = defineEmits(['update:modelValue'])
const dialog = ref(false)
const localItems = ref([])
const originalItems = ref([])
const newItem = ref('')
const editIndex = ref(-1)
const editItem = ref('')
//
const displayItems = computed(() => {
return props.modelValue.slice(0, props.maxDisplayItems)
})
// modelValue localItems
watch(() => props.modelValue, (newValue) => {
localItems.value = [...(newValue || [])]
}, { immediate: true })
function openDialog() {
localItems.value = [...(props.modelValue || [])]
originalItems.value = [...(props.modelValue || [])]
dialog.value = true
editIndex.value = -1
editItem.value = ''
newItem.value = ''
}
function addItem() {
if (newItem.value.trim() !== '') {
localItems.value.push(newItem.value.trim())
newItem.value = ''
}
}
function removeItem(index) {
localItems.value.splice(index, 1)
}
function startEdit(index, item) {
editIndex.value = index
editItem.value = item
}
function saveEdit() {
if (editItem.value.trim() !== '') {
localItems.value[editIndex.value] = editItem.value.trim()
cancelEdit()
}
}
function cancelEdit() {
editIndex.value = -1
editItem.value = ''
}
function confirmDialog() {
emit('update:modelValue', [...localItems.value])
dialog.value = false
}
function cancelDialog() {
localItems.value = [...originalItems.value]
editIndex.value = -1
editItem.value = ''
newItem.value = ''
dialog.value = false
}
</script>
<style scoped>
.list-config-item {
border: 1px solid var(--v-theme-border);
padding: 16px;
margin-bottom: 8px;
border-radius: 10px;
background-color: var(--v-theme-background);
}
.v-list-item {
padding: 0;
transition: all 0.2s ease;
}
.v-list-item-title {
font-size: 14px;
.v-list-item:hover {
background-color: rgba(var(--v-theme-primary), 0.04);
}
.v-btn {
margin-left: 8px;
}
.edit-btn {
margin-right: -8px;
.v-chip {
margin: 2px;
}
</style>
@@ -0,0 +1,141 @@
<template>
<div class="d-flex align-center justify-space-between">
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
未选择
</span>
<span v-else>
{{ modelValue }}
</span>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
{{ buttonText }}
</v-btn>
</div>
<!-- Persona Selection Dialog -->
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
选择人格
</v-card-title>
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<v-list v-if="!loading && personaList.length > 0" density="compact">
<v-list-item
v-for="persona in personaList"
:key="persona.persona_id"
:value="persona.persona_id"
@click="selectPersona(persona)"
:active="selectedPersona === persona.persona_id"
rounded="md"
class="ma-1">
<v-list-item-title>{{ persona.persona_id }}</v-list-item-title>
<v-list-item-subtitle>
{{ persona.system_prompt ? persona.system_prompt.substring(0, 50) + '...' : '无描述' }}
</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedPersona === persona.persona_id" color="primary">mdi-check-circle</v-icon>
</template>
</v-list-item>
</v-list>
<div v-else-if="!loading && personaList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-account-off</v-icon>
<p class="text-grey mt-4">暂无可用的人格</p>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelSelection">取消</v-btn>
<v-btn
color="primary"
@click="confirmSelection"
:disabled="!selectedPersona">
确认选择
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import axios from 'axios'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
buttonText: {
type: String,
default: '选择人格...'
}
})
const emit = defineEmits(['update:modelValue'])
const dialog = ref(false)
const personaList = ref([])
const loading = ref(false)
const selectedPersona = ref('')
// modelValue selectedPersona
watch(() => props.modelValue, (newValue) => {
selectedPersona.value = newValue || ''
}, { immediate: true })
async function openDialog() {
selectedPersona.value = props.modelValue || ''
dialog.value = true
await loadPersonas()
}
async function loadPersonas() {
loading.value = true
try {
const response = await axios.get('/api/persona/list')
if (response.data.status === 'ok') {
personaList.value = response.data.data || []
}
} catch (error) {
console.error('加载人格列表失败:', error)
personaList.value = []
} finally {
loading.value = false
}
}
function selectPersona(persona) {
selectedPersona.value = persona.persona_id
}
function confirmSelection() {
emit('update:modelValue', selectedPersona.value)
dialog.value = false
}
function cancelSelection() {
selectedPersona.value = props.modelValue || ''
dialog.value = false
}
</script>
<style scoped>
.v-list-item {
transition: all 0.2s ease;
}
.v-list-item:hover {
background-color: rgba(var(--v-theme-primary), 0.04);
}
.v-list-item.v-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.08);
}
</style>
@@ -0,0 +1,150 @@
<template>
<div class="d-flex align-center justify-space-between">
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
未选择
</span>
<span v-else>
{{ modelValue }}
</span>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
{{ buttonText }}
</v-btn>
</div>
<!-- Provider Selection Dialog -->
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
选择提供商
</v-card-title>
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<v-list v-if="!loading && providerList.length > 0" density="compact">
<v-list-item
v-for="provider in providerList"
:key="provider.id"
:value="provider.id"
@click="selectProvider(provider)"
:active="selectedProvider === provider.id"
rounded="md"
class="ma-1">
<v-list-item-title>{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle>
{{ provider.type || provider.provider_type || '未知类型' }}
<span v-if="provider.model_config?.model">- {{ provider.model_config.model }}</span>
</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedProvider === provider.id" color="primary">mdi-check-circle</v-icon>
</template>
</v-list-item>
</v-list>
<div v-else-if="!loading && providerList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">暂无可用的提供商</p>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelSelection">取消</v-btn>
<v-btn
color="primary"
@click="confirmSelection"
:disabled="!selectedProvider">
确认选择
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import axios from 'axios'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
providerType: {
type: String,
default: 'chat_completion'
},
buttonText: {
type: String,
default: '选择提供商...'
}
})
const emit = defineEmits(['update:modelValue'])
const dialog = ref(false)
const providerList = ref([])
const loading = ref(false)
const selectedProvider = ref('')
// modelValue selectedProvider
watch(() => props.modelValue, (newValue) => {
selectedProvider.value = newValue || ''
}, { immediate: true })
async function openDialog() {
selectedProvider.value = props.modelValue || ''
dialog.value = true
await loadProviders()
}
async function loadProviders() {
loading.value = true
try {
const response = await axios.get('/api/config/provider/list', {
params: {
provider_type: props.providerType
}
})
if (response.data.status === 'ok') {
providerList.value = response.data.data || []
}
} catch (error) {
console.error('加载提供商列表失败:', error)
providerList.value = []
} finally {
loading.value = false
}
}
function selectProvider(provider) {
selectedProvider.value = provider.id
}
function confirmSelection() {
emit('update:modelValue', selectedProvider.value)
dialog.value = false
}
function cancelSelection() {
selectedProvider.value = props.modelValue || ''
dialog.value = false
}
</script>
<style scoped>
.v-list-item {
transition: all 0.2s ease;
}
.v-list-item:hover {
background-color: rgba(var(--v-theme-primary), 0.04);
}
.v-list-item.v-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.08);
}
</style>
@@ -30,7 +30,11 @@
"configApplyError": "配置未应用,Json 格式错误。",
"saveSuccess": "配置保存成功",
"saveError": "配置保存失败",
"loadError": "配置加载失败"
"loadError": "配置加载失败",
"deleteSuccess": "删除成功",
"deleteError": "删除失败",
"updateSuccess": "更新成功",
"updateError": "更新失败"
},
"sections": {
"general": "常规设置",
@@ -1,6 +1,5 @@
<script setup>
import { ref, shallowRef, onMounted } from 'vue';
import axios from 'axios';
import { ref, shallowRef } from 'vue';
import { useCustomizerStore } from '../../../stores/customizer';
import { useI18n } from '@/i18n/composables';
import sidebarItems from './sidebarItem';
+549 -142
View File
@@ -1,124 +1,175 @@
<template>
<v-card style="margin-bottom: 16px;">
<v-card-text style="padding: 0;">
<div>
<v-btn-group variant="outlined" divided>
<v-btn icon="mdi-text-box-edit-outline" style="width: 80px;" :color="editorTab === 0 ? 'primary' : ''"
@click="editorTab = 0">
<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="d-flex flex-row pr-4"
style="margin-bottom: 16px; align-items: center; gap: 12px; justify-content: space-between; width: 100%;">
<div class="d-flex flex-row align-center" style="gap: 12px;" >
<v-select style="min-width: 130px;" v-model="selectedConfigID" :items="configSelectItems" item-title="name" v-if="!isSystemConfig"
item-value="id" label="选择配置文件" hide-details density="compact" rounded="md" variant="outlined"
@update:model-value="onConfigSelect">
<template v-slot:item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps"
:subtitle="item.raw.id === '_%manage%_' ? '管理所有配置文件' : formatUmop(item.raw.umop)"
:class="item.raw.id === '_%manage%_' ? 'text-primary' : ''">
</v-list-item>
</template>
</v-select>
</div>
<v-btn-toggle v-model="configType" mandatory color="primary" variant="outlined" density="comfortable"
rounded="md" @update:model-value="onConfigTypeToggle">
<v-btn value="normal" prepend-icon="mdi-cog" size="large" >
普通
</v-btn>
<v-btn icon="mdi-code-json" style="width: 80px;" :color="editorTab === 1 ? 'primary' : ''"
@click="configToString(); editorTab = 1;"></v-btn>
</v-btn-group>
<v-btn v-if="editorTab === 1" style="margin-left: 16px;" size="small" @click="configToString()">{{ tm('editor.revertCode') }}</v-btn>
<v-btn v-if="editorTab === 1 && config_data_has_changed" style="margin-left: 16px;" size="small"
@click="applyStrConfig()">{{ tm('editor.applyConfig') }}</v-btn>
<small v-if="editorTab === 1" style="margin-left: 16px;">💡 {{ tm('editor.applyTip') }}</small>
<v-btn value="system" prepend-icon="mdi-cog-outline" size="large">
系统
</v-btn>
</v-btn-toggle>
</div>
</v-card-text>
</v-card>
<v-progress-linear v-if="!fetched" indeterminate color="primary"></v-progress-linear>
<!-- 可视化编辑 -->
<v-card v-if="editorTab === 0">
<v-tabs v-model="tab" align-tabs="left" color="deep-purple-accent-4">
<v-tab v-for="(val, key, index) in metadata" :key="index" :value="index"
style="font-weight: 1000; font-size: 15px">
{{ metadata[key]['name'] }}
</v-tab>
</v-tabs>
<v-tabs-window v-model="tab">
<v-tabs-window-item v-for="(val, key, index) in metadata" v-show="index == tab" :key="index">
<v-container fluid>
<div v-if="(selectedConfigID || isSystemConfig) && fetched" style="width: 100%;">
<!-- 可视化编辑 -->
<div :class="$vuetify.display.mobile ? '' : 'd-flex'">
<v-tabs v-model="tab" :direction="$vuetify.display.mobile ? 'horizontal' : 'vertical'"
:align-tabs="$vuetify.display.mobile ? 'left' : 'start'" color="deep-purple-accent-4" class="config-tabs">
<v-tab v-for="(val, key, index) in metadata" :key="index" :value="index"
style="font-weight: 1000; font-size: 15px">
{{ metadata[key]['name'] }}
</v-tab>
</v-tabs>
<v-tabs-window v-model="tab" class="config-tabs-window">
<v-tabs-window-item v-for="(val, key, index) in metadata" v-show="index == tab" :key="index">
<v-container fluid>
<div v-for="(val2, key2, index2) in metadata[key]['metadata']" :key="key2">
<!-- Support both traditional and JSON selector metadata -->
<AstrBotConfigV4 :metadata="{ [key2]: metadata[key]['metadata'][key2] }" :iterable="config_data"
:metadataKey="key2">
</AstrBotConfigV4>
</div>
</v-container>
</v-tabs-window-item>
<div v-for="(val2, key2, index2) in metadata[key]['metadata']">
<!-- <h3>{{ metadata[key]['metadata'][key2]['description'] }}</h3> -->
<div v-if="metadata[key]['metadata'][key2]?.config_template"
v-show="key2 !== 'platform' && key2 !== 'provider'" style="border: 1px solid var(--v-theme-border); padding: 8px; margin-bottom: 16px; border-radius: 10px">
<!-- 带有 config_template 的配置项 -->
<v-list-item-title style="font-weight: bold;">
{{ metadata[key]['metadata'][key2]['description'] }} ({{ key2 }})
</v-list-item-title>
<v-tabs style="margin-top: 16px;" align-tabs="left" color="deep-purple-accent-4"
v-model="config_template_tab">
<v-tab v-if="metadata[key]['metadata'][key2]?.tmpl_display_title"
v-for="(item, index) in config_data[key2]" :key="index" :value="index">
{{ item[metadata[key]['metadata'][key2]?.tmpl_display_title] }}
</v-tab>
<v-tab v-else v-for="(item, index) in config_data[key2]" :key="index + '_'" :value="index">
{{ item.id }}({{ item.type }})
</v-tab>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn variant="plain" size="large" v-bind="props">
<v-icon>mdi-plus</v-icon>
</v-btn>
</template>
<v-list @update:selected="addFromDefaultConfigTmpl($event, key, key2)">
<v-list-item v-for="(item, index) in metadata[key]['metadata'][key2]?.config_template" :key="index"
:value="index">
<v-list-item-title>{{ index }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-tabs>
<v-tabs-window v-model="config_template_tab">
<v-tabs-window-item v-for="(config_item, index) in config_data[key2]"
v-show="config_template_tab === index" :key="index" :value="index">
<div style="padding: 16px;">
<v-btn variant="tonal" rounded="xl" color="error" @click="deleteItem(key2, index)">
{{ tm('actions.delete') }}
</v-btn>
<AstrBotConfig :metadata="metadata[key]['metadata']" :iterable="config_item" :metadataKey="key2">
</AstrBotConfig>
</div>
</v-tabs-window-item>
</v-tabs-window>
<div style="margin-left: 16px; padding-bottom: 16px">
<small>{{ tm('help.helpPrefix') }}
<a href="https://astrbot.app/" target="_blank">{{ tm('help.documentation') }}</a>
{{ tm('help.helpMiddle') }}
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=EYGsuUTfe00_iOu9JTXS7_TEpMkXOvwv&jump_from=webapi&authKey=uUEMKCROfsseS+8IzqPjzV3y1tzy4AkykwTib2jNkOFdzezF9s9XknqnIaf3CDft"
target="_blank">{{ tm('help.support') }}</a>{{ tm('help.helpSuffix') }}
</small>
</div>
<div v-else>
<!-- 如果配置项是一个 object那么 iterable 需要取到这个 object 的值否则取到整个 config_data -->
<div v-if="metadata[key]['metadata'][key2]['type'] == 'object'" style="border: 1px solid var(--v-theme-border); padding: 8px; margin-bottom: 16px; border-radius: 10px">
<AstrBotConfig
:metadata="metadata[key]['metadata']" :iterable="config_data[key2]" :metadataKey="key2">
</AstrBotConfig>
</v-tabs-window>
</div>
<v-btn icon="mdi-content-save" size="x-large" style="position: fixed; right: 52px; bottom: 52px;"
color="darkprimary" @click="updateConfig">
</v-btn>
<v-btn icon="mdi-code-json" size="x-large" style="position: fixed; right: 52px; bottom: 124px;" color="primary"
@click="configToString(); codeEditorDialog = true">
</v-btn>
</div>
</div>
</div>
<!-- Full Screen Editor Dialog -->
<v-dialog v-model="codeEditorDialog" fullscreen transition="dialog-bottom-transition" scrollable>
<v-card>
<v-toolbar color="primary" dark>
<v-btn icon @click="codeEditorDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>编辑配置文件</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items style="display: flex; align-items: center;">
<v-btn style="margin-left: 16px;" size="small" @click="configToString()">{{
tm('editor.revertCode') }}</v-btn>
<v-btn v-if="config_data_has_changed" style="margin-left: 16px;" size="small" @click="applyStrConfig()">{{
tm('editor.applyConfig') }}</v-btn>
<small style="margin-left: 16px;">💡 {{ tm('editor.applyTip') }}</small>
</v-toolbar-items>
</v-toolbar>
<v-card-text class="pa-0">
<VueMonacoEditor language="json" theme="vs-dark" style="height: calc(100vh - 64px);"
v-model:value="config_data_str">
</VueMonacoEditor>
</v-card-text>
</v-card>
</v-dialog>
<!-- Config Management Dialog -->
<v-dialog v-model="configManageDialog" max-width="800px">
<v-card>
<v-card-title class="d-flex align-center justify-space-between">
<span class="text-h4">配置文件管理</span>
<v-btn icon="mdi-close" variant="text" @click="configManageDialog = false"></v-btn>
</v-card-title>
<v-card-text>
<small>AstrBot 支持针对不同消息平台实例分别设置配置文件默认会使用 `default` 配置</small>
<div class="mt-6 mb-4">
<v-btn prepend-icon="mdi-plus" @click="startCreateConfig" variant="tonal" color="primary">
新建配置文件
</v-btn>
</div>
<!-- Config List -->
<v-list lines="two">
<v-list-item v-for="config in configInfoList" :key="config.id" :title="config.name">
<v-list-item-subtitle>当前应用于: {{ formatUmop(config.umop) }} </v-list-item-subtitle>
<template v-slot:append v-if="config.id !== 'default'">
<div class="d-flex align-center" style="gap: 8px;">
<v-btn icon="mdi-pencil" size="small" variant="text" color="warning"
@click="startEditConfig(config)"></v-btn>
<v-btn icon="mdi-delete" size="small" variant="text" color="error"
@click="confirmDeleteConfig(config)"></v-btn>
</div>
<AstrBotConfig v-else :metadata="metadata[key]['metadata']" :iterable="config_data" :metadataKey="key2">
</AstrBotConfig>
</div>
</template>
</v-list-item>
</v-list>
<!-- Create/Edit Form -->
<v-divider v-if="showConfigForm" class="my-6"></v-divider>
<div v-if="showConfigForm">
<h3 class="mb-4">{{ isEditingConfig ? '编辑配置文件' : '新建配置文件' }}</h3>
<div class="mb-4">
<small v-if="conflictMessage"> {{ conflictMessage }}</small>
</div>
<v-text-field v-model="configFormData.name" label="配置文件名称" variant="outlined" class="mb-4"
hide-details></v-text-field>
<v-select v-model="configFormData.umop" :items="platformList" item-title="id" item-value="id" label="应用于平台"
variant="outlined" hide-details multiple @update:model-value="checkPlatformConflictOnForm">
<template v-slot:item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" :subtitle="item.raw.type"></v-list-item>
</template>
</v-select>
</v-container>
</v-tabs-window-item>
<div style="margin-left: 16px; padding-bottom: 16px">
<small>{{ tm('help.helpPrefix') }}
<a href="https://astrbot.app/" target="_blank">{{ tm('help.documentation') }}</a>
{{ tm('help.helpMiddle') }}
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=EYGsuUTfe00_iOu9JTXS7_TEpMkXOvwv&jump_from=webapi&authKey=uUEMKCROfsseS+8IzqPjzV3y1tzy4AkykwTib2jNkOFdzezF9s9XknqnIaf3CDft" target="_blank">{{ tm('help.support') }}</a>{{ tm('help.helpSuffix') }}
</small>
</div>
</v-tabs-window>
</v-card>
<!-- 代码编辑 -->
<v-card v-else style="background-color: #1e1e1e;">
<VueMonacoEditor theme="vs-dark" language="json" height="80vh" style="padding-top: 16px; padding-bottom: 16px;"
v-model:value="config_data_str">
</VueMonacoEditor>
</v-card>
<v-btn icon="mdi-content-save" size="x-large" style="position: fixed; right: 52px; bottom: 52px;" color="darkprimary"
@click="updateConfig">
</v-btn>
<div class="d-flex justify-end mt-4" style="gap: 8px;">
<v-btn variant="text" @click="cancelConfigForm">取消</v-btn>
<v-btn color="primary" @click="saveConfigForm"
:disabled="!configFormData.name || !configFormData.umop.length || !!conflictMessage">
{{ isEditingConfig ? '更新' : '创建' }}
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</v-dialog>
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
{{ save_message }}
@@ -130,23 +181,22 @@
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import AstrBotConfigV4 from '@/components/shared/AstrBotConfigV4.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import config from '@/config';
import { useI18n, useModuleI18n } from '@/i18n/composables';
export default {
name: 'ConfigPage',
components: {
AstrBotConfig,
AstrBotConfigV4,
VueMonacoEditor,
WaitingForRestart
},
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/config');
return {
t,
tm
@@ -154,7 +204,6 @@ export default {
},
computed: {
// 访
messages() {
return {
loadError: this.tm('messages.loadError'),
@@ -163,7 +212,22 @@ export default {
configApplied: this.tm('messages.configApplied'),
configApplyError: this.tm('messages.configApplyError')
};
}
},
configInfoNameList() {
return this.configInfoList.map(info => info.name);
},
selectedConfigInfo() {
return this.configInfoList.find(info => info.id === this.selectedConfigID) || {};
},
configSelectItems() {
const items = [...this.configInfoList];
items.push({
id: '_%manage%_',
name: '管理配置文件...',
umop: []
});
return items;
},
},
watch: {
config_data_str: function (val) {
@@ -172,6 +236,10 @@ export default {
},
data() {
return {
codeEditorDialog: false,
configManageDialog: false,
showConfigForm: false,
isEditingConfig: false,
config_data_has_changed: false,
config_data_str: "",
config_data: {
@@ -179,30 +247,79 @@ export default {
},
fetched: false,
metadata: {},
provider_config_tmpl: {},
adapter_config_tmpl: {}, //
save_message_snack: false,
save_message: "",
save_message_success: "",
namespace: "",
tab: 0,
editorTab: 0, // 0: visual, 1: code
config_template_tab: 0,
tab: 0, //
//
configType: 'normal', // 'normal' 'system'
//
isSystemConfig: false,
//
selectedConfigID: null, //
configInfoList: [],
platformList: [],
configFormData: {
name: '',
umop: [],
},
editingConfigId: null,
conflictMessage: '', //
}
},
mounted() {
this.getConfig();
this.getConfigInfoList("default");
//
this.configType = this.isSystemConfig ? 'system' : 'normal';
},
methods: {
getConfig() {
//
axios.get('/api/config/get').then((res) => {
getConfigInfoList(abconf_id) {
//
axios.get('/api/config/abconfs').then((res) => {
this.configInfoList = res.data.data.info_list;
if (abconf_id) {
for (let i = 0; i < this.configInfoList.length; i++) {
if (this.configInfoList[i].id === abconf_id) {
this.selectedConfigID = this.configInfoList[i].id
this.getConfig(abconf_id);
break;
}
}
}
}).catch((err) => {
this.save_message = this.messages.loadError;
this.save_message_snack = true;
this.save_message_success = "error";
});
},
getPlatformList() {
axios.get('/api/config/platform/list').then((res) => {
this.platformList = res.data.data.platforms;
}).catch((err) => {
console.error(this.t('status.dataError'), err);
});
},
getConfig(abconf_id) {
this.fetched = false
const params = {};
if (this.isSystemConfig) {
params.system_config = '1';
} else {
params.id = abconf_id || this.selectedConfigID;
}
axios.get('/api/config/abconf', {
params: params
}).then((res) => {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
this.provider_config_tmpl = res.data.data.provider_config_tmpl;
this.adapter_config_tmpl = res.data.data.adapter_config_tmpl;
}).catch((err) => {
this.save_message = this.messages.loadError;
this.save_message_snack = true;
@@ -211,12 +328,28 @@ export default {
},
updateConfig() {
if (!this.fetched) return;
axios.post('/api/config/astrbot/update', this.config_data).then((res) => {
const postData = {
config: this.config_data
};
if (this.isSystemConfig) {
postData.conf_id = 'default';
} else {
postData.conf_id = this.selectedConfigID;
}
axios.post('/api/config/astrbot/update', postData).then((res) => {
if (res.data.status === "ok") {
this.save_message = res.data.message || this.messages.saveSuccess;
this.save_message_snack = true;
this.save_message_success = "success";
this.$refs.wfr.check();
if (this.isSystemConfig) {
axios.post('/api/stat/restart-core').then(() => {
this.$refs.wfr.check();
})
}
} else {
this.save_message = res.data.message || this.messages.saveError;
this.save_message_snack = true;
@@ -245,27 +378,248 @@ export default {
this.save_message_snack = true;
}
},
addFromDefaultConfigTmpl(val, group_name, config_item_name) {
console.log(val);
createNewConfig() {
// umo part
// platform::
const umo_parts = this.configFormData.umop.map(platform => platform + "::");
let tmpl = this.metadata[group_name]['metadata'][config_item_name]['config_template'][val];
let new_tmpl_cfg = JSON.parse(JSON.stringify(tmpl));
// new_tmpl_cfg.id = "new_" + val + "_" + this.config_data[config_item_name].length;
this.config_data[config_item_name].push(new_tmpl_cfg);
this.config_template_tab = this.config_data[config_item_name].length - 1;
axios.post('/api/config/abconf/new', {
umo_parts: umo_parts,
name: this.configFormData.name
}).then((res) => {
if (res.data.status === "ok") {
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
this.getConfigInfoList(res.data.data.conf_id);
this.cancelConfigForm();
} else {
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "error";
}
}).catch((err) => {
console.error(err);
this.save_message = "新配置文件创建失败";
this.save_message_snack = true;
this.save_message_success = "error";
});
},
deleteItem(config_item_name, index) {
console.log(config_item_name, index);
let new_list = [];
for (let i = 0; i < this.config_data[config_item_name].length; i++) {
if (i !== index) {
new_list.push(this.config_data[config_item_name][i]);
checkPlatformConflict(newPlatforms) {
const conflictConfigs = [];
// "default"
for (const config of this.configInfoList) {
if (config.name === 'default') {
continue; // default
}
if (config.umop && config.umop.length > 0) {
//
const existingPlatforms = config.umop.map(umop => {
const platformPart = umop.split(":")[0];
return platformPart === "" ? "*" : platformPart; //
});
//
const hasConflict = newPlatforms.some(newPlatform => {
return existingPlatforms.includes(newPlatform) || existingPlatforms.includes("*");
}) || (newPlatforms.includes("*") && existingPlatforms.length > 0);
if (hasConflict) {
conflictConfigs.push(config);
}
}
}
this.config_data[config_item_name] = new_list;
if (this.config_template_tab > 0) {
this.config_template_tab -= 1;
return conflictConfigs;
},
onConfigSelect(value) {
if (value === '_%manage%_') {
this.configManageDialog = true;
this.getPlatformList();
//
this.$nextTick(() => {
this.selectedConfigID = this.selectedConfigInfo.id || 'default';
});
} else {
this.getConfig(value);
}
},
startCreateConfig() {
this.showConfigForm = true;
this.isEditingConfig = false;
this.configFormData = {
name: '',
umop: [],
};
this.editingConfigId = null;
this.conflictMessage = '';
},
startEditConfig(config) {
this.showConfigForm = true;
this.isEditingConfig = true;
this.editingConfigId = config.id;
this.configFormData = {
name: config.name || '',
umop: config.umop ? config.umop.map(part => part.split("::")[0]).filter(p => p) : [],
};
this.conflictMessage = '';
},
cancelConfigForm() {
this.showConfigForm = false;
this.isEditingConfig = false;
this.editingConfigId = null;
this.configFormData = {
name: '',
umop: [],
};
this.conflictMessage = '';
},
saveConfigForm() {
if (!this.configFormData.name || !this.configFormData.umop.length) {
this.save_message = "请填写配置名称和选择应用平台";
this.save_message_snack = true;
this.save_message_success = "error";
return;
}
if (this.conflictMessage) {
return;
}
if (this.isEditingConfig) {
this.updateConfigInfo();
} else {
this.createNewConfig();
}
},
confirmDeleteConfig(config) {
if (confirm(`确定要删除配置文件 "${config.name}" 吗?此操作不可恢复。`)) {
this.deleteConfig(config.id);
}
},
deleteConfig(configId) {
axios.post('/api/config/abconf/delete', {
id: configId
}).then((res) => {
if (res.data.status === "ok") {
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
//
this.getConfigInfoList("default");
} else {
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "error";
}
}).catch((err) => {
console.error(err);
this.save_message = "删除配置文件失败";
this.save_message_snack = true;
this.save_message_success = "error";
});
},
checkPlatformConflictOnForm() {
if (!this.configFormData.umop || this.configFormData.umop.length === 0) {
this.conflictMessage = '';
return;
}
//
let conflictConfigs = this.checkPlatformConflict(this.configFormData.umop);
//
if (this.isEditingConfig && this.editingConfigId) {
conflictConfigs = conflictConfigs.filter(config => config.id !== this.editingConfigId);
}
if (conflictConfigs.length > 0) {
const conflictNames = conflictConfigs.map(config => config.name).join(', ');
this.conflictMessage = `提示:选择的平台与现有配置文件重复:${conflictNames}。AstrBot 将只会应用首个匹配的配置文件。`;
} else {
this.conflictMessage = '';
}
},
updateConfigInfo() {
// umo part
// platform::
const umo_parts = this.configFormData.umop.map(platform => platform + "::");
axios.post('/api/config/abconf/update', {
id: this.editingConfigId,
name: this.configFormData.name,
umo_parts: umo_parts
}).then((res) => {
if (res.data.status === "ok") {
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
this.getConfigInfoList(this.editingConfigId);
this.cancelConfigForm();
} else {
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "error";
}
}).catch((err) => {
console.error(err);
this.save_message = "更新配置文件失败";
this.save_message_snack = true;
this.save_message_success = "error";
});
},
formatUmop(umop) {
if (!umop) {
return
}
let ret = ""
for (let i = 0; i < umop.length; i++) {
let platformPart = umop[i].split(":")[0];
if (platformPart === "") {
return "所有平台";
} else {
ret += platformPart + ",";
}
}
ret = ret.slice(0, -1);
return ret;
},
onConfigTypeToggle() {
this.isSystemConfig = this.configType === 'system';
this.tab = 0; //
this.fetched = false; //
if (this.isSystemConfig) {
//
this.getConfig();
} else {
// default
if (this.selectedConfigID) {
this.getConfig(this.selectedConfigID);
} else {
this.getConfigInfoList("default");
}
}
},
onSystemConfigToggle() {
// configType
this.configType = this.isSystemConfig ? 'system' : 'normal';
this.tab = 0; //
this.fetched = false; //
if (this.isSystemConfig) {
//
this.getConfig();
} else {
// default
if (this.selectedConfigID) {
this.getConfig(this.selectedConfigID);
} else {
this.getConfigInfoList("default");
}
}
}
},
@@ -277,4 +631,57 @@ export default {
.v-tab {
text-transform: none !important;
}
/* 按钮切换样式优化 */
.v-btn-toggle .v-btn {
transition: all 0.3s ease !important;
}
.v-btn-toggle .v-btn:not(.v-btn--active) {
opacity: 0.7;
}
.v-btn-toggle .v-btn.v-btn--active {
opacity: 1;
font-weight: 600;
}
@media (min-width: 768px) {
.config-tabs {
display: flex;
margin: 16px 16px 0 0;
}
.config-panel {
width: 750px;
}
.config-tabs-window {
flex: 1;
}
.config-tabs .v-tab {
justify-content: flex-start !important;
text-align: left;
min-height: 48px;
}
}
@media (max-width: 767px) {
.config-tabs {
width: 100%;
}
.v-container {
padding: 4px;
}
.config-panel {
width: 100%;
}
.config-tabs-window {
margin-top: 16px;
}
}
</style>
-5
View File
@@ -151,8 +151,6 @@
{{ save_message }}
</v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart>
<!-- ID冲突确认对话框 -->
<v-dialog v-model="showIdConflictDialog" max-width="450" persistent>
<v-card>
@@ -359,7 +357,6 @@ export default {
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.$refs.wfr.check();
this.showSuccess(res.data.message || this.messages.updateSuccess);
}).catch((err) => {
this.loading = false;
@@ -413,7 +410,6 @@ export default {
if (confirm(`${this.messages.deleteConfirm} ${platform.id}?`)) {
axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
this.getConfig();
this.$refs.wfr.check();
this.showSuccess(res.data.message || this.messages.deleteSuccess);
}).catch((err) => {
this.showError(err.response?.data?.message || err.message);
@@ -429,7 +425,6 @@ export default {
config: platform
}).then((res) => {
this.getConfig();
this.$refs.wfr.check();
this.showSuccess(res.data.message || this.messages.statusUpdateSuccess);
}).catch((err) => {
platform.enable = !platform.enable; //
-80
View File
@@ -12,9 +12,6 @@
</p>
</div>
<div>
<v-btn color="success" prepend-icon="mdi-cog" variant="tonal" class="me-2" @click="showSettingsDialog = true" rounded="xl" size="x-large">
{{ tm('providers.settings') }}
</v-btn>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true" rounded="xl" size="x-large">
{{ tm('providers.addProvider') }}
</v-btn>
@@ -250,49 +247,6 @@
</v-card>
</v-dialog>
<!-- 设置对话框 -->
<v-dialog v-model="showSettingsDialog" max-width="600px">
<v-card>
<v-card-title class="bg-primary text-white py-3 px-4" style="display: flex; align-items: center;">
<v-icon color="white" class="me-2">mdi-cog</v-icon>
<span>{{ tm('dialogs.settings.title') }}</span>
<v-spacer></v-spacer>
<v-btn icon variant="text" color="white" @click="showSettingsDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<v-list>
<v-list-item>
<v-switch
style="padding: 12px;"
v-model="sessionSeparationEnabled"
color="primary"
:loading="sessionSettingLoading"
@change="updateSessionSeparation"
hide-details
>
<template v-slot:label>
<div>
<div class="text-subtitle-1">{{ tm('dialogs.settings.sessionSeparation.title') }}</div>
<div class="text-caption text-medium-emphasis">{{ tm('dialogs.settings.sessionSeparation.description') }}</div>
</div>
</template>
</v-switch>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showSettingsDialog = false">
{{ tm('dialogs.settings.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
@@ -365,11 +319,6 @@ export default {
metadata: {},
showProviderCfg: false,
//
showSettingsDialog: false,
sessionSeparationEnabled: false,
sessionSettingLoading: false,
// ID
showIdConflictDialog: false,
conflictId: '',
@@ -464,10 +413,8 @@ export default {
add: this.tm('messages.success.add'),
delete: this.tm('messages.success.delete'),
statusUpdate: this.tm('messages.success.statusUpdate'),
sessionSeparation: this.tm('messages.success.sessionSeparation')
},
error: {
sessionSeparation: this.tm('messages.error.sessionSeparation'),
fetchStatus: this.tm('messages.error.fetchStatus')
},
confirm: {
@@ -502,7 +449,6 @@ export default {
mounted() {
this.getConfig();
this.getSessionSeparationStatus();
},
methods: {
@@ -722,32 +668,6 @@ export default {
});
},
//
getSessionSeparationStatus() {
axios.get('/api/config/provider/get_session_seperate').then((res) => {
if (res.data && res.data.status === 'ok') {
this.sessionSeparationEnabled = res.data.data.enable;
}
}).catch((err) => {
this.showError(err.response?.data?.message || this.messages.error.sessionSeparation);
});
},
//
updateSessionSeparation() {
this.sessionSettingLoading = true;
axios.post('/api/config/provider/set_session_seperate', {
enable: this.sessionSeparationEnabled
}).then((res) => {
this.showSuccess(res.data.message || this.messages.success.sessionSeparation);
this.sessionSettingLoading = false;
}).catch((err) => {
this.sessionSeparationEnabled = !this.sessionSeparationEnabled; //
this.showError(err.response?.data?.message || err.message);
this.sessionSettingLoading = false;
});
},
showSuccess(message) {
this.save_message = message;
this.save_message_success = "success";
+55 -35
View File
@@ -8,6 +8,7 @@ from astrbot.api.provider import ProviderRequest
from astrbot.api.message_components import Plain, Image
from astrbot import logger
from collections import defaultdict
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
"""
聊天记忆增强
@@ -15,28 +16,40 @@ from collections import defaultdict
class LongTermMemory:
def __init__(self, config: dict, context: star.Context):
self.config = config
def __init__(self, acm: AstrBotConfigManager, context: star.Context):
self.acm = acm
self.context = context
self.session_chats = defaultdict(list)
"""记录群成员的群聊记录"""
def cfg(self, event: AstrMessageEvent):
cfg = self.context.get_config(umo=event.unified_msg_origin)
try:
self.max_cnt = int(self.config["group_message_max_cnt"])
max_cnt = int(cfg["group_message_max_cnt"])
except BaseException as e:
logger.error(e)
self.max_cnt = 300
self.image_caption = self.config["image_caption"]
self.image_caption_prompt = self.config["image_caption_prompt"]
self.image_caption_provider_id = self.config["image_caption_provider_id"]
self.active_reply = self.config["active_reply"]
self.enable_active_reply = self.active_reply.get("enable", False)
self.ar_method = self.active_reply["method"]
self.ar_possibility = self.active_reply["possibility_reply"]
self.ar_prompt = self.active_reply.get("prompt", "")
self.ar_whitelist = self.active_reply.get("whitelist", [])
# self.put_history_to_prompt = self.config["put_history_to_prompt"]
max_cnt = 300
image_caption = cfg["image_caption"]
image_caption_prompt = cfg["image_caption_prompt"] # TODO: 去掉这个配置项
image_caption_provider_id = cfg["image_caption_provider_id"] # TODO: 去掉这个配置项
active_reply = cfg["active_reply"]
enable_active_reply = active_reply.get("enable", False)
ar_method = active_reply["method"]
ar_possibility = active_reply["possibility_reply"]
ar_prompt = active_reply.get("prompt", "")
ar_whitelist = active_reply.get("whitelist", [])
ret = {
"max_cnt": max_cnt,
"image_caption": image_caption,
"image_caption_prompt": image_caption_prompt,
"image_caption_provider_id": image_caption_provider_id,
"enable_active_reply": enable_active_reply,
"ar_method": ar_method,
"ar_possibility": ar_possibility,
"ar_prompt": ar_prompt,
"ar_whitelist": ar_whitelist,
}
return ret
async def remove_session(self, event: AstrMessageEvent) -> int:
cnt = 0
@@ -45,17 +58,17 @@ class LongTermMemory:
del self.session_chats[event.unified_msg_origin]
return cnt
async def get_image_caption(self, image_url: str) -> str:
if not self.image_caption_provider_id:
async def get_image_caption(
self, image_url: str, image_caption_provider_id: str, image_caption_prompt: str
) -> str:
if not image_caption_provider_id:
provider = self.context.get_using_provider()
else:
provider = self.context.get_provider_by_id(self.image_caption_provider_id)
provider = self.context.get_provider_by_id(image_caption_provider_id)
if not provider:
raise Exception(
f"没有找到 ID 为 {self.image_caption_provider_id} 的提供商"
)
raise Exception(f"没有找到 ID 为 {image_caption_provider_id} 的提供商")
response = await provider.text_chat(
prompt=self.image_caption_prompt,
prompt=image_caption_prompt,
session_id=uuid.uuid4().hex,
image_urls=[image_url],
persist=False,
@@ -63,7 +76,8 @@ class LongTermMemory:
return response.completion_text
async def need_active_reply(self, event: AstrMessageEvent) -> bool:
if not self.enable_active_reply:
cfg = self.cfg(event)
if not cfg["enable_active_reply"]:
return False
if event.get_message_type() != MessageType.GROUP_MESSAGE:
return False
@@ -72,15 +86,15 @@ class LongTermMemory:
# if the message is a command, let it pass
return False
if self.ar_whitelist and (
event.unified_msg_origin not in self.ar_whitelist
and (event.get_group_id() and event.get_group_id() not in self.ar_whitelist)
if cfg["ar_whitelist"] and (
event.unified_msg_origin not in cfg["ar_whitelist"]
and (event.get_group_id() and event.get_group_id() not in cfg["ar_whitelist"])
):
return False
match self.ar_method:
match cfg["ar_method"]:
case "possibility_reply":
trig = random.random() < self.ar_possibility
trig = random.random() < cfg["ar_possibility"]
return trig
return False
@@ -92,15 +106,19 @@ class LongTermMemory:
final_message = f"[{event.message_obj.sender.nickname}/{datetime_str}]: "
cfg = self.cfg(event)
for comp in event.get_messages():
if isinstance(comp, Plain):
final_message += f" {comp.text}"
elif isinstance(comp, Image):
# image_urls.append(comp.url if comp.url else comp.file)
if self.image_caption:
cfg = self.cfg(event)
if cfg["image_caption"]:
try:
caption = await self.get_image_caption(
comp.url if comp.url else comp.file
comp.url if comp.url else comp.file,
cfg["image_caption_provider_id"],
cfg["image_caption_prompt"],
)
final_message += f" [Image: {caption}]"
except Exception as e:
@@ -109,7 +127,7 @@ class LongTermMemory:
final_message += " [Image]"
logger.debug(f"ltm | {event.unified_msg_origin} | {final_message}")
self.session_chats[event.unified_msg_origin].append(final_message)
if len(self.session_chats[event.unified_msg_origin]) > self.max_cnt:
if len(self.session_chats[event.unified_msg_origin]) > cfg["max_cnt"]:
self.session_chats[event.unified_msg_origin].pop(0)
async def on_req_llm(self, event: AstrMessageEvent, req: ProviderRequest):
@@ -119,7 +137,8 @@ class LongTermMemory:
chats_str = "\n---\n".join(self.session_chats[event.unified_msg_origin])
if self.enable_active_reply:
cfg = self.cfg(event)
if cfg["enable_active_reply"]:
prompt = req.prompt
req.prompt = f"You are now in a chatroom. The chat history is as follows:\n{chats_str}"
req.prompt += f"\nNow, a new message is coming: `{prompt}`. Please react to it. Only output your response and do not output any other information."
@@ -138,5 +157,6 @@ class LongTermMemory:
final_message = f"[You/{datetime.datetime.now().strftime('%H:%M:%S')}]: {event.get_result().get_plain_text()}"
logger.debug(f"ltm | {event.unified_msg_origin} | {final_message}")
self.session_chats[event.unified_msg_origin].append(final_message)
if len(self.session_chats[event.unified_msg_origin]) > self.max_cnt:
cfg = self.cfg(event)
if len(self.session_chats[event.unified_msg_origin]) > cfg["max_cnt"]:
self.session_chats[event.unified_msg_origin].pop(0)
+28 -32
View File
@@ -60,9 +60,6 @@ class Main(star.Star):
def __init__(self, context: star.Context) -> None:
self.context = context
cfg = context.get_config()
self.prompt_prefix = cfg["provider_settings"]["prompt_prefix"]
self.identifier = cfg["provider_settings"]["identifier"]
self.enable_datetime = cfg["provider_settings"]["datetime_system_prompt"]
self.timezone = cfg.get("timezone")
if not self.timezone:
# 系统默认时区
@@ -70,18 +67,10 @@ class Main(star.Star):
else:
logger.info(f"Timezone set to: {self.timezone}")
self.ltm = None
if (
self.context.get_config()["provider_ltm_settings"]["group_icl_enable"]
or self.context.get_config()["provider_ltm_settings"]["active_reply"][
"enable"
]
):
try:
self.ltm = LongTermMemory(
self.context.get_config()["provider_ltm_settings"], self.context
)
except BaseException as e:
logger.error(f"聊天增强 err: {e}")
try:
self.ltm = LongTermMemory(self.context.astrbot_config_mgr, self.context)
except BaseException as e:
logger.error(f"聊天增强 err: {e}")
async def _query_astrbot_notice(self):
try:
@@ -93,6 +82,12 @@ class Main(star.Star):
except BaseException:
return ""
def ltm_enabled(self, event: AstrMessageEvent):
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
"provider_ltm_settings"
]
return ltmse["group_icl_enable"] or ltmse["active_reply"]["enable"]
@filter.command("help")
async def help(self, event: AstrMessageEvent):
"""查看帮助"""
@@ -139,7 +134,7 @@ class Main(star.Star):
@filter.command("llm")
async def llm(self, event: AstrMessageEvent):
"""开启/关闭 LLM"""
cfg = self.context.get_config()
cfg = self.context.get_config(umo=event.unified_msg_origin)
enable = cfg["provider_settings"]["enable"]
if enable:
cfg["provider_settings"]["enable"] = False
@@ -294,7 +289,7 @@ class Main(star.Star):
@filter.command("t2i")
async def t2i(self, event: AstrMessageEvent):
"""开关文本转图片"""
config = self.context.get_config()
config = self.context.get_config(umo=event.unified_msg_origin)
if config["t2i"]:
config["t2i"] = False
config.save_config()
@@ -376,8 +371,9 @@ UID: {user_id} 此 ID 可用于设置管理员。
"使用方法: /wl <id> 添加白名单;/dwl <id> 删除白名单。可通过 /sid 获取 ID。"
)
)
self.context.get_config()["platform_settings"]["id_whitelist"].append(str(sid))
self.context.get_config().save_config()
cfg = self.context.get_config(umo=event.unified_msg_origin)
cfg["platform_settings"]["id_whitelist"].append(str(sid))
cfg.save_config()
event.set_result(MessageEventResult().message("添加白名单成功。"))
@filter.permission_type(filter.PermissionType.ADMIN)
@@ -385,10 +381,9 @@ UID: {user_id} 此 ID 可用于设置管理员。
async def dwl(self, event: AstrMessageEvent, sid: str):
"""删除白名单。dwl <sid>"""
try:
self.context.get_config()["platform_settings"]["id_whitelist"].remove(
str(sid)
)
self.context.get_config().save_config()
cfg = self.context.get_config(umo=event.unified_msg_origin)
cfg["platform_settings"]["id_whitelist"].remove(str(sid))
cfg.save_config()
event.set_result(MessageEventResult().message("删除白名单成功。"))
except ValueError:
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
@@ -551,7 +546,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
)
ret = "清除会话 LLM 聊天历史成功。"
if self.ltm:
if self.ltm and self.ltm_enabled(message):
cnt = await self.ltm.remove_session(event=message)
ret += f"\n聊天增强: 已清除 {cnt} 条聊天记录。"
@@ -769,7 +764,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
)
# 长期记忆
if self.ltm:
if self.ltm and self.ltm_enabled(message):
try:
await self.ltm.remove_session(event=message)
except Exception as e:
@@ -1137,7 +1132,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
has_image_or_plain = True
break
if self.ltm and has_image_or_plain:
if self.ltm_enabled(event) and self.ltm and has_image_or_plain:
need_active = await self.ltm.need_active_reply(event)
group_icl_enable = self.context.get_config()["provider_ltm_settings"][
@@ -1205,8 +1200,9 @@ UID: {user_id} 此 ID 可用于设置管理员。
@filter.on_llm_request()
async def decorate_llm_req(self, event: AstrMessageEvent, req: ProviderRequest):
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
if self.prompt_prefix:
req.prompt = self.prompt_prefix + req.prompt
cfg = self.context.get_config(umo=event.unified_msg_origin)["provider_settings"]
if prefix := cfg.get("prompt_prefix"):
req.prompt = prefix + req.prompt
# 解析引用内容
quote = None
@@ -1215,14 +1211,14 @@ UID: {user_id} 此 ID 可用于设置管理员。
quote = comp
break
if self.identifier:
if cfg.get("identifier"):
user_id = event.message_obj.sender.user_id
user_nickname = event.message_obj.sender.nickname
user_info = f"\n[User ID: {user_id}, Nickname: {user_nickname}]\n"
req.prompt = user_info + req.prompt
# 启用附加时间戳
if self.enable_datetime:
if cfg.get("datetime_system_prompt"):
current_time = None
if self.timezone:
# 启用时区
@@ -1300,7 +1296,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
except BaseException as e:
logger.error(f"处理引用图片失败: {e}")
if self.ltm:
if self.ltm and self.ltm_enabled(event):
try:
await self.ltm.on_req_llm(event, req)
except BaseException as e:
@@ -1309,7 +1305,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
@filter.after_message_sent()
async def after_llm_req(self, event: AstrMessageEvent):
"""在 LLM 请求后记录对话"""
if self.ltm:
if self.ltm and self.ltm_enabled(event):
try:
await self.ltm.after_req_llm(event)
except BaseException as e:
+8 -7
View File
@@ -19,9 +19,6 @@ class Waiter(Star):
def __init__(self, context: Context):
super().__init__(context)
self.p_settings: dict = self.context.get_config()["platform_settings"]
self.wake_prefix = self.context.get_config()["wake_prefix"]
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
async def handle_session_control_agent(self, event: AstrMessageEvent):
"""会话控制代理"""
@@ -36,16 +33,19 @@ class Waiter(Star):
"""实现了对只有一个 @ 的消息内容的处理"""
try:
messages = event.get_messages()
cfg = self.context.get_config(umo=event.unified_msg_origin)
p_settings = cfg["platform_settings"]
wake_prefix = cfg.get("wake_prefix", [])
if len(messages) == 1:
if (
isinstance(messages[0], Comp.At)
and str(messages[0].qq) == str(event.get_self_id())
and self.p_settings.get("empty_mention_waiting", True)
and p_settings.get("empty_mention_waiting", True)
) or (
isinstance(messages[0], Comp.Plain)
and messages[0].text.strip() in self.wake_prefix
and messages[0].text.strip() in wake_prefix
):
if self.p_settings.get("empty_mention_waiting_need_reply", True):
if p_settings.get("empty_mention_waiting_need_reply", True):
try:
# 尝试使用 LLM 生成更生动的回复
func_tools_mgr = self.context.get_llm_tool_manager()
@@ -63,7 +63,8 @@ class Waiter(Star):
else:
# 创建新对话
curr_cid = await self.context.conversation_manager.new_conversation(
event.unified_msg_origin, platform_id=event.get_platform_id()
event.unified_msg_origin,
platform_id=event.get_platform_id(),
)
# 使用 LLM 生成回复
+4 -6
View File
@@ -8,15 +8,13 @@ from openai.types.chat.chat_completion import ChatCompletion
class R1Filter(Star):
def __init__(self, context: Context):
super().__init__(context)
self.display_reasoning_text = (
self.context.get_config()
.get("provider_settings", {})
.get("display_reasoning_text", False)
)
@filter.on_llm_response()
async def resp(self, event: AstrMessageEvent, response: LLMResponse):
if self.display_reasoning_text:
cfg = self.context.get_config(umo=event.unified_msg_origin).get(
"provider_settings", {}
)
if cfg.get("display_reasoning_text", False):
# 显示推理内容的处理逻辑
if (
response
+6 -43
View File
@@ -22,19 +22,6 @@ class Main(star.Star):
self.sogo_search = Sogo()
self.google = Google()
self.websearch_link = self.context.get_config()["provider_settings"].get(
"web_search_link", False
)
async def initialize(self):
websearch = self.context.get_config()["provider_settings"]["web_search"]
if websearch:
self.context.activate_llm_tool("web_search")
self.context.activate_llm_tool("fetch_url")
else:
self.context.deactivate_llm_tool("web_search")
self.context.deactivate_llm_tool("fetch_url")
async def _tidy_text(self, text: str) -> str:
"""清理文本,去除空格、换行符等"""
return text.strip().replace("\n", " ").replace("\r", " ").replace(" ", " ")
@@ -54,34 +41,7 @@ class Main(star.Star):
@filter.command("websearch")
async def websearch(self, event: AstrMessageEvent, oper: str = None) -> str:
websearch = self.context.get_config()["provider_settings"]["web_search"]
if oper is None:
status = "开启" if websearch else "关闭"
event.set_result(
MessageEventResult().message(
"当前网页搜索功能状态:"
+ status
+ "。使用 /websearch on 或者 off 启用或者关闭。"
)
)
return
if oper == "on":
self.context.get_config()["provider_settings"]["web_search"] = True
self.context.get_config().save_config()
self.context.activate_llm_tool("web_search")
self.context.activate_llm_tool("fetch_url")
event.set_result(MessageEventResult().message("已开启网页搜索功能"))
elif oper == "off":
self.context.get_config()["provider_settings"]["web_search"] = False
self.context.get_config().save_config()
self.context.deactivate_llm_tool("web_search")
self.context.deactivate_llm_tool("fetch_url")
event.set_result(MessageEventResult().message("已关闭网页搜索功能"))
else:
event.set_result(
MessageEventResult().message("操作参数错误,应为 on 或 off")
)
event.set_result(MessageEventResult().message("此指令已经被废弃,请在 WebUI 中开启或关闭网页搜索功能。"))
@llm_tool("web_search")
async def search_from_search_engine(
@@ -93,6 +53,9 @@ class Main(star.Star):
query(string): 和用户的问题最相关的搜索关键词用于在 Google 上搜索
"""
logger.info("web_searcher - search_from_search_engine: " + query)
websearch_link = self.context.get_config(umo=event.unified_msg_origin)[
"provider_settings"
].get("web_search_link", False)
results = []
RESULT_NUM = 5
try:
@@ -128,13 +91,13 @@ class Main(star.Star):
header = f"{idx}. {i.title} "
if self.websearch_link and i.url:
if websearch_link and i.url:
header += i.url
ret += f"{header}\n{i.snippet}\n{site_result}\n\n"
idx += 1
if self.websearch_link:
if websearch_link:
ret += "针对问题,请根据上面的结果分点总结,并且在结尾处附上对应内容的参考链接(如有)。"
return ret