chore: 项目文件归类整理

- assets/icons/ → 图标资源 (llama.ico, llama.png)
- css/ → 样式文件 (style.css)
- js/ → 所有业务代码
- js/lib/ → 第三方库 (marked.esm.js)
- js/components/ → UI 组件

修正所有 import 路径、HTML 引用、SW 缓存列表
This commit is contained in:
thzxx
2026-04-03 11:40:01 +08:00
parent 38edaee0f0
commit 41b512c779
13 changed files with 44 additions and 19 deletions
+2 -2
View File
@@ -3,8 +3,8 @@
* 负责初始化所有组件、协调事件流、管理生命周期
*/
import { ChatDB } from '../chat-db.js';
import { OllamaAPI } from '../ollama-api.js';
import { ChatDB } from './chat-db.js';
import { OllamaAPI } from './ollama-api.js';
import { state, KEYS } from './state.js';
import { generateId } from './utils.js';
+193
View File
@@ -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);
});
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
import { state, KEYS } from '../state.js';
import { formatSize } from '../utils.js';
import { OllamaAPI } from '../../ollama-api.js';
import { OllamaAPI } from '../ollama-api.js';
let connStatusEl;
+1 -1
View File
@@ -7,7 +7,7 @@ import { debounce, formatSize } from '../utils.js';
import { updateConnectionInfo, updateRunningModels } from './header.js';
import { loadModels } from './model-bar.js';
import { showToast } from './toast.js';
import { OllamaAPI } from '../../ollama-api.js';
import { OllamaAPI } from '../ollama-api.js';
let settingsModalEl;
+2580
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@
* 封装 marked.js,配置自定义渲染器和安全策略
*/
import { marked } from '../marked.esm.js';
import { marked } from './lib/marked.esm.js';
import { SAFE_URI_SCHEMES } from './sanitizer.js';
// 配置 marked
+169
View File
@@ -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 });
}
}