AstrBot 4.12.2__py3-none-any.whl → 4.12.4__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.
Files changed (36) hide show
  1. astrbot/builtin_stars/builtin_commands/commands/__init__.py +0 -2
  2. astrbot/builtin_stars/builtin_commands/commands/persona.py +68 -6
  3. astrbot/builtin_stars/builtin_commands/main.py +0 -26
  4. astrbot/cli/__init__.py +1 -1
  5. astrbot/core/astr_agent_hooks.py +5 -3
  6. astrbot/core/astr_agent_run_util.py +243 -1
  7. astrbot/core/config/default.py +30 -1
  8. astrbot/core/db/__init__.py +91 -1
  9. astrbot/core/db/po.py +42 -0
  10. astrbot/core/db/sqlite.py +230 -0
  11. astrbot/core/persona_mgr.py +154 -2
  12. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +57 -4
  13. astrbot/core/pipeline/process_stage/utils.py +13 -1
  14. astrbot/core/pipeline/waking_check/stage.py +0 -1
  15. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +32 -14
  16. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +61 -2
  17. astrbot/core/platform/sources/dingtalk/dingtalk_event.py +57 -11
  18. astrbot/core/platform/sources/webchat/webchat_adapter.py +1 -0
  19. astrbot/core/platform/sources/webchat/webchat_event.py +24 -0
  20. astrbot/core/provider/manager.py +7 -0
  21. astrbot/core/provider/provider.py +54 -0
  22. astrbot/core/provider/sources/gemini_embedding_source.py +1 -1
  23. astrbot/core/provider/sources/genie_tts.py +128 -0
  24. astrbot/core/provider/sources/openai_embedding_source.py +1 -1
  25. astrbot/core/star/context.py +9 -8
  26. astrbot/core/star/register/star_handler.py +2 -4
  27. astrbot/core/star/star_handler.py +2 -1
  28. astrbot/dashboard/routes/live_chat.py +423 -0
  29. astrbot/dashboard/routes/persona.py +258 -1
  30. astrbot/dashboard/server.py +2 -0
  31. {astrbot-4.12.2.dist-info → astrbot-4.12.4.dist-info}/METADATA +1 -1
  32. {astrbot-4.12.2.dist-info → astrbot-4.12.4.dist-info}/RECORD +35 -34
  33. astrbot/builtin_stars/builtin_commands/commands/tool.py +0 -31
  34. {astrbot-4.12.2.dist-info → astrbot-4.12.4.dist-info}/WHEEL +0 -0
  35. {astrbot-4.12.2.dist-info → astrbot-4.12.4.dist-info}/entry_points.txt +0 -0
  36. {astrbot-4.12.2.dist-info → astrbot-4.12.4.dist-info}/licenses/LICENSE +0 -0
@@ -11,7 +11,6 @@ from .provider import ProviderCommands
11
11
  from .setunset import SetUnsetCommands
12
12
  from .sid import SIDCommand
13
13
  from .t2i import T2ICommand
14
- from .tool import ToolCommands
15
14
  from .tts import TTSCommand
16
15
 
