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'); 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 = ''; }); 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 = `
${escapeHtml(file.name)}
${formatBytes(file.size)} · ${escapeHtml(file.kind)}
`; 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); const response = await fetch(`${API_BASE_URL}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessKey}`, }, body: JSON.stringify(payload), }); 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 = ''; 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 } } } } } 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 }; } function extractAssistantText(data) { const content = data?.choices?.[0]?.message?.content; if (typeof content === 'string') return content; if (Array.isArray(content)) { return content .map((item) => item?.text || item?.content || '') .filter(Boolean) .join('\n'); } return ''; } function createConversation() { const conversation = { id: crypto.randomUUID(), title: '新会话', model: localStorage.getItem(MODEL_STORAGE) || '', createdAt: Date.now(), updatedAt: Date.now(), messages: [], }; state.conversations.unshift(conversation); return conversation.id; } function sanitizeConversation(rawConversation) { if (!rawConversation || typeof rawConversation !== 'object') return null; const messages = Array.isArray(rawConversation.messages) ? rawConversation.messages .filter((message) => message && typeof message === 'object') .map((message) => ({ role: message.role === 'assistant' ? 'assistant' : 'user', content: typeof message.content === 'string' ? message.content : '', createdAt: Number.isFinite(message.createdAt) ? message.createdAt : Date.now(), thinkMs: Number.isFinite(message.thinkMs) ? message.thinkMs : undefined, attachments: Array.isArray(message.attachments) ? message.attachments .filter((file) => file && typeof file === 'object') .map((file) => ({ name: typeof file.name === 'string' ? file.name : '未命名附件', type: typeof file.type === 'string' ? file.type : 'application/octet-stream', size: Number.isFinite(file.size) ? file.size : 0, kind: typeof file.kind === 'string' ? file.kind : 'file', })) : [], })) : []; return { id: typeof rawConversation.id === 'string' && rawConversation.id ? rawConversation.id : crypto.randomUUID(), title: typeof rawConversation.title === 'string' && rawConversation.title.trim() ? rawConversation.title.trim() : '新会话', model: typeof rawConversation.model === 'string' ? rawConversation.model : '', createdAt: Number.isFinite(rawConversation.createdAt) ? rawConversation.createdAt : Date.now(), updatedAt: Number.isFinite(rawConversation.updatedAt) ? rawConversation.updatedAt : Date.now(), messages, }; } function deleteConversation(id) { const target = state.conversations.find((item) => item.id === id); if (!target) return; const confirmed = window.confirm(`确认删除会话「${target.title || '新会话'}」吗?删除后无法恢复。`); if (!confirmed) return; state.conversations = state.conversations.filter((item) => item.id !== id); if (!state.conversations.length) { state.activeId = createConversation(); } else if (state.activeId === id) { state.activeId = state.conversations[0].id; } persistConversations(); renderConversationList(); renderMessages(); renderModelDropdown(); } function getActiveConversation() { return state.conversations.find((item) => item.id === state.activeId); } function renderConversationList() { conversationListEl.innerHTML = ''; state.conversations .sort((a, b) => b.updatedAt - a.updatedAt) .forEach((conversation) => { const item = document.createElement('div'); item.className = `conversation-item${conversation.id === state.activeId ? ' active' : ''}`; item.innerHTML = ` `; item.querySelector('.conversation-main').addEventListener('click', () => { state.activeId = conversation.id; renderConversationList(); renderMessages(); renderModelDropdown(); closeMobileSidebar(); }); item.querySelector('.conversation-delete').addEventListener('click', (event) => { event.stopPropagation(); deleteConversation(conversation.id); }); conversationListEl.appendChild(item); }); } // ── 思考计时 ────────────────────────────────────────────────────────────── // assistantMessage.thinkStart : 出现时记录的 Date.now() // assistantMessage.thinkMs : 出现时写入的耗时毫秒,写入后删除 thinkStart function updateThinkTiming(message, forceClose = false) { const text = String(message.content || ''); const hasOpen = //i.test(text); const hasClose = /<\/think>/i.test(text); if (hasOpen && !message.thinkStart && !('thinkMs' in message)) { message.thinkStart = Date.now(); } if ((hasClose || forceClose) && message.thinkStart && !('thinkMs' in message)) { message.thinkMs = Date.now() - message.thinkStart; delete message.thinkStart; } } function renderMessages() { const conversation = getActiveConversation(); if (!conversation || !conversation.messages.length) { stopLiveThinkTicker(); messagesEl.innerHTML = ''; const empty = document.createElement('div'); empty.className = 'empty-state'; empty.innerHTML = `

今天想聊点什么?

输入访问密钥,选择模型,然后直接开始对话。支持上传图片、文档、压缩包等附件。

