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

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

1688 lines
65 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🦷 치과 사전 이미지 에이전트</title>
<style>
:root {
--bg: #f0f2f5;
--panel: #ffffff;
--border: #d9dde8;
--accent: #6457e8;
--green: #16a34a;
--red: #dc2626;
--yellow: #d97706;
--blue: #0284c7;
--text: #1e1e2e;
--muted: #6b7280;
--card: #f7f8fc;
--log-bg: #fafbff;
}
[data-theme="dark"] {
--bg: #0f0f13;
--panel: #16161e;
--border: #2a2a38;
--accent: #7c6af7;
--green: #22c55e;
--red: #ef4444;
--yellow: #fbbf24;
--blue: #38bdf8;
--text: #d4d4d8;
--muted: #52525b;
--card: #1c1c26;
--log-bg: #0c0c10;
}
[data-theme="warm"] {
--bg: #fdf6ee;
--panel: #fffaf4;
--border: #e8d9c4;
--accent: #c2610a;
--green: #4a7c59;
--red: #c0392b;
--yellow: #b8860b;
--blue: #2471a3;
--text: #2c1a0e;
--muted: #8b6f5e;
--card: #fef4e8;
--log-bg: #fffdf9;
}
[data-theme="mint"] {
--bg: #eef8f4;
--panel: #ffffff;
--border: #b8e0d0;
--accent: #0d9e6e;
--green: #0d9e6e;
--red: #e05252;
--yellow: #d4a017;
--blue: #2e86c1;
--text: #0e2a22;
--muted: #5a8070;
--card: #f4fdf9;
--log-bg: #f8fdfb;
}
[data-theme="ocean"] {
--bg: #0d1b2a;
--panel: #112236;
--border: #1e3a54;
--accent: #00b4d8;
--green: #2ec4b6;
--red: #e63946;
--yellow: #ffd166;
--blue: #00b4d8;
--text: #caf0f8;
--muted: #5e8fa8;
--card: #0a1828;
--log-bg: #080f1a;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 15px;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ── */
.header {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.header h1 { font-size: 16px; font-weight: 700; }
.badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
background: #e5e7eb;
color: var(--muted);
}
.badge.running { background: #dcfce7; color: var(--green); }
.badge.error { background: #fee2e2; color: var(--red); }
.badge.done { background: #dbeafe; color: var(--blue); }
.header-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
#live-thumbs img {
width: 64px; height: 64px;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--border);
cursor: pointer;
transition: transform .15s;
}
#live-thumbs img:hover { transform: scale(1.08); z-index: 10; position: relative; }
button {
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 5px 12px;
font-size: 12px;
cursor: pointer;
transition: all .15s;
font-family: inherit;
}
button:hover { background: var(--bg); border-color: var(--accent); }
button:disabled { opacity: .4; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: #4f46e5; color: #fff; }
button.primary:hover { background: #4f46e5; }
button.danger { background: #fee2e2; border-color: #fca5a5; color: var(--red); }
button.danger:hover { background: #fecaca; }
/* ── Main layout ── */
.main {
display: flex;
flex: 1;
overflow: hidden;
gap: 0;
}
/* ── Left panel: stats + controls ── */
.left-panel {
width: 260px;
min-width: 180px;
max-width: 480px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
overflow-y: auto;
resize: horizontal;
overflow-x: hidden;
}
.panel-section {
padding: 12px;
border-bottom: 1px solid var(--border);
}
.panel-section h2 {
font-size: 10px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 10px;
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.stat-label { color: var(--muted); font-size: 14px; }
.stat-value { font-weight: 700; font-size: 15px; }
.stat-value.green { color: var(--green); }
.stat-value.yellow { color: var(--yellow); }
.stat-value.blue { color: var(--blue); }
.progress-bar {
height: 6px;
background: #2a2a38;
border-radius: 3px;
overflow: hidden;
margin: 8px 0 4px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), #a78bfa);
border-radius: 3px;
transition: width .5s ease;
}
.progress-pct { text-align: right; font-size: 11px; color: var(--muted); }
/* controls */
.control-group { margin-bottom: 10px; }
.control-group label { display: block; font-size: 11px; color: var(--muted); margin-bottom: 4px; }
.control-group select, .control-group input[type="number"] {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 5px;
padding: 5px 8px;
font-size: 12px;
font-family: inherit;
}
.control-group select:focus, .control-group input:focus {
outline: none;
border-color: var(--accent);
}
.checkbox-row { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.checkbox-row input { accent-color: var(--accent); }
/* source chips */
.source-section-label {
font-size: 10px; color: var(--muted); text-transform: uppercase;
letter-spacing: .05em; margin: 6px 0 4px;
}
.source-chip-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-bottom: 4px;
}
.source-item {
display: flex; align-items: center; gap: 5px;
padding: 4px 7px; border-radius: 5px; cursor: pointer;
border: 1px solid var(--border); transition: all .12s;
position: relative;
}
.source-item:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 6%, transparent); }
.source-item input[type="checkbox"] { display: none; }
.source-item.checked {
background: color-mix(in srgb, var(--accent) 15%, transparent);
border-color: var(--accent);
}
.source-name { font-size: 11px; font-weight: 500; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.source-badge {
font-size: 9px; padding: 0 3px; border-radius: 3px; flex-shrink: 0;
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
}
.source-item[title]:hover::after {
content: attr(title);
position: absolute; left: 0; top: calc(100% + 3px);
background: var(--card); border: 1px solid var(--border);
color: var(--text); font-size: 10px; padding: 4px 7px;
border-radius: 5px; white-space: pre-wrap; width: 200px;
z-index: 50; line-height: 1.4; pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,.2);
}
.source-all-btns { display: flex; gap: 3px; margin-bottom: 5px; flex-wrap: wrap; }
.source-all-btns button { font-size: 10px; padding: 2px 7px; }
/* category list */
.cat-item {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
font-size: 11px;
}
.cat-item { cursor: pointer; border-radius: 5px; padding: 2px 4px; transition: background .12s; }
.cat-item:hover { background: var(--card); }
.cat-item.cat-selected { background: color-mix(in srgb, var(--accent) 12%, transparent); }
.cat-item.cat-selected .cat-name { color: var(--accent); font-weight: 600; }
.cat-name { flex: 1; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.cat-bar { flex: 0 0 60px; height: 5px; background: #e5e7eb; border-radius: 2px; overflow: hidden; }
.cat-bar-fill { height: 100%; background: var(--accent); border-radius: 2px; }
.cat-count { flex: 0 0 36px; text-align: right; color: var(--muted); font-size: 12px; }
/* ── Center: log ── */
.center-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--border);
}
.log-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--panel);
}
.log-header span { font-size: 11px; color: var(--muted); }
#log-count { font-weight: 700; color: var(--text); }
#log-output {
flex: 1;
overflow-y: auto;
padding: 12px 14px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
background: var(--log-bg);
}
#log-output .log-line { display: block; }
#log-output .log-ok { color: #15803d; }
#log-output .log-err { color: var(--red); }
#log-output .log-warn { color: #b45309; }
#log-output .log-info { color: var(--muted); font-style: italic; }
/* ── Right panel ── */
.right-panel {
width: 340px;
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Tab bar */
.r-tabs {
display: flex;
border-bottom: 1px solid var(--border);
background: var(--panel);
flex-shrink: 0;
}
.r-tab {
flex: 1;
padding: 8px 4px;
font-size: 12px;
font-weight: 600;
text-align: center;
cursor: pointer;
color: var(--muted);
border-bottom: 2px solid transparent;
transition: all .15s;
user-select: none;
}
.r-tab:hover { color: var(--text); }
.r-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.r-pane { display: none; flex: 1; flex-direction: column; overflow: hidden; }
.r-pane.active { display: flex; }
/* Gallery */
.gallery-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
padding: 8px;
flex: 1;
overflow-y: auto;
align-content: start;
}
.gallery-thumb {
aspect-ratio: 1;
border-radius: 5px;
overflow: hidden;
background: var(--card);
cursor: pointer;
border: 1px solid transparent;
transition: border-color .15s;
position: relative;
}
.gallery-thumb:hover { border-color: var(--accent); }
.gallery-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.gallery-thumb .thumb-label {
position: absolute;
bottom: 0; left: 0; right: 0;
background: rgba(0,0,0,.7);
font-size: 9px;
padding: 2px 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #fff;
opacity: 0;
transition: opacity .15s;
}
.gallery-thumb:hover .thumb-label { opacity: 1; }
/* Search pane */
.search-bar-row {
display: flex;
gap: 6px;
padding: 8px;
border-bottom: 1px solid var(--border);
background: var(--panel);
flex-shrink: 0;
}
#search-input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
}
#search-input:focus { outline: none; border-color: var(--accent); }
#search-results {
flex: 1;
overflow-y: auto;
padding: 6px;
}
#search-results {
flex: 1;
overflow-y: auto;
padding: 6px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
align-content: start;
}
#search-results .search-empty { grid-column: span 2; padding: 12px; color: var(--muted); font-size: 13px; text-align: center; }
.search-item {
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card);
overflow: hidden;
cursor: pointer;
transition: border-color .12s, box-shadow .12s;
display: flex;
flex-direction: column;
}
.search-item:hover { border-color: var(--accent); box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 20%, transparent); }
.search-thumb-wrap {
width: 100%; aspect-ratio: 1;
background: var(--border);
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.search-thumb {
width: 100%; height: 100%;
object-fit: cover;
display: block;
}
.search-thumb-wrap.no-img {
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
background: var(--card);
}
.search-no-img-dot {
position: absolute; top: 4px; right: 4px;
width: 7px; height: 7px; border-radius: 50%;
background: var(--yellow);
}
.search-info { padding: 5px 7px 6px; min-width: 0; }
.search-ko { font-size: 12px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-en { font-size: 10px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-cat { font-size: 10px; color: var(--accent); margin-top: 1px; }
/* Verify pane */
.verify-controls {
padding: 10px;
border-bottom: 1px solid var(--border);
background: var(--panel);
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.verify-row { display: flex; gap: 6px; align-items: center; font-size: 12px; }
.verify-row label { color: var(--muted); width: 60px; }
.verify-row input, .verify-row select {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 5px;
padding: 4px 7px;
font-size: 12px;
font-family: inherit;
}
.verify-actions { display: flex; gap: 6px; }
#verify-results {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.verify-item {
display: flex;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
margin-bottom: 3px;
border: 1px solid var(--border);
background: var(--card);
align-items: flex-start;
}
.verify-item.ok { border-color: #bbf7d0; background: #f0fdf4; }
.verify-item.fail { border-color: #fca5a5; background: #fff5f5; }
[data-theme="dark"] .verify-item.ok { border-color: #166534; background: #052e16; }
[data-theme="dark"] .verify-item.fail { border-color: #7f1d1d; background: #1a0505; }
[data-theme="ocean"] .verify-item.ok { border-color: #2ec4b6; background: #0a1f1e; }
[data-theme="ocean"] .verify-item.fail { border-color: #e63946; background: #1a0508; }
.verify-icon { font-size: 16px; flex-shrink: 0; margin-top: 1px; }
.verify-info { flex: 1; min-width: 0; }
.verify-ko { font-size: 13px; font-weight: 600; }
.verify-reason { font-size: 11px; color: var(--muted); margin-top: 2px; }
.verify-thumb {
width: 36px; height: 36px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
cursor: pointer;
}
/* Agent chat */
.chat-section {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-header {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--panel);
font-size: 11px;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--muted);
display: flex;
align-items: center;
gap: 8px;
}
.chat-session-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background: var(--card);
color: var(--muted);
margin-left: auto;
}
#chat-messages {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-msg {
max-width: 90%;
padding: 9px 12px;
border-radius: 10px;
font-size: 14px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: var(--accent);
border: 1px solid #4f46e5;
color: #fff;
}
.chat-msg.agent {
align-self: flex-start;
background: var(--panel);
border: 1px solid var(--border);
}
.chat-msg.tool {
align-self: flex-start;
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
font-family: monospace;
font-size: 11px;
}
.chat-msg.thinking {
align-self: flex-start;
color: var(--muted);
font-style: italic;
font-size: 11px;
background: none;
border: none;
padding: 2px 0;
}
.chat-input-row {
display: flex;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid var(--border);
background: var(--panel);
}
#chat-input {
flex: 1;
background: #fff;
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
resize: none;
height: 60px;
}
#chat-input:focus { outline: none; border-color: var(--accent); }
#chat-send {
align-self: flex-end;
background: var(--accent);
border-color: #4f46e5;
color: #fff;
padding: 6px 10px;
}
#chat-send:hover { background: #4f46e5; }
/* Theme swatcher */
.theme-picker { display: flex; gap: 5px; align-items: center; }
.theme-dot {
width: 18px; height: 18px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: transform .15s, border-color .15s;
}
.theme-dot:hover { transform: scale(1.2); }
.theme-dot.active { border-color: var(--text); }
/* ── Mobile ── */
@media(max-width:1024px){
.header{flex-wrap:wrap;padding:6px 10px;gap:6px;}
.header h1{font-size:13px;}
.theme-picker{display:none;}
.header-actions{gap:5px;}
.header-actions button{padding:4px 8px;font-size:11px;}
/* Side panels → overlays */
.left-panel{
position:fixed;top:0;left:0;bottom:0;z-index:500;
width:82vw;max-width:300px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 28px rgba(0,0,0,.5);
resize:none;
}
.left-panel.mob-open{transform:translateX(0);}
.right-panel{
position:fixed;top:0;right:0;bottom:0;z-index:500;
width:92vw;max-width:380px;
transform:translateX(100%);transition:transform .25s ease;
box-shadow:-4px 0 28px rgba(0,0,0,.5);
}
.right-panel.mob-open{transform:translateX(0);}
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop.show{display:block;}
/* Bottom tabbar */
#mob-tabbar{display:flex !important;}
/* Center: leave bottom room for tabbar */
.main{padding-bottom:52px;}
.center-panel{border-right:none;}
/* iOS zoom */
input,textarea,select{font-size:16px !important;}
}
@media(min-width:1025px){
#mob-backdrop{display:none !important;}
#mob-tabbar{display:none !important;}
}
/* Image lightbox */
#lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.85);
z-index: 100;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
}
#lightbox.open { display: flex; }
#lightbox img { max-width: 90vw; max-height: 80vh; border-radius: 8px; }
#lightbox-label { color: #fff; font-size: 13px; }
#lightbox { cursor: pointer; }
</style>
</head>
<body>
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
<div id="mob-tabbar" style="display:none;position:fixed;bottom:0;left:0;right:0;height:52px;background:var(--panel);border-top:1px solid var(--border);z-index:490;align-items:stretch;">
<button class="mob-tab" id="mobtab-ctrl" onclick="mobToggle('left')" style="flex:1;border:none;border-radius:0;background:none;color:var(--muted);font-size:10px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:0;cursor:pointer;">
<span style="font-size:18px">⚙</span>컨트롤
</button>
<button class="mob-tab" id="mobtab-log" onclick="mobCloseAll()" style="flex:1;border:none;border-radius:0;background:none;color:var(--accent);font-size:10px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:0;cursor:pointer;">
<span style="font-size:18px">📋</span>로그
</button>
<button class="mob-tab" id="mobtab-panel" onclick="mobToggle('right')" style="flex:1;border:none;border-radius:0;background:none;color:var(--muted);font-size:10px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:0;cursor:pointer;">
<span style="font-size:18px">🖼</span>패널
</button>
</div>
<!-- Header -->
<div class="header">
<h1>🦷 치과 사전 이미지 에이전트</h1>
<span class="badge" id="status-badge">대기중</span>
<span class="badge" id="model-badge" style="background:var(--card);color:var(--muted);font-size:11px;" title="연결 모델">⬤ -</span>
<div class="theme-picker" id="theme-picker">
<div class="theme-dot active" data-theme="" style="background:#6457e8" title="라이트" onclick="setTheme('')"></div>
<div class="theme-dot" data-theme="dark" style="background:#7c6af7;border:2px solid #2a2a38" title="다크" onclick="setTheme('dark')"></div>
<div class="theme-dot" data-theme="warm" style="background:#c2610a" title="웜" onclick="setTheme('warm')"></div>
<div class="theme-dot" data-theme="mint" style="background:#0d9e6e" title="민트" onclick="setTheme('mint')"></div>
<div class="theme-dot" data-theme="ocean" style="background:#00b4d8;border:2px solid #1e3a54" title="오션" onclick="setTheme('ocean')"></div>
</div>
<div class="header-actions">
<button id="btn-dry" onclick="showStats()" title="카테고리별 현황 로그 출력">📊 현황</button>
<button class="danger" id="btn-stop" onclick="stopJob()" disabled>■ 정지</button>
<button onclick="refreshStats()">↻ 새로고침</button>
</div>
</div>
<!-- Main -->
<div class="main">
<!-- Left: stats + controls -->
<div class="left-panel">
<div class="panel-section" id="left-mob-close" style="display:none;padding:8px 12px;">
<button onclick="mobCloseAll()" style="width:100%;background:none;border:1px solid var(--border);color:var(--muted);font-size:12px;">✕ 닫기</button>
</div>
<div class="panel-section">
<h2>전체 현황</h2>
<div class="stat-row">
<span class="stat-label">전체 용어</span>
<span class="stat-value blue" id="stat-total">-</span>
</div>
<div class="stat-row">
<span class="stat-label">이미지 있음</span>
<span class="stat-value green" id="stat-with">-</span>
</div>
<div class="stat-row">
<span class="stat-label">미완료</span>
<span class="stat-value yellow" id="stat-without">-</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width:0%"></div>
</div>
<div class="progress-pct" id="progress-pct">0%</div>
</div>
<div class="panel-section">
<h2>다운로드 설정</h2>
<div class="control-group">
<label>소스 <span style="color:var(--muted);font-weight:400">(복수 선택 가능)</span></label>
<div class="source-all-btns">
<button onclick="selectAllSources(true)">전체</button>
<button onclick="selectAllSources(false)">해제</button>
<button onclick="selectSourceGroup('net')">인터넷</button>
<button onclick="selectSourceGroup('local')">로컬</button>
</div>
<div class="source-section-label">인터넷 검색</div>
<div class="source-chip-grid">
<label class="source-item" title="CC BY-SA 이미지&#10;범용 치과 용어에 강함&#10;비전 검증 후 저장">
<input type="checkbox" value="wikimedia" onchange="onSourceChange()">
<span class="source-name">Wikimedia</span><span class="source-badge">CC</span>
</label>
<label class="source-item" title="NLM 인덱스 PMC figure&#10;임상 사진·방사선 풍부&#10;캡션 힌트 활용">
<input type="checkbox" value="openi" onchange="onSourceChange()">
<span class="source-name">NLM Open-i</span><span class="source-badge">PMC</span>
</label>
<label class="source-item" title="오픈 학술 논문 그림&#10;재료학·SEM 현미경에 강함&#10;item_type=Figure 검색">
<input type="checkbox" value="figshare" onchange="onSourceChange()">
<span class="source-name">Figshare</span><span class="source-badge">학술</span>
</label>
<label class="source-item" title="NCBI 전체 PMC DB&#10;Open-i보다 넓은 범위&#10;JATS XML figure 직접 추출">
<input type="checkbox" value="pmc" onchange="onSourceChange()">
<span class="source-name">PMC Entrez</span><span class="source-badge">NCBI</span>
</label>
<label class="source-item" title="VCU 구강 병리 아틀라스&#10;~60종 병리 임상사진·방사선&#10;MeSH 인덱스 · 스크랩">
<input type="checkbox" value="vcu" onchange="onSourceChange()">
<span class="source-name">VCU 병리</span><span class="source-badge">교육</span>
</label>
<label class="source-item" title="OralSDv1 GitHub&#10;6종 일반질환 ~1163장&#10;치석/우식/치은염/변색/궤양/무치아">
<input type="checkbox" value="oralsd" onchange="onSourceChange()">
<span class="source-name">OralSDv1</span><span class="source-badge">GH</span>
</label>
<label class="source-item" title="AKUDENTAL GitHub 파노라마&#10;333장 CC-BY-4.0&#10;방사선 term 매핑용">
<input type="checkbox" value="akudental" onchange="onSourceChange()">
<span class="source-name">AKUDENTAL</span><span class="source-badge">파노</span>
</label>
</div>
<div class="source-section-label">로컬 데이터셋</div>
<div class="source-chip-grid">
<label class="source-item" title="구강 질환 7종 임상 사진&#10;516장 · CaS/Gum/OC/OLP 등">
<input type="checkbox" value="kaggle" onchange="onSourceChange()">
<span class="source-name">Kaggle MOD</span>
</label>
<label class="source-item" title="DENTEX·Caries·Gingivitis&#10;Implant 등 다수 데이터셋 통합">
<input type="checkbox" value="huggingface" onchange="onSourceChange()">
<span class="source-name">HuggingFace</span>
</label>
<label class="source-item" title="COCO 형식 치과 X선&#10;우식/충전/매복/임플란트">
<input type="checkbox" value="roboflow" onchange="onSourceChange()">
<span class="source-name">Roboflow</span>
</label>
<label class="source-item" title="파노라마 방사선 14종&#10;COCO 어노테이션 CC BY 4.0">
<input type="checkbox" value="zenodo" onchange="onSourceChange()">
<span class="source-name">Zenodo</span>
</label>
<label class="source-item" title="소아치과 구강 내 사진&#10;9,562장 · 8종 표준 뷰 CC BY 4.0">
<input type="checkbox" value="mendeley" onchange="onSourceChange()">
<span class="source-name">Mendeley</span>
</label>
<label class="source-item" title="Roboflow Universe 프로젝트&#10;API 키 필요">
<input type="checkbox" value="roboflow_api" onchange="onSourceChange()">
<span class="source-name">Roboflow API</span>
</label>
<label class="source-item" title="방사선 교육 자료 URL 매핑&#10;(source_url 필드 활용)">
<input type="checkbox" value="radiopaedia" onchange="onSourceChange()">
<span class="source-name">Radiopaedia</span>
</label>
<label class="source-item" title="COde HuggingFace&#10;~50k 구강내사진+8k 방사선&#10;964MB zip 로컬 다운로드 필요">
<input type="checkbox" value="code" onchange="onSourceChange()">
<span class="source-name">COde</span><span class="source-badge">HF</span>
</label>
<label class="source-item" title="AlphaDent Zenodo&#10;치아 병변 탐지 Apache-2.0&#10;4.8GB zip 로컬 다운로드 필요">
<input type="checkbox" value="alphadent" onchange="onSourceChange()">
<span class="source-name">AlphaDent</span><span class="source-badge">Z</span>
</label>
<label class="source-item" title="BRAR Figshare 파노라마&#10;1104장 CC-BY-4.0&#10;467MB zip 로컬 다운로드 필요">
<input type="checkbox" value="brar" onchange="onSourceChange()">
<span class="source-name">BRAR</span><span class="source-badge">파노</span>
</label>
<label class="source-item" title="OOPID 구강 세포병리&#10;9593 patch 17GB&#10;수동 다운로드 · cytology niche">
<input type="checkbox" value="oopid" onchange="onSourceChange()">
<span class="source-name">OOPID</span><span class="source-badge">세포</span>
</label>
<label class="source-item" title="STS-3D CBCT Zenodo&#10;371볼륨 31GB CC-BY-4.0&#10;수동 다운로드 · 3D niche">
<input type="checkbox" value="sts3d" onchange="onSourceChange()">
<span class="source-name">STS-3D</span><span class="source-badge">3D</span>
</label>
<label class="source-item" title="Pixabay(CC0) + Pexels + Unsplash&#10;영문 term 검색 → 비전 검증 저장&#10;일반 임상 term 커버리지 높음">
<input type="checkbox" value="stock" onchange="onSourceChange()">
<span class="source-name">Stock</span><span class="source-badge">사진</span>
</label>
</div>
</div>
<div id="net-options">
<div class="control-group">
<label>최대 개수 <span style="color:var(--muted);font-weight:400">(빈칸=전체)</span></label>
<input type="number" id="inp-limit" value="100" min="1" oninput="document.getElementById('v-limit').value=this.value">
</div>
<div class="control-group">
<label>카테고리 <span style="color:var(--accent);font-size:11px" id="sel-cat-badge"></span></label>
<div style="display:flex;gap:4px">
<input type="text" id="inp-category" placeholder="아래 목록에서 클릭 선택">
<button onclick="clearCatSel()" title="선택 해제" style="padding:4px 8px;font-size:12px;flex-shrink:0">✕</button>
</div>
</div>
</div>
<div class="checkbox-row" style="margin-top:6px" title="COde 전용: 문자열 매칭 실패 term을 LLM이 117개 라벨 중 의미론적으로 매칭 (세균명·술식명·분류 등은 none 반환). 켜면 느림.">
<input type="checkbox" id="inp-llm-match">
<label for="inp-llm-match" style="cursor:pointer">LLM 라벨 매칭 <span style="color:var(--muted);font-weight:400">(COde 전용)</span></label>
</div>
<div class="checkbox-row" style="margin-top:6px" title="켜면 각 이미지를 비전 모델로 검증 후 저장(느림·품질 보장). 꺼면 매칭 즉시 저장(빠름). wikimedia/openi는 설정과 무관하게 항상 검증.">
<input type="checkbox" id="inp-verify">
<label for="inp-verify" style="cursor:pointer">비전 검증 <span style="color:var(--muted);font-weight:400">(끔=빠른 저장, 켬=품질·느림)</span></label>
</div>
<button class="primary" id="btn-start" onclick="confirmStart()" style="width:100%;margin-top:4px">▶ 다운로드 시작</button>
</div>
<div class="panel-section">
<h2>검증 설정</h2>
<div class="control-group">
<label>검증 모델 <button onclick="loadVerifyModels()" style="padding:1px 6px;font-size:10px;margin-left:4px">↻</button></label>
<select id="v-model-sel" onchange="onVerifyModelChange(this.value)">
<option value="kimi-k2.6:cloud">kimi-k2.6:cloud</option>
</select>
</div>
<input type="hidden" id="v-limit" value="100">
<div style="display:flex;align-items:center;gap:6px;font-size:12px;margin-bottom:8px">
<input type="checkbox" id="v-fix" style="accent-color:var(--accent)">
<label for="v-fix" style="cursor:pointer;color:var(--muted)">오류 이미지 자동 제거</label>
</div>
<div style="display:flex;gap:6px">
<button class="primary" onclick="startVerify()" id="btn-verify" style="flex:1">▶ 검증 시작</button>
<button onclick="setRTab('verify')" style="padding:5px 8px" title="검증 결과 보기">결과 →</button>
</div>
</div>
<div class="panel-section" style="flex:1; overflow-y:auto; padding-bottom:4px">
<h2>카테고리별 <span style="font-size:10px;color:var(--muted);font-weight:400">클릭하여 선택</span></h2>
<div id="cat-list"></div>
</div>
</div>
<!-- Center: log -->
<div class="center-panel">
<div class="log-header">
<span>실시간 로그</span>
<span id="log-count">0줄</span>
<button style="margin-left:auto; padding:3px 8px; font-size:11px;" onclick="clearLog()">지우기</button>
<button style="padding:3px 8px; font-size:11px;" onclick="scrollLog()">↓ 맨아래</button>
</div>
<div id="live-panel" style="display:none; border-bottom:1px solid var(--border); padding:8px 10px; background:var(--panel);">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
<span style="font-size:11px; color:var(--muted);">작업 중 — 최근 저장 이미지</span>
<button class="danger" onclick="stopJob()" style="padding:2px 10px; font-size:11px; margin-left:auto;">■ 중단</button>
</div>
<div id="live-thumbs" style="display:flex; flex-wrap:wrap; gap:4px;"></div>
</div>
<div id="log-output"><span class="log-info">▶ 시작 버튼을 눌러 작업을 시작하세요.</span>
</div>
</div>
<!-- Right: tabbed panel -->
<div class="right-panel">
<div style="display:none;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--panel);" id="right-mob-close">
<button onclick="mobCloseAll()" style="width:100%;background:none;border:1px solid var(--border);color:var(--muted);font-size:12px;">✕ 닫기</button>
</div>
<div class="r-tabs">
<div class="r-tab active" onclick="setRTab('gallery')">🖼 갤러리</div>
<div class="r-tab" onclick="setRTab('search')">🔍 검색</div>
<div class="r-tab" onclick="setRTab('verify')">✅ 검증</div>
<div class="r-tab" onclick="setRTab('chat')">🤖 채팅</div>
</div>
<!-- 갤러리 -->
<div class="r-pane active" id="pane-gallery">
<div style="display:flex;align-items:center;gap:6px;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--panel);flex-shrink:0;">
<span id="gallery-label" style="font-size:11px;color:var(--muted);">최근 다운로드</span>
<button id="gallery-cat-clear" onclick="clearGalleryFilter()" style="display:none;padding:1px 6px;font-size:10px;">✕</button>
<button onclick="loadRecent()" style="padding:2px 6px;font-size:11px;" title="새로고침">↻</button>
<button id="btn-gallery-all" onclick="openGalleryAll()" style="display:none;margin-left:auto;padding:2px 8px;font-size:11px;">📂 카테고리 전체</button>
</div>
<div class="gallery-grid" id="gallery-grid">
<div style="color:var(--muted);font-size:11px;padding:4px;grid-column:span 4;">로딩 중...</div>
</div>
</div>
<!-- 검색 -->
<div class="r-pane" id="pane-search">
<div class="search-bar-row">
<input type="text" id="search-input" placeholder="한국어, 영문, 정의 검색..." oninput="onSearch(this.value)">
<button onclick="onSearch(document.getElementById('search-input').value)">검색</button>
</div>
<div id="search-results"><div class="search-empty">검색어를 입력하세요</div></div>
</div>
<!-- 검증 -->
<div class="r-pane" id="pane-verify">
<div style="display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--panel);flex-shrink:0;gap:8px;">
<span style="font-size:11px;color:var(--muted);">검증 결과</span>
<span id="verify-model-name" style="font-size:11px;color:var(--accent);font-weight:600;"></span>
<button style="margin-left:auto;padding:2px 8px;font-size:11px;" onclick="document.getElementById('verify-results').innerHTML=''">지우기</button>
</div>
<div id="verify-results" style="flex:1;overflow-y:auto;padding:6px;">
<div style="padding:12px;color:var(--muted);font-size:13px;text-align:center;">왼쪽 패널에서 검증을 시작하세요</div>
</div>
</div>
<!-- 채팅 -->
<div class="r-pane" id="pane-chat">
<div class="chat-header">
🤖 에이전트 채팅
<span class="chat-session-badge" id="chat-session-id">-</span>
</div>
<div id="chat-messages" class="chat-section" style="flex:1;overflow-y:auto;padding:10px 12px;gap:8px;display:flex;flex-direction:column;">
<div class="chat-msg agent">안녕하세요! 치과 사전 이미지 에이전트입니다.<br><br>예시 명령:<br>• "위키미디어에서 50개 다운로드해줘"<br>• "교정 카테고리 진행률 알려줘"<br>• "오류 이미지 자동 수정해줘"</div>
</div>
<div class="chat-input-row">
<textarea id="chat-input" placeholder="에이전트에게 명령..." onkeydown="chatKeydown(event)"></textarea>
<button id="chat-send" onclick="sendChat()">전송</button>
</div>
</div>
</div>
</div>
<!-- Lightbox -->
<div id="lightbox" onclick="closeLightbox()">
<img id="lightbox-img" src="" alt="">
<div id="lightbox-label"></div>
</div>
<script>
// ── Theme ──────────────────────────────────────────────
const THEME_KEY = 'dental_agent_theme';
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(THEME_KEY, theme);
document.querySelectorAll('.theme-dot').forEach(d => {
d.classList.toggle('active', d.dataset.theme === theme);
});
}
(function() {
const saved = localStorage.getItem(THEME_KEY) || '';
if (saved) document.documentElement.setAttribute('data-theme', saved);
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.theme-dot').forEach(d => {
d.classList.toggle('active', d.dataset.theme === saved);
});
});
})();
const TOKEN_KEY = 'smallclaw_token';
function getToken() { return sessionStorage.getItem(TOKEN_KEY) || localStorage.getItem(TOKEN_KEY) || ''; }
function authH(extra = {}) {
const t = getToken();
return t ? { ...extra, Authorization: 'Bearer ' + t } : extra;
}
function authFetch(url, opts = {}) {
return fetch(url, { credentials: 'include', ...opts, headers: authH(opts.headers || {}) });
}
let logCount = 0;
let autoScroll = true;
let sseConn = null;
let chatSessionId = 'dental_' + Math.random().toString(36).slice(2, 10);
let chatStreaming = false;
let chatAbort = null;
document.getElementById('chat-session-id').textContent = chatSessionId.slice(-6);
// ── Tab switching ──────────────────────────────────────
function setRTab(name) {
document.querySelectorAll('.r-tab').forEach((el, i) => {
const names = ['gallery','search','verify','chat'];
el.classList.toggle('active', names[i] === name);
});
document.querySelectorAll('.r-pane').forEach(el => {
el.classList.toggle('active', el.id === 'pane-' + name);
});
}
// ── Model info ──────────────────────────────────────────
async function loadModelInfo() {
try {
const r = await authFetch('/api/info', { });
if (!r.ok) return;
const d = await r.json();
const model = d.currentModel || d.active_model || '?';
const el = document.getElementById('model-badge');
el.textContent = '⬤ ' + model;
el.style.color = 'var(--green)';
el.title = `연결 모델: ${model} | 공급자: ${d.provider || '?'}`;
} catch {}
}
// ── Search ──────────────────────────────────────────────
let _searchTimer = null;
function onSearch(q) {
clearTimeout(_searchTimer);
if (!q.trim()) {
document.getElementById('search-results').innerHTML =
'<div style="padding:12px;color:var(--muted);font-size:13px;text-align:center;">검색어를 입력하세요</div>';
return;
}
_searchTimer = setTimeout(() => doSearch(q.trim()), 300);
}
const NET_SOURCES = new Set(['wikimedia', 'openi', 'figshare', 'pmc', 'vcu', 'oralsd', 'akudental', 'stock']);
// limit/카테고리 박스를 표시·전송하는 소스 (NET + 로컬 중 옵션 지원 소스)
const OPT_SOURCES = new Set([...NET_SOURCES, 'code']);
function getCheckedSources() {
return [...document.querySelectorAll('.source-item input[type="checkbox"]:checked:not(:disabled)')]
.map(el => el.value);
}
function selectAllSources(on) {
document.querySelectorAll('.source-item input[type="checkbox"]:not(:disabled)')
.forEach(el => { el.checked = on; el.closest('.source-item').classList.toggle('checked', on); });
onSourceChange();
}
function selectSourceGroup(group) {
document.querySelectorAll('.source-item input[type="checkbox"]:not(:disabled)').forEach(el => {
const isNet = NET_SOURCES.has(el.value);
el.checked = group === 'net' ? isNet : !isNet;
el.closest('.source-item').classList.toggle('checked', el.checked);
});
onSourceChange();
}
function onSourceChange() {
document.querySelectorAll('.source-item').forEach(el => {
const cb = el.querySelector('input[type="checkbox"]');
if (cb) el.classList.toggle('checked', cb.checked);
});
// net-options는 항상 표시 (소스 미선택 시에도)
}
document.addEventListener('DOMContentLoaded', onSourceChange);
function highlight(text, q) {
if (!q || !text) return esc(text || '');
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return esc(text).replace(re, '<mark style="background:color-mix(in srgb,var(--accent) 20%,transparent);color:var(--accent);border-radius:2px;padding:0 1px">$1</mark>');
}
async function doSearch(q) {
const el = document.getElementById('search-results');
el.innerHTML = '<div class="search-empty">검색 중...</div>';
try {
const r = await authFetch('/api/dental/search?q=' + encodeURIComponent(q) + '&limit=40', { });
if (!r.ok) { el.innerHTML = '<div class="search-empty" style="color:var(--red)">인증 오류 — 새로고침하세요</div>'; return; }
const rows = await r.json();
if (!rows.length) {
el.innerHTML = `<div class="search-empty">"${esc(q)}" 결과 없음</div>`;
return;
}
const withImg = rows.filter(r => r.image_url).length;
el.innerHTML = `<div style="padding:6px 10px 8px;font-size:11px;color:var(--muted);border-bottom:1px solid var(--border)">
검색어: <b style="color:var(--accent)">${esc(q)}</b> &nbsp;·&nbsp; ${rows.length}건
${withImg ? `&nbsp;·&nbsp; 이미지 <b style="color:var(--green)">${withImg}</b>건` : ''}
</div>` + rows.map(row => {
const hasImg = !!(row.image_url);
const imgSrc = fixImgUrl(row.image_url);
const thumbWrap = hasImg
? `<div class="search-thumb-wrap" onclick="openLightbox('${imgSrc}','${esc(row.korean)}')">
<img class="search-thumb" src="${imgSrc}" alt="" loading="lazy"
onerror="this.parentElement.className='search-thumb-wrap no-img';this.remove();this.parentElement.innerHTML='🦷'">
</div>`
: `<div class="search-thumb-wrap no-img"><div class="search-no-img-dot"></div>🦷</div>`;
return `<div class="search-item">
${thumbWrap}
<div class="search-info">
<div class="search-ko">${highlight(row.korean, q)}</div>
<div class="search-en">${highlight(row.english || '', q)}</div>
<div style="display:flex;gap:6px;align-items:center;margin-top:2px">
<span class="search-cat">${esc(row.category || '')}</span>
${row.image_url ? '<span style="font-size:10px;color:var(--green)">🖼</span>' : '<span style="font-size:10px;color:var(--muted)">—</span>'}
</div>
</div>
</div>`;
}).join('');
} catch (e) {
el.innerHTML = '<div class="search-empty" style="color:var(--red)">오류: ' + e.message + '</div>';
}
}
// ── Verify ──────────────────────────────────────────────
let verifySSE = null;
async function startVerify() {
const limit = Number(document.getElementById('v-limit').value) || 20;
const category = document.getElementById('inp-category').value.trim();
const fix = document.getElementById('v-fix').checked;
const model = getVerifyModel();
document.getElementById('verify-model-name').textContent = model;
const resEl = document.getElementById('verify-results');
resEl.innerHTML = '<div style="padding:10px;color:var(--muted);font-size:12px;">검증 시작...</div>';
document.getElementById('btn-verify').disabled = true;
const r = await authFetch('/api/dental/verify', {
method: 'POST',
headers: authH({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ limit, category: category || undefined, fix, model }),
});
if (!r.ok) {
const d = await r.json();
resEl.innerHTML = '<div style="padding:10px;color:var(--red);">오류: ' + (d.error || '실패') + '</div>';
document.getElementById('btn-verify').disabled = false;
return;
}
resEl.innerHTML = '';
setRTab('verify');
// SSE is already connected via connectSSE() — verify_log/verify_done handled there
}
function parseVerifyLine(text, resEl) {
appendLog(text, false, classifyLog(text));
// "[카테고리] 용어 — 이유" 형식 파싱 (카테고리 prefix 제외)
const okMatch = text.match(/[✓✔]\s+(?:\[.+?\]\s+)?(.+?)\s+[—-]/);
const failMatch = text.match(/[✗✘×]\s+(?:\[.+?\]\s+)?(.+?)\s+[—-]\s*(.+)?/);
const imgMatch = text.match(/image_url=([^\s]+)/);
if (okMatch || failMatch) {
const isOk = !!okMatch;
const korean = (okMatch || failMatch)[1];
const reason = failMatch?.[2] || '';
const rawUrl = imgMatch?.[1] || '';
const imgUrl = fixImgUrl(rawUrl);
const div = document.createElement('div');
div.className = 'verify-item ' + (isOk ? 'ok' : 'fail');
div.innerHTML = `
<div class="verify-icon">${isOk ? '✅' : '❌'}</div>
<div class="verify-info">
<div class="verify-ko">${esc(korean)}</div>
${reason ? `<div class="verify-reason">${esc(reason)}</div>` : ''}
</div>
${imgUrl ? `<img class="verify-thumb" src="${imgUrl}" alt="" loading="lazy" onclick="openLightbox('${imgUrl}','${esc(korean)}')">` : ''}
`;
resEl.appendChild(div);
resEl.scrollTop = resEl.scrollHeight;
}
}
// ── Stats ──────────────────────────────────────────────
async function refreshStats() {
try {
const r = await authFetch('/api/dental/stats', { });
if (!r.ok) return;
const d = await r.json();
document.getElementById('stat-total').textContent = d.total.toLocaleString();
document.getElementById('stat-with').textContent = d.with_image.toLocaleString();
document.getElementById('stat-without').textContent = d.without_image.toLocaleString();
const pct = d.total > 0 ? Math.round(d.with_image / d.total * 100) : 0;
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('progress-pct').textContent = pct + '%';
// Category list
const cats = (d.by_category || []).slice(0, 20);
const maxTotal = cats[0]?.total || 1;
const curCat = document.getElementById('inp-category').value;
const html = cats.map(c => {
const name = c.category || '기타';
const pct = c.total > 0 ? Math.round(c.has_img / c.total * 100) : 0;
const sel = name === curCat ? ' cat-selected' : '';
return `<div class="cat-item${sel}" data-cat="${esc(name)}" onclick="selectCategory('${esc(name)}')" title="클릭하여 선택">
<span class="cat-name">${name}</span>
<div class="cat-bar"><div class="cat-bar-fill" style="width:${pct}%"></div></div>
<span class="cat-count">${c.has_img}/${c.total}</span>
</div>`;
}).join('');
document.getElementById('cat-list').innerHTML = html || '<div style="color:var(--muted);font-size:11px;">데이터 없음</div>';
} catch (e) {
console.warn('stats error', e);
}
}
// ── Job control ──────────────────────────────────────────────
async function showStats() {
clearLog();
appendLog('📊 카테고리별 이미지 현황 조회 중...', false, 'log-info');
try {
const r = await authFetch('/api/dental/stats', { });
const d = await r.json();
appendLog(`전체 용어: ${d.total} 이미지 있음: ${d.with_image} (${Math.round(d.with_image/d.total*100)}%) 미완료: ${d.without_image}`, false, 'log-info');
appendLog('', false, '');
appendLog(`${'카테고리'.padEnd(18)} ${'용어'.padStart(6)} ${'이미지'.padStart(6)} ${'커버리지'.padStart(8)}`, false, 'log-info');
appendLog('─'.repeat(44), false, 'log-info');
for (const c of (d.by_category || [])) {
const pct = c.total > 0 ? Math.round(c.has_img / c.total * 100) : 0;
const bar = '█'.repeat(Math.round(pct/10)) + '░'.repeat(10 - Math.round(pct/10));
appendLog(`${(c.category||'기타').padEnd(18)} ${String(c.total).padStart(6)} ${String(c.has_img).padStart(6)} ${bar} ${pct}%`, false, pct >= 50 ? 'log-ok' : pct >= 20 ? '' : 'log-warn');
}
} catch (e) { appendLog('오류: ' + e.message, true); }
}
function confirmStart() {
const sources = getCheckedSources();
if (!sources.length) { alert('소스를 하나 이상 선택하세요.'); return; }
const hasOpt = sources.some(s => OPT_SOURCES.has(s));
const hasNet = sources.some(s => NET_SOURCES.has(s));
const limit = hasOpt ? (document.getElementById('inp-limit').value || '전체') : '';
const category = hasOpt ? (document.getElementById('inp-category').value.trim() || '') : '';
const verifyOn = document.getElementById('inp-verify').checked;
const llmMatchOn = document.getElementById('inp-llm-match').checked;
const details = [
`소스: ${sources.join(', ')}`,
...(hasOpt && limit ? [`최대 개수: ${limit}`] : []),
...(hasOpt && category ? [`카테고리: ${category}`] : []),
`비전 검증: ${verifyOn ? '켜짐(느림·품질)' : '꺼짐(빠른 저장)'}`,
...(llmMatchOn ? ['LLM 라벨 매칭: 켜짐'] : []),
].join('\n');
if (!confirm(`다운로드를 시작할까요?\n\n${details}`)) return;
startJob();
}
async function startJob() {
const sources = getCheckedSources();
if (!sources.length) { appendLog('[ERR] 소스를 선택하세요.', true); return; }
const hasOpt = sources.some(s => OPT_SOURCES.has(s));
const hasNet = sources.some(s => NET_SOURCES.has(s));
const limit = hasOpt ? document.getElementById('inp-limit').value : '';
const category = hasOpt ? document.getElementById('inp-category').value.trim() : '';
const verifyOn = document.getElementById('inp-verify').checked;
const llmMatchOn = document.getElementById('inp-llm-match').checked;
const verifyModel = getVerifyModel();
const body = { sources };
if (hasOpt && limit) body.limit = Number(limit);
if (hasOpt && category) body.category = category;
if (hasNet && verifyModel) body.model = verifyModel;
if (!verifyOn) body.noVerify = true;
if (llmMatchOn) body.codeLlmMatch = true;
const r = await authFetch('/api/dental/start', {
method: 'POST',
headers: authH({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
});
const d = await r.json();
if (!d.ok) { appendLog('[ERR] ' + (d.error || '시작 실패'), true); return; }
clearLog();
const logMsg = [`▶ 작업 시작 — 소스: ${sources.join(', ')}`, ...(limit?[`최대 ${limit}개`]:[]), ...(category?[`카테고리: ${category}`]:[]), `검증 ${verifyOn?'켜짐':'꺼짐'}`].join(', ');
appendLog(logMsg, false, 'log-info');
connectSSE();
setRunning(true);
}
async function stopJob() {
await authFetch('/api/dental/stop', { method: 'POST', headers: authH() });
setRunning(false);
appendLog('■ 작업 정지됨', false, 'log-warn');
}
function setRunning(on) {
document.getElementById('btn-start').disabled = on;
document.getElementById('btn-dry').disabled = on;
document.getElementById('btn-stop').disabled = !on;
const badge = document.getElementById('status-badge');
badge.textContent = on ? '실행 중' : '대기중';
badge.className = 'badge ' + (on ? 'running' : '');
document.getElementById('live-panel').style.display = on ? '' : 'none';
if (on) startThumbPoll(); else stopThumbPoll();
}
let _thumbTimer = null;
let _thumbSince = 0;
function startThumbPoll() {
_thumbSince = Date.now() - 5000; // 잡 시작 직전 이미지도 포함
stopThumbPoll();
_thumbTimer = setInterval(_pollThumbs, 2500);
_pollThumbs();
}
function stopThumbPoll() {
if (_thumbTimer) { clearInterval(_thumbTimer); _thumbTimer = null; }
}
async function _pollThumbs() {
try {
const r = await authFetch(`/api/dental/recent?limit=8&since=${_thumbSince}`);
if (!r.ok) return;
const rows = await r.json();
if (!Array.isArray(rows) || !rows.length) return;
const strip = document.getElementById('live-thumbs');
strip.innerHTML = rows.map(row =>
`<img src="${fixImgUrl(row.image_url)}" title="${esc(row.korean)}"
onclick="openLightbox('${fixImgUrl(row.image_url)}','${esc(row.korean)}')"
onerror="this.style.display='none'">`
).join('');
} catch {}
}
// ── SSE ──────────────────────────────────────────────
function connectSSE() {
if (sseConn) { sseConn.close(); }
sseConn = new EventSource('/api/dental/events');
sseConn.onmessage = (e) => {
let ev; try { ev = JSON.parse(e.data); } catch { return; }
if (ev.type === 'log') {
const isErr = ev.isErr || ev.text.startsWith('[ERR]');
appendLog(ev.text, false, isErr ? 'log-err' : classifyLog(ev.text));
} else if (ev.type === 'done') {
setRunning(false);
appendLog(ev.code === 0 ? '✓ 완료' : `■ 종료 (code=${ev.code})`, false, ev.code === 0 ? 'log-ok' : 'log-warn');
refreshStats();
loadRecent();
} else if (ev.type === 'verify_log') {
const verifyEl = document.getElementById('verify-results');
if (verifyEl) parseVerifyLine(ev.text, verifyEl);
} else if (ev.type === 'verify_done') {
document.getElementById('btn-verify').disabled = false;
appendLog(ev.code === 0 ? '✓ 검증 완료' : '■ 검증 종료', false, ev.code === 0 ? 'log-ok' : 'log-warn');
}
};
sseConn.onerror = () => {
// EventSource auto-reconnects; just log
console.warn('SSE 연결 끊김, 재연결 중...');
};
}
function classifyLog(text) {
if (/✓|완료|done|downloaded|KB/.test(text)) return 'log-ok';
if (/FAIL|Error|error|ERR/.test(text)) return 'log-err';
if (/retry|warn|skip/i.test(text)) return 'log-warn';
return '';
}
// ── Log output ──────────────────────────────────────────────
function appendLog(text, isErr = false, cls = '') {
const el = document.getElementById('log-output');
const span = document.createElement('span');
span.className = 'log-line ' + (isErr ? 'log-err' : cls);
span.textContent = text.endsWith('\n') ? text : text + '\n';
el.appendChild(span);
logCount++;
document.getElementById('log-count').textContent = logCount + '줄';
if (autoScroll) el.scrollTop = el.scrollHeight;
}
function clearLog() {
document.getElementById('log-output').innerHTML = '';
logCount = 0;
document.getElementById('log-count').textContent = '0줄';
}
function scrollLog() {
const el = document.getElementById('log-output');
el.scrollTop = el.scrollHeight;
autoScroll = true;
}
document.getElementById('log-output').addEventListener('scroll', function() {
const el = this;
autoScroll = el.scrollTop + el.clientHeight >= el.scrollHeight - 20;
});
// ── Gallery ──────────────────────────────────────────────
async function loadRecent(category) {
const grid = document.getElementById('gallery-grid');
const cat = category || document.getElementById('inp-category').value.trim();
const url = '/api/dental/recent?limit=96' + (cat ? '&category=' + encodeURIComponent(cat) : '');
try {
const r = await authFetch(url, { });
if (!r.ok) {
grid.innerHTML = `<div style="color:var(--red);font-size:11px;padding:4px;grid-column:span 4;">인증 오류 (${r.status}) — 메인 화면에서 로그인 후 새로고침</div>`;
return;
}
const rows = await r.json();
if (!rows.length) {
grid.innerHTML = '<div style="color:var(--muted);font-size:11px;padding:4px;grid-column:span 4;">이미지 없음</div>';
return;
}
grid.innerHTML = rows.map(row => `
<div class="gallery-thumb" onclick="openLightbox('${fixImgUrl(row.image_url)}','${esc(row.korean)}')" title="${esc(row.korean)}">
<img src="${fixImgUrl(row.image_url)}" alt="${esc(row.korean)}" loading="lazy" onerror="this.parentElement.style.display='none'">
<div class="thumb-label">${esc(row.korean)}</div>
</div>
`).join('');
} catch (e) { console.warn(e); }
}
function fixImgUrl(url) { return url ? url.replace('/api/files/uploads/', '/api/files/') : url; }
function esc(s) { return String(s || '').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
// ── Category selection ──────────────────────────────────
function clearGalleryFilter() {
document.getElementById('gallery-label').textContent = '최근 다운로드';
document.getElementById('gallery-cat-clear').style.display = 'none';
const cat = document.getElementById('inp-category').value.trim();
document.getElementById('btn-gallery-all').style.display = cat ? '' : 'none';
loadRecent('');
}
function openGalleryAll() {
const cat = document.getElementById('inp-category').value.trim();
document.getElementById('gallery-label').textContent = `📂 ${cat} 전체`;
document.getElementById('gallery-cat-clear').style.display = '';
document.getElementById('btn-gallery-all').style.display = 'none';
loadRecent(cat);
}
function selectCategory(name) {
document.getElementById('inp-category').value = name;
document.getElementById('sel-cat-badge').textContent = name ? '✓ ' + name : '';
// 갤러리: 카테고리 선택 시 전체보기 버튼만 표시 (갤러리는 새 이미지 유지)
document.getElementById('btn-gallery-all').style.display = name ? '' : 'none';
document.getElementById('gallery-label').textContent = '최근 다운로드';
document.getElementById('gallery-cat-clear').style.display = 'none';
// highlight active row
document.querySelectorAll('.cat-item').forEach(el => {
el.classList.toggle('cat-selected', el.dataset.cat === name);
});
}
function clearCatSel() {
selectCategory('');
}
// ── Verify model selector ───────────────────────────────
function getVerifyModel() {
return document.getElementById('v-model-sel').value || 'kimi-k2.6:cloud';
}
function onVerifyModelChange(val) {
document.getElementById('verify-model-name').textContent = val;
}
async function loadVerifyModels() {
try {
const r = await authFetch('/api/ollama/models', { });
if (!r.ok) return;
const d = await r.json();
const all = (d.models || d || []);
const models = all.filter(m => m.vision === true).map(m => m.name || m).filter(Boolean);
if (!models.length) return;
const sel = document.getElementById('v-model-sel');
const cur = sel.value;
sel.innerHTML = models.map(m => `<option value="${esc(m)}"${m===cur?' selected':''}>${m}</option>`).join('');
if (!models.includes(cur) && models.length) {
sel.value = models[0];
onVerifyModelChange(models[0]);
}
} catch(e) { console.warn('models load failed', e); }
}
document.addEventListener('DOMContentLoaded', () => {
loadVerifyModels();
document.getElementById('verify-model-name').textContent = getVerifyModel();
});
function openLightbox(url, label) {
document.getElementById('lightbox-img').src = url;
document.getElementById('lightbox-label').textContent = label;
document.getElementById('lightbox').classList.add('open');
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('open');
}
// ── Agent chat ──────────────────────────────────────────────
function chatKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
}
async function sendChat() {
if (chatStreaming) return;
const input = document.getElementById('chat-input');
const msg = input.value.trim();
if (!msg) return;
input.value = '';
appendChatMsg(msg, 'user');
const CONTEXT = `당신은 치과 사전 이미지 현황 조회 도우미입니다. DB 경로: /home/kim/homeclaw/.smallclaw/databases/dental_dict.db. 이미지 디렉토리: dental_images/. 스크립트: workflow_images.py, download_images.py, manage.py. 질문에 답변하고 현황을 분석해 주세요. 단, 스크립트 실행이나 API 호출은 절대 하지 마세요 — 실행은 사용자가 직접 버튼으로 합니다. 한국어로 답변하세요.`;
const history = [];
const msgs = document.querySelectorAll('#chat-messages .chat-msg');
msgs.forEach(m => {
if (m.classList.contains('user')) history.push({ role: 'user', content: m.textContent });
else if (m.classList.contains('agent')) history.push({ role: 'assistant', content: m.textContent });
});
// pop last (the current msg was already appended visually)
history.pop();
const agentEl = appendChatMsg('...', 'agent');
chatStreaming = true;
chatAbort = new AbortController();
try {
const res = await authFetch('/api/chat', {
method: 'POST',
headers: authH({ 'Content-Type': 'application/json', Accept: 'text/event-stream' }),
body: JSON.stringify({
message: CONTEXT + '\n\n사용자: ' + msg,
history: history.slice(-10),
useTools: true,
sessionId: chatSessionId,
}),
signal: chatAbort.signal,
});
if (!res.ok) { agentEl.textContent = '오류: HTTP ' + res.status; return; }
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
let agentText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
let ev;
try { ev = JSON.parse(line.slice(6)); } catch { continue; }
if (ev.type === 'token') {
agentText += ev.text || '';
agentEl.textContent = agentText;
document.getElementById('chat-messages').scrollTop = 99999;
} else if (ev.type === 'tool_call') {
appendChatMsg(`🔧 ${ev.name}(${JSON.stringify(ev.args || {}).slice(0, 80)})`, 'tool');
} else if (ev.type === 'tool_result') {
const preview = String(ev.result || '').slice(0, 120);
appendChatMsg(`→ ${preview}${preview.length >= 120 ? '...' : ''}`, 'tool');
} else if (ev.type === 'thinking' && ev.thinking) {
appendChatMsg(ev.thinking.slice(0, 100) + '…', 'thinking');
}
}
}
if (!agentText) agentEl.textContent = '(응답 없음)';
// After agent responds, refresh stats in case something changed
setTimeout(refreshStats, 1000);
setTimeout(loadRecent, 2000);
} catch (e) {
if (e.name !== 'AbortError') agentEl.textContent = '오류: ' + e.message;
} finally {
chatStreaming = false;
chatAbort = null;
}
}
function appendChatMsg(text, role) {
const el = document.createElement('div');
el.className = 'chat-msg ' + role;
el.textContent = text;
const container = document.getElementById('chat-messages');
container.appendChild(el);
container.scrollTop = container.scrollHeight;
return el;
}
// ── Mobile ────────────────────────────────────────────
function mobCloseAll(){
document.querySelector('.left-panel').classList.remove('mob-open');
document.querySelector('.right-panel').classList.remove('mob-open');
document.getElementById('mob-backdrop').classList.remove('show');
document.querySelectorAll('#mob-tabbar .mob-tab').forEach(t=>t.style.color='var(--muted)');
const logTab=document.getElementById('mobtab-log');
if(logTab)logTab.style.color='var(--accent)';
// hide close buttons
const lc=document.getElementById('left-mob-close');
const rc=document.getElementById('right-mob-close');
if(lc)lc.style.display='none';
if(rc)rc.style.display='none';
}
function mobToggle(side){
const isMob=window.innerWidth<=640;
if(!isMob)return;
const leftOpen=document.querySelector('.left-panel').classList.contains('mob-open');
const rightOpen=document.querySelector('.right-panel').classList.contains('mob-open');
if(side==='left'&&leftOpen){mobCloseAll();return;}
if(side==='right'&&rightOpen){mobCloseAll();return;}
mobCloseAll();
if(side==='left'){
document.querySelector('.left-panel').classList.add('mob-open');
document.getElementById('mob-backdrop').classList.add('show');
const lc=document.getElementById('left-mob-close');
if(lc)lc.style.display='block';
const t=document.getElementById('mobtab-ctrl');
if(t)t.style.color='var(--accent)';
const lt=document.getElementById('mobtab-log');
if(lt)lt.style.color='var(--muted)';
} else {
document.querySelector('.right-panel').classList.add('mob-open');
document.getElementById('mob-backdrop').classList.add('show');
const rc=document.getElementById('right-mob-close');
if(rc)rc.style.display='block';
const t=document.getElementById('mobtab-panel');
if(t)t.style.color='var(--accent)';
const lt=document.getElementById('mobtab-log');
if(lt)lt.style.color='var(--muted)';
}
}
// ── Init ──────────────────────────────────────────────
(async () => {
await Promise.all([refreshStats(), loadRecent(), loadModelInfo(), loadVerifyModels()]);
connectSSE(); // 항상 SSE 연결 (다운로드·검증 이벤트 수신)
// Check if job already running (page reload case)
try {
const r = await authFetch('/api/dental/status', { });
const d = await r.json();
if (d.running) {
setRunning(true);
appendLog('▶ 진행 중인 작업 연결됨', false, 'log-info');
}
} catch {}
})();
</script>
</body>
</html>