From 38edaee0f00dd170f89bac498800cf645b2c8b72 Mon Sep 17 00:00:00 2001 From: thzxx Date: Fri, 3 Apr 2026 11:34:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=A8=A1=E5=9D=97=E5=8C=96?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将单文件巨型 IIFE (1608行) 拆分为 14 个 ES Module: 核心层: - js/state.js - 轻量级响应式状态管理 (发布-订阅) - js/utils.js - 通用工具函数 - js/sanitizer.js - HTML XSS 净化器 - js/marked-config.js - Markdown 渲染器配置 组件层: - js/components/chat-area.js - 消息渲染/流式更新/导出 - js/components/input-area.js - 文本输入/图片上传/发送 - js/components/history-modal.js - 历史记录面板/搜索/分页 - js/components/settings-modal.js - 设置面板/数据管理 - js/components/header.js - 顶部导航/连接状态 - js/components/model-bar.js - 模型选择栏 - js/components/toast.js - Toast 通知 - js/components/lightbox.js - 图片预览 入口层: - js/app.js - 主入口/初始化协调 index.html: 1608行 → 204行纯模板 --- index.html | 1414 +------------------------------ js/app.js | 180 ++++ js/components/chat-area.js | 276 ++++++ js/components/header.js | 98 +++ js/components/history-modal.js | 205 +++++ js/components/input-area.js | 278 ++++++ js/components/lightbox.js | 33 + js/components/model-bar.js | 78 ++ js/components/settings-modal.js | 169 ++++ js/components/toast.js | 30 + js/marked-config.js | 38 + js/sanitizer.js | 88 ++ js/state.js | 77 ++ js/utils.js | 73 ++ 14 files changed, 1628 insertions(+), 1409 deletions(-) create mode 100644 js/app.js create mode 100644 js/components/chat-area.js create mode 100644 js/components/header.js create mode 100644 js/components/history-modal.js create mode 100644 js/components/input-area.js create mode 100644 js/components/lightbox.js create mode 100644 js/components/model-bar.js create mode 100644 js/components/settings-modal.js create mode 100644 js/components/toast.js create mode 100644 js/marked-config.js create mode 100644 js/sanitizer.js create mode 100644 js/state.js create mode 100644 js/utils.js diff --git a/index.html b/index.html index 681b363..a26c296 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ - + Metona Ollama @@ -20,7 +20,7 @@
Metona Ollama - v1.1.0 + v2.0.0
@@ -59,7 +59,6 @@
-
@@ -74,8 +73,6 @@

开始对话

选择一个模型,输入消息开始聊天

- -
@@ -118,7 +115,6 @@
- -
-
@@ -150,22 +143,16 @@

常见值:2048 / 4096 / 8192 / 32768。值越大上下文越长,显存占用越高。

- -

无运行中的模型

