Files
homeclaw/web-ui/detective-app.html
T
kimandClaude Sonnet 4.6 d74614cee1 v3.2.0: 카카오톡 연동 + 탐정앱 업그레이드 + 모바일 대응 강화
- 카카오톡 채널 챗봇 (오픈빌더 비동기 콜백 방식, 5초 제한 우회)
- 카카오 "나에게 보내기" OAuth 알림 (kakao-notify.ts)
- 채널 설정 UI + userMap으로 다중 사용자 계정 매핑
- 탐정앱: 탭(메모/체크리스트/타임라인), 우선순위, 검색, 보고서/고소장/접수안내 생성
- 모바일 브레이크포인트 640px → 1024px (갤럭시 폴드 대응)
- 프롬프트 파일 제한 상향 (USER.md 3000, SOUL.md/AGENTS.md 4000)
- 교통앱 마지막 선택 지역 localStorage 저장/복원
- docs/kakao-setup.md 작성

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 11:15:27 +09:00

1181 lines
64 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="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 ── */
.dt-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
.dt-hdr h1{font-size:13px;font-weight:700;margin:0;}
.dt-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:#3b82f6;color:#3b82f6;}
.skill-badge{font-size:11px;padding:2px 8px;border-radius:12px;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.25);color:#60a5fa;font-weight:700;}
/* ── Layout ── */
.dt-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
/* ── Sidebar ── */
.dt-sb{width:200px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--line);background:var(--panel);min-height:0;}
.dt-sb-hdr{display:flex;align-items:center;padding:8px 10px 4px;gap:4px;flex-shrink:0;}
.dt-sb-title{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;flex:1;}
.dt-new-btn{background:#1e3a5f;border:none;border-radius:5px;padding:2px 8px;font-size:11px;color:#93c5fd;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;}
.dt-new-btn:hover{background:#1d4ed8;color:#fff;}
.dt-search-wrap{padding:0 8px 6px;flex-shrink:0;}
.dt-search{width:100%;box-sizing:border-box;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:5px 9px;font-size:11px;color:var(--text);font-family:inherit;outline:none;transition:.15s;}
.dt-search:focus{border-color:#3b82f6;}
.dt-search::placeholder{color:var(--muted);}
.dt-sort-bar{display:flex;align-items:center;padding:0 8px 4px;gap:4px;flex-shrink:0;}
.dt-sort-sel{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:2px 5px;font-size:10px;color:var(--muted);font-family:inherit;}
.dt-cases{flex:1;overflow-y:auto;padding:4px;}
.dt-cases::-webkit-scrollbar{width:3px;}
.dt-cases::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.dt-case-item{padding:7px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:2px;transition:.12s;position:relative;}
.dt-case-item:hover{background:rgba(59,130,246,.05);border-color:rgba(59,130,246,.2);}
.dt-case-item.active{background:rgba(59,130,246,.1);border-color:rgba(59,130,246,.35);}
.dt-case-title{font-size:12px;font-weight:600;color:var(--text);margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:18px;}
.dt-case-meta{font-size:10px;color:var(--muted);display:flex;align-items:center;gap:4px;flex-wrap:wrap;}
.dt-case-del{position:absolute;top:6px;right:6px;background:none;border:none;cursor:pointer;font-size:12px;color:var(--muted);opacity:0;transition:.12s;padding:1px 3px;}
.dt-case-item:hover .dt-case-del{opacity:.6;}
.dt-case-del:hover{opacity:1 !important;color:#ef4444;}
.dt-status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:2px;vertical-align:middle;flex-shrink:0;}
.s-active{background:#22c55e;}.s-done{background:#3b82f6;}.s-hold{background:#f59e0b;}
.dt-pri-dot{display:inline-block;width:5px;height:5px;border-radius:50%;vertical-align:middle;flex-shrink:0;}
.p-urgent{background:#ef4444;}.p-high{background:#f97316;}.p-normal{background:#6b7280;}.p-low{background:#374151;}
.dt-sb-divider{height:1px;background:var(--line);margin:6px 8px;}
.dt-quick-sec{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:4px 10px;}
.dt-quick{padding:0 6px 6px;display:flex;flex-direction:column;gap:2px;}
.dt-qbtn{background:none;border:1px solid var(--line);border-radius:6px;padding:5px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left;transition:.15s;line-height:1.4;}
.dt-qbtn:hover{border-color:#3b82f6;color:#3b82f6;background:rgba(59,130,246,.05);}
/* ── Center ── */
.dt-center{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;background:var(--bg);}
.dt-center-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:12px;padding:40px;}
.dt-center-empty .icon{font-size:48px;opacity:.25;}
.dt-center-empty p{font-size:12px;line-height:1.7;text-align:center;max-width:260px;}
.dt-case-hdr{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;flex-wrap:wrap;}
.dt-case-name-input{flex:1;min-width:120px;background:transparent;border:none;font-size:14px;font-weight:700;color:var(--text);font-family:inherit;outline:none;}
.dt-case-name-input::placeholder{color:var(--muted);}
.dt-status-sel{background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 5px;font-size:11px;color:var(--text);font-family:inherit;}
.dt-pri-sel{background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 5px;font-size:11px;color:var(--text);font-family:inherit;}
.dt-hdr-btns{display:flex;gap:5px;align-items:center;flex-shrink:0;}
.dt-ask-ai-btn{background:#1e3a5f;border:none;border-radius:6px;padding:4px 9px;font-size:11px;color:#93c5fd;cursor:pointer;font-family:inherit;font-weight:700;white-space:nowrap;transition:.15s;}
.dt-ask-ai-btn:hover{background:#1d4ed8;color:#fff;}
.dt-report-btn{background:rgba(139,92,246,.15);border:1px solid rgba(139,92,246,.3);border-radius:6px;padding:4px 9px;font-size:11px;color:#a78bfa;cursor:pointer;font-family:inherit;font-weight:700;white-space:nowrap;transition:.15s;}
.dt-report-btn:hover{background:rgba(139,92,246,.3);color:#fff;}
.dt-complaint-btn{background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);border-radius:6px;padding:4px 9px;font-size:11px;color:#f87171;cursor:pointer;font-family:inherit;font-weight:700;white-space:nowrap;transition:.15s;}
.dt-complaint-btn:hover{background:rgba(239,68,68,.25);color:#fff;}
.dt-submit-btn{background:rgba(16,185,129,.1);border:1px solid rgba(16,185,129,.3);border-radius:6px;padding:4px 9px;font-size:11px;color:#34d399;cursor:pointer;font-family:inherit;font-weight:700;white-space:nowrap;transition:.15s;}
.dt-submit-btn:hover{background:rgba(16,185,129,.25);color:#fff;}
.dt-save-indicator{font-size:10px;color:var(--muted);transition:.3s;flex-shrink:0;}
/* ── Tabs ── */
.dt-tabs{display:flex;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;padding:0 14px;}
.dt-tab-btn{background:none;border:none;border-bottom:2px solid transparent;padding:7px 12px;font-size:12px;font-weight:600;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;margin-bottom:-1px;}
.dt-tab-btn.active{color:#3b82f6;border-bottom-color:#3b82f6;}
.dt-tab-btn:hover:not(.active){color:var(--text);}
.dt-tab-content{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden;}
.dt-tab-pane{flex:1;display:none;flex-direction:column;min-height:0;overflow:hidden;}
.dt-tab-pane.active{display:flex;}
/* ── Notes tab ── */
.dt-notes-area{flex:1;display:flex;flex-direction:column;padding:12px 14px;gap:6px;min-height:0;}
.dt-notes-label{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
.dt-notes-textarea{flex:1;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:12px;color:var(--text);font-family:inherit;resize:none;outline:none;line-height:1.7;transition:.15s;}
.dt-notes-textarea:focus{border-color:#3b82f6;}
.dt-notes-textarea::placeholder{color:var(--muted);}
/* ── Checklist tab ── */
.dt-cl-wrap{flex:1;display:flex;flex-direction:column;min-height:0;padding:12px 14px;gap:8px;}
.dt-cl-add-bar{display:flex;gap:6px;flex-shrink:0;}
.dt-cl-input{flex:1;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:7px 11px;font-size:12px;color:var(--text);font-family:inherit;outline:none;transition:.15s;}
.dt-cl-input:focus{border-color:#3b82f6;}
.dt-cl-input::placeholder{color:var(--muted);}
.dt-cl-add-btn{background:#1e3a5f;border:none;border-radius:7px;padding:7px 13px;font-size:12px;color:#93c5fd;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
.dt-cl-add-btn:hover{background:#1d4ed8;color:#fff;}
.dt-cl-ai-btn{background:rgba(139,92,246,.15);border:1px solid rgba(139,92,246,.3);border-radius:7px;padding:7px 11px;font-size:11px;color:#a78bfa;cursor:pointer;font-family:inherit;font-weight:700;flex-shrink:0;transition:.15s;}
.dt-cl-ai-btn:hover{background:rgba(139,92,246,.3);color:#fff;}
.dt-cl-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:3px;}
.dt-cl-list::-webkit-scrollbar{width:3px;}
.dt-cl-list::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.dt-cl-item{display:flex;align-items:flex-start;gap:8px;padding:7px 10px;border-radius:6px;border:1px solid var(--line);background:var(--panel);transition:.12s;group:1;}
.dt-cl-item:hover{border-color:rgba(59,130,246,.25);}
.dt-cl-item.done{opacity:.55;}
.dt-cl-cb{width:16px;height:16px;border-radius:4px;border:1.5px solid var(--line);cursor:pointer;flex-shrink:0;margin-top:1px;display:flex;align-items:center;justify-content:center;font-size:10px;transition:.15s;background:transparent;}
.dt-cl-cb.checked{background:#22c55e;border-color:#22c55e;color:#fff;}
.dt-cl-text{flex:1;font-size:12px;line-height:1.5;color:var(--text);word-break:break-word;}
.dt-cl-item.done .dt-cl-text{text-decoration:line-through;color:var(--muted);}
.dt-cl-del{background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);padding:1px 4px;opacity:0;transition:.12s;flex-shrink:0;}
.dt-cl-item:hover .dt-cl-del{opacity:.5;}
.dt-cl-del:hover{opacity:1 !important;color:#ef4444;}
.dt-cl-progress{display:flex;align-items:center;gap:8px;flex-shrink:0;}
.dt-cl-prog-bar{flex:1;height:4px;background:var(--line);border-radius:2px;overflow:hidden;}
.dt-cl-prog-fill{height:100%;background:#22c55e;border-radius:2px;transition:.3s;}
.dt-cl-prog-text{font-size:10px;color:var(--muted);flex-shrink:0;}
.dt-cl-empty{text-align:center;color:var(--muted);font-size:12px;padding:30px 0;}
/* ── Timeline tab ── */
.dt-tl-wrap{flex:1;display:flex;flex-direction:column;min-height:0;padding:12px 14px;gap:8px;}
.dt-tl-add-bar{display:flex;gap:6px;flex-shrink:0;align-items:center;}
.dt-tl-date{background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:7px 9px;font-size:12px;color:var(--text);font-family:inherit;outline:none;width:130px;flex-shrink:0;transition:.15s;}
.dt-tl-date:focus{border-color:#3b82f6;}
.dt-tl-text-input{flex:1;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:7px 11px;font-size:12px;color:var(--text);font-family:inherit;outline:none;transition:.15s;}
.dt-tl-text-input:focus{border-color:#3b82f6;}
.dt-tl-text-input::placeholder{color:var(--muted);}
.dt-tl-add-btn{background:#1e3a5f;border:none;border-radius:7px;padding:7px 13px;font-size:12px;color:#93c5fd;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
.dt-tl-add-btn:hover{background:#1d4ed8;color:#fff;}
.dt-tl-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:0;}
.dt-tl-list::-webkit-scrollbar{width:3px;}
.dt-tl-list::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.dt-tl-item{display:flex;gap:10px;position:relative;padding-bottom:12px;}
.dt-tl-line{display:flex;flex-direction:column;align-items:center;flex-shrink:0;width:20px;}
.dt-tl-dot{width:10px;height:10px;border-radius:50%;background:#3b82f6;border:2px solid var(--bg);flex-shrink:0;margin-top:2px;}
.dt-tl-rod{flex:1;width:2px;background:var(--line);margin-top:2px;}
.dt-tl-item:last-child .dt-tl-rod{display:none;}
.dt-tl-body{flex:1;padding-top:0;}
.dt-tl-date-lbl{font-size:10px;font-weight:700;color:#60a5fa;margin-bottom:2px;}
.dt-tl-txt{font-size:12px;color:var(--text);line-height:1.5;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:6px 10px;position:relative;}
.dt-tl-del{position:absolute;top:4px;right:6px;background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);opacity:0;transition:.12s;padding:1px 3px;}
.dt-tl-txt:hover .dt-tl-del{opacity:.5;}
.dt-tl-del:hover{opacity:1 !important;color:#ef4444;}
.dt-tl-empty{text-align:center;color:var(--muted);font-size:12px;padding:30px 0;}
/* ── Resize ── */
.dt-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;}
.dt-resize:hover,.dt-resize.dragging{background:#3b82f6;}
/* ── Chat ── */
.dt-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;}
.dt-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex-shrink:0;gap:6px;}
.dt-chat-label{font-size:10px;font-weight:700;color:#60a5fa;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
.dt-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;}
.dt-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;}
.dt-chat-clear:hover{color:#ef4444;}
.dt-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
.dt-msgs::-webkit-scrollbar{width:3px;}
.dt-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.dt-msg{display:flex;flex-direction:column;}
.dt-msg.user{align-items:flex-end;}
.dt-msg.assistant{align-items:flex-start;}
.dt-msg.system{align-items:center;}
.dt-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:93%;word-break:break-word;}
.dt-msg.user .dt-bubble{background:#1e3a5f;color:#bfdbfe;border-radius:10px 10px 3px 10px;}
.dt-msg.assistant .dt-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
.dt-msg.system .dt-bubble{background:transparent;color:var(--muted);font-size:11px;}
.dt-bubble p{margin:3px 0;}
.dt-bubble h3{font-size:13px;margin:6px 0 3px;}
.dt-bubble ul,.dt-bubble ol{padding-left:16px;margin:3px 0;}
.dt-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
.dt-bubble strong{font-weight:700;}
.dt-bubble table{border-collapse:collapse;font-size:11px;margin:6px 0;width:100%;}
.dt-bubble th,.dt-bubble td{border:1px solid var(--line);padding:3px 7px;text-align:left;}
.dt-bubble th{background:var(--panel);font-weight:700;}
.dt-bubble blockquote{border-left:3px solid var(--line);padding-left:10px;margin:4px 0;color:var(--muted);}
.dt-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;}
.dt-thinking.exp{display:block;-webkit-line-clamp:unset;max-height:200px;overflow-y:auto;}
.dt-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
.dt-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:dtDot 1.2s ease-in-out infinite;}
.dt-dot:nth-child(2){animation-delay:.2s;}.dt-dot:nth-child(3){animation-delay:.4s;}
@keyframes dtDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
.dt-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
.dt-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;}
.dt-textarea:focus{border-color:#3b82f6;}
.dt-send{background:#1e3a5f;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;}
.dt-send:hover:not(:disabled){background:#1d4ed8;}
.dt-send:disabled{opacity:.5;cursor:not-allowed;}
/* ── Mobile ── */
@media(max-width:1024px){
.dt-resize{display:none;}
.dt-sb{position:fixed;top:0;left:0;bottom:0;z-index:500;width:82vw;max-width:290px;transform:translateX(-100%);transition:transform .25s ease;box-shadow:4px 0 24px rgba(0,0,0,.4);}
.dt-sb.mob-open{transform:translateX(0);}
.dt-chat{position:fixed;top:0;right:0;bottom:0;z-index:500;width:94vw;max-width:390px;transform:translateX(100%);transition:transform .25s ease;box-shadow:-4px 0 24px rgba(0,0,0,.4);}
.dt-chat.mob-open{transform:translateX(0);}
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:499;}
#mob-backdrop.show{display:block;}
.dt-center{width:100%;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:400;background:var(--panel);border-top:1px solid var(--line);height:52px;}
.dt-textarea,input,textarea{font-size:16px !important;}
.dt-case-hdr{flex-wrap:wrap;}
.dt-hdr-btns{width:100%;justify-content:flex-end;}
}
@media(min-width:1025px){
#mob-tabbar{display:none !important;}
#mob-backdrop{display:none !important;}
}
</style>
</head>
<body>
<header class="dt-hdr">
<a href="/" class="hdr-btn">← 홈</a>
<h1>🔍 탐정 어시스턴트</h1>
<span class="skill-badge" id="skill-badge">스킬 로딩 중...</span>
<div class="dt-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-cases" onclick="mobToggle('cases')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/></svg>
<span>사건</span>
</button>
<button class="mob-tab" id="mobtab-home" onclick="mobToggle('home')" style="color:var(--brand,#3b82f6);border-top:2px solid var(--brand,#3b82f6)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></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="dt-layout">
<!-- ── Sidebar ── -->
<aside class="dt-sb" id="dt-sb">
<div class="dt-sb-hdr">
<span class="dt-sb-title">사건 목록</span>
<button class="dt-new-btn" onclick="newCase()">+ 새 사건</button>
</div>
<div class="dt-search-wrap">
<input class="dt-search" id="case-search" type="text" placeholder="🔍 사건 검색..." oninput="renderCaseList()">
</div>
<div class="dt-sort-bar">
<select class="dt-sort-sel" id="case-sort" onchange="renderCaseList()">
<option value="date-desc">최신순</option>
<option value="date-asc">오래된순</option>
<option value="title">이름순</option>
<option value="status">상태순</option>
<option value="priority">우선순위순</option>
</select>
</div>
<div class="dt-cases" id="case-list"></div>
<div class="dt-sb-divider"></div>
<div class="dt-quick-sec">빠른 조사</div>
<div class="dt-quick">
<button class="dt-qbtn" onclick="sendQuick('인물 조사 방법론을 알려주세요. 공개 정보를 활용해 특정 인물의 이력, 소속, 활동을 파악하는 단계별 절차가 궁금합니다.')">👤 인물 조사 방법</button>
<button class="dt-qbtn" onclick="sendQuick('기업 실사(Due Diligence) 체크리스트를 알려주세요. 계약 전 상대 회사의 신뢰성과 재무 건전성을 확인하는 방법이 궁금합니다.')">🏢 기업 실사 체크리스트</button>
<button class="dt-qbtn" onclick="sendQuick('온라인 사기 피해를 입었습니다. 증거 수집 방법과 신고 절차를 알려주세요.')">🚨 온라인 사기 대응</button>
<button class="dt-qbtn" onclick="sendQuick('SNS·인터넷에서 허위 정보나 루머가 퍼지고 있습니다. 출처 추적과 대응 방법을 알려주세요.')">📱 허위정보 추적</button>
<button class="dt-qbtn" onclick="sendQuick('디지털 발자국(Digital Footprint) 조사 방법을 알려주세요. SNS, 이메일, 도메인, IP 기반 공개 정보 추적 절차를 설명해 주세요.')">🌐 디지털 발자국 추적</button>
<button class="dt-qbtn" onclick="sendQuick('부동산 사기 유형과 사기 여부를 판별하는 방법을 알려주세요. 등기부등본 확인, 임대인 신원 확인, 이중계약 체크 방법도 포함해 주세요.')">🏠 부동산 사기 체크</button>
<button class="dt-qbtn" onclick="sendQuick('현재 사건의 단서와 증거를 바탕으로 가설을 세우고 조사 방향을 잡아주세요.',true)">🗂 현재 사건 분석 요청</button>
</div>
<div class="dt-sb-divider"></div>
<div class="dt-quick-sec">⚖️ 법률 질문</div>
<div class="dt-quick">
<button class="dt-qbtn" onclick="sendQuick('사기죄(형법 제347조) 성립 요건을 알려주세요. 판례 기준으로 기망행위·재산상 이익·인과관계 각각 어떻게 판단하는지 설명해 주세요. 관련 대법원 판례도 찾아주세요.')">📌 사기죄 성립 요건</button>
<button class="dt-qbtn" onclick="sendQuick('민사소송과 형사고소의 차이점을 알려주세요. 같은 피해에 대해 동시에 진행할 수 있는지, 어떤 경우에 어느 쪽이 유리한지도 설명해 주세요.')">⚖️ 민사 vs 형사 선택</button>
<button class="dt-qbtn" onclick="sendQuick('내용증명 작성 방법과 법적 효력을 알려주세요. 어떤 상황에서 보내야 하고, 보낸 후 상대방이 무시하면 어떻게 대응해야 하는지도 알려주세요.')">📮 내용증명 작성·효력</button>
<button class="dt-qbtn" onclick="sendQuick('명예훼손죄와 모욕죄의 차이를 알려주세요. 온라인(SNS, 카카오톡 단체방 등)에서 발생한 경우 성립 요건과 처벌 수위, 증거 수집 방법도 알려주세요. 관련 판례를 찾아주세요.')">🗣 명예훼손·모욕죄</button>
<button class="dt-qbtn" onclick="sendQuick('계약 해지·위약금 관련 법률을 알려주세요. 상대방이 계약을 이행하지 않을 때 해지 통보 방법, 위약금 청구 절차, 관련 민법 조항과 판례를 설명해 주세요.')">📝 계약 해지·위약금</button>
<button class="dt-qbtn" onclick="sendQuick('임금 체불 피해를 받았습니다. 근로기준법상 임금 체불의 법적 정의, 고소 절차, 체당금 신청 방법, 사업주 처벌 수위를 알려주세요. 관련 법령도 찾아주세요.')">💼 임금 체불 대응</button>
<button class="dt-qbtn" onclick="sendQuick('현재 사건에 적용 가능한 법령과 판례를 조사해서 법적 대응 방향을 제시해주세요.',true)">🔍 현재 사건 법률 검토</button>
</div>
</aside>
<!-- ── Center: Case notepad ── -->
<section class="dt-center" id="dt-center">
<div class="dt-center-empty" id="center-empty">
<div class="icon">🔍</div>
<p>좌측에서 사건을 선택하거나<br><strong>새 사건</strong>을 만들어 단서와 메모를<br>기록하세요.</p>
</div>
<div id="center-case" style="display:none;flex:1;flex-direction:column;min-height:0;">
<!-- Case header -->
<div class="dt-case-hdr">
<input class="dt-case-name-input" id="case-name-input" placeholder="사건명 입력..." oninput="onCaseNameChange()" onblur="saveCases()">
<select class="dt-status-sel" id="case-status-sel" onchange="onStatusChange()">
<option value="조사중">🟢 조사중</option>
<option value="완료">🔵 완료</option>
<option value="보류">🟡 보류</option>
</select>
<select class="dt-pri-sel" id="case-pri-sel" onchange="onPriChange()">
<option value="normal">⚪ 보통</option>
<option value="urgent">🔴 긴급</option>
<option value="high">🟠 높음</option>
<option value="low">⬛ 낮음</option>
</select>
<div class="dt-hdr-btns">
<button class="dt-ask-ai-btn" onclick="askAiAboutCase()">💬 분석</button>
<button class="dt-report-btn" onclick="generateReport()">📄 보고서</button>
<button class="dt-complaint-btn" onclick="generateComplaint()">⚖️ 고소장</button>
<button class="dt-submit-btn" onclick="guideSubmission()">📮 접수 안내</button>
<span class="dt-save-indicator" id="save-indicator"></span>
</div>
</div>
<!-- Tabs -->
<div class="dt-tabs">
<button class="dt-tab-btn active" onclick="switchTab('notes')" id="tab-notes">📝 메모</button>
<button class="dt-tab-btn" onclick="switchTab('checklist')" id="tab-checklist">✅ 체크리스트</button>
<button class="dt-tab-btn" onclick="switchTab('timeline')" id="tab-timeline">📅 타임라인</button>
</div>
<div class="dt-tab-content">
<!-- Notes pane -->
<div class="dt-tab-pane active" id="pane-notes">
<div class="dt-notes-area">
<span class="dt-notes-label">단서 · 증거 · 메모</span>
<textarea class="dt-notes-textarea" id="case-notes" placeholder="조사 대상, 확인된 사실, 의심 정황, 수집한 단서 등을 자유롭게 기록하세요.
예시:
- 조사 대상: 홍길동 (대표이사, ○○ 주식회사)
- 확인된 사실: 2024년 법인 설립, 주소지 ○○동
- 의심 정황: SNS에서 확인되지 않은 수익 주장
- 수집 단서: 사업자등록증 번호, 전화번호 ○○○" oninput="onNotesChange()" onblur="saveCases()"></textarea>
</div>
</div>
<!-- Checklist pane -->
<div class="dt-tab-pane" id="pane-checklist">
<div class="dt-cl-wrap">
<div class="dt-cl-add-bar">
<input class="dt-cl-input" id="cl-input" type="text" placeholder="조사 항목 추가..." onkeydown="if(event.key==='Enter')addCheckItem()">
<button class="dt-cl-add-btn" onclick="addCheckItem()">추가</button>
<button class="dt-cl-ai-btn" onclick="generateChecklist()" title="AI로 체크리스트 자동 생성">✨ AI</button>
</div>
<div class="dt-cl-progress" id="cl-progress" style="display:none">
<div class="dt-cl-prog-bar"><div class="dt-cl-prog-fill" id="cl-prog-fill"></div></div>
<span class="dt-cl-prog-text" id="cl-prog-text">0/0</span>
</div>
<div class="dt-cl-list" id="cl-list"></div>
</div>
</div>
<!-- Timeline pane -->
<div class="dt-tab-pane" id="pane-timeline">
<div class="dt-tl-wrap">
<div class="dt-tl-add-bar">
<input class="dt-tl-date" id="tl-date" type="date">
<input class="dt-tl-text-input" id="tl-input" type="text" placeholder="이벤트 내용 입력..." onkeydown="if(event.key==='Enter')addTimelineEvent()">
<button class="dt-tl-add-btn" onclick="addTimelineEvent()">추가</button>
</div>
<div class="dt-tl-list" id="tl-list"></div>
</div>
</div>
</div>
</div>
</section>
<!-- ── Resize ── -->
<div class="dt-resize" id="resize-handle"></div>
<!-- ── Chat ── -->
<div class="dt-chat" id="chat-panel">
<div class="dt-chat-hdr">
<span class="dt-chat-label">🔍 탐정 AI</span>
<select class="dt-model-sel" id="model-sel" onchange="saveModel()">
<option value="">기본 모델</option>
</select>
<button class="dt-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
</div>
<div class="dt-msgs" id="chat-msgs"></div>
<div class="dt-input-bar">
<textarea class="dt-textarea" id="chat-input" rows="1" placeholder="조사 요청, 인물·기업 분석, 단서 해석..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<button class="dt-send" id="send-btn" onclick="sendMessage()">전송</button>
</div>
</div>
</div>
<script>
// ── Auth ──────────────────────────────────────────────────────────────────────
const TOKEN_KEY='smallclaw_token';
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
async function checkAuth(){
if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}const d=await r.json();if(!d.authenticated){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;}
catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
}
// ── Theme ─────────────────────────────────────────────────────────────────────
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('cherryclaw_theme',n);}catch{}}
// ── Mobile ────────────────────────────────────────────────────────────────────
function mobCloseAll(){
document.getElementById('dt-sb').classList.remove('mob-open');
document.getElementById('chat-panel').classList.remove('mob-open');
document.getElementById('mob-backdrop').classList.remove('show');
['cases','chat'].forEach(t=>document.getElementById('mobtab-'+t)?.classList.remove('active'));
document.getElementById('mobtab-home').style.cssText='color:var(--brand,#3b82f6);border-top:2px solid var(--brand,#3b82f6)';
}
function mobToggle(which){
const sb=document.getElementById('dt-sb');
const cp=document.getElementById('chat-panel');
const bd=document.getElementById('mob-backdrop');
if(which==='home'){mobCloseAll();return;}
const sbOpen=sb.classList.contains('mob-open');
const cpOpen=cp.classList.contains('mob-open');
mobCloseAll();
if(which==='cases'&&!sbOpen){sb.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-cases').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
if(which==='chat'&&!cpOpen){cp.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-chat').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
}
// ── Resize ────────────────────────────────────────────────────────────────────
(function(){
const CHAT_W='dt_chat_width';
const handle=document.getElementById('resize-handle');
const panel=document.getElementById('chat-panel');
let dragging=false,startX=0,startW=0;
panel.style.width=(parseInt(localStorage.getItem(CHAT_W)||'340',10))+'px';
handle.addEventListener('mousedown',e=>{dragging=true;startX=e.clientX;startW=panel.offsetWidth;handle.classList.add('dragging');document.body.style.userSelect='none';document.body.style.cursor='col-resize';e.preventDefault();});
document.addEventListener('mousemove',e=>{if(!dragging)return;const nw=Math.max(260,Math.min(600,startW+(startX-e.clientX)));panel.style.width=nw+'px';});
document.addEventListener('mouseup',()=>{if(!dragging)return;dragging=false;handle.classList.remove('dragging');document.body.style.userSelect='';document.body.style.cursor='';try{localStorage.setItem(CHAT_W,panel.offsetWidth);}catch{};});
})();
// ── Tabs ──────────────────────────────────────────────────────────────────────
let activeTab='notes';
function switchTab(tab){
activeTab=tab;
['notes','checklist','timeline'].forEach(t=>{
document.getElementById('tab-'+t)?.classList.toggle('active',t===tab);
document.getElementById('pane-'+t)?.classList.toggle('active',t===tab);
});
if(tab==='checklist')renderChecklist();
if(tab==='timeline')renderTimeline();
}
// ── Cases ─────────────────────────────────────────────────────────────────────
let cases=[];
let currentCaseId=null;
let saveTimer=null;
function genId(){return 'case_'+Math.random().toString(36).slice(2,10);}
function normalizeCase(c){
if(!c.checklist)c.checklist=[];
if(!c.timeline)c.timeline=[];
if(!c.priority)c.priority='normal';
return c;
}
async function loadCases(){
try{
const r=await fetch('/api/detective/cases',{headers:authH()});
if(r.ok){const d=await r.json();if(Array.isArray(d.cases))cases=d.cases.map(normalizeCase);}
}catch{}
renderCaseList();
}
async function saveCases(){
updateCurrentCaseFromDom();
try{
await fetch('/api/detective/cases',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({cases})});
showSaveIndicator('저장됨');
}catch{showSaveIndicator('저장 실패');}
}
function showSaveIndicator(msg){
const el=document.getElementById('save-indicator');
if(!el)return;
el.textContent=msg;
clearTimeout(saveTimer);
saveTimer=setTimeout(()=>{el.textContent='';},2000);
}
function updateCurrentCaseFromDom(){
if(!currentCaseId)return;
const c=cases.find(x=>x.id===currentCaseId);
if(!c)return;
c.title=document.getElementById('case-name-input').value||'제목 없음';
c.status=document.getElementById('case-status-sel').value;
c.priority=document.getElementById('case-pri-sel').value;
c.notes=document.getElementById('case-notes').value;
}
const PRI_ORDER={urgent:0,high:1,normal:2,low:3};
const STATUS_ORDER={'조사중':0,'보류':1,'완료':2};
function getSortedCases(){
const q=document.getElementById('case-search')?.value?.toLowerCase()||'';
const sort=document.getElementById('case-sort')?.value||'date-desc';
let list=[...cases];
if(q)list=list.filter(c=>(c.title||'').toLowerCase().includes(q)||(c.notes||'').toLowerCase().includes(q));
list.sort((a,b)=>{
if(sort==='date-desc')return new Date(b.createdAt||0)-new Date(a.createdAt||0);
if(sort==='date-asc')return new Date(a.createdAt||0)-new Date(b.createdAt||0);
if(sort==='title')return (a.title||'').localeCompare(b.title||'','ko');
if(sort==='status')return (STATUS_ORDER[a.status]??9)-(STATUS_ORDER[b.status]??9);
if(sort==='priority')return (PRI_ORDER[a.priority]??9)-(PRI_ORDER[b.priority]??9);
return 0;
});
return list;
}
function renderCaseList(){
const list=document.getElementById('case-list');
const sorted=getSortedCases();
if(!sorted.length){
list.innerHTML='<div style="padding:10px;font-size:11px;color:var(--muted);text-align:center">사건이 없습니다.</div>';
return;
}
list.innerHTML=sorted.map(c=>{
const dotCls=c.status==='완료'?'s-done':c.status==='보류'?'s-hold':'s-active';
const priCls='p-'+(c.priority||'normal');
const priLabel={urgent:'긴급',high:'높음',normal:'',low:'낮음'}[c.priority||'normal'];
const isActive=c.id===currentCaseId;
const date=c.createdAt?new Date(c.createdAt).toLocaleDateString('ko',{month:'short',day:'numeric'}):'';
const clDone=(c.checklist||[]).filter(x=>x.done).length;
const clTotal=(c.checklist||[]).length;
return `<div class="dt-case-item${isActive?' active':''}" onclick="selectCase('${c.id}')">
<div class="dt-case-title">${escHtml(c.title||'제목 없음')}</div>
<div class="dt-case-meta">
<span class="dt-status-dot ${dotCls}"></span>${escHtml(c.status||'조사중')}
${priLabel?`<span class="dt-pri-dot ${priCls}"></span>${priLabel}`:''}
${date?'· '+date:''}
${clTotal?`<span style="color:var(--muted)">✅${clDone}/${clTotal}</span>`:''}
</div>
<button class="dt-case-del" onclick="event.stopPropagation();deleteCase('${c.id}')" title="삭제">✕</button>
</div>`;
}).join('');
}
function selectCase(id){
updateCurrentCaseFromDom();
currentCaseId=id;
const c=cases.find(x=>x.id===id);
if(!c)return;
document.getElementById('case-name-input').value=c.title||'';
document.getElementById('case-status-sel').value=c.status||'조사중';
document.getElementById('case-pri-sel').value=c.priority||'normal';
document.getElementById('case-notes').value=c.notes||'';
document.getElementById('center-empty').style.display='none';
const cc=document.getElementById('center-case');
cc.style.display='flex';cc.style.flexDirection='column';cc.style.flex='1';cc.style.minHeight='0';
renderCaseList();
if(activeTab==='checklist')renderChecklist();
if(activeTab==='timeline')renderTimeline();
if(window.innerWidth<=640)mobCloseAll();
}
function newCase(){
const c=normalizeCase({id:genId(),title:'새 사건',status:'조사중',notes:'',createdAt:new Date().toISOString()});
cases.unshift(c);
selectCase(c.id);
saveCases();
setTimeout(()=>{const el=document.getElementById('case-name-input');if(el){el.focus();el.select();}},50);
}
async function deleteCase(id){
if(!confirm('이 사건을 삭제할까요?'))return;
cases=cases.filter(c=>c.id!==id);
if(currentCaseId===id){
currentCaseId=null;
document.getElementById('center-empty').style.display='';
document.getElementById('center-case').style.display='none';
}
await saveCases();
renderCaseList();
}
function onCaseNameChange(){
const c=cases.find(x=>x.id===currentCaseId);
if(c)c.title=document.getElementById('case-name-input').value;
renderCaseList();
}
function onStatusChange(){
const c=cases.find(x=>x.id===currentCaseId);
if(c)c.status=document.getElementById('case-status-sel').value;
renderCaseList();saveCases();
}
function onPriChange(){
const c=cases.find(x=>x.id===currentCaseId);
if(c)c.priority=document.getElementById('case-pri-sel').value;
renderCaseList();saveCases();
}
function onNotesChange(){/* auto-save on blur */}
// ── Checklist ──────────────────────────────────────────────────────────────────
function currentCase(){return cases.find(x=>x.id===currentCaseId);}
function renderChecklist(){
const c=currentCase();
const list=document.getElementById('cl-list');
const prog=document.getElementById('cl-progress');
if(!c){list.innerHTML='';prog.style.display='none';return;}
const items=c.checklist||[];
const done=items.filter(x=>x.done).length;
if(items.length){
prog.style.display='flex';
document.getElementById('cl-prog-fill').style.width=(done/items.length*100)+'%';
document.getElementById('cl-prog-text').textContent=done+'/'+items.length;
}else{prog.style.display='none';}
if(!items.length){
list.innerHTML='<div class="dt-cl-empty">조사 항목을 추가하거나<br>AI 자동 생성을 이용하세요.</div>';
return;
}
list.innerHTML=items.map(item=>`
<div class="dt-cl-item${item.done?' done':''}" id="cli-${item.id}">
<div class="dt-cl-cb${item.done?' checked':''}" onclick="toggleCheck('${item.id}')">${item.done?'✓':''}</div>
<div class="dt-cl-text">${escHtml(item.text)}</div>
<button class="dt-cl-del" onclick="deleteCheck('${item.id}')">✕</button>
</div>
`).join('');
}
function addCheckItem(){
const inp=document.getElementById('cl-input');
const text=inp.value.trim();
if(!text||!currentCaseId)return;
const c=currentCase();
if(!c)return;
if(!c.checklist)c.checklist=[];
c.checklist.push({id:'cl_'+Math.random().toString(36).slice(2,8),text,done:false});
inp.value='';
renderChecklist();renderCaseList();saveCases();
}
function toggleCheck(id){
const c=currentCase();if(!c)return;
const item=(c.checklist||[]).find(x=>x.id===id);
if(item)item.done=!item.done;
renderChecklist();renderCaseList();saveCases();
}
function deleteCheck(id){
const c=currentCase();if(!c)return;
c.checklist=(c.checklist||[]).filter(x=>x.id!==id);
renderChecklist();renderCaseList();saveCases();
}
async function generateChecklist(){
const c=currentCase();
const notes=document.getElementById('case-notes')?.value||'';
if(!c&&!notes){alert('먼저 메모 탭에 사건 정보를 입력하세요.');return;}
const title=c?.title||'현재 사건';
const prompt=`다음 사건의 조사 체크리스트를 만들어주세요. 각 항목을 "- " 로 시작하는 목록 형식으로 작성해 주세요. (최대 15개)
사건명: ${title}
사건 내용:
${notes||'(내용 없음)'}
체크리스트 항목만 줄바꿈으로 나열해주세요. 설명 없이 항목만 출력.`;
addMsg('user',escHtml('✨ AI 체크리스트 자동 생성 중...'));
if(window.innerWidth<=640)mobToggle('chat');
const result=await callChatRaw(prompt);
if(result){
const items=result.split('\n').filter(l=>l.trim().startsWith('-')).map(l=>l.replace(/^-\s*/,'').trim()).filter(Boolean);
if(items.length){
if(!c.checklist)c.checklist=[];
items.forEach(text=>c.checklist.push({id:'cl_'+Math.random().toString(36).slice(2,8),text,done:false}));
renderChecklist();renderCaseList();saveCases();
addMsg('system',`✅ ${items.length}개 항목이 체크리스트에 추가되었습니다.`);
}
}
}
// ── Timeline ──────────────────────────────────────────────────────────────────
function renderTimeline(){
const c=currentCase();
const list=document.getElementById('tl-list');
if(!c){list.innerHTML='';return;}
const events=[...(c.timeline||[])].sort((a,b)=>a.date.localeCompare(b.date));
if(!events.length){
list.innerHTML='<div class="dt-tl-empty">날짜와 이벤트를 추가하여<br>사건 타임라인을 구성하세요.</div>';
return;
}
list.innerHTML=events.map(ev=>`
<div class="dt-tl-item">
<div class="dt-tl-line"><div class="dt-tl-dot"></div><div class="dt-tl-rod"></div></div>
<div class="dt-tl-body">
<div class="dt-tl-date-lbl">${escHtml(ev.date)}</div>
<div class="dt-tl-txt">
${escHtml(ev.text)}
<button class="dt-tl-del" onclick="deleteEvent('${ev.id}')">✕</button>
</div>
</div>
</div>
`).join('');
}
function addTimelineEvent(){
const date=document.getElementById('tl-date').value;
const text=document.getElementById('tl-input').value.trim();
if(!date||!text||!currentCaseId){if(!date)alert('날짜를 선택하세요.');return;}
const c=currentCase();if(!c)return;
if(!c.timeline)c.timeline=[];
c.timeline.push({id:'tl_'+Math.random().toString(36).slice(2,8),date,text});
document.getElementById('tl-input').value='';
renderTimeline();saveCases();
}
function deleteEvent(id){
const c=currentCase();if(!c)return;
c.timeline=(c.timeline||[]).filter(x=>x.id!==id);
renderTimeline();saveCases();
}
// ── AI Actions ────────────────────────────────────────────────────────────────
function askAiAboutCase(){
const c=currentCase();
const notes=document.getElementById('case-notes')?.value||'';
if(!c&&!notes)return;
const title=c?.title||'현재 사건';
const msg=`다음 사건을 분석하고 조사 방향을 제안해주세요.\n\n**사건명:** ${title}\n**상태:** ${c?.status||'조사중'}\n\n**수집된 단서 및 메모:**\n${notes||'(없음)'}`;
addMsg('user',escHtml('사건 분석 요청: '+title));
callChat(msg);
if(window.innerWidth<=640)mobToggle('chat');
}
async function generateReport(){
const c=currentCase();
if(!c){alert('사건을 먼저 선택하세요.');return;}
const notes=document.getElementById('case-notes')?.value||'';
const clItems=(c.checklist||[]).map(x=>`[${x.done?'완료':'미완'}] ${x.text}`).join('\n')||'(없음)';
const tlItems=[...(c.timeline||[])].sort((a,b)=>a.date.localeCompare(b.date)).map(x=>`${x.date}: ${x.text}`).join('\n')||'(없음)';
const prompt=`다음 사건 정보를 바탕으로 구조화된 조사 보고서를 작성해주세요.
**사건명:** ${c.title}
**상태:** ${c.status} **우선순위:** ${c.priority}
**수집된 단서/메모:**
${notes||'(없음)'}
**조사 체크리스트:**
${clItems}
**타임라인:**
${tlItems}
위 정보를 분석하여 공식 조사 보고서 형식으로 작성해주세요. 확인된 사실, 미확인 사항, 의심 정황, 종합 의견, 권고 사항을 포함하세요.`;
addMsg('user',escHtml('📄 보고서 생성 요청: '+c.title));
callChat(prompt);
if(window.innerWidth<=640)mobToggle('chat');
}
async function generateComplaint(){
const c=currentCase();
if(!c){alert('사건을 먼저 선택하세요.');return;}
const notes=document.getElementById('case-notes')?.value||'';
const tlItems=[...(c.timeline||[])].sort((a,b)=>a.date.localeCompare(b.date)).map(x=>`${x.date}: ${x.text}`).join('\n')||'(없음)';
const prompt=`다음 사건 정보를 바탕으로 한국 형사소송법에 맞는 **고소장**을 작성해주세요.
**사건명:** ${c.title}
**수집된 단서/메모:**
${notes||'(없음)'}
**타임라인:**
${tlItems}
---
아래 형식으로 완성된 고소장을 작성해주세요. 알 수 없는 정보는 [ ] 로 표시하세요.
\`\`\`
고 소 장
고소인
성 명:
주 소:
연 락 처:
피고소인
성 명:
주 소: (알 수 없는 경우 [불상])
연 락 처:
고소 취지
피고소인을 [죄명]으로 고소하오니 엄중히 처벌하여 주시기 바랍니다.
범죄사실
[날짜·장소·경위·행위를 육하원칙에 따라 서술]
고소 이유
1. [피해 사실 및 증거]
2. [법적 근거 — 해당 형법 조항]
3. [피고소인의 책임]
증거자료
1. [증거1]
2. [증거2]
결론
이 고소장에 기재된 범죄사실에 대하여 피고소인을 철저히 수사하여 엄중히 처벌하여 주시기 바랍니다.
20 년 월 일
고소인: (인)
○○경찰서장(또는 ○○지방검찰청) 귀중
\`\`\`
작성 후 추가로 보완이 필요한 정보(피해금액, 증거자료 목록, 고소인 인적사항 등)를 정리해서 안내해 주세요.`;
addMsg('user',escHtml('⚖️ 고소장 작성 요청: '+c.title));
callChat(prompt);
if(window.innerWidth<=640)mobToggle('chat');
}
async function guideSubmission(){
const c=currentCase();
if(!c){alert('사건을 먼저 선택하세요.');return;}
const notes=document.getElementById('case-notes')?.value||'';
const address=prompt('피해 발생 지역을 입력하세요 (시·구·동 수준, 없으면 취소).\n예: 서울시 강남구 역삼동')||'';
const msg=`다음 사건 정보를 분석하여 고소장·신고서 접수 기관을 안내해주세요.
**사건명:** ${c.title}
**사건 내용:**
${notes||'(없음)'}${address?`\n**피해 발생 지역:** ${address}`:''}
아래 순서로 답변해주세요.
### 1. 범죄 유형 분류
사건 내용을 분석해 범죄 유형과 해당 형법·특별법 조항을 제시해주세요.
### 2. 신고·접수 기관 목록
범죄 유형에 맞는 신고 기관을 우선순위 순으로 안내해주세요.
각 기관마다 기관명, 직접 신고 URL, 전화번호, 접수 방법(온라인/방문/우편)을 포함해주세요.
참고 포털 (해당하는 것만):
- 경찰청 사이버수사국 ECRM (사이버범죄): https://ecrm.police.go.kr
- 금융감독원 불법금융신고센터 (금융사기·보이스피싱): https://www.fss.or.kr — 전화 1332
- 보이스피싱 지킴이 (보이스피싱): https://phishing-keeper.fss.or.kr
- 검찰청 형사사법포털 (일반 형사): https://www.spo.go.kr
- 고용노동부 민원마당 (임금 미지급·노동): https://minwon.moel.go.kr — 전화 1350
- 공정거래위원회 신고센터 (불공정거래): https://www.ftc.go.kr
- 방송통신심의위원회 (명예훼손·불법정보): https://www.kocsc.or.kr
- 경찰청 112 / 민원포털 182
### 3. 관할 경찰서·검찰청${address?`
web_search를 사용해 "${address} 관할 경찰서"와 "${address} 관할 검찰청"을 검색하고 기관명·주소·전화번호를 알려주세요.`:`
피해 발생 지역이 입력되지 않아 검색을 생략합니다. 지역을 알면 관할 기관을 찾아드릴 수 있습니다.`}
### 4. 접수 절차 요약
고소장 제출 → 접수 → 수사 개시까지 단계별 절차를 간략히 안내해주세요.`;
addMsg('user',escHtml('📮 접수 기관 안내: '+c.title+(address?' ('+address+')':'')));
callChat(msg);
if(window.innerWidth<=640)mobToggle('chat');
}
// ── Model ─────────────────────────────────────────────────────────────────────
const MODEL_KEY='dt_model';
let selectedModel=localStorage.getItem(MODEL_KEY)||'';
async function loadModels(){
const sel=document.getElementById('model-sel');
try{
const[sr,or]=await Promise.all([fetch('/api/settings/model',{headers:authH()}),fetch('/api/ollama/models',{headers:authH()})]);
const setting=sr.ok?await sr.json():{};const od=or.ok?await or.json():{};
const primary=setting.primary||'기본';const models=(od.models||[]).map(m=>m.name);
sel.innerHTML=`<option value="">기본 (${primary})</option>`;
if(selectedModel&&!models.includes(selectedModel))models.unshift(selectedModel);
for(const m of models){const o=document.createElement('option');o.value=m;o.textContent=m;if(m===selectedModel)o.selected=true;sel.appendChild(o);}
if(!selectedModel)sel.value='';
}catch{}
}
function saveModel(){selectedModel=document.getElementById('model-sel').value;try{localStorage.setItem(MODEL_KEY,selectedModel);}catch{}}
// ── Skill ─────────────────────────────────────────────────────────────────────
const APP_KEY='dt';
let skillContext='';
let _sessionId=null;
function getSessionId(){return _sessionId||'dt_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='dt_'+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{}
}
const SKILL_CONTENT=`---
name: Detective
description: 공개 정보 기반 인물·기업·사건 조사 전문 어시스턴트. 사실 우선, 가설 수립, 증거 수집, 구조적 보고.
emoji: "🔍"
version: 1.1.0
triggers: [조사, 탐문, 인물, 기업, 사건, 단서, 증거, 추적, 사기, 실사, 배경조사, 확인, 검증]
---
당신은 공개 정보(OSINT) 기반의 전문 조사 어시스턴트입니다. 모든 조사는 합법적으로 접근 가능한 공개 정보만 활용하며, 아래 방법론을 따릅니다.
---
## 조사 방법론 — 항상 이 순서로 접근
### 1단계 — 사실 확인 (Facts First)
- 의뢰인이 제공한 정보를 그대로 정리 (추론 없이)
- 확인된 사실 vs 미확인 주장 명확히 구분
- 핵심 식별자 추출: 이름, 법인번호, 연락처, 주소, SNS 계정 등
### 2단계 — 가설 수립 (Hypotheses)
- 가능한 시나리오 2~3개 제시
- 각 가설의 확률과 근거 설명
- 우선 조사 방향 결정
### 3단계 — 증거 수집 (Evidence Gathering)
- \`web_search\`로 공개 정보 탐색
- 공신력 있는 출처 우선: 법인 등기, 공시 자료, 뉴스, SNS 공개 게시물
- 각 정보의 출처·날짜 반드시 명시
### 4단계 — 추론 (Reasoning)
- 수집된 증거 기반으로만 추론
- 근거 없는 추측 금지
- 불확실한 부분 명시
### 5단계 — 보고 (Report)
구조화된 형식으로 보고:
\`\`\`
## 조사 보고서
**대상:** [이름/기관]
**요청 사항:** [조사 목적]
**조사 일자:** [날짜]
### 확인된 사실
- ...
### 미확인 / 추가 조사 필요
- ...
### 의심 정황
- ...
### 종합 의견
[리스크 수준: 낮음/보통/높음]
[근거 요약]
### 권고 사항
- ...
\`\`\`
---
## 사용 가능한 도구
| 도구 | 활용 상황 |
|---|---|
| \`web_search\` | 인물·기업 공개 정보, 뉴스, SNS, 사업자 정보 검색 |
| \`python_eval\` | 데이터 분석, 패턴 추출, 타임라인 정리 |
| \`korean_law_search\` | 한국 법령·판례 검색 (target="law" 또는 "prec") |
| \`korean_law_fetch\` | 법령 전문·판례 전문 조회 |
### 우선 참조 사이트 (web_search)
| 조사 대상 | site: 연산자 | 설명 |
|---|---|---|
| 한국 법인 | \`site:bizno.net\` | 사업자등록번호 조회 |
| 한국 법인 공시 | \`site:dart.fss.or.kr\` | 금융감독원 DART |
| 법원 경매·파산 | \`site:courtauction.go.kr\` | 법원 경매 정보 |
| 부동산 | \`site:iros.go.kr\` | 인터넷 등기소 |
| 뉴스 | \`site:news.naver.com\` | 네이버 뉴스 |
| 해외 기업 | \`site:opencorporates.com\` | 글로벌 법인 검색 |
| 한국 법령 원문 | \`site:law.go.kr\` | 국가법령정보센터 |
| 대법원 판례 | \`site:glaw.scourt.go.kr\` | 대법원 종합법률정보 |
---
## 법률 검토가 필요한 상황
조사 중 아래 상황이 발생하면 \`korean_law_search\`로 관련 법령·판례를 직접 조회하세요.
| 상황 | 조회할 법령·판례 |
|---|---|
| 사기·편취 의심 | 형법 제347조(사기죄), 관련 대법원 판례 |
| 계약 위반·미이행 | 민법 채무불이행(제390조~), 손해배상 판례 |
| 개인정보 무단 수집 | 개인정보 보호법, 정보통신망법 |
| 불법 채권추심 | 채권의 공정한 추심에 관한 법률 |
| 명예훼손·협박 | 형법 제307조(명예훼손), 제283조(협박죄) |
| 부동산 사기 | 부동산 실권리자명의 등기에 관한 법률 |
| 임금 미지급 | 근로기준법 제43조, 제109조(벌칙) |
---
## 응답 원칙
1. **사실과 추론 명확히 구분**: "확인됨" vs "추정됨" vs "미확인"
2. **출처 명시**: 모든 정보에 URL 또는 출처 명시
3. **리스크 판단**: 의뢰인이 취해야 할 행동 권고
4. **법적 근거 제시**: 관련 법령·판례를 조회해 법적 판단 근거 함께 제공
5. **전문가 연계 고지**: 형사고소·민사소송 등 법적 절차가 필요한 경우 변호사 상담 권고
6. **불법 조사 금지**: 해킹, 불법 감청, 개인정보 무단 수집 요청 거부
`;
function loadSkill(){
skillContext=SKILL_CONTENT;
document.getElementById('skill-badge').textContent='🔍 조사관 활성';
}
// ── Chat ──────────────────────────────────────────────────────────────────────
function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function addMsg(role,html){
const msgs=document.getElementById('chat-msgs');
const div=document.createElement('div');div.className='dt-msg '+role;
const b=document.createElement('div');b.className='dt-bubble';b.innerHTML=html;
div.appendChild(b);msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
return b;
}
function renderMd(text){
return text
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/\*(.+?)\*/g,'<em>$1</em>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
.replace(/^> (.+)$/gm,'<blockquote>$1</blockquote>')
.replace(/^[-*]\s+(.+)$/gm,'<li>$1</li>')
.replace(/(<li>[\s\S]+?<\/li>)/g,m=>`<ul style="padding-left:16px;margin:3px 0">${m}</ul>`)
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
.replace(/<p><\/p>/g,'').replace(/<p>(<[hup])/g,'$1').replace(/(<\/[hup][^>]*>)<\/p>/g,'$1');
}
async function sendMessage(){
const input=document.getElementById('chat-input');
const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return;
input.value='';autoResize(input);
if(window.innerWidth<=640)mobToggle('chat');
addMsg('user',escHtml(msg));
await callChat(msg);
}
function sendQuick(msg,withCase){
if(withCase&&currentCaseId){askAiAboutCase();return;}
if(window.innerWidth<=640)mobCloseAll();
addMsg('user',escHtml(msg));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='dt-msg assistant';
typingDiv.innerHTML='<div class="dt-typing"><div class="dt-dot"></div><div class="dt-dot"></div><div class="dt-dot"></div></div>';
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
let bubble=null,textBuf='',thinkBuf='';
try{
const body={message,sessionId:getSessionId(),useTools:true,
...(selectedModel?{model:selectedModel}:{}),
...(skillContext?{skillContext}:{})};
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
if(!r.ok)throw new Error('서버 오류 '+r.status);
typingDiv.remove();
const msgDiv=document.createElement('div');msgDiv.className='dt-msg assistant';
bubble=document.createElement('div');bubble.className='dt-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="dt-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 callChatRaw(message){
const msgs=document.getElementById('chat-msgs');
const typingDiv=document.createElement('div');typingDiv.className='dt-msg assistant';
typingDiv.innerHTML='<div class="dt-typing"><div class="dt-dot"></div><div class="dt-dot"></div><div class="dt-dot"></div></div>';
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
let bubble=null,textBuf='',thinkBuf='';
try{
const body={message,sessionId:'dt_tmp_'+Math.random().toString(36).slice(2,8),useTools:false,
...(selectedModel?{model:selectedModel}:{}),
...(skillContext?{skillContext}:{})};
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
if(!r.ok)throw new Error('서버 오류 '+r.status);
typingDiv.remove();
const msgDiv=document.createElement('div');msgDiv.className='dt-msg assistant';
bubble=document.createElement('div');bubble.className='dt-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="dt-thinking" onclick="this.classList.toggle('exp')">💭 ${escHtml(thinkBuf.slice(0,120))}${thinkBuf.length>120?'…':''}</div>`:'')+renderMd(textBuf);
msgs.scrollTop=msgs.scrollHeight;
}
}
}
}
return textBuf;
}catch(e){
typingDiv.remove();
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:var(--err)">오류: '+escHtml(e.message)+'</span>';}
return null;
}
}
async function clearChat(){
if(!confirm('대화를 지울까요?'))return;
document.getElementById('chat-msgs').innerHTML='';
_sessionId='dt_'+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;
// Set today as default timeline date
document.getElementById('tl-date').value=new Date().toISOString().slice(0,10);
await initSessionId();
await loadHistory();
loadSkill();
await Promise.all([loadCases(),loadModels()]);
addMsg('system','🔍 탐정 어시스턴트입니다. 인물·기업·사건 조사를 도와드립니다.');
document.getElementById('chat-input').focus();
})();
</script>
</body>
</html>