From ba0935dcafa795f1134d7547e6e680b95560bea2 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 12 Jan 2026 09:12:41 +0800 Subject: [PATCH 01/16] feat(db): add persona folder management for hierarchical organization Implement hierarchical folder structure for organizing personas: - Add PersonaFolder model with recursive parent-child relationships - Add folder_id and sort_order fields to Persona model - Implement CRUD operations for persona folders in database layer - Add migration support for existing databases - Extend PersonaManager with folder management methods - Add dashboard API routes for folder operations --- astrbot/core/db/__init__.py | 64 ++++++++++ astrbot/core/db/po.py | 42 +++++++ astrbot/core/db/sqlite.py | 173 ++++++++++++++++++++++++++++ astrbot/core/persona_mgr.py | 127 +++++++++++++++++++- astrbot/dashboard/routes/persona.py | 170 ++++++++++++++++++++++++++- 5 files changed, 574 insertions(+), 2 deletions(-) diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 3a79e41c2..391b5e2f6 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -13,6 +13,7 @@ CommandConflict, ConversationV2, Persona, + PersonaFolder, PlatformMessageHistory, PlatformSession, PlatformStat, @@ -281,6 +282,69 @@ async def delete_persona(self, persona_id: str) -> None: """Delete a persona by its ID.""" ... + # ==== + # Persona Folder Management + # ==== + + @abc.abstractmethod + async def insert_persona_folder( + self, + name: str, + parent_id: str | None = None, + description: str | None = None, + sort_order: int = 0, + ) -> PersonaFolder: + """Insert a new persona folder.""" + ... + + @abc.abstractmethod + async def get_persona_folder_by_id(self, folder_id: str) -> PersonaFolder | None: + """Get a persona folder by its folder_id.""" + ... + + @abc.abstractmethod + async def get_persona_folders( + self, parent_id: str | None = None + ) -> list[PersonaFolder]: + """Get all persona folders, optionally filtered by parent_id.""" + ... + + @abc.abstractmethod + async def get_all_persona_folders(self) -> list[PersonaFolder]: + """Get all persona folders.""" + ... + + @abc.abstractmethod + async def update_persona_folder( + self, + folder_id: str, + name: str | None = None, + parent_id: T.Any = None, + description: T.Any = None, + sort_order: int | None = None, + ) -> PersonaFolder | None: + """Update a persona folder.""" + ... + + @abc.abstractmethod + async def delete_persona_folder(self, folder_id: str) -> None: + """Delete a persona folder by its folder_id.""" + ... + + @abc.abstractmethod + async def move_persona_to_folder( + self, persona_id: str, folder_id: str | None + ) -> Persona | None: + """Move a persona to a folder (or root if folder_id is None).""" + ... + + @abc.abstractmethod + async def get_personas_by_folder( + self, folder_id: str | None = None + ) -> list[Persona]: + """Get all personas in a specific folder.""" + ... + @abc.abstractmethod async def insert_preference_or_update( self, diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index fdbf4aff3..5b7f6ba3d 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -68,6 +68,44 @@ class ConversationV2(SQLModel, table=True): ) +class PersonaFolder(SQLModel, table=True): + """Persona 文件夹,支持递归层级结构。 + + 用于组织和管理多个 Persona,类似于文件系统的目录结构。 + """ + + __tablename__: str = "persona_folders" + + id: int | None = Field( + primary_key=True, + sa_column_kwargs={"autoincrement": True}, + default=None, + ) + folder_id: str = Field( + max_length=36, + nullable=False, + unique=True, + default_factory=lambda: str(uuid.uuid4()), + ) + name: str = Field(max_length=255, nullable=False) + parent_id: str | None = Field(default=None, max_length=36) + """父文件夹ID,NULL表示根目录""" + description: str | None = Field(default=None, sa_type=Text) + sort_order: int = Field(default=0) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + + __table_args__ = ( + UniqueConstraint( + "folder_id", + name="uix_persona_folder_id", + ), + ) + + class Persona(SQLModel, table=True): """Persona is a set of instructions for LLMs to follow. @@ -87,6 +125,10 @@ class Persona(SQLModel, table=True): """a list of strings, each representing a dialog to start with""" tools: list | None = Field(default=None, sa_type=JSON) """None means use ALL tools for default, empty list means no tools, otherwise a list of tool names.""" + folder_id: str | None = Field(default=None, max_length=36) + """所属文件夹ID,NULL 表示在根目录""" + sort_order: int = Field(default=0) + """排序顺序""" created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index 7422a5cc2..ea2acebad 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -15,6 +15,7 @@ CommandConflict, ConversationV2, Persona, + PersonaFolder, PlatformMessageHistory, PlatformSession, PlatformStat, @@ -49,8 +50,28 @@ async def initialize(self) -> None: await conn.execute(text("PRAGMA temp_store=MEMORY")) await conn.execute(text("PRAGMA mmap_size=134217728")) await conn.execute(text("PRAGMA optimize")) + # 确保 personas 表有 folder_id 和 sort_order 列(前向兼容) + await self._ensure_persona_folder_columns(conn) await conn.commit() + async def _ensure_persona_folder_columns(self, conn) -> None: + """确保 personas 表有 folder_id 和 sort_order 列。 + + 这是为了支持旧版数据库的平滑升级。新版数据库通过 SQLModel + 的 metadata.create_all 自动创建这些列。 + """ + result = await conn.execute(text("PRAGMA table_info(personas)")) + columns = {row[1] for row in result.fetchall()} + + if "folder_id" not in columns: + await conn.execute( + text("ALTER TABLE personas ADD COLUMN folder_id VARCHAR(36) DEFAULT NULL") + ) + if "sort_order" not in columns: + await conn.execute( + text("ALTER TABLE personas ADD COLUMN sort_order INTEGER DEFAULT 0") + ) + # ==== # Platform Statistics # ==== @@ -603,6 +624,158 @@ async def delete_persona(self, persona_id): delete(Persona).where(col(Persona.persona_id) == persona_id), ) + # ==== + # Persona Folder Management + # ==== + + async def insert_persona_folder( + self, + name: str, + parent_id: str | None = None, + description: str | None = None, + sort_order: int = 0, + ) -> PersonaFolder: + """Insert a new persona folder.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + new_folder = PersonaFolder( + name=name, + parent_id=parent_id, + description=description, + sort_order=sort_order, + ) + session.add(new_folder) + await session.flush() + await session.refresh(new_folder) + return new_folder + + async def get_persona_folder_by_id(self, folder_id: str) -> PersonaFolder | None: + """Get a persona folder by its folder_id.""" + async with self.get_db() as session: + session: AsyncSession + query = select(PersonaFolder).where(PersonaFolder.folder_id == folder_id) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def get_persona_folders(self, parent_id: str | None = None) -> list[PersonaFolder]: + """Get all persona folders, optionally filtered by parent_id. + + Args: + parent_id: If None, returns root folders only. If specified, returns + children of that folder. + """ + async with self.get_db() as session: + session: AsyncSession + if parent_id is None: + # Get root folders (parent_id is NULL) + query = select(PersonaFolder).where( + col(PersonaFolder.parent_id).is_(None) + ).order_by(col(PersonaFolder.sort_order), col(PersonaFolder.name)) + else: + query = select(PersonaFolder).where( + PersonaFolder.parent_id == parent_id + ).order_by(col(PersonaFolder.sort_order), col(PersonaFolder.name)) + result = await session.execute(query) + return list(result.scalars().all()) + + async def get_all_persona_folders(self) -> list[PersonaFolder]: + """Get all persona folders.""" + async with self.get_db() as session: + session: AsyncSession + query = select(PersonaFolder).order_by( + col(PersonaFolder.sort_order), col(PersonaFolder.name) + ) + result = await session.execute(query) + return list(result.scalars().all()) + + async def update_persona_folder( + self, + folder_id: str, + name: str | None = None, + parent_id: T.Any = NOT_GIVEN, + description: T.Any = NOT_GIVEN, + sort_order: int | None = None, + ) -> PersonaFolder | None: + """Update a persona folder.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + query = update(PersonaFolder).where( + col(PersonaFolder.folder_id) == folder_id + ) + values: dict[str, T.Any] = {} + if name is not None: + values["name"] = name + if parent_id is not NOT_GIVEN: + values["parent_id"] = parent_id + if description is not NOT_GIVEN: + values["description"] = description + if sort_order is not None: + values["sort_order"] = sort_order + if not values: + return None + query = query.values(**values) + await session.execute(query) + return await self.get_persona_folder_by_id(folder_id) + + async def delete_persona_folder(self, folder_id: str) -> None: + """Delete a persona folder by its folder_id. + + Note: This will also set folder_id to NULL for all personas in this folder, + moving them to the root directory. + """ + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + # Move personas to root directory + await session.execute( + update(Persona) + .where(col(Persona.folder_id) == folder_id) + .values(folder_id=None) + ) + # Delete the folder + await session.execute( + delete(PersonaFolder).where( + col(PersonaFolder.folder_id) == folder_id + ), + ) + + async def move_persona_to_folder( + self, persona_id: str, folder_id: str | None + ) -> Persona | None: + """Move a persona to a folder (or root if folder_id is None).""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + update(Persona) + .where(col(Persona.persona_id) == persona_id) + .values(folder_id=folder_id) + ) + return await self.get_persona_by_id(persona_id) + + async def get_personas_by_folder( + self, folder_id: str | None = None + ) -> list[Persona]: + """Get all personas in a specific folder. + + Args: + folder_id: If None, returns personas in root directory. + """ + async with self.get_db() as session: + session: AsyncSession + if folder_id is None: + query = select(Persona).where( + col(Persona.folder_id).is_(None) + ).order_by(col(Persona.sort_order), col(Persona.persona_id)) + else: + query = select(Persona).where( + Persona.folder_id == folder_id + ).order_by(col(Persona.sort_order), col(Persona.persona_id)) + result = await session.execute(query) + return list(result.scalars().all()) + async def insert_preference_or_update(self, scope, scope_id, key, value): """Insert a new preference record or update if it exists.""" async with self.get_db() as session: diff --git a/astrbot/core/persona_mgr.py b/astrbot/core/persona_mgr.py index b2d2c6be1..540501c73 100644 --- a/astrbot/core/persona_mgr.py +++ b/astrbot/core/persona_mgr.py @@ -1,7 +1,7 @@ from astrbot import logger from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.db import BaseDatabase -from astrbot.core.db.po import Persona, Personality +from astrbot.core.db.po import Persona, PersonaFolder, Personality from astrbot.core.platform.message_session import MessageSession DEFAULT_PERSONALITY = Personality( @@ -94,6 +94,131 @@ async def get_all_personas(self) -> list[Persona]: """获取所有 personas""" return await self.db.get_personas() + async def get_personas_by_folder( + self, folder_id: str | None = None + ) -> list[Persona]: + """获取指定文件夹中的 personas + + Args: + folder_id: 文件夹 ID,None 表示根目录 + """ + return await self.db.get_personas_by_folder(folder_id) + + async def move_persona_to_folder( + self, persona_id: str, folder_id: str | None + ) -> Persona | None: + """移动 persona 到指定文件夹 + + Args: + persona_id: Persona ID + folder_id: 目标文件夹 ID,None 表示移动到根目录 + """ + persona = await self.db.move_persona_to_folder(persona_id, folder_id) + if persona: + for i, p in enumerate(self.personas): + if p.persona_id == persona_id: + self.personas[i] = persona + break + return persona + + # ==== + # Persona Folder Management + # ==== + + async def create_folder( + self, + name: str, + parent_id: str | None = None, + description: str | None = None, + sort_order: int = 0, + ) -> PersonaFolder: + """创建新的文件夹""" + return await self.db.insert_persona_folder( + name=name, + parent_id=parent_id, + description=description, + sort_order=sort_order, + ) + + async def get_folder(self, folder_id: str) -> PersonaFolder | None: + """获取指定文件夹""" + return await self.db.get_persona_folder_by_id(folder_id) + + async def get_folders(self, parent_id: str | None = None) -> list[PersonaFolder]: + """获取文件夹列表 + + Args: + parent_id: 父文件夹 ID,None 表示获取根目录下的文件夹 + """ + return await self.db.get_persona_folders(parent_id) + + async def get_all_folders(self) -> list[PersonaFolder]: + """获取所有文件夹""" + return await self.db.get_all_persona_folders() + + async def update_folder( + self, + folder_id: str, + name: str | None = None, + parent_id: str | None = None, + description: str | None = None, + sort_order: int | None = None, + ) -> PersonaFolder | None: + """更新文件夹信息""" + return await self.db.update_persona_folder( + folder_id=folder_id, + name=name, + parent_id=parent_id, + description=description, + sort_order=sort_order, + ) + + async def delete_folder(self, folder_id: str) -> None: + """删除文件夹 + + Note: 文件夹内的 personas 会被移动到根目录 + """ + await self.db.delete_persona_folder(folder_id) + + async def get_folder_tree(self) -> list[dict]: + """获取文件夹树形结构 + + Returns: + 树形结构的文件夹列表,每个文件夹包含 children 子列表 + """ + all_folders = await self.get_all_folders() + folder_map: dict[str, dict] = {} + + # 创建文件夹字典 + for folder in all_folders: + folder_map[folder.folder_id] = { + "folder_id": folder.folder_id, + "name": folder.name, + "parent_id": folder.parent_id, + "description": folder.description, + "sort_order": folder.sort_order, + "children": [], + } + + # 构建树形结构 + root_folders = [] + for folder_id, folder_data in folder_map.items(): + parent_id = folder_data["parent_id"] + if parent_id is None: + root_folders.append(folder_data) + elif parent_id in folder_map: + folder_map[parent_id]["children"].append(folder_data) + + # 递归排序 + def sort_folders(folders: list[dict]) -> list[dict]: + folders.sort(key=lambda f: (f["sort_order"], f["name"])) + for folder in folders: + if folder["children"]: + folder["children"] = sort_folders(folder["children"]) + return folders + + return sort_folders(root_folders) + async def create_persona( self, persona_id: str, diff --git a/astrbot/dashboard/routes/persona.py b/astrbot/dashboard/routes/persona.py index 7ddb75f17..6b2538157 100644 --- a/astrbot/dashboard/routes/persona.py +++ b/astrbot/dashboard/routes/persona.py @@ -23,6 +23,13 @@ def __init__( "/persona/create": ("POST", self.create_persona), "/persona/update": ("POST", self.update_persona), "/persona/delete": ("POST", self.delete_persona), + "/persona/move": ("POST", self.move_persona), + # Folder routes + "/persona/folder/list": ("GET", self.list_folders), + "/persona/folder/tree": ("GET", self.get_folder_tree), + "/persona/folder/create": ("POST", self.create_folder), + "/persona/folder/update": ("POST", self.update_folder), + "/persona/folder/delete": ("POST", self.delete_folder), } self.db_helper = db_helper self.persona_mgr = core_lifecycle.persona_mgr @@ -31,7 +38,14 @@ def __init__( async def list_personas(self): """获取所有人格列表""" try: - personas = await self.persona_mgr.get_all_personas() + # 支持按文件夹筛选 + folder_id = request.args.get("folder_id") + if folder_id is not None: + personas = await self.persona_mgr.get_personas_by_folder( + folder_id if folder_id else None + ) + else: + personas = await self.persona_mgr.get_all_personas() return ( Response() .ok( @@ -41,6 +55,8 @@ async def list_personas(self): "system_prompt": persona.system_prompt, "begin_dialogs": persona.begin_dialogs or [], "tools": persona.tools, + "folder_id": persona.folder_id, + "sort_order": persona.sort_order, "created_at": persona.created_at.isoformat() if persona.created_at else None, @@ -78,6 +94,8 @@ async def get_persona_detail(self): "system_prompt": persona.system_prompt, "begin_dialogs": persona.begin_dialogs or [], "tools": persona.tools, + "folder_id": persona.folder_id, + "sort_order": persona.sort_order, "created_at": persona.created_at.isoformat() if persona.created_at else None, @@ -200,3 +218,153 @@ async def delete_persona(self): except Exception as e: logger.error(f"删除人格失败: {e!s}\n{traceback.format_exc()}") return Response().error(f"删除人格失败: {e!s}").__dict__ + + async def move_persona(self): + """移动人格到指定文件夹""" + try: + data = await request.get_json() + persona_id = data.get("persona_id") + folder_id = data.get("folder_id") # None 表示移动到根目录 + + if not persona_id: + return Response().error("缺少必要参数: persona_id").__dict__ + + await self.persona_mgr.move_persona_to_folder(persona_id, folder_id) + + return Response().ok({"message": "人格移动成功"}).__dict__ + except ValueError as e: + return Response().error(str(e)).__dict__ + except Exception as e: + logger.error(f"移动人格失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"移动人格失败: {e!s}").__dict__ + + # ==== + # Folder Routes + # ==== + + async def list_folders(self): + """获取文件夹列表""" + try: + parent_id = request.args.get("parent_id") + folders = await self.persona_mgr.get_folders(parent_id) + return ( + Response() + .ok( + [ + { + "folder_id": folder.folder_id, + "name": folder.name, + "parent_id": folder.parent_id, + "description": folder.description, + "sort_order": folder.sort_order, + "created_at": folder.created_at.isoformat() + if folder.created_at + else None, + "updated_at": folder.updated_at.isoformat() + if folder.updated_at + else None, + } + for folder in folders + ], + ) + .__dict__ + ) + except Exception as e: + logger.error(f"获取文件夹列表失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"获取文件夹列表失败: {e!s}").__dict__ + + async def get_folder_tree(self): + """获取文件夹树形结构""" + try: + tree = await self.persona_mgr.get_folder_tree() + return Response().ok(tree).__dict__ + except Exception as e: + logger.error(f"获取文件夹树失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"获取文件夹树失败: {e!s}").__dict__ + + async def create_folder(self): + """创建文件夹""" + try: + data = await request.get_json() + name = data.get("name", "").strip() + parent_id = data.get("parent_id") + description = data.get("description") + sort_order = data.get("sort_order", 0) + + if not name: + return Response().error("文件夹名称不能为空").__dict__ + + folder = await self.persona_mgr.create_folder( + name=name, + parent_id=parent_id, + description=description, + sort_order=sort_order, + ) + + return ( + Response() + .ok( + { + "message": "文件夹创建成功", + "folder": { + "folder_id": folder.folder_id, + "name": folder.name, + "parent_id": folder.parent_id, + "description": folder.description, + "sort_order": folder.sort_order, + "created_at": folder.created_at.isoformat() + if folder.created_at + else None, + "updated_at": folder.updated_at.isoformat() + if folder.updated_at + else None, + }, + }, + ) + .__dict__ + ) + except Exception as e: + logger.error(f"创建文件夹失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"创建文件夹失败: {e!s}").__dict__ + + async def update_folder(self): + """更新文件夹信息""" + try: + data = await request.get_json() + folder_id = data.get("folder_id") + name = data.get("name") + parent_id = data.get("parent_id") + description = data.get("description") + sort_order = data.get("sort_order") + + if not folder_id: + return Response().error("缺少必要参数: folder_id").__dict__ + + await self.persona_mgr.update_folder( + folder_id=folder_id, + name=name, + parent_id=parent_id, + description=description, + sort_order=sort_order, + ) + + return Response().ok({"message": "文件夹更新成功"}).__dict__ + except Exception as e: + logger.error(f"更新文件夹失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"更新文件夹失败: {e!s}").__dict__ + + async def delete_folder(self): + """删除文件夹""" + try: + data = await request.get_json() + folder_id = data.get("folder_id") + + if not folder_id: + return Response().error("缺少必要参数: folder_id").__dict__ + + await self.persona_mgr.delete_folder(folder_id) + + return Response().ok({"message": "文件夹删除成功"}).__dict__ + except Exception as e: + logger.error(f"删除文件夹失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"删除文件夹失败: {e!s}").__dict__ From 24a40e41321066105ee8cedb3555794ab9ad92ef Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 12 Jan 2026 10:44:58 +0800 Subject: [PATCH 02/16] feat(persona): add batch sort order update endpoint for personas and folders Add new API endpoint POST /persona/reorder to batch update sort_order for both personas and folders. This enables drag-and-drop reordering in the dashboard UI. Changes: - Add abstract batch_update_sort_order method to BaseDatabase - Implement batch_update_sort_order in SQLiteDatabase - Add batch_update_sort_order to PersonaManager with cache refresh - Add reorder_items route handler with input validation --- astrbot/core/db/__init__.py | 15 +++++++++++ astrbot/core/db/sqlite.py | 39 +++++++++++++++++++++++++++++ astrbot/core/persona_mgr.py | 14 +++++++++++ astrbot/dashboard/routes/persona.py | 39 +++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+) diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 391b5e2f6..44998186e 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -345,6 +345,21 @@ async def get_personas_by_folder( """Get all personas in a specific folder.""" ... + @abc.abstractmethod + async def batch_update_sort_order( + self, + items: list[dict], + ) -> None: + """Batch update sort_order for personas and/or folders. + + Args: + items: List of dicts with keys: + - id: The persona_id or folder_id + - type: Either "persona" or "folder" + - sort_order: The new sort_order value + """ + ... + @abc.abstractmethod async def insert_preference_or_update( self, diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index ea2acebad..c09456e5b 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -776,6 +776,45 @@ async def get_personas_by_folder( result = await session.execute(query) return list(result.scalars().all()) + async def batch_update_sort_order( + self, + items: list[dict], + ) -> None: + """Batch update sort_order for personas and/or folders. + + Args: + items: List of dicts with keys: + - id: The persona_id or folder_id + - type: Either "persona" or "folder" + - sort_order: The new sort_order value + """ + if not items: + return + + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + for item in items: + item_id = item.get("id") + item_type = item.get("type") + sort_order = item.get("sort_order") + + if item_id is None or item_type is None or sort_order is None: + continue + + if item_type == "persona": + await session.execute( + update(Persona) + .where(col(Persona.persona_id) == item_id) + .values(sort_order=sort_order) + ) + elif item_type == "folder": + await session.execute( + update(PersonaFolder) + .where(col(PersonaFolder.folder_id) == item_id) + .values(sort_order=sort_order) + ) + async def insert_preference_or_update(self, scope, scope_id, key, value): """Insert a new preference record or update if it exists.""" async with self.get_db() as session: diff --git a/astrbot/core/persona_mgr.py b/astrbot/core/persona_mgr.py index 540501c73..c53dd59c5 100644 --- a/astrbot/core/persona_mgr.py +++ b/astrbot/core/persona_mgr.py @@ -180,6 +180,20 @@ async def delete_folder(self, folder_id: str) -> None: """ await self.db.delete_persona_folder(folder_id) + async def batch_update_sort_order(self, items: list[dict]) -> None: + """批量更新 personas 和/或 folders 的排序顺序 + + Args: + items: 包含以下键的字典列表: + - id: persona_id 或 folder_id + - type: "persona" 或 "folder" + - sort_order: 新的排序顺序值 + """ + await self.db.batch_update_sort_order(items) + # 刷新缓存 + self.personas = await self.get_all_personas() + self.get_v3_persona_data() + async def get_folder_tree(self) -> list[dict]: """获取文件夹树形结构 diff --git a/astrbot/dashboard/routes/persona.py b/astrbot/dashboard/routes/persona.py index 6b2538157..d66f023a2 100644 --- a/astrbot/dashboard/routes/persona.py +++ b/astrbot/dashboard/routes/persona.py @@ -24,6 +24,7 @@ def __init__( "/persona/update": ("POST", self.update_persona), "/persona/delete": ("POST", self.delete_persona), "/persona/move": ("POST", self.move_persona), + "/persona/reorder": ("POST", self.reorder_items), # Folder routes "/persona/folder/list": ("GET", self.list_folders), "/persona/folder/tree": ("GET", self.get_folder_tree), @@ -368,3 +369,41 @@ async def delete_folder(self): except Exception as e: logger.error(f"删除文件夹失败: {e!s}\n{traceback.format_exc()}") return Response().error(f"删除文件夹失败: {e!s}").__dict__ + + async def reorder_items(self): + """批量更新排序顺序 + + 请求体格式: + { + "items": [ + {"id": "persona_id_1", "type": "persona", "sort_order": 0}, + {"id": "persona_id_2", "type": "persona", "sort_order": 1}, + {"id": "folder_id_1", "type": "folder", "sort_order": 0}, + ... + ] + } + """ + try: + data = await request.get_json() + items = data.get("items", []) + + if not items: + return Response().error("items 不能为空").__dict__ + + # 验证每个 item 的格式 + for item in items: + if not all(k in item for k in ("id", "type", "sort_order")): + return Response().error( + "每个 item 必须包含 id, type, sort_order 字段" + ).__dict__ + if item["type"] not in ("persona", "folder"): + return Response().error( + "type 字段必须是 'persona' 或 'folder'" + ).__dict__ + + await self.persona_mgr.batch_update_sort_order(items) + + return Response().ok({"message": "排序更新成功"}).__dict__ + except Exception as e: + logger.error(f"更新排序失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"更新排序失败: {e!s}").__dict__ From 27e1a72a9b98f8248e25db80cd49c2edc78dd8f2 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 12 Jan 2026 10:57:59 +0800 Subject: [PATCH 03/16] feat(persona): add folder_id and sort_order params to persona creation Extend persona creation flow to support folder placement and ordering: - Add folder_id and sort_order parameters to insert_persona in db layer - Update PersonaManager.create_persona to accept and pass folder params - Add get_folder_detail API endpoint for retrieving folder information - Include folder_id and sort_order in persona creation response - Add session flush/refresh to return complete persona object --- astrbot/core/db/__init__.py | 13 ++++++++- astrbot/core/db/sqlite.py | 6 ++++ astrbot/core/persona_mgr.py | 15 +++++++++- astrbot/dashboard/routes/persona.py | 43 +++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 44998186e..9b5373e31 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -252,8 +252,19 @@ async def insert_persona( system_prompt: str, begin_dialogs: list[str] | None = None, tools: list[str] | None = None, + folder_id: str | None = None, + sort_order: int = 0, ) -> Persona: - """Insert a new persona record.""" + """Insert a new persona record. + + Args: + persona_id: Unique identifier for the persona + system_prompt: System prompt for the persona + begin_dialogs: Optional list of initial dialog strings + tools: Optional list of tool names (None means all tools, [] means no tools) + folder_id: Optional folder ID to place the persona in (None means root) + sort_order: Sort order within the folder (default 0) + """ ... @abc.abstractmethod diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index c09456e5b..421ff0be4 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -560,6 +560,8 @@ async def insert_persona( system_prompt, begin_dialogs=None, tools=None, + folder_id=None, + sort_order=0, ): """Insert a new persona record.""" async with self.get_db() as session: @@ -570,8 +572,12 @@ async def insert_persona( system_prompt=system_prompt, begin_dialogs=begin_dialogs or [], tools=tools, + folder_id=folder_id, + sort_order=sort_order, ) session.add(new_persona) + await session.flush() + await session.refresh(new_persona) return new_persona async def get_persona_by_id(self, persona_id): diff --git a/astrbot/core/persona_mgr.py b/astrbot/core/persona_mgr.py index c53dd59c5..40909b004 100644 --- a/astrbot/core/persona_mgr.py +++ b/astrbot/core/persona_mgr.py @@ -239,8 +239,19 @@ async def create_persona( system_prompt: str, begin_dialogs: list[str] | None = None, tools: list[str] | None = None, + folder_id: str | None = None, + sort_order: int = 0, ) -> Persona: - """创建新的 persona。tools 参数为 None 时表示使用所有工具,空列表表示不使用任何工具""" + """创建新的 persona。 + + Args: + persona_id: Persona 唯一标识 + system_prompt: 系统提示词 + begin_dialogs: 预设对话列表 + tools: 工具列表,None 表示使用所有工具,空列表表示不使用任何工具 + folder_id: 所属文件夹 ID,None 表示根目录 + sort_order: 排序顺序 + """ if await self.db.get_persona_by_id(persona_id): raise ValueError(f"Persona with ID {persona_id} already exists.") new_persona = await self.db.insert_persona( @@ -248,6 +259,8 @@ async def create_persona( system_prompt, begin_dialogs, tools=tools, + folder_id=folder_id, + sort_order=sort_order, ) self.personas.append(new_persona) self.get_v3_persona_data() diff --git a/astrbot/dashboard/routes/persona.py b/astrbot/dashboard/routes/persona.py index d66f023a2..9f4d18a17 100644 --- a/astrbot/dashboard/routes/persona.py +++ b/astrbot/dashboard/routes/persona.py @@ -28,6 +28,7 @@ def __init__( # Folder routes "/persona/folder/list": ("GET", self.list_folders), "/persona/folder/tree": ("GET", self.get_folder_tree), + "/persona/folder/detail": ("POST", self.get_folder_detail), "/persona/folder/create": ("POST", self.create_folder), "/persona/folder/update": ("POST", self.update_folder), "/persona/folder/delete": ("POST", self.delete_folder), @@ -119,6 +120,8 @@ async def create_persona(self): system_prompt = data.get("system_prompt", "").strip() begin_dialogs = data.get("begin_dialogs", []) tools = data.get("tools") + folder_id = data.get("folder_id") # None 表示根目录 + sort_order = data.get("sort_order", 0) if not persona_id: return Response().error("人格ID不能为空").__dict__ @@ -139,6 +142,8 @@ async def create_persona(self): system_prompt=system_prompt, begin_dialogs=begin_dialogs if begin_dialogs else None, tools=tools if tools else None, + folder_id=folder_id, + sort_order=sort_order, ) return ( @@ -151,6 +156,8 @@ async def create_persona(self): "system_prompt": persona.system_prompt, "begin_dialogs": persona.begin_dialogs or [], "tools": persona.tools or [], + "folder_id": persona.folder_id, + "sort_order": persona.sort_order, "created_at": persona.created_at.isoformat() if persona.created_at else None, @@ -283,6 +290,42 @@ async def get_folder_tree(self): logger.error(f"获取文件夹树失败: {e!s}\n{traceback.format_exc()}") return Response().error(f"获取文件夹树失败: {e!s}").__dict__ + async def get_folder_detail(self): + """获取指定文件夹的详细信息""" + try: + data = await request.get_json() + folder_id = data.get("folder_id") + + if not folder_id: + return Response().error("缺少必要参数: folder_id").__dict__ + + folder = await self.persona_mgr.get_folder(folder_id) + if not folder: + return Response().error("文件夹不存在").__dict__ + + return ( + Response() + .ok( + { + "folder_id": folder.folder_id, + "name": folder.name, + "parent_id": folder.parent_id, + "description": folder.description, + "sort_order": folder.sort_order, + "created_at": folder.created_at.isoformat() + if folder.created_at + else None, + "updated_at": folder.updated_at.isoformat() + if folder.updated_at + else None, + }, + ) + .__dict__ + ) + except Exception as e: + logger.error(f"获取文件夹详情失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"获取文件夹详情失败: {e!s}").__dict__ + async def create_folder(self): """创建文件夹""" try: From fd5dc3c54b7d4005e995f23968af096f6dc2c2fc Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 12 Jan 2026 14:33:38 +0800 Subject: [PATCH 04/16] feat(dashboard): implement persona folder management UI - Add folder management system with tree view and breadcrumbs - Implement create, rename, delete, and move operations for folders - Add drag-and-drop support for organizing personas and folders - Create new PersonaManager component and Pinia store for state management - Refactor PersonaPage to support hierarchical structure - Update locale files with folder-related translations - Handle empty parent_id correctly in backend route --- astrbot/dashboard/routes/persona.py | 3 + .../components/persona/CreateFolderDialog.vue | 117 +++++ .../components/persona/FolderBreadcrumb.vue | 78 +++ .../src/components/persona/FolderCard.vue | 114 +++++ .../src/components/persona/FolderTree.vue | 295 +++++++++++ .../src/components/persona/FolderTreeNode.vue | 133 +++++ .../src/components/persona/MoveTargetNode.vue | 90 ++++ .../components/persona/MoveToFolderDialog.vue | 198 ++++++++ .../src/components/persona/PersonaCard.vue | 163 ++++++ .../src/components/persona/PersonaManager.vue | 480 ++++++++++++++++++ .../src/components/shared/PersonaForm.vue | 13 +- .../i18n/locales/en-US/features/persona.json | 62 ++- .../i18n/locales/zh-CN/features/persona.json | 62 ++- dashboard/src/stores/personaStore.ts | 308 +++++++++++ dashboard/src/views/PersonaPage.vue | 292 +---------- 15 files changed, 2118 insertions(+), 290 deletions(-) create mode 100644 dashboard/src/components/persona/CreateFolderDialog.vue create mode 100644 dashboard/src/components/persona/FolderBreadcrumb.vue create mode 100644 dashboard/src/components/persona/FolderCard.vue create mode 100644 dashboard/src/components/persona/FolderTree.vue create mode 100644 dashboard/src/components/persona/FolderTreeNode.vue create mode 100644 dashboard/src/components/persona/MoveTargetNode.vue create mode 100644 dashboard/src/components/persona/MoveToFolderDialog.vue create mode 100644 dashboard/src/components/persona/PersonaCard.vue create mode 100644 dashboard/src/components/persona/PersonaManager.vue create mode 100644 dashboard/src/stores/personaStore.ts diff --git a/astrbot/dashboard/routes/persona.py b/astrbot/dashboard/routes/persona.py index 9f4d18a17..0e8cf4a3b 100644 --- a/astrbot/dashboard/routes/persona.py +++ b/astrbot/dashboard/routes/persona.py @@ -254,6 +254,9 @@ async def list_folders(self): """获取文件夹列表""" try: parent_id = request.args.get("parent_id") + # 空字符串视为 None(根目录) + if parent_id == "": + parent_id = None folders = await self.persona_mgr.get_folders(parent_id) return ( Response() diff --git a/dashboard/src/components/persona/CreateFolderDialog.vue b/dashboard/src/components/persona/CreateFolderDialog.vue new file mode 100644 index 000000000..8f1c01ca2 --- /dev/null +++ b/dashboard/src/components/persona/CreateFolderDialog.vue @@ -0,0 +1,117 @@ + + + diff --git a/dashboard/src/components/persona/FolderBreadcrumb.vue b/dashboard/src/components/persona/FolderBreadcrumb.vue new file mode 100644 index 000000000..1c1ee762c --- /dev/null +++ b/dashboard/src/components/persona/FolderBreadcrumb.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/dashboard/src/components/persona/FolderCard.vue b/dashboard/src/components/persona/FolderCard.vue new file mode 100644 index 000000000..af317f6a0 --- /dev/null +++ b/dashboard/src/components/persona/FolderCard.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/dashboard/src/components/persona/FolderTree.vue b/dashboard/src/components/persona/FolderTree.vue new file mode 100644 index 000000000..036459316 --- /dev/null +++ b/dashboard/src/components/persona/FolderTree.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/dashboard/src/components/persona/FolderTreeNode.vue b/dashboard/src/components/persona/FolderTreeNode.vue new file mode 100644 index 000000000..cf9e7e551 --- /dev/null +++ b/dashboard/src/components/persona/FolderTreeNode.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/dashboard/src/components/persona/MoveTargetNode.vue b/dashboard/src/components/persona/MoveTargetNode.vue new file mode 100644 index 000000000..9558b54d2 --- /dev/null +++ b/dashboard/src/components/persona/MoveTargetNode.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/dashboard/src/components/persona/MoveToFolderDialog.vue b/dashboard/src/components/persona/MoveToFolderDialog.vue new file mode 100644 index 000000000..b897ae926 --- /dev/null +++ b/dashboard/src/components/persona/MoveToFolderDialog.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/dashboard/src/components/persona/PersonaCard.vue b/dashboard/src/components/persona/PersonaCard.vue new file mode 100644 index 000000000..8feb5c78b --- /dev/null +++ b/dashboard/src/components/persona/PersonaCard.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/dashboard/src/components/persona/PersonaManager.vue b/dashboard/src/components/persona/PersonaManager.vue new file mode 100644 index 000000000..3235c8f33 --- /dev/null +++ b/dashboard/src/components/persona/PersonaManager.vue @@ -0,0 +1,480 @@ + + + + + diff --git a/dashboard/src/components/shared/PersonaForm.vue b/dashboard/src/components/shared/PersonaForm.vue index 48f1a0d0e..c25f5e695 100644 --- a/dashboard/src/components/shared/PersonaForm.vue +++ b/dashboard/src/components/shared/PersonaForm.vue @@ -209,6 +209,10 @@ export default { editingPersona: { type: Object, default: null + }, + currentFolderId: { + type: String, + default: null } }, emits: ['update:modelValue', 'saved', 'error'], @@ -229,7 +233,8 @@ export default { persona_id: '', system_prompt: '', begin_dialogs: [], - tools: [] + tools: [], + folder_id: null }, personaIdRules: [ v => !!v || this.tm('validation.required'), @@ -310,7 +315,8 @@ export default { persona_id: '', system_prompt: '', begin_dialogs: [], - tools: [] + tools: [], + folder_id: this.currentFolderId }; this.toolSelectValue = '0'; this.expandedPanels = []; @@ -321,7 +327,8 @@ export default { persona_id: persona.persona_id, system_prompt: persona.system_prompt, begin_dialogs: [...(persona.begin_dialogs || [])], - tools: persona.tools === null ? null : [...(persona.tools || [])] + tools: persona.tools === null ? null : [...(persona.tools || [])], + folder_id: persona.folder_id }; // 根据 tools 的值设置 toolSelectValue this.toolSelectValue = persona.tools === null ? '0' : '1'; diff --git a/dashboard/src/i18n/locales/en-US/features/persona.json b/dashboard/src/i18n/locales/en-US/features/persona.json index 94708ee56..4da71c9b0 100644 --- a/dashboard/src/i18n/locales/en-US/features/persona.json +++ b/dashboard/src/i18n/locales/en-US/features/persona.json @@ -9,6 +9,7 @@ "delete": "Delete", "cancel": "Cancel", "save": "Save", + "move": "Move", "addDialogPair": "Add Dialog Pair" }, "labels": { @@ -48,7 +49,9 @@ }, "empty": { "title": "No Persona Configured", - "description": "Create your first persona to start using personalized chatbots" + "description": "Create your first persona to start using personalized chatbots", + "folderEmpty": "This folder is empty", + "folderEmptyDescription": "Create a new persona or folder to get started" }, "validation": { "required": "This field is required", @@ -63,5 +66,62 @@ "deleteConfirm": "Are you sure you want to delete persona \"{id}\"? This action cannot be undone.", "deleteSuccess": "Deleted successfully", "deleteError": "Delete failed" + }, + "persona": { + "personasTitle": "Personas", + "toolsCount": "tools", + "contextMenu": { + "moveTo": "Move to..." + }, + "messages": { + "moveSuccess": "Persona moved successfully", + "moveError": "Failed to move persona" + } + }, + "folder": { + "sidebarTitle": "Folders", + "rootFolder": "Root", + "foldersTitle": "Folders", + "noFolders": "No folders yet", + "createButton": "New Folder", + "searchPlaceholder": "Search folders...", + "form": { + "name": "Folder Name", + "description": "Description (optional)" + }, + "validation": { + "nameRequired": "Folder name is required" + }, + "contextMenu": { + "open": "Open", + "rename": "Rename", + "moveTo": "Move to...", + "delete": "Delete" + }, + "createDialog": { + "title": "Create New Folder" + }, + "renameDialog": { + "title": "Rename Folder" + }, + "deleteDialog": { + "title": "Delete Folder", + "message": "Are you sure you want to delete folder \"{name}\"?", + "warning": "All personas inside will be moved to root folder." + }, + "messages": { + "createSuccess": "Folder created successfully", + "createError": "Failed to create folder", + "renameSuccess": "Folder renamed successfully", + "renameError": "Failed to rename folder", + "deleteSuccess": "Folder deleted successfully", + "deleteError": "Failed to delete folder" + } + }, + "moveDialog": { + "title": "Move to Folder", + "description": "Select a destination folder for \"{name}\"", + "success": "Moved successfully", + "error": "Failed to move" } } diff --git a/dashboard/src/i18n/locales/zh-CN/features/persona.json b/dashboard/src/i18n/locales/zh-CN/features/persona.json index 15121df41..adf8cc359 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/persona.json +++ b/dashboard/src/i18n/locales/zh-CN/features/persona.json @@ -9,6 +9,7 @@ "delete": "删除", "cancel": "取消", "save": "保存", + "move": "移动", "addDialogPair": "添加对话对" }, "labels": { @@ -48,7 +49,9 @@ }, "empty": { "title": "暂无人格配置", - "description": "来创建一个吧!" + "description": "来创建一个吧!", + "folderEmpty": "此文件夹为空", + "folderEmptyDescription": "创建新的人格或文件夹开始使用" }, "validation": { "required": "此字段为必填项", @@ -63,5 +66,62 @@ "deleteConfirm": "确定要删除人格 \"{id}\" 吗?此操作不可撤销。", "deleteSuccess": "删除成功", "deleteError": "删除失败" + }, + "persona": { + "personasTitle": "人格", + "toolsCount": "个工具", + "contextMenu": { + "moveTo": "移动到..." + }, + "messages": { + "moveSuccess": "人格移动成功", + "moveError": "移动人格失败" + } + }, + "folder": { + "sidebarTitle": "文件夹", + "rootFolder": "根目录", + "foldersTitle": "文件夹", + "noFolders": "暂无文件夹", + "createButton": "新建文件夹", + "searchPlaceholder": "搜索文件夹...", + "form": { + "name": "文件夹名称", + "description": "描述(可选)" + }, + "validation": { + "nameRequired": "文件夹名称不能为空" + }, + "contextMenu": { + "open": "打开", + "rename": "重命名", + "moveTo": "移动到...", + "delete": "删除" + }, + "createDialog": { + "title": "创建新文件夹" + }, + "renameDialog": { + "title": "重命名文件夹" + }, + "deleteDialog": { + "title": "删除文件夹", + "message": "确定要删除文件夹 \"{name}\" 吗?", + "warning": "文件夹内的所有人格将被移动到根目录。" + }, + "messages": { + "createSuccess": "文件夹创建成功", + "createError": "创建文件夹失败", + "renameSuccess": "文件夹重命名成功", + "renameError": "重命名文件夹失败", + "deleteSuccess": "文件夹删除成功", + "deleteError": "删除文件夹失败" + } + }, + "moveDialog": { + "title": "移动到文件夹", + "description": "为 \"{name}\" 选择目标文件夹", + "success": "移动成功", + "error": "移动失败" } } diff --git a/dashboard/src/stores/personaStore.ts b/dashboard/src/stores/personaStore.ts new file mode 100644 index 000000000..dc2e475f9 --- /dev/null +++ b/dashboard/src/stores/personaStore.ts @@ -0,0 +1,308 @@ +/** + * Persona 文件夹管理 Store + */ +import { defineStore } from 'pinia'; +import axios from 'axios'; + +// 类型定义 +export interface PersonaFolder { + folder_id: string; + name: string; + parent_id: string | null; + description: string | null; + sort_order: number; + created_at: string; + updated_at: string; +} + +export interface Persona { + persona_id: string; + system_prompt: string; + begin_dialogs: string[]; + tools: string[] | null; + folder_id: string | null; + sort_order: number; + created_at: string; + updated_at: string; +} + +export interface FolderTreeNode { + folder_id: string; + name: string; + parent_id: string | null; + description: string | null; + sort_order: number; + children: FolderTreeNode[]; +} + +export interface ReorderItem { + id: string; + type: 'persona' | 'folder'; + sort_order: number; +} + +export const usePersonaStore = defineStore({ + id: 'persona', + state: () => ({ + folderTree: [] as FolderTreeNode[], + currentFolderId: null as string | null, + currentFolders: [] as PersonaFolder[], + currentPersonas: [] as Persona[], + breadcrumbPath: [] as FolderTreeNode[], + loading: false, + treeLoading: false, + }), + + getters: { + // 当前文件夹名称 + currentFolderName(): string { + if (this.breadcrumbPath.length === 0) { + return '根目录'; + } + return this.breadcrumbPath[this.breadcrumbPath.length - 1]?.name || '根目录'; + }, + }, + + actions: { + /** + * 加载文件夹树形结构 + */ + async loadFolderTree(): Promise { + this.treeLoading = true; + try { + const response = await axios.get('/api/persona/folder/tree'); + if (response.data.status === 'ok') { + this.folderTree = response.data.data || []; + } else { + throw new Error(response.data.message || '获取文件夹树失败'); + } + } finally { + this.treeLoading = false; + } + }, + + /** + * 导航到指定文件夹 + */ + async navigateToFolder(folderId: string | null): Promise { + this.loading = true; + try { + this.currentFolderId = folderId; + + // 并行加载子文件夹和 Persona + const [foldersRes, personasRes] = await Promise.all([ + axios.get('/api/persona/folder/list', { + params: { parent_id: folderId ?? '' } + }), + axios.get('/api/persona/list', { + params: { folder_id: folderId ?? '' } + }), + ]); + + if (foldersRes.data.status === 'ok') { + this.currentFolders = foldersRes.data.data || []; + } + + if (personasRes.data.status === 'ok') { + this.currentPersonas = personasRes.data.data || []; + } + + // 更新面包屑 + this.updateBreadcrumb(folderId); + } finally { + this.loading = false; + } + }, + + /** + * 更新面包屑路径 + */ + updateBreadcrumb(folderId: string | null): void { + if (folderId === null) { + this.breadcrumbPath = []; + return; + } + + // 从树中查找路径 + const path: FolderTreeNode[] = []; + const findPath = (nodes: FolderTreeNode[], targetId: string): boolean => { + for (const node of nodes) { + if (node.folder_id === targetId) { + path.push(node); + return true; + } + if (node.children.length > 0 && findPath(node.children, targetId)) { + path.unshift(node); + return true; + } + } + return false; + }; + + findPath(this.folderTree, folderId); + this.breadcrumbPath = path; + }, + + /** + * 刷新当前文件夹内容 + */ + async refreshCurrentFolder(): Promise { + await this.navigateToFolder(this.currentFolderId); + }, + + /** + * 移动 Persona 到文件夹 + */ + async movePersonaToFolder(personaId: string, targetFolderId: string | null): Promise { + const response = await axios.post('/api/persona/move', { + persona_id: personaId, + folder_id: targetFolderId + }); + + if (response.data.status !== 'ok') { + throw new Error(response.data.message || '移动人格失败'); + } + + // 刷新当前文件夹内容和文件夹树 + await Promise.all([ + this.refreshCurrentFolder(), + this.loadFolderTree(), + ]); + }, + + /** + * 移动文件夹到另一个文件夹 + */ + async moveFolderToFolder(folderId: string, targetParentId: string | null): Promise { + const response = await axios.post('/api/persona/folder/update', { + folder_id: folderId, + parent_id: targetParentId + }); + + if (response.data.status !== 'ok') { + throw new Error(response.data.message || '移动文件夹失败'); + } + + // 刷新当前文件夹内容和文件夹树 + await Promise.all([ + this.refreshCurrentFolder(), + this.loadFolderTree(), + ]); + }, + + /** + * 创建文件夹 + */ + async createFolder(data: { + name: string; + parent_id?: string | null; + description?: string; + }): Promise { + const response = await axios.post('/api/persona/folder/create', { + ...data, + parent_id: data.parent_id ?? this.currentFolderId, + }); + + if (response.data.status !== 'ok') { + throw new Error(response.data.message || '创建文件夹失败'); + } + + // 刷新当前文件夹内容和文件夹树 + await Promise.all([ + this.refreshCurrentFolder(), + this.loadFolderTree(), + ]); + + return response.data.data.folder; + }, + + /** + * 更新文件夹 + */ + async updateFolder(data: { + folder_id: string; + name?: string; + description?: string; + }): Promise { + const response = await axios.post('/api/persona/folder/update', data); + + if (response.data.status !== 'ok') { + throw new Error(response.data.message || '更新文件夹失败'); + } + + // 刷新当前文件夹内容和文件夹树 + await Promise.all([ + this.refreshCurrentFolder(), + this.loadFolderTree(), + ]); + }, + + /** + * 删除文件夹 + */ + async deleteFolder(folderId: string): Promise { + const response = await axios.post('/api/persona/folder/delete', { + folder_id: folderId + }); + + if (response.data.status !== 'ok') { + throw new Error(response.data.message || '删除文件夹失败'); + } + + // 刷新当前文件夹内容和文件夹树 + await Promise.all([ + this.refreshCurrentFolder(), + this.loadFolderTree(), + ]); + }, + + /** + * 删除 Persona + */ + async deletePersona(personaId: string): Promise { + const response = await axios.post('/api/persona/delete', { + persona_id: personaId + }); + + if (response.data.status !== 'ok') { + throw new Error(response.data.message || '删除人格失败'); + } + + // 刷新当前文件夹内容 + await this.refreshCurrentFolder(); + }, + + /** + * 批量更新排序 + */ + async reorderItems(items: ReorderItem[]): Promise { + const response = await axios.post('/api/persona/reorder', { items }); + + if (response.data.status !== 'ok') { + throw new Error(response.data.message || '更新排序失败'); + } + + // 刷新当前文件夹内容 + await this.refreshCurrentFolder(); + }, + + /** + * 根据文件夹 ID 查找树节点 + */ + findFolderInTree(folderId: string): FolderTreeNode | null { + const findNode = (nodes: FolderTreeNode[]): FolderTreeNode | null => { + for (const node of nodes) { + if (node.folder_id === folderId) { + return node; + } + if (node.children.length > 0) { + const found = findNode(node.children); + if (found) return found; + } + } + return null; + }; + return findNode(this.folderTree); + }, + } +}); diff --git a/dashboard/src/views/PersonaPage.vue b/dashboard/src/views/PersonaPage.vue index cffeeb549..f985c6851 100644 --- a/dashboard/src/views/PersonaPage.vue +++ b/dashboard/src/views/PersonaPage.vue @@ -2,277 +2,38 @@
- +

