feat: implement persona folder for advanced persona management (#4443)

* 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

* 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

* 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

* 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

* 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

* feat(dashboard): add reusable folder management component library

Extract folder management UI into reusable base components and create
persona-specific wrapper components that integrate with personaStore.

- Add base folder components (tree, breadcrumb, card, dialogs) with
  customizable labels for i18n support
- Create useFolderManager composable for folder state management
- Implement drag-and-drop support for moving personas between folders
- Add persona-specific wrapper components connecting to personaStore
- Reorganize PersonaManager into views/persona directory structure
- Include comprehensive README documentation for component usage

* 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.

* 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

* 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

* feat(persona): add tree-view display for persona list command

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

* refactor(persona): simplify tree-view output with shorter indentation lines

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.

* 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

* 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.

* 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

* style:format code

* fix: remove 'persistent' attribute from dialog components

---------

Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
Ruochen Pan
2026-01-21 13:05:33 +08:00
committed by GitHub
parent c09bbfb8ac
commit 8910ab3a47
36 changed files with 5722 additions and 436 deletions

View File

@@ -1,11 +1,23 @@
<template>
<v-dialog v-model="showDialog" max-width="500px" persistent>
<v-dialog v-model="showDialog" max-width="500px">
<v-card>
<v-card-title class="text-h2">
{{ editingPersona ? tm('dialog.edit.title') : tm('dialog.create.title') }}
</v-card-title>
<v-card-text>
<!-- 创建位置提示 -->
<v-alert
v-if="!editingPersona"
type="info"
variant="tonal"
density="compact"
class="mb-4"
icon="mdi-folder-outline"
>
{{ tm('form.createInFolder', { folder: folderDisplayName }) }}
</v-alert>
<v-form ref="personaForm" v-model="formValid">
<v-text-field v-model="personaForm.persona_id" :label="tm('form.personaId')"
:rules="personaIdRules" :disabled="editingPersona" variant="outlined" density="comfortable"
@@ -209,6 +221,14 @@ export default {
editingPersona: {
type: Object,
default: null
},
currentFolderId: {
type: String,
default: null
},
currentFolderName: {
type: String,
default: null
}
},
emits: ['update:modelValue', 'saved', 'error'],
@@ -225,15 +245,18 @@ export default {
mcpServers: [],
availableTools: [],
loadingTools: false,
existingPersonaIds: [], // 已存在的人格ID列表
personaForm: {
persona_id: '',
system_prompt: '',
begin_dialogs: [],
tools: []
tools: [],
folder_id: null
},
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'),
@@ -262,6 +285,18 @@ export default {
(tool.description && tool.description.toLowerCase().includes(search)) ||
(tool.mcp_server_name && tool.mcp_server_name.toLowerCase().includes(search))
);
},
folderDisplayName() {
// 优先使用传入的文件夹名称
if (this.currentFolderName) {
return this.currentFolderName;
}
// 如果没有文件夹 ID显示根目录
if (!this.currentFolderId) {
return this.tm('form.rootFolder');
}
// 否则显示文件夹 ID作为备用
return this.currentFolderId;
}
},
@@ -273,6 +308,8 @@ export default {
this.initFormWithPersona(this.editingPersona);
} else {
this.initForm();
// 只在创建新人格时加载已存在的人格列表
this.loadExistingPersonaIds();
}
this.loadMcpServers();
this.loadTools();
@@ -310,7 +347,8 @@ export default {
persona_id: '',
system_prompt: '',
begin_dialogs: [],
tools: []
tools: [],
folder_id: this.currentFolderId
};
this.toolSelectValue = '0';
this.expandedPanels = [];
@@ -321,7 +359,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';
@@ -363,6 +402,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;

View File

