neuralcad / web /index.html
CallMeDaniel's picture
feat: add model selector dropdown to UI
1f27d6a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeuralCAD — Multi-Agent Design</title>
<!-- Three.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/STLLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-void: #06080c;
--bg-panel: #0c1018;
--bg-surface: #111822;
--bg-input: #0a0f16;
--border: #1c2636;
--border-active: #2a3a52;
--text-primary: #c8d6e5;
--text-secondary: #5a7089;
--text-muted: #3a4d63;
--accent: #00b4d8;
--accent-glow: rgba(0, 180, 216, 0.15);
--accent-dim: #007a94;
--success: #00e676;
--success-glow: rgba(0, 230, 118, 0.12);
--warning: #ffab40;
--error: #ff5252;
--machined-steel: #8899aa;
--font-mono: 'JetBrains Mono', 'Cascadia Code', monospace;
--font-body: 'DM Sans', system-ui, sans-serif;
--agent-design: #7c3aed;
--agent-engineering: #00b4d8;
--agent-cnc: #00e676;
--agent-cad: #ffab40;
--agent-cam: #ff6b35;
--chat-width: 340px;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-void);
color: var(--text-primary);
font-family: var(--font-body);
}
/* ---- Scrollbar ---- */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-active); }
/* ---- LAYOUT ---- */
#app {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
}
/* ---- TOP BAR ---- */
#topbar {
flex: 0 0 44px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
z-index: 100;
position: relative;
}
#topbar::after {
content: '';
position: absolute;
bottom: -1px;
left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent-dim), transparent);
opacity: 0.4;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-diamond {
color: var(--accent);
font-size: 18px;
line-height: 1;
}
.logo-text {
font-family: var(--font-mono);
font-weight: 600;
font-size: 14px;
letter-spacing: 2px;
color: var(--text-primary);
text-transform: uppercase;
}
.logo-sub {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-muted);
letter-spacing: 3px;
text-transform: uppercase;
margin-left: 8px;
padding-left: 8px;
border-left: 1px solid var(--border);
}
.topbar-center {
display: flex;
align-items: center;
gap: 12px;
}
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.backend-toggle {
display: flex;
align-items: center;
gap: 0;
background: var(--bg-void);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
font-family: var(--font-mono);
font-size: 11px;
}
.backend-toggle button {
all: unset;
padding: 4px 12px;
cursor: pointer;
color: var(--text-muted);
transition: all 0.2s;
border-right: 1px solid var(--border);
}
.backend-toggle button:last-child { border-right: none; }
.backend-toggle button.active {
background: var(--accent-glow);
color: var(--accent);
}
.backend-toggle button:hover:not(.active) {
color: var(--text-secondary);
}
.model-select {
background: var(--bg-void);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
padding: 4px 8px;
cursor: pointer;
outline: none;
}
.model-select:focus {
border-color: var(--accent);
}
.lang-toggle {
display: flex;
align-items: center;
gap: 0;
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
.lang-btn {
all: unset;
padding: 4px 8px;
cursor: pointer;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 10px;
border-right: 1px solid var(--border);
}
.lang-btn:last-child { border-right: none; }
.lang-btn.active {
background: var(--accent-glow);
color: var(--accent);
}
.lang-btn:hover:not(.active) {
color: var(--text-secondary);
}
.gallery-btn {
all: unset;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.gallery-btn:hover {
border-color: var(--accent-dim);
color: var(--accent);
}
.status-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 6px var(--success);
animation: pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ---- MAIN AREA ---- */
#main {
flex: 1;
display: flex;
position: relative;
min-height: 0;
overflow: hidden;
}
/* ---- 3D VIEWER ---- */
#viewer-container {
flex: 1;
position: relative;
background: var(--bg-void);
overflow: hidden;
min-height: 0;
}
#viewer-canvas {
width: 100%;
height: 100%;
display: block;
}
/* Geo stats overlay - top left */
#geo-stats {
position: absolute;
top: 14px;
left: 14px;
z-index: 10;
background: rgba(6, 8, 12, 0.85);
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px 14px;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.7;
backdrop-filter: blur(8px);
display: none;
}
#geo-stats.visible { display: block; }
.stat-label { color: var(--text-muted); }
.stat-value { color: var(--accent); }
/* CNC badge - top right of viewer (NOT behind chat) */
#cnc-badge {
position: absolute;
top: 14px;
right: 14px;
z-index: 10;
display: none;
gap: 6px;
transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
#cnc-badge.visible { display: flex; }
body.chat-open #cnc-badge { right: calc(var(--chat-width) + 14px); }
.badge {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
padding: 4px 10px;
border-radius: 3px;
letter-spacing: 0.5px;
backdrop-filter: blur(8px);
}
.badge-success {
background: var(--success-glow);
border: 1px solid rgba(0, 230, 118, 0.3);
color: var(--success);
}
.badge-warning {
background: rgba(255, 171, 64, 0.1);
border: 1px solid rgba(255, 171, 64, 0.3);
color: var(--warning);
}
.badge-error {
background: rgba(255, 82, 82, 0.1);
border: 1px solid rgba(255, 82, 82, 0.3);
color: var(--error);
}
.badge-info {
background: var(--accent-glow);
border: 1px solid rgba(0, 180, 216, 0.3);
color: var(--accent);
}
/* Download buttons - bottom left */
#download-btns {
position: absolute;
bottom: 14px;
left: 14px;
z-index: 10;
display: none;
gap: 6px;
}
#download-btns.visible { display: flex; }
.dl-btn {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
padding: 5px 14px;
border-radius: 3px;
background: rgba(6, 8, 12, 0.85);
border: 1px solid var(--border);
color: var(--text-secondary);
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
backdrop-filter: blur(8px);
letter-spacing: 0.5px;
}
.dl-btn:hover {
border-color: var(--accent-dim);
color: var(--accent);
}
.view-btn.active { background: var(--accent-dim) !important; color: var(--text-primary) !important; border-color: var(--accent) !important; }
/* Viewer hint */
#viewer-hint {
position: absolute;
bottom: 16px;
right: 16px;
z-index: 10;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.5px;
pointer-events: none;
transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
body.chat-open #viewer-hint { right: calc(var(--chat-width) + 16px); }
/* Loading spinner */
#viewer-loading {
position: absolute;
inset: 0;
z-index: 20;
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 16px;
background: rgba(6, 8, 12, 0.7);
backdrop-filter: blur(4px);
}
#viewer-loading.visible { display: flex; }
.spinner {
width: 36px; height: 36px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
letter-spacing: 1px;
}
/* Empty state */
#viewer-empty {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 16px;
pointer-events: none;
}
.empty-icon {
width: 64px; height: 64px;
border: 2px solid var(--border);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transform: rotate(45deg);
opacity: 0.5;
}
.empty-icon-inner {
width: 20px; height: 20px;
border: 2px solid var(--text-muted);
border-radius: 2px;
transform: rotate(-45deg);
opacity: 0.3;
}
.empty-text {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
letter-spacing: 1px;
text-align: center;
line-height: 1.6;
}
/* ---- CHAT PANEL ---- */
#chat-panel {
position: absolute;
top: 0;
right: 0;
width: var(--chat-width);
height: 100%;
background: rgba(10, 14, 20, 0.92);
backdrop-filter: blur(16px);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 50;
transform: translateX(0);
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
#chat-panel.collapsed {
transform: translateX(100%);
}
/* Collapse toggle */
#chat-toggle {
all: unset;
position: absolute;
top: 50%;
left: -28px;
transform: translateY(-50%);
width: 28px;
height: 56px;
background: rgba(10, 14, 20, 0.92);
backdrop-filter: blur(16px);
border: 1px solid var(--border);
border-right: none;
border-radius: 6px 0 0 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
font-size: 14px;
transition: all 0.2s;
z-index: 51;
}
#chat-toggle:hover {
color: var(--accent);
background: rgba(10, 14, 20, 0.98);
}
/* Floating open pill */
#chat-open-pill {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 60;
display: none;
align-items: center;
gap: 10px;
padding: 10px 20px;
background: rgba(10, 14, 20, 0.95);
backdrop-filter: blur(16px);
border: 1px solid var(--border);
border-radius: 24px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
letter-spacing: 0.5px;
transition: all 0.3s;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
#chat-open-pill:hover {
border-color: var(--accent-dim);
box-shadow: 0 4px 32px rgba(0, 180, 216, 0.15);
}
#chat-open-pill.visible { display: flex; }
.pill-dots {
display: flex;
gap: 4px;
}
.pill-dot {
width: 8px; height: 8px;
border-radius: 50%;
}
/* Chat header */
.chat-header {
flex: 0 0 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--border);
}
.chat-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.chat-header-title {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 2px;
color: var(--text-secondary);
text-transform: uppercase;
}
.agent-dots {
display: flex;
gap: 5px;
}
.agent-dot {
width: 8px; height: 8px;
border-radius: 50%;
opacity: 0.8;
}
/* Messages area */
#chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
/* Quick examples */
.quick-examples {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px 16px 20px;
}
.quick-examples-label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
letter-spacing: 2px;
text-transform: uppercase;
}
.quick-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
}
.quick-chip {
all: unset;
padding: 6px 12px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 16px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.quick-chip:hover {
border-color: var(--accent-dim);
color: var(--accent);
background: var(--accent-glow);
}
/* Message bubbles */
.msg {
display: flex;
gap: 8px;
max-width: 100%;
animation: msg-in 0.25s ease-out both;
}
@keyframes msg-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.msg-user {
justify-content: flex-end;
}
.msg-user .msg-bubble {
background: #1a2a3a;
border: 1px solid rgba(0, 180, 216, 0.15);
border-radius: 12px 12px 4px 12px;
padding: 8px 12px;
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
max-width: 85%;
word-wrap: break-word;
}
.msg-agent {
align-items: flex-start;
}
.msg-avatar {
flex-shrink: 0;
width: 24px; height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: rgba(0, 0, 0, 0.7);
margin-top: 2px;
}
.msg-agent-body {
flex: 1;
min-width: 0;
}
.msg-agent-name {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
margin-bottom: 3px;
text-transform: uppercase;
}
.msg-agent-bubble {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 4px 12px 12px 12px;
padding: 8px 12px;
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
word-wrap: break-word;
}
/* CAD Coder special styling */
.msg-agent-bubble.cad-bubble {
background: rgba(255, 171, 64, 0.08);
border-color: rgba(255, 171, 64, 0.2);
}
.msg-view-code {
display: inline-block;
margin-top: 6px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--warning);
cursor: pointer;
text-decoration: none;
letter-spacing: 0.5px;
transition: opacity 0.2s;
}
.msg-view-code:hover { opacity: 0.7; }
.msg-actions {
display: flex;
gap: 6px;
margin-top: 8px;
}
.msg-actions.resolved { display: none; }
.msg-action-btn {
all: unset;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
padding: 4px 12px;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s;
}
.msg-action-btn.confirm {
background: rgba(0, 230, 118, 0.1);
border: 1px solid rgba(0, 230, 118, 0.3);
color: var(--success);
}
.msg-action-btn.confirm:hover {
background: rgba(0, 230, 118, 0.2);
}
.msg-action-btn.revise {
background: rgba(255, 171, 64, 0.08);
border: 1px solid rgba(255, 171, 64, 0.25);
color: var(--warning);
}
.msg-action-btn.revise:hover {
background: rgba(255, 171, 64, 0.15);
}
.msg-confirmed {
font-family: var(--font-mono);
font-size: 10px;
color: var(--success);
margin-top: 6px;
letter-spacing: 0.5px;
}
/* Typing indicator */
.typing-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
.typing-dots {
display: flex;
gap: 4px;
}
.typing-dots span {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: typing-bounce 1.2s ease-in-out infinite;
}
.typing-dots span:nth-child(2) { animation-delay: 0.15s; }
.typing-dots span:nth-child(3) { animation-delay: 0.3s; }
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-4px); opacity: 1; }
}
.typing-label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.5px;
}
/* Chat input area */
.chat-input-area {
flex: 0 0 auto;
padding: 12px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-input-row {
display: flex;
gap: 6px;
align-items: flex-end;
}
#chat-input {
flex: 1;
min-height: 38px;
max-height: 120px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 13px;
line-height: 1.4;
resize: none;
outline: none;
transition: border-color 0.2s;
}
#chat-input::placeholder { color: var(--text-muted); }
#chat-input:focus { border-color: var(--accent-dim); }
.chat-btn {
all: unset;
flex-shrink: 0;
width: 34px; height: 34px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
font-size: 16px;
}
.chat-btn-preview {
background: rgba(255, 171, 64, 0.1);
border: 1px solid rgba(255, 171, 64, 0.25);
color: var(--warning);
}
.chat-btn-preview:hover {
background: rgba(255, 171, 64, 0.2);
border-color: var(--warning);
}
.chat-btn-send {
background: var(--accent-glow);
border: 1px solid rgba(0, 180, 216, 0.3);
color: var(--accent);
}
.chat-btn-send:hover {
background: rgba(0, 180, 216, 0.25);
border-color: var(--accent);
}
.chat-shortcut-hint {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-muted);
text-align: right;
letter-spacing: 0.3px;
}
/* @mention autocomplete */
#mention-dropdown {
display: none;
position: absolute;
bottom: 100%;
left: 12px;
right: 12px;
margin-bottom: 4px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
z-index: 55;
}
#mention-dropdown.visible { display: block; }
.mention-option {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.15s;
font-size: 12px;
}
.mention-option:hover,
.mention-option.active {
background: var(--bg-surface);
}
.mention-dot {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.mention-name {
font-family: var(--font-mono);
font-weight: 500;
color: var(--text-primary);
font-size: 12px;
}
.mention-role {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
margin-left: auto;
}
/* ---- CODE VIEWER MODAL ---- */
#code-modal {
display: none;
position: fixed;
inset: 0;
z-index: 200;
align-items: center;
justify-content: center;
background: rgba(6, 8, 12, 0.85);
backdrop-filter: blur(8px);
}
#code-modal.visible { display: flex; }
.code-modal-inner {
width: min(720px, 90vw);
max-height: 80vh;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5);
animation: modal-in 0.25s ease-out;
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.96) translateY(12px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.code-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.code-modal-title {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 1px;
text-transform: uppercase;
}
.code-modal-close {
all: unset;
width: 28px; height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
font-size: 18px;
transition: all 0.15s;
}
.code-modal-close:hover {
background: var(--bg-surface);
color: var(--text-primary);
}
#code-display {
flex: 1;
margin: 0;
padding: 16px;
background: var(--bg-input);
color: var(--machined-steel);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.7;
overflow: auto;
white-space: pre;
tab-size: 4;
}
/* Syntax coloring */
.kw { color: #c792ea; }
.fn { color: #82aaff; }
.cm { color: #546e7a; }
.st { color: #c3e88d; }
.nu { color: #f78c6c; }
.op { color: #89ddff; }
/* ---- GALLERY MODAL ---- */
#gallery-modal {
display: none;
position: fixed;
inset: 0;
z-index: 200;
align-items: center;
justify-content: center;
background: rgba(6, 8, 12, 0.85);
backdrop-filter: blur(8px);
}
#gallery-modal.visible { display: flex; }
.gallery-modal-inner {
width: min(800px, 90vw);
max-height: 80vh;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5);
animation: modal-in 0.25s ease-out;
}
.gallery-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.gallery-modal-title {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 1px;
text-transform: uppercase;
}
.gallery-grid {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-content: flex-start;
}
.gallery-empty {
width: 100%;
text-align: center;
padding: 40px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.5px;
}
.gallery-card {
all: unset;
flex: 0 0 auto;
width: 180px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
gap: 8px;
}
.gallery-card:hover {
border-color: var(--accent-dim);
background: var(--bg-input);
}
.gallery-card-name {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gallery-card-meta {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-muted);
display: flex;
gap: 8px;
}
.gallery-card-downloads {
display: flex;
gap: 6px;
margin-top: 4px;
}
.gallery-dl {
all: unset;
font-family: var(--font-mono);
font-size: 9px;
font-weight: 600;
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: 3px;
color: var(--accent);
cursor: pointer;
text-decoration: none;
transition: all 0.15s;
}
.gallery-dl:hover {
background: var(--accent-glow);
border-color: var(--accent-dim);
}
/* ---- ANIMATIONS ---- */
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fade-in-up 0.3s ease-out both;
}
/* ---- TAB SWITCHER ---- */
.chat-tabs {
display: flex;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
flex-shrink: 0;
}
.chat-tab {
flex: 1;
padding: 8px 0;
text-align: center;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
background: none;
border: none;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.2s, border-color 0.2s;
}
.chat-tab:hover { color: var(--text-primary); }
.chat-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
#guided-panel { display: none; overflow-y: auto; flex: 1; padding: 12px; }
#guided-panel.active { display: flex; flex-direction: column; gap: 12px; }
#chat-messages.hidden { display: none; }
/* ---- WIZARD STEPS ---- */
.wizard-step {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
}
.wizard-step.completed { border-color: var(--success); }
.wizard-step-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 8px;
}
.wizard-step-title {
font-family: var(--font-mono); font-size: 11px;
color: var(--text-secondary); font-weight: 600;
}
.wizard-step-check { color: var(--success); font-size: 14px; }
.wizard-chips {
display: flex; flex-wrap: wrap; gap: 6px;
}
.wizard-chip {
padding: 5px 12px; border-radius: 14px;
font-family: var(--font-mono); font-size: 11px;
background: var(--bg-input); border: 1px solid var(--border);
color: var(--text-primary); cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.wizard-chip:hover { border-color: var(--accent); }
.wizard-chip.selected {
border-color: var(--accent); background: var(--accent-glow);
color: var(--accent);
}
.wizard-input {
width: 100%; padding: 6px 10px; margin-top: 6px;
background: var(--bg-input); border: 1px solid var(--border);
border-radius: 6px; color: var(--text-primary);
font-family: var(--font-mono); font-size: 12px;
}
.wizard-input:focus { outline: none; border-color: var(--accent); }
.wizard-dim-row {
display: flex; gap: 8px; align-items: center; margin-top: 6px;
}
.wizard-dim-label {
font-family: var(--font-mono); font-size: 11px;
color: var(--text-secondary); min-width: 50px;
}
.wizard-dim-input {
width: 80px; padding: 4px 8px;
background: var(--bg-input); border: 1px solid var(--border);
border-radius: 4px; color: var(--text-primary);
font-family: var(--font-mono); font-size: 12px;
}
.wizard-dim-unit {
font-family: var(--font-mono); font-size: 10px;
color: var(--text-muted);
}
.wizard-review-field {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 0; border-bottom: 1px solid var(--border);
}
.wizard-review-label {
font-family: var(--font-mono); font-size: 10px;
color: var(--text-secondary);
}
.wizard-review-value {
font-family: var(--font-mono); font-size: 11px;
color: var(--text-primary);
}
.wizard-btn-row { display: flex; gap: 8px; margin-top: 10px; }
.wizard-btn {
flex: 1; padding: 8px; border-radius: 6px;
font-family: var(--font-mono); font-size: 11px;
font-weight: 600; cursor: pointer; border: 1px solid var(--border);
transition: background 0.15s;
}
.wizard-btn-primary {
background: var(--accent); color: var(--bg-void); border-color: var(--accent);
}
.wizard-btn-secondary {
background: var(--bg-surface); color: var(--text-secondary);
}
/* ---- PLAN CARD ---- */
.plan-card {
background: var(--bg-surface);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 14px;
margin: 8px 0;
}
.plan-card-title {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
color: var(--accent);
margin-bottom: 10px;
}
.plan-card .wizard-review-field { padding: 3px 0; }
.plan-card-score {
font-family: var(--font-mono); font-size: 11px;
color: var(--text-secondary); margin-top: 8px;
}
.plan-card-actions { display: flex; gap: 8px; margin-top: 10px; }
.plan-card-btn {
flex: 1; padding: 7px; border-radius: 5px;
font-family: var(--font-mono); font-size: 11px;
font-weight: 600; cursor: pointer; border: 1px solid var(--border);
}
.plan-card-approve {
background: var(--success); color: var(--bg-void); border-color: var(--success);
}
.plan-card-reject {
background: var(--bg-surface); color: var(--text-secondary);
}
.plan-card-save {
background: var(--accent); color: var(--bg-void); border-color: var(--accent);
}
.plan-field-actions {
display: inline-flex; gap: 4px; margin-left: 6px; vertical-align: middle;
}
.plan-field-btn {
background: none; border: 1px solid var(--border); border-radius: 3px;
color: var(--text-secondary); cursor: pointer; font-size: 10px;
padding: 1px 5px; font-family: var(--font-mono); line-height: 1.4;
}
.plan-field-btn:hover { border-color: var(--accent); color: var(--accent); }
.plan-field-input {
background: var(--bg-void); border: 1px solid var(--accent); border-radius: 3px;
color: var(--text-primary); font-family: var(--font-mono); font-size: 11px;
padding: 2px 6px; width: 60%;
}
.plan-field-input:focus { outline: none; border-color: var(--success); }
.plan-dim-group { display: inline-flex; gap: 4px; }
.plan-dim-input {
background: var(--bg-void); border: 1px solid var(--accent); border-radius: 3px;
color: var(--text-primary); font-family: var(--font-mono); font-size: 11px;
padding: 2px 4px; width: 60px;
}
.plan-dim-input:focus { outline: none; border-color: var(--success); }
.plan-dim-label {
font-size: 9px; color: var(--text-secondary); font-family: var(--font-mono);
}
.plan-notes {
width: 100%; min-height: 48px; margin-top: 8px; padding: 6px 8px;
background: var(--bg-void); border: 1px solid var(--border); border-radius: 5px;
color: var(--text-primary); font-family: var(--font-mono); font-size: 11px;
resize: vertical;
}
.plan-notes:focus { outline: none; border-color: var(--accent); }
.plan-score-dual {
display: flex; gap: 16px; font-family: var(--font-mono); font-size: 11px;
color: var(--text-secondary); margin-top: 8px;
}
.plan-score-current { font-weight: 600; }
.plan-score-current.score-ok { color: var(--success); }
.plan-score-current.score-low { color: var(--warning, #ffab40); }
/* ---- GAP / QUESTION CARDS ---- */
.gap-cards {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin: 8px 0;
}
.gap-cards-title {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
color: var(--warning);
margin-bottom: 10px;
}
.gap-card {
padding: 10px;
margin-bottom: 8px;
border-left: 3px solid var(--border);
background: var(--bg-input);
border-radius: 0 6px 6px 0;
}
.gap-card:last-child { margin-bottom: 0; }
.gap-card-header {
display: flex; align-items: center; gap: 6px;
margin-bottom: 6px;
}
.gap-card-dot {
width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0;
}
.gap-card-agent {
font-family: var(--font-mono); font-size: 10px;
color: var(--text-secondary); font-weight: 600;
}
.gap-card-question {
font-family: var(--font-body); font-size: 12px;
color: var(--text-primary); margin-bottom: 8px;
}
.gap-card .wizard-chips { margin-bottom: 0; }
.gap-card-submit {
margin-top: 8px; padding: 5px 14px;
background: var(--accent); color: var(--bg-void);
border: none; border-radius: 4px;
font-family: var(--font-mono); font-size: 11px;
font-weight: 600; cursor: pointer;
}
.gap-card[data-severity="blocking"] {
border-left-color: var(--error);
}
.gap-card[data-severity="nice_to_have"] {
opacity: 0.7;
}
.gap-card-badge {
font-family: var(--font-mono);
font-size: 9px;
font-weight: 700;
color: var(--error);
margin-left: 6px;
text-transform: uppercase;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.logo-sub { display: none; }
:root { --chat-width: 100vw; }
#chat-toggle { display: none; }
.gallery-btn span { display: none; }
}
</style>
</head>
<body class="chat-open">
<div id="app">
<!-- ---- TOP BAR ---- -->
<div id="topbar">
<div class="logo">
<span class="logo-diamond">&#9670;</span>
<span class="logo-text">NeuralCAD</span>
<span class="logo-sub" data-i18n="subtitle">Multi-Agent Design</span>
</div>
<div class="topbar-right">
<div class="lang-toggle">
<button class="lang-btn active" data-lang="en" onclick="setLang('en')">EN</button>
<button class="lang-btn" data-lang="zh-TW" onclick="setLang('zh-TW')">&#x4E2D;</button>
<button class="lang-btn" data-lang="vi" onclick="setLang('vi')">VI</button>
</div>
<div class="backend-toggle">
<button id="btn-gemini" class="active" onclick="setBackend('gemini')">GEMINI</button>
<button id="btn-openai" onclick="setBackend('openai')">OPENAI</button>
</div>
<select id="model-select" class="model-select" title="Select model"></select>
<button class="gallery-btn" onclick="openGallery()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
<span data-i18n="gallery">GALLERY</span>
</button>
<div class="status-dot" id="status-dot" data-i18n="serverConnected" data-i18n-attr="title" title="Server Connected"></div>
</div>
</div>
<!-- ---- MAIN AREA ---- -->
<div id="main">
<!-- 3D Viewer -->
<div id="viewer-container">
<canvas id="viewer-canvas"></canvas>
<div id="geo-stats">
<div><span class="stat-label">VOL </span><span class="stat-value" id="stat-volume">&mdash;</span></div>
<div><span class="stat-label">BBOX </span><span class="stat-value" id="stat-bbox">&mdash;</span></div>
<div><span class="stat-label">FACES </span><span class="stat-value" id="stat-faces">&mdash;</span><span class="stat-label"> EDGES </span><span class="stat-value" id="stat-edges">&mdash;</span></div>
</div>
<div id="cnc-badge">
<div class="badge badge-success" id="badge-cnc"></div>
<div class="badge badge-info" id="badge-axis"></div>
</div>
<div id="download-btns">
<a class="dl-btn" id="dl-step" download>STEP</a>
<a class="dl-btn" id="dl-stl" download>STL</a>
<a class="dl-btn" id="dl-3mf" download>3MF</a>
<a id="dl-gcode" class="dl-btn" download style="display:none"><span class="dl-icon">&#8615;</span> G-CODE</a>
<a class="dl-btn" id="dl-report" download>REPORT</a>
</div>
<div id="viewer-hint" data-i18n="viewerHint">DRAG ROTATE &middot; SCROLL ZOOM &middot; RIGHT-DRAG PAN</div>
<div id="viewer-loading">
<div class="spinner"></div>
<div class="loading-text" id="loading-msg" data-i18n="generatingModel">GENERATING MODEL...</div>
</div>
<div id="view-toolbar" style="position:absolute;top:8px;right:8px;z-index:10;display:none;gap:4px;">
<button class="view-btn active" id="view-part" onclick="setViewMode('part')" style="padding:4px 10px;font-size:11px;font-family:var(--font-mono);background:var(--bg-surface);color:var(--text-secondary);border:1px solid var(--border);border-radius:4px;cursor:pointer;">Part</button>
<button class="view-btn" id="view-toolpath" onclick="setViewMode('toolpath')" style="padding:4px 10px;font-size:11px;font-family:var(--font-mono);background:var(--bg-surface);color:var(--text-secondary);border:1px solid var(--border);border-radius:4px;cursor:pointer;">Toolpath</button>
<button class="view-btn" id="view-overlay" onclick="setViewMode('overlay')" style="padding:4px 10px;font-size:11px;font-family:var(--font-mono);background:var(--bg-surface);color:var(--text-secondary);border:1px solid var(--border);border-radius:4px;cursor:pointer;">Overlay</button>
</div>
<div id="viewer-empty">
<div class="empty-icon"><div class="empty-icon-inner"></div></div>
<div class="empty-text" data-i18n="emptyViewer">Start a conversation to<br>design your part</div>
</div>
</div>
<!-- Chat Panel -->
<div id="chat-panel">
<button id="chat-toggle" onclick="toggleChat()" title="Toggle chat panel">&#9664;</button>
<div class="chat-tabs">
<button class="chat-tab active" id="tab-chat" onclick="switchTab('chat')">Chat</button>
<button class="chat-tab" id="tab-guided" onclick="switchTab('guided')">Guided</button>
</div>
<div class="chat-header">
<div class="chat-header-left">
<span class="chat-header-title" data-i18n="designChat">Design Chat</span>
<button onclick="newDesign()" title="New Design" style="background:none;border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);padding:2px 8px;font-size:10px;cursor:pointer;margin-left:8px;" data-i18n="newBtn">NEW</button>
<div class="agent-dots">
<div class="agent-dot" style="background: var(--agent-design);" title="Design Agent"></div>
<div class="agent-dot" style="background: var(--agent-engineering);" title="Engineering Agent"></div>
<div class="agent-dot" style="background: var(--agent-cnc);" title="CNC Agent"></div>
<div class="agent-dot" style="background: var(--agent-cad);" title="CAD Coder Agent"></div>
</div>
</div>
</div>
<div id="chat-messages">
<div class="quick-examples" id="quick-examples">
<div class="quick-examples-label" data-i18n="quickStart">Quick Start</div>
<div class="quick-chips">
<button class="quick-chip" data-i18n="quickServo" onclick="quickSendI18n('quickServo')">Design a servo bracket</button>
<button class="quick-chip" data-i18n="quickGear" onclick="quickSendI18n('quickGear')">I need a spur gear</button>
<button class="quick-chip" data-i18n="quickHeatsink" onclick="quickSendI18n('quickHeatsink')">Create a heatsink</button>
<button class="quick-chip" data-i18n="quickFlange" onclick="quickSendI18n('quickFlange')">Design a pipe flange</button>
</div>
</div>
</div>
<div id="guided-panel">
<div class="wizard-step" id="wiz-step-1">
<div class="wizard-step-header"><span class="wizard-step-title">1. PART TYPE</span><span class="wizard-step-check" id="wiz-check-1"></span></div>
<div class="wizard-chips">
<button class="wizard-chip" onclick="wizSetPart('bracket','Mounting bracket')">Bracket</button>
<button class="wizard-chip" onclick="wizSetPart('enclosure','Enclosure housing')">Enclosure</button>
<button class="wizard-chip" onclick="wizSetPart('plate','Flat plate')">Plate</button>
<button class="wizard-chip" onclick="wizSetPart('shaft','Cylindrical shaft')">Shaft</button>
<button class="wizard-chip" onclick="wizSetPart('gear','Spur gear')">Gear</button>
<button class="wizard-chip" onclick="wizSetPart('flange','Pipe flange')">Flange</button>
</div>
<input class="wizard-input" placeholder="Or type custom part name..." onchange="wizSetPart(this.value, this.value)">
</div>
<div class="wizard-step" id="wiz-step-2">
<div class="wizard-step-header"><span class="wizard-step-title">2. MATERIAL</span><span class="wizard-step-check" id="wiz-check-2"></span></div>
<div class="wizard-chips">
<button class="wizard-chip" onclick="wizSetMaterial('aluminum 6061')">Aluminum 6061</button>
<button class="wizard-chip" onclick="wizSetMaterial('aluminum 7075')">Aluminum 7075</button>
<button class="wizard-chip" onclick="wizSetMaterial('stainless steel 304')">Steel 304</button>
<button class="wizard-chip" onclick="wizSetMaterial('stainless steel 316')">Steel 316</button>
<button class="wizard-chip" onclick="wizSetMaterial('brass')">Brass</button>
<button class="wizard-chip" onclick="wizSetMaterial('titanium')">Titanium</button>
<button class="wizard-chip" onclick="wizSetMaterial('nylon')">Nylon</button>
<button class="wizard-chip" onclick="wizSetMaterial('delrin')">Delrin</button>
</div>
<input class="wizard-input" placeholder="Or type custom material..." onchange="wizSetMaterial(this.value)">
</div>
<div class="wizard-step" id="wiz-step-3">
<div class="wizard-step-header"><span class="wizard-step-title">3. DIMENSIONS (mm)</span><span class="wizard-step-check" id="wiz-check-3"></span></div>
<div class="wizard-dim-row"><span class="wizard-dim-label">Width</span><input class="wizard-dim-input" id="wiz-dim-width" type="number" onchange="wizSetDim('width', this.value)"><span class="wizard-dim-unit">mm</span></div>
<div class="wizard-dim-row"><span class="wizard-dim-label">Height</span><input class="wizard-dim-input" id="wiz-dim-height" type="number" onchange="wizSetDim('height', this.value)"><span class="wizard-dim-unit">mm</span></div>
<div class="wizard-dim-row"><span class="wizard-dim-label">Depth</span><input class="wizard-dim-input" id="wiz-dim-depth" type="number" onchange="wizSetDim('depth', this.value)"><span class="wizard-dim-unit">mm</span></div>
</div>
<div class="wizard-step" id="wiz-step-4">
<div class="wizard-step-header"><span class="wizard-step-title">4. FEATURES</span><span class="wizard-step-check" id="wiz-check-4"></span></div>
<div class="wizard-chips">
<button class="wizard-chip" id="wiz-feat-holes" onclick="wizToggleFeature(this, 'holes')">Mounting Holes</button>
<button class="wizard-chip" id="wiz-feat-fillets" onclick="wizToggleFeature(this, 'fillets')">Fillets</button>
<button class="wizard-chip" id="wiz-feat-chamfers" onclick="wizToggleFeature(this, 'chamfers')">Chamfers</button>
<button class="wizard-chip" id="wiz-feat-pockets" onclick="wizToggleFeature(this, 'pockets')">Pockets</button>
<button class="wizard-chip" id="wiz-feat-slots" onclick="wizToggleFeature(this, 'slots')">Slots</button>
</div>
<div id="wiz-holes-config" style="display:none;margin-top:8px;">
<div class="wizard-dim-row"><span class="wizard-dim-label">Count</span><input class="wizard-dim-input" id="wiz-hole-count" type="number" value="4" min="1" onchange="wizUpdateHoles()"></div>
<div class="wizard-chips" style="margin-top:6px;">
<button class="wizard-chip" id="wiz-hole-m3" onclick="wizSetHoleSize('M3')">M3</button>
<button class="wizard-chip" id="wiz-hole-m4" onclick="wizSetHoleSize('M4')">M4</button>
<button class="wizard-chip selected" id="wiz-hole-m6" onclick="wizSetHoleSize('M6')">M6</button>
<button class="wizard-chip" id="wiz-hole-m8" onclick="wizSetHoleSize('M8')">M8</button>
</div>
</div>
<input class="wizard-input" placeholder="Or type custom feature..." onkeydown="if(event.key==='Enter'){wizAddCustomFeature(this.value);this.value='';}">
</div>
<div class="wizard-step" id="wiz-step-5">
<div class="wizard-step-header"><span class="wizard-step-title">5. CONSTRAINTS</span><span class="wizard-step-check" id="wiz-check-5"></span></div>
<div class="wizard-dim-row"><span class="wizard-dim-label">Min wall</span><input class="wizard-dim-input" id="wiz-min-wall" type="number" value="3" step="0.5" onchange="wizUpdateConstraints()"><span class="wizard-dim-unit">mm</span></div>
<div class="wizard-dim-row"><span class="wizard-dim-label">Max size</span><input class="wizard-dim-input" id="wiz-max-size" type="number" value="500" onchange="wizUpdateConstraints()"><span class="wizard-dim-unit">mm</span></div>
</div>
<div class="wizard-step" id="wiz-step-6">
<div class="wizard-step-header"><span class="wizard-step-title">6. MACHINING</span><span class="wizard-step-check" id="wiz-check-6"></span></div>
<div class="wizard-chips">
<button class="wizard-chip" onclick="wizSetAxis('3-axis', this)">3-axis</button>
<button class="wizard-chip" onclick="wizSetAxis('3+2-axis', this)">3+2-axis</button>
<button class="wizard-chip" onclick="wizSetAxis('5-axis', this)">5-axis</button>
<button class="wizard-chip" onclick="wizSetAxis('', this)">Auto</button>
</div>
</div>
<div class="wizard-step" id="wiz-step-7">
<div class="wizard-step-header"><span class="wizard-step-title">7. REVIEW</span><span class="wizard-step-check" id="wiz-check-7"></span></div>
<div id="wiz-review-content"></div>
<div id="wiz-score" style="font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);margin-top:8px;"></div>
<div class="wizard-btn-row">
<button class="wizard-btn wizard-btn-primary" onclick="wizApprove()">Approve &amp; Generate</button>
<button class="wizard-btn wizard-btn-secondary" onclick="switchTab('chat')">Back to Chat</button>
</div>
</div>
</div>
<div class="chat-input-area" style="position: relative;">
<div id="mention-dropdown">
<div class="mention-option" data-agent="design" onclick="insertMention('design')">
<div class="mention-dot" style="background: var(--agent-design);"></div>
<span class="mention-name">@design</span>
<span class="mention-role">Design Agent</span>
</div>
<div class="mention-option" data-agent="engineering" onclick="insertMention('engineering')">
<div class="mention-dot" style="background: var(--agent-engineering);"></div>
<span class="mention-name">@engineering</span>
<span class="mention-role">Engineering Agent</span>
</div>
<div class="mention-option" data-agent="cnc" onclick="insertMention('cnc')">
<div class="mention-dot" style="background: var(--agent-cnc);"></div>
<span class="mention-name">@cnc</span>
<span class="mention-role">CNC Agent</span>
</div>
<div class="mention-option" data-agent="cad" onclick="insertMention('cad')">
<div class="mention-dot" style="background: var(--agent-cad);"></div>
<span class="mention-name">@cad</span>
<span class="mention-role">CAD Coder</span>
</div>
</div>
<div class="chat-input-row">
<textarea id="chat-input" rows="1" data-i18n="inputPlaceholder" placeholder="Describe your part... (@design @engineering @cnc @cad)"></textarea>
<button class="chat-btn chat-btn-preview" onclick="sendPreview()" title="Generate 3D preview">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="chat-btn chat-btn-send" onclick="sendFromInput()" title="Send message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
</button>
</div>
<div class="chat-shortcut-hint">Ctrl+Enter to send</div>
</div>
</div>
</div>
</div>
<!-- Floating open pill (when chat collapsed) -->
<div id="chat-open-pill" onclick="toggleChat()">
<span>Open Chat</span>
<div class="pill-dots">
<div class="pill-dot" style="background: var(--agent-design);"></div>
<div class="pill-dot" style="background: var(--agent-engineering);"></div>
<div class="pill-dot" style="background: var(--agent-cnc);"></div>
<div class="pill-dot" style="background: var(--agent-cad);"></div>
</div>
<span>&#9654;</span>
</div>
<!-- Code Viewer Modal -->
<div id="code-modal">
<div class="code-modal-inner">
<div class="code-modal-header">
<span class="code-modal-title">CadQuery Code</span>
<button class="code-modal-close" onclick="closeCodeModal()">&times;</button>
</div>
<pre id="code-display"></pre>
</div>
</div>
<!-- Gallery Modal -->
<div id="gallery-modal">
<div class="gallery-modal-inner">
<div class="gallery-modal-header">
<span class="gallery-modal-title" data-i18n="galleryTitle">Model Gallery</span>
<button class="code-modal-close" onclick="closeGallery()">&times;</button>
</div>
<div class="gallery-grid" id="gallery-grid">
<div class="gallery-empty">No models generated yet.</div>
</div>
</div>
</div>
<script>
// ── STATE ─────────────────────────────────────────────
let currentBackend = 'gemini';
let currentModel = '';
let backendModels = {};
let chatHistory = [];
let designState = {};
let chatPanelOpen = true;
let currentPartName = '';
let currentCode = '';
let scene, camera, renderer, controls, currentMesh, gridHelper;
const galleryItems = [];
let mentionActive = false;
let mentionIndex = 0;
let currentLang = localStorage.getItem('neuralcad_lang') || 'en';
// ── WIZARD STATE ──────────────────────────────────────
let activeTab = 'chat';
let wizHoleSize = 'M6';
let wizFeatures = new Set();
function switchTab(tab) {
activeTab = tab;
document.getElementById('tab-chat').classList.toggle('active', tab === 'chat');
document.getElementById('tab-guided').classList.toggle('active', tab === 'guided');
document.getElementById('chat-messages').classList.toggle('hidden', tab === 'guided');
document.querySelector('.chat-header').style.display = tab === 'chat' ? 'flex' : 'none';
document.getElementById('guided-panel').classList.toggle('active', tab === 'guided');
if (tab === 'guided') syncWizardFromState();
}
function wizSetPart(name, desc) {
designState.part_name = name;
designState.description = desc;
wizMarkStep(1); saveState();
document.querySelectorAll('#wiz-step-1 .wizard-chip').forEach(c => c.classList.remove('selected'));
if (event && event.target) event.target.classList.add('selected');
}
function wizSetMaterial(mat) {
designState.material = mat;
wizMarkStep(2); saveState();
document.querySelectorAll('#wiz-step-2 .wizard-chip').forEach(c => c.classList.remove('selected'));
if (event && event.target) event.target.classList.add('selected');
}
function wizSetDim(name, val) {
if (!designState.dimensions) designState.dimensions = {};
const v = parseFloat(val);
if (v > 0) designState.dimensions[name] = v;
else delete designState.dimensions[name];
wizMarkStep(3); saveState();
}
function wizToggleFeature(el, feat) {
if (wizFeatures.has(feat)) {
wizFeatures.delete(feat);
el.classList.remove('selected');
} else {
wizFeatures.add(feat);
el.classList.add('selected');
}
if (feat === 'holes') {
document.getElementById('wiz-holes-config').style.display = wizFeatures.has('holes') ? 'block' : 'none';
}
wizRebuildFeatures();
wizMarkStep(4); saveState();
}
function wizSetHoleSize(size) {
wizHoleSize = size;
document.querySelectorAll('#wiz-holes-config .wizard-chip').forEach(c => c.classList.remove('selected'));
document.getElementById('wiz-hole-' + size.toLowerCase()).classList.add('selected');
wizRebuildFeatures();
saveState();
}
function wizUpdateHoles() { wizRebuildFeatures(); saveState(); }
function wizRebuildFeatures() {
if (!designState.features) designState.features = [];
const custom = designState.features.filter(f => !['fillets','chamfers','pockets','slots'].includes(f) && !/^\d+x M\d+ holes$/.test(f));
designState.features = [];
if (wizFeatures.has('holes')) {
const count = document.getElementById('wiz-hole-count')?.value || 4;
designState.features.push(count + 'x ' + wizHoleSize + ' holes');
}
if (wizFeatures.has('fillets')) designState.features.push('fillets');
if (wizFeatures.has('chamfers')) designState.features.push('chamfers');
if (wizFeatures.has('pockets')) designState.features.push('pockets');
if (wizFeatures.has('slots')) designState.features.push('slots');
designState.features.push(...custom);
}
function wizAddCustomFeature(val) {
if (!val.trim()) return;
if (!designState.features) designState.features = [];
designState.features.push(val.trim());
wizMarkStep(4); saveState();
}
function wizUpdateConstraints() {
designState.constraints = [];
const wall = document.getElementById('wiz-min-wall')?.value;
const size = document.getElementById('wiz-max-size')?.value;
if (wall) designState.constraints.push('min wall ' + wall + 'mm');
if (size && parseFloat(size) < 500) designState.constraints.push('max size ' + size + 'mm');
wizMarkStep(5); saveState();
}
function wizSetAxis(axis, el) {
designState.axis_recommendation = axis;
document.querySelectorAll('#wiz-step-6 .wizard-chip').forEach(c => c.classList.remove('selected'));
if (el) el.classList.add('selected');
wizMarkStep(6); saveState();
}
function wizMarkStep(n) {
const check = document.getElementById('wiz-check-' + n);
const step = document.getElementById('wiz-step-' + n);
if (check) check.textContent = '\u2713';
if (step) step.classList.add('completed');
if (n <= 6) wizUpdateReview();
}
function wizUpdateReview() {
const el = document.getElementById('wiz-review-content');
if (!el) return;
const fields = [
['Part', designState.part_name || ''],
['Material', designState.material || ''],
['Dimensions', Object.entries(designState.dimensions || {}).map(([k,v]) => k + '=' + v + 'mm').join(', ') || ''],
['Features', (designState.features || []).join(', ') || ''],
['Constraints', (designState.constraints || []).join(', ') || ''],
['Machining', designState.axis_recommendation || 'Auto'],
];
let html = '';
for (const [label, value] of fields) {
html += '<div class="wizard-review-field"><span class="wizard-review-label">' + escapeHtml(label) + '</span><span class="wizard-review-value">' + escapeHtml(value || '\u2014') + '</span></div>';
}
el.innerHTML = html;
const score = wizComputeScore();
const scoreEl = document.getElementById('wiz-score');
if (scoreEl) scoreEl.textContent = 'Score: ' + score.toFixed(0) + '/8 ' + (score >= 8 ? '\u2713 Ready' : '\u2717 Need more info');
}
function wizComputeScore() {
let s = 0;
if (designState.material) s += 3;
if (designState.part_name) s += 1;
if (designState.description) s += 1;
if (designState.axis_recommendation) s += 2;
s += Math.min(Object.keys(designState.dimensions || {}).length, 4);
s += Math.min((designState.features || []).length, 4);
s += Math.min((designState.constraints || []).length, 2);
return s;
}
async function wizApprove() {
const plan = {
part_name: designState.part_name || '',
description: designState.description || '',
material: designState.material || '',
dimensions: designState.dimensions || {},
features: designState.features || [],
constraints: designState.constraints || [],
axis_recommendation: designState.axis_recommendation || '',
machining_notes: [],
confidence_score: wizComputeScore(),
};
try {
const resp = await fetch('/api/plan/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan: plan, design_state: designState }),
});
const data = await resp.json();
designState = data.design_state;
saveState();
switchTab('chat');
await sendMessage('Generate the approved design');
} catch (err) {
console.error('Plan approve failed:', err);
}
}
function syncWizardFromState() {
const dims = designState.dimensions || {};
for (const key of ['width', 'height', 'depth']) {
const el = document.getElementById('wiz-dim-' + key);
if (el && dims[key]) el.value = dims[key];
}
if (designState.part_name) wizMarkStep(1);
if (designState.material) wizMarkStep(2);
if (Object.keys(dims).length > 0) wizMarkStep(3);
if ((designState.features || []).length > 0) wizMarkStep(4);
if ((designState.constraints || []).length > 0) wizMarkStep(5);
if (designState.axis_recommendation) wizMarkStep(6);
wizUpdateReview();
}
// ── PLAN CARD ─────────────────────────────────────────
var PLAN_FIELD_AGENTS = {
part_name: 'design', material: 'engineering', dimensions: 'engineering',
features: 'design', constraints: 'cnc', axis_recommendation: 'cnc',
machining_notes: 'cnc',
};
var PLAN_FIELD_QUESTIONS = {
part_name: 'What should this part be called?',
material: 'What material do you recommend?',
dimensions: 'What dimensions are appropriate?',
features: 'What features should this part have?',
constraints: 'What manufacturing constraints should we consider?',
axis_recommendation: 'What axis strategy do you recommend?',
machining_notes: 'Any machining notes to consider?',
};
function renderPlanCard(plan) {
var fields = [
['Part', 'part_name', plan.part_name, 'text'],
['Material', 'material', plan.material, 'text'],
['Dimensions', 'dimensions', plan.dimensions, 'dimensions'],
['Features', 'features', plan.features, 'list'],
['Constraints', 'constraints', plan.constraints, 'list'],
['Axis', 'axis_recommendation', plan.axis_recommendation || 'Auto', 'text'],
];
if (plan.machining_notes && plan.machining_notes.length) {
fields.push(['Notes', 'machining_notes', plan.machining_notes, 'list']);
}
var html = '<div class="plan-card" id="active-plan-card" data-original-score="' + (plan.confidence_score || 0) + '">';
html += '<div class="plan-card-title">\u25c6 ' + t('planReady') + '</div>';
for (var i = 0; i < fields.length; i++) {
var label = fields[i][0], key = fields[i][1], value = fields[i][2], type = fields[i][3];
html += '<div class="wizard-review-field" data-field="' + key + '">';
html += '<span class="wizard-review-label">' + escapeHtml(label) + '</span>';
html += '<span class="wizard-review-value" id="plan-val-' + key + '">';
if (type === 'dimensions') {
html += escapeHtml(Object.entries(value || {}).map(function(e) { return e[0] + '=' + e[1] + 'mm'; }).join(', ') || '\u2014');
} else if (type === 'list') {
html += escapeHtml((value || []).join(', ') || '\u2014');
} else {
html += escapeHtml(value || '\u2014');
}
html += '</span>';
html += '<span class="plan-field-actions">';
html += '<button class="plan-field-btn" onclick="toggleFieldEdit(\'' + key + '\', \'' + type + '\')" title="Edit">' + t('planEdit') + '</button>';
html += '<button class="plan-field-btn" onclick="askAgentForField(\'' + key + '\')" title="Ask Agent">' + t('planAskAgent') + '</button>';
html += '</span>';
html += '</div>';
}
html += '<textarea class="plan-notes" id="plan-notes" placeholder="' + t('planNotesPlaceholder') + '">' + escapeHtml(plan.notes || '') + '</textarea>';
var origScore = (plan.confidence_score || 0).toFixed(0);
html += '<div class="plan-score-dual">';
html += '<span>' + t('planScoreOriginal') + ': ' + origScore + '/8</span>';
html += '<span>\u2192</span>';
html += '<span class="plan-score-current score-ok" id="plan-current-score">' + t('planScoreCurrent') + ': ' + origScore + '/8</span>';
html += '</div>';
html += '<div class="plan-card-actions">';
html += '<button class="plan-card-btn plan-card-approve" onclick="approvePlanCard()">' + t('planApprove') + '</button>';
html += '<button class="plan-card-btn plan-card-save" onclick="savePlanAndContinue()">' + t('planSave') + '</button>';
html += '<button class="plan-card-btn plan-card-reject" onclick="rejectPlanCard()">' + t('planReject') + '</button>';
html += '</div></div>';
return html;
}
function toggleFieldEdit(fieldKey, fieldType) {
var valEl = document.getElementById('plan-val-' + fieldKey);
if (!valEl) return;
var plan = designState.plan;
if (!plan) return;
var existingInput = valEl.querySelector('input, .plan-dim-group');
if (existingInput) {
if (fieldType === 'dimensions') {
var dimInputs = valEl.querySelectorAll('.plan-dim-input');
var dims = {};
dimInputs.forEach(function(inp) { if (inp.value) dims[inp.dataset.dim] = parseFloat(inp.value) || 0; });
plan.dimensions = dims;
valEl.textContent = Object.entries(dims).map(function(e) { return e[0] + '=' + e[1] + 'mm'; }).join(', ') || '\u2014';
} else if (fieldType === 'list') {
var raw = existingInput.value;
plan[fieldKey] = raw ? raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : [];
valEl.textContent = plan[fieldKey].join(', ') || '\u2014';
} else {
plan[fieldKey] = existingInput.value;
valEl.textContent = plan[fieldKey] || '\u2014';
}
recalcPlanScore();
return;
}
if (fieldType === 'dimensions') {
var dims = plan.dimensions || {};
var dimKeys = Object.keys(dims).length ? Object.keys(dims) : ['width', 'height', 'depth'];
var groupHtml = '<span class="plan-dim-group">';
dimKeys.forEach(function(dk) {
groupHtml += '<span><span class="plan-dim-label">' + escapeHtml(dk) + '</span><input class="plan-dim-input" type="number" data-dim="' + dk + '" value="' + (dims[dk] || '') + '"></span>';
});
groupHtml += '</span>';
valEl.innerHTML = groupHtml;
} else if (fieldType === 'list') {
var current = (plan[fieldKey] || []).join(', ');
valEl.innerHTML = '<input class="plan-field-input" type="text" value="' + escapeHtml(current) + '">';
} else {
var current = plan[fieldKey] || '';
valEl.innerHTML = '<input class="plan-field-input" type="text" value="' + escapeHtml(current) + '">';
}
var firstInput = valEl.querySelector('input');
if (firstInput) firstInput.focus();
}
function recalcPlanScore() {
var plan = designState.plan;
if (!plan) return;
var s = 0;
if (plan.material) s += 3;
if (plan.part_name) s += 1;
if (plan.description) s += 1;
if (plan.axis_recommendation) s += 2;
s += Math.min(Object.keys(plan.dimensions || {}).length, 4);
s += Math.min((plan.features || []).length, 4);
s += Math.min((plan.constraints || []).length, 2);
var el = document.getElementById('plan-current-score');
if (el) {
el.textContent = t('planScoreCurrent') + ': ' + s.toFixed(0) + '/8';
el.className = 'plan-score-current ' + (s >= 8 ? 'score-ok' : 'score-low');
}
}
function askAgentForField(fieldKey) {
var plan = designState.plan;
if (!plan) return;
var agentId = PLAN_FIELD_AGENTS[fieldKey] || 'design';
var question = PLAN_FIELD_QUESTIONS[fieldKey] || 'Can you help with this field?';
var partCtx = plan.part_name ? ' for "' + plan.part_name + '"' : '';
var msg = '@' + agentId + ' Regarding the plan' + partCtx + ': ' + question;
var chatTab = document.querySelector('[data-tab="chat"]');
if (chatTab) chatTab.click();
sendMessage(msg, { planContext: true });
}
function savePlanAndContinue() {
var plan = designState.plan;
if (!plan) return;
var notesEl = document.getElementById('plan-notes');
if (notesEl) plan.notes = notesEl.value;
designState.part_name = plan.part_name;
designState.description = plan.description;
designState.material = plan.material;
designState.dimensions = Object.assign({}, plan.dimensions);
designState.features = (plan.features || []).slice();
designState.constraints = (plan.constraints || []).slice();
designState.axis_recommendation = plan.axis_recommendation;
designState.phase = 'exploring';
saveState();
var card = document.getElementById('active-plan-card');
if (card) card.remove();
}
function updatePlanCardField(fieldKey, value) {
var plan = designState.plan;
if (!plan) return;
var valEl = document.getElementById('plan-val-' + fieldKey);
if (fieldKey === 'dimensions' && typeof value === 'object') {
Object.assign(plan.dimensions, value);
if (valEl) valEl.textContent = Object.entries(plan.dimensions).map(function(e) { return e[0] + '=' + e[1] + 'mm'; }).join(', ') || '\u2014';
} else if (fieldKey === 'features' || fieldKey === 'constraints' || fieldKey === 'machining_notes') {
if (Array.isArray(value)) {
plan[fieldKey] = value;
} else {
if (!plan[fieldKey].includes(value)) plan[fieldKey].push(value);
}
if (valEl) valEl.textContent = plan[fieldKey].join(', ') || '\u2014';
} else {
plan[fieldKey] = value;
if (valEl) valEl.textContent = value || '\u2014';
}
recalcPlanScore();
}
var CATEGORY_KEYWORDS = {
dimension: ['width', 'height', 'depth', 'length', 'diameter', 'dimension', 'size'],
material: ['material', 'alloy', 'grade', 'aluminum', 'steel', 'metal', 'brass', 'titanium', 'nylon'],
feature: ['hole', 'fillet', 'chamfer', 'pocket', 'slot', 'feature'],
constraint: ['tolerance', 'wall thickness', 'constraint', 'min wall', 'max size'],
machining: ['axis', 'machine', 'setup', 'fixture', 'tool access'],
};
var CATEGORY_TO_FIELD = {
material: 'material',
dimension: 'dimensions',
feature: 'features',
constraint: 'constraints',
machining: 'axis_recommendation',
};
function detectPlanField(agentId, content) {
var lower = content.toLowerCase();
for (var cat in CATEGORY_KEYWORDS) {
var expectedAgent = PLAN_FIELD_AGENTS[CATEGORY_TO_FIELD[cat]];
if (expectedAgent !== agentId) continue;
var keywords = CATEGORY_KEYWORDS[cat];
for (var i = 0; i < keywords.length; i++) {
if (lower.indexOf(keywords[i]) !== -1) {
return CATEGORY_TO_FIELD[cat];
}
}
}
return null;
}
function extractFieldValue(fieldKey, content) {
if (fieldKey === 'material') {
var matList = ['aluminum 6061', 'aluminum 7075', 'stainless steel 304', 'stainless steel 316',
'aluminum', 'steel', 'brass', 'copper', 'titanium', 'nylon', 'delrin', 'peek', 'polycarbonate'];
var lower = content.toLowerCase();
for (var i = 0; i < matList.length; i++) {
if (lower.indexOf(matList[i]) !== -1) return matList[i];
}
}
if (fieldKey === 'axis_recommendation') {
var axisMatch = content.match(/(3-axis|3\+2[\s-]*axis|5-axis)/i);
if (axisMatch) return axisMatch[1].toLowerCase();
}
if (fieldKey === 'dimensions') {
var dims = {};
var dimPattern = /(\d+\.?\d*)\s*mm\s+(wide|width|tall|height|high|thick|thickness|deep|depth|long|length|diameter)/gi;
var m;
while ((m = dimPattern.exec(content)) !== null) {
var dimMap = {wide:'width',width:'width',tall:'height',height:'height',high:'height',
thick:'thickness',thickness:'thickness',deep:'depth',depth:'depth',long:'length',length:'length',diameter:'diameter'};
dims[dimMap[m[2].toLowerCase()] || m[2].toLowerCase()] = parseFloat(m[1]);
}
if (Object.keys(dims).length) return dims;
}
var recMatch = content.match(/(?:recommend|suggest|use)\s+(.+?)(?:\.|,|$)/i);
if (recMatch) return recMatch[1].trim();
var quoted = content.match(/"([^"]+)"/);
if (quoted) return quoted[1];
return null;
}
async function approvePlanCard() {
var plan = designState.plan;
if (!plan) return;
var notesEl = document.getElementById('plan-notes');
if (notesEl) plan.notes = notesEl.value;
try {
var resp = await fetch('/api/plan/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan: plan, design_state: designState }),
});
var data = await resp.json();
designState = data.design_state;
saveState();
var card = document.getElementById('active-plan-card');
if (card) card.remove();
await sendMessage('Generate the approved design');
} catch (err) {
console.error('Plan approve failed:', err);
}
}
async function rejectPlanCard() {
try {
const resp = await fetch('/api/plan/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ design_state: designState }),
});
const data = await resp.json();
designState = data.design_state;
saveState();
const card = document.getElementById('active-plan-card');
if (card) card.remove();
} catch (err) {
console.error('Plan reject failed:', err);
}
}
// ── QUESTION CARDS ────────────────────────────────────
let gapSelections = {}; // { category: value } — tracks user selections across cards
function buildGapTitle(cards) {
const counts = { blocking: 0, recommended: 0, nice_to_have: 0 };
for (const c of cards) counts[c.severity || 'recommended']++;
const parts = [];
if (counts.blocking) parts.push(counts.blocking + ' REQUIRED');
if (counts.recommended) parts.push(counts.recommended + ' RECOMMENDED');
if (counts.nice_to_have) parts.push(counts.nice_to_have + ' OPTIONAL');
return parts.join(', ');
}
function renderQuestionCards(cards) {
if (!cards || cards.length === 0) return '';
gapSelections = {};
// Sort by severity: blocking first, recommended second, nice_to_have last
const order = { blocking: 0, recommended: 1, nice_to_have: 2 };
const sorted = [...cards].sort((a, b) => (order[a.severity] || 1) - (order[b.severity] || 1));
let html = '<div class="gap-cards" id="active-gap-cards">';
html += '<div class="gap-cards-title">' + escapeHtml(buildGapTitle(sorted)) + '</div>';
for (const card of sorted) {
const sev = card.severity || 'recommended';
html += '<div class="gap-card" style="border-left-color:' + (sev === 'blocking' ? 'var(--error)' : sev === 'nice_to_have' ? 'var(--border)' : escapeHtml(card.agent_color)) + ';" data-category="' + escapeHtml(card.category) + '" data-severity="' + escapeHtml(sev) + '">';
// Header
html += '<div class="gap-card-header">';
html += '<div class="gap-card-dot" style="background:' + escapeHtml(card.agent_color) + ';"></div>';
html += '<span class="gap-card-agent">' + escapeHtml(card.agent_name) + '</span>';
if (sev === 'blocking') html += '<span class="gap-card-badge">REQUIRED</span>';
html += '</div>';
// Question
html += '<div class="gap-card-question">' + escapeHtml(card.question) + '</div>';
// Generic input: chips + optional custom
if (card.suggestions && card.suggestions.length > 0) {
html += '<div class="wizard-chips">';
for (const s of card.suggestions) {
html += '<button class="wizard-chip" onclick="gapToggleChip(this,\'' + escapeHtml(s) + '\',\'' + escapeHtml(card.category) + '\')">' + escapeHtml(s) + '</button>';
}
html += '</div>';
}
if (card.allow_custom) {
html += '<input class="wizard-input" placeholder="Type your answer..." onchange="gapSetCustom(this.value,\'' + escapeHtml(card.category) + '\')">';
}
html += '</div>';
}
html += '<button class="gap-card-submit" onclick="gapSubmitAll()" style="width:100%;margin-top:4px;">Submit</button>';
html += '</div>';
return html;
}
function removeGapCards() {
const el = document.getElementById('active-gap-cards');
if (el) el.remove();
gapSelections = {};
}
function gapToggleChip(el, value, category) {
// Deselect siblings in the same card
el.closest('.gap-card').querySelectorAll('.wizard-chip').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
gapSelections[category] = value;
// Clear custom input if present
const custom = el.closest('.gap-card').querySelector('.wizard-input');
if (custom) custom.value = '';
}
function gapSetCustom(value, category) {
if (!value.trim()) return;
gapSelections[category] = value.trim();
// Deselect chips in the same card
const card = document.querySelector('.gap-card[data-category="' + category + '"]');
if (card) card.querySelectorAll('.wizard-chip').forEach(c => c.classList.remove('selected'));
}
function gapSubmitAll() {
const parts = [];
for (const [category, value] of Object.entries(gapSelections)) {
if (value) {
const label = category.replace(/_/g, ' ');
parts.push(label + ': ' + value);
}
}
if (parts.length === 0) return;
removeGapCards();
sendMessage(parts.join(', '));
}
// ── i18n ─────────────────────────────────────────────
const I18N = {
en: {
subtitle: 'Multi-Agent Design',
gallery: 'GALLERY',
serverConnected: 'Server Connected',
serverDisconnected: 'Server Disconnected',
designChat: 'Design Chat',
newBtn: 'NEW',
previewBtn: 'PREVIEW',
quickStart: 'Quick Start',
quickServo: 'Design a servo bracket',
quickGear: 'I need a spur gear',
quickHeatsink: 'Create a heatsink',
quickFlange: 'Design a pipe flange',
inputPlaceholder: 'Describe your part... (@design @engineering @cnc @cad)',
viewerHint: 'DRAG ROTATE \u00b7 SCROLL ZOOM \u00b7 RIGHT-DRAG PAN',
generatingModel: 'GENERATING MODEL...',
loadingModel: 'LOADING 3D MODEL...',
loadingModelShort: 'LOADING MODEL...',
emptyViewer: 'Start a conversation to<br>design your part',
agentsThinking: 'Agents are thinking...',
confirm: 'CONFIRM',
revise: 'REVISE',
confirmed: 'CONFIRMED',
regarding: "Regarding {agent}'s suggestion: ",
confirmedMsg: 'Confirmed: {agent}: {content}',
galleryTitle: 'Model Gallery',
galleryEmpty: 'No models generated yet.',
newDesignConfirm: 'Start a new design? Current conversation will be cleared.',
cadFailed: 'CAD execution failed: ',
errorPrefix: 'Error: ',
sendPreviewMsg: '@cad Generate a 3D preview based on our discussion',
tabChat: 'Chat',
tabGuided: 'Guided',
planReady: 'PLAN READY FOR REVIEW',
planApprove: 'Approve',
planReject: 'Reject',
planSave: 'Save & Continue',
planNotesPlaceholder: 'Add notes about this plan...',
planScoreOriginal: 'Original',
planScoreCurrent: 'Current',
planAskAgent: 'Ask',
planEdit: '\u270e',
},
'zh-TW': {
subtitle: '\u591a\u4ee3\u7406\u8a2d\u8a08',
gallery: '\u6a21\u578b\u5eab',
serverConnected: '\u4f3a\u670d\u5668\u5df2\u9023\u7dda',
serverDisconnected: '\u4f3a\u670d\u5668\u5df2\u65b7\u7dda',
designChat: '\u8a2d\u8a08\u5c0d\u8a71',
newBtn: '\u65b0\u5efa',
previewBtn: '\u9810\u89bd',
quickStart: '\u5feb\u901f\u958b\u59cb',
quickServo: '\u8a2d\u8a08\u4f3a\u670d\u652f\u67b6',
quickGear: '\u6211\u9700\u8981\u4e00\u500b\u6b63\u9f52\u8f2a',
quickHeatsink: '\u5efa\u7acb\u6563\u71b1\u5668',
quickFlange: '\u8a2d\u8a08\u7ba1\u6cd5\u862d',
inputPlaceholder: '\u63cf\u8ff0\u60a8\u7684\u96f6\u4ef6... (@design @engineering @cnc @cad)',
viewerHint: '\u62d6\u66f3\u65cb\u8f49 \u00b7 \u6efe\u8f2a\u7e2e\u653e \u00b7 \u53f3\u9375\u62d6\u66f3\u5e73\u79fb',
generatingModel: '\u6b63\u5728\u751f\u6210\u6a21\u578b...',
loadingModel: '\u6b63\u5728\u8f09\u5165 3D \u6a21\u578b...',
loadingModelShort: '\u6b63\u5728\u8f09\u5165\u6a21\u578b...',
emptyViewer: '\u958b\u59cb\u5c0d\u8a71\u4f86<br>\u8a2d\u8a08\u60a8\u7684\u96f6\u4ef6',
agentsThinking: '\u4ee3\u7406\u601d\u8003\u4e2d...',
confirm: '\u78ba\u8a8d',
revise: '\u4fee\u6539',
confirmed: '\u5df2\u78ba\u8a8d',
regarding: "\u95dc\u65bc {agent} \u7684\u5efa\u8b70\uff1a",
confirmedMsg: '\u78ba\u8a8d\uff1a{agent}\uff1a{content}',
galleryTitle: '\u6a21\u578b\u5eab',
galleryEmpty: '\u5c1a\u672a\u751f\u6210\u4efb\u4f55\u6a21\u578b\u3002',
newDesignConfirm: '\u958b\u59cb\u65b0\u8a2d\u8a08\uff1f\u7576\u524d\u5c0d\u8a71\u5c07\u88ab\u6e05\u9664\u3002',
cadFailed: 'CAD \u57f7\u884c\u5931\u6557\uff1a',
errorPrefix: '\u932f\u8aa4\uff1a',
sendPreviewMsg: '@cad \u6839\u64da\u6211\u5011\u7684\u8a0e\u8ad6\u751f\u6210 3D \u9810\u89bd',
tabChat: '\u5c0d\u8a71',
tabGuided: '\u5f15\u5c0e',
planReady: '\u8a08\u756b\u5df2\u6e96\u5099\u5be9\u67e5',
planApprove: '\u6279\u51c6',
planReject: '\u62d2\u7d55',
planSave: '\u5132\u5b58\u4e26\u7e7c\u7e8c',
planNotesPlaceholder: '\u65b0\u589e\u8a08\u756b\u5099\u8a3b...',
planScoreOriginal: '\u539f\u59cb',
planScoreCurrent: '\u7576\u524d',
planAskAgent: '\u8a62\u554f',
planEdit: '\u270e',
},
vi: {
subtitle: 'Thi\u1ebft K\u1ebf \u0110a T\u00e1c T\u1eed',
gallery: 'TH\u01af VI\u1ec6N',
serverConnected: 'M\u00e1y ch\u1ee7 \u0111\u00e3 k\u1ebft n\u1ed1i',
serverDisconnected: 'M\u00e1y ch\u1ee7 \u0111\u00e3 ng\u1eaft k\u1ebft n\u1ed1i',
designChat: 'Tr\u00f2 Chuy\u1ec7n Thi\u1ebft K\u1ebf',
newBtn: 'M\u1edaI',
previewBtn: 'XEM TR\u01af\u1edaC',
quickStart: 'B\u1eaft \u0110\u1ea7u Nhanh',
quickServo: 'Thi\u1ebft k\u1ebf gi\u00e1 \u0111\u1ee1 servo',
quickGear: 'T\u00f4i c\u1ea7n m\u1ed9t b\u00e1nh r\u0103ng th\u1eb3ng',
quickHeatsink: 'T\u1ea1o b\u1ed9 t\u1ea3n nhi\u1ec7t',
quickFlange: 'Thi\u1ebft k\u1ebf m\u1eb7t b\u00edch \u1ed1ng',
inputPlaceholder: 'M\u00f4 t\u1ea3 chi ti\u1ebft c\u1ee7a b\u1ea1n... (@design @engineering @cnc @cad)',
viewerHint: 'K\u00c9O \u0110\u1ec2 XOAY \u00b7 CU\u1ed8N \u0110\u1ec2 ZOOM \u00b7 K\u00c9O PH\u1ea2I \u0110\u1ec2 D\u1ecaCH',
generatingModel: '\u0110ANG T\u1ea0O M\u00d4 H\u00ccNH...',
loadingModel: '\u0110ANG T\u1ea2I M\u00d4 H\u00ccNH 3D...',
loadingModelShort: '\u0110ANG T\u1ea2I M\u00d4 H\u00ccNH...',
emptyViewer: 'B\u1eaft \u0111\u1ea7u cu\u1ed9c tr\u00f2 chuy\u1ec7n \u0111\u1ec3<br>thi\u1ebft k\u1ebf chi ti\u1ebft c\u1ee7a b\u1ea1n',
agentsThinking: 'C\u00e1c t\u00e1c t\u1eed \u0111ang suy ngh\u0129...',
confirm: 'X\u00c1C NH\u1eacN',
revise: 'S\u1eeeA \u0110\u1ed4I',
confirmed: '\u0110\u00c3 X\u00c1C NH\u1eacN',
regarding: "V\u1ec1 g\u1ee3i \u00fd c\u1ee7a {agent}: ",
confirmedMsg: 'X\u00e1c nh\u1eadn: {agent}: {content}',
galleryTitle: 'Th\u01b0 Vi\u1ec7n M\u00f4 H\u00ecnh',
galleryEmpty: 'Ch\u01b0a c\u00f3 m\u00f4 h\u00ecnh n\u00e0o.',
newDesignConfirm: 'B\u1eaft \u0111\u1ea7u thi\u1ebft k\u1ebf m\u1edbi? Cu\u1ed9c tr\u00f2 chuy\u1ec7n hi\u1ec7n t\u1ea1i s\u1ebd b\u1ecb x\u00f3a.',
cadFailed: 'Th\u1ef1c thi CAD th\u1ea5t b\u1ea1i: ',
errorPrefix: 'L\u1ed7i: ',
sendPreviewMsg: '@cad T\u1ea1o b\u1ea3n xem tr\u01b0\u1edbc 3D d\u1ef1a tr\u00ean cu\u1ed9c th\u1ea3o lu\u1eadn c\u1ee7a ch\u00fang ta',
tabChat: 'Tr\u00f2 Chuy\u1ec7n',
tabGuided: 'H\u01b0\u1edbng D\u1eabn',
planReady: 'K\u1ebe HO\u1ea0CH S\u1eb4N S\u00c0NG',
planApprove: 'Duy\u1ec7t',
planReject: 'T\u1eeb Ch\u1ed1i',
planSave: 'L\u01b0u & Ti\u1ebfp T\u1ee5c',
planNotesPlaceholder: 'Th\u00eam ghi ch\u00fa v\u1ec1 k\u1ebf ho\u1ea1ch...',
planScoreOriginal: 'G\u1ed1c',
planScoreCurrent: 'Hi\u1ec7n t\u1ea1i',
planAskAgent: 'H\u1ecfi',
planEdit: '\u270e',
},
};
function t(key) {
return (I18N[currentLang] || I18N.en)[key] || I18N.en[key] || key;
}
function setLang(lang) {
currentLang = lang;
localStorage.setItem('neuralcad_lang', lang);
document.querySelectorAll('.lang-btn').forEach(b => {
b.classList.toggle('active', b.dataset.lang === lang);
});
applyTranslations();
}
function applyTranslations() {
// Static elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
el.placeholder = t(key);
} else if (el.dataset.i18nAttr === 'title') {
el.title = t(key);
} else {
el.innerHTML = t(key);
}
});
// Quick start chips
document.querySelectorAll('.quick-chip').forEach(el => {
if (el.dataset.i18n) el.textContent = t(el.dataset.i18n);
});
}
// Persist/restore from localStorage
function saveState() {
try {
localStorage.setItem('neuralcad_history', JSON.stringify(chatHistory));
localStorage.setItem('neuralcad_state', JSON.stringify(designState));
} catch (e) { /* quota exceeded, ignore */ }
}
function loadState() {
try {
const h = localStorage.getItem('neuralcad_history');
const s = localStorage.getItem('neuralcad_state');
if (h) chatHistory = JSON.parse(h);
if (s) designState = JSON.parse(s);
} catch (e) { /* corrupted, ignore */ }
}
function clearState() {
chatHistory = [];
designState = {};
localStorage.removeItem('neuralcad_history');
localStorage.removeItem('neuralcad_state');
}
function quickSendI18n(key) {
quickSend(t(key));
}
function newDesign() {
if (!confirm(t('newDesignConfirm'))) return;
clearState();
// Clear chat UI
const msgs = document.getElementById('chat-messages');
if (msgs) msgs.innerHTML = '';
// Show examples again
const examples = document.getElementById('quick-examples');
if (examples) examples.style.display = '';
// Clear 3D viewer
if (currentMesh) {
scene.remove(currentMesh);
currentMesh.geometry.dispose();
currentMesh.material.dispose();
currentMesh = null;
}
// Hide overlays
const geo = document.getElementById('geo-stats');
if (geo) geo.classList.remove('visible');
const cnc = document.getElementById('cnc-badge');
if (cnc) cnc.classList.remove('visible');
const dl = document.getElementById('download-btns');
if (dl) dl.classList.remove('visible');
// Show empty state
const empty = document.getElementById('viewer-empty');
if (empty) empty.style.display = '';
}
const AGENTS = {
design: { name: 'Design', color: '#7c3aed', avatar: 'D' },
engineering: { name: 'Engineering', color: '#00b4d8', avatar: 'E' },
cnc: { name: 'CNC', color: '#00e676', avatar: 'C' },
cad: { name: 'CAD Coder', color: '#ffab40', avatar: '{}' },
};
// ── THREE.JS SETUP ────────────────────────────────────
function initViewer() {
const canvas = document.getElementById('viewer-canvas');
const container = document.getElementById('viewer-container');
scene = new THREE.Scene();
// Camera
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 10000);
camera.position.set(120, 80, 120);
// Renderer
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setClearColor(0x06080c, 1);
renderer.shadowMap.enabled = true;
// Lights
const ambient = new THREE.AmbientLight(0x334466, 0.6);
scene.add(ambient);
const dirLight1 = new THREE.DirectionalLight(0xddeeff, 0.8);
dirLight1.position.set(100, 150, 100);
dirLight1.castShadow = true;
scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0x8899bb, 0.4);
dirLight2.position.set(-80, 60, -60);
scene.add(dirLight2);
const rimLight = new THREE.DirectionalLight(0x00b4d8, 0.15);
rimLight.position.set(0, -50, 100);
scene.add(rimLight);
// Grid helper
gridHelper = new THREE.GridHelper(400, 40, 0x1a2636, 0x111822);
gridHelper.position.y = -0.5;
scene.add(gridHelper);
// Controls
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.rotateSpeed = 0.6;
controls.minDistance = 10;
controls.maxDistance = 2000;
// Handle resize
const ro = new ResizeObserver(() => {
const w = container.clientWidth;
const h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
ro.observe(container);
animate();
}
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
// ── G-CODE PARSER & TOOLPATH RENDERER ─────────────────
function parseGCode(gcodeString) {
const segments = [];
let pos = { x: 0, y: 0, z: 0 };
let mode = 'G0';
let feed = 0;
let tool = 0;
let absolute = true;
for (const rawLine of gcodeString.split('\n')) {
const line = rawLine.split(';')[0].split('(')[0].trim();
if (!line) continue;
const gMatch = line.match(/G(0?[0-3]|9[01])\b/);
const xMatch = line.match(/X([-\d.]+)/);
const yMatch = line.match(/Y([-\d.]+)/);
const zMatch = line.match(/Z([-\d.]+)/);
const fMatch = line.match(/F([\d.]+)/);
const tMatch = line.match(/T(\d+)/);
const iMatch = line.match(/I([-\d.]+)/);
const jMatch = line.match(/J([-\d.]+)/);
if (gMatch) {
const code = parseInt(gMatch[1]);
if (code <= 3) mode = 'G' + code;
else if (code === 90) absolute = true;
else if (code === 91) absolute = false;
}
if (fMatch) feed = parseFloat(fMatch[1]);
if (tMatch) tool = parseInt(tMatch[1]);
const hasMove = xMatch || yMatch || zMatch;
if (!hasMove) continue;
const newPos = absolute
? {
x: xMatch ? parseFloat(xMatch[1]) : pos.x,
y: yMatch ? parseFloat(yMatch[1]) : pos.y,
z: zMatch ? parseFloat(zMatch[1]) : pos.z,
}
: {
x: pos.x + (xMatch ? parseFloat(xMatch[1]) : 0),
y: pos.y + (yMatch ? parseFloat(yMatch[1]) : 0),
z: pos.z + (zMatch ? parseFloat(zMatch[1]) : 0),
};
if (mode === 'G0' || mode === 'G1') {
segments.push({
type: mode, start: { ...pos }, end: { ...newPos }, feed, tool,
});
} else if (mode === 'G2' || mode === 'G3') {
const cx = pos.x + (iMatch ? parseFloat(iMatch[1]) : 0);
const cy = pos.y + (jMatch ? parseFloat(jMatch[1]) : 0);
segments.push({
type: mode, start: { ...pos }, end: { ...newPos },
center: { x: cx, y: cy }, feed, tool,
});
}
pos = newPos;
}
return segments;
}
function tessellateArc(seg) {
const points = [];
const cx = seg.center.x;
const cy = seg.center.y;
const startAngle = Math.atan2(seg.start.y - cy, seg.start.x - cx);
const endAngle = Math.atan2(seg.end.y - cy, seg.end.x - cx);
const radius = Math.sqrt(
(seg.start.x - cx) ** 2 + (seg.start.y - cy) ** 2
);
let sweep = endAngle - startAngle;
if (seg.type === 'G2') {
if (sweep > 0) sweep -= 2 * Math.PI;
} else {
if (sweep < 0) sweep += 2 * Math.PI;
}
const steps = Math.max(Math.ceil(Math.abs(sweep) / (Math.PI / 90)), 4);
const zStep = (seg.end.z - seg.start.z) / steps;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const angle = startAngle + sweep * t;
points.push({
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle),
z: seg.start.z + zStep * i,
});
}
return points;
}
let gcodeGroup = null;
let currentViewMode = 'part';
function renderToolpath(segments) {
if (gcodeGroup) {
scene.remove(gcodeGroup);
gcodeGroup.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
}
gcodeGroup = new THREE.Group();
const rapids = [];
const cuts = [];
for (const seg of segments) {
if (seg.type === 'G2' || seg.type === 'G3') {
const pts = tessellateArc(seg);
for (let i = 0; i < pts.length - 1; i++) {
cuts.push(pts[i].x, pts[i].z, pts[i].y,
pts[i+1].x, pts[i+1].z, pts[i+1].y);
}
} else if (seg.type === 'G0') {
rapids.push(seg.start.x, seg.start.z, seg.start.y,
seg.end.x, seg.end.z, seg.end.y);
} else {
cuts.push(seg.start.x, seg.start.z, seg.start.y,
seg.end.x, seg.end.z, seg.end.y);
}
}
if (rapids.length) {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(rapids, 3));
gcodeGroup.add(new THREE.LineSegments(geo,
new THREE.LineBasicMaterial({ color: 0xff4444, opacity: 0.3, transparent: true })));
}
if (cuts.length) {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(cuts, 3));
gcodeGroup.add(new THREE.LineSegments(geo,
new THREE.LineBasicMaterial({ color: 0x00b4d8 })));
}
const box = new THREE.Box3().setFromObject(gcodeGroup);
const center = new THREE.Vector3();
box.getCenter(center);
gcodeGroup.position.sub(center);
scene.add(gcodeGroup);
applyViewMode();
}
function setViewMode(mode) {
currentViewMode = mode;
applyViewMode();
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
const btn = document.getElementById('view-' + mode);
if (btn) btn.classList.add('active');
}
function applyViewMode() {
if (!currentMesh && !gcodeGroup) return;
if (currentViewMode === 'part') {
if (currentMesh) currentMesh.visible = true;
if (gcodeGroup) gcodeGroup.visible = false;
if (currentMesh && currentMesh.material) {
currentMesh.material.transparent = false;
currentMesh.material.opacity = 1;
}
} else if (currentViewMode === 'toolpath') {
if (currentMesh) currentMesh.visible = false;
if (gcodeGroup) gcodeGroup.visible = true;
} else if (currentViewMode === 'overlay') {
if (currentMesh) {
currentMesh.visible = true;
currentMesh.material.transparent = true;
currentMesh.material.opacity = 0.3;
}
if (gcodeGroup) gcodeGroup.visible = true;
}
}
function loadSTL(url) {
return new Promise((resolve, reject) => {
const loader = new THREE.STLLoader();
loader.load(url, (geometry) => {
if (currentMesh) {
scene.remove(currentMesh);
currentMesh.geometry.dispose();
currentMesh.material.dispose();
}
const material = new THREE.MeshPhongMaterial({
color: 0x7799aa,
specular: 0x445566,
shininess: 60,
flatShading: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
geometry.computeBoundingBox();
const center = new THREE.Vector3();
geometry.boundingBox.getCenter(center);
mesh.position.sub(center);
scene.add(mesh);
currentMesh = mesh;
// Fit camera
const size = new THREE.Vector3();
geometry.boundingBox.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
const dist = maxDim * 2.5;
camera.position.set(dist * 0.7, dist * 0.5, dist * 0.7);
controls.target.set(0, 0, 0);
controls.update();
// Update grid to match model scale
if (gridHelper) {
gridHelper.position.y = -size.y / 2 - 0.5;
}
document.getElementById('viewer-empty').style.display = 'none';
resolve();
}, undefined, reject);
});
}
// ── BACKEND TOGGLE ────────────────────────────────────
function setBackend(name) {
currentBackend = name;
document.getElementById('btn-gemini').classList.toggle('active', name === 'gemini');
document.getElementById('btn-openai').classList.toggle('active', name === 'openai');
updateModelSelect();
}
function updateModelSelect() {
const select = document.getElementById('model-select');
const models = backendModels[currentBackend] || [];
select.innerHTML = models.map(m =>
'<option value="' + m + '"' + (m === currentModel ? ' selected' : '') + '>' + m + '</option>'
).join('');
currentModel = select.value || models[0] || '';
}
async function loadBackendModels() {
try {
const resp = await fetch('/api/backend-models');
backendModels = await resp.json();
updateModelSelect();
} catch (e) {
console.warn('Failed to load backend models', e);
}
}
document.getElementById('model-select').addEventListener('change', function() {
currentModel = this.value;
});
// ── CHAT PANEL TOGGLE ─────────────────────────────────
function toggleChat() {
chatPanelOpen = !chatPanelOpen;
const panel = document.getElementById('chat-panel');
const pill = document.getElementById('chat-open-pill');
const toggle = document.getElementById('chat-toggle');
if (chatPanelOpen) {
panel.classList.remove('collapsed');
pill.classList.remove('visible');
toggle.innerHTML = '&#9664;';
document.body.classList.add('chat-open');
} else {
panel.classList.add('collapsed');
pill.classList.add('visible');
toggle.innerHTML = '&#9654;';
document.body.classList.remove('chat-open');
}
}
// ── CHAT MESSAGING ────────────────────────────────────
async function sendMessage(text, options) {
if (!text.trim()) return;
options = options || {};
removeGapCards();
// Parse @mentions
const mentions = [];
const mentionRegex = /@(design|engineering|cnc|cad|cam)\b/gi;
let match;
while ((match = mentionRegex.exec(text)) !== null) {
mentions.push(match[1].toLowerCase());
}
const cleanedText = text.replace(mentionRegex, '').trim();
// Hide quick examples
const examples = document.getElementById('quick-examples');
if (examples) examples.style.display = 'none';
// Add user message to UI
addMessage({ role: 'user', content: text });
// Show typing
showTyping();
try {
// Send history WITHOUT the current message (backend appends it)
const resp = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: cleanedText,
history: chatHistory,
mentions: mentions,
backend: currentBackend,
model: currentModel,
design_state: designState,
plan_context: !!options.planContext,
}),
});
// Add to history AFTER sending (so it's included in future turns)
chatHistory.push({ role: 'user', content: text });
saveState();
const data = await resp.json();
hideTyping();
// Add agent responses
for (const r of data.responses) {
addMessage({
role: 'agent',
agent_id: r.agent_id,
agent_name: r.agent_name,
content: r.message,
color: r.color,
avatar: r.avatar,
code: r.code,
});
chatHistory.push({ role: 'agent', agent_id: r.agent_id, content: r.message });
}
if (data.design_state) {
designState = data.design_state;
}
saveState();
// If phase transitioned to planning, show plan card
if (designState.phase === 'planning' && designState.plan) {
const old = document.getElementById('active-plan-card');
if (old) old.remove();
const msgs = document.getElementById('chat-messages');
const cardDiv = document.createElement('div');
cardDiv.innerHTML = renderPlanCard(designState.plan);
msgs.appendChild(cardDiv.firstChild);
msgs.scrollTop = msgs.scrollHeight;
}
// If question cards are present, render them
if (data.question_cards && data.question_cards.length > 0) {
removeGapCards();
const msgs = document.getElementById('chat-messages');
const cardDiv = document.createElement('div');
cardDiv.innerHTML = renderQuestionCards(data.question_cards);
msgs.appendChild(cardDiv.firstChild);
msgs.scrollTop = msgs.scrollHeight;
}
// If preview available, load 3D model
if (data.preview && data.preview.success) {
setViewerLoading(true, t('loadingModel'));
try {
await loadSTL(data.preview.stl_url);
} catch (e) {
console.warn('STL load failed:', e);
}
setViewerLoading(false);
updateGeoStats(data.preview.execution);
updateCNCBadge(data.preview.validation);
updateDownloads(data.preview.part_name);
// If G-code/CAM data available, render toolpath
if (data.preview.cam && data.preview.cam.success && data.preview.cam.gcode) {
const segments = parseGCode(data.preview.cam.gcode);
if (segments.length > 0) {
renderToolpath(segments);
document.getElementById('view-toolbar').style.display = 'flex';
}
}
if (data.preview.part_name) {
currentPartName = data.preview.part_name;
addToGallery(data.preview);
}
} else if (data.preview && !data.preview.success) {
addMessage({
role: 'agent',
agent_id: 'system',
agent_name: 'System',
content: t('cadFailed') + (data.preview.error || 'Unknown error'),
color: '#ff5252',
avatar: '!',
});
}
} catch (err) {
hideTyping();
addMessage({
role: 'agent',
agent_id: 'system',
agent_name: 'System',
content: t('errorPrefix') + err.message,
color: '#ff5252',
avatar: '!',
});
}
}
function sendFromInput() {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text) return;
input.value = '';
input.style.height = 'auto';
closeMentionDropdown();
sendMessage(text);
}
function sendPreview() {
sendMessage(t('sendPreviewMsg'));
}
function quickSend(text) {
const examples = document.getElementById('quick-examples');
if (examples) examples.style.display = 'none';
sendMessage(text);
}
// ── MESSAGE RENDERING ─────────────────────────────────
function addMessage(msg) {
const container = document.getElementById('chat-messages');
const el = document.createElement('div');
if (msg.role === 'user') {
el.className = 'msg msg-user';
el.innerHTML = '<div class="msg-bubble">' + escapeHtml(msg.content) + '</div>';
} else {
const agentId = msg.agent_id || 'system';
const agentInfo = AGENTS[agentId] || { name: msg.agent_name || 'Agent', color: msg.color || '#5a7089', avatar: '?' };
const color = msg.color || agentInfo.color;
const avatar = msg.avatar || agentInfo.avatar;
const name = msg.agent_name || agentInfo.name;
const isCad = agentId === 'cad';
el.className = 'msg msg-agent';
const msgId = 'msg-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
let html = '<div class="msg-avatar" style="background: ' + color + ';">' + avatar + '</div>';
html += '<div class="msg-agent-body">';
html += '<div class="msg-agent-name" style="color: ' + color + ';">' + escapeHtml(name) + '</div>';
html += '<div class="msg-agent-bubble' + (isCad ? ' cad-bubble' : '') + '">' + escapeHtml(msg.content);
if (msg.code) {
currentCode = msg.code;
html += '<br><a class="msg-view-code" onclick="openCodeModal()">&#9654; View code</a>';
}
html += '</div>';
// Add confirm/revise buttons for agent recommendations (not system, not code-only)
const isSystem = agentId === 'system';
const isCodeOnly = isCad && msg.code;
if (!isSystem && !isCodeOnly && msg.content && !msg.content.startsWith('NOT READY:')) {
html += '<div class="msg-actions" id="' + msgId + '-actions">';
html += '<button class="msg-action-btn confirm" onclick="confirmRecommendation(\'' + msgId + '\', \'' + escapeHtml(agentId) + '\')">' + t('confirm') + '</button>';
html += '<button class="msg-action-btn revise" onclick="reviseRecommendation(\'' + msgId + '\', \'' + escapeHtml(agentId) + '\')">' + t('revise') + '</button>';
html += '</div>';
}
html += '</div>';
el.innerHTML = html;
el.dataset.msgId = msgId;
el.dataset.content = msg.content;
}
container.appendChild(el);
scrollChatToBottom();
}
function confirmRecommendation(msgId, agentId) {
var el = document.querySelector('[data-msg-id="' + msgId + '"]');
if (!el) return;
var content = el.dataset.content;
var actions = document.getElementById(msgId + '-actions');
if (actions) {
actions.innerHTML = '<div class="msg-confirmed">' + t('confirmed') + '</div>';
}
// Auto-fill plan card field if plan is visible
if (designState.phase === 'planning' && designState.plan && document.getElementById('active-plan-card')) {
var field = detectPlanField(agentId, content);
if (field) {
var value = extractFieldValue(field, content);
if (value != null) {
updatePlanCardField(field, value);
}
}
}
// Add confirmation to chat history locally (no backend round-trip)
var agentName = (AGENTS[agentId] || {}).name || agentId;
var confirmMsg = t('confirmedMsg').replace('{agent}', agentName).replace('{content}', content);
addMessage({ role: 'user', content: confirmMsg });
chatHistory.push({ role: 'user', content: confirmMsg });
saveState();
}
function reviseRecommendation(msgId, agentId) {
const el = document.querySelector('[data-msg-id="' + msgId + '"]');
if (!el) return;
const content = el.dataset.content;
const actions = document.getElementById(msgId + '-actions');
if (actions) actions.classList.add('resolved');
// Pre-fill input with context for the user to edit
const input = document.getElementById('chat-input');
const agentName = (AGENTS[agentId] || {}).name || agentId;
input.value = t('regarding').replace('{agent}', agentName);
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}
function showTyping() {
const container = document.getElementById('chat-messages');
const el = document.createElement('div');
el.className = 'typing-indicator';
el.id = 'typing-indicator';
el.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div><span class="typing-label">' + t('agentsThinking') + '</span>';
container.appendChild(el);
scrollChatToBottom();
}
function hideTyping() {
const el = document.getElementById('typing-indicator');
if (el) el.remove();
}
function scrollChatToBottom() {
const container = document.getElementById('chat-messages');
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
}
// ── @MENTION AUTOCOMPLETE ─────────────────────────────
const mentionAgents = ['design', 'engineering', 'cnc', 'cad'];
function handleInputForMention(e) {
const input = document.getElementById('chat-input');
const val = input.value;
const pos = input.selectionStart;
// Find @ before cursor
const before = val.substring(0, pos);
const atMatch = before.match(/@(\w*)$/);
if (atMatch) {
const query = atMatch[1].toLowerCase();
const filtered = mentionAgents.filter(a => a.startsWith(query));
if (filtered.length > 0) {
showMentionDropdown(filtered);
mentionActive = true;
return;
}
}
closeMentionDropdown();
}
function showMentionDropdown(filtered) {
const dropdown = document.getElementById('mention-dropdown');
const options = dropdown.querySelectorAll('.mention-option');
let visibleCount = 0;
options.forEach(opt => {
const agent = opt.dataset.agent;
if (filtered.includes(agent)) {
opt.style.display = 'flex';
visibleCount++;
} else {
opt.style.display = 'none';
}
});
if (visibleCount > 0) {
dropdown.classList.add('visible');
mentionIndex = 0;
updateMentionHighlight();
}
}
function closeMentionDropdown() {
document.getElementById('mention-dropdown').classList.remove('visible');
mentionActive = false;
}
function updateMentionHighlight() {
const options = Array.from(document.querySelectorAll('#mention-dropdown .mention-option'))
.filter(o => o.style.display !== 'none');
options.forEach((o, i) => o.classList.toggle('active', i === mentionIndex));
}
function insertMention(agent) {
const input = document.getElementById('chat-input');
const val = input.value;
const pos = input.selectionStart;
const before = val.substring(0, pos);
const after = val.substring(pos);
const atPos = before.lastIndexOf('@');
input.value = before.substring(0, atPos) + '@' + agent + ' ' + after;
input.focus();
const newPos = atPos + agent.length + 2;
input.setSelectionRange(newPos, newPos);
closeMentionDropdown();
}
// ── UI UPDATES ────────────────────────────────────────
function setViewerLoading(on, msg) {
const el = document.getElementById('viewer-loading');
if (on) {
el.classList.add('visible');
document.getElementById('loading-msg').textContent = msg || 'GENERATING...';
} else {
el.classList.remove('visible');
}
}
function updateGeoStats(exec) {
if (!exec || !exec.success) return;
const el = document.getElementById('geo-stats');
el.classList.add('visible');
const vol = exec.volume_mm3;
if (vol != null) {
document.getElementById('stat-volume').textContent =
vol > 1000 ? (vol / 1000).toFixed(1) + ' cm\u00B3' : vol.toFixed(1) + ' mm\u00B3';
}
const bbox = exec.bounding_box_mm;
if (bbox && bbox.length === 3) {
document.getElementById('stat-bbox').textContent =
bbox.map(v => v.toFixed(1)).join(' \u00D7 ') + ' mm';
}
document.getElementById('stat-faces').textContent = exec.face_count || '\u2014';
document.getElementById('stat-edges').textContent = exec.edge_count || '\u2014';
}
function updateCNCBadge(validation) {
const el = document.getElementById('cnc-badge');
if (!validation) { el.classList.remove('visible'); return; }
el.classList.add('visible');
const cncBadge = document.getElementById('badge-cnc');
if (validation.machinable) {
cncBadge.className = 'badge badge-success';
cncBadge.textContent = '\u2713 CNC MACHINABLE';
} else {
cncBadge.className = 'badge badge-error';
cncBadge.textContent = '\u2717 NOT MACHINABLE';
}
const axisBadge = document.getElementById('badge-axis');
axisBadge.textContent = (validation.axis_recommendation || '').toUpperCase();
}
function updateDownloads(partName) {
const el = document.getElementById('download-btns');
if (!partName) { el.classList.remove('visible'); return; }
el.classList.add('visible');
document.getElementById('dl-step').href = '/api/models/' + partName + '.step';
document.getElementById('dl-stl').href = '/api/models/' + partName + '.stl';
document.getElementById('dl-3mf').href = '/api/models/' + partName + '.3mf';
document.getElementById('dl-report').href = '/api/models/' + partName + '_report.json';
const dlGcode = document.getElementById('dl-gcode');
if (dlGcode) {
const gcodePath = '/api/models/' + partName + '.gcode';
fetch(gcodePath, { method: 'HEAD' }).then(r => {
dlGcode.style.display = r.ok ? 'inline-flex' : 'none';
dlGcode.href = gcodePath;
}).catch(() => { dlGcode.style.display = 'none'; });
}
}
// ── CODE MODAL ────────────────────────────────────────
function openCodeModal() {
const modal = document.getElementById('code-modal');
const display = document.getElementById('code-display');
if (currentCode) {
display.innerHTML = highlightPython(currentCode);
} else {
display.textContent = 'No code available.';
}
modal.classList.add('visible');
}
function closeCodeModal() {
document.getElementById('code-modal').classList.remove('visible');
}
function highlightPython(code) {
let escaped = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
escaped = escaped.replace(/(#.*$)/gm, '<span class="cm">$1</span>');
escaped = escaped.replace(/("""[\s\S]*?"""|'''[\s\S]*?'''|"[^"\n]*"|'[^'\n]*')/g, '<span class="st">$1</span>');
const kw = /\b(import|from|as|def|class|return|if|else|elif|for|while|in|not|and|or|True|False|None|with|try|except|finally|raise|pass|break|continue|lambda|yield)\b/g;
escaped = escaped.replace(kw, '<span class="kw">$1</span>');
escaped = escaped.replace(/\b(\d+\.?\d*)\b/g, '<span class="nu">$1</span>');
escaped = escaped.replace(/\.([a-zA-Z_]\w*)\(/g, '.<span class="fn">$1</span>(');
return escaped;
}
// ── GALLERY ───────────────────────────────────────────
function addToGallery(data) {
galleryItems.unshift({
name: data.part_name,
volume: data.execution?.volume_mm3,
faces: data.execution?.face_count,
machinable: data.validation?.machinable,
});
}
function openGallery() {
renderGallery();
document.getElementById('gallery-modal').classList.add('visible');
}
function closeGallery() {
document.getElementById('gallery-modal').classList.remove('visible');
}
function renderGallery() {
const grid = document.getElementById('gallery-grid');
if (galleryItems.length === 0) {
grid.innerHTML = '<div class="gallery-empty">' + t('galleryEmpty') + '</div>';
return;
}
let html = '';
for (const item of galleryItems) {
const name = escapeHtml(item.name);
html += '<div class="gallery-card fade-in">';
html += '<div class="gallery-card-name" onclick="loadGalleryItem(\'' + name + '\')" style="cursor:pointer">' + name + '</div>';
html += '<div class="gallery-card-meta">';
if (item.faces) html += '<span>' + item.faces + ' faces</span>';
if (item.machinable !== undefined) {
html += '<span style="color:' + (item.machinable ? 'var(--success)' : 'var(--error)') + '">'
+ (item.machinable ? '\u2713 CNC' : '\u2717 CNC') + '</span>';
}
html += '</div>';
html += '<div class="gallery-card-downloads">';
html += '<a class="gallery-dl" href="/api/models/' + name + '.step" download>STEP</a>';
html += '<a class="gallery-dl" href="/api/models/' + name + '.stl" download>STL</a>';
html += '<a class="gallery-dl" href="/api/models/' + name + '.3mf" download>3MF</a>';
html += '<a class="gallery-dl" href="/api/models/' + name + '.gcode" download>GCODE</a>';
html += '</div>';
html += '</div>';
}
grid.innerHTML = html;
}
async function loadGalleryItem(name) {
closeGallery();
setViewerLoading(true, t('loadingModelShort'));
try {
await loadSTL('/api/models/' + name + '.stl');
currentPartName = name;
updateDownloads(name);
} catch (e) {
console.warn('Failed to load:', e);
}
setViewerLoading(false);
}
// ── UTILS ─────────────────────────────────────────────
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ── SERVER STATUS CHECK ───────────────────────────────
async function checkServer() {
try {
const resp = await fetch('/api/capabilities');
const dot = document.getElementById('status-dot');
if (resp.ok) {
dot.style.background = 'var(--success)';
dot.style.boxShadow = '0 0 6px var(--success)';
dot.title = 'Server Connected';
} else {
dot.style.background = 'var(--warning)';
dot.style.boxShadow = '0 0 6px var(--warning)';
dot.title = 'Server Error';
}
} catch {
const dot = document.getElementById('status-dot');
dot.style.background = 'var(--error)';
dot.style.boxShadow = '0 0 6px var(--error)';
dot.title = 'Server Offline';
}
}
// ── KEYBOARD / INPUT EVENTS ──────────────────────────
const chatInput = document.getElementById('chat-input');
chatInput.addEventListener('input', (e) => {
// Auto-resize
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + 'px';
// Check for @mention
handleInputForMention(e);
});
chatInput.addEventListener('keydown', (e) => {
if (mentionActive) {
const dropdown = document.getElementById('mention-dropdown');
const visibleOptions = Array.from(dropdown.querySelectorAll('.mention-option'))
.filter(o => o.style.display !== 'none');
if (e.key === 'ArrowDown') {
e.preventDefault();
mentionIndex = (mentionIndex + 1) % visibleOptions.length;
updateMentionHighlight();
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
mentionIndex = (mentionIndex - 1 + visibleOptions.length) % visibleOptions.length;
updateMentionHighlight();
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const agent = visibleOptions[mentionIndex]?.dataset.agent;
if (agent) insertMention(agent);
return;
}
if (e.key === 'Escape') {
closeMentionDropdown();
return;
}
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
sendFromInput();
}
// Regular enter sends (without shift)
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
sendFromInput();
}
});
// Close modals on backdrop click
document.getElementById('code-modal').addEventListener('click', (e) => {
if (e.target === document.getElementById('code-modal')) closeCodeModal();
});
document.getElementById('gallery-modal').addEventListener('click', (e) => {
if (e.target === document.getElementById('gallery-modal')) closeGallery();
});
// Escape to close modals
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeCodeModal();
closeGallery();
}
});
// ── LOAD SERVER MODELS INTO GALLERY ──────────────────
async function loadServerModels() {
try {
const resp = await fetch('/api/models');
const data = await resp.json();
if (data.models && data.models.length > 0) {
const existingNames = new Set(galleryItems.map(i => i.name));
for (const model of data.models) {
if (!existingNames.has(model.name)) {
galleryItems.push({ name: model.name });
}
}
}
} catch (e) {
console.warn('Failed to load server models:', e);
}
}
// ── INIT ──────────────────────────────────────────────
initViewer();
checkServer();
setInterval(checkServer, 15000);
loadState();
loadServerModels();
loadBackendModels();
// Apply saved language
setLang(currentLang);
// Re-render restored messages
if (chatHistory.length > 0) {
const examples = document.getElementById('quick-examples');
if (examples) examples.style.display = 'none';
for (const msg of chatHistory) {
addMessage(msg);
}
}
</script>
</body>
</html>