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:
+4
-1408
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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>';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = '';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user