@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.
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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> 원본 λ¬Έμ„œ &nbsp;
403
+ <span class="legend-dot concept"></span> κ°œλ… λ¬Έμ„œ &nbsp;
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
+ }