9820 lines
434 KiB
HTML
9820 lines
434 KiB
HTML
<!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">📎</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">✕</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 <token></code><br>
|
||
or <code style="background:#f4f4f4;padding:2px 5px;border-radius:4px">x-cherryclaw-token: <token></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 @modelcontextprotocol/server-filesystem 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">⬇</span> ${escHtml(filename)}</a>
|
||
<button class="pptx-preview-btn" onclick="togglePptxPreview('${escHtml(rawUrl)}', '${previewId}', this)" title="Preview slides">◀ 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">⬇</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, ''')}')">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, '"');
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
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">⬇</span> ' + text + '</a>'
|
||
+ ' <button class="pptx-preview-btn" onclick="togglePptxPreview(\'' + escHtml(url) + '\', \'' + previewId + '\', this)" title="Preview slides">◀ 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">⬇</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: 
|
||
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 = '◀ 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 = '▲ 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 = '';
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>')}</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})">×</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>
|