| <!DOCTYPE html> |
| <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, ""); |
| } |
| |
| |
| |
| |
| 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 = ""; |
| }); |
| |
| |
| |
| |
| 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> |
|
|