Skip to content

Commit 161fc42

Browse files
authored
Merge pull request #1668 from Mai-with-u/dev
Dev
2 parents b41f5d4 + 09eeefc commit 161fc42

31 files changed

Lines changed: 822 additions & 1262 deletions

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ RUN uv sync --frozen --no-dev --no-install-project
1919
# Copy project source
2020
COPY . .
2121

22-
RUN git clone --depth 1 --branch plugin https://github.com/Mai-with-u/MaiBot-Napcat-Adapter.git plugin-templates/MaiBot-Napcat-Adapter
22+
RUN git clone --depth 1 --branch main https://github.com/Mai-with-u/MaiBot-Napcat-Adapter.git plugin-templates/MaiBot-Napcat-Adapter
2323
RUN chmod +x docker-entrypoint.sh
2424

2525
EXPOSE 8000

dashboard/src/lib/plugin-api/marketplace.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const PLUGIN_DETAILS_FILE = 'plugin_details.json'
1818
* 插件列表 API 响应类型(只包含我们需要的字段)
1919
*/
2020
interface PluginApiResponse {
21-
id: string
21+
id?: string
2222
manifest: {
2323
manifest_version: number
2424
id?: string
@@ -110,7 +110,7 @@ export async function fetchPluginList(): Promise<ApiResponse<PluginInfo[]>> {
110110
console.warn('跳过无效插件数据:', item)
111111
return false
112112
}
113-
const pluginId = item.manifest.id || item.id
113+
const pluginId = item.manifest.id
114114
if (!pluginId) {
115115
console.warn('跳过缺少 ID 的插件:', item)
116116
return false
@@ -122,7 +122,7 @@ export async function fetchPluginList(): Promise<ApiResponse<PluginInfo[]>> {
122122
return true
123123
})
124124
.map((item) => ({
125-
id: item.manifest.id || item.id,
125+
id: item.manifest.id!,
126126
manifest: normalizePluginManifest(item.manifest),
127127
downloads: 0,
128128
rating: 0,

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ services:
2525
- ./data/MaiMBot/emoji:/data/emoji # 持久化表情包
2626
- ./data/MaiMBot/plugins:/MaiMBot/plugins # 插件目录
2727
- ./data/MaiMBot/logs:/MaiMBot/logs # 日志目录
28-
- ./depends-data:/MaiMBot/depends-data:ro # 运行时资源文件
28+
- ./depends-data:/MaiMBot/depends-data # 运行时资源文件
2929
# - site-packages:/usr/local/lib/python3.13/site-packages # 持久化Python包,需要时启用
3030
restart: always
3131
networks:
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
请用中文描述这张图片的内容。如果有文字,请把文字描述概括出来,请留意其主题、直观感受,输出为一段平文本,最多30字,请注意不要分点,就输出一段文本
1+
请用中文详细描述这张图片的内容。如果有文字,请把文字描述概括出来,请留意其主题、直观感受,输出为一段平文本,最多100字,请注意不要分点,就输出一段文本

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "MaiBot"
7-
version = "1.0.0"
7+
version = "1.0.0-pre.16"
88
description = "MaiCore 是一个基于大语言模型的可交互智能体"
9-
requires-python = ">=3.10"
9+
requires-python = ">=3.12"
1010
dependencies = [
1111
"Babel>=2.17.0",
1212
"aiohttp>=3.12.14",

pytests/test_maisaka_builtin_context.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from src.common.data_models.message_component_data_model import AtComponent, MessageSequence, ReplyComponent, TextComponent
77
from src.config.config import global_config
88
from src.maisaka.builtin_tool.context import BuiltinToolRuntimeContext
9+
from src.maisaka.runtime import MaisakaHeartFlowChatting
910

1011

1112
def _build_sent_message() -> SessionMessage:
@@ -63,7 +64,9 @@ def test_post_process_reply_message_sequences_converts_at_marker_before_bracket_
6364
)
6465
)
6566
)
66-
runtime = SimpleNamespace(_source_messages_by_id={"12160142": target_message})
67+
runtime = SimpleNamespace(
68+
find_source_message_by_id=lambda message_id: target_message if message_id == "12160142" else None
69+
)
6770
engine = SimpleNamespace(_get_runtime_manager=lambda: None)
6871
tool_ctx = BuiltinToolRuntimeContext(engine=engine, runtime=runtime)
6972

@@ -85,7 +88,7 @@ def test_post_process_reply_message_sequences_ignores_at_marker_when_disabled(mo
8588
"src.maisaka.builtin_tool.context.process_llm_response",
8689
lambda text: [text.strip()] if text.strip() else [],
8790
)
88-
runtime = SimpleNamespace(_source_messages_by_id={})
91+
runtime = SimpleNamespace(find_source_message_by_id=lambda message_id: None)
8992
engine = SimpleNamespace(_get_runtime_manager=lambda: None)
9093
tool_ctx = BuiltinToolRuntimeContext(engine=engine, runtime=runtime)
9194

@@ -96,3 +99,15 @@ def test_post_process_reply_message_sequences_ignores_at_marker_when_disabled(mo
9699
assert len(components) == 1
97100
assert isinstance(components[0], TextComponent)
98101
assert components[0].text == "at[12160142] 就这个群"
102+
103+
104+
def test_runtime_finds_source_message_from_history() -> None:
105+
target_message = _build_sent_message()
106+
runtime = object.__new__(MaisakaHeartFlowChatting)
107+
runtime._chat_history = [
108+
SimpleNamespace(message_id="other-message-id", original_message=SimpleNamespace()),
109+
SimpleNamespace(message_id="real-message-id", original_message=target_message),
110+
]
111+
112+
assert runtime.find_source_message_by_id("real-message-id") is target_message
113+
assert runtime.find_source_message_by_id("missing-message-id") is None
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from types import SimpleNamespace
2+
3+
import pytest
4+
import time
5+
6+
from src.chat.heart_flow import heartflow_manager as heartflow_manager_module
7+
from src.chat.heart_flow.heartflow_manager import HEARTFLOW_ACTIVE_RETENTION_SECONDS, HeartflowManager
8+
from src.learners.expression_learner import ExpressionLearner
9+
from src.maisaka.runtime import MAX_RETAINED_MESSAGE_CACHE_SIZE, MaisakaHeartFlowChatting
10+
11+
12+
def _build_runtime_with_messages(message_count: int) -> MaisakaHeartFlowChatting:
13+
runtime = object.__new__(MaisakaHeartFlowChatting)
14+
runtime.log_prefix = "[test]"
15+
runtime.message_cache = [SimpleNamespace(message_id=f"msg-{index}") for index in range(message_count)]
16+
runtime._last_processed_index = message_count
17+
runtime._expression_learner = ExpressionLearner("session-1")
18+
runtime._expression_learner.mark_all_processed(runtime.message_cache)
19+
return runtime
20+
21+
22+
def test_prune_processed_message_cache_keeps_bounded_recent_window() -> None:
23+
runtime = _build_runtime_with_messages(MAX_RETAINED_MESSAGE_CACHE_SIZE + 25)
24+
25+
runtime._prune_processed_message_cache()
26+
27+
assert len(runtime.message_cache) == MAX_RETAINED_MESSAGE_CACHE_SIZE
28+
assert runtime.message_cache[0].message_id == "msg-25"
29+
assert runtime._last_processed_index == MAX_RETAINED_MESSAGE_CACHE_SIZE
30+
assert runtime._expression_learner.last_processed_index == MAX_RETAINED_MESSAGE_CACHE_SIZE
31+
32+
33+
def test_prune_processed_message_cache_keeps_unlearned_messages() -> None:
34+
runtime = _build_runtime_with_messages(MAX_RETAINED_MESSAGE_CACHE_SIZE + 25)
35+
runtime._expression_learner.discard_processed_prefix(MAX_RETAINED_MESSAGE_CACHE_SIZE + 5)
36+
37+
runtime._prune_processed_message_cache()
38+
39+
assert len(runtime.message_cache) == MAX_RETAINED_MESSAGE_CACHE_SIZE + 5
40+
assert runtime.message_cache[0].message_id == "msg-20"
41+
assert runtime._expression_learner.last_processed_index == 0
42+
43+
44+
def test_collect_pending_messages_uses_single_pending_received_time() -> None:
45+
runtime = _build_runtime_with_messages(2)
46+
runtime._last_processed_index = 0
47+
runtime._oldest_pending_message_received_at = 123.0
48+
runtime._last_message_received_at = 456.0
49+
runtime._reply_latency_measurement_started_at = None
50+
51+
pending_messages = runtime._collect_pending_messages()
52+
53+
assert [message.message_id for message in pending_messages] == ["msg-0", "msg-1"]
54+
assert runtime._reply_latency_measurement_started_at == 123.0
55+
assert runtime._oldest_pending_message_received_at is None
56+
57+
58+
@pytest.mark.asyncio
59+
async def test_heartflow_manager_evicts_lru_chat_over_limit(monkeypatch: pytest.MonkeyPatch) -> None:
60+
manager = HeartflowManager()
61+
stopped_session_ids: list[str] = []
62+
old_active_at = time.time() - HEARTFLOW_ACTIVE_RETENTION_SECONDS - 1
63+
64+
class FakeChat:
65+
def __init__(self, session_id: str) -> None:
66+
self.session_id = session_id
67+
68+
async def stop(self) -> None:
69+
stopped_session_ids.append(self.session_id)
70+
71+
monkeypatch.setattr(heartflow_manager_module, "HEARTFLOW_MAX_ACTIVE_CHATS", 2)
72+
manager.heartflow_chat_list["session-1"] = FakeChat("session-1")
73+
manager.heartflow_chat_list["session-2"] = FakeChat("session-2")
74+
manager.heartflow_chat_list["session-3"] = FakeChat("session-3")
75+
manager._chat_last_active_at["session-1"] = old_active_at
76+
manager._chat_last_active_at["session-2"] = old_active_at
77+
manager._chat_last_active_at["session-3"] = time.time()
78+
79+
await manager._evict_over_limit_chats(protected_session_id="session-3")
80+
81+
assert stopped_session_ids == ["session-1"]
82+
assert list(manager.heartflow_chat_list) == ["session-2", "session-3"]
83+
84+
85+
@pytest.mark.asyncio
86+
async def test_heartflow_manager_keeps_recent_chats_even_over_limit(monkeypatch: pytest.MonkeyPatch) -> None:
87+
manager = HeartflowManager()
88+
stopped_session_ids: list[str] = []
89+
90+
class FakeChat:
91+
def __init__(self, session_id: str) -> None:
92+
self.session_id = session_id
93+
94+
async def stop(self) -> None:
95+
stopped_session_ids.append(self.session_id)
96+
97+
monkeypatch.setattr(heartflow_manager_module, "HEARTFLOW_MAX_ACTIVE_CHATS", 2)
98+
for session_id in ("session-1", "session-2", "session-3"):
99+
manager.heartflow_chat_list[session_id] = FakeChat(session_id)
100+
manager._chat_last_active_at[session_id] = time.time()
101+
102+
await manager._evict_over_limit_chats(protected_session_id="session-3")
103+
104+
assert stopped_session_ids == []
105+
assert list(manager.heartflow_chat_list) == ["session-1", "session-2", "session-3"]

pytests/test_maisaka_monitor_protocol.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ async def generate_reply_with_context(self, **kwargs: Any) -> tuple[bool, ReplyG
199199
),
200200
)
201201
runtime = SimpleNamespace(
202-
_source_messages_by_id={"msg-1": target_message},
202+
find_source_message_by_id=lambda message_id: target_message if message_id == "msg-1" else None,
203203
log_prefix="[test]",
204204
chat_stream=SimpleNamespace(platform=reply_tool_module.CLI_PLATFORM_NAME),
205205
session_id="session-1",

pytests/test_plugin_runtime.py

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,6 +1169,8 @@ def test_normalize(self):
11691169

11701170
assert VersionComparator.normalize_version("0.8.0-snapshot.1") == "0.8.0"
11711171
assert VersionComparator.normalize_version("1.2") == "1.2.0"
1172+
assert VersionComparator.normalize_version("1.0.0rc16") == "1.0.0"
1173+
assert VersionComparator.normalize_version("1.0.0-pre.16") == "1.0.0"
11721174
assert VersionComparator.normalize_version("") == "0.0.0"
11731175

11741176
def test_compare(self):
@@ -2890,22 +2892,105 @@ async def fake_db_get(model_class, filters=None, limit=None, order_by=None, sing
28902892
monkeypatch.setattr(real_database_service, "db_get", fake_db_get)
28912893
monkeypatch.setattr(real_db_models, "DemoTable", DummyModel, raising=False)
28922894

2893-
result = await integration_module.PluginRuntimeManager._cap_database_get(
2895+
manager = object.__new__(integration_module.PluginRuntimeManager)
2896+
result = await manager._cap_database_get(
28942897
"plugin_a",
28952898
"database.get",
28962899
{
2897-
"table": "DemoTable",
2900+
"model_name": "DemoTable",
28982901
"filters": {"status": "active"},
28992902
"limit": 5,
29002903
},
29012904
)
29022905

2903-
assert result == {"success": True, "result": [{"id": 1}]}
2906+
assert result == [{"id": 1}]
29042907
assert captured["model_class"] is DummyModel
29052908
assert captured["filters"] == {"status": "active"}
29062909
assert captured["limit"] == 5
29072910
assert captured["single_result"] is False
29082911

2912+
@pytest.mark.asyncio
2913+
async def test_cap_database_get_response_is_not_double_wrapped(self, monkeypatch):
2914+
from src.plugin_runtime import integration as integration_module
2915+
import src.common.database.database_model as real_db_models
2916+
from src.plugin_runtime.host.capability_service import CapabilityService
2917+
from src.plugin_runtime.protocol.envelope import CapabilityRequestPayload, Envelope, MessageType
2918+
from src.services import database_service as real_database_service
2919+
2920+
class AllowAllAuthorization:
2921+
def check_capability(self, plugin_id, capability):
2922+
return True, ""
2923+
2924+
class DummyModel:
2925+
pass
2926+
2927+
async def fake_db_get(model_class, filters=None, limit=None, order_by=None, single_result=False):
2928+
return {"id": 1, "full_path": "E:\\test.png"}
2929+
2930+
monkeypatch.setattr(real_database_service, "db_get", fake_db_get)
2931+
monkeypatch.setattr(real_db_models, "DemoTable", DummyModel, raising=False)
2932+
2933+
manager = object.__new__(integration_module.PluginRuntimeManager)
2934+
service = CapabilityService(AllowAllAuthorization())
2935+
service.register_capability("database.get", manager._cap_database_get)
2936+
2937+
request = Envelope(
2938+
request_id=1,
2939+
message_type=MessageType.REQUEST,
2940+
method="cap.call",
2941+
plugin_id="plugin_a",
2942+
payload=CapabilityRequestPayload(
2943+
capability="database.get",
2944+
args={"model_name": "DemoTable", "single_result": True},
2945+
).model_dump(),
2946+
)
2947+
2948+
response = await service.handle_capability_request(request)
2949+
2950+
assert response.payload == {
2951+
"success": True,
2952+
"result": {"id": 1, "full_path": "E:\\test.png"},
2953+
}
2954+
2955+
@pytest.mark.asyncio
2956+
async def test_cap_database_success_handlers_return_raw_results(self, monkeypatch):
2957+
from src.plugin_runtime import integration as integration_module
2958+
import src.common.database.database_model as real_db_models
2959+
from src.services import database_service as real_database_service
2960+
2961+
class DummyModel:
2962+
pass
2963+
2964+
async def fake_db_get(**kwargs):
2965+
return [{"id": 1}]
2966+
2967+
async def fake_db_save(**kwargs):
2968+
return {"id": 2}
2969+
2970+
async def fake_db_delete(**kwargs):
2971+
return 3
2972+
2973+
async def fake_db_count(**kwargs):
2974+
return 4
2975+
2976+
monkeypatch.setattr(real_database_service, "db_get", fake_db_get)
2977+
monkeypatch.setattr(real_database_service, "db_save", fake_db_save)
2978+
monkeypatch.setattr(real_database_service, "db_delete", fake_db_delete)
2979+
monkeypatch.setattr(real_database_service, "db_count", fake_db_count)
2980+
monkeypatch.setattr(real_db_models, "DemoTable", DummyModel, raising=False)
2981+
2982+
manager = object.__new__(integration_module.PluginRuntimeManager)
2983+
base_args = {"model_name": "DemoTable"}
2984+
2985+
assert await manager._cap_database_query("plugin_a", "database.query", base_args) == [{"id": 1}]
2986+
assert await manager._cap_database_save(
2987+
"plugin_a", "database.save", {**base_args, "data": {"name": "demo"}}
2988+
) == {"id": 2}
2989+
assert await manager._cap_database_delete(
2990+
"plugin_a", "database.delete", {**base_args, "filters": {"id": 2}}
2991+
) == 3
2992+
assert await manager._cap_database_count("plugin_a", "database.count", base_args) == 4
2993+
29092994
@pytest.mark.asyncio
29102995
async def test_component_enable_rejects_ambiguous_short_name(self, monkeypatch):
29112996
from src.plugin_runtime import integration as integration_module

0 commit comments

Comments
 (0)