- 카카오톡 채널 챗봇 (오픈빌더 비동기 콜백 방식, 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>
1181 lines
64 KiB
HTML
1181 lines
64 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 ── */
|
||
.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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
|
||
.replace(/`([^`]+)`/g,'<code>$1</code>')
|
||
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
||
.replace(/\*(.+?)\*/g,'<em>$1</em>')
|
||
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
|
||
.replace(/^> (.+)$/gm,'<blockquote>$1</blockquote>')
|
||
.replace(/^[-*]\s+(.+)$/gm,'<li>$1</li>')
|
||
.replace(/(<li>[\s\S]+?<\/li>)/g,m=>`<ul style="padding-left:16px;margin:3px 0">${m}</ul>`)
|
||
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
|
||
.replace(/<p><\/p>/g,'').replace(/<p>(<[hup])/g,'$1').replace(/(<\/[hup][^>]*>)<\/p>/g,'$1');
|
||
}
|
||
|
||
async function sendMessage(){
|
||
const input=document.getElementById('chat-input');
|
||
const msg=input.value.trim();
|
||
if(!msg||document.getElementById('send-btn').disabled)return;
|
||
input.value='';autoResize(input);
|
||
if(window.innerWidth<=640)mobToggle('chat');
|
||
addMsg('user',escHtml(msg));
|
||
await callChat(msg);
|
||
}
|
||
|
||
function sendQuick(msg,withCase){
|
||
if(withCase&¤tCaseId){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>
|