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.
@@ -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)