nonebot-plugin-dotcharacter 2.0.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.
- build/lib/nonebot_plugin_dotcharacter/__init__.py +676 -0
- build/lib/nonebot_plugin_dotcharacter/character_loader.py +241 -0
- build/lib/nonebot_plugin_dotcharacter/config.py +161 -0
- build/lib/nonebot_plugin_dotcharacter/conversation.py +107 -0
- build/lib/nonebot_plugin_dotcharacter/llm_client.py +91 -0
- nonebot_plugin_dotcharacter/__init__.py +676 -0
- nonebot_plugin_dotcharacter/character_loader.py +241 -0
- nonebot_plugin_dotcharacter/config.py +161 -0
- nonebot_plugin_dotcharacter/conversation.py +107 -0
- nonebot_plugin_dotcharacter/llm_client.py +91 -0
- nonebot_plugin_dotcharacter-2.0.0.dist-info/METADATA +150 -0
- nonebot_plugin_dotcharacter-2.0.0.dist-info/RECORD +15 -0
- nonebot_plugin_dotcharacter-2.0.0.dist-info/WHEEL +5 -0
- nonebot_plugin_dotcharacter-2.0.0.dist-info/entry_points.txt +2 -0
- nonebot_plugin_dotcharacter-2.0.0.dist-info/top_level.txt +3 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""NoneBot 插件:dot-skill 角色扮演
|
|
2
|
+
|
|
3
|
+
加载 dot-skill / colleague-skill 蒸馏的角色 Persona,通过 QQ Bot 进行 AI 角色扮演对话。
|
|
4
|
+
|
|
5
|
+
权限模型:
|
|
6
|
+
- 命令(!角色列表 / !角色切换 等):仅管理员可用
|
|
7
|
+
- 角色扮演对话:所有用户可用
|
|
8
|
+
- 群聊 @机器人 才触发对话(命令不需要 @)
|
|
9
|
+
|
|
10
|
+
支持 OpenAI / DeepSeek / Kimi / Qwen / Zhipu / SiliconFlow / Groq / Ollama / 自定义
|
|
11
|
+
等所有 OpenAI Chat Completions 兼容的大模型 API。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Dict
|
|
19
|
+
|
|
20
|
+
from nonebot import on_command, on_message, get_driver, logger
|
|
21
|
+
from nonebot.adapters import Event, Message
|
|
22
|
+
from nonebot.matcher import Matcher
|
|
23
|
+
from nonebot.params import CommandArg
|
|
24
|
+
from nonebot.permission import Permission
|
|
25
|
+
from nonebot.plugin import PluginMetadata
|
|
26
|
+
from nonebot.rule import Rule
|
|
27
|
+
|
|
28
|
+
# 本地存储实例(NoneBot 启动后才可用)
|
|
29
|
+
_store = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_store():
|
|
33
|
+
"""懒加载 localstore,仅在 NoneBot 运行时导入。"""
|
|
34
|
+
global _store
|
|
35
|
+
if _store is None:
|
|
36
|
+
try:
|
|
37
|
+
import nonebot_plugin_localstore as s
|
|
38
|
+
_store = s
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
return _store
|
|
42
|
+
|
|
43
|
+
from .config import get_config, PROVIDER_PRESETS, DotCharacterConfig
|
|
44
|
+
from .character_loader import (
|
|
45
|
+
CharacterMeta,
|
|
46
|
+
scan_characters,
|
|
47
|
+
resolve_character,
|
|
48
|
+
)
|
|
49
|
+
from .conversation import get_conversation_manager
|
|
50
|
+
from .llm_client import chat_completion, system_msg, user_msg
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__plugin_meta__ = PluginMetadata(
|
|
54
|
+
name="dot-skill 角色扮演",
|
|
55
|
+
description="加载 dot-skill / colleague-skill 蒸馏的角色 Persona,通过 QQ 进行 AI 角色扮演对话。"
|
|
56
|
+
"支持群聊 @机器人、多模型切换(DeepSeek/OpenAI/Kimi 等)、管理员权限控制。",
|
|
57
|
+
usage=(
|
|
58
|
+
"命令(仅管理员):\n"
|
|
59
|
+
" !角色列表 — 列出所有角色\n"
|
|
60
|
+
" !角色切换 <名称> — 切换到指定角色,开始对话\n"
|
|
61
|
+
" !角色退出 — 退出当前角色\n"
|
|
62
|
+
" !重置对话 — 清空对话历史\n"
|
|
63
|
+
" !角色信息 [名称] — 查看角色详情\n"
|
|
64
|
+
" !角色路径 — 查看角色目录\n"
|
|
65
|
+
" !角色刷新 — 重新扫描角色目录\n"
|
|
66
|
+
" !角色导入 add <路径> — 添加角色目录\n"
|
|
67
|
+
" !模型切换 provider/model <名称> — 切换 LLM\n"
|
|
68
|
+
"\n"
|
|
69
|
+
"切换到角色后,直接发消息即可对话。群聊中需 @机器人。"
|
|
70
|
+
),
|
|
71
|
+
type="application",
|
|
72
|
+
homepage="https://github.com/nonebot/plugin-dotcharacter",
|
|
73
|
+
config=DotCharacterConfig,
|
|
74
|
+
supported_adapters={"~onebot.v11"},
|
|
75
|
+
extra={
|
|
76
|
+
"author": "tghrt",
|
|
77
|
+
"version": "2.0.0",
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ═══════════════════════════════════════════════
|
|
83
|
+
# 全局状态
|
|
84
|
+
# ═══════════════════════════════════════════════
|
|
85
|
+
|
|
86
|
+
_characters: Dict[str, CharacterMeta] = {}
|
|
87
|
+
_initialized: bool = False
|
|
88
|
+
_init_lock = asyncio.Lock()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _reload_characters() -> int:
|
|
92
|
+
"""重新扫描所有角色目录,返回发现的角色数。"""
|
|
93
|
+
global _characters
|
|
94
|
+
cfg = get_config()
|
|
95
|
+
roots = cfg.resolve_skills_paths()
|
|
96
|
+
if not roots:
|
|
97
|
+
_characters = {}
|
|
98
|
+
return 0
|
|
99
|
+
logger.info(f"[dotcharacter] 重新扫描角色目录 ({len(roots)} 个路径)...")
|
|
100
|
+
loop = asyncio.get_running_loop()
|
|
101
|
+
_characters = await loop.run_in_executor(None, scan_characters, roots)
|
|
102
|
+
logger.info(
|
|
103
|
+
f"[dotcharacter] 发现 {len(_characters)} 个角色: {list(_characters.keys())}"
|
|
104
|
+
)
|
|
105
|
+
return len(_characters)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _ensure_initialized() -> None:
|
|
109
|
+
global _initialized
|
|
110
|
+
if _initialized:
|
|
111
|
+
return
|
|
112
|
+
async with _init_lock:
|
|
113
|
+
if _initialized:
|
|
114
|
+
return
|
|
115
|
+
try:
|
|
116
|
+
count = await _reload_characters()
|
|
117
|
+
if count == 0:
|
|
118
|
+
logger.warning(
|
|
119
|
+
"[dotcharacter] 未找到任何角色目录。"
|
|
120
|
+
"请设置 DOTCHARACTER_SKILLS_PATH 指向 skills 目录。"
|
|
121
|
+
)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.error(f"[dotcharacter] 初始化失败: {e}")
|
|
124
|
+
finally:
|
|
125
|
+
_initialized = True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ═══════════════════════════════════════════════
|
|
129
|
+
# 辅助函数
|
|
130
|
+
# ═══════════════════════════════════════════════
|
|
131
|
+
|
|
132
|
+
def _get_raw_qq(event: Event) -> str:
|
|
133
|
+
uid = event.get_user_id()
|
|
134
|
+
return uid.replace("user_", "")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _get_user_name(event: Event) -> str:
|
|
138
|
+
try:
|
|
139
|
+
sender = event.get_sender_name()
|
|
140
|
+
if sender:
|
|
141
|
+
return sender
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
try:
|
|
145
|
+
card = event.sender.card if hasattr(event.sender, "card") else None
|
|
146
|
+
if card:
|
|
147
|
+
return card
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
return "用户"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_group(event: Event) -> bool:
|
|
154
|
+
return getattr(event, "message_type", "") == "group"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _get_group_id(event: Event) -> str:
|
|
158
|
+
return str(getattr(event, "group_id", ""))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _is_at_bot(event: Event) -> bool:
|
|
162
|
+
if hasattr(event, "is_tome") and callable(event.is_tome):
|
|
163
|
+
return event.is_tome()
|
|
164
|
+
try:
|
|
165
|
+
for seg in event.get_message():
|
|
166
|
+
if seg.type == "at" and seg.data.get("qq") == str(event.self_id):
|
|
167
|
+
return True
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_admin(event: Event) -> bool:
|
|
174
|
+
cfg = get_config()
|
|
175
|
+
admins = cfg.get_admin_qqs()
|
|
176
|
+
if not admins:
|
|
177
|
+
return False
|
|
178
|
+
return _get_raw_qq(event) in admins
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _is_group_allowed(event: Event) -> bool:
|
|
182
|
+
if not _is_group(event):
|
|
183
|
+
return True
|
|
184
|
+
cfg = get_config()
|
|
185
|
+
allowed = cfg.get_allowed_groups()
|
|
186
|
+
if not allowed:
|
|
187
|
+
return True
|
|
188
|
+
return _get_group_id(event) in allowed
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _scope_id(event: Event) -> str:
|
|
192
|
+
uid = event.get_user_id()
|
|
193
|
+
if _is_group(event):
|
|
194
|
+
return f"{uid}:{_get_group_id(event)}"
|
|
195
|
+
return uid
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def _combined_perm(event: Event) -> bool:
|
|
199
|
+
return _is_admin(event) and _is_group_allowed(event)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
ADMIN_AND_GROUP = Permission(_combined_perm)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ═══════════════════════════════════════════════
|
|
206
|
+
# 命令:角色列表
|
|
207
|
+
# ═══════════════════════════════════════════════
|
|
208
|
+
|
|
209
|
+
cmd_list = on_command(
|
|
210
|
+
"角色列表", aliases={"角色 list", "characters", "角色"},
|
|
211
|
+
priority=5, block=True, permission=ADMIN_AND_GROUP,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@cmd_list.handle()
|
|
216
|
+
async def handle_list(matcher: Matcher, event: Event):
|
|
217
|
+
await _ensure_initialized()
|
|
218
|
+
if not _characters:
|
|
219
|
+
await matcher.finish(
|
|
220
|
+
"📭 还没有加载任何角色。\n\n"
|
|
221
|
+
"请先用 dot-skill 或 colleague-skill 蒸馏一个角色。\n"
|
|
222
|
+
"设置 DOTCHARACTER_SKILLS_PATH 指向 skills 目录。"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
cfg = get_config()
|
|
226
|
+
provider = cfg.dotcharacter_provider
|
|
227
|
+
model = cfg.dotcharacter_model
|
|
228
|
+
roots = cfg.resolve_skills_paths()
|
|
229
|
+
|
|
230
|
+
lines = [f"🎭 **可用角色列表** (LLM:{provider}/{model},{len(roots)} 个目录)\n"]
|
|
231
|
+
for i, (slug, c) in enumerate(sorted(_characters.items()), 1):
|
|
232
|
+
family_emoji = {
|
|
233
|
+
"colleague": "👔", "relationship": "💞", "celebrity": "🌟"
|
|
234
|
+
}.get(c.family, "❓")
|
|
235
|
+
lines.append(f"{i}. {family_emoji} **{c.display_name}**")
|
|
236
|
+
lines.append(f" `{slug}` — {c.description[:60]}")
|
|
237
|
+
lines.append("")
|
|
238
|
+
|
|
239
|
+
lines.append("使用 **!角色切换 <名称>** 开始对话。")
|
|
240
|
+
await matcher.finish("\n".join(lines))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ═══════════════════════════════════════════════
|
|
244
|
+
# 命令:角色切换
|
|
245
|
+
# ═══════════════════════════════════════════════
|
|
246
|
+
|
|
247
|
+
cmd_switch = on_command(
|
|
248
|
+
"角色切换", aliases={"角色 switch", "switch_char", "chat"},
|
|
249
|
+
priority=5, block=True, permission=ADMIN_AND_GROUP,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@cmd_switch.handle()
|
|
254
|
+
async def handle_switch(matcher: Matcher, event: Event, args: Message = CommandArg()):
|
|
255
|
+
await _ensure_initialized()
|
|
256
|
+
name = args.extract_plain_text().strip()
|
|
257
|
+
if not name:
|
|
258
|
+
await matcher.finish(
|
|
259
|
+
"请指定角色名称或 slug。\n"
|
|
260
|
+
"示例:!角色切换 小小桃子呦\n"
|
|
261
|
+
"用 **!角色列表** 查看所有可用角色。"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
char = resolve_character(name, _characters)
|
|
265
|
+
if not char:
|
|
266
|
+
await matcher.finish(
|
|
267
|
+
f"❌ 找不到角色「{name}」。\n用 **!角色列表** 查看可用角色。"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
sid = _scope_id(event)
|
|
271
|
+
mgr = get_conversation_manager()
|
|
272
|
+
mgr.set_active_character(sid, char.slug)
|
|
273
|
+
|
|
274
|
+
session = mgr.get_session(sid, char.slug)
|
|
275
|
+
history_hint = ""
|
|
276
|
+
if not session.is_empty:
|
|
277
|
+
history_hint = "\n📝 有之前的对话记录。用 **!重置对话** 清除。"
|
|
278
|
+
|
|
279
|
+
family_emoji = {
|
|
280
|
+
"colleague": "👔", "relationship": "💞", "celebrity": "🌟"
|
|
281
|
+
}.get(char.family, "🎭")
|
|
282
|
+
hint = "\n💡 群聊中请 **@机器人** 发送对话哦~" if _is_group(event) else ""
|
|
283
|
+
|
|
284
|
+
await matcher.finish(
|
|
285
|
+
f"{family_emoji} 已切换到 **{char.display_name}**\n"
|
|
286
|
+
f"「{char.description}」\n\n"
|
|
287
|
+
f"现在直接发消息就可以和 {char.display_name} 对话了~"
|
|
288
|
+
f"{history_hint}{hint}\n\n"
|
|
289
|
+
f"命令:!角色退出 | !重置对话 | !角色列表"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ═══════════════════════════════════════════════
|
|
294
|
+
# 命令:角色退出 / 重置 / 信息
|
|
295
|
+
# ═══════════════════════════════════════════════
|
|
296
|
+
|
|
297
|
+
cmd_exit = on_command(
|
|
298
|
+
"角色退出", aliases={"角色 exit", "exit_char"},
|
|
299
|
+
priority=5, block=True, permission=ADMIN_AND_GROUP,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@cmd_exit.handle()
|
|
304
|
+
async def handle_exit(matcher: Matcher, event: Event):
|
|
305
|
+
sid = _scope_id(event)
|
|
306
|
+
mgr = get_conversation_manager()
|
|
307
|
+
active = mgr.get_active_character(sid)
|
|
308
|
+
if active:
|
|
309
|
+
char = _characters.get(active)
|
|
310
|
+
name = char.display_name if char else active
|
|
311
|
+
mgr.clear_active_character(sid)
|
|
312
|
+
await matcher.finish(f"👋 已退出与 **{name}** 的对话。")
|
|
313
|
+
else:
|
|
314
|
+
await matcher.finish("你当前没有在对话中。")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
cmd_reset = on_command(
|
|
318
|
+
"重置对话", aliases={"重置", "reset"},
|
|
319
|
+
priority=5, block=True, permission=ADMIN_AND_GROUP,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@cmd_reset.handle()
|
|
324
|
+
async def handle_reset(matcher: Matcher, event: Event):
|
|
325
|
+
sid = _scope_id(event)
|
|
326
|
+
mgr = get_conversation_manager()
|
|
327
|
+
active = mgr.get_active_character(sid)
|
|
328
|
+
if active:
|
|
329
|
+
mgr.reset_session(sid, active)
|
|
330
|
+
char = _characters.get(active)
|
|
331
|
+
name = char.display_name if char else active
|
|
332
|
+
await matcher.finish(f"🔄 已重置与 **{name}** 的对话记录。")
|
|
333
|
+
else:
|
|
334
|
+
await matcher.finish("你当前没有在对话中。用 **!角色切换 <名称>** 开始。")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
cmd_info = on_command(
|
|
338
|
+
"角色信息", aliases={"角色 info", "char_info"},
|
|
339
|
+
priority=5, block=True, permission=ADMIN_AND_GROUP,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@cmd_info.handle()
|
|
344
|
+
async def handle_info(matcher: Matcher, event: Event, args: Message = CommandArg()):
|
|
345
|
+
await _ensure_initialized()
|
|
346
|
+
name = args.extract_plain_text().strip()
|
|
347
|
+
sid = _scope_id(event)
|
|
348
|
+
mgr = get_conversation_manager()
|
|
349
|
+
|
|
350
|
+
if not name:
|
|
351
|
+
active = mgr.get_active_character(sid)
|
|
352
|
+
if not active:
|
|
353
|
+
await matcher.finish(
|
|
354
|
+
"你当前没有在对话中。\n用 **!角色信息 <名称>** 查看指定角色的信息。"
|
|
355
|
+
)
|
|
356
|
+
name = active
|
|
357
|
+
|
|
358
|
+
char = resolve_character(name, _characters)
|
|
359
|
+
if not char:
|
|
360
|
+
await matcher.finish(f"❌ 找不到角色「{name}」。")
|
|
361
|
+
|
|
362
|
+
session = mgr.get_session(sid, char.slug)
|
|
363
|
+
history_count = len(session.messages)
|
|
364
|
+
|
|
365
|
+
info = (
|
|
366
|
+
f"🎭 **{char.display_name}**\n"
|
|
367
|
+
f"├ 类型:{char.family}\n"
|
|
368
|
+
f"├ Slug:`{char.slug}`\n"
|
|
369
|
+
f"├ 语言:{char.language}\n"
|
|
370
|
+
f"├ 标签:{', '.join(char.tags) if char.tags else '无'}\n"
|
|
371
|
+
f"├ 描述:{char.description}\n"
|
|
372
|
+
f"├ 对话历史:{history_count} 条\n"
|
|
373
|
+
f"├ 来源:{char.source_root}\n"
|
|
374
|
+
f"└ 文件:{char.source_files[0] if char.source_files else '未知'}"
|
|
375
|
+
)
|
|
376
|
+
await matcher.finish(info)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ═══════════════════════════════════════════════
|
|
380
|
+
# 命令:角色路径
|
|
381
|
+
# ═══════════════════════════════════════════════
|
|
382
|
+
|
|
383
|
+
cmd_paths = on_command(
|
|
384
|
+
"角色路径", aliases={"路径", "paths"},
|
|
385
|
+
priority=5, block=True, permission=ADMIN_AND_GROUP,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@cmd_paths.handle()
|
|
390
|
+
async def handle_paths(matcher: Matcher, event: Event):
|
|
391
|
+
cfg = get_config()
|
|
392
|
+
roots = cfg.resolve_skills_paths()
|
|
393
|
+
count = len(_characters)
|
|
394
|
+
|
|
395
|
+
lines = [f"📂 共扫描 {len(roots)} 个目录,加载 {count} 个角色:\n"]
|
|
396
|
+
for i, r in enumerate(roots, 1):
|
|
397
|
+
chars_in_root = [
|
|
398
|
+
c.display_name for c in _characters.values()
|
|
399
|
+
if c.source_root == str(r)
|
|
400
|
+
]
|
|
401
|
+
lines.append(f"{i}. `{r}`")
|
|
402
|
+
if chars_in_root:
|
|
403
|
+
for name in chars_in_root:
|
|
404
|
+
lines.append(f" ├ 🎭 {name}")
|
|
405
|
+
else:
|
|
406
|
+
lines.append(" └ (无角色)")
|
|
407
|
+
lines.append("")
|
|
408
|
+
|
|
409
|
+
lines.append(
|
|
410
|
+
"💡 **如何添加角色**:\n"
|
|
411
|
+
"1. 把蒸馏好的角色文件夹放到任意目录\n"
|
|
412
|
+
"2. 目录结构:`skills/colleague/xxx/SKILL.md` 等\n"
|
|
413
|
+
"3. 在 .env 设置 DOTCHARACTER_SKILLS_PATH=你的路径\n"
|
|
414
|
+
"4. 发 !角色刷新 立即加载"
|
|
415
|
+
)
|
|
416
|
+
await matcher.finish("\n".join(lines))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ═══════════════════════════════════════════════
|
|
420
|
+
# 命令:角色刷新
|
|
421
|
+
# ═══════════════════════════════════════════════
|
|
422
|
+
|
|
423
|
+
cmd_reload = on_command(
|
|
424
|
+
"角色刷新", aliases={"刷新角色", "reload"},
|
|
425
|
+
priority=5, block=True, permission=ADMIN_AND_GROUP,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@cmd_reload.handle()
|
|
430
|
+
async def handle_reload(matcher: Matcher, event: Event):
|
|
431
|
+
await matcher.send("🔄 正在重新扫描角色目录...")
|
|
432
|
+
count = await _reload_characters()
|
|
433
|
+
if count == 0:
|
|
434
|
+
await matcher.finish("📭 未发现任何角色。请检查 DOTCHARACTER_SKILLS_PATH 配置。")
|
|
435
|
+
names = [c.display_name for c in _characters.values()]
|
|
436
|
+
await matcher.finish(
|
|
437
|
+
f"✅ 已刷新!共发现 **{count}** 个角色:\n"
|
|
438
|
+
+ "\n".join(f" • {n}" for n in names)
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ═══════════════════════════════════════════════
|
|
443
|
+
# 命令:角色导入
|
|
444
|
+
# ═══════════════════════════════════════════════
|
|
445
|
+
|
|
446
|
+
cmd_import = on_command(
|
|
447
|
+
"角色导入", aliases={"导入角色", "import"},
|
|
448
|
+
priority=5, block=True, permission=ADMIN_AND_GROUP,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@cmd_import.handle()
|
|
453
|
+
async def handle_import(matcher: Matcher, event: Event, args: Message = CommandArg()):
|
|
454
|
+
"""添加角色目录路径。用法:!角色导入 add <路径>"""
|
|
455
|
+
arg_text = args.extract_plain_text().strip()
|
|
456
|
+
|
|
457
|
+
if not arg_text:
|
|
458
|
+
cfg = get_config()
|
|
459
|
+
roots = cfg.resolve_skills_paths()
|
|
460
|
+
await matcher.finish(
|
|
461
|
+
f"📂 **角色导入**\n\n"
|
|
462
|
+
f"当前扫描 {len(roots)} 个目录,{len(_characters)} 个角色。\n\n"
|
|
463
|
+
f"添加新目录:`!角色导入 add <路径>`\n"
|
|
464
|
+
f"查看详情:`!角色路径`\n\n"
|
|
465
|
+
f"💡 把蒸馏好的角色文件传到任意目录,\n"
|
|
466
|
+
f" 然后 add 进来就行,不需要装 dot-skill。"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
parts = arg_text.split(maxsplit=1)
|
|
470
|
+
action = parts[0].lower()
|
|
471
|
+
value = parts[1] if len(parts) > 1 else ""
|
|
472
|
+
|
|
473
|
+
if action == "add":
|
|
474
|
+
if not value:
|
|
475
|
+
await matcher.finish(
|
|
476
|
+
"❌ 请提供目录路径。\n示例:!角色导入 add /data/chars/skills"
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
p = Path(value)
|
|
480
|
+
if not p.exists() or not p.is_dir():
|
|
481
|
+
await matcher.finish(f"❌ 目录不存在:{p}")
|
|
482
|
+
|
|
483
|
+
cfg = get_config()
|
|
484
|
+
existing = cfg.dotcharacter_skills_path
|
|
485
|
+
new_path = str(p)
|
|
486
|
+
if new_path in existing:
|
|
487
|
+
await matcher.finish("⚠️ 路径已在配置中。用 !角色刷新 重新扫描。")
|
|
488
|
+
|
|
489
|
+
cfg.dotcharacter_skills_path = (
|
|
490
|
+
f"{existing},{new_path}" if existing else new_path
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
await matcher.send(f"🔄 扫描 {new_path} ...")
|
|
494
|
+
count = await _reload_characters()
|
|
495
|
+
names = [c.display_name for c in _characters.values()]
|
|
496
|
+
await matcher.finish(
|
|
497
|
+
f"✅ 已添加!共 {count} 个角色:\n"
|
|
498
|
+
+ "\n".join(f" • {n}" for n in names)
|
|
499
|
+
+ f"\n\n💡 永久生效:在 .env 中设置\n"
|
|
500
|
+
+ f" DOTCHARACTER_SKILLS_PATH={cfg.dotcharacter_skills_path}"
|
|
501
|
+
)
|
|
502
|
+
else:
|
|
503
|
+
await matcher.finish(
|
|
504
|
+
f"❌ 未知操作「{action}」。\n用法:`!角色导入 add <路径>`"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ═══════════════════════════════════════════════
|
|
509
|
+
# 命令:模型切换
|
|
510
|
+
# ═══════════════════════════════════════════════
|
|
511
|
+
|
|
512
|
+
cmd_model = on_command(
|
|
513
|
+
"模型切换", aliases={"模型", "model", "切换模型"},
|
|
514
|
+
priority=5, block=True, permission=ADMIN_AND_GROUP,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@cmd_model.handle()
|
|
519
|
+
async def handle_model(matcher: Matcher, event: Event, args: Message = CommandArg()):
|
|
520
|
+
cfg = get_config()
|
|
521
|
+
arg_text = args.extract_plain_text().strip()
|
|
522
|
+
|
|
523
|
+
if not arg_text:
|
|
524
|
+
provider = cfg.dotcharacter_provider
|
|
525
|
+
models = cfg.get_available_models()
|
|
526
|
+
lines = [
|
|
527
|
+
f"⚙️ **当前 LLM 配置**",
|
|
528
|
+
f"├ Provider:`{provider}`",
|
|
529
|
+
f"├ Base URL:`{cfg.get_api_base()}`",
|
|
530
|
+
f"├ Model:`{cfg.dotcharacter_model}`",
|
|
531
|
+
f"└ 可用模型:{', '.join(f'`{m}`' for m in models)}",
|
|
532
|
+
"",
|
|
533
|
+
"切换:`!模型切换 provider <名称>` / `!模型切换 model <名称>`",
|
|
534
|
+
]
|
|
535
|
+
await matcher.finish("\n".join(lines))
|
|
536
|
+
|
|
537
|
+
parts = arg_text.split(maxsplit=1)
|
|
538
|
+
if len(parts) < 2:
|
|
539
|
+
await matcher.finish(
|
|
540
|
+
"用法:\n"
|
|
541
|
+
" !模型切换 provider deepseek\n"
|
|
542
|
+
" !模型切换 model deepseek-chat\n"
|
|
543
|
+
f" 可用 Provider:{', '.join(PROVIDER_PRESETS.keys())}"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
target, value = parts[0].lower(), parts[1].strip()
|
|
547
|
+
|
|
548
|
+
if target == "provider":
|
|
549
|
+
if value not in PROVIDER_PRESETS:
|
|
550
|
+
await matcher.finish(
|
|
551
|
+
f"❌ 未知 Provider「{value}」。\n"
|
|
552
|
+
f"可用:{', '.join(PROVIDER_PRESETS.keys())}"
|
|
553
|
+
)
|
|
554
|
+
cfg.dotcharacter_provider = value
|
|
555
|
+
preset = PROVIDER_PRESETS[value]
|
|
556
|
+
await matcher.finish(
|
|
557
|
+
f"✅ 已切换到 Provider **{value}**\n"
|
|
558
|
+
f" Base URL:`{preset['base_url']}`\n"
|
|
559
|
+
f" 可用模型:{', '.join(f'`{m}`' for m in preset['models'])}\n"
|
|
560
|
+
f"⚠️ 运行时修改,重启后恢复 .env 设置。"
|
|
561
|
+
)
|
|
562
|
+
elif target == "model":
|
|
563
|
+
cfg.dotcharacter_model = value
|
|
564
|
+
await matcher.finish(
|
|
565
|
+
f"✅ 已切换模型为 **{value}**\n"
|
|
566
|
+
f"⚠️ 运行时修改,重启后恢复 .env 设置。"
|
|
567
|
+
)
|
|
568
|
+
else:
|
|
569
|
+
await matcher.finish(
|
|
570
|
+
f"❌ 未知目标「{target}」。请使用 provider 或 model。"
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ═══════════════════════════════════════════════
|
|
575
|
+
# 自由对话(群聊 @机器人 才触发)
|
|
576
|
+
# ═══════════════════════════════════════════════
|
|
577
|
+
|
|
578
|
+
async def _is_in_chat(event: Event) -> bool:
|
|
579
|
+
await _ensure_initialized()
|
|
580
|
+
if not _is_group_allowed(event):
|
|
581
|
+
return False
|
|
582
|
+
if _is_group(event) and not _is_at_bot(event):
|
|
583
|
+
return False
|
|
584
|
+
mgr = get_conversation_manager()
|
|
585
|
+
return mgr.get_active_character(_scope_id(event)) is not None
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
chat_rule = Rule(_is_in_chat)
|
|
589
|
+
chat_matcher = on_message(rule=chat_rule, priority=99, block=False)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@chat_matcher.handle()
|
|
593
|
+
async def handle_chat(matcher: Matcher, event: Event):
|
|
594
|
+
cfg = get_config()
|
|
595
|
+
msg_text = event.get_plaintext().strip()
|
|
596
|
+
if not msg_text:
|
|
597
|
+
await matcher.finish()
|
|
598
|
+
|
|
599
|
+
sid = _scope_id(event)
|
|
600
|
+
user_name = _get_user_name(event)
|
|
601
|
+
mgr = get_conversation_manager()
|
|
602
|
+
slug = mgr.get_active_character(sid)
|
|
603
|
+
|
|
604
|
+
if not slug:
|
|
605
|
+
await matcher.finish()
|
|
606
|
+
|
|
607
|
+
char = _characters.get(slug)
|
|
608
|
+
if not char:
|
|
609
|
+
mgr.clear_active_character(sid)
|
|
610
|
+
await matcher.finish("⚠️ 角色数据丢失,请重新用 !角色切换 选择角色。")
|
|
611
|
+
|
|
612
|
+
session = mgr.get_session(sid, slug)
|
|
613
|
+
messages = [system_msg(char.combined_prompt)]
|
|
614
|
+
for m in session.messages:
|
|
615
|
+
messages.append(m)
|
|
616
|
+
messages.append(user_msg(user_name, msg_text))
|
|
617
|
+
|
|
618
|
+
try:
|
|
619
|
+
reply = await chat_completion(
|
|
620
|
+
cfg, messages, max_tokens=cfg.dotcharacter_max_tokens
|
|
621
|
+
)
|
|
622
|
+
except ValueError as e:
|
|
623
|
+
await matcher.finish(f"❌ {e}")
|
|
624
|
+
except RuntimeError as e:
|
|
625
|
+
logger.error(f"[dotcharacter] LLM 调用失败: {e}")
|
|
626
|
+
await matcher.finish(
|
|
627
|
+
f"😵 {char.display_name} 暂时无法回应...\n(API 错误,请稍后再试)"
|
|
628
|
+
)
|
|
629
|
+
except Exception as e:
|
|
630
|
+
logger.error(f"[dotcharacter] 未知错误: {e}")
|
|
631
|
+
await matcher.finish(f"😵 出了点问题:{type(e).__name__}")
|
|
632
|
+
|
|
633
|
+
session.add_user_message(msg_text)
|
|
634
|
+
session.add_assistant_message(reply)
|
|
635
|
+
session.trim(cfg.dotcharacter_max_history)
|
|
636
|
+
|
|
637
|
+
await matcher.finish(reply)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# ═══════════════════════════════════════════════
|
|
641
|
+
# 插件生命周期
|
|
642
|
+
# ═══════════════════════════════════════════════
|
|
643
|
+
|
|
644
|
+
driver = get_driver()
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
@driver.on_startup
|
|
648
|
+
async def _on_startup():
|
|
649
|
+
logger.info("[dotcharacter] 插件启动中...")
|
|
650
|
+
# 懒加载 localstore(仅在 NoneBot 环境中可用)
|
|
651
|
+
_get_store()
|
|
652
|
+
await _ensure_initialized()
|
|
653
|
+
cfg = get_config()
|
|
654
|
+
admins = cfg.get_admin_qqs()
|
|
655
|
+
groups = cfg.get_allowed_groups()
|
|
656
|
+
roots = cfg.resolve_skills_paths()
|
|
657
|
+
logger.info(
|
|
658
|
+
f"[dotcharacter] 插件就绪,已加载 {len(_characters)} 个角色。"
|
|
659
|
+
f" Provider={cfg.dotcharacter_provider}, Model={cfg.dotcharacter_model}"
|
|
660
|
+
)
|
|
661
|
+
logger.info(f"[dotcharacter] 角色目录 ({len(roots)} 个):")
|
|
662
|
+
for r in roots:
|
|
663
|
+
logger.info(f"[dotcharacter] - {r}")
|
|
664
|
+
if admins:
|
|
665
|
+
logger.info(f"[dotcharacter] 管理员:{admins}")
|
|
666
|
+
if groups:
|
|
667
|
+
logger.info(f"[dotcharacter] 允许的群组:{groups}")
|
|
668
|
+
else:
|
|
669
|
+
logger.info("[dotcharacter] 群组限制:未配置(所有群可用)")
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@driver.on_shutdown
|
|
673
|
+
async def _on_shutdown():
|
|
674
|
+
mgr = get_conversation_manager()
|
|
675
|
+
cleaned = mgr.cleanup_stale(max_age_seconds=0)
|
|
676
|
+
logger.info(f"[dotcharacter] 插件已关闭,清理了 {cleaned} 个会话。")
|