Spaces:
Sleeping
Sleeping
| <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) ; color: var(--text-primary) ; border-color: var(--accent) ; } | |
| /* 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">◆</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')">中</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">—</span></div> | |
| <div><span class="stat-label">BBOX </span><span class="stat-value" id="stat-bbox">—</span></div> | |
| <div><span class="stat-label">FACES </span><span class="stat-value" id="stat-faces">—</span><span class="stat-label"> EDGES </span><span class="stat-value" id="stat-edges">—</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">↧</span> G-CODE</a> | |
| <a class="dl-btn" id="dl-report" download>REPORT</a> | |
| </div> | |
| <div id="viewer-hint" data-i18n="viewerHint">DRAG ROTATE · SCROLL ZOOM · 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">◀</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 & 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>▶</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()">×</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()">×</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 = '◀'; | |
| document.body.classList.add('chat-open'); | |
| } else { | |
| panel.classList.add('collapsed'); | |
| pill.classList.add('visible'); | |
| toggle.innerHTML = '▶'; | |
| 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()">▶ 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, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>'); | |
| 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> | |