mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
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:
@@ -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",
|
||||
|
||||
@@ -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": "Персонаж",
|
||||
|
||||
@@ -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": "人格",
|
||||
|
||||
@@ -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) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
Reference in New Issue
Block a user