const STORAGE_KEY = 'nekoai.conversations'; const ACCESS_KEY_STORAGE = 'nekoai.accessKey'; const MODEL_STORAGE = 'nekoai.selectedModel'; const API_BASE_URL = (window.NEKOAI_CONFIG?.API_BASE_URL || '').replace(/\/$/, ''); const MAX_ATTACHMENT_SIZE = 15 * 1024 * 1024; const state = { conversations: loadConversations(), activeId: null, models: [], selectedModel: localStorage.getItem(MODEL_STORAGE) || '', sending: false, pendingAttachments: [], authenticated: false, liveThinkTimer: null, }; const loginScreenEl = document.getElementById('loginScreen'); const appShellEl = document.getElementById('appShell'); const loginFormEl = document.getElementById('loginForm'); const loginAccessKeyInputEl = document.getElementById('loginAccessKeyInput'); const loginErrorEl = document.getElementById('loginError'); const conversationListEl = document.getElementById('conversationList'); const messagesEl = document.getElementById('messages'); const chatFormEl = document.getElementById('chatForm'); const messageInputEl = document.getElementById('messageInput'); const newChatBtnEl = document.getElementById('newChatBtn'); const sendBtnEl = document.getElementById('sendBtn'); const clearAllBtnEl = document.getElementById('clearAllBtn'); const modelDropdownEl = document.getElementById('modelDropdown'); const modelDropdownButtonEl = document.getElementById('modelDropdownButton'); const modelDropdownMenuEl = document.getElementById('modelDropdownMenu'); const modelDropdownLabelEl = document.getElementById('modelDropdownLabel'); const mobileSidebarToggleEl = document.getElementById('mobileSidebarToggle'); 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(); function boot() { applyThemeByTime(); const savedKey = localStorage.getItem(ACCESS_KEY_STORAGE) || ''; loginAccessKeyInputEl.value = savedKey; if (!state.conversations.length) { const id = createConversation(); state.activeId = id; } else { state.activeId = state.conversations[0].id; } bindEvents(); autoResizeTextarea(); renderConversationList(); renderMessages(); renderAttachments(); renderModelDropdown(); updateSendingState(); if (savedKey) { loginWithKey(savedKey, false); } else { showLogin(); } } function bindEvents() { loginFormEl.addEventListener('submit', async (event) => { event.preventDefault(); const key = loginAccessKeyInputEl.value.trim(); if (!key) { setLoginError('请先输入访问密钥'); return; } await loginWithKey(key, true); }); newChatBtnEl.addEventListener('click', () => { state.activeId = createConversation(); persistConversations(); renderConversationList(); renderMessages(); renderModelDropdown(); focusComposer(); }); clearAllBtnEl.addEventListener('click', () => { if (!state.conversations.length) return; const confirmed = window.confirm('确认清空全部会话吗?清空后当前浏览器中的聊天记录将无法恢复。'); if (!confirmed) return; state.conversations = []; state.activeId = createConversation(); persistConversations(); renderConversationList(); renderMessages(); renderModelDropdown(); focusComposer(); }); modelDropdownButtonEl.addEventListener('click', () => { if (!state.models.length) return; const expanded = modelDropdownEl.classList.toggle('open'); modelDropdownButtonEl.setAttribute('aria-expanded', expanded ? 'true' : 'false'); }); document.addEventListener('click', (event) => { if (!modelDropdownEl.contains(event.target)) { modelDropdownEl.classList.remove('open'); modelDropdownButtonEl.setAttribute('aria-expanded', 'false'); } }); mobileSidebarToggleEl?.addEventListener('click', () => { appShellEl.classList.add('mobile-sidebar-open'); mobileSidebarBackdropEl?.classList.remove('hidden'); }); mobileSidebarBackdropEl?.addEventListener('click', () => { closeMobileSidebar(); }); fileInputEl.addEventListener('change', async () => { const files = Array.from(fileInputEl.files || []); await addPendingFiles(files); fileInputEl.value = ''; }); stopBtnEl.addEventListener('click', () => { if (state.abortController) { state.abortController.abort(); } }); exportBtnEl.addEventListener('click', () => { exportConversation(); }); messageInputEl.addEventListener('input', () => { autoResizeTextarea(); }); messageInputEl.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); chatFormEl.requestSubmit(); } }); chatFormEl.addEventListener('submit', async (event) => { event.preventDefault(); if (state.sending) return; const text = messageInputEl.value.trim(); if (!text && !state.pendingAttachments.length) return; const accessKey = localStorage.getItem(ACCESS_KEY_STORAGE) || ''; if (!accessKey) { showLogin(); return; } const model = state.selectedModel; if (!model) { alert('当前没有可用模型,请先刷新模型列表'); return; } const conversation = getActiveConversation(); conversation.updatedAt = Date.now(); conversation.model = model; const userMessage = { role: 'user', content: text, createdAt: Date.now(), attachments: state.pendingAttachments.map((item) => ({ name: item.name, type: item.type, size: item.size, kind: item.kind, })), }; conversation.messages.push(userMessage); if (!conversation.title || conversation.title === '新会话') { conversation.title = (text || state.pendingAttachments[0]?.name || '新会话').slice(0, 24); } const assistantMessage = { role: 'assistant', content: '', createdAt: Date.now() }; conversation.messages.push(assistantMessage); const pendingAttachments = [...state.pendingAttachments]; state.pendingAttachments = []; renderAttachments(); messageInputEl.value = ''; autoResizeTextarea(); state.sending = true; updateSendingState(); persistConversations(); renderConversationList(); renderMessages(); try { await sendChat(accessKey, model, conversation, assistantMessage, pendingAttachments); } catch (error) { assistantMessage.content = `请求失败:${error.message}`; } finally { state.sending = false; updateSendingState(); conversation.updatedAt = Date.now(); persistConversations(); renderConversationList(); renderMessages(); focusComposer(); } }); } async function loginWithKey(key, surfaceError = true) { setLoginError(''); loginAccessKeyInputEl.value = key; try { const response = await fetch(`${API_BASE_URL}/api/models`, { headers: { Authorization: `Bearer ${key}` }, }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || '访问密钥无效'); } localStorage.setItem(ACCESS_KEY_STORAGE, key); state.authenticated = true; state.models = Array.isArray(data.models) ? data.models : []; const activeConversation = getActiveConversation(); const preferred = activeConversation?.model || state.selectedModel; state.selectedModel = state.models.find((item) => item.id === preferred)?.id || state.models[0]?.id || ''; localStorage.setItem(MODEL_STORAGE, state.selectedModel); renderModelDropdown(); showApp(); focusComposer(); return true; } catch (error) { state.authenticated = false; state.models = []; state.selectedModel = ''; localStorage.removeItem(MODEL_STORAGE); localStorage.removeItem(ACCESS_KEY_STORAGE); renderModelDropdown(); if (surfaceError) { setLoginError(error.message || '登录失败'); showLogin(); } return false; } } function showLogin() { loginScreenEl.classList.remove('hidden'); appShellEl.classList.add('hidden'); } function showApp() { loginScreenEl.classList.add('hidden'); appShellEl.classList.remove('hidden'); } function closeMobileSidebar() { appShellEl.classList.remove('mobile-sidebar-open'); mobileSidebarBackdropEl?.classList.add('hidden'); } function setLoginError(text) { loginErrorEl.textContent = text || ''; } function renderModelDropdown() { const activeConversation = getActiveConversation(); if (activeConversation?.model && state.models.find((item) => item.id === activeConversation.model)) { state.selectedModel = activeConversation.model; } const current = state.models.find((item) => item.id === state.selectedModel); modelDropdownLabelEl.textContent = current?.label || current?.id || '选择模型'; modelDropdownMenuEl.innerHTML = ''; modelDropdownButtonEl.disabled = !state.models.length; modelDropdownButtonEl.setAttribute('aria-expanded', modelDropdownEl.classList.contains('open') ? 'true' : 'false'); if (!state.models.length) { modelDropdownEl.classList.remove('open'); modelDropdownButtonEl.setAttribute('aria-expanded', 'false'); const empty = document.createElement('div'); empty.className = 'model-option empty'; empty.textContent = '暂无模型'; modelDropdownMenuEl.appendChild(empty); return; } state.models.forEach((model) => { const button = document.createElement('button'); button.type = 'button'; button.className = `model-option${model.id === state.selectedModel ? ' active' : ''}`; button.textContent = model.label || model.id; button.addEventListener('click', () => { state.selectedModel = model.id; localStorage.setItem(MODEL_STORAGE, model.id); const conversation = getActiveConversation(); if (conversation) { conversation.model = model.id; conversation.updatedAt = Date.now(); persistConversations(); renderConversationList(); } modelDropdownEl.classList.remove('open'); modelDropdownButtonEl.setAttribute('aria-expanded', 'false'); renderModelDropdown(); }); modelDropdownMenuEl.appendChild(button); }); } async function addPendingFiles(files) { for (const file of files) { if (file.size > MAX_ATTACHMENT_SIZE) { alert(`${file.name} 超过 15MB,先跳过了。`); 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({ id: crypto.randomUUID(), name: file.name, type: file.type || 'application/octet-stream', size: file.size, 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; state.pendingAttachments.forEach((file) => { const item = document.createElement('div'); item.className = 'attachment-chip'; item.innerHTML = `
`; item.querySelector('.attachment-remove').addEventListener('click', () => { state.pendingAttachments = state.pendingAttachments.filter((entry) => entry.id !== file.id); renderAttachments(); }); attachmentListEl.appendChild(item); }); } async function sendChat(accessKey, model, conversation, assistantMessage, attachments) { const payload = buildChatPayload(model, conversation, assistantMessage, attachments); 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(() => ({})); throw new Error(data.error || `HTTP ${response.status}`); } const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('text/event-stream')) { const data = await response.json(); assistantMessage.content = extractAssistantText(data) || '上游返回为空'; updateThinkTiming(assistantMessage, true); return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; 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() || ''; 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; updateThinkTiming(assistantMessage); renderMessages(); } } } catch { // ignore invalid chunk } } } } } catch (err) { if (err.name === 'AbortError') { // 用户主动停止,保留已生成内容 updateThinkTiming(assistantMessage, true); return; } throw err; } finally { state.abortController = null; } } function buildChatPayload(model, conversation, assistantMessage, attachments) { const messages = conversation.messages .filter((item) => item !== assistantMessage) .map((item, index, arr) => { if (item.role !== 'user') { return { role: item.role, content: item.content }; } const isLastUserMessage = index === arr.length - 1; const attachedFiles = isLastUserMessage ? attachments : []; return buildUserMessage(item, attachedFiles); }); return { model, stream: true, messages, }; } function buildUserMessage(message, attachments) { if (!attachments.length) { return { role: 'user', content: message.content || '' }; } const promptText = message.content || '请读取并处理这些附件。'; const content = []; // 先放主文本 if (promptText) { content.push({ type: 'text', text: promptText }); } const nonInlineable = []; attachments.forEach((file) => { if (file.kind === 'image') { // 图片:用 image_url + base64 data URL(OpenAI 标准) 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); } }); // 不可内联的文件:追加描述性文本 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(/输入访问密钥,选择模型,然后直接开始对话。支持上传图片、文档、压缩包等附件。
${escaped}