mdi-heart{{ t('core.navigation.persona') }}

-

+

{{ tm('page.description') }}

-
- - {{ tm('buttons.create') }} - -
- - - - - - -
- {{ persona.persona_id }} -
- - - - - - mdi-pencil - {{ tm('buttons.edit') }} - - - - - mdi-delete - {{ tm('buttons.delete') }} - - - - -
- - -
- {{ truncateText(persona.system_prompt, 100) }} -
- -
- - {{ tm('labels.presetDialogs', { count: persona.begin_dialogs.length / 2 }) }} - -
- -
- {{ tm('labels.createdAt') }}: {{ formatDate(persona.created_at) }} -
-
-
-
- - - - - mdi-account-group -

{{ tm('empty.title') }}

-

{{ tm('empty.description') }}

- - {{ tm('buttons.createFirst') }} - -
-
-
- - - - - - - + +
- - - - - - - - - {{ viewingPersona.persona_id }} - - - - -
-

{{ tm('form.systemPrompt') }}

-
-                            {{ viewingPersona.system_prompt }}
-                        
-
- -
-

{{ tm('form.presetDialogs') }}

-
- - {{ index % 2 === 0 ? tm('form.userMessage') : tm('form.assistantMessage') }} - -
- {{ dialog }} -
-
-
- -
-

