Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
view/hinana/ai.ejs
공개 가능
1 <!DOCTYPE html>
2 <html lang="ko" xmlns="http://www.w3.org/1999/xhtml">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="color-scheme" content="light dark">
6 <meta name="viewport" content="width=device-width, initial-scale=1">
7 <link rel="manifest" href="/manifest.json">
8 <meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#000000' : '#ffffff' %>">
9 <title>히나나 AI — 비나래아카이브</title>
10 <meta name="apple-mobile-web-app-title" content="히나나 AI">
11 <meta property="og:image" content="/image/2.png" />
12 <meta property="og:description" content="히나나 AI와 대화해보세요."/>
13 <meta property="og:url" content="hinana.moe/hinana/ai"/>
14 <meta property="og:title" content="히나나 AI — 비나래아카이브"/>
15 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
16 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
17 <link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet" type="text/css">
18 <link href="https://fonts.googleapis.com/css?family=Lato" rel="stylesheet" type="text/css">
19 <link rel="stylesheet" href="/css/hinana.css" type="text/css">
20 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
21 <script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
22 <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
23 <script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.2/dist/purify.min.js"></script>
24 <script src="/js/popup.js"></script>
25 <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
26 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
27 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
28 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
29 <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.3/umd/popper.min.js" integrity="sha384-vFJXuSJphROIrBnz7yo7oB41mKfc8JzQZiCq4NCceLEaO4IHwicKwpJf9c9IpFgh" crossorigin="anonymous"></script>
30 </head>
31 <style>
32 :root {
33 --font-family: 'Noto Sans KR', sans-serif;
34 --bg-main: #ffffff; --bg-secondary: #f7f9f9; --bg-tertiary: #eff3f4;
35 --text-primary: #0f1419; --text-secondary: #536471;
36 --accent-color: #1d9bf0; --danger-color: #f4212e; --border-color: #eff3f4;
37 --shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
38
39 --chat-bg-user: #1d9bf0; --chat-text-user: #fff;
40 --chat-bg-ai: #f7f9f9; --chat-text-ai: #0f1419;
41 }
42 body.dark-mode {
43 --bg-main: #000000; --bg-secondary: #16181c; --bg-tertiary: #202327;
44 --text-primary: #e7e9ea; --text-secondary: #71767b;
45 --border-color: #2f3336; --accent-color: #1d9bf0; --danger-color: #f4212e;
46 --shadow-sm: 0 1px 3px rgba(255,255,255,0.04);
47
48 --chat-bg-user: #1d9bf0; --chat-bg-ai: #16181c; --chat-text-ai: #e7e9ea;
49 }
50
51 html, body { height: 100%; margin: 0; font-family: var(--font-family); background-color: var(--bg-main); color: var(--text-primary); overflow: hidden; }
52 a { text-decoration: none; color: inherit; }
53
54 /* [Header] */
55 .global-header {
56 height: 60px;
57 background-color: rgba(255,255,255,0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
58 border-bottom: 1px solid var(--border-color);
59 display: flex; align-items: center; justify-content: space-between; padding: 0 20px;
60 position: sticky; top: 0; z-index: 1000;
61 }
62 body.dark-mode .global-header { background-color: rgba(0,0,0,0.85); }
63 .header-brand { display: flex; align-items: center; gap: 15px; }
64 .header-logo { height: 28px; width: auto; mix-blend-mode: multiply; }
65 body.dark-mode .header-logo { mix-blend-mode: screen; }
66
67 .header-nav {
68 position: absolute; left: 50%; transform: translateX(-50%);
69 display: flex; gap: 20px; align-items: center; z-index: 5;
70 }
71 .nav-link { font-weight: 600; font-size: 0.95rem; color: var(--text-secondary); cursor: pointer; }
72 .nav-link:hover { color: var(--accent-color); }
73 .nav-link.active { color: var(--text-primary); }
74 .nav-divider { opacity: 0.3; color: var(--text-secondary); }
75 .login-link { color: var(--accent-color); font-weight: bold; }
76
77 .header-controls { display: flex; align-items: center; gap: 10px; z-index: 10; position: relative; }
78 .icon-btn { border: none; background: transparent; color: var(--text-secondary); font-size: 1.1rem; cursor: pointer; padding: 4px; }
79 .icon-btn:hover { color: var(--text-primary); }
80
81 /* [Layout] */
82 .layout-container { display: flex; height: calc(100vh - 60px); }
83
84 .shelf-column {
85 width: 300px; min-width: 300px; background-color: var(--bg-secondary);
86 border-right: 1px solid var(--border-color); display: flex; flex-direction: column;
87 overflow: hidden;
88 }
89 .shelf-header {
90 padding: 16px 16px 10px; border-bottom: 1px solid var(--border-color);
91 display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
92 }
93 .shelf-title { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-secondary); }
94 .shelf-history { flex: 1; overflow-y: auto; padding: 10px 10px; display: flex; flex-direction: column; gap: 8px; }
95 .shelf-item {
96 background-color: var(--bg-main); border: 1px solid var(--border-color);
97 border-radius: 10px; padding: 10px 12px; cursor: pointer;
98 transition: border-color 0.15s;
99 }
100 .shelf-item:hover { border-color: var(--accent-color); }
101 .shelf-item-q {
102 font-size: 0.8rem; font-weight: 600; color: var(--text-primary);
103 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 4px;
104 }
105 .shelf-item-a {
106 font-size: 0.75rem; color: var(--text-secondary);
107 display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical;
108 overflow: hidden; line-height: 1.4;
109 }
110 .shelf-empty {
111 flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
112 gap: 8px; color: var(--text-secondary); font-size: 0.85rem; padding: 20px;
113 }
114 .shelf-footer { padding: 12px 16px; border-top: 1px solid var(--border-color); flex-shrink: 0; }
115
116 /* [Center: Chat] */
117 .content-column {
118 flex: 1; display: flex; flex-direction: column;
119 background-color: var(--bg-main); position: relative; overflow: hidden;
120 }
121
122 .chat-scroll-area {
123 flex: 1; overflow-y: auto; padding: 20px;
124 display: flex; flex-direction: column; gap: 15px;
125 }
126
127 .chat-input-area {
128 padding: 20px; background-color: var(--bg-tertiary); border-top: 1px solid var(--border-color);
129 }
130 .chat-form { display: flex; gap: 10px; align-items: flex-end; }
131 .chat-input {
132 flex: 1; border: 1px solid var(--border-color); border-radius: 20px; padding: 10px 15px;
133 background-color: var(--bg-main); color: var(--text-primary); font-size: 1rem;
134 resize: none; overflow-y: hidden; line-height: 1.5;
135 min-height: 44px; max-height: 116px; overflow-y: auto;
136 font-family: var(--font-family);
137 }
138 .chat-input:focus { outline: none; border-color: var(--accent-color); }
139
140 /* [수정 1] 전송 버튼 텍스트 잘림 방지 */
141 .btn-send {
142 background-color: var(--accent-color); color: white; border: none; padding: 0 20px;
143 border-radius: 20px; font-weight: bold; transition: opacity 0.2s;
144 display: flex; align-items: center; gap: 5px; white-space: nowrap;
145 min-width: 90px; justify-content: center;
146 height: 44px; flex-shrink: 0;
147 }
148 .btn-send:hover { opacity: 0.9; }
149
150 /* Messages */
151 .message { max-width: 75%; padding: 12px 16px; border-radius: 18px; line-height: 1.5; word-break: break-word; font-size: 0.95rem; }
152 .hinana-message { align-self: flex-start; background-color: var(--chat-bg-ai); color: var(--chat-text-ai); border-bottom-left-radius: 4px; border: 1px solid var(--border-color); }
153 .user-message { align-self: flex-end; background-color: var(--chat-bg-user); color: var(--chat-text-user); border-bottom-right-radius: 4px; white-space: pre-wrap; }
154 .message p { margin: 0; }
155
156 .btn-share {
157 font-size: 0.75rem; color: var(--text-secondary);
158 background: transparent; border: none; padding: 0; margin-top: 5px;
159 display: flex; align-items: center; gap: 4px; margin-left: auto;
160 }
161 .btn-share:hover { color: #5865F2; }
162
163 .selection-note-menu {
164 position: fixed; z-index: 21000; display: none; align-items: center; gap: 5px;
165 border: 1px solid var(--border-color); border-radius: 999px; padding: 5px;
166 background-color: var(--bg-main); box-shadow: var(--shadow-sm);
167 }
168 .selection-note-menu.visible { display: flex; }
169 .selection-note-button {
170 border: none; border-radius: 999px; padding: 7px 11px; background: transparent;
171 color: var(--text-primary); font-size: 0.78rem; font-weight: 600;
172 }
173 .selection-note-button:hover { color: var(--accent-color); border-color: var(--accent-color); }
174
175 /* [Right Sidebar] */
176 .info-column {
177 width: 260px; min-width: 260px; background-color: var(--bg-secondary);
178 border-left: 1px solid var(--border-color); padding: 30px 20px;
179 display: flex; flex-direction: column; gap: 30px; overflow-y: auto;
180 }
181 .info-card {
182 background-color: var(--bg-main); border-radius: 12px; padding: 20px;
183 border: 1px solid var(--border-color); box-shadow: var(--shadow-sm);
184 }
185 .info-card-title { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-secondary); margin-bottom: 10px; }
186
187 .theme-toggle-wrapper { display: flex; align-items: center; justify-content: space-between; }
188 .switch { position: relative; display: inline-block; width: 40px; height: 22px; vertical-align: middle; }
189 .switch input { opacity: 0; width: 0; height: 0; }
190 .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 34px; }
191 .slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
192 input:checked + .slider { background-color: var(--accent-color); }
193 input:checked + .slider:before { transform: translateX(18px); }
194
195 /* [수정 3] 푸터 테두리 제거 (희미한 선 해결) */
196 .info-column .footer,
197 .info-column footer.footer {
198 background-color: transparent !important;
199 box-shadow: none !important;
200 border: none !important; /* 선 제거 */
201 margin-top: 20px;
202 color: var(--text-secondary) !important;
203 }
204 .footer-logo { width: 80px; opacity: 0.5; mix-blend-mode: multiply; margin-bottom: 8px; }
205 body.dark-mode .footer-logo { mix-blend-mode: screen; }
206
207 /* [Mobile Responsive] */
208 @media (max-width: 960px) {
209 .selection-note-menu {
210 left: 12px !important; right: 12px !important; bottom: 16px !important; top: auto !important;
211 border-radius: 14px; justify-content: stretch; gap: 4px;
212 }
213 .selection-note-button { flex: 1; padding: 11px 10px; }
214 html, body { overflow: auto !important; height: auto !important; padding-top: 0 !important; }
215 .global-header {
216 position: fixed !important; top: 0; left: 0; right: 0;
217 z-index: 10000 !important; height: auto !important; min-height: 60px;
218 padding: 10px 15px; flex-wrap: wrap; box-shadow: 0 2px 5px rgba(0,0,0,0.1);
219 }
220 .header-nav {
221 position: static !important; transform: none !important;
222 gap: 10px; font-size: 0.9rem; order: 3; width: 100%;
223 justify-content: center; margin-top: 5px; padding-top: 5px;
224 border-top: 1px solid rgba(0,0,0,0.05);
225 }
226 .header-left { flex: auto; order: 1; }
227 .header-controls { flex: auto; order: 2; margin-left: auto; justify-content: flex-end; }
228
229 .layout-container {
230 flex-direction: column; height: auto !important;
231 margin-top: 0 !important; padding-top: 130px !important;
232 min-height: calc(100vh - 60px);
233 background-color: var(--bg-main) !important;
234 }
235 .shelf-column {
236 position: fixed; top: 0; left: -300px; height: 100%; width: 280px;
237 z-index: 19999; transition: left 0.28s ease;
238 box-shadow: 4px 0 16px rgba(0,0,0,0.18);
239 }
240 .shelf-column.drawer-open { left: 0; }
241 .shelf-overlay {
242 display: none; position: fixed; inset: 0;
243 background: rgba(0,0,0,0.4); z-index: 19998;
244 }
245 .shelf-overlay.open { display: block; }
246 .btn-history-toggle { display: flex !important; }
247
248 .content-column {
249 width: 100%; height: 70vh !important; min-height: 500px;
250 flex: none; order: 1; border: none !important;
251 border-radius: 12px; margin-bottom: 40px; overflow: hidden;
252 }
253
254 .info-column {
255 display: flex !important; width: 100%; height: auto;
256 border-left: none; border-top: 1px solid var(--border-color); /* 모바일 구분선은 유지 */
257 order: 2; padding: 30px 20px; flex-direction: row; flex-wrap: wrap; gap: 20px;
258 }
259 .info-card { flex: 0 0 calc(50% - 10px); width: calc(50% - 10px); min-width: 0 !important; margin-bottom: 0; }
260
261 /* 푸터 모바일용 */
262 .info-column .footer, .info-column footer.footer { flex: 0 0 100%; width: 100%; text-align: center !important; }
263 .info-column .footer img { margin: 0 auto; display: block; }
264 .content-column {
265 border: none !important;
266 padding-top: 0 !important;
267 margin-top: 0 !important;
268 }
269
270 .chat-scroll-area {
271 padding-top: 0 !important;
272 }
273
274 .chat-scroll-area .message:first-child {
275 margin-top: 0 !important;
276 }
277 }
278 .d-none { display: none !important; }
279
280 /* 로딩 말풍선 */
281 .loading-bubble {
282 align-self: flex-start;
283 background-color: var(--chat-bg-ai);
284 color: var(--chat-text-ai);
285 border: 1px solid var(--border-color);
286 border-radius: 18px;
287 border-bottom-left-radius: 4px;
288 padding: 12px 18px;
289 font-size: 0.9rem;
290 color: var(--text-secondary);
291 display: flex;
292 align-items: center;
293 gap: 10px;
294 }
295 .loading-dots span {
296 display: inline-block;
297 width: 6px; height: 6px;
298 background-color: var(--text-secondary);
299 border-radius: 50%;
300 animation: dotBounce 1.2s infinite ease-in-out;
301 }
302 .loading-dots span:nth-child(2) { animation-delay: 0.2s; }
303 .loading-dots span:nth-child(3) { animation-delay: 0.4s; }
304 @keyframes dotBounce {
305 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
306 40% { transform: translateY(-6px); opacity: 1; }
307 }
308 </style>
309 </head>
310
311 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
312
313 <header class="global-header">
314 <div class="header-brand">
315 <a href="/hinana/index">
316 <img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'archive1.png' : 'archive.png' %>"
317 alt="Hinana Archive" class="header-logo">
318 </a>
319 </div>
320 <nav class="header-nav">
321 <a href="/hinana/index" class="nav-link">Archive</a>
322 <a href="/hinana/info" class="nav-link">Info</a>
323 <a href="/hinana/blog" class="nav-link">Blog</a>
324 <a href="/hinana/lounge" class="nav-link">Lounge</a>
325 <span class="nav-divider">|</span>
326 <% if(username) { %>
327 <a href="/logout?redirect=/hinana/ai" class="nav-link text-danger fw-bold">Logout</a>
328 <% } else { %>
329 <a href="/login?redirect=/hinana/ai" class="nav-link login-link fw-bold">Login</a>
330 <% } %>
331 </nav>
332 <div class="header-controls">
333 <button class="icon-btn btn-history-toggle" id="btn-history-toggle" title="대화 기록" style="display:none;">
334 <i class="bi bi-clock-history"></i>
335 </button>
336 <a href="/hinana/gallery#brand-assets" class="nav-link" style="font-size:0.9rem;">사이트 맵</a>
337 <form action="/toggle-theme" method="POST" class="d-inline">
338 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
339 <button type="submit" class="icon-btn" title="테마 변경">
340 <i class="bi <%= (typeof theme !== 'undefined' && theme==='dark') ? 'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
341 </button>
342 </form>
343 </div>
344 </header>
345
346 <div class="shelf-overlay" id="shelf-overlay"></div>
347 <div class="layout-container">
348
349 <div class="shelf-column">
350 <div class="shelf-header">
351 <span class="shelf-title"><i class="bi bi-clock-history me-1"></i>대화 기록</span>
352 </div>
353
354 <%
355 const qaList = [];
356 const msgs = (typeof history !== 'undefined' && Array.isArray(history)) ? history : [];
357 for (let i = 0; i < msgs.length; i++) {
358 if (msgs[i].role === 'user') {
359 const ans = msgs[i + 1] && msgs[i + 1].role === 'assistant' ? msgs[i + 1].content : null;
360 qaList.push({ q: msgs[i].content, a: ans });
361 }
362 }
363 %>
364
365 <% if (qaList.length === 0) { %>
366 <div class="shelf-empty">
367 <i class="bi bi-chat-dots" style="font-size:1.8rem; opacity:0.3;"></i>
368 <span>아직 대화 내용이 없어요</span>
369 </div>
370 <% } else { %>
371 <div class="shelf-history custom-scrollbar">
372 <% qaList.forEach(function(item, idx) { %>
373 <div class="shelf-item" data-idx="<%= idx %>">
374 <div class="shelf-item-q">❓ <%= item.q.replace(/\n/g, ' ').substring(0, 60) %><%= item.q.length > 60 ? '…' : '' %></div>
375 <% if (item.a) { %>
376 <div class="shelf-item-a"><%= item.a.replace(/[#*`>]/g, '').replace(/\n/g, ' ').substring(0, 80) %><%= item.a.length > 80 ? '…' : '' %></div>
377 <% } %>
378 </div>
379 <% }); %>
380 </div>
381 <% } %>
382
383 <div class="shelf-footer">
384 <a href="/hinana/index" class="btn btn-outline-secondary btn-sm w-100">
385 <i class="bi bi-arrow-left"></i> 홈으로
386 </a>
387 </div>
388 </div>
389
390 <div class="content-column">
391 <div class="chat-scroll-area custom-scrollbar" id="chat-window">
392 <div class="message hinana-message">
393 <p><%= initialMessage || '안녕하세요!' %></p>
394 </div>
395 </div>
396
397 <div class="chat-input-area">
398 <form class="chat-form" id="chat-form">
399 <textarea id="message-input" class="chat-input" placeholder="메시지 보내기..." autocomplete="off" rows="1"></textarea>
400 <button type="submit" class="btn-send">
401 <i class="bi bi-send-fill"></i> 전송
402 </button>
403 </form>
404 </div>
405 </div>
406
407 <div class="selection-note-menu" id="selection-note-menu">
408 <button type="button" class="selection-note-button" id="selection-note-button">
409 <i class="bi bi-journal-plus"></i> 노트 저장
410 </button>
411 <button type="button" class="selection-note-button" id="full-note-button">
412 <i class="bi bi-journal-text"></i> 전체를 노트로 저장
413 </button>
414 </div>
415
416 <div class="info-column">
417
418 <!-- Current User -->
419 <div class="info-card">
420 <div class="info-card-title">Current User</div>
421
422 <div class="user-profile mb-3">
423 <% if (typeof currentUserProfileImage !== 'undefined' && currentUserProfileImage) { %>
424 <img src="<%= currentUserProfileImage %>" alt="프로필" class="user-avatar-lg" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
425 <% } else { %>
426 <i class="bi bi-person-circle user-avatar-lg"></i>
427 <% } %>
428 <div class="user-info-text">
429 <div class="user-name-lg">
430 <% if (username) { %>
431 <a href="/hinana/userInfo" class="text-decoration-none text-reset"><%= username %></a>
432 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
433 <span class="badge bg-warning text-dark align-middle" style="font-size:0.6rem;">ADMIN</span>
434 <% } %>
435 <% } else { %>
436 Guest
437 <% } %>
438 </div>
439
440 <div class="mt-2">
441 <% if (username) { %>
442 <a href="/logout?redirect=/hinana/ai" class="btn btn-outline-secondary btn-sm w-100">
443 <i class="bi bi-box-arrow-right"></i> Logout
444 </a>
445 <% } else { %>
446 <a href="/login?redirect=/hinana/ai" class="btn btn-primary btn-sm w-100">Login</a>
447 <% } %>
448 </div>
449 </div>
450 </div>
451
452 </div>
453
454 <!-- ✅ Settings -->
455 <div class="info-card">
456 <div class="info-card-title">Settings</div>
457
458 <div class="theme-toggle-wrapper mb-3">
459 <span class="d-flex align-items-center gap-2 small text-secondary">
460 <i class="bi <%= (typeof theme!=='undefined'&&theme==='dark')?'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
461 <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'Dark Mode' : 'Light Mode' %>
462 </span>
463
464 <!-- ✅ 여기 _csrf 추가 필수 -->
465 <form action="/toggle-theme" method="POST" id="theme-form-side">
466 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
467 <label class="switch" style="transform:scale(0.8);">
468 <input type="checkbox"
469 <%= (typeof theme!=='undefined'&&theme==='dark')?'checked':'' %>
470 onchange="document.getElementById('theme-form-side').submit()">
471 <span class="slider"></span>
472 </label>
473 </form>
474 </div>
475
476 <div class="theme-toggle-wrapper mb-3">
477 <span class="d-flex align-items-center gap-2 small text-secondary">
478 <i class="bi bi-person-heart"></i> 히나나 모드
479 </span>
480
481 <form action="/hinana/toggle-hinana-mode" method="POST" id="persona-form">
482 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
483 <label class="switch" style="transform:scale(0.8);">
484 <input type="checkbox"
485 <%= (typeof hinanaMode !== 'undefined' && hinanaMode) ? 'checked' : '' %>
486 onchange="document.getElementById('persona-form').submit()">
487 <span class="slider"></span>
488 </label>
489 </form>
490 </div>
491
492 <form id="reset-session-form" action="/hinana/reset-session" method="POST" class="mt-4">
493 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
494 <button type="submit" class="btn btn-outline-danger btn-sm w-100">
495 <i class="bi bi-trash"></i> 대화 초기화
496 </button>
497 </form>
498 <a href="/hinana/ai/notes" class="btn btn-outline-secondary btn-sm w-100 mt-2">
499 <i class="bi bi-journal-text"></i> AI 노트 관리
500 </a>
501 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
502 <a href="/hinana/personal-notes" class="btn btn-outline-secondary btn-sm w-100 mt-2">
503 <i class="bi bi-journal"></i> 개인 노트
504 </a>
505 <% } %>
506 </div>
507
508 <!-- System Info -->
509 <div class="info-card mt-3">
510 <div class="info-card-title">System Info</div>
511 <ul class="small text-secondary list-unstyled mb-0">
512 <li class="mb-1 d-flex justify-content-between"><span>Version</span></li>
513 <li class="mb-1 d-flex justify-content-between"><span> Ver. 6.5.4.0-Kozeki Ui</span></li>
514 <li class="mb-1 d-flex justify-content-between"><span>Powered by gpt-5.5</span></li>
515 </ul>
516 </div>
517
518 <div style="text-align:center; margin-top: 40px;" class="footer">
519 <img src="/image/sign.png" id="fumika_sign" style="max-width:200px; width:80%; opacity:0.8; mix-blend-mode:multiply;" />
520 </div>
521 <footer class="text-center footer mt-2">
522 <p style="margin-bottom: 0rem; font-size: 0.8rem; color: var(--text-secondary);">X - @NoctchillHinana</p>
523 <p style="margin-bottom: 0rem; font-size: 0.8rem; color: var(--text-secondary);">ⓒ 2024~2026. 비나래 | hinana.moe All rights reserved.</p>
524 </footer>
525
526 </div>
527
528 </div>
529
530 <script>
531
532 const chatForm = document.getElementById('chat-form');
533 const messageInput = document.getElementById('message-input');
534 const chatWindow = document.getElementById('chat-window');
535 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
536 const isAdmin = '<%= username %>' === '비나래';
537 const TRUNCATE_LIMIT = 900;
538 const selectionNoteMenu = document.getElementById('selection-note-menu');
539 const selectionNoteButton = document.getElementById('selection-note-button');
540 const fullNoteButton = document.getElementById('full-note-button');
541 let selectedNotePayload = null;
542
543 // 저장된 대화가 있으면 화면에 그리기
544 if (savedHistory && savedHistory.length > 0) {
545 // 기존의 초기 메시지(HTML에 하드코딩된 것)를 지움
546 chatWindow.innerHTML = '';
547 let lastUserQuestion = null;
548
549 savedHistory.forEach(msg => {
550 // 시스템 메시지(설정)는 화면에 안 보여줌
551 if (msg.role === 'system') return;
552
553 if (msg.role === 'user') {
554 lastUserQuestion = msg.content;
555 appendMessage(msg.content, 'user');
556 return;
557 }
558
559 if (msg.role === 'assistant') {
560 appendMessage(msg.content, 'hinana', lastUserQuestion);
561 }
562 });
563 }
564 // textarea 자동 높이 조절 (최대 4줄)
565 messageInput.addEventListener('input', function () {
566 this.style.height = 'auto';
567 this.style.height = Math.min(this.scrollHeight, 116) + 'px';
568 });
569
570 // ★ 페이지 로드 시 한 번만 읽어두기
571 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
572 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
573
574 if (savedHistory && savedHistory.length > 0) {
575 chatWindow.innerHTML = ''; // 초기 메시지 삭제
576
577 let lastUserQuestion = null; // [핵심] 사용자의 마지막 질문을 기억할 변수
578
579 savedHistory.forEach(msg => {
580 if (msg.role === 'system') return;
581
582 if (msg.role === 'user') {
583 // 1. 사용자 질문이면 -> 변수에 저장해둠
584 lastUserQuestion = msg.content;
585 appendMessage(msg.content, 'user');
586 }
587 else if (msg.role === 'assistant') {
588 // 2. AI 답변이면 -> 아까 저장해둔 질문(lastUserQuestion)을 같이 넘김
589 appendMessage(msg.content, 'hinana', lastUserQuestion);
590 }
591 });
592 }
593 // 메시지 추가 + 공유/웹뷰 버튼 생성
594 function appendMessage(text, type, questionText = null) {
595 const div = document.createElement('div');
596 div.classList.add('message');
597 div.classList.add(type === 'user' ? 'user-message' : 'hinana-message');
598
599 const contentDiv = document.createElement('div');
600 if (type === 'hinana') {
601 contentDiv.classList.add('ai-answer-content');
602 const isTruncated = isAdmin && text.length > TRUNCATE_LIMIT;
603 const displayText = isTruncated ? text.substring(0, TRUNCATE_LIMIT) : text;
604 contentDiv.innerHTML = DOMPurify.sanitize(marked.parse(displayText, { breaks: true }));
605
606 if (isTruncated) {
607 const note = document.createElement('p');
608 note.style.cssText = 'font-size:0.8rem; color:var(--text-secondary); margin-top:8px; margin-bottom:0;';
609 note.textContent = '... (답변이 길어 일부만 표시됩니다)';
610 contentDiv.appendChild(note);
611 }
612 } else {
613 contentDiv.textContent = text;
614 }
615 div.appendChild(contentDiv);
616
617 if (type === 'hinana' && questionText) {
618 div.dataset.question = questionText;
619 div.dataset.answer = text;
620 const btnArea = document.createElement('div');
621 btnArea.className = 'mt-2 text-end d-flex justify-content-end gap-2';
622
623 const shareBtn = document.createElement('button');
624 shareBtn.className = 'btn btn-sm btn-link text-decoration-none p-0';
625 shareBtn.style.cssText = 'font-size:0.75rem; color:var(--text-secondary);';
626 shareBtn.innerHTML = '<i class="bi bi-discord"></i> Share';
627 shareBtn.onclick = () => shareToDiscord(questionText, text);
628 btnArea.appendChild(shareBtn);
629
630 if (isAdmin) {
631 const isTruncated = text.length > TRUNCATE_LIMIT;
632 const viewBtn = document.createElement('button');
633 viewBtn.className = 'btn btn-sm btn-link text-decoration-none p-0';
634 viewBtn.style.cssText = 'font-size:0.75rem; color:var(--accent-color);';
635 viewBtn.innerHTML = isTruncated
636 ? '<i class="bi bi-box-arrow-up-right"></i> 웹 뷰어에서 계속 보기'
637 : '<i class="bi bi-box-arrow-up-right"></i> 웹 뷰어에서 보기';
638 viewBtn.onclick = () => createAndOpenView(questionText, text, viewBtn);
639 btnArea.appendChild(viewBtn);
640 }
641
642 div.appendChild(btnArea);
643 }
644
645 chatWindow.appendChild(div);
646 chatWindow.scrollTop = chatWindow.scrollHeight;
647 }
648
649 async function createAndOpenView(question, answer, btn) {
650 btn.disabled = true;
651 const origHtml = btn.innerHTML;
652 btn.innerHTML = '생성 중...';
653
654 // iOS Safari는 async/await 이후 window.open()을 팝업 차단함.
655 // 사용자 제스처 컨텍스트 안에서 먼저 창을 열고, 이후 URL을 설정한다.
656 const newWin = window.open('', '_blank');
657
658 try {
659 const res = await fetch('/hinana/ai/create-view', {
660 method: 'POST',
661 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
662 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
663 });
664 const data = await res.json();
665 if (data.success) {
666 if (newWin) {
667 newWin.location.href = data.viewUrl;
668 } else {
669 window.location.href = data.viewUrl;
670 }
671 } else {
672 if (newWin) newWin.close();
673 await showAlert('웹 뷰 생성 실패: ' + (data.message || ''));
674 }
675 } catch (e) {
676 if (newWin) newWin.close();
677 await showAlert('오류가 발생했습니다.');
678 } finally {
679 btn.disabled = false;
680 btn.innerHTML = origHtml;
681 }
682 }
683
684 // [추가] 디스코드 공유 함수
685 async function shareToDiscord(question, answer) {
686 if (!await showConfirm('이 대화를 디스코드 채널에 공유할까요?')) return;
687
688 try {
689 const res = await fetch('/share-discord', {
690 method: 'POST',
691 headers: {
692 'Content-Type': 'application/json',
693 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
694 },
695 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
696 });
697
698 if (!res.ok) {
699 const txt = await res.text().catch(() => '');
700 console.error('share-discord error:', res.status, txt);
701 await showAlert('공유 실패: 서버 오류 (' + res.status + ')');
702 return;
703 }
704
705 const data = await res.json();
706 await showAlert(data.message || '공유되었습니다.');
707 } catch (e) {
708 console.error(e);
709 await showAlert('공유 실패: 서버 연결 오류');
710 }
711 }
712
713 async function saveNote(notePayload) {
714 if (!notePayload) return;
715
716 selectionNoteButton.disabled = true;
717 fullNoteButton.disabled = true;
718 try {
719 const res = await fetch('/hinana/ai/notes', {
720 method: 'POST',
721 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
722 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
723 });
724 const data = await res.json();
725 if (!res.ok || !data.success) {
726 await showAlert(data.message || '노트 저장에 실패했어요.');
727 return;
728 }
729
730 if (data.similarNote) {
731 await showAlert('비슷한 내용의 노트가 이미 있어요.');
732 }
733
734 if (data.requiresAppendConfirm) {
735 const shouldAppend = await showConfirm('기존 노트에 내용을 추가하시겠어요?');
736 if (!shouldAppend) {
737 hideSelectionNoteButton();
738 return;
739 }
740
741 const appendRes = await fetch('/hinana/ai/notes', {
742 method: 'POST',
743 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
744 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
745 });
746 const appendData = await appendRes.json();
747 if (!appendRes.ok || !appendData.success) {
748 await showAlert(appendData.message || '노트 저장에 실패했어요.');
749 return;
750 }
751 }
752
753 hideSelectionNoteButton();
754 window.getSelection()?.removeAllRanges();
755 await showAlert('노트를 저장했어요.');
756 } catch (error) {
757 console.error(error);
758 await showAlert('노트 저장 중 오류가 발생했어요.');
759 } finally {
760 selectionNoteButton.disabled = false;
761 fullNoteButton.disabled = false;
762 }
763 }
764
765 function hideSelectionNoteButton() {
766 selectedNotePayload = null;
767 selectionNoteMenu.classList.remove('visible');
768 }
769
770 function normalizeVisibleText(value) {
771 return String(value || '').replace(/\s+/g, ' ').trim();
772 }
773
774 function markdownToVisibleText(markdown) {
775 const probe = document.createElement('div');
776 probe.innerHTML = DOMPurify.sanitize(marked.parse(markdown, { breaks: true }));
777 return normalizeVisibleText(probe.textContent || '');
778 }
779
780 function recoverMarkdownExcerpt(rawMarkdown, selectedText) {
781 const selected = normalizeVisibleText(selectedText);
782 if (!selected) return '';
783
784 const lines = String(rawMarkdown || '').replace(/\r\n?/g, '\n').split('\n');
785 const visibleLines = lines.map(function (line) {
786 return markdownToVisibleText(line);
787 });
788
789 for (let start = 0; start < lines.length; start += 1) {
790 let visible = '';
791 for (let end = start; end < lines.length; end += 1) {
792 visible = normalizeVisibleText(visible + ' ' + visibleLines[end]);
793 if (visible === selected) {
794 return lines.slice(start, end + 1).join('\n').trim();
795 }
796 if (visible.length > selected.length && !visible.includes(selected)) {
797 break;
798 }
799 }
800 }
801
802 const lineIndex = visibleLines.findIndex(function (line) {
803 return line.includes(selected);
804 });
805 return lineIndex >= 0 ? lines[lineIndex].trim() : selectedText.replace(/\r\n?/g, '\n').trim();
806 }
807
808 function showSelectionNoteButton() {
809 const selection = window.getSelection();
810 if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
811 hideSelectionNoteButton();
812 return;
813 }
814
815 const selectedText = selection.toString().replace(/\r\n?/g, '\n').trim();
816 if (!selectedText) {
817 hideSelectionNoteButton();
818 return;
819 }
820
821 const range = selection.getRangeAt(0);
822 const commonNode = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
823 ? range.commonAncestorContainer
824 : range.commonAncestorContainer.parentElement;
825 const answerContent = commonNode && commonNode.closest
826 ? commonNode.closest('.ai-answer-content')
827 : null;
828 const message = answerContent ? answerContent.closest('.hinana-message') : null;
829 const question = message ? message.dataset.question : '';
830 if (!answerContent || !question || !chatWindow.contains(answerContent)) {
831 hideSelectionNoteButton();
832 return;
833 }
834
835 const rect = range.getBoundingClientRect();
836 if (!rect.width && !rect.height) {
837 hideSelectionNoteButton();
838 return;
839 }
840
841 const excerpt = recoverMarkdownExcerpt(message.dataset.answer, selectedText);
842 selectedNotePayload = { question, excerpt, fullAnswer: message.dataset.answer };
843 if (!window.matchMedia('(max-width: 960px)').matches) {
844 selectionNoteMenu.style.left = Math.min(window.innerWidth - 260, Math.max(12, rect.left + (rect.width / 2) - 120)) + 'px';
845 selectionNoteMenu.style.top = Math.max(12, rect.top - 48) + 'px';
846 }
847 selectionNoteMenu.classList.add('visible');
848 }
849
850 function showLoadingBubble() {
851 const bubble = document.createElement('div');
852 bubble.className = 'loading-bubble';
853 bubble.id = 'loading-bubble';
854 bubble.innerHTML = '히나나가 열심히 생각하고 답변을 준비중이에요 <span class="loading-dots"><span></span><span></span><span></span></span>';
855 chatWindow.appendChild(bubble);
856 chatWindow.scrollTop = chatWindow.scrollHeight;
857 }
858 function removeLoadingBubble() {
859 const bubble = document.getElementById('loading-bubble');
860 if (bubble) bubble.remove();
861 }
862 function updateLoadingBubble(html) {
863 const bubble = document.getElementById('loading-bubble');
864 if (bubble) bubble.innerHTML = html;
865 }
866
867 let pendingJobId = null;
868 let pendingJobText = null;
869 let pollTimer = null;
870
871 async function pollResult() {
872 if (!pendingJobId) return;
873 try {
874 const res = await fetch('/chat-gpt/result/' + pendingJobId);
875 if (!res.ok) return;
876 const data = await res.json();
877
878 if (data.status === 'done') {
879 clearInterval(pollTimer);
880 pollTimer = null;
881 const text = pendingJobText;
882 pendingJobId = null;
883 pendingJobText = null;
884 removeLoadingBubble();
885 if (data.response) {
886 appendMessage(data.response, 'hinana', text);
887 addToShelf(text, data.response);
888 } else {
889 appendMessage('오류: ' + (data.error || 'Unknown'), 'hinana');
890 }
891 } else if (data.status === 'error') {
892 clearInterval(pollTimer);
893 pollTimer = null;
894 pendingJobId = null;
895 pendingJobText = null;
896 removeLoadingBubble();
897 appendMessage('오류: ' + (data.error || 'Unknown'), 'hinana');
898 }
899 // pending이면 계속 폴링
900 } catch (e) {
901 console.error('poll error:', e);
902 }
903 }
904
905 chatForm.addEventListener('submit', async function (e) {
906 e.preventDefault();
907 const text = messageInput.value.trim();
908 if (!text || pendingJobId) return;
909
910 appendMessage(text, 'user');
911 messageInput.value = '';
912 messageInput.style.height = 'auto';
913 showLoadingBubble();
914
915 try {
916 const res = await fetch('/chat-gpt/submit', {
917 method: 'POST',
918 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
919 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
920 });
921
922 if (!res.ok) {
923 removeLoadingBubble();
924 appendMessage('오류: 전송에 실패했어요', 'hinana');
925 return;
926 }
927
928 const data = await res.json();
929
930 // 즉시 응답(기능 비활성화 등)이 있으면 그대로 표시
931 if (data.response) {
932 removeLoadingBubble();
933 appendMessage(data.response, 'hinana', text);
934 return;
935 }
936
937 pendingJobId = data.jobId;
938 pendingJobText = text;
939
940 // 2초마다 폴링 (화면이 보일 때만)
941 pollTimer = setInterval(() => {
942 if (document.visibilityState === 'visible') pollResult();
943 }, 2000);
944
945 // 즉시 첫 번째 폴링
946 pollResult();
947
948 } catch (err) {
949 console.error(err);
950 removeLoadingBubble();
951 appendMessage('서버 연결 실패', 'hinana');
952 }
953 });
954
955 // 화면 복귀 시 즉시 폴링 재개
956 document.addEventListener('visibilitychange', function () {
957 if (document.visibilityState === 'visible' && pendingJobId) {
958 updateLoadingBubble('답변을 가져오는 중이에요 <span class="loading-dots"><span></span><span></span><span></span></span>');
959 pollResult();
960 }
961 });
962
963 document.getElementById('reset-session-form').addEventListener('submit', function (e) {
964 e.preventDefault();
965 var form = this;
966 showConfirm('대화 내용을 초기화할까요?').then(function(ok) {
967 if (ok) form.submit();
968 });
969 });
970
971 if (document.body.classList.contains('dark-mode')) {
972 const sign = document.getElementById('fumika_sign');
973 if(sign) sign.style.mixBlendMode = 'screen';
974 }
975
976 // 사이드바 실시간 업데이트
977 function addToShelf(question, answer) {
978 const shelfCol = document.querySelector('.shelf-column');
979
980 // 빈 상태 메시지 제거
981 const empty = shelfCol.querySelector('.shelf-empty');
982 if (empty) empty.remove();
983
984 // shelf-history가 없으면 생성
985 let shelfHistory = shelfCol.querySelector('.shelf-history');
986 if (!shelfHistory) {
987 shelfHistory = document.createElement('div');
988 shelfHistory.className = 'shelf-history custom-scrollbar';
989 const footer = shelfCol.querySelector('.shelf-footer');
990 shelfCol.insertBefore(shelfHistory, footer);
991 }
992
993 // 현재 항목 수 기준으로 새 idx 계산
994 const existingItems = shelfHistory.querySelectorAll('.shelf-item');
995 const newIdx = existingItems.length;
996
997 const q = question.replace(/\n/g, ' ');
998 const a = answer.replace(/[#*`>]/g, '').replace(/\n/g, ' ');
999
1000 const item = document.createElement('div');
1001 item.className = 'shelf-item';
1002 item.dataset.idx = String(newIdx);
1003 item.innerHTML =
1004 '<div class="shelf-item-q">❓ ' + (q.length > 60 ? q.substring(0, 60) + '…' : q) + '</div>' +
1005 '<div class="shelf-item-a">' + (a.length > 80 ? a.substring(0, 80) + '…' : a) + '</div>';
1006
1007 item.addEventListener('click', function () {
1008 scrollToMessage(parseInt(this.dataset.idx));
1009 });
1010
1011 shelfHistory.appendChild(item);
1012 shelfHistory.scrollTop = shelfHistory.scrollHeight;
1013 }
1014
1015 // 모바일 드로어 토글
1016 const shelfCol = document.querySelector('.shelf-column');
1017 const shelfOverlay = document.getElementById('shelf-overlay');
1018 const btnHistoryToggle = document.getElementById('btn-history-toggle');
1019 function openDrawer() { shelfCol.classList.add('drawer-open'); shelfOverlay.classList.add('open'); }
1020 function closeDrawer() { shelfCol.classList.remove('drawer-open'); shelfOverlay.classList.remove('open'); }
1021 if (btnHistoryToggle) btnHistoryToggle.addEventListener('click', openDrawer);
1022 if (shelfOverlay) shelfOverlay.addEventListener('click', closeDrawer);
1023
1024 // 대화 기록 패널 클릭 → 해당 메시지로 스크롤
1025 function scrollToMessage(idx) {
1026 const messages = chatWindow.querySelectorAll('.message.user-message');
1027 if (messages[idx]) {
1028 messages[idx].scrollIntoView({ behavior: 'smooth', block: 'center' });
1029 }
1030 }
1031 document.querySelectorAll('.shelf-item').forEach(function(el) {
1032 el.addEventListener('click', function() {
1033 scrollToMessage(parseInt(this.dataset.idx));
1034 });
1035 });
1036
1037 selectionNoteButton.addEventListener('click', function () {
1038 if (!selectedNotePayload) return;
1039 saveNote({ question: selectedNotePayload.question, excerpt: selectedNotePayload.excerpt });
1040 });
1041 fullNoteButton.addEventListener('click', function () {
1042 if (!selectedNotePayload) return;
1043 saveNote({ question: selectedNotePayload.question, excerpt: selectedNotePayload.fullAnswer });
1044 });
1045 chatWindow.addEventListener('mouseup', function () {
1046 window.setTimeout(showSelectionNoteButton, 0);
1047 });
1048 chatWindow.addEventListener('touchend', function () {
1049 window.setTimeout(showSelectionNoteButton, 220);
1050 });
1051 selectionNoteMenu.addEventListener('pointerdown', function (event) {
1052 event.stopPropagation();
1053 });
1054 document.addEventListener('mousedown', function (event) {
1055 if (!selectionNoteMenu.contains(event.target)) hideSelectionNoteButton();
1056 });
1057 window.addEventListener('resize', hideSelectionNoteButton);
1058 chatWindow.addEventListener('scroll', hideSelectionNoteButton);
1059 </script>
1060 <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
1061 </body>
1062 </html>
1063