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.
Files changed (16) hide show
  1. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/PKG-INFO +1 -1
  2. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter/__init__.py +1 -1
  3. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter/conversation.py +184 -107
  4. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter.egg-info/PKG-INFO +1 -1
  5. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/pyproject.toml +1 -1
  6. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/LICENSE +0 -0
  7. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/README.md +0 -0
  8. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter/character_loader.py +0 -0
  9. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter/config.py +0 -0
  10. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter/llm_client.py +0 -0
  11. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter.egg-info/SOURCES.txt +0 -0
  12. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter.egg-info/dependency_links.txt +0 -0
  13. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter.egg-info/entry_points.txt +0 -0
  14. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter.egg-info/requires.txt +0 -0
  15. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/nonebot_plugin_dotcharacter.egg-info/top_level.txt +0 -0
  16. {nonebot_plugin_dotcharacter-2.0.6 → nonebot_plugin_dotcharacter-2.0.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonebot-plugin-dotcharacter
3
- Version: 2.0.6
3
+ Version: 2.0.7
4
4
  Summary: NoneBot 插件:加载 dot-skill / colleague-skill 蒸馏角色,通过 QQ Bot 进行 AI 角色扮演对话
5
5
  Author: tghrt
6
6
  License: MIT
@@ -59,7 +59,7 @@ __plugin_meta__ = PluginMetadata(
59
59
  supported_adapters={"~onebot.v11"},
60
60
  extra={
61
61
  "author": "tghrt",
62
- "version": "2.0.6",
62
+ "version": "2.0.7",
63
63
  },
64
64
  )
65
65
 
@@ -1,107 +1,184 @@
1
- """会话管理 — 每个用户 × 每个角色维护独立对话历史。"""
2
-
3
- import time
4
- from collections import OrderedDict
5
- from dataclasses import dataclass, field
6
- from typing import Dict, List, Optional
7
-
8
-
9
- @dataclass
10
- class ConversationSession:
11
- """单个用户对单个角色的对话会话。"""
12
- user_id: str
13
- character_slug: str
14
- messages: List[dict] = field(default_factory=list) # [{"role":"user"|"assistant","content":"..."}]
15
- created_at: float = field(default_factory=time.time)
16
- last_active_at: float = field(default_factory=time.time)
17
-
18
- def add_user_message(self, content: str) -> None:
19
- self.messages.append({"role": "user", "content": content})
20
- self.last_active_at = time.time()
21
-
22
- def add_assistant_message(self, content: str) -> None:
23
- self.messages.append({"role": "assistant", "content": content})
24
- self.last_active_at = time.time()
25
-
26
- def trim(self, max_history: int) -> None:
27
- """保留最近 N 轮对话(每轮 = user + assistant)。"""
28
- if max_history <= 0:
29
- self.messages.clear()
30
- return
31
- # max_history 指的是消息数,保留最后 N 条
32
- if len(self.messages) > max_history:
33
- self.messages = self.messages[-max_history:]
34
-
35
- @property
36
- def is_empty(self) -> bool:
37
- return len(self.messages) == 0
38
-
39
-
40
- class ConversationManager:
41
- """管理所有用户的会话。
42
-
43
- 内部使用 OrderedDict 做简单的 LRU 淘汰,
44
- 避免长时间运行时内存无限增长。
45
- """
46
-
47
- MAX_SESSIONS = 500 # 最多保留 500 个会话
48
-
49
- def __init__(self) -> None:
50
- self._sessions: OrderedDict[str, ConversationSession] = OrderedDict()
51
- self._active_character: Dict[str, str] = {} # user_id → slug
52
-
53
- def _make_key(self, user_id: str, slug: str) -> str:
54
- return f"{user_id}::{slug}"
55
-
56
- def get_session(self, user_id: str, slug: str) -> ConversationSession:
57
- """获取或创建会话。"""
58
- key = self._make_key(user_id, slug)
59
- if key not in self._sessions:
60
- if len(self._sessions) >= self.MAX_SESSIONS:
61
- # 淘汰最老的会话
62
- self._sessions.popitem(last=False)
63
- self._sessions[key] = ConversationSession(
64
- user_id=user_id,
65
- character_slug=slug,
66
- )
67
- else:
68
- # 移到末尾(最近使用)
69
- self._sessions.move_to_end(key)
70
- return self._sessions[key]
71
-
72
- def reset_session(self, user_id: str, slug: str) -> None:
73
- """重置某用户对某角色的对话。"""
74
- key = self._make_key(user_id, slug)
75
- self._sessions.pop(key, None)
76
-
77
- def set_active_character(self, user_id: str, slug: str) -> None:
78
- self._active_character[user_id] = slug
79
-
80
- def get_active_character(self, user_id: str) -> Optional[str]:
81
- return self._active_character.get(user_id)
82
-
83
- def clear_active_character(self, user_id: str) -> None:
84
- self._active_character.pop(user_id, None)
85
-
86
- def cleanup_stale(self, max_age_seconds: float = 3600.0) -> int:
87
- """清理超过 max_age_seconds 未活跃的会话。返回清理数量。"""
88
- now = time.time()
89
- stale_keys = [
90
- key
91
- for key, session in self._sessions.items()
92
- if now - session.last_active_at > max_age_seconds
93
- ]
94
- for key in stale_keys:
95
- self._sessions.pop(key, None)
96
- return len(stale_keys)
97
-
98
-
99
- # 全局单例
100
- _conversation_manager: Optional[ConversationManager] = None
101
-
102
-
103
- def get_conversation_manager() -> ConversationManager:
104
- global _conversation_manager
105
- if _conversation_manager is None:
106
- _conversation_manager = ConversationManager()
107
- return _conversation_manager
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonebot-plugin-dotcharacter
3
- Version: 2.0.6
3
+ Version: 2.0.7
4
4
  Summary: NoneBot 插件:加载 dot-skill / colleague-skill 蒸馏角色,通过 QQ Bot 进行 AI 角色扮演对话
5
5
  Author: tghrt
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-dotcharacter"
3
- version = "2.0.6"
3
+ version = "2.0.7"
4
4
  description = "NoneBot 插件:加载 dot-skill / colleague-skill 蒸馏角色,通过 QQ Bot 进行 AI 角色扮演对话"
5
5
  requires-python = ">=3.9"
6
6
  readme = "README.md"