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,26 @@
1
+ from nonebot import get_driver, require
2
+ from nonebot.plugin import PluginMetadata
3
+
4
+ from .config import Config
5
+
6
+ __plugin_meta__ = PluginMetadata(
7
+ name="群聊记忆",
8
+ description="为群聊提供共享长期记忆、点名对话和按群概率回复",
9
+ usage=(
10
+ "在群聊中 @机器人 或回复机器人的消息即可对话。\n"
11
+ "SUPERUSER:/回复概率 [0~100] 查询或设置当前群随机回复概率;"
12
+ "/清空群记忆 清除当前群聊天记录与长期记忆。"
13
+ ),
14
+ type="application",
15
+ homepage="https://github.com/TonyLiangP2010405/nonebot-plugin-group-chat-memory",
16
+ config=Config,
17
+ supported_adapters={"~onebot.v11"},
18
+ )
19
+
20
+ try:
21
+ get_driver()
22
+ except ValueError:
23
+ pass # 允许在 NoneBot 初始化前单独导入模块
24
+ else:
25
+ require("nonebot_plugin_localstore")
26
+ from . import handlers as handlers
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import math
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from .config import Config
10
+ from .context import build_chat_messages
11
+ from .models import ChatContext
12
+
13
+ CHAT_RETRY_DELAYS: tuple[float, ...] = (0.5, 1.0)
14
+
15
+
16
+ class ModelAPIError(RuntimeError):
17
+ """模型 API 调用失败;异常消息只包含状态或原因,不含密钥与响应正文。"""
18
+
19
+
20
+ class EmbeddingAPIError(ModelAPIError):
21
+ """Embedding 请求失败或响应不可用。"""
22
+
23
+
24
+ class ChatAPIError(ModelAPIError):
25
+ """聊天请求失败或响应不可用。"""
26
+
27
+ def __init__(self, message: str, *, retryable: bool = False) -> None:
28
+ super().__init__(message)
29
+ self.retryable = retryable
30
+
31
+
32
+ def _make_client(timeout: float) -> httpx.AsyncClient:
33
+ """为单次请求创建客户端,使聊天与 Embedding 各自使用独立超时。"""
34
+ return httpx.AsyncClient(timeout=timeout)
35
+
36
+
37
+ def _endpoint(base_url: str, path: str) -> str:
38
+ return f"{base_url.rstrip('/')}/{path}"
39
+
40
+
41
+ def _auth_headers(api_key: str) -> dict[str, str]:
42
+ return {"Authorization": f"Bearer {api_key.strip()}"}
43
+
44
+
45
+ class EmbeddingClient:
46
+ """OpenAI 兼容 `/embeddings` 客户端,用于批量向量化。"""
47
+
48
+ def __init__(self, config: Config) -> None:
49
+ self._config = config
50
+
51
+ async def embed(self, texts: list[str]) -> list[list[float]]:
52
+ """批量向量化 texts,返回与输入顺序一致的向量;失败时抛出 EmbeddingAPIError。"""
53
+ if not texts:
54
+ return []
55
+ self._config.require_embedding_config()
56
+ payload = {"model": self._config.group_chat_memory_embedding_model, "input": list(texts)}
57
+ response = await self._post(payload)
58
+ return self._parse(response, len(texts))
59
+
60
+ async def _post(self, payload: dict[str, Any]) -> httpx.Response:
61
+ url = _endpoint(self._config.group_chat_memory_embedding_base_url, "embeddings")
62
+ headers = _auth_headers(self._config.group_chat_memory_embedding_api_key.get_secret_value())
63
+ timeout = self._config.group_chat_memory_embedding_timeout
64
+ try:
65
+ async with _make_client(timeout) as client:
66
+ response = await client.post(url, json=payload, headers=headers)
67
+ except httpx.TimeoutException as exc:
68
+ raise EmbeddingAPIError("Embedding 请求超时。") from exc
69
+ except httpx.HTTPError as exc:
70
+ raise EmbeddingAPIError("Embedding 请求失败,无法连接服务。") from exc
71
+ if not 200 <= response.status_code < 300:
72
+ raise EmbeddingAPIError(f"Embedding 服务返回 HTTP {response.status_code}。")
73
+ return response
74
+
75
+ def _parse(self, response: httpx.Response, expected: int) -> list[list[float]]:
76
+ try:
77
+ payload = response.json()
78
+ except ValueError as exc:
79
+ raise EmbeddingAPIError("Embedding 响应不是合法 JSON。") from exc
80
+ data = payload.get("data") if isinstance(payload, dict) else None
81
+ if not isinstance(data, list) or not data:
82
+ raise EmbeddingAPIError("Embedding 响应缺少向量数据。")
83
+ if len(data) != expected:
84
+ raise EmbeddingAPIError(
85
+ f"Embedding 响应返回 {len(data)} 条向量,与请求的 {expected} 条不一致。"
86
+ )
87
+
88
+ ordered = sorted(
89
+ enumerate(data),
90
+ key=lambda item: _index_of(item[1], item[0]),
91
+ )
92
+ dimensions = self._config.group_chat_memory_embedding_dimensions
93
+ return [_parse_vector(item, dimensions) for _, item in ordered]
94
+
95
+
96
+ class ChatClient:
97
+ """OpenAI 兼容 `/chat/completions` 客户端,带有限重试。"""
98
+
99
+ def __init__(
100
+ self,
101
+ config: Config,
102
+ *,
103
+ retry_delays: tuple[float, ...] = CHAT_RETRY_DELAYS,
104
+ ) -> None:
105
+ self._config = config
106
+ self._retry_delays = tuple(retry_delays)
107
+
108
+ async def complete(self, context: ChatContext, current_text: str) -> str:
109
+ """按上下文请求一条回复;瞬时失败按配置次数重试,最终失败抛出 ChatAPIError。"""
110
+ self._config.require_chat_config()
111
+ if not current_text.strip():
112
+ raise ChatAPIError("当前消息为空,无法请求聊天模型。")
113
+ payload = {
114
+ "model": self._config.group_chat_memory_chat_model,
115
+ "messages": build_chat_messages(self._config, context, current_text),
116
+ }
117
+
118
+ attempts = len(self._retry_delays) + 1
119
+ for attempt in range(attempts):
120
+ try:
121
+ response = await self._post(payload)
122
+ except ChatAPIError as exc:
123
+ if not exc.retryable or attempt + 1 >= attempts:
124
+ raise
125
+ await asyncio.sleep(self._retry_delays[attempt])
126
+ continue
127
+ return self._parse(response)
128
+ raise ChatAPIError("聊天请求重试次数已用尽。")
129
+
130
+ async def _post(self, payload: dict[str, Any]) -> httpx.Response:
131
+ url = _endpoint(self._config.group_chat_memory_chat_base_url, "chat/completions")
132
+ headers = _auth_headers(self._config.group_chat_memory_chat_api_key.get_secret_value())
133
+ timeout = self._config.group_chat_memory_chat_timeout
134
+ try:
135
+ async with _make_client(timeout) as client:
136
+ response = await client.post(url, json=payload, headers=headers)
137
+ except httpx.TimeoutException as exc:
138
+ raise ChatAPIError("聊天请求超时。", retryable=True) from exc
139
+ except httpx.HTTPError as exc:
140
+ raise ChatAPIError("聊天请求失败,无法连接服务。", retryable=True) from exc
141
+ if not 200 <= response.status_code < 300:
142
+ raise ChatAPIError(
143
+ f"聊天服务返回 HTTP {response.status_code}。",
144
+ retryable=response.status_code == 429 or response.status_code >= 500,
145
+ )
146
+ return response
147
+
148
+ @staticmethod
149
+ def _parse(response: httpx.Response) -> str:
150
+ try:
151
+ payload = response.json()
152
+ except ValueError as exc:
153
+ raise ChatAPIError("聊天响应不是合法 JSON。") from exc
154
+ choices = payload.get("choices") if isinstance(payload, dict) else None
155
+ if not isinstance(choices, list) or not choices:
156
+ raise ChatAPIError("聊天响应缺少 choices。")
157
+ message = choices[0].get("message") if isinstance(choices[0], dict) else None
158
+ content = message.get("content") if isinstance(message, dict) else None
159
+ if not isinstance(content, str) or not content.strip():
160
+ raise ChatAPIError("聊天响应缺少回复内容。")
161
+ return content.strip()
162
+
163
+
164
+ def _index_of(item: Any, fallback: int) -> int:
165
+ index = item.get("index") if isinstance(item, dict) else None
166
+ if isinstance(index, int) and not isinstance(index, bool):
167
+ return index
168
+ return fallback
169
+
170
+
171
+ def _parse_vector(item: Any, dimensions: int) -> list[float]:
172
+ vector = item.get("embedding") if isinstance(item, dict) else None
173
+ if not isinstance(vector, list):
174
+ raise EmbeddingAPIError("Embedding 响应缺少向量。")
175
+ if len(vector) != dimensions:
176
+ raise EmbeddingAPIError(f"Embedding 向量维度为 {len(vector)},配置要求 {dimensions}。")
177
+ values: list[float] = []
178
+ for value in vector:
179
+ if isinstance(value, bool) or not isinstance(value, int | float):
180
+ raise EmbeddingAPIError("Embedding 向量包含非数值。")
181
+ number = float(value)
182
+ if not math.isfinite(number):
183
+ raise EmbeddingAPIError("Embedding 向量包含非有限数值。")
184
+ values.append(number)
185
+ return values
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from nonebot import get_plugin_config
6
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr
7
+
8
+ DEFAULT_DB_FILENAME = "group_chat_memory.db"
9
+
10
+ DEFAULT_SYSTEM_PROMPT = (
11
+ "你是一个群聊助手。回答自然、简洁,像群友一样交流。"
12
+ "只使用提供的聊天记录和长期记忆,不要假装记得没有出现过的内容。"
13
+ )
14
+
15
+ RUNTIME_CONFIG_DEFAULTS = {
16
+ "chat_base_url": "",
17
+ "chat_api_key": "",
18
+ "chat_model": "deepseek-v4.1-flash",
19
+ "embedding_api_key": "",
20
+ }
21
+
22
+
23
+ class ConfigError(RuntimeError):
24
+ """功能所需的配置缺失时抛出。"""
25
+
26
+
27
+ def _localstore_data_dir() -> Path:
28
+ from nonebot_plugin_localstore import get_plugin_data_dir
29
+
30
+ return get_plugin_data_dir()
31
+
32
+
33
+ class Config(BaseModel):
34
+ """群聊记忆插件配置;四个模型运行时字段由私聊命令和数据库覆盖。"""
35
+
36
+ model_config = ConfigDict(extra="ignore")
37
+
38
+ group_chat_memory_chat_base_url: str = ""
39
+ group_chat_memory_chat_api_key: SecretStr = SecretStr("")
40
+ group_chat_memory_chat_model: str = "deepseek-v4.1-flash"
41
+ group_chat_memory_chat_timeout: float = Field(default=60.0, gt=0)
42
+
43
+ group_chat_memory_embedding_base_url: str = "https://api.siliconflow.cn/v1"
44
+ group_chat_memory_embedding_api_key: SecretStr = SecretStr("")
45
+ group_chat_memory_embedding_model: str = "BAAI/bge-m3"
46
+ group_chat_memory_embedding_dimensions: int = Field(default=1024, gt=0)
47
+ group_chat_memory_embedding_timeout: float = Field(default=10.0, gt=0)
48
+ group_chat_memory_query_timeout: float = Field(default=3.0, gt=0)
49
+
50
+ group_chat_memory_db_path_override: str | None = None
51
+
52
+ group_chat_memory_recent_messages: int = Field(default=20, ge=1)
53
+ group_chat_memory_chunk_size: int = Field(default=10, ge=1)
54
+ group_chat_memory_long_term_memories: int = Field(default=5, ge=0)
55
+ group_chat_memory_context_max_chars: int = Field(default=6000, ge=1)
56
+ group_chat_memory_default_probability: float = Field(default=0.0, ge=0.0, le=100.0)
57
+ group_chat_memory_ignored_qq: set[int] = Field(default_factory=set)
58
+ group_chat_memory_system_prompt: str = DEFAULT_SYSTEM_PROMPT
59
+
60
+ @property
61
+ def database_path(self) -> Path:
62
+ """数据库文件路径;未配置时使用 localstore 的插件数据目录。"""
63
+ configured = (self.group_chat_memory_db_path_override or "").strip()
64
+ if configured:
65
+ return Path(configured).expanduser()
66
+ return _localstore_data_dir() / DEFAULT_DB_FILENAME
67
+
68
+ def apply_runtime_config(self, values: dict[str, str]) -> None:
69
+ """应用数据库中的四项全局配置,未保存的字段恢复内置默认值。"""
70
+ for key, default in RUNTIME_CONFIG_DEFAULTS.items():
71
+ value = values.get(key, default)
72
+ if key == "chat_base_url":
73
+ self.group_chat_memory_chat_base_url = value
74
+ elif key == "chat_api_key":
75
+ self.group_chat_memory_chat_api_key = SecretStr(value)
76
+ elif key == "chat_model":
77
+ self.group_chat_memory_chat_model = value
78
+ elif key == "embedding_api_key":
79
+ self.group_chat_memory_embedding_api_key = SecretStr(value)
80
+
81
+ def require_chat_config(self) -> None:
82
+ """检查聊天模型配置,仅在真正调用聊天功能时使用。"""
83
+ missing = self._missing(
84
+ ("GROUP_CHAT_MEMORY_CHAT_BASE_URL", self.group_chat_memory_chat_base_url),
85
+ (
86
+ "GROUP_CHAT_MEMORY_CHAT_API_KEY",
87
+ self.group_chat_memory_chat_api_key.get_secret_value(),
88
+ ),
89
+ ("GROUP_CHAT_MEMORY_CHAT_MODEL", self.group_chat_memory_chat_model),
90
+ )
91
+ if missing:
92
+ raise ConfigError(f"聊天模型未配置,请设置:{'、'.join(missing)}")
93
+
94
+ def require_embedding_config(self) -> None:
95
+ """检查 Embedding 配置,仅在真正调用向量化功能时使用。"""
96
+ missing = self._missing(
97
+ (
98
+ "GROUP_CHAT_MEMORY_EMBEDDING_BASE_URL",
99
+ self.group_chat_memory_embedding_base_url,
100
+ ),
101
+ (
102
+ "GROUP_CHAT_MEMORY_EMBEDDING_API_KEY",
103
+ self.group_chat_memory_embedding_api_key.get_secret_value(),
104
+ ),
105
+ ("GROUP_CHAT_MEMORY_EMBEDDING_MODEL", self.group_chat_memory_embedding_model),
106
+ )
107
+ if missing:
108
+ raise ConfigError(f"Embedding 未配置,请设置:{'、'.join(missing)}")
109
+
110
+ @staticmethod
111
+ def _missing(*items: tuple[str, str]) -> list[str]:
112
+ return [name for name, value in items if not value.strip()]
113
+
114
+
115
+ def get_settings() -> Config:
116
+ """从 NoneBot 全局配置读取本插件配置。"""
117
+ return get_plugin_config(Config)
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from datetime import datetime, timezone
6
+ from typing import TYPE_CHECKING
7
+
8
+ from .config import Config
9
+ from .models import ChatContext, Chunk, Message
10
+ from .store import MemoryStore
11
+
12
+ if TYPE_CHECKING:
13
+ from .api import EmbeddingClient
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ MEMORY_SECTION_TITLE = "## 长期记忆(较早的相关片段)"
18
+ RECENT_SECTION_TITLE = "## 最近群聊(从旧到新)"
19
+ CURRENT_SECTION_TITLE = "## 当前消息"
20
+ MEMBER_LABEL = "群友"
21
+ BOT_LABEL = "Bot"
22
+ SPEAKER_GUIDE = (
23
+ "聊天记录中的「群友」是群里的其他人,「Bot」或「(bot)」标记的都是你自己之前说过的话。"
24
+ "只回复「当前消息」,不要替其他群友发言,也不要编造记录中没有出现过的内容。"
25
+ )
26
+ TRUNCATION_MARK = "…"
27
+
28
+
29
+ class ContextBuilder:
30
+ """并行读取当前群的最近消息与长期记忆;长期记忆失败时降级为最近消息。"""
31
+
32
+ def __init__(self, config: Config, store: MemoryStore, embedding: EmbeddingClient) -> None:
33
+ self._config = config
34
+ self._store = store
35
+ self._embedding = embedding
36
+
37
+ async def build(self, group_id: int, query: str) -> ChatContext:
38
+ """构建一次回复所需的上下文;Embedding 或检索失败不影响最近消息。"""
39
+ recent, chunks = await asyncio.gather(
40
+ self._store.recent_messages(group_id, self._config.group_chat_memory_recent_messages),
41
+ self._long_term_chunks(group_id, query),
42
+ )
43
+ return ChatContext(recent_messages=recent, long_term_chunks=chunks)
44
+
45
+ async def _long_term_chunks(self, group_id: int, query: str) -> list[Chunk]:
46
+ """在独立短超时内完成查询向量与检索,超时或失败都降级为空列表。"""
47
+ limit = self._config.group_chat_memory_long_term_memories
48
+ if limit < 1 or not query.strip():
49
+ return []
50
+ try:
51
+ return await asyncio.wait_for(
52
+ self._lookup_long_term(group_id, query, limit),
53
+ timeout=self._config.group_chat_memory_query_timeout,
54
+ )
55
+ except Exception as exc:
56
+ logger.warning("长期记忆检索失败,仅使用最近消息:%s: %s", type(exc).__name__, exc)
57
+ return []
58
+
59
+ async def _lookup_long_term(self, group_id: int, query: str, limit: int) -> list[Chunk]:
60
+ vectors = await self._embedding.embed([query])
61
+ if not vectors:
62
+ return []
63
+ return await self._store.search(group_id, vectors[0], limit)
64
+
65
+
66
+ def build_chat_messages(
67
+ config: Config, context: ChatContext, current_text: str
68
+ ) -> list[dict[str, str]]:
69
+ """组装 OpenAI 兼容 messages:系统提示 + 长期记忆 + 最近消息 + 当前输入。
70
+
71
+ 字符预算优先保证当前输入完整,其次保留最近消息,最后才放长期记忆。
72
+ """
73
+ budget = config.group_chat_memory_context_max_chars
74
+ current_block = f"{CURRENT_SECTION_TITLE}\n{MEMBER_LABEL}: {current_text.strip()}"
75
+ remaining = max(budget - len(current_block), 0)
76
+
77
+ recent_lines = _take_recent_lines(context.recent_messages, remaining)
78
+ remaining -= sum(len(line) for line in recent_lines) + max(len(recent_lines) - 1, 0)
79
+
80
+ memory_blocks = _take_chunk_blocks(
81
+ context.long_term_chunks,
82
+ config.group_chat_memory_long_term_memories,
83
+ max(remaining, 0),
84
+ )
85
+
86
+ sections: list[str] = []
87
+ if memory_blocks:
88
+ sections.append(f"{MEMORY_SECTION_TITLE}\n" + "\n\n".join(memory_blocks))
89
+ if recent_lines:
90
+ sections.append(f"{RECENT_SECTION_TITLE}\n" + "\n".join(recent_lines))
91
+ sections.append(current_block)
92
+
93
+ system_prompt = "\n\n".join(
94
+ part for part in (config.group_chat_memory_system_prompt.strip(), SPEAKER_GUIDE) if part
95
+ )
96
+ return [
97
+ {"role": "system", "content": system_prompt},
98
+ {"role": "user", "content": "\n\n".join(sections)},
99
+ ]
100
+
101
+
102
+ def _take_recent_lines(messages: list[Message], budget: int) -> list[str]:
103
+ """从最新消息往前取,放不下时截断最后一条,返回从旧到新的行。"""
104
+ lines: list[str] = []
105
+ used = 0
106
+ for message in reversed(messages):
107
+ line = _format_message(message)
108
+ separator = 1 if lines else 0
109
+ available = budget - used - separator
110
+ if available <= 0:
111
+ break
112
+ if len(line) > available:
113
+ line = line[: available - 1] + TRUNCATION_MARK if available >= 2 else line[:available]
114
+ lines.append(line)
115
+ used += len(line) + separator
116
+ if used >= budget:
117
+ break
118
+ lines.reverse()
119
+ return lines
120
+
121
+
122
+ def _take_chunk_blocks(chunks: list[Chunk], limit: int, budget: int) -> list[str]:
123
+ """按相关度顺序取最多 limit 个片段,放不下时停止,保留更高优先级的最近消息。"""
124
+ blocks: list[str] = []
125
+ used = 0
126
+ for chunk in chunks[: max(limit, 0)]:
127
+ text = chunk.text.strip()
128
+ if not text:
129
+ continue
130
+ separator = 2 if blocks else 0
131
+ if used + len(text) + separator > budget:
132
+ break
133
+ blocks.append(text)
134
+ used += len(text) + separator
135
+ return blocks
136
+
137
+
138
+ def _format_message(message: Message) -> str:
139
+ created_at = _as_utc(message.created_at)
140
+ label = BOT_LABEL if message.is_bot else MEMBER_LABEL
141
+ name = message.display_name.strip() or str(message.user_id)
142
+ return f"[{created_at:%Y-%m-%d %H:%M}] {label} {name}: {message.text.strip()}"
143
+
144
+
145
+ def _as_utc(value: datetime) -> datetime:
146
+ if value.tzinfo is None:
147
+ return value.replace(tzinfo=timezone.utc)
148
+ return value.astimezone(timezone.utc)