Spaces:
Configuration error
Configuration error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Self-Service KB Assistant</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> | |
| <style> | |
| /* βββ Reset & tokens ββββββββββββββββββββββββββββββββββββββββββ */ | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #F5F0E8; | |
| --surface: #FDFAF5; | |
| --border: #E2D9CE; | |
| --border2: #C9BFB3; | |
| --ink: #1E1C1A; | |
| --ink2: #4A4540; | |
| --muted: #7A7167; | |
| --muted2: #A09890; | |
| --sage: #5C7E62; | |
| --sage-h: #4A6550; | |
| --sage-bg: #EAF2EB; | |
| --sage-bg2: #D0E6D3; | |
| --rose: #B5626A; | |
| --rose-bg: #F8EDEE; | |
| --r: 10px; | |
| --r2: 7px; | |
| --r3: 5px; | |
| --sh: 0 1px 4px rgba(0,0,0,.08); | |
| --sh2: 0 4px 16px rgba(0,0,0,.12); | |
| } | |
| html, body { | |
| height: 100%; overflow: hidden; | |
| background: var(--bg); | |
| font-family: "Inter", ui-sans-serif, system-ui, sans-serif; | |
| font-size: 14px; color: var(--ink); | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| /* βββ Layout ββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| #app { display: flex; flex-direction: column; height: 100vh; } | |
| /* Header */ | |
| header { | |
| height: 52px; flex-shrink: 0; | |
| background: var(--surface); border-bottom: 1px solid var(--border); | |
| display: flex; align-items: center; padding: 0 20px; gap: 12px; | |
| box-shadow: var(--sh); z-index: 10; | |
| } | |
| .h-logo { | |
| width: 32px; height: 32px; border-radius: 8px; | |
| background: var(--sage); display: flex; align-items: center; | |
| justify-content: center; font-size: 16px; flex-shrink: 0; | |
| box-shadow: 0 1px 5px rgba(92,126,98,.4); | |
| } | |
| .h-name { font-size: 14px; font-weight: 600; color: var(--ink); letter-spacing: -.01em; } | |
| .h-sub { font-size: 11px; color: var(--muted); margin-top: 1px; } | |
| .h-badge { | |
| margin-left: auto; font-size: 10.5px; font-weight: 500; | |
| color: var(--sage); background: var(--sage-bg); | |
| border: 1px solid var(--sage-bg2); border-radius: 999px; padding: 3px 10px; | |
| } | |
| /* Body grid */ | |
| #body { | |
| display: grid; | |
| grid-template-columns: 200px 1fr 224px; | |
| flex: 1; overflow: hidden; | |
| } | |
| /* βββ Left panel ββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| #left { | |
| border-right: 1px solid var(--border); | |
| background: var(--surface); | |
| display: flex; flex-direction: column; overflow: hidden; | |
| } | |
| .panel-hdr { | |
| padding: 13px 14px 10px; | |
| font-size: 10px; font-weight: 600; letter-spacing: .08em; | |
| text-transform: uppercase; color: var(--muted2); | |
| border-bottom: 1px solid var(--border); | |
| display: flex; align-items: center; gap: 6px; flex-shrink: 0; | |
| } | |
| .panel-hdr::before { | |
| content: ""; width: 5px; height: 5px; | |
| background: var(--sage); border-radius: 50%; flex-shrink: 0; | |
| } | |
| #suggestions { overflow-y: auto; padding: 10px 10px 16px; flex: 1; } | |
| .pill { | |
| width: 100%; display: flex; align-items: flex-start; gap: 8px; | |
| text-align: left; background: var(--surface); | |
| border: 1px solid var(--border); border-radius: var(--r); | |
| padding: 9px 11px; font-size: 12.5px; color: var(--ink2); | |
| cursor: pointer; margin-bottom: 7px; line-height: 1.45; | |
| transition: all .15s; opacity: 0; | |
| animation: fadeUp .3s ease forwards; | |
| } | |
| .pill::before { content: "β"; color: var(--sage); flex-shrink: 0; transition: transform .15s; } | |
| .pill:hover { background: var(--sage-bg); border-color: var(--sage); color: var(--sage-h); transform: translateX(2px); } | |
| .pill:hover::before { transform: translateX(2px); } | |
| @keyframes fadeUp { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } } | |
| /* βββ Centre: chat βββββββββββββββββββββββββββββββββββββββββββββ */ | |
| #centre { | |
| display: flex; flex-direction: column; overflow: hidden; | |
| background: var(--bg); | |
| } | |
| #messages { | |
| flex: 1; overflow-y: auto; padding: 20px 20px 12px; | |
| display: flex; flex-direction: column; gap: 14px; | |
| } | |
| /* Empty state */ | |
| #empty-state { | |
| flex: 1; display: flex; flex-direction: column; | |
| align-items: center; justify-content: center; | |
| gap: 10px; color: var(--muted); text-align: center; | |
| padding: 40px 20px; | |
| } | |
| .es-icon { font-size: 2.6rem; opacity: .35; } | |
| .es-title { font-size: 14px; font-weight: 500; color: var(--ink2); } | |
| .es-sub { font-size: 12px; line-height: 1.6; max-width: 260px; } | |
| /* Bubbles */ | |
| .msg { display: flex; flex-direction: column; animation: bubIn .2s ease; } | |
| @keyframes bubIn { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } } | |
| .msg.user { align-items: flex-end; } | |
| .msg.bot { align-items: flex-start; } | |
| .bubble { | |
| padding: 10px 14px; max-width: 74%; line-height: 1.65; font-size: 13.5px; | |
| } | |
| .msg.user .bubble { | |
| background: var(--sage); color: #fff; | |
| border-radius: var(--r) var(--r) 3px var(--r); | |
| box-shadow: 0 2px 8px rgba(92,126,98,.28); | |
| } | |
| .msg.bot .bubble { | |
| background: var(--surface); color: var(--ink); | |
| border: 1px solid var(--border); | |
| border-radius: var(--r) var(--r) var(--r) 3px; | |
| box-shadow: var(--sh); | |
| } | |
| /* Markdown-ish */ | |
| .bubble p { margin-bottom: .5em; } | |
| .bubble p:last-child { margin-bottom: 0; } | |
| .bubble ul, .bubble ol { padding-left: 1.3em; margin: .4em 0; } | |
| .bubble li { margin-bottom: .25em; } | |
| .bubble strong { font-weight: 600; } | |
| .bubble code { background: rgba(0,0,0,.07); border-radius: 3px; padding: 1px 5px; font-family: monospace; font-size: .92em; } | |
| /* Source chips */ | |
| .src-row { | |
| display: flex; align-items: center; flex-wrap: wrap; gap: 5px; | |
| margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--border); | |
| } | |
| .src-lbl { | |
| font-size: 9.5px; font-weight: 700; letter-spacing: .08em; | |
| text-transform: uppercase; color: var(--muted2); flex-shrink: 0; | |
| } | |
| .src-chip { | |
| display: inline-flex; align-items: center; gap: 4px; | |
| background: var(--sage-bg); border: 1px solid var(--sage-bg2); | |
| border-radius: 999px; padding: 2px 10px 2px 7px; | |
| font-size: 11px; font-weight: 500; color: var(--sage-h); | |
| animation: chipIn .2s ease; transition: background .13s; | |
| } | |
| .src-chip:hover { background: var(--sage-bg2); } | |
| @keyframes chipIn { from { opacity:0; transform:scale(.9) translateY(3px); } to { opacity:1; transform:scale(1) translateY(0); } } | |
| /* Typing indicator */ | |
| .typing { display: flex; gap: 5px; align-items: center; padding: 12px 14px; } | |
| .typing span { | |
| width: 7px; height: 7px; border-radius: 50%; background: var(--border2); | |
| animation: bounce .9s infinite ease; | |
| } | |
| .typing span:nth-child(2) { animation-delay: .15s; } | |
| .typing span:nth-child(3) { animation-delay: .30s; } | |
| @keyframes bounce { 0%,80%,100% { transform:translateY(0); } 40% { transform:translateY(-6px); } } | |
| /* βββ Input area βββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| #input-area { | |
| flex-shrink: 0; background: var(--surface); | |
| border-top: 1px solid var(--border); padding: 10px 14px 10px; | |
| } | |
| #input-card { | |
| border: 1.5px solid var(--border); border-radius: var(--r); | |
| background: var(--bg); overflow: hidden; | |
| transition: border-color .18s, box-shadow .18s; | |
| } | |
| #input-card:focus-within { | |
| border-color: var(--sage); | |
| box-shadow: 0 0 0 3px rgba(92,126,98,.12); | |
| } | |
| #input-row { display: flex; align-items: flex-end; } | |
| #msg-input { | |
| flex: 1; border: none; background: transparent; resize: none; | |
| padding: 11px 13px; font-size: 13.5px; font-family: inherit; | |
| color: var(--ink); line-height: 1.55; outline: none; | |
| min-height: 44px; max-height: 140px; | |
| } | |
| #msg-input::placeholder { color: var(--muted2); } | |
| #send-btn { | |
| margin: 0 7px 7px 0; align-self: flex-end; | |
| width: 34px; height: 34px; border-radius: var(--r2); border: none; | |
| background: var(--sage); color: #fff; cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; | |
| box-shadow: 0 1px 4px rgba(92,126,98,.3); | |
| transition: background .14s, transform .1s; flex-shrink: 0; | |
| } | |
| #send-btn:hover { background: var(--sage-h); transform: translateY(-1px); } | |
| #send-btn:disabled { opacity: .5; cursor: not-allowed; transform: none; } | |
| #send-btn svg { width: 16px; height: 16px; pointer-events: none; } | |
| /* Toolbar */ | |
| #toolbar { | |
| display: flex; align-items: center; gap: 1px; | |
| padding: 4px 9px 6px; background: var(--bg); | |
| border-top: 1px solid var(--border); | |
| } | |
| .tb { | |
| width: 26px; height: 26px; border: none; background: none; | |
| border-radius: var(--r3); cursor: pointer; color: var(--muted); | |
| display: flex; align-items: center; justify-content: center; | |
| transition: background .12s, color .12s; position: relative; | |
| } | |
| .tb svg { width: 13px; height: 13px; pointer-events: none; } | |
| .tb:hover { background: var(--sage-bg); color: var(--sage); } | |
| .tb-sep { width: 1px; height: 12px; background: var(--border2); margin: 0 3px; flex-shrink: 0; } | |
| .tb[title]::after { | |
| content: attr(title); | |
| position: absolute; bottom: calc(100% + 6px); left: 50%; | |
| transform: translateX(-50%); | |
| background: var(--ink); color: #fff; | |
| font-size: 10px; padding: 3px 7px; border-radius: 4px; | |
| white-space: nowrap; pointer-events: none; | |
| opacity: 0; transition: opacity .12s; z-index: 99; | |
| } | |
| .tb:hover[title]::after { opacity: 1; } | |
| #char-count { margin-left: auto; font-size: 10px; color: var(--muted2); } | |
| /* βββ Right panel: KB files ββββββββββββββββββββββββββββββββββββ */ | |
| #right { | |
| border-left: 1px solid var(--border); | |
| background: var(--surface); | |
| display: flex; flex-direction: column; overflow: hidden; | |
| } | |
| .rp-head { | |
| padding: 12px 14px 11px; border-bottom: 1px solid var(--border); | |
| flex-shrink: 0; display: flex; align-items: center; gap: 0; | |
| } | |
| .rp-title { | |
| font-size: 10px; font-weight: 600; letter-spacing: .08em; | |
| text-transform: uppercase; color: var(--muted2); | |
| display: flex; align-items: center; gap: 6px; | |
| } | |
| .rp-title::before { | |
| content: ""; width: 5px; height: 5px; | |
| background: var(--sage); border-radius: 50%; | |
| } | |
| #file-count { | |
| font-size: 10px; font-weight: 600; color: var(--sage); | |
| background: var(--sage-bg); border: 1px solid var(--sage-bg2); | |
| border-radius: 999px; padding: 1px 7px; margin-left: 7px; | |
| } | |
| /* Add file button */ | |
| .add-wrap { position: relative; margin-left: auto; } | |
| .add-btn { | |
| display: flex; align-items: center; gap: 4px; | |
| background: var(--sage-bg); border: 1px solid var(--sage-bg2); | |
| color: var(--sage-h); border-radius: var(--r2); | |
| padding: 5px 11px; font-size: 12px; font-weight: 600; | |
| cursor: pointer; transition: all .14s; | |
| } | |
| .add-btn:hover { background: var(--sage); color: #fff; border-color: var(--sage); box-shadow: 0 1px 6px rgba(92,126,98,.3); } | |
| .add-tip { | |
| display: none; position: absolute; top: calc(100% + 7px); right: 0; | |
| background: var(--ink); color: #f5f5f0; | |
| border-radius: var(--r2); padding: 11px 14px; | |
| font-size: 11.5px; line-height: 1.7; min-width: 210px; | |
| box-shadow: var(--sh2); z-index: 60; | |
| } | |
| .add-tip strong { display: block; margin-bottom: 5px; color: #fff; font-size: 12px; } | |
| .add-tip i { display: flex; align-items: center; gap: 6px; opacity: .85; font-style: normal; } | |
| .add-tip i::before { content: "Β·"; color: #9ecfa4; } | |
| .add-tip::before { | |
| content: ""; position: absolute; top: -5px; right: 13px; | |
| border: 5px solid transparent; border-top: 0; | |
| border-bottom-color: var(--ink); | |
| } | |
| .add-wrap:hover .add-tip { display: block; } | |
| #file-input { display: none; } | |
| /* File list */ | |
| #file-list { overflow-y: auto; padding: 4px 8px 16px; flex: 1; } | |
| #file-count-line { font-size: 10.5px; color: var(--muted2); padding: 8px 6px 4px; } | |
| .f-row { | |
| display: flex; align-items: center; gap: 7px; | |
| padding: 7px 8px; border-radius: var(--r2); | |
| transition: background .12s; position: relative; | |
| } | |
| .f-row:hover { background: var(--bg); } | |
| .f-ico { font-size: 13px; opacity: .75; flex-shrink: 0; } | |
| .f-name { | |
| flex: 1; font-size: 12px; color: var(--ink2); | |
| overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| } | |
| .f-actions { | |
| display: none; gap: 1px; align-items: center; flex-shrink: 0; | |
| } | |
| .f-row:hover .f-actions { display: flex; } | |
| .f-btn { | |
| background: none; border: none; cursor: pointer; | |
| font-size: 12px; padding: 3px 5px; border-radius: 4px; | |
| color: var(--muted); transition: background .11s, color .11s; | |
| } | |
| .f-btn:hover { background: var(--border); color: var(--ink); } | |
| .f-btn.del:hover { background: var(--rose-bg); color: var(--rose); } | |
| /* Rename row */ | |
| .f-rename { | |
| display: none; padding: 5px 8px; gap: 5px; align-items: center; | |
| } | |
| .f-rename.on { display: flex; } | |
| .f-rename input { | |
| flex: 1; border: 1.5px solid var(--sage); border-radius: var(--r3); | |
| padding: 4px 8px; font-size: 11px; background: var(--bg); | |
| color: var(--ink); outline: none; font-family: inherit; | |
| } | |
| .f-rename .r-save { | |
| background: var(--sage); color: #fff; border: none; | |
| border-radius: var(--r3); padding: 4px 9px; | |
| font-size: 11px; font-weight: 600; cursor: pointer; | |
| } | |
| .f-rename .r-cancel { | |
| background: none; border: 1px solid var(--border); color: var(--muted); | |
| border-radius: var(--r3); padding: 4px 7px; font-size: 11px; cursor: pointer; | |
| } | |
| /* βββ Toast βββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| #toast { | |
| position: fixed; bottom: 20px; left: 50%; | |
| transform: translateX(-50%) translateY(8px); | |
| background: var(--ink); color: #fff; | |
| border-radius: var(--r2); padding: 8px 18px; | |
| font-size: 12px; box-shadow: var(--sh2); | |
| opacity: 0; pointer-events: none; | |
| transition: opacity .2s, transform .2s; z-index: 999; white-space: nowrap; | |
| } | |
| #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } | |
| /* βββ Upload progress overlay βββββββββββββββββββββββββββββββββ */ | |
| #upload-overlay { | |
| display: none; position: fixed; inset: 0; | |
| background: rgba(30,28,26,.45); z-index: 998; | |
| align-items: center; justify-content: center; | |
| } | |
| #upload-overlay.show { display: flex; } | |
| .upload-card { | |
| background: var(--surface); border-radius: var(--r); | |
| padding: 28px 32px; text-align: center; box-shadow: var(--sh2); | |
| display: flex; flex-direction: column; align-items: center; gap: 10px; | |
| } | |
| .upload-card .spinner { | |
| width: 30px; height: 30px; border: 3px solid var(--border); | |
| border-top-color: var(--sage); border-radius: 50%; | |
| animation: spin .7s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .upload-card p { font-size: 13px; color: var(--muted); } | |
| /* βββ Scrollbar βββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| * { scrollbar-width: thin; scrollbar-color: var(--border2) transparent; } | |
| ::-webkit-scrollbar { width: 4px; } | |
| ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 99px; } | |
| ::selection { background: rgba(92,126,98,.2); } | |
| /* βββ Mobile ββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| @media (max-width: 860px) { | |
| #body { grid-template-columns: 1fr; } | |
| #left, #right { display: none; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <!-- Header --> | |
| <header> | |
| <div class="h-logo">π€</div> | |
| <div> | |
| <div class="h-name" id="app-name">KB Assistant</div> | |
| <div class="h-sub">Search the knowledge base and get answers in plain language</div> | |
| </div> | |
| <span class="h-badge">MVP Β· AI-powered</span> | |
| </header> | |
| <!-- 3-col body --> | |
| <div id="body"> | |
| <!-- Left: suggestions --> | |
| <div id="left"> | |
| <div class="panel-hdr">Try asking</div> | |
| <div id="suggestions"></div> | |
| </div> | |
| <!-- Centre: chat --> | |
| <div id="centre"> | |
| <div id="messages"> | |
| <div id="empty-state"> | |
| <div class="es-icon">π¬</div> | |
| <div class="es-title">Ask me anything</div> | |
| <div class="es-sub" id="welcome-msg">Search the knowledge base and get an answer in plain language.</div> | |
| </div> | |
| </div> | |
| <!-- Input area --> | |
| <div id="input-area"> | |
| <div id="input-card"> | |
| <div id="input-row"> | |
| <textarea id="msg-input" placeholder="Ask a questionβ¦" rows="1"></textarea> | |
| <button id="send-btn" title="Send (Enter)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"> | |
| <line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div id="toolbar"> | |
| <button class="tb" title="Attach file" id="attach-btn"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> | |
| <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/> | |
| </svg> | |
| </button> | |
| <button class="tb" title="Paste from clipboard" id="paste-btn"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> | |
| <rect x="8" y="2" width="8" height="4" rx="1"/> | |
| <path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2"/> | |
| </svg> | |
| </button> | |
| <div class="tb-sep"></div> | |
| <button class="tb" title="Bold" data-wrap="**"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"> | |
| <path d="M6 4h8a4 4 0 010 8H6zM6 12h9a4 4 0 010 8H6z"/> | |
| </svg> | |
| </button> | |
| <button class="tb" title="Italic" data-wrap="*"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> | |
| <line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/> | |
| <line x1="15" y1="4" x2="9" y2="20"/> | |
| </svg> | |
| </button> | |
| <button class="tb" title="Bullet list" id="bullet-btn"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> | |
| <line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/> | |
| <line x1="9" y1="18" x2="20" y2="18"/> | |
| <circle cx="4" cy="6" r="1.5" fill="currentColor" stroke="none"/> | |
| <circle cx="4" cy="12" r="1.5" fill="currentColor" stroke="none"/> | |
| <circle cx="4" cy="18" r="1.5" fill="currentColor" stroke="none"/> | |
| </svg> | |
| </button> | |
| <button class="tb" title="Inline code" data-wrap="`"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> | |
| <polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/> | |
| </svg> | |
| </button> | |
| <span id="char-count"></span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Right: KB files --> | |
| <div id="right"> | |
| <div class="rp-head"> | |
| <span class="rp-title">Knowledge Base</span> | |
| <span id="file-count">0</span> | |
| <div class="add-wrap"> | |
| <button class="add-btn" id="add-file-btn">+ Add file</button> | |
| <div class="add-tip"> | |
| <strong>Add to knowledge base</strong> | |
| <i>Formats: .txt .md .pdf .docx</i> | |
| <i>Max 10 MB per file</i> | |
| <i>Multiple files at once</i> | |
| <i>Index rebuilds automatically</i> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="file-list"> | |
| <div id="file-count-line"></div> | |
| <div id="file-rows"></div> | |
| </div> | |
| </div> | |
| </div><!-- #body --> | |
| </div><!-- #app --> | |
| <input type="file" id="file-input" multiple accept=".txt,.md,.pdf,.docx,.doc"> | |
| <div id="upload-overlay"><div class="upload-card"><div class="spinner"></div><p>Indexing documentsβ¦</p></div></div> | |
| <div id="toast"></div> | |
| <script> | |
| // βββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const history = []; | |
| let isThinking = false; | |
| // βββ Utilities βββββββββββββββββββββββββββββββββββββββββββββββ | |
| function toast(msg, duration = 2800) { | |
| const t = document.getElementById('toast'); | |
| t.textContent = msg; t.classList.add('show'); | |
| setTimeout(() => t.classList.remove('show'), duration); | |
| } | |
| function md(text) { | |
| // Minimal markdown β HTML | |
| return text | |
| .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') | |
| .replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>') | |
| .replace(/\*(.+?)\*/g,'<em>$1</em>') | |
| .replace(/`(.+?)`/g,'<code>$1</code>') | |
| .replace(/^β’\s+(.+)/gm,'<li>$1</li>') | |
| .replace(/^-\s+(.+)/gm,'<li>$1</li>') | |
| .replace(/(<li>.*<\/li>)/s,'<ul>$1</ul>') | |
| .replace(/\n\n+/g,'</p><p>') | |
| .replace(/\n/g,'<br>') | |
| .replace(/^(.)/,'<p>$1').replace(/(.)$/,'$1</p>'); | |
| } | |
| function extIcon(name) { | |
| const e = name.split('.').pop().toLowerCase(); | |
| return ({pdf:'π', docx:'π', doc:'π', md:'π', txt:'π'})[e] || 'π'; | |
| } | |
| // βββ Chat βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function addMessage(role, text, sources) { | |
| const msgs = document.getElementById('messages'); | |
| // Remove empty state on first message | |
| const es = document.getElementById('empty-state'); | |
| if (es) es.remove(); | |
| const msg = document.createElement('div'); | |
| msg.className = `msg ${role}`; | |
| if (role === 'user') { | |
| const b = document.createElement('div'); | |
| b.className = 'bubble'; | |
| b.textContent = text; | |
| msg.appendChild(b); | |
| } else { | |
| const b = document.createElement('div'); | |
| b.className = 'bubble'; | |
| b.innerHTML = md(text); | |
| // Source chips | |
| if (sources && sources.length) { | |
| const row = document.createElement('div'); | |
| row.className = 'src-row'; | |
| const lbl = document.createElement('span'); | |
| lbl.className = 'src-lbl'; lbl.textContent = 'Source'; | |
| row.appendChild(lbl); | |
| sources.forEach(s => { | |
| const chip = document.createElement('span'); | |
| chip.className = 'src-chip'; | |
| chip.innerHTML = `${extIcon(s)} ${s}`; | |
| row.appendChild(chip); | |
| }); | |
| b.appendChild(row); | |
| } | |
| msg.appendChild(b); | |
| } | |
| msgs.appendChild(msg); | |
| msgs.scrollTop = msgs.scrollHeight; | |
| return msg; | |
| } | |
| function addTyping() { | |
| const msgs = document.getElementById('messages'); | |
| const es = document.getElementById('empty-state'); | |
| if (es) es.remove(); | |
| const msg = document.createElement('div'); | |
| msg.className = 'msg bot'; msg.id = 'typing-indicator'; | |
| const b = document.createElement('div'); | |
| b.className = 'bubble typing'; | |
| b.innerHTML = '<span></span><span></span><span></span>'; | |
| msg.appendChild(b); msgs.appendChild(msg); | |
| msgs.scrollTop = msgs.scrollHeight; | |
| } | |
| function removeTyping() { | |
| const t = document.getElementById('typing-indicator'); | |
| if (t) t.remove(); | |
| } | |
| async function sendMessage() { | |
| const input = document.getElementById('msg-input'); | |
| const text = input.value.trim(); | |
| if (!text || isThinking) return; | |
| input.value = ''; input.style.height = 'auto'; | |
| isThinking = true; | |
| document.getElementById('send-btn').disabled = true; | |
| document.getElementById('char-count').textContent = ''; | |
| addMessage('user', text, null); | |
| history.push({ role: 'user', content: text }); | |
| addTyping(); | |
| try { | |
| const res = await fetch('/api/chat', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ message: text, history: history.slice(-6) }) | |
| }); | |
| const data = await res.json(); | |
| removeTyping(); | |
| addMessage('bot', data.answer, data.sources); | |
| history.push({ role: 'assistant', content: data.answer }); | |
| } catch (e) { | |
| removeTyping(); | |
| addMessage('bot', 'Sorry, something went wrong. Please try again.', null); | |
| } finally { | |
| isThinking = false; | |
| document.getElementById('send-btn').disabled = false; | |
| input.focus(); | |
| } | |
| } | |
| // βββ Files ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadFiles() { | |
| const res = await fetch('/api/files'); | |
| const data = await res.json(); | |
| renderFiles(data.files, data.count); | |
| } | |
| function renderFiles(files, count) { | |
| document.getElementById('file-count').textContent = count; | |
| document.getElementById('file-count-line').textContent = | |
| count ? `${count} file${count !== 1 ? 's' : ''} indexed` : ''; | |
| const rows = document.getElementById('file-rows'); | |
| rows.innerHTML = ''; | |
| if (!files.length) { | |
| rows.innerHTML = ` | |
| <div style="text-align:center;padding:32px 12px;color:var(--muted2)"> | |
| <div style="font-size:2rem;opacity:.35;margin-bottom:8px">π</div> | |
| <div style="font-size:11.5px;line-height:1.6">No files yet.<br>Click <strong>+ Add file</strong> above.</div> | |
| </div>`; | |
| return; | |
| } | |
| files.forEach(f => { | |
| const name = f.name; | |
| const safeId = name.replace(/[^a-zA-Z0-9_-]/g, '_'); | |
| const row = document.createElement('div'); | |
| row.className = 'f-row'; row.id = `row-${safeId}`; | |
| row.innerHTML = ` | |
| <span class="f-ico">${extIcon(name)}</span> | |
| <span class="f-name" title="${name}">${name}</span> | |
| <div class="f-actions"> | |
| <button class="f-btn" title="Rename" onclick="startRename('${name}','${safeId}')">β</button> | |
| <button class="f-btn del" title="Delete" onclick="deleteFile('${name}')">π</button> | |
| </div>`; | |
| const renameRow = document.createElement('div'); | |
| renameRow.className = 'f-rename'; renameRow.id = `rename-${safeId}`; | |
| renameRow.innerHTML = ` | |
| <input id="ri-${safeId}" value="${name}" placeholder="${name}"> | |
| <button class="r-save" onclick="saveRename('${name}','${safeId}')">Save</button> | |
| <button class="r-cancel" onclick="cancelRename('${safeId}')">β</button>`; | |
| rows.appendChild(row); | |
| rows.appendChild(renameRow); | |
| }); | |
| } | |
| async function deleteFile(name) { | |
| if (!confirm(`Delete ${name}?`)) return; | |
| try { | |
| await fetch(`/api/files/${encodeURIComponent(name)}`, { method: 'DELETE' }); | |
| toast(`π Deleted ${name}`); | |
| loadFiles(); | |
| } catch { toast('β Could not delete file'); } | |
| } | |
| function startRename(name, safeId) { | |
| document.getElementById(`row-${safeId}`).style.display = 'none'; | |
| const rr = document.getElementById(`rename-${safeId}`); | |
| rr.classList.add('on'); | |
| const inp = document.getElementById(`ri-${safeId}`); | |
| inp.focus(); inp.select(); | |
| } | |
| function cancelRename(safeId) { | |
| document.getElementById(`row-${safeId}`).style.display = ''; | |
| document.getElementById(`rename-${safeId}`).classList.remove('on'); | |
| } | |
| async function saveRename(oldName, safeId) { | |
| const newName = document.getElementById(`ri-${safeId}`).value.trim(); | |
| if (!newName || newName === oldName) { cancelRename(safeId); return; } | |
| try { | |
| const res = await fetch('/api/files/rename', { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ old_name: oldName, new_name: newName }) | |
| }); | |
| if (!res.ok) { const d = await res.json(); toast(`β ${d.detail}`); return; } | |
| toast(`β Renamed to ${newName}`); | |
| loadFiles(); | |
| } catch { toast('β Could not rename file'); } | |
| } | |
| async function uploadFiles(fileList) { | |
| const overlay = document.getElementById('upload-overlay'); | |
| overlay.classList.add('show'); | |
| const fd = new FormData(); | |
| Array.from(fileList).forEach(f => fd.append('files', f)); | |
| try { | |
| const res = await fetch('/api/files/upload', { method: 'POST', body: fd }); | |
| const data = await res.json(); | |
| const saved = data.saved || []; | |
| const skipped = data.skipped || []; | |
| if (saved.length) toast(`β Uploaded: ${saved.join(', ')}`); | |
| if (skipped.length) toast(`β οΈ Skipped: ${skipped.join(', ')}`, 4000); | |
| loadFiles(); | |
| } catch { toast('β Upload failed'); } | |
| finally { overlay.classList.remove('show'); } | |
| } | |
| // βββ Toolbar βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function wrapSelection(tag) { | |
| const t = document.getElementById('msg-input'); | |
| const s = t.selectionStart, e = t.selectionEnd; | |
| if (s === e) return; | |
| const v = t.value; | |
| t.value = v.slice(0,s) + tag + v.slice(s,e) + tag + v.slice(e); | |
| t.selectionStart = s; t.selectionEnd = e + tag.length * 2; | |
| t.dispatchEvent(new Event('input', { bubbles: true })); | |
| } | |
| // βββ Init βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function init() { | |
| const cfg = await fetch('/api/config').then(r => r.json()); | |
| document.title = cfg.name; | |
| document.getElementById('app-name').textContent = cfg.name; | |
| document.getElementById('welcome-msg').textContent = cfg.welcome; | |
| // Suggestion pills | |
| const container = document.getElementById('suggestions'); | |
| cfg.quick_actions.forEach((q, i) => { | |
| const btn = document.createElement('button'); | |
| btn.className = 'pill'; | |
| btn.textContent = q; | |
| btn.style.animationDelay = `${i * 0.07}s`; | |
| btn.onclick = () => { | |
| const inp = document.getElementById('msg-input'); | |
| inp.value = q; | |
| inp.dispatchEvent(new Event('input', { bubbles: true })); | |
| inp.focus(); | |
| }; | |
| container.appendChild(btn); | |
| }); | |
| await loadFiles(); | |
| // Event listeners | |
| const input = document.getElementById('msg-input'); | |
| input.addEventListener('input', () => { | |
| input.style.height = 'auto'; | |
| input.style.height = Math.min(input.scrollHeight, 140) + 'px'; | |
| const len = input.value.length; | |
| const cc = document.getElementById('char-count'); | |
| cc.textContent = len > 600 ? `${len}/800` : ''; | |
| }); | |
| input.addEventListener('keydown', e => { | |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } | |
| }); | |
| document.getElementById('send-btn').addEventListener('click', sendMessage); | |
| document.getElementById('attach-btn').addEventListener('click', () => { | |
| document.getElementById('file-input').click(); | |
| }); | |
| document.getElementById('add-file-btn').addEventListener('click', () => { | |
| document.getElementById('file-input').click(); | |
| }); | |
| document.getElementById('file-input').addEventListener('change', e => { | |
| if (e.target.files.length) uploadFiles(e.target.files); | |
| e.target.value = ''; | |
| }); | |
| document.getElementById('paste-btn').addEventListener('click', async () => { | |
| try { | |
| const txt = await navigator.clipboard.readText(); | |
| const t = document.getElementById('msg-input'); | |
| const s = t.selectionStart; | |
| t.value = t.value.slice(0, s) + txt + t.value.slice(t.selectionEnd); | |
| t.selectionStart = t.selectionEnd = s + txt.length; | |
| t.dispatchEvent(new Event('input', { bubbles: true })); | |
| } catch { input.focus(); } | |
| }); | |
| document.getElementById('bullet-btn').addEventListener('click', () => { | |
| const t = document.getElementById('msg-input'); | |
| const lines = t.value.split('\n'); | |
| const idx = t.value.slice(0, t.selectionStart).split('\n').length - 1; | |
| lines[idx] = '- ' + lines[idx]; | |
| t.value = lines.join('\n'); | |
| t.dispatchEvent(new Event('input', { bubbles: true })); | |
| }); | |
| document.querySelectorAll('.tb[data-wrap]').forEach(btn => { | |
| btn.addEventListener('click', () => wrapSelection(btn.dataset.wrap)); | |
| }); | |
| // Drag-and-drop onto right panel | |
| document.getElementById('right').addEventListener('dragover', e => e.preventDefault()); | |
| document.getElementById('right').addEventListener('drop', e => { | |
| e.preventDefault(); | |
| if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); | |
| }); | |
| // Focus input on load | |
| input.focus(); | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> | |