Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>ember · token forge</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| * { box-sizing: border-box; } | |
| :root { | |
| --bg-deep: #0f0f0f; --bg-panel: #1a1a1a; --amber-glow: #fbbf24; | |
| --amber-soft: #d97706; --amber-muted: #b68b40; --text-luminous: #f5f2e8; | |
| --text-dim: #a89f8e; --border-glow: rgba(251, 191, 36, 0.15); | |
| --scroll-thumb: rgba(251, 191, 36, 0.3); --bubble-user: rgba(251, 191, 36, 0.12); | |
| --bubble-assistant: rgba(245, 235, 200, 0.04); --bubble-system: rgba(180, 130, 40, 0.15); | |
| --thought-bg: rgba(251, 191, 36, 0.06); --success: #a3e635; | |
| } | |
| body { | |
| background: var(--bg-deep); color: var(--text-luminous); | |
| font-family: 'Inter', system-ui, sans-serif; font-feature-settings: "cv02", "ss01"; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar { width: 5px; } | |
| .custom-scrollbar::-webkit-scrollbar-track { background: transparent; } | |
| .custom-scrollbar::-webkit-scrollbar-thumb { background: var(--scroll-thumb); border-radius: 20px; } | |
| /* panels */ | |
| .panel-glass { | |
| background: var(--bg-panel); border: 1px solid var(--border-glow); | |
| border-radius: 20px; box-shadow: 0 20px 35px -15px black; | |
| } | |
| /* bubbles */ | |
| .bubble-user { | |
| background: var(--bubble-user); border: 1px solid rgba(251,191,36,0.2); | |
| border-radius: 22px 22px 6px 22px; padding: 0.8rem 1.4rem; | |
| } | |
| .bubble-assistant { | |
| background: var(--bubble-assistant); border: 1px solid rgba(255,235,160,0.08); | |
| border-radius: 22px 22px 22px 6px; padding: 0.8rem 1.4rem; | |
| } | |
| .bubble-system { | |
| background: var(--bubble-system); border-left: 4px solid var(--amber-soft); | |
| border-radius: 16px; padding: 0.8rem 1.4rem; color: #e6d7b0; | |
| } | |
| /* thought container */ | |
| .thought-container { | |
| background: var(--thought-bg); border-radius: 20px; | |
| border: 1px solid rgba(251,191,36,0.18); margin-bottom: 8px; | |
| } | |
| details.thought-container > summary { | |
| cursor: pointer; list-style: none; padding: 0.4rem 1rem; | |
| font-size: 0.65rem; font-weight: 600; letter-spacing: 1px; | |
| color: var(--amber-glow); display: flex; align-items: center; | |
| } | |
| details.thought-container > summary::-webkit-details-marker { display: none; } | |
| details.thought-container summary::before { | |
| content: "▶"; font-size: 9px; color: var(--amber-glow); | |
| display: inline-block; transition: 0.15s; margin-right: 6px; | |
| } | |
| details.thought-container[open] summary::before { content: "▼"; } | |
| .thought-body { padding: 0 1rem 0.6rem 1rem; font-size: 0.8rem; color: #d4c29b; } | |
| /* editable only for content/thought — role labels now static */ | |
| .editable-text, .editable-thought { | |
| transition: all 0.1s ease; border-radius: 6px; cursor: text; | |
| } | |
| .editable-text:focus, .editable-thought:focus { | |
| outline: 1px solid var(--amber-glow); background: rgba(251,191,36,0.04); | |
| box-shadow: 0 0 0 2px rgba(251,191,36,0.2); | |
| } | |
| /* token badges – minimal */ | |
| .token-badge { | |
| background: rgba(0,0,0,0.5); backdrop-filter: blur(2px); | |
| border: 1px solid rgba(251,191,36,0.25); color: #d4b27e; | |
| font-size: 0.6rem; padding: 0.2rem 0.6rem; border-radius: 30px; | |
| white-space: nowrap; font-family: monospace; | |
| } | |
| /* global token meter */ | |
| .meter-bar { | |
| height: 6px; background: #2a2a2a; border-radius: 30px; overflow: hidden; | |
| width: 140px; box-shadow: inset 0 1px 4px black; | |
| } | |
| .meter-fill { | |
| height: 100%; background: linear-gradient(90deg, #fbbf24, #d97706); | |
| border-radius: 30px; width: 0%; transition: width 0.2s; | |
| } | |
| /* role badges (static now, just visual) */ | |
| .role-badge { | |
| background: rgba(251,191,36,0.1); padding: 0.2rem 0.8rem; | |
| border-radius: 30px; font-size: 0.65rem; font-weight: 500; | |
| border: 1px solid rgba(251,191,36,0.15); color: var(--amber-glow); | |
| text-transform: uppercase; letter-spacing: 0.04em; | |
| } | |
| .main-container { height: calc(100vh - 118px); gap: 8px; } | |
| @media (max-width: 700px) { .main-container { flex-direction: column; height: auto; } } | |
| </style> | |
| </head> | |
| <body class="flex flex-col h-screen antialiased p-2"> | |
| <!-- header: token meter + next turn button --> | |
| <header class="panel-glass flex items-center justify-between px-5 py-2.5 mx-1 mb-1"> | |
| <div class="flex items-center gap-4"> | |
| <div class="flex items-center gap-2"> | |
| <div class="p-2 rounded-xl bg-amber-500/10"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/> | |
| </svg> | |
| </div> | |
| <h2 class="text-lg font-semibold tracking-tight text-amber-400/90">token<span class="text-amber-600">forge</span></h2> | |
| </div> | |
| <!-- GLOBAL TOKEN COUNTER with editable limit --> | |
| <div class="flex items-center gap-3 bg-black/30 px-3 py-1.5 rounded-full border border-amber-900/30"> | |
| <span class="text-xs text-amber-400/80">∑ tokens</span> | |
| <span id="global-total" class="text-sm font-mono text-amber-300">0</span> | |
| <span class="text-amber-700">/</span> | |
| <input id="token-limit" type="number" value="32000" min="100" max="1000000" step="100" | |
| class="w-20 bg-transparent border border-amber-800/50 rounded-md px-1.5 py-0.5 text-xs text-amber-200 font-mono text-right focus:outline-amber-500/50"> | |
| <div class="meter-bar"><div id="meter-fill" class="meter-fill" style="width:0%"></div></div> | |
| <span id="percent-used" class="text-[0.6rem] text-amber-600/90 w-10">0%</span> | |
| </div> | |
| </div> | |
| <!-- NEXT TURN BUTTON --> | |
| <div class="flex gap-2"> | |
| <button id="nextTurnBtn" class="flex items-center gap-1.5 bg-amber-600/20 hover:bg-amber-600/30 border border-amber-500/30 rounded-full px-5 py-2 text-xs font-medium text-amber-300 transition-all"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> | |
| next turn | |
| </button> | |
| </div> | |
| </header> | |
| <!-- main columns --> | |
| <main class="main-container flex overflow-hidden p-1"> | |
| <!-- LEFT JSON --> | |
| <section class="panel-glass w-1/2 flex flex-col overflow-hidden"> | |
| <div class="flex items-center justify-between px-5 py-2 border-b border-amber-900/30"> | |
| <span class="text-[0.65rem] font-mono tracking-wider text-amber-600/80">source.json</span> | |
| <span id="json-error" class="text-red-400/90 text-[0.6rem] bg-red-950/40 px-2 py-0.5 rounded hidden">invalid</span> | |
| </div> | |
| <textarea id="json-editor" class="flex-1 p-5 bg-[#0c0c0c] text-amber-100/80 font-mono text-sm outline-none resize-none custom-scrollbar" spellcheck="false"></textarea> | |
| </section> | |
| <!-- RIGHT CHAT PREVIEW + token details inline --> | |
| <section id="chat-preview" class="panel-glass w-1/2 overflow-y-auto p-5 custom-scrollbar space-y-5 relative"> | |
| <!-- chat bubbles go here --> | |
| </section> | |
| </main> | |
| <!-- footer: subtle hint --> | |
| <footer class="flex items-center justify-between px-5 py-1.5 text-[0.55rem] uppercase tracking-widest text-amber-700/60"> | |
| <div>↻ next turn auto‑selects user/assistant • roles static • token count per message (t)</div> | |
| <div>⊙ token forge</div> | |
| </footer> | |
| <script> | |
| (function() { | |
| const jsonEditor = document.getElementById('json-editor'); | |
| const chatPreview = document.getElementById('chat-preview'); | |
| const errorLabel = document.getElementById('json-error'); | |
| const globalTotalSpan = document.getElementById('global-total'); | |
| const tokenLimitInput = document.getElementById('token-limit'); | |
| const meterFill = document.getElementById('meter-fill'); | |
| const percentSpan = document.getElementById('percent-used'); | |
| const nextTurnBtn = document.getElementById('nextTurnBtn'); | |
| // --- default data with alternating pattern --- | |
| let data = { | |
| "messages": [ | |
| { | |
| "role": "system", | |
| "content": "You are a helpful assistant." | |
| }, | |
| { | |
| "role": "user", | |
| "content": "hi" | |
| }, | |
| { | |
| "role": "assistant", | |
| "content": "." | |
| } | |
| ] | |
| }; | |
| // simple token estimator (char ~0.25 token, but for demo we use rough) | |
| function estimateTokens(str) { | |
| if (!str) return 0; | |
| // simulate tiktoken-ish: 4 chars per token on average | |
| return Math.ceil(str.length / 3.8); | |
| } | |
| function computeAllTokens() { | |
| let total = 0; | |
| data.messages.forEach(msg => { | |
| const content = msg.content || ''; | |
| total += estimateTokens(content); | |
| // also count think part? already inside content. fine. | |
| }); | |
| return total; | |
| } | |
| function updateGlobalMeter() { | |
| const total = computeAllTokens(); | |
| const limit = parseInt(tokenLimitInput.value, 10) || 32000; | |
| const percent = Math.min(100, ((total / limit) * 100).toFixed(1)); | |
| globalTotalSpan.innerText = total; | |
| meterFill.style.width = percent + '%'; | |
| percentSpan.innerText = percent + '%'; | |
| // visual warning if >90% | |
| if (percent > 90) { | |
| meterFill.style.background = 'linear-gradient(90deg, #f97316, #dc2626)'; | |
| } else { | |
| meterFill.style.background = 'linear-gradient(90deg, #fbbf24, #d97706)'; | |
| } | |
| } | |
| // per-message token display (tiny badge) | |
| function createTokenBadge(count) { | |
| const badge = document.createElement('span'); | |
| badge.className = 'token-badge ml-2'; | |
| badge.innerText = `⚡${count}`; | |
| return badge; | |
| } | |
| // render everything | |
| function renderChat() { | |
| chatPreview.innerHTML = ''; | |
| data.messages.forEach((msg, idx) => { | |
| const bubble = createBubble(msg, idx); | |
| chatPreview.appendChild(bubble); | |
| }); | |
| updateGlobalMeter(); | |
| } | |
| function createBubble(msg, index) { | |
| const role = msg.role || 'assistant'; | |
| const isUser = role === 'user'; | |
| const isAssistant = role === 'assistant'; | |
| const isSystem = role === 'system'; | |
| const container = document.createElement('div'); | |
| container.className = `flex flex-col w-full ${isUser ? 'items-end' : 'items-start'} mb-5 relative`; | |
| // --- static role badge (NO contenteditable) --- | |
| const labelRow = document.createElement('div'); | |
| labelRow.className = 'flex items-center gap-2 mb-1.5'; | |
| const roleSpan = document.createElement('span'); | |
| roleSpan.className = 'role-badge'; | |
| roleSpan.innerText = role; | |
| labelRow.appendChild(roleSpan); | |
| // add token badge for message (total tokens of content, includes thought) | |
| const contentTokens = estimateTokens(msg.content || ''); | |
| const tokenBadge = createTokenBadge(contentTokens); | |
| labelRow.appendChild(tokenBadge); | |
| container.appendChild(labelRow); | |
| // --- bubble body --- | |
| const body = document.createElement('div'); | |
| let bubbleClass = 'bubble-assistant'; | |
| if (isUser) bubbleClass = 'bubble-user'; | |
| else if (isSystem) bubbleClass = 'bubble-system'; | |
| body.className = bubbleClass + ' w-fit max-w-[90%] break-words'; | |
| // extract thought | |
| const raw = msg.content || ''; | |
| const thinkRegex = /<think>([\s\S]*?)<\/think>/; | |
| const thinkMatch = raw.match(thinkRegex); | |
| const thoughtText = thinkMatch ? thinkMatch[1].trim() : ''; | |
| const cleanText = raw.replace(thinkRegex, '').trim(); | |
| // thought section (only if assistant, but could appear) | |
| if (isAssistant || thoughtText) { | |
| const details = document.createElement('details'); | |
| details.className = 'thought-container'; | |
| if (thoughtText) details.open = true; | |
| const summary = document.createElement('summary'); | |
| summary.innerText = ' thought process'; | |
| const thoughtDiv = document.createElement('div'); | |
| thoughtDiv.className = 'thought-body editable-thought outline-none'; | |
| thoughtDiv.contentEditable = true; | |
| thoughtDiv.innerText = thoughtText; | |
| thoughtDiv.onblur = (e) => updateData(index, 'thought', e.target.innerText); | |
| details.appendChild(summary); | |
| details.appendChild(thoughtDiv); | |
| body.appendChild(details); | |
| } | |
| // main editable content | |
| const mainDiv = document.createElement('div'); | |
| mainDiv.contentEditable = true; | |
| mainDiv.className = 'editable-text outline-none whitespace-pre-wrap leading-relaxed text-[0.95rem]'; | |
| mainDiv.innerText = cleanText || '...'; | |
| mainDiv.onblur = (e) => updateData(index, 'content', e.target.innerText); | |
| body.appendChild(mainDiv); | |
| container.appendChild(body); | |
| return container; | |
| } | |
| function updateData(index, field, value) { | |
| const msg = data.messages[index]; | |
| if (!msg) return; | |
| if (field === 'thought') { | |
| const current = msg.content || ''; | |
| const clean = current.replace(/<think>[\s\S]*?<\/think>/, '').trim(); | |
| if (value.trim()) { | |
| msg.content = `<think>${value.trim()}</think> ${clean}`; | |
| } else { | |
| msg.content = clean; | |
| } | |
| } else if (field === 'content') { | |
| const current = msg.content || ''; | |
| const thinkMatch = current.match(/<think>([\s\S]*?)<\/think>/); | |
| const thoughtPart = thinkMatch ? `<think>${thinkMatch[1]}</think>` : ''; | |
| msg.content = thoughtPart + (thoughtPart && value ? ' ' : '') + value; | |
| } | |
| // update json & re-render | |
| jsonEditor.value = JSON.stringify(data, null, 2); | |
| renderChat(); | |
| } | |
| // listen to JSON edits | |
| jsonEditor.addEventListener('input', (e) => { | |
| try { | |
| const parsed = JSON.parse(e.target.value); | |
| if (parsed.messages && Array.isArray(parsed.messages)) { | |
| data = parsed; | |
| renderChat(); | |
| errorLabel.classList.add('hidden'); | |
| } else throw new Error(); | |
| } catch { | |
| errorLabel.classList.remove('hidden'); | |
| } | |
| }); | |
| // next turn: add user or assistant based on last role | |
| function addNextTurn() { | |
| const messages = data.messages; | |
| if (!messages.length) return; | |
| const lastRole = messages[messages.length - 1].role; | |
| let newRole = 'user'; | |
| if (lastRole === 'user') newRole = 'assistant'; | |
| else if (lastRole === 'assistant') newRole = 'user'; | |
| else newRole = 'user'; // system? fallback to user | |
| // default empty content | |
| const newMsg = { role: newRole, content: '' }; | |
| data.messages.push(newMsg); | |
| jsonEditor.value = JSON.stringify(data, null, 2); | |
| renderChat(); | |
| // auto-scroll to bottom | |
| setTimeout(() => { | |
| chatPreview.scrollTo({ top: chatPreview.scrollHeight, behavior: 'smooth' }); | |
| }, 30); | |
| } | |
| nextTurnBtn.addEventListener('click', addNextTurn); | |
| // token limit change | |
| tokenLimitInput.addEventListener('input', updateGlobalMeter); | |
| // initialize | |
| jsonEditor.value = JSON.stringify(data, null, 2); | |
| renderChat(); | |
| // optional: copy function (simple) | |
| window.copyJson = function() { | |
| jsonEditor.select(); | |
| navigator.clipboard?.writeText(jsonEditor.value); | |
| // visual feedback can be added but not needed | |
| }; | |
| })(); | |
| </script> | |
| </body> | |
| </html> |