Compare commits

...

11 Commits

4 changed files with 478 additions and 85 deletions

View File

@@ -79,7 +79,7 @@ npm run deploy
- `nekoai-api`
默认地址类似:
- `https://nekoai-api.<your-subdomain>.workers.dev`
- `https://nekoai-api.your-subdomain.workers.dev`
### 2. 部署 Pages 前端
@@ -93,7 +93,7 @@ npm run deploy
```js
window.NEKOAI_CONFIG = {
API_BASE_URL: "https://nekoai-api.git.llc"
API_BASE_URL: "https://nekoai-api.your-subdomain.workers.dev"
};
```

View File

@@ -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();
});
@@ -244,8 +256,10 @@ async function loginWithKey(key, surfaceError = true) {
localStorage.removeItem(MODEL_STORAGE);
localStorage.removeItem(ACCESS_KEY_STORAGE);
renderModelDropdown();
if (surfaceError) setLoginError(error.message || '登录失败');
showLogin();
if (surfaceError) {
setLoginError(error.message || '登录失败');
showLogin();
}
return false;
}
}
@@ -322,6 +336,67 @@ async function addPendingFiles(files) {
continue;
}
const kind = getAttachmentKind(file);
let parsedText = null;
// Office 文件:前端解析提取文本
if (kind === 'docx') {
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const result = await mammoth.extractRawText({ arrayBuffer });
parsedText = result.value || '';
} catch (err) {
console.warn('mammoth 解析失败', err);
}
} else if (kind === 'xlsx') {
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const lines = [];
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
const csv = XLSX.utils.sheet_to_csv(sheet);
if (csv.trim()) lines.push(`## Sheet: ${sheetName}\n${csv}`);
});
parsedText = lines.join('\n\n');
} catch (err) {
console.warn('SheetJS 解析失败', err);
}
} else if (kind === 'pptx') {
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
// pptx 本质是 zip用 XLSX 的 zip 工具提取文本节点
const zip = XLSX.read(arrayBuffer, { type: 'array' });
const textParts = [];
Object.keys(zip.Strings || {}).forEach((k) => {
const v = zip.Strings[k];
if (typeof v === 'string' && v.trim()) textParts.push(v.trim());
});
// 更可靠的方式:直接用 JSZip-like 解包XLSX 内置 CFB/ZIP
// 若 zip.Strings 为空则给提示
parsedText = textParts.length ? textParts.join('\n') : 'PPT 文本提取失败,内容可能为空)';
} catch (err) {
console.warn('pptx 解析失败', err);
}
} else if (kind === 'pdf') {
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfjsLib = window['pdfjs-dist/build/pdf'];
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const pageTexts = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items.map((item) => item.str).join(' ');
if (pageText.trim()) pageTexts.push(`[第 ${i} 页]\n${pageText}`);
}
parsedText = pageTexts.join('\n\n') || 'PDF 文本提取为空,可能是扫描件)';
} catch (err) {
console.warn('pdf.js 解析失败', err);
}
}
const dataUrl = await readFileAsDataURL(file);
const base64 = dataUrl.split(',')[1] || '';
state.pendingAttachments.push({
@@ -329,15 +404,25 @@ async function addPendingFiles(files) {
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
kind: getAttachmentKind(file),
kind,
dataUrl,
base64,
parsedText,
});
}
renderAttachments();
}
function readFileAsArrayBuffer(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error(`读取文件失败:${file.name}`));
reader.readAsArrayBuffer(file);
});
}
function renderAttachments() {
attachmentListEl.innerHTML = '';
if (!state.pendingAttachments.length) return;
@@ -363,14 +448,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(() => ({}));
@@ -389,41 +484,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;
}
}
@@ -452,31 +558,130 @@ function buildUserMessage(message, attachments) {
return { role: 'user', content: message.content || '' };
}
const attachmentText = attachments
.map((file) => `- ${file.name} (${file.type || 'application/octet-stream'}, ${formatBytes(file.size)})`)
.join('\n');
const promptText = message.content || '请读取并处理这些附件。';
const content = [
{
type: 'text',
text: `${promptText}\n\n附件列表:\n${attachmentText}`,
},
];
const content = [];
// 先放主文本
if (promptText) {
content.push({ type: 'text', text: promptText });
}
const nonInlineable = [];
attachments.forEach((file) => {
content.push({
type: 'image_url',
image_url: {
url: file.dataUrl,
},
});
if (file.kind === 'image') {
// 图片:用 image_url + base64 data URLOpenAI 标准)
content.push({
type: 'image_url',
image_url: { url: file.dataUrl },
});
} else if (file.kind === 'text') {
// 文本类文件:解码 base64内联为 text block
let decoded = '';
try {
decoded = atob(file.base64);
// 尝试 UTF-8 解码(处理多字节字符)
decoded = new TextDecoder('utf-8').decode(
Uint8Array.from(atob(file.base64), (c) => c.charCodeAt(0))
);
} catch {
decoded = file.base64;
}
content.push({
type: 'text',
text: `文件名:${file.name}\n内容:\n\`\`\`\n${decoded}\n\`\`\``,
});
} else if ((file.kind === 'docx' || file.kind === 'xlsx' || file.kind === 'pptx' || file.kind === 'pdf') && file.parsedText != null) {
// Office 文档 / PDF使用前端解析出的文本内联
content.push({
type: 'text',
text: `文件名:${file.name}\n内容:\n\`\`\`\n${file.parsedText}\n\`\`\``,
});
} else {
// 其他二进制:记录下来,后面统一追加描述
nonInlineable.push(file);
}
});
return {
role: 'user',
content,
};
// 不可内联的文件:追加描述性文本
if (nonInlineable.length) {
const desc = nonInlineable
.map((f) => `- ${f.name} (${f.type || 'application/octet-stream'}, ${formatBytes(f.size)})`)
.join('\n');
content.push({
type: 'text',
text: `以下附件无法直接内联,仅供参考:\n${desc}`,
});
}
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) {
@@ -678,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.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();
@@ -732,39 +979,52 @@ function renderMessageContent(container, message) {
container.appendChild(details);
}
// 主文本
const images = extractImageDataUrls(mainText);
const textOnly = removeImageDataUrls(mainText).trim();
if (textOnly) {
// 主文本marked.js 会自动处理图片)
if (mainText) {
const div = document.createElement('div');
div.innerHTML = renderMarkdown(textOnly);
container.appendChild(div);
}
if (images.length) {
const gallery = document.createElement('div');
gallery.className = 'message-image-gallery';
images.forEach((src) => {
const img = document.createElement('img');
img.src = src;
div.innerHTML = renderMarkdown(mainText);
// 给 marked 渲染出来的 img 加上样式类
div.querySelectorAll('img').forEach((img) => {
img.className = 'message-inline-image';
gallery.appendChild(img);
});
container.appendChild(gallery);
container.appendChild(div);
}
}
function renderMarkdown(input) {
return marked.parse(String(input || ''), {
renderer: (() => {
const r = new marked.Renderer();
r.code = ({ text, lang }) => {
const escaped = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return `<pre class="md-pre"><code>${escaped}</code></pre>`;
};
return r;
})(),
breaks: true,
});
const renderer = new marked.Renderer();
renderer.code = ({ text, lang }) => {
const escaped = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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 }) => {
if (!href) return '';
const isVideo = /\.(mp4|webm|ogg|mov)([?#]|$)/i.test(href) || /video/i.test(href);
if (isVideo) {
return `<video class="message-inline-video" controls playsinline preload="metadata" style="max-width:100%;border-radius:12px;margin-top:8px">
<source src="${href}">
<a href="${href}" target="_blank">点击查看视频</a>
</video>`;
}
const alt = text || title || '';
return `<img class="message-inline-image" src="${href}" alt="${alt}">`;
};
renderer.link = ({ href, title, text }) => {
if (!href) return text;
const isVideo = /\.(mp4|webm|ogg|mov)([?#]|$)/i.test(href) || /video/i.test(href);
if (isVideo) {
return `<video class="message-inline-video" controls playsinline preload="metadata" style="max-width:100%;border-radius:12px;margin-top:8px">
<source src="${href}">
<a href="${href}" target="_blank">点击查看视频</a>
</video>`;
}
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`;
};
return marked.parse(String(input || ''), { renderer, breaks: true });
}
function extractImageDataUrls(text) {
@@ -800,6 +1060,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() {
@@ -839,6 +1106,35 @@ function loadConversations() {
function getAttachmentKind(file) {
if (file.type?.startsWith('image/')) return 'image';
const textTypes = [
'text/',
'application/json',
'application/xml',
'application/javascript',
'application/typescript',
'application/x-yaml',
'application/x-sh',
'application/x-python',
];
if (textTypes.some((t) => file.type?.startsWith(t))) return 'text';
const textExts = /\.(txt|md|markdown|csv|json|xml|yaml|yml|toml|ini|cfg|conf|log|sh|bash|zsh|py|js|ts|jsx|tsx|java|c|cpp|h|hpp|cs|go|rs|rb|php|swift|kt|scala|r|sql|html|htm|css|scss|sass|less|vue|svelte|astro|diff|patch)$/i;
if (textExts.test(file.name)) return 'text';
if (file.type === 'application/pdf') return 'pdf';
const docxTypes = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword',
];
const xlsxTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
];
const pptxTypes = [
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-powerpoint',
];
if (docxTypes.includes(file.type) || /\.(docx|doc)$/i.test(file.name)) return 'docx';
if (xlsxTypes.includes(file.type) || /\.(xlsx|xls)$/i.test(file.name)) return 'xlsx';
if (pptxTypes.includes(file.type) || /\.(pptx|ppt)$/i.test(file.name)) return 'pptx';
return 'file';
}

View File

@@ -40,6 +40,7 @@
<div class="sidebar-section-header">
<div class="sidebar-section-label">最近会话</div>
<button id="clearAllBtn" class="sidebar-text-btn" type="button">清空</button>
<button id="exportBtn" class="sidebar-text-btn" type="button">导出</button>
</div>
<div id="conversationList" class="conversation-list"></div>
</aside>
@@ -79,6 +80,7 @@
</label>
<div class="composer-hint">支持图片、文档、压缩包等</div>
<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>
</div>
</div>
@@ -89,7 +91,13 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mammoth@1.8.0/mammoth.browser.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js"></script>
<script src="/config.js"></script>
<script src="/app.js" defer></script>
</body>
</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;
gap: 12px;
}
.attach-btn svg { display: block; }
.attach-btn {
width: 40px;
height: 40px;
@@ -586,7 +587,8 @@ body.theme-light .md-pre { background: #f3f4f6; border-color: rgba(15,23,42,.08)
height: 100dvh;
min-height: 0;
overflow: hidden;
grid-template-rows: auto minmax(0, 1fr) auto;
display: flex;
flex-direction: column;
}
.topbar {
gap: 8px;
@@ -622,6 +624,8 @@ body.theme-light .md-pre { background: #f3f4f6; border-color: rgba(15,23,42,.08)
.messages {
height: 100%;
flex: 1 1 0;
min-height: 0;
-webkit-overflow-scrolling: touch;
}
.empty-state,
@@ -706,3 +710,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,.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); }