Skip to content

Commit 7733ccc

Browse files
Merge pull request #6429 from xkeyC/feat/persona_clone
feat: add clone persona functionality
2 parents 9c7c0ec + 38f2167 commit 7733ccc

File tree

8 files changed

+207
-3
lines changed

8 files changed

+207
-3
lines changed

astrbot/core/persona_mgr.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,41 @@ async def create_persona(
339339
self.get_v3_persona_data()
340340
return new_persona
341341

342+
async def clone_persona(
343+
self,
344+
source_persona_id: str,
345+
new_persona_id: str,
346+
) -> Persona:
347+
"""Clone an existing persona with a new ID.
348+
349+
Args:
350+
source_persona_id: Source persona ID to clone from
351+
new_persona_id: New persona ID for the clone
352+
353+
Returns:
354+
The newly created persona clone
355+
"""
356+
source_persona = await self.db.get_persona_by_id(source_persona_id)
357+
if not source_persona:
358+
raise ValueError(f"Persona with ID {source_persona_id} does not exist.")
359+
360+
if await self.db.get_persona_by_id(new_persona_id):
361+
raise ValueError(f"Persona with ID {new_persona_id} already exists.")
362+
363+
new_persona = await self.db.insert_persona(
364+
new_persona_id,
365+
source_persona.system_prompt,
366+
source_persona.begin_dialogs,
367+
tools=source_persona.tools,
368+
skills=source_persona.skills,
369+
custom_error_message=source_persona.custom_error_message,
370+
folder_id=source_persona.folder_id,
371+
sort_order=source_persona.sort_order,
372+
)
373+
self.personas.append(new_persona)
374+
self.get_v3_persona_data()
375+
return new_persona
376+
342377
def get_v3_persona_data(
343378
self,
344379
) -> tuple[list[dict], list[Personality], Personality]:

astrbot/dashboard/routes/persona.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def __init__(
2323
"/persona/create": ("POST", self.create_persona),
2424
"/persona/update": ("POST", self.update_persona),
2525
"/persona/delete": ("POST", self.delete_persona),
26+
"/persona/clone": ("POST", self.clone_persona),
2627
"/persona/move": ("POST", self.move_persona),
2728
"/persona/reorder": ("POST", self.reorder_items),
2829
# Folder routes
@@ -262,6 +263,55 @@ async def delete_persona(self):
262263
logger.error(f"删除人格失败: {e!s}\n{traceback.format_exc()}")
263264
return Response().error(f"删除人格失败: {e!s}").__dict__
264265

266+
async def clone_persona(self):
267+
"""克隆人格"""
268+
try:
269+
data = await request.get_json()
270+
source_persona_id = data.get("source_persona_id")
271+
new_persona_id = data.get("new_persona_id", "").strip()
272+
273+
if not source_persona_id:
274+
return Response().error("缺少必要参数: source_persona_id").__dict__
275+
276+
if not new_persona_id:
277+
return Response().error("新人格ID不能为空").__dict__
278+
279+
persona = await self.persona_mgr.clone_persona(
280+
source_persona_id=source_persona_id,
281+
new_persona_id=new_persona_id,
282+
)
283+
284+
return (
285+
Response()
286+
.ok(
287+
{
288+
"message": "人格克隆成功",
289+
"persona": {
290+
"persona_id": persona.persona_id,
291+
"system_prompt": persona.system_prompt,
292+
"begin_dialogs": persona.begin_dialogs or [],
293+
"tools": persona.tools or [],
294+
"skills": persona.skills or [],
295+
"custom_error_message": persona.custom_error_message,
296+
"folder_id": persona.folder_id,
297+
"sort_order": persona.sort_order,
298+
"created_at": persona.created_at.isoformat()
299+
if persona.created_at
300+
else None,
301+
"updated_at": persona.updated_at.isoformat()
302+
if persona.updated_at
303+
else None,
304+
},
305+
},
306+
)
307+
.__dict__
308+
)
309+
except ValueError as e:
310+
return Response().error(str(e)).__dict__
311+
except Exception as e:
312+
logger.error(f"克隆人格失败: {e!s}\n{traceback.format_exc()}")
313+
return Response().error(f"克隆人格失败: {e!s}").__dict__
314+
265315
async def move_persona(self):
266316
"""移动人格到指定文件夹"""
267317
try:

dashboard/src/i18n/locales/en-US/features/persona.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"cancel": "Cancel",
1111
"save": "Save",
1212
"move": "Move",
13+
"clone": "Clone",
1314
"addDialogPair": "Add Dialog Pair"
1415
},
1516
"labels": {
@@ -142,5 +143,17 @@
142143
"description": "Select a destination folder for \"{name}\"",
143144
"success": "Moved successfully",
144145
"error": "Failed to move"
146+
},
147+
"cloneDialog": {
148+
"title": "Clone Persona",
149+
"description": "Create a copy of \"{name}\" with a new ID",
150+
"newPersonaId": "New Persona ID",
151+
"newPersonaIdHint": "Enter a unique name for the cloned persona",
152+
"success": "Persona cloned successfully",
153+
"error": "Failed to clone persona",
154+
"validation": {
155+
"required": "Persona ID is required",
156+
"exists": "This persona ID already exists"
157+
}
145158
}
146159
}