@@ -1,84 +1,46 @@
<template>
<div class="d-flex align-center justify-space-between">
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
{{ tm('personaSelector.notSelected') }}
</span>
<span v-else>
{{ modelValue === 'default' ? tm('personaSelector.defaultPersona') : modelValue }}
</span>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
{{ buttonText || tm('personaSelector.buttonText') }}
</v-btn>
</div>
<!-- Persona Selection Dialog -->
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
{{ tm('personaSelector.dialogTitle') }}
</v-card-title>
<v-card-text class="pa-2" style="max-height: 400px; overflow-y: auto;">
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<v-list v-if="!loading && personaList.length > 0" density="compact">
<v-list-item
v-for="persona in personaList"
:key="persona.persona_id"
:value="persona.persona_id"
@click="selectPersona(persona)"
:active="selectedPersona === persona.persona_id"
rounded="md"
class="ma-1">
<v-list-item-title>{{ persona.persona_id === 'default' ? tm('personaSelector.defaultPersona') : persona.persona_id }}</v-list-item-title>
<v-list-item-subtitle>
{{ persona.system_prompt ? persona.system_prompt.substring(0, 50) + '...' : tm('personaSelector.noDescription') }}
</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedPersona === persona.persona_id" color="primary">mdi-check-circle</v-icon>
</template>
</v-list-item>
</v-list>
<div v-else-if="!loading && personaList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-account-off</v-icon>
<p class="text-grey mt-4">{{ tm('personaSelector.noPersonas') }}</p>
</div>
</v-card-text>
<v-card-actions class="pa-4">
<v-btn variant="text" color="primary" prepend-icon="mdi-plus" @click="openCreatePersona">
{{ tm('personaSelector.createPersona') }}
</v-btn>
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelSelection">{{ t('core.common.cancel') }}</v-btn>
<v-btn
color="primary"
@click="confirmSelection"
:disabled="!selectedPersona">
{{ t('core.common.confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<BaseFolderItemSelector
:model-value="modelValue"
@update:model-value="handleUpdate"
:folder-tree="folderTree"
:items="currentPersonas as any"
:tree-loading="treeLoading"
:items-loading="itemsLoading"
:labels="labels"
:show-create-button="true"
:default-item="defaultPersona"
item-id-field="persona_id"
item-name-field="persona_id"
item-description-field="system_prompt"
:display-value-formatter="formatDisplayValue"
@navigate="handleNavigate"
@create="openCreatePersona"
/>
<!-- 创建人格对话框 -->
<PersonaForm
<PersonaForm
v-model="showCreateDialog"
:editing-persona="null"
:mcp-servers="mcpServers"
:available-tools="availableTools"
:loading-tools="loadingTools"
:editing-persona="undefined"
:current-folder-id="currentFolderId ?? undefined"
:current-folder-name="currentFolderName ?? undefined"
@saved="handlePersonaCreated"
@error="handleError" />
</template>
<script setup>
import { ref, watch } from 'vue'
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
import BaseFolderItemSelector from '@/components/folder/BaseFolderItemSelector.vue'
import PersonaForm from './PersonaForm.vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import type { FolderTreeNode, SelectableItem } from '@/components/folder/types'
interface Persona {
persona_id: string
system_prompt: string
folder_id?: string | null
[key: string]: any
}
const props = defineProps({
modelValue: {
@@ -95,91 +57,142 @@ const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const { tm } = useModuleI18n('core.shared')
const dialog = ref(false)
const personaList = ref([])
const loading = ref(false)
const selectedPersona = ref('')
// 状态
const folderTree = ref<FolderTreeNode[]>([])
const currentPersonas = ref<Persona[]>([])
const treeLoading = ref(false)
const itemsLoading = ref(false)
const showCreateDialog = ref(false)
const currentFolderId = ref<string | null>(null)
// 监听 modelValue 变化,同步到 selectedPersona
watch(() => props.modelValue, (newValue) => {
selectedPersona.value = newValue || ''
}, { immediate: true })
async function openDialog() {
selectedPersona.value = props.modelValue || ''
dialog.value = true
await loadPersonas()
// 默认人格
const defaultPersona: SelectableItem = {
id: 'default',
persona_id: 'default',
name: tm('personaSelector.defaultPersona'),
system_prompt: 'You are a helpful and friendly assistant.'
}
async function loadPersonas() {
loading.value = true
// 递归查找文件夹名称
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'),
notSelected: tm('personaSelector.notSelected'),
buttonText: props.buttonText || tm('personaSelector.buttonText'),
noItems: tm('personaSelector.noPersonas'),
defaultItem: tm('personaSelector.defaultPersona'),
noDescription: tm('personaSelector.noDescription'),
createButton: tm('personaSelector.createPersona'),
confirmButton: t('core.common.confirm'),
cancelButton: t('core.common.cancel'),
rootFolder: tm('personaSelector.rootFolder') || '全部人格',
emptyFolder: tm('personaSelector.emptyFolder') || '此文件夹为空'
}))
// 格式化显示值
function formatDisplayValue(value: string): string {
if (value === 'default') {
return tm('personaSelector.defaultPersona')
}
return value
}
// 处理值更新
function handleUpdate(value: string) {
emit('update:modelValue', value)
}
// 加载文件夹树
async function loadFolderTree() {
treeLoading.value = true
try {
const response = await axios.get('/api/persona/list')
const response = await axios.get('/api/persona/folder/tree')
if (response.data.status === 'ok') {
const personas = response.data.data || []
// 添加默认人格选项
personaList.value = [
{
persona_id: 'default',
system_prompt: 'You are a helpful and friendly assistant.'
},
...personas
]
folderTree.value = response.data.data || []
}
} catch (error) {
console.error('加载人格列表失败:', error)
personaList.value = [
{
persona_id: 'default',
system_prompt: 'You are a helpful and friendly assistant.'
}
]
console.error('加载文件夹树失败:', error)
folderTree.value = []
} finally {
loading.value = false
treeLoading.value = false
}
}
function selectPersona(persona) {
selectedPersona.value = persona.persona_id
// 加载指定文件夹的人格
async function loadPersonasInFolder(folderId: string | null) {
itemsLoading.value = true
try {
// 使用 /api/persona/list 端点,通过 folder_id 参数筛选
const params = new URLSearchParams()
if (folderId !== null) {
params.set('folder_id', folderId)
} else {
// 根目录folder_id 为空字符串表示获取根目录下的人格
params.set('folder_id', '')
}
const response = await axios.get(`/api/persona/list?${params.toString()}`)
if (response.data.status === 'ok') {
currentPersonas.value = response.data.data || []
}
} catch (error) {
console.error('加载人格列表失败:', error)
currentPersonas.value = []
} finally {
itemsLoading.value = false
}
}
function confirmSelection() {
emit('update:modelValue', selectedPersona.value)
dialog.value = false
}
function cancelSelection() {
selectedPersona.value = props.modelValue || ''
dialog.value = false
// 处理文件夹导航
async function handleNavigate(folderId: string | null) {
currentFolderId.value = folderId
await loadPersonasInFolder(folderId)
}
// 打开创建人格对话框
function openCreatePersona() {
showCreateDialog.value = true
}
async function handlePersonaCreated(message) {
// 人格创建成功
async function handlePersonaCreated(message: string) {
console.log('人格创建成功:', message)
showCreateDialog.value = false
// 刷新人格列表
await loadPersonas()
// 刷新当前文件夹的人格列表
await loadPersonasInFolder(currentFolderId.value)
}
function handleError(error) {
// 错误处理
function handleError(error: string) {
console.error('创建人格失败:', error)
}
// 初始化加载文件夹树
onMounted(() => {
loadFolderTree()
})
</script>
<style scoped>
.v-list-item {
transition: all 0.2s ease;
}
.v-list-item:hover {
background-color: rgba(var(--v-theme-primary), 0.04);
}
.v-list-item.v-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.08);
}
/* 样式继承自 BaseFolderItemSelector */
</style>