{{ tm('form.tools') }}

-
- - {{ tm('form.allToolsAvailable') }} - -
-
- - {{ toolName }} - -
-
- {{ tm('form.noToolsSelected') }} -
-
- -
-
{{ tm('labels.createdAt') }}: {{ formatDate(viewingPersona.created_at) }}
-
{{ tm('labels.updatedAt') }}: {{ - formatDate(viewingPersona.updated_at) }}
-
-
-
-
- - - - {{ message }} -
From 36355ad391f2fccc0ae8f59853562761725d0a44 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 12 Jan 2026 14:38:32 +0800 Subject: [PATCH 05/16] feat(dashboard): centralize folder expansion state in persona store Move folder expansion logic from local component state to global Pinia store to persist expansion state. - Add `expandedFolderIds` state and toggle actions to `personaStore` - Update `FolderTreeNode` to use store state instead of local data - Automatically navigate to target folder after moving a persona --- .../src/components/persona/FolderTreeNode.vue | 13 +++++++--- .../src/components/persona/PersonaManager.vue | 2 ++ dashboard/src/stores/personaStore.ts | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/dashboard/src/components/persona/FolderTreeNode.vue b/dashboard/src/components/persona/FolderTreeNode.vue index cf9e7e551..409b33aa9 100644 --- a/dashboard/src/components/persona/FolderTreeNode.vue +++ b/dashboard/src/components/persona/FolderTreeNode.vue @@ -31,6 +31,9 @@ diff --git a/dashboard/src/components/folder/BaseFolderBreadcrumb.vue b/dashboard/src/components/folder/BaseFolderBreadcrumb.vue new file mode 100644 index 000000000..037d0ff2f --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderBreadcrumb.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseFolderCard.vue b/dashboard/src/components/folder/BaseFolderCard.vue new file mode 100644 index 000000000..eddda9b62 --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderCard.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseFolderTree.vue b/dashboard/src/components/folder/BaseFolderTree.vue new file mode 100644 index 000000000..1fe924153 --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderTree.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseFolderTreeNode.vue b/dashboard/src/components/folder/BaseFolderTreeNode.vue new file mode 100644 index 000000000..b02cd3c2c --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderTreeNode.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseMoveTargetNode.vue b/dashboard/src/components/folder/BaseMoveTargetNode.vue new file mode 100644 index 000000000..330947be0 --- /dev/null +++ b/dashboard/src/components/folder/BaseMoveTargetNode.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseMoveToFolderDialog.vue b/dashboard/src/components/folder/BaseMoveToFolderDialog.vue new file mode 100644 index 000000000..de2686798 --- /dev/null +++ b/dashboard/src/components/folder/BaseMoveToFolderDialog.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/dashboard/src/components/folder/README.md b/dashboard/src/components/folder/README.md new file mode 100644 index 000000000..cacf874c7 --- /dev/null +++ b/dashboard/src/components/folder/README.md @@ -0,0 +1,349 @@ +# 通用文件夹管理组件库 + +这是一个可复用的文件夹管理 UI 组件库,提供了完整的文件夹树、面包屑导航、拖放操作等功能。可用于管理各种类型的项目,如 Persona、模板、知识库等。 + +## 组件列表 + +| 组件 | 说明 | +|------|------| +| `BaseFolderTree` | 文件夹树组件,支持搜索、展开/折叠、右键菜单、拖放 | +| `BaseFolderTreeNode` | 文件夹树节点组件(内部使用) | +| `BaseFolderCard` | 文件夹卡片组件,用于网格布局展示 | +| `BaseFolderBreadcrumb` | 面包屑导航组件 | +| `BaseCreateFolderDialog` | 创建文件夹对话框 | +| `BaseMoveToFolderDialog` | 移动项目到文件夹对话框 | +| `BaseMoveTargetNode` | 移动对话框中的目标文件夹节点(内部使用) | + +## Composable + +### `useFolderManager` + +提供文件夹管理的核心逻辑,包括状态管理、导航、CRUD 操作等。 + +```typescript +import { useFolderManager } from '@/components/folder'; + +const { + // 状态 + folderTree, + currentFolderId, + currentFolders, + breadcrumbPath, + expandedFolderIds, + loading, + treeLoading, + + // 计算属性 + currentFolderName, + breadcrumbItems, + + // 方法 + loadFolderTree, + navigateToFolder, + refreshCurrentFolder, + createFolder, + updateFolder, + deleteFolder, + moveFolder, + toggleFolderExpansion, + setFolderExpansion, + findFolderInTree, + findPathToFolder, + filterTreeBySearch, +} = useFolderManager({ + operations: { + loadFolderTree: async () => { + const response = await axios.get('/api/your-module/folder/tree'); + return response.data.data; + }, + loadSubFolders: async (parentId) => { + const response = await axios.get('/api/your-module/folder/list', { + params: { parent_id: parentId ?? '' } + }); + return response.data.data; + }, + createFolder: async (data) => { + const response = await axios.post('/api/your-module/folder/create', data); + return response.data.data.folder; + }, + updateFolder: async (data) => { + await axios.post('/api/your-module/folder/update', data); + }, + deleteFolder: async (folderId) => { + await axios.post('/api/your-module/folder/delete', { folder_id: folderId }); + }, + }, + rootFolderName: '根目录', + autoLoad: true, +}); +``` + +## 使用示例 + +### 基础用法 + +```vue + + + +``` + +## 类型定义 + +```typescript +// 文件夹基础接口 +interface Folder { + folder_id: string; + name: string; + parent_id: string | null; + description?: string | null; + sort_order?: number; + created_at?: string; + updated_at?: string; +} + +// 文件夹树节点接口 +interface FolderTreeNode extends Folder { + children: FolderTreeNode[]; +} + +// 拖放事件数据 +interface DropEventData { + item_id: string; + item_type: string; + target_folder_id: string | null; + source_data?: any; +} + +// 创建文件夹数据 +interface CreateFolderData { + name: string; + parent_id?: string | null; + description?: string; +} +``` + +## 国际化支持 + +所有组件都支持通过 `labels` prop 自定义文本,方便集成到不同的国际化方案中: + +```vue + +``` + +## 拖放支持 + +组件内置了拖放支持,可以通过 `acceptDropTypes` 指定接受的拖放类型: + +```vue + + + + + +``` + +## 与 Pinia Store 集成 + +如果你更喜欢使用 Pinia Store 管理状态,可以参考现有的 `personaStore.ts` 实现: + +```typescript +// stores/myFolderStore.ts +import { defineStore } from 'pinia'; +import type { FolderTreeNode, Folder } from '@/components/folder'; + +export const useMyFolderStore = defineStore('myFolder', { + state: () => ({ + folderTree: [] as FolderTreeNode[], + currentFolderId: null as string | null, + currentFolders: [] as Folder[], + // ... + }), + + actions: { + async loadFolderTree() { + // ... + }, + // ... + }, +}); +``` diff --git a/dashboard/src/components/folder/index.ts b/dashboard/src/components/folder/index.ts new file mode 100644 index 000000000..07fde8313 --- /dev/null +++ b/dashboard/src/components/folder/index.ts @@ -0,0 +1,46 @@ +/** + * 通用文件夹管理组件库 + * + * 提供可复用的文件夹管理 UI 组件,适用于各种需要文件夹组织功能的场景 + * 如:persona 管理、模板管理、知识库管理等 + * + * 使用示例: + * ```vue + * + * ``` + */ + +// 类型导出 +export * from './types'; + +// Composable 导出 +export { useFolderManager, collectFolderAndChildrenIds } from './useFolderManager'; +export type { UseFolderManagerOptions, UseFolderManagerReturn } from './useFolderManager'; + +// 组件导出 +export { default as BaseFolderTree } from './BaseFolderTree.vue'; +export { default as BaseFolderTreeNode } from './BaseFolderTreeNode.vue'; +export { default as BaseFolderCard } from './BaseFolderCard.vue'; +export { default as BaseFolderBreadcrumb } from './BaseFolderBreadcrumb.vue'; +export { default as BaseCreateFolderDialog } from './BaseCreateFolderDialog.vue'; +export { default as BaseMoveToFolderDialog } from './BaseMoveToFolderDialog.vue'; +export { default as BaseMoveTargetNode } from './BaseMoveTargetNode.vue'; diff --git a/dashboard/src/components/folder/types.ts b/dashboard/src/components/folder/types.ts new file mode 100644 index 000000000..20a8b5e14 --- /dev/null +++ b/dashboard/src/components/folder/types.ts @@ -0,0 +1,200 @@ +/** + * 通用文件夹管理组件类型定义 + * + * 这是一个可复用的文件夹管理系统,可用于管理各种类型的项目(如 persona、模板、知识库等) + */ + +/** + * 文件夹基础接口 + */ +export interface Folder { + folder_id: string; + name: string; + parent_id: string | null; + description?: string | null; + sort_order?: number; + created_at?: string; + updated_at?: string; +} + +/** + * 文件夹树节点接口 + */ +export interface FolderTreeNode extends Folder { + children: FolderTreeNode[]; +} + +/** + * 可拖拽的项目接口(可以是文件夹或其他项目) + */ +export interface DraggableItem { + id: string; + type: string; + [key: string]: any; +} + +/** + * 拖拽放置事件数据 + */ +export interface DropEventData { + item_id: string; + item_type: string; + target_folder_id: string | null; + source_data?: any; +} + +/** + * 文件夹操作接口 - 由使用方提供具体实现 + */ +export interface FolderOperations { + // 加载文件夹树 + loadFolderTree: () => Promise; + + // 加载指定文件夹的子文件夹 + loadSubFolders: (parentId: string | null) => Promise; + + // 创建文件夹 + createFolder: (data: CreateFolderData) => Promise; + + // 更新文件夹 + updateFolder: (data: UpdateFolderData) => Promise; + + // 删除文件夹 + deleteFolder: (folderId: string) => Promise; + + // 移动文件夹 + moveFolder?: (folderId: string, targetParentId: string | null) => Promise; +} + +/** + * 创建文件夹数据 + */ +export interface CreateFolderData { + name: string; + parent_id?: string | null; + description?: string; +} + +/** + * 更新文件夹数据 + */ +export interface UpdateFolderData { + folder_id: string; + name?: string; + description?: string; + parent_id?: string | null; +} + +/** + * 文件夹管理器状态 + */ +export interface FolderManagerState { + folderTree: FolderTreeNode[]; + currentFolderId: string | null; + currentFolders: Folder[]; + breadcrumbPath: FolderTreeNode[]; + expandedFolderIds: string[]; + loading: boolean; + treeLoading: boolean; +} + +/** + * 面包屑项接口 + */ +export interface BreadcrumbItem { + title: string; + folderId: string | null; + disabled: boolean; + isRoot: boolean; +} + +/** + * 上下文菜单事件 + */ +export interface ContextMenuEvent { + event: MouseEvent; + folder: Folder; +} + +/** + * 文件夹组件 i18n 键配置 + * 允许使用方自定义翻译键 + */ +export interface FolderI18nKeys { + // 搜索框 + searchPlaceholder?: string; + + // 根目录 + rootFolder?: string; + + // 侧边栏标题 + sidebarTitle?: string; + + // 空状态 + noFolders?: string; + + // 文件夹标题 + foldersTitle?: string; + + // 按钮 + buttons?: { + create?: string; + cancel?: string; + save?: string; + delete?: string; + move?: string; + }; + + // 表单 + form?: { + name?: string; + description?: string; + }; + + // 验证 + validation?: { + nameRequired?: string; + }; + + // 右键菜单 + contextMenu?: { + open?: string; + rename?: string; + moveTo?: string; + delete?: string; + }; + + // 对话框 + dialogs?: { + createTitle?: string; + renameTitle?: string; + deleteTitle?: string; + deleteMessage?: string; + deleteWarning?: string; + moveTitle?: string; + moveDescription?: string; + }; + + // 消息 + messages?: { + createSuccess?: string; + createError?: string; + renameSuccess?: string; + renameError?: string; + deleteSuccess?: string; + deleteError?: string; + moveSuccess?: string; + moveError?: string; + }; +} + +/** + * 通用文件夹组件 Props + */ +export interface BaseFolderProps { + // i18n 翻译函数 + t?: (key: string, params?: Record) => string; + + // i18n 键配置 + i18nKeys?: FolderI18nKeys; +} diff --git a/dashboard/src/components/folder/useFolderManager.ts b/dashboard/src/components/folder/useFolderManager.ts new file mode 100644 index 000000000..a6c1e4b22 --- /dev/null +++ b/dashboard/src/components/folder/useFolderManager.ts @@ -0,0 +1,324 @@ +/** + * 通用文件夹管理 Composable + * + * 提供文件夹管理的核心逻辑,可以被不同的业务模块复用 + */ +import { ref, computed, reactive, type Ref, type ComputedRef } from 'vue'; +import type { + Folder, + FolderTreeNode, + FolderOperations, + CreateFolderData, + UpdateFolderData, + BreadcrumbItem, +} from './types'; + +export interface UseFolderManagerOptions { + // 文件夹操作实现 + operations: FolderOperations; + + // 根目录显示名称 + rootFolderName?: string; + + // 是否自动加载 + autoLoad?: boolean; +} + +export interface UseFolderManagerReturn { + // 状态 + folderTree: Ref; + currentFolderId: Ref; + currentFolders: Ref; + breadcrumbPath: Ref; + expandedFolderIds: Ref; + loading: Ref; + treeLoading: Ref; + + // 计算属性 + currentFolderName: ComputedRef; + breadcrumbItems: ComputedRef; + + // 方法 + loadFolderTree: () => Promise; + navigateToFolder: (folderId: string | null) => Promise; + refreshCurrentFolder: () => Promise; + + createFolder: (data: CreateFolderData) => Promise; + updateFolder: (data: UpdateFolderData) => Promise; + deleteFolder: (folderId: string) => Promise; + moveFolder: (folderId: string, targetParentId: string | null) => Promise; + + toggleFolderExpansion: (folderId: string) => void; + setFolderExpansion: (folderId: string, expanded: boolean) => void; + + findFolderInTree: (folderId: string) => FolderTreeNode | null; + findPathToFolder: (folderId: string) => FolderTreeNode[]; + + filterTreeBySearch: (query: string) => FolderTreeNode[]; +} + +/** + * 创建文件夹管理 composable + */ +export function useFolderManager(options: UseFolderManagerOptions): UseFolderManagerReturn { + const { operations, rootFolderName = '根目录', autoLoad = false } = options; + + // 状态 + const folderTree = ref([]); + const currentFolderId = ref(null); + const currentFolders = ref([]); + const breadcrumbPath = ref([]); + const expandedFolderIds = ref([]); + const loading = ref(false); + const treeLoading = ref(false); + + // 计算属性 + const currentFolderName = computed(() => { + if (breadcrumbPath.value.length === 0) { + return rootFolderName; + } + return breadcrumbPath.value[breadcrumbPath.value.length - 1]?.name || rootFolderName; + }); + + const breadcrumbItems = computed((): BreadcrumbItem[] => { + const items: BreadcrumbItem[] = [ + { + title: rootFolderName, + folderId: null, + disabled: currentFolderId.value === null, + isRoot: true, + }, + ]; + + breadcrumbPath.value.forEach((folder, index) => { + items.push({ + title: folder.name, + folderId: folder.folder_id, + disabled: index === breadcrumbPath.value.length - 1, + isRoot: false, + }); + }); + + return items; + }); + + // 内部方法 + const findPathToFolderInternal = ( + nodes: FolderTreeNode[], + targetId: string, + path: FolderTreeNode[] = [] + ): FolderTreeNode[] | null => { + for (const node of nodes) { + if (node.folder_id === targetId) { + return [...path, node]; + } + if (node.children && node.children.length > 0) { + const result = findPathToFolderInternal(node.children, targetId, [...path, node]); + if (result) return result; + } + } + return null; + }; + + const updateBreadcrumb = (folderId: string | null): void => { + if (folderId === null) { + breadcrumbPath.value = []; + return; + } + + const path = findPathToFolderInternal(folderTree.value, folderId); + breadcrumbPath.value = path || []; + }; + + // 公开方法 + const loadFolderTree = async (): Promise => { + treeLoading.value = true; + try { + folderTree.value = await operations.loadFolderTree(); + } finally { + treeLoading.value = false; + } + }; + + const navigateToFolder = async (folderId: string | null): Promise => { + loading.value = true; + try { + currentFolderId.value = folderId; + currentFolders.value = await operations.loadSubFolders(folderId); + updateBreadcrumb(folderId); + } finally { + loading.value = false; + } + }; + + const refreshCurrentFolder = async (): Promise => { + await navigateToFolder(currentFolderId.value); + }; + + const createFolder = async (data: CreateFolderData): Promise => { + const folder = await operations.createFolder({ + ...data, + parent_id: data.parent_id ?? currentFolderId.value, + }); + + await Promise.all([refreshCurrentFolder(), loadFolderTree()]); + + return folder; + }; + + const updateFolder = async (data: UpdateFolderData): Promise => { + await operations.updateFolder(data); + await Promise.all([refreshCurrentFolder(), loadFolderTree()]); + }; + + const deleteFolder = async (folderId: string): Promise => { + await operations.deleteFolder(folderId); + await Promise.all([refreshCurrentFolder(), loadFolderTree()]); + }; + + const moveFolder = async (folderId: string, targetParentId: string | null): Promise => { + if (operations.moveFolder) { + await operations.moveFolder(folderId, targetParentId); + } else { + // 如果没有专门的移动方法,使用更新方法 + await operations.updateFolder({ + folder_id: folderId, + parent_id: targetParentId, + }); + } + await Promise.all([refreshCurrentFolder(), loadFolderTree()]); + }; + + const toggleFolderExpansion = (folderId: string): void => { + const index = expandedFolderIds.value.indexOf(folderId); + if (index === -1) { + expandedFolderIds.value.push(folderId); + } else { + expandedFolderIds.value.splice(index, 1); + } + }; + + const setFolderExpansion = (folderId: string, expanded: boolean): void => { + const index = expandedFolderIds.value.indexOf(folderId); + if (expanded && index === -1) { + expandedFolderIds.value.push(folderId); + } else if (!expanded && index !== -1) { + expandedFolderIds.value.splice(index, 1); + } + }; + + const findFolderInTree = (folderId: string): FolderTreeNode | null => { + const findNode = (nodes: FolderTreeNode[]): FolderTreeNode | null => { + for (const node of nodes) { + if (node.folder_id === folderId) { + return node; + } + if (node.children && node.children.length > 0) { + const found = findNode(node.children); + if (found) return found; + } + } + return null; + }; + return findNode(folderTree.value); + }; + + const findPathToFolder = (folderId: string): FolderTreeNode[] => { + return findPathToFolderInternal(folderTree.value, folderId) || []; + }; + + const filterTreeBySearch = (query: string): FolderTreeNode[] => { + if (!query) return folderTree.value; + + const lowerQuery = query.toLowerCase(); + + const filterNodes = (nodes: FolderTreeNode[]): FolderTreeNode[] => { + return nodes + .filter((node) => { + const matches = node.name.toLowerCase().includes(lowerQuery); + const childMatches = filterNodes(node.children || []); + return matches || childMatches.length > 0; + }) + .map((node) => ({ + ...node, + children: filterNodes(node.children || []), + })); + }; + + return filterNodes(folderTree.value); + }; + + // 自动加载 + if (autoLoad) { + loadFolderTree(); + navigateToFolder(null); + } + + return { + // 状态 + folderTree, + currentFolderId, + currentFolders, + breadcrumbPath, + expandedFolderIds, + loading, + treeLoading, + + // 计算属性 + currentFolderName, + breadcrumbItems, + + // 方法 + loadFolderTree, + navigateToFolder, + refreshCurrentFolder, + createFolder, + updateFolder, + deleteFolder, + moveFolder, + toggleFolderExpansion, + setFolderExpansion, + findFolderInTree, + findPathToFolder, + filterTreeBySearch, + }; +} + +/** + * 收集文件夹及其所有子文件夹的 ID + * 用于禁用移动对话框中不能选择的目标 + */ +export function collectFolderAndChildrenIds( + folderTree: FolderTreeNode[], + folderId: string +): string[] { + const ids: string[] = [folderId]; + + const collectChildIds = (nodes: FolderTreeNode[]): boolean => { + for (const node of nodes) { + if (node.folder_id === folderId) { + const collectAllChildren = (children: FolderTreeNode[]) => { + for (const child of children) { + ids.push(child.folder_id); + if (child.children) { + collectAllChildren(child.children); + } + } + }; + if (node.children) { + collectAllChildren(node.children); + } + return true; + } + if (node.children && collectChildIds(node.children)) { + return true; + } + } + return false; + }; + + collectChildIds(folderTree); + return ids; +} + +export default useFolderManager; diff --git a/dashboard/src/views/PersonaPage.vue b/dashboard/src/views/PersonaPage.vue index f985c6851..102028c6d 100644 --- a/dashboard/src/views/PersonaPage.vue +++ b/dashboard/src/views/PersonaPage.vue @@ -21,7 +21,7 @@ diff --git a/dashboard/src/views/persona/FolderBreadcrumb.vue b/dashboard/src/views/persona/FolderBreadcrumb.vue new file mode 100644 index 000000000..9e4c57b60 --- /dev/null +++ b/dashboard/src/views/persona/FolderBreadcrumb.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/dashboard/src/views/persona/FolderCard.vue b/dashboard/src/views/persona/FolderCard.vue new file mode 100644 index 000000000..5ee4a14a0 --- /dev/null +++ b/dashboard/src/views/persona/FolderCard.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/dashboard/src/views/persona/FolderTree.vue b/dashboard/src/views/persona/FolderTree.vue new file mode 100644 index 000000000..13c596990 --- /dev/null +++ b/dashboard/src/views/persona/FolderTree.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/dashboard/src/views/persona/FolderTreeNode.vue b/dashboard/src/views/persona/FolderTreeNode.vue new file mode 100644 index 000000000..c6a511fda --- /dev/null +++ b/dashboard/src/views/persona/FolderTreeNode.vue @@ -0,0 +1,66 @@ + + + diff --git a/dashboard/src/views/persona/MoveTargetNode.vue b/dashboard/src/views/persona/MoveTargetNode.vue new file mode 100644 index 000000000..90e1113f8 --- /dev/null +++ b/dashboard/src/views/persona/MoveTargetNode.vue @@ -0,0 +1,36 @@ + + + diff --git a/dashboard/src/views/persona/MoveToFolderDialog.vue b/dashboard/src/views/persona/MoveToFolderDialog.vue new file mode 100644 index 000000000..aeae03d3a --- /dev/null +++ b/dashboard/src/views/persona/MoveToFolderDialog.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/dashboard/src/views/persona/PersonaCard.vue b/dashboard/src/views/persona/PersonaCard.vue new file mode 100644 index 000000000..1468cda83 --- /dev/null +++ b/dashboard/src/views/persona/PersonaCard.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/dashboard/src/views/persona/PersonaManager.vue b/dashboard/src/views/persona/PersonaManager.vue new file mode 100644 index 000000000..985bad4c5 --- /dev/null +++ b/dashboard/src/views/persona/PersonaManager.vue @@ -0,0 +1,501 @@ + + + + + diff --git a/dashboard/src/views/persona/index.ts b/dashboard/src/views/persona/index.ts new file mode 100644 index 000000000..322155b93 --- /dev/null +++ b/dashboard/src/views/persona/index.ts @@ -0,0 +1,23 @@ +/** + * Persona 管理相关组件 + * + * 这些组件使用了 dashboard/src/components/folder 下的通用文件夹组件 + * 通过包装器模式将 personaStore 的状态和方法连接到通用组件 + */ + +// 主组件 +export { default as PersonaManager } from './PersonaManager.vue'; + +// 文件夹相关组件 +export { default as FolderTree } from './FolderTree.vue'; +export { default as FolderTreeNode } from './FolderTreeNode.vue'; +export { default as FolderBreadcrumb } from './FolderBreadcrumb.vue'; +export { default as FolderCard } from './FolderCard.vue'; + +// 对话框组件 +export { default as CreateFolderDialog } from './CreateFolderDialog.vue'; +export { default as MoveToFolderDialog } from './MoveToFolderDialog.vue'; +export { default as MoveTargetNode } from './MoveTargetNode.vue'; + +// Persona 相关组件 +export { default as PersonaCard } from './PersonaCard.vue'; From 442ecee2f654e776c75277c0e610b93d4994a6d0 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 12 Jan 2026 15:17:51 +0800 Subject: [PATCH 07/16] refactor(dashboard): remove legacy persona folder management components Remove deprecated persona folder management Vue components that have been superseded by the new reusable folder management component library. Deleted components: - CreateFolderDialog.vue - FolderBreadcrumb.vue - FolderCard.vue - FolderTree.vue - FolderTreeNode.vue - MoveTargetNode.vue - MoveToFolderDialog.vue - PersonaCard.vue - PersonaManager.vue These components are replaced by the centralized folder management implementation introduced in commit 3fbb3db2. --- .../components/persona/CreateFolderDialog.vue | 117 ----- .../components/persona/FolderBreadcrumb.vue | 78 --- .../src/components/persona/FolderCard.vue | 114 ----- .../src/components/persona/FolderTree.vue | 295 ----------- .../src/components/persona/FolderTreeNode.vue | 140 ----- .../src/components/persona/MoveTargetNode.vue | 90 ---- .../components/persona/MoveToFolderDialog.vue | 198 ------- .../src/components/persona/PersonaCard.vue | 163 ------ .../src/components/persona/PersonaManager.vue | 482 ------------------ 9 files changed, 1677 deletions(-) delete mode 100644 dashboard/src/components/persona/CreateFolderDialog.vue delete mode 100644 dashboard/src/components/persona/FolderBreadcrumb.vue delete mode 100644 dashboard/src/components/persona/FolderCard.vue delete mode 100644 dashboard/src/components/persona/FolderTree.vue delete mode 100644 dashboard/src/components/persona/FolderTreeNode.vue delete mode 100644 dashboard/src/components/persona/MoveTargetNode.vue delete mode 100644 dashboard/src/components/persona/MoveToFolderDialog.vue delete mode 100644 dashboard/src/components/persona/PersonaCard.vue delete mode 100644 dashboard/src/components/persona/PersonaManager.vue diff --git a/dashboard/src/components/persona/CreateFolderDialog.vue b/dashboard/src/components/persona/CreateFolderDialog.vue deleted file mode 100644 index 8f1c01ca2..000000000 --- a/dashboard/src/components/persona/CreateFolderDialog.vue +++ /dev/null @@ -1,117 +0,0 @@ - - - diff --git a/dashboard/src/components/persona/FolderBreadcrumb.vue b/dashboard/src/components/persona/FolderBreadcrumb.vue deleted file mode 100644 index 1c1ee762c..000000000 --- a/dashboard/src/components/persona/FolderBreadcrumb.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - - - diff --git a/dashboard/src/components/persona/FolderCard.vue b/dashboard/src/components/persona/FolderCard.vue deleted file mode 100644 index af317f6a0..000000000 --- a/dashboard/src/components/persona/FolderCard.vue +++ /dev/null @@ -1,114 +0,0 @@ - - - - - diff --git a/dashboard/src/components/persona/FolderTree.vue b/dashboard/src/components/persona/FolderTree.vue deleted file mode 100644 index 036459316..000000000 --- a/dashboard/src/components/persona/FolderTree.vue +++ /dev/null @@ -1,295 +0,0 @@ - - - - - diff --git a/dashboard/src/components/persona/FolderTreeNode.vue b/dashboard/src/components/persona/FolderTreeNode.vue deleted file mode 100644 index 409b33aa9..000000000 --- a/dashboard/src/components/persona/FolderTreeNode.vue +++ /dev/null @@ -1,140 +0,0 @@ - - - - - diff --git a/dashboard/src/components/persona/MoveTargetNode.vue b/dashboard/src/components/persona/MoveTargetNode.vue deleted file mode 100644 index 9558b54d2..000000000 --- a/dashboard/src/components/persona/MoveTargetNode.vue +++ /dev/null @@ -1,90 +0,0 @@ - - - - - diff --git a/dashboard/src/components/persona/MoveToFolderDialog.vue b/dashboard/src/components/persona/MoveToFolderDialog.vue deleted file mode 100644 index b897ae926..000000000 --- a/dashboard/src/components/persona/MoveToFolderDialog.vue +++ /dev/null @@ -1,198 +0,0 @@ - - - - - diff --git a/dashboard/src/components/persona/PersonaCard.vue b/dashboard/src/components/persona/PersonaCard.vue deleted file mode 100644 index 8feb5c78b..000000000 --- a/dashboard/src/components/persona/PersonaCard.vue +++ /dev/null @@ -1,163 +0,0 @@ - - - - - diff --git a/dashboard/src/components/persona/PersonaManager.vue b/dashboard/src/components/persona/PersonaManager.vue deleted file mode 100644 index 984703eaa..000000000 --- a/dashboard/src/components/persona/PersonaManager.vue +++ /dev/null @@ -1,482 +0,0 @@ - - - - - From 4243d2f7b5d9019ee0b46a019514c7934c1e8633 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 12 Jan 2026 15:29:07 +0800 Subject: [PATCH 08/16] fix(dashboard): add delayed skeleton loading to prevent UI flicker Implement a 150ms delay before showing the skeleton loader in PersonaManager to prevent visual flicker during fast loading operations. - Add showSkeleton state with timer-based delay mechanism - Use v-fade-transition for smooth skeleton visibility transitions - Clean up timer on component unmount to prevent memory leaks - Only display skeleton when loading exceeds threshold duration --- .../src/views/persona/PersonaManager.vue | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/dashboard/src/views/persona/PersonaManager.vue b/dashboard/src/views/persona/PersonaManager.vue index 985bad4c5..c818ac512 100644 --- a/dashboard/src/views/persona/PersonaManager.vue +++ b/dashboard/src/views/persona/PersonaManager.vue @@ -39,17 +39,19 @@ - -
- - - - - -
+ + +
+ + + + + +
+
-
+

