Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
src/routes/ai.routes.ts
공개 가능
1
import { Router, Request, Response } from 'express';
2
import fs from 'fs';
3
import path from 'path';
4
import { createHash } from 'crypto';
5
import { v4 as uuidv4 } from 'uuid';
6
import { genAI, openai, hinanaGptSystemPrompt, normalGptSystemPrompt, hinanaPersona } from '../config/ai.config';
7
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
8
import { readSettings } from '../services/settings.service';
9
import { limitHistory, getFileChatHistory, getModelInputMessages } from '../services/ai.service';
10
import { deleteUserAiNote, getUserAiNotes, saveUserAiNote, updateUserAiNote } from '../services/ai-note.service';
11
import { getProfileImage } from '../services/bookmark.service';
12
import { recordSecurityLog } from '../services/security-log.service';
13
import { escapeHtml } from '../utils/escapeHtml';
14
import { AiNote } from '../types/models';
15
16
const router = Router();
17
18
// ── 비동기 GPT 작업 큐 ──────────────────────────────────────────
19
interface ChatJob {
20
status: 'pending' | 'done' | 'error';
21
response?: string;
22
error?: string;
23
createdAt: number;
24
}
25
const chatJobs = new Map<string, ChatJob>();
26
27
// 10분 지난 job 자동 정리
28
setInterval(() => {
29
const cutoff = Date.now() - 10 * 60 * 1000;
30
for (const [id, job] of chatJobs) {
31
if (job.createdAt < cutoff) chatJobs.delete(id);
32
}
33
}, 2 * 60 * 1000);
34
35
function genJobId(): string {
36
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
37
}
38
// ───────────────────────────────────────────────────────────────
39
40
const EXHIBITION_REVIEW_CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 365;
41
const EXHIBITION_REVIEW_CACHE_MAX_ENTRIES = 300;
42
type ExhibitionReviewCacheEntry = { review: string; expiresAt: number; createdAt: string; updatedAt: string };
43
const exhibitionReviewCache = new Map<string, ExhibitionReviewCacheEntry>();
44
45
function loadExhibitionReviewCache(): void {
46
try {
47
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
48
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
49
if (!raw.trim()) return;
50
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
51
const now = Date.now();
52
Object.entries(data).forEach(([key, entry]) => {
53
if (!entry?.review || (entry.expiresAt && entry.expiresAt <= now)) return;
54
exhibitionReviewCache.set(key, entry);
55
});
56
} catch (err) {
57
console.error('전시 감상평 캐시 읽기 오류:', err);
58
}
59
}
60
61
function persistExhibitionReviewCache(): void {
62
try {
63
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
64
const entries = Array.from(exhibitionReviewCache.entries())
65
.sort((a, b) => new Date(b[1].updatedAt || b[1].createdAt).getTime() - new Date(a[1].updatedAt || a[1].createdAt).getTime())
66
.slice(0, EXHIBITION_REVIEW_CACHE_MAX_ENTRIES);
67
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
68
} catch (err) {
69
console.error('전시 감상평 캐시 저장 오류:', err);
70
}
71
}
72
73
loadExhibitionReviewCache();
74
75
function buildExhibitionReviewCacheKey(payload: {
76
title: string;
77
subtitle: string;
78
description: string;
79
techniques: string[];
80
}): string {
81
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
82
title: payload.title.trim(),
83
subtitle: payload.subtitle.trim(),
84
description: payload.description.trim(),
85
techniques: payload.techniques.map((v) => v.trim())
86
});
87
return createHash('sha256').update(canonical).digest('hex');
88
}
89
90
function getCachedExhibitionReview(key: string): string | null {
91
const entry = exhibitionReviewCache.get(key);
92
if (!entry) return null;
93
if (Date.now() > entry.expiresAt) {
94
exhibitionReviewCache.delete(key);
95
persistExhibitionReviewCache();
96
return null;
97
}
98
return entry.review;
99
}
100
101
function setCachedExhibitionReview(key: string, review: string) {
102
if (exhibitionReviewCache.size >= EXHIBITION_REVIEW_CACHE_MAX_ENTRIES) {
103
const oldestKey = exhibitionReviewCache.keys().next().value;
104
if (oldestKey) exhibitionReviewCache.delete(oldestKey);
105
}
106
107
exhibitionReviewCache.set(key, {
108
review,
109
expiresAt: Date.now() + EXHIBITION_REVIEW_CACHE_TTL_MS,
110
createdAt: new Date().toISOString(),
111
updatedAt: new Date().toISOString()
112
});
113
persistExhibitionReviewCache();
114
}
115
116
function parseExhibitionReviewInput(req: Request): {
117
title: string;
118
subtitle: string;
119
description: string;
120
techniques: string[];
121
} {
122
const title = String(req.body?.title || '').trim().slice(0, 120);
123
const subtitle = String(req.body?.subtitle || '').trim().slice(0, 180);
124
const description = String(req.body?.description || '').trim().slice(0, 1200);
125
const techniques = Array.isArray(req.body?.techniques)
126
? req.body.techniques.map((v: any) => String(v).trim().slice(0, 120)).filter(Boolean).slice(0, 6)
127
: [];
128
129
return { title, subtitle, description, techniques };
130
}
131
132
function getSessionAiNotes(req: Request): AiNote[] {
133
if (!Array.isArray(req.session.aiNotes)) {
134
req.session.aiNotes = [];
135
}
136
return req.session.aiNotes;
137
}
138
139
function getAiNotes(req: Request): AiNote[] {
140
return req.session.username
141
? getUserAiNotes(req.session.username)
142
: getSessionAiNotes(req);
143
}
144
145
function normalizeNoteText(value: unknown, maxLength: number): string {
146
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength);
147
}
148
149
function normalizeNoteExcerpt(value: unknown, maxLength: number): string {
150
return String(value || '').replace(/\r\n?/g, '\n').trim().slice(0, maxLength);
151
}
152
153
function normalizeTags(value: unknown): string[] {
154
if (!Array.isArray(value)) return [];
155
return value
156
.map((tag) => normalizeNoteText(tag, 24))
157
.filter(Boolean)
158
.slice(0, 8);
159
}
160
161
const AI_NOTE_TAG_CATALOG = [
162
'문장', '감정', '관계', '캐릭터', '설정', '세계관', '아이디어', '대사',
163
'글쓰기', '철학', '조언', '일상', '기억', '음악', '미술', '여행',
164
'공부', '기술', '건강', '작업', '인용', '질문', '분석', '계획',
165
'일기', '읽을거리', '감상', '메모', '사람', '대화', '생각', '리뷰',
166
'영화', '게임', '음식', '쇼핑', '할일', '취향', '기록', '테스트'
167
];
168
169
type AutoTagRule = {
170
strong: string[];
171
weak?: string[];
172
exclude?: string[];
173
priority?: number;
174
};
175
176
const AUTO_TAG_RULES: Record<string, AutoTagRule> = {
177
'문장': { strong: ['문장', '표현', '단어', '어휘'], weak: ['말', '문구'], priority: 8 },
178
'감정': { strong: ['감정', '슬프', '기쁘', '불안', '행복', '외로', '짜증', '설레', '화남'], weak: ['마음', '기분'], priority: 9 },
179
'관계': { strong: ['관계', '친구', '사랑', '가족'], weak: ['사람', '동료'], priority: 7 },
180
'캐릭터': { strong: ['캐릭터', '인물', '히나나', '아이돌'], weak: ['성격', '말투'], priority: 8 },
181
'설정': { strong: ['설정', '배경', '규칙'], priority: 8 },
182
'세계관': { strong: ['세계관'], weak: ['세계', '도시', '학교'], priority: 7 },
183
'아이디어': { strong: ['아이디어', '발상', '기획', '컨셉', '소재'], weak: ['떠올랐', '생각났', '해보면'], priority: 8 },
184
'대사': { strong: ['대사', '말투'], weak: ['대화'], priority: 8 },
185
'글쓰기': { strong: ['글쓰기', '소설', '문체', '서사', '문단'], weak: ['글', '쓰고', '표현'], priority: 8 },
186
'철학': { strong: ['철학', '존재'], weak: ['의미', '가치'], priority: 7 },
187
'조언': { strong: ['조언', '추천'], weak: ['어떻게', '방법'], priority: 6 },
188
'일상': { strong: ['일상', '오늘 있었', '하루'], weak: ['오늘', '생활', '어제'], priority: 5 },
189
'기억': { strong: ['기억', '추억', '회상', '예전 일'], weak: ['예전', '떠오름'], priority: 7 },
190
'음악': { strong: ['음악', '노래', '멜로디', '앨범', '가사'], weak: ['듣고', '플레이리스트'], priority: 7 },
191
'미술': { strong: ['미술', '전시', '작품', '화가', '그림'], weak: ['갤러리'], priority: 7 },
192
'여행': { strong: ['여행', '숙소', '항공', '기차', '호텔'], weak: ['장소', '풍경', '가고 싶'], priority: 6 },
193
'공부': { strong: ['공부', '학습', '시험', '복습', '강의'], weak: ['배운', '외우'], priority: 7 },
194
'기술': { strong: ['코드', '개발', '기술', '프로그램', '마크다운', 'api', '서버'], weak: ['버그', '테스트', '기능'], priority: 8 },
195
'건강': { strong: ['건강', '수면', '운동', '병원', '약', '식단'], weak: ['몸', '컨디션', '피곤'], priority: 7 },
196
'작업': { strong: ['작업', '프로젝트', '업무', '마감', '수정'], weak: ['진행', '완료', '해야'], priority: 7 },
197
'인용': { strong: ['인용', '명언', '발췌'], weak: ['문장', '구절'], priority: 6 },
198
'질문': { strong: ['질문', '궁금', '왜', '어떻게'], weak: ['무엇', '뭐지', '될까'], priority: 6 },
199
'분석': { strong: ['분석', '해석', '비교'], priority: 7 },
200
'계획': { strong: ['계획', '일정', '목표', '루틴'], weak: ['예정', '다음', '언젠가'], priority: 7 },
201
'일기': { strong: ['일기', '오늘 있었', '하루', '어제 있었', '하루를'], weak: ['오늘', '어제', '기분'], priority: 8 },
202
'읽을거리': { strong: ['책', '읽을거리', '기사', '논문', '링크'], weak: ['읽기', '읽어', '읽을'], priority: 6 },
203
'감상': { strong: ['감상', '후기', '느낀점', '귀엽', '예쁘', '멋지', '좋았', '별로'], weak: ['인상', '느낌', '진짜'], priority: 7 },
204
'메모': { strong: ['메모', '노트', '끄적', '적어둠'], weak: ['참고', '잊지'], priority: 5 },
205
'사람': { strong: ['사람', '친구', '가족', '동료', '선배', '후배'], weak: ['만난', '대화한'], priority: 5 },
206
'대화': { strong: ['대화', '대사', '채팅', '말투'], weak: ['말했', '답변'], priority: 6 },
207
'생각': { strong: ['생각', '고민', '상상', '의문'], weak: ['왜', '문득'], priority: 6 },
208
'리뷰': { strong: ['리뷰', '후기', '평가'], weak: ['추천', '별점'], priority: 6 },
209
'영화': { strong: ['영화', '드라마', '애니', '시리즈'], weak: ['봤다', '본 작품'], priority: 6 },
210
'게임': { strong: ['게임', '플레이', '스테이지', '퀘스트'], weak: ['클리어'], priority: 6 },
211
'음식': { strong: ['음식', '식사', '맛집', '커피', '디저트'], weak: ['먹었', '맛있'], priority: 5 },
212
'쇼핑': { strong: ['쇼핑', '구매', '장바구니', '가격'], weak: ['사고 싶', '살까'], priority: 5 },
213
'할일': { strong: ['할 일', '해야 할', '체크리스트', 'todo'], weak: ['해야', '잊지 말'], priority: 8 },
214
'취향': { strong: ['취향', '좋아하', '싫어하', '최애'], weak: ['마음에 든'], priority: 6 },
215
'기록': { strong: ['기록', '로그', '정리'], weak: ['남겨'], priority: 5 },
216
'테스트': { strong: ['테스트', '시험해', '해보자', '검증'], weak: ['되는지'], priority: 6 }
217
};
218
219
function inferCatalogTags(question: string, excerpt: string): string[] {
220
const normalizedQuestion = question.toLowerCase();
221
const normalizedExcerpt = excerpt.toLowerCase();
222
223
return AI_NOTE_TAG_CATALOG
224
.map((tag) => {
225
const rule = AUTO_TAG_RULES[tag];
226
if (!rule) return { tag, score: 0, priority: 0 };
227
228
if ((rule.exclude || []).some((keyword) =>
229
normalizedQuestion.includes(keyword) || normalizedExcerpt.includes(keyword)
230
)) {
231
return { tag, score: 0, priority: rule.priority || 0 };
232
}
233
234
let score = 0;
235
rule.strong.forEach((keyword) => {
236
if (normalizedQuestion.includes(keyword)) score += 4;
237
if (normalizedExcerpt.includes(keyword)) score += 3;
238
});
239
(rule.weak || []).forEach((keyword) => {
240
if (normalizedQuestion.includes(keyword)) score += 2;
241
if (normalizedExcerpt.includes(keyword)) score += 1;
242
});
243
244
return { tag, score, priority: rule.priority || 0 };
245
})
246
.filter((item) => item.score >= 2)
247
.sort((a, b) => b.score - a.score || b.priority - a.priority || a.tag.localeCompare(b.tag, 'ko'))
248
.slice(0, 5)
249
.map((item) => item.tag);
250
}
251
252
function normalizeForSimilarity(value: string): string[] {
253
return value.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean);
254
}
255
256
function findSimilarNote(notes: AiNote[], excerpt: string): AiNote | undefined {
257
const incoming = new Set(normalizeForSimilarity(excerpt));
258
if (incoming.size === 0) return undefined;
259
260
return notes.find((note) => {
261
const existing = new Set(normalizeForSimilarity(note.excerpt));
262
if (existing.size === 0) return false;
263
let overlap = 0;
264
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
265
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
266
});
267
return overlap / Math.min(incoming.size, existing.size) >= 0.8;
268
});
269
}
270
271
function formatLocalKstDate(isoString: string): string {
272
return new Intl.DateTimeFormat('en-CA', {
273
timeZone: 'Asia/Seoul',
274
year: 'numeric',
275
month: '2-digit',
276
day: '2-digit'
277
}).format(new Date(isoString));
278
}
279
280
router.post('/chat', async (req: Request, res: Response) => {
281
if (!req.session || !req.session.chatHistory) {
282
return res.status(400).json({ error: "채팅 세션이 만료되었거나 존재하지 않아요~ 페이지를 새로고침 해볼래?" });
283
}
284
285
try {
286
const userMessage = req.body.message;
287
288
const model = genAI.getGenerativeModel({ model: "gemini-3-pro-preview" });
289
const chat = model.startChat({
290
history: req.session.chatHistory,
291
});
292
293
const result = await chat.sendMessage(userMessage);
294
const response = result.response;
295
const text = response.text();
296
297
req.session.chatHistory.push({ role: "user", parts: [{ text: escapeHtml(userMessage) }] });
298
req.session.chatHistory.push({ role: "model", parts: [{ text: text }] });
299
res.json({ response: text });
300
} catch (error) {
301
console.error("Gemini API 통신 중 에러 발생:", error);
302
res.status(500).json({ error: "히나나...... 지금은 조금 생각할 시간이 필요한가봐~" });
303
}
304
});
305
306
router.post('/hinana/reset-session', (req: Request, res: Response) => {
307
const useHinana = req.session.hinanaMode !== false;
308
309
if (req.session.username) {
310
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
311
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
312
const key = `${req.session.username}_${useHinana ? 'hinana' : 'normal'}`;
313
delete allHistory[key];
314
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
315
}
316
} else {
317
if (useHinana) delete req.session.hinanaHistory;
318
else delete req.session.normalHistory;
319
}
320
321
res.redirect('/hinana/ai');
322
});
323
324
router.post('/hinana/toggle-hinana-mode', (req: Request, res: Response) => {
325
const current = req.session.hinanaMode;
326
req.session.hinanaMode = !(current !== false);
327
328
delete req.session.hinanaHistory;
329
delete req.session.normalHistory;
330
331
res.redirect('/hinana/ai');
332
});
333
334
router.get('/hinana/ai', (req: Request, res: Response) => {
335
const settings = readSettings();
336
const useHinana = req.session.hinanaMode !== false;
337
let history: any[] = [];
338
339
if (req.session.username) {
340
const { myHistory } = getFileChatHistory(req.session.username, useHinana);
341
history = myHistory;
342
} else {
343
const historyKey = useHinana ? 'hinanaHistory' : 'normalHistory';
344
if (!req.session[historyKey]) {
345
req.session[historyKey] = [
346
{ role: "system", content: useHinana ? hinanaGptSystemPrompt : normalGptSystemPrompt },
347
{ role: "assistant", content: useHinana
348
? "아하~ 프로듀서님, 히나나예요! 뭐 부터 시작해볼까요?"
349
: "안녕하세요, 무엇을 도와드릴까요?" }
350
];
351
}
352
history = req.session[historyKey];
353
}
354
355
res.render('hinana/ai', {
356
theme: req.session.theme || req.cookies.theme || 'light',
357
username: req.session.username || null,
358
hinanaMode: useHinana,
359
initialMessage: useHinana
360
? "아하~ 프로듀서님, 히나나예요! 뭐 부터 시작해볼까요?"
361
: "안녕하세요, 무엇을 도와드릴까요?",
362
isGptEnabled: settings.isGptEnabled,
363
isSignupEnabled: settings.isSignupEnabled,
364
isAnonymousPostingEnabled: settings.isAnonymousPostingEnabled,
365
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
366
history: history,
367
currentUserProfileImage: req.session.username ? getProfileImage(req.session.username) : null
368
});
369
});
370
371
router.get('/hinana/ai/notes', (req: Request, res: Response) => {
372
const notes = getAiNotes(req);
373
const search = String(req.query.q || '').trim();
374
const date = String(req.query.date || '').trim();
375
const tag = String(req.query.tag || '').trim();
376
const sort = ['old', 'updated', 'pinned'].includes(String(req.query.sort))
377
? String(req.query.sort)
378
: 'new';
379
const filteredNotes = notes.filter((note) => {
380
const normalizedSearch = search.toLowerCase();
381
const matchesSearch = !normalizedSearch
382
|| (note.title || '').toLowerCase().includes(normalizedSearch)
383
|| note.question.toLowerCase().includes(normalizedSearch)
384
|| note.excerpt.toLowerCase().includes(normalizedSearch)
385
|| (note.tags || []).some((item) => item.toLowerCase().includes(normalizedSearch));
386
const matchesDate = !date || formatLocalKstDate(note.createdAt) === date;
387
const matchesTag = !tag || (note.tags || []).includes(tag);
388
return matchesSearch && matchesDate && matchesTag;
389
}).sort((a, b) => {
390
if (sort === 'old') return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
391
if (sort === 'updated') return new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime();
392
if (sort === 'pinned') {
393
if (Boolean(a.pinned) !== Boolean(b.pinned)) return a.pinned ? -1 : 1;
394
}
395
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
396
});
397
const pageSize = 10;
398
const totalPages = Math.max(1, Math.ceil(filteredNotes.length / pageSize));
399
const requestedPage = Math.max(1, Number.parseInt(String(req.query.page || '1'), 10) || 1);
400
const page = Math.min(requestedPage, totalPages);
401
const start = (page - 1) * pageSize;
402
403
res.render('hinana/aiNotes', {
404
theme: req.session.theme || req.cookies.theme || 'light',
405
username: req.session.username || null,
406
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
407
aiNotes: filteredNotes.slice(start, start + pageSize),
408
totalNotes: notes.length,
409
filteredCount: filteredNotes.length,
410
currentPage: page,
411
totalPages,
412
search,
413
date,
414
tag,
415
sort,
416
availableTags: Array.from(new Set([...AI_NOTE_TAG_CATALOG, ...notes.flatMap((note) => note.tags || [])])).sort(),
417
tagCatalog: AI_NOTE_TAG_CATALOG
418
});
419
});
420
421
router.post('/hinana/ai/notes', (req: Request, res: Response) => {
422
if (!req.session) {
423
return res.status(400).json({ success: false, message: '세션이 없습니다.' });
424
}
425
426
const question = normalizeNoteText(req.body?.question, 2000);
427
const excerpt = normalizeNoteExcerpt(req.body?.excerpt, 10000);
428
if (!question || !excerpt) {
429
return res.status(400).json({ success: false, message: '질문과 저장할 문장이 필요합니다.' });
430
}
431
432
const existingNotes = getAiNotes(req);
433
const existingNote = existingNotes.find((note) => note.question === question);
434
const similarNote = findSimilarNote(existingNotes, excerpt);
435
const shouldAppend = req.body?.append === true;
436
if (existingNote && !shouldAppend) {
437
return res.json({ success: true, requiresAppendConfirm: true, note: existingNote });
438
}
439
440
if (existingNote && shouldAppend) {
441
const appendedExcerpt = `${existingNote.excerpt}\n${excerpt}`;
442
const notes = req.session.username
443
? updateUserAiNote(req.session.username, existingNote.id, { excerpt: appendedExcerpt })
444
: getSessionAiNotes(req).map((note) => note.id === existingNote.id
445
? { ...note, excerpt: appendedExcerpt, updatedAt: new Date().toISOString() }
446
: note);
447
448
if (!req.session.username) {
449
req.session.aiNotes = notes;
450
}
451
452
recordSecurityLog(req, {
453
type: 'feature_use',
454
action: 'AI 노트 내용 추가',
455
target: existingNote.id
456
});
457
458
return res.json({
459
success: true,
460
note: notes.find((note) => note.id === existingNote.id),
461
notes,
462
appended: true
463
});
464
}
465
466
const note: AiNote = {
467
id: uuidv4(),
468
title: '',
469
question,
470
excerpt,
471
tags: inferCatalogTags(question, excerpt),
472
pinned: false,
473
sourceViewId: normalizeNoteText(req.body?.sourceViewId, 80) || undefined,
474
createdAt: new Date().toISOString(),
475
updatedAt: new Date().toISOString()
476
};
477
478
const notes = req.session.username
479
? saveUserAiNote(req.session.username, note)
480
: [note, ...getSessionAiNotes(req)];
481
482
if (!req.session.username) {
483
req.session.aiNotes = notes;
484
}
485
486
recordSecurityLog(req, {
487
type: 'feature_use',
488
action: 'AI 노트 생성',
489
target: note.id,
490
detail: note.title || question.slice(0, 80)
491
});
492
493
return res.json({ success: true, note, notes, similarNote });
494
});
495
496
router.patch('/hinana/ai/notes/:id', (req: Request, res: Response) => {
497
if (!req.session) {
498
return res.status(400).json({ success: false, message: '세션이 없습니다.' });
499
}
500
501
const noteId = String(req.params.id || '');
502
const excerpt = req.body?.excerpt === undefined ? undefined : normalizeNoteExcerpt(req.body.excerpt, 10000);
503
if (excerpt !== undefined && !excerpt) {
504
return res.status(400).json({ success: false, message: '노트 내용이 비어 있습니다.' });
505
}
506
507
const currentNotes = getAiNotes(req);
508
if (!currentNotes.some((note) => note.id === noteId)) {
509
return res.status(404).json({ success: false, message: '노트를 찾을 수 없습니다.' });
510
}
511
512
const patch: Partial<AiNote> = {};
513
if (excerpt !== undefined) patch.excerpt = excerpt;
514
if (req.body?.title !== undefined) patch.title = normalizeNoteText(req.body.title, 80);
515
if (req.body?.tags !== undefined) patch.tags = normalizeTags(req.body.tags);
516
if (req.body?.pinned !== undefined) patch.pinned = req.body.pinned === true;
517
const updatedAt = new Date().toISOString();
518
519
const notes = req.session.username
520
? updateUserAiNote(req.session.username, noteId, patch)
521
: getSessionAiNotes(req).map((note) => note.id === noteId ? { ...note, ...patch, updatedAt } : note);
522
523
if (!req.session.username) {
524
req.session.aiNotes = notes;
525
}
526
527
recordSecurityLog(req, {
528
type: 'feature_use',
529
action: 'AI 노트 수정',
530
target: noteId,
531
detail: patch.title || undefined
532
});
533
534
return res.json({ success: true, notes, note: notes.find((note) => note.id === noteId) });
535
});
536
537
router.get('/hinana/ai/notes/export/:format', (req: Request, res: Response) => {
538
const notes = getAiNotes(req);
539
const format = String(req.params.format);
540
if (format !== 'txt' && format !== 'md') {
541
return res.status(400).send('지원하지 않는 형식입니다.');
542
}
543
544
const body = notes.map((note) => {
545
const title = note.title || note.question;
546
const tags = (note.tags || []).length ? `\nTags: ${(note.tags || []).join(', ')}` : '';
547
const source = note.sourceViewId ? `\nSource: /hinana/ai/view/${note.sourceViewId}` : '';
548
if (format === 'md') {
549
return `## ${title}\n\n**Question:** ${note.question}\n\n${note.excerpt}${tags}${source}\n`;
550
}
551
return `${title}\nQ. ${note.question}\n${note.excerpt}${tags}${source}\n`;
552
}).join('\n');
553
554
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
555
res.setHeader('Content-Disposition', `attachment; filename="ai-notes.${format}"`);
556
recordSecurityLog(req, {
557
type: 'feature_use',
558
action: 'AI 노트 전체 내보내기',
559
detail: format
560
});
561
return res.send(body);
562
});
563
564
router.get('/hinana/ai/notes/:id/export/:format', (req: Request, res: Response) => {
565
const note = getAiNotes(req).find((item) => item.id === String(req.params.id));
566
if (!note) return res.status(404).send('노트를 찾을 수 없습니다.');
567
568
const format = String(req.params.format);
569
if (format !== 'txt' && format !== 'md') {
570
return res.status(400).send('지원하지 않는 형식입니다.');
571
}
572
573
const title = note.title || note.question;
574
const tags = (note.tags || []).length ? `\nTags: ${(note.tags || []).join(', ')}` : '';
575
const source = note.sourceViewId ? `\nSource: /hinana/ai/view/${note.sourceViewId}` : '';
576
const body = format === 'md'
577
? `## ${title}\n\n**Question:** ${note.question}\n\n${note.excerpt}${tags}${source}\n`
578
: `${title}\nQ. ${note.question}\n${note.excerpt}${tags}${source}\n`;
579
580
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
581
res.setHeader('Content-Disposition', `attachment; filename="ai-note-${note.id}.${format}"`);
582
recordSecurityLog(req, {
583
type: 'feature_use',
584
action: 'AI 노트 개별 내보내기',
585
target: note.id,
586
detail: format
587
});
588
return res.send(body);
589
});
590
591
router.delete('/hinana/ai/notes/:id', (req: Request, res: Response) => {
592
if (!req.session) {
593
return res.status(400).json({ success: false, message: '세션이 없습니다.' });
594
}
595
596
const noteId = String(req.params.id || '');
597
const notes = req.session.username
598
? deleteUserAiNote(req.session.username, noteId)
599
: getSessionAiNotes(req).filter((note) => note.id !== noteId);
600
601
if (!req.session.username) {
602
req.session.aiNotes = notes;
603
}
604
605
recordSecurityLog(req, {
606
type: 'feature_use',
607
action: 'AI 노트 삭제',
608
target: noteId
609
});
610
611
return res.json({ success: true, notes });
612
});
613
614
// ── 비동기 제출 ─────────────────────────────────────────────────
615
router.post('/chat-gpt/submit', async (req: Request, res: Response) => {
616
if (!req.session) return res.status(400).json({ error: '세션이 없습니다.' });
617
618
const settings = readSettings();
619
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
620
if (!settings.isGptEnabled && !isAdmin) {
621
return res.json({ jobId: null, response: '지금은 히나나가 잠시 쉬고 있어요... 💤 (관리자에 의해 기능이 중지됨)' });
622
}
623
624
const userMessage = (req.body.message || '').toString();
625
if (!userMessage.trim()) return res.status(400).json({ error: '메시지가 비어있어요~' });
626
627
const useHinana = req.session.hinanaMode !== false;
628
let history: any[] = [];
629
let saveCallback: Function | null = null;
630
631
if (req.session.username) {
632
const { allHistory, myHistory, key } = getFileChatHistory(req.session.username, useHinana);
633
history = myHistory;
634
saveCallback = () => {
635
allHistory[key] = { lastActive: new Date().toISOString(), messages: limitHistory(history, 20) };
636
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
637
};
638
} else {
639
const historyKey = useHinana ? 'hinanaHistory' : 'normalHistory';
640
if (!req.session[historyKey]) {
641
req.session[historyKey] = [
642
{ role: 'system', content: useHinana ? hinanaGptSystemPrompt : normalGptSystemPrompt },
643
{ role: 'assistant', content: useHinana ? '아하~ 프로듀서님, 히나나예요! 뭐 부터 시작해볼까요?' : '안녕하세요, 무엇을 도와드릴까요?' }
644
];
645
}
646
history = req.session[historyKey];
647
const sessionRef = req.session;
648
saveCallback = () => { sessionRef[historyKey] = limitHistory(history, 20); };
649
}
650
651
const jobId = genJobId();
652
chatJobs.set(jobId, { status: 'pending', createdAt: Date.now() });
653
654
// 즉시 응답
655
res.json({ jobId });
656
657
// GPT 처리는 서버에서 백그라운드로 계속
658
history.push({ role: 'user', content: userMessage });
659
const inputMessages = getModelInputMessages(history, 4).map((m: any) => ({ role: m.role, content: m.content }));
660
661
try {
662
const response: any = await openai.responses.create({
663
model: 'gpt-5.5',
664
input: inputMessages,
665
prompt: { id: 'pmpt_691e7a3e7fc88197b3719307aaec77b80977453400b4101c', version: '10' },
666
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
667
} as any);
668
669
const assistantMessage = response.output_text || (useHinana
670
? '흐으응~ 잘 안 된 것 같아요...'
671
: '죄송해요, 지금은 제대로 답변을 못 했어요.');
672
673
history.push({ role: 'assistant', content: assistantMessage });
674
if (saveCallback) saveCallback();
675
676
recordSecurityLog(req, {
677
type: 'feature_use',
678
action: 'AI 채팅 사용',
679
detail: useHinana ? '히나나 모드' : '일반 모드'
680
});
681
682
chatJobs.set(jobId, { status: 'done', response: assistantMessage, createdAt: Date.now() });
683
} catch (err) {
684
console.error('Async chat job error:', err);
685
chatJobs.set(jobId, { status: 'error', error: useHinana
686
? '히나나 GPT가 지금은 조금 쉬고 있는 것 같아요... 나중에 다시 시도해볼래요~?'
687
: '서버 오류가 발생했습니다.', createdAt: Date.now() });
688
}
689
});
690
691
// ── 결과 폴링 ────────────────────────────────────────────────────
692
router.get('/chat-gpt/result/:jobId', (req: Request, res: Response) => {
693
const job = chatJobs.get(String(req.params.jobId));
694
if (!job) return res.status(404).json({ error: 'not found' });
695
res.json({ status: job.status, response: job.response, error: job.error });
696
});
697
// ───────────────────────────────────────────────────────────────
698
699
router.post('/chat-gpt', async (req: Request, res: Response) => {
700
if (!req.session) {
701
return res.status(400).json({ error: "세션이 없습니다. 페이지를 새로고침 해주세요." });
702
}
703
704
const settings = readSettings();
705
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
706
707
if (!settings.isGptEnabled && !isAdmin) {
708
return res.json({
709
response: "지금은 히나나가 잠시 쉬고 있어요... 💤 (관리자에 의해 기능이 중지됨)"
710
});
711
}
712
713
const userMessage = (req.body.message || '').toString();
714
if (!userMessage.trim()) {
715
return res.status(400).json({ error: "메시지가 비어있어요~" });
716
}
717
718
// 재시도 시 중복 처리 방지 — 같은 requestId면 캐시된 응답 반환
719
const requestId = (req.body.requestId || '').toString().slice(0, 80);
720
if (requestId && req.session.lastChatRequestId === requestId && req.session.lastChatResponse) {
721
return res.json({ response: req.session.lastChatResponse });
722
}
723
724
const useHinana = req.session.hinanaMode !== false;
725
let history: any[] = [];
726
let saveCallback: Function | null = null;
727
728
if (req.session.username) {
729
const { allHistory, myHistory, key } = getFileChatHistory(req.session.username, useHinana);
730
history = myHistory;
731
732
saveCallback = () => {
733
allHistory[key] = { lastActive: new Date().toISOString(), messages: limitHistory(history, 20) };
734
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
735
};
736
} else {
737
const historyKey = useHinana ? 'hinanaHistory' : 'normalHistory';
738
739
if (!req.session[historyKey]) {
740
req.session[historyKey] = [
741
{ role: "system", content: useHinana ? hinanaGptSystemPrompt : normalGptSystemPrompt },
742
{ role: "assistant", content: useHinana
743
? "아하~ 프로듀서님, 히나나예요! 뭐 부터 시작해볼까요?"
744
: "안녕하세요, 무엇을 도와드릴까요?" }
745
];
746
}
747
history = req.session[historyKey];
748
749
saveCallback = () => {
750
req.session[historyKey] = limitHistory(history, 20);
751
};
752
}
753
754
try {
755
history.push({ role: "user", content: userMessage });
756
757
const inputMessages = getModelInputMessages(history, 4).map((m: any) => ({
758
role: m.role,
759
content: m.content
760
}));
761
762
const response: any = await openai.responses.create({
763
model: "gpt-5.5",
764
input: inputMessages,
765
prompt: {
766
"id": "pmpt_691e7a3e7fc88197b3719307aaec77b80977453400b4101c",
767
"version": "10"
768
},
769
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
770
} as any);
771
772
const assistantMessage = response.output_text || (
773
useHinana
774
? "흐으응~ 잘 안 된 것 같아요..."
775
: "죄송해요, 지금은 제대로 답변을 못 했어요."
776
);
777
778
history.push({ role: "assistant", content: assistantMessage });
779
if (saveCallback) saveCallback();
780
781
// idempotency 캐시 저장 (재시도 대비)
782
if (requestId) {
783
req.session.lastChatRequestId = requestId;
784
req.session.lastChatResponse = assistantMessage;
785
}
786
787
recordSecurityLog(req, {
788
type: 'feature_use',
789
action: 'AI 채팅 사용',
790
detail: useHinana ? '히나나 모드' : '일반 모드'
791
});
792
793
res.json({ response: assistantMessage });
794
795
} catch (error) {
796
console.error("OpenAI 통신 중 에러:", error);
797
res.status(500).json({
798
error: useHinana
799
? "히나나 GPT가 지금은 조금 쉬고 있는 것 같아요... 나중에 다시 시도해볼래요~?"
800
: "서버 오류가 발생했습니다."
801
});
802
}
803
});
804
805
router.post('/api/exhibition/review', async (req: Request, res: Response) => {
806
if (!req.session) {
807
return res.status(400).json({ error: '세션이 없습니다. 페이지를 새로고침 해주세요.' });
808
}
809
810
const { title, subtitle, description, techniques } = parseExhibitionReviewInput(req);
811
812
if (!title || !description) {
813
return res.status(400).json({ error: '작품 정보가 부족해서 감상평을 만들 수 없어요.' });
814
}
815
816
const cacheKey = buildExhibitionReviewCacheKey({ title, subtitle, description, techniques });
817
const cachedReview = getCachedExhibitionReview(cacheKey);
818
if (cachedReview) {
819
return res.json({ review: cachedReview });
820
}
821
822
const settings = readSettings();
823
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
824
if (!settings.isGptEnabled && !isAdmin) {
825
return res.status(403).json({
826
error: '히나나 감상평 기능이 잠시 쉬고 있어요. 잠시 후 다시 시도해 주세요.'
827
});
828
}
829
830
const inputMessages = [
831
{
832
role: 'system',
833
content: `${hinanaPersona}
834
835
추가 지시:
836
- 전시 작품 감상평을 한국어로 작성한다.
837
- 2~4문장, 220자 내외로 짧고 자연스럽게 작성한다.
838
- 과장된 사실/환각을 만들지 말고, 제공된 정보만 바탕으로 표현한다.
839
- 인삿말/마크다운/목록 없이 감상문 본문만 출력한다.`
840
},
841
{
842
role: 'user',
843
content: [
844
`작품 제목: ${title}`,
845
subtitle ? `부제: ${subtitle}` : '',
846
techniques.length > 0 ? `기법/메타: ${techniques.join(' | ')}` : '',
847
`설명: ${description}`
848
].filter(Boolean).join('\n')
849
}
850
];
851
852
try {
853
const response: any = await openai.responses.create({
854
model: 'gpt-5.5',
855
input: inputMessages
856
} as any);
857
858
const review = String(response.output_text || '').trim();
859
if (!review) {
860
return res.status(502).json({ error: '감상평 생성에 실패했어요. 잠시 후 다시 시도해 주세요.' });
861
}
862
863
setCachedExhibitionReview(cacheKey, review);
864
return res.json({ review });
865
} catch (error) {
866
console.error('전시 감상평 생성 오류:', error);
867
return res.status(500).json({ error: '히나나가 잠깐 쉬고 있어요. 조금 뒤에 다시 불러볼래요?' });
868
}
869
});
870
871
router.post('/api/exhibition/review/cached', (req: Request, res: Response) => {
872
if (!req.session) {
873
return res.status(400).json({ error: '세션이 없습니다. 페이지를 새로고침 해주세요.' });
874
}
875
876
const { title, subtitle, description, techniques } = parseExhibitionReviewInput(req);
877
if (!title || !description) {
878
return res.status(400).json({ error: '작품 정보가 부족해서 감상평을 확인할 수 없어요.' });
879
}
880
881
const cacheKey = buildExhibitionReviewCacheKey({ title, subtitle, description, techniques });
882
const cachedReview = getCachedExhibitionReview(cacheKey);
883
if (!cachedReview) {
884
return res.json({ cached: false });
885
}
886
887
return res.json({ cached: true, review: cachedReview });
888
});
889
890
// 어드민 전용: Q&A 웹 뷰 생성
891
router.post('/hinana/ai/create-view', (req: Request, res: Response) => {
892
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
893
recordSecurityLog(req, {
894
type: 'access_denied',
895
action: 'AI 답변 웹 뷰 생성 차단',
896
detail: '관리자 권한이 없는 사용자의 웹 뷰 생성 시도'
897
});
898
return res.status(403).json({ success: false, message: '권한이 없습니다.' });
899
}
900
901
const { question, answer } = req.body;
902
if (!question || !answer) {
903
return res.status(400).json({ success: false, message: '질문과 답변이 필요합니다.' });
904
}
905
906
let views: Record<string, any> = {};
907
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
908
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
909
}
910
911
const id = uuidv4();
912
views[id] = { question, answer, createdAt: new Date().toISOString() };
913
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
914
915
recordSecurityLog(req, {
916
type: 'admin_action',
917
action: 'AI 답변 웹 뷰 생성',
918
target: id
919
});
920
921
res.json({ success: true, viewUrl: `/hinana/ai/view/${id}` });
922
});
923
924
// 공개: Q&A 웹 뷰 조회
925
router.get('/hinana/ai/view/:id', (req: Request, res: Response) => {
926
let views: Record<string, any> = {};
927
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
928
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
929
}
930
931
const viewId = String(req.params.id);
932
const view = views[viewId];
933
if (!view) return res.status(404).send('찾을 수 없는 페이지입니다.');
934
935
res.render('hinana/aiView', {
936
question: view.question,
937
answer: view.answer,
938
createdAt: view.createdAt,
939
viewId,
940
theme: req.session.theme || req.cookies.theme || 'light',
941
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
942
});
943
});
944
945
export default router;
946