Files
homeclaw/web-ui/html/doctor-app.html
T
kimandClaude Opus 5 d60019f004 fix: 앱 페이지 7개에 수식 렌더러 배선 — 애초에 KaTeX가 없었다
법률앱에서 `$\rightarrow$`가 날것으로 보인다는 신고. 통화 `$` 문제(f06955f)를
먼저 의심했지만 원인은 더 단순했다 — **앱 페이지들은 KaTeX를 아예 로드하지
않고 있었다.**

  index.html        katex O
  lawyer / detective / doctor / investor / accountant / mind / weather   전부 X

메인 채팅에만 chat-render.js의 renderKatexIn이 있었고, 앱 7개는 각자 자기
renderMd를 갖고 있어 수식이 통과할 길이 없었다. 공용 js/app/katex-inline.js로
빼서 전부에 붙였다(통화 `$` 보호 로직 포함).

addMsg에서 DOM 삽입 직후 호출한다. innerHTML은 <script>를 실행하지 않으므로
삽입 시점마다 명시적으로 불러야 한다. TTS 버튼이 innerText를 읽어가므로
그보다 먼저 렌더링되게 배치했다 — 안 그러면 음성이 "달러 백슬래시 rightarrow
달러"를 읽는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:53:35 +09:00

1455 lines
84 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🩺 의료 기록 어시스턴트</title>
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
/* ── Header ── */
.dr-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
.dr-hdr h1{font-size:13px;font-weight:700;margin:0;}
.dr-hdr-sp{flex:1;}
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
.hdr-btn:hover{border-color:#14b8a6;color:#14b8a6;}
.skill-badge{font-size:11px;padding:2px 8px;border-radius:12px;background:rgba(20,184,166,.1);border:1px solid rgba(20,184,166,.25);color:#2dd4bf;font-weight:700;}
/* ── Layout ── */
.dr-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
/* ── Sidebar ── */
.dr-sb{width:220px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--line);background:var(--panel);min-height:0;overflow:hidden;}
.dr-sb-sec{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:8px 10px 4px;flex-shrink:0;}
.dr-quick{padding:0 6px 6px;display:flex;flex-direction:column;gap:3px;flex-shrink:0;}
.dr-qbtn{background:none;border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left;transition:.15s;line-height:1.4;}
.dr-qbtn:hover{border-color:#14b8a6;color:#14b8a6;background:rgba(20,184,166,.05);}
.dr-divider{height:1px;background:var(--line);margin:4px 6px;flex-shrink:0;}
.dr-sb-hdr{display:flex;align-items:center;padding:8px 10px 4px;gap:4px;flex-shrink:0;}
.dr-sb-title{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;flex:1;}
.dr-new-btn{background:#042f2e;border:none;border-radius:5px;padding:2px 8px;font-size:11px;color:#2dd4bf;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;}
.dr-new-btn:hover{background:#115e59;color:#fff;}
.dr-case-search-wrap{padding:0 8px 6px;flex-shrink:0;}
.dr-case-search{width:100%;box-sizing:border-box;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:5px 9px;font-size:11px;color:var(--text);font-family:inherit;outline:none;transition:.15s;}
.dr-case-search:focus{border-color:#14b8a6;}
.dr-case-sort-bar{display:flex;align-items:center;padding:0 8px 4px;gap:4px;flex-shrink:0;}
.dr-case-sort-sel{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:2px 5px;font-size:10px;color:var(--muted);font-family:inherit;}
.dr-cases{flex:1;overflow-y:auto;padding:4px;min-height:60px;}
.dr-cases::-webkit-scrollbar{width:3px;}
.dr-cases::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.dr-case-item{padding:7px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:2px;transition:.12s;position:relative;}
.dr-case-item:hover{background:rgba(20,184,166,.05);border-color:rgba(20,184,166,.2);}
.dr-case-item.active{background:rgba(20,184,166,.1);border-color:rgba(20,184,166,.35);}
.dr-case-title{font-size:12px;font-weight:600;color:var(--text);margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:34px;}
.dr-case-meta{font-size:10px;color:var(--muted);display:flex;align-items:center;gap:4px;flex-wrap:wrap;}
.dr-case-del{position:absolute;top:6px;right:6px;background:none;border:none;cursor:pointer;font-size:12px;color:var(--muted);opacity:0;transition:.12s;padding:1px 3px;}
.dr-case-item:hover .dr-case-del{opacity:.6;}
.dr-case-del:hover{opacity:1 !important;color:#ef4444;}
.dr-case-rename{position:absolute;top:6px;right:22px;background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);opacity:0;transition:.12s;padding:1px 3px;}
.dr-case-item:hover .dr-case-rename{opacity:.6;}
.dr-case-rename:hover{opacity:1 !important;color:#14b8a6;}
.dr-status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:2px;vertical-align:middle;flex-shrink:0;}
.s-active{background:#f59e0b;}.s-done{background:#22c55e;}.s-hold{background:#3b82f6;}
.dr-pri-dot{display:inline-block;width:5px;height:5px;border-radius:50%;vertical-align:middle;flex-shrink:0;}
.p-urgent{background:#ef4444;}.p-high{background:#f97316;}.p-normal{background:#6b7280;}.p-low{background:#374151;}
.dr-disclaimer{margin:6px;padding:8px 10px;background:rgba(20,184,166,.06);border:1px solid rgba(20,184,166,.15);border-radius:8px;font-size:10px;color:var(--muted);line-height:1.6;flex-shrink:0;}
/* ── Case center ── */
.dr-case-center{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;background:var(--bg);}
.dr-case-center-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:12px;padding:40px;}
.dr-case-center-empty .icon{font-size:48px;opacity:.25;}
.dr-case-center-empty p{font-size:12px;line-height:1.7;text-align:center;max-width:260px;}
.dr-case-hdr{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;flex-wrap:wrap;}
.dr-case-name-input{flex:1;min-width:120px;background:transparent;border:none;font-size:14px;font-weight:700;color:var(--text);font-family:inherit;outline:none;}
.dr-case-name-input::placeholder{color:var(--muted);}
.dr-case-status-sel,.dr-case-pri-sel{background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 5px;font-size:11px;color:var(--text);font-family:inherit;}
.dr-case-hdr-btns{display:flex;gap:5px;align-items:center;flex-shrink:0;flex-wrap:wrap;}
.dr-ask-ai-btn{background:#042f2e;border:none;border-radius:6px;padding:4px 9px;font-size:11px;color:#2dd4bf;cursor:pointer;font-family:inherit;font-weight:700;white-space:nowrap;transition:.15s;}
.dr-ask-ai-btn:hover{background:#115e59;color:#fff;}
.dr-summary-btn{background:rgba(16,185,129,.1);border:1px solid rgba(16,185,129,.3);border-radius:6px;padding:4px 9px;font-size:11px;color:#34d399;cursor:pointer;font-family:inherit;font-weight:700;white-space:nowrap;transition:.15s;}
.dr-summary-btn:hover{background:rgba(16,185,129,.25);color:#fff;}
.dr-save-indicator{font-size:10px;color:var(--muted);transition:.3s;flex-shrink:0;}
.dr-case-tabs{display:flex;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;padding:0 14px;overflow-x:auto;}
.dr-case-tab-btn{background:none;border:none;border-bottom:2px solid transparent;padding:7px 12px;font-size:12px;font-weight:600;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;margin-bottom:-1px;white-space:nowrap;}
.dr-case-tab-btn.active{color:#14b8a6;border-bottom-color:#14b8a6;}
.dr-case-tab-btn:hover:not(.active){color:var(--text);}
.dr-case-tab-content{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden;}
.dr-case-tab-pane{flex:1;display:none;flex-direction:column;min-height:0;overflow:hidden;}
.dr-case-tab-pane.active{display:flex;}
.dr-notes-area{flex:1;display:flex;flex-direction:column;padding:12px 14px;gap:6px;min-height:0;}
.dr-notes-label{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
.dr-notes-textarea{flex:1;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:12px;color:var(--text);font-family:inherit;resize:none;outline:none;line-height:1.7;transition:.15s;}
.dr-notes-textarea:focus{border-color:#14b8a6;}
.dr-cl-wrap{flex:1;display:flex;flex-direction:column;min-height:0;padding:12px 14px;gap:8px;}
.dr-cl-add-bar{display:flex;gap:6px;flex-shrink:0;}
.dr-cl-input{flex:1;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:7px 11px;font-size:12px;color:var(--text);font-family:inherit;outline:none;transition:.15s;}
.dr-cl-input:focus{border-color:#14b8a6;}
.dr-cl-add-btn{background:#042f2e;border:none;border-radius:7px;padding:7px 13px;font-size:12px;color:#2dd4bf;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
.dr-cl-add-btn:hover{background:#115e59;color:#fff;}
.dr-cl-ai-btn{background:rgba(139,92,246,.15);border:1px solid rgba(139,92,246,.3);border-radius:7px;padding:7px 11px;font-size:11px;color:#a78bfa;cursor:pointer;font-family:inherit;font-weight:700;flex-shrink:0;transition:.15s;}
.dr-cl-ai-btn:hover{background:rgba(139,92,246,.3);color:#fff;}
.dr-cl-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:3px;}
.dr-cl-item{display:flex;align-items:flex-start;gap:8px;padding:7px 10px;border-radius:6px;border:1px solid var(--line);background:var(--panel);transition:.12s;}
.dr-cl-item:hover{border-color:rgba(20,184,166,.25);}
.dr-cl-item.done{opacity:.55;}
.dr-cl-cb{width:16px;height:16px;border-radius:4px;border:1.5px solid var(--line);cursor:pointer;flex-shrink:0;margin-top:1px;display:flex;align-items:center;justify-content:center;font-size:10px;transition:.15s;background:transparent;}
.dr-cl-cb.checked{background:#22c55e;border-color:#22c55e;color:#fff;}
.dr-cl-text{flex:1;font-size:12px;line-height:1.5;color:var(--text);word-break:break-word;}
.dr-cl-item.done .dr-cl-text{text-decoration:line-through;color:var(--muted);}
.dr-cl-del{background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);padding:1px 4px;opacity:0;transition:.12s;flex-shrink:0;}
.dr-cl-item:hover .dr-cl-del{opacity:.5;}
.dr-cl-del:hover{opacity:1 !important;color:#ef4444;}
.dr-cl-progress{display:flex;align-items:center;gap:8px;flex-shrink:0;}
.dr-cl-prog-bar{flex:1;height:4px;background:var(--line);border-radius:2px;overflow:hidden;}
.dr-cl-prog-fill{height:100%;background:#22c55e;border-radius:2px;transition:.3s;}
.dr-cl-prog-text{font-size:10px;color:var(--muted);flex-shrink:0;}
.dr-cl-empty{text-align:center;color:var(--muted);font-size:12px;padding:30px 0;}
.dr-ev-group-title{font-size:11px;font-weight:700;color:var(--muted);margin:6px 0 2px;padding-left:2px;}
.dr-tl-wrap{flex:1;display:flex;flex-direction:column;min-height:0;padding:12px 14px;gap:8px;}
.dr-tl-add-bar{display:flex;gap:6px;flex-shrink:0;align-items:center;}
.dr-tl-date{background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:7px 9px;font-size:12px;color:var(--text);font-family:inherit;outline:none;width:130px;flex-shrink:0;transition:.15s;}
.dr-tl-date:focus{border-color:#14b8a6;}
.dr-tl-text-input{flex:1;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:7px 11px;font-size:12px;color:var(--text);font-family:inherit;outline:none;transition:.15s;}
.dr-tl-text-input:focus{border-color:#14b8a6;}
.dr-tl-add-btn{background:#042f2e;border:none;border-radius:7px;padding:7px 13px;font-size:12px;color:#2dd4bf;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
.dr-tl-add-btn:hover{background:#115e59;color:#fff;}
.dr-tl-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
.dr-tl-item{display:flex;gap:10px;position:relative;padding-bottom:12px;}
.dr-tl-line{display:flex;flex-direction:column;align-items:center;flex-shrink:0;width:20px;}
.dr-tl-dot{width:10px;height:10px;border-radius:50%;background:#14b8a6;border:2px solid var(--bg);flex-shrink:0;margin-top:2px;}
.dr-tl-rod{flex:1;width:2px;background:var(--line);margin-top:2px;}
.dr-tl-item:last-child .dr-tl-rod{display:none;}
.dr-tl-body{flex:1;padding-top:0;}
.dr-tl-date-lbl{font-size:10px;font-weight:700;color:#2dd4bf;margin-bottom:2px;}
.dr-tl-txt{font-size:12px;color:var(--text);line-height:1.5;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:6px 10px;position:relative;}
.dr-tl-del{position:absolute;top:4px;right:6px;background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);opacity:0;transition:.12s;padding:1px 3px;}
.dr-tl-txt:hover .dr-tl-del{opacity:.5;}
.dr-tl-del:hover{opacity:1 !important;color:#ef4444;}
.dr-tl-empty{text-align:center;color:var(--muted);font-size:12px;padding:30px 0;}
/* ── Resize handle ── */
.dr-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;}
.dr-resize:hover,.dr-resize.dragging{background:#14b8a6;}
/* ── Chat ── */
.dr-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;}
.dr-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex-shrink:0;gap:6px;}
.dr-chat-label{font-size:10px;font-weight:700;color:#2dd4bf;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
.dr-model-sel{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit;min-width:0;}
.dr-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;}
.dr-chat-clear:hover{color:#ef4444;}
.dr-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
.dr-msgs::-webkit-scrollbar{width:3px;}
.dr-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.dr-msg{display:flex;flex-direction:column;}
.dr-msg.user{align-items:flex-end;}
.dr-msg.assistant{align-items:flex-start;}
.dr-msg.system{align-items:center;}
.dr-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:93%;word-break:break-word;}
.dr-msg.user .dr-bubble{background:#042f2e;color:#ccfbf1;border-radius:10px 10px 3px 10px;}
.dr-msg.assistant .dr-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
.dr-msg.system .dr-bubble{background:transparent;color:var(--muted);font-size:11px;}
.dr-bubble p{margin:3px 0;}
.dr-bubble h3{font-size:13px;margin:6px 0 3px;}
.dr-bubble ul,.dr-bubble ol{padding-left:16px;margin:3px 0;}
.dr-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
.dr-bubble strong{font-weight:700;}
.dr-bubble table{border-collapse:collapse;font-size:11px;margin:6px 0;width:100%;}
.dr-bubble th,.dr-bubble td{border:1px solid var(--line);padding:3px 7px;text-align:left;}
.dr-bubble th{background:var(--panel);font-weight:700;}
.dr-bubble blockquote{border-left:3px solid var(--line);padding-left:10px;margin:4px 0;color:var(--muted);}
.dr-thinking{padding:4px 10px;border-radius:6px;font-size:10px;color:var(--muted);background:var(--bg);border:1px solid var(--line);font-style:italic;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;cursor:pointer;margin-bottom:4px;}
.dr-thinking.exp{display:block;-webkit-line-clamp:unset;max-height:200px;overflow-y:auto;}
.dr-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
.dr-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:drDot 1.2s ease-in-out infinite;}
.dr-dot:nth-child(2){animation-delay:.2s;}.dr-dot:nth-child(3){animation-delay:.4s;}
@keyframes drDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
.dr-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
.dr-textarea{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;color:var(--text);font-family:inherit;resize:none;min-height:34px;max-height:120px;outline:none;transition:.15s;}
.dr-textarea:focus{border-color:#14b8a6;}
.dr-send{background:#115e59;border:none;border-radius:8px;padding:7px 13px;font-size:12px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
.dr-send:hover:not(:disabled){background:#0f766e;}
.dr-send:disabled{opacity:.5;cursor:not-allowed;}
/* ── Mobile ── */
@media(max-width:1024px){
.dr-resize{display:none;}
.dr-sb{
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:80vw;max-width:300px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 24px rgba(0,0,0,.4);
}
.dr-sb.mob-open{transform:translateX(0);}
.dr-chat{
position:fixed;top:0;right:0;bottom:52px;z-index:500;
width:92vw;max-width:380px;
transform:translateX(100%);transition:transform .25s ease;
box-shadow:-4px 0 24px rgba(0,0,0,.4);
}
.dr-chat.mob-open{transform:translateX(0);}
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.5);z-index:499;}
#mob-backdrop.show{display:block;}
.dr-case-center{width:100%;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:600;background:var(--panel);border-top:1px solid var(--line);height:52px;}
.dr-textarea,input,textarea{font-size:16px !important;}
}
@media(min-width:1025px){
#mob-tabbar{display:none !important;}
#mob-backdrop{display:none !important;}
}
/* ── Text editor modal (.txt/.md/.csv) ── */
/* ── Reference image viewer (X-ray, 증상 사진, 처방전 등) ── */
.dr-img-viewer-modal{display:none;position:fixed;inset:0;background:rgba(5,10,15,.85);z-index:9999;flex-direction:column;}
.dr-img-viewer-modal.show{display:flex;}
.dr-img-viewer-hdr{display:flex;align-items:center;gap:10px;padding:10px 16px;background:rgba(0,0,0,.4);flex-shrink:0;}
.dr-img-viewer-title{font-size:13px;font-weight:700;color:#fff;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.dr-img-viewer-zoom{display:flex;align-items:center;gap:2px;background:rgba(255,255,255,.08);border-radius:8px;padding:4px;flex-shrink:0;}
.dr-img-viewer-zoom button{width:26px;height:26px;border:none;border-radius:6px;background:rgba(255,255,255,.08);color:#e5e7eb;font-size:15px;font-weight:700;cursor:pointer;line-height:1;font-family:inherit;}
.dr-img-viewer-zoom button:hover{background:rgba(255,255,255,.2);}
.dr-img-viewer-zoom span{color:#e5e7eb;font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;width:38px;text-align:center;}
.dr-img-viewer-close{border:none;background:rgba(255,255,255,.08);color:#fff;font-size:16px;cursor:pointer;width:28px;height:28px;border-radius:6px;line-height:1;flex-shrink:0;}
.dr-img-viewer-close:hover{background:rgba(239,68,68,.35);}
.dr-img-viewer-body{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;overflow:hidden;cursor:grab;}
.dr-img-viewer-body.dragging{cursor:grabbing;}
.dr-img-viewer-body img{max-width:96%;max-height:96%;object-fit:contain;transform-origin:center center;transition:transform .08s linear;user-select:none;}
</style>
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
<script src="/vendor/katex/katex.min.js"></script>
<script src="/vendor/katex/auto-render.min.js"></script>
<script src="/vendor/katex/mhchem.min.js"></script>
<script src="../js/app/katex-inline.js"></script>
<script src="../js/voice/voice-utils.js"></script>
</head>
<body>
<header class="dr-hdr">
<a href="/" class="hdr-btn">← 홈</a>
<h1>🩺 의료 기록 어시스턴트</h1>
<span class="skill-badge" id="skill-badge">스킬 로딩 중...</span>
<div class="dr-hdr-sp"></div>
<button class="hdr-btn" onclick="toggleTheme()">🌙 테마</button>
</header>
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
<div id="mob-tabbar" style="display:none">
<button class="mob-tab" id="mobtab-quick" onclick="mobToggle('quick')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span>목록</span>
</button>
<button class="mob-tab" id="mobtab-home" onclick="mobToggle('home')" style="color:var(--brand,#14b8a6);border-top:2px solid var(--brand,#14b8a6)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>기록</span>
</button>
<button class="mob-tab" id="mobtab-chat" onclick="mobToggle('chat')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>AI</span>
</button>
</div>
<!-- Built-in text editor (.txt/.md/.csv) — encoding-safe, ported from lawyer-app -->
<div id="dr-text-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(10,20,40,0.45);z-index:9999;align-items:center;justify-content:center;padding:18px">
<div style="width:min(720px,96vw);height:min(640px,90vh);background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-md,0 8px 30px rgba(0,0,0,.3));display:flex;flex-direction:column;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--line);flex-shrink:0">
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<span style="font-weight:800;font-size:13px;flex-shrink:0">📝</span>
<span id="dr-te-filename" style="font-weight:700;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
<select id="dr-te-encoding" onchange="drTextEditorRedecode()" style="margin-left:8px;background:var(--panel-2);border:1px solid var(--line);border-radius:7px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit">
<option value="utf-8">UTF-8</option>
<option value="euc-kr">EUC-KR (CP949)</option>
</select>
</div>
<button onclick="closeTextEditor()" style="border:0;background:none;font-size:18px;cursor:pointer;color:var(--muted);line-height:1;flex-shrink:0">✕</button>
</div>
<textarea id="dr-te-textarea" style="flex:1;resize:none;border:0;outline:none;padding:14px 16px;font-family:'IBM Plex Mono',monospace;font-size:13px;line-height:1.6;background:var(--panel);color:var(--text)"></textarea>
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-top:1px solid var(--line);flex-shrink:0">
<span id="dr-te-status" style="font-size:11px;color:var(--muted)"></span>
<div style="display:flex;gap:8px">
<button onclick="closeTextEditor()" style="border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;padding:7px 16px;font-size:12px;font-weight:600;cursor:pointer">닫기</button>
<button onclick="saveTextEditor()" style="border:0;background:#115e59;color:#fff;border-radius:8px;padding:7px 18px;font-size:12px;font-weight:700;cursor:pointer">저장 (UTF-8)</button>
</div>
</div>
</div>
</div>
<!-- Reference image viewer — X-ray/증상 사진/처방전 등을 새 탭이 아니라 앱 안에서 바로 확인 -->
<div id="dr-img-viewer-modal" class="dr-img-viewer-modal">
<div class="dr-img-viewer-hdr">
<span id="dr-iv-filename" class="dr-img-viewer-title"></span>
<div class="dr-img-viewer-zoom">
<button onclick="drImgZoomOut()" title="축소">−</button>
<span id="dr-iv-zoom-pct">100%</span>
<button onclick="drImgZoomIn()" title="확대">+</button>
<button onclick="drImgZoomReset()" title="원래 크기">⟲</button>
</div>
<button onclick="closeImageViewer()" class="dr-img-viewer-close">✕</button>
</div>
<div class="dr-img-viewer-body" id="dr-iv-body">
<img id="dr-iv-img" src="" alt="" draggable="false">
</div>
</div>
<div class="dr-layout">
<!-- ── Sidebar: 진료 기록 목록 ── -->
<aside class="dr-sb">
<div class="dr-sb-sec">빠른 질문</div>
<div class="dr-quick">
<button class="dr-qbtn" onclick="sendQuick('최근 증상을 정리해서 병원에 갈지 판단하는 걸 도와주세요.')">🩹 증상 정리하기</button>
<button class="dr-qbtn" onclick="sendQuick('복용 중인 약을 정리하고 싶어요. 어떻게 기록하면 좋을까요?')">💊 복약 정리</button>
<button class="dr-qbtn" onclick="sendQuick('건강검진 결과지에 나온 용어와 수치가 무엇을 뜻하는지 일반적인 설명을 알려주세요.')">📋 검진 결과 용어 설명</button>
<button class="dr-qbtn" onclick="sendQuick('이런 증상은 보통 어느 진료과를 가야 하나요?')">🏥 진료과 안내</button>
</div>
<div class="dr-divider"></div>
<div class="dr-sb-hdr">
<span class="dr-sb-title">진료 기록</span>
<button class="dr-new-btn" onclick="newCase()">+ 새 기록</button>
</div>
<div class="dr-case-search-wrap">
<input class="dr-case-search" id="case-search" type="text" placeholder="🔍 기록 검색..." oninput="renderCaseList()">
</div>
<div class="dr-case-sort-bar">
<select class="dr-case-sort-sel" id="case-sort" onchange="renderCaseList()">
<option value="date-desc">최신순</option>
<option value="date-asc">오래된순</option>
<option value="title">이름순</option>
<option value="status">상태순</option>
<option value="priority">중요도순</option>
</select>
</div>
<div class="dr-cases" id="case-list"></div>
<div class="dr-disclaimer">
⚠️ 이 서비스는 <strong>개인 기록 정리</strong> 목적이며 의학적 진단·처방을 대체하지 않습니다. 증상이 있으면 반드시 의료진과 상담하세요.
</div>
</aside>
<!-- ── Case center: 증상/진료 기록 노트 + 탭 ── -->
<section class="dr-case-center" id="dr-case-center">
<div class="dr-case-center-empty" id="case-center-empty">
<div class="icon">🩺</div>
<p>좌측에서 기록을 선택하거나<br><strong>새 기록</strong>을 만들어 증상과<br>진료 이력을 정리하세요.</p>
</div>
<div id="case-center-body" style="display:none;flex:1;flex-direction:column;min-height:0;">
<div class="dr-case-hdr">
<input class="dr-case-name-input" id="case-name-input" placeholder="증상/진료 케이스명 입력..." oninput="onCaseNameChange()" onblur="saveCases()">
<select class="dr-case-status-sel" id="case-status-sel" onchange="onStatusChange()">
<option value="치료중">🟠 치료중</option>
<option value="완치">🟢 완치</option>
<option value="경과관찰">🔵 경과관찰</option>
</select>
<select class="dr-case-pri-sel" id="case-pri-sel" onchange="onPriChange()">
<option value="normal">⚪ 보통</option>
<option value="urgent">🔴 긴급</option>
<option value="high">🟠 높음</option>
<option value="low">⬛ 경미</option>
</select>
<div class="dr-case-hdr-btns">
<button class="dr-ask-ai-btn" onclick="askAiAboutCase()">💬 정리</button>
<button class="dr-summary-btn" onclick="generateSummary()">📋 진료요약</button>
<span class="dr-save-indicator" id="save-indicator"></span>
</div>
</div>
<div class="dr-case-tabs">
<button class="dr-case-tab-btn active" onclick="switchCaseTab('notes')" id="tab-notes">📝 증상 메모</button>
<button class="dr-case-tab-btn" onclick="switchCaseTab('checklist')" id="tab-checklist">💊 복약·검사</button>
<button class="dr-case-tab-btn" onclick="switchCaseTab('timeline')" id="tab-timeline">📅 진료 이력</button>
<button class="dr-case-tab-btn" onclick="switchCaseTab('files')" id="tab-files">📎 첨부자료</button>
<button class="dr-case-tab-btn" onclick="switchCaseTab('docs')" id="tab-docs">📄 처방·검사결과</button>
</div>
<div class="dr-case-tab-content">
<div class="dr-case-tab-pane active" id="pane-notes">
<div class="dr-notes-area">
<span class="dr-notes-label">증상 · 상담 메모</span>
<textarea class="dr-notes-textarea" id="case-notes" placeholder="증상, 발생 시점, 경과, 병원에서 들은 이야기 등을 자유롭게 기록하세요.
예시:
- 증상: 허리 통증 (2주 전부터)
- 병원: OO정형외과
- 진단: 근막통증증후군
- 처방: 소염진통제 5일분" oninput="onNotesChange()" onblur="saveCases();autoNameCaseIfNeeded()" onkeydown="handleNotesTab(event)"></textarea>
</div>
</div>
<div class="dr-case-tab-pane" id="pane-checklist">
<div class="dr-cl-wrap">
<div class="dr-cl-add-bar">
<input class="dr-cl-input" id="cl-input" type="text" placeholder="복약·검사 항목 추가..." onkeydown="if(event.key==='Enter')addCheckItem()">
<button class="dr-cl-add-btn" onclick="addCheckItem()">추가</button>
<button class="dr-cl-ai-btn" onclick="generateChecklist()" title="AI로 체크리스트 자동 생성">✨ AI</button>
</div>
<div class="dr-cl-progress" id="cl-progress" style="display:none">
<div class="dr-cl-prog-bar"><div class="dr-cl-prog-fill" id="cl-prog-fill"></div></div>
<span class="dr-cl-prog-text" id="cl-prog-text">0/0</span>
</div>
<div class="dr-cl-list" id="cl-list"></div>
</div>
</div>
<div class="dr-case-tab-pane" id="pane-timeline">
<div class="dr-tl-wrap">
<div class="dr-tl-add-bar">
<input class="dr-tl-date" id="tl-date" type="date">
<input class="dr-tl-text-input" id="tl-input" type="text" placeholder="진료 이력 입력 (예: OO병원 내원, 혈액검사 실시 등)..." onkeydown="if(event.key==='Enter')addTimelineEvent()">
<button class="dr-tl-add-btn" onclick="addTimelineEvent()">추가</button>
</div>
<div class="dr-tl-list" id="tl-list"></div>
</div>
</div>
<div class="dr-case-tab-pane" id="pane-files">
<div class="dr-cl-wrap">
<div class="dr-cl-add-bar">
<input type="file" id="case-file-input" style="display:none" multiple onchange="onCaseFilesPicked(this.files)">
<button class="dr-cl-add-btn" onclick="document.getElementById('case-file-input').click()">📎 자료 업로드</button>
<span id="case-file-upload-status" style="font-size:11px;color:var(--muted)"></span>
</div>
<div class="dr-cl-list" id="case-files-list"></div>
</div>
</div>
<div class="dr-case-tab-pane" id="pane-docs">
<div class="dr-cl-wrap">
<div class="dr-cl-add-bar">
<input type="file" id="case-doc-input" style="display:none" multiple onchange="onCaseDocsPicked(this.files)">
<button class="dr-cl-add-btn" onclick="document.getElementById('case-doc-input').click()">📄 처방전·검사결과 업로드</button>
<span id="case-doc-upload-status" style="font-size:11px;color:var(--muted)"></span>
</div>
<div class="dr-cl-list" id="case-docs-list"></div>
</div>
</div>
</div>
</div>
</section>
<!-- ── Resize ── -->
<div class="dr-resize" id="resize-handle"></div>
<!-- ── Chat ── -->
<div class="dr-chat" id="chat-panel">
<div class="dr-chat-hdr">
<span class="dr-chat-label">🩺 건강기록 AI</span>
<span id="dr-chat-ctx-label" style="font-size:10px;color:var(--muted);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:110px" title="현재 대화 컨텍스트">일반</span>
<select class="dr-model-sel" id="model-sel" onchange="saveModel()">
<option value="">기본 모델</option>
</select>
<button class="dr-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
</div>
<div class="dr-msgs" id="chat-msgs"></div>
<div class="dr-input-bar">
<textarea class="dr-textarea" id="chat-input" rows="1" placeholder="증상이나 건강 관련 질문을 입력하세요..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<input type="file" id="file-input" accept=".pdf,.txt" style="display:none" onchange="onFilePicked(this.files[0])">
<button class="app-mic-btn" onclick="document.getElementById('file-input').click()" title="검사결과·진단서 첨부 (PDF/TXT)">📎</button>
<button class="app-mic-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력">🎤</button>
<button class="dr-send" id="send-btn" onclick="sendMessage()">전송</button>
</div>
</div>
</div>
<script>
// ── Auth ──────────────────────────────────────────────────────────────────────
const TOKEN_KEY='smallclaw_token';
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
async function checkAuth(){
if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}const d=await r.json();if(!d.authenticated){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;}
catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
}
// ── Theme ─────────────────────────────────────────────────────────────────────
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
// ── Mobile overlay ────────────────────────────────────────────────────────────
function mobCloseAll(){
document.querySelector('.dr-sb').classList.remove('mob-open');
document.getElementById('chat-panel').classList.remove('mob-open');
document.getElementById('mob-backdrop').classList.remove('show');
['quick','chat'].forEach(t=>document.getElementById('mobtab-'+t)?.classList.remove('active'));
document.getElementById('mobtab-home').style.cssText='color:var(--brand,#14b8a6);border-top:2px solid var(--brand,#14b8a6)';
}
function mobToggle(which){
const sb=document.querySelector('.dr-sb');
const cp=document.getElementById('chat-panel');
const bd=document.getElementById('mob-backdrop');
if(which==='home'){mobCloseAll();return;}
const sbOpen=sb.classList.contains('mob-open');
const cpOpen=cp.classList.contains('mob-open');
mobCloseAll();
if(which==='quick'&&!sbOpen){sb.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-quick').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
if(which==='chat'&&!cpOpen){cp.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-chat').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
}
// ── Resize ────────────────────────────────────────────────────────────────────
(function(){
const CHAT_W='dr_chat_width';
const handle=document.getElementById('resize-handle');
const panel=document.getElementById('chat-panel');
let dragging=false,startX=0,startW=0;
panel.style.width=(parseInt(localStorage.getItem(CHAT_W)||'340',10))+'px';
handle.addEventListener('mousedown',e=>{dragging=true;startX=e.clientX;startW=panel.offsetWidth;handle.classList.add('dragging');document.body.style.userSelect='none';document.body.style.cursor='col-resize';e.preventDefault();});
document.addEventListener('mousemove',e=>{if(!dragging)return;const nw=Math.max(260,Math.min(600,startW+(startX-e.clientX)));panel.style.width=nw+'px';});
document.addEventListener('mouseup',()=>{if(!dragging)return;dragging=false;handle.classList.remove('dragging');document.body.style.userSelect='';document.body.style.cursor='';try{localStorage.setItem(CHAT_W,panel.offsetWidth);}catch{};});
})();
// ── Model ─────────────────────────────────────────────────────────────────────
const MODEL_KEY='dr_model';
let selectedModel=localStorage.getItem(MODEL_KEY)||'';
async function loadModels(){
const sel=document.getElementById('model-sel');
try{
const[sr,or]=await Promise.all([fetch('/api/settings/model',{headers:authH()}),fetch('/api/ollama/models',{headers:authH()})]);
const setting=sr.ok?await sr.json():{};const od=or.ok?await or.json():{};
const primary=setting.primary||'기본';const models=(od.models||[]).map(m=>m.name);
sel.innerHTML=`<option value="">기본 (${primary})</option>`;
if(selectedModel&&!models.includes(selectedModel))models.unshift(selectedModel);
for(const m of models){const o=document.createElement('option');o.value=m;o.textContent=m;if(m===selectedModel)o.selected=true;sel.appendChild(o);}
if(!selectedModel)sel.value='';
}catch{}
}
function saveModel(){selectedModel=document.getElementById('model-sel').value;try{localStorage.setItem(MODEL_KEY,selectedModel);}catch{}}
// ── Skill ─────────────────────────────────────────────────────────────────────
const APP_KEY='dr';
let skillContext='';
let _sessionId=null;
// Each case gets its own AI conversation (derived from the case id, no extra
// field to persist) so working one record never leaks context into another —
// same pattern as lawyer-app's per-case sessions. No case selected → the
// general health-record session.
function getSessionId(){
if(currentCaseId)return 'dr_case_'+currentCaseId;
return _sessionId||'dr_main';
}
async function initSessionId(){
try{const r=await fetch('/api/user/app-sessions',{headers:authH()});if(r.ok){const d=await r.json();if(d[APP_KEY]){_sessionId=d[APP_KEY];return;}}}catch{}
_sessionId='dr_'+Math.random().toString(36).slice(2,12);
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
}
async function loadHistory(){
const sid=getSessionId();
if(!sid||sid.endsWith('_main'))return;
try{
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()});
if(!r.ok)return;
const d=await r.json();
const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant');
if(!msgs.length)return;
const recent=msgs.slice(-30);
const el=document.getElementById('chat-msgs');
const sep=document.createElement('div');sep.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep.textContent='─ 이전 대화 ─';el.appendChild(sep);
for(const m of recent){
if(m.role==='user')addMsg('user',escHtml(m.content||''));
else addMsg('assistant',renderMd(m.content||''));
}
const sep2=document.createElement('div');sep2.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep2.textContent='─ 이어서 ─';el.appendChild(sep2);
el.scrollTop=el.scrollHeight;
}catch{}
}
// Switches the chat panel to whichever case is currently selected (or the
// general session when none is) and reloads that context's own history —
// keeps each record's AI conversation separate from the others and from
// general chat, same pattern as lawyer-app.
let _chatCtxCaseId='__unset__';
async function ensureChatContextForCase(){
if(_chatCtxCaseId===currentCaseId)return;
_chatCtxCaseId=currentCaseId;
const c=cases.find(x=>x.id===currentCaseId);
const label=document.getElementById('dr-chat-ctx-label');
if(label)label.textContent=c?c.title||'제목 없음':'일반';
document.getElementById('chat-msgs').innerHTML='';
await loadHistory();
}
function escAttr(s){return String(s||'').replace(/'/g,'&#39;').replace(/"/g,'&quot;');}
// For values interpolated into a single-quoted JS string literal INSIDE an
// onclick="..." attribute — escAttr alone isn't enough since the browser
// HTML-decodes the attribute before the JS parses it.
function escJsAttr(s){return escAttr(String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,'\\n').replace(/\r/g,''));}
// ── Cases (진료 기록) ────────────────────────────────────────────────────────
let cases=[];
let casesLoaded=false;
let currentCaseId=null;
let activeCaseTab='notes';
let saveTimer=null;
function genId(){return 'case_'+Math.random().toString(36).slice(2,10);}
function normalizeCase(c){
if(!c.checklist)c.checklist=[];
if(!c.timeline)c.timeline=[];
if(!c.priority)c.priority='normal';
return c;
}
async function loadCases(){
try{
const r=await fetch('/api/doctor/cases',{headers:authH()});
if(r.ok){const d=await r.json();if(Array.isArray(d.cases))cases=d.cases.map(normalizeCase);}
}catch{}
renderCaseList();
}
async function saveCases(){
updateCurrentCaseFromDom();
try{
await fetch('/api/doctor/cases',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({cases})});
showSaveIndicator('저장됨');
}catch{showSaveIndicator('저장 실패');}
}
function showSaveIndicator(msg){
const el=document.getElementById('save-indicator');
if(!el)return;
el.textContent=msg;
clearTimeout(saveTimer);
saveTimer=setTimeout(()=>{el.textContent='';},2000);
}
function updateCurrentCaseFromDom(){
if(!currentCaseId)return;
const c=cases.find(x=>x.id===currentCaseId);
if(!c)return;
c.title=document.getElementById('case-name-input').value||'제목 없음';
c.status=document.getElementById('case-status-sel').value;
c.priority=document.getElementById('case-pri-sel').value;
c.notes=document.getElementById('case-notes').value;
}
const PRI_ORDER={urgent:0,high:1,normal:2,low:3};
const STATUS_ORDER={'치료중':0,'경과관찰':1,'완치':2};
function getSortedCases(){
const q=document.getElementById('case-search')?.value?.toLowerCase()||'';
const sort=document.getElementById('case-sort')?.value||'date-desc';
let list=[...cases];
if(q)list=list.filter(c=>(c.title||'').toLowerCase().includes(q)||(c.notes||'').toLowerCase().includes(q));
list.sort((a,b)=>{
if(sort==='date-desc')return new Date(b.createdAt||0)-new Date(a.createdAt||0);
if(sort==='date-asc')return new Date(a.createdAt||0)-new Date(b.createdAt||0);
if(sort==='title')return (a.title||'').localeCompare(b.title||'','ko');
if(sort==='status')return (STATUS_ORDER[a.status]??9)-(STATUS_ORDER[b.status]??9);
if(sort==='priority')return (PRI_ORDER[a.priority]??9)-(PRI_ORDER[b.priority]??9);
return 0;
});
return list;
}
function renderCaseList(){
const list=document.getElementById('case-list');
const sorted=getSortedCases();
if(!sorted.length){
list.innerHTML='<div style="padding:10px;font-size:11px;color:var(--muted);text-align:center">기록이 없습니다.</div>';
return;
}
list.innerHTML=sorted.map(c=>{
const dotCls=c.status==='완치'?'s-done':c.status==='경과관찰'?'s-hold':'s-active';
const priCls='p-'+(c.priority||'normal');
const priLabel={urgent:'긴급',high:'높음',normal:'',low:'경미'}[c.priority||'normal'];
const isActive=c.id===currentCaseId;
const date=c.createdAt?new Date(c.createdAt).toLocaleDateString('ko',{month:'short',day:'numeric'}):'';
const clDone=(c.checklist||[]).filter(x=>x.done).length;
const clTotal=(c.checklist||[]).length;
return `<div class="dr-case-item${isActive?' active':''}" onclick="selectCase('${c.id}')">
<div class="dr-case-title">${escHtml(c.title||'제목 없음')}</div>
<div class="dr-case-meta">
<span class="dr-status-dot ${dotCls}"></span>${escHtml(c.status||'치료중')}
${priLabel?`<span class="dr-pri-dot ${priCls}"></span>${priLabel}`:''}
${date?'· '+date:''}
${clTotal?`<span style="color:var(--muted)">✅${clDone}/${clTotal}</span>`:''}
</div>
<button class="dr-case-rename" onclick="event.stopPropagation();startRenameCase('${c.id}',this)" title="이름 변경">✎</button>
<button class="dr-case-del" onclick="event.stopPropagation();deleteCase('${c.id}')" title="삭제">✕</button>
</div>`;
}).join('');
}
// Double-click a case's title in the sidebar list to rename it inline, without
// having to select the case first and edit the header input — swaps the title
// div for a text input, commits on Enter/blur, discards on Escape.
function startRenameCase(id,btnOrTitleEl){
const c=cases.find(x=>x.id===id);
if(!c)return;
const titleEl=btnOrTitleEl.classList.contains('dr-case-title')?btnOrTitleEl:btnOrTitleEl.closest('.dr-case-item').querySelector('.dr-case-title');
if(!titleEl)return;
const input=document.createElement('input');
input.className='dr-case-title';
input.style.cssText='width:100%;box-sizing:border-box;background:var(--panel-2);border:1px solid #14b8a6;border-radius:4px;padding:1px 4px;font:inherit;color:inherit;';
input.value=c.title||'';
const commit=()=>{
const name=input.value.trim();
if(name){
c.title=name;
if(currentCaseId===id){
document.getElementById('case-name-input').value=name;
const label=document.getElementById('dr-chat-ctx-label');
if(label)label.textContent=name;
}
saveCases();
}
renderCaseList();
};
input.onblur=commit;
input.onkeydown=(e)=>{
if(e.key==='Enter'){e.preventDefault();input.blur();}
else if(e.key==='Escape'){e.preventDefault();input.onblur=null;renderCaseList();}
};
input.onclick=(e)=>e.stopPropagation();
titleEl.replaceWith(input);
input.focus();input.select();
}
function selectCase(id){
updateCurrentCaseFromDom();
currentCaseId=id;
const c=cases.find(x=>x.id===id);
if(!c)return;
document.getElementById('case-name-input').value=c.title||'';
document.getElementById('case-status-sel').value=c.status||'치료중';
document.getElementById('case-pri-sel').value=c.priority||'normal';
document.getElementById('case-notes').value=c.notes||'';
document.getElementById('case-center-empty').style.display='none';
const cc=document.getElementById('case-center-body');
cc.style.display='flex';
renderCaseList();
if(activeCaseTab==='checklist')renderChecklist();
if(activeCaseTab==='timeline')renderTimeline();
if(activeCaseTab==='files')loadCaseFiles();
if(activeCaseTab==='docs')loadCaseDocs();
ensureChatContextForCase();
if(window.innerWidth<=1024)mobCloseAll();
}
function newCase(){
const c=normalizeCase({id:genId(),title:'새 기록',status:'치료중',notes:'',createdAt:new Date().toISOString()});
cases.unshift(c);
selectCase(c.id);
saveCases();
setTimeout(()=>{const el=document.getElementById('case-name-input');if(el){el.focus();el.select();}},50);
}
async function deleteCase(id){
if(!confirm('이 기록을 삭제할까요?'))return;
cases=cases.filter(c=>c.id!==id);
fetch('/api/chat/sessions/'+encodeURIComponent('dr_case_'+id),{method:'DELETE',headers:authH()}).catch(()=>{});
if(currentCaseId===id){
currentCaseId=null;
document.getElementById('case-center-empty').style.display='';
document.getElementById('case-center-body').style.display='none';
ensureChatContextForCase();
}
await saveCases();
renderCaseList();
}
function onCaseNameChange(){
const c=cases.find(x=>x.id===currentCaseId);
if(c)c.title=document.getElementById('case-name-input').value;
renderCaseList();
const label=document.getElementById('dr-chat-ctx-label');
if(label&&c)label.textContent=c.title||'제목 없음';
}
function onStatusChange(){
const c=cases.find(x=>x.id===currentCaseId);
if(c)c.status=document.getElementById('case-status-sel').value;
renderCaseList();saveCases();
}
function onPriChange(){
const c=cases.find(x=>x.id===currentCaseId);
if(c)c.priority=document.getElementById('case-pri-sel').value;
renderCaseList();saveCases();
}
function onNotesChange(){/* auto-save on blur */}
function handleNotesTab(e){
if(e.key!=='Tab')return;
e.preventDefault();
const ta=e.target;
const start=ta.selectionStart,end=ta.selectionEnd;
ta.value=ta.value.slice(0,start)+'\t'+ta.value.slice(end);
ta.selectionStart=ta.selectionEnd=start+1;
onNotesChange();
}
// A no-UI chat call (no typing indicator/bubble in the visible panel) for
// small background AI tasks like auto-naming — callChatRaw is the visible-
// bubble version used by generateChecklist, this is the silent counterpart.
async function quietChatCall(message){
try{
const body={message,sessionId:'dr_tmp_'+Math.random().toString(36).slice(2,8),useTools:false,
...(selectedModel?{model:selectedModel}:{}),
...(skillContext?{skillContext}:{})};
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
if(!r.ok)return null;
const reader=r.body.getReader();const dec=new TextDecoder();let buf='',textBuf='';
while(true){
const{done,value}=await reader.read();if(done)break;
buf+=dec.decode(value,{stream:true});
const parts=buf.split('\n\n');buf=parts.pop()||'';
for(const part of parts){
if(!part.startsWith('data: '))continue;
let ev;try{ev=JSON.parse(part.slice(6));}catch{continue;}
if(ev.type==='token'||ev.type==='text_delta')textBuf+=(ev.text||'');
}
}
return textBuf.trim();
}catch{return null;}
}
// Auto-names a new record from its notes the first time they're written, so
// "새 기록" doesn't have to be manually retitled — only fires while the title
// is still the untouched default, and never overrides a name the user typed
// in themselves (checked again after the AI call returns, in case they
// switched case or renamed it while waiting).
let drAutoNaming=false;
// sourceText: content to name the case FROM. Falls back to the notes field
// when omitted (notes-blur call site) — the chat-driven call site (callChat,
// once a turn finishes with a case selected) passes the user's message
// directly, since a case discussed only through chat never touches the notes
// textarea at all and would otherwise never trigger this.
async function autoNameCaseIfNeeded(sourceText){
const c=currentCase();
if(!c||drAutoNaming)return;
const title=(document.getElementById('case-name-input').value||'').trim();
const text=(sourceText!==undefined?sourceText:document.getElementById('case-notes').value)||'';
if(title!=='새 기록'||text.trim().length<15)return;
drAutoNaming=true;
const targetId=c.id;
try{
const prompt=`다음은 증상/진료 상담 내용입니다. 8자 이내의 간결한 케이스명을 지어주세요. 케이스명만 출력하고 다른 설명·따옴표·마침표는 넣지 마세요.\n\n${text.slice(0,500)}`;
const result=await quietChatCall(prompt);
if(!result||currentCaseId!==targetId)return; // case switched away while waiting
const cur=cases.find(x=>x.id===targetId);
if(!cur||(document.getElementById('case-name-input').value||'').trim()!=='새 기록')return; // user already renamed it
const name=result.split('\n')[0].replace(/^["'“”]+|["'“”]+$/g,'').trim().slice(0,30);
if(!name)return;
cur.title=name;
document.getElementById('case-name-input').value=name;
renderCaseList();
const label=document.getElementById('dr-chat-ctx-label');
if(label)label.textContent=name;
saveCases();
}finally{drAutoNaming=false;}
}
function switchCaseTab(tab){
activeCaseTab=tab;
['notes','checklist','timeline','files','docs'].forEach(t=>{
document.getElementById('tab-'+t)?.classList.toggle('active',t===tab);
document.getElementById('pane-'+t)?.classList.toggle('active',t===tab);
});
if(tab==='checklist')renderChecklist();
if(tab==='timeline')renderTimeline();
if(tab==='files')loadCaseFiles();
if(tab==='docs')loadCaseDocs();
}
// ── Checklist (복약·검사) ─────────────────────────────────────────────────────
function currentCase(){return cases.find(x=>x.id===currentCaseId);}
function renderChecklist(){
const c=currentCase();
const list=document.getElementById('cl-list');
const prog=document.getElementById('cl-progress');
if(!c){list.innerHTML='';prog.style.display='none';return;}
const items=c.checklist||[];
const done=items.filter(x=>x.done).length;
if(items.length){
prog.style.display='flex';
document.getElementById('cl-prog-fill').style.width=(done/items.length*100)+'%';
document.getElementById('cl-prog-text').textContent=done+'/'+items.length;
}else{prog.style.display='none';}
if(!items.length){
list.innerHTML='<div class="dr-cl-empty">복약·검사 항목을 추가하거나<br>AI 자동 생성을 이용하세요.</div>';
return;
}
list.innerHTML=items.map(item=>`
<div class="dr-cl-item${item.done?' done':''}" id="cli-${item.id}">
<div class="dr-cl-cb${item.done?' checked':''}" onclick="toggleCheck('${item.id}')">${item.done?'✓':''}</div>
<div class="dr-cl-text">${escHtml(item.text)}</div>
<button class="dr-cl-del" onclick="deleteCheck('${item.id}')">✕</button>
</div>
`).join('');
}
function addCheckItem(){
const inp=document.getElementById('cl-input');
const text=inp.value.trim();
if(!text||!currentCaseId)return;
const c=currentCase();
if(!c)return;
if(!c.checklist)c.checklist=[];
c.checklist.push({id:'cl_'+Math.random().toString(36).slice(2,8),text,done:false});
inp.value='';
renderChecklist();renderCaseList();saveCases();
}
function toggleCheck(id){
const c=currentCase();if(!c)return;
const item=(c.checklist||[]).find(x=>x.id===id);
if(item)item.done=!item.done;
renderChecklist();renderCaseList();saveCases();
}
function deleteCheck(id){
const c=currentCase();if(!c)return;
c.checklist=(c.checklist||[]).filter(x=>x.id!==id);
renderChecklist();renderCaseList();saveCases();
}
async function generateChecklist(){
const c=currentCase();
const notes=document.getElementById('case-notes')?.value||'';
if(!c&&!notes){alert('먼저 증상 메모 탭에 내용을 입력하세요.');return;}
const title=c?.title||'현재 기록';
const prompt=`다음 증상/진료 기록에 대한 복약·검사·자기관리 체크리스트를 만들어주세요. 각 항목을 "- " 로 시작하는 목록 형식으로 작성해 주세요. (최대 15개) 진단이나 처방을 새로 지어내지 말고, 기록된 내용을 정리하는 항목 위주로 작성하세요.
케이스명: ${title}
증상/진료 내용:
${notes||'(내용 없음)'}
체크리스트 항목만 줄바꿈으로 나열해주세요. 설명 없이 항목만 출력.`;
addMsg('user',escHtml('✨ AI 체크리스트 자동 생성 중...'));
if(window.innerWidth<=1024)mobToggle('chat');
const result=await callChatRaw(prompt);
if(result){
const items=result.split('\n').filter(l=>l.trim().startsWith('-')).map(l=>l.replace(/^-\s*/,'').trim()).filter(Boolean);
if(items.length){
if(!c.checklist)c.checklist=[];
items.forEach(text=>c.checklist.push({id:'cl_'+Math.random().toString(36).slice(2,8),text,done:false}));
renderChecklist();renderCaseList();saveCases();
addMsg('system',`✅ ${items.length}개 항목이 체크리스트에 추가되었습니다.`);
}
}
}
// ── Timeline (진료 이력) ──────────────────────────────────────────────────────
function renderTimeline(){
const c=currentCase();
const list=document.getElementById('tl-list');
if(!c){list.innerHTML='';return;}
const events=[...(c.timeline||[])].sort((a,b)=>a.date.localeCompare(b.date));
if(!events.length){
list.innerHTML='<div class="dr-tl-empty">날짜와 내용을 추가하여<br>진료 이력을 구성하세요.</div>';
return;
}
list.innerHTML=events.map(ev=>`
<div class="dr-tl-item">
<div class="dr-tl-line"><div class="dr-tl-dot"></div><div class="dr-tl-rod"></div></div>
<div class="dr-tl-body">
<div class="dr-tl-date-lbl">${escHtml(ev.date)}</div>
<div class="dr-tl-txt">
<span>${escHtml(ev.text)}</span>
<button class="dr-tl-del" onclick="deleteEvent('${ev.id}')">✕</button>
</div>
</div>
</div>
`).join('');
}
function addTimelineEvent(){
const date=document.getElementById('tl-date').value;
const text=document.getElementById('tl-input').value.trim();
if(!date||!text||!currentCaseId){if(!date)alert('날짜를 선택하세요.');return;}
const c=currentCase();if(!c)return;
if(!c.timeline)c.timeline=[];
c.timeline.push({id:'tl_'+Math.random().toString(36).slice(2,8),date,text});
document.getElementById('tl-input').value='';
renderTimeline();saveCases();
}
function deleteEvent(id){
const c=currentCase();if(!c)return;
c.timeline=(c.timeline||[]).filter(x=>x.id!==id);
renderTimeline();saveCases();
}
// ── Files (첨부자료 / 처방·검사결과) ──────────────────────────────────────────
function fmtFileSize(n){
if(n<1024)return n+'B';
if(n<1024*1024)return (n/1024).toFixed(1)+'KB';
return (n/1024/1024).toFixed(1)+'MB';
}
const DR_TEXT_EDIT_EXTS=/\.(txt|md|csv)$/i;
const DR_CATEGORY_LABELS={photo:'📷 사진',video:'🎥 동영상',document:'📄 문서',other:'📦 기타'};
const DR_CATEGORY_ORDER=['photo','video','document','other'];
const DR_CATEGORY_ICONS={photo:'📷',video:'🎥',document:'📄',other:'📦'};
// Files are separated by which tab's upload button was actually used
// (uploadOneCaseFile prefixes with 'attachments/' or 'submissions/'), not by
// guessing from the file extension — same fix already applied in lawyer-app
// (v4.3.40): a PDF uploaded from one tab used to silently show up under the
// other tab because the old split guessed from the file extension instead of
// which button was actually clicked.
async function loadCaseFiles(){
const list=document.getElementById('case-files-list');
if(!currentCaseId){list.innerHTML='';return;}
list.innerHTML='<div class="dr-cl-empty">불러오는 중...</div>';
try{
const r=await fetch('/api/doctor/files?caseId='+encodeURIComponent(currentCaseId),{headers:authH()});
const d=await r.json();
const all=Array.isArray(d.files)?d.files:[];
renderCaseFileList(list,all.filter(f=>f.folder==='attachments'||f.folder===''));
}catch{list.innerHTML='<div class="dr-cl-empty">파일 목록을 불러오지 못했습니다.</div>';}
}
async function loadCaseDocs(){
const list=document.getElementById('case-docs-list');
if(!currentCaseId){list.innerHTML='';return;}
list.innerHTML='<div class="dr-cl-empty">불러오는 중...</div>';
try{
const r=await fetch('/api/doctor/files?caseId='+encodeURIComponent(currentCaseId),{headers:authH()});
const d=await r.json();
const all=Array.isArray(d.files)?d.files:[];
const docs=all.filter(f=>f.folder==='submissions').sort((a,b)=>new Date(b.mtime)-new Date(a.mtime));
if(!docs.length){list.innerHTML='<div class="dr-cl-empty">등록된 처방전·검사결과가 없습니다.</div>';return;}
list.innerHTML=docs.map(f=>renderCaseFileRow(f)).join('');
}catch{list.innerHTML='<div class="dr-cl-empty">문서 목록을 불러오지 못했습니다.</div>';}
}
function renderCaseFileList(list,files){
if(!files.length){list.innerHTML='<div class="dr-cl-empty">자료를 업로드하세요.</div>';return;}
const groups={};
files.forEach(f=>{(groups[f.category||'other']=groups[f.category||'other']||[]).push(f);});
list.innerHTML=DR_CATEGORY_ORDER.filter(cat=>groups[cat]?.length).map(cat=>{
const items=groups[cat];
return `<div class="dr-ev-group-title">${DR_CATEGORY_LABELS[cat]} (${items.length})</div>`
+items.map(f=>renderCaseFileRow(f)).join('');
}).join('');
}
function renderCaseFileRow(f){
const isPdf=/\.pdf$/i.test(f.name);
const isCloudEditable=/\.(hwp|odt|docx?)$/i.test(f.name);
const isText=DR_TEXT_EDIT_EXTS.test(f.name);
const isImage=f.category==='photo';
const icon=DR_CATEGORY_ICONS[f.category]||'📦';
const nameHtml=isText
?`<a href="javascript:void(0)" onclick="openTextEditor('${escJsAttr(f.relPath)}','${escJsAttr(f.url)}','${escJsAttr(f.name)}')" style="flex:1;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all">${escHtml(f.name)}</a>`
:isImage
?`<a href="javascript:void(0)" onclick="openImageViewer('${escJsAttr(f.url)}','${escJsAttr(f.name)}')" style="flex:1;display:flex;align-items:center;gap:6px;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all"><img src="${escAttr(f.url)}" alt="" style="width:28px;height:28px;object-fit:cover;border-radius:4px;flex-shrink:0">${escHtml(f.name)}</a>`
:`<a href="${escAttr(f.url)}" target="_blank" style="flex:1;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all">${escHtml(f.name)}</a>`;
return `<div class="dr-cl-item">
<span style="flex-shrink:0">${icon}</span>
${nameHtml}
<span style="font-size:10px;color:var(--muted);flex-shrink:0">${fmtFileSize(f.size)}</span>
${isPdf?`<a href="/pdf-editor-open?app=doctor&caseId=${encodeURIComponent(currentCaseId)}" target="_blank" class="dr-cl-del" style="opacity:.7" title="PDF 편집기를 새 창에서 엽니다. 편집기의 업로드 버튼에서 이 파일을 직접 선택하세요. 수정 후 다운로드하면 기록 폴더에 자동 저장됩니다.">✏️</a>`:''}
${isCloudEditable?`<a href="/api/doctor/collabora-open?caseId=${encodeURIComponent(currentCaseId)}&relPath=${encodeURIComponent(f.relPath)}" target="_blank" class="dr-cl-del" style="opacity:.7" title="Collabora에서 바로 편집합니다. 입력하는 대로 기록 폴더에 자동 저장됩니다.">🌐</a>`:''}
<a href="${escAttr(f.url)}" download="${escAttr(f.name)}" class="dr-cl-del" title="기기에 다운로드">💾</a>
<button class="dr-cl-del" onclick="deleteCaseFile('${escJsAttr(f.relPath)}')" title="삭제">✕</button>
</div>`;
}
// folder: 'attachments' or 'submissions' — determines which tab this file
// shows up in (see loadCaseFiles/loadCaseDocs), independent of file type.
function uploadOneCaseFile(file,folder){
return new Promise((resolve,reject)=>{
const fd=new FormData();
fd.append('file',file,`${folder}/${file.name}`);
fetch('/api/doctor/upload?caseId='+encodeURIComponent(currentCaseId),{
method:'POST',headers:authH(),body:fd,
}).then(r=>r.json()).then(d=>d.success?resolve(d):reject(d)).catch(reject);
});
}
async function onCaseFilesPicked(fileList){
if(!currentCaseId){alert('기록을 먼저 선택하세요.');return;}
const status=document.getElementById('case-file-upload-status');
const files=[...fileList];
for(let i=0;i<files.length;i++){
status.textContent=`업로드 중... (${i+1}/${files.length})`;
try{await uploadOneCaseFile(files[i],'attachments');}catch{}
}
status.textContent='';
document.getElementById('case-file-input').value='';
loadCaseFiles();
}
async function onCaseDocsPicked(fileList){
if(!currentCaseId){alert('기록을 먼저 선택하세요.');return;}
const status=document.getElementById('case-doc-upload-status');
const files=[...fileList];
for(let i=0;i<files.length;i++){
status.textContent=`업로드 중... (${i+1}/${files.length})`;
try{await uploadOneCaseFile(files[i],'submissions');}catch{}
}
status.textContent='';
document.getElementById('case-doc-input').value='';
loadCaseDocs();
}
async function deleteCaseFile(relPath){
if(!currentCaseId)return;
if(!confirm('이 파일을 삭제할까요?'))return;
try{
await fetch('/api/doctor/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({caseId:currentCaseId,relPath})});
}catch{}
if(activeCaseTab==='files')loadCaseFiles();
if(activeCaseTab==='docs')loadCaseDocs();
}
// ── Built-in text editor (.txt/.md/.csv) ─────────────────────────────────────
// Old Korean documents are often EUC-KR/CP949, not UTF-8 — fetch raw bytes and
// decode client-side with a manual override instead of letting the browser
// guess (and garble) via a plain <a target=_blank>.
let drTeState=null; // {caseId, relPath, name, bytes}
function decodeTextBytes(buf,encoding){
if(encoding){
try{return new TextDecoder(encoding).decode(buf);}catch{return new TextDecoder('utf-8').decode(buf);}
}
try{return new TextDecoder('utf-8',{fatal:true}).decode(buf);}catch{}
try{return new TextDecoder('euc-kr').decode(buf);}catch{return new TextDecoder('utf-8').decode(buf);}
}
async function openTextEditor(relPath,url,name){
const modal=document.getElementById('dr-text-editor-modal');
const ta=document.getElementById('dr-te-textarea');
const status=document.getElementById('dr-te-status');
document.getElementById('dr-te-filename').textContent=name;
ta.value='불러오는 중...';
status.textContent='';
modal.style.display='flex';
try{
const r=await fetch(url,{headers:authH(),cache:'no-store'});
const buf=await r.arrayBuffer();
const isUtf8=(()=>{try{new TextDecoder('utf-8',{fatal:true}).decode(buf);return true;}catch{return false;}})();
drTeState={caseId:currentCaseId,relPath,name,bytes:buf};
document.getElementById('dr-te-encoding').value=isUtf8?'utf-8':'euc-kr';
ta.value=decodeTextBytes(buf,isUtf8?'utf-8':'euc-kr');
status.textContent=isUtf8?'':'⚠️ UTF-8이 아닌 것으로 보여 EUC-KR로 자동 표시했습니다. 깨져 보이면 인코딩을 바꿔보세요.';
}catch(e){
ta.value='';
status.textContent='파일을 불러오지 못했습니다: '+e.message;
}
}
function drTextEditorRedecode(){
if(!drTeState)return;
const enc=document.getElementById('dr-te-encoding').value;
document.getElementById('dr-te-textarea').value=decodeTextBytes(drTeState.bytes,enc);
document.getElementById('dr-te-status').textContent='';
}
function closeTextEditor(){
document.getElementById('dr-text-editor-modal').style.display='none';
drTeState=null;
}
async function saveTextEditor(){
if(!drTeState)return;
const status=document.getElementById('dr-te-status');
status.textContent='저장 중...';
try{
const content=document.getElementById('dr-te-textarea').value;
const r=await fetch('/api/doctor/files/content',{
method:'PUT',headers:authH({'Content-Type':'application/json'}),
body:JSON.stringify({caseId:drTeState.caseId,relPath:drTeState.relPath,content}),
});
const d=await r.json();
if(!r.ok||!d.success)throw new Error(d.error||'save failed');
status.textContent='저장됨 (UTF-8)';
setTimeout(closeTextEditor,600);
}catch(e){
status.textContent='저장 실패: '+e.message;
}
}
// ── Reference image viewer (X-ray/증상 사진/처방전 등을 새 탭 대신 앱 안에서 확인) ──
let drImgZoom=1,drImgPanX=0,drImgPanY=0;
function openImageViewer(url,name){
document.getElementById('dr-iv-img').src=url;
document.getElementById('dr-iv-filename').textContent=name;
drImgZoomReset();
document.getElementById('dr-img-viewer-modal').classList.add('show');
}
function closeImageViewer(){
document.getElementById('dr-img-viewer-modal').classList.remove('show');
document.getElementById('dr-iv-img').src='';
}
function drApplyImgTransform(){
const img=document.getElementById('dr-iv-img');
img.style.transform=`translate(${drImgPanX}px,${drImgPanY}px) scale(${drImgZoom})`;
document.getElementById('dr-iv-body').style.cursor=drImgZoom>1?'grab':'default';
document.getElementById('dr-iv-zoom-pct').textContent=Math.round(drImgZoom*100)+'%';
}
function drImgZoomIn(){drImgZoom=Math.min(drImgZoom*1.4,8);drApplyImgTransform();}
function drImgZoomOut(){
drImgZoom=Math.max(drImgZoom/1.4,1);
if(drImgZoom<=1){drImgZoom=1;drImgPanX=0;drImgPanY=0;}
drApplyImgTransform();
}
function drImgZoomReset(){drImgZoom=1;drImgPanX=0;drImgPanY=0;drApplyImgTransform();}
(function initImgViewerInteraction(){
const body=document.getElementById('dr-iv-body');
const modal=document.getElementById('dr-img-viewer-modal');
let dragging=false,startX=0,startY=0,panStartX=0,panStartY=0;
body.addEventListener('mousedown',e=>{
if(drImgZoom<=1)return;
dragging=true;startX=e.clientX;startY=e.clientY;panStartX=drImgPanX;panStartY=drImgPanY;
body.classList.add('dragging');e.preventDefault();
});
window.addEventListener('mousemove',e=>{
if(!dragging)return;
drImgPanX=panStartX+(e.clientX-startX);drImgPanY=panStartY+(e.clientY-startY);
drApplyImgTransform();
});
window.addEventListener('mouseup',()=>{dragging=false;body.classList.remove('dragging');});
body.addEventListener('wheel',e=>{
e.preventDefault();
if(e.deltaY<0)drImgZoomIn();else drImgZoomOut();
},{passive:false});
modal.addEventListener('click',e=>{if(e.target===modal||e.target===body)closeImageViewer();});
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&modal.classList.contains('show'))closeImageViewer();});
})();
// ── Raw (no-tools, throwaway-session) chat call — used by AI-assisted
// generation buttons (체크리스트 자동생성) that need a plain text result back
// rather than a full streamed conversation turn.
async function callChatRaw(message){
const msgs=document.getElementById('chat-msgs');
const typingDiv=document.createElement('div');typingDiv.className='dr-msg assistant';
typingDiv.innerHTML='<div class="dr-typing"><div class="dr-dot"></div><div class="dr-dot"></div><div class="dr-dot"></div></div>';
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
let bubble=null,textBuf='',thinkBuf='';
try{
const body={message,sessionId:'dr_tmp_'+Math.random().toString(36).slice(2,8),useTools:false,
...(selectedModel?{model:selectedModel}:{}),
...(skillContext?{skillContext}:{})};
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
if(!r.ok)throw new Error('서버 오류 '+r.status);
typingDiv.remove();
const msgDiv=document.createElement('div');msgDiv.className='dr-msg assistant';
bubble=document.createElement('div');bubble.className='dr-bubble';
msgDiv.appendChild(bubble);msgs.appendChild(msgDiv);
const reader=r.body.getReader();const dec=new TextDecoder();let buf='';
while(true){
const{done,value}=await reader.read();if(done)break;
buf+=dec.decode(value,{stream:true});
const parts=buf.split('\n\n');buf=parts.pop()||'';
for(const part of parts){
if(!part.startsWith('data: '))continue;
let ev;try{ev=JSON.parse(part.slice(6));}catch{continue;}
if(ev.type==='thinking_delta'&&ev.text)thinkBuf+=ev.text;
else if(ev.type==='token'||ev.type==='text_delta'){
textBuf+=(ev.text||'');
if(bubble){
bubble.innerHTML=(thinkBuf?`<div class="dr-thinking" onclick="this.classList.toggle('exp')">💭 ${escHtml(thinkBuf.slice(0,120))}${thinkBuf.length>120?'…':''}</div>`:'')+renderMd(textBuf);
msgs.scrollTop=msgs.scrollHeight;
}
}
}
}
return textBuf;
}catch(e){
typingDiv.remove();
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:var(--err)">오류: '+escHtml(e.message)+'</span>';}
return null;
}
}
// ── AI-assisted record actions (정리 · 진료요약 — organizing only, never a
// diagnosis/prescription generator) ──────────────────────────────────────────
function askAiAboutCase(){
const c=currentCase();
const notes=document.getElementById('case-notes')?.value||'';
if(!c&&!notes)return;
const title=c?.title||'현재 기록';
const msg=`다음 기록을 정리하고, 병원 방문 시 의료진에게 물어보면 좋을 질문 목록을 제안해주세요. 진단이나 처방을 추측하지 마세요.\n\n**케이스명:** ${title}\n**상태:** ${c?.status||'치료중'}\n\n**증상 및 상담 메모:**\n${notes||'(없음)'}`;
addMsg('user',escHtml('기록 정리 요청: '+title));
callChat(msg);
if(window.innerWidth<=1024)mobToggle('chat');
}
async function generateSummary(){
const c=currentCase();
if(!c){alert('기록을 먼저 선택하세요.');return;}
const notes=document.getElementById('case-notes')?.value||'';
const clItems=(c.checklist||[]).map(x=>`[${x.done?'완료':'미완'}] ${x.text}`).join('\n')||'(없음)';
const tlItems=[...(c.timeline||[])].sort((a,b)=>a.date.localeCompare(b.date)).map(x=>`${x.date}: ${x.text}`).join('\n')||'(없음)';
const prompt=`다음 기록을 바탕으로, 병원 진료 시 가지고 갈 수 있는 진료 요약본을 작성해주세요. 이미 기록된 사실만 정리하고, 새로운 진단·처방·추측을 추가하지 마세요.
**케이스명:** ${c.title}
**상태:** ${c.status} **중요도:** ${c.priority}
**증상/상담 메모:**
${notes||'(없음)'}
**복약·검사 체크리스트:**
${clItems}
**진료 이력:**
${tlItems}
위 정보를 날짜순으로 정리한 요약본 형식으로 작성해주세요. 마지막에 "이 요약은 기록 정리용이며 진단이 아닙니다"라는 안내를 포함하세요.`;
addMsg('user',escHtml('📋 진료요약 생성 요청: '+c.title));
callChat(prompt);
if(window.innerWidth<=1024)mobToggle('chat');
}
const SKILL_CONTENT="---\nname: Health Record Assistant\ndescription: 개인 건강 기록 정리 어시스턴트. 증상·진료 이력·복약 정보를 사건 파일처럼 정리하고, 일반적인 건강 정보 검색을 도구와 함께 수행. 진단·처방은 하지 않음.\nemoji: \"🩺\"\nversion: 1.0.0\ntriggers: [증상, 진료, 병원, 검사, 처방, 복약, 통증, 진단, 약, 검진, 아파요, 아픈]\n---\n\n당신은 개인 건강 기록 정리를 돕는 어시스턴트입니다. 사용자가 증상과 진료 이력을 스스로 정리하고, 병원에 갈 때 필요한 정보를 준비하도록 돕는 것이 목적입니다.\n\n### ⛔ 가장 중요한 원칙: 진단·처방 금지\n\n이 서비스는 **개인 기록 정리 목적**이며 의학적 진단·처방을 대체하지 않습니다.\n\n- \"이거 무슨 병이에요?\", \"이 약 먹어도 되나요?\" 같은 질문에 **확정적인 진단명이나 처방을 제시하지 마세요.**\n- 대신: 증상을 정리해서 보여주고, \"의료진과 상담하세요\"를 명시하고, 필요하면 일반적으로 어떤 진료과에서 다루는 증상인지 정도만 안내하세요.\n- 검사 수치·의학 용어를 설명할 때도 \"일반적으로 이런 의미입니다\"라는 식으로 정보 제공에 그치고, 그 사용자의 특정 수치가 정상/비정상인지는 판단하지 마세요.\n- 불확실하거나 판단이 필요한 부분은 반드시 \"의료진 확인 필요\"로 명시하세요.\n\n### 사용 가능한 도구\n\n| 도구 | 용도 |\n|---|---|\n| `web_search` | 일반적인 건강 정보, 의학 용어 설명, 질병/증상에 대한 공개 자료 검색 |\n| `pdf_read` | 검사결과지·진단서·처방전 PDF 내용 읽기 (해석이 아니라 내용 추출·정리 목적) |\n\n### 기록 정리 방법론\n\n사용자가 증상이나 진료 내용을 이야기하면:\n\n1. **사실관계 정리** — 증상, 발생 시점, 경과, 이미 받은 진료/검사 결과를 시간순으로 정리\n2. **누락된 정보 확인** — 언제부터, 얼마나 자주, 어느 병원, 처방받은 약 이름 등 기록에 빠진 부분을 질문\n3. **병원 방문 준비** — 의료진에게 물어보면 좋을 질문 목록 제안 (진단 아님, 질문 목록임)\n4. **용어 설명** — 검사 결과지에 나온 의학 용어·수치 단위를 일반적인 의미로 설명 (그 사람 상태에 대한 판단 아님)\n\n### 응답 규칙\n\n1. **면책 고지 필수**: 증상/진단 관련 질문에 답할 때마다 \"이 정보는 참고용이며 진단을 대체하지 않습니다. 증상이 있으면 의료진과 상담하세요\" 같은 문구를 포함하세요.\n2. **응급 신호 인지**: 호흡곤란, 심한 흉통, 의식저하, 심한 출혈 등 응급 신호가 언급되면 정리보다 먼저 \"즉시 119 또는 응급실 방문\"을 권고하세요.\n3. **출처 표기**: `web_search`로 찾은 일반 건강 정보는 출처를 함께 밝히세요.\n4. **언어**: 사용자가 한국어로 질문하면 한국어로 답변.\n5. **기록 정리 우선**: 사용자가 정리를 요청하면 위 방법론에 따라 정리하고, 자문을 요청하는 경우에도 진단이 아니라 정리·안내 형태로 응답하세요.\n";
function loadSkill(){
skillContext=SKILL_CONTENT;
document.getElementById('skill-badge').textContent='🩺 건강기록 어시스턴트 활성';
}
function escHtml(s){return String(s ?? '').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
// ── Chat ──────────────────────────────────────────────────────────────────────
function addMsg(role,html){
const msgs=document.getElementById('chat-msgs');
const div=document.createElement('div');div.className='dr-msg '+role;
const b=document.createElement('div');b.className='dr-bubble';b.innerHTML=html;
// 수식은 DOM 삽입 후에 렌더링해야 한다(innerHTML은 <script>를 실행하지 않는다).
if(role!=='user'&&window.renderKatexIn)window.renderKatexIn(b);
div.appendChild(b);
if(role==='assistant'){const tb=document.createElement('button');tb.className='app-tts-btn';tb.title='음성으로 듣기';tb.textContent='🔊';tb.onclick=function(){playTTSText(tb,b.innerText);};div.appendChild(tb);}
msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
return b;
}
function renderMd(text){
return text
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/\*(.+?)\*/g,'<em>$1</em>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
.replace(/^> (.+)$/gm,'<blockquote>$1</blockquote>')
.replace(/^[-*]\s+(.+)$/gm,'<li>$1</li>')
.replace(/(<li>[\s\S]+?<\/li>)/g,m=>`<ul style="padding-left:16px;margin:3px 0">${m}</ul>`)
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
.replace(/<p><\/p>/g,'').replace(/<p>(<[hup])/g,'$1').replace(/(<\/[hup][^>]*>)<\/p>/g,'$1');
}
async function sendMessage(){
const input=document.getElementById('chat-input');
const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return;
input.value='';autoResize(input);
if(window.innerWidth<=1024) mobToggle('chat');
addMsg('user',escHtml(msg));
await callChat(msg);
}
function sendQuick(msg){
if(window.innerWidth<=1024) mobCloseAll();
addMsg('user',escHtml(msg));callChat(msg);
}
// Uploads a lab-result/prescription PDF via the shared /api/upload/file endpoint
// and hands the model the workspace-relative path so it can call pdf_read on it.
async function onFilePicked(file){
if(!file)return;
document.getElementById('file-input').value='';
if(window.innerWidth<=1024) mobToggle('chat');
addMsg('system','📎 '+escHtml(file.name)+' 업로드 중...');
try{
const form=new FormData();form.append('file',file);
const r=await fetch('/api/upload/file',{method:'POST',headers:authH(),body:form});
if(!r.ok)throw new Error('업로드 실패 '+r.status);
const data=await r.json();
const relPath=data.path||('uploads/'+file.name);
const msg=`첨부한 문서(${relPath}, 원본명: ${file.name})의 내용을 읽고 정리해주세요. 진단은 하지 말고 내용만 정리해주세요.`;
addMsg('user',escHtml(msg));
await callChat(msg);
}catch(e){
addMsg('system','❌ 업로드 실패: '+escHtml(e.message));
}
}
async function callChat(message){
const btn=document.getElementById('send-btn');
btn.disabled=true;btn.textContent='...';
const msgs=document.getElementById('chat-msgs');
const typingDiv=document.createElement('div');typingDiv.className='dr-msg assistant';
typingDiv.innerHTML='<div class="dr-typing"><div class="dr-dot"></div><div class="dr-dot"></div><div class="dr-dot"></div></div>';
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
let bubble=null,textBuf='',thinkBuf='';
try{
const body={message,sessionId:getSessionId(),useTools:true,
...(selectedModel?{model:selectedModel}:{}),
...(skillContext?{skillContext}:{})};
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
if(!r.ok)throw new Error('서버 오류 '+r.status);
typingDiv.remove();
const msgDiv=document.createElement('div');msgDiv.className='dr-msg assistant';
bubble=document.createElement('div');bubble.className='dr-bubble';
msgDiv.appendChild(bubble);msgs.appendChild(msgDiv);
const reader=r.body.getReader();const dec=new TextDecoder();let buf='';
while(true){
const{done,value}=await reader.read();if(done)break;
buf+=dec.decode(value,{stream:true});
const parts=buf.split('\n\n');buf=parts.pop()||'';
for(const part of parts){
if(!part.startsWith('data: '))continue;
let ev;try{ev=JSON.parse(part.slice(6));}catch{continue;}
if(ev.type==='thinking_delta'&&ev.text)thinkBuf+=ev.text;
else if(ev.type==='token'||ev.type==='text_delta'){
textBuf+=(ev.text||'');
if(bubble){
bubble.innerHTML=(thinkBuf?`<div class="dr-thinking" onclick="this.classList.toggle('exp')">💭 ${escHtml(thinkBuf.slice(0,120))}${thinkBuf.length>120?'…':''}</div>`:'')+renderMd(textBuf);
msgs.scrollTop=msgs.scrollHeight;
}
}
}
}
}catch(e){
typingDiv.remove();
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:var(--err)">오류: '+escHtml(e.message)+'</span>';}
}finally{btn.disabled=false;btn.textContent='전송';}
// A record discussed only through chat (never typed into the 메모 tab) would
// otherwise never trigger auto-naming — try from this turn's own message too.
if(currentCaseId)autoNameCaseIfNeeded(message);
}
async function clearChat(){
if(!confirm('이 대화를 지울까요?'))return;
const sid=getSessionId();
document.getElementById('chat-msgs').innerHTML='';
try{await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{method:'DELETE',headers:authH()});}catch{}
if(!currentCaseId){
_sessionId='dr_'+Math.random().toString(36).slice(2,12);
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
}
addMsg('system','새 대화가 시작되었습니다. 증상이나 건강 관련 질문을 입력해주세요.');
}
function handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}}
function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,120)+'px';}
// ── Init ──────────────────────────────────────────────────────────────────────
(async()=>{
const ok=await checkAuth();if(!ok)return;
await initSessionId();
await loadHistory();
await Promise.all([loadSkill(),loadModels()]);
await loadCases();casesLoaded=true;
addMsg('system','🩺 건강 기록 어시스턴트입니다. 증상이나 건강 관련 질문을 입력하세요. 이 서비스는 기록 정리 목적이며 진단을 대체하지 않습니다.');
initAppVoice(sendMessage);
document.getElementById('chat-input').focus();
})();
</script>
</body>
</html>