开始对话
+选择一个模型,输入消息开始聊天
+commit 98a20812b15f021a219e76f16cd6956155cde84b Author: 紫影233 <1440196015@qq.com> Date: Fri Apr 3 10:36:29 2026 +0800 初始版本 - Metona Ollama Client v1.1.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..04ee571 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# 🦙 Metona Ollama Client + +基于原生 JavaScript 的 [Ollama](https://ollama.com) AI 聊天客户端,支持流式对话、多模态图片、Think 推理模式、历史管理,可安装为 PWA 离线使用。 + + + + + +## ✨ 功能特性 + +- **流式对话** — 基于 ReadableStream 的实时打字机效果 +- **多模型支持** — 自动加载 Ollama 已安装模型,一键切换 +- **Think 推理模式** — 可展开/收起的思考过程展示 +- **多模态输入** — 支持图片上传,兼容视觉模型 +- **历史管理** — IndexedDB 持久化,支持搜索、分页、导出(Markdown/HTML/TXT/JSON) +- **PWA 离线** — Service Worker 缓存,可安装到桌面 +- **连接检测** — 实时状态指示,CORS 问题自动提示 +- **显存管理** — 一键卸载模型释放显存 +- **XSS 防护** — 内置 HTML 净化器,安全渲染 Markdown + +## 🚀 快速开始 + +### 前置条件 + +1. 安装 [Ollama](https://ollama.com) 并启动服务 +2. 下载至少一个模型(例如 `ollama pull qwen2.5`) + +### 启动 + +```bash +# 克隆仓库 +git clone https://gitee.com/thzxx/metona-ollama.git +cd metona-ollama + +# 直接用浏览器打开 +open index.html + +# 或用任意 HTTP 服务器托管 +python3 -m http.server 8080 +# 然后访问 http://localhost:8080 +``` + +### 跨域配置 + +如果 Ollama 和页面不在同一域,需要设置环境变量: + +```bash +OLLAMA_ORIGINS="*" ollama serve +``` + +## 📁 项目结构 + +``` +metona-ollama/ +├── index.html # 主页面 + 应用逻辑 +├── style.css # 暗色主题样式 +├── ollama-api.js # Ollama REST API 封装 +├── chat-db.js # IndexedDB 持久化层 +├── marked.esm.js # Markdown 渲染器 +├── sw.js # Service Worker (PWA) +├── manifest.json # PWA 清单 +└── README.md +``` + +## ⚙️ 设置项 + +| 设置 | 说明 | 默认值 | +|------|------|--------| +| Ollama 服务地址 | API 端点 | `http://127.0.0.1:11434` | +| 系统提示词 | 全局 System Prompt | 关闭 | +| 上下文长度 | `num_ctx` 参数 | 24576 tokens | +| Think 模式 | 启用深度推理(需要模型支持) | 关闭 | + +## 📦 数据导出 + +支持以下导出格式: + +- **Markdown** — 单会话导出,便于阅读 +- **HTML** — 带样式的离线页面 +- **TXT** — 纯文本备份 +- **JSON** — 全量备份/迁移,可跨设备导入 + +## 🛠️ 技术栈 + +- **纯原生 JS** — 零依赖(除 marked.js),无构建步骤 +- **IndexedDB** — 异步持久化,支持大数据存储 +- **Fetch API + ReadableStream** — 流式 NDJSON 解析 +- **PWA** — Service Worker 离线缓存 +- **CSS Variables** — 暗色主题,响应式布局 + +## 🔒 安全 + +- 内置 HTML 净化器(白名单标签 + 属性过滤) +- Markdown 链接协议白名单(http/https/mailto/tel) +- 阻止 `javascript:` / `vbscript:` / `data:` 协议注入 +- 输入内容自动转义 + +## 📄 License + +MIT diff --git a/chat-db.js b/chat-db.js new file mode 100644 index 0000000..53ddb3b --- /dev/null +++ b/chat-db.js @@ -0,0 +1,193 @@ +/** + * ChatDB - IndexedDB 封装层 + * + * 用于持久化聊天历史记录和设置 + * 使用 IndexedDB 而非 localStorage,原因: + * 1. 支持存储更大的数据(如 base64 图片) + * 2. 异步操作不阻塞主线程 + * 3. 支持事务和索引查询 + */ + +export class ChatDB { + constructor(dbName = 'metona-ollama', version = 1) { + this.dbName = dbName; + this.version = version; + this.db = null; + } + + /** + * 初始化数据库 + * 创建 object stores 和索引 + */ + async init() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version); + + request.onerror = () => { + console.error('[ChatDB] 数据库打开失败:', request.error); + reject(request.error); + }; + + request.onsuccess = () => { + this.db = request.result; + console.log('[ChatDB] 数据库已连接'); + resolve(); + }; + + // 数据库升级/创建回调 + request.onupgradeneeded = (event) => { + const db = event.target.result; + + // 会话存储 + if (!db.objectStoreNames.contains('sessions')) { + const sessionStore = db.createObjectStore('sessions', { keyPath: 'id' }); + sessionStore.createIndex('updatedAt', 'updatedAt', { unique: false }); + sessionStore.createIndex('model', 'model', { unique: false }); + console.log('[ChatDB] 创建 sessions 存储'); + } + + // 设置存储 + if (!db.objectStoreNames.contains('settings')) { + db.createObjectStore('settings', { keyPath: 'key' }); + console.log('[ChatDB] 创建 settings 存储'); + } + }; + }); + } + + /** + * 确保数据库已连接 + */ + _ensureDB() { + if (!this.db) throw new Error('数据库未初始化,请先调用 init()'); + } + + /** + * 获取事务 + */ + _tx(storeName, mode = 'readonly') { + this._ensureDB(); + const tx = this.db.transaction(storeName, mode); + return tx.objectStore(storeName); + } + + // ═══════════════════════════════════════════════════════════════ + // 会话 CRUD + // ═══════════════════════════════════════════════════════════════ + + /** 保存/更新会话 */ + async saveSession(session) { + return new Promise((resolve, reject) => { + const store = this._tx('sessions', 'readwrite'); + const request = store.put(session); + request.onsuccess = () => resolve(session.id); + request.onerror = () => reject(request.error); + }); + } + + /** 获取单个会话 */ + async getSession(id) { + return new Promise((resolve, reject) => { + const store = this._tx('sessions'); + const request = store.get(id); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + }); + } + + /** 获取所有会话 */ + async getAllSessions() { + return new Promise((resolve, reject) => { + const store = this._tx('sessions'); + const request = store.getAll(); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); + } + + /** 删除会话 */ + async deleteSession(id) { + return new Promise((resolve, reject) => { + const store = this._tx('sessions', 'readwrite'); + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + /** 清空所有会话 */ + async clearAll() { + return new Promise((resolve, reject) => { + const store = this._tx('sessions', 'readwrite'); + const request = store.clear(); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + /** 批量保存会话(单事务,原子性) */ + async importSessions(sessions) { + return new Promise((resolve, reject) => { + const tx = this.db.transaction('sessions', 'readwrite'); + const store = tx.objectStore('sessions'); + let imported = 0; + let skipped = 0; + + tx.oncomplete = () => resolve({ imported, skipped }); + tx.onerror = () => reject(tx.error); + + // 先检查每个 ID 是否已存在,再 put + for (const session of sessions) { + if (!session.id || !Array.isArray(session.messages)) { + skipped++; + continue; + } + const getRequest = store.get(session.id); + getRequest.onsuccess = () => { + if (getRequest.result) { + skipped++; + } else { + store.put(session); + imported++; + } + }; + } + }); + } + + /** 按时间范围查询会话 */ + async getSessionsByTimeRange(startTime, endTime) { + return new Promise((resolve, reject) => { + const store = this._tx('sessions'); + const index = store.index('updatedAt'); + const range = IDBKeyRange.bound(startTime, endTime); + const request = index.getAll(range); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); + } + + // ═══════════════════════════════════════════════════════════════ + // 设置 CRUD + // ═══════════════════════════════════════════════════════════════ + + /** 保存设置 */ + async saveSetting(key, value) { + return new Promise((resolve, reject) => { + const store = this._tx('settings', 'readwrite'); + const request = store.put({ key, value }); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + /** 获取设置 */ + async getSetting(key, defaultValue = null) { + return new Promise((resolve, reject) => { + const store = this._tx('settings'); + const request = store.get(key); + request.onsuccess = () => resolve(request.result ? request.result.value : defaultValue); + request.onerror = () => reject(request.error); + }); + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..681b363 --- /dev/null +++ b/index.html @@ -0,0 +1,1608 @@ + + +
+ + + + + + + + +选择一个模型,输入消息开始聊天
+'
+ + (escaped ? code : escape(code, true))
+ + '\n';
+ }
+ return ''
+ + (escaped ? code : escape(code, true))
+ + '\n';
+ }
+ blockquote({ tokens }) {
+ const body = this.parser.parse(tokens);
+ return `\n${body}\n`; + } + html({ text }) { + return text; + } + heading({ tokens, depth }) { + return `
${this.parser.parseInline(tokens)}
\n`; + } + table(token) { + let header = ''; + // header + let cell = ''; + for (let j = 0; j < token.header.length; j++) { + cell += this.tablecell(token.header[j]); + } + header += this.tablerow({ text: cell }); + let body = ''; + for (let j = 0; j < token.rows.length; j++) { + const row = token.rows[j]; + cell = ''; + for (let k = 0; k < row.length; k++) { + cell += this.tablecell(row[k]); + } + body += this.tablerow({ text: cell }); + } + if (body) + body = `${body}`; + return '${escape(text, true)}`;
+ }
+ br(token) {
+ return 'An error occurred:
' + + escape(e.message + '', true) + + ''; + if (async) { + return Promise.resolve(msg); + } + return msg; + } + if (async) { + return Promise.reject(e); + } + throw e; + }; + } +} + +const markedInstance = new Marked(); +function marked(src, opt) { + return markedInstance.parse(src, opt); +} +/** + * Sets the default options. + * + * @param options Hash of options + */ +marked.options = + marked.setOptions = function (options) { + markedInstance.setOptions(options); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; + }; +/** + * Gets the original marked default options. + */ +marked.getDefaults = _getDefaults; +marked.defaults = _defaults; +/** + * Use Extension + */ +marked.use = function (...args) { + markedInstance.use(...args); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; +}; +/** + * Run callback for every token + */ +marked.walkTokens = function (tokens, callback) { + return markedInstance.walkTokens(tokens, callback); +}; +/** + * Compiles markdown to HTML without enclosing `p` tag. + * + * @param src String of markdown source to be compiled + * @param options Hash of options + * @return String of compiled HTML + */ +marked.parseInline = markedInstance.parseInline; +/** + * Expose + */ +marked.Parser = _Parser; +marked.parser = _Parser.parse; +marked.Renderer = _Renderer; +marked.TextRenderer = _TextRenderer; +marked.Lexer = _Lexer; +marked.lexer = _Lexer.lex; +marked.Tokenizer = _Tokenizer; +marked.Hooks = _Hooks; +marked.parse = marked; +const options = marked.options; +const setOptions = marked.setOptions; +const use = marked.use; +const walkTokens = marked.walkTokens; +const parseInline = marked.parseInline; +const parse = marked; +const parser = _Parser.parse; +const lexer = _Lexer.lex; + +export { _Hooks as Hooks, _Lexer as Lexer, Marked, _Parser as Parser, _Renderer as Renderer, _TextRenderer as TextRenderer, _Tokenizer as Tokenizer, _defaults as defaults, _getDefaults as getDefaults, lexer, marked, options, parse, parseInline, parser, setOptions, use, walkTokens }; +//# sourceMappingURL=marked.esm.js.map diff --git a/ollama-api.js b/ollama-api.js new file mode 100644 index 0000000..d2f2632 --- /dev/null +++ b/ollama-api.js @@ -0,0 +1,169 @@ +/** + * Ollama API 客户端封装 + * + * 基于 Fetch API 与 Ollama REST 接口交互 + * 支持:流式聊天、模型管理、连接检测 + * + * API 文档:https://docs.ollama.com/api + */ + +export class OllamaAPI { + constructor(baseUrl = 'http://127.0.0.1:11434') { + this.baseUrl = baseUrl.replace(/\/+$/, ''); + } + + /** 通用请求方法 */ + async _request(path, options = {}) { + const url = `${this.baseUrl}${path}`; + const config = { + ...options, + headers: { 'Content-Type': 'application/json', ...options.headers } + }; + const response = await fetch(url, config); + if (!response.ok) { + const errorBody = await response.text().catch(() => ''); + throw new Error(`Ollama API 错误 ${response.status}: ${errorBody || response.statusText}`); + } + return response; + } + + /** JSON 请求快捷方法 */ + async _json(path, body = null) { + const options = body ? { method: 'POST', body: JSON.stringify(body) } : {}; + const response = await this._request(path, options); + return response.json(); + } + + // ── 模型管理 ── + + /** 获取已安装模型列表 */ + async listModels() { return this._json('/api/tags'); } + + /** 获取运行中的模型列表 */ + async psModels() { return this._json('/api/ps'); } + + /** 获取 Ollama 版本 */ + async getVersion() { return this._json('/api/version'); } + + /** 查看模型详情 */ + async showModel(model) { return this._json('/api/show', { model }); } + + // ── 聊天 API ── + + /** 非流式聊天 */ + async chat(params) { + const body = { + model: params.model, + messages: params.messages, + stream: false, + ...(params.think !== undefined && { think: params.think }), + ...(params.system && { system: params.system }), + ...(params.keep_alive !== undefined && { keep_alive: params.keep_alive }), + ...(params.options && { options: params.options }) + }; + return this._json('/api/chat', body); + } + + /** + * 流式聊天 + * 使用 ReadableStream + TextDecoder 解析 NDJSON 流 + * Ollama 流式响应格式 (NDJSON): + * {"model":"xxx","message":{"role":"assistant","content":"Hello"},"done":false} + */ + async chatStream(params, onChunk) { + const body = { + model: params.model, + messages: params.messages, + stream: true, + ...(params.think !== undefined && { think: params.think }), + ...(params.system && { system: params.system }), + ...(params.keep_alive !== undefined && { keep_alive: params.keep_alive }), + ...(params.options && { options: params.options }) + }; + + const response = await this._request('/api/chat', { + method: 'POST', + body: JSON.stringify(body) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const chunk = JSON.parse(line); + if (onChunk) onChunk(chunk); + if (chunk.done) { + reader.cancel(); + return; + } + } catch (err) { + console.warn('[OllamaAPI] 流式数据解析警告:', err.message); + } + } + } + + if (buffer.trim()) { + try { + const chunk = JSON.parse(buffer); + if (onChunk) onChunk(chunk); + } catch { /* 忽略 */ } + } + } + + /** 流式生成(单轮) */ + async generateStream(params, onChunk) { + const body = { + model: params.model, + prompt: params.prompt, + stream: true, + ...(params.images && { images: params.images }), + ...(params.options && { options: params.options }) + }; + + const response = await this._request('/api/generate', { + method: 'POST', + body: JSON.stringify(body) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const chunk = JSON.parse(line); + if (onChunk) onChunk(chunk); + if (chunk.done) { + reader.cancel(); + return; + } + } catch { /* 忽略不完整数据 */ } + } + } + } + + /** 生成嵌入向量 */ + async embed(model, input) { + return this._json('/api/embed', { model, input }); + } +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..d1b8404 --- /dev/null +++ b/style.css @@ -0,0 +1,1413 @@ +/* ═══════════════════════════════════════════════════════════════ + Metona Ollama Client - 未来感深色主题 + 玻璃拟态 + 霓虹色点缀 + Mobile First 响应式 + ═══════════════════════════════════════════════════════════════ */ + +/* ── CSS 变量 ── */ +:root { + --bg-primary: #0a0a1a; + --bg-secondary: #0f0f2a; + --bg-glass: rgba(15, 15, 42, 0.7); + --bg-glass-light: rgba(25, 25, 60, 0.5); + --bg-glass-hover: rgba(35, 35, 80, 0.5); + --border-glass: rgba(100, 100, 255, 0.15); + --border-glow: rgba(0, 245, 212, 0.3); + --text-primary: #e8e8f0; + --text-secondary: #8888aa; + --text-muted: #555570; + --accent-cyan: #00f5d4; + --accent-purple: #7b2ff7; + --accent-blue: #3b82f6; + --accent-pink: #f472b6; + --accent-red: #ef4444; + --accent-green: #22c55e; + --accent-yellow: #eab308; + --gradient-main: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); + --shadow-glow: 0 0 20px rgba(0, 245, 212, 0.15); + --shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.4); + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --transition: all 0.2s ease; +} + +/* ── 全局重置 ── */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + overflow: hidden; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* 背景动态网格 */ +body::before { + content: ''; + position: fixed; + inset: 0; + background: + radial-gradient(circle at 20% 30%, rgba(0, 245, 212, 0.05) 0%, transparent 50%), + radial-gradient(circle at 80% 70%, rgba(123, 47, 247, 0.05) 0%, transparent 50%); + pointer-events: none; + z-index: 0; +} + +#app { + display: flex; + flex-direction: column; + height: 100vh; + position: relative; + z-index: 1; +} + +/* ═══ 顶部导航 ═══ */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: var(--bg-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border-glass); + flex-shrink: 0; + z-index: 10; +} + +.header-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} + +.logo { + font-size: 24px; + filter: drop-shadow(0 0 8px rgba(0, 245, 212, 0.4)); + flex-shrink: 0; +} + +.app-title { + font-size: 16px; + font-weight: 700; + background: var(--gradient-main); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: 0.5px; + white-space: nowrap; +} + +.app-version { + font-size: 10px; + font-weight: 600; + color: #fff; + background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); + border: none; + border-radius: 10px; + padding: 2px 8px; + margin-left: 4px; + letter-spacing: 0.8px; + user-select: none; + flex-shrink: 0; + box-shadow: 0 0 8px rgba(0, 245, 212, 0.3); + animation: version-pulse 3s ease-in-out infinite; +} + +@keyframes version-pulse { + 0%, 100% { box-shadow: 0 0 8px rgba(0, 245, 212, 0.25); } + 50% { box-shadow: 0 0 14px rgba(0, 245, 212, 0.45); } +} + +.header-right { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +/* ── 连接状态指示器 ── */ +.conn-status { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 20px; + cursor: default; + transition: var(--transition); + font-size: 12px; + font-weight: 500; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + transition: var(--transition); +} + +.status-label { + white-space: nowrap; +} + +/* 已连接 - 绿色脉冲 */ +.conn-status.connected { + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.3); + color: var(--accent-green); +} +.conn-status.connected .status-dot { + background: var(--accent-green); + box-shadow: 0 0 6px var(--accent-green); + animation: pulse 2s ease-in-out infinite; +} + +/* 未连接 - 红色 */ +.conn-status.disconnected { + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--accent-red); +} +.conn-status.disconnected .status-dot { + background: var(--accent-red); + box-shadow: 0 0 6px var(--accent-red); +} + +/* 连接中 - 黄色闪烁 */ +.conn-status.pending { + background: rgba(234, 179, 8, 0.12); + border: 1px solid rgba(234, 179, 8, 0.3); + color: var(--accent-yellow); +} +.conn-status.pending .status-dot { + background: var(--accent-yellow); + box-shadow: 0 0 6px var(--accent-yellow); + animation: pulse 1s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 6px currentColor; } + 50% { opacity: 0.5; box-shadow: 0 0 12px currentColor; } +} + +/* ── 图标按钮 ── */ +.icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + background: transparent; + color: var(--text-secondary); + border-radius: var(--radius-sm); + cursor: pointer; + transition: var(--transition); +} + +.icon-btn:hover { + background: var(--bg-glass-hover); + color: var(--text-primary); +} + +.icon-btn svg { + width: 18px; + height: 18px; +} + +.icon-btn.sm { + width: 28px; + height: 28px; + font-size: 14px; + background: transparent; + border: none; + cursor: pointer; + opacity: 0.6; + transition: var(--transition); +} + +.icon-btn.sm:hover { opacity: 1; } +.icon-btn.danger:hover { color: var(--accent-red); } + +/* ═══ 模型选择栏 ═══ */ +.model-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-glass); + flex-shrink: 0; +} + +.model-select { + flex: 1; + padding: 6px 12px; + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 13px; + outline: none; + cursor: pointer; + transition: var(--transition); +} + +.model-select:focus { + border-color: var(--accent-cyan); + box-shadow: 0 0 0 2px rgba(0, 245, 212, 0.1); +} + +.model-select option { + background: var(--bg-secondary); + color: var(--text-primary); +} + +/* ── 开关控件 ── */ +.toggle-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; + white-space: nowrap; +} + +.toggle-label input { display: none; } + +.toggle-slider { + position: relative; + width: 32px; + height: 18px; + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); + border-radius: 9px; + transition: var(--transition); +} + +.toggle-slider::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + background: var(--text-secondary); + border-radius: 50%; + transition: var(--transition); +} + +.toggle-label input:checked + .toggle-slider { + background: rgba(0, 245, 212, 0.2); + border-color: var(--accent-cyan); +} + +.toggle-label input:checked + .toggle-slider::after { + transform: translateX(14px); + background: var(--accent-cyan); + box-shadow: 0 0 6px var(--accent-cyan); +} + +/* ═══ 聊天区域 ═══ */ +.chat-area { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 16px; + scroll-behavior: smooth; +} + +.chat-area::-webkit-scrollbar { + width: 6px; +} + +.chat-area::-webkit-scrollbar-track { + background: transparent; +} + +.chat-area::-webkit-scrollbar-thumb { + background: var(--border-glass); + border-radius: 3px; +} + +/* ── 空状态 ── */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 16px; + opacity: 0.6; +} + +.empty-icon svg { + width: 100px; + height: 100px; + opacity: 0.5; +} + +.empty-state h2 { + font-size: 20px; + font-weight: 600; + background: var(--gradient-main); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.empty-state p { + color: var(--text-muted); + font-size: 14px; +} + +/* ── 消息气泡 ── */ +.messages { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 800px; + margin: 0 auto; +} + +.message { + display: flex; + gap: 12px; + animation: fadeInUp 0.3s ease; +} + +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.msg-avatar { + flex-shrink: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + border-radius: var(--radius-sm); + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); +} + +.msg-body { + flex: 1; + min-width: 0; +} + +.msg-role { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 1px; + display: flex; + align-items: center; + gap: 8px; +} + +.model-tag { + display: inline-block; + font-size: 11px; + font-weight: 700; + text-transform: none; + letter-spacing: 0.3px; + color: #fff; + background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); + border-radius: 6px; + padding: 2px 10px; + line-height: 1.5; + white-space: nowrap; + box-shadow: 0 0 8px rgba(0, 245, 212, 0.2); +} + +.message.user .msg-body { + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); + border-radius: var(--radius-md); + padding: 10px 14px; +} + +.message.assistant .msg-body { + background: rgba(0, 245, 212, 0.03); + border: 1px solid rgba(0, 245, 212, 0.08); + border-radius: var(--radius-md); + padding: 10px 14px; +} + +.msg-content { + font-size: 14px; + line-height: 1.7; + word-break: break-word; +} + +.msg-content pre { + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + padding: 12px; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 13px; + margin: 8px 0; +} + +.msg-content code { + background: rgba(0, 0, 0, 0.2); + padding: 2px 6px; + border-radius: 4px; + font-family: var(--font-mono); + font-size: 13px; +} + +.msg-content pre code { + background: none; + padding: 0; +} + +.msg-content p { margin-bottom: 8px; } +.msg-content p:last-child { margin-bottom: 0; } + +/* 思考块 */ +.think-block { + margin-bottom: 8px; + border: 1px solid rgba(234, 179, 8, 0.2); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.think-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: rgba(234, 179, 8, 0.05); + color: var(--accent-yellow); + font-size: 12px; + cursor: pointer; + user-select: none; +} + +.think-header .chevron { + margin-left: auto; + transition: transform 0.2s; +} + +.think-header .chevron.expanded { + transform: rotate(180deg); +} + +.think-content { + padding: 8px 12px; + background: rgba(234, 179, 8, 0.03); +} + +.think-content pre { + font-size: 12px; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + margin: 0; + background: none; + border: none; + padding: 0; +} + +/* 打字光标 */ +.typing-cursor { + display: inline-block; + animation: blink 0.8s infinite; + color: var(--accent-cyan); +} + +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + +/* ═══ Loading 等待动画 ═══ */ +.loading-dots { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 0; +} + +.loading-dots span { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent-cyan); + animation: dotPulse 1.4s ease-in-out infinite; + box-shadow: 0 0 8px rgba(0, 245, 212, 0.5); +} + +.loading-dots span:nth-child(1) { animation-delay: 0s; } +.loading-dots span:nth-child(2) { animation-delay: 0.2s; } +.loading-dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes dotPulse { + 0%, 80%, 100% { + transform: scale(0.4); + opacity: 0.3; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +.loading-text { + display: inline-block; + font-size: 13px; + color: var(--text-muted); + animation: loadingFade 1.5s ease-in-out infinite; +} + +@keyframes loadingFade { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} + +/* 消息图片 */ +.msg-images { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.msg-img { + max-width: 200px; + max-height: 200px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-glass); + object-fit: cover; +} + +/* 统计信息 */ +.msg-stats { + display: flex; + gap: 12px; + margin-top: 6px; + font-size: 11px; + color: var(--text-muted); +} + +/* ═══ 输入区域 ═══ */ +.input-area { + flex-shrink: 0; + padding: 10px 16px 14px; + background: var(--bg-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-top: 1px solid var(--border-glass); +} + +.image-preview { + display: flex; + gap: 8px; + padding: 8px 0; + flex-wrap: wrap; +} + +.preview-thumb { + position: relative; + width: 60px; + height: 60px; + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid var(--border-glass); +} + +.preview-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.remove-img { + position: absolute; + top: 2px; + right: 2px; + width: 20px; + height: 20px; + border: none; + background: rgba(239, 68, 68, 0.9); + color: white; + border-radius: 50%; + font-size: 14px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.input-row { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.attach-btn { + flex-shrink: 0; +} + +.chat-input { + flex: 1; + padding: 10px 14px; + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 14px; + resize: none; + outline: none; + min-height: 42px; + max-height: 120px; + line-height: 1.5; + transition: var(--transition); +} + +.chat-input:focus { + border-color: var(--accent-cyan); + box-shadow: 0 0 0 2px rgba(0, 245, 212, 0.1); +} + +.chat-input::placeholder { + color: var(--text-muted); +} + +.send-btn { + flex-shrink: 0; + width: 42px; + height: 42px; + border: none; + border-radius: var(--radius-md); + background: var(--gradient-main); + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); + box-shadow: 0 2px 10px rgba(0, 245, 212, 0.3); +} + +.send-btn:hover { + transform: scale(1.05); + box-shadow: 0 4px 20px rgba(0, 245, 212, 0.4); +} + +.send-btn:active { + transform: scale(0.95); +} + +.send-btn.disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.send-btn svg { + width: 18px; + height: 18px; +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(255,255,255,0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ═══ 模态框 ═══ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + animation: fadeIn 0.2s ease; + padding: 16px; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal { + background: var(--bg-secondary); + border: 1px solid var(--border-glass); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-glass); + width: 100%; + max-width: 500px; + max-height: 80vh; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-glass); +} + +.modal-header h3 { + font-size: 16px; + font-weight: 600; + background: var(--gradient-main); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.modal-body { + padding: 16px 20px; + overflow-y: auto; + flex: 1; +} + +/* ── 设置项 ── */ +.setting-group { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-glass); +} + +.setting-group:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.setting-label { + display: flex; + align-items: center; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.setting-input { + width: 100%; + padding: 10px 12px; + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 13px; + outline: none; + margin-bottom: 8px; + transition: var(--transition); +} + +.setting-input:focus { + border-color: var(--accent-cyan); +} + +/* ── 按钮 ── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border: none; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + gap: 6px; +} + +.btn-primary { + background: var(--gradient-main); + color: white; +} + +.btn-primary:hover { + box-shadow: 0 4px 16px rgba(0, 245, 212, 0.3); +} + +.btn-outline { + background: transparent; + border: 1px solid var(--border-glass); + color: var(--text-secondary); +} + +.btn-outline:hover { + border-color: var(--accent-cyan); + color: var(--text-primary); +} + +.btn-danger { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--accent-red); +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.25); +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +/* ── 连接信息 ── */ +.connection-info { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.status-badge { + padding: 4px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; +} + +.status-badge.success { + background: rgba(34, 197, 94, 0.15); + color: var(--accent-green); + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.status-badge.error { + background: rgba(239, 68, 68, 0.15); + color: var(--accent-red); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.status-badge.warning { + background: rgba(234, 179, 8, 0.15); + color: var(--accent-yellow); + border: 1px solid rgba(234, 179, 8, 0.3); +} + +.server-version { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.cors-hint { + padding: 10px; + background: rgba(234, 179, 8, 0.08); + border: 1px solid rgba(234, 179, 8, 0.2); + border-radius: var(--radius-sm); + margin-top: 8px; +} + +.cors-hint p { + font-size: 12px; + color: var(--accent-yellow); + margin-bottom: 6px; +} + +.cors-hint code { + display: block; + padding: 8px; + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--accent-cyan); + user-select: all; +} + +/* ── 运行中的模型 ── */ +.running-models { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 10px; +} + +.running-model { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: var(--bg-glass-light); + border-radius: var(--radius-sm); + border: 1px solid var(--border-glass); +} + +.running-model .model-name { + flex: 1; + font-size: 13px; + font-weight: 500; +} + +.running-model .model-vram { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.text-muted { + color: var(--text-muted); + font-size: 13px; +} + +/* ── 历史记录 ── */ +.empty-history { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 30px 0; + opacity: 0.5; +} + +.history-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* 历史搜索栏 */ +.history-search-wrap { + position: relative; + margin-bottom: 10px; +} + +.history-search-input { + width: 100%; + padding: 10px 36px 10px 14px; + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 13px; + outline: none; + transition: var(--transition); +} + +.history-search-input::placeholder { + color: var(--text-muted); +} + +.history-search-input:focus { + border-color: var(--accent-cyan); + box-shadow: 0 0 0 2px rgba(0, 245, 212, 0.1); +} + +.history-search-clear { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + padding: 4px; + line-height: 1; + border-radius: 4px; + transition: var(--transition); +} + +.history-search-clear:hover { + color: var(--text-primary); + background: rgba(255,255,255,0.06); +} + +/* 历史列表项 */ +.history-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + transition: var(--transition); +} + +.history-item:hover { + background: var(--bg-glass-light); + border-color: var(--border-glass); +} + +.history-info { + flex: 1; + min-width: 0; + cursor: pointer; +} + +.history-title { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.history-meta { + display: flex; + gap: 10px; + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +.history-actions { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +/* ── 历史只读模式提示栏 ── */ +.history-view-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 16px; + background: rgba(123, 47, 247, 0.15); + border-top: 1px solid rgba(123, 47, 247, 0.3); + font-size: 13px; + color: var(--accent-purple); + flex-shrink: 0; +} + +/* ── 历史记录分页 ── */ +.history-pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0 4px; + border-top: 1px solid var(--border-glass); + margin-top: 8px; +} + +.page-info { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; +} + +.page-buttons { + display: flex; + align-items: center; + gap: 4px; +} + +.page-btn { + min-width: 32px; + height: 32px; + border: 1px solid var(--border-glass); + background: var(--bg-glass-light); + color: var(--text-secondary); + border-radius: var(--radius-sm); + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); +} + +.page-btn:hover:not(.disabled):not(.active) { + border-color: var(--accent-cyan); + color: var(--text-primary); +} + +.page-btn.active { + background: rgba(0, 245, 212, 0.15); + border-color: var(--accent-cyan); + color: var(--accent-cyan); + font-weight: 600; +} + +.page-btn.disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.page-ellipsis { + color: var(--text-muted); + font-size: 13px; + padding: 0 2px; +} + +/* ═══ 图片预览 Lightbox ═══ */ +.lightbox-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; + cursor: zoom-out; + animation: fadeIn 0.2s ease; +} + +.lightbox-img { + max-width: 90vw; + max-height: 90vh; + border-radius: var(--radius-md); + box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); + object-fit: contain; + cursor: default; +} + +.lightbox-close { + position: fixed; + top: 16px; + right: 20px; + width: 40px; + height: 40px; + border: none; + background: rgba(255, 255, 255, 0.1); + color: white; + font-size: 20px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); + z-index: 301; +} + +.lightbox-close:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* 消息图片可点击提示 */ +.msg-img[data-lightbox] { + cursor: zoom-in; + transition: var(--transition); +} + +.msg-img[data-lightbox]:hover { + opacity: 0.85; + transform: scale(1.02); +} + +/* ═══ 响应式 - 平板及以上 ═══ */ +@media (min-width: 768px) { + .app-header { + padding: 12px 24px; + } + + .app-title { + font-size: 18px; + } + + .model-bar { + padding: 8px 24px; + } + + .chat-area { + padding: 24px; + } + + .messages { + max-width: 900px; + } + + .input-area { + padding: 12px 24px 16px; + } + + .modal { + max-width: 560px; + } +} + +/* ═══ 响应式 - 桌面 ═══ */ +@media (min-width: 1024px) { + .messages { + max-width: 960px; + } + + .msg-content { + font-size: 15px; + } + + /* 历史记录面板加宽 */ + #historyModal .modal { + max-width: 680px; + } +} + +@media (min-width: 1440px) { + #historyModal .modal { + max-width: 800px; + } +} + +/* ── 选中文本样式 ── */ +::selection { + background: rgba(0, 245, 212, 0.3); + color: white; +} + +/* ── 链接样式 ── */ +.msg-content a { + color: var(--accent-cyan); + text-decoration: none; +} + +.msg-content a:hover { + text-decoration: underline; +} + +/* 被 sanitizer 拦截的危险链接/图片 */ +.msg-content .blocked-link { + color: var(--accent-red); + text-decoration: line-through; + cursor: not-allowed; + font-size: 0.9em; + opacity: 0.7; +} + +/* ── 列表样式 ── */ +.msg-content ul, .msg-content ol { + padding-left: 20px; + margin: 8px 0; +} + +.msg-content li { + margin-bottom: 4px; +} + +/* ── 表格样式 ── */ +.msg-content table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; + font-size: 13px; +} + +.msg-content th, .msg-content td { + padding: 6px 10px; + border: 1px solid var(--border-glass); + text-align: left; +} + +.msg-content th { + background: var(--bg-glass-light); + font-weight: 600; +} + +/* ── 引用块 ── */ +.msg-content blockquote { + border-left: 3px solid var(--accent-cyan); + padding-left: 12px; + margin: 8px 0; + color: var(--text-secondary); +} + +/* ═══ Toast 通知 ═══ */ +.toast-container { + position: fixed; + top: 60px; + right: 16px; + z-index: 200; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-radius: var(--radius-md); + background: var(--bg-secondary); + border: 1px solid var(--border-glass); + box-shadow: var(--shadow-glass); + font-size: 13px; + color: var(--text-primary); + pointer-events: auto; + animation: toastIn 0.3s ease; + max-width: 340px; + backdrop-filter: blur(12px); +} + +.toast.removing { + animation: toastOut 0.25s ease forwards; +} + +.toast-icon { + font-size: 16px; + flex-shrink: 0; +} + +.toast.success { border-color: rgba(34, 197, 94, 0.4); } +.toast.success .toast-icon { color: var(--accent-green); } + +.toast.error { border-color: rgba(239, 68, 68, 0.4); } +.toast.error .toast-icon { color: var(--accent-red); } + +.toast.warning { border-color: rgba(234, 179, 8, 0.4); } +.toast.warning .toast-icon { color: var(--accent-yellow); } + +.toast.info { border-color: rgba(0, 245, 212, 0.3); } +.toast.info .toast-icon { color: var(--accent-cyan); } + +@keyframes toastIn { + from { opacity: 0; transform: translateX(40px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes toastOut { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(40px); } +} diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..ae8682d --- /dev/null +++ b/sw.js @@ -0,0 +1,63 @@ +/** + * Service Worker - PWA 离线支持 + * 缓存应用资源以便离线使用 + */ + +const CACHE_NAME = 'metona-ollama-v3'; +const ASSETS = [ + './', + './index.html', + './style.css', + './chat-db.js', + './marked.esm.js', + './ollama-api.js', + './manifest.json' +]; + +// 安装阶段:预缓存核心资源 +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => cache.addAll(ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +// 激活阶段:清理旧缓存 +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then(keys => Promise.all( + keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)) + )) + .then(() => self.clients.claim()) + ); +}); + +// 拦截请求:缓存优先,网络回退 +self.addEventListener('fetch', (event) => { + // 不拦截非 GET 请求 + if (event.request.method !== 'GET') return; + + event.respondWith( + caches.match(event.request) + .then(cached => { + if (cached) return cached; + return fetch(event.request).then(response => { + // 仅缓存成功的响应 + if (response && response.status === 200) { + const clone = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone)); + } + return response; + }).catch(() => { + // 离线且无缓存时返回简单提示 + if (event.request.destination === 'document') { + return new Response('离线模式:请检查网络连接', { + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }); + } + }); + }) + ); +});