Public Source Viewer

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

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

Redacted View
view/hinana/aiView.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 <meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#000000' : '#ffffff' %>">
8 <meta property="og:image" content="/image/2.png" />
9 <meta property="og:title" content="히나나 AI 답변" />
10 <meta property="og:description" content="<%= answer.substring(0, 100).replace(/[\r\n]/g, ' ') %>..." />
11 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
12 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
13 <link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet" type="text/css">
14 <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
15 <link rel="stylesheet" href="/css/hinana.css" type="text/css">
16 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
17 <script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
18 <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
19 <script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.2/dist/purify.min.js"></script>
20 <script src="/js/popup.js"></script>
21 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
22 <title>히나나 AI 답변</title>
23 <style>
24 :root {
25 --font-family: 'Noto Sans KR', sans-serif;
26 --bg-main: #ffffff; --bg-secondary: #f7f9f9; --bg-tertiary: #eff3f4;
27 --text-primary: #0f1419; --text-secondary: #536471;
28 --accent-color: #1d9bf0; --danger-color: #f4212e; --border-color: #eff3f4;
29 --chat-bg-ai: #ffffff; --chat-bg-user: #1d9bf0;
30 --shadow-sm: 0 1px 3px rgba(0,0,0,0.08); --shadow-md: 0 4px 12px rgba(0,0,0,0.10);
31 }
32 body.dark-mode {
33 --bg-main: #000000; --bg-secondary: #16181c; --bg-tertiary: #202327;
34 --text-primary: #e7e9ea; --text-secondary: #71767b;
35 --border-color: #2f3336; --accent-color: #1d9bf0; --danger-color: #f4212e;
36 --chat-bg-ai: #16181c; --chat-bg-user: #1d9bf0;
37 --shadow-sm: 0 1px 3px rgba(255,255,255,0.04); --shadow-md: 0 4px 12px rgba(0,0,0,0.6);
38 }
39
40 html, body { height: 100%; margin: 0; font-family: var(--font-family); background-color: var(--bg-main); color: var(--text-primary); overflow: hidden; }
41 a { text-decoration: none; color: inherit; }
42
43 .global-header {
44 height: 60px;
45 background-color: rgba(255,255,255,0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
46 border-bottom: 1px solid var(--border-color);
47 display: flex; align-items: center; justify-content: space-between; padding: 0 20px; z-index: 100; position: sticky; top: 0;
48 }
49 body.dark-mode .global-header { background-color: rgba(0,0,0,0.85); }
50 .header-brand { display: flex; align-items: center; gap: 15px; }
51 .header-logo { height: 28px; width: auto; }
52 body:not(.dark-mode) .logo-night { display: none; }
53 body.dark-mode .logo-day { display: none; }
54 .header-nav {
55 position: absolute; left: 50%; transform: translateX(-50%);
56 display: flex; gap: 20px; align-items: center; z-index: 5;
57 }
58 .nav-link { font-weight: 600; font-size: 0.95rem; color: var(--text-secondary); }
59 .nav-link:hover { color: var(--accent-color); }
60 .nav-link.active { color: var(--text-primary); }
61 .header-controls { display: flex; align-items: center; gap: 10px; z-index: 10; position: relative; }
62 .icon-btn { border: none; background: transparent; color: var(--text-secondary); font-size: 1.1rem; cursor: pointer; padding: 4px; }
63 .icon-btn:hover { color: var(--text-primary); }
64
65 .layout-container { display: flex; height: calc(100vh - 60px); }
66
67 .shelf-column {
68 width: 300px; min-width: 300px; background-color: var(--bg-secondary);
69 border-right: 1px solid var(--border-color); display: flex; flex-direction: column;
70 justify-content: center; align-items: center; gap: 12px;
71 }
72 .shelf-column .shelf-label {
73 font-size: 0.75rem; font-weight: 700; text-transform: uppercase;
74 color: var(--text-secondary); letter-spacing: 0.08em;
75 }
76
77 .content-column {
78 flex: 1; display: flex; flex-direction: column;
79 background-color: var(--bg-main); position: relative; overflow: hidden;
80 }
81 .content-scroll-area {
82 flex: 1; overflow-y: auto; padding: 40px;
83 display: flex; justify-content: center;
84 }
85 .content-card {
86 background-color: var(--bg-secondary); border-radius: 12px;
87 box-shadow: var(--shadow-sm); border: 1px solid var(--border-color);
88 overflow: hidden; width: 100%; max-width: 760px; height: fit-content; margin-bottom: 40px;
89 }
90
91 /* 카드 헤더 */
92 .qa-header {
93 padding: 20px 25px; background-color: var(--bg-tertiary);
94 border-bottom: 1px solid var(--border-color);
95 display: flex; align-items: center; justify-content: space-between;
96 }
97 .qa-header-title {
98 display: flex; align-items: center; gap: 8px;
99 font-size: 0.85rem; font-weight: 700; color: var(--accent-color);
100 }
101 .qa-header-date { font-size: 0.78rem; color: var(--text-secondary); }
102
103 /* Q&A 버블 영역 */
104 .qa-body { padding: 28px; display: flex; flex-direction: column; gap: 20px; }
105
106 .bubble { border-radius: 16px; padding: 16px 20px; line-height: 1.7; word-break: break-word; font-size: 0.97rem; }
107 .bubble-label { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; margin-bottom: 8px; }
108
109 .bubble-q-wrap { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
110 .bubble-outer-label { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; color: var(--text-secondary); }
111 .bubble-q {
112 background-color: var(--chat-bg-user); color: #fff;
113 border-bottom-right-radius: 4px; max-width: 88%; white-space: pre-wrap;
114 }
115
116 .bubble-a {
117 background-color: var(--chat-bg-ai); border: 1px solid var(--border-color);
118 border-bottom-left-radius: 4px;
119 }
120 body.dark-mode .bubble-a { color: var(--text-primary); }
121 .bubble-a .bubble-label { color: var(--accent-color); }
122 .bubble-a-content p:last-child { margin-bottom: 0; }
123 .bubble-a-content p { margin-bottom: 0.6em; }
124 .bubble-a-content ul, .bubble-a-content ol { padding-left: 1.4em; margin-bottom: 0.6em; }
125 .bubble-a-content code { background-color: var(--bg-tertiary); padding: 1px 5px; border-radius: 4px; font-size: 0.9em; }
126 .bubble-a-content pre { background-color: var(--bg-tertiary); padding: 12px; border-radius: 8px; overflow-x: auto; }
127 .bubble-a-content pre code { background: none; padding: 0; }
128 .bubble-a-content h1, .bubble-a-content h2, .bubble-a-content h3,
129 .bubble-a-content h4, .bubble-a-content h5, .bubble-a-content h6 {
130 font-size: 1em; font-weight: 700; margin: 0.8em 0 0.4em;
131 }
132
133 /* 카드 푸터 */
134 .qa-footer {
135 padding: 14px 25px; background-color: var(--bg-main);
136 border-top: 1px solid var(--border-color);
137 display: flex; align-items: center; justify-content: space-between;
138 }
139 .qa-footer-brand { font-size: 0.78rem; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
140 .qa-footer-link { font-size: 0.78rem; color: var(--accent-color); display: flex; align-items: center; gap: 5px; }
141 .qa-footer-link:hover { text-decoration: underline; }
142
143 .selection-note-menu {
144 position: fixed; z-index: 21000; display: none; align-items: center; gap: 5px;
145 border: 1px solid var(--border-color); border-radius: 999px; padding: 5px;
146 background-color: var(--bg-main); box-shadow: var(--shadow-md);
147 }
148 .selection-note-menu.visible { display: flex; }
149 .selection-note-button {
150 border: none; border-radius: 999px; padding: 7px 11px; background: transparent;
151 color: var(--text-primary); font-size: 0.78rem; font-weight: 600;
152 }
153 .selection-note-button:hover { color: var(--accent-color); border-color: var(--accent-color); }
154
155 /* 반응형 */
156 @media (max-width: 960px) {
157 .selection-note-menu {
158 left: 12px !important; right: 12px !important; bottom: 16px !important; top: auto !important;
159 border-radius: 14px; justify-content: stretch; gap: 4px;
160 }
161 .selection-note-button { flex: 1; padding: 11px 10px; }
162 html, body { overflow: auto !important; height: auto !important; }
163 .layout-container { flex-direction: column; height: auto !important; }
164 .shelf-column { display: none; }
165 .content-column { width: 100%; height: auto !important; overflow: visible; }
166 .content-scroll-area { padding: 16px; height: auto !important; overflow: visible; }
167 .global-header { flex-wrap: wrap; height: auto; padding: 10px 20px; }
168 .header-nav {
169 position: static; transform: none; width: 100%;
170 justify-content: center; margin-top: 10px; padding-top: 10px;
171 border-top: 1px solid rgba(0,0,0,0.05); order: 3;
172 }
173 .header-brand { flex: 1; order: 1; }
174 .header-controls { flex: auto; justify-content: flex-end; background-color: transparent; order: 2; }
175 .bubble-q { max-width: 96%; }
176 .qa-body { padding: 16px; }
177 }
178 </style>
179 </head>
180
181 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
182
183 <header class="global-header">
184 <div class="header-brand">
185 <a href="/hinana/index">
186 <img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'archive1.png' : 'archive.png' %>"
187 alt="Hinana Archive" class="header-logo">
188 </a>
189 </div>
190
191 <nav class="header-nav">
192 <a href="/hinana/index" class="nav-link">Archive</a>
193 <a href="/hinana/info" class="nav-link">Info</a>
194 <a href="/hinana/blog" class="nav-link">Blog</a>
195 <a href="/hinana/lounge" class="nav-link">Lounge</a>
196 <span class="nav-divider">|</span>
197 <a href="/hinana/ai" class="nav-link active"><i class="bi bi-stars"></i> AI</a>
198 </nav>
199
200 <div class="header-controls">
201 <form action="/toggle-theme" method="POST" class="d-inline">
202 <input type="hidden" name="redirect" value="/hinana/ai/view/<%= typeof viewId !== 'undefined' ? viewId : '' %>">
203 <button type="submit" class="icon-btn" title="테마 변경">
204 <i class="bi <%= (typeof theme !== 'undefined' && theme==='dark') ? 'bi-moon-stars-fill' : 'bi-sun-fill' %>"></i>
205 </button>
206 </form>
207 </div>
208 </header>
209
210 <div class="layout-container">
211
212 <div class="shelf-column">
213 <span class="shelf-label"><i class="bi bi-stars me-1"></i> 히나나 AI</span>
214 <a href="/hinana/ai" class="btn btn-outline-secondary btn-sm">
215 <i class="bi bi-chat-dots"></i> AI 채팅으로
216 </a>
217 </div>
218
219 <div class="content-column">
220 <div class="content-scroll-area custom-scrollbar">
221 <div class="content-card">
222
223 <div class="qa-header">
224 <div class="qa-header-title">
225 <i class="bi bi-stars"></i> 히나나 AI 답변
226 </div>
227 <span class="qa-header-date">
228 <%= new Date(createdAt).toLocaleDateString('ko-KR', { year:'numeric', month:'long', day:'numeric' }) %>
229 </span>
230 </div>
231
232 <div class="qa-body">
233 <div class="bubble-q-wrap">
234 <span class="bubble-outer-label">질문</span>
235 <div class="bubble bubble-q"><%= question %></div>
236 </div>
237 <div class="bubble bubble-a">
238 <div class="bubble-label"><i class="bi bi-stars"></i> 히나나의 답변</div>
239 <div class="bubble-a-content" id="answer-content"></div>
240 </div>
241 </div>
242
243 <div class="qa-footer">
244 <span class="qa-footer-brand">
245 <i class="bi bi-stars"></i> hinana.moe AI Chat &nbsp;·&nbsp; Powered by gpt-5.5
246 </span>
247 <div style="display:flex; align-items:center; gap:14px;">
248 <a href="/hinana/ai/notes" class="qa-footer-link">
249 <i class="bi bi-journal-text"></i> AI 노트 보기
250 </a>
251 <a href="/hinana/ai" class="qa-footer-link">
252 <i class="bi bi-chat-dots"></i> 직접 물어보기
253 </a>
254 </div>
255 </div>
256
257 </div>
258 </div>
259 </div>
260
261 <div class="selection-note-menu" id="selection-note-menu">
262 <button type="button" class="selection-note-button" id="selection-note-button">
263 <i class="bi bi-journal-plus"></i> 노트 저장
264 </button>
265 <button type="button" class="selection-note-button" id="full-note-button">
266 <i class="bi bi-journal-text"></i> 전체를 노트로 저장
267 </button>
268 </div>
269
270 </div>
271
272 <script>
273 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
274 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
275 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
276 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
277 const answerContent = document.getElementById('answer-content');
278 const selectionNoteMenu = document.getElementById('selection-note-menu');
279 const selectionNoteButton = document.getElementById('selection-note-button');
280 const fullNoteButton = document.getElementById('full-note-button');
281 let selectedNotePayload = null;
282 const html = DOMPurify.sanitize(marked.parse(raw, { breaks: true }));
283 answerContent.innerHTML = html;
284
285 async function saveNote(notePayload) {
286 if (!notePayload) return;
287
288 selectionNoteButton.disabled = true;
289 fullNoteButton.disabled = true;
290 try {
291 const res = await fetch('/hinana/ai/notes', {
292 method: 'POST',
293 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
294 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
295 });
296 const data = await res.json();
297 if (!res.ok || !data.success) {
298 await showAlert(data.message || '노트 저장에 실패했어요.');
299 return;
300 }
301
302 if (data.requiresAppendConfirm) {
303 const shouldAppend = await showConfirm('기존 노트에 내용을 추가하시겠어요?');
304 if (!shouldAppend) {
305 hideSelectionNoteButton();
306 return;
307 }
308
309 const appendRes = await fetch('/hinana/ai/notes', {
310 method: 'POST',
311 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
312 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
313 });
314 const appendData = await appendRes.json();
315 if (!appendRes.ok || !appendData.success) {
316 await showAlert(appendData.message || '노트 저장에 실패했어요.');
317 return;
318 }
319 }
320
321 hideSelectionNoteButton();
322 window.getSelection()?.removeAllRanges();
323 await showAlert('노트를 저장했어요.');
324 } catch (error) {
325 console.error(error);
326 await showAlert('노트 저장 중 오류가 발생했어요.');
327 } finally {
328 selectionNoteButton.disabled = false;
329 fullNoteButton.disabled = false;
330 }
331 }
332
333 function hideSelectionNoteButton() {
334 selectedNotePayload = null;
335 selectionNoteMenu.classList.remove('visible');
336 }
337
338 function normalizeVisibleText(value) {
339 return String(value || '').replace(/\s+/g, ' ').trim();
340 }
341
342 function markdownToVisibleText(markdown) {
343 const probe = document.createElement('div');
344 probe.innerHTML = DOMPurify.sanitize(marked.parse(markdown, { breaks: true }));
345 return normalizeVisibleText(probe.textContent || '');
346 }
347
348 function recoverMarkdownExcerpt(rawMarkdown, selectedText) {
349 const selected = normalizeVisibleText(selectedText);
350 if (!selected) return '';
351
352 const lines = String(rawMarkdown || '').replace(/\r\n?/g, '\n').split('\n');
353 const visibleLines = lines.map(function (line) {
354 return markdownToVisibleText(line);
355 });
356
357 for (let start = 0; start < lines.length; start += 1) {
358 let visible = '';
359 for (let end = start; end < lines.length; end += 1) {
360 visible = normalizeVisibleText(visible + ' ' + visibleLines[end]);
361 if (visible === selected) {
362 return lines.slice(start, end + 1).join('\n').trim();
363 }
364 if (visible.length > selected.length && !visible.includes(selected)) {
365 break;
366 }
367 }
368 }
369
370 const lineIndex = visibleLines.findIndex(function (line) {
371 return line.includes(selected);
372 });
373 return lineIndex >= 0 ? lines[lineIndex].trim() : selectedText.replace(/\r\n?/g, '\n').trim();
374 }
375
376 function showSelectionNoteButton() {
377 const selection = window.getSelection();
378 if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
379 hideSelectionNoteButton();
380 return;
381 }
382
383 const selectedText = selection.toString().replace(/\r\n?/g, '\n').trim();
384 if (!selectedText) {
385 hideSelectionNoteButton();
386 return;
387 }
388
389 const range = selection.getRangeAt(0);
390 const commonNode = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
391 ? range.commonAncestorContainer
392 : range.commonAncestorContainer.parentElement;
393 if (!commonNode || !answerContent.contains(commonNode)) {
394 hideSelectionNoteButton();
395 return;
396 }
397
398 const rect = range.getBoundingClientRect();
399 if (!rect.width && !rect.height) {
400 hideSelectionNoteButton();
401 return;
402 }
403
404 const excerpt = recoverMarkdownExcerpt(raw, selectedText);
405 selectedNotePayload = { question, excerpt, fullAnswer: raw, sourceViewId };
406 if (!window.matchMedia('(max-width: 960px)').matches) {
407 selectionNoteMenu.style.left = Math.min(window.innerWidth - 260, Math.max(12, rect.left + (rect.width / 2) - 120)) + 'px';
408 selectionNoteMenu.style.top = Math.max(12, rect.top - 48) + 'px';
409 }
410 selectionNoteMenu.classList.add('visible');
411 }
412
413 selectionNoteButton.addEventListener('click', function () {
414 if (!selectedNotePayload) return;
415 saveNote({ question, excerpt: selectedNotePayload.excerpt, sourceViewId });
416 });
417 fullNoteButton.addEventListener('click', function () {
418 if (!selectedNotePayload) return;
419 saveNote({ question, excerpt: selectedNotePayload.fullAnswer, sourceViewId });
420 });
421 answerContent.addEventListener('mouseup', function () {
422 window.setTimeout(showSelectionNoteButton, 0);
423 });
424 answerContent.addEventListener('touchend', function () {
425 window.setTimeout(showSelectionNoteButton, 220);
426 });
427 selectionNoteMenu.addEventListener('pointerdown', function (event) {
428 event.stopPropagation();
429 });
430 document.addEventListener('mousedown', function (event) {
431 if (!selectionNoteMenu.contains(event.target)) hideSelectionNoteButton();
432 });
433 window.addEventListener('resize', hideSelectionNoteButton);
434 document.querySelector('.content-scroll-area').addEventListener('scroll', hideSelectionNoteButton);
435 </script>
436 </body>
437 </html>
438