@open330/kiwimu 0.7.1 → 1.1.0
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/README.md +189 -62
- package/package.json +1 -1
- package/src/build/renderer.ts +273 -32
- package/src/build/static/dynamic-qa.js +423 -0
- package/src/build/static/edit-page.js +58 -0
- package/src/build/static/peek-panel.css +201 -0
- package/src/build/static/peek-panel.js +470 -0
- package/src/build/static/search.js +30 -15
- package/src/build/static/style.css +821 -6
- package/src/build/templates.ts +757 -49
- package/src/config.ts +41 -3
- package/src/demo/sample-data.ts +75 -8
- package/src/demo/setup.ts +26 -7
- package/src/expand/llm.ts +2 -2
- package/src/index.ts +497 -64
- package/src/ingest/docx.ts +1 -1
- package/src/ingest/markdown.ts +21 -0
- package/src/ingest/pdf.ts +4 -2
- package/src/llm-client.ts +63 -69
- package/src/pipeline/citations.ts +107 -0
- package/src/pipeline/llm-chunker.ts +281 -128
- package/src/pipeline/standardizer.ts +41 -0
- package/src/server.ts +466 -33
- package/src/services/dynamic-qa.ts +190 -0
- package/src/services/embedding.ts +122 -0
- package/src/services/index-generator.ts +185 -0
- package/src/services/ingest.ts +84 -26
- package/src/services/lint.ts +249 -0
- package/src/services/promote.ts +150 -0
- package/src/store.test.ts +11 -0
- package/src/store.ts +652 -15
- package/src/utils.ts +30 -0
package/src/build/templates.ts
CHANGED
|
@@ -1,25 +1,107 @@
|
|
|
1
|
+
import { escapeHtml } from "../utils";
|
|
2
|
+
|
|
1
3
|
interface PageLink {
|
|
2
4
|
slug: string;
|
|
3
5
|
title: string;
|
|
4
6
|
pageType?: string;
|
|
7
|
+
origin?: string;
|
|
8
|
+
sourceUri?: string;
|
|
5
9
|
}
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
/** Configurable category definition (matches SourceCategory in config.ts). */
|
|
12
|
+
interface CategorySpec {
|
|
13
|
+
name: string;
|
|
14
|
+
order: number;
|
|
15
|
+
patterns: string[];
|
|
9
16
|
}
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Convert a glob-like pattern (`*` wildcard) into a case-insensitive RegExp.
|
|
20
|
+
* Anchors are NOT applied — the result is meant to be tested against either
|
|
21
|
+
* the basename or the full URI, matching either substring is acceptable.
|
|
22
|
+
*/
|
|
23
|
+
function patternToRegex(pat: string): RegExp {
|
|
24
|
+
// Escape regex specials except *
|
|
25
|
+
const escaped = pat.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
26
|
+
return new RegExp(escaped, "i");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Categorize a source URI against user-supplied categories. Returns the first
|
|
31
|
+
* matching category; falls back to "기타" / "Other" with a high order if no match.
|
|
32
|
+
* If `categories` is empty/undefined the caller should treat the result as
|
|
33
|
+
* "no grouping" (caller decides — see `groupByCategory`).
|
|
34
|
+
*/
|
|
35
|
+
function categorize(
|
|
36
|
+
uri: string | undefined,
|
|
37
|
+
categories: CategorySpec[] | undefined
|
|
38
|
+
): { name: string; order: number; sortKey: string } {
|
|
39
|
+
const u = uri || "";
|
|
40
|
+
const base = u.split("/").pop()?.replace(/\.[a-z0-9]+$/i, "") || "";
|
|
41
|
+
if (categories && categories.length > 0) {
|
|
42
|
+
for (const cat of categories) {
|
|
43
|
+
for (const pat of cat.patterns) {
|
|
44
|
+
const re = patternToRegex(pat);
|
|
45
|
+
if (re.test(base) || re.test(u)) {
|
|
46
|
+
return { name: cat.name, order: cat.order, sortKey: base };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { name: "기타", order: 9999, sortKey: base };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function groupByCategory(
|
|
55
|
+
pages: PageLink[],
|
|
56
|
+
categories: CategorySpec[] | undefined
|
|
57
|
+
): { name: string; order: number; pages: PageLink[] }[] {
|
|
58
|
+
const groups = new Map<string, { name: string; order: number; pages: PageLink[] }>();
|
|
59
|
+
for (const p of pages) {
|
|
60
|
+
const c = categorize(p.sourceUri, categories);
|
|
61
|
+
if (!groups.has(c.name)) groups.set(c.name, { name: c.name, order: c.order, pages: [] });
|
|
62
|
+
groups.get(c.name)!.pages.push(p);
|
|
63
|
+
}
|
|
64
|
+
const sorted = [...groups.values()].sort((a, b) => a.order - b.order);
|
|
65
|
+
for (const g of sorted) {
|
|
66
|
+
g.pages.sort((a, b) => {
|
|
67
|
+
const ka = (a.sourceUri || "") + "|" + a.title;
|
|
68
|
+
const kb = (b.sourceUri || "") + "|" + b.title;
|
|
69
|
+
return ka.localeCompare(kb);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return sorted;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sidebarHtml(sourcePages: PageLink[], conceptPages: PageLink[], activeSlug?: string, categories?: CategorySpec[]): string {
|
|
76
|
+
// Group sources by category only when we have both sourceUri info AND user-defined categories.
|
|
77
|
+
const hasUri = sourcePages.some((p) => p.sourceUri);
|
|
78
|
+
const hasCats = !!(categories && categories.length > 0);
|
|
79
|
+
let sourceItems: string;
|
|
80
|
+
if (hasUri && hasCats) {
|
|
81
|
+
const groups = groupByCategory(sourcePages, categories);
|
|
82
|
+
sourceItems = groups
|
|
83
|
+
.map((g) => {
|
|
84
|
+
const items = g.pages
|
|
85
|
+
.map((p) => `<li><a href="/wiki/${p.slug}.html"${p.slug === activeSlug ? ' class="active"' : ""}>${escapeHtml(p.title)}</a></li>`)
|
|
86
|
+
.join("\n");
|
|
87
|
+
return `<details open class="sidebar-group"><summary>${escapeHtml(g.name)} <span class="sidebar-count">${g.pages.length}</span></summary><ul class="page-list">${items}</ul></details>`;
|
|
88
|
+
})
|
|
89
|
+
.join("\n");
|
|
90
|
+
} else {
|
|
91
|
+
sourceItems = sourcePages
|
|
92
|
+
.map(
|
|
93
|
+
(p) =>
|
|
94
|
+
`<li><a href="/wiki/${p.slug}.html"${p.slug === activeSlug ? ' class="active"' : ""}>${escapeHtml(p.title)}</a></li>`
|
|
95
|
+
)
|
|
96
|
+
.join("\n");
|
|
97
|
+
}
|
|
18
98
|
|
|
19
99
|
const conceptItems = conceptPages
|
|
20
100
|
.map(
|
|
21
|
-
(p) =>
|
|
22
|
-
|
|
101
|
+
(p) => {
|
|
102
|
+
const icon = p.origin === 'user' ? '💬' : '📝';
|
|
103
|
+
return `<li><a href="/wiki/${p.slug}.html"${p.slug === activeSlug ? ' class="active"' : ""}>${icon} ${escapeHtml(p.title)}</a></li>`;
|
|
104
|
+
}
|
|
23
105
|
)
|
|
24
106
|
.join("\n");
|
|
25
107
|
|
|
@@ -36,6 +118,15 @@ function sidebarHtml(sourcePages: PageLink[], conceptPages: PageLink[], activeSl
|
|
|
36
118
|
</div>
|
|
37
119
|
<div class="sidebar-panel${!activeIsSource && activeSlug ? " active" : ""}" id="tab-concept">
|
|
38
120
|
<ul class="page-list">${conceptItems}</ul>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="sidebar-mobile-nav">
|
|
123
|
+
<a href="/catalog.html">📑 목록</a>
|
|
124
|
+
<a href="/wiki/random.html">🎲 임의 문서</a>
|
|
125
|
+
<a href="/quiz.html">📝 퀴즈</a>
|
|
126
|
+
<a href="/dashboard.html">📊 대시보드</a>
|
|
127
|
+
<a href="/graph.html">🔗 그래프</a>
|
|
128
|
+
<a href="/provenance">📚 출처</a>
|
|
129
|
+
<a href="/manage">⚙️ 관리</a>
|
|
39
130
|
</div>`;
|
|
40
131
|
}
|
|
41
132
|
|
|
@@ -45,25 +136,60 @@ function base(opts: {
|
|
|
45
136
|
sourcePages: PageLink[];
|
|
46
137
|
conceptPages: PageLink[];
|
|
47
138
|
activeSlug?: string;
|
|
139
|
+
description?: string;
|
|
48
140
|
content: string;
|
|
141
|
+
categories?: CategorySpec[];
|
|
49
142
|
}) {
|
|
143
|
+
const ogDescription = escapeHtml(opts.description || 'LLM으로 자동 생성된 학습 위키');
|
|
50
144
|
return `<!DOCTYPE html>
|
|
51
145
|
<html lang="ko">
|
|
52
146
|
<head>
|
|
53
147
|
<meta charset="UTF-8">
|
|
54
148
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
55
149
|
<title>${escapeHtml(opts.title)}</title>
|
|
150
|
+
<meta property="og:title" content="${escapeHtml(opts.title)}">
|
|
151
|
+
<meta property="og:description" content="${ogDescription}">
|
|
152
|
+
<meta property="og:type" content="article">
|
|
153
|
+
<meta name="twitter:card" content="summary">
|
|
154
|
+
<meta name="twitter:title" content="${escapeHtml(opts.title)}">
|
|
155
|
+
<meta name="description" content="${ogDescription}">
|
|
56
156
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
57
157
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
58
158
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
59
159
|
<link rel="stylesheet" href="/static/style.css">
|
|
160
|
+
<link rel="stylesheet" href="/static/peek-panel.css">
|
|
60
161
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
|
61
|
-
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
|
62
|
-
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
|
162
|
+
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
|
163
|
+
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js" onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}]})"></script>
|
|
164
|
+
<script>
|
|
165
|
+
// Mermaid: lazy-load on first need, expose window.kiwiRenderMermaid(root)
|
|
166
|
+
// for static + dynamically injected content (peek panel, dynamic-qa).
|
|
167
|
+
(function(){
|
|
168
|
+
var loadPromise = null;
|
|
169
|
+
function load(){
|
|
170
|
+
if (!loadPromise) {
|
|
171
|
+
loadPromise = import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs').then(function(m){
|
|
172
|
+
m.default.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });
|
|
173
|
+
return m.default;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return loadPromise;
|
|
177
|
+
}
|
|
178
|
+
window.kiwiRenderMermaid = function(root){
|
|
179
|
+
var scope = root && root.querySelectorAll ? root : document;
|
|
180
|
+
var nodes = scope.querySelectorAll('.mermaid:not([data-processed="true"])');
|
|
181
|
+
if (!nodes.length) return Promise.resolve();
|
|
182
|
+
return load().then(function(mermaid){
|
|
183
|
+
return mermaid.run({ nodes: nodes }).catch(function(e){ console.warn('mermaid render failed', e); });
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
document.addEventListener('DOMContentLoaded', function(){ window.kiwiRenderMermaid(); });
|
|
187
|
+
})();
|
|
188
|
+
</script>
|
|
63
189
|
</head>
|
|
64
190
|
<body>
|
|
65
191
|
<nav class="topbar">
|
|
66
|
-
<button class="topbar-menu-btn" aria-label="메뉴">☰</button>
|
|
192
|
+
<button class="topbar-menu-btn" aria-label="메뉴 열기" aria-expanded="false">☰</button>
|
|
67
193
|
<a href="/index.html" class="topbar-brand">
|
|
68
194
|
<img src="/static/logo.png" alt="Kiwi Mu" class="topbar-logo">
|
|
69
195
|
${escapeHtml(opts.wikiName)}
|
|
@@ -73,22 +199,27 @@ function base(opts: {
|
|
|
73
199
|
<div id="search-results" class="search-dropdown"></div>
|
|
74
200
|
</div>
|
|
75
201
|
<div class="topbar-links">
|
|
76
|
-
<a href="/
|
|
202
|
+
<a href="/catalog.html" class="btn-graph">📑 목록</a>
|
|
203
|
+
<a href="/wiki/random.html" class="btn-graph">🎲 임의</a>
|
|
77
204
|
<a href="/quiz.html" class="btn-graph">📝 퀴즈</a>
|
|
78
|
-
<a href="/
|
|
79
|
-
<a href="/
|
|
205
|
+
<a href="/dashboard.html" class="btn-graph">📊 대시보드</a>
|
|
206
|
+
<a href="/graph.html" class="btn-graph">🔗 그래프</a>
|
|
207
|
+
<a href="/manage" class="btn-graph">⚙️ 관리</a>
|
|
80
208
|
</div>
|
|
81
209
|
</nav>
|
|
82
210
|
<div class="sidebar-overlay"></div>
|
|
83
211
|
<div class="layout">
|
|
84
212
|
<aside class="sidebar">
|
|
85
|
-
${sidebarHtml(opts.sourcePages, opts.conceptPages, opts.activeSlug)}
|
|
213
|
+
${sidebarHtml(opts.sourcePages, opts.conceptPages, opts.activeSlug, opts.categories)}
|
|
86
214
|
</aside>
|
|
87
215
|
<main class="content">
|
|
88
216
|
${opts.content}
|
|
89
217
|
</main>
|
|
90
218
|
</div>
|
|
91
219
|
<script src="/static/search.js"></script>
|
|
220
|
+
<script src="/static/dynamic-qa.js"></script>
|
|
221
|
+
<script src="/static/edit-page.js"></script>
|
|
222
|
+
<script src="/static/peek-panel.js"></script>
|
|
92
223
|
<script>
|
|
93
224
|
// Mobile hamburger menu
|
|
94
225
|
(function() {
|
|
@@ -97,8 +228,10 @@ function base(opts: {
|
|
|
97
228
|
const overlay = document.querySelector('.sidebar-overlay');
|
|
98
229
|
if (menuBtn && sidebar) {
|
|
99
230
|
menuBtn.addEventListener('click', () => {
|
|
100
|
-
sidebar.classList.toggle('open');
|
|
231
|
+
const isOpen = sidebar.classList.toggle('open');
|
|
101
232
|
overlay?.classList.toggle('active');
|
|
233
|
+
menuBtn.setAttribute('aria-expanded', isOpen);
|
|
234
|
+
menuBtn.setAttribute('aria-label', isOpen ? '메뉴 닫기' : '메뉴 열기');
|
|
102
235
|
});
|
|
103
236
|
overlay?.addEventListener('click', () => {
|
|
104
237
|
sidebar.classList.remove('open');
|
|
@@ -115,18 +248,10 @@ function base(opts: {
|
|
|
115
248
|
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
|
116
249
|
});
|
|
117
250
|
});
|
|
118
|
-
// KaTeX
|
|
119
|
-
document.addEventListener("DOMContentLoaded", function() {
|
|
120
|
-
renderMathInElement(document.body, {
|
|
121
|
-
delimiters: [
|
|
122
|
-
{left: "$$", right: "$$", display: true},
|
|
123
|
-
{left: "$", right: "$", display: false},
|
|
124
|
-
{left: "\\\\(", right: "\\\\)", display: false},
|
|
125
|
-
{left: "\\\\[", right: "\\\\]", display: true}
|
|
126
|
-
]
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
251
|
</script>
|
|
252
|
+
<footer class="kiwimu-badge" style="text-align:center;padding:16px;margin-top:32px;border-top:1px solid var(--border);font-size:12px;color:var(--text-muted);">
|
|
253
|
+
🥝 Built with <a href="https://github.com/Open330/kiwimu" target="_blank" rel="noopener" style="color:var(--namu-green);text-decoration:none;">Kiwi Mu</a> — 나만의 학습 위키 빌더
|
|
254
|
+
</footer>
|
|
130
255
|
</body>
|
|
131
256
|
</html>`;
|
|
132
257
|
}
|
|
@@ -136,15 +261,24 @@ export function renderPage(opts: {
|
|
|
136
261
|
pageTitle: string;
|
|
137
262
|
pageSlug: string;
|
|
138
263
|
pageType: string;
|
|
264
|
+
pageId: number;
|
|
265
|
+
origin?: string;
|
|
139
266
|
content: string;
|
|
140
267
|
externalRefs: string;
|
|
141
268
|
toc: string;
|
|
142
269
|
backlinks: PageLink[];
|
|
270
|
+
citationsHtml?: string;
|
|
143
271
|
sourcePages: PageLink[];
|
|
144
272
|
conceptPages: PageLink[];
|
|
273
|
+
categories?: CategorySpec[];
|
|
145
274
|
}): string {
|
|
146
|
-
|
|
147
|
-
|
|
275
|
+
let typeBadge: string;
|
|
276
|
+
if (opts.origin === 'user') {
|
|
277
|
+
typeBadge = `<span class="page-type-badge dynamic">💬 질문 생성</span>`;
|
|
278
|
+
} else {
|
|
279
|
+
const typeLabel = opts.pageType === "source" ? "📖 원본 문서" : "📝 개념 문서";
|
|
280
|
+
typeBadge = `<span class="page-type-badge ${opts.pageType}">${typeLabel}</span>`;
|
|
281
|
+
}
|
|
148
282
|
|
|
149
283
|
// Separate backlinks by type
|
|
150
284
|
const sourceBacklinks = opts.backlinks.filter((bl) => bl.pageType === "source");
|
|
@@ -174,25 +308,44 @@ export function renderPage(opts: {
|
|
|
174
308
|
? `<details class="toc-box" open><summary>목차</summary>${opts.toc}</details>`
|
|
175
309
|
: "";
|
|
176
310
|
|
|
311
|
+
const citationsHtml = opts.citationsHtml || "";
|
|
312
|
+
|
|
177
313
|
const content = `
|
|
178
|
-
<article class="wiki-page">
|
|
314
|
+
<article class="wiki-page" data-page-slug="${opts.pageSlug}" data-page-id="${opts.pageId}">
|
|
179
315
|
<header class="page-header">
|
|
180
316
|
${typeBadge}
|
|
181
|
-
<h1>${escapeHtml(opts.pageTitle)}
|
|
317
|
+
<h1>${escapeHtml(opts.pageTitle)} <button class="edit-btn" data-slug="${opts.pageSlug}" title="편집">✎</button></h1>
|
|
182
318
|
</header>
|
|
183
319
|
${tocHtml}
|
|
184
320
|
<div class="page-body">${opts.content}</div>
|
|
321
|
+
${citationsHtml}
|
|
185
322
|
${externalRefsHtml}
|
|
186
323
|
${backlinksHtml}
|
|
187
|
-
</article
|
|
324
|
+
</article>
|
|
325
|
+
<div class="edit-modal" id="edit-modal" style="display:none">
|
|
326
|
+
<div class="edit-modal-inner">
|
|
327
|
+
<div class="edit-modal-header">
|
|
328
|
+
<span>페이지 편집</span>
|
|
329
|
+
<button class="edit-modal-close">×</button>
|
|
330
|
+
</div>
|
|
331
|
+
<textarea class="edit-textarea" id="edit-textarea"></textarea>
|
|
332
|
+
<div class="edit-modal-footer">
|
|
333
|
+
<button class="edit-cancel">취소</button>
|
|
334
|
+
<button class="edit-save">저장</button>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
</div>`;
|
|
188
338
|
|
|
189
339
|
return base({
|
|
190
340
|
title: `${opts.pageTitle} - ${opts.wikiName}`,
|
|
191
341
|
wikiName: opts.wikiName,
|
|
192
342
|
sourcePages: opts.sourcePages,
|
|
193
343
|
conceptPages: opts.conceptPages,
|
|
344
|
+
categories: opts.categories,
|
|
194
345
|
activeSlug: opts.pageSlug,
|
|
346
|
+
description: `${opts.pageTitle} - ${opts.wikiName} 학습 위키`,
|
|
195
347
|
content,
|
|
348
|
+
categories: opts.categories,
|
|
196
349
|
});
|
|
197
350
|
}
|
|
198
351
|
|
|
@@ -201,13 +354,30 @@ export function renderIndex(opts: {
|
|
|
201
354
|
sourcePages: PageLink[];
|
|
202
355
|
conceptPages: PageLink[];
|
|
203
356
|
sourceCount: number;
|
|
357
|
+
categories?: CategorySpec[];
|
|
204
358
|
}): string {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
.
|
|
359
|
+
// Group source cards only when both sourceUri AND user categories are present.
|
|
360
|
+
const sourceHasUri = opts.sourcePages.some((p) => p.sourceUri);
|
|
361
|
+
const hasCats = !!(opts.categories && opts.categories.length > 0);
|
|
362
|
+
const shouldGroup = sourceHasUri && hasCats;
|
|
363
|
+
const sourceCards = shouldGroup
|
|
364
|
+
? groupByCategory(opts.sourcePages, opts.categories)
|
|
365
|
+
.map((g) => {
|
|
366
|
+
const cards = g.pages
|
|
367
|
+
.map(
|
|
368
|
+
(p) =>
|
|
369
|
+
`<a href="/wiki/${p.slug}.html" class="page-card source"><span class="card-title">${escapeHtml(p.title)}</span></a>`
|
|
370
|
+
)
|
|
371
|
+
.join("\n");
|
|
372
|
+
return `<div class="page-group"><h3 class="page-group-title">${escapeHtml(g.name)} <span class="page-group-count">${g.pages.length}</span></h3><div class="page-cards">${cards}</div></div>`;
|
|
373
|
+
})
|
|
374
|
+
.join("\n")
|
|
375
|
+
: opts.sourcePages
|
|
376
|
+
.map(
|
|
377
|
+
(p) =>
|
|
378
|
+
`<a href="/wiki/${p.slug}.html" class="page-card source"><span class="card-title">${escapeHtml(p.title)}</span></a>`
|
|
379
|
+
)
|
|
380
|
+
.join("\n");
|
|
211
381
|
|
|
212
382
|
const conceptCards = opts.conceptPages
|
|
213
383
|
.map(
|
|
@@ -230,12 +400,12 @@ export function renderIndex(opts: {
|
|
|
230
400
|
<!-- Add document link -->
|
|
231
401
|
<section class="index-section add-section">
|
|
232
402
|
<h2>➕ 문서 추가</h2>
|
|
233
|
-
<p>문서를 추가하려면 <a href="/
|
|
403
|
+
<p>문서를 추가하려면 <a href="/manage">관리 페이지</a>에서 문서를 추가하세요.</p>
|
|
234
404
|
</section>
|
|
235
405
|
|
|
236
406
|
<section class="index-section">
|
|
237
407
|
<h2>📖 원본 문서</h2>
|
|
238
|
-
|
|
408
|
+
${sourceCards.length > 0 ? (shouldGroup ? sourceCards : `<div class="page-cards">${sourceCards}</div>`) : '<div class="page-cards"><div class="empty-state">아직 원본 문서가 없습니다. URL이나 파일을 추가해보세요!</div></div>'}
|
|
239
409
|
</section>
|
|
240
410
|
<section class="index-section">
|
|
241
411
|
<h2>📝 개념 문서</h2>
|
|
@@ -243,6 +413,7 @@ export function renderIndex(opts: {
|
|
|
243
413
|
</section>
|
|
244
414
|
<section class="index-section">
|
|
245
415
|
<div class="quick-links">
|
|
416
|
+
<a href="/catalog.html" class="quick-link">📑 문서 목록</a>
|
|
246
417
|
<a href="/quiz.html" class="quick-link">📝 학습 퀴즈</a>
|
|
247
418
|
<a href="/graph.html" class="quick-link">📊 지식 그래프 보기</a>
|
|
248
419
|
</div>
|
|
@@ -256,7 +427,10 @@ export function renderIndex(opts: {
|
|
|
256
427
|
wikiName: opts.wikiName,
|
|
257
428
|
sourcePages: opts.sourcePages,
|
|
258
429
|
conceptPages: opts.conceptPages,
|
|
430
|
+
categories: opts.categories,
|
|
431
|
+
description: `${opts.wikiName} — LLM으로 자동 생성된 학습 위키`,
|
|
259
432
|
content,
|
|
433
|
+
categories: opts.categories,
|
|
260
434
|
});
|
|
261
435
|
}
|
|
262
436
|
|
|
@@ -264,6 +438,7 @@ export function renderGraph(opts: {
|
|
|
264
438
|
wikiName: string;
|
|
265
439
|
sourcePages: PageLink[];
|
|
266
440
|
conceptPages: PageLink[];
|
|
441
|
+
categories?: CategorySpec[];
|
|
267
442
|
}): string {
|
|
268
443
|
const content = `
|
|
269
444
|
<div class="graph-page">
|
|
@@ -284,14 +459,16 @@ export function renderGraph(opts: {
|
|
|
284
459
|
sourcePages: opts.sourcePages,
|
|
285
460
|
conceptPages: opts.conceptPages,
|
|
286
461
|
content,
|
|
462
|
+
categories: opts.categories,
|
|
287
463
|
});
|
|
288
464
|
}
|
|
289
465
|
|
|
290
466
|
export function renderQuizPage(opts: {
|
|
291
467
|
wikiName: string;
|
|
292
|
-
quizzes: Array<{ id: number; question: string; answer: string; quiz_type: string; page_title?: string; page_slug?: string }>;
|
|
468
|
+
quizzes: Array<{ id: number; question: string; answer: string; explanation?: string; quiz_type: string; page_title?: string; page_slug?: string }>;
|
|
293
469
|
sourcePages: PageLink[];
|
|
294
470
|
conceptPages: PageLink[];
|
|
471
|
+
categories?: CategorySpec[];
|
|
295
472
|
}): string {
|
|
296
473
|
const quizzesJson = JSON.stringify(opts.quizzes).replace(/</g, "\\u003c");
|
|
297
474
|
|
|
@@ -327,7 +504,11 @@ export function renderQuizPage(opts: {
|
|
|
327
504
|
<div id="quiz-result-icon" class="quiz-result-icon"></div>
|
|
328
505
|
<p class="quiz-answer-label">정답</p>
|
|
329
506
|
<p class="quiz-answer-text" id="quiz-answer-text"></p>
|
|
507
|
+
<div id="quiz-explanation" class="quiz-explanation" style="display:none;">
|
|
508
|
+
<p id="quiz-explanation-text" class="explanation-text"></p>
|
|
509
|
+
</div>
|
|
330
510
|
<p class="quiz-source" id="quiz-source"></p>
|
|
511
|
+
<p class="quiz-review-info" id="quiz-review-info" style="display:none;"></p>
|
|
331
512
|
<button id="quiz-next-btn" class="quiz-btn primary">다음 문제 →</button>
|
|
332
513
|
</div>
|
|
333
514
|
</div>
|
|
@@ -343,6 +524,11 @@ export function renderQuizPage(opts: {
|
|
|
343
524
|
<div id="quiz-score-bar" class="quiz-score-bar"></div>
|
|
344
525
|
</div>
|
|
345
526
|
<p id="quiz-score-msg" class="quiz-score-msg"></p>
|
|
527
|
+
<div id="quiz-stats" class="quiz-stats" style="display:none;">
|
|
528
|
+
<h3>📊 학습 통계</h3>
|
|
529
|
+
<p id="quiz-stats-summary"></p>
|
|
530
|
+
<p id="quiz-stats-weak" style="display:none;"></p>
|
|
531
|
+
</div>
|
|
346
532
|
<button id="quiz-restart-btn" class="quiz-btn primary">🔄 다시 풀기</button>
|
|
347
533
|
</div>
|
|
348
534
|
</div>
|
|
@@ -395,6 +581,12 @@ export function renderQuizPage(opts: {
|
|
|
395
581
|
.quiz-score-bar-container { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; margin: 16px 0 20px; }
|
|
396
582
|
.quiz-score-bar { height: 100%; background: var(--accent, #4caf50); border-radius: 4px; transition: width 0.5s ease; }
|
|
397
583
|
.quiz-score-msg { font-size: 16px; color: var(--text-muted); margin-bottom: 24px; }
|
|
584
|
+
.quiz-explanation { background: var(--accent-light, #e8f5e9); border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; text-align: left; }
|
|
585
|
+
.explanation-text { font-size: 14px; line-height: 1.6; color: var(--text, #333); margin: 0; }
|
|
586
|
+
.quiz-stats { background: var(--bg-alt, #f5f5f5); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 20px; text-align: left; }
|
|
587
|
+
.quiz-stats h3 { font-size: 15px; margin: 0 0 8px; }
|
|
588
|
+
.quiz-stats p { font-size: 14px; color: var(--text-muted); margin: 4px 0; }
|
|
589
|
+
.quiz-review-info { font-size: 13px; color: var(--text-muted); margin-bottom: 12px; padding: 6px 12px; background: var(--accent-light, #e8f5e9); border-radius: 6px; display: inline-block; }
|
|
398
590
|
</style>
|
|
399
591
|
<script>
|
|
400
592
|
(function() {
|
|
@@ -465,19 +657,61 @@ export function renderQuizPage(opts: {
|
|
|
465
657
|
|
|
466
658
|
function checkAnswer(userAnswer) {
|
|
467
659
|
const q = quizzes[current];
|
|
468
|
-
const isCorrect = normalize(userAnswer) === normalize(q.answer)
|
|
660
|
+
const isCorrect = normalize(userAnswer) === normalize(q.answer);
|
|
469
661
|
|
|
470
662
|
if (isCorrect) score++;
|
|
471
663
|
|
|
664
|
+
// SM-2 spaced repetition in localStorage
|
|
665
|
+
var quality = isCorrect ? 4 : 1;
|
|
666
|
+
var srsData = JSON.parse(localStorage.getItem('kiwimu-srs') || '{}');
|
|
667
|
+
var srs = srsData[q.id] || { ef: 2.5, interval: 0 };
|
|
668
|
+
if (quality >= 3) {
|
|
669
|
+
if (srs.interval === 0) srs.interval = 1;
|
|
670
|
+
else if (srs.interval === 1) srs.interval = 6;
|
|
671
|
+
else srs.interval = Math.round(srs.interval * srs.ef);
|
|
672
|
+
srs.ef = srs.ef + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
|
|
673
|
+
} else {
|
|
674
|
+
srs.interval = 0;
|
|
675
|
+
}
|
|
676
|
+
if (srs.ef < 1.3) srs.ef = 1.3;
|
|
677
|
+
var nextDate = new Date();
|
|
678
|
+
nextDate.setDate(nextDate.getDate() + srs.interval);
|
|
679
|
+
srs.nextReview = nextDate.toISOString();
|
|
680
|
+
srsData[q.id] = srs;
|
|
681
|
+
localStorage.setItem('kiwimu-srs', JSON.stringify(srsData));
|
|
682
|
+
|
|
683
|
+
// Record attempt in localStorage
|
|
684
|
+
var attempts = JSON.parse(localStorage.getItem('kiwimu-quiz-attempts') || '[]');
|
|
685
|
+
attempts.push({ quizId: q.id, isCorrect: isCorrect, quality: quality, timestamp: new Date().toISOString() });
|
|
686
|
+
localStorage.setItem('kiwimu-quiz-attempts', JSON.stringify(attempts));
|
|
687
|
+
|
|
472
688
|
document.getElementById('quiz-result-icon').textContent = isCorrect ? '🎉' : '😅';
|
|
473
689
|
document.getElementById('quiz-answer-text').innerHTML = esc(q.answer);
|
|
474
690
|
document.getElementById('quiz-answer-text').style.color = isCorrect ? 'var(--accent, #4caf50)' : '#e53935';
|
|
475
691
|
|
|
692
|
+
// Show next review info
|
|
693
|
+
var reviewInfoEl = document.getElementById('quiz-review-info');
|
|
694
|
+
if (srs.interval === 0) {
|
|
695
|
+
reviewInfoEl.textContent = '🔄 다음 복습: 오늘';
|
|
696
|
+
} else {
|
|
697
|
+
reviewInfoEl.textContent = '📅 다음 복습: ' + srs.interval + '일 후';
|
|
698
|
+
}
|
|
699
|
+
reviewInfoEl.style.display = 'block';
|
|
700
|
+
|
|
701
|
+
// Show explanation if available
|
|
702
|
+
var explanationEl = document.getElementById('quiz-explanation');
|
|
703
|
+
if (q.explanation) {
|
|
704
|
+
document.getElementById('quiz-explanation-text').textContent = '💡 ' + q.explanation;
|
|
705
|
+
explanationEl.style.display = 'block';
|
|
706
|
+
} else {
|
|
707
|
+
explanationEl.style.display = 'none';
|
|
708
|
+
}
|
|
709
|
+
|
|
476
710
|
const sourceEl = document.getElementById('quiz-source');
|
|
477
711
|
if (q.page_slug) {
|
|
478
712
|
const a = document.createElement('a');
|
|
479
713
|
a.href = '/wiki/' + encodeURIComponent(q.page_slug) + '.html';
|
|
480
|
-
a.textContent = q.page_title || q.page_slug;
|
|
714
|
+
a.textContent = '📖 ' + (q.page_title || q.page_slug) + ' 보기';
|
|
481
715
|
sourceEl.textContent = '출처: ';
|
|
482
716
|
sourceEl.appendChild(a);
|
|
483
717
|
} else {
|
|
@@ -509,6 +743,35 @@ export function renderQuizPage(opts: {
|
|
|
509
743
|
|
|
510
744
|
const msgs = pct >= 90 ? '🏆 완벽에 가깝습니다!' : pct >= 70 ? '👏 잘 하셨습니다!' : pct >= 50 ? '📚 조금 더 복습해보세요!' : '💪 다시 도전해보세요!';
|
|
511
745
|
document.getElementById('quiz-score-msg').textContent = msgs;
|
|
746
|
+
|
|
747
|
+
// Show cumulative stats from localStorage
|
|
748
|
+
var allAttempts = JSON.parse(localStorage.getItem('kiwimu-quiz-attempts') || '[]');
|
|
749
|
+
if (allAttempts.length > 0) {
|
|
750
|
+
var totalAttempts = allAttempts.length;
|
|
751
|
+
var correctAttempts = allAttempts.filter(function(a) { return a.isCorrect; }).length;
|
|
752
|
+
var overallPct = Math.round(correctAttempts / totalAttempts * 100);
|
|
753
|
+
|
|
754
|
+
var statsEl = document.getElementById('quiz-stats');
|
|
755
|
+
statsEl.style.display = 'block';
|
|
756
|
+
document.getElementById('quiz-stats-summary').textContent = '전체 시도: ' + totalAttempts + '회 | 정답률: ' + overallPct + '%';
|
|
757
|
+
|
|
758
|
+
// Find weak concepts (most wrong answers by page)
|
|
759
|
+
var wrongByPage = {};
|
|
760
|
+
allAttempts.forEach(function(a) {
|
|
761
|
+
if (!a.isCorrect) {
|
|
762
|
+
var q = ALL_QUIZZES.find(function(quiz) { return quiz.id === a.quizId; });
|
|
763
|
+
if (q && q.page_title) {
|
|
764
|
+
wrongByPage[q.page_title] = (wrongByPage[q.page_title] || 0) + 1;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
var weakConcepts = Object.keys(wrongByPage).sort(function(a, b) { return wrongByPage[b] - wrongByPage[a]; }).slice(0, 3);
|
|
769
|
+
if (weakConcepts.length > 0) {
|
|
770
|
+
var weakEl = document.getElementById('quiz-stats-weak');
|
|
771
|
+
weakEl.style.display = 'block';
|
|
772
|
+
weakEl.textContent = '💪 약한 개념: ' + weakConcepts.join(', ');
|
|
773
|
+
}
|
|
774
|
+
}
|
|
512
775
|
}
|
|
513
776
|
|
|
514
777
|
// Event listeners
|
|
@@ -541,6 +804,132 @@ export function renderQuizPage(opts: {
|
|
|
541
804
|
});
|
|
542
805
|
}
|
|
543
806
|
|
|
807
|
+
export function renderDashboardPage(opts: {
|
|
808
|
+
wikiName: string;
|
|
809
|
+
stats: { total: number; mastered: number; learning: number; new: number; dueToday: number };
|
|
810
|
+
weakConcepts: Array<{ title: string; slug: string; wrongCount: number }>;
|
|
811
|
+
recentAttempts: Array<{ quiz_id: number; question: string; is_correct: boolean; attempted_at: string }>;
|
|
812
|
+
sourcePages: PageLink[];
|
|
813
|
+
conceptPages: PageLink[];
|
|
814
|
+
categories?: CategorySpec[];
|
|
815
|
+
}): string {
|
|
816
|
+
const { stats } = opts;
|
|
817
|
+
const progressPct = stats.total > 0 ? Math.round((stats.mastered / stats.total) * 100) : 0;
|
|
818
|
+
|
|
819
|
+
const weakConceptsHtml = opts.weakConcepts.length > 0
|
|
820
|
+
? opts.weakConcepts.map(c =>
|
|
821
|
+
`<li><a href="/wiki/${c.slug}.html">${escapeHtml(c.title)}</a> <span class="dash-weak-count">오답 ${c.wrongCount}회</span></li>`
|
|
822
|
+
).join("")
|
|
823
|
+
: `<li class="dash-empty">아직 데이터가 없습니다.</li>`;
|
|
824
|
+
|
|
825
|
+
const recentHtml = opts.recentAttempts.length > 0
|
|
826
|
+
? opts.recentAttempts.map(a => {
|
|
827
|
+
const icon = a.is_correct ? '✅' : '❌';
|
|
828
|
+
const date = a.attempted_at ? a.attempted_at.slice(0, 10) : '';
|
|
829
|
+
return `<li>${icon} <span class="dash-q">${escapeHtml(a.question.length > 60 ? a.question.slice(0, 57) + '...' : a.question)}</span> <span class="dash-date">${date}</span></li>`;
|
|
830
|
+
}).join("")
|
|
831
|
+
: `<li class="dash-empty">아직 시도한 퀴즈가 없습니다.</li>`;
|
|
832
|
+
|
|
833
|
+
const content = `
|
|
834
|
+
<div class="dash-page">
|
|
835
|
+
<h1>📊 학습 대시보드</h1>
|
|
836
|
+
<p class="dash-desc">스페이스드 리피티션(SM-2) 기반 학습 현황을 확인하세요.</p>
|
|
837
|
+
|
|
838
|
+
<div class="dash-cards">
|
|
839
|
+
<div class="dash-card">
|
|
840
|
+
<div class="dash-card-value">${stats.total}</div>
|
|
841
|
+
<div class="dash-card-label">전체 문제</div>
|
|
842
|
+
</div>
|
|
843
|
+
<div class="dash-card dash-card-mastered">
|
|
844
|
+
<div class="dash-card-value">${stats.mastered}</div>
|
|
845
|
+
<div class="dash-card-label">숙달</div>
|
|
846
|
+
</div>
|
|
847
|
+
<div class="dash-card dash-card-learning">
|
|
848
|
+
<div class="dash-card-value">${stats.learning}</div>
|
|
849
|
+
<div class="dash-card-label">학습중</div>
|
|
850
|
+
</div>
|
|
851
|
+
<div class="dash-card dash-card-new">
|
|
852
|
+
<div class="dash-card-value">${stats.new}</div>
|
|
853
|
+
<div class="dash-card-label">새 문제</div>
|
|
854
|
+
</div>
|
|
855
|
+
<div class="dash-card dash-card-due">
|
|
856
|
+
<div class="dash-card-value">${stats.dueToday}</div>
|
|
857
|
+
<div class="dash-card-label">오늘 복습</div>
|
|
858
|
+
</div>
|
|
859
|
+
</div>
|
|
860
|
+
|
|
861
|
+
<div class="dash-progress-section">
|
|
862
|
+
<h2>📈 숙달 진행률</h2>
|
|
863
|
+
<div class="dash-progress-bar-container">
|
|
864
|
+
<div class="dash-progress-bar" style="width:${progressPct}%"></div>
|
|
865
|
+
</div>
|
|
866
|
+
<p class="dash-progress-text">${stats.mastered} / ${stats.total} 문제 숙달 (${progressPct}%)</p>
|
|
867
|
+
</div>
|
|
868
|
+
|
|
869
|
+
<div class="dash-columns">
|
|
870
|
+
<div class="dash-section">
|
|
871
|
+
<h2>💪 약한 개념</h2>
|
|
872
|
+
<ul class="dash-list">${weakConceptsHtml}</ul>
|
|
873
|
+
</div>
|
|
874
|
+
<div class="dash-section">
|
|
875
|
+
<h2>🕐 최근 시도</h2>
|
|
876
|
+
<ul class="dash-list">${recentHtml}</ul>
|
|
877
|
+
</div>
|
|
878
|
+
</div>
|
|
879
|
+
|
|
880
|
+
<div class="dash-action">
|
|
881
|
+
<a href="/quiz.html" class="dash-review-btn">📝 복습 시작</a>
|
|
882
|
+
</div>
|
|
883
|
+
</div>
|
|
884
|
+
<style>
|
|
885
|
+
.dash-page { max-width: 800px; margin: 0 auto; padding: 24px 16px; }
|
|
886
|
+
.dash-page h1 { font-size: 24px; margin-bottom: 8px; }
|
|
887
|
+
.dash-page h2 { font-size: 18px; margin-bottom: 12px; }
|
|
888
|
+
.dash-desc { color: var(--text-muted); font-size: 14px; margin-bottom: 24px; }
|
|
889
|
+
.dash-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 28px; }
|
|
890
|
+
.dash-card {
|
|
891
|
+
background: var(--bg-alt, #fff); border: 1px solid var(--border); border-radius: 10px;
|
|
892
|
+
padding: 16px; text-align: center;
|
|
893
|
+
}
|
|
894
|
+
.dash-card-value { font-size: 28px; font-weight: 800; color: var(--text); }
|
|
895
|
+
.dash-card-label { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
|
|
896
|
+
.dash-card-mastered .dash-card-value { color: #2e7d32; }
|
|
897
|
+
.dash-card-learning .dash-card-value { color: #f9a825; }
|
|
898
|
+
.dash-card-new .dash-card-value { color: #1565c0; }
|
|
899
|
+
.dash-card-due .dash-card-value { color: #e53935; }
|
|
900
|
+
.dash-progress-section { margin-bottom: 28px; }
|
|
901
|
+
.dash-progress-bar-container { height: 10px; background: var(--border); border-radius: 5px; overflow: hidden; margin: 8px 0; }
|
|
902
|
+
.dash-progress-bar { height: 100%; background: #2e7d32; border-radius: 5px; transition: width 0.5s ease; }
|
|
903
|
+
.dash-progress-text { font-size: 14px; color: var(--text-muted); }
|
|
904
|
+
.dash-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 28px; }
|
|
905
|
+
@media (max-width: 600px) { .dash-columns { grid-template-columns: 1fr; } }
|
|
906
|
+
.dash-section { background: var(--bg-alt, #fff); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
|
|
907
|
+
.dash-list { list-style: none; padding: 0; margin: 0; }
|
|
908
|
+
.dash-list li { padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
|
909
|
+
.dash-list li:last-child { border-bottom: none; }
|
|
910
|
+
.dash-list a { color: var(--accent, #4caf50); text-decoration: none; }
|
|
911
|
+
.dash-list a:hover { text-decoration: underline; }
|
|
912
|
+
.dash-weak-count { font-size: 12px; color: #e53935; margin-left: auto; white-space: nowrap; }
|
|
913
|
+
.dash-date { font-size: 12px; color: var(--text-muted); margin-left: auto; white-space: nowrap; }
|
|
914
|
+
.dash-q { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
915
|
+
.dash-empty { color: var(--text-muted); font-style: italic; }
|
|
916
|
+
.dash-action { text-align: center; margin-top: 8px; }
|
|
917
|
+
.dash-review-btn {
|
|
918
|
+
display: inline-block; padding: 12px 32px; background: var(--accent, #4caf50); color: white;
|
|
919
|
+
border-radius: 8px; font-size: 16px; font-weight: 600; text-decoration: none; transition: opacity 0.2s;
|
|
920
|
+
}
|
|
921
|
+
.dash-review-btn:hover { opacity: 0.9; }
|
|
922
|
+
</style>`;
|
|
923
|
+
|
|
924
|
+
return base({
|
|
925
|
+
title: `📊 학습 대시보드 — ${opts.wikiName}`,
|
|
926
|
+
wikiName: opts.wikiName,
|
|
927
|
+
sourcePages: opts.sourcePages,
|
|
928
|
+
conceptPages: opts.conceptPages,
|
|
929
|
+
content,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
544
933
|
export function renderAdmin(opts: {
|
|
545
934
|
wikiName: string;
|
|
546
935
|
sources: Array<{ id: number; uri: string; type: string; title: string; fetched_at: string }>;
|
|
@@ -602,7 +991,7 @@ export function renderAdmin(opts: {
|
|
|
602
991
|
<div class="topbar-links">
|
|
603
992
|
<a href="/index.html" class="btn-graph">🏠 홈</a>
|
|
604
993
|
<a href="/graph.html" class="btn-graph">📊 그래프</a>
|
|
605
|
-
<a href="/
|
|
994
|
+
<a href="/manage" class="btn-graph" style="border-color: var(--accent);">⚙️ 관리</a>
|
|
606
995
|
</div>
|
|
607
996
|
</nav>
|
|
608
997
|
<div class="admin-page">
|
|
@@ -634,7 +1023,7 @@ export function renderAdmin(opts: {
|
|
|
634
1023
|
</div>
|
|
635
1024
|
<div class="config-row">
|
|
636
1025
|
<span class="config-key">모델</span>
|
|
637
|
-
<input id="llm-model" class="config-input" value="${escapeHtml(opts.llmConfig.model)}" placeholder="gemini-
|
|
1026
|
+
<input id="llm-model" class="config-input" value="${escapeHtml(opts.llmConfig.model)}" placeholder="gemini-3.1-flash-lite-preview">
|
|
638
1027
|
</div>
|
|
639
1028
|
<div class="config-row">
|
|
640
1029
|
<span class="config-key">API Key</span>
|
|
@@ -781,7 +1170,7 @@ export function renderAdmin(opts: {
|
|
|
781
1170
|
|
|
782
1171
|
document.getElementById('llm-provider').addEventListener('change', (e) => {
|
|
783
1172
|
document.getElementById('endpoint-row').style.display = e.target.value === 'azure-openai' ? '' : 'none';
|
|
784
|
-
const models = { gemini: 'gemini-
|
|
1173
|
+
const models = { gemini: 'gemini-3.1-flash-lite-preview', 'azure-openai': 'gpt-5.4-nano', openai: 'gpt-5.4-nano', anthropic: 'claude-sonnet-4-6' };
|
|
785
1174
|
document.getElementById('llm-model').placeholder = models[e.target.value] || '';
|
|
786
1175
|
});
|
|
787
1176
|
document.getElementById('llm-form').addEventListener('submit', async (e) => {
|
|
@@ -867,3 +1256,322 @@ export function renderAdmin(opts: {
|
|
|
867
1256
|
</body>
|
|
868
1257
|
</html>`;
|
|
869
1258
|
}
|
|
1259
|
+
|
|
1260
|
+
export function renderCatalogPage(opts: {
|
|
1261
|
+
wikiName: string;
|
|
1262
|
+
categories: Array<{
|
|
1263
|
+
name: string;
|
|
1264
|
+
slug: string;
|
|
1265
|
+
description?: string;
|
|
1266
|
+
pages: Array<{ id: number; title: string; slug: string; type: string; linkCount: number }>;
|
|
1267
|
+
}>;
|
|
1268
|
+
totalPages: number;
|
|
1269
|
+
totalLinks: number;
|
|
1270
|
+
generatedAt: string;
|
|
1271
|
+
sourcePages: PageLink[];
|
|
1272
|
+
conceptPages: PageLink[];
|
|
1273
|
+
}): string {
|
|
1274
|
+
const categoriesHtml = opts.categories.map((cat) => {
|
|
1275
|
+
const pagesHtml = cat.pages.map((p) => {
|
|
1276
|
+
const typeBadge = p.type === 'source'
|
|
1277
|
+
? '<span class="catalog-badge source">📖 원본</span>'
|
|
1278
|
+
: '<span class="catalog-badge concept">📝 개념</span>';
|
|
1279
|
+
const linkBadge = p.linkCount > 0
|
|
1280
|
+
? `<span class="catalog-link-count" title="연결된 문서 수">🔗 ${p.linkCount}</span>`
|
|
1281
|
+
: '';
|
|
1282
|
+
return `<li class="catalog-item" data-title="${escapeHtml(p.title.toLowerCase())}">
|
|
1283
|
+
<a href="/wiki/${p.slug}.html">${escapeHtml(p.title)}</a>
|
|
1284
|
+
${typeBadge}
|
|
1285
|
+
${linkBadge}
|
|
1286
|
+
</li>`;
|
|
1287
|
+
}).join("\n");
|
|
1288
|
+
|
|
1289
|
+
return `
|
|
1290
|
+
<details class="catalog-category" open>
|
|
1291
|
+
<summary class="catalog-category-header">
|
|
1292
|
+
<span class="catalog-category-name">${escapeHtml(cat.name)}</span>
|
|
1293
|
+
<span class="catalog-category-count">${cat.pages.length}개 문서</span>
|
|
1294
|
+
</summary>
|
|
1295
|
+
${cat.description ? `<p class="catalog-category-desc">${escapeHtml(cat.description)}</p>` : ''}
|
|
1296
|
+
<ul class="catalog-list">${pagesHtml}</ul>
|
|
1297
|
+
</details>`;
|
|
1298
|
+
}).join("\n");
|
|
1299
|
+
|
|
1300
|
+
const content = `
|
|
1301
|
+
<div class="catalog-page">
|
|
1302
|
+
<h1>📑 문서 목록</h1>
|
|
1303
|
+
<p class="catalog-desc">전체 ${opts.totalPages}개 문서 · ${opts.totalLinks}개 링크 · ${opts.categories.length}개 카테고리</p>
|
|
1304
|
+
|
|
1305
|
+
<div class="catalog-filter">
|
|
1306
|
+
<input type="text" id="catalog-search" placeholder="문서 이름으로 검색..." autocomplete="off">
|
|
1307
|
+
</div>
|
|
1308
|
+
|
|
1309
|
+
<div id="catalog-categories">
|
|
1310
|
+
${categoriesHtml || '<p class="catalog-empty">아직 문서가 없습니다. 소스를 추가하면 자동으로 목록이 생성됩니다.</p>'}
|
|
1311
|
+
</div>
|
|
1312
|
+
</div>
|
|
1313
|
+
<script>
|
|
1314
|
+
(function() {
|
|
1315
|
+
const input = document.getElementById('catalog-search');
|
|
1316
|
+
if (!input) return;
|
|
1317
|
+
input.addEventListener('input', function() {
|
|
1318
|
+
const q = this.value.toLowerCase().trim();
|
|
1319
|
+
document.querySelectorAll('.catalog-item').forEach(function(item) {
|
|
1320
|
+
const title = item.getAttribute('data-title') || '';
|
|
1321
|
+
item.style.display = (!q || title.includes(q)) ? '' : 'none';
|
|
1322
|
+
});
|
|
1323
|
+
// Hide empty categories
|
|
1324
|
+
document.querySelectorAll('.catalog-category').forEach(function(cat) {
|
|
1325
|
+
const visible = cat.querySelectorAll('.catalog-item[style=""], .catalog-item:not([style])');
|
|
1326
|
+
const allItems = cat.querySelectorAll('.catalog-item');
|
|
1327
|
+
let visibleCount = 0;
|
|
1328
|
+
allItems.forEach(function(item) { if (item.style.display !== 'none') visibleCount++; });
|
|
1329
|
+
cat.style.display = (q && visibleCount === 0) ? 'none' : '';
|
|
1330
|
+
});
|
|
1331
|
+
});
|
|
1332
|
+
})();
|
|
1333
|
+
</script>`;
|
|
1334
|
+
|
|
1335
|
+
return base({
|
|
1336
|
+
title: `문서 목록 - ${opts.wikiName}`,
|
|
1337
|
+
wikiName: opts.wikiName,
|
|
1338
|
+
sourcePages: opts.sourcePages,
|
|
1339
|
+
conceptPages: opts.conceptPages,
|
|
1340
|
+
// NOTE: renderCatalogPage's `opts.categories` has its own shape
|
|
1341
|
+
// (catalog UI categories with `.pages`), distinct from SourceCategory.
|
|
1342
|
+
// We deliberately don't forward it to `base()` to avoid the name clash;
|
|
1343
|
+
// catalog page's sidebar therefore uses the flat fallback rendering.
|
|
1344
|
+
description: `${opts.wikiName} 전체 문서 목록 — ${opts.totalPages}개 문서`,
|
|
1345
|
+
content,
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
export function renderProvenancePage(opts: {
|
|
1350
|
+
wikiName: string;
|
|
1351
|
+
coverage: Array<{
|
|
1352
|
+
sourceId: number;
|
|
1353
|
+
sourceTitle: string;
|
|
1354
|
+
citationCount: number;
|
|
1355
|
+
pageCount: number;
|
|
1356
|
+
pages: Array<{ title: string; slug: string }>;
|
|
1357
|
+
}>;
|
|
1358
|
+
sourcePages: PageLink[];
|
|
1359
|
+
conceptPages: PageLink[];
|
|
1360
|
+
categories?: CategorySpec[];
|
|
1361
|
+
}): string {
|
|
1362
|
+
const totalCitations = opts.coverage.reduce((s, c) => s + c.citationCount, 0);
|
|
1363
|
+
const totalSourcesCited = opts.coverage.filter(c => c.citationCount > 0).length;
|
|
1364
|
+
|
|
1365
|
+
const rows = opts.coverage.map(c => {
|
|
1366
|
+
const pageLinks = c.pages.map(p =>
|
|
1367
|
+
`<a href="/wiki/${p.slug}.html" class="provenance-page-link">${escapeHtml(p.title)}</a>`
|
|
1368
|
+
).join(", ") || '<span class="text-muted">-</span>';
|
|
1369
|
+
|
|
1370
|
+
const barWidth = totalCitations > 0 ? Math.max(2, Math.round((c.citationCount / totalCitations) * 100)) : 0;
|
|
1371
|
+
const barColor = c.citationCount === 0 ? '#e0e0e0' : c.citationCount < 3 ? '#ffc107' : '#28a745';
|
|
1372
|
+
|
|
1373
|
+
return `<tr>
|
|
1374
|
+
<td>${escapeHtml(c.sourceTitle || 'Untitled')}</td>
|
|
1375
|
+
<td class="text-center">${c.citationCount}</td>
|
|
1376
|
+
<td class="text-center">${c.pageCount}</td>
|
|
1377
|
+
<td><div class="provenance-bar" style="width:${barWidth}%;background:${barColor}"></div></td>
|
|
1378
|
+
<td class="provenance-pages">${pageLinks}</td>
|
|
1379
|
+
</tr>`;
|
|
1380
|
+
}).join("\n");
|
|
1381
|
+
|
|
1382
|
+
const content = `
|
|
1383
|
+
<div class="provenance-page">
|
|
1384
|
+
<h1>Source Provenance</h1>
|
|
1385
|
+
<p class="provenance-summary">
|
|
1386
|
+
${totalCitations} citations across ${totalSourcesCited}/${opts.coverage.length} sources
|
|
1387
|
+
</p>
|
|
1388
|
+
|
|
1389
|
+
<table class="provenance-table">
|
|
1390
|
+
<thead>
|
|
1391
|
+
<tr>
|
|
1392
|
+
<th>Source</th>
|
|
1393
|
+
<th class="text-center">Citations</th>
|
|
1394
|
+
<th class="text-center">Pages</th>
|
|
1395
|
+
<th>Coverage</th>
|
|
1396
|
+
<th>Citing Pages</th>
|
|
1397
|
+
</tr>
|
|
1398
|
+
</thead>
|
|
1399
|
+
<tbody>
|
|
1400
|
+
${rows}
|
|
1401
|
+
</tbody>
|
|
1402
|
+
</table>
|
|
1403
|
+
|
|
1404
|
+
${opts.coverage.some(c => c.citationCount === 0) ? `
|
|
1405
|
+
<div class="provenance-warning">
|
|
1406
|
+
<strong>Uncited sources:</strong>
|
|
1407
|
+
${opts.coverage.filter(c => c.citationCount === 0).map(c => escapeHtml(c.sourceTitle || 'Untitled')).join(", ")}
|
|
1408
|
+
<br><small>Run <code>kiwimu cite</code> to retroactively generate citations for existing content.</small>
|
|
1409
|
+
</div>` : ''}
|
|
1410
|
+
</div>
|
|
1411
|
+
|
|
1412
|
+
<style>
|
|
1413
|
+
.provenance-page { max-width: 960px; margin: 0 auto; }
|
|
1414
|
+
.provenance-page h1 { margin-bottom: 8px; }
|
|
1415
|
+
.provenance-summary { color: var(--text-muted); margin-bottom: 24px; }
|
|
1416
|
+
.provenance-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
1417
|
+
.provenance-table th, .provenance-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); text-align: left; }
|
|
1418
|
+
.provenance-table th { font-weight: 600; background: var(--bg-secondary, #f8f9fa); }
|
|
1419
|
+
.text-center { text-align: center !important; }
|
|
1420
|
+
.text-muted { color: var(--text-muted, #999); }
|
|
1421
|
+
.provenance-bar { height: 8px; border-radius: 4px; min-width: 2px; }
|
|
1422
|
+
.provenance-pages { font-size: 12px; }
|
|
1423
|
+
.provenance-page-link { display: inline-block; margin: 2px 4px 2px 0; padding: 1px 6px; background: var(--bg-secondary, #f0f0f0); border-radius: 3px; text-decoration: none; color: var(--namu-green, #2e7d32); }
|
|
1424
|
+
.provenance-page-link:hover { background: var(--namu-green, #2e7d32); color: white; }
|
|
1425
|
+
.provenance-warning { margin-top: 24px; padding: 12px 16px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 6px; font-size: 13px; }
|
|
1426
|
+
</style>`;
|
|
1427
|
+
|
|
1428
|
+
return base({
|
|
1429
|
+
title: `Source Provenance - ${opts.wikiName}`,
|
|
1430
|
+
wikiName: opts.wikiName,
|
|
1431
|
+
sourcePages: opts.sourcePages,
|
|
1432
|
+
conceptPages: opts.conceptPages,
|
|
1433
|
+
content,
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
export function renderActivityPage(
|
|
1438
|
+
authToken: string,
|
|
1439
|
+
wikiName: string,
|
|
1440
|
+
stats: { total: number; byAction: Record<string, number>; recentDays: { date: string; count: number }[] }
|
|
1441
|
+
): string {
|
|
1442
|
+
const actionIcons: Record<string, string> = {
|
|
1443
|
+
ingest: "\u{1F4E5}", page_created: "\u{1F4C4}", page_updated: "\u270F\uFE0F", quiz_generated: "\u{1F9E9}",
|
|
1444
|
+
quiz_attempted: "\u{1F4DD}", query: "\u2753", build: "\u{1F528}", deploy: "\u{1F680}", expand: "\u{1F9E0}",
|
|
1445
|
+
};
|
|
1446
|
+
const actionLabels: Record<string, string> = {
|
|
1447
|
+
ingest: "Ingest", page_created: "Page Created", page_updated: "Page Updated",
|
|
1448
|
+
quiz_generated: "Quiz Generated", quiz_attempted: "Quiz Attempted", query: "Q&A",
|
|
1449
|
+
build: "Build", deploy: "Deploy", expand: "Expand",
|
|
1450
|
+
};
|
|
1451
|
+
const filterButtons = Object.entries(stats.byAction)
|
|
1452
|
+
.map(([action, count]) => `<button class="filter-btn" data-action="${action}">${actionIcons[action] || "\u{1F4CC}"} ${actionLabels[action] || action} <span class="count">(${count})</span></button>`)
|
|
1453
|
+
.join("\n ");
|
|
1454
|
+
|
|
1455
|
+
return `<!DOCTYPE html>
|
|
1456
|
+
<html lang="ko">
|
|
1457
|
+
<head>
|
|
1458
|
+
<meta charset="UTF-8">
|
|
1459
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1460
|
+
<meta name="kiwi-auth" content="${authToken}">
|
|
1461
|
+
<title>Activity Log - ${wikiName}</title>
|
|
1462
|
+
<style>
|
|
1463
|
+
:root { --bg: #fff; --fg: #1a1a2e; --card-bg: #f8f9fa; --border: #e0e0e0; --accent: #4a90d9; --muted: #6c757d; --badge-bg: #e8f0fe; --badge-fg: #1a73e8; }
|
|
1464
|
+
@media (prefers-color-scheme: dark) {
|
|
1465
|
+
:root { --bg: #1a1a2e; --fg: #e0e0e0; --card-bg: #16213e; --border: #2a2a4a; --accent: #64b5f6; --muted: #9e9e9e; --badge-bg: #1e3a5f; --badge-fg: #90caf9; }
|
|
1466
|
+
}
|
|
1467
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1468
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--fg); line-height: 1.6; }
|
|
1469
|
+
.container { max-width: 860px; margin: 0 auto; padding: 2rem 1rem; }
|
|
1470
|
+
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
|
1471
|
+
.subtitle { color: var(--muted); margin-bottom: 1.5rem; }
|
|
1472
|
+
.filters { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem; }
|
|
1473
|
+
.filter-btn { background: var(--card-bg); border: 1px solid var(--border); border-radius: 1rem; padding: 0.3rem 0.8rem; cursor: pointer; font-size: 0.85rem; color: var(--fg); transition: all 0.15s; }
|
|
1474
|
+
.filter-btn:hover, .filter-btn.active { background: var(--badge-bg); color: var(--badge-fg); border-color: var(--accent); }
|
|
1475
|
+
.filter-btn .count { color: var(--muted); font-size: 0.75rem; }
|
|
1476
|
+
.timeline { list-style: none; border-left: 2px solid var(--border); padding-left: 1.5rem; }
|
|
1477
|
+
.timeline-item { position: relative; padding: 0.75rem 0; }
|
|
1478
|
+
.timeline-item::before { content: ""; position: absolute; left: -1.75rem; top: 1.1rem; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); border: 2px solid var(--bg); }
|
|
1479
|
+
.timeline-item .time { font-size: 0.75rem; color: var(--muted); }
|
|
1480
|
+
.timeline-item .badge { display: inline-block; background: var(--badge-bg); color: var(--badge-fg); font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 0.75rem; margin-left: 0.5rem; }
|
|
1481
|
+
.timeline-item .title { font-weight: 500; margin-top: 0.15rem; }
|
|
1482
|
+
.timeline-item .details { font-size: 0.8rem; color: var(--muted); margin-top: 0.15rem; }
|
|
1483
|
+
.load-more { display: block; width: 100%; padding: 0.6rem; margin-top: 1rem; background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; cursor: pointer; color: var(--fg); font-size: 0.9rem; text-align: center; }
|
|
1484
|
+
.load-more:hover { background: var(--badge-bg); }
|
|
1485
|
+
.empty { text-align: center; color: var(--muted); padding: 3rem; }
|
|
1486
|
+
a.back { color: var(--accent); text-decoration: none; font-size: 0.9rem; }
|
|
1487
|
+
a.back:hover { text-decoration: underline; }
|
|
1488
|
+
</style>
|
|
1489
|
+
</head>
|
|
1490
|
+
<body>
|
|
1491
|
+
<div class="container">
|
|
1492
|
+
<a class="back" href="/">← Back to Wiki</a>
|
|
1493
|
+
<h1>Activity Log</h1>
|
|
1494
|
+
<p class="subtitle">${stats.total} total events</p>
|
|
1495
|
+
<div class="filters">
|
|
1496
|
+
<button class="filter-btn active" data-action="">All (${stats.total})</button>
|
|
1497
|
+
${filterButtons}
|
|
1498
|
+
</div>
|
|
1499
|
+
<ul class="timeline" id="timeline"></ul>
|
|
1500
|
+
<button class="load-more" id="load-more">Load more</button>
|
|
1501
|
+
<div class="empty" id="empty" style="display:none;">No activity yet.</div>
|
|
1502
|
+
</div>
|
|
1503
|
+
<script>
|
|
1504
|
+
const authToken = document.querySelector('meta[name="kiwi-auth"]')?.content || '';
|
|
1505
|
+
const icons = ${JSON.stringify(actionIcons)};
|
|
1506
|
+
const labels = ${JSON.stringify(actionLabels)};
|
|
1507
|
+
let currentAction = '';
|
|
1508
|
+
let offset = 0;
|
|
1509
|
+
const limit = 50;
|
|
1510
|
+
|
|
1511
|
+
function formatTime(iso) {
|
|
1512
|
+
const d = new Date(iso + 'Z');
|
|
1513
|
+
const now = new Date();
|
|
1514
|
+
const diff = now - d;
|
|
1515
|
+
if (diff < 60000) return 'just now';
|
|
1516
|
+
if (diff < 3600000) return Math.floor(diff/60000) + 'm ago';
|
|
1517
|
+
if (diff < 86400000) return Math.floor(diff/3600000) + 'h ago';
|
|
1518
|
+
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function esc(s) { return s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; }
|
|
1522
|
+
|
|
1523
|
+
function renderEntry(e) {
|
|
1524
|
+
const icon = icons[e.action] || '\u{1F4CC}';
|
|
1525
|
+
const label = labels[e.action] || e.action;
|
|
1526
|
+
let detailsHtml = '';
|
|
1527
|
+
if (e.details) {
|
|
1528
|
+
try {
|
|
1529
|
+
const d = JSON.parse(e.details);
|
|
1530
|
+
detailsHtml = '<span class="details">' + Object.entries(d).map(([k,v]) => esc(k) + ': ' + esc(String(v).slice(0,60))).join(' | ') + '</span>';
|
|
1531
|
+
} catch {}
|
|
1532
|
+
}
|
|
1533
|
+
return '<li class="timeline-item" data-action="' + esc(e.action) + '">' +
|
|
1534
|
+
'<span class="time">' + formatTime(e.created_at) + '</span>' +
|
|
1535
|
+
'<span class="badge">' + icon + ' ' + esc(label) + '</span>' +
|
|
1536
|
+
'<div class="title">' + esc(e.title || '') + '</div>' +
|
|
1537
|
+
detailsHtml + '</li>';
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
async function loadEntries(append) {
|
|
1541
|
+
const params = new URLSearchParams({ limit: String(limit), offset: String(offset), token: authToken });
|
|
1542
|
+
if (currentAction) params.set('action', currentAction);
|
|
1543
|
+
const res = await fetch('/api/activity?' + params);
|
|
1544
|
+
const data = await res.json();
|
|
1545
|
+
const entries = data.entries;
|
|
1546
|
+
const tl = document.getElementById('timeline');
|
|
1547
|
+
if (!append) tl.innerHTML = '';
|
|
1548
|
+
if (entries.length === 0 && offset === 0) {
|
|
1549
|
+
document.getElementById('empty').style.display = '';
|
|
1550
|
+
document.getElementById('load-more').style.display = 'none';
|
|
1551
|
+
} else {
|
|
1552
|
+
document.getElementById('empty').style.display = 'none';
|
|
1553
|
+
document.getElementById('load-more').style.display = entries.length < limit ? 'none' : '';
|
|
1554
|
+
tl.innerHTML += entries.map(renderEntry).join('');
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
document.querySelectorAll('.filter-btn').forEach(btn => {
|
|
1559
|
+
btn.addEventListener('click', () => {
|
|
1560
|
+
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
|
1561
|
+
btn.classList.add('active');
|
|
1562
|
+
currentAction = btn.dataset.action;
|
|
1563
|
+
offset = 0;
|
|
1564
|
+
loadEntries(false);
|
|
1565
|
+
});
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
document.getElementById('load-more').addEventListener('click', () => {
|
|
1569
|
+
offset += limit;
|
|
1570
|
+
loadEntries(true);
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
loadEntries(false);
|
|
1574
|
+
</script>
|
|
1575
|
+
</body>
|
|
1576
|
+
</html>`;
|
|
1577
|
+
}
|