`; messagesEl.appendChild(empty); return; } // 如果有正在思考的消息,只更新计时器文字,不重绘整个列表 const thinkingMsg = conversation.messages.find((m) => m.thinkStart); if (thinkingMsg && messagesEl.children.length > 0) { const elapsed = Date.now() - thinkingMsg.thinkStart; const timerEl = messagesEl.querySelector('.message-think.is-live .think-timer'); if (timerEl) { timerEl.textContent = formatThinkDuration(elapsed); startLiveThinkTicker(); return; } } // 全量重绘 const scrollNearBottom = Math.abs(messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight) < 80; messagesEl.innerHTML = ''; conversation.messages.forEach((message) => { const row = document.createElement('div'); const isAssistant = message.role === 'assistant'; row.className = `message-row ${isAssistant ? 'assistant-row' : 'user-row'}`; row.innerHTML = isAssistant ? `
N
NekoAI
` : `
用户
`; renderMessageContent(row.querySelector('.message-content'), message); // 用户消息内容为空时隐藏气泡(如纯附件消息) if (message.role === 'user') { const contentEl = row.querySelector('.message-content'); if (!contentEl.children.length && !contentEl.textContent.trim()) { contentEl.style.display = 'none'; } } const attachmentWrap = row.querySelector('.message-attachments'); if (message.attachments?.length) { message.attachments.forEach((file) => { const chip = document.createElement('div'); chip.className = 'message-attachment-chip'; chip.textContent = `${file.name} · ${formatBytes(file.size)}`; attachmentWrap.appendChild(chip); }); } messagesEl.appendChild(row); }); if (thinkingMsg) startLiveThinkTicker(); else stopLiveThinkTicker(); if (scrollNearBottom) messagesEl.scrollTop = messagesEl.scrollHeight; } function renderMessageContent(container, message) { container.innerHTML = ''; const raw = String(message.content || ''); const isThinking = Boolean(message.thinkStart); // 剥离思考内容,主文本永远不含 块 const mainText = raw .replace(/[\s\S]*?<\/think>/gi, '') .replace(/[\s\S]*$/gi, '') .trim(); // 思考框:只对 assistant 消息,且确实含有 标记才渲染 const hasThink = message.role === 'assistant' && ( isThinking || Number.isFinite(message.thinkMs) || //i.test(raw) ); if (hasThink) { // 提取已收到的思考内容(无论是否完成) let thinkContent = ''; if (isThinking) { // 还在思考中:提取 之后的所有内容(不需要闭合标签) const m = raw.match(/([\s\S]*)$/i); thinkContent = m ? m[1].trim() : ''; } else { // 思考完成:提取 ... 中间的内容 thinkContent = [...raw.matchAll(/([\s\S]*?)<\/think>/gi)] .map((m) => m[1].trim()).join('\n\n'); } const ms = isThinking ? (Date.now() - message.thinkStart) : (message.thinkMs || 0); const isOpen = Boolean(message._thinkOpen); const details = document.createElement('details'); details.className = `message-think${isThinking ? ' is-live' : ''}`; if (isOpen) details.open = true; details.innerHTML = ` 思考过程 ${formatThinkDuration(ms)} ${thinkContent ? `
${renderMarkdown(thinkContent)}
` : ''} `; details.addEventListener('toggle', () => { message._thinkOpen = details.open; }); container.appendChild(details); } // 主文本(marked.js 会自动处理图片) if (mainText) { const div = document.createElement('div'); div.innerHTML = renderMarkdown(mainText); // 给 marked 渲染出来的 img 加上样式类 div.querySelectorAll('img').forEach((img) => { img.className = 'message-inline-image'; }); container.appendChild(div); } } function renderMarkdown(input) { const renderer = new marked.Renderer(); renderer.code = ({ text, lang }) => { const escaped = text.replace(/&/g,'&').replace(//g,'>'); return `
${escaped}
`; }; renderer.image = ({ href, title, text }) => { if (!href) return ''; const isVideo = /\.(mp4|webm|ogg|mov)([?#]|$)/i.test(href) || /video/i.test(href); if (isVideo) { return ``; } const alt = text || title || ''; return `${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 ``; } return `${text}`; }; return marked.parse(String(input || ''), { renderer, breaks: true }); } function extractImageDataUrls(text) { const matches = String(text || '').match(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=\n\r]+/g); return matches ? matches.map((item) => item.replace(/[\n\r]/g, '')) : []; } function formatThinkDuration(ms) { if (!ms) return '0.0 秒'; if (ms < 60000) return `${(ms / 1000).toFixed(1)} 秒`; const minutes = Math.floor(ms / 60000); const seconds = ((ms % 60000) / 1000).toFixed(1); return `${minutes} 分 ${seconds} 秒`; } function removeImageDataUrls(text) { return String(text || '').replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=\n\r]+/g, '').trim(); } function startLiveThinkTicker() { if (state.liveThinkTimer) return; state.liveThinkTimer = setInterval(() => { renderMessages(); }, 1000); } function stopLiveThinkTicker() { if (!state.liveThinkTimer) return; clearInterval(state.liveThinkTimer); state.liveThinkTimer = null; } function updateSendingState() { sendBtnEl.disabled = state.sending; sendBtnEl.textContent = state.sending ? '发送中...' : '发送'; } function autoResizeTextarea() { messageInputEl.style.height = '32px'; const maxHeight = 220; const nextHeight = Math.min(Math.max(messageInputEl.scrollHeight, 32), maxHeight); messageInputEl.style.height = `${nextHeight}px`; messageInputEl.style.overflowY = messageInputEl.scrollHeight > maxHeight ? 'auto' : 'hidden'; } function focusComposer() { messageInputEl.focus(); } function applyThemeByTime() { const hour = new Date().getHours(); const isLight = hour >= 7 && hour < 19; document.body.classList.toggle('theme-light', isLight); } function persistConversations() { const safeConversations = state.conversations.map((conversation) => sanitizeConversation(conversation)).filter(Boolean); localStorage.setItem(STORAGE_KEY, JSON.stringify(safeConversations)); } function loadConversations() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.map((conversation) => sanitizeConversation(conversation)).filter(Boolean); } catch { return []; } } 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'; } function readFileAsDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || '')); reader.onerror = () => reject(new Error(`读取文件失败:${file.name}`)); reader.readAsDataURL(file); }); } function formatBytes(bytes) { if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; let value = bytes; let idx = 0; while (value >= 1024 && idx < units.length - 1) { value /= 1024; idx += 1; } return `${value >= 10 || idx === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[idx]}`; } function escapeHtml(text) { return String(text) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); }