Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>SoniCoder</title> | |
| <meta name="description" content="AI-powered fullstack app generator with local model inference"> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| /* ═══════════════════════════════════════════════════════ | |
| RESET & BASE | |
| ═══════════════════════════════════════════════════════ */ | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg-deep: #0a0e14; | |
| --bg-panel: #0d1117; | |
| --bg-code: #161b22; | |
| --border: #1e2a3a; | |
| --border-focus: #2d4a6a; | |
| --green: #39ff14; | |
| --green-dim: #1a7a0a; | |
| --cyan: #00d4ff; | |
| --amber: #ffb300; | |
| --purple: #a855f7; | |
| --gray-light: #e0e0e0; | |
| --gray-mid: #8b949e; | |
| --gray-dim: #484f58; | |
| --red: #ff5555; | |
| --success: #50fa7b; | |
| --code-text: #f8f8f2; | |
| --glow-green: 0 0 8px rgba(57,255,20,0.3); | |
| --glow-cyan: 0 0 8px rgba(0,212,255,0.3); | |
| --glow-amber: 0 0 8px rgba(255,179,0,0.2); | |
| --glow-purple: 0 0 8px rgba(168,85,247,0.3); | |
| --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| --radius: 4px; | |
| --transition: 0.2s ease; | |
| } | |
| html, body { | |
| height: 100%; | |
| background: var(--bg-deep); | |
| color: var(--gray-light); | |
| font-family: var(--font-mono); | |
| font-size: 13px; | |
| line-height: 1.6; | |
| overflow: hidden; | |
| } | |
| body::after { | |
| content: ''; | |
| position: fixed; | |
| inset: 0; | |
| pointer-events: none; | |
| z-index: 9999; | |
| background: repeating-linear-gradient( | |
| 0deg, | |
| transparent, | |
| transparent 2px, | |
| rgba(0, 0, 0, 0.03) 2px, | |
| rgba(0, 0, 0, 0.03) 4px | |
| ); | |
| } | |
| ::selection { | |
| background: rgba(57, 255, 20, 0.25); | |
| color: #fff; | |
| } | |
| ::-webkit-scrollbar { width: 6px; height: 6px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } | |
| ::-webkit-scrollbar-thumb:hover { background: var(--gray-dim); } | |
| a { color: var(--cyan); text-decoration: none; } | |
| a:hover { text-decoration: underline; text-shadow: var(--glow-cyan); } | |
| /* ═══════════════════════════════════════════════════════ | |
| APP SHELL | |
| ═══════════════════════════════════════════════════════ */ | |
| #app { | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| max-height: 100vh; | |
| } | |
| /* ═══════════════════════════════════════════════════════ | |
| HEADER | |
| ═══════════════════════════════════════════════════════ */ | |
| #header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 10px 20px; | |
| background: var(--bg-panel); | |
| border-bottom: 1px solid var(--border); | |
| flex-shrink: 0; | |
| gap: 16px; | |
| } | |
| .header-title { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 2px; | |
| } | |
| .header-ascii { | |
| color: var(--green); | |
| font-size: 11px; | |
| line-height: 1.3; | |
| text-shadow: var(--glow-green); | |
| white-space: pre; | |
| letter-spacing: 0.5px; | |
| } | |
| .header-subtitle { | |
| color: var(--gray-mid); | |
| font-size: 11px; | |
| padding-left: 3px; | |
| } | |
| .header-actions { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| flex-shrink: 0; | |
| } | |
| .pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| padding: 4px 10px; | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| font-size: 11px; | |
| color: var(--gray-mid); | |
| text-decoration: none; | |
| transition: all var(--transition); | |
| } | |
| .pill:hover { | |
| border-color: var(--cyan); | |
| color: var(--cyan); | |
| text-decoration: none; | |
| text-shadow: var(--glow-cyan); | |
| } | |
| .pill .dot { | |
| width: 6px; | |
| height: 6px; | |
| border-radius: 50%; | |
| background: var(--success); | |
| box-shadow: 0 0 6px var(--success); | |
| } | |
| .pill .dot.loading { | |
| background: var(--amber); | |
| box-shadow: 0 0 6px var(--amber); | |
| animation: pulse 1.5s ease infinite; | |
| } | |
| .pill .dot.error { | |
| background: var(--red); | |
| box-shadow: 0 0 6px var(--red); | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.4; } | |
| } | |
| #btn-new-chat { | |
| background: transparent; | |
| border: 1px solid var(--border); | |
| color: var(--amber); | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| padding: 5px 12px; | |
| border-radius: var(--radius); | |
| cursor: pointer; | |
| transition: all var(--transition); | |
| letter-spacing: 1px; | |
| } | |
| #btn-new-chat:hover { | |
| border-color: var(--amber); | |
| background: rgba(255,179,0,0.08); | |
| text-shadow: var(--glow-amber); | |
| } | |
| .btn-thinking { | |
| background: transparent; | |
| border: 1px solid var(--border); | |
| color: var(--purple); | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| padding: 5px 12px; | |
| border-radius: var(--radius); | |
| cursor: pointer; | |
| transition: all var(--transition); | |
| letter-spacing: 0.5px; | |
| } | |
| .btn-thinking:hover { | |
| border-color: var(--purple); | |
| background: rgba(168,85,247,0.08); | |
| text-shadow: var(--glow-purple); | |
| } | |
| .btn-thinking.active { | |
| border-color: var(--purple); | |
| background: rgba(168,85,247,0.15); | |
| color: var(--purple); | |
| text-shadow: var(--glow-purple); | |
| } | |
| #model-select { | |
| background: var(--bg-deep); | |
| border: 1px solid var(--border); | |
| color: var(--cyan); | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| padding: 5px 8px; | |
| border-radius: var(--radius); | |
| outline: none; | |
| cursor: pointer; | |
| transition: border-color var(--transition); | |
| } | |
| #model-select:focus { border-color: var(--border-focus); } | |
| #model-select option { background: var(--bg-deep); color: var(--gray-light); } | |
| #btn-attach-image { | |
| background: transparent; border: 1px solid var(--border); color: var(--amber); | |
| font-family: var(--font-mono); font-size: 14px; padding: 3px 8px; | |
| border-radius: var(--radius); cursor: pointer; transition: all var(--transition); | |
| } | |
| #btn-attach-image:hover { | |
| border-color: var(--amber); background: rgba(255,179,0,0.1); | |
| } | |
| /* ═══════════════════════════════════════════════════════ | |
| BANNER | |
| ═══════════════════════════════════════════════════════ */ | |
| #playground-banner { | |
| background: linear-gradient(90deg, rgba(168,85,247,0.08), rgba(57,255,20,0.05)); | |
| border-bottom: 1px solid var(--border); | |
| color: var(--gray-mid); | |
| font-size: 12px; | |
| padding: 7px 18px; | |
| text-align: center; | |
| flex-shrink: 0; | |
| } | |
| #playground-banner strong { color: var(--gray-light); font-weight: 600; } | |
| #playground-banner a { color: var(--purple); font-weight: 600; } | |
| /* ═══════════════════════════════════════════════════════ | |
| MAIN LAYOUT | |
| ═══════════════════════════════════════════════════════ */ | |
| #main { | |
| display: flex; | |
| flex: 1; | |
| min-height: 0; | |
| overflow: hidden; | |
| } | |
| /* ═══════════════════════════════════════════════════════ | |
| TERMINAL (LEFT PANEL) | |
| ═══════════════════════════════════════════════════════ */ | |
| #terminal-panel { | |
| display: flex; | |
| flex-direction: column; | |
| flex: 1; | |
| min-width: 0; | |
| border-right: 1px solid var(--border); | |
| } | |
| .panel-label { | |
| padding: 6px 16px; | |
| font-size: 10px; | |
| letter-spacing: 2px; | |
| color: var(--gray-dim); | |
| border-bottom: 1px solid var(--border); | |
| background: rgba(13,17,23,0.6); | |
| text-transform: uppercase; | |
| flex-shrink: 0; | |
| } | |
| #chat-messages { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 16px; | |
| scroll-behavior: smooth; | |
| } | |
| /* Message styles */ | |
| .msg { | |
| margin-bottom: 14px; | |
| line-height: 1.65; | |
| animation: msgFadeIn 0.25s ease; | |
| } | |
| @keyframes msgFadeIn { | |
| from { opacity: 0; transform: translateY(6px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .msg-prefix { font-weight: 600; margin-right: 4px; } | |
| .msg-user .msg-prefix { color: var(--green); text-shadow: var(--glow-green); } | |
| .msg-user .msg-content { color: var(--green); text-shadow: var(--glow-green); } | |
| .msg-assistant .msg-prefix { color: var(--cyan); text-shadow: var(--glow-cyan); float: left; } | |
| .msg-assistant .msg-body { overflow: hidden; } | |
| .msg-assistant .msg-content { color: var(--gray-light); } | |
| .msg-system .msg-prefix { color: var(--amber); text-shadow: var(--glow-amber); } | |
| .msg-system .msg-content { color: var(--amber); opacity: 0.85; } | |
| /* Markdown elements */ | |
| .msg-content strong { color: #fff; font-weight: 600; } | |
| .msg-content em { font-style: italic; color: var(--gray-mid); } | |
| .msg-content code:not(pre code) { | |
| background: var(--bg-code); | |
| color: var(--code-text); | |
| padding: 1px 5px; | |
| border-radius: 3px; | |
| font-size: 12px; | |
| border: 1px solid var(--border); | |
| } | |
| .msg-content a { color: var(--cyan); } | |
| .msg-content ul, .msg-content ol { margin: 6px 0 6px 20px; } | |
| .msg-content li { margin-bottom: 3px; } | |
| .msg-content h1, .msg-content h2, .msg-content h3 { | |
| color: var(--cyan); margin: 10px 0 6px; font-size: 14px; text-shadow: var(--glow-cyan); | |
| } | |
| .msg-content h1 { font-size: 16px; } | |
| .msg-content h2 { font-size: 15px; } | |
| .msg-content p { margin: 4px 0; } | |
| /* Code blocks */ | |
| .code-block-wrap { | |
| margin: 8px 0; | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| overflow: hidden; | |
| background: var(--bg-code); | |
| } | |
| .code-block-header { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 4px 10px; background: rgba(30,42,58,0.5); | |
| border-bottom: 1px solid var(--border); font-size: 11px; | |
| } | |
| .code-lang { color: var(--amber); text-transform: uppercase; letter-spacing: 1px; } | |
| .btn-copy { | |
| background: transparent; border: 1px solid var(--border); color: var(--gray-mid); | |
| font-family: var(--font-mono); font-size: 10px; padding: 2px 8px; | |
| border-radius: 3px; cursor: pointer; transition: all var(--transition); | |
| } | |
| .btn-copy:hover { border-color: var(--green); color: var(--green); } | |
| .btn-copy.copied { border-color: var(--success); color: var(--success); } | |
| .code-block-wrap pre { | |
| margin: 0; padding: 10px 12px; overflow-x: auto; | |
| font-size: 12px; line-height: 1.5; color: var(--code-text); | |
| } | |
| .code-block-wrap pre code { font-family: var(--font-mono); background: none; border: none; padding: 0; } | |
| /* Thinking blocks */ | |
| .think-block { | |
| margin: 8px 0; border: 1px solid rgba(255,179,0,0.15); | |
| border-radius: var(--radius); background: rgba(255,179,0,0.03); | |
| } | |
| .think-summary { | |
| display: block; width: 100%; background: transparent; border: none; | |
| padding: 6px 10px; cursor: pointer; font-size: 12px; | |
| font-family: var(--font-mono); text-align: left; color: var(--gray-dim); | |
| user-select: none; transition: color var(--transition); | |
| } | |
| .think-summary:hover { color: var(--amber); } | |
| .think-block .think-content { | |
| padding: 6px 12px 10px; font-size: 12px; color: var(--gray-dim); | |
| line-height: 1.55; border-top: 1px solid rgba(255,179,0,0.1); | |
| } | |
| .think-block:not(.open) .think-content { display: none; } | |
| /* Hide thinking blocks entirely when toggle is off */ | |
| body.hide-thinking .think-block { display: none; } | |
| /* ─── Search Source Badge (Grok-style) ────────────────────── */ | |
| .search-source-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| margin: 4px 0 2px; | |
| padding: 4px 10px; | |
| border-radius: 20px; | |
| background: rgba(168, 85, 247, 0.08); | |
| border: 1px solid rgba(168, 85, 247, 0.2); | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| font-size: 11px; | |
| color: var(--purple); | |
| user-select: none; | |
| white-space: nowrap; | |
| } | |
| .search-source-badge:hover { | |
| background: rgba(168, 85, 247, 0.15); | |
| border-color: rgba(168, 85, 247, 0.4); | |
| box-shadow: 0 0 8px rgba(168, 85, 247, 0.15); | |
| } | |
| .search-source-badge .badge-icon { | |
| font-size: 13px; | |
| line-height: 1; | |
| } | |
| .search-source-badge .badge-count { | |
| font-weight: 600; | |
| } | |
| .search-source-badge .badge-arrow { | |
| font-size: 9px; | |
| transition: transform 0.2s ease; | |
| opacity: 0.6; | |
| } | |
| .search-source-badge.open .badge-arrow { | |
| transform: rotate(180deg); | |
| } | |
| .search-source-panel { | |
| margin: 0 0 6px; | |
| border-radius: 8px; | |
| border: 1px solid rgba(168, 85, 247, 0.15); | |
| background: rgba(168, 85, 247, 0.03); | |
| max-height: 0; | |
| overflow: hidden; | |
| transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease; | |
| opacity: 0; | |
| } | |
| .search-source-panel.open { | |
| max-height: 600px; | |
| overflow-y: auto; | |
| opacity: 1; | |
| padding: 8px 10px; | |
| } | |
| .search-source-panel::-webkit-scrollbar { width: 4px; } | |
| .search-source-panel::-webkit-scrollbar-track { background: transparent; } | |
| .search-source-panel::-webkit-scrollbar-thumb { background: var(--gray-dim); border-radius: 2px; } | |
| .source-item { | |
| display: flex; | |
| gap: 8px; | |
| padding: 6px 4px; | |
| border-bottom: 1px solid rgba(168, 85, 247, 0.07); | |
| align-items: flex-start; | |
| } | |
| .source-item:last-child { border-bottom: none; } | |
| .source-favicon { | |
| width: 16px; height: 16px; | |
| border-radius: 3px; | |
| flex-shrink: 0; | |
| margin-top: 2px; | |
| background: var(--bg-code); | |
| border: 1px solid var(--border); | |
| object-fit: contain; | |
| } | |
| .source-favicon-placeholder { | |
| width: 16px; height: 16px; | |
| border-radius: 3px; | |
| flex-shrink: 0; | |
| margin-top: 2px; | |
| background: var(--bg-code); | |
| border: 1px solid var(--border); | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 8px; color: var(--gray-dim); | |
| } | |
| .source-info { flex: 1; min-width: 0; } | |
| .source-title { | |
| font-size: 11px; font-weight: 600; color: var(--cyan); | |
| text-decoration: none; display: block; | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| } | |
| .source-title:hover { text-decoration: underline; } | |
| .source-domain { | |
| font-size: 9px; color: var(--green-dim); | |
| display: block; margin-top: 1px; | |
| } | |
| .source-snippet { | |
| font-size: 10px; color: var(--gray-mid); | |
| line-height: 1.35; | |
| margin-top: 2px; | |
| display: -webkit-box; | |
| -webkit-line-clamp: 2; | |
| -webkit-box-orient: vertical; | |
| overflow: hidden; | |
| } | |
| /* Streaming cursor */ | |
| .streaming-cursor::after { | |
| content: '\u2588'; animation: blink 0.8s step-end infinite; | |
| color: var(--green); margin-left: 2px; | |
| } | |
| @keyframes blink { 50% { opacity: 0; } } | |
| /* ═══════════════════════════════════════════════════════ | |
| INPUT AREA | |
| ═══════════════════════════════════════════════════════ */ | |
| #input-area { | |
| flex-shrink: 0; | |
| border-top: 1px solid var(--border); | |
| background: var(--bg-panel); | |
| padding: 10px 16px 8px; | |
| } | |
| /* Target selectors */ | |
| #target-selector { | |
| display: flex; | |
| gap: 10px; | |
| margin-bottom: 6px; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| } | |
| .selector-group { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .selector-label { | |
| font-size: 10px; | |
| color: var(--gray-dim); | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| } | |
| #lang-select, #framework-select { | |
| background: var(--bg-deep); | |
| border: 1px solid var(--border); | |
| color: var(--green); | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| padding: 3px 8px; | |
| border-radius: var(--radius); | |
| outline: none; | |
| cursor: pointer; | |
| transition: border-color var(--transition); | |
| } | |
| #lang-select:focus, #framework-select:focus { | |
| border-color: var(--border-focus); | |
| } | |
| #lang-select option, #framework-select option { | |
| background: var(--bg-deep); | |
| color: var(--gray-light); | |
| } | |
| #input-row { | |
| display: flex; | |
| gap: 8px; | |
| align-items: flex-end; | |
| } | |
| .input-prompt-symbol { | |
| color: var(--green); | |
| font-weight: 700; | |
| font-size: 14px; | |
| line-height: 36px; | |
| text-shadow: var(--glow-green); | |
| flex-shrink: 0; | |
| } | |
| #chat-input { | |
| flex: 1; | |
| background: var(--bg-deep); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| color: var(--green); | |
| font-family: var(--font-mono); | |
| font-size: 13px; | |
| padding: 8px 12px; | |
| resize: none; | |
| outline: none; | |
| min-height: 36px; | |
| max-height: 120px; | |
| line-height: 1.5; | |
| transition: border-color var(--transition); | |
| caret-color: var(--green); | |
| text-shadow: var(--glow-green); | |
| } | |
| #chat-input::placeholder { color: var(--gray-dim); text-shadow: none; } | |
| #chat-input:focus { border-color: var(--border-focus); } | |
| #btn-web-search { | |
| background: transparent; border: 1px solid var(--purple); color: var(--purple); | |
| font-family: var(--font-mono); font-size: 11px; padding: 8px 10px; | |
| border-radius: var(--radius); cursor: pointer; transition: all var(--transition); | |
| letter-spacing: 1px; flex-shrink: 0; height: 36px; | |
| display: flex; align-items: center; gap: 4px; | |
| } | |
| #btn-web-search:hover { | |
| background: var(--purple); color: white; | |
| box-shadow: 0 0 12px rgba(168,85,247,0.3); | |
| text-shadow: none; | |
| } | |
| #btn-send, #btn-stop { | |
| font-family: var(--font-mono); font-size: 12px; padding: 8px 14px; | |
| border-radius: var(--radius); cursor: pointer; transition: all var(--transition); | |
| letter-spacing: 1px; flex-shrink: 0; height: 36px; | |
| display: flex; align-items: center; gap: 4px; | |
| } | |
| #btn-send { | |
| background: transparent; border: 1px solid var(--green); color: var(--green); | |
| } | |
| #btn-send:hover:not(:disabled) { | |
| background: var(--green); color: var(--bg-deep); | |
| box-shadow: 0 0 12px rgba(57,255,20,0.3); | |
| } | |
| #btn-send:disabled { opacity: 0.3; cursor: not-allowed; } | |
| #btn-stop { | |
| background: transparent; border: 1px solid var(--red); color: var(--red); display: none; | |
| } | |
| #btn-stop:hover { | |
| background: var(--red); color: var(--bg-deep); | |
| box-shadow: 0 0 12px rgba(255,85,85,0.3); | |
| } | |
| /* Examples */ | |
| #examples-row { | |
| display: flex; align-items: center; gap: 8px; | |
| margin-top: 8px; flex-wrap: wrap; | |
| } | |
| .examples-label { | |
| font-size: 10px; color: var(--gray-dim); letter-spacing: 1px; | |
| text-transform: uppercase; flex-shrink: 0; | |
| } | |
| .example-chip { | |
| background: rgba(30,42,58,0.4); border: 1px solid var(--border); | |
| border-radius: 12px; padding: 3px 10px; font-family: var(--font-mono); | |
| font-size: 11px; color: var(--gray-mid); cursor: pointer; | |
| transition: all var(--transition); white-space: nowrap; | |
| } | |
| .example-chip:hover { | |
| border-color: var(--purple); color: var(--purple); | |
| background: rgba(168,85,247,0.05); text-shadow: var(--glow-purple); | |
| } | |
| /* ═══════════════════════════════════════════════════════ | |
| OUTPUT PANEL (RIGHT) | |
| ═══════════════════════════════════════════════════════ */ | |
| #output-panel { | |
| display: flex; flex-direction: column; width: 45%; min-width: 340px; | |
| max-width: 55%; min-height: 0; background: var(--bg-panel); | |
| } | |
| #output-tabs { | |
| display: flex; border-bottom: 1px solid var(--border); | |
| background: rgba(13,17,23,0.6); flex-shrink: 0; | |
| } | |
| .output-tab { | |
| flex: 1; background: transparent; border: none; | |
| border-bottom: 2px solid transparent; color: var(--gray-dim); | |
| font-family: var(--font-mono); font-size: 11px; padding: 8px 12px; | |
| cursor: pointer; transition: all var(--transition); | |
| letter-spacing: 1px; text-transform: uppercase; | |
| } | |
| .output-tab:hover { color: var(--gray-mid); } | |
| .output-tab.active { | |
| color: var(--cyan); border-bottom-color: var(--cyan); | |
| text-shadow: var(--glow-cyan); | |
| } | |
| #output-content { | |
| flex: 1; min-height: 0; overflow: hidden; position: relative; | |
| } | |
| /* Tab panes */ | |
| .tab-pane { display: none; height: 100%; min-height: 0; } | |
| .tab-pane.active { display: flex; flex-direction: column; } | |
| /* Preview tab */ | |
| #pane-preview { | |
| align-items: stretch; justify-content: stretch; | |
| position: relative; min-height: 0; overflow: hidden; | |
| } | |
| .preview-placeholder { | |
| align-self: center; margin: auto; text-align: center; | |
| color: var(--gray-dim); padding: 40px 20px; | |
| } | |
| .preview-placeholder .ascii-art { | |
| font-size: 11px; line-height: 1.3; margin-bottom: 16px; color: var(--border-focus); | |
| } | |
| .preview-placeholder .placeholder-text { font-size: 12px; letter-spacing: 0.5px; } | |
| #preview-image { | |
| display: none; max-width: 100%; max-height: 100%; | |
| object-fit: contain; padding: 12px; | |
| } | |
| #preview-iframe { | |
| display: none; position: absolute; inset: 0; width: 100%; height: 100%; | |
| min-height: 0; border: none; background: #fff; | |
| } | |
| #btn-fullscreen { | |
| display: none; position: absolute; top: 8px; right: 8px; | |
| background: rgba(13,17,23,0.8); border: 1px solid var(--border); | |
| color: var(--gray-mid); font-family: var(--font-mono); font-size: 11px; | |
| padding: 4px 10px; border-radius: var(--radius); cursor: pointer; | |
| z-index: 5; transition: all var(--transition); | |
| } | |
| #btn-fullscreen:hover { border-color: var(--cyan); color: var(--cyan); } | |
| /* Console tab */ | |
| #pane-console { padding: 12px 16px; gap: 12px; overflow-y: auto; } | |
| .console-section { margin-bottom: 8px; } | |
| .console-label { | |
| font-size: 10px; letter-spacing: 2px; color: var(--gray-dim); | |
| margin-bottom: 4px; text-transform: uppercase; | |
| } | |
| .console-output { | |
| background: var(--bg-deep); border: 1px solid var(--border); | |
| border-radius: var(--radius); padding: 10px 12px; font-size: 12px; | |
| line-height: 1.5; white-space: pre-wrap; word-break: break-word; | |
| min-height: 40px; max-height: 280px; overflow-y: auto; | |
| } | |
| #console-stdout { color: var(--success); } | |
| #console-stderr { color: var(--red); } | |
| /* Code tab */ | |
| #pane-code { padding: 0; } | |
| .code-tab-header { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 8px 12px; border-bottom: 1px solid var(--border); | |
| background: rgba(30,42,58,0.3); flex-shrink: 0; | |
| } | |
| .code-tab-lang { | |
| font-size: 11px; color: var(--amber); letter-spacing: 1px; text-transform: uppercase; | |
| } | |
| .code-tab-actions { display: flex; gap: 8px; } | |
| .code-tab-btn { | |
| background: transparent; border: 1px solid var(--border); color: var(--gray-mid); | |
| font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; | |
| border-radius: 3px; cursor: pointer; text-decoration: none; | |
| transition: all var(--transition); display: inline-flex; | |
| align-items: center; gap: 4px; | |
| } | |
| .code-tab-btn:hover { border-color: var(--cyan); color: var(--cyan); text-decoration: none; } | |
| #code-display { | |
| flex: 1; overflow: auto; padding: 12px; background: var(--bg-code); | |
| } | |
| #code-display pre { margin: 0; font-size: 12px; line-height: 1.5; color: var(--code-text); } | |
| .code-placeholder { | |
| display: flex; align-items: center; justify-content: center; | |
| height: 100%; color: var(--gray-dim); font-size: 12px; | |
| } | |
| /* ═══════════════════════════════════════════════════════ | |
| SEARCH TAB | |
| ═══════════════════════════════════════════════════════ */ | |
| #pane-search { padding: 12px 16px; gap: 12px; overflow-y: auto; } | |
| .search-bar { | |
| display: flex; | |
| gap: 8px; | |
| margin-bottom: 10px; | |
| } | |
| #search-input { | |
| flex: 1; | |
| background: var(--bg-deep); | |
| border: 1px solid var(--border); | |
| color: var(--green); | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| padding: 6px 10px; | |
| border-radius: var(--radius); | |
| outline: none; | |
| transition: border-color var(--transition); | |
| } | |
| #search-input:focus { border-color: var(--border-focus); } | |
| #search-input::placeholder { color: var(--gray-dim); } | |
| #btn-search-go { | |
| background: transparent; | |
| border: 1px solid var(--purple); | |
| color: var(--purple); | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| padding: 6px 12px; | |
| border-radius: var(--radius); | |
| cursor: pointer; | |
| transition: all var(--transition); | |
| letter-spacing: 1px; | |
| } | |
| #btn-search-go:hover { | |
| background: var(--purple); | |
| color: white; | |
| text-shadow: none; | |
| } | |
| .search-result-item { | |
| padding: 8px 0; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .search-result-item:last-child { border-bottom: none; } | |
| .search-result-title { | |
| color: var(--cyan); | |
| font-size: 12px; | |
| font-weight: 600; | |
| text-decoration: none; | |
| display: block; | |
| margin-bottom: 2px; | |
| } | |
| .search-result-title:hover { text-decoration: underline; text-shadow: var(--glow-cyan); } | |
| .search-result-url { | |
| color: var(--green-dim); | |
| font-size: 10px; | |
| display: block; | |
| margin-bottom: 2px; | |
| word-break: break-all; | |
| } | |
| .search-result-snippet { | |
| color: var(--gray-mid); | |
| font-size: 11px; | |
| line-height: 1.4; | |
| } | |
| .search-results-empty { | |
| color: var(--gray-dim); | |
| font-size: 12px; | |
| text-align: center; | |
| padding: 40px 20px; | |
| } | |
| /* Gradio preview */ | |
| .gradio-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| padding: 3px 8px; | |
| background: rgba(168,85,247,0.15); | |
| border: 1px solid var(--purple); | |
| border-radius: 10px; | |
| font-size: 10px; | |
| color: var(--purple); | |
| letter-spacing: 0.5px; | |
| } | |
| #gradio-iframe { | |
| display: none; | |
| position: absolute; | |
| inset: 0; | |
| width: 100%; | |
| height: 100%; | |
| min-height: 0; | |
| border: none; | |
| } | |
| /* ═══════════════════════════════════════════════════════ | |
| DEPLOY TAB | |
| ═══════════════════════════════════════════════════════ */ | |
| #pane-deploy { padding: 16px; gap: 14px; overflow-y: auto; } | |
| .deploy-section { | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 14px; | |
| background: var(--bg-code); | |
| } | |
| .deploy-title { | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: var(--purple); | |
| text-shadow: var(--glow-purple); | |
| margin-bottom: 10px; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| } | |
| .deploy-field { | |
| margin-bottom: 10px; | |
| } | |
| .deploy-field label { | |
| display: block; | |
| font-size: 10px; | |
| color: var(--gray-dim); | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| margin-bottom: 4px; | |
| } | |
| .deploy-field input, .deploy-field select { | |
| width: 100%; | |
| background: var(--bg-deep); | |
| border: 1px solid var(--border); | |
| color: var(--green); | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| padding: 6px 10px; | |
| border-radius: var(--radius); | |
| outline: none; | |
| transition: border-color var(--transition); | |
| } | |
| .deploy-field input:focus, .deploy-field select:focus { | |
| border-color: var(--border-focus); | |
| } | |
| .deploy-field input::placeholder { | |
| color: var(--gray-dim); | |
| } | |
| .deploy-field select option { | |
| background: var(--bg-deep); | |
| color: var(--gray-light); | |
| } | |
| .deploy-hint { | |
| font-size: 10px; | |
| color: var(--gray-dim); | |
| margin-top: 3px; | |
| } | |
| #btn-push-hf { | |
| width: 100%; | |
| background: linear-gradient(135deg, rgba(168,85,247,0.2), rgba(57,255,20,0.1)); | |
| border: 1px solid var(--purple); | |
| color: var(--purple); | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| padding: 8px 14px; | |
| border-radius: var(--radius); | |
| cursor: pointer; | |
| transition: all var(--transition); | |
| letter-spacing: 1px; | |
| margin-top: 6px; | |
| } | |
| #btn-push-hf:hover:not(:disabled) { | |
| background: var(--purple); | |
| color: white; | |
| box-shadow: 0 0 12px rgba(168,85,247,0.4); | |
| text-shadow: none; | |
| } | |
| #btn-push-hf:disabled { | |
| opacity: 0.4; | |
| cursor: not-allowed; | |
| } | |
| #btn-hf-login { | |
| background: linear-gradient(135deg, #FFD21E, #FF9D00); | |
| border: none; | |
| color: #1a1a1a; | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| font-weight: 600; | |
| padding: 8px 16px; | |
| border-radius: 20px; | |
| cursor: pointer; | |
| transition: all var(--transition); | |
| letter-spacing: 0.5px; | |
| } | |
| #btn-hf-login:hover { | |
| box-shadow: 0 0 12px rgba(255,210,30,0.5); | |
| transform: translateY(-1px); | |
| } | |
| #hf-owner { | |
| width: 100%; | |
| background: var(--bg-code); | |
| color: var(--gray-light); | |
| border: 1px solid var(--border); | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| padding: 6px 10px; | |
| border-radius: var(--radius); | |
| cursor: pointer; | |
| } | |
| .deploy-status { | |
| margin-top: 10px; | |
| padding: 8px 12px; | |
| border-radius: var(--radius); | |
| font-size: 11px; | |
| display: none; | |
| } | |
| .deploy-status.success { | |
| display: block; | |
| background: rgba(80,250,123,0.1); | |
| border: 1px solid var(--success); | |
| color: var(--success); | |
| } | |
| .deploy-status.error { | |
| display: block; | |
| background: rgba(255,85,85,0.1); | |
| border: 1px solid var(--red); | |
| color: var(--red); | |
| } | |
| .deploy-status.working { | |
| display: block; | |
| background: rgba(255,179,0,0.1); | |
| border: 1px solid var(--amber); | |
| color: var(--amber); | |
| } | |
| .deploy-status a { | |
| color: var(--cyan); | |
| font-weight: 600; | |
| } | |
| /* Project files list */ | |
| .project-files { | |
| margin-top: 10px; | |
| } | |
| .file-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 4px 0; | |
| font-size: 11px; | |
| color: var(--gray-mid); | |
| border-bottom: 1px solid rgba(30,42,58,0.5); | |
| } | |
| .file-item:last-child { border-bottom: none; } | |
| .file-icon { color: var(--amber); } | |
| .file-name { color: var(--cyan); } | |
| /* ═══════════════════════════════════════════════════════ | |
| STATUS BAR | |
| ═══════════════════════════════════════════════════════ */ | |
| #status-bar { | |
| display: flex; align-items: center; gap: 8px; padding: 5px 16px; | |
| border-top: 1px solid var(--border); background: var(--bg-panel); | |
| font-size: 11px; flex-shrink: 0; | |
| } | |
| .status-indicator { | |
| display: inline-flex; align-items: center; gap: 6px; | |
| } | |
| .status-dot { font-size: 10px; line-height: 1; } | |
| #status-text { letter-spacing: 1px; text-transform: uppercase; } | |
| .status-idle { color: var(--gray-dim); } | |
| .status-working { color: var(--amber); text-shadow: var(--glow-amber); } | |
| .status-success { color: var(--success); text-shadow: 0 0 8px rgba(80,250,123,0.3); } | |
| .status-error { color: var(--red); text-shadow: 0 0 8px rgba(255,85,85,0.3); } | |
| .status-info { color: var(--cyan); text-shadow: var(--glow-cyan); } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .status-working .status-dot { display: inline-block; animation: spin 1s linear infinite; } | |
| /* ═══════════════════════════════════════════════════════ | |
| FULLSCREEN OVERLAY | |
| ═══════════════════════════════════════════════════════ */ | |
| #fullscreen-overlay { | |
| display: none; position: fixed; inset: 0; z-index: 1000; | |
| background: var(--bg-deep); flex-direction: column; | |
| } | |
| #fullscreen-overlay.active { display: flex; } | |
| #fullscreen-bar { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 8px 16px; border-bottom: 1px solid var(--border); background: var(--bg-panel); | |
| } | |
| #fullscreen-bar span { color: var(--cyan); font-size: 12px; letter-spacing: 1px; } | |
| #btn-exit-fullscreen { | |
| background: transparent; border: 1px solid var(--border); color: var(--gray-mid); | |
| font-family: var(--font-mono); font-size: 11px; padding: 4px 12px; | |
| border-radius: var(--radius); cursor: pointer; transition: all var(--transition); | |
| } | |
| #btn-exit-fullscreen:hover { border-color: var(--red); color: var(--red); } | |
| #fullscreen-iframe { flex: 1; border: none; background: #fff; } | |
| /* ═══════════════════════════════════════════════════════ | |
| RESPONSIVE | |
| ═══════════════════════════════════════════════════════ */ | |
| @media (max-width: 900px) { | |
| #main { flex-direction: column; } | |
| #terminal-panel { border-right: none; border-bottom: 1px solid var(--border); max-height: 55vh; } | |
| #output-panel { width: 100%; max-width: 100%; min-width: 0; flex: 1; } | |
| .header-ascii { font-size: 10px; } | |
| #chat-input { font-size: 12px; } | |
| #preview-iframe { min-height: 400px; } | |
| } | |
| @media (max-width: 600px) { | |
| #header { padding: 8px 12px; gap: 8px; } | |
| .header-ascii { display: none; } | |
| .header-subtitle { display: none; } | |
| .pill { font-size: 10px; padding: 3px 8px; } | |
| #chat-messages { padding: 10px; } | |
| #input-area { padding: 8px 10px 6px; } | |
| #examples-row { display: none; } | |
| #target-selector { gap: 6px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <!-- Header --> | |
| <header id="header"> | |
| <div class="header-title"> | |
| <div class="header-ascii">╔═══ FULLSTACK CODE BUILDER ═══╚</div> | |
| <div class="header-subtitle">Local AI App Generator | <span id="header-model-name">MiniCPM5-1B</span></div> | |
| </div> | |
| <div class="header-actions"> | |
| <a class="pill" id="model-pill" href="#" target="_blank" rel="noopener"> | |
| <span class="dot loading" id="model-dot"></span> | |
| <span id="model-pill-text">MiniCPM5-1B</span> | |
| </a> | |
| <select id="model-select" onchange="onModelChange()" title="Switch AI model"> | |
| <option value="minicpm5-1b">MiniCPM5-1B (text)</option> | |
| <option value="minicpm-v-4.6">MiniCPM-V-4.6 (vision)</option> | |
| </select> | |
| <button id="btn-thinking" class="btn-thinking active" onclick="toggleThinking()" title="Show/hide thinking blocks">🧠 Think</button> | |
| <button id="btn-new-chat" onclick="newChat()" title="Start a new chat session">[NEW]</button> | |
| </div> | |
| </header> | |
| <div id="playground-banner"> | |
| Powered by <a id="banner-model-link" href="https://huggingface.co/openbmb/MiniCPM5-1B" target="_blank" rel="noopener"><strong>MiniCPM5-1B</strong></a> running locally — no external APIs. Generate fullstack apps in any language and deploy to HuggingFace. | |
| </div> | |
| <!-- Main Layout --> | |
| <div id="main"> | |
| <!-- Terminal Panel --> | |
| <div id="terminal-panel"> | |
| <div class="panel-label">Terminal</div> | |
| <div id="chat-messages"></div> | |
| <div id="input-area"> | |
| <div id="target-selector"> | |
| <div class="selector-group"> | |
| <span class="selector-label">Lang:</span> | |
| <select id="lang-select" onchange="onLanguageChange()"></select> | |
| </div> | |
| <div class="selector-group"> | |
| <span class="selector-label">Framework:</span> | |
| <select id="framework-select"></select> | |
| </div> | |
| <div class="selector-group" id="image-attach-group" style="display:none;"> | |
| <input type="file" id="image-upload" accept="image/*" style="display:none" onchange="onImageUpload(event)"> | |
| <button id="btn-attach-image" onclick="document.getElementById('image-upload').click()" title="Attach image (VLM only)">📷</button> | |
| <span id="image-attach-name" style="font-size:10px;color:var(--gray-dim);max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span> | |
| <button id="btn-remove-image" onclick="removeImage()" title="Remove image" style="display:none;font-size:10px;color:var(--red);background:none;border:none;cursor:pointer;">✕</button> | |
| </div> | |
| </div> | |
| <div id="input-row"> | |
| <span class="input-prompt-symbol">❯</span> | |
| <textarea id="chat-input" rows="1" placeholder="Describe the app you want to build..." spellcheck="false"></textarea> | |
| <button id="btn-web-search" onclick="searchAndGenerate()" title="Search web + Generate">🔍</button> | |
| <button id="btn-send" onclick="handleSend()" title="Send message (Shift+Enter)">➤</button> | |
| <button id="btn-stop" onclick="stopGeneration()" title="Stop generation">■ STOP</button> | |
| </div> | |
| <div id="examples-row"></div> | |
| </div> | |
| </div> | |
| <!-- Output Panel --> | |
| <div id="output-panel"> | |
| <div id="output-tabs"> | |
| <button class="output-tab active" data-tab="preview" onclick="switchTab('preview')">Preview</button> | |
| <button class="output-tab" data-tab="console" onclick="switchTab('console')">Console</button> | |
| <button class="output-tab" data-tab="code" onclick="switchTab('code')">Code</button> | |
| <button class="output-tab" data-tab="search" onclick="switchTab('search')">Search</button> | |
| <button class="output-tab" data-tab="deploy" onclick="switchTab('deploy')">Deploy</button> | |
| </div> | |
| <div id="output-content"> | |
| <!-- Preview Pane --> | |
| <div class="tab-pane active" id="pane-preview"> | |
| <div class="preview-placeholder" id="preview-placeholder"> | |
| <div class="ascii-art"> | |
| ┌─────────────────────┐ | |
| │ ╱━━━╲ │ | |
| │ │ ▶ │ OUTPUT │ | |
| │ ╵━━━╴ │ | |
| └─────────────────────┘</div> | |
| <div class="placeholder-text">Generate code to see output here</div> | |
| </div> | |
| <img id="preview-image" alt="Generated output"> | |
| <iframe id="preview-iframe" sandbox="allow-scripts"></iframe> | |
| <button id="btn-fullscreen" onclick="openFullscreen()">⥊ FULLSCREEN</button> | |
| </div> | |
| <!-- Console Pane --> | |
| <div class="tab-pane" id="pane-console"> | |
| <div class="console-section"> | |
| <div class="console-label">stdout:</div> | |
| <div class="console-output" id="console-stdout">No output yet.</div> | |
| </div> | |
| <div class="console-section"> | |
| <div class="console-label">stderr:</div> | |
| <div class="console-output" id="console-stderr">No errors.</div> | |
| </div> | |
| </div> | |
| <!-- Code Pane --> | |
| <div class="tab-pane" id="pane-code"> | |
| <div class="code-tab-header"> | |
| <span class="code-tab-lang" id="code-tab-lang">—</span> | |
| <div class="code-tab-actions"> | |
| <button class="code-tab-btn" id="btn-copy-code" onclick="copyCode()">📋 Copy</button> | |
| <a class="code-tab-btn" id="btn-download" href="#" style="display:none;">⬇ Download</a> | |
| </div> | |
| </div> | |
| <div id="code-display"> | |
| <div class="code-placeholder">No code generated yet.</div> | |
| </div> | |
| </div> | |
| <!-- Search Pane --> | |
| <div class="tab-pane" id="pane-search"> | |
| <div class="search-bar"> | |
| <input type="text" id="search-input" placeholder="Search the web... (Google, no API needed)" spellcheck="false"> | |
| <button id="btn-search-go" onclick="doWebSearch()">🔍 Search</button> | |
| </div> | |
| <div id="search-results"> | |
| <div class="search-results-empty">Search the web for documentation, examples, and references to use in your code.</div> | |
| </div> | |
| </div> | |
| <!-- Deploy Pane --> | |
| <div class="tab-pane" id="pane-deploy"> | |
| <div class="deploy-section"> | |
| <div class="deploy-title">🚀 Deploy to HuggingFace</div> | |
| <!-- OAuth Login Section --> | |
| <div class="deploy-field" id="hf-auth-section"> | |
| <label>Sign In</label> | |
| <div id="hf-auth-container"> | |
| <button id="btn-hf-login" onclick="loginWithHF()">🤝 Sign in with HuggingFace</button> | |
| <div id="hf-user-info" style="display:none;"> | |
| <img id="hf-user-avatar" src="" alt="" style="width:24px;height:24px;border-radius:50%;vertical-align:middle;margin-right:6px;"> | |
| <span id="hf-user-name" style="color:var(--green);font-weight:600;"></span> | |
| <button id="btn-hf-logout" onclick="logoutHF()" style="margin-left:8px;font-size:10px;color:var(--red);background:none;border:none;cursor:pointer;">Sign out</button> | |
| </div> | |
| </div> | |
| <div class="deploy-hint" id="hf-auth-hint">Sign in with OAuth — no token paste needed</div> | |
| </div> | |
| <!-- Push to: User or Org selector --> | |
| <div class="deploy-field"> | |
| <label for="hf-owner">Push to</label> | |
| <select id="hf-owner"> | |
| <option value="">Sign in to see options</option> | |
| </select> | |
| <div class="deploy-hint">Select your account or an organization</div> | |
| </div> | |
| <div class="deploy-field"> | |
| <label for="hf-repo-name">Repository Name</label> | |
| <input type="text" id="hf-repo-name" placeholder="my-app" autocomplete="off"> | |
| <div class="deploy-hint">The repo will be created at owner/repo-name</div> | |
| </div> | |
| <div class="deploy-field" id="hf-manual-token-section"> | |
| <label for="hf-token">HuggingFace Token <span style="font-weight:400;color:var(--gray-dim);">(manual fallback)</span></label> | |
| <input type="password" id="hf-token" placeholder="hf_xxxxxxxxxxxxxxxxxxxxx" autocomplete="off"> | |
| <div class="deploy-hint">Only needed if OAuth is unavailable. <a href="https://huggingface.co/settings/tokens" target="_blank">Get token</a></div> | |
| </div> | |
| <div class="deploy-field"> | |
| <label for="hf-space-sdk">Space SDK</label> | |
| <select id="hf-space-sdk"> | |
| <option value="auto">Auto-detect</option> | |
| <option value="docker">Docker (React/Next/Vue/Express/Node)</option> | |
| <option value="static">Static (HTML/CSS/JS)</option> | |
| <option value="gradio">Gradio (Python)</option> | |
| <option value="streamlit">Streamlit (Python)</option> | |
| </select> | |
| <div class="deploy-hint">JS frameworks auto-use Docker with Dockerfile build</div> | |
| </div> | |
| <button id="btn-push-hf" onclick="pushToHuggingFace()" disabled>🚀 Push to HuggingFace</button> | |
| <div class="deploy-status" id="deploy-status"></div> | |
| <div class="project-files" id="project-files"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Status Bar --> | |
| <div id="status-bar"> | |
| <div class="status-indicator status-idle" id="status-indicator"> | |
| <span class="status-dot">●</span> | |
| <span id="status-text">LOADING MODEL...</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Fullscreen Overlay --> | |
| <div id="fullscreen-overlay"> | |
| <div id="fullscreen-bar"> | |
| <span>WEB PREVIEW</span> | |
| <button id="btn-exit-fullscreen" onclick="closeFullscreen()">[✕ CLOSE]</button> | |
| </div> | |
| <iframe id="fullscreen-iframe" sandbox="allow-scripts"></iframe> | |
| </div> | |
| <script> | |
| // ═══════════════════════════════════════════════════════ | |
| // CONFIG | |
| // ═══════════════════════════════════════════════════════ | |
| const CONFIG = __RUNTIME_CONFIG__; | |
| // ═══════════════════════════════════════════════════════ | |
| // STATE | |
| // ═══════════════════════════════════════════════════════ | |
| const state = { | |
| history: [], | |
| executionContext: {}, | |
| targetLanguage: 'Python', | |
| targetFramework: 'Flask', | |
| isGenerating: false, | |
| currentEventSource: null, | |
| activeTab: 'preview', | |
| lastExecution: null, | |
| lastCode: '', | |
| lastCodeLang: '', | |
| pendingWebPreviewCode: '', | |
| loadedWebPreviewCode: '', | |
| scheduledWebPreviewCode: '', | |
| reasoningExpanded: false, | |
| lastReasoningPressAt: 0, | |
| modelReady: false, | |
| searchEnabled: false, | |
| lastSearchResults: [], | |
| currentSearchResults: [], | |
| searchPanelExpanded: false, | |
| showThinking: true, | |
| currentModelKey: 'minicpm5-1b', | |
| currentModelType: 'text', | |
| uploadedImageFileUrl: '', | |
| uploadedImageName: '', | |
| }; | |
| // ═══════════════════════════════════════════════════════ | |
| // INITIALIZATION | |
| // ═══════════════════════════════════════════════════════ | |
| document.addEventListener('DOMContentLoaded', () => { | |
| document.title = CONFIG.app_title || 'SoniCoder'; | |
| try { | |
| if (CONFIG.model_url) { | |
| document.getElementById('model-pill').href = CONFIG.model_url; | |
| document.getElementById('banner-model-link').href = CONFIG.model_url; | |
| } | |
| const modelId = typeof CONFIG.model_id === 'string' ? CONFIG.model_id : (CONFIG.model_configs ? Object.values(CONFIG.model_configs)[0]?.name || 'AI Model' : 'AI Model'); | |
| document.getElementById('model-pill-text').textContent = modelId.split('/').pop(); | |
| } catch (e) { console.warn('Model pill setup error:', e); } | |
| // Populate language/framework selects | |
| populateLanguageSelects(); | |
| // Render examples | |
| renderExamples(); | |
| // Welcome message | |
| addSystemMessage('Welcome to SoniCoder. The model is loading locally (no API keys needed). Select a language and framework, then describe the app you want to build.'); | |
| // Input auto-resize & keybinding | |
| const input = document.getElementById('chat-input'); | |
| input.addEventListener('input', autoResize); | |
| input.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && e.shiftKey) { | |
| e.preventDefault(); | |
| handleSend(); | |
| } | |
| }); | |
| // Search input Enter key | |
| document.getElementById('search-input')?.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { e.preventDefault(); doWebSearch(); } | |
| }); | |
| document.addEventListener('pointerdown', handleReasoningPress, true); | |
| document.addEventListener('mousedown', handleReasoningPress, true); | |
| document.addEventListener('keydown', handleReasoningKeydown, true); | |
| document.addEventListener('keydown', handleFullscreenKeydown); | |
| observePreviewSize(); | |
| // Poll model status | |
| pollModelStatus(); | |
| // Check for HuggingFace OAuth session | |
| checkGradioOAuth(); | |
| }); | |
| function autoResize() { | |
| const el = document.getElementById('chat-input'); | |
| el.style.height = 'auto'; | |
| el.style.height = Math.min(el.scrollHeight, 120) + 'px'; | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // MODEL STATUS POLLING | |
| // ═══════════════════════════════════════════════════════ | |
| async function pollModelStatus() { | |
| try { | |
| const resp = await fetch('/api/model-status'); | |
| const data = await resp.json(); | |
| const dot = document.getElementById('model-dot'); | |
| const statusText = document.getElementById('status-text'); | |
| const indicator = document.getElementById('status-indicator'); | |
| if (data.status === 'ready') { | |
| state.modelReady = true; | |
| dot.className = 'dot'; | |
| statusText.textContent = 'MODEL READY'; | |
| indicator.className = 'status-indicator status-success'; | |
| document.getElementById('btn-push-hf').disabled = false; | |
| // Update model info from server response | |
| if (data.model_key) state.currentModelKey = data.model_key; | |
| if (data.model_type) state.currentModelType = data.model_type; | |
| if (data.model_name) { | |
| document.getElementById('model-pill-text').textContent = data.model_name; | |
| document.getElementById('header-model-name').textContent = data.model_name; | |
| } | |
| // Show/hide image upload based on model type | |
| const imageGroup = document.getElementById('image-attach-group'); | |
| if (state.currentModelType === 'vlm') { | |
| imageGroup.style.display = 'flex'; | |
| } else { | |
| imageGroup.style.display = 'none'; | |
| } | |
| // Sync model selector | |
| const modelSelect = document.getElementById('model-select'); | |
| if (modelSelect && data.model_key) modelSelect.value = data.model_key; | |
| setTimeout(() => { | |
| if (!state.isGenerating) { | |
| indicator.className = 'status-indicator status-idle'; | |
| statusText.textContent = 'IDLE'; | |
| } | |
| }, 3000); | |
| return; | |
| } else if (data.status === 'loading') { | |
| dot.className = 'dot loading'; | |
| statusText.textContent = 'LOADING MODEL...'; | |
| indicator.className = 'status-indicator status-working'; | |
| } else { | |
| dot.className = 'dot error'; | |
| statusText.textContent = 'MODEL ERROR'; | |
| indicator.className = 'status-indicator status-error'; | |
| } | |
| setTimeout(pollModelStatus, 3000); | |
| } catch (err) { | |
| console.error('Model status poll error:', err); | |
| setTimeout(pollModelStatus, 5000); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // LANGUAGE / FRAMEWORK SELECTS | |
| // ═══════════════════════════════════════════════════════ | |
| function populateLanguageSelects() { | |
| const langSelect = document.getElementById('lang-select'); | |
| const fwSelect = document.getElementById('framework-select'); | |
| if (!langSelect || !fwSelect) return; | |
| // Fallback languages if CONFIG not loaded | |
| const languages = CONFIG.languages || [ | |
| ["Python", ["Gradio", "Flask", "Django", "FastAPI", "Streamlit", "Plain Python"]], | |
| ["JavaScript", ["React", "Vue.js", "Next.js", "Express.js", "Node.js", "Vanilla JS"]], | |
| ["TypeScript", ["React", "Next.js", "Express.js", "NestJS"]], | |
| ["HTML/CSS/JS", ["Tailwind CSS", "Bootstrap", "Vanilla"]], | |
| ["Java", ["Spring Boot", "Maven", "Gradle"]], | |
| ["Go", ["Gin", "Fiber", "Echo", "Plain Go"]], | |
| ["Rust", ["Actix", "Axum", "Rocket"]], | |
| ["PHP", ["Laravel", "Symfony", "Plain PHP"]], | |
| ["Ruby", ["Rails", "Sinatra"]], | |
| ["C#", ["ASP.NET", "Blazor"]], | |
| ["Swift", ["Vapor", "SwiftUI"]], | |
| ["Kotlin", ["Ktor", "Spring Boot"]], | |
| ]; | |
| languages.forEach((entry) => { | |
| const lang = Array.isArray(entry) ? entry[0] : entry; | |
| const opt = document.createElement('option'); | |
| opt.value = lang; | |
| opt.textContent = lang; | |
| if (lang === 'Python') opt.selected = true; | |
| langSelect.appendChild(opt); | |
| }); | |
| onLanguageChange(); | |
| } | |
| function onLanguageChange() { | |
| const langSelect = document.getElementById('lang-select'); | |
| const fwSelect = document.getElementById('framework-select'); | |
| const selectedLang = langSelect.value; | |
| state.targetLanguage = selectedLang; | |
| // Update frameworks | |
| fwSelect.innerHTML = ''; | |
| const languages = CONFIG.languages || []; | |
| const entry = languages.find((e) => (Array.isArray(e) ? e[0] : e) === selectedLang); | |
| if (entry && Array.isArray(entry) && entry[1]) { | |
| entry[1].forEach((fw) => { | |
| const opt = document.createElement('option'); | |
| opt.value = fw; | |
| opt.textContent = fw; | |
| fwSelect.appendChild(opt); | |
| }); | |
| state.targetFramework = entry[1][0]; | |
| } | |
| fwSelect.onchange = () => { | |
| state.targetFramework = fwSelect.value; | |
| autoSelectSDK(selectedLang, fwSelect.value); | |
| }; | |
| // Auto-select SDK based on language/framework | |
| autoSelectSDK(selectedLang, fwSelect.value); | |
| } | |
| function autoSelectSDK(lang, framework) { | |
| const sdkSelect = document.getElementById('hf-space-sdk'); | |
| if (!sdkSelect) return; | |
| const jsLangs = ['JavaScript', 'TypeScript']; | |
| const jsFrameworks = ['React', 'Next.js', 'Vue.js', 'Express.js', 'Node.js', 'NestJS']; | |
| const pythonFrameworks = ['Gradio', 'Streamlit', 'Flask', 'Django', 'FastAPI']; | |
| if (jsLangs.includes(lang) || jsFrameworks.includes(framework)) { | |
| // JS frameworks need Docker | |
| if (jsFrameworks.includes(framework) || jsLangs.includes(lang)) { | |
| sdkSelect.value = 'docker'; | |
| } | |
| } else if (framework === 'Gradio') { | |
| sdkSelect.value = 'gradio'; | |
| } else if (framework === 'Streamlit') { | |
| sdkSelect.value = 'streamlit'; | |
| } else if (lang === 'Python' && pythonFrameworks.includes(framework)) { | |
| sdkSelect.value = 'gradio'; | |
| } else if (lang === 'HTML/CSS/JS' || framework === 'Vanilla' || framework === 'Tailwind CSS' || framework === 'Bootstrap') { | |
| sdkSelect.value = 'static'; | |
| } else { | |
| sdkSelect.value = 'auto'; | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // EXAMPLES | |
| // ═══════════════════════════════════════════════════════ | |
| function renderExamples() { | |
| const row = document.getElementById('examples-row'); | |
| if (!CONFIG.examples || CONFIG.examples.length === 0) { | |
| row.style.display = 'none'; | |
| return; | |
| } | |
| row.innerHTML = '<span class="examples-label">Try:</span>'; | |
| CONFIG.examples.forEach((ex) => { | |
| const chip = document.createElement('button'); | |
| chip.className = 'example-chip'; | |
| chip.textContent = ex.label; | |
| chip.title = ex.prompt; | |
| chip.addEventListener('click', () => { | |
| if (state.isGenerating) return; | |
| resetConversation(); | |
| if (ex.language) { | |
| document.getElementById('lang-select').value = ex.language; | |
| onLanguageChange(); | |
| } | |
| if (ex.framework) { | |
| document.getElementById('framework-select').value = ex.framework; | |
| state.targetFramework = ex.framework; | |
| } | |
| sendMessage(ex.prompt); | |
| }); | |
| row.appendChild(chip); | |
| }); | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // CHAT MESSAGES | |
| // ═══════════════════════════════════════════════════════ | |
| function addSystemMessage(text) { | |
| const container = document.getElementById('chat-messages'); | |
| const div = document.createElement('div'); | |
| div.className = 'msg msg-system'; | |
| div.innerHTML = `<span class="msg-prefix">system></span><span class="msg-content">${escapeHtml(text)}</span>`; | |
| container.appendChild(div); | |
| scrollToBottom(); | |
| } | |
| function addUserMessage(text) { | |
| const container = document.getElementById('chat-messages'); | |
| const div = document.createElement('div'); | |
| div.className = 'msg msg-user'; | |
| div.innerHTML = `<span class="msg-prefix">user></span><span class="msg-content">${escapeHtml(text)}</span>`; | |
| container.appendChild(div); | |
| scrollToBottom(); | |
| } | |
| function addAssistantMessage() { | |
| const container = document.getElementById('chat-messages'); | |
| const div = document.createElement('div'); | |
| div.className = 'msg msg-assistant'; | |
| div.id = 'current-assistant-msg'; | |
| div.innerHTML = `<span class="msg-prefix">ai></span><div class="msg-body"><div class="search-source-container"></div><span class="msg-content streaming-cursor"></span></div>`; | |
| container.appendChild(div); | |
| state.reasoningExpanded = false; | |
| state.currentSearchResults = []; | |
| state.searchPanelExpanded = false; | |
| scrollToBottom(); | |
| return div; | |
| } | |
| function updateAssistantMessage(content, isStreaming) { | |
| const div = document.getElementById('current-assistant-msg'); | |
| if (!div) return; | |
| const contentEl = div.querySelector('.msg-content'); | |
| const keepReasoningExpanded = state.reasoningExpanded || Boolean(contentEl.querySelector('.think-block.open')); | |
| state.reasoningExpanded = keepReasoningExpanded; | |
| contentEl.innerHTML = parseMarkdown(content); | |
| contentEl.querySelectorAll('.think-block').forEach((block) => { | |
| setReasoningBlockOpen(block, keepReasoningExpanded); | |
| }); | |
| if (isStreaming) { | |
| contentEl.classList.add('streaming-cursor'); | |
| } else { | |
| contentEl.classList.remove('streaming-cursor'); | |
| } | |
| scrollToBottom(); | |
| } | |
| function finalizeAssistantMessage() { | |
| const div = document.getElementById('current-assistant-msg'); | |
| if (div) { | |
| div.id = ''; | |
| const contentEl = div.querySelector('.msg-content'); | |
| if (contentEl) contentEl.classList.remove('streaming-cursor'); | |
| } | |
| } | |
| function scrollToBottom() { | |
| const container = document.getElementById('chat-messages'); | |
| requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; }); | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // MARKDOWN PARSER | |
| // ═══════════════════════════════════════════════════════ | |
| function parseMarkdown(text) { | |
| if (!text) return ''; | |
| const thinkBlocks = []; | |
| text = text.replace(/<think>([\s\S]*?)<\/think>/g, (_, content) => { | |
| const idx = thinkBlocks.length; | |
| thinkBlocks.push(renderThinkBlock(content, '\ud83d\udcad Reasoning (click to expand)')); | |
| return `@@THINKBLOCK_${idx}@@`; | |
| }); | |
| text = text.replace(/<think>([\s\S]*)$/g, (_, content) => { | |
| const idx = thinkBlocks.length; | |
| thinkBlocks.push(renderThinkBlock(content, '\ud83d\udcad Reasoning (thinking...)')); | |
| return `@@THINKBLOCK_${idx}@@`; | |
| }); | |
| const codeBlocks = []; | |
| text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { | |
| const idx = codeBlocks.length; | |
| codeBlocks.push({ lang: lang || 'text', code: code.trimEnd() }); | |
| return `@@CODEBLOCK_${idx}@@`; | |
| }); | |
| text = escapeHtml(text); | |
| text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); | |
| text = text.replace(/(?<!\*)\*([^*]+?)\*(?!\*)/g, '<em>$1</em>'); | |
| text = text.replace(/`([^`]+?)`/g, '<code>$1</code>'); | |
| text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>'); | |
| text = text.replace(/^### (.+)$/gm, '<h3>$1</h3>'); | |
| text = text.replace(/^## (.+)$/gm, '<h2>$1</h2>'); | |
| text = text.replace(/^# (.+)$/gm, '<h1>$1</h1>'); | |
| text = text.replace(/^(?:[-*]) (.+)$/gm, '<li>$1</li>'); | |
| text = text.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>'); | |
| text = text.replace(/^\d+\. (.+)$/gm, '<li>$1</li>'); | |
| text = text.replace(/(<li>(?:(?!<\/?[uo]l>).)*<\/li>(?:\s*<li>(?:(?!<\/?[uo]l>).)*<\/li>)*)/g, (match) => { | |
| if (!match.includes('<ul>') && !match.includes('</ul>')) return '<ol>' + match + '</ol>'; | |
| return match; | |
| }); | |
| text = text.replace(/@@CODEBLOCK_(\d+)@@/g, (_, idx) => { | |
| const block = codeBlocks[parseInt(idx)]; | |
| const escapedCode = escapeHtml(block.code); | |
| const id = `code-${Date.now()}-${idx}`; | |
| return `<div class="code-block-wrap"><div class="code-block-header"><span class="code-lang">${escapeHtml(block.lang)}</span><button class="btn-copy" onclick="copyBlock(this, '${id}')">📋 Copy</button></div><pre><code id="${id}">${escapedCode}</code></pre></div>`; | |
| }); | |
| text = text.replace(/@@THINKBLOCK_(\d+)@@/g, (_, idx) => thinkBlocks[parseInt(idx)]); | |
| text = text.replace(/\n\n/g, '</p><p>'); | |
| text = text.replace(/\n/g, '<br>'); | |
| text = '<p>' + text + '</p>'; | |
| text = text.replace(/<p>\s*<\/p>/g, ''); | |
| text = text.replace(/<p>(<(?:div|ul|ol|h[1-3]))/g, '$1'); | |
| text = text.replace(/(<\/(?:div|ul|ol|h[1-3])>)<\/p>/g, '$1'); | |
| return text; | |
| } | |
| function renderThinkBlock(content, summary) { | |
| const escapedContent = escapeHtml(content.trim()).replace(/\n/g, '<br>'); | |
| const openClass = state.reasoningExpanded ? ' open' : ''; | |
| const expanded = state.reasoningExpanded ? 'true' : 'false'; | |
| return `<div class="think-block${openClass}"><button type="button" class="think-summary" aria-expanded="${expanded}">${summary}</button><div class="think-content">${escapedContent}</div></div>`; | |
| } | |
| function handleReasoningPress(event) { updateReasoningFromEvent(event); } | |
| function handleReasoningKeydown(event) { | |
| if (event.key !== 'Enter' && event.key !== ' ') return; | |
| updateReasoningFromEvent(event); | |
| } | |
| function updateReasoningFromEvent(event) { | |
| if (event.type === 'mousedown' && Date.now() - state.lastReasoningPressAt < 500) return; | |
| const target = event.target; | |
| if (!target || !target.closest) return; | |
| const button = target.closest('.think-summary'); | |
| if (!button) return; | |
| const block = button.closest('.think-block'); | |
| if (!block) return; | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| if (event.stopImmediatePropagation) event.stopImmediatePropagation(); | |
| state.lastReasoningPressAt = Date.now(); | |
| const nextOpen = !block.classList.contains('open'); | |
| state.reasoningExpanded = nextOpen; | |
| const scope = block.closest('.msg-content') || document; | |
| scope.querySelectorAll('.think-block').forEach((trace) => { | |
| setReasoningBlockOpen(trace, nextOpen); | |
| }); | |
| } | |
| function setReasoningBlockOpen(block, open) { | |
| block.classList.toggle('open', open); | |
| const button = block.querySelector('.think-summary'); | |
| if (button) button.setAttribute('aria-expanded', open ? 'true' : 'false'); | |
| } | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // COPY FUNCTIONS | |
| // ═══════════════════════════════════════════════════════ | |
| function copyBlock(button, codeId) { | |
| const codeEl = document.getElementById(codeId); | |
| if (!codeEl) return; | |
| navigator.clipboard.writeText(codeEl.textContent).then(() => { | |
| button.textContent = '\u2713 Copied!'; | |
| button.classList.add('copied'); | |
| setTimeout(() => { button.textContent = '\ud83d\udccb Copy'; button.classList.remove('copied'); }, 2000); | |
| }); | |
| } | |
| function copyCode() { | |
| if (!state.lastCode) return; | |
| const btn = document.getElementById('btn-copy-code'); | |
| navigator.clipboard.writeText(state.lastCode).then(() => { | |
| btn.textContent = '\u2713 Copied!'; | |
| setTimeout(() => { btn.textContent = '\ud83d\udccb Copy'; }, 2000); | |
| }); | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // STATUS BAR | |
| // ═══════════════════════════════════════════════════════ | |
| function renderStatus(text, statusState) { | |
| const indicator = document.getElementById('status-indicator'); | |
| const textEl = document.getElementById('status-text'); | |
| const dotEl = indicator.querySelector('.status-dot'); | |
| indicator.className = 'status-indicator'; | |
| switch (statusState) { | |
| case 'working': indicator.classList.add('status-working'); dotEl.textContent = '\u25d0'; break; | |
| case 'success': indicator.classList.add('status-success'); dotEl.textContent = '\u2713'; break; | |
| case 'error': indicator.classList.add('status-error'); dotEl.textContent = '\u2717'; break; | |
| case 'info': indicator.classList.add('status-info'); dotEl.textContent = '\u2139'; break; | |
| default: indicator.classList.add('status-idle'); dotEl.textContent = '\u25cf'; | |
| } | |
| textEl.textContent = text || 'IDLE'; | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // OUTPUT PANEL | |
| // ═══════════════════════════════════════════════════════ | |
| function switchTab(tab, { forcePreviewReload = false } = {}) { | |
| const wasPreview = state.activeTab === 'preview'; | |
| state.activeTab = tab; | |
| document.querySelectorAll('.output-tab').forEach((btn) => { | |
| btn.classList.toggle('active', btn.dataset.tab === tab); | |
| }); | |
| document.querySelectorAll('.tab-pane').forEach((pane) => { | |
| pane.classList.toggle('active', pane.id === `pane-${tab}`); | |
| }); | |
| if (tab === 'preview') { | |
| ensureWebPreviewLoaded({ forceReload: forcePreviewReload || !wasPreview }); | |
| } | |
| } | |
| function renderExecution(execution) { | |
| if (!execution) return; | |
| state.lastExecution = execution; | |
| // Console | |
| document.getElementById('console-stdout').textContent = execution.stdout || 'No output.'; | |
| document.getElementById('console-stderr').textContent = execution.stderr || 'No errors.'; | |
| // Code | |
| if (execution.code) { | |
| state.lastCode = execution.code; | |
| state.lastCodeLang = execution.language || 'code'; | |
| document.getElementById('code-tab-lang').textContent = state.lastCodeLang; | |
| document.getElementById('code-display').innerHTML = `<pre>${escapeHtml(execution.code)}</pre>`; | |
| } | |
| // Download | |
| const dlBtn = document.getElementById('btn-download'); | |
| if (execution.download_url) { | |
| dlBtn.href = execution.download_url; | |
| dlBtn.style.display = 'inline-flex'; | |
| dlBtn.setAttribute('download', ''); | |
| } else { | |
| dlBtn.style.display = 'none'; | |
| } | |
| // Preview | |
| const placeholder = document.getElementById('preview-placeholder'); | |
| const img = document.getElementById('preview-image'); | |
| const iframe = getPreviewIframe(); | |
| const fsBtn = document.getElementById('btn-fullscreen'); | |
| if (execution.image_url) { | |
| placeholder.style.display = 'none'; | |
| iframe.style.display = 'none'; | |
| fsBtn.style.display = 'none'; | |
| img.src = execution.image_url; | |
| img.style.display = 'block'; | |
| if (state.activeTab !== 'console' && state.activeTab !== 'code' && state.activeTab !== 'search' && state.activeTab !== 'deploy') { | |
| switchTab('preview'); | |
| } | |
| } else if (execution.is_web && execution.code) { | |
| placeholder.style.display = 'none'; | |
| img.style.display = 'none'; | |
| iframe.style.display = 'block'; | |
| fsBtn.style.display = 'block'; | |
| state.pendingWebPreviewCode = execution.code; | |
| state.loadedWebPreviewCode = ''; | |
| state.scheduledWebPreviewCode = ''; | |
| if (state.activeTab !== 'console' && state.activeTab !== 'code' && state.activeTab !== 'search' && state.activeTab !== 'deploy') { | |
| switchTab('preview', { forcePreviewReload: true }); | |
| } else { | |
| iframe.srcdoc = ''; | |
| } | |
| } else if (execution.is_gradio && execution.gradio_url) { | |
| // Gradio app handling | |
| placeholder.style.display = 'none'; | |
| img.style.display = 'none'; | |
| iframe.style.display = 'block'; | |
| fsBtn.style.display = 'block'; | |
| // Show Gradio badge | |
| const badge = document.createElement('span'); | |
| badge.className = 'gradio-badge'; | |
| badge.innerHTML = '\u26a1 Gradio'; | |
| const codeTabHeader = document.querySelector('.code-tab-header'); | |
| if (codeTabHeader) codeTabHeader.prepend(badge); | |
| state.pendingWebPreviewCode = `<html><body style="margin:0;display:flex;align-items:center;justify-content:center;height:100vh;font-family:monospace;background:#0d1117;color:#a855f7;"><div style="text-align:center"><h2>\u26a1 Gradio App Running</h2><p>App is running at: <a href="${execution.gradio_url}" target="_blank" style="color:#00d4ff">${execution.gradio_url}</a></p><p style="color:#8b949e;font-size:12px">Open in a new tab to interact with the Gradio interface</p></div></body></html>`; | |
| state.loadedWebPreviewCode = ''; | |
| state.scheduledWebPreviewCode = ''; | |
| switchTab('preview', { forcePreviewReload: true }); | |
| } else { | |
| if (execution.stdout || execution.stderr) { | |
| const suggested = execution.suggested_tab || 'console'; | |
| if (state.activeTab !== 'deploy') switchTab(suggested); | |
| } | |
| } | |
| // Deploy tab - project files | |
| renderProjectFiles(execution.project_files || {}); | |
| // Enable deploy button | |
| document.getElementById('btn-push-hf').disabled = !execution.code; | |
| } | |
| function renderProjectFiles(files) { | |
| const container = document.getElementById('project-files'); | |
| if (!files || Object.keys(files).length === 0) { | |
| container.innerHTML = ''; | |
| return; | |
| } | |
| let html = '<div style="margin-top: 12px; font-size: 10px; color: var(--gray-dim); letter-spacing: 1px; text-transform: uppercase;">Project Files:</div>'; | |
| for (const [filepath, content] of Object.entries(files)) { | |
| const ext = filepath.split('.').pop(); | |
| const icon = getFileIcon(ext); | |
| html += `<div class="file-item"><span class="file-icon">${icon}</span><span class="file-name">${escapeHtml(filepath)}</span><span style="color:var(--gray-dim);font-size:10px;">(${content.length} chars)</span></div>`; | |
| } | |
| container.innerHTML = html; | |
| } | |
| function getFileIcon(ext) { | |
| const icons = { | |
| 'py': '\ud83d\udc0d', 'js': '\u26a1', 'ts': '\ud83d\udde1\ufe0f', 'html': '\ud83c\udf10', | |
| 'css': '\ud83c\udfa8', 'json': '\ud83d\udcc4', 'md': '\ud83d\udcd3', 'yml': '\u2699\ufe0f', | |
| 'yaml': '\u2699\ufe0f', 'java': '\u2615', 'go': '\ud83e\udd85', 'rs': '\ud83e\udd80', | |
| 'php': '\ud83d\udc18', 'rb': '\ud83d\udc8e', 'swift': '\ud83e\udd85', 'kt': '\ud83c\udf0a', | |
| }; | |
| return icons[ext] || '\ud83d\udcc1'; | |
| } | |
| function resetOutput() { | |
| const iframe = getPreviewIframe(); | |
| document.getElementById('preview-placeholder').style.display = ''; | |
| document.getElementById('preview-image').style.display = 'none'; | |
| iframe.style.display = 'none'; | |
| iframe.srcdoc = ''; | |
| document.getElementById('btn-fullscreen').style.display = 'none'; | |
| document.getElementById('console-stdout').textContent = 'No output.'; | |
| document.getElementById('console-stderr').textContent = 'No errors.'; | |
| document.getElementById('code-display').innerHTML = '<div class="code-placeholder">No code generated yet.</div>'; | |
| document.getElementById('code-tab-lang').textContent = '\u2014'; | |
| document.getElementById('btn-download').style.display = 'none'; | |
| document.getElementById('project-files').innerHTML = ''; | |
| document.getElementById('deploy-status').className = 'deploy-status'; | |
| document.getElementById('deploy-status').style.display = 'none'; | |
| state.lastExecution = null; | |
| state.lastCode = ''; | |
| state.lastCodeLang = ''; | |
| state.pendingWebPreviewCode = ''; | |
| state.loadedWebPreviewCode = ''; | |
| state.scheduledWebPreviewCode = ''; | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // FULLSCREEN | |
| // ═══════════════════════════════════════════════════════ | |
| function getPreviewIframe() { return document.getElementById('preview-iframe'); } | |
| function recreatePreviewIframe() { | |
| const oldFrame = getPreviewIframe(); | |
| const freshFrame = document.createElement('iframe'); | |
| freshFrame.id = 'preview-iframe'; | |
| freshFrame.setAttribute('sandbox', 'allow-scripts'); | |
| freshFrame.style.display = oldFrame.style.display || 'block'; | |
| oldFrame.replaceWith(freshFrame); | |
| return freshFrame; | |
| } | |
| function ensureWebPreviewLoaded({ forceReload = false } = {}) { | |
| const iframe = getPreviewIframe(); | |
| if (!state.pendingWebPreviewCode || state.activeTab !== 'preview' || iframe.style.display === 'none') return; | |
| if (!forceReload && state.loadedWebPreviewCode === state.pendingWebPreviewCode) { | |
| schedulePreviewResize(iframe); | |
| return; | |
| } | |
| if (!forceReload && state.scheduledWebPreviewCode === state.pendingWebPreviewCode) return; | |
| state.scheduledWebPreviewCode = state.pendingWebPreviewCode; | |
| iframe.srcdoc = ''; | |
| const loadWhenLaidOut = () => { | |
| if (state.activeTab !== 'preview' || !state.pendingWebPreviewCode) { | |
| state.scheduledWebPreviewCode = ''; | |
| return; | |
| } | |
| if (!forceReload && state.loadedWebPreviewCode === state.pendingWebPreviewCode) return; | |
| const visibleFrame = getPreviewIframe(); | |
| const rect = visibleFrame.getBoundingClientRect(); | |
| if (rect.width < 10 || rect.height < 10) { | |
| state.scheduledWebPreviewCode = ''; | |
| setTimeout(() => ensureWebPreviewLoaded({ forceReload }), 50); | |
| return; | |
| } | |
| const freshFrame = recreatePreviewIframe(); | |
| freshFrame.srcdoc = state.pendingWebPreviewCode; | |
| state.loadedWebPreviewCode = state.pendingWebPreviewCode; | |
| state.scheduledWebPreviewCode = ''; | |
| schedulePreviewResize(freshFrame); | |
| }; | |
| requestAnimationFrame(() => requestAnimationFrame(loadWhenLaidOut)); | |
| setTimeout(loadWhenLaidOut, 75); | |
| } | |
| function schedulePreviewResize(iframe) { | |
| const dispatchResize = () => { | |
| try { iframe.contentWindow?.dispatchEvent(new Event('resize')); } catch (_err) {} | |
| }; | |
| requestAnimationFrame(() => requestAnimationFrame(dispatchResize)); | |
| setTimeout(dispatchResize, 100); | |
| setTimeout(dispatchResize, 350); | |
| } | |
| function observePreviewSize() { | |
| const previewPane = document.getElementById('pane-preview'); | |
| if (!previewPane) return; | |
| window.addEventListener('resize', () => { | |
| if (state.activeTab === 'preview' && state.loadedWebPreviewCode) { | |
| schedulePreviewResize(getPreviewIframe()); | |
| } | |
| }); | |
| if (typeof ResizeObserver === 'undefined') return; | |
| const observer = new ResizeObserver(() => { | |
| if (state.activeTab === 'preview' && state.loadedWebPreviewCode) { | |
| schedulePreviewResize(getPreviewIframe()); | |
| } | |
| }); | |
| observer.observe(previewPane); | |
| } | |
| function openFullscreen() { | |
| const overlay = document.getElementById('fullscreen-overlay'); | |
| const iframe = document.getElementById('fullscreen-iframe'); | |
| if (state.lastExecution && state.lastExecution.is_web && state.lastExecution.code) { | |
| iframe.srcdoc = state.lastExecution.code; | |
| } | |
| overlay.classList.add('active'); | |
| } | |
| function closeFullscreen() { | |
| document.getElementById('fullscreen-overlay').classList.remove('active'); | |
| document.getElementById('fullscreen-iframe').srcdoc = ''; | |
| } | |
| function handleFullscreenKeydown(event) { | |
| if (event.key !== 'Escape') return; | |
| const overlay = document.getElementById('fullscreen-overlay'); | |
| if (!overlay.classList.contains('active')) return; | |
| event.preventDefault(); | |
| closeFullscreen(); | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // SEND / RECEIVE | |
| // ═══════════════════════════════════════════════════════ | |
| function handleSend() { | |
| const input = document.getElementById('chat-input'); | |
| const prompt = input.value.trim(); | |
| if (!prompt || state.isGenerating) return; | |
| input.value = ''; | |
| autoResize(); | |
| sendMessage(prompt); | |
| } | |
| async function sendMessage(prompt) { | |
| if (state.isGenerating) return; | |
| if (!state.modelReady) { | |
| addSystemMessage('The model is still loading. Please wait...'); | |
| return; | |
| } | |
| state.isGenerating = true; | |
| toggleInputState(true); | |
| addUserMessage(prompt); | |
| addAssistantMessage(); | |
| renderStatus('Thinking...', 'working'); | |
| const historyJSON = JSON.stringify(state.history); | |
| const execContextJSON = JSON.stringify(state.executionContext); | |
| const framework = document.getElementById('framework-select')?.value || state.targetFramework; | |
| try { | |
| const resp = await fetch('/gradio_api/call/chat', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| data: [prompt, state.targetLanguage, framework, historyJSON, execContextJSON, state.searchEnabled ? 'true' : 'false', state.uploadedImageFileUrl || ''] | |
| }) | |
| }); | |
| if (!resp.ok) throw new Error(`API error: ${resp.status} ${resp.statusText}`); | |
| const { event_id } = await resp.json(); | |
| const eventSource = new EventSource(`/gradio_api/call/chat/${event_id}`); | |
| state.currentEventSource = eventSource; | |
| eventSource.addEventListener('generating', (e) => { | |
| try { | |
| const dataArray = JSON.parse(e.data); | |
| const payload = JSON.parse(dataArray[0]); | |
| handlePayload(payload, true); | |
| } catch (err) { console.error('Parse error (generating):', err); } | |
| }); | |
| eventSource.addEventListener('complete', (e) => { | |
| try { | |
| const dataArray = JSON.parse(e.data); | |
| const payload = JSON.parse(dataArray[0]); | |
| handlePayload(payload, false); | |
| } catch (err) { console.error('Parse error (complete):', err); } | |
| eventSource.close(); | |
| onGenerationEnd(); | |
| }); | |
| eventSource.addEventListener('error', (e) => { | |
| let errorMsg = 'An error occurred during generation.'; | |
| if (e.data) errorMsg = e.data; | |
| console.error('SSE error:', errorMsg); | |
| finalizeAssistantMessage(); | |
| addSystemMessage(`Error: ${errorMsg}`); | |
| renderStatus('Error', 'error'); | |
| eventSource.close(); | |
| onGenerationEnd(); | |
| }); | |
| } catch (err) { | |
| console.error('Send error:', err); | |
| finalizeAssistantMessage(); | |
| addSystemMessage(`Error: ${err.message}`); | |
| renderStatus('Error', 'error'); | |
| onGenerationEnd(); | |
| } | |
| } | |
| function handlePayload(payload, isStreaming) { | |
| if (payload.status_text) renderStatus(payload.status_text, payload.status_state || 'working'); | |
| // Handle search results (show inline source badge) | |
| if (payload.type === 'search_results' && payload.search_results) { | |
| state.currentSearchResults = payload.search_results; | |
| state.lastSearchResults = payload.search_results; | |
| renderSearchSourceBadge(payload.search_results, false); | |
| // Also render in the Search tab | |
| renderSearchResults(payload.search_results); | |
| } | |
| if (payload.history) { | |
| state.history = payload.history; | |
| const lastMsg = payload.history[payload.history.length - 1]; | |
| if (lastMsg && lastMsg.role === 'assistant') { | |
| updateAssistantMessage(lastMsg.content, isStreaming); | |
| } | |
| } | |
| if (payload.execution) { | |
| renderExecution(payload.execution); | |
| if (payload.execution) state.executionContext = payload.execution; | |
| } | |
| if (payload.type === 'complete') { | |
| finalizeAssistantMessage(); | |
| renderStatus('Done', 'success'); | |
| setTimeout(() => { if (!state.isGenerating) renderStatus('Idle', 'idle'); }, 3000); | |
| } | |
| if (payload.type === 'error') { | |
| finalizeAssistantMessage(); | |
| addSystemMessage(`Error: ${payload.status_text || 'Unknown error'}`); | |
| renderStatus('Error', 'error'); | |
| } | |
| if (payload.execution && payload.execution.suggested_tab) { | |
| switchTab(payload.execution.suggested_tab); | |
| } | |
| } | |
| function onGenerationEnd() { | |
| state.isGenerating = false; | |
| state.currentEventSource = null; | |
| toggleInputState(false); | |
| } | |
| function toggleInputState(generating) { | |
| const sendBtn = document.getElementById('btn-send'); | |
| const stopBtn = document.getElementById('btn-stop'); | |
| const input = document.getElementById('chat-input'); | |
| if (generating) { | |
| sendBtn.style.display = 'none'; | |
| stopBtn.style.display = 'flex'; | |
| input.disabled = true; | |
| input.placeholder = 'Generating...'; | |
| } else { | |
| sendBtn.style.display = 'flex'; | |
| stopBtn.style.display = 'none'; | |
| sendBtn.disabled = false; | |
| input.disabled = false; | |
| input.placeholder = 'Describe the app you want to build...'; | |
| input.focus(); | |
| } | |
| } | |
| function stopGeneration() { | |
| if (state.currentEventSource) { | |
| state.currentEventSource.close(); | |
| state.currentEventSource = null; | |
| } | |
| finalizeAssistantMessage(); | |
| addSystemMessage('Generation stopped by user.'); | |
| renderStatus('Stopped', 'info'); | |
| onGenerationEnd(); | |
| } | |
| function resetConversation(announcement) { | |
| state.history = []; | |
| state.executionContext = {}; | |
| state.lastExecution = null; | |
| state.lastCode = ''; | |
| state.lastCodeLang = ''; | |
| state.reasoningExpanded = false; | |
| if (state.currentEventSource) { | |
| state.currentEventSource.close(); | |
| state.currentEventSource = null; | |
| } | |
| state.isGenerating = false; | |
| toggleInputState(false); | |
| document.getElementById('chat-messages').innerHTML = ''; | |
| resetOutput(); | |
| switchTab('preview'); | |
| renderStatus('Idle', 'idle'); | |
| if (announcement) addSystemMessage(announcement); | |
| } | |
| function newChat() { | |
| resetConversation(`Session reset. Welcome back to ${CONFIG.app_title || 'SoniCoder'}.`); | |
| } | |
| function toggleThinking() { | |
| state.showThinking = !state.showThinking; | |
| const btn = document.getElementById('btn-thinking'); | |
| if (state.showThinking) { | |
| btn.classList.add('active'); | |
| document.body.classList.remove('hide-thinking'); | |
| btn.textContent = '🧠 Think'; | |
| } else { | |
| btn.classList.remove('active'); | |
| document.body.classList.add('hide-thinking'); | |
| btn.textContent = '🧠 Think'; | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // MODEL SWITCHING | |
| // ═══════════════════════════════════════════════════════ | |
| async function onModelChange() { | |
| const select = document.getElementById('model-select'); | |
| const modelKey = select.value; | |
| if (modelKey === state.currentModelKey) return; | |
| const isVLM = modelKey === 'minicpm-v-4.6'; | |
| const imageGroup = document.getElementById('image-attach-group'); | |
| if (isVLM) { | |
| imageGroup.style.display = 'flex'; | |
| } else { | |
| imageGroup.style.display = 'none'; | |
| removeImage(); | |
| } | |
| addSystemMessage(`Switching model to ${select.options[select.selectedIndex].text}...`); | |
| renderStatus('Switching model...', 'working'); | |
| try { | |
| const resp = await fetch('/gradio_api/call/switch_model', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ data: [modelKey] }) | |
| }); | |
| const { event_id } = await resp.json(); | |
| const eventSource = new EventSource(`/gradio_api/call/switch_model/${event_id}`); | |
| eventSource.addEventListener('complete', (e) => { | |
| const dataArray = JSON.parse(e.data); | |
| const result = JSON.parse(dataArray[0]); | |
| if (result.success) { | |
| state.currentModelKey = modelKey; | |
| state.currentModelType = isVLM ? 'vlm' : 'text'; | |
| const name = isVLM ? 'MiniCPM-V-4.6' : 'MiniCPM5-1B'; | |
| document.getElementById('model-pill-text').textContent = name; | |
| document.getElementById('header-model-name').textContent = name; | |
| addSystemMessage(`Switched to ${name}. Model is loading in background...`); | |
| // Poll for model ready | |
| pollModelStatus(); | |
| } else { | |
| addSystemMessage(`Failed to switch: ${result.message}`); | |
| select.value = state.currentModelKey; | |
| } | |
| eventSource.close(); | |
| }); | |
| eventSource.addEventListener('error', () => { | |
| addSystemMessage('Model switch failed'); | |
| select.value = state.currentModelKey; | |
| eventSource.close(); | |
| }); | |
| } catch (err) { | |
| addSystemMessage(`Switch error: ${err.message}`); | |
| select.value = state.currentModelKey; | |
| } | |
| } | |
| function pollModelStatus() { | |
| const interval = setInterval(async () => { | |
| try { | |
| const resp = await fetch('/api/model-status'); | |
| const status = await resp.json(); | |
| if (status.status === 'ready') { | |
| state.modelReady = true; | |
| const dot = document.getElementById('model-dot'); | |
| dot.className = 'dot'; | |
| dot.style.background = 'var(--success)'; | |
| dot.style.boxShadow = '0 0 6px var(--success)'; | |
| renderStatus('Ready', 'success'); | |
| setTimeout(() => renderStatus('Idle', 'idle'), 2000); | |
| clearInterval(interval); | |
| } else if (status.status === 'error') { | |
| state.modelReady = false; | |
| renderStatus('Model error', 'error'); | |
| clearInterval(interval); | |
| } | |
| } catch { clearInterval(interval); } | |
| }, 2000); | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // IMAGE UPLOAD (VLM) | |
| // ═══════════════════════════════════════════════════════ | |
| async function onImageUpload(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| state.uploadedImageName = file.name; | |
| document.getElementById('image-attach-name').textContent = file.name; | |
| document.getElementById('btn-remove-image').style.display = 'inline'; | |
| // Convert to base64 | |
| const reader = new FileReader(); | |
| reader.onload = async function(e) { | |
| const base64Data = e.target.result; | |
| // Upload to server | |
| try { | |
| const resp = await fetch('/gradio_api/call/upload_image', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ data: [base64Data] }) | |
| }); | |
| const { event_id } = await resp.json(); | |
| const eventSource = new EventSource(`/gradio_api/call/upload_image/${event_id}`); | |
| eventSource.addEventListener('complete', (ev) => { | |
| const dataArray = JSON.parse(ev.data); | |
| const result = JSON.parse(dataArray[0]); | |
| if (result.success) { | |
| state.uploadedImageFileUrl = result.file_url; | |
| document.getElementById('image-attach-name').textContent = '✓ ' + file.name; | |
| } else { | |
| document.getElementById('image-attach-name').textContent = '✗ Upload failed'; | |
| } | |
| eventSource.close(); | |
| }); | |
| eventSource.addEventListener('error', () => { | |
| document.getElementById('image-attach-name').textContent = '✗ Upload error'; | |
| eventSource.close(); | |
| }); | |
| } catch (err) { | |
| document.getElementById('image-attach-name').textContent = '✗ Error'; | |
| } | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function removeImage() { | |
| state.uploadedImageFileUrl = ''; | |
| state.uploadedImageName = ''; | |
| document.getElementById('image-upload').value = ''; | |
| document.getElementById('image-attach-name').textContent = ''; | |
| document.getElementById('btn-remove-image').style.display = 'none'; | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // WEB SEARCH | |
| // ═══════════════════════════════════════════════════════ | |
| async function doWebSearch() { | |
| const query = document.getElementById('search-input').value.trim(); | |
| if (!query) return; | |
| const resultsContainer = document.getElementById('search-results'); | |
| resultsContainer.innerHTML = '<div class="search-results-empty" style="color:var(--amber);">Searching...</div>'; | |
| try { | |
| const resp = await fetch('/gradio_api/call/web_search', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ data: [query] }) | |
| }); | |
| const { event_id } = await resp.json(); | |
| const eventSource = new EventSource(`/gradio_api/call/web_search/${event_id}`); | |
| eventSource.addEventListener('complete', (e) => { | |
| const dataArray = JSON.parse(e.data); | |
| const result = JSON.parse(dataArray[0]); | |
| if (result.success) { | |
| state.lastSearchResults = result.results; | |
| renderSearchResults(result.results); | |
| } else { | |
| resultsContainer.innerHTML = `<div class="search-results-empty">${escapeHtml(result.message)}</div>`; | |
| } | |
| eventSource.close(); | |
| }); | |
| eventSource.addEventListener('error', (e) => { | |
| resultsContainer.innerHTML = '<div class="search-results-empty">Search failed</div>'; | |
| eventSource.close(); | |
| }); | |
| } catch (err) { | |
| resultsContainer.innerHTML = `<div class="search-results-empty">Error: ${err.message}</div>`; | |
| } | |
| } | |
| function searchAndGenerate() { | |
| const input = document.getElementById('chat-input'); | |
| const prompt = input.value.trim(); | |
| if (!prompt || state.isGenerating) return; | |
| input.value = ''; | |
| autoResize(); | |
| state.searchEnabled = true; | |
| sendMessage(prompt); | |
| // Reset after sending | |
| state.searchEnabled = false; | |
| } | |
| function renderSearchResults(results) { | |
| const container = document.getElementById('search-results'); | |
| if (!results || results.length === 0) { | |
| container.innerHTML = '<div class="search-results-empty">No results found.</div>'; | |
| return; | |
| } | |
| let html = ''; | |
| results.forEach((r) => { | |
| html += `<div class="search-result-item"> | |
| <a class="search-result-title" href="${escapeHtml(r.url)}" target="_blank" rel="noopener">${escapeHtml(r.title)}</a> | |
| <span class="search-result-url">${escapeHtml(r.url)}</span> | |
| <div class="search-result-snippet">${escapeHtml(r.snippet)}</div> | |
| </div>`; | |
| }); | |
| container.innerHTML = html; | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // SEARCH SOURCE BADGE (Grok-style inline in chat) | |
| // ═══════════════════════════════════════════════════════ | |
| function renderSearchSourceBadge(results, expanded) { | |
| const div = document.getElementById('current-assistant-msg'); | |
| if (!div) return; | |
| const container = div.querySelector('.search-source-container'); | |
| if (!container || !results || results.length === 0) return; | |
| const count = results.length; | |
| const openClass = expanded ? ' open' : ''; | |
| // Build source items HTML | |
| let sourceItems = ''; | |
| results.forEach((r) => { | |
| let domain = ''; | |
| try { domain = new URL(r.url).hostname.replace('www.', ''); } catch(e) { domain = r.host_name || r.url; } | |
| const faviconUrl = r.favicon || `https://www.google.com/s2/favicons?domain=${domain}&sz=16`; | |
| sourceItems += `<div class="source-item"> | |
| <img class="source-favicon" src="${escapeHtml(faviconUrl)}" alt="" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"> | |
| <div class="source-favicon-placeholder" style="display:none;">${escapeHtml(domain.charAt(0).toUpperCase())}</div> | |
| <div class="source-info"> | |
| <a class="source-title" href="${escapeHtml(r.url)}" target="_blank" rel="noopener" onclick="event.stopPropagation();">${escapeHtml(r.title)}</a> | |
| <span class="source-domain">${escapeHtml(domain)}</span> | |
| <div class="source-snippet">${escapeHtml(r.snippet)}</div> | |
| </div> | |
| </div>`; | |
| }); | |
| container.innerHTML = ` | |
| <div class="search-source-badge${openClass}" onclick="toggleSearchPanel(this)"> | |
| <span class="badge-icon">🔍</span> | |
| <span>Searched</span> | |
| <span class="badge-count">${count} source${count !== 1 ? 's' : ''}</span> | |
| <span class="badge-arrow">▼</span> | |
| </div> | |
| <div class="search-source-panel${openClass}"> | |
| ${sourceItems} | |
| </div> | |
| `; | |
| scrollToBottom(); | |
| } | |
| function toggleSearchPanel(badge) { | |
| const panel = badge.nextElementSibling; | |
| const isOpen = badge.classList.contains('open'); | |
| badge.classList.toggle('open', !isOpen); | |
| panel.classList.toggle('open', !isOpen); | |
| state.searchPanelExpanded = !isOpen; | |
| scrollToBottom(); | |
| } | |
| // ═══════════════════════════════════════════════════════ | |
| // HUGGINGFACE OAUTH + PUSH | |
| // ═══════════════════════════════════════════════════════ | |
| // OAuth state | |
| state.hfAuth = { | |
| authenticated: false, | |
| token: '', | |
| username: '', | |
| name: '', | |
| picture: '', | |
| organizations: [], | |
| }; | |
| async function loginWithHF() { | |
| // On HuggingFace Spaces, Gradio handles OAuth via /login/huggingface | |
| // We redirect to the Gradio OAuth endpoint | |
| const currentUrl = window.location.href; | |
| const loginUrl = `/login/huggingface?callback_url=${encodeURIComponent(currentUrl)}`; | |
| window.location.href = loginUrl; | |
| } | |
| function logoutHF() { | |
| state.hfAuth = { | |
| authenticated: false, | |
| token: '', | |
| username: '', | |
| name: '', | |
| picture: '', | |
| organizations: [], | |
| }; | |
| // Update UI | |
| document.getElementById('btn-hf-login').style.display = ''; | |
| document.getElementById('hf-user-info').style.display = 'none'; | |
| document.getElementById('hf-manual-token-section').style.display = ''; | |
| document.getElementById('hf-auth-hint').textContent = 'Sign in with OAuth — no token paste needed'; | |
| // Reset owner dropdown | |
| const ownerSelect = document.getElementById('hf-owner'); | |
| ownerSelect.innerHTML = '<option value="">Sign in to see options</option>'; | |
| // Check for Gradio OAuth token in session | |
| checkGradioOAuth(); | |
| } | |
| async function checkGradioOAuth() { | |
| // Try to get the OAuth token from Gradio's session | |
| // Gradio stores the token in cookies/session after OAuth login | |
| try { | |
| // Check if we have a Gradio session with OAuth token | |
| const resp = await fetch('/gradio_api/call/hf_auth', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ data: [''] }) | |
| }); | |
| if (!resp.ok) return; | |
| const { event_id } = await resp.json(); | |
| const eventSource = new EventSource(`/gradio_api/call/hf_auth/${event_id}`); | |
| eventSource.addEventListener('complete', (e) => { | |
| try { | |
| const dataArray = JSON.parse(e.data); | |
| const result = JSON.parse(dataArray[0]); | |
| if (result.authenticated) { | |
| handleAuthResult(result); | |
| } | |
| } catch (err) { /* not authenticated */ } | |
| eventSource.close(); | |
| }); | |
| eventSource.addEventListener('error', () => { eventSource.close(); }); | |
| } catch (err) { | |
| // OAuth not available — show manual token input | |
| } | |
| } | |
| function handleAuthResult(result) { | |
| state.hfAuth = { | |
| authenticated: result.authenticated, | |
| token: result.token || '', | |
| username: result.username || '', | |
| name: result.name || '', | |
| picture: result.picture || '', | |
| organizations: result.organizations || [], | |
| }; | |
| if (result.authenticated) { | |
| // Update UI — show user info | |
| document.getElementById('btn-hf-login').style.display = 'none'; | |
| document.getElementById('hf-user-info').style.display = ''; | |
| document.getElementById('hf-user-name').textContent = result.name || result.username; | |
| if (result.picture) { | |
| document.getElementById('hf-user-avatar').src = result.picture; | |
| document.getElementById('hf-user-avatar').style.display = ''; | |
| } else { | |
| document.getElementById('hf-user-avatar').style.display = 'none'; | |
| } | |
| document.getElementById('hf-auth-hint').textContent = `Signed in as ${result.username}`; | |
| document.getElementById('hf-manual-token-section').style.display = 'none'; | |
| // Populate owner dropdown | |
| const ownerSelect = document.getElementById('hf-owner'); | |
| ownerSelect.innerHTML = ''; | |
| // Add user as first option | |
| const userOpt = document.createElement('option'); | |
| userOpt.value = result.username; | |
| userOpt.textContent = `${result.username} (you)`; | |
| ownerSelect.appendChild(userOpt); | |
| // Add organizations | |
| if (result.organizations && result.organizations.length > 0) { | |
| result.organizations.forEach(org => { | |
| const opt = document.createElement('option'); | |
| opt.value = org.name; | |
| const roleSuffix = org.role ? ` (${org.role})` : ''; | |
| opt.textContent = `${org.name}${roleSuffix}`; | |
| ownerSelect.appendChild(opt); | |
| }); | |
| } | |
| // Auto-fill token in hidden field for push | |
| document.getElementById('hf-token').value = result.token; | |
| } | |
| } | |
| async function pushToHuggingFace() { | |
| const ownerSelect = document.getElementById('hf-owner'); | |
| const owner = ownerSelect.value; | |
| const repoName = document.getElementById('hf-repo-name').value.trim(); | |
| const hfToken = document.getElementById('hf-token').value.trim(); | |
| const spaceSdk = document.getElementById('hf-space-sdk').value; | |
| const statusEl = document.getElementById('deploy-status'); | |
| // Build full repo name | |
| let fullRepoName = repoName; | |
| if (owner && repoName && !repoName.includes('/')) { | |
| fullRepoName = `${owner}/${repoName}`; | |
| } | |
| if (!fullRepoName) { | |
| statusEl.className = 'deploy-status error'; | |
| statusEl.textContent = 'Please enter a repository name.'; | |
| statusEl.style.display = 'block'; | |
| return; | |
| } | |
| if (!hfToken) { | |
| statusEl.className = 'deploy-status error'; | |
| statusEl.textContent = 'Please sign in with HuggingFace or enter a token.'; | |
| statusEl.style.display = 'block'; | |
| return; | |
| } | |
| if (!state.executionContext || !state.executionContext.code) { | |
| statusEl.className = 'deploy-status error'; | |
| statusEl.textContent = 'No code to push. Generate some code first.'; | |
| statusEl.style.display = 'block'; | |
| return; | |
| } | |
| statusEl.className = 'deploy-status working'; | |
| statusEl.textContent = 'Pushing to HuggingFace...'; | |
| statusEl.style.display = 'block'; | |
| const btn = document.getElementById('btn-push-hf'); | |
| btn.disabled = true; | |
| try { | |
| const execContextJSON = JSON.stringify(state.executionContext); | |
| const resp = await fetch('/gradio_api/call/push_hf', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| data: [execContextJSON, fullRepoName, hfToken, spaceSdk, 'true'] | |
| }) | |
| }); | |
| if (!resp.ok) throw new Error(`API error: ${resp.status}`); | |
| const { event_id } = await resp.json(); | |
| const eventSource = new EventSource(`/gradio_api/call/push_hf/${event_id}`); | |
| eventSource.addEventListener('complete', (e) => { | |
| try { | |
| const dataArray = JSON.parse(e.data); | |
| const result = JSON.parse(dataArray[0]); | |
| if (result.success) { | |
| statusEl.className = 'deploy-status success'; | |
| statusEl.innerHTML = `\u2713 ${result.message}<br><a href="${result.url}" target="_blank" rel="noopener">${result.url} \u2197</a>`; | |
| } else { | |
| statusEl.className = 'deploy-status error'; | |
| statusEl.textContent = `\u2717 ${result.message}`; | |
| } | |
| } catch (err) { | |
| statusEl.className = 'deploy-status error'; | |
| statusEl.textContent = `Parse error: ${err.message}`; | |
| } | |
| eventSource.close(); | |
| btn.disabled = false; | |
| }); | |
| eventSource.addEventListener('error', (e) => { | |
| statusEl.className = 'deploy-status error'; | |
| statusEl.textContent = `Push failed: ${e.data || 'Unknown error'}`; | |
| eventSource.close(); | |
| btn.disabled = false; | |
| }); | |
| } catch (err) { | |
| statusEl.className = 'deploy-status error'; | |
| statusEl.textContent = `Push failed: ${err.message}`; | |
| btn.disabled = false; | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |