feat: import and export personas (#4532)

* [Feature] 人格设定支持导出/导入。
Fixes #4409

- 导出的json

* 导入(dev

* 稍微进化

* 改为直接导出文件

* 删除未使用的i18n键值

* fix:仅白名单字段可以作为文件名

* PersonaForm.vue - savePersona 方法添加了白名单字段过滤

* update

* [Feature] 人格设定支持导出/导入。
Fixes #4409

* 再次完成

* 修复bug 405

* 移除导入和导出按钮的本地化文本(上一次修改的

* 移动到 persona 的 i18n JSON

* [Feature] 人格设定支持导出/导入。
Fixes #4409

找回需要的i18n

* 写错地方了,更正i18n

* 保证相同的i18n

* 在导出时说明不包含tools Skills

* 修改导出格式

* 新格式导入

* 重复检查

* [Bug]当 LLM 的回复本身包含类似 JSON 的格式的时候消息的 content 字段可能被错误地多次序列化
Fixes #4363

* [Bug]当 LLM 的回复本身包含类似 JSON 的格式的时候消息的 content 字段可能被错误地多次序列化
Fixes #4363

* [Bug]当 LLM 的回复本身包含类似 JSON 的格式的时候消息的 content 字段可能被错误地多次序列化
Fixes #4363

提取_parse_image_url_part 处理base64

* - 当原始数据是字符串时(`raw_data` 不为 None):返回 `{"type": "json", "data": {"content": "..."}}` 包装形式

- 当原始数据是字典时:直接返回 `{"type": "json", "data": {...原始字典...}}`,不使用 content 包装

* 像其他组件一样使用 self.type

* 在 Json.__init__ 中 传入 self.raw_data

* 在 Json.to_dict 中 使用 self.type.lower() 来推导

* [Bug]当 LLM 的回复本身包含类似 JSON 的格式的时候消息的 content 字段可能被错误地多次序列化
Fixes #4363

* [Bug]当 LLM 的回复本身包含类似 JSON 的格式的时候消息的 content 字段可能被错误地多次序列化
Fixes #4363

* [Bug]当 LLM 的回复本身包含类似 JSON 的格式的时候消息的 content 字段可能被错误地多次序列化
Fixes #4363

提取_parse_image_url_part 处理base64

* - 当原始数据是字符串时(`raw_data` 不为 None):返回 `{"type": "json", "data": {"content": "..."}}` 包装形式

- 当原始数据是字典时:直接返回 `{"type": "json", "data": {...原始字典...}}`,不使用 content 包装

* 像其他组件一样使用 self.type

* 在 Json.__init__ 中 传入 self.raw_data

* 在 Json.to_dict 中 使用 self.type.lower() 来推导

* [Feature] 人格设定支持导出/导入。
Fixes #4409

* 同步

* feat(persona): 添加导入和导出功能,增强用户体验
This commit is contained in:
圣达生物多
2026-07-13 23:52:05 +08:00
committed by GitHub
parent 6070da9ae2
commit a298c7bf49
5 changed files with 176 additions and 9 deletions

View File

@@ -10,7 +10,9 @@
"cancel": "Cancel",
"save": "Save",
"move": "Move",
"addDialogPair": "Add Dialog Pair"
"addDialogPair": "Add Dialog Pair",
"export": "Export",
"import": "Import Persona"
},
"labels": {
"presetDialogs": "Preset Dialogs ({count} pairs)",
@@ -83,7 +85,15 @@
"saveError": "Save failed",
"deleteConfirm": "Are you sure you want to delete persona \"{id}\"? This action cannot be undone.",
"deleteSuccess": "Deleted successfully",
"deleteError": "Delete failed"
"deleteError": "Delete failed",
"exportConfirm": "Export only includes system prompt and preset dialogs, not tools and Skills config. Continue?",
"exportSuccess": "Exported successfully",
"exportError": "Export failed: {error}",
"importSuccess": "Persona imported successfully, please configure tools and Skills manually",
"importError": "Import failed: {error}",
"importFormatError": "Invalid file format, please select a valid persona export file",
"importMissingPrompt": "Incomplete file content, missing system prompt",
"importIdExists": "Persona name already exists, renamed to \"{id}\""
},
"persona": {
"personasTitle": "Personas",

View File

@@ -10,7 +10,9 @@
"cancel": "Отмена",
"save": "Сохранить",
"move": "Переместить",
"addDialogPair": "Добавить пример диалога"
"addDialogPair": "Добавить пример диалога",
"export": "Экспорт",
"import": "Импорт персонажа"
},
"labels": {
"presetDialogs": "Примеры диалогов ({count})",
@@ -83,7 +85,15 @@
"saveError": "Ошибка сохранения",
"deleteConfirm": "Вы уверены, что хотите удалить персонажа «{id}»? Это действие необратимо.",
"deleteSuccess": "Удалено",
"deleteError": "Ошибка удаления"
"deleteError": "Ошибка удаления",
"exportConfirm": "Экспорт содержит только системный промпт и примеры диалогов, без инструментов и навыков. Продолжить?",
"exportSuccess": "Экспортировано",
"exportError": "Ошибка экспорта: {error}",
"importSuccess": "Персонаж импортирован, настройте инструменты и навыки вручную",
"importError": "Ошибка импорта: {error}",
"importFormatError": "Неверный формат файла, выберите корректный файл экспорта",
"importMissingPrompt": "Неполное содержимое файла, отсутствует системный промпт",
"importIdExists": "Имя персонажа уже существует, переименовано в «{id}»"
},
"persona": {
"personasTitle": "Персонаж",

View File

@@ -10,7 +10,9 @@
"cancel": "取消",
"save": "保存",
"move": "移动",
"addDialogPair": "添加对话对"
"addDialogPair": "添加对话对",
"export": "导出",
"import": "导入人格"
},
"labels": {
"presetDialogs": "预设对话 ({count} 对)",
@@ -83,7 +85,15 @@
"saveError": "保存失败",
"deleteConfirm": "确定要删除人格 \"{id}\" 吗?此操作不可撤销。",
"deleteSuccess": "删除成功",
"deleteError": "删除失败"
"deleteError": "删除失败",
"exportConfirm": "导出仅包含系统提示词和预设对话,不包含工具和 Skills 配置。是否继续导出?",
"exportSuccess": "导出成功",
"exportError": "导出失败:{error}",
"importSuccess": "人格导入成功,请手动配置工具和 Skills",
"importError": "导入失败:{error}",
"importFormatError": "文件格式错误,请选择有效的人格导出文件",
"importMissingPrompt": "文件内容不完整,缺少系统提示词",
"importIdExists": "人格名称已存在,已重命名为 \"{id}\""
},
"persona": {
"personasTitle": "人格",

View File

@@ -20,6 +20,12 @@
</template>
<v-list-item-title>{{ tm('persona.contextMenu.moveTo') }}</v-list-item-title>
</v-list-item>
<v-list-item @click.stop="exportPersona">
<template v-slot:prepend>
<v-icon size="small">mdi-download</v-icon>
</template>
<v-list-item-title>{{ tm('buttons.export') }}</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-item @click.stop="$emit('delete')" class="text-error">
<template v-slot:prepend>
@@ -75,6 +81,10 @@
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import {
askForConfirmation as askForConfirmationDialog,
useConfirmDialog
} from '@/utils/confirmDialog';
interface Persona {
persona_id: string;
@@ -97,10 +107,11 @@ export default defineComponent({
required: true
}
},
emits: ['view', 'edit', 'move', 'delete'],
emits: ['view', 'edit', 'move', 'delete', 'export'],
setup() {
const { tm } = useModuleI18n('features/persona');
return { tm };
const confirmDialog = useConfirmDialog();
return { tm, confirmDialog };
},
data() {
return {
@@ -135,6 +146,41 @@ export default defineComponent({
formatDate(dateString: string | undefined | null): string {
if (!dateString) return '';
return new Date(dateString).toLocaleString();
},
async exportPersona() {
// 确认提示:告知用户导出仅包含系统提示词和预设对话
const confirmed = await askForConfirmationDialog(
this.tm('messages.exportConfirm'),
this.confirmDialog,
);
if (!confirmed) return;
try {
// 仅导出 persona_id, system_prompt, begin_dialogs
const exportData = {
persona_id: this.persona.persona_id,
system_prompt: this.persona.system_prompt,
begin_dialogs: this.persona.begin_dialogs || []
};
const jsonStr = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `persona_${this.persona.persona_id}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
// 通过事件通知父组件显示成功消息
this.$emit('export', this.tm('messages.exportSuccess'));
} catch (error: any) {
console.error('导出人格失败:', error);
// 通过事件通知父组件显示错误消息
this.$emit('export', this.tm('messages.exportError', { error: error.message || String(error) }));
}
}
}
});

View File

@@ -28,6 +28,10 @@
<!-- 操作按钮组 -->
<div class="d-flex ga-2">
<v-btn variant="outlined" prepend-icon="mdi-upload" @click="triggerImport"
rounded="lg">
{{ tm('buttons.import') }}
</v-btn>
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" @click="openCreatePersonaDialog"
rounded="lg">
{{ tm('buttons.create') }}
@@ -36,6 +40,8 @@
rounded="lg">
{{ tm('folder.createButton') }}
</v-btn>
<input ref="importFileInput" type="file" accept=".json" style="display: none"
@change="handleImportFile" />
</div>
</div>
@@ -80,7 +86,7 @@
xl="4">
<PersonaCard :persona="persona" @view="viewPersona(persona)"
@edit="editPersona(persona)" @move="openMovePersonaDialog(persona)"
@delete="confirmDeletePersona(persona)" />
@delete="confirmDeletePersona(persona)" @export="handlePersonaExport" />
</v-col>
</v-row>
</div>
@@ -267,6 +273,7 @@
import { defineComponent } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { usePersonaStore } from '@/stores/personaStore';
import { personaApi } from '@/api/v1';
import { mapState, mapActions } from 'pinia';
import FolderTree from './FolderTree.vue';
@@ -533,6 +540,90 @@ export default defineComponent({
}
},
// 导出/导入操作
handlePersonaExport(message: string) {
// 根据消息内容判断成功还是失败
if (message.includes(this.tm('messages.exportSuccess'))) {
this.showSuccess(message);
} else {
this.showError(message);
}
},
triggerImport() {
const input = this.$refs.importFileInput as HTMLInputElement;
if (input) {
input.value = ''; // 清空之前的选择,允许重复导入同一文件
input.click();
}
},
async handleImportFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
let data: any;
try {
data = JSON.parse(text);
} catch (e) {
this.showError(this.tm('messages.importFormatError'));
return;
}
// 校验字段
if (!data.system_prompt) {
this.showError(this.tm('messages.importMissingPrompt'));
return;
}
// 检查 persona_id 是否已存在
let personaId = data.persona_id || 'imported_persona';
const listRes = await personaApi.list();
const existingIds = listRes.data.status === 'ok'
? (listRes.data.data || []).map((p: any) => p.persona_id)
: [];
let renamed = false;
if (existingIds.includes(personaId)) {
personaId = `${personaId}_imported`;
// 如果 _imported 也存在,加数字后缀
let counter = 1;
while (existingIds.includes(personaId)) {
personaId = `${data.persona_id}_imported_${counter}`;
counter++;
}
renamed = true;
}
// 构造新的人格数据
const newPersona = {
persona_id: personaId,
system_prompt: data.system_prompt,
begin_dialogs: data.begin_dialogs || [],
tools: null, // 默认使用所有工具
skills: null, // 默认使用所有 Skills
folder_id: this.currentFolderId
};
await personaApi.create(newPersona);
// 刷新列表
await this.refreshCurrentFolder();
if (renamed) {
this.showSuccess(this.tm('messages.importIdExists', { id: personaId }));
} else {
this.showSuccess(this.tm('messages.importSuccess'));
}
} catch (error: any) {
console.error('导入人格失败:', error);
this.showError(this.tm('messages.importError', { error: error.message || String(error) }));
}
},
// 辅助方法
formatDate(dateString: string | undefined | null): string {
if (!dateString) return '';