Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
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