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

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

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

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

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

1328 lines
92 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🧠 마음 상담실</title>
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
/* ── Header ── */
.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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/\*(.+?)\*/g,'<em>$1</em>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
.replace(/^> (.+)$/gm,'<blockquote 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>