diff --git a/apps/llm-proxy/web/chat.html b/apps/llm-proxy/web/chat.html index f8c57d9..ec52cef 100644 --- a/apps/llm-proxy/web/chat.html +++ b/apps/llm-proxy/web/chat.html @@ -2,54 +2,226 @@ - + llm.famzheng.me @@ -60,15 +232,22 @@
- - + +
-
+
+
+ 填好 token 后开聊。
+ Enter 发送 · Shift+Enter 换行 +
+
@@ -90,6 +269,7 @@ const resetBtn = document.getElementById('reset') const input = document.getElementById('input') const thread = document.getElementById('thread') + const empty = document.getElementById('empty') tokenInput.value = localStorage.getItem(TOKEN_KEY) || '' tokenInput.addEventListener('change', () => { @@ -98,34 +278,100 @@ const history = [] - function bubble(role, text, cls) { - const div = document.createElement('div') - div.className = 'bubble ' + (cls || role) - div.textContent = text - thread.appendChild(div) - thread.scrollTop = thread.scrollHeight - return div + function clearEmpty() { + if (empty && empty.parentNode === thread) thread.removeChild(empty) } - function typing() { - const div = document.createElement('div') - div.className = 'bubble assistant typing' - div.innerHTML = '' - thread.appendChild(div) - thread.scrollTop = thread.scrollHeight - return div + function scrollToBottom() { + // double rAF: 一次让浏览器 layout 新节点,第二次再滚 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + thread.scrollTo({ top: thread.scrollHeight, behavior: 'smooth' }) + }) + }) } + function addBubble(role, text, opts = {}) { + clearEmpty() + const row = document.createElement('div') + row.className = 'row ' + role + const bubble = document.createElement('div') + bubble.className = 'bubble' + bubble.textContent = text + row.appendChild(bubble) + if (role === 'assistant' && !opts.err) { + const meta = document.createElement('div') + meta.className = 'meta' + const copy = document.createElement('button') + copy.className = 'copy-btn' + copy.type = 'button' + copy.textContent = '复制' + copy.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(text) + copy.textContent = '已复制' + setTimeout(() => (copy.textContent = '复制'), 1200) + } catch { + copy.textContent = '复制失败' + } + }) + meta.appendChild(copy) + bubble.appendChild(document.createElement('br')) + bubble.appendChild(meta) + } + thread.appendChild(row) + scrollToBottom() + return bubble + } + + function addErr(text) { + clearEmpty() + const row = document.createElement('div') + row.className = 'row err' + const b = document.createElement('div') + b.className = 'bubble' + b.textContent = text + row.appendChild(b) + thread.appendChild(row) + scrollToBottom() + } + + function addTyping() { + clearEmpty() + const row = document.createElement('div') + row.className = 'row assistant' + const bubble = document.createElement('div') + bubble.className = 'bubble typing' + bubble.innerHTML = '' + row.appendChild(bubble) + thread.appendChild(row) + scrollToBottom() + return row + } + + // textarea 自动 grow + function autoGrow() { + input.style.height = 'auto' + const next = Math.min(input.scrollHeight, 200) + input.style.height = next + 'px' + } + input.addEventListener('input', autoGrow) + async function send() { const text = input.value.trim() const token = tokenInput.value.trim() if (!text) return - if (!token) { bubble('err', '先在上方填 token。', 'err'); return } + if (!token) { + addErr('先在上方填 token。') + tokenInput.focus() + return + } input.value = '' + autoGrow() history.push({ role: 'user', content: text }) - bubble('user', text) + addBubble('user', text) sendBtn.disabled = true - const dot = typing() + const dot = addTyping() try { const res = await fetch('/v1/chat/completions', { method: 'POST', @@ -141,33 +387,49 @@ const body = await res.text() dot.remove() if (!res.ok) { - bubble('err', `${res.status}: ${body}`, 'err') + addErr(`HTTP ${res.status} — ${body || '(空响应)'}`) history.pop() return } let data - try { data = JSON.parse(body) } catch (e) { - bubble('err', '上游返回非 JSON: ' + body.slice(0, 300), 'err'); history.pop(); return + try { + data = JSON.parse(body) + } catch { + addErr('上游返回非 JSON: ' + body.slice(0, 300)) + history.pop() + return } const reply = data?.choices?.[0]?.message?.content?.trim() || '(空回复)' history.push({ role: 'assistant', content: reply }) - bubble('assistant', reply) + addBubble('assistant', reply) } catch (e) { dot.remove() - bubble('err', '网络错误: ' + e.message, 'err') + addErr('网络错误: ' + e.message) history.pop() } finally { sendBtn.disabled = false + input.focus() } } sendBtn.addEventListener('click', send) input.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + send() + } }) resetBtn.addEventListener('click', () => { history.length = 0 thread.innerHTML = '' + thread.appendChild(empty) + input.focus() + }) + + // 自动聚焦:如果已有 token 聚焦输入框,否则聚焦 token 框 + window.addEventListener('load', () => { + if (tokenInput.value) input.focus() + else tokenInput.focus() }) diff --git a/apps/notes/frontend/index.html b/apps/notes/frontend/index.html index 1e46d61..3f6c5f7 100644 --- a/apps/notes/frontend/index.html +++ b/apps/notes/frontend/index.html @@ -5,6 +5,7 @@ Notes +