@open330/kiwimu 0.4.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/kiwimu +1 -1
- package/package.json +4 -1
- package/personas/namuwiki.json +6 -0
- package/src/build/renderer.ts +49 -2
- package/src/build/static/search.js +33 -2
- package/src/build/static/style.css +181 -44
- package/src/build/templates.ts +297 -167
- package/src/config.ts +35 -29
- package/src/demo/sample-data.ts +70 -0
- package/src/demo/setup.ts +31 -0
- package/src/expand/llm.ts +1 -1
- package/src/index.ts +208 -458
- package/src/ingest/docx.ts +0 -8
- package/src/ingest/legacy.ts +4 -4
- package/src/ingest/pdf.ts +1 -1
- package/src/ingest/pptx.ts +0 -1
- package/src/ingest/web.test.ts +41 -0
- package/src/ingest/web.ts +61 -62
- package/src/llm-client.ts +203 -126
- package/src/pipeline/chunker.test.ts +42 -0
- package/src/pipeline/chunker.ts +1 -48
- package/src/pipeline/llm-chunker.ts +133 -55
- package/src/server.ts +327 -0
- package/src/services/ingest.ts +100 -0
- package/src/store.test.ts +132 -0
- package/src/store.ts +102 -2
- package/src/pipeline/llm-linker.ts +0 -84
package/src/build/templates.ts
CHANGED
|
@@ -53,6 +53,9 @@ function base(opts: {
|
|
|
53
53
|
<meta charset="UTF-8">
|
|
54
54
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
55
55
|
<title>${escapeHtml(opts.title)}</title>
|
|
56
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
57
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
58
|
+
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
56
59
|
<link rel="stylesheet" href="/static/style.css">
|
|
57
60
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
|
58
61
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
|
@@ -60,6 +63,7 @@ function base(opts: {
|
|
|
60
63
|
</head>
|
|
61
64
|
<body>
|
|
62
65
|
<nav class="topbar">
|
|
66
|
+
<button class="topbar-menu-btn" aria-label="메뉴">☰</button>
|
|
63
67
|
<a href="/index.html" class="topbar-brand">
|
|
64
68
|
<img src="/static/logo.png" alt="Kiwi Mu" class="topbar-logo">
|
|
65
69
|
${escapeHtml(opts.wikiName)}
|
|
@@ -69,10 +73,13 @@ function base(opts: {
|
|
|
69
73
|
<div id="search-results" class="search-dropdown"></div>
|
|
70
74
|
</div>
|
|
71
75
|
<div class="topbar-links">
|
|
76
|
+
<a href="/wiki/random.html" style="color:#fff;text-decoration:none;font-size:13px;">🎲 임의</a>
|
|
77
|
+
<a href="/quiz.html" class="btn-graph">📝 퀴즈</a>
|
|
72
78
|
<a href="/graph.html" class="btn-graph">📊 그래프</a>
|
|
73
79
|
<a href="/admin" class="btn-graph">⚙️ 관리</a>
|
|
74
80
|
</div>
|
|
75
81
|
</nav>
|
|
82
|
+
<div class="sidebar-overlay"></div>
|
|
76
83
|
<div class="layout">
|
|
77
84
|
<aside class="sidebar">
|
|
78
85
|
${sidebarHtml(opts.sourcePages, opts.conceptPages, opts.activeSlug)}
|
|
@@ -83,6 +90,22 @@ function base(opts: {
|
|
|
83
90
|
</div>
|
|
84
91
|
<script src="/static/search.js"></script>
|
|
85
92
|
<script>
|
|
93
|
+
// Mobile hamburger menu
|
|
94
|
+
(function() {
|
|
95
|
+
const menuBtn = document.querySelector('.topbar-menu-btn');
|
|
96
|
+
const sidebar = document.querySelector('.sidebar');
|
|
97
|
+
const overlay = document.querySelector('.sidebar-overlay');
|
|
98
|
+
if (menuBtn && sidebar) {
|
|
99
|
+
menuBtn.addEventListener('click', () => {
|
|
100
|
+
sidebar.classList.toggle('open');
|
|
101
|
+
overlay?.classList.toggle('active');
|
|
102
|
+
});
|
|
103
|
+
overlay?.addEventListener('click', () => {
|
|
104
|
+
sidebar.classList.remove('open');
|
|
105
|
+
overlay.classList.remove('active');
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
86
109
|
// Sidebar tabs
|
|
87
110
|
document.querySelectorAll('.sidebar-tab').forEach(tab => {
|
|
88
111
|
tab.addEventListener('click', () => {
|
|
@@ -204,182 +227,29 @@ export function renderIndex(opts: {
|
|
|
204
227
|
</div>
|
|
205
228
|
|
|
206
229
|
<div class="index-grid">
|
|
207
|
-
<!-- Add document
|
|
230
|
+
<!-- Add document link -->
|
|
208
231
|
<section class="index-section add-section">
|
|
209
232
|
<h2>➕ 문서 추가</h2>
|
|
210
|
-
<
|
|
211
|
-
<button class="add-tab active" data-target="url-form">🔗 URL</button>
|
|
212
|
-
<button class="add-tab" data-target="file-form">📄 파일 업로드</button>
|
|
213
|
-
</div>
|
|
214
|
-
<form id="url-form" class="add-form add-panel active">
|
|
215
|
-
<input type="text" id="add-source" placeholder="URL을 입력하세요 (https://...)" autocomplete="off">
|
|
216
|
-
<button type="submit" id="add-btn">추가</button>
|
|
217
|
-
</form>
|
|
218
|
-
<form id="file-form" class="add-form add-panel" enctype="multipart/form-data">
|
|
219
|
-
<div class="file-drop" id="file-drop">
|
|
220
|
-
<input type="file" id="file-input" accept=".pdf,.docx,.doc,.pptx,.ppt,.key,.rtf" hidden>
|
|
221
|
-
<span class="file-drop-text">📁 파일을 드래그하거나 클릭하세요</span>
|
|
222
|
-
<span class="file-drop-hint">PDF, DOCX, PPTX, PPT, KEY, RTF</span>
|
|
223
|
-
</div>
|
|
224
|
-
<button type="submit" id="upload-btn" disabled>업로드</button>
|
|
225
|
-
</form>
|
|
226
|
-
<div id="add-status" class="add-status" style="display:none"></div>
|
|
227
|
-
</section>
|
|
228
|
-
|
|
229
|
-
<!-- Usage stats -->
|
|
230
|
-
<section class="index-section">
|
|
231
|
-
<div id="usage-stats" class="usage-stats"></div>
|
|
233
|
+
<p>문서를 추가하려면 <a href="/admin">관리 페이지</a>에서 문서를 추가하세요.</p>
|
|
232
234
|
</section>
|
|
233
235
|
|
|
234
236
|
<section class="index-section">
|
|
235
237
|
<h2>📖 원본 문서</h2>
|
|
236
|
-
<div class="page-cards">${sourceCards}</div>
|
|
238
|
+
<div class="page-cards">${sourceCards.length > 0 ? sourceCards : '<div class="empty-state">아직 원본 문서가 없습니다. URL이나 파일을 추가해보세요!</div>'}</div>
|
|
237
239
|
</section>
|
|
238
240
|
<section class="index-section">
|
|
239
241
|
<h2>📝 개념 문서</h2>
|
|
240
|
-
<div class="page-cards">${conceptCards}</div>
|
|
242
|
+
<div class="page-cards">${conceptCards.length > 0 ? conceptCards : '<div class="empty-state">아직 개념 문서가 없습니다. 원본 문서를 추가하면 자동으로 생성됩니다.</div>'}</div>
|
|
241
243
|
</section>
|
|
242
244
|
<section class="index-section">
|
|
243
245
|
<div class="quick-links">
|
|
246
|
+
<a href="/quiz.html" class="quick-link">📝 학습 퀴즈</a>
|
|
244
247
|
<a href="/graph.html" class="quick-link">📊 지식 그래프 보기</a>
|
|
245
248
|
</div>
|
|
246
249
|
</section>
|
|
247
250
|
</div>
|
|
248
|
-
</div
|
|
249
|
-
<script>
|
|
250
|
-
// Add tabs
|
|
251
|
-
document.querySelectorAll('.add-tab').forEach(tab => {
|
|
252
|
-
tab.addEventListener('click', () => {
|
|
253
|
-
document.querySelectorAll('.add-tab').forEach(t => t.classList.remove('active'));
|
|
254
|
-
document.querySelectorAll('.add-panel').forEach(p => p.classList.remove('active'));
|
|
255
|
-
tab.classList.add('active');
|
|
256
|
-
document.getElementById(tab.dataset.target).classList.add('active');
|
|
257
|
-
});
|
|
258
|
-
});
|
|
251
|
+
</div>`;
|
|
259
252
|
|
|
260
|
-
// File upload
|
|
261
|
-
const fileInput = document.getElementById('file-input');
|
|
262
|
-
const fileDrop = document.getElementById('file-drop');
|
|
263
|
-
const uploadBtn = document.getElementById('upload-btn');
|
|
264
|
-
|
|
265
|
-
fileInput.addEventListener('change', () => {
|
|
266
|
-
if (fileInput.files.length) {
|
|
267
|
-
fileDrop.querySelector('.file-drop-text').textContent = '📄 ' + fileInput.files[0].name;
|
|
268
|
-
uploadBtn.disabled = false;
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
fileDrop.addEventListener('dragover', (e) => { e.preventDefault(); fileDrop.classList.add('dragover'); });
|
|
273
|
-
fileDrop.addEventListener('dragleave', () => fileDrop.classList.remove('dragover'));
|
|
274
|
-
fileDrop.addEventListener('drop', (e) => {
|
|
275
|
-
e.preventDefault();
|
|
276
|
-
fileDrop.classList.remove('dragover');
|
|
277
|
-
fileInput.files = e.dataTransfer.files;
|
|
278
|
-
if (fileInput.files.length) {
|
|
279
|
-
fileDrop.querySelector('.file-drop-text').textContent = '📄 ' + fileInput.files[0].name;
|
|
280
|
-
uploadBtn.disabled = false;
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
fileDrop.addEventListener('click', () => fileInput.click());
|
|
284
|
-
|
|
285
|
-
document.getElementById('file-form').addEventListener('submit', async (e) => {
|
|
286
|
-
e.preventDefault();
|
|
287
|
-
if (!fileInput.files.length) return;
|
|
288
|
-
const status = document.getElementById('add-status');
|
|
289
|
-
uploadBtn.disabled = true;
|
|
290
|
-
uploadBtn.textContent = '처리 중...';
|
|
291
|
-
status.style.display = 'block';
|
|
292
|
-
status.className = 'add-status processing';
|
|
293
|
-
status.textContent = '⏳ 업로드 중...';
|
|
294
|
-
|
|
295
|
-
const formData = new FormData();
|
|
296
|
-
formData.append('file', fileInput.files[0]);
|
|
297
|
-
|
|
298
|
-
try {
|
|
299
|
-
const resp = await fetch('/api/upload', { method: 'POST', body: formData });
|
|
300
|
-
const data = await resp.json();
|
|
301
|
-
if (!resp.ok) { status.className = 'add-status error'; status.textContent = '❌ ' + data.error; uploadBtn.disabled = false; uploadBtn.textContent = '업로드'; return; }
|
|
302
|
-
const poll = setInterval(async () => {
|
|
303
|
-
const r = await fetch('/api/status'); const s = await r.json();
|
|
304
|
-
status.textContent = '⏳ ' + s.processingStatus;
|
|
305
|
-
if (!s.processing) { clearInterval(poll); status.className = 'add-status success'; status.textContent = '✅ 완료! 새로고침 중...'; setTimeout(() => location.reload(), 1500); }
|
|
306
|
-
}, 2000);
|
|
307
|
-
} catch (err) { status.className = 'add-status error'; status.textContent = '❌ 연결 실패'; uploadBtn.disabled = false; uploadBtn.textContent = '업로드'; }
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
// URL add form
|
|
311
|
-
document.getElementById('url-form').addEventListener('submit', async (e) => {
|
|
312
|
-
e.preventDefault();
|
|
313
|
-
const input = document.getElementById('add-source');
|
|
314
|
-
const btn = document.getElementById('add-btn');
|
|
315
|
-
const status = document.getElementById('add-status');
|
|
316
|
-
const source = input.value.trim();
|
|
317
|
-
if (!source) return;
|
|
318
|
-
|
|
319
|
-
btn.disabled = true;
|
|
320
|
-
btn.textContent = '처리 중...';
|
|
321
|
-
status.style.display = 'block';
|
|
322
|
-
status.className = 'add-status processing';
|
|
323
|
-
status.textContent = '⏳ 시작 중...';
|
|
324
|
-
|
|
325
|
-
try {
|
|
326
|
-
const resp = await fetch('/api/add', {
|
|
327
|
-
method: 'POST',
|
|
328
|
-
headers: { 'Content-Type': 'application/json' },
|
|
329
|
-
body: JSON.stringify({ source }),
|
|
330
|
-
});
|
|
331
|
-
const data = await resp.json();
|
|
332
|
-
if (!resp.ok) {
|
|
333
|
-
status.className = 'add-status error';
|
|
334
|
-
status.textContent = '❌ ' + data.error;
|
|
335
|
-
btn.disabled = false;
|
|
336
|
-
btn.textContent = '추가';
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Poll for completion
|
|
341
|
-
const poll = setInterval(async () => {
|
|
342
|
-
const r = await fetch('/api/status');
|
|
343
|
-
const s = await r.json();
|
|
344
|
-
status.textContent = '⏳ ' + s.processingStatus;
|
|
345
|
-
if (!s.processing) {
|
|
346
|
-
clearInterval(poll);
|
|
347
|
-
status.className = 'add-status success';
|
|
348
|
-
status.textContent = '✅ 완료! 페이지를 새로고침합니다...';
|
|
349
|
-
btn.disabled = false;
|
|
350
|
-
btn.textContent = '추가';
|
|
351
|
-
input.value = '';
|
|
352
|
-
setTimeout(() => location.reload(), 1500);
|
|
353
|
-
}
|
|
354
|
-
}, 2000);
|
|
355
|
-
} catch (err) {
|
|
356
|
-
status.className = 'add-status error';
|
|
357
|
-
status.textContent = '❌ 연결 실패';
|
|
358
|
-
btn.disabled = false;
|
|
359
|
-
btn.textContent = '추가';
|
|
360
|
-
}
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
// Load usage stats
|
|
364
|
-
(async () => {
|
|
365
|
-
try {
|
|
366
|
-
const resp = await fetch('/api/status');
|
|
367
|
-
const data = await resp.json();
|
|
368
|
-
const u = data.usage;
|
|
369
|
-
if (u && u.totalTokens > 0) {
|
|
370
|
-
document.getElementById('usage-stats').innerHTML =
|
|
371
|
-
'<h2>📊 LLM 사용량</h2>' +
|
|
372
|
-
'<div class="stats-grid">' +
|
|
373
|
-
'<div class="stat-card"><div class="stat-value">' + u.totalCalls + '</div><div class="stat-label">API 호출</div></div>' +
|
|
374
|
-
'<div class="stat-card"><div class="stat-value">' + u.promptTokens.toLocaleString() + '</div><div class="stat-label">입력 토큰</div></div>' +
|
|
375
|
-
'<div class="stat-card"><div class="stat-value">' + u.completionTokens.toLocaleString() + '</div><div class="stat-label">출력 토큰</div></div>' +
|
|
376
|
-
'<div class="stat-card"><div class="stat-value">' + u.totalTokens.toLocaleString() + '</div><div class="stat-label">총 토큰</div></div>' +
|
|
377
|
-
'<div class="stat-card accent"><div class="stat-value">$' + u.totalCost.toFixed(4) + '</div><div class="stat-label">예상 비용</div></div>' +
|
|
378
|
-
'</div>';
|
|
379
|
-
}
|
|
380
|
-
} catch {}
|
|
381
|
-
})();
|
|
382
|
-
</script>`;
|
|
383
253
|
|
|
384
254
|
return base({
|
|
385
255
|
title: `${opts.wikiName} - 대문`,
|
|
@@ -417,6 +287,260 @@ export function renderGraph(opts: {
|
|
|
417
287
|
});
|
|
418
288
|
}
|
|
419
289
|
|
|
290
|
+
export function renderQuizPage(opts: {
|
|
291
|
+
wikiName: string;
|
|
292
|
+
quizzes: Array<{ id: number; question: string; answer: string; quiz_type: string; page_title?: string; page_slug?: string }>;
|
|
293
|
+
sourcePages: PageLink[];
|
|
294
|
+
conceptPages: PageLink[];
|
|
295
|
+
}): string {
|
|
296
|
+
const quizzesJson = JSON.stringify(opts.quizzes).replace(/</g, "\\u003c");
|
|
297
|
+
|
|
298
|
+
const content = `
|
|
299
|
+
<div class="quiz-page">
|
|
300
|
+
<h1>📝 학습 퀴즈</h1>
|
|
301
|
+
<p class="quiz-desc">위키 내용을 기반으로 생성된 퀴즈입니다. 학습한 내용을 확인해보세요!</p>
|
|
302
|
+
|
|
303
|
+
<div id="quiz-container">
|
|
304
|
+
<div id="quiz-empty" style="display:none;">
|
|
305
|
+
<p style="text-align:center;color:var(--text-muted);padding:40px 0;">퀴즈가 없습니다. 먼저 문서를 추가하세요.</p>
|
|
306
|
+
</div>
|
|
307
|
+
<div id="quiz-active" style="display:none;">
|
|
308
|
+
<div class="quiz-progress">
|
|
309
|
+
<span id="quiz-progress-text">1 / 5</span>
|
|
310
|
+
<div class="quiz-progress-bar"><div id="quiz-progress-fill" class="quiz-progress-fill"></div></div>
|
|
311
|
+
</div>
|
|
312
|
+
<div class="quiz-card" id="quiz-card">
|
|
313
|
+
<div class="quiz-card-inner" id="quiz-card-inner">
|
|
314
|
+
<div class="quiz-card-front">
|
|
315
|
+
<span class="quiz-type-badge" id="quiz-type-badge">빈칸 채우기</span>
|
|
316
|
+
<p class="quiz-question" id="quiz-question"></p>
|
|
317
|
+
<div class="quiz-input-area" id="quiz-input-area">
|
|
318
|
+
<input type="text" id="quiz-answer-input" placeholder="정답을 입력하세요..." autocomplete="off">
|
|
319
|
+
<button id="quiz-submit-btn" class="quiz-btn primary">확인</button>
|
|
320
|
+
</div>
|
|
321
|
+
<div class="quiz-ox-area" id="quiz-ox-area" style="display:none;">
|
|
322
|
+
<button class="quiz-btn ox-btn" data-answer="O">⭕ O</button>
|
|
323
|
+
<button class="quiz-btn ox-btn" data-answer="X">❌ X</button>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
<div class="quiz-card-back">
|
|
327
|
+
<div id="quiz-result-icon" class="quiz-result-icon"></div>
|
|
328
|
+
<p class="quiz-answer-label">정답</p>
|
|
329
|
+
<p class="quiz-answer-text" id="quiz-answer-text"></p>
|
|
330
|
+
<p class="quiz-source" id="quiz-source"></p>
|
|
331
|
+
<button id="quiz-next-btn" class="quiz-btn primary">다음 문제 →</button>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
<div id="quiz-done" style="display:none;">
|
|
337
|
+
<div class="quiz-score-card">
|
|
338
|
+
<h2>🎉 퀴즈 완료!</h2>
|
|
339
|
+
<div class="quiz-score">
|
|
340
|
+
<span id="quiz-score-text">0 / 5</span>
|
|
341
|
+
</div>
|
|
342
|
+
<div class="quiz-score-bar-container">
|
|
343
|
+
<div id="quiz-score-bar" class="quiz-score-bar"></div>
|
|
344
|
+
</div>
|
|
345
|
+
<p id="quiz-score-msg" class="quiz-score-msg"></p>
|
|
346
|
+
<button id="quiz-restart-btn" class="quiz-btn primary">🔄 다시 풀기</button>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
<style>
|
|
352
|
+
.quiz-page { max-width: 700px; margin: 0 auto; padding: 24px 16px; }
|
|
353
|
+
.quiz-page h1 { font-size: 24px; margin-bottom: 8px; }
|
|
354
|
+
.quiz-desc { color: var(--text-muted); font-size: 14px; margin-bottom: 24px; }
|
|
355
|
+
.quiz-progress { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
|
356
|
+
#quiz-progress-text { font-size: 14px; font-weight: 600; color: var(--text-muted); white-space: nowrap; }
|
|
357
|
+
.quiz-progress-bar { flex: 1; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
|
|
358
|
+
.quiz-progress-fill { height: 100%; background: var(--accent, #4caf50); border-radius: 3px; transition: width 0.3s ease; }
|
|
359
|
+
.quiz-card { perspective: 1000px; min-height: 300px; }
|
|
360
|
+
.quiz-card-inner { position: relative; transition: transform 0.5s ease; transform-style: preserve-3d; }
|
|
361
|
+
.quiz-card-inner.flipped { transform: rotateY(180deg); }
|
|
362
|
+
.quiz-card-front, .quiz-card-back {
|
|
363
|
+
background: var(--bg-alt, #fff); border: 1px solid var(--border); border-radius: 12px;
|
|
364
|
+
padding: 32px 24px; backface-visibility: hidden;
|
|
365
|
+
}
|
|
366
|
+
.quiz-card-back { position: absolute; top: 0; left: 0; right: 0; transform: rotateY(180deg); text-align: center; }
|
|
367
|
+
.quiz-type-badge {
|
|
368
|
+
display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600;
|
|
369
|
+
background: var(--accent-light, #e8f5e9); color: #2e7d32; margin-bottom: 16px;
|
|
370
|
+
}
|
|
371
|
+
.quiz-question { font-size: 18px; line-height: 1.6; margin-bottom: 24px; font-weight: 500; }
|
|
372
|
+
.quiz-input-area { display: flex; gap: 8px; }
|
|
373
|
+
#quiz-answer-input {
|
|
374
|
+
flex: 1; padding: 10px 14px; border: 2px solid var(--border); border-radius: 8px;
|
|
375
|
+
font-size: 16px; outline: none; transition: border-color 0.2s;
|
|
376
|
+
}
|
|
377
|
+
#quiz-answer-input:focus { border-color: var(--accent, #4caf50); }
|
|
378
|
+
.quiz-btn {
|
|
379
|
+
padding: 10px 20px; border: none; border-radius: 8px; font-size: 15px; font-weight: 600;
|
|
380
|
+
cursor: pointer; transition: all 0.2s;
|
|
381
|
+
}
|
|
382
|
+
.quiz-btn.primary { background: var(--accent, #4caf50); color: white; }
|
|
383
|
+
.quiz-btn.primary:hover { opacity: 0.9; transform: translateY(-1px); }
|
|
384
|
+
.quiz-ox-area { display: flex; gap: 16px; justify-content: center; }
|
|
385
|
+
.ox-btn { padding: 16px 32px; font-size: 20px; border: 2px solid var(--border); border-radius: 12px; background: var(--bg-alt, #fff); }
|
|
386
|
+
.ox-btn:hover { border-color: var(--accent, #4caf50); background: var(--accent-light, #e8f5e9); }
|
|
387
|
+
.quiz-result-icon { font-size: 48px; margin-bottom: 12px; }
|
|
388
|
+
.quiz-answer-label { font-size: 13px; color: var(--text-muted); margin-bottom: 4px; }
|
|
389
|
+
.quiz-answer-text { font-size: 22px; font-weight: 700; color: var(--accent, #4caf50); margin-bottom: 16px; }
|
|
390
|
+
.quiz-source { font-size: 13px; color: var(--text-muted); margin-bottom: 20px; }
|
|
391
|
+
.quiz-source a { color: var(--accent, #4caf50); text-decoration: none; }
|
|
392
|
+
.quiz-source a:hover { text-decoration: underline; }
|
|
393
|
+
.quiz-score-card { text-align: center; background: var(--bg-alt, #fff); border: 1px solid var(--border); border-radius: 12px; padding: 40px 24px; }
|
|
394
|
+
.quiz-score { font-size: 48px; font-weight: 800; color: var(--accent, #4caf50); margin: 16px 0; }
|
|
395
|
+
.quiz-score-bar-container { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; margin: 16px 0 20px; }
|
|
396
|
+
.quiz-score-bar { height: 100%; background: var(--accent, #4caf50); border-radius: 4px; transition: width 0.5s ease; }
|
|
397
|
+
.quiz-score-msg { font-size: 16px; color: var(--text-muted); margin-bottom: 24px; }
|
|
398
|
+
</style>
|
|
399
|
+
<script>
|
|
400
|
+
(function() {
|
|
401
|
+
const ALL_QUIZZES = ${quizzesJson};
|
|
402
|
+
const QUIZ_COUNT = Math.min(ALL_QUIZZES.length, 10);
|
|
403
|
+
|
|
404
|
+
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
|
|
405
|
+
|
|
406
|
+
function normalize(s) {
|
|
407
|
+
return s.trim().toLowerCase().replace(/\\s+/g, ' ');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (ALL_QUIZZES.length === 0) {
|
|
411
|
+
document.getElementById('quiz-empty').style.display = 'block';
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
let quizzes = [];
|
|
416
|
+
let current = 0;
|
|
417
|
+
let score = 0;
|
|
418
|
+
|
|
419
|
+
function shuffle(arr) {
|
|
420
|
+
const a = [...arr];
|
|
421
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
422
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
423
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
424
|
+
}
|
|
425
|
+
return a;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function startQuiz() {
|
|
429
|
+
quizzes = shuffle(ALL_QUIZZES).slice(0, QUIZ_COUNT);
|
|
430
|
+
current = 0;
|
|
431
|
+
score = 0;
|
|
432
|
+
document.getElementById('quiz-active').style.display = 'block';
|
|
433
|
+
document.getElementById('quiz-done').style.display = 'none';
|
|
434
|
+
showQuestion();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function typeLabel(t) {
|
|
438
|
+
return t === 'fill_blank' ? '빈칸 채우기' : t === 'ox' ? 'OX 퀴즈' : '단답형';
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function showQuestion() {
|
|
442
|
+
const q = quizzes[current];
|
|
443
|
+
const inner = document.getElementById('quiz-card-inner');
|
|
444
|
+
inner.classList.remove('flipped');
|
|
445
|
+
|
|
446
|
+
document.getElementById('quiz-progress-text').textContent = (current + 1) + ' / ' + quizzes.length;
|
|
447
|
+
document.getElementById('quiz-progress-fill').style.width = ((current + 1) / quizzes.length * 100) + '%';
|
|
448
|
+
document.getElementById('quiz-type-badge').textContent = typeLabel(q.quiz_type);
|
|
449
|
+
document.getElementById('quiz-question').innerHTML = esc(q.question);
|
|
450
|
+
|
|
451
|
+
const inputArea = document.getElementById('quiz-input-area');
|
|
452
|
+
const oxArea = document.getElementById('quiz-ox-area');
|
|
453
|
+
const answerInput = document.getElementById('quiz-answer-input');
|
|
454
|
+
|
|
455
|
+
if (q.quiz_type === 'ox') {
|
|
456
|
+
inputArea.style.display = 'none';
|
|
457
|
+
oxArea.style.display = 'flex';
|
|
458
|
+
} else {
|
|
459
|
+
inputArea.style.display = 'flex';
|
|
460
|
+
oxArea.style.display = 'none';
|
|
461
|
+
answerInput.value = '';
|
|
462
|
+
setTimeout(() => answerInput.focus(), 100);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function checkAnswer(userAnswer) {
|
|
467
|
+
const q = quizzes[current];
|
|
468
|
+
const isCorrect = normalize(userAnswer) === normalize(q.answer) || normalize(q.answer).includes(normalize(userAnswer)) && normalize(userAnswer).length > 0;
|
|
469
|
+
|
|
470
|
+
if (isCorrect) score++;
|
|
471
|
+
|
|
472
|
+
document.getElementById('quiz-result-icon').textContent = isCorrect ? '🎉' : '😅';
|
|
473
|
+
document.getElementById('quiz-answer-text').innerHTML = esc(q.answer);
|
|
474
|
+
document.getElementById('quiz-answer-text').style.color = isCorrect ? 'var(--accent, #4caf50)' : '#e53935';
|
|
475
|
+
|
|
476
|
+
const sourceEl = document.getElementById('quiz-source');
|
|
477
|
+
if (q.page_slug) {
|
|
478
|
+
const a = document.createElement('a');
|
|
479
|
+
a.href = '/wiki/' + encodeURIComponent(q.page_slug) + '.html';
|
|
480
|
+
a.textContent = q.page_title || q.page_slug;
|
|
481
|
+
sourceEl.textContent = '출처: ';
|
|
482
|
+
sourceEl.appendChild(a);
|
|
483
|
+
} else {
|
|
484
|
+
sourceEl.textContent = '';
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
document.getElementById('quiz-card-inner').classList.add('flipped');
|
|
488
|
+
|
|
489
|
+
const nextBtn = document.getElementById('quiz-next-btn');
|
|
490
|
+
nextBtn.textContent = current < quizzes.length - 1 ? '다음 문제 →' : '결과 보기 →';
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function nextQuestion() {
|
|
494
|
+
current++;
|
|
495
|
+
if (current >= quizzes.length) {
|
|
496
|
+
showResults();
|
|
497
|
+
} else {
|
|
498
|
+
showQuestion();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function showResults() {
|
|
503
|
+
document.getElementById('quiz-active').style.display = 'none';
|
|
504
|
+
document.getElementById('quiz-done').style.display = 'block';
|
|
505
|
+
|
|
506
|
+
const pct = Math.round(score / quizzes.length * 100);
|
|
507
|
+
document.getElementById('quiz-score-text').textContent = score + ' / ' + quizzes.length;
|
|
508
|
+
document.getElementById('quiz-score-bar').style.width = pct + '%';
|
|
509
|
+
|
|
510
|
+
const msgs = pct >= 90 ? '🏆 완벽에 가깝습니다!' : pct >= 70 ? '👏 잘 하셨습니다!' : pct >= 50 ? '📚 조금 더 복습해보세요!' : '💪 다시 도전해보세요!';
|
|
511
|
+
document.getElementById('quiz-score-msg').textContent = msgs;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Event listeners
|
|
515
|
+
document.getElementById('quiz-submit-btn').addEventListener('click', function() {
|
|
516
|
+
const val = document.getElementById('quiz-answer-input').value;
|
|
517
|
+
if (val.trim()) checkAnswer(val);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
document.getElementById('quiz-answer-input').addEventListener('keydown', function(e) {
|
|
521
|
+
if (e.key === 'Enter' && this.value.trim()) checkAnswer(this.value);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
document.querySelectorAll('.ox-btn').forEach(function(btn) {
|
|
525
|
+
btn.addEventListener('click', function() { checkAnswer(this.dataset.answer); });
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
document.getElementById('quiz-next-btn').addEventListener('click', nextQuestion);
|
|
529
|
+
document.getElementById('quiz-restart-btn').addEventListener('click', startQuiz);
|
|
530
|
+
|
|
531
|
+
startQuiz();
|
|
532
|
+
})();
|
|
533
|
+
</script>`;
|
|
534
|
+
|
|
535
|
+
return base({
|
|
536
|
+
title: `학습 퀴즈 - ${opts.wikiName}`,
|
|
537
|
+
wikiName: opts.wikiName,
|
|
538
|
+
sourcePages: opts.sourcePages,
|
|
539
|
+
conceptPages: opts.conceptPages,
|
|
540
|
+
content,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
420
544
|
export function renderAdmin(opts: {
|
|
421
545
|
wikiName: string;
|
|
422
546
|
sources: Array<{ id: number; uri: string; type: string; title: string; fetched_at: string }>;
|
|
@@ -424,6 +548,7 @@ export function renderAdmin(opts: {
|
|
|
424
548
|
llmConfig: { provider: string; model: string; api_key: string; endpoint: string };
|
|
425
549
|
personas: Array<{ name: string; description: string; system_prompt: string; content_style: string }>;
|
|
426
550
|
activePersona: string;
|
|
551
|
+
authToken?: string;
|
|
427
552
|
}): string {
|
|
428
553
|
const maskedKey = opts.llmConfig.api_key ? "••••" + opts.llmConfig.api_key.slice(-4) : "(미설정)";
|
|
429
554
|
const sourceRows = opts.sources
|
|
@@ -439,6 +564,9 @@ export function renderAdmin(opts: {
|
|
|
439
564
|
<meta charset="UTF-8">
|
|
440
565
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
441
566
|
<title>관리 - ${escapeHtml(opts.wikiName)}</title>
|
|
567
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
568
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
569
|
+
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
442
570
|
<link rel="stylesheet" href="/static/style.css">
|
|
443
571
|
<style>
|
|
444
572
|
.admin-page { max-width: 900px; margin: 80px auto; padding: 0 24px; }
|
|
@@ -617,15 +745,17 @@ export function renderAdmin(opts: {
|
|
|
617
745
|
</div>
|
|
618
746
|
</div>
|
|
619
747
|
<script>
|
|
748
|
+
const AUTH_TOKEN = ${opts.authToken ? JSON.stringify(opts.authToken).replace(/</g, "\\u003c") : "''"};
|
|
749
|
+
const authHeaders = AUTH_TOKEN ? { 'Authorization': 'Bearer ' + AUTH_TOKEN } : {};
|
|
620
750
|
async function runAction(url, label) {
|
|
621
751
|
const status = document.getElementById('action-status');
|
|
622
752
|
status.textContent = '⏳ ' + label + ' 중...';
|
|
623
753
|
status.style.color = '#e65100';
|
|
624
754
|
try {
|
|
625
|
-
const r = await fetch(url, { method: 'POST' });
|
|
755
|
+
const r = await fetch(url, { method: 'POST', headers: authHeaders });
|
|
626
756
|
if (!r.ok) { const d = await r.json(); status.textContent = '❌ ' + (d.error || '실패'); status.style.color = '#c62828'; return; }
|
|
627
757
|
const poll = setInterval(async () => {
|
|
628
|
-
const sr = await fetch('/api/status');
|
|
758
|
+
const sr = await fetch('/api/status', { headers: authHeaders });
|
|
629
759
|
const s = await sr.json();
|
|
630
760
|
status.textContent = '⏳ ' + s.processingStatus;
|
|
631
761
|
if (!s.processing) {
|
|
@@ -643,7 +773,7 @@ export function renderAdmin(opts: {
|
|
|
643
773
|
const name = document.getElementById('wiki-name').value.trim();
|
|
644
774
|
if (!name) return;
|
|
645
775
|
try {
|
|
646
|
-
const r = await fetch('/api/settings', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ wiki_name: name }) });
|
|
776
|
+
const r = await fetch('/api/settings', { method: 'POST', headers: {...authHeaders, 'Content-Type':'application/json'}, body: JSON.stringify({ wiki_name: name }) });
|
|
647
777
|
if (r.ok) { status.textContent = '✅ 저장됨'; status.style.color = '#2e7d32'; setTimeout(() => location.reload(), 1000); }
|
|
648
778
|
else { status.textContent = '❌ 실패'; status.style.color = '#c62828'; }
|
|
649
779
|
} catch { status.textContent = '❌ 연결 실패'; status.style.color = '#c62828'; }
|
|
@@ -663,19 +793,19 @@ export function renderAdmin(opts: {
|
|
|
663
793
|
const ep = document.getElementById('llm-endpoint').value;
|
|
664
794
|
if (ep) body.endpoint = ep;
|
|
665
795
|
try {
|
|
666
|
-
const r = await fetch('/api/settings', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
|
796
|
+
const r = await fetch('/api/settings', { method: 'POST', headers: {...authHeaders, 'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
|
667
797
|
if (r.ok) { status.textContent = '✅ 저장됨'; status.style.color = '#2e7d32'; setTimeout(() => location.reload(), 1000); }
|
|
668
798
|
else { status.textContent = '❌ 실패'; status.style.color = '#c62828'; }
|
|
669
799
|
} catch { status.textContent = '❌ 연결 실패'; status.style.color = '#c62828'; }
|
|
670
800
|
});
|
|
671
801
|
|
|
672
802
|
// ── Persona management ──
|
|
673
|
-
let personaData = ${JSON.stringify(opts.personas)};
|
|
803
|
+
let personaData = ${JSON.stringify(opts.personas).replace(/</g, "\\u003c")};
|
|
674
804
|
|
|
675
805
|
async function activatePersona(name) {
|
|
676
806
|
const status = document.getElementById('persona-activate-status');
|
|
677
807
|
try {
|
|
678
|
-
const r = await fetch('/api/personas', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'activate', name }) });
|
|
808
|
+
const r = await fetch('/api/personas', { method: 'POST', headers: {...authHeaders, 'Content-Type':'application/json'}, body: JSON.stringify({ action: 'activate', name }) });
|
|
679
809
|
if (r.ok) { status.textContent = '✅'; status.style.color = '#2e7d32'; setTimeout(() => location.reload(), 800); }
|
|
680
810
|
else { status.textContent = '❌'; status.style.color = '#c62828'; }
|
|
681
811
|
} catch { status.textContent = '❌'; status.style.color = '#c62828'; }
|
|
@@ -719,7 +849,7 @@ export function renderAdmin(opts: {
|
|
|
719
849
|
? { action: 'update', original_name: originalName, persona }
|
|
720
850
|
: { action: 'add', persona };
|
|
721
851
|
try {
|
|
722
|
-
const r = await fetch('/api/personas', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
|
852
|
+
const r = await fetch('/api/personas', { method: 'POST', headers: {...authHeaders, 'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
|
723
853
|
if (r.ok) { closePersonaModal(); location.reload(); }
|
|
724
854
|
else { const d = await r.json(); alert(d.error || '실패'); }
|
|
725
855
|
} catch { alert('연결 실패'); }
|
|
@@ -728,7 +858,7 @@ export function renderAdmin(opts: {
|
|
|
728
858
|
async function deletePersona(name) {
|
|
729
859
|
if (!confirm(name + ' 페르소나를 삭제하시겠습니까?')) return;
|
|
730
860
|
try {
|
|
731
|
-
const r = await fetch('/api/personas', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', name }) });
|
|
861
|
+
const r = await fetch('/api/personas', { method: 'POST', headers: {...authHeaders, 'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', name }) });
|
|
732
862
|
if (r.ok) location.reload();
|
|
733
863
|
else { const d = await r.json(); alert(d.error || '실패'); }
|
|
734
864
|
} catch { alert('연결 실패'); }
|