Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
Update app.py
Browse files
app.py
CHANGED
@@ -49,7 +49,19 @@ ADMIN_PASSWORD = os.getenv("PASSWORD", "admin") # 환경 변수에서 가져오
|
|
49 |
|
50 |
# OpenAI API 키 설정
|
51 |
OPENAI_API_KEY = os.getenv("LLM_API", "")
|
52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
53 |
|
54 |
# 전역 캐시 객체
|
55 |
pdf_cache: Dict[str, Dict[str, Any]] = {}
|
@@ -224,8 +236,16 @@ async def get_pdf_embedding(pdf_id: str) -> Dict[str, Any]:
|
|
224 |
return {"error": str(e), "pdf_id": pdf_id}
|
225 |
|
226 |
# PDF 내용 기반 질의응답
|
|
|
227 |
async def query_pdf(pdf_id: str, query: str) -> Dict[str, Any]:
|
228 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
229 |
# 임베딩 데이터 가져오기
|
230 |
embedding_data = await get_pdf_embedding(pdf_id)
|
231 |
if "error" in embedding_data:
|
@@ -234,7 +254,6 @@ async def query_pdf(pdf_id: str, query: str) -> Dict[str, Any]:
|
|
234 |
# 청크 텍스트 모으기 (임시로 간단하게 전체 텍스트 사용)
|
235 |
all_text = "\n\n".join([f"Page {chunk['page']}: {chunk['text']}" for chunk in embedding_data["chunks"]])
|
236 |
|
237 |
-
# OpenAI API 호출
|
238 |
# 컨텍스트 크기를 고려하여 텍스트가 너무 길면 앞부분만 사용
|
239 |
max_context_length = 60000 # 토큰 수가 아닌 문자 수 기준 (대략적인 제한)
|
240 |
if len(all_text) > max_context_length:
|
@@ -249,37 +268,66 @@ async def query_pdf(pdf_id: str, query: str) -> Dict[str, Any]:
|
|
249 |
|
250 |
# gpt-4.1-mini 모델 사용
|
251 |
try:
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
261 |
|
262 |
-
|
263 |
-
|
264 |
-
"answer": answer,
|
265 |
-
"pdf_id": pdf_id,
|
266 |
-
"query": query
|
267 |
-
}
|
268 |
except Exception as api_error:
|
269 |
-
logger.error(f"OpenAI API 호출 오류: {api_error}")
|
270 |
-
|
271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
272 |
except Exception as e:
|
273 |
logger.error(f"질의응답 처리 오류: {e}")
|
274 |
return {"error": str(e)}
|
275 |
|
276 |
# PDF 요약 생성
|
|
|
277 |
async def summarize_pdf(pdf_id: str) -> Dict[str, Any]:
|
278 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
279 |
# 임베딩 데이터 가져오기
|
280 |
embedding_data = await get_pdf_embedding(pdf_id)
|
281 |
if "error" in embedding_data:
|
282 |
-
return {"error": embedding_data["error"]}
|
283 |
|
284 |
# 청크 텍스트 모으기 (제한된 길이)
|
285 |
all_text = "\n\n".join([f"Page {chunk['page']}: {chunk['text']}" for chunk in embedding_data["chunks"]])
|
@@ -291,29 +339,54 @@ async def summarize_pdf(pdf_id: str) -> Dict[str, Any]:
|
|
291 |
|
292 |
# OpenAI API 호출
|
293 |
try:
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
303 |
|
304 |
-
|
305 |
-
|
306 |
-
"summary": summary,
|
307 |
-
"pdf_id": pdf_id
|
308 |
-
}
|
309 |
except Exception as api_error:
|
310 |
-
logger.error(f"OpenAI API 호출 오류: {api_error}")
|
311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
312 |
|
313 |
except Exception as e:
|
314 |
logger.error(f"PDF 요약 생성 오류: {e}")
|
315 |
-
return {
|
|
|
|
|
|
|
316 |
|
|
|
317 |
# 최적화된 PDF 페이지 캐싱 함수
|
318 |
async def cache_pdf(pdf_path: str):
|
319 |
try:
|
@@ -2268,79 +2341,104 @@ HTML = """
|
|
2268 |
}
|
2269 |
|
2270 |
// PDF 요약 로드 함수
|
2271 |
-
|
2272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2273 |
|
2274 |
-
|
2275 |
-
|
2276 |
-
|
2277 |
-
|
2278 |
-
// 서버에 요약 요청
|
2279 |
-
const response = await fetch(`/api/ai/summarize-pdf/${currentPdfId}`);
|
2280 |
-
const data = await response.json();
|
2281 |
-
|
2282 |
-
// 로딩 표시기 제거
|
2283 |
-
typingIndicator.remove();
|
2284 |
-
|
2285 |
-
if (data.error) {
|
2286 |
-
addChatMessage(`요약을 생성하는 중 오류가 발생했습니다: ${data.error}`);
|
2287 |
-
} else {
|
2288 |
-
// 환영 메시지와 요약 추가
|
2289 |
-
addChatMessage(`안녕하세요! 이 PDF에 대해 어떤 것이든 질문해주세요. 제가 도와드리겠습니다.<br><br><strong>PDF 요약:</strong><br>${data.summary}`);
|
2290 |
-
hasLoadedSummary = true;
|
2291 |
-
}
|
2292 |
-
} catch (error) {
|
2293 |
-
console.error("PDF 요약 로드 오류:", error);
|
2294 |
-
addChatMessage("PDF 요약을 로드하는 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
|
2295 |
-
} finally {
|
2296 |
-
isAiProcessing = false;
|
2297 |
}
|
|
|
|
|
|
|
|
|
2298 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2299 |
|
2300 |
-
//
|
2301 |
-
|
2302 |
-
|
2303 |
-
|
2304 |
-
|
2305 |
-
|
2306 |
-
|
2307 |
-
|
2308 |
-
|
2309 |
-
|
2310 |
-
|
2311 |
-
|
2312 |
-
|
2313 |
-
|
2314 |
-
|
2315 |
-
|
2316 |
-
|
2317 |
-
|
2318 |
-
|
2319 |
-
|
2320 |
-
|
2321 |
-
|
2322 |
-
|
2323 |
-
|
2324 |
-
|
2325 |
-
|
2326 |
-
|
2327 |
-
|
2328 |
-
|
2329 |
-
|
2330 |
-
} else {
|
2331 |
-
// AI 응답 추가 (마크다운 처리 등 필요시 추가)
|
2332 |
-
addChatMessage(data.answer);
|
2333 |
-
}
|
2334 |
-
} catch (error) {
|
2335 |
-
console.error("질문 제출 오류:", error);
|
2336 |
-
addChatMessage("죄송합니다. 서버와 통신 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
|
2337 |
-
} finally {
|
2338 |
-
isAiProcessing = false;
|
2339 |
-
$id('aiChatSubmit').disabled = false;
|
2340 |
-
$id('aiChatInput').value = '';
|
2341 |
-
$id('aiChatInput').focus();
|
2342 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2343 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2344 |
|
2345 |
// DOM이 로드되면 실행
|
2346 |
document.addEventListener('DOMContentLoaded', function() {
|
|
|
49 |
|
50 |
# OpenAI API 키 설정
|
51 |
OPENAI_API_KEY = os.getenv("LLM_API", "")
|
52 |
+
# API 키가 없거나 비어있을 때 플래그 설정
|
53 |
+
HAS_VALID_API_KEY = bool(OPENAI_API_KEY and OPENAI_API_KEY.strip())
|
54 |
+
|
55 |
+
if HAS_VALID_API_KEY:
|
56 |
+
try:
|
57 |
+
openai_client = OpenAI(api_key=OPENAI_API_KEY, timeout=30.0)
|
58 |
+
logger.info("OpenAI 클라이언트 초기화 성공")
|
59 |
+
except Exception as e:
|
60 |
+
logger.error(f"OpenAI 클라이언트 초기화 실패: {e}")
|
61 |
+
HAS_VALID_API_KEY = False
|
62 |
+
else:
|
63 |
+
logger.warning("유효한 OpenAI API 키가 없습니다. AI 기능이 제한됩니다.")
|
64 |
+
openai_client = None
|
65 |
|
66 |
# 전역 캐시 객체
|
67 |
pdf_cache: Dict[str, Dict[str, Any]] = {}
|
|
|
236 |
return {"error": str(e), "pdf_id": pdf_id}
|
237 |
|
238 |
# PDF 내용 기반 질의응답
|
239 |
+
# PDF 내용 기반 질의응답 함수 개선
|
240 |
async def query_pdf(pdf_id: str, query: str) -> Dict[str, Any]:
|
241 |
try:
|
242 |
+
# API 키가 없거나 유효하지 않은 경우
|
243 |
+
if not HAS_VALID_API_KEY or not openai_client:
|
244 |
+
return {
|
245 |
+
"error": "OpenAI API 키가 설정되지 않았습니다.",
|
246 |
+
"answer": "죄송합니다. 현재 AI 기능이 비활성화되어 있어 질문에 답변할 수 없습니다. 시스템 관리자에게 문의하세요."
|
247 |
+
}
|
248 |
+
|
249 |
# 임베딩 데이터 가져오기
|
250 |
embedding_data = await get_pdf_embedding(pdf_id)
|
251 |
if "error" in embedding_data:
|
|
|
254 |
# 청크 텍스트 모으기 (임시로 간단하게 전체 텍스트 사용)
|
255 |
all_text = "\n\n".join([f"Page {chunk['page']}: {chunk['text']}" for chunk in embedding_data["chunks"]])
|
256 |
|
|
|
257 |
# 컨텍스트 크기를 고려하여 텍스트가 너무 길면 앞부분만 사용
|
258 |
max_context_length = 60000 # 토큰 수가 아닌 문자 수 기준 (대략적인 제한)
|
259 |
if len(all_text) > max_context_length:
|
|
|
268 |
|
269 |
# gpt-4.1-mini 모델 사용
|
270 |
try:
|
271 |
+
# 타임아웃 및 재시도 설정 개선
|
272 |
+
for attempt in range(3): # 최대 3번 재시도
|
273 |
+
try:
|
274 |
+
response = openai_client.chat.completions.create(
|
275 |
+
model="gpt-4.1-mini",
|
276 |
+
messages=[
|
277 |
+
{"role": "system", "content": system_prompt},
|
278 |
+
{"role": "user", "content": f"다음 PDF 내용을 참고하여 질문에 답변해주세요.\n\nPDF 내용:\n{all_text}\n\n질문: {query}"}
|
279 |
+
],
|
280 |
+
temperature=0.7,
|
281 |
+
max_tokens=2048,
|
282 |
+
timeout=30.0 # 30초 타임아웃
|
283 |
+
)
|
284 |
+
|
285 |
+
answer = response.choices[0].message.content
|
286 |
+
return {
|
287 |
+
"answer": answer,
|
288 |
+
"pdf_id": pdf_id,
|
289 |
+
"query": query
|
290 |
+
}
|
291 |
+
except Exception as api_error:
|
292 |
+
logger.error(f"OpenAI API 호출 오류 (시도 {attempt+1}/3): {api_error}")
|
293 |
+
if attempt == 2: # 마지막 시도에서도 실패
|
294 |
+
raise api_error
|
295 |
+
await asyncio.sleep(1 * (attempt + 1)) # 재시도 간 지연 시간 증가
|
296 |
|
297 |
+
# 여기까지 도달하지 않아야 함
|
298 |
+
raise Exception("API 호출 재시도 모두 실패")
|
|
|
|
|
|
|
|
|
299 |
except Exception as api_error:
|
300 |
+
logger.error(f"OpenAI API 호출 최종 오류: {api_error}")
|
301 |
+
# 오류 유형에 따른 더 명확한 메시지 제공
|
302 |
+
error_message = str(api_error)
|
303 |
+
if "Connection" in error_message:
|
304 |
+
return {"error": "OpenAI 서버와 연결할 수 없습니다. 인터넷 연결을 확인하세요."}
|
305 |
+
elif "Unauthorized" in error_message or "Authentication" in error_message:
|
306 |
+
return {"error": "API 키가 유효하지 않습니다."}
|
307 |
+
elif "Rate limit" in error_message:
|
308 |
+
return {"error": "API 호출 한도를 초과했습니다. 잠시 후 다시 시도하세요."}
|
309 |
+
else:
|
310 |
+
return {"error": f"AI 응답 생성 중 오류가 발생했습니다: {error_message}"}
|
311 |
+
|
312 |
except Exception as e:
|
313 |
logger.error(f"질의응답 처리 오류: {e}")
|
314 |
return {"error": str(e)}
|
315 |
|
316 |
# PDF 요약 생성
|
317 |
+
# PDF 요약 생성 함수 개선
|
318 |
async def summarize_pdf(pdf_id: str) -> Dict[str, Any]:
|
319 |
try:
|
320 |
+
# API 키가 없거나 유효하지 않은 경우
|
321 |
+
if not HAS_VALID_API_KEY or not openai_client:
|
322 |
+
return {
|
323 |
+
"error": "OpenAI API 키가 설정되지 않았습니다. 'LLM_API' 환경 변수를 확인하세요.",
|
324 |
+
"summary": "API 키가 없어 요약을 생성할 수 없습니다. 시스템 관리자에게 문의하세요."
|
325 |
+
}
|
326 |
+
|
327 |
# 임베딩 데이터 가져오기
|
328 |
embedding_data = await get_pdf_embedding(pdf_id)
|
329 |
if "error" in embedding_data:
|
330 |
+
return {"error": embedding_data["error"], "summary": "PDF에서 텍스트를 추출할 수 없습니다."}
|
331 |
|
332 |
# 청크 텍스트 모으기 (제한된 길이)
|
333 |
all_text = "\n\n".join([f"Page {chunk['page']}: {chunk['text']}" for chunk in embedding_data["chunks"]])
|
|
|
339 |
|
340 |
# OpenAI API 호출
|
341 |
try:
|
342 |
+
# 타임아웃 및 재시도 설정 개선
|
343 |
+
for attempt in range(3): # 최대 3번 재시도
|
344 |
+
try:
|
345 |
+
response = openai_client.chat.completions.create(
|
346 |
+
model="gpt-4.1-mini",
|
347 |
+
messages=[
|
348 |
+
{"role": "system", "content": "다음 PDF 내용을 간결하게 요약해주세요. 핵심 주제와 주요 포인트를 포함한 요약을 500자 이내로 작성해주세요."},
|
349 |
+
{"role": "user", "content": f"PDF 내용:\n{all_text}"}
|
350 |
+
],
|
351 |
+
temperature=0.7,
|
352 |
+
max_tokens=1024,
|
353 |
+
timeout=30.0 # 30초 타임아웃
|
354 |
+
)
|
355 |
+
|
356 |
+
summary = response.choices[0].message.content
|
357 |
+
return {
|
358 |
+
"summary": summary,
|
359 |
+
"pdf_id": pdf_id
|
360 |
+
}
|
361 |
+
except Exception as api_error:
|
362 |
+
logger.error(f"OpenAI API 호출 오류 (시도 {attempt+1}/3): {api_error}")
|
363 |
+
if attempt == 2: # 마지막 시도에서도 실패
|
364 |
+
raise api_error
|
365 |
+
await asyncio.sleep(1 * (attempt + 1)) # 재시도 간 지연 시간 증가
|
366 |
|
367 |
+
# 여기까지 도달하지 않아야 함
|
368 |
+
raise Exception("API 호출 재시도 모두 실패")
|
|
|
|
|
|
|
369 |
except Exception as api_error:
|
370 |
+
logger.error(f"OpenAI API 호출 최종 오류: {api_error}")
|
371 |
+
# 오류 유형에 따른 더 명확한 메시지 제공
|
372 |
+
error_message = str(api_error)
|
373 |
+
if "Connection" in error_message:
|
374 |
+
return {"error": "OpenAI 서버와 연결할 수 없습니다. 인터넷 연결을 확인하세요.", "pdf_id": pdf_id}
|
375 |
+
elif "Unauthorized" in error_message or "Authentication" in error_message:
|
376 |
+
return {"error": "API 키가 유효하지 않습니다.", "pdf_id": pdf_id}
|
377 |
+
elif "Rate limit" in error_message:
|
378 |
+
return {"error": "API 호출 한도를 초과했습니다. 잠시 후 다시 시도하세요.", "pdf_id": pdf_id}
|
379 |
+
else:
|
380 |
+
return {"error": f"AI 요약 생성 중 오류가 발생했습니다: {error_message}", "pdf_id": pdf_id}
|
381 |
|
382 |
except Exception as e:
|
383 |
logger.error(f"PDF 요약 생성 오류: {e}")
|
384 |
+
return {
|
385 |
+
"error": str(e),
|
386 |
+
"summary": "PDF 요약 중 오류가 발생했습니다. PDF 페이지 수가 너무 많거나 형식이 지원되지 않을 수 있습니다."
|
387 |
+
}
|
388 |
|
389 |
+
|
390 |
# 최적화된 PDF 페이지 캐싱 함수
|
391 |
async def cache_pdf(pdf_path: str):
|
392 |
try:
|
|
|
2341 |
}
|
2342 |
|
2343 |
// PDF 요약 로드 함수
|
2344 |
+
// PDF 요약 로드 함수
|
2345 |
+
async function loadPdfSummary() {
|
2346 |
+
if (!currentPdfId || isAiProcessing || hasLoadedSummary) return;
|
2347 |
+
|
2348 |
+
try {
|
2349 |
+
isAiProcessing = true;
|
2350 |
+
const typingIndicator = addTypingIndicator();
|
2351 |
+
|
2352 |
+
// 서버에 요약 요청
|
2353 |
+
const response = await fetch(`/api/ai/summarize-pdf/${currentPdfId}`);
|
2354 |
+
const data = await response.json();
|
2355 |
+
|
2356 |
+
// 로딩 표시기 제거
|
2357 |
+
typingIndicator.remove();
|
2358 |
+
|
2359 |
+
if (data.error) {
|
2360 |
+
// 오류 메시지 표시
|
2361 |
+
addChatMessage(`PDF 요약을 생성하는 중 문제가 발생했습니다: ${data.error}<br><br>계속 질문을 입력하시면 PDF 내용을 기반으로 답변을 시도하겠습니다.`);
|
2362 |
|
2363 |
+
// 요약이 실패해도 특정 경우에는 사용자에게 알리고 계속 사용 가능하도록 설정
|
2364 |
+
if (data.summary) {
|
2365 |
+
addChatMessage(`<strong>PDF에서 추출한 정보:</strong><br>${data.summary}`);
|
2366 |
+
hasLoadedSummary = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2367 |
}
|
2368 |
+
} else {
|
2369 |
+
// 환영 메시지와 요약 추가
|
2370 |
+
addChatMessage(`안녕하세요! 이 PDF에 대해 어떤 것이든 질문해주세요. 제가 도와드리겠습니다.<br><br><strong>PDF 요약:</strong><br>${data.summary}`);
|
2371 |
+
hasLoadedSummary = true;
|
2372 |
}
|
2373 |
+
} catch (error) {
|
2374 |
+
console.error("PDF 요약 로드 오류:", error);
|
2375 |
+
addChatMessage(`PDF 요약을 로드하는 중 오류가 발생했습니다. 서버 연결을 확인해주세요.<br><br>어떤 질문이든 입력하시면 최선을 다해 답변하겠습니다.`);
|
2376 |
+
} finally {
|
2377 |
+
isAiProcessing = false;
|
2378 |
+
}
|
2379 |
+
}
|
2380 |
+
|
2381 |
+
// 질문 제출 함수
|
2382 |
+
async function submitQuestion(question) {
|
2383 |
+
if (!currentPdfId || isAiProcessing || !question.trim()) return;
|
2384 |
+
|
2385 |
+
try {
|
2386 |
+
isAiProcessing = true;
|
2387 |
+
$id('aiChatSubmit').disabled = true;
|
2388 |
|
2389 |
+
// 사용자 메시지 추가
|
2390 |
+
addChatMessage(question, true);
|
2391 |
+
|
2392 |
+
// 로딩 표시기 추가
|
2393 |
+
const typingIndicator = addTypingIndicator();
|
2394 |
+
|
2395 |
+
// 서버에 질의 요청
|
2396 |
+
const response = await fetch(`/api/ai/query-pdf/${currentPdfId}`, {
|
2397 |
+
method: 'POST',
|
2398 |
+
headers: {
|
2399 |
+
'Content-Type': 'application/json'
|
2400 |
+
},
|
2401 |
+
body: JSON.stringify({ query: question }),
|
2402 |
+
// 타임아웃 설정 추가
|
2403 |
+
signal: AbortSignal.timeout(60000) // 60초 타임아웃
|
2404 |
+
});
|
2405 |
+
|
2406 |
+
const data = await response.json();
|
2407 |
+
|
2408 |
+
// 로딩 표시기 제거
|
2409 |
+
typingIndicator.remove();
|
2410 |
+
|
2411 |
+
if (data.error) {
|
2412 |
+
// 오류 메시지에 따라 다른 친절한 안내 제공
|
2413 |
+
if (data.error.includes("API 키")) {
|
2414 |
+
addChatMessage("죄송합니다. 현재 AI 서비스에 연결할 수 없습니다. 시스템 관리자에게 API 키 설정을 확인해달라고 요청해주세요.");
|
2415 |
+
} else if (data.error.includes("연결")) {
|
2416 |
+
addChatMessage("죄송합니다. AI 서비스에 연결할 수 없습니다. 인터넷 연결을 확인하거나 잠시 후 다시 시도해주세요.");
|
2417 |
+
} else {
|
2418 |
+
addChatMessage(`죄송합니다. 질문에 답변하는 중 문제가 발생했습니다: ${data.error}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2419 |
}
|
2420 |
+
} else {
|
2421 |
+
// AI 응답 추가
|
2422 |
+
addChatMessage(data.answer);
|
2423 |
+
}
|
2424 |
+
} catch (error) {
|
2425 |
+
console.error("질문 제출 오류:", error);
|
2426 |
+
if (error.name === 'AbortError') {
|
2427 |
+
addChatMessage("죄송합니다. 응답 시간이 너무 오래 걸려 요청이 취소되었습니다. 인터넷 연결을 확인하거나 더 짧은 질문으로 다시 시도해보세요.");
|
2428 |
+
} else {
|
2429 |
+
addChatMessage("죄송합니다. 서버와 통신 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
|
2430 |
}
|
2431 |
+
} finally {
|
2432 |
+
isAiProcessing = false;
|
2433 |
+
$id('aiChatSubmit').disabled = false;
|
2434 |
+
$id('aiChatInput').value = '';
|
2435 |
+
$id('aiChatInput').focus();
|
2436 |
+
}
|
2437 |
+
}
|
2438 |
+
|
2439 |
+
|
2440 |
+
|
2441 |
+
|
2442 |
|
2443 |
// DOM이 로드되면 실행
|
2444 |
document.addEventListener('DOMContentLoaded', function() {
|