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
+