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 @@ class PersonaRoute(Route): """获取文件夹列表""" 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 }} -