- -
- -
@@ -196,9 +183,7 @@
-
- -
+
@@ -213,1396 +198,7 @@
- - + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..1a62e8f --- /dev/null +++ b/js/app.js @@ -0,0 +1,180 @@ +/** + * App - 主入口模块 + * 负责初始化所有组件、协调事件流、管理生命周期 + */ + +import { ChatDB } from '../chat-db.js'; +import { OllamaAPI } from '../ollama-api.js'; +import { state, KEYS } from './state.js'; +import { generateId } from './utils.js'; + +// 组件 +import { initToast, showToast } from './components/toast.js'; +import { initLightbox, closeLightbox } from './components/lightbox.js'; +import { initHeader, checkConnection } from './components/header.js'; +import { initModelBar, loadModels, setSelectedModel } from './components/model-bar.js'; +import { initChatArea, renderMessages, clearMessages } from './components/chat-area.js'; +import { initInputArea } from './components/input-area.js'; +import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js'; +import { initHistoryModal, closeHistoryModal } from './components/history-modal.js'; + +console.log('[Metona] 模块化版本 v2.0.0 ' + new Date().toISOString()); + +// ── 会话管理 ── + +function createNewSession() { + return { + id: generateId(), + title: '新会话', + model: document.querySelector('#modelSelect')?.value || '', + messages: [], + createdAt: Date.now(), + updatedAt: Date.now() + }; +} + +function startNewSession() { + const session = createNewSession(); + state.set(KEYS.CURRENT_SESSION, session); + state.set(KEYS.IS_HISTORY_VIEW, false); + + document.querySelector('#historyBar').style.display = 'none'; + document.querySelector('#inputArea').style.display = ''; + + clearMessages(); + renderMessages(); + document.querySelector('#chatInput')?.focus(); +} + +// ── 初始化 ── + +async function init() { + let db, api; + + try { + // 1. 初始化 IndexedDB + db = new ChatDB(); + await db.init(); + state.set(KEYS.DB, db); + + // 2. 读取保存的设置 + const serverUrl = await db.getSetting('serverUrl', 'http://127.0.0.1:11434'); + api = new OllamaAPI(serverUrl); + state.set(KEYS.API, api); + + // 3. 创建初始会话 + state.set(KEYS.CURRENT_SESSION, createNewSession()); + state.set(KEYS.IS_STREAMING, false); + state.set(KEYS.IS_HISTORY_VIEW, false); + state.set(KEYS.NUM_CTX, 24576); + + // 4. 初始化所有组件 + initToast(); + initLightbox(); + initHeader(); + initModelBar(); + initChatArea(); + initInputArea(); + initSettingsModal(); + initHistoryModal(); + + // 5. 绑定全局事件 + bindGlobalEvents(); + + // 6. 检测连接 + 加载模型 + await checkConnection(); + await loadModels(); + + // 7. 恢复上次选择的模型 + const savedModel = await db.getSetting('selectedModel', ''); + if (savedModel) setSelectedModel(savedModel); + + // 8. 恢复系统提示词和上下文长度 + const systemPromptEnabled = await db.getSetting('systemPromptEnabled', false); + const systemPrompt = await db.getSetting('systemPrompt', ''); + const numCtx = await db.getSetting('numCtx', 24576); + + state.set(KEYS.SYSTEM_PROMPT_ENABLED, systemPromptEnabled); + state.set(KEYS.SYSTEM_PROMPT, systemPrompt); + state.set(KEYS.NUM_CTX, numCtx); + + document.querySelector('#toggleSystemPrompt').checked = systemPromptEnabled; + document.querySelector('#inputSystemPrompt').disabled = !systemPromptEnabled; + document.querySelector('#inputSystemPrompt').value = systemPrompt; + document.querySelector('#inputNumCtx').value = numCtx; + + // 9. 聚焦输入框 + document.querySelector('#chatInput')?.focus(); + + // 10. 注册 Service Worker (PWA) + initServiceWorker(); + + console.log('[App] 初始化完成'); + } catch (err) { + console.error('[App] 初始化失败:', err); + // 即使 DB 失败也尝试运行 + if (!api) { + api = new OllamaAPI(); + state.set(KEYS.API, api); + } + state.set(KEYS.CURRENT_SESSION, createNewSession()); + bindGlobalEvents(); + initToast(); + initLightbox(); + initHeader(); + initModelBar(); + initChatArea(); + initInputArea(); + initSettingsModal(); + initHistoryModal(); + checkConnection().then(loadModels); + } +} + +function bindGlobalEvents() { + // 新建会话 + document.querySelector('#btnNewChat')?.addEventListener('click', startNewSession); + + // ESC 关闭模态框 + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeSettingsModal(); + closeHistoryModal(); + closeLightbox(); + } + }); + + // 全局错误捕获 + window.addEventListener('error', (e) => { + console.error('[App] 未捕获错误:', e.error || e.message); + showToast(`发生错误: ${e.message}`, 'error', 5000); + }); + + window.addEventListener('unhandledrejection', (e) => { + console.error('[App] 未处理 Promise 拒绝:', e.reason); + const msg = e.reason?.message || String(e.reason); + showToast(`操作失败: ${msg}`, 'error', 5000); + }); +} + +function initServiceWorker() { + if (!('serviceWorker' in navigator)) return; + + navigator.serviceWorker.getRegistrations() + .then(regs => regs.forEach(r => r.unregister())) + .then(() => caches.keys().then(keys => Promise.all(keys.map(k => caches.delete(k))))) + .then(() => navigator.serviceWorker.register('sw.js?v=3', { updateViaCache: 'none' })) + .then(reg => { + reg.addEventListener('updatefound', () => { + const w = reg.installing; + if (w) w.addEventListener('statechange', () => { + if (w.state === 'activated') window.location.reload(); + }); + }); + reg.update(); + }) + .catch(err => console.warn('[App] Service Worker 注册失败:', err)); +} + +// 启动 +init(); diff --git a/js/components/chat-area.js b/js/components/chat-area.js new file mode 100644 index 0000000..37f4bb7 --- /dev/null +++ b/js/components/chat-area.js @@ -0,0 +1,276 @@ +/** + * ChatArea - 聊天区域组件 + * 负责消息渲染、流式更新、导出功能 + */ + +import { state, KEYS } from '../state.js'; +import { marked } from '../marked-config.js'; +import { sanitize } from '../sanitizer.js'; +import { escapeHtml, formatTime, downloadFile } from '../utils.js'; + +let chatAreaEl, messagesContainerEl, emptyStateEl; + +export function initChatArea() { + chatAreaEl = document.querySelector('#chatArea'); + messagesContainerEl = document.querySelector('#messagesContainer'); + emptyStateEl = document.querySelector('#emptyState'); +} + +/** 安全地将 Markdown 转为 HTML */ +export function safeMarkdown(text) { + if (!text) return ''; + try { + const result = marked.parse(String(text)); + const html = typeof result === 'string' ? result : escapeHtml(String(text)); + return sanitize(html); + } catch (e) { + return escapeHtml(String(text)); + } +} + +/** 滚动到底部 */ +export function scrollToBottom() { + requestAnimationFrame(() => { + chatAreaEl.scrollTop = chatAreaEl.scrollHeight; + }); +} + +/** 渲染所有消息(增量渲染) */ +export function renderMessages() { + const currentSession = state.get(KEYS.CURRENT_SESSION); + const msgs = currentSession ? currentSession.messages : []; + + if (msgs.length === 0) { + emptyStateEl.style.display = ''; + messagesContainerEl.style.display = 'none'; + return; + } + + emptyStateEl.style.display = 'none'; + messagesContainerEl.style.display = ''; + + const existingCount = messagesContainerEl.children.length; + for (let i = existingCount; i < msgs.length; i++) { + appendMessageDOM(msgs[i], i); + } + + scrollToBottom(); +} + +/** 追加单条消息 DOM */ +export function appendMessageDOM(msg, index) { + const div = document.createElement('div'); + div.className = `message ${msg.role}`; + div.dataset.index = index; + + const avatar = msg.role === 'user' ? '👤' : '🤖'; + const roleLabel = msg.role === 'user' ? '你' : 'AI'; + const modelTag = (msg.role === 'assistant' && msg.model) + ? `${escapeHtml(msg.model)}` + : ''; + + let contentHtml = ''; + const safeContent = (msg.content != null) ? String(msg.content) : ''; + + if (msg.role === 'assistant') { + // 思考块 + if (msg.think) { + contentHtml += ` +
+
+ 🤔 思考过程 + +
+
+
${escapeHtml(msg.think)}
+
+
`; + } + contentHtml += `
${safeMarkdown(safeContent)}
`; + } else { + contentHtml += `
${escapeHtml(safeContent)}
`; + } + + // 图片 + if (msg.images && msg.images.length > 0) { + contentHtml += '
'; + msg.images.forEach(() => { + contentHtml += `用户图片`; + }); + contentHtml += '
'; + } + + // 纯图片消息隐藏空内容 + if (msg.role === 'user' && (!msg.content || msg.content.trim() === '') && msg.images?.length > 0) { + contentHtml = contentHtml.replace('
', ''); + } + + // 统计信息 + const stats = []; + if (msg.timestamp) stats.push(formatTime(msg.timestamp)); + if (msg.eval_count) stats.push(`${msg.eval_count} tokens`); + if (msg.total_duration) stats.push(`${(msg.total_duration / 1e9).toFixed(1)}s`); + if (stats.length > 0) { + contentHtml += `
${stats.map(s => `${s}`).join(' · ')}
`; + } + + div.innerHTML = ` +
${avatar}
+
+
${roleLabel}${modelTag}
+ ${contentHtml} +
+ `; + + messagesContainerEl.appendChild(div); +} + +/** 更新最后一条 assistant 消息(流式输出) */ +export function updateLastAssistantMessage(content, think, stats, model) { + const lastMsg = messagesContainerEl.querySelector('.message.assistant:last-child'); + if (!lastMsg) return; + + const loadingDots = lastMsg.querySelector('.loading-dots'); + const loadingText = lastMsg.querySelector('.loading-text'); + if (loadingDots) loadingDots.remove(); + if (loadingText) loadingText.remove(); + + if (model) { + let modelTag = lastMsg.querySelector('.model-tag'); + if (!modelTag) { + modelTag = document.createElement('span'); + modelTag.className = 'model-tag'; + const roleEl = lastMsg.querySelector('.msg-role'); + if (roleEl) roleEl.appendChild(modelTag); + } + modelTag.textContent = model; + } + + const contentDiv = lastMsg.querySelector('.msg-content'); + const safeContent = (content != null) ? String(content) : ''; + if (contentDiv && safeContent) { + contentDiv.innerHTML = safeMarkdown(safeContent); + } + + // 思考块 + let thinkBlock = lastMsg.querySelector('.think-block'); + if (think) { + if (!thinkBlock) { + const msgBody = lastMsg.querySelector('.msg-body'); + thinkBlock = document.createElement('div'); + thinkBlock.className = 'think-block'; + thinkBlock.innerHTML = ` +
+ 🤔 思考过程 + +
+
+ `; + msgBody.insertBefore(thinkBlock, contentDiv); + } + thinkBlock.querySelector('pre').textContent = think; + } + + if (stats) { + let statsDiv = lastMsg.querySelector('.msg-stats'); + if (!statsDiv) { + statsDiv = document.createElement('div'); + statsDiv.className = 'msg-stats'; + lastMsg.querySelector('.msg-body').appendChild(statsDiv); + } + const parts = []; + if (stats.eval_count) parts.push(`${stats.eval_count} tokens`); + if (stats.total_duration) parts.push(`${(stats.total_duration / 1e9).toFixed(1)}s`); + if (parts.length) statsDiv.innerHTML = parts.join(' · '); + } + + scrollToBottom(); +} + +/** 追加临时的 assistant 消息占位 */ +export function appendAssistantPlaceholder() { + const div = document.createElement('div'); + div.className = 'message assistant'; + div.innerHTML = ` +
🤖
+
+
AI
+
+
+ +
+ 正在思考... +
+
+ `; + messagesContainerEl.appendChild(div); + scrollToBottom(); +} + +/** 追加系统提示消息 */ +export function appendSystemMessage(text) { + const div = document.createElement('div'); + div.style.cssText = 'text-align:center;padding:8px;font-size:12px;color:var(--text-muted);'; + div.textContent = text; + emptyStateEl.style.display = 'none'; + messagesContainerEl.style.display = ''; + messagesContainerEl.appendChild(div); + scrollToBottom(); +} + +/** 获取消息容器 */ +export function getMessagesContainer() { + return messagesContainerEl; +} + +/** 清空消息 DOM */ +export function clearMessages() { + messagesContainerEl.innerHTML = ''; +} + +// ── 导出功能 ── + +/** 导出为 Markdown */ +export function exportAsMarkdown(session) { + let md = `# ${session.title}\n\n`; + md += `**模型:** ${session.model} \n`; + md += `**时间:** ${formatTime(session.createdAt)}\n\n---\n\n`; + + session.messages.forEach(m => { + const role = m.role === 'user' ? '👤 用户' : '🤖 AI'; + md += `### ${role}\n\n${m.content || ''}\n\n`; + if (m.think) md += `> **思考:** ${m.think}\n\n`; + }); + + downloadFile(`${session.title}.md`, md, 'text/markdown'); +} + +/** 导出为 HTML */ +export function exportAsHtml(session) { + let html = `${escapeHtml(session.title)} + +