17
16
  __all__ = [
@@ -27,5 +26,4 @@ __all__ = [
27
26
  "SetUnsetCommands",
28
27
  "T2ICommand",
29
28
  "TTSCommand",
30
- "ToolCommands",
31
29
  ]
@@ -1,13 +1,55 @@
1
1
  import builtins
2
+ from typing import TYPE_CHECKING
2
3
 
3
4
  from astrbot.api import sp, star
4
5
  from astrbot.api.event import AstrMessageEvent, MessageEventResult
5
6
 
7
+ if TYPE_CHECKING:
8
+ from astrbot.core.db.po import Persona
9
+
6
10
 
7
11
  class PersonaCommands:
8
12
  def __init__(self, context: star.Context):
9
13
  self.context = context
10
14
 
15
+ def _build_tree_output(
16
+ self,
17
+ folder_tree: list[dict],
18
+ all_personas: list["Persona"],
19
+ depth: int = 0,
20
+ ) -> list[str]:
21
+ """递归构建树状输出,使用短线条表示层级"""
22
+ lines: list[str] = []
23
+ # 使用短线条作为缩进前缀,每层只用 "│" 加一个空格
24
+ prefix = "│ " * depth
25
+
26
+ for folder in folder_tree:
27
+ # 输出文件夹
28
+ lines.append(f"{prefix}├ 📁 {folder['name']}/")
29
+
30
+ # 获取该文件夹下的人格
31
+ folder_personas = [
32
+ p for p in all_personas if p.folder_id == folder["folder_id"]
33
+ ]
34
+ child_prefix = "│ " * (depth + 1)
35
+
36
+ # 输出该文件夹下的人格
37
+ for persona in folder_personas:
38
+ lines.append(f"{child_prefix}├ 👤 {persona.persona_id}")
39
+
40
+ # 递归处理子文件夹
41
+ children = folder.get("children", [])
42
+ if children:
43
+ lines.extend(
44
+ self._build_tree_output(
45
+ children,
46
+ all_personas,
47
+ depth + 1,
48
+ )
49
+ )
50
+
51
+ return lines
52
+
11
53
  async def persona(self, message: AstrMessageEvent):
12
54
  l = message.message_str.split(" ") # noqa: E741
13
55
  umo = message.unified_msg_origin
@@ -69,12 +111,32 @@ class PersonaCommands:
69
111
  .use_t2i(False),
70
112
  )
71
113
  elif l[1] == "list":
72
- parts = ["人格列表:\n"]
73
- for persona in self.context.provider_manager.personas:
74
- parts.append(f"- {persona['name']}\n")
75
- parts.append("\n\n*输入 `/persona view 人格名` 查看人格详细信息")
76
- msg = "".join(parts)
77
- message.set_result(MessageEventResult().message(msg))
114
+ # 获取文件夹树和所有人格
115
+ folder_tree = await self.context.persona_manager.get_folder_tree()
116
+ all_personas = self.context.persona_manager.personas
117
+
118
+ lines = ["📂 人格列表:\n"]
119
+
120
+ # 构建树状输出
121
+ tree_lines = self._build_tree_output(folder_tree, all_personas)
122
+ lines.extend(tree_lines)
123
+
124
+ # 输出根目录下的人格(没有文件夹的)
125
+ root_personas = [p for p in all_personas if p.folder_id is None]
126
+ if root_personas:
127
+ if tree_lines: # 如果有文件夹内容,加个空行
128
+ lines.append("")
129
+ for persona in root_personas:
130
+ lines.append(f"👤 {persona.persona_id}")
131
+
132
+ # 统计信息
133
+ total_count = len(all_personas)
134
+ lines.append(f"\n共 {total_count} 个人格")
135
+ lines.append("\n*使用 `/persona <人格名>` 设置人格")
136
+ lines.append("*使用 `/persona view <人格名>` 查看详细信息")
137
+
138
+ msg = "\n".join(lines)
139
+ message.set_result(MessageEventResult().message(msg).use_t2i(False))
78
140
  elif l[1] == "view":
79
141
  if len(l) == 2:
80
142
  message.set_result(MessageEventResult().message("请输入人格情景名"))
@@ -13,7 +13,6 @@ from .commands import (
13
13
  SetUnsetCommands,
14
14
  SIDCommand,
15
15
  T2ICommand,
16
- ToolCommands,
17
16
  TTSCommand,
18
17
  )
19
18
 
@@ -24,7 +23,6 @@ class Main(star.Star):
24
23
 
25
24
  self.help_c = HelpCommand(self.context)
26
25
  self.llm_c = LLMCommands(self.context)
27
- self.tool_c = ToolCommands(self.context)
28
26
  self.plugin_c = PluginCommands(self.context)
29
27
  self.admin_c = AdminCommands(self.context)
30
28
  self.conversation_c = ConversationCommands(self.context)
@@ -47,30 +45,6 @@ class Main(star.Star):
47
45
  """开启/关闭 LLM"""
48
46
  await self.llm_c.llm(event)
49
47
 
