初始版本 - Metona Ollama Client v1.1.0
This commit is contained in:
@@ -0,0 +1,100 @@
|
|||||||
|
# 🦙 Metona Ollama Client
|
||||||
|
|
||||||
|
基于原生 JavaScript 的 [Ollama](https://ollama.com) AI 聊天客户端,支持流式对话、多模态图片、Think 推理模式、历史管理,可安装为 PWA 离线使用。
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## ✨ 功能特性
|
||||||
|
|
||||||
|
- **流式对话** — 基于 ReadableStream 的实时打字机效果
|
||||||
|
- **多模型支持** — 自动加载 Ollama 已安装模型,一键切换
|
||||||
|
- **Think 推理模式** — 可展开/收起的思考过程展示
|
||||||
|
- **多模态输入** — 支持图片上传,兼容视觉模型
|
||||||
|
- **历史管理** — IndexedDB 持久化,支持搜索、分页、导出(Markdown/HTML/TXT/JSON)
|
||||||
|
- **PWA 离线** — Service Worker 缓存,可安装到桌面
|
||||||
|
- **连接检测** — 实时状态指示,CORS 问题自动提示
|
||||||
|
- **显存管理** — 一键卸载模型释放显存
|
||||||
|
- **XSS 防护** — 内置 HTML 净化器,安全渲染 Markdown
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 前置条件
|
||||||
|
|
||||||
|
1. 安装 [Ollama](https://ollama.com) 并启动服务
|
||||||
|
2. 下载至少一个模型(例如 `ollama pull qwen2.5`)
|
||||||
|
|
||||||
|
### 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆仓库
|
||||||
|
git clone https://gitee.com/thzxx/metona-ollama.git
|
||||||
|
cd metona-ollama
|
||||||
|
|
||||||
|
# 直接用浏览器打开
|
||||||
|
open index.html
|
||||||
|
|
||||||
|
# 或用任意 HTTP 服务器托管
|
||||||
|
python3 -m http.server 8080
|
||||||
|
# 然后访问 http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 跨域配置
|
||||||
|
|
||||||
|
如果 Ollama 和页面不在同一域,需要设置环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OLLAMA_ORIGINS="*" ollama serve
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
metona-ollama/
|
||||||
|
├── index.html # 主页面 + 应用逻辑
|
||||||
|
├── style.css # 暗色主题样式
|
||||||
|
├── ollama-api.js # Ollama REST API 封装
|
||||||
|
├── chat-db.js # IndexedDB 持久化层
|
||||||
|
├── marked.esm.js # Markdown 渲染器
|
||||||
|
├── sw.js # Service Worker (PWA)
|
||||||
|
├── manifest.json # PWA 清单
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ 设置项
|
||||||
|
|
||||||
|
| 设置 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| Ollama 服务地址 | API 端点 | `http://127.0.0.1:11434` |
|
||||||
|
| 系统提示词 | 全局 System Prompt | 关闭 |
|
||||||
|
| 上下文长度 | `num_ctx` 参数 | 24576 tokens |
|
||||||
|
| Think 模式 | 启用深度推理(需要模型支持) | 关闭 |
|
||||||
|
|
||||||
|
## 📦 数据导出
|
||||||
|
|
||||||
|
支持以下导出格式:
|
||||||
|
|
||||||
|
- **Markdown** — 单会话导出,便于阅读
|
||||||
|
- **HTML** — 带样式的离线页面
|
||||||
|
- **TXT** — 纯文本备份
|
||||||
|
- **JSON** — 全量备份/迁移,可跨设备导入
|
||||||
|
|
||||||
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
|
- **纯原生 JS** — 零依赖(除 marked.js),无构建步骤
|
||||||
|
- **IndexedDB** — 异步持久化,支持大数据存储
|
||||||
|
- **Fetch API + ReadableStream** — 流式 NDJSON 解析
|
||||||
|
- **PWA** — Service Worker 离线缓存
|
||||||
|
- **CSS Variables** — 暗色主题,响应式布局
|
||||||
|
|
||||||
|
## 🔒 安全
|
||||||
|
|
||||||
|
- 内置 HTML 净化器(白名单标签 + 属性过滤)
|
||||||
|
- Markdown 链接协议白名单(http/https/mailto/tel)
|
||||||
|
- 阻止 `javascript:` / `vbscript:` / `data:` 协议注入
|
||||||
|
- 输入内容自动转义
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT
|
||||||
+193
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+1608
File diff suppressed because it is too large
Load Diff
@@ -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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🦙</text></svg>",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["productivity", "utilities"]
|
||||||
|
}
|
||||||
+2580
File diff suppressed because it is too large
Load Diff
+169
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user