|
| 1 | +/** |
| 2 | + * 窗口选择历史记忆(纯函数 + SQLite 薄封装) |
| 3 | + * |
| 4 | + * 按「进程名 + 标题模板」维度记忆用户选择:模板归一化(会议号/章节数字 → |
| 5 | + * 占位符)使同一窗口的反复选择命中同一记忆条目。boost 分 = min(useCount,5)×6 |
| 6 | + * + recency(7 天内 +10 / 30 天内 +5,封顶 +40)。 |
| 7 | + * |
| 8 | + * @ai-context: 标题模板化是记忆可靠性的关键——"腾讯会议 123456789"与 |
| 9 | + * "腾讯会议 987654321"必须命中同一条记忆,否则每次会议号变化都失忆。 |
| 10 | + * @ai-context EN: Choice memory keyed by process + normalized title template. |
| 11 | + * SQLite writes are a thin layer over sqliteService; pure functions are |
| 12 | + * unit-testable without the Electron-ABI better-sqlite3. |
| 13 | + */ |
| 14 | +import { getConnection } from './db/sqliteService.js'; |
| 15 | + |
| 16 | +// ================================================================ |
| 17 | +// 类型定义 |
| 18 | +// ================================================================ |
| 19 | + |
| 20 | +export interface WindowMemoryEntry { |
| 21 | + processName: string; |
| 22 | + titleHash: string; |
| 23 | + titleTemplate: string; |
| 24 | + courseName?: string; |
| 25 | + useCount: number; |
| 26 | + lastUsedAt: number; |
| 27 | +} |
| 28 | + |
| 29 | +/** 记忆上限(LRU 淘汰) */ |
| 30 | +export const MEMORY_MAX_ENTRIES = 100; |
| 31 | + |
| 32 | +// ================================================================ |
| 33 | +// 纯函数 |
| 34 | +// ================================================================ |
| 35 | + |
| 36 | +/** 数字(含千分位/标点包裹的编号)→ 占位符;保留标题其余结构 */ |
| 37 | +export function normalizeTitleTemplate(title: string): string { |
| 38 | + return (title ?? '') |
| 39 | + .replace(/\d[\d,,..\-—_]*/g, '{n}') |
| 40 | + .replace(/\{n\}[^-\w]*\{n\}/g, '{n}'); |
| 41 | +} |
| 42 | + |
| 43 | +/** djb2 字符串 hash → hex(稳定、碰撞概率可接受) */ |
| 44 | +export function hashTitleTemplate(template: string): string { |
| 45 | + let h = 5381; |
| 46 | + for (let i = 0; i < template.length; i += 1) { |
| 47 | + h = ((h << 5) + h + template.charCodeAt(i)) >>> 0; |
| 48 | + } |
| 49 | + return h.toString(16); |
| 50 | +} |
| 51 | + |
| 52 | +/** |
| 53 | + * 计算记忆 boost 分(封顶 +40)。 |
| 54 | + * @param now 当前时间戳(注入便于测试) |
| 55 | + */ |
| 56 | +export function computeMemoryBoost(entry: WindowMemoryEntry | null, now: number): number { |
| 57 | + if (!entry) return 0; |
| 58 | + const DAY = 24 * 3600 * 1000; |
| 59 | + let boost = Math.min(entry.useCount, 5) * 6; |
| 60 | + const age = now - entry.lastUsedAt; |
| 61 | + if (age >= 0 && age <= 7 * DAY) boost += 10; |
| 62 | + else if (age > 7 * DAY && age <= 30 * DAY) boost += 5; |
| 63 | + return Math.min(boost, 40); |
| 64 | +} |
| 65 | + |
| 66 | +/** LRU 淘汰:返回最久未使用的 titleHash(entries 为空返回 null) */ |
| 67 | +export function pickLruEviction( |
| 68 | + entries: Array<Pick<WindowMemoryEntry, 'titleHash' | 'lastUsedAt'>>, |
| 69 | +): string | null { |
| 70 | + let oldest: string | null = null; |
| 71 | + let oldestAt = Infinity; |
| 72 | + for (const e of entries) { |
| 73 | + if (e.lastUsedAt < oldestAt) { |
| 74 | + oldestAt = e.lastUsedAt; |
| 75 | + oldest = e.titleHash; |
| 76 | + } |
| 77 | + } |
| 78 | + return oldest; |
| 79 | +} |
| 80 | + |
| 81 | +// ================================================================ |
| 82 | +// SQLite 薄封装(运行时路径,异常由调用方降级) |
| 83 | +// ================================================================ |
| 84 | + |
| 85 | +/** 建表(幂等) */ |
| 86 | +function ensureTable(): void { |
| 87 | + getConnection() |
| 88 | + .prepare( |
| 89 | + `CREATE TABLE IF NOT EXISTS window_memory ( |
| 90 | + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 91 | + process_name TEXT NOT NULL, |
| 92 | + title_hash TEXT NOT NULL, |
| 93 | + title_template TEXT NOT NULL, |
| 94 | + course_name TEXT, |
| 95 | + use_count INTEGER NOT NULL DEFAULT 1, |
| 96 | + last_used_at INTEGER NOT NULL, |
| 97 | + UNIQUE(process_name, title_hash) |
| 98 | + )`, |
| 99 | + ) |
| 100 | + .run(); |
| 101 | +} |
| 102 | + |
| 103 | +/** 记录一次选择(upsert:use_count+1、更新 last_used_at 与 course_name) */ |
| 104 | +export function recordChoice( |
| 105 | + processName: string, |
| 106 | + title: string, |
| 107 | + courseName?: string, |
| 108 | +): void { |
| 109 | + try { |
| 110 | + ensureTable(); |
| 111 | + const template = normalizeTitleTemplate(title); |
| 112 | + const titleHash = hashTitleTemplate(template); |
| 113 | + const now = Date.now(); |
| 114 | + const db = getConnection(); |
| 115 | + db.prepare( |
| 116 | + `INSERT INTO window_memory (process_name, title_hash, title_template, course_name, use_count, last_used_at) |
| 117 | + VALUES (?, ?, ?, ?, 1, ?) |
| 118 | + ON CONFLICT(process_name, title_hash) DO UPDATE SET |
| 119 | + use_count = use_count + 1, |
| 120 | + last_used_at = excluded.last_used_at, |
| 121 | + course_name = COALESCE(excluded.course_name, window_memory.course_name)`, |
| 122 | + ).run(processName.toLowerCase(), titleHash, template, courseName ?? null, now); |
| 123 | + |
| 124 | + // LRU 淘汰:超过上限删除最久未使用条目 |
| 125 | + const row = db |
| 126 | + .prepare('SELECT COUNT(*) AS c FROM window_memory') |
| 127 | + .get() as { c: number }; |
| 128 | + if (row.c > MEMORY_MAX_ENTRIES) { |
| 129 | + const victims = db |
| 130 | + .prepare('SELECT title_hash, last_used_at FROM window_memory ORDER BY last_used_at ASC LIMIT ?') |
| 131 | + .all(row.c - MEMORY_MAX_ENTRIES) as Array<{ title_hash: string; last_used_at: number }>; |
| 132 | + const evict = pickLruEviction( |
| 133 | + victims.map((v) => ({ titleHash: v.title_hash, lastUsedAt: v.last_used_at })), |
| 134 | + ); |
| 135 | + if (evict) { |
| 136 | + db.prepare('DELETE FROM window_memory WHERE title_hash = ?').run(evict); |
| 137 | + } |
| 138 | + } |
| 139 | + } catch (err) { |
| 140 | + console.warn('[windowHistory] recordChoice failed:', err); |
| 141 | + } |
| 142 | +} |
| 143 | + |
| 144 | +/** 查询记忆条目(进程名 + 标题模板);无命中返回 null */ |
| 145 | +export function lookupMemory( |
| 146 | + processName: string, |
| 147 | + title: string, |
| 148 | +): WindowMemoryEntry | null { |
| 149 | + try { |
| 150 | + ensureTable(); |
| 151 | + const template = normalizeTitleTemplate(title); |
| 152 | + const row = getConnection() |
| 153 | + .prepare( |
| 154 | + `SELECT process_name AS processName, title_hash AS titleHash, title_template AS titleTemplate, |
| 155 | + course_name AS courseName, use_count AS useCount, last_used_at AS lastUsedAt |
| 156 | + FROM window_memory WHERE process_name = ? AND title_hash = ?`, |
| 157 | + ) |
| 158 | + .get(processName.toLowerCase(), hashTitleTemplate(template)) as |
| 159 | + | WindowMemoryEntry |
| 160 | + | undefined; |
| 161 | + return row ?? null; |
| 162 | + } catch { |
| 163 | + return null; |
| 164 | + } |
| 165 | +} |
| 166 | + |
| 167 | +/** 清空全部记忆(设置页入口) */ |
| 168 | +export function clearMemory(): void { |
| 169 | + try { |
| 170 | + ensureTable(); |
| 171 | + getConnection().prepare('DELETE FROM window_memory').run(); |
| 172 | + } catch { |
| 173 | + // 静默降级 |
| 174 | + } |
| 175 | +} |
0 commit comments