법률앱에서 `$\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>
1455 lines
84 KiB
HTML
1455 lines
84 KiB
HTML
<!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,''').replace(/"/g,'"');}
|
||
// 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.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>
|