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 离线使用。 + +![版本](https://img.shields.io/badge/version-1.1.0-brightgreen) +![平台](https://img.shields.io/badge/platform-Web-blue) +![协议](https://img.shields.io/badge/license-MIT-green) + +## ✨ 功能特性 + +- **流式对话** — 基于 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 @@ + + + + + + + + + + + + Metona Ollama + + + +
+ + +
+
+ + Metona Ollama + v1.1.0 +
+
+
+ + 未连接 +
+ + + +
+
+ + +
+ + +
+ + +
+ +
+
+ + + + + + + + +
+

开始对话

+

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

+
+ + + +
+ + + + + +
+ +
+ + + + +
+
+
+ + + + + + + + + + + +
+ + + + + diff --git a/llama.ico b/llama.ico new file mode 100644 index 0000000..549d7a8 Binary files /dev/null and b/llama.ico differ diff --git a/llama.png b/llama.png new file mode 100644 index 0000000..5f53924 Binary files /dev/null and b/llama.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..ec9255e --- /dev/null +++ b/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "Metona Ollama Client", + "short_name": "Ollama", + "description": "基于原生 JavaScript 的 Ollama AI 客户端 - 流式聊天、多模态、历史管理", + "start_url": "./index.html", + "display": "standalone", + "background_color": "#0a0a1a", + "theme_color": "#0a0a1a", + "orientation": "any", + "icons": [ + { + "src": "data:image/svg+xml,🦙", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "categories": ["productivity", "utilities"] +} diff --git a/marked.esm.js b/marked.esm.js new file mode 100644 index 0000000..d07059e --- /dev/null +++ b/marked.esm.js @@ -0,0 +1,2580 @@ +/** + * marked v15.0.7 - a markdown parser + * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ + +/** + * Gets the original marked default options. + */ +function _getDefaults() { + return { + async: false, + breaks: false, + extensions: null, + gfm: true, + hooks: null, + pedantic: false, + renderer: null, + silent: false, + tokenizer: null, + walkTokens: null, + }; +} +let _defaults = _getDefaults(); +function changeDefaults(newDefaults) { + _defaults = newDefaults; +} + +const noopTest = { exec: () => null }; +function edit(regex, opt = '') { + let source = typeof regex === 'string' ? regex : regex.source; + const obj = { + replace: (name, val) => { + let valSource = typeof val === 'string' ? val : val.source; + valSource = valSource.replace(other.caret, '$1'); + source = source.replace(name, valSource); + return obj; + }, + getRegex: () => { + return new RegExp(source, opt); + }, + }; + return obj; +} +const other = { + codeRemoveIndent: /^(?: {1,4}| {0,3}\t)/gm, + outputLinkReplace: /\\([\[\]])/g, + indentCodeCompensation: /^(\s+)(?:```)/, + beginningSpace: /^\s+/, + endingHash: /#$/, + startingSpaceChar: /^ /, + endingSpaceChar: / $/, + nonSpaceChar: /[^ ]/, + newLineCharGlobal: /\n/g, + tabCharGlobal: /\t/g, + multipleSpaceGlobal: /\s+/g, + blankLine: /^[ \t]*$/, + doubleBlankLine: /\n[ \t]*\n[ \t]*$/, + blockquoteStart: /^ {0,3}>/, + blockquoteSetextReplace: /\n {0,3}((?:=+|-+) *)(?=\n|$)/g, + blockquoteSetextReplace2: /^ {0,3}>[ \t]?/gm, + listReplaceTabs: /^\t+/, + listReplaceNesting: /^ {1,4}(?=( {4})*[^ ])/g, + listIsTask: /^\[[ xX]\] /, + listReplaceTask: /^\[[ xX]\] +/, + anyLine: /\n.*\n/, + hrefBrackets: /^<(.*)>$/, + tableDelimiter: /[:|]/, + tableAlignChars: /^\||\| *$/g, + tableRowBlankLine: /\n[ \t]*$/, + tableAlignRight: /^ *-+: *$/, + tableAlignCenter: /^ *:-+: *$/, + tableAlignLeft: /^ *:-+ *$/, + startATag: /^/i, + startPreScriptTag: /^<(pre|code|kbd|script)(\s|>)/i, + endPreScriptTag: /^<\/(pre|code|kbd|script)(\s|>)/i, + startAngleBracket: /^$/, + pedanticHrefTitle: /^([^'"]*[^\s])\s+(['"])(.*)\2/, + unicodeAlphaNumeric: /[\p{L}\p{N}]/u, + escapeTest: /[&<>"']/, + escapeReplace: /[&<>"']/g, + escapeTestNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/, + escapeReplaceNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g, + unescapeTest: /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig, + caret: /(^|[^\[])\^/g, + percentDecode: /%25/g, + findPipe: /\|/g, + splitPipe: / \|/, + slashPipe: /\\\|/g, + carriageReturn: /\r\n|\r/g, + spaceLine: /^ +$/gm, + notSpaceStart: /^\S*/, + endingNewline: /\n$/, + listItemRegex: (bull) => new RegExp(`^( {0,3}${bull})((?:[\t ][^\\n]*)?(?:\\n|$))`), + nextBulletRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`), + hrRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`), + fencesBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:\`\`\`|~~~)`), + headingBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}#`), + htmlBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}<(?:[a-z].*>|!--)`, 'i'), +}; +/** + * Block-Level Grammar + */ +const newline = /^(?:[ \t]*(?:\n|$))+/; +const blockCode = /^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/; +const fences = /^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/; +const hr = /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/; +const heading = /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/; +const bullet = /(?:[*+-]|\d{1,9}[.)])/; +const lheadingCore = /^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/; +const lheading = edit(lheadingCore) + .replace(/bull/g, bullet) // lists can interrupt + .replace(/blockCode/g, /(?: {4}| {0,3}\t)/) // indented code blocks can interrupt + .replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt + .replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt + .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt + .replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt + .replace(/\|table/g, '') // table not in commonmark + .getRegex(); +const lheadingGfm = edit(lheadingCore) + .replace(/bull/g, bullet) // lists can interrupt + .replace(/blockCode/g, /(?: {4}| {0,3}\t)/) // indented code blocks can interrupt + .replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt + .replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt + .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt + .replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt + .replace(/table/g, / {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/) // table can interrupt + .getRegex(); +const _paragraph = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/; +const blockText = /^[^\n]+/; +const _blockLabel = /(?!\s*\])(?:\\.|[^\[\]\\])+/; +const def = edit(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/) + .replace('label', _blockLabel) + .replace('title', /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/) + .getRegex(); +const list = edit(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/) + .replace(/bull/g, bullet) + .getRegex(); +const _tag = 'address|article|aside|base|basefont|blockquote|body|caption' + + '|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption' + + '|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe' + + '|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option' + + '|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title' + + '|tr|track|ul'; +const _comment = /|$))/; +const html = edit('^ {0,3}(?:' // optional indentation + + '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)' // (1) + + '|comment[^\\n]*(\\n+|$)' // (2) + + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3) + + '|\\n*|$)' // (4) + + '|\\n*|$)' // (5) + + '|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (6) + + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) open tag + + '|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) closing tag + + ')', 'i') + .replace('comment', _comment) + .replace('tag', _tag) + .replace('attribute', / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/) + .getRegex(); +const paragraph = edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex(); +const blockquote = edit(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/) + .replace('paragraph', paragraph) + .getRegex(); +/** + * Normal Block Grammar + */ +const blockNormal = { + blockquote, + code: blockCode, + def, + fences, + heading, + hr, + html, + lheading, + list, + newline, + paragraph, + table: noopTest, + text: blockText, +}; +/** + * GFM Block Grammar + */ +const gfmTable = edit('^ *([^\\n ].*)\\n' // Header + + ' {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)' // Align + + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)') // Cells + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('blockquote', ' {0,3}>') + .replace('code', '(?: {4}| {0,3}\t)[^\\n]') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // tables can be interrupted by type (6) html blocks + .getRegex(); +const blockGfm = { + ...blockNormal, + lheading: lheadingGfm, + table: gfmTable, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs + .replace('table', gfmTable) // interrupt paragraphs with table + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex(), +}; +/** + * Pedantic grammar (original John Gruber's loose markdown specification) + */ +const blockPedantic = { + ...blockNormal, + html: edit('^ *(?:comment *(?:\\n|\\s*$)' + + '|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)' // closed tag + + '|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))') + .replace('comment', _comment) + .replace(/tag/g, '(?!(?:' + + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub' + + '|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)' + + '\\b)\\w+(?!:|[^\\w\\s@]*@)\\b') + .getRegex(), + def: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, + heading: /^(#{1,6})(.*)(?:\n+|$)/, + fences: noopTest, // fences not supported + lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' *#{1,6} *[^\n]') + .replace('lheading', lheading) + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('|fences', '') + .replace('|list', '') + .replace('|html', '') + .replace('|tag', '') + .getRegex(), +}; +/** + * Inline-Level Grammar + */ +const escape$1 = /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/; +const inlineCode = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/; +const br = /^( {2,}|\\)\n(?!\s*$)/; +const inlineText = /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\ +const blockSkip = /\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g; +const emStrongLDelimCore = /^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/; +const emStrongLDelim = edit(emStrongLDelimCore, 'u') + .replace(/punct/g, _punctuation) + .getRegex(); +const emStrongLDelimGfm = edit(emStrongLDelimCore, 'u') + .replace(/punct/g, _punctuationGfmStrongEm) + .getRegex(); +const emStrongRDelimAstCore = '^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)' // Skip orphan inside strong + + '|[^*]+(?=[^*])' // Consume to delim + + '|(?!\\*)punct(\\*+)(?=[\\s]|$)' // (1) #*** can only be a Right Delimiter + + '|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)' // (2) a***#, a*** can only be a Right Delimiter + + '|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)' // (3) #***a, ***a can only be Left Delimiter + + '|[\\s](\\*+)(?!\\*)(?=punct)' // (4) ***# can only be Left Delimiter + + '|(?!\\*)punct(\\*+)(?!\\*)(?=punct)' // (5) #***# can be either Left or Right Delimiter + + '|notPunctSpace(\\*+)(?=notPunctSpace)'; // (6) a***a can be either Left or Right Delimiter +const emStrongRDelimAst = edit(emStrongRDelimAstCore, 'gu') + .replace(/notPunctSpace/g, _notPunctuationOrSpace) + .replace(/punctSpace/g, _punctuationOrSpace) + .replace(/punct/g, _punctuation) + .getRegex(); +const emStrongRDelimAstGfm = edit(emStrongRDelimAstCore, 'gu') + .replace(/notPunctSpace/g, _notPunctuationOrSpaceGfmStrongEm) + .replace(/punctSpace/g, _punctuationOrSpaceGfmStrongEm) + .replace(/punct/g, _punctuationGfmStrongEm) + .getRegex(); +// (6) Not allowed for _ +const emStrongRDelimUnd = edit('^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)' // Skip orphan inside strong + + '|[^_]+(?=[^_])' // Consume to delim + + '|(?!_)punct(_+)(?=[\\s]|$)' // (1) #___ can only be a Right Delimiter + + '|notPunctSpace(_+)(?!_)(?=punctSpace|$)' // (2) a___#, a___ can only be a Right Delimiter + + '|(?!_)punctSpace(_+)(?=notPunctSpace)' // (3) #___a, ___a can only be Left Delimiter + + '|[\\s](_+)(?!_)(?=punct)' // (4) ___# can only be Left Delimiter + + '|(?!_)punct(_+)(?!_)(?=punct)', 'gu') // (5) #___# can be either Left or Right Delimiter + .replace(/notPunctSpace/g, _notPunctuationOrSpace) + .replace(/punctSpace/g, _punctuationOrSpace) + .replace(/punct/g, _punctuation) + .getRegex(); +const anyPunctuation = edit(/\\(punct)/, 'gu') + .replace(/punct/g, _punctuation) + .getRegex(); +const autolink = edit(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/) + .replace('scheme', /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/) + .replace('email', /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/) + .getRegex(); +const _inlineComment = edit(_comment).replace('(?:-->|$)', '-->').getRegex(); +const tag = edit('^comment' + + '|^' // self-closing tag + + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag + + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. + + '|^' // declaration, e.g. + + '|^') // CDATA section + .replace('comment', _inlineComment) + .replace('attribute', /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/) + .getRegex(); +const _inlineLabel = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; +const link = edit(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/) + .replace('label', _inlineLabel) + .replace('href', /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/) + .replace('title', /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/) + .getRegex(); +const reflink = edit(/^!?\[(label)\]\[(ref)\]/) + .replace('label', _inlineLabel) + .replace('ref', _blockLabel) + .getRegex(); +const nolink = edit(/^!?\[(ref)\](?:\[\])?/) + .replace('ref', _blockLabel) + .getRegex(); +const reflinkSearch = edit('reflink|nolink(?!\\()', 'g') + .replace('reflink', reflink) + .replace('nolink', nolink) + .getRegex(); +/** + * Normal Inline Grammar + */ +const inlineNormal = { + _backpedal: noopTest, // only used for GFM url + anyPunctuation, + autolink, + blockSkip, + br, + code: inlineCode, + del: noopTest, + emStrongLDelim, + emStrongRDelimAst, + emStrongRDelimUnd, + escape: escape$1, + link, + nolink, + punctuation, + reflink, + reflinkSearch, + tag, + text: inlineText, + url: noopTest, +}; +/** + * Pedantic Inline Grammar + */ +const inlinePedantic = { + ...inlineNormal, + link: edit(/^!?\[(label)\]\((.*?)\)/) + .replace('label', _inlineLabel) + .getRegex(), + reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/) + .replace('label', _inlineLabel) + .getRegex(), +}; +/** + * GFM Inline Grammar + */ +const inlineGfm = { + ...inlineNormal, + emStrongRDelimAst: emStrongRDelimAstGfm, + emStrongLDelim: emStrongLDelimGfm, + url: edit(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/, 'i') + .replace('email', /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/) + .getRegex(), + _backpedal: /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/, + del: /^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/, + text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\': '>', + '"': '"', + "'": ''', +}; +const getEscapeReplacement = (ch) => escapeReplacements[ch]; +function escape(html, encode) { + if (encode) { + if (other.escapeTest.test(html)) { + return html.replace(other.escapeReplace, getEscapeReplacement); + } + } + else { + if (other.escapeTestNoEncode.test(html)) { + return html.replace(other.escapeReplaceNoEncode, getEscapeReplacement); + } + } + return html; +} +function cleanUrl(href) { + try { + href = encodeURI(href).replace(other.percentDecode, '%'); + } + catch { + return null; + } + return href; +} +function splitCells(tableRow, count) { + // ensure that every cell-delimiting pipe has a space + // before it to distinguish it from an escaped pipe + const row = tableRow.replace(other.findPipe, (match, offset, str) => { + let escaped = false; + let curr = offset; + while (--curr >= 0 && str[curr] === '\\') + escaped = !escaped; + if (escaped) { + // odd number of slashes means | is escaped + // so we leave it alone + return '|'; + } + else { + // add space before unescaped | + return ' |'; + } + }), cells = row.split(other.splitPipe); + let i = 0; + // First/last cell in a row cannot be empty if it has no leading/trailing pipe + if (!cells[0].trim()) { + cells.shift(); + } + if (cells.length > 0 && !cells.at(-1)?.trim()) { + cells.pop(); + } + if (count) { + if (cells.length > count) { + cells.splice(count); + } + else { + while (cells.length < count) + cells.push(''); + } + } + for (; i < cells.length; i++) { + // leading or trailing whitespace is ignored per the gfm spec + cells[i] = cells[i].trim().replace(other.slashPipe, '|'); + } + return cells; +} +/** + * Remove trailing 'c's. Equivalent to str.replace(/c*$/, ''). + * /c*$/ is vulnerable to REDOS. + * + * @param str + * @param c + * @param invert Remove suffix of non-c chars instead. Default falsey. + */ +function rtrim(str, c, invert) { + const l = str.length; + if (l === 0) { + return ''; + } + // Length of suffix matching the invert condition. + let suffLen = 0; + // Step left until we fail to match the invert condition. + while (suffLen < l) { + const currChar = str.charAt(l - suffLen - 1); + if (currChar === c && true) { + suffLen++; + } + else { + break; + } + } + return str.slice(0, l - suffLen); +} +function findClosingBracket(str, b) { + if (str.indexOf(b[1]) === -1) { + return -1; + } + let level = 0; + for (let i = 0; i < str.length; i++) { + if (str[i] === '\\') { + i++; + } + else if (str[i] === b[0]) { + level++; + } + else if (str[i] === b[1]) { + level--; + if (level < 0) { + return i; + } + } + } + return -1; +} + +function outputLink(cap, link, raw, lexer, rules) { + const href = link.href; + const title = link.title || null; + const text = cap[1].replace(rules.other.outputLinkReplace, '$1'); + if (cap[0].charAt(0) !== '!') { + lexer.state.inLink = true; + const token = { + type: 'link', + raw, + href, + title, + text, + tokens: lexer.inlineTokens(text), + }; + lexer.state.inLink = false; + return token; + } + return { + type: 'image', + raw, + href, + title, + text, + }; +} +function indentCodeCompensation(raw, text, rules) { + const matchIndentToCode = raw.match(rules.other.indentCodeCompensation); + if (matchIndentToCode === null) { + return text; + } + const indentToCode = matchIndentToCode[1]; + return text + .split('\n') + .map(node => { + const matchIndentInNode = node.match(rules.other.beginningSpace); + if (matchIndentInNode === null) { + return node; + } + const [indentInNode] = matchIndentInNode; + if (indentInNode.length >= indentToCode.length) { + return node.slice(indentToCode.length); + } + return node; + }) + .join('\n'); +} +/** + * Tokenizer + */ +class _Tokenizer { + options; + rules; // set by the lexer + lexer; // set by the lexer + constructor(options) { + this.options = options || _defaults; + } + space(src) { + const cap = this.rules.block.newline.exec(src); + if (cap && cap[0].length > 0) { + return { + type: 'space', + raw: cap[0], + }; + } + } + code(src) { + const cap = this.rules.block.code.exec(src); + if (cap) { + const text = cap[0].replace(this.rules.other.codeRemoveIndent, ''); + return { + type: 'code', + raw: cap[0], + codeBlockStyle: 'indented', + text: !this.options.pedantic + ? rtrim(text, '\n') + : text, + }; + } + } + fences(src) { + const cap = this.rules.block.fences.exec(src); + if (cap) { + const raw = cap[0]; + const text = indentCodeCompensation(raw, cap[3] || '', this.rules); + return { + type: 'code', + raw, + lang: cap[2] ? cap[2].trim().replace(this.rules.inline.anyPunctuation, '$1') : cap[2], + text, + }; + } + } + heading(src) { + const cap = this.rules.block.heading.exec(src); + if (cap) { + let text = cap[2].trim(); + // remove trailing #s + if (this.rules.other.endingHash.test(text)) { + const trimmed = rtrim(text, '#'); + if (this.options.pedantic) { + text = trimmed.trim(); + } + else if (!trimmed || this.rules.other.endingSpaceChar.test(trimmed)) { + // CommonMark requires space before trailing #s + text = trimmed.trim(); + } + } + return { + type: 'heading', + raw: cap[0], + depth: cap[1].length, + text, + tokens: this.lexer.inline(text), + }; + } + } + hr(src) { + const cap = this.rules.block.hr.exec(src); + if (cap) { + return { + type: 'hr', + raw: rtrim(cap[0], '\n'), + }; + } + } + blockquote(src) { + const cap = this.rules.block.blockquote.exec(src); + if (cap) { + let lines = rtrim(cap[0], '\n').split('\n'); + let raw = ''; + let text = ''; + const tokens = []; + while (lines.length > 0) { + let inBlockquote = false; + const currentLines = []; + let i; + for (i = 0; i < lines.length; i++) { + // get lines up to a continuation + if (this.rules.other.blockquoteStart.test(lines[i])) { + currentLines.push(lines[i]); + inBlockquote = true; + } + else if (!inBlockquote) { + currentLines.push(lines[i]); + } + else { + break; + } + } + lines = lines.slice(i); + const currentRaw = currentLines.join('\n'); + const currentText = currentRaw + // precede setext continuation with 4 spaces so it isn't a setext + .replace(this.rules.other.blockquoteSetextReplace, '\n $1') + .replace(this.rules.other.blockquoteSetextReplace2, ''); + raw = raw ? `${raw}\n${currentRaw}` : currentRaw; + text = text ? `${text}\n${currentText}` : currentText; + // parse blockquote lines as top level tokens + // merge paragraphs if this is a continuation + const top = this.lexer.state.top; + this.lexer.state.top = true; + this.lexer.blockTokens(currentText, tokens, true); + this.lexer.state.top = top; + // if there is no continuation then we are done + if (lines.length === 0) { + break; + } + const lastToken = tokens.at(-1); + if (lastToken?.type === 'code') { + // blockquote continuation cannot be preceded by a code block + break; + } + else if (lastToken?.type === 'blockquote') { + // include continuation in nested blockquote + const oldToken = lastToken; + const newText = oldToken.raw + '\n' + lines.join('\n'); + const newToken = this.blockquote(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - oldToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.text.length) + newToken.text; + break; + } + else if (lastToken?.type === 'list') { + // include continuation in nested list + const oldToken = lastToken; + const newText = oldToken.raw + '\n' + lines.join('\n'); + const newToken = this.list(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - lastToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.raw.length) + newToken.raw; + lines = newText.substring(tokens.at(-1).raw.length).split('\n'); + continue; + } + } + return { + type: 'blockquote', + raw, + tokens, + text, + }; + } + } + list(src) { + let cap = this.rules.block.list.exec(src); + if (cap) { + let bull = cap[1].trim(); + const isordered = bull.length > 1; + const list = { + type: 'list', + raw: '', + ordered: isordered, + start: isordered ? +bull.slice(0, -1) : '', + loose: false, + items: [], + }; + bull = isordered ? `\\d{1,9}\\${bull.slice(-1)}` : `\\${bull}`; + if (this.options.pedantic) { + bull = isordered ? bull : '[*+-]'; + } + // Get next list item + const itemRegex = this.rules.other.listItemRegex(bull); + let endsWithBlankLine = false; + // Check if current bullet point can start a new List Item + while (src) { + let endEarly = false; + let raw = ''; + let itemContents = ''; + if (!(cap = itemRegex.exec(src))) { + break; + } + if (this.rules.block.hr.test(src)) { // End list if bullet was actually HR (possibly move into itemRegex?) + break; + } + raw = cap[0]; + src = src.substring(raw.length); + let line = cap[2].split('\n', 1)[0].replace(this.rules.other.listReplaceTabs, (t) => ' '.repeat(3 * t.length)); + let nextLine = src.split('\n', 1)[0]; + let blankLine = !line.trim(); + let indent = 0; + if (this.options.pedantic) { + indent = 2; + itemContents = line.trimStart(); + } + else if (blankLine) { + indent = cap[1].length + 1; + } + else { + indent = cap[2].search(this.rules.other.nonSpaceChar); // Find first non-space char + indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent + itemContents = line.slice(indent); + indent += cap[1].length; + } + if (blankLine && this.rules.other.blankLine.test(nextLine)) { // Items begin with at most one blank line + raw += nextLine + '\n'; + src = src.substring(nextLine.length + 1); + endEarly = true; + } + if (!endEarly) { + const nextBulletRegex = this.rules.other.nextBulletRegex(indent); + const hrRegex = this.rules.other.hrRegex(indent); + const fencesBeginRegex = this.rules.other.fencesBeginRegex(indent); + const headingBeginRegex = this.rules.other.headingBeginRegex(indent); + const htmlBeginRegex = this.rules.other.htmlBeginRegex(indent); + // Check if following lines should be included in List Item + while (src) { + const rawLine = src.split('\n', 1)[0]; + let nextLineWithoutTabs; + nextLine = rawLine; + // Re-align to follow commonmark nesting rules + if (this.options.pedantic) { + nextLine = nextLine.replace(this.rules.other.listReplaceNesting, ' '); + nextLineWithoutTabs = nextLine; + } + else { + nextLineWithoutTabs = nextLine.replace(this.rules.other.tabCharGlobal, ' '); + } + // End list item if found code fences + if (fencesBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new heading + if (headingBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of html block + if (htmlBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new bullet + if (nextBulletRegex.test(nextLine)) { + break; + } + // Horizontal rule found + if (hrRegex.test(nextLine)) { + break; + } + if (nextLineWithoutTabs.search(this.rules.other.nonSpaceChar) >= indent || !nextLine.trim()) { // Dedent if possible + itemContents += '\n' + nextLineWithoutTabs.slice(indent); + } + else { + // not enough indentation + if (blankLine) { + break; + } + // paragraph continuation unless last line was a different block level element + if (line.replace(this.rules.other.tabCharGlobal, ' ').search(this.rules.other.nonSpaceChar) >= 4) { // indented code block + break; + } + if (fencesBeginRegex.test(line)) { + break; + } + if (headingBeginRegex.test(line)) { + break; + } + if (hrRegex.test(line)) { + break; + } + itemContents += '\n' + nextLine; + } + if (!blankLine && !nextLine.trim()) { // Check if current line is blank + blankLine = true; + } + raw += rawLine + '\n'; + src = src.substring(rawLine.length + 1); + line = nextLineWithoutTabs.slice(indent); + } + } + if (!list.loose) { + // If the previous item ended with a blank line, the list is loose + if (endsWithBlankLine) { + list.loose = true; + } + else if (this.rules.other.doubleBlankLine.test(raw)) { + endsWithBlankLine = true; + } + } + let istask = null; + let ischecked; + // Check for task list items + if (this.options.gfm) { + istask = this.rules.other.listIsTask.exec(itemContents); + if (istask) { + ischecked = istask[0] !== '[ ] '; + itemContents = itemContents.replace(this.rules.other.listReplaceTask, ''); + } + } + list.items.push({ + type: 'list_item', + raw, + task: !!istask, + checked: ischecked, + loose: false, + text: itemContents, + tokens: [], + }); + list.raw += raw; + } + // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic + const lastItem = list.items.at(-1); + if (lastItem) { + lastItem.raw = lastItem.raw.trimEnd(); + lastItem.text = lastItem.text.trimEnd(); + } + else { + // not a list since there were no items + return; + } + list.raw = list.raw.trimEnd(); + // Item child tokens handled here at end because we needed to have the final item to trim it first + for (let i = 0; i < list.items.length; i++) { + this.lexer.state.top = false; + list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, []); + if (!list.loose) { + // Check if list should be loose + const spacers = list.items[i].tokens.filter(t => t.type === 'space'); + const hasMultipleLineBreaks = spacers.length > 0 && spacers.some(t => this.rules.other.anyLine.test(t.raw)); + list.loose = hasMultipleLineBreaks; + } + } + // Set all items to loose if list is loose + if (list.loose) { + for (let i = 0; i < list.items.length; i++) { + list.items[i].loose = true; + } + } + return list; + } + } + html(src) { + const cap = this.rules.block.html.exec(src); + if (cap) { + const token = { + type: 'html', + block: true, + raw: cap[0], + pre: cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style', + text: cap[0], + }; + return token; + } + } + def(src) { + const cap = this.rules.block.def.exec(src); + if (cap) { + const tag = cap[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal, ' '); + const href = cap[2] ? cap[2].replace(this.rules.other.hrefBrackets, '$1').replace(this.rules.inline.anyPunctuation, '$1') : ''; + const title = cap[3] ? cap[3].substring(1, cap[3].length - 1).replace(this.rules.inline.anyPunctuation, '$1') : cap[3]; + return { + type: 'def', + tag, + raw: cap[0], + href, + title, + }; + } + } + table(src) { + const cap = this.rules.block.table.exec(src); + if (!cap) { + return; + } + if (!this.rules.other.tableDelimiter.test(cap[2])) { + // delimiter row must have a pipe (|) or colon (:) otherwise it is a setext heading + return; + } + const headers = splitCells(cap[1]); + const aligns = cap[2].replace(this.rules.other.tableAlignChars, '').split('|'); + const rows = cap[3]?.trim() ? cap[3].replace(this.rules.other.tableRowBlankLine, '').split('\n') : []; + const item = { + type: 'table', + raw: cap[0], + header: [], + align: [], + rows: [], + }; + if (headers.length !== aligns.length) { + // header and align columns must be equal, rows can be different. + return; + } + for (const align of aligns) { + if (this.rules.other.tableAlignRight.test(align)) { + item.align.push('right'); + } + else if (this.rules.other.tableAlignCenter.test(align)) { + item.align.push('center'); + } + else if (this.rules.other.tableAlignLeft.test(align)) { + item.align.push('left'); + } + else { + item.align.push(null); + } + } + for (let i = 0; i < headers.length; i++) { + item.header.push({ + text: headers[i], + tokens: this.lexer.inline(headers[i]), + header: true, + align: item.align[i], + }); + } + for (const row of rows) { + item.rows.push(splitCells(row, item.header.length).map((cell, i) => { + return { + text: cell, + tokens: this.lexer.inline(cell), + header: false, + align: item.align[i], + }; + })); + } + return item; + } + lheading(src) { + const cap = this.rules.block.lheading.exec(src); + if (cap) { + return { + type: 'heading', + raw: cap[0], + depth: cap[2].charAt(0) === '=' ? 1 : 2, + text: cap[1], + tokens: this.lexer.inline(cap[1]), + }; + } + } + paragraph(src) { + const cap = this.rules.block.paragraph.exec(src); + if (cap) { + const text = cap[1].charAt(cap[1].length - 1) === '\n' + ? cap[1].slice(0, -1) + : cap[1]; + return { + type: 'paragraph', + raw: cap[0], + text, + tokens: this.lexer.inline(text), + }; + } + } + text(src) { + const cap = this.rules.block.text.exec(src); + if (cap) { + return { + type: 'text', + raw: cap[0], + text: cap[0], + tokens: this.lexer.inline(cap[0]), + }; + } + } + escape(src) { + const cap = this.rules.inline.escape.exec(src); + if (cap) { + return { + type: 'escape', + raw: cap[0], + text: cap[1], + }; + } + } + tag(src) { + const cap = this.rules.inline.tag.exec(src); + if (cap) { + if (!this.lexer.state.inLink && this.rules.other.startATag.test(cap[0])) { + this.lexer.state.inLink = true; + } + else if (this.lexer.state.inLink && this.rules.other.endATag.test(cap[0])) { + this.lexer.state.inLink = false; + } + if (!this.lexer.state.inRawBlock && this.rules.other.startPreScriptTag.test(cap[0])) { + this.lexer.state.inRawBlock = true; + } + else if (this.lexer.state.inRawBlock && this.rules.other.endPreScriptTag.test(cap[0])) { + this.lexer.state.inRawBlock = false; + } + return { + type: 'html', + raw: cap[0], + inLink: this.lexer.state.inLink, + inRawBlock: this.lexer.state.inRawBlock, + block: false, + text: cap[0], + }; + } + } + link(src) { + const cap = this.rules.inline.link.exec(src); + if (cap) { + const trimmedUrl = cap[2].trim(); + if (!this.options.pedantic && this.rules.other.startAngleBracket.test(trimmedUrl)) { + // commonmark requires matching angle brackets + if (!(this.rules.other.endAngleBracket.test(trimmedUrl))) { + return; + } + // ending angle bracket cannot be escaped + const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\'); + if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { + return; + } + } + else { + // find closing parenthesis + const lastParenIndex = findClosingBracket(cap[2], '()'); + if (lastParenIndex > -1) { + const start = cap[0].indexOf('!') === 0 ? 5 : 4; + const linkLen = start + cap[1].length + lastParenIndex; + cap[2] = cap[2].substring(0, lastParenIndex); + cap[0] = cap[0].substring(0, linkLen).trim(); + cap[3] = ''; + } + } + let href = cap[2]; + let title = ''; + if (this.options.pedantic) { + // split pedantic href and title + const link = this.rules.other.pedanticHrefTitle.exec(href); + if (link) { + href = link[1]; + title = link[3]; + } + } + else { + title = cap[3] ? cap[3].slice(1, -1) : ''; + } + href = href.trim(); + if (this.rules.other.startAngleBracket.test(href)) { + if (this.options.pedantic && !(this.rules.other.endAngleBracket.test(trimmedUrl))) { + // pedantic allows starting angle bracket without ending angle bracket + href = href.slice(1); + } + else { + href = href.slice(1, -1); + } + } + return outputLink(cap, { + href: href ? href.replace(this.rules.inline.anyPunctuation, '$1') : href, + title: title ? title.replace(this.rules.inline.anyPunctuation, '$1') : title, + }, cap[0], this.lexer, this.rules); + } + } + reflink(src, links) { + let cap; + if ((cap = this.rules.inline.reflink.exec(src)) + || (cap = this.rules.inline.nolink.exec(src))) { + const linkString = (cap[2] || cap[1]).replace(this.rules.other.multipleSpaceGlobal, ' '); + const link = links[linkString.toLowerCase()]; + if (!link) { + const text = cap[0].charAt(0); + return { + type: 'text', + raw: text, + text, + }; + } + return outputLink(cap, link, cap[0], this.lexer, this.rules); + } + } + emStrong(src, maskedSrc, prevChar = '') { + let match = this.rules.inline.emStrongLDelim.exec(src); + if (!match) + return; + // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well + if (match[3] && prevChar.match(this.rules.other.unicodeAlphaNumeric)) + return; + const nextChar = match[1] || match[2] || ''; + if (!nextChar || !prevChar || this.rules.inline.punctuation.exec(prevChar)) { + // unicode Regex counts emoji as 1 char; spread into array for proper count (used multiple times below) + const lLength = [...match[0]].length - 1; + let rDelim, rLength, delimTotal = lLength, midDelimTotal = 0; + const endReg = match[0][0] === '*' ? this.rules.inline.emStrongRDelimAst : this.rules.inline.emStrongRDelimUnd; + endReg.lastIndex = 0; + // Clip maskedSrc to same section of string as src (move to lexer?) + maskedSrc = maskedSrc.slice(-1 * src.length + lLength); + while ((match = endReg.exec(maskedSrc)) != null) { + rDelim = match[1] || match[2] || match[3] || match[4] || match[5] || match[6]; + if (!rDelim) + continue; // skip single * in __abc*abc__ + rLength = [...rDelim].length; + if (match[3] || match[4]) { // found another Left Delim + delimTotal += rLength; + continue; + } + else if (match[5] || match[6]) { // either Left or Right Delim + if (lLength % 3 && !((lLength + rLength) % 3)) { + midDelimTotal += rLength; + continue; // CommonMark Emphasis Rules 9-10 + } + } + delimTotal -= rLength; + if (delimTotal > 0) + continue; // Haven't found enough closing delimiters + // Remove extra characters. *a*** -> *a* + rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal); + // char length can be >1 for unicode characters; + const lastCharLength = [...match[0]][0].length; + const raw = src.slice(0, lLength + match.index + lastCharLength + rLength); + // Create `em` if smallest delimiter has odd char count. *a*** + if (Math.min(lLength, rLength) % 2) { + const text = raw.slice(1, -1); + return { + type: 'em', + raw, + text, + tokens: this.lexer.inlineTokens(text), + }; + } + // Create 'strong' if smallest delimiter has even char count. **a*** + const text = raw.slice(2, -2); + return { + type: 'strong', + raw, + text, + tokens: this.lexer.inlineTokens(text), + }; + } + } + } + codespan(src) { + const cap = this.rules.inline.code.exec(src); + if (cap) { + let text = cap[2].replace(this.rules.other.newLineCharGlobal, ' '); + const hasNonSpaceChars = this.rules.other.nonSpaceChar.test(text); + const hasSpaceCharsOnBothEnds = this.rules.other.startingSpaceChar.test(text) && this.rules.other.endingSpaceChar.test(text); + if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { + text = text.substring(1, text.length - 1); + } + return { + type: 'codespan', + raw: cap[0], + text, + }; + } + } + br(src) { + const cap = this.rules.inline.br.exec(src); + if (cap) { + return { + type: 'br', + raw: cap[0], + }; + } + } + del(src) { + const cap = this.rules.inline.del.exec(src); + if (cap) { + return { + type: 'del', + raw: cap[0], + text: cap[2], + tokens: this.lexer.inlineTokens(cap[2]), + }; + } + } + autolink(src) { + const cap = this.rules.inline.autolink.exec(src); + if (cap) { + let text, href; + if (cap[2] === '@') { + text = cap[1]; + href = 'mailto:' + text; + } + else { + text = cap[1]; + href = text; + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text, + }, + ], + }; + } + } + url(src) { + let cap; + if (cap = this.rules.inline.url.exec(src)) { + let text, href; + if (cap[2] === '@') { + text = cap[0]; + href = 'mailto:' + text; + } + else { + // do extended autolink path validation + let prevCapZero; + do { + prevCapZero = cap[0]; + cap[0] = this.rules.inline._backpedal.exec(cap[0])?.[0] ?? ''; + } while (prevCapZero !== cap[0]); + text = cap[0]; + if (cap[1] === 'www.') { + href = 'http://' + cap[0]; + } + else { + href = cap[0]; + } + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text, + }, + ], + }; + } + } + inlineText(src) { + const cap = this.rules.inline.text.exec(src); + if (cap) { + const escaped = this.lexer.state.inRawBlock; + return { + type: 'text', + raw: cap[0], + text: cap[0], + escaped, + }; + } + } +} + +/** + * Block Lexer + */ +class _Lexer { + tokens; + options; + state; + tokenizer; + inlineQueue; + constructor(options) { + // TokenList cannot be created in one go + this.tokens = []; + this.tokens.links = Object.create(null); + this.options = options || _defaults; + this.options.tokenizer = this.options.tokenizer || new _Tokenizer(); + this.tokenizer = this.options.tokenizer; + this.tokenizer.options = this.options; + this.tokenizer.lexer = this; + this.inlineQueue = []; + this.state = { + inLink: false, + inRawBlock: false, + top: true, + }; + const rules = { + other, + block: block.normal, + inline: inline.normal, + }; + if (this.options.pedantic) { + rules.block = block.pedantic; + rules.inline = inline.pedantic; + } + else if (this.options.gfm) { + rules.block = block.gfm; + if (this.options.breaks) { + rules.inline = inline.breaks; + } + else { + rules.inline = inline.gfm; + } + } + this.tokenizer.rules = rules; + } + /** + * Expose Rules + */ + static get rules() { + return { + block, + inline, + }; + } + /** + * Static Lex Method + */ + static lex(src, options) { + const lexer = new _Lexer(options); + return lexer.lex(src); + } + /** + * Static Lex Inline Method + */ + static lexInline(src, options) { + const lexer = new _Lexer(options); + return lexer.inlineTokens(src); + } + /** + * Preprocessing + */ + lex(src) { + src = src.replace(other.carriageReturn, '\n'); + this.blockTokens(src, this.tokens); + for (let i = 0; i < this.inlineQueue.length; i++) { + const next = this.inlineQueue[i]; + this.inlineTokens(next.src, next.tokens); + } + this.inlineQueue = []; + return this.tokens; + } + blockTokens(src, tokens = [], lastParagraphClipped = false) { + if (this.options.pedantic) { + src = src.replace(other.tabCharGlobal, ' ').replace(other.spaceLine, ''); + } + while (src) { + let token; + if (this.options.extensions?.block?.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // newline + if (token = this.tokenizer.space(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (token.raw.length === 1 && lastToken !== undefined) { + // if there's a single \n as a spacer, it's terminating the last line, + // so move it there so that we don't get unnecessary paragraph tags + lastToken.raw += '\n'; + } + else { + tokens.push(token); + } + continue; + } + // code + if (token = this.tokenizer.code(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + // An indented code block cannot interrupt a paragraph. + if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + // fences + if (token = this.tokenizer.fences(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // heading + if (token = this.tokenizer.heading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // hr + if (token = this.tokenizer.hr(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // blockquote + if (token = this.tokenizer.blockquote(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // list + if (token = this.tokenizer.list(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // html + if (token = this.tokenizer.html(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // def + if (token = this.tokenizer.def(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.raw; + this.inlineQueue.at(-1).src = lastToken.text; + } + else if (!this.tokens.links[token.tag]) { + this.tokens.links[token.tag] = { + href: token.href, + title: token.title, + }; + } + continue; + } + // table (gfm) + if (token = this.tokenizer.table(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // lheading + if (token = this.tokenizer.lheading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // top-level paragraph + // prevent paragraph consuming extensions by clipping 'src' to extension start + let cutSrc = src; + if (this.options.extensions?.startBlock) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startBlock.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) { + const lastToken = tokens.at(-1); + if (lastParagraphClipped && lastToken?.type === 'paragraph') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + lastParagraphClipped = cutSrc.length !== src.length; + src = src.substring(token.raw.length); + continue; + } + // text + if (token = this.tokenizer.text(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + this.state.top = true; + return tokens; + } + inline(src, tokens = []) { + this.inlineQueue.push({ src, tokens }); + return tokens; + } + /** + * Lexing/Compiling + */ + inlineTokens(src, tokens = []) { + // String with links masked to avoid interference with em and strong + let maskedSrc = src; + let match = null; + // Mask out reflinks + if (this.tokens.links) { + const links = Object.keys(this.tokens.links); + if (links.length > 0) { + while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) != null) { + if (links.includes(match[0].slice(match[0].lastIndexOf('[') + 1, -1))) { + maskedSrc = maskedSrc.slice(0, match.index) + + '[' + 'a'.repeat(match[0].length - 2) + ']' + + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex); + } + } + } + } + // Mask out other blocks + while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '[' + 'a'.repeat(match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex); + } + // Mask out escaped characters + while ((match = this.tokenizer.rules.inline.anyPunctuation.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '++' + maskedSrc.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex); + } + let keepPrevChar = false; + let prevChar = ''; + while (src) { + if (!keepPrevChar) { + prevChar = ''; + } + keepPrevChar = false; + let token; + // extensions + if (this.options.extensions?.inline?.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // escape + if (token = this.tokenizer.escape(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // tag + if (token = this.tokenizer.tag(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // link + if (token = this.tokenizer.link(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // reflink, nolink + if (token = this.tokenizer.reflink(src, this.tokens.links)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (token.type === 'text' && lastToken?.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + // em & strong + if (token = this.tokenizer.emStrong(src, maskedSrc, prevChar)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // code + if (token = this.tokenizer.codespan(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // br + if (token = this.tokenizer.br(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // del (gfm) + if (token = this.tokenizer.del(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // autolink + if (token = this.tokenizer.autolink(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // url (gfm) + if (!this.state.inLink && (token = this.tokenizer.url(src))) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // text + // prevent inlineText consuming extensions by clipping 'src' to extension start + let cutSrc = src; + if (this.options.extensions?.startInline) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startInline.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (token = this.tokenizer.inlineText(cutSrc)) { + src = src.substring(token.raw.length); + if (token.raw.slice(-1) !== '_') { // Track prevChar before string of ____ started + prevChar = token.raw.slice(-1); + } + keepPrevChar = true; + const lastToken = tokens.at(-1); + if (lastToken?.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + return tokens; + } +} + +/** + * Renderer + */ +class _Renderer { + options; + parser; // set by the parser + constructor(options) { + this.options = options || _defaults; + } + space(token) { + return ''; + } + code({ text, lang, escaped }) { + const langString = (lang || '').match(other.notSpaceStart)?.[0]; + const code = text.replace(other.endingNewline, '') + '\n'; + if (!langString) { + return '
'
+                + (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`; + } + hr(token) { + return '
\n'; + } + list(token) { + const ordered = token.ordered; + const start = token.start; + let body = ''; + for (let j = 0; j < token.items.length; j++) { + const item = token.items[j]; + body += this.listitem(item); + } + const type = ordered ? 'ol' : 'ul'; + const startAttr = (ordered && start !== 1) ? (' start="' + start + '"') : ''; + return '<' + type + startAttr + '>\n' + body + '\n'; + } + listitem(item) { + let itemBody = ''; + if (item.task) { + const checkbox = this.checkbox({ checked: !!item.checked }); + if (item.loose) { + if (item.tokens[0]?.type === 'paragraph') { + item.tokens[0].text = checkbox + ' ' + item.tokens[0].text; + if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') { + item.tokens[0].tokens[0].text = checkbox + ' ' + escape(item.tokens[0].tokens[0].text); + item.tokens[0].tokens[0].escaped = true; + } + } + else { + item.tokens.unshift({ + type: 'text', + raw: checkbox + ' ', + text: checkbox + ' ', + escaped: true, + }); + } + } + else { + itemBody += checkbox + ' '; + } + } + itemBody += this.parser.parse(item.tokens, !!item.loose); + return `
  • ${itemBody}
  • \n`; + } + checkbox({ checked }) { + return ''; + } + paragraph({ tokens }) { + 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 '\n' + + '\n' + + header + + '\n' + + body + + '
    \n'; + } + tablerow({ text }) { + return `\n${text}\n`; + } + tablecell(token) { + const content = this.parser.parseInline(token.tokens); + const type = token.header ? 'th' : 'td'; + const tag = token.align + ? `<${type} align="${token.align}">` + : `<${type}>`; + return tag + content + `\n`; + } + /** + * span level renderer + */ + strong({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + em({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + codespan({ text }) { + return `${escape(text, true)}`; + } + br(token) { + return '
    '; + } + del({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + link({ href, title, tokens }) { + const text = this.parser.parseInline(tokens); + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return text; + } + href = cleanHref; + let out = '
    '; + return out; + } + image({ href, title, text }) { + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return escape(text); + } + href = cleanHref; + let out = `${text} { + const tokens = genericToken[childTokens].flat(Infinity); + values = values.concat(this.walkTokens(tokens, callback)); + }); + } + else if (genericToken.tokens) { + values = values.concat(this.walkTokens(genericToken.tokens, callback)); + } + } + } + } + return values; + } + use(...args) { + const extensions = this.defaults.extensions || { renderers: {}, childTokens: {} }; + args.forEach((pack) => { + // copy options to new object + const opts = { ...pack }; + // set async to true if it was set to true before + opts.async = this.defaults.async || opts.async || false; + // ==-- Parse "addon" extensions --== // + if (pack.extensions) { + pack.extensions.forEach((ext) => { + if (!ext.name) { + throw new Error('extension name required'); + } + if ('renderer' in ext) { // Renderer extensions + const prevRenderer = extensions.renderers[ext.name]; + if (prevRenderer) { + // Replace extension with func to run new extension but fall back if false + extensions.renderers[ext.name] = function (...args) { + let ret = ext.renderer.apply(this, args); + if (ret === false) { + ret = prevRenderer.apply(this, args); + } + return ret; + }; + } + else { + extensions.renderers[ext.name] = ext.renderer; + } + } + if ('tokenizer' in ext) { // Tokenizer Extensions + if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) { + throw new Error("extension level must be 'block' or 'inline'"); + } + const extLevel = extensions[ext.level]; + if (extLevel) { + extLevel.unshift(ext.tokenizer); + } + else { + extensions[ext.level] = [ext.tokenizer]; + } + if (ext.start) { // Function to check for start of token + if (ext.level === 'block') { + if (extensions.startBlock) { + extensions.startBlock.push(ext.start); + } + else { + extensions.startBlock = [ext.start]; + } + } + else if (ext.level === 'inline') { + if (extensions.startInline) { + extensions.startInline.push(ext.start); + } + else { + extensions.startInline = [ext.start]; + } + } + } + } + if ('childTokens' in ext && ext.childTokens) { // Child tokens to be visited by walkTokens + extensions.childTokens[ext.name] = ext.childTokens; + } + }); + opts.extensions = extensions; + } + // ==-- Parse "overwrite" extensions --== // + if (pack.renderer) { + const renderer = this.defaults.renderer || new _Renderer(this.defaults); + for (const prop in pack.renderer) { + if (!(prop in renderer)) { + throw new Error(`renderer '${prop}' does not exist`); + } + if (['options', 'parser'].includes(prop)) { + // ignore options property + continue; + } + const rendererProp = prop; + const rendererFunc = pack.renderer[rendererProp]; + const prevRenderer = renderer[rendererProp]; + // Replace renderer with func to run extension, but fall back if false + renderer[rendererProp] = (...args) => { + let ret = rendererFunc.apply(renderer, args); + if (ret === false) { + ret = prevRenderer.apply(renderer, args); + } + return ret || ''; + }; + } + opts.renderer = renderer; + } + if (pack.tokenizer) { + const tokenizer = this.defaults.tokenizer || new _Tokenizer(this.defaults); + for (const prop in pack.tokenizer) { + if (!(prop in tokenizer)) { + throw new Error(`tokenizer '${prop}' does not exist`); + } + if (['options', 'rules', 'lexer'].includes(prop)) { + // ignore options, rules, and lexer properties + continue; + } + const tokenizerProp = prop; + const tokenizerFunc = pack.tokenizer[tokenizerProp]; + const prevTokenizer = tokenizer[tokenizerProp]; + // Replace tokenizer with func to run extension, but fall back if false + // @ts-expect-error cannot type tokenizer function dynamically + tokenizer[tokenizerProp] = (...args) => { + let ret = tokenizerFunc.apply(tokenizer, args); + if (ret === false) { + ret = prevTokenizer.apply(tokenizer, args); + } + return ret; + }; + } + opts.tokenizer = tokenizer; + } + // ==-- Parse Hooks extensions --== // + if (pack.hooks) { + const hooks = this.defaults.hooks || new _Hooks(); + for (const prop in pack.hooks) { + if (!(prop in hooks)) { + throw new Error(`hook '${prop}' does not exist`); + } + if (['options', 'block'].includes(prop)) { + // ignore options and block properties + continue; + } + const hooksProp = prop; + const hooksFunc = pack.hooks[hooksProp]; + const prevHook = hooks[hooksProp]; + if (_Hooks.passThroughHooks.has(prop)) { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (arg) => { + if (this.defaults.async) { + return Promise.resolve(hooksFunc.call(hooks, arg)).then(ret => { + return prevHook.call(hooks, ret); + }); + } + const ret = hooksFunc.call(hooks, arg); + return prevHook.call(hooks, ret); + }; + } + else { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (...args) => { + let ret = hooksFunc.apply(hooks, args); + if (ret === false) { + ret = prevHook.apply(hooks, args); + } + return ret; + }; + } + } + opts.hooks = hooks; + } + // ==-- Parse WalkTokens extensions --== // + if (pack.walkTokens) { + const walkTokens = this.defaults.walkTokens; + const packWalktokens = pack.walkTokens; + opts.walkTokens = function (token) { + let values = []; + values.push(packWalktokens.call(this, token)); + if (walkTokens) { + values = values.concat(walkTokens.call(this, token)); + } + return values; + }; + } + this.defaults = { ...this.defaults, ...opts }; + }); + return this; + } + setOptions(opt) { + this.defaults = { ...this.defaults, ...opt }; + return this; + } + lexer(src, options) { + return _Lexer.lex(src, options ?? this.defaults); + } + parser(tokens, options) { + return _Parser.parse(tokens, options ?? this.defaults); + } + parseMarkdown(blockType) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parse = (src, options) => { + const origOpt = { ...options }; + const opt = { ...this.defaults, ...origOpt }; + const throwError = this.onError(!!opt.silent, !!opt.async); + // throw error if an extension set async to true but parse was called with async: false + if (this.defaults.async === true && origOpt.async === false) { + return throwError(new Error('marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.')); + } + // throw error in case of non string input + if (typeof src === 'undefined' || src === null) { + return throwError(new Error('marked(): input parameter is undefined or null')); + } + if (typeof src !== 'string') { + return throwError(new Error('marked(): input parameter is of type ' + + Object.prototype.toString.call(src) + ', string expected')); + } + if (opt.hooks) { + opt.hooks.options = opt; + opt.hooks.block = blockType; + } + const lexer = opt.hooks ? opt.hooks.provideLexer() : (blockType ? _Lexer.lex : _Lexer.lexInline); + const parser = opt.hooks ? opt.hooks.provideParser() : (blockType ? _Parser.parse : _Parser.parseInline); + if (opt.async) { + return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src) + .then(src => lexer(src, opt)) + .then(tokens => opt.hooks ? opt.hooks.processAllTokens(tokens) : tokens) + .then(tokens => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens) + .then(tokens => parser(tokens, opt)) + .then(html => opt.hooks ? opt.hooks.postprocess(html) : html) + .catch(throwError); + } + try { + if (opt.hooks) { + src = opt.hooks.preprocess(src); + } + let tokens = lexer(src, opt); + if (opt.hooks) { + tokens = opt.hooks.processAllTokens(tokens); + } + if (opt.walkTokens) { + this.walkTokens(tokens, opt.walkTokens); + } + let html = parser(tokens, opt); + if (opt.hooks) { + html = opt.hooks.postprocess(html); + } + return html; + } + catch (e) { + return throwError(e); + } + }; + return parse; + } + onError(silent, async) { + return (e) => { + e.message += '\nPlease report this to https://github.com/markedjs/marked.'; + if (silent) { + const msg = '

    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' } + }); + } + }); + }) + ); +});