@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/server.ts
CHANGED
|
@@ -1,40 +1,104 @@
|
|
|
1
1
|
import { join } from "path";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import crypto from "crypto";
|
|
4
|
-
import {
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
5
|
+
import { DB_FILE, SUPPORTED_EXTENSIONS, loadConfig, saveConfig, getActivePersona } from "./config";
|
|
5
6
|
import { Store } from "./store";
|
|
6
7
|
import type { KiwiConfig } from "./config";
|
|
8
|
+
import type { ContentIndex } from "./services/index-generator";
|
|
9
|
+
import { renderActivityPage } from "./build/templates";
|
|
7
10
|
|
|
8
11
|
export function startServer(root: string, port: number, host: string): void {
|
|
9
12
|
const config = loadConfig(root);
|
|
10
13
|
const siteDir = join(root, config.build.output_dir);
|
|
14
|
+
const store = new Store(join(root, DB_FILE));
|
|
15
|
+
|
|
16
|
+
process.on('beforeExit', () => store.close());
|
|
11
17
|
|
|
12
18
|
let isProcessing = false;
|
|
13
19
|
let processingStatus = "";
|
|
14
20
|
|
|
21
|
+
// Cached content index for /api/index
|
|
22
|
+
let cachedIndex: { data: ContentIndex; pageCount: number } | null = null;
|
|
23
|
+
|
|
24
|
+
const askRateLimit = new Map<string, number[]>(); // ip -> timestamps
|
|
25
|
+
const ASK_RATE_LIMIT = 10; // max requests
|
|
26
|
+
const ASK_RATE_WINDOW = 60_000; // per minute
|
|
27
|
+
|
|
28
|
+
// Background task tracking for dynamic Q&A
|
|
29
|
+
const bgTasks = new Map<string, { status: 'processing' | 'completed' | 'error'; result?: any; error?: string }>();
|
|
30
|
+
|
|
15
31
|
const hostname = host;
|
|
16
|
-
|
|
32
|
+
|
|
33
|
+
// Persistent auth token: env var → on-disk file → fresh UUID (cached to file).
|
|
34
|
+
// This keeps the manage URL stable across server restarts and lets the
|
|
35
|
+
// settings page / dynamic Q&A keep working once a user has authed in once.
|
|
36
|
+
const tokenFile = join(root, ".kiwi-token");
|
|
37
|
+
function loadOrCreateToken(): string {
|
|
38
|
+
const fromEnv = process.env.KIWIMU_AUTH_TOKEN;
|
|
39
|
+
if (fromEnv && fromEnv.trim().length >= 16) return fromEnv.trim();
|
|
40
|
+
try {
|
|
41
|
+
const cached = readFileSync(tokenFile, "utf-8").trim();
|
|
42
|
+
if (cached.length >= 16) return cached;
|
|
43
|
+
} catch { /* file does not exist — fall through */ }
|
|
44
|
+
const fresh = crypto.randomUUID();
|
|
45
|
+
try { writeFileSync(tokenFile, fresh, { mode: 0o600 }); } catch { /* best effort */ }
|
|
46
|
+
return fresh;
|
|
47
|
+
}
|
|
48
|
+
const authToken = loadOrCreateToken();
|
|
49
|
+
const AUTH_COOKIE_NAME = "kiwi-auth";
|
|
50
|
+
const AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
|
|
51
|
+
|
|
52
|
+
function readCookie(req: Request, name: string): string | null {
|
|
53
|
+
const raw = req.headers.get("cookie") || "";
|
|
54
|
+
const part = raw.split(";").map(s => s.trim()).find(s => s.startsWith(name + "="));
|
|
55
|
+
return part ? decodeURIComponent(part.slice(name.length + 1)) : null;
|
|
56
|
+
}
|
|
57
|
+
function isAuthenticated(req: Request, url: URL): boolean {
|
|
58
|
+
const queryToken = url.searchParams.get("token");
|
|
59
|
+
const authHeader = req.headers.get("Authorization");
|
|
60
|
+
const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
61
|
+
const cookieToken = readCookie(req, AUTH_COOKIE_NAME);
|
|
62
|
+
return queryToken === authToken || bearerToken === authToken || cookieToken === authToken;
|
|
63
|
+
}
|
|
64
|
+
function authCookieHeader(): string {
|
|
65
|
+
return `${AUTH_COOKIE_NAME}=${encodeURIComponent(authToken)}; Path=/; Max-Age=${AUTH_COOKIE_MAX_AGE}; SameSite=Lax; HttpOnly`;
|
|
66
|
+
}
|
|
67
|
+
|
|
17
68
|
console.log(`\x1b[32m🥝 Kiwi Mu 서버 시작!\x1b[0m`);
|
|
18
69
|
console.log(` http://${hostname === "0.0.0.0" ? "localhost" : hostname}:${port}`);
|
|
19
|
-
console.log(` 관리 페이지: http://${hostname === "0.0.0.0" ? "localhost" : hostname}:${port}/
|
|
70
|
+
console.log(` 관리 페이지: http://${hostname === "0.0.0.0" ? "localhost" : hostname}:${port}/manage?token=${authToken}`);
|
|
20
71
|
console.log(` 인증 토큰: ${authToken}`);
|
|
21
72
|
if (hostname === "0.0.0.0") console.log(" 네트워크에 공개됨 (0.0.0.0)");
|
|
22
73
|
console.log(" 웹에서 문서 추가 가능합니다.\n");
|
|
23
74
|
|
|
75
|
+
// TLS: auto-detect cert files for HTTPS
|
|
76
|
+
const certPaths = [
|
|
77
|
+
{ cert: join(root, "certs", "fullchain.pem"), key: join(root, "certs", "privkey.pem") },
|
|
78
|
+
{ cert: "/etc/letsencrypt/live/internal.jiun.dev/fullchain.pem", key: "/etc/letsencrypt/live/internal.jiun.dev/privkey.pem" },
|
|
79
|
+
{ cert: "/certs/fullchain.pem", key: "/certs/privkey.pem" },
|
|
80
|
+
];
|
|
81
|
+
const tlsConfig = certPaths.find(p => existsSync(p.cert) && existsSync(p.key));
|
|
82
|
+
if (tlsConfig) {
|
|
83
|
+
console.log(` 🔒 HTTPS 활성화 (${tlsConfig.cert})`);
|
|
84
|
+
}
|
|
85
|
+
|
|
24
86
|
Bun.serve({
|
|
25
87
|
port,
|
|
26
88
|
hostname,
|
|
89
|
+
...(tlsConfig ? { tls: { cert: Bun.file(tlsConfig.cert), key: Bun.file(tlsConfig.key) } } : {}),
|
|
27
90
|
async fetch(req) {
|
|
28
91
|
const url = new URL(req.url);
|
|
29
92
|
|
|
30
93
|
// ── Auth middleware for /api/* and /admin ──
|
|
31
|
-
if (url.pathname.startsWith("/api/") || url.pathname === "/
|
|
32
|
-
|
|
33
|
-
const queryToken = url.searchParams.get("token");
|
|
34
|
-
const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
35
|
-
if (bearerToken !== authToken && queryToken !== authToken) {
|
|
94
|
+
if (url.pathname.startsWith("/api/") || url.pathname === "/manage" || url.pathname === "/activity" || url.pathname === "/provenance") {
|
|
95
|
+
if (!isAuthenticated(req, url)) {
|
|
36
96
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
37
97
|
}
|
|
98
|
+
// If user supplied token via ?token=…, set a cookie so subsequent
|
|
99
|
+
// requests don't need it. Continue handling the request below.
|
|
100
|
+
// (We set the Set-Cookie header on the eventual response in branches
|
|
101
|
+
// that build their own Response; for /manage we wrap below.)
|
|
38
102
|
}
|
|
39
103
|
|
|
40
104
|
// ── API endpoints ──
|
|
@@ -57,9 +121,8 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
57
121
|
}
|
|
58
122
|
|
|
59
123
|
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return Response.json({ error: `지원하지 않는 형식: .${ext}. 지원: ${supported.join(", ")}` }, { status: 400 });
|
|
124
|
+
if (!SUPPORTED_EXTENSIONS.includes(ext)) {
|
|
125
|
+
return Response.json({ error: `지원하지 않는 형식: .${ext}. 지원: ${SUPPORTED_EXTENSIONS.join(", ")}` }, { status: 400 });
|
|
63
126
|
}
|
|
64
127
|
|
|
65
128
|
// Save uploaded file
|
|
@@ -73,7 +136,6 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
73
136
|
processingStatus = "파일 처리 시작...";
|
|
74
137
|
|
|
75
138
|
(async () => {
|
|
76
|
-
const store = new Store(join(root, DB_FILE));
|
|
77
139
|
try {
|
|
78
140
|
const { ingestFile } = await import("./services/ingest");
|
|
79
141
|
const currentConfig = loadConfig(root);
|
|
@@ -81,7 +143,7 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
81
143
|
|
|
82
144
|
await ingestFile(root, store, filePath, file.name, currentConfig.llm, currentPersona, (status) => {
|
|
83
145
|
processingStatus = status;
|
|
84
|
-
});
|
|
146
|
+
}, currentConfig.schema);
|
|
85
147
|
|
|
86
148
|
processingStatus = "빌드 중...";
|
|
87
149
|
const { buildSite } = await import("./build/renderer");
|
|
@@ -92,7 +154,6 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
92
154
|
const message = e instanceof Error ? e.message : String(e);
|
|
93
155
|
processingStatus = `오류: ${message}`;
|
|
94
156
|
} finally {
|
|
95
|
-
store.close();
|
|
96
157
|
setTimeout(() => { isProcessing = false; }, 2000);
|
|
97
158
|
}
|
|
98
159
|
})();
|
|
@@ -123,7 +184,6 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
123
184
|
processingStatus = "시작 중...";
|
|
124
185
|
|
|
125
186
|
(async () => {
|
|
126
|
-
const store = new Store(join(root, DB_FILE));
|
|
127
187
|
try {
|
|
128
188
|
const { ingestUrl } = await import("./services/ingest");
|
|
129
189
|
const currentConfig = loadConfig(root);
|
|
@@ -131,7 +191,7 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
131
191
|
|
|
132
192
|
await ingestUrl(root, store, body.source, currentConfig.llm, currentPersona, (status) => {
|
|
133
193
|
processingStatus = status;
|
|
134
|
-
});
|
|
194
|
+
}, currentConfig.schema);
|
|
135
195
|
|
|
136
196
|
processingStatus = "빌드 중...";
|
|
137
197
|
const { buildSite } = await import("./build/renderer");
|
|
@@ -142,7 +202,6 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
142
202
|
const message = e instanceof Error ? e.message : String(e);
|
|
143
203
|
processingStatus = `오류: ${message}`;
|
|
144
204
|
} finally {
|
|
145
|
-
store.close();
|
|
146
205
|
setTimeout(() => { isProcessing = false; }, 2000);
|
|
147
206
|
}
|
|
148
207
|
})();
|
|
@@ -165,16 +224,13 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
165
224
|
|
|
166
225
|
// Auto-rebuild site with new settings
|
|
167
226
|
(async () => {
|
|
168
|
-
const store = new Store(join(root, DB_FILE));
|
|
169
227
|
try {
|
|
170
228
|
const { buildSite } = await import("./build/renderer");
|
|
171
229
|
await buildSite(store, currentConfig, root);
|
|
172
230
|
console.log("\x1b[32m✅ 설정 변경 후 사이트 리빌드 완료\x1b[0m");
|
|
173
231
|
} catch (e: unknown) {
|
|
174
232
|
const message = e instanceof Error ? e.message : String(e);
|
|
175
|
-
console.
|
|
176
|
-
} finally {
|
|
177
|
-
store.close();
|
|
233
|
+
console.error(`\x1b[31m❌ 리빌드 실패: ${message}\x1b[0m`);
|
|
178
234
|
}
|
|
179
235
|
})();
|
|
180
236
|
|
|
@@ -246,7 +302,6 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
246
302
|
isProcessing = true;
|
|
247
303
|
processingStatus = "빌드 중...";
|
|
248
304
|
(async () => {
|
|
249
|
-
const store = new Store(join(root, DB_FILE));
|
|
250
305
|
try {
|
|
251
306
|
const { buildSite } = await import("./build/renderer");
|
|
252
307
|
await buildSite(store, loadConfig(root), root);
|
|
@@ -256,7 +311,6 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
256
311
|
const message = e instanceof Error ? e.message : String(e);
|
|
257
312
|
processingStatus = `빌드 오류: ${message}`;
|
|
258
313
|
} finally {
|
|
259
|
-
store.close();
|
|
260
314
|
setTimeout(() => { isProcessing = false; }, 2000);
|
|
261
315
|
}
|
|
262
316
|
})();
|
|
@@ -264,14 +318,14 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
264
318
|
}
|
|
265
319
|
|
|
266
320
|
// Admin page
|
|
267
|
-
if (url.pathname === "/
|
|
268
|
-
const store = new Store(join(root, DB_FILE));
|
|
321
|
+
if (url.pathname === "/manage") {
|
|
269
322
|
const sources = store.listSourcesMeta();
|
|
270
323
|
const usage = store.getUsageSummary();
|
|
271
324
|
const configData = loadConfig(root);
|
|
272
|
-
store.close();
|
|
273
325
|
|
|
274
326
|
const { renderAdmin } = await import("./build/templates");
|
|
327
|
+
const headers: Record<string, string> = { "Content-Type": "text/html" };
|
|
328
|
+
if (url.searchParams.get("token") === authToken) headers["Set-Cookie"] = authCookieHeader();
|
|
275
329
|
return new Response(renderAdmin({
|
|
276
330
|
wikiName: configData.project.name,
|
|
277
331
|
sources,
|
|
@@ -280,17 +334,253 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
280
334
|
personas: configData.personas || [],
|
|
281
335
|
activePersona: configData.active_persona || "",
|
|
282
336
|
authToken,
|
|
283
|
-
}), { headers
|
|
337
|
+
}), { headers });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (url.pathname === "/api/ask" && req.method === "POST") {
|
|
341
|
+
const clientIp = req.headers.get("x-forwarded-for") || "local";
|
|
342
|
+
const now = Date.now();
|
|
343
|
+
const timestamps = askRateLimit.get(clientIp) || [];
|
|
344
|
+
const recent = timestamps.filter(t => now - t < ASK_RATE_WINDOW);
|
|
345
|
+
if (recent.length >= ASK_RATE_LIMIT) {
|
|
346
|
+
return Response.json({ error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." }, { status: 429 });
|
|
347
|
+
}
|
|
348
|
+
recent.push(now);
|
|
349
|
+
askRateLimit.set(clientIp, recent);
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const body = await req.json();
|
|
353
|
+
const { selected_text, question, page_slug, page_id } = body;
|
|
354
|
+
|
|
355
|
+
if (!selected_text || !page_slug) {
|
|
356
|
+
return Response.json({ error: "선택한 텍스트가 필요합니다" }, { status: 400 });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const parentPage = store.getPage(page_slug);
|
|
360
|
+
if (!parentPage) {
|
|
361
|
+
return Response.json({ error: "페이지를 찾을 수 없습니다" }, { status: 404 });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Auto-generate question from selected text if not provided
|
|
365
|
+
const autoQuestion = question || `"${selected_text.slice(0, 100)}" 개념을 자세히 설명해주세요`;
|
|
366
|
+
|
|
367
|
+
// Run generation in background, return task ID immediately
|
|
368
|
+
const taskId = crypto.randomUUID();
|
|
369
|
+
bgTasks.set(taskId, { status: 'processing' });
|
|
370
|
+
|
|
371
|
+
(async () => {
|
|
372
|
+
try {
|
|
373
|
+
const currentConfig = loadConfig(root);
|
|
374
|
+
const persona = getActivePersona(currentConfig);
|
|
375
|
+
const { LLMClient } = await import("./llm-client");
|
|
376
|
+
const llmClient = new LLMClient(currentConfig.llm);
|
|
377
|
+
|
|
378
|
+
const { generateDynamicPage } = await import("./services/dynamic-qa");
|
|
379
|
+
const result = await generateDynamicPage(store, llmClient, persona, parentPage, selected_text, autoQuestion);
|
|
380
|
+
|
|
381
|
+
// Hot-render the new page + re-render parent page
|
|
382
|
+
const { buildSinglePage } = await import("./build/renderer");
|
|
383
|
+
await buildSinglePage(root, store, result.slug);
|
|
384
|
+
await buildSinglePage(root, store, page_slug);
|
|
385
|
+
|
|
386
|
+
// Check auto_promote config
|
|
387
|
+
const qaConfig = currentConfig.qa;
|
|
388
|
+
if (qaConfig?.auto_promote && result.isPromotable) {
|
|
389
|
+
// Auto-promote: create permanent wiki page with quizzes
|
|
390
|
+
try {
|
|
391
|
+
const { promoteToWiki } = await import("./services/promote");
|
|
392
|
+
const promoteResult = await promoteToWiki(store, {
|
|
393
|
+
question: autoQuestion,
|
|
394
|
+
answer: result.content,
|
|
395
|
+
title: result.suggestedTitle,
|
|
396
|
+
sourcePageId: parentPage.id,
|
|
397
|
+
selectedText: selected_text,
|
|
398
|
+
}, currentConfig.llm);
|
|
399
|
+
|
|
400
|
+
// Hot-render the promoted page
|
|
401
|
+
await buildSinglePage(root, store, promoteResult.slug);
|
|
402
|
+
|
|
403
|
+
console.log(`\x1b[32m✅ Auto-promoted: ${result.title}\x1b[0m`);
|
|
404
|
+
} catch (promoteErr) {
|
|
405
|
+
console.log(`\x1b[33m⚠ Auto-promote failed: ${promoteErr}\x1b[0m`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
bgTasks.set(taskId, {
|
|
410
|
+
status: 'completed',
|
|
411
|
+
result: {
|
|
412
|
+
ok: true,
|
|
413
|
+
slug: result.slug,
|
|
414
|
+
title: result.title,
|
|
415
|
+
url: `/wiki/${result.slug}.html`,
|
|
416
|
+
isPromotable: result.isPromotable,
|
|
417
|
+
suggestedTitle: result.suggestedTitle,
|
|
418
|
+
keyConcepts: result.keyConcepts,
|
|
419
|
+
sourcePageId: parentPage.id,
|
|
420
|
+
content: result.content,
|
|
421
|
+
question: autoQuestion,
|
|
422
|
+
selectedText: selected_text,
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
} catch (e: unknown) {
|
|
426
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
427
|
+
bgTasks.set(taskId, { status: 'error', error: message });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Clean up task after 5 minutes
|
|
431
|
+
setTimeout(() => bgTasks.delete(taskId), 5 * 60 * 1000);
|
|
432
|
+
})();
|
|
433
|
+
|
|
434
|
+
return Response.json({ task_id: taskId, message: "생성 시작" });
|
|
435
|
+
} catch (e: unknown) {
|
|
436
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
437
|
+
return Response.json({ error: message }, { status: 500 });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Background task status polling for dynamic Q&A
|
|
442
|
+
if (url.pathname === "/api/ask/status" && req.method === "GET") {
|
|
443
|
+
const taskId = url.searchParams.get("task_id");
|
|
444
|
+
if (!taskId) {
|
|
445
|
+
return Response.json({ error: "task_id가 필요합니다" }, { status: 400 });
|
|
446
|
+
}
|
|
447
|
+
const task = bgTasks.get(taskId);
|
|
448
|
+
if (!task) {
|
|
449
|
+
return Response.json({ error: "작업을 찾을 수 없습니다" }, { status: 404 });
|
|
450
|
+
}
|
|
451
|
+
return Response.json(task);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── Promote Q&A answer to permanent wiki page ──
|
|
455
|
+
if (url.pathname === "/api/promote" && req.method === "POST") {
|
|
456
|
+
try {
|
|
457
|
+
const body = await req.json() as {
|
|
458
|
+
question: string;
|
|
459
|
+
answer: string;
|
|
460
|
+
title: string;
|
|
461
|
+
sourcePageId: number;
|
|
462
|
+
selectedText?: string;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
if (!body.question || !body.answer || !body.title || !body.sourcePageId) {
|
|
466
|
+
return Response.json({ error: "question, answer, title, sourcePageId가 필요합니다" }, { status: 400 });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (body.title.length > 200) {
|
|
470
|
+
return Response.json({ error: "title은 200자 이하여야 합니다" }, { status: 400 });
|
|
471
|
+
}
|
|
472
|
+
if (body.question.length > 2000) {
|
|
473
|
+
return Response.json({ error: "question은 2000자 이하여야 합니다" }, { status: 400 });
|
|
474
|
+
}
|
|
475
|
+
if (body.answer.length > 50000) {
|
|
476
|
+
return Response.json({ error: "answer는 50000자 이하여야 합니다" }, { status: 400 });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const sourcePage = store.getPageById(body.sourcePageId);
|
|
480
|
+
if (!sourcePage) {
|
|
481
|
+
return Response.json({ error: "원본 페이지를 찾을 수 없습니다" }, { status: 404 });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const currentConfig = loadConfig(root);
|
|
485
|
+
const { promoteToWiki } = await import("./services/promote");
|
|
486
|
+
const result = await promoteToWiki(store, body, currentConfig.llm);
|
|
487
|
+
|
|
488
|
+
// Hot-render the affected pages
|
|
489
|
+
const { buildSinglePage } = await import("./build/renderer");
|
|
490
|
+
await buildSinglePage(root, store, result.slug);
|
|
491
|
+
await buildSinglePage(root, store, sourcePage.slug);
|
|
492
|
+
|
|
493
|
+
return Response.json({
|
|
494
|
+
ok: true,
|
|
495
|
+
slug: result.slug,
|
|
496
|
+
title: result.title,
|
|
497
|
+
url: `/wiki/${result.slug}.html`,
|
|
498
|
+
updated: !result.isNew,
|
|
499
|
+
message: result.isNew ? "새 위키 페이지가 생성되었습니다" : "기존 페이지에 내용이 추가되었습니다",
|
|
500
|
+
});
|
|
501
|
+
} catch (e: unknown) {
|
|
502
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
503
|
+
return Response.json({ error: message }, { status: 500 });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (url.pathname === "/api/search" && req.method === "GET") {
|
|
508
|
+
const query = url.searchParams.get("q")?.trim();
|
|
509
|
+
if (!query || query.length < 2) {
|
|
510
|
+
return Response.json({ results: [] });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Try semantic search first (if embeddings exist)
|
|
514
|
+
try {
|
|
515
|
+
const searchConfig = loadConfig(root);
|
|
516
|
+
// Use embedding config if available, fall back to llm config
|
|
517
|
+
const embeddingLlmConfig = searchConfig.embedding
|
|
518
|
+
? { ...searchConfig.llm, provider: searchConfig.embedding.provider, api_key: searchConfig.embedding.api_key }
|
|
519
|
+
: searchConfig.llm;
|
|
520
|
+
if (embeddingLlmConfig.api_key) {
|
|
521
|
+
const { semanticSearch } = await import("./services/embedding");
|
|
522
|
+
const semanticResults = await semanticSearch(query, store, embeddingLlmConfig, 5);
|
|
523
|
+
if (semanticResults.length > 0) {
|
|
524
|
+
return Response.json({
|
|
525
|
+
results: semanticResults.map(r => ({
|
|
526
|
+
slug: r.slug,
|
|
527
|
+
title: r.title,
|
|
528
|
+
page_type: r.pageType,
|
|
529
|
+
origin: r.origin,
|
|
530
|
+
preview: '',
|
|
531
|
+
similarity: r.similarity
|
|
532
|
+
})),
|
|
533
|
+
method: 'semantic'
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
} catch {
|
|
538
|
+
// Fall through to FTS/LIKE search
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Fallback: FTS5 / LIKE search
|
|
542
|
+
const results = store.searchPages(query, 5);
|
|
543
|
+
return Response.json({ results, method: 'fts' });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (url.pathname === "/api/lint" && req.method === "GET") {
|
|
547
|
+
try {
|
|
548
|
+
const { lintWiki } = await import("./services/lint");
|
|
549
|
+
const report = lintWiki(store);
|
|
550
|
+
return Response.json(report);
|
|
551
|
+
} catch (e: unknown) {
|
|
552
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
553
|
+
return Response.json({ error: message }, { status: 500 });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Content index API
|
|
558
|
+
if (url.pathname === "/api/index" && req.method === "GET") {
|
|
559
|
+
const refresh = url.searchParams.get("refresh") === "true";
|
|
560
|
+
const currentPageCount = store.countPages();
|
|
561
|
+
|
|
562
|
+
if (!refresh && cachedIndex && cachedIndex.pageCount === currentPageCount) {
|
|
563
|
+
return Response.json(cachedIndex.data);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const { generateContentIndex } = await import("./services/index-generator");
|
|
567
|
+
const useLLM = url.searchParams.get("llm") === "true";
|
|
568
|
+
const currentConfig = loadConfig(root);
|
|
569
|
+
const indexData = await generateContentIndex(store, {
|
|
570
|
+
useLLM,
|
|
571
|
+
llmConfig: currentConfig.llm,
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
cachedIndex = { data: indexData, pageCount: currentPageCount };
|
|
575
|
+
return Response.json(indexData);
|
|
284
576
|
}
|
|
285
577
|
|
|
286
578
|
if (url.pathname === "/api/status") {
|
|
287
|
-
const store = new Store(join(root, DB_FILE));
|
|
288
579
|
const sources = store.listSourcesMeta();
|
|
289
580
|
const sourcePages = store.listSourcePages();
|
|
290
581
|
const conceptPages = store.listConceptPages();
|
|
291
|
-
const
|
|
582
|
+
const linkCount = store.countLinks();
|
|
292
583
|
const usage = store.getUsageSummary();
|
|
293
|
-
store.close();
|
|
294
584
|
|
|
295
585
|
return Response.json({
|
|
296
586
|
processing: isProcessing,
|
|
@@ -298,13 +588,140 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
298
588
|
sources: sources.length,
|
|
299
589
|
sourcePages: sourcePages.length,
|
|
300
590
|
conceptPages: conceptPages.length,
|
|
301
|
-
links:
|
|
591
|
+
links: linkCount,
|
|
302
592
|
usage,
|
|
303
593
|
});
|
|
304
594
|
}
|
|
305
595
|
|
|
596
|
+
// Page edit endpoint
|
|
597
|
+
if (url.pathname === "/api/page/edit" && req.method === "POST") {
|
|
598
|
+
try {
|
|
599
|
+
const { slug, content } = await req.json() as { slug: string; content: string };
|
|
600
|
+
if (!slug || !content) {
|
|
601
|
+
return Response.json({ error: "slug과 content가 필요합니다" }, { status: 400 });
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const page = store.getPage(slug);
|
|
605
|
+
if (!page) {
|
|
606
|
+
return Response.json({ error: "페이지를 찾을 수 없습니다" }, { status: 404 });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Update page content in DB
|
|
610
|
+
store.updatePageContentBySlug(slug, content);
|
|
611
|
+
|
|
612
|
+
// Hot-render the updated page
|
|
613
|
+
const { buildSinglePage } = await import("./build/renderer");
|
|
614
|
+
await buildSinglePage(root, store, slug);
|
|
615
|
+
|
|
616
|
+
return Response.json({ ok: true, slug });
|
|
617
|
+
} catch (e: unknown) {
|
|
618
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
619
|
+
return Response.json({ error: message }, { status: 500 });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Citation endpoints
|
|
624
|
+
if (url.pathname.match(/^\/api\/pages\/(\d+)\/citations$/) && req.method === "GET") {
|
|
625
|
+
const pageId = parseInt(url.pathname.split("/")[3]);
|
|
626
|
+
if (isNaN(pageId)) return Response.json({ error: "잘못된 ID입니다" }, { status: 400 });
|
|
627
|
+
const citations = store.getCitationsForPage(pageId);
|
|
628
|
+
return Response.json({ citations });
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (url.pathname.match(/^\/api\/sources\/(\d+)\/citations$/) && req.method === "GET") {
|
|
632
|
+
const sourceId = parseInt(url.pathname.split("/")[3]);
|
|
633
|
+
if (isNaN(sourceId)) return Response.json({ error: "잘못된 ID입니다" }, { status: 400 });
|
|
634
|
+
const citations = store.getCitationsForSource(sourceId);
|
|
635
|
+
return Response.json({ citations });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (url.pathname === "/api/provenance" && req.method === "GET") {
|
|
639
|
+
const coverage = store.getSourceCoverage();
|
|
640
|
+
return Response.json({ coverage });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Get page raw content endpoint. ?format=html also returns the rendered
|
|
644
|
+
// article HTML fragment (used by the peek panel client-side).
|
|
645
|
+
if (url.pathname.startsWith("/api/page/") && req.method === "GET") {
|
|
646
|
+
const slug = url.pathname.replace("/api/page/", "");
|
|
647
|
+
const page = store.getPage(decodeURIComponent(slug));
|
|
648
|
+
if (!page) return Response.json({ error: "찾을 수 없습니다" }, { status: 404 });
|
|
649
|
+
const body: Record<string, unknown> = {
|
|
650
|
+
slug: page.slug,
|
|
651
|
+
title: page.title,
|
|
652
|
+
content: page.content,
|
|
653
|
+
origin: page.origin,
|
|
654
|
+
};
|
|
655
|
+
if (url.searchParams.get("format") === "html") {
|
|
656
|
+
const { renderPageContent } = await import("./build/renderer");
|
|
657
|
+
const allSlugs = new Set(store.listPages().map(p => p.slug));
|
|
658
|
+
body.html = await renderPageContent(page, allSlugs);
|
|
659
|
+
}
|
|
660
|
+
return Response.json(body);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Activity log API
|
|
664
|
+
if (url.pathname === "/api/activity" && req.method === "GET") {
|
|
665
|
+
const limit = Math.min(Math.max(parseInt(url.searchParams.get("limit") || "50") || 50, 1), 200);
|
|
666
|
+
const offset = Math.max(parseInt(url.searchParams.get("offset") || "0") || 0, 0);
|
|
667
|
+
const action = url.searchParams.get("action") || undefined;
|
|
668
|
+
const entries = store.getActivityLog(limit, offset, action);
|
|
669
|
+
const stats = store.getActivityStats();
|
|
670
|
+
return Response.json({ entries, total: stats.total });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Activity log page
|
|
674
|
+
if (url.pathname === "/activity") {
|
|
675
|
+
const stats = store.getActivityStats();
|
|
676
|
+
const html = renderActivityPage(authToken, config.project.name, stats);
|
|
677
|
+
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Provenance page (no auth required — it's a public view page)
|
|
681
|
+
if (url.pathname === "/provenance") {
|
|
682
|
+
const coverage = store.getSourceCoverage();
|
|
683
|
+
const sources = store.listSourcesMeta();
|
|
684
|
+
const conceptPages = store.listConceptPages();
|
|
685
|
+
const sourcePages = store.listSourcePages();
|
|
686
|
+
const configData = loadConfig(root);
|
|
687
|
+
|
|
688
|
+
// Build citation matrix: for each source, which pages cite it
|
|
689
|
+
const matrix: Array<{
|
|
690
|
+
sourceId: number;
|
|
691
|
+
sourceTitle: string;
|
|
692
|
+
citationCount: number;
|
|
693
|
+
pageCount: number;
|
|
694
|
+
pages: Array<{ title: string; slug: string }>;
|
|
695
|
+
}> = [];
|
|
696
|
+
|
|
697
|
+
for (const cov of coverage) {
|
|
698
|
+
const citations = store.getCitationsForSource(cov.sourceId);
|
|
699
|
+
const pageMap = new Map<number, { title: string; slug: string }>();
|
|
700
|
+
for (const c of citations) {
|
|
701
|
+
if (c.page_title && c.page_slug && !pageMap.has(c.page_id)) {
|
|
702
|
+
pageMap.set(c.page_id, { title: c.page_title, slug: c.page_slug });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
matrix.push({
|
|
706
|
+
sourceId: cov.sourceId,
|
|
707
|
+
sourceTitle: cov.sourceTitle,
|
|
708
|
+
citationCount: cov.citationCount,
|
|
709
|
+
pageCount: cov.pageCount,
|
|
710
|
+
pages: Array.from(pageMap.values()),
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const { renderProvenancePage } = await import("./build/templates");
|
|
715
|
+
return new Response(renderProvenancePage({
|
|
716
|
+
wikiName: configData.project.name,
|
|
717
|
+
coverage: matrix,
|
|
718
|
+
sourcePages: sourcePages.map(p => ({ slug: p.slug, title: p.title })),
|
|
719
|
+
conceptPages: conceptPages.map(p => ({ slug: p.slug, title: p.title })),
|
|
720
|
+
}), { headers: { "Content-Type": "text/html" } });
|
|
721
|
+
}
|
|
722
|
+
|
|
306
723
|
// ── Static file serving ──
|
|
307
|
-
let pathname = url.pathname;
|
|
724
|
+
let pathname = decodeURIComponent(url.pathname);
|
|
308
725
|
if (pathname === "/") pathname = "/index.html";
|
|
309
726
|
|
|
310
727
|
const resolved = path.resolve(join(siteDir, pathname));
|
|
@@ -315,7 +732,22 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
315
732
|
|
|
316
733
|
if (await staticFile.exists()) {
|
|
317
734
|
const isHtml = pathname.endsWith(".html");
|
|
318
|
-
const cspValue = "default-src 'self'; script-src 'self' 'unsafe-inline' cdn.jsdelivr.net d3js.org; style-src 'self' 'unsafe-inline' cdn.jsdelivr.net fonts.googleapis.com; font-src fonts.gstatic.com
|
|
735
|
+
const cspValue = "default-src 'self'; script-src 'self' 'unsafe-inline' cdn.jsdelivr.net d3js.org static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src https://fonts.gstatic.com https://*.gstatic.com data:; img-src * data:; connect-src 'self' cloudflareinsights.com";
|
|
736
|
+
if (isHtml) {
|
|
737
|
+
const queryToken = url.searchParams.get("token");
|
|
738
|
+
const isAuthed = isAuthenticated(req, url);
|
|
739
|
+
if (isAuthed) {
|
|
740
|
+
let html = await staticFile.text();
|
|
741
|
+
if (!html.includes('kiwi-auth')) {
|
|
742
|
+
html = html.replace('</head>', `<meta name="kiwi-auth" content="${authToken}"></head>`);
|
|
743
|
+
}
|
|
744
|
+
const headers: Record<string, string> = { "Content-Type": "text/html", "Content-Security-Policy": cspValue };
|
|
745
|
+
// If token came in via query, persist it as a cookie so the user
|
|
746
|
+
// doesn't need to re-paste ?token=… on every navigation.
|
|
747
|
+
if (queryToken === authToken) headers["Set-Cookie"] = authCookieHeader();
|
|
748
|
+
return new Response(html, { headers });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
319
751
|
if (isHtml) {
|
|
320
752
|
return new Response(staticFile, { headers: { "Content-Type": "text/html", "Content-Security-Policy": cspValue } });
|
|
321
753
|
}
|
|
@@ -325,3 +757,4 @@ export function startServer(root: string, port: number, host: string): void {
|
|
|
325
757
|
},
|
|
326
758
|
});
|
|
327
759
|
}
|
|
760
|
+
|