| <!DOCTYPE html> |
| <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>⚡ Keefe-Discere OS — Multimodal Any-to-Any Edge</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script src="https://unpkg.com/lucide@latest"></script> |
| <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=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet"> |
| <script> |
| tailwind.config = { |
| theme: { |
| extend: { |
| fontFamily: { |
| sans: ['Inter', 'ui-sans-serif', 'system-ui'], |
| mono: ['JetBrains Mono', 'Fira Code', 'ui-monospace'], |
| }, |
| colors: { |
| keefe: { |
| bg: '#090d16', |
| panel: '#0d1420', |
| border: '#1e293b', |
| indigo: '#6366f1', |
| cyan: '#06b6d4', |
| emerald: '#10b981', |
| amber: '#f59e0b', |
| rose: '#f43f5e', |
| } |
| }, |
| animation: { |
| 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', |
| 'glow': 'glow 2s ease-in-out infinite alternate', |
| 'scan': 'scan 3s linear infinite', |
| 'blink': 'blink 1.2s steps(2, start) infinite', |
| }, |
| keyframes: { |
| glow: { |
| '0%': { boxShadow: '0 0 10px rgba(99, 102, 241, 0.3)' }, |
| '100%': { boxShadow: '0 0 20px rgba(6, 182, 212, 0.5)' }, |
| }, |
| scan: { |
| '0%': { transform: 'translateY(-100%)' }, |
| '100%': { transform: 'translateY(100%)' }, |
| }, |
| blink: { |
| 'to': { visibility: 'hidden' } |
| } |
| } |
| } |
| } |
| } |
| </script> |
| <style> |
| * { -webkit-tap-highlight-color: transparent; } |
| html, body { |
| background-color: #090d16; |
| color: #e2e8f0; |
| font-family: 'Inter', sans-serif; |
| overflow: hidden; |
| } |
| |
| .cyber-grid { |
| background-image: |
| linear-gradient(rgba(99, 102, 241, 0.04) 1px, transparent 1px), |
| linear-gradient(90deg, rgba(99, 102, 241, 0.04) 1px, transparent 1px); |
| background-size: 44px 44px; |
| background-position: -1px -1px; |
| } |
| .cyber-grid::before { |
| content: ''; |
| position: fixed; |
| inset: 0; |
| background: radial-gradient(ellipse at top, rgba(99, 102, 241, 0.12), transparent 50%), |
| radial-gradient(ellipse at bottom, rgba(6, 182, 212, 0.08), transparent 50%); |
| pointer-events: none; |
| z-index: 0; |
| } |
| |
| .glass { |
| background: rgba(13, 20, 32, 0.75); |
| backdrop-filter: blur(14px); |
| -webkit-backdrop-filter: blur(14px); |
| border: 1px solid rgba(30, 41, 59, 0.7); |
| } |
| |
| .glass-strong { |
| background: rgba(9, 13, 22, 0.85); |
| backdrop-filter: blur(18px); |
| -webkit-backdrop-filter: blur(18px); |
| border: 1px solid rgba(30, 41, 59, 0.85); |
| } |
| |
| |
| .thin-scroll::-webkit-scrollbar { width: 8px; height: 8px; } |
| .thin-scroll::-webkit-scrollbar-track { background: transparent; } |
| .thin-scroll::-webkit-scrollbar-thumb { |
| background: rgba(99, 102, 241, 0.2); |
| border-radius: 10px; |
| } |
| .thin-scroll::-webkit-scrollbar-thumb:hover { |
| background: rgba(99, 102, 241, 0.4); |
| } |
| |
| |
| .xml-block { |
| background: linear-gradient(135deg, rgba(245, 158, 11, 0.08), rgba(245, 158, 11, 0.03)); |
| border: 1px solid rgba(245, 158, 11, 0.25); |
| border-left: 3px solid #f59e0b; |
| border-radius: 10px; |
| padding: 12px 14px; |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 12.5px; |
| color: #fcd34d; |
| position: relative; |
| overflow: hidden; |
| } |
| .xml-block::before { |
| content: '⚡ TOOL_CALL'; |
| position: absolute; |
| top: -8px; |
| right: 10px; |
| background: #090d16; |
| padding: 0 6px; |
| font-size: 9px; |
| letter-spacing: 2px; |
| color: #f59e0b; |
| font-weight: 700; |
| } |
| .xml-tag { color: #fbbf24; font-weight: 500; } |
| .xml-name { color: #fb923c; font-weight: 600; } |
| .xml-input { color: #fde68a; } |
| |
| |
| @keyframes slideInUp { |
| from { opacity: 0; transform: translateY(12px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| .msg-enter { animation: slideInUp 0.35s ease-out forwards; } |
| |
| |
| .drop-zone.drag-over { |
| background: rgba(6, 182, 212, 0.08) !important; |
| border-color: #06b6d4 !important; |
| box-shadow: inset 0 0 0 2px rgba(6, 182, 212, 0.4), 0 0 30px rgba(6, 182, 212, 0.2); |
| } |
| .drop-zone.drag-over .drop-label { color: #06b6d4; } |
| |
| |
| .cursor-blink::after { |
| content: '▊'; |
| color: #06b6d4; |
| animation: blink 1.2s steps(2, start) infinite; |
| margin-left: 2px; |
| } |
| |
| |
| .status-dot { |
| position: relative; |
| } |
| .status-dot::after { |
| content: ''; |
| position: absolute; |
| inset: -2px; |
| border-radius: 999px; |
| background: #10b981; |
| animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; |
| opacity: 0.6; |
| } |
| @keyframes pulse-ring { |
| 0%, 100% { transform: scale(1); opacity: 0.6; } |
| 50% { transform: scale(1.8); opacity: 0; } |
| } |
| |
| |
| .scan-line { |
| position: absolute; |
| top: 0; left: 0; right: 0; |
| height: 100%; |
| background: linear-gradient(180deg, transparent, rgba(6, 182, 212, 0.2), transparent); |
| animation: scan 2.5s linear infinite; |
| pointer-events: none; |
| } |
| |
| |
| textarea { resize: none; } |
| |
| |
| .btn-primary { |
| background: linear-gradient(135deg, #6366f1, #06b6d4); |
| transition: all 0.2s ease; |
| } |
| .btn-primary:hover:not(:disabled) { |
| box-shadow: 0 0 25px rgba(99, 102, 241, 0.5), 0 0 45px rgba(6, 182, 212, 0.3); |
| transform: translateY(-1px); |
| } |
| .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } |
| |
| |
| .modal-backdrop { |
| background: rgba(0, 0, 0, 0.7); |
| backdrop-filter: blur(8px); |
| } |
| |
| |
| .gradient-text { |
| background: linear-gradient(135deg, #a5b4fc 0%, #67e8f9 50%, #86efac 100%); |
| -webkit-background-clip: text; |
| background-clip: text; |
| -webkit-text-fill-color: transparent; |
| } |
| |
| |
| .code-block { |
| background: rgba(2, 6, 23, 0.8); |
| border: 1px solid rgba(30, 41, 59, 0.8); |
| border-radius: 8px; |
| padding: 12px; |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 12px; |
| color: #94a3b8; |
| overflow-x: auto; |
| } |
| |
| |
| .tool-result { |
| background: linear-gradient(135deg, rgba(16, 185, 129, 0.06), rgba(6, 182, 212, 0.03)); |
| border: 1px solid rgba(16, 185, 129, 0.25); |
| border-left: 3px solid #10b981; |
| border-radius: 10px; |
| padding: 12px 14px; |
| } |
| .tool-result::before { |
| content: '✓ TOOL_RESULT'; |
| display: block; |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 9px; |
| letter-spacing: 2px; |
| color: #10b981; |
| font-weight: 700; |
| margin-bottom: 6px; |
| } |
| |
| |
| .error-block { |
| background: linear-gradient(135deg, rgba(244, 63, 94, 0.08), rgba(244, 63, 94, 0.03)); |
| border: 1px solid rgba(244, 63, 94, 0.25); |
| border-left: 3px solid #f43f5e; |
| border-radius: 10px; |
| padding: 12px 14px; |
| color: #fca5a5; |
| } |
| |
| |
| .chip { |
| display: inline-flex; |
| align-items: center; |
| gap: 4px; |
| padding: 3px 9px; |
| border-radius: 999px; |
| font-size: 11px; |
| font-weight: 500; |
| background: rgba(30, 41, 59, 0.7); |
| border: 1px solid rgba(51, 65, 85, 0.6); |
| color: #cbd5e1; |
| } |
| |
| |
| .no-select { user-select: none; } |
| |
| |
| .loading-dots span { |
| display: inline-block; |
| width: 6px; |
| height: 6px; |
| margin: 0 2px; |
| background: #06b6d4; |
| border-radius: 50%; |
| animation: bounce 1.4s infinite ease-in-out both; |
| } |
| .loading-dots span:nth-child(1) { animation-delay: -0.32s; } |
| .loading-dots span:nth-child(2) { animation-delay: -0.16s; } |
| @keyframes bounce { |
| 0%, 80%, 100% { transform: scale(0); } |
| 40% { transform: scale(1); } |
| } |
| |
| |
| input:focus, textarea:focus { |
| outline: none; |
| } |
| .focus-ring:focus { |
| box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.4); |
| } |
| </style> |
| </head> |
| <body class="cyber-grid h-screen w-screen relative"> |
|
|
| |
| <div id="app" class="relative z-10 h-full w-full flex flex-col"> |
|
|
| |
| <header class="glass-strong border-b border-slate-800/80 px-4 sm:px-6 py-3 flex items-center justify-between gap-4 flex-shrink-0"> |
| <div class="flex items-center gap-3 min-w-0"> |
| <div class="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-500 to-cyan-500 flex items-center justify-center flex-shrink-0 shadow-lg shadow-indigo-500/30"> |
| <i data-lucide="zap" class="w-5 h-5 text-white"></i> |
| </div> |
| <div class="min-w-0"> |
| <h1 class="text-sm sm:text-base font-bold gradient-text truncate">Keefe-Discere OS</h1> |
| <div class="flex items-center gap-2 mt-0.5"> |
| <span class="status-dot w-1.5 h-1.5 rounded-full bg-emerald-400 inline-block"></span> |
| <span class="text-[10px] text-slate-400 font-mono tracking-wide">Online • Static Edge Router</span> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="flex items-center gap-2 flex-shrink-0"> |
| <div id="modelChip" class="chip hidden sm:inline-flex"> |
| <i data-lucide="cpu" class="w-3 h-3"></i> |
| <span class="font-mono">KeefeBuild/Keefe-Discere</span> |
| </div> |
| <button id="tokenBtn" class="relative px-3 py-1.5 rounded-lg glass hover:bg-slate-800/80 transition-colors flex items-center gap-2 group"> |
| <i data-lucide="key-round" class="w-4 h-4 text-slate-400 group-hover:text-indigo-400"></i> |
| <span class="text-xs font-medium text-slate-300 hidden sm:inline" id="tokenStatus">Set Token</span> |
| <span id="tokenSavedBadge" class="hidden absolute -top-1 -right-1 w-2 h-2 rounded-full bg-emerald-400 ring-2 ring-keefe-bg"></span> |
| </button> |
| <button id="clearBtn" class="p-2 rounded-lg glass hover:bg-slate-800/80 transition-colors" title="Clear chat"> |
| <i data-lucide="trash-2" class="w-4 h-4 text-slate-400"></i> |
| </button> |
| </div> |
| </header> |
|
|
| |
| <main class="flex-1 flex flex-col min-h-0 relative"> |
|
|
| |
| <div class="px-4 sm:px-6 pt-3 flex-shrink-0"> |
| <button id="systemPromptToggle" class="flex items-center gap-2 text-xs text-slate-400 hover:text-slate-200 transition-colors group"> |
| <i data-lucide="settings-2" class="w-3.5 h-3.5"></i> |
| <span class="font-mono tracking-wide">SYSTEM_INSTRUCTIONS</span> |
| <i data-lucide="chevron-down" id="sysPromptIcon" class="w-3.5 h-3.5 transition-transform"></i> |
| </button> |
| <div id="systemPromptPanel" class="hidden mt-2"> |
| <div class="glass rounded-xl p-3"> |
| <textarea id="systemPromptInput" rows="4" |
| class="w-full bg-slate-950/60 text-slate-200 text-xs font-mono p-3 rounded-lg border border-slate-800 focus-ring" |
| placeholder="Enter system prompt...">You are Keefe-Discere, an advanced multimodal AI operating system. You can reason across text, images, and audio inputs. When you need to use an external tool, you MUST emit a tool call in this exact format: |
|
|
| <tool_call><name>tool_name</name><input>input_data</input></tool_call> |
|
|
| Available tools: |
| - ocr_vision: Extract text from an image URL (input: image URL) |
| - transcribe_audio: Transcribe audio (input: audio URL or description) |
| - image_caption: Generate a detailed caption for an image (input: image URL) |
| - web_search: Search the web for information (input: query) |
| - image_generation: Generate an image from a text prompt (input: prompt) |
|
|
| Be concise, precise, and always acknowledge multimodal inputs. When routing to a tool, wait for its result before responding.</textarea> |
| <div class="flex items-center justify-between mt-2"> |
| <span class="text-[10px] text-slate-500 font-mono">Tokens auto-injected into every request</span> |
| <span class="text-[10px] text-emerald-400 font-mono">● LIVE</span> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="messageStream" class="flex-1 overflow-y-auto thin-scroll px-4 sm:px-6 py-4 min-h-0"> |
| <div id="messageContainer" class="max-w-4xl mx-auto space-y-4"> |
|
|
| |
| <div id="welcomeScreen" class="flex flex-col items-center justify-center py-12 text-center"> |
| <div class="w-20 h-20 rounded-2xl bg-gradient-to-br from-indigo-500/20 to-cyan-500/20 border border-indigo-500/30 flex items-center justify-center mb-6 animate-glow"> |
| <i data-lucide="sparkles" class="w-10 h-10 text-indigo-400"></i> |
| </div> |
| <h2 class="text-2xl sm:text-3xl font-bold gradient-text mb-3">Welcome to Keefe-Discere OS</h2> |
| <p class="text-slate-400 max-w-md text-sm sm:text-base leading-relaxed"> |
| Multimodal any-to-any intelligence router. Drop an image, attach audio, or just type — and watch XML tool calls flow through the edge in real-time. |
| </p> |
| <div class="flex flex-wrap gap-2 mt-6 justify-center"> |
| <div class="chip"><i data-lucide="image" class="w-3 h-3"></i>Vision</div> |
| <div class="chip"><i data-lucide="mic" class="w-3 h-3"></i>Audio</div> |
| <div class="chip"><i data-lucide="text" class="w-3 h-3"></i>Text</div> |
| <div class="chip"><i data-lucide="route" class="w-3 h-3"></i>Tool Routing</div> |
| </div> |
| <div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-8 max-w-xl w-full"> |
| <button class="suggestion-btn glass rounded-xl p-3 text-left hover:bg-slate-800/80 transition-colors group" data-prompt="Analyze this screenshot and tell me what UI patterns are used"> |
| <div class="flex items-center gap-2 mb-1"> |
| <i data-lucide="layout" class="w-3.5 h-3.5 text-indigo-400"></i> |
| <span class="text-xs font-semibold text-slate-200">UI Analysis</span> |
| </div> |
| <p class="text-[11px] text-slate-400 line-clamp-2">Analyze a screenshot and identify UI patterns...</p> |
| </button> |
| <button class="suggestion-btn glass rounded-xl p-3 text-left hover:bg-slate-800/80 transition-colors group" data-prompt="Generate a cyberpunk logo for a startup called 'Nexora'"> |
| <div class="flex items-center gap-2 mb-1"> |
| <i data-lucide="palette" class="w-3.5 h-3.5 text-cyan-400"></i> |
| <span class="text-xs font-semibold text-slate-200">Logo Generation</span> |
| </div> |
| <p class="text-[11px] text-slate-400 line-clamp-2">Design a logo with tool routing...</p> |
| </button> |
| <button class="suggestion-btn glass rounded-xl p-3 text-left hover:bg-slate-800/80 transition-colors group" data-prompt="What are the latest breakthroughs in multimodal AI this month?"> |
| <div class="flex items-center gap-2 mb-1"> |
| <i data-lucide="globe" class="w-3.5 h-3.5 text-emerald-400"></i> |
| <span class="text-xs font-semibold text-slate-200">Web Research</span> |
| </div> |
| <p class="text-[11px] text-slate-400 line-clamp-2">Search for recent AI breakthroughs...</p> |
| </button> |
| <button class="suggestion-btn glass rounded-xl p-3 text-left hover:bg-slate-800/80 transition-colors group" data-prompt="Help me debug this error: TypeError cannot read property of undefined"> |
| <div class="flex items-center gap-2 mb-1"> |
| <i data-lucide="bug" class="w-3.5 h-3.5 text-amber-400"></i> |
| <span class="text-xs font-semibold text-slate-200">Code Debug</span> |
| </div> |
| <p class="text-[11px] text-slate-400 line-clamp-2">Diagnose and resolve coding errors...</p> |
| </button> |
| </div> |
| </div> |
|
|
| </div> |
| </div> |
|
|
| |
| <div class="px-4 sm:px-6 pb-4 pt-2 flex-shrink-0"> |
| <div id="dropZone" class="drop-zone max-w-4xl mx-auto glass-strong rounded-2xl p-3 transition-all relative overflow-hidden"> |
| |
| <div id="attachedFiles" class="hidden mb-2 flex flex-wrap gap-2"></div> |
|
|
| |
| <div id="dropLabel" class="hidden text-center py-2"> |
| <i data-lucide="upload-cloud" class="w-6 h-6 text-cyan-400 mx-auto mb-1"></i> |
| <p class="text-sm text-cyan-300 font-medium">Drop files here</p> |
| <p class="text-xs text-slate-500">PNG, JPG, MP3, WAV supported</p> |
| </div> |
|
|
| <div class="flex items-end gap-2"> |
| |
| <button id="attachBtn" class="p-2.5 rounded-xl bg-slate-800/80 hover:bg-slate-700/80 transition-colors flex-shrink-0 group relative"> |
| <i data-lucide="paperclip" class="w-4 h-4 text-slate-400 group-hover:text-cyan-400"></i> |
| <input type="file" id="fileInput" class="absolute inset-0 opacity-0 cursor-pointer" accept="image/png,image/jpeg,image/jpg,image/webp,image/gif,audio/mpeg,audio/wav,audio/mp3,audio/ogg" multiple> |
| </button> |
|
|
| |
| <div class="flex-1 min-w-0"> |
| <textarea id="userInput" rows="1" |
| class="w-full bg-slate-950/60 text-slate-100 text-sm p-3 rounded-xl border border-slate-800 focus-ring transition-all placeholder:text-slate-600" |
| placeholder="Message Keefe-Discere... (Enter to send, Shift+Enter for newline)" |
| maxlength="8000"></textarea> |
| </div> |
|
|
| |
| <button id="sendBtn" class="btn-primary p-2.5 rounded-xl flex-shrink-0 flex items-center justify-center" title="Send (Enter)"> |
| <i data-lucide="send-horizontal" class="w-4 h-4 text-white"></i> |
| </button> |
| </div> |
|
|
| <div class="flex items-center justify-between mt-2 px-1"> |
| <div class="flex items-center gap-3 text-[10px] text-slate-500 font-mono"> |
| <span class="flex items-center gap-1"><i data-lucide="command" class="w-2.5 h-2.5"></i>ENTER to send</span> |
| <span class="flex items-center gap-1"><i data-lucide="shift" class="w-2.5 h-2.5"></i>+↵ for newline</span> |
| </div> |
| <div id="charCount" class="text-[10px] text-slate-500 font-mono">0 / 8000</div> |
| </div> |
| </div> |
| </div> |
|
|
| </main> |
|
|
| </div> |
|
|
| |
| <div id="tokenModal" class="fixed inset-0 z-50 hidden items-center justify-center p-4 modal-backdrop"> |
| <div class="glass-strong rounded-2xl p-6 w-full max-w-md shadow-2xl border border-slate-800/80"> |
| <div class="flex items-center justify-between mb-4"> |
| <div> |
| <h3 class="text-base font-bold gradient-text">HF Access Token</h3> |
| <p class="text-xs text-slate-400 mt-0.5">Stored locally in your browser only</p> |
| </div> |
| <button id="closeTokenModal" class="p-1.5 rounded-lg hover:bg-slate-800/80 transition-colors"> |
| <i data-lucide="x" class="w-4 h-4 text-slate-400"></i> |
| </button> |
| </div> |
|
|
| <div class="space-y-3"> |
| <div class="relative"> |
| <input type="password" id="tokenInput" |
| class="w-full bg-slate-950/80 text-slate-100 text-sm font-mono p-3 pr-10 rounded-lg border border-slate-800 focus-ring" |
| placeholder="hf_xxxxxxxxxxxxxxxxxxxxx" autocomplete="off"> |
| <button id="toggleTokenVis" class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-slate-800/80 transition-colors"> |
| <i data-lucide="eye" id="eyeIcon" class="w-4 h-4 text-slate-400"></i> |
| </button> |
| </div> |
|
|
| <div class="flex items-center justify-between text-xs"> |
| <a href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener" |
| class="text-indigo-400 hover:text-indigo-300 underline-offset-2 hover:underline flex items-center gap-1"> |
| <i data-lucide="external-link" class="w-3 h-3"></i> Get token at huggingface.co |
| </a> |
| <span id="tokenSavedStatus" class="text-emerald-400 hidden">✓ Saved</span> |
| </div> |
|
|
| <div class="flex gap-2 pt-2"> |
| <button id="saveTokenBtn" class="btn-primary flex-1 py-2.5 rounded-lg text-sm font-semibold text-white flex items-center justify-center gap-2"> |
| <i data-lucide="shield-check" class="w-4 h-4"></i> Save & Encrypt Locally |
| </button> |
| <button id="clearTokenBtn" class="px-4 py-2.5 rounded-lg bg-slate-800/80 hover:bg-slate-700/80 transition-colors text-sm text-slate-300"> |
| Clear |
| </button> |
| </div> |
|
|
| <div class="bg-slate-950/50 rounded-lg p-3 border border-slate-800/80"> |
| <p class="text-[10px] text-slate-500 leading-relaxed font-mono"> |
| <i data-lucide="info" class="w-3 h-3 inline mr-1"></i> |
| Token never leaves your browser except for direct HTTPS calls to the Hugging Face Inference API (api-inference.huggingface.co). No analytics, no tracking. |
| </p> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="toastContainer" class="fixed bottom-24 right-4 z-50 space-y-2 pointer-events-none"></div> |
|
|
| <script type="module"> |
| import { InferenceClient } from "https://cdn.jsdelivr.net/npm/@huggingface/inference@4.13.22/+esm"; |
| |
| |
| const state = { |
| token: localStorage.getItem('keefe_hf_token') || '', |
| model: 'KeefeBuild/Keefe-Discere', |
| messages: [], |
| attachedFiles: [], |
| isStreaming: false, |
| toolCallDepth: 0, |
| maxToolCallDepth: 3, |
| }; |
| |
| |
| const $ = (id) => document.getElementById(id); |
| const messageContainer = $('messageContainer'); |
| const messageStream = $('messageStream'); |
| const userInput = $('userInput'); |
| const sendBtn = $('sendBtn'); |
| const attachBtn = $('attachBtn'); |
| const fileInput = $('fileInput'); |
| const attachedFilesEl = $('attachedFiles'); |
| const dropZone = $('dropZone'); |
| const dropLabel = $('dropLabel'); |
| const charCount = $('charCount'); |
| const tokenModal = $('tokenModal'); |
| const tokenInput = $('tokenInput'); |
| const tokenBtn = $('tokenBtn'); |
| const tokenStatus = $('tokenStatus'); |
| const tokenSavedBadge = $('tokenSavedBadge'); |
| const systemPromptToggle = $('systemPromptToggle'); |
| const systemPromptPanel = $('systemPromptPanel'); |
| const systemPromptInput = $('systemPromptInput'); |
| const sysPromptIcon = $('sysPromptIcon'); |
| const clearBtn = $('clearBtn'); |
| const welcomeScreen = $('welcomeScreen'); |
| |
| |
| function escapeHtml(str) { |
| return String(str) |
| .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') |
| .replace(/"/g, '"').replace(/'/g, '''); |
| } |
| |
| function showToast(msg, type = 'info', duration = 3000) { |
| const colors = { |
| info: 'border-indigo-500/50 bg-indigo-500/10 text-indigo-200', |
| success: 'border-emerald-500/50 bg-emerald-500/10 text-emerald-200', |
| error: 'border-rose-500/50 bg-rose-500/10 text-rose-200', |
| warning: 'border-amber-500/50 bg-amber-500/10 text-amber-200', |
| }; |
| const icons = { |
| info: 'info', success: 'check-circle-2', error: 'alert-circle', warning: 'alert-triangle' |
| }; |
| const toast = document.createElement('div'); |
| toast.className = `glass-strong rounded-lg px-4 py-3 border ${colors[type]} shadow-xl pointer-events-auto max-w-sm msg-enter flex items-start gap-3`; |
| toast.innerHTML = ` |
| <i data-lucide="${icons[type]}" class="w-4 h-4 mt-0.5 flex-shrink-0"></i> |
| <span class="text-xs leading-relaxed">${escapeHtml(msg)}</span> |
| `; |
| $('toastContainer').appendChild(toast); |
| lucide.createIcons({ nodes: [toast] }); |
| setTimeout(() => { |
| toast.style.transition = 'opacity 0.3s, transform 0.3s'; |
| toast.style.opacity = '0'; |
| toast.style.transform = 'translateX(20px)'; |
| setTimeout(() => toast.remove(), 300); |
| }, duration); |
| } |
| |
| function scrollToBottom() { |
| requestAnimationFrame(() => { |
| messageStream.scrollTop = messageStream.scrollHeight; |
| }); |
| } |
| |
| function autoResizeTextarea() { |
| userInput.style.height = 'auto'; |
| userInput.style.height = Math.min(userInput.scrollHeight, 180) + 'px'; |
| } |
| |
| |
| function renderContent(text) { |
| if (!text) return ''; |
| let html = escapeHtml(text); |
| |
| |
| html = html.replace(/```(\w+)?\n?([\s\S]*?)```/g, (m, lang, code) => { |
| return `<pre class="code-block my-2"><code class="language-${lang || 'text'}">${code.trim()}</code></pre>`; |
| }); |
| |
| |
| html = html.replace(/`([^`]+)`/g, '<code class="bg-slate-800/80 px-1.5 py-0.5 rounded text-xs text-cyan-300 font-mono">$1</code>'); |
| |
| |
| html = html.replace(/\*\*(.+?)\*\*/g, '<strong class="font-semibold text-slate-100">$1</strong>'); |
| |
| |
| html = html.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>'); |
| |
| |
| html = html.replace(/^### (.+)$/gm, '<h3 class="font-bold text-base mt-3 mb-1 text-slate-100">$1</h3>'); |
| html = html.replace(/^## (.+)$/gm, '<h2 class="font-bold text-lg mt-3 mb-1 text-slate-100">$1</h2>'); |
| html = html.replace(/^# (.+)$/gm, '<h1 class="font-bold text-xl mt-3 mb-2 text-slate-100">$1</h1>'); |
| |
| |
| html = html.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc text-slate-300">$1</li>'); |
| html = html.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 list-decimal text-slate-300">$2</li>'); |
| html = html.replace(/(<li[^>]*>.*?<\/li>\n?)+/g, (m) => `<ul class="my-2 space-y-0.5">${m}</ul>`); |
| |
| |
| html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" class="text-cyan-400 hover:text-cyan-300 underline underline-offset-2">$1</a>'); |
| |
| |
| html = html.replace(/(?<!\()https?:\/\/[^\s<)]+/g, (url) => { |
| if (url.includes('class="') || url.includes('href="')) return url; |
| return `<a href="${url}" target="_blank" rel="noopener" class="text-cyan-400 hover:text-cyan-300 break-all">${url}</a>`; |
| }); |
| |
| |
| html = html.replace(/<tool_call>([\s\S]*?)<\/tool_call>/g, (m, inner) => { |
| |
| const nameMatch = inner.match(/<name>([\s\S]*?)<\/name>/); |
| const inputMatch = inner.match(/<input>([\s\S]*?)<\/input>/); |
| const name = nameMatch ? nameMatch[1] : 'unknown'; |
| const input = inputMatch ? inputMatch[1] : ''; |
| const icon = getToolIcon(name); |
| return ` |
| <div class="xml-block my-3"> |
| <div class="flex items-center gap-2 mb-2"> |
| <i data-lucide="${icon}" class="w-3.5 h-3.5 text-amber-400"></i> |
| <span class="text-[10px] font-mono tracking-wider text-amber-300 uppercase">${escapeHtml(name)}</span> |
| </div> |
| <div class="flex flex-col gap-1 text-[11.5px]"> |
| <div><span class="xml-tag"><name></span><span class="xml-name">${escapeHtml(name)}</span><span class="xml-tag"></name></span></div> |
| <div class="break-all"><span class="xml-tag"><input></span><span class="xml-input">${escapeHtml(input)}</span><span class="xml-tag"></input></span></div> |
| </div> |
| </div> |
| `; |
| }); |
| |
| |
| html = html.replace(/<(\/?[a-zA-Z_][\w\-]*)>/g, '<span class="xml-tag"><$1></span>'); |
| |
| |
| html = html.split('\n\n').map(p => { |
| if (p.includes('<pre') || p.includes('<div class="xml-block"') || p.includes('<h') || p.includes('<ul')) return p; |
| return `<p class="mb-2 leading-relaxed text-slate-200">${p.replace(/\n/g, '<br>')}</p>`; |
| }).join(''); |
| |
| return html; |
| } |
| |
| function getToolIcon(name) { |
| const icons = { |
| ocr_vision: 'scan-eye', |
| transcribe_audio: 'audio-waveform', |
| image_caption: 'image-text', |
| web_search: 'globe', |
| image_generation: 'image-plus', |
| code_execute: 'terminal', |
| math: 'sigma', |
| }; |
| return icons[name] || 'wrench'; |
| } |
| |
| |
| function addMessage(role, content, files = [], opts = {}) { |
| if (welcomeScreen && !welcomeScreen.classList.contains('hidden')) { |
| welcomeScreen.classList.add('hidden'); |
| } |
| |
| const msgId = 'msg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7); |
| const msg = { id: msgId, role, content, files, timestamp: new Date() }; |
| state.messages.push(msg); |
| |
| const wrapper = document.createElement('div'); |
| wrapper.id = msgId; |
| wrapper.className = `msg-enter flex gap-3 ${role === 'user' ? 'justify-end' : ''}`; |
| |
| if (role === 'user') { |
| wrapper.innerHTML = ` |
| <div class="max-w-[85%] sm:max-w-[75%]"> |
| ${files.length ? renderUserFiles(files) : ''} |
| <div class="bg-gradient-to-br from-indigo-600/90 to-indigo-700/90 rounded-2xl rounded-tr-sm px-4 py-2.5 shadow-lg shadow-indigo-900/30"> |
| <div class="text-sm text-slate-50 leading-relaxed whitespace-pre-wrap break-words">${escapeHtml(content)}</div> |
| </div> |
| <div class="text-[10px] text-slate-500 font-mono mt-1 text-right"> |
| <i data-lucide="user" class="w-2.5 h-2.5 inline"></i> YOU • ${formatTime(msg.timestamp)} |
| </div> |
| </div> |
| `; |
| } else if (role === 'system-log') { |
| wrapper.className = 'msg-enter flex justify-center'; |
| wrapper.innerHTML = ` |
| <div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/60 border border-slate-700/60"> |
| <i data-lucide="${opts.icon || 'info'}" class="w-3 h-3 text-slate-400"></i> |
| <span class="text-[11px] font-mono text-slate-400">${escapeHtml(content)}</span> |
| </div> |
| `; |
| } else if (role === 'tool-call') { |
| wrapper.className = 'msg-enter flex justify-center'; |
| wrapper.innerHTML = ` |
| <div class="glass rounded-xl px-4 py-2.5 border-amber-500/40 shadow-lg max-w-md relative overflow-hidden"> |
| <div class="scan-line"></div> |
| <div class="relative flex items-center gap-3"> |
| <div class="w-7 h-7 rounded-lg bg-amber-500/20 border border-amber-500/40 flex items-center justify-center"> |
| <i data-lucide="${opts.icon || 'route'}" class="w-3.5 h-3.5 text-amber-400"></i> |
| </div> |
| <div> |
| <div class="text-[10px] font-mono tracking-wider text-amber-300 uppercase">Cloud Action Badge</div> |
| <div class="text-xs text-slate-200">${escapeHtml(content)}</div> |
| </div> |
| <div class="loading-dots"><span></span><span></span><span></span></div> |
| </div> |
| </div> |
| `; |
| } else if (role === 'tool-result') { |
| wrapper.innerHTML = ` |
| <div class="w-full max-w-3xl"> |
| <div class="tool-result"> |
| <div class="flex items-center gap-2 mb-2"> |
| <i data-lucide="${opts.icon || 'check'}" class="w-3.5 h-3.5 text-emerald-400"></i> |
| <span class="text-[10px] font-mono tracking-wider text-emerald-300 uppercase">${escapeHtml(opts.toolName || 'RESULT')}</span> |
| <span class="text-[10px] text-slate-500 font-mono ml-auto">${formatTime(msg.timestamp)}</span> |
| </div> |
| <div class="text-xs text-slate-300 whitespace-pre-wrap break-words leading-relaxed">${opts.html ? opts.html : escapeHtml(content)}</div> |
| </div> |
| </div> |
| `; |
| } else { |
| |
| wrapper.innerHTML = ` |
| <div class="w-full max-w-3xl"> |
| <div class="flex items-center gap-2 mb-2"> |
| <div class="w-6 h-6 rounded-lg bg-gradient-to-br from-indigo-500 to-cyan-500 flex items-center justify-center"> |
| <i data-lucide="zap" class="w-3 h-3 text-white"></i> |
| </div> |
| <span class="text-xs font-semibold text-slate-300">Keefe-Discere</span> |
| <span class="text-[10px] text-slate-500 font-mono">• ${formatTime(msg.timestamp)}</span> |
| </div> |
| <div class="glass rounded-2xl rounded-tl-sm px-4 py-3 text-sm leading-relaxed break-words assistant-content"> |
| ${opts.html || renderContent(content)} |
| </div> |
| </div> |
| `; |
| } |
| |
| messageContainer.appendChild(wrapper); |
| lucide.createIcons({ nodes: [wrapper] }); |
| scrollToBottom(); |
| return msgId; |
| } |
| |
| function renderUserFiles(files) { |
| return `<div class="flex flex-wrap gap-2 mb-2 justify-end"> |
| ${files.map(f => { |
| if (f.type.startsWith('image/')) { |
| return `<img src="${f.dataUrl}" alt="${escapeHtml(f.name)}" class="h-24 w-24 object-cover rounded-lg border border-slate-700">`; |
| } else if (f.type.startsWith('audio/')) { |
| return `<div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800/80 border border-slate-700"> |
| <i data-lucide="audio-waveform" class="w-4 h-4 text-cyan-400"></i> |
| <span class="text-xs text-slate-300">${escapeHtml(f.name)}</span> |
| </div>`; |
| } |
| return ''; |
| }).join('')} |
| </div>`; |
| } |
| |
| function updateAssistantMessage(msgId, content) { |
| const el = document.getElementById(msgId); |
| if (!el) return; |
| const contentEl = el.querySelector('.assistant-content'); |
| if (!contentEl) return; |
| contentEl.innerHTML = renderContent(content); |
| lucide.createIcons({ nodes: [contentEl] }); |
| scrollToBottom(); |
| } |
| |
| function removeMessage(msgId) { |
| const el = document.getElementById(msgId); |
| if (el) el.remove(); |
| state.messages = state.messages.filter(m => m.id !== msgId); |
| } |
| |
| function formatTime(d) { |
| const date = new Date(d); |
| return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); |
| } |
| |
| |
| function readFileAsDataURL(file) { |
| return new Promise((resolve, reject) => { |
| const reader = new FileReader(); |
| reader.onload = () => resolve(reader.result); |
| reader.onerror = reject; |
| reader.readAsDataURL(file); |
| }); |
| } |
| |
| async function handleFiles(fileList) { |
| const files = Array.from(fileList); |
| for (const file of files) { |
| if (!file.type.startsWith('image/') && !file.type.startsWith('audio/')) { |
| showToast(`Unsupported file type: ${file.type}`, 'error'); |
| continue; |
| } |
| if (file.size > 20 * 1024 * 1024) { |
| showToast(`File too large (max 20MB): ${file.name}`, 'error'); |
| continue; |
| } |
| try { |
| const dataUrl = await readFileAsDataURL(file); |
| state.attachedFiles.push({ file, dataUrl, type: file.type, name: file.name }); |
| } catch (err) { |
| console.error(err); |
| showToast(`Failed to read: ${file.name}`, 'error'); |
| } |
| } |
| renderAttachedFiles(); |
| } |
| |
| function renderAttachedFiles() { |
| if (state.attachedFiles.length === 0) { |
| attachedFilesEl.classList.add('hidden'); |
| attachedFilesEl.innerHTML = ''; |
| return; |
| } |
| attachedFilesEl.classList.remove('hidden'); |
| attachedFilesEl.innerHTML = state.attachedFiles.map((f, idx) => { |
| if (f.type.startsWith('image/')) { |
| return `<div class="relative group"> |
| <img src="${f.dataUrl}" alt="${escapeHtml(f.name)}" class="h-16 w-16 object-cover rounded-lg border border-slate-700"> |
| <button class="remove-file absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-rose-500 hover:bg-rose-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" data-idx="${idx}"> |
| <i data-lucide="x" class="w-3 h-3 text-white"></i> |
| </button> |
| <div class="text-[9px] text-slate-400 mt-0.5 truncate max-w-[64px] font-mono">${escapeHtml(f.name)}</div> |
| </div>`; |
| } else { |
| return `<div class="relative group flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800/80 border border-slate-700"> |
| <i data-lucide="audio-waveform" class="w-4 h-4 text-cyan-400"></i> |
| <span class="text-xs text-slate-300 max-w-[120px] truncate">${escapeHtml(f.name)}</span> |
| <button class="remove-file p-1 rounded hover:bg-slate-700/80" data-idx="${idx}"> |
| <i data-lucide="x" class="w-3 h-3 text-slate-400"></i> |
| </button> |
| </div>`; |
| } |
| }).join(''); |
| |
| attachedFilesEl.querySelectorAll('.remove-file').forEach(btn => { |
| btn.addEventListener('click', (e) => { |
| const idx = parseInt(btn.dataset.idx); |
| state.attachedFiles.splice(idx, 1); |
| renderAttachedFiles(); |
| }); |
| }); |
| |
| lucide.createIcons({ nodes: [attachedFilesEl] }); |
| } |
| |
| |
| function parseToolCalls(text) { |
| const calls = []; |
| const regex = /<tool_call>\s*<name>([\s\S]*?)<\/name>\s*<input>([\s\S]*?)<\/input>\s*<\/tool_call>/g; |
| let match; |
| while ((match = regex.exec(text)) !== null) { |
| calls.push({ |
| full: match[0], |
| name: match[1].trim(), |
| input: match[2].trim(), |
| }); |
| } |
| return calls; |
| } |
| |
| |
| async function executeTool(name, input) { |
| if (!state.token) { |
| throw new Error('HF token required to execute tool'); |
| } |
| const client = new InferenceClient(state.token); |
| |
| const callMsgId = addMessage('tool-call', `👁️ Routing to HF Inference • ${name}`, [], { |
| icon: getToolIcon(name) |
| }); |
| |
| try { |
| let result = ''; |
| let resultHtml = ''; |
| |
| if (name === 'ocr_vision' || name === 'image_caption') { |
| |
| let imageUrl = input.trim(); |
| |
| if (!imageUrl.match(/^https?:\/\//)) { |
| const attachedImage = state.attachedFiles.find(f => f.type.startsWith('image/')); |
| if (attachedImage) { |
| imageUrl = attachedImage.dataUrl; |
| } else { |
| throw new Error('No image URL provided and no image attached'); |
| } |
| } |
| |
| const caption = await client.imageToText({ |
| model: name === 'ocr_vision' ? 'Salesforce/blip-image-captioning-large' : 'Salesforce/blip-image-captioning-large', |
| data: imageUrl, |
| }); |
| result = Array.isArray(caption) ? caption.map(c => c.generated_text).join('\n') : (caption?.generated_text || String(caption)); |
| resultHtml = ` |
| <div class="flex items-start gap-3"> |
| <img src="${imageUrl.startsWith('data:') ? imageUrl : imageUrl}" alt="input" class="w-20 h-20 rounded-lg object-cover border border-slate-700 flex-shrink-0" onerror="this.style.display='none'"> |
| <div class="flex-1 min-w-0"> |
| <div class="text-[10px] font-mono text-emerald-300 mb-1 uppercase tracking-wider">Caption Output</div> |
| <div class="text-slate-200">${escapeHtml(result)}</div> |
| </div> |
| </div>`; |
| } |
| else if (name === 'transcribe_audio') { |
| let audioData = input.trim(); |
| const attachedAudio = state.attachedFiles.find(f => f.type.startsWith('audio/')); |
| if (attachedAudio) { |
| |
| const res = await fetch(attachedAudio.dataUrl); |
| audioData = await res.blob(); |
| } else if (audioData.match(/^https?:\/\//)) { |
| const res = await fetch(audioData); |
| audioData = await res.blob(); |
| } else { |
| throw new Error('No audio URL provided and no audio attached'); |
| } |
| const transcript = await client.automaticSpeechRecognition({ |
| model: 'openai/whisper-large-v3', |
| data: audioData, |
| }); |
| result = transcript?.text || String(transcript); |
| resultHtml = ` |
| <div> |
| <div class="text-[10px] font-mono text-emerald-300 mb-1 uppercase tracking-wider">Transcript</div> |
| <div class="text-slate-200 leading-relaxed">${escapeHtml(result)}</div> |
| </div>`; |
| } |
| else if (name === 'web_search') { |
| |
| result = `Searched for: "${input}"\n\n[Simulated search results would appear here. In production, this would route to a search API or use a web-grounded model.]`; |
| resultHtml = ` |
| <div> |
| <div class="text-[10px] font-mono text-emerald-300 mb-1 uppercase tracking-wider">Query: ${escapeHtml(input)}</div> |
| <div class="text-slate-200 leading-relaxed text-[11px] font-mono opacity-80">${escapeHtml(result)}</div> |
| </div>`; |
| } |
| else if (name === 'image_generation') { |
| const blob = await client.textToImage({ |
| model: 'black-forest-labs/FLUX.1-schnell', |
| inputs: input, |
| parameters: { num_inference_steps: 25 } |
| }); |
| const imgUrl = URL.createObjectURL(blob); |
| result = `Generated image for: "${input}"`; |
| resultHtml = ` |
| <div> |
| <div class="text-[10px] font-mono text-emerald-300 mb-2 uppercase tracking-wider">Generated Image</div> |
| <img src="${imgUrl}" alt="${escapeHtml(input)}" class="max-w-full rounded-lg border border-slate-700 shadow-lg"> |
| <div class="text-[10px] text-slate-400 mt-1 font-mono">Prompt: ${escapeHtml(input)}</div> |
| </div>`; |
| } |
| else { |
| throw new Error(`Unknown tool: ${name}`); |
| } |
| |
| |
| removeMessage(callMsgId); |
| addMessage('tool-result', result, [], { |
| html: resultHtml, |
| toolName: name, |
| icon: getToolIcon(name) |
| }); |
| |
| return result; |
| } catch (err) { |
| removeMessage(callMsgId); |
| addMessage('system-log', `Tool error: ${err.message}`, [], { icon: 'alert-triangle' }); |
| throw err; |
| } |
| } |
| |
| |
| async function sendMessage() { |
| const text = userInput.value.trim(); |
| if (!text && state.attachedFiles.length === 0) return; |
| if (state.isStreaming) return; |
| |
| if (!state.token) { |
| openTokenModal(); |
| showToast('Please set your HF access token first', 'warning'); |
| return; |
| } |
| |
| state.isStreaming = true; |
| sendBtn.disabled = true; |
| sendBtn.innerHTML = '<div class="loading-dots"><span></span><span></span><span></span></div>'; |
| |
| const files = [...state.attachedFiles]; |
| state.attachedFiles = []; |
| renderAttachedFiles(); |
| userInput.value = ''; |
| autoResizeTextarea(); |
| updateCharCount(); |
| |
| |
| addMessage('user', text || '(multimodal input)', files); |
| |
| try { |
| await runAssistantLoop(text, files); |
| } catch (err) { |
| console.error(err); |
| addMessage('system-log', `Error: ${err.message}`, [], { icon: 'alert-circle' }); |
| showToast(err.message, 'error'); |
| } finally { |
| state.isStreaming = false; |
| sendBtn.disabled = false; |
| sendBtn.innerHTML = '<i data-lucide="send-horizontal" class="w-4 h-4 text-white"></i>'; |
| lucide.createIcons({ nodes: [sendBtn] }); |
| } |
| } |
| |
| async function runAssistantLoop(userText, files) { |
| |
| const systemPrompt = systemPromptInput.value.trim(); |
| const apiMessages = []; |
| if (systemPrompt) { |
| apiMessages.push({ role: 'system', content: systemPrompt }); |
| } |
| |
| |
| const history = state.messages.slice(-10).filter(m => m.role === 'user' || m.role === 'assistant'); |
| for (const m of history) { |
| if (m.role === 'user') { |
| if (m.files && m.files.length > 0) { |
| const content = []; |
| if (m.content && m.content !== '(multimodal input)') { |
| content.push({ type: 'text', text: m.content }); |
| } |
| for (const f of m.files) { |
| if (f.type.startsWith('image/')) { |
| content.push({ type: 'image_url', image_url: { url: f.dataUrl } }); |
| } else if (f.type.startsWith('audio/')) { |
| content.push({ type: 'text', text: `[Audio attachment: ${f.name}]` }); |
| } |
| } |
| apiMessages.push({ role: 'user', content }); |
| } else { |
| apiMessages.push({ role: 'user', content: m.content }); |
| } |
| } else { |
| apiMessages.push({ role: 'assistant', content: m.content }); |
| } |
| } |
| |
| const assistantMsgId = addMessage('assistant', '', [], { |
| html: '<div class="text-slate-400 flex items-center gap-2"><div class="loading-dots"><span></span><span></span><span></span></div><span class="text-xs font-mono">Reasoning...</span></div>' |
| }); |
| |
| const client = new InferenceClient(state.token); |
| let fullResponse = ''; |
| |
| try { |
| |
| const stream = client.chatCompletionStream({ |
| model: state.model, |
| messages: apiMessages, |
| temperature: 0.7, |
| max_tokens: 2048, |
| stream: true, |
| }); |
| |
| let firstChunk = true; |
| for await (const chunk of stream) { |
| const delta = chunk.choices?.[0]?.delta?.content || ''; |
| if (delta) { |
| if (firstChunk) { |
| fullResponse = delta; |
| firstChunk = false; |
| } else { |
| fullResponse += delta; |
| } |
| updateAssistantMessage(assistantMsgId, fullResponse); |
| } |
| } |
| } catch (streamErr) { |
| console.warn('Streaming failed, falling back to non-streaming:', streamErr.message); |
| |
| try { |
| const result = await client.chatCompletion({ |
| model: state.model, |
| messages: apiMessages, |
| temperature: 0.7, |
| max_tokens: 2048, |
| }); |
| fullResponse = result.choices?.[0]?.message?.content || ''; |
| updateAssistantMessage(assistantMsgId, fullResponse); |
| } catch (fallbackErr) { |
| removeMessage(assistantMsgId); |
| throw fallbackErr; |
| } |
| } |
| |
| |
| const toolCalls = parseToolCalls(fullResponse); |
| if (toolCalls.length > 0 && state.toolCallDepth < state.maxToolCallDepth) { |
| state.toolCallDepth++; |
| const toolResults = []; |
| for (const call of toolCalls) { |
| try { |
| const result = await executeTool(call.name, call.input); |
| toolResults.push({ name: call.name, input: call.input, result }); |
| } catch (err) { |
| toolResults.push({ name: call.name, input: call.input, error: err.message }); |
| } |
| } |
| |
| |
| if (toolResults.length > 0) { |
| const followUpMessages = [ |
| ...apiMessages, |
| { role: 'assistant', content: fullResponse }, |
| { |
| role: 'user', |
| content: `[SYSTEM] Tool execution complete. Here are the results:\n\n${toolResults.map(r => |
| `--- ${r.name} ---\nInput: ${r.input}\nResult: ${r.error ? `ERROR: ${r.error}` : r.result}\n` |
| ).join('\n')}\n\nPlease respond to the user incorporating these tool results. Do NOT emit any new <tool_call> tags unless absolutely necessary.` |
| } |
| ]; |
| |
| const followUpMsgId = addMessage('assistant', '', [], { |
| html: '<div class="text-slate-400 flex items-center gap-2"><div class="loading-dots"><span></span><span></span><span></span></div><span class="text-xs font-mono">Synthesizing tool results...</span></div>' |
| }); |
| |
| let followUpResponse = ''; |
| try { |
| const followStream = client.chatCompletionStream({ |
| model: state.model, |
| messages: followUpMessages, |
| temperature: 0.5, |
| max_tokens: 2048, |
| stream: true, |
| }); |
| |
| let first = true; |
| for await (const chunk of followStream) { |
| const delta = chunk.choices?.[0]?.delta?.content || ''; |
| if (delta) { |
| followUpResponse += delta; |
| updateAssistantMessage(followUpMsgId, followUpResponse); |
| } |
| } |
| } catch (followErr) { |
| console.warn('Follow-up stream failed:', followErr.message); |
| try { |
| const result = await client.chatCompletion({ |
| model: state.model, |
| messages: followUpMessages, |
| temperature: 0.5, |
| max_tokens: 2048, |
| }); |
| followUpResponse = result.choices?.[0]?.message?.content || ''; |
| updateAssistantMessage(followUpMsgId, followUpResponse); |
| } catch (e) { |
| removeMessage(followUpMsgId); |
| addMessage('system-log', `Follow-up failed: ${e.message}`, [], { icon: 'alert-circle' }); |
| } |
| } |
| } |
| state.toolCallDepth = 0; |
| } |
| } |
| |
| |
| userInput.addEventListener('input', () => { |
| autoResizeTextarea(); |
| updateCharCount(); |
| }); |
| |
| userInput.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| sendMessage(); |
| } |
| }); |
| |
| sendBtn.addEventListener('click', sendMessage); |
| |
| function updateCharCount() { |
| const len = userInput.value.length; |
| charCount.textContent = `${len} / 8000`; |
| charCount.classList.toggle('text-amber-400', len > 6000); |
| charCount.classList.toggle('text-rose-400', len > 7500); |
| } |
| |
| fileInput.addEventListener('change', (e) => { |
| handleFiles(e.target.files); |
| e.target.value = ''; |
| }); |
| |
| |
| let dragCounter = 0; |
| ['dragenter', 'dragover'].forEach(evt => { |
| dropZone.addEventListener(evt, (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| dragCounter++; |
| dropZone.classList.add('drag-over'); |
| if (state.attachedFiles.length === 0) { |
| dropLabel.classList.remove('hidden'); |
| } |
| }); |
| }); |
| ['dragleave', 'drop'].forEach(evt => { |
| dropZone.addEventListener(evt, (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| dragCounter--; |
| if (dragCounter <= 0) { |
| dragCounter = 0; |
| dropZone.classList.remove('drag-over'); |
| dropLabel.classList.add('hidden'); |
| } |
| }); |
| }); |
| dropZone.addEventListener('drop', (e) => { |
| if (e.dataTransfer?.files?.length) { |
| handleFiles(e.dataTransfer.files); |
| } |
| }); |
| |
| |
| userInput.addEventListener('paste', (e) => { |
| const items = e.clipboardData?.items; |
| if (!items) return; |
| const files = []; |
| for (const item of items) { |
| if (item.kind === 'file') { |
| const file = item.getAsFile(); |
| if (file) files.push(file); |
| } |
| } |
| if (files.length > 0) { |
| e.preventDefault(); |
| handleFiles(files); |
| } |
| }); |
| |
| |
| systemPromptToggle.addEventListener('click', () => { |
| systemPromptPanel.classList.toggle('hidden'); |
| sysPromptIcon.classList.toggle('rotate-180'); |
| }); |
| |
| |
| clearBtn.addEventListener('click', () => { |
| if (state.messages.length === 0) return; |
| if (confirm('Clear all messages?')) { |
| state.messages = []; |
| messageContainer.innerHTML = ''; |
| welcomeScreen.classList.remove('hidden'); |
| messageContainer.appendChild(welcomeScreen); |
| showToast('Chat cleared', 'info'); |
| } |
| }); |
| |
| |
| document.addEventListener('click', (e) => { |
| const btn = e.target.closest('.suggestion-btn'); |
| if (btn) { |
| userInput.value = btn.dataset.prompt || ''; |
| userInput.focus(); |
| autoResizeTextarea(); |
| updateCharCount(); |
| } |
| }); |
| |
| |
| function openTokenModal() { |
| tokenModal.classList.remove('hidden'); |
| tokenModal.classList.add('flex'); |
| tokenInput.value = state.token; |
| tokenInput.focus(); |
| lucide.createIcons(); |
| } |
| function closeTokenModal() { |
| tokenModal.classList.add('hidden'); |
| tokenModal.classList.remove('flex'); |
| } |
| |
| tokenBtn.addEventListener('click', openTokenModal); |
| $('closeTokenModal').addEventListener('click', closeTokenModal); |
| tokenModal.addEventListener('click', (e) => { |
| if (e.target === tokenModal) closeTokenModal(); |
| }); |
| |
| $('toggleTokenVis').addEventListener('click', () => { |
| const isPw = tokenInput.type === 'password'; |
| tokenInput.type = isPw ? 'text' : 'password'; |
| const icon = $('eyeIcon'); |
| icon.setAttribute('data-lucide', isPw ? 'eye-off' : 'eye'); |
| lucide.createIcons({ nodes: [icon.parentElement] }); |
| }); |
| |
| $('saveTokenBtn').addEventListener('click', () => { |
| const val = tokenInput.value.trim(); |
| if (!val) { |
| showToast('Token cannot be empty', 'error'); |
| return; |
| } |
| state.token = val; |
| localStorage.setItem('keefe_hf_token', val); |
| updateTokenStatus(); |
| showToast('Token saved locally', 'success'); |
| closeTokenModal(); |
| }); |
| |
| $('clearTokenBtn').addEventListener('click', () => { |
| if (confirm('Remove stored token?')) { |
| state.token = ''; |
| localStorage.removeItem('keefe_hf_token'); |
| tokenInput.value = ''; |
| updateTokenStatus(); |
| showToast('Token cleared', 'info'); |
| } |
| }); |
| |
| tokenInput.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter') { |
| e.preventDefault(); |
| $('saveTokenBtn').click(); |
| } |
| }); |
| |
| function updateTokenStatus() { |
| if (state.token) { |
| tokenStatus.textContent = 'Token Active'; |
| tokenStatus.classList.remove('text-slate-300'); |
| tokenStatus.classList.add('text-emerald-400'); |
| tokenSavedBadge.classList.remove('hidden'); |
| } else { |
| tokenStatus.textContent = 'Set Token'; |
| tokenStatus.classList.add('text-slate-300'); |
| tokenStatus.classList.remove('text-emerald-400'); |
| tokenSavedBadge.classList.add('hidden'); |
| } |
| } |
| |
| |
| updateTokenStatus(); |
| updateCharCount(); |
| lucide.createIcons(); |
| |
| |
| if (!state.token) { |
| setTimeout(() => { |
| showToast('Set your Hugging Face token to begin', 'info', 5000); |
| }, 800); |
| } |
| |
| |
| document.addEventListener('keydown', (e) => { |
| |
| if ((e.metaKey || e.ctrlKey) && e.key === 'k') { |
| e.preventDefault(); |
| userInput.focus(); |
| } |
| |
| if (e.key === 'Escape') { |
| if (!tokenModal.classList.contains('hidden')) closeTokenModal(); |
| } |
| }); |
| |
| |
| window.keefe = { state, sendMessage }; |
| |
| </script> |
| </body> |
| </html> |