Files
homeclaw/web-ui/html/lawyer-app.html
T
kimandClaude Opus 5 1811f62a18 feat: 법률앱 검색 UI — 판례 기본 + 결과 내 좁히기 + 결정례 드롭다운
- 판례 탭을 맨 왼쪽으로 옮기고 기본 검색 대상으로 바꿨다. 법률앱에서 가장
  자주 쓰는 게 판례다.
- 결과 목록 위에 좁히기 입력칸. 사건명·법원·사건번호에서 찾는다. 서버를 다시
  부르지 않고 이미 받아온 결과를 숨김/표시로 거르므로 즉시 반응하고 law.go.kr
  쿼터도 안 쓴다. 새 검색이 오면 조건을 비운다(안 그러면 결과가 있는데 빈
  화면으로 보인다). 1건이면 좁힐 게 없으므로 입력칸을 숨긴다.
- 결정례 4종(헌재·법령해석·행정심판·심판)은 드롭다운으로 묶었다. 탭이 8개가
  되면 좁은 화면에서 줄바꿈이 생긴다. 드롭다운 항목을 고르면 탭 하이라이트를
  끄고 드롭다운이 현재 선택을 나타낸다.
- 키가 없어 검색 자체가 불가능한 경우(미국 판례)를 "결과 없음"과 구분해
  안내를 띄운다. 예전엔 "다른 키워드로 검색해보세요"가 떠서 헛되이 재시도했다.

부트스트랩에서 setSearchType()을 부른다. 이 함수는 탭 클릭 때만 돌아서 최초
로드에는 안 걸렸고, 그래서 기본값이 판례인데 placeholder는 법령용이었다 —
📁 버튼이 안 보이던 것과 같은 종류의 누락이라 이번엔 같이 잡았다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:19:50 +09:00

1859 lines
108 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>⚖️ 법률 어시스턴트</title>
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
/* ── Header ── */
.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,'&#39;').replace(/"/g,'&quot;');}
// 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/\*(.+?)\*/g,'<em>$1</em>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
.replace(/^> (.+)$/gm,'<blockquote>$1</blockquote>')
.replace(/^[-*]\s+(.+)$/gm,'<li>$1</li>')
.replace(/(<li>[\s\S]+?<\/li>)/g,m=>`<ul style="padding-left:16px;margin:3px 0">${m}</ul>`)
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
.replace(/<p><\/p>/g,'').replace(/<p>(<[hup])/g,'$1').replace(/(<\/[hup][^>]*>)<\/p>/g,'$1');
}
async function sendMessage(){
const input=document.getElementById('chat-input');
const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return;
input.value='';autoResize(input);
if(window.innerWidth<=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>