Spaces:
Running on Zero
Running on Zero
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>MiniCPM-V | OpenBMB Premium</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"> | |
| <script src="https://unpkg.com/lucide@latest"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css"> | |
| <script src="https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/contrib/auto-render.min.js"></script> | |
| <style> | |
| :root { | |
| --bg: #05070A; | |
| --blue: #3B5BFF; | |
| --cyan: #27D4EA; | |
| --text: #FFFFFF; | |
| --text-muted: #8B949E; | |
| --glass: rgba(255, 255, 255, 0.03); | |
| --glass-border: rgba(255, 255, 255, 0.08); | |
| --accent: #3B5BFF; | |
| } | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background-color: var(--bg); | |
| color: var(--text); | |
| height: 100vh; | |
| margin: 0; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| h1, h2, h3 { font-family: 'Outfit', sans-serif; } | |
| .chat-scroll-area { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding-bottom: 140px; | |
| -webkit-overflow-scrolling: touch; | |
| scroll-behavior: smooth; | |
| } | |
| .chat-scroll-area::-webkit-scrollbar { width: 4px; } | |
| .chat-scroll-area::-webkit-scrollbar-track { background: transparent; } | |
| .chat-scroll-area::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 10px; } | |
| .message-bubble { | |
| max-width: 85%; | |
| animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; | |
| position: relative; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(15px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .user-message { | |
| background: linear-gradient(135deg, var(--blue), var(--cyan)); | |
| color: #FFFFFF; | |
| box-shadow: 0 8px 25px rgba(59, 91, 255, 0.15); | |
| border-radius: 24px 24px 4px 24px; | |
| } | |
| .bot-message { | |
| background: rgba(255, 255, 255, 0.03); | |
| border: 1px solid var(--glass-border); | |
| border-radius: 24px 24px 24px 4px; | |
| backdrop-filter: blur(10px); | |
| } | |
| .thinking-block { | |
| background: rgba(59, 91, 255, 0.05); | |
| border-left: 3px solid var(--blue); | |
| padding: 12px 16px; | |
| margin-bottom: 12px; | |
| border-radius: 4px 12px 12px 4px; | |
| font-size: 14px; | |
| color: var(--text-muted); | |
| font-style: italic; | |
| } | |
| .typing-dot { | |
| width: 4px; height: 4px; | |
| background: var(--cyan); | |
| border-radius: 50%; | |
| animation: bounce 1.4s infinite ease-in-out; | |
| } | |
| /* Tab Styles */ | |
| .tab-btn { | |
| @apply px-6 py-3 text-sm font-bold text-white/40 border-b-2 border-transparent transition-all; | |
| } | |
| .tab-btn.active { | |
| @apply text-white border-white; | |
| } | |
| .tab-content { | |
| display: none; | |
| } | |
| .tab-content.active { | |
| display: flex; | |
| } | |
| @keyframes bounce { | |
| 0%, 80%, 100% { transform: scale(0.3); opacity: 0.4; } | |
| 40% { transform: scale(1); opacity: 1; } | |
| } | |
| .input-pill { | |
| background: rgba(255, 255, 255, 0.04); | |
| backdrop-filter: blur(25px); | |
| -webkit-backdrop-filter: blur(25px); | |
| border: 1px solid var(--glass-border); | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .input-pill:focus-within { | |
| border-color: rgba(59, 91, 255, 0.4); | |
| background: rgba(255, 255, 255, 0.06); | |
| box-shadow: 0 0 40px rgba(59, 91, 255, 0.08); | |
| } | |
| .logo-glow { | |
| filter: drop-shadow(0 0 15px rgba(39, 212, 234, 0.4)); | |
| } | |
| .send-btn { | |
| background: linear-gradient(135deg, var(--blue), var(--cyan)); | |
| transition: all 0.3s ease; | |
| } | |
| .send-btn:hover:not(:disabled) { transform: scale(1.05); filter: brightness(1.1); } | |
| .send-btn:active:not(:disabled) { transform: scale(0.95); } | |
| .settings-panel { | |
| background: rgba(10, 12, 16, 0.95); | |
| backdrop-filter: blur(30px); | |
| border-left: 1px solid var(--glass-border); | |
| transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); | |
| } | |
| .control-slider { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 4px; | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 2px; | |
| outline: none; | |
| } | |
| .control-slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 12px; height: 12px; | |
| background: var(--blue); | |
| border-radius: 50%; | |
| cursor: pointer; | |
| transition: scale 0.2s; | |
| } | |
| .control-slider::-webkit-slider-thumb:hover { scale: 1.2; } | |
| .toggle-switch { | |
| width: 36px; height: 20px; | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 10px; | |
| position: relative; | |
| cursor: pointer; | |
| transition: background 0.3s; | |
| } | |
| .toggle-switch.active { background: var(--blue); } | |
| .toggle-switch::after { | |
| content: ''; | |
| position: absolute; | |
| top: 2px; left: 2px; | |
| width: 16px; height: 16px; | |
| background: white; | |
| border-radius: 50%; | |
| transition: transform 0.3s; | |
| } | |
| .toggle-switch.active::after { transform: translateX(16px); } | |
| .media-preview-item { | |
| position: relative; | |
| animation: scaleIn 0.3s ease-out; | |
| } | |
| @keyframes scaleIn { from { scale: 0.8; opacity: 0; } to { scale: 1; opacity: 1; } } | |
| .shimmer { | |
| background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent); | |
| background-size: 200% 100%; | |
| animation: shimmer 2s infinite; | |
| } | |
| @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Header --> | |
| <header class="h-20 flex items-center justify-between px-6 md:px-12 shrink-0 z-50 border-b border-white/5"> | |
| <div class="flex items-center gap-4"> | |
| <div class="relative"> | |
| <img src="https://cdn-avatars.huggingface.co/v1/production/uploads/1670387859384-633fe7784b362488336bbfad.png" | |
| alt="OpenBMB" class="w-10 h-10 logo-glow"> | |
| <div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-[var(--bg)]"></div> | |
| </div> | |
| <div> | |
| <h1 class="text-xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/60">MiniCPM-V</h1> | |
| <p class="text-[10px] text-muted uppercase tracking-[0.2em] font-bold opacity-50">By OpenBMB</p> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-6"> | |
| <div class="hidden md:flex items-center gap-2 text-[10px] font-bold text-muted uppercase tracking-widest bg-white/5 px-3 py-1.5 rounded-full border border-white/5"> | |
| <span class="w-1.5 h-1.5 rounded-full bg-[#27D4EA] animate-pulse"></span> | |
| v4.6 Intelligence Engine | |
| </div> | |
| <button id="toggle-settings" class="p-2.5 rounded-xl hover:bg-white/5 text-white/40 hover:text-white transition-all relative"> | |
| <i data-lucide="sliders-horizontal" class="w-5 h-5"></i> | |
| </button> | |
| </div> | |
| </header> | |
| <!-- Settings Panel (Side) --> | |
| <div id="settings-panel" class="fixed top-0 right-0 h-full w-80 z-[100] translate-x-full settings-panel p-8 flex flex-col gap-8 shadow-[-20px_0_50px_rgba(0,0,0,0.5)]"> | |
| <div class="flex items-center justify-between"> | |
| <h2 class="text-lg font-bold">Engine Settings</h2> | |
| <button id="close-settings" class="text-white/40 hover:text-white"><i data-lucide="x" class="w-5 h-5"></i></button> | |
| </div> | |
| <div class="space-y-6"> | |
| <div class="flex items-center justify-between"> | |
| <span class="text-sm font-medium text-white/70">Thinking Mode</span> | |
| <div id="thinking-toggle" class="toggle-switch active"></div> | |
| </div> | |
| <div class="flex items-center justify-between"> | |
| <span class="text-sm font-medium text-white/70">Streaming</span> | |
| <div id="streaming-toggle" class="toggle-switch active"></div> | |
| </div> | |
| <div class="space-y-3"> | |
| <span class="text-xs font-bold text-white/40 uppercase tracking-widest">Generation Mode</span> | |
| <div class="flex gap-2 p-1 bg-white/5 rounded-xl border border-white/10"> | |
| <button id="mode-sampling" class="flex-1 py-2 text-xs font-bold rounded-lg bg-white/10 text-white transition-all">Sampling</button> | |
| <button id="mode-beam" class="flex-1 py-2 text-xs font-bold rounded-lg text-white/40 hover:text-white transition-all">Beam Search</button> | |
| </div> | |
| </div> | |
| <div class="space-y-3"> | |
| <div class="flex justify-between text-xs font-bold text-white/40 uppercase tracking-widest"> | |
| <span>Max Tokens</span> | |
| <span id="tokens-val">2048</span> | |
| </div> | |
| <input type="range" id="tokens-slider" min="64" max="16384" step="64" value="2048" class="control-slider"> | |
| </div> | |
| <div class="space-y-3"> | |
| <div class="flex justify-between text-xs font-bold text-white/40 uppercase tracking-widest"> | |
| <span>Temperature</span> | |
| <span id="temp-val">0.7</span> | |
| </div> | |
| <input type="range" id="temp-slider" min="0" max="2" step="0.01" value="0.7" class="control-slider"> | |
| </div> | |
| <div class="space-y-3"> | |
| <div class="flex justify-between text-xs font-bold text-white/40 uppercase tracking-widest"> | |
| <span>Top-P</span> | |
| <span id="p-val">0.8</span> | |
| </div> | |
| <input type="range" id="p-slider" min="0" max="1" step="0.05" value="0.8" class="control-slider"> | |
| </div> | |
| <div class="space-y-3"> | |
| <div class="flex justify-between text-xs font-bold text-white/40 uppercase tracking-widest"> | |
| <span>Top-K</span> | |
| <span id="k-val">100</span> | |
| </div> | |
| <input type="range" id="k-slider" min="0" max="200" step="1" value="100" class="control-slider"> | |
| </div> | |
| <div class="space-y-3"> | |
| <div class="flex justify-between text-xs font-bold text-white/40 uppercase tracking-widest"> | |
| <span>Max Frames</span> | |
| <span id="frames-val">64</span> | |
| </div> | |
| <input type="range" id="frames-slider" min="8" max="256" step="8" value="64" class="control-slider"> | |
| </div> | |
| <button id="open-fewshot" class="w-full py-4 rounded-2xl bg-white/5 hover:bg-white/10 border border-white/5 transition-all flex items-center justify-center gap-2 group mb-2"> | |
| <i data-lucide="sparkles" class="w-4 h-4 text-[#27D4EA] group-hover:scale-110 transition-transform"></i> | |
| <span class="text-sm font-bold">Few-Shot Builder</span> | |
| </button> | |
| <button onclick="clearHistory()" class="w-full py-4 rounded-2xl bg-red-500/10 border border-red-500/20 text-red-500 text-sm font-bold hover:bg-red-500/20 transition-all flex items-center justify-center gap-2"> | |
| <i data-lucide="trash-2" class="w-4 h-4"></i> | |
| Clear Conversation | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Help Modal --> | |
| <div id="help-modal" class="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm hidden p-6"> | |
| <div class="max-w-4xl w-full bg-[#0D1117] border border-white/10 rounded-[32px] overflow-hidden shadow-2xl flex flex-col max-h-[90vh]"> | |
| <div class="p-8 border-b border-white/10 flex items-center justify-between"> | |
| <h2 class="text-2xl font-bold">How to use MiniCPM-V 4.6</h2> | |
| <button id="close-help" class="text-white/40 hover:text-white"><i data-lucide="x" class="w-6 h-6"></i></button> | |
| </div> | |
| <div class="p-8 overflow-y-auto space-y-12"> | |
| <div class="grid grid-cols-1 md:grid-cols-3 gap-8"> | |
| <div class="space-y-4"> | |
| <div class="aspect-video bg-white/5 rounded-2xl overflow-hidden border border-white/5"> | |
| <img src="http://thunlp.oss-cn-qingdao.aliyuncs.com/multi_modal/never_delete/m_bear2.gif" class="w-full h-full object-cover"> | |
| </div> | |
| <h3 class="font-bold text-lg">1. Multi-Image Chat</h3> | |
| <p class="text-sm text-white/50 leading-relaxed">Upload multiple images at once. Use the "+" button to select files or drag them into the input area.</p> | |
| </div> | |
| <div class="space-y-4"> | |
| <div class="aspect-video bg-white/5 rounded-2xl overflow-hidden border border-white/5"> | |
| <img src="http://thunlp.oss-cn-qingdao.aliyuncs.com/multi_modal/never_delete/video2.gif" class="w-full h-full object-cover"> | |
| </div> | |
| <h3 class="font-bold text-lg">2. Video Intelligence</h3> | |
| <p class="text-sm text-white/50 leading-relaxed">Upload videos for temporal reasoning. MiniCPM-V will sample frames and describe events over time.</p> | |
| </div> | |
| <div class="space-y-4"> | |
| <div class="aspect-video bg-white/5 rounded-2xl overflow-hidden border border-white/5"> | |
| <img src="http://thunlp.oss-cn-qingdao.aliyuncs.com/multi_modal/never_delete/fshot.gif" class="w-full h-full object-cover"> | |
| </div> | |
| <h3 class="font-bold text-lg">3. Few-Shot Learning</h3> | |
| <p class="text-sm text-white/50 leading-relaxed">Provide examples to guide the model. Add turns with correct answers to teach the model a specific style.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Chat Area (Tab 1) --> | |
| <div id="tab-chat" class="tab-content active flex-col h-full"> | |
| <main id="chat-messages" class="chat-scroll-area px-4 flex-1"> | |
| <div class="max-w-3xl mx-auto space-y-8 pt-24 pb-40" id="chat-container"> | |
| </div> | |
| </main> | |
| <!-- Input Area --> | |
| <div class="fixed bottom-0 left-0 right-0 p-6 md:p-10 pointer-events-none z-50"> | |
| <div class="max-w-3xl mx-auto pointer-events-auto"> | |
| <!-- Multi-file Preview --> | |
| <div id="preview-container" class="hidden mb-6 flex flex-wrap gap-3 max-h-40 overflow-y-auto p-2 scrollbar-none"> | |
| <!-- Preview items will be injected here --> | |
| </div> | |
| <!-- Input Bar --> | |
| <div class="input-pill rounded-[2rem] p-2 flex items-end shadow-2xl overflow-hidden bg-white/5 backdrop-blur-xl border border-white/10"> | |
| <input type="file" id="file-input" class="hidden" accept="image/*,video/*" multiple> | |
| <button id="upload-trigger" class="w-12 h-12 flex items-center justify-center text-white/50 hover:text-[#27D4EA] transition-colors relative shrink-0 mb-1"> | |
| <i data-lucide="plus" class="w-6 h-6"></i> | |
| <span id="file-count-badge" class="absolute top-2 right-2 w-4 h-4 bg-indigo-500 text-[10px] text-white rounded-full flex items-center justify-center hidden shadow-lg">0</span> | |
| </button> | |
| <textarea id="user-input" placeholder="Ask MiniCPM-V..." class="flex-1 bg-transparent border-none focus:ring-0 text-white placeholder-white/30 py-4 px-2 resize-none max-h-48 leading-relaxed font-medium" rows="1"></textarea> | |
| <div class="flex items-center gap-1 mb-1 pr-2"> | |
| <button onclick="regenerate()" class="w-10 h-10 flex items-center justify-center text-white/30 hover:text-white transition-colors shrink-0" title="Regenerate last response"> | |
| <i data-lucide="refresh-cw" class="w-4 h-4"></i> | |
| </button> | |
| <button id="send-btn" class="send-btn w-12 h-12 text-white rounded-full flex items-center justify-center disabled:opacity-20 disabled:grayscale shrink-0"> | |
| <i data-lucide="arrow-up" class="w-5 h-5" id="send-icon"></i> | |
| <div id="loading-icon" class="hidden"><div class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div></div> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Few-Shot Area (Tab 2) --> | |
| <div id="tab-fewshot" class="tab-content flex-col items-center pt-32 px-4 h-full overflow-y-auto"> | |
| <div class="max-w-3xl w-full space-y-8 pb-20"> | |
| <div class="flex items-center justify-between"> | |
| <div class="space-y-2"> | |
| <h2 class="text-2xl font-bold tracking-tight">Few-Shot Builder</h2> | |
| <p class="text-white/40 text-sm">Add custom examples to guide the model's behavior.</p> | |
| </div> | |
| <button id="return-chat" class="px-6 py-2 rounded-full bg-white/5 hover:bg-white/10 border border-white/10 transition-all flex items-center gap-2 text-xs font-bold uppercase tracking-widest"> | |
| <i data-lucide="arrow-left" class="w-4 h-4"></i> | |
| Back to Chat | |
| </button> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| <div class="space-y-4"> | |
| <div class="aspect-video bg-white/5 rounded-3xl border border-white/10 flex items-center justify-center relative overflow-hidden group cursor-pointer" onclick="document.getElementById('fs-file').click()"> | |
| <input type="file" id="fs-file" class="hidden" accept="image/*"> | |
| <img id="fs-preview" class="hidden w-full h-full object-contain"> | |
| <div id="fs-placeholder" class="flex flex-col items-center gap-2 text-white/20"> | |
| <i data-lucide="image" class="w-10 h-10"></i> | |
| <span class="text-[10px] font-bold uppercase tracking-[0.2em]">Upload Example Image</span> | |
| </div> | |
| <div class="absolute inset-0 bg-indigo-500/10 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center font-bold text-[10px] uppercase tracking-widest">Update Image</div> | |
| </div> | |
| </div> | |
| <div class="space-y-4 flex flex-col"> | |
| <textarea id="fs-user" placeholder="User question for this example..." class="flex-1 bg-white/5 border border-white/10 rounded-2xl p-4 text-sm text-white placeholder-white/20 resize-none focus:border-indigo-500/50 focus:ring-0 transition-all"></textarea> | |
| <textarea id="fs-bot" placeholder="Model's expected answer..." class="flex-1 bg-white/5 border border-white/10 rounded-2xl p-4 text-sm text-white placeholder-white/20 resize-none focus:border-indigo-500/50 focus:ring-0 transition-all"></textarea> | |
| </div> | |
| </div> | |
| <div class="flex gap-4"> | |
| <button id="fs-add" class="flex-1 py-4 rounded-2xl bg-white/10 hover:bg-white/20 border border-white/10 font-bold text-xs uppercase tracking-widest transition-all">Add to Context</button> | |
| <button id="fs-gen" class="flex-1 py-4 rounded-2xl bg-indigo-500 hover:bg-indigo-600 font-bold text-xs uppercase tracking-widest transition-all shadow-lg shadow-indigo-500/20">Ask with Context</button> | |
| </div> | |
| <div class="p-6 rounded-3xl bg-indigo-500/5 border border-indigo-500/10 space-y-4"> | |
| <div class="flex items-center gap-2 text-indigo-400"> | |
| <i data-lucide="info" class="w-4 h-4"></i> | |
| <span class="text-[10px] font-bold uppercase tracking-widest">Few-Shot Pro Tip</span> | |
| </div> | |
| <p class="text-xs text-white/40 leading-relaxed italic">"Demonstrations help the model learn complex formatting or specific domain knowledge by example. Upload an image, type a question and the ideal answer, then click 'Add to Context' to teach the model before you start chatting."</p> | |
| </div> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| import { Client, handle_file } from "https://huggingface.co/buckets/gradio/npm-previews/resolve/4392744dfbe0a9790251c9f0c6521a8954a6211a/browser.js"; | |
| lucide.createIcons(); | |
| // DOM Elements | |
| const chatContainer = document.getElementById('chat-container'); | |
| const chatScrollArea = document.getElementById('chat-messages'); | |
| const userInput = document.getElementById('user-input'); | |
| const sendBtn = document.getElementById('send-btn'); | |
| const fileInput = document.getElementById('file-input'); | |
| const uploadTrigger = document.getElementById('upload-trigger'); | |
| const previewContainer = document.getElementById('preview-container'); | |
| const fileCountBadge = document.getElementById('file-count-badge'); | |
| const sendIcon = document.getElementById('send-icon'); | |
| const loadingIcon = document.getElementById('loading-icon'); | |
| const settingsPanel = document.getElementById('settings-panel'); | |
| const toggleSettings = document.getElementById('toggle-settings'); | |
| const closeSettings = document.getElementById('close-settings'); | |
| const thinkingToggle = document.getElementById('thinking-toggle'); | |
| const streamingToggle = document.getElementById('streaming-toggle'); | |
| const tempSlider = document.getElementById('temp-slider'); | |
| const tokensSlider = document.getElementById('tokens-slider'); | |
| const tokensVal = document.getElementById('tokens-val'); | |
| const pSlider = document.getElementById('p-slider'); | |
| const pVal = document.getElementById('p-val'); | |
| const kSlider = document.getElementById('k-slider'); | |
| const kVal = document.getElementById('k-val'); | |
| const framesSlider = document.getElementById('frames-slider'); | |
| const framesVal = document.getElementById('frames-val'); | |
| const modeSampling = document.getElementById('mode-sampling'); | |
| const modeBeam = document.getElementById('mode-beam'); | |
| const helpModal = document.getElementById('help-modal'); | |
| const closeHelp = document.getElementById('close-help'); | |
| let client = null; | |
| let selectedFiles = []; | |
| let isSettingsOpen = false; | |
| let generationMode = 'Sampling'; | |
| modeSampling.onclick = () => { | |
| generationMode = 'Sampling'; | |
| modeSampling.classList.add('bg-white/10'); | |
| modeSampling.classList.remove('text-white/40'); | |
| modeBeam.classList.remove('bg-white/10'); | |
| modeBeam.classList.add('text-white/40'); | |
| }; | |
| modeBeam.onclick = () => { | |
| generationMode = 'Beam Search'; | |
| modeBeam.classList.add('bg-white/10'); | |
| modeBeam.classList.remove('text-white/40'); | |
| modeSampling.classList.remove('bg-white/10'); | |
| modeSampling.classList.add('text-white/40'); | |
| // Disable streaming toggle for beam search parity | |
| if (streamingToggle.classList.contains('active')) { | |
| streamingToggle.click(); | |
| } | |
| }; | |
| // Help Modal Logic | |
| window.openHelp = () => helpModal.classList.remove('hidden'); | |
| closeHelp.onclick = () => helpModal.classList.add('hidden'); | |
| tempSlider.oninput = () => tempVal.textContent = tempSlider.value; | |
| tokensSlider.oninput = () => tokensVal.textContent = tokensSlider.value; | |
| pSlider.oninput = () => pVal.textContent = pSlider.value; | |
| kSlider.oninput = () => kVal.textContent = kSlider.value; | |
| framesSlider.oninput = () => framesVal.textContent = framesSlider.value; | |
| // Tab Switching Logic | |
| const tabChat = document.getElementById('tab-chat'); | |
| const tabFewshot = document.getElementById('tab-fewshot'); | |
| function switchTab(target) { | |
| if (target === 'chat') { | |
| tabChat.classList.add('active'); | |
| tabFewshot.classList.remove('active'); | |
| } else { | |
| tabFewshot.classList.add('active'); | |
| tabChat.classList.remove('active'); | |
| } | |
| } | |
| const openFewShot = document.getElementById('open-fewshot'); | |
| const returnChat = document.getElementById('return-chat'); | |
| openFewShot.onclick = () => { | |
| toggleSettingsSidebar(false); | |
| switchTab('fewshot'); | |
| }; | |
| returnChat.onclick = () => switchTab('chat'); | |
| // Few-Shot Builder | |
| const fsFile = document.getElementById('fs-file'); | |
| const fsPreview = document.getElementById('fs-preview'); | |
| const fsPlaceholder = document.getElementById('fs-placeholder'); | |
| const fsUser = document.getElementById('fs-user'); | |
| const fsBot = document.getElementById('fs-bot'); | |
| const fsAddBtn = document.getElementById('fs-add'); | |
| const fsGenBtn = document.getElementById('fs-gen'); | |
| let fsSelectedFile = null; | |
| fsFile.onchange = (e) => { | |
| const file = e.target.files[0]; | |
| if (file) { | |
| fsSelectedFile = file; | |
| const reader = new FileReader(); | |
| reader.onload = (re) => { | |
| fsPreview.src = re.target.result; | |
| fsPreview.classList.remove('hidden'); | |
| fsPlaceholder.classList.add('hidden'); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| }; | |
| fsAddBtn.onclick = () => { | |
| if (!fsUser.value.trim() && !fsSelectedFile) { | |
| alert("Please provide at least a question or an image."); | |
| return; | |
| } | |
| // Add to history as a "completed turn" | |
| const userText = fsUser.value.trim(); | |
| const botText = fsBot.value.trim(); | |
| // Parity with reference: add to chatHistory and show in UI | |
| chatHistory.push([userText || null, botText || null, fsSelectedFile ? [handle_file(fsSelectedFile)] : []]); | |
| // Visual feedback | |
| appendMessage('user', userText || "(Example Image)", fsSelectedFile ? [fsSelectedFile] : []); | |
| if (botText) appendMessage('bot', botText); | |
| // Clear inputs | |
| fsUser.value = ''; | |
| fsBot.value = ''; | |
| fsSelectedFile = null; | |
| fsPreview.classList.add('hidden'); | |
| fsPlaceholder.classList.remove('hidden'); | |
| switchTab('chat'); | |
| }; | |
| fsGenBtn.onclick = async () => { | |
| if (!fsUser.value.trim() && !fsSelectedFile) return; | |
| // Switch to chat tab and trigger generation with current FS inputs | |
| const tempUser = fsUser.value; | |
| const tempFile = fsSelectedFile; | |
| switchTab('chat'); | |
| userInput.value = tempUser; | |
| if (tempFile) { | |
| selectedFiles = [tempFile]; | |
| renderPreviews(); | |
| } | |
| sendMessage(); | |
| // Clear FS inputs | |
| fsUser.value = ''; | |
| fsBot.value = ''; | |
| fsSelectedFile = null; | |
| fsPreview.classList.add('hidden'); | |
| fsPlaceholder.classList.remove('hidden'); | |
| }; | |
| let chatHistory = []; | |
| let currentJob = null; | |
| async function init() { | |
| try { | |
| client = await Client.connect(window.location.origin, { events: ["data", "status"] }); | |
| } catch (err) { console.error("Gradio Connection Error", err); } | |
| } | |
| init(); | |
| // Settings Logic | |
| const toggleSettingsSidebar = (open) => { | |
| isSettingsOpen = open; | |
| if (open) { | |
| settingsPanel.classList.remove('translate-x-full'); | |
| settingsPanel.classList.add('translate-x-0'); | |
| } else { | |
| settingsPanel.classList.add('translate-x-full'); | |
| settingsPanel.classList.remove('translate-x-0'); | |
| } | |
| }; | |
| toggleSettings.onclick = (e) => { | |
| e.stopPropagation(); | |
| toggleSettingsSidebar(true); | |
| }; | |
| closeSettings.onclick = (e) => { | |
| e.stopPropagation(); | |
| toggleSettingsSidebar(false); | |
| }; | |
| // Close sidebar when clicking outside | |
| document.addEventListener('click', (e) => { | |
| if (isSettingsOpen && !settingsPanel.contains(e.target) && !toggleSettings.contains(e.target)) { | |
| toggleSettingsSidebar(false); | |
| } | |
| }); | |
| const setupToggle = (el) => { | |
| el.onclick = () => el.classList.toggle('active'); | |
| }; | |
| setupToggle(thinkingToggle); | |
| setupToggle(streamingToggle); | |
| const setupSlider = (slider, valEl) => { | |
| slider.oninput = () => valEl.textContent = slider.value; | |
| }; | |
| setupSlider(tempSlider, document.getElementById('temp-val')); | |
| setupSlider(tokensSlider, document.getElementById('tokens-val')); | |
| setupSlider(pSlider, document.getElementById('p-val')); | |
| // File Handling | |
| uploadTrigger.onclick = () => fileInput.click(); | |
| fileInput.onchange = (e) => { | |
| const files = Array.from(e.target.files); | |
| selectedFiles = [...selectedFiles, ...files]; | |
| renderPreviews(); | |
| }; | |
| function renderPreviews() { | |
| previewContainer.innerHTML = ''; | |
| if (selectedFiles.length > 0) { | |
| previewContainer.classList.remove('hidden'); | |
| fileCountBadge.classList.remove('hidden'); | |
| fileCountBadge.textContent = selectedFiles.length; | |
| selectedFiles.forEach((file, index) => { | |
| const url = URL.createObjectURL(file); | |
| const item = document.createElement('div'); | |
| item.className = 'media-preview-item h-24 w-24 rounded-2xl overflow-hidden border border-white/20 shadow-lg'; | |
| if (file.type.startsWith('image/')) { | |
| item.innerHTML = `<img src="${url}" class="w-full h-full object-cover">`; | |
| } else { | |
| item.innerHTML = `<video src="${url}" class="w-full h-full object-cover" muted></video><div class="absolute inset-0 flex items-center justify-center bg-black/20"><i data-lucide="play" class="w-6 h-6 text-white"></i></div>`; | |
| } | |
| const removeBtn = document.createElement('button'); | |
| removeBtn.className = 'absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-1 shadow-lg scale-75'; | |
| removeBtn.innerHTML = '<i data-lucide="x" class="w-3 h-3"></i>'; | |
| removeBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| selectedFiles.splice(index, 1); | |
| renderPreviews(); | |
| }; | |
| item.appendChild(removeBtn); | |
| previewContainer.appendChild(item); | |
| }); | |
| lucide.createIcons(); | |
| } else { | |
| previewContainer.classList.add('hidden'); | |
| fileCountBadge.classList.add('hidden'); | |
| } | |
| } | |
| // Message Handling | |
| function appendMessage(role, text = '', files = []) { | |
| const div = document.createElement('div'); | |
| div.className = `flex gap-4 items-start ${role === 'user' ? 'flex-row-reverse' : ''}`; | |
| let mediaHtml = ''; | |
| if (files.length > 0) { | |
| mediaHtml = '<div class="flex flex-wrap gap-2 mb-4">'; | |
| files.forEach(file => { | |
| const url = typeof file === 'string' ? file : URL.createObjectURL(file); | |
| const type = typeof file === 'string' ? (file.match(/\.(mp4|webm|mkv)/i) ? 'video' : 'image') : (file.type.startsWith('video') ? 'video' : 'image'); | |
| if (type === 'image') { | |
| mediaHtml += `<img src="${url}" class="h-48 rounded-2xl border border-white/10 shadow-lg object-contain bg-black/20" />`; | |
| } else { | |
| mediaHtml += `<video src="${url}" controls class="h-48 rounded-2xl border border-white/10 shadow-lg" />`; | |
| } | |
| }); | |
| mediaHtml += '</div>'; | |
| } | |
| const bubbleClass = role === 'user' ? 'user-message' : 'bot-message'; | |
| div.innerHTML = ` | |
| <div class="${bubbleClass} p-6 message-bubble shadow-xl"> | |
| ${mediaHtml} | |
| <div class="thinking-container hidden"></div> | |
| <div class="content-container leading-relaxed text-[15px] whitespace-pre-wrap font-medium">${marked.parse(text)}</div> | |
| </div> | |
| `; | |
| chatContainer.appendChild(div); | |
| const contentContainer = div.querySelector('.content-container'); | |
| if (window.renderMathInElement) { | |
| renderMathInElement(contentContainer, { | |
| delimiters: [ | |
| {left: '$$', right: '$$', display: true}, | |
| {left: '$', right: '$', display: false}, | |
| {left: '\\(', right: '\\)', display: false}, | |
| {left: '\\[', right: '\\]', display: true} | |
| ], | |
| throwOnError: false | |
| }); | |
| } | |
| chatScrollArea.scrollTo({ top: chatScrollArea.scrollHeight, behavior: 'smooth' }); | |
| return div; | |
| } | |
| function updateBotMessage(div, fullText) { | |
| const thinkingContainer = div.querySelector('.thinking-container'); | |
| const contentContainer = div.querySelector('.content-container'); | |
| const thinkMatch = fullText.match(/<think>([\s\S]*?)<\/think>/); | |
| const thinkingText = thinkMatch ? thinkMatch[1].trim() : (fullText.includes('<think>') && !fullText.includes('</think>') ? fullText.split('<think>')[1].trim() : ''); | |
| const actualText = fullText.replace(/<think>[\s\S]*?<\/think>/, '').trim(); | |
| if (thinkingText) { | |
| thinkingContainer.classList.remove('hidden'); | |
| thinkingContainer.innerHTML = `<div class="thinking-block">${marked.parse(thinkingText)}</div>`; | |
| } else { | |
| thinkingContainer.classList.add('hidden'); | |
| } | |
| contentContainer.innerHTML = marked.parse(actualText); | |
| // Render Math | |
| [thinkingContainer, contentContainer].forEach(el => { | |
| if (window.renderMathInElement) { | |
| renderMathInElement(el, { | |
| delimiters: [ | |
| {left: '$$', right: '$$', display: true}, | |
| {left: '$', right: '$', display: false}, | |
| {left: '\\(', right: '\\)', display: false}, | |
| {left: '\\[', right: '\\]', display: true} | |
| ], | |
| throwOnError: false | |
| }); | |
| } | |
| }); | |
| chatScrollArea.scrollTo({ top: chatScrollArea.scrollHeight, behavior: 'smooth' }); | |
| return actualText; | |
| } | |
| async function sendMessage() { | |
| const text = userInput.value.trim(); | |
| if (!text && selectedFiles.length === 0) return; | |
| const filesToUpload = [...selectedFiles]; | |
| const content = text; | |
| userInput.value = ''; | |
| userInput.style.height = 'auto'; | |
| appendMessage('user', content, filesToUpload); | |
| selectedFiles = []; | |
| renderPreviews(); | |
| sendIcon.classList.add('hidden'); | |
| loadingIcon.classList.remove('hidden'); | |
| sendBtn.innerHTML = '<i data-lucide="square" class="w-5 h-5 fill-white"></i>'; | |
| sendBtn.classList.remove('send-btn'); | |
| sendBtn.classList.add('bg-red-500/20', 'hover:bg-red-500/40', 'border', 'border-red-500/50'); | |
| lucide.createIcons(); | |
| let isStopped = false; | |
| sendBtn.onclick = () => { | |
| if (currentJob) { | |
| currentJob.cancel(); | |
| isStopped = true; | |
| resetSendBtn(); | |
| } | |
| }; | |
| // Bot response placeholder | |
| const botDiv = appendMessage('bot', ''); | |
| const contentContainer = botDiv.querySelector('.content-container'); | |
| contentContainer.innerHTML = '<div class="flex gap-1.5 py-2"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>'; | |
| try { | |
| const gradioFiles = filesToUpload.length > 0 ? filesToUpload.map(f => handle_file(f)) : null; | |
| currentJob = client.submit("/predict", { | |
| message: content, | |
| history: chatHistory, | |
| files: gradioFiles, | |
| thinking_mode: thinkingToggle.classList.contains('active'), | |
| max_new_tokens: parseInt(tokensSlider.value), | |
| temperature: parseFloat(tempSlider.value), | |
| top_p: parseFloat(pSlider.value), | |
| top_k: parseInt(kSlider.value), | |
| max_frames: parseInt(framesSlider.value), | |
| generation_mode: generationMode | |
| }); | |
| let finalAnswer = ""; | |
| for await (const msg of currentJob) { | |
| if (isStopped) break; | |
| if (msg.type === "data" && msg.data) { | |
| const chunk = msg.data[0]; | |
| finalAnswer = updateBotMessage(botDiv, chunk); | |
| } else if (msg.type === "status") { | |
| if (msg.stage === "complete") { | |
| break; | |
| } | |
| if (msg.stage === "error") { | |
| throw new Error(msg.message || "Generation failed"); | |
| } | |
| } | |
| } | |
| if (!isStopped) { | |
| chatHistory.push([content, finalAnswer]); | |
| } | |
| } catch (err) { | |
| console.error(err); | |
| if (!isStopped) { | |
| contentContainer.textContent = "I encountered an error while processing your request. Please try again."; | |
| } | |
| } finally { | |
| resetSendBtn(); | |
| currentJob = null; | |
| } | |
| } | |
| function resetSendBtn() { | |
| sendBtn.innerHTML = '<i data-lucide="arrow-up" class="w-5 h-5" id="send-icon"></i>'; | |
| sendBtn.className = 'send-btn w-12 h-12 text-white rounded-full flex items-center justify-center disabled:opacity-20 disabled:grayscale shrink-0 mb-1'; | |
| sendBtn.onclick = sendMessage; | |
| lucide.createIcons(); | |
| } | |
| window.clearHistory = function() { | |
| chatHistory = []; | |
| chatContainer.innerHTML = ` | |
| <div class="flex gap-4 items-start"> | |
| <div class="bot-message p-6 message-bubble shadow-2xl"> | |
| <p class="text-white/90 leading-relaxed text-[15px]"> | |
| History cleared. How can I help you today? | |
| </p> | |
| </div> | |
| </div> | |
| `; | |
| closeSettings.click(); | |
| } | |
| async function regenerate() { | |
| if (chatHistory.length === 0) return; | |
| const lastTurn = chatHistory.pop(); | |
| // Remove last assistant message from UI | |
| chatContainer.removeChild(chatContainer.lastChild); | |
| // Re-send last user message | |
| userInput.value = lastTurn[0]; | |
| // We need to re-handle files if we want perfect parity, but for now we re-send text | |
| sendMessage(); | |
| } | |
| thinkingToggle.onclick = () => { | |
| thinkingToggle.classList.toggle('active'); | |
| if (chatHistory.length > 0) { | |
| if (confirm("Changing Thinking Mode will clear your current conversation history. Proceed?")) { | |
| window.clearHistory(); | |
| } else { | |
| thinkingToggle.classList.toggle('active'); | |
| } | |
| } | |
| }; | |
| // Client ID injection (Parity with reference) | |
| const header = "x-v46-client-id"; | |
| const key = "minicpm_v46_demo_client_id"; | |
| let clientId = localStorage.getItem(key); | |
| if (!clientId) { | |
| clientId = "local-" + Math.random().toString(36).slice(2) + Date.now().toString(36); | |
| localStorage.setItem(key, clientId); | |
| } | |
| window.__minicpmV46ClientId = clientId; | |
| // Patch fetch to include client ID | |
| const originalFetch = window.fetch; | |
| window.fetch = function(input, init) { | |
| const nextInit = init ? Object.assign({}, init) : {}; | |
| const headers = new Headers(nextInit.headers || (input instanceof Request ? input.headers : undefined)); | |
| headers.set(header, clientId); | |
| nextInit.headers = headers; | |
| return originalFetch.call(this, input, nextInit); | |
| }; | |
| // Few-Shot Logic (Enhanced for Parity) | |
| window.addFewShot = function() { | |
| // In the reference, this is a form. Here we can use a modal or simple prompts. | |
| // For true parity, let's add a more structured prompt sequence. | |
| const hasImage = confirm("Include an image in this example?"); | |
| if (hasImage) { | |
| // In a real web app we'd open a file picker, but here we can just ask the user to upload it normally | |
| // and then "Mark as Few-Shot". | |
| // For now, let's keep it simple as a guided prompt. | |
| alert("Please upload your image normally, then click 'Add Example' in the settings to mark the current turn as few-shot."); | |
| } | |
| }; | |
| // Initial Button Wiring | |
| sendBtn.onclick = sendMessage; | |
| userInput.onkeydown = (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }; | |
| // Auto-resize textarea | |
| userInput.oninput = () => { | |
| userInput.style.height = 'auto'; | |
| userInput.style.height = userInput.scrollHeight + 'px'; | |
| }; | |
| // Make regenerate global for the HTML onclick | |
| window.regenerate = regenerate; | |
| </script> | |
| </body> | |
| </html> | |