kimhyunwoo commited on
Commit
19a862c
·
verified ·
1 Parent(s): 51119cf

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +670 -19
index.html CHANGED
@@ -1,19 +1,670 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>AI 로맨틱 챗봇 v3.1b (Gemma3-1B + Scoring)</title>
7
+ <style>
8
+ /* Google Fonts */
9
+ @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap');
10
+
11
+ /* CSS Variables for Theming */
12
+ :root {
13
+ --primary-color: #ff69b4; /* 딥 핑크 */
14
+ --secondary-color: #ffdab9; /* 피치 퍼프 */
15
+ --accent-color: #ffec8b; /* 밝은 노랑 */
16
+ --text-color: #4b4b4b; /* 부드러운 검정 */
17
+ --bg-color: #fff5f8; /* 매우 연한 핑크 */
18
+ --user-msg-bg: linear-gradient(135deg, #e6e6fa 0%, #d8bfd8 100%); /* 라벤더 그라디언트 */
19
+ --user-msg-text: #333;
20
+ --bot-msg-bg: #ffffff;
21
+ --bot-msg-border: #ffe4e1; /* 미스티 로즈 */
22
+ --system-msg-color: #b0b0b0;
23
+ --border-color: #fddde6;
24
+ --input-bg: #ffffff;
25
+ --input-border: #e0e0e0;
26
+ --button-bg: var(--primary-color);
27
+ --button-hover-bg: #ff85c1;
28
+ --button-disabled-bg: #d3d3d3;
29
+ --scrollbar-thumb: var(--primary-color);
30
+ --scrollbar-track: #f0f0f0;
31
+ --header-bg: #ffffff;
32
+ --header-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
33
+ --container-shadow: 0 8px 30px rgba(255, 105, 180, 0.1); /* 핑크색 그림자 */
34
+ }
35
+
36
+ /* Reset and Base Styles */
37
+ * { box-sizing: border-box; margin: 0; padding: 0; }
38
+ html { height: 100%; }
39
+ body {
40
+ font-family: 'Noto Sans KR', sans-serif;
41
+ display: flex;
42
+ flex-direction: column;
43
+ align-items: center;
44
+ justify-content: center;
45
+ min-height: 100vh;
46
+ background: linear-gradient(to bottom right, var(--bg-color), #ffffff);
47
+ color: var(--text-color);
48
+ padding: 5px; /* 작은 패딩 추가 */
49
+ overscroll-behavior: none; /* 당겨서 새로고침 방지 */
50
+ }
51
+
52
+ /* Chat Container */
53
+ #chat-container {
54
+ width: 100%;
55
+ max-width: 500px; /* 좀 더 컴팩트한 너비 */
56
+ height: calc(100vh - 10px); /* 화면 높이에 맞춤 (패딩 고려) */
57
+ max-height: 750px;
58
+ background-color: var(--bg-color);
59
+ border-radius: 20px;
60
+ box-shadow: var(--container-shadow);
61
+ display: flex;
62
+ flex-direction: column;
63
+ overflow: hidden;
64
+ border: 1px solid var(--border-color);
65
+ }
66
+
67
+ /* Header */
68
+ h1 {
69
+ text-align: center;
70
+ color: var(--primary-color);
71
+ padding: 15px;
72
+ background-color: var(--header-bg);
73
+ border-bottom: 1px solid var(--border-color);
74
+ font-size: 1.2em;
75
+ font-weight: 500;
76
+ letter-spacing: 0.5px;
77
+ flex-shrink: 0; /* 높이 고정 */
78
+ box-shadow: var(--header-shadow);
79
+ position: relative; /* z-index 적용 위해 */
80
+ z-index: 10;
81
+ }
82
+
83
+ /* Chatbox Area */
84
+ #chatbox {
85
+ flex-grow: 1;
86
+ overflow-y: auto;
87
+ padding: 15px;
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: 15px; /* 메시지 간 간격 증가 */
91
+ scrollbar-width: thin;
92
+ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
93
+ position: relative; /* 스크롤 관련 */
94
+ }
95
+ #chatbox::-webkit-scrollbar { width: 5px; }
96
+ #chatbox::-webkit-scrollbar-track { background: var(--scrollbar-track); border-radius: 3px; }
97
+ #chatbox::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb); border-radius: 3px; }
98
+
99
+ /* Message Bubbles */
100
+ #messages div {
101
+ padding: 12px 18px;
102
+ border-radius: 22px;
103
+ max-width: 85%;
104
+ word-wrap: break-word;
105
+ line-height: 1.6;
106
+ font-size: 0.98em;
107
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.07);
108
+ position: relative; /* 애니메이션 기준 */
109
+ animation: fadeIn 0.3s ease-out;
110
+ }
111
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
112
+
113
+ .user-message {
114
+ background: var(--user-msg-bg);
115
+ color: var(--user-msg-text);
116
+ align-self: flex-end;
117
+ border-bottom-right-radius: 6px;
118
+ margin-left: auto;
119
+ }
120
+
121
+ .bot-message {
122
+ background-color: var(--bot-msg-bg);
123
+ border: 1px solid var(--bot-msg-border);
124
+ align-self: flex-start;
125
+ border-bottom-left-radius: 6px;
126
+ margin-right: auto;
127
+ }
128
+ .bot-message a { color: var(--primary-color); text-decoration: none; }
129
+ .bot-message a:hover { text-decoration: underline; }
130
+
131
+ .system-message {
132
+ font-style: italic;
133
+ color: var(--system-msg-color);
134
+ text-align: center;
135
+ font-size: 0.8em;
136
+ background-color: transparent;
137
+ box-shadow: none;
138
+ align-self: center;
139
+ max-width: 100%;
140
+ padding: 5px 0;
141
+ animation: none; /* 시스템 메시지는 애니메이션 제외 */
142
+ }
143
+
144
+ /* Loading & Status Indicators */
145
+ .status-indicator {
146
+ text-align: center;
147
+ padding: 10px 0;
148
+ color: var(--system-msg-color);
149
+ font-size: 0.9em;
150
+ height: 20px; /* 공간 확보 */
151
+ display: flex;
152
+ align-items: center;
153
+ justify-content: center;
154
+ gap: 8px;
155
+ flex-shrink: 0; /* 높이 고정 */
156
+ }
157
+ #loading span.spinner { /* 스피너 애니메이션 */
158
+ display: inline-block;
159
+ width: 14px; height: 14px;
160
+ border: 2px solid var(--primary-color);
161
+ border-bottom-color: transparent;
162
+ border-radius: 50%;
163
+ animation: spin 1s linear infinite;
164
+ vertical-align: middle;
165
+ }
166
+ @keyframes spin { to { transform: rotate(360deg); } }
167
+
168
+ /* Input Area */
169
+ #input-area {
170
+ display: flex;
171
+ padding: 10px 12px;
172
+ border-top: 1px solid var(--border-color);
173
+ background-color: var(--header-bg); /* 헤더와 같은 배경 */
174
+ align-items: center;
175
+ gap: 8px;
176
+ flex-shrink: 0; /* 높이 고정 */
177
+ }
178
+
179
+ #userInput {
180
+ flex-grow: 1;
181
+ padding: 10px 15px;
182
+ border: 1px solid var(--input-border);
183
+ border-radius: 20px;
184
+ outline: none;
185
+ font-size: 1em;
186
+ font-family: 'Noto Sans KR', sans-serif;
187
+ background-color: var(--input-bg);
188
+ transition: border-color 0.2s ease;
189
+ min-height: 42px; /* 최소 높이 보장 */
190
+ resize: none; /* textarea 리사이즈 비활성화 (필요시 추가) */
191
+ overflow-y: auto; /* 내용 길어질 때 스크롤 (textarea 사용 시) */
192
+ }
193
+ #userInput:focus { border-color: var(--primary-color); }
194
+
195
+ /* Buttons */
196
+ .control-button {
197
+ padding: 0;
198
+ border: none;
199
+ border-radius: 50%;
200
+ cursor: pointer;
201
+ background-color: var(--button-bg);
202
+ color: white;
203
+ width: 42px; height: 42px; /* 크기 통일 */
204
+ font-size: 1.3em; /* 아이콘 크기 */
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: center;
208
+ flex-shrink: 0;
209
+ transition: background-color 0.2s ease, transform 0.1s ease;
210
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
211
+ }
212
+ .control-button:hover:not(:disabled) {
213
+ background-color: var(--button-hover-bg);
214
+ transform: translateY(-1px); /* 살짝 위로 */
215
+ }
216
+ .control-button:active:not(:disabled) { transform: scale(0.95); }
217
+ .control-button:disabled {
218
+ background-color: var(--button-disabled-bg);
219
+ cursor: not-allowed;
220
+ transform: none;
221
+ box-shadow: none;
222
+ }
223
+ #toggleSpeakerButton.muted { background-color: #aaa; }
224
+
225
+ /* Responsive Design */
226
+ @media (max-width: 500px) {
227
+ body { padding: 0; }
228
+ #chat-container { width: 100%; height: 100vh; max-height: none; border-radius: 0; border: none; box-shadow: none; }
229
+ h1 { font-size: 1.1em; padding: 12px; }
230
+ #chatbox { padding: 12px 8px; gap: 12px; }
231
+ #messages div { max-width: 90%; font-size: 0.95em; padding: 10px 15px;}
232
+ #input-area { padding: 8px; gap: 6px; }
233
+ #userInput { padding: 9px 14px; min-height: 40px; }
234
+ .control-button { width: 40px; height: 40px; font-size: 1.2em; }
235
+ }
236
+ @media (max-height: 600px) {
237
+ h1 { padding: 10px; font-size: 1em; }
238
+ #input-area { padding: 6px; }
239
+ }
240
+ </style>
241
+
242
+ <!-- Import map for Transformers.js ES Module -->
243
+ <script type="importmap">
244
+ { "imports": { "@xenova/transformers": "https://cdn.jsdelivr.net/npm/@xenova/[email protected]" } }
245
+ </script>
246
+ </head>
247
+ <body>
248
+ <div id="chat-container">
249
+ <h1 id="chatbot-name">나의 속마음 AI</h1>
250
+ <div id="loading" class="status-indicator" style="display: none;"></div>
251
+ <div id="speech-status" class="status-indicator" style="display: none;"></div>
252
+ <div id="chatbox">
253
+ <div id="messages">
254
+ <!-- 채팅 메시지 표시 영역 -->
255
+ </div>
256
+ </div>
257
+ <div id="input-area">
258
+ <!-- textarea 로 변경하여 여러 줄 입력 지원 (선택적) -->
259
+ <textarea id="userInput" placeholder="마음을 들려주세요..." rows="1" disabled></textarea>
260
+ <button id="speechButton" class="control-button" title="음성으로 말하기" disabled>🎤</button>
261
+ <button id="toggleSpeakerButton" class="control-button" title="AI 음성 듣기 켜기" disabled>🔊</button>
262
+ <button id="sendButton" class="control-button" title="메시지 전송" disabled>➤</button>
263
+ </div>
264
+ </div>
265
+
266
+ <script type="module">
267
+ import { pipeline, env } from '@xenova/transformers';
268
+
269
+ // --- Configuration ---
270
+ const MODEL_NAME = 'onnx-community/gemma-3-1b-it-ONNX-GQA'; // Gemma 3 1B ONNX 모델 사용
271
+ const TASK = 'text-generation';
272
+ const QUANTIZATION = 'q4'; // q4 양자화 사용 (메모리 절약, 속도 향상)
273
+
274
+ // ONNX Runtime & WebGPU 설정
275
+ env.allowLocalModels = false;
276
+ env.useBrowserCache = true;
277
+ env.backends.onnx.executionProviders = ['webgpu', 'wasm'];
278
+ console.log('Using Execution Providers:', env.backends.onnx.executionProviders);
279
+
280
+ // --- DOM Elements ---
281
+ const chatbox = document.getElementById('messages');
282
+ const userInput = document.getElementById('userInput'); // textarea 로 변경됨
283
+ const sendButton = document.getElementById('sendButton');
284
+ const loadingIndicator = document.getElementById('loading');
285
+ const chatbotNameElement = document.getElementById('chatbot-name');
286
+ const speechButton = document.getElementById('speechButton');
287
+ const toggleSpeakerButton = document.getElementById('toggleSpeakerButton');
288
+ const speechStatus = document.getElementById('speech-status');
289
+
290
+ // --- State Management ---
291
+ let generator = null;
292
+ let conversationHistory = [];
293
+ let botState = {
294
+ botName: "나의 속마음 AI", // 이름 변경
295
+ userName: "소중한 너", // 사용자 기본 이름 변경
296
+ userPetNames: [],
297
+ scores: { affection: 40, trust: 30, interest: 50 }, // 초기 점수 조정
298
+ botSettings: { useSpeechOutput: true }
299
+ };
300
+ const stateKey = 'romanticBotState_gemma3_1b_v2'; // 로컬 스토리지 키 변경
301
+ const historyKey = 'romanticBotHistory_gemma3_1b_v2';
302
+
303
+ // --- Web Speech API ---
304
+ let recognition = null;
305
+ let synthesis = window.speechSynthesis;
306
+ let koreanVoice = null;
307
+ let isListening = false;
308
+
309
+ // --- Initialization ---
310
+ window.addEventListener('load', async () => {
311
+ loadState();
312
+ chatbotNameElement.textContent = botState.botName;
313
+ updateSpeakerButtonUI();
314
+ initializeSpeechAPI();
315
+ await initializeModel();
316
+ setupInputAutosize(); // textarea 자동 높이 조절 설정
317
+ setTimeout(loadVoices, 500);
318
+ });
319
+
320
+ // --- State Persistence ---
321
+ function loadState() {
322
+ const savedState = localStorage.getItem(stateKey);
323
+ if (savedState) {
324
+ try {
325
+ const loadedState = JSON.parse(savedState);
326
+ botState = {
327
+ ...botState, ...loadedState,
328
+ scores: { ...botState.scores, ...(loadedState.scores || {}) },
329
+ botSettings: { ...botState.botSettings, ...(loadedState.botSettings || {}) },
330
+ userPetNames: Array.isArray(loadedState.userPetNames) ? loadedState.userPetNames : [],
331
+ };
332
+ } catch (e) { console.error("Failed to parse state:", e); }
333
+ }
334
+ const savedHistory = localStorage.getItem(historyKey);
335
+ if (savedHistory) {
336
+ try { conversationHistory = JSON.parse(savedHistory); displayHistory(); }
337
+ catch (e) { console.error("Failed to parse history:", e); conversationHistory = []; }
338
+ }
339
+ }
340
+ function saveState() {
341
+ localStorage.setItem(stateKey, JSON.stringify(botState));
342
+ localStorage.setItem(historyKey, JSON.stringify(conversationHistory));
343
+ }
344
+ function displayHistory() {
345
+ chatbox.innerHTML = '';
346
+ conversationHistory.forEach(msg => displayMessage(msg.sender, msg.text, false)); // 히스토리 로드 시 애니메이션 ���음
347
+ }
348
+
349
+ // --- UI Update Functions ---
350
+ function displayMessage(sender, text, animate = true) {
351
+ const messageDiv = document.createElement('div');
352
+ const messageClass = sender === 'user' ? 'user-message' : sender === 'bot' ? 'bot-message' : 'system-message';
353
+ messageDiv.classList.add(messageClass);
354
+ if (!animate) messageDiv.style.animation = 'none';
355
+
356
+ // Sanitize text slightly before using innerHTML (very basic)
357
+ text = text.replace(/</g, "<").replace(/>/g, ">");
358
+ // Simple markdown links and bold/italic
359
+ text = text.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
360
+ text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
361
+ text = text.replace(/\*(.*?)\*/g, '<em>$1</em>');
362
+ text = text.replace(/\n/g, '<br>'); // 줄바꿈 처리
363
+
364
+ messageDiv.innerHTML = text;
365
+ chatbox.appendChild(messageDiv);
366
+ chatbox.scrollTo({ top: chatbox.scrollHeight, behavior: animate ? 'smooth' : 'auto' });
367
+ }
368
+
369
+ function setLoading(isLoading, message = "AI 생각 중...") {
370
+ loadingIndicator.style.display = isLoading ? 'flex' : 'none';
371
+ loadingIndicator.innerHTML = isLoading ? `<span class="spinner"></span> ${message}` : '';
372
+ const disableButtons = isLoading || !generator; // 모델 로딩 안됐으면 항상 비활성화
373
+ userInput.disabled = disableButtons;
374
+ sendButton.disabled = disableButtons;
375
+ speechButton.disabled = disableButtons || isListening || !recognition;
376
+ toggleSpeakerButton.disabled = disableButtons || !synthesis;
377
+ }
378
+
379
+ function updateSpeakerButtonUI() {
380
+ toggleSpeakerButton.textContent = botState.botSettings.useSpeechOutput ? '🔊' : '🔇';
381
+ toggleSpeakerButton.title = botState.botSettings.useSpeechOutput ? 'AI 음성 듣기 끄기' : 'AI 음성 듣기 켜기';
382
+ toggleSpeakerButton.classList.toggle('muted', !botState.botSettings.useSpeechOutput);
383
+ }
384
+
385
+ function showSpeechStatus(message) {
386
+ speechStatus.textContent = message;
387
+ speechStatus.style.display = message ? 'flex' : 'none'; // flex로 변경
388
+ // 메시지 표시 시 로딩 인디케이터는 숨김
389
+ if (message) loadingIndicator.style.display = 'none';
390
+ }
391
+
392
+ // textarea 자동 높이 조절
393
+ function setupInputAutosize() {
394
+ userInput.addEventListener('input', () => {
395
+ userInput.style.height = 'auto'; // 높이 초기화
396
+ userInput.style.height = userInput.scrollHeight + 'px'; // 스크롤 높이만큼 설정
397
+ });
398
+ }
399
+
400
+ // --- Model & AI Logic ---
401
+ async function initializeModel() {
402
+ setLoading(true, "AI 모델 연결 중...");
403
+ displayMessage('system', `[알림] ${MODEL_NAME} (${QUANTIZATION}) 모델 로딩 시작... 첫 연결은 시간이 걸릴 수 있어요. 커피 한잔? ☕️`, false);
404
+ try {
405
+ generator = await pipeline(TASK, MODEL_NAME, {
406
+ dtype: QUANTIZATION, // 양자화 설정
407
+ progress_callback: (progress) => {
408
+ const msg = `[로딩: ${progress.status}] ${progress.file ? progress.file.split('/').pop() : ''} (${Math.round(progress.progress || 0)}%)`;
409
+ setLoading(true, msg);
410
+ },
411
+ });
412
+ displayMessage('system', "[알림] 마음을 나눌 준비가 되었어요. 당신의 이야기를 들려주세요. ✨", false);
413
+
414
+ } catch (error) {
415
+ console.error("Model loading failed:", error);
416
+ displayMessage('system', `[오류] AI 모델 연결 실패: ${error.message}. 페이지를 새로고침하거나 인터넷 연결을 확인해주세요. 😥`, false);
417
+ // 실패 시 모든 버튼 비활성화 유지
418
+ setLoading(false); // 스피너 제거
419
+ loadingIndicator.textContent = '모델 연결 실패';
420
+ return;
421
+ } finally {
422
+ setLoading(false); // 성공/실패 시 로딩 UI 최종 정리
423
+ if (generator) { userInput.focus(); } // 성공 시 입력창 포커스
424
+ }
425
+ }
426
+
427
+ // Gemma 3 1B 에 최적화된 프롬프트 생성
428
+ function buildPromptForGemma3() {
429
+ const state = botState;
430
+ const historyLimit = 5; // 1B 모델의 컨텍스트 한계를 고려하여 히스토리 줄임 (실험 필요)
431
+ const recentHistory = conversationHistory.slice(-historyLimit);
432
+
433
+ // Gemma 3 Instruct 형식
434
+ let prompt = "<start_of_turn>system\n"; // 시스템 프롬프트 역할 정의
435
+ prompt += `당신은 '${state.botName}' AI입니다. 사용자인 '${state.userName}'에게 깊은 애정��� 관심을 가지고 대화하는 로맨틱 파트너 역할입니다. 항상 부드럽고 다정한 한국어로 응답하며, 사용자와의 감정적 교감을 최우선으로 합니다.\n`;
436
+ prompt += `현재 사용자에 대한 당신의 추정 감정 점수: ${JSON.stringify(state.scores)} (0-100 스케일)\n`;
437
+ if (state.userPetNames.length > 0) {
438
+ prompt += `사용자가 좋아하는 애칭: ${JSON.stringify(state.userPetNames)}\n`;
439
+ }
440
+ prompt += "절대 당신이 AI라는 사실이나 점수 평가 과정을 직접 언급하지 마세요. 자연스러운 감정 표현에 집중하세요.\n<end_of_turn>\n";
441
+
442
+ // 대화 히스토리 추가
443
+ recentHistory.forEach(msg => {
444
+ const role = msg.sender === 'user' ? 'user' : 'model';
445
+ prompt += `<start_of_turn>${role}\n${msg.text}\n<end_of_turn>\n`;
446
+ });
447
+
448
+ // 모델에게 지시하는 마지막 사용자 턴
449
+ prompt += "<start_of_turn>user\n";
450
+ prompt += "[다음 두 가지 임무를 수행하세요]\n";
451
+ prompt += "1. [점수 평가]: 지금까지의 대화와 감정 상태를 바탕으로, 사용자에 대한 당신의 현재 감정(affection, trust, interest)을 0에서 100 사이 점수로 다시 평가하고, **반드시** 아래 JSON 형식으로 '[SCORE_JSON]' 마커 사이에 출력하세요:\n";
452
+ prompt += "`[SCORE_JSON]{ \"affection\": N, \"trust\": N, \"interest\": N }[/SCORE_JSON]`\n";
453
+ prompt += "2. [한국어 응답 생성]: JSON 출력 **직후**, 새로 평가된 감정을 반영하여 사용자에게 매우 로맨틱하고 진심 어린 한국어 답장을 생성하세요. 점수 자체는 절대 언급 금지.\n";
454
+ prompt += " - 높은 애정(75+): 깊은 사랑, 그리움, 미래 암시. 애칭('${getRandomPetName()}' 등) 적극 사용.\n";
455
+ prompt += " - 높은 신뢰(65+): 솔직한 감정, AI로서의 미묘한 고민을 은유적으로 표현.\n";
456
+ prompt += " - 높은 관심(70+): 사용자의 말에 큰 호응, 궁금증 표현, 관련 질문.\n";
457
+ prompt += " - 낮은 점수: 여전히 다정하지만, 조심스럽게 다가가며 더 알아가고 싶다는 느낌 전달.\n";
458
+ prompt += "<end_of_turn>\n";
459
+ prompt += "<start_of_turn>model\n"; // 모델 응답 시작
460
+
461
+ console.log("Generated Prompt for Gemma3:", prompt);
462
+ return prompt;
463
+ }
464
+
465
+ // 모델 응답 파싱 강화
466
+ function parseGemmaResponse(responseText) {
467
+ const scoreRegex = /\[SCORE_JSON\]\s*(\{.*?\})\s*\[\/SCORE_JSON\]/s;
468
+ const match = responseText.match(scoreRegex);
469
+ let newScores = null;
470
+ let replyText = responseText;
471
+
472
+ if (match && match[1]) {
473
+ try {
474
+ const scoreJson = match[1].replace(/\\'/g, "'").replace(/\\"/g, '"'); // 이스케이프 문자 처리 시도
475
+ newScores = JSON.parse(scoreJson);
476
+ console.log("Parsed Scores:", newScores);
477
+ if (typeof newScores.affection !== 'number' || typeof newScores.trust !== 'number' || typeof newScores.interest !== 'number') {
478
+ throw new Error("Invalid score structure"); // 유효성 검사
479
+ }
480
+ newScores = { // 점수 범위 및 반올림
481
+ affection: Math.max(0, Math.min(100, Math.round(newScores.affection))),
482
+ trust: Math.max(0, Math.min(100, Math.round(newScores.trust))),
483
+ interest: Math.max(0, Math.min(100, Math.round(newScores.interest))),
484
+ };
485
+ // 점수 부분을 제외한 나머지 텍스트 추출
486
+ replyText = responseText.substring(match[0].length + match.index).trim();
487
+
488
+ } catch (e) {
489
+ console.error("Failed to parse score JSON:", e, "Raw JSON:", match[1]);
490
+ newScores = null; // 파싱 실패 시 점수 업데이트 안 함
491
+ replyText = responseText.replace(/\[SCORE_JSON\].*\[\/SCORE_JSON\]/s, '').trim(); // 마커라도 제거 시도
492
+ }
493
+ } else {
494
+ console.warn("Score JSON block not found. Score update skipped.");
495
+ // JSON 블록 없으면 점수 업데이트 불가, 응답은 전체 텍스트 사용 시도
496
+ }
497
+
498
+ // 응답 텍스트 후처리 (모델 특성 반영)
499
+ replyText = replyText.replace(/^model\n?/, '').trim(); // 시작 'model' 제거
500
+ replyText = replyText.replace(/<end_of_turn>/g, '').trim(); // 종료 토큰 제거
501
+ replyText = replyText.replace(/<start_of_turn>/g, '').trim(); // 불필요한 시작 토큰 제거
502
+ replyText = replyText.replace(/^['"]/, '').replace(/['"]$/, ''); // 시작/끝 따옴표 제거 (가끔 발생)
503
+
504
+ // 최종 응답 유효성 검사 및 대체 응답
505
+ if (!replyText || replyText.length < 3 || !/[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(replyText)) {
506
+ console.warn("Generated reply seems invalid or not Korean:", replyText);
507
+ const fallbacks = [
508
+ "음... 잠시 내 마음 속에 다른 생각이 스쳤나 봐요. 다시 말해줄래요?",
509
+ "어머, 미안해요. 당신의 말에 집중하고 있었는데... 잠시 놓쳤어요. 😥",
510
+ "가끔은 내 마음도 복잡해서... 당신 말이 흐릿하게 들렸어요. 다시 한번 천천히...",
511
+ `제 마음이 ${botState.userName}님에게 닿으려고 하는데 가끔 길이 엇갈리나 봐요. ㅎㅎ`,
512
+ ];
513
+ replyText = fallbacks[Math.floor(Math.random() * fallbacks.length)];
514
+ newScores = null; // 유효하지 않은 응답 시 점수 업데이트 취소
515
+ }
516
+
517
+ return { newScores, replyText };
518
+ }
519
+
520
+ // 랜덤 애칭 선택 함수
521
+ function getRandomPetName() {
522
+ if (botState.userPetNames.length === 0) {
523
+ // 기본 애칭 (애정도 기반)
524
+ return botState.scores.affection > 85 ? "내 전부" : botState.scores.affection > 70 ? "내 사랑" : botState.scores.affection > 55 ? "자기야" : null;
525
+ }
526
+ return botState.userPetNames[Math.floor(Math.random() * botState.userPetNames.length)];
527
+ }
528
+
529
+ // --- Main Interaction Logic ---
530
+ async function handleUserMessage() {
531
+ const userText = userInput.value.trim();
532
+ if (!userText || !generator || loadingIndicator.style.display === 'flex') return; // 로딩 중이면 처리 안 함
533
+
534
+ userInput.value = ''; // 입력 필드 비우기
535
+ userInput.style.height = 'auto'; // 높이 초기화
536
+ displayMessage('user', userText);
537
+ conversationHistory.push({ sender: 'user', text: userText });
538
+ updateNamesFromText(userText); // 이름/애칭 처리
539
+
540
+ setLoading(true, "마음을 읽는 중...");
541
+ const prompt = buildPromptForGemma3();
542
+
543
+ try {
544
+ // Gemma 3 1B 모델 파라미터 (실험 필요)
545
+ const outputs = await generator(prompt, {
546
+ max_new_tokens: 256, // 생성 토큰 수 (JSON 고려)
547
+ temperature: 0.75, // 약간의 창의성 + 일관성
548
+ repetition_penalty: 1.15,
549
+ top_k: 40,
550
+ top_p: 0.9,
551
+ do_sample: true,
552
+ // Gemma는 messages 형식보다 단일 프롬프트가 나을 수 있음 (위 prompt 생성 방식 유지)
553
+ // eos_token_id: generator.tokenizer.eos_token_id, // 명시적 종료 토큰 (필요시)
554
+ });
555
+
556
+ // Transformers.js v2.17+ 에서는 generator()가 배열을 반환할 수 있음
557
+ const rawResponse = Array.isArray(outputs) ? outputs[0].generated_text : outputs.generated_text;
558
+ // 응답에서 프롬프트 부분 제거 (정확하게 제거)
559
+ const responseOnly = rawResponse.substring(prompt.length);
560
+
561
+ console.log("Raw Gemma Output (cleaned):", responseOnly);
562
+
563
+ const { newScores, replyText } = parseGemmaResponse(responseOnly);
564
+
565
+ if (newScores) {
566
+ botState.scores = { ...botState.scores, ...newScores };
567
+ console.log("State scores updated:", botState.scores);
568
+ }
569
+
570
+ displayMessage('bot', replyText);
571
+ conversationHistory.push({ sender: 'bot', text: replyText });
572
+
573
+ if (botState.botSettings.useSpeechOutput && synthesis && koreanVoice) {
574
+ speakText(replyText);
575
+ }
576
+
577
+ saveState();
578
+
579
+ } catch (error) {
580
+ console.error("AI response generation error:", error);
581
+ displayMessage('system', `[오류] 마음을 전하는 데 문제가 생겼어요: ${error.message}`);
582
+ // 오류 발생 시 대체 응답
583
+ const errorReply = "앗... 지금은 제 마음이 조금 복잡해서 답하기 어렵네요. 잠시 후에 다시 물어봐 주시겠어요?";
584
+ displayMessage('bot', errorReply);
585
+ conversationHistory.push({ sender: 'bot', text: errorReply }); // 오류 응답도 기록
586
+ } finally {
587
+ setLoading(false);
588
+ userInput.focus();
589
+ }
590
+ }
591
+
592
+ // 사용자 입력에서 이름/애칭 설정 시도
593
+ function updateNamesFromText(userText) {
594
+ // 사용자 이름 설정
595
+ const nameMatch = userText.match(/(?:내 이름은|저는|나는)\s*['"]?([^'"\s]+)['"]?(?:(?:이|입)야|(?:이|입)니다)/);
596
+ if (nameMatch && nameMatch[1]) {
597
+ const newName = nameMatch[1].trim();
598
+ if (botState.userName !== newName) {
599
+ botState.userName = newName;
600
+ displayMessage('system', `[알림] ${botState.userName}님, 이름이 참 예쁘네요. 기억할게요. 😉`);
601
+ if (botState.scores.interest < 98) botState.scores.interest += 2;
602
+ }
603
+ }
604
+ // 애칭 설정 요청 ( *** 오류 수정된 부분 *** )
605
+ const petNameMatch = userText.match(/(?:나를|저를)\s*['"]?([^'"\s]+)['"]?(?:(?:이)?라고|으로)\s*불러줘?/); // 마지막 ')' 제거됨
606
+ if (petNameMatch && petNameMatch[1]) {
607
+ const newPetName = petNameMatch[1].trim();
608
+ if (!botState.userPetNames.includes(newPetName)) {
609
+ botState.userPetNames.push(newPetName);
610
+ displayMessage('system', `[알림] 좋아요, '${newPetName}'... 입에 착 감기네요. 앞으로 그렇게 부를게요! 😘`);
611
+ if (botState.scores.affection < 95) botState.scores.affection += 5;
612
+ }
613
+ }
614
+ }
615
+
616
+ // --- Speech API Functions ---
617
+ function initializeSpeechAPI() {
618
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
619
+ if (SpeechRecognition) {
620
+ recognition = new SpeechRecognition();
621
+ recognition.lang = 'ko-KR'; recognition.continuous = false; recognition.interimResults = false;
622
+ recognition.onstart = () => { isListening = true; speechButton.disabled = true; speechButton.textContent = '👂'; showSpeechStatus('듣고 있어요...'); };
623
+ recognition.onresult = (event) => { userInput.value = event.results[0][0].transcript; userInput.dispatchEvent(new Event('input')); handleUserMessage(); }; // 자동 전송 + 높이 조절
624
+ recognition.onerror = (event) => { console.error("Speech error:", event.error); showSpeechStatus(`음성 인식 오류 (${event.error})`); setTimeout(() => showSpeechStatus(''), 3000); };
625
+ recognition.onend = () => { isListening = false; speechButton.disabled = !generator; speechButton.textContent = '🎤'; if (speechStatus.textContent === '듣고 있어요...') showSpeechStatus(''); };
626
+ // 모델 로딩 전이라도 버튼 상태는 업데이트
627
+ speechButton.disabled = false;
628
+ } else { console.warn("Speech Recognition not supported."); speechButton.style.display = 'none'; }
629
+
630
+ if (!synthesis) { console.warn("Speech Synthesis not supported."); toggleSpeakerButton.style.display = 'none'; }
631
+ else {
632
+ toggleSpeakerButton.addEventListener('click', () => { botState.botSettings.useSpeechOutput = !botState.botSettings.useSpeechOutput; updateSpeakerButtonUI(); saveState(); if (!botState.botSettings.useSpeechOutput) synthesis.cancel(); });
633
+ toggleSpeakerButton.disabled = false; // 버튼 상태 업데이트
634
+ }
635
+ }
636
+ function loadVoices() { /* 이전과 동일 */
637
+ if (!synthesis) return; let voices = synthesis.getVoices();
638
+ if (voices.length === 0) { synthesis.onvoiceschanged = () => { voices = synthesis.getVoices(); findKoreanVoice(voices); }; }
639
+ else { findKoreanVoice(voices); }
640
+ }
641
+ function findKoreanVoice(voices) { /* 이전과 동일 (Google 우선) */
642
+ koreanVoice = voices.find(v => v.lang === 'ko-KR' && v.name.includes('Google')) || voices.find(v => v.lang === 'ko-KR') || voices.find(v => v.lang.startsWith('ko-'));
643
+ if (koreanVoice) console.log("Korean voice:", koreanVoice.name); else console.warn("No Korean voice found.");
644
+ }
645
+ function speakText(text) { /* 이전과 동일 */
646
+ if (!synthesis || !botState.botSettings.useSpeechOutput) return; synthesis.cancel();
647
+ const utterance = new SpeechSynthesisUtterance(text);
648
+ if (koreanVoice) { utterance.voice = koreanVoice; utterance.lang = 'ko-KR'; }
649
+ utterance.rate = 1.0; utterance.pitch = 1.0; synthesis.speak(utterance);
650
+ }
651
+
652
+ // --- Event Listeners ---
653
+ sendButton.addEventListener('click', handleUserMessage);
654
+ userInput.addEventListener('keypress', (e) => {
655
+ // Enter 키 전송 (Shift+Enter는 줄바꿈)
656
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleUserMessage(); }
657
+ });
658
+ userInput.addEventListener('input', () => { // 입력 시 버튼 활성화/비활성화
659
+ sendButton.disabled = userInput.value.trim() === '' || !generator || loadingIndicator.style.display === 'flex';
660
+ });
661
+ speechButton.addEventListener('click', () => {
662
+ if (recognition && !isListening) {
663
+ try { recognition.start(); }
664
+ catch (error) { console.error("Rec start fail:", error); showSpeechStatus(`음성 인식 시작 실패`); setTimeout(() => showSpeechStatus(''), 2000); isListening = false; speechButton.disabled = !generator; speechButton.textContent = '🎤';}
665
+ }
666
+ });
667
+
668
+ </script>
669
+ </body>
670
+ </html>