Files
homeclaw/web-ui/index.html
T
2026-04-30 12:41:49 +09:00

9820 lines
434 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="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>
<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;
}
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: 1; min-height: 0; display: flex; flex-direction: column; }
#sidebar-skills { display: none; }
.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); }
.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); }
.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); }
#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; }
.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 drawer — slides in from left over content */
body.mob-menu aside {
display: flex;
position: fixed;
top: 52px; left: 0; bottom: 56px;
width: min(300px, 85vw);
z-index: 500;
box-shadow: 4px 0 24px rgba(0,0,0,0.18);
overflow-y: auto;
border-right: 1px solid var(--line);
}
/* 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;
}
</style>
</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>
<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-list" style="flex:1;min-height:0;overflow-y:auto;padding:10px">
<div class="empty-state" style="padding:16px">스킬 로딩 중...</div>
</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>
<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 class="chat-input-row">
<input type="file" id="image-upload" accept="image/*" style="display:none" onchange="handleImageUpload(this)" />
<button class="send-btn" onclick="document.getElementById('image-upload').click()" title="이미지 첨부" style="font-size:18px;width:44px;height:44px">&#128206;</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>
<!-- 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>
<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>
</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 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">
<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>
</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>
</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>
<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 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 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>
</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>
</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" style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:13px">
<option value="ddg">DuckDuckGo</option>
<option value="tavily">Tavily</option>
<option value="google">Google CSE</option>
<option value="brave">Brave</option>
</select>
<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>
</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>
<!-- 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>
</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="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 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>
</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 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 = 'cherryclaw_chat_sessions_v1';
const AGENT_SESSION_KEY = 'cherryclaw_agent_session_id';
const THEME_KEY = 'cherryclaw_theme';
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(AGENT_SESSION_KEY, next);
return next;
}
let agentSessionId = '';
let currentUser = null;
setAgentSessionId(generateSessionId()); // always fresh session on page load
checkAuth(); // Check authentication on page load
// 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(CHAT_SESSIONS_KEY, 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() {
try {
chatSessions = JSON.parse(localStorage.getItem(CHAT_SESSIONS_KEY) || '[]');
} 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') 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;
}
// Collect /api/files/ URLs from this message's own data only (not global log)
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));
}
}
// 3. msg.content itself (LLM may include the link in its reply)
if (arguments.length > 2 && arguments[2]) {
const contentText = String(arguments[2]);
if (contentText.indexOf('/api/files/') !== -1) allUrls.push(...extractUrls(contentText));
}
for (const rawUrl of allUrls) {
if (seenUrls.has(rawUrl)) continue;
seenUrls.add(rawUrl);
const decoded = (() => { try { return decodeURIComponent(rawUrl); } catch { return rawUrl; } })();
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',
credentials: 'same-origin',
headers: {
'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})"` : '';
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')}</div>
${msg.role === 'ai' ? renderAssistantContent(msg.content) : `<div class="msg-content">${renderMarkdown(msg.content)}</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('');
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) 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;
}
chatHistory.push({ role: 'user', content: message });
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;
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',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
signal: currentAbortController.signal,
body: JSON.stringify({ message, 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 '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) {
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`, {
credentials: 'same-origin'
})
.catch(err => console.error('Unable to open process log:', err));
}
}
function clearChat() {
chatHistory = [];
persistActiveChat();
renderChatMessages();
}
// 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';
ws = new WebSocket(`${proto}://${API.replace(/^https?:\/\//, '')}/ws`);
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') {
// Inject boot greeting as first message in current fresh session
const bootSess = chatSessions.find(s => s.id === activeChatSessionId);
if (bootSess && (!bootSess.history || bootSess.history.length === 0)) {
bootSess.history = [{ 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}`, {
credentials: 'same-origin'
}).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 = { 'Content-Type': 'application/json', ...optsHeaders };
const r = await fetch(API + path, { credentials: 'same-origin', headers, ...restOpts });
if (r.status === 401) { 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;
}
async function checkAuth() {
try {
const res = await fetch('/api/auth/status');
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 };
// 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;
}
} catch (e) {
console.error('Auth check failed:', e);
}
}
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
} catch (e) { /* ignore network errors */ }
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();
}
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'];
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();
} 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'];
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);
// 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' };
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' };
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';
// 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="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--line)">
<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>
${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>
`).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 || '삭제 실패'); }
}
// --- 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',
credentials: 'same-origin',
headers: {
'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',
};
const guideMap = {
telegram: 'channel-guide-telegram',
discord: 'channel-guide-discord',
whatsapp: 'channel-guide-whatsapp',
};
const statusMap = {
telegram: 'tg-status-bar',
discord: 'dc-status-bar',
whatsapp: 'wa-status-bar',
};
['telegram', 'discord', 'whatsapp'].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';
});
}
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);
}
}
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');
}
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() || '',
};
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}`
);
} 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 _renderLinkButton(text, 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 isPptx = decodeURIComponent(url).toLowerCase().indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx');
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="Preview slides">&#9664; Preview</button>'
+ '<div id="' + previewId + '" class="pptx-preview-strip" style="display:none"></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 renderMarkdown(text) {
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) {
return '<pre class="md-code-block"><code>' + code.trim() + '</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);
});
// 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);
});
// 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 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';
}
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, {
credentials: 'same-origin'
})
.then(function(r) {
if (!r.ok) throw new Error('Download failed: ' + 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) {
alert('Download failed: ' + err.message);
});
}
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), {
credentials: 'same-origin'
})
.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',
credentials: 'same-origin',
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); });
}
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;
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 data = await api('/api/skills');
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 : '🧩';
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)}</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);
// Block only when trying to enable an ineligible skill.
if (skillMeta && skillMeta.eligible === false && !skillMeta.enabled) {
addProcessEntry('error', skillMeta.eligibleReason || 'This skill is currently unavailable.');
return;
}
try {
const res = await api(`/api/skills/${encodeURIComponent(id)}/toggle`, { method: 'POST' });
if (!res.success) {
addProcessEntry('error', res.error || 'Failed to toggle skill.');
return;
}
addProcessEntry('info', `Skill "${res.skill.name}": ${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');
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();
} else {
panel.style.display = 'none';
if (btn) { btn.style.background=''; btn.style.borderColor=''; btn.style.color=''; }
}
}
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 = 'flex'; 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.`);
}
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',
credentials: 'same-origin',
headers: { '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');
document.getElementById('mob-backdrop').style.display = 'block';
}
// 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 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
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>
</body>
</html>