Merge remote-tracking branch 'origin/v3' into v3

# Conflicts:
#	app/api/endpoints/agent.py
#	app/api/endpoints/anthropic.py
#	app/api/endpoints/openai.py
#	app/chain/__init__.py
#	app/chain/message.py
#	app/chain/site.py
#	app/chain/subscribe.py
#	app/chain/transfer.py
#	app/modules/discord/__init__.py
#	app/modules/qqbot/__init__.py
#	app/modules/slack/__init__.py
#	app/modules/telegram/__init__.py
#	app/modules/wechat/__init__.py
#	app/runtime/extensions/module_manager.py
#	app/runtime/extensions/service_registry.py
#	tests/test_agent_interaction.py
#	tests/test_slash_command_interactions.py
#	tests/test_web_agent_stream.py
This commit is contained in:
jxxghp
2026-08-16 19:44:43 +08:00
232 changed files with 21375 additions and 6683 deletions
@@ -0,0 +1,260 @@
from __future__ import annotations
import importlib
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Any, Mapping
from app.runtime.capabilities.model import (
ActivationPolicy,
AdapterExecutionMode,
CapabilitySpec,
SelectorSchema,
)
from app.runtime.capabilities.registry import CapabilityRegistry
from app.runtime.config import settings
from app.runtime.extensions.service_config import ServiceConfigHelper
from app.schemas.types import (
DownloaderType,
MediaRecognizeType,
MediaServerType,
NotificationChannel,
ModuleType,
OtherModulesType,
StorageSchema,
SystemConfigKey,
)
HOST_MODULE_KIND = "host_module"
_SETTING_SELECTOR = "setting_truthy"
_SERVICE_SELECTOR = "system_config_item"
_MODULE_ROOT = Path(__file__).resolve().parents[2] / "modules"
_SERVICE_CONFIG_GETTERS = MappingProxyType({
SystemConfigKey.Downloaders.value: ServiceConfigHelper.get_downloader_configs,
SystemConfigKey.MediaServers.value: ServiceConfigHelper.get_mediaserver_configs,
SystemConfigKey.Notifications.value: ServiceConfigHelper.get_notification_configs,
})
_SUBTYPE_NAMES = frozenset(
item.name
for enum_type in (
DownloaderType,
MediaServerType,
NotificationChannel,
StorageSchema,
OtherModulesType,
MediaRecognizeType,
)
for item in enum_type
)
@dataclass(frozen=True, slots=True)
class HostModuleConfigSnapshot:
"""一次 reconcile 使用的不可变配置视图,避免每个能力重复查询配置。"""
settings: Mapping[str, Any]
services: Mapping[str, tuple[Any, ...]]
def _validate_setting_selector(config: Mapping[str, Any]) -> None:
"""限制 setting selector 只能读取已声明的应用设置。"""
key = config["key"]
if not isinstance(key, str) or not key or not hasattr(settings, key):
raise ValueError(f"未知应用设置:{key!r}")
def _validate_service_selector(config: Mapping[str, Any]) -> None:
"""限制服务 selector 使用经过 Schema 校验的三个宿主服务配置。"""
key = config["key"]
if key not in _SERVICE_CONFIG_GETTERS:
raise ValueError(f"不支持的服务配置:{key!r}")
if config["match_field"] != "type":
raise ValueError("服务 selector 的 match_field 必须是 type")
if config["enabled_field"] != "enabled":
raise ValueError("服务 selector 的 enabled_field 必须是 enabled")
match_value = config["match_value"]
if not isinstance(match_value, str) or not match_value:
raise ValueError("服务 selector 的 match_value 必须是非空字符串")
HOST_MODULE_SELECTOR_SCHEMAS = MappingProxyType({
_SETTING_SELECTOR: SelectorSchema(
required_fields=frozenset({"key"}),
validator=_validate_setting_selector,
),
_SERVICE_SELECTOR: SelectorSchema(
required_fields=frozenset({
"key",
"match_field",
"match_value",
"enabled_field",
}),
validator=_validate_service_selector,
),
})
def _validate_manifest_inventory(registry: CapabilityRegistry) -> None:
"""校验一级模块包与 manifest 一一对应,并固定宿主声明合同。"""
module_packages = {
child.name
for child in _MODULE_ROOT.iterdir()
if child.is_dir()
and not child.name.startswith("_")
and (child / "__init__.py").is_file()
}
specs = registry.list_specs()
manifest_packages = {spec.source.parent.name for spec in specs}
if module_packages != manifest_packages:
missing = sorted(module_packages - manifest_packages)
unknown = sorted(manifest_packages - module_packages)
raise ValueError(
f"Host Module manifest inventory 不一致:missing={missing} unknown={unknown}"
)
allowed_metadata = {"name", "type", "subtype", "priority"}
module_type_values = {item.value for item in ModuleType}
for spec in specs:
if spec.source.parent.parent != _MODULE_ROOT:
raise ValueError(f"Host Module manifest 必须位于一级模块包:{spec.source}")
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
expected_module = f"app.modules.{spec.source.parent.name}"
if module_name != expected_module or symbol_name != spec.id:
raise ValueError(
f"{spec.source}: entrypoint 必须指向同包且类名等于 capability id"
)
if set(spec.metadata) != allowed_metadata:
raise ValueError(
f"{spec.source}: metadata 字段必须是 {sorted(allowed_metadata)}"
)
if spec.metadata["type"] not in module_type_values:
raise ValueError(f"{spec.source}: 非法 metadata.type={spec.metadata['type']!r}")
if spec.metadata["subtype"] not in _SUBTYPE_NAMES:
raise ValueError(
f"{spec.source}: 非法 metadata.subtype={spec.metadata['subtype']!r}"
)
priority = spec.metadata["priority"]
if isinstance(priority, bool) or not isinstance(priority, int):
raise ValueError(f"{spec.source}: metadata.priority 必须是整数")
if spec.activation is ActivationPolicy.WHEN_CONFIGURED:
selector_key = str(spec.selector.config["key"])
if selector_key not in spec.watch:
raise ValueError(
f"{spec.source}: activation.watch 必须包含 selector 配置键"
)
def build_host_module_registry() -> CapabilityRegistry:
"""从现有物理模块包构建 import-free Host Module Registry。"""
registry = CapabilityRegistry.discover(
(_MODULE_ROOT,),
kinds={HOST_MODULE_KIND},
selector_schemas=HOST_MODULE_SELECTOR_SCHEMAS,
)
_validate_manifest_inventory(registry)
return registry
def capture_host_module_config(
specs: tuple[CapabilitySpec, ...],
) -> HostModuleConfigSnapshot:
"""对本轮涉及的设置和服务配置各读取一次并冻结容器。"""
setting_keys: set[str] = set()
service_keys: set[str] = set()
for spec in specs:
selector = spec.selector
if selector is None:
continue
key = str(selector.config["key"])
if selector.kind == _SETTING_SELECTOR:
setting_keys.add(key)
elif selector.kind == _SERVICE_SELECTOR:
service_keys.add(key)
setting_values = {
key: getattr(settings, key)
for key in sorted(setting_keys)
}
service_values = {
key: tuple(_SERVICE_CONFIG_GETTERS[key]())
for key in sorted(service_keys)
}
return HostModuleConfigSnapshot(
settings=MappingProxyType(setting_values),
services=MappingProxyType(service_values),
)
def should_run_host_module(
spec: CapabilitySpec,
snapshot: HostModuleConfigSnapshot,
) -> bool:
"""依据有限 selector 语法判断能力是否应拥有运行资源。"""
if spec.activation is ActivationPolicy.BOOTSTRAP:
return True
if spec.activation is ActivationPolicy.ON_FIRST_USE:
return False
selector = spec.selector
if selector is None:
return False
if selector.kind == _SETTING_SELECTOR:
return bool(snapshot.settings[selector.config["key"]])
if selector.kind == _SERVICE_SELECTOR:
config = selector.config
return any(
getattr(item, config["match_field"]) == config["match_value"]
and bool(getattr(item, config["enabled_field"]))
for item in snapshot.services[config["key"]]
)
raise ValueError(f"未支持的 Host Module selector{selector.kind}")
class HostModuleAdapter:
"""把现有模块类接入 Capability Runtime,保持类路径与对象 identity 不变。"""
execution_mode = AdapterExecutionMode.SYNC
@staticmethod
def materialize(spec: CapabilitySpec) -> type:
"""按 manifest entrypoint 导入并返回原始模块类。"""
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
implementation = getattr(importlib.import_module(module_name), symbol_name)
if not isinstance(implementation, type):
raise TypeError(f"{spec.entrypoint} 不是模块类")
return implementation
@staticmethod
def create(
spec: CapabilitySpec,
implementation: type,
generation: int,
previous: Any = None,
) -> Any:
"""首次创建实例;配置重载继续使用原实例以保留既有模块语义。"""
del spec, generation
return previous if previous is not None else implementation()
@staticmethod
def start(spec: CapabilitySpec, candidate: Any, generation: int) -> None:
"""初始化候选实例拥有的连接、线程或客户端资源。"""
del spec, generation
candidate.init_module()
@staticmethod
def stop(spec: CapabilitySpec, instance: Any, generation: int) -> None:
"""停止实例拥有的资源;Runtime 会先撤销其运行态可见性。"""
del spec, generation
instance.stop()
@staticmethod
def cleanup(
spec: CapabilitySpec,
candidate: Any,
generation: int,
error: BaseException,
) -> None:
"""启动失败后尽力回收候选实例已创建的部分资源。"""
del spec, generation, error
candidate.stop()