Minecraft-Console-Client/DebugTools/MccMcpWebPlayground/wwwroot/index.html

1117 lines
No EOL
36 KiB
HTML

<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MCC MCP Live Playground</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
/* ─── TOKENS ──────────────────────────────────────────────── */
:root {
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
[data-theme="dark"] {
--bg: #050810;
--bg-mid: #080d1a;
--panel: #0c1220;
--panel-hover: #101828;
--border: #1a2540;
--border-light: #2a3a60;
--text: #d4deff;
--text-dim: #7080a8;
--text-faint: #3a4a70;
--accent: #3de8ff;
--accent-glow: rgba(61, 232, 255, 0.15);
--accent-dim: rgba(61, 232, 255, 0.06);
--tool-color: #39ffa0;
--tool-bg: rgba(57, 255, 160, 0.05);
--tool-border: rgba(57, 255, 160, 0.2);
--user-bg: rgba(30, 70, 140, 0.3);
--user-border: rgba(60, 120, 220, 0.35);
--error-color: #ff4d6a;
--error-bg: rgba(255, 77, 106, 0.08);
--error-border: rgba(255, 77, 106, 0.3);
--overlay: rgba(5, 8, 16, 0.85);
--scrollbar: #1a2540;
--canvas-dot: rgba(61, 232, 255, 0.06);
--shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
}
[data-theme="light"] {
--bg: #f0f4ff;
--bg-mid: #e8eeff;
--panel: #ffffff;
--panel-hover: #f8f9ff;
--border: #d4daf0;
--border-light: #b8c4e8;
--text: #1a2050;
--text-dim: #5a6a9a;
--text-faint: #b0bcd8;
--accent: #0077cc;
--accent-glow: rgba(0, 119, 204, 0.12);
--accent-dim: rgba(0, 119, 204, 0.05);
--tool-color: #008855;
--tool-bg: rgba(0, 136, 85, 0.05);
--tool-border: rgba(0, 136, 85, 0.2);
--user-bg: rgba(200, 220, 255, 0.5);
--user-border: rgba(100, 150, 240, 0.4);
--error-color: #cc2244;
--error-bg: rgba(204, 34, 68, 0.06);
--error-border: rgba(204, 34, 68, 0.25);
--overlay: rgba(240, 244, 255, 0.9);
--scrollbar: #c0cbea;
--canvas-dot: rgba(0, 100, 200, 0.07);
--shadow: 0 4px 24px rgba(80, 100, 160, 0.15);
--shadow-sm: 0 2px 8px rgba(80, 100, 160, 0.1);
}
/* ─── RESET ───────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 99px; }
/* ─── BASE ────────────────────────────────────────────────── */
html { height: 100%; }
body {
font-family: "Space Mono", "Courier New", monospace;
color: var(--text);
background: var(--bg);
height: 100%;
display: grid;
grid-template-rows: auto 1fr auto;
overflow: hidden;
transition: background 0.35s, color 0.35s;
position: relative;
}
#canvas-bg {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.9;
}
/* ─── HEADER ──────────────────────────────────────────────── */
header {
position: relative;
z-index: 10;
border-bottom: 1px solid var(--border);
padding: 0 20px;
background: var(--overlay);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
display: flex;
align-items: center;
gap: 16px;
height: 56px;
}
.header-brand {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.brand-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px var(--accent);
animation: pulse-dot 2.4s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.75); }
}
header h1 {
font-family: "Syne", sans-serif;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text);
}
header h1 span {
color: var(--accent);
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
#status {
font-size: 0.72rem;
color: var(--text-dim);
letter-spacing: 0.05em;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 99px;
background: var(--accent-dim);
white-space: nowrap;
transition: color 0.2s, border-color 0.2s;
}
#status.busy {
color: var(--accent);
border-color: rgba(61, 232, 255, 0.35);
animation: status-pulse 1.5s ease-in-out infinite;
}
@keyframes status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Theme toggle */
.theme-toggle {
width: 36px; height: 36px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--accent-dim);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.2s, border-color 0.2s, transform 0.2s;
flex-shrink: 0;
color: var(--text);
}
.theme-toggle:hover {
background: var(--accent-glow);
border-color: var(--accent);
transform: rotate(15deg);
}
.theme-toggle svg { width: 16px; height: 16px; }
/* ─── MAIN LAYOUT ─────────────────────────────────────────── */
main {
position: relative;
z-index: 5;
display: flex;
flex-direction: row;
overflow: hidden;
}
/* ─── PANELS ──────────────────────────────────────────────── */
.panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.panel-header {
padding: 0 16px;
height: 42px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
background: var(--panel);
}
.panel-title {
font-family: "Syne", sans-serif;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-dim);
}
.panel-actions {
display: flex;
gap: 6px;
}
.icon-btn {
width: 28px; height: 28px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: var(--text-dim);
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.icon-btn:hover {
background: var(--accent-dim);
border-color: var(--border-light);
color: var(--accent);
}
.icon-btn svg { width: 13px; height: 13px; }
/* ─── CHAT PANEL ──────────────────────────────────────────── */
#chat-panel {
flex: 1 1 0;
min-width: 0;
background: var(--bg-mid);
border-right: 1px solid var(--border);
}
#chat {
flex: 1;
overflow-y: auto;
padding: 20px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* messages */
.msg {
border-radius: 12px;
padding: 12px 14px;
animation: msg-in 0.35s var(--ease-out-expo) both;
max-width: 100%;
word-break: break-word;
white-space: pre-wrap;
line-height: 1.6;
font-size: 0.83rem;
}
@keyframes msg-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.msg-meta {
font-family: "Syne", sans-serif;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 6px;
}
.msg.user {
background: var(--user-bg);
border: 1px solid var(--user-border);
}
.msg.user .msg-meta { color: #7ab0ff; }
.msg.assistant {
background: var(--panel);
border: 1px solid var(--border);
}
.msg.assistant .msg-meta { color: var(--accent); }
.msg.error {
background: var(--error-bg);
border: 1px solid var(--error-border);
}
.msg.error .msg-meta { color: var(--error-color); }
.msg.error .msg-body { color: var(--error-color); }
/* Typing indicator */
#typing-indicator {
display: none;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--panel);
animation: msg-in 0.35s var(--ease-out-expo) both;
width: fit-content;
}
#typing-indicator.visible {
display: flex;
}
.typing-label {
font-family: "Syne", sans-serif;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
}
.typing-dots {
display: flex;
gap: 4px;
align-items: center;
}
.typing-dots span {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
display: block;
animation: typing-bounce 1.1s ease-in-out infinite;
}
.typing-dots span:nth-child(2) { animation-delay: 0.18s; }
.typing-dots span:nth-child(3) { animation-delay: 0.36s; }
@keyframes typing-bounce {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.75); }
40% { opacity: 1; transform: scale(1); }
}
/* ─── TOOLS PANEL ─────────────────────────────────────────── */
#tools-panel {
flex: 0 0 340px;
width: 340px;
min-width: 0;
background: var(--bg-mid);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
transition: flex-basis 0.38s cubic-bezier(0.16,1,0.3,1),
width 0.38s cubic-bezier(0.16,1,0.3,1),
opacity 0.28s ease,
border-color 0.38s ease;
}
#tools-panel.collapsed {
flex: 0 0 0;
width: 0;
opacity: 0;
border-color: transparent;
pointer-events: none;
}
#tools {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.tool-event {
border-radius: 10px;
padding: 10px 12px;
font-size: 0.76rem;
line-height: 1.55;
background: var(--tool-bg);
border: 1px solid var(--tool-border);
color: var(--tool-color);
white-space: pre-wrap;
word-break: break-word;
animation: msg-in 0.3s var(--ease-out-expo) both;
}
.tool-event-title {
font-family: "Syne", sans-serif;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 6px;
opacity: 0.7;
}
/* Tools toggle FAB (visible when panel is collapsed) */
#tools-toggle-fab {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
z-index: 20;
width: 36px; height: 36px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel);
cursor: pointer;
display: none;
align-items: center; justify-content: center;
color: var(--text-dim);
box-shadow: var(--shadow-sm);
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
#tools-toggle-fab:hover {
background: var(--accent-glow);
border-color: var(--accent);
color: var(--accent);
}
#tools-panel.collapsed ~ #tools-toggle-fab {
display: flex;
}
#tools-toggle-fab svg { width: 15px; height: 15px; }
/* ─── FOOTER / INPUT AREA ─────────────────────────────────── */
footer {
position: relative;
z-index: 10;
border-top: 1px solid var(--border);
background: var(--overlay);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
transition: padding 0.2s;
}
.input-wrapper {
position: relative;
display: flex;
flex-direction: column;
}
textarea#prompt {
width: 100%;
resize: none;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
padding: 12px 44px 12px 14px;
font: 0.83rem/1.5 "Space Mono", monospace;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s, height 0.25s var(--ease-out-expo), background 0.2s;
height: 48px;
min-height: 48px;
max-height: 220px;
overflow-y: auto;
}
textarea#prompt.expanded {
height: 140px;
}
textarea#prompt:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow), inset 0 0 0 1px var(--accent);
}
textarea#prompt::placeholder {
color: var(--text-faint);
}
textarea#prompt:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* expand toggle button inside textarea */
#expand-btn {
position: absolute;
right: 10px;
top: 10px;
width: 26px; height: 26px;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text-dim);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.2s;
flex-shrink: 0;
}
#expand-btn:hover {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
}
#expand-btn.expanded {
transform: rotate(180deg);
}
#expand-btn svg { width: 12px; height: 12px; }
/* footer action row */
.footer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.footer-hint {
font-size: 0.68rem;
color: var(--text-faint);
letter-spacing: 0.03em;
line-height: 1.4;
}
.footer-hint kbd {
display: inline-block;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 4px;
font: inherit;
font-size: 0.65rem;
}
.footer-buttons {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
.btn {
height: 36px;
padding: 0 16px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
font: 0.78rem/1 "Syne", sans-serif;
font-weight: 700;
letter-spacing: 0.06em;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s, box-shadow 0.15s;
white-space: nowrap;
}
.btn:hover {
background: var(--panel-hover);
border-color: var(--border-light);
}
.btn:active {
transform: scale(0.97);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--text-dim);
}
.btn-ghost:hover {
background: var(--accent-dim);
border-color: var(--border);
color: var(--text);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #050810;
font-weight: 700;
box-shadow: 0 0 14px rgba(61, 232, 255, 0.25);
}
.btn-primary:hover:not(:disabled) {
background: #6ef0ff;
border-color: #6ef0ff;
box-shadow: 0 0 20px rgba(61, 232, 255, 0.45);
}
[data-theme="light"] .btn-primary {
color: #fff;
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 2px 12px rgba(0, 119, 204, 0.3);
}
.btn svg { width: 14px; height: 14px; flex-shrink: 0; }
/* ─── EMPTY STATE ─────────────────────────────────────────── */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-faint);
text-align: center;
padding: 20px;
}
.empty-state svg {
width: 32px; height: 32px;
opacity: 0.3;
margin-bottom: 4px;
}
.empty-state p {
font-size: 0.8rem;
line-height: 1.5;
}
/* ─── RESPONSIVE ──────────────────────────────────────────── */
@media (max-width: 880px) {
#tools-panel { display: none; }
}
</style>
</head>
<body>
<!-- Animated canvas background -->
<canvas id="canvas-bg"></canvas>
<!-- Header -->
<header>
<div class="header-brand">
<div class="brand-dot"></div>
<h1><span>MCC</span> MCP Playground</h1>
</div>
<div class="header-right">
<div id="status">Booting…</div>
<button class="theme-toggle" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
<!-- Moon icon (dark mode shown, click = go light) -->
<svg id="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
<svg id="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
</button>
</div>
</header>
<!-- Main -->
<main id="main">
<!-- Chat panel -->
<section class="panel" id="chat-panel">
<div class="panel-header">
<span class="panel-title">Chat</span>
<div class="panel-actions">
<button class="icon-btn" id="clear-chat-btn" title="Clear chat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>
</svg>
</button>
</div>
</div>
<div id="chat">
<div class="empty-state" id="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.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>
<p>No messages yet.<br>Ask the LLM to control MCC via MCP.</p>
</div>
<div id="typing-indicator">
<span class="typing-label">Thinking</span>
<div class="typing-dots">
<span></span><span></span><span></span>
</div>
</div>
</div>
</section>
<!-- Tools panel -->
<section class="panel" id="tools-panel">
<div class="panel-header">
<span class="panel-title" style="display:flex;align-items:center;gap:7px;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--tool-color)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.8">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
<span style="color:var(--tool-color);">Tool Events</span>
</span>
<div class="panel-actions">
<button class="icon-btn" id="clear-tools-btn" title="Clear tool events">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
</svg>
</button>
<button class="icon-btn" id="collapse-tools-btn" title="Collapse panel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 21 12 15 6"/>
</svg>
</button>
</div>
</div>
<div id="tools">
<div class="empty-state" id="tools-empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
<p>No tool events yet.</p>
</div>
</div>
</section>
<!-- FAB to re-open collapsed tools panel -->
<button id="tools-toggle-fab" title="Show Tool Events">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 3 12 9 6"/>
</svg>
</button>
</main>
<!-- Footer / Input -->
<footer>
<div class="input-wrapper">
<textarea id="prompt" placeholder="Ask the LLM to control MCC via MCP…" rows="1"></textarea>
<button id="expand-btn" title="Expand input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="18 15 12 9 6 15"/>
</svg>
</button>
</div>
<div class="footer-actions">
<div class="footer-hint">
<kbd>Enter</kbd> to send &nbsp;·&nbsp; <kbd>Shift</kbd>+<kbd>Enter</kbd> for new line
</div>
<div class="footer-buttons">
<button class="btn btn-ghost" id="stop">
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
Stop
</button>
<button class="btn btn-ghost" id="clear">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.29"/></svg>
Clear
</button>
<button class="btn btn-primary" id="send">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
Send
</button>
</div>
</div>
</footer>
<script>
/* ─── CANVAS BACKGROUND ─────────────────────────────────── */
(function () {
const canvas = document.getElementById("canvas-bg");
const ctx = canvas.getContext("2d");
let W, H, cols, rows, dots = [], raf;
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
const spacing = 38;
cols = Math.ceil(W / spacing) + 1;
rows = Math.ceil(H / spacing) + 1;
dots = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
dots.push({
x: c * spacing,
y: r * spacing,
phase: Math.random() * Math.PI * 2,
speed: 0.004 + Math.random() * 0.006,
});
}
}
}
function draw(t) {
ctx.clearRect(0, 0, W, H);
const isDark = document.documentElement.getAttribute("data-theme") !== "light";
const baseAlpha = isDark ? 0.065 : 0.09;
const color = isDark ? "61, 232, 255" : "0, 100, 200";
for (const d of dots) {
const a = baseAlpha * (0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * d.speed + d.phase)));
ctx.beginPath();
ctx.arc(d.x, d.y, 1.5, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${color}, ${a})`;
ctx.fill();
}
raf = requestAnimationFrame(draw);
}
window.addEventListener("resize", resize);
resize();
raf = requestAnimationFrame(draw);
})();
/* ─── THEME TOGGLE ──────────────────────────────────────── */
const html = document.documentElement;
const themeToggle = document.getElementById("theme-toggle");
const iconMoon = document.getElementById("icon-moon");
const iconSun = document.getElementById("icon-sun");
function setTheme(theme) {
html.setAttribute("data-theme", theme);
if (theme === "dark") {
iconMoon.style.display = "";
iconSun.style.display = "none";
} else {
iconMoon.style.display = "none";
iconSun.style.display = "";
}
localStorage.setItem("mcc-theme", theme);
}
themeToggle.addEventListener("click", () => {
setTheme(html.getAttribute("data-theme") === "dark" ? "light" : "dark");
});
// Load saved theme
const savedTheme = localStorage.getItem("mcc-theme");
if (savedTheme) setTheme(savedTheme);
/* ─── COLLAPSIBLE TOOLS PANEL ───────────────────────────── */
const mainEl = document.getElementById("main");
const toolsPanelEl = document.getElementById("tools-panel");
const collapseToolsBtn = document.getElementById("collapse-tools-btn");
const toolsToggleFab = document.getElementById("tools-toggle-fab");
function setToolsOpen(open) {
toolsPanelEl.classList.toggle("collapsed", !open);
}
collapseToolsBtn.addEventListener("click", () => setToolsOpen(false));
toolsToggleFab.addEventListener("click", () => setToolsOpen(true));
/* ─── EXPANDABLE TEXTAREA ───────────────────────────────── */
const promptEl = document.getElementById("prompt");
const expandBtn = document.getElementById("expand-btn");
let isExpanded = false;
expandBtn.addEventListener("click", () => {
isExpanded = !isExpanded;
promptEl.classList.toggle("expanded", isExpanded);
expandBtn.classList.toggle("expanded", isExpanded);
});
/* ─── CORE CHAT LOGIC ───────────────────────────────────── */
const chatEl = document.getElementById("chat");
const toolsEl = document.getElementById("tools");
const statusEl = document.getElementById("status");
const sendBtn = document.getElementById("send");
const stopBtn = document.getElementById("stop");
const clearBtn = document.getElementById("clear");
const clearChatBtn = document.getElementById("clear-chat-btn");
const clearToolsBtn = document.getElementById("clear-tools-btn");
const emptyState = document.getElementById("empty-state");
const toolsEmptyState = document.getElementById("tools-empty-state");
const typingIndicator = document.getElementById("typing-indicator");
let history = [];
let activeAssistant = null;
let abortController = null;
stopBtn.disabled = true;
function updateEmptyStates() {
// Check messages (excluding typing indicator and empty state)
const realMessages = chatEl.querySelectorAll(".msg");
emptyState.style.display = realMessages.length === 0 ? "" : "none";
const toolEvents = toolsEl.querySelectorAll(".tool-event");
toolsEmptyState.style.display = toolEvents.length === 0 ? "" : "none";
}
function addMessage(role, content, extraClass = "") {
const wrap = document.createElement("div");
wrap.className = `msg ${role} ${extraClass}`.trim();
const meta = document.createElement("div");
meta.className = "msg-meta";
meta.textContent = role;
const body = document.createElement("div");
body.className = "msg-body";
body.textContent = content;
wrap.append(meta, body);
// Insert before the typing indicator
chatEl.insertBefore(wrap, typingIndicator);
chatEl.scrollTop = chatEl.scrollHeight;
updateEmptyStates();
return body;
}
function addToolEvent(title, payload) {
const el = document.createElement("div");
el.className = "tool-event";
const titleEl = document.createElement("div");
titleEl.className = "tool-event-title";
titleEl.textContent = title;
const body = document.createElement("pre");
body.style.margin = "0";
body.style.fontFamily = "inherit";
body.style.fontSize = "inherit";
body.textContent = JSON.stringify(payload, null, 2);
el.append(titleEl, body);
toolsEl.appendChild(el);
toolsEl.scrollTop = toolsEl.scrollHeight;
// Auto-open if collapsed
if (toolsPanelEl.classList.contains("collapsed")) {
setToolsOpen(true);
}
updateEmptyStates();
}
function setTyping(on) {
typingIndicator.classList.toggle("visible", on);
if (on) chatEl.scrollTop = chatEl.scrollHeight;
}
function setBusy(busy) {
sendBtn.disabled = busy;
stopBtn.disabled = !busy;
promptEl.disabled = busy;
statusEl.textContent = busy ? "Streaming…" : "Ready";
statusEl.classList.toggle("busy", busy);
setTyping(busy && !activeAssistant?.textContent);
}
async function loadConfig() {
try {
const res = await fetch("/api/config");
if (!res.ok) throw new Error("Failed to load config.");
const config = await res.json();
statusEl.textContent = config.hasApiKey
? `Model: ${config.model}`
: "Missing OPENROUTER_API_KEY";
} catch (err) {
statusEl.textContent = `Config error: ${err.message}`;
}
}
function parseSseChunk(buffer, onEvent) {
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const eventBlock = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const lines = eventBlock.split("\n");
let eventName = "message";
let data = "";
for (const line of lines) {
if (line.startsWith("event:")) eventName = line.slice(6).trim();
if (line.startsWith("data:")) data += line.slice(5).trim();
}
if (data.length > 0) {
try {
onEvent(eventName, JSON.parse(data));
} catch {
onEvent("error", { message: "Failed to parse SSE payload.", raw: data });
}
}
}
return buffer;
}
async function sendPrompt() {
const prompt = promptEl.value.trim();
if (!prompt) return;
history.push({ role: "user", content: prompt });
addMessage("user", prompt);
promptEl.value = "";
activeAssistant = addMessage("assistant", "");
setBusy(true);
abortController = new AbortController();
try {
const res = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history }),
signal: abortController.signal
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let assistantText = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
buffer = parseSseChunk(buffer, (eventName, payload) => {
if (eventName === "token") {
setTyping(false);
assistantText += payload.text ?? "";
activeAssistant.textContent = assistantText;
chatEl.scrollTop = chatEl.scrollHeight;
} else if (eventName === "tool_call") {
addToolEvent("Tool Call", payload);
} else if (eventName === "tool_result") {
addToolEvent("Tool Result", payload);
} else if (eventName === "final") {
assistantText = payload.text ?? assistantText;
activeAssistant.textContent = assistantText;
} else if (eventName === "error") {
addMessage("error", payload.message ?? "Unknown error", "error");
if (payload.detail) addToolEvent("Error Detail", payload);
}
});
}
if (assistantText.trim().length > 0) {
history.push({ role: "assistant", content: assistantText });
}
} catch (err) {
if (err.name !== "AbortError") {
addMessage("error", `Request failed: ${err.message}`, "error");
}
} finally {
abortController = null;
activeAssistant = null;
setBusy(false);
}
}
/* ─── EVENT LISTENERS ───────────────────────────────────── */
sendBtn.addEventListener("click", sendPrompt);
stopBtn.addEventListener("click", () => abortController?.abort());
clearBtn.addEventListener("click", () => {
history = [];
chatEl.querySelectorAll(".msg").forEach(el => el.remove());
toolsEl.querySelectorAll(".tool-event").forEach(el => el.remove());
promptEl.value = "";
updateEmptyStates();
});
clearChatBtn.addEventListener("click", () => {
history = [];
chatEl.querySelectorAll(".msg").forEach(el => el.remove());
promptEl.value = "";
updateEmptyStates();
});
clearToolsBtn.addEventListener("click", () => {
toolsEl.querySelectorAll(".tool-event").forEach(el => el.remove());
updateEmptyStates();
});
promptEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!sendBtn.disabled) sendPrompt();
}
});
// Initialise
updateEmptyStates();
loadConfig();
</script>
</body>
</html>