From 199049ab7ce73e24927fb53df8269b749c0584f3 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Sat, 6 Sep 2025 00:58:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=ED=8F=BC=EC=97=90=20JSON=20=EB=82=B4=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B8=B0/=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 툴바에 'JSON 다운로드/업로드' 버튼 추가 - buildSerializablePayload, exportToJson, onImportFileChange, applyImportedData 메서드 구현 - 이미지(image, imageUrl) 및 isActive는 직렬화/역직렬화에서 제외 - 업로드 시 버전 검증 및 길이/개수 제한, 중요도(1~10) 보정 적용 - 사용자 알림 메시지(성공/오류) 한글화 --- src/views/Chat/CharacterForm.vue | 144 +++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) 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 + }; } } }