Merge pull request #6429 from xkeyC/feat/persona_clone

feat: add clone persona functionality
This commit is contained in:
LIghtJUNction
2026-03-16 17:23:01 +08:00
committed by GitHub
8 changed files with 207 additions and 3 deletions
+35
View File
@@ -339,6 +339,41 @@ class PersonaManager:
self.get_v3_persona_data()
return new_persona
async def clone_persona(
self,
source_persona_id: str,
new_persona_id: str,
) -> Persona:
"""Clone an existing persona with a new ID.
Args:
source_persona_id: Source persona ID to clone from
new_persona_id: New persona ID for the clone
Returns:
The newly created persona clone
"""
source_persona = await self.db.get_persona_by_id(source_persona_id)
if not source_persona:
raise ValueError(f"Persona with ID {source_persona_id} does not exist.")
if await self.db.get_persona_by_id(new_persona_id):
raise ValueError(f"Persona with ID {new_persona_id} already exists.")
new_persona = await self.db.insert_persona(
new_persona_id,
source_persona.system_prompt,
source_persona.begin_dialogs,
tools=source_persona.tools,
skills=source_persona.skills,
custom_error_message=source_persona.custom_error_message,
folder_id=source_persona.folder_id,
sort_order=source_persona.sort_order,
)
self.personas.append(new_persona)
self.get_v3_persona_data()
return new_persona
def get_v3_persona_data(
self,
) -> tuple[list[dict], list[Personality], Personality]:
+50
View File
@@ -23,6 +23,7 @@ class PersonaRoute(Route):
"/persona/create": ("POST", self.create_persona),
"/persona/update": ("POST", self.update_persona),
"/persona/delete": ("POST", self.delete_persona),
"/persona/clone": ("POST", self.clone_persona),
"/persona/move": ("POST", self.move_persona),
"/persona/reorder": ("POST", self.reorder_items),
# Folder routes
@@ -262,6 +263,55 @@ class PersonaRoute(Route):
logger.error(f"删除人格失败: {e!s}\n{traceback.format_exc()}")
return Response().error(f"删除人格失败: {e!s}").__dict__
async def clone_persona(self):
"""克隆人格"""
try:
data = await request.get_json()
source_persona_id = data.get("source_persona_id")
new_persona_id = data.get("new_persona_id", "").strip()
if not source_persona_id:
return Response().error("缺少必要参数: source_persona_id").__dict__
if not new_persona_id:
return Response().error("新人格ID不能为空").__dict__
persona = await self.persona_mgr.clone_persona(
source_persona_id=source_persona_id,
new_persona_id=new_persona_id,
)
return (
Response()
.ok(
{
"message": "人格克隆成功",
"persona": {
"persona_id": persona.persona_id,
"system_prompt": persona.system_prompt,
"begin_dialogs": persona.begin_dialogs or [],
"tools": persona.tools or [],
"skills": persona.skills or [],
"custom_error_message": persona.custom_error_message,
"folder_id": persona.folder_id,
"sort_order": persona.sort_order,
"created_at": persona.created_at.isoformat()
if persona.created_at
else None,
"updated_at": persona.updated_at.isoformat()
if persona.updated_at
else None,
},
},
)
.__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__
async def move_persona(self):
"""移动人格到指定文件夹"""
try:
@@ -10,6 +10,7 @@
"cancel": "Cancel",
"save": "Save",
"move": "Move",
"clone": "Clone",
"addDialogPair": "Add Dialog Pair"
},
"labels": {
@@ -142,5 +143,17 @@
"description": "Select a destination folder for \"{name}\"",
"success": "Moved successfully",
"error": "Failed to move"
},
"cloneDialog": {
"title": "Clone Persona",
"description": "Create a copy of \"{name}\" with a new ID",
"newPersonaId": "New Persona ID",
"newPersonaIdHint": "Enter a unique name for the cloned persona",
"success": "Persona cloned successfully",
"error": "Failed to clone persona",
"validation": {
"required": "Persona ID is required",
"exists": "This persona ID already exists"
}
}
}
@@ -10,6 +10,7 @@
"cancel": "Отмена",
"save": "Сохранить",
"move": "Переместить",
"clone": "Клонировать",
"addDialogPair": "Добавить пример диалога"
},
"labels": {
@@ -142,5 +143,17 @@
"description": "Выберите папку для «{name}»",
"success": "Объект перемещен",
"error": "Ошибка перемещения"
},
"cloneDialog": {
"title": "Клонировать персонажа",
"description": "Создать копию «{name}» с новым ID",
"newPersonaId": "ID нового персонажа",
"newPersonaIdHint": "Введите уникальное имя для клона",
"success": "Персонаж клонирован",
"error": "Ошибка клонирования",
"validation": {
"required": "ID персонажа обязателен",
"exists": "Такой ID уже существует"
}
}
}
@@ -10,6 +10,7 @@
"cancel": "取消",
"save": "保存",
"move": "移动",
"clone": "克隆",
"addDialogPair": "添加对话对"
},
"labels": {
@@ -142,5 +143,17 @@
"description": "为 \"{name}\" 选择目标文件夹",
"success": "移动成功",
"error": "移动失败"
},
"cloneDialog": {
"title": "克隆人格",
"description": "为 \"{name}\" 创建一份副本",
"newPersonaId": "新人格 ID",
"newPersonaIdHint": "输入克隆人格的唯一名称",
"success": "人格克隆成功",
"error": "克隆人格失败",
"validation": {
"required": "人格 ID 不能为空",
"exists": "该人格 ID 已存在"
}
}
}
+19
View File
@@ -299,6 +299,25 @@ export const usePersonaStore = defineStore({
await this.refreshCurrentFolder();
},
/**
* 克隆 Persona
*/
async clonePersona(sourcePersonaId: string, newPersonaId: string): Promise<Persona> {
const response = await axios.post('/api/persona/clone', {
source_persona_id: sourcePersonaId,
new_persona_id: newPersonaId
});
if (response.data.status !== 'ok') {
throw new Error(response.data.message || '克隆人格失败');
}
// 刷新当前文件夹内容
await this.refreshCurrentFolder();
return response.data.data.persona;
},
/**
* 批量更新排序
*/
+7 -1
View File
@@ -14,6 +14,12 @@
</template>
<v-list-item-title>{{ tm('buttons.edit') }}</v-list-item-title>
</v-list-item>
<v-list-item @click.stop="$emit('clone')">
<template v-slot:prepend>
<v-icon size="small">mdi-content-copy</v-icon>
</template>
<v-list-item-title>{{ tm('buttons.clone') }}</v-list-item-title>
</v-list-item>
<v-list-item @click.stop="$emit('move')">
<template v-slot:prepend>
<v-icon size="small">mdi-folder-move</v-icon>
@@ -97,7 +103,7 @@ export default defineComponent({
required: true
}
},
emits: ['view', 'edit', 'move', 'delete'],
emits: ['view', 'edit', 'clone', 'move', 'delete'],
setup() {
const { tm } = useModuleI18n('features/persona');
return { tm };
+57 -2
View File
@@ -79,7 +79,8 @@
<v-col v-for="persona in currentPersonas" :key="persona.persona_id" cols="12" sm="6" lg="4"
xl="3">
<PersonaCard :persona="persona" @view="viewPersona(persona)"
@edit="editPersona(persona)" @move="openMovePersonaDialog(persona)"
@edit="editPersona(persona)" @clone="openClonePersonaDialog(persona)"
@move="openMovePersonaDialog(persona)"
@delete="confirmDeletePersona(persona)" />
</v-col>
</v-row>
@@ -230,6 +231,33 @@
<MoveToFolderDialog v-model="showMoveDialog" :item-type="moveDialogType" :item="moveDialogItem"
@moved="showSuccess" @error="showError" />
<!-- 克隆人格对话框 -->
<v-dialog v-model="showCloneDialog" max-width="450px">
<v-card>
<v-card-title>{{ tm('cloneDialog.title') }}</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-4">
{{ tm('cloneDialog.description', { name: cloningPersona?.persona_id ?? '' }) }}
</p>
<v-text-field v-model="cloneNewPersonaId" :label="tm('cloneDialog.newPersonaId')"
:hint="tm('cloneDialog.newPersonaIdHint')" persistent-hint variant="outlined"
density="comfortable" autofocus
:rules="[v => !!v || tm('cloneDialog.validation.required')]"
@keyup.enter="submitClonePersona" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showCloneDialog = false">
{{ tm('buttons.cancel') }}
</v-btn>
<v-btn color="primary" variant="flat" @click="submitClonePersona" :loading="cloneLoading"
:disabled="!cloneNewPersonaId">
{{ tm('buttons.clone') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除文件夹确认对话框 -->
<v-dialog v-model="showDeleteFolderDialog" max-width="450px">
<v-card>
@@ -340,6 +368,12 @@ export default defineComponent({
moveDialogType: 'persona' as 'persona' | 'folder',
moveDialogItem: null as Persona | Folder | null,
// 克隆对话框
showCloneDialog: false,
cloningPersona: null as Persona | null,
cloneNewPersonaId: '',
cloneLoading: false,
// 消息提示
showMessage: false,
message: '',
@@ -406,7 +440,7 @@ export default defineComponent({
await this.initialize();
},
methods: {
...mapActions(usePersonaStore, ['loadFolderTree', 'navigateToFolder', 'updateFolder', 'deleteFolder', 'deletePersona', 'refreshCurrentFolder', 'movePersonaToFolder']),
...mapActions(usePersonaStore, ['loadFolderTree', 'navigateToFolder', 'updateFolder', 'deleteFolder', 'deletePersona', 'refreshCurrentFolder', 'movePersonaToFolder', 'clonePersona']),
async initialize() {
await Promise.all([
@@ -472,6 +506,27 @@ export default defineComponent({
this.showMoveDialog = true;
},
openClonePersonaDialog(persona: Persona) {
this.cloningPersona = persona;
this.cloneNewPersonaId = `${persona.persona_id}_copy`;
this.showCloneDialog = true;
},
async submitClonePersona() {
if (!this.cloneNewPersonaId || !this.cloningPersona) return;
this.cloneLoading = true;
try {
await this.clonePersona(this.cloningPersona.persona_id, this.cloneNewPersonaId);
this.showSuccess(this.tm('cloneDialog.success'));
this.showCloneDialog = false;
} catch (error: any) {
this.showError(error.message || this.tm('cloneDialog.error'));
} finally {
this.cloneLoading = false;
}
},
async handlePersonaDropped({ persona_id, target_folder_id }: { persona_id: string; target_folder_id: string | null }) {
try {
await this.movePersonaToFolder(persona_id, target_folder_id);