diff --git a/public/app.js b/public/app.js index ac4a785..cabd576 100644 --- a/public/app.js +++ b/public/app.js @@ -36,6 +36,8 @@ const mobileSidebarBackdropEl = document.getElementById('mobileSidebarBackdrop') const fileInputEl = document.getElementById('fileInput'); const attachBtnEl = document.getElementById('attachBtn'); const attachmentListEl = document.getElementById('attachmentList'); +const stopBtnEl = document.getElementById('stopBtn'); +const exportBtnEl = document.getElementById('exportBtn'); boot(); @@ -130,6 +132,16 @@ function bindEvents() { fileInputEl.value = ''; }); + stopBtnEl.addEventListener('click', () => { + if (state.abortController) { + state.abortController.abort(); + } + }); + + exportBtnEl.addEventListener('click', () => { + exportConversation(); + }); + messageInputEl.addEventListener('input', () => { autoResizeTextarea(); }); @@ -434,14 +446,24 @@ function renderAttachments() { async function sendChat(accessKey, model, conversation, assistantMessage, attachments) { const payload = buildChatPayload(model, conversation, assistantMessage, attachments); - const response = await fetch(`${API_BASE_URL}/api/chat`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessKey}`, - }, - body: JSON.stringify(payload), - }); + state.abortController = new AbortController(); + const signal = state.abortController.signal; + + let response; + try { + response = await fetch(`${API_BASE_URL}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessKey}`, + }, + body: JSON.stringify(payload), + signal, + }); + } catch (err) { + if (err.name === 'AbortError') return; + throw err; + } if (!response.ok) { const data = await response.json().catch(() => ({})); @@ -460,41 +482,52 @@ async function sendChat(accessKey, model, conversation, assistantMessage, attach const decoder = new TextDecoder(); let buffer = ''; - while (true) { - const { value, done } = await reader.read(); - if (done) break; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; - buffer += decoder.decode(value, { stream: true }); - const parts = buffer.split('\n\n'); - buffer = parts.pop() || ''; + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split('\n\n'); + buffer = parts.pop() || ''; - for (const part of parts) { - const lines = part.split('\n'); - for (const line of lines) { - if (!line.startsWith('data:')) continue; - const data = line.slice(5).trim(); - if (!data || data === '[DONE]') continue; + for (const part of parts) { + const lines = part.split('\n'); + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') continue; - try { - const json = JSON.parse(data); - const delta = json.choices?.[0]?.delta?.content; - if (typeof delta === 'string' && delta) { - assistantMessage.content += delta; - updateThinkTiming(assistantMessage); - renderMessages(); - } else if (Array.isArray(delta)) { - const text = delta.map((item) => item?.text || '').join(''); - if (text) { - assistantMessage.content += text; + try { + const json = JSON.parse(data); + const delta = json.choices?.[0]?.delta?.content; + if (typeof delta === 'string' && delta) { + assistantMessage.content += delta; updateThinkTiming(assistantMessage); renderMessages(); + } else if (Array.isArray(delta)) { + const text = delta.map((item) => item?.text || '').join(''); + if (text) { + assistantMessage.content += text; + updateThinkTiming(assistantMessage); + renderMessages(); + } } + } catch { + // ignore invalid chunk } - } catch { - // ignore invalid chunk } } } + } catch (err) { + if (err.name === 'AbortError') { + // 用户主动停止,保留已生成内容 + updateThinkTiming(assistantMessage, true); + return; + } + throw err; + } finally { + state.abortController = null; } } @@ -582,6 +615,73 @@ function buildUserMessage(message, attachments) { return { role: 'user', content }; } +async function regenLastAssistant() { + if (state.sending) return; + const conversation = getActiveConversation(); + if (!conversation) return; + + // 找到最后一条 assistant 消息,删掉它,重新发 + const lastIdx = conversation.messages.map((m) => m.role).lastIndexOf('assistant'); + if (lastIdx === -1) return; + conversation.messages.splice(lastIdx, 1); + + const accessKey = localStorage.getItem(ACCESS_KEY_STORAGE) || ''; + const model = state.selectedModel; + if (!accessKey || !model) return; + + const assistantMessage = { role: 'assistant', content: '', createdAt: Date.now() }; + conversation.messages.push(assistantMessage); + conversation.updatedAt = Date.now(); + + state.sending = true; + updateSendingState(); + renderMessages(); + + try { + await sendChat(accessKey, model, conversation, assistantMessage, []); + } catch (err) { + assistantMessage.content = `请求失败:${err.message}`; + } finally { + state.sending = false; + updateSendingState(); + conversation.updatedAt = Date.now(); + persistConversations(); + renderConversationList(); + renderMessages(); + focusComposer(); + } +} + +function exportConversation() { + const conversation = getActiveConversation(); + if (!conversation || !conversation.messages.length) { + alert('当前会话没有内容可以导出。'); + return; + } + + const lines = [`# ${conversation.title || '新会话'}`, '']; + conversation.messages.forEach((msg) => { + const role = msg.role === 'assistant' ? '**NekoAI**' : '**用户**'; + const content = String(msg.content || '') + .replace(/[\s\S]*?<\/think>/gi, '') + .replace(/[\s\S]*$/gi, '') + .trim(); + lines.push(`${role}\n\n${content}`, ''); + if (msg.attachments?.length) { + msg.attachments.forEach((f) => lines.push(`> 附件:${f.name} (${formatBytes(f.size)})`, '')); + } + lines.push('---', ''); + }); + + const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${(conversation.title || '会话').slice(0, 40)}.md`; + a.click(); + URL.revokeObjectURL(url); +} + function extractAssistantText(data) { const content = data?.choices?.[0]?.message?.content; if (typeof content === 'string') return content; @@ -781,9 +881,51 @@ function renderMessages() { }); } + // 复制按钮(所有消息) + const copyBtn = document.createElement('button'); + copyBtn.className = 'message-copy-btn'; + copyBtn.type = 'button'; + copyBtn.textContent = '复制'; + copyBtn.addEventListener('click', () => { + const plainText = String(message.content || '') + .replace(/[\s\S]*?<\/think>/gi, '') + .replace(/[\s\S]*$/gi, '') + .trim(); + navigator.clipboard.writeText(plainText).then(() => { + copyBtn.textContent = '已复制'; + copyBtn.classList.add('copied'); + setTimeout(() => { copyBtn.textContent = '复制'; copyBtn.classList.remove('copied'); }, 1500); + }); + }); + row.querySelector('.message-bubble').appendChild(copyBtn); + + // 重新生成按钮(仅 assistant 最后一条) + const isLastAssistant = message.role === 'assistant' && + conversation.messages[conversation.messages.length - 1] === message; + if (isLastAssistant && !state.sending) { + const regenBtn = document.createElement('button'); + regenBtn.className = 'message-regen-btn'; + regenBtn.type = 'button'; + regenBtn.textContent = '重新生成'; + regenBtn.addEventListener('click', () => regenLastAssistant()); + row.querySelector('.message-bubble').appendChild(regenBtn); + } + messagesEl.appendChild(row); }); + // 代码块复制按钮事件委托 + messagesEl.querySelectorAll('.copy-code-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const code = btn.previousElementSibling?.querySelector('code')?.textContent || ''; + navigator.clipboard.writeText(code).then(() => { + btn.textContent = '已复制'; + btn.classList.add('copied'); + setTimeout(() => { btn.textContent = '复制'; btn.classList.remove('copied'); }, 1500); + }); + }); + }); + if (thinkingMsg) startLiveThinkTicker(); else stopLiveThinkTicker(); @@ -852,7 +994,7 @@ function renderMarkdown(input) { renderer.code = ({ text, lang }) => { const escaped = text.replace(/&/g,'&').replace(//g,'>'); - return `
${escaped}
`; + return `
${escaped}
`; }; renderer.image = ({ href, title, text }) => { @@ -916,6 +1058,13 @@ function stopLiveThinkTicker() { function updateSendingState() { sendBtnEl.disabled = state.sending; sendBtnEl.textContent = state.sending ? '发送中...' : '发送'; + if (state.sending) { + stopBtnEl.classList.remove('hidden'); + sendBtnEl.classList.add('hidden'); + } else { + stopBtnEl.classList.add('hidden'); + sendBtnEl.classList.remove('hidden'); + } } function autoResizeTextarea() { diff --git a/public/index.html b/public/index.html index cf21053..7eb3cb8 100644 --- a/public/index.html +++ b/public/index.html @@ -40,6 +40,7 @@
@@ -96,3 +97,6 @@ +> + + diff --git a/public/styles.css b/public/styles.css index bd8ad1d..def9b38 100644 --- a/public/styles.css +++ b/public/styles.css @@ -497,6 +497,7 @@ body.theme-light .md-pre { background: #f3f4f6; border-color: rgba(15,23,42,.08) align-items: center; gap: 12px; } +.attach-btn svg { display: block; } .attach-btn { width: 40px; height: 40px; @@ -706,3 +707,88 @@ body.theme-light .md-pre { background: #f3f4f6; border-color: rgba(15,23,42,.08) place-items: center; } } + +/* ── 新增样式 ── */ + +/* 停止按钮 */ +.stop-btn { + padding: 10px 16px; + background: #ff4d4d; + color: #fff; + border: none; + font-weight: 600; + border-radius: 14px; + min-width: 72px; + font-size: 14px; + cursor: pointer; + transition: .18s ease; + margin-right: 8px; +} +.stop-btn:hover { background: #e03c3c; } + +/* 代码块复制按钮 */ +.md-pre-wrap { + position: relative; + margin: 0 0 12px; +} +.md-pre-wrap .md-pre { + margin: 0; +} +.copy-code-btn { + position: absolute; + top: 8px; + right: 8px; + padding: 4px 10px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,.12); + background: rgba(255,255,255,.08); + color: var(--muted); + font-size: 12px; + cursor: pointer; + opacity: 0; + transition: opacity .15s ease, background .15s ease; +} +.md-pre-wrap:hover .copy-code-btn { opacity: 1; } +.copy-code-btn:hover { background: rgba(255,255,255,.16); color: var(--text); } +.copy-code-btn.copied { color: #4ade80; } +body.theme-light .copy-code-btn { + border-color: rgba(0,0,0,.1); + background: rgba(0,0,0,.04); +} +body.theme-light .copy-code-btn:hover { background: rgba(0,0,0,.08); } + +/* 消息复制按钮 */ +.message-copy-btn { + display: inline-block; + margin-top: 6px; + padding: 3px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + font-size: 12px; + cursor: pointer; + opacity: 0; + transition: opacity .15s ease, background .15s ease; +} +.message-bubble:hover .message-copy-btn { opacity: 1; } +.message-copy-btn:hover { background: var(--panel-hover); color: var(--text); } +.message-copy-btn.copied { color: #4ade80; } + +/* 消息重新生成按钮 */ +.message-regen-btn { + display: inline-block; + margin-top: 6px; + margin-left: 6px; + padding: 3px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + font-size: 12px; + cursor: pointer; + opacity: 0; + transition: opacity .15s ease, background .15s ease; +} +.message-bubble:hover .message-regen-btn { opacity: 1; } +.message-regen-btn:hover { background: var(--panel-hover); color: var(--text); } diff --git a/wrangler.toml b/wrangler.toml index 82f4752..226074c 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,3 +2,4 @@ name = "nekoai-api" main = "src/index.js" compatibility_date = "2026-03-12" workers_dev = true +pages_build_output_dir = "public"