logu-fosablanca
Initial web app: REIC intent classifier demo
07ab316
Raw History Blame Contribute Delete
70.5 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Intent Classifier Β· REIC Demo</title> <!-- Fonts --> <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@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" /> <!-- Tailwind CDN --> <script src="https://cdn.tailwindcss.com"></script> <script> tailwind.config = { theme: { extend: { fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui'], mono: ['JetBrains Mono', 'ui-monospace', 'Menlo'], }, }, }, }; </script> <!-- Alpine.js --> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <style> [x-cloak] { display: none !important; } /* ── Scrollbar ───────────────────────────────────────────────────────── */ .scroll-thin::-webkit-scrollbar { width: 4px; height: 4px; } .scroll-thin::-webkit-scrollbar-track { background: transparent; } .scroll-thin::-webkit-scrollbar-thumb { background: #D1D5DB; border-radius: 4px; } .scroll-thin::-webkit-scrollbar-thumb:hover { background: #9CA3AF; } /* ── Entry animations ───────────────────────────────────────────────── */ @keyframes card-enter { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } @keyframes bubble-enter { from { opacity: 0; transform: translateX(8px); } to { opacity: 1; transform: translateX(0); } } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } .card-enter { animation: card-enter 280ms cubic-bezier(0.16, 1, 0.3, 1) both; } .bubble-enter { animation: bubble-enter 200ms cubic-bezier(0.16, 1, 0.3, 1) both; } .fade-in { animation: fade-in 180ms ease both; } /* ── Bounce loader ──────────────────────────────────────────────────── */ @keyframes bounce-dot { 0%, 80%, 100% { transform: translateY(0); opacity: 1; } 40% { transform: translateY(-7px); opacity: .7; } } .dot-1 { animation: bounce-dot 1.4s cubic-bezier(.4,0,.6,1) 0.00s infinite; } .dot-2 { animation: bounce-dot 1.4s cubic-bezier(.4,0,.6,1) 0.16s infinite; } .dot-3 { animation: bounce-dot 1.4s cubic-bezier(.4,0,.6,1) 0.32s infinite; } /* ── Confidence bar fill ────────────────────────────────────────────── */ .conf-fill { transition: width 600ms cubic-bezier(0.16, 1, 0.3, 1); } /* ── Spinner ────────────────────────────────────────────────────────── */ @keyframes spin { to { transform: rotate(360deg); } } .spin { animation: spin .7s linear infinite; } /* ── Pipeline pulse while loading ──────────────────────────────────── */ @keyframes pipeline-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } } .pipeline-node-pulse { animation: pipeline-pulse 1.3s ease-in-out infinite; } /* ── Misc ───────────────────────────────────────────────────────────── */ .chevron-open { transform: rotate(90deg); } textarea { resize: none; } ::placeholder { font-style: normal; } input[type=password] { letter-spacing: 0.12em; } .send-btn:active:not(:disabled) { transform: scale(.95); } </style> </head> <!-- ═══════════════════════════════════════════════════════════ ROOT DATA ═══ --> <body class="bg-slate-50 text-neutral-900 font-sans antialiased overflow-hidden" x-data="app()" x-cloak> <!-- ══════════════════════════════════════════════════════════════ HEADER ═══ --> <header class="fixed inset-x-0 top-0 z-30 h-14 bg-white border-b border-neutral-200 flex items-center px-5 gap-4"> <!-- Logo + title --> <div class="flex items-center gap-3 flex-shrink-0"> <div class="w-7 h-7 bg-indigo-600 rounded-lg flex items-center justify-center"> <svg class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2 M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/> </svg> </div> <div class="leading-tight"> <p class="text-[13px] font-semibold text-neutral-900">Intent Classifier</p> <p class="text-[10px] text-neutral-400 tracking-wider">REIC β€” RAG-Enhanced Classification</p> </div> </div> <div class="flex-1"></div> <!-- Active pills --> <div class="flex items-center gap-2"> <span class="text-[10px] font-bold px-2.5 py-1 rounded-full transition-colors duration-150" :class="{ 'bg-blue-100 text-blue-700': cfg.mode === 'flat', 'bg-violet-100 text-violet-700': cfg.mode === 'flat_rag', 'bg-emerald-100 text-emerald-700': cfg.mode === 'hierarchical_rag', }" x-text="modeLabelFull(cfg.mode)"></span> <span class="text-[10px] font-bold px-2.5 py-1 rounded-full transition-colors duration-150" :class="cfg.turnMode === 'multi' ? 'bg-amber-100 text-amber-800' : 'bg-neutral-100 text-neutral-500'" x-text="cfg.turnMode.toUpperCase() + '-TURN'"></span> </div> </header> <!-- ══════════════════════════════════════════════════════════════ LAYOUT ════ --> <div class="flex pt-14" style="height:100vh"> <!-- ═══════════════════════════════════════════════════════ SIDEBAR ════ --> <aside class="w-[360px] flex-shrink-0 bg-white border-r border-neutral-200 flex flex-col overflow-hidden"> <!-- Section 1: Mode + TurnMode + Dirty notice --> <div class="p-4 space-y-3 border-b border-neutral-100 flex-shrink-0"> <!-- Mode --> <div> <p class="text-[10px] font-bold text-neutral-400 uppercase tracking-widest mb-1.5">Mode</p> <div class="grid grid-cols-3 gap-1 bg-neutral-100 rounded-xl p-1"> <template x-for="m in modes" :key="m.value"> <button @click="cfg.mode = m.value; onCfgChange()" class="text-[11px] font-semibold h-7 rounded-lg transition-all duration-150" :class="cfg.mode === m.value ? 'bg-white shadow text-indigo-700' : 'text-neutral-400 hover:text-neutral-600'" x-text="m.label"></button> </template> </div> </div> <!-- Turn Mode --> <div> <p class="text-[10px] font-bold text-neutral-400 uppercase tracking-widest mb-1.5">Turn Mode</p> <div class="grid grid-cols-2 gap-1 bg-neutral-100 rounded-xl p-1"> <button @click="cfg.turnMode = 'single'" class="text-[11px] font-semibold h-7 rounded-lg transition-all duration-150" :class="cfg.turnMode === 'single' ? 'bg-white shadow text-indigo-700' : 'text-neutral-400 hover:text-neutral-600'"> Single-Turn </button> <button @click="cfg.turnMode = 'multi'" class="text-[11px] font-semibold h-7 rounded-lg transition-all duration-150" :class="cfg.turnMode === 'multi' ? 'bg-white shadow text-amber-700' : 'text-neutral-400 hover:text-neutral-600'"> Multi-Turn </button> </div> </div> <!-- Dirty notice --> <div x-show="cfgDirty" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 -translate-y-1" x-transition:enter-end="opacity-100 translate-y-0" class="flex items-center gap-2 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2"> <svg class="w-3 h-3 text-amber-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/> </svg> <span class="text-[11px] text-amber-700">Router will rebuild on next query</span> </div> </div> <!-- Section 2: Tab bar --> <div class="flex border-b border-neutral-100 flex-shrink-0"> <template x-for="tab in computedTabs" :key="tab.id"> <button @click="!tab.locked && (activeTab = tab.id)" :title="tab.locked ? tab.lockedMsg : ''" class="flex-1 flex items-center justify-center gap-1 h-10 text-[11px] font-semibold border-b-2 transition-all duration-150" :class="{ 'border-indigo-600 text-indigo-600': activeTab === tab.id, 'border-transparent text-neutral-400 hover:text-neutral-600': activeTab !== tab.id && !tab.locked, 'border-transparent text-neutral-300 cursor-not-allowed': tab.locked, }"> <span x-text="tab.label"></span> <!-- Lock icon for locked tabs --> <svg x-show="tab.locked" class="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/> </svg> <!-- Count badge --> <span x-show="tab.count !== null" class="text-[9px] text-neutral-300 font-normal" x-text="'(' + tab.count + ')'"></span> </button> </template> </div> <!-- Section 3: Tab content (scrollable) --> <div class="flex-1 overflow-y-auto scroll-thin"> <!-- ── LLM tab ───────────────────────────────────────────────────── --> <div x-show="activeTab === 'llm'" class="p-4 space-y-4 fade-in"> <!-- Provider toggle β€” 3 way --> <div> <p class="text-[10px] font-bold text-neutral-400 uppercase tracking-widest mb-1.5">Provider</p> <div class="grid grid-cols-3 gap-1 bg-neutral-100 rounded-xl p-1"> <button @click="cfg.llmProvider = 'openai'; ollamaModels = []; openrouterModels = []" class="text-[11px] font-semibold h-7 rounded-lg transition-all duration-150" :class="cfg.llmProvider === 'openai' ? 'bg-white shadow text-indigo-700' : 'text-neutral-400 hover:text-neutral-600'"> OpenAI </button> <button @click="cfg.llmProvider = 'openrouter'; cfg.llmBaseUrl = 'https://openrouter.ai/api'; ollamaModels = []; fetchOpenRouterModels()" class="text-[11px] font-semibold h-7 rounded-lg transition-all duration-150" :class="cfg.llmProvider === 'openrouter' ? 'bg-white shadow text-violet-700' : 'text-neutral-400 hover:text-neutral-600'"> OpenRouter </button> <button @click="cfg.llmProvider = 'ollama'; cfg.llmBaseUrl = 'http://localhost:11434'; openrouterModels = []; fetchOllamaModels()" class="text-[11px] font-semibold h-7 rounded-lg transition-all duration-150" :class="cfg.llmProvider === 'ollama' ? 'bg-white shadow text-indigo-700' : 'text-neutral-400 hover:text-neutral-600'"> Ollama </button> </div> </div> <!-- Fields --> <div class="space-y-3"> <!-- Base URL β€” hidden for OpenRouter (endpoint is fixed) --> <div x-show="cfg.llmProvider !== 'openrouter'"> <label class="text-[11px] font-semibold text-neutral-500 block mb-1">Base URL</label> <div class="flex gap-2"> <input x-model="cfg.llmBaseUrl" type="text" :placeholder="cfg.llmProvider === 'ollama' ? 'http://localhost:11434' : 'https://api.openai.com'" class="flex-1 text-xs border border-neutral-200 rounded-[10px] px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:border-transparent transition-shadow duration-150"> <!-- Re-fetch button for Ollama --> <button x-show="cfg.llmProvider === 'ollama'" @click="fetchOllamaModels()" :disabled="ollamaModelsLoading" title="Reload models from Ollama" class="flex-shrink-0 w-9 h-9 flex items-center justify-center rounded-[10px] border border-neutral-200 bg-neutral-50 hover:bg-indigo-50 hover:border-indigo-300 disabled:opacity-40 transition-all duration-150"> <svg x-show="!ollamaModelsLoading" class="w-3.5 h-3.5 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 4v5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/> </svg> <svg x-show="ollamaModelsLoading" class="w-3.5 h-3.5 text-indigo-500 spin" fill="none" viewBox="0 0 24 24"> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/> </svg> </button> </div> </div> <!-- OpenRouter: fixed endpoint badge + re-fetch --> <div x-show="cfg.llmProvider === 'openrouter'" class="flex items-center justify-between px-3 py-2 bg-violet-50 border border-violet-100 rounded-[10px]"> <div> <p class="text-[10px] font-semibold text-violet-700">openrouter.ai/api/v1</p> <p class="text-[9px] text-violet-400 mt-0.5">Endpoint is fixed β€” no configuration needed</p> </div> <button @click="fetchOpenRouterModels()" :disabled="openrouterModelsLoading" title="Reload model list from OpenRouter" class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg bg-white border border-violet-200 hover:border-violet-400 disabled:opacity-40 transition-all duration-150"> <svg x-show="!openrouterModelsLoading" class="w-3.5 h-3.5 text-violet-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 4v5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/> </svg> <svg x-show="openrouterModelsLoading" class="w-3.5 h-3.5 text-violet-400 spin" fill="none" viewBox="0 0 24 24"> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/> </svg> </button> </div> <!-- Model --> <div> <div class="flex items-center justify-between mb-1.5"> <label class="text-[11px] font-semibold text-neutral-500">Model</label> <span x-show="cfg.llmProvider === 'ollama' && ollamaModels.length > 0" class="text-[10px] text-neutral-400" x-text="ollamaModels.length + ' available'"></span> <span x-show="cfg.llmProvider === 'openrouter' && openrouterModels.length > 0" class="text-[10px] text-neutral-400" x-text="openrouterModels.length + ' models Β· ' + openrouterFreeCount + ' free'"></span> </div> <!-- Loading state (shared) --> <div x-show="(cfg.llmProvider === 'ollama' && ollamaModelsLoading) || (cfg.llmProvider === 'openrouter' && openrouterModelsLoading)" class="flex items-center gap-2 px-3 py-2.5 border border-neutral-200 rounded-[10px] bg-neutral-50"> <svg class="w-3.5 h-3.5 text-indigo-400 spin flex-shrink-0" fill="none" viewBox="0 0 24 24"> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/> </svg> <span class="text-xs text-neutral-400" x-text="cfg.llmProvider === 'ollama' ? 'Loading from Ollama…' : 'Loading from OpenRouter…'"></span> </div> <!-- Ollama: model chips --> <div x-show="cfg.llmProvider === 'ollama' && !ollamaModelsLoading && ollamaModels.length > 0" class="flex flex-wrap gap-1.5"> <template x-for="m in ollamaModels" :key="m"> <button @click="cfg.llmModelName = m" class="text-[11px] font-mono px-2.5 py-1.5 rounded-lg border transition-all duration-150" :class="cfg.llmModelName === m ? 'bg-indigo-600 border-indigo-600 text-white shadow-sm' : 'bg-white border-neutral-200 text-neutral-600 hover:border-indigo-300 hover:text-indigo-600'" x-text="m"></button> </template> </div> <!-- OpenRouter: model chips (scrollable) --> <div x-show="cfg.llmProvider === 'openrouter' && !openrouterModelsLoading && openrouterModels.length > 0" class="max-h-40 overflow-y-auto scroll-thin flex flex-wrap gap-1.5 pr-1"> <template x-for="m in openrouterModels" :key="m"> <button @click="cfg.llmModelName = m" class="text-[11px] font-mono px-2.5 py-1.5 rounded-lg border transition-all duration-150 flex-shrink-0" :class="cfg.llmModelName === m ? 'bg-violet-600 border-violet-600 text-white shadow-sm' : 'bg-white border-neutral-200 text-neutral-600 hover:border-violet-300 hover:text-violet-600'" x-text="m"></button> </template> </div> <!-- Error state (shared) --> <div x-show="(cfg.llmProvider === 'ollama' && !ollamaModelsLoading && ollamaFetchError) || (cfg.llmProvider === 'openrouter' && !openrouterModelsLoading && openrouterFetchError)" class="flex items-start gap-2 px-3 py-2.5 border border-red-100 rounded-[10px] bg-red-50"> <svg class="w-3.5 h-3.5 text-red-400 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> </svg> <div> <p class="text-[11px] text-red-600 font-semibold" x-text="cfg.llmProvider === 'ollama' ? 'Ollama not reachable' : 'OpenRouter error'"></p> <p class="text-[10px] text-red-400 mt-0.5" x-text="cfg.llmProvider === 'ollama' ? ollamaFetchError : openrouterFetchError"></p> </div> </div> <!-- Ollama idle (no models yet) --> <div x-show="cfg.llmProvider === 'ollama' && !ollamaModelsLoading && !ollamaFetchError && ollamaModels.length === 0" class="px-3 py-2.5 border border-dashed border-neutral-200 rounded-[10px] text-center"> <p class="text-[11px] text-neutral-400">No models β€” make sure Ollama is running</p> <button @click="fetchOllamaModels()" class="mt-1 text-[11px] font-semibold text-indigo-500 hover:text-indigo-700 transition-colors duration-150"> Try again </button> </div> <!-- OpenRouter idle (no models yet) --> <div x-show="cfg.llmProvider === 'openrouter' && !openrouterModelsLoading && !openrouterFetchError && openrouterModels.length === 0" class="px-3 py-2.5 border border-dashed border-neutral-200 rounded-[10px] text-center"> <p class="text-[11px] text-neutral-400">Enter your API key then fetch models</p> <button @click="fetchOpenRouterModels()" class="mt-1 text-[11px] font-semibold text-violet-500 hover:text-violet-700 transition-colors duration-150"> Fetch models </button> </div> <!-- OpenAI: plain text input --> <input x-show="cfg.llmProvider === 'openai'" x-model="cfg.llmModelName" type="text" placeholder="gpt-4o-mini" class="w-full text-xs border border-neutral-200 rounded-[10px] px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:border-transparent transition-shadow duration-150"> </div> <!-- API Key --> <div x-show="cfg.llmProvider !== 'ollama' || cfg.llmApiKey"> <label class="text-[11px] font-semibold text-neutral-500 block mb-1">API Key</label> <input x-model="cfg.llmApiKey" type="password" :placeholder="cfg.llmProvider === 'openrouter' ? 'sk-or-…' : 'sk-…'" @change="cfg.llmProvider === 'openrouter' && fetchOpenRouterModels()" class="w-full text-xs border border-neutral-200 rounded-[10px] px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:border-transparent transition-shadow duration-150 font-mono tracking-widest"> <p class="mt-1.5 text-[10px] text-neutral-400" x-show="cfg.llmProvider === 'openai'">Sent only to your configured endpoint.</p> <p class="mt-1.5 text-[10px] text-violet-400" x-show="cfg.llmProvider === 'openrouter'"> Get a free key at <span class="font-semibold">openrouter.ai/keys</span>. Free models work without a key. </p> </div> <button x-show="cfg.llmProvider === 'ollama' && !cfg.llmApiKey" @click="cfg.llmApiKey = ' '; $nextTick(() => cfg.llmApiKey = '')" class="text-[11px] text-neutral-400 hover:text-neutral-600 transition-colors duration-150"> + Add auth token (optional) </button> </div> <!-- Presets --> <div> <p class="text-[10px] font-bold text-neutral-400 uppercase tracking-widest mb-2">Quick Presets</p> <div class="space-y-1.5"> <template x-for="p in llmPresets" :key="p.label"> <button @click="applyPreset(p)" class="group w-full flex items-center justify-between px-3 py-2.5 text-left bg-neutral-50 rounded-lg transition-all duration-150 hover:bg-indigo-50 active:scale-[.98]"> <span class="text-xs font-semibold text-neutral-700 group-hover:text-indigo-700 transition-colors duration-150" x-text="p.label"></span> <span class="text-[11px] text-neutral-400 group-hover:text-indigo-400 transition-colors duration-150 font-mono" x-text="p.model"></span> </button> </template> </div> </div> </div> <!-- ── Intents tab ───────────────────────────────────────────────── --> <div x-show="activeTab === 'intents'" class="p-4 fade-in"> <div class="flex items-center justify-between mb-3"> <p class="text-[10px] font-bold text-neutral-400 uppercase tracking-widest"> Intents (<span x-text="intents.length"></span>) </p> <button @click="addIntent()" class="flex items-center gap-1 text-xs font-semibold text-indigo-600 hover:text-indigo-800 transition-colors duration-150"> <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/> </svg> Add intent </button> </div> <div class="space-y-2"> <template x-for="(intent, idx) in intents" :key="idx"> <div class="border border-neutral-100 bg-neutral-50 rounded-xl p-3 space-y-2"> <div class="flex items-center gap-2"> <input x-model="intent.name" @input="onCfgChange()" type="text" placeholder="intent_name" class="flex-1 text-xs font-mono bg-white border border-neutral-200 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-indigo-400 transition-shadow duration-150"> <button @click="removeIntent(idx)" class="text-neutral-300 hover:text-red-400 transition-colors duration-150 flex-shrink-0"> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/> </svg> </button> </div> <textarea x-model="intent.description" @input="onCfgChange()" placeholder="Describe what this intent means…" rows="2" class="w-full text-xs bg-white border border-neutral-200 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-indigo-400 transition-shadow duration-150"></textarea> </div> </template> </div> <button @click="resetIntents()" class="mt-4 w-full text-[11px] text-neutral-400 hover:text-neutral-600 py-2 border border-dashed border-neutral-200 hover:border-neutral-400 rounded-xl transition-all duration-150"> Reset to banking defaults </button> </div> <!-- ── Hierarchy tab ─────────────────────────────────────────────── --> <div x-show="activeTab === 'hierarchy'" class="p-4 fade-in"> <div class="flex items-center justify-between mb-3"> <p class="text-[10px] font-bold text-neutral-400 uppercase tracking-widest"> Categories (<span x-text="hierarchyList.length"></span>) </p> <button @click="addCategory()" class="flex items-center gap-1 text-xs font-semibold text-indigo-600 hover:text-indigo-800 transition-colors duration-150"> <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/> </svg> Add category </button> </div> <div class="space-y-2"> <template x-for="(cat, idx) in hierarchyList" :key="cat.name + idx"> <div class="border border-neutral-100 bg-neutral-50 rounded-xl p-3 space-y-2"> <div class="flex items-center gap-2"> <input :value="cat.name" @change="renameCategory(cat.name, $event.target.value)" type="text" class="flex-1 text-xs font-mono font-semibold bg-white border border-neutral-200 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-indigo-400 transition-shadow duration-150"> <button @click="removeCategory(cat.name)" class="text-neutral-300 hover:text-red-400 transition-colors duration-150 flex-shrink-0"> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/> </svg> </button> </div> <textarea :value="cat.description" @input="updateCategoryDesc(cat.name, $event.target.value)" placeholder="Category description for embedding…" rows="2" class="w-full text-xs bg-white border border-neutral-200 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-indigo-400 transition-shadow duration-150"></textarea> <div> <p class="text-[10px] font-medium text-neutral-400 mb-1.5">Intents in this category</p> <div class="flex flex-wrap gap-x-3 gap-y-1.5"> <template x-for="intent in intents" :key="intent.name"> <label class="flex items-center gap-1.5 cursor-pointer select-none"> <input type="checkbox" :checked="(cat.intents || []).includes(intent.name)" @change="toggleIntentInCategory(cat.name, intent.name, $event.target.checked)" class="w-3 h-3 rounded accent-indigo-600 border-neutral-300 focus:ring-indigo-400 cursor-pointer"> <span class="text-[11px] font-mono text-neutral-600" x-text="intent.name"></span> </label> </template> </div> </div> </div> </template> </div> </div> <!-- ── Examples tab ──────────────────────────────────────────────── --> <div x-show="activeTab === 'examples'" class="p-4 fade-in"> <div class="flex items-center justify-between mb-1"> <p class="text-[10px] font-bold text-neutral-400 uppercase tracking-widest"> Seed Examples (<span x-text="examples.length"></span>) </p> <button @click="addExample()" class="flex items-center gap-1 text-xs font-semibold text-indigo-600 hover:text-indigo-800 transition-colors duration-150"> <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/> </svg> Add </button> </div> <p class="text-[11px] text-neutral-400 mb-3"> Used by ExampleStore for RAG retrieval. <span class="text-neutral-300 ml-1"> <span x-text="examples.filter(e => e.history_context).length"></span> multi-turn, <span x-text="examples.filter(e => !e.history_context).length"></span> single-turn. </span> </p> <div x-show="cfg.mode === 'flat'" class="mb-3 flex items-center gap-2 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-[11px] text-amber-700"> <svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/> </svg> Examples are only used in Flat+RAG and Hier+RAG modes. </div> <div class="space-y-2"> <template x-for="(ex, idx) in examples" :key="idx"> <div class="border border-neutral-100 bg-neutral-50 rounded-xl p-3 space-y-2"> <div class="flex items-center gap-2"> <select x-model="ex.intent" @change="onCfgChange()" class="flex-1 text-[11px] font-mono bg-white border border-neutral-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-indigo-400 transition-shadow duration-150"> <template x-for="intent in intents" :key="intent.name"> <option :value="intent.name" x-text="intent.name"></option> </template> </select> <button @click="removeExample(idx)" class="text-neutral-300 hover:text-red-400 transition-colors duration-150 flex-shrink-0"> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/> </svg> </button> </div> <input x-model="ex.text" @input="onCfgChange()" type="text" placeholder="User utterance…" class="w-full text-xs bg-white border border-neutral-200 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-indigo-400 transition-shadow duration-150"> <input x-model="ex.history_context" @input="onCfgChange()" type="text" placeholder="History context (optional β€” for multi-turn follow-ups)" class="w-full text-xs bg-white border border-neutral-200 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-indigo-400 transition-shadow duration-150 placeholder:italic placeholder:text-neutral-300"> </div> </template> </div> <button @click="resetExamples()" class="mt-4 w-full text-[11px] text-neutral-400 hover:text-neutral-600 py-2 border border-dashed border-neutral-200 hover:border-neutral-400 rounded-xl transition-all duration-150"> Reset to banking defaults </button> </div> </div><!-- end tab content --> <!-- Section 4: Sidebar footer --> <div class="flex-shrink-0 border-t border-neutral-100 bg-neutral-50 px-4 py-3 space-y-2"> <!-- Session badge --> <div class="flex items-center justify-between"> <div class="flex items-center gap-1.5"> <div class="w-1.5 h-1.5 rounded-full" :class="sessionId ? 'bg-emerald-400' : 'bg-neutral-300'"></div> <span class="text-[10px] text-neutral-400 font-mono" x-text="sessionId ? 'Session ' + sessionId.slice(0,8) + '…' : 'No session'"></span> </div> <span class="text-[10px] text-neutral-300">auto-clears in 1 hr</span> </div> <!-- Restoring notice --> <div x-show="sessionRestoring" class="text-[10px] text-indigo-500 flex items-center gap-1.5"> <svg class="w-3 h-3 spin flex-shrink-0" fill="none" viewBox="0 0 24 24"> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/> </svg> Restoring conversation… </div> <button @click="clearChat()" class="w-full text-center text-[11px] font-semibold text-neutral-400 hover:text-red-500 transition-colors duration-150 py-1"> New conversation </button> </div> </aside> <!-- ═══════════════════════════════════════════════════════ CHAT PANEL ═══ --> <main class="flex-1 flex flex-col min-w-0 bg-slate-50"> <!-- Message feed --> <div id="msgContainer" class="flex-1 overflow-y-auto scroll-thin px-6 py-6 space-y-3"> <!-- Empty state --> <div x-show="messages.length === 0" class="h-full flex flex-col items-center justify-center text-center select-none fade-in"> <div class="w-16 h-16 rounded-2xl bg-white border border-neutral-200 shadow-[0_1px_4px_rgba(0,0,0,0.06)] flex items-center justify-center"> <svg class="w-8 h-8 text-neutral-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <path stroke-linecap="round" stroke-linejoin="round" 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> <p class="mt-5 text-sm font-semibold text-neutral-500">Start a conversation</p> <p class="mt-1 text-xs text-neutral-400 max-w-[280px] leading-relaxed"> Type any query or pick a suggestion below. The pipeline on the left controls how intent is detected. </p> <!-- Pipeline summary in empty state --> <div class="mt-4 flex items-center gap-1.5 text-[10px]"> <span class="px-2 py-1 rounded font-semibold" :class="{ 'bg-blue-50 text-blue-600': cfg.mode==='flat', 'bg-violet-50 text-violet-600': cfg.mode==='flat_rag', 'bg-emerald-50 text-emerald-600': cfg.mode==='hierarchical_rag', }" x-text="modeLabelFull(cfg.mode)"></span> <span class="text-neutral-300">Β·</span> <span class="px-2 py-1 rounded font-semibold" :class="cfg.turnMode === 'multi' ? 'bg-amber-50 text-amber-600' : 'bg-neutral-100 text-neutral-400'" x-text="cfg.turnMode === 'multi' ? 'Multi-Turn' : 'Single-Turn'"></span> <span class="text-neutral-300">Β·</span> <span class="text-neutral-400 font-mono" x-text="intents.length + ' intents'"></span> </div> <!-- Quick query chips --> <div class="mt-6 flex flex-wrap gap-2 justify-center max-w-lg"> <template x-for="q in quickQueries" :key="q"> <button @click="sendQuery(q)" class="text-xs bg-white border border-neutral-200 rounded-full px-3 py-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] transition-all duration-150 hover:border-indigo-300 hover:text-indigo-600 hover:shadow-[0_0_0_3px_rgba(99,102,241,0.10)]" x-text="q"></button> </template> </div> </div> <!-- Messages --> <template x-for="(msg, idx) in messages" :key="idx"> <div> <!-- User bubble --> <div x-show="msg.role === 'user'" class="flex justify-end bubble-enter"> <div class="max-w-sm bg-indigo-600 text-white text-sm px-4 py-2.5 leading-relaxed shadow-[0_1px_3px_rgba(99,102,241,0.25)]" style="border-radius: 16px 4px 16px 16px" x-text="msg.content"></div> </div> <!-- AI result card --> <div x-show="msg.role === 'assistant'" class="flex justify-start card-enter"> <div class="w-full max-w-2xl bg-white border border-neutral-100 overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06)]" style="border-radius: 4px 16px 16px 16px" :style="`border-left: 4px solid ${confColor(msg.confidence)}`"> <!-- Card header: intent + confidence --> <div class="flex items-center justify-between px-4 py-3 border-b border-neutral-50"> <div class="flex items-center gap-2 min-w-0"> <div class="w-2 h-2 rounded-full flex-shrink-0" :style="`background:${confColor(msg.confidence)}`"></div> <span class="text-sm font-bold font-mono text-neutral-900 truncate" x-text="msg.intent || 'β€”'"></span> </div> <div class="flex items-center gap-3 flex-shrink-0 ml-3"> <!-- Category tags --> <template x-for="cat in (msg.categories || [])" :key="cat"> <span class="text-[10px] font-mono font-semibold px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200" x-text="cat"></span> </template> <!-- Confidence bar + % --> <div class="flex items-center gap-2"> <div class="w-24 h-1.5 bg-neutral-100 rounded-full overflow-hidden"> <div class="conf-fill h-full rounded-full" :style="`width:${Math.round((msg.confidence||0)*100)}%; background:${confColor(msg.confidence)}`"></div> </div> <span class="text-xs font-bold tabular-nums" :style="`color:${confTextColor(msg.confidence)}`" x-text="Math.round((msg.confidence||0)*100) + '%'"></span> </div> </div> </div> <!-- RAG examples (collapsible) --> <div x-show="msg.ragExamples && msg.ragExamples.length > 0" class="px-4 py-2.5 border-b border-neutral-50 bg-neutral-50/40"> <button @click="msg.showRag = !msg.showRag" class="flex items-center gap-1.5 text-[11px] text-neutral-400 hover:text-neutral-600 transition-colors duration-150 w-full text-left"> <svg class="w-3 h-3 flex-shrink-0 transition-transform duration-200" :class="msg.showRag ? 'rotate-90' : ''" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/> </svg> <span x-text="`${(msg.ragExamples||[]).length} RAG examples retrieved`"></span> </button> <div x-show="msg.showRag" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 -translate-y-1" x-transition:enter-end="opacity-100 translate-y-0" class="mt-2 space-y-1.5"> <template x-for="(ex, i) in (msg.ragExamples||[])" :key="i"> <div class="flex items-center gap-2 text-[11px]"> <span class="font-mono px-1.5 py-0.5 bg-neutral-100 text-neutral-600 rounded flex-shrink-0" x-text="ex.intent"></span> <span class="text-neutral-500 flex-1 min-w-0 truncate italic" x-text="`"${ex.text}"`"></span> <span class="text-neutral-300 flex-shrink-0 tabular-nums" x-text="`${(ex.score*100).toFixed(0)}%`"></span> </div> </template> </div> </div> <!-- Card footer: mode badges + timestamp --> <div class="flex items-center justify-between px-4 py-2"> <div class="flex items-center gap-1.5"> <span class="text-[10px] font-bold px-1.5 py-0.5 rounded" :class="{ 'bg-blue-50 text-blue-600': msg.mode==='flat', 'bg-violet-50 text-violet-600': msg.mode==='flat_rag', 'bg-emerald-50 text-emerald-600':msg.mode==='hierarchical_rag', }" x-text="modeLabelFull(msg.mode)"></span> <span class="text-[10px] font-bold px-1.5 py-0.5 rounded" :class="msg.turnMode==='multi' ? 'bg-amber-50 text-amber-600' : 'bg-neutral-50 text-neutral-400'" x-text="(msg.turnMode||'').toUpperCase()"></span> </div> <span class="text-[10px] text-neutral-300" x-text="msg.time"></span> </div> </div> </div> <!-- Error card --> <div x-show="msg.role === 'error'" class="flex justify-start fade-in"> <div class="max-w-2xl w-full bg-red-50 border border-red-100 flex items-start gap-2 px-4 py-3" style="border-radius: 4px 16px 16px 16px"> <svg class="w-3.5 h-3.5 text-red-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> </svg> <span class="text-xs text-red-600" x-text="msg.content"></span> </div> </div> </div> </template> <!-- Loading card --> <div x-show="loading" class="flex justify-start fade-in"> <div class="bg-white border border-neutral-100 px-4 py-3 flex items-center gap-2.5 shadow-[0_2px_8px_rgba(0,0,0,0.06)]" style="border-radius: 4px 16px 16px 16px"> <div class="flex gap-1"> <div class="w-2 h-2 bg-indigo-400 rounded-full dot-1"></div> <div class="w-2 h-2 bg-indigo-400 rounded-full dot-2"></div> <div class="w-2 h-2 bg-indigo-400 rounded-full dot-3"></div> </div> <span class="text-xs text-neutral-400">Classifying…</span> </div> </div> </div><!-- end message feed --> <!-- Pipeline visualizer --> <div class="flex-shrink-0 bg-white border-t border-neutral-100 px-6 flex items-center gap-2" style="height:52px"> <span class="text-[10px] text-neutral-300 font-semibold uppercase tracking-wider mr-1">Pipeline</span> <template x-for="(node, idx) in pipelineNodes" :key="node.id"> <div class="flex items-center gap-2"> <span class="text-[10px] font-semibold px-2 py-1 rounded-md transition-all duration-200" :class="[ node.active ? 'bg-indigo-50 text-indigo-700 border border-indigo-200' : 'bg-neutral-50 text-neutral-300 border border-neutral-100', loading && node.active ? 'pipeline-node-pulse' : '' ]" x-text="node.label"></span> <span x-show="idx < pipelineNodes.length - 1" class="text-[10px] text-neutral-300 select-none">β†’</span> </div> </template> </div> <!-- Input bar --> <div class="flex-shrink-0 bg-white border-t border-neutral-200 px-5 py-4"> <div class="flex gap-3"> <input x-model="input" @keydown.enter.prevent="send()" :disabled="loading" type="text" placeholder="Type a query and press Enter…" class="flex-1 text-sm border border-neutral-200 rounded-xl px-4 py-2.5 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed transition-shadow duration-150"> <button @click="send()" :disabled="loading || !input.trim()" class="send-btn w-11 h-11 bg-indigo-600 rounded-xl flex items-center justify-center hover:bg-indigo-700 disabled:opacity-40 disabled:cursor-not-allowed transition-all duration-150 flex-shrink-0"> <svg x-show="!loading" class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/> </svg> <svg x-show="loading" class="w-4 h-4 text-white spin" fill="none" viewBox="0 0 24 24"> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/> </svg> </button> </div> <div class="mt-2 flex items-center justify-center gap-3 text-[10px] text-neutral-300"> <span>Press <kbd class="px-1.5 py-0.5 bg-neutral-100 rounded text-[10px] font-mono text-neutral-400">Enter</kbd> to send</span> <span x-show="cfg.turnMode === 'multi'" class="flex items-center gap-1.5 text-amber-400"> <span class="w-1.5 h-1.5 rounded-full bg-amber-400 flex-shrink-0"></span> Multi-turn: conversation history is maintained </span> </div> </div> </main> </div> <!-- ════════════════════════════════════════════════════════════ SCRIPT ═════ --> <script> /* ── Defaults ─────────────────────────────────────────────────────────────── */ const DEFAULT_INTENTS = [ { name: 'check_balance', description: 'User wants to check their account balance.' }, { name: 'bank_statement', description: 'User wants to download or view a bank statement.' }, { name: 'transfer_money', description: 'User wants to transfer money to another account.' }, { name: 'block_card', description: 'User wants to block a lost or stolen card.' }, { name: 'apply_loan', description: 'User wants to apply for a loan.' }, { name: 'contact_support', description: 'User wants to speak to a human support agent.' }, ]; const DEFAULT_HIERARCHY = { accounts: { description: 'Managing bank accounts: balance and statements.', intents: ['check_balance', 'bank_statement'] }, transactions: { description: 'Money movement: transfers and payments.', intents: ['transfer_money'] }, cards: { description: 'Card management: block or unblock a card.', intents: ['block_card'] }, loans: { description: 'Borrowing: loan applications and repayments.', intents: ['apply_loan'] }, support: { description: 'Customer support and human agent escalation.', intents: ['contact_support'] }, }; const DEFAULT_EXAMPLES = [ { text: 'what is my account balance', intent: 'check_balance', history_context: '' }, { text: 'how much money do I have', intent: 'check_balance', history_context: '' }, { text: 'I need my bank statement', intent: 'bank_statement', history_context: '' }, { text: 'download statement last month', intent: 'bank_statement', history_context: '' }, { text: 'transfer money to my savings', intent: 'transfer_money', history_context: '' }, { text: 'send 500 dollars to john', intent: 'transfer_money', history_context: '' }, { text: 'block my lost card', intent: 'block_card', history_context: '' }, { text: 'freeze my credit card', intent: 'block_card', history_context: '' }, { text: 'apply for a personal loan', intent: 'apply_loan', history_context: '' }, { text: 'speak to customer support', intent: 'contact_support', history_context: '' }, { text: 'for last 6 months', intent: 'bank_statement', history_context: 'I need my bank statement' }, { text: 'yes block it now', intent: 'block_card', history_context: 'my card was stolen' }, { text: 'the one ending in 4521', intent: 'block_card', history_context: 'I want to block a card' }, ]; const CONF_COLORS = { high: { border: '#4ADE80', text: '#16A34A' }, // green-400 / green-600 medium: { border: '#FBBF24', text: '#D97706' }, // amber-400 / amber-600 low: { border: '#F87171', text: '#DC2626' }, // red-400 / red-600 }; function clone(obj) { return JSON.parse(JSON.stringify(obj)); } /* ── Alpine App ───────────────────────────────────────────────────────────── */ function app() { return { /* ── Config ─────────────────────────────────────────────────────────── */ cfg: { llmProvider: 'openai', llmBaseUrl: 'https://api.openai.com', llmModelName: 'gpt-4o-mini', llmApiKey: '', mode: 'flat', turnMode: 'single', }, intents: clone(DEFAULT_INTENTS), hierarchy: clone(DEFAULT_HIERARCHY), examples: clone(DEFAULT_EXAMPLES), /* ── UI state ────────────────────────────────────────────────────────── */ messages: [], input: '', loading: false, activeTab: 'llm', cfgDirty: false, ollamaModels: [], ollamaModelsLoading: false, ollamaFetchError: '', openrouterModels: [], openrouterModelsLoading: false, openrouterFetchError: '', openrouterFreeCount: 0, sessionId: '', sessionRestoring: false, /* ── Static ──────────────────────────────────────────────────────────── */ modes: [ { value: 'flat', label: 'Flat' }, { value: 'flat_rag', label: 'Flat+RAG' }, { value: 'hierarchical_rag', label: 'Hier+RAG' }, ], llmPresets: [ { label: 'OpenAI', model: 'gpt-4o-mini', baseUrl: 'https://api.openai.com', provider: 'openai' }, { label: 'Groq', model: 'llama3-8b-8192', baseUrl: 'https://api.groq.com/openai', provider: 'openai' }, { label: 'Together', model: 'meta-llama/Llama-3-8b-chat-hf', baseUrl: 'https://api.together.xyz', provider: 'openai' }, { label: 'OpenRouter', model: 'openai/gpt-4o-mini', baseUrl: 'https://openrouter.ai/api', provider: 'openrouter' }, { label: 'OR Free Llama', model: 'meta-llama/llama-3.1-8b-instruct:free', baseUrl: 'https://openrouter.ai/api', provider: 'openrouter' }, { label: 'Ollama', model: 'llama3', baseUrl: 'http://localhost:11434', provider: 'ollama' }, ], quickQueries: [ 'how much money do I have?', 'I need my bank statement', 'block my stolen card', 'apply for a home loan', 'connect me to a human agent', ], /* ── Computed ────────────────────────────────────────────────────────── */ get hierarchyList() { return Object.entries(this.hierarchy).map(([name, data]) => ({ name, ...data })); }, get computedTabs() { return [ { id: 'llm', label: 'LLM', locked: false, lockedMsg: '', count: null }, { id: 'intents', label: 'Intents', locked: false, lockedMsg: '', count: this.intents.length }, { id: 'hierarchy', label: 'Hierarchy', locked: this.cfg.mode !== 'hierarchical_rag', lockedMsg: 'Only used in Hierarchical+RAG mode', count: Object.keys(this.hierarchy).length, }, { id: 'examples', label: 'Examples', locked: false, lockedMsg: '', count: this.examples.length }, ]; }, get pipelineNodes() { const m = this.cfg.mode; return [ { id: 'embed', label: 'Embed', active: true }, { id: 'router', label: 'Router', active: true }, { id: 'hier', label: 'Hier', active: m === 'hierarchical_rag' }, { id: 'rag', label: 'Store', active: m !== 'flat' }, { id: 'llm', label: 'LLM', active: true }, { id: 'intent', label: 'Intent', active: true }, ]; }, /* ── Helpers ─────────────────────────────────────────────────────────── */ modeLabelFull(mode) { return { flat: 'FLAT', flat_rag: 'FLAT+RAG', hierarchical_rag: 'HIER+RAG' }[mode] || mode; }, confColor(c) { const level = c > .75 ? 'high' : c > .50 ? 'medium' : 'low'; return CONF_COLORS[level].border; }, confTextColor(c) { const level = c > .75 ? 'high' : c > .50 ? 'medium' : 'low'; return CONF_COLORS[level].text; }, onCfgChange() { this.cfgDirty = true; // Auto-unlock tab: if user was on hierarchy and switches mode away, go back to LLM if (this.cfg.mode !== 'hierarchical_rag' && this.activeTab === 'hierarchy') { this.activeTab = 'llm'; } try { localStorage.setItem('reic_cfg', JSON.stringify(this.cfg)); } catch (_) {} }, applyPreset(p) { this.cfg.llmProvider = p.provider; this.cfg.llmBaseUrl = p.baseUrl; this.cfg.llmModelName = p.model; this.ollamaModels = []; this.ollamaFetchError = ''; this.openrouterModels = []; this.openrouterFetchError = ''; if (p.provider === 'ollama') this.fetchOllamaModels(); if (p.provider === 'openrouter') this.fetchOpenRouterModels(); }, async fetchOpenRouterModels() { this.openrouterModelsLoading = true; this.openrouterFetchError = ''; this.openrouterModels = []; try { const key = encodeURIComponent(this.cfg.llmApiKey || ''); const r = await fetch(`/api/openrouter/models?api_key=${key}`); if (!r.ok) { const err = await r.json().catch(() => ({ detail: `HTTP ${r.status}` })); throw new Error(err.detail || `HTTP ${r.status}`); } const data = await r.json(); this.openrouterModels = data.models || []; this.openrouterFreeCount = data.free_count || 0; // Auto-select first if current isn't in list if (this.openrouterModels.length > 0 && !this.openrouterModels.includes(this.cfg.llmModelName)) { this.cfg.llmModelName = this.openrouterModels[0]; } } catch (err) { this.openrouterFetchError = err.message; } finally { this.openrouterModelsLoading = false; } }, async fetchOllamaModels() { this.ollamaModelsLoading = true; this.ollamaFetchError = ''; this.ollamaModels = []; try { const url = `/api/ollama/models?base_url=${encodeURIComponent(this.cfg.llmBaseUrl)}`; const r = await fetch(url); if (!r.ok) { const err = await r.json().catch(() => ({ detail: `HTTP ${r.status}` })); throw new Error(err.detail || `HTTP ${r.status}`); } const data = await r.json(); this.ollamaModels = data.models || []; // Auto-select first model if current isn't in the list if (this.ollamaModels.length > 0 && !this.ollamaModels.includes(this.cfg.llmModelName)) { this.cfg.llmModelName = this.ollamaModels[0]; } } catch (err) { this.ollamaFetchError = err.message; } finally { this.ollamaModelsLoading = false; } }, async clearChat() { this.messages = []; // Start a brand-new session so history is clean server-side too if (this.sessionId) { try { await fetch(`/api/sessions/${this.sessionId}`, { method: 'DELETE' }); } catch (_) {} } await this._createSession(); }, scrollToBottom() { this.$nextTick(() => { const c = document.getElementById('msgContainer'); if (c) c.scrollTop = c.scrollHeight; }); }, /* ── Init ────────────────────────────────────────────────────────────── */ async init() { // Restore saved LLM config try { const saved = JSON.parse(localStorage.getItem('reic_cfg') || '{}'); Object.assign(this.cfg, saved); } catch (_) {} // Restore or create session const storedSid = localStorage.getItem('reic_session_id'); if (storedSid) { this.sessionRestoring = true; try { const r = await fetch(`/api/sessions/${storedSid}`); if (r.ok) { const data = await r.json(); this.sessionId = storedSid; // Rebuild messages from stored turns for (const turn of data.turns) { if (turn.role === 'user') { this.messages.push({ role: 'user', content: turn.content }); } else if (turn.role === 'assistant') { this.messages.push({ role: 'assistant', intent: turn.intent_classified || turn.content, confidence: turn.confidence || 0, ragExamples: turn.rag_examples || [], categories: turn.matched_categories || [], mode: turn.mode || 'flat', turnMode: turn.turn_mode || 'single', showRag: false, time: '(restored)', }); } } this.scrollToBottom(); } else { // Expired or not found β€” create fresh await this._createSession(); } } catch (_) { await this._createSession(); } finally { this.sessionRestoring = false; } } else { await this._createSession(); } }, async _createSession() { try { const r = await fetch('/api/sessions', { method: 'POST' }); const d = await r.json(); this.sessionId = d.session_id; localStorage.setItem('reic_session_id', this.sessionId); } catch (_) {} }, /* ── Intents CRUD ────────────────────────────────────────────────────── */ addIntent() { this.intents.push({ name: 'new_intent', description: '' }); this.onCfgChange(); }, removeIntent(idx) { const name = this.intents[idx]?.name; this.intents.splice(idx, 1); if (name) { for (const cat of Object.values(this.hierarchy)) cat.intents = (cat.intents || []).filter(n => n !== name); this.examples = this.examples.filter(e => e.intent !== name); } this.onCfgChange(); }, resetIntents() { this.intents = clone(DEFAULT_INTENTS); this.onCfgChange(); }, /* ── Hierarchy CRUD ──────────────────────────────────────────────────── */ addCategory() { const key = 'new_category'; let finalKey = key, n = 1; while (this.hierarchy[finalKey]) finalKey = key + '_' + (n++); this.hierarchy = { ...this.hierarchy, [finalKey]: { description: '', intents: [] } }; this.onCfgChange(); }, removeCategory(name) { const h = { ...this.hierarchy }; delete h[name]; this.hierarchy = h; this.onCfgChange(); }, renameCategory(oldName, newName) { newName = (newName || '').trim(); if (!newName || newName === oldName) return; const h = {}; for (const [k, v] of Object.entries(this.hierarchy)) h[k === oldName ? newName : k] = v; this.hierarchy = h; this.onCfgChange(); }, updateCategoryDesc(name, desc) { if (this.hierarchy[name]) { this.hierarchy[name].description = desc; this.onCfgChange(); } }, toggleIntentInCategory(catName, intentName, checked) { const cat = this.hierarchy[catName]; if (!cat) return; const set = new Set(cat.intents || []); checked ? set.add(intentName) : set.delete(intentName); cat.intents = [...set]; this.onCfgChange(); }, /* ── Examples CRUD ───────────────────────────────────────────────────── */ addExample() { this.examples.push({ text: '', intent: this.intents[0]?.name || '', history_context: '' }); this.onCfgChange(); }, removeExample(idx) { this.examples.splice(idx, 1); this.onCfgChange(); }, resetExamples() { this.examples = clone(DEFAULT_EXAMPLES); this.onCfgChange(); }, /* ── Classify ────────────────────────────────────────────────────────── */ sendQuery(q) { this.input = q; this.send(); }, async send() { const text = this.input.trim(); if (!text || this.loading) return; this.input = ''; this.messages.push({ role: 'user', content: text }); this.scrollToBottom(); this.loading = true; const payload = { text, mode: this.cfg.mode, turn_mode: this.cfg.turnMode, intents: this.intents, hierarchy: this.cfg.mode === 'hierarchical_rag' ? this.hierarchy : null, examples: this.examples .filter(e => e.text.trim()) .map(e => ({ ...e, history_context: e.history_context || null })), session_id: this.sessionId || null, llm_base_url: this.cfg.llmBaseUrl, llm_model_name: this.cfg.llmModelName, llm_api_key: this.cfg.llmApiKey, llm_provider: this.cfg.llmProvider, }; try { const r = await fetch('/api/classify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!r.ok) { const err = await r.json().catch(() => ({ detail: `HTTP ${r.status}: ${r.statusText}` })); throw new Error(err.detail || `HTTP ${r.status}`); } const data = await r.json(); this.cfgDirty = false; // Keep session_id in sync (server may have issued a new one if ours expired) if (data.session_id && data.session_id !== this.sessionId) { this.sessionId = data.session_id; localStorage.setItem('reic_session_id', this.sessionId); } this.messages.push({ role: 'assistant', intent: data.intent, confidence: data.confidence, ragExamples: data.rag_examples || [], categories: data.matched_categories || [], mode: this.cfg.mode, turnMode: this.cfg.turnMode, showRag: false, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), }); } catch (err) { this.messages.push({ role: 'error', content: 'Classification failed: ' + err.message, }); } finally { this.loading = false; this.scrollToBottom(); } }, }; // end return } </script> </body> </html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Intent Classifier Β· REIC Demo</title>
<!-- Fonts -->
<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@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
<!-- Tailwind CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
mono: ['JetBrains Mono', 'ui-monospace', 'Menlo'],
},
},
},
};
</script>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
[x-cloak] { display: none !important; }
/* ── Scrollbar ───────────────────────────────────────────────────────── */
.scroll-thin::-webkit-scrollbar { width: 4px; height: 4px; }
.scroll-thin::-webkit-scrollbar-track { background: transparent; }
.scroll-thin::-webkit-scrollbar-thumb { background: #D1D5DB; border-radius: 4px; }
.scroll-thin::-webkit-scrollbar-thumb:hover { background: #9CA3AF; }
/* ── Entry animations ───────────────────────────────────────────────── */
@keyframes card-enter {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes bubble-enter {
from { opacity: 0; transform: translateX(8px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.card-enter { animation: card-enter 280ms cubic-bezier(0.16, 1, 0.3, 1) both; }
.bubble-enter { animation: bubble-enter 200ms cubic-bezier(0.16, 1, 0.3, 1) both; }
.fade-in { animation: fade-in 180ms ease both; }
/* ── Bounce loader ──────────────────────────────────────────────────── */
@keyframes bounce-dot {
0%, 80%, 100% { transform: translateY(0); opacity: 1; }
40% { transform: translateY(-7px); opacity: .7; }
}
.dot-1 { animation: bounce-dot 1.4s cubic-bezier(.4,0,.6,1) 0.00s infinite; }
.dot-2 { animation: bounce-dot 1.4s cubic-bezier(.4,0,.6,1) 0.16s infinite; }
.dot-3 { animation: bounce-dot 1.4s cubic-bezier(.4,0,.6,1) 0.32s infinite; }
/* ── Confidence bar fill ────────────────────────────────────────────── */
.conf-fill { transition: width 600ms cubic-bezier(0.16, 1, 0.3, 1); }
/* ── Spinner ────────────────────────────────────────────────────────── */
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin .7s linear infinite; }
/* ── Pipeline pulse while loading ──────────────────────────────────── */
@keyframes pipeline-pulse {
0%, 100% { opacity: 1; }
50% { opacity: .4; }
}
.pipeline-node-pulse { animation: pipeline-pulse 1.3s ease-in-out infinite; }
/* ── Misc ───────────────────────────────────────────────────────────── */
.chevron-open { transform: rotate(90deg); }
textarea { resize: none; }
::placeholder { font-style: normal; }
input[type=password] { letter-spacing: 0.12em; }
.send-btn:active:not(:disabled) { transform: scale(.95); }
</style>
</head>
<!-- ═══════════════════════════════════════════════════════════ ROOT DATA ═══ -->
<body class="bg-slate-50 text-neutral-900 font-sans antialiased overflow-hidden"
x-data="app()" x-cloak>
<!-- ══════════════════════════════════════════════════════════════ HEADER ═══ -->
<header class="fixed inset-x-0 top-0 z-30 h-14 bg-white border-b border-neutral-200
flex items-center px-5 gap-4">
<!-- Logo + title -->
<div class="flex items-center gap-3 flex-shrink-0">
<div class="w-7 h-7 bg-indigo-600 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">