Files
homeclaw/web-ui/index.html
T
kimandClaude Sonnet 4.6 6df1bcf5b3 Add TradingView chart widget + MIDI/tab close fixes
- investor skill: TradingView 실시간 주가 차트 렌더링 지원
  - ```tradingview SYMBOL|INTERVAL``` 블록 → 차트 위젯 자동 생성
  - JSON 형식 출력 시 HTML 언이스케이프 후 파싱 (symbol/interval 추출)
  - renderTVChart(): tv.js 동적 로드, 다크/라이트 테마 연동, RSI·MACD 기본 포함
- musician skill: MIDI 다운로드 사이트 4개 추가 (freemidi, bitmidi, piano-midi, midiworld)
- MIDI 탭 닫기 버그: window.open에 noopener,noreferrer 추가
- beforeunload/pagehide 시 midiRouter.stop() 호출

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 23:26:28 +09:00

12788 lines
576 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CherryClaw</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nanum+Gothic:wght@400;700;800&display=swap" rel="stylesheet">
<link rel="icon" type="image/png" sizes="64x64" href="cherry_logo.png">
<link rel="apple-touch-icon" sizes="128x128" href="cherry_logo.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/xml/xml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/css/css.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/javascript/javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/markdown/markdown.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
:root {
--bg: #f4f6fb;
--bg-soft: #eef2f8;
--panel: #ffffff;
--panel-2: #f8fafc;
--line: #dbe3ee;
--line-strong: #c7d3e3;
--text: #0f172a;
--muted: #5f6f86;
--brand: #1668e3;
--brand-2: #0f4fba;
--ok: #0f9f6e;
--warn: #d18b19;
--err: #d14b4b;
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 18px;
--shadow-sm: 0 2px 6px rgba(16, 32, 64, 0.06);
--shadow-md: 0 8px 24px rgba(16, 32, 64, 0.08);
}
:root[data-theme="dark"] {
--bg: #1f242d;
--bg-soft: #242a34;
--panel: #2a313d;
--panel-2: #323a47;
--line: #3d4656;
--line-strong: #505b70;
--text: #e8edf6;
--muted: #aeb9cb;
--brand: #4c8dff;
--brand-2: #2f6fe0;
--ok: #31b884;
--warn: #d6a64f;
--err: #e06d6d;
--shadow-sm: 0 2px 8px rgba(4, 7, 12, 0.28);
--shadow-md: 0 10px 30px rgba(4, 7, 12, 0.36);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100dvh;
height: 100dvh;
font-family: 'Manrope', sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 8% -15%, rgba(22, 104, 227, 0.12), transparent 65%),
radial-gradient(900px 420px at 100% -20%, rgba(33, 166, 255, 0.12), transparent 62%),
var(--bg);
display: grid;
grid-template-rows: 64px 1fr;
grid-template-columns: 280px 1fr minmax(380px, 34vw);
grid-template-areas:
"header header header"
"sidebar main right";
gap: 14px;
padding: 12px;
overflow: hidden;
}
:root[data-theme="dark"] body {
background:
radial-gradient(900px 420px at 8% -15%, rgba(76, 141, 255, 0.12), transparent 65%),
radial-gradient(900px 420px at 100% -20%, rgba(89, 188, 255, 0.1), transparent 62%),
var(--bg);
}
header {
grid-area: header;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(10px);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
display: flex;
align-items: center;
padding: 10px 16px;
gap: 14px;
position: relative;
z-index: 20;
}
.logo {
font-size: 20px;
font-weight: 800;
letter-spacing: -0.02em;
color: #0b1f3f;
display: flex;
align-items: center;
gap: 8px;
}
.logo img { width: 56px; height: 56px; border-radius: 8px; }
:root[data-theme="dark"] .logo { color: #e8edf6; }
.mode-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-2);
}
.mode-btn {
border: 0;
background: transparent;
color: var(--muted);
padding: 8px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.18s ease;
font-family: 'Manrope', sans-serif;
}
.mode-btn.active {
background: var(--brand);
color: #fff;
box-shadow: 0 2px 10px rgba(22, 104, 227, 0.28);
}
.status-bar {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel);
color: var(--muted);
cursor: pointer;
transition: all 0.15s ease;
}
.icon-btn:hover { color: var(--text); border-color: var(--line-strong); }
.icon-btn svg { width: 16px; height: 16px; }
.icon-btn .theme-icon { display: none; }
.icon-btn[data-theme-state="light"] .theme-icon.sun { display: block; }
.icon-btn[data-theme-state="dark"] .theme-icon.moon { display: block; }
.status-pill {
border: 1px solid var(--line);
background: var(--panel);
border-radius: 999px;
padding: 8px 12px;
font-size: 12px;
color: var(--muted);
display: flex;
align-items: center;
gap: 7px;
}
.status-pill.stat-compact {
min-width: 178px;
justify-content: center;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: #a9b4c5; }
.dot.online { background: var(--ok); box-shadow: 0 0 0 4px rgba(15, 159, 110, 0.16); }
.dot.offline { background: var(--err); box-shadow: 0 0 0 4px rgba(209, 75, 75, 0.14); }
aside,
main,
.right-panel {
min-height: 0;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--panel);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.right-panel {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
/* Collapsed (minimized to right rail) — grid columns are set via JS so the
mobile single-column media query keeps working. */
body.right-collapsed .right-panel > *:not(#right-panel-rail) { display: none !important; }
body.right-collapsed #right-panel-rail { display: flex !important; }
#right-panel-rail { display: none; }
aside {
grid-area: sidebar;
display: flex;
flex-direction: column;
}
#sidebar-tabs {
display: flex;
padding: 8px;
gap: 6px;
border-bottom: 1px solid var(--line);
background: var(--panel-2);
}
.sidebar-tab {
flex: 1;
border: 1px solid var(--line);
background: #fff;
color: var(--muted);
border-radius: 999px;
padding: 8px 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
transition: all 0.18s ease;
font-family: 'Manrope', sans-serif;
}
:root[data-theme="dark"] .sidebar-tab { background: var(--panel); }
.sidebar-tab.active {
background: #eaf2ff;
border-color: #bdd3f6;
color: #0d4faf;
}
#sidebar-jobs,
#sidebar-skills { flex: 3; min-height: 0; display: flex; flex-direction: column; }
#sidebar-skills { display: none; }
/* ── Server log panel ─────────────────────────────── */
#sidebar-log-panel {
flex: 2;
min-height: 0;
display: flex;
flex-direction: column;
border-top: 2px solid var(--line);
}
#sidebar-log-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px;
background: var(--panel-2, var(--panel));
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
#sidebar-log-header span {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--muted);
text-transform: uppercase;
}
#sidebar-log-header button {
background: none;
border: none;
cursor: pointer;
color: var(--muted);
font-size: 11px;
padding: 1px 4px;
border-radius: 4px;
line-height: 1;
}
#sidebar-log-header button:hover { background: var(--line); color: var(--text); }
#sidebar-log-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 2px;
background: var(--bg, #f7f8fa);
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
line-height: 1.5;
}
:root[data-theme="dark"] #sidebar-log-body { background: #0f1117; }
.log-line {
display: flex;
gap: 4px;
padding: 1px 6px;
border-radius: 2px;
overflow: hidden;
}
.log-line:hover { background: var(--line); }
.log-ts { color: var(--muted); flex-shrink: 0; }
.log-msg { color: var(--text); word-break: break-all; white-space: pre-wrap; }
.log-line.warn .log-msg { color: #b45309; }
.log-line.error .log-msg { color: #dc2626; }
:root[data-theme="dark"] .log-line.warn .log-msg { color: #fbbf24; }
:root[data-theme="dark"] .log-line.error .log-msg { color: #f87171; }
.sidebar-section {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.sidebar-action {
border: 1px solid var(--line);
background: #fff;
border-radius: 999px;
padding: 5px 10px;
font-size: 11px;
color: var(--muted);
cursor: pointer;
font-family: 'Manrope', sans-serif;
}
:root[data-theme="dark"] .sidebar-action { background: var(--panel); color: var(--text); }
:root[data-theme="dark"] .hb-modal,
:root[data-theme="dark"] #settings-modal > div {
background: var(--panel) !important;
color: var(--text);
border-color: var(--line) !important;
}
:root[data-theme="dark"] header {
background: rgba(42, 49, 61, 0.88);
}
:root[data-theme="dark"] main,
:root[data-theme="dark"] .right-panel,
:root[data-theme="dark"] aside {
background: var(--panel);
}
:root[data-theme="dark"] .job-item,
:root[data-theme="dark"] .skill-item,
:root[data-theme="dark"] .skill-result,
:root[data-theme="dark"] .task-card,
:root[data-theme="dark"] .approval-card,
:root[data-theme="dark"] .agent-metric,
:root[data-theme="dark"] .task-list,
:root[data-theme="dark"] .task-row,
:root[data-theme="dark"] .artifact-card,
:root[data-theme="dark"] .artifact-preview {
background: var(--panel-2);
border-color: var(--line);
}
:root[data-theme="dark"] .msg {
border-bottom-color: var(--line);
}
:root[data-theme="dark"] .msg-role { color: #c1cde0; }
:root[data-theme="dark"] .msg-content { color: #e8edf6; }
:root[data-theme="dark"] .msg.user .msg-content { color: #9fc2ff; }
:root[data-theme="dark"] .chat-welcome h2 { color: #c8dbff; }
:root[data-theme="dark"] .agent-card {
background: linear-gradient(180deg, #303846 0%, #2a313d 100%);
border-color: var(--line);
}
:root[data-theme="dark"] .task-progress-wrap {
background: #242c39;
border-color: var(--line);
}
:root[data-theme="dark"] #process-log,
:root[data-theme="dark"] #log-panel {
background: #242c39;
border-color: var(--line);
color: #d4deee;
}
:root[data-theme="dark"] .log-line { color: #b8c4da; }
:root[data-theme="dark"] .log-line.info { color: #82adff; }
:root[data-theme="dark"] .chat-input-area,
:root[data-theme="dark"] .mission-bar {
background: linear-gradient(180deg, #313947, #2a313d);
}
:root[data-theme="dark"] .queued-prompts-panel {
background: linear-gradient(180deg, #2c3442, #272f3a);
}
:root[data-theme="dark"] .queued-item {
background: var(--panel);
border-color: var(--line);
color: var(--text);
}
:root[data-theme="dark"] .queued-item-btn,
:root[data-theme="dark"] .queued-clear-btn {
background: var(--panel-2);
border-color: var(--line);
color: var(--text);
}
:root[data-theme="dark"] .skill-card {
background: var(--panel-2);
border-color: var(--line);
}
:root[data-theme="dark"] .skill-card:hover {
border-color: #5c6980;
box-shadow: 0 6px 16px rgba(8, 12, 20, 0.35);
}
:root[data-theme="dark"] .skill-card.enabled {
border-color: #3f8f67;
background: linear-gradient(135deg, #273b33 0%, #23352e 100%);
box-shadow: 0 0 0 2px rgba(49, 184, 132, 0.18), 0 4px 12px rgba(10, 20, 14, 0.35);
}
:root[data-theme="dark"] .skill-card.enabled .skill-card-name { color: #9fe3c2; }
:root[data-theme="dark"] .skill-card-dot { background: #6c788f; }
:root[data-theme="dark"] .skill-card.enabled .skill-card-dot {
background: #31b884;
box-shadow: 0 0 0 3px rgba(49, 184, 132, 0.22);
}
:root[data-theme="dark"] .skill-mini-btn {
background: var(--panel);
border-color: var(--line);
color: #c8d4e8;
}
:root[data-theme="dark"] .skill-mini-btn:hover {
border-color: #64738e;
color: #e8edf6;
}
:root[data-theme="dark"] .skill-remove-btn { color: #aeb9cc; }
:root[data-theme="dark"] .skill-remove-btn:hover { color: #f09999; }
:root[data-theme="dark"] .toggle-track {
background: #3a4454;
border-color: #546177;
}
:root[data-theme="dark"] .toggle-track::after { background: #b6c2d8; }
:root[data-theme="dark"] .toggle-switch input:checked + .toggle-track {
background: #334c77;
border-color: #4b73b3;
}
:root[data-theme="dark"] #chat-input,
:root[data-theme="dark"] #mission-input,
:root[data-theme="dark"] #skill-search-input {
background: var(--panel-2);
border-color: var(--line);
color: var(--text);
}
:root[data-theme="dark"] #chat-input::placeholder,
:root[data-theme="dark"] #mission-input::placeholder,
:root[data-theme="dark"] #skill-search-input::placeholder {
color: #a9b5ca;
}
:root[data-theme="dark"] #settings-modal input,
:root[data-theme="dark"] #settings-modal select,
:root[data-theme="dark"] #settings-modal textarea,
:root[data-theme="dark"] .settings-input {
background: var(--panel-2) !important;
color: var(--text) !important;
border-color: var(--line) !important;
}
:root[data-theme="dark"] #settings-modal [style*="background:#fff"],
:root[data-theme="dark"] #settings-modal [style*="background: #fff"] {
background: var(--panel-2) !important;
color: var(--text) !important;
}
:root[data-theme="dark"] #settings-modal [style*="linear-gradient(180deg,#fff"],
:root[data-theme="dark"] #settings-modal [style*="linear-gradient(180deg, #fff"] {
background: linear-gradient(180deg, #323a47 0%, #2b3340 100%) !important;
}
:root[data-theme="dark"] #settings-modal #tg-status-bar {
background: #2a313d !important;
border: 1px solid var(--line);
}
:root[data-theme="dark"] #settings-modal .info-row {
border-bottom-color: var(--line);
}
:root[data-theme="dark"] #settings-modal button[style*="background:#fff"],
:root[data-theme="dark"] #settings-modal button[style*="background: #fff"] {
background: var(--panel) !important;
color: var(--text) !important;
border-color: var(--line) !important;
}
:root[data-theme="dark"] #settings-modal code {
background: #232a35 !important;
color: #d8e2f1;
}
:root[data-theme="dark"] .CodeMirror {
background: #1f242d;
color: #e8edf6;
}
:root[data-theme="dark"] .CodeMirror-gutters {
background: #232a35;
border-right: 1px solid var(--line);
}
#jobs-list,
#skills-list,
#skills-search-results { flex: 1; min-height: 0; overflow-y: auto; padding: 10px; }
.job-item,
.skill-item,
.skill-result,
.task-card,
.approval-card {
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #fff;
transition: all 0.15s ease;
}
.job-item {
padding: 12px;
margin-bottom: 8px;
cursor: pointer;
}
.job-item:hover { border-color: #c2d6f4; box-shadow: 0 4px 12px rgba(15, 60, 120, 0.08); }
.job-item.active { border-color: #9fbef1; background: #f1f6ff; }
.job-item.unread { background: linear-gradient(135deg, #f0f8ff 0%, #e8f4ff 100%); border-color: #8ec4f8; }
.job-item.unread.active { background: #f1f6ff; border-color: #5ca9f0; }
.job-item-title { font-size: 12px; font-weight: 700; }
.badge {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
border-radius: 999px;
padding: 4px 8px;
}
.badge-planning { background: #e6f0ff; color: #225ab6; }
.badge-executing { background: #e9f8ff; color: #1d7ea7; }
.badge-completed { background: #e8fbf3; color: #0f8b63; }
.badge-failed { background: #fff0f0; color: #c33f3f; }
.badge-queued { background: #eef2f7; color: #607086; }
.badge-needs_approval { background: #fff6e9; color: #b06b00; }
.empty-state {
border: 1px dashed var(--line-strong);
border-radius: var(--radius-md);
padding: 22px 12px;
text-align: center;
color: var(--muted);
font-size: 12px;
line-height: 1.7;
background: var(--panel-2);
}
.status-active {
background: #c8f0c4;
color: #0d5c2f;
}
.status-paused {
background: #fff4d6;
color: #7d5700;
}
.status-failed {
background: #ffd6d6;
color: #8b0000;
}
.agent-card {
border: 1px solid var(--line);
border-radius: 12px;
background: linear-gradient(180deg, #fff 0%, #f9fbff 100%);
padding: 10px;
display: flex;
flex-direction: column;
min-height: 0;
}
.agent-context-scroll {
margin-top: 10px;
max-height: none;
overflow: visible;
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 2px;
}
.agent-metric-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.agent-metric {
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
padding: 8px;
}
.agent-metric .k {
font-size: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.agent-metric .v {
font-size: 12px;
color: var(--text);
font-weight: 700;
margin-top: 3px;
}
.task-progress-wrap {
margin-top: 10px;
border: 1px solid var(--line);
border-radius: 999px;
height: 8px;
background: #eef3fb;
overflow: hidden;
}
.task-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #0f9f6e, #20b182);
transition: width 180ms ease;
}
.task-list {
margin-top: 0;
max-height: 180px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
padding: 8px;
}
.agent-context-scroll .task-list {
max-height: none;
overflow: visible;
min-height: 52px;
}
.task-row {
padding: 7px 8px;
border: 1px solid var(--line);
border-radius: 9px;
margin-bottom: 6px;
background: #fcfdff;
}
.task-row:last-child { margin-bottom: 0; }
.task-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.task-title {
font-size: 11px;
color: var(--text);
font-weight: 700;
line-height: 1.45;
overflow-wrap: anywhere;
word-break: break-word;
}
.task-meta {
font-size: 10px;
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.status-chip {
border-radius: 999px;
padding: 2px 8px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.status-chip.pending { background: #eef2f7; color: #607086; }
.status-chip.planned { background: #eef2f7; color: #607086; }
.status-chip.running { background: #e6f0ff; color: #225ab6; }
.status-chip.in_progress { background: #e6f0ff; color: #225ab6; }
.status-chip.verifying { background: #fff6e9; color: #b06b00; }
.status-chip.repaired { background: #e6fff9; color: #0f8b63; }
.status-chip.done { background: #e8fbf3; color: #0f8b63; }
.status-chip.skipped { background: #f4f6fa; color: #7d8ca2; }
.status-chip.failed { background: #fff0f0; color: #c33f3f; }
main {
grid-area: main;
display: flex;
flex-direction: column;
background: #fff;
height: 100%;
}
#chat-view,
#mission-view { flex: 1; min-height: 0; height: 100%; display: flex; flex-direction: column; }
#mission-view { display: none; }
#chat-messages,
#main-content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 24px;
}
.chat-welcome,
.welcome {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 10px;
color: var(--muted);
}
.chat-welcome-icon,
.welcome-logo { font-size: 48px; opacity: 0.6; }
.chat-welcome-icon img { width: 112px; height: 112px; border-radius: 16px; }
.chat-welcome h2 { font-size: 28px; font-weight: 800; color: #10346f; }
.msg {
display: flex;
gap: 12px;
padding: 16px 0;
border-bottom: 1px solid #eef2f7;
}
.msg-avatar {
width: 30px;
height: 30px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
border: 1px solid var(--line);
background: var(--panel-2);
}
.msg.user .msg-avatar { background: #e8f1ff; border-color: #c4d9fb; }
.msg-body { flex: 1; min-width: 0; }
.msg-role { font-size: 11px; color: #4f617b; font-weight: 700; margin-bottom: 6px; }
.msg-content { line-height: 1.75; font-size: 14px; white-space: pre-wrap; word-break: break-word; color: #17243b; }
.msg.user .msg-content { color: #0f3d8a; }
.md-img { max-width: 100%; max-height: 400px; border-radius: 8px; cursor: pointer; margin: 8px 0; display: block; }
.md-img:hover { opacity: 0.9; }
.md-code-block { background: #1e1e2e; color: #cdd6f4; padding: 12px 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; margin: 8px 0; }
.md-inline-code { background: #e8edf6; color: #17243b; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
:root[data-theme="dark"] .md-inline-code { background: #2a2a3e; color: #cdd6f4; }
:root[data-theme="dark"] .md-code-block { background: #11111b; }
.download-btn { display: inline-flex; align-items: center; gap: 4px; background: linear-gradient(135deg, #3b82f6, #2563eb); color: #fff !important; padding: 4px 10px; border-radius: 6px; text-decoration: none !important; font-weight: 600; font-size: 12px; margin: 4px 2px; transition: all 0.15s; box-shadow: 0 1px 3px rgba(37,99,235,0.25); cursor: pointer; }
.download-btn:hover { background: linear-gradient(135deg, #2563eb, #1d4ed8); transform: translateY(-1px); box-shadow: 0 2px 6px rgba(37,99,235,0.35); }
.download-icon { font-size: 12px; line-height: 1; }
:root[data-theme="dark"] .download-btn { background: linear-gradient(135deg, #6366f1, #4f46e5); box-shadow: 0 1px 3px rgba(79,70,229,0.25); }
:root[data-theme="dark"] .download-btn:hover { background: linear-gradient(135deg, #4f46e5, #4338ca); }
.download-btn-error { display: inline-flex; align-items: center; gap: 4px; background: #fee2e2; color: #991b1b !important; padding: 4px 10px; border-radius: 6px; font-weight: 600; font-size: 12px; margin: 4px 2px; cursor: default; text-decoration: none !important; }
:root[data-theme="dark"] .download-btn-error { background: #3f1515; color: #fca5a5 !important; }
.pptx-file-row { margin: 4px 0; }
.pptx-preview-btn { display: inline-flex; align-items: center; gap: 4px; background: linear-gradient(135deg, #10b981, #059669); color: #fff; padding: 4px 10px; border-radius: 6px; border: none; font-weight: 600; font-size: 12px; cursor: pointer; margin-left: 4px; transition: all 0.15s; box-shadow: 0 1px 3px rgba(16,185,129,0.25); box-sizing: border-box; line-height: 1.4; vertical-align: middle; }
.pptx-preview-btn:hover { background: linear-gradient(135deg, #059669, #047857); transform: translateY(-1px); }
.pptx-preview-btn.loading { opacity: 0.6; pointer-events: none; }
.pptx-preview-strip { margin-top: 8px; display: flex; gap: 8px; overflow-x: auto; padding: 8px 4px; border-radius: 8px; background: var(--panel-2, #f3f4f6); }
:root[data-theme="dark"] .pptx-preview-strip { background: var(--panel-2, #1e293b); }
.pptx-preview-strip img { height: 120px; border-radius: 4px; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; flex-shrink: 0; }
.pptx-preview-strip img:hover { transform: scale(1.08); box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
/* Luckysheet font & rendering overrides */
#lucky-container, #lucky-container * {
font-family: "Nanum Gothic", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
#lucky-container .iconfont { font-family: "iconfont" !important; }
#lucky-container .luckysheet-wa { position: absolute !important; }
#image-zoom-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 10000; align-items: center; justify-content: center; cursor: pointer; }
#image-zoom-modal img { max-width: 95vw; max-height: 95vh; border-radius: 8px; box-shadow: 0 4px 24px rgba(0,0,0,0.5); }
.msg-staged { display: flex; flex-direction: column; gap: 10px; }
.msg-stage {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-2);
padding: 10px;
}
.msg-stage-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
font-weight: 800;
margin-bottom: 6px;
}
.artifact-list {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.artifact-card {
border: 1px solid var(--line);
border-radius: 10px;
background: #f9fbff;
padding: 10px;
}
.artifact-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.artifact-title {
font-size: 11px;
font-weight: 800;
color: #15386f;
letter-spacing: 0.02em;
}
.artifact-status {
border-radius: 999px;
padding: 2px 7px;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.artifact-status.ok { background: #e8fbf3; color: #0f8b63; }
.artifact-status.error { background: #fff0f0; color: #c33f3f; }
.artifact-status.skipped { background: #f4f6fa; color: #7d8ca2; }
.artifact-row {
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
line-height: 1.5;
word-break: break-word;
}
.artifact-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
}
.artifact-btn {
border: 1px solid var(--line);
background: #fff;
color: #1a355f;
border-radius: 8px;
padding: 4px 8px;
font-size: 10px;
font-weight: 700;
cursor: pointer;
}
.artifact-btn:hover { border-color: #aac6ef; background: #f2f7ff; }
.artifact-preview {
margin-top: 6px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 8px;
font-size: 11px;
color: #2f3e55;
white-space: pre-wrap;
max-height: 180px;
overflow: auto;
}
.chat-input-area,
.mission-bar {
border-top: 1px solid var(--line);
background: linear-gradient(180deg, #fff, #f9fbff);
padding: 14px 16px;
}
.agent-toggle,
.mission-input-row,
.chat-input-row { display: flex; align-items: center; gap: 10px; }
.queued-prompts-panel {
display: none;
margin-bottom: 10px;
border: 1px solid var(--line);
border-radius: 12px;
background: linear-gradient(180deg, #f7faff, #f2f7ff);
overflow: hidden;
}
.queued-prompts-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
font-size: 11px;
color: var(--muted);
font-weight: 700;
}
.queued-prompts-list { padding: 6px 8px; display: flex; flex-direction: column; gap: 6px; }
.queued-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid #d9e4f4;
border-radius: 10px;
background: #fff;
padding: 7px 8px;
font-size: 12px;
color: #21324a;
}
.queued-item-text { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.queued-item-btn,
.queued-clear-btn {
border: 1px solid var(--line);
background: #fff;
color: var(--muted);
border-radius: 999px;
font-size: 11px;
padding: 3px 8px;
cursor: pointer;
font-family: 'Manrope', sans-serif;
}
.queued-item-btn:hover,
.queued-clear-btn:hover { border-color: #c1d2ec; color: #26487e; }
.agent-toggle { margin-bottom: 10px; }
.agent-toggle-right { margin-left: auto; position: relative; }
.agent-toggle-label { font-size: 11px; color: var(--muted); font-weight: 700; }
.toggle-switch { position: relative; width: 38px; height: 20px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-track {
position: absolute; inset: 0;
border-radius: 999px;
border: 1px solid var(--line-strong);
background: #e8eef7;
}
.toggle-track::after {
content: '';
position: absolute;
width: 14px; height: 14px;
top: 2px; left: 2px;
border-radius: 50%;
background: #6d7f97;
transition: all 0.2s ease;
}
.toggle-switch input:checked + .toggle-track { background: #dce9ff; border-color: #9fc1f7; }
.toggle-switch input:checked + .toggle-track::after { left: 20px; background: var(--brand); }
.agent-mode-badge {
display: none;
border: 1px solid #9fc1f7;
background: #ebf3ff;
color: #0d4faf;
border-radius: 999px;
padding: 3px 8px;
font-size: 10px;
font-weight: 700;
}
.agent-mode-badge.visible { display: inline-block; }
#chat-input,
#mission-input,
#skill-search-input {
flex: 1;
border: 1px solid var(--line-strong);
border-radius: 12px;
background: #fff;
color: var(--text);
font-family: 'Manrope', sans-serif;
font-size: 14px;
padding: 10px 12px;
outline: none;
}
#chat-input { min-height: 44px; max-height: 150px; resize: none; line-height: 1.55; overflow-y: auto; }
#chat-input:focus,
#mission-input:focus,
#skill-search-input:focus { border-color: #9fc1f7; box-shadow: 0 0 0 4px rgba(22, 104, 227, 0.11); }
.btn,
.send-btn,
.skill-search-btn,
.skill-install-btn {
border: 1px solid transparent;
border-radius: 12px;
background: var(--brand);
color: #fff;
cursor: pointer;
font-weight: 700;
font-family: 'Manrope', sans-serif;
transition: all 0.15s ease;
}
.btn:hover,
.send-btn:hover,
.skill-search-btn:hover,
.skill-install-btn:hover { background: var(--brand-2); transform: translateY(-1px); }
.btn { padding: 10px 16px; font-size: 12px; }
.btn-sm { padding: 6px 10px; font-size: 11px; border-radius: 10px; }
#mic-btn.recording { background: #ef4444 !important; animation: mic-pulse 1s ease-in-out infinite; }
@keyframes mic-pulse { 0%,100%{opacity:1} 50%{opacity:0.55} }
.tts-btn { background:none;border:none;cursor:pointer;font-size:13px;padding:1px 4px;border-radius:4px;opacity:0.45;vertical-align:middle;line-height:1; }
.tts-btn:hover,.tts-btn.playing{opacity:1;background:var(--panel-2);}
body.tts-disabled .tts-btn { display:none !important; }
.export-dropdown { position:relative; display:inline-block; }
.export-menu { display:none; position:absolute; right:0; top:calc(100% + 4px); background:var(--panel-2); border:1px solid var(--border); border-radius:8px; box-shadow:0 4px 16px rgba(0,0,0,0.18); z-index:2000; min-width:160px; overflow:hidden; }
.export-menu button { display:block; width:100%; padding:8px 14px; text-align:left; background:none; border:none; cursor:pointer; font-size:12px; color:var(--text); white-space:nowrap; }
.export-menu button:hover { background:var(--bg3); }
.btn-green { background: var(--ok); }
.btn-red { background: var(--err); }
.settings-input {
width: 100%;
border: 1px solid var(--line-strong);
border-radius: 10px;
background: #fff;
color: var(--text);
padding: 8px 10px;
font-size: 12px;
font-family: 'Manrope', sans-serif;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
outline: none;
}
.settings-input:focus {
border-color: #9fc1f7;
box-shadow: 0 0 0 4px rgba(22, 104, 227, 0.11);
}
.settings-input[type="number"] {
font-family: 'IBM Plex Mono', monospace;
}
select.settings-input {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 32px;
background-image:
linear-gradient(45deg, transparent 50%, #6d7f97 50%),
linear-gradient(135deg, #6d7f97 50%, transparent 50%);
background-position:
calc(100% - 16px) calc(50% + 1px),
calc(100% - 11px) calc(50% + 1px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.send-btn { width: 44px; height: 44px; font-size: 16px; }
.send-btn:disabled,
.btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
.quick-mode-btn {
border: 1px solid var(--line-strong);
background: #eef5ff;
color: #0d4faf;
border-radius: 999px;
padding: 6px 10px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
font-family: 'Manrope', sans-serif;
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.quick-mode-popover {
position: absolute;
right: 0;
bottom: 34px;
width: 240px;
border: 1px solid var(--line);
background: #fff;
border-radius: 12px;
box-shadow: var(--shadow-md);
z-index: 120;
padding: 10px;
display: none;
}
.quick-mode-popover.open { display: block; }
.quick-mode-popover.clamp-left { right: auto; left: 0; }
.quick-mode-title {
font-size: 11px;
font-weight: 800;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 6px;
}
.quick-mode-opt {
width: 100%;
border: 1px solid var(--line);
background: #fff;
color: var(--text);
border-radius: 9px;
padding: 7px 9px;
margin-bottom: 6px;
font-size: 12px;
text-align: left;
cursor: pointer;
font-family: 'Manrope', sans-serif;
}
.quick-mode-opt.active {
border-color: #9fc1f7;
background: #edf4ff;
color: #0d4faf;
font-weight: 700;
}
.chat-hint { margin-top: 8px; color: var(--muted); font-size: 11px; }
.chat-hint .switch-link { color: var(--brand); cursor: pointer; text-decoration: underline; }
.thinking { display: flex; align-items: center; gap: 6px; padding-top: 4px; }
.thinking-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--brand);
opacity: 0.25;
animation: thinkBounce 1.2s ease-in-out infinite;
}
.thinking-dot:nth-child(2) { animation-delay: 0.2s; }
.thinking-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes thinkBounce { 0%,80%,100%{transform:scale(0.75);opacity:0.25;} 40%{transform:scale(1);opacity:1;} }
.react-steps {
margin-top: 10px;
border-left: 2px solid #c8d9f1;
padding-left: 10px;
}
.react-step { font-size: 12px; margin-bottom: 8px; }
.react-step-thought { color: var(--muted); font-style: italic; }
.react-step-action { color: #0d4faf; font-weight: 700; margin-top: 3px; }
.react-step-result { color: var(--ok); margin-top: 3px; font-size: 11px; }
.react-step-result.error { color: var(--err); }
.react-steps-toggle { margin-top: 8px; font-size: 11px; color: #0d4faf; cursor: pointer; }
.think-block { margin-top: 8px; border: 1px solid #d5e3f8; border-radius: 12px; overflow: hidden; }
.think-toggle {
width: 100%; border: 0; cursor: pointer; text-align: left;
padding: 8px 10px; background: #f3f8ff; color: #23519f;
display: flex; align-items: center; gap: 8px; font-size: 11px; font-family: 'IBM Plex Mono', monospace;
}
.think-toggle .think-icon { transition: transform 0.2s ease; }
.think-toggle.open .think-icon { transform: rotate(90deg); }
.think-content {
display: none;
padding: 10px 12px;
background: #fff;
color: var(--muted);
border-top: 1px solid #dde7f6;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
line-height: 1.6;
}
.mission-bar-label,
.section-title,
.right-section-title {
font-size: 11px;
font-weight: 800;
color: #5b6e88;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.job-detail-title { font-size: 22px; font-weight: 800; margin-bottom: 8px; }
.job-detail-meta { display: flex; flex-wrap: wrap; gap: 10px; color: var(--muted); font-size: 12px; margin-bottom: 18px; }
.task-card { padding: 14px; margin-bottom: 8px; }
.task-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.task-card-title { font-weight: 700; font-size: 14px; }
.task-card-desc { color: var(--muted); font-size: 12px; line-height: 1.7; }
.task-criteria { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
.task-criteria-item { font-size: 12px; color: var(--muted); padding: 2px 0; }
#log-panel,
#process-log {
background: #fbfdff;
border: 1px solid var(--line);
border-radius: var(--radius-md);
font-family: 'IBM Plex Mono', monospace;
}
#log-panel { padding: 12px; margin-top: 16px; max-height: 220px; overflow-y: auto; font-size: 11px; }
.log-line { color: var(--muted); }
.log-line.info { color: #2d5fb3; }
.log-line.success { color: var(--ok); }
.log-line.error { color: var(--err); }
.log-line.warn { color: var(--warn); }
/* Agent Execution Panel */
#agent-execution-panel {
display: flex;
flex-direction: column;
gap: 0;
}
.agent-item {
padding: 8px 12px;
border-bottom: 1px solid var(--line);
cursor: pointer;
transition: background 0.15s ease;
user-select: none;
}
.agent-item:hover {
background: rgba(22, 104, 227, 0.08);
}
.agent-item:last-child {
border-bottom: none;
}
.agent-item.expanded {
background: rgba(22, 104, 227, 0.12);
}
.agent-item-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.agent-item-name {
font-weight: 600;
font-size: 11px;
color: var(--text);
flex: 1;
}
.agent-item-status {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.agent-item-status.active {
background: rgba(15, 159, 110, 0.2);
color: var(--ok);
}
.agent-item-status.completed {
background: rgba(22, 104, 227, 0.2);
color: var(--brand);
}
.agent-item-status.paused {
background: rgba(209, 75, 75, 0.2);
color: var(--err);
}
.agent-item-task {
font-size: 10px;
color: var(--muted);
margin-bottom: 4px;
word-break: break-word;
}
.agent-item-logs {
display: none;
margin-top: 8px;
padding: 8px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 6px;
max-height: 150px;
overflow-y: auto;
font-size: 10px;
line-height: 1.5;
font-family: 'IBM Plex Mono', monospace;
color: var(--muted);
}
.agent-item.expanded .agent-item-logs {
display: block;
}
.agent-log-entry {
margin-bottom: 3px;
}
.agent-log-entry-time {
color: var(--muted);
opacity: 0.6;
margin-right: 6px;
}
.agent-log-entry-type {
color: var(--brand);
font-weight: 600;
margin-right: 6px;
}
.agent-log-entry-content {
color: var(--text);
}
/* Error Response Panel */
#error-response-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--panel);
border: 2px solid var(--line);
border-radius: var(--radius-lg);
box-shadow: 0 20px 60px rgba(16, 32, 64, 0.16);
z-index: 9998;
max-width: 520px;
width: 90vw;
max-height: 80vh;
display: none;
flex-direction: column;
overflow: hidden;
}
#error-response-panel.visible {
display: flex;
}
.error-response-header {
padding: 16px;
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.error-response-title {
font-size: 16px;
font-weight: 800;
color: var(--text);
margin-bottom: 4px;
}
.error-response-desc {
font-size: 12px;
color: var(--muted);
line-height: 1.6;
}
.error-response-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.error-response-options {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.error-option-btn {
padding: 10px 12px;
border: 1px solid var(--line);
background: var(--panel-2);
color: var(--text);
border-radius: 8px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
text-align: left;
transition: all 0.15s ease;
font-family: 'Manrope', sans-serif;
}
.error-option-btn:hover {
border-color: var(--brand);
background: rgba(22, 104, 227, 0.08);
}
.error-option-btn.selected {
border-color: var(--brand);
background: rgba(22, 104, 227, 0.15);
color: var(--brand-2);
}
.error-option-btn.danger {
border-color: var(--err);
color: var(--err);
}
.error-option-btn.danger:hover {
background: rgba(209, 75, 75, 0.1);
}
.error-response-inputs {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
padding: 12px;
background: var(--panel-2);
border-radius: 8px;
display: none;
}
.error-response-inputs.visible {
display: flex;
}
.input-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.input-field label {
font-size: 11px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.input-field input {
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 12px;
font-family: 'Manrope', sans-serif;
}
.input-field input::placeholder {
color: var(--muted);
opacity: 0.6;
}
.input-field input:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 2px rgba(22, 104, 227, 0.1);
}
.error-response-footer {
padding: 12px 16px;
border-top: 1px solid var(--line);
display: flex;
gap: 8px;
flex-shrink: 0;
justify-content: flex-end;
}
.btn-error-submit {
padding: 8px 14px;
background: var(--brand);
color: #fff;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
font-family: 'Manrope', sans-serif;
transition: background 0.15s ease;
}
.btn-error-submit:hover {
background: var(--brand-2);
}
.btn-error-cancel {
padding: 8px 14px;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
font-family: 'Manrope', sans-serif;
transition: all 0.15s ease;
}
.btn-error-cancel:hover {
border-color: var(--muted);
}
.right-panel { grid-area: right; display: flex; flex-direction: column; }
.right-section { padding: 14px 16px; border-bottom: 1px solid var(--line); }
.sys-grid {
margin-top: 10px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.sys-card {
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-2);
padding: 8px 10px;
min-height: 62px;
}
.sys-card.full {
grid-column: 1 / -1;
min-height: 74px;
}
.sys-k {
font-size: 10px;
font-weight: 800;
color: var(--muted);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.sys-v {
margin-top: 4px;
font-size: 16px;
font-weight: 800;
color: #0d2858;
letter-spacing: -0.01em;
}
.sys-sub {
margin-top: 4px;
font-size: 11px;
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
}
.sys-meter {
margin-top: 6px;
height: 6px;
border-radius: 999px;
background: #e5edf7;
overflow: hidden;
}
.sys-meter > i {
display: block;
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, #1e9d7b 0%, #1b75e6 55%, #0f4fba 100%);
transition: width 0.35s ease;
}
#r-live-steps,
#r-recent-turns {
max-height: none !important;
overflow: visible !important;
min-height: 52px;
}
.info-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #edf2f8; font-size: 12px; }
.info-row:last-child { border-bottom: 0; }
.info-key { color: var(--muted); }
.info-val { color: var(--text); font-weight: 700; }
.info-val.green { color: var(--ok); }
.info-val.red { color: var(--err); }
#approvals-list { max-height: 220px; overflow-y: auto; }
.approval-card { padding: 12px; margin-bottom: 8px; background: #fffaf0; border-color: #f1dbad; }
.approval-action { color: #8b5c00; font-weight: 700; font-size: 12px; margin-bottom: 4px; }
.approval-reason { color: #726344; font-size: 11px; line-height: 1.6; margin-bottom: 10px; }
.approval-buttons { display: flex; gap: 8px; }
.skills-search-bar { padding: 10px; border-bottom: 1px solid var(--line); display: flex; gap: 8px; background: var(--panel-2); }
.skill-item { padding: 10px; margin-bottom: 6px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.skill-item-name { font-size: 12px; font-weight: 700; color: #14478f; }
.skill-remove-btn { border: 0; background: transparent; color: var(--muted); cursor: pointer; }
.skill-remove-btn:hover { color: var(--err); }
.skill-status-row { display:flex; align-items:center; gap:6px; margin-top:4px; flex-wrap: wrap; }
.skill-badge {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 800;
border-radius: 999px;
border: 1px solid var(--line);
background: #f5f8fd;
color: #4e5f78;
padding: 2px 7px;
}
.skill-badge.ready { background:#e8fbf3; color:#0f8b63; border-color:#bde9d4; }
.skill-badge.needs_setup { background:#fff6e9; color:#b06b00; border-color:#f0d49e; }
.skill-badge.blocked { background:#fff0f0; color:#c33f3f; border-color:#efc0c0; }
.skill-badge.risk-low { background:#e8fbf3; color:#0f8b63; border-color:#bde9d4; }
.skill-badge.risk-medium { background:#fff6e9; color:#b06b00; border-color:#f0d49e; }
.skill-badge.risk-high { background:#fff0f0; color:#c33f3f; border-color:#efc0c0; }
.skill-item-actions { display:flex; align-items:center; gap:6px; }
.skill-mini-btn {
border: 1px solid var(--line);
background: #fff;
color: var(--muted);
cursor: pointer;
border-radius: 999px;
padding: 3px 8px;
font-size: 10px;
font-weight: 700;
font-family: 'Manrope', sans-serif;
}
.skill-mini-btn:hover { border-color:#c1d2ec; color:#26487e; }
.skill-mini-btn.enabled {
background:#e8fbf3;
border-color:#bde9d4;
color:#0f8b63;
}
.skill-details {
margin: 8px 10px 10px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fbfdff;
padding: 10px;
display: none;
}
.skill-details.open { display:block; }
.skill-details h4 { font-size: 11px; margin: 0 0 6px; color:#17417f; }
.skill-details .line { font-size: 11px; color: var(--muted); margin: 2px 0; line-height: 1.5; }
.skill-details .line code { color:#10346f; font-family:'IBM Plex Mono', monospace; }
.skill-upload-btn {
border: 1px solid #bcd4f6;
background: #ebf3ff;
color: #0d4faf;
border-radius: 999px;
padding: 5px 10px;
font-size: 10px;
font-weight: 700;
cursor: pointer;
font-family: 'Manrope', sans-serif;
}
.skill-upload-btn:hover { background:#dfeeff; border-color:#a8c7f2; }
/* Skill cards - same color as chat sessions, green glow when enabled */
.skill-card {
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #fff;
padding: 10px 12px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.skill-card:hover {
border-color: #c2d6f4;
box-shadow: 0 4px 12px rgba(15, 60, 120, 0.08);
}
.skill-card.enabled {
border-color: #86d4a8;
background: linear-gradient(135deg, #f0faf4 0%, #e8f8ee 100%);
box-shadow: 0 0 0 2px rgba(15, 159, 110, 0.12), 0 2px 8px rgba(15, 159, 110, 0.1);
}
.skill-card.enabled:hover {
border-color: #5ec48a;
box-shadow: 0 0 0 2px rgba(15, 159, 110, 0.18), 0 4px 12px rgba(15, 159, 110, 0.14);
}
.skill-card-top {
display: flex;
align-items: center;
gap: 8px;
}
.skill-card-emoji {
font-size: 18px;
flex-shrink: 0;
}
.skill-card-info {
flex: 1;
min-width: 0;
}
.skill-card-name {
font-size: 12px;
font-weight: 700;
color: var(--text);
line-height: 1.3;
}
.skill-card.enabled .skill-card-name {
color: #0a6b3d;
}
.skill-card-desc {
font-size: 10px;
color: var(--muted);
line-height: 1.4;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.skill-card-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.skill-card-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #c5d0de;
transition: all 0.2s ease;
}
.skill-card.enabled .skill-card-dot {
background: var(--ok);
box-shadow: 0 0 0 3px rgba(15, 159, 110, 0.16);
}
/* Context pinning - message selection mode */
.msg.pin-selectable {
cursor: pointer;
border-radius: 12px;
padding: 12px 16px;
margin: 4px -16px;
transition: all 0.2s ease;
}
.msg.pin-selectable:hover {
background: #f0f4fb;
}
.msg.pin-selected {
background: linear-gradient(135deg, #f0faf4 0%, #e8f8ee 100%) !important;
border: 1px solid #86d4a8;
box-shadow: 0 0 0 2px rgba(15, 159, 110, 0.12);
}
.context-pin-active #context-pin-btn {
background: #dce9ff;
border-color: #9fc1f7;
color: #0d4faf;
}
.pin-card {
border: 1px solid #86d4a8;
border-radius: 8px;
background: #f0faf4;
padding: 6px 8px;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.pin-card-text {
flex: 1;
font-size: 11px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pin-card-remove {
border: none;
background: none;
color: var(--muted);
cursor: pointer;
font-size: 14px;
padding: 0 2px;
}
.pin-card-remove:hover { color: var(--err); }
.skill-result { padding: 10px; margin-bottom: 6px; }
.skill-result-name { font-size: 12px; font-weight: 700; margin-bottom: 3px; }
.skill-result-desc { font-size: 11px; color: var(--muted); line-height: 1.6; margin-bottom: 6px; }
.skill-install-btn { background: #ebf3ff; color: #0d4faf; border: 1px solid #bcd4f6; padding: 5px 10px; border-radius: 999px; }
.spinner {
display: inline-block;
width: 11px;
height: 11px;
border-radius: 50%;
border: 2px solid #d2dded;
border-top-color: var(--brand);
animation: spin 0.8s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
.pulsing { animation: pulse 1.5s ease-in-out infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.45} }
/* -- Canvas Panel ------------------------------- */
#canvas-panel { box-shadow: -4px 0 24px rgba(16,32,64,0.08); }
#canvas-panel.canvas-drag-over { outline: 2px dashed var(--brand); outline-offset: -4px; }
.canvas-tab {
display: flex; align-items: center; gap: 5px;
padding: 6px 10px 6px 12px;
font-size: 11px; font-weight: 700; color: var(--muted);
border-right: 1px solid var(--line); cursor: pointer;
white-space: nowrap; transition: background 0.12s ease; flex-shrink: 0;
user-select: none;
}
.canvas-tab:hover { background: #f0f4fb; color: var(--text); }
.canvas-tab.active { background: #fff; color: var(--text); border-bottom: 2px solid var(--brand); }
.canvas-tab-close {
border: 0; background: transparent; color: var(--muted); cursor: pointer;
font-size: 14px; line-height: 1; padding: 0 2px; border-radius: 4px;
transition: all 0.12s ease;
}
.canvas-tab-close:hover { background: #fee; color: var(--err); }
.canvas-new-tab {
border: 0; background: transparent; color: var(--muted); cursor: pointer;
font-size: 16px; padding: 4px 12px; flex-shrink: 0;
transition: color 0.12s ease;
}
.canvas-new-tab:hover { color: var(--brand); }
.canvas-mode-btn.active { background: var(--brand) !important; color: #fff !important; }
#canvas-editor-wrap .CodeMirror {
height: 100%; font-family: 'IBM Plex Mono', monospace;
font-size: 13px; line-height: 1.65;
}
#canvas-editor-wrap { height: 100%; }
#canvas-preview-frame { background: #fff; }
/* -- Stop Button ---------------------------------- */
.send-btn { transition: background 0.15s ease !important; display: flex; align-items: center; justify-content: center; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd7e8; border-radius: 999px; }
@media (max-width: 1200px) {
body {
grid-template-columns: 250px 1fr;
grid-template-areas:
"header header"
"main right";
}
aside { display: none; }
}
@media (max-width: 900px) {
body {
padding: 0;
gap: 0;
grid-template-rows: 52px 1fr 56px;
grid-template-columns: 1fr;
grid-template-areas:
"header"
"content"
"mobile-nav";
}
header {
border-radius: 0;
border-left: none;
border-right: none;
border-top: none;
}
aside { display: none; }
.status-bar { display: none; }
/* Both panels share the content cell; JS toggles which is visible */
main { grid-area: content; border-radius: 0; }
.right-panel { grid-area: content; display: none; border-radius: 0; }
body.mob-ctx main { display: none; }
body.mob-ctx .right-panel { display: flex; }
/* Sidebar — full screen overlay when menu tab is active */
body.mob-menu aside {
display: flex;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
width: 100%;
z-index: 600;
overflow-y: auto;
border: none;
border-radius: 0;
animation: mobMenuSlideUp 0.22s ease;
padding-top: 52px;
}
body.mob-menu aside #mob-menu-close {
display: block !important;
position: fixed;
top: 12px;
right: 12px;
}
@keyframes mobMenuSlideUp {
from { transform: translateY(5%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Hide desktop resize handle on mobile */
#right-panel-resize-handle { display: none !important; }
/* Prevent iOS auto-zoom on input focus (requires ≥16px) */
#chat-input, input[type="text"], input[type="password"],
input[type="email"], input[type="number"], textarea {
font-size: 16px !important;
}
#chat-messages, #main-content { padding: 12px; }
.chat-input-area, .mission-bar { padding: 8px 10px; }
/* Show mobile bottom nav */
#mobile-nav { display: flex !important; }
}
/* --- Mobile Bottom Nav ---------------------------- */
#mobile-nav {
display: none;
grid-area: mobile-nav;
align-items: stretch;
background: var(--panel);
border-top: 1px solid var(--line);
}
.mob-tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
border: none;
background: transparent;
color: var(--muted);
font-family: 'Manrope', sans-serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
padding: 6px 4px;
border-top: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.mob-tab.active { color: var(--brand); border-top-color: var(--brand); }
.mob-tab svg { width: 20px; height: 20px; stroke-width: 2; }
/* --- Heartbeat Page ------------------------------- */
#heartbeat-view {
display: none;
flex-direction: column;
grid-column: 1 / -1; /* span all columns when visible */
grid-row: 2;
height: 100%;
overflow: hidden;
background: var(--surface);
border-radius: 14px;
border: 1px solid var(--line);
}
.hb-topbar {
display: flex; align-items: center; gap: 12px; padding: 14px 20px;
border-bottom: 1px solid var(--line); background: #fff;
flex-shrink: 0;
}
.hb-status-dot {
width: 10px; height: 10px; border-radius: 50%;
background: #ccc; flex-shrink: 0;
}
.hb-status-dot.active {
background: var(--ok);
box-shadow: 0 0 0 3px rgba(15,159,110,0.15);
animation: hbPulse 2s ease infinite;
}
@keyframes hbPulse {
0%, 100% { box-shadow: 0 0 0 3px rgba(15,159,110,0.15); }
50% { box-shadow: 0 0 0 6px rgba(15,159,110,0.08); }
}
.hb-status-dot.paused { background: #f0ad4e; }
.hb-topbar-label { font-size: 13px; font-weight: 700; color: var(--text); }
.hb-topbar-meta { font-size: 11px; color: var(--muted); }
.hb-topbar-actions { margin-left: auto; display: flex; gap: 8px; }
.hb-btn {
border: 1px solid var(--line); background: #fff; color: var(--text);
border-radius: 8px; padding: 5px 12px; font-size: 11px; font-weight: 700;
cursor: pointer; font-family: 'Manrope',sans-serif;
display: flex; align-items: center; gap: 5px;
}
.hb-btn:hover { background: #f5f7fa; }
.hb-btn.primary { background: var(--brand); color: #fff; border-color: var(--brand); }
.hb-btn.primary:hover { opacity: 0.9; }
.hb-btn.danger { color: var(--err); border-color: #f5c6cb; }
.hb-content { flex: 1; overflow-y: auto; padding: 20px; }
.hb-section { margin-bottom: 24px; }
.hb-section-title {
font-size: 11px; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.8px; color: var(--muted); margin-bottom: 10px;
display: flex; align-items: center; gap: 8px;
}
.hb-section-dot { width: 8px; height: 8px; border-radius: 50%; }
.hb-section-dot.red { background: #e74c3c; }
.hb-section-dot.yellow { background: #f0ad4e; }
.hb-section-dot.green { background: var(--ok); }
.hb-section-dot.gray { background: #ccc; }
.hb-card {
border: 1px solid var(--line); border-radius: 10px; background: #fff;
padding: 12px 14px; margin-bottom: 8px;
cursor: grab; transition: all 0.15s ease;
display: flex; align-items: flex-start; gap: 10px;
}
.hb-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.hb-card.running {
border-color: var(--brand); background: #f0f4ff;
box-shadow: 0 0 0 2px rgba(55,110,245,0.12);
}
.hb-card.completed { opacity: 0.6; }
.hb-card.paused { opacity: 0.7; border-style: dashed; }
.hb-card-drag {
color: var(--muted); font-size: 14px; cursor: grab; user-select: none;
flex-shrink: 0; padding-top: 2px;
}
.hb-card-body { flex: 1; min-width: 0; }
.hb-card-name { font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: 3px; }
.hb-card-meta { font-size: 11px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 8px; }
.hb-card-actions { display: flex; gap: 4px; flex-shrink: 0; align-items: flex-start; }
.hb-card-btn {
border: none; background: none; color: var(--muted); cursor: pointer;
font-size: 12px; padding: 3px 6px; border-radius: 4px;
}
.hb-card-btn:hover { background: #f0f4fa; color: var(--text); }
.hb-card-btn.danger:hover { color: var(--err); background: #fef0f0; }
.hb-empty {
text-align: center; padding: 40px 20px; color: var(--muted);
font-size: 13px; line-height: 1.8;
}
/* Automated session indicator in sidebar */
.session-auto-badge {
display: inline-flex; align-items: center; gap: 3px;
font-size: 9px; color: #c77d00; background: #fff8e7;
border: 1px solid #f0dfa0; border-radius: 4px;
padding: 1px 5px; font-weight: 700;
}
/* Add task modal */
.hb-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 10000;
display: flex; align-items: center; justify-content: center;
}
.hb-modal {
background: #fff; border-radius: 14px; padding: 24px; width: 420px;
max-height: 80vh; overflow-y: auto;
box-shadow: 0 12px 40px rgba(0,0,0,0.15);
}
.hb-modal h3 { margin: 0 0 16px; font-size: 16px; }
.hb-modal label { display: block; font-size: 11px; font-weight: 700; color: var(--muted); margin: 10px 0 4px; }
.hb-modal input, .hb-modal textarea, .hb-modal select {
width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
font-size: 13px; font-family: 'Manrope',sans-serif; box-sizing: border-box;
}
.hb-modal textarea { min-height: 80px; resize: vertical; }
.hb-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
/* Error Response Panel - Inline Styling */
.error-response-panel {
border: 1px solid var(--line);
border-radius: 10px;
padding: 16px;
background: var(--panel);
margin: 12px 0;
animation: slideDown 0.3s ease;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.error-header {
margin-bottom: 16px;
}
.error-title {
font-size: 16px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.error-description {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.error-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
margin: 16px 0;
}
.error-option-btn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--text);
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
font-size: 13px;
text-align: left;
white-space: normal;
}
.error-option-btn:hover:not(.danger):not(:disabled) {
background: rgba(22, 104, 227, 0.08);
border-color: var(--brand);
transform: translateY(-1px);
}
.error-option-btn:active:not(.danger):not(:disabled) {
transform: translateY(0);
}
.error-option-btn.danger {
border-color: var(--err);
color: var(--err);
}
.error-option-btn.danger:hover {
background: rgba(209, 75, 75, 0.1);
}
.error-option-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-inputs {
display: none;
flex-direction: column;
gap: 12px;
margin-top: 16px;
padding: 16px;
padding-top: 0;
border-top: 1px solid var(--line);
}
.error-inputs.visible {
display: flex;
}
.input-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.input-field label {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.input-field input {
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
background: var(--panel-2);
color: var(--text);
transition: all 0.2s ease;
}
.input-field input:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(22, 104, 227, 0.1);
}
.continue-btn {
padding: 10px 16px;
background: var(--brand);
color: white;
border: none;
border-radius: 6px;
font-weight: 500;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 8px;
}
.continue-btn:hover {
background: #0f4ac9;
transform: translateY(-1px);
}
.continue-btn:active {
transform: translateY(0);
}
.continue-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-context {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.error-context-btn {
background: none;
border: none;
color: var(--brand);
cursor: pointer;
font-size: 12px;
text-decoration: underline;
padding: 0;
}
.error-context-detail {
display: none;
margin-top: 8px;
padding: 8px 12px;
background: rgba(0,0,0,0.02);
border-radius: 6px;
border-left: 3px solid var(--muted);
font-size: 12px;
color: var(--muted);
font-family: 'Monaco', 'Courier New', monospace;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.error-context-detail.visible {
display: block;
}
/* Music widget */
.music-widget {
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
margin: 10px 0;
background: var(--surface);
}
.music-widget-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--hover);
border-bottom: 1px solid var(--line);
}
.music-widget-label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.music-render-btn {
font-size: 12px;
padding: 4px 12px;
border-radius: 8px;
border: 1px solid var(--brand);
background: var(--brand);
color: #fff;
cursor: pointer;
font-family: inherit;
}
.music-render-btn:disabled {
opacity: 0.5;
cursor: default;
}
.music-widget-body {
overflow: hidden;
transition: max-height 0.2s ease;
}
.music-widget-body.collapsed {
max-height: 0 !important;
}
.music-output {
padding: 12px;
}
.music-output img {
width: 100%;
height: auto;
display: block;
}
midi-player {
display: block;
width: 100%;
max-width: 420px;
margin-top: 6px;
--player-row-height: 16px;
--player-font-size: 11px;
}
.music-actions {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
align-items: center;
}
.music-dl-btn {
font-size: 12px;
padding: 4px 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
cursor: pointer;
text-decoration: none;
font-family: inherit;
display: inline-flex;
align-items: center;
gap: 5px;
}
.music-dl-btn:hover { background: var(--hover); }
.music-dl-btn.btn-green { background:#0d2b18; border-color:#2ea043; color:#3fb950; }
.music-dl-btn.btn-green:hover { background:#163d22; }
.music-dl-btn.btn-purple { background:#1e1040; border-color:#7c3aed; color:#a78bfa; }
.music-dl-btn.btn-purple:hover { background:#281552; }
.music-dl-btn.btn-amber { background:#2d1d04; border-color:#d97706; color:#fbbf24; }
.music-dl-btn.btn-amber:hover { background:#3d2806; }
.music-dl-btn.btn-blue { background:#071d3a; border-color:#2563eb; color:#60a5fa; }
.music-dl-btn.btn-blue:hover { background:#0a2848; }
.music-page-nav {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 12px;
color: var(--muted);
}
.music-page-btn {
padding: 2px 10px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
cursor: pointer;
font-size: 13px;
font-family: inherit;
line-height: 1.4;
}
.music-page-btn:disabled { opacity: 0.35; cursor: default; }
.music-page-btn:not(:disabled):hover { background: var(--hover); }
.midi-router-panel {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 4px;
width: 100%;
}
.midi-router-sel {
font-size: 11px;
padding: 2px 6px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface);
color: var(--text);
max-width: 200px;
flex: 1;
}
.midi-router-status { font-size: 11px; color: var(--muted); }
</style>
<script src="https://cdn.jsdelivr.net/npm/@tonejs/midi@2.0.28/build/Midi.js"></script>
<script src="https://cdn.jsdelivr.net/combine/npm/tone@14,npm/@magenta/music@1.23.1/es6/core.js,npm/html-midi-player@1.6.0" defer></script>
</head>
<body>
<!-- HEADER -->
<header>
<div class="logo">
<img src="cherry_logo.png" alt="🍒"> CherryClaw
</div>
<div class="mode-toggle">
<button class="mode-btn active" id="btn-chat" onclick="setMode('chat')">채팅</button>
<button class="mode-btn" id="btn-bgtasks" onclick="setMode('bgtasks')">작업</button>
<button class="mode-btn" id="btn-schedule" onclick="setMode('schedule')">일정</button>
</div>
<div class="status-bar">
<button
id="theme-toggle"
class="icon-btn"
data-theme-state="light"
onclick="toggleTheme()"
title="다크 모드로 전환"
aria-label="다크 모드 전환"
>
<svg class="theme-icon sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"></path>
</svg>
<svg class="theme-icon moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3c-.04.25-.06.5-.06.76a8 8 0 0 0 9.85 7.84z"></path>
</svg>
</button>
<button onclick="openSettings()" title="Settings" style="display:flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-sm);padding:6px 12px;font-size:13px;font-weight:600;color:var(--muted);cursor:pointer;white-space:nowrap" onmouseover="this.style.color='var(--text)';this.style.borderColor='var(--line-strong)'" onmouseout="this.style.color='var(--muted)';this.style.borderColor='var(--line)'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
설정
</button>
<!-- User info + logout (hidden until auth check confirms logged-in user) -->
<div id="user-info-pill" style="display:none;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-sm);padding:4px 10px 4px 8px;font-size:12px;color:var(--muted)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
<span id="user-info-name" style="font-weight:600;color:var(--text);max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
<button onclick="logout()" title="로그아웃" style="display:flex;align-items:center;gap:4px;background:none;border:none;cursor:pointer;padding:2px 4px;border-radius:5px;font-size:11px;color:var(--muted)" onmouseover="this.style.background='var(--line)';this.style.color='var(--text)'" onmouseout="this.style.background='none';this.style.color='var(--muted)'">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
로그아웃
</button>
</div>
<div class="status-pill">
<div class="dot" id="ollama-dot"></div>
<span id="ollama-status">확인 중...</span>
</div>
<div class="status-pill" id="model-pill" onclick="openSettings('models')" title="모델 변경하려면 클릭" style="cursor:pointer">
<span id="current-provider-badge" style="font-size:10px;font-weight:700;color:var(--muted);margin-right:4px"></span><span id="current-model">-</span>
</div>
<div class="status-pill stat-compact" id="system-pill">
<span id="quick-system-stats">CPU --% • RAM --% • GPU --%</span>
</div>
</div>
</header>
<!-- SIDEBAR -->
<aside>
<button id="mob-menu-close" onclick="mobTab('chat')" style="display:none;position:absolute;top:12px;right:12px;z-index:10;border:1px solid var(--line);background:var(--panel-2);border-radius:999px;padding:6px 14px;font-size:13px;font-weight:700;color:var(--muted);cursor:pointer;font-family:'Manrope',sans-serif">✕ 닫기</button>
<div id="sidebar-tabs">
<button class="sidebar-tab active" id="tab-jobs" onclick="setSidebarTab('jobs')">세션</button>
<button class="sidebar-tab" id="tab-skills" onclick="setSidebarTab('skills')">🧩 스킬</button>
</div>
<!-- SESSIONS TAB -->
<div id="sidebar-jobs">
<div class="sidebar-section" style="border-top:none">
<span></span>
<div style="display:flex;align-items:center;gap:8px">
<button class="sidebar-action" onclick="newChatSession()">+ 새 채팅</button>
<button class="sidebar-action" id="sessions-edit-btn" onclick="toggleSessionsEditMode()">편집</button>
</div>
</div>
<div id="jobs-list">
<div class="empty-state">아직 대화가 없습니다.<br>새 대화를 시작해 보세요.</div>
</div>
</div>
<!-- SKILLS TAB -->
<div id="sidebar-skills">
<div class="sidebar-section" style="border-top:none">
<span>Skills</span>
<button class="sidebar-action" onclick="openCreateSkillModal()">+ Create</button>
</div>
<div id="skills-user-selector" style="display:none;padding:5px 10px 6px;border-bottom:1px solid var(--line);background:var(--panel-2)">
<label style="font-size:10px;color:var(--muted);display:block;margin-bottom:3px">대상 유저</label>
<select id="skills-target-user" onchange="onSkillsUserChange()" style="width:100%;font-size:12px;padding:3px 6px;background:var(--bg2);color:var(--text);border:1px solid var(--line);border-radius:6px">
<option value="">글로벌 기본값</option>
</select>
</div>
<div id="skills-list" style="flex:1;min-height:0;overflow-y:auto;padding:10px">
<div class="empty-state" style="padding:16px">스킬 로딩 중...</div>
</div>
</div>
<!-- SERVER LOG PANEL -->
<div id="sidebar-log-panel">
<div id="sidebar-log-header">
<span>서버 로그</span>
<div style="display:flex;gap:4px;align-items:center">
<button id="log-scroll-lock-btn" onclick="toggleLogScrollLock()" title="자동 스크롤 고정">↓</button>
<button onclick="clearLogPanel()" title="지우기">✕</button>
</div>
</div>
<div id="sidebar-log-body"></div>
</div>
</aside>
<!-- MAIN -->
<main>
<!-- CHAT VIEW (default) -->
<div id="chat-view">
<div id="chat-messages">
<div class="chat-welcome" id="chat-welcome">
<div class="chat-welcome-icon"><img src="cherry_logo.png" alt="🍒"></div>
<h2>CherryClaw</h2>
<p>로컬 모델과 직접 대화하세요. API 키도, 클라우드도 없이 — 오직 나와 모델만.</p>
<div class="hint">멀티스텝 에이전트 작업은 에이전트 모드로 전환하세요.</div>
</div>
</div>
<div class="chat-input-area">
<div class="agent-toggle">
<span class="agent-toggle-label">모드:</span>
<label class="toggle-switch" title="에이전트 모드 전환 (AI에 도구 접근 허용)">
<input type="checkbox" id="agent-mode-toggle" onchange="updateAgentMode()">
<span class="toggle-track"></span>
</label>
<span class="agent-toggle-label" id="agent-mode-label">채팅</span>
<span class="agent-mode-badge" id="agent-mode-badge">AGENT</span>
<span style="color:var(--line);margin:0 6px;user-select:none">|</span>
<span class="agent-toggle-label">TTS</span>
<label class="toggle-switch" title="AI 응답 음성 읽기 켜기/끄기">
<input type="checkbox" id="tts-mode-toggle" onchange="updateTTSMode()">
<span class="toggle-track"></span>
</label>
<span style="color:var(--line);margin:0 6px;user-select:none">|</span>
<span class="agent-toggle-label">바로보내기</span>
<label class="toggle-switch" title="음성 인식 후 자동 전송">
<input type="checkbox" id="mic-autosend-toggle" onchange="updateMicAutosend()">
<span class="toggle-track"></span>
</label>
<div class="agent-toggle-right" style="display:flex;align-items:center;gap:6px">
<button class="quick-mode-btn" id="context-pin-btn" onclick="toggleContextPinMode()" title="메시지를 컨텍스트에 고정">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>
<span id="context-pin-label">컨텍스트</span>
</button>
<div class="quick-mode-popover" id="context-pin-popover" style="width:280px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div class="quick-mode-title" style="margin:0">컨텍스트에 메시지 고정</div>
<span id="context-pin-count" style="font-size:11px;color:var(--muted);font-weight:700">0/3 selected</span>
</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px;line-height:1.5">채팅에서 메시지를 클릭하여 고정하세요. 고정된 메시지는 매 요청마다 컨텍스트에 재주입됩니다.</div>
<div id="context-pin-list" style="max-height:200px;overflow-y:auto;margin-bottom:8px"></div>
<div style="display:flex;justify-content:flex-end;gap:6px">
<button class="skill-mini-btn" onclick="cancelContextPin()" style="display:flex;align-items:center;gap:4px">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
취소
</button>
<button class="skill-mini-btn enabled" onclick="confirmContextPin()" style="display:flex;align-items:center;gap:4px">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>
Done
</button>
</div>
</div>
<button class="quick-mode-btn" id="quick-mode-btn" onclick="toggleQuickModePopover()" title="웹 검색 / 사고 설정">
<span id="quick-mode-label">Verified</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="quick-mode-popover" id="quick-mode-popover">
<div class="quick-mode-title">웹 검색 엄격도</div>
<button class="quick-mode-opt" id="rigor-fast" onclick="setQuickSearchRigor('fast')">빠름</button>
<button class="quick-mode-opt" id="rigor-verified" onclick="setQuickSearchRigor('verified')">검증됨</button>
<button class="quick-mode-opt" id="rigor-strict" onclick="setQuickSearchRigor('strict')">엄격</button>
<div class="quick-mode-title" style="margin-top:8px">사고 노력</div>
<button class="quick-mode-opt" id="think-standard" onclick="setQuickThinkingEffort('standard')">기본</button>
<button class="quick-mode-opt" id="think-extended" onclick="setQuickThinkingEffort('extended')">확장</button>
</div>
</div>
</div>
<div class="queued-prompts-panel" id="queued-prompts-panel">
<div class="queued-prompts-head">
<span id="queued-prompts-title">대기 중인 프롬프트</span>
<button class="queued-clear-btn" onclick="clearQueuedPrompts()">지우기</button>
</div>
<div class="queued-prompts-list" id="queued-prompts-list"></div>
</div>
<!-- 첨부 파일 카드 영역 (전송 전 표시) -->
<div id="staged-attachments" style="display:none;flex-wrap:wrap;gap:8px;padding:8px 12px 0;border-top:1px solid var(--line)"></div>
<div class="chat-input-row">
<input type="file" id="file-upload" accept="image/*,.pdf,.txt,.md,.csv,.xlsx,.xls,.docx,.pptx,.mid,.midi" style="display:none" onchange="handleFileUpload(this)" />
<button class="send-btn" onclick="document.getElementById('file-upload').click()" title="파일 첨부 (이미지·PDF·Excel·txt 등)" style="font-size:18px;width:44px;height:44px" aria-label="파일 첨부">📎</button>
<button class="send-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력 (클릭: 시작/중지)" style="font-size:18px;width:44px;height:44px" aria-label="음성 입력">🎤</button>
<textarea
id="chat-input"
placeholder="메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)"
rows="1"
autocomplete="off"
></textarea>
<button class="send-btn" id="send-btn" onclick="handleSendStop()" title="Send">
<svg id="send-icon" width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="22 2 15 22 11 13 2 9"/></svg>
<svg id="stop-icon" width="13" height="13" viewBox="0 0 14 14" fill="currentColor" style="display:none"><rect x="2" y="2" width="10" height="10" rx="1.5"/></svg>
</button>
</div>
<div class="chat-hint">
<span id="chat-model-name">모델</span>과 대화 중 — <span id="chat-provider-name">CherryClaw</span> 경유.
<span id="queued-prompts-indicator" style="display:none;margin-left:8px;color:var(--brand);font-weight:700"></span>
</div>
</div>
</div>
</main>
<!-- Task Modal -->
<div id="task-modal" style="display:none"></div>
<!-- BACKGROUND TASKS KANBAN VIEW -->
<div id="bgtasks-view" style="display:none;flex-direction:column;gap:0;position:absolute;top:90px;bottom:12px;left:12px;right:12px;background:var(--bg);padding:16px;overflow:hidden;border:1px solid var(--line);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);z-index:5;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-shrink:0">
<div style="display:flex;align-items:center;gap:12px">
<span style="font-size:16px;font-weight:800;letter-spacing:-0.01em">백그라운드 작업</span>
<span id="bgt-count" style="font-size:11px;background:#eaf2ff;color:#0d4faf;border-radius:999px;padding:2px 10px;font-weight:700">0 tasks</span>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button
id="bgt-heartbeat-label"
onclick="openBgtHeartbeatModal()"
style="border:1px solid var(--line);background:var(--panel);color:var(--muted);border-radius:8px;padding:5px 10px;font-size:11px;font-weight:600;cursor:pointer"
title="작업 하트비트 설정"
>? heartbeat: 10min</button>
<button onclick="refreshBgTasks()" style="border:1px solid var(--line);background:var(--panel);color:var(--muted);border-radius:8px;padding:5px 12px;font-size:12px;font-weight:600;cursor:pointer">새로고침</button>
</div>
</div>
<!-- Kanban columns -->
<div id="bgt-board" style="display:flex;gap:12px;flex:1;min-height:0;overflow-x:auto;padding-bottom:8px">
<!-- Columns injected by renderBgTasks() -->
</div>
<!-- Task detail side panel -->
<div id="bgt-panel" style="display:none;position:absolute;top:0;right:0;width:480px;bottom:0;background:var(--panel);border-left:1px solid var(--line);flex-direction:column;overflow:hidden;z-index:10">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line);flex-shrink:0">
<span id="bgt-panel-title" style="font-size:14px;font-weight:800">작업 상세</span>
<div style="display:flex;gap:8px">
<button id="bgt-panel-pause" onclick="bgtPauseResume()" style="border:1px solid var(--line);background:var(--panel-2);border-radius:8px;padding:4px 12px;font-size:12px;font-weight:600;cursor:pointer;color:var(--muted)">일시정지</button>
<button onclick="closeBgtPanel()" style="border:0;background:none;font-size:18px;cursor:pointer;color:var(--muted);line-height:1">&#x2715;</button>
</div>
</div>
<div id="bgt-panel-body" style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:14px">
<!-- Filled by openBgtPanel() -->
</div>
<!-- Persistent task chat input — always visible regardless of task status -->
<div id="bgt-chat-bar" style="flex-shrink:0;border-top:1px solid var(--line);padding:10px 12px;background:var(--panel);display:flex;flex-direction:column;gap:6px">
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted)">에이전트와 채팅</div>
<div style="display:flex;gap:8px;align-items:flex-end">
<textarea id="bgt-chat-input" rows="2" placeholder="에이전트에게 메시지 전송... (Shift+Enter로 줄바꿈)" style="flex:1;resize:none;border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:12px;font-family:inherit;background:var(--panel-2);color:var(--text);outline:none" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();bgtChatSend();}"></textarea>
<button onclick="bgtChatSend()" style="background:var(--brand);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;flex-shrink:0;height:36px">전송</button>
</div>
</div>
</div>
</div>
<!-- SCHEDULE VIEW -->
<div id="schedule-view" style="display:none;flex-direction:column;gap:0;position:absolute;top:90px;bottom:12px;left:12px;right:12px;background:var(--bg);padding:16px;overflow:hidden;border:1px solid var(--line);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);z-index:5;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-shrink:0">
<div style="display:flex;align-items:center;gap:12px">
<span style="font-size:16px;font-weight:800;letter-spacing:-0.01em">일정</span>
<span id="schedule-count" style="font-size:11px;background:#eaf2ff;color:#0d4faf;border-radius:999px;padding:2px 10px;font-weight:700">0 schedules</span>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button onclick="openScheduleCreateModal()" style="border:1px solid var(--brand);background:var(--brand);color:white;border-radius:8px;padding:6px 14px;font-size:12px;font-weight:600;cursor:pointer">+ 새 일정</button>
<button onclick="refreshSchedules()" style="border:1px solid var(--line);background:var(--panel);color:var(--muted);border-radius:8px;padding:6px 12px;font-size:12px;font-weight:600;cursor:pointer">새로고침</button>
</div>
</div>
<!-- Schedule list -->
<div id="schedule-list" style="flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:10px;padding-right:8px">
<div class="empty-state" style="text-align:center;color:var(--muted);padding:40px 20px">No schedules yet. <strong>+ 새 일정</strong> to get started.</div>
</div>
</div>
<!-- Schedule Create/Edit Modal -->
<div id="schedule-modal" style="display:none;position:fixed;inset:0;background:rgba(10,20,40,0.35);z-index:9999;align-items:center;justify-content:center;padding:18px">
<div style="width:min(480px,96vw);max-height:90vh;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-md)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line);flex-shrink:0">
<div id="schedule-modal-title" style="font-weight:800">일정 생성</div>
<button onclick="closeScheduleModal()" style="border:0;background:none;font-size:18px;cursor:pointer;color:var(--muted);line-height:1">✕</button>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:14px">
<div>
<label style="display:block;font-size:12px;font-weight:700;color:var(--text);margin-bottom:6px">Name</label>
<input id="schedule-name" type="text" placeholder="예: 매일 스탠드업" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;background:var(--panel-2)" />
</div>
<div>
<label style="display:block;font-size:12px;font-weight:700;color:var(--text);margin-bottom:6px">일정 패턴</label>
<div style="display:flex;gap:8px;margin-bottom:8px">
<input id="schedule-pattern" type="text" placeholder="예: daily at 09:00 또는 '0 9 * * *' cron" style="flex:1;border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;background:var(--panel-2)" />
<button onclick="parseSchedulePattern()" style="border:1px solid var(--brand);background:var(--brand);color:white;border-radius:8px;padding:6px 12px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap">파싱</button>
</div>
<div id="schedule-pattern-preview" style="font-size:11px;color:var(--muted);padding:6px 8px;background:var(--panel-2);border-radius:6px;display:none"></div>
</div>
<div>
<label style="display:block;font-size:12px;font-weight:700;color:var(--text);margin-bottom:6px">시간대</label>
<select id="schedule-timezone" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;background:var(--panel-2)">
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Chicago">America/Chicago</option>
<option value="America/Denver">America/Denver</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="Europe/London">Europe/London</option>
<option value="Europe/Paris">Europe/Paris</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
</select>
</div>
<div>
<label style="display:block;font-size:12px;font-weight:700;color:var(--text);margin-bottom:6px">프롬프트 / 작업</label>
<textarea id="schedule-prompt" placeholder="이 일정이 실행될 때 수행할 작업은 무엇인가요?" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;background:var(--panel-2);resize:vertical;min-height:80px;font-family:'IBM Plex Mono',monospace"></textarea>
</div>
<div>
<label style="display:block;font-size:12px;font-weight:700;color:var(--text);margin-bottom:6px">전송 채널</label>
<select id="schedule-channel" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;background:var(--panel-2)">
<option value="web">웹 (알림)</option>
<option value="telegram">텔레그램 (준비 중)</option>
<option value="discord">디스코드 (준비 중)</option>
</select>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--line);flex-shrink:0">
<button class="btn btn-sm" style="background:var(--panel-2);color:var(--muted);border:1px solid var(--line)" onclick="closeScheduleModal()">취소</button>
<button class="btn btn-sm" id="schedule-save-btn" onclick="saveSchedule()">일정 생성</button>
</div>
</div>
</div>
<!-- Background Task Heartbeat Modal -->
<div id="bgt-heartbeat-modal" style="display:none;position:fixed;inset:0;background:rgba(10,20,40,0.35);z-index:9999;align-items:center;justify-content:center;padding:18px">
<div style="width:min(420px,96vw);max-height:90vh;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-md)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)">
<div style="font-weight:800">작업 하트비트</div>
<button class="btn btn-sm" style="background:var(--panel-2);color:var(--muted);border:1px solid var(--line)" onclick="closeBgtHeartbeatModal()">닫기</button>
</div>
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:12px">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="checkbox" id="bgt-hb-enabled" />
Enable task heartbeat
</label>
<div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">Interval (minutes)</label>
<input id="bgt-hb-interval" type="number" min="1" max="1440" step="1" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:13px;font-family:'IBM Plex Mono',monospace" />
<div style="font-size:11px;color:var(--muted);margin-top:6px">Range: 1 to 1440 minutes</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--line)">
<button class="btn btn-sm" style="background:var(--panel-2);color:var(--muted);border:1px solid var(--line)" onclick="closeBgtHeartbeatModal()">취소</button>
<button class="btn btn-sm" onclick="saveBgtHeartbeatConfig()">저장</button>
</div>
</div>
</div>
<!-- RIGHT PANEL -->
<div class="right-panel" id="right-panel" style="position:relative">
<!-- Resize handle on left edge -->
<div id="right-panel-resize-handle" style="position:absolute;left:0;top:0;bottom:0;width:5px;cursor:col-resize;z-index:200;background:transparent;transition:background 0.15s ease" title="드래그하여 크기 조절"></div>
<!-- Collapsed rail (visible when right panel is minimized) -->
<div id="right-panel-rail" onclick="toggleRightPanel()" title="컨텍스트 패널 펼치기" style="flex-direction:column;align-items:center;justify-content:flex-start;gap:14px;padding:10px 0;cursor:pointer;background:var(--panel);height:100%">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="color:var(--muted)"><polyline points="15 18 9 12 15 6"/></svg>
<div style="writing-mode:vertical-rl;font-size:10px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:0.12em">컨텍스트</div>
</div>
<!-- Right panel topbar with canvas toggle -->
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 12px;border-bottom:1px solid var(--line);flex-shrink:0;background:var(--panel);min-height:38px">
<span style="font-size:10px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em">컨텍스트</span>
<div style="display:flex;align-items:center;gap:6px">
<button id="canvas-toggle-btn" onclick="toggleCanvas()" title="캔버스 전환" style="display:flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;padding:5px 10px;font-size:11px;font-weight:700;cursor:pointer;font-family:'Manrope',sans-serif;transition:all 0.15s ease">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="18" rx="2"/><path d="M8 9l-4 3 4 3"/><path d="M16 9l4 3-4 3"/><path d="M12 6v12"/></svg>
Canvas
</button>
<button onclick="toggleRightPanel()" title="컨텍스트 패널 최소화" style="display:flex;align-items:center;justify-content:center;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;width:28px;height:28px;cursor:pointer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
<!-- Canvas Overlay -->
<div id="canvas-panel" style="display:none;position:absolute;top:38px;bottom:0;left:0;right:0;z-index:20;flex-direction:column;background:var(--panel)">
<!-- Canvas header row: tabs left, actions right -->
<div style="display:flex;align-items:stretch;border-bottom:1px solid var(--line);background:var(--panel-2);flex-shrink:0;min-height:38px">
<div id="canvas-tabs" style="display:flex;align-items:stretch;flex:1;min-width:0;overflow-x:auto"></div>
<div style="display:flex;align-items:center;gap:4px;padding:4px 8px;flex-shrink:0;border-left:1px solid var(--line)">
<div style="display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#fff">
<button id="canvas-code-btn" onclick="setCanvasMode('code')" class="canvas-mode-btn active" style="border:0;background:transparent;padding:4px 10px;font-size:11px;font-weight:700;cursor:pointer;font-family:'Manrope',sans-serif;color:var(--muted)">코드</button>
<button id="canvas-preview-btn" onclick="setCanvasMode('preview')" class="canvas-mode-btn" style="border:0;background:transparent;padding:4px 10px;font-size:11px;font-weight:700;cursor:pointer;font-family:'Manrope',sans-serif;color:var(--muted)">미리보기</button>
</div>
<button onclick="canvasSave()" title="저장 (Ctrl+S)" style="display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;color:var(--text);border-radius:8px;padding:4px 10px;font-size:11px;font-weight:700;cursor:pointer;font-family:'Manrope',sans-serif">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
저장
</button>
<button id="canvas-run-btn" onclick="canvasRun()" title="코드 실행 (터미널)" style="display:flex;align-items:center;gap:5px;border:1px solid #333;background:#1a1a1a;color:#28c840;border-radius:8px;padding:4px 10px;font-size:11px;font-weight:700;cursor:pointer;font-family:'IBM Plex Mono',monospace">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg>
▶ 실행
</button>
<button onclick="canvasAddToContext()" title="채팅 입력에 추가" style="display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;color:var(--text);border-radius:8px;padding:4px 10px;font-size:11px;font-weight:700;cursor:pointer;font-family:'Manrope',sans-serif">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Add to Chat
</button>
<button onclick="toggleCanvas()" title="캔버스 닫기" style="display:flex;align-items:center;justify-content:center;border:1px solid var(--line);background:#fff;color:var(--muted);border-radius:8px;width:28px;height:28px;cursor:pointer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
<!-- Canvas body -->
<div style="flex:1;min-height:0;display:flex;flex-direction:column;position:relative">
<div id="canvas-empty-state" style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:var(--muted);text-align:center;padding:24px;pointer-events:none;z-index:1">
<svg width="42" height="42" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.25"><rect x="2" y="3" width="20" height="18" rx="2"/><path d="M8 9l-4 3 4 3"/><path d="M16 9l4 3-4 3"/><path d="M12 6v12"/></svg>
<div style="font-size:13px;font-weight:700">열린 파일 없음</div>
<div style="font-size:11px;line-height:1.7">Drop a file here or click <strong>+</strong> to create one</div>
</div>
<div id="canvas-editor-wrap" style="display:none;flex:1;min-height:0;overflow:hidden"></div>
<iframe id="canvas-preview-frame" style="display:none;flex:1;border:none;width:100%;min-height:0"></iframe>
<!-- Terminal panel (VSCode-style, bottom) -->
<div id="canvas-terminal" style="display:flex;flex-direction:column;border-top:1px solid #333;background:rgba(12,12,12,0.9);height:50%;min-height:32px;flex-shrink:0;margin-top:auto">
<!-- Drag handle -->
<div id="canvas-term-drag" style="height:3px;cursor:ns-resize;background:#333;flex-shrink:0;transition:background .15s"></div>
<!-- Shell tab bar -->
<div style="display:flex;align-items:center;height:30px;background:#0C0C0C;font-size:13px;flex-shrink:0;overflow-x:auto">
<div id="canvas-shell-tabs" style="display:flex;align-items:stretch;height:100%"></div>
<button onclick="addTermShell()" title="새 쉘" style="background:0;border:0;color:#767676;cursor:pointer;font-size:16px;line-height:1;padding:0 8px;flex-shrink:0">+</button>
<div style="flex:1"></div>
<span id="canvas-run-status" style="font-size:10px;color:#767676;margin-right:4px"></span>
<button onclick="document.getElementById('canvas-terminal').style.display='none'" style="background:0;border:0;color:#767676;cursor:pointer;font-size:14px;line-height:1;padding:2px 4px;flex-shrink:0" title="패널 닫기">&times;</button>
</div>
<!-- xterm.js terminal container -->
<div id="canvas-term-container" style="flex:1;min-height:0;padding:0 4px;background:#0C0C0C;position:relative"></div>
</div>
</div>
</div>
<!-- End Canvas Overlay -->
<div class="right-section" id="system-stats-section">
<div class="right-section-title">시스템 통계</div>
<div class="sys-grid">
<div class="sys-card">
<div class="sys-k">CPU</div>
<div class="sys-v" id="sys-cpu">--%</div>
<div class="sys-meter"><i id="sys-cpu-meter"></i></div>
</div>
<div class="sys-card">
<div class="sys-k">RAM</div>
<div class="sys-v" id="sys-ram">--%</div>
<div class="sys-meter"><i id="sys-ram-meter"></i></div>
</div>
<div class="sys-card">
<div class="sys-k">GPU</div>
<div class="sys-v" id="sys-gpu">--%</div>
<div class="sys-meter"><i id="sys-gpu-meter"></i></div>
</div>
<div class="sys-card">
<div class="sys-k">VRAM</div>
<div class="sys-v" id="sys-vram">--%</div>
<div class="sys-meter"><i id="sys-vram-meter"></i></div>
</div>
<div class="sys-card full" onclick="openSettings('models')" title="제공자/모델 변경하려면 클릭" style="cursor:pointer">
<div class="sys-k">활성 LLM</div>
<div class="sys-v" id="sys-ollama">확인 중...</div>
<div class="sys-sub" id="sys-runtime-sub">Gateway RSS -- MB • Updated --</div>
</div>
<div class="sys-card full" onclick="openSettings('context-viewer')" title="컨텍스트 뷰어 열기" style="cursor:pointer">
<div class="sys-k">접속자</div>
<div class="sys-v" id="sys-online-users" style="font-size:12px;font-weight:600;letter-spacing:0">-</div>
</div>
</div>
</div>
<div class="right-section" id="agent-context-section">
<div class="right-section-title">에이전트 컨텍스트</div>
<div class="agent-card">
<div class="agent-metric-grid">
<div class="agent-metric">
<div class="k">세션 모드</div>
<div class="v" id="r-session-mode">-</div>
</div>
<div class="agent-metric">
<div class="k">에이전트 상태</div>
<div class="v" id="r-agent-state">-</div>
</div>
</div>
<div class="agent-metric-grid">
<div class="agent-metric">
<div class="k">개요</div>
<div class="v" id="r-overview-objective">-</div>
</div>
<div class="agent-metric">
<div class="k">Active</div>
<div class="v" id="r-active-objective">-</div>
</div>
</div>
<div class="agent-metric-grid">
<div class="agent-metric">
<div class="k">작업</div>
<div class="v" id="r-task-counts">0</div>
</div>
<div class="agent-metric">
<div class="k">Turns</div>
<div class="v" id="r-turn-counts">0</div>
</div>
</div>
<div class="agent-metric-grid">
<div class="agent-metric">
<div class="k">하트비트</div>
<div class="v" id="r-heartbeat-state">idle</div>
</div>
<div class="agent-metric">
<div class="k">감시</div>
<div class="v" id="r-heartbeat-meta">step:-</div>
</div>
</div>
<div class="task-progress-wrap"><div class="task-progress-fill" id="r-task-progress-bar"></div></div>
<div class="agent-context-scroll" id="r-agent-context-scroll">
<div class="task-list" id="r-turn-execution">
<div style="font-size:11px;color:var(--muted)">활성 턴 실행 없음.</div>
</div>
<div class="task-list" id="r-task-list">
<div style="font-size:11px;color:var(--muted)">작업 없음.</div>
</div>
<div class="task-list" id="r-live-steps">
<div style="font-size:11px;color:var(--muted)">활성 도구 단계 없음.</div>
</div>
<div id="r-recent-turns" style="padding:8px;border:1px solid var(--line);border-radius:10px;background:var(--panel-2);font-size:11px;line-height:1.6;color:var(--muted)">
Waiting for turn history...
</div>
</div>
</div>
</div>
<div class="right-section">
<div class="right-section-title">⚠️ Pending Approvals</div>
<div id="approvals-list">
<div style="color:var(--muted);font-size:11px">대기 중인 승인 없음</div>
</div>
</div>
<div style="padding:10px 16px 8px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between">
<div class="right-section-title" style="margin:0">빠른 작업</div>
<div style="display:flex;gap:6px">
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="clearChat()">✕ Chat</button>
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="newChatSession()">+</button>
<div class="export-dropdown">
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="toggleExportMenu(event)" title="채팅 내보내기">↓ 내보내기</button>
<div class="export-menu" id="export-menu">
<button onclick="exportChat('md')">📄 Markdown (.md)</button>
<button onclick="exportChat('txt')">📝 텍스트 (.txt)</button>
<button onclick="exportChat('json')">{ } JSON (.json)</button>
</div>
</div>
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="openSettings()">설정</button>
<button class="btn btn-sm" style="background:var(--bg3);color:var(--muted);border:1px solid var(--border);padding:3px 8px" onclick="setSidebarTab('skills')">🧩</button>
</div>
</div>
<!-- AGENT EXECUTION PANEL -->
<div id="agent-panel" style="display:flex;flex-direction:column;max-height:200px;overflow:hidden;border:1px solid var(--border);border-radius:10px;background:var(--panel-2);margin-bottom:10px">
<div style="padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
<div style="font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);margin-bottom:8px">활성 에이전트</div>
</div>
<div id="agent-execution-panel" style="flex:1;overflow-y:auto;padding:0">
<div style="padding:8px 12px;font-size:11px;color:var(--muted)">작업 실행 대기 중...</div>
</div>
</div>
<!-- PROCESS LOG -->
<div id="process-section" style="display:flex;flex-direction:column;flex:1 1 320px;overflow:hidden;min-height:280px">
<div style="padding:8px 16px 6px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);flex-shrink:0">
<a href="javascript:void(0)" onclick="openProcessLogFile()" style="font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted);text-decoration:none;cursor:pointer;hover:color:var(--brand)">프로세스 로그</a>
<button onclick="clearProcessLog()" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:10px;font-family:inherit">✕ clear</button>
</div>
<div id="process-log" style="flex:1;overflow-y:auto;padding:8px 12px;font-size:11px;line-height:1.7;font-family:'JetBrains Mono',monospace">
<div style="color:var(--muted);text-align:center;padding:20px 0;opacity:0.5">활동 대기 중...</div>
</div>
</div>
</div>
<!-- MOBILE BOTTOM NAV -->
<nav id="mobile-nav">
<button id="mob-tab-chat" class="mob-tab active" onclick="mobTab('chat')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Chat
</button>
<button id="mob-tab-ctx" class="mob-tab" onclick="mobTab('ctx')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
Context
</button>
<button id="mob-tab-menu" class="mob-tab" onclick="mobTab('menu')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
Menu
</button>
<button id="mob-tab-logout" class="mob-tab" onclick="logout()" style="display:none;color:var(--muted)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
로그아웃
</button>
</nav>
<!-- Mobile backdrop (closes sidebar drawer) -->
<div id="mob-backdrop" onclick="mobCloseMenu()" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.38);z-index:499;-webkit-tap-highlight-color:transparent"></div>
<!-- ERROR RESPONSE MODAL -->
<div id="error-response-panel">
<div class="error-response-header">
<div class="error-response-title" id="error-response-title">⚠️ Authentication Required</div>
<div class="error-response-desc" id="error-response-desc">작업을 계속하려면 도움이 필요합니다. 필요한 정보를 입력해 주세요.</div>
</div>
<div class="error-response-content">
<div class="error-response-options" id="error-response-options">
<!-- Options will be populated by JavaScript -->
</div>
<div class="error-response-inputs" id="error-response-inputs">
<!-- Input fields will be populated by JavaScript -->
</div>
</div>
<div class="error-response-footer">
<button class="btn-error-cancel" onclick="closeErrorResponse()">작업 취소</button>
<button class="btn-error-submit" id="error-response-submit-btn" onclick="submitErrorResponse()">계속</button>
</div>
</div>
<!-- CREATE SKILL MODAL -->
<div id="skill-modal" style="display:none;position:fixed;inset:0;background:rgba(10,20,40,0.35);z-index:9999;align-items:center;justify-content:center;padding:18px">
<div style="width:min(520px,96vw);max-height:90vh;overflow:auto;background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-md)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)">
<div style="font-weight:800" id="skill-modal-title">스킬 생성</div>
<button class="btn btn-sm" style="background:var(--panel-2);color:var(--muted);border:1px solid var(--line)" onclick="closeSkillModal()">닫기</button>
</div>
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:12px">
<div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px;font-weight:700">Name</label>
<input id="skill-modal-name" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px 10px;font-size:13px;font-family:'Manrope',sans-serif" placeholder="예: 모던 스타일링" />
</div>
<div style="display:flex;gap:10px">
<div style="flex:1">
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px;font-weight:700">Emoji</label>
<input id="skill-modal-emoji" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px 10px;font-size:13px;font-family:'Manrope',sans-serif" placeholder="??" maxlength="4" />
</div>
<div style="flex:3">
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px;font-weight:700">짧은 설명</label>
<input id="skill-modal-desc" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px 10px;font-size:13px;font-family:'Manrope',sans-serif" placeholder="이 스킬은 무엇을 하나요?" />
</div>
</div>
<div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px;font-weight:700">Instructions <span style="font-weight:400">(what the model should do when this skill is active)</span></label>
<textarea id="skill-modal-instructions" style="width:100%;min-height:180px;border:1px solid var(--line);border-radius:10px;padding:10px;font-size:12px;font-family:'IBM Plex Mono',monospace;line-height:1.6;resize:vertical" placeholder="HTML 파일 생성 시, 항상 모던 CSS 패턴을 사용하세요..."></textarea>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--line)">
<button class="btn btn-sm" style="background:var(--panel-2);color:var(--muted);border:1px solid var(--line)" onclick="closeSkillModal()">취소</button>
<button class="btn btn-sm" id="skill-modal-save-btn" onclick="saveSkillFromModal()">스킬 생성</button>
</div>
</div>
</div>
<!-- SETTINGS MODAL -->
<div id="settings-modal" style="display:none;position:fixed;inset:0;background:rgba(10,20,40,0.35);z-index:9999;align-items:center;justify-content:center;padding:18px">
<div style="width:min(980px,96vw);max-height:90vh;overflow:auto;background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-md)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)">
<div style="font-weight:800">설정</div>
<button class="btn btn-sm" style="background:var(--panel-2);color:var(--muted);border:1px solid var(--line)" onclick="closeSettings()">닫기</button>
</div>
<div style="padding:10px 16px;border-bottom:1px solid var(--line);display:flex;gap:8px">
<button id="settings-tab-system" class="sidebar-action" onclick="setSettingsTab('system')">시스템</button>
<button id="settings-tab-search" class="sidebar-action" onclick="setSettingsTab('search')">검색</button>
<button id="settings-tab-credentials" class="sidebar-action" onclick="setSettingsTab('credentials')">자격증명</button>
<button id="settings-tab-policy" class="sidebar-action" onclick="setSettingsTab('policy')">에이전트 정책</button>
<button id="settings-tab-security" class="sidebar-action" onclick="setSettingsTab('security')">보안</button>
<button id="settings-tab-users" class="sidebar-action" onclick="setSettingsTab('users')">사용자</button>
<button id="settings-tab-models" class="sidebar-action" onclick="setSettingsTab('models')">모델</button>
<button id="settings-tab-agents" class="sidebar-action" onclick="setSettingsTab('agents')">에이전트</button>
<button id="settings-tab-channels" class="sidebar-action" onclick="setSettingsTab('channels')">채널</button>
<button id="settings-tab-ppt" class="sidebar-action" onclick="setSettingsTab('ppt')">프레젠테이션</button>
<button id="settings-tab-integrations" class="sidebar-action" onclick="setSettingsTab('integrations')">연동</button>
<button id="settings-tab-context-viewer" class="sidebar-action" onclick="setSettingsTab('context-viewer')">컨텍스트 뷰어</button>
</div>
<div id="settings-panel-system" style="padding:14px 16px;display:grid;grid-template-columns:1fr 1fr;gap:14px">
<div style="border:1px solid var(--line);border-radius:12px;padding:12px;background:#fff">
<div class="right-section-title" style="margin-bottom:8px">런타임</div>
<div class="info-row"><span class="info-key">모델</span><span class="info-val" id="settings-runtime-model">-</span></div>
<div class="info-row"><span class="info-key">게이트웨이</span><span class="info-val" id="settings-runtime-gateway">-</span></div>
<div class="info-row"><span class="info-key">Ollama</span><span class="info-val" id="settings-runtime-ollama">-</span></div>
</div>
<div style="border:1px solid var(--line);border-radius:12px;padding:12px;background:#fff">
<div class="right-section-title" style="margin-bottom:8px">참고</div>
<div style="font-size:12px;color:var(--muted);line-height:1.7">
설정은 이 게이트웨이 프로세스에 즉시 적용됩니다.
소형 모델 사용 시 도구 라우팅을 엄격하게, 메모리 범위를 제한하세요.
</div>
</div>
</div>
<div id="settings-panel-search" style="display:none;padding:14px 16px;grid-template-columns:1fr 1fr;gap:14px">
<div style="border:1px solid var(--line);border-radius:12px;padding:12px">
<div class="right-section-title" style="margin-bottom:8px">웹 검색</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">검색 제공자</label>
<select id="settings-provider" onchange="toggleOllamaModelRow(this.value);toggleSearxngUrlRow(this.value)" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:13px">
<option value="ddg">DuckDuckGo</option>
<option value="ollama">Ollama (모델 보조 검색)</option>
<option value="tavily">Tavily</option>
<option value="google">Google CSE</option>
<option value="brave">Brave</option>
<option value="searxng">SearXNG (셀프호스트)</option>
</select>
<div id="search-ollama-model-row" style="display:none;margin-top:8px">
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">Ollama 검색 모델</label>
<input id="settings-search-ollama-model" type="text" placeholder="예: gemini-3-flash-preview:cloud" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:12px;box-sizing:border-box">
</div>
<div id="search-searxng-url-row" style="display:none;margin-top:8px">
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">SearXNG 인스턴스 URL</label>
<input id="settings-search-searxng-url" type="text" placeholder="예: http://127.0.0.1:8888" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:12px;box-sizing:border-box">
</div>
<label style="display:block;font-size:12px;color:var(--muted);margin:10px 0 6px">검색 엄격도</label>
<select id="settings-search-rigor" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:13px">
<option value="fast">빠름</option>
<option value="verified">검증됨</option>
<option value="strict">엄격</option>
</select>
<div style="margin-top:12px;padding:8px 10px;background:#f0f6ff;border:1px solid #bdd3f6;border-radius:8px;font-size:11px;color:#1e40af">
🔑 API 키는 <strong>자격증명</strong> 탭에서 관리되며 암호화되어 저장됩니다.
</div>
</div>
<div style="border:1px solid var(--line);border-radius:12px;padding:12px;background:#fff">
<div class="right-section-title" style="margin-bottom:8px">동작</div>
<div style="font-size:12px;color:var(--muted);line-height:1.7">
빠름: 가장 빠른 조회, 적은 검증.
검증됨: 1회 재시도 + 신뢰도 우선.
엄격: 검증 필수 도메인에 가장 강력한 소스 요구.
</div>
</div>
</div>
<div id="settings-panel-credentials" style="display:none;padding:14px 16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<!-- Search Provider Keys -->
<div style="border:1px solid var(--line);border-radius:12px;padding:14px">
<div class="right-section-title" style="margin-bottom:4px">검색 제공자 키</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:12px">Vault에 암호화 저장 — config.json에 평문으로 저장되지 않습니다.</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:5px">Tavily API 키</label>
<div style="display:flex;gap:6px;margin-bottom:10px">
<input id="cred-tavily-key" type="password" placeholder="tvLy-…" autocomplete="new-password"
style="flex:1;border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;font-family:var(--font-mono, monospace)" />
<button onclick="toggleCredVis('cred-tavily-key',this)" title="Show/hide"
style="padding:0 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel-2);font-size:13px;cursor:pointer;flex-shrink:0">👁</button>
</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:5px">Google API 키</label>
<div style="display:flex;gap:6px;margin-bottom:10px">
<input id="cred-google-key" type="password" placeholder="AIzaSy…" autocomplete="new-password"
style="flex:1;border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;font-family:var(--font-mono, monospace)" />
<button onclick="toggleCredVis('cred-google-key',this)" title="Show/hide"
style="padding:0 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel-2);font-size:13px;cursor:pointer;flex-shrink:0">👁</button>
</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:5px">Google CSE ID <span style="font-weight:400;color:var(--muted)">(비밀 아님 — config에 저장)</span></label>
<input id="cred-google-cx" type="text" placeholder="c3222d…"
style="width:100%;border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;margin-bottom:10px;box-sizing:border-box" />
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:5px">Brave API 키</label>
<div style="display:flex;gap:6px">
<input id="cred-brave-key" type="password" placeholder="BSA…" autocomplete="new-password"
style="flex:1;border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;font-family:var(--font-mono, monospace)" />
<button onclick="toggleCredVis('cred-brave-key',this)" title="Show/hide"
style="padding:0 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel-2);font-size:13px;cursor:pointer;flex-shrink:0">👁</button>
</div>
</div>
<!-- Right column: status + info -->
<div style="display:flex;flex-direction:column;gap:12px">
<!-- Vault status card -->
<div style="border:1px solid var(--line);border-radius:12px;padding:14px">
<div class="right-section-title" style="margin-bottom:8px">Vault 상태</div>
<div id="cred-vault-status" style="font-size:12px;color:var(--muted)">Loading…</div>
</div>
<!-- How it works -->
<div style="border:1px solid #bdd3f6;border-radius:12px;padding:14px;background:#f0f6ff">
<div style="font-size:12px;font-weight:700;color:#1e40af;margin-bottom:6px">🔒 자격증명 작동 방식</div>
<div style="font-size:11.5px;color:#1e40af;line-height:1.65">
<strong>설정 저장</strong> 시 입력한 키는 AES-256-GCM으로 암호화되어 <code style="background:#dbeafe;padding:1px 4px;border-radius:4px">vault.enc</code>에 저장됩니다.<br><br>
Config 파일에는 <code style="background:#dbeafe;padding:1px 4px;border-radius:4px">vault:…</code> 참조만 포함되며, 실제 키는 저장되지 않습니다.<br><br>
필드에 <strong>••••••••</strong>이 표시되면 키가 이미 저장된 것입니다. 그대로 두면 기존 키가 유지되고, 새로 입력하면 교체됩니다.
</div>
</div>
<!-- Audit log preview -->
<div style="border:1px solid var(--line);border-radius:12px;padding:14px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div class="right-section-title">최근 Vault 접근</div>
<button onclick="loadCredVaultLog()" style="font-size:10px;padding:3px 8px;border:1px solid var(--line);border-radius:6px;background:var(--panel-2);cursor:pointer;color:var(--muted)">새로고침</button>
</div>
<div id="cred-vault-log" style="font-size:10.5px;color:var(--muted);font-family:monospace;max-height:110px;overflow-y:auto;line-height:1.6">Loading…</div>
</div>
</div>
</div>
</div>
<div id="settings-panel-policy" style="display:none;padding:14px 16px">
<div style="border:1px solid var(--line);border-radius:12px;padding:12px;background:#fff">
<div class="right-section-title" style="margin-bottom:8px">에이전트 신선도 + 메모리 정책</div>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:10px">
<input type="checkbox" id="settings-force-web-fresh" />
최신/현재 쿼리에 대해 최소 1회 웹 검색 강제
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:10px">
<input type="checkbox" id="settings-memory-fallback" />
모든 제공자 실패 시 메모리 대체 허용
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px">
<input type="checkbox" id="settings-auto-store-web-facts" />
검증된 웹 팩트를 메모리에 자동 저장
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin-top:10px">
<input type="checkbox" id="settings-nl-tool-router" />
자연어 도구 라우팅 활성화 ("찾아줘", "웹 사용", "검증해줘")
</label>
<label style="display:block;font-size:12px;color:var(--muted);margin:10px 0 6px">코드 검색 모드</label>
<select id="settings-retrieval-mode" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:13px">
<option value="fast">빠름 (작은 스니펫, 적은 읽기)</option>
<option value="standard">표준</option>
<option value="deep">심층 (큰 스니펫, 더 많은 컨텍스트)</option>
</select>
</div>
</div>
<div id="settings-panel-security" style="display:none;padding:14px 16px">
<div style="border:1px solid var(--line);border-radius:12px;padding:12px">
<div class="right-section-title" style="margin-bottom:8px">파일 접근</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">작업공간 경로</label>
<input id="settings-workspace-path" type="text" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-family:'IBM Plex Mono',monospace;font-size:12px;margin-bottom:10px" placeholder="예: D:\CherryClaw\workspace" />
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">허용 경로 (한 줄에 하나)</label>
<textarea id="settings-allowed-paths" style="width:100%;min-height:120px;border:1px solid var(--line);border-radius:10px;padding:8px;font-family:'IBM Plex Mono',monospace;font-size:12px"></textarea>
<label style="display:block;font-size:12px;color:var(--muted);margin:10px 0 6px">차단 경로 (한 줄에 하나)</label>
<textarea id="settings-blocked-paths" style="width:100%;min-height:100px;border:1px solid var(--line);border-radius:10px;padding:8px;font-family:'IBM Plex Mono',monospace;font-size:12px"></textarea>
</div>
</div>
<div id="settings-panel-users" style="display:none;padding:14px 16px">
<div style="border:1px solid var(--line);border-radius:12px;padding:12px">
<div class="right-section-title" style="margin-bottom:8px">계정 관리</div>
<div id="users-list" style="margin-bottom:12px"></div>
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:8px;align-items:end">
<div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">사용자 이름</label>
<input id="new-user-username" type="text" placeholder="username" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
</div>
<div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">비밀번호</label>
<input id="new-user-password" type="password" placeholder="password" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
</div>
<div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">역할</label>
<select id="new-user-role" style="width:80px;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<button class="btn btn-primary" style="width:100%;margin-top:8px" onclick="addUser()">사용자 추가</button>
<div id="add-user-error" class="error-msg" style="margin-top:8px"></div>
</div>
</div>
<div id="settings-panel-models" style="display:none;padding:14px 16px">
<!-- Provider selector -->
<div style="border:1px solid var(--line);border-radius:12px;padding:12px;margin-bottom:14px">
<div class="right-section-title" style="margin-bottom:8px">LLM 제공자</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">백엔드</label>
<select id="settings-llm-provider" class="settings-input" onchange="onProviderChange()" style="font-size:13px">
<option value="ollama">Ollama (로컬)</option>
<option value="llama_cpp">llama.cpp (로컬)</option>
<option value="lm_studio">LM Studio (로컬)</option>
<option value="openai">OpenAI — API 키</option>
<option value="openai_codex">OpenAI — GPT Plus / Codex (OAuth)</option>
<option value="anthropic">Anthropic — Claude API 키</option>
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<!-- Left col: provider-specific connection fields -->
<div style="border:1px solid var(--line);border-radius:12px;padding:12px">
<!-- Ollama -->
<div id="prov-fields-ollama">
<div class="right-section-title" style="margin-bottom:8px">Ollama 연결</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">엔드포인트</label>
<input id="settings-ollama-endpoint" type="text" placeholder="http://localhost:11434" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-sm" onclick="refreshProviderModels()" style="background:#fff;border:1px solid var(--line);color:var(--text)">모델 새로고침</button>
<button class="btn btn-sm" onclick="testProviderConnection()" style="background:#fff;border:1px solid var(--line);color:var(--text)">테스트</button>
</div>
<div id="provider-status-msg" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
<div class="right-section-title" style="margin:14px 0 8px">기본 모델</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">활성 모델</label>
<select id="settings-primary-model" class="settings-input" style="font-size:13px">
<option value="">— 새로고침하여 로드 —</option>
</select>
</div>
<!-- llama.cpp -->
<div id="prov-fields-llama_cpp" style="display:none">
<div class="right-section-title" style="margin-bottom:8px">llama.cpp 서버</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">엔드포인트</label>
<input id="settings-llamacpp-endpoint" type="text" placeholder="http://localhost:8080" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
<label style="display:block;font-size:12px;color:var(--muted);margin:10px 0 6px">모델 이름 <span style="color:var(--muted);font-weight:400">(직접 입력)</span></label>
<input id="settings-llamacpp-model" type="text" placeholder="e.g. qwen2.5-7b" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-sm" onclick="testProviderConnection()" style="background:#fff;border:1px solid var(--line);color:var(--text)">연결 테스트</button>
</div>
<div id="provider-status-msg-llamacpp" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
<div style="font-size:11px;color:var(--muted);margin-top:12px;line-height:1.6">Start with: <code style="background:var(--bg-soft);padding:1px 5px;border-radius:4px">llama-server -m model.gguf --port 8080</code></div>
</div>
<!-- LM Studio -->
<div id="prov-fields-lm_studio" style="display:none">
<div class="right-section-title" style="margin-bottom:8px">LM Studio 연결</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">엔드포인트</label>
<input id="settings-lmstudio-endpoint" type="text" placeholder="http://localhost:1234" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
<label style="display:block;font-size:12px;color:var(--muted);margin:10px 0 6px">모델 이름</label>
<input id="settings-lmstudio-model" type="text" placeholder="e.g. qwen2.5-7b-instruct" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-sm" onclick="refreshProviderModels()" style="background:#fff;border:1px solid var(--line);color:var(--text)">모델 감지</button>
<button class="btn btn-sm" onclick="testProviderConnection()" style="background:#fff;border:1px solid var(--line);color:var(--text)">테스트</button>
</div>
<div id="provider-status-msg-lmstudio" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
<div style="font-size:11px;color:var(--muted);margin-top:12px;line-height:1.6">연결 전 LM Studio에서 로컬 서버를 활성화하세요.</div>
</div>
<!-- OpenAI API Key -->
<div id="prov-fields-openai" style="display:none">
<div class="right-section-title" style="margin-bottom:8px">OpenAI API 키</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">API 키</label>
<input id="settings-openai-key" type="password" placeholder="sk-..." style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
<label style="display:block;font-size:12px;color:var(--muted);margin:10px 0 6px">모델</label>
<select id="settings-openai-model" class="settings-input" style="font-size:13px">
<option value="gpt-4o">gpt-4o</option>
<option value="gpt-4o-mini">gpt-4o-mini</option>
<option value="o4-mini">o4-mini</option>
<option value="o3">o3</option>
</select>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-sm" onclick="refreshOpenAIModels()" style="background:#fff;border:1px solid var(--line);color:var(--text)">모델 새로고침</button>
<button class="btn btn-sm" onclick="testProviderConnection()" style="background:#fff;border:1px solid var(--line);color:var(--text)">연결 테스트</button>
</div>
<div id="provider-status-msg-openai" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
</div>
<!-- Anthropic -->
<div id="prov-fields-anthropic" style="display:none">
<div class="right-section-title" style="margin-bottom:8px">Anthropic API 키</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">API 키</label>
<input id="settings-anthropic-key" type="password" placeholder="sk-ant-api03-..." style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
<label style="display:block;font-size:12px;color:var(--muted);margin:10px 0 6px">모델</label>
<select id="settings-anthropic-model" class="settings-input" style="font-size:13px">
<option value="claude-sonnet-4-6">claude-sonnet-4-6 (권장)</option>
<option value="claude-opus-4-7">claude-opus-4-7</option>
<option value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001 (빠름)</option>
</select>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-sm" onclick="testProviderConnection()" style="background:#fff;border:1px solid var(--line);color:var(--text)">연결 테스트</button>
</div>
<div id="provider-status-msg-anthropic" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
</div>
<!-- OpenAI Codex OAuth -->
<div id="prov-fields-openai_codex" style="display:none">
<div class="right-section-title" style="margin-bottom:8px">OpenAI — GPT Plus / Codex</div>
<div id="codex-disconnected-state">
<div style="font-size:12px;color:var(--muted);margin-bottom:12px;line-height:1.6">OAuth로 ChatGPT Plus 또는 Pro 계정을 연결하세요. 브라우저에서 CherryClaw 인증이 열립니다.</div>
<button class="btn" onclick="startCodexOAuth()" style="width:100%;background:var(--brand);color:#fff;border:none;padding:10px;border-radius:10px;font-size:13px;cursor:pointer">ChatGPT 계정 연결</button>
<div id="codex-oauth-status" style="font-size:11px;color:var(--muted);margin-top:8px;text-align:center"></div>
</div>
<div id="codex-connected-state" style="display:none">
<div style="background:#f0faf4;border:1px solid #86d4a8;border-radius:10px;padding:10px;margin-bottom:12px">
<div style="font-size:12px;font-weight:600;color:#0a6b3d">연결됨</div>
<div id="codex-account-id" style="font-size:11px;color:var(--muted);margin-top:2px"></div>
</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">Model</label>
<select id="settings-codex-model" class="settings-input" style="font-size:13px">
<option value="gpt-5.3-codex">gpt-5.3-codex (recommended)</option>
<option value="gpt-5.3-codex-spark">gpt-5.3-codex-spark</option>
<option value="gpt-5.2-codex">gpt-5.2-codex</option>
<option value="gpt-5.1-codex-max">gpt-5.1-codex-max</option>
<option value="gpt-5.1-codex-mini">gpt-5.1-codex-mini (faster)</option>
<option value="gpt-5.2">gpt-5.2</option>
<option value="gpt-5.1">gpt-5.1</option>
</select>
<button class="btn btn-sm" onclick="disconnectCodex()" style="margin-top:10px;background:#fff;border:1px solid var(--err);color:var(--err)">계정 연결 해제</button>
</div>
</div>
</div>
<!-- Right col: orchestration -->
<div id="orch-settings-col" style="border:1px solid var(--line);border-radius:12px;padding:12px;background:linear-gradient(180deg,#fff 0%,#f9fbff 100%)">
<!-- -- Multi-Agent Orchestration -- -->
<div class="right-section-title" style="margin:16px 0 8px">멀티 에이전트 오케스트레이션 <span style="font-weight:400;color:var(--muted);font-size:11px">선택</span></div>
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">
보조 AI 어드바이저가 기본 모델이 막히거나 계획이 필요할 때 도와줍니다.
설정 후 <strong>멀티 에이전트 오케스트레이터</strong> 스킬을 활성화하세요.
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">보조 제공자</div>
<select id="orch-secondary-provider" class="settings-input" onchange="updateOrchSecondaryModels()">
<option value="">— 없음 —</option>
<option value="openai_codex">ChatGPT (OAuth)</option>
<option value="openai">OpenAI API</option>
<option value="ollama">Ollama (local)</option>
<option value="llama_cpp">llama.cpp</option>
<option value="lm_studio">LM Studio</option>
</select>
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">보조 모델</div>
<select id="orch-secondary-model" class="settings-input">
<option value="">— 제공자를 먼저 선택 —</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">실패 임계값</div>
<input id="orch-fail-threshold" class="settings-input" type="number" min="1" max="5" value="2" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">정체 라운드</div>
<input id="orch-stagnation" class="settings-input" type="number" min="1" max="8" value="3" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">루프 감지</div>
<select id="orch-loop-detect" class="settings-input">
<option value="true">예</option>
<option value="false">아니요</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">위험 파일 수</div>
<input id="orch-risk-files" class="settings-input" type="number" min="1" max="30" value="6" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">위험 줄 수정수</div>
<input id="orch-risk-ops" class="settings-input" type="number" min="10" max="2000" value="220" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">진행 없음 (초)</div>
<input id="orch-no-progress-sec" class="settings-input" type="number" min="15" max="600" value="90" />
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">사전 어드바이저</div>
<select id="orch-preflight-mode" class="settings-input">
<option value="off">꺼짐</option>
<option value="complex_only">복잡한 작업만</option>
<option value="always">항상</option>
</select>
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">보조 직접 채팅</div>
<select id="orch-allow-secondary-chat" class="settings-input">
<option value="false">아니요 (권장)</option>
<option value="true">예</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">쿨다운 라운드</div>
<input id="orch-cooldown-rounds" class="settings-input" type="number" min="1" max="12" value="3" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">턴당 최대 어시스트</div>
<input id="orch-max-assists-turn" class="settings-input" type="number" min="1" max="12" value="3" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">세션당 최대 어시스트</div>
<input id="orch-max-assists-session" class="settings-input" type="number" min="1" max="100" value="18" />
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">턴당 브라우저 어드바이저 호출</div>
<input id="orch-browser-calls-turn" class="settings-input" type="number" min="1" max="12" value="5" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">브라우저 최대 수집 항목</div>
<input id="orch-browser-max-items" class="settings-input" type="number" min="12" max="240" value="80" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">브라우저 강제 재시도</div>
<input id="orch-browser-forced-retries" class="settings-input" type="number" min="0" max="6" value="2" />
</div>
</div>
<div id="orch-preempt-panel" style="margin-bottom:12px;padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--panel-2)">
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">
Ollama 멈춤 선제조치: 생성이 너무 오래 걸리면 Ollama를 강제 재시작합니다 (스킬 게이트 필요).
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:8px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">선제조치 활성화</div>
<select id="orch-preempt-enabled" class="settings-input">
<option value="false">아니요</option>
<option value="true">예</option>
</select>
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">멈춤 임계값 (초)</div>
<input id="orch-preempt-threshold" class="settings-input" type="number" min="10" max="300" value="45" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">턴당 최대 선제조치</div>
<input id="orch-preempt-max-turn" class="settings-input" type="number" min="1" max="3" value="1" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">세션당 최대 선제조치</div>
<input id="orch-preempt-max-session" class="settings-input" type="number" min="1" max="10" value="3" />
</div>
</div>
<div id="orch-preempt-note" style="font-size:10px;color:var(--muted);margin-top:8px;display:none">
선제조치는 활성 기본 제공자가 Ollama일 때만 실행됩니다.
</div>
</div>
<div id="orch-fileops-panel" style="margin-bottom:12px;padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--panel-2)">
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">
FILE_OP v2: 크기 제한, 검증 트리거, 워치독, 체크포인트.
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">FILE_OP v2 활성화</div>
<select id="orch-fileops-enabled" class="settings-input">
<option value="true">예</option>
<option value="false">아니요</option>
</select>
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">생성 시 항상 검증</div>
<select id="orch-fileops-verify-create" class="settings-input">
<option value="true">예</option>
<option value="false">아니요</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">기본 생성 최대 줄 수</div>
<input id="orch-fileops-create-lines" class="settings-input" type="number" min="20" max="400" value="80" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">기본 생성 최대 문자 수</div>
<input id="orch-fileops-create-chars" class="settings-input" type="number" min="800" max="40000" value="3500" />
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:10px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">기본 수정 최대 줄 수</div>
<input id="orch-fileops-edit-lines" class="settings-input" type="number" min="1" max="80" value="12" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">기본 수정 최대 문자 수</div>
<input id="orch-fileops-edit-chars" class="settings-input" type="number" min="100" max="8000" value="800" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">기본 수정 최대 파일 수</div>
<input id="orch-fileops-edit-files" class="settings-input" type="number" min="1" max="8" value="1" />
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">대형 페이로드 검증 줄 수</div>
<input id="orch-fileops-verify-lines" class="settings-input" type="number" min="5" max="400" value="25" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">대형 페이로드 검증 문자 수</div>
<input id="orch-fileops-verify-chars" class="settings-input" type="number" min="200" max="50000" value="1200" />
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">워치독 진행 없음 사이클</div>
<input id="orch-fileops-watchdog-cycles" class="settings-input" type="number" min="2" max="8" value="3" />
</div>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">체크포인트</div>
<select id="orch-fileops-checkpointing" class="settings-input">
<option value="true">활성화</option>
<option value="false">비활성화</option>
</select>
</div>
</div>
</div>
<div id="orch-eligibility-msg" style="font-size:11px;color:var(--muted);margin-bottom:8px;padding:8px 10px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px;display:none"></div>
<button onclick="saveOrchestrationConfig()" class="btn" style="width:100%;margin-bottom:8px">오케스트레이션 설정 저장</button>
</div>
</div>
</div>
<div id="settings-panel-channels" style="display:none;padding:14px 16px">
<div style="display:grid;grid-template-columns:1.2fr 1fr;gap:14px">
<div style="border:1px solid var(--line);border-radius:12px;padding:12px;background:#fff">
<div class="right-section-title" style="margin-bottom:8px">채널 연결</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">채널</label>
<select id="settings-channel-select" class="settings-input" onchange="onChannelTypeChange()" style="margin-bottom:10px">
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
<option value="whatsapp">WhatsApp</option>
<option value="email">Email</option>
</select>
<div id="tg-status-bar" style="display:flex;align-items:center;gap:8px;margin-bottom:10px;padding:8px 10px;border-radius:8px;background:#f8f9fa;font-size:12px">
<div id="tg-status-dot" style="width:8px;height:8px;border-radius:50%;background:#ccc;flex-shrink:0"></div>
<span id="tg-status-text" style="color:var(--muted)">미설정</span>
</div>
<div id="dc-status-bar" style="display:none;align-items:center;gap:8px;margin-bottom:10px;padding:8px 10px;border-radius:8px;background:#f8f9fa;font-size:12px">
<div id="dc-status-dot" style="width:8px;height:8px;border-radius:50%;background:#ccc;flex-shrink:0"></div>
<span id="dc-status-text" style="color:var(--muted)">미설정</span>
</div>
<div id="wa-status-bar" style="display:none;align-items:center;gap:8px;margin-bottom:10px;padding:8px 10px;border-radius:8px;background:#f8f9fa;font-size:12px">
<div id="wa-status-dot" style="width:8px;height:8px;border-radius:50%;background:#ccc;flex-shrink:0"></div>
<span id="wa-status-text" style="color:var(--muted)">미설정</span>
</div>
<div id="em-status-bar" style="display:none;align-items:center;gap:8px;margin-bottom:10px;padding:8px 10px;border-radius:8px;background:#f8f9fa;font-size:12px">
<div id="em-status-dot" style="width:8px;height:8px;border-radius:50%;background:#ccc;flex-shrink:0"></div>
<span id="em-status-text" style="color:var(--muted)">미설정</span>
</div>
<div id="channel-form-telegram">
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">Bot Token</label>
<input id="settings-tg-token" type="password" placeholder="123456:ABC-DEF1234..." style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:block;font-size:12px;color:var(--muted);margin:10px 0 6px">내 Telegram 사용자 ID</label>
<input id="settings-tg-userid" type="text" placeholder="e.g. 123456789, 987654321" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin:10px 0 0;cursor:pointer">
<input type="checkbox" id="settings-tg-enabled" />
Telegram 채널 활성화
</label>
</div>
<div id="channel-form-discord" style="display:none">
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">Bot Token</label>
<input id="settings-dc-token" type="password" placeholder="Discord bot token" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:block;font-size:12px;color:var(--muted);margin:8px 0 6px">Application ID (optional)</label>
<input id="settings-dc-appid" type="text" placeholder="Discord app ID" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:block;font-size:12px;color:var(--muted);margin:8px 0 6px">Guild ID (optional)</label>
<input id="settings-dc-guildid" type="text" placeholder="Server ID" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:block;font-size:12px;color:var(--muted);margin:8px 0 6px">Channel ID (for send-test)</label>
<input id="settings-dc-channelid" type="text" placeholder="Channel ID" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:block;font-size:12px;color:var(--muted);margin:8px 0 6px">Webhook URL (optional)</label>
<input id="settings-dc-webhook" type="password" placeholder="https://discord.com/api/webhooks/..." style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin:10px 0 0;cursor:pointer">
<input type="checkbox" id="settings-dc-enabled" />
Discord 채널 활성화
</label>
</div>
<div id="channel-form-whatsapp" style="display:none">
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">Access Token</label>
<input id="settings-wa-token" type="password" placeholder="Meta access token" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:block;font-size:12px;color:var(--muted);margin:8px 0 6px">Phone Number ID</label>
<input id="settings-wa-phoneid" type="text" placeholder="WhatsApp phone number ID" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:block;font-size:12px;color:var(--muted);margin:8px 0 6px">Business Account ID (optional)</label>
<input id="settings-wa-baid" type="text" placeholder="WABA ID" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:block;font-size:12px;color:var(--muted);margin:8px 0 6px">Webhook Verify Token (optional)</label>
<input id="settings-wa-verify" type="password" placeholder="Verify token" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:block;font-size:12px;color:var(--muted);margin:8px 0 6px">Webhook Secret (optional)</label>
<input id="settings-wa-secret" type="password" placeholder="Webhook secret" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:block;font-size:12px;color:var(--muted);margin:8px 0 6px">Test Recipient (E.164)</label>
<input id="settings-wa-recipient" type="text" placeholder="15551234567" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin:10px 0 0;cursor:pointer">
<input type="checkbox" id="settings-wa-enabled" />
WhatsApp 채널 활성화
</label>
</div>
<div id="channel-form-email" style="display:none">
<div id="email-accounts-list" style="margin-bottom:10px"></div>
<div style="border:1px dashed var(--line);border-radius:8px;padding:10px;margin-top:6px">
<div style="font-size:12px;font-weight:600;margin-bottom:6px">계정 추가 / 수정</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<div>
<label style="display:block;font-size:11px;color:var(--muted)">계정 ID</label>
<input id="em-id" type="text" placeholder="applecherry" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
</div>
<div>
<label style="display:block;font-size:11px;color:var(--muted)">이메일 주소</label>
<input id="em-username" type="text" placeholder="user@example.com" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
</div>
<div>
<label style="display:block;font-size:11px;color:var(--muted)">비밀번호</label>
<input id="em-password" type="password" placeholder="••••••••" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
</div>
<div>
<label style="display:block;font-size:11px;color:var(--muted)">IMAP 서버</label>
<input id="em-imap-host" type="text" placeholder="imap.example.com" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
</div>
<div>
<label style="display:block;font-size:11px;color:var(--muted)">IMAP 포트</label>
<input id="em-imap-port" type="number" value="993" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
</div>
<div>
<label style="display:block;font-size:11px;color:var(--muted)">SMTP 서버</label>
<input id="em-smtp-host" type="text" placeholder="smtp.example.com" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
</div>
<div>
<label style="display:block;font-size:11px;color:var(--muted)">SMTP 포트</label>
<input id="em-smtp-port" type="number" value="587" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
</div>
<div style="display:flex;flex-direction:column;justify-content:end;gap:4px">
<label style="display:flex;align-items:center;gap:6px;font-size:11px;cursor:pointer"><input type="checkbox" id="em-imap-tls" checked /> IMAP TLS</label>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;cursor:pointer"><input type="checkbox" id="em-smtp-secure" /> SMTP SSL</label>
</div>
</div>
<div style="display:flex;gap:6px;margin-top:8px">
<button class="btn btn-sm" onclick="saveEmailAccount()" style="background:var(--brand);color:#fff;border:1px solid var(--brand);padding:5px 12px;font-size:12px">저장</button>
</div>
</div>
</div>
<div style="margin-top:14px;padding-top:10px;border-top:1px solid var(--line)">
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="font-size:13px;font-weight:600">허트비트 알림</div>
<label style="display:flex;align-items:center;gap:8px;font-size:12px;cursor:pointer">
<input type="checkbox" id="settings-hb-notify" checked onchange="saveHbNotifyConfig()" />
<span id="settings-hb-notify-label">알림 받기</span>
</label>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:12px">
<button class="btn btn-sm" id="channel-test-btn" onclick="testSelectedChannel()" style="background:#fff;border:1px solid var(--line);color:var(--text)">테스트</button>
<button class="btn btn-sm" onclick="saveSelectedChannelSettings()" style="background:var(--brand);color:#fff;border:1px solid var(--brand)">저장</button>
<button class="btn btn-sm" id="channel-send-test-btn" onclick="sendSelectedChannelTest()" style="background:#fff;border:1px solid var(--line);color:var(--text)">테스트 전송</button>
</div>
</div>
<div style="border:1px solid var(--line);border-radius:12px;padding:12px;background:#fff">
<div class="right-section-title" style="margin-bottom:8px">안내</div>
<div id="channel-guide-telegram" style="font-size:12px;color:var(--muted);line-height:1.8">
<b>Telegram Setup</b><br>
1. Create a bot via <a href="https://t.me/BotFather" target="_blank" style="color:var(--brand)">@BotFather</a>.<br>
2. Copy the bot token into this panel.<br>
3. Get your user ID(s) from <a href="https://t.me/userinfobot" target="_blank" style="color:var(--brand)">@userinfobot</a>.<br>
4. Add allowed IDs, then click <b>Test</b> and <b>Save</b>.<br>
5. Use <b>Send Test</b> to verify delivery.
</div>
<div id="channel-guide-discord" style="display:none;font-size:12px;color:var(--muted);line-height:1.8">
<b>Discord Setup</b><br>
1. Create an app + bot in Discord Developer Portal.<br>
2. Enable required intents (including Message Content if needed).<br>
3. Invite bot to your server with message permissions.<br>
4. Paste bot token; optionally add app/guild/channel IDs.<br>
5. Click <b>Test</b>, then <b>Save</b>, then <b>Send Test</b>.
</div>
<div id="channel-guide-whatsapp" style="display:none;font-size:12px;color:var(--muted);line-height:1.8">
<b>WhatsApp Cloud API Setup</b><br>
1. In Meta Developer dashboard, open WhatsApp Cloud API.<br>
2. Copy access token and phone number ID into this panel.<br>
3. Configure webhook verify token/secret (if using inbound webhooks).<br>
4. Add a test recipient number in E.164 format.<br>
5. Click <b>Test</b>, <b>Save</b>, then <b>Send Test</b>.
</div>
<div id="channel-guide-email" style="display:none;font-size:12px;color:var(--muted);line-height:1.8">
<b>Email 계정 설정</b><br>
1. IMAP/SMTP 서버 주소와 포트를 입력하세요.<br>
2. 일반적인 포트: IMAP=993(TLS), SMTP=587(STARTTLS) 또는 465(SSL).<br>
3. Gmail은 앱 비밀번호 사용 (imap.gmail.com / smtp.gmail.com).<br>
4. 저장 후 <b>테스트</b> 버튼으로 IMAP 연결을 확인하세요.<br>
5. 여러 계정을 추가하면 email_list에서 모든 계정을 순회합니다.
</div>
</div>
</div>
</div>
<div id="settings-panel-agents" style="display:none;padding:14px 16px">
<div style="display:grid;grid-template-columns:0.9fr 1.1fr;gap:14px">
<div style="border:1px solid var(--line);border-radius:12px;padding:12px;background:#fff">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div class="right-section-title">구성된 에이전트</div>
<button class="btn btn-sm" style="padding:4px 8px" onclick="agentFormNew()">+ New</button>
</div>
<div id="settings-agents-list" style="display:flex;flex-direction:column;gap:8px;max-height:520px;overflow:auto;font-size:12px;color:var(--muted)">에이전트 로딩 중...</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px">
<div style="border:1px solid var(--line);border-radius:12px;padding:12px;background:#fff">
<div class="right-section-title" style="margin-bottom:8px">에이전트 상세</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div>
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:4px">ID</label>
<input id="agent-edit-id" type="text" placeholder="researcher" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
</div>
<div>
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:4px">Name</label>
<input id="agent-edit-name" type="text" placeholder="Scout" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:12px;box-sizing:border-box" />
</div>
<div>
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:4px">Emoji</label>
<input id="agent-edit-emoji" type="text" placeholder="🔍" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:12px;box-sizing:border-box" />
</div>
<div>
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:4px">도구 프로필</label>
<select id="agent-edit-profile" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:12px;box-sizing:border-box">
<option value="">(inherit)</option>
<option value="minimal">minimal</option>
<option value="coding">coding</option>
<option value="web">web</option>
<option value="full">full</option>
</select>
</div>
</div>
<label style="display:block;font-size:11px;color:var(--muted);margin:8px 0 4px">설명</label>
<textarea id="agent-edit-description" rows="3" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:12px;box-sizing:border-box;resize:vertical"></textarea>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">
<div>
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:4px">워크스페이스</label>
<input id="agent-edit-workspace" type="text" placeholder="D:/CherryClaw/agents/researcher/workspace" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
</div>
<div>
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:4px">Model</label>
<input id="agent-edit-model" type="text" placeholder="ollama/qwen3:4b" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
</div>
<div>
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:4px">크론 일정</label>
<input id="agent-edit-cron" type="text" placeholder="0 8 * * *" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
</div>
<div>
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:4px">최대 단계</label>
<input id="agent-edit-max-steps" type="number" min="1" step="1" placeholder="8" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:12px;box-sizing:border-box" />
</div>
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:10px;font-size:12px">
<label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="agent-edit-default" /> default</label>
<label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="agent-edit-minimal" /> minimalPrompt</label>
<label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="agent-edit-can-spawn" /> canSpawn</label>
</div>
<label style="display:block;font-size:11px;color:var(--muted);margin:8px 0 4px">Spawn Allowlist (comma-separated IDs)</label>
<input id="agent-edit-spawn-allowlist" type="text" placeholder="researcher,writer" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box" />
<div style="display:flex;gap:8px;margin-top:10px">
<button class="btn btn-sm" onclick="saveAgentFromForm()">에이전트 저장</button>
<button class="btn btn-sm" style="background:#fff;border:1px solid var(--line);color:var(--text)" onclick="deleteSelectedAgent()">삭제</button>
</div>
</div>
<div style="border:1px solid var(--line);border-radius:12px;padding:12px;background:#fff">
<div class="right-section-title" style="margin-bottom:8px">AGENTS.md (CodeMirror)</div>
<div id="agent-md-editor-wrap" style="border:1px solid var(--line);border-radius:8px;overflow:hidden"></div>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-sm" style="background:#fff;border:1px solid var(--line);color:var(--text)" onclick="loadSelectedAgentMd()">Reload File</button>
<button class="btn btn-sm" onclick="saveSelectedAgentMd()">Save AGENTS.md</button>
</div>
<div id="agent-md-path" style="margin-top:6px;font-size:11px;color:var(--muted);font-family:'IBM Plex Mono',monospace"></div>
</div>
<div style="border:1px solid var(--line);border-radius:12px;padding:12px;background:#fff">
<div class="right-section-title" style="margin-bottom:8px">Manual Spawn + Run History</div>
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:4px">Task</label>
<textarea id="agent-spawn-task" rows="3" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:12px;box-sizing:border-box;resize:vertical" placeholder="Run a one-off task for this agent..."></textarea>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-sm" onclick="runSelectedAgentOnce()">Run Once</button>
<button class="btn btn-sm" style="background:#fff;border:1px solid var(--line);color:var(--text)" onclick="loadAgentRunHistory()">Refresh History</button>
</div>
<div id="agent-spawn-result" style="margin-top:8px;font-size:12px;color:var(--muted);white-space:pre-wrap;max-height:120px;overflow:auto"></div>
<div id="agent-run-history" style="margin-top:8px;display:flex;flex-direction:column;gap:6px;max-height:170px;overflow:auto;font-size:12px"></div>
</div>
</div>
</div>
</div>
<div id="settings-panel-integrations" style="display:none;padding:14px 16px">
<!-- Sub-tab row -->
<div style="display:flex;gap:8px;margin-bottom:14px">
<button id="itab-webhooks" onclick="setIntegTab('webhooks')" style="padding:5px 14px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;border:1px solid var(--line);background:#eaf2ff;color:#0d4faf">Webhooks</button>
<button id="itab-mcp" onclick="setIntegTab('mcp')" style="padding:5px 14px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;border:1px solid var(--line);background:#fff;color:var(--muted)">MCP Servers</button>
</div>
<!-- WEBHOOKS PANEL ----------------------------------------------- -->
<div id="itab-panel-webhooks">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<!-- Left: config -->
<div style="border:1px solid var(--line);border-radius:12px;padding:14px;background:#fff">
<div class="right-section-title" style="margin-bottom:10px">Webhook Endpoint</div>
<div id="wh-status-bar" style="display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:8px 10px;border-radius:8px;background:#f8f9fa;font-size:12px">
<div id="wh-status-dot" style="width:8px;height:8px;border-radius:50%;background:#ccc;flex-shrink:0"></div>
<span id="wh-status-text" style="color:var(--muted)">미설정</span>
</div>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:10px;cursor:pointer">
<input type="checkbox" id="wh-enabled" />
Enable webhook endpoint
</label>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">Secret Token</label>
<div style="display:flex;gap:6px;margin-bottom:10px">
<input id="wh-token" type="password" placeholder="Paste or generate a secret token" style="flex:1;border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
<button onclick="generateWebhookToken()" style="padding:6px 10px;border:1px solid var(--line);border-radius:8px;background:#f8f9fa;font-size:11px;cursor:pointer;white-space:nowrap">Generate</button>
</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">URL Path Prefix</label>
<input id="wh-path" type="text" value="/hooks" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;font-family:'IBM Plex Mono',monospace;box-sizing:border-box;margin-bottom:12px" />
<div id="wh-url-display" style="display:none;background:#f0f7ff;border:1px solid #c7dff7;border-radius:8px;padding:8px 10px;font-size:11px;font-family:'IBM Plex Mono',monospace;color:#0d4faf;margin-bottom:12px;word-break:break-all"></div>
<div style="display:flex;gap:8px">
<button onclick="saveWebhookSettings()" style="padding:6px 14px;background:var(--brand);color:#fff;border:none;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer">Save</button>
<button onclick="testWebhookEndpoint()" style="padding:6px 14px;background:#fff;color:var(--text);border:1px solid var(--line);border-radius:8px;font-size:12px;cursor:pointer">Test</button>
<button onclick="copyWebhookCurl()" style="padding:6px 14px;background:#fff;color:var(--text);border:1px solid var(--line);border-radius:8px;font-size:12px;cursor:pointer">Copy cURL</button>
</div>
</div>
<!-- Right: guide -->
<div style="border:1px solid var(--line);border-radius:12px;padding:14px;background:#fff">
<div class="right-section-title" style="margin-bottom:8px">Setup Guide</div>
<div style="font-size:12px;color:var(--muted);line-height:1.85">
<b>1. Enable + set a token</b> — any secret string you choose.<br>
<b>2. Save</b> and restart the gateway to activate.<br>
<b>3. Access from the internet?</b> Use
<a href="https://tailscale.com" target="_blank" style="color:var(--brand)">Tailscale</a> (recommended),
<a href="https://ngrok.com" target="_blank" style="color:var(--brand)">ngrok</a>, or
<a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/" target="_blank" style="color:var(--brand)">Cloudflare Tunnel</a>.
<br><br>
<b>Endpoints:</b><br>
<code style="background:#f4f4f4;padding:2px 5px;border-radius:4px">POST /hooks/agent</code> — full AI run<br>
<code style="background:#f4f4f4;padding:2px 5px;border-radius:4px">POST /hooks/wake</code> — lightweight nudge<br>
<code style="background:#f4f4f4;padding:2px 5px;border-radius:4px">GET /hooks/status</code> — health check<br><br>
<b>Auth:</b> Send token as<br>
<code style="background:#f4f4f4;padding:2px 5px;border-radius:4px">Authorization: Bearer &lt;token&gt;</code><br>
or <code style="background:#f4f4f4;padding:2px 5px;border-radius:4px">x-cherryclaw-token: &lt;token&gt;</code><br><br>
Works with: GitHub Actions, Stripe, Vercel, n8n, IFTTT, Home Assistant, or any script.
</div>
</div>
</div>
</div>
<!-- MCP PANEL ---------------------------------------------------- -->
<div id="itab-panel-mcp" style="display:none">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<!-- Left: server list + add form -->
<div>
<div style="border:1px solid var(--line);border-radius:12px;padding:14px;background:#fff;margin-bottom:12px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<div class="right-section-title">MCP Servers</div>
<button onclick="showMCPAddForm()" style="padding:4px 10px;border:1px solid var(--brand);border-radius:6px;background:#fff;color:var(--brand);font-size:11px;font-weight:600;cursor:pointer">+ Add Server</button>
</div>
<div id="mcp-server-list" style="font-size:12px;color:var(--muted)">Loading...</div>
</div>
<!-- Add/Edit form (hidden by default) -->
<div id="mcp-add-form" style="display:none;border:1px solid var(--brand);border-radius:12px;padding:14px;background:#f8faff">
<div class="right-section-title" style="margin-bottom:10px" id="mcp-form-title">Add MCP Server</div>
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:3px">ID <span style="color:#888">(letters/numbers/dash, no spaces)</span></label>
<input id="mcp-f-id" type="text" placeholder="e.g. filesystem" style="width:100%;border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:12px;box-sizing:border-box;margin-bottom:8px" />
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:3px">Display Name</label>
<input id="mcp-f-name" type="text" placeholder="e.g. Filesystem" style="width:100%;border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:12px;box-sizing:border-box;margin-bottom:8px" />
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:3px">Transport</label>
<select id="mcp-f-transport" onchange="onMCPTransportChange()" style="width:100%;border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:12px;box-sizing:border-box;margin-bottom:8px">
<option value="stdio">stdio (local process)</option>
<option value="sse">HTTP/SSE (remote)</option>
</select>
<!-- stdio fields -->
<div id="mcp-stdio-fields">
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:3px">Command</label>
<input id="mcp-f-command" type="text" placeholder="e.g. npx" style="width:100%;border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:12px;box-sizing:border-box;margin-bottom:8px" />
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:3px">Arguments <span style="color:#888">(one per line)</span></label>
<textarea id="mcp-f-args" placeholder="-y&#10;@modelcontextprotocol/server-filesystem&#10;C:\Users\you\Documents" style="width:100%;border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:12px;box-sizing:border-box;height:64px;resize:vertical;font-family:'IBM Plex Mono',monospace;margin-bottom:8px"></textarea>
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:3px">Environment Variables <span style="color:#888">(KEY=VALUE, one per line)</span></label>
<textarea id="mcp-f-env" placeholder="GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx" style="width:100%;border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:12px;box-sizing:border-box;height:52px;resize:vertical;font-family:'IBM Plex Mono',monospace;margin-bottom:8px"></textarea>
</div>
<!-- SSE fields -->
<div id="mcp-sse-fields" style="display:none">
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:3px">Server URL</label>
<input id="mcp-f-url" type="text" placeholder="https://mcp.example.com/sse" style="width:100%;border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:12px;box-sizing:border-box;margin-bottom:8px" />
<label style="display:block;font-size:11px;color:var(--muted);margin-bottom:3px">Headers <span style="color:#888">(KEY: value, one per line)</span></label>
<textarea id="mcp-f-headers" placeholder="Authorization: Bearer token123" style="width:100%;border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:12px;box-sizing:border-box;height:52px;resize:vertical;font-family:'IBM Plex Mono',monospace;margin-bottom:8px"></textarea>
</div>
<label style="display:flex;align-items:center;gap:7px;font-size:12px;margin-bottom:10px;cursor:pointer">
<input type="checkbox" id="mcp-f-enabled" checked />
Enable on startup
</label>
<div style="display:flex;gap:8px">
<button onclick="saveMCPServer()" style="padding:6px 14px;background:var(--brand);color:#fff;border:none;border-radius:7px;font-size:12px;font-weight:600;cursor:pointer">Save</button>
<button onclick="hideMCPAddForm()" style="padding:6px 14px;background:#fff;color:var(--muted);border:1px solid var(--line);border-radius:7px;font-size:12px;cursor:pointer">Cancel</button>
</div>
<div id="mcp-form-msg" style="margin-top:8px;font-size:11px;color:var(--muted)"></div>
</div>
</div>
<!-- Right: info + popular servers -->
<div>
<div style="border:1px solid var(--line);border-radius:12px;padding:14px;background:#fff;margin-bottom:12px">
<div class="right-section-title" style="margin-bottom:8px">What is MCP?</div>
<div style="font-size:12px;color:var(--muted);line-height:1.85">
<b>Model Context Protocol</b> (by Anthropic) is a standard that lets AI connect to external tools and data sources.<br><br>
Each MCP server exposes a set of <b>tools</b> — CherryClaw discovers them automatically when you connect.
The AI can then call them during tasks, just like its built-in tools.<br><br>
Servers run <b>locally as child processes</b> (stdio) or connect to <b>remote HTTP endpoints</b> (SSE).
No data leaves your machine unless the tool itself calls an external API.
</div>
</div>
<div style="border:1px solid var(--line);border-radius:12px;padding:14px;background:#fff">
<div class="right-section-title" style="margin-bottom:8px">Popular Servers</div>
<div style="font-size:12px;color:var(--muted);line-height:1">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<button onclick="prefillMCPServer('filesystem')" style="text-align:left;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#fafafa;cursor:pointer;font-size:11px">
<div style="font-weight:600;color:var(--text)">Filesystem</div>
<div style="color:var(--muted)">Read/write local files</div>
</button>
<button onclick="prefillMCPServer('github')" style="text-align:left;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#fafafa;cursor:pointer;font-size:11px">
<div style="font-weight:600;color:var(--text)">GitHub</div>
<div style="color:var(--muted)">Repos, PRs, issues</div>
</button>
<button onclick="prefillMCPServer('postgres')" style="text-align:left;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#fafafa;cursor:pointer;font-size:11px">
<div style="font-weight:600;color:var(--text)">PostgreSQL</div>
<div style="color:var(--muted)">Query your DB</div>
</button>
<button onclick="prefillMCPServer('sqlite')" style="text-align:left;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#fafafa;cursor:pointer;font-size:11px">
<div style="font-weight:600;color:var(--text)">SQLite</div>
<div style="color:var(--muted)">Local database queries</div>
</button>
<button onclick="prefillMCPServer('brave')" style="text-align:left;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#fafafa;cursor:pointer;font-size:11px">
<div style="font-weight:600;color:var(--text)">Brave Search</div>
<div style="color:var(--muted)">Web search via MCP</div>
</button>
<button onclick="prefillMCPServer('memory')" style="text-align:left;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#fafafa;cursor:pointer;font-size:11px">
<div style="font-weight:600;color:var(--text)">Memory</div>
<div style="color:var(--muted)">Persistent knowledge store</div>
</button>
</div>
<div style="margin-top:8px;font-size:11px">
<a href="https://github.com/modelcontextprotocol/servers" target="_blank" style="color:var(--brand)">Browse all official servers →</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- PPT Template & Skin Selection -->
<div id="settings-panel-ppt" style="display:none;padding:14px 16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<div style="border:1px solid var(--line);border-radius:12px;padding:12px">
<div class="right-section-title" style="margin-bottom:8px">템플릿</div>
<div id="ppt-templates-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:8px"></div>
</div>
<div style="border:1px solid var(--line);border-radius:12px;padding:12px">
<div class="right-section-title" style="margin-bottom:8px">스킨 (배경)</div>
<div id="ppt-skins-grid" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px"></div>
</div>
</div>
<div style="margin-top:12px;padding:8px 10px;background:#f0f6ff;border:1px solid #bdd3f6;border-radius:8px;font-size:11px;color:#1e40af">
선택한 템플릿과 스킨은 프레젠테이션 제작 시 기본값으로 적용됩니다. 채팅에서 <code>template: "dark"</code>, <code>background: "ocean"</code> 등으로 개별 지정할 수도 있습니다.
</div>
</div>
<div id="settings-panel-context-viewer" style="display:none;padding:14px 16px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div class="right-section-title">사용자 컨텍스트 뷰어</div>
<button class="btn btn-sm" style="background:var(--panel-2);border:1px solid var(--line);color:var(--muted)" onclick="loadContextViewerTab()">새로고침</button>
</div>
<div id="cv-warn-banner" style="display:none;padding:8px 12px;background:#fff7ed;border:1px solid #fbbf24;border-radius:8px;font-size:12px;color:#92400e;margin-bottom:12px"></div>
<div id="cv-sessions-list" style="display:flex;flex-direction:column;gap:10px">
<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">로딩 중...</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--line)">
<button class="btn btn-sm" style="background:var(--panel-2);color:var(--muted);border:1px solid var(--line)" onclick="closeSettings()">취소</button>
<button class="btn btn-sm" onclick="saveSettings()">저장</button>
</div>
</div>
</div>
<script>
const API = window.location.origin || 'http://127.0.0.1:18789';
let ws = null;
let selectedJobId = null;
let allJobs = [];
let pollInterval = null;
let currentMode = 'chat';
let sidebarTab = 'jobs';
let useAgentMode = false;
const CHAT_SESSIONS_KEY_BASE = 'cherryclaw_chat_sessions_v1';
const AGENT_SESSION_KEY_BASE = 'cherryclaw_agent_session_id';
const THEME_KEY = 'cherryclaw_theme';
const TOKEN_KEY = 'smallclaw_token';
function getAuthToken() { try { return sessionStorage.getItem(TOKEN_KEY) || ''; } catch { return ''; } }
function setAuthToken(t) { try { t ? sessionStorage.setItem(TOKEN_KEY, t) : sessionStorage.removeItem(TOKEN_KEY); } catch {} }
function authHeaders(extra) { const t = getAuthToken(); const h = { ...(extra||{}) }; if (t) h['Authorization'] = 'Bearer ' + t; return h; }
function chatSessionsKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${CHAT_SESSIONS_KEY_BASE}__${u}`; }
function agentSessionKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${AGENT_SESSION_KEY_BASE}__${u}`; }
function generateSessionId() {
return (crypto?.randomUUID?.() || ('sess_' + Math.random().toString(36).slice(2)));
}
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
}
function setAgentSessionId(id) {
const next = String(id || '').trim() || generateSessionId();
agentSessionId = next;
localStorage.setItem(agentSessionKey(), next);
return next;
}
let agentSessionId = '';
let currentUser = null;
setAgentSessionId(generateSessionId()); // always fresh session on page load
checkAuth(); // Check authentication on page load
initTTSToggle();
initMicAutosendToggle();
// ── Server log panel ──────────────────────────────────────────────────────────
let logScrollLocked = true;
let logLastTotalCount = 0; // tracks server's monotonic counter, not ring size
function toggleLogScrollLock() {
logScrollLocked = !logScrollLocked;
const btn = document.getElementById('log-scroll-lock-btn');
if (btn) btn.style.opacity = logScrollLocked ? '1' : '0.4';
}
function clearLogPanel() {
const body = document.getElementById('sidebar-log-body');
if (body) body.innerHTML = '';
logLastTotalCount = 0;
}
function renderLogEntries(entries) {
const body = document.getElementById('sidebar-log-body');
if (!body) return;
const frag = document.createDocumentFragment();
for (const e of entries) {
const line = document.createElement('div');
line.className = 'log-line ' + (e.level === 'error' ? 'error' : e.level === 'warn' ? 'warn' : '');
const ts = document.createElement('span');
ts.className = 'log-ts';
ts.textContent = e.ts;
const msg = document.createElement('span');
msg.className = 'log-msg';
msg.textContent = e.msg;
line.appendChild(ts);
line.appendChild(msg);
frag.appendChild(line);
}
body.appendChild(frag);
if (logScrollLocked) body.scrollTop = body.scrollHeight;
}
async function pollServerLogs() {
try {
const data = await api('/api/logs');
const entries = data?.entries || [];
const totalCount = data?.totalCount ?? entries.length;
if (totalCount <= logLastTotalCount) return;
const newCount = totalCount - logLastTotalCount;
// Slice new entries from the end of the ring (handles wrap-around correctly)
const newEntries = entries.slice(Math.max(0, entries.length - newCount));
renderLogEntries(newEntries);
logLastTotalCount = totalCount;
} catch {}
}
setInterval(pollServerLogs, 2000);
pollServerLogs();
// ─────────────────────────────────────────────────────────────────────────────
// Chat state
let chatHistory = [];
let chatSessions = [];
let activeChatSessionId = null;
let sessionsEditMode = false;
let isThinking = false;
let streamingSessionId = null; // which session owns the active stream
let processLogEntries = [];
let currentTurnStartIndex = -1;
let lastAgentMode = '-';
let lastTurnKind = '-';
let settingsTab = 'system';
let liveTaskSteps = [];
let quickSearchRigor = 'verified';
let quickThinkingEffort = localStorage.getItem('cherryclaw_quick_thinking_effort') || 'standard';
let queuedPrompts = [];
const MAX_QUEUED_PROMPTS = 8;
let currentPreflightStatus = '';
let currentProgressLines = [];
let processLogAutoFollow = true;
let rightColumnAutoFollow = true;
// Agent Execution Tracking
let agentExecutionMap = new Map(); // Map<agentId, { name, status, task, logs: [] }>
let agentIdCounter = 0;
const AGENT_STATUS = { ACTIVE: 'active', COMPLETED: 'completed', PAUSED: 'paused' };
let agentsConfigList = [];
let selectedAgentId = '';
let agentMdEditor = null;
// (skills cache moved to skillsCache in v2 skills management section)
let lastHeartbeat = {
state: 'idle',
level: '',
current_step: '-',
retry_count: 0,
format_violation_count: 0,
message: '',
};
let lastHeartbeatLogSignature = '';
function renderQueuedPromptsPanel() {
const panel = document.getElementById('queued-prompts-panel');
const list = document.getElementById('queued-prompts-list');
const title = document.getElementById('queued-prompts-title');
if (!panel || !list || !title) return;
const count = queuedPrompts.length;
if (count === 0) {
panel.style.display = 'none';
list.innerHTML = '';
return;
}
panel.style.display = 'block';
title.textContent = `Queued prompts (${count})`;
list.innerHTML = queuedPrompts.map((q, i) => `
<div class="queued-item">
<div class="queued-item-text">${escHtml(q)}</div>
<button class="queued-item-btn" onclick="removeQueuedPrompt(${i})">Remove</button>
</div>
`).join('');
}
function removeQueuedPrompt(index) {
if (!Number.isInteger(index) || index < 0 || index >= queuedPrompts.length) return;
queuedPrompts.splice(index, 1);
updateQueuedPromptUI();
}
function clearQueuedPrompts() {
if (!queuedPrompts.length) return;
queuedPrompts = [];
addProcessEntry('info', 'Cleared queued prompts.');
updateQueuedPromptUI();
}
function updateQueuedPromptUI() {
const badge = document.getElementById('queued-prompts-indicator');
const sendBtn = document.getElementById('send-btn');
const input = document.getElementById('chat-input');
const count = queuedPrompts.length;
if (badge) {
if (count > 0) {
badge.style.display = 'inline';
badge.textContent = `Queued: ${count}`;
} else {
badge.style.display = 'none';
badge.textContent = '';
}
}
if (sendBtn) {
sendBtn.disabled = false;
sendBtn.title = isThinking ? `Queue next prompt${count ? ` (${count} queued)` : ''}` : 'Send';
}
if (input) {
input.placeholder = isThinking
? 'Assistant is responding. Press Enter/Send to queue next prompt.'
: 'Type a message... (Enter to send, Shift+Enter for newline)';
}
renderQueuedPromptsPanel();
}
function saveChatSessions() {
localStorage.setItem(chatSessionsKey(), JSON.stringify(chatSessions));
}
function makeSessionTitle(history) {
const firstUser = (history || []).find(m => m.role === 'user');
if (!firstUser) return 'New chat';
// Strip markdown image/link syntax for clean session title
let text = firstUser.content
.replace(/!\[[^\]]*\]\([^)]+\)/g, '[image]')
.replace(/\[[^\]]+\]\([^)]+\)/g, (m) => m.replace(/\[([^\]]*)\].*/, '$1'))
.replace(/```[\s\S]*?```/g, '')
.replace(/\n/g, ' ')
.trim();
return text.slice(0, 42) || 'New chat';
}
function loadChatSessions() {
const key = chatSessionsKey();
try {
const raw = localStorage.getItem(key);
if (raw) {
chatSessions = JSON.parse(raw);
} else {
// Migrate from old non-namespaced key once, then delete it so other users can't see it.
const oldRaw = localStorage.getItem(CHAT_SESSIONS_KEY_BASE);
if (oldRaw) {
chatSessions = JSON.parse(oldRaw);
localStorage.setItem(key, oldRaw);
localStorage.removeItem(CHAT_SESSIONS_KEY_BASE);
} else {
chatSessions = [];
}
}
} catch {
chatSessions = [];
}
if (!Array.isArray(chatSessions)) chatSessions = [];
if (chatSessions.length === 0) {
const id = crypto?.randomUUID?.() || ('chat_' + Math.random().toString(36).slice(2));
chatSessions.push({ id, title: 'New chat', history: [], processLog: [], createdAt: Date.now(), updatedAt: Date.now() });
activeChatSessionId = id;
setAgentSessionId(id);
saveChatSessions();
} else if (!activeChatSessionId || !chatSessions.some(s => s.id === activeChatSessionId)) {
activeChatSessionId = chatSessions[0].id;
setAgentSessionId(activeChatSessionId);
}
if (!agentSessionId || agentSessionId !== activeChatSessionId) setAgentSessionId(activeChatSessionId);
syncActiveChat();
}
function syncActiveChat() {
const sess = chatSessions.find(s => s.id === activeChatSessionId);
chatHistory = sess ? (sess.history || []) : [];
processLogEntries = sess ? (sess.processLog || []) : [];
liveTaskSteps = []; // clear live steps so a different session never inherits in-progress tool display
// Mark session as read when opening it
if (sess) sess.unread = false;
renderChatMessages();
renderProcessLog();
renderLiveStepsPanel();
renderSessionsList();
updateStats([]);
loadAgentSessionContext();
}
function persistActiveChat() {
const idx = chatSessions.findIndex(s => s.id === activeChatSessionId);
if (idx === -1) return;
chatSessions[idx].history = chatHistory;
chatSessions[idx].processLog = processLogEntries;
chatSessions[idx].title = makeSessionTitle(chatHistory);
chatSessions[idx].updatedAt = Date.now();
saveChatSessions();
renderSessionsList();
updateStats([]);
}
function newChatSession() {
const id = crypto?.randomUUID?.() || ('chat_' + Math.random().toString(36).slice(2));
chatSessions.unshift({ id, title: 'New chat', history: [], processLog: [], createdAt: Date.now(), updatedAt: Date.now() });
activeChatSessionId = id;
setAgentSessionId(id);
syncActiveChat();
}
function toggleSessionsEditMode() {
sessionsEditMode = !sessionsEditMode;
const btn = document.getElementById('sessions-edit-btn');
if (btn) btn.textContent = sessionsEditMode ? '완료' : '편집';
renderSessionsList();
}
function deleteChatSession(id, ev) {
if (ev) ev.stopPropagation();
const idx = chatSessions.findIndex(s => s.id === id);
if (idx === -1) return;
chatSessions.splice(idx, 1);
if (chatSessions.length === 0) {
const newId = crypto?.randomUUID?.() || ('chat_' + Math.random().toString(36).slice(2));
chatSessions.push({ id: newId, title: 'New chat', history: [], processLog: [], createdAt: Date.now(), updatedAt: Date.now() });
activeChatSessionId = newId;
setAgentSessionId(newId);
} else if (activeChatSessionId === id) {
activeChatSessionId = chatSessions[0].id;
setAgentSessionId(activeChatSessionId);
}
saveChatSessions();
syncActiveChat();
}
function openSession(id) {
activeChatSessionId = id;
setAgentSessionId(id);
syncActiveChat();
}
function markSessionUnread(sessionId) {
const sess = chatSessions.find(s => s.id === sessionId);
if (sess && sessionId !== activeChatSessionId) {
sess.unread = true;
renderSessionsList();
}
}
// ---- Mode switching ----
function setMode(mode) {
if (mode !== 'chat' && mode !== 'bgtasks' && mode !== 'schedule') mode = 'chat';
currentMode = mode;
// Toggle buttons
document.getElementById('btn-chat').classList.toggle('active', mode === 'chat');
const bgtBtn = document.getElementById('btn-bgtasks');
if (bgtBtn) bgtBtn.classList.toggle('active', mode === 'bgtasks');
const schedBtn = document.getElementById('btn-schedule');
if (schedBtn) schedBtn.classList.toggle('active', mode === 'schedule');
// Toggle views
document.getElementById('chat-view').style.display = mode === 'chat' ? 'flex' : 'none';
const bgtView = document.getElementById('bgtasks-view');
if (bgtView) bgtView.style.display = mode === 'bgtasks' ? 'flex' : 'none';
const schedView = document.getElementById('schedule-view');
if (schedView) schedView.style.display = mode === 'schedule' ? 'flex' : 'none';
// Toggle sidebar + right panel visibility
const aside = document.querySelector('aside');
const rightPanel = document.getElementById('right-panel');
if (aside) aside.style.display = mode === 'chat' ? '' : 'none';
if (rightPanel) rightPanel.style.display = mode === 'chat' ? '' : 'none';
// Load data when switching to respective views
if (mode === 'bgtasks') refreshBgTasks();
if (mode === 'schedule') refreshSchedules();
}
// --- Heartbeat / Tasks System -----------------------------------------------
let hbJobs = [];
let hbConfig = { enabled: false, intervalMinutes: 30, activeHoursStart: 8, activeHoursEnd: 22 };
let editingTaskId = null;
async function loadTasksFromServer() {
try {
const data = await api('/api/tasks');
if (data.success) {
hbJobs = data.jobs || [];
hbConfig = data.config || hbConfig;
renderHeartbeatPage();
}
} catch (err) { console.error('[HB] Load failed:', err); }
}
function renderHeartbeatPage() {
// Update topbar
const dot = document.getElementById('hb-dot');
const label = document.getElementById('hb-label');
const meta = document.getElementById('hb-meta');
const btn = document.getElementById('hb-toggle-btn');
if (dot) dot.className = 'hb-status-dot ' + (hbConfig.enabled ? 'active' : '');
if (label) label.textContent = hbConfig.enabled ? '하트비트 ON' : '하트비트 OFF';
if (meta) meta.textContent = `Every ${hbConfig.intervalMinutes}m \u00b7 ${hbConfig.activeHoursStart}:00\u2013${hbConfig.activeHoursEnd}:00`;
if (btn) { btn.textContent = hbConfig.enabled ? 'Pause' : 'Enable'; btn.className = 'hb-btn' + (hbConfig.enabled ? ' danger' : ' primary'); }
// Split jobs into sections
const running = hbJobs.filter(j => j.status === 'running');
const queued = hbJobs.filter(j => j.enabled && j.status !== 'running' && j.status !== 'completed' && j.status !== 'paused').sort((a,b) => a.priority - b.priority);
const paused = hbJobs.filter(j => j.status === 'paused');
const completed = hbJobs.filter(j => j.status === 'completed' || j.type === 'one-shot' && j.lastRun).slice(0, 15);
const empty = document.getElementById('hb-empty');
if (empty) empty.style.display = hbJobs.length === 0 ? '' : 'none';
renderHBSection('hb-section-running', 'hb-running-cards', running, 'running');
renderHBSection('hb-section-queued', 'hb-queued-cards', [...queued, ...paused], 'queued');
renderHBSection('hb-section-scheduled', 'hb-scheduled-cards', queued.filter(j => j.schedule), 'scheduled');
renderHBSection('hb-section-completed', 'hb-completed-cards', completed, 'completed');
}
function renderHBSection(sectionId, cardsId, jobs, sectionType) {
const section = document.getElementById(sectionId);
const container = document.getElementById(cardsId);
if (!section || !container) return;
section.style.display = jobs.length > 0 ? '' : 'none';
container.innerHTML = jobs.map(j => renderTaskCard(j, sectionType)).join('');
}
function renderTaskCard(job, sectionType) {
const statusClass = job.status === 'running' ? 'running' : job.status === 'completed' ? 'completed' : job.status === 'paused' ? 'paused' : '';
const scheduleText = job.schedule ? cronToHuman(job.schedule) : (job.runAt ? 'One-shot: ' + new Date(job.runAt).toLocaleString() : 'Manual');
const lastRunText = job.lastRun ? timeAgo(new Date(job.lastRun).getTime()) : 'Never';
const nextRunText = job.nextRun ? new Date(job.nextRun).toLocaleTimeString('en-US', {hour:'2-digit',minute:'2-digit'}) : '-';
const resultSnippet = job.lastResult ? (job.lastResult.length > 60 ? job.lastResult.slice(0,60)+'...' : job.lastResult) : '';
const isOk = /^\s*HEARTBEAT_OK\s*$/i.test(job.lastResult || '');
return `<div class="hb-card ${statusClass}" data-job-id="${job.id}">
<div class="hb-card-drag">${sectionType === 'completed' ? '' : '\u2261'}</div>
<div class="hb-card-body">
<div class="hb-card-name">${escHtml(job.name)}</div>
<div class="hb-card-meta">
<span>${scheduleText}</span>
<span>Next: ${nextRunText}</span>
<span>Last: ${lastRunText}</span>
${resultSnippet && !isOk ? `<span style="color:var(--text);font-weight:600">${escHtml(resultSnippet)}</span>` : ''}
${isOk ? '<span style="color:var(--ok)">\u2713 OK</span>' : ''}
</div>
</div>
<div class="hb-card-actions">
${job.status !== 'running' ? `<button class="hb-card-btn" onclick="runTaskNow('${job.id}')" title="Run now">\u25b6</button>` : '<span style="font-size:11px;color:var(--brand)">Running...</span>'}
<button class="hb-card-btn" onclick="editTask('${job.id}')" title="Edit">\u270e</button>
<button class="hb-card-btn" onclick="toggleTaskPause('${job.id}')" title="${job.status === 'paused' ? 'Resume' : 'Pause'}">${job.status === 'paused' ? '\u25b6' : '\u23f8'}</button>
<button class="hb-card-btn danger" onclick="deleteTask('${job.id}')" title="Delete">\u2715</button>
${job.sessionId ? `<button class="hb-card-btn" onclick="viewTaskSession('${job.sessionId}')" title="View response">\u2192</button>` : ''}
</div>
</div>`;
}
function cronToHuman(expr) {
if (!expr) return 'Manual';
const p = expr.split(/\s+/);
if (p.length !== 5) return expr;
const [min, hr, dom, , dow] = p;
if (min.startsWith('*/')) return `Every ${min.slice(2)}m`;
if (hr === '*') return `Every minute`;
if (dom === '*' && dow === '*') return `Daily at ${hr}:${min.padStart(2,'0')}`;
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
if (dow !== '*') return `${days[Number(dow)] || dow} at ${hr}:${min.padStart(2,'0')}`;
return expr;
}
async function toggleHeartbeatEnabled() {
try {
const data = await api('/api/tasks/config', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ enabled: !hbConfig.enabled }) });
if (data.success) { hbConfig = data.config; renderHeartbeatPage(); }
} catch (err) { console.error('[HB] Toggle failed:', err); }
}
async function runTaskNow(id) {
try { await api(`/api/tasks/${id}/run`, { method: 'POST' }); } catch {}
}
async function deleteTask(id) {
if (!confirm('Delete this task?')) return;
try {
await api(`/api/tasks/${id}`, { method: 'DELETE' });
hbJobs = hbJobs.filter(j => j.id !== id);
renderHeartbeatPage();
} catch {}
}
async function toggleTaskPause(id) {
const job = hbJobs.find(j => j.id === id);
if (!job) return;
const newStatus = job.status === 'paused' ? 'scheduled' : 'paused';
try {
const data = await api(`/api/tasks/${id}`, { method: 'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ status: newStatus, enabled: newStatus !== 'paused' }) });
if (data.success) { Object.assign(job, data.job); renderHeartbeatPage(); }
} catch {}
}
function viewTaskSession(sessionId) {
// Switch to chat mode and open the automated session
const session = chatSessions.find(s => s.id === sessionId);
if (session) {
setMode('chat');
openSession(sessionId);
}
}
function openTaskModal(taskId) {
editingTaskId = taskId || null;
const job = taskId ? hbJobs.find(j => j.id === taskId) : null;
const modal = document.getElementById('task-modal');
modal.style.display = '';
modal.innerHTML = `<div class="hb-modal-overlay" onclick="if(event.target===this)closeTaskModal()">
<div class="hb-modal">
<h3>${job ? 'Edit Task' : 'Add Task'}</h3>
<label>Name</label>
<input id="tm-name" value="${job ? escHtml(job.name) : ''}" placeholder="e.g. Check inbox for urgent emails">
<label>Prompt (what the LLM should do)</label>
<textarea id="tm-prompt" placeholder="e.g. Search for any urgent emails or messages I should know about">${job ? escHtml(job.prompt) : ''}</textarea>
<label>Type</label>
<select id="tm-type">
<option value="recurring" ${!job || job.type==='recurring' ? 'selected' : ''}>Recurring (cron)</option>
<option value="one-shot" ${job?.type==='one-shot' ? 'selected' : ''}>One-shot (run once)</option>
</select>
<label>Schedule (cron expression)</label>
<input id="tm-schedule" value="${job?.schedule || '*/30 * * * *'}" placeholder="*/30 * * * * (every 30 min)">
<div style="font-size:10px;color:var(--muted);margin-top:2px">Examples: */30 * * * * (every 30m), 0 9 * * * (daily 9am), 0 9 * * 1 (Mon 9am)</div>
<div class="hb-modal-actions">
<button class="hb-btn" onclick="closeTaskModal()">Cancel</button>
<button class="hb-btn primary" onclick="saveTask()">Save</button>
</div>
</div>
</div>`;
}
function editTask(id) { openTaskModal(id); }
function closeTaskModal() {
document.getElementById('task-modal').style.display = 'none';
editingTaskId = null;
}
async function saveTask() {
const name = document.getElementById('tm-name').value.trim();
const prompt = document.getElementById('tm-prompt').value.trim();
const type = document.getElementById('tm-type').value;
const schedule = document.getElementById('tm-schedule').value.trim();
if (!name || !prompt) { alert('Name and prompt are required'); return; }
try {
if (editingTaskId) {
const data = await api(`/api/tasks/${editingTaskId}`, { method: 'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name, prompt, type, schedule }) });
if (data.success) {
const idx = hbJobs.findIndex(j => j.id === editingTaskId);
if (idx >= 0) hbJobs[idx] = data.job;
}
} else {
const data = await api('/api/tasks', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name, prompt, type, schedule }) });
if (data.success) hbJobs.push(data.job);
}
closeTaskModal();
renderHeartbeatPage();
} catch (err) { alert('Failed to save: ' + err.message); }
}
// ---- Sidebar tabs ----
function setSidebarTab(tab) {
sidebarTab = tab;
document.getElementById('tab-jobs').classList.toggle('active', tab === 'jobs');
document.getElementById('tab-skills').classList.toggle('active', tab === 'skills');
document.getElementById('sidebar-jobs').style.display = tab === 'jobs' ? 'flex' : 'none';
document.getElementById('sidebar-skills').style.display = tab === 'skills' ? 'flex' : 'none';
if (tab === 'skills') { initSkillsUserSelector(); loadInstalledSkills(); }
}
// ---- Agent mode toggle ----
function updateAgentMode() {
useAgentMode = document.getElementById('agent-mode-toggle').checked;
document.getElementById('agent-mode-label').textContent = useAgentMode ? '에이전트' : '채팅';
document.getElementById('agent-mode-badge').classList.toggle('visible', useAgentMode);
document.getElementById('chat-input').placeholder = useAgentMode
? 'Ask agent to do something... (has access to tools)'
: 'Type a message... (Enter to send, Shift+Enter for newline)';
}
// ---- Chat ----
function renderThinkBlock(thinking) {
if (!thinking || !thinking.trim()) return '';
const id = 'think_' + Math.random().toString(36).slice(2);
const wordCount = thinking.trim().split(/\s+/).length;
return `
<div class="think-block">
<button class="think-toggle" id="${id}_btn" onclick="toggleThink('${id}')">
<span class="think-icon">...</span>
<span>Thought process</span>
<span style="margin-left:auto;opacity:0.5">${wordCount} words</span>
</button>
<div class="think-content" id="${id}_body">${escHtml(thinking)}</div>
</div>
`;
}
function toggleThink(id) {
const btn = document.getElementById(id + '_btn');
const body = document.getElementById(id + '_body');
if (!btn || !body) return;
const open = body.style.display !== 'none' && body.style.display !== '';
body.style.display = open ? 'none' : 'block';
btn.classList.toggle('open', !open);
}
function renderReactSteps(steps) {
if (!steps || steps.length === 0) return '';
const toolSteps = steps.filter(s => s.action);
if (toolSteps.length === 0) return '';
const getSearchDiagnostics = (s) => s?.toolData?.search_diagnostics || s?.diagnostics || null;
const extractUrls = (s, resultText) => {
const fromToolData = Array.isArray(s?.toolData?.results)
? s.toolData.results.map(r => String(r?.url || '').trim()).filter(u => /^https?:\/\//i.test(u))
: [];
const fromText = String(resultText || '').match(/https?:\/\/[^\s)]+/g) || [];
const merged = [...fromToolData, ...fromText.map(u => String(u).trim())];
const out = [];
const seen = new Set();
for (const u of merged) {
if (!u || seen.has(u)) continue;
seen.add(u);
out.push(u);
if (out.length >= 5) break;
}
return out;
};
const providerSummary = (diag) => {
if (!diag || !Array.isArray(diag.attempted) || !diag.attempted.length) return '';
return diag.attempted.map(a => {
const p = String(a.provider || '').toLowerCase();
const status = String(a.status || '').toLowerCase();
if (status === 'success') {
const count = Number.isFinite(a.result_count) ? `, ${a.result_count} result${a.result_count === 1 ? '' : 's'}` : '';
return `${p}=success${count}`;
}
if (status === 'skipped') return `${p}=skipped${a.reason ? ` (${a.reason})` : ''}`;
return `${p}=failed${a.reason ? ` (${a.reason})` : ''}`;
}).join(' | ');
};
const id = 'steps_' + Math.random().toString(36).slice(2);
const items = toolSteps.map(s => {
const resultText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || '');
const diag = String(s.action || '') === 'web_search' ? getSearchDiagnostics(s) : null;
const query = String(diag?.query || s?.params?.query || '').trim();
const selectedProvider = String(diag?.selected_provider || s?.toolData?.provider || '').toLowerCase();
const providers = diag ? providerSummary(diag) : '';
const urls = String(s.action || '') === 'web_search' ? extractUrls(s, resultText) : [];
return `
<div class="react-step">
${s.thought ? `<div class="react-step-thought">${escHtml(s.thought.slice(0, 120))}</div>` : ''}
<div class="react-step-action">${escHtml(s.action)}</div>
${resultText ? `<div class="react-step-result${resultText.startsWith('ERROR') ? ' error' : ''}">${escHtml(resultText.slice(0, 160))}${resultText.length > 160 ? '…' : ''}</div>` : ''}
${diag ? `
<div style="margin-top:6px;font-size:10px;color:var(--muted);line-height:1.5">
${query ? `<div><b>query</b>: ${escHtml(query)}</div>` : ''}
${selectedProvider ? `<div><b>provider hit</b>: ${escHtml(selectedProvider)}</div>` : ''}
${providers ? `<div><b>providers</b>: ${escHtml(providers)}</div>` : ''}
</div>
` : ''}
${String(s.action || '') === 'web_search' && !diag ? `
<div style="margin-top:6px;font-size:10px;color:var(--muted);line-height:1.5">
${selectedProvider ? `<div><b>provider hit</b>: ${escHtml(selectedProvider)}</div>` : ''}
</div>
` : ''}
${urls.length ? `
<div style="margin-top:6px;font-size:10px;line-height:1.6">
<div style="color:var(--muted);margin-bottom:2px"><b>sources</b>:</div>
${urls.slice(0, 3).map((u, i) => `<div>${i + 1}. <a href="${escHtml(u)}" target="_blank" rel="noopener noreferrer">${escHtml(u)}</a></div>`).join('')}
</div>
` : ''}
</div>
`;
}).join('');
return `
<div class="react-steps-toggle" onclick="toggleSteps('${id}')">
? ${toolSteps.length} tool step${toolSteps.length !== 1 ? 's' : ''} used
</div>
<div class="react-steps" id="${id}" style="display:none">${items}</div>
`;
}
function toggleSteps(id) {
const el = document.getElementById(id);
if (!el) return;
const visible = el.style.display !== 'none';
el.style.display = visible ? 'none' : 'block';
el.previousElementSibling.textContent = `${visible ? '?' : '?'} ${el.querySelectorAll('.react-step').length} tool step${el.querySelectorAll('.react-step').length !== 1 ? 's' : ''} used`;
}
function renderFileDownloads(steps, processEntries) {
const seenUrls = new Set();
const buttons = [];
const URL_RE = /\/api\/files\/[^\s)"'\]]+/g;
function extractUrls(text) {
const urls = [];
let m;
while ((m = URL_RE.exec(text)) !== null) urls.push(m[0]);
return urls;
}
// Pre-mark URLs already present in msg.content so tool-result duplicates are suppressed.
// renderMarkdown() handles content links — we must not render a second button for the same URL.
if (arguments.length > 2 && arguments[2]) {
const contentText = String(arguments[2]);
if (contentText.indexOf('/api/files/') !== -1) {
for (const u of extractUrls(contentText)) seenUrls.add(u);
URL_RE.lastIndex = 0;
}
}
// Collect /api/files/ URLs from tool results only (not content — already handled above)
const allUrls = [];
// 1. msg.steps (legacy step events)
if (steps && steps.length) {
for (const s of steps) {
const resultText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || '');
allUrls.push(...extractUrls(resultText));
}
}
// 2. msg.processEntries (stored per-message in chatHistory)
if (processEntries && processEntries.length) {
for (const e of processEntries) {
const txt = String(e.content || '');
if (txt.indexOf('/api/files/') !== -1) allUrls.push(...extractUrls(txt));
}
}
const _imgExts = /\.(jpg|jpeg|png|gif|webp|heic|heif|bmp|svg|avif|tiff|tif)(\?|$)/i;
for (const rawUrl of allUrls) {
if (seenUrls.has(rawUrl)) continue;
seenUrls.add(rawUrl);
const decoded = (() => { try { return decodeURIComponent(rawUrl); } catch { return rawUrl; } })();
if (_imgExts.test(decoded)) continue; // images shown inline, no download button needed
const filename = decoded.split('/').pop() || 'download';
const isPptx = decoded.toLowerCase().indexOf('.pptx') !== -1;
if (isPptx) {
const previewId = 'pptx-chat-' + Math.random().toString(36).substr(2, 9);
buttons.push(`
<div class="pptx-file-row" style="margin:6px 0">
<a href="${escHtml(rawUrl)}" onclick="downloadFile(event, this)" class="download-btn" title="Download ${escHtml(filename)}">
<span class="download-icon">&#11015;</span> ${escHtml(filename)}</a>
<button class="pptx-preview-btn" onclick="togglePptxPreview('${escHtml(rawUrl)}', '${previewId}', this)" title="Preview slides">&#9664; Preview</button>
<div id="${previewId}" class="pptx-preview-strip" style="display:none"></div>
</div>`);
} else {
buttons.push(`
<a href="${escHtml(rawUrl)}" onclick="downloadFile(event, this)" class="download-btn" title="Download ${escHtml(filename)}">
<span class="download-icon">&#11015;</span> ${escHtml(filename)}</a>`);
}
}
return buttons.length ? `<div style="margin-top:8px">${buttons.join(' ')}</div>` : '';
}
function renderAssistantContent(content) {
const text = String(content || '');
const hasStaged = /(^|\n)(Initial chat|Execution result|Final chat):\s*/i.test(text);
if (!hasStaged) return `<div class="msg-content">${renderMarkdown(text)}</div>`;
const chunks = text
.split(/\n\s*---\s*\n/g)
.map(s => String(s || '').trim())
.filter(Boolean);
const html = chunks.map((chunk, idx) => {
const m = chunk.match(/^(Initial chat|Execution result|Final chat):\s*\n?/i);
const title = m ? m[1] : `Section ${idx + 1}`;
const body = m ? chunk.slice(m[0].length).trim() : chunk;
return `
<div class="msg-stage">
<div class="msg-stage-title">${escHtml(title)}</div>
<div class="msg-content">${renderMarkdown(body)}</div>
</div>
`;
}).join('');
return `<div class="msg-staged">${html}</div>`;
}
function renderArtifacts(artifacts) {
const rows = Array.isArray(artifacts) ? artifacts : [];
if (!rows.length) return '';
const cards = rows.map((a, idx) => {
const type = String(a?.type || '').trim();
const title = String(a?.title || type || `Artifact ${idx + 1}`).trim();
const status = String(a?.status || 'ok').toLowerCase();
const pathVal = String(a?.path || '').trim();
const fromPath = String(a?.from_path || '').trim();
const toPath = String(a?.to_path || '').trim();
const summary = String(a?.summary || '').trim();
const preview = String(a?.preview || '').trim();
const files = Array.isArray(a?.files) ? a.files.slice(0, 12).map(x => String(x || '')).filter(Boolean) : [];
const dirs = Array.isArray(a?.directories) ? a.directories.slice(0, 8).map(x => String(x || '')).filter(Boolean) : [];
const locationTarget = toPath || pathVal || fromPath;
return `
<div class="artifact-card">
<div class="artifact-head">
<div class="artifact-title">${escHtml(title)}</div>
<div class="artifact-status ${escHtml(status)}">${escHtml(status)}</div>
</div>
${pathVal ? `<div class="artifact-row"><b>Path:</b> <code>${escHtml(pathVal)}</code></div>` : ''}
${fromPath ? `<div class="artifact-row"><b>From:</b> <code>${escHtml(fromPath)}</code></div>` : ''}
${toPath ? `<div class="artifact-row"><b>To:</b> <code>${escHtml(toPath)}</code></div>` : ''}
${summary ? `<div class="artifact-row">${escHtml(summary)}</div>` : ''}
${files.length ? `<div class="artifact-row"><b>Files:</b> ${escHtml(files.join(', '))}${Array.isArray(a?.files) && a.files.length > files.length ? ' ...' : ''}</div>` : ''}
${dirs.length ? `<div class="artifact-row"><b>Directories:</b> ${escHtml(dirs.join(', '))}${Array.isArray(a?.directories) && a.directories.length > dirs.length ? ' ...' : ''}</div>` : ''}
${preview ? `<div class="artifact-preview">${escHtml(preview)}</div>` : ''}
${locationTarget ? `
<div class="artifact-actions">
<button class="artifact-btn" onclick="openInFileLocation('${escHtml(locationTarget).replace(/'/g, '&#39;')}')">Open In File Location</button>
</div>
` : ''}
</div>
`;
}).join('');
return `<div class="artifact-list">${cards}</div>`;
}
async function openInFileLocation(targetPath) {
const p = String(targetPath || '').trim();
if (!p) return;
try {
const res = await fetch('/api/open-path', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ path: p }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data?.ok) {
throw new Error(String(data?.error || `HTTP ${res.status}`));
}
addProcessEntry('info', `Opened in file location: ${p}`);
} catch (err) {
addProcessEntry('error', `Open path failed: ${String(err?.message || err)}`);
}
}
function renderChatMessages() {
const container = document.getElementById('chat-messages');
if (chatHistory.length === 0) {
container.innerHTML = `
<div class="chat-welcome" id="chat-welcome">
<div class="chat-welcome-icon"><img src="cherry_logo.png" alt="🍒"></div>
<h2>CherryClaw</h2>
<p>Chat directly with your local model. No API keys, no cloud — just you and the model.</p>
<div class="hint">Enable Agent mode when you want planning + tool execution in this chat.</div>
</div>`;
return;
}
container.innerHTML = chatHistory.map((msg, idx) => {
const pinClass = contextPinMode ? ' pin-selectable' : '';
const selectedClass = (contextPinMode && pinnedMessageIndices.has(idx)) ? ' pin-selected' : '';
// Also show green for confirmed pins even outside pin mode
const confirmedClass = (!contextPinMode && confirmedPins.some(p => p.content === msg.content && p.role === (msg.role === 'user' ? 'user' : 'assistant'))) ? ' pin-selected' : '';
const clickHandler = contextPinMode ? ` onclick="togglePinMessage(${idx})"` : '';
if (msg.role === 'widget') {
return `<div class="msg widget${pinClass}${selectedClass}${confirmedClass}"${clickHandler}><div class="msg-content">${msg.content}</div></div>`;
}
return `
<div class="msg ${msg.role}${pinClass}${selectedClass}${confirmedClass}"${clickHandler}>
<div class="msg-avatar">${msg.role === 'user' ? 'U' : '<img src="cherry_logo.png" style="width:48px;height:48px;border-radius:6px">'}</div>
<div class="msg-body">
<div class="msg-role">${msg.role === 'user' ? 'You' : (msg.mode === 'agentic' ? 'CherryClaw (Agent)' : 'CherryClaw')}${msg.role === 'ai' ? ` <button class="tts-btn" onclick="playTTSMsg(this,${idx})" title="음성으로 듣기">🔊</button>` : ''}</div>
${msg.role === 'ai' ? renderAssistantContent(msg.content) : `<div class="msg-content">${renderMarkdown(msg.display !== undefined ? msg.display : msg.content, { hideDownload: true })}</div>`}
${msg.role === 'ai' ? renderArtifacts(msg.artifacts) : ''}
${msg.role === 'ai' ? renderFileDownloads(msg.steps, msg.processEntries, msg.content) : ''}
${msg.role === 'ai' && msg.processEntries && msg.processEntries.length ? renderProcessPill(msg.processEntries) : ''}
${msg.role === 'ai' ? renderThinkBlock(msg.thinking) : ''}
${msg.role === 'ai' ? renderReactSteps(msg.steps) : ''}
</div>
</div>`;
}).join('');
validateDownloadLinks(container);
if (isThinking && streamingSessionId === activeChatSessionId) {
const progressHtml = currentProgressLines.length
? `<div style="margin:6px 0 8px 0;font-size:11px;line-height:1.6;color:var(--muted)">
${currentProgressLines.map((l) => `<div>• ${escHtml(l)}</div>`).join('')}
</div>`
: '';
container.innerHTML += `
<div class="msg ai" id="thinking-msg">
<div class="msg-avatar"><img src="cherry_logo.png" style="width:48px;height:48px;border-radius:6px"></div>
<div class="msg-body">
<div class="msg-role">CherryClaw${useAgentMode ? ' (Agent)' : ''}</div>
${currentPreflightStatus ? `<div class="msg-content" style="margin-bottom:6px;color:#26487e">${escHtml(currentPreflightStatus)}</div>` : ''}
${progressHtml}
<div class="thinking">
<div class="thinking-dot"></div>
<div class="thinking-dot"></div>
<div class="thinking-dot"></div>
</div>
<div style="margin-top:8px">
<button class="skill-install-btn" style="font-size:10px;padding:3px 9px" onclick="toggleCurrentProcess()">Process</button>
<div id="current-turn-process" style="display:none;margin-top:8px;border:1px solid var(--line);border-radius:10px;background:var(--panel-2);padding:8px;max-height:220px;overflow:auto;font-size:11px;line-height:1.6"></div>
</div>
</div>
</div>`;
}
container.scrollTop = container.scrollHeight;
}
function pushProgressLine(line) {
const txt = String(line || '').trim();
if (!txt) return;
const last = currentProgressLines[currentProgressLines.length - 1] || '';
if (last === txt) return;
currentProgressLines.push(txt);
if (currentProgressLines.length > 8) currentProgressLines = currentProgressLines.slice(-8);
if (isThinking) renderChatMessages();
}
// ---- Process log ----
function renderProcessPill(entries) {
const id = 'proc_' + Math.random().toString(36).slice(2);
return `
<div style="margin-top:8px">
<button class="skill-install-btn" style="font-size:10px;padding:3px 9px" onclick="togglePL('${id}')">Process</button>
<div id="${id}" style="display:none;margin-top:8px;border:1px solid var(--line);border-radius:10px;background:var(--panel-2);padding:8px;max-height:220px;overflow:auto;font-size:11px;line-height:1.6">
${formatProcessLines(entries)}
</div>
</div>
`;
}
function formatProcessLines(entries) {
if (!entries || entries.length === 0) return '<div style="color:var(--muted)">No process details.</div>';
const TYPE_COLORS = {
user: 'var(--text)', think: '#388bfd', tool: '#e3b341',
result: '#bc8cff', error: 'var(--red)', final: '#56d364',
warn: 'var(--yellow)', step: '#56d364', info: '#79c0ff',
split: 'var(--accent2)', synth: 'var(--accent2)',
};
return entries.map(e => {
const rawType = String(e.type || 'info');
const typeLabel = escHtml(rawType).toUpperCase();
const typeColor = TYPE_COLORS[rawType] || 'var(--muted)';
const rawContent = String(e.content || '');
// Render markdown for result entries so download links show as clickable buttons
const content = rawType === 'result' ? renderMarkdown(rawContent) : escHtml(rawContent);
return `<div style="margin-bottom:4px"><span style="color:var(--muted)">[${escHtml(String(e.ts || ''))}]</span> <span style="color:${typeColor};font-weight:600">${typeLabel}</span> ${content}</div>`;
}).join('');
}
function toggleCurrentProcess() {
const panel = document.getElementById('current-turn-process');
if (!panel) return;
const open = panel.style.display !== 'none';
panel.style.display = open ? 'none' : 'block';
if (!open) {
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : [];
panel.innerHTML = formatProcessLines(turnEntries);
}
}
function isNearBottom(el, threshold = 40) {
if (!el) return true;
return (el.scrollHeight - (el.scrollTop + el.clientHeight)) <= threshold;
}
function setupProcessAndRightScrollTracking() {
const processEl = document.getElementById('process-log');
if (processEl && !processEl.dataset.scrollBound) {
processEl.dataset.scrollBound = '1';
processEl.addEventListener('scroll', () => {
processLogAutoFollow = isNearBottom(processEl, 36);
});
}
const rightEl = document.getElementById('right-panel');
if (rightEl && !rightEl.dataset.scrollBound) {
rightEl.dataset.scrollBound = '1';
rightEl.addEventListener('scroll', () => {
rightColumnAutoFollow = isNearBottom(rightEl, 28);
});
}
}
function maybeAutoScrollRightColumn(force = false) {
const rightEl = document.getElementById('right-panel');
if (!rightEl) return;
if (force || rightColumnAutoFollow || isThinking) {
rightEl.scrollTop = rightEl.scrollHeight;
}
}
function updateHeartbeatUI() {
const stateEl = document.getElementById('r-heartbeat-state');
const metaEl = document.getElementById('r-heartbeat-meta');
if (stateEl) {
const state = String(lastHeartbeat.state || 'idle').toLowerCase();
stateEl.textContent = state || 'idle';
stateEl.style.color = state === 'running' || state === 'retrying'
? 'var(--warn)'
: (state === 'stalled' ? 'var(--err)' : 'var(--text)');
}
if (metaEl) {
const step = String(lastHeartbeat.current_step || '-');
const retries = Number(lastHeartbeat.retry_count || 0);
const fv = Number(lastHeartbeat.format_violation_count || 0);
const lvl = String(lastHeartbeat.level || '').toLowerCase();
const lvlTag = lvl ? ` ${lvl}` : '';
metaEl.textContent = `step:${step} • retries:${retries} • fv:${fv}${lvlTag}`;
}
}
function clearProcessLog() {
processLogEntries.length = 0;
const el = document.getElementById('process-log');
if (el) el.innerHTML = '<div style="color:var(--muted);text-align:center;padding:20px 0;opacity:0.5">활동 대기 중...</div>';
processLogAutoFollow = true;
rightColumnAutoFollow = true;
maybeAutoScrollRightColumn(true);
persistActiveChat();
}
function addProcessEntry(type, content, extra) {
const ts = new Date().toLocaleTimeString();
processLogEntries.push({ ts, type, content, extra });
renderProcessLog();
maybeAutoScrollRightColumn(isThinking || rightColumnAutoFollow);
persistActiveChat();
if (isThinking) {
const panel = document.getElementById('current-turn-process');
if (panel && panel.style.display !== 'none' && currentTurnStartIndex >= 0) {
panel.innerHTML = formatProcessLines(processLogEntries.slice(currentTurnStartIndex));
}
}
}
function renderProcessLog() {
const el = document.getElementById('process-log');
if (!el) return;
setupProcessAndRightScrollTracking();
const shouldFollow = processLogAutoFollow || isThinking;
if (processLogEntries.length === 0) {
el.innerHTML = '<div style="color:var(--muted);text-align:center;padding:20px 0;opacity:0.5">활동 대기 중...</div>';
if (shouldFollow) el.scrollTop = el.scrollHeight;
return;
}
el.innerHTML = processLogEntries.map((e,i) => {
let icon, color, label, labelColor;
switch(e.type) {
case 'user': icon='?'; color='var(--accent2)'; labelColor='var(--text)'; label='USER'; break;
case 'think': icon='??'; color='#388bfd'; labelColor='#388bfd'; label='THINK'; break;
case 'tool': icon='?'; color='#e3b341'; labelColor='#e3b341'; label='TOOL'; break;
case 'result': icon='?'; color='#bc8cff'; labelColor='#bc8cff'; label='OK'; break;
case 'error': icon='?'; color='var(--red)'; labelColor='var(--red)'; label='FAIL'; break;
case 'final': icon='?'; color='#56d364'; labelColor='#56d364'; label='FINAL'; break;
case 'warn': icon='?'; color='var(--yellow)'; labelColor='var(--yellow)'; label='WARN'; break;
case 'step': icon='?'; color='#56d364'; labelColor='#56d364'; label='STEP'; break;
case 'split': icon='?'; color='var(--accent2)'; labelColor='var(--accent2)'; label='SPLIT'; break;
case 'synth': icon='?'; color='var(--accent2)'; labelColor='var(--accent2)'; label='SYNTH'; break;
case 'info': icon='·'; color='#79c0ff'; labelColor='#79c0ff'; label='INFO'; break;
default: icon='·'; color='var(--muted)'; labelColor='var(--muted)'; label=e.type; break;
}
// Think entries: show full content in a scrollable block
if (e.type === 'think' && e.content && e.content.length > 60) {
const id = 'pl_' + Math.random().toString(36).slice(2,8);
const preview = e.content.slice(0, 80).replace(/\n/g,' ');
return `
<div style="margin-bottom:4px">
<span style="color:var(--muted);font-size:10px">${e.ts}</span>
<span style="color:${color};margin:0 6px">${icon} <span style="color:${labelColor}">${label}</span></span>
<span style="color:var(--muted);cursor:pointer;text-decoration:underline;font-size:10px" onclick="togglePL('${id}')">${escHtml(preview)}…</span>
</div>
<div id="${id}" style="display:none;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;white-space:pre-wrap;color:var(--muted);font-size:10px;max-height:200px;overflow-y:auto;font-style:italic">${escHtml(e.content)}</div>
`;
}
// Tool result entries: collapsible if long
if (e.type === 'result' && e.content && e.content.length > 120) {
const id = 'pl_' + Math.random().toString(36).slice(2,8);
const preview = e.content.slice(0, 100).replace(/\n/g,' ');
return `
<div style="margin-bottom:4px">
<span style="color:var(--muted);font-size:10px">${e.ts}</span>
<span style="color:${color};margin:0 6px">${icon} <span style="color:${labelColor}">${label}</span></span>
<span style="color:${color};cursor:pointer;font-size:10px" onclick="togglePL('${id}')">${escHtml(preview)}… <span style="opacity:0.5">[expand]</span></span>
</div>
<div id="${id}" style="display:none;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;white-space:pre-wrap;color:var(--text);font-size:10px;max-height:300px;overflow-y:auto">${escHtml(e.content)}</div>
`;
}
// Memory suggestion: show approve/reject buttons
if (e.type === 'memory') {
const id = 'pl_mem_' + i;
const suggest = e.extra || {};
return `
<div style="margin-bottom:6px">
<span style="color:var(--muted);font-size:10px">${e.ts}</span>
<span style="color:${color};margin:0 6px">${icon} <span style="color:${labelColor}">${label}</span></span>
<span style="color:var(--text);">${escHtml(String(e.content || ''))}</span>
<div style="margin-top:6px">
<button class="btn btn-sm" onclick="confirmMemory(${i})">Approve</button>
<button class="btn btn-sm" style="margin-left:6px" onclick="rejectMemory(${i})">Reject</button>
</div>
</div>
`;
}
// If an 'extra' object/array was provided, show a details toggle
if (e.extra) {
const id = 'pl_extra_' + i;
const pretty = escHtml(JSON.stringify(e.extra, null, 2));
return `
<div style="margin-bottom:3px">
<span style="color:var(--muted);font-size:10px">${e.ts}</span>
<span style="color:${color}">${icon} <span style="color:${labelColor}">${label}</span></span>
<span style="color:var(--text);margin-left:8px">${escHtml(String(e.content || ''))}</span>
<span style="color:var(--muted);cursor:pointer;margin-left:8px;font-size:10px" onclick="togglePL('${id}')">[details]</span>
</div>
<pre id="${id}" style="display:none;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;color:var(--muted);font-size:11px;max-height:300px;overflow:auto">${pretty}</pre>
`;
}
return `<div style="margin-bottom:3px"><span style="color:var(--muted);font-size:10px">${e.ts}</span> <span style="color:${color}">${icon} <span style="color:${labelColor}">${label}</span></span> <span style="color:var(--text)">${escHtml(String(e.content || ''))}</span></div>`;
}).join('');
if (shouldFollow) el.scrollTop = el.scrollHeight;
}
function togglePL(id) {
const el = document.getElementById(id);
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function isFailedTurnReply(text) {
const t = String(text || '').trim().toLowerCase();
if (!t) return true;
if (/^blocked\b/.test(t)) return true;
if (/^no response received\.?$/.test(t)) return true;
if (/^connection error:/.test(t)) return true;
if (/^sorry, i didn'?t catch that/.test(t)) return true;
if (/could not|couldn't/.test(t) && /(verify|format|synthes|extract|read|write|tool|request)/.test(t)) return true;
return false;
}
// ---- Send chat (SSE version) ----
async function sendChat(queuedMessage = null) {
const input = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
const raw = typeof queuedMessage === 'string' ? queuedMessage : input.value;
const message = String(raw || '').trim();
if (!message && stagedAttachments.length === 0) return;
if (isThinking) {
if (typeof queuedMessage === 'string') return;
if (queuedPrompts.length >= MAX_QUEUED_PROMPTS) {
addProcessEntry('warn', `Queue full (${MAX_QUEUED_PROMPTS}). Wait for current run to finish.`);
return;
}
queuedPrompts.push(message);
addProcessEntry('info', `Queued prompt #${queuedPrompts.length}. It will run automatically next.`);
input.value = '';
input.style.height = 'auto';
updateQueuedPromptUI();
return;
}
// 첨부파일: AI에겐 전체 내용, 채팅 버블엔 파일명만
let fullMessage = message;
let displayMessage = message;
if (stagedAttachments.length > 0 && typeof queuedMessage !== 'string') {
const attachParts = stagedAttachments
.filter(function(a) { return a.type !== 'txt-auto'; }) // txt-auto는 PDF가 이미 내용 포함
.map(function(a) {
return a.content || ('[첨부: ' + a.name + '](' + (a.url || '') + ')');
});
fullMessage = attachParts.join('\n\n') + (message ? '\n\n' + message : '');
const compactParts = stagedAttachments
.filter(function(a) { return a.type !== 'txt-auto'; })
.map(function(a) {
// [파일명](url) → renderMarkdown이 PDF/파일 버튼으로 렌더링
var line = a.url ? '[' + a.name + '](' + a.url + ')' : '📎 **' + a.name + '**';
// PDF에 txt 저장본이 있으면 txt 다운로드 링크도 추가
if (a.type === 'pdf' && a.txtUrl) {
line += ' [📝 ' + a.txtName + '](' + a.txtUrl + ')';
}
return line;
});
displayMessage = compactParts.join('\n') + (message ? '\n\n' + message : '');
clearStagedAttachments();
}
chatHistory.push({ role: 'user', content: fullMessage, display: displayMessage });
persistActiveChat();
if (typeof queuedMessage !== 'string') {
input.value = '';
input.style.height = 'auto';
}
isThinking = true;
streamingSessionId = activeChatSessionId; // lock bubble to this session
setButtonState(true);
currentPreflightStatus = '';
currentProgressLines = [];
liveTaskSteps = [];
lastHeartbeat = {
state: 'running',
level: '',
current_step: 'dispatch',
retry_count: 0,
format_violation_count: 0,
message: 'Turn started',
};
lastHeartbeatLogSignature = '';
processLogAutoFollow = true;
rightColumnAutoFollow = true;
updateHeartbeatUI();
renderLiveStepsPanel();
sendBtn.disabled = false;
updateQueuedPromptUI();
renderChatMessages();
maybeAutoScrollRightColumn(true);
currentTurnStartIndex = processLogEntries.length;
addProcessEntry('user', message);
const historyForAPI = chatHistory.slice(-13, -1).map(m => ({
role: m.role === 'user' ? 'user' : 'assistant',
content: m.content
}));
const allSteps = [];
let finalReply = '';
let finalArtifacts = [];
const turnThinkingBuffer = [];
const turnThinkingSeen = new Set();
let sawExecuteModeThisTurn = false;
let sawToolActivityThisTurn = false;
let turnFileLinks = [];
const collectTurnThinking = (value) => {
const text = String(value || '').trim();
if (!text) return;
const key = text.replace(/\s+/g, ' ').trim();
if (!key || turnThinkingSeen.has(key)) return;
turnThinkingSeen.add(key);
turnThinkingBuffer.push(text);
};
try {
// Use SSE fetch — stream steps live as they arrive
currentAbortController = new AbortController();
let partialContent = '';
const res = await fetch('/api/chat', {
method: 'POST',
headers: authHeaders({
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
}),
signal: currentAbortController.signal,
body: JSON.stringify({ message: fullMessage, history: historyForAPI, useTools: useAgentMode, sessionId: agentSessionId, pinnedMessages: confirmedPins.length > 0 ? confirmedPins : undefined })
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // keep incomplete line
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
let event;
try { event = JSON.parse(line.slice(6)); } catch { continue; }
switch (event.type) {
case 'agent_mode':
lastAgentMode = event.mode || '-';
lastTurnKind = event.turnKind || lastTurnKind;
if (event.mode === 'execute') sawExecuteModeThisTurn = true;
addProcessEntry(
'info',
`Agent mode: ${event.mode || 'unknown'}${event.turnKind ? ` (${event.turnKind})` : ''}${event.switched_from ? ` | switched_from=${event.switched_from}` : ''}${event.route_target ? ` | route_target=${event.route_target}` : ''}${event.trigger ? ` | trigger=${event.trigger}` : ''}`
);
if (event.mode === 'execute') renderLiveStepsPanel();
loadAgentSessionContext();
break;
case 'session_mode_locked':
addProcessEntry('info', `Session mode locked: ${event.mode || 'unknown'}`);
break;
case 'decomposed':
addProcessEntry('split', `Split into ${event.questions.length} sub-questions: ${event.questions.map((q,i) => `Q${i+1}: ${q.slice(0,40)}`).join(' | ')}`);
break;
case 'thinking':
if (event.thinking && String(event.thinking).trim()) {
const thinkingText = String(event.thinking).trim();
collectTurnThinking(thinkingText);
addProcessEntry('think', thinkingText);
}
break;
case 'info':
if (event.message) addProcessEntry('info', String(event.message));
break;
case 'heartbeat': {
lastHeartbeat = {
...lastHeartbeat,
state: event.state || lastHeartbeat.state,
level: event.level || lastHeartbeat.level,
current_step: event.current_step || lastHeartbeat.current_step,
retry_count: Number(event.retry_count || lastHeartbeat.retry_count || 0),
format_violation_count: Number(event.format_violation_count || lastHeartbeat.format_violation_count || 0),
message: event.message || lastHeartbeat.message || '',
};
updateHeartbeatUI();
const sig = `${lastHeartbeat.state}|${lastHeartbeat.level}|${lastHeartbeat.current_step}|${lastHeartbeat.retry_count}|${lastHeartbeat.format_violation_count}`;
if (sig !== lastHeartbeatLogSignature) {
const level = String(lastHeartbeat.level || '').toLowerCase();
const text = String(lastHeartbeat.message || `state=${lastHeartbeat.state} step=${lastHeartbeat.current_step}`).trim();
addProcessEntry(level === 'hard' ? 'warn' : 'info', `Heartbeat: ${text}`);
lastHeartbeatLogSignature = sig;
}
break;
}
case 'ui_preflight':
if (event.message) {
currentPreflightStatus = String(event.message);
pushProgressLine(currentPreflightStatus);
renderChatMessages();
}
break;
case 'tool_call': {
const action = String(event.action || '').trim();
const stepNum = Number(event.stepNum || 0);
const args = (event.args && typeof event.args === 'object') ? event.args : null;
const argsPreview = args ? JSON.stringify(args).slice(0, 260) : '';
const syntheticTag = event.synthetic ? ' [synthetic]' : '';
sawToolActivityThisTurn = true;
if (action) pushProgressLine(`${stepNum ? `Step ${stepNum}: ` : ''}Running ${action}...`);
upsertLiveStep(action || 'tool', stepNum || (liveTaskSteps.length + 1), 'in_progress', 'running');
if (action) {
addProcessEntry(
'tool',
`${stepNum ? `Step ${stepNum}: ` : ''}${action}${syntheticTag}${argsPreview ? ` ${argsPreview}` : ''}`,
args || undefined,
);
}
break;
}
case 'tool_result': {
const action = String(event.action || '').trim();
const stepNum = Number(event.stepNum || 0);
sawToolActivityThisTurn = true;
const text = String(event.result || '');
const ok = event.error === true ? false : !/^ERROR:/i.test(text);
const syntheticTag = event.synthetic ? ' [synthetic]' : '';
if (action) pushProgressLine(`${stepNum ? `Step ${stepNum}: ` : ''}${action} ${ok ? 'complete' : 'failed'}`);
upsertLiveStep(action || 'tool', stepNum || (liveTaskSteps.length + 1), ok ? 'done' : 'failed', ok ? 'completed' : text.slice(0, 120));
addProcessEntry(
ok ? 'result' : 'error',
`${stepNum ? `Step ${stepNum}: ` : ''}${action || 'tool'}${syntheticTag} => ${text || '(no output)'}`,
);
loadAgentSessionContext();
break;
}
case 'orchestration': {
try {
if (typeof window !== 'undefined' && typeof window._orchEventHandler === 'function') {
window._orchEventHandler(event);
}
} catch {}
const trigger = String(event.trigger || 'unknown');
const mode = String(event.mode || 'rescue');
const route = event.route ? ` | route=${event.route}` : '';
const reason = event.reason ? ` | ${event.reason}` : '';
const cap = (Number.isFinite(Number(event.assist_count)) && Number.isFinite(Number(event.assist_cap)))
? ` | assists ${event.assist_count}/${event.assist_cap}`
: '';
addProcessEntry('info', `Orchestrator: trigger=${trigger} mode=${mode}${route}${reason}${cap}`, event);
if (event.preflight?.quick_plan?.length) {
addProcessEntry('info', `Orchestrator plan: ${event.preflight.quick_plan[0]}`);
} else if (event.advice?.next_actions?.length) {
addProcessEntry('info', `Orchestrator next: ${event.advice.next_actions[0]}`);
}
const preflightRaw = String(event.preflight?.raw_response || '').trim();
if (preflightRaw) {
addProcessEntry('think', `[Secondary AI preflight raw response]\n${preflightRaw}`);
}
const adviceRaw = String(event.advice?.raw_response || '').trim();
if (adviceRaw) {
addProcessEntry('think', `[Secondary AI advisor raw response]\n${adviceRaw}`);
}
break;
}
case 'browser_advisor_start': {
const pageType = event.page_type ? ` type=${event.page_type}` : '';
const count = Number.isFinite(Number(event.extracted_count)) ? ` | extracted=${event.extracted_count}` : '';
addProcessEntry('info', `Browser advisor start:${pageType}${count}`);
break;
}
case 'feed_collected': {
const b = Number(event.batch || 0);
const added = Number(event.added || 0);
const total = Number(event.total || 0);
const deduped = Number(event.deduped || 0);
addProcessEntry('info', `Feed collected: batch ${b} | +${added} | total ${total} | deduped ${deduped}`);
break;
}
case 'browser_advisor_route': {
const route = String(event.route || 'unknown');
const reason = event.reason ? ` | ${String(event.reason)}` : '';
const cap = (Number.isFinite(Number(event.assist_count)) && Number.isFinite(Number(event.assist_cap)))
? ` | assists ${event.assist_count}/${event.assist_cap}`
: '';
const nextTool = event.next_tool?.tool ? ` | next=${event.next_tool.tool}` : '';
addProcessEntry('info', `Browser advisor route=${route}${nextTool}${reason}${cap}`, event);
const rawResponse = String(event.raw_response || '').trim();
if (rawResponse) {
addProcessEntry('think', `[Secondary AI browser advisor raw response]\n${rawResponse}`);
}
break;
}
case 'browser_advisor_nudge': {
const route = event.route ? `[${event.route}] ` : '';
const preview = String(event.preview || '').trim();
if (preview) addProcessEntry('info', `Advisor nudge ${route}${preview}`);
break;
}
case 'forced_retry': {
const reason = String(event.reason || 'advisor requested continuation');
const retry = Number(event.retry || 0);
const max = Number(event.max_retries || 0);
addProcessEntry('warn', `Forced retry ${retry}/${max}: ${reason}`);
break;
}
case 'preempt_start': {
const elapsedSec = Math.max(1, Math.round(Number(event.elapsed_ms || 0) / 1000));
const thresholdSec = Math.max(1, Math.round(Number(event.threshold_ms || 0) / 1000));
addProcessEntry('warn', `Preempt start: generation stalled ${elapsedSec}s (threshold ${thresholdSec}s).`);
break;
}
case 'preempt_killed': {
const restarted = event.restarted === true;
const cap = (Number.isFinite(Number(event.preempts_session)) && Number.isFinite(Number(event.preempts_session_cap)))
? ` | preempts ${event.preempts_session}/${event.preempts_session_cap}`
: '';
addProcessEntry(restarted ? 'info' : 'warn', `Preempt kill/restart ${restarted ? 'completed' : 'failed'}${cap}`);
break;
}
case 'preempt_ready': {
const cap = (Number.isFinite(Number(event.preempts_session)) && Number.isFinite(Number(event.preempts_session_cap)))
? ` | preempts ${event.preempts_session}/${event.preempts_session_cap}`
: '';
addProcessEntry('info', `Preempt ready: Ollama online, running rescue advisor${cap}`);
break;
}
case 'preempt_rescue': {
const cap = (Number.isFinite(Number(event.assist_count)) && Number.isFinite(Number(event.assist_cap)))
? ` | assists ${event.assist_count}/${event.assist_cap}`
: '';
addProcessEntry('info', `Preempt rescue guidance injected${cap}`);
break;
}
case 'preempt_retry':
addProcessEntry('info', 'Preempt retry: re-running primary with rescue context.');
break;
case 'synthesizing':
pushProgressLine(`Synthesizing ${Number(event.count || 1)} answer(s)...`);
addProcessEntry('synth', `Combining ${event.count} answers...`);
break;
case 'step': {
const s = event;
allSteps.push(s);
// Show step number
if (s.stepNum && !s.isFormatViolation && !s.finalAnswer && !s.action) {
// just a step counter with no other info — skip
}
// Thinking block
if (s.thinking) {
collectTurnThinking(s.thinking);
addProcessEntry('think', s.thinking);
}
// Format violation
if (s.isFormatViolation) {
addProcessEntry('warn', 'Format violation — retrying');
break;
}
// Tool call
if (s.action && !s.toolResult) {
sawToolActivityThisTurn = true;
addProcessEntry('tool', `${s.action} ${JSON.stringify(s.params || {}).slice(0, 100)}`);
if (s.thought) addProcessEntry('info', s.thought);
}
// Tool result (same step object updated)
if (s.action && s.toolResult) {
sawToolActivityThisTurn = true;
const toolText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || '');
const isErr = toolText.startsWith('ERROR');
addProcessEntry(isErr ? 'error' : 'result', toolText);
if (s.action === 'web_search') {
const diag = s?.toolData?.search_diagnostics || s?.diagnostics || null;
if (diag) {
const query = String(diag.query || s?.params?.query || '').trim();
if (query) addProcessEntry('info', `Search query: ${query}`);
if (Array.isArray(diag.attempted) && diag.attempted.length) {
const providers = diag.attempted.map(a => {
const p = String(a.provider || '').toLowerCase();
const status = String(a.status || '').toLowerCase();
if (status === 'success') {
const count = Number.isFinite(a.result_count) ? `, ${a.result_count} result${a.result_count === 1 ? '' : 's'}` : '';
return `${p}=success${count}`;
}
if (status === 'skipped') return `${p}=skipped${a.reason ? ` (${a.reason})` : ''}`;
return `${p}=failed${a.reason ? ` (${a.reason})` : ''}`;
}).join(' | ');
addProcessEntry('info', `Providers: ${providers}`);
}
}
}
}
// Final answer
if (s.finalAnswer) {
addProcessEntry('final', s.finalAnswer);
partialContent = s.finalAnswer; // track for stop
}
break;
}
case 'memory_suggest':
// event.suggestion: { fact, reference, source_tool, source_output, actor }
const s = event.suggestion || {};
addProcessEntry('memory', s.fact || '(memory suggestion)', s);
addProcessEntry('info', 'A memory suggestion was created; approve to persist.');
break;
case 'memory_saved':
addProcessEntry(event.ok ? 'result' : 'warn', event.ok
? `Memory updated${event.key ? ` (${event.key})` : ''}.`
: 'Memory update failed.');
break;
case 'web_search_snippets':
// event: { query, snippets }
const q = event.query || '(search)';
addProcessEntry('info', `Search results: ${q}`, event.snippets || []);
if (event.diagnostics && Array.isArray(event.diagnostics.attempted)) {
const providers = event.diagnostics.attempted.map(a => {
const p = String(a.provider || '').toLowerCase();
const status = String(a.status || '').toLowerCase();
if (status === 'success') {
const count = Number.isFinite(a.result_count) ? `, ${a.result_count} result${a.result_count === 1 ? '' : 's'}` : '';
return `${p}=success${count}`;
}
if (status === 'skipped') return `${p}=skipped${a.reason ? ` (${a.reason})` : ''}`;
return `${p}=failed${a.reason ? ` (${a.reason})` : ''}`;
}).join(' | ');
addProcessEntry('info', `Providers: ${providers}`);
}
break;
case 'image':
if (event.url) {
pushProgressLine(`Image: ${event.alt || event.url}`);
const imgAlt = String(event.alt || '').replace(/"/g, '&quot;');
const imgHtml = `<img class="md-img" src="${escHtml(event.url)}" alt="${imgAlt}" loading="lazy" onclick="openImageZoom(this.src)" />`;
// Append image to current streaming AI message
const lastAiMsg = chatHistory.length > 0 && chatHistory[chatHistory.length - 1].role === 'ai'
? chatHistory[chatHistory.length - 1] : null;
if (lastAiMsg) {
lastAiMsg.content += '\n\n' + imgHtml;
} else {
chatHistory.push({ role: 'ai', content: imgHtml });
}
renderChatMessages();
}
break;
case 'files':
if (Array.isArray(event.links)) {
for (const lnk of event.links) turnFileLinks.push(lnk);
}
break;
case 'error':
addProcessEntry('error', event.message);
break;
case 'done':
finalReply = event.reply || '';
if (finalReply) partialContent = finalReply;
finalArtifacts = Array.isArray(event.artifacts) ? event.artifacts : [];
loadAgentSessionContext();
renderLiveStepsPanel();
break;
case 'turn_execution_created':
case 'turn_execution_updated':
loadAgentSessionContext();
break;
}
}
}
if (finalReply) {
// Append file cards for any attachments sent via the 'files' SSE event
if (turnFileLinks.length > 0) {
const seen = new Set();
const newLinks = turnFileLinks.filter(l => { const k = l.url || l.name; if (seen.has(k)) return false; seen.add(k); return true; });
// Only append links not already present as markdown in the reply
const toAppend = newLinks.filter(l => !finalReply.includes(l.url || ''));
if (toAppend.length > 0) {
finalReply += '\n\n' + toAppend.map(l => `[${l.name}](${l.url})`).join('\n');
}
}
const finalStep = allSteps.find(s => s.finalAnswer);
const mergedThinking = turnThinkingBuffer.join('\n\n').trim();
const shouldAttachThinkingPanel = sawExecuteModeThisTurn || sawToolActivityThisTurn;
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : [];
chatHistory.push({
role: 'ai',
content: finalReply,
artifacts: finalArtifacts,
steps: allSteps,
mode: useAgentMode ? 'agentic' : 'chat',
thinking: shouldAttachThinkingPanel ? (mergedThinking || finalStep?.thinking) : '',
processEntries: turnEntries
});
} else {
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : [];
chatHistory.push({ role: 'ai', content: 'No response received.', processEntries: turnEntries });
}
persistActiveChat();
} catch (err) {
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : [];
if (err.name === 'AbortError') {
addProcessEntry('warn', 'Generation stopped by user.');
const content = partialContent ||
(allSteps.length ? `[Stopped — ${allSteps.length} step${allSteps.length !== 1 ? 's' : ''} completed]` : '[Generation stopped]');
chatHistory.push({ role: 'ai', content, steps: allSteps, mode: useAgentMode ? 'agentic' : 'chat', processEntries: turnEntries });
} else {
lastHeartbeat = { ...lastHeartbeat, state: 'stalled', level: 'hard', message: String(err.message || 'connection_error'), current_step: 'error' };
updateHeartbeatUI();
addProcessEntry('error', err.message);
chatHistory.push({ role: 'ai', content: `Connection error: ${err.message}`, processEntries: turnEntries });
}
persistActiveChat();
}
isThinking = false;
streamingSessionId = null; // release session lock
currentAbortController = null;
setButtonState(false);
currentPreflightStatus = '';
currentProgressLines = [];
lastHeartbeat = { ...lastHeartbeat, state: 'idle', level: '', message: '', current_step: 'done' };
updateHeartbeatUI();
currentTurnStartIndex = -1;
sendBtn.disabled = false;
renderChatMessages();
updateQueuedPromptUI();
const shouldPauseQueue = isFailedTurnReply(finalReply || (chatHistory[chatHistory.length - 1]?.content || ''));
if (queuedPrompts.length > 0 && shouldPauseQueue) {
addProcessEntry('warn', 'Queue paused because the previous turn failed/blocked. Press Send to resume queued prompts.');
} else if (queuedPrompts.length > 0) {
const next = queuedPrompts.shift();
updateQueuedPromptUI();
addProcessEntry('info', `Auto-running queued prompt${queuedPrompts.length ? ` (${queuedPrompts.length} remaining)` : ''}.`);
setTimeout(() => { sendChat(next); }, 0);
}
}
// ---- Agent Execution Tracking ----
function spawnAgentExecution(agentName, taskPrompt, isSubagent = false) {
const agentId = (isSubagent ? 'sub_' : 'main_') + (++agentIdCounter);
const agentData = {
id: agentId,
name: agentName,
status: AGENT_STATUS.ACTIVE,
task: taskPrompt,
logs: [],
isSubagent: isSubagent,
startTime: new Date(),
};
agentExecutionMap.set(agentId, agentData);
renderAgentExecutionPanel();
return agentId;
}
function addAgentLog(agentId, type, content) {
const agentData = agentExecutionMap.get(agentId);
if (!agentData) return;
const ts = new Date().toLocaleTimeString();
agentData.logs.push({ ts, type, content });
// Keep only last 20 logs per agent
if (agentData.logs.length > 20) agentData.logs.shift();
renderAgentExecutionPanel();
}
function completeAgentExecution(agentId) {
const agentData = agentExecutionMap.get(agentId);
if (!agentData) return;
agentData.status = AGENT_STATUS.COMPLETED;
renderAgentExecutionPanel();
}
function pauseAgentExecution(agentId, reason) {
const agentData = agentExecutionMap.get(agentId);
if (!agentData) return;
agentData.status = AGENT_STATUS.PAUSED;
if (reason) addAgentLog(agentId, 'error', reason);
renderAgentExecutionPanel();
}
function renderAgentExecutionPanel() {
const panel = document.getElementById('agent-execution-panel');
if (!panel) return;
if (agentExecutionMap.size === 0) {
panel.innerHTML = '<div style="padding:8px 12px;font-size:11px;color:var(--muted)">작업 실행 대기 중...</div>';
return;
}
const agents = Array.from(agentExecutionMap.values()).sort((a, b) => a.startTime - b.startTime);
panel.innerHTML = agents.map((agent) => {
const statusClass = agent.status === AGENT_STATUS.ACTIVE ? 'active' :
agent.status === AGENT_STATUS.COMPLETED ? 'completed' : 'paused';
const arrow = agent.expanded ? '▼' : '▶';
const logsHtml = agent.logs.length > 0
? agent.logs.map(log => `<div class="agent-log-entry"><span class="agent-log-entry-time">${escHtml(log.ts)}</span><span class="agent-log-entry-type">[${escHtml(log.type.toUpperCase())}]</span><span class="agent-log-entry-content">${escHtml(log.content)}</span></div>`).join('')
: '<div style="color:var(--muted);opacity:0.6">No logs yet</div>';
return `
<div class="agent-item ${agent.expanded ? 'expanded' : ''}" onclick="toggleAgentPanel('${agent.id}')">
<div class="agent-item-header">
<span style="color:var(--muted);font-size:10px;min-width:12px">${arrow}</span>
<div class="agent-item-name">${escHtml(agent.isSubagent ? '└─ ' + agent.name : agent.name)}</div>
<div class="agent-item-status ${statusClass}">${agent.status.toUpperCase()}</div>
</div>
<div class="agent-item-task">${escHtml(agent.task.substring(0, 70))}${agent.task.length > 70 ? '...' : ''}</div>
<div class="agent-item-logs">${logsHtml}</div>
</div>
`;
}).join('');
}
function toggleAgentPanel(agentId) {
const agentData = agentExecutionMap.get(agentId);
if (!agentData) return;
agentData.expanded = !agentData.expanded;
renderAgentExecutionPanel();
}
function openProcessLogFile() {
// Call server endpoint to open the process log file
if (activeTaskId) {
fetch(`/api/open-file?task=${encodeURIComponent(activeTaskId)}&type=process`, {
headers: authHeaders()
})
.catch(err => console.error('Unable to open process log:', err));
}
}
function clearChat() {
chatHistory = [];
persistActiveChat();
renderChatMessages();
}
function toggleExportMenu(e) {
if (e) e.stopPropagation();
var menu = document.getElementById('export-menu');
if (!menu) return;
var opening = menu.style.display === 'none' || !menu.style.display;
menu.style.display = opening ? 'block' : 'none';
if (opening) {
setTimeout(function() {
document.addEventListener('click', function closeExport() {
menu.style.display = 'none';
document.removeEventListener('click', closeExport);
});
}, 0);
}
}
function exportChat(format) {
var menu = document.getElementById('export-menu');
if (menu) menu.style.display = 'none';
if (!chatHistory || !chatHistory.length) {
alert('내보낼 채팅 내용이 없습니다.');
return;
}
var sess = chatSessions.find(function(s) { return s.id === activeChatSessionId; });
var title = (sess && sess.title) ? sess.title : '채팅';
var dateStr = new Date().toLocaleString('ko-KR');
var safeName = title.replace(/[^\w가-힣\s-]/g, '').trim().replace(/\s+/g, '_').slice(0, 40) || 'chat';
var dateSuffix = new Date().toISOString().slice(0, 10);
var filename = safeName + '_' + dateSuffix;
var content, mime, ext;
if (format === 'json') {
content = JSON.stringify({
title: title,
exportedAt: new Date().toISOString(),
sessionId: activeChatSessionId,
messages: chatHistory.map(function(m) {
return { role: m.role, content: (m.display || m.content || '').trim() };
})
}, null, 2);
mime = 'application/json';
ext = 'json';
} else if (format === 'md') {
var lines = ['# ' + title, '', '> 내보낸 시간: ' + dateStr, ''];
chatHistory.forEach(function(m) {
var label = m.role === 'user' ? '## 사용자' : '## 어시스턴트';
var text = (m.display || m.content || '').trim();
lines.push(label, '', text, '', '---', '');
});
content = lines.join('\n');
mime = 'text/markdown';
ext = 'md';
} else {
var lines = [title, '내보낸 시간: ' + dateStr, '='.repeat(50), ''];
chatHistory.forEach(function(m) {
var label = m.role === 'user' ? '[사용자]' : '[AI]';
var text = (m.display || m.content || '').trim()
.replace(/!\[[^\]]*\]\([^)]*\)/g, '[이미지]')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/[*_`#>]/g, '');
lines.push(label, text, '');
});
content = lines.join('\n');
mime = 'text/plain';
ext = 'txt';
}
var blob = new Blob([content], { type: mime + '; charset=utf-8' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename + '.' + ext;
document.body.appendChild(a);
a.click();
setTimeout(function() { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
}
// Enter to send, Shift+Enter for newline
document.getElementById('chat-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendChat();
}
});
// Auto-resize textarea
document.getElementById('chat-input').addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 140) + 'px';
});
// ---- WebSocket ----
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const token = getAuthToken();
const qs = token ? `?token=${encodeURIComponent(token)}` : '';
ws = new WebSocket(`${proto}://${API.replace(/^https?:\/\//, '')}/ws${qs}`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'jobs_update') {
allJobs = msg.jobs;
updateStats([]);
if (selectedJobId) refreshJobDetail(selectedJobId);
}
if (msg.type === 'job_created') {
log(`Job created: ${msg.jobId}`, 'info');
}
if (msg.type === 'boot_greeting') {
// Show boot greeting in current session regardless of existing history
const bootSess = chatSessions.find(s => s.id === activeChatSessionId);
if (bootSess) {
if (!bootSess.history) bootSess.history = [];
bootSess.history.push({ role: 'assistant', content: msg.text });
bootSess.updatedAt = Date.now();
saveChatSessions();
syncActiveChat();
renderChatMessages();
}
}
// -- Cron/Heartbeat task events --
if (msg.type === 'tasks_update') {
hbJobs = msg.jobs || [];
if (msg.config) hbConfig = msg.config;
if (currentMode === 'heartbeat') renderHeartbeatPage();
}
if (msg.type === 'task_running') {
const job = hbJobs.find(j => j.id === msg.jobId);
if (job) job.status = 'running';
if (currentMode === 'heartbeat') renderHeartbeatPage();
addProcessEntry('info', `Task running: ${msg.jobName}`);
}
if (msg.type === 'task_done') {
hbJobs = msg.jobs || hbJobs;
if (msg.config) hbConfig = msg.config;
if (currentMode === 'heartbeat') renderHeartbeatPage();
if (msg.automatedSession && !msg.isOk) {
// Add the automated session to chatSessions so it appears in the sidebar
const as = msg.automatedSession;
const existingIdx = chatSessions.findIndex(s => s.id === as.id);
const newSession = {
id: as.id, title: as.title,
history: as.history.map(m => ({ role: m.role === 'ai' ? 'assistant' : m.role, content: m.content })),
processLog: [], createdAt: as.createdAt, updatedAt: Date.now(),
automated: true, jobName: as.jobName, unread: true,
};
if (existingIdx === -1) chatSessions.push(newSession);
else chatSessions[existingIdx] = { ...chatSessions[existingIdx], ...newSession };
saveChatSessions();
renderSessionsList();
bgtToast('\uD83D\uDD50 Scheduled task done', `"${msg.jobName}" — results in sidebar`);
addProcessEntry('info', `Task "${msg.jobName}" completed — check sidebar for results`);
} else if (msg.isOk) {
addProcessEntry('info', `Task "${msg.jobName}" — OK (nothing to report)`);
}
}
// Cron job spawned a background task — switch to tasks view if currently watching
if (msg.type === 'cron_task_spawned') {
bgtToast('\uD83D\uDD50 Scheduled job running', `"${msg.jobName}" started as background task`);
if (currentMode === 'bgtasks') refreshBgTasks();
}
if (msg.type === 'task_sse') {
// Live progress from a running task — could update the NOW card
if (currentMode === 'heartbeat' && msg.event === 'tool_result') {
const card = document.querySelector(`[data-job-id="${msg.jobId}"] .hb-card-meta`);
if (card) card.innerHTML = `<span style="color:var(--brand)">Working: ${escHtml((msg.data?.action || '').slice(0,40))}...</span>`;
}
}
// -- Background Task (Kanban) events --
if (msg.type === 'task_running' || msg.type === 'task_complete' || msg.type === 'task_paused' ||
msg.type === 'task_failed' || msg.type === 'task_needs_assistance' || msg.type === 'task_step_done' || msg.type === 'task_tool_call' ||
msg.type === 'task_heartbeat_tick' || msg.type === 'task_heartbeat_resumed') {
// Live-refresh the kanban board if visible
if (currentMode === 'bgtasks') refreshBgTasks();
// If panel is open for this task, refresh it
if (bgtOpenTaskId && (msg.taskId === bgtOpenTaskId)) {
bgtRefreshOpenPanel();
}
// Toast notification for completions and errors
if (msg.type === 'task_complete') {
bgtToast(`\u2713 Task complete`, msg.summary ? msg.summary.slice(0,120) : '');
// If this task has a cron session ID (starts with cron_), create/update a sidebar session
// so the results are immediately visible without a page refresh.
if (msg.taskId) {
(async () => {
try {
const data = await fetch(`/api/bg-tasks/${msg.taskId}`, {
headers: authHeaders()
}).then(r => r.json());
const task = data?.task || data;
if (task && task.sessionId && task.sessionId.startsWith('cron_')) {
const journal = task.journal || [];
const summaryEntry = journal.filter(e => e.type === 'summary' || e.type === 'complete').pop();
const summaryText = msg.summary || (summaryEntry?.content) || '(Task complete — open Tasks panel for details)';
const titleText = `\uD83D\uDD50 ${task.title || 'Scheduled Task'} \u2014 ${new Date().toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`;
const sessionId = `auto_${task.id}_${Date.now()}`;
const existingIdx = chatSessions.findIndex(s => s.id === task.sessionId || (s.jobName && s.automated && s.title === titleText));
const newSession = {
id: sessionId, title: titleText,
history: [
{ role: 'user', content: `[Scheduled Task: ${task.title}]\n\n${task.prompt || ''}` },
{ role: 'assistant', content: summaryText },
],
processLog: [], createdAt: Date.now(), updatedAt: Date.now(),
automated: true, jobName: task.title, unread: true,
};
if (existingIdx === -1) chatSessions.push(newSession);
saveChatSessions();
renderSessionsList();
}
} catch {}
})();
}
} else if (msg.type === 'task_failed') {
bgtToast(`\u2715 Task failed`, msg.error ? msg.error.slice(0,120) : '');
} else if (msg.type === 'task_needs_assistance') {
bgtToast(`Task paused`, msg.reason ? String(msg.reason).slice(0, 120) : 'Needs assistance');
}
}
// Panel-only refresh (frequent tool_call updates — only refresh open panel, not full kanban)
if (msg.type === 'task_panel_update') {
if (bgtOpenTaskId && msg.taskId === bgtOpenTaskId) {
bgtRefreshOpenPanel();
}
}
// Notification from task runner (status push to channel)
if (msg.type === 'task_notification' && msg.sessionId) {
// Try to add a status message to the originating chat session
const sess = chatSessions.find(s => s.id === msg.sessionId);
if (sess) {
sess.history = sess.history || [];
sess.history.push({ role: 'assistant', content: msg.message || '' });
saveChatSessions();
if (currentSessionId === msg.sessionId) renderMessages();
}
}
// ---- Agent Execution Events ----
if (msg.type === 'agent_spawned') {
const agentId = spawnAgentExecution(msg.name || 'Subagent', msg.task || '', msg.isSubagent);
// Store mapping for WebSocket message routing
if (msg.serverAgentId) {
if (!window.agentIdMap) window.agentIdMap = {};
window.agentIdMap[msg.serverAgentId] = agentId;
}
}
if (msg.type === 'agent_log') {
const agentId = window.agentIdMap?.[msg.serverAgentId] || msg.agentId;
if (agentId) addAgentLog(agentId, msg.logType || 'info', msg.content);
}
if (msg.type === 'agent_completed') {
const agentId = window.agentIdMap?.[msg.serverAgentId] || msg.agentId;
if (agentId) completeAgentExecution(agentId);
}
if (msg.type === 'agent_paused') {
const agentId = window.agentIdMap?.[msg.serverAgentId] || msg.agentId;
if (agentId) pauseAgentExecution(agentId, msg.reason);
}
};
ws.onclose = () => setTimeout(connectWS, 2000);
ws.onerror = () => ws.close();
}
// ---- API ----
async function api(path, opts = {}) {
const { headers: optsHeaders, credentials: _discard, ...restOpts } = opts;
const headers = authHeaders({ 'Content-Type': 'application/json', ...optsHeaders });
const r = await fetch(API + path, { headers, ...restOpts });
if (r.status === 401) { setAuthToken(''); window.location.href = '/login.html'; throw new Error('Authentication required'); }
const ct = String(r.headers.get('content-type') || '').toLowerCase();
const bodyText = await r.text();
if (!ct.includes('application/json')) {
const preview = bodyText.slice(0, 120).replace(/\s+/g, ' ');
throw new Error(`Expected JSON from ${path}, got ${ct || 'unknown'} (HTTP ${r.status}) :: ${preview}`);
}
let data = null;
try {
data = bodyText ? JSON.parse(bodyText) : null;
} catch {
const preview = bodyText.slice(0, 120).replace(/\s+/g, ' ');
throw new Error(`Invalid JSON from ${path} (HTTP ${r.status}) :: ${preview}`);
}
if (!r.ok) {
const msg = (data && (data.error || data.message)) ? String(data.error || data.message) : `HTTP ${r.status}`;
throw new Error(msg);
}
return data;
}
const LAST_USER_KEY = 'cherryclaw_last_user';
function clearLocalUserState(reason) {
try {
localStorage.removeItem(chatSessionsKey());
localStorage.removeItem(agentSessionKey());
} catch {}
try {
chatSessions = [];
activeChatSessionId = null;
} catch {}
setAgentSessionId(generateSessionId());
// Rebuild a fresh "New chat" + re-render so the UI does not briefly
// display the previous user's session list before the wipe takes effect.
try { if (typeof loadChatSessions === 'function') loadChatSessions(); } catch {}
try { if (typeof renderSessionsList === 'function') renderSessionsList(); } catch {}
try { if (typeof renderMessages === 'function') renderMessages(); } catch {}
console.log(`[auth] cleared local session state (${reason})`);
}
async function checkAuth() {
try {
const res = await fetch('/api/auth/status', { headers: authHeaders() });
const data = await res.json();
if (data.enabled && !data.authenticated && !data.hasPassword) {
window.location.href = '/login.html';
return;
}
if (data.enabled && !data.authenticated) {
window.location.href = '/login.html';
return;
}
if (data.authenticated && data.username) {
currentUser = { username: data.username, role: data.role };
// Reload chat sessions under the correct user-scoped key now that we know who is logged in.
// This also migrates any data from the old non-namespaced key on first load after server restart.
if (typeof loadChatSessions === 'function') loadChatSessions();
// Multi-user safety: if a different user was logged in on this browser
// before, wipe the cached session list & active sessionId so the
// incoming user does not reuse the previous user's chat IDs.
const lastUser = (localStorage.getItem(LAST_USER_KEY) || '').trim();
if (lastUser && lastUser !== data.username) {
clearLocalUserState(`user changed: ${lastUser} -> ${data.username}`);
}
try { localStorage.setItem(LAST_USER_KEY, data.username); } catch {}
// Show user pill with username
const pill = document.getElementById('user-info-pill');
const nameEl = document.getElementById('user-info-name');
if (pill) pill.style.display = 'flex';
if (nameEl) nameEl.textContent = data.username;
const mobileLogout = document.getElementById('mob-tab-logout');
if (mobileLogout) mobileLogout.style.display = 'flex';
}
} catch (e) {
console.error('Auth check failed:', e);
}
}
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST', headers: authHeaders() });
} catch (e) { /* ignore network errors */ }
// Wipe cached chat list / sessionId so a different user logging in next
// does not inherit any of this user's session IDs.
clearLocalUserState('logout');
try { localStorage.removeItem(LAST_USER_KEY); } catch {}
setAuthToken('');
window.location.href = '/login.html';
}
async function loadSearchSettingsSummary() {
try {
const s = await api('/api/settings/search');
const el = document.getElementById('r-failed');
if (el) el.textContent = s.preferred_provider || 'ddg';
quickSearchRigor = s.search_rigor || 'verified';
updateQuickModeUI();
} catch {}
}
function updateQuickModeUI() {
const lbl = document.getElementById('quick-mode-label');
if (lbl) lbl.textContent = (quickSearchRigor || 'verified').replace(/^./, c => c.toUpperCase());
['fast', 'verified', 'strict'].forEach(v => {
const el = document.getElementById(`rigor-${v}`);
if (el) el.classList.toggle('active', quickSearchRigor === v);
});
['standard', 'extended'].forEach(v => {
const el = document.getElementById(`think-${v}`);
if (el) el.classList.toggle('active', quickThinkingEffort === v);
});
}
function toggleQuickModePopover() {
const pop = document.getElementById('quick-mode-popover');
if (!pop) return;
const nextOpen = !pop.classList.contains('open');
pop.classList.toggle('open', nextOpen);
if (nextOpen) {
pop.classList.remove('clamp-left');
requestAnimationFrame(() => {
const rect = pop.getBoundingClientRect();
if (rect.left < 8) pop.classList.add('clamp-left');
const after = pop.getBoundingClientRect();
if (after.top < 8) {
pop.style.bottom = 'auto';
pop.style.top = '34px';
} else {
pop.style.top = '';
pop.style.bottom = '34px';
}
});
}
updateQuickModeUI();
}
function toggleOllamaModelRow(provider) {
const row = document.getElementById('search-ollama-model-row');
if (row) row.style.display = provider === 'ollama' ? 'block' : 'none';
}
function toggleSearxngUrlRow(provider) {
const row = document.getElementById('search-searxng-url-row');
if (row) row.style.display = provider === 'searxng' ? 'block' : 'none';
}
async function setQuickSearchRigor(level) {
quickSearchRigor = level;
updateQuickModeUI();
try {
const s = await api('/api/settings/search');
const payload = {
preferred_provider: s.preferred_provider || 'ddg',
search_rigor: level,
tavily_api_key: s.tavily_api_key || '',
google_api_key: s.google_api_key || '',
google_cx: s.google_cx || '',
brave_api_key: s.brave_api_key || '',
};
await api('/api/settings/search', { method: 'POST', body: JSON.stringify(payload) });
addProcessEntry('info', `Search rigor set to ${level}.`);
await loadSearchSettingsSummary();
} catch (err) {
addProcessEntry('error', `Failed to set search rigor: ${err.message}`);
}
}
function setQuickThinkingEffort(level) {
quickThinkingEffort = level === 'extended' ? 'extended' : 'standard';
localStorage.setItem('cherryclaw_quick_thinking_effort', quickThinkingEffort);
updateQuickModeUI();
addProcessEntry('info', `Thinking effort set to ${quickThinkingEffort} (UI preference).`);
}
function setSettingsTab(tab) {
settingsTab = tab;
const tabs = ['system', 'search', 'credentials', 'policy', 'security', 'users', 'models', 'agents', 'channels', 'integrations', 'ppt', 'context-viewer'];
tabs.forEach(t => {
const btn = document.getElementById(`settings-tab-${t}`);
const panel = document.getElementById(`settings-panel-${t}`);
if (btn) {
btn.style.background = (t === tab) ? '#eaf2ff' : '#fff';
btn.style.borderColor = (t === tab) ? '#bdd3f6' : 'var(--line)';
btn.style.color = (t === tab) ? '#0d4faf' : 'var(--muted)';
}
if (panel) {
if (t === tab) {
panel.style.display = 'block';
if (t === 'channels') loadChannelsStatus();
if (t === 'models') loadModelSettings();
if (t === 'agents') loadAgentsTab();
if (t === 'integrations') loadIntegrationsTab();
if (t === 'credentials') loadCredentialsTab();
if (t === 'ppt') loadPptTab();
if (t === 'users') loadUsersTab();
if (t === 'context-viewer') loadContextViewerTab();
} else {
panel.style.display = 'none';
}
}
});
}
// --- Credentials Tab ---------------------------------------------------------
async function loadCredentialsTab() {
await Promise.all([loadCredFields(), loadCredVaultStatus(), loadCredVaultLog()]);
}
async function loadCredFields() {
try {
const s = await api('/api/settings/search');
// Server returns '••••••••' if key is set, '' if not
const setField = (id, val) => {
const el = document.getElementById(id);
if (!el) return;
el.value = val || '';
el.placeholder = val ? '•••••••• (key stored — enter new value to replace)' : el.getAttribute('data-placeholder') || '';
};
setField('cred-tavily-key', s.tavily_api_key);
setField('cred-google-key', s.google_api_key);
setField('cred-brave-key', s.brave_api_key);
const cxEl = document.getElementById('cred-google-cx');
if (cxEl) cxEl.value = s.google_cx || '';
} catch(e) {
console.warn('loadCredFields:', e);
}
}
async function loadCredVaultStatus() {
const el = document.getElementById('cred-vault-status');
if (!el) return;
try {
const data = await api('/api/credentials/status');
const keys = data.keys || [];
if (!keys.length) {
el.innerHTML = '<span style="color:var(--warn)">⚠ No credentials stored yet.</span>';
return;
}
el.innerHTML = keys.map(k => {
const label = {
'search.tavily_api_key': 'Tavily API Key',
'search.google_api_key': 'Google API Key',
'search.brave_api_key': 'Brave API Key',
'llm.openai.api_key': 'OpenAI API Key',
'hooks.token': 'Webhook Token',
'channels.telegram.botToken': 'Telegram Token',
'channels.discord.botToken': 'Discord Token',
}[k] || k;
return `<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px">
<span style="color:var(--ok);font-size:13px">✓</span>
<span style="font-size:12px;color:var(--text)">${label}</span>
<span style="font-size:10px;color:var(--muted);font-family:monospace">${k}</span>
</div>`;
}).join('');
} catch(e) {
el.innerHTML = `<span style="color:var(--err);font-size:12px">Could not load vault status: ${e.message}</span>`;
}
}
async function loadCredVaultLog() {
const el = document.getElementById('cred-vault-log');
if (!el) return;
try {
const data = await api('/api/credentials/audit');
const lines = (data.lines || []).slice(-18).reverse();
if (!lines.length) { el.textContent = '감사 항목 없음.'; return; }
el.innerHTML = lines.map(l => {
const safe = l.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const color = l.includes('SET') ? 'var(--ok)' : l.includes('GET') ? 'var(--brand)' : l.includes('DEL') ? 'var(--err)' : 'var(--muted)';
return `<div style="color:${color};white-space:nowrap">${safe}</div>`;
}).join('');
} catch(e) {
el.textContent = '감사 로그를 불러올 수 없습니다.';
}
}
function toggleCredVis(inputId, btn) {
const el = document.getElementById(inputId);
if (!el) return;
if (el.type === 'password') {
el.type = 'text';
btn.textContent = '🙈';
// Auto-rehide after 8s
setTimeout(() => { el.type = 'password'; btn.textContent = '👁'; }, 8000);
} else {
el.type = 'password';
btn.textContent = '👁';
}
}
// --- Provider-aware Model Settings ------------------------------------------
const PROVIDER_IDS = ['ollama', 'llama_cpp', 'lm_studio', 'openai', 'openai_codex', 'anthropic'];
function onProviderChange() {
const provider = document.getElementById('settings-llm-provider').value;
// Show only the matching fields panel
PROVIDER_IDS.forEach(id => {
const el = document.getElementById('prov-fields-' + id);
if (el) el.style.display = id === provider ? 'block' : 'none';
});
if (provider === 'openai') {
refreshOpenAIModels(true).catch(() => {});
} else if (['ollama', 'lm_studio', 'llama_cpp'].includes(provider)) {
refreshProviderModels().catch(() => {});
}
syncOrchPreemptControls();
}
async function loadModelSettings() {
try {
const data = await api('/api/settings/provider');
const llm = data?.llm || { provider: 'ollama', providers: {} };
const prov = llm.provider || 'ollama';
const provSel = document.getElementById('settings-llm-provider');
if (provSel) provSel.value = prov;
// Only toggle visibility, skip model refresh here to avoid double-loading
PROVIDER_IDS.forEach(id => {
const el = document.getElementById('prov-fields-' + id);
if (el) el.style.display = id === prov ? 'block' : 'none';
});
const pc = llm.providers || {};
// Populate each provider's fields from saved config
const v = (id, val) => { const el = document.getElementById(id); if (el && val) el.value = val; };
v('settings-ollama-endpoint', pc.ollama?.endpoint);
v('settings-llamacpp-endpoint', pc.llama_cpp?.endpoint);
v('settings-llamacpp-model', pc.llama_cpp?.model);
v('settings-lmstudio-endpoint', pc.lm_studio?.endpoint);
v('settings-lmstudio-model', pc.lm_studio?.model);
v('settings-openai-key', pc.openai?.api_key);
v('settings-anthropic-key', pc.anthropic?.api_key);
if (pc.anthropic?.model) { const s = document.getElementById('settings-anthropic-model'); if (s) s.value = pc.anthropic.model; }
// Set primary model from saved config before refreshing the list
const savedPrimaryModel = pc[prov]?.model || '';
if (savedPrimaryModel) {
const sel = document.getElementById('settings-primary-model');
if (sel) sel.value = savedPrimaryModel;
}
if (pc.openai?.model) { const s = document.getElementById('settings-openai-model'); if (s) s.value = pc.openai.model; }
if (pc.openai_codex?.model) { const s = document.getElementById('settings-codex-model'); if (s) s.value = pc.openai_codex.model; }
// Check Codex OAuth status
await refreshCodexStatus();
// Refresh OpenAI model dropdown from current key when applicable
if (prov === 'openai') await refreshOpenAIModels(true);
// Auto-load model list for list-capable providers (preserves saved selection)
if (['ollama', 'lm_studio', 'llama_cpp'].includes(prov)) await refreshProviderModels();
} catch (e) {
console.warn('loadModelSettings error:', e);
}
}
async function refreshProviderModels() {
const provider = document.getElementById('settings-llm-provider')?.value || 'ollama';
const msg = document.getElementById('provider-status-msg');
if (msg) msg.textContent = '모델 로딩 중…';
try {
const llm = buildProviderPayload();
if (llm.provider !== provider) llm.provider = provider;
const data = await api('/api/models/test', { method: 'POST', body: JSON.stringify({ llm }) });
const models = (data?.models || []).map(m => typeof m === 'string' ? m : (m.name || String(m)));
const selects = ['settings-primary-model'];
selects.forEach((selId, i) => {
const sel = document.getElementById(selId);
if (!sel) return;
if (i === 0) {
const prev = String(sel.value || '').trim();
sel.innerHTML = models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
if (models.length) sel.value = (prev && models.includes(prev)) ? prev : models[0];
}
});
if (msg) msg.textContent = models.length ? `${models.length}개 모델 발견.` : '모델 없음 — 서버가 실행 중인가요?';
} catch (e) {
if (msg) msg.textContent = '모델 가져오기 실패.';
}
}
async function testProviderConnection() {
const provider = document.getElementById('settings-llm-provider').value;
const statusIds = { ollama: 'provider-status-msg', llama_cpp: 'provider-status-msg-llamacpp', lm_studio: 'provider-status-msg-lmstudio', openai: 'provider-status-msg-openai', openai_codex: 'codex-oauth-status', anthropic: 'provider-status-msg-anthropic' };
const msgId = statusIds[provider] || 'provider-status-msg';
const msg = document.getElementById(msgId);
if (msg) msg.textContent = '테스트 중…';
if (provider === 'openai') {
const ok = await refreshOpenAIModels(false);
if (msg) msg.textContent = ok ? '✓ 연결됨' : (msg.textContent || '✗ 연결 실패');
return;
}
try {
const llm = buildProviderPayload();
if (llm.provider !== provider) llm.provider = provider;
const data = await api('/api/models/test', { method: 'POST', body: JSON.stringify({ llm }) });
if (msg) msg.textContent = data?.success ? '✓ 연결됨' : '✗ ' + (data?.error || '연결 실패');
} catch (e) {
if (msg) msg.textContent = '✗ ' + e.message;
}
}
function updateOpenAIModelDropdown(models) {
const sel = document.getElementById('settings-openai-model');
if (!sel) return;
const current = String(sel.value || '').trim();
const unique = Array.from(new Set((models || []).filter(Boolean).map(m => String(m).trim()).filter(Boolean)));
if (!unique.length) return;
if (current && !unique.includes(current)) unique.unshift(current);
sel.innerHTML = unique.slice(0, 500).map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
if (current) sel.value = current;
}
async function refreshOpenAIModels(silent = false) {
const statusEl = document.getElementById('provider-status-msg-openai');
const apiKey = document.getElementById('settings-openai-key')?.value?.trim() || '';
if (!apiKey) {
if (!silent && statusEl) statusEl.textContent = 'API 키를 먼저 입력하세요.';
return false;
}
if (!silent && statusEl) statusEl.textContent = '모델 목록 가져오는 중…';
try {
const data = await api('/api/openai/models', { method: 'POST', body: JSON.stringify({ api_key: apiKey }) });
if (!data?.success) {
if (statusEl) statusEl.textContent = '✗ ' + (data?.error || '모델 가져오기 실패');
return false;
}
const models = (data?.models || []).map(m => typeof m === 'string' ? m : (m.name || String(m)));
updateOpenAIModelDropdown(models);
if (statusEl) statusEl.textContent = models.length ? `✓ ${models.length}개 모델 사용 가능` : '✓ 연결됨';
return true;
} catch (e) {
if (statusEl) statusEl.textContent = '✗ ' + e.message;
return false;
}
}
// Build the llm config object from current UI state
function buildProviderPayload() {
const provider = document.getElementById('settings-llm-provider')?.value || 'ollama';
const providers = {};
providers.ollama = { endpoint: document.getElementById('settings-ollama-endpoint')?.value || 'http://localhost:11434', model: document.getElementById('settings-primary-model')?.value || '' };
providers.llama_cpp = { endpoint: document.getElementById('settings-llamacpp-endpoint')?.value || 'http://localhost:8080', model: document.getElementById('settings-llamacpp-model')?.value || '' };
providers.lm_studio = { endpoint: document.getElementById('settings-lmstudio-endpoint')?.value || 'http://localhost:1234', model: document.getElementById('settings-lmstudio-model')?.value || '' };
providers.openai = { api_key: document.getElementById('settings-openai-key')?.value || '', model: document.getElementById('settings-openai-model')?.value || 'gpt-4o' };
providers.openai_codex = { model: document.getElementById('settings-codex-model')?.value || 'gpt-5.3-codex' };
providers.anthropic = { api_key: document.getElementById('settings-anthropic-key')?.value || '', model: document.getElementById('settings-anthropic-model')?.value || 'claude-sonnet-4-6' };
return { provider, providers };
}
// --- Codex OAuth UI ------------------------------------------------
async function refreshCodexStatus() {
try {
const data = await api('/api/auth/openai/status');
const disc = document.getElementById('codex-disconnected-state');
const conn = document.getElementById('codex-connected-state');
const acct = document.getElementById('codex-account-id');
if (data?.connected) {
if (disc) disc.style.display = 'none';
if (conn) conn.style.display = 'block';
if (acct) acct.textContent = data.account_id ? `계정: ${data.account_id}` : '계정 연결됨';
} else {
if (disc) disc.style.display = 'block';
if (conn) conn.style.display = 'none';
}
} catch {}
}
async function startCodexOAuth() {
const statusEl = document.getElementById('codex-oauth-status');
if (statusEl) statusEl.textContent = '브라우저 열기 중…';
try {
const data = await api('/api/auth/openai/start', { method: 'POST', body: '{}' });
if (data?.needsManualPaste) {
if (statusEl) statusEl.innerHTML = 'Browser did not open. <a href="' + data.authUrl + '" target="_blank">Click here to authorize</a>, then paste the redirect URL below.<br><input id="codex-manual-url" type="text" placeholder="Paste redirect URL here…" style="width:100%;margin-top:6px;padding:6px;border:1px solid var(--line);border-radius:8px;font-size:11px"><button onclick="submitManualCodexUrl()" style="margin-top:4px;font-size:11px;padding:4px 8px;border-radius:6px;border:1px solid var(--line);cursor:pointer">Submit</button>';
} else if (data?.success) {
if (statusEl) statusEl.textContent = '';
await refreshCodexStatus();
} else {
if (statusEl) statusEl.textContent = '? ' + (data?.error || 'OAuth failed');
}
} catch (e) {
if (statusEl) statusEl.textContent = '✗ ' + e.message;
}
}
async function submitManualCodexUrl() {
const url = document.getElementById('codex-manual-url')?.value?.trim();
if (!url) return;
const statusEl = document.getElementById('codex-oauth-status');
if (statusEl) statusEl.textContent = '토큰 교환 중…';
try {
const data = await api('/api/auth/openai/manual', { method: 'POST', body: JSON.stringify({ url }) });
if (data?.success) {
if (statusEl) statusEl.textContent = '';
await refreshCodexStatus();
} else {
if (statusEl) statusEl.textContent = '? ' + (data?.error || 'Failed');
}
} catch (e) {
if (statusEl) statusEl.textContent = '✗ ' + e.message;
}
}
async function disconnectCodex() {
await api('/api/auth/openai/disconnect', { method: 'POST', body: '{}' });
await refreshCodexStatus();
}
// Legacy alias so any old references still work
async function refreshOllamaModels() { await refreshProviderModels(); }
async function loadAgentSessionContext() {
try {
if (!agentSessionId) setAgentSessionId(activeChatSessionId);
const sid = encodeURIComponent(agentSessionId);
const data = await api(`/api/agent/session/${sid}`);
const set = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = val;
};
set('r-session-mode', data.mode_lock || '-');
set('r-agent-state', `${data.mode || '-'} / ${lastTurnKind !== '-' ? lastTurnKind : 'n/a'}`);
set('r-overview-objective', (data.overview_objective || '(none)').slice(0, 56));
set('r-active-objective', (data.active_objective || '(none)').slice(0, 56));
const tc = data.task_counts || {};
const tr = data.turn_counts || {};
const ec = data.execution_counts || {};
const useExecutionCounts = Number(ec.total || 0) > 0 || data.current_turn_execution;
if (useExecutionCounts) {
const doneExec = Number(ec.done || 0) + Number(ec.repaired || 0);
set('r-task-counts', `${doneExec}/${ec.total || 0} completed`);
set('r-turn-counts', `${ec.running || 0} running • ${ec.failed || 0} failed`);
} else {
set('r-task-counts', `${tc.done || 0}/${tc.total || 0} done`);
set('r-turn-counts', `${tr.completed || 0} done • ${tr.open || 0} open`);
}
const total = useExecutionCounts ? Number(ec.total || 0) : Number(tc.total || 0);
const done = useExecutionCounts ? (Number(ec.done || 0) + Number(ec.repaired || 0)) : Number(tc.done || 0);
const pct = total > 0 ? Math.max(0, Math.min(100, Math.round((done / total) * 100))) : 0;
const bar = document.getElementById('r-task-progress-bar');
if (bar) bar.style.width = `${pct}%`;
const execPanel = document.getElementById('r-turn-execution');
if (execPanel) {
const cur = data.current_turn_execution;
if (!cur || typeof cur !== 'object') {
execPanel.innerHTML = '<div style="font-size:11px;color:var(--muted)">활성 턴 실행 없음.</div>';
} else {
const objective = escHtml(String(cur.objective_raw || '(no objective)').slice(0, 220));
const status = escHtml(String(cur.status || 'planned'));
const mode = escHtml(String(cur.mode || 'unknown'));
const turnKind = escHtml(String(cur.turn_kind || 'unknown'));
const steps = Array.isArray(cur.steps) ? cur.steps : [];
const verification = cur.verification && typeof cur.verification === 'object' ? cur.verification : null;
const trace = cur.decision_trace && typeof cur.decision_trace === 'object' ? cur.decision_trace : null;
const traceEvents = Array.isArray(trace?.events) ? trace.events : [];
const stepRows = steps.map((s, idx) => {
const stepTitle = escHtml(String(s.title || s.type || `Step ${idx + 1}`));
const stepStatus = escHtml(String(s.status || 'pending'));
const outputs = s.outputs && typeof s.outputs === 'object'
? `<div class="task-meta">${escHtml(JSON.stringify(s.outputs).slice(0, 160))}</div>`
: '';
return `
<div class="task-row">
<div class="task-top">
<div class="task-title">${idx + 1}. ${stepTitle}</div>
<span class="status-chip ${stepStatus}">${stepStatus.replace('_', ' ')}</span>
</div>
${outputs}
</div>
`;
}).join('');
const verifyHtml = verification
? `<div class="task-meta">verify: <strong>${escHtml(String(verification.status || 'pass'))}</strong>${Array.isArray(verification.repairs_applied) && verification.repairs_applied.length ? ` • repairs: ${verification.repairs_applied.length}` : ''}${Array.isArray(verification.errors) && verification.errors.length ? ` • errors: ${verification.errors.length}` : ''}</div>`
: '<div class="task-meta">verify: (not run)</div>';
const traceHtml = traceEvents.length
? `
<div class="task-row">
<div class="task-top">
<div class="task-title">Decision Trace</div>
<span class="status-chip done">${traceEvents.length}</span>
</div>
${traceEvents.slice(-10).map((ev) => {
const stage = escHtml(String(ev?.stage || 'trace'));
const msg = escHtml(String(ev?.message || '').slice(0, 180));
return `<div class="task-meta">[${stage}] ${msg}</div>`;
}).join('')}
</div>
`
: '';
execPanel.innerHTML = `
<div class="task-row">
<div class="task-top">
<div class="task-title">Current Turn</div>
<span class="status-chip ${status}">${status.replace('_', ' ')}</span>
</div>
<div class="task-meta">${objective}</div>
<div class="task-meta">mode: <strong>${mode}</strong> • kind: <strong>${turnKind}</strong></div>
${verifyHtml}
</div>
${stepRows || '<div style="font-size:11px;color:var(--muted)">No execution steps.</div>'}
${traceHtml}
`;
}
}
const taskList = document.getElementById('r-task-list');
if (taskList) {
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
if (!tasks.length) {
taskList.innerHTML = '<div style="font-size:11px;color:var(--muted)">작업 없음.</div>';
} else {
taskList.innerHTML = tasks.slice(0, 20).map(t => {
const title = escHtml(String(t.title || '').slice(0, 120));
const status = escHtml(String(t.status || 'pending'));
const tool = escHtml(String(t.tool || 'tool'));
return `
<div class="task-row">
<div class="task-top">
<div class="task-title">${title}</div>
<span class="status-chip ${status}">${status.replace('_', ' ')}</span>
</div>
<div class="task-meta">tool: ${tool}</div>
</div>
`;
}).join('');
}
}
const rt = document.getElementById('r-recent-turns');
if (rt) {
const recentExec = Array.isArray(data.recent_turn_executions) ? data.recent_turn_executions : [];
if (recentExec.length) {
rt.innerHTML = recentExec.slice(0, 10).map((e, idx) => {
const status = escHtml(String(e.status || 'planned'));
const objective = escHtml(String(e.objective_raw || '').slice(0, 120));
const tools = Array.isArray(e.tool_calls) ? e.tool_calls.length : 0;
const traceCount = Array.isArray(e?.decision_trace?.events) ? e.decision_trace.events.length : 0;
const repairs = Array.isArray(e?.verification?.repairs_applied) ? e.verification.repairs_applied.length : 0;
return `<div style="margin-bottom:6px"><span style="color:var(--muted)">#${idx + 1} [${status}]</span> ${objective || '(objective)'}<div style="font-size:10px;color:var(--muted)">tools: ${tools}${traceCount ? ` • trace: ${traceCount}` : ''}${repairs ? ` • repairs: ${repairs}` : ''}</div></div>`;
}).join('');
} else {
const turns = data.recent_turns || [];
if (!turns.length) {
rt.innerHTML = 'No turn objectives yet.';
} else {
rt.innerHTML = turns.map(t => {
const kind = escHtml(String(t.kind || 'turn'));
const status = escHtml(String(t.status || 'open'));
const text = escHtml(String(t.text || '').slice(0, 120));
return `<div style="margin-bottom:6px"><span style="color:var(--muted)">[${kind}/${status}]</span> ${text}</div>`;
}).join('');
}
}
}
const toggle = document.getElementById('agent-mode-toggle');
const label = document.getElementById('agent-mode-label');
if (toggle) {
const locked = data.mode_lock === 'agent' || data.mode_lock === 'chat';
toggle.disabled = !!locked;
if (data.mode_lock === 'agent') {
toggle.checked = true;
useAgentMode = true;
} else if (data.mode_lock === 'chat') {
toggle.checked = false;
useAgentMode = false;
}
}
if (label) {
label.textContent = useAgentMode ? '에이전트' : '채팅';
}
maybeAutoScrollRightColumn(rightColumnAutoFollow && isThinking);
} catch {
const rt = document.getElementById('r-recent-turns');
if (rt) rt.innerHTML = 'Unable to load session context.';
}
}
function renderLiveStepsPanel() {
const el = document.getElementById('r-live-steps');
if (!el) return;
if (!liveTaskSteps.length) {
el.innerHTML = '<div style="font-size:11px;color:var(--muted)">활성 도구 단계 없음.</div>';
return;
}
el.innerHTML = liveTaskSteps.slice(-20).map((s) => {
const action = escHtml(String(s.action || 'tool'));
const status = escHtml(String(s.status || 'pending'));
const stepNum = Number(s.stepNum || 0);
const meta = s.meta ? `<div class="task-meta">${escHtml(String(s.meta).slice(0, 140))}</div>` : '';
return `
<div class="task-row">
<div class="task-top">
<div class="task-title">${stepNum ? `Step ${stepNum}: ` : ''}${action}</div>
<span class="status-chip ${status}">${status.replace('_', ' ')}</span>
</div>
${meta}
</div>
`;
}).join('');
}
function upsertLiveStep(action, stepNum, status, meta = '') {
const key = `${String(action || '')}:${Number(stepNum || 0)}`;
const idx = liveTaskSteps.findIndex(s => s.key === key);
const row = { key, action: String(action || ''), stepNum: Number(stepNum || 0), status: String(status || 'pending'), meta: String(meta || '') };
if (idx >= 0) liveTaskSteps[idx] = { ...liveTaskSteps[idx], ...row };
else liveTaskSteps.push(row);
renderLiveStepsPanel();
}
async function openSettings(tab) {
document.getElementById('settings-modal').style.display = 'flex';
setSettingsTab(tab || settingsTab || 'system');
try {
const status = await api('/api/status');
document.getElementById('settings-runtime-model').textContent = status.currentModel || '-';
loadOrchestrationConfig();
document.getElementById('settings-runtime-gateway').textContent = status.gateway || '-';
document.getElementById('settings-runtime-ollama').textContent = status.ollama ? 'Online' : 'Offline';
} catch {}
try {
const paths = await api('/api/settings/paths');
document.getElementById('settings-workspace-path').value = paths.workspace_path || '';
document.getElementById('settings-allowed-paths').value = (paths.allowed_paths || []).join('\n');
document.getElementById('settings-blocked-paths').value = (paths.blocked_paths || []).join('\n');
} catch {}
try {
const s = await api('/api/settings/search');
document.getElementById('settings-provider').value = s.preferred_provider || 'ddg';
document.getElementById('settings-search-rigor').value = s.search_rigor || 'verified';
if (document.getElementById('settings-search-ollama-model'))
document.getElementById('settings-search-ollama-model').value = s.ollama_model || '';
if (document.getElementById('settings-search-searxng-url'))
document.getElementById('settings-search-searxng-url').value = s.searxng_url || '';
toggleOllamaModelRow(s.preferred_provider);
toggleSearxngUrlRow(s.preferred_provider);
// Keys are loaded via the Credentials tab — not here
} catch {}
try {
const p = await api('/api/settings/agent');
document.getElementById('settings-force-web-fresh').checked = p.force_web_for_fresh !== false;
document.getElementById('settings-memory-fallback').checked = p.memory_fallback_on_search_failure !== false;
document.getElementById('settings-auto-store-web-facts').checked = p.auto_store_web_facts !== false;
document.getElementById('settings-nl-tool-router').checked = p.natural_language_tool_router !== false;
document.getElementById('settings-retrieval-mode').value = p.retrieval_mode || 'standard';
} catch {}
}
// --- Users Tab ---------------------------------------------------------
async function loadUsersTab() {
const container = document.getElementById('users-list');
if (!container) return;
try {
const data = await api('/api/users');
if (!data || !data.users) { container.innerHTML = '<div style="font-size:12px;color:var(--muted)">인증이 비활성화되어 있거나 권한이 없습니다.</div>'; return; }
if (data.users.length === 0) {
container.innerHTML = '<div style="font-size:12px;color:var(--muted)">등록된 사용자가 없습니다.</div>';
return;
}
container.innerHTML = data.users.map(u => `
<div style="border-bottom:1px solid var(--line);padding:8px 0">
<div style="display:flex;align-items:center;gap:10px">
<div style="width:28px;height:28px;border-radius:50%;background:var(--brand);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700">${u.username.charAt(0).toUpperCase()}</div>
<div style="flex:1"><strong style="font-size:13px">${u.username}</strong> <span style="color:var(--muted);font-size:11px">(${u.role})</span></div>
<span style="color:var(--muted);font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : ''}</span>
<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;cursor:pointer" onclick="toggleUserEmail('${u.username}')">✉ 이메일</button>
${u.username !== data.current_user ? `<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--err);color:#fff;border:none;border-radius:8px;cursor:pointer" onclick="removeUser('${u.username}')">삭제</button>` : '<span style="font-size:11px;color:var(--ok);font-weight:600">현재</span>'}
</div>
<div id="user-email-panel-${u.username}" style="display:none;margin-top:8px;padding:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px"></div>
</div>
`).join('');
} catch (e) {
container.innerHTML = '<div style="font-size:12px;color:var(--err)">사용자 목록을 불러올 수 없습니다.</div>';
}
}
async function addUser() {
const username = document.getElementById('new-user-username').value.trim();
const password = document.getElementById('new-user-password').value;
const role = document.getElementById('new-user-role').value;
const errEl = document.getElementById('add-user-error');
errEl.classList.remove('visible');
try {
const data = await api('/api/users', { method: 'POST', body: JSON.stringify({ username, password, role }) });
if (data.success) {
document.getElementById('new-user-username').value = '';
document.getElementById('new-user-password').value = '';
loadUsersTab();
} else {
errEl.textContent = data.error || '사용자 추가 실패';
errEl.classList.add('visible');
}
} catch (e) {
errEl.textContent = e.message || '사용자 추가 실패';
errEl.classList.add('visible');
}
}
async function removeUser(username) {
if (!confirm(`사용자 "${username}"를 삭제하시겠습니까?`)) return;
try {
await api(`/api/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
loadUsersTab();
} catch (e) { alert(e.message || '삭제 실패'); }
}
// --- Per-User Email Accounts -------------------------------------------------
function toggleUserEmail(username) {
const panel = document.getElementById(`user-email-panel-${username}`);
if (!panel) return;
if (panel.style.display === 'none') {
panel.style.display = 'block';
loadUserEmailAccounts(username);
} else {
panel.style.display = 'none';
}
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
async function loadUserEmailAccounts(username) {
const panel = document.getElementById(`user-email-panel-${username}`);
if (!panel) return;
panel.innerHTML = '<div style="font-size:12px;color:var(--muted)">불러오는 중…</div>';
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/email/config`);
const accounts = data.accounts || [];
const def = data.default || '';
const formId = `email-form-${username}`;
const list = accounts.length === 0
? '<div style="font-size:12px;color:var(--muted);margin-bottom:8px">등록된 이메일 계정이 없습니다.</div>'
: accounts.map(a => `
<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:#fff;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;font-size:12px">
<strong style="min-width:90px">${escapeHtml(a.id)}</strong>
${def === a.id ? '<span style="font-size:10px;background:var(--brand);color:#fff;padding:1px 6px;border-radius:6px">기본</span>' : ''}
<span style="color:var(--muted);flex:1;font-family:'IBM Plex Mono',monospace;font-size:11px">${escapeHtml(a.username)} @ ${escapeHtml(a.imap_host)}:${a.imap_port}</span>
${a.password_set ? '' : '<span style="font-size:10px;color:var(--err)">비번 없음</span>'}
${def !== a.id ? `<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;cursor:pointer" onclick="setDefaultUserEmailAccount('${username}','${escapeHtml(a.id)}')">기본 설정</button>` : ''}
<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;cursor:pointer" onclick="testUserEmailAccount('${username}','${escapeHtml(a.id)}',this)" ${a.password_set ? '' : 'disabled'}>테스트</button>
<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--err);color:#fff;border:none;border-radius:6px;cursor:pointer" onclick="deleteUserEmailAccount('${username}','${escapeHtml(a.id)}')">삭제</button>
</div>`).join('');
panel.innerHTML = `
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">이메일 계정 (${accounts.length})</div>
${list}
<details style="margin-top:8px"><summary style="font-size:12px;cursor:pointer;color:var(--muted)">+ 계정 추가/수정</summary>
<div id="${formId}" style="margin-top:8px;padding:10px;background:#fff;border:1px solid var(--line);border-radius:8px;display:grid;grid-template-columns:1fr 1fr;gap:6px;font-size:12px">
<label style="grid-column:1/3"><span style="color:var(--muted);font-size:11px">계정 ID (예: applecherry)</span>
<input data-f="id" type="text" placeholder="applecherry" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;font-family:'IBM Plex Mono',monospace" /></label>
<label><span style="color:var(--muted);font-size:11px">IMAP 호스트</span>
<input data-f="imap_host" type="text" placeholder="imap.example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">IMAP 포트</span>
<input data-f="imap_port" type="number" value="993" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">SMTP 호스트</span>
<input data-f="smtp_host" type="text" placeholder="smtp.example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">SMTP 포트</span>
<input data-f="smtp_port" type="number" value="587" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">사용자명 (이메일)</span>
<input data-f="username" type="text" placeholder="user@example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">비밀번호 (vault에 암호화 저장)</span>
<input data-f="password" type="password" autocomplete="new-password" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)">
<input data-f="imap_tls" type="checkbox" checked /> IMAP TLS</label>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)">
<input data-f="smtp_secure" type="checkbox" /> SMTP secure (465)</label>
<button class="btn btn-primary" style="grid-column:1/3;padding:7px;margin-top:6px;font-size:12px" onclick="submitUserEmailAccount('${username}')">저장</button>
<div id="${formId}-msg" style="grid-column:1/3;font-size:11px;color:var(--err)"></div>
</div>
</details>`;
} catch (e) {
panel.innerHTML = `<div style="font-size:12px;color:var(--err)">불러오기 실패: ${escapeHtml(e.message || e)}</div>`;
}
}
async function submitUserEmailAccount(username) {
const form = document.getElementById(`email-form-${username}`);
const msg = document.getElementById(`email-form-${username}-msg`);
if (!form) return;
const get = (k) => form.querySelector(`[data-f="${k}"]`);
const id = (get('id').value || '').trim();
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(id)) { msg.textContent = '계정 ID는 1-32자 영숫자/대시/언더스코어'; return; }
const body = {
imap_host: get('imap_host').value.trim(),
imap_port: Number(get('imap_port').value) || 993,
imap_tls: get('imap_tls').checked,
smtp_host: get('smtp_host').value.trim(),
smtp_port: Number(get('smtp_port').value) || 587,
smtp_secure: get('smtp_secure').checked,
username: get('username').value.trim(),
password: get('password').value,
};
if (!body.imap_host || !body.smtp_host || !body.username) { msg.textContent = 'IMAP/SMTP 호스트와 사용자명은 필수'; return; }
msg.textContent = '저장 중…'; msg.style.color = 'var(--muted)';
try {
await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(id)}`, {
method: 'PUT', body: JSON.stringify(body),
});
loadUserEmailAccounts(username);
} catch (e) {
msg.textContent = e.message || '저장 실패'; msg.style.color = 'var(--err)';
}
}
async function deleteUserEmailAccount(username, accountId) {
if (!confirm(`계정 "${accountId}" 삭제하시겠습니까? (vault의 비밀번호도 함께 삭제됩니다)`)) return;
try {
await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}`, { method: 'DELETE' });
loadUserEmailAccounts(username);
} catch (e) { alert(e.message || '삭제 실패'); }
}
async function setDefaultUserEmailAccount(username, accountId) {
try {
await api(`/api/users/${encodeURIComponent(username)}/email/default`, {
method: 'PUT', body: JSON.stringify({ accountId }),
});
loadUserEmailAccounts(username);
} catch (e) { alert(e.message || '기본 설정 실패'); }
}
async function testUserEmailAccount(username, accountId, btn) {
const orig = btn.textContent;
btn.disabled = true; btn.textContent = '연결 중…';
try {
const r = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}/test`, { method: 'POST' });
if (r.success) { btn.textContent = '✓ 성공'; setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1500); }
else { alert(`테스트 실패: ${r.error || 'unknown'}`); btn.textContent = orig; btn.disabled = false; }
} catch (e) {
alert(e.message || '테스트 실패'); btn.textContent = orig; btn.disabled = false;
}
}
// --- Context Viewer Tab -------------------------------------------------------
let cvAutoRefreshTimer = null;
async function loadContextViewerTab() {
const listEl = document.getElementById('cv-sessions-list');
const warnEl = document.getElementById('cv-warn-banner');
if (!listEl) return;
listEl.innerHTML = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">로딩 중...</div>';
if (warnEl) warnEl.style.display = 'none';
try {
const data = await api('/api/admin/context-viewer');
if (!data || !data.sessions) {
listEl.innerHTML = '<div style="font-size:12px;color:var(--err)">권한이 없거나 데이터를 불러올 수 없습니다.</div>';
return;
}
const sessions = data.sessions;
if (sessions.length === 0) {
listEl.innerHTML = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">세션이 없습니다.</div>';
return;
}
// Token warning banner
const warnSessions = sessions.filter(s => s.usagePct >= 70);
if (warnEl && warnSessions.length > 0) {
warnEl.style.display = 'block';
warnEl.innerHTML = '⚠️ 토큰 경고: ' + warnSessions.map(s => {
const color = s.usagePct >= 90 ? '#dc2626' : '#d97706';
return `<strong style="color:${color}">${s.username}</strong> (${s.id.slice(0,8)}…) ${s.usagePct}%`;
}).join(' &nbsp;|&nbsp; ');
}
listEl.innerHTML = sessions.map(s => {
const pct = s.usagePct;
const barColor = pct >= 90 ? 'var(--err)' : pct >= 70 ? 'var(--warn)' : 'var(--ok)';
const lastActive = s.lastActiveAt ? new Date(s.lastActiveAt).toLocaleString() : '-';
const onlineDot = s.online
? '<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px"></span>'
: '<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--line-strong);margin-right:4px"></span>';
return `
<div style="border:1px solid var(--line);border-radius:12px;padding:12px;background:var(--panel)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div style="display:flex;align-items:center;gap:8px">
${onlineDot}
<strong style="font-size:13px">${s.username}</strong>
<span style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--muted)">${s.id.slice(0,12)}…</span>
</div>
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:11px;color:var(--muted)">${lastActive}</span>
${pct >= 70 ? `<span style="font-size:11px;font-weight:700;color:${barColor};padding:2px 7px;border-radius:6px;background:${pct>=90?'#fee2e2':'#fff7ed'}">${pct}%</span>` : ''}
</div>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
<div style="flex:1;height:6px;border-radius:3px;background:var(--line);overflow:hidden">
<div style="height:100%;width:${Math.min(pct,100)}%;background:${barColor};border-radius:3px;transition:width .3s"></div>
</div>
<span style="font-size:11px;color:var(--muted);white-space:nowrap">${s.contextTokenEstimate.toLocaleString()} / ${s.numCtx.toLocaleString()} tok</span>
</div>
<div style="display:flex;gap:8px;font-size:11px;color:var(--muted);margin-bottom:6px">
<span>메시지 ${s.historyCount}개</span>
</div>
${s.lastUserMessage ? `<div style="font-size:11px;padding:6px 8px;background:var(--panel-2);border-radius:8px;margin-bottom:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%"><span style="color:var(--brand);font-weight:600">사용자: </span>${escHtml(s.lastUserMessage)}</div>` : ''}
${s.lastAssistantMessage ? `<div style="font-size:11px;padding:6px 8px;background:var(--panel-2);border-radius:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%"><span style="color:var(--ok);font-weight:600">AI: </span>${escHtml(s.lastAssistantMessage)}</div>` : ''}
</div>`;
}).join('');
} catch (e) {
listEl.innerHTML = `<div style="font-size:12px;color:var(--err)">오류: ${e.message || '알 수 없는 오류'}</div>`;
}
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// --- PPT Template & Skin Tab ---------------------------------------------------
var pptTemplates = [];
var pptSkins = [];
var selectedPptTemplate = 'business';
var selectedPptSkin = '';
async function loadPptTab() {
try {
const [tRes, sRes, cfg] = await Promise.all([
api('/api/ppt/templates'),
api('/api/ppt/skins'),
api('/api/settings/ppt').catch(() => ({})),
]);
pptTemplates = tRes.templates || [];
pptSkins = sRes.skins || [];
selectedPptTemplate = cfg.template || 'business';
selectedPptSkin = cfg.skin || '';
renderPptTemplates();
renderPptSkins();
} catch (e) { console.error('loadPptTab', e); }
}
function renderPptTemplates() {
var grid = document.getElementById('ppt-templates-grid');
if (!grid) return;
grid.innerHTML = pptTemplates.map(function(t) {
var sel = t.name.toLowerCase() === selectedPptTemplate;
var c = t.colors || {};
return '<div onclick="selectPptTemplate(\'' + t.name.toLowerCase() + '\')" style="cursor:pointer;border:2px solid ' + (sel ? c.accent || '#1668E3' : 'var(--line)') + ';border-radius:10px;padding:10px;background:' + (sel ? '#f0f6ff' : '#fff') + ';transition:all .15s">'
+ '<div style="font-weight:700;font-size:13px;margin-bottom:6px;color:' + (c.title || '#1A1A2E') + '">' + escHtml(t.name) + '</div>'
+ '<div style="font-size:11px;color:var(--muted);margin-bottom:8px">' + escHtml(t.description || '') + '</div>'
+ '<div style="display:flex;gap:4px">'
+ '<span style="display:inline-block;width:18px;height:18px;border-radius:4px;background:#' + (c.accent || '1668E3') + '"></span>'
+ '<span style="display:inline-block;width:18px;height:18px;border-radius:4px;background:#' + (c.title || '1A1A2E') + '"></span>'
+ '<span style="display:inline-block;width:18px;height:18px;border-radius:4px;background:#' + (c.subtitle || '5F6F86') + '"></span>'
+ '<span style="display:inline-block;width:18px;height:18px;border-radius:4px;background:#' + (c.background || 'FFFFFF') + ';border:1px solid var(--line)"></span>'
+ '</div>'
+ (sel ? '<div style="margin-top:6px;font-size:10px;color:#0d4faf;font-weight:600">선택됨</div>' : '')
+ '</div>';
}).join('');
}
function renderPptSkins() {
var grid = document.getElementById('ppt-skins-grid');
if (!grid) return;
grid.innerHTML = '<div onclick="selectPptSkin(\'\')" style="cursor:pointer;border:2px solid ' + (selectedPptSkin === '' ? '#1668E3' : 'var(--line)') + ';border-radius:10px;padding:10px;background:#fff;text-align:center">'
+ '<div style="font-size:12px;color:var(--muted)">없음</div>'
+ (selectedPptSkin === '' ? '<div style="font-size:10px;color:#0d4faf;font-weight:600;margin-top:4px">선택됨</div>' : '')
+ '</div>'
+ pptSkins.map(function(s) {
var sel = s.name === selectedPptSkin;
return '<div onclick="selectPptSkin(\'' + escHtml(s.name) + '\')" style="cursor:pointer;border:2px solid ' + (sel ? '#1668E3' : 'var(--line)') + ';border-radius:10px;padding:8px;background:#fff;text-align:center">'
+ '<img src="/api/ppt/skins/' + encodeURIComponent(s.name) + '/thumb" style="width:100%;height:40px;object-fit:cover;border-radius:6px;margin-bottom:4px" />'
+ '<div style="font-size:11px;font-weight:600;color:var(--text)">' + escHtml(s.name) + '</div>'
+ (sel ? '<div style="font-size:10px;color:#0d4faf;font-weight:600">선택됨</div>' : '')
+ '</div>';
}).join('');
}
function selectPptTemplate(name) {
selectedPptTemplate = name;
renderPptTemplates();
savePptPrefs();
}
function selectPptSkin(name) {
selectedPptSkin = name;
renderPptSkins();
savePptPrefs();
}
async function savePptPrefs() {
try {
await fetch('/api/settings/ppt', {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ template: selectedPptTemplate, skin: selectedPptSkin }),
});
} catch (e) { console.error('savePptPrefs', e); }
}
function closeSettings() {
document.getElementById('settings-modal').style.display = 'none';
}
function ensureAgentMdEditor() {
if (agentMdEditor || typeof CodeMirror === 'undefined') return;
const wrap = document.getElementById('agent-md-editor-wrap');
if (!wrap) return;
agentMdEditor = CodeMirror(wrap, {
value: '',
mode: 'markdown',
lineNumbers: true,
lineWrapping: true,
tabSize: 2,
indentWithTabs: false,
theme: 'default',
});
agentMdEditor.setSize('100%', 240);
}
function getAgentFromForm() {
const profile = document.getElementById('agent-edit-profile').value.trim();
const maxStepsRaw = document.getElementById('agent-edit-max-steps').value;
const maxSteps = Number(maxStepsRaw);
const spawnAllowlist = (document.getElementById('agent-edit-spawn-allowlist').value || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
const agent = {
id: document.getElementById('agent-edit-id').value.trim(),
name: document.getElementById('agent-edit-name').value.trim(),
emoji: document.getElementById('agent-edit-emoji').value.trim(),
description: document.getElementById('agent-edit-description').value.trim(),
workspace: document.getElementById('agent-edit-workspace').value.trim(),
model: document.getElementById('agent-edit-model').value.trim(),
cronSchedule: document.getElementById('agent-edit-cron').value.trim(),
minimalPrompt: document.getElementById('agent-edit-minimal').checked,
default: document.getElementById('agent-edit-default').checked,
canSpawn: document.getElementById('agent-edit-can-spawn').checked,
spawnAllowlist,
tools: {},
};
if (profile) agent.tools.profile = profile;
if (Number.isFinite(maxSteps) && maxSteps > 0) agent.maxSteps = Math.floor(maxSteps);
if (!agent.tools.profile) delete agent.tools;
if (!agent.spawnAllowlist.length) delete agent.spawnAllowlist;
return agent;
}
function setAgentForm(agent) {
const a = agent || {};
document.getElementById('agent-edit-id').value = a.id || '';
document.getElementById('agent-edit-name').value = a.name || '';
document.getElementById('agent-edit-emoji').value = a.emoji || '';
document.getElementById('agent-edit-description').value = a.description || '';
document.getElementById('agent-edit-workspace').value = a.workspace || '';
document.getElementById('agent-edit-model').value = a.model || '';
document.getElementById('agent-edit-cron').value = a.cronSchedule || '';
document.getElementById('agent-edit-max-steps').value = (a.maxSteps || '') + '';
document.getElementById('agent-edit-default').checked = a.default === true;
document.getElementById('agent-edit-minimal').checked = a.minimalPrompt === true;
document.getElementById('agent-edit-can-spawn').checked = a.canSpawn === true;
document.getElementById('agent-edit-spawn-allowlist').value = Array.isArray(a.spawnAllowlist) ? a.spawnAllowlist.join(',') : '';
document.getElementById('agent-edit-profile').value = a.tools?.profile || '';
}
function renderAgentsList() {
const el = document.getElementById('settings-agents-list');
if (!el) return;
if (!agentsConfigList.length) {
el.innerHTML = '<div style="color:var(--muted)">No agents found.</div>';
return;
}
el.innerHTML = agentsConfigList.map((a) => {
const selected = a.id === selectedAgentId;
const lastRun = a.lastRun?.finishedAt ? new Date(a.lastRun.finishedAt).toLocaleString() : 'never';
const heartbeat = a.lastHeartbeatAt ? new Date(a.lastHeartbeatAt).toLocaleString() : 'never';
const badge = a.default ? '<span style="font-size:10px;padding:2px 6px;border-radius:999px;background:#eaf2ff;color:#0d4faf;border:1px solid #bdd3f6">default</span>' : '';
return `
<button onclick="selectAgent('${escHtml(a.id)}')" style="text-align:left;border:1px solid ${selected ? '#bdd3f6' : 'var(--line)'};border-radius:10px;padding:8px;background:${selected ? '#f5f9ff' : '#fff'};cursor:pointer">
<div style="display:flex;align-items:center;gap:6px">
<div style="font-size:15px">${escHtml(a.emoji || '🤖')}</div>
<div style="font-weight:700;color:var(--text)">${escHtml(a.name || a.id)}</div>
${badge}
</div>
<div style="margin-top:4px;font-family:'IBM Plex Mono',monospace;color:var(--muted)">${escHtml(a.id)}</div>
<div style="margin-top:4px;font-size:11px;color:var(--muted)">last run: ${escHtml(lastRun)}</div>
<div style="font-size:11px;color:var(--muted)">last heartbeat: ${escHtml(heartbeat)}</div>
</button>
`;
}).join('');
}
function findSelectedAgent() {
return agentsConfigList.find(a => a.id === selectedAgentId) || null;
}
function agentFormNew() {
selectedAgentId = '';
setAgentForm({
id: '',
name: '',
emoji: '',
description: '',
workspace: '',
model: '',
cronSchedule: '',
maxSteps: '',
default: false,
minimalPrompt: true,
canSpawn: false,
spawnAllowlist: [],
tools: {},
});
if (agentMdEditor) agentMdEditor.setValue('');
const mdPath = document.getElementById('agent-md-path');
if (mdPath) mdPath.textContent = '에이전트를 선택하거나 저장하면 AGENTS.md를 불러옵니다';
const resultEl = document.getElementById('agent-spawn-result');
if (resultEl) resultEl.textContent = '';
renderAgentsList();
}
async function selectAgent(id) {
selectedAgentId = id;
const selected = findSelectedAgent();
setAgentForm(selected);
renderAgentsList();
await loadSelectedAgentMd();
await loadAgentRunHistory();
}
async function loadAgentsTab() {
ensureAgentMdEditor();
try {
const data = await api('/api/agents');
agentsConfigList = Array.isArray(data?.agents) ? data.agents : [];
if (!selectedAgentId || !agentsConfigList.some(a => a.id === selectedAgentId)) {
selectedAgentId = data?.defaultAgentId || agentsConfigList[0]?.id || '';
}
renderAgentsList();
const selected = findSelectedAgent();
setAgentForm(selected);
if (selected) {
await loadSelectedAgentMd();
await loadAgentRunHistory();
} else {
agentFormNew();
}
} catch (err) {
addProcessEntry('error', `Failed to load agents: ${err.message}`);
}
}
async function saveAgentFromForm() {
const agent = getAgentFromForm();
if (!agent.id) {
alert('Agent ID is required');
return;
}
if (!agent.name) {
alert('Agent name is required');
return;
}
try {
const exists = agentsConfigList.some(a => a.id === agent.id);
const endpoint = exists ? `/api/agents/${encodeURIComponent(agent.id)}` : '/api/agents';
const method = exists ? 'PUT' : 'POST';
const data = await api(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent }),
});
if (!data?.success) throw new Error(data?.error || 'save failed');
selectedAgentId = data?.agent?.id || agent.id;
addProcessEntry('info', `Agent "${selectedAgentId}" saved.`);
await loadAgentsTab();
} catch (err) {
alert(`Failed to save agent: ${err.message}`);
}
}
async function deleteSelectedAgent() {
if (!selectedAgentId) return;
if (!confirm(`Delete agent "${selectedAgentId}"?`)) return;
try {
await api(`/api/agents/${encodeURIComponent(selectedAgentId)}`, { method: 'DELETE' });
addProcessEntry('info', `Agent "${selectedAgentId}" deleted.`);
selectedAgentId = '';
await loadAgentsTab();
} catch (err) {
alert(`Failed to delete agent: ${err.message}`);
}
}
async function loadSelectedAgentMd() {
ensureAgentMdEditor();
if (!selectedAgentId || !agentMdEditor) return;
try {
const data = await api(`/api/agents/${encodeURIComponent(selectedAgentId)}/agents-md`);
agentMdEditor.setValue(data?.content || '');
const pathEl = document.getElementById('agent-md-path');
if (pathEl) pathEl.textContent = data?.path || '';
} catch (err) {
addProcessEntry('error', `Failed to load AGENTS.md: ${err.message}`);
}
}
async function saveSelectedAgentMd() {
if (!selectedAgentId || !agentMdEditor) return;
try {
const content = agentMdEditor.getValue();
await api(`/api/agents/${encodeURIComponent(selectedAgentId)}/agents-md`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
addProcessEntry('final', `Saved AGENTS.md for "${selectedAgentId}".`);
} catch (err) {
alert(`Failed to save AGENTS.md: ${err.message}`);
}
}
async function runSelectedAgentOnce() {
if (!selectedAgentId) return;
const task = (document.getElementById('agent-spawn-task').value || '').trim();
if (!task) {
alert('Provide a task first.');
return;
}
const out = document.getElementById('agent-spawn-result');
if (out) out.textContent = '실행 중...';
try {
const data = await api(`/api/agents/${encodeURIComponent(selectedAgentId)}/spawn`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task }),
});
const result = data?.result || {};
if (out) {
const head = result.success ? `Success (${result.durationMs || 0}ms)` : `Failed: ${result.error || 'unknown error'}`;
out.textContent = `${head}\n\n${String(result.result || '').slice(0, 2500)}`;
}
await loadAgentsTab();
} catch (err) {
if (out) out.textContent = `Run failed: ${err.message}`;
}
}
async function loadAgentRunHistory() {
const el = document.getElementById('agent-run-history');
if (!el) return;
if (!selectedAgentId) { el.innerHTML = ''; return; }
try {
const data = await api(`/api/agents/history?agentId=${encodeURIComponent(selectedAgentId)}&limit=12`);
const rows = Array.isArray(data?.history) ? data.history : [];
if (!rows.length) {
el.innerHTML = '<div style="color:var(--muted)">No runs yet.</div>';
return;
}
el.innerHTML = rows.map((r) => {
const color = r.success ? '#1a6e35' : '#9c1a1a';
const when = new Date(r.finishedAt || r.startedAt || Date.now()).toLocaleString();
const label = `${r.trigger || 'manual'} • ${r.durationMs || 0}ms`;
const preview = String(r.resultPreview || r.error || '').slice(0, 140);
return `<div style="border:1px solid var(--line);border-radius:8px;padding:6px 8px">
<div style="display:flex;justify-content:space-between;gap:8px">
<span style="font-weight:700;color:${color}">${r.success ? 'success' : 'failed'}</span>
<span style="font-size:11px;color:var(--muted)">${escHtml(when)}</span>
</div>
<div style="font-size:11px;color:var(--muted)">${escHtml(label)}</div>
<div style="font-size:11px;color:var(--text);margin-top:2px">${escHtml(preview)}</div>
</div>`;
}).join('');
} catch (err) {
el.innerHTML = `<div style="color:#9c1a1a">Failed to load history: ${escHtml(err.message)}</div>`;
}
}
// --- Channels Settings Functions ---------------------------------------
let channelsStatusLoaded = false;
function setChannelStatus(idPrefix, state, message) {
const dot = document.getElementById(`${idPrefix}-status-dot`);
const text = document.getElementById(`${idPrefix}-status-text`);
if (!dot || !text) return;
if (state === 'ok') {
dot.style.background = 'var(--ok)';
text.style.color = 'var(--text)';
} else if (state === 'warn') {
dot.style.background = '#f0ad4e';
text.style.color = '#b87b00';
} else {
dot.style.background = '#ccc';
text.style.color = 'var(--muted)';
}
text.textContent = message || '미설정';
}
function readChannelPayload(channel) {
if (channel === 'telegram') {
const userIdStr = (document.getElementById('settings-tg-userid')?.value || '').trim();
const allowedUserIds = userIdStr ? userIdStr.split(/[,\s]+/).map(Number).filter(n => !isNaN(n) && n > 0) : [];
return {
enabled: !!document.getElementById('settings-tg-enabled')?.checked,
botToken: (document.getElementById('settings-tg-token')?.value || '').trim(),
allowedUserIds,
streamMode: 'full',
};
}
if (channel === 'discord') {
return {
enabled: !!document.getElementById('settings-dc-enabled')?.checked,
botToken: (document.getElementById('settings-dc-token')?.value || '').trim(),
applicationId: (document.getElementById('settings-dc-appid')?.value || '').trim(),
guildId: (document.getElementById('settings-dc-guildid')?.value || '').trim(),
channelId: (document.getElementById('settings-dc-channelid')?.value || '').trim(),
webhookUrl: (document.getElementById('settings-dc-webhook')?.value || '').trim(),
};
}
return {
enabled: !!document.getElementById('settings-wa-enabled')?.checked,
accessToken: (document.getElementById('settings-wa-token')?.value || '').trim(),
phoneNumberId: (document.getElementById('settings-wa-phoneid')?.value || '').trim(),
businessAccountId: (document.getElementById('settings-wa-baid')?.value || '').trim(),
verifyToken: (document.getElementById('settings-wa-verify')?.value || '').trim(),
webhookSecret: (document.getElementById('settings-wa-secret')?.value || '').trim(),
testRecipient: (document.getElementById('settings-wa-recipient')?.value || '').trim(),
};
}
function setButtonBusy(id, busy, busyLabel, normalLabel) {
const btn = document.getElementById(id);
if (!btn) return;
btn.disabled = !!busy;
btn.textContent = busy ? busyLabel : normalLabel;
}
function getSelectedChannelType() {
return String(document.getElementById('settings-channel-select')?.value || 'telegram');
}
function onChannelTypeChange() {
const ch = getSelectedChannelType();
const formMap = {
telegram: 'channel-form-telegram',
discord: 'channel-form-discord',
whatsapp: 'channel-form-whatsapp',
email: 'channel-form-email',
};
const guideMap = {
telegram: 'channel-guide-telegram',
discord: 'channel-guide-discord',
whatsapp: 'channel-guide-whatsapp',
email: 'channel-guide-email',
};
const statusMap = {
telegram: 'tg-status-bar',
discord: 'dc-status-bar',
whatsapp: 'wa-status-bar',
email: 'em-status-bar',
};
['telegram', 'discord', 'whatsapp', 'email'].forEach((k) => {
const form = document.getElementById(formMap[k]);
const guide = document.getElementById(guideMap[k]);
const status = document.getElementById(statusMap[k]);
if (form) form.style.display = (k === ch) ? 'block' : 'none';
if (guide) guide.style.display = (k === ch) ? 'block' : 'none';
if (status) status.style.display = (k === ch) ? 'flex' : 'none';
});
// Email: hide test/send-test buttons (email has per-account test)
const testBtn = document.getElementById('channel-test-btn');
const sendTestBtn = document.getElementById('channel-send-test-btn');
if (testBtn) testBtn.style.display = (ch === 'email') ? 'none' : '';
if (sendTestBtn) sendTestBtn.style.display = (ch === 'email') ? 'none' : '';
// Load email accounts when switching to email tab
if (ch === 'email') loadEmailAccounts();
}
async function saveSelectedChannelSettings() {
await saveChannelSettings(getSelectedChannelType());
}
async function testSelectedChannel() {
await testChannel(getSelectedChannelType());
}
async function sendSelectedChannelTest() {
await sendChannelTest(getSelectedChannelType());
}
async function loadChannelsStatus() {
try {
const data = await api('/api/channels/status');
if (!data?.success) return;
const tg = data.telegram || {};
const dc = data.discord || {};
const wa = data.whatsapp || {};
if (tg.connected && tg.polling) setChannelStatus('tg', 'ok', `Connected as @${tg.username || 'bot'}`);
else if (tg.hasToken) setChannelStatus('tg', 'warn', 'Token saved, not connected');
else setChannelStatus('tg', 'off', '미설정');
if (dc.hasToken || dc.hasWebhook) setChannelStatus('dc', 'warn', 'Credentials saved');
else setChannelStatus('dc', 'off', '미설정');
if (wa.hasAccessToken && wa.phoneNumberId) setChannelStatus('wa', 'warn', 'Credentials saved');
else setChannelStatus('wa', 'off', '미설정');
if (!channelsStatusLoaded) {
channelsStatusLoaded = true;
if (tg.hasToken) document.getElementById('settings-tg-token').placeholder = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (saved)';
if (dc.hasToken) document.getElementById('settings-dc-token').placeholder = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (saved)';
if (wa.hasAccessToken) document.getElementById('settings-wa-token').placeholder = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (saved)';
document.getElementById('settings-tg-enabled').checked = !!tg.enabled;
document.getElementById('settings-tg-userid').value = (tg.allowedUserIds || []).join(', ');
document.getElementById('settings-dc-enabled').checked = !!dc.enabled;
document.getElementById('settings-dc-appid').value = dc.applicationId || '';
document.getElementById('settings-dc-guildid').value = dc.guildId || '';
document.getElementById('settings-dc-channelid').value = dc.channelId || '';
document.getElementById('settings-wa-enabled').checked = !!wa.enabled;
document.getElementById('settings-wa-phoneid').value = wa.phoneNumberId || '';
document.getElementById('settings-wa-baid').value = wa.businessAccountId || '';
document.getElementById('settings-wa-recipient').value = wa.testRecipient || '';
}
onChannelTypeChange();
} catch (err) {
console.error('[Channels] Status load failed:', err);
}
// Load heartbeat notify toggle
try {
const hbData = await api('/api/heartbeat/config');
if (hbData?.success) {
document.getElementById('settings-hb-notify').checked = hbData.config.notifyEnabled !== false;
updateHbNotifyLabel();
}
} catch {}
}
async function saveChannelSettings(channel) {
const payload = readChannelPayload(channel);
try {
const data = await api('/api/channels/config', {
method: 'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ channels: { [channel]: payload } }),
});
if (data?.success) {
addProcessEntry('final', `${channel} settings saved.`);
channelsStatusLoaded = false;
await loadChannelsStatus();
} else {
alert('Save failed: ' + (data?.error || 'unknown error'));
}
} catch (err) {
alert('Save failed: ' + err.message);
}
}
async function testChannel(channel) {
const btnId = document.getElementById('channel-test-btn')
? 'channel-test-btn'
: channel === 'telegram' ? 'tg-test-btn' : channel === 'discord' ? 'dc-test-btn' : 'wa-test-btn';
setButtonBusy(btnId, true, 'Testing...', 'Test');
try {
const payload = readChannelPayload(channel);
const data = await api(`/api/channels/test/${channel}`, {
method: 'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload),
});
if (data?.success) {
if (channel === 'telegram') setChannelStatus('tg', 'ok', `Valid bot @${data?.bot?.username || 'telegram'}`);
if (channel === 'discord') setChannelStatus('dc', 'ok', `Valid bot @${data?.bot?.username || 'discord'}`);
if (channel === 'whatsapp') setChannelStatus('wa', 'ok', `Valid phone ${data?.account?.display_phone_number || data?.account?.id || ''}`.trim());
addProcessEntry('final', `${channel} connection test passed.`);
} else {
alert(`${channel} test failed: ${data?.error || 'unknown error'}`);
}
} catch (err) {
alert(`${channel} test failed: ${err.message}`);
}
setButtonBusy(btnId, false, 'Testing...', 'Test');
}
async function sendChannelTest(channel) {
const btnId = document.getElementById('channel-send-test-btn')
? 'channel-send-test-btn'
: channel === 'telegram' ? 'tg-send-test-btn' : channel === 'discord' ? 'dc-send-test-btn' : 'wa-send-test-btn';
setButtonBusy(btnId, true, 'Sending...', 'Send Test');
try {
const payload = readChannelPayload(channel);
const data = await api(`/api/channels/send-test/${channel}`, {
method: 'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload),
});
if (data?.success) {
addProcessEntry('final', `Test message sent via ${channel}.`);
} else {
alert(`${channel} send-test failed: ${data?.error || 'unknown error'}`);
}
} catch (err) {
alert(`${channel} send-test failed: ${err.message}`);
}
setButtonBusy(btnId, false, 'Sending...', 'Send Test');
}
// ─── Email Account Management (Channel Settings) ─────────────────────────
let emailAccountsCache = [];
function getCurrentUsername() {
if (typeof currentUser === 'object' && currentUser?.username) return currentUser.username;
try { return localStorage.getItem(LAST_USER_KEY) || ''; } catch { return ''; }
}
async function loadEmailAccounts() {
const username = getCurrentUsername();
if (!username) {
document.getElementById('email-accounts-list').innerHTML = '<div style="font-size:12px;color:var(--muted)">사용자 정보를 불러올 수 없습니다.</div>';
return;
}
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/email/config`);
const accounts = data?.accounts || [];
emailAccountsCache = accounts;
const count = accounts.length;
if (count > 0) setChannelStatus('em', 'ok', `${count}개 계정 연결됨`);
else setChannelStatus('em', 'off', '미설정');
renderEmailAccountsList(accounts);
} catch (err) {
document.getElementById('email-accounts-list').innerHTML = '<div style="font-size:12px;color:var(--muted)">이메일 계정을 불러올 수 없습니다.</div>';
}
}
function renderEmailAccountsList(accounts) {
const container = document.getElementById('email-accounts-list');
if (!accounts.length) {
container.innerHTML = '<div style="font-size:12px;color:var(--muted);margin-bottom:6px">등록된 이메일 계정이 없습니다.</div>';
return;
}
container.innerHTML = accounts.map(a => {
const esc = s => String(s||'').replace(/</g,'&lt;');
return `<div style="border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin-bottom:6px;background:var(--panel-2)">
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<span style="font-weight:600;font-size:13px">${esc(a.id)}</span>
<span style="font-size:11px;color:var(--muted);margin-left:6px">${esc(a.username)}</span>
</div>
<div style="display:flex;gap:4px">
<button class="btn btn-sm" onclick="testEmailAccount('${esc(a.id)}')" style="background:#fff;border:1px solid var(--line);color:var(--text);padding:3px 8px;font-size:11px">테스트</button>
<button class="btn btn-sm" onclick="editEmailAccount('${esc(a.id)}')" style="background:#fff;border:1px solid var(--line);color:var(--text);padding:3px 8px;font-size:11px">수정</button>
<button class="btn btn-sm" onclick="deleteEmailAccount('${esc(a.id)}')" style="background:#fff;border:1px solid #e55;color:#e55;padding:3px 8px;font-size:11px">삭제</button>
</div>
</div>
<div style="font-size:11px;color:var(--muted);margin-top:2px">IMAP: ${esc(a.imap_host)}:${a.imap_port} | SMTP: ${esc(a.smtp_host)}:${a.smtp_port}</div>
</div>`;
}).join('');
}
async function saveEmailAccount() {
const username = getCurrentUsername();
if (!username) { alert('사용자 정보를 확인할 수 없습니다.'); return; }
const id = document.getElementById('em-id').value.trim();
if (!id) { alert('계정 ID를 입력하세요.'); return; }
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(id)) { alert('계정 ID는 1-32자의 영문, 숫자, 대시, 언더스코어만 가능합니다.'); return; }
const username_field = document.getElementById('em-username').value.trim();
if (!username_field) { alert('이메일 주소를 입력하세요.'); return; }
const imap_host = document.getElementById('em-imap-host').value.trim();
if (!imap_host) { alert('IMAP 서버를 입력하세요.'); return; }
const smtp_host = document.getElementById('em-smtp-host').value.trim();
if (!smtp_host) { alert('SMTP 서버를 입력하세요.'); return; }
const payload = {
imap_host,
imap_port: Number(document.getElementById('em-imap-port').value) || 993,
imap_tls: !!document.getElementById('em-imap-tls').checked,
imap_reject_unauthorized: true,
smtp_host,
smtp_port: Number(document.getElementById('em-smtp-port').value) || 587,
smtp_secure: !!document.getElementById('em-smtp-secure').checked,
smtp_reject_unauthorized: true,
username: username_field,
password: document.getElementById('em-password').value || undefined,
};
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (data?.success) {
addProcessEntry('final', `Email account "${id}" saved.`);
document.getElementById('em-password').value = '';
await loadEmailAccounts();
} else {
alert('저장 실패: ' + (data?.error || '알 수 없는 오류'));
}
} catch (err) {
alert('저장 실패: ' + err.message);
}
}
async function testEmailAccount(accountId) {
const username = getCurrentUsername();
if (!username) return;
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}/test`, {
method: 'POST',
});
if (data?.success) {
addProcessEntry('final', `Email account "${accountId}" IMAP connection OK.`);
} else {
alert(`테스트 실패: ${data?.error || '알 수 없는 오류'}`);
}
} catch (err) {
alert('테스트 실패: ' + err.message);
}
}
function editEmailAccount(accountId) {
const acct = emailAccountsCache.find(a => a.id === accountId);
if (!acct) return;
document.getElementById('em-id').value = acct.id;
document.getElementById('em-username').value = acct.username || '';
document.getElementById('em-imap-host').value = acct.imap_host || '';
document.getElementById('em-imap-port').value = acct.imap_port || 993;
document.getElementById('em-smtp-host').value = acct.smtp_host || '';
document.getElementById('em-smtp-port').value = acct.smtp_port || 587;
document.getElementById('em-imap-tls').checked = acct.imap_tls !== false;
document.getElementById('em-smtp-secure').checked = acct.smtp_secure === true;
document.getElementById('em-password').value = '';
document.getElementById('em-password').placeholder = '•••••••• (변경 시에만 입력)';
}
async function deleteEmailAccount(accountId) {
if (!confirm(`이메일 계정 "${accountId}"를 삭제하시겠습니까?`)) return;
const username = getCurrentUsername();
if (!username) return;
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}`, {
method: 'DELETE',
});
if (data?.success) {
addProcessEntry('final', `Email account "${accountId}" deleted.`);
await loadEmailAccounts();
} else {
alert('삭제 실패: ' + (data?.error || '알 수 없는 오류'));
}
} catch (err) {
alert('삭제 실패: ' + err.message);
}
}
// ─── Heartbeat Notify Toggle ──────────────────────────────────────────────
function updateHbNotifyLabel() {
const on = document.getElementById('settings-hb-notify')?.checked;
const label = document.getElementById('settings-hb-notify-label');
if (label) label.textContent = on ? '알림 받기' : '알림 끄기';
}
async function saveHbNotifyConfig() {
const notifyEnabled = !!document.getElementById('settings-hb-notify')?.checked;
try {
const data = await api('/api/heartbeat/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notifyEnabled }),
});
if (data?.success) {
updateHbNotifyLabel();
addProcessEntry('final', notifyEnabled ? '허트비트 알림 켜짐' : '허트비트 알림 꺼짐');
}
} catch {}
}
async function saveSettings() {
const workspace_path = document.getElementById('settings-workspace-path').value.trim();
const allowed_paths = document.getElementById('settings-allowed-paths').value.split('\n').map(s => s.trim()).filter(Boolean);
const blocked_paths = document.getElementById('settings-blocked-paths').value.split('\n').map(s => s.trim()).filter(Boolean);
const payload = {
preferred_provider: document.getElementById('settings-provider').value,
search_rigor: document.getElementById('settings-search-rigor').value,
tavily_api_key: document.getElementById('cred-tavily-key')?.value.trim() || '',
google_api_key: document.getElementById('cred-google-key')?.value.trim() || '',
google_cx: document.getElementById('cred-google-cx')?.value.trim() || '',
brave_api_key: document.getElementById('cred-brave-key')?.value.trim() || '',
ollama_model: document.getElementById('settings-search-ollama-model')?.value.trim() || '',
searxng_url: document.getElementById('settings-search-searxng-url')?.value.trim() || '',
};
const policyPayload = {
force_web_for_fresh: document.getElementById('settings-force-web-fresh').checked,
memory_fallback_on_search_failure: document.getElementById('settings-memory-fallback').checked,
auto_store_web_facts: document.getElementById('settings-auto-store-web-facts').checked,
natural_language_tool_router: document.getElementById('settings-nl-tool-router').checked,
retrieval_mode: document.getElementById('settings-retrieval-mode').value,
};
const primaryModel = (document.getElementById('settings-primary-model') || {}).value || '';
const modelPayload = {
ollama_endpoint: (document.getElementById('settings-ollama-endpoint') || {}).value || 'http://localhost:11434',
primary_model: primaryModel,
role_models: {
manager: primaryModel,
executor: primaryModel,
verifier: primaryModel,
}
};
const providerPayload = buildProviderPayload();
// Keep llm.providers.ollama.model in sync with the primary model
// so getModelForRole() never falls back to the hardcoded 'qwen3:4b'.
if (providerPayload.providers && providerPayload.providers.ollama) {
providerPayload.providers.ollama.model = primaryModel || providerPayload.providers.ollama.model || '';
}
try {
await api('/api/settings/paths', { method: 'POST', body: JSON.stringify({ workspace_path, allowed_paths, blocked_paths }) });
await api('/api/settings/search', { method: 'POST', body: JSON.stringify(payload) });
await api('/api/settings/agent', { method: 'POST', body: JSON.stringify(policyPayload) });
await api('/api/settings/model', { method: 'POST', body: JSON.stringify(modelPayload) });
await api('/api/settings/provider', { method: 'POST', body: JSON.stringify({ llm: providerPayload }) });
await loadSearchSettingsSummary();
quickSearchRigor = payload.search_rigor || quickSearchRigor;
updateQuickModeUI();
addProcessEntry('final', '설정이 저장되었습니다.');
closeSettings();
} catch (err) {
addProcessEntry('error', `설정 저장 실패: ${err.message}`);
}
}
// Approve a memory suggestion shown in the process log
async function confirmMemory(index) {
const entry = processLogEntries[index];
if (!entry || entry.type !== 'memory') return;
const suggestion = entry.extra || {};
try {
addProcessEntry('info', 'Saving memory...');
const res = await api('/api/memory/confirm', { method: 'POST', body: JSON.stringify(suggestion) });
if (res && res.ok === false) {
addProcessEntry('error', `Memory save failed: ${res.error || JSON.stringify(res)}`);
return;
}
processLogEntries[index].type = 'result';
processLogEntries[index].content = `Saved memory: ${String(entry.content).slice(0,200)}`;
renderProcessLog();
addProcessEntry('final', 'Memory persisted.');
} catch (err) {
addProcessEntry('error', `Failed to save memory: ${err.message}`);
}
}
function rejectMemory(index) {
const entry = processLogEntries[index];
if (!entry || entry.type !== 'memory') return;
processLogEntries[index].type = 'warn';
processLogEntries[index].content = 'Memory suggestion rejected.';
renderProcessLog();
addProcessEntry('info', 'Memory suggestion rejected by user.');
}
// ---- Run mission ----
async function runMission() {
const input = document.getElementById('mission-input');
const btn = document.getElementById('run-btn');
const mission = input.value.trim();
if (!mission) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Starting...';
try {
const res = await api('/api/jobs', { method: 'POST', body: JSON.stringify({ mission }) });
if (res.error) {
log(`Error: ${res.error}`, 'error');
} else {
log(`Mission started ? Job ${res.jobId.slice(0, 8)}...`, 'success');
input.value = '';
setTimeout(() => selectJob(res.jobId), 500);
}
} catch (err) {
log(`Failed: ${err.message}`, 'error');
}
btn.disabled = false;
btn.innerHTML = '? Run';
}
// ---- Sessions list ----
function renderSessionsList() {
const el = document.getElementById('jobs-list');
if (!chatSessions || chatSessions.length === 0) {
el.innerHTML = '<div class="empty-state">No chats yet.<br>Start a new conversation.</div>';
return;
}
const sorted = [...chatSessions].sort((a, b) => (b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt));
el.innerHTML = sorted.map(s => `
<div class="job-item ${s.id === activeChatSessionId ? 'active' : ''}${s.unread ? ' unread' : ''}" onclick="openSession('${s.id}')">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<div class="job-item-title" style="flex:1">${s.unread ? '🔵 ' : ''}${escHtml(s.title || 'New chat')}</div>
${sessionsEditMode ? `<button class="sidebar-action" style="padding:3px 8px;font-size:10px" onclick="deleteChatSession('${s.id}', event)">삭제</button>` : ''}
</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
${s.automated ? '<span class="session-auto-badge">Auto</span>' : ''}
<span class="badge badge-queued">${(s.history || []).length} msgs</span>
<span style="color:var(--muted);font-size:10px">${timeAgo(s.updatedAt || s.createdAt)}</span>
</div>
</div>
`).join('');
}
// ---- Select job ----
async function selectJob(jobId) {
selectedJobId = jobId;
setMode('chat');
renderJobsList(allJobs);
await refreshJobDetail(jobId);
clearInterval(pollInterval);
pollInterval = setInterval(async () => {
const job = allJobs.find(j => j.id === jobId);
if (job && ['planning', 'executing', 'verifying'].includes(job.status)) {
await refreshJobDetail(jobId);
} else {
clearInterval(pollInterval);
}
}, 2000);
}
async function refreshJobDetail(jobId) {
try {
const data = await api(`/api/jobs/${jobId}`);
renderJobDetail(data);
} catch {}
}
function renderJobDetail(data) {
const { job, tasks, artifacts, state } = data;
const content = document.getElementById('main-content');
const isRunning = ['planning','executing','verifying'].includes(job.status);
content.innerHTML = `
<div class="job-detail-title">${escHtml(job.title)}</div>
<div class="job-detail-meta">
<span class="badge badge-${job.status}">${isRunning ? '<span class="spinner"></span>' : ''}${job.status}</span>
<span>ID: ${job.id.slice(0, 12)}...</span>
<span>${timeAgo(job.created_at)}</span>
</div>
${state ? `
<div class="section-title">Mission</div>
<div style="color:var(--muted);font-size:12px;line-height:1.7;background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:16px">
${escHtml(state.mission)}
</div>` : ''}
${tasks && tasks.length > 0 ? `
<div class="section-title">Tasks (${tasks.length})</div>
${tasks.map(t => `
<div class="task-card">
<div class="task-card-header">
<span class="task-card-title">${escHtml(t.title)}</span>
<span class="badge badge-${t.status}">${t.status}</span>
</div>
<div class="task-card-desc">${escHtml(t.description || '')}</div>
${t.acceptance_criteria && t.acceptance_criteria.length > 0 ? `
<div class="task-criteria">
${t.acceptance_criteria.map(c => `<div class="task-criteria-item">${escHtml(c)}</div>`).join('')}
</div>` : ''}
</div>
`).join('')}` : `
<div class="section-title">작업</div>
<div style="color:var(--muted);font-size:12px;padding:20px 0">
${isRunning ? '<span class="spinner pulsing"></span> Planning tasks...' : 'No tasks'}
</div>`}
${artifacts && artifacts.length > 0 ? `
<div class="section-title" style="margin-top:16px">Artifacts (${artifacts.length})</div>
${artifacts.map(a => `
<div class="task-card">
<div class="task-card-header">
<span class="task-card-title">${escHtml(a.path || a.type)}</span>
<span class="badge badge-completed">${a.type}</span>
</div>
${a.content ? `<div class="task-card-desc" style="margin-top:6px">${escHtml(a.content.slice(0, 200))}${a.content.length > 200 ? '...' : ''}</div>` : ''}
</div>
`).join('')}` : ''}
<div id="log-panel">${logLines.map(l => `<div class="log-line ${l.type}">${l.text}</div>`).join('')}</div>
`;
const lp = document.getElementById('log-panel');
if (lp) lp.scrollTop = lp.scrollHeight;
}
// ---- Stats ----
function updateStats(jobs) {
const totalEl = document.getElementById('r-total');
const runningEl = document.getElementById('r-running');
const modeEl = document.getElementById('r-completed');
if (totalEl) totalEl.textContent = String(chatSessions.length);
if (runningEl) runningEl.textContent = activeChatSessionId ? '1' : '0';
if (modeEl) modeEl.textContent = useAgentMode ? 'On' : 'Off';
}
// ---- Approvals ----
async function loadApprovals() {
try {
const approvals = await api('/api/approvals');
const el = document.getElementById('approvals-list');
if (!approvals || approvals.length === 0) {
el.innerHTML = '<div style="color:var(--muted);font-size:11px">대기 중인 승인 없음</div>';
return;
}
el.innerHTML = approvals.map(a => `
<div class="approval-card">
<div class="approval-action">${escHtml(a.action)}</div>
<div class="approval-reason">${escHtml(a.reason || 'Approval required')}</div>
<div class="approval-buttons">
<button class="btn btn-sm btn-green" onclick="resolveApproval('${a.id}','approved')">? Approve</button>
<button class="btn btn-sm btn-red" onclick="resolveApproval('${a.id}','rejected')">? Reject</button>
</div>
</div>
`).join('');
} catch {}
}
async function resolveApproval(id, decision) {
await api(`/api/approvals/${id}`, { method: 'POST', body: JSON.stringify({ decision }) });
loadApprovals();
}
function fmtPercent(v, digits = 0) {
const n = Number(v);
if (!Number.isFinite(n)) return '--%';
return `${n.toFixed(digits)}%`;
}
function fmtMemoryGb(usedGb, totalGb) {
const u = Number(usedGb);
const t = Number(totalGb);
if (!Number.isFinite(u) || !Number.isFinite(t) || t <= 0) return '-- / -- GB';
return `${u.toFixed(1)} / ${t.toFixed(1)} GB`;
}
function meterWidth(pct) {
const n = Number(pct);
if (!Number.isFinite(n)) return '0%';
const bounded = Math.max(0, Math.min(100, n));
return `${bounded}%`;
}
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = String(text || '');
}
function setMeter(id, pct) {
const el = document.getElementById(id);
if (!el) return;
el.style.width = meterWidth(pct);
}
let systemStatsFetchInFlight = false;
async function loadSystemStats() {
if (systemStatsFetchInFlight) return;
systemStatsFetchInFlight = true;
try {
const stats = await api('/api/system-stats');
const cpu = stats?.system?.cpu_percent;
const ram = stats?.system?.memory_percent;
const gpu = stats?.gpu?.gpu_util_percent;
const vram = stats?.gpu?.vram_used_percent;
const gpuAvailable = !!stats?.gpu?.available;
setText('quick-system-stats', `CPU ${fmtPercent(cpu)} • RAM ${fmtPercent(ram)} • GPU ${gpuAvailable ? fmtPercent(gpu) : '--%'}`);
setText('sys-cpu', fmtPercent(cpu));
setText('sys-ram', fmtPercent(ram));
setText('sys-gpu', gpuAvailable ? fmtPercent(gpu) : 'N/A');
setText('sys-vram', gpuAvailable ? fmtPercent(vram) : 'N/A');
setMeter('sys-cpu-meter', cpu);
setMeter('sys-ram-meter', ram);
setMeter('sys-gpu-meter', gpuAvailable ? gpu : 0);
setMeter('sys-vram-meter', gpuAvailable ? vram : 0);
const ollamaRunning = !!stats?.ollama_process?.running;
const ollamaCount = Number(stats?.ollama_process?.process_count || 0);
const ollamaMemMb = Number(stats?.ollama_process?.total_memory_mb || 0);
const gatewayRss = Number(stats?.gateway_process?.rss_mb || 0);
const gpuName = String(stats?.gpu?.name || '').trim();
const memUsage = fmtMemoryGb(stats?.system?.memory_used_gb, stats?.system?.memory_total_gb);
const updated = new Date(stats?.timestamp || Date.now()).toLocaleTimeString();
const activeProvider = stats?.active_provider || 'ollama';
const activeModel = stats?.active_model || document.getElementById('current-model')?.textContent || '-';
const isLocalProvider = activeProvider === 'ollama' || activeProvider === 'llama_cpp' || activeProvider === 'lm_studio';
// Show provider + model in the runtime card
const providerDisp = (PROVIDER_LABELS[activeProvider] || activeProvider);
let runtimeLabel;
if (activeProvider === 'ollama') {
runtimeLabel = ollamaRunning ? `Ollama • ${activeModel}` : `Ollama offline • ${activeModel}`;
} else {
runtimeLabel = `${providerDisp} • ${activeModel}`;
}
setText('sys-ollama', runtimeLabel);
const memPart = isLocalProvider && ollamaMemMb > 0 ? ` • LLM ${ollamaMemMb.toFixed(0)} MB` : '';
setText(
'sys-runtime-sub',
`${memUsage}${memPart} • Gateway ${gatewayRss.toFixed(0)} MB${gpuName ? ` • ${gpuName}` : ''} • ${updated}`
);
const onlineUsers = Array.isArray(stats?.online_users) ? stats.online_users : [];
const onlineEl = document.getElementById('sys-online-users');
if (onlineEl) {
if (onlineUsers.length === 0) {
onlineEl.textContent = '없음';
onlineEl.style.color = 'var(--muted)';
} else {
onlineEl.innerHTML = onlineUsers.map(u =>
`<span style="display:inline-flex;align-items:center;gap:4px;margin-right:8px"><span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ok)"></span>${escHtml(String(u))}</span>`
).join('');
onlineEl.style.color = 'var(--text)';
}
}
} catch {
setText('quick-system-stats', 'CPU --% • RAM --% • GPU --%');
setText('sys-cpu', '--%');
setText('sys-ram', '--%');
setText('sys-gpu', 'N/A');
setText('sys-vram', 'N/A');
setText('sys-ollama', 'Stats unavailable');
setText('sys-runtime-sub', 'Could not fetch system stats right now');
setMeter('sys-cpu-meter', 0);
setMeter('sys-ram-meter', 0);
setMeter('sys-gpu-meter', 0);
setMeter('sys-vram-meter', 0);
} finally {
systemStatsFetchInFlight = false;
}
}
// ---- Status ----
const PROVIDER_LABELS = {
ollama: 'Ollama', llama_cpp: 'llama.cpp', lm_studio: 'LM Studio',
openai: 'OpenAI', openai_codex: 'ChatGPT'
};
async function checkStatus() {
try {
const status = await api('/api/status');
const provider = status.provider || 'ollama';
const providerLabel = PROVIDER_LABELS[provider] || provider;
const isCloud = provider === 'openai' || provider === 'openai_codex';
const dot = document.getElementById('ollama-dot');
// For cloud providers, always show as online if gateway is up
const backendOnline = isCloud ? true : !!status.ollama;
dot.className = 'dot ' + (backendOnline ? 'online' : 'offline');
document.getElementById('ollama-status').textContent = backendOnline
? `${providerLabel} Online` : `${providerLabel} Offline`;
const ro = document.getElementById('r-ollama');
if (ro) {
ro.textContent = backendOnline ? 'Online' : 'Offline';
ro.className = 'info-val ' + (backendOnline ? 'green' : 'red');
}
const modelName = status.currentModel || '-';
const rm = document.getElementById('r-model');
if (rm) rm.textContent = modelName;
document.getElementById('current-model').textContent = modelName;
document.getElementById('chat-model-name').textContent = modelName;
const badge = document.getElementById('current-provider-badge');
if (badge) badge.textContent = isCloud ? providerLabel.toUpperCase() : '';
const cpn = document.getElementById('chat-provider-name');
if (cpn) cpn.textContent = providerLabel;
} catch {
document.getElementById('ollama-dot').className = 'dot offline';
}
}
// --- Orchestration Settings -------------------------------------------------
const SECONDARY_MODEL_DEFAULTS = {
openai_codex: 'gpt-5.3-codex',
openai: 'gpt-4o',
ollama: '',
llama_cpp: '',
lm_studio: '',
};
const ORCH_STATIC_MODEL_OPTIONS = {
openai_codex: ['gpt-5.3-codex', 'gpt-5.3-codex-spark', 'gpt-5.2-codex', 'gpt-5.1-codex-mini'],
openai: ['gpt-4o', 'gpt-4o-mini', 'o4-mini', 'o3'],
};
function getSelectOptionValues(selectId) {
const sel = document.getElementById(selectId);
if (!sel) return [];
return Array.from(sel.options || []).map(o => String(o.value || '').trim()).filter(Boolean);
}
function getDraftModelForProvider(provider) {
if (provider === 'ollama') {
return String(document.getElementById('settings-primary-model')?.value || '').trim();
}
if (provider === 'llama_cpp') {
return String(document.getElementById('settings-llamacpp-model')?.value || '').trim();
}
if (provider === 'lm_studio') {
return String(document.getElementById('settings-lmstudio-model')?.value || '').trim();
}
if (provider === 'openai') {
return String(document.getElementById('settings-openai-model')?.value || '').trim();
}
if (provider === 'openai_codex') {
return String(document.getElementById('settings-codex-model')?.value || '').trim();
}
return '';
}
function uniqueStrings(values) {
const seen = new Set();
const out = [];
for (const v of values || []) {
const s = String(v || '').trim();
if (!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out;
}
async function updateOrchSecondaryModels(preferredModel = '') {
const provider = document.getElementById('orch-secondary-provider')?.value || '';
const modelSelect = document.getElementById('orch-secondary-model');
if (!modelSelect) return;
if (!provider) {
modelSelect.innerHTML = '<option value="">— select provider first —</option>';
modelSelect.value = '';
return;
}
let models = [];
const draftModel = getDraftModelForProvider(provider);
if (draftModel) models.push(draftModel);
if (provider === 'openai') {
try { await refreshOpenAIModels(true); } catch {}
models.push(...getSelectOptionValues('settings-openai-model'));
} else if (provider === 'openai_codex') {
models.push(...getSelectOptionValues('settings-codex-model'));
} else if (['ollama', 'llama_cpp', 'lm_studio'].includes(provider)) {
try {
const llm = buildProviderPayload();
llm.provider = provider;
const data = await api('/api/models/test', { method: 'POST', body: JSON.stringify({ llm }) });
const detected = (data?.models || []).map(m => typeof m === 'string' ? m : (m.name || String(m)));
models.push(...detected);
} catch {}
}
models = uniqueStrings([...(ORCH_STATIC_MODEL_OPTIONS[provider] || []), ...models]);
if (preferredModel && !models.includes(preferredModel)) {
models.unshift(preferredModel);
}
if (!models.length && SECONDARY_MODEL_DEFAULTS[provider]) {
models.push(SECONDARY_MODEL_DEFAULTS[provider]);
}
if (!models.length) {
modelSelect.innerHTML = '<option value="">— 감지된 모델 없음 —</option>';
modelSelect.value = '';
return;
}
modelSelect.innerHTML = models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
const selected = preferredModel || modelSelect.value || draftModel || SECONDARY_MODEL_DEFAULTS[provider] || models[0];
modelSelect.value = models.includes(selected) ? selected : models[0];
}
async function loadOrchestrationConfig() {
try {
const cfg = await api('/api/orchestration/config');
const provEl = document.getElementById('orch-secondary-provider');
const failEl = document.getElementById('orch-fail-threshold');
const stagEl = document.getElementById('orch-stagnation');
const loopEl = document.getElementById('orch-loop-detect');
const riskFilesEl = document.getElementById('orch-risk-files');
const riskOpsEl = document.getElementById('orch-risk-ops');
const noProgressEl = document.getElementById('orch-no-progress-sec');
const preflightModeEl = document.getElementById('orch-preflight-mode');
const allowSecondaryChatEl = document.getElementById('orch-allow-secondary-chat');
const cooldownEl = document.getElementById('orch-cooldown-rounds');
const maxAssistTurnEl = document.getElementById('orch-max-assists-turn');
const maxAssistSessionEl = document.getElementById('orch-max-assists-session');
const browserCallsEl = document.getElementById('orch-browser-calls-turn');
const browserItemsEl = document.getElementById('orch-browser-max-items');
const browserRetriesEl = document.getElementById('orch-browser-forced-retries');
const preemptEnabledEl = document.getElementById('orch-preempt-enabled');
const preemptThresholdEl = document.getElementById('orch-preempt-threshold');
const preemptMaxTurnEl = document.getElementById('orch-preempt-max-turn');
const preemptMaxSessionEl = document.getElementById('orch-preempt-max-session');
const fileOpsEnabledEl = document.getElementById('orch-fileops-enabled');
const fileOpsVerifyCreateEl = document.getElementById('orch-fileops-verify-create');
const fileOpsCreateLinesEl = document.getElementById('orch-fileops-create-lines');
const fileOpsCreateCharsEl = document.getElementById('orch-fileops-create-chars');
const fileOpsEditLinesEl = document.getElementById('orch-fileops-edit-lines');
const fileOpsEditCharsEl = document.getElementById('orch-fileops-edit-chars');
const fileOpsEditFilesEl = document.getElementById('orch-fileops-edit-files');
const fileOpsVerifyLinesEl = document.getElementById('orch-fileops-verify-lines');
const fileOpsVerifyCharsEl = document.getElementById('orch-fileops-verify-chars');
const fileOpsWatchdogCyclesEl = document.getElementById('orch-fileops-watchdog-cycles');
const fileOpsCheckpointingEl = document.getElementById('orch-fileops-checkpointing');
if (provEl) provEl.value = cfg.secondary?.provider || '';
await updateOrchSecondaryModels(cfg.secondary?.model || '');
if (failEl) failEl.value = String(cfg.triggers?.consecutive_failures ?? 2);
if (stagEl) stagEl.value = String(cfg.triggers?.stagnation_rounds ?? 3);
if (loopEl) loopEl.value = String(cfg.triggers?.loop_detection ?? true);
if (riskFilesEl) riskFilesEl.value = String(cfg.triggers?.risky_files_threshold ?? 6);
if (riskOpsEl) riskOpsEl.value = String(cfg.triggers?.risky_tool_ops_threshold ?? 220);
if (noProgressEl) noProgressEl.value = String(cfg.triggers?.no_progress_seconds ?? 90);
if (preflightModeEl) preflightModeEl.value = String(cfg.preflight?.mode || 'complex_only');
if (allowSecondaryChatEl) allowSecondaryChatEl.value = String(cfg.preflight?.allow_secondary_chat === true);
if (cooldownEl) cooldownEl.value = String(cfg.limits?.assist_cooldown_rounds ?? 3);
if (maxAssistTurnEl) maxAssistTurnEl.value = String(cfg.limits?.max_assists_per_turn ?? 3);
if (maxAssistSessionEl) maxAssistSessionEl.value = String(cfg.limits?.max_assists_per_session ?? 18);
if (browserCallsEl) browserCallsEl.value = String(cfg.browser?.max_advisor_calls_per_turn ?? 5);
if (browserItemsEl) browserItemsEl.value = String(cfg.browser?.max_collected_items ?? 80);
if (browserRetriesEl) browserRetriesEl.value = String(cfg.browser?.max_forced_retries ?? 2);
if (preemptEnabledEl) preemptEnabledEl.value = String(cfg.preempt?.enabled === true);
if (preemptThresholdEl) preemptThresholdEl.value = String(cfg.preempt?.stall_threshold_seconds ?? 45);
if (preemptMaxTurnEl) preemptMaxTurnEl.value = String(cfg.preempt?.max_preempts_per_turn ?? 1);
if (preemptMaxSessionEl) preemptMaxSessionEl.value = String(cfg.preempt?.max_preempts_per_session ?? 3);
if (fileOpsEnabledEl) fileOpsEnabledEl.value = String(cfg.file_ops?.enabled !== false);
if (fileOpsVerifyCreateEl) fileOpsVerifyCreateEl.value = String(cfg.file_ops?.verify_create_always !== false);
if (fileOpsCreateLinesEl) fileOpsCreateLinesEl.value = String(cfg.file_ops?.primary_create_max_lines ?? 80);
if (fileOpsCreateCharsEl) fileOpsCreateCharsEl.value = String(cfg.file_ops?.primary_create_max_chars ?? 3500);
if (fileOpsEditLinesEl) fileOpsEditLinesEl.value = String(cfg.file_ops?.primary_edit_max_lines ?? 12);
if (fileOpsEditCharsEl) fileOpsEditCharsEl.value = String(cfg.file_ops?.primary_edit_max_chars ?? 800);
if (fileOpsEditFilesEl) fileOpsEditFilesEl.value = String(cfg.file_ops?.primary_edit_max_files ?? 1);
if (fileOpsVerifyLinesEl) fileOpsVerifyLinesEl.value = String(cfg.file_ops?.verify_large_payload_lines ?? 25);
if (fileOpsVerifyCharsEl) fileOpsVerifyCharsEl.value = String(cfg.file_ops?.verify_large_payload_chars ?? 1200);
if (fileOpsWatchdogCyclesEl) fileOpsWatchdogCyclesEl.value = String(cfg.file_ops?.watchdog_no_progress_cycles ?? 3);
if (fileOpsCheckpointingEl) fileOpsCheckpointingEl.value = String(cfg.file_ops?.checkpointing_enabled !== false);
syncOrchPreemptControls();
// Check eligibility
const elig = await api('/api/orchestration/eligible');
const msg = document.getElementById('orch-eligibility-msg');
if (msg) {
if (elig.eligible) {
msg.textContent = '? Orchestration eligible — enable the skill in the Skills tab.';
msg.style.color = 'var(--green, #4ade80)';
} else {
msg.textContent = elig.reason || 'Not eligible.';
msg.style.color = 'var(--muted)';
}
msg.style.display = 'block';
}
} catch {}
}
async function saveOrchestrationConfig() {
const provider = document.getElementById('orch-secondary-provider')?.value || '';
const model = document.getElementById('orch-secondary-model')?.value || '';
const failT = Number(document.getElementById('orch-fail-threshold')?.value || 2);
const stagT = Number(document.getElementById('orch-stagnation')?.value || 3);
const loopD = document.getElementById('orch-loop-detect')?.value !== 'false';
const riskFiles = Number(document.getElementById('orch-risk-files')?.value || 6);
const riskOps = Number(document.getElementById('orch-risk-ops')?.value || 220);
const noProgressSeconds = Number(document.getElementById('orch-no-progress-sec')?.value || 90);
const preflightMode = document.getElementById('orch-preflight-mode')?.value || 'complex_only';
const allowSecondaryChat = document.getElementById('orch-allow-secondary-chat')?.value === 'true';
const cooldownRounds = Number(document.getElementById('orch-cooldown-rounds')?.value || 3);
const maxAssistsTurn = Number(document.getElementById('orch-max-assists-turn')?.value || 3);
const maxAssistsSession = Number(document.getElementById('orch-max-assists-session')?.value || 18);
const browserCallsPerTurn = Number(document.getElementById('orch-browser-calls-turn')?.value || 5);
const browserMaxItems = Number(document.getElementById('orch-browser-max-items')?.value || 80);
const browserForcedRetries = Number(document.getElementById('orch-browser-forced-retries')?.value || 2);
const preemptEnabled = document.getElementById('orch-preempt-enabled')?.value === 'true';
const preemptThresholdSec = Number(document.getElementById('orch-preempt-threshold')?.value || 45);
const preemptMaxTurn = Number(document.getElementById('orch-preempt-max-turn')?.value || 1);
const preemptMaxSession = Number(document.getElementById('orch-preempt-max-session')?.value || 3);
const fileOpsEnabled = document.getElementById('orch-fileops-enabled')?.value !== 'false';
const fileOpsVerifyCreate = document.getElementById('orch-fileops-verify-create')?.value !== 'false';
const fileOpsCreateLines = Number(document.getElementById('orch-fileops-create-lines')?.value || 80);
const fileOpsCreateChars = Number(document.getElementById('orch-fileops-create-chars')?.value || 3500);
const fileOpsEditLines = Number(document.getElementById('orch-fileops-edit-lines')?.value || 12);
const fileOpsEditChars = Number(document.getElementById('orch-fileops-edit-chars')?.value || 800);
const fileOpsEditFiles = Number(document.getElementById('orch-fileops-edit-files')?.value || 1);
const fileOpsVerifyLines = Number(document.getElementById('orch-fileops-verify-lines')?.value || 25);
const fileOpsVerifyChars = Number(document.getElementById('orch-fileops-verify-chars')?.value || 1200);
const fileOpsWatchdogCycles = Number(document.getElementById('orch-fileops-watchdog-cycles')?.value || 3);
const fileOpsCheckpointing = document.getElementById('orch-fileops-checkpointing')?.value !== 'false';
try {
await api('/api/orchestration/config', {
method: 'POST',
body: JSON.stringify({
secondary: { provider, model },
triggers: {
consecutive_failures: failT,
stagnation_rounds: stagT,
loop_detection: loopD,
risky_files_threshold: riskFiles,
risky_tool_ops_threshold: riskOps,
no_progress_seconds: noProgressSeconds,
},
preflight: { mode: preflightMode, allow_secondary_chat: allowSecondaryChat },
limits: {
assist_cooldown_rounds: cooldownRounds,
max_assists_per_turn: maxAssistsTurn,
max_assists_per_session: maxAssistsSession,
},
browser: {
max_advisor_calls_per_turn: browserCallsPerTurn,
max_collected_items: browserMaxItems,
max_forced_retries: browserForcedRetries,
},
preempt: {
enabled: preemptEnabled,
stall_threshold_seconds: preemptThresholdSec,
max_preempts_per_turn: preemptMaxTurn,
max_preempts_per_session: preemptMaxSession,
},
file_ops: {
enabled: fileOpsEnabled,
primary_create_max_lines: fileOpsCreateLines,
primary_create_max_chars: fileOpsCreateChars,
primary_edit_max_lines: fileOpsEditLines,
primary_edit_max_chars: fileOpsEditChars,
primary_edit_max_files: fileOpsEditFiles,
verify_create_always: fileOpsVerifyCreate,
verify_large_payload_lines: fileOpsVerifyLines,
verify_large_payload_chars: fileOpsVerifyChars,
watchdog_no_progress_cycles: fileOpsWatchdogCycles,
checkpointing_enabled: fileOpsCheckpointing,
},
}),
});
const msg = document.getElementById('orch-eligibility-msg');
if (msg) {
msg.textContent = '? Saved. Now enable the Multi-Agent Orchestrator skill in the Skills tab.';
msg.style.color = 'var(--green, #4ade80)';
msg.style.display = 'block';
}
loadOrchestrationConfig(); // refresh eligibility
loadInstalledSkills(); // refresh skill gating state
} catch (err) {
alert('Failed to save: ' + (err.message || err));
}
}
function syncOrchPreemptControls() {
const provider = document.getElementById('settings-llm-provider')?.value || 'ollama';
const note = document.getElementById('orch-preempt-note');
const fields = [
document.getElementById('orch-preempt-enabled'),
document.getElementById('orch-preempt-threshold'),
document.getElementById('orch-preempt-max-turn'),
document.getElementById('orch-preempt-max-session'),
].filter(Boolean);
const ollamaPrimary = provider === 'ollama';
fields.forEach(el => { el.disabled = !ollamaPrimary; });
if (note) note.style.display = ollamaPrimary ? 'none' : 'block';
}
// --- Skill card eligibility rendering ---------------------------------------
function renderSkillEligibility(skills) {
// Called after skills render — grey out ineligible skill cards
setTimeout(() => {
document.querySelectorAll('.skill-card').forEach(card => {
const skillId = card.dataset.skillId;
const skill = skills.find(s => s.id === skillId);
if (!skill) return;
if (skill.eligible === false) {
card.style.opacity = '0.5';
// Keep card clickable when already enabled so user can still disable it.
card.style.pointerEvents = skill.enabled ? 'auto' : 'none';
card.title = skill.eligibleReason || 'Not available';
const badge = document.createElement('div');
badge.textContent = skill.eligibleReason || 'Configure secondary model first';
badge.style = 'font-size:10px;color:var(--muted);margin-top:4px;';
if (!card.querySelector('.elig-badge')) {
badge.className = 'elig-badge';
card.appendChild(badge);
}
}
});
}, 50);
}
// --- Handle orchestration SSE events ----------------------------------------
function handleOrchestrationEvent(data) {
const panel =
document.getElementById('tool-calls-panel')
|| document.getElementById('steps-panel')
|| document.getElementById('current-turn-process')
|| document.getElementById('process-log');
if (!panel) return;
const el = document.createElement('div');
el.style = 'padding:6px 10px;margin:4px 0;background:linear-gradient(135deg,#1a1a2e,#16213e);border-left:3px solid #7c3aed;border-radius:4px;font-size:12px;color:#a78bfa;';
const modeLabel = data.mode === 'planner' ? 'Planner' : 'Rescue';
const routeText = data.route ? ` | route=${data.route}` : '';
const countText = (typeof data.assist_count === 'number' && typeof data.assist_cap === 'number')
? ` | assists ${data.assist_count}/${data.assist_cap}`
: '';
el.innerHTML = `<strong>Advisor (${modeLabel})</strong><br><span style="color:var(--muted)">Trigger: ${data.reason || data.trigger}${routeText}${countText}</span>`;
if (data.advice?.next_actions?.length) {
el.innerHTML += '<br><span style="color:#e2e8f0">? ' + data.advice.next_actions[0] + '</span>';
}
if (data.preflight?.quick_plan?.length) {
el.innerHTML += '<br><span style="color:#e2e8f0">? ' + data.preflight.quick_plan[0] + '</span>';
}
panel.appendChild(el);
panel.scrollTop = panel.scrollHeight;
}
// --- Expose to SSE handler ---------------------------------------------------
if (typeof window !== 'undefined') {
window._orchEventHandler = handleOrchestrationEvent;
}
// ---- Refresh all ----
async function refreshAll() {
renderSessionsList();
updateStats([]);
loadApprovals();
checkStatus();
loadSystemStats();
loadInstalledSkills();
loadSearchSettingsSummary();
loadAgentSessionContext();
}
// ---- Log ----
const logLines = [];
function log(text, type = 'log') {
const ts = new Date().toLocaleTimeString();
logLines.push({ text: `[${ts}] ${text}`, type });
if (logLines.length > 100) logLines.shift();
const lp = document.getElementById('log-panel');
if (lp) {
lp.innerHTML = logLines.map(l => `<div class="log-line ${l.type}">${l.text}</div>`).join('');
lp.scrollTop = lp.scrollHeight;
}
}
// ---- Helpers ----
function escHtml(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _normalizeFileUrl(url) {
// Convert sandbox:/mnt/... or /mnt/user-data/... paths to /api/files/... URLs
if (/^sandbox:/i.test(url)) {
var p = url.replace(/^sandbox:\/*/i, '');
var idx = p.indexOf('uploads/');
return '/api/files/' + (idx !== -1 ? p.slice(idx) : 'uploads/' + p.split('/').pop());
}
if (/^\/mnt\/(user-data|data)\//i.test(url)) {
var idx = url.indexOf('uploads/');
return '/api/files/' + (idx !== -1 ? url.slice(idx) : 'uploads/' + url.split('/').pop());
}
return url;
}
function _renderLinkButton(text, url, opts) {
var hideDownload = opts && opts.hideDownload;
url = _normalizeFileUrl(url);
var fileExts = ['.pptx','.pdf','.xlsx','.xls','.docx','.doc','.zip','.csv','.mp4','.mp3'];
var isDownload = fileExts.some(function(ext) { return text.toLowerCase().endsWith(ext); })
|| fileExts.some(function(ext) { return decodeURIComponent(url).toLowerCase().indexOf(ext) !== -1; });
var decodedUrl = decodeURIComponent(url).toLowerCase();
var isPptx = decodedUrl.indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx');
var isPdf = decodedUrl.indexOf('.pdf') !== -1 || text.toLowerCase().endsWith('.pdf');
var isXlsx = /\.(xlsx|xls)(\?.*)?$/i.test(decodedUrl) || /\.(xlsx|xls)$/i.test(text.toLowerCase());
if (isPptx) {
var previewId = 'pptx-preview-' + Math.random().toString(36).substr(2, 9);
return '<div class="pptx-file-row">'
+ '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
+ '<span class="download-icon">&#11015;</span> ' + text + '</a>'
+ ' <button class="pptx-preview-btn" onclick="togglePptxPreview(\'' + escHtml(url) + '\', \'' + previewId + '\', this)" title="미리보기">&#9664; 미리보기</button>'
+ '<div id="' + previewId + '" class="pptx-preview-strip" style="display:none"></div>'
+ '</div>';
}
if (isPdf) {
var pdfId = 'pdf-embed-' + Math.random().toString(36).substr(2, 9);
return '<div class="pdf-file-row" style="display:flex;flex-direction:column;gap:6px;margin:4px 0">'
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
+ '<a href="' + url + '" target="_blank" rel="noopener" class="download-btn" title="새 탭에서 열기">'
+ '<span style="font-size:14px">📄</span> ' + text + '</a>'
+ '<button class="pptx-preview-btn" onclick="togglePdfEmbed(\'' + escHtml(url) + '\',\'' + pdfId + '\',this)">&#9664; 미리보기</button>'
+ (hideDownload ? '' : '<a href="' + url + '" onclick="downloadFile(event,this)" class="pptx-preview-btn" style="text-decoration:none">&#11015; 저장</a>')
+ '</div>'
+ '<div id="' + pdfId + '" style="display:none">'
+ '<iframe style="width:100%;height:600px;border:1px solid var(--line);border-radius:8px" allowfullscreen></iframe>'
+ '</div>'
+ '</div>';
}
if (isXlsx) {
var xlsxSafeUrl = escHtml(url);
return '<div class="pdf-file-row xlsx-card" style="display:flex;flex-direction:column;gap:6px;margin:4px 0">'
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
+ '<span style="font-size:14px">📊</span>'
+ '<span style="font-weight:600;color:var(--text)">' + escHtml(text) + '</span>'
+ '<button class="pptx-preview-btn" data-xlsx-url="' + xlsxSafeUrl + '" data-xlsx-save-url="' + xlsxSafeUrl + '" onclick="toggleXlsxEmbedBtn(this)" style="background:linear-gradient(135deg,#3b82f6,#2563eb)">✏️ 편집</button>'
+ '<button class="pptx-preview-btn" onclick="openXlsxNewWindow(\'' + xlsxSafeUrl + '\',\'' + escHtml(text) + '\')" style="background:linear-gradient(135deg,#6366f1,#4f46e5)" title="새 창에서 열기">↗ 새 창</button>'
+ '<a href="' + xlsxSafeUrl + '" download class="pptx-preview-btn" style="text-decoration:none">&#11015; 다운로드</a>'
+ '</div>'
+ ''
+ '</div>';
}
var isTxt = /\.txt(\?[^"]*)?$/i.test(decodedUrl) || /\.txt$/i.test(text.toLowerCase());
if (isTxt) {
var txtLabel = escHtml(text.replace(/^📝\s*/, ''));
return '<div class="pdf-file-row txt-card" style="display:flex;flex-direction:column;gap:6px;margin:4px 0">'
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
+ '<span style="font-size:14px">📝</span>'
+ '<span style="font-weight:600;color:var(--text)">' + txtLabel + '</span>'
+ '<button class="pptx-preview-btn" data-txt-url="' + escHtml(url) + '" onclick="toggleTxtEmbedBtn(this)">&#9664; 미리보기</button>'
+ '<a href="' + escHtml(url) + '" download="' + txtLabel + '" class="pptx-preview-btn" style="text-decoration:none">&#11015; 저장</a>'
+ '</div>'
+ '<div class="txt-preview-body" style="display:none;max-height:420px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:8px;padding:12px;font-size:12px;white-space:pre-wrap;word-break:break-word;line-height:1.7;color:var(--text);font-family:inherit"></div>'
+ '</div>';
}
if (isDownload) {
return '<a href="' + url + '" onclick="downloadFile(event, this)" class="download-btn" title="Download ' + text + '">'
+ '<span class="download-icon">&#11015;</span> ' + text + '</a>';
}
return '<a href="' + url + '" target="_blank" rel="noopener">' + text + '</a>';
}
function togglePdfEmbed(url, containerId, btn) {
var container = document.getElementById(containerId);
if (!container) return;
var iframe = container.querySelector('iframe');
if (container.style.display === 'none') {
container.style.display = 'block';
if (iframe) iframe.src = url; // src=""이면 현재페이지로 해석 → 항상 명시 설정
btn.innerHTML = '&#9650; 닫기';
} else {
container.style.display = 'none';
btn.innerHTML = '&#9664; 미리보기';
}
}
var _luckyCurrentSaveUrl = null;
function openXlsxNewWindow(url, title) {
var q = '/xlsx-viewer.html?file=' + encodeURIComponent(url)
+ '&save=' + encodeURIComponent(url)
+ '&title=' + encodeURIComponent(title || '스프레드시트');
window.open(q, '_blank', 'width=1200,height=800,menubar=no,toolbar=no,location=no,status=no,scrollbars=no,resizable=yes');
}
function toggleXlsxEmbedBtn(btn) {
var url = encodeURI(btn.getAttribute('data-xlsx-url'));
var card = btn.closest('.xlsx-card');
var nameEl = card ? card.querySelector('span[style*="font-weight"]') : null;
var title = nameEl ? nameEl.textContent : url.split('/').pop();
var saveUrl = card ? card.querySelector('[data-xlsx-save-url]')?.getAttribute('data-xlsx-save-url') : null;
openLuckyModal(url, title, saveUrl);
}
function openLuckyModal(url, title, saveUrl) {
var modal = document.getElementById('lucky-modal');
var titleEl = document.getElementById('lucky-modal-title');
var container = document.getElementById('lucky-container');
if (!modal || !container) return;
// Reset box to centered state on each open
var box = document.getElementById('lucky-modal-box');
if (box) {
box.style.position = 'relative';
box.style.left = ''; box.style.top = '';
box.style.width = '96vw'; box.style.height = '92vh';
box._isAbsolute = false; box.dataset.minimized = '0';
}
modal.style.display = 'flex';
modal.style.alignItems = 'center';
modal.style.justifyContent = 'center';
titleEl.textContent = title || '스프레드시트';
_luckyCurrentSaveUrl = saveUrl || null;
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#666;font-size:13px">Luckysheet 로딩 중… (최초 1회 ~3MB)</div>';
initLuckyModalInteraction();
fetch(url, { headers: authHeaders() })
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
.then(function(buf) {
loadLuckysheet(function() {
if (typeof LuckyExcel === 'undefined' || typeof luckysheet === 'undefined') {
container.innerHTML = '<div style="padding:20px;color:red">Luckysheet 로드 실패 — 네트워크 연결을 확인하세요.</div>'; return;
}
var file = new File([buf], 'sheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
LuckyExcel.transformExcelToLucky(file, function(exportInfo) {
container.innerHTML = '';
try { luckysheet.destroy(); } catch(e) {}
luckysheet.create({
container: 'lucky-container',
data: exportInfo.sheets,
title: '',
lang: 'en',
showinfobar: false,
showstatisticBar: false,
sheetBottomConfig: true,
allowUpdate: true,
enableAddRow: true,
enableAddCol: true,
showToolbar: true,
plugins: [],
defaultFontSize: 11,
defaultFontName: 'Nanum Gothic',
});
}, function(err) {
container.innerHTML = '<div style="padding:20px;color:red">Excel 변환 오류: ' + escHtml(String(err?.message || err)) + '</div>';
});
});
})
.catch(function(e) {
container.innerHTML = '<div style="padding:20px;color:red">불러오기 실패: ' + escHtml(e.message) + '</div>';
});
}
function minimizeLuckyModal() {
var box = document.getElementById('lucky-modal-box');
var modal = document.getElementById('lucky-modal');
var btn = event.target;
if (!box || !modal) return;
var minimized = box.dataset.minimized === '1';
if (minimized) {
box.style.height = box._savedHeight || '92vh';
box.dataset.minimized = '0';
btn.textContent = '–';
btn.title = '최소화';
if (!box._isAbsolute) { modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; }
} else {
box._savedHeight = box.style.height || '92vh';
box.dataset.minimized = '1';
btn.innerHTML = '&#9723;';
btn.title = '복원';
if (box._isAbsolute) {
box.style.top = (window.innerHeight - 52) + 'px';
box.style.height = 'auto';
} else {
box.style.height = 'auto';
modal.style.alignItems = 'flex-end';
modal.style.justifyContent = 'center';
modal.style.paddingBottom = '0';
}
}
}
function closeLuckyModal() {
var modal = document.getElementById('lucky-modal');
var box = document.getElementById('lucky-modal-box');
if (modal) { modal.style.display = 'none'; modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; }
if (box) {
box.style.position = 'relative';
box.style.left = ''; box.style.top = '';
box.style.width = '96vw'; box.style.height = '92vh';
box._isAbsolute = false; box._savedHeight = null;
box.dataset.minimized = '0';
}
try { luckysheet.destroy(); } catch(e) {}
_luckyCurrentSaveUrl = null;
}
// Close on backdrop click
document.getElementById('lucky-modal')?.addEventListener('click', function(e) {
if (e.target === this) closeLuckyModal();
});
function initLuckyModalInteraction() {
var box = document.getElementById('lucky-modal-box');
var modal = document.getElementById('lucky-modal');
if (!box || box._interactInit) return;
box._interactInit = true;
// Inject 8 resize handles
[
{ dir:'n', css:'top:0;left:10px;right:10px;height:6px;cursor:n-resize' },
{ dir:'s', css:'bottom:0;left:10px;right:10px;height:6px;cursor:s-resize' },
{ dir:'e', css:'right:0;top:10px;bottom:10px;width:6px;cursor:e-resize' },
{ dir:'w', css:'left:0;top:10px;bottom:10px;width:6px;cursor:w-resize' },
{ dir:'ne', css:'top:0;right:0;width:12px;height:12px;cursor:ne-resize' },
{ dir:'nw', css:'top:0;left:0;width:12px;height:12px;cursor:nw-resize' },
{ dir:'se', css:'bottom:0;right:0;width:12px;height:12px;cursor:se-resize' },
{ dir:'sw', css:'bottom:0;left:0;width:12px;height:12px;cursor:sw-resize' },
].forEach(function(d) {
var h = document.createElement('div');
h.className = 'lucky-rh';
h.dataset.dir = d.dir;
h.style.cssText = 'position:absolute;z-index:200;' + d.css;
box.appendChild(h);
});
function toAbsolute() {
if (box._isAbsolute) return;
var rect = box.getBoundingClientRect();
modal.style.display = 'block';
box.style.position = 'absolute';
box.style.left = rect.left + 'px';
box.style.top = rect.top + 'px';
box.style.width = rect.width + 'px';
box.style.height = rect.height + 'px';
box._isAbsolute = true;
}
// Title bar drag
var header = box.firstElementChild;
if (header) {
header.style.cursor = 'grab';
header.addEventListener('mousedown', function(e) {
if (e.target.tagName === 'BUTTON') return;
toAbsolute();
var rect = box.getBoundingClientRect();
var ox = e.clientX - rect.left, oy = e.clientY - rect.top;
header.style.cursor = 'grabbing';
function onMove(e) {
box.style.left = (e.clientX - ox) + 'px';
box.style.top = Math.max(0, e.clientY - oy) + 'px';
}
function onUp() {
header.style.cursor = 'grab';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault();
});
}
// Resize handle drag
box.addEventListener('mousedown', function(e) {
var handle = e.target.closest ? e.target.closest('.lucky-rh') : null;
if (!handle) return;
toAbsolute();
var dir = handle.dataset.dir;
var rect = box.getBoundingClientRect();
var sx = e.clientX, sy = e.clientY;
var sl = rect.left, st = rect.top, sw = rect.width, sh = rect.height;
document.body.style.userSelect = 'none';
document.body.style.cursor = handle.style.cursor;
function onMove(e) {
var dx = e.clientX - sx, dy = e.clientY - sy;
var nl = sl, nt = st, nw = sw, nh = sh;
if (dir.includes('e')) nw = Math.max(380, sw + dx);
if (dir.includes('s')) nh = Math.max(180, sh + dy);
if (dir.includes('w')) { nw = Math.max(380, sw - dx); nl = sl + sw - nw; }
if (dir.includes('n')) { nh = Math.max(180, sh - dy); nt = st + sh - nh; }
box.style.left = nl + 'px'; box.style.top = nt + 'px';
box.style.width = nw + 'px'; box.style.height = nh + 'px';
}
function onUp() {
document.body.style.userSelect = '';
document.body.style.cursor = '';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault();
e.stopPropagation();
});
}
// ── Voice: STT (Web Speech API — Chrome 내장 Google STT) ────────────────
var _speechRecog = null;
var _isRecording = false;
var _micAutosend = localStorage.getItem('micAutosend') !== 'false'; // default on
function updateMicAutosend() {
_micAutosend = document.getElementById('mic-autosend-toggle').checked;
localStorage.setItem('micAutosend', String(_micAutosend));
}
function initMicAutosendToggle() {
var toggle = document.getElementById('mic-autosend-toggle');
if (toggle) toggle.checked = _micAutosend;
}
function toggleMicRecording() {
if (_isRecording) stopMicRecording();
else startMicRecording();
}
function startMicRecording() {
var SpeechRecog = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecog) {
alert('이 브라우저는 음성 인식을 지원하지 않습니다.\nChrome을 사용해주세요.');
return;
}
_speechRecog = new SpeechRecog();
_speechRecog.lang = 'ko-KR';
_speechRecog.interimResults = false;
_speechRecog.maxAlternatives = 1;
_speechRecog.continuous = false;
var btn = document.getElementById('mic-btn');
_speechRecog.onstart = function() {
_isRecording = true;
if (btn) { btn.textContent = '⏹'; btn.title = '말하는 중… (클릭 시 중지)'; btn.classList.add('recording'); }
};
_speechRecog.onresult = function(e) {
var transcript = e.results[0][0].transcript;
var input = document.getElementById('chat-input');
if (input && transcript) {
input.value = (input.value ? input.value + ' ' : '') + transcript;
input.focus();
input.dispatchEvent(new Event('input'));
if (_micAutosend) setTimeout(function() { handleSendStop(); }, 50);
}
};
_speechRecog.onerror = function(e) {
if (e.error !== 'no-speech') alert('음성 인식 오류: ' + e.error);
};
_speechRecog.onend = function() {
_isRecording = false;
_speechRecog = null;
if (btn) { btn.textContent = '🎤'; btn.title = '음성 입력'; btn.classList.remove('recording'); }
};
_speechRecog.start();
}
function stopMicRecording() {
if (_speechRecog) { _speechRecog.stop(); }
}
// ── Voice: TTS (음성 출력) ───────────────────────────────────────────────
var _ttsEnabled = localStorage.getItem('ttsEnabled') !== 'false'; // default on
var _ttsAudio = null;
var _ttsBtnActive = null;
function updateTTSMode() {
_ttsEnabled = document.getElementById('tts-mode-toggle').checked;
localStorage.setItem('ttsEnabled', String(_ttsEnabled));
document.body.classList.toggle('tts-disabled', !_ttsEnabled);
if (!_ttsEnabled && _ttsAudio) {
_ttsAudio.pause(); _ttsAudio = null;
if (_ttsBtnActive) { _ttsBtnActive.textContent = '🔊'; _ttsBtnActive.classList.remove('playing'); _ttsBtnActive = null; }
}
}
function initTTSToggle() {
var toggle = document.getElementById('tts-mode-toggle');
if (toggle) toggle.checked = _ttsEnabled;
document.body.classList.toggle('tts-disabled', !_ttsEnabled);
}
function playTTSMsg(btn, idx) {
var msg = chatHistory[idx];
if (!msg) return;
var text = (msg.content || '').replace(/!\[.*?\]\(.*?\)/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/```[\s\S]*?```/g, '').replace(/[#*`_~>]/g, ' ').replace(/\s+/g, ' ').trim();
if (!text) return;
// Toggle off if same button is playing
if (_ttsBtnActive === btn && _ttsAudio) {
_ttsAudio.pause(); _ttsAudio = null;
btn.textContent = '🔊'; btn.classList.remove('playing');
_ttsBtnActive = null;
return;
}
// Stop previous
if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; }
if (_ttsBtnActive) { _ttsBtnActive.textContent = '🔊'; _ttsBtnActive.classList.remove('playing'); }
btn.textContent = '⌛'; btn.classList.add('playing');
_ttsBtnActive = btn;
fetch('/api/voice/tts', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
credentials: 'include',
body: JSON.stringify({ text: text.slice(0, 2000) }),
})
.then(function(r) { if (!r.ok) throw new Error('TTS 오류 ' + r.status); return r.blob(); })
.then(function(blob) {
var url = URL.createObjectURL(blob);
_ttsAudio = new Audio(url);
_ttsAudio.onended = function() {
URL.revokeObjectURL(url); _ttsAudio = null;
btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null;
};
_ttsAudio.onerror = function() {
URL.revokeObjectURL(url); _ttsAudio = null;
btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null;
};
btn.textContent = '⏹';
_ttsAudio.play();
})
.catch(function(e) {
btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null;
alert('TTS 오류: ' + e.message);
});
}
function saveLuckyModal() {
var url = _luckyCurrentSaveUrl;
if (!url) { alert('저장할 파일 경로가 없습니다.'); return; }
var btn = document.getElementById('lucky-modal-save');
var relPath = url.replace(/^\/api\/files\//, '');
loadSheetJS(function() {
try {
// Luckysheet → SheetJS aoa conversion
var sheets = luckysheet.getAllSheets();
var wb = XLSX.utils.book_new();
sheets.forEach(function(sheet) {
var celldata = sheet.celldata || [];
if (!celldata.length) { XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([[]]), sheet.name || 'Sheet'); return; }
var maxR = 0, maxC = 0;
celldata.forEach(function(c) { maxR = Math.max(maxR, c.r); maxC = Math.max(maxC, c.c); });
var aoa = [];
for (var r = 0; r <= maxR; r++) aoa.push(new Array(maxC + 1).fill(''));
celldata.forEach(function(c) {
if (c.v == null) return;
var val = (typeof c.v === 'object') ? (c.v.m !== undefined ? c.v.m : (c.v.v !== undefined ? c.v.v : '')) : c.v;
aoa[c.r][c.c] = val;
});
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), sheet.name || 'Sheet');
});
var xlsxBuf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
btn.disabled = true; btn.textContent = '저장 중…';
fetch('/api/files/' + relPath, {
method: 'PUT', headers: authHeaders(),
body: new Blob([xlsxBuf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
})
.then(function(r) { return r.json(); })
.then(function(res) {
btn.disabled = false; btn.innerHTML = '&#128190; 저장';
if (res.success) { btn.textContent = '✓ 저장됨'; setTimeout(function() { btn.innerHTML = '&#128190; 저장'; }, 2000); }
else alert('저장 실패: ' + (res.error || '알 수 없는 오류'));
})
.catch(function(e) { btn.disabled = false; btn.innerHTML = '&#128190; 저장'; alert('저장 오류: ' + e.message); });
} catch(e) { alert('Excel 변환 오류: ' + e.message); }
});
}
function toggleStagedExcelPreview(id) {
var el = document.getElementById('excel-staged-' + id);
if (!el) return;
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function toggleTxtEmbedBtn(btn) {
var card = btn.closest('.txt-card');
if (!card) return;
var container = card.querySelector('.txt-preview-body');
if (!container) return;
var url = encodeURI(btn.getAttribute('data-txt-url'));
if (container.style.display === 'none') {
container.style.display = 'block';
btn.innerHTML = '&#9650; 접기';
if (!container.dataset.loaded) {
container.textContent = '불러오는 중…';
fetch(url, { headers: authHeaders() })
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
})
.then(function(t) {
container.textContent = t;
container.dataset.loaded = '1';
})
.catch(function(e) {
container.textContent = '텍스트를 불러올 수 없습니다. (' + e.message + ')';
});
}
} else {
container.style.display = 'none';
btn.innerHTML = '&#9664; 미리보기';
}
}
function renderMarkdown(text, opts) {
if (!text) return '';
// Extract existing <img> tags before escaping so they survive escHtml()
var imgPlaceholders = [];
var imgRe = /<img\s+[^>]*class=["']md-img["'][^>]*>/gi;
var textSafe = text.replace(imgRe, function(m) {
var idx = imgPlaceholders.length;
imgPlaceholders.push(m);
return '\x00IMG' + idx + '\x00';
});
var html = escHtml(textSafe);
// Restore <img> tags (already safe HTML)
imgPlaceholders.forEach(function(img, i) {
html = html.replace('\x00IMG' + i + '\x00', img);
});
// Code blocks (``` ... ```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
var trimmed = code.trim();
if (lang === 'abc' || lang === 'lilypond') {
var encoded = encodeURIComponent(trimmed);
var widgetId = 'music-' + Math.random().toString(36).slice(2, 9);
return '<div class="music-widget" id="' + widgetId + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">🎵 ' + (lang === 'abc' ? 'ABC Notation' : 'LilyPond') + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button class="music-render-btn" onclick="renderMusicBlock(\'' + widgetId + '\', decodeURIComponent(\'' + encoded + '\'))">악보 렌더링</button>' +
'<button onclick="toggleMusicWidget(\'' + widgetId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + widgetId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body">' +
'<pre class="md-code-block music-source" style="margin:0;border-radius:0;border:none;border-bottom:1px solid var(--line);max-height:120px;overflow-y:auto;font-size:11px"><code>' + escHtml(trimmed) + '</code></pre>' +
'<div class="music-output" style="display:none"></div>' +
'</div>' +
'</div>';
}
if (lang === 'tradingview') {
var tvSymbol = '', tvInterval = 'D';
// code content is already HTML-escaped at this point — unescape before parsing
var tvRaw = trimmed.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/gi,'"').replace(/&#39;/g,"'").trim();
if (tvRaw.startsWith('{')) {
try {
var tvJson = JSON.parse(tvRaw);
tvSymbol = (tvJson.symbol || tvJson.SYMBOL || '').toString().toUpperCase();
tvInterval = (tvJson.interval || tvJson.INTERVAL || 'D').toString().toUpperCase();
} catch(e) { tvSymbol = tvRaw.split('|')[0].trim().toUpperCase(); }
} else {
var parts = tvRaw.split('|');
tvSymbol = (parts[0] || '').trim().toUpperCase();
tvInterval = (parts[1] || 'D').trim().toUpperCase();
}
if (!tvSymbol) return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
var tvId = 'tv-' + Math.random().toString(36).slice(2, 9);
return '<div class="music-widget" id="' + tvId + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">📈 ' + escHtml(tvSymbol) + ' · ' + escHtml(tvInterval) + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button class="music-render-btn" onclick="renderTVChart(this,\'' + tvId + '-c\',\'' + escHtml(tvSymbol) + '\',\'' + escHtml(tvInterval) + '\')">📈 차트 불러오기</button>' +
'<button onclick="toggleMusicWidget(\'' + tvId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + tvId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body">' +
'<div id="' + tvId + '-c" style="height:440px;width:100%"></div>' +
'</div>' +
'</div>';
}
return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
});
// Inline code
html = html.replace(/`([^`]+)`/g, '<code class="md-inline-code">$1</code>');
// Images: ![alt](url)
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(_, alt, url) {
return '<img class="md-img" src="' + url + '" alt="' + alt + '" loading="lazy" onclick="openImageZoom(this.src)" />';
});
// Links: [text](url) — render download links for file types as buttons, PPTX gets preview
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_, text, url) {
return _renderLinkButton(text, url, opts);
});
// Bare /api/files/... URLs — auto-convert to download buttons even without markdown syntax
html = html.replace(/(\s|^)(\/api\/files\/[^\s<"]+)/g, function(_, space, url) {
return space + _renderLinkButton(decodeURIComponent(url.split('/').pop() || 'download'), url, opts);
});
// Bold
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
// Line breaks
html = html.replace(/\n/g, '<br>');
return html;
}
function renderTVChart(btn, containerId, symbol, interval) {
btn.disabled = true;
btn.textContent = '로딩 중...';
var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
function doInit() {
try {
new TradingView.widget({
container_id: containerId,
autosize: true,
symbol: symbol,
interval: interval || 'D',
timezone: 'Asia/Seoul',
theme: isDark ? 'dark' : 'light',
style: '1',
locale: 'kr',
enable_publishing: false,
allow_symbol_change: true,
save_image: false,
withdateranges: true,
hide_side_toolbar: false,
studies: ['RSI@tv-basicstudies', 'MACD@tv-basicstudies'],
});
btn.style.display = 'none';
} catch(e) {
btn.disabled = false;
btn.textContent = '📈 차트 불러오기';
}
}
if (window.TradingView && window.TradingView.widget) {
doInit();
} else {
var s = document.createElement('script');
s.src = 'https://s3.tradingview.com/tv.js';
s.onload = doInit;
s.onerror = function() { btn.disabled = false; btn.textContent = '📈 차트 불러오기'; };
document.head.appendChild(s);
}
}
function toggleMusicWidget(widgetId) {
var widget = document.getElementById(widgetId);
if (!widget) return;
// widgetId may point to either .music-widget or .music-widget-body
var body = widget.classList.contains('music-widget-body')
? widget
: widget.querySelector('.music-widget-body');
var btn = widget.classList.contains('music-widget-body')
? widget.closest('.music-widget')?.querySelector('[title="접기/펼치기"]')
: widget.querySelector('[title="접기/펼치기"]');
if (!body) return;
var collapsed = body.classList.toggle('collapsed');
if (btn) btn.textContent = collapsed ? '▸' : '▾';
}
function closeMusicWidget(widgetId) {
var el = document.getElementById(widgetId);
if (!el) return;
var widget = el.closest('.music-widget') || el;
widget.remove();
}
// ── Web MIDI router ──────────────────────────────────────────────────────────
const midiRouter = {
access: null,
timers: [],
isPlaying: false,
stop() {
this.timers.forEach(t => clearTimeout(t));
this.timers = [];
this.isPlaying = false;
if (this.access) {
this.access.outputs.forEach(out => {
for (let ch = 0; ch < 16; ch++) {
try { out.send([0xB0 | ch, 123, 0]); } catch {} // All Notes Off
try { out.send([0xB0 | ch, 121, 0]); } catch {} // Reset All Controllers
}
});
}
},
// Parse raw MIDI bytes → scheduled events list
// Returns [{timeMs, msg:[...bytes]}, ...]
_parseMidiRaw(buf) {
const u8 = new Uint8Array(buf);
const dv = new DataView(buf);
if (dv.getUint32(0) !== 0x4D546864) return []; // not MThd
const tpq = dv.getUint16(12);
const ntrk = dv.getUint16(10);
let tempo = 500000;
const events = [];
const readVarLen = (pos) => {
let val = 0, b;
do { b = u8[pos++]; val = (val << 7) | (b & 0x7F); } while (b & 0x80);
return { val, pos };
};
const ticksToMs = (ticks) => (ticks / tpq) * (tempo / 1000);
let p = 14;
for (let t = 0; t < ntrk; t++) {
if (p + 8 > u8.length || dv.getUint32(p) !== 0x4D54726B) break;
const tlen = dv.getUint32(p + 4);
p += 8;
const end = p + tlen;
let tick = 0, rs = 0;
while (p < end) {
const dl = readVarLen(p); p = dl.pos; tick += dl.val;
let b = u8[p];
if (b & 0x80) { rs = b; p++; } else { b = rs; }
const typ = b & 0xF0, ch = b & 0x0F;
const ms = ticksToMs(tick);
if (typ === 0x90 || typ === 0x80) {
const note = u8[p++], vel = u8[p++];
events.push({ ms, msg: [typ | ch, note, vel] });
} else if (typ === 0xA0 || typ === 0xB0 || typ === 0xE0) {
events.push({ ms, msg: [b, u8[p], u8[p+1]] }); p += 2;
} else if (typ === 0xC0) {
events.push({ ms, msg: [0xC0 | ch, u8[p++]] });
} else if (typ === 0xD0) {
events.push({ ms, msg: [b, u8[p++]] });
} else if (b === 0xFF) {
const mt = u8[p++];
const ll = readVarLen(p); p = ll.pos;
if (mt === 0x51 && ll.val >= 3) {
// Tempo change: recalculate from current tick
tempo = (u8[p] << 16) | (u8[p+1] << 8) | u8[p+2];
}
p += ll.val;
} else if (b === 0xF0 || b === 0xF7) {
const ll = readVarLen(p); p = ll.pos + ll.val;
} else { p++; }
}
p = end;
}
events.sort((a, b) => a.ms - b.ms);
return events;
},
async play(midiUrl, outputId, onStatus) {
this.stop();
const output = this.access.outputs.get(outputId);
if (!output) throw new Error('기기를 찾을 수 없습니다');
if (onStatus) onStatus('로딩 중...');
const resp = await fetch(midiUrl);
const buf = await resp.arrayBuffer();
// Parse raw MIDI for exact Program Change + all events
const events = this._parseMidiRaw(buf);
if (!events.length) throw new Error('MIDI 파싱 실패');
// GM Reset SysEx → 신디를 GM 모드로 초기화
try { output.send(new Uint8Array([0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7])); } catch {}
// Send all PC + Bank Select (CC#0=0, CC#32=0) events upfront
const initPc = new Set();
events.forEach(ev => {
if ((ev.msg[0] & 0xF0) === 0xC0 && !initPc.has(ev.msg[0] & 0x0F)) {
initPc.add(ev.msg[0] & 0x0F);
const ch = ev.msg[0] & 0x0F;
try { output.send(new Uint8Array([0xB0 | ch, 0, 0])); } catch {} // Bank Select MSB=0
try { output.send(new Uint8Array([0xB0 | ch, 32, 0])); } catch {} // Bank Select LSB=0
try { output.send(new Uint8Array(ev.msg)); } catch {}
}
});
const NOTE_DELAY = 200; // ms — give synth time to process GM reset + PCs
const totalMs = events[events.length - 1].ms + NOTE_DELAY + 500;
this.isPlaying = true;
if (onStatus) onStatus('재생 중...');
events.forEach(ev => {
this.timers.push(setTimeout(() => {
if (!this.isPlaying) return;
try { output.send(new Uint8Array(ev.msg)); } catch {}
}, ev.ms + NOTE_DELAY));
});
this.timers.push(setTimeout(() => {
this.isPlaying = false;
if (onStatus) onStatus('완료');
}, totalMs));
}
};
async function midiRouterToggle(routerId, btnId) {
const panel = document.getElementById(routerId);
if (!panel) { alert('패널을 찾을 수 없습니다: ' + routerId); return; }
const isHidden = panel.style.display === 'none' || panel.style.display === '';
if (isHidden) {
panel.style.display = 'flex';
panel.style.flexWrap = 'wrap';
panel.style.gap = '6px';
panel.style.alignItems = 'center';
const sel = document.getElementById(routerId + '-sel');
const st = document.getElementById(routerId + '-st');
try {
if (!navigator.requestMIDIAccess) throw new Error('Web MIDI 미지원 — Chrome/Edge 필요');
if (!midiRouter.access) {
midiRouter.access = await navigator.requestMIDIAccess();
}
if (sel) {
sel.innerHTML = '<option value="">-- MIDI 기기 선택 --</option>';
midiRouter.access.outputs.forEach((out, id) => {
const opt = document.createElement('option');
opt.value = id; opt.textContent = out.name; sel.appendChild(opt);
});
if (sel.options.length <= 1 && st) st.textContent = 'MIDI 출력 기기 없음';
}
} catch (e) {
if (st) st.textContent = '오류: ' + e.message;
}
} else {
panel.style.display = 'none';
midiRouter.stop();
}
}
async function midiRouterPlay(routerId, midiUrl) {
const sel = document.getElementById(routerId + '-sel');
const st = document.getElementById(routerId + '-st');
const outputId = sel ? sel.value : '';
if (!outputId) { if (st) st.textContent = '기기를 선택하세요'; return; }
try {
await midiRouter.play(midiUrl, outputId, msg => { if (st) st.textContent = msg; });
} catch (e) { if (st) st.textContent = '오류: ' + e.message; }
}
function midiRouterStop(routerId) {
midiRouter.stop();
const st = document.getElementById(routerId + '-st');
if (st) st.textContent = '정지';
}
function buildMusicActions(data, downloadName) {
var midiUrl = data.midi_url;
var pdfUrl = data.pdf_url;
var rid = 'mr-' + Math.random().toString(36).slice(2, 10);
var html = '<div class="music-actions">';
if (midiUrl) {
html += '<div class="midi-router-panel" id="' + rid + '" style="display:none;margin-bottom:4px">'
+ '<select class="midi-router-sel" id="' + rid + '-sel" style="font-size:11px;padding:2px 6px;border:1px solid var(--line);border-radius:6px"><option value="">-- MIDI 기기 선택 --</option></select>'
+ '<button class="music-page-btn" onclick="midiRouterPlay(\'' + rid + '\',\'' + midiUrl + '\')">▶</button>'
+ '<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>'
+ '<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>'
+ '</div>';
html += '<button class="music-dl-btn btn-green" id="' + rid + '-btn" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '-btn\')">▶ 외부 신디</button>';
html += '<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</button>';
html += '<a class="music-dl-btn" href="' + midiUrl + '" download="' + escHtml(downloadName || 'score.midi') + '">⬇ MIDI</a>';
}
if (pdfUrl) html += '<a class="music-dl-btn" href="' + pdfUrl + '" download="score.pdf">⬇ PDF</a>';
html += '</div>';
return html;
}
// ─────────────────────────────────────────────────────────────────────────────
function buildSvgPagesHtml(data) {
var pages = data.page_count || 1;
var baseId = data.id;
// Use a random suffix so duplicate renders in chat history never share the same navId
var navId = 'music-nav-' + Math.random().toString(36).slice(2, 10);
var src1 = pages === 1 ? data.svg_url : '/api/music/file/' + baseId + '/svg/1';
var nav = '';
if (pages > 1) {
nav = '<div class="music-page-nav" id="' + navId + '" data-page="1" data-total="' + pages + '" data-id="' + baseId + '">' +
'<button class="music-page-btn" onclick="musicPageNav(\'' + navId + '\',-1)" id="' + navId + '-prev" disabled>◀</button>' +
'<span id="' + navId + '-label">1 / ' + pages + '</span>' +
'<button class="music-page-btn" onclick="musicPageNav(\'' + navId + '\',1)" id="' + navId + '-next"' + (pages <= 1 ? ' disabled' : '') + '>▶</button>' +
'</div>';
}
return nav + '<img id="' + navId + '-img" src="' + src1 + '" alt="악보" style="width:100%;height:auto;background:#fff;border-radius:8px;padding:8px;display:block" />';
}
function musicPageNav(navId, delta) {
var nav = document.getElementById(navId);
if (!nav) return;
var cur = parseInt(nav.dataset.page, 10);
var total = parseInt(nav.dataset.total, 10);
var next = cur + delta;
if (next < 1 || next > total) return;
nav.dataset.page = String(next);
var baseId = nav.dataset.id;
var img = document.getElementById(navId + '-img');
var lbl = document.getElementById(navId + '-label');
var prev = document.getElementById(navId + '-prev');
var nxt = document.getElementById(navId + '-next');
var url = '/api/music/file/' + baseId + '/svg/' + next;
if (img) img.src = url;
if (lbl) lbl.textContent = next + ' / ' + total;
if (prev) prev.disabled = next <= 1;
if (nxt) nxt.disabled = next >= total;
}
async function renderMusicBlock(widgetId, source) {
var widget = document.getElementById(widgetId);
if (!widget) return;
var btn = widget.querySelector('.music-render-btn');
var output = widget.querySelector('.music-output');
if (btn) { btn.disabled = true; btn.textContent = '렌더링 중…'; }
try {
var data = await api('/api/music/render', {
method: 'POST',
body: JSON.stringify({ source: source }),
});
if (data.error) throw new Error(data.error);
var html = buildSvgPagesHtml(data);
html += buildMusicActions(data, 'score.midi');
if (output) {
output.innerHTML = html;
output.style.display = 'block';
// Update chatHistory so the rendered output survives page reload
persistActiveChat();
}
if (btn) { btn.textContent = '다시 렌더링'; btn.disabled = false; }
} catch (e) {
if (output) {
output.innerHTML = '<span style="color:var(--err);font-size:12px">렌더링 실패: ' + escHtml(e.message) + '</span>';
output.style.display = 'block';
}
if (btn) { btn.textContent = '악보 렌더링'; btn.disabled = false; }
}
}
// ─── MIDI 업로드 → 액션 피커 ──────────────────────────────────────────────────
function buildMidiPickerWidget(data, filename) {
var wid = 'midi-' + Math.random().toString(36).slice(2, 10);
var midiUrl = data.midi_url;
var rid = 'mr-' + Math.random().toString(36).slice(2, 10);
var savedLine = data.saved_to
? '<div style="font-size:11px;color:var(--muted);margin-bottom:8px">💾 ' + escHtml(data.saved_to) + '</div>'
: '';
// MIDI player panel (hidden by default) — includes MIDI download
var playerPanel =
'<div id="' + rid + '" class="midi-router-panel" style="display:none;margin-top:8px">' +
'<select class="midi-router-sel" id="' + rid + '-sel" style="font-size:11px;padding:2px 6px;border:1px solid var(--line);border-radius:6px"><option value="">-- MIDI 기기 선택 --</option></select>' +
'<button class="music-page-btn" onclick="midiRouterPlay(\'' + rid + '\',\'' + midiUrl + '\')">▶</button>' +
'<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>' +
'<a class="music-dl-btn" href="' + midiUrl + '" download="' + escHtml(filename) + '" style="padding:2px 8px;font-size:11px">⬇ MIDI</a>' +
'<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>' +
'</div>';
// Score area — always hidden by default; content pre-filled if already rendered
var scoreInner = (data.has_svg && data.svg_url)
? buildSvgPagesHtml(data) + buildMusicActions(data, filename)
: '';
var scoreArea = '<div id="' + wid + '-score" style="margin-top:8px;display:none">' + scoreInner + '</div>';
var scoreBtnLabel = '🎼 악보';
var html =
'<div class="music-widget" id="' + wid + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">🎵 ' + escHtml(filename) + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button onclick="toggleMusicWidget(\'' + wid + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + wid + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body"><div class="music-output">' +
savedLine +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
'<button class="music-dl-btn btn-green" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '\')">▶ 재생</button>' +
'<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</button>' +
'<button class="music-dl-btn btn-amber" id="' + wid + '-score-btn" onclick="midiPickerScore(\'' + wid + '\',\'' + data.id + '\',\'' + escHtml(filename) + '\')">' + scoreBtnLabel + '</button>' +
'</div>' +
playerPanel +
scoreArea +
'</div></div>' +
'</div>';
return { wid, html };
}
async function midiPickerScore(wid, midiId, filename) {
var scoreDiv = document.getElementById(wid + '-score');
var btn = document.getElementById(wid + '-score-btn');
if (!scoreDiv) return;
// If score already rendered, toggle visibility
if (scoreDiv.style.display !== 'none' && scoreDiv.innerHTML.trim()) {
scoreDiv.style.display = 'none';
if (btn) btn.textContent = '📄 악보';
return;
}
if (scoreDiv.innerHTML.trim()) {
scoreDiv.style.display = '';
if (btn) btn.textContent = '📄 닫기';
return;
}
// Show progress
scoreDiv.style.display = '';
scoreDiv.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">📄 악보 변환 중... <span id="' + wid + '-sprog"></span></div>';
if (btn) btn.disabled = true;
try {
var resp = await fetch('/api/music/midi-score', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ id: midiId }),
});
if (!resp.ok || !resp.body) throw new Error('서버 오류 ' + resp.status);
var reader = resp.body.getReader(), decoder = new TextDecoder(), buf = '';
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
var parts = buf.split('\n\n'); buf = parts.pop() || '';
for (var i = 0; i < parts.length; i++) {
if (!parts[i].startsWith('data: ')) continue;
var ev; try { ev = JSON.parse(parts[i].slice(6)); } catch { continue; }
if (ev.type === 'progress') {
var sp = document.getElementById(wid + '-sprog'); if (sp) sp.textContent = ev.message;
} else if (ev.type === 'done') {
scoreDiv.innerHTML = buildSvgPagesHtml(ev) + buildMusicActions(ev, filename);
if (btn) { btn.disabled = false; btn.textContent = '📄 닫기'; }
} else if (ev.type === 'error') {
scoreDiv.innerHTML = '<span style="color:var(--err);font-size:12px">악보 변환 실패: ' + escHtml(ev.message) + '</span>';
if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; }
}
}
}
} catch (e) {
scoreDiv.innerHTML = '<span style="color:var(--err);font-size:12px">오류: ' + escHtml(e.message) + '</span>';
if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; }
}
}
async function uploadMidiAndRender(file) {
var msgId = 'midi-upload-' + Date.now();
appendChatBubble('system',
'<div id="' + msgId + '" style="font-size:13px;color:var(--muted)">🎵 <strong>' + escHtml(file.name) + '</strong> 업로드 중...</div>'
);
try {
var form = new FormData();
form.append('file', file);
var resp = await fetch('/api/music/midi', { method: 'POST', headers: authHeaders(), body: form });
var data = await resp.json();
if (!resp.ok) throw new Error(data.error || '서버 오류 ' + resp.status);
var result = buildMidiPickerWidget(data, file.name);
var placeholder = document.getElementById(msgId);
if (placeholder) placeholder.outerHTML = result.html;
chatHistory.push({ role: 'widget', content: result.html });
persistActiveChat();
} catch (e) {
var pl = document.getElementById(msgId);
if (pl) pl.outerHTML = '<span style="color:var(--err);font-size:12px">업로드 실패: ' + escHtml(e.message) + '</span>';
}
}
function appendChatBubble(role, html) {
var wrap = document.getElementById('chat-messages');
if (!wrap) return;
var div = document.createElement('div');
div.className = 'msg msg-' + role;
div.innerHTML = '<div class="msg-content">' + html + '</div>';
wrap.appendChild(div);
wrap.scrollTop = wrap.scrollHeight;
}
function openImageZoom(src) {
var modal = document.getElementById('image-zoom-modal');
var img = document.getElementById('image-zoom-img');
if (modal && img) {
img.src = src;
modal.style.display = 'flex';
}
}
function closeImageZoom() {
var modal = document.getElementById('image-zoom-modal');
if (modal) modal.style.display = 'none';
}
// Cache of checked /api/files/ URLs: url -> true (exists) | false (not found)
var _dlLinkCache = {};
function markDownloadLinkError(btn) {
var href = btn.getAttribute ? btn.getAttribute('href') : (btn.href || '');
var filename = decodeURIComponent((href || '').split('/').pop() || '파일');
_dlLinkCache[href] = false;
var span = document.createElement('span');
span.className = 'download-btn-error';
span.title = '파일을 찾을 수 없음: ' + filename;
span.textContent = '⚠ ' + filename + ' (파일 없음)';
if (btn.parentNode) btn.parentNode.replaceChild(span, btn);
}
function validateDownloadLinks(container) {
var buttons = (container || document).querySelectorAll('a.download-btn[href]');
buttons.forEach(function(btn) {
var url = btn.getAttribute('href');
if (!url || url.indexOf('/api/files/') === -1) return;
if (_dlLinkCache[url] === false) { markDownloadLinkError(btn); return; }
if (_dlLinkCache[url] === true || _dlLinkCache[url] === 'pending') return;
_dlLinkCache[url] = 'pending';
fetch(url, { method: 'HEAD', headers: authHeaders() })
.then(function(r) {
_dlLinkCache[url] = r.ok;
if (!r.ok) {
var found = (container || document).querySelector('a.download-btn[href="' + url.replace(/"/g, '\\"') + '"]');
if (found) markDownloadLinkError(found);
}
})
.catch(function() { _dlLinkCache[url] = false; });
});
}
function downloadFile(e, el) {
e.preventDefault();
e.stopPropagation();
var url = el.getAttribute('href');
if (!url) return;
// Ensure absolute URL (handle sandboxed environments where origin may be sandbox:/)
if (!url.match(/^https?:\/\//)) {
if (url.charAt(0) === '/') {
// Already an absolute path — use relative to current host
url = url;
} else {
url = new URL(url, window.location.origin).href;
}
}
var filename = decodeURIComponent(url.split('/').pop() || 'download');
fetch(url, {
headers: authHeaders()
})
.then(function(r) {
if (!r.ok) throw new Error(r.status);
return r.blob();
})
.then(function(blob) {
var blobUrl = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
})
.catch(function(err) {
markDownloadLinkError(el);
});
}
function togglePptxPreview(fileUrl, containerId, btn) {
var container = document.getElementById(containerId);
if (!container) return;
var isVisible = container.style.display !== 'none';
if (isVisible) {
container.style.display = 'none';
btn.innerHTML = '&#9664; Preview';
return;
}
// Show loading state
container.style.display = 'flex';
container.innerHTML = '<span style="color:var(--muted);font-size:13px;padding:12px">Generating preview...</span>';
btn.innerHTML = '&#9650; Preview';
btn.classList.add('loading');
// Extract relative path from various URL formats:
// /api/files/Folder/file.pptx → Folder/file.pptx
// https://host/api/files/Folder/file.pptx → Folder/file.pptx
// Folder/file.pptx → Folder/file.pptx
var filePath = fileUrl;
// Handle sandboxed environments where origin may be sandbox:/
if (fileUrl.charAt(0) === '/') {
filePath = fileUrl; // Already an absolute path, use as-is
} else {
try { filePath = new URL(fileUrl, window.location.origin).pathname; } catch(e) {}
}
filePath = filePath.replace(/^\/api\/files\/?/, '');
// If the link itself is a preview URL, extract the path query parameter
var previewMatch = filePath.match(/^\/api\/pptx\/preview\?path=(.+)$/);
if (previewMatch) {
filePath = previewMatch[1];
}
// Safely decode - handle already-decoded or invalid encodings
try { filePath = decodeURIComponent(filePath); } catch(e) {}
fetch('/api/pptx/preview?path=' + encodeURIComponent(filePath), {
headers: authHeaders()
})
.then(function(r) { return r.json(); })
.then(function(data) {
btn.classList.remove('loading');
if (!data.success || !data.images || !data.images.length) {
container.innerHTML = '<span style="color:#ef4444;font-size:13px;padding:12px">Preview failed: ' + escHtml(data.error || 'No slides found') + '</span>';
return;
}
container.innerHTML = data.images.map(function(imgUrl, i) {
return '<img src="' + imgUrl + '" alt="Slide ' + (i + 1) + '" onclick="openImageZoom(this.src)" loading="lazy" />';
}).join('');
})
.catch(function(err) {
btn.classList.remove('loading');
container.innerHTML = '<span style="color:#ef4444;font-size:13px;padding:12px">Preview error: ' + escHtml(err.message) + '</span>';
});
}
function handleImageUpload(input) {
var file = input.files && input.files[0];
if (!file) return;
input.value = '';
var formData = new FormData();
formData.append('image', file);
fetch('/api/upload/image', {
method: 'POST',
headers: authHeaders(),
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.path) {
var chatInput = document.getElementById('chat-input');
var imgRef = '![image](/api/files/' + data.path + ')';
chatInput.value = chatInput.value ? chatInput.value + '\n' + imgRef : imgRef;
chatInput.focus();
} else {
alert(data.error || 'Upload failed');
}
})
.catch(function(e) { alert('Upload error: ' + e.message); });
}
// ── 파일 첨부 (이미지·PDF·Excel·txt·csv 등) ─────────────────────────────────
function handleFileUpload(input) {
var file = input.files && input.files[0];
if (!file) return;
input.value = '';
var name = file.name.toLowerCase();
// MIDI → 악보 변환
if (/\.(mid|midi)$/.test(name) || file.type === 'audio/midi' || file.type === 'audio/x-midi') {
uploadMidiAndRender(file);
return;
}
// 이미지
if (file.type.startsWith('image/')) {
var imgForm = new FormData();
imgForm.append('image', file);
fetch('/api/upload/image', { method: 'POST', headers: authHeaders(), body: imgForm })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.path) {
insertIntoChatInput('![' + file.name + '](/api/files/' + data.path + ')');
} else { alert(data.error || '이미지 업로드 실패'); }
})
.catch(function(e) { alert('업로드 오류: ' + e.message); });
// txt / md
} else if (/\.(txt|md)$/.test(name)) {
var r1 = new FileReader();
r1.onload = function(e) {
var ext = name.split('.').pop();
stageAttachment(file.name, null, '**[파일: ' + file.name + ']**\n```' + ext + '\n' + e.target.result + '\n```', 'text');
};
r1.readAsText(file, 'UTF-8');
// CSV
} else if (/\.csv$/.test(name)) {
var r2 = new FileReader();
r2.onload = function(e) {
stageAttachment(file.name, null, '**[파일: ' + file.name + ']**\n```csv\n' + e.target.result + '\n```', 'csv');
};
r2.readAsText(file, 'UTF-8');
// Excel
} else if (/\.(xlsx|xls)$/.test(name)) {
var xlsxFile = file;
var xlsxForm = new FormData();
xlsxForm.append('file', xlsxFile);
var xlsxUploadPromise = fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: xlsxForm })
.then(function(r) { return r.json(); }).catch(function() { return {}; });
loadSheetJS(function() {
var r3 = new FileReader();
r3.onload = function(e) {
try {
var wb = XLSX.read(e.target.result, { type: 'array' });
var markdownContent = '**[파일: ' + xlsxFile.name + ']**\n\n';
var previewHtml = '';
wb.SheetNames.forEach(function(sn) {
var rows = XLSX.utils.sheet_to_json(wb.Sheets[sn], { header: 1 });
markdownContent += '### ' + sn + '\n';
rows.forEach(function(row) { markdownContent += '| ' + row.map(function(c) { return String(c ?? ''); }).join(' | ') + ' |\n'; });
markdownContent += '\n';
previewHtml += '<div style="margin-bottom:6px;font-weight:700;font-size:11px">' + escHtml(sn) + '</div>';
previewHtml += '<div style="overflow-x:auto"><table style="border-collapse:collapse;font-size:11px">';
rows.slice(0, 30).forEach(function(row, ri) {
previewHtml += '<tr>';
row.forEach(function(cell) {
var tag = ri === 0 ? 'th' : 'td';
previewHtml += '<' + tag + ' style="border:1px solid var(--line);padding:3px 7px;text-align:left;white-space:nowrap">' + escHtml(String(cell ?? '')) + '</' + tag + '>';
});
previewHtml += '</tr>';
});
if (rows.length > 30) previewHtml += '<tr><td colspan="99" style="text-align:center;color:var(--muted);font-size:10px;padding:4px">… ' + (rows.length - 30) + '행 더</td></tr>';
previewHtml += '</table></div>';
});
xlsxUploadPromise.then(function(d) {
var fileUrl = (d.success && d.path) ? '/api/files/' + d.path : null;
stageAttachment(xlsxFile.name, fileUrl, markdownContent, 'excel', { excelPreviewHtml: previewHtml });
});
} catch(err) { alert('Excel 파싱 오류: ' + err.message); }
};
r3.readAsArrayBuffer(xlsxFile);
});
// PDF — 업로드 + PDF.js 텍스트 추출 + txt 자동 저장
} else if (/\.pdf$/.test(name)) {
var pdfFile = file;
var pdfForm = new FormData();
pdfForm.append('file', pdfFile);
var uploadPromise = fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: pdfForm })
.then(function(r) { return r.json(); });
var extractPromise = new Promise(function(resolve) {
loadPdfJs(function() {
var fr = new FileReader();
fr.onload = function(e) {
pdfjsLib.getDocument({ data: new Uint8Array(e.target.result) }).promise
.then(function(pdf) {
var pages = [];
for (var i = 1; i <= pdf.numPages; i++) pages.push(pdf.getPage(i));
Promise.all(pages).then(function(ps) {
return Promise.all(ps.map(function(p, pi) {
return p.getTextContent().then(function(tc) {
return pdfPageToText(tc.items, pi + 1, pdf.numPages);
});
}));
}).then(function(texts) {
resolve({ text: texts.join('\n\n'), pages: pdf.numPages });
}).catch(function() { resolve({ text: '', pages: 0 }); });
}).catch(function() { resolve({ text: '', pages: 0 }); });
};
fr.readAsArrayBuffer(pdfFile);
});
});
Promise.all([uploadPromise, extractPromise]).then(function(res) {
var upData = res[0], extracted = res[1];
var fileUrl = (upData.success && upData.path) ? '/api/files/' + upData.path : null;
var fullText = (extracted.text || '').trim();
if (fullText) {
var txtName = pdfFile.name.replace(/\.pdf$/i, '.txt');
var txtBlob = new Blob([extracted.text], { type: 'text/plain; charset=utf-8' });
var txtForm = new FormData();
txtForm.append('file', new File([txtBlob], txtName, { type: 'text/plain' }));
fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: txtForm })
.then(function(r) { return r.json(); })
.then(function(d) {
var txtUrl = (d.success && d.path) ? '/api/files/' + d.path : null;
if (txtUrl) addProcessEntry('info', '📝 텍스트 저장: ' + txtName);
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')';
if (txtUrl) content += '\n[' + txtName + '](' + txtUrl + ')';
content += '\n\n이 파일로 무엇을 도와드릴까요? 사용자가 원하는 작업을 직접 알려주기 전까지 어떤 변환이나 생성 작업도 시작하지 마세요.';
stageAttachment(pdfFile.name, fileUrl, content, 'pdf', {
txtPreview: fullText.slice(0, 500),
txtUrl: txtUrl,
txtName: txtName,
pages: extracted.pages
});
}).catch(function() {
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n\n이 파일로 무엇을 도와드릴까요?';
stageAttachment(pdfFile.name, fileUrl, content, 'pdf', {
txtPreview: fullText.slice(0, 500),
pages: extracted.pages
});
});
} else {
var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n*(텍스트 추출 불가 — 스캔 이미지이거나 보안 잠금 PDF)*';
stageAttachment(pdfFile.name, fileUrl, content, 'pdf');
}
}).catch(function(e) { alert('PDF 처리 오류: ' + e.message); });
// 기타 (docx/pptx 등)
} else {
var otherForm = new FormData();
otherForm.append('file', file);
fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: otherForm })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.path) {
insertIntoChatInput('[' + file.name + '](/api/files/' + data.path + ')');
} else { alert(data.error || '파일 업로드 실패'); }
})
.catch(function(e) { alert('업로드 오류: ' + e.message); });
}
}
function insertIntoChatInput(text) {
var el = document.getElementById('chat-input');
el.value = el.value ? el.value + '\n' + text : text;
el.focus();
el.dispatchEvent(new Event('input'));
}
// ── 첨부 파일 스테이징 ──────────────────────────────────────────────────────
var stagedAttachments = [];
function stageAttachment(name, url, content, type, extra) {
var id = 'att-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
var att = { id: id, name: name, url: url, content: content, type: type };
if (extra) Object.assign(att, extra);
stagedAttachments.push(att);
renderStagedAttachments();
}
function removeStagedAttachment(id) {
stagedAttachments = stagedAttachments.filter(function(a) { return a.id !== id; });
renderStagedAttachments();
}
function clearStagedAttachments() {
stagedAttachments = [];
renderStagedAttachments();
}
function renderStagedAttachments() {
var container = document.getElementById('staged-attachments');
if (!container) return;
if (!stagedAttachments.length) { container.style.display = 'none'; container.innerHTML = ''; return; }
container.style.display = 'flex';
container.innerHTML = stagedAttachments.map(function(a) {
var icon = a.type === 'pdf' ? '📄' : a.type === 'excel' ? '📊' : a.type === 'csv' ? '📋' : '📝';
var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px';
// PDF 카드
if (a.type === 'pdf') {
var txtPreview = a.txtPreview ? escHtml(a.txtPreview) + (a.txtPreview.length >= 500 ? '...' : '') : '';
var pagesInfo = a.pages ? ' (' + a.pages + '페이지)' : '';
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
+ '<div style="display:flex;align-items:center;gap:6px">'
+ '<span>📄</span>'
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(a.name) + pagesInfo + '">' + escHtml(a.name) + '</span>'
+ (txtPreview ? '<button onclick="toggleStagedTxtPdfPreview(\'' + a.id + '\')" style="' + btnStyle + '">txt 미리보기</button>' : '')
+ (a.url ? '<button onclick="toggleStagedPdfPreview(\'' + a.id + '\')" style="' + btnStyle + '">PDF</button>' : '')
+ (a.txtUrl ? '<a href="' + escHtml(a.txtUrl) + '" download="' + escHtml(a.txtName || '') + '" style="' + btnStyle + ';text-decoration:none;display:inline-block" title="txt 다운로드">📝⬇</a>' : '')
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
+ '</div>'
+ (pagesInfo ? '<div style="font-size:10px;color:var(--muted);padding-left:2px">텍스트 추출 완료' + pagesInfo + '</div>' : '')
+ (txtPreview ? '<div id="txt-pdf-preview-' + a.id + '" style="display:none;max-height:200px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:6px;padding:8px;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text)">' + txtPreview + '</div>' : '')
+ '<div id="pdf-preview-' + a.id + '" style="display:none"><iframe data-src="' + escHtml(a.url || '') + '" style="width:100%;height:400px;border:1px solid var(--line);border-radius:6px"></iframe></div>'
+ '</div>';
}
// TXT 자동저장 카드 (미리보기 + 다운로드)
if (a.type === 'txt-auto') {
var preview = escHtml((a.content || '').slice(0, 300)) + ((a.content || '').length > 300 ? '...' : '');
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px dashed var(--brand);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
+ '<div style="display:flex;align-items:center;gap:6px">'
+ '<span>📝</span>'
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
+ '<button onclick="toggleStagedTxtPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>'
+ (a.url ? '<a href="' + escHtml(a.url) + '" download="' + escHtml(a.name) + '" style="' + btnStyle + ';text-decoration:none;display:inline-block" title="다운로드">⬇ 저장</a>' : '')
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
+ '</div>'
+ '<div id="txt-preview-' + a.id + '" style="display:none;max-height:200px;overflow-y:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:6px;padding:8px;font-size:11px;white-space:pre-wrap;word-break:break-all;color:var(--text)">' + preview + '</div>'
+ '</div>';
}
// excel 카드
if (a.type === 'excel') {
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:360px">'
+ '<div style="display:flex;align-items:center;gap:6px">'
+ '<span>📊</span>'
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
+ (a.excelPreviewHtml ? '<button onclick="toggleStagedExcelPreview(\'' + a.id + '\')" style="' + btnStyle + '">미리보기</button>' : '')
+ (a.url ? '<a href="' + escHtml(a.url) + '" download="' + escHtml(a.name) + '" style="' + btnStyle + ';text-decoration:none;display:inline-block">⬇ 저장</a>' : '')
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
+ '</div>'
+ (a.excelPreviewHtml ? '<div id="excel-staged-' + a.id + '" style="display:none;max-height:260px;overflow:auto;background:var(--panel-1);border:1px solid var(--line);border-radius:6px;padding:8px">' + a.excelPreviewHtml + '</div>' : '')
+ '</div>';
}
// 기타 카드 (text, csv)
return '<div id="card-' + a.id + '" style="display:flex;flex-direction:column;gap:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12px;max-width:320px">'
+ '<div style="display:flex;align-items:center;gap:6px">'
+ '<span>' + icon + '</span>'
+ '<span style="font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(a.name) + '</span>'
+ '<button onclick="removeStagedAttachment(\'' + a.id + '\')" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px">✕</button>'
+ '</div>'
+ '</div>';
}).join('');
}
function toggleStagedPdfPreview(id) {
var el = document.getElementById('pdf-preview-' + id);
if (!el) return;
var att = stagedAttachments.find(function(a) { return a.id === id; });
if (el.style.display === 'none') {
el.style.display = 'block';
var iframe = el.querySelector('iframe');
if (iframe && att && att.url) iframe.src = att.url; // 항상 명시 설정
} else {
el.style.display = 'none';
}
}
function toggleStagedTxtPreview(id) {
var el = document.getElementById('txt-preview-' + id);
if (!el) return;
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function toggleStagedTxtPdfPreview(id) {
var el = document.getElementById('txt-pdf-preview-' + id);
if (!el) return;
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
// PDF 카드에 txt 저장 버튼 동적 삽입 (txt 업로드 완료 후 호출)
function addTxtButtonToPdfCard(pdfName, txtName, txtUrl) {
// stagedAttachments에서 해당 PDF 찾기
var att = stagedAttachments.find(function(a) { return a.name === pdfName && a.type === 'pdf'; });
if (!att) return;
// txt 정보를 attachment에 저장 (전송 시 displayMessage에 포함용)
att.txtName = txtName;
att.txtUrl = txtUrl;
// 카드 DOM에 txt 버튼 삽입
var card = document.getElementById('card-' + att.id);
if (!card) return;
var btnRow = card.querySelector('div');
if (!btnRow) return;
var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px;text-decoration:none;display:inline-block';
var txtBtn = document.createElement('a');
txtBtn.href = txtUrl;
txtBtn.download = txtName;
txtBtn.title = txtName + ' 다운로드';
txtBtn.style.cssText = btnStyle;
txtBtn.innerHTML = '📝 txt ⬇';
// ✕ 버튼 바로 앞에 삽입
var closeBtn = btnRow.lastElementChild;
btnRow.insertBefore(txtBtn, closeBtn);
}
// ── PDF 페이지 텍스트 구조화 ────────────────────────────────────────────────
function pdfPageToText(items, pageNum, totalPages) {
// filter out empty items
var valid = items.filter(function(it) { return it.str && it.str.trim() !== ''; });
if (!valid.length) return '';
// sort top→bottom (y desc), left→right (x asc)
valid.sort(function(a, b) {
var ay = a.transform[5], by = b.transform[5];
if (Math.abs(ay - by) > 2) return by - ay;
return a.transform[4] - b.transform[4];
});
// group items into lines by y-coordinate proximity
var LINE_Y_THRESH = 3;
var lines = [];
var cur = null;
valid.forEach(function(item) {
var y = item.transform[5];
var h = item.height || 10;
if (!cur || Math.abs(y - cur.y) > LINE_Y_THRESH) {
cur = { y: y, h: h, items: [item] };
lines.push(cur);
} else {
cur.items.push(item);
if (h > cur.h) cur.h = h;
}
});
// build output: join items per line; insert blank line on large y-gaps (paragraph break)
var out = [];
if (totalPages > 1) out.push('── ' + pageNum + '페이지 ──');
for (var i = 0; i < lines.length; i++) {
var lineText = lines[i].items.map(function(it) { return it.str; }).join('').replace(/\s+/g, ' ').trim();
if (!lineText) continue;
// paragraph break: gap > 1.4× current line height
if (i > 0) {
var gap = lines[i - 1].y - lines[i].y;
if (gap > lines[i - 1].h * 1.4) out.push('');
}
// fix hyphenation: merge "단어-\n다음줄" → "단어다음줄"
if (out.length && out[out.length - 1].endsWith('-')) {
out[out.length - 1] = out[out.length - 1].slice(0, -1) + lineText;
} else {
out.push(lineText);
}
}
return out.join('\n');
}
// ── SheetJS / PDF.js / x-spreadsheet 동적 로드 ──────────────────────────────────────────────
var _sheetJsLoaded = false;
function loadSheetJS(cb) {
if (_sheetJsLoaded) return cb();
var s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
s.onload = function() { _sheetJsLoaded = true; cb(); };
document.head.appendChild(s);
}
var _luckyLoaded = false;
function loadLuckysheet(cb) {
if (_luckyLoaded) return cb();
var cssList = [
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/css/pluginsCss.css',
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/plugins.css',
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/css/luckysheet.css',
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/assets/iconfont/iconfont.css',
];
cssList.forEach(function(href) {
var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href;
document.head.appendChild(l);
});
var scripts = [
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/js/plugin.js',
'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/luckysheet.umd.js',
'https://cdn.jsdelivr.net/npm/luckyexcel@1.0.1/dist/luckyexcel.umd.js',
];
function next(i) {
if (i >= scripts.length) { _luckyLoaded = true; cb(); return; }
var s = document.createElement('script');
s.src = scripts[i]; s.onload = function() { next(i + 1); };
s.onerror = function() { next(i + 1); }; // 실패해도 진행
document.head.appendChild(s);
}
next(0);
}
var _pdfJsLoaded = false;
function loadPdfJs(cb) {
if (_pdfJsLoaded) return cb();
var s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
s.onload = function() {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
_pdfJsLoaded = true; cb();
};
document.head.appendChild(s);
}
function timeAgo(ts) {
const diff = Date.now() - ts;
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`;
return `${Math.floor(diff/86400000)}d ago`;
}
function openOllamaInfo() { window.open('http://localhost:11434','_blank'); }
// ---- Skills management (v2 - local skills system) ----
let skillsCache = [];
let editingSkillId = null;
let skillsTargetUser = ''; // '' = global (admin) or own username (regular user)
async function initSkillsUserSelector() {
const selector = document.getElementById('skills-user-selector');
if (!selector) return;
if (!currentUser) { selector.style.display = 'none'; return; }
if (currentUser.role !== 'admin') {
selector.style.display = 'none';
skillsTargetUser = currentUser.username || '';
return;
}
selector.style.display = 'block';
try {
const data = await api('/api/users');
const sel = document.getElementById('skills-target-user');
if (!sel || !data.users) return;
const opts = ['<option value="">글로벌 기본값</option>'];
for (const u of data.users) {
const selected = u.username === skillsTargetUser ? ' selected' : '';
opts.push(`<option value="${escHtml(u.username)}"${selected}>${escHtml(u.username)}</option>`);
}
sel.innerHTML = opts.join('');
} catch {}
}
function onSkillsUserChange() {
const sel = document.getElementById('skills-target-user');
skillsTargetUser = sel ? sel.value : '';
loadInstalledSkills();
}
async function loadInstalledSkills() {
const el = document.getElementById('skills-list');
el.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:11px"><span class="spinner"></span> Loading...</div>';
try {
const effectiveUser = skillsTargetUser || (currentUser?.role !== 'admin' ? currentUser?.username : null);
const url = effectiveUser ? `/api/skills?username=${encodeURIComponent(effectiveUser)}` : '/api/skills';
const data = await api(url);
const skills = data.skills || [];
skillsCache = skills;
if (skills.length === 0) {
el.innerHTML = '<div class="empty-state" style="padding:16px">No skills yet.<br>Click "+ Create" to make one!</div>';
} else {
el.innerHTML = skills.map(s => {
const skillEmoji = (s.emoji === '🧩' || s.emoji === '🦞' || s.emoji === '⚠️') ? s.emoji : '🧩';
const globalBadge = s.globalOnly ? '<span style="font-size:9px;opacity:.6;margin-left:4px">[전체]</span>' : '';
return `
<div class="skill-card ${s.enabled ? 'enabled' : ''}" data-skill-id="${escHtml(s.id)}" onclick="toggleSkill('${escHtml(s.id)}')">
<div class="skill-card-top">
<div class="skill-card-emoji">${escHtml(skillEmoji)}</div>
<div class="skill-card-info">
<div class="skill-card-name">${escHtml(s.name)}${globalBadge}</div>
<div class="skill-card-desc">${escHtml(s.description || 'No description')}</div>
</div>
<div class="skill-card-actions">
<button class="skill-mini-btn" onclick="event.stopPropagation();editSkill('${escHtml(s.id)}')" title="Edit" aria-label="Edit skill">✎</button>
<button class="skill-remove-btn" onclick="event.stopPropagation();deleteSkill('${escHtml(s.id)}')" title="Delete" aria-label="Delete skill">✕</button>
<div class="skill-card-dot"></div>
</div>
</div>
</div>
`;
}).join('');
renderSkillEligibility(skills);
}
} catch (err) {
el.innerHTML = `<div style="padding:12px;color:var(--err);font-size:11px">Error: ${err.message}</div>`;
}
}
async function toggleSkill(id) {
const skillMeta = (skillsCache || []).find(s => s.id === id);
if (skillMeta && skillMeta.eligible === false && !skillMeta.enabled) {
addProcessEntry('error', skillMeta.eligibleReason || 'This skill is currently unavailable.');
return;
}
// globalOnly 스킬은 per-user 모드에서 토글 불가
if (skillMeta?.globalOnly && (skillsTargetUser || currentUser?.role !== 'admin')) {
addProcessEntry('error', `"${skillMeta.name}" 스킬은 글로벌 전용입니다.`);
return;
}
try {
const effectiveUser = skillsTargetUser || (currentUser?.role !== 'admin' ? currentUser?.username : null);
const body = effectiveUser ? JSON.stringify({ username: effectiveUser }) : undefined;
const res = await api(`/api/skills/${encodeURIComponent(id)}/toggle`, { method: 'POST', body });
if (!res.success) {
addProcessEntry('error', res.error || 'Failed to toggle skill.');
return;
}
const label = res.targetUser ? ` (${res.targetUser})` : '';
addProcessEntry('info', `Skill "${res.skill.name}"${label}: ${res.skill.enabled ? 'ENABLED' : 'DISABLED'}`);
loadInstalledSkills();
} catch (err) {
addProcessEntry('error', `Failed to toggle skill: ${err.message}`);
}
}
async function deleteSkill(id) {
if (!confirm(`Delete skill "${id}"?`)) return;
try {
const res = await api(`/api/skills/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.success) {
addProcessEntry('info', `Skill "${id}" deleted.`);
loadInstalledSkills();
} else {
alert(`Delete failed: ${res.error}`);
}
} catch (err) {
alert(`Error: ${err.message}`);
}
}
function openCreateSkillModal() {
editingSkillId = null;
document.getElementById('skill-modal-title').textContent = '스킬 생성';
document.getElementById('skill-modal-save-btn').textContent = '스킬 생성';
document.getElementById('skill-modal-name').value = '';
document.getElementById('skill-modal-emoji').value = '';
document.getElementById('skill-modal-desc').value = '';
document.getElementById('skill-modal-instructions').value = '';
document.getElementById('skill-modal').style.display = 'flex';
document.getElementById('skill-modal-name').focus();
}
async function editSkill(id) {
try {
const res = await api(`/api/skills/${encodeURIComponent(id)}`);
if (!res.success) { alert('Skill not found'); return; }
const s = res.skill;
editingSkillId = id;
document.getElementById('skill-modal-title').textContent = '스킬 편집';
document.getElementById('skill-modal-save-btn').textContent = '변경 저장';
document.getElementById('skill-modal-name').value = s.name || '';
document.getElementById('skill-modal-emoji').value = s.emoji || '';
document.getElementById('skill-modal-desc').value = s.description || '';
document.getElementById('skill-modal-instructions').value = s.instructions || '';
document.getElementById('skill-modal').style.display = 'flex';
document.getElementById('skill-modal-name').focus();
} catch (err) {
alert(`Error loading skill: ${err.message}`);
}
}
function closeSkillModal() {
document.getElementById('skill-modal').style.display = 'none';
editingSkillId = null;
}
async function saveSkillFromModal() {
const name = document.getElementById('skill-modal-name').value.trim();
const rawEmoji = document.getElementById('skill-modal-emoji').value.trim();
const emoji = (rawEmoji === '🧩' || rawEmoji === '🦞' || rawEmoji === '⚠️') ? rawEmoji : '🧩';
const description = document.getElementById('skill-modal-desc').value.trim();
const instructions = document.getElementById('skill-modal-instructions').value.trim();
if (!name) { alert('Name is required'); return; }
if (!instructions) { alert('Instructions are required'); return; }
const btn = document.getElementById('skill-modal-save-btn');
btn.disabled = true;
btn.textContent = '저장 중...';
try {
let res;
if (editingSkillId) {
// Update existing
res = await api(`/api/skills/${encodeURIComponent(editingSkillId)}`, {
method: 'PUT',
body: JSON.stringify({ name, emoji, description, instructions }),
});
} else {
// Create new
res = await api('/api/skills', {
method: 'POST',
body: JSON.stringify({ name, emoji, description, instructions }),
});
}
if (res.success) {
addProcessEntry('info', `Skill "${name}" ${editingSkillId ? 'updated' : 'created'}!`);
closeSkillModal();
loadInstalledSkills();
} else {
alert(`Failed: ${res.error}`);
}
} catch (err) {
alert(`Error: ${err.message}`);
} finally {
btn.disabled = false;
btn.textContent = editingSkillId ? '변경 저장' : '스킬 생성';
}
}
// ---- Stop Button ----
let currentAbortController = null;
function handleSendStop() {
if (isThinking) stopGeneration();
else sendChat();
}
function stopGeneration() {
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
}
function setButtonState(thinking) {
const sendIcon = document.getElementById('send-icon');
const stopIcon = document.getElementById('stop-icon');
const btn = document.getElementById('send-btn');
if (!btn) return;
if (thinking) {
if (sendIcon) sendIcon.style.display = 'none';
if (stopIcon) stopIcon.style.display = '';
btn.style.background = 'var(--err)';
btn.title = 'Stop generation';
} else {
if (sendIcon) sendIcon.style.display = '';
if (stopIcon) stopIcon.style.display = 'none';
btn.style.background = '';
btn.title = 'Send';
}
}
// ---- Canvas Panel ----
let canvasOpen = false;
let canvasTabs = [];
let activeCanvasTabId = null;
let canvasEditor = null;
let canvasEditorInitialized = false;
const CANVAS_LANG_MAP = {
js:'javascript', ts:'javascript', jsx:'javascript', tsx:'javascript', mjs:'javascript',
html:'htmlmixed', htm:'htmlmixed',
css:'css', py:'python', xml:'xml',
json:'javascript', md:'markdown', txt:'null',
};
function getCanvasLang(filename) {
const ext = String(filename || '').split('.').pop().toLowerCase();
return CANVAS_LANG_MAP[ext] || 'null';
}
function isHtmlFile(filename) {
const ext = String(filename || '').split('.').pop().toLowerCase();
return ext === 'html' || ext === 'htm';
}
function toggleCanvas() {
canvasOpen = !canvasOpen;
const panel = document.getElementById('canvas-panel');
const btn = document.getElementById('canvas-toggle-btn');
const hideEls = ['system-stats-section', 'process-section', 'agent-panel'];
if (!panel) return;
if (canvasOpen) {
panel.style.display = 'flex';
if (btn) { btn.style.background='#eaf2ff'; btn.style.borderColor='#bdd3f6'; btn.style.color='#0d4faf'; }
if (!canvasEditorInitialized) initCanvasEditor();
canvasRenderTabs();
hideEls.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; });
setTimeout(() => {
const shell = getActiveShell();
if (shell && !shell.term) {
const container = document.getElementById('canvas-term-container');
if (container) {
const result = createXtermShell(container, shell.id);
shell.term = result.term;
shell.fitAddon = result.fitAddon;
shell.ptyReady = result.inst.ptyReady;
}
}
if (shell && shell.term) {
shell.fitAddon.fit();
shell.term.focus();
}
}, 150);
} else {
panel.style.display = 'none';
if (btn) { btn.style.background=''; btn.style.borderColor=''; btn.style.color=''; }
hideEls.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = ''; });
}
}
function initCanvasEditor() {
canvasEditorInitialized = true;
const wrap = document.getElementById('canvas-editor-wrap');
if (!wrap || typeof CodeMirror === 'undefined') return;
canvasEditor = CodeMirror(wrap, {
value: '',
mode: 'htmlmixed',
lineNumbers: true,
lineWrapping: true,
tabSize: 2,
indentWithTabs: false,
theme: 'default',
extraKeys: { 'Ctrl-S': () => canvasSave(), 'Cmd-S': () => canvasSave() },
});
canvasEditor.on('change', () => {
const tab = canvasTabs.find(t => t.id === activeCanvasTabId);
if (!tab) return;
tab.content = canvasEditor.getValue();
if (tab.mode === 'preview') canvasUpdatePreview();
});
}
function canvasNewFile() {
const name = prompt('File name:', 'untitled.html');
if (!name || !name.trim()) return;
const id = 'ctab_' + Math.random().toString(36).slice(2);
canvasTabs.push({ id, name: name.trim(), content: '', mode: 'code', language: getCanvasLang(name) });
canvasOpenTab(id);
}
function canvasOpenTab(id) {
activeCanvasTabId = id;
const tab = canvasTabs.find(t => t.id === id);
if (!tab) return;
if (canvasEditor) {
canvasEditor.setValue(tab.content || '');
canvasEditor.setOption('mode', tab.language || 'null');
setTimeout(() => canvasEditor.refresh(), 10);
}
applyCanvasViewMode(tab.mode || 'code', tab);
canvasRenderTabs();
}
function canvasCloseTab(id, ev) {
if (ev) ev.stopPropagation();
const idx = canvasTabs.findIndex(t => t.id === id);
if (idx === -1) return;
canvasTabs.splice(idx, 1);
if (activeCanvasTabId === id) {
if (canvasTabs.length > 0) {
canvasOpenTab(canvasTabs[Math.min(idx, canvasTabs.length - 1)].id);
} else {
activeCanvasTabId = null;
if (canvasEditor) canvasEditor.setValue('');
applyCanvasViewMode('code', null);
canvasRenderTabs();
}
} else {
canvasRenderTabs();
}
}
function setCanvasMode(mode) {
const tab = canvasTabs.find(t => t.id === activeCanvasTabId);
if (!tab) return;
// sync editor -> tab before switching
if (canvasEditor && tab.mode !== 'preview') tab.content = canvasEditor.getValue();
tab.mode = mode;
applyCanvasViewMode(mode, tab);
canvasRenderTabs();
}
function applyCanvasViewMode(mode, tab) {
const editorWrap = document.getElementById('canvas-editor-wrap');
const previewFrame = document.getElementById('canvas-preview-frame');
const emptyState = document.getElementById('canvas-empty-state');
const codeModeBtn = document.getElementById('canvas-code-btn');
const previewModeBtn = document.getElementById('canvas-preview-btn');
if (!tab) {
if (editorWrap) editorWrap.style.display = 'none';
if (previewFrame) previewFrame.style.display = 'none';
if (emptyState) emptyState.style.display = 'flex';
return;
}
if (emptyState) emptyState.style.display = 'none';
if (mode === 'preview') {
if (editorWrap) editorWrap.style.display = 'none';
if (previewFrame) { previewFrame.style.display = 'flex'; previewFrame.style.flex = '1'; }
if (codeModeBtn) codeModeBtn.classList.remove('active');
if (previewModeBtn) previewModeBtn.classList.add('active');
canvasUpdatePreview();
} else {
if (editorWrap) { editorWrap.style.display = 'block'; editorWrap.style.flex = '1'; }
if (previewFrame) previewFrame.style.display = 'none';
if (codeModeBtn) codeModeBtn.classList.add('active');
if (previewModeBtn) previewModeBtn.classList.remove('active');
if (canvasEditor) setTimeout(() => { canvasEditor.refresh(); canvasEditor.focus(); }, 10);
}
}
function canvasRenderTabs() {
const tabsEl = document.getElementById('canvas-tabs');
if (!tabsEl) return;
tabsEl.innerHTML = canvasTabs.map(t => `
<div class="canvas-tab ${t.id === activeCanvasTabId ? 'active' : ''}" onclick="canvasOpenTab('${t.id}')">
<span>${escHtml(t.name)}</span>
<button class="canvas-tab-close" onclick="canvasCloseTab('${t.id}', event)" title="Close">×</button>
</div>
`).join('') + `<button class="canvas-new-tab" onclick="canvasNewFile()" title="New file">+</button>`;
const tab = canvasTabs.find(t => t.id === activeCanvasTabId);
const codeModeBtn = document.getElementById('canvas-code-btn');
const previewModeBtn = document.getElementById('canvas-preview-btn');
if (codeModeBtn) codeModeBtn.classList.toggle('active', !tab || tab.mode !== 'preview');
if (previewModeBtn) previewModeBtn.classList.toggle('active', !!(tab && tab.mode === 'preview'));
}
function canvasUpdatePreview() {
const frame = document.getElementById('canvas-preview-frame');
const tab = canvasTabs.find(t => t.id === activeCanvasTabId);
if (!frame || !tab) return;
const content = tab.content || '';
if (!isHtmlFile(tab.name) && !content.trim().startsWith('<')) {
frame.srcdoc = `<pre style="font-family:monospace;padding:16px;white-space:pre-wrap;word-break:break-word">${content.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</pre>`;
return;
}
frame.srcdoc = content;
frame.onload = () => {
try {
const doc = frame.contentDocument;
if (!doc || !doc.body) return;
doc.body.contentEditable = 'true';
doc.body.spellcheck = false;
doc.body.addEventListener('input', () => {
const html = '<!DOCTYPE html>\n' + doc.documentElement.outerHTML;
tab.content = html;
// sync back to editor so Code mode shows updated content
if (canvasEditor && tab.mode !== 'preview') canvasEditor.setValue(html);
});
} catch {}
};
}
function canvasSave() {
const tab = canvasTabs.find(t => t.id === activeCanvasTabId);
if (!tab) { addProcessEntry('warn', 'Canvas: no file open to save.'); return; }
if (canvasEditor && tab.mode !== 'preview') tab.content = canvasEditor.getValue();
const blob = new Blob([tab.content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = tab.name; a.click();
URL.revokeObjectURL(url);
addProcessEntry('info', `Canvas: saved ${tab.name}`);
}
function canvasAddToContext() {
const tab = canvasTabs.find(t => t.id === activeCanvasTabId);
if (!tab) { addProcessEntry('warn', 'Canvas: no file open.'); return; }
if (canvasEditor && tab.mode !== 'preview') tab.content = canvasEditor.getValue();
const ext = tab.name.split('.').pop() || 'txt';
const block = '```' + ext + '\n' + tab.content + '\n```';
// Send as a message to the LLM so it enters context
const prompt = `Here is the contents of my file "${tab.name}" for reference:\n\n${block}\n\nI've loaded this file into context. What would you like me to do with it?`;
const chatInput = document.getElementById('chat-input');
if (chatInput) {
chatInput.value = prompt;
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 140) + 'px';
}
sendChat();
addProcessEntry('info', `Canvas: sent ${tab.name} to LLM as context.`);
}
// ── xterm.js Terminal ────────────────────────────────────────────────────────
let termWs = null;
let termWsReconnectTimer = null;
let termWsConnected = false;
const xtermInstances = new Map(); // shellId → { term, fitAddon, ptyReady }
function getCookie(name) {
const m = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'));
return m ? decodeURIComponent(m[1]) : '';
}
function termWsConnect() {
if (termWs && (termWs.readyState === WebSocket.OPEN || termWs.readyState === WebSocket.CONNECTING)) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
let url = `${proto}//${location.host}/ws`;
const token = sessionStorage.getItem('smallclaw_token') || getCookie('smallclaw_session');
if (token) url += `?token=${encodeURIComponent(token)}`;
try { termWs = new WebSocket(url); } catch { return; }
termWs.onopen = () => {
termWsConnected = true;
// Spawn PTY for each existing shell
for (const [shellId, inst] of xtermInstances) {
if (!inst.ptyReady) {
inst.ptyReady = true;
const term = inst.term;
termWsSend({ type: 'pty_spawn', shellId, cols: term.cols, rows: term.rows });
}
}
};
termWs.onmessage = (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.type === 'pty_output') {
const inst = xtermInstances.get(msg.shellId);
if (inst) inst.term.write(msg.data);
} else if (msg.type === 'pty_exit') {
const inst = xtermInstances.get(msg.shellId);
if (inst) {
inst.ptyReady = false;
inst.term.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
}
} else if (msg.type === 'pty_error') {
const inst = xtermInstances.get(msg.shellId);
if (inst) inst.term.write('\r\n\x1b[31mPTY error: ' + (msg.error || 'unknown') + '\x1b[0m\r\n');
}
};
termWs.onclose = () => {
termWsConnected = false;
termWs = null;
for (const [id, inst] of xtermInstances) inst.ptyReady = false;
termWsReconnectTimer = setTimeout(termWsConnect, 3000);
};
termWs.onerror = () => { termWsConnected = false; };
}
function termWsSend(msg) {
if (termWs && termWs.readyState === WebSocket.OPEN) {
termWs.send(JSON.stringify(msg));
return true;
}
return false;
}
function createXtermShell(container, shellId) {
const id = shellId || 'sh_' + Math.random().toString(36).slice(2, 8);
// Create a sub-container for this shell's xterm instance
const sub = document.createElement('div');
sub.id = 'xterm-' + id;
sub.style.cssText = 'position:absolute;inset:0';
container.appendChild(sub);
const term = new Terminal({
theme: {
background: '#0C0C0C',
foreground: '#CCCCCC',
cursor: '#CCCCCC',
cursorAccent: '#0C0C0C',
selectionBackground: 'rgba(59,120,255,0.4)',
black: '#1e1e1e',
red: '#E74856',
green: '#16C60C',
yellow: '#C19C00',
blue: '#3B78FF',
magenta: '#B4009E',
cyan: '#61D6D6',
white: '#CCCCCC',
brightBlack: '#767676',
brightRed: '#FF6E6E',
brightGreen: '#69FF69',
brightYellow: '#FFFF55',
brightBlue: '#96CBFE',
brightMagenta: '#FF76F6',
brightCyan: '#8FFFFF',
brightWhite: '#FFFFFF',
},
fontFamily: "'IBM Plex Mono', 'Courier New', monospace",
fontSize: 14,
lineHeight: 1.4,
cursorBlink: true,
scrollback: 5000,
allowProposedApi: true,
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(webLinksAddon);
term.open(sub);
fitAddon.fit();
const inst = { term, fitAddon, ptyReady: false };
xtermInstances.set(id, inst);
// Send keystrokes to PTY
term.onData((data) => {
if (termWsConnected && inst.ptyReady) {
termWsSend({ type: 'pty_input', shellId: id, data });
}
});
// Resize PTY when terminal resizes
term.onResize(({ cols, rows }) => {
if (termWsConnected && inst.ptyReady) {
termWsSend({ type: 'pty_resize', shellId: id, cols, rows });
}
});
// Spawn PTY if WS is already connected
if (termWsConnected) {
inst.ptyReady = true;
termWsSend({ type: 'pty_spawn', shellId: id, cols: term.cols, rows: term.rows });
}
return { id, term, fitAddon, inst };
}
// ── Canvas interactive shell (multi-shell tabs) ─────────────────────────────
let termShells = [];
let activeTermShellId = null;
function createShell() {
const id = 'sh_' + Math.random().toString(36).slice(2, 8);
const shell = { id, name: 'bash', cwd: null, ptyReady: false, term: null, fitAddon: null };
termShells.push(shell);
return shell;
}
function getActiveShell() {
return termShells.find(s => s.id === activeTermShellId);
}
function renderShellTabs() {
const bar = document.getElementById('canvas-shell-tabs');
if (!bar) return;
bar.innerHTML = termShells.map(s => {
const active = s.id === activeTermShellId;
return `<div onclick="switchTermShell('${s.id}')" style="display:flex;align-items:center;gap:5px;padding:0 10px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-size:13px;color:${active ? '#CCCCCC' : '#767676'};background:${active ? '#0C0C0C' : 'transparent'};border-top:${active ? '2px solid #3B78FF' : '2px solid transparent'};white-space:nowrap;user-select:none">
<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" style="opacity:.6;flex-shrink:0"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg>
<span>${escHtml(s.name)}</span>
${termShells.length > 1 ? `<span onclick="closeTermShell('${s.id}',event)" style="margin-left:3px;color:#767676;font-size:13px;line-height:1">&times;</span>` : ''}
</div>`;
}).join('');
}
function switchTermShell(id) {
// Hide current terminal sub-container
const cur = getActiveShell();
if (cur) {
const curSub = document.getElementById('xterm-' + cur.id);
if (curSub) curSub.style.display = 'none';
}
activeTermShellId = id;
const shell = getActiveShell();
if (!shell) return;
// Show or create xterm for this shell
if (shell.term) {
const sub = document.getElementById('xterm-' + shell.id);
if (sub) sub.style.display = '';
shell.term.focus();
if (shell.fitAddon) shell.fitAddon.fit();
} else {
const container = document.getElementById('canvas-term-container');
if (container) {
const result = createXtermShell(container, shell.id);
shell.term = result.term;
shell.fitAddon = result.fitAddon;
shell.ptyReady = result.inst.ptyReady;
}
}
renderShellTabs();
}
function addTermShell() {
const shell = createShell();
switchTermShell(shell.id);
}
function closeTermShell(id, ev) {
if (ev) { ev.stopPropagation(); }
const idx = termShells.findIndex(s => s.id === id);
if (idx === -1 || termShells.length <= 1) return;
termWsSend({ type: 'pty_kill', shellId: id });
const shell = termShells[idx];
if (shell.term) shell.term.dispose();
xtermInstances.delete(id);
// Remove sub-container
const sub = document.getElementById('xterm-' + id);
if (sub) sub.remove();
termShells.splice(idx, 1);
if (activeTermShellId === id) {
switchTermShell(termShells[Math.min(idx, termShells.length - 1)].id);
} else {
renderShellTabs();
}
}
// Fallback: HTTP POST for canvasRun and non-PTY scenarios
async function termExec(command) {
const shell = getActiveShell();
if (!shell || !command.trim()) return;
if (termWsConnected && shell.ptyReady && shell.term) {
termWsSend({ type: 'pty_input', shellId: shell.id, data: command + '\r' });
return;
}
const status = document.getElementById('canvas-run-status');
if (status) { status.textContent = '●'; status.style.color = '#C19C00'; }
try {
const resp = await fetch('/api/canvas/shell', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, cwd: shell.cwd }),
});
const data = await resp.json();
if (data.cwd) shell.cwd = data.cwd;
if (shell.term) {
if (data.stdout) shell.term.write(data.stdout);
if (data.stderr) shell.term.write('\x1b[31m' + data.stderr + '\x1b[0m');
}
if (status) { status.textContent = data.exitCode === 0 ? '✓' : '✗'; status.style.color = data.exitCode === 0 ? '#16C60C' : '#E74856'; }
} catch (err) {
if (shell.term) shell.term.write('\x1b[31mError: ' + String(err) + '\x1b[0m\r\n');
if (status) { status.textContent = '✗'; status.style.color = '#E74856'; }
}
}
function canvasRun() {
const tab = canvasTabs.find(t => t.id === activeCanvasTabId);
if (!tab) return;
if (canvasEditor && tab.mode !== 'preview') tab.content = canvasEditor.getValue();
const ext = (tab.name.split('.').pop() || '').toLowerCase();
if (!['py','js','ts','mjs'].includes(ext)) return;
const runner = ext === 'py' ? 'python3' : (ext === 'ts' ? 'npx tsx' : 'node');
const tmpName = '.canvas-tmp/' + tab.name.replace(/[^a-zA-Z0-9._-]/g, '_');
termExec(`cat > "${tmpName}" << 'CANVAS_EOF'\n${tab.content}\nCANVAS_EOF && ${runner} "${tmpName}" && rm -f "${tmpName}"`);
}
// Init first shell + WS PTY connection
(function() {
const shell = createShell();
activeTermShellId = shell.id;
renderShellTabs();
fetch('/api/status').then(r => r.json()).then(d => {
if (d.workspace) shell.cwd = d.workspace;
termWsConnect();
}).catch(() => { termWsConnect(); });
})();
// Terminal drag-resize
(function() {
const drag = document.getElementById('canvas-term-drag');
const term = document.getElementById('canvas-terminal');
if (!drag || !term) return;
let startY, startH;
drag.addEventListener('mousedown', e => {
e.preventDefault();
startY = e.clientY;
startH = term.offsetHeight;
const onMove = ev => {
const dy = startY - ev.clientY;
const next = Math.max(32, Math.min(startH + dy, term.parentElement.offsetHeight - 38));
term.style.height = next + 'px';
const shell = getActiveShell();
if (shell && shell.fitAddon) shell.fitAddon.fit();
if (canvasEditor) canvasEditor.refresh();
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
drag.style.background = '';
const shell = getActiveShell();
if (shell && shell.fitAddon) shell.fitAddon.fit();
};
drag.style.background = '#007acc';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
})();
// Stop MIDI playback when tab is closed or navigated away
window.addEventListener('beforeunload', () => { try { midiRouter.stop(); } catch {} });
window.addEventListener('pagehide', () => { try { midiRouter.stop(); } catch {} });
// Resize xterm when window resizes
window.addEventListener('resize', () => {
const shell = getActiveShell();
if (shell && shell.fitAddon) shell.fitAddon.fit();
});
function canvasInitDrop() {
const panel = document.getElementById('canvas-panel');
if (!panel) return;
panel.addEventListener('dragover', (e) => { e.preventDefault(); panel.classList.add('canvas-drag-over'); });
panel.addEventListener('dragleave', (e) => { if (!panel.contains(e.relatedTarget)) panel.classList.remove('canvas-drag-over'); });
panel.addEventListener('drop', (e) => {
e.preventDefault();
panel.classList.remove('canvas-drag-over');
Array.from(e.dataTransfer.files).forEach(canvasLoadFile);
});
}
function canvasLoadFile(file) {
const reader = new FileReader();
reader.onload = (ev) => {
const content = ev.target.result;
const id = 'ctab_' + Math.random().toString(36).slice(2);
canvasTabs.push({ id, name: file.name, content, mode: 'code', language: getCanvasLang(file.name) });
if (!canvasOpen) toggleCanvas();
canvasOpenTab(id);
addProcessEntry('info', `Canvas: opened ${file.name}`);
};
reader.readAsText(file);
}
// ---- Context Pinning ----
const MAX_PINS = 3;
let contextPinMode = false;
let pinnedMessageIndices = new Set();
let confirmedPins = []; // Array of { role, content } sent with each request
function toggleContextPinMode() {
const pop = document.getElementById('context-pin-popover');
if (!pop) return;
const isOpen = pop.classList.contains('open');
// Close other popovers
document.getElementById('quick-mode-popover')?.classList.remove('open');
if (isOpen) {
cancelContextPin();
} else {
contextPinMode = true;
pop.classList.add('open');
document.body.classList.add('context-pin-active');
// Reset selection to currently confirmed pins
pinnedMessageIndices = new Set();
for (const pin of confirmedPins) {
const idx = chatHistory.findIndex(m => m.content === pin.content && m.role === pin.role);
if (idx >= 0) pinnedMessageIndices.add(idx);
}
updatePinUI();
renderChatMessages();
}
}
function cancelContextPin() {
contextPinMode = false;
pinnedMessageIndices = new Set();
document.getElementById('context-pin-popover')?.classList.remove('open');
document.body.classList.remove('context-pin-active');
renderChatMessages();
}
function confirmContextPin() {
confirmedPins = [];
for (const idx of pinnedMessageIndices) {
const msg = chatHistory[idx];
if (msg) confirmedPins.push({ role: msg.role === 'user' ? 'user' : 'assistant', content: msg.content });
}
contextPinMode = false;
document.getElementById('context-pin-popover')?.classList.remove('open');
document.body.classList.remove('context-pin-active');
updatePinBadge();
renderChatMessages();
if (confirmedPins.length > 0) {
addProcessEntry('info', `${confirmedPins.length} message(s) pinned to context.`);
} else {
addProcessEntry('info', 'Context pins cleared.');
}
}
function togglePinMessage(index) {
if (!contextPinMode) return;
if (pinnedMessageIndices.has(index)) {
pinnedMessageIndices.delete(index);
} else if (pinnedMessageIndices.size < MAX_PINS) {
pinnedMessageIndices.add(index);
}
updatePinUI();
renderChatMessages();
}
function removePinFromList(index) {
pinnedMessageIndices.delete(index);
updatePinUI();
renderChatMessages();
}
function updatePinUI() {
const countEl = document.getElementById('context-pin-count');
const listEl = document.getElementById('context-pin-list');
if (countEl) countEl.textContent = `${pinnedMessageIndices.size}/${MAX_PINS} selected`;
if (listEl) {
if (pinnedMessageIndices.size === 0) {
listEl.innerHTML = '<div style="font-size:11px;color:var(--muted);text-align:center;padding:8px">Click messages in chat to select them</div>';
} else {
listEl.innerHTML = Array.from(pinnedMessageIndices).map(idx => {
const msg = chatHistory[idx];
if (!msg) return '';
const icon = msg.role === 'user' ? '??' : '??';
const text = msg.content.slice(0, 60) + (msg.content.length > 60 ? '...' : '');
return `<div class="pin-card">
<span>${icon}</span>
<div class="pin-card-text">${escHtml(text)}</div>
<button class="pin-card-remove" onclick="removePinFromList(${idx})">&times;</button>
</div>`;
}).join('');
}
}
}
function updatePinBadge() {
const label = document.getElementById('context-pin-label');
if (label) {
label.textContent = confirmedPins.length > 0 ? `컨텍스트 (${confirmedPins.length})` : '컨텍스트';
}
const btn = document.getElementById('context-pin-btn');
if (btn) {
btn.style.borderColor = confirmedPins.length > 0 ? '#86d4a8' : '';
btn.style.background = confirmedPins.length > 0 ? '#f0faf4' : '';
btn.style.color = confirmedPins.length > 0 ? '#0a6b3d' : '';
}
}
function getInitialTheme() {
try {
const saved = localStorage.getItem(THEME_KEY);
if (saved === 'light' || saved === 'dark') return saved;
} catch {}
return 'light';
}
function applyTheme(theme) {
const resolved = theme === 'dark' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', resolved);
const toggle = document.getElementById('theme-toggle');
if (toggle) {
toggle.setAttribute('data-theme-state', resolved);
const title = resolved === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
toggle.title = title;
toggle.setAttribute('aria-label', title);
}
try { localStorage.setItem(THEME_KEY, resolved); } catch {}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
applyTheme(current === 'dark' ? 'light' : 'dark');
}
// ---- Boot ----
document.addEventListener('click', (e) => {
const pop = document.getElementById('quick-mode-popover');
const btn = document.getElementById('quick-mode-btn');
if (!pop || !btn) return;
if (!pop.contains(e.target) && !btn.contains(e.target)) pop.classList.remove('open');
// Also close context pin popover
const pinPop = document.getElementById('context-pin-popover');
const pinBtn = document.getElementById('context-pin-btn');
if (pinPop && pinBtn && !pinPop.contains(e.target) && !pinBtn.contains(e.target)) {
// Don't close if in pin mode and clicking on messages
if (contextPinMode && e.target.closest && e.target.closest('.msg')) return;
}
});
// --- SCHEDULER MANAGEMENT ---------------------------------------------------
let schedules = [];
let editingScheduleId = null;
async function refreshSchedules() {
try {
const result = await api('/api/schedules');
if (result.success && Array.isArray(result.schedules)) {
schedules = result.schedules;
renderScheduleList();
}
} catch (err) {
console.error('Failed to load schedules:', err);
}
}
function renderScheduleList() {
const list = document.getElementById('schedule-list');
const count = document.getElementById('schedule-count');
if (!list) return;
if (schedules.length === 0) {
list.innerHTML = '<div class="empty-state" style="text-align:center;color:var(--muted);padding:40px 20px">No schedules yet. <strong>+ 새 일정</strong> to get started.</div>';
if (count) count.textContent = '0 schedules';
return;
}
if (count) count.textContent = `${schedules.length} schedule${schedules.length !== 1 ? 's' : ''}`;
list.innerHTML = schedules.map(job => {
const nextRun = job.next_run ? new Date(job.next_run).toLocaleString() : 'Never';
const lastRun = job.last_run ? new Date(job.last_run).toLocaleString() : 'Never';
const statusClass = job.status === 'active' ? 'status-active' : job.status === 'paused' ? 'status-paused' : 'status-failed';
return `
<div style="display:flex;align-items:start;justify-content:space-between;gap:12px;padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;cursor:pointer" onclick="editSchedule('${job.id}')">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<div style="font-weight:700;font-size:13px">${job.name || 'Untitled'}</div>
<span class="${statusClass}" style="font-size:10px;font-weight:700;padding:2px 8px;border-radius:6px;${job.status === 'active' ? 'background:#c8f0c4;color:#0d5c2f' : job.status === 'paused' ? 'background:#fff4d6;color:#7d5700' : 'background:#ffd6d6;color:#8b0000'}">${job.status || 'unknown'}</span>
</div>
<div style="font-size:12px;color:var(--muted);line-height:1.4;margin-bottom:6px">${job.prompt ? job.prompt.slice(0, 60) + (job.prompt.length > 60 ? '...' : '') : 'No prompt'}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:11px;color:var(--muted)">
<div><strong>Next:</strong> ${nextRun}</div>
<div><strong>Last:</strong> ${lastRun}</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px;flex-shrink:0">
<button onclick="event.stopPropagation(); runScheduleNow('${job.id}')" style="border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap" title="Run now">Run Now</button>
<button onclick="event.stopPropagation(); toggleSchedulePause('${job.id}')" style="border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap" title="Toggle pause">${job.status === 'active' ? 'Pause' : 'Resume'}</button>
<button onclick="event.stopPropagation(); deleteSchedule('${job.id}')" style="border:1px solid #ff6b6b;background:#ffe0e0;color:#8b0000;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap" title="Delete">삭제</button>
</div>
</div>
`;
}).join('');
}
function openScheduleCreateModal() {
editingScheduleId = null;
document.getElementById('schedule-modal-title').textContent = '일정 생성';
document.getElementById('schedule-save-btn').textContent = '일정 생성';
document.getElementById('schedule-name').value = '';
document.getElementById('schedule-pattern').value = '';
document.getElementById('schedule-prompt').value = '';
document.getElementById('schedule-timezone').value = 'UTC';
document.getElementById('schedule-channel').value = 'web';
document.getElementById('schedule-pattern-preview').style.display = 'none';
document.getElementById('schedule-modal').style.display = 'flex';
}
function editSchedule(jobId) {
const job = schedules.find(j => j.id === jobId);
if (!job) return;
editingScheduleId = jobId;
document.getElementById('schedule-modal-title').textContent = '일정 편집';
document.getElementById('schedule-save-btn').textContent = '변경 저장';
document.getElementById('schedule-name').value = job.name || '';
document.getElementById('schedule-pattern').value = job.cron || job.run_at || '';
document.getElementById('schedule-prompt').value = job.prompt || '';
document.getElementById('schedule-timezone').value = job.timezone || 'UTC';
document.getElementById('schedule-channel').value = job.delivery_channel || 'web';
document.getElementById('schedule-pattern-preview').style.display = 'none';
document.getElementById('schedule-modal').style.display = 'flex';
}
function closeScheduleModal() {
document.getElementById('schedule-modal').style.display = 'none';
editingScheduleId = null;
}
async function parseSchedulePattern() {
const pattern = document.getElementById('schedule-pattern').value.trim();
const timezone = document.getElementById('schedule-timezone').value;
if (!pattern) {
alert('Enter a schedule pattern (e.g., "daily at 09:00" or "0 9 * * *")');
return;
}
try {
const result = await api('/api/schedules/parse', {
method: 'POST',
body: JSON.stringify({ text: pattern, timezone })
});
const preview = document.getElementById('schedule-pattern-preview');
if (result.success) {
preview.textContent = `✓ ${result.preview || result.human_text || 'Valid pattern'}`;
preview.style.display = 'block';
preview.style.color = 'var(--muted)';
} else {
preview.textContent = `⚠ ${result.error || 'Invalid pattern'}`;
preview.style.display = 'block';
preview.style.color = '#ff6b6b';
}
} catch (err) {
document.getElementById('schedule-pattern-preview').textContent = '⚠ Parse failed';
document.getElementById('schedule-pattern-preview').style.display = 'block';
document.getElementById('schedule-pattern-preview').style.color = '#ff6b6b';
}
}
async function saveSchedule() {
const name = document.getElementById('schedule-name').value.trim();
const pattern = document.getElementById('schedule-pattern').value.trim();
const prompt = document.getElementById('schedule-prompt').value.trim();
const timezone = document.getElementById('schedule-timezone').value;
const channel = document.getElementById('schedule-channel').value;
if (!name) return alert('Schedule name is required');
if (!pattern) return alert('Schedule pattern is required');
if (!prompt) return alert('Prompt/action is required');
const method = editingScheduleId ? 'PUT' : 'POST';
const path = editingScheduleId ? `/api/schedules/${editingScheduleId}` : '/api/schedules';
try {
const result = await api(path, {
method,
body: JSON.stringify({
name,
pattern,
prompt,
timezone,
delivery_channel: channel,
confirm: true // Confirmation (as per scheduler rules)
})
});
if (result.success) {
closeScheduleModal();
await refreshSchedules();
} else {
alert('Error: ' + (result.error || 'Unknown error'));
}
} catch (err) {
alert('Failed to save schedule: ' + err.message);
}
}
async function deleteSchedule(jobId) {
if (!confirm('Delete this schedule? This action cannot be undone.')) return;
try {
const result = await api(`/api/schedules/${jobId}`, {
method: 'DELETE',
body: JSON.stringify({ confirm: true })
});
if (result.success) {
await refreshSchedules();
} else {
alert('Error: ' + (result.error || 'Failed to delete'));
}
} catch (err) {
alert('Failed to delete schedule: ' + err.message);
}
}
async function toggleSchedulePause(jobId) {
const job = schedules.find(j => j.id === jobId);
if (!job) return;
const newStatus = job.status === 'active' ? 'paused' : 'active';
try {
const result = await api(`/api/schedules/${jobId}`, {
method: 'PATCH',
body: JSON.stringify({ status: newStatus })
});
if (result.success) {
await refreshSchedules();
} else {
alert('Error: ' + (result.error || 'Failed to update'));
}
} catch (err) {
alert('Failed to toggle pause: ' + err.message);
}
}
async function runScheduleNow(jobId) {
try {
const result = await api(`/api/schedules/${jobId}/run`, {
method: 'POST',
body: JSON.stringify({})
});
if (result.success) {
await refreshSchedules();
alert('Schedule running now...');
} else {
alert('Error: ' + (result.error || 'Failed to run'));
}
} catch (err) {
alert('Failed to run schedule: ' + err.message);
}
}
loadChatSessions();
applyTheme(getInitialTheme());
setupProcessAndRightScrollTracking();
updateHeartbeatUI();
connectWS();
refreshAll();
updateQuickModeUI();
updateQueuedPromptUI();
setInterval(checkStatus, 10000);
setInterval(loadSystemStats, 3000);
setInterval(loadApprovals, 5000);
setMode('chat'); // Start in chat mode
canvasInitDrop();
// ---------------------------------------------------------------------------
// BACKGROUND TASKS KANBAN SYSTEM
// ---------------------------------------------------------------------------
let bgtTasks = []; // all task records from server
let bgtOpenTaskId = null; // currently open panel task id
let bgtHeartbeatConfig = { enabled: true, interval_minutes: 10 };
function openBgtHeartbeatModal() {
const modal = document.getElementById('bgt-heartbeat-modal');
const enabledEl = document.getElementById('bgt-hb-enabled');
const intervalEl = document.getElementById('bgt-hb-interval');
if (!modal || !enabledEl || !intervalEl) return;
enabledEl.checked = !!bgtHeartbeatConfig.enabled;
intervalEl.value = String(Math.max(1, Number(bgtHeartbeatConfig.interval_minutes) || 10));
modal.style.display = 'flex';
}
function closeBgtHeartbeatModal() {
const modal = document.getElementById('bgt-heartbeat-modal');
if (modal) modal.style.display = 'none';
}
async function saveBgtHeartbeatConfig() {
const enabledEl = document.getElementById('bgt-hb-enabled');
const intervalEl = document.getElementById('bgt-hb-interval');
if (!enabledEl || !intervalEl) return;
const enabled = !!enabledEl.checked;
const interval = Math.max(1, Math.min(1440, Number(intervalEl.value) || 10));
try {
const data = await api('/api/bg-tasks/heartbeat/config', {
method: 'PUT',
body: JSON.stringify({ enabled, interval_minutes: interval }),
});
if (data?.success && data.config) {
bgtHeartbeatConfig = data.config;
renderBgTasks();
closeBgtHeartbeatModal();
bgtToast('Heartbeat updated', enabled ? `Every ${interval} minute(s)` : 'Task heartbeat disabled');
return;
}
bgtToast('Heartbeat update failed', String(data?.error || 'Unknown error'));
} catch (err) {
bgtToast('Heartbeat update failed', String(err?.message || err));
}
}
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const modal = document.getElementById('bgt-heartbeat-modal');
if (modal && modal.style.display === 'flex') closeBgtHeartbeatModal();
});
document.addEventListener('click', (e) => {
const modal = document.getElementById('bgt-heartbeat-modal');
if (!modal || modal.style.display !== 'flex') return;
if (e.target === modal) closeBgtHeartbeatModal();
});
// --- Columns config ---------------------------------------------------------
const BGT_COLUMNS = [
{ key: 'running', label: '● In Progress', color: '#0d4faf', bg: '#eaf2ff' },
{ key: 'queued', label: '◷ Queued', color: '#7c4d00', bg: '#fff8e1' },
{ key: 'paused', label: '‖ Paused', color: '#555', bg: '#f5f5f5' },
{ key: 'stalled', label: '▲ Stalled', color: '#9c1a1a', bg: '#fff0f0' },
{ key: 'needs_assistance', label: '⚠️ Needs You', color: '#6d2d9e', bg: '#f5eeff' },
{ key: 'complete', label: '✓ Complete', color: '#1a6e35', bg: '#efffea' },
{ key: 'failed', label: '✕ Failed', color: '#9c1a1a', bg: '#fff0f0' },
];
const STATUS_ICON = {
running: '●',
queued: '◷',
paused: '‖',
stalled: '▲',
needs_assistance: '⚠️',
complete: '✓',
failed: '✕',
};
// --- Fetch & Render ----------------------------------------------------------
async function refreshBgTasks() {
try {
const data = await api('/api/bg-tasks');
if (data.success) {
bgtTasks = data.tasks || [];
bgtHeartbeatConfig = data.heartbeatConfig || bgtHeartbeatConfig;
}
} catch (err) {
console.error('[BGT] refreshBgTasks error:', err);
}
renderBgTasks();
}
function renderBgTasks() {
const board = document.getElementById('bgt-board');
if (!board) return;
// Update count badge
const countEl = document.getElementById('bgt-count');
if (countEl) {
const active = bgtTasks.filter(t => t.status !== 'complete' && t.status !== 'failed').length;
countEl.textContent = `${active} active`;
}
// Update heartbeat label
const hbLabel = document.getElementById('bgt-heartbeat-label');
if (hbLabel) {
const mins = bgtHeartbeatConfig.interval_minutes || 10;
const on = bgtHeartbeatConfig.enabled;
hbLabel.textContent = on ? `• heartbeat: ${mins}min` : '• heartbeat: off';
hbLabel.style.color = on ? 'var(--brand)' : 'var(--muted)';
}
// Only render non-empty columns
const byStatus = {};
for (const col of BGT_COLUMNS) byStatus[col.key] = [];
for (const t of bgtTasks) {
if (byStatus[t.status]) byStatus[t.status].push(t);
else if (byStatus['stalled']) byStatus['stalled'].push(t);
}
board.innerHTML = '';
for (const col of BGT_COLUMNS) {
const tasks = byStatus[col.key];
if (tasks.length === 0 && (col.key === 'complete' || col.key === 'failed' || col.key === 'stalled' || col.key === 'needs_assistance')) continue;
const colEl = document.createElement('div');
colEl.style.cssText = 'min-width:240px;max-width:280px;flex:0 0 auto;display:flex;flex-direction:column;gap:0;';
colEl.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<span style="font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.06em;color:${col.color}">${col.label}</span>
<span style="font-size:10px;background:${col.bg};color:${col.color};border-radius:999px;padding:1px 8px;font-weight:700">${tasks.length}</span>
</div>
<div class="bgt-col-cards" style="display:flex;flex-direction:column;gap:8px;min-height:60px">${tasks.map(t => bgtCardHTML(t, col)).join('')}</div>
`;
board.appendChild(colEl);
if (tasks.length === 0) {
// Empty placeholder
colEl.querySelector('.bgt-col-cards').innerHTML = `<div style="text-align:center;padding:20px 8px;color:var(--muted);font-size:11px;border:1.5px dashed var(--line);border-radius:10px">No ${col.label.split(' ').slice(1).join(' ').toLowerCase()} tasks</div>`;
}
}
// If no tasks at all
if (bgtTasks.length === 0) {
board.innerHTML = `<div style="text-align:center;padding:60px 24px;color:var(--muted);font-size:13px;flex:1">
<div style="font-size:36px;margin-bottom:12px">작업</div>
<div style="font-weight:700;margin-bottom:4px">No background tasks yet</div>
<div style="font-size:12px">Ask CherryClaw to do something in the background and it'll appear here.</div>
</div>`;
}
}
function bgtCardHTML(t, col) {
const mins = Math.round((Date.now() - (t.lastProgressAt || t.startedAt)) / 60000);
const timeAgo = mins < 1 ? 'just now' : mins < 60 ? `${mins}m ago` : `${Math.round(mins/60)}h ago`;
const stepsDone = (t.plan || []).filter(s => s.status === 'done' || s.status === 'skipped').length;
const totalSteps = (t.plan || []).length;
const pct = totalSteps > 0 ? Math.round(stepsDone / totalSteps * 100) : 0;
const currentStep = (t.plan || [])[t.currentStepIndex || 0];
const channel = t.channel === 'telegram' ? 'Telegram' : 'Web UI';
return `
<div onclick="openBgtPanel('${escHtml(t.id)}')" data-bgt-id="${escHtml(t.id)}" style="
background:var(--panel);border:1.5px solid var(--line);border-radius:12px;padding:12px 14px;
cursor:pointer;transition:border-color 0.15s,box-shadow 0.15s;
${bgtOpenTaskId === t.id ? 'border-color:var(--brand);box-shadow:0 0 0 2px rgba(90,145,255,0.15);' : ''}
" onmouseover="this.style.borderColor='var(--brand)'" onmouseout="this.style.borderColor='${bgtOpenTaskId===t.id?'var(--brand)':'var(--line)'}'">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:6px">
<span style="font-size:13px;font-weight:700;line-height:1.3;flex:1;min-width:0">${escHtml(t.title || 'Untitled Task')}</span>
<div style="display:flex;align-items:center;gap:6px;flex-shrink:0">
<button
onclick="event.stopPropagation(); bgtDeleteTask('${escHtml(t.id)}', '${escHtml(t.status)}')"
title="Remove task"
style="border:1px solid #fca5a5;background:#fff0f0;color:#9c1a1a;border-radius:7px;padding:1px 6px;font-size:11px;font-weight:700;cursor:pointer"
>✕</button>
<span style="font-size:14px;flex-shrink:0">${STATUS_ICON[t.status] || '?'}</span>
</div>
</div>
${currentStep ? `<div style="font-size:11px;color:var(--muted);margin-bottom:8px;line-height:1.4">Step ${(t.currentStepIndex||0)+1}/${totalSteps}: ${escHtml(currentStep.description.slice(0,60))}${currentStep.description.length>60?'…':''}</div>` : ''}
${totalSteps > 0 ? `
<div style="background:var(--line);border-radius:999px;height:3px;margin-bottom:8px;overflow:hidden">
<div style="background:${col.color};width:${pct}%;height:100%;border-radius:999px;transition:width 0.4s"></div>
</div>` : ''}
<div style="display:flex;align-items:center;justify-content:space-between">
<span style="font-size:10px;color:var(--muted)">${channel} ${timeAgo}</span>
${t.status === 'needs_assistance' ? `<span style="font-size:10px;background:#f5eeff;color:#6d2d9e;border-radius:999px;padding:1px 8px;font-weight:700">Needs you</span>` : ''}
${t.status === 'paused' ? `<span style="font-size:10px;background:#f5f5f5;color:#555;border-radius:999px;padding:1px 8px;font-weight:700">${t.pauseReason || 'paused'}</span>` : ''}
</div>
</div>`;
}
// --- Side Panel --------------------------------------------------------------
async function openBgtPanel(taskId) {
bgtOpenTaskId = taskId;
const panel = document.getElementById('bgt-panel');
if (panel) panel.style.display = 'flex';
// Clear chat input when switching tasks
const chatInput = document.getElementById('bgt-chat-input');
if (chatInput) { chatInput.value = ''; chatInput.disabled = false; chatInput.style.opacity = ''; }
await bgtRefreshOpenPanel();
// Highlight the card
renderBgTasks();
}
function closeBgtPanel() {
bgtOpenTaskId = null;
const panel = document.getElementById('bgt-panel');
if (panel) panel.style.display = 'none';
renderBgTasks();
}
async function bgtRefreshOpenPanel() {
if (!bgtOpenTaskId) return;
let task;
try {
const data = await api(`/api/bg-tasks/${bgtOpenTaskId}`);
if (data.success) task = data.task;
} catch {}
if (!task) { task = bgtTasks.find(t => t.id === bgtOpenTaskId); }
if (!task) return;
const titleEl = document.getElementById('bgt-panel-title');
if (titleEl) titleEl.textContent = task.title || '작업 상세';
const pauseBtn = document.getElementById('bgt-panel-pause');
if (pauseBtn) {
if (task.status === 'running') {
pauseBtn.textContent = '일시정지';
pauseBtn.style.display = '';
} else if (task.status === 'paused' || task.status === 'queued') {
pauseBtn.textContent = '재개';
pauseBtn.style.display = '';
} else {
pauseBtn.style.display = 'none';
}
}
const body = document.getElementById('bgt-panel-body');
if (!body) return;
const plan = task.plan || [];
const journal = task.journal || [];
const stepsDone = plan.filter(s => s.status === 'done' || s.status === 'skipped').length;
const lastTool = task.lastToolCall || '—';
const lastToolAt = task.lastToolCallAt ? new Date(task.lastToolCallAt).toLocaleTimeString() : '—';
const channel = task.channel === 'telegram' ? 'Telegram' : 'Web UI';
const startedAt = task.startedAt ? new Date(task.startedAt).toLocaleString() : '—';
const statusColor = { running:'#0d4faf', queued:'#7c4d00', paused:'#555', stalled:'#9c1a1a', needs_assistance:'#6d2d9e', complete:'#1a6e35', failed:'#9c1a1a' };
const sc = statusColor[task.status] || '#555';
// Build plan steps HTML
const stepsHTML = plan.map((step, i) => {
const icon = step.status === 'done' ? '✓' : step.status === 'failed' ? '✕' : step.status === 'skipped' ? '↷' : i === (task.currentStepIndex || 0) && task.status === 'running' ? '▶' : '○';
const isCurrent = i === (task.currentStepIndex || 0) && task.status !== 'complete';
return `<div style="display:flex;gap:10px;align-items:flex-start;padding:7px 0;border-bottom:1px solid var(--line);${isCurrent?'background:#f0f5ff;margin:0 -8px;padding:7px 8px;border-radius:6px;':''}"
title="${step.notes ? escHtml(step.notes) : ''}">
<span style="font-size:14px;flex-shrink:0;line-height:1.4">${icon}</span>
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:${isCurrent?'700':'500'};color:${isCurrent?'var(--text)':'var(--muted)'}">${escHtml(step.description)}</div>
${step.notes ? `<div style="font-size:11px;color:var(--muted);margin-top:2px">${escHtml(step.notes.slice(0,120))}</div>` : ''}
</div>
</div>`;
}).join('');
// Build journal HTML (last 30 entries)
const recentJournal = journal.slice(-30).reverse();
const journalHTML = recentJournal.map(entry => {
const time = new Date(entry.t).toLocaleTimeString();
const typeColor = { tool_call:'#0d4faf', tool_result:'#1a6e35', error:'#9c1a1a', plan_mutation:'#6d2d9e', status_push:'#555', pause:'#7c4d00', resume:'#7c4d00', advisor_decision:'#555', heartbeat:'var(--brand)' };
const tc = typeColor[entry.type] || '#888';
return `<div style="display:flex;gap:8px;padding:5px 0;border-bottom:1px solid var(--line);font-size:11px">
<span style="color:var(--muted);flex-shrink:0;min-width:52px">${time}</span>
<span style="color:${tc};flex-shrink:0;font-weight:700;min-width:60px">${entry.type}</span>
<span style="color:var(--text);flex:1;min-width:0;word-break:break-word">${escHtml(entry.content)}</span>
</div>`;
}).join('');
// Build assistance block outside template literal to avoid backtick nesting issues
let assistanceHTML = '';
const taskStatusNorm = String(task.status || '').trim().toLowerCase();
console.log('[BGT panel] task.status =', JSON.stringify(task.status), 'norm =', taskStatusNorm);
if (taskStatusNorm === 'needs_assistance' || taskStatusNorm === 'paused' || taskStatusNorm === 'stalled' || taskStatusNorm === 'failed') {
const lastPause = [...journal].reverse().find(e => e.type === 'pause' || e.type === 'error');
const lastStatusPush = [...journal].reverse().find(e => e.type === 'status_push' && e.content);
const pauseMsg = escHtml((lastPause?.content || lastStatusPush?.content || 'Task paused and waiting for input.').replace(/^Task paused for assistance:\s*/i, ''));
const pauseDetail = lastPause?.detail ? escHtml(lastPause.detail.slice(0, 300)) : '';
const tid = escHtml(task.id);
assistanceHTML = '<div style="background:#f5eeff;border:1px solid #cba8f5;border-radius:10px;padding:12px 14px;font-size:12px;line-height:1.6;color:#6d2d9e">'
+ '<div style="font-weight:800;margin-bottom:6px">⚠️ Task needs your input</div>'
+ '<div style="color:#3d1a6e;margin-bottom:' + (pauseDetail ? '6px' : '0') + '">' + pauseMsg + '</div>'
+ (pauseDetail ? '<div style="font-size:11px;color:#6d2d9e;opacity:0.8;margin-bottom:4px">' + pauseDetail + '</div>' : '')
+ '</div>'
+ '<div style="border:1px solid #cba8f5;border-radius:10px;overflow:hidden;background:#faf5ff;margin-top:8px">'
+ '<div style="padding:8px 12px;font-size:11px;font-weight:700;color:#6d2d9e;border-bottom:1px solid #e9d8ff">💬 Reply to agent</div>'
+ '<div style="display:flex;gap:8px;padding:8px">'
+ '<textarea id="task-reply-input" rows="2" placeholder="Type your reply — the agent will see this and continue..." style="flex:1;resize:none;border:1px solid #cba8f5;border-radius:8px;padding:8px;font-size:12px;font-family:inherit;background:#fff;color:var(--text)"></textarea>'
+ '<button id="task-reply-send" data-taskid="' + tid + '" style="background:#7c3aed;color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;align-self:flex-end">전송</button>'
+ '</div></div>';
}
body.innerHTML = `
<!-- Status row -->
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<span style="font-size:11px;background:var(--line);border-radius:999px;padding:2px 10px;font-weight:700;color:${sc}">${STATUS_ICON[task.status]} ${task.status.replace(/_/g,' ')}</span>
<span style="font-size:11px;color:var(--muted)">${channel}</span>
<span style="font-size:11px;color:var(--muted)">Started ${startedAt}</span>
</div>
<!-- Summary if complete -->
${task.finalSummary ? `<div style="background:#efffea;border:1px solid #b2dfb2;border-radius:10px;padding:12px 14px;font-size:12px;line-height:1.6;color:#1a6e35">
<div style="font-weight:800;margin-bottom:4px">✓ Summary</div>
${escHtml(task.finalSummary)}
</div>` : ''}
<!-- Needs assistance -->
${assistanceHTML}
<!-- Stats row -->
<div style="display:flex;gap:12px;flex-wrap:wrap">
<div style="flex:1;min-width:100px;background:var(--panel-2);border-radius:10px;padding:10px 12px">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;font-weight:700;margin-bottom:2px">Progress</div>
<div style="font-size:18px;font-weight:800">${stepsDone}<span style="font-size:12px;color:var(--muted)">/${plan.length}</span></div>
<div style="font-size:10px;color:var(--muted)">steps done</div>
</div>
<div style="flex:1;min-width:100px;background:var(--panel-2);border-radius:10px;padding:10px 12px">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;font-weight:700;margin-bottom:2px">Last Tool</div>
<div style="font-size:12px;font-weight:700;word-break:break-all">${escHtml(lastTool.slice(0,30))}</div>
<div style="font-size:10px;color:var(--muted)">${lastToolAt}</div>
</div>
</div>
<!-- Plan steps -->
${plan.length > 0 ? `
<div>
<div style="font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);margin-bottom:8px">Execution Plan</div>
<div style="border:1px solid var(--line);border-radius:10px;padding:6px 8px">${stepsHTML}</div>
</div>` : ''}
<!-- Process journal -->
<div>
<div style="font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);margin-bottom:6px">Process Log <span style="font-weight:400;opacity:0.6">(latest first)</span></div>
<div style="font-family:monospace;font-size:11px;border:1px solid var(--line);border-radius:10px;padding:6px 8px;max-height:300px;overflow-y:auto">
${journalHTML || '<div style="color:var(--muted);padding:8px">No entries yet.</div>'}
</div>
</div>
<!-- Delete button -->
${(task.status === 'complete' || task.status === 'failed') ? `
<button onclick="bgtDeleteTask('${escHtml(task.id)}')" style="border:1px solid #fca5a5;background:#fff0f0;color:#9c1a1a;border-radius:8px;padding:6px 14px;font-size:12px;font-weight:700;cursor:pointer;align-self:flex-start">Remove</button>` : ''}
`;
// Wire up the reply send button via JS (avoids inline onclick escaping issues)
const sendBtn = document.getElementById('task-reply-send');
if (sendBtn) {
sendBtn.addEventListener('click', () => bgtSendReply(sendBtn.dataset.taskid));
}
// Also allow Enter (without Shift) in the textarea to send
const replyInput = document.getElementById('task-reply-input');
if (replyInput) {
replyInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
bgtSendReply(sendBtn ? sendBtn.dataset.taskid : bgtOpenTaskId);
}
});
}
}
// --- Actions -----------------------------------------------------------------
async function bgtPauseResume() {
if (!bgtOpenTaskId) return;
const task = bgtTasks.find(t => t.id === bgtOpenTaskId);
if (!task) return;
if (task.status === 'running') {
// Pause
try { await api(`/api/bg-tasks/${bgtOpenTaskId}/pause`, { method: 'POST' }); } catch {}
} else if (task.status === 'paused' || task.status === 'queued') {
// Resume
try { await api(`/api/bg-tasks/${bgtOpenTaskId}/resume`, { method: 'POST' }); } catch {}
}
await refreshBgTasks();
await bgtRefreshOpenPanel();
}
async function bgtSendReply(taskId) {
const input = document.getElementById('task-reply-input');
if (!input) return;
const message = input.value.trim();
if (!message) return;
input.value = '';
input.disabled = true;
try {
const result = await api(`/api/bg-tasks/${taskId}/message`, { method: 'POST', body: JSON.stringify({ message }) });
if (result.success) {
// Refresh the panel after a short delay to show the journal update
setTimeout(() => bgtRefreshOpenPanel(), 800);
} else {
alert('Failed to send: ' + (result.error || 'unknown error'));
}
} catch (e) {
alert('Error sending message: ' + e.message);
} finally {
input.disabled = false;
input.focus();
}
}
async function bgtChatSend() {
const input = document.getElementById('bgt-chat-input');
if (!input || !bgtOpenTaskId) return;
const message = input.value.trim();
if (!message) return;
input.value = '';
input.disabled = true;
input.style.opacity = '0.5';
try {
const result = await api(`/api/bg-tasks/${bgtOpenTaskId}/message`, { method: 'POST', body: JSON.stringify({ message }) });
if (result.success) {
// Show optimistic sent indicator in journal area
const body = document.getElementById('bgt-panel-body');
if (body) {
const sentDiv = document.createElement('div');
sentDiv.style.cssText = 'background:var(--brand);color:#fff;border-radius:8px;padding:6px 10px;font-size:12px;align-self:flex-end;max-width:80%;word-break:break-word;opacity:0.85';
sentDiv.textContent = '\u2192 ' + message;
body.appendChild(sentDiv);
body.scrollTop = body.scrollHeight;
}
// Refresh panel after short delay to show journal update
setTimeout(() => bgtRefreshOpenPanel(), 1000);
} else {
alert('Failed to send: ' + (result.error || 'unknown error'));
}
} catch (e) {
alert('Error: ' + e.message);
} finally {
input.disabled = false;
input.style.opacity = '';
input.focus();
}
}
async function bgtDeleteTask(taskId, status) {
const isActive = status && status !== 'complete' && status !== 'failed';
const prompt = isActive
? 'This task is still active. Remove it from the board anyway?'
: 'Remove this task from the board?';
if (!confirm(prompt)) return;
try { await api(`/api/bg-tasks/${taskId}`, { method: 'DELETE' }); } catch {}
if (bgtOpenTaskId === taskId) closeBgtPanel();
await refreshBgTasks();
}
// --- Toast notification -------------------------------------------------------
function bgtToast(title, body) {
const toast = document.createElement('div');
toast.style.cssText = `position:fixed;bottom:24px;right:24px;z-index:9999;background:var(--panel);border:1.5px solid var(--line);border-radius:12px;padding:12px 16px;min-width:240px;max-width:360px;box-shadow:0 4px 24px rgba(0,0,0,0.12);font-family:var(--font)`;
toast.innerHTML = `<div style="font-size:13px;font-weight:800;margin-bottom:4px">${escHtml(title)}</div>${body ? `<div style="font-size:12px;color:var(--muted);line-height:1.4">${escHtml(body)}</div>` : ''}`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 6000);
}
// --- Integrations Tab ---------------------------------------------------------
let _integTab = 'webhooks';
function setIntegTab(tab) {
_integTab = tab;
['webhooks', 'mcp'].forEach(t => {
const btn = document.getElementById('itab-' + t);
const panel = document.getElementById('itab-panel-' + t);
if (btn) {
btn.style.background = t === tab ? '#eaf2ff' : '#fff';
btn.style.color = t === tab ? '#0d4faf' : 'var(--muted)';
btn.style.borderColor = t === tab ? '#bdd3f6' : 'var(--line)';
}
if (panel) panel.style.display = t === tab ? 'block' : 'none';
});
}
async function loadIntegrationsTab() {
setIntegTab(_integTab);
await loadWebhookSettings();
await loadMCPServers();
}
// --- Webhooks -----------------------------------------------------------------
async function loadWebhookSettings() {
try {
const data = await api('/api/settings/hooks');
const h = data.hooks || {};
const cb = document.getElementById('wh-enabled');
const inp = document.getElementById('wh-token');
const pathInp = document.getElementById('wh-path');
if (cb) cb.checked = h.enabled === true;
if (inp) inp.value = h.tokenSet ? '••••••••' : '';
if (pathInp) pathInp.value = h.path || '/hooks';
updateWebhookStatus(h.enabled, h.tokenSet);
updateWebhookUrlDisplay(h.enabled, h.tokenSet, h.path || '/hooks');
} catch(e) {
console.warn('loadWebhookSettings:', e);
}
}
function updateWebhookStatus(enabled, tokenSet) {
const dot = document.getElementById('wh-status-dot');
const txt = document.getElementById('wh-status-text');
if (!dot || !txt) return;
if (enabled && tokenSet) {
dot.style.background = '#22c55e';
txt.textContent = '활성 — 엔드포인트 실행 중';
txt.style.color = '#166534';
} else if (!enabled) {
dot.style.background = '#ccc';
txt.textContent = '비활성';
txt.style.color = 'var(--muted)';
} else {
dot.style.background = '#f59e0b';
txt.textContent = '활성화됨 — 토큰 미설정';
txt.style.color = '#92400e';
}
}
function updateWebhookUrlDisplay(enabled, tokenSet, hookPath) {
const el = document.getElementById('wh-url-display');
if (!el) return;
if (enabled && tokenSet) {
const origin = window.location.origin || 'http://localhost:18789';
el.style.display = 'block';
el.textContent = origin + hookPath + '/agent';
} else {
el.style.display = 'none';
}
}
function generateWebhookToken() {
const arr = new Uint8Array(24);
crypto.getRandomValues(arr);
const token = Array.from(arr).map(b => b.toString(16).padStart(2,'0')).join('');
const inp = document.getElementById('wh-token');
if (inp) { inp.value = token; inp.type = 'text'; setTimeout(() => { inp.type = 'password'; }, 4000); }
}
async function saveWebhookSettings() {
const enabled = document.getElementById('wh-enabled')?.checked ?? false;
const rawToken = document.getElementById('wh-token')?.value?.trim() || '';
const hookPath = document.getElementById('wh-path')?.value?.trim() || '/hooks';
try {
const r = await api('/api/settings/hooks', {
method: 'POST',
body: JSON.stringify({ enabled, token: rawToken, path: hookPath }),
});
if (r.success) {
await loadWebhookSettings();
showIntegMsg('? Webhook settings saved — restart gateway to apply', '#166534', '#f0fdf4');
} else {
showIntegMsg('Error: ' + (r.error || 'Unknown error'), '#991b1b', '#fef2f2');
}
} catch(e) {
showIntegMsg('Save failed: ' + e.message, '#991b1b', '#fef2f2');
}
}
async function testWebhookEndpoint() {
try {
const r = await api('/api/settings/hooks/test', { method: 'POST' });
if (r.success) {
showIntegMsg('? ' + (r.message || 'Endpoint is active'), '#166534', '#f0fdf4');
} else {
showIntegMsg('? ' + (r.error || 'Test failed'), '#991b1b', '#fef2f2');
}
} catch(e) {
showIntegMsg('Test failed: ' + e.message, '#991b1b', '#fef2f2');
}
}
function copyWebhookCurl() {
const origin = window.location.origin || 'http://localhost:18789';
const hookPath = document.getElementById('wh-path')?.value?.trim() || '/hooks';
const curl = `curl -X POST ${origin}${hookPath}/agent \\
-H "x-cherryclaw-token: YOUR_TOKEN" \\
-H "Content-Type: application/json" \\
-d '{"message": "Hello from webhook", "name": "MyApp", "deliver": true}'`;
navigator.clipboard?.writeText(curl).then(() => {
showIntegMsg('cURL example copied to clipboard', '#1e40af', '#eff6ff');
}).catch(() => {
showIntegMsg('Could not copy — check browser permissions', '#92400e', '#fffbeb');
});
}
// --- MCP ---------------------------------------------------------------------
let _mcpEditingId = null;
const MCP_PRESETS = {
filesystem: { name: 'Filesystem', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-filesystem\nC:\\Users', env: '', description: 'Read/write local files' },
github: { name: 'GitHub', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-github', env: 'GITHUB_PERSONAL_ACCESS_TOKEN=', description: 'Repos, PRs, issues' },
postgres: { name: 'PostgreSQL', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-postgres\npostgresql://localhost/mydb', env: '', description: 'Query PostgreSQL' },
sqlite: { name: 'SQLite', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-sqlite\n--db-path\nC:\\path\\to\\db.sqlite', env: '', description: 'Local database' },
brave: { name: 'Brave Search', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-brave-search', env: 'BRAVE_API_KEY=', description: 'Web search' },
memory: { name: 'Memory', transport: 'stdio', command: 'npx', args: '-y\n@modelcontextprotocol/server-memory', env: '', description: 'Persistent knowledge store' },
};
async function loadMCPServers() {
const el = document.getElementById('mcp-server-list');
if (!el) return;
try {
const data = await api('/api/mcp/servers');
const servers = data.servers || [];
if (servers.length === 0) {
el.innerHTML = '<div style="color:var(--muted);font-style:italic;padding:8px 0">No MCP servers configured yet.<br>Add one above or click a preset on the right.</div>';
return;
}
el.innerHTML = servers.map(s => {
const statusColor = s.status === 'connected' ? '#22c55e' : s.status === 'error' ? '#ef4444' : s.status === 'connecting' ? '#f59e0b' : '#9ca3af';
const statusLabel = s.status === 'connected' ? `Connected · ${s.toolCount} tool${s.toolCount !== 1 ? 's' : ''}` : s.status === 'error' ? 'Error' : s.status === 'connecting' ? 'Connecting…' : 'Disconnected';
const toolTip = s.toolNames?.length ? s.toolNames.join(', ') : '';
return `<div style="display:flex;align-items:flex-start;gap:8px;padding:8px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;background:${s.enabled ? '#fff' : '#fafafa'}">
<div style="padding-top:2px">
<div style="width:8px;height:8px;border-radius:50%;background:${statusColor};flex-shrink:0"></div>
</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:12px;color:var(--text)">${escHtml(s.name)} <span style="font-weight:400;color:var(--muted);font-size:11px">(${escHtml(s.id)})</span></div>
<div style="font-size:11px;color:${statusColor};margin-top:1px" title="${escHtml(toolTip)}">${statusLabel}${s.error ? ` — ${escHtml(s.error.slice(0,60))}` : ''}</div>
${toolTip ? `<div style="font-size:10px;color:var(--muted);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escHtml(toolTip)}">${escHtml(toolTip.slice(0,80))}${toolTip.length>80?'…':''}</div>` : ''}
</div>
<div style="display:flex;gap:4px;flex-shrink:0">
${s.status === 'connected'
? `<button onclick="disconnectMCPServer('${s.id}')" style="padding:3px 8px;border:1px solid var(--line);border-radius:5px;background:#fff;font-size:10px;cursor:pointer">Disconnect</button>`
: `<button onclick="connectMCPServer('${s.id}')" style="padding:3px 8px;border:1px solid var(--brand);border-radius:5px;background:#fff;color:var(--brand);font-size:10px;font-weight:600;cursor:pointer">Connect</button>`
}
<button onclick="editMCPServer('${s.id}')" style="padding:3px 8px;border:1px solid var(--line);border-radius:5px;background:#fff;font-size:10px;cursor:pointer">Edit</button>
<button onclick="deleteMCPServer('${s.id}')" style="padding:3px 8px;border:1px solid #fecaca;border-radius:5px;background:#fff;color:#ef4444;font-size:10px;cursor:pointer">?</button>
</div>
</div>`;
}).join('');
} catch(e) {
el.innerHTML = `<div style="color:#ef4444">Failed to load: ${escHtml(e.message)}</div>`;
}
}
function showMCPAddForm(editData) {
_mcpEditingId = editData?.id || null;
document.getElementById('mcp-add-form').style.display = 'block';
document.getElementById('mcp-form-title').textContent = _mcpEditingId ? 'MCP 서버 편집' : 'MCP 서버 추가';
document.getElementById('mcp-form-msg').textContent = '';
if (editData) {
const v = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; };
v('mcp-f-id', editData.id); v('mcp-f-name', editData.name);
document.getElementById('mcp-f-transport').value = editData.transport || 'stdio';
onMCPTransportChange();
v('mcp-f-command', editData.command || '');
v('mcp-f-args', (editData.args || []).join('\n'));
v('mcp-f-env', Object.entries(editData.env || {}).map(([k,v]) => `${k}=${v}`).join('\n'));
v('mcp-f-url', editData.url || '');
v('mcp-f-headers', Object.entries(editData.headers || {}).map(([k,v]) => `${k}: ${v}`).join('\n'));
const cb = document.getElementById('mcp-f-enabled');
if (cb) cb.checked = editData.enabled !== false;
} else {
['mcp-f-id','mcp-f-name','mcp-f-command','mcp-f-args','mcp-f-env','mcp-f-url','mcp-f-headers'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
document.getElementById('mcp-f-transport').value = 'stdio';
onMCPTransportChange();
const cb = document.getElementById('mcp-f-enabled'); if (cb) cb.checked = true;
}
document.getElementById('mcp-f-id').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function hideMCPAddForm() {
document.getElementById('mcp-add-form').style.display = 'none';
_mcpEditingId = null;
}
function onMCPTransportChange() {
const t = document.getElementById('mcp-f-transport')?.value;
document.getElementById('mcp-stdio-fields').style.display = t === 'stdio' ? 'block' : 'none';
document.getElementById('mcp-sse-fields').style.display = t === 'sse' ? 'block' : 'none';
}
function prefillMCPServer(presetKey) {
const p = MCP_PRESETS[presetKey];
if (!p) return;
showMCPAddForm();
const v = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; };
v('mcp-f-id', presetKey);
v('mcp-f-name', p.name);
document.getElementById('mcp-f-transport').value = p.transport;
onMCPTransportChange();
v('mcp-f-command', p.command || '');
v('mcp-f-args', p.args || '');
v('mcp-f-env', p.env || '');
v('mcp-f-url', p.url || '');
setIntegTab('mcp');
}
async function saveMCPServer() {
const id = document.getElementById('mcp-f-id')?.value?.trim();
const name = document.getElementById('mcp-f-name')?.value?.trim();
const transport = document.getElementById('mcp-f-transport')?.value || 'stdio';
const enabled = document.getElementById('mcp-f-enabled')?.checked ?? true;
const msgEl = document.getElementById('mcp-form-msg');
if (!id || !name) { if (msgEl) msgEl.textContent = 'ID와 이름은 필수입니다.'; return; }
const cfg = { id, name, transport, enabled };
if (transport === 'stdio') {
cfg.command = document.getElementById('mcp-f-command')?.value?.trim() || '';
const argsRaw = document.getElementById('mcp-f-args')?.value || '';
cfg.args = argsRaw.split('\n').map(s => s.trim()).filter(Boolean);
const envRaw = document.getElementById('mcp-f-env')?.value || '';
cfg.env = {};
envRaw.split('\n').map(l => l.trim()).filter(Boolean).forEach(line => {
const eq = line.indexOf('='); if (eq > 0) cfg.env[line.slice(0,eq).trim()] = line.slice(eq+1);
});
} else {
cfg.url = document.getElementById('mcp-f-url')?.value?.trim() || '';
const headersRaw = document.getElementById('mcp-f-headers')?.value || '';
cfg.headers = {};
headersRaw.split('\n').map(l => l.trim()).filter(Boolean).forEach(line => {
const colon = line.indexOf(':'); if (colon > 0) cfg.headers[line.slice(0,colon).trim()] = line.slice(colon+1).trim();
});
}
try {
const r = await api('/api/mcp/servers', { method: 'POST', body: JSON.stringify(cfg) });
if (r.success) {
if (msgEl) msgEl.textContent = '? Saved';
setTimeout(() => hideMCPAddForm(), 600);
await loadMCPServers();
// Auto-connect if enabled
if (enabled) connectMCPServer(id);
} else {
if (msgEl) msgEl.textContent = '오류: ' + (r.error || '알 수 없음');
}
} catch(e) {
if (msgEl) msgEl.textContent = '저장 실패: ' + e.message;
}
}
async function editMCPServer(id) {
try {
const data = await api('/api/mcp/servers');
const server = (data.servers || []).find(s => s.id === id);
if (server) showMCPAddForm(server);
} catch(e) { console.warn('editMCPServer:', e); }
}
async function deleteMCPServer(id) {
if (!confirm(`Delete MCP server "${id}"?`)) return;
try {
await api(`/api/mcp/servers/${id}`, { method: 'DELETE' });
await loadMCPServers();
} catch(e) { showIntegMsg('Delete failed: ' + e.message, '#991b1b', '#fef2f2'); }
}
async function connectMCPServer(id) {
showIntegMsg('Connecting to ' + id + '…', '#1e40af', '#eff6ff');
try {
const r = await api(`/api/mcp/servers/${id}/connect`, { method: 'POST' });
if (r.success) {
showIntegMsg(`? Connected — ${(r.tools||[]).length} tool(s) available`, '#166534', '#f0fdf4');
} else {
showIntegMsg('? Connection failed: ' + (r.error || 'Unknown'), '#991b1b', '#fef2f2');
}
await loadMCPServers();
} catch(e) {
showIntegMsg('Connect error: ' + e.message, '#991b1b', '#fef2f2');
await loadMCPServers();
}
}
async function disconnectMCPServer(id) {
try {
await api(`/api/mcp/servers/${id}/disconnect`, { method: 'POST' });
await loadMCPServers();
showIntegMsg('Disconnected from ' + id, '#475569', '#f8fafc');
} catch(e) { showIntegMsg('Disconnect error: ' + e.message, '#991b1b', '#fef2f2'); }
}
// --- Shared helper ------------------------------------------------------------
function showIntegMsg(msg, color, bg) {
let el = document.getElementById('integ-toast');
if (!el) {
el = document.createElement('div');
el.id = 'integ-toast';
el.style.cssText = 'position:fixed;bottom:80px;right:24px;z-index:9999;padding:10px 16px;border-radius:10px;font-size:12px;font-weight:600;max-width:320px;box-shadow:0 4px 16px rgba(0,0,0,0.12);transition:opacity .3s';
document.body.appendChild(el);
}
el.style.background = bg || '#f0fdf4';
el.style.color = color || '#166534';
el.style.border = `1px solid ${color || '#166534'}33`;
el.style.opacity = '1';
el.textContent = msg;
clearTimeout(el._timeout);
el._timeout = setTimeout(() => { el.style.opacity = '0'; }, 3500);
}
// ───────────────────────────────────────────────────────────────────────────
// ERROR RESPONSE SYSTEM — Full JavaScript Implementation
// Handles: show/hide panel, option selection, input rendering,
// credential entry, form submission, WS event binding
// ───────────────────────────────────────────────────────────────────────────
// State
let _errTaskId = null;
let _errTemplate = null;
let _errSelectedAction = null;
let _errCategory = null;
let _errBackdrop = null;
function _getOrCreateErrorBackdrop() {
if (_errBackdrop) return _errBackdrop;
_errBackdrop = document.createElement('div');
_errBackdrop.id = 'error-response-backdrop';
_errBackdrop.style.cssText = 'position:fixed;inset:0;background:rgba(10,20,40,0.45);z-index:9997;display:none';
_errBackdrop.addEventListener('click', closeErrorResponse);
document.body.appendChild(_errBackdrop);
return _errBackdrop;
}
function showErrorResponsePanel(taskId, category, errorMessage, errorDetail, template) {
_errTaskId = taskId;
_errTemplate = template;
_errSelectedAction = null;
_errCategory = category;
// Title & description
const titleEl = document.getElementById('error-response-title');
const descEl = document.getElementById('error-response-desc');
if (titleEl) titleEl.textContent = template?.title || ('\u26a0\ufe0f ' + (category || 'Error').toUpperCase());
if (descEl) descEl.textContent = template?.description || errorMessage || 'The task needs your help to continue.';
// Render options
const optionsEl = document.getElementById('error-response-options');
if (optionsEl) {
optionsEl.innerHTML = '';
const options = template?.options || [
{ id: 'retry_now', label: '\u27f3 Retry Now', icon: '\u27f3' },
{ id: 'skip', label: '\u229a Skip Step', icon: '\u229a' },
{ id: 'cancel', label: '\u2715 Cancel Task', icon: '\u2715', danger: true },
];
options.forEach(opt => {
const btn = document.createElement('button');
btn.className = 'error-option-btn' + (opt.danger ? ' danger' : '');
btn.dataset.actionId = opt.id;
btn.innerHTML = `<strong>${escHtml(opt.icon || '')} ${escHtml(opt.label)}</strong>${opt.description ? `<br><span style="font-weight:400;opacity:0.75">${escHtml(opt.description)}</span>` : ''}`;
btn.addEventListener('click', () => selectErrorOption(opt));
optionsEl.appendChild(btn);
});
}
// Clear inputs
const inputsEl = document.getElementById('error-response-inputs');
if (inputsEl) {
inputsEl.innerHTML = '';
inputsEl.classList.remove('visible');
}
// Reset submit button
const submitBtn = document.getElementById('error-response-submit-btn');
if (submitBtn) submitBtn.disabled = true;
// Show backdrop + panel
const backdrop = _getOrCreateErrorBackdrop();
backdrop.style.display = 'block';
const panel = document.getElementById('error-response-panel');
if (panel) panel.classList.add('visible');
// Notification toast
bgtToast('\u26a0\ufe0f Task needs help', (template?.title || category || 'Error') + ' — see panel');
}
function selectErrorOption(opt) {
_errSelectedAction = opt.id;
// Highlight selected
document.querySelectorAll('.error-option-btn').forEach(b => b.classList.remove('selected'));
const btn = document.querySelector(`.error-option-btn[data-action-id="${opt.id}"]`);
if (btn) btn.classList.add('selected');
// Show input fields if this option requires them
const inputsEl = document.getElementById('error-response-inputs');
if (inputsEl) {
inputsEl.innerHTML = '';
inputsEl.classList.remove('visible');
const triggerInputs = opt.triggerInputs || [];
const allFields = _errTemplate?.requiredInputs || [];
const fieldsToShow = triggerInputs.length > 0
? allFields.filter(f => triggerInputs.includes(f.id))
: [];
if (fieldsToShow.length > 0) {
fieldsToShow.forEach(field => {
const wrap = document.createElement('div');
wrap.className = 'input-field';
const label = document.createElement('label');
label.textContent = field.label;
label.setAttribute('for', 'err-input-' + field.id);
const input = document.createElement('input');
input.id = 'err-input-' + field.id;
input.type = field.type || 'text';
input.placeholder = field.placeholder || '';
input.dataset.fieldId = field.id;
if (field.validation === 'digits_only') {
input.inputMode = 'numeric';
input.pattern = '[0-9]*';
input.maxLength = 8;
}
wrap.appendChild(label);
wrap.appendChild(input);
inputsEl.appendChild(wrap);
});
inputsEl.classList.add('visible');
// Focus first input
const firstInput = inputsEl.querySelector('input');
if (firstInput) setTimeout(() => firstInput.focus(), 80);
}
}
// Enable submit unless it's cancel (which submits immediately)
const submitBtn = document.getElementById('error-response-submit-btn');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = opt.danger ? '작업 취소' : '계속';
}
}
function closeErrorResponse() {
_errTaskId = null;
_errTemplate = null;
_errSelectedAction = null;
_errCategory = null;
const backdrop = document.getElementById('error-response-backdrop');
if (backdrop) backdrop.style.display = 'none';
const panel = document.getElementById('error-response-panel');
if (panel) panel.classList.remove('visible');
}
async function submitErrorResponse() {
if (!_errTaskId || !_errSelectedAction) return;
const action = _errSelectedAction;
const category = _errCategory;
// Collect input values
const inputs = {};
document.querySelectorAll('#error-response-inputs input[data-field-id]').forEach(el => {
if (el.value.trim()) inputs[el.dataset.fieldId] = el.value.trim();
});
// Validate required fields for credential action
if (action === 'credentials') {
if (!inputs.email) {
const emailEl = document.getElementById('err-input-email');
if (emailEl) { emailEl.style.borderColor = 'var(--err)'; emailEl.focus(); }
return;
}
if (!inputs.password) {
const pwEl = document.getElementById('err-input-password');
if (pwEl) { pwEl.style.borderColor = 'var(--err)'; pwEl.focus(); }
return;
}
}
if (action === 'verification_code') {
if (!inputs.code) {
const codeEl = document.getElementById('err-input-code');
if (codeEl) { codeEl.style.borderColor = 'var(--err)'; codeEl.focus(); }
return;
}
}
// Disable submit to prevent double-send
const submitBtn = document.getElementById('error-response-submit-btn');
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '전송 중…'; }
try {
const resp = await fetch(`/api/bg-tasks/${_errTaskId}/error-response`, {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ action, category, inputs }),
});
const data = await resp.json();
if (data.success) {
closeErrorResponse();
if (data.resumed) {
bgtToast('\u2705 Task resuming', 'Agent is continuing with your response');
// Refresh the kanban board to show updated status
if (typeof refreshBgTasks === 'function') setTimeout(refreshBgTasks, 800);
} else {
bgtToast('Task cancelled', 'Task has been stopped');
if (typeof refreshBgTasks === 'function') setTimeout(refreshBgTasks, 400);
}
} else {
bgtToast('\u274c Error', data.error || 'Failed to submit response');
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '계속'; }
}
} catch (err) {
console.error('[ErrorResponse] Submit failed:', err);
bgtToast('\u274c Network error', 'Could not submit response');
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '계속'; }
}
}
// Hook into the existing WebSocket message handler
// We extend the onmessage handler after the page loads
(function patchWebSocketForErrorResponse() {
function applyPatch() {
// Find the ws variable — it’s declared in a closure, so we intercept via MutationObserver
// Alternative: override addEventListener on WebSocket prototype to intercept messages
const _origOnMessage = WebSocket.prototype.__defineGetter__
? null
: null; // not needed — use event listener approach below
// We hook into the document-level custom event that server-v2’s WS handler
// already dispatches. If that’s not available, we’ll patch the WS directly.
document.addEventListener('ws:message', handleErrorResponseWsEvent);
// Also directly patch any open WebSocket connections by overriding WebSocket prototype
const _OrigWS = window.WebSocket;
window.WebSocket = function(...args) {
const ws = new _OrigWS(...args);
const _origAddEventListener = ws.addEventListener.bind(ws);
ws.addEventListener = function(type, listener, ...rest) {
if (type === 'message') {
const wrappedListener = function(e) {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'task_error_requires_response') {
handleErrorResponseWsEvent({ detail: msg });
}
} catch {}
return listener.call(this, e);
};
return _origAddEventListener('message', wrappedListener, ...rest);
}
return _origAddEventListener(type, listener, ...rest);
};
// Also patch onmessage setter
let _onmsgHandler = null;
Object.defineProperty(ws, 'onmessage', {
get: () => _onmsgHandler,
set: (fn) => {
_onmsgHandler = fn;
_origAddEventListener('message', (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'task_error_requires_response') {
handleErrorResponseWsEvent({ detail: msg });
}
} catch {}
if (_onmsgHandler) _onmsgHandler.call(ws, e);
});
},
configurable: true,
});
return ws;
};
// Copy static properties
Object.assign(window.WebSocket, _OrigWS);
window.WebSocket.prototype = _OrigWS.prototype;
window.WebSocket.CONNECTING = _OrigWS.CONNECTING;
window.WebSocket.OPEN = _OrigWS.OPEN;
window.WebSocket.CLOSING = _OrigWS.CLOSING;
window.WebSocket.CLOSED = _OrigWS.CLOSED;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyPatch);
} else {
applyPatch();
}
})();
// ── Mobile tab navigation ───────────────────────────────────────────────────
function mobTab(tab) {
const body = document.body;
if (tab === 'menu') {
const isOpen = body.classList.contains('mob-menu');
if (isOpen) {
mobCloseMenu();
} else {
body.classList.add('mob-menu');
}
// Keep chat/ctx tab highlight unchanged; toggle menu highlight
document.getElementById('mob-tab-menu').classList.toggle('active', !isOpen);
return;
}
mobCloseMenu();
['chat', 'ctx', 'menu'].forEach(t =>
document.getElementById('mob-tab-' + t)?.classList.toggle('active', t === tab)
);
if (tab === 'ctx') {
body.classList.add('mob-ctx');
} else {
body.classList.remove('mob-ctx');
}
}
function mobCloseMenu() {
document.body.classList.remove('mob-menu');
document.getElementById('mob-backdrop').style.display = 'none';
document.getElementById('mob-tab-menu')?.classList.remove('active');
}
// ── Right-panel collapse ────────────────────────────────────────────────────
const RIGHT_COLLAPSE_KEY = 'rightPanelCollapsed';
function applyRightPanelCollapsed(collapsed) {
document.body.classList.toggle('right-collapsed', !!collapsed);
// Only override on desktop — narrow viewports use the mobile single-column layout.
const isDesktop = window.matchMedia('(min-width: 980px)').matches;
if (!isDesktop) { document.body.style.gridTemplateColumns = ''; return; }
if (collapsed) {
document.body.style.gridTemplateColumns = '280px 1fr 36px';
} else {
const saved = parseInt(localStorage.getItem('rightPanelWidth'), 10);
const max = Math.floor(window.innerWidth * 0.65);
const w = saved && saved >= 280 ? Math.min(saved, max) : '';
document.body.style.gridTemplateColumns = w ? `280px 1fr ${w}px` : '';
}
}
function toggleRightPanel() {
const next = !document.body.classList.contains('right-collapsed');
localStorage.setItem(RIGHT_COLLAPSE_KEY, next ? '1' : '0');
applyRightPanelCollapsed(next);
}
// Default to collapsed when no preference saved.
(function initRightPanelCollapse() {
const stored = localStorage.getItem(RIGHT_COLLAPSE_KEY);
const collapsed = stored === null ? true : stored === '1';
applyRightPanelCollapsed(collapsed);
})();
// ── Right-panel resize ──────────────────────────────────────────────────────
(function initRightPanelResize() {
const MIN_W = 280;
const MAX_W_RATIO = 0.65;
const STORE_KEY = 'rightPanelWidth';
function applyWidth(px) {
document.body.style.gridTemplateColumns = `280px 1fr ${px}px`;
}
function clamp(px) {
return Math.max(MIN_W, Math.min(Math.floor(window.innerWidth * MAX_W_RATIO), px));
}
// Restore saved width (only if not collapsed)
if (!document.body.classList.contains('right-collapsed')) {
const saved = parseInt(localStorage.getItem(STORE_KEY), 10);
if (saved && saved >= MIN_W) applyWidth(clamp(saved));
}
function setup() {
const handle = document.getElementById('right-panel-resize-handle');
const panel = document.getElementById('right-panel');
if (!handle || !panel) return;
handle.addEventListener('mouseenter', () => { handle.style.background = 'var(--brand, #1668e3)'; handle.style.opacity = '0.35'; });
handle.addEventListener('mouseleave', () => { handle.style.background = 'transparent'; handle.style.opacity = '1'; });
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
const startX = e.clientX;
const startW = panel.getBoundingClientRect().width;
// Prevent iframes from swallowing events during drag
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = 'none');
document.body.style.userSelect = 'none';
handle.style.background = 'var(--brand, #1668e3)';
handle.style.opacity = '0.6';
function onMove(ev) {
const newW = clamp(startW + (startX - ev.clientX));
applyWidth(newW);
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = '');
document.body.style.userSelect = '';
handle.style.background = 'transparent';
handle.style.opacity = '1';
const finalW = panel.getBoundingClientRect().width;
localStorage.setItem(STORE_KEY, Math.round(finalW));
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', setup);
else setup();
})();
function handleErrorResponseWsEvent(e) {
const msg = e.detail || e;
if (!msg || msg.type !== 'task_error_requires_response') return;
showErrorResponsePanel(
msg.taskId,
msg.errorCategory,
msg.errorMessage,
msg.errorDetail,
msg.template,
);
}
</script>
<div id="image-zoom-modal" onclick="closeImageZoom()">
<img id="image-zoom-img" src="" alt="Zoomed image" />
</div>
<!-- ── Luckysheet Modal ─────────────────────────────────────────── -->
<div id="lucky-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.55);align-items:center;justify-content:center">
<div id="lucky-modal-box" style="background:#fff;width:96vw;height:92vh;border-radius:10px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,0.35);position:relative">
<div style="display:flex;align-items:center;gap:10px;padding:10px 14px;background:#f5f5f5;border-bottom:1px solid #ddd;flex-shrink:0;cursor:default">
<span id="lucky-modal-title" style="font-weight:700;font-size:14px;color:#222;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
<button id="lucky-modal-save" onclick="saveLuckyModal()" style="background:linear-gradient(135deg,#3b82f6,#2563eb);color:#fff;border:none;border-radius:6px;padding:5px 14px;font-size:13px;font-weight:600;cursor:pointer">💾 저장</button>
<button onclick="minimizeLuckyModal()" title="최소화" style="background:#e5e7eb;color:#333;border:none;border-radius:6px;padding:5px 10px;font-size:13px;cursor:pointer">&#8211;</button>
<button onclick="closeLuckyModal()" title="닫기" style="background:#e5e7eb;color:#333;border:none;border-radius:6px;padding:5px 10px;font-size:13px;cursor:pointer">✕</button>
</div>
<div id="lucky-container" style="flex:1;position:relative;color-scheme:light"></div>
</div>
</div>
</body>
</html>