Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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