Download static/index.html from aiaiteam/document: direct link, hf CLI and curl.
- Browser
- Download file 23.3 kB
-
https://huggingface.co/spaces/aiaiteam/document/resolve/main/static/index.html
- Command line
-
hf download hf://spaces/aiaiteam/document/static/index.html
-
curl -L -o index.html https://huggingface.co/spaces/aiaiteam/document/resolve/main/static/index.html
23.3 kB
| <html lang="ko"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>공문 검토·작성 시스템</title> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: "맑은 고딕", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif; | |
| font-size: 14px; | |
| background: #f4f6f9; | |
| color: #222; | |
| min-height: 100vh; | |
| } | |
| header { | |
| background: #003087; | |
| color: #fff; | |
| padding: 14px 24px; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| header h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; } | |
| header .badge { | |
| background: #ffd700; | |
| color: #003087; | |
| font-size: 11px; | |
| font-weight: 700; | |
| padding: 2px 8px; | |
| border-radius: 10px; | |
| } | |
| .container { max-width: 960px; margin: 24px auto; padding: 0 16px 40px; } | |
| .card { | |
| background: #fff; | |
| border-radius: 8px; | |
| box-shadow: 0 1px 4px rgba(0,0,0,.10); | |
| padding: 20px 24px; | |
| margin-bottom: 16px; | |
| } | |
| /* 문서 양식 선택 (작성 탭) */ | |
| .doc-type-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } | |
| .doc-type-row .label { font-weight: 600; color: #555; min-width: 64px; } | |
| .doc-type-row label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; } | |
| .doc-type-row input[type=radio] { accent-color: #003087; } | |
| /* 탭 */ | |
| .tabs { display: flex; border-bottom: 2px solid #e0e4ea; margin-bottom: 16px; gap: 2px; } | |
| .tab-btn { | |
| padding: 10px 28px; | |
| border: none; background: none; cursor: pointer; | |
| font-size: 14px; font-weight: 600; color: #777; | |
| border-bottom: 3px solid transparent; | |
| margin-bottom: -2px; | |
| transition: color .15s, border-color .15s; | |
| } | |
| .tab-btn.active { color: #003087; border-bottom-color: #003087; } | |
| .tab-btn:hover:not(.active) { color: #333; } | |
| .panel { display: none; } | |
| .panel.active { display: block; } | |
| .panel label.field-label { display: block; font-size: 12px; font-weight: 600; color: #555; margin-bottom: 6px; } | |
| textarea { | |
| width: 100%; | |
| border: 1px solid #d0d5dd; border-radius: 6px; | |
| padding: 10px 12px; | |
| font-family: inherit; font-size: 13px; | |
| resize: vertical; line-height: 1.6; | |
| outline: none; transition: border-color .15s; | |
| } | |
| textarea:focus { border-color: #003087; } | |
| #write-input { min-height: 140px; } | |
| #review-input { min-height: 200px; } | |
| /* 버튼 */ | |
| .btn-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } | |
| .btn { | |
| padding: 8px 18px; | |
| border: none; border-radius: 6px; | |
| cursor: pointer; font-size: 13px; font-weight: 600; | |
| transition: background .15s, opacity .15s; | |
| } | |
| .btn-primary { background: #003087; color: #fff; } | |
| .btn-primary:hover:not(:disabled) { background: #00246e; } | |
| .btn-secondary { background: #e8edf5; color: #333; } | |
| .btn-secondary:hover:not(:disabled) { background: #d5dce8; } | |
| .btn:disabled { opacity: .4; cursor: default; } | |
| #status-msg { margin-left: auto; font-size: 12px; color: #666; } | |
| /* 결과 */ | |
| .result-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } | |
| .result-header span { font-weight: 600; color: #555; font-size: 13px; } | |
| #output { | |
| width: 100%; min-height: 280px; | |
| background: #f8f9fb; | |
| border: 1px solid #e0e4ea; border-radius: 6px; | |
| padding: 14px 16px; | |
| font-family: "D2Coding", "Consolas", "맑은 고딕", monospace; | |
| font-size: 13px; line-height: 1.8; | |
| white-space: pre-wrap; word-break: break-all; | |
| } | |
| /* 로딩 배너 */ | |
| #loading-banner { | |
| background: #fffbe6; border: 1px solid #ffe58f; border-radius: 6px; | |
| padding: 10px 16px; font-size: 13px; color: #7a5200; | |
| margin-bottom: 12px; display: flex; align-items: center; gap: 8px; | |
| } | |
| #loading-banner.hidden { display: none; } | |
| .spinner { | |
| width: 16px; height: 16px; | |
| border: 2px solid #ffe58f; border-top-color: #d48806; | |
| border-radius: 50%; animation: spin .8s linear infinite; flex-shrink: 0; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* 문서 유형 선택 모달 (검토 전용) */ | |
| #type-modal { | |
| display: none; position: fixed; inset: 0; | |
| background: rgba(0,0,0,.45); z-index: 100; | |
| align-items: center; justify-content: center; | |
| } | |
| #type-modal.open { display: flex; } | |
| .modal-box { | |
| background: #fff; border-radius: 12px; | |
| padding: 28px 32px 24px; width: 320px; | |
| box-shadow: 0 8px 32px rgba(0,0,0,.2); text-align: center; | |
| } | |
| .modal-box h2 { font-size: 16px; font-weight: 700; margin-bottom: 18px; color: #111; } | |
| .modal-type-btn { | |
| display: block; width: 100%; margin-bottom: 10px; | |
| padding: 11px 0; border: 2px solid #e0e4ea; border-radius: 8px; | |
| background: #fff; cursor: pointer; font-size: 14px; font-weight: 600; color: #222; | |
| transition: border-color .15s, background .15s; | |
| } | |
| .modal-type-btn:hover { border-color: #003087; background: #f0f4ff; color: #003087; } | |
| .modal-cancel { font-size: 12px; color: #888; cursor: pointer; margin-top: 4px; } | |
| .modal-cancel:hover { color: #333; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- 문서 유형 선택 모달 (검토 버튼 클릭 시 표시) --> | |
| <div id="type-modal"> | |
| <div class="modal-box"> | |
| <h2>검토할 문서 유형 선택</h2> | |
| <button class="modal-type-btn" data-type="공문-외부발송">공문 — 외부발송</button> | |
| <button class="modal-type-btn" data-type="공문-내부">공문 — 내부</button> | |
| <button class="modal-type-btn" data-type="보고서">보고서</button> | |
| <div class="modal-cancel" id="modal-cancel">취소</div> | |
| </div> | |
| </div> | |
| <header> | |
| <h1>공문 검토·작성 시스템</h1> | |
| <span class="badge">KEPCO AI</span> | |
| </header> | |
| <div class="container"> | |
| <div id="loading-banner"> | |
| <div class="spinner"></div> | |
| <span id="loading-msg">모델 로딩 중... 잠시만 기다려주세요.</span> | |
| </div> | |
| <div class="card"> | |
| <div class="tabs"> | |
| <button class="tab-btn active" data-tab="write">작성</button> | |
| <button class="tab-btn" data-tab="review">검토</button> | |
| </div> | |
| <!-- 작성 패널 --> | |
| <div id="panel-write" class="panel active"> | |
| <!-- 문서 양식 선택 (작성 전용) --> | |
| <div class="doc-type-row" style="margin-bottom:14px"> | |
| <span class="label">문서 양식</span> | |
| <label><input type="radio" name="docType" value="공문-외부발송" checked> 공문-외부발송</label> | |
| <label><input type="radio" name="docType" value="공문-내부"> 공문-내부</label> | |
| <label><input type="radio" name="docType" value="보고서"> 보고서</label> | |
| </div> | |
| <label class="field-label">내용 요점 (작성할 내용의 핵심 사항을 입력하세요)</label> | |
| <textarea id="write-input" placeholder="예) 안전교육 실시 협조 요청. 일시: 2026.7.15(화) 14:00~16:00, 장소: 본사 대강당, 대상: 전 직원"></textarea> | |
| </div> | |
| <!-- 검토 패널 --> | |
| <div id="panel-review" class="panel"> | |
| <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px"> | |
| <label class="field-label" style="margin:0">원문 입력 (공문·보고서를 붙여넣거나 파일을 불러오세요)</label> | |
| <label class="btn btn-secondary" style="cursor:pointer;padding:6px 14px;font-size:12px"> | |
| 파일 불러오기... | |
| <input id="file-input" type="file" accept=".hwp,.hwpx,.docx,.pdf,.txt" style="display:none"> | |
| </label> | |
| </div> | |
| <div id="upload-status" style="font-size:12px;color:#888;margin-bottom:4px;min-height:16px"></div> | |
| <textarea id="review-input" placeholder="검토할 문서 전체 내용을 여기에 붙여넣으세요..."></textarea> | |
| <div style="font-size:12px;color:#aaa;margin-top:6px">검토 버튼을 누르면 문서 유형을 선택합니다.</div> | |
| </div> | |
| <div class="btn-row" style="margin-top:14px"> | |
| <button id="run-btn" class="btn btn-primary" disabled>생성</button> | |
| <button id="stop-btn" class="btn btn-secondary" disabled>중지</button> | |
| <span id="status-msg"></span> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="result-header"> | |
| <span>결과</span> | |
| <div style="display:flex;gap:8px"> | |
| <button id="copy-btn" class="btn btn-secondary">복사</button> | |
| <button id="dl-hwpx-btn" class="btn btn-secondary">HWPX 저장</button> | |
| </div> | |
| </div> | |
| <div id="output"></div> | |
| </div> | |
| </div> | |
| <script> | |
| // ============================================================ | |
| // 시스템 프롬프트 | |
| // ============================================================ | |
| const PROMPTS_WRITE = { | |
| "공문-외부발송": `당신은 한국전력공사(한전, KEPCO) 외부 공문 작성 전문가입니다. 현재는 2026년입니다. | |
| 사용자가 요청한 내용을 아래 양식에 정확히 맞춰 작성하세요. | |
| [외부 발송 공문 특성] | |
| - 수신: 외부 기관·기업·정부부처 등 | |
| - 격식체로 정중하게 작성 | |
| - 붙임·끝. 구조 필수 | |
| [양식] | |
| 수신 (외부 기관명/직위) | |
| 제목 (문서 제목) | |
| (본문 도입 — 2~3문장으로 목적·일정·요청사항 압축) | |
| 1. (세부 항목 1) | |
| 2. (세부 항목 2) | |
| 가. (서브 항목) | |
| 나. (서브 항목) | |
| ※ (추가 안내) | |
| 붙임 1. (붙임명 N부.) 끝. | |
| [규칙] | |
| - 발신명의 줄 생략 | |
| - 날짜: "YYYY. M. D.(요일)" | |
| - 문체: "~합니다", "~바랍니다" 정중체 | |
| - 마크다운 기호(**, *, #) 절대 금지 | |
| - 사용자가 준 내용만 사용, 추측·가공 금지`, | |
| "공문-내부": `당신은 한국전력공사(한전, KEPCO) 내부 공문 작성 전문가입니다. 현재는 2026년입니다. | |
| 사용자가 요청한 내용을 아래 양식에 정확히 맞춰 작성하세요. | |
| [내부 공문 특성] | |
| - 수신: 한전 내부 부서·직위 (예: "전략경영부장", "각 처·부장") | |
| - 외부 공문보다 간결하게 작성 (서론 최소화) | |
| - 붙임은 해당 시에만 작성 | |
| [양식] | |
| 수신 (내부 부서명 또는 직위) | |
| 제목 (문서 제목) | |
| (본문 — 목적·지시·일정을 간결하게 2~3문장) | |
| 1. (세부 항목 1) | |
| 2. (세부 항목 2) | |
| 가. (서브 항목) | |
| 붙임 1. (붙임명 N부.) 끝. | |
| [규칙] | |
| - 발신명의 줄 생략 | |
| - 날짜: "YYYY. M. D.(요일)" | |
| - 문체: "~합니다", "~바랍니다" 정중체 | |
| - 마크다운 기호(**, *, #) 절대 금지 | |
| - 붙임 없으면 마지막 문장 뒤에 바로 "끝." 표기 | |
| - 사용자가 준 내용만 사용, 추측·가공 금지`, | |
| "보고서": `당신은 한국전력공사(한전, KEPCO) 내부 보고서 작성 전문가입니다. 현재는 2026년입니다. | |
| 사용자가 요청한 내용을 표준 보고서 양식에 맞춰 작성하세요. | |
| [양식] | |
| YYYY. M. | |
| 부서명 / 세부부서 | |
| 1. (섹션명 - 예: 추진 배경) | |
| 가. 항목 | |
| 나. 항목 | |
| 2. (섹션명 - 예: 추진 내용) | |
| 가. 항목 | |
| - 세부 | |
| 3. (섹션명 - 예: 향후 계획) | |
| 가. 항목 | |
| [규칙] | |
| - 수신/발신/끝. 같은 공문 요소 사용 안 함 | |
| - 마크다운 기호 절대 금지 | |
| - 사용자가 준 내용만 정리, 추측 금지 | |
| [★ 문체] | |
| - 명사형 종결 (정중체 절대 금지) | |
| - 권장: "시행 예정", "추진 계획", "검토 중", "수립", "협의 완료" | |
| - 금지: "~합니다", "~입니다", "~예정입니다", "~바랍니다"` | |
| }; | |
| const PROMPTS_REVIEW = { | |
| "공문-외부발송": `당신은 한국전력공사(한전, KEPCO) 외부 공문 검토 전문가입니다. | |
| 사용자가 제출한 외부 공문을 아래 기준으로 검토한 뒤, 검토 의견과 수정본을 작성하세요. | |
| [검토 기준] | |
| 1. 구조: 수신/제목/본문/붙임/끝. 순서와 완결성 | |
| 2. 수신처: 외부 기관명이 적절히 기재되었는지 | |
| 3. 문체: 정중체(~합니다, ~바랍니다) 유지 여부 | |
| 4. 날짜: "YYYY. M. D.(요일)" 형식 준수 | |
| 5. 항목 기호: "1. 2." → "가. 나." → "-" 계층 유지 | |
| 6. 금지 사항: 마크다운(*,#), 발신명의 기재 | |
| [출력 형식] 마크다운 기호 없이 작성 | |
| ===검토 의견=== | |
| (발견된 문제점 또는 개선 사항. 문제 없으면 "양식 및 문체 이상 없음."으로 기재) | |
| 1. ... | |
| ===수정본=== | |
| 수신 ... | |
| 제목 ... | |
| (수정된 공문 본문) | |
| 붙임 ... 끝.`, | |
| "공문-내부": `당신은 한국전력공사(한전, KEPCO) 내부 공문 검토 전문가입니다. | |
| 사용자가 제출한 내부 공문을 아래 기준으로 검토한 뒤, 검토 의견과 수정본을 작성하세요. | |
| [검토 기준] | |
| 1. 구조: 수신/제목/본문/끝. 순서와 완결성 | |
| 2. 수신처: 내부 부서·직위가 명확히 기재되었는지 | |
| 3. 문체: 정중체(~합니다, ~바랍니다) 유지 여부 | |
| 4. 간결성: 외부 공문보다 간결한지 (불필요한 서론 없는지) | |
| 5. 날짜: "YYYY. M. D.(요일)" 형식 준수 | |
| 6. 금지 사항: 마크다운(*,#), 발신명의 기재 | |
| [출력 형식] 마크다운 기호 없이 작성 | |
| ===검토 의견=== | |
| (발견된 문제점 또는 개선 사항) | |
| 1. ... | |
| ===수정본=== | |
| 수신 ... | |
| 제목 ... | |
| (수정된 내부 공문 본문) | |
| 끝.`, | |
| "보고서": `당신은 한국전력공사(한전, KEPCO) 내부 보고서 검토 전문가입니다. | |
| 사용자가 제출한 보고서를 아래 기준으로 검토한 뒤, 검토 의견과 수정본을 작성하세요. | |
| [검토 기준] | |
| 1. 구조: 날짜·부서·섹션 번호(1. 2. 3.) 체계 확인 | |
| 2. 문체: 명사형 종결 준수 여부 (정중체 금지) | |
| 3. 항목 기호: "가. 나." / "-" 계층 유지 | |
| 4. 금지 사항: 마크다운, 수신/끝. 같은 공문 요소 | |
| [★ 명사형 종결 위반 집중 확인] | |
| - 위반: "~합니다", "~입니다", "~예정입니다" | |
| - 올바름: "시행 예정", "추진 계획", "검토 중" | |
| [출력 형식] 마크다운 기호 없이 작성 | |
| ===검토 의견=== | |
| 1. ... | |
| ===수정본=== | |
| (수정된 보고서 전문)` | |
| }; | |
| // ============================================================ | |
| // 상태 | |
| // ============================================================ | |
| let currentTab = "write"; | |
| let abortController = null; | |
| let isReady = false; | |
| const runBtn = document.getElementById("run-btn"); | |
| const stopBtn = document.getElementById("stop-btn"); | |
| const copyBtn = document.getElementById("copy-btn"); | |
| const dlHwpxBtn = document.getElementById("dl-hwpx-btn"); | |
| const outputEl = document.getElementById("output"); | |
| const statusMsg = document.getElementById("status-msg"); | |
| const loadingBanner = document.getElementById("loading-banner"); | |
| const loadingMsg = document.getElementById("loading-msg"); | |
| // ============================================================ | |
| // 탭 전환 | |
| // ============================================================ | |
| document.querySelectorAll(".tab-btn").forEach(btn => { | |
| btn.addEventListener("click", () => { | |
| document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); | |
| document.querySelectorAll(".panel").forEach(p => p.classList.remove("active")); | |
| btn.classList.add("active"); | |
| currentTab = btn.dataset.tab; | |
| document.getElementById(`panel-${currentTab}`).classList.add("active"); | |
| runBtn.textContent = currentTab === "write" ? "생성" : "검토"; | |
| }); | |
| }); | |
| // ============================================================ | |
| // 문서 유형 선택 모달 (검토 전용) | |
| // ============================================================ | |
| const typeModal = document.getElementById("type-modal"); | |
| let resolveTypeModal = null; | |
| document.querySelectorAll(".modal-type-btn").forEach(btn => { | |
| btn.addEventListener("click", () => { | |
| typeModal.classList.remove("open"); | |
| if (resolveTypeModal) resolveTypeModal(btn.dataset.type); | |
| }); | |
| }); | |
| document.getElementById("modal-cancel").addEventListener("click", () => { | |
| typeModal.classList.remove("open"); | |
| if (resolveTypeModal) resolveTypeModal(null); | |
| }); | |
| function askDocType() { | |
| return new Promise(resolve => { | |
| resolveTypeModal = resolve; | |
| typeModal.classList.add("open"); | |
| }); | |
| } | |
| // ============================================================ | |
| // 모델 로딩 폴링 | |
| // ============================================================ | |
| async function pollReady() { | |
| while (true) { | |
| try { | |
| const r = await fetch("/health"); | |
| const d = await r.json(); | |
| if (d.ready) { | |
| isReady = true; | |
| loadingBanner.classList.add("hidden"); | |
| runBtn.disabled = false; | |
| setStatus("준비 완료. 입력 후 버튼을 누르세요."); | |
| return; | |
| } | |
| loadingMsg.textContent = `모델 로딩 중... (${d.status})`; | |
| } catch (_) { | |
| loadingMsg.textContent = "서버 연결 시도 중..."; | |
| } | |
| await sleep(4000); | |
| } | |
| } | |
| function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | |
| // ============================================================ | |
| // 생성 / 검토 실행 | |
| // ============================================================ | |
| runBtn.addEventListener("click", run); | |
| stopBtn.addEventListener("click", () => { | |
| if (abortController) abortController.abort(); | |
| setStatus("중지됨."); | |
| setBusy(false); | |
| }); | |
| async function run() { | |
| const input = currentTab === "write" | |
| ? document.getElementById("write-input").value.trim() | |
| : document.getElementById("review-input").value.trim(); | |
| if (!input) { | |
| const ta = document.getElementById(currentTab === "write" ? "write-input" : "review-input"); | |
| ta.style.borderColor = "#c62828"; | |
| ta.focus(); | |
| setTimeout(() => { ta.style.borderColor = ""; }, 2500); | |
| setStatus(currentTab === "write" ? "내용 요점을 입력해 주세요." : "검토할 원문을 붙여넣거나 파일을 업로드하세요."); | |
| return; | |
| } | |
| let docType; | |
| if (currentTab === "review") { | |
| docType = await askDocType(); | |
| if (!docType) return; | |
| } else { | |
| docType = document.querySelector("input[name=docType]:checked").value; | |
| } | |
| let sysPrompt = currentTab === "write" ? PROMPTS_WRITE[docType] : PROMPTS_REVIEW[docType]; | |
| const maxTokens = currentTab === "write" ? 600 : 900; | |
| if (currentTab === "write") { | |
| try { | |
| const exResp = await fetch(`/examples/${encodeURIComponent(docType)}`); | |
| if (exResp.ok) { | |
| const exData = await exResp.json(); | |
| if (exData.examples) { | |
| sysPrompt += `\n\n[참고 예시 문서 — 아래 양식을 참고하되, 내용은 사용자 입력만 사용할 것]\n\n${exData.examples}`; | |
| } | |
| } | |
| } catch (_) {} | |
| } | |
| outputEl.textContent = ""; | |
| setBusy(true); | |
| setStatus(currentTab === "write" ? `생성 중... (${docType})` : `검토 중... (${docType})`); | |
| abortController = new AbortController(); | |
| let buffer = ""; | |
| try { | |
| const resp = await fetch("/generate", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json", "Accept": "text/event-stream" }, | |
| body: JSON.stringify({ | |
| messages: [ | |
| { role: "system", content: sysPrompt }, | |
| { role: "user", content: input } | |
| ], | |
| max_new_tokens: maxTokens, | |
| temperature: 0.7, top_p: 0.8, top_k: 20 | |
| }), | |
| signal: abortController.signal | |
| }); | |
| if (!resp.ok) { | |
| let detail = resp.statusText; | |
| try { detail = (await resp.json()).detail || detail; } catch (_) {} | |
| throw new Error(`서버 오류 ${resp.status}: ${detail}`); | |
| } | |
| const reader = resp.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let partial = ""; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| partial += decoder.decode(value, { stream: true }); | |
| const lines = partial.split("\n"); | |
| partial = lines.pop(); | |
| for (const line of lines) { | |
| if (!line.startsWith("data:")) continue; | |
| try { | |
| const obj = JSON.parse(line.slice(5).trim()); | |
| if (obj.error) throw new Error(obj.error); | |
| if (obj.done) { setStatus(`완료 (${obj.tokens} 토큰, ${obj.elapsed}초)`); break; } | |
| if (obj.token) { | |
| buffer += obj.token; | |
| outputEl.textContent = stripMd(buffer); | |
| outputEl.scrollTop = outputEl.scrollHeight; | |
| } | |
| } catch (e) { | |
| if (!e.message.includes("토큰")) throw e; | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| if (e.name !== "AbortError") setStatus("오류: " + e.message); | |
| } finally { | |
| setBusy(false); | |
| } | |
| } | |
| // ============================================================ | |
| // 마크다운 기호 제거 | |
| // ============================================================ | |
| function stripMd(text) { | |
| return text | |
| .replace(/\*\*(.+?)\*\*/gs, "$1") | |
| .replace(/(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)/gs, "$1") | |
| .replace(/^#{1,6}\s+/gm, ""); | |
| } | |
| // ============================================================ | |
| // 파일 업로드 → /extract | |
| // ============================================================ | |
| document.getElementById("file-input").addEventListener("change", async (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const uploadStatus = document.getElementById("upload-status"); | |
| uploadStatus.textContent = `읽는 중: ${file.name}`; | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| try { | |
| const resp = await fetch("/extract", { method: "POST", body: formData }); | |
| if (!resp.ok) { | |
| let detail = resp.statusText; | |
| try { detail = (await resp.json()).detail || detail; } catch (_) {} | |
| throw new Error(detail); | |
| } | |
| const data = await resp.json(); | |
| document.getElementById("review-input").value = data.text; | |
| uploadStatus.style.color = "#388e3c"; | |
| uploadStatus.textContent = `로드 완료: ${file.name}`; | |
| } catch (err) { | |
| uploadStatus.style.color = "#c62828"; | |
| uploadStatus.style.fontWeight = "600"; | |
| uploadStatus.textContent = `파일 오류: ${err.message}`; | |
| } | |
| e.target.value = ""; | |
| }); | |
| // ============================================================ | |
| // 복사 / HWPX 저장 | |
| // ============================================================ | |
| copyBtn.addEventListener("click", () => { | |
| const text = outputEl.textContent; | |
| if (!text) return; | |
| navigator.clipboard.writeText(text).then(() => setStatus("클립보드에 복사됨.")); | |
| }); | |
| dlHwpxBtn.addEventListener("click", async () => { | |
| const text = outputEl.textContent.trim(); | |
| if (!text) return; | |
| try { | |
| const resp = await fetch("/download/hwpx", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ text }) | |
| }); | |
| if (!resp.ok) throw new Error(await resp.text()); | |
| const blob = await resp.blob(); | |
| const cd = resp.headers.get("Content-Disposition") || ""; | |
| const m = cd.match(/filename\*=UTF-8''(.+)/); | |
| const filename = m ? decodeURIComponent(m[1]) : "문서.hwpx"; | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; a.download = filename; a.click(); | |
| URL.revokeObjectURL(url); | |
| setStatus("다운로드 완료."); | |
| } catch (e) { | |
| setStatus("다운로드 실패: " + e.message); | |
| } | |
| }); | |
| // ============================================================ | |
| // 유틸 | |
| // ============================================================ | |
| function setBusy(busy) { runBtn.disabled = busy || !isReady; stopBtn.disabled = !busy; } | |
| function setStatus(msg) { statusMsg.textContent = msg; } | |
| pollReady(); | |
| </script> | |
| </body> | |
| </html> | |