Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions astrbot/core/persona_mgr.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,41 @@ async def create_persona(
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]:
Expand Down
50 changes: 50 additions & 0 deletions astrbot/dashboard/routes/persona.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def __init__(
"/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
Expand Down Expand Up @@ -262,6 +263,55 @@ async def delete_persona(self):
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__
)
Comment on lines +284 to +308
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The manual serialization of the Persona object is verbose and increases maintenance effort, as any changes to the Persona model require updates in multiple places. You can make this more concise and maintainable by using model_dump() from Pydantic/SQLModel, which is already a dependency. This will automatically handle most fields, including datetime to string conversion.

            persona_data = persona.model_dump(mode='json', exclude={'id'})
            persona_data.update({
                "begin_dialogs": persona.begin_dialogs or [],
                "tools": persona.tools or [],
                "skills": persona.skills or [],
            })
            return (
                Response()
                .ok(
                    {
                        "message": "人格克隆成功",
                        "persona": persona_data,
                    },
                )
                .__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:
Expand Down
13 changes: 13 additions & 0 deletions dashboard/src/i18n/locales/en-US/features/persona.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"cancel": "Cancel",
"save": "Save",
"move": "Move",
"clone": "Clone",
"addDialogPair": "Add Dialog Pair"
},
"labels": {
Expand Down Expand Up @@ -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"
}
}
}
13 changes: 13 additions & 0 deletions dashboard/src/i18n/locales/ru-RU/features/persona.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"cancel": "Отмена",
"save": "Сохранить",
"move": "Переместить",
"clone": "Клонировать",
"addDialogPair": "Добавить пример диалога"
},
"labels": {
Expand Down Expand Up @@ -142,5 +143,17 @@
"description": "Выберите папку для «{name}»",
"success": "Объект перемещен",
"error": "Ошибка перемещения"
},
"cloneDialog": {
"title": "Клонировать персонажа",
"description": "Создать копию «{name}» с новым ID",
"newPersonaId": "ID нового персонажа",
"newPersonaIdHint": "Введите уникальное имя для клона",
"success": "Персонаж клонирован",
"error": "Ошибка клонирования",
"validation": {
"required": "ID персонажа обязателен",
"exists": "Такой ID уже существует"
}
}
}
13 changes: 13 additions & 0 deletions dashboard/src/i18n/locales/zh-CN/features/persona.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"cancel": "取消",
"save": "保存",
"move": "移动",
"clone": "克隆",
"addDialogPair": "添加对话对"
},
"labels": {
Expand Down Expand Up @@ -142,5 +143,17 @@
"description": "为 \"{name}\" 选择目标文件夹",
"success": "移动成功",
"error": "移动失败"
},
"cloneDialog": {
"title": "克隆人格",
"description": "为 \"{name}\" 创建一份副本",
"newPersonaId": "新人格 ID",
"newPersonaIdHint": "输入克隆人格的唯一名称",
"success": "人格克隆成功",
"error": "克隆人格失败",
"validation": {
"required": "人格 ID 不能为空",
"exists": "该人格 ID 已存在"
}
}
}
19 changes: 19 additions & 0 deletions dashboard/src/stores/personaStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},

/**
* 批量更新排序
*/
Expand Down
8 changes: 7 additions & 1 deletion dashboard/src/views/persona/PersonaCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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 };
Expand Down
59 changes: 57 additions & 2 deletions dashboard/src/views/persona/PersonaManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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')]"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The validation for the new persona ID only checks if it's required. For a better user experience, you should also validate if the ID already exists on the client-side to provide immediate feedback and avoid an unnecessary server request. You can achieve this by fetching all existing persona IDs when the dialog opens and adding a validation rule, similar to the implementation in PersonaForm.vue.

To implement this, you would need to:

  1. Add a data property, e.g., allPersonaIds: [].
  2. Create a method to fetch all persona IDs (e.g., GET /api/persona/list) and populate allPersonaIds.
  3. Call this method in openClonePersonaDialog.
  4. Update the :rules prop as suggested below.
                        :rules="[v => !!v || tm('cloneDialog.validation.required'), v => !allPersonaIds.includes(v) || tm('cloneDialog.validation.exists')]"

@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>
Expand Down Expand Up @@ -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: '',
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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'));
Comment on lines +515 to +524
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Trim the new persona ID on the client to match backend behavior and avoid surprise whitespace issues.

The backend strips new_persona_id, but the UI uses this.cloneNewPersonaId as‑is for validation and enabling the button. With trailing spaces, the field appears valid while the stored ID is different. Trim on the client (e.g. const trimmed = this.cloneNewPersonaId.trim(); if (!trimmed) return; and use trimmed for validation and the request) so the UI state matches the persisted value.

Suggested implementation:

        async submitClonePersona() {
            const trimmedPersonaId = this.cloneNewPersonaId ? this.cloneNewPersonaId.trim() : '';

            if (!trimmedPersonaId || !this.cloningPersona) return;

            this.cloneLoading = true;
            try {
                await this.clonePersona(this.cloningPersona.persona_id, trimmedPersonaId);
                this.showSuccess(this.tm('cloneDialog.success'));
                this.showCloneDialog = false;
            } catch (error: any) {
                this.showError(error.message || this.tm('cloneDialog.error'));
            } finally {
                this.cloneLoading = false;
            }
        },

To fully align the UI behavior with the trimmed value, you should also:

  1. Update any validation logic or computed properties that enable/disable the "Clone" button to use a trimmed version of cloneNewPersonaId (e.g. const trimmed = this.cloneNewPersonaId?.trim(); return !!trimmed && ...).
  2. Ensure any client-side checks for uniqueness or format of the new persona ID are performed against the trimmed value, not the raw cloneNewPersonaId.

} 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);
Expand Down