diff --git a/src/views/Chat/CharacterForm.vue b/src/views/Chat/CharacterForm.vue
index 191f10b..c1af94b 100644
--- a/src/views/Chat/CharacterForm.vue
+++ b/src/views/Chat/CharacterForm.vue
@@ -10,6 +10,9 @@
{{ isEdit ? '캐릭터 수정' : '캐릭터 등록' }}
+ JSON 다운로드
+
+ JSON 업로드
@@ -1630,6 +1633,147 @@ export default {
} finally {
this.isLoading = false;
}
+ },
+
+ // ===== JSON 내보내기/가져오기 =====
+ buildSerializablePayload() {
+ const c = {
+ ...this.character,
+ tags: this.tags.filter(t => t && typeof t === 'string' && t.trim()),
+ hobbies: [...this.hobbies],
+ values: [...this.values],
+ goals: [...this.goals],
+ relationships: [...this.relationships],
+ personalities: [...this.personalities],
+ backgrounds: [...this.backgrounds],
+ memories: [...this.memories]
+ };
+
+ // 이미지 및 상태 관련 필드 제외
+ delete c.image;
+ delete c.imageUrl;
+ delete c.isActive;
+
+ return {
+ version: 1,
+ ...c
+ };
+ },
+
+ exportToJson() {
+ try {
+ const payload = this.buildSerializablePayload();
+ const json = JSON.stringify(payload, null, 2);
+ const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
+
+ const filenameBase = (this.character.name || 'character').replace(/\s+/g, '_');
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(blob);
+ link.download = `${filenameBase}.character.json`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(link.href);
+ this.notifySuccess('JSON 파일로 내보냈습니다.');
+ } catch (e) {
+ console.error(e);
+ this.notifyError('내보내기 중 오류가 발생했습니다.');
+ }
+ },
+
+ onImportFileChange(e) {
+ const file = e.target.files && e.target.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ try {
+ const text = reader.result;
+ const data = JSON.parse(text);
+ this.applyImportedData(data);
+ this.notifySuccess('JSON 데이터를 불러왔습니다.');
+ } catch (err) {
+ console.error(err);
+ this.notifyError('유효한 JSON 파일이 아닙니다.');
+ } finally {
+ // 동일 파일 재업로드 허용
+ e.target.value = '';
+ }
+ };
+ reader.readAsText(file, 'utf-8');
+ },
+
+ applyImportedData(data) {
+ // 1) 버전/형식 검증
+ if (!data || (data.version !== 1 && data.version !== undefined)) {
+ throw new Error('지원되지 않는 파일 버전입니다.');
+ }
+
+ // 2) 안전한 추출(helper)
+ const arr = (v) => Array.isArray(v) ? v : [];
+ const str = (v) => (v == null ? '' : String(v));
+
+ // 3) 이미지 및 상태 관련 필드 강제 제거 (파일 포맷 요구사항)
+ if (data) {
+ delete data.image;
+ delete data.imageUrl;
+ delete data.isActive;
+ }
+
+ // 4) 기본 필드 주입
+ const patch = {
+ name: str(data.name),
+ description: str(data.description),
+ systemPrompt: str(data.systemPrompt),
+ age: str(data.age),
+ gender: str(data.gender),
+ mbti: str(data.mbti),
+ characterType: str(data.characterType),
+ originalTitle: str(data.originalTitle),
+ originalLink: str(data.originalLink),
+ speechPattern: str(data.speechPattern),
+ speechStyle: str(data.speechStyle),
+ appearance: str(data.appearance)
+ };
+
+ // 5) 배열 필드 주입 + 간단 검증/정제
+ const sanitizeString = (s, max) => String(s || '').trim().substring(0, max);
+
+ this.tags = arr(data.tags).map(t => sanitizeString(t, 50)).slice(0, 20);
+ this.hobbies = arr(data.hobbies).map(h => sanitizeString(h, 100)).slice(0, 10);
+ this.values = arr(data.values).map(v => sanitizeString(v, 100)).slice(0, 10);
+ this.goals = arr(data.goals).map(g => sanitizeString(g, 200)).slice(0, 10);
+
+ this.memories = arr(data.memories).map(m => ({
+ title: sanitizeString(m.title, 100),
+ content: sanitizeString(m.content, 1000),
+ emotion: sanitizeString(m.emotion, 50)
+ })).slice(0, 20);
+
+ this.relationships = arr(data.relationships).map(r => ({
+ personName: sanitizeString(r.personName, 10),
+ relationshipName: sanitizeString(r.relationshipName, 20),
+ description: sanitizeString(r.description, 500),
+ importance: Math.max(1, Math.min(10, parseInt(r.importance || 1))),
+ relationshipType: sanitizeString(r.relationshipType, 10),
+ currentStatus: sanitizeString(r.currentStatus, 10)
+ })).slice(0, 10);
+
+ this.personalities = arr(data.personalities).map(p => ({
+ trait: sanitizeString(p.trait, 100),
+ description: sanitizeString(p.description, 500)
+ })).slice(0, 10);
+
+ this.backgrounds = arr(data.backgrounds).map(b => ({
+ topic: sanitizeString(b.topic, 100),
+ description: sanitizeString(b.description, 1000)
+ })).slice(0, 10);
+
+ // 6) character에 반영 (image는 반드시 null 유지)
+ this.character = {
+ ...this.character,
+ ...patch,
+ image: null
+ };
}
}
}