Compare commits
8 Commits
9bb830c277
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5033fa52b | ||
|
|
b8a730ef48 | ||
|
|
13f2ecc1c9 | ||
|
|
9d061975ab | ||
|
|
7edc2f3134 | ||
|
|
a57bebb385 | ||
|
|
e1a3b629b4 | ||
|
|
cde00d0879 |
@@ -79,7 +79,7 @@ npm run deploy
|
|||||||
- `nekoai-api`
|
- `nekoai-api`
|
||||||
|
|
||||||
默认地址类似:
|
默认地址类似:
|
||||||
- `https://nekoai-api.<your-subdomain>.workers.dev`
|
- `https://nekoai-api.your-subdomain.workers.dev`
|
||||||
|
|
||||||
### 2. 部署 Pages 前端
|
### 2. 部署 Pages 前端
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ npm run deploy
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
window.NEKOAI_CONFIG = {
|
window.NEKOAI_CONFIG = {
|
||||||
API_BASE_URL: "https://nekoai-api.git.llc"
|
API_BASE_URL: "https://nekoai-api.your-subdomain.workers.dev"
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
223
public/app.js
223
public/app.js
@@ -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();
|
||||||
});
|
});
|
||||||
@@ -244,8 +256,10 @@ async function loginWithKey(key, surfaceError = true) {
|
|||||||
localStorage.removeItem(MODEL_STORAGE);
|
localStorage.removeItem(MODEL_STORAGE);
|
||||||
localStorage.removeItem(ACCESS_KEY_STORAGE);
|
localStorage.removeItem(ACCESS_KEY_STORAGE);
|
||||||
renderModelDropdown();
|
renderModelDropdown();
|
||||||
if (surfaceError) setLoginError(error.message || '登录失败');
|
if (surfaceError) {
|
||||||
showLogin();
|
setLoginError(error.message || '登录失败');
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,14 +448,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 +484,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 +617,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 +883,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 +996,7 @@ function renderMarkdown(input) {
|
|||||||
|
|
||||||
renderer.code = ({ text, lang }) => {
|
renderer.code = ({ text, lang }) => {
|
||||||
const escaped = text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
const escaped = text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
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 +1060,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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="composer-hint">支持图片、文档、压缩包等</div>
|
<div class="composer-hint">支持图片、文档、压缩包等</div>
|
||||||
<div class="composer-actions single-line-actions">
|
<div class="composer-actions single-line-actions">
|
||||||
|
<button type="button" id="stopBtn" class="stop-btn compact-send-btn hidden">停止</button>
|
||||||
<button type="submit" id="sendBtn" class="send-btn compact-send-btn">发送</button>
|
<button type="submit" id="sendBtn" class="send-btn compact-send-btn">发送</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,3 +98,6 @@
|
|||||||
<script src="/app.js" defer></script>
|
<script src="/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -586,7 +587,8 @@ body.theme-light .md-pre { background: #f3f4f6; border-color: rgba(15,23,42,.08)
|
|||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.topbar {
|
.topbar {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -622,6 +624,8 @@ body.theme-light .md-pre { background: #f3f4f6; border-color: rgba(15,23,42,.08)
|
|||||||
|
|
||||||
.messages {
|
.messages {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
.empty-state,
|
.empty-state,
|
||||||
@@ -706,3 +710,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,.18);
|
||||||
|
background: rgba(255,255,255,.12);
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity .15s ease, background .15s ease;
|
||||||
|
}
|
||||||
|
.copy-code-btn:hover { background: rgba(255,255,255,.22); color: #fff; }
|
||||||
|
.copy-code-btn.copied { color: #4ade80; }
|
||||||
|
body.theme-light .copy-code-btn {
|
||||||
|
border-color: rgba(0,0,0,.15);
|
||||||
|
background: rgba(0,0,0,.06);
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
body.theme-light .copy-code-btn:hover { background: rgba(0,0,0,.12); color: #111; }
|
||||||
|
|
||||||
|
/* 消息复制按钮 */
|
||||||
|
.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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user