50
- @filter.command_group("tool")
51
- def tool(self):
52
- """函数工具管理"""
53
-
54
- @tool.command("ls")
55
- async def tool_ls(self, event: AstrMessageEvent):
56
- """查看函数工具列表"""
57
- await self.tool_c.tool_ls(event)
58
-
59
- @tool.command("on")
60
- async def tool_on(self, event: AstrMessageEvent, tool_name: str):
61
- """启用一个函数工具"""
62
- await self.tool_c.tool_on(event, tool_name)
63
-
64
- @tool.command("off")
65
- async def tool_off(self, event: AstrMessageEvent, tool_name: str):
66
- """停用一个函数工具"""
67
- await self.tool_c.tool_off(event, tool_name)
68
-
69
- @tool.command("off_all")
70
- async def tool_all_off(self, event: AstrMessageEvent):
71
- """停用所有函数工具"""
72
- await self.tool_c.tool_all_off(event)
73
-
74
48
  @filter.command_group("plugin")
75
49
  def plugin(self):
76
50
  """插件管理"""
astrbot/cli/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "4.12.2"
1
+ __version__ = "4.12.4"
@@ -34,7 +34,7 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
34
34
  ):
35
35
  await call_event_hook(
36
36
  run_context.context.event,
37
- EventType.OnCallingFuncToolEvent,
37
+ EventType.OnUsingLLMToolEvent,
38
38
  tool,
39
39
  tool_args,
40
40
  )
@@ -49,15 +49,17 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
49
49
  run_context.context.event.clear_result()
50
50
  await call_event_hook(
51
51
  run_context.context.event,
52
- EventType.OnAfterCallingFuncToolEvent,
52
+ EventType.OnLLMToolRespondEvent,
53
53
  tool,
54
54
  tool_args,
55
55
  tool_result,
56
56
  )
57
57
 
58
58
  # special handle web_search_tavily
