nonebot-plugin-group-chat-memory 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nonebot_plugin_group_chat_memory/__init__.py +26 -0
- nonebot_plugin_group_chat_memory/api.py +185 -0
- nonebot_plugin_group_chat_memory/config.py +117 -0
- nonebot_plugin_group_chat_memory/context.py +148 -0
- nonebot_plugin_group_chat_memory/handlers.py +298 -0
- nonebot_plugin_group_chat_memory/memory.py +283 -0
- nonebot_plugin_group_chat_memory/models.py +42 -0
- nonebot_plugin_group_chat_memory/replies.py +193 -0
- nonebot_plugin_group_chat_memory/store.py +425 -0
- nonebot_plugin_group_chat_memory-0.1.0.dist-info/METADATA +173 -0
- nonebot_plugin_group_chat_memory-0.1.0.dist-info/RECORD +13 -0
- nonebot_plugin_group_chat_memory-0.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_group_chat_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
from weakref import WeakKeyDictionary
|
|
10
|
+
|
|
11
|
+
from nonebot import get_driver, on_command, on_message
|
|
12
|
+
from nonebot import logger as nonebot_logger
|
|
13
|
+
from nonebot.adapters.onebot.v11 import (
|
|
14
|
+
Bot,
|
|
15
|
+
GroupMessageEvent,
|
|
16
|
+
Message,
|
|
17
|
+
MessageEvent,
|
|
18
|
+
MessageSegment,
|
|
19
|
+
PrivateMessageEvent,
|
|
20
|
+
)
|
|
21
|
+
from nonebot.params import CommandArg
|
|
22
|
+
from nonebot.permission import SUPERUSER
|
|
23
|
+
|
|
24
|
+
from .api import ChatClient, EmbeddingClient
|
|
25
|
+
from .config import get_settings
|
|
26
|
+
from .context import ContextBuilder
|
|
27
|
+
from .memory import MemoryWriter
|
|
28
|
+
from .models import Message as MemoryMessage
|
|
29
|
+
from .replies import ReplyCoordinator
|
|
30
|
+
from .store import MemoryStore
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
GENERIC_REPLY_ERROR = "暂时无法生成回复,请稍后再试。"
|
|
35
|
+
PERMISSION_DENIED = "仅 SUPERUSER 可执行此命令。"
|
|
36
|
+
INVALID_PROBABILITY = "概率需为 0~100 的数字,最多一位小数。"
|
|
37
|
+
PROBABILITY_PATTERN = re.compile(r"(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)")
|
|
38
|
+
SECRET_COMMAND_LOG_PATTERN = re.compile(r"(设置聊天密钥|设置向量密钥)\s+[^\r\n]*")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _redact_secret_command_logs(record) -> None:
|
|
42
|
+
record["message"] = SECRET_COMMAND_LOG_PATTERN.sub(r"\1 ****", record["message"])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
nonebot_logger.configure(patcher=_redact_secret_command_logs)
|
|
46
|
+
|
|
47
|
+
config = get_settings()
|
|
48
|
+
store = MemoryStore(config)
|
|
49
|
+
embedding_client = EmbeddingClient(config)
|
|
50
|
+
writer = MemoryWriter(config, store, embedding_client)
|
|
51
|
+
context_builder = ContextBuilder(config, store, embedding_client)
|
|
52
|
+
chat_client = ChatClient(config)
|
|
53
|
+
_bots: dict[int, Bot] = {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def startup() -> None:
|
|
57
|
+
await store.open()
|
|
58
|
+
config.apply_runtime_config(await store.get_runtime_config())
|
|
59
|
+
writer.start()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def shutdown() -> None:
|
|
63
|
+
try:
|
|
64
|
+
try:
|
|
65
|
+
try:
|
|
66
|
+
await writer.flush(timeout=5)
|
|
67
|
+
except asyncio.TimeoutError:
|
|
68
|
+
logger.warning("关闭插件时等待记忆写入超时,将保留未完成数据供下次启动恢复。")
|
|
69
|
+
finally:
|
|
70
|
+
await writer.close()
|
|
71
|
+
finally:
|
|
72
|
+
try:
|
|
73
|
+
await coordinator.close()
|
|
74
|
+
finally:
|
|
75
|
+
await store.close()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def reply(group_id: int, message: MemoryMessage) -> None:
|
|
79
|
+
bot = _bots[group_id]
|
|
80
|
+
try:
|
|
81
|
+
context = await context_builder.build(group_id, message.text)
|
|
82
|
+
response = await chat_client.complete(context, message.text)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
logger.error("群 %s 的聊天回复失败:%s", group_id, type(exc).__name__)
|
|
85
|
+
await bot.send_group_msg(group_id=group_id, message=GENERIC_REPLY_ERROR)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
await bot.send_group_msg(group_id=group_id, message=MessageSegment.text(response))
|
|
89
|
+
await writer.record(
|
|
90
|
+
MemoryMessage(
|
|
91
|
+
group_id=group_id,
|
|
92
|
+
user_id=int(bot.self_id),
|
|
93
|
+
display_name="Bot",
|
|
94
|
+
text=response,
|
|
95
|
+
is_bot=True,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
coordinator = ReplyCoordinator(store, reply)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def _eligible_group_message(bot: Bot, event: GroupMessageEvent) -> bool:
|
|
104
|
+
if not isinstance(event, GroupMessageEvent):
|
|
105
|
+
return False
|
|
106
|
+
if event.anonymous is not None:
|
|
107
|
+
return False
|
|
108
|
+
if event.user_id in {event.self_id, int(bot.self_id)}:
|
|
109
|
+
return False
|
|
110
|
+
return event.user_id not in config.group_chat_memory_ignored_qq
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
settings_locks: WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = WeakKeyDictionary()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _settings_lock() -> asyncio.Lock:
|
|
117
|
+
loop = asyncio.get_running_loop()
|
|
118
|
+
if (lock := settings_locks.get(loop)) is None:
|
|
119
|
+
lock = asyncio.Lock()
|
|
120
|
+
settings_locks[loop] = lock
|
|
121
|
+
return lock
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def apply_setting(key: str, value: str) -> None:
|
|
125
|
+
async with _settings_lock():
|
|
126
|
+
values = {
|
|
127
|
+
"chat_base_url": config.group_chat_memory_chat_base_url,
|
|
128
|
+
"chat_api_key": config.group_chat_memory_chat_api_key.get_secret_value(),
|
|
129
|
+
"chat_model": config.group_chat_memory_chat_model,
|
|
130
|
+
"embedding_api_key": config.group_chat_memory_embedding_api_key.get_secret_value(),
|
|
131
|
+
}
|
|
132
|
+
values[key] = value
|
|
133
|
+
await store.set_runtime_config(key, value)
|
|
134
|
+
config.apply_runtime_config(values)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
chat_url_matcher = on_command("设置聊天地址", priority=5, block=True)
|
|
138
|
+
chat_key_matcher = on_command("设置聊天密钥", priority=5, block=True)
|
|
139
|
+
chat_model_matcher = on_command("设置聊天模型", priority=5, block=True)
|
|
140
|
+
embedding_key_matcher = on_command("设置向量密钥", priority=5, block=True)
|
|
141
|
+
model_config_matcher = on_command("模型配置", priority=5, block=True)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _valid_http_url(value: str) -> bool:
|
|
145
|
+
if any(
|
|
146
|
+
character.isspace() or ord(character) < 32 or ord(character) == 127
|
|
147
|
+
for character in value
|
|
148
|
+
):
|
|
149
|
+
return False
|
|
150
|
+
try:
|
|
151
|
+
parsed = urlparse(value)
|
|
152
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
153
|
+
return False
|
|
154
|
+
_ = parsed.port
|
|
155
|
+
except ValueError:
|
|
156
|
+
return False
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def _handle_setting(matcher, bot: Bot, event: MessageEvent, args: Message, key: str) -> None:
|
|
161
|
+
if not isinstance(event, PrivateMessageEvent):
|
|
162
|
+
await matcher.finish("请在私聊中设置模型配置。")
|
|
163
|
+
if not await SUPERUSER(bot, event):
|
|
164
|
+
await matcher.finish(PERMISSION_DENIED)
|
|
165
|
+
|
|
166
|
+
value = args.extract_plain_text().strip()
|
|
167
|
+
if not value:
|
|
168
|
+
await matcher.finish("配置参数不能为空。")
|
|
169
|
+
if key == "chat_base_url" and not _valid_http_url(value):
|
|
170
|
+
await matcher.finish("聊天 API 地址需为有效的 http(s) URL。")
|
|
171
|
+
try:
|
|
172
|
+
await apply_setting(key, value)
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
logger.error("保存模型配置失败:%s", type(exc).__name__)
|
|
175
|
+
await matcher.finish("配置保存失败,请稍后再试。")
|
|
176
|
+
await matcher.finish("模型配置已更新。")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@chat_url_matcher.handle()
|
|
180
|
+
async def handle_chat_url(
|
|
181
|
+
bot: Bot, event: MessageEvent, args: Annotated[Message, CommandArg()]
|
|
182
|
+
) -> None:
|
|
183
|
+
await _handle_setting(chat_url_matcher, bot, event, args, "chat_base_url")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@chat_key_matcher.handle()
|
|
187
|
+
async def handle_chat_key(
|
|
188
|
+
bot: Bot, event: MessageEvent, args: Annotated[Message, CommandArg()]
|
|
189
|
+
) -> None:
|
|
190
|
+
await _handle_setting(chat_key_matcher, bot, event, args, "chat_api_key")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@chat_model_matcher.handle()
|
|
194
|
+
async def handle_chat_model(
|
|
195
|
+
bot: Bot, event: MessageEvent, args: Annotated[Message, CommandArg()]
|
|
196
|
+
) -> None:
|
|
197
|
+
await _handle_setting(chat_model_matcher, bot, event, args, "chat_model")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@embedding_key_matcher.handle()
|
|
201
|
+
async def handle_embedding_key(
|
|
202
|
+
bot: Bot, event: MessageEvent, args: Annotated[Message, CommandArg()]
|
|
203
|
+
) -> None:
|
|
204
|
+
await _handle_setting(embedding_key_matcher, bot, event, args, "embedding_api_key")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@model_config_matcher.handle()
|
|
208
|
+
async def handle_model_config(bot: Bot, event: MessageEvent) -> None:
|
|
209
|
+
if not await SUPERUSER(bot, event):
|
|
210
|
+
await model_config_matcher.finish(PERMISSION_DENIED)
|
|
211
|
+
|
|
212
|
+
def status(secret: str) -> str:
|
|
213
|
+
return "已配置(****)" if secret else "未配置"
|
|
214
|
+
|
|
215
|
+
await model_config_matcher.finish(
|
|
216
|
+
f"聊天地址:{config.group_chat_memory_chat_base_url or '未配置'}\n"
|
|
217
|
+
f"聊天模型:{config.group_chat_memory_chat_model}\n"
|
|
218
|
+
f"聊天密钥:{status(config.group_chat_memory_chat_api_key.get_secret_value())}\n"
|
|
219
|
+
"Embedding 密钥:"
|
|
220
|
+
f"{status(config.group_chat_memory_embedding_api_key.get_secret_value())}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
probability_matcher = on_command(
|
|
225
|
+
"回复概率", rule=_eligible_group_message, priority=5, block=True
|
|
226
|
+
)
|
|
227
|
+
clear_matcher = on_command("清空群记忆", rule=_eligible_group_message, priority=5, block=True)
|
|
228
|
+
group_message_matcher = on_message(rule=_eligible_group_message, priority=20, block=False)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@probability_matcher.handle()
|
|
232
|
+
async def handle_probability(
|
|
233
|
+
bot: Bot,
|
|
234
|
+
event: GroupMessageEvent,
|
|
235
|
+
args: Annotated[Message, CommandArg()],
|
|
236
|
+
) -> None:
|
|
237
|
+
if not await SUPERUSER(bot, event):
|
|
238
|
+
await probability_matcher.finish(PERMISSION_DENIED)
|
|
239
|
+
|
|
240
|
+
text = args.extract_plain_text().strip()
|
|
241
|
+
if not text:
|
|
242
|
+
probability = await store.get_probability(event.group_id)
|
|
243
|
+
await probability_matcher.finish(f"当前群随机回复概率:{probability:.1f}%")
|
|
244
|
+
if PROBABILITY_PATTERN.fullmatch(text) is None:
|
|
245
|
+
await probability_matcher.finish(INVALID_PROBABILITY)
|
|
246
|
+
|
|
247
|
+
probability = float(text)
|
|
248
|
+
await store.set_probability(event.group_id, probability)
|
|
249
|
+
await probability_matcher.finish(f"已设置当前群随机回复概率:{probability:.1f}%")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@clear_matcher.handle()
|
|
253
|
+
async def handle_clear(bot: Bot, event: GroupMessageEvent) -> None:
|
|
254
|
+
if not await SUPERUSER(bot, event):
|
|
255
|
+
await clear_matcher.finish(PERMISSION_DENIED)
|
|
256
|
+
await store.clear_group(event.group_id)
|
|
257
|
+
await clear_matcher.finish("已清空当前群记忆。")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@group_message_matcher.handle()
|
|
261
|
+
async def handle_group_message(bot: Bot, event: GroupMessageEvent) -> None:
|
|
262
|
+
explicit_at = any(
|
|
263
|
+
segment.type == "at" and segment.data.get("qq") == str(bot.self_id)
|
|
264
|
+
for segment in event.message
|
|
265
|
+
)
|
|
266
|
+
directed = explicit_at or (
|
|
267
|
+
event.reply is not None and event.reply.sender.user_id == int(bot.self_id)
|
|
268
|
+
)
|
|
269
|
+
allowed_types = {"text", "at", "reply"}
|
|
270
|
+
if any(segment.type not in allowed_types for segment in event.message):
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
text = event.message.extract_plain_text().strip()
|
|
274
|
+
if not text:
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
memory_message = MemoryMessage(
|
|
278
|
+
group_id=event.group_id,
|
|
279
|
+
user_id=event.user_id,
|
|
280
|
+
display_name=event.sender.card or event.sender.nickname or str(event.user_id),
|
|
281
|
+
text=text,
|
|
282
|
+
created_at=datetime.fromtimestamp(event.time, timezone.utc),
|
|
283
|
+
)
|
|
284
|
+
_bots[event.group_id] = bot
|
|
285
|
+
await writer.record(memory_message)
|
|
286
|
+
if directed:
|
|
287
|
+
coordinator.submit(event.group_id, memory_message, explicit=True)
|
|
288
|
+
elif (
|
|
289
|
+
event.reply is None
|
|
290
|
+
and all(segment.type == "text" for segment in event.message)
|
|
291
|
+
and await coordinator.should_random_reply(event.group_id)
|
|
292
|
+
):
|
|
293
|
+
coordinator.submit(event.group_id, memory_message, explicit=False)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
driver = get_driver()
|
|
297
|
+
driver.on_startup(startup)
|
|
298
|
+
driver.on_shutdown(shutdown)
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from typing import NamedTuple
|
|
8
|
+
|
|
9
|
+
from .api import EmbeddingClient
|
|
10
|
+
from .config import Config
|
|
11
|
+
from .models import Chunk, Message
|
|
12
|
+
from .store import MemoryStore
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
DEFAULT_RETRY_DELAYS: tuple[float, ...] = (1.0, 2.0, 4.0)
|
|
17
|
+
DEFAULT_QUEUE_SIZE = 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Wake(NamedTuple):
|
|
21
|
+
"""提示后台任务为某个群尝试分块;同群唤醒会合并。"""
|
|
22
|
+
|
|
23
|
+
group_id: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Stop:
|
|
27
|
+
"""关闭后台任务的哨兵。"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_STOP = _Stop()
|
|
31
|
+
|
|
32
|
+
_Item = _Wake | Chunk | _Stop
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MemoryWriter:
|
|
36
|
+
"""在后台把已持久化的群消息分块并写入长期记忆向量。
|
|
37
|
+
|
|
38
|
+
`record` 只负责持久化原文并唤醒后台任务;分块、Embedding 与写入向量都在
|
|
39
|
+
单一后台任务中完成,任何失败都不会抛回消息处理流程。后台任务被意外取消或
|
|
40
|
+
抛出 `BaseException` 时由监督回调记录并允许重新 `start()`,`flush()` 不会
|
|
41
|
+
无限等待。关闭前应先 `flush()`,`close()` 会取消退避休眠并等待在途请求
|
|
42
|
+
收尾。
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
config: Config,
|
|
48
|
+
store: MemoryStore,
|
|
49
|
+
embedding: EmbeddingClient,
|
|
50
|
+
*,
|
|
51
|
+
retry_delays: tuple[float, ...] = DEFAULT_RETRY_DELAYS,
|
|
52
|
+
queue_size: int = DEFAULT_QUEUE_SIZE,
|
|
53
|
+
sleep: Callable[[float], Awaitable[None]] | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._store = store
|
|
56
|
+
self._embedding = embedding
|
|
57
|
+
self._chunk_size = config.group_chat_memory_chunk_size
|
|
58
|
+
self._retry_delays = tuple(retry_delays)
|
|
59
|
+
self._sleep_impl = sleep
|
|
60
|
+
self._queue: asyncio.Queue[_Item] = asyncio.Queue(maxsize=max(1, queue_size))
|
|
61
|
+
self._stop_event = asyncio.Event()
|
|
62
|
+
self._recovered = asyncio.Event()
|
|
63
|
+
self._queued_wakes: set[int] = set()
|
|
64
|
+
self._dirty_groups: set[int] = set()
|
|
65
|
+
self._overflow_warned = False
|
|
66
|
+
self._closing = False
|
|
67
|
+
self._task: asyncio.Task[None] | None = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def running(self) -> bool:
|
|
71
|
+
"""后台任务是否仍在运行。"""
|
|
72
|
+
return self._is_running()
|
|
73
|
+
|
|
74
|
+
def start(self) -> None:
|
|
75
|
+
"""启动后台任务并恢复待处理记忆;已在运行时不做任何事。"""
|
|
76
|
+
if self._is_running():
|
|
77
|
+
return
|
|
78
|
+
self._task = None
|
|
79
|
+
self._closing = False
|
|
80
|
+
self._stop_event.clear()
|
|
81
|
+
self._recovered.clear()
|
|
82
|
+
self._queued_wakes.clear()
|
|
83
|
+
task = asyncio.create_task(self._run(), name="group-chat-memory-writer")
|
|
84
|
+
self._task = task
|
|
85
|
+
task.add_done_callback(self._on_task_done)
|
|
86
|
+
|
|
87
|
+
async def record(self, message: Message) -> None:
|
|
88
|
+
"""先持久化消息原文,再交给后台任务分块;数据库失败只记录日志。"""
|
|
89
|
+
try:
|
|
90
|
+
await self._store.add_message(message)
|
|
91
|
+
except Exception:
|
|
92
|
+
logger.exception("群 %s 的消息写入数据库失败,已跳过长期记忆。", message.group_id)
|
|
93
|
+
return
|
|
94
|
+
self._dirty_groups.add(message.group_id)
|
|
95
|
+
if self._closing:
|
|
96
|
+
return
|
|
97
|
+
self._wake(message.group_id)
|
|
98
|
+
|
|
99
|
+
async def flush(self, timeout: float | None = None) -> None:
|
|
100
|
+
"""等待已排队的分块与向量写入全部结束。
|
|
101
|
+
|
|
102
|
+
传入 `timeout`(秒)时最多等待该时长,超时抛出 `asyncio.TimeoutError`;
|
|
103
|
+
该异常只表示本次等待超时,后台任务与后续 `close()` 都不受影响。后台任务
|
|
104
|
+
未运行时立即返回,未完成的群聊消息由下次启动恢复。
|
|
105
|
+
"""
|
|
106
|
+
if timeout is None:
|
|
107
|
+
await self._wait_until_idle()
|
|
108
|
+
return
|
|
109
|
+
await asyncio.wait_for(self._wait_until_idle(), timeout)
|
|
110
|
+
|
|
111
|
+
async def close(self) -> None:
|
|
112
|
+
"""停止后台任务:取消退避休眠,等待在途请求结束,可重复调用。
|
|
113
|
+
|
|
114
|
+
等待时使用 `asyncio.shield`,调用方取消 `close()` 不会级联取消后台任务,
|
|
115
|
+
取消异常照常向上抛出;只有后台任务本身被取消时才按关闭处理并只记录日志。
|
|
116
|
+
"""
|
|
117
|
+
task = self._task
|
|
118
|
+
if task is None or task.done():
|
|
119
|
+
return
|
|
120
|
+
first = not self._closing
|
|
121
|
+
self._closing = True
|
|
122
|
+
self._stop_event.set()
|
|
123
|
+
if first:
|
|
124
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
125
|
+
self._queue.put_nowait(_STOP)
|
|
126
|
+
try:
|
|
127
|
+
await asyncio.shield(task)
|
|
128
|
+
except asyncio.CancelledError:
|
|
129
|
+
if not task.cancelled():
|
|
130
|
+
raise
|
|
131
|
+
if first:
|
|
132
|
+
logger.warning("记忆后台任务在关闭时被取消,未完成的消息由下次启动补做。")
|
|
133
|
+
except Exception:
|
|
134
|
+
if first:
|
|
135
|
+
logger.exception("记忆后台任务异常退出。")
|
|
136
|
+
|
|
137
|
+
def _is_running(self) -> bool:
|
|
138
|
+
return self._task is not None and not self._task.done()
|
|
139
|
+
|
|
140
|
+
def _on_task_done(self, task: asyncio.Task[None]) -> None:
|
|
141
|
+
if self._task is task:
|
|
142
|
+
self._task = None
|
|
143
|
+
self._recovered.set()
|
|
144
|
+
if task.cancelled():
|
|
145
|
+
if not self._closing:
|
|
146
|
+
logger.warning("记忆后台任务被意外取消,未处理的消息将在下次启动时补做。")
|
|
147
|
+
return
|
|
148
|
+
error = task.exception()
|
|
149
|
+
if error is not None and not self._closing:
|
|
150
|
+
logger.error("记忆后台任务意外退出:%r", error, exc_info=error)
|
|
151
|
+
|
|
152
|
+
def _wake(self, group_id: int) -> None:
|
|
153
|
+
if not self._is_running() or group_id in self._queued_wakes:
|
|
154
|
+
return
|
|
155
|
+
try:
|
|
156
|
+
self._queue.put_nowait(_Wake(group_id))
|
|
157
|
+
except asyncio.QueueFull:
|
|
158
|
+
if not self._overflow_warned:
|
|
159
|
+
self._overflow_warned = True
|
|
160
|
+
logger.warning("记忆队列已满,未完成的分块将在下次启动时补做。")
|
|
161
|
+
return
|
|
162
|
+
self._queued_wakes.add(group_id)
|
|
163
|
+
self._overflow_warned = False
|
|
164
|
+
|
|
165
|
+
async def _wait_until_idle(self) -> None:
|
|
166
|
+
if not self._is_running():
|
|
167
|
+
self._warn_stranded()
|
|
168
|
+
return
|
|
169
|
+
await self._recovered.wait()
|
|
170
|
+
if not self._is_running():
|
|
171
|
+
self._warn_stranded()
|
|
172
|
+
return
|
|
173
|
+
await self._queue.join()
|
|
174
|
+
|
|
175
|
+
def _warn_stranded(self) -> None:
|
|
176
|
+
if not self._queue.empty():
|
|
177
|
+
logger.warning(
|
|
178
|
+
"记忆后台任务未运行,%s 项排队工作将在下次启动时补做。", self._queue.qsize()
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def _run(self) -> None:
|
|
182
|
+
try:
|
|
183
|
+
try:
|
|
184
|
+
await self._recover_pending()
|
|
185
|
+
finally:
|
|
186
|
+
self._recovered.set()
|
|
187
|
+
while True:
|
|
188
|
+
item = await self._queue.get()
|
|
189
|
+
try:
|
|
190
|
+
if isinstance(item, _Wake):
|
|
191
|
+
self._queued_wakes.discard(item.group_id)
|
|
192
|
+
if isinstance(item, _Stop) or self._closing:
|
|
193
|
+
return
|
|
194
|
+
if isinstance(item, _Wake):
|
|
195
|
+
await self._chunk_group(item.group_id)
|
|
196
|
+
else:
|
|
197
|
+
await self._embed_chunk(item)
|
|
198
|
+
finally:
|
|
199
|
+
self._queue.task_done()
|
|
200
|
+
finally:
|
|
201
|
+
self._drain_queue()
|
|
202
|
+
|
|
203
|
+
def _drain_queue(self) -> None:
|
|
204
|
+
while True:
|
|
205
|
+
try:
|
|
206
|
+
item = self._queue.get_nowait()
|
|
207
|
+
except asyncio.QueueEmpty:
|
|
208
|
+
return
|
|
209
|
+
if isinstance(item, _Wake):
|
|
210
|
+
self._queued_wakes.discard(item.group_id)
|
|
211
|
+
self._dirty_groups.add(item.group_id)
|
|
212
|
+
self._queue.task_done()
|
|
213
|
+
|
|
214
|
+
async def _recover_pending(self) -> None:
|
|
215
|
+
try:
|
|
216
|
+
chunks = await self._store.pending_chunks()
|
|
217
|
+
except Exception:
|
|
218
|
+
logger.exception("读取待处理记忆片段失败,已跳过片段恢复。")
|
|
219
|
+
chunks = []
|
|
220
|
+
try:
|
|
221
|
+
groups = await self._store.pending_groups(self._chunk_size)
|
|
222
|
+
except Exception:
|
|
223
|
+
logger.exception("读取待处理群聊消息失败,已跳过补分块恢复。")
|
|
224
|
+
groups = []
|
|
225
|
+
for chunk in chunks:
|
|
226
|
+
if self._closing:
|
|
227
|
+
return
|
|
228
|
+
await self._embed_chunk(chunk)
|
|
229
|
+
for group_id in dict.fromkeys([*groups, *sorted(self._dirty_groups)]):
|
|
230
|
+
if self._closing:
|
|
231
|
+
return
|
|
232
|
+
self._dirty_groups.add(group_id)
|
|
233
|
+
await self._chunk_group(group_id)
|
|
234
|
+
|
|
235
|
+
async def _chunk_group(self, group_id: int) -> None:
|
|
236
|
+
while not self._closing:
|
|
237
|
+
try:
|
|
238
|
+
chunk = await self._store.create_ready_chunk(group_id, self._chunk_size)
|
|
239
|
+
except Exception:
|
|
240
|
+
logger.exception("群 %s 的消息分块失败,原文已保留。", group_id)
|
|
241
|
+
return
|
|
242
|
+
if chunk is None:
|
|
243
|
+
self._dirty_groups.discard(group_id)
|
|
244
|
+
return
|
|
245
|
+
await self._embed_chunk(chunk)
|
|
246
|
+
|
|
247
|
+
async def _embed_chunk(self, chunk: Chunk) -> None:
|
|
248
|
+
attempts = len(self._retry_delays) + 1
|
|
249
|
+
for attempt in range(attempts):
|
|
250
|
+
try:
|
|
251
|
+
vectors = await self._embedding.embed([chunk.text])
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
if attempt + 1 >= attempts:
|
|
254
|
+
logger.error("片段 %s 向量化失败,已保留待处理状态:%s", chunk.id, exc)
|
|
255
|
+
return
|
|
256
|
+
logger.warning("片段 %s 向量化失败,将重试:%s", chunk.id, exc)
|
|
257
|
+
if not await self._sleep(self._retry_delays[attempt]):
|
|
258
|
+
return
|
|
259
|
+
continue
|
|
260
|
+
if len(vectors) != 1:
|
|
261
|
+
logger.error(
|
|
262
|
+
"片段 %s 的 Embedding 返回 %s 条向量,期望 1 条。", chunk.id, len(vectors)
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
try:
|
|
266
|
+
await self._store.complete_chunk(chunk.id, vectors[0])
|
|
267
|
+
except Exception:
|
|
268
|
+
logger.exception("片段 %s 写入向量失败,已保留待处理状态。", chunk.id)
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
async def _sleep(self, delay: float) -> bool:
|
|
272
|
+
"""等待退避时间;返回 False 表示等待期间收到关闭请求。"""
|
|
273
|
+
if self._sleep_impl is not None:
|
|
274
|
+
await self._sleep_impl(delay)
|
|
275
|
+
return not self._closing
|
|
276
|
+
if delay <= 0:
|
|
277
|
+
await asyncio.sleep(0)
|
|
278
|
+
return not self._closing
|
|
279
|
+
try:
|
|
280
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=delay)
|
|
281
|
+
except asyncio.TimeoutError:
|
|
282
|
+
return True
|
|
283
|
+
return False
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _utc_now() -> datetime:
|
|
10
|
+
return datetime.now(timezone.utc)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Message(BaseModel):
|
|
14
|
+
"""群聊中的一条文本消息。"""
|
|
15
|
+
|
|
16
|
+
group_id: int
|
|
17
|
+
user_id: int
|
|
18
|
+
display_name: str
|
|
19
|
+
text: str
|
|
20
|
+
created_at: datetime = Field(default_factory=_utc_now)
|
|
21
|
+
is_bot: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChunkStatus(str, Enum):
|
|
25
|
+
PENDING = "pending"
|
|
26
|
+
READY = "ready"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Chunk(BaseModel):
|
|
30
|
+
"""一段待向量化或已向量化的群聊记忆。"""
|
|
31
|
+
|
|
32
|
+
id: int
|
|
33
|
+
group_id: int
|
|
34
|
+
text: str
|
|
35
|
+
status: ChunkStatus = ChunkStatus.PENDING
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ChatContext(BaseModel):
|
|
39
|
+
"""一次模型请求所需的最近消息与长期记忆。"""
|
|
40
|
+
|
|
41
|
+
recent_messages: list[Message] = Field(default_factory=list)
|
|
42
|
+
long_term_chunks: list[Chunk] = Field(default_factory=list)
|