dashboard/src/i18n/locales/ru-RU/features/persona.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"cancel": "Отмена",
1111
"save": "Сохранить",
1212
"move": "Переместить",
13+
"clone": "Клонировать",
1314
"addDialogPair": "Добавить пример диалога"
1415
},
1516
"labels": {
@@ -142,5 +143,17 @@
142143
"description": "Выберите папку для «{name}»",
143144
"success": "Объект перемещен",
144145
"error": "Ошибка перемещения"
146+
},
147+
"cloneDialog": {
148+
"title": "Клонировать персонажа",
149+
"description": "Создать копию «{name}» с новым ID",
150+
"newPersonaId": "ID нового персонажа",
151+
"newPersonaIdHint": "Введите уникальное имя для клона",
152+
"success": "Персонаж клонирован",
153+
"error": "Ошибка клонирования",
154+
"validation": {
155+
"required": "ID персонажа обязателен",
156+
"exists": "Такой ID уже существует"
157+
}
145158
}
146159
}

dashboard/src/i18n/locales/zh-CN/features/persona.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"cancel": "取消",
1111
"save": "保存",
1212
"move": "移动",
13+
"clone": "克隆",
1314
"addDialogPair": "添加对话对"
1415
},
1516
"labels": {
@@ -142,5 +143,17 @@
142143
"description": "\"{name}\" 选择目标文件夹",
143144
"success": "移动成功",
144145
"error": "移动失败"
146+
},
147+
"cloneDialog": {
148+
"title": "克隆人格",
149+
"description": "\"{name}\" 创建一份副本",
150+
"newPersonaId": "新人格 ID",
151+
"newPersonaIdHint": "输入克隆人格的唯一名称",
152+
"success": "人格克隆成功",
153+
"error": "克隆人格失败",
154+
"validation": {
155+
"required": "人格 ID 不能为空",
156+
"exists": "该人格 ID 已存在"
157+
}
145158
}
146159
}

