앱 HTML 복붙 조사의 후속. getToken/authH는 12개 앱에 복사돼 있는데
dental-agent 한 곳만 다른 구현이었고, 두 가지 실제 동작 차이가 있었음.
1) getToken에 try/catch 없음
나머지 11개는 예외를 삼키고 빈 문자열을 반환하는데 dental-agent만 그대로
던짐. 시크릿 모드나 스토리지가 차단된 환경에서 sessionStorage.getItem이
SecurityError를 던지면 페이지 초기화가 그 자리에서 멈춤. 실증:
차단 상황 dental → 예외 전파 / 표준 → "" 반환
2) authH가 토큰 없을 때 입력 객체를 그대로 반환
`return t ? {...extra, Authorization} : extra` 형태라 토큰이 없으면 호출자가
넘긴 객체를 참조째 돌려줌. 호출자가 그 객체를 재사용하면 서로 오염될 수 있는
구조(표준은 항상 새 객체를 만듦). dental-agent의 실제 호출부는 대부분 객체
리터럴이라 현재 피해는 없었으나, 같은 이름의 함수가 페이지마다 다르게
동작한다는 점이 문제.
전 앱 스캔으로 이 두 결함이 dental-agent에만 있는 것을 확인한 뒤 표준으로 교체.
이제 getToken/authH 모두 저장소 전체에서 단일 버전.
검증: dental-agent가 쓰는 네 가지 호출 형태 authH() / authH({...}) /
authH(null) / authH(opts.headers||{}) 전부 정상 동작, 스토리지 차단 시 예외
없음, 배포 후 페이지 HTTP 200 및 /api/dental/status 인증 통과 확인.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1685 lines
65 KiB
HTML
1685 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:52px;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:52px;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 0 52px 0;background:rgba(0,0,0,.55);z-index:499;}
|
|
#mob-backdrop.show{display:block;}
|
|
|
|
/* Bottom tabbar */
|
|
#mob-tabbar{display:flex !important;z-index:600 !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:600;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 이미지 범용 치과 용어에 강함 비전 검증 후 저장">
|
|
<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 임상 사진·방사선 풍부 캡션 힌트 활용">
|
|
<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="오픈 학술 논문 그림 재료학·SEM 현미경에 강함 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 Open-i보다 넓은 범위 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 구강 병리 아틀라스 ~60종 병리 임상사진·방사선 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 6종 일반질환 ~1163장 치석/우식/치은염/변색/궤양/무치아">
|
|
<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 파노라마 333장 CC-BY-4.0 방사선 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종 임상 사진 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 Implant 등 다수 데이터셋 통합">
|
|
<input type="checkbox" value="huggingface" onchange="onSourceChange()">
|
|
<span class="source-name">HuggingFace</span>
|
|
</label>
|
|
<label class="source-item" title="COCO 형식 치과 X선 우식/충전/매복/임플란트">
|
|
<input type="checkbox" value="roboflow" onchange="onSourceChange()">
|
|
<span class="source-name">Roboflow</span>
|
|
</label>
|
|
<label class="source-item" title="파노라마 방사선 14종 COCO 어노테이션 CC BY 4.0">
|
|
<input type="checkbox" value="zenodo" onchange="onSourceChange()">
|
|
<span class="source-name">Zenodo</span>
|
|
</label>
|
|
<label class="source-item" title="소아치과 구강 내 사진 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 프로젝트 API 키 필요">
|
|
<input type="checkbox" value="roboflow_api" onchange="onSourceChange()">
|
|
<span class="source-name">Roboflow API</span>
|
|
</label>
|
|
<label class="source-item" title="방사선 교육 자료 URL 매핑 (source_url 필드 활용)">
|
|
<input type="checkbox" value="radiopaedia" onchange="onSourceChange()">
|
|
<span class="source-name">Radiopaedia</span>
|
|
</label>
|
|
<label class="source-item" title="COde HuggingFace ~50k 구강내사진+8k 방사선 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 치아 병변 탐지 Apache-2.0 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 파노라마 1104장 CC-BY-4.0 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 구강 세포병리 9593 patch 17GB 수동 다운로드 · 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 371볼륨 31GB CC-BY-4.0 수동 다운로드 · 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 영문 term 검색 → 비전 검증 저장 일반 임상 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(){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;}
|
|
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> · ${rows.length}건
|
|
${withImg ? ` · 이미지 <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, '"').replace(/</g, '<').replace(/>/g, '>'); }
|
|
|
|
// ── 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 경로: /srv/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<=1024;
|
|
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>
|