nonebot-plugin-dotcharacter 2.0.6__tar.gz → 2.0.7__tar.gz
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_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/PKG-INFO +1 -1
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter/__init__.py +1 -1
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter/conversation.py +184 -107
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter.egg-info/PKG-INFO +1 -1
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/pyproject.toml +1 -1
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/LICENSE +0 -0
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/README.md +0 -0
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter/character_loader.py +0 -0
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter/config.py +0 -0
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter/llm_client.py +0 -0
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter.egg-info/SOURCES.txt +0 -0
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter.egg-info/dependency_links.txt +0 -0
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter.egg-info/entry_points.txt +0 -0
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter.egg-info/requires.txt +0 -0
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter.egg-info/top_level.txt +0 -0
- {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/setup.cfg +0 -0
|
@@ -1,107 +1,184 @@
|
|
|
1
|
-
"""会话管理 — 每个用户 × 每个角色维护独立对话历史。
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def
|
|
54
|
-
return
|
|
55
|
-
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
1
|
+
"""会话管理 — 每个用户 × 每个角色维护独立对话历史。
|
|
2
|
+
|
|
3
|
+
支持持久化到本地 JSON 文件,重启后对话历史不丢失。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
# 数据文件路径
|
|
14
|
+
_DATA_DIR = Path.home() / ".nonebot2" / "data" / "dotcharacter"
|
|
15
|
+
_SESSIONS_FILE = _DATA_DIR / "sessions.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ConversationSession:
|
|
20
|
+
"""单个用户对单个角色的对话会话。"""
|
|
21
|
+
user_id: str
|
|
22
|
+
character_slug: str
|
|
23
|
+
messages: List[dict] = field(default_factory=list)
|
|
24
|
+
created_at: float = field(default_factory=time.time)
|
|
25
|
+
last_active_at: float = field(default_factory=time.time)
|
|
26
|
+
_manager: Optional["ConversationManager"] = field(default=None, repr=False)
|
|
27
|
+
|
|
28
|
+
def add_user_message(self, content: str) -> None:
|
|
29
|
+
self.messages.append({"role": "user", "content": content})
|
|
30
|
+
self.last_active_at = time.time()
|
|
31
|
+
if self._manager:
|
|
32
|
+
self._manager._save()
|
|
33
|
+
|
|
34
|
+
def add_assistant_message(self, content: str) -> None:
|
|
35
|
+
self.messages.append({"role": "assistant", "content": content})
|
|
36
|
+
self.last_active_at = time.time()
|
|
37
|
+
if self._manager:
|
|
38
|
+
self._manager._save()
|
|
39
|
+
|
|
40
|
+
def trim(self, max_history: int) -> None:
|
|
41
|
+
"""保留最近 N 轮对话(每轮 = user + assistant)。"""
|
|
42
|
+
if max_history <= 0:
|
|
43
|
+
self.messages.clear()
|
|
44
|
+
if self._manager:
|
|
45
|
+
self._manager._save()
|
|
46
|
+
return
|
|
47
|
+
if len(self.messages) > max_history:
|
|
48
|
+
self.messages = self.messages[-max_history:]
|
|
49
|
+
if self._manager:
|
|
50
|
+
self._manager._save()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_empty(self) -> bool:
|
|
54
|
+
return len(self.messages) == 0
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict:
|
|
57
|
+
return {
|
|
58
|
+
"user_id": self.user_id,
|
|
59
|
+
"character_slug": self.character_slug,
|
|
60
|
+
"messages": self.messages,
|
|
61
|
+
"created_at": self.created_at,
|
|
62
|
+
"last_active_at": self.last_active_at,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, data: dict, manager: Optional["ConversationManager"] = None) -> "ConversationSession":
|
|
67
|
+
return cls(
|
|
68
|
+
user_id=data["user_id"],
|
|
69
|
+
character_slug=data["character_slug"],
|
|
70
|
+
messages=data.get("messages", []),
|
|
71
|
+
created_at=data.get("created_at", time.time()),
|
|
72
|
+
last_active_at=data.get("last_active_at", time.time()),
|
|
73
|
+
_manager=manager,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ConversationManager:
|
|
78
|
+
"""管理所有用户的会话。
|
|
79
|
+
|
|
80
|
+
内部使用 OrderedDict 做简单的 LRU 淘汰,
|
|
81
|
+
支持持久化到本地 JSON 文件。
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
MAX_SESSIONS = 500 # 最多保留 500 个会话
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
self._sessions: OrderedDict[str, ConversationSession] = OrderedDict()
|
|
88
|
+
self._active_character: Dict[str, str] = {} # user_id → slug
|
|
89
|
+
self._load()
|
|
90
|
+
|
|
91
|
+
def _save(self) -> None:
|
|
92
|
+
"""将会话状态持久化到本地文件。"""
|
|
93
|
+
try:
|
|
94
|
+
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
data = {
|
|
96
|
+
"sessions": {
|
|
97
|
+
key: session.to_dict()
|
|
98
|
+
for key, session in self._sessions.items()
|
|
99
|
+
},
|
|
100
|
+
"active_characters": dict(self._active_character),
|
|
101
|
+
}
|
|
102
|
+
_SESSIONS_FILE.write_text(
|
|
103
|
+
json.dumps(data, ensure_ascii=False, indent=2),
|
|
104
|
+
encoding="utf-8",
|
|
105
|
+
)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass # 保存失败不影响运行
|
|
108
|
+
|
|
109
|
+
def _load(self) -> None:
|
|
110
|
+
"""从本地文件恢复会话状态。"""
|
|
111
|
+
if not _SESSIONS_FILE.exists():
|
|
112
|
+
return
|
|
113
|
+
try:
|
|
114
|
+
raw = _SESSIONS_FILE.read_text(encoding="utf-8")
|
|
115
|
+
data = json.loads(raw)
|
|
116
|
+
sessions_data = data.get("sessions", {})
|
|
117
|
+
for key, sess_data in sessions_data.items():
|
|
118
|
+
session = ConversationSession.from_dict(sess_data, manager=self)
|
|
119
|
+
self._sessions[key] = session
|
|
120
|
+
self._active_character = data.get("active_characters", {})
|
|
121
|
+
except Exception:
|
|
122
|
+
pass # 加载失败不影响运行
|
|
123
|
+
|
|
124
|
+
def _make_key(self, user_id: str, slug: str) -> str:
|
|
125
|
+
return f"{user_id}::{slug}"
|
|
126
|
+
|
|
127
|
+
def get_session(self, user_id: str, slug: str) -> ConversationSession:
|
|
128
|
+
"""获取或创建会话。"""
|
|
129
|
+
key = self._make_key(user_id, slug)
|
|
130
|
+
if key not in self._sessions:
|
|
131
|
+
if len(self._sessions) >= self.MAX_SESSIONS:
|
|
132
|
+
# 淘汰最老的会话
|
|
133
|
+
self._sessions.popitem(last=False)
|
|
134
|
+
self._sessions[key] = ConversationSession(
|
|
135
|
+
user_id=user_id,
|
|
136
|
+
character_slug=slug,
|
|
137
|
+
_manager=self,
|
|
138
|
+
)
|
|
139
|
+
self._save()
|
|
140
|
+
else:
|
|
141
|
+
# 移到末尾(最近使用)
|
|
142
|
+
self._sessions.move_to_end(key)
|
|
143
|
+
return self._sessions[key]
|
|
144
|
+
|
|
145
|
+
def reset_session(self, user_id: str, slug: str) -> None:
|
|
146
|
+
"""重置某用户对某角色的对话。"""
|
|
147
|
+
key = self._make_key(user_id, slug)
|
|
148
|
+
self._sessions.pop(key, None)
|
|
149
|
+
self._save()
|
|
150
|
+
|
|
151
|
+
def set_active_character(self, user_id: str, slug: str) -> None:
|
|
152
|
+
self._active_character[user_id] = slug
|
|
153
|
+
self._save()
|
|
154
|
+
|
|
155
|
+
def get_active_character(self, user_id: str) -> Optional[str]:
|
|
156
|
+
return self._active_character.get(user_id)
|
|
157
|
+
|
|
158
|
+
def clear_active_character(self, user_id: str) -> None:
|
|
159
|
+
self._active_character.pop(user_id, None)
|
|
160
|
+
self._save()
|
|
161
|
+
|
|
162
|
+
def cleanup_stale(self, max_age_seconds: float = 3600.0) -> int:
|
|
163
|
+
"""清理超过 max_age_seconds 未活跃的会话。返回清理数量。"""
|
|
164
|
+
now = time.time()
|
|
165
|
+
stale_keys = [
|
|
166
|
+
key
|
|
167
|
+
for key, session in self._sessions.items()
|
|
168
|
+
if now - session.last_active_at > max_age_seconds
|
|
169
|
+
]
|
|
170
|
+
for key in stale_keys:
|
|
171
|
+
self._sessions.pop(key, None)
|
|
172
|
+
self._save()
|
|
173
|
+
return len(stale_keys)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# 全局单例
|
|
177
|
+
_conversation_manager: Optional[ConversationManager] = None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def get_conversation_manager() -> ConversationManager:
|
|
181
|
+
global _conversation_manager
|
|
182
|
+
if _conversation_manager is None:
|
|
183
|
+
_conversation_manager = ConversationManager()
|
|
184
|
+
return _conversation_manager
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|