${escapeHtml(session.title)}

${formatTime(session.createdAt)} · ${session.model}


`; + + session.messages.forEach(m => { + html += `
${m.role === 'user' ? '👤 用户' : '🤖 AI'}
${(m.content || '').replace(/\n/g, '
')}
`; + }); + + html += ''; + downloadFile(`${session.title}.html`, html, 'text/html'); +} + +/** 导出为 TXT */ +export function exportAsTxt(session) { + let txt = `${session.title}\n模型: ${session.model}\n时间: ${formatTime(session.createdAt)}\n${'='.repeat(50)}\n\n`; + + session.messages.forEach(m => { + txt += `[${m.role === 'user' ? '用户' : 'AI'}]\n${m.content || ''}\n\n`; + }); + + downloadFile(`${session.title}.txt`, txt, 'text/plain'); +} diff --git a/js/components/header.js b/js/components/header.js new file mode 100644 index 0000000..c00ed3a --- /dev/null +++ b/js/components/header.js @@ -0,0 +1,98 @@ +/** + * Header - 顶部导航组件 + * 包含连接状态检测、新建会话按钮 + */ + +import { state, KEYS } from '../state.js'; +import { formatSize } from '../utils.js'; +import { OllamaAPI } from '../../ollama-api.js'; + +let connStatusEl; + +export function initHeader() { + connStatusEl = document.querySelector('#connStatus'); +} + +/** + * 检测连接状态并更新 UI + * @returns {Promise<{ok: boolean, version?: object, error?: Error}>} + */ +export async function checkConnection() { + const api = state.get(KEYS.API); + if (!api) return { ok: false, error: new Error('API 未初始化') }; + + connStatusEl.className = 'conn-status pending'; + connStatusEl.querySelector('.status-label').textContent = '连接中...'; + + try { + const version = await api.getVersion(); + connStatusEl.className = 'conn-status connected'; + connStatusEl.title = `已连接 (v${version.version})`; + connStatusEl.querySelector('.status-label').textContent = '已连接'; + return { ok: true, version }; + } catch (err) { + connStatusEl.className = 'conn-status disconnected'; + connStatusEl.title = '未连接'; + connStatusEl.querySelector('.status-label').textContent = '未连接'; + return { ok: false, error: err }; + } +} + +/** + * 更新设置面板中的连接信息 + */ +export async function updateConnectionInfo() { + const api = state.get(KEYS.API); + if (!api) return; + + const badge = document.querySelector('#connBadge'); + const verEl = document.querySelector('#serverVersion'); + const corsHint = document.querySelector('#corsHint'); + + badge.textContent = '检测中...'; + badge.className = 'status-badge warning'; + verEl.textContent = ''; + + try { + const version = await api.getVersion(); + badge.textContent = '✓ 已连接'; + badge.className = 'status-badge success'; + verEl.textContent = `v${version.version}`; + corsHint.style.display = 'none'; + } catch (err) { + badge.textContent = '✗ 未连接'; + badge.className = 'status-badge error'; + verEl.textContent = ''; + + if (err.message.includes('Failed to fetch') || err.message.includes('NetworkError') || err.message.includes('CORS')) { + corsHint.style.display = ''; + } else { + corsHint.style.display = 'none'; + } + } +} + +/** + * 更新运行中的模型列表 + */ +export async function updateRunningModels() { + const api = state.get(KEYS.API); + if (!api) return; + + const container = document.querySelector('#runningModels'); + try { + const data = await api.psModels(); + if (!data.models || data.models.length === 0) { + container.innerHTML = '

无运行中的模型

'; + return; + } + container.innerHTML = data.models.map(m => ` +
+ ${m.name} + ${formatSize(m.size_vram || 0)} VRAM +
+ `).join(''); + } catch { + container.innerHTML = '

无法获取运行信息

'; + } +} diff --git a/js/components/history-modal.js b/js/components/history-modal.js new file mode 100644 index 0000000..eb8d457 --- /dev/null +++ b/js/components/history-modal.js @@ -0,0 +1,205 @@ +/** + * HistoryModal - 历史记录面板组件 + */ + +import { state, KEYS } from '../state.js'; +import { debounce, escapeHtml, formatTime } from '../utils.js'; +import { exportAsMarkdown, exportAsHtml, exportAsTxt, renderMessages, clearMessages } from './chat-area.js'; + +const HISTORY_PAGE_SIZE = 10; + +let historyModalEl, historyListEl, historyPaginationEl; +let historyPage = 1; +let historySearchQuery = ''; + +export function initHistoryModal() { + historyModalEl = document.querySelector('#historyModal'); + historyListEl = document.querySelector('#historyList'); + historyPaginationEl = document.querySelector('#historyPagination'); + + // 打开/关闭 + document.querySelector('#btnHistory').addEventListener('click', openHistoryModal); + document.querySelector('#btnCloseHistory').addEventListener('click', closeHistoryModal); + historyModalEl.addEventListener('click', (e) => { + if (e.target === historyModalEl) closeHistoryModal(); + }); + + // 退出历史只读模式 + document.querySelector('#btnExitHistory').addEventListener('click', () => { + document.querySelector('#btnNewChat').click(); + }); + + // 搜索 + const historySearchInput = document.querySelector('#historySearchInput'); + const historySearchClear = document.querySelector('#historySearchClear'); + const doHistorySearch = debounce(() => { + historySearchQuery = historySearchInput.value.trim(); + historySearchClear.style.display = historySearchQuery ? '' : 'none'; + historyPage = 1; + loadHistory(); + }, 250); + historySearchInput.addEventListener('input', doHistorySearch); + historySearchClear.addEventListener('click', () => { + historySearchInput.value = ''; + historySearchQuery = ''; + historySearchClear.style.display = 'none'; + historyPage = 1; + loadHistory(); + }); + + // 历史列表事件委托 + historyListEl.addEventListener('click', async (e) => { + const target = e.target.closest('[data-id]'); + if (!target) return; + const sessionId = target.dataset.id; + const db = state.get(KEYS.DB); + + if (target.classList.contains('btn-delete-session')) { + if (confirm('确定删除此会话?')) { + await db.deleteSession(sessionId); + const currentSession = state.get(KEYS.CURRENT_SESSION); + if (currentSession && currentSession.id === sessionId) { + document.querySelector('#btnNewChat').click(); + } + loadHistory(); + } + } else if (target.classList.contains('btn-export-md')) { + const session = await db.getSession(sessionId); + if (session) exportAsMarkdown(session); + } else if (target.classList.contains('btn-export-html')) { + const session = await db.getSession(sessionId); + if (session) exportAsHtml(session); + } else if (target.classList.contains('btn-export-txt')) { + const session = await db.getSession(sessionId); + if (session) exportAsTxt(session); + } else if (target.classList.contains('history-info') || target.classList.contains('history-item')) { + loadHistorySession(sessionId); + } + }); + + // 分页事件 + historyPaginationEl.addEventListener('click', (e) => { + const btn = e.target.closest('.page-btn'); + if (!btn || btn.classList.contains('disabled')) return; + const page = parseInt(btn.dataset.page); + if (page && page !== historyPage) { + historyPage = page; + loadHistory(); + historyListEl.scrollTop = 0; + } + }); +} + +export async function loadHistory() { + const db = state.get(KEYS.DB); + if (!db) return; + + let allSessions = await db.getAllSessions(); + + if (historySearchQuery) { + const q = historySearchQuery.toLowerCase(); + allSessions = allSessions.filter(s => { + if (s.title?.toLowerCase().includes(q)) return true; + if (s.model?.toLowerCase().includes(q)) return true; + return s.messages.some(m => m.content?.toLowerCase().includes(q)); + }); + } + + allSessions.sort((a, b) => b.updatedAt - a.updatedAt); + + if (allSessions.length === 0) { + historyListEl.innerHTML = ` +
+ + + +

暂无历史记录

+
`; + historyPaginationEl.innerHTML = ''; + return; + } + + const totalPages = Math.ceil(allSessions.length / HISTORY_PAGE_SIZE); + if (historyPage > totalPages) historyPage = totalPages; + + const start = (historyPage - 1) * HISTORY_PAGE_SIZE; + const pageSessions = allSessions.slice(start, start + HISTORY_PAGE_SIZE); + + historyListEl.innerHTML = pageSessions.map(s => ` +
+
+
${escapeHtml(s.title)}
+
+ ${formatTime(s.updatedAt)} + ${s.messages.length} 条消息 + ${s.model || '无模型'} +
+
+
+ + + + +
+
+ `).join(''); + + // 分页 + if (totalPages <= 1) { + historyPaginationEl.innerHTML = `共 ${allSessions.length} 条`; + return; + } + + let html = `共 ${allSessions.length} 条`; + html += '
'; + html += ``; + + const range = 2; + let pages = []; + for (let i = 1; i <= totalPages; i++) { + if (i === 1 || i === totalPages || (i >= historyPage - range && i <= historyPage + range)) { + pages.push(i); + } + } + let prev = 0; + for (const p of pages) { + if (p - prev > 1) html += ''; + html += ``; + prev = p; + } + + html += ``; + html += '
'; + historyPaginationEl.innerHTML = html; +} + +async function loadHistorySession(sessionId) { + const db = state.get(KEYS.DB); + if (!db) return; + + const session = await db.getSession(sessionId); + if (!session) return; + + state.set(KEYS.CURRENT_SESSION, session); + state.set(KEYS.IS_HISTORY_VIEW, true); + + document.querySelector('#historyBar').style.display = ''; + document.querySelector('#inputArea').style.display = 'none'; + + clearMessages(); + renderMessages(); + closeHistoryModal(); +} + +export function openHistoryModal() { + historyPage = 1; + historySearchQuery = ''; + document.querySelector('#historySearchInput').value = ''; + document.querySelector('#historySearchClear').style.display = 'none'; + loadHistory(); + historyModalEl.style.display = ''; +} + +export function closeHistoryModal() { + historyModalEl.style.display = 'none'; +} diff --git a/js/components/input-area.js b/js/components/input-area.js new file mode 100644 index 0000000..2aeb9b5 --- /dev/null +++ b/js/components/input-area.js @@ -0,0 +1,278 @@ +/** + * InputArea - 输入区域组件 + * 包含文本输入、图片上传、发送逻辑 + */ + +import { state, KEYS } from '../state.js'; +import { fileToBase64, debounce, truncate } from '../utils.js'; +import { getSelectedModel, isThinkEnabled } from './model-bar.js'; +import { + renderMessages, appendAssistantPlaceholder, appendSystemMessage, + updateLastAssistantMessage, clearMessages +} from './chat-area.js'; +import { showToast } from './toast.js'; +import { checkConnection } from './header.js'; + +let chatInputEl, btnSendEl, fileInputEl, imagePreviewEl; +let pendingImages = []; + +export function initInputArea() { + chatInputEl = document.querySelector('#chatInput'); + btnSendEl = document.querySelector('#btnSend'); + fileInputEl = document.querySelector('#fileInput'); + imagePreviewEl = document.querySelector('#imagePreview'); + + // 发送 + btnSendEl.addEventListener('click', sendMessage); + chatInputEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + + // 输入框自动调整 + chatInputEl.addEventListener('input', autoResizeTextarea); + + // 图片上传 + document.querySelector('#btnAttach').addEventListener('click', () => fileInputEl.click()); + fileInputEl.addEventListener('change', (e) => { + handleFileSelect(e.target.files); + e.target.value = ''; + }); + + // 删除图片预览 + imagePreviewEl.addEventListener('click', (e) => { + if (e.target.classList.contains('remove-img')) { + const idx = parseInt(e.target.dataset.index); + pendingImages.splice(idx, 1); + renderImagePreviews(); + } + }); +} + +function autoResizeTextarea() { + chatInputEl.style.height = 'auto'; + chatInputEl.style.height = Math.min(chatInputEl.scrollHeight, 120) + 'px'; +} + +async function handleFileSelect(files) { + const maxSize = 20 * 1024 * 1024; + + for (const file of files) { + if (!file.type.startsWith('image/')) continue; + if (file.size > maxSize) { + appendSystemMessage(`⚠️ 图片 ${file.name} 超过 20MB 限制`); + continue; + } + + try { + const base64 = await fileToBase64(file); + pendingImages.push({ name: file.name, base64 }); + renderImagePreviews(); + } catch (err) { + console.error('[InputArea] 图片读取失败:', err); + } + } +} + +function renderImagePreviews() { + if (pendingImages.length === 0) { + imagePreviewEl.style.display = 'none'; + imagePreviewEl.innerHTML = ''; + return; + } + imagePreviewEl.style.display = ''; + imagePreviewEl.innerHTML = pendingImages.map((img, i) => ` +
+ ${img.name} + +
+ `).join(''); +} + +function updateSendButton(streaming) { + if (streaming) { + btnSendEl.innerHTML = '
'; + btnSendEl.classList.add('disabled'); + } else { + btnSendEl.innerHTML = ` + + `; + btnSendEl.classList.remove('disabled'); + } +} + +/** + * 核心发送逻辑 + */ +export async function sendMessage() { + const text = chatInputEl.value.trim(); + if (!text && pendingImages.length === 0) return; + if (state.get(KEYS.IS_STREAMING)) return; + + const model = getSelectedModel(); + if (!model) { + appendSystemMessage('⚠️ 请先选择一个模型'); + return; + } + + const currentSession = state.get(KEYS.CURRENT_SESSION); + if (!currentSession) return; + + // 构造用户消息 + const now = Date.now(); + const msgsToAdd = []; + + if (pendingImages.length > 0) { + msgsToAdd.push({ + role: 'user', + content: '', + images: pendingImages.map(img => img.base64), + timestamp: now + }); + } + + if (text) { + msgsToAdd.push({ + role: 'user', + content: text, + timestamp: now + }); + } + + // 更新会话标题 + if (currentSession.messages.length === 0) { + currentSession.title = truncate(text || '[图片消息]', 30); + currentSession.model = model; + } + + msgsToAdd.forEach(m => currentSession.messages.push(m)); + renderMessages(); + + // 清空输入 + chatInputEl.value = ''; + pendingImages = []; + imagePreviewEl.style.display = 'none'; + imagePreviewEl.innerHTML = ''; + autoResizeTextarea(); + + // 助手占位 + appendAssistantPlaceholder(); + + // 流式回复 + state.set(KEYS.IS_STREAMING, true); + updateSendButton(true); + + const api = state.get(KEYS.API); + const abortController = new AbortController(); + state.set(KEYS.ABORT_CONTROLLER, abortController); + const thinkMode = isThinkEnabled(); + const systemPromptEnabled = state.get(KEYS.SYSTEM_PROMPT_ENABLED); + const systemPrompt = state.get(KEYS.SYSTEM_PROMPT); + const numCtx = state.get(KEYS.NUM_CTX, 24576); + + try { + let assistantContent = ''; + let thinkContent = ''; + let finalStats = null; + let modelName = ''; + + const apiMessages = currentSession.messages.map(m => ({ + role: m.role, + content: m.content || '', + ...(m.images && { images: m.images }) + })); + + const chatParams = { + model, + messages: apiMessages, + think: thinkMode, + ...(systemPromptEnabled && systemPrompt && { system: systemPrompt }), + ...(numCtx && { options: { num_ctx: numCtx } }) + }; + + await api.chatStream(chatParams, (chunk) => { + if (chunk.message) { + if (chunk.message.content) { + assistantContent += chunk.message.content; + } + const think = chunk.message.thinking || chunk.message.reasoning_content; + if (think && typeof think === 'string') { + thinkContent += think; + } + } + if (chunk.model) modelName = chunk.model; + updateLastAssistantMessage(assistantContent, thinkContent || null, null, modelName || null); + if (chunk.done) { + finalStats = { eval_count: chunk.eval_count, total_duration: chunk.total_duration }; + } + }); + + const assistantMsg = { + role: 'assistant', + content: assistantContent || '', + timestamp: Date.now(), + ...(modelName && { model: modelName }), + ...(thinkContent && { think: thinkContent }), + ...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration }) + }; + currentSession.messages.push(assistantMsg); + + if (finalStats) { + updateLastAssistantMessage(assistantContent, thinkContent || null, finalStats, modelName || null); + } + + await saveCurrentSession(); + + } catch (err) { + console.error('[InputArea] 流式聊天错误:', err); + + const placeholder = document.querySelector('#messagesContainer .message.assistant:last-child'); + const loadingDots = placeholder?.querySelector('.loading-dots'); + const loadingText = placeholder?.querySelector('.loading-text'); + if (loadingDots) loadingDots.remove(); + if (loadingText) loadingText.remove(); + + const assistantContent = ''; // 已在 catch 作用域外不可用,取 DOM 值 + const existingContent = placeholder?.querySelector('.msg-content')?.textContent || ''; + + if (existingContent) { + const partialMsg = { + role: 'assistant', + content: existingContent + '\n\n`[已中断]`', + timestamp: Date.now(), + }; + currentSession.messages.push(partialMsg); + const contentDiv = placeholder?.querySelector('.msg-content'); + if (contentDiv) contentDiv.innerHTML = `

${existingContent}

[已中断]

`; + saveCurrentSession(); + } else { + if (placeholder) placeholder.remove(); + } + + let errMsg = `❌ 错误: ${err.message}`; + if (err.message.includes('Failed to fetch') || err.message.includes('NetworkError')) { + errMsg = '❌ 连接失败。请检查:\n1. Ollama 是否正在运行\n2. 地址是否正确\n3. 是否设置了 OLLAMA_ORIGINS="*"'; + } + appendSystemMessage(errMsg); + + } finally { + state.set(KEYS.IS_STREAMING, false); + updateSendButton(false); + state.set(KEYS.ABORT_CONTROLLER, null); + } +} + +async function saveCurrentSession() { + const db = state.get(KEYS.DB); + const currentSession = state.get(KEYS.CURRENT_SESSION); + const isHistoryView = state.get(KEYS.IS_HISTORY_VIEW); + if (!currentSession || !db || isHistoryView) return; + currentSession.updatedAt = Date.now(); + try { + await db.saveSession(currentSession); + } catch (err) { + console.error('[InputArea] 保存会话失败:', err); + } +} diff --git a/js/components/lightbox.js b/js/components/lightbox.js new file mode 100644 index 0000000..794b64e --- /dev/null +++ b/js/components/lightbox.js @@ -0,0 +1,33 @@ +/** + * Lightbox - 图片预览组件 + */ + +let lightboxEl, lightboxImg; + +export function initLightbox() { + lightboxEl = document.querySelector('#lightbox'); + lightboxImg = document.querySelector('#lightboxImg'); + + // 关闭事件 + lightboxEl.addEventListener('click', (e) => { + if (e.target === lightboxEl) closeLightbox(); + }); + document.querySelector('#lightboxClose').addEventListener('click', closeLightbox); + + // 消息区域点击图片打开预览 + document.querySelector('#messagesContainer')?.addEventListener('click', (e) => { + if (e.target.dataset.lightbox === 'true') { + openLightbox(e.target.src); + } + }); +} + +export function openLightbox(src) { + lightboxImg.src = src; + lightboxEl.style.display = ''; +} + +export function closeLightbox() { + lightboxEl.style.display = 'none'; + lightboxImg.src = ''; +} diff --git a/js/components/model-bar.js b/js/components/model-bar.js new file mode 100644 index 0000000..befa501 --- /dev/null +++ b/js/components/model-bar.js @@ -0,0 +1,78 @@ +/** + * ModelBar - 模型选择栏组件 + */ + +import { state, KEYS } from '../state.js'; +import { formatSize } from '../utils.js'; +import { showToast } from './toast.js'; + +let modelSelectEl, toggleThinkEl; + +export function initModelBar() { + modelSelectEl = document.querySelector('#modelSelect'); + toggleThinkEl = document.querySelector('#toggleThink'); + + // 模型选择变化 → 保存到设置 + modelSelectEl.addEventListener('change', async () => { + const db = state.get(KEYS.DB); + if (db) await db.saveSetting('selectedModel', modelSelectEl.value); + }); +} + +/** + * 加载模型列表 + */ +export async function loadModels() { + const api = state.get(KEYS.API); + const db = state.get(KEYS.DB); + const currentSession = state.get(KEYS.CURRENT_SESSION); + + if (!api) return; + + try { + const data = await api.listModels(); + modelSelectEl.innerHTML = ''; + if (!data.models || data.models.length === 0) { + modelSelectEl.innerHTML = ''; + return; + } + + data.models.forEach(m => { + const opt = document.createElement('option'); + opt.value = m.name; + opt.textContent = `${m.name} (${formatSize(m.size)})`; + modelSelectEl.appendChild(opt); + }); + + // 恢复上次选择的模型 + if (currentSession && currentSession.model) { + modelSelectEl.value = currentSession.model; + } + + if (db) await db.saveSetting('selectedModel', modelSelectEl.value); + } catch (err) { + console.error('[ModelBar] 加载模型失败:', err); + modelSelectEl.innerHTML = ''; + } +} + +/** + * 获取当前选择的模型 + */ +export function getSelectedModel() { + return modelSelectEl.value; +} + +/** + * 设置选中的模型 + */ +export function setSelectedModel(modelName) { + modelSelectEl.value = modelName; +} + +/** + * Think 模式是否开启 + */ +export function isThinkEnabled() { + return toggleThinkEl.checked; +} diff --git a/js/components/settings-modal.js b/js/components/settings-modal.js new file mode 100644 index 0000000..76aaa21 --- /dev/null +++ b/js/components/settings-modal.js @@ -0,0 +1,169 @@ +/** + * SettingsModal - 设置面板组件 + */ + +import { state, KEYS } from '../state.js'; +import { debounce, formatSize } from '../utils.js'; +import { updateConnectionInfo, updateRunningModels } from './header.js'; +import { loadModels } from './model-bar.js'; +import { showToast } from './toast.js'; +import { OllamaAPI } from '../../ollama-api.js'; + +let settingsModalEl; + +export function initSettingsModal() { + settingsModalEl = document.querySelector('#settingsModal'); + + // 打开/关闭 + document.querySelector('#btnSettings').addEventListener('click', openSettingsModal); + document.querySelector('#btnCloseSettings').addEventListener('click', closeSettingsModal); + settingsModalEl.addEventListener('click', (e) => { + if (e.target === settingsModalEl) closeSettingsModal(); + }); + + // 保存服务器地址 + const saveServerUrl = debounce(async () => { + const url = document.querySelector('#inputServerUrl').value.trim(); + if (!url) return; + const db = state.get(KEYS.DB); + const api = new OllamaAPI(url); + state.set(KEYS.API, api); + if (db) await db.saveSetting('serverUrl', url); + updateConnectionInfo(); + loadModels(); + }, 500); + document.querySelector('#inputServerUrl').addEventListener('input', saveServerUrl); + + // 系统提示词开关 + document.querySelector('#toggleSystemPrompt').addEventListener('change', async (e) => { + const db = state.get(KEYS.DB); + state.set(KEYS.SYSTEM_PROMPT_ENABLED, e.target.checked); + document.querySelector('#inputSystemPrompt').disabled = !e.target.checked; + if (db) await db.saveSetting('systemPromptEnabled', e.target.checked); + }); + + // 保存系统提示词 + const saveSystemPrompt = debounce(async () => { + const db = state.get(KEYS.DB); + const val = document.querySelector('#inputSystemPrompt').value.trim(); + state.set(KEYS.SYSTEM_PROMPT, val); + if (db) await db.saveSetting('systemPrompt', val); + }, 500); + document.querySelector('#inputSystemPrompt').addEventListener('input', saveSystemPrompt); + + // 保存上下文长度 + const saveNumCtx = debounce(async () => { + const db = state.get(KEYS.DB); + const val = document.querySelector('#inputNumCtx').value.trim(); + const numCtx = val ? parseInt(val) : 24576; + state.set(KEYS.NUM_CTX, numCtx); + if (db) await db.saveSetting('numCtx', numCtx); + }, 500); + document.querySelector('#inputNumCtx').addEventListener('input', saveNumCtx); + + // 释放显存 + document.querySelector('#btnReleaseVRAM').addEventListener('click', async () => { + const api = state.get(KEYS.API); + const model = document.querySelector('#modelSelect').value; + try { + await api.chat({ model: model || 'any', messages: [], keep_alive: 0 }); + showToast('显存已释放', 'success'); + updateRunningModels(); + } catch (err) { + showToast(`释放失败: ${err.message}`, 'error'); + } + }); + + // 清空所有历史 + document.querySelector('#btnClearAllHistory').addEventListener('click', async () => { + const db = state.get(KEYS.DB); + if (confirm('确定清空所有历史记录?此操作不可恢复!')) { + await db.clearAll(); + // 触发新会话 + document.querySelector('#btnNewChat').click(); + showToast('已清空所有历史记录', 'success'); + } + }); + + // 导出全部会话 + document.querySelector('#btnExportAll').addEventListener('click', exportAllSessions); + + // 导入会话 + const importFileInput = document.querySelector('#importFileInput'); + document.querySelector('#btnImportSessions').addEventListener('click', () => importFileInput.click()); + importFileInput.addEventListener('change', (e) => { + if (e.target.files.length > 0) { + importSessions(e.target.files[0]); + e.target.value = ''; + } + }); +} + +export function openSettingsModal() { + settingsModalEl.style.display = ''; + document.querySelector('#inputServerUrl').value = state.get(KEYS.API)?.baseUrl || ''; + updateConnectionInfo(); + updateRunningModels(); +} + +export function closeSettingsModal() { + settingsModalEl.style.display = 'none'; +} + +async function exportAllSessions() { + const db = state.get(KEYS.DB); + if (!db) return; + const sessions = await db.getAllSessions(); + if (sessions.length === 0) { + showToast('没有可导出的会话', 'warning'); + return; + } + + const backup = { + app: 'Metona Ollama Client', + version: 1, + exportedAt: new Date().toISOString(), + count: sessions.length, + sessions + }; + + const json = JSON.stringify(backup, null, 2); + const ts = new Date().toISOString().slice(0, 10); + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([json], { type: 'application/json;charset=utf-8' })); + a.download = `metona-backup-${ts}.json`; + a.click(); + showToast(`已导出 ${sessions.length} 个会话`, 'success'); +} + +async function importSessions(file) { + const db = state.get(KEYS.DB); + if (!db) return; + + try { + const text = await file.text(); + let data; + try { + data = JSON.parse(text); + } catch { + showToast('文件格式错误:不是有效的 JSON', 'error'); + return; + } + + let sessions; + if (Array.isArray(data)) { + sessions = data; + } else if (data.sessions && Array.isArray(data.sessions)) { + sessions = data.sessions; + } else { + showToast('文件格式错误:未找到会话数据', 'error'); + return; + } + + const result = await db.importSessions(sessions); + showToast(`导入完成:${result.imported} 个会话${result.skipped > 0 ? `,跳过 ${result.skipped} 个` : ''}`, 'success', 4000); + } catch (err) { + console.error('[Settings] 导入失败:', err); + showToast(`导入失败: ${err.message}`, 'error'); + } +} diff --git a/js/components/toast.js b/js/components/toast.js new file mode 100644 index 0000000..8f60d9a --- /dev/null +++ b/js/components/toast.js @@ -0,0 +1,30 @@ +/** + * Toast - 通知组件 + */ + +let toastContainer = null; + +export function initToast() { + toastContainer = document.querySelector('#toastContainer'); +} + +/** + * 显示 Toast 通知 + * @param {string} text - 通知文本 + * @param {'info'|'success'|'warning'|'error'} type - 通知类型 + * @param {number} duration - 显示时长 (ms) + */ +export function showToast(text, type = 'info', duration = 3000) { + if (!toastContainer) return; + + const iconMap = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' }; + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.innerHTML = `${iconMap[type] || 'ℹ'}${text}`; + toastContainer.appendChild(toast); + + setTimeout(() => { + toast.classList.add('removing'); + toast.addEventListener('animationend', () => toast.remove()); + }, duration); +} diff --git a/js/marked-config.js b/js/marked-config.js new file mode 100644 index 0000000..145fe71 --- /dev/null +++ b/js/marked-config.js @@ -0,0 +1,38 @@ +/** + * Markdown 渲染器配置 + * 封装 marked.js,配置自定义渲染器和安全策略 + */ + +import { marked } from '../marked.esm.js'; +import { SAFE_URI_SCHEMES } from './sanitizer.js'; + +// 配置 marked +marked.setOptions({ + breaks: true, // 换行转
+ gfm: true, // GitHub 风格 Markdown +}); + +// 自定义渲染器:链接新窗口打开 + 安全属性 +const renderer = new marked.Renderer(); + +renderer.link = function ({ href, title, text }) { + const safe = SAFE_URI_SCHEMES.has(new URL(href, 'https://placeholder').protocol); + const t = title ? ` title="${title}"` : ''; + if (!safe) { + return `${text}`; + } + return `${text}`; +}; + +renderer.image = function ({ href, title, text }) { + const safe = SAFE_URI_SCHEMES.has(new URL(href, 'https://placeholder').protocol); + if (!safe && !/^data:image\//i.test(href)) { + return `[已阻止不安全图片: ${text}]`; + } + const t = title ? ` title="${title}"` : ''; + return `${text}`; +}; + +marked.use({ renderer }); + +export { marked }; diff --git a/js/sanitizer.js b/js/sanitizer.js new file mode 100644 index 0000000..66e5389 --- /dev/null +++ b/js/sanitizer.js @@ -0,0 +1,88 @@ +/** + * Sanitizer - HTML 净化器 + * 基于白名单策略,防御 XSS 攻击 + */ + +export const SAFE_URI_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:', 'ftp:', 'ftps:', 'xmpp:']); +export const SAFE_DATA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']); + +const ALLOWED_TAGS = new Set([ + 'a', 'abbr', 'b', 'blockquote', 'br', 'code', 'del', 'details', 'div', + 'dl', 'dt', 'dd', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', + 'i', 'img', 'ins', 'kbd', 'li', 'ol', 'p', 'pre', 'q', 'rp', 'rt', + 'ruby', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', + 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', + 'tr', 'u', 'ul', 'var', 'input', +]); + +const ALLOWED_ATTRS = new Set([ + 'href', 'src', 'alt', 'title', 'width', 'height', 'align', + 'target', 'rel', 'class', 'id', 'type', 'checked', 'disabled', + 'start', 'value', 'scope', 'rowspan', 'colspan', 'lang', +]); + +const DANGEROUS_PREFIXES = ['on', 'xlink:href', 'xmlns:']; + +/** + * 净化 HTML 字符串,移除危险标签和属性 + * @param {string} html - 原始 HTML + * @returns {string} 净化后的 HTML + */ +export function sanitize(html) { + try { + const template = document.createElement('template'); + template.innerHTML = html; + const root = template.content; + + // 删除注释节点 + const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT); + const comments = []; + while (walker.nextNode()) comments.push(walker.currentNode); + comments.forEach(c => c.remove()); + + // 遍历所有元素(先收集再处理,避免动态集合问题) + const elements = [...root.querySelectorAll('*')]; + for (const el of elements) { + if (!el.parentNode) continue; + + const tag = el.tagName.toLowerCase(); + + if (!ALLOWED_TAGS.has(tag)) { + while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el); + el.remove(); + continue; + } + + // 清理属性 + for (const attr of [...el.attributes]) { + const name = attr.name.toLowerCase(); + + if (DANGEROUS_PREFIXES.some(p => name.startsWith(p))) { + el.removeAttribute(attr.name); + continue; + } + + if (!ALLOWED_ATTRS.has(name)) { + el.removeAttribute(attr.name); + continue; + } + + // URI 协议检查 + if ((name === 'href' || name === 'src' || name === 'action') && attr.value) { + const val = attr.value.trim().toLowerCase(); + if (/^(javascript|vbscript|data|file):/i.test(val)) { + const mimeMatch = val.match(/^data:([^;,]+)/i); + if (!mimeMatch || !SAFE_DATA_TYPES.has(mimeMatch[1].toLowerCase())) { + el.removeAttribute(attr.name); + } + } + } + } + } + + return root.innerHTML; + } catch (e) { + console.warn('[Sanitizer] 净化失败,返回原始文本:', e); + return html; + } +} diff --git a/js/state.js b/js/state.js new file mode 100644 index 0000000..46b82c9 --- /dev/null +++ b/js/state.js @@ -0,0 +1,77 @@ +/** + * State - 轻量级响应式状态管理 + * 使用发布-订阅模式,状态变更时通知所有订阅者 + */ + +class AppState { + constructor() { + this._state = {}; + this._listeners = new Map(); // key → Set + } + + /** + * 设置状态值并通知订阅者 + * @param {string} key - 状态键名 + * @param {*} value - 状态值 + */ + set(key, value) { + const old = this._state[key]; + this._state[key] = value; + if (old !== value && this._listeners.has(key)) { + for (const cb of this._listeners.get(key)) { + try { cb(value, old); } catch (e) { console.error(`[State] 监听器错误 (${key}):`, e); } + } + } + } + + /** + * 获取状态值 + * @param {string} key - 状态键名 + * @param {*} [defaultValue] - 默认值 + * @returns {*} + */ + get(key, defaultValue = undefined) { + return key in this._state ? this._state[key] : defaultValue; + } + + /** + * 订阅状态变更 + * @param {string} key - 状态键名 + * @param {function} callback - 回调函数 (newValue, oldValue) => void + * @returns {function} 取消订阅函数 + */ + on(key, callback) { + if (!this._listeners.has(key)) { + this._listeners.set(key, new Set()); + } + this._listeners.get(key).add(callback); + return () => this._listeners.get(key)?.delete(callback); + } + + /** + * 批量设置初始状态 + * @param {Object} initial - 初始状态对象 + */ + init(initial) { + Object.assign(this._state, initial); + } +} + +// 单例 +export const state = new AppState(); + +// ── 状态键名常量 ── +export const KEYS = { + DB: 'db', + API: 'api', + CURRENT_SESSION: 'currentSession', + IS_STREAMING: 'isStreaming', + IS_HISTORY_VIEW: 'isHistoryView', + PENDING_IMAGES: 'pendingImages', + ABORT_CONTROLLER: 'abortController', + SYSTEM_PROMPT: 'systemPrompt', + SYSTEM_PROMPT_ENABLED: 'systemPromptEnabled', + NUM_CTX: 'numCtx', + HISTORY_PAGE: 'historyPage', + HISTORY_SEARCH: 'historySearchQuery', +}; diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..bb9d03a --- /dev/null +++ b/js/utils.js @@ -0,0 +1,73 @@ +/** + * Utils - 通用工具函数 + */ + +/** 生成唯一 ID */ +export function generateId() { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +/** 格式化时间戳 */ +export function formatTime(ts) { + const d = new Date(ts); + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** 截断文本 */ +export function truncate(str, len = 50) { + if (!str) return ''; + return str.length > len ? str.substring(0, len) + '...' : str; +} + +/** 防抖 */ +export function debounce(fn, ms = 300) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }; +} + +/** 格式化字节大小 */ +export function formatSize(bytes) { + if (!bytes) return ''; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let size = bytes; + while (size >= 1024 && i < units.length - 1) { + size /= 1024; + i++; + } + return `${size.toFixed(1)} ${units[i]}`; +} + +/** HTML 转义 */ +export function escapeHtml(str) { + const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + return String(str).replace(/[&<>"']/g, (c) => map[c]); +} + +/** 下载文件 */ +export function downloadFile(filename, content, type) { + const blob = new Blob([content], { type: type + ';charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +/** File → Base64(去前缀) */ +export function fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const base64 = reader.result.split(',')[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +}