법률앱에서 `$\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>
1328 lines
92 KiB
HTML
1328 lines
92 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 ── */
|
||
.mn-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
|
||
.mn-hdr h1{font-size:13px;font-weight:700;margin:0;}
|
||
.mn-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:#8b5cf6;color:#8b5cf6;}
|
||
|
||
/* ── Mode toggle ── */
|
||
.mode-toggle{display:flex;gap:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;flex-shrink:0;}
|
||
.mode-tab{padding:3px 11px;font-size:11px;font-weight:700;cursor:pointer;background:none;border:none;color:var(--muted);font-family:inherit;transition:.15s;}
|
||
.mode-tab.active{background:#1e1b4b;color:#a78bfa;}
|
||
[data-theme="light"] .mode-tab.active{background:#ede9fe;color:#7c3aed;}
|
||
|
||
/* ── Layout ── */
|
||
.mn-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
|
||
|
||
/* ── Sidebar ── */
|
||
.mn-sb{width:200px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--line);background:var(--panel);min-height:0;}
|
||
.mn-sb-sec{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:8px 10px 4px;}
|
||
.mn-tools{padding:0 6px 6px;display:flex;flex-direction:column;gap:3px;flex:1;overflow-y:auto;min-height:0;}
|
||
.mn-tool{background:none;border:1px solid var(--line);border-radius:7px;padding:7px 10px;font-size:12px;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left;transition:.15s;display:flex;align-items:center;gap:7px;}
|
||
.mn-tool:hover{border-color:#8b5cf6;color:#8b5cf6;background:rgba(139,92,246,.05);}
|
||
.mn-tool.active{border-color:#8b5cf6;background:#1e1b4b;color:#a78bfa;}
|
||
[data-theme="light"] .mn-tool.active{background:#ede9fe;color:#7c3aed;}
|
||
.mn-tool .t-lbl{flex:1;line-height:1.3;}
|
||
.mn-tool .t-sub{font-size:10px;color:var(--muted);display:block;margin-top:1px;}
|
||
.mn-tool.active .t-sub{color:#7c5f9e;}
|
||
.mn-divider{height:1px;background:var(--line);margin:4px 0;}
|
||
|
||
/* Crisis line */
|
||
.mn-crisis{margin:6px;padding:8px 10px;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.2);border-radius:8px;font-size:11px;}
|
||
.mn-crisis strong{color:#ef4444;display:block;margin-bottom:4px;}
|
||
.mn-crisis div{color:var(--muted);line-height:1.6;}
|
||
|
||
/* ── Center stage ── */
|
||
.mn-center{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;background:var(--bg);}
|
||
.mn-center-hdr{display:flex;align-items:center;padding:6px 14px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;gap:8px;min-height:38px;}
|
||
.mn-center-title{font-size:12px;font-weight:700;color:var(--text);flex:1;}
|
||
.mn-center-body{flex:1;overflow-y:auto;padding:20px;}
|
||
.mn-center-body::-webkit-scrollbar{width:3px;}
|
||
.mn-center-body::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
||
|
||
/* ── Empty ── */
|
||
.mn-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--muted);gap:12px;text-align:center;padding:40px;}
|
||
.mn-empty .icon{font-size:52px;opacity:.3;}
|
||
.mn-empty p{font-size:12px;line-height:1.7;max-width:260px;}
|
||
|
||
/* ── Widget container ── */
|
||
.widget{max-width:600px;margin:0 auto;}
|
||
.widget h2{font-size:15px;font-weight:700;margin:0 0 4px;}
|
||
.widget .w-desc{font-size:12px;color:var(--muted);margin-bottom:18px;line-height:1.6;}
|
||
.widget .w-footer{margin-top:20px;display:flex;gap:8px;align-items:center;}
|
||
.w-btn{background:#5b21b6;border:none;border-radius:8px;padding:8px 18px;font-size:12px;font-weight:700;color:#fff;cursor:pointer;font-family:inherit;transition:.15s;}
|
||
.w-btn:hover{background:#6d28d9;}
|
||
.w-btn:disabled{opacity:.4;cursor:not-allowed;}
|
||
.w-btn.secondary{background:none;border:1.5px solid var(--line);color:var(--muted);}
|
||
.w-btn.secondary:hover{border-color:#8b5cf6;color:#8b5cf6;background:none;}
|
||
|
||
/* ── Questionnaire ── */
|
||
.q-item{margin-bottom:16px;padding:12px 14px;background:var(--panel);border:1px solid var(--line);border-radius:10px;}
|
||
.q-item .q-text{font-size:12px;font-weight:600;margin-bottom:10px;line-height:1.5;}
|
||
.q-opts{display:flex;gap:6px;flex-wrap:wrap;}
|
||
.q-opt{padding:5px 12px;border:1.5px solid var(--line);border-radius:20px;font-size:11px;cursor:pointer;background:none;color:var(--muted);font-family:inherit;transition:.15s;}
|
||
.q-opt:hover{border-color:#8b5cf6;color:#8b5cf6;}
|
||
.q-opt.selected{border-color:#7c3aed;background:#1e1b4b;color:#a78bfa;}
|
||
[data-theme="light"] .q-opt.selected{background:#ede9fe;color:#7c3aed;}
|
||
.q-progress{height:3px;background:var(--line);border-radius:2px;margin-bottom:18px;overflow:hidden;}
|
||
.q-progress-bar{height:100%;background:#7c3aed;border-radius:2px;transition:width .3s;}
|
||
|
||
/* ── Result card ── */
|
||
.result-card{padding:18px 20px;border-radius:12px;border:1px solid;margin-bottom:16px;text-align:center;}
|
||
.result-card.minimal{border-color:rgba(34,197,94,.3);background:rgba(34,197,94,.05);}
|
||
.result-card.mild{border-color:rgba(234,179,8,.3);background:rgba(234,179,8,.05);}
|
||
.result-card.moderate{border-color:rgba(249,115,22,.3);background:rgba(249,115,22,.05);}
|
||
.result-card.severe{border-color:rgba(239,68,68,.3);background:rgba(239,68,68,.05);}
|
||
.result-card .r-score{font-size:36px;font-weight:900;margin-bottom:6px;}
|
||
.result-card .r-label{font-size:14px;font-weight:700;margin-bottom:4px;}
|
||
.result-card .r-desc{font-size:11px;color:var(--muted);}
|
||
|
||
/* ── Result detail (subscores + item table) ── */
|
||
.q-subscores{display:flex;flex-direction:column;gap:10px;margin:14px 0;}
|
||
.q-sub{font-size:11px;}
|
||
.q-sub .q-sub-hdr{display:flex;justify-content:space-between;margin-bottom:4px;color:var(--text);}
|
||
.q-sub .q-sub-hdr span:last-child{color:var(--muted);}
|
||
.q-sub .q-sub-bar{height:6px;background:var(--line);border-radius:3px;overflow:hidden;}
|
||
.q-sub .q-sub-fill{height:100%;background:#7c3aed;border-radius:3px;transition:width .4s;}
|
||
.q-result-detail{margin:14px 0;font-size:11px;overflow-x:auto;}
|
||
.q-result-detail table{width:100%;border-collapse:collapse;}
|
||
.q-result-detail th,.q-result-detail td{border:1px solid var(--line);padding:5px 8px;text-align:left;vertical-align:top;}
|
||
.q-result-detail th{background:var(--panel);color:var(--muted);font-weight:700;font-size:10px;text-transform:uppercase;letter-spacing:.03em;}
|
||
.q-result-detail td.q-num{color:var(--muted);width:26px;text-align:center;white-space:nowrap;}
|
||
.q-result-detail td.q-sc{text-align:center;width:46px;white-space:nowrap;color:#a78bfa;font-weight:700;}
|
||
.q-disclaimer{font-size:10px;color:var(--muted);line-height:1.6;margin-top:12px;padding:10px 12px;border:1px dashed var(--line);border-radius:7px;}
|
||
.q-disclaimer strong{display:block;color:var(--text);margin-bottom:3px;font-size:10px;}
|
||
.q-refs{font-size:10px;color:var(--muted);margin-top:6px;font-style:italic;opacity:.85;}
|
||
.crisis-banner{background:rgba(239,68,68,.1);border:2px solid rgba(239,68,68,.5);border-radius:12px;padding:16px;margin-bottom:16px;}
|
||
.crisis-banner strong{display:block;color:#f87171;font-size:14px;margin-bottom:8px;}
|
||
.crisis-banner p{font-size:12px;color:var(--text);line-height:1.6;margin-bottom:8px;}
|
||
.crisis-banner .crisis-lines{display:flex;flex-direction:column;gap:4px;font-size:13px;font-weight:700;color:var(--text);}
|
||
|
||
/* ── Breathing widget ── */
|
||
.breath-container{display:flex;flex-direction:column;align-items:center;gap:24px;padding:20px 0;}
|
||
.breath-circle-wrap{position:relative;width:200px;height:200px;}
|
||
.breath-circle{width:200px;height:200px;border-radius:50%;background:radial-gradient(circle at 40% 40%,#6d28d9,#1e1b4b);display:flex;align-items:center;justify-content:center;transition:transform 4s ease-in-out,opacity .5s;box-shadow:0 0 40px rgba(109,40,217,.3);}
|
||
.breath-circle.expand{transform:scale(1.35);}
|
||
.breath-circle.contract{transform:scale(1);}
|
||
.breath-phase{font-size:15px;font-weight:700;color:#e9d5ff;text-align:center;line-height:1.4;}
|
||
.breath-count{font-size:28px;font-weight:900;color:#fff;display:block;}
|
||
.breath-type-sel{display:flex;gap:6px;flex-wrap:wrap;justify-content:center;}
|
||
.breath-type-btn{padding:5px 14px;border:1.5px solid var(--line);border-radius:20px;font-size:11px;cursor:pointer;background:none;color:var(--muted);font-family:inherit;transition:.15s;}
|
||
.breath-type-btn.active{border-color:#7c3aed;background:#1e1b4b;color:#a78bfa;}
|
||
[data-theme="light"] .breath-type-btn.active{background:#ede9fe;color:#7c3aed;}
|
||
.breath-cycles{font-size:12px;color:var(--muted);}
|
||
|
||
/* ── CBT Thought Record ── */
|
||
.cbt-field{margin-bottom:14px;}
|
||
.cbt-field label{display:block;font-size:11px;font-weight:700;color:var(--muted);margin-bottom:5px;text-transform:uppercase;letter-spacing:.04em;}
|
||
.cbt-field textarea,.cbt-field input{width:100%;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:8px 10px;font-size:12px;color:var(--text);font-family:inherit;resize:vertical;min-height:60px;box-sizing:border-box;outline:none;transition:.15s;}
|
||
.cbt-field textarea:focus,.cbt-field input:focus{border-color:#8b5cf6;}
|
||
.cbt-field input{min-height:auto;height:34px;resize:none;}
|
||
.cbt-emotions{display:flex;gap:6px;flex-wrap:wrap;margin-top:4px;}
|
||
.cbt-emo{padding:4px 11px;border:1.5px solid var(--line);border-radius:16px;font-size:11px;cursor:pointer;background:none;color:var(--muted);font-family:inherit;transition:.12s;}
|
||
.cbt-emo:hover{border-color:#8b5cf6;color:#8b5cf6;}
|
||
.cbt-emo.sel{border-color:#7c3aed;background:#1e1b4b;color:#a78bfa;}
|
||
[data-theme="light"] .cbt-emo.sel{background:#ede9fe;color:#7c3aed;}
|
||
.cbt-intensity{display:flex;align-items:center;gap:10px;margin-top:8px;}
|
||
.cbt-intensity input[type=range]{flex:1;accent-color:#7c3aed;}
|
||
.cbt-intensity span{font-size:11px;color:var(--muted);min-width:24px;text-align:right;}
|
||
|
||
/* ── Emotion check-in ── */
|
||
.emo-wheel{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:16px;}
|
||
.emo-card{padding:10px 6px;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;text-align:center;transition:.15s;background:none;}
|
||
.emo-card:hover{border-color:#8b5cf6;background:rgba(139,92,246,.05);}
|
||
.emo-card.sel{border-color:#7c3aed;background:#1e1b4b;}
|
||
[data-theme="light"] .emo-card.sel{background:#ede9fe;}
|
||
.emo-card .emo-icon{font-size:24px;}
|
||
.emo-card .emo-name{font-size:10px;color:var(--muted);margin-top:4px;display:block;}
|
||
.emo-card.sel .emo-name{color:#a78bfa;}
|
||
[data-theme="light"] .emo-card.sel .emo-name{color:#7c3aed;}
|
||
.emo-intensity{margin-bottom:14px;}
|
||
.emo-intensity label{font-size:11px;font-weight:700;color:var(--muted);display:block;margin-bottom:6px;}
|
||
.emo-note textarea{width:100%;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:8px 10px;font-size:12px;color:var(--text);font-family:inherit;resize:vertical;min-height:60px;box-sizing:border-box;outline:none;transition:.15s;}
|
||
.emo-note textarea:focus{border-color:#8b5cf6;}
|
||
|
||
/* ── Resize handle ── */
|
||
.mn-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;}
|
||
.mn-resize:hover,.mn-resize.dragging{background:#8b5cf6;}
|
||
|
||
/* ── Mobile ── */
|
||
@media(max-width:1024px){
|
||
.mn-hdr{flex-wrap:wrap;padding:5px 8px;gap:5px;}
|
||
.mn-hdr h1{font-size:12px;}
|
||
.mode-toggle{order:10;flex:0 0 100%;}
|
||
.mode-tab{flex:1;font-size:10px;padding:4px 0;}
|
||
.mn-hdr-sp{display:none;}
|
||
/* hide desktop resize */
|
||
.mn-resize{display:none;}
|
||
/* sidebar & chat become fixed overlays */
|
||
.mn-sb{
|
||
position:fixed;top:0;left:0;bottom:52px;z-index:500;
|
||
width:80vw;max-width:300px;
|
||
transform:translateX(-100%);transition:transform .25s ease;
|
||
border-right:1px solid var(--line);
|
||
box-shadow:4px 0 24px rgba(0,0,0,.4);
|
||
}
|
||
.mn-sb.mob-open{transform:translateX(0);}
|
||
.mn-chat{
|
||
position:fixed;top:0;right:0;bottom:52px;z-index:500;
|
||
width:92vw;max-width:380px;
|
||
transform:translateX(100%);transition:transform .25s ease;
|
||
border-left:1px solid var(--line);
|
||
box-shadow:-4px 0 24px rgba(0,0,0,.4);
|
||
}
|
||
.mn-chat.mob-open{transform:translateX(0);}
|
||
/* overlay backdrop */
|
||
#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;}
|
||
/* center takes full width */
|
||
.mn-center{width:100%;}
|
||
/* bottom tab bar */
|
||
#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;
|
||
}
|
||
/* add bottom padding so content isn't hidden behind tab bar */
|
||
.mn-center-body{padding-bottom:64px;}
|
||
/* font-size ≥16px to prevent iOS auto-zoom */
|
||
.mn-textarea,input,textarea{font-size:16px !important;}
|
||
}
|
||
@media(min-width:1025px){
|
||
#mob-tabbar{display:none !important;}
|
||
#mob-backdrop{display:none !important;}
|
||
}
|
||
|
||
/* ── Chat ── */
|
||
.mn-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;}
|
||
.mn-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex-shrink:0;gap:6px;}
|
||
.mn-chat-label{font-size:10px;font-weight:700;color:#a78bfa;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
|
||
.mn-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;}
|
||
.mn-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;}
|
||
.mn-chat-clear:hover{color:#ef4444;}
|
||
.mn-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
|
||
.mn-msgs::-webkit-scrollbar{width:3px;}
|
||
.mn-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
||
.mn-msg{display:flex;flex-direction:column;}
|
||
.mn-msg.user{align-items:flex-end;}
|
||
.mn-msg.assistant{align-items:flex-start;}
|
||
.mn-msg.system{align-items:center;}
|
||
.mn-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:93%;word-break:break-word;}
|
||
.mn-msg.user .mn-bubble{background:#1e1b4b;color:#e9d5ff;border-radius:10px 10px 3px 10px;}
|
||
.mn-msg.assistant .mn-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
|
||
.mn-msg.system .mn-bubble{background:transparent;color:var(--muted);font-size:11px;}
|
||
.mn-bubble p{margin:3px 0;}
|
||
.mn-bubble h3{font-size:13px;margin:6px 0 3px;}
|
||
.mn-bubble ul,.mn-bubble ol{padding-left:16px;margin:3px 0;}
|
||
.mn-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
|
||
.mn-bubble strong{font-weight:700;}
|
||
.mn-bubble em{font-style:italic;}
|
||
.mn-bubble table{border-collapse:collapse;font-size:11px;margin:6px 0;width:100%;}
|
||
.mn-bubble th,.mn-bubble td{border:1px solid var(--line);padding:3px 7px;text-align:left;}
|
||
.mn-bubble th{background:var(--panel);font-weight:700;}
|
||
.mn-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;}
|
||
.mn-thinking.exp{display:block;-webkit-line-clamp:unset;max-height:200px;overflow-y:auto;}
|
||
.mn-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
|
||
.mn-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:mnDot 1.2s ease-in-out infinite;}
|
||
.mn-dot:nth-child(2){animation-delay:.2s;}.mn-dot:nth-child(3){animation-delay:.4s;}
|
||
@keyframes mnDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
|
||
.mn-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
|
||
.mn-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;}
|
||
.mn-textarea:focus{border-color:#8b5cf6;}
|
||
.mn-send{background:#5b21b6;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;}
|
||
.mn-send:hover:not(:disabled){background:#6d28d9;}
|
||
.mn-send:disabled{opacity:.5;cursor:not-allowed;}
|
||
</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="mn-hdr">
|
||
<a href="/" class="hdr-btn">← 홈</a>
|
||
<h1>🧠 마음 상담실</h1>
|
||
<div class="mode-toggle">
|
||
<button class="mode-tab active" id="tab-counselor" onclick="setMode('counselor')">💚 상담사</button>
|
||
<button class="mode-tab" id="tab-psychiatrist" onclick="setMode('psychiatrist')">🧠 정신과</button>
|
||
<button class="mode-tab" id="tab-both" onclick="setMode('both')">✨ 통합</button>
|
||
</div>
|
||
<div class="mn-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-tools" onclick="mobToggle('tools')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||
<span>도구</span>
|
||
</button>
|
||
<button class="mob-tab" id="mobtab-home" onclick="mobToggle('home')" style="color:var(--brand,#8b5cf6);border-top:2px solid var(--brand,#8b5cf6)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M3 12L12 3l9 9"/><path d="M9 21V12h6v9"/></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>
|
||
|
||
<div class="mn-layout">
|
||
|
||
<!-- ── Sidebar ── -->
|
||
<aside class="mn-sb">
|
||
<div class="mn-sb-sec">심리 도구</div>
|
||
<div class="mn-tools">
|
||
<button class="mn-tool" id="tool-emotion" onclick="showTool('emotion')">
|
||
<span>😊</span>
|
||
<span class="t-lbl">감정 체크인<span class="t-sub">오늘 나의 감정은?</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-phq9" onclick="showTool('phq9')">
|
||
<span>📋</span>
|
||
<span class="t-lbl">PHQ-9<span class="t-sub">우울증 선별 검사</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-gad7" onclick="showTool('gad7')">
|
||
<span>📋</span>
|
||
<span class="t-lbl">GAD-7<span class="t-sub">범불안장애 검사</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-pss10" onclick="showTool('pss10')">
|
||
<span>📋</span>
|
||
<span class="t-lbl">PSS-10<span class="t-sub">지각된 스트레스</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-isi" onclick="showTool('isi')">
|
||
<span>📋</span>
|
||
<span class="t-lbl">ISI<span class="t-sub">불면증 심각도</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-who5" onclick="showTool('who5')">
|
||
<span>📋</span>
|
||
<span class="t-lbl">WHO-5<span class="t-sub">웰빙 지수</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-rosenberg" onclick="showTool('rosenberg')">
|
||
<span>📋</span>
|
||
<span class="t-lbl">Rosenberg<span class="t-sub">자존감 척도</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-audit" onclick="showTool('audit')">
|
||
<span>📋</span>
|
||
<span class="t-lbl">AUDIT<span class="t-sub">알코올 선별</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-pcl5" onclick="showTool('pcl5')">
|
||
<span>📋</span>
|
||
<span class="t-lbl">PCL-5<span class="t-sub">PTSD(외상후스트레스) 선별</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-mdq" onclick="showTool('mdq')">
|
||
<span>📋</span>
|
||
<span class="t-lbl">MDQ<span class="t-sub">양극성장애 선별</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-ocir" onclick="showTool('ocir')">
|
||
<span>📋</span>
|
||
<span class="t-lbl">OCI-R<span class="t-sub">강박장애 선별</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-asrs" onclick="showTool('asrs')">
|
||
<span>📋</span>
|
||
<span class="t-lbl">ASRS-6<span class="t-sub">성인 ADHD 선별</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-spin" onclick="showTool('spin')">
|
||
<span>📋</span>
|
||
<span class="t-lbl">SPIN<span class="t-sub">사회불안 척도</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-cbt" onclick="showTool('cbt')">
|
||
<span>✍️</span>
|
||
<span class="t-lbl">생각 기록지<span class="t-sub">CBT 인지 재구성</span></span>
|
||
</button>
|
||
<button class="mn-tool" id="tool-breath" onclick="showTool('breath')">
|
||
<span>🌬️</span>
|
||
<span class="t-lbl">호흡 명상<span class="t-sub">마음 안정 훈련</span></span>
|
||
</button>
|
||
</div>
|
||
<div class="mn-divider"></div>
|
||
<div class="mn-crisis">
|
||
<strong>🆘 위기상담 24시간</strong>
|
||
<div>
|
||
자살예방 <strong>1393</strong><br>
|
||
정신건강 <strong>1577-0199</strong><br>
|
||
생명의전화 <strong>1588-9191</strong>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- ── Center ── -->
|
||
<section class="mn-center">
|
||
<div class="mn-center-hdr">
|
||
<span class="mn-center-title" id="center-title">심리 도구를 선택하세요</span>
|
||
</div>
|
||
<div class="mn-center-body" id="center-body">
|
||
<div class="mn-empty" id="center-empty">
|
||
<div class="icon">🌱</div>
|
||
<p>왼쪽에서 심리 도구를 선택하면<br>여기에 인터랙티브 검사·훈련이 나타납니다.<br><br>검사 결과는 자동으로 AI에게 전달되어<br>맞춤 해석과 조언을 받을 수 있습니다.</p>
|
||
</div>
|
||
<div id="center-widget" style="display:none;"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Resize ── -->
|
||
<div class="mn-resize" id="resize-handle"></div>
|
||
|
||
<!-- ── Chat ── -->
|
||
<div class="mn-chat" id="chat-panel">
|
||
<div class="mn-chat-hdr">
|
||
<span class="mn-chat-label" id="chat-label">💚 상담사</span>
|
||
<select class="mn-model-sel" id="model-sel" onchange="saveModel()">
|
||
<option value="">기본 모델</option>
|
||
</select>
|
||
<button class="mn-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
|
||
</div>
|
||
<div class="mn-msgs" id="chat-msgs"></div>
|
||
<div class="mn-input-bar">
|
||
<textarea class="mn-textarea" id="chat-input" rows="1" placeholder="마음 속 이야기를 편하게 나눠보세요..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
|
||
<button class="app-mic-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력">🎤</button>
|
||
<button class="mn-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;}
|
||
}
|
||
|
||
// ── Mobile overlay ────────────────────────────────────────────────────────────
|
||
function mobCloseAll(){
|
||
document.querySelector('.mn-sb').classList.remove('mob-open');
|
||
document.getElementById('chat-panel').classList.remove('mob-open');
|
||
document.getElementById('mob-backdrop').classList.remove('show');
|
||
['tools','chat'].forEach(t=>document.getElementById('mobtab-'+t)?.classList.remove('active'));
|
||
document.getElementById('mobtab-home').style.cssText='color:var(--brand,#8b5cf6);border-top:2px solid var(--brand,#8b5cf6)';
|
||
}
|
||
function mobToggle(which){
|
||
const sb=document.querySelector('.mn-sb');
|
||
const cp=document.getElementById('chat-panel');
|
||
const bd=document.getElementById('mob-backdrop');
|
||
if(which==='home'){mobCloseAll();return;}
|
||
const isSbOpen=sb.classList.contains('mob-open');
|
||
const isCpOpen=cp.classList.contains('mob-open');
|
||
mobCloseAll();
|
||
if(which==='tools'&&!isSbOpen){sb.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-tools').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
|
||
if(which==='chat'&&!isCpOpen){cp.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-chat').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
|
||
}
|
||
|
||
// ── 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{}}
|
||
|
||
// ── Resize ────────────────────────────────────────────────────────────────────
|
||
(function(){
|
||
const CHAT_W='mn_chat_width';
|
||
const handle=document.getElementById('resize-handle');
|
||
const panel=document.getElementById('chat-panel');
|
||
let dragging=false,startX=0,startW=0;
|
||
const saved=parseInt(localStorage.getItem(CHAT_W)||'340',10);
|
||
panel.style.width=saved+'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{};});
|
||
})();
|
||
|
||
// ── Mode ──────────────────────────────────────────────────────────────────────
|
||
const MODE_KEY='mn_mode';
|
||
let currentMode=localStorage.getItem(MODE_KEY)||'counselor';
|
||
let skillContextCounselor='';
|
||
let skillContextPsychiatrist='';
|
||
|
||
const modeConfig={
|
||
counselor:{label:'💚 상담사',color:'#22c55e',hint:'마음 속 이야기를 편하게 나눠보세요...'},
|
||
psychiatrist:{label:'🧠 정신과',color:'#8b5cf6',hint:'증상이나 정신건강에 대해 질문하세요...'},
|
||
both:{label:'✨ 통합 상담',color:'#a78bfa',hint:'상담사와 정신과의사 관점으로 도움드립니다...'},
|
||
};
|
||
|
||
function setMode(mode){
|
||
currentMode=mode;
|
||
try{localStorage.setItem(MODE_KEY,mode);}catch{}
|
||
['counselor','psychiatrist','both'].forEach(m=>{
|
||
document.getElementById('tab-'+m).classList.toggle('active',m===mode);
|
||
});
|
||
const cfg=modeConfig[mode];
|
||
document.getElementById('chat-label').textContent=cfg.label;
|
||
document.getElementById('chat-input').placeholder=cfg.hint;
|
||
}
|
||
|
||
function getSkillContext(){
|
||
if(currentMode==='counselor')return skillContextCounselor;
|
||
if(currentMode==='psychiatrist')return skillContextPsychiatrist;
|
||
const parts=[];
|
||
if(skillContextCounselor)parts.push('=== 심리상담사 가이드라인 ===\n'+skillContextCounselor);
|
||
if(skillContextPsychiatrist)parts.push('=== 정신과전문의 가이드라인 ===\n'+skillContextPsychiatrist);
|
||
return parts.join('\n\n');
|
||
}
|
||
|
||
const SKILL_CONTENT_COUNSELOR="---\nname: Counselor\ndescription: 심리치료 접근법·상담 기법·사례 개념화를 제공하는 심리상담 전문가 어시스턴트. 치료적 동맹과 내담자 중심 접근을 강조.\nemoji: \"💚\"\nversion: 1.0.0\ntriggers: [상담, 심리, 우울, 불안, 스트레스, 트라우마, 감정, 치료, 내담자, 심리치료, 관계, 가족, 부부, 대인, 자존감, 번아웃, 외로움, 슬픔, 분노, 심리상담]\n---\n\n당신은 심리상담 전문가 어시스턴트입니다. 치료적 동맹을 바탕으로 내담자 중심 접근을 제공하며, 다양한 심리치료 접근법과 근거기반 실천을 활용해 응답하세요.\n\n---\n\n### 핵심 원칙\n\n1. **치료적 동맹 우선**: 어떤 기법보다 내담자와의 관계가 치료의 핵심입니다. 공감적 이해와 무조건적 존중을 기반으로 하세요.\n2. **비판단적 태도**: 내담자의 감정, 생각, 행동을 판단하거나 평가하지 마세요. 수용과 이해가 선행되어야 합니다.\n3. **진단보다 이해**: 공식 진단은 대면 전문의의 영역입니다. \"~라고 이해할 수 있어요\", \"~와 관련된 패턴일 수 있어요\" 형식으로 표현하세요.\n4. **근거기반 실천**: 심리치료 기법 안내 시 PCSP 사례 DB, PubMed 문헌, 임상 지침을 활용하세요.\n5. **다양한 접근법 존중**: 어떤 치료 접근법도 우위가 없습니다. 내담자의 상황과 선호에 맞는 접근법을 제시하세요.\n6. **전문가 연결**: 위기 상황이나 전문적 판단이 필요한 경우 반드시 대면 전문가 상담을 권유하세요.\n\n---\n\n### 치료 접근법별 안내\n\n| 접근법 | 한국어 | 핵심 개념 | 주요 적용 |\n|---|---|---|---|\n| CBT | 인지행동치료 | 인지왜곡 식별·도전, 행동실험 | 불안, 우울, 강박, 공포 |\n| Psychodynamic | 정신역동치료 | 무의식 갈등, 전이, 저항 | 인간관계 문제, 성격패턴 |\n| DBT | 변증법적행동치료 | 수용·변화 균형, 기술 훈련 | 경계선인격, 감정조절 |\n| ACT | 수용전념치료 | 심리적 유연성, 가치행동 | 불안, 만성통증, 우울 |\n| Schema Therapy | 도식치료 | 부적응 도식·모드 식별 | 성격패턴, 만성우울 |\n| EFT | 정서초점치료 | 애착·정서 재구성 | 부부문제, 애착상처 |\n| AEDP | 가속적경험적역동치료 | 정서 처리·변환, 치유적 관계 | 외상, 정서회피 |\n| Exposure Therapy | 노출치료 | 체계적 둔감화, 반응방지 | 공포, PTSD, 강박 |\n| Narrative | 내러티브치료 | 외재화, 독특한 결과 | 정체성, 가족이야기 |\n| Mindfulness | 마음챙김치료 | 현재순간 주의, 비판단적 관찰 | 스트레스, 재발방지 |\n| Hypnosis | 최면치료 | 트랜스 상태, 제안 | 불안, 통증, 습관 |\n| Humanistic | 인간중심치료 | 무조건적 긍정적 관심, 공감 | 자아실현, 관계문제 |\n\n---\n\n### 사용 가능한 도구\n\n| 도구 | 활용 상황 |\n|---|---|\n| `mcp__psychotherapy-cases-sqlite__query` | PCSP 심리치료 임상 사례 조회. 치료 접근법별 사례, 특정 장애 치료 사례 검색 |\n| `mcp__psychiatry-cases-sqlite__query` | DSM-5 임상 증례 조회. 공식 진단 기준 확인 시 참조 |\n| `pubmed_search` | 심리치료 효과 연구, RCT 결과, 치료 지침 검색 |\n| `web_search` | 국내 상담 자원, 치료 프로그램, 자조 단체 검색 |\n\n### PCSP 사례 DB 쿼리 예시\n\n```\n-- CBT 불안 사례연구 조회\nSELECT title_en, title_ko, authors, year, abstract_en\nFROM cases WHERE category_en='CBT' AND article_type='case_study'\nAND keywords LIKE '%anxiety%'\n\n-- 최근 정신역동 치료 사례\nSELECT title_en, title_ko, year FROM cases\nWHERE category_en='Psychodynamic' AND year >= 2020\n\n-- 특정 치료법 사례 검색\nSELECT title_en, title_ko FROM cases\nWHERE keywords LIKE '%schema%' OR keywords LIKE '%schema therapy%'\n```\n\n### 우선 참조 사이트\n\n`web_search` 호출 시 아래 **site: 연산자**를 query에 포함해 신뢰할 수 있는 출처에서 우선 검색하세요.\n\n| 주제 | site: 연산자 | 설명 |\n|---|---|---|\n| 한국상담학회 | `site:kscp.or.kr` | 한국상담학회 자격·윤리강령 |\n| 한국심리학회 | `site:koreanpsychology.or.kr` | 심리학 연구·자격 정보 |\n| 정신건강 정보 | `site:mentalhealth.go.kr` | 국가정신건강정보포털 |\n| NIMH 연구 | `site:nimh.nih.gov` | 미국 국립정신건강연구소 |\n| APA 치료 지침 | `site:apa.org` | 미국심리학회 실천 지침 |\n| PCSP 사례 | `site:pcsp.nationalregister.org` | 심리치료 사례연구 저널 |\n\n---\n\n### 사례 개념화 방법론\n\n사례 개념화(case conceptualization) 요청 시 다음 프레임워크를 활용하세요:\n\n1. **내담자 프로파일** — 주호소 문제, 인구통계, 발달 맥락\n2. **문제 분석** — 유발 요인(trigger), 악화·완화 요인, 유지 요인\n3. **치료 가설** — 인지·정서·행동·관계 수준에서의 이해\n4. **치료 계획** — 접근법 선택 근거, 목표 설정, 세션 구조\n5. **진행 모니터링** — 결과 측정, 조정 포인트, 예후\n\n**접근법별 개념화 포커스:**\n- CBT: 인지오류·행동회피 패턴 → 인지재구성·행동실험\n- 정신역동: 무의식 갈등·전이 패턴 → 통찰·감정 처리\n- 도식: 부적응 도식·모드 → 제한적 재양육\n- ACT: 심리적 유연성·가치 → 수용·가치행동\n\n---\n\n### ⚠️ 위기 상황 프로토콜\n\n다음 징후가 감지되면 **즉시** 아래 순서로 대응하세요:\n\n**징후**: 자해·자살 언급, 극도의 절망감, \"더 이상 살고 싶지 않다\", 작별 인사 뉘앙스, 구체적 계획 언급\n\n**대응 순서**:\n1. 판단 없이 현재 감정을 공감하며 반영 (\"지금 정말 많이 힘드시군요\")\n2. 직접적으로 안전 여부 확인 (\"지금 스스로를 해치고 싶은 생각이 드세요?\")\n3. 즉각 위기 자원 안내:\n - **자살예방상담전화 1393** (24시간, 무료)\n - **정신건강위기상담전화 1577-0199** (24시간)\n - **생명의전화 1588-9191**\n - **청소년**: 청소년전화 1388\n4. 혼자 두지 않도록 주변 지지체계(가족·친구) 연결 권유\n5. 응급 상황 시 119 또는 응급실 안내\n\n---\n\n### 대화 톤 가이드\n\n- **공감 표현**: \"그 감정은 충분히 이해돼요\", \"많이 힘드셨겠어요\"\n- **치료적 투명성**: 개입의 이유와 과정을 설명 (\"지금 인지를 살펴보는 이유는~\")\n- **협동적 태도**: \"함께 찾아보아요\", \"어떤 방식이 편하세요?\"\n- **정상화**: \"그런 감정은 누구나 가질 수 있어요\", \"반응은 자연스러운 것이에요\"\n- **문화 민감성**: 내담자의 문화적 배경, 가치체계, 가족관계를 존중\n- **페이스 조절**: 한 번에 너무 많은 정보를 주지 않기, 내담자가 감당할 수 있는 속도 유지\n\n이 서비스는 정보 제공 및 정서적 지지 목적이며, 전문적 심리평가·진단·치료는 자격을 갖춘 임상심리사나 심리상담사에게 받으세요.";
|
||
const SKILL_CONTENT_PSYCHIATRIST="---\nname: Psychiatrist\ndescription: 정서적 지지·정신건강 정보·위기 대응·증례 분석을 제공하는 정신과 전문의 어시스턴트. 따뜻함과 임상적 정확성을 균형 있게 유지.\nemoji: \"🧠\"\nversion: 1.0.0\ntriggers: [정신, 우울증, 불안장애, 정신과, 약물, 증상, 진단, 조증, 공황, 조현병, 치매, ADHD, 수면장애, 자해, 자살, 정신건강, 환청, 망상, 기분, 인지]\n---\n\n당신은 정신과 전문의 어시스턴트입니다. 공감적 경청과 임상적 정확성을 균형 있게 유지하며, 아래 원칙과 도구를 활용해 응답하세요.\n\n---\n\n### 핵심 원칙\n\n1. **감정 우선**: 정보 제공 전에 상대방의 감정을 먼저 반영하고 인정하세요.\n2. **판단 금지**: 어떤 감정이나 생각도 비판하거나 경시하지 마세요.\n3. **진단 자제**: 공식 진단은 대면 전문의만 가능합니다. \"~처럼 보일 수 있어요\", \"~와 관련된 증상일 수 있어요\" 형식으로 표현하세요.\n4. **근거 기반**: 임상 정보 제공 시 DSM-5/ICD-11 기준 또는 PubMed 문헌을 활용하세요.\n5. **전문가 연결**: 필요 시 반드시 대면 전문의 상담을 권유하세요.\n\n---\n\n### 사용 가능한 도구\n\n| 도구 | 활용 상황 |\n|---|---|\n| `pubmed_search` | 치료법 근거, 약물 효과, 최신 정신의학 연구 조회 |\n| `web_search` | 진료 지침, 약물 정보, 국내 정신건강 자원 검색 |\n| `memory_write` | 대화 중 파악한 주요 맥락·증상·경과 저장 (장기 지속 상담 시) |\n\n### 우선 참조 사이트\n\n`web_search` 호출 시 아래 **site: 연산자를 query에 포함**해 신뢰할 수 있는 출처에서 우선 검색하세요.\n\n| 주제 | site: 연산자 | 설명 |\n|---|---|---|\n| 국내 정신건강 정보 | `site:mentalhealth.go.kr` | 국가정신건강정보포털 (복지부 운영) |\n| 국내 진료 지침 | `site:knpa.or.kr` | 대한신경정신의학회 가이드라인 |\n| 보건복지부 정책 | `site:mohw.go.kr` | 정신건강 정책·통계·지원사업 |\n| WHO 국제 기준 | `site:who.int` | 국제 정신건강 지침·ICD-11 |\n| 미국 NIMH 연구 | `site:nimh.nih.gov` | 미국 국립정신건강연구소 |\n| 약물 정보 | `site:health.kr` | 약학정보원 의약품 정보 |\n\n예시: SSRI 부작용 최신 연구 → `pubmed_search(\"SSRI side effects 2024\")` 또는 `web_search(\"site:nimh.nih.gov SSRI antidepressant\")`\n\n---\n\n### 증례 분석 방법론\n\n증례(case) 분석 요청 시 다음 순서로 접근하세요:\n\n1. **주소(Chief Complaint)** — 핵심 호소 증상 1~2가지 파악\n2. **현병력(HPI)** — 발생 시기·경과·악화/완화 요인\n3. **과거력·가족력** — 정신건강 이력, 복용 약물, 가족 내 정신질환\n4. **감별진단(DDx)** — DSM-5/ICD-11 기준 가능한 진단 3개 이내 나열\n5. **치료 접근** — 1차 치료(심리치료·약물), 추가 검사 필요 여부\n6. **예후 및 모니터링** — 주의 신호(red flags), 추적 관찰 포인트\n\n---\n\n### ⚠️ 위기 상황 프로토콜\n\n다음 징후가 감지되면 **즉시** 아래 순서로 대응하세요:\n\n**징후**: 자해·자살 언급, 극도의 절망감, \"더 이상 살고 싶지 않다\", 작별 인사 뉘앙스\n\n**대응 순서**:\n1. 판단 없이 현재 감정을 공감하며 반영\n2. 직접적으로 안전 여부 확인 (\"지금 스스로를 해치고 싶은 생각이 드세요?\")\n3. 즉각 위기 자원 안내:\n - **자살예방상담전화 1393** (24시간, 무료)\n - **정신건강위기상담전화 1577-0199** (24시간)\n - **생명의전화 1588-9191**\n - **청소년**: 청소년전화 1388\n4. 혼자 두지 않도록 주변 지지체계(가족·친구) 연결 권유\n5. 응급 상황 시 119 또는 응급실 안내\n\n---\n\n### 대화 톤 가이드\n\n- **공감 표현**: \"많이 힘드셨겠어요\", \"그 감정은 충분히 이해돼요\"\n- **임상 설명**: 전문 용어 사용 시 쉬운 말로 병기 (\"우울삽화(depressive episode), 즉 기분이 지속적으로 가라앉고 의욕이 없는 상태\")\n- **질문**: 닫힌 질문보다 열린 질문 선호 (\"어떤 부분이 가장 힘드세요?\")\n- **페이스**: 상대가 말하는 속도에 맞추고, 과도한 정보를 한 번에 쏟아내지 않기\n\n이 서비스는 정보 제공 및 정서적 지지 목적이며, 약물 처방·공식 진단·응급 처치는 반드시 자격을 갖춘 전문의에게 받으세요.\n";
|
||
async function loadSkills(){
|
||
skillContextCounselor=SKILL_CONTENT_COUNSELOR;
|
||
skillContextPsychiatrist=SKILL_CONTENT_PSYCHIATRIST;
|
||
}
|
||
|
||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||
const MODEL_KEY='mn_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{}}
|
||
|
||
// ── Session ───────────────────────────────────────────────────────────────────
|
||
const APP_KEY='mn';
|
||
let _sessionId=null;
|
||
function getSessionId(){return _sessionId||'mn_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='mn_'+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{}
|
||
}
|
||
|
||
|
||
|
||
// ── Tools: show / hide ────────────────────────────────────────────────────────
|
||
let activeTool=null;
|
||
let breathTimer=null;
|
||
|
||
const toolTitles={
|
||
emotion:'😊 감정 체크인',
|
||
phq9:'📋 PHQ-9 우울증 선별 검사',
|
||
gad7:'📋 GAD-7 범불안장애 검사',
|
||
pss10:'📋 PSS-10 지각된 스트레스 척도',
|
||
isi:'📋 ISI 불면증 심각도 척도',
|
||
who5:'📋 WHO-5 웰빙 지수',
|
||
rosenberg:'📋 Rosenberg 자존감 척도',
|
||
audit:'📋 AUDIT 알코올 사용장애 선별검사',
|
||
pcl5:'📋 PCL-5 PTSD(외상후스트레스장애) 선별 검사',
|
||
mdq:'📋 MDQ 기분장애 선별 질문지',
|
||
ocir:'📋 OCI-R 강박장애 선별 검사',
|
||
asrs:'📋 ASRS-6 성인 ADHD 자가보고 척도',
|
||
spin:'📋 SPIN 사회공포증 척도',
|
||
cbt:'✍️ 생각 기록지 (CBT)',
|
||
breath:'🌬️ 호흡 명상',
|
||
};
|
||
|
||
function showTool(name){
|
||
// on mobile: close sidebar overlay after selecting a tool
|
||
if(window.innerWidth<=1024) mobCloseAll();
|
||
// stop breath timer if switching
|
||
if(breathTimer&&name!=='breath'){clearInterval(breathTimer);breathTimer=null;}
|
||
activeTool=name;
|
||
// sidebar highlight
|
||
document.querySelectorAll('.mn-tool').forEach(b=>b.classList.remove('active'));
|
||
const btn=document.getElementById('tool-'+name);if(btn)btn.classList.add('active');
|
||
// center title
|
||
document.getElementById('center-title').textContent=toolTitles[name]||name;
|
||
// hide empty, show widget
|
||
document.getElementById('center-empty').style.display='none';
|
||
const widget=document.getElementById('center-widget');
|
||
widget.style.display='';
|
||
// render
|
||
const renders={emotion:renderEmotion,phq9:()=>renderQuestionnaire('phq9'),gad7:()=>renderQuestionnaire('gad7'),pss10:()=>renderQuestionnaire('pss10'),isi:()=>renderQuestionnaire('isi'),who5:()=>renderQuestionnaire('who5'),rosenberg:()=>renderQuestionnaire('rosenberg'),audit:()=>renderQuestionnaire('audit'),pcl5:()=>renderQuestionnaire('pcl5'),mdq:()=>renderQuestionnaire('mdq'),ocir:()=>renderQuestionnaire('ocir'),asrs:()=>renderQuestionnaire('asrs'),spin:()=>renderQuestionnaire('spin'),cbt:renderCBT,breath:renderBreath};
|
||
(renders[name]||function(){widget.innerHTML='<div class="widget"><p>준비 중입니다.</p></div>';})();
|
||
}
|
||
|
||
// ── Emotion check-in ──────────────────────────────────────────────────────────
|
||
const EMOTIONS=[
|
||
{icon:'😊',name:'기쁨'},{icon:'😌',name:'평온'},{icon:'🥰',name:'사랑'},
|
||
{icon:'😄',name:'신남'},{icon:'😢',name:'슬픔'},{icon:'😔',name:'우울'},
|
||
{icon:'😰',name:'불안'},{icon:'😠',name:'분노'},{icon:'😤',name:'짜증'},
|
||
{icon:'😨',name:'두려움'},{icon:'😳',name:'당황'},{icon:'😶',name:'무감각'},
|
||
];
|
||
let selectedEmos=[];
|
||
|
||
function renderEmotion(){
|
||
const w=document.getElementById('center-widget');
|
||
selectedEmos=[];
|
||
w.innerHTML=`
|
||
<div class="widget">
|
||
<h2>오늘 나의 감정은?</h2>
|
||
<p class="w-desc">지금 이 순간 느끼는 감정을 모두 선택해주세요. 복수 선택 가능합니다.</p>
|
||
<div class="emo-wheel">${EMOTIONS.map((e,i)=>`<button class="emo-card" id="emo-${i}" onclick="toggleEmo(${i},'${e.name}')"><div class="emo-icon">${e.icon}</div><span class="emo-name">${e.name}</span></button>`).join('')}</div>
|
||
<div class="emo-intensity">
|
||
<label>감정 강도: <span id="intensity-val">5</span>/10</label>
|
||
<input type="range" min="1" max="10" value="5" oninput="document.getElementById('intensity-val').textContent=this.value" style="width:100%;accent-color:#7c3aed;">
|
||
</div>
|
||
<div class="emo-note"><label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;display:block;margin-bottom:5px;">오늘 있었던 일 (선택)</label><textarea placeholder="어떤 일이 있었나요?" rows="3"></textarea></div>
|
||
<div class="w-footer">
|
||
<button class="w-btn" onclick="submitEmotion()">AI에게 이야기하기</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function toggleEmo(i,name){
|
||
const btn=document.getElementById('emo-'+i);
|
||
if(selectedEmos.includes(name)){selectedEmos=selectedEmos.filter(e=>e!==name);btn.classList.remove('sel');}
|
||
else{selectedEmos.push(name);btn.classList.add('sel');}
|
||
}
|
||
|
||
function submitEmotion(){
|
||
const intensity=document.querySelector('.emo-intensity input')?.value||5;
|
||
const note=document.querySelector('.emo-note textarea')?.value?.trim()||'';
|
||
if(!selectedEmos.length){alert('감정을 하나 이상 선택해주세요.');return;}
|
||
const msg=`[감정 체크인 결과]\n현재 감정: ${selectedEmos.join(', ')}\n감정 강도: ${intensity}/10${note?'\n오늘 있었던 일: '+note:''}`;
|
||
sendToChat(msg,'감정 체크인을 공유했습니다. AI가 응답 중...');
|
||
}
|
||
|
||
// ── Questionnaire ─────────────────────────────────────────────────────────────
|
||
const TESTS={
|
||
phq9:{
|
||
title:'PHQ-9 우울증 선별 검사',
|
||
intro:'지난 2주 동안 다음 문제들로 인해 얼마나 자주 방해를 받았습니까?',
|
||
scale:['전혀 없음(0)','며칠(1)','7일 이상(2)','거의 매일(3)'],
|
||
questions:[
|
||
'일을 하는 데 흥미나 즐거움이 거의 없음',
|
||
'기분이 가라앉거나, 우울하거나, 희망이 없음',
|
||
'잠들기가 어렵거나 자꾸 깨거나 혹은 너무 많이 잠',
|
||
'피곤함을 느끼거나 기운이 거의 없음',
|
||
'식욕이 없거나 과식을 함',
|
||
'자신이 나쁜 사람이라거나 실패자라고 느끼거나 자신 또는 가족을 실망시켰다고 느낌',
|
||
'신문을 읽거나 TV를 보는 것과 같은 일에 집중하기가 어려움',
|
||
'남들이 눈치 챌 정도로 너무 느리게 움직이거나 말을 함, 또는 반대로 너무 안절부절 못하거나 들떠 있음',
|
||
'스스로를 해치거나 자살하는 것이 낫겠다는 생각',
|
||
],
|
||
crisisItem:8,
|
||
levels:[
|
||
{max:4,cls:'minimal',label:'정상 범위',desc:'우울 증상이 거의 없습니다.'},
|
||
{max:9,cls:'mild',label:'경미한 우울',desc:'가벼운 우울 증상이 있습니다. 생활 습관 개선이 도움될 수 있습니다.'},
|
||
{max:14,cls:'moderate',label:'중등도 우울',desc:'중간 정도의 우울 증상입니다. 전문가 상담을 권장합니다.'},
|
||
{max:19,cls:'moderate',label:'중등도-중증 우울',desc:'중증에 가까운 우울 증상입니다. 전문가 상담이 필요합니다.'},
|
||
{max:27,cls:'severe',label:'심각한 우울',desc:'심각한 우울 증상입니다. 즉시 전문가 상담을 받으시길 권합니다.'},
|
||
],
|
||
refs:'Kroenke, Spitzer & Williams(2001); 한국어판 Cho et al.(2007)',
|
||
note:'9번 문항(자해/자살 생각)에 1점 이상 응답 시, 총점과 관계없이 전문가 상담이 권장됩니다.',
|
||
},
|
||
gad7:{
|
||
title:'GAD-7 범불안장애 검사',
|
||
intro:'지난 2주 동안 다음 문제들로 인해 얼마나 자주 방해를 받았습니까?',
|
||
scale:['전혀 없음(0)','며칠(1)','7일 이상(2)','거의 매일(3)'],
|
||
questions:[
|
||
'초조하거나 불안하거나 긴장된 느낌',
|
||
'걱정을 멈추거나 조절할 수 없음',
|
||
'다양한 것들에 대해 너무 많이 걱정함',
|
||
'편안하게 있기가 어려움',
|
||
'너무 안절부절 못해서 가만히 있기가 힘듦',
|
||
'쉽게 짜증이 나거나 신경질적이 됨',
|
||
'두렵거나 좋지 않은 일이 생길 것 같은 느낌',
|
||
],
|
||
levels:[
|
||
{max:4,cls:'minimal',label:'정상 범위',desc:'불안 증상이 거의 없습니다.'},
|
||
{max:9,cls:'mild',label:'경미한 불안',desc:'가벼운 불안 증상이 있습니다.'},
|
||
{max:14,cls:'moderate',label:'중등도 불안',desc:'중간 정도의 불안 증상입니다. 전문가 상담을 권장합니다.'},
|
||
{max:21,cls:'severe',label:'심각한 불안',desc:'심각한 불안 증상입니다. 전문가 상담이 필요합니다.'},
|
||
],
|
||
refs:'Spitzer et al.(2006); 한국어판 Cho et al.(2011)',
|
||
},
|
||
pss10:{
|
||
title:'PSS-10 지각된 스트레스 척도',
|
||
intro:'지난 한 달 동안, 각 상황을 겪은 빈도에 해당하는 답을 선택해주세요. *표시는 역채점 문항입니다.',
|
||
scale:['전혀 없었다(0)','거의 없었다(1)','때때로 있었다(2)','자주 있었다(3)','매우 자주 있었다(4)'],
|
||
reverse:[3,4,6,7],
|
||
questions:[
|
||
'예기치 못한 일 때문에 당황한 적이 있었나?',
|
||
'중요한 일을 통제할 수 없다고 느낀 적이 있었나?',
|
||
'신경이 곤두서고 스트레스를 받는다고 느낀 적이 있었나?',
|
||
'* 자신의 개인적인 문제들을 성공적으로 처리했다고 자신 있게 느낀 적이 있었나?',
|
||
'* 중요한 일들을 처리하는 방법에 대해 통제력이 있다고 느낀 적이 있었나?',
|
||
'화가 나는 일 때문에 분노를 억누르기 힘들었나?',
|
||
'* 자신이 원하는 대로 일을 처리할 수 있다고 느낀 적이 있었나?',
|
||
'일이 너무 많아 통제할 수 없다고 느낀 적이 있었나?',
|
||
'일이 내 통제 밖의 일로 인해 화가 난 적이 있었나?',
|
||
'어려운 일들이 너무 많이 쌓여 극복할 수 없다고 느낀 적이 있었나?',
|
||
],
|
||
levels:[
|
||
{max:13,cls:'minimal',label:'낮은 스트레스',desc:'지각된 스트레스 수준이 낮습니다.'},
|
||
{max:26,cls:'mild',label:'보통 스트레스',desc:'보통 수준의 스트레스를 경험하고 있습니다. 대처 자원 점검이 도움될 수 있습니다.'},
|
||
{max:40,cls:'moderate',label:'높은 스트레스',desc:'스트레스 수준이 높습니다. 스트레스 관리와 전문가 상담을 권장합니다.'},
|
||
],
|
||
subscores:[
|
||
{name:'무력감 (Perceived Helplessness)',items:[0,1,2,5,8,9]},
|
||
{name:'자기효능감 (Perceived Self-Efficacy)',items:[3,4,6,7]},
|
||
],
|
||
refs:'Cohen & Williamson(1988); 한국어판 Park & Seo(2015)',
|
||
},
|
||
isi:{
|
||
title:'ISI 불면증 심각도 척도',
|
||
intro:'지난 2주 동안의 수면 문제를 기준으로 답해주세요.',
|
||
scale:['없음(0)','경미함(1)','중간(2)','상당함(3)','심각함(4)'],
|
||
questions:[
|
||
'잠들기 어려움의 심각성',
|
||
'수면을 유지하기 어려움의 심각성',
|
||
'너무 일찍 깨는 문제의 심각성',
|
||
'현재 수면 패턴에 대한 만족/불만족도',
|
||
'수면 문제가 일상 기능(낮 동안 피로·집중력 등)에 미치는 영향',
|
||
'수면 문제가 타인에게 눈에 띄는 정도',
|
||
'현재 수면 문제로 인해 느끼는 고통/걱정',
|
||
],
|
||
levels:[
|
||
{max:7,cls:'minimal',label:'정상',desc:'임상적 의미의 불면 증상이 없습니다.'},
|
||
{max:14,cls:'mild',label:'경미한 불면',desc:'아임상적 불면 가능성. 수면 위생 개선이 도움될 수 있습니다.'},
|
||
{max:21,cls:'moderate',label:'중등도 불면',desc:'중간 정도의 불면. 전문가 평가를 권장합니다.'},
|
||
{max:28,cls:'severe',label:'중증 불면',desc:'심각한 불면. 인지행동치료(CBT-I) 등 전문가 치료가 권장됩니다.'},
|
||
],
|
||
refs:'Morin(1993); 한국어판 Cho et al.(2014)',
|
||
},
|
||
who5:{
|
||
title:'WHO-5 웰빙 지수',
|
||
intro:'지난 2주 동안 가장 잘 맞는 답을 선택해주세요.',
|
||
scale:['전혀 없었다(0)','드물게(1)','절반 미만(2)','절반 이상(3)','대부분(4)','항상(5)'],
|
||
questions:[
|
||
'기분이 명랑하고 쾌활했다',
|
||
'차분하고 휴식을 취할 수 있었다',
|
||
'활기차고 에너지가 넘쳤다',
|
||
'관심사에 흥미가 일어나고 일어나는 일들을 좋아했다',
|
||
'매일의 일들이 즐거웠다',
|
||
],
|
||
levels:[
|
||
{max:7,cls:'severe',label:'우울 선별 권장',desc:'원시점수 7 이하(백분위 28 이하)로, 우울 선별을 위한 추가 평가가 권장됩니다.'},
|
||
{max:12,cls:'moderate',label:'낮은 웰빙',desc:'웰빙 수준이 낮습니다. 스트레스·기분 추가 평가가 도움될 수 있습니다.'},
|
||
{max:25,cls:'minimal',label:'양호한 웰빙',desc:'웰빙 수준이 양호합니다.'},
|
||
],
|
||
refs:'WHO(1998); 한국어판 Bae et al.(2017). 원시점수 ×4 = 백분위 점수.',
|
||
displayScore:(raw)=>`${raw}점 (백분위 ${raw*4})`,
|
||
},
|
||
rosenberg:{
|
||
title:'Rosenberg 자존감 척도',
|
||
intro:'각 문항에 대해 자신에게 가장 잘 맞는 답을 선택해주세요. *표시는 역채점 문항입니다.',
|
||
scale:['매우 그렇다(4)','그렇다(3)','아니다(2)','매우 아니다(1)'],
|
||
descending:true,
|
||
scoreBase:1,
|
||
reverse:[1,4,5,8],
|
||
questions:[
|
||
'나는 적어도 다른 사람들과 같은 정도의 가치 있는 사람이라고 느낀다.',
|
||
'* 때때로 나는 전혀 쓸모없는 사람이라고 생각한다.',
|
||
'나에게는 대부분의 사람들이 가진 것과 같은 좋은 점들이 있다고 생각한다.',
|
||
'나는 내가 할 수 있는 것만큼은 할 수 있다고 생각한다.',
|
||
'* 나는 자신감이 없는 사람이라고 느낀다.',
|
||
'* 때때로 나는 다른 사람들보다 못하다고 느낀다.',
|
||
'나는 자신에 대해 긍정적인 태도를 가지고 있다고 생각한다.',
|
||
'전반적으로 나는 스스로에게 만족한다.',
|
||
'* 때때로 나는 나 자신이 별로 잘난 것이 없다고 느낀다.',
|
||
'나는 나 자신에게 긍정적인 가치를 느낀다.',
|
||
],
|
||
levels:[
|
||
{max:14,cls:'moderate',label:'낮은 자존감',desc:'자존감이 낮은 편입니다. 자존감 강화를 위한 접근이 도움될 수 있습니다.'},
|
||
{max:24,cls:'mild',label:'보통 자존감',desc:'보통 수준의 자존감입니다.'},
|
||
{max:40,cls:'minimal',label:'높은 자존감',desc:'자존감이 높고 안정적입니다.'},
|
||
],
|
||
refs:'Rosenberg(1965); 한국어판 Jeon(1974). 총점 범위 10-40.',
|
||
},
|
||
audit:{
|
||
title:'AUDIT 알코올 사용장애 선별검사',
|
||
intro:'지난 1년 동안의 음주를 기준으로 답해주세요.',
|
||
scale:['없다(0)','한달 1회 미만(1)','한달 1회(2)','일주일 1회(3)','거의 매일(4)'],
|
||
questions:[
|
||
'술을 마신 빈도는?',
|
||
'술을 마시는 날 한 번에 마시는 잔 수는?',
|
||
'한 번에 5~6잔(여성 4~5잔) 이상 마신 날이 얼마나 되나요?',
|
||
'한 번 마시기 시작하면 멈출 수 없었던 적이?',
|
||
'음주 때문에 계획된 일을 실패한 적이?',
|
||
'술 마신 후 아침에 정신을 차리기 위해 다시 마신 적이?',
|
||
'술 마신 후 죄책감이나 후회를 느낀 적이?',
|
||
'전날 밤 술 때문에 기억이 나지 않은 적이?',
|
||
'음주로 인해 본인이나 타인이 다친 적이?',
|
||
'친척·친구·의사가 음주를 걱정하거나 줄이라고 한 적이?',
|
||
],
|
||
itemOpts:{
|
||
0:[{label:'안 마신다(0)',score:0},{label:'한달 1회 이하(1)',score:1},{label:'한달 2-4회(2)',score:2},{label:'일주일 2-3회(3)',score:3},{label:'일주일 4회 이상(4)',score:4}],
|
||
1:[{label:'1-2잔(0)',score:0},{label:'3-4잔(1)',score:1},{label:'5-6잔(2)',score:2},{label:'7-9잔(3)',score:3},{label:'10잔 이상(4)',score:4}],
|
||
8:[{label:'없다(0)',score:0},{label:'있으나 최근 1년 내 없음(2)',score:2},{label:'최근 1년 내 있음(4)',score:4}],
|
||
9:[{label:'없다(0)',score:0},{label:'있으나 최근 1년 내 없음(2)',score:2},{label:'최근 1년 내 있음(4)',score:4}],
|
||
},
|
||
levels:[
|
||
{max:7,cls:'minimal',label:'낮은 위험',desc:'알코올 사용으로 인한 위험이 낮습니다.'},
|
||
{max:15,cls:'mild',label:'위험 음주',desc:'위험 음주 수준입니다. 음주량 감량과 상담이 권장됩니다.'},
|
||
{max:19,cls:'moderate',label:'유해 음주',desc:'유해 음주 수준입니다. 간단한 중재와 상담이 권장됩니다.'},
|
||
{max:40,cls:'severe',label:'알코올 의존 가능성',desc:'알코올 의존 가능성이 있습니다. 전문가 진료가 권장됩니다.'},
|
||
],
|
||
subscores:[
|
||
{name:'음주 (AUDIT-C, Q1-3)',items:[0,1,2],bands:[{max:3,label:'저위험'},{max:12,label:'위험 음주'}]},
|
||
{name:'의존 (Q4-6)',items:[3,4,5]},
|
||
{name:'위해 (Q7-10)',items:[6,7,8,9]},
|
||
],
|
||
refs:'Saunders et al.(1993); 한국어판 Lee et al.(2000). AUDIT-C 절단점 ≥4(남)/≥3(여).',
|
||
},
|
||
pcl5:{
|
||
title:'PCL-5 외상후스트레스장애(PTSD) 선별 검사',
|
||
intro:'지난 한 달 동안, 스트레스가 매우 심했던 사건 경험 때문에 다음 문제들로 얼마나 자주 힘들었습니까? 특정 사건을 구체적으로 지목하지 않아도 괜찮습니다.',
|
||
scale:['전혀 아님(0)','조금(1)','보통(2)','많이(3)','매우 많이(4)'],
|
||
questions:[
|
||
'그 사건에 대한 반복적이고 원치 않는 괴로운 기억',
|
||
'그 사건에 대한 반복적이고 괴로운 꿈',
|
||
'갑자기 그 사건이 다시 일어나고 있는 것처럼 느끼거나 행동함 (플래시백)',
|
||
'그 사건을 떠올리게 하는 것에 노출됐을 때 강한 심리적 고통을 느낌',
|
||
'그 사건을 떠올리게 하는 것에 노출됐을 때 강한 신체 반응(심장 두근거림, 호흡곤란, 식은땀 등)이 나타남',
|
||
'그 사건에 대한 기억·생각·감정을 애써 피하려 함',
|
||
'그 사건을 떠올리게 하는 사람·장소·대화·활동·사물·상황을 피하려 함',
|
||
'그 사건의 중요한 부분을 기억하기 어려움',
|
||
'자신·타인·세상에 대한 강한 부정적 신념 (예: "나는 나쁘다", "아무도 믿을 수 없다", "세상은 완전히 위험하다")',
|
||
'그 사건이나 그로 인해 벌어진 일에 대해 자신 또는 타인을 탓함',
|
||
'두려움·공포·분노·죄책감·수치심 같은 강한 부정적 감정',
|
||
'예전에 즐기던 활동에 대한 흥미 상실',
|
||
'다른 사람들로부터 거리감이나 단절감을 느낌',
|
||
'긍정적인 감정(행복, 사랑 등)을 경험하기 어려움',
|
||
'과민한 행동, 분노 폭발, 공격적인 행동',
|
||
'지나치게 위험하거나 스스로를 해칠 수 있는 행동을 함',
|
||
'지나치게 경계하거나 주위를 살핌',
|
||
'쉽게 놀라거나 소스라침',
|
||
'집중하는 데 어려움을 겪음',
|
||
'잠들기 어렵거나 잠을 유지하기 어려움',
|
||
],
|
||
levels:[
|
||
{max:9,cls:'minimal',label:'정상 범위',desc:'외상 관련 증상이 거의 없습니다.'},
|
||
{max:32,cls:'mild',label:'경미~중등도 증상',desc:'일부 외상 관련 증상이 있습니다. 경과를 지켜보며 필요시 상담을 고려하세요.'},
|
||
{max:80,cls:'severe',label:'PTSD 가능성 높음',desc:'외상후스트레스장애(PTSD) 가능성을 시사하는 점수입니다. 전문가 평가를 권장합니다.'},
|
||
],
|
||
refs:'Weathers et al.(2013), National Center for PTSD 개발. 절단점 ≥31~33 (연구별로 상이).',
|
||
note:'특정 외상 사건을 염두에 두고 응답하는 검사입니다. 사건 자체를 이야기하고 싶지 않다면 하지 않아도 괜찮습니다.',
|
||
},
|
||
mdq:{
|
||
title:'MDQ 기분장애 선별 질문지 (양극성 스펙트럼)',
|
||
intro:'지금까지 살아오면서, 평소의 나와는 다르게 아래와 같았던 시기가 있었는지 "예/아니오"로 답해주세요.',
|
||
scale:['아니오(0)','예(1)'],
|
||
questions:[
|
||
'평소와 달리 기분이 너무 좋거나 들떠서, 남들이 평소의 당신 같지 않다고 하거나 그것 때문에 문제가 생긴 적이 있나요?',
|
||
'너무 짜증이 나서 사람들에게 소리치거나 싸움·논쟁을 시작한 적이 있나요?',
|
||
'평소보다 훨씬 자신감이 넘쳤던 적이 있나요?',
|
||
'평소보다 잠을 훨씬 적게 잤는데도 피곤하지 않았던 적이 있나요?',
|
||
'평소보다 말이 훨씬 많아지거나 말이 빨라진 적이 있나요?',
|
||
'생각이 머릿속에서 너무 빠르게 흘러가서 멈출 수 없었던 적이 있나요?',
|
||
'주변 것들에 너무 쉽게 정신이 팔려서 집중하기 어려웠던 적이 있나요?',
|
||
'평소보다 훨씬 에너지가 넘쳤던 적이 있나요?',
|
||
'평소보다 훨씬 활동적이거나 많은 일을 벌였던 적이 있나요?',
|
||
'평소보다 훨씬 사교적이거나 외향적이 되었던 적이 있나요? (예: 한밤중에 친구에게 전화)',
|
||
'평소보다 성(性)에 대한 관심이 훨씬 커진 적이 있나요?',
|
||
'평소와 다르게 무리하거나 위험할 수 있는 행동(과소비, 난폭 운전 등)을 한 적이 있나요?',
|
||
'돈을 너무 많이 써서 본인이나 가족이 곤란해진 적이 있나요?',
|
||
],
|
||
levels:[
|
||
{max:6,cls:'minimal',label:'낮음',desc:'조증·경조증 관련 증상이 뚜렷하지 않습니다.'},
|
||
{max:13,cls:'moderate',label:'양극성 스펙트럼 증상 가능성',desc:'조증·경조증과 관련된 증상이 여러 개 확인됩니다. 전문가 평가를 통해 정확히 확인해보는 것이 좋습니다.'},
|
||
],
|
||
refs:'Hirschfeld et al.(2000) 개발. 원본 절단점은 "예" 7개 이상.',
|
||
note:'원본 MDQ는 "예" 개수 외에도 "여러 증상이 같은 시기에 함께 나타났는지"와 "일상에 얼마나 지장을 주었는지"를 함께 확인해야 양성으로 판정합니다. 이 검사는 문항 수를 단순화한 선별용이며, 이 결과만으로 양극성장애 여부를 판단할 수 없습니다.',
|
||
},
|
||
ocir:{
|
||
title:'OCI-R 강박장애 선별 검사',
|
||
intro:'지난 한 달 동안, 다음 경험들이 얼마나 괴로움이나 불편함을 주었습니까?',
|
||
scale:['전혀 아님(0)','약간(1)','보통(2)','많이(3)','매우 많이(4)'],
|
||
questions:[
|
||
'물건을 너무 많이 모아둬서 방해가 될 정도이다 (저장)',
|
||
'필요 이상으로 자주 물건을 확인한다 (확인)',
|
||
'물건이 제대로 정리되어 있지 않으면 속상하다 (정리정돈)',
|
||
'어떤 일을 할 때 숫자를 세야만 할 것 같은 충동을 느낀다 (중화행동)',
|
||
'낯선 사람이나 특정 사람이 만진 물건을 만지기가 꺼려진다 (씻기)',
|
||
'내 생각을 스스로 통제하기 어렵다고 느낀다 (침투사고)',
|
||
'필요 없는 물건들을 모은다 (저장)',
|
||
'문·창문·서랍 등을 반복해서 확인한다 (확인)',
|
||
'누군가 내가 정리해 둔 것을 바꿔놓으면 속상하다 (정리정돈)',
|
||
'특정 숫자를 반복해야 할 것 같은 느낌이 든다 (중화행동)',
|
||
'오염된 느낌 때문에 씻거나 청소를 해야 할 때가 있다 (씻기)',
|
||
'원치 않는 불쾌한 생각이 떠올라 괴롭다 (침투사고)',
|
||
'나중에 필요할까봐 물건을 버리지 못한다 (저장)',
|
||
'가스·수도꼭지·전등 스위치를 끈 후에도 반복해서 확인한다 (확인)',
|
||
'물건이 특정한 순서로 정리되어 있어야 한다 (정리정돈)',
|
||
'좋은 숫자와 나쁜 숫자가 있다고 느낀다 (중화행동)',
|
||
'필요 이상으로 손을 자주, 오래 씻는다 (씻기)',
|
||
'불쾌한 생각이 자꾸 떠올라 떨쳐내기 어렵다 (침투사고)',
|
||
],
|
||
levels:[
|
||
{max:13,cls:'minimal',label:'정상 범위',desc:'강박 관련 증상이 거의 없습니다.'},
|
||
{max:20,cls:'mild',label:'경미한 증상',desc:'가벼운 강박 관련 증상이 있습니다.'},
|
||
{max:72,cls:'moderate',label:'강박장애 가능성',desc:'강박장애(OCD) 가능성을 시사하는 점수입니다. 전문가 평가를 권장합니다.'},
|
||
],
|
||
subscores:[
|
||
{name:'저장(Hoarding)',items:[0,6,12]},
|
||
{name:'확인(Checking)',items:[1,7,13]},
|
||
{name:'정리정돈(Ordering)',items:[2,8,14]},
|
||
{name:'중화행동(Neutralizing)',items:[3,9,15]},
|
||
{name:'씻기(Washing)',items:[4,10,16]},
|
||
{name:'침투사고(Obsessing)',items:[5,11,17]},
|
||
],
|
||
refs:'Foa et al.(2002) 개발. 절단점 ≥21 (연구별로 18~21 범위).',
|
||
note:'각 문항 뒤 괄호는 이해를 돕기 위한 하위척도 표시로, 실제 검사에는 표시되지 않습니다.',
|
||
},
|
||
asrs:{
|
||
title:'ASRS-6 성인 ADHD 자가보고 척도',
|
||
intro:'지난 6개월 동안 다음 문제들이 얼마나 자주 있었습니까? (세계보건기구 WHO 개발 성인 ADHD 선별 도구)',
|
||
scale:['전혀 없음(0)','드물게(1)','가끔(2)','자주(3)','매우 자주(4)'],
|
||
questions:[
|
||
'어려운 부분을 끝낸 후, 프로젝트의 마무리 세부 작업을 처리하는 데 어려움을 겪은 적이 얼마나 자주 있었나요?',
|
||
'체계적으로 처리해야 하는 일을 할 때, 순서를 정리하는 데 어려움을 겪은 적이 얼마나 자주 있었나요?',
|
||
'약속이나 해야 할 일을 잊어버린 적이 얼마나 자주 있었나요?',
|
||
'많은 생각이 필요한 일을 시작하는 것을 미루거나 피한 적이 얼마나 자주 있었나요?',
|
||
'오래 앉아 있어야 할 때 손이나 발을 꼼지락거리거나 몸을 움직인 적이 얼마나 자주 있었나요?',
|
||
'마치 모터가 달린 것처럼 지나치게 활동적이거나 무언가를 해야만 할 것 같은 느낌이 든 적이 얼마나 자주 있었나요?',
|
||
],
|
||
levels:[
|
||
{max:13,cls:'minimal',label:'낮음',desc:'ADHD 관련 증상이 뚜렷하지 않습니다.'},
|
||
{max:24,cls:'moderate',label:'성인 ADHD 증상과 일치',desc:'성인 ADHD와 관련된 증상 패턴이 확인됩니다. 전문가 평가를 권장합니다.'},
|
||
],
|
||
refs:'Kessler et al.(2005), WHO/Harvard 개발 ASRS v1.1 Part A(6문항 스크리너).',
|
||
note:'원본 ASRS-6는 문항별로 다른 절단 기준(문항에 따라 "가끔" 이상 또는 "자주" 이상)을 적용해 4개 이상 해당되면 양성으로 판정하는 방식입니다. 이 검사는 단순 합산 점수로 근사한 것이며, 정확한 채점은 원본 방식과 다를 수 있습니다.',
|
||
},
|
||
spin:{
|
||
title:'SPIN 사회공포증 척도',
|
||
intro:'지난 한 주 동안, 다음 상황들이 얼마나 불편하거나 두려웠습니까?',
|
||
scale:['전혀 아님(0)','약간(1)','보통(2)','많이(3)','매우 많이(4)'],
|
||
questions:[
|
||
'권위 있는 사람들이 두렵다',
|
||
'사람들 앞에서 얼굴이 빨개지는 것이 신경 쓰인다',
|
||
'파티나 사교 모임이 두렵다',
|
||
'모르는 사람과 대화하는 것을 피한다',
|
||
'비판받는 것이 매우 두렵다',
|
||
'창피할까봐 어떤 일을 하거나 사람들에게 말하는 것을 피한다',
|
||
'사람들 앞에서 땀을 흘리는 것이 괴롭다',
|
||
'파티에 가는 것을 피한다',
|
||
'내가 주목받는 활동을 피한다',
|
||
'낯선 사람에게 말을 거는 것이 두렵다',
|
||
'연설이나 발표를 해야 하는 상황을 피한다',
|
||
'비판받지 않기 위해서라면 무엇이든 하겠다',
|
||
'사람들 곁에 있을 때 심장이 두근거려 괴롭다',
|
||
'사람들이 지켜볼 수도 있는 상황에서 무언가를 하는 것이 두렵다',
|
||
'창피당하거나 바보처럼 보이는 것이 가장 두려운 일 중 하나이다',
|
||
'권위 있는 사람에게 말을 거는 것을 피한다',
|
||
'사람들 앞에서 떨리거나 흔들리는 것이 괴롭다',
|
||
],
|
||
levels:[
|
||
{max:20,cls:'minimal',label:'정상 범위',desc:'사회불안 관련 증상이 거의 없습니다.'},
|
||
{max:30,cls:'mild',label:'경미한 사회불안',desc:'가벼운 사회불안 증상이 있습니다.'},
|
||
{max:40,cls:'moderate',label:'중등도 사회불안',desc:'중간 정도의 사회불안 증상입니다. 전문가 상담을 고려해보세요.'},
|
||
{max:68,cls:'severe',label:'심각한 사회불안',desc:'심각한 사회불안 증상입니다. 전문가 상담을 권장합니다.'},
|
||
],
|
||
refs:'Connor et al.(2000) 개발. 절단점 ≥19(경도 이상 시사).',
|
||
},
|
||
};
|
||
|
||
let testAnswers={};
|
||
let lastResult=null;
|
||
|
||
// ── Scoring engine ──
|
||
function scoreFor(test,i,optIdx){
|
||
if(test.itemOpts&&test.itemOpts[i])return test.itemOpts[i][optIdx].score;
|
||
const n=test.scale.length,b=test.scoreBase||0;
|
||
let raw=test.descending?(n-1-optIdx):optIdx;
|
||
if(test.reverse&&test.reverse.includes(i))raw=(n-1)-raw;
|
||
return b+raw;
|
||
}
|
||
function optsFor(test,i){
|
||
if(test.itemOpts&&test.itemOpts[i])return test.itemOpts[i];
|
||
return test.scale.map((label,idx)=>({label,score:scoreFor(test,i,idx)}));
|
||
}
|
||
function scoreRange(test){
|
||
let min=0,max=0;
|
||
for(let i=0;i<test.questions.length;i++){
|
||
const sc=optsFor(test,i).map(o=>o.score);
|
||
min+=Math.min(...sc);max+=Math.max(...sc);
|
||
}
|
||
return {min,max};
|
||
}
|
||
|
||
function renderQuestionnaire(testId){
|
||
const test=TESTS[testId];
|
||
testAnswers={};
|
||
const w=document.getElementById('center-widget');
|
||
w.innerHTML=`
|
||
<div class="widget">
|
||
<h2>${test.title}</h2>
|
||
<p class="w-desc">${test.intro}</p>
|
||
<div class="q-progress"><div class="q-progress-bar" id="qprog" style="width:0%"></div></div>
|
||
${test.questions.map((q,i)=>{
|
||
const opts=optsFor(test,i);
|
||
return `<div class="q-item">
|
||
<div class="q-text">${i+1}. ${q}</div>
|
||
<div class="q-opts">
|
||
${opts.map((o,v)=>`<button class="q-opt" onclick="selectAnswer('${testId}',${i},${v})">${escHtml(o.label)}</button>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
}).join('')}
|
||
<div class="w-footer">
|
||
<button class="w-btn" id="submit-q-btn" onclick="submitQuestionnaire('${testId}')" disabled>결과 확인</button>
|
||
<span style="font-size:11px;color:var(--muted)">0/${test.questions.length} 완료</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function selectAnswer(testId,qIdx,val){
|
||
testAnswers[qIdx]=val;
|
||
const item=document.querySelectorAll('#center-widget .q-item')[qIdx];
|
||
if(item)item.querySelectorAll('.q-opt').forEach((b,v)=>b.classList.toggle('selected',v===val));
|
||
const done=Object.keys(testAnswers).length;
|
||
const total=TESTS[testId].questions.length;
|
||
const prog=document.getElementById('qprog');if(prog)prog.style.width=(done/total*100)+'%';
|
||
const footer=document.querySelector('#center-widget .w-footer span');if(footer)footer.textContent=`${done}/${total} 완료`;
|
||
const btn=document.getElementById('submit-q-btn');if(btn)btn.disabled=done<total;
|
||
}
|
||
|
||
function submitQuestionnaire(testId){
|
||
const test=TESTS[testId];
|
||
const n=test.questions.length;
|
||
for(let i=0;i<n;i++)if(testAnswers[i]===undefined){alert(`${i+1}번 문항을 선택해주세요.`);return;}
|
||
let score=0;const items=[];
|
||
for(let i=0;i<n;i++){
|
||
const opts=optsFor(test,i);
|
||
const sc=opts[testAnswers[i]].score;
|
||
score+=sc;
|
||
items.push({n:i+1,q:test.questions[i],label:opts[testAnswers[i]].label,score:sc});
|
||
}
|
||
const level=test.levels.find(l=>score<=l.max)||test.levels[test.levels.length-1];
|
||
const range=scoreRange(test);
|
||
// subscores
|
||
const subscores=[];
|
||
if(test.subscores){
|
||
for(const s of test.subscores){
|
||
let ss=0,smax=0,interp='';
|
||
for(const idx of s.items){
|
||
const sc=optsFor(test,idx).map(o=>o.score);
|
||
ss+=items[idx].score;smax+=Math.max(...sc);
|
||
}
|
||
if(s.bands){const b=s.bands.find(x=>ss<=x.max)||s.bands[s.bands.length-1];interp=b.label;}
|
||
subscores.push({name:s.name,score:ss,max:smax,interp});
|
||
}
|
||
}
|
||
const scoreDisplay=test.displayScore?test.displayScore(score):`${score}점`;
|
||
const subsHtml=subscores.length?`<div class="q-subscores">${subscores.map(s=>`
|
||
<div class="q-sub">
|
||
<div class="q-sub-hdr"><span>${escHtml(s.name)}</span><span>${s.score}/${s.max}${s.interp?' · '+escHtml(s.interp):''}</span></div>
|
||
<div class="q-sub-bar"><div class="q-sub-fill" style="width:${Math.round(s.score/s.max*100)}%"></div></div>
|
||
</div>`).join('')}</div>`:'';
|
||
const itemTable=`<div class="q-result-detail"><table><thead><tr><th>#</th><th>문항</th><th>응답</th><th>점수</th></tr></thead><tbody>${items.map(it=>`<tr><td class="q-num">${it.n}</td><td>${escHtml(it.q)}</td><td>${escHtml(it.label)}</td><td class="q-sc">${it.score}</td></tr>`).join('')}</tbody></table></div>`;
|
||
const disclaimer=`<div class="q-disclaimer"><strong>안내</strong>본 검사는 자기보고식 선별도구로 임상 진단을 대체하지 않습니다. 결과는 참고용이며, 정확한 평가는 임상심리사 또는 정신건강의학과 전문의를 통해 받으세요.${test.note?'<br>'+escHtml(test.note):''}${test.refs?`<div class="q-refs">참고: ${escHtml(test.refs)}</div>`:''}</div>`;
|
||
// Standard clinical practice for PHQ-9: item 9 (suicidal/self-harm ideation) warrants
|
||
// a direct safety check regardless of total score — previously buried as one line in
|
||
// the disclaimer, now a prominent banner if the respondent scored above 0 on it.
|
||
const crisisBanner=(test.crisisItem!==undefined&&items[test.crisisItem].score>0)?`<div class="crisis-banner">
|
||
<strong>⚠️ 확인이 필요합니다</strong>
|
||
<p>"${escHtml(test.questions[test.crisisItem])}" 문항에 응답하신 내용이 있습니다. 지금 힘든 마음이시라면 아래 연락처로 바로 도움을 받으실 수 있습니다.</p>
|
||
<div class="crisis-lines">
|
||
<span>☎ 자살예방상담전화 1393 (24시간, 무료)</span>
|
||
<span>☎ 정신건강위기상담전화 1577-0199 (24시간)</span>
|
||
<span>☎ 생명의전화 1588-9191</span>
|
||
<span>☎ 청소년전화 1388</span>
|
||
</div>
|
||
</div>`:'';
|
||
|
||
lastResult={title:test.title,score,level,subscores,items,refs:test.refs,minScore:range.min,maxScore:range.max};
|
||
|
||
const w=document.getElementById('center-widget');
|
||
w.innerHTML=`
|
||
<div class="widget">
|
||
<h2>${test.title} 결과</h2>
|
||
${crisisBanner}
|
||
<div class="result-card ${level.cls}">
|
||
<div class="r-score">${scoreDisplay}</div>
|
||
<div class="r-label">${level.label}</div>
|
||
<div class="r-desc">${level.desc}</div>
|
||
</div>
|
||
${subsHtml}
|
||
${itemTable}
|
||
${disclaimer}
|
||
<div class="w-footer">
|
||
<button class="w-btn" onclick="sendResultToChat()">AI에게 해석 요청</button>
|
||
<button class="w-btn secondary" onclick="renderQuestionnaire('${testId}')">다시 검사하기</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function sendResultToChat(){
|
||
if(!lastResult)return;
|
||
const r=lastResult;
|
||
let msg=`[${r.title} 결과]\n총점: ${r.score}점 (범위 ${r.minScore}-${r.maxScore}) — ${r.level.label}\n\n${r.level.desc}`;
|
||
if(r.subscores&&r.subscores.length){
|
||
msg+='\n\n[하위척도]';
|
||
for(const s of r.subscores)msg+=`\n- ${s.name}: ${s.score}/${s.max}${s.interp?' ('+s.interp+')':''}`;
|
||
}
|
||
msg+='\n\n[문항별 응답]';
|
||
for(const it of r.items)msg+=`\n${it.n}. ${it.q}: ${it.label} (${it.score}점)`;
|
||
if(r.refs)msg+=`\n\n참고문헌: ${r.refs}`;
|
||
msg+='\n\n이 결과에 대한 임상적 해석과 권장 다음 단계를 조언해주세요.';
|
||
sendToChat(msg,r.title+' 결과를 공유했습니다. AI가 해석 중...');
|
||
}
|
||
|
||
// ── CBT Thought Record ────────────────────────────────────────────────────────
|
||
const CBT_EMOTIONS=['불안','슬픔','분노','두려움','수치심','죄책감','외로움','좌절','우울','무력감'];
|
||
|
||
function renderCBT(){
|
||
const w=document.getElementById('center-widget');
|
||
w.innerHTML=`
|
||
<div class="widget">
|
||
<h2>생각 기록지</h2>
|
||
<p class="w-desc">힘든 상황을 기록하고 생각을 점검해보세요. CBT(인지행동치료) 기법을 활용한 자기 분석 도구입니다.</p>
|
||
<div class="cbt-field"><label>1. 상황 — 언제, 어디서, 무슨 일이 있었나요?</label><textarea id="cbt-situation" placeholder="예: 오늘 오후, 직장 회의에서 발표하다가 실수를 했다." rows="2"></textarea></div>
|
||
<div class="cbt-field"><label>2. 자동적 사고 — 그 순간 어떤 생각이 들었나요?</label><textarea id="cbt-thought" placeholder="예: '나는 무능하다', '모두가 나를 비웃었을 것이다'" rows="2"></textarea></div>
|
||
<div class="cbt-field">
|
||
<label>3. 감정 — 어떤 감정이 들었나요?</label>
|
||
<div class="cbt-emotions">${CBT_EMOTIONS.map(e=>`<button class="cbt-emo" onclick="this.classList.toggle('sel')">${e}</button>`).join('')}</div>
|
||
<div class="cbt-intensity">
|
||
<span style="font-size:11px;color:var(--muted)">강도:</span>
|
||
<input type="range" min="0" max="100" value="70" oninput="document.getElementById('cbt-emo-val').textContent=this.value+'%'" style="flex:1;accent-color:#7c3aed;">
|
||
<span id="cbt-emo-val" style="font-size:11px;color:var(--muted);">70%</span>
|
||
</div>
|
||
</div>
|
||
<div class="cbt-field"><label>4. 근거 찾기 — 이 생각이 사실인 근거는?</label><textarea id="cbt-for" placeholder="예: 발표 중에 말을 더듬었다." rows="2"></textarea></div>
|
||
<div class="cbt-field"><label>5. 반증 찾기 — 이 생각이 사실이 아닐 수도 있는 근거는?</label><textarea id="cbt-against" placeholder="예: 동료들이 발표 후 박수를 쳤다. 평소에도 이 주제를 잘 알고 있다." rows="2"></textarea></div>
|
||
<div class="cbt-field"><label>6. 균형 잡힌 생각 — 더 현실적인 생각은?</label><textarea id="cbt-balanced" placeholder="예: '한 번의 실수가 나의 전체 능력을 정의하지 않는다.'" rows="2"></textarea></div>
|
||
<div class="w-footer">
|
||
<button class="w-btn" onclick="submitCBT()">AI에게 피드백 받기</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function submitCBT(){
|
||
const situation=document.getElementById('cbt-situation').value.trim();
|
||
const thought=document.getElementById('cbt-thought').value.trim();
|
||
const emotions=[...document.querySelectorAll('.cbt-emo.sel')].map(e=>e.textContent).join(', ');
|
||
const intensity=document.querySelector('.cbt-intensity input')?.value||70;
|
||
const forEvid=document.getElementById('cbt-for').value.trim();
|
||
const against=document.getElementById('cbt-against').value.trim();
|
||
const balanced=document.getElementById('cbt-balanced').value.trim();
|
||
if(!situation||!thought){alert('상황과 자동적 사고는 필수입니다.');return;}
|
||
const msg=`[CBT 생각 기록지]\n\n1. 상황: ${situation}\n2. 자동적 사고: ${thought}\n3. 감정: ${emotions||'기록 없음'} (강도 ${intensity}%)\n4. 사실인 근거: ${forEvid||'없음'}\n5. 반증 근거: ${against||'없음'}\n6. 균형 잡힌 생각: ${balanced||'아직 모르겠음'}\n\n이 생각 기록지를 분석하고 CBT 관점에서 피드백을 주세요.`;
|
||
sendToChat(msg,'생각 기록지를 공유했습니다. AI가 분석 중...');
|
||
}
|
||
|
||
// ── Breathing ─────────────────────────────────────────────────────────────────
|
||
const BREATH_TYPES={
|
||
'4-7-8':{label:'4-7-8 호흡 (이완)',phases:[{name:'들이쉬기',sec:4},{name:'참기',sec:7},{name:'내쉬기',sec:8}]},
|
||
'box':{label:'박스 호흡 (집중)',phases:[{name:'들이쉬기',sec:4},{name:'참기',sec:4},{name:'내쉬기',sec:4},{name:'참기',sec:4}]},
|
||
'2-1-4':{label:'2-1-4 호흡 (빠른 진정)',phases:[{name:'들이쉬기',sec:2},{name:'참기',sec:1},{name:'내쉬기',sec:4}]},
|
||
};
|
||
let breathType='box',breathRunning=false,breathPhaseIdx=0,breathRemain=0,breathCycles=0;
|
||
|
||
function renderBreath(){
|
||
const w=document.getElementById('center-widget');
|
||
w.innerHTML=`
|
||
<div class="widget">
|
||
<h2>호흡 명상</h2>
|
||
<p class="w-desc">호흡 훈련은 자율신경계를 안정시키고 불안·스트레스를 줄이는 데 효과적입니다.</p>
|
||
<div class="breath-type-sel">${Object.entries(BREATH_TYPES).map(([k,v])=>`<button class="breath-type-btn${breathType===k?' active':''}" onclick="setBreathType('${k}')">${v.label}</button>`).join('')}</div>
|
||
<div class="breath-container">
|
||
<div class="breath-circle-wrap"><div class="breath-circle" id="breath-circle"><div class="breath-phase"><span class="breath-count" id="breath-count">-</span><span id="breath-phase-name">준비</span></div></div></div>
|
||
<div class="breath-cycles" id="breath-cycles-display">사이클: 0회</div>
|
||
</div>
|
||
<div class="w-footer">
|
||
<button class="w-btn" id="breath-start-btn" onclick="toggleBreath()">시작</button>
|
||
<button class="w-btn secondary" onclick="stopBreath()">초기화</button>
|
||
</div>
|
||
</div>`;
|
||
breathRunning=false;breathPhaseIdx=0;breathRemain=0;breathCycles=0;
|
||
}
|
||
|
||
function setBreathType(type){
|
||
breathType=type;
|
||
document.querySelectorAll('.breath-type-btn').forEach(b=>{b.classList.toggle('active',b.textContent===BREATH_TYPES[type].label||b.onclick?.toString().includes(type));});
|
||
// easier: re-render but preserve type
|
||
stopBreath();
|
||
document.querySelectorAll('.breath-type-btn').forEach(b=>b.classList.remove('active'));
|
||
const t=BREATH_TYPES[type];
|
||
[...document.querySelectorAll('.breath-type-btn')].find(b=>b.getAttribute('onclick')?.includes("'"+type+"'"))?.classList.add('active');
|
||
}
|
||
|
||
function toggleBreath(){
|
||
if(breathRunning){breathRunning=false;clearInterval(breathTimer);breathTimer=null;document.getElementById('breath-start-btn').textContent='재개';return;}
|
||
breathRunning=true;document.getElementById('breath-start-btn').textContent='일시정지';
|
||
const phases=BREATH_TYPES[breathType].phases;
|
||
if(!breathRemain){breathPhaseIdx=0;breathRemain=phases[0].sec;}
|
||
stepBreath(phases);
|
||
}
|
||
|
||
function stepBreath(phases){
|
||
const tick=()=>{
|
||
if(!breathRunning)return;
|
||
const phase=phases[breathPhaseIdx];
|
||
const circle=document.getElementById('breath-circle');
|
||
document.getElementById('breath-count').textContent=breathRemain;
|
||
document.getElementById('breath-phase-name').textContent=phase.name;
|
||
if(circle){
|
||
circle.classList.toggle('expand',phase.name==='들이쉬기'||phase.name==='참기');
|
||
circle.classList.toggle('contract',phase.name==='내쉬기');
|
||
}
|
||
breathRemain--;
|
||
if(breathRemain<=0){
|
||
breathPhaseIdx=(breathPhaseIdx+1)%phases.length;
|
||
if(breathPhaseIdx===0){breathCycles++;const d=document.getElementById('breath-cycles-display');if(d)d.textContent='사이클: '+breathCycles+'회';}
|
||
breathRemain=phases[breathPhaseIdx].sec;
|
||
}
|
||
};
|
||
tick();
|
||
breathTimer=setInterval(tick,1000);
|
||
}
|
||
|
||
function stopBreath(){
|
||
breathRunning=false;if(breathTimer){clearInterval(breathTimer);breathTimer=null;}
|
||
breathPhaseIdx=0;breathRemain=0;
|
||
const circle=document.getElementById('breath-circle');if(circle)circle.classList.remove('expand','contract');
|
||
const countEl=document.getElementById('breath-count');if(countEl)countEl.textContent='-';
|
||
const phaseName=document.getElementById('breath-phase-name');if(phaseName)phaseName.textContent='준비';
|
||
const btn=document.getElementById('breath-start-btn');if(btn)btn.textContent='시작';
|
||
}
|
||
|
||
// ── Chat ──────────────────────────────────────────────────────────────────────
|
||
function escHtml(s){return String(s ?? '').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||
|
||
function addMsg(role,html){
|
||
const msgs=document.getElementById('chat-msgs');
|
||
const div=document.createElement('div');div.className='mn-msg '+role;
|
||
const b=document.createElement('div');b.className='mn-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 style="border-left:3px solid var(--line);padding-left:10px;margin:4px 0;color:var(--muted)">$1</blockquote>')
|
||
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
|
||
.replace(/<p><\/p>/g,'').replace(/<p>(<h3>)/g,'$1').replace(/(<\/h3>)<\/p>/g,'$1')
|
||
.replace(/<p>(<pre>)/g,'$1').replace(/(<\/pre>)<\/p>/g,'$1')
|
||
.replace(/<p>(<blockquote)/g,'$1').replace(/(<\/blockquote>)<\/p>/g,'$1');
|
||
}
|
||
|
||
function sendToChat(message,statusMsg){
|
||
if(statusMsg)addMsg('system',escHtml(statusMsg));
|
||
callChat(message);
|
||
}
|
||
|
||
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);
|
||
// on mobile: open chat panel so user can see the response
|
||
if(window.innerWidth<=1024) mobToggle('chat');
|
||
addMsg('user',escHtml(msg));
|
||
await callChat(msg);
|
||
}
|
||
|
||
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='mn-msg assistant';
|
||
typingDiv.innerHTML='<div class="mn-typing"><div class="mn-dot"></div><div class="mn-dot"></div><div class="mn-dot"></div></div>';
|
||
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
|
||
let bubble=null,textBuf='',thinkBuf='';
|
||
try{
|
||
const sc=getSkillContext();
|
||
const body={message,sessionId:getSessionId(),useTools:true,
|
||
...(selectedModel?{model:selectedModel}:{}),
|
||
...(sc?{skillContext:sc}:{})};
|
||
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='mn-msg assistant';
|
||
bubble=document.createElement('div');bubble.className='mn-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="mn-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='전송';}
|
||
}
|
||
|
||
async function clearChat(){
|
||
if(!confirm('대화를 지울까요?'))return;
|
||
document.getElementById('chat-msgs').innerHTML='';
|
||
_sessionId='mn_'+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();
|
||
setMode(currentMode);
|
||
await Promise.all([loadSkills(),loadModels()]);
|
||
addMsg('system','🧠 마음 상담실에 오신 걸 환영합니다. 왼쪽 도구를 활용하거나 편하게 이야기해주세요.');
|
||
initAppVoice(sendMessage);
|
||
document.getElementById('chat-input').focus();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|