feat: 停止生成、代码块复制、消息复制、重新生成、会话导出 MD

This commit is contained in:
zimk
2026-03-17 00:41:46 +08:00
parent e1a3b629b4
commit a57bebb385
4 changed files with 274 additions and 34 deletions

View File

@@ -36,6 +36,8 @@ const mobileSidebarBackdropEl = document.getElementById('mobileSidebarBackdrop')
const fileInputEl = document.getElementById('fileInput'); const fileInputEl = document.getElementById('fileInput');
const attachBtnEl = document.getElementById('attachBtn'); const attachBtnEl = document.getElementById('attachBtn');
const attachmentListEl = document.getElementById('attachmentList'); const attachmentListEl = document.getElementById('attachmentList');
const stopBtnEl = document.getElementById('stopBtn');
const exportBtnEl = document.getElementById('exportBtn');
boot(); boot();
@@ -130,6 +132,16 @@ function bindEvents() {
fileInputEl.value = ''; fileInputEl.value = '';
}); });
stopBtnEl.addEventListener('click', () => {
if (state.abortController) {
state.abortController.abort();
}
});
exportBtnEl.addEventListener('click', () => {
exportConversation();
});
messageInputEl.addEventListener('input', () => { messageInputEl.addEventListener('input', () => {
autoResizeTextarea(); autoResizeTextarea();
}); });
@@ -434,14 +446,24 @@ function renderAttachments() {
async function sendChat(accessKey, model, conversation, assistantMessage, attachments) { async function sendChat(accessKey, model, conversation, assistantMessage, attachments) {
const payload = buildChatPayload(model, conversation, assistantMessage, attachments); const payload = buildChatPayload(model, conversation, assistantMessage, attachments);
const response = await fetch(`${API_BASE_URL}/api/chat`, { state.abortController = new AbortController();
method: 'POST', const signal = state.abortController.signal;
headers: {
'Content-Type': 'application/json', let response;
Authorization: `Bearer ${accessKey}`, try {
}, response = await fetch(`${API_BASE_URL}/api/chat`, {
body: JSON.stringify(payload), 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) { if (!response.ok) {
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
@@ -460,41 +482,52 @@ async function sendChat(accessKey, model, conversation, assistantMessage, attach
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
while (true) { try {
const { value, done } = await reader.read(); while (true) {
if (done) break; const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n'); const parts = buffer.split('\n\n');
buffer = parts.pop() || ''; buffer = parts.pop() || '';
for (const part of parts) { for (const part of parts) {
const lines = part.split('\n'); const lines = part.split('\n');
for (const line of lines) { for (const line of lines) {
if (!line.startsWith('data:')) continue; if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim(); const data = line.slice(5).trim();
if (!data || data === '[DONE]') continue; if (!data || data === '[DONE]') continue;
try { try {
const json = JSON.parse(data); const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta?.content; const delta = json.choices?.[0]?.delta?.content;
if (typeof delta === 'string' && delta) { if (typeof delta === 'string' && delta) {
assistantMessage.content += 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); updateThinkTiming(assistantMessage);
renderMessages(); 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 }; 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(/<think>[\s\S]*?<\/think>/gi, '')
.replace(/<think>[\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) { function extractAssistantText(data) {
const content = data?.choices?.[0]?.message?.content; const content = data?.choices?.[0]?.message?.content;
if (typeof content === 'string') return 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(/<think>[\s\S]*?<\/think>/gi, '')
.replace(/<think>[\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.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(); if (thinkingMsg) startLiveThinkTicker();
else stopLiveThinkTicker(); else stopLiveThinkTicker();
@@ -852,7 +994,7 @@ function renderMarkdown(input) {
renderer.code = ({ text, lang }) => { renderer.code = ({ text, lang }) => {
const escaped = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); const escaped = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return `<pre class="md-pre"><code>${escaped}</code></pre>`; return `<div class="md-pre-wrap"><pre class="md-pre"><code>${escaped}</code></pre><button class="copy-code-btn" type="button">复制</button></div>`;
}; };
renderer.image = ({ href, title, text }) => { renderer.image = ({ href, title, text }) => {
@@ -916,6 +1058,13 @@ function stopLiveThinkTicker() {
function updateSendingState() { function updateSendingState() {
sendBtnEl.disabled = state.sending; sendBtnEl.disabled = state.sending;
sendBtnEl.textContent = 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() { function autoResizeTextarea() {

View File

@@ -40,6 +40,7 @@
<div class="sidebar-section-header"> <div class="sidebar-section-header">
<div class="sidebar-section-label">最近会话</div> <div class="sidebar-section-label">最近会话</div>
<button id="clearAllBtn" class="sidebar-text-btn" type="button">清空</button> <button id="clearAllBtn" class="sidebar-text-btn" type="button">清空</button>
<button id="exportBtn" class="sidebar-text-btn" type="button">导出</button>
</div> </div>
<div id="conversationList" class="conversation-list"></div> <div id="conversationList" class="conversation-list"></div>
</aside> </aside>
@@ -96,3 +97,6 @@
<script src="/app.js" defer></script> <script src="/app.js" defer></script>
</body> </body>
</html> </html>
>
</body>
</html>

View File

@@ -497,6 +497,7 @@ body.theme-light .md-pre { background: #f3f4f6; border-color: rgba(15,23,42,.08)
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.attach-btn svg { display: block; }
.attach-btn { .attach-btn {
width: 40px; width: 40px;
height: 40px; height: 40px;
@@ -706,3 +707,88 @@ body.theme-light .md-pre { background: #f3f4f6; border-color: rgba(15,23,42,.08)
place-items: center; 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); }

View File

@@ -2,3 +2,4 @@ name = "nekoai-api"
main = "src/index.js" main = "src/index.js"
compatibility_date = "2026-03-12" compatibility_date = "2026-03-12"
workers_dev = true workers_dev = true
pages_build_output_dir = "public"