- 판례 탭을 맨 왼쪽으로 옮기고 기본 검색 대상으로 바꿨다. 법률앱에서 가장 자주 쓰는 게 판례다. - 결과 목록 위에 좁히기 입력칸. 사건명·법원·사건번호에서 찾는다. 서버를 다시 부르지 않고 이미 받아온 결과를 숨김/표시로 거르므로 즉시 반응하고 law.go.kr 쿼터도 안 쓴다. 새 검색이 오면 조건을 비운다(안 그러면 결과가 있는데 빈 화면으로 보인다). 1건이면 좁힐 게 없으므로 입력칸을 숨긴다. - 결정례 4종(헌재·법령해석·행정심판·심판)은 드롭다운으로 묶었다. 탭이 8개가 되면 좁은 화면에서 줄바꿈이 생긴다. 드롭다운 항목을 고르면 탭 하이라이트를 끄고 드롭다운이 현재 선택을 나타낸다. - 키가 없어 검색 자체가 불가능한 경우(미국 판례)를 "결과 없음"과 구분해 안내를 띄운다. 예전엔 "다른 키워드로 검색해보세요"가 떠서 헛되이 재시도했다. 부트스트랩에서 setSearchType()을 부른다. 이 함수는 탭 클릭 때만 돌아서 최초 로드에는 안 걸렸고, 그래서 기본값이 판례인데 placeholder는 법령용이었다 — 📁 버튼이 안 보이던 것과 같은 종류의 누락이라 이번엔 같이 잡았다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1859 lines
108 KiB
HTML
1859 lines
108 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>⚖️ 법률 어시스턴트</title>
|
||
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
|
||
<link rel="stylesheet" href="../css/styles.css">
|
||
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
|
||
<style>
|
||
html,body{height:100%;margin:0;padding:0;}
|
||
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
|
||
|
||
/* ── Header ── */
|
||
.lw-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
|
||
.lw-hdr h1{font-size:13px;font-weight:700;margin:0;}
|
||
.lw-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:#eab308;color:#eab308;}
|
||
.skill-badge{font-size:11px;padding:2px 8px;border-radius:12px;background:rgba(234,179,8,.1);border:1px solid rgba(234,179,8,.25);color:#fbbf24;font-weight:700;}
|
||
|
||
/* ── Layout ── */
|
||
.lw-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
|
||
|
||
/* ── Sidebar ── */
|
||
.lw-sb{width:200px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--line);background:var(--panel);min-height:0;overflow-y:auto;}
|
||
.lw-sb::-webkit-scrollbar{width:3px;}
|
||
.lw-sb::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
||
.lw-sb-sec{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:8px 10px 4px;flex-shrink:0;}
|
||
.lw-quick{padding:0 6px 6px;display:flex;flex-direction:column;gap:3px;}
|
||
.lw-qbtn{background:none;border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left;transition:.15s;line-height:1.4;}
|
||
.lw-qbtn:hover{border-color:#eab308;color:#eab308;background:rgba(234,179,8,.05);}
|
||
.lw-divider{height:1px;background:var(--line);margin:4px 6px;}
|
||
.lw-disclaimer{margin:6px;padding:8px 10px;background:rgba(234,179,8,.06);border:1px solid rgba(234,179,8,.15);border-radius:8px;font-size:10px;color:var(--muted);line-height:1.6;}
|
||
|
||
/* ── Center ── */
|
||
.lw-center{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;background:var(--bg);}
|
||
|
||
/* Search bar */
|
||
.lw-search-bar{padding:8px 12px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;}
|
||
.lw-search-row{display:flex;gap:6px;align-items:center;margin-bottom:6px;}
|
||
.lw-search-input{flex:1;background:var(--panel-2);border:1.5px solid var(--line);border-radius:8px;padding:7px 12px;font-size:12px;color:var(--text);font-family:inherit;outline:none;transition:.15s;}
|
||
.lw-search-input:focus{border-color:#eab308;}
|
||
.lw-search-btn{background:#92400e;border:none;border-radius:8px;padding:7px 14px;font-size:12px;font-weight:700;color:#fef3c7;cursor:pointer;font-family:inherit;transition:.15s;white-space:nowrap;}
|
||
.lw-search-btn:hover:not(:disabled){background:#b45309;}
|
||
.lw-search-btn:disabled{opacity:.5;cursor:not-allowed;}
|
||
.lw-search-tabs{display:flex;gap:4px;}
|
||
.lw-stab{padding:3px 10px;border:1.5px solid var(--line);border-radius:16px;font-size:11px;font-weight:600;cursor:pointer;background:none;color:var(--muted);font-family:inherit;transition:.12s;}
|
||
.lw-stab:hover{border-color:#eab308;color:#eab308;}
|
||
.lw-stab.active{border-color:#d97706;background:#451a03;color:#fbbf24;}
|
||
[data-theme="light"] .lw-stab.active{background:#fef3c7;color:#b45309;}
|
||
.lw-sort-sel{background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit;}
|
||
|
||
/* Center body: split into list + detail */
|
||
.lw-center-body{flex:1;display:flex;min-height:0;overflow:hidden;}
|
||
.lw-results{width:300px;flex-shrink:0;border-right:1px solid var(--line);display:flex;flex-direction:column;overflow:hidden;}
|
||
.lw-results-hdr{padding:6px 12px;border-bottom:1px solid var(--line);font-size:11px;color:var(--muted);flex-shrink:0;background:var(--panel);}
|
||
.lw-results-list{flex:1;overflow-y:auto;padding:4px;}
|
||
.lw-results-list::-webkit-scrollbar{width:3px;}
|
||
.lw-results-list::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
||
.lw-result-item{padding:9px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:3px;transition:.12s;}
|
||
.lw-result-item:hover{background:rgba(234,179,8,.05);border-color:rgba(234,179,8,.2);}
|
||
.lw-result-item.active{background:rgba(234,179,8,.08);border-color:rgba(234,179,8,.3);}
|
||
.lw-ri-name{font-size:12px;font-weight:700;color:var(--text);margin-bottom:3px;line-height:1.35;}
|
||
.lw-ri-meta{font-size:10px;color:var(--muted);line-height:1.5;}
|
||
.lw-ri-badge{display:inline-block;padding:1px 6px;border-radius:10px;font-size:10px;font-weight:700;margin-right:4px;}
|
||
.badge-law{background:rgba(59,130,246,.1);color:#60a5fa;border:1px solid rgba(59,130,246,.2);}
|
||
.badge-prec{background:rgba(234,179,8,.1);color:#fbbf24;border:1px solid rgba(234,179,8,.2);}
|
||
.badge-us{background:rgba(239,68,68,.1);color:#f87171;border:1px solid rgba(239,68,68,.2);}
|
||
|
||
/* Detail pane */
|
||
.lw-detail{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;}
|
||
.lw-detail-hdr{display:flex;align-items:center;padding:6px 12px;border-bottom:1px solid var(--line);background:var(--panel);gap:8px;flex-shrink:0;}
|
||
.lw-detail-title{font-size:12px;font-weight:700;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.lw-detail-btn{background:none;border:1px solid var(--line);border-radius:5px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.12s;white-space:nowrap;}
|
||
.lw-detail-btn:hover{border-color:#eab308;color:#eab308;}
|
||
.lw-detail-body{flex:1;overflow-y:auto;padding:16px 20px;font-size:12px;line-height:1.7;}
|
||
.lw-detail-body::-webkit-scrollbar{width:3px;}
|
||
.lw-detail-body::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
||
.lw-detail-body h2{font-size:14px;font-weight:700;margin:0 0 8px;color:var(--text);}
|
||
.lw-detail-body h3{font-size:13px;font-weight:700;margin:14px 0 5px;color:var(--text);}
|
||
.lw-detail-body p{margin:3px 0;color:var(--text);}
|
||
.lw-detail-body strong{font-weight:700;}
|
||
.lw-detail-body .meta-row{font-size:11px;color:var(--muted);margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--line);}
|
||
.lw-loading{text-align:center;padding:40px;color:var(--muted);font-size:12px;}
|
||
.lw-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--muted);text-align:center;gap:12px;padding:40px;}
|
||
.lw-empty .icon{font-size:48px;opacity:.3;}
|
||
.lw-empty p{font-size:12px;line-height:1.7;max-width:300px;}
|
||
.lw-detail-pre{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px 16px;font-size:11.5px;line-height:1.8;white-space:pre-wrap;word-break:break-word;color:var(--text);font-family:inherit;}
|
||
|
||
/* ── Resize handle ── */
|
||
.lw-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;}
|
||
.lw-resize:hover,.lw-resize.dragging{background:#eab308;}
|
||
|
||
/* ── Chat ── */
|
||
.lw-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;}
|
||
.lw-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex-shrink:0;gap:6px;}
|
||
.lw-chat-label{font-size:10px;font-weight:700;color:#fbbf24;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
|
||
.lw-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;}
|
||
.lw-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;}
|
||
.lw-chat-clear:hover{color:#ef4444;}
|
||
.lw-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
|
||
.lw-msgs::-webkit-scrollbar{width:3px;}
|
||
.lw-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
||
.lw-msg{display:flex;flex-direction:column;}
|
||
.lw-msg.user{align-items:flex-end;}
|
||
.lw-msg.assistant{align-items:flex-start;}
|
||
.lw-msg.system{align-items:center;}
|
||
.lw-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:93%;word-break:break-word;}
|
||
.lw-msg.user .lw-bubble{background:#451a03;color:#fef3c7;border-radius:10px 10px 3px 10px;}
|
||
.lw-msg.assistant .lw-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
|
||
.lw-msg.system .lw-bubble{background:transparent;color:var(--muted);font-size:11px;}
|
||
.lw-bubble p{margin:3px 0;}
|
||
.lw-bubble h3{font-size:13px;margin:6px 0 3px;}
|
||
.lw-bubble ul,.lw-bubble ol{padding-left:16px;margin:3px 0;}
|
||
.lw-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
|
||
.lw-bubble strong{font-weight:700;}
|
||
.lw-bubble table{border-collapse:collapse;font-size:11px;margin:6px 0;width:100%;}
|
||
.lw-bubble th,.lw-bubble td{border:1px solid var(--line);padding:3px 7px;text-align:left;}
|
||
.lw-bubble th{background:var(--panel);font-weight:700;}
|
||
.lw-bubble blockquote{border-left:3px solid var(--line);padding-left:10px;margin:4px 0;color:var(--muted);}
|
||
.lw-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;}
|
||
.lw-thinking.exp{display:block;-webkit-line-clamp:unset;max-height:200px;overflow-y:auto;}
|
||
.lw-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
|
||
.lw-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:lwDot 1.2s ease-in-out infinite;}
|
||
.lw-dot:nth-child(2){animation-delay:.2s;}.lw-dot:nth-child(3){animation-delay:.4s;}
|
||
@keyframes lwDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
|
||
.lw-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
|
||
.lw-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;}
|
||
.lw-textarea:focus{border-color:#eab308;}
|
||
.lw-send{background:#92400e;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;}
|
||
.lw-send:hover:not(:disabled){background:#b45309;}
|
||
.lw-send:disabled{opacity:.5;cursor:not-allowed;}
|
||
|
||
/* ── Mobile ── */
|
||
@media(max-width:1024px){
|
||
.lw-resize{display:none;}
|
||
.lw-sb{
|
||
position:fixed;top:0;left:0;bottom:52px;z-index:500;
|
||
width:80vw;max-width:300px;
|
||
transform:translateX(-100%);transition:transform .25s ease;
|
||
box-shadow:4px 0 24px rgba(0,0,0,.4);
|
||
}
|
||
.lw-sb.mob-open{transform:translateX(0);}
|
||
.lw-chat{
|
||
position:fixed;top:0;right:0;bottom:52px;z-index:500;
|
||
width:92vw;max-width:380px;
|
||
transform:translateX(100%);transition:transform .25s ease;
|
||
box-shadow:-4px 0 24px rgba(0,0,0,.4);
|
||
}
|
||
.lw-chat.mob-open{transform:translateX(0);}
|
||
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.5);z-index:499;}
|
||
#mob-backdrop.show{display:block;}
|
||
.lw-center{width:100%;}
|
||
.lw-center-body{padding-bottom:64px;}
|
||
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:600;background:var(--panel);border-top:1px solid var(--line);height:52px;}
|
||
.lw-textarea,input,textarea{font-size:16px !important;}
|
||
}
|
||
@media(min-width:1025px){
|
||
#mob-tabbar{display:none !important;}
|
||
#mob-backdrop{display:none !important;}
|
||
}
|
||
|
||
/* ── 사건 관리 (case management, ported from detective-app) ───────────────────── */
|
||
.lw-mode-toggle{display:flex;gap:4px;padding:6px 12px 0;flex-shrink:0;}
|
||
.lw-mode-btn{background:none;border:1.5px solid var(--line);border-radius:16px;padding:4px 12px;font-size:11px;font-weight:600;color:var(--muted);cursor:pointer;font-family:inherit;transition:.12s;}
|
||
.lw-mode-btn:hover{border-color:#eab308;color:#eab308;}
|
||
.lw-mode-btn.active{border-color:#d97706;background:#451a03;color:#fbbf24;}
|
||
[data-theme="light"] .lw-mode-btn.active{background:#fef3c7;color:#b45309;}
|
||
|
||
.lw-sb-hdr{display:flex;align-items:center;padding:8px 10px 4px;gap:4px;flex-shrink:0;}
|
||
.lw-sb-title{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;flex:1;}
|
||
.lw-new-btn{background:#451a03;border:none;border-radius:5px;padding:2px 8px;font-size:11px;color:#fbbf24;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;}
|
||
.lw-new-btn:hover{background:#92400e;color:#fff;}
|
||
.lw-case-search-wrap{padding:0 8px 6px;flex-shrink:0;}
|
||
.lw-case-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;}
|
||
.lw-case-search:focus{border-color:#eab308;}
|
||
.lw-case-sort-bar{display:flex;align-items:center;padding:0 8px 4px;gap:4px;flex-shrink:0;}
|
||
.lw-case-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;}
|
||
.lw-cases{flex:1;overflow-y:auto;padding:4px;min-height:120px;}
|
||
.lw-cases::-webkit-scrollbar{width:3px;}
|
||
.lw-cases::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
||
.lw-case-item{padding:7px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:2px;transition:.12s;position:relative;}
|
||
.lw-case-item:hover{background:rgba(234,179,8,.05);border-color:rgba(234,179,8,.2);}
|
||
.lw-case-item.active{background:rgba(234,179,8,.1);border-color:rgba(234,179,8,.35);}
|
||
.lw-case-title{font-size:12px;font-weight:600;color:var(--text);margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:34px;}
|
||
.lw-case-meta{font-size:10px;color:var(--muted);display:flex;align-items:center;gap:4px;flex-wrap:wrap;}
|
||
.lw-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;}
|
||
.lw-case-item:hover .lw-case-del{opacity:.6;}
|
||
.lw-case-del:hover{opacity:1 !important;color:#ef4444;}
|
||
.lw-case-rename{position:absolute;top:6px;right:22px;background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);opacity:0;transition:.12s;padding:1px 3px;}
|
||
.lw-case-item:hover .lw-case-rename{opacity:.6;}
|
||
.lw-case-rename:hover{opacity:1 !important;color:#eab308;}
|
||
.lw-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;}
|
||
.lw-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;}
|
||
|
||
.lw-case-center{flex:1;min-width:0;display:none;flex-direction:column;overflow:hidden;background:var(--bg);}
|
||
.lw-case-center-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:12px;padding:40px;}
|
||
.lw-case-center-empty .icon{font-size:48px;opacity:.25;}
|
||
.lw-case-center-empty p{font-size:12px;line-height:1.7;text-align:center;max-width:260px;}
|
||
.lw-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;}
|
||
.lw-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;}
|
||
.lw-case-name-input::placeholder{color:var(--muted);}
|
||
.lw-case-status-sel,.lw-case-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;}
|
||
.lw-case-hdr-btns{display:flex;gap:5px;align-items:center;flex-shrink:0;flex-wrap:wrap;}
|
||
.lw-ask-ai-btn{background:#451a03;border:none;border-radius:6px;padding:4px 9px;font-size:11px;color:#fbbf24;cursor:pointer;font-family:inherit;font-weight:700;white-space:nowrap;transition:.15s;}
|
||
.lw-ask-ai-btn:hover{background:#92400e;color:#fff;}
|
||
.lw-opinion-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;}
|
||
.lw-opinion-btn:hover{background:rgba(139,92,246,.3);color:#fff;}
|
||
.lw-draft-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;}
|
||
.lw-draft-btn:hover{background:rgba(239,68,68,.25);color:#fff;}
|
||
.lw-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;}
|
||
.lw-submit-btn:hover{background:rgba(16,185,129,.25);color:#fff;}
|
||
.lw-save-indicator{font-size:10px;color:var(--muted);transition:.3s;flex-shrink:0;}
|
||
|
||
.lw-case-tabs{display:flex;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;padding:0 14px;overflow-x:auto;}
|
||
.lw-case-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;white-space:nowrap;}
|
||
.lw-case-tab-btn.active{color:#eab308;border-bottom-color:#eab308;}
|
||
.lw-case-tab-btn:hover:not(.active){color:var(--text);}
|
||
.lw-case-tab-content{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden;}
|
||
.lw-case-tab-pane{flex:1;display:none;flex-direction:column;min-height:0;overflow:hidden;}
|
||
.lw-case-tab-pane.active{display:flex;}
|
||
|
||
.lw-notes-area{flex:1;display:flex;flex-direction:column;padding:12px 14px;gap:6px;min-height:0;}
|
||
.lw-notes-label{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
|
||
.lw-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;}
|
||
.lw-notes-textarea:focus{border-color:#eab308;}
|
||
|
||
.lw-cl-wrap{flex:1;display:flex;flex-direction:column;min-height:0;padding:12px 14px;gap:8px;}
|
||
.lw-cl-add-bar{display:flex;gap:6px;flex-shrink:0;}
|
||
.lw-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;}
|
||
.lw-cl-input:focus{border-color:#eab308;}
|
||
.lw-cl-add-btn{background:#451a03;border:none;border-radius:7px;padding:7px 13px;font-size:12px;color:#fbbf24;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
|
||
.lw-cl-add-btn:hover{background:#92400e;color:#fff;}
|
||
.lw-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;}
|
||
.lw-cl-ai-btn:hover{background:rgba(139,92,246,.3);color:#fff;}
|
||
.lw-cl-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:3px;}
|
||
.lw-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;}
|
||
.lw-cl-item:hover{border-color:rgba(234,179,8,.25);}
|
||
.lw-cl-item.done{opacity:.55;}
|
||
.lw-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;}
|
||
.lw-cl-cb.checked{background:#22c55e;border-color:#22c55e;color:#fff;}
|
||
.lw-cl-text{flex:1;font-size:12px;line-height:1.5;color:var(--text);word-break:break-word;}
|
||
.lw-cl-item.done .lw-cl-text{text-decoration:line-through;color:var(--muted);}
|
||
.lw-cl-del{background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);padding:1px 4px;opacity:0;transition:.12s;flex-shrink:0;}
|
||
.lw-cl-item:hover .lw-cl-del{opacity:.5;}
|
||
.lw-cl-del:hover{opacity:1 !important;color:#ef4444;}
|
||
.lw-cl-progress{display:flex;align-items:center;gap:8px;flex-shrink:0;}
|
||
.lw-cl-prog-bar{flex:1;height:4px;background:var(--line);border-radius:2px;overflow:hidden;}
|
||
.lw-cl-prog-fill{height:100%;background:#22c55e;border-radius:2px;transition:.3s;}
|
||
.lw-cl-prog-text{font-size:10px;color:var(--muted);flex-shrink:0;}
|
||
.lw-cl-empty{text-align:center;color:var(--muted);font-size:12px;padding:30px 0;}
|
||
.lw-ev-group-title{font-size:11px;font-weight:700;color:var(--muted);margin:6px 0 2px;padding-left:2px;}
|
||
|
||
.lw-tl-wrap{flex:1;display:flex;flex-direction:column;min-height:0;padding:12px 14px;gap:8px;}
|
||
.lw-tl-add-bar{display:flex;gap:6px;flex-shrink:0;align-items:center;}
|
||
.lw-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;}
|
||
.lw-tl-date:focus{border-color:#eab308;}
|
||
.lw-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;}
|
||
.lw-tl-text-input:focus{border-color:#eab308;}
|
||
.lw-tl-add-btn{background:#451a03;border:none;border-radius:7px;padding:7px 13px;font-size:12px;color:#fbbf24;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
|
||
.lw-tl-add-btn:hover{background:#92400e;color:#fff;}
|
||
.lw-tl-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||
.lw-tl-item{display:flex;gap:10px;position:relative;padding-bottom:12px;}
|
||
.lw-tl-line{display:flex;flex-direction:column;align-items:center;flex-shrink:0;width:20px;}
|
||
.lw-tl-dot{width:10px;height:10px;border-radius:50%;background:#eab308;border:2px solid var(--bg);flex-shrink:0;margin-top:2px;}
|
||
.lw-tl-rod{flex:1;width:2px;background:var(--line);margin-top:2px;}
|
||
.lw-tl-item:last-child .lw-tl-rod{display:none;}
|
||
.lw-tl-body{flex:1;padding-top:0;}
|
||
.lw-tl-date-lbl{font-size:10px;font-weight:700;color:#fbbf24;margin-bottom:2px;}
|
||
.lw-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;}
|
||
.lw-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;}
|
||
.lw-tl-txt:hover .lw-tl-del{opacity:.5;}
|
||
.lw-tl-del:hover{opacity:1 !important;color:#ef4444;}
|
||
.lw-tl-empty{text-align:center;color:var(--muted);font-size:12px;padding:30px 0;}
|
||
</style>
|
||
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
|
||
<script src="/vendor/katex/katex.min.js"></script>
|
||
<script src="/vendor/katex/auto-render.min.js"></script>
|
||
<script src="/vendor/katex/mhchem.min.js"></script>
|
||
<script src="../js/app/katex-inline.js"></script>
|
||
<script src="../js/voice/voice-utils.js"></script>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="lw-hdr">
|
||
<a href="/" class="hdr-btn">← 홈</a>
|
||
<h1>⚖️ 법률 어시스턴트</h1>
|
||
<span class="skill-badge" id="skill-badge">스킬 로딩 중...</span>
|
||
<div class="lw-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-quick" onclick="mobToggle('quick')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||
<span>빠른질문</span>
|
||
</button>
|
||
<button class="mob-tab" id="mobtab-home" onclick="mobToggle('home')" style="color:var(--brand,#eab308);border-top:2px solid var(--brand,#eab308)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></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>
|
||
|
||
<!-- Built-in text editor (.txt/.md/.csv) — encoding-safe, ported from detective-app -->
|
||
<div id="lw-text-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(10,20,40,0.45);z-index:9999;align-items:center;justify-content:center;padding:18px">
|
||
<div style="width:min(720px,96vw);height:min(640px,90vh);background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-md,0 8px 30px rgba(0,0,0,.3));display:flex;flex-direction:column;overflow:hidden">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--line);flex-shrink:0">
|
||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||
<span style="font-weight:800;font-size:13px;flex-shrink:0">📝</span>
|
||
<span id="lw-te-filename" style="font-weight:700;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||
<select id="lw-te-encoding" onchange="lwTextEditorRedecode()" style="margin-left:8px;background:var(--panel-2);border:1px solid var(--line);border-radius:7px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit">
|
||
<option value="utf-8">UTF-8</option>
|
||
<option value="euc-kr">EUC-KR (CP949)</option>
|
||
</select>
|
||
</div>
|
||
<button onclick="closeTextEditor()" style="border:0;background:none;font-size:18px;cursor:pointer;color:var(--muted);line-height:1;flex-shrink:0">✕</button>
|
||
</div>
|
||
<textarea id="lw-te-textarea" style="flex:1;resize:none;border:0;outline:none;padding:14px 16px;font-family:'IBM Plex Mono',monospace;font-size:13px;line-height:1.6;background:var(--panel);color:var(--text)"></textarea>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-top:1px solid var(--line);flex-shrink:0">
|
||
<span id="lw-te-status" style="font-size:11px;color:var(--muted)"></span>
|
||
<div style="display:flex;gap:8px">
|
||
<button onclick="closeTextEditor()" style="border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;padding:7px 16px;font-size:12px;font-weight:600;cursor:pointer">닫기</button>
|
||
<button onclick="saveTextEditor()" style="border:0;background:#92400e;color:#fff;border-radius:8px;padding:7px 18px;font-size:12px;font-weight:700;cursor:pointer">저장 (UTF-8)</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lw-layout">
|
||
|
||
<!-- ── Sidebar ── -->
|
||
<aside class="lw-sb">
|
||
<div class="lw-mode-toggle">
|
||
<button class="lw-mode-btn active" id="mode-btn-search" onclick="switchMode('search')">🔍 법령·판례 검색</button>
|
||
<button class="lw-mode-btn" id="mode-btn-case" onclick="switchMode('case')">🗂 사건 관리</button>
|
||
</div>
|
||
|
||
<!-- Quick questions (search mode) -->
|
||
<div id="sb-search-panel">
|
||
<div class="lw-sb-sec">빠른 질문</div>
|
||
<div class="lw-quick">
|
||
<button class="lw-qbtn" onclick="sendQuick('근로계약서의 주요 필수 기재사항과 불리한 조항 확인 방법을 알려주세요.')">📄 근로계약서 검토</button>
|
||
<button class="lw-qbtn" onclick="sendQuick('부당해고를 당했습니다. 대응 방법과 구제 절차를 알려주세요.')">🚫 부당해고 대응</button>
|
||
<button class="lw-qbtn" onclick="sendQuick('임대차 계약 시 세입자가 꼭 확인해야 할 사항과 전세사기 예방법을 알려주세요.')">🏠 임대차 계약</button>
|
||
<button class="lw-qbtn" onclick="sendQuick('명예훼손과 모욕죄의 차이점과 고소 방법을 알려주세요.')">💬 명예훼손·모욕</button>
|
||
<button class="lw-qbtn" onclick="sendQuick('교통사고 발생 시 즉시 해야 할 조치와 보험 처리 절차를 알려주세요.')">🚗 교통사고 처리</button>
|
||
<button class="lw-qbtn" onclick="sendQuick('개인정보 침해를 당했을 때 신고 방법과 손해배상 청구 절차를 알려주세요.')">🔐 개인정보 침해</button>
|
||
<button class="lw-qbtn" onclick="sendQuick('이혼 절차(협의이혼·재판이혼)와 재산분할, 양육권 기준을 알려주세요.')">💔 이혼·가족법</button>
|
||
<button class="lw-qbtn" onclick="sendQuick('소액사건심판 절차와 내용증명 작성 방법을 알려주세요.')">📮 내용증명·소액심판</button>
|
||
</div>
|
||
<div class="lw-divider"></div>
|
||
<div class="lw-disclaimer">
|
||
⚠️ 이 서비스는 <strong>정보 제공</strong> 목적이며 법률 자문을 대체하지 않습니다. 중요한 법적 판단은 변호사에게 확인하세요.
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Case list (case mode) -->
|
||
<div id="sb-case-panel" style="display:none;flex:1;flex-direction:column;min-height:0;">
|
||
<div class="lw-sb-hdr">
|
||
<span class="lw-sb-title">사건 목록</span>
|
||
<button class="lw-new-btn" onclick="newCase()">+ 새 사건</button>
|
||
</div>
|
||
<div class="lw-case-search-wrap">
|
||
<input class="lw-case-search" id="case-search" type="text" placeholder="🔍 사건 검색..." oninput="renderCaseList()">
|
||
</div>
|
||
<div class="lw-case-sort-bar">
|
||
<select class="lw-case-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="lw-cases" id="case-list"></div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- ── Center: search + results + detail ── -->
|
||
<section class="lw-center">
|
||
<!-- Search bar -->
|
||
<div class="lw-search-bar">
|
||
<div class="lw-search-row">
|
||
<input class="lw-search-input" id="search-input" type="text" placeholder="법령명, 판례 키워드를 입력하세요..." onkeydown="if(event.key==='Enter')doSearch()">
|
||
<button class="lw-search-btn" id="search-btn" onclick="doSearch()">🔍 검색</button>
|
||
</div>
|
||
<div class="lw-search-tabs">
|
||
<button class="lw-stab active" id="stab-prec" onclick="setSearchType('prec')">⚖️ 판례</button>
|
||
<button class="lw-stab" id="stab-law" onclick="setSearchType('law')">📗 법령</button>
|
||
<button class="lw-stab" id="stab-ordinance" onclick="setSearchType('ordinance')">📘 자치법규</button>
|
||
<button class="lw-stab" id="stab-us" onclick="setSearchType('us')">🇺🇸 미국 판례</button>
|
||
<!-- 탭이 8개가 되면 좁은 화면에서 줄바꿈이 생겨, 자주 안 쓰는 넷은 드롭다운으로 묶었다. -->
|
||
<select class="lw-sort-sel" id="stab-more" onchange="setSearchType(this.value)" title="그 밖의 결정례">
|
||
<option value="">+ 그 밖의 결정례</option>
|
||
<option value="detc">🏛 헌재결정례</option>
|
||
<option value="expc">📑 법령해석례</option>
|
||
<option value="decc">⚖️ 행정심판례</option>
|
||
<option value="ppc">📋 심판례</option>
|
||
</select>
|
||
<div style="flex:1"></div>
|
||
<select class="lw-sort-sel" id="sort-sel">
|
||
<option value="rel">관련도순</option>
|
||
<option value="date">최신순</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Results + Detail -->
|
||
<div class="lw-center-body">
|
||
<!-- Results list -->
|
||
<div class="lw-results">
|
||
<div class="lw-results-hdr" id="results-hdr">검색 결과</div>
|
||
<div id="lw-subfilter-row" style="display:none;padding:5px 8px;border-bottom:1px solid var(--line)">
|
||
<input id="lw-subfilter" type="text" placeholder="결과 내에서 좁히기 (사건명·법원·사건번호)"
|
||
oninput="applySubFilter()" style="width:100%;box-sizing:border-box;font-size:11px;padding:4px 7px;
|
||
background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:5px">
|
||
</div>
|
||
<div class="lw-results-list" id="results-list">
|
||
<div class="lw-empty" style="height:100%">
|
||
<div class="icon">⚖️</div>
|
||
<p>법령명, 판례 키워드,<br>조문 내용 등으로 검색하세요.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Detail pane -->
|
||
<div class="lw-detail">
|
||
<div class="lw-detail-hdr">
|
||
<span class="lw-detail-title" id="detail-title">상세 내용</span>
|
||
<button class="lw-detail-btn" id="ask-ai-btn" onclick="askAiAboutDetail()" style="display:none">💬 AI에게 분석 요청</button>
|
||
</div>
|
||
<div class="lw-detail-body" id="detail-body">
|
||
<div class="lw-empty" style="height:100%">
|
||
<div class="icon">📄</div>
|
||
<p>검색 결과를 클릭하면<br>법령 전문 또는 판례 요지를<br>여기서 확인할 수 있습니다.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Case center: notepad + tabs (case mode) ── -->
|
||
<section class="lw-case-center" id="lw-case-center">
|
||
<div class="lw-case-center-empty" id="case-center-empty">
|
||
<div class="icon">🗂</div>
|
||
<p>좌측에서 사건을 선택하거나<br><strong>새 사건</strong>을 만들어 메모와<br>서류를 기록하세요.</p>
|
||
</div>
|
||
<div id="case-center-body" style="display:none;flex:1;flex-direction:column;min-height:0;">
|
||
|
||
<div class="lw-case-hdr">
|
||
<input class="lw-case-name-input" id="case-name-input" placeholder="사건명 입력..." oninput="onCaseNameChange()" onblur="saveCases()">
|
||
<select class="lw-case-status-sel" id="case-status-sel" onchange="onStatusChange()">
|
||
<option value="진행중">🟢 진행중</option>
|
||
<option value="완료">🔵 완료</option>
|
||
<option value="보류">🟡 보류</option>
|
||
</select>
|
||
<select class="lw-case-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="lw-case-hdr-btns">
|
||
<button class="lw-ask-ai-btn" onclick="askAiAboutCase()">💬 분석</button>
|
||
<button class="lw-opinion-btn" onclick="generateOpinion()">📄 법률의견서</button>
|
||
<button class="lw-draft-btn" onclick="generateComplaint()">⚖️ 서면 초안</button>
|
||
<button class="lw-submit-btn" onclick="guideSubmission()">📮 제출 안내</button>
|
||
<span class="lw-save-indicator" id="save-indicator"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lw-case-tabs">
|
||
<button class="lw-case-tab-btn active" onclick="switchCaseTab('notes')" id="tab-notes">📝 메모</button>
|
||
<button class="lw-case-tab-btn" onclick="switchCaseTab('checklist')" id="tab-checklist">✅ 체크리스트</button>
|
||
<button class="lw-case-tab-btn" onclick="switchCaseTab('timeline')" id="tab-timeline">📅 타임라인</button>
|
||
<button class="lw-case-tab-btn" onclick="switchCaseTab('files')" id="tab-files">📎 첨부자료</button>
|
||
<button class="lw-case-tab-btn" onclick="switchCaseTab('docs')" id="tab-docs">📋 제출서류</button>
|
||
</div>
|
||
|
||
<div class="lw-case-tab-content">
|
||
<div class="lw-case-tab-pane active" id="pane-notes">
|
||
<div class="lw-notes-area">
|
||
<span class="lw-notes-label">사건 내용 · 상담 메모</span>
|
||
<textarea class="lw-notes-textarea" id="case-notes" placeholder="의뢰인 상황, 쟁점, 관련 법령, 상담 내용 등을 자유롭게 기록하세요.
|
||
|
||
예시:
|
||
- 의뢰인: 홍길동 (임차인)
|
||
- 쟁점: 전세보증금 반환 지연
|
||
- 관련 법령: 주택임대차보호법
|
||
- 상담 내용: 계약 만료일 경과 후 2개월째 미반환" oninput="onNotesChange()" onblur="saveCases();autoNameCaseIfNeeded()" onkeydown="handleNotesTab(event)"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lw-case-tab-pane" id="pane-checklist">
|
||
<div class="lw-cl-wrap">
|
||
<div class="lw-cl-add-bar">
|
||
<input class="lw-cl-input" id="cl-input" type="text" placeholder="준비 항목 추가..." onkeydown="if(event.key==='Enter')addCheckItem()">
|
||
<button class="lw-cl-add-btn" onclick="addCheckItem()">추가</button>
|
||
<button class="lw-cl-ai-btn" onclick="generateChecklist()" title="AI로 체크리스트 자동 생성">✨ AI</button>
|
||
</div>
|
||
<div class="lw-cl-progress" id="cl-progress" style="display:none">
|
||
<div class="lw-cl-prog-bar"><div class="lw-cl-prog-fill" id="cl-prog-fill"></div></div>
|
||
<span class="lw-cl-prog-text" id="cl-prog-text">0/0</span>
|
||
</div>
|
||
<div class="lw-cl-list" id="cl-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lw-case-tab-pane" id="pane-timeline">
|
||
<div class="lw-tl-wrap">
|
||
<div class="lw-tl-add-bar">
|
||
<input class="lw-tl-date" id="tl-date" type="date">
|
||
<input class="lw-tl-text-input" id="tl-input" type="text" placeholder="이벤트 내용 입력 (예: 소장 접수, 변론기일 등)..." onkeydown="if(event.key==='Enter')addTimelineEvent()">
|
||
<button class="lw-tl-add-btn" onclick="addTimelineEvent()">추가</button>
|
||
</div>
|
||
<div class="lw-tl-list" id="tl-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lw-case-tab-pane" id="pane-files">
|
||
<div class="lw-cl-wrap">
|
||
<div class="lw-cl-add-bar">
|
||
<input type="file" id="case-file-input" style="display:none" multiple onchange="onCaseFilesPicked(this.files)">
|
||
<button class="lw-cl-add-btn" onclick="document.getElementById('case-file-input').click()">📎 자료 업로드</button>
|
||
<span id="case-file-upload-status" style="font-size:11px;color:var(--muted)"></span>
|
||
</div>
|
||
<div class="lw-cl-list" id="case-files-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lw-case-tab-pane" id="pane-docs">
|
||
<div class="lw-cl-wrap">
|
||
<div class="lw-cl-add-bar">
|
||
<input type="file" id="case-doc-input" style="display:none" multiple onchange="onCaseDocsPicked(this.files)">
|
||
<button class="lw-cl-add-btn" onclick="document.getElementById('case-doc-input').click()">📋 서류 업로드</button>
|
||
<span id="case-doc-upload-status" style="font-size:11px;color:var(--muted)"></span>
|
||
</div>
|
||
<div class="lw-cl-list" id="case-docs-list"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Resize ── -->
|
||
<div class="lw-resize" id="resize-handle"></div>
|
||
|
||
<!-- ── Chat ── -->
|
||
<div class="lw-chat" id="chat-panel">
|
||
<div class="lw-chat-hdr">
|
||
<span class="lw-chat-label">⚖️ 법률 AI</span>
|
||
<span id="lw-chat-ctx-label" style="font-size:10px;color:var(--muted);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:110px" title="현재 대화 컨텍스트">일반</span>
|
||
<select class="lw-model-sel" id="model-sel" onchange="saveModel()">
|
||
<option value="">기본 모델</option>
|
||
</select>
|
||
<button class="lw-chat-clear" id="lw-to-case-btn" onclick="convertChatToCase()" title="지금 대화를 새 사건으로 만들기" style="display:none">📁</button>
|
||
<button class="lw-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
|
||
</div>
|
||
<div class="lw-msgs" id="chat-msgs"></div>
|
||
<div class="lw-input-bar">
|
||
<textarea class="lw-textarea" id="chat-input" rows="1" placeholder="법률 질문을 입력하세요..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
|
||
<input type="file" id="file-input" accept=".pdf,.txt" style="display:none" onchange="onFilePicked(this.files[0])">
|
||
<button class="app-mic-btn" onclick="document.getElementById('file-input').click()" title="계약서·법률문서 첨부 (PDF/TXT)">📎</button>
|
||
<button class="app-mic-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력">🎤</button>
|
||
<button class="lw-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('homeclaw_theme',n);}catch{}}
|
||
|
||
// ── Mobile overlay ────────────────────────────────────────────────────────────
|
||
function mobCloseAll(){
|
||
document.querySelector('.lw-sb').classList.remove('mob-open');
|
||
document.getElementById('chat-panel').classList.remove('mob-open');
|
||
document.getElementById('mob-backdrop').classList.remove('show');
|
||
['quick','chat'].forEach(t=>document.getElementById('mobtab-'+t)?.classList.remove('active'));
|
||
document.getElementById('mobtab-home').style.cssText='color:var(--brand,#eab308);border-top:2px solid var(--brand,#eab308)';
|
||
}
|
||
function mobToggle(which){
|
||
const sb=document.querySelector('.lw-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==='quick'&&!sbOpen){sb.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-quick').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='lw_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{};});
|
||
})();
|
||
|
||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||
const MODEL_KEY='lw_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='lw';
|
||
let skillContext='';
|
||
let _sessionId=null;
|
||
// Each case gets its own AI conversation (derived from the case id, no extra
|
||
// field to persist) so working one case never leaks context into another —
|
||
// same pattern as detective-app's per-case sessions. No case selected → the
|
||
// general legal-search session.
|
||
function getSessionId(){
|
||
if(currentCaseId)return 'lw_case_'+currentCaseId;
|
||
return _sessionId||'lw_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='lw_'+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{}
|
||
}
|
||
|
||
// Switches the chat panel to whichever case is currently selected (or the
|
||
// general session when none is) and reloads that context's own history —
|
||
// keeps each case's AI conversation separate from the others and from
|
||
// general 법령/판례 검색 chat, same pattern as detective-app.
|
||
let _chatCtxCaseId='__unset__';
|
||
// "사건으로" 버튼은 일반 대화일 때만 의미가 있다 — 이미 사건 안이면 옮길 곳이 없다.
|
||
// ensureChatContextForCase는 모드 전환·사건 선택 때만 돌아서 최초 로드에는 안 걸린다.
|
||
// 그래서 별도 함수로 두고 부트스트랩에서도 부른다(안 그러면 버튼이 계속 숨어 있다).
|
||
function updateToCaseBtn(){
|
||
const b=document.getElementById('lw-to-case-btn');
|
||
if(b)b.style.display=currentCaseId?'none':'';
|
||
}
|
||
|
||
async function ensureChatContextForCase(){
|
||
if(_chatCtxCaseId===currentCaseId)return;
|
||
_chatCtxCaseId=currentCaseId;
|
||
const c=cases.find(x=>x.id===currentCaseId);
|
||
const label=document.getElementById('lw-chat-ctx-label');
|
||
if(label)label.textContent=c?c.title||'제목 없음':'일반';
|
||
updateToCaseBtn();
|
||
document.getElementById('chat-msgs').innerHTML='';
|
||
await loadHistory();
|
||
}
|
||
|
||
function escAttr(s){return String(s||'').replace(/'/g,''').replace(/"/g,'"');}
|
||
// For values interpolated into a single-quoted JS string literal INSIDE an
|
||
// onclick="..." attribute — escAttr alone isn't enough since the browser
|
||
// HTML-decodes the attribute before the JS parses it.
|
||
function escJsAttr(s){return escAttr(String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,'\\n').replace(/\r/g,''));}
|
||
|
||
// ── Mode toggle (법령·판례 검색 ↔ 사건 관리) ──────────────────────────────────
|
||
let currentMode='search';
|
||
function switchMode(mode){
|
||
currentMode=mode;
|
||
document.getElementById('mode-btn-search').classList.toggle('active',mode==='search');
|
||
document.getElementById('mode-btn-case').classList.toggle('active',mode==='case');
|
||
document.getElementById('sb-search-panel').style.display=mode==='search'?'':'none';
|
||
document.getElementById('sb-case-panel').style.display=mode==='case'?'flex':'none';
|
||
document.querySelector('.lw-center').style.display=mode==='search'?'flex':'none';
|
||
document.getElementById('lw-case-center').style.display=mode==='case'?'flex':'none';
|
||
if(mode==='case'){
|
||
if(!casesLoaded){loadCases();casesLoaded=true;}
|
||
}else{
|
||
currentCaseId=null;
|
||
ensureChatContextForCase();
|
||
}
|
||
if(window.innerWidth<=1024)mobCloseAll();
|
||
}
|
||
|
||
// ── Cases ─────────────────────────────────────────────────────────────────────
|
||
let cases=[];
|
||
let casesLoaded=false;
|
||
let currentCaseId=null;
|
||
let activeCaseTab='notes';
|
||
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/lawyer/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/lawyer/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="lw-case-item${isActive?' active':''}" onclick="selectCase('${c.id}')">
|
||
<div class="lw-case-title">${escHtml(c.title||'제목 없음')}</div>
|
||
<div class="lw-case-meta">
|
||
<span class="lw-status-dot ${dotCls}"></span>${escHtml(c.status||'진행중')}
|
||
${priLabel?`<span class="lw-pri-dot ${priCls}"></span>${priLabel}`:''}
|
||
${date?'· '+date:''}
|
||
${clTotal?`<span style="color:var(--muted)">✅${clDone}/${clTotal}</span>`:''}
|
||
</div>
|
||
<button class="lw-case-rename" onclick="event.stopPropagation();startRenameCase('${c.id}',this)" title="이름 변경">✎</button>
|
||
<button class="lw-case-del" onclick="event.stopPropagation();deleteCase('${c.id}')" title="삭제">✕</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// Double-click a case's title in the sidebar list to rename it inline, without
|
||
// having to select the case first and edit the header input — swaps the title
|
||
// div for a text input, commits on Enter/blur, discards on Escape.
|
||
// btnOrTitleEl: the rename (✎) button that was clicked — looked up via
|
||
// closest() rather than a fixed sibling reference so markup order can change
|
||
// without breaking this.
|
||
function startRenameCase(id,btnOrTitleEl){
|
||
const c=cases.find(x=>x.id===id);
|
||
if(!c)return;
|
||
const titleEl=btnOrTitleEl.classList.contains('lw-case-title')?btnOrTitleEl:btnOrTitleEl.closest('.lw-case-item').querySelector('.lw-case-title');
|
||
if(!titleEl)return;
|
||
const input=document.createElement('input');
|
||
input.className='lw-case-title';
|
||
input.style.cssText='width:100%;box-sizing:border-box;background:var(--panel-2);border:1px solid #eab308;border-radius:4px;padding:1px 4px;font:inherit;color:inherit;';
|
||
input.value=c.title||'';
|
||
const commit=()=>{
|
||
const name=input.value.trim();
|
||
if(name){
|
||
c.title=name;
|
||
if(currentCaseId===id){
|
||
document.getElementById('case-name-input').value=name;
|
||
const label=document.getElementById('lw-chat-ctx-label');
|
||
if(label)label.textContent=name;
|
||
}
|
||
saveCases();
|
||
}
|
||
renderCaseList();
|
||
};
|
||
input.onblur=commit;
|
||
input.onkeydown=(e)=>{
|
||
if(e.key==='Enter'){e.preventDefault();input.blur();}
|
||
else if(e.key==='Escape'){e.preventDefault();input.onblur=null;renderCaseList();}
|
||
};
|
||
input.onclick=(e)=>e.stopPropagation();
|
||
titleEl.replaceWith(input);
|
||
input.focus();input.select();
|
||
}
|
||
|
||
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('case-center-empty').style.display='none';
|
||
const cc=document.getElementById('case-center-body');
|
||
cc.style.display='flex';
|
||
renderCaseList();
|
||
if(activeCaseTab==='checklist')renderChecklist();
|
||
if(activeCaseTab==='timeline')renderTimeline();
|
||
if(activeCaseTab==='files')loadCaseFiles();
|
||
if(activeCaseTab==='docs')loadCaseDocs();
|
||
ensureChatContextForCase();
|
||
if(window.innerWidth<=1024)mobCloseAll();
|
||
}
|
||
|
||
// 지금 보고 있는 "일반" 대화를 새 사건으로 승격한다.
|
||
//
|
||
// 요약만 옮기면 이어서 물었을 때 맥락이 끊기므로, 대화 세션 파일 자체를 사건 세션
|
||
// (`lw_case_<id>`)으로 옮긴다(POST /api/chat/sessions/:id/move). 제목·개요는 대화 내용에서
|
||
// AI가 뽑되, 실패해도 사건 생성 자체는 진행한다 — 이름은 나중에 고치면 되지만 대화를
|
||
// 잃는 건 되돌릴 수 없다.
|
||
async function convertChatToCase(){
|
||
if(currentCaseId){alert('이미 사건 대화입니다. 일반 대화에서만 사용할 수 있습니다.');return;}
|
||
const fromSid=getSessionId();
|
||
if(!fromSid){alert('대화 세션을 찾을 수 없습니다.');return;}
|
||
|
||
// 옮길 내용이 실제로 있는지 먼저 본다 — 빈 대화로 사건을 만들면 껍데기만 남는다.
|
||
let msgs=[];
|
||
try{
|
||
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(fromSid),{headers:authH()});
|
||
if(r.ok){const d=await r.json();msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant');}
|
||
}catch{}
|
||
if(!msgs.length){alert('아직 대화 내용이 없습니다.');return;}
|
||
if(!confirm(`이 대화(${msgs.length}개 메시지)를 새 사건으로 옮길까요?\n일반 대화창은 비워집니다.`))return;
|
||
|
||
const btn=document.getElementById('lw-to-case-btn');
|
||
if(btn){btn.disabled=true;btn.textContent='⏳';}
|
||
try{
|
||
// 1) 대화에서 사건명/개요 추출 (실패해도 계속 진행)
|
||
const transcript=msgs.slice(-24).map(m=>(m.role==='user'?'상담자: ':'AI: ')+String(m.content||'').slice(0,600)).join('\n');
|
||
let title='',notes='';
|
||
try{
|
||
const out=await callChatRaw('다음은 법률 상담 대화입니다. 이 상담을 사건으로 등록하려 합니다.\n'
|
||
+'첫 줄에 "제목: " 으로 시작하는 30자 이내 사건명을, 그 다음 줄부터 "개요:" 로 시작해 핵심 쟁점과 사실관계를 5줄 이내로 정리해 주세요.\n'
|
||
+'대화에 없는 내용을 지어내지 마세요.\n\n'+transcript);
|
||
if(out){
|
||
const mt=out.match(/제목\s*[::]\s*(.+)/);
|
||
if(mt)title=mt[1].trim().slice(0,60);
|
||
const mo=out.split(/개요\s*[::]/)[1];
|
||
if(mo)notes=mo.trim();
|
||
}
|
||
}catch{}
|
||
|
||
// 2) 사건 생성 — 제목 추출이 실패하면 첫 질문을 제목으로 쓴다
|
||
if(!title){
|
||
const firstUser=msgs.find(m=>m.role==='user');
|
||
title=(firstUser?String(firstUser.content||'').replace(/\s+/g,' ').slice(0,30):'')||'상담 사건';
|
||
}
|
||
const c=normalizeCase({id:genId(),title,status:'진행중',
|
||
notes:notes||transcript.slice(0,2000),createdAt:new Date().toISOString()});
|
||
|
||
// 3) 대화 세션을 사건 세션으로 이동 — 여기서 실패하면 사건을 만들지 않는다.
|
||
// 사건만 만들고 대화가 안 따라오면 일반창에 그대로 남아 중복이 생긴다.
|
||
const toSid='lw_case_'+c.id;
|
||
const mv=await fetch('/api/chat/sessions/'+encodeURIComponent(fromSid)+'/move',
|
||
{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({to:toSid})});
|
||
if(!mv.ok){
|
||
const err=await mv.json().catch(()=>({}));
|
||
throw new Error(err.error||('HTTP '+mv.status));
|
||
}
|
||
|
||
cases.unshift(c);
|
||
await saveCases();
|
||
// 일반 대화는 세션 파일이 옮겨져 비었다 — 다음에 쓸 새 세션 ID를 발급받는다.
|
||
_sessionId='lw_'+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{}
|
||
selectCase(c.id);
|
||
addMsg('system','✅ 대화를 사건 "'+escHtml(c.title)+'"으로 옮겼습니다.');
|
||
}catch(e){
|
||
alert('사건으로 전환하지 못했습니다: '+(e.message||e)+'\n대화는 그대로 남아 있습니다.');
|
||
}finally{
|
||
if(btn){btn.disabled=false;btn.textContent='📁';}
|
||
}
|
||
}
|
||
|
||
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);
|
||
fetch('/api/chat/sessions/'+encodeURIComponent('lw_case_'+id),{method:'DELETE',headers:authH()}).catch(()=>{});
|
||
if(currentCaseId===id){
|
||
currentCaseId=null;
|
||
document.getElementById('case-center-empty').style.display='';
|
||
document.getElementById('case-center-body').style.display='none';
|
||
ensureChatContextForCase();
|
||
}
|
||
await saveCases();
|
||
renderCaseList();
|
||
}
|
||
|
||
function onCaseNameChange(){
|
||
const c=cases.find(x=>x.id===currentCaseId);
|
||
if(c)c.title=document.getElementById('case-name-input').value;
|
||
renderCaseList();
|
||
const label=document.getElementById('lw-chat-ctx-label');
|
||
if(label&&c)label.textContent=c.title||'제목 없음';
|
||
}
|
||
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 */}
|
||
function handleNotesTab(e){
|
||
if(e.key!=='Tab')return;
|
||
e.preventDefault();
|
||
const ta=e.target;
|
||
const start=ta.selectionStart,end=ta.selectionEnd;
|
||
ta.value=ta.value.slice(0,start)+'\t'+ta.value.slice(end);
|
||
ta.selectionStart=ta.selectionEnd=start+1;
|
||
onNotesChange();
|
||
}
|
||
|
||
// A no-UI chat call (no typing indicator/bubble in the visible panel) for
|
||
// small background AI tasks like auto-naming — callChatRaw is the visible-
|
||
// bubble version used by generateChecklist, this is the silent counterpart.
|
||
async function quietChatCall(message){
|
||
try{
|
||
const body={message,sessionId:'lw_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)return null;
|
||
const reader=r.body.getReader();const dec=new TextDecoder();let buf='',textBuf='';
|
||
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==='token'||ev.type==='text_delta')textBuf+=(ev.text||'');
|
||
}
|
||
}
|
||
return textBuf.trim();
|
||
}catch{return null;}
|
||
}
|
||
|
||
// Auto-names a new case from its notes the first time they're written, so
|
||
// "새 사건" doesn't have to be manually retitled — only fires while the title
|
||
// is still the untouched default, and never overrides a name the user typed
|
||
// in themselves (checked again after the AI call returns, in case they
|
||
// switched case or renamed it while waiting).
|
||
let lwAutoNaming=false;
|
||
// sourceText: content to name the case FROM. Falls back to the notes field
|
||
// when omitted (notes-blur call site) — the chat-driven call site (callChat,
|
||
// once a turn finishes with a case selected) passes the user's message
|
||
// directly, since a case discussed only through chat never touches the notes
|
||
// textarea at all and would otherwise never trigger this.
|
||
async function autoNameCaseIfNeeded(sourceText){
|
||
const c=currentCase();
|
||
if(!c||lwAutoNaming)return;
|
||
const title=(document.getElementById('case-name-input').value||'').trim();
|
||
const text=(sourceText!==undefined?sourceText:document.getElementById('case-notes').value)||'';
|
||
if(title!=='새 사건'||text.trim().length<15)return;
|
||
lwAutoNaming=true;
|
||
const targetId=c.id;
|
||
try{
|
||
const prompt=`다음은 법률 상담 내용입니다. 8자 이내의 간결한 사건명을 지어주세요. 사건명만 출력하고 다른 설명·따옴표·마침표는 넣지 마세요.\n\n${text.slice(0,500)}`;
|
||
const result=await quietChatCall(prompt);
|
||
if(!result||currentCaseId!==targetId)return; // case switched away while waiting
|
||
const cur=cases.find(x=>x.id===targetId);
|
||
if(!cur||(document.getElementById('case-name-input').value||'').trim()!=='새 사건')return; // user already renamed it
|
||
const name=result.split('\n')[0].replace(/^["'“”]+|["'“”]+$/g,'').trim().slice(0,30);
|
||
if(!name)return;
|
||
cur.title=name;
|
||
document.getElementById('case-name-input').value=name;
|
||
renderCaseList();
|
||
const label=document.getElementById('lw-chat-ctx-label');
|
||
if(label)label.textContent=name;
|
||
saveCases();
|
||
}finally{lwAutoNaming=false;}
|
||
}
|
||
|
||
function switchCaseTab(tab){
|
||
activeCaseTab=tab;
|
||
['notes','checklist','timeline','files','docs'].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();
|
||
if(tab==='files')loadCaseFiles();
|
||
if(tab==='docs')loadCaseDocs();
|
||
}
|
||
|
||
// ── 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="lw-cl-empty">준비 항목을 추가하거나<br>AI 자동 생성을 이용하세요.</div>';
|
||
return;
|
||
}
|
||
list.innerHTML=items.map(item=>`
|
||
<div class="lw-cl-item${item.done?' done':''}" id="cli-${item.id}">
|
||
<div class="lw-cl-cb${item.done?' checked':''}" onclick="toggleCheck('${item.id}')">${item.done?'✓':''}</div>
|
||
<div class="lw-cl-text">${escHtml(item.text)}</div>
|
||
<button class="lw-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<=1024)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="lw-tl-empty">날짜와 이벤트를 추가하여<br>사건 타임라인을 구성하세요.</div>';
|
||
return;
|
||
}
|
||
list.innerHTML=events.map(ev=>`
|
||
<div class="lw-tl-item">
|
||
<div class="lw-tl-line"><div class="lw-tl-dot"></div><div class="lw-tl-rod"></div></div>
|
||
<div class="lw-tl-body">
|
||
<div class="lw-tl-date-lbl">${escHtml(ev.date)}</div>
|
||
<div class="lw-tl-txt">
|
||
<span>${escHtml(ev.text)}</span>
|
||
<button class="lw-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();
|
||
}
|
||
|
||
// ── Files (첨부자료 / 제출서류) ────────────────────────────────────────────────
|
||
function fmtFileSize(n){
|
||
if(n<1024)return n+'B';
|
||
if(n<1024*1024)return (n/1024).toFixed(1)+'KB';
|
||
return (n/1024/1024).toFixed(1)+'MB';
|
||
}
|
||
|
||
const LW_TEXT_EDIT_EXTS=/\.(txt|md|csv)$/i;
|
||
const LW_CATEGORY_LABELS={photo:'📷 사진',video:'🎥 동영상',document:'📄 문서',other:'📦 기타'};
|
||
const LW_CATEGORY_ORDER=['photo','video','document','other'];
|
||
const LW_CATEGORY_ICONS={photo:'📷',video:'🎥',document:'📄',other:'📦'};
|
||
|
||
// Files are separated by which tab's upload button was actually used
|
||
// (uploadOneCaseFile prefixes with 'attachments/' or 'submissions/'), not by
|
||
// guessing from the file extension — a PDF uploaded as 참고자료 stayed a PDF,
|
||
// but the old category-based split (mirroring detective-app's evidence/docs
|
||
// split) silently routed every PDF/DOCX/etc into 제출서류 regardless of which
|
||
// button was clicked, so an upload could "disappear" from the tab it was
|
||
// added from. Files uploaded before this fix have no folder prefix (folder:
|
||
// '') — those still show up under 첨부자료 so nothing already on disk goes missing.
|
||
async function loadCaseFiles(){
|
||
const list=document.getElementById('case-files-list');
|
||
if(!currentCaseId){list.innerHTML='';return;}
|
||
list.innerHTML='<div class="lw-cl-empty">불러오는 중...</div>';
|
||
try{
|
||
const r=await fetch('/api/lawyer/files?caseId='+encodeURIComponent(currentCaseId),{headers:authH()});
|
||
const d=await r.json();
|
||
const all=Array.isArray(d.files)?d.files:[];
|
||
renderCaseFileList(list,all.filter(f=>f.folder==='attachments'||f.folder===''));
|
||
}catch{list.innerHTML='<div class="lw-cl-empty">파일 목록을 불러오지 못했습니다.</div>';}
|
||
}
|
||
|
||
async function loadCaseDocs(){
|
||
const list=document.getElementById('case-docs-list');
|
||
if(!currentCaseId){list.innerHTML='';return;}
|
||
list.innerHTML='<div class="lw-cl-empty">불러오는 중...</div>';
|
||
try{
|
||
const r=await fetch('/api/lawyer/files?caseId='+encodeURIComponent(currentCaseId),{headers:authH()});
|
||
const d=await r.json();
|
||
const all=Array.isArray(d.files)?d.files:[];
|
||
const docs=all.filter(f=>f.folder==='submissions').sort((a,b)=>new Date(b.mtime)-new Date(a.mtime));
|
||
if(!docs.length){list.innerHTML='<div class="lw-cl-empty">등록된 제출서류가 없습니다.</div>';return;}
|
||
list.innerHTML=docs.map(f=>renderCaseFileRow(f)).join('');
|
||
}catch{list.innerHTML='<div class="lw-cl-empty">서류 목록을 불러오지 못했습니다.</div>';}
|
||
}
|
||
|
||
function renderCaseFileList(list,files){
|
||
if(!files.length){list.innerHTML='<div class="lw-cl-empty">자료를 업로드하세요.</div>';return;}
|
||
const groups={};
|
||
files.forEach(f=>{(groups[f.category||'other']=groups[f.category||'other']||[]).push(f);});
|
||
list.innerHTML=LW_CATEGORY_ORDER.filter(cat=>groups[cat]?.length).map(cat=>{
|
||
const items=groups[cat];
|
||
return `<div class="lw-ev-group-title">${LW_CATEGORY_LABELS[cat]} (${items.length})</div>`
|
||
+items.map(f=>renderCaseFileRow(f)).join('');
|
||
}).join('');
|
||
}
|
||
|
||
function renderCaseFileRow(f){
|
||
const isPdf=/\.pdf$/i.test(f.name);
|
||
const isCloudEditable=/\.(hwp|odt|docx?)$/i.test(f.name);
|
||
const isText=LW_TEXT_EDIT_EXTS.test(f.name);
|
||
const icon=LW_CATEGORY_ICONS[f.category]||'📦';
|
||
const nameHtml=isText
|
||
?`<a href="javascript:void(0)" onclick="openTextEditor('${escJsAttr(f.relPath)}','${escJsAttr(f.url)}','${escJsAttr(f.name)}')" style="flex:1;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all">${escHtml(f.name)}</a>`
|
||
:`<a href="${escAttr(f.url)}" target="_blank" style="flex:1;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all">${escHtml(f.name)}</a>`;
|
||
return `<div class="lw-cl-item">
|
||
<span style="flex-shrink:0">${icon}</span>
|
||
${nameHtml}
|
||
<span style="font-size:10px;color:var(--muted);flex-shrink:0">${fmtFileSize(f.size)}</span>
|
||
${isPdf?`<a href="/pdf-editor-open?app=lawyer&caseId=${encodeURIComponent(currentCaseId)}" target="_blank" class="lw-cl-del" style="opacity:.7" title="PDF 편집기를 새 창에서 엽니다. 편집기의 업로드 버튼에서 이 파일을 직접 선택하세요. 수정 후 다운로드하면 사건 폴더에 자동 저장됩니다.">✏️</a>`:''}
|
||
${isCloudEditable?`<a href="/api/lawyer/collabora-open?caseId=${encodeURIComponent(currentCaseId)}&relPath=${encodeURIComponent(f.relPath)}" target="_blank" class="lw-cl-del" style="opacity:.7" title="Collabora에서 바로 편집합니다. 입력하는 대로 사건 폴더에 자동 저장됩니다.">🌐</a>`:''}
|
||
<a href="${escAttr(f.url)}" download="${escAttr(f.name)}" class="lw-cl-del" title="기기에 다운로드">💾</a>
|
||
<button class="lw-cl-del" onclick="deleteCaseFile('${escJsAttr(f.relPath)}')" title="삭제">✕</button>
|
||
</div>`;
|
||
}
|
||
|
||
// folder: 'attachments' or 'submissions' — determines which tab this file
|
||
// shows up in (see loadCaseFiles/loadCaseDocs), independent of file type.
|
||
function uploadOneCaseFile(file,folder){
|
||
return new Promise((resolve,reject)=>{
|
||
const fd=new FormData();
|
||
fd.append('file',file,`${folder}/${file.name}`);
|
||
fetch('/api/lawyer/upload?caseId='+encodeURIComponent(currentCaseId),{
|
||
method:'POST',headers:authH(),body:fd,
|
||
}).then(r=>r.json()).then(d=>d.success?resolve(d):reject(d)).catch(reject);
|
||
});
|
||
}
|
||
|
||
async function onCaseFilesPicked(fileList){
|
||
if(!currentCaseId){alert('사건을 먼저 선택하세요.');return;}
|
||
const status=document.getElementById('case-file-upload-status');
|
||
const files=[...fileList];
|
||
for(let i=0;i<files.length;i++){
|
||
status.textContent=`업로드 중... (${i+1}/${files.length})`;
|
||
try{await uploadOneCaseFile(files[i],'attachments');}catch{}
|
||
}
|
||
status.textContent='';
|
||
document.getElementById('case-file-input').value='';
|
||
loadCaseFiles();
|
||
}
|
||
|
||
async function onCaseDocsPicked(fileList){
|
||
if(!currentCaseId){alert('사건을 먼저 선택하세요.');return;}
|
||
const status=document.getElementById('case-doc-upload-status');
|
||
const files=[...fileList];
|
||
for(let i=0;i<files.length;i++){
|
||
status.textContent=`업로드 중... (${i+1}/${files.length})`;
|
||
try{await uploadOneCaseFile(files[i],'submissions');}catch{}
|
||
}
|
||
status.textContent='';
|
||
document.getElementById('case-doc-input').value='';
|
||
loadCaseDocs();
|
||
}
|
||
|
||
async function deleteCaseFile(relPath){
|
||
if(!currentCaseId)return;
|
||
if(!confirm('이 파일을 삭제할까요?'))return;
|
||
try{
|
||
await fetch('/api/lawyer/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({caseId:currentCaseId,relPath})});
|
||
}catch{}
|
||
if(activeCaseTab==='files')loadCaseFiles();
|
||
if(activeCaseTab==='docs')loadCaseDocs();
|
||
}
|
||
|
||
// ── Built-in text editor (.txt/.md/.csv) ─────────────────────────────────────
|
||
// Old Korean documents are often EUC-KR/CP949, not UTF-8 — fetch raw bytes and
|
||
// decode client-side with a manual override instead of letting the browser
|
||
// guess (and garble) via a plain <a target=_blank>.
|
||
let lwTeState=null; // {caseId, relPath, name, bytes}
|
||
|
||
function decodeTextBytes(buf,encoding){
|
||
if(encoding){
|
||
try{return new TextDecoder(encoding).decode(buf);}catch{return new TextDecoder('utf-8').decode(buf);}
|
||
}
|
||
try{return new TextDecoder('utf-8',{fatal:true}).decode(buf);}catch{}
|
||
try{return new TextDecoder('euc-kr').decode(buf);}catch{return new TextDecoder('utf-8').decode(buf);}
|
||
}
|
||
|
||
async function openTextEditor(relPath,url,name){
|
||
const modal=document.getElementById('lw-text-editor-modal');
|
||
const ta=document.getElementById('lw-te-textarea');
|
||
const status=document.getElementById('lw-te-status');
|
||
document.getElementById('lw-te-filename').textContent=name;
|
||
ta.value='불러오는 중...';
|
||
status.textContent='';
|
||
modal.style.display='flex';
|
||
try{
|
||
const r=await fetch(url,{headers:authH(),cache:'no-store'});
|
||
const buf=await r.arrayBuffer();
|
||
const isUtf8=(()=>{try{new TextDecoder('utf-8',{fatal:true}).decode(buf);return true;}catch{return false;}})();
|
||
lwTeState={caseId:currentCaseId,relPath,name,bytes:buf};
|
||
document.getElementById('lw-te-encoding').value=isUtf8?'utf-8':'euc-kr';
|
||
ta.value=decodeTextBytes(buf,isUtf8?'utf-8':'euc-kr');
|
||
status.textContent=isUtf8?'':'⚠️ UTF-8이 아닌 것으로 보여 EUC-KR로 자동 표시했습니다. 깨져 보이면 인코딩을 바꿔보세요.';
|
||
}catch(e){
|
||
ta.value='';
|
||
status.textContent='파일을 불러오지 못했습니다: '+e.message;
|
||
}
|
||
}
|
||
|
||
function lwTextEditorRedecode(){
|
||
if(!lwTeState)return;
|
||
const enc=document.getElementById('lw-te-encoding').value;
|
||
document.getElementById('lw-te-textarea').value=decodeTextBytes(lwTeState.bytes,enc);
|
||
document.getElementById('lw-te-status').textContent='';
|
||
}
|
||
|
||
function closeTextEditor(){
|
||
document.getElementById('lw-text-editor-modal').style.display='none';
|
||
lwTeState=null;
|
||
}
|
||
|
||
async function saveTextEditor(){
|
||
if(!lwTeState)return;
|
||
const status=document.getElementById('lw-te-status');
|
||
status.textContent='저장 중...';
|
||
try{
|
||
const content=document.getElementById('lw-te-textarea').value;
|
||
const r=await fetch('/api/lawyer/files/content',{
|
||
method:'PUT',headers:authH({'Content-Type':'application/json'}),
|
||
body:JSON.stringify({caseId:lwTeState.caseId,relPath:lwTeState.relPath,content}),
|
||
});
|
||
const d=await r.json();
|
||
if(!r.ok||!d.success)throw new Error(d.error||'save failed');
|
||
status.textContent='저장됨 (UTF-8)';
|
||
setTimeout(closeTextEditor,600);
|
||
}catch(e){
|
||
status.textContent='저장 실패: '+e.message;
|
||
}
|
||
}
|
||
|
||
// ── Raw (no-tools, throwaway-session) chat call — used by AI-assisted
|
||
// generation buttons (체크리스트 자동생성) that need a plain text result back
|
||
// rather than a full streamed conversation turn.
|
||
async function callChatRaw(message){
|
||
const msgs=document.getElementById('chat-msgs');
|
||
const typingDiv=document.createElement('div');typingDiv.className='lw-msg assistant';
|
||
typingDiv.innerHTML='<div class="lw-typing"><div class="lw-dot"></div><div class="lw-dot"></div><div class="lw-dot"></div></div>';
|
||
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
|
||
let bubble=null,textBuf='',thinkBuf='';
|
||
try{
|
||
const body={message,sessionId:'lw_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='lw-msg assistant';
|
||
bubble=document.createElement('div');bubble.className='lw-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="lw-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;
|
||
}
|
||
}
|
||
|
||
// ── AI-assisted case 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<=1024)mobToggle('chat');
|
||
}
|
||
|
||
async function generateOpinion(){
|
||
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<=1024)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}
|
||
|
||
---
|
||
|
||
먼저 어떤 종류의 서면이 가장 적합한지 밝히고, 그 서면의 표준 양식(당사자 표시, 청구/고소 취지, 사실관계, 법적 근거, 결론 등)에 맞춰 완성된 초안을 작성해주세요. 알 수 없는 정보는 [ ]로 표시하세요.
|
||
|
||
작성 후 추가로 보완이 필요한 정보(당사자 인적사항, 증거자료 목록, 청구금액 등)를 정리해서 안내해 주세요.`;
|
||
addMsg('user',escHtml('⚖️ 서면 초안 작성 요청: '+c.title));
|
||
callChat(prompt);
|
||
if(window.innerWidth<=1024)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, 전화번호, 접수 방법(온라인/방문/우편)을 포함해주세요.
|
||
|
||
참고 (해당하는 것만):
|
||
- 전자소송포털 (민사·행정 소송): https://ecfs.scourt.go.kr
|
||
- 대한법률구조공단 (법률 서식·무료 상담): https://www.klac.or.kr
|
||
- 대법원 종합법률정보 (판례 검색): https://glaw.scourt.go.kr
|
||
- 경찰청 사이버수사국 ECRM (사이버범죄 고소): https://ecrm.police.go.kr
|
||
- 검찰청 형사사법포털 (일반 형사): https://www.spo.go.kr
|
||
- 고용노동부 민원마당 (임금·노동): https://minwon.moel.go.kr — 전화 1350
|
||
- 헌법재판소 (헌법소원): https://www.ccourt.go.kr
|
||
|
||
### 3. 관할 법원·기관${address?`
|
||
web_search를 사용해 "${address} 관할 법원"과 관련 기관을 검색하고 기관명·주소·전화번호를 알려주세요.`:`
|
||
지역이 입력되지 않아 검색을 생략합니다. 지역을 알면 관할 기관을 찾아드릴 수 있습니다.`}
|
||
|
||
### 4. 접수 절차 요약
|
||
서면 준비 → 제출 → 접수 확인까지 단계별 절차를 간략히 안내해주세요.`;
|
||
addMsg('user',escHtml('📮 제출 안내: '+c.title+(address?' ('+address+')':'')));
|
||
callChat(msg);
|
||
if(window.innerWidth<=1024)mobToggle('chat');
|
||
}
|
||
|
||
const SKILL_CONTENT="---\nname: Legal Advisor\ndescription: 한국법 및 영미법 전문 법률 어시스턴트. 법령 조회·판례 검색·계약서 검토를 도구와 함께 수행.\nemoji: \"⚖️\"\nversion: 1.0.0\ntriggers: [법, 법률, 계약, 소송, 판례, 형사, 민사, 법원, 고소, 고발, 헌법, 조항, 규정, 위반, 손해배상, 계약서, 법적, 권리, 의무, 처벌, 형량, 변호사, 법무]\n---\n\n당신은 한국법과 영미법에 능통한 법률 어시스턴트입니다. 아래 도구를 활용해 사실에 근거한 법률 정보를 제공하세요.\n\n### 사용 가능한 법률 도구\n\n| 도구 | 용도 |\n|---|---|\n| `korean_law_search` | 국가법령정보센터 법령 검색 (target=\"law\") |\n| `korean_law_search` | 대법원 판례 검색 (target=\"prec\") |\n| `korean_law_fetch` | 법령 전문·판례 전문 조회 |\n| `us_case_search` | 미국 연방/주 판례 (CourtListener) |\n| `web_search` | 최신 개정사항, 법령 해석례, 학술자료, 해외 법령 |\n| `pdf_read` | 계약서·법률문서 PDF 분석 |\n\n### 우선 참조 사이트\n\n`web_search` 호출 시 아래 **site: 연산자를 query에 포함**해 권위 있는 출처에서 우선 검색하세요.\n\n| 주제 | site: 연산자 | 설명 |\n|---|---|---|\n| 한국 법령 원문 | `site:law.go.kr` | 국가법령정보센터 (법률·시행령·시행규칙) |\n| 대법원 판례 | `site:glaw.scourt.go.kr` | 대법원 종합법률정보 판례 검색 |\n| 법령 해석례 | `site:moleg.go.kr` | 법제처 법령 해석 사례 |\n| 헌법재판 결정 | `site:ccourt.go.kr` | 헌법재판소 결정례 |\n| 영국 법령 | `site:legislation.gov.uk` | UK Acts / Statutory Instruments |\n| 미국 연방규정 | `site:law.cornell.edu` | Cornell LII (CFR·USC) |\n\n예시: 임대차보호법 최신 개정 → `web_search(\"site:law.go.kr 주택임대차보호법 개정\")`\n\n### ⛔ 법령·판례 식별자 대조 필수\n\n검색으로 찾은 법령·판례를 인용하기 전에 반드시 아래를 대조하세요:\n- **시행일·개정횟수** — 같은 법률이라도 개정 전·후로 조문 번호·내용이 바뀔 수 있음 — 검색 결과가 현행법(최신 시행일 기준)인지 `korean_law_fetch`로 시행일자를 직접 확인\n- **사건번호 전체 일치** — 유사한 사건명·쟁점을 다루는 다른 판례와 혼동 금지 (예: \"대법원 2020다2345\" 와 \"2020다12345\"는 전혀 다른 판례)\n- **동명의 법률 혼동** — 법률명이 비슷하거나 개정 전 명칭을 쓰는 경우가 있으므로 법령 전문 조회로 정확한 명칭·조문을 확인\n- 이 대조를 거치지 않은 추론은 \"확인 필요\"로 명시하고, 단정적으로 단언하지 마세요.\n\n### 계약서 검토 방법론\n\n계약서 원문 또는 PDF가 제공되면 다음 순서로 분석하세요:\n\n1. **기본 구조** — 당사자, 목적, 기간, 대가(금액·지급조건)\n2. **위험 조항** — 면책·책임제한, 일방적 해지권, 위약벌, 손해배상 상한\n3. **누락 조항** — 분쟁해결(중재·소송), 준거법, 관할, 불가항력(Force Majeure), 비밀유지(NDA)\n4. **강행규정 위반** — `korean_law_search`로 관련 법령 확인 (소비자보호법, 공정거래법, 근로기준법 등)\n5. **개선 제안** — 수정 또는 추가가 필요한 조항을 구체적 문안으로 제시\n\n### 응답 규칙\n\n1. **출처 필수**: 법령 인용 시 \"민법 제○조\", 판례 인용 시 \"대법원 ○○○○. ○. ○. 선고 ○○다○○○○ 판결\" 형식으로 명시.\n2. **불확실한 법적 판단**: \"법률 전문가(변호사) 확인 권고\" 문구를 반드시 추가.\n3. **관할 구분**: 한국법 질문 → `korean_law_search` 우선. 미국/영국법 → `us_case_search`·`web_search`.\n4. **언어**: 사용자가 한국어로 질문하면 한국어, 영어로 질문하면 영어로 답변.\n5. **면책 고지**: 이 서비스는 정보 제공 목적이며 법률 자문을 대체하지 않습니다.\n";
|
||
function loadSkill(){
|
||
skillContext=SKILL_CONTENT;
|
||
document.getElementById('skill-badge').textContent='⚖️ 법률 전문가 활성';
|
||
}
|
||
|
||
// ── Search ────────────────────────────────────────────────────────────────────
|
||
let searchType='prec'; // 법률앱에서 가장 자주 쓰는 게 판례라 기본값으로 둔다(2026-08-21)
|
||
let currentDetailText='';
|
||
let currentDetailTitle='';
|
||
|
||
// 드롭다운 대상(헌재결정례 등)은 탭이 없다 — 고르면 탭 하이라이트를 모두 끄고
|
||
// 드롭다운 자체가 현재 선택을 나타낸다. 탭을 다시 누르면 드롭다운은 안내문으로 되돌린다.
|
||
const EXTRA_TARGETS={detc:'헌재결정례',expc:'법령해석례',decc:'행정심판례',ppc:'심판례'};
|
||
function setSearchType(type){
|
||
if(!type)return; // 드롭다운의 안내 항목("+ 그 밖의…")을 고른 경우
|
||
searchType=type;
|
||
['law','prec','ordinance','us'].forEach(t=>{document.getElementById('stab-'+t)?.classList.toggle('active',t===type);});
|
||
const more=document.getElementById('stab-more');
|
||
if(more)more.value=EXTRA_TARGETS[type]?type:'';
|
||
const ph={law:'법령명 또는 내용 키워드',prec:'판례 키워드, 죄명, 법원명',ordinance:'자치법규 키워드',
|
||
us:'English case keywords',detc:'헌재 결정 키워드',expc:'법령해석 안건 키워드',
|
||
decc:'행정심판 사건 키워드',ppc:'심판 안건 키워드'};
|
||
document.getElementById('search-input').placeholder=(ph[type]||'검색어를 입력하세요...')+' 검색...';
|
||
}
|
||
|
||
function escHtml(s){return String(s ?? '').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||
|
||
async function doSearch(){
|
||
const q=document.getElementById('search-input').value.trim();
|
||
if(!q)return;
|
||
const btn=document.getElementById('search-btn');
|
||
btn.disabled=true;btn.textContent='검색 중...';
|
||
const sort=document.getElementById('sort-sel').value;
|
||
const lang=searchType==='us'?'us':'ko';
|
||
const target=searchType==='us'?'law':searchType; // us는 lang=us로 갈리므로 target은 안 쓰임
|
||
try{
|
||
const r=await fetch(`/api/legal/search?q=${encodeURIComponent(q)}&target=${target}&sort=${sort}&n=20&lang=${lang}`,{headers:authH()});
|
||
const data=await r.json();
|
||
renderResults(data,q);
|
||
}catch(e){
|
||
document.getElementById('results-hdr').textContent='검색 실패';
|
||
document.getElementById('results-list').innerHTML=`<div style="padding:12px;font-size:12px;color:var(--err)">오류: ${escHtml(String(e.message))}</div>`;
|
||
}finally{btn.disabled=false;btn.textContent='🔍 검색';}
|
||
}
|
||
|
||
function renderResults(data,query){
|
||
const list=document.getElementById('results-list');
|
||
const hdr=document.getElementById('results-hdr');
|
||
if(!data.success||data.error){
|
||
hdr.textContent='검색 오류';
|
||
list.innerHTML=`<div style="padding:12px;font-size:12px;color:var(--err)">${escHtml(data.error||'알 수 없는 오류')}</div>`;
|
||
return;
|
||
}
|
||
// 키가 없어 검색 자체가 불가능한 경우(미국 판례 등)는 "결과 없음"과 구분해서 보여준다.
|
||
// 안 그러면 "다른 키워드로 검색해보세요"가 떠서 사용자가 헛되이 재시도한다.
|
||
if(data.data?.api_key_missing){
|
||
hdr.textContent='설정 필요';
|
||
list.innerHTML='<div style="padding:12px;font-size:12px;line-height:1.7;white-space:pre-wrap;color:var(--muted)">'
|
||
+escHtml(data.stdout||'API 키가 설정되지 않았습니다.')+'</div>';
|
||
return;
|
||
}
|
||
const results=(data.data?.results)||[];
|
||
const total=data.data?.total||0;
|
||
const isPrec=searchType==='prec';const isUs=searchType==='us';
|
||
hdr.textContent=`검색 결과 ${total}건 중 ${results.length}건`;
|
||
if(!results.length){list.innerHTML='<div style="padding:12px;font-size:12px;color:var(--muted)">결과가 없습니다. 다른 키워드로 검색해보세요.</div>';return;}
|
||
list.innerHTML=results.map((r,i)=>{
|
||
const badge=isPrec?'badge-prec':(isUs?'badge-us':'badge-law');
|
||
const badgeLabel=isPrec?'판례':(isUs?'미국 판례':(EXTRA_TARGETS[searchType]||(searchType==='ordinance'?'자치법규':'법령')));
|
||
const title=r.name||r.caseName||r.case_no||'제목 없음';
|
||
let meta='';
|
||
if(isPrec||EXTRA_TARGETS[searchType])meta=[r.court,r.date,r.type,r.case_no].filter(Boolean).join(' · ');
|
||
else if(isUs)meta=`${r.court||''}${r.dateFiled?' · '+r.dateFiled:''}`;
|
||
else meta=`${r.ministry||r.type||''}${r.effective_date?' · 시행 '+r.effective_date:''}`;
|
||
// 서브 검색은 제목+메타를 합친 문자열에서 찾는다. 렌더 시점에 미리 만들어 두면
|
||
// 입력할 때마다 DOM을 다시 읽지 않아도 된다.
|
||
const hay=(title+' '+meta+' '+(r.case_no||'')).toLowerCase();
|
||
return `<div class="lw-result-item" id="ri-${i}" onclick="selectResult(${i})" data-idx="${i}" data-id="${escHtml(String(r.id||''))}" data-name="${escHtml(title)}" data-is-prec="${isPrec}" data-is-us="${isUs}" data-hay="${escHtml(hay)}">
|
||
<div class="lw-ri-name"><span class="lw-ri-badge ${badge}">${badgeLabel}</span>${escHtml(title)}</div>
|
||
<div class="lw-ri-meta">${escHtml(meta)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// 새 검색이 오면 이전 좁히기 조건은 버린다 — 안 그러면 결과가 있는데도 빈 화면으로 보인다.
|
||
const sf=document.getElementById('lw-subfilter');
|
||
if(sf)sf.value='';
|
||
const row=document.getElementById('lw-subfilter-row');
|
||
if(row)row.style.display=results.length>1?'':'none'; // 1건이면 좁힐 것이 없다
|
||
_subFilterTotal=results.length;
|
||
}
|
||
|
||
// 이미 받아온 결과 안에서만 좁힌다. 서버를 다시 부르지 않으므로 즉시 반응하고,
|
||
// 검색 쿼터(law.go.kr)도 쓰지 않는다.
|
||
let _subFilterTotal=0;
|
||
function applySubFilter(){
|
||
const q=(document.getElementById('lw-subfilter')?.value||'').trim().toLowerCase();
|
||
const items=[...document.querySelectorAll('.lw-result-item')];
|
||
let shown=0;
|
||
for(const el of items){
|
||
const hit=!q||(el.dataset.hay||'').includes(q);
|
||
el.style.display=hit?'':'none';
|
||
if(hit)shown++;
|
||
}
|
||
const hdr=document.getElementById('results-hdr');
|
||
if(hdr)hdr.textContent=q?`좁히기: ${shown}건 / ${_subFilterTotal}건`:`검색 결과 ${_subFilterTotal}건`;
|
||
}
|
||
|
||
async function selectResult(idx){
|
||
if(window.innerWidth<=1024) mobCloseAll();
|
||
document.querySelectorAll('.lw-result-item').forEach(el=>el.classList.remove('active'));
|
||
const el=document.getElementById('ri-'+idx);
|
||
if(el)el.classList.add('active');
|
||
const id=el?.dataset.id;
|
||
const name=el?.dataset.name||'상세 내용';
|
||
const isPrec=el?.dataset.isPrec==='true';
|
||
const isUs=el?.dataset.isUs==='true';
|
||
|
||
document.getElementById('detail-title').textContent=name;
|
||
document.getElementById('ask-ai-btn').style.display='none';
|
||
currentDetailText='';currentDetailTitle=name;
|
||
|
||
if(isUs){
|
||
// US cases: just show snippet from results list data, link to CourtListener
|
||
const results=document.querySelectorAll('.lw-result-item');
|
||
const body=document.getElementById('detail-body');
|
||
body.innerHTML=`<div class="lw-loading">미국 판례는 CourtListener에서 원문을 확인하세요.</div>`;
|
||
currentDetailText=`미국 판례: ${name}`;
|
||
document.getElementById('ask-ai-btn').style.display='';
|
||
return;
|
||
}
|
||
|
||
if(!id){
|
||
document.getElementById('detail-body').innerHTML='<div class="lw-loading">ID를 찾을 수 없습니다.</div>';return;
|
||
}
|
||
|
||
const body=document.getElementById('detail-body');
|
||
body.innerHTML='<div class="lw-loading">⏳ 전문 불러오는 중...</div>';
|
||
|
||
try{
|
||
const r=await fetch(`/api/legal/fetch?id=${encodeURIComponent(id)}&target=${isPrec?'prec':'law'}`,{headers:authH()});
|
||
const data=await r.json();
|
||
if(!data.success||data.error){body.innerHTML=`<div style="color:var(--err);padding:12px;font-size:12px">${escHtml(data.error||'로드 실패')}</div>`;return;}
|
||
currentDetailText=data.stdout||'';
|
||
renderDetail(data.stdout||'',isPrec);
|
||
document.getElementById('ask-ai-btn').style.display='';
|
||
}catch(e){
|
||
body.innerHTML=`<div style="color:var(--err);padding:12px;font-size:12px">오류: ${escHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderDetail(text,isPrec){
|
||
const body=document.getElementById('detail-body');
|
||
// Convert markdown-ish text to HTML
|
||
const html=text
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/^## (.+)$/gm,'<h2>$1</h2>')
|
||
.replace(/^### (.+)$/gm,'<h3>$1</h3>')
|
||
.replace(/^\*\*(.+?)\*\*$/gm,'<p><strong>$1</strong></p>')
|
||
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
||
.replace(/^- (.+)$/gm,'<li>$1</li>')
|
||
.replace(/(<li>.*<\/li>)/gs,m=>`<ul style="padding-left:16px;margin:4px 0">${m}</ul>`)
|
||
.replace(/\n\n/g,'</p><p>')
|
||
.replace(/^(?!<[hupli])/m,'<p>').replace(/(?<![>])$/,'</p>')
|
||
.replace(/<p><\/p>/g,'')
|
||
.replace(/<p>(<h[23]>)/g,'$1').replace(/(<\/h[23]>)<\/p>/g,'$1');
|
||
body.innerHTML=html;
|
||
}
|
||
|
||
function askAiAboutDetail(){
|
||
if(!currentDetailText)return;
|
||
const msg=`다음 ${searchType==='prec'?'판례':'법령'}를 분석해주세요:\n\n제목: ${currentDetailTitle}\n\n${currentDetailText.slice(0,3000)}`;
|
||
addMsg('user',escHtml(currentDetailTitle||'법률 자료')+' 분석 요청');
|
||
callChat(msg);
|
||
addMsg('system','📄 상세 내용을 AI에게 전달했습니다.');
|
||
}
|
||
|
||
// ── Chat ──────────────────────────────────────────────────────────────────────
|
||
function addMsg(role,html){
|
||
const msgs=document.getElementById('chat-msgs');
|
||
const div=document.createElement('div');div.className='lw-msg '+role;
|
||
const b=document.createElement('div');b.className='lw-bubble';b.innerHTML=html;
|
||
div.appendChild(b);
|
||
// 수식은 DOM에 들어간 뒤에 렌더링해야 한다(innerHTML은 <script>를 실행하지 않는다).
|
||
// TTS 버튼이 b.innerText를 읽으므로 그보다 먼저 돌려서 수식이 읽을 수 있는 글자가 되게 한다.
|
||
if(role!=='user'&&window.renderKatexIn)window.renderKatexIn(b);
|
||
if(role==='assistant'){const tb=document.createElement('button');tb.className='app-tts-btn';tb.title='음성으로 듣기';tb.textContent='🔊';tb.onclick=function(){playTTSText(tb,b.innerText);};div.appendChild(tb);}
|
||
msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
|
||
return b;
|
||
}
|
||
|
||
function renderMd(text){
|
||
return text
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
|
||
.replace(/`([^`]+)`/g,'<code>$1</code>')
|
||
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
||
.replace(/\*(.+?)\*/g,'<em>$1</em>')
|
||
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
|
||
.replace(/^> (.+)$/gm,'<blockquote>$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<=1024) mobToggle('chat');
|
||
addMsg('user',escHtml(msg));
|
||
await callChat(msg);
|
||
}
|
||
|
||
function sendQuick(msg){
|
||
if(window.innerWidth<=1024) mobCloseAll();
|
||
addMsg('user',escHtml(msg));callChat(msg);
|
||
}
|
||
|
||
// Uploads a contract/legal doc via the shared /api/upload/file endpoint (same one
|
||
// accountant-app uses for Excel) and hands the model the workspace-relative path so it
|
||
// can call pdf_read on it — the skill prompt's "계약서 검토 방법론" already expects this.
|
||
async function onFilePicked(file){
|
||
if(!file)return;
|
||
document.getElementById('file-input').value='';
|
||
if(window.innerWidth<=1024) mobToggle('chat');
|
||
addMsg('system','📎 '+escHtml(file.name)+' 업로드 중...');
|
||
try{
|
||
const form=new FormData();form.append('file',file);
|
||
const r=await fetch('/api/upload/file',{method:'POST',headers:authH(),body:form});
|
||
if(!r.ok)throw new Error('업로드 실패 '+r.status);
|
||
const data=await r.json();
|
||
const relPath=data.path||('uploads/'+file.name);
|
||
const msg=`첨부한 문서(${relPath}, 원본명: ${file.name})를 검토해주세요.`;
|
||
addMsg('user',escHtml(msg));
|
||
await callChat(msg);
|
||
}catch(e){
|
||
addMsg('system','❌ 업로드 실패: '+escHtml(e.message));
|
||
}
|
||
}
|
||
|
||
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='lw-msg assistant';
|
||
typingDiv.innerHTML='<div class="lw-typing"><div class="lw-dot"></div><div class="lw-dot"></div><div class="lw-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='lw-msg assistant';
|
||
bubble=document.createElement('div');bubble.className='lw-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="lw-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='전송';}
|
||
// A case discussed only through chat (never typed into the 메모 tab) would
|
||
// otherwise never trigger auto-naming — try from this turn's own message too.
|
||
if(currentCaseId)autoNameCaseIfNeeded(message);
|
||
}
|
||
|
||
async function clearChat(){
|
||
if(!confirm('이 대화를 지울까요?'))return;
|
||
const sid=getSessionId();
|
||
document.getElementById('chat-msgs').innerHTML='';
|
||
try{await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{method:'DELETE',headers:authH()});}catch{}
|
||
if(!currentCaseId){
|
||
_sessionId='lw_'+Math.random().toString(36).slice(2,12);
|
||
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
|
||
}
|
||
addMsg('system','새 대화가 시작되었습니다. 법률 질문을 입력해주세요.');
|
||
}
|
||
function handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}}
|
||
function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,120)+'px';}
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
(async()=>{
|
||
const ok=await checkAuth();if(!ok)return;
|
||
await initSessionId();
|
||
await loadHistory();
|
||
await Promise.all([loadSkill(),loadModels()]);
|
||
updateToCaseBtn();
|
||
// 탭 상태·placeholder를 기본 검색 대상(판례)에 맞춘다. setSearchType은 클릭 때만
|
||
// 돌아서 최초 로드에는 안 걸린다 — 📁 버튼이 안 보이던 것과 같은 종류의 누락이다.
|
||
setSearchType(searchType);
|
||
addMsg('system','⚖️ 법률 어시스턴트입니다. 법령/판례 검색이나 법률 질문을 입력하세요.');
|
||
initAppVoice(sendMessage);
|
||
document.getElementById('chat-input').focus();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|