refactor: 模块化架构重构

将单文件巨型 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行纯模板
This commit is contained in:
thzxx
2026-04-03 11:34:24 +08:00
parent 41128d1937
commit 38edaee0f0
14 changed files with 1628 additions and 1409 deletions
+180
View File
@@ -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();
+276
View File
@@ -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)
? `<span class="model-tag">${escapeHtml(msg.model)}</span>`
: '';
let contentHtml = '';
const safeContent = (msg.content != null) ? String(msg.content) : '';
if (msg.role === 'assistant') {
// 思考块
if (msg.think) {
contentHtml += `
<div class="think-block">
<div class="think-header" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'; this.querySelector('.chevron').classList.toggle('expanded');">
<span>🤔 思考过程</span>
<svg class="chevron expanded" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="think-content">
<pre>${escapeHtml(msg.think)}</pre>
</div>
</div>`;
}
contentHtml += `<div class="msg-content">${safeMarkdown(safeContent)}</div>`;
} else {
contentHtml += `<div class="msg-content">${escapeHtml(safeContent)}</div>`;
}
// 图片
if (msg.images && msg.images.length > 0) {
contentHtml += '<div class="msg-images">';
msg.images.forEach(() => {
contentHtml += `<img class="msg-img" src="data:image/png;base64,${msg.images[0]}" alt="用户图片" data-lightbox="true">`;
});
contentHtml += '</div>';
}
// 纯图片消息隐藏空内容
if (msg.role === 'user' && (!msg.content || msg.content.trim() === '') && msg.images?.length > 0) {
contentHtml = contentHtml.replace('<div class="msg-content"></div>', '');
}
// 统计信息
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 += `<div class="msg-stats">${stats.map(s => `<span>${s}</span>`).join(' · ')}</div>`;
}
div.innerHTML = `
<div class="msg-avatar">${avatar}</div>
<div class="msg-body">
<div class="msg-role">${roleLabel}${modelTag}</div>
${contentHtml}
</div>
`;
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 = `
<div class="think-header" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'; this.querySelector('.chevron').classList.toggle('expanded');">
<span>🤔 思考过程</span>
<svg class="chevron expanded" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="think-content"><pre></pre></div>
`;
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 = `
<div class="msg-avatar">🤖</div>
<div class="msg-body">
<div class="msg-role">AI</div>
<div class="msg-content">
<div class="loading-dots">
<span></span><span></span><span></span>
</div>
<span class="loading-text">正在思考...</span>
</div>
</div>
`;
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 = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${escapeHtml(session.title)}</title>
<style>body{max-width:800px;margin:0 auto;padding:20px;font-family:system-ui;line-height:1.6;background:#0a0a1a;color:#e8e8f0;}
.user{background:rgba(25,25,60,0.5);padding:12px;border-radius:8px;margin:8px 0;}
.assistant{background:rgba(0,245,212,0.03);padding:12px;border-radius:8px;margin:8px 0;border:1px solid rgba(0,245,212,0.08);}
h1{background:linear-gradient(135deg,#00f5d4,#7b2ff7);-webkit-background-clip:text;-webkit-text-fill-color:transparent;}
pre{background:rgba(0,0,0,0.3);padding:12px;border-radius:8px;overflow-x:auto;}
code{background:rgba(0,0,0,0.2);padding:2px 6px;border-radius:4px;}</style></head><body>
<h1>${escapeHtml(session.title)}</h1><p>${formatTime(session.createdAt)} · ${session.model}</p><hr>`;
session.messages.forEach(m => {
html += `<div class="${m.role}"><strong>${m.role === 'user' ? '👤 用户' : '🤖 AI'}</strong><br>${(m.content || '').replace(/\n/g, '<br>')}</div>`;
});
html += '</body></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');
}
+98
View File
@@ -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 = '<p class="text-muted">无运行中的模型</p>';
return;
}
container.innerHTML = data.models.map(m => `
<div class="running-model">
<span class="model-name">${m.name}</span>
<span class="model-vram">${formatSize(m.size_vram || 0)} VRAM</span>
</div>
`).join('');
} catch {
container.innerHTML = '<p class="text-muted">无法获取运行信息</p>';
}
}
+205
View File
@@ -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 = `
<div class="empty-history">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="rgba(85,85,112,0.5)" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<p class="text-muted">暂无历史记录</p>
</div>`;
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 => `
<div class="history-item" data-id="${s.id}">
<div class="history-info" data-id="${s.id}">
<div class="history-title">${escapeHtml(s.title)}</div>
<div class="history-meta">
<span>${formatTime(s.updatedAt)}</span>
<span>${s.messages.length} 条消息</span>
<span>${s.model || '无模型'}</span>
</div>
</div>
<div class="history-actions">
<button class="icon-btn sm btn-export-md" data-id="${s.id}" title="导出为 Markdown">📄</button>
<button class="icon-btn sm btn-export-html" data-id="${s.id}" title="导出为 HTML">🌐</button>
<button class="icon-btn sm btn-export-txt" data-id="${s.id}" title="导出为 TXT">📝</button>
<button class="icon-btn sm danger btn-delete-session" data-id="${s.id}" title="删除">🗑️</button>
</div>
</div>
`).join('');
// 分页
if (totalPages <= 1) {
historyPaginationEl.innerHTML = `<span class="page-info">共 ${allSessions.length} 条</span>`;
return;
}
let html = `<span class="page-info">共 ${allSessions.length} 条</span>`;
html += '<div class="page-buttons">';
html += `<button class="page-btn${historyPage <= 1 ? ' disabled' : ''}" data-page="${historyPage - 1}"></button>`;
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 += '<span class="page-ellipsis">…</span>';
html += `<button class="page-btn${p === historyPage ? ' active' : ''}" data-page="${p}">${p}</button>`;
prev = p;
}
html += `<button class="page-btn${historyPage >= totalPages ? ' disabled' : ''}" data-page="${historyPage + 1}"></button>`;
html += '</div>';
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';
}
+278
View File
@@ -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) => `
<div class="preview-thumb">
<img src="data:image/png;base64,${img.base64}" alt="${img.name}">
<button class="remove-img" data-index="${i}">×</button>
</div>
`).join('');
}
function updateSendButton(streaming) {
if (streaming) {
btnSendEl.innerHTML = '<div class="spinner"></div>';
btnSendEl.classList.add('disabled');
} else {
btnSendEl.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>`;
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 = `<p>${existingContent}</p><p><code>[已中断]</code></p>`;
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);
}
}
+33
View File
@@ -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 = '';
}
+78
View File
@@ -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 = '<option value="">未安装任何模型</option>';
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 = '<option value="">加载失败 - 检查连接</option>';
}
}
/**
* 获取当前选择的模型
*/
export function getSelectedModel() {
return modelSelectEl.value;
}
/**
* 设置选中的模型
*/
export function setSelectedModel(modelName) {
modelSelectEl.value = modelName;
}
/**
* Think 模式是否开启
*/
export function isThinkEnabled() {
return toggleThinkEl.checked;
}
+169
View File
@@ -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');
}
}
+30
View File
@@ -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 = `<span class="toast-icon">${iconMap[type] || ''}</span><span>${text}</span>`;
toastContainer.appendChild(toast);
setTimeout(() => {
toast.classList.add('removing');
toast.addEventListener('animationend', () => toast.remove());
}, duration);
}
+38
View File
@@ -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, // 换行转 <br>
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 `<span class="blocked-link" title="已阻止不安全链接">${text}</span>`;
}
return `<a href="${href}"${t} target="_blank" rel="noopener noreferrer">${text}</a>`;
};
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 `<span class="blocked-link">[已阻止不安全图片: ${text}]</span>`;
}
const t = title ? ` title="${title}"` : '';
return `<img src="${href}" alt="${text}"${t} loading="lazy">`;
};
marked.use({ renderer });
export { marked };
+88
View File
@@ -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;
}
}
+77
View File
@@ -0,0 +1,77 @@
/**
* State - 轻量级响应式状态管理
* 使用发布-订阅模式,状态变更时通知所有订阅者
*/
class AppState {
constructor() {
this._state = {};
this._listeners = new Map(); // key → Set<callback>
}
/**
* 设置状态值并通知订阅者
* @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',
};
+73
View File
@@ -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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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);
});
}