feat(app): add Electron desktop UI and evolve memory/retrieval layer

Introduce an Electron app with session browser, memory list, and usage
  views (vanilla JS + Vue scaffolding). On the data layer: add content_type
  and is_meta to messages for transcript control-plane filtering, introduce
  FTS5-backed memory recall with safe tokenization, support memory archival
  via forget() through the renamed --attune runtime, and expose anchors on
  memory records.
This commit is contained in:
tommy0103
2026-06-12 22:20:29 +08:00
parent b52f57b538
commit b524339d85
48 changed files with 16538 additions and 97 deletions
+194
View File
@@ -0,0 +1,194 @@
// Utility functions extracted from render.js
// Pure helpers with no side-effects on global state (except formatProjectLabel which reads state).
import { state } from './state.js';
// --- Time / formatting ---
export function pad2(n) { return String(n).padStart(2, '0'); }
export function isSameDay(a, b) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
export function fmtListTime(ts) {
const d = new Date(ts);
const now = new Date();
const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
if (isSameDay(d, now)) return hhmm;
const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`;
return `${d.getFullYear()}/${mmdd} ${hhmm}`;
}
export function fmtRelative(ts) {
const diff = Date.now() - ts;
const min = 60000, hr = 3600000, day = 86400000;
if (diff < 0) return 'in the future';
if (diff < min) return 'just now';
if (diff < hr) return Math.floor(diff / min) + 'm ago';
if (diff < day) return Math.floor(diff / hr) + 'h ago';
if (diff < day * 30) return Math.floor(diff / day) + 'd ago';
if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago';
return Math.floor(diff / (day * 365)) + 'y ago';
}
export function fmtClockTime(iso) {
const d = new Date(iso);
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
export function fmtSize(bytes) {
if (!bytes) return '-';
if (bytes < 1024) return bytes + 'B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K';
return (bytes / 1024 / 1024).toFixed(1) + 'M';
}
// --- HTML / Markdown ---
export function escapeHTML(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
export function highlightPlain(text, query) {
if (!query) return escapeHTML(text);
const safe = escapeHTML(text);
const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return safe.replace(new RegExp(q, 'gi'), m => `<mark>${m}</mark>`);
}
export function sanitizeMarkdown(html) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/\son\w+="[^"]*"/gi, '');
}
export function highlightTextNodes(rootEl, query) {
if (!query) return;
const q = query.toLowerCase();
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, null);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
const text = node.nodeValue;
if (!text) continue;
const lower = text.toLowerCase();
if (!lower.includes(q)) continue;
const frag = document.createDocumentFragment();
let last = 0, i = lower.indexOf(q);
while (i !== -1) {
if (i > last) frag.appendChild(document.createTextNode(text.slice(last, i)));
const mark = document.createElement('mark');
mark.textContent = text.slice(i, i + q.length);
frag.appendChild(mark);
last = i + q.length;
i = lower.indexOf(q, last);
}
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
node.parentNode.replaceChild(frag, node);
}
}
export function renderMarkdown(text, opts = {}) {
if (text == null) return '';
const html = sanitizeMarkdown(marked.parse(text));
const cls = opts.variant === 'msg' ? 'markdown-msg'
: opts.variant === 'compact' ? 'markdown-compact'
: 'markdown-body';
const container = document.createElement('div');
container.className = cls;
container.innerHTML = html;
if (opts.query) highlightTextNodes(container, opts.query.trim());
return container.outerHTML;
}
// --- Duration / tokens / tooltip ---
export function fmtDuration(ms) {
if (!ms) return '—';
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const parts = [];
if (d) parts.push(`${d}d`);
if (h) parts.push(`${h}h`);
if (m) parts.push(`${m}m`);
if (sec || !parts.length) parts.push(`${sec}s`);
return parts.join(' ');
}
export function fmtTokens(n) {
if (!n) return '0';
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + 'B';
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
return String(n);
}
export function fmtTooltipDate(isoDay) {
const d = new Date(isoDay + 'T00:00:00');
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const day = d.getDate();
const suffix = day === 1 || day === 21 || day === 31 ? 'st' : day === 2 || day === 22 ? 'nd' : day === 3 || day === 23 ? 'rd' : 'th';
const thisYear = new Date().getFullYear();
if (d.getFullYear() === thisYear) return `${months[d.getMonth()]} ${day}${suffix}`;
return `${months[d.getMonth()]} ${day}${suffix}, ${d.getFullYear()}`;
}
export function positionTooltip(el, x, y) {
const pad = 12;
const rect = el.getBoundingClientRect();
let left = x + pad;
if (left + rect.width > window.innerWidth - pad) left = x - rect.width - pad;
el.style.left = left + 'px';
el.style.top = (y - 28) + 'px';
}
// --- DOM helpers ---
export const $ = sel => document.querySelector(sel);
export const $$ = sel => Array.from(document.querySelectorAll(sel));
export function ensureVisible(el, wrapSel) {
const wrap = $(wrapSel);
if (!wrap || !el) return;
const elRect = el.getBoundingClientRect();
const wrapRect = wrap.getBoundingClientRect();
if (elRect.top < wrapRect.top + 30) wrap.scrollTop -= (wrapRect.top + 30 - elRect.top);
else if (elRect.bottom > wrapRect.bottom - 10) wrap.scrollTop += (elRect.bottom - wrapRect.bottom + 10);
}
// --- Project label ---
export function formatProjectLabel(slug) {
if (!slug) return '(no project)';
// Use project_path if available from sessions, otherwise show slug as-is
const session = state.sessions.find(s => s.project === slug && s.project_path);
if (session?.project_path) {
const parts = session.project_path.split('/');
return parts.slice(-2).join('/');
}
return slug.replace(/^-/, '');
}
// --- Row status ---
export function dominantRowStatus(m) {
if (m.health === 'broken') return 'broken';
if (m.health === 'partial') return 'partial';
if (m.archived) return 'archived';
return null;
}
export function statusGlyphHTML(status) {
if (!status) return '';
const glyphs = {
broken: `<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 1.5l5.5 9.5h-11z M7 5v3M7 9.2v.6"/></svg>`,
partial: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="3.5"/></svg>`,
archived: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="2.5"/></svg>`
};
return `<span class="row-status ${status}" title="${status}">${glyphs[status] || ''}</span>`;
}