dashboard/src/stores/personaStore.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,25 @@ export const usePersonaStore = defineStore({
299299
await this.refreshCurrentFolder();
300300
},
301301

302+
/**
303+
* 克隆 Persona
304+
*/
305+
async clonePersona(sourcePersonaId: string, newPersonaId: string): Promise<Persona> {
306+
const response = await axios.post('/api/persona/clone', {
307+
source_persona_id: sourcePersonaId,
308+
new_persona_id: newPersonaId
309+
});
310+
311+
if (response.data.status !== 'ok') {
312+
throw new Error(response.data.message || '克隆人格失败');
313+
}
314+
315+
// 刷新当前文件夹内容
316+
await this.refreshCurrentFolder();
317+
318+
return response.data.data.persona;
319+
},
320+
302321
/**
303322
* 批量更新排序
304323
*/

dashboard/src/views/persona/PersonaCard.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
</template>
1515
<v-list-item-title>{{ tm('buttons.edit') }}</v-list-item-title>
1616
</v-list-item>
17+
<v-list-item @click.stop="$emit('clone')">
18+
<template v-slot:prepend>
19+
<v-icon size="small">mdi-content-copy</v-icon>
20+
</template>
21+
<v-list-item-title>{{ tm('buttons.clone') }}</v-list-item-title>
22+
</v-list-item>
1723
<v-list-item @click.stop="$emit('move')">
1824
<template v-slot:prepend>
1925
<v-icon size="small">mdi-folder-move</v-icon>
@@ -97,7 +103,7 @@ export default defineComponent({
97103
required: true
98104
}
99105
},
100-
emits: ['view', 'edit', 'move', 'delete'],
106+
emits: ['view', 'edit', 'clone', 'move', 'delete'],
101107
setup() {
102108
const { tm } = useModuleI18n('features/persona');
103109
return { tm };

dashboard/src/views/persona/PersonaManager.vue

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@
7979
<v-col v-for="persona in currentPersonas" :key="persona.persona_id" cols="12" sm="6" lg="4"
8080
xl="3">
8181
<PersonaCard :persona="persona" @view="viewPersona(persona)"
82-
@edit="editPersona(persona)" @move="openMovePersonaDialog(persona)"
82+
@edit="editPersona(persona)" @clone="openClonePersonaDialog(persona)"
83+
@move="openMovePersonaDialog(persona)"
8384
@delete="confirmDeletePersona(persona)" />
8485
</v-col>
8586
</v-row>
@@ -230,6 +231,33 @@
230231
<MoveToFolderDialog v-model="showMoveDialog" :item-type="moveDialogType" :item="moveDialogItem"
231232
@moved="showSuccess" @error="showError" />
232233

234+
<!-- 克隆人格对话框 -->
235+
<v-dialog v-model="showCloneDialog" max-width="450px">
236+
<v-card>
237+
<v-card-title>{{ tm('cloneDialog.title') }}</v-card-title>
238+
<v-card-text>
239+
<p class="text-body-2 text-medium-emphasis mb-4">
240+
{{ tm('cloneDialog.description', { name: cloningPersona?.persona_id ?? '' }) }}
241+
</p>
242+
<v-text-field v-model="cloneNewPersonaId" :label="tm('cloneDialog.newPersonaId')"
243+
:hint="tm('cloneDialog.newPersonaIdHint')" persistent-hint variant="outlined"
244+
density="comfortable" autofocus
245+
:rules="[v => !!v || tm('cloneDialog.validation.required')]"
246+
@keyup.enter="submitClonePersona" />
247+
</v-card-text>
248+
<v-card-actions>
249+
<v-spacer />
250+
<v-btn variant="text" @click="showCloneDialog = false">
251+
{{ tm('buttons.cancel') }}
252+
</v-btn>
253+
<v-btn color="primary" variant="flat" @click="submitClonePersona" :loading="cloneLoading"
254+
:disabled="!cloneNewPersonaId">
255+
{{ tm('buttons.clone') }}
256+
</v-btn>
257+
</v-card-actions>
258+
</v-card>
259+
</v-dialog>
260+
233261
<!-- 删除文件夹确认对话框 -->
234262
<v-dialog v-model="showDeleteFolderDialog" max-width="450px">
235263
<v-card>
@@ -340,6 +368,12 @@ export default defineComponent({
340368
moveDialogType: 'persona' as 'persona' | 'folder',
341369
moveDialogItem: null as Persona | Folder | null,
342370
371+
// 克隆对话框
372+
showCloneDialog: false,
373+
cloningPersona: null as Persona | null,
374+
cloneNewPersonaId: '',
375+
cloneLoading: false,
376+
343377
// 消息提示
344378
showMessage: false,
345379
message: '',
@@ -406,7 +440,7 @@ export default defineComponent({
406440
await this.initialize();
407441
},
408442
methods: {
409-
...mapActions(usePersonaStore, ['loadFolderTree', 'navigateToFolder', 'updateFolder', 'deleteFolder', 'deletePersona', 'refreshCurrentFolder', 'movePersonaToFolder']),
443+
...mapActions(usePersonaStore, ['loadFolderTree', 'navigateToFolder', 'updateFolder', 'deleteFolder', 'deletePersona', 'refreshCurrentFolder', 'movePersonaToFolder', 'clonePersona']),
410444
411445
async initialize() {
412446
await Promise.all([
@@ -472,6 +506,27 @@ export default defineComponent({
472506
this.showMoveDialog = true;
473507
},
474508
509+
openClonePersonaDialog(persona: Persona) {
510+
this.cloningPersona = persona;
511+
this.cloneNewPersonaId = `${persona.persona_id}_copy`;
512+
this.showCloneDialog = true;
513+
},
514+
515+
async submitClonePersona() {
516+
if (!this.cloneNewPersonaId || !this.cloningPersona) return;
517+
518+
this.cloneLoading = true;
519+
try {
520+
await this.clonePersona(this.cloningPersona.persona_id, this.cloneNewPersonaId);
521+
this.showSuccess(this.tm('cloneDialog.success'));
522+
this.showCloneDialog = false;
523+
} catch (error: any) {
524+
this.showError(error.message || this.tm('cloneDialog.error'));
525+
} finally {
526+
this.cloneLoading = false;
527+
}
528+
},
529+
475530
async handlePersonaDropped({ persona_id, target_folder_id }: { persona_id: string; target_folder_id: string | null }) {
476531
try {
477532
await this.movePersonaToFolder(persona_id, target_folder_id);

0 commit comments

Comments
 (0)