Files
metona-ollama-desktop/index.html
T

1609 lines
65 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0a0a1a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="description" content="Metona Ollama Client - 流式聊天、多模态、历史管理">
<link rel="manifest" href="manifest.json">
<link rel="icon" href="llama.ico" type="image/x-icon">
<title>Metona Ollama</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<!-- ═══════════════ 顶部导航 ═══════════════ -->
<header class="app-header">
<div class="header-left">
<span class="logo">🦙</span>
<span class="app-title">Metona Ollama</span>
<span class="app-version">v1.1.0</span>
</div>
<div class="header-right">
<div class="conn-status pending" id="connStatus" title="连接状态">
<span class="status-dot"></span>
<span class="status-label">未连接</span>
</div>
<button class="icon-btn" id="btnNewChat" title="新建会话">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
<button class="icon-btn" id="btnHistory" title="历史记录">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
</button>
<button class="icon-btn" id="btnSettings" title="设置">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
</button>
</div>
</header>
<!-- ═══════════════ 模型选择栏 ═══════════════ -->
<div class="model-bar">
<select class="model-select" id="modelSelect">
<option value="">正在加载模型...</option>
</select>
<label class="toggle-label" title="Think 模式:启用深度推理">
<input type="checkbox" id="toggleThink">
<span class="toggle-slider"></span>
<span>Think</span>
</label>
</div>
<!-- ═══════════════ 聊天区域 ═══════════════ -->
<main class="chat-area" id="chatArea">
<!-- 空状态 -->
<div class="empty-state" id="emptyState">
<div class="empty-icon">
<svg viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="100" cy="100" r="80" stroke="rgba(0,245,212,0.2)" stroke-width="2"/>
<circle cx="100" cy="100" r="60" stroke="rgba(123,47,247,0.2)" stroke-width="2"/>
<path d="M70 90c0-16.57 13.43-30 30-30s30 13.43 30 30" stroke="rgba(0,245,212,0.5)" stroke-width="2" stroke-linecap="round"/>
<circle cx="85" cy="100" r="5" fill="rgba(0,245,212,0.6)"/>
<circle cx="115" cy="100" r="5" fill="rgba(0,245,212,0.6)"/>
<path d="M88 120c4 4 20 4 24 0" stroke="rgba(0,245,212,0.4)" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<h2>开始对话</h2>
<p>选择一个模型,输入消息开始聊天</p>
</div>
<!-- 消息列表 -->
<div class="messages" id="messagesContainer" style="display:none;"></div>
</main>
<!-- ═══════════════ 历史只读提示栏 ═══════════════ -->
<div class="history-view-bar" id="historyBar" style="display:none;">
<span>📖 正在查看历史记录(只读模式)</span>
<button class="btn btn-sm btn-outline" id="btnExitHistory">返回新会话</button>
</div>
<!-- ═══════════════ 输入区域 ═══════════════ -->
<div class="input-area" id="inputArea">
<div class="image-preview" id="imagePreview" style="display:none;"></div>
<div class="input-row">
<button class="icon-btn attach-btn" id="btnAttach" title="上传图片">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</button>
<input type="file" id="fileInput" accept="image/*" multiple style="display:none;">
<textarea class="chat-input" id="chatInput" placeholder="输入消息..." rows="1"></textarea>
<button class="send-btn" id="btnSend">
<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>
</button>
</div>
</div>
</div>
<!-- ═══════════════ 设置模态框 ═══════════════ -->
<div class="modal-overlay" id="settingsModal" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3>⚙️ 设置</h3>
<button class="icon-btn" id="btnCloseSettings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<!-- 连接配置 -->
<div class="setting-group">
<label class="setting-label">Ollama 服务地址</label>
<input class="setting-input" id="inputServerUrl" type="url" placeholder="http://127.0.0.1:11434">
<div class="connection-info" id="connInfo">
<span class="status-badge" id="connBadge">检测中...</span>
<span class="server-version" id="serverVersion"></span>
</div>
<div class="cors-hint" id="corsHint" style="display:none;">
<p>⚠️ 连接失败,可能是 CORS 问题。请在启动 Ollama 时设置环境变量:</p>
<code>OLLAMA_ORIGINS="*" ollama serve</code>
</div>
</div>
<!-- 模型参数 -->
<div class="setting-group">
<label class="setting-label">
系统提示词(System Prompt
<label class="toggle-label" style="margin-left:auto;display:inline-flex;">
<input type="checkbox" id="toggleSystemPrompt">
<span class="toggle-slider"></span>
</label>
</label>
<textarea class="setting-input" id="inputSystemPrompt" rows="4" placeholder="例如:你是一个专业的编程助手,用中文回答。" style="resize:vertical;min-height:80px;" disabled></textarea>
<label class="setting-label" style="margin-top:12px;">上下文长度(num_ctx</label>
<div style="display:flex;gap:8px;align-items:center;">
<input class="setting-input" id="inputNumCtx" type="number" min="512" max="131072" step="512" value="24576" style="margin-bottom:0;">
<span class="text-muted" style="white-space:nowrap;">tokens</span>
</div>
<p class="text-muted" style="margin-top:4px;font-size:11px;">常见值:2048 / 4096 / 8192 / 32768。值越大上下文越长,显存占用越高。</p>
</div>
<!-- 运行中的模型 -->
<div class="setting-group">
<label class="setting-label">运行中的模型</label>
<div class="running-models" id="runningModels">
<p class="text-muted">无运行中的模型</p>
</div>
</div>
<!-- 操作按钮 -->
<div class="setting-group">
<label class="setting-label">显存管理</label>
<button class="btn btn-danger" id="btnReleaseVRAM">释放显存(卸载模型)</button>
</div>
<!-- 数据管理 -->
<div class="setting-group">
<label class="setting-label">数据管理</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn btn-outline" id="btnExportAll">📦 导出全部会话</button>
<button class="btn btn-outline" id="btnImportSessions">📥 导入会话</button>
<input type="file" id="importFileInput" accept=".json" style="display:none;">
<button class="btn btn-danger" id="btnClearAllHistory">清空所有历史</button>
</div>
<p class="text-muted" style="margin-top:6px;font-size:11px;">导出格式为 JSON,可备份后在其他设备导入恢复。</p>
</div>
</div>
</div>
</div>
<!-- ═══════════════ 历史记录模态框 ═══════════════ -->
<div class="modal-overlay" id="historyModal" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3>🕐 历史记录</h3>
<button class="icon-btn" id="btnCloseHistory">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="history-search-wrap">
<input class="history-search-input" id="historySearchInput" type="text" placeholder="搜索会话标题或内容...">
<button class="history-search-clear" id="historySearchClear" style="display:none;"></button>
</div>
<div class="history-list" id="historyList">
<!-- 动态生成 -->
</div>
<div class="history-pagination" id="historyPagination"></div>
</div>
</div>
</div>
<!-- ═══════════════ 图片预览 Lightbox ═══════════════ -->
<div class="lightbox-overlay" id="lightbox" style="display:none;">
<button class="lightbox-close" id="lightboxClose"></button>
<img class="lightbox-img" id="lightboxImg" src="" alt="预览">
</div>
<!-- ═══════════════ Toast 通知容器 ═══════════════ -->
<div class="toast-container" id="toastContainer"></div>
<!-- ═══════════════ 主应用逻辑 ═══════════════ -->
<script type="module">
import { ChatDB } from './chat-db.js';
import { OllamaAPI } from './ollama-api.js';
import { marked } from './marked.esm.js';
console.log('[Metona] 代码版本: v3-fix ' + new Date().toISOString());
// 配置 marked
marked.setOptions({
breaks: true, // 换行转 <br>
gfm: true, // GitHub 风格 Markdown
});
// ══════════════════════════════════════════════════════════════
// HTML Sanitizer — 防止 marked 输出中的 XSS
// ══════════════════════════════════════════════════════════════
const SAFE_URI_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:', 'ftp:', 'ftps:', 'xmpp:']);
const SAFE_DATA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']);
/**
* 净化 HTML 字符串,移除危险标签和属性
* 基于白名单策略,保留 Markdown 常用标签
*/
function sanitize(html) {
try {
const template = document.createElement('template');
template.innerHTML = html;
const root = template.content;
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:'];
// 删除注释节点
const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
const comments = [];
while (walker.nextNode()) comments.push(walker.currentNode);
comments.forEach(c => c.remove());
// 用 querySelectorAll 安全遍历所有元素(先收集再处理)
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;
}
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) {
return html;
}
}
// 自定义渲染器:链接新窗口打开 + 安全属性
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 }) {
// 图片 src 也需要协议检查
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 });
/**
* Metona Ollama Client - 主应用
* 使用 IIFE 避免全局变量污染
*/
const App = (() => {
// ── DOM 元素引用 ──
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
const DOM = {
chatArea: $('#chatArea'),
messagesContainer: $('#messagesContainer'),
emptyState: $('#emptyState'),
chatInput: $('#chatInput'),
btnSend: $('#btnSend'),
btnAttach: $('#btnAttach'),
fileInput: $('#fileInput'),
imagePreview: $('#imagePreview'),
modelSelect: $('#modelSelect'),
toggleThink: $('#toggleThink'),
connStatus: $('#connStatus'),
settingsModal: $('#settingsModal'),
historyModal: $('#historyModal'),
historyList: $('#historyList'),
historyPagination: $('#historyPagination'),
historyBar: $('#historyBar'),
inputArea: $('#inputArea'),
lightbox: $('#lightbox'),
lightboxImg: $('#lightboxImg'),
};
// ── 状态 ──
let db = null; // ChatDB 实例
let api = null; // OllamaAPI 实例
let currentSession = null; // 当前会话 { id, title, model, messages, createdAt, updatedAt }
let isStreaming = false; // 是否正在流式回复中
let isHistoryView = false; // 是否在历史只读模式
let pendingImages = []; // 待发送的图片 (base64)
let abortController = null; // 用于取消流式请求
let systemPrompt = ''; // 系统提示词
let systemPromptEnabled = false; // 系统提示词开关
let numCtx = 24576; // 上下文长度
let historyPage = 1; // 历史记录当前页码
let historySearchQuery = ''; // 历史搜索关键词
const HISTORY_PAGE_SIZE = 10; // 每页条数
// ── 工具函数 ──
/** 生成唯一 ID */
function generateId() {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/** 格式化时间 */
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())}`;
}
/** 截断文本 */
function truncate(str, len = 50) {
if (!str) return '';
return str.length > len ? str.substring(0, len) + '...' : str;
}
/** 防抖 */
function debounce(fn, ms = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
// ── 会话管理 ──
/** 创建新会话 */
function createNewSession() {
const model = DOM.modelSelect.value || '';
return {
id: generateId(),
title: '新会话',
model: model,
messages: [],
createdAt: Date.now(),
updatedAt: Date.now()
};
}
/** 初始化新会话 */
function startNewSession() {
currentSession = createNewSession();
isHistoryView = false;
DOM.historyBar.style.display = 'none';
DOM.inputArea.style.display = '';
DOM.messagesContainer.innerHTML = ''; // 清空旧 DOM
renderMessages();
DOM.chatInput.focus();
}
/** 保存当前会话到数据库 */
async function saveCurrentSession() {
if (!currentSession || !db || isHistoryView) return;
currentSession.updatedAt = Date.now();
try {
await db.saveSession(currentSession);
} catch (err) {
console.error('[App] 保存会话失败:', err);
}
}
// ── 渲染消息 ──
/** 渲染所有消息 */
function renderMessages() {
const msgs = currentSession ? currentSession.messages : [];
if (msgs.length === 0) {
DOM.emptyState.style.display = '';
DOM.messagesContainer.style.display = 'none';
return;
}
DOM.emptyState.style.display = 'none';
DOM.messagesContainer.style.display = '';
// 增量渲染:只追加新消息
const existingCount = DOM.messagesContainer.children.length;
for (let i = existingCount; i < msgs.length; i++) {
appendMessageDOM(msgs[i], i);
}
scrollToBottom();
}
/** 安全地将 Markdown 转为 HTML,失败则返回转义后的纯文本 */
function safeMarkdown(text) {
if (!text) return '';
try {
const result = marked.parse(String(text));
return typeof result === 'string' ? result : escapeHtml(String(text));
} catch (e) {
return escapeHtml(String(text));
}
}
/** 追加单条消息 DOM */
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';
// 模型名称标签(仅 assistant 消息)
const modelTag = (msg.role === 'assistant' && msg.model)
? `<span class="model-tag">${escapeHtml(msg.model)}</span>`
: '';
let contentHtml = '';
// 安全获取内容,防御 null/undefined
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((img, i) => {
contentHtml += `<img class="msg-img" src="data:image/png;base64,${img}" alt="用户图片" data-lightbox="true">`;
});
contentHtml += '</div>';
}
// 纯图片消息隐藏空内容区域
if (msg.role === 'user' && (!msg.content || msg.content.trim() === '') && msg.images && 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>
`;
DOM.messagesContainer.appendChild(div);
}
/** 更新最后一条 assistant 消息(用于流式输出) */
function updateLastAssistantMessage(content, think, stats, model) {
const lastMsg = DOM.messagesContainer.querySelector('.message.assistant:last-child');
if (!lastMsg) return;
// 移除 loading 动画
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 消息占位 */
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>
`;
DOM.messagesContainer.appendChild(div);
scrollToBottom();
}
/** 追加系统提示消息 */
function appendSystemMessage(text, type = 'info') {
const div = document.createElement('div');
div.style.cssText = 'text-align:center;padding:8px;font-size:12px;color:var(--text-muted);';
div.textContent = text;
DOM.emptyState.style.display = 'none';
DOM.messagesContainer.style.display = '';
DOM.messagesContainer.appendChild(div);
scrollToBottom();
}
/** 右侧弹出 Toast 通知 */
function showToast(text, type = 'info', duration = 3000) {
const iconMap = { success: '✓', error: '✗', warning: '⚠', info: '' };
const container = document.querySelector('#toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `<span class="toast-icon">${iconMap[type] || ''}</span><span>${text}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('removing');
toast.addEventListener('animationend', () => toast.remove());
}, duration);
}
/** 打开图片预览 */
function openLightbox(src) {
DOM.lightboxImg.src = src;
DOM.lightbox.style.display = '';
}
/** 关闭图片预览 */
function closeLightbox() {
DOM.lightbox.style.display = 'none';
DOM.lightboxImg.src = '';
}
/** 滚动到底部 */
function scrollToBottom() {
requestAnimationFrame(() => {
DOM.chatArea.scrollTop = DOM.chatArea.scrollHeight;
});
}
/** 转义 HTML(本地快捷方法) */
function escapeHtml(str) {
const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
return String(str).replace(/[&<>\"']/g, (c) => map[c]);
}
// ── 发送消息 ──
/** 核心发送逻辑 */
async function sendMessage() {
const text = DOM.chatInput.value.trim();
if (!text && pendingImages.length === 0) return;
if (isStreaming) return;
const model = DOM.modelSelect.value;
if (!model) {
appendSystemMessage('⚠️ 请先选择一个模型', 'warning');
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();
// 清空输入
DOM.chatInput.value = '';
pendingImages = [];
DOM.imagePreview.style.display = 'none';
DOM.imagePreview.innerHTML = '';
autoResizeTextarea();
// 显示助手占位
appendAssistantPlaceholder();
// 开始流式回复
isStreaming = true;
updateSendButton(true);
abortController = new AbortController();
const thinkMode = DOM.toggleThink.checked;
try {
let assistantContent = '';
let thinkContent = '';
let finalStats = null;
let modelName = '';
// 构造消息历史(不含 timestamp 和 stats 字段)
const apiMessages = currentSession.messages.map(m => ({
role: m.role,
content: m.content || '',
...(m.images && { images: m.images })
}));
// 构造 API 请求参数
const chatParams = {
model: 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;
}
// 兼容不同 Ollama 版本的 thinking 字段
const think = chunk.message.thinking || chunk.message.reasoning_content;
if (think && typeof think === 'string') {
thinkContent += think;
}
}
// 捕获模型名称
if (chunk.model) {
modelName = chunk.model;
}
// 实时更新 UI
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('[App] 流式聊天错误:', err);
// 移除 loading 占位动画
const placeholder = DOM.messagesContainer.querySelector('.message.assistant:last-child');
const loadingDots = placeholder?.querySelector('.loading-dots');
const loadingText = placeholder?.querySelector('.loading-text');
if (loadingDots) loadingDots.remove();
if (loadingText) loadingText.remove();
if (assistantContent) {
// 有已生成的内容 → 保留并存入会话
const partialMsg = {
role: 'assistant',
content: assistantContent + '\n\n`[已中断]`',
timestamp: Date.now(),
...(modelName && { model: modelName }),
...(thinkContent && { think: thinkContent })
};
currentSession.messages.push(partialMsg);
// 更新 DOM 显示
const contentDiv = placeholder?.querySelector('.msg-content');
if (contentDiv) contentDiv.innerHTML = safeMarkdown(partialMsg.content);
saveCurrentSession();
} else {
// 没有内容 → 移除占位
if (placeholder) placeholder.remove();
}
// 检查是否是 CORS 错误
let errMsg = `❌ 错误: ${err.message}`;
if (err.message.includes('Failed to fetch') || err.message.includes('NetworkError')) {
errMsg = '❌ 连接失败。请检查:\n1. Ollama 是否正在运行\n2. 地址是否正确\n3. 是否设置了 OLLAMA_ORIGINS="*"';
}
appendSystemMessage(errMsg, 'error');
// 如果当前没有消息,保持空状态
if (currentSession.messages.length === 0) {
DOM.emptyState.style.display = '';
DOM.messagesContainer.style.display = 'none';
}
} finally {
isStreaming = false;
updateSendButton(false);
abortController = null;
}
}
/** 更新发送按钮状态 */
function updateSendButton(streaming) {
if (streaming) {
DOM.btnSend.innerHTML = '<div class="spinner"></div>';
DOM.btnSend.classList.add('disabled');
} else {
DOM.btnSend.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>`;
DOM.btnSend.classList.remove('disabled');
}
}
// ── 图片处理 ──
/** 处理文件选择 */
async function handleFileSelect(files) {
const maxSize = 20 * 1024 * 1024; // 20MB
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
if (file.size > maxSize) {
appendSystemMessage(`⚠️ 图片 ${file.name} 超过 20MB 限制`, 'warning');
continue;
}
try {
const base64 = await fileToBase64(file);
pendingImages.push({ name: file.name, base64 });
renderImagePreviews();
} catch (err) {
console.error('[App] 图片读取失败:', err);
}
}
}
/** File → Base64 */
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// 去掉 data:image/...;base64, 前缀
const base64 = reader.result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/** 渲染图片预览 */
function renderImagePreviews() {
if (pendingImages.length === 0) {
DOM.imagePreview.style.display = 'none';
DOM.imagePreview.innerHTML = '';
return;
}
DOM.imagePreview.style.display = '';
DOM.imagePreview.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('');
}
// ── 模型管理 ──
/** 加载模型列表 */
async function loadModels() {
try {
const data = await api.listModels();
DOM.modelSelect.innerHTML = '';
if (!data.models || data.models.length === 0) {
DOM.modelSelect.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)})`;
DOM.modelSelect.appendChild(opt);
});
// 恢复上次选择的模型
if (currentSession && currentSession.model) {
DOM.modelSelect.value = currentSession.model;
}
// 保存到设置
if (db) await db.saveSetting('selectedModel', DOM.modelSelect.value);
} catch (err) {
console.error('[App] 加载模型失败:', err);
DOM.modelSelect.innerHTML = '<option value="">加载失败 - 检查连接</option>';
}
}
/** 格式化字节大小 */
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]}`;
}
// ── 连接检测 ──
/** 检测连接状态 */
async function checkConnection() {
const statusEl = DOM.connStatus;
statusEl.className = 'conn-status pending';
statusEl.querySelector('.status-label').textContent = '连接中...';
try {
const version = await api.getVersion();
statusEl.className = 'conn-status connected';
statusEl.title = `已连接 (v${version.version})`;
statusEl.querySelector('.status-label').textContent = '已连接';
return { ok: true, version };
} catch (err) {
statusEl.className = 'conn-status disconnected';
statusEl.title = '未连接';
statusEl.querySelector('.status-label').textContent = '未连接';
return { ok: false, error: err };
}
}
/** 更新设置面板中的连接信息 */
async function updateConnectionInfo() {
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 = '';
// 检测是否是 CORS 错误
if (err.message.includes('Failed to fetch') || err.message.includes('NetworkError') || err.message.includes('CORS')) {
corsHint.style.display = '';
} else {
corsHint.style.display = 'none';
}
}
}
/** 更新运行中的模型列表 */
async function updateRunningModels() {
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>';
}
}
// ── 历史记录 ──
/** 加载历史记录列表(带搜索和分页) */
async function loadHistory() {
if (!db) return;
let allSessions = await db.getAllSessions();
// 搜索过滤
if (historySearchQuery) {
const q = historySearchQuery.toLowerCase();
allSessions = allSessions.filter(s => {
if (s.title && s.title.toLowerCase().includes(q)) return true;
if (s.model && s.model.toLowerCase().includes(q)) return true;
return s.messages.some(m => m.content && m.content.toLowerCase().includes(q));
});
}
allSessions.sort((a, b) => b.updatedAt - a.updatedAt);
const listEl = DOM.historyList;
const paginationEl = DOM.historyPagination;
if (allSessions.length === 0) {
listEl.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>`;
paginationEl.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);
listEl.innerHTML = pageSessions.map(s => `
<div class="history-item" data-id="${s.id}">
<div class="history-info">
<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) {
paginationEl.innerHTML = `<span class="page-info">共 ${allSessions.length} 条</span>`;
return;
}
let paginationHtml = `<span class="page-info">共 ${allSessions.length} 条</span>`;
paginationHtml += '<div class="page-buttons">';
paginationHtml += `<button class="page-btn${historyPage <= 1 ? ' disabled' : ''}" data-page="${historyPage - 1}"></button>`;
// 页码按钮:显示当前页前后各2页
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) paginationHtml += '<span class="page-ellipsis">…</span>';
paginationHtml += `<button class="page-btn${p === historyPage ? ' active' : ''}" data-page="${p}">${p}</button>`;
prev = p;
}
paginationHtml += `<button class="page-btn${historyPage >= totalPages ? ' disabled' : ''}" data-page="${historyPage + 1}"></button>`;
paginationHtml += '</div>';
paginationEl.innerHTML = paginationHtml;
}
/** 加载历史会话(只读模式) */
async function loadHistorySession(sessionId) {
if (!db) return;
const session = await db.getSession(sessionId);
if (!session) {
appendSystemMessage('❌ 会话不存在', 'error');
return;
}
currentSession = session;
isHistoryView = true;
DOM.historyBar.style.display = '';
DOM.inputArea.style.display = 'none';
DOM.messagesContainer.innerHTML = ''; // 清空旧 DOM
renderMessages();
closeHistoryModal();
}
// ── 导出功能 ──
/** 导出为 Markdown */
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 */
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 */
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');
}
/** 下载文件 */
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);
}
// ── 批量导出/导入 ──
/** 导出全部会话为 JSON */
async function exportAllSessions() {
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: sessions
};
const json = JSON.stringify(backup, null, 2);
const ts = new Date().toISOString().slice(0, 10);
downloadFile(`metona-backup-${ts}.json`, json, 'application/json');
showToast(`已导出 ${sessions.length} 个会话`, 'success');
}
/** 从 JSON 文件导入会话 */
async function importSessions(file) {
if (!db) return;
try {
const text = await file.text();
let data;
try {
data = JSON.parse(text);
} catch {
showToast('文件格式错误:不是有效的 JSON', 'error');
return;
}
// 兼容两种格式:直接数组 或 { sessions: [...] }
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);
loadHistory();
} catch (err) {
console.error('[App] 导入失败:', err);
showToast(`导入失败: ${err.message}`, 'error');
}
}
// ── 模态框控制 ──
function openSettingsModal() {
DOM.settingsModal.style.display = '';
document.querySelector('#inputServerUrl').value = api.baseUrl;
updateConnectionInfo();
updateRunningModels();
}
function closeSettingsModal() {
DOM.settingsModal.style.display = 'none';
}
function openHistoryModal() {
historyPage = 1;
historySearchQuery = '';
const searchInput = document.querySelector('#historySearchInput');
const searchClear = document.querySelector('#historySearchClear');
if (searchInput) searchInput.value = '';
if (searchClear) searchClear.style.display = 'none';
loadHistory();
DOM.historyModal.style.display = '';
}
function closeHistoryModal() {
DOM.historyModal.style.display = 'none';
}
// ── 自动调整 textarea 高度 ──
function autoResizeTextarea() {
const el = DOM.chatInput;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}
// ═══════════════════════════════════════════════════════════
// 事件绑定
// ═══════════════════════════════════════════════════════════
function bindEvents() {
// 发送消息
DOM.btnSend.addEventListener('click', sendMessage);
DOM.chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// 输入框自动调整
DOM.chatInput.addEventListener('input', autoResizeTextarea);
// 图片上传
DOM.btnAttach.addEventListener('click', () => DOM.fileInput.click());
DOM.fileInput.addEventListener('change', (e) => {
handleFileSelect(e.target.files);
e.target.value = ''; // 重置,允许重复选择同一文件
});
// 删除图片预览
DOM.imagePreview.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-img')) {
const idx = parseInt(e.target.dataset.index);
pendingImages.splice(idx, 1);
renderImagePreviews();
}
});
// 新建会话
document.querySelector('#btnNewChat').addEventListener('click', startNewSession);
// 设置面板
document.querySelector('#btnSettings').addEventListener('click', openSettingsModal);
document.querySelector('#btnCloseSettings').addEventListener('click', closeSettingsModal);
DOM.settingsModal.addEventListener('click', (e) => {
if (e.target === DOM.settingsModal) closeSettingsModal();
});
// 历史记录面板
document.querySelector('#btnHistory').addEventListener('click', openHistoryModal);
document.querySelector('#btnCloseHistory').addEventListener('click', closeHistoryModal);
DOM.historyModal.addEventListener('click', (e) => {
if (e.target === DOM.historyModal) closeHistoryModal();
});
// 历史搜索
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();
});
// 退出历史只读模式
document.querySelector('#btnExitHistory').addEventListener('click', startNewSession);
// 历史记录列表事件(事件委托)
DOM.historyList.addEventListener('click', async (e) => {
const target = e.target.closest('[data-id]');
if (!target) return;
const sessionId = target.dataset.id;
if (target.classList.contains('btn-delete-session')) {
if (confirm('确定删除此会话?')) {
await db.deleteSession(sessionId);
if (currentSession && currentSession.id === sessionId) {
startNewSession();
}
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);
}
});
// 历史记录分页事件
DOM.historyPagination.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();
DOM.historyList.scrollTop = 0;
}
});
// 保存服务器地址
const saveServerUrl = debounce(async () => {
const url = document.querySelector('#inputServerUrl').value.trim();
if (!url) return;
api = new OllamaAPI(url);
if (db) await db.saveSetting('serverUrl', url);
updateConnectionInfo();
loadModels();
}, 500);
document.querySelector('#inputServerUrl').addEventListener('input', saveServerUrl);
// 模型选择变化
DOM.modelSelect.addEventListener('change', async () => {
if (db) await db.saveSetting('selectedModel', DOM.modelSelect.value);
});
// 系统提示词开关
document.querySelector('#toggleSystemPrompt').addEventListener('change', async (e) => {
systemPromptEnabled = e.target.checked;
document.querySelector('#inputSystemPrompt').disabled = !systemPromptEnabled;
if (db) await db.saveSetting('systemPromptEnabled', systemPromptEnabled);
});
// 保存系统提示词
const saveSystemPrompt = debounce(async () => {
systemPrompt = document.querySelector('#inputSystemPrompt').value.trim();
if (db) await db.saveSetting('systemPrompt', systemPrompt);
}, 500);
document.querySelector('#inputSystemPrompt').addEventListener('input', saveSystemPrompt);
// 保存上下文长度
const saveNumCtx = debounce(async () => {
const val = document.querySelector('#inputNumCtx').value.trim();
numCtx = val ? parseInt(val) : 24576;
if (db) await db.saveSetting('numCtx', numCtx);
}, 500);
document.querySelector('#inputNumCtx').addEventListener('input', saveNumCtx);
// 释放显存
document.querySelector('#btnReleaseVRAM').addEventListener('click', async () => {
try {
await api.chat({ model: DOM.modelSelect.value || 'any', messages: [], keep_alive: 0 });
showToast('显存已释放', 'success');
updateRunningModels();
} catch (err) {
showToast(`释放失败: ${err.message}`, 'error');
}
});
// 清空所有历史
document.querySelector('#btnClearAllHistory').addEventListener('click', async () => {
if (confirm('确定清空所有历史记录?此操作不可恢复!')) {
await db.clearAll();
startNewSession();
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 = '';
}
});
// ESC 关闭模态框
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeSettingsModal();
closeHistoryModal();
closeLightbox();
}
});
// 点击消息图片打开预览
DOM.messagesContainer.addEventListener('click', (e) => {
if (e.target.dataset.lightbox === 'true') {
openLightbox(e.target.src);
}
});
// 关闭图片预览
DOM.lightbox.addEventListener('click', (e) => {
if (e.target === DOM.lightbox) closeLightbox();
});
document.querySelector('#lightboxClose').addEventListener('click', closeLightbox);
}
// ═══════════════════════════════════════════════════════════
// 初始化
// ═══════════════════════════════════════════════════════════
async function init() {
try {
// 1. 初始化 IndexedDB
db = new ChatDB();
await db.init();
// 2. 读取保存的设置
const serverUrl = await db.getSetting('serverUrl', 'http://127.0.0.1:11434');
api = new OllamaAPI(serverUrl);
// 3. 创建初始会话
currentSession = createNewSession();
// 4. 绑定事件
bindEvents();
// 5. 检测连接 + 加载模型
await checkConnection();
await loadModels();
// 6. 恢复上次选择的模型
const savedModel = await db.getSetting('selectedModel', '');
if (savedModel) DOM.modelSelect.value = savedModel;
// 7. 恢复系统提示词和上下文长度
systemPromptEnabled = await db.getSetting('systemPromptEnabled', false);
systemPrompt = await db.getSetting('systemPrompt', '');
numCtx = await db.getSetting('numCtx', 24576);
document.querySelector('#toggleSystemPrompt').checked = systemPromptEnabled;
document.querySelector('#inputSystemPrompt').disabled = !systemPromptEnabled;
document.querySelector('#inputSystemPrompt').value = systemPrompt;
document.querySelector('#inputNumCtx').value = numCtx;
// 7. 聚焦输入框
DOM.chatInput.focus();
// 8. 注册 Service Worker (PWA) — 先清除旧缓存再注册,避免旧 SW 拦截
if ('serviceWorker' in navigator) {
// 先获取所有已注册的 SW,注销旧的
navigator.serviceWorker.getRegistrations().then(regs => {
regs.forEach(r => r.unregister());
}).then(() => {
// 清除所有缓存
return caches.keys().then(keys => Promise.all(keys.map(k => caches.delete(k))));
}).then(() => {
// 重新注册新的 SW
return 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);
});
}
console.log('[App] 初始化完成');
} catch (err) {
console.error('[App] 初始化失败:', err);
// 即使 DB 失败也尝试运行
if (!api) api = new OllamaAPI();
currentSession = createNewSession();
bindEvents();
checkConnection().then(loadModels);
}
}
// 全局错误捕获
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);
});
// 启动应用
init();
// 暴露公共 API(用于调试)
return { sendMessage, startNewSession, checkConnection };
})();
</script>
</body>
</html>