- 스킬앱 HTML 내장화: accountant/lawyer/investor/weather/music/mind/dental-agent 각 앱이 SKILL_CONTENT를 HTML에 직접 내장 → .smallclaw/skills/ SKILL.md 파일 제거 - 새 게이트웨이 라우트: routes-dental, routes-music, routes-mcp, routes-voice - skillContext 서버 지원: /api/chat에서 skill 컨텍스트를 callerCtx로 주입 - 세션 삭제 API: DELETE /api/admin/sessions/:username/:id - Telegram defaultUsername: 어드민 계정 자동 추론 - Ollama /api/ollama/models에 vision 지원 여부 플래그 추가 - DB 워크플로 개선: workflow_images.py 대규모 리팩터링 - 아두이노 에뮬레이터 추가 개선 (arduino-emulator.html +400줄) - 우즈베크어 학습 콘텐츠 및 MIDI 파일 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
757 lines
56 KiB
HTML
757 lines
56 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="cherry_logo.png">
|
|
<link rel="stylesheet" href="styles.css">
|
|
<script>try{var _t=localStorage.getItem('cherryclaw_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;}
|
|
.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);}
|
|
|
|
/* ── 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;}
|
|
|
|
/* ── 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>
|
|
</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 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-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="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;}return true;}
|
|
catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
|
|
}
|
|
|
|
// ── Theme ─────────────────────────────────────────────────────────────────────
|
|
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('cherryclaw_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 SID_KEY='mn_app_session';
|
|
function getSessionId(){try{let s=sessionStorage.getItem(SID_KEY);if(!s){s='mn_'+Math.random().toString(36).slice(2,12);sessionStorage.setItem(SID_KEY,s);}return s;}catch{return 'mn_main';}}
|
|
|
|
// ── Tools: show / hide ────────────────────────────────────────────────────────
|
|
let activeTool=null;
|
|
let breathTimer=null;
|
|
|
|
const toolTitles={
|
|
emotion:'😊 감정 체크인',
|
|
phq9:'📋 PHQ-9 우울증 선별 검사',
|
|
gad7:'📋 GAD-7 범불안장애 검사',
|
|
cbt:'✍️ 생각 기록지 (CBT)',
|
|
breath:'🌬️ 호흡 명상',
|
|
};
|
|
|
|
function showTool(name){
|
|
// 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'),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를 보는 것과 같은 일에 집중하기가 어려움',
|
|
'남들이 눈치 챌 정도로 너무 느리게 움직이거나 말을 함, 또는 반대로 너무 안절부절 못하거나 들떠 있음',
|
|
'스스로를 해치거나 자살하는 것이 낫겠다는 생각',
|
|
],
|
|
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:'심각한 우울 증상입니다. 즉시 전문가 상담을 받으시길 권합니다.'},
|
|
],
|
|
},
|
|
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:'심각한 불안 증상입니다. 전문가 상담이 필요합니다.'},
|
|
],
|
|
},
|
|
};
|
|
|
|
let testAnswers={};
|
|
|
|
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)=>`
|
|
<div class="q-item">
|
|
<div class="q-text">${i+1}. ${q}</div>
|
|
<div class="q-opts">
|
|
${test.scale.map((s,v)=>`<button class="q-opt" id="q${testId}_${i}_${v}" onclick="selectAnswer('${testId}',${i},${v})">${s}</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){
|
|
const test=TESTS[testId];
|
|
testAnswers[qIdx]=val;
|
|
// highlight
|
|
for(let v=0;v<4;v++){const b=document.getElementById(`q${testId}_${qIdx}_${v}`);if(b){b.classList.toggle('selected',v===val);}}
|
|
// progress
|
|
const done=Object.keys(testAnswers).length;
|
|
const total=test.questions.length;
|
|
document.getElementById('qprog').style.width=(done/total*100)+'%';
|
|
const footer=document.querySelector('.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;}
|
|
const score=Object.values(testAnswers).reduce((a,b)=>a+b,0);
|
|
const level=test.levels.find(l=>score<=l.max)||test.levels[test.levels.length-1];
|
|
const w=document.getElementById('center-widget');
|
|
const answers=test.questions.map((q,i)=>`${i+1}. ${q}: ${test.scale[testAnswers[i]]}`).join('\n');
|
|
w.innerHTML=`
|
|
<div class="widget">
|
|
<h2>${test.title} 결과</h2>
|
|
<div class="result-card ${level.cls}">
|
|
<div class="r-score">${score}점</div>
|
|
<div class="r-label">${level.label}</div>
|
|
<div class="r-desc">${level.desc}</div>
|
|
</div>
|
|
<div class="w-footer">
|
|
<button class="w-btn" onclick="sendToChat('[${test.title} 결과]\\n총점: ${score}점 — ${level.label}\\n\\n${level.desc}\\n\\n[각 문항 응답]\\n${answers}\\n\\n이 결과에 대한 설명과 다음 단계를 조언해주세요.')">AI에게 해석 요청</button>
|
|
<button class="w-btn secondary" onclick="renderQuestionnaire('${testId}')">다시 검사하기</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ── 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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
|
|
|
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;
|
|
div.appendChild(b);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);
|
|
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='전송';}
|
|
}
|
|
|
|
function clearChat(){
|
|
if(!confirm('대화를 지울까요?'))return;
|
|
document.getElementById('chat-msgs').innerHTML='';
|
|
try{sessionStorage.removeItem(SID_KEY);}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;
|
|
setMode(currentMode);
|
|
await Promise.all([loadSkills(),loadModels()]);
|
|
addMsg('system','🧠 마음 상담실에 오신 걸 환영합니다. 왼쪽 도구를 활용하거나 편하게 이야기해주세요.');
|
|
document.getElementById('chat-input').focus();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|