@@ -298,12 +300,46 @@ export default defineComponent({ // 消息提示 showMessage: false, message: '', - messageType: 'success' as 'success' | 'error' + messageType: 'success' as 'success' | 'error', + + // 骨架屏延迟显示控制 + showSkeleton: false, + skeletonTimer: null as ReturnType | null }; }, computed: { ...mapState(usePersonaStore, ['folderTree', 'currentFolderId', 'currentFolders', 'currentPersonas', 'loading']) }, + watch: { + // 监听 loading 状态变化,实现延迟显示骨架屏 + loading: { + handler(newVal: boolean) { + if (newVal) { + // 加载开始时,延迟 150ms 后才显示骨架屏 + // 如果加载在 150ms 内完成,则不显示骨架屏,避免闪烁 + this.skeletonTimer = setTimeout(() => { + if (this.loading) { + this.showSkeleton = true; + } + }, 150); + } else { + // 加载结束,立即隐藏骨架屏并清除定时器 + if (this.skeletonTimer) { + clearTimeout(this.skeletonTimer); + this.skeletonTimer = null; + } + this.showSkeleton = false; + } + }, + immediate: true + } + }, + beforeUnmount() { + // 组件卸载时清除定时器 + if (this.skeletonTimer) { + clearTimeout(this.skeletonTimer); + } + }, async mounted() { await this.initialize(); }, From d621e432ff48a6314547e05797661f82f512a09c Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 12 Jan 2026 16:00:15 +0800 Subject: [PATCH 09/16] feat(dashboard): add generic folder item selector component for persona selection Introduce BaseFolderItemSelector.vue as a reusable component for selecting items within folder hierarchies. Refactor PersonaSelector to use this new base component instead of its previous flat list implementation. Changes: - Add BaseFolderItemSelector with folder tree navigation and item selection - Extend folder types with SelectableItem and FolderItemSelectorLabels - Refactor PersonaSelector to leverage the new base component - Add i18n translations for rootFolder and emptyFolder labels --- .../folder/BaseFolderItemSelector.vue | 513 ++++++++++++++++++ dashboard/src/components/folder/types.ts | 49 ++ .../src/components/shared/PersonaSelector.vue | 241 ++++---- .../src/i18n/locales/en-US/core/shared.json | 4 +- .../src/i18n/locales/zh-CN/core/shared.json | 4 +- 5 files changed, 683 insertions(+), 128 deletions(-) create mode 100644 dashboard/src/components/folder/BaseFolderItemSelector.vue diff --git a/dashboard/src/components/folder/BaseFolderItemSelector.vue b/dashboard/src/components/folder/BaseFolderItemSelector.vue new file mode 100644 index 000000000..8ba37b789 --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderItemSelector.vue @@ -0,0 +1,513 @@ + + + + + diff --git a/dashboard/src/components/folder/types.ts b/dashboard/src/components/folder/types.ts index 20a8b5e14..6fbeb39c7 100644 --- a/dashboard/src/components/folder/types.ts +++ b/dashboard/src/components/folder/types.ts @@ -198,3 +198,52 @@ export interface BaseFolderProps { // i18n 键配置 i18nKeys?: FolderI18nKeys; } + +/** + * 可选择的项目基础接口 + */ +export interface SelectableItem { + id: string; + name: string; + description?: string | null; + folder_id?: string | null; + [key: string]: any; +} + +/** + * 文件夹项目选择器操作接口 + */ +export interface FolderItemSelectorOperations { + // 加载文件夹树 + loadFolderTree: () => Promise; + + // 加载指定文件夹下的项目 + loadItemsInFolder: (folderId: string | null) => Promise; + + // 创建项目(可选) + createItem?: (data: any) => Promise; +} + +/** + * 文件夹项目选择器标签配置 + */ +export interface FolderItemSelectorLabels { + // 对话框 + dialogTitle?: string; + notSelected?: string; + buttonText?: string; + + // 项目列表 + noItems?: string; + defaultItem?: string; + noDescription?: string; + emptyFolder?: string; + + // 按钮 + createButton?: string; + confirmButton?: string; + cancelButton?: string; + + // 文件夹 + rootFolder?: string; +} diff --git a/dashboard/src/components/shared/PersonaSelector.vue b/dashboard/src/components/shared/PersonaSelector.vue index 2d4c84fa0..dda3f8894 100644 --- a/dashboard/src/components/shared/PersonaSelector.vue +++ b/dashboard/src/components/shared/PersonaSelector.vue @@ -1,84 +1,44 @@ - diff --git a/dashboard/src/i18n/locales/en-US/core/shared.json b/dashboard/src/i18n/locales/en-US/core/shared.json index 772e53112..ed4fefc55 100644 --- a/dashboard/src/i18n/locales/en-US/core/shared.json +++ b/dashboard/src/i18n/locales/en-US/core/shared.json @@ -57,7 +57,9 @@ "createPersona": "Create New Persona", "cancelSelection": "Cancel", "confirmSelection": "Confirm Selection", - "selectPersonaPool": "Select Persona Pool..." + "selectPersonaPool": "Select Persona Pool...", + "rootFolder": "All Personas", + "emptyFolder": "This folder is empty" }, "t2iTemplateEditor": { "buttonText": "Customize T2I Template", diff --git a/dashboard/src/i18n/locales/zh-CN/core/shared.json b/dashboard/src/i18n/locales/zh-CN/core/shared.json index 83be5caa1..106700551 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/shared.json +++ b/dashboard/src/i18n/locales/zh-CN/core/shared.json @@ -57,7 +57,9 @@ "createPersona": "创建新人格", "cancelSelection": "取消", "confirmSelection": "确认选择", - "selectPersonaPool": "选择人格池..." + "selectPersonaPool": "选择人格池...", + "rootFolder": "全部人格", + "emptyFolder": "此文件夹为空" }, "t2iTemplateEditor": { "buttonText": "自定义 T2I 模板", From 35f22cda39245bfd932c380548476cfeb745a1d1 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 12 Jan 2026 16:11:17 +0800 Subject: [PATCH 10/16] feat(persona): add tree-view display for persona list command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add hierarchical folder tree output for the persona list command, showing personas organized by folders with visual tree connectors. - Add _build_tree_output method for recursive tree structure rendering - Display folders with 📁 icon and personas with 👤 icon - Show root-level personas separately from folder contents - Include total persona count in output --- .../builtin_commands/commands/persona.py | 85 +++++++++++++++++-- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/astrbot/builtin_stars/builtin_commands/commands/persona.py b/astrbot/builtin_stars/builtin_commands/commands/persona.py index 13a57f07f..293ce5934 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/persona.py +++ b/astrbot/builtin_stars/builtin_commands/commands/persona.py @@ -1,13 +1,66 @@ import builtins +from typing import TYPE_CHECKING from astrbot.api import sp, star from astrbot.api.event import AstrMessageEvent, MessageEventResult +if TYPE_CHECKING: + from astrbot.core.db.po import Persona + class PersonaCommands: def __init__(self, context: star.Context): self.context = context + def _build_tree_output( + self, + folder_tree: list[dict], + all_personas: list["Persona"], + prefix: str = "", + is_last: bool = True, + ) -> list[str]: + """递归构建树状输出,使用线条表示层级""" + lines: list[str] = [] + + for i, folder in enumerate(folder_tree): + is_folder_last = i == len(folder_tree) - 1 + + # 获取该文件夹下的人格 + folder_personas = [ + p for p in all_personas if p.folder_id == folder["folder_id"] + ] + children = folder.get("children", []) + has_content = len(folder_personas) > 0 or len(children) > 0 + + # 输出文件夹 + connector = "└─" if is_folder_last else "├─" + lines.append(f"{prefix}{connector} 📁 {folder['name']}") + + # 计算子项的前缀 + child_prefix = prefix + (" " if is_folder_last else "│ ") + + # 输出该文件夹下的人格 + total_items = len(folder_personas) + len(children) + item_idx = 0 + + for persona in folder_personas: + item_idx += 1 + item_connector = "└─" if item_idx == total_items else "├─" + lines.append(f"{child_prefix}{item_connector} 👤 {persona.persona_id}") + + # 递归处理子文件夹 + if children: + lines.extend( + self._build_tree_output( + children, + all_personas, + child_prefix, + is_folder_last, + ) + ) + + return lines + async def persona(self, message: AstrMessageEvent): l = message.message_str.split(" ") # noqa: E741 umo = message.unified_msg_origin @@ -69,12 +122,32 @@ async def persona(self, message: AstrMessageEvent): .use_t2i(False), ) elif l[1] == "list": - parts = ["人格列表:\n"] - for persona in self.context.provider_manager.personas: - parts.append(f"- {persona['name']}\n") - parts.append("\n\n*输入 `/persona view 人格名` 查看人格详细信息") - msg = "".join(parts) - message.set_result(MessageEventResult().message(msg)) + # 获取文件夹树和所有人格 + folder_tree = await self.context.persona_manager.get_folder_tree() + all_personas = self.context.persona_manager.personas + + lines = ["📂 人格列表:\n"] + + # 构建树状输出 + tree_lines = self._build_tree_output(folder_tree, all_personas) + lines.extend(tree_lines) + + # 输出根目录下的人格(没有文件夹的) + root_personas = [p for p in all_personas if p.folder_id is None] + if root_personas: + if tree_lines: # 如果有文件夹内容,加个空行 + lines.append("") + for persona in root_personas: + lines.append(f"👤 {persona.persona_id}") + + # 统计信息 + total_count = len(all_personas) + lines.append(f"\n共 {total_count} 个人格") + lines.append("\n*使用 `/persona <人格名>` 设置人格") + lines.append("*使用 `/persona view <人格名>` 查看详细信息") + + msg = "\n".join(lines) + message.set_result(MessageEventResult().message(msg).use_t2i(False)) elif l[1] == "view": if len(l) == 2: message.set_result(MessageEventResult().message("请输入人格情景名")) From 92febb77baf01ef45b191d6e38f72d179ff31deb Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 12 Jan 2026 16:18:33 +0800 Subject: [PATCH 11/16] refactor(persona): simplify tree-view output with shorter indentation lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace complex tree connector logic with simpler depth-based indentation using "│ " prefix. Remove unnecessary parameters (prefix, is_last) and computed variables (has_content, total_items, item_idx) in favor of a cleaner depth-based approach. --- .../builtin_commands/commands/persona.py | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/astrbot/builtin_stars/builtin_commands/commands/persona.py b/astrbot/builtin_stars/builtin_commands/commands/persona.py index 293ce5934..109164657 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/persona.py +++ b/astrbot/builtin_stars/builtin_commands/commands/persona.py @@ -16,46 +16,35 @@ def _build_tree_output( self, folder_tree: list[dict], all_personas: list["Persona"], - prefix: str = "", - is_last: bool = True, + depth: int = 0, ) -> list[str]: - """递归构建树状输出,使用线条表示层级""" + """递归构建树状输出,使用短线条表示层级""" lines: list[str] = [] + # 使用短线条作为缩进前缀,每层只用 "│" 加一个空格 + prefix = "│ " * depth - for i, folder in enumerate(folder_tree): - is_folder_last = i == len(folder_tree) - 1 + for folder in folder_tree: + # 输出文件夹 + lines.append(f"{prefix}├ 📁 {folder['name']}/") # 获取该文件夹下的人格 folder_personas = [ p for p in all_personas if p.folder_id == folder["folder_id"] ] - children = folder.get("children", []) - has_content = len(folder_personas) > 0 or len(children) > 0 - - # 输出文件夹 - connector = "└─" if is_folder_last else "├─" - lines.append(f"{prefix}{connector} 📁 {folder['name']}") - - # 计算子项的前缀 - child_prefix = prefix + (" " if is_folder_last else "│ ") + child_prefix = "│ " * (depth + 1) # 输出该文件夹下的人格 - total_items = len(folder_personas) + len(children) - item_idx = 0 - for persona in folder_personas: - item_idx += 1 - item_connector = "└─" if item_idx == total_items else "├─" - lines.append(f"{child_prefix}{item_connector} 👤 {persona.persona_id}") + lines.append(f"{child_prefix}├ 👤 {persona.persona_id}") # 递归处理子文件夹 + children = folder.get("children", []) if children: lines.extend( self._build_tree_output( children, all_personas, - child_prefix, - is_folder_last, + depth + 1, ) ) From c766bf538221855f73b2534dea2ec86b174b946d Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 12 Jan 2026 16:23:10 +0800 Subject: [PATCH 12/16] feat(dashboard): add duplicate persona ID validation in create form Add frontend validation to prevent creating personas with duplicate IDs. Load existing persona IDs when opening the create form and validate against them in real-time. - Add existingPersonaIds array and loadExistingPersonaIds method - Add validation rule to check for duplicate persona IDs - Add i18n messages for duplicate ID error (en-US and zh-CN) - Fix minLength validation to require at least 1 character --- .../src/components/shared/PersonaForm.vue | 18 +++++++++++++++++- .../i18n/locales/en-US/features/persona.json | 3 ++- .../i18n/locales/zh-CN/features/persona.json | 3 ++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/dashboard/src/components/shared/PersonaForm.vue b/dashboard/src/components/shared/PersonaForm.vue index c25f5e695..6ce2787a9 100644 --- a/dashboard/src/components/shared/PersonaForm.vue +++ b/dashboard/src/components/shared/PersonaForm.vue @@ -229,6 +229,7 @@ export default { mcpServers: [], availableTools: [], loadingTools: false, + existingPersonaIds: [], // 已存在的人格ID列表 personaForm: { persona_id: '', system_prompt: '', @@ -238,7 +239,8 @@ export default { }, personaIdRules: [ v => !!v || this.tm('validation.required'), - v => (v && v.length >= 0) || this.tm('validation.minLength', { min: 2 }), + v => (v && v.length >= 1) || this.tm('validation.minLength', { min: 1 }), + v => !this.existingPersonaIds.includes(v) || this.tm('validation.personaIdExists'), ], systemPromptRules: [ v => !!v || this.tm('validation.required'), @@ -278,6 +280,8 @@ export default { this.initFormWithPersona(this.editingPersona); } else { this.initForm(); + // 只在创建新人格时加载已存在的人格列表 + this.loadExistingPersonaIds(); } this.loadMcpServers(); this.loadTools(); @@ -370,6 +374,18 @@ export default { } }, + async loadExistingPersonaIds() { + try { + const response = await axios.get('/api/persona/list'); + if (response.data.status === 'ok') { + this.existingPersonaIds = (response.data.data || []).map(p => p.persona_id); + } + } catch (error) { + // 加载失败不影响表单使用,只是无法进行前端重名校验 + this.existingPersonaIds = []; + } + }, + async savePersona() { if (!this.formValid) return; diff --git a/dashboard/src/i18n/locales/en-US/features/persona.json b/dashboard/src/i18n/locales/en-US/features/persona.json index 4da71c9b0..cb46585ff 100644 --- a/dashboard/src/i18n/locales/en-US/features/persona.json +++ b/dashboard/src/i18n/locales/en-US/features/persona.json @@ -57,7 +57,8 @@ "required": "This field is required", "minLength": "Minimum {min} characters required", "alphanumeric": "Only letters, numbers, underscores and hyphens are allowed", - "dialogRequired": "{type} cannot be empty" + "dialogRequired": "{type} cannot be empty", + "personaIdExists": "This persona name already exists" }, "messages": { "loadError": "Failed to load persona list", diff --git a/dashboard/src/i18n/locales/zh-CN/features/persona.json b/dashboard/src/i18n/locales/zh-CN/features/persona.json index adf8cc359..d3e36b855 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/persona.json +++ b/dashboard/src/i18n/locales/zh-CN/features/persona.json @@ -57,7 +57,8 @@ "required": "此字段为必填项", "minLength": "最少需要 {min} 个字符", "alphanumeric": "只能包含字母、数字、下划线和连字符", - "dialogRequired": "{type}不能为空" + "dialogRequired": "{type}不能为空", + "personaIdExists": "该人格名称已存在" }, "messages": { "loadError": "加载人格列表失败", From b96c1e477421493555cd7e3f74052067fe1ad85f Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 12 Jan 2026 16:31:48 +0800 Subject: [PATCH 13/16] i18n(persona): add createButton translation key for folder dialog Move create button label to folder-specific translation path instead of using generic buttons.create key. --- dashboard/src/i18n/locales/en-US/features/persona.json | 3 ++- dashboard/src/i18n/locales/zh-CN/features/persona.json | 3 ++- dashboard/src/views/persona/CreateFolderDialog.vue | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/dashboard/src/i18n/locales/en-US/features/persona.json b/dashboard/src/i18n/locales/en-US/features/persona.json index cb46585ff..2df4b1aa5 100644 --- a/dashboard/src/i18n/locales/en-US/features/persona.json +++ b/dashboard/src/i18n/locales/en-US/features/persona.json @@ -100,7 +100,8 @@ "delete": "Delete" }, "createDialog": { - "title": "Create New Folder" + "title": "Create New Folder", + "createButton": "Create" }, "renameDialog": { "title": "Rename Folder" diff --git a/dashboard/src/i18n/locales/zh-CN/features/persona.json b/dashboard/src/i18n/locales/zh-CN/features/persona.json index d3e36b855..938235c2c 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/persona.json +++ b/dashboard/src/i18n/locales/zh-CN/features/persona.json @@ -100,7 +100,8 @@ "delete": "删除" }, "createDialog": { - "title": "创建新文件夹" + "title": "创建新文件夹", + "createButton": "创建" }, "renameDialog": { "title": "重命名文件夹" diff --git a/dashboard/src/views/persona/CreateFolderDialog.vue b/dashboard/src/views/persona/CreateFolderDialog.vue index fa78ba5e8..3106d73c9 100644 --- a/dashboard/src/views/persona/CreateFolderDialog.vue +++ b/dashboard/src/views/persona/CreateFolderDialog.vue @@ -47,7 +47,7 @@ export default defineComponent({ descriptionLabel: this.tm('folder.form.description'), nameRequired: this.tm('folder.validation.nameRequired'), cancelButton: this.tm('buttons.cancel'), - createButton: this.tm('buttons.create') + createButton: this.tm('folder.createDialog.createButton') }; } }, From 52a24ee6dd63be444de0831a8c587a1ae073bf38 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Mon, 12 Jan 2026 16:58:41 +0800 Subject: [PATCH 14/16] feat(persona): show target folder name in persona creation dialog Add visual feedback showing which folder a new persona will be created in. - Add info alert in PersonaForm displaying the target folder name - Pass currentFolderName prop from PersonaManager and PersonaSelector - Add recursive findFolderName helper to resolve folder ID to name - Add i18n translations for createInFolder and rootFolder labels --- .../src/components/shared/PersonaForm.vue | 28 +++++++++++++++++++ .../src/components/shared/PersonaSelector.vue | 26 ++++++++++++++++- .../i18n/locales/en-US/features/persona.json | 4 ++- .../i18n/locales/zh-CN/features/persona.json | 4 ++- .../src/views/persona/PersonaManager.vue | 24 ++++++++++++++-- 5 files changed, 81 insertions(+), 5 deletions(-) diff --git a/dashboard/src/components/shared/PersonaForm.vue b/dashboard/src/components/shared/PersonaForm.vue index 6ce2787a9..4b0afa677 100644 --- a/dashboard/src/components/shared/PersonaForm.vue +++ b/dashboard/src/components/shared/PersonaForm.vue @@ -6,6 +6,18 @@ + + + {{ tm('form.createInFolder', { folder: folderDisplayName }) }} + + - @@ -71,6 +73,28 @@ const defaultPersona: SelectableItem = { system_prompt: 'You are a helpful and friendly assistant.' } +// 递归查找文件夹名称 +function findFolderName(nodes: FolderTreeNode[], folderId: string): string | null { + for (const node of nodes) { + if (node.folder_id === folderId) { + return node.name + } + if (node.children && node.children.length > 0) { + const found = findFolderName(node.children, folderId) + if (found) return found + } + } + return null +} + +// 当前文件夹名称 +const currentFolderName = computed(() => { + if (!currentFolderId.value) { + return null // 根目录,PersonaForm 会使用 tm('form.rootFolder') + } + return findFolderName(folderTree.value, currentFolderId.value) +}) + // 标签配置 const labels = computed(() => ({ dialogTitle: tm('personaSelector.dialogTitle'), diff --git a/dashboard/src/i18n/locales/en-US/features/persona.json b/dashboard/src/i18n/locales/en-US/features/persona.json index 2df4b1aa5..67e3682f0 100644 --- a/dashboard/src/i18n/locales/en-US/features/persona.json +++ b/dashboard/src/i18n/locales/en-US/features/persona.json @@ -37,7 +37,9 @@ "noToolsFound": "No matching tools found", "loadingTools": "Loading tools...", "allToolsAvailable": "Use all available tools", - "noToolsSelected": "No tools selected" + "noToolsSelected": "No tools selected", + "createInFolder": "Will be created in \"{folder}\"", + "rootFolder": "All Personas" }, "dialog": { "create": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/persona.json b/dashboard/src/i18n/locales/zh-CN/features/persona.json index 938235c2c..3f8b1b253 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/persona.json +++ b/dashboard/src/i18n/locales/zh-CN/features/persona.json @@ -37,7 +37,9 @@ "noToolsFound": "未找到匹配的工具", "loadingTools": "正在加载工具...", "allToolsAvailable": "使用所有可用工具", - "noToolsSelected": "未选择任何工具" + "noToolsSelected": "未选择任何工具", + "createInFolder": "将在「{folder}」中创建", + "rootFolder": "全部人格" }, "dialog": { "create": { diff --git a/dashboard/src/views/persona/PersonaManager.vue b/dashboard/src/views/persona/PersonaManager.vue index c818ac512..eeea389e7 100644 --- a/dashboard/src/views/persona/PersonaManager.vue +++ b/dashboard/src/views/persona/PersonaManager.vue @@ -109,7 +109,8 @@ + :current-folder-id="currentFolderId ?? undefined" :current-folder-name="currentFolderName ?? undefined" + @saved="handlePersonaSaved" @error="showError" /> @@ -308,7 +309,26 @@ export default defineComponent({ }; }, computed: { - ...mapState(usePersonaStore, ['folderTree', 'currentFolderId', 'currentFolders', 'currentPersonas', 'loading']) + ...mapState(usePersonaStore, ['folderTree', 'currentFolderId', 'currentFolders', 'currentPersonas', 'loading']), + currentFolderName(): string | null { + if (!this.currentFolderId) { + return null; // 根目录,PersonaForm 会使用 tm('form.rootFolder') + } + // 递归查找文件夹名称 + const findName = (nodes: FolderTreeNode[], id: string): string | null => { + for (const node of nodes) { + if (node.folder_id === id) { + return node.name; + } + if (node.children && node.children.length > 0) { + const found = findName(node.children, id); + if (found) return found; + } + } + return null; + }; + return findName(this.folderTree, this.currentFolderId); + } }, watch: { // 监听 loading 状态变化,实现延迟显示骨架屏 From 70fff5e2761e0f3a315e4fbb4edbf230bd44a4c0 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Tue, 13 Jan 2026 09:53:24 +0800 Subject: [PATCH 15/16] style:format code --- .../builtin_commands/commands/persona.py | 4 +- astrbot/core/db/__init__.py | 4 +- astrbot/core/db/po.py | 2 +- astrbot/core/db/sqlite.py | 58 +++++++++++-------- astrbot/core/persona_mgr.py | 22 +++---- astrbot/dashboard/routes/persona.py | 18 +++--- 6 files changed, 62 insertions(+), 46 deletions(-) diff --git a/astrbot/builtin_stars/builtin_commands/commands/persona.py b/astrbot/builtin_stars/builtin_commands/commands/persona.py index 109164657..169c9e2b6 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/persona.py +++ b/astrbot/builtin_stars/builtin_commands/commands/persona.py @@ -26,13 +26,13 @@ def _build_tree_output( for folder in folder_tree: # 输出文件夹 lines.append(f"{prefix}├ 📁 {folder['name']}/") - + # 获取该文件夹下的人格 folder_personas = [ p for p in all_personas if p.folder_id == folder["folder_id"] ] child_prefix = "│ " * (depth + 1) - + # 输出该文件夹下的人格 for persona in folder_personas: lines.append(f"{child_prefix}├ 👤 {persona.persona_id}") diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 9b5373e31..8e2da52ef 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -256,7 +256,7 @@ async def insert_persona( sort_order: int = 0, ) -> Persona: """Insert a new persona record. - + Args: persona_id: Unique identifier for the persona system_prompt: System prompt for the persona @@ -362,7 +362,7 @@ async def batch_update_sort_order( items: list[dict], ) -> None: """Batch update sort_order for personas and/or folders. - + Args: items: List of dicts with keys: - id: The persona_id or folder_id diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 5b7f6ba3d..fe30c61e9 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -70,7 +70,7 @@ class ConversationV2(SQLModel, table=True): class PersonaFolder(SQLModel, table=True): """Persona 文件夹,支持递归层级结构。 - + 用于组织和管理多个 Persona,类似于文件系统的目录结构。 """ diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index 421ff0be4..f3a6816e5 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -56,16 +56,18 @@ async def initialize(self) -> None: async def _ensure_persona_folder_columns(self, conn) -> None: """确保 personas 表有 folder_id 和 sort_order 列。 - + 这是为了支持旧版数据库的平滑升级。新版数据库通过 SQLModel 的 metadata.create_all 自动创建这些列。 """ result = await conn.execute(text("PRAGMA table_info(personas)")) columns = {row[1] for row in result.fetchall()} - + if "folder_id" not in columns: await conn.execute( - text("ALTER TABLE personas ADD COLUMN folder_id VARCHAR(36) DEFAULT NULL") + text( + "ALTER TABLE personas ADD COLUMN folder_id VARCHAR(36) DEFAULT NULL" + ) ) if "sort_order" not in columns: await conn.execute( @@ -664,9 +666,11 @@ async def get_persona_folder_by_id(self, folder_id: str) -> PersonaFolder | None result = await session.execute(query) return result.scalar_one_or_none() - async def get_persona_folders(self, parent_id: str | None = None) -> list[PersonaFolder]: + async def get_persona_folders( + self, parent_id: str | None = None + ) -> list[PersonaFolder]: """Get all persona folders, optionally filtered by parent_id. - + Args: parent_id: If None, returns root folders only. If specified, returns children of that folder. @@ -675,13 +679,17 @@ async def get_persona_folders(self, parent_id: str | None = None) -> list[Person session: AsyncSession if parent_id is None: # Get root folders (parent_id is NULL) - query = select(PersonaFolder).where( - col(PersonaFolder.parent_id).is_(None) - ).order_by(col(PersonaFolder.sort_order), col(PersonaFolder.name)) + query = ( + select(PersonaFolder) + .where(col(PersonaFolder.parent_id).is_(None)) + .order_by(col(PersonaFolder.sort_order), col(PersonaFolder.name)) + ) else: - query = select(PersonaFolder).where( - PersonaFolder.parent_id == parent_id - ).order_by(col(PersonaFolder.sort_order), col(PersonaFolder.name)) + query = ( + select(PersonaFolder) + .where(PersonaFolder.parent_id == parent_id) + .order_by(col(PersonaFolder.sort_order), col(PersonaFolder.name)) + ) result = await session.execute(query) return list(result.scalars().all()) @@ -727,7 +735,7 @@ async def update_persona_folder( async def delete_persona_folder(self, folder_id: str) -> None: """Delete a persona folder by its folder_id. - + Note: This will also set folder_id to NULL for all personas in this folder, moving them to the root directory. """ @@ -765,20 +773,24 @@ async def get_personas_by_folder( self, folder_id: str | None = None ) -> list[Persona]: """Get all personas in a specific folder. - + Args: folder_id: If None, returns personas in root directory. """ async with self.get_db() as session: session: AsyncSession if folder_id is None: - query = select(Persona).where( - col(Persona.folder_id).is_(None) - ).order_by(col(Persona.sort_order), col(Persona.persona_id)) + query = ( + select(Persona) + .where(col(Persona.folder_id).is_(None)) + .order_by(col(Persona.sort_order), col(Persona.persona_id)) + ) else: - query = select(Persona).where( - Persona.folder_id == folder_id - ).order_by(col(Persona.sort_order), col(Persona.persona_id)) + query = ( + select(Persona) + .where(Persona.folder_id == folder_id) + .order_by(col(Persona.sort_order), col(Persona.persona_id)) + ) result = await session.execute(query) return list(result.scalars().all()) @@ -787,7 +799,7 @@ async def batch_update_sort_order( items: list[dict], ) -> None: """Batch update sort_order for personas and/or folders. - + Args: items: List of dicts with keys: - id: The persona_id or folder_id @@ -796,7 +808,7 @@ async def batch_update_sort_order( """ if not items: return - + async with self.get_db() as session: session: AsyncSession async with session.begin(): @@ -804,10 +816,10 @@ async def batch_update_sort_order( item_id = item.get("id") item_type = item.get("type") sort_order = item.get("sort_order") - + if item_id is None or item_type is None or sort_order is None: continue - + if item_type == "persona": await session.execute( update(Persona) diff --git a/astrbot/core/persona_mgr.py b/astrbot/core/persona_mgr.py index 40909b004..3bc6ab4a9 100644 --- a/astrbot/core/persona_mgr.py +++ b/astrbot/core/persona_mgr.py @@ -98,7 +98,7 @@ async def get_personas_by_folder( self, folder_id: str | None = None ) -> list[Persona]: """获取指定文件夹中的 personas - + Args: folder_id: 文件夹 ID,None 表示根目录 """ @@ -108,7 +108,7 @@ async def move_persona_to_folder( self, persona_id: str, folder_id: str | None ) -> Persona | None: """移动 persona 到指定文件夹 - + Args: persona_id: Persona ID folder_id: 目标文件夹 ID,None 表示移动到根目录 @@ -146,7 +146,7 @@ async def get_folder(self, folder_id: str) -> PersonaFolder | None: async def get_folders(self, parent_id: str | None = None) -> list[PersonaFolder]: """获取文件夹列表 - + Args: parent_id: 父文件夹 ID,None 表示获取根目录下的文件夹 """ @@ -175,14 +175,14 @@ async def update_folder( async def delete_folder(self, folder_id: str) -> None: """删除文件夹 - + Note: 文件夹内的 personas 会被移动到根目录 """ await self.db.delete_persona_folder(folder_id) async def batch_update_sort_order(self, items: list[dict]) -> None: """批量更新 personas 和/或 folders 的排序顺序 - + Args: items: 包含以下键的字典列表: - id: persona_id 或 folder_id @@ -196,13 +196,13 @@ async def batch_update_sort_order(self, items: list[dict]) -> None: async def get_folder_tree(self) -> list[dict]: """获取文件夹树形结构 - + Returns: 树形结构的文件夹列表,每个文件夹包含 children 子列表 """ all_folders = await self.get_all_folders() folder_map: dict[str, dict] = {} - + # 创建文件夹字典 for folder in all_folders: folder_map[folder.folder_id] = { @@ -213,7 +213,7 @@ async def get_folder_tree(self) -> list[dict]: "sort_order": folder.sort_order, "children": [], } - + # 构建树形结构 root_folders = [] for folder_id, folder_data in folder_map.items(): @@ -222,7 +222,7 @@ async def get_folder_tree(self) -> list[dict]: root_folders.append(folder_data) elif parent_id in folder_map: folder_map[parent_id]["children"].append(folder_data) - + # 递归排序 def sort_folders(folders: list[dict]) -> list[dict]: folders.sort(key=lambda f: (f["sort_order"], f["name"])) @@ -230,7 +230,7 @@ def sort_folders(folders: list[dict]) -> list[dict]: if folder["children"]: folder["children"] = sort_folders(folder["children"]) return folders - + return sort_folders(root_folders) async def create_persona( @@ -243,7 +243,7 @@ async def create_persona( sort_order: int = 0, ) -> Persona: """创建新的 persona。 - + Args: persona_id: Persona 唯一标识 system_prompt: 系统提示词 diff --git a/astrbot/dashboard/routes/persona.py b/astrbot/dashboard/routes/persona.py index 0e8cf4a3b..07a959396 100644 --- a/astrbot/dashboard/routes/persona.py +++ b/astrbot/dashboard/routes/persona.py @@ -418,7 +418,7 @@ async def delete_folder(self): async def reorder_items(self): """批量更新排序顺序 - + 请求体格式: { "items": [ @@ -439,13 +439,17 @@ async def reorder_items(self): # 验证每个 item 的格式 for item in items: if not all(k in item for k in ("id", "type", "sort_order")): - return Response().error( - "每个 item 必须包含 id, type, sort_order 字段" - ).__dict__ + return ( + Response() + .error("每个 item 必须包含 id, type, sort_order 字段") + .__dict__ + ) if item["type"] not in ("persona", "folder"): - return Response().error( - "type 字段必须是 'persona' 或 'folder'" - ).__dict__ + return ( + Response() + .error("type 字段必须是 'persona' 或 'folder'") + .__dict__ + ) await self.persona_mgr.batch_update_sort_order(items) From 14296ffd1655d03d831fd4792d56087535fc1ce3 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Wed, 21 Jan 2026 13:03:16 +0800 Subject: [PATCH 16/16] fix: remove 'persistent' attribute from dialog components --- dashboard/src/components/folder/BaseCreateFolderDialog.vue | 2 +- dashboard/src/components/shared/PersonaForm.vue | 2 +- dashboard/src/views/persona/PersonaManager.vue | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dashboard/src/components/folder/BaseCreateFolderDialog.vue b/dashboard/src/components/folder/BaseCreateFolderDialog.vue index 7adab5bee..59075f06f 100644 --- a/dashboard/src/components/folder/BaseCreateFolderDialog.vue +++ b/dashboard/src/components/folder/BaseCreateFolderDialog.vue @@ -1,5 +1,5 @@