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 + }; } } }