59
+ platform_name = run_context.context.event.get_platform_name()
59
60
  if (
60
- tool.name == "web_search_tavily"
61
+ platform_name == "webchat"
62
+ and tool.name == "web_search_tavily"
61
63
  and len(run_context.messages) > 0
62
64
  and tool_result
63
65
  and len(tool_result.content)
@@ -1,3 +1,6 @@
1
+ import asyncio
2
+ import re
3
+ import time
1
4
  import traceback
2
5
  from collections.abc import AsyncGenerator
3
6
 
@@ -5,13 +8,14 @@ from astrbot.core import logger
5
8
  from astrbot.core.agent.message import Message
6
9
  from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
7
10
  from astrbot.core.astr_agent_context import AstrAgentContext
8
- from astrbot.core.message.components import Json
11
+ from astrbot.core.message.components import BaseMessageComponent, Json, Plain
9
12
  from astrbot.core.message.message_event_result import (
10
13
  MessageChain,
11
14
  MessageEventResult,
12
15
  ResultContentType,
13
16
  )
14
17
  from astrbot.core.provider.entities import LLMResponse
18
+ from astrbot.core.provider.provider import TTSProvider
15
19
 
16
20
  AgentRunner = ToolLoopAgentRunner[AstrAgentContext]
17
21
 
@@ -131,3 +135,241 @@ async def run_agent(
131
135
  else:
132
136
  astr_event.set_result(MessageEventResult().message(err_msg))
133
137
  return
138
+
139
+
140
+ async def run_live_agent(
141
+ agent_runner: AgentRunner,
142
+ tts_provider: TTSProvider | None = None,
143
+ max_step: int = 30,
144
+ show_tool_use: bool = True,
145
+ show_reasoning: bool = False,
146
+ ) -> AsyncGenerator[MessageChain | None, None]:
147
+ """Live Mode 的 Agent 运行器,支持流式 TTS
148
+
149
+ Args:
150
+ agent_runner: Agent 运行器
151
+ tts_provider: TTS Provider 实例
152
+ max_step: 最大步数
153
+ show_tool_use: 是否显示工具使用
154
+ show_reasoning: 是否显示推理过程
155
+
156
+ Yields:
157
+ MessageChain: 包含文本或音频数据的消息链
158
+ """
159
+ # 如果没有 TTS Provider,直接发送文本
160
+ if not tts_provider:
161
+ async for chain in run_agent(
162
+ agent_runner,
163
+ max_step=max_step,
164
+ show_tool_use=show_tool_use,
165
+ stream_to_general=False,
166
+ show_reasoning=show_reasoning,
167
+ ):
168
+ yield chain
169
+ return
170
+
171
+ support_stream = tts_provider.support_stream()
172
+ if support_stream:
173
+ logger.info("[Live Agent] 使用流式 TTS(原生支持 get_audio_stream)")
174
+ else:
175
+ logger.info(
176
+ f"[Live Agent] 使用 TTS({tts_provider.meta().type} "
177
+ "使用 get_audio,将按句子分块生成音频)"
178
+ )
179
+
180
+ # 统计数据初始化
181
+ tts_start_time = time.time()
182
+ tts_first_frame_time = 0.0
183
+ first_chunk_received = False
184
+
185
+ # 创建队列
186
+ text_queue: asyncio.Queue[str | None] = asyncio.Queue()
187
+ # audio_queue stored bytes or (text, bytes)
188
+ audio_queue: asyncio.Queue[bytes | tuple[str, bytes] | None] = asyncio.Queue()
189
+
190
+ # 1. 启动 Agent Feeder 任务:负责运行 Agent 并将文本分句喂给 text_queue
191
+ feeder_task = asyncio.create_task(
192
+ _run_agent_feeder(
193
+ agent_runner, text_queue, max_step, show_tool_use, show_reasoning
194
+ )
195
+ )
196
+
197
+ # 2. 启动 TTS 任务:负责从 text_queue 读取文本并生成音频到 audio_queue
198
+ if support_stream:
199
+ tts_task = asyncio.create_task(
200
+ _safe_tts_stream_wrapper(tts_provider, text_queue, audio_queue)
201
+ )
202
+ else:
203
+ tts_task = asyncio.create_task(
204
+ _simulated_stream_tts(tts_provider, text_queue, audio_queue)
205
+ )
206
+
207
+ # 3. 主循环:从 audio_queue 读取音频并 yield
208
+ try:
209
+ while True:
210
+ queue_item = await audio_queue.get()
211
+
212
+ if queue_item is None:
213
+ break
214
+
215
+ text = None
216
+ if isinstance(queue_item, tuple):
217
+ text, audio_data = queue_item
218
+ else:
219
+ audio_data = queue_item
220
+
221
+ if not first_chunk_received:
222
+ # 记录首帧延迟(从开始处理到收到第一个音频块)
223
+ tts_first_frame_time = time.time() - tts_start_time
224
+ first_chunk_received = True
225
+
226
+ # 将音频数据封装为 MessageChain
227
+ import base64
228
+
229
+ audio_b64 = base64.b64encode(audio_data).decode("utf-8")
230
+ comps: list[BaseMessageComponent] = [Plain(audio_b64)]
231
+ if text:
232
+ comps.append(Json(data={"text": text}))
233
+ chain = MessageChain(chain=comps, type="audio_chunk")
234
+ yield chain
235
+
236
+ except Exception as e:
237
+ logger.error(f"[Live Agent] 运行时发生错误: {e}", exc_info=True)
238
+ finally:
239
+ # 清理任务
240
+ if not feeder_task.done():
241
+ feeder_task.cancel()
242
+ if not tts_task.done():
243
+ tts_task.cancel()
244
+
245
+ # 确保队列被消费
246
+ pass
247
+
248
+ tts_end_time = time.time()
249
+
250
+ # 发送 TTS 统计信息
251
+ try:
252
+ astr_event = agent_runner.run_context.context.event
253
+ if astr_event.get_platform_name() == "webchat":
254
+ tts_duration = tts_end_time - tts_start_time
255
+ await astr_event.send(
256
+ MessageChain(
257
+ type="tts_stats",
258
+ chain=[
259
+ Json(
260
+ data={
261
+ "tts_total_time": tts_duration,
262
+ "tts_first_frame_time": tts_first_frame_time,
263
+ "tts": tts_provider.meta().type,
264
+ "chat_model": agent_runner.provider.get_model(),
265
+ }
266
+ )
267
+ ],
268
+ )
269
+ )
270
+ except Exception as e:
271
+ logger.error(f"发送 TTS 统计信息失败: {e}")
272
+
273
+
274
+ async def _run_agent_feeder(
275
+ agent_runner: AgentRunner,
276
+ text_queue: asyncio.Queue,
277
+ max_step: int,
278
+ show_tool_use: bool,
279
+ show_reasoning: bool,
280
+ ):
281
+ """运行 Agent 并将文本输出分句放入队列"""
282
+ buffer = ""
283
+ try:
284
+ async for chain in run_agent(
285
+ agent_runner,
286
+ max_step=max_step,
287
+ show_tool_use=show_tool_use,
288
+ stream_to_general=False,
289
+ show_reasoning=show_reasoning,
290
+ ):
291
+ if chain is None:
292
+ continue
293
+
294
+ # 提取文本
295
+ text = chain.get_plain_text()
296
+ if text:
297
+ buffer += text
298
+
299
+ # 分句逻辑:匹配标点符号
300
+ # r"([.。!!??\n]+)" 会保留分隔符
301
+ parts = re.split(r"([.。!!??\n]+)", buffer)
302
+
303
+ if len(parts) > 1:
304
+ # 处理完整的句子
305
+ # range step 2 因为 split 后是 [text, delim, text, delim, ...]
306
+ temp_buffer = ""
307
+ for i in range(0, len(parts) - 1, 2):
308
+ sentence = parts[i]
309
+ delim = parts[i + 1]
310
+ full_sentence = sentence + delim
311
+ temp_buffer += full_sentence
312
+
313
+ if len(temp_buffer) >= 10:
314
+ if temp_buffer.strip():
315
+ logger.info(f"[Live Agent Feeder] 分句: {temp_buffer}")
316
+ await text_queue.put(temp_buffer)
317
+ temp_buffer = ""
318
+
319
+ # 更新 buffer 为剩余部分
320
+ buffer = temp_buffer + parts[-1]
321
+
322
+ # 处理剩余 buffer
323
+ if buffer.strip():
324
+ await text_queue.put(buffer)
325
+
326
+ except Exception as e:
327
+ logger.error(f"[Live Agent Feeder] Error: {e}", exc_info=True)
328
+ finally:
329
+ # 发送结束信号
330
+ await text_queue.put(None)
331
+
332
+
333
+ async def _safe_tts_stream_wrapper(
334
+ tts_provider: TTSProvider,
335
+ text_queue: asyncio.Queue[str | None],
336
+ audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
337
+ ):
338
+ """包装原生流式 TTS 确保异常处理和队列关闭"""
339
+ try:
340
+ await tts_provider.get_audio_stream(text_queue, audio_queue)
341
+ except Exception as e:
342
+ logger.error(f"[Live TTS Stream] Error: {e}", exc_info=True)
343
+ finally:
344
+ await audio_queue.put(None)
345
+
346
+
347
+ async def _simulated_stream_tts(
348
+ tts_provider: TTSProvider,
349
+ text_queue: asyncio.Queue[str | None],
350
+ audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
351
+ ):
352
+ """模拟流式 TTS 分句生成音频"""
353
+ try:
354
+ while True:
355
+ text = await text_queue.get()
356
+ if text is None:
357
+ break
358
+
359
+ try:
360
+ audio_path = await tts_provider.get_audio(text)
361
+
362
+ if audio_path:
363
+ with open(audio_path, "rb") as f:
364
+ audio_data = f.read()
365
+ await audio_queue.put((text, audio_data))
366
+ except Exception as e:
367
+ logger.error(
368
+ f"[Live TTS Simulated] Error processing text '{text[:20]}...': {e}"
369
+ )
370
+ # 继续处理下一句
371
+
372
+ except Exception as e:
373
+ logger.error(f"[Live TTS Simulated] Critical Error: {e}", exc_info=True)
374
+ finally:
375
+ await audio_queue.put(None)
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
5
5
 
6
6
  from astrbot.core.utils.astrbot_path import get_astrbot_data_path
7
7
 
8
- VERSION = "4.12.2"
8
+ VERSION = "4.12.4"
9
9
  DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
10
10
 
11
11
  WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -321,6 +321,7 @@ CONFIG_METADATA_2 = {
321
321
  "enable": False,
322
322
  "client_id": "",
323
323
  "client_secret": "",
324
+ "card_template_id": "",
324
325
  },
325
326
  "Telegram": {
326
327
  "id": "telegram",
@@ -582,6 +583,11 @@ CONFIG_METADATA_2 = {
582
583
  "type": "string",
583
584
  "hint": "可选:填写 Misskey 网盘中目标文件夹的 ID,上传的文件将放置到该文件夹内。留空则使用账号网盘根目录。",
584
585
  },
586
+ "card_template_id": {
587
+ "description": "卡片模板 ID",
588
+ "type": "string",
589
+ "hint": "可选。钉钉互动卡片模板 ID。启用后将使用互动卡片进行流式回复。",
590
+ },
585
591
  "telegram_command_register": {
586
592
  "description": "Telegram 命令注册",
587
593
  "type": "bool",
@@ -1179,6 +1185,19 @@ CONFIG_METADATA_2 = {
1179
1185
  "openai-tts-voice": "alloy",
1180
1186
  "timeout": "20",
1181
1187
  },
1188
+ "Genie TTS": {
1189
+ "id": "genie_tts",
1190
+ "provider": "genie_tts",
1191
+ "type": "genie_tts",
1192
+ "provider_type": "text_to_speech",
1193
+ "enable": False,
1194
+ "genie_character_name": "mika",
1195
+ "genie_onnx_model_dir": "CharacterModels/v2ProPlus/mika/tts_models",
1196
+ "genie_language": "Japanese",
1197
+ "genie_refer_audio_path": "",
1198
+ "genie_refer_text": "",
1199
+ "timeout": 20,
1200
+ },
1182
1201
  "Edge TTS": {
1183
1202
  "id": "edge_tts",
1184
1203
  "provider": "microsoft",
@@ -1395,6 +1414,16 @@ CONFIG_METADATA_2 = {
1395
1414
  },
1396
1415
  },
1397
1416
  "items": {
1417
+ "genie_onnx_model_dir": {
1418
+ "description": "ONNX Model Directory",
1419
+ "type": "string",
1420
+ "hint": "The directory path containing the ONNX model files",
1421
+ },
1422
+ "genie_language": {
1423
+ "description": "Language",
1424
+ "type": "string",
1425
+ "options": ["Japanese", "English", "Chinese"],
1426
+ },
1398
1427
  "provider_source_id": {
1399
1428
  "invisible": True,
1400
1429
  "type": "string",
@@ -14,6 +14,7 @@ from astrbot.core.db.po import (
14
14
  CommandConflict,
15
15
  ConversationV2,
16
16
  Persona,
17
+ PersonaFolder,
17
18
  PlatformMessageHistory,
18
19
  PlatformSession,
19
20
  PlatformStat,
@@ -253,8 +254,19 @@ class BaseDatabase(abc.ABC):
253
254
  system_prompt: str,
254
255
  begin_dialogs: list[str] | None = None,
255
256
  tools: list[str] | None = None,
257
+ folder_id: str | None = None,
258
+ sort_order: int = 0,
256
259
  ) -> Persona:
257
- """Insert a new persona record."""
260
+ """Insert a new persona record.
261
+
262
+ Args:
263
+ persona_id: Unique identifier for the persona
264
+ system_prompt: System prompt for the persona
265
+ begin_dialogs: Optional list of initial dialog strings
266
+ tools: Optional list of tool names (None means all tools, [] means no tools)
267
+ folder_id: Optional folder ID to place the persona in (None means root)
268
+ sort_order: Sort order within the folder (default 0)
269
+ """
258
270
  ...
259
271
 
260
272
  @abc.abstractmethod
@@ -283,6 +295,84 @@ class BaseDatabase(abc.ABC):
283
295
  """Delete a persona by its ID."""
284
296
  ...
285
297
 
298
+ # ====
299
+ # Persona Folder Management
300
+ # ====
301
+
302
+ @abc.abstractmethod
303
+ async def insert_persona_folder(
304
+ self,
305
+ name: str,
306
+ parent_id: str | None = None,
307
+ description: str | None = None,
308
+ sort_order: int = 0,
309
+ ) -> PersonaFolder:
310
+ """Insert a new persona folder."""
311
+ ...
312
+
313
+ @abc.abstractmethod
314
+ async def get_persona_folder_by_id(self, folder_id: str) -> PersonaFolder | None:
315
+ """Get a persona folder by its folder_id."""
316
+ ...
317
+
318
+ @abc.abstractmethod
319
+ async def get_persona_folders(
320
+ self, parent_id: str | None = None
321
+ ) -> list[PersonaFolder]:
322
+ """Get all persona folders, optionally filtered by parent_id."""
323
+ ...
324
+
325
+ @abc.abstractmethod
326
+ async def get_all_persona_folders(self) -> list[PersonaFolder]:
327
+ """Get all persona folders."""
328
+ ...
329
+
330
+ @abc.abstractmethod
331
+ async def update_persona_folder(
332
+ self,
333
+ folder_id: str,
334
+ name: str | None = None,
335
+ parent_id: T.Any = None,
336
+ description: T.Any = None,
337
+ sort_order: int | None = None,
338
+ ) -> PersonaFolder | None:
339
+ """Update a persona folder."""
340
+ ...
341
+
342
+ @abc.abstractmethod
343
+ async def delete_persona_folder(self, folder_id: str) -> None:
344
+ """Delete a persona folder by its folder_id."""
345
+ ...
346
+
347
+ @abc.abstractmethod
348
+ async def move_persona_to_folder(
349
+ self, persona_id: str, folder_id: str | None
350
+ ) -> Persona | None:
351
+ """Move a persona to a folder (or root if folder_id is None)."""
352
+ ...
353
+
354
+ @abc.abstractmethod
355
+ async def get_personas_by_folder(
356
+ self, folder_id: str | None = None
357
+ ) -> list[Persona]:
358
+ """Get all personas in a specific folder."""
359
+ ...
360
+
361
+ @abc.abstractmethod
362
+ async def batch_update_sort_order(
363
+ self,
364
+ items: list[dict],
365
+ ) -> None:
366
+ """Batch update sort_order for personas and/or folders.
367
+
368
+ Args:
369
+ items: List of dicts with keys:
370
+ - id: The persona_id or folder_id
371
+ - type: Either "persona" or "folder"
372
+ - sort_order: The new sort_order value
373
+ """
374
+ ...
375
+
286
376
  @abc.abstractmethod
287
377
  async def insert_preference_or_update(
288
378
  self,
astrbot/core/db/po.py CHANGED
@@ -68,6 +68,44 @@ class ConversationV2(SQLModel, table=True):
68
68
  )
69
69
 
70
70
 
71
+ class PersonaFolder(SQLModel, table=True):
72
+ """Persona 文件夹,支持递归层级结构。
73
+
74
+ 用于组织和管理多个 Persona,类似于文件系统的目录结构。
75
+ """
76
+
77
+ __tablename__: str = "persona_folders"
78
+
79
+ id: int | None = Field(
80
+ primary_key=True,
81
+ sa_column_kwargs={"autoincrement": True},
82
+ default=None,
83
+ )
84
+ folder_id: str = Field(
85
+ max_length=36,
86
+ nullable=False,
87
+ unique=True,
88
+ default_factory=lambda: str(uuid.uuid4()),
89
+ )
90
+ name: str = Field(max_length=255, nullable=False)
91
+ parent_id: str | None = Field(default=None, max_length=36)
92
+ """父文件夹ID,NULL表示根目录"""
93
+ description: str | None = Field(default=None, sa_type=Text)
94
+ sort_order: int = Field(default=0)
95
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
96
+ updated_at: datetime = Field(
97
+ default_factory=lambda: datetime.now(timezone.utc),
98
+ sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
99
+ )
100
+
101
+ __table_args__ = (
102
+ UniqueConstraint(
103
+ "folder_id",
104
+ name="uix_persona_folder_id",
105
+ ),
106
+ )
107
+
108
+
71
109
  class Persona(SQLModel, table=True):
72
110
  """Persona is a set of instructions for LLMs to follow.
73
111
 
@@ -87,6 +125,10 @@ class Persona(SQLModel, table=True):
87
125
  """a list of strings, each representing a dialog to start with"""
88
126
  tools: list | None = Field(default=None, sa_type=JSON)
89
127
  """None means use ALL tools for default, empty list means no tools, otherwise a list of tool names."""
128
+ folder_id: str | None = Field(default=None, max_length=36)
129
+ """所属文件夹ID,NULL 表示在根目录"""
130
+ sort_order: int = Field(default=0)
131
+ """排序顺序"""
90
132
  created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
91
133
  updated_at: datetime = Field(
92
134
  default_factory=lambda: datetime.now(timezone.utc),