@open330/kiwimu 0.3.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/LICENSE +21 -0
- package/README.md +230 -0
- package/assets/logos/logo_2_minimalist_icon.png +0 -0
- package/assets/logos/logo_2_minimalist_icon_transparent.png +0 -0
- package/package.json +62 -0
- package/src/build/renderer.ts +128 -0
- package/src/build/static/graph.js +114 -0
- package/src/build/static/search.js +66 -0
- package/src/build/static/style.css +853 -0
- package/src/build/templates.ts +616 -0
- package/src/config.ts +54 -0
- package/src/deploy.ts +32 -0
- package/src/expand/llm.ts +63 -0
- package/src/index.ts +615 -0
- package/src/ingest/docx.ts +15 -0
- package/src/ingest/legacy.ts +66 -0
- package/src/ingest/pdf.ts +14 -0
- package/src/ingest/pptx.ts +39 -0
- package/src/ingest/web.ts +77 -0
- package/src/llm-client.ts +177 -0
- package/src/pipeline/chunker.ts +63 -0
- package/src/pipeline/graph.ts +35 -0
- package/src/pipeline/linker.ts +49 -0
- package/src/pipeline/llm-chunker.ts +368 -0
- package/src/pipeline/llm-linker.ts +84 -0
- package/src/store.ts +209 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
interface PageLink {
|
|
2
|
+
slug: string;
|
|
3
|
+
title: string;
|
|
4
|
+
pageType?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function escapeHtml(s: string): string {
|
|
8
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sidebarHtml(sourcePages: PageLink[], conceptPages: PageLink[], activeSlug?: string): string {
|
|
12
|
+
const sourceItems = sourcePages
|
|
13
|
+
.map(
|
|
14
|
+
(p) =>
|
|
15
|
+
`<li><a href="/wiki/${p.slug}.html"${p.slug === activeSlug ? ' class="active"' : ""}>${escapeHtml(p.title)}</a></li>`
|
|
16
|
+
)
|
|
17
|
+
.join("\n");
|
|
18
|
+
|
|
19
|
+
const conceptItems = conceptPages
|
|
20
|
+
.map(
|
|
21
|
+
(p) =>
|
|
22
|
+
`<li><a href="/wiki/${p.slug}.html"${p.slug === activeSlug ? ' class="active"' : ""}>${escapeHtml(p.title)}</a></li>`
|
|
23
|
+
)
|
|
24
|
+
.join("\n");
|
|
25
|
+
|
|
26
|
+
// Determine which tab is active
|
|
27
|
+
const activeIsSource = sourcePages.some((p) => p.slug === activeSlug);
|
|
28
|
+
|
|
29
|
+
return `
|
|
30
|
+
<div class="sidebar-tabs">
|
|
31
|
+
<button class="sidebar-tab${activeIsSource || !activeSlug ? " active" : ""}" data-tab="source">π μλ³Έ (${sourcePages.length})</button>
|
|
32
|
+
<button class="sidebar-tab${!activeIsSource && activeSlug ? " active" : ""}" data-tab="concept">π κ°λ
(${conceptPages.length})</button>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="sidebar-panel${activeIsSource || !activeSlug ? " active" : ""}" id="tab-source">
|
|
35
|
+
<ul class="page-list">${sourceItems}</ul>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="sidebar-panel${!activeIsSource && activeSlug ? " active" : ""}" id="tab-concept">
|
|
38
|
+
<ul class="page-list">${conceptItems}</ul>
|
|
39
|
+
</div>`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function base(opts: {
|
|
43
|
+
title: string;
|
|
44
|
+
wikiName: string;
|
|
45
|
+
sourcePages: PageLink[];
|
|
46
|
+
conceptPages: PageLink[];
|
|
47
|
+
activeSlug?: string;
|
|
48
|
+
content: string;
|
|
49
|
+
}) {
|
|
50
|
+
return `<!DOCTYPE html>
|
|
51
|
+
<html lang="ko">
|
|
52
|
+
<head>
|
|
53
|
+
<meta charset="UTF-8">
|
|
54
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
55
|
+
<title>${escapeHtml(opts.title)}</title>
|
|
56
|
+
<link rel="stylesheet" href="/static/style.css">
|
|
57
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
|
58
|
+
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
|
59
|
+
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
|
60
|
+
</head>
|
|
61
|
+
<body>
|
|
62
|
+
<nav class="topbar">
|
|
63
|
+
<a href="/index.html" class="topbar-brand">
|
|
64
|
+
<img src="/static/logo.png" alt="Kiwi Mu" class="topbar-logo">
|
|
65
|
+
${escapeHtml(opts.wikiName)}
|
|
66
|
+
</a>
|
|
67
|
+
<div class="topbar-search">
|
|
68
|
+
<input type="text" id="search-input" placeholder="λ¬Έμ κ²μ..." autocomplete="off">
|
|
69
|
+
<div id="search-results" class="search-dropdown"></div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="topbar-links">
|
|
72
|
+
<a href="/graph.html" class="btn-graph">π κ·Έλν</a>
|
|
73
|
+
<a href="/admin" class="btn-graph">βοΈ κ΄λ¦¬</a>
|
|
74
|
+
</div>
|
|
75
|
+
</nav>
|
|
76
|
+
<div class="layout">
|
|
77
|
+
<aside class="sidebar">
|
|
78
|
+
${sidebarHtml(opts.sourcePages, opts.conceptPages, opts.activeSlug)}
|
|
79
|
+
</aside>
|
|
80
|
+
<main class="content">
|
|
81
|
+
${opts.content}
|
|
82
|
+
</main>
|
|
83
|
+
</div>
|
|
84
|
+
<script src="/static/search.js"></script>
|
|
85
|
+
<script>
|
|
86
|
+
// Sidebar tabs
|
|
87
|
+
document.querySelectorAll('.sidebar-tab').forEach(tab => {
|
|
88
|
+
tab.addEventListener('click', () => {
|
|
89
|
+
document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active'));
|
|
90
|
+
document.querySelectorAll('.sidebar-panel').forEach(p => p.classList.remove('active'));
|
|
91
|
+
tab.classList.add('active');
|
|
92
|
+
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
// KaTeX
|
|
96
|
+
document.addEventListener("DOMContentLoaded", function() {
|
|
97
|
+
renderMathInElement(document.body, {
|
|
98
|
+
delimiters: [
|
|
99
|
+
{left: "$$", right: "$$", display: true},
|
|
100
|
+
{left: "$", right: "$", display: false},
|
|
101
|
+
{left: "\\\\(", right: "\\\\)", display: false},
|
|
102
|
+
{left: "\\\\[", right: "\\\\]", display: true}
|
|
103
|
+
]
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
</script>
|
|
107
|
+
</body>
|
|
108
|
+
</html>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function renderPage(opts: {
|
|
112
|
+
wikiName: string;
|
|
113
|
+
pageTitle: string;
|
|
114
|
+
pageSlug: string;
|
|
115
|
+
pageType: string;
|
|
116
|
+
content: string;
|
|
117
|
+
externalRefs: string;
|
|
118
|
+
toc: string;
|
|
119
|
+
backlinks: PageLink[];
|
|
120
|
+
sourcePages: PageLink[];
|
|
121
|
+
conceptPages: PageLink[];
|
|
122
|
+
}): string {
|
|
123
|
+
const typeLabel = opts.pageType === "source" ? "π μλ³Έ λ¬Έμ" : "π κ°λ
λ¬Έμ";
|
|
124
|
+
const typeBadge = `<span class="page-type-badge ${opts.pageType}">${typeLabel}</span>`;
|
|
125
|
+
|
|
126
|
+
// Separate backlinks by type
|
|
127
|
+
const sourceBacklinks = opts.backlinks.filter((bl) => bl.pageType === "source");
|
|
128
|
+
const conceptBacklinks = opts.backlinks.filter((bl) => bl.pageType === "concept");
|
|
129
|
+
|
|
130
|
+
let backlinksHtml = "";
|
|
131
|
+
if (opts.backlinks.length) {
|
|
132
|
+
let inner = "";
|
|
133
|
+
if (sourceBacklinks.length) {
|
|
134
|
+
inner += `<div class="backlink-group"><span class="backlink-label">π μλ³Έ</span>${sourceBacklinks
|
|
135
|
+
.map((bl) => `<a href="/wiki/${bl.slug}.html" class="backlink-item source">${escapeHtml(bl.title)}</a>`)
|
|
136
|
+
.join("")}</div>`;
|
|
137
|
+
}
|
|
138
|
+
if (conceptBacklinks.length) {
|
|
139
|
+
inner += `<div class="backlink-group"><span class="backlink-label">π κ°λ
</span>${conceptBacklinks
|
|
140
|
+
.map((bl) => `<a href="/wiki/${bl.slug}.html" class="backlink-item concept">${escapeHtml(bl.title)}</a>`)
|
|
141
|
+
.join("")}</div>`;
|
|
142
|
+
}
|
|
143
|
+
backlinksHtml = `<aside class="backlinks"><h3>π μ΄ λ¬Έμλ₯Ό μ°Έμ‘°νλ λ¬Έμ</h3>${inner}</aside>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const externalRefsHtml = opts.externalRefs
|
|
147
|
+
? `<aside class="external-refs"><h3>π μΈλΆ μ°Έκ³ μλ£</h3>${opts.externalRefs}</aside>`
|
|
148
|
+
: "";
|
|
149
|
+
|
|
150
|
+
const tocHtml = opts.toc
|
|
151
|
+
? `<details class="toc-box" open><summary>λͺ©μ°¨</summary>${opts.toc}</details>`
|
|
152
|
+
: "";
|
|
153
|
+
|
|
154
|
+
const content = `
|
|
155
|
+
<article class="wiki-page">
|
|
156
|
+
<header class="page-header">
|
|
157
|
+
${typeBadge}
|
|
158
|
+
<h1>${escapeHtml(opts.pageTitle)}</h1>
|
|
159
|
+
</header>
|
|
160
|
+
${tocHtml}
|
|
161
|
+
<div class="page-body">${opts.content}</div>
|
|
162
|
+
${externalRefsHtml}
|
|
163
|
+
${backlinksHtml}
|
|
164
|
+
</article>`;
|
|
165
|
+
|
|
166
|
+
return base({
|
|
167
|
+
title: `${opts.pageTitle} - ${opts.wikiName}`,
|
|
168
|
+
wikiName: opts.wikiName,
|
|
169
|
+
sourcePages: opts.sourcePages,
|
|
170
|
+
conceptPages: opts.conceptPages,
|
|
171
|
+
activeSlug: opts.pageSlug,
|
|
172
|
+
content,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function renderIndex(opts: {
|
|
177
|
+
wikiName: string;
|
|
178
|
+
sourcePages: PageLink[];
|
|
179
|
+
conceptPages: PageLink[];
|
|
180
|
+
sourceCount: number;
|
|
181
|
+
}): string {
|
|
182
|
+
const sourceCards = opts.sourcePages
|
|
183
|
+
.map(
|
|
184
|
+
(p) =>
|
|
185
|
+
`<a href="/wiki/${p.slug}.html" class="page-card source"><span class="card-title">${escapeHtml(p.title)}</span></a>`
|
|
186
|
+
)
|
|
187
|
+
.join("\n");
|
|
188
|
+
|
|
189
|
+
const conceptCards = opts.conceptPages
|
|
190
|
+
.map(
|
|
191
|
+
(p) =>
|
|
192
|
+
`<a href="/wiki/${p.slug}.html" class="page-card concept"><span class="card-title">${escapeHtml(p.title)}</span></a>`
|
|
193
|
+
)
|
|
194
|
+
.join("\n");
|
|
195
|
+
|
|
196
|
+
const totalPages = opts.sourcePages.length + opts.conceptPages.length;
|
|
197
|
+
|
|
198
|
+
const content = `
|
|
199
|
+
<div class="index-page">
|
|
200
|
+
<div class="hero">
|
|
201
|
+
<img src="/static/logo.png" alt="Kiwi Mu" class="hero-logo">
|
|
202
|
+
<h1>${escapeHtml(opts.wikiName)}</h1>
|
|
203
|
+
<p class="hero-sub">λλ§μ νμ΅ μν€ Β· ${totalPages}κ° λ¬Έμ (π ${opts.sourcePages.length} + π ${opts.conceptPages.length}) Β· ${opts.sourceCount}κ° μμ€</p>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<div class="index-grid">
|
|
207
|
+
<!-- Add document form -->
|
|
208
|
+
<section class="index-section add-section">
|
|
209
|
+
<h2>β λ¬Έμ μΆκ°</h2>
|
|
210
|
+
<div class="add-tabs">
|
|
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>
|
|
232
|
+
</section>
|
|
233
|
+
|
|
234
|
+
<section class="index-section">
|
|
235
|
+
<h2>π μλ³Έ λ¬Έμ</h2>
|
|
236
|
+
<div class="page-cards">${sourceCards}</div>
|
|
237
|
+
</section>
|
|
238
|
+
<section class="index-section">
|
|
239
|
+
<h2>π κ°λ
λ¬Έμ</h2>
|
|
240
|
+
<div class="page-cards">${conceptCards}</div>
|
|
241
|
+
</section>
|
|
242
|
+
<section class="index-section">
|
|
243
|
+
<div class="quick-links">
|
|
244
|
+
<a href="/graph.html" class="quick-link">π μ§μ κ·Έλν 보기</a>
|
|
245
|
+
</div>
|
|
246
|
+
</section>
|
|
247
|
+
</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
|
+
});
|
|
259
|
+
|
|
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
|
+
|
|
384
|
+
return base({
|
|
385
|
+
title: `${opts.wikiName} - λλ¬Έ`,
|
|
386
|
+
wikiName: opts.wikiName,
|
|
387
|
+
sourcePages: opts.sourcePages,
|
|
388
|
+
conceptPages: opts.conceptPages,
|
|
389
|
+
content,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function renderGraph(opts: {
|
|
394
|
+
wikiName: string;
|
|
395
|
+
sourcePages: PageLink[];
|
|
396
|
+
conceptPages: PageLink[];
|
|
397
|
+
}): string {
|
|
398
|
+
const content = `
|
|
399
|
+
<div class="graph-page">
|
|
400
|
+
<h1>π μ§μ κ·Έλν</h1>
|
|
401
|
+
<p class="graph-desc">
|
|
402
|
+
<span class="legend-dot source"></span> μλ³Έ λ¬Έμ
|
|
403
|
+
<span class="legend-dot concept"></span> κ°λ
λ¬Έμ
|
|
404
|
+
Β· λ
Έλλ₯Ό ν΄λ¦νλ©΄ ν΄λΉ λ¬Έμλ‘ μ΄λν©λλ€
|
|
405
|
+
</p>
|
|
406
|
+
<div id="graph-container"></div>
|
|
407
|
+
</div>
|
|
408
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
409
|
+
<script src="/static/graph.js"></script>`;
|
|
410
|
+
|
|
411
|
+
return base({
|
|
412
|
+
title: `μ§μ κ·Έλν - ${opts.wikiName}`,
|
|
413
|
+
wikiName: opts.wikiName,
|
|
414
|
+
sourcePages: opts.sourcePages,
|
|
415
|
+
conceptPages: opts.conceptPages,
|
|
416
|
+
content,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function renderAdmin(opts: {
|
|
421
|
+
wikiName: string;
|
|
422
|
+
sources: Array<{ id: number; uri: string; type: string; title: string; fetched_at: string }>;
|
|
423
|
+
usage: { totalCalls: number; promptTokens: number; completionTokens: number; totalTokens: number; totalCost: number };
|
|
424
|
+
llmConfig: { provider: string; model: string; api_key: string; endpoint: string };
|
|
425
|
+
}): string {
|
|
426
|
+
const maskedKey = opts.llmConfig.api_key ? "β’β’β’β’" + opts.llmConfig.api_key.slice(-4) : "(λ―Έμ€μ )";
|
|
427
|
+
const sourceRows = opts.sources
|
|
428
|
+
.map(
|
|
429
|
+
(s) =>
|
|
430
|
+
`<tr><td>${s.id}</td><td><span class="badge">${s.type}</span></td><td>${escapeHtml(s.title || "")}</td><td class="uri-cell" title="${escapeHtml(s.uri)}">${escapeHtml(s.uri.length > 50 ? "..." + s.uri.slice(-47) : s.uri)}</td><td>${s.fetched_at}</td></tr>`
|
|
431
|
+
)
|
|
432
|
+
.join("");
|
|
433
|
+
|
|
434
|
+
return `<!DOCTYPE html>
|
|
435
|
+
<html lang="ko">
|
|
436
|
+
<head>
|
|
437
|
+
<meta charset="UTF-8">
|
|
438
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
439
|
+
<title>κ΄λ¦¬ - ${escapeHtml(opts.wikiName)}</title>
|
|
440
|
+
<link rel="stylesheet" href="/static/style.css">
|
|
441
|
+
<style>
|
|
442
|
+
.admin-page { max-width: 900px; margin: 80px auto; padding: 0 24px; }
|
|
443
|
+
.admin-page h1 { font-size: 24px; margin-bottom: 24px; }
|
|
444
|
+
.admin-section { margin-bottom: 32px; }
|
|
445
|
+
.admin-section h2 { font-size: 18px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
|
446
|
+
.admin-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
447
|
+
.admin-table th, .admin-table td { padding: 8px 12px; border: 1px solid var(--border); text-align: left; }
|
|
448
|
+
.admin-table th { background: var(--bg-alt); font-weight: 600; }
|
|
449
|
+
.uri-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
450
|
+
.badge { padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; }
|
|
451
|
+
.badge { background: var(--accent-light); color: #2e7d32; }
|
|
452
|
+
.config-card { background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
|
|
453
|
+
.config-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
|
454
|
+
.config-row:last-child { border: none; }
|
|
455
|
+
.config-key { font-weight: 600; color: var(--text-muted); min-width: 100px; }
|
|
456
|
+
.config-value { font-family: monospace; }
|
|
457
|
+
.config-input { flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 4px; font-size: 14px; font-family: monospace; }
|
|
458
|
+
.config-input:focus { outline: none; border-color: var(--accent); }
|
|
459
|
+
select.config-input { font-family: inherit; }
|
|
460
|
+
.config-hint { font-size: 12px; color: var(--text-muted); margin-left: 8px; }
|
|
461
|
+
.save-btn { padding: 6px 16px; background: var(--accent); color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; }
|
|
462
|
+
.save-btn:hover { background: #43a047; }
|
|
463
|
+
#save-status { font-size: 13px; margin-left: 8px; }
|
|
464
|
+
</style>
|
|
465
|
+
</head>
|
|
466
|
+
<body>
|
|
467
|
+
<nav class="topbar">
|
|
468
|
+
<a href="/index.html" class="topbar-brand">
|
|
469
|
+
<img src="/static/logo.png" alt="Kiwi Mu" class="topbar-logo">
|
|
470
|
+
${escapeHtml(opts.wikiName)}
|
|
471
|
+
</a>
|
|
472
|
+
<div class="topbar-links">
|
|
473
|
+
<a href="/index.html" class="btn-graph">π ν</a>
|
|
474
|
+
<a href="/graph.html" class="btn-graph">π κ·Έλν</a>
|
|
475
|
+
<a href="/admin" class="btn-graph" style="border-color: var(--accent);">βοΈ κ΄λ¦¬</a>
|
|
476
|
+
</div>
|
|
477
|
+
</nav>
|
|
478
|
+
<div class="admin-page">
|
|
479
|
+
<h1>βοΈ κ΄λ¦¬</h1>
|
|
480
|
+
|
|
481
|
+
<div class="admin-section">
|
|
482
|
+
<h2>π μΌλ° μ€μ </h2>
|
|
483
|
+
<form id="general-form" class="config-card">
|
|
484
|
+
<div class="config-row">
|
|
485
|
+
<span class="config-key">μν€ μ΄λ¦</span>
|
|
486
|
+
<input id="wiki-name" class="config-input" value="${escapeHtml(opts.wikiName)}">
|
|
487
|
+
<button type="submit" class="save-btn">πΎ μ μ₯</button>
|
|
488
|
+
<span id="general-save-status"></span>
|
|
489
|
+
</div>
|
|
490
|
+
</form>
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
<div class="admin-section">
|
|
494
|
+
<h2>π€ LLM μ€μ </h2>
|
|
495
|
+
<form id="llm-form" class="config-card">
|
|
496
|
+
<div class="config-row">
|
|
497
|
+
<span class="config-key">νλ‘λ°μ΄λ</span>
|
|
498
|
+
<select id="llm-provider" class="config-input">
|
|
499
|
+
<option value="gemini"${opts.llmConfig.provider === "gemini" ? " selected" : ""}>Google Gemini</option>
|
|
500
|
+
<option value="azure-openai"${opts.llmConfig.provider === "azure-openai" ? " selected" : ""}>Azure OpenAI</option>
|
|
501
|
+
<option value="openai"${opts.llmConfig.provider === "openai" ? " selected" : ""}>OpenAI</option>
|
|
502
|
+
<option value="anthropic"${opts.llmConfig.provider === "anthropic" ? " selected" : ""}>Anthropic</option>
|
|
503
|
+
</select>
|
|
504
|
+
</div>
|
|
505
|
+
<div class="config-row">
|
|
506
|
+
<span class="config-key">λͺ¨λΈ</span>
|
|
507
|
+
<input id="llm-model" class="config-input" value="${escapeHtml(opts.llmConfig.model)}" placeholder="gemini-2.0-flash-lite">
|
|
508
|
+
</div>
|
|
509
|
+
<div class="config-row">
|
|
510
|
+
<span class="config-key">API Key</span>
|
|
511
|
+
<input id="llm-key" class="config-input" type="password" placeholder="API ν€ μ
λ ₯..." value="">
|
|
512
|
+
<span class="config-hint">${maskedKey}</span>
|
|
513
|
+
</div>
|
|
514
|
+
<div class="config-row" id="endpoint-row" style="${opts.llmConfig.provider === "azure-openai" ? "" : "display:none"}">
|
|
515
|
+
<span class="config-key">Endpoint</span>
|
|
516
|
+
<input id="llm-endpoint" class="config-input" value="${escapeHtml(opts.llmConfig.endpoint)}" placeholder="https://...">
|
|
517
|
+
</div>
|
|
518
|
+
<div class="config-row">
|
|
519
|
+
<span></span>
|
|
520
|
+
<button type="submit" class="save-btn">πΎ μ μ₯</button>
|
|
521
|
+
<span id="save-status"></span>
|
|
522
|
+
</div>
|
|
523
|
+
</form>
|
|
524
|
+
<div class="config-card" style="margin-top:12px">
|
|
525
|
+
<div class="config-row"><span class="config-key">API νΈμΆ μ</span><span class="config-value">${opts.usage.totalCalls}ν</span></div>
|
|
526
|
+
<div class="config-row"><span class="config-key">μ
λ ₯ ν ν°</span><span class="config-value">${opts.usage.promptTokens.toLocaleString()}</span></div>
|
|
527
|
+
<div class="config-row"><span class="config-key">μΆλ ₯ ν ν°</span><span class="config-value">${opts.usage.completionTokens.toLocaleString()}</span></div>
|
|
528
|
+
<div class="config-row"><span class="config-key">μ΄ ν ν°</span><span class="config-value">${opts.usage.totalTokens.toLocaleString()}</span></div>
|
|
529
|
+
<div class="config-row"><span class="config-key">μμ λΉμ©</span><span class="config-value" style="color:#2e7d32;font-weight:700;">$${opts.usage.totalCost.toFixed(4)}</span></div>
|
|
530
|
+
</div>
|
|
531
|
+
</div>
|
|
532
|
+
|
|
533
|
+
<div class="admin-section">
|
|
534
|
+
<h2>π λ±λ‘λ μμ€ (${opts.sources.length})</h2>
|
|
535
|
+
<table class="admin-table">
|
|
536
|
+
<thead><tr><th>ID</th><th>νμ
</th><th>μ λͺ©</th><th>URI</th><th>λ±λ‘μΌ</th></tr></thead>
|
|
537
|
+
<tbody>${sourceRows || '<tr><td colspan="5" style="text-align:center;color:var(--text-muted)">μμ€κ° μμ΅λλ€</td></tr>'}</tbody>
|
|
538
|
+
</table>
|
|
539
|
+
</div>
|
|
540
|
+
|
|
541
|
+
<div class="admin-section">
|
|
542
|
+
<h2>π§ μμ
</h2>
|
|
543
|
+
<div class="config-card" style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;">
|
|
544
|
+
<button class="save-btn" id="btn-build" onclick="runAction('/api/build', 'λΉλ')">π¨ μ¬μ΄νΈ λΉλ</button>
|
|
545
|
+
<span id="action-status" style="font-size:13px;"></span>
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
|
|
549
|
+
<div class="admin-section">
|
|
550
|
+
<h2>π μ§μ νμΌ νμ</h2>
|
|
551
|
+
<div class="config-card">
|
|
552
|
+
<div class="config-row"><span class="config-key">URL</span><span class="config-value">μΉ νμ΄μ§ ν¬λ‘€λ§</span></div>
|
|
553
|
+
<div class="config-row"><span class="config-key">PDF</span><span class="config-value">pdf-parse</span></div>
|
|
554
|
+
<div class="config-row"><span class="config-key">DOCX</span><span class="config-value">mammoth</span></div>
|
|
555
|
+
<div class="config-row"><span class="config-key">PPTX</span><span class="config-value">ZIP/XML νμ±</span></div>
|
|
556
|
+
<div class="config-row"><span class="config-key">DOC / PPT / RTF</span><span class="config-value">macOS textutil</span></div>
|
|
557
|
+
<div class="config-row"><span class="config-key">KEY (Keynote)</span><span class="config-value">ν
μ€νΈ μΆμΆ (μ νμ )</span></div>
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
<script>
|
|
562
|
+
async function runAction(url, label) {
|
|
563
|
+
const status = document.getElementById('action-status');
|
|
564
|
+
status.textContent = 'β³ ' + label + ' μ€...';
|
|
565
|
+
status.style.color = '#e65100';
|
|
566
|
+
try {
|
|
567
|
+
const r = await fetch(url, { method: 'POST' });
|
|
568
|
+
if (!r.ok) { const d = await r.json(); status.textContent = 'β ' + (d.error || 'μ€ν¨'); status.style.color = '#c62828'; return; }
|
|
569
|
+
const poll = setInterval(async () => {
|
|
570
|
+
const sr = await fetch('/api/status');
|
|
571
|
+
const s = await sr.json();
|
|
572
|
+
status.textContent = 'β³ ' + s.processingStatus;
|
|
573
|
+
if (!s.processing) {
|
|
574
|
+
clearInterval(poll);
|
|
575
|
+
status.textContent = 'β
μλ£!';
|
|
576
|
+
status.style.color = '#2e7d32';
|
|
577
|
+
}
|
|
578
|
+
}, 1000);
|
|
579
|
+
} catch { status.textContent = 'β μ°κ²° μ€ν¨'; status.style.color = '#c62828'; }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
document.getElementById('general-form').addEventListener('submit', async (e) => {
|
|
583
|
+
e.preventDefault();
|
|
584
|
+
const status = document.getElementById('general-save-status');
|
|
585
|
+
const name = document.getElementById('wiki-name').value.trim();
|
|
586
|
+
if (!name) return;
|
|
587
|
+
try {
|
|
588
|
+
const r = await fetch('/api/settings', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ wiki_name: name }) });
|
|
589
|
+
if (r.ok) { status.textContent = 'β
μ μ₯λ¨'; status.style.color = '#2e7d32'; setTimeout(() => location.reload(), 1000); }
|
|
590
|
+
else { status.textContent = 'β μ€ν¨'; status.style.color = '#c62828'; }
|
|
591
|
+
} catch { status.textContent = 'β μ°κ²° μ€ν¨'; status.style.color = '#c62828'; }
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
document.getElementById('llm-provider').addEventListener('change', (e) => {
|
|
595
|
+
document.getElementById('endpoint-row').style.display = e.target.value === 'azure-openai' ? '' : 'none';
|
|
596
|
+
const models = { gemini: 'gemini-2.0-flash-lite', 'azure-openai': 'gpt-5-nano', openai: 'gpt-4o-mini', anthropic: 'claude-sonnet-4-20250514' };
|
|
597
|
+
document.getElementById('llm-model').placeholder = models[e.target.value] || '';
|
|
598
|
+
});
|
|
599
|
+
document.getElementById('llm-form').addEventListener('submit', async (e) => {
|
|
600
|
+
e.preventDefault();
|
|
601
|
+
const status = document.getElementById('save-status');
|
|
602
|
+
const body = { provider: document.getElementById('llm-provider').value, model: document.getElementById('llm-model').value };
|
|
603
|
+
const key = document.getElementById('llm-key').value;
|
|
604
|
+
if (key) body.api_key = key;
|
|
605
|
+
const ep = document.getElementById('llm-endpoint').value;
|
|
606
|
+
if (ep) body.endpoint = ep;
|
|
607
|
+
try {
|
|
608
|
+
const r = await fetch('/api/settings', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
|
609
|
+
if (r.ok) { status.textContent = 'β
μ μ₯λ¨'; status.style.color = '#2e7d32'; setTimeout(() => location.reload(), 1000); }
|
|
610
|
+
else { status.textContent = 'β μ€ν¨'; status.style.color = '#c62828'; }
|
|
611
|
+
} catch { status.textContent = 'β μ°κ²° μ€ν¨'; status.style.color = '#c62828'; }
|
|
612
|
+
});
|
|
613
|
+
</script>
|
|
614
|
+
</body>
|
|
615
|
+
</html>`;
|
|
616
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { parse, stringify } from "smol-toml";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
export const CONFIG_FILE = "kiwi.toml";
|
|
6
|
+
export const DB_FILE = "kiwi.db";
|
|
7
|
+
export const SITE_DIR = "_site";
|
|
8
|
+
|
|
9
|
+
export interface LLMConfig {
|
|
10
|
+
provider: string; // "gemini" | "azure-openai" | "openai" | "anthropic"
|
|
11
|
+
model: string;
|
|
12
|
+
api_key: string;
|
|
13
|
+
endpoint: string; // for Azure OpenAI
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface KiwiConfig {
|
|
17
|
+
project: { name: string; created: string };
|
|
18
|
+
build: { output_dir: string };
|
|
19
|
+
llm: LLMConfig;
|
|
20
|
+
deploy: { target: string };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function defaultConfig(name: string): KiwiConfig {
|
|
24
|
+
return {
|
|
25
|
+
project: { name, created: new Date().toISOString().slice(0, 10) },
|
|
26
|
+
build: { output_dir: SITE_DIR },
|
|
27
|
+
llm: { provider: "gemini", model: "gemini-2.0-flash-lite", api_key: "", endpoint: "" },
|
|
28
|
+
deploy: { target: "gh-pages" },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveConfig(root: string, config: KiwiConfig): void {
|
|
33
|
+
Bun.write(join(root, CONFIG_FILE), stringify(config));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadConfig(root: string): KiwiConfig {
|
|
37
|
+
const content = require("fs").readFileSync(join(root, CONFIG_FILE), "utf-8");
|
|
38
|
+
const raw = parse(content) as any;
|
|
39
|
+
// Migrate old config format
|
|
40
|
+
if (!raw.llm) {
|
|
41
|
+
raw.llm = { provider: "gemini", model: "gemini-2.0-flash-lite", api_key: "", endpoint: "" };
|
|
42
|
+
}
|
|
43
|
+
return raw as KiwiConfig;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function findProjectRoot(from: string = process.cwd()): string {
|
|
47
|
+
let dir = from;
|
|
48
|
+
while (true) {
|
|
49
|
+
if (existsSync(join(dir, CONFIG_FILE))) return dir;
|
|
50
|
+
const parent = join(dir, "..");
|
|
51
|
+
if (parent === dir) throw new Error("No kiwi.toml found. Run 'kiwimu init' first.");
|
|
52
|
+
dir = parent;
|
|
53
|
+
}
|
|
54
|
+
}
|