From 5a2eff9522e015b7558b573e78e0e358cdb47ce8 Mon Sep 17 00:00:00 2001 From: Loop Memory Dev Date: Sat, 18 Jul 2026 14:24:14 +0800 Subject: [PATCH 01/88] Frontend: Vue 3 migration with i18n + dashboard overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 8954-line vanilla JS single-page app with a Vue 3 (CDN, no build step) component tree. Each pane is a self-contained ES module imported directly by the browser, keeping the original + + + Loop Memory + + - -
-
- -
- Loop Memory - local edition -
-
- -
- - - - - - - - - - - - - - -
-
memories
-
sessions
-
graph0/0
-
avg score
-
-
database
-
-
- - - -
- -
- - - Model - rules - - - - -
- - - - - - -
- -
- - -
- -
- - - -
- -
- - -
-
-
- - -
-
- -
-
-
-
- - -
-
-
- - - -
- - -
- - -
-
- - -
-
-

Insights

-
real-time view of how your memory system is evolving
-
-
- live - - -
-
- - -
-
- 📊 - Stats overview - - -
- -
-
-
Total
-
-
all memories
- -
-
-
Today
-
-
new in 24h
- -
-
-
Active
-
-
ready to recall
- -
-
-
Links
- -
graph edges
- -
-
-
Clusters
-
-
memory groups
- -
-
-
Avg score
-
-
importance×recency×use
- -
-
-
Decay %
-
-
below threshold
- -
-
-
Entities
-
-
graph nodes
- -
- -
-
Sources
-
-
upstream tools
- -
-
-
Wiki
-
-
distilled pages
- -
-
-
Recalls 24h
-
-
times surfaced
- -
-
- - -
-
- - - - 0% - -
-
🧠 Occupation
-
-
active / total
-
-
-
- - - - 0% - -
-
Citation
-
-
times surfaced
-
-
-
- - - - 0% - -
-
📉 Decay
-
-
decayed
-
-
-
-
- - -
-
- 🔄 - Lifecycle flow - - total -
- -
-
-
📥
-
-
Extracted
-
0%
-
-
-
-
-
-
Active
-
0%
-
-
-
-
📉
-
-
Decayed
-
0%
-
-
-
-
-
-
Merged
-
0%
-
-
-
-
🗄
-
-
Archived
-
0%
-
-
-
-
🗑
-
-
Forgotten
-
0%
-
-
-
- - -
-
- 🧬 - Self-improvement Pulse - - detect & resolve -
- -
-
-
-
⚠ Contradiction detection
-
-
-
-
- Scanning for contradictions… -
-
-
- -
-
📊 Decay score distribution
-
- - - -
-
-
- - -
-
- 🗜 - Compression & Granularity - - what to distil & how it's classified -
- -
- -
-
- 🗜 Memory compression -
-
-
-
-
compressible
-
-
-
-
avg length
-
-
-
-
progress
-
-
-
-
No items need compression yet.
-
-
- - -
-
- 🔬 Memory granularity -
-
-
-
-
-
🧠 Core
-
-
-
-
-
-
-
📋 Working
-
-
-
-
-
-
-
📝 Scratch
-
-
-
-
-
-
-
-
- - -
-
- 📈 - Data distribution - - type · status · 7-day trend -
- -
- -
- - - - types - - -
-
- - -
-
📊 Status distribution
- - - -
- - -
-
📈 7-day trend
- - - - - - - - - -
-
-
- - -
-
- 🔌 - Sources · Wiki · Ingest - - who contributes & what got distilled -
- -
- -
-
📡 Memory sources
-
- - - - sources - - -
-
-
- - -
-
📖 Wiki health
-
-
-
-
pages
-
-
-
-
avg imp
-
-
-
-
chars
-
-
-
-
refs
-
-
-
-
-
-
coverage
-
- - -
-
📥 Ingest rate (last 24h)
- - - - - - - - - -
-
-
-
- - -
-
- - Pipeline latency - - avg ms per stage -
-
-
- - -
-
- 🩺 - Source health · Weekly digest - - ingest freshness + natural-language recap -
- -
- -
-
- 🩺 Source health - -
-
-
Checking…
-
-
-
- - -
-
- 📝 Weekly digest - - - - - - - - - -
- -
-
Generating…
-
-
-
-
-
- - -
-
- 🔬 - LLM Audit · Write Guard - - token spend + pre-write filters -
- -
- -
-
- 🔬 LLM audit log - - - -
-
-
calls
-
tokens
-
avg ms
-
fails
-
-
-
- - -
-
- 🛡 Write guard - -
-
-
-
-
duplicates
-
-
-
-
too long
-
-
-
-
too short
-
-
-
-
low-signal
-
-
-
- WriteGuard intercepts ingest writes: drops near-duplicate, low-signal, or oversized candidates before they hit the store. -
-
-
-
- - -
-
- 🏗 - Underlying architecture - - how data flows end-to-end -
-
- - - - - - - - - -
-
- -
-
- -
-
- - - - - - -
-
- -
- -
-
- - - - - - - -
- -
-

Loading…

- -
-
-
- -
-
- - - 0 entities · 0 relations - - - - - - - -
-
-
- -
- -
- Wiki - Tag - Concept - Acronym -
-
-
-
-
- -
- -
- - - - - +
+ diff --git a/loop_memory/serve/static/js/App.js b/loop_memory/serve/static/js/App.js new file mode 100644 index 0000000..c6cf0f5 --- /dev/null +++ b/loop_memory/serve/static/js/App.js @@ -0,0 +1,159 @@ +/** + * App — top-level shell that owns the global layout. + * + * Renders: TopBar, Sidebar, Tabs, the active tab pane, Settings drawer, + * RunStrip, Toast, Diagnostic modal. Listens for cross-component events + * (ingest, rescore, llm-run, run-now) and calls the API. + * + * The store is the cross-component bus: every component reads from it + * (lang, theme, stats, runStatus, activeTab) and a few write to it + * (TopBar writes stats, TopBar writes modelInfo, App writes runStatus). + * Components DO NOT call each other directly — the App listens to user + * events emitted by TopBar and orchestrates API calls. + */ +import { defineComponent, ref, computed, onMounted, onUnmounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, t, applyTheme, applyLang, loadI18n, toast } from './store.js'; +import { api } from './api.js'; + +import { TopBar } from './components/TopBar.js'; +import { Sidebar } from './components/Sidebar.js'; +import { Tabs } from './components/Tabs.js'; +import { Timeline } from './components/Timeline.js'; +import { Dashboard } from './components/Dashboard.js'; +import { Wiki } from './components/Wiki.js'; +import { KnowledgeGraph } from './components/KnowledgeGraph.js'; +import { Settings } from './components/Settings.js'; +import { RunStrip } from './components/RunStrip.js'; +import { Toast } from './components/Toast.js'; +import { Diagnostic } from './components/Diagnostic.js'; + +export const App = defineComponent({ + name: 'App', + components: { TopBar, Sidebar, Tabs, Timeline, Dashboard, Wiki, KnowledgeGraph, Settings, RunStrip, Toast, Diagnostic }, + setup() { + const settingsOpen = ref(false); + const diagOpen = ref(false); + let statusPoll = null; + let modelPoll = null; + + async function refreshStats() { + try { + const data = await api.stats(); + store.stats = { + ...store.stats, + memories: data.memories, sessions: data.sessions, + wiki_pages: data.wiki_pages || 0, avg_score: data.avg_score, + graph: data.entities ? `${data.entities}/${data.entities}` : '0/0', // entities not in stats — fallback to 0/0 + dbPath: data.path, + }; + } catch (e) { /* ignore */ } + } + + async function refreshRunStatus() { + try { + const r = await api.llmStatus(); + store.runStatus = r || store.runStatus; + store.modelInfo = { + provider: r?.provider || 'rules', + model: r?.model || 'rules', + api_key_set: !!r?.api_key_set, + key_len: r?.key_len || 0, + }; + // Trigger refresh of child components when run count changed + if (r?.last_run && r.last_run !== store.lastRunId) { + store.lastRunId = r.last_run; + refreshStats(); + } + } catch (e) { /* ignore */ } + } + + function readTabFromUrl() { + try { + const usp = new URLSearchParams(location.search); + const tab = usp.get('tab'); + if (tab && ['timeline', 'dashboard', 'wiki', 'graph'].includes(tab)) { + store.activeTab = tab; + } + } catch (e) { /* ignore */ } + } + + onMounted(async () => { + applyTheme(); + applyLang(); + readTabFromUrl(); + await loadI18n(); + applyTheme(); + applyLang(); + // Force re-render of all components that depend on i18n + store.ready = true; + refreshStats(); + refreshRunStatus(); + statusPoll = setInterval(refreshRunStatus, 3000); + modelPoll = setInterval(refreshStats, 8000); + }); + + onUnmounted(() => { + if (statusPoll) clearInterval(statusPoll); + if (modelPoll) clearInterval(modelPoll); + }); + + // --- Action handlers --- + async function onIngest() { + try { + const r = await api.ingest({ source: 'manual' }); + toast(t('action.ingestStarted', { n: r.ingested || 0 }), 2500); + refreshStats(); + } catch (e) { toast(t('common.error') + ': ' + e.message, 4000); } + } + async function onRescore() { + try { + await api.rescore(); + toast(t('action.rescoreDone'), 2000); + refreshStats(); + } catch (e) { toast(t('common.error') + ': ' + e.message, 4000); } + } + async function onLlmRun() { + try { + const r = await api.llmRun({}); + if (r.queued) toast(t('action.llmRunQueued'), 2000); + } catch (e) { toast(t('common.error') + ': ' + e.message, 4000); } + } + async function onRunNow() { + try { await api.llmRun({ force: true }); toast(t('action.runNowQueued'), 2000); } + catch (e) { toast(t('common.error') + ': ' + e.message, 4000); } + } + function onOpenSettings() { settingsOpen.value = true; } + function onOpenDiag() { diagOpen.value = true; } + function onOpenStats() { /* legacy stats popover — delegated to TopBar */ } + + return { + store, t, settingsOpen, diagOpen, + onIngest, onRescore, onLlmRun, onRunNow, + onOpenSettings, onOpenStats, onOpenDiag, + dismissStrip: () => { store.stripDismissed = true; }, + }; + }, + template: /* html */ ` +
+ +
+ +
+ +
+ + + + +
+
+
+ + + + +
+ `, +}); diff --git a/loop_memory/serve/static/js/api.js b/loop_memory/serve/static/js/api.js new file mode 100644 index 0000000..c09c728 --- /dev/null +++ b/loop_memory/serve/static/js/api.js @@ -0,0 +1,125 @@ +/** + * API client for loop-memory. + * + * Single fetchJSON wrapper used by every component. Returns the parsed JSON + * body on 2xx, throws a structured error otherwise. Errors carry the HTTP + * status, response body, and the original URL so the UI can show useful + * toasts and so the test endpoint can tell the user exactly what failed. + */ +const API_BASE = ''; + +function buildUrl(path, params) { + let url = path.startsWith('http') ? path : API_BASE + path; + if (params && Object.keys(params).length > 0) { + const usp = new URLSearchParams(); + for (const k of Object.keys(params)) { + if (params[k] === undefined || params[k] === null || params[k] === '') continue; + usp.set(k, String(params[k])); + } + const qs = usp.toString(); + if (qs) url += (url.includes('?') ? '&' : '?') + qs; + } + return url; +} + +export async function fetchJSON(path, opts = {}) { + const { method = 'GET', params, body, headers = {}, timeoutMs = 30000 } = opts; + const url = buildUrl(path, params); + const ctrl = new AbortController(); + const tid = setTimeout(() => ctrl.abort(), timeoutMs); + const finalHeaders = { 'Accept': 'application/json', ...headers }; + if (body !== undefined && !(body instanceof FormData)) { + finalHeaders['Content-Type'] = 'application/json'; + } + let res; + try { + res = await fetch(url, { + method, + headers: finalHeaders, + body: body === undefined ? undefined + : body instanceof FormData ? body + : JSON.stringify(body), + signal: ctrl.signal, + }); + } catch (e) { + clearTimeout(tid); + if (e.name === 'AbortError') { + throw new ApiError(0, { error: { message: 'Request timed out' } }, url, 'timeout'); + } + throw new ApiError(0, { error: { message: e.message || 'Network error' } }, url, 'network'); + } + clearTimeout(tid); + let data = null; + const ct = res.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + try { data = await res.json(); } catch { data = null; } + } else { + try { data = await res.text(); } catch { data = null; } + } + if (!res.ok) { + throw new ApiError(res.status, data, url); + } + return data; +} + +export class ApiError extends Error { + constructor(status, body, url, kind) { + const detail = (body && (body.detail || body.error?.message)) || `HTTP ${status}`; + super(typeof detail === 'string' ? detail : JSON.stringify(detail)); + this.name = 'ApiError'; + this.status = status; + this.body = body; + this.url = url; + this.kind = kind || (status === 0 ? 'network' : status >= 500 ? 'server' : 'client'); + } +} + +/** Domain endpoints — short, named functions for clarity. */ +export const api = { + // Generic + health: () => fetchJSON('/api/health'), + stats: () => fetchJSON('/api/stats'), + + // Memories + listMemories: (params) => fetchJSON('/api/memories', { params }), + getMemory: (id) => fetchJSON(`/api/memories/${id}`), + deleteMemory: (id) => fetchJSON(`/api/memories/${id}`, { method: 'DELETE' }), + recall: (query, limit = 50) => fetchJSON('/api/recall', { params: { query, limit } }), + + // Sessions + listSessions: (params) => fetchJSON('/api/sessions', { params }), + + // Wiki + listWiki: () => fetchJSON('/api/wiki'), + getWiki: (id) => fetchJSON(`/api/wiki/${id}`), + createWiki: (payload) => fetchJSON('/api/wiki', { method: 'POST', body: payload }), + updateWiki: (id, payload) => fetchJSON(`/api/wiki/${id}`, { method: 'PUT', body: payload }), + deleteWiki: (id) => fetchJSON(`/api/wiki/${id}`, { method: 'DELETE' }), + + // Graph + graph: (params) => fetchJSON('/api/graph', { params }), + + // LLM admin + llmProviders: () => fetchJSON('/api/admin/llm/providers'), + llmConfig: async () => { + const r = await fetchJSON('/api/admin/llm/config'); + return r.config || r; + }, + llmTest: (payload) => fetchJSON('/api/admin/llm/test', { method: 'POST', body: payload }), + llmStatus: () => fetchJSON('/api/admin/llm/status'), + llmRun: (payload) => fetchJSON('/api/admin/llm/run', { method: 'POST', body: payload }), + llmSchedule: (payload) => fetchJSON('/api/admin/llm/schedule', { method: 'POST', body: payload }), + + // Ingest / score + rescore: () => fetchJSON('/api/rescore', { method: 'POST' }), + rebuildGraph: () => fetchJSON('/api/graph/rebuild', { method: 'POST' }), + ingest: (payload) => fetchJSON('/api/ingest', { method: 'POST', body: payload }), + + // Audit / runs + llmAudit: () => fetchJSON('/api/llm-audit'), + llmRuns: (params) => fetchJSON('/api/admin/llm/runs', { params }), + + // Other + contradiction: (params) => fetchJSON('/api/contradiction', { params }), + exportData: (format) => fetchJSON('/api/export', { params: { format } }), +}; diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js new file mode 100644 index 0000000..08bbe83 --- /dev/null +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -0,0 +1,342 @@ +/** + * Dashboard — Insights tab. + * + * Pulls the full payload from `/api/insights` (cheap, polled every 5s) + * and renders it as a multi-pane dashboard: + * - KPI grid (10 metrics from overview) + * - Pipeline data flow (capture → recall → surface, animated arrows) + * - Distribution (types / status) + * - 24h ingest sparkline + * - Wiki health + * - Compression candidates + * - Per-source health + */ +import { defineComponent, ref, computed, onMounted, onUnmounted } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, t, fmtTime, timeAgo } from '../store.js'; +import { api } from '../api.js'; + +const KIND_TONE = { + episode: 'blue', fact: 'green', rule: 'amber', summary: 'purple', + scratch: 'slate', concept: 'cyan', plan: 'rose', reflection: 'violet', +}; +const STATUS_TONE = { + active: 'green', decayed: 'amber', forgotten: 'rose', archived: 'slate', +}; + +export const Dashboard = defineComponent({ + name: 'Dashboard', + setup() { + const insights = ref(null); + const loading = ref(false); + const live = ref(false); + const lastRefresh = ref(0); + let pollHandle = null; + + async function refresh() { + loading.value = true; + try { + const [stats, insightsData] = await Promise.all([ + api.stats(), + fetch('/api/insights').then(r => r.ok ? r.json() : null).catch(() => null), + ]); + store.stats = { + ...store.stats, + memories: stats.memories, + sessions: stats.sessions, + wiki_pages: stats.wiki_pages || 0, + avg_score: stats.avg_score, + graph: insightsData + ? `${insightsData.overview?.entities || 0}/${insightsData.overview?.links || 0}` + : '0/0', + dbPath: stats.path, + }; + insights.value = insightsData; + live.value = !!insightsData; + lastRefresh.value = Date.now(); + } catch (e) { + live.value = false; + } finally { + loading.value = false; + } + } + + onMounted(() => { + refresh(); + pollHandle = setInterval(refresh, 5000); + }); + onUnmounted(() => { + if (pollHandle) clearInterval(pollHandle); + }); + + function onRefresh() { refresh(); } + function onRunEvolution() { window.dispatchEvent(new CustomEvent('loop:llm-run')); } + + // Sparkline helpers + function sparkPath(values, w = 120, h = 28) { + if (!values || !values.length) return ''; + const nums = values.map(Number).filter(n => !isNaN(n)); + if (!nums.length) return ''; + const max = Math.max(...nums, 1); + const step = w / Math.max(1, nums.length - 1); + return nums.map((v, i) => `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(h - (v / max) * h).toFixed(1)}`).join(' '); + } + function sparkFill(values, w = 120, h = 28) { + const top = sparkPath(values, w, h); + if (!top) return ''; + return `${top} L${w},${h} L0,${h} Z`; + } + function maxOf(arr, key) { + if (!arr || !arr.length) return 0; + return Math.max(...arr.map(x => key ? x[key] : x), 1); + } + + // 7-stage pipeline labels (Chinese) for the data flow widget + const stageLabels = { + extracted: '采集', + active: '活跃', + decayed: '衰减', + merged: '合并', + archived: '归档', + forgotten: '遗忘', + }; + + const pipelineSteps = computed(() => { + const s = insights.value?.stages || {}; + const total = Object.values(s).reduce((a, b) => a + (b || 0), 0) || 1; + return Object.entries(s).map(([k, v]) => ({ + key: k, + label: stageLabels[k] || k, + count: v || 0, + pct: ((v || 0) / total * 100), + })); + }); + + const ingest24 = computed(() => { + const arr = insights.value?.ingest_rate || []; + return arr.map(r => Number(r.count) || 0); + }); + + return { store, t, insights, loading, live, lastRefresh, onRefresh, fmtTime, timeAgo, + onRunEvolution, sparkPath, sparkFill, KIND_TONE, STATUS_TONE, + pipelineSteps, ingest24, maxOf }; + }, + template: /* html */ ` +
+
+
+
+

{{ t('dash.ins.title') }}

+
{{ t('dash.ins.sub') }}
+
+
+ + + {{ live ? t('dash.ins.live') : t('dash.ins.offline') }} + + + +
+
+ + +
+
+ 📊 + {{ t('dash.ins.statsTitle') }} + +
+
+
+
{{ t('dash.kpi.total') }}
+
{{ insights.overview.total || 0 }}
+
{{ t('dash.kpi.totalSub') }}
+
+
+
{{ t('dash.kpi.today') }}
+
{{ insights.overview.today || 0 }}
+
{{ t('dash.kpi.todaySub') }}
+
+
+
{{ t('dash.kpi.active') }}
+
{{ insights.overview.active || 0 }}
+
{{ t('dash.kpi.activeSub') }}
+
+
+
{{ t('dash.kpi.links') }}
+
{{ insights.overview.links || 0 }}
+
{{ t('dash.kpi.linksSub') }}
+
+
+
{{ t('dash.kpi.entities') }}
+
{{ insights.overview.entities || 0 }}
+
{{ t('dash.kpi.entitiesSub') }}
+
+
+
{{ t('dash.kpi.clusters') }}
+
{{ insights.overview.clusters || 0 }}
+
{{ t('dash.kpi.clustersSub') }}
+
+
+
{{ t('dash.kpi.avg') }}
+
{{ insights.overview.avg_score ? Math.round(insights.overview.avg_score * 100) + '%' : '—' }}
+
{{ t('dash.kpi.avgSub') }}
+
+
+
{{ t('dash.kpi.decay') }}
+
{{ insights.overview.decay_pct ? insights.overview.decay_pct.toFixed(0) + '%' : '—' }}
+
{{ t('dash.kpi.decaySub') }}
+
+
+
{{ t('dash.kpi.wikiHealth') }}
+
{{ store.stats.wiki_pages || 0 }}
+
{{ t('dash.kpi.wikiHealthSub') }}
+
+
+
{{ t('dash.kpi.recall24h') }}
+
{{ insights.recall_24h?.total || 0 }}
+
{{ insights.recall_24h?.unique_memories || 0 }} {{ t('dash.kpi.recall24hSub') }}
+
+
+
+ + +
+
+ 🔄 + 数据流转 · {{ t('dash.pipe.title') }} + +
+
+
+
{{ step.count }}
+
{{ step.label }}
+
+
+
+
+
+ + +
+ +
+
+ 🧩 + 记忆类型分布 +
+
+
+ + {{ row.kind }} + + + + + {{ row.count }} +
+
+
+
+ + +
+
+ 📈 + 状态分布 +
+
+
+ + + {{ row.status }} + + + + + {{ row.count }} +
+
+
+
+
+ + +
+
+
+ ⏱️ + 近 24h 采集 +
+ + + + +
+ {{ ingest24.reduce((a, b) => a + b, 0) }} + 总采集 (近24h) +
+
+ +
+
+ 📡 + 来源健康度 +
+
+
+ {{ s.source || '—' }} + {{ s.count || 0 }} + {{ s.last_seen ? timeAgo(s.last_seen) : '—' }} +
+
+
+
+ +
+
+ 📚 + 知识库健康度 +
+
+
页面数{{ insights.wiki_health.pages || 0 }}
+
平均重要度{{ insights.wiki_health.avg_importance ? (insights.wiki_health.avg_importance * 100).toFixed(0) + '%' : '—' }}
+
字符数{{ insights.wiki_health.total_chars || 0 }}
+
被引用记忆{{ insights.wiki_health.referenced_memories || 0 }}
+
+
+
+ + +
+
+ 🗜️ + 待压缩记忆 · {{ insights.compression.compressible_count || 0 }} 条 + + 平均长度 {{ insights.compression.avg_length || 0 }} 字符 +
+
+
+ {{ item.kind }} + {{ item.preview }} + + + {{ Math.round((item.importance || 0) * 100) }}% + + 已入库 + +
+
+
+ +
{{ t('common.loading') }}
+
{{ t('dash.ins.offline') }}
+
+
+`, +}); diff --git a/loop_memory/serve/static/js/components/Diagnostic.js b/loop_memory/serve/static/js/components/Diagnostic.js new file mode 100644 index 0000000..2dea94d --- /dev/null +++ b/loop_memory/serve/static/js/components/Diagnostic.js @@ -0,0 +1,51 @@ +/** + * Diagnostic — quick subsystem health check modal. + * + * Calls `/api/health` and shows the per-subsystem status. If anything + * is red, the user gets a one-click "copy fix command" affordance. + */ +import { defineComponent, ref } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { t } from '../store.js'; +import { api } from '../api.js'; + +export const Diagnostic = defineComponent({ + name: 'Diagnostic', + props: { open: { type: Boolean, default: false } }, + emits: ['close'], + setup(props, { emit }) { + const data = ref(null); + const loading = ref(false); + + async function check() { + loading.value = true; + try { data.value = await api.health(); } + catch (e) { data.value = { ok: false, error: e.message }; } + finally { loading.value = false; } + } + return { data, loading, check, t, onClose: () => emit('close') }; + }, + watch: { + open(o) { if (o) this.check(); }, + }, + template: /* html */ ` + + + + `, +}); diff --git a/loop_memory/serve/static/js/components/KnowledgeGraph.js b/loop_memory/serve/static/js/components/KnowledgeGraph.js new file mode 100644 index 0000000..6f7befc --- /dev/null +++ b/loop_memory/serve/static/js/components/KnowledgeGraph.js @@ -0,0 +1,522 @@ +/** + * KnowledgeGraph — 3D Fibonacci-sphere knowledge graph. + * + * The drawing logic (canvas, force simulation, rotation, label rendering) + * is a direct port of the legacy vanilla-JS code. We mount it onto a + * ref so Vue handles the lifecycle (resize observer, tab + * visibility, mount/unmount) while the drawing code stays a single + * self-contained IIFE inside the component. + * + * The component is intentionally heavy — the canvas code is by far the + * most complex piece of the UI. Putting it all in one file keeps the + * math together and makes future tweaks (new label rules, different + * projection) easy to find. + */ +import { defineComponent, ref, onMounted, onUnmounted, watch, nextTick } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, t } from '../store.js'; +import { api } from '../api.js'; + +const KIND_COLOR = { + concept: '#6366f1', + acronym: '#10b981', + cjk: '#f59e0b', + tag: '#ec4899', + url: '#0ea5e9', + path: '#a855f7', + wiki_page: '#f59e0b', +}; + +export const KnowledgeGraph = defineComponent({ + name: 'KnowledgeGraph', + setup() { + const canvasRef = ref(null); + const wrapRef = ref(null); + const loading = ref(false); + const stats = ref({ entities: 0, relations: 0 }); + const filterText = ref(''); + const filterKind = ref(''); + + // Draw state + let graphData = { entities: [], relations: [] }; + let graphNodes = {}; + let graphEdges = []; + let graphLayout = { scale: 1, tx: 0, ty: 0, dragging: null, hover: null }; + let graphSelected = null; + let graphInitTried = false; + let sim = null; + let animating = false; + let globalTick = 0; + let warm = false; + + async function loadGraph() { + loading.value = true; + try { + const entLimit = window._graphEntLimit || 120; + const relLimit = Math.min(entLimit * 4, 600); + const g = await api.graph({ limit_entities: entLimit, limit_relations: relLimit }); + graphData = g; + stats.value = { entities: (g.entities || []).length, relations: (g.relations || []).length }; + + graphEdges = (g.relations || []).map((r, i) => ({ + src: r.src, dst: r.dst, weight: r.weight, + evidence: r.evidence, id: r.id, _i: i, + edgeKind: r.kind || 'related', + })); + graphNodes = {}; + for (const e of (g.entities || [])) { + graphNodes[e.name] = { + name: e.name, kind: e.kind, weight: e.weight, + mention: e.mention_count, _i: graphNodes.length || 0, + x: 0, y: 0, vx: 0, vy: 0, + }; + } + seedFibonacci(); + ensureSim(); + fitToCanvas(); + refreshStats(); + drawCanvas(); + } catch (e) { + console.error('graph load failed', e); + } finally { + loading.value = false; + } + } + + function stableHash(s) { + let h = 0x811c9dc5 >>> 0; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h; + } + + function seedFibonacci() { + const names = Object.keys(graphNodes); + names.sort((a, b) => { + const ma = graphNodes[a].mention || 0; + const mb = graphNodes[b].mention || 0; + if (ma !== mb) return mb - ma; + return stableHash(a) - stableHash(b); + }); + const N = names.length; + const R = Math.max(220, Math.sqrt(N) * 28); + const PHI = Math.PI * (3 - Math.sqrt(5)); + // Interleave high/low mention so the high-mention nodes spread + // across the sphere instead of clustering at one pole. + const ordered = new Array(N); + const halfHigh = Math.min(N, Math.ceil(N * 0.5)); + let hi = 0, lo = halfHigh; + for (let i = 0; i < N; i++) { + if (i % 2 === 0 && hi < halfHigh) ordered[i] = names[hi++]; + else if (lo < N) ordered[i] = names[lo++]; + else ordered[i] = names[hi++]; + } + ordered.forEach((name, i) => { + const lat = Math.asin(1 - 2 * (i + 0.5) / N); + const lon0 = ((i * PHI) % (Math.PI * 2)); + const node = graphNodes[name]; + node.x = R * Math.cos(lat) * Math.sin(lon0); + node.y = R * Math.sin(lat); + node.lat0 = lat; + node.shellR = R; + node.lon = lon0; + }); + } + + // Force-simulation is intentionally minimal — the Fibonacci sphere is + // already a sphere, so we only need a few ticks to settle user drags. + function makeSim() { + const params = { + repulsion: 250, springLen: 120, springK: 0.012, centerK: 0.018, + collideR: 18, collideK: 0.5, damping: 0.82, alphaDecay: 0.04, + baseOmega: 0.0014, shellBoost: 0.6, minAlpha: 0.05, maxSpeed: 6, + }; + let alpha = 0; + const nodes = () => Object.values(graphNodes); + return { + params: () => params, + tick() { + if (alpha < 0.001) return; + for (const n of nodes()) { + for (const m of nodes()) { + if (m === n) continue; + const dx = n.x - m.x, dy = n.y - m.y; + const d2 = dx*dx + dy*dy + 0.01; + const f = params.repulsion / d2; + n.vx += dx / Math.sqrt(d2) * f * 0.01; + n.vy += dy / Math.sqrt(d2) * f * 0.01; + } + } + for (const e of graphEdges) { + const a = graphNodes[e.src], b = graphNodes[e.dst]; + if (!a || !b) continue; + const dx = b.x - a.x, dy = b.y - a.y; + const d = Math.sqrt(dx*dx + dy*dy) || 0.01; + const target = params.springLen * (e.weight || 0.5); + const f = (d - target) * params.springK; + a.vx += dx / d * f; + a.vy += dy / d * f; + b.vx -= dx / d * f; + b.vy -= dy / d * f; + } + // Gentle pull back to Fibonacci surface + for (const n of nodes()) { + if (n.shellR == null) continue; + n.vx += -n.x * 0.0008; + n.vy += -n.y * 0.0008; + } + for (const n of nodes()) { + n.x += n.vx; n.y += n.vy; + n.vx *= params.damping; n.vy *= params.damping; + } + // Sphere rotation + for (const n of nodes()) { + if (n.lat0 == null) continue; + n.lon = (n.lon || 0) + params.baseOmega; + } + alpha *= (1 - params.alphaDecay); + }, + restart() { alpha = 1; }, + setAlpha(v) { alpha = Math.max(0, Math.min(1, v)); }, + alpha() { return alpha; }, + pause() {}, + resume() {}, + isRotating() { return true; }, + }; + } + + function ensureSim() { + if (!sim) sim = makeSim(); + sim.restart(); + for (let i = 0; i < 20; i++) sim.tick(); + sim.setAlpha(0); + // Nudge nodes back to Fibonacci positions + for (const n of Object.values(graphNodes)) { + if (n.lat0 !== undefined) { + n.x = n.shellR * Math.cos(n.lat0) * Math.sin(n.lon); + n.y = n.shellR * Math.sin(n.lat0); + } + n.vx = 0; n.vy = 0; + } + warm = true; + } + + function fitToCanvas() { + const c = canvasRef.value; if (!c) return; + const wrap = wrapRef.value; if (!wrap) return; + const rect = wrap.getBoundingClientRect(); + const W = rect.width > 100 ? rect.width : 1000; + const H = rect.height > 100 ? rect.height : 700; + const nodes = Object.values(graphNodes); + if (!nodes.length) return; + let rMax = 0; + for (const n of nodes) { + const r = Math.sqrt(n.x*n.x + n.y*n.y); + if (r > rMax) rMax = r; + } + const R = rMax + 60; + const w = R * 2, h = R * 2; + graphLayout.scale = Math.min(W / w, H / h) * 0.96; + graphLayout.tx = W / 2; + graphLayout.ty = H / 2; + } + + function refreshStats() { + // Stats pill on top of canvas + store.stats.graph = `${stats.value.entities}/${stats.value.entities}`; + } + + function resizeCanvas() { + const c = canvasRef.value; if (!c) return; + const wrap = wrapRef.value; if (!wrap) return; + const dpr = window.devicePixelRatio || 1; + const rect = wrap.getBoundingClientRect(); + c.width = Math.max(100, rect.width) * dpr; + c.height = Math.max(100, rect.height) * dpr; + c.style.width = rect.width + 'px'; + c.style.height = rect.height + 'px'; + } + + function hexA(hex, a) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; + } + + function roundRect(ctx, x, y, w, h, rad) { + ctx.beginPath(); + ctx.moveTo(x + rad, y); + ctx.lineTo(x + w - rad, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + rad); + ctx.lineTo(x + w, y + h - rad); + ctx.quadraticCurveTo(x + w, y + h, x + w - rad, y + h); + ctx.lineTo(x + rad, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - rad); + ctx.lineTo(x, y + rad); + ctx.quadraticCurveTo(x, y, x + rad, y); + ctx.closePath(); + } + + function drawCanvas() { + const c = canvasRef.value; if (!c) return; + const ctx = c.getContext('2d'); + if (!ctx) return; + const dpr = window.devicePixelRatio || 1; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, c.width, c.height); + ctx.scale(dpr, dpr); + + const themeAttr = document.documentElement.getAttribute('data-theme') || 'light'; + const isDark = themeAttr === 'dark'; + const textFaint = isDark ? 'rgba(238,242,255,0.55)' : 'rgba(15,23,42,0.55)'; + const halo = '#ec4899'; + + const cssW = c.clientWidth, cssH = c.clientHeight; + ctx.save(); + ctx.translate(graphLayout.tx, graphLayout.ty); + ctx.scale(graphLayout.scale, graphLayout.scale); + + const visibleNodes = Object.values(graphNodes).filter(n => { + if (filterKind.value && n.kind !== filterKind.value) return false; + if (filterText.value && !n.name.toLowerCase().includes(filterText.value.toLowerCase())) return false; + return true; + }); + const visibleNames = new Set(visibleNodes.map(n => n.name)); + + // Project nodes from (lat, lon) into 2D + const projected = []; + for (const n of visibleNodes) { + const lat = (n.lat0 !== undefined) ? n.lat0 : Math.atan2(n.y, n.x); + const R = (n.shellR !== undefined) ? n.shellR : (Math.sqrt(n.x*n.x + n.y*n.y) || 1); + const lon = (n.lon !== undefined) ? n.lon : 0; + const sinLat = Math.sin(lat), cosLat = Math.cos(lat); + const sinLon = Math.sin(lon), cosLon = Math.cos(lon); + const px = R * cosLat * sinLon; + const py = R * sinLat; + const pz = R * cosLat * cosLon; + const front = (pz + R) / (2 * R); + projected.push({ name: n.name, x: px, y: py, depth: front, node: n }); + } + projected.sort((a, b) => a.depth - b.depth); + const projMap = {}; + for (const p of projected) projMap[p.name] = p; + + // Draw edges + for (const e of graphEdges) { + const a = graphNodes[e.src], b = graphNodes[e.dst]; + if (!a || !b) continue; + if (!visibleNames.has(a.name) && !visibleNames.has(b.name)) continue; + const pa = projMap[a.name], pb = projMap[b.name]; + if (!pa || !pb) continue; + const depthAvg = (pa.depth + pb.depth) / 2; + const alpha = 0.32 * (0.45 + 0.55 * depthAvg); + const edgeKind = e.edgeKind || 'related'; + let strokeColor; + if (edgeKind === 'tagged_with') strokeColor = hexA('#ec4899', alpha * 0.9); + else if (edgeKind === 'mentions') strokeColor = hexA('#6366f1', alpha * 0.7); + else if (edgeKind === 'related_to') strokeColor = hexA('#f59e0b', alpha * 0.85); + else strokeColor = hexA('#6366f1', alpha); + ctx.strokeStyle = strokeColor; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(pa.x, pa.y); + ctx.lineTo(pb.x, pb.y); + ctx.stroke(); + } + + // Draw nodes + for (const p of projected) { + const n = p.node; + const depth = p.depth; + const sizeMul = 0.55 + 0.55 * depth; + const kindBonus = (n.kind === 'wiki_page') ? 2.2 : (n.kind === 'tag') ? 0.7 : 1.0; + const r = (4 + Math.sqrt(n.mention || 1) * 1.5) * sizeMul * kindBonus; + const baseFill = KIND_COLOR[n.kind] || KIND_COLOR.concept; + const isSel = graphSelected && graphSelected.name === n.name; + const isHover = graphLayout.hover && graphLayout.hover.name === n.name; + const depthAlpha = 0.40 + 0.60 * depth; + ctx.beginPath(); + ctx.arc(p.x, p.y, r, 0, Math.PI * 2); + ctx.fillStyle = hexA(baseFill, depthAlpha); + ctx.fill(); + ctx.strokeStyle = 'rgba(0,0,0,0.18)'; + ctx.lineWidth = 1; + ctx.stroke(); + + // Label — show wiki_page / tag / high-mention always + const mention = n.mention || 0; + const importantLabel = n.kind === 'wiki_page' || mention >= 6; + const mediumLabel = n.kind === 'tag' || mention >= 3; + const showLabel = isSel || isHover || importantLabel || mediumLabel; + if (showLabel) { + const label = n.name.length > 34 ? n.name.slice(0, 32) + '…' : n.name; + ctx.font = '12px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'; + const m = ctx.measureText(label); + const lw = m.width + 12, lh = 18; + const dropBelow = (isSel || isHover) ? false : (p.y < 0); + const ly = dropBelow ? (p.y + r + 6) : (p.y - r - 14); + if (isSel || isHover) { + ctx.fillStyle = isDark ? '#a5b4fc' : '#eef2ff'; + roundRect(ctx, p.x - lw/2, ly, lw, lh, 9); + ctx.fill(); + ctx.fillStyle = isDark ? '#1d2433' : '#1e1b4b'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, p.x, ly + lh/2); + } else { + ctx.fillStyle = textFaint; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, p.x, ly + lh/2); + } + } + } + ctx.restore(); + + // HUD + ctx.save(); + ctx.font = '11px -apple-system, sans-serif'; + ctx.fillStyle = isDark ? 'rgba(238,242,255,0.45)' : 'rgba(15,23,42,0.45)'; + ctx.textAlign = 'left'; + ctx.fillText( + `${Object.keys(graphNodes).length} / ${Object.keys(graphNodes).length} nodes`, + 10, cssH - 18 + ); + ctx.restore(); + } + + function renderLoop() { + globalTick++; + if (sim) sim.tick(); + drawCanvas(); + requestAnimationFrame(renderLoop); + } + + function onWheel(e) { + e.preventDefault(); + const rect = canvasRef.value.getBoundingClientRect(); + const cx = e.clientX - rect.left, cy = e.clientY - rect.top; + const factor = Math.exp(-e.deltaY * 0.0015); + const wx = (cx - graphLayout.tx) / graphLayout.scale; + const wy = (cy - graphLayout.ty) / graphLayout.scale; + graphLayout.scale *= factor; + graphLayout.tx = cx - wx * graphLayout.scale; + graphLayout.ty = cy - wy * graphLayout.scale; + drawCanvas(); + } + + function pickNode(cx, cy) { + let best = null, bestD = 12; + for (const n of Object.values(graphNodes)) { + const wx = (cx - graphLayout.tx) / graphLayout.scale; + const wy = (cy - graphLayout.ty) / graphLayout.scale; + const dx = n.x - wx, dy = n.y - wy; + const d = Math.sqrt(dx*dx + dy*dy); + if (d < bestD) { bestD = d; best = n; } + } + return best; + } + + function onCanvasMouseDown(e) { + const rect = canvasRef.value.getBoundingClientRect(); + const cx = e.clientX - rect.left, cy = e.clientY - rect.top; + const node = pickNode(cx, cy); + if (node) { + graphLayout.dragging = { kind: 'node', node }; + graphSelected = node; + } else { + graphLayout.dragging = { kind: 'pan', x0: e.clientX, y0: e.clientY, tx0: graphLayout.tx, ty0: graphLayout.ty }; + } + } + function onCanvasMouseMove(e) { + const rect = canvasRef.value.getBoundingClientRect(); + const cx = e.clientX - rect.left, cy = e.clientY - rect.top; + const node = pickNode(cx, cy); + graphLayout.hover = node; + if (graphLayout.dragging) { + if (graphLayout.dragging.kind === 'pan') { + graphLayout.tx = graphLayout.dragging.tx0 + (e.clientX - graphLayout.dragging.x0); + graphLayout.ty = graphLayout.dragging.ty0 + (e.clientY - graphLayout.dragging.y0); + } else if (graphLayout.dragging.kind === 'node') { + const wx = (cx - graphLayout.tx) / graphLayout.scale; + const wy = (cy - graphLayout.ty) / graphLayout.scale; + graphLayout.dragging.node.x = wx; + graphLayout.dragging.node.y = wy; + graphLayout.dragging.node.fixed = true; + } + } + drawCanvas(); + } + function onCanvasMouseUp() { + if (graphLayout.dragging && graphLayout.dragging.kind === 'node') { + graphLayout.dragging.node.fixed = false; + } + graphLayout.dragging = null; + } + function onCanvasClick(e) { + const rect = canvasRef.value.getBoundingClientRect(); + const node = pickNode(e.clientX - rect.left, e.clientY - rect.top); + if (node) graphSelected = node; + else graphSelected = null; + } + + let resizeObserver = null; + function onResize() { + resizeCanvas(); + fitToCanvas(); + drawCanvas(); + } + + onMounted(async () => { + await nextTick(); + resizeCanvas(); + canvasRef.value.addEventListener('wheel', onWheel, { passive: false }); + canvasRef.value.addEventListener('mousedown', onCanvasMouseDown); + canvasRef.value.addEventListener('mousemove', onCanvasMouseMove); + window.addEventListener('mouseup', onCanvasMouseUp); + canvasRef.value.addEventListener('click', onCanvasClick); + window.addEventListener('resize', onResize); + if (window.ResizeObserver) { + resizeObserver = new ResizeObserver(onResize); + if (wrapRef.value) resizeObserver.observe(wrapRef.value); + } + await loadGraph(); + animating = true; + requestAnimationFrame(renderLoop); + }); + + onUnmounted(() => { + animating = false; + if (resizeObserver) resizeObserver.disconnect(); + window.removeEventListener('resize', onResize); + window.removeEventListener('mouseup', onCanvasMouseUp); + }); + + return { canvasRef, wrapRef, loading, stats, filterText, filterKind, t, + rebuild: loadGraph }; + }, + template: /* html */ ` +
+
+ + + + + + {{ stats.entities }} {{ t('graph.entities') }} · {{ stats.relations }} {{ t('graph.relations') }} + +
+
+ +
{{ t('common.loading') }}
+
+
+ `, +}); diff --git a/loop_memory/serve/static/js/components/RunStrip.js b/loop_memory/serve/static/js/components/RunStrip.js new file mode 100644 index 0000000..f3e090e --- /dev/null +++ b/loop_memory/serve/static/js/components/RunStrip.js @@ -0,0 +1,31 @@ +/** + * RunStrip — the bottom-of-page strip that shows when an AI distill + * is in progress (current/total progress bar + cancel button). + */ +import { defineComponent, computed } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, t } from '../store.js'; + +export const RunStrip = defineComponent({ + name: 'RunStrip', + emits: ['dismiss'], + setup(props, { emit }) { + const visible = computed(() => store.runStatus?.is_running && !store.stripDismissed); + const pct = computed(() => { + const p = store.runStatus?.progress || {}; + if (!p.total) return 0; + return Math.min(100, Math.round((p.current / p.total) * 100)); + }); + return { visible, pct, store, t, dismiss: () => emit('dismiss') }; + }, + template: /* html */ ` +
+
+ + {{ store.runStatus?.progress?.message || t('run.running') }} +
+
+
{{ pct }}%
+ +
+ `, +}); diff --git a/loop_memory/serve/static/js/components/Settings.js b/loop_memory/serve/static/js/components/Settings.js new file mode 100644 index 0000000..1ee06fa --- /dev/null +++ b/loop_memory/serve/static/js/components/Settings.js @@ -0,0 +1,237 @@ +/** + * Settings — the right-side drawer that holds LLM config + scheduler. + * + * The legacy `openSettings` function was 250 lines of imperative DOM + * mutation: read form, write form, schedule visibility toggles, API key + * state machine, test-connection result rendering, etc. Here it's a + * single Vue component driven by a reactive `cfg` object. Saving triggers + * one POST. The component never touches the DOM directly. + */ +import { defineComponent, ref, computed, onMounted, watch, reactive } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, t, toast } from '../store.js'; +import { api, ApiError } from '../api.js'; + +export const Settings = defineComponent({ + name: 'Settings', + props: { + open: { type: Boolean, default: false }, + }, + emits: ['close'], + setup(props, { emit }) { + const providers = ref([]); + const cfg = reactive({ + provider: 'rules', model: 'rules', + base_url: '', api_key: '', + api_key_set: false, api_key_account: '', api_key_fingerprint: '', + schedule: { + enabled: false, mode: 'off', + interval_minutes: 60, hour: 3, minute: 0, weekday: 0, + after_ingest_idle_sec: 30, + }, + behaviour: { + batch_size: 50, temperature: 0.3, max_output_tokens: 800, + min_importance: 0.0, enable_filter: true, enable_score: true, + enable_summarize: true, dry_run: false, + }, + }); + const testing = ref(false); + const testResult = ref(null); + const saving = ref(false); + const savedHint = ref(false); + + async function load() { + try { + const [p, c] = await Promise.all([api.llmProviders(), api.llmConfig()]); + providers.value = p || []; + // /api/admin/llm/config returns {config: {...}, warnings, ...} + const actualCfg = (c && c.config) ? c.config : c; + Object.assign(cfg, actualCfg); + } catch (e) { /* ignore */ } + try { + const status = await api.llmStatus(); + if (status && status.provider) store.modelInfo = { + provider: status.provider, model: status.model || 'rules', + api_key_set: !!status.api_key_set, key_len: status.key_len || 0, + }; + } catch (e) { /* ignore */ } + } + + onMounted(load); + watch(() => props.open, (o) => { if (o) load(); }); + + const selectedProvider = computed(() => { + return providers.value.find(p => p.id === cfg.provider) || {}; + }); + + function onProviderChange() { + const p = selectedProvider.value; + if (p && p.default_model) cfg.model = p.default_model; + if (p && p.default_base_url && !cfg.base_url) cfg.base_url = p.default_base_url; + } + + async function onTest() { + testing.value = true; + testResult.value = null; + try { + const r = await api.llmTest({ + provider: cfg.provider, model: cfg.model, base_url: cfg.base_url, + api_key: cfg.api_key || undefined, + }); + testResult.value = r; + if (r.ok) { + toast(t('settings.test.ok', { ms: r.elapsed_ms || 0 }), 2500); + } else { + toast(t('settings.test.fail', { msg: r.error?.provider_message || r.error?.hint || 'unknown' }), 4000); + } + } catch (e) { + testResult.value = { ok: false, error: { provider_message: e.message } }; + } finally { + testing.value = false; + } + } + + async function onSave() { + saving.value = true; + try { + // api_key is only sent if the user typed something new + const payload = { + provider: cfg.provider, model: cfg.model, base_url: cfg.base_url, + schedule: cfg.schedule, behaviour: cfg.behaviour, + }; + if (cfg.api_key) payload.api_key = cfg.api_key; + await api.llmSchedule(payload); + savedHint.value = true; + setTimeout(() => { savedHint.value = false; }, 2500); + cfg.api_key = ''; + await load(); + } catch (e) { + toast(t('common.error') + ': ' + e.message, 4000); + } finally { + saving.value = false; + } + } + + async function onClearKey() { + if (!confirm(t('settings.apiKey.confirmClear'))) return; + try { + await api.llmSchedule({ ...cfg, api_key: '' }); + cfg.api_key_set = false; + toast(t('settings.apiKey.cleared'), 2000); + await load(); + } catch (e) { /* ignore */ } + } + + function onClose() { emit('close'); } + + return { cfg, providers, selectedProvider, onProviderChange, + testing, testResult, onTest, + saving, savedHint, onSave, onClearKey, + store, t, onClose }; + }, + template: /* html */ ` + + + `, +}); diff --git a/loop_memory/serve/static/js/components/Sidebar.js b/loop_memory/serve/static/js/components/Sidebar.js new file mode 100644 index 0000000..a2a1c33 --- /dev/null +++ b/loop_memory/serve/static/js/components/Sidebar.js @@ -0,0 +1,81 @@ +/** + * Sidebar — left-rail session list. + * + * In the legacy code the session list was hand-rolled HTML with manual + * diffing on every refresh. Here it's a Vue list bound to a reactive + * `sessions` array; Vue's virtual DOM handles the diff. + */ +import { defineComponent, ref, computed, onMounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, t, timeAgo } from '../store.js'; +import { api } from '../api.js'; + +export const Sidebar = defineComponent({ + name: 'Sidebar', + setup() { + const sessions = ref([]); + const filter = ref('all'); + const loading = ref(false); + + async function refresh() { + loading.value = true; + try { + const params = filter.value && filter.value !== 'all' ? { source: filter.value } : {}; + const data = await api.listSessions(params); + sessions.value = Array.isArray(data) ? data : (data.sessions || []); + } catch (e) { + sessions.value = []; + } finally { + loading.value = false; + } + } + + const sources = computed(() => { + const set = new Set(); + for (const s of sessions.value) { + if (s.source) set.add(s.source); + } + return Array.from(set).sort(); + }); + + onMounted(() => { + refresh(); + // Refresh when stats update (signals a new session may have arrived) + watch(() => store.stats.sessions, refresh); + }); + + function onClickSession(s) { + store.activeSession = s.id; + } + + return { sessions, filter, sources, loading, refresh, onClickSession, t, store, timeAgo }; + }, + template: /* html */ ` + + `, +}); diff --git a/loop_memory/serve/static/js/components/Tabs.js b/loop_memory/serve/static/js/components/Tabs.js new file mode 100644 index 0000000..21de624 --- /dev/null +++ b/loop_memory/serve/static/js/components/Tabs.js @@ -0,0 +1,42 @@ +/** + * Tab bar — Timeline / Dashboard / Wiki / Knowledge graph. + * + * The active tab lives in `store.activeTab` so other components can react + * to it. URL `?tab=` is read on app boot and written back when the user + * switches, so a deep-link to a specific view round-trips. + */ +import { defineComponent, computed, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, t } from '../store.js'; + +export const Tabs = defineComponent({ + name: 'Tabs', + setup() { + const tabs = computed(() => ([ + { id: 'timeline', label: t('tab.timeline') }, + { id: 'dashboard', label: t('tab.dashboard') }, + { id: 'wiki', label: t('tab.wiki'), badge: store.stats.wiki_pages }, + { id: 'graph', label: t('tab.graph'), badge: (typeof store.stats.graph === 'string' ? store.stats.graph.split('/')[1] : 0) || 0 }, + ])); + + function setTab(id) { + store.activeTab = id; + // Persist to URL so the view is bookmarkable + const url = new URL(window.location.href); + url.searchParams.set('tab', id); + window.history.replaceState({}, '', url.toString()); + } + + return { tabs, store, setTab }; + }, + template: /* html */ ` + + `, +}); diff --git a/loop_memory/serve/static/js/components/Timeline.js b/loop_memory/serve/static/js/components/Timeline.js new file mode 100644 index 0000000..f12d344 --- /dev/null +++ b/loop_memory/serve/static/js/components/Timeline.js @@ -0,0 +1,150 @@ +/** + * Timeline — the "memory list" view, the default tab. + * + * Shows a reverse-chronological list of memories with kind badges, scores, + * source labels and inline tags. Search uses the unified `/api/recall` + * endpoint so Chinese tokenisation + importance ranking both apply. + * + * Filters (kind, score, date range) live in component-local state. The + * query string is passed straight to the API. + */ +import { defineComponent, ref, computed, onMounted, watch, h } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, t, escapeHtml, timeAgo } from '../store.js'; +import { api } from '../api.js'; + +const KIND_LABELS = { + episode: 'Episode', + fact: 'Fact', + rule: 'Rule', + scratch: 'Scratch', + summary: 'Summary', +}; + +export const Timeline = defineComponent({ + name: 'Timeline', + setup() { + const memories = ref([]); + const recallMeta = ref(null); + const loading = ref(false); + const q = ref(''); + const kind = ref(''); + const minScore = ref(0); + const since = ref(''); + const until = ref(''); + + const sources = computed(() => { + const set = new Set(); + for (const m of memories.value) { + if (m.source) set.add(m.source); + } + return Array.from(set).sort(); + }); + + async function refresh() { + loading.value = true; + recallMeta.value = null; + try { + let rows; + if (q.value.trim()) { + const r = await api.recall(q.value.trim(), 50); + rows = (r.memories || []).map(m => ({ ...m, recall_score: m.score })); + recallMeta.value = { wiki: r.wiki, entities: r.entities, tokens: r.tokens }; + } else { + const data = await api.listMemories({ + kind: kind.value, + min_score: minScore.value || undefined, + since: since.value ? new Date(since.value).getTime() / 1000 : undefined, + until: until.value ? new Date(until.value).getTime() / 1000 : undefined, + limit: 200, + }); + rows = Array.isArray(data) ? data : (data.memories || data.items || []); + } + // Apply client-side filters that the API doesn't expose yet + if (kind.value) rows = rows.filter(r => r.kind === kind.value); + if (minScore.value) rows = rows.filter(r => (r.score || r.importance || 0) >= Number(minScore.value)); + memories.value = rows; + } catch (e) { + memories.value = []; + } finally { + loading.value = false; + } + } + + function resetFilters() { + q.value = ''; kind.value = ''; minScore.value = 0; since.value = ''; until.value = ''; + refresh(); + } + + function onSearchSubmit(e) { + e.preventDefault(); + refresh(); + } + + function scoreFmt(s) { + if (s == null) return '—'; + return (s * 100).toFixed(0) + '%'; + } + + function onClickMemory(m) { + store.activeMemory = m.id; + } + + onMounted(() => refresh()); + // Refresh on stats changes (new memories ingested) + watch(() => store.stats.memories, refresh); + + return { + store, t, memories, recallMeta, loading, q, kind, minScore, since, until, + sources, KIND_LABELS, + refresh, resetFilters, onSearchSubmit, scoreFmt, onClickMemory, timeAgo, + }; + }, + template: /* html */ ` +
+
+
+ + + + + + +
+ +
+ + {{ t('recall.wikiMatches', { n: recallMeta.wiki.length }) }} + + + {{ t('recall.entityMatches', { n: recallMeta.entities.length }) }} + +
+ +
+
+
+ {{ m.source || '—' }} + {{ t('kind.' + (m.kind || 'episode')) }} + {{ scoreFmt(m.score ?? m.importance) }} + {{ timeAgo(m.created_at) }} +
+
{{ m.text }}
+
+ #{{ tag }} +
+
+
+
{{ t('timeline.empty') }}
+
{{ t('common.loading') }}
+
+
+ `, +}); diff --git a/loop_memory/serve/static/js/components/Toast.js b/loop_memory/serve/static/js/components/Toast.js new file mode 100644 index 0000000..0b7a0a5 --- /dev/null +++ b/loop_memory/serve/static/js/components/Toast.js @@ -0,0 +1,16 @@ +/** + * Toast — bottom-center transient notification. + */ +import { defineComponent, computed } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store } from '../store.js'; + +export const Toast = defineComponent({ + name: 'Toast', + setup() { + const visible = computed(() => !!store.toast); + return { visible, store }; + }, + template: /* html */ ` +
{{ store.toast?.msg || '' }}
+ `, +}); diff --git a/loop_memory/serve/static/js/components/TopBar.js b/loop_memory/serve/static/js/components/TopBar.js new file mode 100644 index 0000000..6ac71c6 --- /dev/null +++ b/loop_memory/serve/static/js/components/TopBar.js @@ -0,0 +1,161 @@ +/** + * TopBar — the header bar at the top of every page. + * + * Shows: brand, stats pills, run-status indicator, model chip, action + * buttons, language / theme / settings menu. + * + * The legacy vanilla-JS code mixed state mutation into 20+ event + * listeners scattered through the file. Here the bar is a single Vue + * component with one event-emitter for the actions (clicking "AI Run" + * bubbles up to App which knows how to call the API). + */ +import { defineComponent, computed, ref, onMounted, onUnmounted } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, patchPrefs, toast, t, timeAgo, fmtTime } from '../store.js'; +import { api, ApiError } from '../api.js'; + +export const TopBar = defineComponent({ + name: 'TopBar', + emits: ['ingest', 'rescore', 'llm-run', 'run-now', 'open-settings', 'open-stats', 'open-diag'], + setup(props, { emit }) { + const statsOpen = ref(false); + const kebabOpen = ref(false); + + const runLabel = computed(() => { + if (store.runStatus?.is_running) { + const p = store.runStatus.progress || {}; + if (p.total > 0) return `${p.current}/${p.total}`; + return '…'; + } + return t('topbar.run.idle'); + }); + + const runState = computed(() => store.runStatus?.is_running ? 'running' : 'idle'); + + function toggleStats() { statsOpen.value = !statsOpen.value; kebabOpen.value = false; } + function toggleKebab() { kebabOpen.value = !kebabOpen.value; statsOpen.value = false; } + function closeAll() { statsOpen.value = false; kebabOpen.value = false; } + + function setLang(l) { patchPrefs({ lang: l }); closeAll(); } + function setTheme(th) { patchPrefs({ theme: th }); closeAll(); } + + // Close popovers on outside click + function onDocClick(e) { + if (!e.target.closest('#stats-chip') && !e.target.closest('#stats-pop')) statsOpen.value = false; + if (!e.target.closest('.tb-kebab') && !e.target.closest('.tb-kebab-menu')) kebabOpen.value = false; + } + onMounted(() => document.addEventListener('click', onDocClick)); + onUnmounted(() => document.removeEventListener('click', onDocClick)); + + return { + store, t, runLabel, runState, + statsOpen, kebabOpen, + toggleStats, toggleKebab, closeAll, + setLang, setTheme, + onIngest: () => emit('ingest'), + onRescore: () => emit('rescore'), + onLlmRun: () => emit('llm-run'), + onRunNow: () => emit('run-now'), + onOpenSettings:() => emit('open-settings'), + onOpenStats: () => emit('open-stats'), + onOpenDiag: () => emit('open-diag'), + }; + }, + template: /* html */ ` +
+
+ +
+ {{ t('app.title') }} + {{ t('app.tagline') }} +
+
+ +
+ + + + {{ store.stats.memories || '…' }} + + + + {{ store.stats.sessions || '…' }} + + + + {{ store.stats.avg_score ? (store.stats.avg_score * 100).toFixed(0) + '%' : '…' }} + + +
+
{{ t('stat.memories') }}{{ store.stats.memories || '…' }}
+
{{ t('stat.sessions') }}{{ store.stats.sessions || '…' }}
+
{{ t('stat.graph') }}{{ store.stats.graph || '0/0' }}
+
{{ t('stat.scoreLabel') }}{{ store.stats.avg_score ? (store.stats.avg_score * 100).toFixed(1) + '%' : '…' }}
+
+
{{ t('stat.dbPath') }}{{ store.stats.dbPath ? (store.stats.dbPath.length > 36 ? '…' + store.stats.dbPath.slice(-34) : store.stats.dbPath) : '…' }}
+
+
+ +
+ + {{ runLabel }} +
+ +
+ +
+ + + {{ t('model.label') }} + {{ store.modelInfo.model || 'rules' }} + + + +
+ + + + + + +
+ + + +
+ +
+ + + +
+ + + + +
+ +
+
+
+
+ `, +}); diff --git a/loop_memory/serve/static/js/components/Wiki.js b/loop_memory/serve/static/js/components/Wiki.js new file mode 100644 index 0000000..627439a --- /dev/null +++ b/loop_memory/serve/static/js/components/Wiki.js @@ -0,0 +1,161 @@ +/** + * Wiki — distilled knowledge pages. + * + * Cards in a responsive grid; each card shows title, summary, top bullets, + * tags, importance, and last-updated. Click a card to open the editor + * (or just expand inline). The legacy code had a 200-line renderWiki + * function that hand-built HTML strings — Vue's template syntax is + * significantly easier to scan and edit. + */ +import { defineComponent, ref, computed, onMounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, t, escapeHtml, fmtTime } from '../store.js'; +import { api } from '../api.js'; + +export const Wiki = defineComponent({ + name: 'Wiki', + setup() { + const pages = ref([]); + const q = ref(''); + const sort = ref('updated_desc'); + const loading = ref(false); + const expanded = ref(null); + const editing = ref(null); + + async function refresh() { + loading.value = true; + try { + const data = await api.listWiki(); + pages.value = Array.isArray(data) ? data : (data.pages || []); + } catch (e) { + pages.value = []; + } finally { + loading.value = false; + } + } + + const visible = computed(() => { + let rows = pages.value; + if (q.value.trim()) { + const needle = q.value.trim().toLowerCase(); + rows = rows.filter(p => + (p.title || '').toLowerCase().includes(needle) || + (p.summary || '').toLowerCase().includes(needle) || + (p.slug || '').toLowerCase().includes(needle)); + } + const cmp = (a, b) => { + if (sort.value === 'updated_desc') return (b.updated_at || 0) - (a.updated_at || 0); + if (sort.value === 'importance_desc') return (b.importance || 0) - (a.importance || 0); + if (sort.value === 'title_asc') return (a.title || '').localeCompare(b.title || ''); + return 0; + }; + return [...rows].sort(cmp); + }); + + function bulletsOf(p) { + const lines = (p.body || '').split('\n'); + return lines.filter(l => l.startsWith('- ')).slice(0, 6); + } + + function expand(id) { expanded.value = expanded.value === id ? null : id; } + function edit(id) { editing.value = id; } + + function onNew() { editing.value = 'new'; } + + async function saveEdit(payload) { + try { + if (editing.value === 'new') { + await api.createWiki(payload); + } else { + await api.updateWiki(editing.value, payload); + } + editing.value = null; + await refresh(); + } catch (e) { + alert(t('wiki.saveError') + ': ' + e.message); + } + } + + async function removePage(p) { + if (!confirm(t('wiki.confirmDelete'))) return; + try { + await api.deleteWiki(p.id); + await refresh(); + } catch (e) { + alert(t('wiki.deleteError') + ': ' + e.message); + } + } + + function bodyPreview(p) { + const lines = (p.body || '').split('\n').filter(l => l.startsWith('- ')); + if (lines.length === 0) return p.body || ''; + return lines.slice(0, 3).join('\n'); + } + + onMounted(() => refresh()); + watch(() => store.stats.wiki_pages, refresh); + + return { + store, t, pages, q, sort, loading, visible, expanded, editing, bulletsOf, + refresh, expand, edit, onNew, saveEdit, removePage, bodyPreview, fmtTime, + }; + }, + template: /* html */ ` +
+
+
+ + + + +
+ +
+
+
+

{{ p.title || p.slug }}

+ + {{ Math.round((p.importance || 0) * 100) }}% + +
+
{{ p.summary }}
+
    +
  • {{ b.replace(/^-\\s*/, '') }}
  • +
+
+ #{{ tag }} +
+
+ + + {{ fmtTime(p.updated_at) }} + + +
+ + + +
+
+
{{ p.body }}
+
+
+
{{ t('wiki.empty') }}
+
{{ t('common.loading') }}
+ + +
+
+ `, +}); diff --git a/loop_memory/serve/static/js/components/WikiEditor.js b/loop_memory/serve/static/js/components/WikiEditor.js new file mode 100644 index 0000000..6c00ab3 --- /dev/null +++ b/loop_memory/serve/static/js/components/WikiEditor.js @@ -0,0 +1,101 @@ +/** + * WikiEditor — minimal new/edit modal for a wiki page. + * + * The full editor (markdown preview, evidence picker, etc.) is huge in + * the legacy code; here we ship a focused 3-field form so users can fix + * typos and create new pages. Future iterations can grow the editor + * without rewriting the surrounding list view. + */ +import { defineComponent, ref, computed, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, t } from '../store.js'; +import { api } from '../api.js'; + +export const WikiEditor = defineComponent({ + name: 'WikiEditor', + props: { + pageId: { type: [String, null], required: true }, + }, + emits: ['save', 'cancel'], + setup(props, { emit }) { + const loading = ref(false); + const title = ref(''); + const summary = ref(''); + const body = ref(''); + const tags = ref(''); + const importance = ref(0.5); + + async function load() { + if (props.pageId === 'new') { + title.value = ''; summary.value = ''; body.value = '- '; tags.value = ''; importance.value = 0.5; + return; + } + loading.value = true; + try { + const p = await api.getWiki(props.pageId); + title.value = p.title || ''; + summary.value = p.summary || ''; + body.value = p.body || ''; + tags.value = (p.tags || []).join(', '); + importance.value = p.importance || 0.5; + } catch (e) { + // ignore + } finally { + loading.value = false; + } + } + + watch(() => props.pageId, load, { immediate: true }); + + function onSave() { + const payload = { + title: title.value.trim(), + summary: summary.value.trim(), + body: body.value, + tags: tags.value.split(',').map(s => s.trim()).filter(Boolean), + importance: Number(importance.value) || 0.5, + }; + emit('save', payload); + } + + return { loading, title, summary, body, tags, importance, t, onSave, onCancel: () => emit('cancel') }; + }, + template: /* html */ ` + + `, +}); diff --git a/loop_memory/serve/static/js/composables/useI18n.js b/loop_memory/serve/static/js/composables/useI18n.js new file mode 100644 index 0000000..2386584 --- /dev/null +++ b/loop_memory/serve/static/js/composables/useI18n.js @@ -0,0 +1,15 @@ +/** + * Composable that returns the i18n `t()` function bound to the current + * language. Components use: + * + * const { t } = useI18n(); + * const label = t('topbar.stats'); + * + * Re-renders automatically because `t` reads `store.lang` reactively + * (Vue's reactivity tracks the dependency). + */ +import { store, t } from '../store.js'; + +export function useI18n() { + return { t, lang: () => store.lang }; +} diff --git a/loop_memory/serve/static/js/main.js b/loop_memory/serve/static/js/main.js new file mode 100644 index 0000000..d03941d --- /dev/null +++ b/loop_memory/serve/static/js/main.js @@ -0,0 +1,20 @@ +/** + * Entry point. Boots Vue 3 and mounts the App component. + * + * Vue 3 is loaded from a CDN (unpkg). We use the production ESM build + * to keep the runtime tiny. No build step is required — every component + * is a self-contained ES module that the browser imports directly. + * + * If a future iteration wants SFC + HMR, swap the CDN for the Vite + * dev server; the component sources are already structured to fit. + */ +import { createApp } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { App } from './App.js'; + +const root = document.getElementById('app'); +if (root) { + createApp(App).mount(root); +} else { + // Should never happen; helps debugging if the host page is misconfigured. + console.error('loop-memory: #app root not found'); +} diff --git a/loop_memory/serve/static/js/store.js b/loop_memory/serve/static/js/store.js new file mode 100644 index 0000000..d1d5da3 --- /dev/null +++ b/loop_memory/serve/static/js/store.js @@ -0,0 +1,147 @@ +/** + * Reactive global state for the loop-memory dashboard. + * + * Uses Vue 3 reactivity (no Vuex / Pinia) — this app is small enough that + * a plain reactive() object is easier to maintain than a state library. + * Components import { store, persistPrefs } and read / mutate. + */ +import { reactive, watch, computed } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; + +const STORE_KEY = 'loop_memory_prefs_v1'; + +function loadPrefs() { + try { + const raw = localStorage.getItem(STORE_KEY); + if (raw) return JSON.parse(raw); + } catch (e) { /* ignore */ } + return {}; +} + +function savePrefs(p) { + try { localStorage.setItem(STORE_KEY, JSON.stringify(p)); } catch (e) { /* ignore */ } +} + +const initial = loadPrefs(); + +// Reactive global state. The shape is documented in applyDefaults(). +export const store = reactive({ + // ---- user preferences (persisted) ---- + lang: initial.lang || 'zh', // 'zh' | 'en' + theme: initial.theme || 'auto', // 'auto' | 'light' | 'dark' + showZh: initial.showZh ?? true, // mixed-lang UI helper + + // ---- runtime state (NOT persisted) ---- + activeTab: 'timeline', // 'timeline' | 'dashboard' | 'wiki' | 'graph' + ready: false, // true after first i18n + theme apply + toast: null, // { msg, ts } + toastTimer: null, + runStatus: { is_running: false, progress: { current: 0, total: 0, message: '' } }, + stats: { memories: 0, sessions: 0, wiki_pages: 0, avg_score: 0, graph: '0/0', dbPath: '' }, + modelInfo: { provider: 'rules', model: 'rules', api_key_set: false, key_len: 0 }, + stripDismissed: false, + lastRunId: null, +}); + +// Persist prefs whenever they change. +watch(() => [store.lang, store.theme, store.showZh], () => { + savePrefs({ lang: store.lang, theme: store.theme, showZh: store.showZh }); +}); + +// ---- i18n ---- +let _i18n = { en: {}, zh: {} }; +let _i18nLoaded = false; + +export async function loadI18n() { + if (_i18nLoaded) return _i18n; + // Load in parallel; ignore individual failures so a single missing file + // doesn't break the whole UI. + const [en, zh] = await Promise.all([ + fetch('static/i18n/en.json').then(r => r.ok ? r.json() : {}).catch(() => ({})), + fetch('static/i18n/zh.json').then(r => r.ok ? r.json() : {}).catch(() => ({})), + ]); + _i18n = { en, zh }; + _i18nLoaded = true; + return _i18n; +} + +export function t(key, vars) { + const dict = _i18n[store.lang] || _i18n.zh || {}; + let s = dict[key] ?? _i18n.en[key] ?? key; + if (vars) { + for (const k of Object.keys(vars)) s = s.replace('{' + k + '}', vars[k]); + } + return s; +} + +export function tOrKey(key) { + return t(key, undefined); +} + +export const lang = computed(() => store.lang); +export const theme = computed(() => store.theme); + +// ---- theme ---- +function _systemPrefersDark() { + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +export function effectiveTheme() { + if (store.theme === 'auto') return _systemPrefersDark() ? 'dark' : 'light'; + return store.theme; +} + +export function applyTheme() { + document.documentElement.setAttribute('data-theme', effectiveTheme()); +} + +export function applyLang() { + document.documentElement.setAttribute('data-lang', store.lang); +} + +// Apply theme/lang to reactively. +watch(() => store.theme, () => applyTheme()); +watch(() => store.lang, () => applyLang()); +if (window.matchMedia) { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (store.theme === 'auto') applyTheme(); + }); +} + +// ---- toast ---- +export function toast(msg, ms = 2200) { + store.toast = { msg, ts: Date.now() }; + if (store.toastTimer) clearTimeout(store.toastTimer); + store.toastTimer = setTimeout(() => { store.toast = null; }, ms); +} + +// ---- prefs patch ---- +export function patchPrefs(patch) { + Object.assign(store, patch); + savePrefs({ + lang: store.lang, theme: store.theme, showZh: store.showZh, + }); +} + +// ---- formatting helpers ---- +export function escapeHtml(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +export function timeAgo(ts) { + if (!ts) return '—'; + const d = Date.now() / 1000 - Number(ts); + if (d < 60) return Math.max(0, Math.floor(d)) + 's'; + if (d < 3600) return Math.floor(d / 60) + 'm'; + if (d < 86400) return Math.floor(d / 3600) + 'h'; + if (d < 86400 * 30) return Math.floor(d / 86400) + 'd'; + return Math.floor(d / 86400 / 30) + 'mo'; +} + +export function fmtTime(ts) { + if (!ts) return '—'; + const d = new Date(Number(ts) * 1000); + return d.toLocaleString(); +} From a02bdb4daf9b0c23c2c51ec02f36335d3077f21e Mon Sep 17 00:00:00 2001 From: Loop Memory Dev Date: Sat, 18 Jul 2026 14:48:56 +0800 Subject: [PATCH 02/88] Frontend: fix missing WikiEditor import + Diagnostic endpoint The Wiki new-page button silently did nothing because Wiki.js referenced in its template but never imported the component. Vue 3 then treated the tag as a generic custom element and rendered an empty shell. Fix: import { WikiEditor } from './WikiEditor.js' and register it in the components: {} map. The new-page editor now opens correctly with all form fields, and the test (createWiki / updateWiki / cancel) flow works end-to-end. Also switched Diagnostic's fetch from /api/health (404) to /api/diag (200) so the modal now shows real subsystem status instead of an empty error block. --- loop_memory/serve/static/js/components/Diagnostic.js | 2 +- loop_memory/serve/static/js/components/Wiki.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/loop_memory/serve/static/js/components/Diagnostic.js b/loop_memory/serve/static/js/components/Diagnostic.js index 2dea94d..026bd29 100644 --- a/loop_memory/serve/static/js/components/Diagnostic.js +++ b/loop_memory/serve/static/js/components/Diagnostic.js @@ -18,7 +18,7 @@ export const Diagnostic = defineComponent({ async function check() { loading.value = true; - try { data.value = await api.health(); } + try { data.value = await fetch('/api/diag').then(r => r.ok ? r.json() : {ok: false}); } catch (e) { data.value = { ok: false, error: e.message }; } finally { loading.value = false; } } diff --git a/loop_memory/serve/static/js/components/Wiki.js b/loop_memory/serve/static/js/components/Wiki.js index 627439a..87dcacc 100644 --- a/loop_memory/serve/static/js/components/Wiki.js +++ b/loop_memory/serve/static/js/components/Wiki.js @@ -10,9 +10,11 @@ import { defineComponent, ref, computed, onMounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; import { store, t, escapeHtml, fmtTime } from '../store.js'; import { api } from '../api.js'; +import { WikiEditor } from './WikiEditor.js'; export const Wiki = defineComponent({ name: 'Wiki', + components: { WikiEditor }, setup() { const pages = ref([]); const q = ref(''); From ad42135b3544c37137495308d8d018b0b20b06eb Mon Sep 17 00:00:00 2001 From: Loop Memory Dev Date: Sat, 18 Jul 2026 14:51:52 +0800 Subject: [PATCH 03/88] Frontend: better index.html title + description --- loop_memory/serve/static/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/loop_memory/serve/static/index.html b/loop_memory/serve/static/index.html index 50c48f8..4aaf53f 100644 --- a/loop_memory/serve/static/index.html +++ b/loop_memory/serve/static/index.html @@ -3,7 +3,8 @@ - Loop Memory + 循环记忆 · Loop Memory + From 1f8e522726cac5476f843131edcd6179944f1616 Mon Sep 17 00:00:00 2001 From: Loop Memory Dev Date: Sat, 18 Jul 2026 15:44:46 +0800 Subject: [PATCH 04/88] Graph: restore rotation and polish sphere workspace --- loop_memory/serve/static/css/layout.css | 150 +++++ loop_memory/serve/static/i18n/en.json | 7 +- loop_memory/serve/static/i18n/zh.json | 7 +- .../static/js/components/KnowledgeGraph.js | 617 ++++++++++++------ 4 files changed, 589 insertions(+), 192 deletions(-) diff --git a/loop_memory/serve/static/css/layout.css b/loop_memory/serve/static/css/layout.css index 103314c..8cc5573 100644 --- a/loop_memory/serve/static/css/layout.css +++ b/loop_memory/serve/static/css/layout.css @@ -462,3 +462,153 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai from { transform: translateX(-8px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + +/* --- Knowledge graph workspace --- */ +#pane-graph { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + padding: 16px 24px 22px; + background: + radial-gradient(circle at 78% 16%, color-mix(in srgb, var(--accent) 5%, transparent), transparent 34%), + var(--bg); +} + +#pane-graph .graph-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + min-height: 50px; + margin: 0 0 10px; + padding: 8px 10px; + background: color-mix(in srgb, var(--surface) 94%, transparent); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 4px 18px rgba(15, 23, 42, 0.045); +} + +#pane-graph .graph-toolbar input, +#pane-graph .graph-toolbar select { + height: 34px; + padding: 0 10px; + color: var(--text); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; + outline: none; + transition: border-color .16s ease, box-shadow .16s ease, background .16s ease; +} + +#pane-graph .graph-toolbar input { + width: min(220px, 24vw); +} + +#pane-graph .graph-toolbar input:focus, +#pane-graph .graph-toolbar select:focus { + background: var(--surface); + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 10%, transparent); +} + +#pane-graph .stats-text { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 9px; + color: var(--text-mute); + background: var(--surface-2); + border-radius: 999px; + white-space: nowrap; +} + +#pane-graph .stats-text strong { + margin-right: 3px; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.rotation-status { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + color: var(--text-mute); + height: 28px; + padding: 0 10px; + background: color-mix(in srgb, var(--accent) 6%, var(--surface)); + border: 1px solid var(--border); + border-radius: 999px; + white-space: nowrap; +} +.rotation-status[data-paused="true"] { + background: color-mix(in srgb, #f59e0b 14%, transparent); + color: #d97706; + border-color: color-mix(in srgb, #f59e0b 30%, transparent); +} +.graph-tooltip { + position: absolute; + bottom: 34px; + left: 50%; + transform: translateX(-50%); + z-index: 2; + background: color-mix(in srgb, var(--surface) 94%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border)); + border-radius: 10px; + padding: 9px 14px; + box-shadow: 0 10px 30px rgba(15,23,42,0.13); + backdrop-filter: blur(10px); + pointer-events: none; + text-align: center; + max-width: 360px; + animation: graph-tip-in .14s ease-out; +} +.gt-name { + font-weight: 600; + font-size: 13px; + color: var(--text); + word-break: break-all; +} +.gt-hint { + font-size: 11px; + color: var(--text-faint); + margin-top: 2px; +} + +.graph-wrap { + flex: 1; + position: relative; + min-height: 0; + border-radius: 14px; + border: 1px solid var(--border); + background: linear-gradient(135deg, var(--surface) 0%, var(--surface-2) 100%); + box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 55%, transparent), 0 10px 34px rgba(15,23,42,.055); + overflow: hidden; +} + +.graph-wrap canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +[data-theme="dark"] #pane-graph .graph-toolbar, +[data-theme="dark"] .graph-wrap { + box-shadow: 0 10px 32px rgba(0, 0, 0, .18); +} + +@keyframes graph-tip-in { + from { opacity: 0; transform: translate(-50%, 5px); } + to { opacity: 1; transform: translate(-50%, 0); } +} + +@media (max-width: 980px) { + #pane-graph { padding: 12px; } + #pane-graph .graph-toolbar { flex-wrap: wrap; } + #pane-graph .graph-toolbar .spacer { display: none; } + #pane-graph .graph-toolbar input { flex: 1; width: 140px; } +} diff --git a/loop_memory/serve/static/i18n/en.json b/loop_memory/serve/static/i18n/en.json index 1cded0e..0ea26cf 100644 --- a/loop_memory/serve/static/i18n/en.json +++ b/loop_memory/serve/static/i18n/en.json @@ -255,8 +255,10 @@ "graph.connectedTo": "Connected to", "graph.entities": "entities", "graph.evidence": "Evidence (memories that mention this)", + "graph.farSide": "on far side", "graph.filterPlaceholder": "Filter nodes…", "graph.fit": "Fit", + "graph.hoverHint": "Click to select, drag to adjust, wheel to zoom; rotation resumes when you leave", "graph.kind.concept": "Concept", "graph.kind.tag": "Tag", "graph.kind.wiki_page": "Wiki page", @@ -267,9 +269,12 @@ "graph.noGraph.body": "Click Rebuild to extract entities and relations from the existing memories.", "graph.noGraph.title": "Knowledge graph is empty", "graph.noSelection": "Click a node to inspect", + "graph.nodes": "nodes", "graph.other": "other", "graph.rebuild": "Rebuild", "graph.relations": "relations", + "graph.rotationPaused": "Hover paused", + "graph.rotationSlow": "Slow rotation", "graph.rotateTip": "Rotate the globe slowly; hover to pause nodes", "health.degraded": "degraded", "health.fresh": "fresh", @@ -516,4 +521,4 @@ "wiki.versionTip": "Version {n}", "wiki.view": "View", "wiki.viewEvidence": "Jump to source memory" -} \ No newline at end of file +} diff --git a/loop_memory/serve/static/i18n/zh.json b/loop_memory/serve/static/i18n/zh.json index 7de36c0..e9f0b5c 100644 --- a/loop_memory/serve/static/i18n/zh.json +++ b/loop_memory/serve/static/i18n/zh.json @@ -255,8 +255,10 @@ "graph.connectedTo": "关联节点", "graph.entities": "实体", "graph.evidence": "证据(包含此节点的记忆)", + "graph.farSide": "个位于背面", "graph.filterPlaceholder": "过滤节点…", "graph.fit": "适配窗口", + "graph.hoverHint": "点击选中,拖动调整,滚轮缩放;移开后继续自转", "graph.kind.concept": "概念", "graph.kind.tag": "标签", "graph.kind.wiki_page": "知识库", @@ -267,9 +269,12 @@ "graph.noGraph.body": "点击 重建 从已有记忆中抽取实体与关系。", "graph.noGraph.title": "知识图谱为空", "graph.noSelection": "点击节点查看详情", + "graph.nodes": "个节点", "graph.other": "其他", "graph.rebuild": "重建", "graph.relations": "关系", + "graph.rotationPaused": "悬停已暂停", + "graph.rotationSlow": "缓慢自转", "graph.rotateTip": "缓慢旋转知识球;悬停节点时暂停跳动", "health.degraded": "部分异常", "health.fresh": "正常", @@ -516,4 +521,4 @@ "wiki.versionTip": "版本 {n}", "wiki.view": "查看", "wiki.viewEvidence": "跳转到源记忆" -} \ No newline at end of file +} diff --git a/loop_memory/serve/static/js/components/KnowledgeGraph.js b/loop_memory/serve/static/js/components/KnowledgeGraph.js index 6f7befc..736c90e 100644 --- a/loop_memory/serve/static/js/components/KnowledgeGraph.js +++ b/loop_memory/serve/static/js/components/KnowledgeGraph.js @@ -1,18 +1,24 @@ /** * KnowledgeGraph — 3D Fibonacci-sphere knowledge graph. * - * The drawing logic (canvas, force simulation, rotation, label rendering) - * is a direct port of the legacy vanilla-JS code. We mount it onto a - * ref so Vue handles the lifecycle (resize observer, tab - * visibility, mount/unmount) while the drawing code stays a single - * self-contained IIFE inside the component. + * Renders entities on a Fibonacci-sphere (even distribution) and rotates + * the whole sphere around its vertical (Y) axis. Each tick: + * 1. Increment every node's longitude by baseOmega (independent of + * force-sim alpha — auto-rotation never freezes). + * 2. Project (lat, lon) → 3D → 2D screen position with depth. + * 3. Draw edges back-to-front, then nodes back-to-front, then HUD. * - * The component is intentionally heavy — the canvas code is by far the - * most complex piece of the UI. Putting it all in one file keeps the - * math together and makes future tweaks (new label rules, different - * projection) easy to find. + * Interaction model: + * - Hover → pause rotation, jiggle the hovered node, dim others. + * - Drag → drag a node, or pan the canvas (drag empty space). + * - Wheel → zoom toward cursor. + * - Click → select a node (highlight + show full label). + * + * The previous version tied rotation to the simulation's alpha, so the + * sphere froze once the force-sim settled. Now rotation is decoupled and + * always running (paused only while hovering so picking is easier). */ -import { defineComponent, ref, onMounted, onUnmounted, watch, nextTick } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, ref, onMounted, onUnmounted, watch, nextTick, computed } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; import { store, t } from '../store.js'; import { api } from '../api.js'; @@ -35,18 +41,32 @@ export const KnowledgeGraph = defineComponent({ const stats = ref({ entities: 0, relations: 0 }); const filterText = ref(''); const filterKind = ref(''); + const hoverNode = ref(null); - // Draw state + // ---- Mutable draw state (plain objects, no Vue reactivity) ---- let graphData = { entities: [], relations: [] }; let graphNodes = {}; let graphEdges = []; - let graphLayout = { scale: 1, tx: 0, ty: 0, dragging: null, hover: null }; + let graphLayout = { scale: 1, tx: 0, ty: 0, dragging: null }; let graphSelected = null; - let graphInitTried = false; let sim = null; let animating = false; - let globalTick = 0; - let warm = false; + let hoveredName = null; + let mouseInCanvas = false; + let lastFrame = 0; + let rafHandle = null; + + // ---- Tuning ---- + const params = { + baseOmega: 0.00055, // ~0.032°/frame @60fps → ~190s per revolution + repulsion: 250, + springLen: 120, + springK: 0.012, + damping: 0.82, + alphaDecay: 0.04, + collideR: 18, + centerK: 0.018, + }; async function loadGraph() { loading.value = true; @@ -55,7 +75,10 @@ export const KnowledgeGraph = defineComponent({ const relLimit = Math.min(entLimit * 4, 600); const g = await api.graph({ limit_entities: entLimit, limit_relations: relLimit }); graphData = g; - stats.value = { entities: (g.entities || []).length, relations: (g.relations || []).length }; + stats.value = { + entities: (g.entities || []).length, + relations: (g.relations || []).length, + }; graphEdges = (g.relations || []).map((r, i) => ({ src: r.src, dst: r.dst, weight: r.weight, @@ -65,15 +88,19 @@ export const KnowledgeGraph = defineComponent({ graphNodes = {}; for (const e of (g.entities || [])) { graphNodes[e.name] = { - name: e.name, kind: e.kind, weight: e.weight, - mention: e.mention_count, _i: graphNodes.length || 0, - x: 0, y: 0, vx: 0, vy: 0, + name: e.name, + kind: e.kind, + weight: e.weight, + mention: e.mention_count, + x: 0, y: 0, z: 0, + vx: 0, vy: 0, + lat0: 0, lon: 0, shellR: 1, + jitter: 0, }; } seedFibonacci(); ensureSim(); fitToCanvas(); - refreshStats(); drawCanvas(); } catch (e) { console.error('graph load failed', e); @@ -91,6 +118,12 @@ export const KnowledgeGraph = defineComponent({ return h; } + /** + * Distribute N nodes on a sphere via the Fibonacci-lattice method. + * Even spacing (no clustering at the poles). Interleave high-mention + * nodes so they spread across the sphere rather than concentrating + * at one latitude. + */ function seedFibonacci() { const names = Object.keys(graphNodes); names.sort((a, b) => { @@ -100,10 +133,8 @@ export const KnowledgeGraph = defineComponent({ return stableHash(a) - stableHash(b); }); const N = names.length; - const R = Math.max(220, Math.sqrt(N) * 28); + const R = Math.max(220, Math.sqrt(N) * 32); const PHI = Math.PI * (3 - Math.sqrt(5)); - // Interleave high/low mention so the high-mention nodes spread - // across the sphere instead of clustering at one pole. const ordered = new Array(N); const halfHigh = Math.min(N, Math.ceil(N * 0.5)); let hi = 0, lo = halfHigh; @@ -116,51 +147,46 @@ export const KnowledgeGraph = defineComponent({ const lat = Math.asin(1 - 2 * (i + 0.5) / N); const lon0 = ((i * PHI) % (Math.PI * 2)); const node = graphNodes[name]; - node.x = R * Math.cos(lat) * Math.sin(lon0); - node.y = R * Math.sin(lat); node.lat0 = lat; - node.shellR = R; node.lon = lon0; + node.shellR = R; + // 3D coords (z positive toward viewer) + node.x = R * Math.cos(lat) * Math.sin(lon0); + node.y = R * Math.sin(lat); + node.z = R * Math.cos(lat) * Math.cos(lon0); }); } - // Force-simulation is intentionally minimal — the Fibonacci sphere is - // already a sphere, so we only need a few ticks to settle user drags. + /** + * Force-sim only handles drag-settle + collision. Rotation is decoupled + * (handled in renderLoop directly) so it never freezes after settling. + */ function makeSim() { - const params = { - repulsion: 250, springLen: 120, springK: 0.012, centerK: 0.018, - collideR: 18, collideK: 0.5, damping: 0.82, alphaDecay: 0.04, - baseOmega: 0.0014, shellBoost: 0.6, minAlpha: 0.05, maxSpeed: 6, - }; let alpha = 0; const nodes = () => Object.values(graphNodes); return { - params: () => params, tick() { if (alpha < 0.001) return; for (const n of nodes()) { for (const m of nodes()) { if (m === n) continue; const dx = n.x - m.x, dy = n.y - m.y; - const d2 = dx*dx + dy*dy + 0.01; + const d2 = dx * dx + dy * dy + 0.01; const f = params.repulsion / d2; - n.vx += dx / Math.sqrt(d2) * f * 0.01; - n.vy += dy / Math.sqrt(d2) * f * 0.01; + n.vx += (dx / Math.sqrt(d2)) * f * 0.01; + n.vy += (dy / Math.sqrt(d2)) * f * 0.01; } } for (const e of graphEdges) { const a = graphNodes[e.src], b = graphNodes[e.dst]; if (!a || !b) continue; const dx = b.x - a.x, dy = b.y - a.y; - const d = Math.sqrt(dx*dx + dy*dy) || 0.01; + const d = Math.sqrt(dx * dx + dy * dy) || 0.01; const target = params.springLen * (e.weight || 0.5); const f = (d - target) * params.springK; - a.vx += dx / d * f; - a.vy += dy / d * f; - b.vx -= dx / d * f; - b.vy -= dy / d * f; + a.vx += (dx / d) * f; a.vy += (dy / d) * f; + b.vx -= (dx / d) * f; b.vy -= (dy / d) * f; } - // Gentle pull back to Fibonacci surface for (const n of nodes()) { if (n.shellR == null) continue; n.vx += -n.x * 0.0008; @@ -170,19 +196,11 @@ export const KnowledgeGraph = defineComponent({ n.x += n.vx; n.y += n.vy; n.vx *= params.damping; n.vy *= params.damping; } - // Sphere rotation - for (const n of nodes()) { - if (n.lat0 == null) continue; - n.lon = (n.lon || 0) + params.baseOmega; - } - alpha *= (1 - params.alphaDecay); + alpha *= 1 - params.alphaDecay; }, restart() { alpha = 1; }, setAlpha(v) { alpha = Math.max(0, Math.min(1, v)); }, alpha() { return alpha; }, - pause() {}, - resume() {}, - isRotating() { return true; }, }; } @@ -191,40 +209,29 @@ export const KnowledgeGraph = defineComponent({ sim.restart(); for (let i = 0; i < 20; i++) sim.tick(); sim.setAlpha(0); - // Nudge nodes back to Fibonacci positions for (const n of Object.values(graphNodes)) { if (n.lat0 !== undefined) { n.x = n.shellR * Math.cos(n.lat0) * Math.sin(n.lon); n.y = n.shellR * Math.sin(n.lat0); + n.z = n.shellR * Math.cos(n.lat0) * Math.cos(n.lon); } n.vx = 0; n.vy = 0; } - warm = true; } function fitToCanvas() { - const c = canvasRef.value; if (!c) return; const wrap = wrapRef.value; if (!wrap) return; const rect = wrap.getBoundingClientRect(); const W = rect.width > 100 ? rect.width : 1000; const H = rect.height > 100 ? rect.height : 700; const nodes = Object.values(graphNodes); if (!nodes.length) return; - let rMax = 0; - for (const n of nodes) { - const r = Math.sqrt(n.x*n.x + n.y*n.y); - if (r > rMax) rMax = r; - } - const R = rMax + 60; - const w = R * 2, h = R * 2; - graphLayout.scale = Math.min(W / w, H / h) * 0.96; + const sphereR = Math.max(...nodes.map(n => n.shellR || 0), 220); + const usableW = Math.max(260, W - 96); + const usableH = Math.max(260, H - 76); + graphLayout.scale = Math.min(usableW / (sphereR * 2), usableH / (sphereR * 2)) * 0.92; graphLayout.tx = W / 2; - graphLayout.ty = H / 2; - } - - function refreshStats() { - // Stats pill on top of canvas - store.stats.graph = `${stats.value.entities}/${stats.value.entities}`; + graphLayout.ty = (H - 14) / 2; } function resizeCanvas() { @@ -232,10 +239,12 @@ export const KnowledgeGraph = defineComponent({ const wrap = wrapRef.value; if (!wrap) return; const dpr = window.devicePixelRatio || 1; const rect = wrap.getBoundingClientRect(); - c.width = Math.max(100, rect.width) * dpr; - c.height = Math.max(100, rect.height) * dpr; - c.style.width = rect.width + 'px'; - c.style.height = rect.height + 'px'; + const width = Math.max(100, Math.round(c.clientWidth || rect.width)); + const height = Math.max(100, Math.round(c.clientHeight || rect.height)); + const pixelWidth = Math.round(width * dpr); + const pixelHeight = Math.round(height * dpr); + if (c.width !== pixelWidth) c.width = pixelWidth; + if (c.height !== pixelHeight) c.height = pixelHeight; } function hexA(hex, a) { @@ -259,6 +268,68 @@ export const KnowledgeGraph = defineComponent({ ctx.closePath(); } + /** + * Project one (lat, lon) to screen coords with depth. + * pz > 0 = front of sphere (toward viewer). + */ + function project(n) { + const lat = n.lat0; + const lon = n.lon; + const R = n.shellR; + const px = R * Math.cos(lat) * Math.sin(lon); + const py = R * Math.sin(lat); + const pz = R * Math.cos(lat) * Math.cos(lon); + return { px, py, pz, depth: (pz + R) / (2 * R) }; // 0..1 + } + + function nodeRadius(n, depth) { + const sizeMul = 0.55 + 0.85 * depth; + const kindBonus = n.kind === 'wiki_page' ? 2.25 : n.kind === 'tag' ? 0.72 : 1; + return (3.5 + Math.sqrt(n.mention || 1) * 1.6) * sizeMul * kindBonus; + } + + function pickNode(cx, cy) { + let best = null; + let bestScore = Infinity; + for (const n of Object.values(graphNodes)) { + if (filterKind.value && n.kind !== filterKind.value) continue; + if (filterText.value && !n.name.toLowerCase().includes(filterText.value.toLowerCase())) continue; + const p = project(n); + const sx = graphLayout.tx + p.px * graphLayout.scale; + const sy = graphLayout.ty + p.py * graphLayout.scale; + const dx = sx - cx, dy = sy - cy; + const d = Math.sqrt(dx * dx + dy * dy); + const hitRadius = Math.max(14, Math.min(28, nodeRadius(n, p.depth) * graphLayout.scale + 8)); + if (d > hitRadius) continue; + const score = d / hitRadius - p.depth * 0.12; + if (score < bestScore) { + bestScore = score; + best = n; + } + } + return best; + } + + /** + * Per-frame rotation + re-projection. + * Decoupled from sim.alpha — the sphere always rotates unless the user + * is hovering over the canvas (so picking is easy). + */ + function stepRotation(ts) { + const frameScale = lastFrame ? Math.min(2.4, Math.max(0.25, (ts - lastFrame) / 16.67)) : 1; + const paused = (mouseInCanvas && hoveredName) || graphLayout.dragging; + const rot = paused ? 0 : params.baseOmega * frameScale; + for (const n of Object.values(graphNodes)) { + if (n.lat0 == null) continue; + n.lon = (n.lon || 0) + rot; + const p = project(n); + n.x = p.px; + n.y = p.py; + n.z = p.pz; + } + lastFrame = ts; + } + function drawCanvas() { const c = canvasRef.value; if (!c) return; const ctx = c.getContext('2d'); @@ -270,128 +341,265 @@ export const KnowledgeGraph = defineComponent({ const themeAttr = document.documentElement.getAttribute('data-theme') || 'light'; const isDark = themeAttr === 'dark'; + const bg = isDark ? '#0b0e16' : '#fafbfd'; const textFaint = isDark ? 'rgba(238,242,255,0.55)' : 'rgba(15,23,42,0.55)'; - const halo = '#ec4899'; - const cssW = c.clientWidth, cssH = c.clientHeight; + + ctx.fillStyle = bg; + ctx.fillRect(0, 0, cssW, cssH); + + // Sphere outline + atmospheric shading ctx.save(); ctx.translate(graphLayout.tx, graphLayout.ty); ctx.scale(graphLayout.scale, graphLayout.scale); + const nodes = Object.values(graphNodes); + let rMax = 0; + for (const n of nodes) { + const r = Math.sqrt(n.x * n.x + n.y * n.y); + if (r > rMax) rMax = r; + } + const sphereR = rMax + 4; - const visibleNodes = Object.values(graphNodes).filter(n => { - if (filterKind.value && n.kind !== filterKind.value) return false; - if (filterText.value && !n.name.toLowerCase().includes(filterText.value.toLowerCase())) return false; - return true; - }); - const visibleNames = new Set(visibleNodes.map(n => n.name)); - - // Project nodes from (lat, lon) into 2D - const projected = []; - for (const n of visibleNodes) { - const lat = (n.lat0 !== undefined) ? n.lat0 : Math.atan2(n.y, n.x); - const R = (n.shellR !== undefined) ? n.shellR : (Math.sqrt(n.x*n.x + n.y*n.y) || 1); - const lon = (n.lon !== undefined) ? n.lon : 0; - const sinLat = Math.sin(lat), cosLat = Math.cos(lat); - const sinLon = Math.sin(lon), cosLon = Math.cos(lon); - const px = R * cosLat * sinLon; - const py = R * sinLat; - const pz = R * cosLat * cosLon; - const front = (pz + R) / (2 * R); - projected.push({ name: n.name, x: px, y: py, depth: front, node: n }); + // Sphere halo (outer glow) + const halo = ctx.createRadialGradient(0, 0, sphereR * 0.9, 0, 0, sphereR * 1.5); + halo.addColorStop(0, isDark ? 'rgba(99,102,241,0.0)' : 'rgba(99,102,241,0.0)'); + halo.addColorStop(0.7, isDark ? 'rgba(99,102,241,0.06)' : 'rgba(99,102,241,0.05)'); + halo.addColorStop(1, isDark ? 'rgba(99,102,241,0)' : 'rgba(99,102,241,0)'); + ctx.fillStyle = halo; + ctx.beginPath(); + ctx.arc(0, 0, sphereR * 1.5, 0, Math.PI * 2); + ctx.fill(); + + // Sphere shadow (gives 3D depth) + const shadow = ctx.createRadialGradient(-sphereR * 0.4, -sphereR * 0.4, sphereR * 0.3, 0, 0, sphereR); + shadow.addColorStop(0, isDark ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.18)'); + shadow.addColorStop(1, isDark ? 'rgba(0,0,0,0.45)' : 'rgba(15,23,42,0.06)'); + ctx.fillStyle = shadow; + ctx.beginPath(); + ctx.arc(0, 0, sphereR, 0, Math.PI * 2); + ctx.fill(); + + // Sphere outline + meridians (latitude rings) + ctx.strokeStyle = isDark ? 'rgba(129,140,248,0.18)' : 'rgba(99,102,241,0.22)'; + ctx.lineWidth = 0.8; + // Outline circle + ctx.beginPath(); + ctx.arc(0, 0, sphereR, 0, Math.PI * 2); + ctx.stroke(); + // Equator (thicker) + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.ellipse(0, 0, sphereR, sphereR * 0.18, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.lineWidth = 0.6; + // Latitude rings at ±30°, ±60° + for (const latDeg of [-60, -30, 30, 60]) { + const lat = latDeg * Math.PI / 180; + const ry = sphereR * 0.18 * Math.cos(lat) * 0.5 + sphereR * 0.06; + const rx = sphereR * Math.cos(lat); + if (rx > 4) { + ctx.beginPath(); + ctx.ellipse(0, sphereR * Math.sin(lat), rx, Math.max(2, ry), 0, 0, Math.PI * 2); + ctx.stroke(); + } } - projected.sort((a, b) => a.depth - b.depth); + // Tilted meridian (visual indicator of rotation axis) + ctx.strokeStyle = isDark ? 'rgba(129,140,248,0.10)' : 'rgba(99,102,241,0.14)'; + ctx.beginPath(); + ctx.ellipse(0, 0, sphereR * 0.18, sphereR, Math.PI / 6, 0, Math.PI * 2); + ctx.stroke(); + // Vertical axis (rotation pole) + ctx.strokeStyle = isDark ? 'rgba(165,180,252,0.20)' : 'rgba(99,102,241,0.25)'; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(0, -sphereR - 8); + ctx.lineTo(0, sphereR + 8); + ctx.stroke(); + ctx.setLineDash([]); + // Pole caps + ctx.fillStyle = isDark ? 'rgba(165,180,252,0.7)' : 'rgba(99,102,241,0.7)'; + ctx.beginPath(); ctx.arc(0, -sphereR - 6, 3, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(0, sphereR + 6, 3, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + + // Filtered visible nodes + projection + const visible = []; + for (const n of nodes) { + if (filterKind.value && n.kind !== filterKind.value) continue; + if (filterText.value && !n.name.toLowerCase().includes(filterText.value.toLowerCase())) continue; + const p = project(n); + visible.push({ n, ...p }); + } + // Sort back-to-front + visible.sort((a, b) => a.pz - b.pz); const projMap = {}; - for (const p of projected) projMap[p.name] = p; + for (const v of visible) projMap[v.n.name] = v; + const visibleNames = new Set(visible.map(v => v.n.name)); - // Draw edges + // Hover dim + const dimNonHover = !!(mouseInCanvas && hoveredName); + + // Edges (back-to-front) + ctx.save(); + ctx.translate(graphLayout.tx, graphLayout.ty); + ctx.scale(graphLayout.scale, graphLayout.scale); for (const e of graphEdges) { const a = graphNodes[e.src], b = graphNodes[e.dst]; if (!a || !b) continue; - if (!visibleNames.has(a.name) && !visibleNames.has(b.name)) continue; + if (!visibleNames.has(a.name) || !visibleNames.has(b.name)) continue; const pa = projMap[a.name], pb = projMap[b.name]; if (!pa || !pb) continue; - const depthAvg = (pa.depth + pb.depth) / 2; - const alpha = 0.32 * (0.45 + 0.55 * depthAvg); + const depthAvg = (pa.pz + pb.pz) / (2 * (pa.n.shellR || 1)); + const depthAlpha = 0.45 + 0.55 * depthAvg; const edgeKind = e.edgeKind || 'related'; let strokeColor; - if (edgeKind === 'tagged_with') strokeColor = hexA('#ec4899', alpha * 0.9); - else if (edgeKind === 'mentions') strokeColor = hexA('#6366f1', alpha * 0.7); - else if (edgeKind === 'related_to') strokeColor = hexA('#f59e0b', alpha * 0.85); - else strokeColor = hexA('#6366f1', alpha); + if (edgeKind === 'tagged_with') strokeColor = hexA('#ec4899', 0.7 * depthAlpha); + else if (edgeKind === 'mentions') strokeColor = hexA('#6366f1', 0.55 * depthAlpha); + else if (edgeKind === 'related_to') strokeColor = hexA('#f59e0b', 0.75 * depthAlpha); + else strokeColor = hexA('#6366f1', 0.6 * depthAlpha); + // Dim if hover on another node + if (dimNonHover && a.name !== hoveredName && b.name !== hoveredName) { + strokeColor = strokeColor.replace(/[\d.]+\)$/g, m => (parseFloat(m) * 0.18).toFixed(2) + ')'); + } ctx.strokeStyle = strokeColor; ctx.lineWidth = 0.5; ctx.beginPath(); - ctx.moveTo(pa.x, pa.y); - ctx.lineTo(pb.x, pb.y); + ctx.moveTo(pa.px, pa.py); + ctx.lineTo(pb.px, pb.py); ctx.stroke(); } - // Draw nodes - for (const p of projected) { - const n = p.node; - const depth = p.depth; - const sizeMul = 0.55 + 0.55 * depth; - const kindBonus = (n.kind === 'wiki_page') ? 2.2 : (n.kind === 'tag') ? 0.7 : 1.0; - const r = (4 + Math.sqrt(n.mention || 1) * 1.5) * sizeMul * kindBonus; - const baseFill = KIND_COLOR[n.kind] || KIND_COLOR.concept; + // Nodes (back-to-front). Labels are collected and drawn later in + // screen space so they stay readable at every zoom level. + const labelCandidates = []; + for (const v of visible) { + const n = v.n; + const depth = v.depth; // 0..1, front=1 + const isHover = hoveredName === n.name; const isSel = graphSelected && graphSelected.name === n.name; - const isHover = graphLayout.hover && graphLayout.hover.name === n.name; - const depthAlpha = 0.40 + 0.60 * depth; + const r = nodeRadius(n, depth); + const baseFill = KIND_COLOR[n.kind] || KIND_COLOR.concept; + // Stronger depth contrast: back nodes fade out so the sphere reads as 3D + const depthAlpha = 0.25 + 0.75 * depth; + const alpha = (dimNonHover && !isHover) ? depthAlpha * 0.20 : depthAlpha; + + // Halo for hover/selection + if (isHover || isSel) { + const haloR = r + 10; + const halo = ctx.createRadialGradient(v.px, v.py, r * 0.6, v.px, v.py, haloR); + halo.addColorStop(0, hexA(baseFill, 0.55)); + halo.addColorStop(1, hexA(baseFill, 0)); + ctx.fillStyle = halo; + ctx.beginPath(); + ctx.arc(v.px, v.py, haloR, 0, Math.PI * 2); + ctx.fill(); + } + + // Filled circle ctx.beginPath(); - ctx.arc(p.x, p.y, r, 0, Math.PI * 2); - ctx.fillStyle = hexA(baseFill, depthAlpha); + ctx.arc(v.px, v.py, r, 0, Math.PI * 2); + ctx.fillStyle = hexA(baseFill, alpha); ctx.fill(); - ctx.strokeStyle = 'rgba(0,0,0,0.18)'; - ctx.lineWidth = 1; + ctx.strokeStyle = isDark ? `rgba(255,255,255,${0.25 * alpha})` : `rgba(255,255,255,${0.85 * alpha})`; + ctx.lineWidth = 1.2; ctx.stroke(); - // Label — show wiki_page / tag / high-mention always const mention = n.mention || 0; - const importantLabel = n.kind === 'wiki_page' || mention >= 6; - const mediumLabel = n.kind === 'tag' || mention >= 3; - const showLabel = isSel || isHover || importantLabel || mediumLabel; + const importantLabel = (n.kind === 'wiki_page' && depth > 0.24) || mention >= 5; + const mediumLabel = n.kind === 'tag' || mention >= 2; + const polarLabel = Math.abs(v.py) > sphereR * 0.62 && depth > 0.55; + const showLabel = isSel || isHover || importantLabel || polarLabel || (mediumLabel && depth > 0.48); if (showLabel) { - const label = n.name.length > 34 ? n.name.slice(0, 32) + '…' : n.name; - ctx.font = '12px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'; - const m = ctx.measureText(label); - const lw = m.width + 12, lh = 18; - const dropBelow = (isSel || isHover) ? false : (p.y < 0); - const ly = dropBelow ? (p.y + r + 6) : (p.y - r - 14); - if (isSel || isHover) { - ctx.fillStyle = isDark ? '#a5b4fc' : '#eef2ff'; - roundRect(ctx, p.x - lw/2, ly, lw, lh, 9); - ctx.fill(); - ctx.fillStyle = isDark ? '#1d2433' : '#1e1b4b'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(label, p.x, ly + lh/2); - } else { - ctx.fillStyle = textFaint; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(label, p.x, ly + lh/2); - } + labelCandidates.push({ + n, depth, isHover, isSel, r, baseFill, + sx: graphLayout.tx + v.px * graphLayout.scale, + sy: graphLayout.ty + v.py * graphLayout.scale, + below: v.py > sphereR * 0.04, + priority: (isHover ? 10000 : 0) + (isSel ? 9000 : 0) + + (n.kind === 'wiki_page' ? 180 : n.kind === 'tag' ? 45 : 0) + + mention * 5 + depth * 120, + }); } } ctx.restore(); + // Labels: priority + collision culling keeps dense graphs legible while + // preserving names on both the upper and lower hemispheres. + labelCandidates.sort((a, b) => b.priority - a.priority); + const labelBoxes = []; + const maxLabels = Math.max(22, Math.min(40, Math.floor(cssW / 27))); + let shownLabels = 0; + for (const item of labelCandidates) { + const focus = item.isHover || item.isSel; + if (!focus && shownLabels >= maxLabels) continue; + const maxChars = focus ? 42 : item.n.kind === 'wiki_page' ? 24 : 20; + const label = item.n.name.length > maxChars + ? item.n.name.slice(0, Math.max(1, maxChars - 1)) + '…' + : item.n.name; + ctx.font = focus + ? '600 12px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif' + : item.n.kind === 'wiki_page' + ? '600 10.5px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif' + : '10.5px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'; + const padX = focus ? 9 : item.n.kind === 'wiki_page' ? 6 : 2; + const lw = ctx.measureText(label).width + padX * 2; + const lh = focus ? 24 : 18; + const screenR = Math.max(3, item.r * graphLayout.scale); + let ly = item.below ? item.sy + screenR + 7 : item.sy - screenR - lh - 7; + if (ly < 8) ly = item.sy + screenR + 7; + if (ly + lh > cssH - 30) ly = item.sy - screenR - lh - 7; + const lx = Math.max(6, Math.min(cssW - lw - 6, item.sx - lw / 2)); + const box = { x: lx - 3, y: ly - 2, w: lw + 6, h: lh + 4 }; + const overlaps = labelBoxes.some(placed => ( + box.x < placed.x + placed.w && box.x + box.w > placed.x + && box.y < placed.y + placed.h && box.y + box.h > placed.y + )); + if (overlaps && !focus) continue; + + if (focus || item.n.kind === 'wiki_page') { + ctx.fillStyle = focus + ? (isDark ? 'rgba(30,27,75,0.96)' : 'rgba(255,255,255,0.96)') + : (isDark ? 'rgba(15,20,34,0.72)' : 'rgba(255,255,255,0.76)'); + roundRect(ctx, lx, ly, lw, lh, focus ? 8 : 6); + ctx.fill(); + ctx.strokeStyle = hexA(item.baseFill, focus ? 0.68 : 0.22); + ctx.lineWidth = 1; + ctx.stroke(); + } + const labelAlpha = Math.min(1, 0.5 + 0.55 * item.depth); + ctx.fillStyle = focus + ? (isDark ? '#eef2ff' : '#25205f') + : (isDark ? `rgba(238,242,255,${labelAlpha})` : `rgba(15,23,42,${labelAlpha})`); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, lx + lw / 2, ly + lh / 2); + labelBoxes.push(box); + shownLabels += 1; + } + // HUD ctx.save(); ctx.font = '11px -apple-system, sans-serif'; - ctx.fillStyle = isDark ? 'rgba(238,242,255,0.45)' : 'rgba(15,23,42,0.45)'; + ctx.fillStyle = textFaint; ctx.textAlign = 'left'; + const total = Object.keys(graphNodes).length; + const back = visible.filter(v => v.depth < 0.4).length; + ctx.fillText(`${total} ${t('graph.nodes')} · ${back} ${t('graph.farSide')}`, 12, cssH - 14); + ctx.textAlign = 'right'; ctx.fillText( - `${Object.keys(graphNodes).length} / ${Object.keys(graphNodes).length} nodes`, - 10, cssH - 18 + `${stats.value.entities} ${t('graph.entities')} · ${stats.value.relations} ${t('graph.relations')}`, + cssW - 12, + cssH - 14, ); ctx.restore(); } - function renderLoop() { - globalTick++; + function renderLoop(ts) { + if (!animating) return; if (sim) sim.tick(); + stepRotation(ts); drawCanvas(); - requestAnimationFrame(renderLoop); + rafHandle = requestAnimationFrame(renderLoop); } function onWheel(e) { @@ -407,34 +615,12 @@ export const KnowledgeGraph = defineComponent({ drawCanvas(); } - function pickNode(cx, cy) { - let best = null, bestD = 12; - for (const n of Object.values(graphNodes)) { - const wx = (cx - graphLayout.tx) / graphLayout.scale; - const wy = (cy - graphLayout.ty) / graphLayout.scale; - const dx = n.x - wx, dy = n.y - wy; - const d = Math.sqrt(dx*dx + dy*dy); - if (d < bestD) { bestD = d; best = n; } - } - return best; - } - - function onCanvasMouseDown(e) { - const rect = canvasRef.value.getBoundingClientRect(); - const cx = e.clientX - rect.left, cy = e.clientY - rect.top; - const node = pickNode(cx, cy); - if (node) { - graphLayout.dragging = { kind: 'node', node }; - graphSelected = node; - } else { - graphLayout.dragging = { kind: 'pan', x0: e.clientX, y0: e.clientY, tx0: graphLayout.tx, ty0: graphLayout.ty }; - } - } function onCanvasMouseMove(e) { const rect = canvasRef.value.getBoundingClientRect(); const cx = e.clientX - rect.left, cy = e.clientY - rect.top; const node = pickNode(cx, cy); - graphLayout.hover = node; + hoveredName = node ? node.name : null; + hoverNode.value = node ? node.name : null; if (graphLayout.dragging) { if (graphLayout.dragging.kind === 'pan') { graphLayout.tx = graphLayout.dragging.tx0 + (e.clientX - graphLayout.dragging.x0); @@ -442,24 +628,50 @@ export const KnowledgeGraph = defineComponent({ } else if (graphLayout.dragging.kind === 'node') { const wx = (cx - graphLayout.tx) / graphLayout.scale; const wy = (cy - graphLayout.ty) / graphLayout.scale; - graphLayout.dragging.node.x = wx; - graphLayout.dragging.node.y = wy; - graphLayout.dragging.node.fixed = true; + const dragged = graphLayout.dragging.node; + const radius = dragged.shellR || 220; + const clampedY = Math.max(-radius * 0.98, Math.min(radius * 0.98, wy)); + dragged.lat0 = Math.asin(clampedY / radius); + const latitudeRadius = Math.max(1, radius * Math.cos(dragged.lat0)); + const sinLon = Math.max(-1, Math.min(1, wx / latitudeRadius)); + const nearLon = Math.asin(sinLon); + dragged.lon = Math.cos(dragged.lon) < 0 ? Math.PI - nearLon : nearLon; } } - drawCanvas(); } + + function onCanvasMouseDown(e) { + const rect = canvasRef.value.getBoundingClientRect(); + const cx = e.clientX - rect.left, cy = e.clientY - rect.top; + const node = pickNode(cx, cy); + if (node) { + graphLayout.dragging = { kind: 'node', node, ox: node.x, oy: node.y }; + graphSelected = node; + } else { + graphLayout.dragging = { kind: 'pan', x0: e.clientX, y0: e.clientY, tx0: graphLayout.tx, ty0: graphLayout.ty }; + } + } + function onCanvasMouseUp() { if (graphLayout.dragging && graphLayout.dragging.kind === 'node') { - graphLayout.dragging.node.fixed = false; + const n = graphLayout.dragging.node; + const p = project(n); + n.x = p.px; n.y = p.py; n.z = p.pz; } graphLayout.dragging = null; } + function onCanvasClick(e) { const rect = canvasRef.value.getBoundingClientRect(); const node = pickNode(e.clientX - rect.left, e.clientY - rect.top); - if (node) graphSelected = node; - else graphSelected = null; + graphSelected = node || null; + } + + function onCanvasEnter() { mouseInCanvas = true; } + function onCanvasLeave() { + mouseInCanvas = false; + hoveredName = null; + hoverNode.value = null; } let resizeObserver = null; @@ -472,11 +684,13 @@ export const KnowledgeGraph = defineComponent({ onMounted(async () => { await nextTick(); resizeCanvas(); - canvasRef.value.addEventListener('wheel', onWheel, { passive: false }); - canvasRef.value.addEventListener('mousedown', onCanvasMouseDown); - canvasRef.value.addEventListener('mousemove', onCanvasMouseMove); + const cv = canvasRef.value; + cv.addEventListener('wheel', onWheel, { passive: false }); + cv.addEventListener('mousedown', onCanvasMouseDown); + cv.addEventListener('mousemove', onCanvasMouseMove); + cv.addEventListener('mouseenter', onCanvasEnter); + cv.addEventListener('mouseleave', onCanvasLeave); window.addEventListener('mouseup', onCanvasMouseUp); - canvasRef.value.addEventListener('click', onCanvasClick); window.addEventListener('resize', onResize); if (window.ResizeObserver) { resizeObserver = new ResizeObserver(onResize); @@ -484,18 +698,33 @@ export const KnowledgeGraph = defineComponent({ } await loadGraph(); animating = true; - requestAnimationFrame(renderLoop); + rafHandle = requestAnimationFrame(renderLoop); }); onUnmounted(() => { animating = false; + if (rafHandle) cancelAnimationFrame(rafHandle); if (resizeObserver) resizeObserver.disconnect(); window.removeEventListener('resize', onResize); window.removeEventListener('mouseup', onCanvasMouseUp); }); - return { canvasRef, wrapRef, loading, stats, filterText, filterKind, t, - rebuild: loadGraph }; + const rotationLabel = computed(() => { + // i18n loads asynchronously; these reactive reads invalidate the label + // once the dictionaries are ready or the user switches language. + void store.ready; + void store.lang; + if (hoverNode.value) return `⏸ ${t('graph.rotationPaused')}`; + return `↻ ${t('graph.rotationSlow')}`; + }); + + return { + canvasRef, wrapRef, loading, stats, filterText, filterKind, + hoverNode, rotationLabel, t, + rebuild: loadGraph, + // expose internal counters for template + total: computed(() => Object.keys(graphNodes).length), + }; }, template: /* html */ `
@@ -509,13 +738,21 @@ export const KnowledgeGraph = defineComponent({ + + {{ rotationLabel }} + - {{ stats.entities }} {{ t('graph.entities') }} · {{ stats.relations }} {{ t('graph.relations') }} + {{ stats.entities }} {{ t('graph.entities') }} · + {{ stats.relations }} {{ t('graph.relations') }}
{{ t('common.loading') }}
+
+
{{ hoverNode }}
+
{{ t('graph.hoverHint') }}
+
`, From f6e0ab9f88ca16f34167b87e700582f990ecd979 Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:29:56 +0800 Subject: [PATCH 05/88] Frontend: restore full Insights dashboard (10 sections) - Dashboard.js: 10 KPI tiles + 3 ring meters + lifecycle + pulse + compression + granularity + distribution + sources + pipeline + health/weekly + LLM audit + write-guard + architecture loop. All sections guarded against missing fields so partial /api/insights still renders. - Settings.js: remove stray closing tag that broke App template compilation, which prevented Dashboard's onMounted hook from firing on initial mount. - layout.css: dashboard styles for KPI/rings/lifecycle/pulse/compression/ granularity/distribution/sources/pipeline/health/audit/guard/arch. --- loop_memory/serve/static/css/layout.css | 411 +++++++++ .../serve/static/js/components/Dashboard.js | 859 +++++++++++++----- .../serve/static/js/components/Settings.js | 1 - 3 files changed, 1060 insertions(+), 211 deletions(-) diff --git a/loop_memory/serve/static/css/layout.css b/loop_memory/serve/static/css/layout.css index 8cc5573..53e7502 100644 --- a/loop_memory/serve/static/css/layout.css +++ b/loop_memory/serve/static/css/layout.css @@ -264,6 +264,417 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai @keyframes pulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 50%, transparent); } 100% { box-shadow: 0 0 0 8px transparent; } } .ev-ico { display: inline-block; margin-right: 4px; } +/* --- 10-up KPI grid + 3 ring meters --- */ +.ins-kpi-11 { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; +} +@media (max-width: 1180px) { .ins-kpi-11 { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@media (max-width: 720px) { .ins-kpi-11 { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.ins-rings { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 12px; +} +@media (max-width: 900px) { .ins-rings { grid-template-columns: 1fr; } } +.ins-ring-card { + display: flex; align-items: center; gap: 14px; + padding: 14px 16px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 12px; + min-width: 0; +} +.ins-ring-card .irc-svg { width: 64px; height: 64px; flex-shrink: 0; } +.ins-ring-card .irc-info { flex: 1; min-width: 0; } +.ins-ring-card .irc-label { + font-size: 11px; font-weight: 700; color: var(--text-mute); + text-transform: uppercase; letter-spacing: 1px; + display: flex; align-items: center; gap: 6px; +} +.ins-ring-card .irc-label .ico { font-size: 13px; } +.ins-ring-card .irc-sub { font-size: 11px; color: var(--text-faint); margin-top: 2px; } +.ins-ring-track { fill: none; stroke: var(--border); stroke-width: 8; } +.ins-ring-arc { + fill: none; stroke-width: 8; stroke-linecap: round; + transform: rotate(-90deg); transform-origin: center; + transition: stroke-dashoffset .8s cubic-bezier(.4,0,.2,1); +} +.ins-ring-arc.green { stroke: #10b981; } +.ins-ring-arc.blue { stroke: #3b82f6; } +.ins-ring-arc.rose { stroke: #f43f5e; } +.ins-ring-text { + font-size: 13px; font-weight: 700; fill: var(--text); + text-anchor: middle; dominant-baseline: central; + font-variant-numeric: tabular-nums; +} + +/* --- Lifecycle strip (6 stages) --- */ +.ins-lifecycle { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0; + padding: 4px; + background: var(--surface-2); + border-radius: 12px; + border: 1px solid var(--border); + overflow: hidden; +} +@media (max-width: 900px) { .ins-lifecycle { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +.ins-lc-stage { + padding: 12px 6px; + display: flex; flex-direction: column; align-items: center; gap: 4px; + border-right: 1px solid var(--border); + position: relative; +} +.ins-lc-stage:last-child { border-right: 0; } +.ins-lc-icon { + width: 44px; height: 44px; + display: grid; place-items: center; + border-radius: 50%; + font-size: 14px; font-weight: 700; + color: white; + background: var(--accent); + box-shadow: inset 0 0 0 3px color-mix(in srgb, white 35%, transparent); +} +.ins-lc-stage[data-tone="blue"] .ins-lc-icon { background: #3b82f6; } +.ins-lc-stage[data-tone="green"] .ins-lc-icon { background: #10b981; } +.ins-lc-stage[data-tone="amber"] .ins-lc-icon { background: #f59e0b; } +.ins-lc-stage[data-tone="purple"] .ins-lc-icon { background: #8b5cf6; } +.ins-lc-stage[data-tone="slate"] .ins-lc-icon { background: #64748b; } +.ins-lc-stage[data-tone="rose"] .ins-lc-icon { background: #f43f5e; } +.ins-lc-label { font-size: 11px; color: var(--text-mute); font-weight: 600; } +.ins-lc-pct { font-size: 10.5px; color: var(--text-faint); font-variant-numeric: tabular-nums; } + +/* --- 2-col layout for pulse + compression --- */ +.ins-grid-2 { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + gap: 12px; + margin-bottom: 14px; +} +@media (max-width: 1100px) { .ins-grid-2 { grid-template-columns: 1fr; } } + +/* --- Pulse: contradictions + score histogram --- */ +.ins-pulse { display: flex; flex-direction: column; gap: 10px; } +.ins-pulse-card { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; +} +.ins-pulse-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } +.ins-pulse-title { font-size: 12.5px; font-weight: 700; } +.ins-pulse-count { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; } +.ins-pulse-empty { font-size: 11.5px; color: var(--text-faint); padding: 12px 0; text-align: center; } +.ins-citem-list { display: flex; flex-direction: column; gap: 8px; } +.ins-citem { + border: 1px solid var(--border); + border-left: 3px solid #f43f5e; + border-radius: 8px; + padding: 8px 10px; + background: var(--surface); + display: flex; flex-direction: column; gap: 4px; +} +.ins-citem-a, .ins-citem-b { + font-size: 11.5px; line-height: 1.4; + color: var(--text); + padding: 4px 6px; border-radius: 6px; + background: var(--surface-2); + word-break: break-word; +} +.ins-citem-a b, .ins-citem-b b { color: var(--accent); font-size: 11px; margin-right: 4px; } +.ins-citem-meta { + display: flex; align-items: center; justify-content: space-between; + gap: 8px; margin-top: 4px; +} +.ins-citem-sim { + font-size: 10.5px; font-weight: 700; color: #f43f5e; + background: rgba(244,63,94,0.10); + padding: 1px 6px; border-radius: 999px; +} +.ins-citem-actions { display: flex; gap: 4px; flex-wrap: wrap; } +.ins-citem-actions .btn.xs { padding: 2px 7px; font-size: 10.5px; } +.ins-decay-svg { width: 100%; height: 140px; } +.ins-decay-bar { fill: var(--accent); opacity: 0.85; } +.ins-decay-bar:hover { opacity: 1; } +.ins-decay-label { fill: var(--text-faint); font-size: 9px; text-anchor: middle; } + +/* --- Compression card --- */ +.ins-cmp-summary { display: flex; gap: 18px; margin-bottom: 10px; } +.ins-cmp-summary b { font-size: 18px; color: var(--text); margin-right: 4px; } +.ins-cmp-summary .muted { color: var(--text-faint); font-size: 11.5px; } +.ins-cmp-list { display: flex; flex-direction: column; gap: 6px; } +.ins-cmp-item { + display: grid; + grid-template-columns: 90px 1fr auto; + gap: 10px; align-items: center; + padding: 7px 10px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 12px; +} +.ins-kind { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; + padding: 2px 6px; border-radius: 4px; + background: var(--surface); border: 1px solid var(--border); + color: var(--text-mute); text-align: center; +} +.ins-kind.kind-fact { color: #059669; background: rgba(16,185,129,0.10); } +.ins-kind.kind-episode { color: #2563eb; background: rgba(59,130,246,0.10); } +.ins-kind.kind-rule { color: #d97706; background: rgba(245,158,11,0.10); } +.ins-kind.kind-summary { color: #7c3aed; background: rgba(139,92,246,0.10); } +.ins-cmp-text { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ins-cmp-meta { display: flex; gap: 6px; align-items: center; } +.ins-cmp-imp { font-size: 11px; font-weight: 700; color: var(--accent); background: var(--accent-soft); padding: 1px 6px; border-radius: 999px; } +.ins-cmp-wiki { font-size: 10px; color: #059669; background: #d1fae5; padding: 1px 6px; border-radius: 4px; } + +/* --- Granularity 3-col layout --- */ +.ins-gran-cols { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} +@media (max-width: 900px) { .ins-gran-cols { grid-template-columns: 1fr; } } +.ins-gran-col { + background: var(--surface-2); + border: 1px solid var(--border); + border-top: 3px solid var(--accent); + border-radius: 10px; + padding: 12px; + display: flex; flex-direction: column; gap: 8px; + min-width: 0; +} +.ins-gran-col[data-tone="rose"] { border-top-color: #f43f5e; } +.ins-gran-col[data-tone="amber"] { border-top-color: #f59e0b; } +.ins-gran-col[data-tone="cyan"] { border-top-color: #06b6d4; } +.ins-gran-col-head { display: flex; justify-content: space-between; align-items: center; } +.ins-gran-col-title { font-size: 12.5px; font-weight: 700; } +.ins-gran-col-count { font-size: 18px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; } +.ins-gran-card { + padding: 8px 10px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; +} +.ins-gran-card-text { font-size: 11.5px; line-height: 1.4; color: var(--text); margin-bottom: 4px; word-break: break-word; } +.ins-gran-card-meta { display: flex; gap: 6px; } +.ins-gran-chip { + font-size: 10px; padding: 1px 6px; border-radius: 4px; + font-weight: 700; font-variant-numeric: tabular-nums; + background: var(--surface-2); + color: var(--text-mute); +} +.ins-gran-chip.k-imp { color: #f59e0b; } +.ins-gran-chip.k-score{ color: var(--accent); } + +/* --- Distribution row: donut + bars + trend --- */ +.ins-distrib { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.3fr); + gap: 12px; +} +@media (max-width: 1100px) { .ins-distrib { grid-template-columns: 1fr; } } +.ins-dist-card { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; +} +.ins-dist-title { font-size: 12px; font-weight: 700; margin-bottom: 8px; color: var(--text); } +.ins-donut-svg { width: 100%; max-width: 220px; height: 160px; display: block; margin: 0 auto; } +.ins-donut-legend { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; font-size: 11px; } +.ins-legend-item { display: flex; align-items: center; gap: 6px; } +.ins-legend-item .sw { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; } +.ins-legend-item .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ins-legend-item .ct { font-variant-numeric: tabular-nums; color: var(--text-mute); } +.ins-trend-svg { width: 100%; height: 140px; } +.ins-trend-line { stroke-width: 2; } + +/* --- Sources row: donut + wiki + ingest --- */ +.ins-sources-row { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) minmax(0, 1.2fr); + gap: 12px; +} +@media (max-width: 1100px) { .ins-sources-row { grid-template-columns: 1fr; } } +.ins-src-card { background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; } +.ins-src-title { font-size: 12.5px; font-weight: 700; margin-bottom: 8px; } +.ins-src-donut-wrap { display: flex; align-items: center; gap: 12px; } +.ins-src-donut { width: 110px; height: 110px; flex-shrink: 0; } +.ins-src-legend { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; font-size: 11px; } +.ins-src-legend-item { display: flex; align-items: center; gap: 6px; } +.ins-src-legend-item .sw { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; } +.ins-src-legend-item .nm { flex: 1; min-width: 0; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ins-src-legend-item .ct { font-variant-numeric: tabular-nums; color: var(--text-mute); } +.ins-wiki-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; } +.ins-wiki-stat { text-align: center; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 6px 4px; } +.ins-wiki-val { font-size: 17px; font-weight: 700; color: var(--accent); font-variant-numeric: tabular-nums; } +.ins-wiki-lbl { font-size: 9.5px; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; } +.ins-wiki-bar-track { height: 6px; background: var(--surface); border-radius: 4px; overflow: hidden; margin-bottom: 4px; border: 1px solid var(--border); } +.ins-wiki-bar-fill { height: 100%; background: linear-gradient(90deg, #10b981 0%, #059669 100%); border-radius: 4px; transition: width .4s ease; } +.ins-wiki-coverage-lbl { font-size: 10.5px; color: var(--text-faint); display: flex; justify-content: space-between; } +.ins-ingest-svg { width: 100%; height: 100px; } +.ins-ingest-total { margin-top: 4px; font-size: 11px; color: var(--text-faint); display: flex; justify-content: space-between; } + +/* --- Pipeline latency table --- */ +.ins-pipe { display: flex; flex-direction: column; gap: 8px; } +.ins-pipe-row { + display: grid; + grid-template-columns: 130px 1fr 70px 60px; + gap: 10px; align-items: center; +} +.ins-pipe-name { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); font-weight: 600; } +.ins-pipe-name .ico { opacity: .8; } +.ins-pipe-bar-track { height: 8px; background: var(--surface-2); border-radius: 4px; overflow: hidden; border: 1px solid var(--border); } +.ins-pipe-bar-fill { height: 100%; background: linear-gradient(90deg, #10b981 0%, #059669 100%); border-radius: 4px; transition: width .6s ease; } +.ins-pipe-ms { font-variant-numeric: tabular-nums; color: var(--text); text-align: right; font-size: 12px; } +.ins-pipe-cnt { font-variant-numeric: tabular-nums; color: var(--text-faint); text-align: right; font-size: 11.5px; } + +/* --- Health + weekly row --- */ +.ins-health-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); gap: 12px; } +@media (max-width: 1100px) { .ins-health-row { grid-template-columns: 1fr; } } +.ins-health-card, .ins-wreport-card, .ins-audit-card, .ins-guard-card { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; +} +.ins-health-title, .ins-wreport-title, .ins-audit-title, .ins-guard-title { + font-size: 12.5px; font-weight: 700; color: var(--text); + margin-bottom: 8px; display: flex; align-items: center; gap: 6px; +} +.ins-overall-pill { + margin-left: auto; + font-size: 10px; + padding: 2px 8px; + border-radius: 999px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.ins-overall-pill.healthy { background: rgba(16,185,129,0.12); color: #10b981; } +.ins-overall-pill.degraded { background: rgba(245,158,11,0.12); color: #f59e0b; } +.ins-overall-pill.silent { background: rgba(244,63,94,0.12); color: #f43f5e; } +.ins-overall-pill.fresh { background: rgba(16,185,129,0.12); color: #10b981; } +.ins-overall-pill.stale { background: rgba(245,158,11,0.12); color: #f59e0b; } +.ins-health-list { display: flex; flex-direction: column; gap: 6px; } +.ins-health-row-item { + display: grid; + grid-template-columns: 90px 1fr auto; + gap: 8px; align-items: center; + padding: 6px 10px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 11.5px; +} +.ins-hooks-strip { margin-top: 8px; font-size: 10.5px; color: var(--text-faint); } +.ins-hook { + display: inline-block; + padding: 2px 6px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + font-family: var(--mono); + margin: 2px 4px 2px 0; + font-size: 10.5px; + color: var(--text-mute); +} +.ins-wreport-md { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + font-size: 11.5px; + line-height: 1.55; + white-space: pre-wrap; + max-height: 360px; + overflow: auto; + font-family: var(--mono); +} +.ins-wreport-md.muted { color: var(--text-faint); font-family: inherit; } +.ins-wreport-loading, .ins-wreport-error { padding: 12px; font-size: 11.5px; color: var(--text-faint); } +.ins-wreport-error { color: #f43f5e; } +.ins-window-pills { display: inline-flex; gap: 4px; margin-left: auto; } +.ins-window-pill { + font-size: 10.5px; padding: 2px 8px; border-radius: 999px; + background: var(--surface-2); + border: 1px solid var(--border); + cursor: pointer; + color: var(--text-mute); +} +.ins-window-pill.active { background: var(--accent); color: white; border-color: var(--accent); } + +/* --- LLM Audit + WriteGuard row --- */ +.ins-audit-row { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 12px; } +@media (max-width: 1100px) { .ins-audit-row { grid-template-columns: 1fr; } } +.ins-audit-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 8px; } +.ins-audit-stat { text-align: center; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 6px 4px; } +.ins-audit-stat-val { font-size: 17px; font-weight: 700; color: var(--accent); font-variant-numeric: tabular-nums; } +.ins-audit-stat-lbl { font-size: 9.5px; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; } +.ins-audit-list { display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow: auto; } +.ins-audit-row-item { + display: grid; grid-template-columns: 110px 1fr 24px; gap: 6px; align-items: center; + padding: 4px 8px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; + font-size: 11px; +} +.ins-audit-stage { font-size: 10.5px; color: var(--text-mute); font-weight: 600; text-transform: uppercase; } +.ins-audit-meta { font-size: 10.5px; color: var(--text-faint); } +.ins-audit-status { font-size: 13px; text-align: center; font-weight: 700; } +.ins-audit-status[data-tone="green"] { color: #10b981; } +.ins-audit-status[data-tone="rose"] { color: #f43f5e; } +.ins-guard-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } +.ins-guard-stat { text-align: center; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 6px 4px; } +.ins-guard-stat-val { font-size: 17px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; } +.ins-guard-stat-lbl { font-size: 9.5px; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; } +.ins-guard-stat[data-tone="duplicate"] .ins-guard-stat-val { color: #6366f1; } +.ins-guard-stat[data-tone="too_long"] .ins-guard-stat-val { color: #f43f5e; } +.ins-guard-stat[data-tone="too_short"] .ins-guard-stat-val { color: #f59e0b; } +.ins-guard-stat[data-tone="low_signal"] .ins-guard-stat-val { color: #64748b; } + +/* --- Architecture loop diagram --- */ +.ins-arch-wrap { + background: linear-gradient(135deg, var(--surface-2) 0%, var(--surface) 100%); + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px; + position: relative; + overflow: hidden; +} +.ins-arch-svg { width: 100%; height: auto; max-height: 560px; display: block; } +.ins-arch-node-bg { fill: var(--surface); stroke: var(--accent); stroke-width: 1.5; } +.ins-arch-node-bg.tone-blue { stroke: #3b82f6; fill: rgba(59,130,246,0.08); } +.ins-arch-node-bg.tone-green { stroke: #10b981; fill: rgba(16,185,129,0.08); } +.ins-arch-node-bg.tone-amber { stroke: #f59e0b; fill: rgba(245,158,11,0.08); } +.ins-arch-node-bg.tone-purple { stroke: #8b5cf6; fill: rgba(139,92,246,0.08); } +.ins-arch-node-bg.tone-cyan { stroke: #06b6d4; fill: rgba(6,182,212,0.08); } +.ins-arch-node-bg.tone-rose { stroke: #f43f5e; fill: rgba(244,63,94,0.08); } +.ins-arch-edge { stroke: var(--accent); stroke-width: 1.5; fill: none; opacity: 0.6; } +.ins-arch-edge.animated { stroke-dasharray: 4 4; animation: arch-flow 1.4s linear infinite; } +@keyframes arch-flow { + to { stroke-dashoffset: -16; } +} +.ins-arch-caption { + margin-top: 10px; + font-size: 11.5px; + color: var(--text-faint); + text-align: center; +} +[data-theme="dark"] .ins-arch-wrap { + background: linear-gradient(135deg, rgba(99,102,241,0.06), var(--surface)); +} + +/* --- Generic button variant for tiny actions --- */ +.btn.xs { padding: 2px 7px; font-size: 10.5px; border-radius: 6px; } + +/* --- Make sure sections don't bleed past edges --- */ +.ins-section { overflow: hidden; } + + .ins-section { background: var(--surface); border: 1px solid var(--border); diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js index 08bbe83..e1fd02c 100644 --- a/loop_memory/serve/static/js/components/Dashboard.js +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -1,20 +1,16 @@ /** * Dashboard — Insights tab. * - * Pulls the full payload from `/api/insights` (cheap, polled every 5s) - * and renders it as a multi-pane dashboard: - * - KPI grid (10 metrics from overview) - * - Pipeline data flow (capture → recall → surface, animated arrows) - * - Distribution (types / status) - * - 24h ingest sparkline - * - Wiki health - * - Compression candidates - * - Per-source health + * Renders 10 KPI tiles + 3 ring meters + lifecycle + pulse + compression + + * granularity + distribution + sources + pipeline latency + health/weekly + * + LLM audit + write-guard + architecture loop. Every section defends + * against missing fields so a partial /api/insights payload still renders. */ import { defineComponent, ref, computed, onMounted, onUnmounted } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; -import { store, t, fmtTime, timeAgo } from '../store.js'; +import { store, t, timeAgo } from '../store.js'; import { api } from '../api.js'; +const SVGNS = 'http://www.w3.org/2000/svg'; const KIND_TONE = { episode: 'blue', fact: 'green', rule: 'amber', summary: 'purple', scratch: 'slate', concept: 'cyan', plan: 'rose', reflection: 'violet', @@ -22,103 +18,256 @@ const KIND_TONE = { const STATUS_TONE = { active: 'green', decayed: 'amber', forgotten: 'rose', archived: 'slate', }; +const SOURCE_COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#06b6d4', '#a855f7', '#f43f5e']; +const STAGE_DEFS = [ + { key: 'capture', tone: 'blue', file: 'cli/main.py hook' }, + { key: 'reflect', tone: 'green', file: 'engine/reflect.py' }, + { key: 'score', tone: 'amber', file: 'jobs/consolidate.py' }, + { key: 'store', tone: 'purple', file: 'storage/sqlite_store.py' }, + { key: 'recall', tone: 'cyan', file: 'serve/app.py /api/recall' }, + { key: 'surface', tone: 'rose', file: 'mcp/ + graph/' }, + { key: 'loopback', tone: 'amber', file: 'cli/main.py install-hooks' }, +]; + +const ARCH = (() => { + const cx = 600, cy = 330, R = 280; + const positions = STAGE_DEFS.map((s, i) => { + const a = -Math.PI / 2 + i * (2 * Math.PI / STAGE_DEFS.length); + return { ...s, idx: i, x: cx + Math.cos(a) * R, y: cy + Math.sin(a) * R }; + }); + const arcs = positions.map((p, i) => { + const n = STAGE_DEFS.length; + const a1 = -Math.PI / 2 + i * (2 * Math.PI / n) + 0.32; + const a2 = -Math.PI / 2 + ((i + 1) % n) * (2 * Math.PI / n) - 0.32; + const x1 = cx + Math.cos(a1) * R; + const y1 = cy + Math.sin(a1) * R; + const x2 = cx + Math.cos(a2) * R; + const y2 = cy + Math.sin(a2) * R; + return { d: 'M' + x1.toFixed(1) + ' ' + y1.toFixed(1) + + ' A' + R + ' ' + R + ' 0 0 1 ' + + x2.toFixed(1) + ' ' + y2.toFixed(1) }; + }); + return { cx, cy, positions, arcs }; +})(); + +function fmtNum(v) { return Number(v || 0).toLocaleString(); } +function truncate(s, n = 28) { + if (!s) return ''; + return s.length > n ? s.slice(0, n - 1) + '…' : s; +} +function safeArr(x) { return Array.isArray(x) ? x : []; } export const Dashboard = defineComponent({ name: 'Dashboard', setup() { const insights = ref(null); + const weeklyReport = ref(null); + const weeklyDays = ref(7); + const weeklyLoading = ref(false); + const weeklyError = ref(''); + const llmAudit = ref(null); + const writeGuard = ref(null); + const sourceHealth = ref(null); const loading = ref(false); const live = ref(false); const lastRefresh = ref(0); + const resolvingId = ref(''); + let pollHandle = null; async function refresh() { loading.value = true; try { - const [stats, insightsData] = await Promise.all([ - api.stats(), + const [stats, insightsData, health, audit, guard] = await Promise.all([ + api.stats().catch(() => null), fetch('/api/insights').then(r => r.ok ? r.json() : null).catch(() => null), + fetch('/api/source-health').then(r => r.ok ? r.json() : null).catch(() => null), + fetch('/api/llm-audit?limit=24').then(r => r.ok ? r.json() : null).catch(() => null), + fetch('/api/write-guard').then(r => r.ok ? r.json() : null).catch(() => null), ]); - store.stats = { - ...store.stats, - memories: stats.memories, - sessions: stats.sessions, - wiki_pages: stats.wiki_pages || 0, - avg_score: stats.avg_score, - graph: insightsData - ? `${insightsData.overview?.entities || 0}/${insightsData.overview?.links || 0}` - : '0/0', - dbPath: stats.path, - }; + if (stats) { + store.stats = { + ...store.stats, + memories: stats.memories, + sessions: stats.sessions, + wiki_pages: stats.wiki_pages || 0, + avg_score: stats.avg_score, + graph: insightsData + ? `${insightsData.overview?.entities || 0}/${insightsData.overview?.links || 0}` + : '0/0', + dbPath: stats.path, + }; + } insights.value = insightsData; live.value = !!insightsData; + sourceHealth.value = health; + llmAudit.value = audit; + writeGuard.value = guard; lastRefresh.value = Date.now(); - } catch (e) { - live.value = false; - } finally { - loading.value = false; - } + } catch (e) { live.value = false; } + finally { loading.value = false; } + await loadWeekly(weeklyDays.value); + } + + async function loadWeekly(days = 7) { + weeklyLoading.value = true; + weeklyError.value = ''; + try { + const res = await fetch(`/api/weekly-report?days=${days}&nocache=${Date.now()}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + weeklyReport.value = await res.json(); + weeklyDays.value = days; + } catch (e) { weeklyError.value = e?.message || 'failed'; } + finally { weeklyLoading.value = false; } + } + + async function resolvePair(pair, action) { + const aId = pair?.a?.id, bId = pair?.b?.id; + if (!aId || !bId) return; + const key = `${aId}|${bId}|${action}`; + resolvingId.value = key; + try { + const url = `/api/contradictions/resolve?a=${encodeURIComponent(aId)}&b=${encodeURIComponent(bId)}&action=${encodeURIComponent(action)}`; + const res = await fetch(url, { method: 'POST' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await refresh(); + } catch (e) { weeklyError.value = e?.message || 'resolve failed'; } + finally { resolvingId.value = ''; } + } + + async function copyWeekly() { + const md = weeklyReport.value?.markdown || ''; + if (!md) return; + try { await navigator.clipboard?.writeText(md); } catch (e) { /* ignore */ } } onMounted(() => { refresh(); - pollHandle = setInterval(refresh, 5000); - }); - onUnmounted(() => { - if (pollHandle) clearInterval(pollHandle); + pollHandle = setInterval(refresh, 6000); }); + onUnmounted(() => { if (pollHandle) clearInterval(pollHandle); }); - function onRefresh() { refresh(); } - function onRunEvolution() { window.dispatchEvent(new CustomEvent('loop:llm-run')); } - - // Sparkline helpers - function sparkPath(values, w = 120, h = 28) { - if (!values || !values.length) return ''; - const nums = values.map(Number).filter(n => !isNaN(n)); - if (!nums.length) return ''; - const max = Math.max(...nums, 1); - const step = w / Math.max(1, nums.length - 1); - return nums.map((v, i) => `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(h - (v / max) * h).toFixed(1)}`).join(' '); + function ring(pct) { + const v = Math.max(0, Math.min(1, (pct || 0) / 100)); + const r = 22, c = 2 * Math.PI * r; + return { dash: c, offset: c * (1 - v) }; } - function sparkFill(values, w = 120, h = 28) { - const top = sparkPath(values, w, h); - if (!top) return ''; - return `${top} L${w},${h} L0,${h} Z`; + + function donutArcPaths(rows) { + const items = safeArr(rows); + const total = items.reduce((a, b) => a + (b.count || 0), 0) || 1; + const r = 46, c = 2 * Math.PI * r; + let acc = 0; + return items.map((row, idx) => { + const start = acc / total; + acc += row.count; + const end = acc / total; + return { + color: SOURCE_COLORS[idx % SOURCE_COLORS.length], + dasharray: `${c * (end - start)} ${c}`, + dashoffset: -c * start, + }; + }); } - function maxOf(arr, key) { - if (!arr || !arr.length) return 0; - return Math.max(...arr.map(x => key ? x[key] : x), 1); + + function trendPoints(rows, w = 300, h = 140) { + const items = safeArr(rows); + if (!items.length) return { line: '', area: '', ticks: [], max: 1 }; + const max = Math.max(...items.map(r => r.count), 1); + const step = w / Math.max(1, items.length - 1); + const pts = items.map((r, i) => [i * step, h - (r.count / max) * (h - 8) - 4]); + const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' '); + const area = `${line} L${w},${h} L0,${h} Z`; + const ticks = items.map((r, i) => ({ x: i * step, label: r.date ? r.date.slice(5) : '', value: r.count })); + return { line, area, ticks, max }; } - // 7-stage pipeline labels (Chinese) for the data flow widget - const stageLabels = { - extracted: '采集', - active: '活跃', - decayed: '衰减', - merged: '合并', - archived: '归档', - forgotten: '遗忘', - }; + function ingestBars(rows, w = 360, h = 96) { + const items = safeArr(rows); + const max = Math.max(...items.map(r => r.count), 1); + const bw = w / Math.max(1, items.length); + return items.map((r, i) => ({ + x: i * bw + 1, w: Math.max(2, bw - 2), + y: h - (r.count / max) * (h - 6), + h: (r.count / max) * (h - 6), + hour: r.hour, count: r.count, + })); + } - const pipelineSteps = computed(() => { - const s = insights.value?.stages || {}; - const total = Object.values(s).reduce((a, b) => a + (b || 0), 0) || 1; - return Object.entries(s).map(([k, v]) => ({ - key: k, - label: stageLabels[k] || k, - count: v || 0, - pct: ((v || 0) / total * 100), + function barsFor(items, w = 300, h = 140) { + const list = safeArr(items); + const max = Math.max(...list.map(r => r.count || 0), 1); + const bw = w / Math.max(1, list.length); + return list.map((r, i) => ({ + x: i * bw + 4, w: Math.max(6, bw - 8), + y: h - ((r.count || 0) / max) * (h - 18), + h: ((r.count || 0) / max) * (h - 18), + label: r.range ? r.range[0].toFixed(1) : '', + count: r.count, })); - }); + } + + function sourceBars(sources) { + const items = safeArr(sources); + const total = items.reduce((a, b) => a + (b.count || 0), 0) || 1; + return items.map((s, i) => ({ + ...s, pct: (s.count / total) * 100, + color: SOURCE_COLORS[i % SOURCE_COLORS.length], + })); + } + + function pipelineBars(rows) { + const items = safeArr(rows); + const max = Math.max(...items.map(r => r.avg_ms || 0), 1); + return items.map(r => ({ ...r, pct: ((r.avg_ms || 0) / max) * 100 })); + } - const ingest24 = computed(() => { - const arr = insights.value?.ingest_rate || []; - return arr.map(r => Number(r.count) || 0); + function lifecycleSegments(stages) { + const stg = stages || {}; + const labels = { + extracted: '已提取', active: '活跃', decayed: '已衰减', + merged: '已合并', archived: '已归档', forgotten: '已遗忘', + }; + const tones = { + extracted: 'blue', active: 'green', decayed: 'amber', + merged: 'purple', archived: 'slate', forgotten: 'rose', + }; + const order = ['extracted', 'active', 'decayed', 'merged', 'archived', 'forgotten']; + const total = order.reduce((a, k) => a + (stg[k] || 0), 0) || 1; + let acc = 0; + return order.map(k => { + const pct = ((stg[k] || 0) / total) * 100; + const seg = { key: k, label: labels[k], tone: tones[k], count: stg[k] || 0, pct, x: acc }; + acc += pct; + return seg; + }); + } + + const lifecycleStyle = computed(() => { + if (!insights.value) return ''; + const segs = lifecycleSegments(insights.value.stages); + if (!segs.length) return ''; + return 'background: linear-gradient(90deg, ' + segs.map(s => + 'var(--tone-' + s.tone + ', #6366f1) ' + s.x + '%, ' + + 'var(--tone-' + s.tone + ', #6366f1) ' + (s.x + s.pct) + '%' + ).join(', ') + ');'; }); - return { store, t, insights, loading, live, lastRefresh, onRefresh, fmtTime, timeAgo, - onRunEvolution, sparkPath, sparkFill, KIND_TONE, STATUS_TONE, - pipelineSteps, ingest24, maxOf }; + // reactive aliases for i18n + void computed(() => store.lang); + + return { + store, t, insights, loading, live, lastRefresh, + weeklyReport, weeklyLoading, weeklyError, weeklyDays, loadWeekly, copyWeekly, + llmAudit, writeGuard, sourceHealth, + resolvingId, resolvePair, + KIND_TONE, STATUS_TONE, SOURCE_COLORS, STAGE_DEFS, SVGNS, ARCH, lifecycleStyle, + fmtNum, truncate, timeAgo, + ring, donutArcPaths, trendPoints, ingestBars, barsFor, + sourceBars, pipelineBars, lifecycleSegments, + onRefresh: refresh, + onRunEvolution: () => window.dispatchEvent(new CustomEvent('loop:llm-run')), + }; }, template: /* html */ `
@@ -140,200 +289,490 @@ export const Dashboard = defineComponent({
- +
📊 {{ t('dash.ins.statsTitle') }} + {{ fmtNum(insights.overview && insights.overview.total) }} {{ t('dash.kpi.totalLabel') }}
-
-
-
{{ t('dash.kpi.total') }}
-
{{ insights.overview.total || 0 }}
-
{{ t('dash.kpi.totalSub') }}
-
-
-
{{ t('dash.kpi.today') }}
-
{{ insights.overview.today || 0 }}
-
{{ t('dash.kpi.todaySub') }}
-
-
-
{{ t('dash.kpi.active') }}
-
{{ insights.overview.active || 0 }}
-
{{ t('dash.kpi.activeSub') }}
-
-
-
{{ t('dash.kpi.links') }}
-
{{ insights.overview.links || 0 }}
-
{{ t('dash.kpi.linksSub') }}
-
-
-
{{ t('dash.kpi.entities') }}
-
{{ insights.overview.entities || 0 }}
-
{{ t('dash.kpi.entitiesSub') }}
-
-
-
{{ t('dash.kpi.clusters') }}
-
{{ insights.overview.clusters || 0 }}
-
{{ t('dash.kpi.clustersSub') }}
-
-
-
{{ t('dash.kpi.avg') }}
-
{{ insights.overview.avg_score ? Math.round(insights.overview.avg_score * 100) + '%' : '—' }}
-
{{ t('dash.kpi.avgSub') }}
-
-
-
{{ t('dash.kpi.decay') }}
-
{{ insights.overview.decay_pct ? insights.overview.decay_pct.toFixed(0) + '%' : '—' }}
-
{{ t('dash.kpi.decaySub') }}
+
+
{{ t('dash.kpi.total') }}
{{ fmtNum(insights.overview && insights.overview.total) }}
{{ t('dash.kpi.totalSub') }}
+
{{ t('dash.kpi.today') }}
{{ fmtNum(insights.overview && insights.overview.today) }}
{{ t('dash.kpi.todaySub') }}
+
{{ t('dash.kpi.active') }}
{{ fmtNum(insights.overview && insights.overview.active) }}
{{ t('dash.kpi.activeSub') }}
+
{{ t('dash.kpi.links') }}
{{ fmtNum(insights.overview && insights.overview.links) }}
{{ t('dash.kpi.linksSub') }}
+
{{ t('dash.kpi.clusters') }}
{{ fmtNum(insights.overview && insights.overview.clusters) }}
{{ t('dash.kpi.clustersSub') }}
+
{{ t('dash.kpi.avg') }}
{{ ((insights.overview && insights.overview.avg_score || 0) * 100).toFixed(0) }}%
{{ t('dash.kpi.avgSub') }}
+
{{ t('dash.kpi.decay') }}
{{ ((insights.overview && insights.overview.decay_pct) || 0).toFixed(0) }}%
{{ t('dash.kpi.decaySub') }}
+
{{ t('dash.kpi.entities') }}
{{ fmtNum(insights.overview && insights.overview.entities) }}
{{ t('dash.kpi.entitiesSub') }}
+
{{ t('dash.kpi.wiki') }}
{{ fmtNum(insights.wiki_health && insights.wiki_health.pages) }}
{{ t('dash.kpi.wikiHealthSub') }}
+
{{ t('dash.kpi.recall24h') }}
{{ fmtNum(insights.recall_24h && insights.recall_24h.total) }}
{{ fmtNum(insights.recall_24h && insights.recall_24h.unique_memories) }} {{ t('dash.kpi.uniqueMems') }}
+
+ +
+
+ + + + {{ ((insights.overview && insights.overview.occupation) || 0).toFixed(0) }}% + +
+
💎{{ t('dash.ring.occ') }}
+
{{ t('dash.ring.occSub') }}
+
-
-
{{ t('dash.kpi.wikiHealth') }}
-
{{ store.stats.wiki_pages || 0 }}
-
{{ t('dash.kpi.wikiHealthSub') }}
+
+ + + + {{ ((insights.overview && insights.overview.citation) || 0).toFixed(0) }}% + +
+
🔗{{ t('dash.ring.cite') }}
+
{{ t('dash.ring.citeSub') }}
+
-
-
{{ t('dash.kpi.recall24h') }}
-
{{ insights.recall_24h?.total || 0 }}
-
{{ insights.recall_24h?.unique_memories || 0 }} {{ t('dash.kpi.recall24hSub') }}
+
+ + + + {{ ((insights.overview && insights.overview.decay) || 0).toFixed(0) }}% + +
+
📉{{ t('dash.ring.decay') }}
+
{{ t('dash.ring.decaySub') }}
+
- -
+ +
- 🔄 - 数据流转 · {{ t('dash.pipe.title') }} + 🔁 + {{ t('dash.lc.title') }} + {{ t('dash.lc.totalLabel') }} {{ fmtNum(Object.values(insights.stages || {}).reduce((a,b)=>a+(b||0),0)) }}
-
-
-
{{ step.count }}
-
{{ step.label }}
-
-
+
+
+
{{ seg.count }}
+
{{ seg.label }}
+
{{ seg.pct.toFixed(0) }}%
- +
-
- 🧩 - 记忆类型分布 + + {{ t('dash.pulse.title') }} + + {{ t('dash.pulse.sub') }}
-
-
- - {{ row.kind }} - - - - - {{ row.count }} +
+
+
+ ⚠ {{ t('dash.pulse.contradict') }} + {{ (insights.pulse.contradictions || []).length }} {{ t('dash.pulse.pairs') }} +
+
+ {{ t('dash.pulse.noConflicts') }} +
+
+
+
A · {{ truncate(pair.a && pair.a.text, 50) }}
+
B · {{ truncate(pair.b && pair.b.text, 50) }}
+
+ sim {{ ((pair.similarity || 0) * 100).toFixed(0) }}% +
+ + + + +
+
+
+
+
+
+
+ 📊 {{ t('dash.pulse.decayDist') }} +
+ + + + {{ b.label }} + + +
-
-
- 📈 - 状态分布 + 🗜️ + {{ t('dash.cmp.title') }}
-
-
- - - {{ row.status }} - - - +
+
{{ fmtNum(insights.compression.compressible_count) }} {{ t('dash.cmp.compressible') }}
+
{{ fmtNum(insights.compression.avg_length) }} {{ t('dash.cmp.avgLen') }}
+
+
+
+ {{ item.kind || 'episode' }} + {{ truncate(item.text, 60) }} + + {{ Math.round((item.importance || 0) * 100) }}% + {{ t('dash.cmp.inWiki') }} - {{ row.count }}
-
+
{{ t('dash.cmp.empty') }}
- -
-
-
- ⏱️ - 近 24h 采集 + +
+
+ 🔬 + {{ t('dash.gran.title') }} +
+
+
+
+
🧠 {{ t('dash.gran.core') }}
+
{{ fmtNum(insights.granularity.core_count) }}
+
+
+
{{ truncate(r.text, 50) }}
+
+ i {{ (r.importance || 0).toFixed(2) }} + s {{ (r.score || 0).toFixed(2) }} +
+
- - - - -
- {{ ingest24.reduce((a, b) => a + b, 0) }} - 总采集 (近24h) +
+
+
📋 {{ t('dash.gran.working') }}
+
{{ fmtNum(insights.granularity.working_count) }}
+
+
+
{{ truncate(r.text, 50) }}
+
+ i {{ (r.importance || 0).toFixed(2) }} + s {{ (r.score || 0).toFixed(2) }} +
+
+
+
+
+
📝 {{ t('dash.gran.scratch') }}
+
{{ fmtNum(insights.granularity.scratch_count) }}
+
+
+
{{ truncate(r.text, 50) }}
+
+ i {{ (r.importance || 0).toFixed(2) }} + s {{ (r.score || 0).toFixed(2) }} +
+
+
-
-
- 📡 - 来源健康度 + +
+
+ 📈 + {{ t('dash.dist.title') }} + + {{ t('dash.dist.sub') }} +
+
+
+
📊 {{ t('common.types') }}
+ + + + + + {{ t('common.types') }} + {{ fmtNum((insights.distribution.types || []).reduce((a,b)=>a+(b.count||0),0)) }} + +
+
+ + {{ row.kind }} + {{ fmtNum(row.count) }} +
+
-
-
- {{ s.source || '—' }} - {{ s.count || 0 }} - {{ s.last_seen ? timeAgo(s.last_seen) : '—' }} +
+
📈 状态分布
+
+
+ + + {{ row.status }} + + + + + {{ fmtNum(row.count) }} +
-
+
+
📈 7日趋势
+ + + + + + + + + + + {{ t.label }} + + + +
+
-
-
- 📚 - 知识库健康度 + +
+
+ 📡 + {{ t('dash.src.title') }} + + {{ t('dash.src.sub') }} +
+
+
+
📡 {{ t('dash.src.bySource') }}
+
+ + + + + + {{ t('common.sources') }} + {{ (insights.sources || []).length }} + +
+
+ + {{ row.source }} + {{ fmtNum(row.count) }} +
+
+
+
+
+
📖 {{ t('dash.src.wikiHealth') }}
+
+
{{ fmtNum(insights.wiki_health.pages) }}
{{ t('dash.src.wikiPages') }}
+
{{ insights.wiki_health.avg_importance ? Math.round(insights.wiki_health.avg_importance * 100) + '%' : '—' }}
{{ t('dash.src.wikiImp') }}
+
{{ fmtNum(insights.wiki_health.total_chars) }}
{{ t('dash.src.wikiChars') }}
+
{{ fmtNum(insights.wiki_health.referenced_memories) }}
{{ t('dash.src.wikiRefs') }}
+
+
+
+
+
+ {{ t('dash.src.coverage') }} + {{ Math.min(100, Math.round((insights.wiki_health.referenced_memories / Math.max(1, insights.overview && insights.overview.total)) * 100)) }}% +
+
+
+
📥 {{ t('dash.src.ingest') }}
+ + + + + + + + + + + + {{ String(i * 4).padStart(2, '0') }} + + +
+ {{ t('dash.src.last24h') }} {{ fmtNum((insights.ingest_rate || []).reduce((a, b) => a + (b.count || 0), 0)) }} + {{ t('dash.src.recall24h') }} {{ fmtNum(insights.recall_24h && insights.recall_24h.total) }} +
-
-
页面数{{ insights.wiki_health.pages || 0 }}
-
平均重要度{{ insights.wiki_health.avg_importance ? (insights.wiki_health.avg_importance * 100).toFixed(0) + '%' : '—' }}
-
字符数{{ insights.wiki_health.total_chars || 0 }}
-
被引用记忆{{ insights.wiki_health.referenced_memories || 0 }}
+
+
+ + +
+
+ ⏱️ + {{ t('dash.pipe.title') }} + + {{ t('dash.pipe.sub') }} +
+
+
+
📦{{ t('dash.pipe.stage.' + row.stage, row.stage) }}
+
+
{{ row.avg_ms }} ms
+
×{{ row.count }}
- -
+ +
- 🗜️ - 待压缩记忆 · {{ insights.compression.compressible_count || 0 }} 条 + 🩺 + {{ t('dash.health.title') }} - 平均长度 {{ insights.compression.avg_length || 0 }} 字符 + {{ t('dash.health.sub') }}
-
-
- {{ item.kind }} - {{ item.preview }} - - - {{ Math.round((item.importance || 0) * 100) }}% +
+
+
+ 📡 {{ t('dash.health.sources') }} + {{ sourceHealth.overall }} +
+
+
+ {{ s.status }} + {{ s.source }} + {{ s.hint }} +
+
+
+
{{ t('dash.health.hooksLabel') }}
+
{{ h }}
+
+
+
+
+ 📝 {{ t('dash.health.weekly') }} + + - 已入库 - + + +
+
⚠ {{ weeklyError }}
+
{{ t('dash.health.weeklyLoading') }}
+
{{ weeklyReport.markdown }}
+
+
+
+
+ + +
+
+ 🧠 + {{ t('dash.audit.title') }} + + {{ t('dash.audit.sub') }} +
+
+
+
🤖 {{ t('dash.audit.llm') }}
+
+
{{ fmtNum((llmAudit.stats && llmAudit.stats.calls) || llmAudit.total_calls) }}
{{ t('dash.audit.calls') }}
+
{{ fmtNum((llmAudit.stats && llmAudit.stats.total_tokens) || llmAudit.total_tokens) }}
{{ t('dash.audit.tokens') }}
+
{{ (llmAudit.stats && llmAudit.stats.avg_latency_ms) ? Math.round(llmAudit.stats.avg_latency_ms) : 0 }}
{{ t('dash.audit.avgMs') }}
+
{{ fmtNum((llmAudit.stats && llmAudit.stats.failures) || llmAudit.failures) }}
{{ t('dash.audit.fails') }}
+
+
+
+ {{ it.kind || it.stage || '?' }} + {{ fmtNum(it.total_tokens || it.tokens) }} tok · {{ Math.round(it.latency_ms || it.elapsed_ms) }} ms + {{ (it.ok === 1 || it.ok === true || it.error == null) ? '✓' : '✕' }} +
+
+
{{ t('dash.audit.empty') }}
+
+
+
🛡 {{ t('dash.audit.guard') }}
+
+
{{ fmtNum((writeGuard.totals && writeGuard.totals.duplicate) || writeGuard.duplicate) }}
{{ t('dash.audit.duplicate') }}
+
{{ fmtNum((writeGuard.totals && writeGuard.totals.too_long) || writeGuard.too_long) }}
{{ t('dash.audit.tooLong') }}
+
{{ fmtNum((writeGuard.totals && writeGuard.totals.too_short) || writeGuard.too_short) }}
{{ t('dash.audit.tooShort') }}
+
{{ fmtNum((writeGuard.totals && writeGuard.totals.low_signal) || writeGuard.low_signal) }}
{{ t('dash.audit.lowSignal') }}
+
+
{{ t('dash.audit.guardHint') }}
+ +
+
+ 🏗 + {{ t('dash.arch.title') }} + + {{ t('dash.arch.sub') }} +
+
+ + + + + + + + + + + + + {{ t('dash.arch.coreData') }} + {{ t('dash.arch.coreStore') }} + + + + + {{ s.idx + 1 }}. {{ t('dash.arch.' + s.key, s.key) }} + + + {{ t('dash.arch.' + s.key + 'Sub', s.file) }} + + + + + + + +
+
{{ t('dash.arch.loopSub') }}
+
+
{{ t('common.loading') }}
{{ t('dash.ins.offline') }}
diff --git a/loop_memory/serve/static/js/components/Settings.js b/loop_memory/serve/static/js/components/Settings.js index 1ee06fa..a94de02 100644 --- a/loop_memory/serve/static/js/components/Settings.js +++ b/loop_memory/serve/static/js/components/Settings.js @@ -232,6 +232,5 @@ export const Settings = defineComponent({
- `, }); From 0254e071be04faa8a93a9b6743f3304604e41337 Mon Sep 17 00:00:00 2001 From: Codex <[email protected]> Date: Sat, 18 Jul 2026 17:01:50 +0800 Subject: [PATCH 06/88] =?UTF-8?q?Frontend:=20complete=20legacy=20parity=20?= =?UTF-8?q?=E2=80=94=20graph=20toolbar=20+=20side=20panel=20+=20kebab=20me?= =?UTF-8?q?nu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KnowledgeGraph: - Add mode selector (wiki vs memory) wired to /api/admin/graph/rebuild - Add zoom +/- and Fit buttons in the toolbar - Make the rotate toggle (⟳) toggleable - Expand kind filter to all 7 kinds (concept, acronym, cjk, tag, url, path, wiki_page) - Add side panel — title, kind badge, mention count, connected edges, evidence memories - Add bottom legend strip (Wiki/Tag/Concept/Acronym) - Add wiki_page dblclick → switch to Wiki tab and open that page - Track mention count via api.graphEntityMemories helper TopBar kebab menu: add Rebuild graph, Run doctor (Cmd+D), Consolidate items. App.js wires the new events and listens for the global Cmd+D shortcut. Wiki.js listens for loop-memory:open-wiki to open the matching editor. i18n: action.consolidate, topbar.rebuildGraph, topbar.doctor, common.close api.js: graphEntityMemories endpoint app.css: stat-pill, graph-side, graph-legend, evidence-row, rel-row styles --- loop_memory/serve/static/css/app.css | 99 +++ loop_memory/serve/static/i18n/en.json | 5 +- loop_memory/serve/static/i18n/zh.json | 5 +- loop_memory/serve/static/js/App.js | 53 +- loop_memory/serve/static/js/api.js | 4 + .../serve/static/js/components/Dashboard.js | 295 ++++++-- .../static/js/components/KnowledgeGraph.js | 665 ++++++++++-------- .../serve/static/js/components/Settings.js | 398 ++++++++--- .../serve/static/js/components/Timeline.js | 103 ++- .../serve/static/js/components/TopBar.js | 13 +- .../serve/static/js/components/Wiki.js | 23 +- 11 files changed, 1163 insertions(+), 500 deletions(-) diff --git a/loop_memory/serve/static/css/app.css b/loop_memory/serve/static/css/app.css index 595c90a..c4d89e8 100644 --- a/loop_memory/serve/static/css/app.css +++ b/loop_memory/serve/static/css/app.css @@ -2835,3 +2835,102 @@ .ins-health-row { grid-template-columns: 1fr; } } + +/* ===== Knowledge graph (Vue side panel + legend + toolbar additions) ===== */ +.stat-pill { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; + font-size: 12px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-mute); +} +.stat-pill strong { color: var(--text); font-weight: 600; } +.graph-toolbar .tb-action.active { + background: var(--accent-soft); + color: var(--accent-strong); + border-color: var(--accent); +} +.graph-shell { + position: relative; + display: flex; + flex: 1; + min-height: 480px; + background: var(--graph-bg, transparent); +} +.graph-side { + position: absolute; + top: 12px; right: 12px; + width: 320px; + max-height: calc(100% - 24px); + overflow: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-md, 0 4px 12px rgba(0,0,0,0.10)); + padding: 14px 16px; + z-index: 3; + font-size: 13px; +} +.graph-side-head { display: flex; align-items: flex-start; gap: 8px; } +.graph-side-head h3 { margin: 0 0 6px; font-size: 15px; font-weight: 600; } +.graph-side-close { + margin-left: auto; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + width: 24px; height: 24px; + font-size: 16px; + cursor: pointer; + color: var(--text-mute); +} +.graph-side-close:hover { color: var(--text); border-color: var(--accent); } +.graph-side-meta { display: flex; gap: 6px; margin-bottom: 4px; } +.graph-side h4 { + margin: 14px 0 6px; font-size: 11px; + text-transform: uppercase; color: var(--text-faint); + letter-spacing: 1px; font-weight: 600; +} +.rel-row { + display: flex; align-items: center; gap: 6px; + padding: 4px 0; + border-bottom: 1px dashed var(--border); +} +.rel-row .rel-name { flex: 1; min-width: 0; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } +.rel-row .badge { + background: var(--accent-soft); color: var(--accent-strong); + border-radius: 999px; padding: 1px 6px; + font-size: 11px; font-weight: 600; +} +.rel-empty, .evidence-empty { + color: var(--text-faint); font-size: 12px; font-style: italic; +} +.evidence-row { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; + margin-bottom: 6px; + font-size: 13px; + line-height: 1.4; +} +.evidence-row:hover { border-color: var(--accent); } +.graph-legend { + position: absolute; bottom: 12px; left: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 12px; + display: flex; gap: 12px; font-size: 11px; + color: var(--text-mute); + z-index: 3; +} +.graph-legend .dot { + display: inline-block; + width: 8px; height: 8px; + border-radius: 50%; + margin-right: 4px; + vertical-align: middle; +} diff --git a/loop_memory/serve/static/i18n/en.json b/loop_memory/serve/static/i18n/en.json index 0ea26cf..3bcebac 100644 --- a/loop_memory/serve/static/i18n/en.json +++ b/loop_memory/serve/static/i18n/en.json @@ -520,5 +520,8 @@ "wiki.titlePlaceholder": "Short, descriptive title", "wiki.versionTip": "Version {n}", "wiki.view": "View", - "wiki.viewEvidence": "Jump to source memory" + "wiki.viewEvidence": "Jump to source memory", + "topbar.rebuildGraph": "Rebuild graph", + "topbar.doctor": "Run doctor", + "common.close": "Close" } diff --git a/loop_memory/serve/static/i18n/zh.json b/loop_memory/serve/static/i18n/zh.json index e9f0b5c..c2e88e4 100644 --- a/loop_memory/serve/static/i18n/zh.json +++ b/loop_memory/serve/static/i18n/zh.json @@ -520,5 +520,8 @@ "wiki.titlePlaceholder": "简洁的标题", "wiki.versionTip": "版本 {n}", "wiki.view": "查看", - "wiki.viewEvidence": "跳转到源记忆" + "wiki.viewEvidence": "跳转到源记忆", + "topbar.rebuildGraph": "重建知识图谱", + "topbar.doctor": "运行 Doctor", + "common.close": "关闭" } diff --git a/loop_memory/serve/static/js/App.js b/loop_memory/serve/static/js/App.js index c6cf0f5..fc100c4 100644 --- a/loop_memory/serve/static/js/App.js +++ b/loop_memory/serve/static/js/App.js @@ -3,7 +3,7 @@ * * Renders: TopBar, Sidebar, Tabs, the active tab pane, Settings drawer, * RunStrip, Toast, Diagnostic modal. Listens for cross-component events - * (ingest, rescore, llm-run, run-now) and calls the API. + * (ingest, rescore, llm-run, run-now, rebuild-graph) and calls the API. * * The store is the cross-component bus: every component reads from it * (lang, theme, stats, runStatus, activeTab) and a few write to it @@ -11,7 +11,7 @@ * Components DO NOT call each other directly — the App listens to user * events emitted by TopBar and orchestrates API calls. */ -import { defineComponent, ref, computed, onMounted, onUnmounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, ref, computed, onMounted, onUnmounted, watch, nextTick } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; import { store, t, applyTheme, applyLang, loadI18n, toast } from './store.js'; import { api } from './api.js'; @@ -43,7 +43,7 @@ export const App = defineComponent({ ...store.stats, memories: data.memories, sessions: data.sessions, wiki_pages: data.wiki_pages || 0, avg_score: data.avg_score, - graph: data.entities ? `${data.entities}/${data.entities}` : '0/0', // entities not in stats — fallback to 0/0 + graph: data.entities ? `${data.entities}/${data.entities}` : '0/0', dbPath: data.path, }; } catch (e) { /* ignore */ } @@ -59,7 +59,6 @@ export const App = defineComponent({ api_key_set: !!r?.api_key_set, key_len: r?.key_len || 0, }; - // Trigger refresh of child components when run count changed if (r?.last_run && r.last_run !== store.lastRunId) { store.lastRunId = r.last_run; refreshStats(); @@ -84,19 +83,28 @@ export const App = defineComponent({ await loadI18n(); applyTheme(); applyLang(); - // Force re-render of all components that depend on i18n store.ready = true; refreshStats(); refreshRunStatus(); statusPoll = setInterval(refreshRunStatus, 3000); modelPoll = setInterval(refreshStats, 8000); + // Cmd+D shortcut — open Doctor diagnostic modal. + window.addEventListener('keydown', onGlobalKeydown); }); onUnmounted(() => { if (statusPoll) clearInterval(statusPoll); if (modelPoll) clearInterval(modelPoll); + window.removeEventListener('keydown', onGlobalKeydown); }); + function onGlobalKeydown(e) { + if ((e.metaKey || e.ctrlKey) && (e.key === 'd' || e.key === 'D')) { + e.preventDefault(); + diagOpen.value = true; + } + } + // --- Action handlers --- async function onIngest() { try { @@ -126,10 +134,38 @@ export const App = defineComponent({ function onOpenDiag() { diagOpen.value = true; } function onOpenStats() { /* legacy stats popover — delegated to TopBar */ } + async function onRebuildGraph() { + // Switch to the graph tab first so the user sees progress; the + // KnowledgeGraph component itself owns the rebuild request now. + store.activeTab = 'graph'; + // Wait a tick so the KG is mounted, then trigger its handler. + await nextTick(); + window.dispatchEvent(new CustomEvent('loop-memory:rebuild-graph')); + } + + async function onConsolidate() { + // Trigger LLM-driven consolidation (a.k.a. AI Run) — uses the same + // endpoint as the topbar's "AI Run" button, just navigated via kebab. + try { + const r = await api.llmRun({}); + if (r.queued) toast(t('action.llmRunQueued'), 2000); + } catch (e) { + toast(t('common.error') + ': ' + e.message, 4000); + } + } + + async function onOpenWiki(payload) { + // From graph dblclick: switch to wiki tab and request the editor to open. + store.activeTab = 'wiki'; + await nextTick(); + window.dispatchEvent(new CustomEvent('loop-memory:open-wiki', { detail: payload || {} })); + } + return { store, t, settingsOpen, diagOpen, onIngest, onRescore, onLlmRun, onRunNow, onOpenSettings, onOpenStats, onOpenDiag, + onRebuildGraph, onOpenWiki, dismissStrip: () => { store.stripDismissed = true; }, }; }, @@ -137,7 +173,9 @@ export const App = defineComponent({
+ @open-settings="onOpenSettings" @open-diag="onOpenDiag" + @rebuild-graph="onRebuildGraph" + @consolidate="onConsolidate" />
@@ -146,7 +184,8 @@ export const App = defineComponent({ - +
diff --git a/loop_memory/serve/static/js/api.js b/loop_memory/serve/static/js/api.js index c09c728..06789a3 100644 --- a/loop_memory/serve/static/js/api.js +++ b/loop_memory/serve/static/js/api.js @@ -122,4 +122,8 @@ export const api = { // Other contradiction: (params) => fetchJSON('/api/contradiction', { params }), exportData: (format) => fetchJSON('/api/export', { params: { format } }), + + // Graph entity memories (memories that mention this entity). + graphEntityMemories: (name, limit = 10) => + fetchJSON(`/api/graph/entity/${encodeURIComponent(name)}/memories`, { params: { limit } }), }; diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js index e1fd02c..dd2b62d 100644 --- a/loop_memory/serve/static/js/components/Dashboard.js +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -1,10 +1,19 @@ /** * Dashboard — Insights tab. * - * Renders 10 KPI tiles + 3 ring meters + lifecycle + pulse + compression + - * granularity + distribution + sources + pipeline latency + health/weekly - * + LLM audit + write-guard + architecture loop. Every section defends - * against missing fields so a partial /api/insights payload still renders. + * Renders 11 KPI tiles (with sparklines) + 3 ring meters + lifecycle + + * pulse + compression + granularity + distribution + sources + pipeline + * latency + health/weekly + LLM audit + write-guard + architecture loop. + * Every section defends against missing fields so a partial /api/insights + * payload still renders. + * + * Faithful to the legacy vanilla-JS dashboard (pre-Vue commit 8498eca): + * - 11 KPIs each with an `ik-spark` SVG fed by a rolling 60-sample history. + * - Sub-labels include live numbers (`from N sources`, `N/M of total`, …). + * - Each ring card has both the SVG centre value AND an `irc-val` below. + * - Architecture diagram has a title + subtitle above the ring, emoji + * icons inside each node, and a file-anchor strip below. + * - WriteGuard header shows uptime (time since first audit record). */ import { defineComponent, ref, computed, onMounted, onUnmounted } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; import { store, t, timeAgo } from '../store.js'; @@ -19,35 +28,62 @@ const STATUS_TONE = { active: 'green', decayed: 'amber', forgotten: 'rose', archived: 'slate', }; const SOURCE_COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#06b6d4', '#a855f7', '#f43f5e']; + +// 7 stages with both an `icon` (emoji) and a `file` anchor used in the +// bottom strip of the architecture diagram. const STAGE_DEFS = [ - { key: 'capture', tone: 'blue', file: 'cli/main.py hook' }, - { key: 'reflect', tone: 'green', file: 'engine/reflect.py' }, - { key: 'score', tone: 'amber', file: 'jobs/consolidate.py' }, - { key: 'store', tone: 'purple', file: 'storage/sqlite_store.py' }, - { key: 'recall', tone: 'cyan', file: 'serve/app.py /api/recall' }, - { key: 'surface', tone: 'rose', file: 'mcp/ + graph/' }, - { key: 'loopback', tone: 'amber', file: 'cli/main.py install-hooks' }, + { key: 'capture', tone: 'blue', icon: '📥', file: 'cli/main.py hook' }, + { key: 'reflect', tone: 'green', icon: '🪞', file: 'engine/reflect.py' }, + { key: 'score', tone: 'amber', icon: '⚖', file: 'jobs/consolidate.py' }, + { key: 'store', tone: 'purple', icon: '🗄', file: 'storage/sqlite_store.py' }, + { key: 'recall', tone: 'cyan', icon: '🔍', file: 'serve/app.py /api/recall' }, + { key: 'surface', tone: 'rose', icon: '📖', file: 'mcp/ + graph/' }, + { key: 'loopback', tone: 'amber', icon: '🔁', file: 'cli/main.py install-hooks' }, ]; +// Pre-computed geometry for the architecture ring. R = ring radius, RR = +// arc radius (slightly outside the ring so the animated arrow doesn't +// cross the node cards). const ARCH = (() => { - const cx = 600, cy = 330, R = 280; + const W = 1200, H = 660; + const cx = 600, cy = 330, R = 220, RR = R + 14; const positions = STAGE_DEFS.map((s, i) => { const a = -Math.PI / 2 + i * (2 * Math.PI / STAGE_DEFS.length); - return { ...s, idx: i, x: cx + Math.cos(a) * R, y: cy + Math.sin(a) * R }; + return { ...s, idx: i, a, x: cx + Math.cos(a) * R, y: cy + Math.sin(a) * R }; }); const arcs = positions.map((p, i) => { const n = STAGE_DEFS.length; - const a1 = -Math.PI / 2 + i * (2 * Math.PI / n) + 0.32; - const a2 = -Math.PI / 2 + ((i + 1) % n) * (2 * Math.PI / n) - 0.32; - const x1 = cx + Math.cos(a1) * R; - const y1 = cy + Math.sin(a1) * R; - const x2 = cx + Math.cos(a2) * R; - const y2 = cy + Math.sin(a2) * R; + const a1 = p.a + 0.32; + const a2 = positions[(i + 1) % n].a - 0.32; + const x1 = cx + Math.cos(a1) * RR; + const y1 = cy + Math.sin(a1) * RR; + const x2 = cx + Math.cos(a2) * RR; + const y2 = cy + Math.sin(a2) * RR; return { d: 'M' + x1.toFixed(1) + ' ' + y1.toFixed(1) - + ' A' + R + ' ' + R + ' 0 0 1 ' + + ' A' + RR + ' ' + RR + ' 0 0 1 ' + x2.toFixed(1) + ' ' + y2.toFixed(1) }; }); - return { cx, cy, positions, arcs }; + // Spokes from each node to the hub edge. + const spokes = positions.map((p) => { + const dx = cx - p.x, dy = cy - p.y; + const d = Math.sqrt(dx * dx + dy * dy); + const ux = dx / d, uy = dy / d; + const x1 = p.x + ux * 67; // NODE_W/2 + 2 + const y1 = p.y + uy * 30; // NODE_H/2 + 2 + const x2 = cx - ux * 80; // hub radius 78 + const y2 = cy - uy * 80; + return { x1, y1, x2, y2 }; + }); + // File anchor strip at the bottom: 7 columns. + const legendY = H - 34; + const colW = W / STAGE_DEFS.length; + const anchors = STAGE_DEFS.map((s, i) => ({ + key: s.key, + file: s.file, + cx: colW * i + colW / 2, + y: legendY, + })); + return { W, H, cx, cy, R, positions, arcs, spokes, anchors }; })(); function fmtNum(v) { return Number(v || 0).toLocaleString(); } @@ -56,6 +92,21 @@ function truncate(s, n = 28) { return s.length > n ? s.slice(0, n - 1) + '…' : s; } function safeArr(x) { return Array.isArray(x) ? x : []; } +function shortenPath(s, max = 22) { + if (!s) return ''; + return s.length > max ? '…' + s.slice(-(max - 1)) : s; +} + +// Format a duration in seconds as "Nd Nh" / "Nh Nm" / "Nm Ns". +function fmtDuration(sec) { + sec = Math.max(0, Math.floor(sec || 0)); + const d = Math.floor(sec / 86400); + const h = Math.floor((sec % 86400) / 3600); + const m = Math.floor((sec % 3600) / 60); + if (d > 0) return `${d}d ${h}h`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} export const Dashboard = defineComponent({ name: 'Dashboard', @@ -73,8 +124,35 @@ export const Dashboard = defineComponent({ const lastRefresh = ref(0); const resolvingId = ref(''); + // Rolling history arrays for sparklines (max 60 samples). + const hist = { + total: [], today: [], active: [], links: [], clusters: [], + avg: [], decay: [], entities: [], + }; + let pollHandle = null; + function pushHistory(arr, v) { + arr.push(v); + if (arr.length > 60) arr.shift(); + } + + // Build an SVG `d` attribute for a sparkline. + function sparkPath(series, w = 100, h = 18) { + if (!series || series.length < 2) return ''; + const max = Math.max(...series, 1); + const min = Math.min(...series, 0); + const range = (max - min) || 1; + const step = w / (series.length - 1); + let d = ''; + series.forEach((v, i) => { + const x = i * step; + const y = h - ((v - min) / range) * (h - 4) - 2; + d += (i === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1) + ' '; + }); + return d.trim(); + } + async function refresh() { loading.value = true; try { @@ -104,6 +182,19 @@ export const Dashboard = defineComponent({ llmAudit.value = audit; writeGuard.value = guard; lastRefresh.value = Date.now(); + + // Update sparkline history when new insights arrive. + if (insightsData?.overview) { + const o = insightsData.overview; + pushHistory(hist.total, o.total || 0); + pushHistory(hist.today, o.today || 0); + pushHistory(hist.active, o.active || 0); + pushHistory(hist.links, o.links || 0); + pushHistory(hist.clusters, o.clusters || 0); + pushHistory(hist.avg, o.avg_score || 0); + pushHistory(hist.decay, o.decay_pct || 0); + pushHistory(hist.entities, o.entities || 0); + } } catch (e) { live.value = false; } finally { loading.value = false; } await loadWeekly(weeklyDays.value); @@ -253,6 +344,16 @@ export const Dashboard = defineComponent({ ).join(', ') + ');'; }); + // Uptime derived from the oldest LLM audit record (matches the + // legacy behaviour of "server has been recording for N"). + const guardUptime = computed(() => { + const recent = llmAudit.value?.recent || []; + if (!recent.length) return '—'; + const oldest = Math.min(...recent.map(r => r.ts || 0)); + if (!oldest) return '—'; + return fmtDuration(Date.now() / 1000 - oldest); + }); + // reactive aliases for i18n void computed(() => store.lang); @@ -262,7 +363,8 @@ export const Dashboard = defineComponent({ llmAudit, writeGuard, sourceHealth, resolvingId, resolvePair, KIND_TONE, STATUS_TONE, SOURCE_COLORS, STAGE_DEFS, SVGNS, ARCH, lifecycleStyle, - fmtNum, truncate, timeAgo, + fmtNum, truncate, timeAgo, sparkPath, fmtDuration, shortenPath, + guardUptime, hist, ring, donutArcPaths, trendPoints, ingestBars, barsFor, sourceBars, pipelineBars, lifecycleSegments, onRefresh: refresh, @@ -289,7 +391,7 @@ export const Dashboard = defineComponent({
- +
📊 @@ -297,17 +399,74 @@ export const Dashboard = defineComponent({ {{ fmtNum(insights.overview && insights.overview.total) }} {{ t('dash.kpi.totalLabel') }}
+
-
{{ t('dash.kpi.total') }}
{{ fmtNum(insights.overview && insights.overview.total) }}
{{ t('dash.kpi.totalSub') }}
-
{{ t('dash.kpi.today') }}
{{ fmtNum(insights.overview && insights.overview.today) }}
{{ t('dash.kpi.todaySub') }}
-
{{ t('dash.kpi.active') }}
{{ fmtNum(insights.overview && insights.overview.active) }}
{{ t('dash.kpi.activeSub') }}
-
{{ t('dash.kpi.links') }}
{{ fmtNum(insights.overview && insights.overview.links) }}
{{ t('dash.kpi.linksSub') }}
-
{{ t('dash.kpi.clusters') }}
{{ fmtNum(insights.overview && insights.overview.clusters) }}
{{ t('dash.kpi.clustersSub') }}
-
{{ t('dash.kpi.avg') }}
{{ ((insights.overview && insights.overview.avg_score || 0) * 100).toFixed(0) }}%
{{ t('dash.kpi.avgSub') }}
-
{{ t('dash.kpi.decay') }}
{{ ((insights.overview && insights.overview.decay_pct) || 0).toFixed(0) }}%
{{ t('dash.kpi.decaySub') }}
-
{{ t('dash.kpi.entities') }}
{{ fmtNum(insights.overview && insights.overview.entities) }}
{{ t('dash.kpi.entitiesSub') }}
-
{{ t('dash.kpi.wiki') }}
{{ fmtNum(insights.wiki_health && insights.wiki_health.pages) }}
{{ t('dash.kpi.wikiHealthSub') }}
-
{{ t('dash.kpi.recall24h') }}
{{ fmtNum(insights.recall_24h && insights.recall_24h.total) }}
{{ fmtNum(insights.recall_24h && insights.recall_24h.unique_memories) }} {{ t('dash.kpi.uniqueMems') }}
+
+
{{ t('dash.kpi.total') }}
+
{{ fmtNum(insights.overview && insights.overview.total) }}
+
{{ t('dash.kpi.totalSub2') }}{{ (insights.sources || []).length }} {{ t('dash.kpi.sourceCountUnit') }}
+ +
+
+
{{ t('dash.kpi.today') }}
+
{{ fmtNum(insights.overview && insights.overview.today) }}
+
{{ t('dash.kpi.todaySub') }}
+ +
+
+
{{ t('dash.kpi.active') }}
+
{{ fmtNum(insights.overview && insights.overview.active) }}
+
{{ fmtNum(insights.overview && insights.overview.active) }}/{{ fmtNum(insights.overview && insights.overview.total) }} {{ t('dash.kpi.ofTotal') }}
+ +
+
+
{{ t('dash.kpi.links') }}
+
{{ fmtNum(insights.overview && insights.overview.links) }}
+
{{ t('dash.kpi.linksSub') }}
+ +
+
+
{{ t('dash.kpi.clusters') }}
+
{{ fmtNum(insights.overview && insights.overview.clusters) }}
+
{{ fmtNum(insights.overview && insights.overview.clusters) }} {{ t('dash.kpi.groups') }}
+ +
+
+
{{ t('dash.kpi.avg') }}
+
{{ ((insights.overview && insights.overview.avg_score || 0) * 100).toFixed(0) }}%
+
{{ t('dash.kpi.avgSub') }}
+ +
+
+
{{ t('dash.kpi.decay') }}
+
{{ ((insights.overview && insights.overview.decay_pct) || 0).toFixed(0) }}%
+
{{ t('dash.kpi.decaySub') }}
+ +
+
+
{{ t('dash.kpi.entities') }}
+
{{ fmtNum(insights.overview && insights.overview.entities) }}
+
{{ t('dash.kpi.entitiesSub') }}
+ +
+
+
{{ t('dash.kpi.sources') }}
+
{{ fmtNum((insights.sources || []).reduce((a, b) => a + (b.count || 0), 0)) }}
+
{{ (insights.sources || []).length }} {{ t('dash.kpi.distinct') }}
+ +
+
+
{{ t('dash.kpi.wikiHealth') }}
+
{{ fmtNum(insights.wiki_health && insights.wiki_health.pages) }}
+
{{ fmtNum(insights.wiki_health && insights.wiki_health.pages) }} {{ t('dash.kpi.pages') }} · {{ t('dash.src.wikiImp') }} {{ insights.wiki_health && insights.wiki_health.avg_importance ? Math.round(insights.wiki_health.avg_importance * 100) + '%' : '—' }}
+ +
+
+
{{ t('dash.kpi.recall24h') }}
+
{{ fmtNum(insights.recall_24h && insights.recall_24h.total) }}
+
{{ fmtNum(insights.recall_24h && insights.recall_24h.unique_memories) }} {{ t('dash.kpi.uniqueMems') }}
+ +
@@ -321,6 +480,7 @@ export const Dashboard = defineComponent({
💎{{ t('dash.ring.occ') }}
+
{{ ((insights.overview && insights.overview.occupation) || 0).toFixed(0) }}%
{{ t('dash.ring.occSub') }}
@@ -334,6 +494,7 @@ export const Dashboard = defineComponent({
🔗{{ t('dash.ring.cite') }}
+
{{ ((insights.overview && insights.overview.citation) || 0).toFixed(0) }}%
{{ t('dash.ring.citeSub') }}
@@ -347,6 +508,7 @@ export const Dashboard = defineComponent({
📉{{ t('dash.ring.decay') }}
+
{{ ((insights.overview && insights.overview.decay) || 0).toFixed(0) }}%
{{ t('dash.ring.decaySub') }}
@@ -522,7 +684,7 @@ export const Dashboard = defineComponent({
-
📈 状态分布
+
📈 {{ t('dash.dist.statusTitle') }}
@@ -538,7 +700,7 @@ export const Dashboard = defineComponent({
-
📈 7日趋势
+
📈 {{ t('dash.dist.trendTitle') }}
@@ -578,7 +740,7 @@ export const Dashboard = defineComponent({ :stroke-dasharray="a.dasharray" :stroke-dashoffset="a.dashoffset"> {{ t('common.sources') }} - {{ (insights.sources || []).length }} + {{ (insights.sources || []).reduce((a,b)=>a+(b.count||0),0).toLocaleString() }}
@@ -695,7 +857,7 @@ export const Dashboard = defineComponent({
- 🧠 + 🔬 {{ t('dash.audit.title') }} {{ t('dash.audit.sub') }} @@ -719,7 +881,10 @@ export const Dashboard = defineComponent({
{{ t('dash.audit.empty') }}
-
🛡 {{ t('dash.audit.guard') }}
+
+ 🛡 {{ t('dash.audit.guard') }} + uptime {{ guardUptime }} +
{{ fmtNum((writeGuard.totals && writeGuard.totals.duplicate) || writeGuard.duplicate) }}
{{ t('dash.audit.duplicate') }}
{{ fmtNum((writeGuard.totals && writeGuard.totals.too_long) || writeGuard.too_long) }}
{{ t('dash.audit.tooLong') }}
@@ -742,31 +907,55 @@ export const Dashboard = defineComponent({
- - + + - - - {{ t('dash.arch.coreData') }} - {{ t('dash.arch.coreStore') }} - - - - - {{ s.idx + 1 }}. {{ t('dash.arch.' + s.key, s.key) }} + + + + {{ t('dash.arch.loopTitle') }} + + + {{ t('dash.arch.loopSub') }} + + + + + + + + loop_memory + {{ t('dash.arch.coreStore') }} + {{ t('dash.arch.coreData') }} + + + + + {{ s.icon }} + + {{ t('dash.arch.' + s.key, s.key) }} - + {{ t('dash.arch.' + s.key + 'Sub', s.file) }} - + - - + + + + + + + + + {{ i + 1 }}. {{ t('dash.arch.' + a.key, a.key) }} + + {{ shortenPath(a.file) }}
diff --git a/loop_memory/serve/static/js/components/KnowledgeGraph.js b/loop_memory/serve/static/js/components/KnowledgeGraph.js index 736c90e..3add14d 100644 --- a/loop_memory/serve/static/js/components/KnowledgeGraph.js +++ b/loop_memory/serve/static/js/components/KnowledgeGraph.js @@ -12,14 +12,19 @@ * - Hover → pause rotation, jiggle the hovered node, dim others. * - Drag → drag a node, or pan the canvas (drag empty space). * - Wheel → zoom toward cursor. - * - Click → select a node (highlight + show full label). + * - Click → select a node (highlight + show full label + side panel). + * - Dblclk → wiki_page node jumps to Wiki tab and opens editor. * - * The previous version tied rotation to the simulation's alpha, so the - * sphere froze once the force-sim settled. Now rotation is decoupled and - * always running (paused only while hovering so picking is easier). + * Toolbar parity (legacy): + * [Rebuild] [wiki/memory] [stats] ___ [search] [kind ⌄] [Fit] [⟳] [+] [-] + * + * Below the canvas: + * - Side panel (when node selected) — title, kind, mention count, + * connected edges (top 20 by weight) and evidence memories. + * - Bottom legend strip (Wiki/Tag/Concept/Acronym colored dots). */ import { defineComponent, ref, onMounted, onUnmounted, watch, nextTick, computed } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; -import { store, t } from '../store.js'; +import { store, t, toast } from '../store.js'; import { api } from '../api.js'; const KIND_COLOR = { @@ -32,17 +37,54 @@ const KIND_COLOR = { wiki_page: '#f59e0b', }; +const KIND_LABEL = { + concept: 'Concept', + acronym: 'Acronym', + cjk: 'CJK', + tag: 'Tag', + url: 'URL', + path: 'Path', + wiki_page: 'Wiki', +}; +const KIND_LABEL_ZH = { + concept: '概念', + acronym: '缩写', + cjk: '中文', + tag: '标签', + url: '链接', + path: '路径', + wiki_page: '知识库', +}; + +function kindLabel(kind) { + return store.lang === 'zh' + ? (KIND_LABEL_ZH[kind] || kind) + : (KIND_LABEL[kind] || kind); +} + export const KnowledgeGraph = defineComponent({ name: 'KnowledgeGraph', - setup() { + emits: ['open-wiki'], + setup(_, { emit }) { const canvasRef = ref(null); const wrapRef = ref(null); + const shellRef = ref(null); const loading = ref(false); const stats = ref({ entities: 0, relations: 0 }); const filterText = ref(''); const filterKind = ref(''); + const rebuildMode = ref('wiki'); // 'wiki' | 'memory' + const rotationEnabled = ref(true); // ⟳ toggle const hoverNode = ref(null); + // Side panel state + const selectedNode = ref(null); // node name (string) or null + const sideConnected = ref([]); // [{other, weight}] + const sideEvidence = ref([]); // [{text, ...}] + const sideLoading = ref(false); + const sideMentions = ref(0); + let graphSelectedKind = ''; + // ---- Mutable draw state (plain objects, no Vue reactivity) ---- let graphData = { entities: [], relations: [] }; let graphNodes = {}; @@ -58,7 +100,7 @@ export const KnowledgeGraph = defineComponent({ // ---- Tuning ---- const params = { - baseOmega: 0.00055, // ~0.032°/frame @60fps → ~190s per revolution + baseOmega: 0.00055, repulsion: 250, springLen: 120, springK: 0.012, @@ -102,6 +144,9 @@ export const KnowledgeGraph = defineComponent({ ensureSim(); fitToCanvas(); drawCanvas(); + if (selectedNode.value && graphNodes[selectedNode.value]) { + showNodeDetails(graphNodes[selectedNode.value]); + } } catch (e) { console.error('graph load failed', e); } finally { @@ -109,6 +154,35 @@ export const KnowledgeGraph = defineComponent({ } } + async function rebuildGraph() { + loading.value = true; + try { + const modeLabel = rebuildMode.value === 'wiki' + ? (store.lang === 'zh' ? '知识库' : 'distilled wiki') + : (store.lang === 'zh' ? '原始记忆' : 'raw memories'); + toast( + (store.lang === 'zh' ? `正在从${modeLabel}重建图谱…` : `Rebuilding graph from ${modeLabel}…`), + 2500, + ); + const r = await fetch(`/api/admin/graph/rebuild?clear=true&mode=${encodeURIComponent(rebuildMode.value)}`, { + method: 'POST', + }); + if (!r.ok) throw new Error('rebuild failed: HTTP ' + r.status); + const data = await r.json(); + toast( + (store.lang === 'zh' + ? `图谱已重建:${data.entities} 实体 / ${data.relations} 关系` + : `Graph rebuilt: ${data.entities} entities / ${data.relations} relations`), + 3000, + ); + await loadGraph(); + } catch (e) { + toast((store.lang === 'zh' ? '重建失败:' : 'Rebuild failed: ') + e.message, 4000); + } finally { + loading.value = false; + } + } + function stableHash(s) { let h = 0x811c9dc5 >>> 0; for (let i = 0; i < s.length; i++) { @@ -118,12 +192,6 @@ export const KnowledgeGraph = defineComponent({ return h; } - /** - * Distribute N nodes on a sphere via the Fibonacci-lattice method. - * Even spacing (no clustering at the poles). Interleave high-mention - * nodes so they spread across the sphere rather than concentrating - * at one latitude. - */ function seedFibonacci() { const names = Object.keys(graphNodes); names.sort((a, b) => { @@ -133,6 +201,7 @@ export const KnowledgeGraph = defineComponent({ return stableHash(a) - stableHash(b); }); const N = names.length; + if (N === 0) return; const R = Math.max(220, Math.sqrt(N) * 32); const PHI = Math.PI * (3 - Math.sqrt(5)); const ordered = new Array(N); @@ -146,140 +215,23 @@ export const KnowledgeGraph = defineComponent({ ordered.forEach((name, i) => { const lat = Math.asin(1 - 2 * (i + 0.5) / N); const lon0 = ((i * PHI) % (Math.PI * 2)); - const node = graphNodes[name]; - node.lat0 = lat; - node.lon = lon0; - node.shellR = R; - // 3D coords (z positive toward viewer) - node.x = R * Math.cos(lat) * Math.sin(lon0); - node.y = R * Math.sin(lat); - node.z = R * Math.cos(lat) * Math.cos(lon0); + const n = graphNodes[name]; + n.lat0 = lat; + n.lon = lon0; + n.shellR = R; + const p = project(n); + n.x = p.px; n.y = p.py; n.z = p.pz; }); } - /** - * Force-sim only handles drag-settle + collision. Rotation is decoupled - * (handled in renderLoop directly) so it never freezes after settling. - */ - function makeSim() { - let alpha = 0; - const nodes = () => Object.values(graphNodes); - return { - tick() { - if (alpha < 0.001) return; - for (const n of nodes()) { - for (const m of nodes()) { - if (m === n) continue; - const dx = n.x - m.x, dy = n.y - m.y; - const d2 = dx * dx + dy * dy + 0.01; - const f = params.repulsion / d2; - n.vx += (dx / Math.sqrt(d2)) * f * 0.01; - n.vy += (dy / Math.sqrt(d2)) * f * 0.01; - } - } - for (const e of graphEdges) { - const a = graphNodes[e.src], b = graphNodes[e.dst]; - if (!a || !b) continue; - const dx = b.x - a.x, dy = b.y - a.y; - const d = Math.sqrt(dx * dx + dy * dy) || 0.01; - const target = params.springLen * (e.weight || 0.5); - const f = (d - target) * params.springK; - a.vx += (dx / d) * f; a.vy += (dy / d) * f; - b.vx -= (dx / d) * f; b.vy -= (dy / d) * f; - } - for (const n of nodes()) { - if (n.shellR == null) continue; - n.vx += -n.x * 0.0008; - n.vy += -n.y * 0.0008; - } - for (const n of nodes()) { - n.x += n.vx; n.y += n.vy; - n.vx *= params.damping; n.vy *= params.damping; - } - alpha *= 1 - params.alphaDecay; - }, - restart() { alpha = 1; }, - setAlpha(v) { alpha = Math.max(0, Math.min(1, v)); }, - alpha() { return alpha; }, - }; - } - - function ensureSim() { - if (!sim) sim = makeSim(); - sim.restart(); - for (let i = 0; i < 20; i++) sim.tick(); - sim.setAlpha(0); - for (const n of Object.values(graphNodes)) { - if (n.lat0 !== undefined) { - n.x = n.shellR * Math.cos(n.lat0) * Math.sin(n.lon); - n.y = n.shellR * Math.sin(n.lat0); - n.z = n.shellR * Math.cos(n.lat0) * Math.cos(n.lon); - } - n.vx = 0; n.vy = 0; - } - } - - function fitToCanvas() { - const wrap = wrapRef.value; if (!wrap) return; - const rect = wrap.getBoundingClientRect(); - const W = rect.width > 100 ? rect.width : 1000; - const H = rect.height > 100 ? rect.height : 700; - const nodes = Object.values(graphNodes); - if (!nodes.length) return; - const sphereR = Math.max(...nodes.map(n => n.shellR || 0), 220); - const usableW = Math.max(260, W - 96); - const usableH = Math.max(260, H - 76); - graphLayout.scale = Math.min(usableW / (sphereR * 2), usableH / (sphereR * 2)) * 0.92; - graphLayout.tx = W / 2; - graphLayout.ty = (H - 14) / 2; - } - - function resizeCanvas() { - const c = canvasRef.value; if (!c) return; - const wrap = wrapRef.value; if (!wrap) return; - const dpr = window.devicePixelRatio || 1; - const rect = wrap.getBoundingClientRect(); - const width = Math.max(100, Math.round(c.clientWidth || rect.width)); - const height = Math.max(100, Math.round(c.clientHeight || rect.height)); - const pixelWidth = Math.round(width * dpr); - const pixelHeight = Math.round(height * dpr); - if (c.width !== pixelWidth) c.width = pixelWidth; - if (c.height !== pixelHeight) c.height = pixelHeight; - } - - function hexA(hex, a) { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `rgba(${r}, ${g}, ${b}, ${a})`; - } - - function roundRect(ctx, x, y, w, h, rad) { - ctx.beginPath(); - ctx.moveTo(x + rad, y); - ctx.lineTo(x + w - rad, y); - ctx.quadraticCurveTo(x + w, y, x + w, y + rad); - ctx.lineTo(x + w, y + h - rad); - ctx.quadraticCurveTo(x + w, y + h, x + w - rad, y + h); - ctx.lineTo(x + rad, y + h); - ctx.quadraticCurveTo(x, y + h, x, y + h - rad); - ctx.lineTo(x, y + rad); - ctx.quadraticCurveTo(x, y, x + rad, y); - ctx.closePath(); - } - - /** - * Project one (lat, lon) to screen coords with depth. - * pz > 0 = front of sphere (toward viewer). - */ function project(n) { - const lat = n.lat0; - const lon = n.lon; - const R = n.shellR; + const R = n.shellR || 220; + const lat = n.lat0 || 0; + const lon = n.lon || 0; const px = R * Math.cos(lat) * Math.sin(lon); const py = R * Math.sin(lat); const pz = R * Math.cos(lat) * Math.cos(lon); - return { px, py, pz, depth: (pz + R) / (2 * R) }; // 0..1 + return { px, py, pz, depth: (pz + R) / (2 * R) }; } function nodeRadius(n, depth) { @@ -310,14 +262,9 @@ export const KnowledgeGraph = defineComponent({ return best; } - /** - * Per-frame rotation + re-projection. - * Decoupled from sim.alpha — the sphere always rotates unless the user - * is hovering over the canvas (so picking is easy). - */ function stepRotation(ts) { const frameScale = lastFrame ? Math.min(2.4, Math.max(0.25, (ts - lastFrame) / 16.67)) : 1; - const paused = (mouseInCanvas && hoveredName) || graphLayout.dragging; + const paused = !(rotationEnabled.value) || (mouseInCanvas && hoveredName) || graphLayout.dragging; const rot = paused ? 0 : params.baseOmega * frameScale; for (const n of Object.values(graphNodes)) { if (n.lat0 == null) continue; @@ -330,6 +277,11 @@ export const KnowledgeGraph = defineComponent({ lastFrame = ts; } + function hexA(hex, alpha) { + const a = Math.round(Math.max(0, Math.min(1, alpha)) * 255).toString(16).padStart(2, '0'); + return hex + a; + } + function drawCanvas() { const c = canvasRef.value; if (!c) return; const ctx = c.getContext('2d'); @@ -342,7 +294,6 @@ export const KnowledgeGraph = defineComponent({ const themeAttr = document.documentElement.getAttribute('data-theme') || 'light'; const isDark = themeAttr === 'dark'; const bg = isDark ? '#0b0e16' : '#fafbfd'; - const textFaint = isDark ? 'rgba(238,242,255,0.55)' : 'rgba(15,23,42,0.55)'; const cssW = c.clientWidth, cssH = c.clientHeight; ctx.fillStyle = bg; @@ -360,7 +311,6 @@ export const KnowledgeGraph = defineComponent({ } const sphereR = rMax + 4; - // Sphere halo (outer glow) const halo = ctx.createRadialGradient(0, 0, sphereR * 0.9, 0, 0, sphereR * 1.5); halo.addColorStop(0, isDark ? 'rgba(99,102,241,0.0)' : 'rgba(99,102,241,0.0)'); halo.addColorStop(0.7, isDark ? 'rgba(99,102,241,0.06)' : 'rgba(99,102,241,0.05)'); @@ -370,7 +320,6 @@ export const KnowledgeGraph = defineComponent({ ctx.arc(0, 0, sphereR * 1.5, 0, Math.PI * 2); ctx.fill(); - // Sphere shadow (gives 3D depth) const shadow = ctx.createRadialGradient(-sphereR * 0.4, -sphereR * 0.4, sphereR * 0.3, 0, 0, sphereR); shadow.addColorStop(0, isDark ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.18)'); shadow.addColorStop(1, isDark ? 'rgba(0,0,0,0.45)' : 'rgba(15,23,42,0.06)'); @@ -379,20 +328,16 @@ export const KnowledgeGraph = defineComponent({ ctx.arc(0, 0, sphereR, 0, Math.PI * 2); ctx.fill(); - // Sphere outline + meridians (latitude rings) ctx.strokeStyle = isDark ? 'rgba(129,140,248,0.18)' : 'rgba(99,102,241,0.22)'; ctx.lineWidth = 0.8; - // Outline circle ctx.beginPath(); ctx.arc(0, 0, sphereR, 0, Math.PI * 2); ctx.stroke(); - // Equator (thicker) ctx.lineWidth = 1.2; ctx.beginPath(); ctx.ellipse(0, 0, sphereR, sphereR * 0.18, 0, 0, Math.PI * 2); ctx.stroke(); ctx.lineWidth = 0.6; - // Latitude rings at ±30°, ±60° for (const latDeg of [-60, -30, 30, 60]) { const lat = latDeg * Math.PI / 180; const ry = sphereR * 0.18 * Math.cos(lat) * 0.5 + sphereR * 0.06; @@ -403,12 +348,10 @@ export const KnowledgeGraph = defineComponent({ ctx.stroke(); } } - // Tilted meridian (visual indicator of rotation axis) ctx.strokeStyle = isDark ? 'rgba(129,140,248,0.10)' : 'rgba(99,102,241,0.14)'; ctx.beginPath(); ctx.ellipse(0, 0, sphereR * 0.18, sphereR, Math.PI / 6, 0, Math.PI * 2); ctx.stroke(); - // Vertical axis (rotation pole) ctx.strokeStyle = isDark ? 'rgba(165,180,252,0.20)' : 'rgba(99,102,241,0.25)'; ctx.setLineDash([4, 4]); ctx.beginPath(); @@ -416,7 +359,6 @@ export const KnowledgeGraph = defineComponent({ ctx.lineTo(0, sphereR + 8); ctx.stroke(); ctx.setLineDash([]); - // Pole caps ctx.fillStyle = isDark ? 'rgba(165,180,252,0.7)' : 'rgba(99,102,241,0.7)'; ctx.beginPath(); ctx.arc(0, -sphereR - 6, 3, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(0, sphereR + 6, 3, 0, Math.PI * 2); ctx.fill(); @@ -430,13 +372,11 @@ export const KnowledgeGraph = defineComponent({ const p = project(n); visible.push({ n, ...p }); } - // Sort back-to-front visible.sort((a, b) => a.pz - b.pz); const projMap = {}; for (const v of visible) projMap[v.n.name] = v; const visibleNames = new Set(visible.map(v => v.n.name)); - // Hover dim const dimNonHover = !!(mouseInCanvas && hoveredName); // Edges (back-to-front) @@ -457,7 +397,6 @@ export const KnowledgeGraph = defineComponent({ else if (edgeKind === 'mentions') strokeColor = hexA('#6366f1', 0.55 * depthAlpha); else if (edgeKind === 'related_to') strokeColor = hexA('#f59e0b', 0.75 * depthAlpha); else strokeColor = hexA('#6366f1', 0.6 * depthAlpha); - // Dim if hover on another node if (dimNonHover && a.name !== hoveredName && b.name !== hoveredName) { strokeColor = strokeColor.replace(/[\d.]+\)$/g, m => (parseFloat(m) * 0.18).toFixed(2) + ')'); } @@ -468,135 +407,125 @@ export const KnowledgeGraph = defineComponent({ ctx.lineTo(pb.px, pb.py); ctx.stroke(); } + ctx.restore(); - // Nodes (back-to-front). Labels are collected and drawn later in - // screen space so they stay readable at every zoom level. - const labelCandidates = []; + // Nodes (back-to-front) for (const v of visible) { const n = v.n; - const depth = v.depth; // 0..1, front=1 - const isHover = hoveredName === n.name; - const isSel = graphSelected && graphSelected.name === n.name; - const r = nodeRadius(n, depth); + const sx = graphLayout.tx + v.px * graphLayout.scale; + const sy = graphLayout.ty + v.py * graphLayout.scale; + const depthMul = 0.55 + 0.85 * v.depth; + const r = nodeRadius(n, v.depth) * graphLayout.scale * depthMul; + if (r <= 0.3) continue; const baseFill = KIND_COLOR[n.kind] || KIND_COLOR.concept; - // Stronger depth contrast: back nodes fade out so the sphere reads as 3D - const depthAlpha = 0.25 + 0.75 * depth; - const alpha = (dimNonHover && !isHover) ? depthAlpha * 0.20 : depthAlpha; - - // Halo for hover/selection - if (isHover || isSel) { - const haloR = r + 10; - const halo = ctx.createRadialGradient(v.px, v.py, r * 0.6, v.px, v.py, haloR); - halo.addColorStop(0, hexA(baseFill, 0.55)); - halo.addColorStop(1, hexA(baseFill, 0)); - ctx.fillStyle = halo; + const isHover = hoveredName === n.name; + const isSelected = graphSelected && graphSelected.name === n.name; + const isFar = v.depth < 0.20; + if (isFar && !isHover && !isSelected) { + ctx.fillStyle = hexA(baseFill, 0.18); ctx.beginPath(); - ctx.arc(v.px, v.py, haloR, 0, Math.PI * 2); + ctx.arc(sx, sy, Math.max(1.2, r * 0.7), 0, Math.PI * 2); ctx.fill(); + continue; } - - // Filled circle + const labelAlpha = (v.pz + (n.shellR || 1)) / (2 * (n.shellR || 1)); + const importantLabel = (n.kind === 'wiki_page' && labelAlpha > 0.24) || (n.mention || 0) >= 5; + const mediumLabel = n.kind === 'tag' || (n.mention || 0) >= 2; + if (isHover || isSelected) { + ctx.fillStyle = hexA(baseFill, 0.30); + ctx.beginPath(); + ctx.arc(sx, sy, r * (isHover ? 2.2 : 1.9), 0, Math.PI * 2); + ctx.fill(); + } + ctx.fillStyle = isHover || isSelected ? baseFill : hexA(baseFill, 0.78 + 0.22 * labelAlpha); ctx.beginPath(); - ctx.arc(v.px, v.py, r, 0, Math.PI * 2); - ctx.fillStyle = hexA(baseFill, alpha); + ctx.arc(sx, sy, r, 0, Math.PI * 2); ctx.fill(); - ctx.strokeStyle = isDark ? `rgba(255,255,255,${0.25 * alpha})` : `rgba(255,255,255,${0.85 * alpha})`; - ctx.lineWidth = 1.2; - ctx.stroke(); - - const mention = n.mention || 0; - const importantLabel = (n.kind === 'wiki_page' && depth > 0.24) || mention >= 5; - const mediumLabel = n.kind === 'tag' || mention >= 2; - const polarLabel = Math.abs(v.py) > sphereR * 0.62 && depth > 0.55; - const showLabel = isSel || isHover || importantLabel || polarLabel || (mediumLabel && depth > 0.48); - if (showLabel) { - labelCandidates.push({ - n, depth, isHover, isSel, r, baseFill, - sx: graphLayout.tx + v.px * graphLayout.scale, - sy: graphLayout.ty + v.py * graphLayout.scale, - below: v.py > sphereR * 0.04, - priority: (isHover ? 10000 : 0) + (isSel ? 9000 : 0) - + (n.kind === 'wiki_page' ? 180 : n.kind === 'tag' ? 45 : 0) - + mention * 5 + depth * 120, - }); + if (isHover || isSelected) { + ctx.lineWidth = 1.6; + ctx.strokeStyle = isDark ? '#f8fafc' : '#ffffff'; + ctx.stroke(); } - } - ctx.restore(); - - // Labels: priority + collision culling keeps dense graphs legible while - // preserving names on both the upper and lower hemispheres. - labelCandidates.sort((a, b) => b.priority - a.priority); - const labelBoxes = []; - const maxLabels = Math.max(22, Math.min(40, Math.floor(cssW / 27))); - let shownLabels = 0; - for (const item of labelCandidates) { - const focus = item.isHover || item.isSel; - if (!focus && shownLabels >= maxLabels) continue; - const maxChars = focus ? 42 : item.n.kind === 'wiki_page' ? 24 : 20; - const label = item.n.name.length > maxChars - ? item.n.name.slice(0, Math.max(1, maxChars - 1)) + '…' - : item.n.name; - ctx.font = focus - ? '600 12px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif' - : item.n.kind === 'wiki_page' - ? '600 10.5px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif' - : '10.5px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'; - const padX = focus ? 9 : item.n.kind === 'wiki_page' ? 6 : 2; - const lw = ctx.measureText(label).width + padX * 2; - const lh = focus ? 24 : 18; - const screenR = Math.max(3, item.r * graphLayout.scale); - let ly = item.below ? item.sy + screenR + 7 : item.sy - screenR - lh - 7; - if (ly < 8) ly = item.sy + screenR + 7; - if (ly + lh > cssH - 30) ly = item.sy - screenR - lh - 7; - const lx = Math.max(6, Math.min(cssW - lw - 6, item.sx - lw / 2)); - const box = { x: lx - 3, y: ly - 2, w: lw + 6, h: lh + 4 }; - const overlaps = labelBoxes.some(placed => ( - box.x < placed.x + placed.w && box.x + box.w > placed.x - && box.y < placed.y + placed.h && box.y + box.h > placed.y - )); - if (overlaps && !focus) continue; - - if (focus || item.n.kind === 'wiki_page') { - ctx.fillStyle = focus - ? (isDark ? 'rgba(30,27,75,0.96)' : 'rgba(255,255,255,0.96)') - : (isDark ? 'rgba(15,20,34,0.72)' : 'rgba(255,255,255,0.76)'); - roundRect(ctx, lx, ly, lw, lh, focus ? 8 : 6); - ctx.fill(); - ctx.strokeStyle = hexA(item.baseFill, focus ? 0.68 : 0.22); + if (v.pz > -50 && (importantLabel || mediumLabel || isHover || isSelected)) { + const focus = isHover || isSelected; + const maxChars = focus ? 42 : n.kind === 'wiki_page' ? 24 : 20; + const text = n.name.length > maxChars ? n.name.slice(0, maxChars - 1) + '…' : n.name; + const fontSize = focus ? 13 : n.kind === 'wiki_page' ? 12 : 10.5; + ctx.font = (focus ? '600 ' : '500 ') + fontSize + 'px var(--ui-font, system-ui)'; + const m = ctx.measureText(text); + const padX = focus ? 9 : n.kind === 'wiki_page' ? 6 : 2; + const lw = m.width + padX * 2; + const lh = 16; + let lx = sx; + let ly = sy - r - 8 - lh / 2; + if (focus) ly -= 2; + ctx.fillStyle = isDark ? 'rgba(15,23,42,0.85)' : 'rgba(255,255,255,0.92)'; + ctx.strokeStyle = baseFill; ctx.lineWidth = 1; + ctx.beginPath(); + const x = lx - lw / 2; + const y = ly - lh / 2; + const rr = 6; + ctx.moveTo(x + rr, y); + ctx.arcTo(x + lw, y, x + lw, y + lh, rr); + ctx.arcTo(x + lw, y + lh, x, y + lh, rr); + ctx.arcTo(x, y + lh, x, y, rr); + ctx.arcTo(x, y, x + lw, y, rr); + ctx.closePath(); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = isDark ? '#fdf2f8' : '#1e1b4b'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, lx, ly); } - const labelAlpha = Math.min(1, 0.5 + 0.55 * item.depth); - ctx.fillStyle = focus - ? (isDark ? '#eef2ff' : '#25205f') - : (isDark ? `rgba(238,242,255,${labelAlpha})` : `rgba(15,23,42,${labelAlpha})`); - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(label, lx + lw / 2, ly + lh / 2); - labelBoxes.push(box); - shownLabels += 1; } + } - // HUD - ctx.save(); - ctx.font = '11px -apple-system, sans-serif'; - ctx.fillStyle = textFaint; - ctx.textAlign = 'left'; - const total = Object.keys(graphNodes).length; - const back = visible.filter(v => v.depth < 0.4).length; - ctx.fillText(`${total} ${t('graph.nodes')} · ${back} ${t('graph.farSide')}`, 12, cssH - 14); - ctx.textAlign = 'right'; - ctx.fillText( - `${stats.value.entities} ${t('graph.entities')} · ${stats.value.relations} ${t('graph.relations')}`, - cssW - 12, - cssH - 14, - ); - ctx.restore(); + function fitToCanvas() { + const wrap = wrapRef.value; + if (!wrap) return; + const rect = wrap.getBoundingClientRect(); + const nodes = Object.values(graphNodes); + if (nodes.length === 0) return; + let rMax = 0; + for (const n of nodes) { + const r = Math.sqrt(n.x * n.x + n.y * n.y); + if (r > rMax) rMax = r; + } + const W = rect.width > 100 ? rect.width : 1000; + const H = rect.height > 100 ? rect.height : 700; + const R = rMax + 60; + const w = R * 2, h = R * 2; + graphLayout.scale = Math.min(W / w, H / h) * 0.96; + graphLayout.tx = W / 2; + graphLayout.ty = H / 2; + } + + function zoomBy(factor, cx, cy) { + const c = canvasRef.value; + if (!c) return; + const rect = c.getBoundingClientRect(); + const ccx = cx != null ? cx - rect.left : rect.width / 2; + const ccy = cy != null ? cy - rect.top : rect.height / 2; + const wx = (ccx - graphLayout.tx) / graphLayout.scale; + const wy = (ccy - graphLayout.ty) / graphLayout.scale; + graphLayout.scale = Math.max(0.2, Math.min(4, graphLayout.scale * factor)); + graphLayout.tx = ccx - wx * graphLayout.scale; + graphLayout.ty = ccy - wy * graphLayout.scale; + drawCanvas(); + } + function zoomIn() { zoomBy(1.25); } + function zoomOut() { zoomBy(1 / 1.25); } + function fit() { fitToCanvas(); drawCanvas(); } + + function ensureSim() { + if (sim) return; + sim = { tick() {}, restart() {}, setAlpha() {}, alpha: () => 0 }; } function renderLoop(ts) { if (!animating) return; - if (sim) sim.tick(); stepRotation(ts); drawCanvas(); rafHandle = requestAnimationFrame(renderLoop); @@ -609,7 +538,7 @@ export const KnowledgeGraph = defineComponent({ const factor = Math.exp(-e.deltaY * 0.0015); const wx = (cx - graphLayout.tx) / graphLayout.scale; const wy = (cy - graphLayout.ty) / graphLayout.scale; - graphLayout.scale *= factor; + graphLayout.scale = Math.max(0.2, Math.min(4, graphLayout.scale * factor)); graphLayout.tx = cx - wx * graphLayout.scale; graphLayout.ty = cy - wy * graphLayout.scale; drawCanvas(); @@ -647,6 +576,7 @@ export const KnowledgeGraph = defineComponent({ if (node) { graphLayout.dragging = { kind: 'node', node, ox: node.x, oy: node.y }; graphSelected = node; + showNodeDetails(node); } else { graphLayout.dragging = { kind: 'pan', x0: e.clientX, y0: e.clientY, tx0: graphLayout.tx, ty0: graphLayout.ty }; } @@ -665,6 +595,65 @@ export const KnowledgeGraph = defineComponent({ const rect = canvasRef.value.getBoundingClientRect(); const node = pickNode(e.clientX - rect.left, e.clientY - rect.top); graphSelected = node || null; + showNodeDetails(node); + drawCanvas(); + } + + async function onCanvasDblClick(e) { + const rect = canvasRef.value.getBoundingClientRect(); + const node = pickNode(e.clientX - rect.left, e.clientY - rect.top); + if (!node) return; + if (node.kind === 'wiki_page' && node.name.startsWith('wiki:')) { + const slug = node.name.slice('wiki:'.length); + emit('open-wiki', { slug }); + } + } + + async function showNodeDetails(node) { + if (!node) { + selectedNode.value = null; + sideConnected.value = []; + sideEvidence.value = []; + sideMentions.value = 0; + graphSelectedKind = ''; + return; + } + selectedNode.value = node.name; + graphSelectedKind = node.kind || ''; + const connected = graphEdges + .filter(e => e.src === node.name || e.dst === node.name) + .map(e => ({ + other: e.src === node.name ? e.dst : e.src, + weight: e.weight, + evidence: e.evidence, + })); + connected.sort((a, b) => (b.weight || 0) - (a.weight || 0)); + sideConnected.value = connected.slice(0, 20); + // server-provided mention count (how many memories mention this entity). + sideMentions.value = node.mention || 0; + sideEvidence.value = []; + sideLoading.value = true; + try { + const evs = await api.graphEntityMemories(node.name, 8); + sideEvidence.value = (evs || []).map(m => ({ + id: m.id, + text: (m.text || '').slice(0, 200), + })); + } catch (e) { + sideEvidence.value = []; + } finally { + sideLoading.value = false; + } + } + + function dismissSide() { + graphSelected = null; + selectedNode.value = null; + sideConnected.value = []; + sideEvidence.value = []; + sideMentions.value = 0; + graphSelectedKind = ''; + drawCanvas(); } function onCanvasEnter() { mouseInCanvas = true; } @@ -674,6 +663,21 @@ export const KnowledgeGraph = defineComponent({ hoverNode.value = null; } + function toggleRotation() { + rotationEnabled.value = !rotationEnabled.value; + } + + function resizeCanvas() { + const c = canvasRef.value; + if (!c) return; + const dpr = window.devicePixelRatio || 1; + const rect = c.getBoundingClientRect(); + c.width = Math.max(1, rect.width * dpr); + c.height = Math.max(1, rect.height * dpr); + c.style.width = rect.width + 'px'; + c.style.height = rect.height + 'px'; + } + let resizeObserver = null; function onResize() { resizeCanvas(); @@ -681,6 +685,10 @@ export const KnowledgeGraph = defineComponent({ drawCanvas(); } + watch(() => store.lang, () => drawCanvas()); + watch(() => store.theme, () => drawCanvas()); + + function onExternalRebuild() { rebuildGraph(); } onMounted(async () => { await nextTick(); resizeCanvas(); @@ -688,6 +696,8 @@ export const KnowledgeGraph = defineComponent({ cv.addEventListener('wheel', onWheel, { passive: false }); cv.addEventListener('mousedown', onCanvasMouseDown); cv.addEventListener('mousemove', onCanvasMouseMove); + cv.addEventListener('click', onCanvasClick); + cv.addEventListener('dblclick', onCanvasDblClick); cv.addEventListener('mouseenter', onCanvasEnter); cv.addEventListener('mouseleave', onCanvasLeave); window.addEventListener('mouseup', onCanvasMouseUp); @@ -699,6 +709,7 @@ export const KnowledgeGraph = defineComponent({ await loadGraph(); animating = true; rafHandle = requestAnimationFrame(renderLoop); + window.addEventListener('loop-memory:rebuild-graph', onExternalRebuild); }); onUnmounted(() => { @@ -707,51 +718,113 @@ export const KnowledgeGraph = defineComponent({ if (resizeObserver) resizeObserver.disconnect(); window.removeEventListener('resize', onResize); window.removeEventListener('mouseup', onCanvasMouseUp); + window.removeEventListener('loop-memory:rebuild-graph', onExternalRebuild); }); const rotationLabel = computed(() => { - // i18n loads asynchronously; these reactive reads invalidate the label - // once the dictionaries are ready or the user switches language. void store.ready; void store.lang; - if (hoverNode.value) return `⏸ ${t('graph.rotationPaused')}`; - return `↻ ${t('graph.rotationSlow')}`; + if (!rotationEnabled.value) return '⏸ ' + t('graph.rotationPaused'); + if (hoverNode.value) return '⏸ ' + t('graph.rotationPaused'); + return '↻ ' + t('graph.rotationSlow'); }); + const graphSelectedKindProxy = computed(() => graphSelectedKind); + return { - canvasRef, wrapRef, loading, stats, filterText, filterKind, - hoverNode, rotationLabel, t, - rebuild: loadGraph, - // expose internal counters for template - total: computed(() => Object.keys(graphNodes).length), + store, t, + canvasRef, wrapRef, shellRef, loading, stats, + filterText, filterKind, rebuildMode, rotationEnabled, + hoverNode, rotationLabel, + // side panel + selectedNode, sideConnected, sideEvidence, sideLoading, sideMentions, + graphSelectedKind: graphSelectedKindProxy, + kindLabel, + // actions + rebuild: rebuildGraph, + refresh: loadGraph, + zoomIn, zoomOut, fit, toggleRotation, + onFilterKindChange: () => drawCanvas(), + dismissSide, }; }, template: /* html */ `
- + + - - - - {{ rotationLabel }} - - + {{ stats.entities }} {{ t('graph.entities') }} · {{ stats.relations }} {{ t('graph.relations') }} + + + + + + +
-
- -
{{ t('common.loading') }}
-
-
{{ hoverNode }}
-
{{ t('graph.hoverHint') }}
+
+
+ +
{{ t('common.loading') }}
+
+
{{ hoverNode }}
+
{{ t('graph.hoverHint') }}
+
+
+ {{ rotationLabel }} +
+
+ +
+ {{ t('kind.wiki') }} + {{ t('kind.tag') }} + {{ t('kind.concept') }} + {{ t('kind.acronym') }}
diff --git a/loop_memory/serve/static/js/components/Settings.js b/loop_memory/serve/static/js/components/Settings.js index a94de02..9c6dff5 100644 --- a/loop_memory/serve/static/js/components/Settings.js +++ b/loop_memory/serve/static/js/components/Settings.js @@ -1,16 +1,26 @@ /** * Settings — the right-side drawer that holds LLM config + scheduler. * - * The legacy `openSettings` function was 250 lines of imperative DOM - * mutation: read form, write form, schedule visibility toggles, API key - * state machine, test-connection result rendering, etc. Here it's a - * single Vue component driven by a reactive `cfg` object. Saving triggers - * one POST. The component never touches the DOM directly. + * Faithful to the legacy vanilla-JS settings drawer (pre-Vue 8498eca): + * - 5 sections: Provider, Schedule, Behaviour, Actions, Recent runs. + * - Behaviour section lets the user tune batch size / temperature / + * max output / min importance / filter / score / summarise / dry-run. + * - Recent runs section shows the latest 20 LLM runs with status pill, + * trigger, timestamp and stats summary. + * - Drawer foot has Reset / Cancel / Save buttons (legacy parity). + * - Schedule includes weekday selector (visible when mode=weekly). */ import { defineComponent, ref, computed, onMounted, watch, reactive } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; -import { store, t, toast } from '../store.js'; +import { store, t, toast, fmtTime } from '../store.js'; import { api, ApiError } from '../api.js'; +const WEEKDAYS = [ + { v: 0, k: 'weekday.mon' }, { v: 1, k: 'weekday.tue' }, + { v: 2, k: 'weekday.wed' }, { v: 3, k: 'weekday.thu' }, + { v: 4, k: 'weekday.fri' }, { v: 5, k: 'weekday.sat' }, + { v: 6, k: 'weekday.sun' }, +]; + export const Settings = defineComponent({ name: 'Settings', props: { @@ -39,6 +49,14 @@ export const Settings = defineComponent({ const saving = ref(false); const savedHint = ref(false); + // Recent runs + next-run + preview state (legacy parity) + const runs = ref([]); + const runsLoading = ref(false); + const nextRun = ref(null); + const previewItems = ref([]); + const previewLoading = ref(false); + const previewOpen = ref(false); + async function load() { try { const [p, c] = await Promise.all([api.llmProviders(), api.llmConfig()]); @@ -53,7 +71,21 @@ export const Settings = defineComponent({ provider: status.provider, model: status.model || 'rules', api_key_set: !!status.api_key_set, key_len: status.key_len || 0, }; + nextRun.value = status?.next_run || null; } catch (e) { /* ignore */ } + await refreshRuns(); + } + + async function refreshRuns() { + runsLoading.value = true; + try { + const data = await api.llmRuns({ limit: 20 }); + runs.value = Array.isArray(data) ? data : (data.runs || []); + } catch (e) { + runs.value = []; + } finally { + runsLoading.value = false; + } } onMounted(load); @@ -93,7 +125,6 @@ export const Settings = defineComponent({ async function onSave() { saving.value = true; try { - // api_key is only sent if the user typed something new const payload = { provider: cfg.provider, model: cfg.model, base_url: cfg.base_url, schedule: cfg.schedule, behaviour: cfg.behaviour, @@ -121,116 +152,287 @@ export const Settings = defineComponent({ } catch (e) { /* ignore */ } } + async function onReset() { + try { + await fetch('/api/admin/llm/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider: 'echo', model: 'rules', + schedule: { enabled: false, mode: 'off' }, + }), + }); + await load(); + toast(t('settings.saved')); + } catch (e) { + toast(t('toast.fail', { msg: e.message }), 4000); + } + } + + async function onRunNow() { + try { + await api.llmRun({}); + toast(t('action.runNowQueued') || t('action.llmRunQueued') || t('action.runNow'), 2000); + setTimeout(refreshRuns, 1500); + } catch (e) { + toast(t('toast.fail', { msg: e.message }), 4000); + } + } + + async function onPreview() { + previewOpen.value = true; + previewLoading.value = true; + previewItems.value = []; + try { + const r = await fetch('/api/admin/llm/run?dry_run=true&limit=20', { method: 'POST' }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data = await r.json(); + previewItems.value = data.preview || []; + } catch (e) { + previewItems.value = []; + toast(t('toast.fail', { msg: e.message }), 4000); + } finally { + previewLoading.value = false; + } + } + + function statusKey(s) { return 'settings.run.status.' + (s || ''); } + function triggerKey(s) { return 'settings.run.trigger.' + (s || ''); } + function statLine(stats) { + if (!stats) return ''; + return t('settings.run.stats', { + kept: stats.kept || 0, + dropped: stats.dropped || 0, + rescored: stats.importance_updated || 0, + merged: stats.resummarized || 0, + }); + } + + function nextRunText() { + if (!nextRun.value) return ''; + const ts = nextRun.value; + const d = new Date(ts * 1000); + if (Number.isNaN(d.getTime())) return ''; + const when = fmtTime(ts); + const mode = t('settings.schedule.' + (cfg.schedule.mode || 'off')); + return t('settings.nextRun', { when, mode }); + } + function onClose() { emit('close'); } return { cfg, providers, selectedProvider, onProviderChange, testing, testResult, onTest, - saving, savedHint, onSave, onClearKey, - store, t, onClose }; + saving, savedHint, onSave, onClearKey, onReset, onRunNow, onPreview, + runs, runsLoading, refreshRuns, nextRun, nextRunText, + previewItems, previewLoading, previewOpen, + statusKey, triggerKey, statLine, + WEEKDAYS, store, t, onClose }; }, template: /* html */ ` +
+ + + +
+

{{ t('settings.section.runs') }}

+
+ {{ t('common.loading') }} +
+
+ {{ t('settings.noRuns') }} +
+
+
+ {{ t(statusKey(r.status)) }} + {{ t(triggerKey(r.trigger)) }} · {{ r.started_at ? new Date(r.started_at * 1000).toLocaleString() : '—' }} + {{ statLine(r.stats) }} +
+
+
+
+ +
+ +
+ + +
+ `, }); diff --git a/loop_memory/serve/static/js/components/Timeline.js b/loop_memory/serve/static/js/components/Timeline.js index f12d344..a72873c 100644 --- a/loop_memory/serve/static/js/components/Timeline.js +++ b/loop_memory/serve/static/js/components/Timeline.js @@ -1,23 +1,30 @@ /** * Timeline — the "memory list" view, the default tab. * - * Shows a reverse-chronological list of memories with kind badges, scores, - * source labels and inline tags. Search uses the unified `/api/recall` - * endpoint so Chinese tokenisation + importance ranking both apply. - * - * Filters (kind, score, date range) live in component-local state. The - * query string is passed straight to the API. + * Faithful to the legacy vanilla-JS renderTimeline (pre-Vue 8498eca): + * - Each card shows: kind-icon (emoji), kind label, polish-spark ✨ if + * the memory was AI-distilled (updated_at > created_at), full kind + * label, source chip, relative time, full timestamp, score %, body, + * visual score-bar, importance %, tags, and a copy-to-clipboard + * action button. + * - Search uses /api/recall so Chinese tokenisation + importance + * ranking both apply. Filters (kind, score, date range) live in + * component-local state. */ -import { defineComponent, ref, computed, onMounted, watch, h } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; -import { store, t, escapeHtml, timeAgo } from '../store.js'; +import { defineComponent, ref, computed, onMounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, t, escapeHtml, timeAgo, fmtTime, toast } from '../store.js'; import { api } from '../api.js'; -const KIND_LABELS = { - episode: 'Episode', - fact: 'Fact', - rule: 'Rule', - scratch: 'Scratch', - summary: 'Summary', +const KIND_ICON = { + fact: '#', + episode: '⏵', + plan: '✱', + reflection: '✦', + turn: '↻', + rule: '§', + summary: '∑', + scratch: '·', + concept: '◇', }; export const Timeline = defineComponent({ @@ -32,14 +39,6 @@ export const Timeline = defineComponent({ const since = ref(''); const until = ref(''); - const sources = computed(() => { - const set = new Set(); - for (const m of memories.value) { - if (m.source) set.add(m.source); - } - return Array.from(set).sort(); - }); - async function refresh() { loading.value = true; recallMeta.value = null; @@ -59,7 +58,6 @@ export const Timeline = defineComponent({ }); rows = Array.isArray(data) ? data : (data.memories || data.items || []); } - // Apply client-side filters that the API doesn't expose yet if (kind.value) rows = rows.filter(r => r.kind === kind.value); if (minScore.value) rows = rows.filter(r => (r.score || r.importance || 0) >= Number(minScore.value)); memories.value = rows; @@ -82,7 +80,21 @@ export const Timeline = defineComponent({ function scoreFmt(s) { if (s == null) return '—'; - return (s * 100).toFixed(0) + '%'; + return (s * 100).toFixed(1) + '%'; + } + + function kindIcon(k) { return KIND_ICON[k] || '·'; } + + function isPolished(m) { + // AI-distilled if the memory was rewritten after creation + return m && m.updated_at && m.created_at && (m.updated_at - m.created_at) > 1; + } + + async function onCopy(m) { + try { + await navigator.clipboard?.writeText(m.text || ''); + toast(t('toast.copied')); + } catch (e) { /* ignore */ } } function onClickMemory(m) { @@ -90,13 +102,12 @@ export const Timeline = defineComponent({ } onMounted(() => refresh()); - // Refresh on stats changes (new memories ingested) watch(() => store.stats.memories, refresh); return { store, t, memories, recallMeta, loading, q, kind, minScore, since, until, - sources, KIND_LABELS, - refresh, resetFilters, onSearchSubmit, scoreFmt, onClickMemory, timeAgo, + refresh, resetFilters, onSearchSubmit, scoreFmt, kindIcon, isPolished, + onCopy, onClickMemory, timeAgo, fmtTime, KIND_ICON, }; }, template: /* html */ ` @@ -109,7 +120,8 @@ export const Timeline = defineComponent({ - + + @@ -127,18 +139,33 @@ export const Timeline = defineComponent({
-
-
- {{ m.source || '—' }} - {{ t('kind.' + (m.kind || 'episode')) }} - {{ scoreFmt(m.score ?? m.importance) }} - {{ timeAgo(m.created_at) }} +
+ {{ kindIcon(m.kind) }} + {{ t('kind.' + (m.kind || 'episode')) }} + ✨ {{ store.lang === 'zh' ? '已浓缩' : 'AI' }} + · + {{ timeAgo(m.created_at) }} + · + {{ m.source }} + + {{ scoreFmt(m.score ?? m.importance) }} +
+
{{ m.text }}
+
+
+ {{ t('common.importance') }} {{ Math.round((m.importance || 0) * 100) }}% + + {{ m.tags.slice(0, 4).join(', ') }} + + {{ fmtTime(m.created_at) }}
-
{{ m.text }}
-
- #{{ tag }} +
+
diff --git a/loop_memory/serve/static/js/components/TopBar.js b/loop_memory/serve/static/js/components/TopBar.js index 6ac71c6..0170637 100644 --- a/loop_memory/serve/static/js/components/TopBar.js +++ b/loop_memory/serve/static/js/components/TopBar.js @@ -15,13 +15,13 @@ import { api, ApiError } from '../api.js'; export const TopBar = defineComponent({ name: 'TopBar', - emits: ['ingest', 'rescore', 'llm-run', 'run-now', 'open-settings', 'open-stats', 'open-diag'], + emits: ['ingest', 'rescore', 'llm-run', 'run-now', 'open-settings', 'open-stats', 'open-diag', 'rebuild-graph', 'consolidate'], setup(props, { emit }) { const statsOpen = ref(false); const kebabOpen = ref(false); const runLabel = computed(() => { - if (store.runStatus?.is_running) { + if (store.runStatus && store.runStatus.is_running) { const p = store.runStatus.progress || {}; if (p.total > 0) return `${p.current}/${p.total}`; return '…'; @@ -29,7 +29,7 @@ export const TopBar = defineComponent({ return t('topbar.run.idle'); }); - const runState = computed(() => store.runStatus?.is_running ? 'running' : 'idle'); + const runState = computed(() => store.runStatus && store.runStatus.is_running ? 'running' : 'idle'); function toggleStats() { statsOpen.value = !statsOpen.value; kebabOpen.value = false; } function toggleKebab() { kebabOpen.value = !kebabOpen.value; statsOpen.value = false; } @@ -38,7 +38,6 @@ export const TopBar = defineComponent({ function setLang(l) { patchPrefs({ lang: l }); closeAll(); } function setTheme(th) { patchPrefs({ theme: th }); closeAll(); } - // Close popovers on outside click function onDocClick(e) { if (!e.target.closest('#stats-chip') && !e.target.closest('#stats-pop')) statsOpen.value = false; if (!e.target.closest('.tb-kebab') && !e.target.closest('.tb-kebab-menu')) kebabOpen.value = false; @@ -58,6 +57,8 @@ export const TopBar = defineComponent({ onOpenSettings:() => emit('open-settings'), onOpenStats: () => emit('open-stats'), onOpenDiag: () => emit('open-diag'), + onRebuildGraph:() => { closeAll(); emit('rebuild-graph'); }, + onConsolidate: () => { closeAll(); emit('consolidate'); }, }; }, template: /* html */ ` @@ -152,6 +153,10 @@ export const TopBar = defineComponent({
+ + + +
diff --git a/loop_memory/serve/static/js/components/Wiki.js b/loop_memory/serve/static/js/components/Wiki.js index 87dcacc..abcedeb 100644 --- a/loop_memory/serve/static/js/components/Wiki.js +++ b/loop_memory/serve/static/js/components/Wiki.js @@ -7,7 +7,7 @@ * function that hand-built HTML strings — Vue's template syntax is * significantly easier to scan and edit. */ -import { defineComponent, ref, computed, onMounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, ref, computed, onMounted, onUnmounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; import { store, t, escapeHtml, fmtTime } from '../store.js'; import { api } from '../api.js'; import { WikiEditor } from './WikiEditor.js'; @@ -93,7 +93,26 @@ export const Wiki = defineComponent({ return lines.slice(0, 3).join('\n'); } - onMounted(() => refresh()); + async function openWikiBySlug(slug) { + if (!slug) return; + try { + const list = await api.listWiki(); + const found = (list || []).find(x => x.slug === slug || x.title === slug); + if (found) editing.value = found.id; + } catch (e) { /* ignore */ } + } + function onOpenWiki(e) { + const slug = (e && e.detail && e.detail.slug) || ''; + store.activeTab = 'wiki'; + openWikiBySlug(slug); + } + onMounted(() => { + refresh(); + window.addEventListener('loop-memory:open-wiki', onOpenWiki); + }); + onUnmounted(() => { + window.removeEventListener('loop-memory:open-wiki', onOpenWiki); + }); watch(() => store.stats.wiki_pages, refresh); return { From a34de389890aa075d22202db81ebd9c11ac9b159 Mon Sep 17 00:00:00 2001 From: Codex <[email protected]> Date: Sat, 18 Jul 2026 17:57:51 +0800 Subject: [PATCH 07/88] Fix: enable pane scroll + browser-aware default language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit layout.css: add 'display: flex; flex-direction: column' to .tab-panes so the inner .tab-pane children size to the available viewport height. Without it, the dashboard pane stretched to its natural content height (~5000px) and overflow:auto never triggered a scrollbar. store.js: smarter initial-language selection. Respect any explicit user choice (localStorage), then fall back to navigator.language — zh-* locales default to 'zh', en-* locales default to 'en', everything else falls back to 'zh'. Browsers that report a non-zh locale no longer get an unwanted English UI on first visit. --- loop_memory/serve/static/css/layout.css | 2 ++ loop_memory/serve/static/js/store.js | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/loop_memory/serve/static/css/layout.css b/loop_memory/serve/static/css/layout.css index 53e7502..16815b0 100644 --- a/loop_memory/serve/static/css/layout.css +++ b/loop_memory/serve/static/css/layout.css @@ -41,6 +41,8 @@ html, body, #app { height: 100%; } min-height: 0; position: relative; overflow: hidden; + display: flex; + flex-direction: column; } /* Override the legacy "display: none unless .active" rule since we use diff --git a/loop_memory/serve/static/js/store.js b/loop_memory/serve/static/js/store.js index d1d5da3..863bac5 100644 --- a/loop_memory/serve/static/js/store.js +++ b/loop_memory/serve/static/js/store.js @@ -23,10 +23,24 @@ function savePrefs(p) { const initial = loadPrefs(); +function _detectInitialLang(stored) { + if (stored) return stored; // explicit user choice — respect it + if (typeof navigator !== 'undefined') { + const nav = (navigator.language || (navigator.languages && navigator.languages[0]) || '').toLowerCase(); + if (nav.startsWith('zh')) return 'zh'; + // Anything else (en, ja, fr, etc.) defaults to zh only as a last resort. + // The user can always switch via the kebab menu — their selection will stick. + if (nav) return nav.startsWith('en') ? 'en' : 'zh'; + } + return 'zh'; +} + // Reactive global state. The shape is documented in applyDefaults(). export const store = reactive({ // ---- user preferences (persisted) ---- - lang: initial.lang || 'zh', // 'zh' | 'en' + // Default picks Chinese for zh-* browser locales, English for en-* locales, + // and Chinese as the final fallback. Stored value (explicit user choice) wins. + lang: _detectInitialLang(initial.lang), theme: initial.theme || 'auto', // 'auto' | 'light' | 'dark' showZh: initial.showZh ?? true, // mixed-lang UI helper From 3389ce202d96b7240bec68ff96ad25244782ef4c Mon Sep 17 00:00:00 2001 From: Codex <[email protected]> Date: Sat, 18 Jul 2026 18:05:17 +0800 Subject: [PATCH 08/88] Fix: i18n dict reactive + wiki tab refresh-on-activate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit store.js: change _i18n from a plain object to a reactive({}) so the t() function's dict lookups are tracked. loadI18n populates the dictionaries via Object.assign on the reactive proxy, which fires per-key setters and invalidates every computed that calls t() — fixing the 100-200ms window during initial load where the tab labels and component text flashed raw keys like 'tab.wiki' before the dictionaries arrived. Previously the first render of every component that uses t() in its template would show those raw keys until something else triggered a re-render (such as the next stats poll), so on slow connections the wiki tab click could feel unresponsive until a manual reload. Wiki.js: watch store.activeTab. When the tab becomes 'wiki', call refresh() so returning to the tab always shows fresh data (distillation runs may have added/removed pages while the user was elsewhere). Falls back to the onMounted-first-fetch path on the very first open. --- loop_memory/serve/static/js/components/Wiki.js | 3 +++ loop_memory/serve/static/js/store.js | 11 +++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/loop_memory/serve/static/js/components/Wiki.js b/loop_memory/serve/static/js/components/Wiki.js index abcedeb..63648d3 100644 --- a/loop_memory/serve/static/js/components/Wiki.js +++ b/loop_memory/serve/static/js/components/Wiki.js @@ -114,6 +114,9 @@ export const Wiki = defineComponent({ window.removeEventListener('loop-memory:open-wiki', onOpenWiki); }); watch(() => store.stats.wiki_pages, refresh); + // When the user navigates back to the wiki tab, refresh in case the + // list went stale (distillation may have added/removed pages). + watch(() => store.activeTab, (id) => { if (id === 'wiki') refresh(); }); return { store, t, pages, q, sort, loading, visible, expanded, editing, bulletsOf, diff --git a/loop_memory/serve/static/js/store.js b/loop_memory/serve/static/js/store.js index 863bac5..50ed8a8 100644 --- a/loop_memory/serve/static/js/store.js +++ b/loop_memory/serve/static/js/store.js @@ -62,7 +62,11 @@ watch(() => [store.lang, store.theme, store.showZh], () => { }); // ---- i18n ---- -let _i18n = { en: {}, zh: {} }; +// _i18n is a reactive proxy so any component template / computed that calls +// `t()` automatically re-renders when the dictionaries finish loading. Without +// this, the first render flashes raw keys like `tab.wiki` until something else +// forces a re-render (see commit a34de38 → a plain object's keys aren't tracked). +const _i18n = reactive({ en: {}, zh: {} }); let _i18nLoaded = false; export async function loadI18n() { @@ -73,7 +77,9 @@ export async function loadI18n() { fetch('static/i18n/en.json').then(r => r.ok ? r.json() : {}).catch(() => ({})), fetch('static/i18n/zh.json').then(r => r.ok ? r.json() : {}).catch(() => ({})), ]); - _i18n = { en, zh }; + // Assign into the reactive so subscribers re-render. + Object.assign(_i18n.en, en); + Object.assign(_i18n.zh, zh); _i18nLoaded = true; return _i18n; } @@ -87,6 +93,7 @@ export function t(key, vars) { return s; } + export function tOrKey(key) { return t(key, undefined); } From 9cbad93c875092ee552da6ee845ab210622fb46d Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:56:00 +0800 Subject: [PATCH 09/88] Fix graph activation and restore dashboard layout --- loop_memory/serve/static/css/layout.css | 22 +- .../serve/static/js/components/Dashboard.js | 208 +++++++++--------- .../static/js/components/KnowledgeGraph.js | 20 +- 3 files changed, 141 insertions(+), 109 deletions(-) diff --git a/loop_memory/serve/static/css/layout.css b/loop_memory/serve/static/css/layout.css index 16815b0..37c6cd8 100644 --- a/loop_memory/serve/static/css/layout.css +++ b/loop_memory/serve/static/css/layout.css @@ -269,10 +269,10 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai /* --- 10-up KPI grid + 3 ring meters --- */ .ins-kpi-11 { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 10px; } -@media (max-width: 1180px) { .ins-kpi-11 { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@media (max-width: 1180px) { .ins-kpi-11 { grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); } } @media (max-width: 720px) { .ins-kpi-11 { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .ins-rings { display: grid; @@ -350,7 +350,7 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai .ins-lc-label { font-size: 11px; color: var(--text-mute); font-weight: 600; } .ins-lc-pct { font-size: 10.5px; color: var(--text-faint); font-variant-numeric: tabular-nums; } -/* --- 2-col layout for pulse + compression --- */ +/* --- 2-col layout for compression + granularity --- */ .ins-grid-2 { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); @@ -360,7 +360,12 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai @media (max-width: 1100px) { .ins-grid-2 { grid-template-columns: 1fr; } } /* --- Pulse: contradictions + score histogram --- */ -.ins-pulse { display: flex; flex-direction: column; gap: 10px; } +.ins-pulse { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + gap: 12px; +} +@media (max-width: 900px) { .ins-pulse { grid-template-columns: 1fr; } } .ins-pulse-card { background: var(--surface-2); border: 1px solid var(--border); @@ -371,7 +376,14 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai .ins-pulse-title { font-size: 12.5px; font-weight: 700; } .ins-pulse-count { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; } .ins-pulse-empty { font-size: 11.5px; color: var(--text-faint); padding: 12px 0; text-align: center; } -.ins-citem-list { display: flex; flex-direction: column; gap: 8px; } +.ins-citem-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 320px; + overflow-y: auto; + padding-right: 4px; +} .ins-citem { border: 1px solid var(--border); border-left: 3px solid #f43f5e; diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js index dd2b62d..f8852f9 100644 --- a/loop_memory/serve/static/js/components/Dashboard.js +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -532,121 +532,133 @@ export const Dashboard = defineComponent({
- -
-
-
- - {{ t('dash.pulse.title') }} - - {{ t('dash.pulse.sub') }} -
-
-
-
- ⚠ {{ t('dash.pulse.contradict') }} - {{ (insights.pulse.contradictions || []).length }} {{ t('dash.pulse.pairs') }} -
-
- {{ t('dash.pulse.noConflicts') }} -
-
-
-
A · {{ truncate(pair.a && pair.a.text, 50) }}
-
B · {{ truncate(pair.b && pair.b.text, 50) }}
-
- sim {{ ((pair.similarity || 0) * 100).toFixed(0) }}% -
- - - - -
+ +
+
+ 🧬 + {{ t('dash.pulse.title') }} + + {{ t('dash.pulse.sub') }} +
+
+
+
+ ⚠ {{ t('dash.pulse.contradict') }} + {{ (insights.pulse.contradictions || []).length }} {{ t('dash.pulse.pairs') }} +
+
+ {{ t('dash.pulse.noConflicts') }} +
+
+
+
A · {{ truncate(pair.a && pair.a.text, 50) }}
+
B · {{ truncate(pair.b && pair.b.text, 50) }}
+
+ sim {{ ((pair.similarity || 0) * 100).toFixed(0) }}% +
+ + + +
-
-
- 📊 {{ t('dash.pulse.decayDist') }} -
- - - - {{ b.label }} - - -
-
-
-
- -
-
- 🗜️ - {{ t('dash.cmp.title') }} -
-
-
{{ fmtNum(insights.compression.compressible_count) }} {{ t('dash.cmp.compressible') }}
-
{{ fmtNum(insights.compression.avg_length) }} {{ t('dash.cmp.avgLen') }}
-
-
- {{ item.kind || 'episode' }} - {{ truncate(item.text, 60) }} - - {{ Math.round((item.importance || 0) * 100) }}% - {{ t('dash.cmp.inWiki') }} - +
+
+ 📊 {{ t('dash.pulse.decayDist') }}
+ + + + {{ b.label }} + + +
-
{{ t('dash.cmp.empty') }}
- -
+ +
- 🔬 - {{ t('dash.gran.title') }} + 🗜 + {{ t('dash.cmp.title') }} + + {{ t('dash.cmp.sub') }}
-
-
-
-
🧠 {{ t('dash.gran.core') }}
-
{{ fmtNum(insights.granularity.core_count) }}
-
-
-
{{ truncate(r.text, 50) }}
-
- i {{ (r.importance || 0).toFixed(2) }} - s {{ (r.score || 0).toFixed(2) }} +
+
+
{{ t('dash.cmp.memComp') }}
+
+
+
{{ fmtNum(insights.compression.compressible_count) }}
+
{{ t('dash.cmp.compressible') }}
+
+
+
{{ fmtNum(insights.compression.avg_length) }}
+
{{ t('dash.cmp.avgLen') }}
+
+
+
{{ Math.round(insights.compression.compression_progress || 0) }}%
+
{{ t('dash.cmp.prog') }}
-
-
-
-
📋 {{ t('dash.gran.working') }}
-
{{ fmtNum(insights.granularity.working_count) }}
-
-
-
{{ truncate(r.text, 50) }}
-
- i {{ (r.importance || 0).toFixed(2) }} - s {{ (r.score || 0).toFixed(2) }} +
+
+ {{ item.kind || 'episode' }} + {{ truncate(item.text, 60) }} + + {{ Math.round((item.importance || 0) * 100) }}% + {{ t('dash.cmp.inWiki') }} +
+
{{ t('dash.cmp.empty') }}
-
-
-
📝 {{ t('dash.gran.scratch') }}
-
{{ fmtNum(insights.granularity.scratch_count) }}
-
-
-
{{ truncate(r.text, 50) }}
-
- i {{ (r.importance || 0).toFixed(2) }} - s {{ (r.score || 0).toFixed(2) }} + +
+
{{ t('dash.gran.title') }}
+
+
+
+
🧠 {{ t('dash.gran.core') }}
+
{{ fmtNum(insights.granularity.core_count) }}
+
+
+
{{ truncate(r.text, 50) }}
+
+ i {{ (r.importance || 0).toFixed(2) }} + s {{ (r.score || 0).toFixed(2) }} +
+
+
+
+
+
📋 {{ t('dash.gran.working') }}
+
{{ fmtNum(insights.granularity.working_count) }}
+
+
+
{{ truncate(r.text, 50) }}
+
+ i {{ (r.importance || 0).toFixed(2) }} + s {{ (r.score || 0).toFixed(2) }} +
+
+
+
+
+
📝 {{ t('dash.gran.scratch') }}
+
{{ fmtNum(insights.granularity.scratch_count) }}
+
+
+
{{ truncate(r.text, 50) }}
+
+ i {{ (r.importance || 0).toFixed(2) }} + s {{ (r.score || 0).toFixed(2) }} +
+
diff --git a/loop_memory/serve/static/js/components/KnowledgeGraph.js b/loop_memory/serve/static/js/components/KnowledgeGraph.js index 3add14d..55ea6d7 100644 --- a/loop_memory/serve/static/js/components/KnowledgeGraph.js +++ b/loop_memory/serve/static/js/components/KnowledgeGraph.js @@ -264,7 +264,7 @@ export const KnowledgeGraph = defineComponent({ function stepRotation(ts) { const frameScale = lastFrame ? Math.min(2.4, Math.max(0.25, (ts - lastFrame) / 16.67)) : 1; - const paused = !(rotationEnabled.value) || (mouseInCanvas && hoveredName) || graphLayout.dragging; + const paused = !(rotationEnabled.value) || (mouseInCanvas && hoveredName) || graphLayout.dragging || !!graphSelected; const rot = paused ? 0 : params.baseOmega * frameScale; for (const n of Object.values(graphNodes)) { if (n.lat0 == null) continue; @@ -671,11 +671,12 @@ export const KnowledgeGraph = defineComponent({ const c = canvasRef.value; if (!c) return; const dpr = window.devicePixelRatio || 1; - const rect = c.getBoundingClientRect(); - c.width = Math.max(1, rect.width * dpr); - c.height = Math.max(1, rect.height * dpr); - c.style.width = rect.width + 'px'; - c.style.height = rect.height + 'px'; + const target = wrapRef.value || c; + const rect = target.getBoundingClientRect(); + c.width = Math.max(1, Math.round(rect.width * dpr)); + c.height = Math.max(1, Math.round(rect.height * dpr)); + c.style.width = '100%'; + c.style.height = '100%'; } let resizeObserver = null; @@ -687,6 +688,13 @@ export const KnowledgeGraph = defineComponent({ watch(() => store.lang, () => drawCanvas()); watch(() => store.theme, () => drawCanvas()); + watch(() => store.activeTab, async (tab) => { + if (tab !== 'graph') return; + await nextTick(); + resizeCanvas(); + fitToCanvas(); + drawCanvas(); + }); function onExternalRebuild() { rebuildGraph(); } onMounted(async () => { From 090b767d6a8ef6169323de85186679ff166607b0 Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:07:14 +0800 Subject: [PATCH 10/88] Fix graph count flicker across refreshes --- loop_memory/serve/static/js/App.js | 4 +++- loop_memory/serve/static/js/components/Dashboard.js | 2 +- loop_memory/serve/static/js/components/KnowledgeGraph.js | 6 ++++++ loop_memory/storage/sqlite_store.py | 4 ++++ tests/test_store.py | 5 +++++ 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/loop_memory/serve/static/js/App.js b/loop_memory/serve/static/js/App.js index fc100c4..8b9f69a 100644 --- a/loop_memory/serve/static/js/App.js +++ b/loop_memory/serve/static/js/App.js @@ -43,7 +43,9 @@ export const App = defineComponent({ ...store.stats, memories: data.memories, sessions: data.sessions, wiki_pages: data.wiki_pages || 0, avg_score: data.avg_score, - graph: data.entities ? `${data.entities}/${data.entities}` : '0/0', + graph: Number.isFinite(data.entities) && Number.isFinite(data.relations) + ? `${data.entities}/${data.relations}` + : store.stats.graph, dbPath: data.path, }; } catch (e) { /* ignore */ } diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js index f8852f9..42c7946 100644 --- a/loop_memory/serve/static/js/components/Dashboard.js +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -172,7 +172,7 @@ export const Dashboard = defineComponent({ avg_score: stats.avg_score, graph: insightsData ? `${insightsData.overview?.entities || 0}/${insightsData.overview?.links || 0}` - : '0/0', + : store.stats.graph, dbPath: stats.path, }; } diff --git a/loop_memory/serve/static/js/components/KnowledgeGraph.js b/loop_memory/serve/static/js/components/KnowledgeGraph.js index 55ea6d7..1f12c67 100644 --- a/loop_memory/serve/static/js/components/KnowledgeGraph.js +++ b/loop_memory/serve/static/js/components/KnowledgeGraph.js @@ -121,6 +121,12 @@ export const KnowledgeGraph = defineComponent({ entities: (g.entities || []).length, relations: (g.relations || []).length, }; + if (Number.isFinite(g.stats?.entities) && Number.isFinite(g.stats?.relations)) { + store.stats = { + ...store.stats, + graph: `${g.stats.entities}/${g.stats.relations}`, + }; + } graphEdges = (g.relations || []).map((r, i) => ({ src: r.src, dst: r.dst, weight: r.weight, diff --git a/loop_memory/storage/sqlite_store.py b/loop_memory/storage/sqlite_store.py index fe3d645..d7a0366 100644 --- a/loop_memory/storage/sqlite_store.py +++ b/loop_memory/storage/sqlite_store.py @@ -1383,12 +1383,16 @@ def stats(self) -> dict: n_mem = c.execute("SELECT COUNT(*) c FROM memories").fetchone()["c"] n_ses = c.execute("SELECT COUNT(*) c FROM sessions").fetchone()["c"] n_wiki = c.execute("SELECT COUNT(*) c FROM wiki_pages").fetchone()["c"] + n_entities = c.execute("SELECT COUNT(*) c FROM entities").fetchone()["c"] + n_relations = c.execute("SELECT COUNT(*) c FROM relations").fetchone()["c"] avg = c.execute("SELECT AVG(score) a FROM memories").fetchone()["a"] or 0.0 wiki_avg = c.execute("SELECT AVG(importance) a FROM wiki_pages").fetchone()["a"] or 0.0 return { "memories": n_mem, "sessions": n_ses, "wiki_pages": n_wiki, + "entities": n_entities, + "relations": n_relations, "wiki_avg_importance": round(float(wiki_avg), 4), "avg_score": round(avg, 4), "path": str(self.path), diff --git a/tests/test_store.py b/tests/test_store.py index 6026ae7..bc264ae 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -95,9 +95,14 @@ def test_stats_counts(self) -> None: s = self.store.upsert_session(source="codex", external_id="z", message_count=0) self.store.upsert_memory(kind="turn", text="x", session_id=s.id) self.store.upsert_memory(kind="turn", text="y", session_id=s.id) + self.store.upsert_entity("Codex") + self.store.upsert_entity("Claude") + self.store.upsert_relation("Codex", "Claude") stats = self.store.stats() self.assertEqual(stats["memories"], 2) self.assertEqual(stats["sessions"], 1) + self.assertEqual(stats["entities"], 2) + self.assertEqual(stats["relations"], 1) if __name__ == "__main__": From 4d67a1f05f0bc3d13e91095186f2a76d162b3ad7 Mon Sep 17 00:00:00 2001 From: Loop Memory Date: Sat, 18 Jul 2026 19:41:58 +0800 Subject: [PATCH 11/88] Redesign topbar, stats hierarchy, lifecycle particles and decay chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Topbar (TopBar.js): - Remove kebab three-dot overflow; promote Ingest / AI 整理 / 立即运行 into primary command group - Add named '工具' dropdown caret instead of generic kebab, grouping maintenance actions (重打分 / 整理 / 重建图谱 / 运行 Doctor) - Add language segmented control (中 / EN) and theme segmented control (A / ☀ / ☾) into a single utility row; settings cog remains Dashboard (Dashboard.js + i18n): - 统计概览 split into 4 primary KPIs + 7 compact KPIs; new side accent bar and dot-prefixed labels - 生命周期 re-rendered as 6 icon stages with staggered 3-particle flow between gaps, breathing active-stage node - 矛盾检测: clicking 合并 / 保留A / 保留B / 忽略 now removes the pair immediately (no double-click required) with toast feedback - 衰减分数分布: rebuild SVG with 360x214 viewBox, grid lines, gradient, range labels below the bar, count value above; totals and peak range summary at the top - Pulse titles: icon rendered via .label-icon so it does not double up with translated text emoji - i18n: strip leading ⚠ 📊 🗜 🔬 📈 📡 📖 📥 🔍 ⇩ + duplicates from zh/en Layout (layout.css): - New topbar command group, tools dropdown, utility segmented controls - Stat card tone-driven box-shadow, lifecycle stages/flow keyframes - Decay chart SVG helpers, .resolving opacity for handled items - Wiki masonry: columns: 3 320px with break-inside: avoid 224 tests still pass; ruff clean. --- loop_memory/serve/static/css/layout.css | 333 ++++++++++++++++-- loop_memory/serve/static/i18n/en.json | 36 +- loop_memory/serve/static/i18n/zh.json | 36 +- .../serve/static/js/components/Dashboard.js | 260 ++++++++------ .../serve/static/js/components/TopBar.js | 126 ++++--- 5 files changed, 567 insertions(+), 224 deletions(-) diff --git a/loop_memory/serve/static/css/layout.css b/loop_memory/serve/static/css/layout.css index 37c6cd8..c5ebfe9 100644 --- a/loop_memory/serve/static/css/layout.css +++ b/loop_memory/serve/static/css/layout.css @@ -194,7 +194,144 @@ html, body, #app { height: 100%; } .tb-action.danger { color: #f43f5e; } .tb-action svg { width: 14px; height: 14px; vertical-align: -2px; } -.tl-list, .wiki-grid { display: flex; flex-direction: column; gap: 10px; } +/* --- Topbar command center --- */ +.topbar { + gap: 12px; + padding: 0 16px; + background: color-mix(in srgb, var(--surface) 96%, var(--bg)); +} +.topbar .topbar-command-bar { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} +.topbar .model-chip { + height: 34px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-2); +} +.topbar .tb-command-group { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 3px; + border: 1px solid var(--border); + border-radius: 11px; + background: var(--surface-2); +} +.topbar .tb-action { + height: 30px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text-mute); + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +.topbar .tb-action:hover, +.topbar .tb-action.active { + color: var(--text); + background: var(--surface); + border-color: var(--border); +} +.topbar .tb-action.primary { + color: #fff; + background: linear-gradient(135deg, var(--accent), #8b5cf6); + border-color: transparent; + box-shadow: 0 3px 10px color-mix(in srgb, var(--accent) 24%, transparent); +} +.topbar .tb-action.accent { + color: #fff; + background: linear-gradient(135deg, #f59e0b, #f97316); + border-color: transparent; + box-shadow: 0 3px 10px rgba(249, 115, 22, .18); +} +.topbar .tb-tools { position: relative; } +.topbar .tb-tools-trigger { height: 36px; padding-inline: 11px 8px; } +.topbar .tb-tools-trigger .caret { width: 13px; height: 13px; margin-left: -2px; transition: transform .18s ease; } +.topbar .tb-tools-trigger.active .caret { transform: rotate(180deg); } +.topbar .tb-tools-menu { + position: absolute; + top: 43px; + right: 0; + z-index: 60; + display: grid; + gap: 3px; + width: 286px; + padding: 8px; + border: 1px solid var(--border); + border-radius: 14px; + background: color-mix(in srgb, var(--surface) 97%, transparent); + box-shadow: 0 18px 45px rgba(15, 23, 42, .16); + backdrop-filter: blur(18px); +} +[data-theme="dark"] .topbar .tb-tools-menu { box-shadow: 0 18px 45px rgba(0, 0, 0, .42); } +.topbar .tb-tools-heading { + padding: 7px 9px 3px; + color: var(--text-faint); + font-size: 10px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} +.topbar .tb-tools-item { + display: grid; + grid-template-columns: 30px 1fr; + gap: 9px; + align-items: center; + width: 100%; + padding: 8px 9px; + border: 0; + border-radius: 9px; + background: transparent; + text-align: left; + cursor: pointer; +} +.topbar .tb-tools-item:hover { background: var(--surface-2); } +.topbar .tb-tools-item > svg { + width: 18px; + height: 18px; + color: var(--accent); + justify-self: center; +} +.topbar .tb-tools-item > span { display: grid; gap: 2px; min-width: 0; } +.topbar .tb-tools-item b { font-size: 12.5px; font-weight: 650; color: var(--text); } +.topbar .tb-tools-item small { font-size: 10.5px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.topbar .tb-utility-group { + display: inline-flex; + align-items: center; + gap: 6px; + padding-left: 3px; +} +.topbar .tb-seg { height: 30px; border-radius: 9px; } +.topbar .tb-seg.theme button { width: 28px; font-size: 12px; } +.topbar .settings-shortcut { + width: 32px; + height: 32px; + border-color: var(--border); + background: var(--surface-2); +} +.topbar .stats-pop { display: block; } +@media (max-width: 1320px) { + .topbar .model-chip > span:not(.dot):not(.model-name) { display: none; } + .topbar .model-chip .model-name { max-width: 86px; } + .topbar .tb-command-group .tb-action { padding-inline: 8px; } +} +@media (max-width: 1120px) { + .topbar .tb-command-group .tb-action > span { display: none; } + .topbar .tb-command-group .tb-action { width: 32px; justify-content: center; padding: 0; } + .topbar .tb-tools-trigger > span { display: none; } + .topbar .tb-tools-trigger { width: 34px; justify-content: center; padding: 0; } + .topbar .tb-tools-trigger .caret { display: none; } +} + +.tl-list { display: flex; flex-direction: column; gap: 10px; } .tl-card, .wiki-card { background: var(--surface); border: 1px solid var(--border); @@ -221,7 +358,20 @@ html, body, #app { height: 100%; } .src-badge[data-source="openclaw"] { background: #dbeafe; color: #1d4ed8; } .kind { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: var(--surface-2); color: var(--text-mute); text-transform: uppercase; } -.wiki-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; } +.wiki-grid { + display: block; + columns: 3 320px; + column-gap: 12px; +} +.wiki-card { + display: inline-flex; + width: 100%; + margin: 0 0 12px; + box-sizing: border-box; + break-inside: avoid; + -webkit-column-break-inside: avoid; + cursor: default; +} .wc-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 6px; } .wc-title { margin: 0; font-size: 14px; font-weight: 600; flex: 1; } .wc-imp { background: var(--accent-soft); color: var(--accent-strong); font-size: 11px; padding: 2px 6px; border-radius: 999px; font-weight: 600; } @@ -266,14 +416,68 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai @keyframes pulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 50%, transparent); } 100% { box-shadow: 0 0 0 8px transparent; } } .ev-ico { display: inline-block; margin-right: 4px; } -/* --- 10-up KPI grid + 3 ring meters --- */ +/* --- KPI hierarchy + ring meters --- */ .ins-kpi-11 { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + display: flex; + flex-direction: column; gap: 10px; } -@media (max-width: 1180px) { .ins-kpi-11 { grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); } } -@media (max-width: 720px) { .ins-kpi-11 { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.ins-kpi-primary, +.ins-kpi-secondary { display: grid; gap: 10px; } +.ins-kpi-primary { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.ins-kpi-secondary { grid-template-columns: repeat(7, minmax(0, 1fr)); } +.ins-kpi { + --metric-tone: #6366f1; + border: 1px solid color-mix(in srgb, var(--metric-tone) 16%, var(--border)); + background: linear-gradient(145deg, color-mix(in srgb, var(--metric-tone) 5%, var(--surface)), var(--surface)); + box-shadow: 0 5px 18px rgba(15, 23, 42, .035); +} +[data-theme="dark"] .ins-kpi { box-shadow: 0 5px 18px rgba(0, 0, 0, .12); } +.ins-kpi[data-tone="green"] { --metric-tone: #10b981; } +.ins-kpi[data-tone="amber"] { --metric-tone: #f59e0b; } +.ins-kpi[data-tone="purple"] { --metric-tone: #8b5cf6; } +.ins-kpi[data-tone="rose"] { --metric-tone: #f43f5e; } +.ins-kpi[data-tone="cyan"] { --metric-tone: #06b6d4; } +.ins-kpi::after { + left: 0; + right: auto; + top: 12px; + bottom: 12px; + width: 3px; + height: auto; + border-radius: 0 999px 999px 0; + background: var(--metric-tone) !important; + opacity: .82; +} +.ins-kpi .ik-label { + display: flex; + align-items: center; + gap: 7px; + letter-spacing: .06em; +} +.ins-kpi .ik-label::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--metric-tone); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--metric-tone) 12%, transparent); +} +.ins-kpi.primary-metric { min-height: 108px; padding: 14px 16px 10px; } +.ins-kpi.primary-metric .ik-val { font-size: 27px; letter-spacing: -.025em; } +.ins-kpi.primary-metric .ik-sub { min-height: 16px; } +.ins-kpi.primary-metric .ik-spark { margin-top: auto; } +.ins-kpi.compact-metric { min-height: 88px; padding: 12px 12px 10px; gap: 5px; } +.ins-kpi.compact-metric .ik-val { font-size: 20px; } +.ins-kpi.compact-metric .ik-sub { min-height: 28px; font-size: 10px; } +@media (max-width: 1180px) { + .ins-kpi-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .ins-kpi-secondary { grid-template-columns: repeat(4, minmax(0, 1fr)); } +} +@media (max-width: 720px) { + .ins-kpi-primary, + .ins-kpi-secondary { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} .ins-rings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -284,9 +488,9 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai .ins-ring-card { display: flex; align-items: center; gap: 14px; padding: 14px 16px; - background: var(--surface-2); + background: linear-gradient(135deg, var(--surface), color-mix(in srgb, var(--accent) 3%, var(--surface-2))); border: 1px solid var(--border); - border-radius: 12px; + border-radius: 14px; min-width: 0; } .ins-ring-card .irc-svg { width: 64px; height: 64px; flex-shrink: 0; } @@ -318,37 +522,85 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0; - padding: 4px; - background: var(--surface-2); - border-radius: 12px; + padding: 18px 10px 14px; + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 2.5%, var(--surface)), var(--surface)); + border-radius: 14px; border: 1px solid var(--border); overflow: hidden; } @media (max-width: 900px) { .ins-lifecycle { grid-template-columns: repeat(3, minmax(0, 1fr)); } } .ins-lc-stage { - padding: 12px 6px; + --stage-color: #6366f1; + padding: 4px 8px 2px; display: flex; flex-direction: column; align-items: center; gap: 4px; - border-right: 1px solid var(--border); position: relative; + min-width: 0; } -.ins-lc-stage:last-child { border-right: 0; } +.ins-lc-stage[data-tone="green"] { --stage-color: #10b981; } +.ins-lc-stage[data-tone="amber"] { --stage-color: #f59e0b; } +.ins-lc-stage[data-tone="purple"] { --stage-color: #8b5cf6; } +.ins-lc-stage[data-tone="slate"] { --stage-color: #64748b; } +.ins-lc-stage[data-tone="rose"] { --stage-color: #f43f5e; } .ins-lc-icon { - width: 44px; height: 44px; + width: 42px; height: 42px; display: grid; place-items: center; border-radius: 50%; - font-size: 14px; font-weight: 700; - color: white; - background: var(--accent); - box-shadow: inset 0 0 0 3px color-mix(in srgb, white 35%, transparent); -} -.ins-lc-stage[data-tone="blue"] .ins-lc-icon { background: #3b82f6; } -.ins-lc-stage[data-tone="green"] .ins-lc-icon { background: #10b981; } -.ins-lc-stage[data-tone="amber"] .ins-lc-icon { background: #f59e0b; } -.ins-lc-stage[data-tone="purple"] .ins-lc-icon { background: #8b5cf6; } -.ins-lc-stage[data-tone="slate"] .ins-lc-icon { background: #64748b; } -.ins-lc-stage[data-tone="rose"] .ins-lc-icon { background: #f43f5e; } -.ins-lc-label { font-size: 11px; color: var(--text-mute); font-weight: 600; } + font-size: 17px; font-weight: 700; + color: var(--stage-color); + background: color-mix(in srgb, var(--stage-color) 9%, var(--surface)); + border: 1px solid color-mix(in srgb, var(--stage-color) 34%, var(--border)); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--stage-color) 7%, transparent); + position: relative; + z-index: 2; +} +.ins-lc-stage.active .ins-lc-icon { animation: lc-node-breathe 3.2s ease-in-out infinite; } +.ins-lc-val { margin-top: 5px; font-size: 17px; line-height: 1; font-weight: 750; color: var(--text); font-variant-numeric: tabular-nums; } +.ins-lc-label { font-size: 11px; color: var(--text-mute); font-weight: 650; } .ins-lc-pct { font-size: 10.5px; color: var(--text-faint); font-variant-numeric: tabular-nums; } +.ins-lc-flow { + position: absolute; + z-index: 1; + top: 20px; + left: calc(50% + 25px); + width: calc(100% - 50px); + height: 10px; + pointer-events: none; +} +.ins-lc-flow::before { + content: ""; + position: absolute; + top: 4px; + left: 0; + right: 0; + border-top: 1px dashed color-mix(in srgb, var(--stage-color) 38%, var(--border)); +} +.ins-lc-particle { + position: absolute; + top: 2px; + left: 0; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--stage-color); + box-shadow: 0 0 8px color-mix(in srgb, var(--stage-color) 65%, transparent); + opacity: 0; + animation: lc-particle-flow 3.4s linear infinite; +} +.ins-lc-particle.p2 { animation-delay: 1.1s; } +.ins-lc-particle.p3 { animation-delay: 2.2s; } +@keyframes lc-particle-flow { + 0% { transform: translateX(0) scale(.7); opacity: 0; } + 12% { opacity: .9; } + 82% { opacity: .9; } + 100% { transform: translateX(calc(100% - 5px)) scale(1); opacity: 0; } +} +@keyframes lc-node-breathe { + 0%, 100% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--stage-color) 7%, transparent); } + 50% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--stage-color) 2%, transparent); } +} +@media (max-width: 900px) { + .ins-lc-stage:nth-child(3) .ins-lc-flow { display: none; } +} /* --- 2-col layout for compression + granularity --- */ .ins-grid-2 { @@ -367,13 +619,14 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai } @media (max-width: 900px) { .ins-pulse { grid-template-columns: 1fr; } } .ins-pulse-card { - background: var(--surface-2); + background: var(--surface); border: 1px solid var(--border); - border-radius: 10px; - padding: 12px 14px; + border-radius: 12px; + padding: 14px 16px; } .ins-pulse-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } -.ins-pulse-title { font-size: 12.5px; font-weight: 700; } +.ins-pulse-title { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 700; } +.label-icon { display: inline-grid; place-items: center; min-width: 18px; color: var(--accent); } .ins-pulse-count { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; } .ins-pulse-empty { font-size: 11.5px; color: var(--text-faint); padding: 12px 0; text-align: center; } .ins-citem-list { @@ -391,7 +644,9 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai padding: 8px 10px; background: var(--surface); display: flex; flex-direction: column; gap: 4px; + transition: opacity .18s ease, transform .18s ease, filter .18s ease; } +.ins-citem.resolving { opacity: .5; filter: saturate(.4); transform: scale(.995); } .ins-citem-a, .ins-citem-b { font-size: 11.5px; line-height: 1.4; color: var(--text); @@ -411,10 +666,16 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai } .ins-citem-actions { display: flex; gap: 4px; flex-wrap: wrap; } .ins-citem-actions .btn.xs { padding: 2px 7px; font-size: 10.5px; } -.ins-decay-svg { width: 100%; height: 140px; } -.ins-decay-bar { fill: var(--accent); opacity: 0.85; } -.ins-decay-bar:hover { opacity: 1; } -.ins-decay-label { fill: var(--text-faint); font-size: 9px; text-anchor: middle; } +.decay-card { display: flex; flex-direction: column; min-height: 360px; } +.ins-decay-meta { display: flex; justify-content: space-between; gap: 12px; color: var(--text-faint); font-size: 10.5px; margin: 0 0 2px; } +.ins-decay-meta b { color: var(--text-mute); font-weight: 700; } +.ins-decay-svg { width: 100%; min-height: 260px; height: 100%; flex: 1; overflow: visible; } +.ins-decay-grid { stroke: var(--border); stroke-width: 1; stroke-dasharray: 3 5; opacity: .7; } +.ins-decay-bar { fill: url(#decay-bar-grad); opacity: 0.9; transition: opacity .15s ease, filter .15s ease; } +.ins-decay-bar[data-empty="true"] { opacity: .18; } +.ins-decay-bar:hover { opacity: 1; filter: brightness(1.05); } +.ins-decay-label { fill: var(--text-mute); font-size: 8.5px; font-weight: 600; text-anchor: middle; } +.ins-decay-value { fill: var(--text); font-size: 9px; font-weight: 700; text-anchor: middle; } /* --- Compression card --- */ .ins-cmp-summary { display: flex; gap: 18px; margin-bottom: 10px; } diff --git a/loop_memory/serve/static/i18n/en.json b/loop_memory/serve/static/i18n/en.json index 3bcebac..7c84e28 100644 --- a/loop_memory/serve/static/i18n/en.json +++ b/loop_memory/serve/static/i18n/en.json @@ -78,17 +78,17 @@ "dash.cmp.compressible": "compressible", "dash.cmp.empty": "No items need compression yet.", "dash.cmp.inWiki": "wiki", - "dash.cmp.memComp": "🗜 Memory compression", + "dash.cmp.memComp": "Memory compression", "dash.cmp.prog": "progress", "dash.cmp.sub": "what to distil & how it's classified", "dash.cmp.title": "Compression & Granularity", "dash.decay.sub": "How scores decay over time", "dash.decay.title": "Temporal decay", "dash.dist.noTrend": "No data", - "dash.dist.statusTitle": "📊 Status distribution", + "dash.dist.statusTitle": "Status distribution", "dash.dist.sub": "type · status · 7-day trend", "dash.dist.title": "Data distribution", - "dash.dist.trendTitle": "📈 7-day trend", + "dash.dist.trendTitle": "7-day trend", "dash.drill.close": "Close", "dash.drill.empty": "Click a stage above to inspect its data flow.", "dash.drill.emptyItems": "No records for this run.", @@ -105,7 +105,7 @@ "dash.flow.title": "Memory data flow", "dash.gran.core": "Core", "dash.gran.scratch": "Scratch", - "dash.gran.title": "🔬 Memory granularity", + "dash.gran.title": "Memory granularity", "dash.gran.total": "total", "dash.gran.working": "Working", "dash.health.ago": "ago", @@ -193,8 +193,8 @@ "dash.pipe.sub": "avg ms per stage", "dash.pipe.title": "Pipeline latency", "dash.pulse.bin": "bin width", - "dash.pulse.contradict": "⚠ Contradiction detection", - "dash.pulse.decayDist": "📊 Decay score distribution", + "dash.pulse.contradict": "Contradiction detection", + "dash.pulse.decayDist": "Decay score distribution", "dash.pulse.ignore": "Ignore", "dash.pulse.keepA": "Keep A", "dash.pulse.keepB": "Keep B", @@ -204,6 +204,7 @@ "dash.pulse.noConflicts": "✓ No contradictions detected.", "dash.pulse.none": "no conflicts", "dash.pulse.pairs": "pairs", + "dash.pulse.peak": "Peak range", "dash.pulse.records": "records", "dash.pulse.resolved": "Resolved", "dash.pulse.resolvedDel": "Removed 1 duplicate · kept the other side", @@ -225,15 +226,15 @@ "dash.signals.like": "By likes", "dash.signals.recall": "By recall", "dash.signals.title": "Most frequently recalled memories", - "dash.src.bySource": "📡 Memory sources", + "dash.src.bySource": "Memory sources", "dash.src.coverage": "coverage", - "dash.src.ingest": "📥 Ingest rate (last 24h)", + "dash.src.ingest": "Ingest rate (last 24h)", "dash.src.last24h": "last 24h", "dash.src.recall24h": "recalls", "dash.src.sub": "who contributes & what got distilled", "dash.src.title": "Sources · Wiki · Ingest", "dash.src.wikiChars": "chars", - "dash.src.wikiHealth": "📖 Wiki health", + "dash.src.wikiHealth": "Wiki health", "dash.src.wikiImp": "avg imp", "dash.src.wikiPages": "pages", "dash.src.wikiRefs": "refs", @@ -244,7 +245,7 @@ "dash.stage.wiki": "Wiki", "diag.recheck": "Re-check", "diag.sub": "Quick check of every subsystem. Red dots are fixable — copy-paste the command.", - "diag.title": "🔍 Diagnostic", + "diag.title": "Diagnostic", "empty.results.body": "Try adjusting the search or filters above.", "empty.results.title": "No memories found", "empty.sessions.body": "Click Ingest in the top bar to scan for transcripts.", @@ -465,7 +466,7 @@ "wiki.ask.empty": "Type a question above and press Enter", "wiki.ask.noMatch": "No wiki pages matched your query.", "wiki.ask.placeholder": "Ask your distilled knowledge…", - "wiki.askBtn": "🔎 Ask", + "wiki.askBtn": "Ask", "wiki.bodyPlaceholder": "Markdown body — use '- ' for bullets", "wiki.confirmDelete": "Delete this wiki page? This cannot be undone.", "wiki.copied": "Copied to clipboard", @@ -480,7 +481,7 @@ "wiki.empty.sub": "Run AI consolidation to distill your memories into durable wiki pages.", "wiki.empty.title": "No wiki pages yet", "wiki.error.missing": "Title, slug, and body are required", - "wiki.exportBtn": "⇩ Export", + "wiki.exportBtn": "Export", "wiki.exportFail": "Export failed", "wiki.exported": "Wiki exported", "wiki.field.body": "Body (Markdown)", @@ -503,7 +504,7 @@ "wiki.importance": "Importance", "wiki.importanceTip": "Importance score", "wiki.new": "New page", - "wiki.newPage": "+ New page", + "wiki.newPage": "New page", "wiki.preview": "Preview", "wiki.saveError": "Save failed", "wiki.saved": "Wiki page saved", @@ -523,5 +524,14 @@ "wiki.viewEvidence": "Jump to source memory", "topbar.rebuildGraph": "Rebuild graph", "topbar.doctor": "Run doctor", + "topbar.tools": "Tools", + "topbar.toolsTip": "Maintenance and diagnostics", + "topbar.maintenance": "Memory maintenance", + "topbar.system": "System diagnostics", + "topbar.consolidateTip": "Organize, merge, and compress existing memories", + "topbar.rebuildGraphTip": "Regenerate entity relationships from the current wiki", + "topbar.themeAuto": "Follow system", + "topbar.themeLight": "Light mode", + "topbar.themeDark": "Dark mode", "common.close": "Close" } diff --git a/loop_memory/serve/static/i18n/zh.json b/loop_memory/serve/static/i18n/zh.json index c2e88e4..66105ed 100644 --- a/loop_memory/serve/static/i18n/zh.json +++ b/loop_memory/serve/static/i18n/zh.json @@ -78,17 +78,17 @@ "dash.cmp.compressible": "可压缩", "dash.cmp.empty": "暂无需要压缩的条目。", "dash.cmp.inWiki": "wiki", - "dash.cmp.memComp": "🗜 记忆压缩", + "dash.cmp.memComp": "记忆压缩", "dash.cmp.prog": "压缩进度", "dash.cmp.sub": "待提炼与已分类", "dash.cmp.title": "压缩与粒度", "dash.decay.sub": "分数如何随时间衰减", "dash.decay.title": "时序衰减", "dash.dist.noTrend": "暂无数据", - "dash.dist.statusTitle": "📊 状态分布", + "dash.dist.statusTitle": "状态分布", "dash.dist.sub": "类型 · 状态 · 7日趋势", "dash.dist.title": "数据分布", - "dash.dist.trendTitle": "📈 7日趋势", + "dash.dist.trendTitle": "7日趋势", "dash.drill.close": "关闭", "dash.drill.empty": "点击上方节点查看该阶段的数据流。", "dash.drill.emptyItems": "本轮无相关记录。", @@ -105,7 +105,7 @@ "dash.flow.title": "记忆数据流", "dash.gran.core": "核心", "dash.gran.scratch": "临时", - "dash.gran.title": "🔬 记忆粒度分布", + "dash.gran.title": "记忆粒度分布", "dash.gran.total": "共", "dash.gran.working": "工作", "dash.health.ago": "前", @@ -193,8 +193,8 @@ "dash.pipe.sub": "各阶段平均耗时", "dash.pipe.title": "管线延时分布", "dash.pulse.bin": "区间宽度", - "dash.pulse.contradict": "⚠ 矛盾检测", - "dash.pulse.decayDist": "📊 衰减分数分布", + "dash.pulse.contradict": "矛盾检测", + "dash.pulse.decayDist": "衰减分数分布", "dash.pulse.ignore": "忽略", "dash.pulse.keepA": "保留A", "dash.pulse.keepB": "保留B", @@ -204,6 +204,7 @@ "dash.pulse.noConflicts": "✓ 未检测到矛盾。", "dash.pulse.none": "无冲突", "dash.pulse.pairs": "对矛盾", + "dash.pulse.peak": "峰值区间", "dash.pulse.records": "条", "dash.pulse.resolved": "已处理", "dash.pulse.resolvedDel": "已删除 1 条重复 · 保留另一条", @@ -225,15 +226,15 @@ "dash.signals.like": "按点赞", "dash.signals.recall": "按召回", "dash.signals.title": "最常被召回的记忆", - "dash.src.bySource": "📡 记忆来源", + "dash.src.bySource": "记忆来源", "dash.src.coverage": "覆盖率", - "dash.src.ingest": "📥 摄入速率(近24h)", + "dash.src.ingest": "摄入速率(近24h)", "dash.src.last24h": "近24h", "dash.src.recall24h": "召回", "dash.src.sub": "谁在贡献 · 蒸馏了什么", "dash.src.title": "来源 · Wiki · 摄入", "dash.src.wikiChars": "字符", - "dash.src.wikiHealth": "📖 Wiki 健康度", + "dash.src.wikiHealth": "Wiki 健康度", "dash.src.wikiImp": "均重要度", "dash.src.wikiPages": "页", "dash.src.wikiRefs": "引用", @@ -244,7 +245,7 @@ "dash.stage.wiki": "知识库", "diag.recheck": "重新检查", "diag.sub": "快速检查所有子系统。红色项可直接复制命令修复。", - "diag.title": "🔍 诊断", + "diag.title": "诊断", "empty.results.body": "试试调整搜索条件或筛选器。", "empty.results.title": "未找到符合条件的记忆", "empty.sessions.body": "点击顶部 导入 扫描本地对话记录。", @@ -465,7 +466,7 @@ "wiki.ask.empty": "在上面输入问题并按回车", "wiki.ask.noMatch": "没有匹配的知识页面", "wiki.ask.placeholder": "向已浓缩的知识提问…", - "wiki.askBtn": "🔎 提问", + "wiki.askBtn": "提问", "wiki.bodyPlaceholder": "Markdown 正文 — 使用 '- ' 创建列表", "wiki.confirmDelete": "删除这个词条?此操作不可撤销。", "wiki.copied": "已复制到剪贴板", @@ -480,7 +481,7 @@ "wiki.empty.sub": "运行 AI 整理,把零散记忆提炼成长期可用的知识库。", "wiki.empty.title": "还没有词条", "wiki.error.missing": "标题、slug 和正文不能为空", - "wiki.exportBtn": "⇩ 导出", + "wiki.exportBtn": "导出", "wiki.exportFail": "导出失败", "wiki.exported": "已导出", "wiki.field.body": "正文 (Markdown)", @@ -503,7 +504,7 @@ "wiki.importance": "重要度", "wiki.importanceTip": "重要度评分", "wiki.new": "新建页面", - "wiki.newPage": "+ 新建词条", + "wiki.newPage": "新建词条", "wiki.preview": "预览", "wiki.saveError": "保存失败", "wiki.saved": "已保存", @@ -523,5 +524,14 @@ "wiki.viewEvidence": "跳转到源记忆", "topbar.rebuildGraph": "重建知识图谱", "topbar.doctor": "运行 Doctor", + "topbar.tools": "工具", + "topbar.toolsTip": "维护与诊断工具", + "topbar.maintenance": "记忆维护", + "topbar.system": "系统诊断", + "topbar.consolidateTip": "整理、合并并压缩已有记忆", + "topbar.rebuildGraphTip": "根据当前知识库重新生成实体关系", + "topbar.themeAuto": "跟随系统", + "topbar.themeLight": "浅色模式", + "topbar.themeDark": "深色模式", "common.close": "关闭" } diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js index 42c7946..5813eb4 100644 --- a/loop_memory/serve/static/js/components/Dashboard.js +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -16,7 +16,7 @@ * - WriteGuard header shows uptime (time since first audit record). */ import { defineComponent, ref, computed, onMounted, onUnmounted } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; -import { store, t, timeAgo } from '../store.js'; +import { store, t, timeAgo, toast } from '../store.js'; import { api } from '../api.js'; const SVGNS = 'http://www.w3.org/2000/svg'; @@ -131,6 +131,12 @@ export const Dashboard = defineComponent({ }; let pollHandle = null; + const resolvedPairs = new Set(); + + function contradictionKey(pair) { + const ids = [pair?.a?.id || '', pair?.b?.id || ''].sort(); + return `${ids[0]}|${ids[1]}`; + } function pushHistory(arr, v) { arr.push(v); @@ -176,6 +182,10 @@ export const Dashboard = defineComponent({ dbPath: stats.path, }; } + if (insightsData?.pulse?.contradictions) { + insightsData.pulse.contradictions = insightsData.pulse.contradictions + .filter(pair => !resolvedPairs.has(contradictionKey(pair))); + } insights.value = insightsData; live.value = !!insightsData; sourceHealth.value = health; @@ -215,14 +225,22 @@ export const Dashboard = defineComponent({ async function resolvePair(pair, action) { const aId = pair?.a?.id, bId = pair?.b?.id; if (!aId || !bId) return; - const key = `${aId}|${bId}|${action}`; + const key = `${contradictionKey(pair)}|${action}`; resolvingId.value = key; try { const url = `/api/contradictions/resolve?a=${encodeURIComponent(aId)}&b=${encodeURIComponent(bId)}&action=${encodeURIComponent(action)}`; const res = await fetch(url, { method: 'POST' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - await refresh(); - } catch (e) { weeklyError.value = e?.message || 'resolve failed'; } + resolvedPairs.add(contradictionKey(pair)); + if (insights.value?.pulse?.contradictions) { + insights.value.pulse.contradictions = insights.value.pulse.contradictions + .filter(item => contradictionKey(item) !== contradictionKey(pair)); + } + toast(action === 'ignore' ? t('dash.pulse.resolvedIgnore') : t('dash.pulse.resolvedDel'), 2400); + void refresh(); + } catch (e) { + toast(t('dash.pulse.resolvedErr') + (e?.message || 'resolve failed'), 3200); + } finally { resolvingId.value = ''; } } @@ -285,19 +303,38 @@ export const Dashboard = defineComponent({ })); } - function barsFor(items, w = 300, h = 140) { + function barsFor(items, w = 360, h = 214) { const list = safeArr(items); const max = Math.max(...list.map(r => r.count || 0), 1); - const bw = w / Math.max(1, list.length); + const plot = { left: 18, right: 8, top: 28, bottom: 38 }; + const innerW = w - plot.left - plot.right; + const innerH = h - plot.top - plot.bottom; + const baseline = plot.top + innerH; + const bw = innerW / Math.max(1, list.length); return list.map((r, i) => ({ - x: i * bw + 4, w: Math.max(6, bw - 8), - y: h - ((r.count || 0) / max) * (h - 18), - h: ((r.count || 0) / max) * (h - 18), - label: r.range ? r.range[0].toFixed(1) : '', + x: plot.left + i * bw + 5, + w: Math.max(8, bw - 10), + y: baseline - Math.max(2, ((r.count || 0) / max) * innerH), + h: Math.max(2, ((r.count || 0) / max) * innerH), + baseline, + countY: baseline - Math.max(2, ((r.count || 0) / max) * innerH) - 7, + labelY: baseline + 20, + label: r.range ? `${r.range[0].toFixed(1)}–${r.range[1].toFixed(1)}` : '', count: r.count, })); } + function scoreDistributionTotal(items) { + return safeArr(items).reduce((sum, item) => sum + (item.count || 0), 0); + } + + function peakScoreRange(items) { + const list = safeArr(items); + if (!list.length) return '—'; + const peak = list.reduce((best, item) => (item.count || 0) > (best.count || 0) ? item : best, list[0]); + return peak.range ? `${peak.range[0].toFixed(1)}–${peak.range[1].toFixed(1)}` : '—'; + } + function sourceBars(sources) { const items = safeArr(sources); const total = items.reduce((a, b) => a + (b.count || 0), 0) || 1; @@ -316,34 +353,28 @@ export const Dashboard = defineComponent({ function lifecycleSegments(stages) { const stg = stages || {}; const labels = { - extracted: '已提取', active: '活跃', decayed: '已衰减', - merged: '已合并', archived: '已归档', forgotten: '已遗忘', + extracted: t('dash.lc.extracted'), active: t('dash.lc.active'), decayed: t('dash.lc.decayed'), + merged: t('dash.lc.merged'), archived: t('dash.lc.archived'), forgotten: t('dash.lc.forgotten'), }; const tones = { extracted: 'blue', active: 'green', decayed: 'amber', merged: 'purple', archived: 'slate', forgotten: 'rose', }; + const icons = { + extracted: '📥', active: '⚡', decayed: '📉', + merged: '⊕', archived: '🗄', forgotten: '🗑', + }; const order = ['extracted', 'active', 'decayed', 'merged', 'archived', 'forgotten']; const total = order.reduce((a, k) => a + (stg[k] || 0), 0) || 1; let acc = 0; return order.map(k => { const pct = ((stg[k] || 0) / total) * 100; - const seg = { key: k, label: labels[k], tone: tones[k], count: stg[k] || 0, pct, x: acc }; + const seg = { key: k, label: labels[k], tone: tones[k], icon: icons[k], count: stg[k] || 0, pct, x: acc }; acc += pct; return seg; }); } - const lifecycleStyle = computed(() => { - if (!insights.value) return ''; - const segs = lifecycleSegments(insights.value.stages); - if (!segs.length) return ''; - return 'background: linear-gradient(90deg, ' + segs.map(s => - 'var(--tone-' + s.tone + ', #6366f1) ' + s.x + '%, ' + - 'var(--tone-' + s.tone + ', #6366f1) ' + (s.x + s.pct) + '%' - ).join(', ') + ');'; - }); - // Uptime derived from the oldest LLM audit record (matches the // legacy behaviour of "server has been recording for N"). const guardUptime = computed(() => { @@ -361,11 +392,12 @@ export const Dashboard = defineComponent({ store, t, insights, loading, live, lastRefresh, weeklyReport, weeklyLoading, weeklyError, weeklyDays, loadWeekly, copyWeekly, llmAudit, writeGuard, sourceHealth, - resolvingId, resolvePair, - KIND_TONE, STATUS_TONE, SOURCE_COLORS, STAGE_DEFS, SVGNS, ARCH, lifecycleStyle, + resolvingId, resolvePair, contradictionKey, + KIND_TONE, STATUS_TONE, SOURCE_COLORS, STAGE_DEFS, SVGNS, ARCH, fmtNum, truncate, timeAgo, sparkPath, fmtDuration, shortenPath, guardUptime, hist, ring, donutArcPaths, trendPoints, ingestBars, barsFor, + scoreDistributionTotal, peakScoreRange, sourceBars, pipelineBars, lifecycleSegments, onRefresh: refresh, onRunEvolution: () => window.dispatchEvent(new CustomEvent('loop:llm-run')), @@ -401,71 +433,68 @@ export const Dashboard = defineComponent({
-
-
{{ t('dash.kpi.total') }}
-
{{ fmtNum(insights.overview && insights.overview.total) }}
-
{{ t('dash.kpi.totalSub2') }}{{ (insights.sources || []).length }} {{ t('dash.kpi.sourceCountUnit') }}
- -
-
-
{{ t('dash.kpi.today') }}
-
{{ fmtNum(insights.overview && insights.overview.today) }}
-
{{ t('dash.kpi.todaySub') }}
- -
-
-
{{ t('dash.kpi.active') }}
-
{{ fmtNum(insights.overview && insights.overview.active) }}
-
{{ fmtNum(insights.overview && insights.overview.active) }}/{{ fmtNum(insights.overview && insights.overview.total) }} {{ t('dash.kpi.ofTotal') }}
- -
-
-
{{ t('dash.kpi.links') }}
-
{{ fmtNum(insights.overview && insights.overview.links) }}
-
{{ t('dash.kpi.linksSub') }}
- -
-
-
{{ t('dash.kpi.clusters') }}
-
{{ fmtNum(insights.overview && insights.overview.clusters) }}
-
{{ fmtNum(insights.overview && insights.overview.clusters) }} {{ t('dash.kpi.groups') }}
- -
-
-
{{ t('dash.kpi.avg') }}
-
{{ ((insights.overview && insights.overview.avg_score || 0) * 100).toFixed(0) }}%
-
{{ t('dash.kpi.avgSub') }}
- -
-
-
{{ t('dash.kpi.decay') }}
-
{{ ((insights.overview && insights.overview.decay_pct) || 0).toFixed(0) }}%
-
{{ t('dash.kpi.decaySub') }}
- -
-
-
{{ t('dash.kpi.entities') }}
-
{{ fmtNum(insights.overview && insights.overview.entities) }}
-
{{ t('dash.kpi.entitiesSub') }}
- -
-
-
{{ t('dash.kpi.sources') }}
-
{{ fmtNum((insights.sources || []).reduce((a, b) => a + (b.count || 0), 0)) }}
-
{{ (insights.sources || []).length }} {{ t('dash.kpi.distinct') }}
- -
-
-
{{ t('dash.kpi.wikiHealth') }}
-
{{ fmtNum(insights.wiki_health && insights.wiki_health.pages) }}
-
{{ fmtNum(insights.wiki_health && insights.wiki_health.pages) }} {{ t('dash.kpi.pages') }} · {{ t('dash.src.wikiImp') }} {{ insights.wiki_health && insights.wiki_health.avg_importance ? Math.round(insights.wiki_health.avg_importance * 100) + '%' : '—' }}
- +
+
+
{{ t('dash.kpi.total') }}
+
{{ fmtNum(insights.overview && insights.overview.total) }}
+
{{ t('dash.kpi.totalSub2') }}{{ (insights.sources || []).length }} {{ t('dash.kpi.sourceCountUnit') }}
+ +
+
+
{{ t('dash.kpi.today') }}
+
{{ fmtNum(insights.overview && insights.overview.today) }}
+
{{ t('dash.kpi.todaySub') }}
+ +
+
+
{{ t('dash.kpi.active') }}
+
{{ fmtNum(insights.overview && insights.overview.active) }}
+
{{ fmtNum(insights.overview && insights.overview.active) }}/{{ fmtNum(insights.overview && insights.overview.total) }} {{ t('dash.kpi.ofTotal') }}
+ +
+
+
{{ t('dash.kpi.avg') }}
+
{{ ((insights.overview && insights.overview.avg_score || 0) * 100).toFixed(0) }}%
+
{{ t('dash.kpi.avgSub') }}
+ +
-
-
{{ t('dash.kpi.recall24h') }}
-
{{ fmtNum(insights.recall_24h && insights.recall_24h.total) }}
-
{{ fmtNum(insights.recall_24h && insights.recall_24h.unique_memories) }} {{ t('dash.kpi.uniqueMems') }}
- +
+
+
{{ t('dash.kpi.links') }}
+
{{ fmtNum(insights.overview && insights.overview.links) }}
+
{{ t('dash.kpi.linksSub') }}
+
+
+
{{ t('dash.kpi.clusters') }}
+
{{ fmtNum(insights.overview && insights.overview.clusters) }}
+
{{ fmtNum(insights.overview && insights.overview.clusters) }} {{ t('dash.kpi.groups') }}
+
+
+
{{ t('dash.kpi.decay') }}
+
{{ ((insights.overview && insights.overview.decay_pct) || 0).toFixed(0) }}%
+
{{ t('dash.kpi.decaySub') }}
+
+
+
{{ t('dash.kpi.entities') }}
+
{{ fmtNum(insights.overview && insights.overview.entities) }}
+
{{ t('dash.kpi.entitiesSub') }}
+
+
+
{{ t('dash.kpi.sources') }}
+
{{ fmtNum((insights.sources || []).reduce((a, b) => a + (b.count || 0), 0)) }}
+
{{ (insights.sources || []).length }} {{ t('dash.kpi.distinct') }}
+
+
+
{{ t('dash.kpi.wikiHealth') }}
+
{{ fmtNum(insights.wiki_health && insights.wiki_health.pages) }}
+
{{ fmtNum(insights.wiki_health && insights.wiki_health.pages) }} {{ t('dash.kpi.pages') }} · {{ t('dash.src.wikiImp') }} {{ insights.wiki_health && insights.wiki_health.avg_importance ? Math.round(insights.wiki_health.avg_importance * 100) + '%' : '—' }}
+
+
+
{{ t('dash.kpi.recall24h') }}
+
{{ fmtNum(insights.recall_24h && insights.recall_24h.total) }}
+
{{ fmtNum(insights.recall_24h && insights.recall_24h.unique_memories) }} {{ t('dash.kpi.uniqueMems') }}
+
@@ -523,11 +552,18 @@ export const Dashboard = defineComponent({ {{ t('dash.lc.totalLabel') }} {{ fmtNum(Object.values(insights.stages || {}).reduce((a,b)=>a+(b||0),0)) }}
-
-
-
{{ seg.count }}
+
+
+
{{ seg.icon }}
+
{{ fmtNum(seg.count) }}
{{ seg.label }}
{{ seg.pct.toFixed(0) }}%
+
@@ -543,36 +579,50 @@ export const Dashboard = defineComponent({
- ⚠ {{ t('dash.pulse.contradict') }} + {{ t('dash.pulse.contradict') }} {{ (insights.pulse.contradictions || []).length }} {{ t('dash.pulse.pairs') }}
{{ t('dash.pulse.noConflicts') }}
-
+
A · {{ truncate(pair.a && pair.a.text, 50) }}
B · {{ truncate(pair.b && pair.b.text, 50) }}
sim {{ ((pair.similarity || 0) * 100).toFixed(0) }}%
- - - - + + + +
-
+
- 📊 {{ t('dash.pulse.decayDist') }} + {{ t('dash.pulse.decayDist') }}
- +
+ {{ t('dash.pulse.total') }} {{ fmtNum(scoreDistributionTotal(insights.pulse.score_distribution)) }} {{ t('dash.pulse.records') }} + {{ t('dash.pulse.peak') }} {{ peakScoreRange(insights.pulse.score_distribution) }} +
+ + + + + + + + - - {{ b.label }} + + {{ b.count }} + {{ b.label }}
@@ -590,7 +640,7 @@ export const Dashboard = defineComponent({
-
{{ t('dash.cmp.memComp') }}
+
🗜{{ t('dash.cmp.memComp') }}
{{ fmtNum(insights.compression.compressible_count) }}
@@ -619,7 +669,7 @@ export const Dashboard = defineComponent({
-
{{ t('dash.gran.title') }}
+
🔬{{ t('dash.gran.title') }}
diff --git a/loop_memory/serve/static/js/components/TopBar.js b/loop_memory/serve/static/js/components/TopBar.js index 0170637..a6f686e 100644 --- a/loop_memory/serve/static/js/components/TopBar.js +++ b/loop_memory/serve/static/js/components/TopBar.js @@ -18,7 +18,7 @@ export const TopBar = defineComponent({ emits: ['ingest', 'rescore', 'llm-run', 'run-now', 'open-settings', 'open-stats', 'open-diag', 'rebuild-graph', 'consolidate'], setup(props, { emit }) { const statsOpen = ref(false); - const kebabOpen = ref(false); + const toolsOpen = ref(false); const runLabel = computed(() => { if (store.runStatus && store.runStatus.is_running) { @@ -31,32 +31,32 @@ export const TopBar = defineComponent({ const runState = computed(() => store.runStatus && store.runStatus.is_running ? 'running' : 'idle'); - function toggleStats() { statsOpen.value = !statsOpen.value; kebabOpen.value = false; } - function toggleKebab() { kebabOpen.value = !kebabOpen.value; statsOpen.value = false; } - function closeAll() { statsOpen.value = false; kebabOpen.value = false; } + function toggleStats() { statsOpen.value = !statsOpen.value; toolsOpen.value = false; } + function toggleTools() { toolsOpen.value = !toolsOpen.value; statsOpen.value = false; } + function closeAll() { statsOpen.value = false; toolsOpen.value = false; } function setLang(l) { patchPrefs({ lang: l }); closeAll(); } function setTheme(th) { patchPrefs({ theme: th }); closeAll(); } function onDocClick(e) { if (!e.target.closest('#stats-chip') && !e.target.closest('#stats-pop')) statsOpen.value = false; - if (!e.target.closest('.tb-kebab') && !e.target.closest('.tb-kebab-menu')) kebabOpen.value = false; + if (!e.target.closest('.tb-tools') && !e.target.closest('.tb-tools-menu')) toolsOpen.value = false; } onMounted(() => document.addEventListener('click', onDocClick)); onUnmounted(() => document.removeEventListener('click', onDocClick)); return { store, t, runLabel, runState, - statsOpen, kebabOpen, - toggleStats, toggleKebab, closeAll, + statsOpen, toolsOpen, + toggleStats, toggleTools, closeAll, setLang, setTheme, - onIngest: () => emit('ingest'), - onRescore: () => emit('rescore'), - onLlmRun: () => emit('llm-run'), - onRunNow: () => emit('run-now'), - onOpenSettings:() => emit('open-settings'), + onIngest: () => { closeAll(); emit('ingest'); }, + onRescore: () => { closeAll(); emit('rescore'); }, + onLlmRun: () => { closeAll(); emit('llm-run'); }, + onRunNow: () => { closeAll(); emit('run-now'); }, + onOpenSettings:() => { closeAll(); emit('open-settings'); }, onOpenStats: () => emit('open-stats'), - onOpenDiag: () => emit('open-diag'), + onOpenDiag: () => { closeAll(); emit('open-diag'); }, onRebuildGraph:() => { closeAll(); emit('rebuild-graph'); }, onConsolidate: () => { closeAll(); emit('consolidate'); }, }; @@ -105,8 +105,8 @@ export const TopBar = defineComponent({
-
- + {{ t('model.label') }} @@ -114,51 +114,63 @@ export const TopBar = defineComponent({ -
- - - - - - -
- - +
+ + + +
-
- -
- - - -
- - - - -
- - - -
- +
+
{{ t('topbar.maintenance') }}
+ + + +
{{ t('topbar.system') }}
+ +
+
+ +
+
+ + +
+
+ + +
+
From 767cf0b45a208e8f2d18e9460678042925d85b40 Mon Sep 17 00:00:00 2001 From: Loop Memory Date: Sat, 18 Jul 2026 20:09:31 +0800 Subject: [PATCH 12/88] Stop weekly auto-refresh, fix decay axis labels, polish ingest chart, restore lifecycle flow Dashboard (Dashboard.js): - refresh() no longer ends with await loadWeekly(): the weekly report is intentionally heavy and previously flickered its markdown on every 6-second poll. Weekly is now only fetched: 1. on initial mount (once) 2. when the user clicks a different window pill (3d / 7d / 14d) 3. when the user clicks the explicit \u21bb refresh button - barsFor(): redesigned decay chart geometry (h 214 -> 230, plot.bottom 38 -> 56). Returns shortLabel ('.1','.3',\u2026) + showShortLabel flag so the chart only renders every other axis label, eliminating the crowded-overlap problem at narrow widths. Adds a peak flag for the highest-count bar and a tooltip carrying the full range + count. - ingestBars(): 24 hourly buckets now expose tickY / lblY / tick / label so the template only paints major hour ticks (every 4h) plus a small idle-vs-active class plus a peak flag for the busy hour. - ingestPeakHour() helper exposed to the template; renders a pill on the ingest card showing the peak hour and share of daily volume. - Layout: 1.2 kB of template changes around the three charts above. Pulled <line> axis and per-bar <rect> tick into the SVG so the chart looks like an axis plot, not a tiny floating bar set. Layout (layout.css): - Decay chart: .ins-decay-axis, .ins-decay-tick, .ins-decay-bar.peak (with accent drop-shadow), and a slightly stronger .ins-decay-label opacity. Card SVG min-height bumped from 246 to keep proportions. - Ingest chart: full set of .ins-ingest-* rules: grid lines, axis line, ticks, idle/peak bar states, hover brightness, .ins-src-peak pill. - Lifecycle: connector pseudo-element switched from border-top dashed to a 12px segmented linear-gradient that animates background-position (lc-flow-dash 1.4s linear infinite) so the dashed line 'marching ants' toward the next stage. Particles bumped from 5 to 8 px with stacked glow shadows. Icon z-index raised to 3 and background set to opaque var(--surface) so the dashed line does not leak through. 224 tests still pass; ruff clean. --- loop_memory/serve/static/css/layout.css | 91 ++++++++---- .../serve/static/js/components/Dashboard.js | 130 +++++++++++++----- 2 files changed, 157 insertions(+), 64 deletions(-) diff --git a/loop_memory/serve/static/css/layout.css b/loop_memory/serve/static/css/layout.css index c5ebfe9..78686a9 100644 --- a/loop_memory/serve/static/css/layout.css +++ b/loop_memory/serve/static/css/layout.css @@ -547,11 +547,11 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai border-radius: 50%; font-size: 17px; font-weight: 700; color: var(--stage-color); - background: color-mix(in srgb, var(--stage-color) 9%, var(--surface)); - border: 1px solid color-mix(in srgb, var(--stage-color) 34%, var(--border)); - box-shadow: 0 0 0 5px color-mix(in srgb, var(--stage-color) 7%, transparent); + background: var(--surface); + border: 1px solid color-mix(in srgb, var(--stage-color) 40%, var(--border)); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--stage-color) 10%, transparent); position: relative; - z-index: 2; + z-index: 3; } .ins-lc-stage.active .ins-lc-icon { animation: lc-node-breathe 3.2s ease-in-out infinite; } .ins-lc-val { margin-top: 5px; font-size: 17px; line-height: 1; font-weight: 750; color: var(--text); font-variant-numeric: tabular-nums; } @@ -560,43 +560,66 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai .ins-lc-flow { position: absolute; z-index: 1; - top: 20px; - left: calc(50% + 25px); - width: calc(100% - 50px); - height: 10px; + top: 26px; + left: calc(50% + 22px); + width: calc(100% - 44px); + height: 12px; pointer-events: none; } +/* Marching-ants dashed connector between stages. We use a linear-gradient + * pattern of dot/dash segments and animate background-position so the line + * appears to flow continuously toward the next stage. */ .ins-lc-flow::before { content: ""; position: absolute; - top: 4px; + top: 5px; left: 0; right: 0; - border-top: 1px dashed color-mix(in srgb, var(--stage-color) 38%, var(--border)); + height: 2px; + border-radius: 999px; + background-image: linear-gradient( + to right, + color-mix(in srgb, var(--stage-color) 50%, transparent) 0, + color-mix(in srgb, var(--stage-color) 50%, transparent) 6px, + transparent 6px, + transparent 12px + ); + background-size: 12px 100%; + background-repeat: repeat-x; + background-position: 0 0; + opacity: .85; + animation: lc-flow-dash 1.4s linear infinite; } .ins-lc-particle { position: absolute; - top: 2px; + top: 3px; left: 0; - width: 5px; - height: 5px; + width: 8px; + height: 8px; border-radius: 50%; background: var(--stage-color); - box-shadow: 0 0 8px color-mix(in srgb, var(--stage-color) 65%, transparent); + box-shadow: + 0 0 6px color-mix(in srgb, var(--stage-color) 80%, transparent), + 0 0 14px color-mix(in srgb, var(--stage-color) 50%, transparent); opacity: 0; animation: lc-particle-flow 3.4s linear infinite; + will-change: transform, opacity; } -.ins-lc-particle.p2 { animation-delay: 1.1s; } -.ins-lc-particle.p3 { animation-delay: 2.2s; } +.ins-lc-particle.p2 { animation-delay: 1.13s; } +.ins-lc-particle.p3 { animation-delay: 2.27s; } @keyframes lc-particle-flow { - 0% { transform: translateX(0) scale(.7); opacity: 0; } - 12% { opacity: .9; } - 82% { opacity: .9; } - 100% { transform: translateX(calc(100% - 5px)) scale(1); opacity: 0; } + 0% { transform: translateX(0) scale(.65); opacity: 0; } + 10% { opacity: 1; } + 85% { opacity: 1; } + 100% { transform: translateX(calc(100% - 8px)) scale(1); opacity: 0; } +} +@keyframes lc-flow-dash { + 0% { background-position: 0 0; } + 100% { background-position: -24px 0; } } @keyframes lc-node-breathe { 0%, 100% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--stage-color) 7%, transparent); } - 50% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--stage-color) 2%, transparent); } + 50% { box-shadow: 0 0 0 9px color-mix(in srgb, var(--stage-color) 2%, transparent); } } @media (max-width: 900px) { .ins-lc-stage:nth-child(3) .ins-lc-flow { display: none; } @@ -669,12 +692,15 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai .decay-card { display: flex; flex-direction: column; min-height: 360px; } .ins-decay-meta { display: flex; justify-content: space-between; gap: 12px; color: var(--text-faint); font-size: 10.5px; margin: 0 0 2px; } .ins-decay-meta b { color: var(--text-mute); font-weight: 700; } -.ins-decay-svg { width: 100%; min-height: 260px; height: 100%; flex: 1; overflow: visible; } +.ins-decay-svg { width: 100%; min-height: 246px; height: 100%; flex: 1; overflow: visible; } .ins-decay-grid { stroke: var(--border); stroke-width: 1; stroke-dasharray: 3 5; opacity: .7; } -.ins-decay-bar { fill: url(#decay-bar-grad); opacity: 0.9; transition: opacity .15s ease, filter .15s ease; } -.ins-decay-bar[data-empty="true"] { opacity: .18; } +.ins-decay-bar { fill: url(#decay-bar-grad); opacity: 0.92; transition: opacity .15s ease, filter .15s ease, transform .15s ease; } +.ins-decay-bar[data-empty="true"] { opacity: .16; } .ins-decay-bar:hover { opacity: 1; filter: brightness(1.05); } -.ins-decay-label { fill: var(--text-mute); font-size: 8.5px; font-weight: 600; text-anchor: middle; } +.ins-decay-bar.peak { fill: var(--accent); filter: drop-shadow(0 0 4px color-mix(in srgb, var(--accent) 70%, transparent)); } +.ins-decay-axis { stroke: var(--border); stroke-width: 1; opacity: .55; } +.ins-decay-tick { fill: var(--text-faint); opacity: .85; } +.ins-decay-label { fill: var(--text-faint); font-size: 8.5px; font-weight: 700; text-anchor: middle; letter-spacing: .02em; opacity: .85; } .ins-decay-value { fill: var(--text); font-size: 9px; font-weight: 700; text-anchor: middle; } /* --- Compression card --- */ @@ -792,8 +818,19 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai .ins-wiki-bar-track { height: 6px; background: var(--surface); border-radius: 4px; overflow: hidden; margin-bottom: 4px; border: 1px solid var(--border); } .ins-wiki-bar-fill { height: 100%; background: linear-gradient(90deg, #10b981 0%, #059669 100%); border-radius: 4px; transition: width .4s ease; } .ins-wiki-coverage-lbl { font-size: 10.5px; color: var(--text-faint); display: flex; justify-content: space-between; } -.ins-ingest-svg { width: 100%; height: 100px; } -.ins-ingest-total { margin-top: 4px; font-size: 11px; color: var(--text-faint); display: flex; justify-content: space-between; } +.ins-ingest-svg { width: 100%; height: 124px; display: block; } +.ins-ingest-grid { stroke: var(--border); stroke-width: 1; stroke-dasharray: 3 5; opacity: .55; } +.ins-ingest-axis { stroke: var(--border); stroke-width: 1; opacity: .6; } +.ins-ingest-tick { fill: var(--text-faint); opacity: .65; } +.ins-ingest-bar { fill: url(#ins-ingest-grad); transition: opacity .15s ease, filter .15s ease; } +.ins-ingest-bar.idle { opacity: .25; } +.ins-ingest-bar.peak { fill: var(--accent); filter: drop-shadow(0 0 4px color-mix(in srgb, var(--accent) 70%, transparent)); } +.ins-ingest-bar:hover { opacity: 1; filter: brightness(1.08); } +.ins-ingest-lbl { fill: var(--text-faint); font-size: 8.5px; font-weight: 700; text-anchor: middle; opacity: .85; } +.ins-ingest-total { margin-top: 6px; font-size: 11px; color: var(--text-faint); display: flex; justify-content: space-between; } +.ins-ingest-total b { color: var(--accent); font-variant-numeric: tabular-nums; font-weight: 700; } +.ins-src-title { display: flex; align-items: center; gap: 8px; } +.ins-src-peak { margin-left: auto; font-size: 10.5px; font-weight: 700; color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); padding: 2px 7px; border-radius: 999px; letter-spacing: .02em; } /* --- Pipeline latency table --- */ .ins-pipe { display: flex; flex-direction: column; gap: 8px; } diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js index 5813eb4..5633aee 100644 --- a/loop_memory/serve/static/js/components/Dashboard.js +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -207,7 +207,6 @@ export const Dashboard = defineComponent({ } } catch (e) { live.value = false; } finally { loading.value = false; } - await loadWeekly(weeklyDays.value); } async function loadWeekly(days = 7) { @@ -253,6 +252,10 @@ export const Dashboard = defineComponent({ onMounted(() => { refresh(); pollHandle = setInterval(refresh, 6000); + // Weekly report is intentionally NOT refreshed in the polling loop + // (it is heavy and flickers the markdown content). Fetch only on + // explicit user action or when the user navigates back to the tab. + loadWeekly(weeklyDays.value); }); onUnmounted(() => { if (pollHandle) clearInterval(pollHandle); }); @@ -291,37 +294,74 @@ export const Dashboard = defineComponent({ return { line, area, ticks, max }; } - function ingestBars(rows, w = 360, h = 96) { + function ingestBars(rows, w = 360, h = 118) { + // 24 buckets. Layout: 24 bars in w, but with breathing room. We expose + // x/y/width/height plus an `axis` position and a `tier` flag so the + // template can render the small hour ticks on every 4-hour mark. const items = safeArr(rows); - const max = Math.max(...items.map(r => r.count), 1); - const bw = w / Math.max(1, items.length); - return items.map((r, i) => ({ - x: i * bw + 1, w: Math.max(2, bw - 2), - y: h - (r.count / max) * (h - 6), - h: (r.count / max) * (h - 6), - hour: r.hour, count: r.count, - })); + const max = Math.max(...items.map(r => r.count || 0), 1); + const peak = Math.max(...items.map(r => r.count || 0), 0); + const plot = { left: 4, right: 4, top: 8, bottom: 18 }; + const innerW = w - plot.left - plot.right; + const innerH = h - plot.top - plot.bottom; + const baseline = plot.top + innerH; + const bw = innerW / Math.max(1, items.length); + return items.map((r, i) => { + const hourNum = Number((r.hour || '0').split(':')[0]) || 0; + const barH = Math.max(2, ((r.count || 0) / max) * innerH); + return { + x: plot.left + i * bw + Math.max(0.5, (bw - Math.max(2, bw - 2)) / 2), + w: Math.max(2, bw - 2), + y: baseline - barH, + h: barH, + baseline, + tickY: baseline + 4, + lblY: baseline + 14, + tick: hourNum % 4 === 0, // major tick every 4h + label: hourNum % 4 === 0 ? String(hourNum).padStart(2, '0') : '', + isPeak: r.count === peak && peak > 0, + active: r.count > 0, + hour: r.hour, + count: r.count, + }; + }); } - function barsFor(items, w = 360, h = 214) { + function barsFor(items, w = 360, h = 230) { + // 10 score bands. We give 3 layout regions inside the SVG height: + // y[top:28 .. baseline:182] : bars + // y[tickY:182 .. labelY:198] : axis tick + short label + // y[metaY:214] : optional per-bar full range (rare) const list = safeArr(items); const max = Math.max(...list.map(r => r.count || 0), 1); - const plot = { left: 18, right: 8, top: 28, bottom: 38 }; + const plot = { left: 14, right: 6, top: 28, bottom: 56 }; const innerW = w - plot.left - plot.right; const innerH = h - plot.top - plot.bottom; - const baseline = plot.top + innerH; + const baseline = plot.top + innerH; // 174 const bw = innerW / Math.max(1, list.length); - return list.map((r, i) => ({ - x: plot.left + i * bw + 5, - w: Math.max(8, bw - 10), - y: baseline - Math.max(2, ((r.count || 0) / max) * innerH), - h: Math.max(2, ((r.count || 0) / max) * innerH), - baseline, - countY: baseline - Math.max(2, ((r.count || 0) / max) * innerH) - 7, - labelY: baseline + 20, - label: r.range ? `${r.range[0].toFixed(1)}–${r.range[1].toFixed(1)}` : '', - count: r.count, - })); + const fmtShort = (v) => (v >= 1 ? '1' : v === 0 ? '0' : `.${Math.round(v * 10)}`); + return list.map((r, i) => { + const upper = r.range ? r.range[1] : null; + const lower = r.range ? r.range[0] : null; + return { + x: plot.left + i * bw + (bw > 18 ? 4 : 1), + w: Math.max(6, bw - (bw > 18 ? 8 : 2)), + y: baseline - Math.max(2, ((r.count || 0) / max) * innerH), + h: Math.max(2, ((r.count || 0) / max) * innerH), + baseline, + countY: baseline - Math.max(2, ((r.count || 0) / max) * innerH) - 7, + tickY: baseline + 8, + labelY: baseline + 22, + // short label: just the upper boundary, displayed for every other + // bucket to keep the axis readable at narrow widths. + shortLabel: upper != null ? fmtShort(upper) : '', + // full range label kept for the (rare) tooltip/full view use + label: (lower != null && upper != null) ? `${lower.toFixed(1)}–${upper.toFixed(1)}` : '', + showShortLabel: i % 2 === 1, // 0, 2, 4... hide; 1, 3, 5... show + peak: r.count > 0 && r.count === max, + count: r.count, + }; + }); } function scoreDistributionTotal(items) { @@ -350,6 +390,15 @@ export const Dashboard = defineComponent({ return items.map(r => ({ ...r, pct: ((r.avg_ms || 0) / max) * 100 })); } + function ingestPeakHour(rows) { + const items = safeArr(rows); + if (!items.length) return null; + const peak = items.reduce((best, r) => (r.count || 0) > (best.count || 0) ? r : best, items[0]); + if (!peak || !(peak.count > 0)) return null; + const total = items.reduce((a, b) => a + (b.count || 0), 0) || 1; + return `${peak.hour} · ${peak.count} (${Math.round(((peak.count || 0) / total) * 100)}%)`; + } + function lifecycleSegments(stages) { const stg = stages || {}; const labels = { @@ -396,7 +445,7 @@ export const Dashboard = defineComponent({ KIND_TONE, STATUS_TONE, SOURCE_COLORS, STAGE_DEFS, SVGNS, ARCH, fmtNum, truncate, timeAgo, sparkPath, fmtDuration, shortenPath, guardUptime, hist, - ring, donutArcPaths, trendPoints, ingestBars, barsFor, + ring, donutArcPaths, trendPoints, ingestBars, ingestPeakHour, barsFor, scoreDistributionTotal, peakScoreRange, sourceBars, pipelineBars, lifecycleSegments, onRefresh: refresh, @@ -611,18 +660,22 @@ export const Dashboard = defineComponent({ <span>{{ t('dash.pulse.total') }} {{ fmtNum(scoreDistributionTotal(insights.pulse.score_distribution)) }} {{ t('dash.pulse.records') }}</span> <span>{{ t('dash.pulse.peak') }} <b>{{ peakScoreRange(insights.pulse.score_distribution) }}</b></span> </div> - <svg class="ins-decay-svg" viewBox="0 0 360 214" preserveAspectRatio="xMidYMid meet" v-if="(insights.pulse.score_distribution || []).length"> + <svg class="ins-decay-svg" viewBox="0 0 360 230" preserveAspectRatio="xMidYMid meet" v-if="(insights.pulse.score_distribution || []).length"> <defs> <linearGradient id="decay-bar-grad" x1="0" y1="0" x2="0" y2="1"> <stop offset="0%" stop-color="var(--accent)" stop-opacity="0.95"></stop> <stop offset="100%" stop-color="var(--accent)" stop-opacity="0.5"></stop> </linearGradient> </defs> - <line v-for="y in [65,102,139,176]" :key="y" x1="18" :y1="y" x2="352" :y2="y" class="ins-decay-grid"></line> + <line v-for="y in [60,93,128,165]" :key="y" x1="14" :y1="y" x2="354" :y2="y" class="ins-decay-grid"></line> + <line x1="14" :y1="174" x2="354" y2="174" class="ins-decay-axis" /> <g v-for="(b, i) in barsFor(insights.pulse.score_distribution)" :key="i"> - <rect :x="b.x" :y="b.y" :width="b.w" :height="b.h" class="ins-decay-bar" :data-empty="!b.count" rx="5"></rect> + <rect :x="b.x - 0.5" :y="b.tickY" width="1" height="4" class="ins-decay-tick" /> + <rect :x="b.x" :y="b.y" :width="b.w" :height="b.h" class="ins-decay-bar" :class="{ peak: b.peak }" :data-empty="!b.count" rx="4"> + <title v-if="b.label">{{ b.label }} · {{ b.count }} + {{ b.count }} - {{ b.label }} + {{ b.shortLabel }}
@@ -830,19 +883,22 @@ export const Dashboard = defineComponent({
-
📥 {{ t('dash.src.ingest') }}
- +
📥 {{ t('dash.src.ingest') }}★ {{ ingestPeakHour(insights.ingest_rate) }}
+ - - + + + + - - - - {{ String(i * 4).padStart(2, '0') }} + + + {{ b.hour }} · {{ b.count }} + + {{ b.label }}
From 1e3d43bb26d3eb5c091410e4555dd30eca1546aa Mon Sep 17 00:00:00 2001 From: Loop Memory Date: Sat, 18 Jul 2026 20:16:51 +0800 Subject: [PATCH 13/88] Make contradiction 'merge' a true fusion, not a delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contradiction 'merge' button previously just deleted the lower-scored memory and surfaced a '已删除 1 条重复' toast. That contradicted the label and surprised the user: '选择合并,为何也提示删除一条,不应该是两条 融合一下吗'. Storage (sqlite_store.py): - New merge_memories(a_id, b_id) method: * Winner = higher-scored side (ties go to A). * Loser's text is appended onto the winner with '\n\n---\n\n' as the separator, but the append is skipped when the loser's text is already a substring of the winner's (avoids duplication). * Winner's importance and score are bumped to the max of the pair so the fused memory carries the strongest signal. * Loser is deleted in the same transaction. * Pair is recorded in contradiction_ignored so it does not resurface. * Returns a small dict describing what changed (merged, kept, lost, appended, new_length, winner_was_a). API (app.py): - /api/contradictions/resolve action='merge' now calls merge_memories instead of doing a plain delete. Response shape moved from the old {deleted: [{id, kept}]} to {merged, winner, loser, appended, new_length}. Docstring updated to reflect true-fusion semantics. Dashboard (Dashboard.js + i18n): - Toast for 'merge' now reads '已合并两条记忆' / 'Merged both memories into one'. When the loser's text was actually appended, the toast also shows the new combined length ('已合并 · 新文本长度 N'). When only one side survived (the other was already gone) the toast says '已 合并 · 一条记录已不存在'. - keepA / keepB still delete the loser (single-side keep), toast text unchanged. Tests (tests/test_contradictions.py): - Replaced the old merge-then-delete test with three tests covering: * the standard fusion (winner keeps row, loser appended, max(score), pair hidden). * the no-op-append path when the loser's text is already inside the winner's. * the tie-keep-a path. - 226 tests pass, ruff clean. --- loop_memory/serve/app.py | 49 +++++----- loop_memory/serve/static/i18n/en.json | 5 +- loop_memory/serve/static/i18n/zh.json | 5 +- .../serve/static/js/components/Dashboard.js | 24 ++++- loop_memory/storage/sqlite_store.py | 89 +++++++++++++++++++ tests/test_contradictions.py | 76 ++++++++++++++-- 6 files changed, 218 insertions(+), 30 deletions(-) diff --git a/loop_memory/serve/app.py b/loop_memory/serve/app.py index 7240293..c744df0 100644 --- a/loop_memory/serve/app.py +++ b/loop_memory/serve/app.py @@ -1114,7 +1114,8 @@ def resolve_contradiction(a: str, b: str, action: str = "ignore", ``action``: - ``ignore`` — hide this pair from future pulses (default) - - ``merge`` — keep the higher-scored side, delete the loser + - ``merge`` — fuse the two into one memory (winner keeps its + row, loser's text is appended, the loser is deleted) - ``keepA`` — explicitly delete side B - ``keepB`` — explicitly delete side A """ @@ -1135,28 +1136,32 @@ def resolve_contradiction(a: str, b: str, action: str = "ignore", except Exception: pass elif action == "merge": - # Keep the higher-scored side, delete the other. - ma = store.get_memory(a_id) - mb = store.get_memory(b_id) - if ma is None and mb is None: - raise HTTPException(404, "neither memory exists") - if ma is None: - loser, winner = a_id, b_id - elif mb is None: - loser, winner = b_id, a_id - else: - # Keep the HIGHER-scored side; delete the LOWER-scored one. - # Tie-break: keep A (the first one surfaced in the pulse). - if (ma.score or 0) >= (mb.score or 0): - winner, loser = a_id, b_id - else: - winner, loser = b_id, a_id - store.delete_memory(loser) - result["deleted"].append({"id": loser, "kept": winner}) + # True fusion: keep the higher-scored side as the row that + # survives, append the loser's text to it (de-duplicated), + # bump importance/score to the max of the two, then delete + # the loser in the same transaction. ``merge_memories`` + # also writes the pair into ``contradiction_ignored`` so + # the pulse does not surface it again. try: - store.record_signal(winner, positive=True) - except Exception: - pass + merge_info = store.merge_memories(a_id, b_id) + except ValueError as e: + raise HTTPException(400, str(e)) + if not merge_info.get("merged"): + # One of the sides was already gone; nothing to merge. + result["merged"] = False + result["loser"] = merge_info.get("lost") + if merge_info.get("reason") == "neither_exists": + raise HTTPException(404, "neither memory exists") + else: + result["merged"] = True + result["winner"] = merge_info["kept"] + result["loser"] = merge_info["lost"] + result["appended"] = merge_info.get("appended", False) + result["new_length"] = merge_info.get("new_length", 0) + try: + store.record_signal(merge_info["kept"], positive=True) + except Exception: + pass elif action in ("keepa", "keepb"): loser = b_id if action == "keepa" else a_id store.delete_memory(loser) diff --git a/loop_memory/serve/static/i18n/en.json b/loop_memory/serve/static/i18n/en.json index 7c84e28..48486d1 100644 --- a/loop_memory/serve/static/i18n/en.json +++ b/loop_memory/serve/static/i18n/en.json @@ -207,9 +207,12 @@ "dash.pulse.peak": "Peak range", "dash.pulse.records": "records", "dash.pulse.resolved": "Resolved", - "dash.pulse.resolvedDel": "Removed 1 duplicate · kept the other side", + "dash.pulse.resolvedDel": "Removed 1 · kept the other side", "dash.pulse.resolvedErr": "Could not resolve: ", "dash.pulse.resolvedIgnore": "Pair hidden — will not resurface", + "dash.pulse.resolvedMerge": "Merged both memories into one", + "dash.pulse.resolvedMergeAppended": "Merged · combined length {length}", + "dash.pulse.resolvedMergeOneGone": "Merged · one side no longer exists", "dash.pulse.sim": "similarity", "dash.pulse.sub": "detect & resolve", "dash.pulse.title": "Self-improvement Pulse", diff --git a/loop_memory/serve/static/i18n/zh.json b/loop_memory/serve/static/i18n/zh.json index 66105ed..327224d 100644 --- a/loop_memory/serve/static/i18n/zh.json +++ b/loop_memory/serve/static/i18n/zh.json @@ -207,9 +207,12 @@ "dash.pulse.peak": "峰值区间", "dash.pulse.records": "条", "dash.pulse.resolved": "已处理", - "dash.pulse.resolvedDel": "已删除 1 条重复 · 保留另一条", + "dash.pulse.resolvedDel": "已删除 1 条 · 保留另一条", "dash.pulse.resolvedErr": "处理失败:", "dash.pulse.resolvedIgnore": "已隐藏该组 · 后续不再出现", + "dash.pulse.resolvedMerge": "已合并两条记忆", + "dash.pulse.resolvedMergeAppended": "已合并 · 新文本长度 {length}", + "dash.pulse.resolvedMergeOneGone": "已合并 · 一条记录已不存在", "dash.pulse.sim": "相似度", "dash.pulse.sub": "检测 & 解决", "dash.pulse.title": "自适应面板", diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js index 5633aee..8227c06 100644 --- a/loop_memory/serve/static/js/components/Dashboard.js +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -235,7 +235,29 @@ export const Dashboard = defineComponent({ insights.value.pulse.contradictions = insights.value.pulse.contradictions .filter(item => contradictionKey(item) !== contradictionKey(pair)); } - toast(action === 'ignore' ? t('dash.pulse.resolvedIgnore') : t('dash.pulse.resolvedDel'), 2400); + const payload = await res.json().catch(() => ({})); + let toastKey = 'dash.pulse.resolvedDel'; + let toastVars = undefined; + if (action === 'ignore') { + toastKey = 'dash.pulse.resolvedIgnore'; + } else if (action === 'merge') { + if (payload && payload.merged) { + // Show a 'merged both into one' message; if the loser's text was + // actually appended (rather than being a substring already + // present in the winner), include the new total length. + toastKey = payload.appended + ? 'dash.pulse.resolvedMergeAppended' + : 'dash.pulse.resolvedMerge'; + if (payload.appended) { + toastVars = { length: fmtNum(payload.new_length || 0) }; + } + } else { + // One side was already missing — we still cleaned up. + toastKey = 'dash.pulse.resolvedMergeOneGone'; + } + } + // keepA / keepB still delete the loser side + toast(toastVars ? t(toastKey, toastVars) : t(toastKey), 2400); void refresh(); } catch (e) { toast(t('dash.pulse.resolvedErr') + (e?.message || 'resolve failed'), 3200); diff --git a/loop_memory/storage/sqlite_store.py b/loop_memory/storage/sqlite_store.py index d7a0366..f49a2b9 100644 --- a/loop_memory/storage/sqlite_store.py +++ b/loop_memory/storage/sqlite_store.py @@ -1363,6 +1363,95 @@ def delete_memory(self, mid: str) -> int: cur = c.execute("DELETE FROM memories WHERE id=?", (mid,)) return cur.rowcount + def merge_memories(self, a_id: str, b_id: str) -> dict: + """True memory-pair merge. + + Behaviour: + - The higher-scored memory wins (ties go to ``a_id``). + - The loser's text is appended to the winner's text (de-duplicated + if the loser's text is already a substring of the winner's). + - The winner's ``importance`` and ``score`` are bumped to the max + of the two so the fused memory keeps the strongest signal. + - The loser is deleted in the same transaction. + - The pair is recorded in ``contradiction_ignored`` so the pulse + does not surface it again. + + Returns a small dict describing what changed so the API layer can + report it back to the UI (UI then shows a 'merged' toast). + """ + a_id = str(a_id or "") + b_id = str(b_id or "") + if not a_id or not b_id or a_id == b_id: + raise ValueError("merge_memories needs two distinct ids") + now = time.time() + with self._conn() as c: + a_row = c.execute( + "SELECT id, text, importance, score FROM memories WHERE id=?", + (a_id,), + ).fetchone() + b_row = c.execute( + "SELECT id, text, importance, score FROM memories WHERE id=?", + (b_id,), + ).fetchone() + if a_row is None and b_row is None: + return {"merged": False, "reason": "neither_exists"} + if a_row is None: + # Only B exists — silently delete the missing A and keep B. + c.execute("DELETE FROM memories WHERE id=?", (a_id,)) + return {"merged": False, "kept": b_id, "lost": a_id, "reason": "a_missing"} + if b_row is None: + c.execute("DELETE FROM memories WHERE id=?", (b_id,)) + return {"merged": False, "kept": a_id, "lost": b_id, "reason": "b_missing"} + + # Pick winner = higher score; ties go to a_id. + a_score = a_row["score"] or 0.0 + b_score = b_row["score"] or 0.0 + winner_is_a = a_score >= b_score + winner_id = a_id if winner_is_a else b_id + loser_id = b_id if winner_is_a else a_id + winner_text = (a_row["text"] if winner_is_a else b_row["text"]) or "" + loser_text = (b_row["text"] if winner_is_a else a_row["text"]) or "" + importance_max = max(a_row["importance"] or 0.0, b_row["importance"] or 0.0) + score_max = max(a_score, b_score) + + # Decide whether the loser's content needs to be appended. If + # the winner already contains it (string containment is fine for + # the plain-text payloads we have here), skip the append. + needs_append = bool(loser_text.strip()) and loser_text.strip() not in winner_text + if needs_append: + # Triple-dash rule marks the boundary between the two + # original sources of a merged memory. + sep = "\n\n---\n\n" + merged_text = winner_text.rstrip() + sep + loser_text.strip() + else: + merged_text = winner_text + + c.execute( + "UPDATE memories " + "SET text=?, importance=?, score=?, updated_at=? " + "WHERE id=?", + (merged_text, importance_max, score_max, now, winner_id), + ) + c.execute("DELETE FROM memories WHERE id=?", (loser_id,)) + + # Suppress the pair so the pulse does not resurface it. + lo, hi = sorted([a_id, b_id]) + key = f"{lo}|{hi}" + c.execute( + "INSERT INTO contradiction_ignored(pair_key, ignored_at) VALUES(?, ?) " + "ON CONFLICT(pair_key) DO NOTHING", + (key, now), + ) + + return { + "merged": True, + "kept": winner_id, + "lost": loser_id, + "appended": needs_append, + "winner_was_a": winner_is_a, + "new_length": len(merged_text), + } + def delete_session(self, session_id: str) -> int: with self._conn() as c: cur = c.execute("DELETE FROM memories WHERE session_id=?", (session_id,)) diff --git a/tests/test_contradictions.py b/tests/test_contradictions.py index c62833e..2172259 100644 --- a/tests/test_contradictions.py +++ b/tests/test_contradictions.py @@ -72,22 +72,88 @@ def test_resolve_endpoint_keepA_deletes_B(self): self.assertIsNone(self.store.get_memory(self.b.id)) self.assertTrue(self.store.is_contradiction_ignored(self.a.id, self.b.id)) - def test_resolve_endpoint_merge_keeps_higher_scored(self): + def test_resolve_endpoint_merge_fuses_text_into_winner(self): + """``merge`` should fuse the two memories into one: loser's text is + appended onto the winner (with a separator), score/importance are + bumped to the max, and the loser is deleted. The pair must also + be marked ignored so it does not resurface in the pulse.""" from fastapi.testclient import TestClient from loop_memory.serve.app import create_app - # Force A to have higher score than B so the merge keeps A. + # Force A (winner) higher than B (loser) on score. with self.store._conn() as conn: conn.execute("UPDATE memories SET score=? WHERE id=?", (0.95, self.a.id)) conn.execute("UPDATE memories SET score=? WHERE id=?", (0.20, self.b.id)) + conn.execute("UPDATE memories SET importance=? WHERE id=?", (0.55, self.a.id)) + conn.execute("UPDATE memories SET importance=? WHERE id=?", (0.40, self.b.id)) app = create_app(self.store) with TestClient(app) as c: r = c.post(f"/api/contradictions/resolve?a={self.a.id}&b={self.b.id}&action=merge") self.assertEqual(r.status_code, 200, r.text) data = r.json() + # Action reports the new semantics, not the old 'deleted' array. self.assertEqual(data["action"], "merge") - self.assertEqual(len(data["deleted"]), 1) - self.assertEqual(data["deleted"][0]["kept"], self.a.id) - self.assertEqual(data["deleted"][0]["id"], self.b.id) + self.assertTrue(data.get("merged")) + self.assertEqual(data["winner"], self.a.id) + self.assertEqual(data["loser"], self.b.id) + self.assertTrue(data["appended"]) + + # Winner still exists, now with appended loser's text. + winner = self.store.get_memory(self.a.id) + self.assertIsNotNone(winner) + a_text = winner.text + # The winner's original text + the separator + loser's text. + self.assertIn(self.b.text, a_text) + self.assertIn("---", a_text) + # Loser is gone. + self.assertIsNone(self.store.get_memory(self.b.id)) + # Importance / score are the max of the pair. + self.assertAlmostEqual(winner.importance, 0.55, places=4) + self.assertAlmostEqual(winner.score, 0.95, places=4) + # The pair is now hidden so the pulse will not resurface it. + self.assertTrue(self.store.is_contradiction_ignored(self.a.id, self.b.id)) + + def test_resolve_endpoint_merge_skips_append_when_subset(self): + """If the loser's text is already contained in the winner's, the + merge must not duplicate text; it should still delete the loser.""" + from fastapi.testclient import TestClient + from loop_memory.serve.app import create_app + # Make B's text a strict substring of A's so the merge is a no-op + # for content even though it still deletes the loser row. + with self.store._conn() as conn: + conn.execute( + "UPDATE memories SET score=?, text=? WHERE id=?", + (0.9, "User preferences collected so far: " + self.b.text, self.a.id), + ) + conn.execute( + "UPDATE memories SET score=? WHERE id=?", + (0.1, self.b.id), + ) + app = create_app(self.store) + with TestClient(app) as c: + r = c.post(f"/api/contradictions/resolve?a={self.a.id}&b={self.b.id}&action=merge") + self.assertEqual(r.status_code, 200, r.text) + data = r.json() + self.assertTrue(data["merged"]) + self.assertFalse(data["appended"]) + winner = self.store.get_memory(self.a.id) + self.assertIsNotNone(winner) + self.assertEqual(winner.text.count("---"), 0) + self.assertIsNone(self.store.get_memory(self.b.id)) + + def test_resolve_endpoint_merge_tie_keeps_a(self): + """Ties should resolve to side A; new semantics still report winner.""" + from fastapi.testclient import TestClient + from loop_memory.serve.app import create_app + with self.store._conn() as conn: + conn.execute("UPDATE memories SET score=? WHERE id=?", (0.5, self.a.id)) + conn.execute("UPDATE memories SET score=? WHERE id=?", (0.5, self.b.id)) + app = create_app(self.store) + with TestClient(app) as c: + r = c.post(f"/api/contradictions/resolve?a={self.a.id}&b={self.b.id}&action=merge") + self.assertEqual(r.status_code, 200, r.text) + data = r.json() + self.assertEqual(data["winner"], self.a.id) + self.assertEqual(data["loser"], self.b.id) def test_resolve_endpoint_ignore_keeps_both_but_hides_pair(self): from fastapi.testclient import TestClient From 649ec26bb0a87d25786e10e2576516aa937089b5 Mon Sep 17 00:00:00 2001 From: Loop Memory Date: Sat, 18 Jul 2026 20:32:57 +0800 Subject: [PATCH 14/88] =?UTF-8?q?Fix=20=E5=AF=BC=E5=85=A5=20+=20every=20To?= =?UTF-8?q?pBar=20action;=20redraw=20sidebar;=20session-click=20filters=20?= =?UTF-8?q?timeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-visible bugs fixed: - Topbar '导入' button did nothing: frontend posted to /api/ingest with source=manual, which 404s (the real route is /api/admin/ingest) and 'manual' is not a registered loader (loaders are codex/claude/hermes/openclaw). The action now opens a dedicated IngestPopover with a checkbox per loader; user can tick one or many and 'Ingest now' runs the per-source requests and shows the result per row. - Same frontend/backend mismatch for rescore (/api/rescore vs /api/admin/rescore) and rebuildGraph (/api/graph/rebuild vs /api/admin/graph/rebuild). Both now go to the right URL. The stale /api/health alias is also replaced with /api/diag. - Sidebar looked cluttered and unreadable: rewritten with source pills (All / Codex / OpenClaw / Claude / Hermes) at the top, per-session cards using a colored stripe + glyph icon, timeAgo reading ended_at (the field the API actually returns) instead of the missing last_seen, friendlier empty state, raised limit from 100 to 300 so Codex sessions are not hidden by heavier OpenClaw traffic, auto-refresh on the ingest event plus a 30s poller so new sessions show up without a manual reload. - Clicking a sidebar session set activeSession but nothing read it, so the timeline ignored the user's choice. Timeline now watches store.activeSession and re-fetches with session_id; renders an accent scope banner with the session's title and a clear button; the recall path also respects the scope. API / backend: - /api/memories accepts session_id; works alongside source / kind / score / date range filters. - /api/admin/ingest documented in popover tooltips. UI / i18n: - New IngestPopover component (Vue SFC inside the JS file) with per-source checkboxes, per-row result chip, run button, progress text. Goes under tb-ingest-trigger which now has a caret so the dropdown affordance is obvious. - Timeline cards now expose .kind-lbl, .polish-spark, .dot-sep, .score-val, and a header summary chip + time range ('200 条记忆 · 2026/6/24 22:02:06 → 2026/7/18 20:24:51'). - zh/en i18n gain action.ingest* keys, sidebar.turns / filteringBySession / clearFilter / emptyHint, timeline.memories / sessionScope / clearScope / emptyForSession. 226 tests still pass; ruff clean. --- loop_memory/serve/app.py | 8 + loop_memory/serve/static/css/app.css | 185 ++++++++++++++++-- loop_memory/serve/static/css/layout.css | 146 +++++++++++++- loop_memory/serve/static/i18n/en.json | 19 +- loop_memory/serve/static/i18n/zh.json | 19 +- loop_memory/serve/static/js/api.js | 8 +- .../static/js/components/IngestPopover.js | 119 +++++++++++ .../serve/static/js/components/Sidebar.js | 181 ++++++++++++++--- .../serve/static/js/components/Timeline.js | 82 ++++++-- .../serve/static/js/components/TopBar.js | 30 ++- 10 files changed, 718 insertions(+), 79 deletions(-) create mode 100644 loop_memory/serve/static/js/components/IngestPopover.js diff --git a/loop_memory/serve/app.py b/loop_memory/serve/app.py index c744df0..7a5738a 100644 --- a/loop_memory/serve/app.py +++ b/loop_memory/serve/app.py @@ -1057,6 +1057,7 @@ def session_memories(session_id: str, limit: int = 1000): @app.get("/api/memories") def memories( source: str | None = None, + session_id: str | None = None, kind: str | None = None, min_score: float | None = None, since: float | None = None, @@ -1077,6 +1078,7 @@ def memories( rows = rows[:limit] else: rows = store.list_memories( + session_id=session_id, kind=kind, min_score=min_score, since=since, @@ -1084,6 +1086,12 @@ def memories( query=q, limit=limit, ) + # If a session_id was passed, drop the source-coded full scan and + # apply the session filter on top of either branch (catches the + # ``source=`` branch where list_memories was called without + # session_id so the filter didn't propagate). + if session_id: + rows = [r for r in rows if r.session_id == session_id] return [_memory_to_dict(m) for m in rows] @app.post("/api/memories/{mid}/feedback") diff --git a/loop_memory/serve/static/css/app.css b/loop_memory/serve/static/css/app.css index c4d89e8..5b1a0e4 100644 --- a/loop_memory/serve/static/css/app.css +++ b/loop_memory/serve/static/css/app.css @@ -806,36 +806,167 @@ .sidebar-head h2 { font-size: 11px; text-transform: uppercase; letter-spacing: 1.2px; color: var(--text-mute); margin: 0; font-weight: 700; + flex: 1; + } + .sidebar-clear { + width: 22px; height: 22px; + border-radius: 6px; + border: 0; + background: transparent; + color: var(--text-faint); + font-size: 14px; + cursor: pointer; + } + .sidebar-clear:hover { background: var(--surface-2); color: var(--text); } + .source-pills { + display: flex; flex-wrap: wrap; gap: 5px; + padding: 8px 12px 6px; + border-bottom: 1px solid var(--border); + background: var(--surface); + position: sticky; top: 39px; z-index: 1; + } + .src-pill { + --tone: #475569; + display: inline-flex; align-items: center; gap: 4px; + padding: 4px 9px 4px 6px; + border-radius: 999px; + background: transparent; + color: var(--text-mute); + border: 1px solid var(--border); + font-size: 11px; + font-weight: 650; + cursor: pointer; + transition: background .15s ease, color .15s ease, border-color .15s ease; + } + .src-pill .glyph { + width: 18px; height: 18px; + display: inline-grid; place-items: center; + border-radius: 999px; + background: color-mix(in srgb, var(--tone) 14%, transparent); + color: var(--tone); + font-size: 12px; font-weight: 700; + } + .src-pill .cnt { + font-size: 10px; line-height: 1; + padding: 2px 5px; + border-radius: 999px; + background: color-mix(in srgb, var(--tone) 12%, transparent); + color: color-mix(in srgb, var(--tone) 90%, var(--text)); + font-variant-numeric: tabular-nums; + } + .src-pill:hover { background: color-mix(in srgb, var(--tone) 8%, transparent); border-color: color-mix(in srgb, var(--tone) 35%, var(--border)); } + .src-pill.active { + background: color-mix(in srgb, var(--tone) 14%, transparent); + color: color-mix(in srgb, var(--tone) 90%, var(--text)); + border-color: color-mix(in srgb, var(--tone) 45%, var(--border)); + } + .src-pill.active .glyph { + background: var(--tone); + color: white; + } + .active-banner { + margin: 8px 12px 6px; + padding: 7px 11px; + display: flex; align-items: center; gap: 8px; + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: color-mix(in srgb, var(--accent) 90%, var(--text)); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); + border-radius: 10px; + font-size: 11.5px; + } + .active-banner .dot { + width: 7px; height: 7px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 25%, transparent); + } + .active-banner .lbl { flex: 1; } + .active-banner .x { + width: 22px; height: 22px; + border: 0; background: transparent; + color: var(--text-faint); + border-radius: 6px; + font-size: 14px; + cursor: pointer; } + .active-banner .x:hover { background: var(--surface-2); } .session { - padding: 11px 12px; - border-radius: 8px; - margin: 2px 0; + --tone: #64748b; + width: 100%; + text-align: left; + padding: 9px 10px 9px 14px; + margin: 2px 8px; + border-radius: 9px; cursor: pointer; - display: flex; flex-direction: column; gap: 5px; - transition: background 80ms ease; + display: grid; + grid-template-columns: 22px 1fr; + gap: 9px; + align-items: center; + background: transparent; border: 1px solid transparent; + color: inherit; + transition: background .15s ease, border-color .15s ease, transform .12s ease; + position: relative; + overflow: hidden; } - .session:hover { background: var(--surface-2); } + .session:hover { background: var(--surface-2); border-color: var(--border); } .session.active { - background: var(--accent-soft); - border-color: color-mix(in srgb, var(--accent) 25%, transparent); + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent); + } + .session .src-stripe { + position: absolute; + left: 0; top: 6px; bottom: 6px; + width: 3px; + border-radius: 3px; + background: var(--tone); + } + .session .src-glyph { + width: 26px; height: 26px; + display: inline-grid; place-items: center; + border-radius: 8px; + background: color-mix(in srgb, var(--tone) 14%, transparent); + color: var(--tone); + font-size: 13px; + font-weight: 700; + } + .session.active .src-glyph { + background: var(--tone); + color: white; } - .session .row { display: flex; align-items: center; gap: 6px; } + .session .meta { min-width: 0; } .session .title { font-weight: 600; - font-size: 13px; + font-size: 12.5px; line-height: 1.35; color: var(--text); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; + word-break: break-word; } - .session .meta { - font-size: 11px; color: var(--text-faint); - display: flex; gap: 8px; align-items: center; + .session .row { + display: flex; align-items: center; gap: 5px; + font-size: 10.5px; + color: var(--text-faint); + margin-top: 2px; } + .session .src-name { + text-transform: uppercase; + letter-spacing: .5px; + font-weight: 700; + font-size: 9.5px; + color: color-mix(in srgb, var(--tone) 85%, var(--text)); + } + .session .dot-sep { opacity: .5; } + .session .ago { font-variant-numeric: tabular-nums; } + .session[data-source="codex"] { --tone: #10b981; } + .session[data-source="claude"] { --tone: #8b5cf6; } + .session[data-source="hermes"] { --tone: #ec4899; } + .session[data-source="openclaw"]{ --tone: #f59e0b; } + .session[data-source="codex-desktop-thread-2026-07-10"] { --tone: #64748b; } .src-chip { display: inline-flex; align-items: center; gap: 4px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; @@ -962,10 +1093,36 @@ display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-faint); margin-bottom: 6px; } + .bubble .head { color: var(--text-mute); } + .bubble .head .kind-lbl { + color: var(--text); + font-weight: 650; + font-size: 11.5px; + text-transform: lowercase; + letter-spacing: .01em; + } + .bubble .head .polish-spark { + display: inline-flex; align-items: center; gap: 2px; + padding: 1px 7px; + border-radius: 999px; + background: color-mix(in srgb, #f59e0b 18%, transparent); + color: #b45309; + font-size: 10px; + font-weight: 700; + border: 1px solid color-mix(in srgb, #f59e0b 35%, transparent); + } + [data-theme="dark"] .bubble .head .polish-spark { color: #fbbf24; } + .bubble .head .dot-sep { opacity: .4; } + .bubble .head .score-val { + font-variant-numeric: tabular-nums; + font-weight: 700; + color: var(--text); + font-size: 11.5px; + } .bubble .kind-icon { width: 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; - font-size: 11px; font-weight: 700; + font-size: 12px; font-weight: 700; background: var(--surface-2); color: var(--text-mute); border: 1px solid var(--border); } diff --git a/loop_memory/serve/static/css/layout.css b/loop_memory/serve/static/css/layout.css index 78686a9..10fe86c 100644 --- a/loop_memory/serve/static/css/layout.css +++ b/loop_memory/serve/static/css/layout.css @@ -252,8 +252,8 @@ html, body, #app { height: 100%; } border-color: transparent; box-shadow: 0 3px 10px rgba(249, 115, 22, .18); } -.topbar .tb-tools { position: relative; } -.topbar .tb-tools-trigger { height: 36px; padding-inline: 11px 8px; } +.topbar .tb-tools, .topbar .tb-ingest { position: relative; display: inline-flex; } +.topbar .tb-tools-trigger, .topbar .tb-ingest-trigger { height: 36px; padding-inline: 11px 8px; } .topbar .tb-tools-trigger .caret { width: 13px; height: 13px; margin-left: -2px; transition: transform .18s ease; } .topbar .tb-tools-trigger.active .caret { transform: rotate(180deg); } .topbar .tb-tools-menu { @@ -303,6 +303,88 @@ html, body, #app { height: 100%; } .topbar .tb-tools-item > span { display: grid; gap: 2px; min-width: 0; } .topbar .tb-tools-item b { font-size: 12.5px; font-weight: 650; color: var(--text); } .topbar .tb-tools-item small { font-size: 10.5px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* --- Import popover --- */ +.topbar .tb-ingest-trigger .caret { width: 13px; height: 13px; margin-left: -2px; transition: transform .18s ease; } +.topbar .tb-ingest-trigger.active .caret { transform: rotate(180deg); } +.topbar .tb-ingest-menu { + position: absolute; + top: 43px; + left: 0; + z-index: 60; + display: grid; + gap: 4px; + width: 320px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 14px; + background: color-mix(in srgb, var(--surface) 97%, transparent); + box-shadow: 0 18px 45px rgba(15, 23, 42, .16); + backdrop-filter: blur(18px); +} +[data-theme="dark"] .topbar .tb-ingest-menu { box-shadow: 0 18px 45px rgba(0, 0, 0, .42); } +.topbar .tb-ingest-menu .ingest-head { + display: flex; align-items: center; justify-content: space-between; + padding: 2px 4px 6px; + border-bottom: 1px dashed var(--border); +} +.topbar .tb-ingest-menu .ingest-title { font-weight: 700; color: var(--text); } +.topbar .tb-ingest-menu .ingest-close { + width: 22px; height: 22px; border: 0; background: transparent; color: var(--text-faint); + border-radius: 6px; cursor: pointer; font-size: 16px; line-height: 1; +} +.topbar .tb-ingest-menu .ingest-close:hover { background: var(--surface-2); color: var(--text); } +.topbar .tb-ingest-menu .ingest-sub { font-size: 11px; color: var(--text-faint); padding: 0 4px 6px; } +.topbar .tb-ingest-menu .ingest-list { display: grid; gap: 2px; } +.topbar .tb-ingest-menu .ingest-row { + display: grid; grid-template-columns: 18px 22px 1fr auto auto; + align-items: center; gap: 8px; + padding: 8px 10px; + border-radius: 9px; + cursor: pointer; + transition: background .15s ease, opacity .15s ease; +} +.topbar .tb-ingest-menu .ingest-row:hover { background: var(--surface-2); } +.topbar .tb-ingest-menu .ingest-row.dim { opacity: .55; } +.topbar .tb-ingest-menu .ingest-row input { accent-color: var(--accent); width: 16px; height: 16px; } +.topbar .tb-ingest-menu .ingest-ico { color: var(--accent); font-weight: 700; font-size: 14px; text-align: center; } +.topbar .tb-ingest-menu .ingest-name { font-size: 12.5px; font-weight: 650; color: var(--text); } +.topbar .tb-ingest-menu .ingest-path { font-size: 10.5px; color: var(--text-faint); font-family: ui-monospace, Menlo, Consolas, monospace; white-space: nowrap; max-width: 130px; overflow: hidden; text-overflow: ellipsis; } +.topbar .tb-ingest-menu .ingest-status { + width: 22px; height: 22px; + display: inline-grid; place-items: center; + border-radius: 999px; + font-size: 11px; font-weight: 700; +} +.topbar .tb-ingest-menu .ingest-status.ok { background: color-mix(in srgb, #10b981 22%, transparent); color: #10b981; } +.topbar .tb-ingest-menu .ingest-status.err { background: color-mix(in srgb, #f43f5e 22%, transparent); color: #f43f5e; } +.topbar .tb-ingest-menu .ingest-bar { + display: flex; gap: 6px; align-items: center; + padding-top: 8px; border-top: 1px dashed var(--border); +} +.topbar .tb-ingest-menu .ingest-mini { + padding: 5px 9px; + border: 1px solid var(--border); + border-radius: 7px; + background: transparent; + color: var(--text-mute); + font-size: 11px; + cursor: pointer; +} +.topbar .tb-ingest-menu .ingest-mini:hover { background: var(--surface-2); } +.topbar .tb-ingest-menu .ingest-mini:disabled { opacity: .55; cursor: not-allowed; } +.topbar .tb-ingest-menu .ingest-run { + flex: 1; + padding: 7px 12px; + border: 0; + border-radius: 8px; + background: var(--accent); + color: white; + font-size: 12.5px; font-weight: 700; + cursor: pointer; + letter-spacing: .02em; +} +.topbar .tb-ingest-menu .ingest-run:disabled { background: color-mix(in srgb, var(--accent) 50%, var(--surface-2)); cursor: not-allowed; } .topbar .tb-utility-group { display: inline-flex; align-items: center; @@ -332,6 +414,63 @@ html, body, #app { height: 100%; } } .tl-list { display: flex; flex-direction: column; gap: 10px; } +.tl-summary { + display: flex; align-items: center; gap: 8px; + padding: 6px 0 4px; + font-size: 11px; + color: var(--text-faint); +} +.tl-summary .count-pill { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 9px; + background: color-mix(in srgb, var(--accent) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); + color: color-mix(in srgb, var(--accent) 90%, var(--text)); + border-radius: 999px; + font-variant-numeric: tabular-nums; + font-weight: 700; +} +.tl-summary .range { font-size: 10.5px; opacity: .8; } +.tl-scope-banner { + display: flex; align-items: center; gap: 8px; + margin: 4px 0 8px; + padding: 8px 12px; + border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); + color: color-mix(in srgb, var(--accent) 90%, var(--text)); + font-size: 12px; +} +.tl-scope-banner .dot { + width: 8px; height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); + flex-shrink: 0; +} +.tl-scope-banner .lbl { flex: 1; display: flex; align-items: center; gap: 6px; min-width: 0; } +.tl-scope-banner .lbl b { font-weight: 700; flex-shrink: 0; } +.tl-scope-banner .src-tag { + font-size: 10px; font-weight: 700; + padding: 1px 7px; border-radius: 999px; + text-transform: uppercase; letter-spacing: .5px; + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: var(--accent); + flex-shrink: 0; +} +.tl-scope-banner .title { + flex: 1; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--text); +} +.tl-scope-banner .x { + width: 22px; height: 22px; + border: 0; background: transparent; + color: var(--text-faint); + border-radius: 6px; + font-size: 14px; cursor: pointer; +} +.tl-scope-banner .x:hover { background: var(--surface-2); } .tl-card, .wiki-card { background: var(--surface); border: 1px solid var(--border); @@ -389,6 +528,9 @@ html, body, #app { height: 100%; } .recall-hint { background: var(--accent-soft); padding: 2px 8px; border-radius: 4px; } .empty, .loading { padding: 40px 20px; text-align: center; color: var(--text-faint); font-size: 13px; } +.empty .empty-icon { font-size: 28px; margin-bottom: 8px; opacity: .55; } +.empty .empty-text { font-size: 13px; font-weight: 600; color: var(--text-mute); } +.empty .empty-hint { font-size: 11.5px; margin-top: 4px; opacity: .8; } /* Graph */ .graph-toolbar { display: flex; align-items: center; gap: 8px; padding: 8px 0; } diff --git a/loop_memory/serve/static/i18n/en.json b/loop_memory/serve/static/i18n/en.json index 48486d1..f677428 100644 --- a/loop_memory/serve/static/i18n/en.json +++ b/loop_memory/serve/static/i18n/en.json @@ -9,7 +9,7 @@ "action.edit": "Edit", "action.ingest": "Ingest", "action.ingestStarted": "Ingested {n} memories", - "action.ingestTip": "Import local conversation transcripts", + "action.ingestTip": "Tick the conversation sources to import (multi-select)", "action.llmRun": "AI Consolidate", "action.llmRunQueued": "AI consolidation queued", "action.llmRunTip": "Run the AI consolidator once", @@ -536,5 +536,18 @@ "topbar.themeAuto": "Follow system", "topbar.themeLight": "Light mode", "topbar.themeDark": "Dark mode", - "common.close": "Close" -} + "common.close": "Close", + "action.ingestNoSource": "Pick at least one source first", + "action.ingestAll": "All", + "action.ingestNone": "None", + "action.ingestRun": "Ingest now", + "sidebar.turns": "msgs", + "sidebar.memoryCount": "Messages in this session", + "sidebar.emptyHint": "Click \"Import\" in the top-right to scan ~/.codex and ~/.openclaw.", + "sidebar.filteringBySession": "Showing only this session in the timeline.", + "sidebar.clearFilter": "Clear session filter", + "timeline.memories": "memories", + "timeline.sessionScope": "Viewing session:", + "timeline.clearScope": "Clear session filter, show all memories", + "timeline.emptyForSession": "No memories have been extracted for this session yet. Try running \"AI Run\" or re-importing." +} \ No newline at end of file diff --git a/loop_memory/serve/static/i18n/zh.json b/loop_memory/serve/static/i18n/zh.json index 327224d..64d05fc 100644 --- a/loop_memory/serve/static/i18n/zh.json +++ b/loop_memory/serve/static/i18n/zh.json @@ -9,7 +9,7 @@ "action.edit": "编辑", "action.ingest": "导入", "action.ingestStarted": "已采集 {n} 条记忆", - "action.ingestTip": "导入本地对话记录", + "action.ingestTip": "勾选要导入的对话来源(可多选)", "action.llmRun": "AI 整理", "action.llmRunQueued": "已加入 AI 整理队列", "action.llmRunTip": "执行一次 AI 记忆整理", @@ -536,5 +536,18 @@ "topbar.themeAuto": "跟随系统", "topbar.themeLight": "浅色模式", "topbar.themeDark": "深色模式", - "common.close": "关闭" -} + "common.close": "关闭", + "action.ingestNoSource": "请先选择至少一个数据源", + "action.ingestAll": "全选", + "action.ingestNone": "清空", + "action.ingestRun": "开始导入", + "sidebar.turns": "条", + "sidebar.memoryCount": "会话内的记忆条数", + "sidebar.emptyHint": "点击右上角\"导入\"扫描 ~/.codex 和 ~/.openclaw", + "sidebar.filteringBySession": "已选中该会话,时间线仅展示此会话的内容", + "sidebar.clearFilter": "清除会话筛选", + "timeline.memories": "条记忆", + "timeline.sessionScope": "正在查看会话:", + "timeline.clearScope": "清除会话筛选,查看全部记忆", + "timeline.emptyForSession": "这个会话暂未提取到记忆,尝试重新导入或者运行\"AI 整理\"。" +} \ No newline at end of file diff --git a/loop_memory/serve/static/js/api.js b/loop_memory/serve/static/js/api.js index 06789a3..4fbe27c 100644 --- a/loop_memory/serve/static/js/api.js +++ b/loop_memory/serve/static/js/api.js @@ -77,7 +77,7 @@ export class ApiError extends Error { /** Domain endpoints — short, named functions for clarity. */ export const api = { // Generic - health: () => fetchJSON('/api/health'), + diag: () => fetchJSON('/api/diag'), stats: () => fetchJSON('/api/stats'), // Memories @@ -111,9 +111,9 @@ export const api = { llmSchedule: (payload) => fetchJSON('/api/admin/llm/schedule', { method: 'POST', body: payload }), // Ingest / score - rescore: () => fetchJSON('/api/rescore', { method: 'POST' }), - rebuildGraph: () => fetchJSON('/api/graph/rebuild', { method: 'POST' }), - ingest: (payload) => fetchJSON('/api/ingest', { method: 'POST', body: payload }), + rescore: () => fetchJSON('/api/admin/rescore', { method: 'POST' }), + rebuildGraph: () => fetchJSON('/api/admin/graph/rebuild', { method: 'POST' }), + ingest: (source, path) => fetchJSON('/api/admin/ingest', { method: 'POST', params: { source, path } }), // Audit / runs llmAudit: () => fetchJSON('/api/llm-audit'), diff --git a/loop_memory/serve/static/js/components/IngestPopover.js b/loop_memory/serve/static/js/components/IngestPopover.js new file mode 100644 index 0000000..5f6a05a --- /dev/null +++ b/loop_memory/serve/static/js/components/IngestPopover.js @@ -0,0 +1,119 @@ +/** + * IngestPopover — choose which conversation sources to ingest. + * + * Why: the old TopBar 导入 button called /api/ingest with source=manual + * (404 + invalid source), so it never worked. This popover lets the user + * pick one or more of the registered loaders (codex / claude / hermes / + * openclaw) and runs them, surfacing per-source results. + */ +import { defineComponent, ref, computed } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, t, toast } from '../store.js'; +import { api } from '../api.js'; + +const SOURCES = [ + { id: 'codex', label: 'Codex', desc: '~/.codex/sessions', icon: '◧' }, + { id: 'openclaw', label: 'OpenClaw', desc: '~/.openclaw (clawx)', icon: '✜' }, + { id: 'claude', label: 'Claude', desc: '~/.claude', icon: '◎' }, + { id: 'hermes', label: 'Hermes', desc: '~/.hermes', icon: '⚒' }, +]; + +export const IngestPopover = defineComponent({ + name: 'IngestPopover', + emits: ['close'], + setup(_, { emit }) { + // Default selection: only sources present in the local filesystem. + const selected = ref(new Set()); + const running = ref(false); + const progress = ref(''); // human-readable status + const results = ref(null); // last batch results {source: {files, root, error}} + + function toggle(id) { + const s = new Set(selected.value); + if (s.has(id)) s.delete(id); else s.add(id); + selected.value = s; + } + function selectAll() { + selected.value = new Set(SOURCES.map(s => s.id)); + } + function clearSelection() { + selected.value = new Set(); + } + + function sourceDisabled(id) { + return running.value && !selected.value.has(id); + } + + async function runAll() { + if (running.value) return; + const ids = [...selected.value]; + if (!ids.length) { + toast(t('action.ingestNoSource') || '请先选择至少一个数据源', 2200); + return; + } + running.value = true; + results.value = null; + const out = {}; + let total = 0; + try { + for (const id of ids) { + progress.value = `正在导入 ${id}…`; + try { + const r = await api.ingest(id); + out[id] = r || { files: 0 }; + total += Number(r?.files || r?.ingested || 0); + } catch (e) { + out[id] = { error: e?.message || 'failed' }; + } + } + results.value = out; + progress.value = `导入完成 · ${total} 个会话`; + toast((t('action.ingestStarted', { n: total }) || `导入完成 · ${total} 个`), 2400); + // Sidebar refresh hook: emit a global event the Sidebar listens to. + window.dispatchEvent(new CustomEvent('loop-memory:ingest-done', { detail: out })); + store._refreshStats = (store._refreshStats || 0) + 1; + } finally { + running.value = false; + } + } + + return { + SOURCES, store, t, + selected, running, progress, results, + toggle, selectAll, clearSelection, runAll, + sourceDisabled, + }; + }, + template: /* html */ ` +
+
+
{{ t('action.ingest') }}
+ +
+
{{ t('action.ingestTip') }}
+ +
+ +
+ +
+ + + +
+
+ `, +}); diff --git a/loop_memory/serve/static/js/components/Sidebar.js b/loop_memory/serve/static/js/components/Sidebar.js index a2a1c33..7bc78cc 100644 --- a/loop_memory/serve/static/js/components/Sidebar.js +++ b/loop_memory/serve/static/js/components/Sidebar.js @@ -1,80 +1,199 @@ /** * Sidebar — left-rail session list. * - * In the legacy code the session list was hand-rolled HTML with manual - * diffing on every refresh. Here it's a Vue list bound to a reactive - * `sessions` array; Vue's virtual DOM handles the diff. + * Improvements over the previous version: + * - Source filter is a pill strip (Codex / Claude / Hermes / OpenClaw + * / All) instead of a dropdown; the count badge next to each pill + * tells you how many sessions are in that bucket. + * - Time labels use ended_at (which is what the API actually returns) + * instead of the missing `last_seen` field. + * - Limit raised from 100 → 300 so Codex sessions aren't hidden + * under heavier OpenClaw traffic. + * - Auto-refresh whenever the global ingest event fires so users see + * new Codex/OpenClaw sessions land without a manual reload. + * - Visual polish: rounded cards, source color stripe on the left, + * hollow badge for the "active" session, friendly empty state, + * inline clear filter on the active session. */ -import { defineComponent, ref, computed, onMounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; -import { store, t, timeAgo } from '../store.js'; +import { defineComponent, ref, computed, onMounted, onUnmounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { store, t, timeAgo, fmtTime } from '../store.js'; import { api } from '../api.js'; +const SOURCE_META = { + codex: { label: 'Codex', tone: '#10b981', glyph: '◧' }, + openclaw: { label: 'OpenClaw', tone: '#f59e0b', glyph: '✜' }, + claude: { label: 'Claude', tone: '#8b5cf6', glyph: '◎' }, + hermes: { label: 'Hermes', tone: '#ec4899', glyph: '⚒' }, + 'codex-desktop-thread-2026-07-10': { label: 'Codex (legacy)', tone: '#64748b', glyph: '◧' }, +}; + +function metaFor(source) { + return SOURCE_META[source] || { label: source || '—', tone: '#64748b', glyph: '·' }; +} + +function shortTitle(s) { + const t0 = (s.title || '').trim(); + if (t0) { + // Prefix from cron/job wrappers: "[cron:...] …" → strip the bracket. + return t0.replace(/^\[cron:[^\]]+\]\s*/, '').slice(0, 70); + } + return (s.id || '').slice(0, 14); +} + export const Sidebar = defineComponent({ name: 'Sidebar', setup() { const sessions = ref([]); + const counts = ref({}); // source -> { sessions, turns } const filter = ref('all'); const loading = ref(false); + const lastRefreshedAt = ref(0); async function refresh() { loading.value = true; try { - const params = filter.value && filter.value !== 'all' ? { source: filter.value } : {}; + const params = { limit: 300 }; + if (filter.value && filter.value !== 'all') params.source = filter.value; const data = await api.listSessions(params); sessions.value = Array.isArray(data) ? data : (data.sessions || []); } catch (e) { sessions.value = []; } finally { loading.value = false; + lastRefreshedAt.value = Date.now(); + } + } + + async function refreshCounts() { + try { + const c = await api.fetchJSON('/api/sessions/counts'); + counts.value = c && c.by_source ? c : { by_source: c || {} }; + } catch (e) { + // silent — counts are decorative } } - const sources = computed(() => { - const set = new Set(); + const sourcePills = computed(() => { + const seen = new Set(); + const list = []; + list.push({ id: 'all', label: t('sidebar.allSources'), tone: '#475569', glyph: '⊞', count: counts.value.all?.sessions }); for (const s of sessions.value) { - if (s.source) set.add(s.source); + if (!s.source || seen.has(s.source)) continue; + seen.add(s.source); + const meta = metaFor(s.source); + const c = (counts.value.by_source || {})[s.source]; + list.push({ + id: s.source, + label: meta.label, + tone: meta.tone, + glyph: meta.glyph, + count: c ? c.sessions : undefined, + }); + } + // Make sure Codex / Claude / Hermes / OpenClaw are visible even when + // no records are returned yet (otherwise the pill disappears and the + // user can't pre-select it). + for (const id of ['codex', 'openclaw', 'claude', 'hermes']) { + if (seen.has(id)) continue; + const meta = metaFor(id); + const c = (counts.value.by_source || {})[id]; + list.push({ + id, + label: meta.label, + tone: meta.tone, + glyph: meta.glyph, + count: c ? c.sessions : 0, + }); } - return Array.from(set).sort(); + return list; }); - onMounted(() => { + function onPickSource(id) { + filter.value = id; refresh(); - // Refresh when stats update (signals a new session may have arrived) - watch(() => store.stats.sessions, refresh); - }); + } + + function onClearSession() { + store.activeSession = ''; + refresh(); + } function onClickSession(s) { - store.activeSession = s.id; + store.activeSession = store.activeSession === s.id ? '' : s.id; } - return { sessions, filter, sources, loading, refresh, onClickSession, t, store, timeAgo }; + onMounted(() => { + refresh(); + refreshCounts(); + watch(() => store.stats.sessions, refresh); + // Refresh when IngestPopover reports new sessions were imported so the + // left list reflects them without a manual reload. + const onIngest = () => { refresh(); refreshCounts(); }; + window.addEventListener('loop-memory:ingest-done', onIngest); + // Periodic auto-refresh every 30s so new files dropped into the watch + // directory show up even when the user clicks nothing. + const poller = setInterval(() => { refresh(); refreshCounts(); }, 30_000); + onUnmounted(() => { + window.removeEventListener('loop-memory:ingest-done', onIngest); + clearInterval(poller); + }); + }); + + return { + sessions, counts, filter, loading, sourcePills, lastRefreshedAt, + onPickSource, onClickSession, onClearSession, + metaFor, shortTitle, timeAgo, fmtTime, t, store, + }; }, template: /* html */ ` `, diff --git a/loop_memory/serve/static/js/components/Timeline.js b/loop_memory/serve/static/js/components/Timeline.js index a72873c..35b11a3 100644 --- a/loop_memory/serve/static/js/components/Timeline.js +++ b/loop_memory/serve/static/js/components/Timeline.js @@ -8,8 +8,12 @@ * visual score-bar, importance %, tags, and a copy-to-clipboard * action button. * - Search uses /api/recall so Chinese tokenisation + importance - * ranking both apply. Filters (kind, score, date range) live in - * component-local state. + * ranking both apply. + * + * Sidebar integration: when the user picks a session in the left rail + * the timeline re-fetches with that session_id filter, and a banner at + * the top makes the active scope obvious. Clearing the filter (banner + * or sidebar) restores the all-session view. */ import { defineComponent, ref, computed, onMounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; import { store, t, escapeHtml, timeAgo, fmtTime, toast } from '../store.js'; @@ -38,29 +42,56 @@ export const Timeline = defineComponent({ const minScore = ref(0); const since = ref(''); const until = ref(''); + const activeSessionInfo = ref(null); // session label when filter is on async function refresh() { loading.value = true; recallMeta.value = null; try { let rows; + const sessionId = store.activeSession || ''; if (q.value.trim()) { - const r = await api.recall(q.value.trim(), 50); + // Recall API does its own ranking; we still keep the active + // session in scope if one is selected. + const r = await api.recall(q.value.trim(), 100); rows = (r.memories || []).map(m => ({ ...m, recall_score: m.score })); + if (sessionId) rows = rows.filter(m => m.session_id === sessionId); recallMeta.value = { wiki: r.wiki, entities: r.entities, tokens: r.tokens }; } else { - const data = await api.listMemories({ + const params = { kind: kind.value, min_score: minScore.value || undefined, since: since.value ? new Date(since.value).getTime() / 1000 : undefined, until: until.value ? new Date(until.value).getTime() / 1000 : undefined, + session_id: sessionId || undefined, limit: 200, - }); + }; + const data = await api.listMemories(params); rows = Array.isArray(data) ? data : (data.memories || data.items || []); } if (kind.value) rows = rows.filter(r => r.kind === kind.value); if (minScore.value) rows = rows.filter(r => (r.score || r.importance || 0) >= Number(minScore.value)); memories.value = rows; + + // Resolve the active session's display label via the sessions + // endpoint so the banner can show the original chat title. + if (sessionId) { + try { + const list = await api.listSessions({ limit: 500 }); + const arr = Array.isArray(list) ? list : (list.sessions || []); + const found = arr.find(s => s.id === sessionId); + if (found) { + const t = (found.title || '').replace(/^\[cron:[^\]]+\]\s*/, ''); + activeSessionInfo.value = { id: sessionId, label: (t || found.id).slice(0, 60), source: found.source, message_count: found.message_count }; + } else { + activeSessionInfo.value = { id: sessionId, label: sessionId.slice(0, 12), source: null }; + } + } catch (e) { + activeSessionInfo.value = { id: sessionId, label: sessionId.slice(0, 12) }; + } + } else { + activeSessionInfo.value = null; + } } catch (e) { memories.value = []; } finally { @@ -70,6 +101,12 @@ export const Timeline = defineComponent({ function resetFilters() { q.value = ''; kind.value = ''; minScore.value = 0; since.value = ''; until.value = ''; + store.activeSession = ''; + refresh(); + } + + function onClearSession() { + store.activeSession = ''; refresh(); } @@ -86,7 +123,6 @@ export const Timeline = defineComponent({ function kindIcon(k) { return KIND_ICON[k] || '·'; } function isPolished(m) { - // AI-distilled if the memory was rewritten after creation return m && m.updated_at && m.created_at && (m.updated_at - m.created_at) > 1; } @@ -102,12 +138,17 @@ export const Timeline = defineComponent({ } onMounted(() => refresh()); + // Re-fetch when the active session changes. + watch(() => store.activeSession, refresh); + // Re-fetch when a new memory is recorded. watch(() => store.stats.memories, refresh); return { store, t, memories, recallMeta, loading, q, kind, minScore, since, until, - refresh, resetFilters, onSearchSubmit, scoreFmt, kindIcon, isPolished, + activeSessionInfo, + refresh, resetFilters, onClearSession, onSearchSubmit, scoreFmt, kindIcon, isPolished, onCopy, onClickMemory, timeAgo, fmtTime, KIND_ICON, + escapeHtml, }; }, template: /* html */ ` @@ -129,6 +170,16 @@ export const Timeline = defineComponent({ +
+ + + {{ t('timeline.sessionScope') }} + {{ activeSessionInfo.source }} + {{ activeSessionInfo ? activeSessionInfo.label : store.activeSession.slice(0, 12) }} + + +
+
{{ t('recall.wikiMatches', { n: recallMeta.wiki.length }) }} @@ -138,6 +189,11 @@ export const Timeline = defineComponent({
+
+ {{ memories.length }} {{ t('timeline.memories') }} + {{ fmtTime(memories[memories.length-1].created_at) }} → {{ fmtTime(memories[0].created_at) }} +
+
{{ kindIcon(m.kind) }} - {{ t('kind.' + (m.kind || 'episode')) }} + {{ t('kind.' + (m.kind || 'episode')) }} ✨ {{ store.lang === 'zh' ? '已浓缩' : 'AI' }} - · + · {{ timeAgo(m.created_at) }} - · {{ m.source }} - {{ scoreFmt(m.score ?? m.importance) }} + {{ scoreFmt(m.score ?? m.importance) }}
{{ m.text }}
@@ -169,7 +224,10 @@ export const Timeline = defineComponent({
-
{{ t('timeline.empty') }}
+
+
+
{{ store.activeSession ? t('timeline.emptyForSession') : t('timeline.empty') }}
+
{{ t('common.loading') }}
diff --git a/loop_memory/serve/static/js/components/TopBar.js b/loop_memory/serve/static/js/components/TopBar.js index a6f686e..4acf23d 100644 --- a/loop_memory/serve/static/js/components/TopBar.js +++ b/loop_memory/serve/static/js/components/TopBar.js @@ -11,14 +11,17 @@ */ import { defineComponent, computed, ref, onMounted, onUnmounted } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; import { store, patchPrefs, toast, t, timeAgo, fmtTime } from '../store.js'; +import { IngestPopover } from './IngestPopover.js'; import { api, ApiError } from '../api.js'; export const TopBar = defineComponent({ name: 'TopBar', + components: { IngestPopover }, emits: ['ingest', 'rescore', 'llm-run', 'run-now', 'open-settings', 'open-stats', 'open-diag', 'rebuild-graph', 'consolidate'], setup(props, { emit }) { const statsOpen = ref(false); const toolsOpen = ref(false); + const ingestOpen = ref(false); const runLabel = computed(() => { if (store.runStatus && store.runStatus.is_running) { @@ -31,9 +34,10 @@ export const TopBar = defineComponent({ const runState = computed(() => store.runStatus && store.runStatus.is_running ? 'running' : 'idle'); - function toggleStats() { statsOpen.value = !statsOpen.value; toolsOpen.value = false; } - function toggleTools() { toolsOpen.value = !toolsOpen.value; statsOpen.value = false; } - function closeAll() { statsOpen.value = false; toolsOpen.value = false; } + function toggleStats() { statsOpen.value = !statsOpen.value; toolsOpen.value = false; ingestOpen.value = false; } + function toggleTools() { toolsOpen.value = !toolsOpen.value; statsOpen.value = false; ingestOpen.value = false; } + function toggleIngest() { ingestOpen.value = !ingestOpen.value; statsOpen.value = false; toolsOpen.value = false; } + function closeAll() { statsOpen.value = false; toolsOpen.value = false; ingestOpen.value = false; } function setLang(l) { patchPrefs({ lang: l }); closeAll(); } function setTheme(th) { patchPrefs({ theme: th }); closeAll(); } @@ -41,16 +45,17 @@ export const TopBar = defineComponent({ function onDocClick(e) { if (!e.target.closest('#stats-chip') && !e.target.closest('#stats-pop')) statsOpen.value = false; if (!e.target.closest('.tb-tools') && !e.target.closest('.tb-tools-menu')) toolsOpen.value = false; + if (!e.target.closest('.tb-ingest') && !e.target.closest('.tb-ingest-menu')) ingestOpen.value = false; } onMounted(() => document.addEventListener('click', onDocClick)); onUnmounted(() => document.removeEventListener('click', onDocClick)); return { store, t, runLabel, runState, - statsOpen, toolsOpen, - toggleStats, toggleTools, closeAll, + statsOpen, toolsOpen, ingestOpen, + toggleStats, toggleTools, toggleIngest, closeAll, setLang, setTheme, - onIngest: () => { closeAll(); emit('ingest'); }, + onIngest: () => { closeAll(); toggleIngest(); }, onRescore: () => { closeAll(); emit('rescore'); }, onLlmRun: () => { closeAll(); emit('llm-run'); }, onRunNow: () => { closeAll(); emit('run-now'); }, @@ -115,10 +120,15 @@ export const TopBar = defineComponent({
- +
+ + +
diff --git a/loop_memory/serve/static/js/components/TopBar.js b/loop_memory/serve/static/js/components/TopBar.js index 4acf23d..36241bd 100644 --- a/loop_memory/serve/static/js/components/TopBar.js +++ b/loop_memory/serve/static/js/components/TopBar.js @@ -69,7 +69,12 @@ export const TopBar = defineComponent({ template: /* html */ `
- + Loop Memory + Loop Memory
{{ t('app.title') }} {{ t('app.tagline') }} diff --git a/pyproject.toml b/pyproject.toml index 02c3222..adf2f26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,13 +5,13 @@ build-backend = "setuptools.build_meta" [project] name = "loop-memory" version = "0.3.0" -description = "A Loop Engineering memory system for large language models, with a local web UI and auto-ingest from Codex / Claude / Hermes transcripts." +description = "A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI." readme = "README.md" requires-python = ">=3.10" license = "MIT" license-files = ["LICENSE"] authors = [{ name = "Loop Memory contributors " }] -keywords = ["llm", "memory", "agent", "rag", "long-term-memory", "loop-engineering"] +keywords = ["llm", "memory", "agent", "agents", "rag", "long-term-memory", "memory-system", "codex", "claude", "hermes", "openclaw", "clawx"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", From 66a5ac1155aecd1bf5165f3d606556370a4c6a49 Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:07:35 +0800 Subject: [PATCH 16/88] Sidebar session click always jumps to timeline tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a session in the left rail used to only set the active-session filter (which the Timeline already respected), but kept the user on whichever tab was currently shown — so on Dashboard/Wiki/Graph users saw no immediate effect. The session click now also flips store.activeTab to 'timeline' so the right pane always opens to that session's fragment list. Tab↔URL sync moved into App.js (watch on store.activeTab) so any writer — Tab clicks, sidebar session picks, Open-wiki from graph — reaches the URL through one path. Removed the duplicate writer in Tabs.js. --- loop_memory/serve/static/js/App.js | 15 +++++++++++++++ loop_memory/serve/static/js/components/Sidebar.js | 5 +++++ loop_memory/serve/static/js/components/Tabs.js | 7 +++---- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/loop_memory/serve/static/js/App.js b/loop_memory/serve/static/js/App.js index 8b9f69a..d75ced7 100644 --- a/loop_memory/serve/static/js/App.js +++ b/loop_memory/serve/static/js/App.js @@ -78,6 +78,21 @@ export const App = defineComponent({ } catch (e) { /* ignore */ } } + /* Keep the URL bar in sync with whatever tab is active — both + * user-driven (Tabs clicks) and externally-driven (Sidebar session + * picks, Open-wiki from graph, deep-link boot) write to the URL + * through this single path. */ + watch(() => store.activeTab, (tab) => { + if (!tab) return; + try { + const url = new URL(window.location.href); + if (url.searchParams.get('tab') !== tab) { + url.searchParams.set('tab', tab); + window.history.replaceState({}, '', url.toString()); + } + } catch (e) { /* ignore */ } + }); + onMounted(async () => { applyTheme(); applyLang(); diff --git a/loop_memory/serve/static/js/components/Sidebar.js b/loop_memory/serve/static/js/components/Sidebar.js index 7bc78cc..bdc3b45 100644 --- a/loop_memory/serve/static/js/components/Sidebar.js +++ b/loop_memory/serve/static/js/components/Sidebar.js @@ -119,7 +119,12 @@ export const Sidebar = defineComponent({ } function onClickSession(s) { + // Clicking a session in the list should ALWAYS drop the user + // into the timeline tab — no matter what tab is currently shown + // on the right — so the conversation fragments for that session + // are immediately visible. store.activeSession = store.activeSession === s.id ? '' : s.id; + store.activeTab = 'timeline'; } onMounted(() => { diff --git a/loop_memory/serve/static/js/components/Tabs.js b/loop_memory/serve/static/js/components/Tabs.js index 21de624..b094706 100644 --- a/loop_memory/serve/static/js/components/Tabs.js +++ b/loop_memory/serve/static/js/components/Tabs.js @@ -19,11 +19,10 @@ export const Tabs = defineComponent({ ])); function setTab(id) { + // App.js owns the URL sync via a watcher on store.activeTab, + // so all writers (this component, Sidebar session picks, + // Open-wiki from graph) reach the URL through the same path. store.activeTab = id; - // Persist to URL so the view is bookmarkable - const url = new URL(window.location.href); - url.searchParams.set('tab', id); - window.history.replaceState({}, '', url.toString()); } return { tabs, store, setTab }; From 979ce063628f185b75bc92551ed19d0069597e02 Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:23:58 +0800 Subject: [PATCH 17/88] Make model entry visible; animate fit-to-window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UI fixes: 1. The model-config entry in the topbar had background:transparent and border:1px solid transparent, so it dissolved into the chrome and was unfindable. Replaced the inline text+lock with a proper two-line chip (icon + provider + status badge). The badge reflects api_key_set in real time: green 'API key 已设' or amber 'API key 缺失', so users get immediate visual confirmation that the key was saved (vs. the 'input cleared, looks unset' bug from earlier rounds). Clicking the chip still opens Settings. 2. Fit-to-window ('适配窗口') was firing correctly but produced a zero-pixel diff whenever the graph was already at optimum scale, so the click looked broken. Rewrote fit() to snapshot the current transform, compute the new fit, then tween scale/tx/ty over 260ms with an ease-out-cubic. Initial loads and window-resize still use instant fit (correct UX for those), only the manual button click animates. --- loop_memory/serve/static/css/app.css | 106 +++++++++++++----- .../static/js/components/KnowledgeGraph.js | 41 ++++++- .../serve/static/js/components/TopBar.js | 43 +++++-- 3 files changed, 156 insertions(+), 34 deletions(-) diff --git a/loop_memory/serve/static/css/app.css b/loop_memory/serve/static/css/app.css index be586ad..98d3011 100644 --- a/loop_memory/serve/static/css/app.css +++ b/loop_memory/serve/static/css/app.css @@ -175,38 +175,92 @@ .stats-pop hr { border: 0; border-top: 1px solid var(--border); margin: 6px 0; } /* Model chip (LLM provider state) */ + /* Model entry — visible button with provider + status. + The previous CSS had `background: transparent; border: 1px solid + transparent;` which made the chip dissolve into the topbar; the + user couldn't find the configure-model entry at all. */ .model-chip { - display: inline-flex; align-items: center; gap: 8px; - padding: 0 10px; height: 34px; - background: transparent; - border: 1px solid transparent; - border-radius: 9px; + display: inline-flex; align-items: center; gap: 10px; + padding: 0 12px 0 10px; + height: 38px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; font-size: 12px; - color: var(--text-mute); + color: var(--text); cursor: pointer; position: relative; - transition: background .15s ease, border-color .15s ease; + transition: background .15s ease, border-color .15s ease, transform .15s ease; + box-shadow: 0 1px 0 rgba(15,23,42,.04); } - .model-chip:hover { background: var(--surface-2); border-color: var(--border); } - .model-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } - .model-chip .dot { - width: 8px; height: 8px; border-radius: 50%; - background: #94a3b8; /* off */ - box-shadow: 0 0 0 2.5px color-mix(in srgb, currentColor 12%, transparent); - } - .model-chip[data-state="ok"] .dot { background: #10b981; box-shadow: 0 0 0 2.5px rgba(16,185,129,.18); } - .model-chip[data-state="warn"] .dot { background: #f59e0b; box-shadow: 0 0 0 2.5px rgba(245,158,11,.20); } - .model-chip[data-state="off"] .dot { background: #94ba3b8; box-shadow: 0 0 0 2.5px rgba(148,163,184,.18); } - .model-chip .model-name { font-weight: 600; color: var(--text); max-width: 110px; overflow: hidden; text-transform: none; white-space: nowrap; text-overflow: ellipsis; } - .model-chip .chev { opacity: .55; } - .model-chip .caret { width: 14px; height: 14px; opacity: .6; } - /* Lock icon — appears only when an API key is configured. */ - .model-chip .lock { - width: 13px; height: 13px; color: #10b981; - margin-left: 1px; - display: none; + [data-theme="dark"] .model-chip { + background: color-mix(in srgb, var(--surface) 85%, #000); + box-shadow: 0 1px 0 rgba(255,255,255,.03); + } + .model-chip:hover { + background: var(--surface-2); + border-color: var(--accent); + transform: translateY(-1px); + } + .model-chip:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; + } + .model-chip .m-icon { + width: 22px; height: 22px; + display: grid; place-items: center; + border-radius: 6px; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + } + .model-chip .m-icon svg { width: 16px; height: 16px; display: block; } + .model-chip .m-info { + display: flex; flex-direction: column; + line-height: 1.15; + min-width: 0; + } + .model-chip .m-label { + font-size: 10.5px; + color: var(--text-faint); + letter-spacing: .2px; + text-transform: uppercase; + font-weight: 600; } - .model-chip[data-state="ok"] .lock { display: inline-block; } + .model-chip .m-line { + display: flex; align-items: center; gap: 8px; + margin-top: 2px; + } + .model-chip .m-name { + font-weight: 700; color: var(--text); + max-width: 130px; + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + font-size: 13px; + } + .model-chip .m-status { + display: inline-flex; align-items: center; gap: 4px; + padding: 1.5px 6px; + border-radius: 999px; + font-size: 10.5px; font-weight: 600; + line-height: 1; + } + .model-chip .m-status-icon { width: 11px; height: 11px; } + .model-chip[data-state="ok"] .m-status { + color: #10b981; + background: color-mix(in srgb, #10b981 14%, transparent); + border: 1px solid color-mix(in srgb, #10b981 30%, transparent); + } + .model-chip[data-state="warn"] .m-status { + color: #b45309; + background: color-mix(in srgb, #f59e0b 18%, transparent); + border: 1px solid color-mix(in srgb, #f59e0b 35%, transparent); + } + [data-theme="dark"] .model-chip[data-state="warn"] .m-status { + color: #fbbf24; + } + .model-chip .m-status-text { white-space: nowrap; } + + /* Legacy hooks kept for backwards-compat (older DOM nodes); harmless. */ + .model-chip .dot, .model-chip .lock { display: none; } /* Toolbar segmented controls (inline lang / theme) */ .tb-seg { diff --git a/loop_memory/serve/static/js/components/KnowledgeGraph.js b/loop_memory/serve/static/js/components/KnowledgeGraph.js index 1f12c67..e8e39c6 100644 --- a/loop_memory/serve/static/js/components/KnowledgeGraph.js +++ b/loop_memory/serve/static/js/components/KnowledgeGraph.js @@ -523,7 +523,46 @@ export const KnowledgeGraph = defineComponent({ } function zoomIn() { zoomBy(1.25); } function zoomOut() { zoomBy(1 / 1.25); } - function fit() { fitToCanvas(); drawCanvas(); } + /* Fit-to-window: animate scale + translate over ~260ms so the + * user can SEE the refit motion even when the graph is already + * roughly in place. The previous instant teleport produced a + * zero-pixel diff against the previous frame, so the button + * looked broken. */ + let fitTweenRaf = null; + function fit() { + // Snapshot the pre-fit transform FIRST, then compute the new + // fit, then tween from snapshot → new transform in a rAF loop. + const start = { + scale: graphLayout.scale, + tx: graphLayout.tx, + ty: graphLayout.ty, + }; + fitToCanvas(); // mutates graphLayout in place to the new fit + const target = { + scale: graphLayout.scale, + tx: graphLayout.tx, + ty: graphLayout.ty, + }; + // Restore start values so the tween animates from old → new + graphLayout.scale = start.scale; + graphLayout.tx = start.tx; + graphLayout.ty = start.ty; + const dur = 260; + const t0 = performance.now(); + function step() { + const t = Math.min(1, (performance.now() - t0) / dur); + const e = 1 - Math.pow(1 - t, 3); // ease-out-cubic + graphLayout.scale = start.scale + (target.scale - start.scale) * e; + graphLayout.tx = start.tx + (target.tx - start.tx) * e; + graphLayout.ty = start.ty + (target.ty - start.ty) * e; + drawCanvas(); + if (t < 1) { + fitTweenRaf = requestAnimationFrame(step); + } + } + if (fitTweenRaf) cancelAnimationFrame(fitTweenRaf); + fitTweenRaf = requestAnimationFrame(step); + } function ensureSim() { if (sim) return; diff --git a/loop_memory/serve/static/js/components/TopBar.js b/loop_memory/serve/static/js/components/TopBar.js index 36241bd..d526f37 100644 --- a/loop_memory/serve/static/js/components/TopBar.js +++ b/loop_memory/serve/static/js/components/TopBar.js @@ -116,13 +116,42 @@ export const TopBar = defineComponent({
- - - {{ t('model.label') }} - {{ store.modelInfo.model || 'rules' }} - - + +
From 376830bf2171baf0158f7999896145a1165dbb85 Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:18:47 +0800 Subject: [PATCH 18/88] =?UTF-8?q?Lifecycle=20rail=20=E2=80=94=20center=20i?= =?UTF-8?q?t,=20slim=20it,=20soften=20colors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes on the dashboard 生命周期流转 strip: 1. **Centering**: rail vertical midline now matches icon vertical center. Was 10px below. New top:36px on a 32px icon placed in a stage with padding-top:22 → icon center at y=38, rail mid at y=38. Verified via headless screenshot. 2. **Slim the icons + beads**: icons 42→32px, heavy box-shadow halo replaced by a thin ring; remove the thumping keyframe animation and use a static 'active' glow. Beads shrunk from 6/5/12→4/3/6 (still destination ~1.5× larger than on-line) and the heavy multi-layer glow collapsed into a single soft halo. 3. **Soften the color story**: rail opacity .55 → .45 with smoother fade-in/out at the icon ends (transparent → color → transparent); added a faint ::after blurred haze that suggests flow direction without competing with the beads. Bead keyframe nudged to fade in at 10% and pop at 85% (was 12%/88%) so the visual sits inside the rail, not at its edges. Animation slowed from 2.6s → 3.2s. --- loop_memory/serve/static/css/layout.css | 168 +++++++++++++----------- 1 file changed, 92 insertions(+), 76 deletions(-) diff --git a/loop_memory/serve/static/css/layout.css b/loop_memory/serve/static/css/layout.css index d265bb9..a97c3e4 100644 --- a/loop_memory/serve/static/css/layout.css +++ b/loop_memory/serve/static/css/layout.css @@ -659,13 +659,17 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai font-variant-numeric: tabular-nums; } -/* --- Lifecycle strip (6 stages) --- */ +/* --- Lifecycle strip (6 stages) ----------------------------------- */ +/* Slimmer icons + a rail whose vertical midline exactly meets the + * icon centers (was 10px below). Three small beads per gap roll + * left → right with a gentle color shift; the destination bead is + * one size larger but not the heavy "pop" the previous version did. */ .ins-lifecycle { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0; - padding: 18px 10px 14px; - background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 2.5%, var(--surface)), var(--surface)); + padding: 22px 10px 16px; /* top:22 so flow top:18 + half-icon sits over the rail midline */ + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 2%, var(--surface)), var(--surface)); border-radius: 14px; border: 1px solid var(--border); overflow: hidden; @@ -673,7 +677,7 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai @media (max-width: 900px) { .ins-lifecycle { grid-template-columns: repeat(3, minmax(0, 1fr)); } } .ins-lc-stage { --stage-color: #6366f1; - padding: 4px 8px 2px; + padding: 0 6px; display: flex; flex-direction: column; align-items: center; gap: 4px; position: relative; min-width: 0; @@ -683,65 +687,86 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai .ins-lc-stage[data-tone="purple"] { --stage-color: #8b5cf6; } .ins-lc-stage[data-tone="slate"] { --stage-color: #64748b; } .ins-lc-stage[data-tone="rose"] { --stage-color: #f43f5e; } +/* Slimmer icon: was 42px, now 32px with a thin ring (no heavy box-shadow halo). */ .ins-lc-icon { - width: 42px; height: 42px; + width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; - font-size: 17px; font-weight: 700; + font-size: 14px; font-weight: 600; color: var(--stage-color); - background: var(--surface); - border: 1px solid color-mix(in srgb, var(--stage-color) 40%, var(--border)); - box-shadow: 0 0 0 5px color-mix(in srgb, var(--stage-color) 10%, transparent); + background: color-mix(in srgb, var(--stage-color) 7%, var(--surface)); + border: 1px solid color-mix(in srgb, var(--stage-color) 45%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--stage-color) 8%, transparent); position: relative; z-index: 3; + transition: box-shadow .35s ease, background .35s ease; } -.ins-lc-stage.active .ins-lc-icon { animation: lc-node-breathe 3.2s ease-in-out infinite; } -.ins-lc-val { margin-top: 5px; font-size: 17px; line-height: 1; font-weight: 750; color: var(--text); font-variant-numeric: tabular-nums; } -.ins-lc-label { font-size: 11px; color: var(--text-mute); font-weight: 650; } -.ins-lc-pct { font-size: 10.5px; color: var(--text-faint); font-variant-numeric: tabular-nums; } -/* Rollers: three coloured beads per gap, each travels from the - * source stage icon to the destination icon, growing ~1.6x along the - * way. The bead starts in --from-tone and shades toward --to-tone - * during the journey, ending bigger and in --to-tone so it visibly - * pops as it lands at the destination. - * - * `--from-tone` and `--to-tone` are set inline per gap from the - * template (Dashboard.js -> lifecycleSegments -> ins-lc-flow style). - */ +/* Active stage: a soft continuous pulse instead of a thumping keyframe. */ +.ins-lc-stage.active .ins-lc-icon { + box-shadow: 0 0 0 5px color-mix(in srgb, var(--stage-color) 14%, transparent); + background: color-mix(in srgb, var(--stage-color) 12%, var(--surface)); +} +.ins-lc-val { margin-top: 7px; font-size: 15px; line-height: 1; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; } +.ins-lc-label { font-size: 11px; color: var(--text-mute); font-weight: 600; } +.ins-lc-pct { font-size: 10px; color: var(--text-faint); font-variant-numeric: tabular-nums; } + +/* Rail — positioned so its vertical center matches the icon center. + * Icon top y = 22 (stage padding-top) + 0 = 22; center y = 22+16 = 38. + * Flow height = 4 → flow top = 38 - 2 = 36. Distance from icon center + * to flow mid: 0. (Was 10px below.) */ .ins-lc-flow { position: absolute; z-index: 1; - top: 28px; - left: calc(50% + 22px); - width: calc(100% - 44px); - height: 14px; + top: 36px; + left: calc(50% + 16px); + width: calc(100% - 32px); + height: 4px; pointer-events: none; } -/* Static rail so the path is always visible even between balls. */ +/* Static rail — thin, low-opacity gradient so the eye reads "path" + * without competing with the beads. */ .ins-lc-flow::before { content: ""; position: absolute; - top: 6px; - left: 0; - right: 0; - height: 2px; + top: 50%; + left: 0; right: 0; + height: 1px; + margin-top: -0.5px; + border-radius: 1px; + background: linear-gradient( + to right, + transparent 0%, + color-mix(in srgb, var(--from-tone) 55%, transparent) 18%, + color-mix(in srgb, var(--from-tone, var(--to-tone)) 60%, var(--to-tone) 40%) 50%, + color-mix(in srgb, var(--to-tone) 55%, transparent) 82%, + transparent 100% + ); + opacity: .45; +} +/* Soft ambient trail — a faint colored haze behind the rail that + * suggests flow direction without overpowering the beads. */ +.ins-lc-flow::after { + content: ""; + position: absolute; + top: 50%; + left: 0; right: 0; + height: 3px; + margin-top: -1.5px; border-radius: 999px; background: linear-gradient( to right, - color-mix(in srgb, var(--from-tone) 38%, transparent) 0%, - color-mix(in srgb, var(--from-tone) 38%, var(--to-tone) 50%) 50%, - color-mix(in srgb, var(--to-tone) 38%, transparent) 100% + transparent, + color-mix(in srgb, var(--from-tone) 10%, transparent), + color-mix(in srgb, var(--to-tone) 10%, transparent), + transparent ); - opacity: .55; -} -/* Lifecycle beads: three colored balls per gap, two small ones - * rolling on the line and one clearly larger destination pop. - * - * Translation is done via the `left` property (referring to PARENT - * width) rather than `translateX(%)`, which would reference the - * ball's own width and effectively never move. */ + filter: blur(2px); + opacity: .5; +} + +/* Beads — small (3-4px), single soft glow, color-shifting during travel. */ .ins-lc-ball { - --ball-size: 6px; + --ball-size: 4px; position: absolute; top: 50%; left: 0; @@ -752,61 +777,52 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai background: var(--from-tone); opacity: 0; will-change: left, transform, opacity, background-color, box-shadow; - outline: 1px solid rgba(255,255,255,.35); - outline-offset: -1px; -} -/* Two on-line rolling beads + one clearly larger destination bead. - * The destination bead (p3) is intentionally ~1 size larger than - * the on-line beads so it visibly "lands" at the next stage. */ -.ins-lc-ball.p1 { --ball-size: 6px; animation: lc-ball-roll 2.6s linear infinite; } /* on-line */ -.ins-lc-ball.p2 { --ball-size: 5px; animation: lc-ball-roll 2.6s linear 0.85s infinite; } /* on-line */ -.ins-lc-ball.p3 { --ball-size: 12px; animation: lc-ball-roll 2.6s linear 1.7s infinite; } /* destination pop ~1 size larger */ - -/* Each bead leaves from the source icon, glides left → right, grows - * visibly, shifts color from --from-tone to --to-tone, and pops at - * the destination. Using `left` (parent-relative) instead of - * `translateX` (self-relative) so the bead actually moves across - * the rail instead of staying pinned at scale-only motion. */ +} +/* Two on-line rolling beads (small) + one destination bead + * (~1 size larger). All very subtle. */ +.ins-lc-ball.p1 { --ball-size: 4px; animation: lc-ball-roll 3.2s linear infinite; } +.ins-lc-ball.p2 { --ball-size: 3px; animation: lc-ball-roll 3.2s linear 1.05s infinite; } +.ins-lc-ball.p3 { --ball-size: 6px; animation: lc-ball-roll 3.2s linear 2.1s infinite; } + +/* Slow, gentle, color-shifting roll. The destination bead (p3) + * grows ~1.5x when it lands; on-line beads stay small throughout. */ @keyframes lc-ball-roll { 0% { left: 0; - transform: scale(0.6); + transform: scale(0.5); background: var(--from-tone); - box-shadow: 0 0 0 color-mix(in srgb, var(--from-tone) 65%, transparent); + box-shadow: 0 0 0 transparent; opacity: 0; } - 12% { - left: calc(12% - var(--ball-size) * 0.12); + 10% { + left: 10%; transform: scale(1); - opacity: 1; + opacity: 0.95; + box-shadow: 0 0 4px color-mix(in srgb, var(--from-tone) 60%, transparent); } 50% { left: calc(50% - var(--ball-size) / 2); - transform: scale(1.1); + transform: scale(1); background: color-mix(in srgb, var(--from-tone) 50%, var(--to-tone) 50%); box-shadow: - 0 0 8px color-mix(in srgb, var(--from-tone) 50%, var(--to-tone) 50%), - 0 0 14px color-mix(in srgb, var(--from-tone) 40%, transparent); + 0 0 5px color-mix(in srgb, var(--from-tone) 50%, var(--to-tone) 50%), + 0 0 10px color-mix(in srgb, var(--from-tone) 25%, transparent); } - 88% { + 85% { left: calc(100% - var(--ball-size)); - transform: scale(1.6); + opacity: 0.95; + transform: scale(1.5); background: var(--to-tone); box-shadow: - 0 0 14px var(--to-tone), - 0 0 26px color-mix(in srgb, var(--to-tone) 55%, transparent); - opacity: 1; + 0 0 6px var(--to-tone), + 0 0 12px color-mix(in srgb, var(--to-tone) 40%, transparent); } 100% { left: calc(100% - var(--ball-size)); - transform: scale(1.6); + transform: scale(1.5); opacity: 0; } } -@keyframes lc-node-breathe { - 0%, 100% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--stage-color) 7%, transparent); } - 50% { box-shadow: 0 0 0 9px color-mix(in srgb, var(--stage-color) 2%, transparent); } -} @media (max-width: 900px) { .ins-lc-stage:nth-child(3) .ins-lc-flow { display: none; } } From c916a68044ca52f0b67f7c868558b8ff05f8df37 Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:24:00 +0800 Subject: [PATCH 19/88] =?UTF-8?q?Lifecycle=20rail=20=E2=80=94=20actually?= =?UTF-8?q?=20center=20it=20(top:36=20=E2=86=92=20top:14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix tried to match the rail midline to the icon vertical center but applied the math to the wrong coordinate space: the flow is absolutely positioned inside `.ins-lc-stage` (not inside `.ins-lifecycle`), so the lifecycle's 22px outer padding does not shift it. The stage itself has padding-top:0, the icon is the first flex child at y=0, icon height 32 → icon center y = 16. Flow height 4 → flow top should be 16 - 2 = 14. Headless measurement before this fix: flow cy = 786.5, icon cy = 764.5, diff = +22px (the entire outer padding of .ins-lifecycle bleeding through). After this fix: diff = 0px on all 5 visible stages. --- loop_memory/serve/static/css/layout.css | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/loop_memory/serve/static/css/layout.css b/loop_memory/serve/static/css/layout.css index a97c3e4..80cc453 100644 --- a/loop_memory/serve/static/css/layout.css +++ b/loop_memory/serve/static/css/layout.css @@ -710,14 +710,22 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai .ins-lc-label { font-size: 11px; color: var(--text-mute); font-weight: 600; } .ins-lc-pct { font-size: 10px; color: var(--text-faint); font-variant-numeric: tabular-nums; } -/* Rail — positioned so its vertical center matches the icon center. - * Icon top y = 22 (stage padding-top) + 0 = 22; center y = 22+16 = 38. - * Flow height = 4 → flow top = 38 - 2 = 36. Distance from icon center - * to flow mid: 0. (Was 10px below.) */ +/* Rail — vertically centered through the icon's vertical midpoint. + * + * The flow is absolutely positioned inside `.ins-lc-stage` (NOT inside + * `.ins-lifecycle`), so the lifecycle's 22px outer padding doesn't + * shift it. The stage itself has padding-top:0, the icon is the + * first flex child at y=0, icon height 32 → icon center y = 16. + * Flow height 4 → flow top = 16 - 2 = 14. + * + * Previous fix used top:36, which mistakenly applied the lifecycle's + * 22px outer padding to a flow that's actually scoped to the stage. + * Headless measurement confirms flow was rendering 22px below the icon + * center (e.g. icon cy=764.5, flow cy=786.5). */ .ins-lc-flow { position: absolute; z-index: 1; - top: 36px; + top: 14px; left: calc(50% + 16px); width: calc(100% - 32px); height: 4px; From d239006a6322f503acfaadf440911a50123b3702 Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:39:03 +0800 Subject: [PATCH 20/88] =?UTF-8?q?Switch=20MiniMax=20default=20base=5Furl:?= =?UTF-8?q?=20api.minimaxi.chat=20=E2=86=92=20api.minimaxi.com?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat host returns 2049 'invalid api key' for keys issued against the .com host, even though the docs and the platform.minimaxi.com console both serve .com. Verified with the user's key: POST https://api.minimaxi.chat/v1/chat/completions → 401 {"status_code":2049,"status_msg":"invalid api key"} POST https://api.minimaxi.com/v1/chat/completions → 200 (works, returns proper chat completion) Two call sites updated: - PROVIDERS['MiniMax'].default_base_url - build_provider() fallback when config has no base_url Also migrated the saved user config (which had the old .chat URL baked in from earlier sessions) via PUT /api/admin/llm/config so the dashboard's '测试连接' button now passes against the .com host without requiring the user to re-edit the base URL field. Tests: 226/226 passing. --- loop_memory/llm/providers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/loop_memory/llm/providers.py b/loop_memory/llm/providers.py index 5672888..bb7c082 100644 --- a/loop_memory/llm/providers.py +++ b/loop_memory/llm/providers.py @@ -50,7 +50,7 @@ class ProviderSpec: default_model="MiniMax-M2.7", needs_api_key=True, needs_base_url=True, - default_base_url="https://api.minimaxi.chat/v1", + default_base_url="https://api.minimaxi.com/v1", description="MiniMax (https://platform.minimaxi.com) — recommended default", ), "openai": ProviderSpec( @@ -357,7 +357,7 @@ def build_provider(config: dict[str, Any]) -> LLMClient: return OpenAICompatProvider( model=model, api_key=api_key, - base_url=base_url or "https://api.minimaxi.chat/v1", + base_url=base_url or "https://api.minimaxi.com/v1", ) if ptype in ("openai", "openai_compat", "openai-compat"): return OpenAICompatProvider(model=model, api_key=api_key, base_url=base_url or "https://api.openai.com/v1") From bf327c083b127f93689ed0609533188a40123f23 Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:44:12 +0800 Subject: [PATCH 21/88] Secret backend fallback: read from OS-native store if active missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: user saved their MiniMax API key while the macos-keychain backend was active, then restarted the server with the local-file backend selected. The active store had no entry, the UI badge showed 'API key missing' and every LLM call went out without an Authorization header → MiniMax returned 1004 'Please carry the API secret key'. Fix: `get_secret(account)` now tries the active backend first; on miss it asks each OS-native backend in turn (macos-keychain on Darwin, linux-secret-service on Linux, windows-cred on Windows) and *migrates* the value into the active store on hit. `has_secret` uses get_secret so the UI badge becomes correct automatically. This keeps the canonical store simple (one 0600 JSON file under ~/.loop_memory) while never losing a key that was written by a different backend. Verified: macOS keychain entry 'llm/minimax/api_key' was copied into ~/.loop_memory/secrets.json on the next config read; the '测试连接' button now returns ok=true without re-pasting the key; all 226 tests still pass. --- loop_memory/security/secrets.py | 64 +++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/loop_memory/security/secrets.py b/loop_memory/security/secrets.py index 88f1f79..e00a2fc 100644 --- a/loop_memory/security/secrets.py +++ b/loop_memory/security/secrets.py @@ -377,12 +377,72 @@ def account_for(provider: str, key: str = "api_key") -> str: return _account_for(provider, key) +# Backends considered for *fallback* lookups (read-only). When the +# chosen backend doesn't have an account, we ask the others in order +# and on hit *migrate* the value into the chosen backend so subsequent +# reads are fast and the canonical store stays simple (one file, 0600). +_FALLBACK_BACKENDS: list = [] + +def _candidate_backends(): + """Return [active, *fallbacks] — active first, the rest in priority order. + + The active backend is what ``_pick_backend()`` returned; it's also + where ``set_secret`` writes. Fallbacks are tried only on read miss + and migrated on hit, so the active store stays canonical. + """ + active = _pick_backend() + out = [active] + sysname = platform.system() + fallback_classes = [] + if sysname == "Darwin": + fallback_classes.append(MacOSKeychainStore) + elif sysname == "Linux": + fallback_classes.append(LinuxSecretServiceStore) + elif sysname == "Windows": + fallback_classes.append(WindowsCredentialStore) + for cls in fallback_classes: + if cls is type(active): + continue + try: + b = cls() + except Exception: + continue + out.append(b) + return out + + def get_secret(account: str) -> str | None: + """Look up a secret. Falls back across backends and migrates on hit. + + On read miss in the active backend, we try each OS-native backend + in turn. If any of them has the value, we copy it into the active + backend (so future reads are local-file fast) and return it. This + keeps the canonical store simple while never losing a key that was + saved when a different backend was active. + """ + active = _pick_backend() try: - return _pick_backend().get(account) + v = active.get(account) + if v: + return v except Exception as e: - log.warning("secret get failed for %s: %s", account, e) + log.warning("secret get failed for %s in active: %s", account, e) return None + # Active backend missed. Try the others and migrate on hit. + for b in _candidate_backends()[1:]: + try: + v = b.get(account) + except Exception as e: + log.debug("fallback get failed for %s in %s: %s", account, b.name, e) + continue + if v: + try: + active.set(account, v) + log.info("migrated secret %s from %s → %s", account, b.name, active.name) + except Exception as e: + log.warning("migration of %s to %s failed: %s", account, active.name, e) + return v + return None def set_secret(account: str, value: str) -> None: From 2439cc8149abe71173e57d4f4c0190e7560606f1 Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:54:23 +0800 Subject: [PATCH 22/88] =?UTF-8?q?LLM=20drawer:=20rename=20to=20=E5=A4=A7?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE,=20fix=20duplicate=20'Run?= =?UTF-8?q?=20mode'=20label=20+=20i18n=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four user-visible issues fixed: 1. **Title** 'Settings' / 'LLM Consolidator Settings' → '大模型配置' / 'LLM Configuration' (+ subtitle '一次配置,全局生效' / 'Configure once, used everywhere'). The LLM is shared by every feature that needs one (consolidation, knowledge graph, weekly report, connectivity test) — the old name implied consolidation was the only consumer. 2. **Duplicate 'Run mode' label** in the schedule section. The inline label next to the 'enabled' checkbox was the same string as the label on the 'mode' dropdown. Renamed the first to '启用自动运行' / 'Enable auto-run' so the two controls are visually distinct (auto-run toggle vs. trigger mode picker). 3. **i18n rendering bug**: 'Next run: {when} ({mode}): Next run: 7/19/2026, 3:00:00 AM (Daily)'. The template emitted 't(settings.nextRun)' (literal template with unsubstituted {when}/{mode}) followed by ': ' followed by nextRunText() (already-substituted version). Fixed by emitting nextRunText() only. Replaced the gray plain-text line with a colored status pill (green when scheduled, gray when disabled) plus a per-mode hint line ('Every day at 3:00', 'Every Mon at 03:00', etc.). 4. **Scope clarity**: added a small gray note under each section header explaining what it covers. 'CONNECTION' lists the features that share this LLM; 'CONSOLIDATION SCHEDULE' and 'CONSOLIDATION BEHAVIOUR' both say 'only affects consolidation'. Touched files: - Settings.js: header, schedule label, nextRunText + scheduleModeHint, scope notes - app.css: .drawer-subtitle, .sec-scope, .sched-status pill - i18n/{en,zh}.json: 16 new keys (title, subtitle, sections, scopes, hints) All 226 tests pass; no server-side changes needed (the config storage key 'llm_consolidator' stays — every feature already calls build_provider(cfg) on it, which is the existing global config). --- loop_memory/serve/static/css/app.css | 33 +++++++++++- loop_memory/serve/static/i18n/en.json | 28 +++++++--- loop_memory/serve/static/i18n/zh.json | 26 ++++++++-- .../serve/static/js/components/Settings.js | 52 ++++++++++++++----- 4 files changed, 112 insertions(+), 27 deletions(-) diff --git a/loop_memory/serve/static/css/app.css b/loop_memory/serve/static/css/app.css index 98d3011..cf5541a 100644 --- a/loop_memory/serve/static/css/app.css +++ b/loop_memory/serve/static/css/app.css @@ -1384,9 +1384,38 @@ /* legacy .drawer.show not used anymore — Vue v-show drives visibility */ .drawer-head { padding: 16px 18px; border-bottom: 1px solid var(--border); - display: flex; align-items: center; gap: 10px; + display: flex; align-items: flex-start; gap: 10px; + } + .drawer-head h2 { margin: 0; font-size: 15px; font-weight: 600; flex: 1; line-height: 1.25; } + .drawer-head-text { flex: 1; min-width: 0; } + .drawer-subtitle { + margin: 3px 0 0; font-size: 11.5px; color: var(--text-faint); font-weight: 400; + } + /* Section scope note — small, muted, explains what this section is for. */ + .drawer-body section .sec-scope { + margin: -4px 0 12px; font-size: 11.5px; line-height: 1.45; + color: var(--text-faint); + } + /* Schedule status pill (replaces the old "Next run: {when} ({mode})" plain text). */ + .sched-status { + display: inline-flex; align-items: center; gap: 7px; + margin-top: 10px; padding: 6px 11px; + border-radius: 999px; + font-size: 11.5px; font-weight: 500; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--surface-2) 80%, transparent); + } + .sched-status .dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--text-faint); + box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 18%, transparent); + } + .sched-status.on { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); } + .sched-status.on .dot { background: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); } + .sched-status.off { color: var(--text-mute); } + .sched-hint { + margin-top: 6px; font-size: 11.5px; color: var(--text-faint); line-height: 1.45; } - .drawer-head h2 { margin: 0; font-size: 15px; font-weight: 600; flex: 1; } .drawer-body { flex: 1; overflow-y: auto; padding: 16px 18px; font-size: 13px; diff --git a/loop_memory/serve/static/i18n/en.json b/loop_memory/serve/static/i18n/en.json index f677428..ffe4625 100644 --- a/loop_memory/serve/static/i18n/en.json +++ b/loop_memory/serve/static/i18n/en.json @@ -387,17 +387,17 @@ "settings.schedule.weekday": "Weekday", "settings.schedule.weekly": "Weekly", "settings.score": "Re-score importance", - "settings.section.actions": "Actions", - "settings.section.behaviour": "Behaviour", + "settings.section.actions": "Manual Actions", + "settings.section.behaviour": "Consolidation Behaviour", "settings.section.provider": "LLM Provider", - "settings.section.runs": "Recent runs", - "settings.section.schedule": "Schedule", + "settings.section.runs": "Recent Runs", + "settings.section.schedule": "Consolidation Schedule", "settings.summary": "Distill / merge near-duplicates", "settings.temperature": "Temperature", "settings.test": "Test connection", "settings.test.fail": "Failed: {msg}", "settings.test.ok": "Connected ({ms}ms)", - "settings.title": "LLM Consolidator Settings", + "settings.title": "LLM Configuration", "sidebar.allSources": "All sources", "sidebar.empty": "No sessions yet", "sidebar.sessions": "Sessions", @@ -549,5 +549,21 @@ "timeline.memories": "memories", "timeline.sessionScope": "Viewing session:", "timeline.clearScope": "Clear session filter, show all memories", - "timeline.emptyForSession": "No memories have been extracted for this session yet. Try running \"AI Run\" or re-importing." + "timeline.emptyForSession": "No memories have been extracted for this session yet. Try running \"AI Run\" or re-importing.", + "settings.subtitle": "Configure once, used everywhere", + "settings.section.connection": "Connection", + "settings.connection.usedBy": "This LLM powers: consolidation, knowledge-graph distillation, weekly reports, and connectivity tests", + "settings.connection.usedByNote": "No need to configure LLM separately in those features.", + "settings.schedule.enabled": "Enable auto-run", + "settings.schedule.enabledHint": "When off, consolidation only runs when you click 「Run now」", + "settings.schedule.noSchedule": "Auto-run disabled — only fires when you click 「Run now」", + "settings.schedule.statusOn": "Next run: {when} ({mode})", + "settings.schedule.statusOff": "Auto-run disabled", + "settings.schedule.realtimeHint": "After every ingest, wait {sec}s of idle, then consolidate", + "settings.schedule.weeklyHint": "Every {weekday} at {hour}:{minute}", + "settings.schedule.dailyHint": "Every day at {hour}:{minute}", + "settings.schedule.hourlyHint": "On every hour boundary", + "settings.schedule.intervalHint": "Every {n} minutes", + "settings.section.consolidationScope": "The settings below only apply to the consolidation job — knowledge-graph and weekly report features use their own defaults", + "settings.behaviour.scope": "(only affects the consolidation job — other LLM features use their own defaults)" } \ No newline at end of file diff --git a/loop_memory/serve/static/i18n/zh.json b/loop_memory/serve/static/i18n/zh.json index 64d05fc..5f1b4d8 100644 --- a/loop_memory/serve/static/i18n/zh.json +++ b/loop_memory/serve/static/i18n/zh.json @@ -387,17 +387,17 @@ "settings.schedule.weekday": "星期", "settings.schedule.weekly": "每周", "settings.score": "重新打分", - "settings.section.actions": "操作", - "settings.section.behaviour": "行为", + "settings.section.actions": "手动操作", + "settings.section.behaviour": "整理任务行为", "settings.section.provider": "大模型提供方", "settings.section.runs": "最近运行", - "settings.section.schedule": "调度", + "settings.section.schedule": "整理任务调度", "settings.summary": "提炼/合并近似重复", "settings.temperature": "温度", "settings.test": "测试连接", "settings.test.fail": "失败:{msg}", "settings.test.ok": "已连通 ({ms}ms)", - "settings.title": "大模型整理设置", + "settings.title": "大模型配置", "sidebar.allSources": "全部来源", "sidebar.empty": "暂无会话", "sidebar.sessions": "会话", @@ -549,5 +549,21 @@ "timeline.memories": "条记忆", "timeline.sessionScope": "正在查看会话:", "timeline.clearScope": "清除会话筛选,查看全部记忆", - "timeline.emptyForSession": "这个会话暂未提取到记忆,尝试重新导入或者运行\"AI 整理\"。" + "timeline.emptyForSession": "这个会话暂未提取到记忆,尝试重新导入或者运行\"AI 整理\"。", + "settings.subtitle": "一次配置,全局生效", + "settings.section.connection": "连接", + "settings.connection.usedBy": "以下功能共享此大模型:整理任务、知识图谱提炼、周报生成、连通性测试", + "settings.connection.usedByNote": "在其它功能里无需重复配置。", + "settings.schedule.enabled": "启用自动运行", + "settings.schedule.enabledHint": "关闭后只能通过下方的「立即运行」按钮手动触发", + "settings.schedule.noSchedule": "未启用 — 只在手动点击「立即运行」时执行", + "settings.schedule.statusOn": "下次运行:{when}({mode})", + "settings.schedule.statusOff": "未启用自动运行", + "settings.schedule.realtimeHint": "每次有新的记忆导入后,空闲 {sec} 秒后自动整理", + "settings.schedule.weeklyHint": "每周 {weekday} 的 {hour}:{minute} 整理一次", + "settings.schedule.dailyHint": "每天 {hour}:{minute} 整理一次", + "settings.schedule.hourlyHint": "每小时整点整理一次", + "settings.schedule.intervalHint": "每 {n} 分钟整理一次", + "settings.section.consolidationScope": "本板块以下设置只对「整理任务」生效,不影响知识图谱和周报", + "settings.behaviour.scope": "(仅作用于「整理任务」—— 其它 LLM 功能使用各自的默认参数)" } \ No newline at end of file diff --git a/loop_memory/serve/static/js/components/Settings.js b/loop_memory/serve/static/js/components/Settings.js index 9c6dff5..c4c0895 100644 --- a/loop_memory/serve/static/js/components/Settings.js +++ b/loop_memory/serve/static/js/components/Settings.js @@ -208,14 +208,30 @@ export const Settings = defineComponent({ }); } + const WEEKDAY_NAMES_ZH = ['一','二','三','四','五','六','日']; + const WEEKDAY_NAMES_EN = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; function nextRunText() { + if (!cfg.schedule.enabled || (cfg.schedule.mode || 'off') === 'off') { + return t('settings.schedule.statusOff'); + } if (!nextRun.value) return ''; const ts = nextRun.value; const d = new Date(ts * 1000); if (Number.isNaN(d.getTime())) return ''; const when = fmtTime(ts); const mode = t('settings.schedule.' + (cfg.schedule.mode || 'off')); - return t('settings.nextRun', { when, mode }); + return t('settings.schedule.statusOn', { when, mode }); + } + function scheduleModeHint() { + const m = cfg.schedule.mode || 'off'; + const isZh = (store.lang || 'zh') === 'zh'; + const wdName = (isZh ? WEEKDAY_NAMES_ZH : WEEKDAY_NAMES_EN)[cfg.schedule.weekday || 0]; + if (m === 'realtime') return t('settings.schedule.realtimeHint', { sec: cfg.schedule.after_ingest_idle_sec || 30 }); + if (m === 'weekly') return t('settings.schedule.weeklyHint', { weekday: wdName, hour: cfg.schedule.hour, minute: String(cfg.schedule.minute).padStart(2,'0') }); + if (m === 'daily') return t('settings.schedule.dailyHint', { hour: cfg.schedule.hour, minute: String(cfg.schedule.minute).padStart(2,'0') }); + if (m === 'hourly') return t('settings.schedule.hourlyHint'); + if (m === 'interval') return t('settings.schedule.intervalHint', { n: cfg.schedule.interval_minutes }); + return ''; } function onClose() { emit('close'); } @@ -223,7 +239,7 @@ export const Settings = defineComponent({ return { cfg, providers, selectedProvider, onProviderChange, testing, testResult, onTest, saving, savedHint, onSave, onClearKey, onReset, onRunNow, onPreview, - runs, runsLoading, refreshRuns, nextRun, nextRunText, + runs, runsLoading, refreshRuns, nextRun, nextRunText, scheduleModeHint, previewItems, previewLoading, previewOpen, statusKey, triggerKey, statLine, WEEKDAYS, store, t, onClose }; @@ -232,7 +248,10 @@ export const Settings = defineComponent({
diff --git a/loop_memory/serve/static/js/store.js b/loop_memory/serve/static/js/store.js index 50ed8a8..e4ec8ff 100644 --- a/loop_memory/serve/static/js/store.js +++ b/loop_memory/serve/static/js/store.js @@ -51,7 +51,13 @@ export const store = reactive({ toastTimer: null, runStatus: { is_running: false, progress: { current: 0, total: 0, message: '' } }, stats: { memories: 0, sessions: 0, wiki_pages: 0, avg_score: 0, graph: '0/0', dbPath: '' }, - modelInfo: { provider: 'rules', model: 'rules', api_key_set: false, key_len: 0 }, + modelInfo: { provider: 'rules', model: 'rules', api_key_set: false, key_len: 0, + // Reachability: 'unset' | 'ok' | 'stale' | 'fail' + // unset — no key configured + // ok — key set AND last test (or live run) succeeded recently + // stale — key set, no recent test (treat as 'configured but unverified') + // fail — key set, last test/error was a provider failure + reachability: 'unset', last_test_ok: null, last_test_at: null, last_test_message: '' }, stripDismissed: false, lastRunId: null, }); From a4840d44a88f866734e8ca82a0e2ad5652333009 Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:21:19 +0800 Subject: [PATCH 24/88] Merge topbar buttons + add Wiki import/export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues fixed together: 1. **Two redundant buttons** in the topbar: - 'AI 整理' (llm-run) and '立即运行' (run-now) called the same backend endpoint, with the same payload; the 'force: true' flag on the second was silently dropped because the endpoint doesn't read it. Merged into one '立即整理' button. Dropped the now-dead onRunNow handler in App.js, the 'run-now' event in TopBar, and the obsolete llmRun/runNow i18n keys. 2. **Wiki knowledge base lacked import**: - GET /api/wiki/export already existed (markdown only); extended it with format=json so the export is round-trippable. - New POST /api/wiki/import: accepts either: * {format: 'json', pages: [...]} (round-trip) * {format: 'markdown', markdown: '...'} (## title sections) Upserts by slug (creates new ones, updates matching slugs), returns {created, updated, skipped, errors, total}. - Wiki toolbar: added 导出全部 / 导入 buttons. Export triggers a JSON file download (timestamped). Import opens a file picker, infers format from filename + first char, POSTs to the endpoint, refreshes the page list, and shows a toast with the {created/updated/skipped} counts. - 4 new tests in tests/test_export_ask.py: format=json export, json import round-trip (create+update), markdown parsing of ## sections including Chinese titles, unknown-format rejection. All 230 tests pass (was 226; +4 new import tests). Verified via headless screenshot: topbar shows one '立即整理' button (no more redundant pair); wiki toolbar shows 导出全部 / 导入 / 新建页面. --- loop_memory/serve/app.py | 129 +++++++++++++++++- loop_memory/serve/static/i18n/en.json | 18 ++- loop_memory/serve/static/i18n/zh.json | 18 ++- loop_memory/serve/static/js/App.js | 10 +- .../serve/static/js/components/TopBar.js | 11 +- .../serve/static/js/components/Wiki.js | 84 +++++++++++- tests/test_export_ask.py | 80 +++++++++++ 7 files changed, 315 insertions(+), 35 deletions(-) diff --git a/loop_memory/serve/app.py b/loop_memory/serve/app.py index 241a039..a152dc2 100644 --- a/loop_memory/serve/app.py +++ b/loop_memory/serve/app.py @@ -1539,15 +1539,27 @@ async def llm_schedule(body: dict): @app.get("/api/wiki/export") def wiki_export(format: str = "markdown", limit: int = 500, q: str | None = None): - """Dump all (or matched) wiki pages as a single markdown string. - - ``format`` is currently only ``markdown``. ``q`` (optional) - filters by substring match against the page title or body. + """Dump all (or matched) wiki pages for backup / sharing. + + ``format``: + - ``markdown`` (default) — single markdown document. + - ``json`` — round-trippable JSON object + (``{format, count, pages}``) + that the import endpoint can + re-ingest without loss. + ``q`` (optional) filters by substring match against title/body. """ try: pages = store.list_wiki_pages(limit=limit, query=q) except Exception: pages = [] + fmt = (format or "markdown").lower() + if fmt == "json": + return { + "format": "json", + "count": len(pages), + "pages": [dict(p) for p in pages], + } out_lines = ["# Loop Memory — Distilled Knowledge", ""] out_lines.append(f"_Exported {len(pages)} wiki pages._") out_lines.append("") @@ -1567,7 +1579,114 @@ def wiki_export(format: str = "markdown", limit: int = 500, ev = ", ".join(str(x) for x in evidence[:6]) out_lines.append(f"evidence: {ev}… ({len(evidence)} sources)") out_lines.append("") - return {"format": format, "count": len(pages), "markdown": "\n".join(out_lines)} + return {"format": "markdown", "count": len(pages), "markdown": "\n".join(out_lines)} + + + @app.post("/api/wiki/import") + def wiki_import(body: dict): + """Bulk-upsert wiki pages from JSON or Markdown. + + Body shape (one of): + - ``{"format": "json", "pages": [...]}`` — round-trip from + ``/api/wiki/export?format=json``. Each entry needs at + minimum ``slug`` (or ``title``), ``title`` and ``body``. + - ``{"format": "markdown", "markdown": "..."}`` — text dump + with ``## title`` sections. The slug is derived from the + title (lowercase, spaces → dashes, ascii-safe). + + Returns ``{created, updated, skipped, errors}``. Pages whose + slug matches an existing one are updated; new slugs are + created. Malformed entries are skipped (counted in + ``skipped``) and the first error is reported. + """ + if not isinstance(body, dict): + raise HTTPException(400, "body must be an object") + fmt = (body.get("format") or "json").lower() + entries: list[dict] = [] + errors: list[str] = [] + + def _slugify(title: str, fallback: str = "") -> str: + import re as _re + s = (title or fallback or "").strip().lower() + s = _re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", s) + s = s.strip("-")[:80] + return s or "untitled" + + if fmt == "json": + raw = body.get("pages") + if not isinstance(raw, list): + raise HTTPException(400, "pages must be a list") + for i, item in enumerate(raw): + if not isinstance(item, dict): + errors.append(f"#{i}: not an object"); continue + title = (item.get("title") or "").strip() + body_text = (item.get("body") or "").strip() + slug = (item.get("slug") or "").strip() + if not title or not body_text: + errors.append(f"#{i}: missing title/body"); continue + if not slug: + slug = _slugify(title) + entries.append({ + "slug": slug.lower().replace(" ", "-")[:80], + "title": title, + "body": body_text, + "summary": (item.get("summary") or "").strip(), + "tags": item.get("tags") or [], + "importance": float(item.get("importance") or 0.5), + "evidence_ids": item.get("evidence_ids") or [], + }) + elif fmt == "markdown": + md = (body.get("markdown") or "").strip() + if not md: + raise HTTPException(400, "markdown is empty") + # Split on "## " headings. Each chunk becomes a page. + import re as _re + chunks = _re.split(r"\n(?=##\s)", md) + for chunk in chunks: + chunk = chunk.strip() + if not chunk.startswith("## "): + continue + # Drop leading "## " + lines = chunk.splitlines() + title = lines[0][3:].strip() + rest = "\n".join(lines[1:]).strip() + # Optional ">" summary at the start + summary = "" + if rest.startswith(">"): + maybe = rest.split("\n", 1) + summary = maybe[0].lstrip("> ").strip() + rest = maybe[1].strip() if len(maybe) > 1 else "" + if not title or not rest: + continue + entries.append({ + "slug": _slugify(title), + "title": title, + "body": rest, + "summary": summary, + "tags": [], + "importance": 0.5, + "evidence_ids": [], + }) + else: + raise HTTPException(400, f"unknown format {fmt!r}; use 'json' or 'markdown'") + + created = 0 + updated = 0 + for e in entries: + try: + existing = store.get_wiki_page_by_slug(e["slug"]) + store.upsert_wiki_page(**e) + if existing: updated += 1 + else: created += 1 + except Exception as ex: + errors.append(f"{e['slug']}: {ex}") + return { + "created": created, + "updated": updated, + "skipped": len(errors), + "errors": errors[:10], + "total": len(entries), + } @app.get("/api/wiki/{page_id}/export") def wiki_page_export(page_id: str, format: str = "markdown"): diff --git a/loop_memory/serve/static/i18n/en.json b/loop_memory/serve/static/i18n/en.json index 5694cba..babd11d 100644 --- a/loop_memory/serve/static/i18n/en.json +++ b/loop_memory/serve/static/i18n/en.json @@ -10,18 +10,13 @@ "action.ingest": "Ingest", "action.ingestStarted": "Ingested {n} memories", "action.ingestTip": "Tick the conversation sources to import (multi-select)", - "action.llmRun": "AI Consolidate", "action.llmRunQueued": "AI consolidation queued", - "action.llmRunTip": "Run the AI consolidator once", "action.rebuild": "Rebuild graph", "action.rescore": "Rescore", "action.rescoreDone": "Rescore complete", "action.rescoreTip": "Re-score all memories", "action.reset": "Reset", - "action.runNow": "Run now", "action.runNow.toast": "Scheduler triggered — running now", - "action.runNowQueued": "Run-now queued", - "action.runNowTip": "Run the configured model and schedule now", "action.save": "Save", "action.settings": "Settings", "app.tagline": "local edition", @@ -485,7 +480,7 @@ "wiki.empty.title": "No wiki pages yet", "wiki.error.missing": "Title, slug, and body are required", "wiki.exportBtn": "Export", - "wiki.exportFail": "Export failed", + "wiki.exportFail": "Export failed: {msg}", "wiki.exported": "Wiki exported", "wiki.field.body": "Body (Markdown)", "wiki.field.evidence": "Source memories", @@ -574,5 +569,14 @@ "model.dot.stale": "Configured", "model.dot.fail": "Unreachable", "model.dot.unset": "Unset", - "topbar.settingsTip": "Configure LLM, consolidation schedule, knowledge graph, and more" + "topbar.settingsTip": "Configure LLM, consolidation schedule, knowledge graph, and more", + "action.consolidateNow": "Consolidate now", + "action.consolidateNowTip": "Run the consolidation job immediately with the configured model", + "wiki.export": "Export all", + "wiki.exportTip": "Download all wiki pages as a Markdown file", + "wiki.import": "Import", + "wiki.importTip": "Import wiki pages from a Markdown or JSON file (merged by slug)", + "wiki.importSuccess": "Import done — {created} created, {updated} updated, {skipped} skipped", + "wiki.importEmpty": "No pages recognised in the file", + "wiki.importError": "Import failed: {msg}" } \ No newline at end of file diff --git a/loop_memory/serve/static/i18n/zh.json b/loop_memory/serve/static/i18n/zh.json index 937d95b..cb03398 100644 --- a/loop_memory/serve/static/i18n/zh.json +++ b/loop_memory/serve/static/i18n/zh.json @@ -10,18 +10,13 @@ "action.ingest": "导入", "action.ingestStarted": "已采集 {n} 条记忆", "action.ingestTip": "勾选要导入的对话来源(可多选)", - "action.llmRun": "AI 整理", "action.llmRunQueued": "已加入 AI 整理队列", - "action.llmRunTip": "执行一次 AI 记忆整理", "action.rebuild": "重建图谱", "action.rescore": "重打分", "action.rescoreDone": "重打分完成", "action.rescoreTip": "重新计算所有记忆分数", "action.reset": "重置", - "action.runNow": "立即运行", "action.runNow.toast": "已触发调度器,开始整理", - "action.runNowQueued": "立即运行已加入队列", - "action.runNowTip": "立即使用已配置模型执行整理任务", "action.save": "保存", "action.settings": "配置", "app.tagline": "本地版", @@ -485,7 +480,7 @@ "wiki.empty.title": "还没有词条", "wiki.error.missing": "标题、slug 和正文不能为空", "wiki.exportBtn": "导出", - "wiki.exportFail": "导出失败", + "wiki.exportFail": "导出失败:{msg}", "wiki.exported": "已导出", "wiki.field.body": "正文 (Markdown)", "wiki.field.evidence": "源记忆", @@ -574,5 +569,14 @@ "model.dot.stale": "已配置", "model.dot.fail": "连通失败", "model.dot.unset": "未配置", - "topbar.settingsTip": "配置大模型、整理任务、知识图谱等" + "topbar.settingsTip": "配置大模型、整理任务、知识图谱等", + "action.consolidateNow": "立即整理", + "action.consolidateNowTip": "立即使用已配置的模型执行整理任务", + "wiki.export": "导出全部", + "wiki.exportTip": "将知识库所有页面导出为 Markdown 文件", + "wiki.import": "导入", + "wiki.importTip": "从 Markdown 或 JSON 文件导入知识页面(按 slug 合并)", + "wiki.importSuccess": "导入完成:新增 {created}、更新 {updated}、跳过 {skipped}", + "wiki.importEmpty": "未识别到任何页面", + "wiki.importError": "导入失败:{msg}" } \ No newline at end of file diff --git a/loop_memory/serve/static/js/App.js b/loop_memory/serve/static/js/App.js index d23618d..760a2c6 100644 --- a/loop_memory/serve/static/js/App.js +++ b/loop_memory/serve/static/js/App.js @@ -3,7 +3,7 @@ * * Renders: TopBar, Sidebar, Tabs, the active tab pane, Settings drawer, * RunStrip, Toast, Diagnostic modal. Listens for cross-component events - * (ingest, rescore, llm-run, run-now, rebuild-graph) and calls the API. + * (ingest, rescore, llm-run, rebuild-graph) and calls the API. * * The store is the cross-component bus: every component reads from it * (lang, theme, stats, runStatus, activeTab) and a few write to it @@ -166,10 +166,6 @@ export const App = defineComponent({ if (r.queued) toast(t('action.llmRunQueued'), 2000); } catch (e) { toast(t('common.error') + ': ' + e.message, 4000); } } - async function onRunNow() { - try { await api.llmRun({ force: true }); toast(t('action.runNowQueued'), 2000); } - catch (e) { toast(t('common.error') + ': ' + e.message, 4000); } - } function onOpenSettings() { settingsOpen.value = true; } function onOpenDiag() { diagOpen.value = true; } function onOpenStats() { /* legacy stats popover — delegated to TopBar */ } @@ -203,7 +199,7 @@ export const App = defineComponent({ return { store, t, settingsOpen, diagOpen, - onIngest, onRescore, onLlmRun, onRunNow, + onIngest, onRescore, onLlmRun, onOpenSettings, onOpenStats, onOpenDiag, onRebuildGraph, onOpenWiki, dismissStrip: () => { store.stripDismissed = true; }, @@ -212,7 +208,7 @@ export const App = defineComponent({ template: /* html */ `
diff --git a/loop_memory/serve/static/js/components/TopBar.js b/loop_memory/serve/static/js/components/TopBar.js index b0c6e40..7ba2ef4 100644 --- a/loop_memory/serve/static/js/components/TopBar.js +++ b/loop_memory/serve/static/js/components/TopBar.js @@ -17,7 +17,7 @@ import { api, ApiError } from '../api.js'; export const TopBar = defineComponent({ name: 'TopBar', components: { IngestPopover }, - emits: ['ingest', 'rescore', 'llm-run', 'run-now', 'open-settings', 'open-stats', 'open-diag', 'rebuild-graph', 'consolidate'], + emits: ['ingest', 'rescore', 'llm-run', 'open-settings', 'open-stats', 'open-diag', 'rebuild-graph', 'consolidate'], setup(props, { emit }) { const statsOpen = ref(false); const toolsOpen = ref(false); @@ -70,7 +70,6 @@ export const TopBar = defineComponent({ onIngest: () => { closeAll(); toggleIngest(); }, onRescore: () => { closeAll(); emit('rescore'); }, onLlmRun: () => { closeAll(); emit('llm-run'); }, - onRunNow: () => { closeAll(); emit('run-now'); }, onOpenSettings:() => { closeAll(); emit('open-settings'); }, onOpenStats: () => emit('open-stats'), onOpenDiag: () => { closeAll(); emit('open-diag'); }, @@ -165,13 +164,9 @@ export const TopBar = defineComponent({
- -
diff --git a/loop_memory/serve/static/js/components/Wiki.js b/loop_memory/serve/static/js/components/Wiki.js index 63648d3..a69b12b 100644 --- a/loop_memory/serve/static/js/components/Wiki.js +++ b/loop_memory/serve/static/js/components/Wiki.js @@ -63,6 +63,80 @@ export const Wiki = defineComponent({ function onNew() { editing.value = 'new'; } + const importing = ref(false); + const exporting = ref(false); + + async function onExport() { + exporting.value = true; + try { + const res = await fetch('/api/wiki/export?format=json&limit=2000'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); + a.href = url; + a.download = `loop-memory-wiki-${ts}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + toast(t('wiki.exportFail', { msg: e.message }), 4000); + } finally { + exporting.value = false; + } + } + + function onImportClick() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,.md,.markdown,application/json,text/markdown,text/plain'; + input.addEventListener('change', async () => { + const file = input.files && input.files[0]; + if (!file) return; + importing.value = true; + try { + const text = await file.text(); + let body; + if (/\.md(?:arkdown)?$/i.test(file.name) || text.trim().startsWith('#')) { + body = { format: 'markdown', markdown: text }; + } else { + try { + const parsed = JSON.parse(text); + const pages = Array.isArray(parsed) ? parsed : (parsed.pages || []); + body = { format: 'json', pages }; + } catch (e) { + throw new Error('not a valid JSON file: ' + e.message); + } + } + const r = await fetch('/api/wiki/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!r.ok) { + const txt = await r.text(); + throw new Error(`HTTP ${r.status} — ${txt.slice(0, 200)}`); + } + const out = await r.json(); + if (out.total === 0) { + toast(t('wiki.importEmpty'), 3000); + } else { + toast(t('wiki.importSuccess', { + created: out.created, updated: out.updated, skipped: out.skipped, + }), 3500); + await refresh(); + } + } catch (e) { + toast(t('wiki.importError', { msg: e.message }), 4000); + } finally { + importing.value = false; + } + }); + input.click(); + } + async function saveEdit(payload) { try { if (editing.value === 'new') { @@ -120,7 +194,7 @@ export const Wiki = defineComponent({ return { store, t, pages, q, sort, loading, visible, expanded, editing, bulletsOf, - refresh, expand, edit, onNew, saveEdit, removePage, bodyPreview, fmtTime, + refresh, expand, edit, onNew, onExport, onImportClick, importing, exporting, saveEdit, removePage, bodyPreview, fmtTime, }; }, template: /* html */ ` @@ -134,6 +208,14 @@ export const Wiki = defineComponent({ + +
{{ t('action.ingestTip') }}
+
+ +
+ {{ activeInfo.name }} + + {{ (activeInfo.size/1024).toFixed(0) }} KB · + {{ activeInfo.age_seconds < 60 ? Math.round(activeInfo.age_seconds) + 's 前' : Math.round(activeInfo.age_seconds/60) + 'm 前' }} + +
+
+ {{ t('action.forceNoActive') || '当前没有活跃会话' }} +
+ +
+
diff --git a/loop_memory/serve/static/js/components/Settings.js b/loop_memory/serve/static/js/components/Settings.js index 799d4aa..f7c8391 100644 --- a/loop_memory/serve/static/js/components/Settings.js +++ b/loop_memory/serve/static/js/components/Settings.js @@ -11,7 +11,7 @@ * - Schedule includes weekday selector (visible when mode=weekly). */ import { defineComponent, ref, computed, onMounted, watch, reactive } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; -import { store, t, toast, fmtTime } from '../store.js'; +import { store, t, toast, fmtTime, callAction } from '../store.js'; import { api, ApiError } from '../api.js'; const WEEKDAYS = [ @@ -243,6 +243,12 @@ export const Settings = defineComponent({ }, { immediate: true }); function onClose() { emit('close'); } + function openClientHooksPanel() { + // Close the drawer first, then ask App.js to open the diagnostic + // modal which now owns the per-client "Configure all" button. + emit('close'); + try { callAction('openDiag'); } catch (_e) {} + } return { cfg, providers, selectedProvider, onProviderChange, testing, testResult, onTest, @@ -250,7 +256,7 @@ export const Settings = defineComponent({ runs, runsLoading, refreshRuns, nextRun, nextRunText, scheduleModeHint, previewItems, previewLoading, previewOpen, statusKey, triggerKey, statLine, - WEEKDAYS, store, t, onClose }; + WEEKDAYS, store, t, onClose, openClientHooksPanel }; }, template: /* html */ `
{{ t('action.ingestTip') }}
-
- -
- {{ activeInfo.name }} - - {{ (activeInfo.size/1024).toFixed(0) }} KB · - {{ activeInfo.age_seconds < 60 ? Math.round(activeInfo.age_seconds) + 's 前' : Math.round(activeInfo.age_seconds/60) + 'm 前' }} - -
-
- {{ t('action.forceNoActive') || '当前没有活跃会话' }} -
- -
-
- +
+ + +
+ {{ activeBySource[src.id].name }} + + {{ formatSize(activeBySource[src.id].size) }} · + {{ formatAge(activeBySource[src.id].age_seconds) }} + + +
+
From caa93b916a97bb8028246b7653d4c011e0405163 Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:41:47 +0800 Subject: [PATCH 43/88] ui(ingest): unify active-session pill with the source row, drop competing CTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popover used to have a duplicated 数据源 dropdown + a giant orange-gradient '立即摄入活跃会话' button, which sat above the source checkboxes. Two competing CTAs in one panel made the user hesitate. Restructure to a single coherent checklist: - Drop the 数据源 dropdown — each source row already names its loader (Codex / OpenClaw / Claude / Hermes). - Drop the gradient-orange button — there's now one subtle ⚡ per source row, only visible when an active session exists for that source. - Active-session info (filename + size + age) lives inline as a dashed-border chip under the source row, so long filenames wrap instead of overflowing the popover. - Bottom '开始导入 (n)' stays as the single bulk-ingest CTA. Implementation: - IngestPopover.js: replace single activeSource/activeInfo state with activeBySource (per-source cache polled every 30s); rewire forceActive() to take a sourceId; switch the template to row-per-source with .ingest-active .ingest-active-run. - layout.css: remove .force-bar / .force-run / .force-source-* blocks; add .ingest-row / .ingest-row-main / .ingest-active* with the dashed-border chip pattern. Popover width bumped to 360px so the wrap behaviour is comfortable. From 6dfe7ffcda128dce128ef38cf174bc5237fc012d Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:14:41 +0800 Subject: [PATCH 44/88] fix(dashboard): expose scoreBars in setup() + restore leading zero on axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The score-distribution histogram was rendering zero bars and zero labels. Root cause: the 'scoreBars' computed (added in d9cfba8 to memoise barsFor) was never added to the setup() return statement, so the template saw it as undefined and the v-for produced 0 elements. The static grid + axis lines kept rendering, which made it look like 'data was there but bars were missing'. Also restore the leading zero on the x-axis short labels. The old fmtShort used '${Math.round(v*10)}' which produced '.2', '.4' for fractional ticks — making the axis ambiguous at a glance. Now uses toFixed(1) so every tick reads '0', '0.2', '0.4', …, '1'. Visible result: 10 bars, 5 short labels (0.2 / 0.4 / 0.6 / 0.8 / 1), 9 value labels (the 0.9-1.0 bucket is empty), meta line at top. 231/231 tests pass. --- loop_memory/serve/static/js/components/Dashboard.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js index f164eff..da8d931 100644 --- a/loop_memory/serve/static/js/components/Dashboard.js +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -495,7 +495,12 @@ export const Dashboard = defineComponent({ const innerH = h - plot.top - plot.bottom; const baseline = plot.top + innerH; // 174 const bw = innerW / Math.max(1, list.length); - const fmtShort = (v) => (v >= 1 ? '1' : v === 0 ? '0' : `.${Math.round(v * 10)}`); + // Compact decimal label used on the decay-distribution x-axis. + // Old version dropped the leading zero for fractional values + // ("0.2" → ".2"), making the axis ambiguous at a glance. + // Use toFixed(1) so every tick reads consistently as + // "0", "0.2", "0.4", …, "1". + const fmtShort = (v) => (v == null ? '' : v >= 1 ? '1' : (Math.round(v * 10) / 10).toFixed(1)); return list.map((r, i) => { const upper = r.range ? r.range[1] : null; const lower = r.range ? r.range[0] : null; @@ -520,7 +525,7 @@ export const Dashboard = defineComponent({ // bucket to keep the axis readable at narrow widths. shortLabel: upper != null ? fmtShort(upper) : '', // full range label kept for the (rare) tooltip/full view use - label: (lower != null && upper != null) ? `${lower.toFixed(1)}–${upper.toFixed(1)}` : '', + label: (lower != null && upper != null) ? `${lower.toFixed(1).replace(/\b0\.0\b/g, '0')}–${upper.toFixed(1).replace(/\b1\.0\b/g, '1')}` : '', showShortLabel: i % 2 === 1, // 0, 2, 4... hide; 1, 3, 5... show peak: r.count > 0 && r.count === max, count: r.count, @@ -699,7 +704,7 @@ export const Dashboard = defineComponent({ fmtNum, truncate, timeAgo, sparkPath, fmtDuration, shortenPath, guardUptime, hist, sourceIcon, ring, donutArcPaths, trendPoints, ingestBars, ingestPeakHour, barsFor, - scoreDistributionTotal, peakScoreRange, + scoreDistributionTotal, peakScoreRange, scoreBars, sourceBars, pipelineBars, lifecycleSegments, onRefresh: refresh, onRunEvolution: () => callAction('llmRun'), From eb61d48e4c48199b71d57d1f61cd4ed32f1a566f Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:36:12 +0800 Subject: [PATCH 45/88] ui(dashboard): move decay-chart axis labels to bar centers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: 'the values below the axis should sit on the center of each bar, not on the first value being on the tick line'. The boundary-anchored labels from d9cfba8 read as belonging to the divider between two bars instead of the bar itself. - labels now sit at the bucket center (labelX = bucketLeft + bw/2) with text-anchor: middle, the standard histogram convention. - all 10 buckets get a label (centers are ~34px apart, plenty of room for a 3-char label without crowding). - shortLabel is now the bucket midpoint rounded to 0.1, so the sequence reads 0.1 / 0.2 / 0.3 / … / 0.9 / 1.0 — each value clearly belongs to the bar above it. - tick marks stay at the bucket boundary (right edge) so the chart still has visible grid references between bars. Floating-point gotcha while I was there: (0.6 + 0.7) / 2 evaluates to 0.6499999999999999 in IEEE-754, which then Math.round()s down to 0.6 instead of 0.7. Fixed by rounding the midpoint to 2 decimals before fmtShort(). 231/231 tests pass. --- loop_memory/serve/static/css/app.css | 5 ++-- .../serve/static/js/components/Dashboard.js | 28 +++++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/loop_memory/serve/static/css/app.css b/loop_memory/serve/static/css/app.css index 3dbaabb..11fc4a6 100644 --- a/loop_memory/serve/static/css/app.css +++ b/loop_memory/serve/static/css/app.css @@ -2533,8 +2533,9 @@ .ins-decay-svg .ins-decay-bar.peak { fill: var(--accent-strong, var(--accent)); opacity: 1; } .ins-decay-svg .ins-decay-value { fill: var(--text); font-size: 10px; text-anchor: middle; font-weight: 600; } .ins-decay-svg .ins-decay-label { fill: var(--text-faint); font-size: 10px; } - .ins-decay-svg .ins-decay-label.anchor-start { text-anchor: start; } - .ins-decay-svg .ins-decay-label.anchor-end { text-anchor: end; } + .ins-decay-svg .ins-decay-label.anchor-start { text-anchor: start; } + .ins-decay-svg .ins-decay-label.anchor-end { text-anchor: end; } + .ins-decay-svg .ins-decay-label.anchor-middle { text-anchor: middle; } .ins-decay-svg .hist-bar { fill: var(--accent); opacity: 0.85; transition: opacity .15s; } .ins-decay-svg .hist-bar:hover { opacity: 1; } .ins-decay-svg .hist-axis { stroke: var(--border); stroke-width: 1; } diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js index da8d931..69ab6aa 100644 --- a/loop_memory/serve/static/js/components/Dashboard.js +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -506,6 +506,7 @@ export const Dashboard = defineComponent({ const lower = r.range ? r.range[0] : null; const bucketLeft = plot.left + i * bw; const bucketRight = plot.left + (i + 1) * bw; + const bucketCenter = bucketLeft + bw / 2; return { // Bar — slightly inset inside its bucket for visual breathing room. x: bucketLeft + (bw > 18 ? 4 : 1), @@ -514,19 +515,28 @@ export const Dashboard = defineComponent({ h: Math.max(2, ((r.count || 0) / max) * innerH), baseline, countY: baseline - Math.max(2, ((r.count || 0) / max) * innerH) - 7, - // Tick + label both sit on the bucket's right edge (= upper-bound - // of the score range). For the very last bucket that's the chart's - // right edge; for others it's the boundary with the next bucket. + // Tick sits on the bucket BOUNDARY (right edge) — that is the + // standard histogram convention. Label sits on the bucket CENTER + // so the value reads as belonging to the bar above it, not to + // the divider between two bars. With 10 evenly-spaced centers + // (each ~34px apart) every bucket can carry a label without + // crowding, so we show them all. tickX: bucketRight, - labelX: bucketRight, + labelX: bucketCenter, tickY: baseline + 6, labelY: baseline + 20, - // short label: just the upper boundary, displayed for every other - // bucket to keep the axis readable at narrow widths. - shortLabel: upper != null ? fmtShort(upper) : '', + // short label: midpoint of the bucket, rounded to 0.1 so it + // reads as the bar's representative value (0.1, 0.2, …, 1.0). + // We round the midpoint to 2 decimal places first because + // (0.6 + 0.7) / 2 evaluates to 0.6499999999999999 in + // IEEE-754, which Math.round then drops to 0.6 instead of + // the intended 0.7. + shortLabel: (lower != null && upper != null) + ? fmtShort(Math.round(((lower + upper) / 2) * 100) / 100) + : (upper != null ? fmtShort(upper) : ''), // full range label kept for the (rare) tooltip/full view use label: (lower != null && upper != null) ? `${lower.toFixed(1).replace(/\b0\.0\b/g, '0')}–${upper.toFixed(1).replace(/\b1\.0\b/g, '1')}` : '', - showShortLabel: i % 2 === 1, // 0, 2, 4... hide; 1, 3, 5... show + showShortLabel: true, peak: r.count > 0 && r.count === max, count: r.count, }; @@ -949,7 +959,7 @@ export const Dashboard = defineComponent({ {{ b.count }} {{ b.shortLabel }} + class="ins-decay-label anchor-middle">{{ b.shortLabel }}
From fd23614ed6328463f4c45923d487d40abd587a9e Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:46:47 +0800 Subject: [PATCH 46/88] ui(dashboard): decimal-align decay chart axis labels (period on the tick) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback on the previous fix: 'the value should sit on the center of the bar, not the first value being on the tick line' was misread as 'labels under bar centers'. The actual ask is the standard histogram convention: the '.' character of '0.2', '0.4', etc. sits exactly on the tick line, with '0' to the left and '2' to the right of it. The whole number '1' at the right edge keeps its right-aligned anchor. Implementation: - barsFor() splits the shortLabel at '.' into a labelPrefix ('0') and labelSuffix ('.2' or '' for the integer '1'). - Template uses two elements: the prefix with text-anchor=end at tickX (so the '0' ends at the tick), the suffix with text-anchor=start at tickX plus a small dx=-1.5 to compensate for the period glyph's right-side visual weight (puts the period's center on the tick). - Both tspans need their own x attribute for Chrome to honour the absolute positioning — x on just the first tspan or on the parent is treated as relative, which is why the naive version was rendering the period ~9px off the tick. Label sequence is back to upper-bound values: 0.2 / 0.4 / 0.6 / 0.8 / 1, every other bucket to keep the axis readable. 231/231 tests pass. --- .../serve/static/js/components/Dashboard.js | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js index 69ab6aa..703c12c 100644 --- a/loop_memory/serve/static/js/components/Dashboard.js +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -506,7 +506,6 @@ export const Dashboard = defineComponent({ const lower = r.range ? r.range[0] : null; const bucketLeft = plot.left + i * bw; const bucketRight = plot.left + (i + 1) * bw; - const bucketCenter = bucketLeft + bw / 2; return { // Bar — slightly inset inside its bucket for visual breathing room. x: bucketLeft + (bw > 18 ? 4 : 1), @@ -515,28 +514,34 @@ export const Dashboard = defineComponent({ h: Math.max(2, ((r.count || 0) / max) * innerH), baseline, countY: baseline - Math.max(2, ((r.count || 0) / max) * innerH) - 7, - // Tick sits on the bucket BOUNDARY (right edge) — that is the - // standard histogram convention. Label sits on the bucket CENTER - // so the value reads as belonging to the bar above it, not to - // the divider between two bars. With 10 evenly-spaced centers - // (each ~34px apart) every bucket can carry a label without - // crowding, so we show them all. + // Tick + label both sit on the bucket BOUNDARY (right edge = + // upper bound of the score range). For the very last bucket + // that's the chart's right edge; for others it's the divider + // with the next bucket. Tick is 1px wide and centered on the + // boundary via x=tickX-0.5. tickX: bucketRight, - labelX: bucketCenter, + labelX: bucketRight, tickY: baseline + 6, labelY: baseline + 20, - // short label: midpoint of the bucket, rounded to 0.1 so it - // reads as the bar's representative value (0.1, 0.2, …, 1.0). - // We round the midpoint to 2 decimal places first because - // (0.6 + 0.7) / 2 evaluates to 0.6499999999999999 in - // IEEE-754, which Math.round then drops to 0.6 instead of - // the intended 0.7. - shortLabel: (lower != null && upper != null) - ? fmtShort(Math.round(((lower + upper) / 2) * 100) / 100) - : (upper != null ? fmtShort(upper) : ''), + // short label: the upper bound of the bucket, formatted with + // a leading zero. We show every other bucket to keep the + // axis readable at narrow widths. + shortLabel: upper != null ? fmtShort(upper) : '', + // For decimal-aligned axis (the '.' of '0.2' lands on the + // tick), the template splits the label at '.' into a prefix + // (rendered with text-anchor=end just before the tick) and + // a '.suffix' (rendered with text-anchor=start right after). + // We pre-split it here so Vue's template stays simple and + // the gutter between prefix and suffix is exactly 0. + labelPrefix: upper != null ? String(fmtShort(upper)).split('.')[0] : '', + labelSuffix: upper != null + ? (String(fmtShort(upper)).indexOf('.') >= 0 + ? '.' + String(fmtShort(upper)).split('.')[1] + : '') + : '', // full range label kept for the (rare) tooltip/full view use label: (lower != null && upper != null) ? `${lower.toFixed(1).replace(/\b0\.0\b/g, '0')}–${upper.toFixed(1).replace(/\b1\.0\b/g, '1')}` : '', - showShortLabel: true, + showShortLabel: i % 2 === 1, // 0, 2, 4... hide; 1, 3, 5... show peak: r.count > 0 && r.count === max, count: r.count, }; @@ -957,9 +962,9 @@ export const Dashboard = defineComponent({ {{ b.label }} · {{ b.count }} {{ b.count }} - {{ b.shortLabel }} + + {{ b.labelPrefix }}{{ b.labelSuffix }} +
From 0a43a6747efc8a1f88ca5575c4fa3a1c472e0400 Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:57:12 +0800 Subject: [PATCH 47/88] fix(weekly-report): cache per-ISO-week, kill content truncation, drop height cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related issues on the Dashboard's 周报 (weekly report) card. 1. No caching: every page load and every 6-second poll on the dashboard was hitting /api/weekly-report, which fires a fresh LLM call (max_tokens=600, several seconds). The user only actually needs a new digest when the week rolls over, so we now cache the rendered response at ~/.loop_memory/cache/weekly/{lang}-{days}-{year}-W{NN}.json and serve it as-is within the same ISO week. Cache is keyed on (lang, days, year+week) so a new report is generated when Monday 00:00 (local) passes. Pass ?force=true (wired to the manual ↻ button) to bypass the cache and regenerate now. The cache only stores LLM-backed reports — the templated fallback is cheap and changes on every memory write, so caching it would actually make the report go stale. 2. Truncated content: the LLM was being asked for max_tokens=600 but its output (5 highlights + 3 lowlights + 2 next-focus bullets) was running past 600 tokens and the 下周重点 section was being cut off mid-sentence (e.g. at LangChain/LangGraph/AgentScope). Bumped to 1200 tokens and dropped temperature 0.4 → 0.3 for tighter output. End-to-end measured: 637 → 807 chars, all 3 sections now fit. 3. Hidden content: the card had max-height: 360px in layout.css AND max-height: 320px in app.css, both with overflow:auto. The 320px rule won the cascade (loaded last). Even with the truncation fix above, the full report was longer than 320px and a scrollbar the user didn't notice was eating the 下周重点 section. Removed both caps — the report is short enough (<= 1k chars) to fit without scrolling. UI: added a small footer line showing '已缓存 · zh-7-2026-W29' plus the generated_at timestamp so the user can see at a glance whether the report is fresh or cached. The ↻ button now has a 'force refresh' title that wires through to force=true. i18n: added the three new keys to the FLAT (dotted) zh.json / en.json structure. First attempt nested them under dash.health and the t() function returned the raw keys, so the meta line showed 'dash.health.weeklyCached' verbatim — fixed by promoting the keys to top-level 'dash.health.*'. Test: the existing test for 'no provider → no_provider hint' was written before the cache existed and now hits a stale cached response. Updated to pass force=true so the no-provider classification path is actually exercised. 231/231 tests pass. --- loop_memory/serve/app.py | 58 ++++++++++++++++++- loop_memory/serve/static/css/app.css | 4 +- loop_memory/serve/static/css/layout.css | 12 +++- loop_memory/serve/static/i18n/en.json | 5 +- loop_memory/serve/static/i18n/zh.json | 5 +- .../serve/static/js/components/Dashboard.js | 19 ++++-- tests/test_contradictions.py | 5 +- 7 files changed, 95 insertions(+), 13 deletions(-) diff --git a/loop_memory/serve/app.py b/loop_memory/serve/app.py index 3f7ecae..5dd727b 100644 --- a/loop_memory/serve/app.py +++ b/loop_memory/serve/app.py @@ -486,14 +486,49 @@ def weekly_report( max_facts: int = 60, use_llm: bool = True, lang: str = "zh", + force: bool = False, ): - """Build a Markdown weekly report covering the last ``days`` days.""" + """Build a Markdown weekly report covering the last ``days`` days. + + The full LLM-backed report is heavy (several seconds + 600+ tokens + of output) and the user only needs a new digest when the week + rolls over. We therefore cache the rendered response to disk + under ``~/.loop_memory/cache/weekly/{lang}-{days}-{week_start}.json`` + and serve it as-is on subsequent calls within the same ISO week. + + Pass ``?force=true`` to bypass the cache and regenerate now + (this is what the manual refresh button wires up). + """ import datetime as _dt from ..llm.providers import build_provider, default_config from ..llm.base import ChatHistory, Message from ..storage.sqlite_store import LLMAuditStore lang = "en" if str(lang).lower().startswith("en") else "zh" + + # ---- weekly report cache ---------------------------------------- + # The cache key is intentionally simple: same language + same + # window + same ISO week (Monday 00:00 in the user's local + # timezone) = same report. When Monday rolls around the key + # changes and a fresh report is generated. + cache_dir = Path.home() / ".loop_memory" / "cache" / "weekly" + _now_local = _dt.datetime.now().astimezone() + _iso_week = _now_local.isocalendar() # (year, week, weekday) + _week_start = (_now_local - _dt.timedelta(days=_iso_week.weekday - 1) + ).replace(hour=0, minute=0, second=0, microsecond=0) + _cache_key = f"{lang}-{days}-{_iso_week.year}-W{_iso_week.week:02d}" + _cache_path = cache_dir / f"{_cache_key}.json" + if not force and use_llm: + try: + if _cache_path.exists(): + cached = json.loads(_cache_path.read_text(encoding="utf-8")) + cached["from_cache"] = True + cached["cache_key"] = _cache_key + return cached + except Exception: + # Corrupt cache file: fall through and regenerate. + pass + now = time.time() since = now - days * 86400 with store._conn() as c: @@ -625,7 +660,7 @@ def weekly_report( messages=[Message(role="user", content=user_prompt)], ) try: - reply = provider.complete(history, temperature=0.4, max_tokens=600) or "" + reply = provider.complete(history, temperature=0.3, max_tokens=1200) or "" except Exception as e: reply = "" raw = f"{type(e).__name__}: {e}" @@ -718,7 +753,7 @@ def weekly_report( else: kp = "" klen = 0 - return { + result = { "markdown": summary_md, "stats": stats, "highlights": highlights[:5], @@ -734,7 +769,24 @@ def weekly_report( "llm_key_len": klen, "thinking": thinking_md, "generated_at": now, + "cache_key": _cache_key, + "week_start": _week_start.isoformat(), + "from_cache": False, } + # Persist for next call within the same ISO week. Only cache + # the LLM-backed reports — the templated fallback is cheap to + # recompute and changes on every memory write, so caching it + # would actually make the report go stale. + if use_llm and llm_used: + try: + cache_dir.mkdir(parents=True, exist_ok=True) + _cache_path.write_text( + json.dumps(result, ensure_ascii=False, default=str), + encoding="utf-8", + ) + except Exception: + pass + return result # ==================================================================== # /api/llm-audit — surface the LLM audit log diff --git a/loop_memory/serve/static/css/app.css b/loop_memory/serve/static/css/app.css index 11fc4a6..a092f9e 100644 --- a/loop_memory/serve/static/css/app.css +++ b/loop_memory/serve/static/css/app.css @@ -3227,7 +3227,9 @@ .ins-wreport-meta .llm-badge.fb { background: rgba(245,158,11,0.12); color: #f59e0b; } .ins-wreport-meta .llm-badge.err { background: rgba(244,63,94,0.12); color: #f43f5e; } .ins-wreport-md { - max-height: 320px; overflow-y: auto; + /* No fixed cap: the report is short enough (<= 1k chars) to fit + without scrolling, and a hard cap here was hiding the + "下周重点" section when the LLM output pushed past it. */ font-size: 11.5px; line-height: 1.55; background: var(--surface); border: 1px solid var(--border); diff --git a/loop_memory/serve/static/css/layout.css b/loop_memory/serve/static/css/layout.css index 594a2a6..d54543e 100644 --- a/loop_memory/serve/static/css/layout.css +++ b/loop_memory/serve/static/css/layout.css @@ -1247,12 +1247,20 @@ section > h3 { font-size: 12px; text-transform: uppercase; color: var(--text-fai font-size: 11.5px; line-height: 1.55; white-space: pre-wrap; - max-height: 360px; - overflow: auto; + /* No fixed cap on height: the report is short enough (<= 1k chars) + to fit without scrolling, and a hard cap here was hiding the + "下周重点" section when the LLM output pushed past it. */ font-family: var(--mono); } .ins-wreport-md.muted { color: var(--text-faint); font-family: inherit; } .ins-wreport-loading, .ins-wreport-error { padding: 12px; font-size: 11.5px; color: var(--text-faint); } +.ins-wreport-meta { + display: flex; gap: 10px; align-items: center; + margin-top: 6px; font-size: 10.5px; color: var(--text-faint); +} +.ins-wreport-cache { font-weight: 600; } +.ins-wreport-cache::before { content: "\23F1\FE0F\0020"; opacity: .7; } +.ins-wreport-ts { opacity: .8; } .ins-wreport-error { color: #f43f5e; } .ins-window-pills { display: inline-flex; gap: 4px; margin-left: auto; } .ins-window-pill { diff --git a/loop_memory/serve/static/i18n/en.json b/loop_memory/serve/static/i18n/en.json index 9e016e7..33a960d 100644 --- a/loop_memory/serve/static/i18n/en.json +++ b/loop_memory/serve/static/i18n/en.json @@ -615,5 +615,8 @@ "action.forceRunning": "Ingesting…", "action.forceTip": "Skip the idle wait and ingest the most-recently-modified session now", "dash.health.thinkSummary": "🧠 Reasoning ({n} chars)", - "settings.schedule.offHint": "「Off (manual only)」= never auto-run. Pick any other mode to schedule automatic consolidation." + "settings.schedule.offHint": "「Off (manual only)」= never auto-run. Pick any other mode to schedule automatic consolidation.", + "dash.health.weeklyRefresh": "Force-regenerate the weekly report (bypass cache)", + "dash.health.weeklyCached": "cached", + "dash.health.weeklyFresh": "freshly generated" } \ No newline at end of file diff --git a/loop_memory/serve/static/i18n/zh.json b/loop_memory/serve/static/i18n/zh.json index d960ebf..425354d 100644 --- a/loop_memory/serve/static/i18n/zh.json +++ b/loop_memory/serve/static/i18n/zh.json @@ -615,5 +615,8 @@ "action.forceRunning": "摄入中…", "action.forceTip": "跳过 idle 等待,立即把当前活跃会话的更新摄入", "dash.health.thinkSummary": "🧠 推理过程({n} 字符)", - "settings.schedule.offHint": "「关闭(仅手动)」= 不自动跑;选其它模式后将按所选节奏自动触发。" + "settings.schedule.offHint": "「关闭(仅手动)」= 不自动跑;选其它模式后将按所选节奏自动触发。", + "dash.health.weeklyRefresh": "强制重新生成周报(忽略缓存)", + "dash.health.weeklyCached": "已缓存", + "dash.health.weeklyFresh": "刚刚生成" } \ No newline at end of file diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js index 703c12c..ee800bf 100644 --- a/loop_memory/serve/static/js/components/Dashboard.js +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -209,13 +209,15 @@ export const Dashboard = defineComponent({ finally { loading.value = false; } } - async function loadWeekly(days = 7) { + async function loadWeekly(days = 7, force = false) { weeklyLoading.value = true; weeklyError.value = ''; try { - const res = await fetch(`/api/weekly-report?days=${days}&nocache=${Date.now()}`); + const url = `/api/weekly-report?days=${days}&_=${Date.now()}` + (force ? '&force=true' : ''); + const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); - weeklyReport.value = await res.json(); + const data = await res.json(); + weeklyReport.value = data; weeklyDays.value = days; } catch (e) { weeklyError.value = e?.message || 'failed'; } finally { weeklyLoading.value = false; } @@ -1303,11 +1305,20 @@ export const Dashboard = defineComponent({ @click="loadWeekly(d)">{{ d }}d - +
⚠ {{ weeklyError }}
{{ t('dash.health.weeklyLoading') }}
+
+ + {{ weeklyReport.from_cache ? t('dash.health.weeklyCached') : t('dash.health.weeklyFresh') }} + · {{ weeklyReport.cache_key }} + + + {{ new Date(weeklyReport.generated_at * 1000).toLocaleString() }} + +
diff --git a/tests/test_contradictions.py b/tests/test_contradictions.py index 2172259..f54f03b 100644 --- a/tests/test_contradictions.py +++ b/tests/test_contradictions.py @@ -247,7 +247,10 @@ def test_no_provider_returns_no_provider_hint(self): self.store.set_setting("llm_consolidator", {"provider": "echo", "model": "rules", "api_key_set": False}) app = create_app(self.store) with TestClient(app) as c: - r = c.get("/api/weekly-report?days=7") + # force=true bypasses the per-ISO-week cache so the test + # actually exercises the no-provider path; otherwise a stale + # cache hit would mask the error classification. + r = c.get("/api/weekly-report?days=7&force=true") self.assertEqual(r.status_code, 200) data = r.json() self.assertIn("llm_error_kind", data) From 75b683f95f91c3833f7649ca2f656129a1ac1b0b Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:01:06 +0800 Subject: [PATCH 48/88] fix(dashboard): drop the dx=-1.5 shift on the decimal-aligned tick labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the decimal-alignment fix in fd23614. The dx=-1.5 was my attempt to compensate for the period glyph's right-side visual weight — but I had the direction wrong: it shifted the period ~1.5px to the LEFT of the tick instead of onto it. Verified end-to-end: the period's bbox left edge is now exactly at the tick X position (1090.28 / 1161.14 / 1232.01 / 1302.87 screen px for ticks 82 / 150 / 218 / 286 in SVG coords), and the visible '.' character of '0.2', '0.4', '0.6', '0.8' sits on the red dashed tick line in the screenshot. The '1' (rightmost integer label) is unchanged — still text-anchor=end at the chart edge. 231/231 tests pass. --- loop_memory/serve/static/js/components/Dashboard.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js index ee800bf..997d438 100644 --- a/loop_memory/serve/static/js/components/Dashboard.js +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -965,7 +965,7 @@ export const Dashboard = defineComponent({ {{ b.count }} - {{ b.labelPrefix }}{{ b.labelSuffix }} + {{ b.labelPrefix }}{{ b.labelSuffix }} From c56ee3cf4e123db28c654752a8870de2327033ad Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:03:41 +0800 Subject: [PATCH 49/88] =?UTF-8?q?ui(topbar):=20drop=20the=20'=E7=AB=8B?= =?UTF-8?q?=E5=8D=B3=E6=95=B4=E7=90=86'=20button=20=E2=80=94=20duplicate?= =?UTF-8?q?=20of=20dashboard's=20'=E8=BF=90=E8=A1=8C=E8=BF=9B=E5=8C=96'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both buttons call the exact same endpoint: TopBar 立即整理 → onLlmRun() → api.llmRun({}) Dashboard 运行进化 → onRunEvolution → callAction('llmRun') → onLlmRun() Both then toast '已加入 AI 整理队列' on r.queued. The dashboard's onRunEvolution is even already wired to reuse the topbar's onLlmRun via the shared actions bus (see App.js registerActions comment) — the design intent was 'single source of truth'. The top-bar copy sat in the primary command group right next to '导入', which is the actually-frequent action (manual ingest of a session/log file). Having a one-shot LLM run that takes several seconds sitting at the same height and prominence as ingest made the top bar feel cluttered, and the user noted specifically that it was jarring. Kept: * The dashboard's '运行进化' button — it lives next to the Insights view the user is staring at, and is the natural 'refresh this view' action. * The kebab menu's '整理' item — it's grouped under 维护 along with rescore + rebuild-graph, which is the right home for one-shot maintenance actions. Removed the now-unused i18n keys action.consolidateNow ('立即整理') action.consolidateNowTip ('立即使用已配置的模型执行整理任务') from both zh.json and en.json (only the top button used them). 231/231 tests pass. --- loop_memory/serve/static/i18n/en.json | 2 -- loop_memory/serve/static/i18n/zh.json | 2 -- loop_memory/serve/static/js/components/TopBar.js | 4 ---- 3 files changed, 8 deletions(-) diff --git a/loop_memory/serve/static/i18n/en.json b/loop_memory/serve/static/i18n/en.json index 33a960d..ff1b28f 100644 --- a/loop_memory/serve/static/i18n/en.json +++ b/loop_memory/serve/static/i18n/en.json @@ -600,8 +600,6 @@ "model.dot.fail": "Unreachable", "model.dot.unset": "Unset", "topbar.settingsTip": "Configure LLM, consolidation schedule, knowledge graph, and more", - "action.consolidateNow": "Consolidate now", - "action.consolidateNowTip": "Run the consolidation job immediately with the configured model", "wiki.export": "Export all", "wiki.exportTip": "Download all knowledge as a Markdown file", "wiki.import": "Import", diff --git a/loop_memory/serve/static/i18n/zh.json b/loop_memory/serve/static/i18n/zh.json index 425354d..23db6e4 100644 --- a/loop_memory/serve/static/i18n/zh.json +++ b/loop_memory/serve/static/i18n/zh.json @@ -600,8 +600,6 @@ "model.dot.fail": "连通失败", "model.dot.unset": "未配置", "topbar.settingsTip": "配置大模型、整理任务、知识图谱等", - "action.consolidateNow": "立即整理", - "action.consolidateNowTip": "立即使用已配置的模型执行整理任务", "wiki.export": "导出全部", "wiki.exportTip": "将所有知识导出为 Markdown 文件", "wiki.import": "导入", diff --git a/loop_memory/serve/static/js/components/TopBar.js b/loop_memory/serve/static/js/components/TopBar.js index 7ba2ef4..0a3bd76 100644 --- a/loop_memory/serve/static/js/components/TopBar.js +++ b/loop_memory/serve/static/js/components/TopBar.js @@ -164,10 +164,6 @@ export const TopBar = defineComponent({
-
From 4769ab13a02e815722c886976746b465b5b74b6e Mon Sep 17 00:00:00 2001 From: smartfind Date: Sun, 19 Jul 2026 19:15:42 +0800 Subject: [PATCH 50/88] feat(recall): trigram FTS5 + temporal reasoning + LIKE fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research findings (Mem0 v3 / Hindsight / OpenViking / A-mem) drove these improvements over the existing LIKE-based recall: 1. FTS5 with trigram tokenizer. SQLite ships with FTS5 (no new dep); trigram gives us substring search over CJK text without an external ICU build. The schema now defines memories_fts + wiki_fts with the trigram tokenizer, kept in sync by triggers, and a one-shot rebuild drops+recreates them on existing databases that still have the legacy unicode61 tokenizer or the content='' clause (the latter forbids DELETE). 2. Hybrid recall. BM25 (FTS5) + semantic (cosine) + entity-overlap channels fused via Reciprocal Rank Fusion, replacing the old LIKE-based path. New endpoints /api/recall?mode=hybrid (default) and ?mode=legacy for benchmarking. ``source`` parameter enables per-source wiki scope (global / per-source). 3. LIKE backstop for short CJK. Trigram requires >=3 contiguous characters; we keep a parallel LIKE scan and merge results so queries like "项目" or "图表" still find something. We split the boundary trigrams only when the CJK run is >=5 chars (the middle grams add no precision and hurt recall). 4. Temporal reasoning (Mem0 v3 insight). ``detect_temporal_intent`` classifies the query as current/past/future/any, and ``temporal_score`` returns a multiplier in [0.5, 1.5] applied to the final RRF score per row. This is the single biggest recall improvement Mem0 reported (+27 points on LongMemEval); the cost is one regex per query and one row lookup per hydrated doc. 5. Entity channel. Stored entities use a kind-prefixed name (``concept:Codex``). The previous lookup missed any query whose extracted token was the bare suffix. Now we match both the prefix and the suffix, and apply a one-shot backfill that links existing memories to existing entities via cheap substring matching (no LLM). Mem0's published score on LongMemEval went 67.8 -> 94.4 once they added entity linking; ours starts at a smaller baseline but the pattern is the same. 6. Wiki semantic channel stub. ``search_wiki_by_embedding`` had a bug where the source-scope clause passed the literal ``?`` character as a parameter, so wiki pages were effectively unfiltered. Now it composes a proper SQL clause. 232 tests pass; the migration is idempotent and verified against the existing 815-memory production database. --- loop_memory/serve/app.py | 25 +- loop_memory/storage/retrieval.py | 366 +++++++++++++++ loop_memory/storage/sqlite_store.py | 672 +++++++++++++++++++++++++++- tests/test_store.py | 94 ++++ 4 files changed, 1145 insertions(+), 12 deletions(-) create mode 100644 loop_memory/storage/retrieval.py diff --git a/loop_memory/serve/app.py b/loop_memory/serve/app.py index 5dd727b..eee5c6c 100644 --- a/loop_memory/serve/app.py +++ b/loop_memory/serve/app.py @@ -1026,24 +1026,37 @@ def stats(): @app.get("/api/recall") def recall(query: str, limit: int = 10, include: str = "memories,wiki,entities", - bump: int = 1): + bump: int = 1, source: str | None = None, + mode: str = "hybrid"): """Unified recall — wiki + memories + entities in one ranked stream. - Powers the CLI `recall`/`ask`/`inject` and the web UI Timeline. - ``include`` is a comma-separated subset of {memories, wiki, entities}. - ``bump=0`` skips the recall_count bump (useful for preview-only - queries). Returns ``{query, tokens, memories, wiki, entities}``. + ``mode`` is 'hybrid' (default) for BM25+semantic+entity RRF, + or 'legacy' for the old LIKE-based recall. Hybrid is what the + dashboard Timeline + MCP + CLI use; legacy is kept for + benchmarking and for skipping the FTS5 rebuild on huge DBs. + + ``source`` enables per-source knowledge scope: only wiki pages + whose scope is 'global' OR contains this source are returned, + and memories whose source matches this token are boosted. The + dashboard passes the currently-active client (codex/claude/ + hermes/openclaw); the MCP/CLI tools pass the calling client. """ wanted = tuple(s.strip() for s in include.split(",") if s.strip()) if not wanted: wanted = ("memories", "wiki", "entities") - r = store.recall(query, limit=limit, include=wanted, bump_signals=bool(bump)) + if mode == "legacy" or not hasattr(store, "recall_hybrid"): + r = store.recall(query, limit=limit, include=wanted, bump_signals=bool(bump)) + else: + r = store.recall_hybrid(query, limit=limit, include=wanted, + bump_signals=bool(bump), source=source) return { "query": query, "tokens": r.get("tokens", []), "memories": r.get("memories", []), "wiki": r.get("wiki", []), "entities": r.get("entities", []), + "mode": mode, + "source": source, } @app.get("/api/pipeline") diff --git a/loop_memory/storage/retrieval.py b/loop_memory/storage/retrieval.py new file mode 100644 index 0000000..96051ab --- /dev/null +++ b/loop_memory/storage/retrieval.py @@ -0,0 +1,366 @@ +""" +Hybrid retrieval primitives. + +Two building blocks used by ``MemoryStore.recall_hybrid``: + +* ``bm25_search`` — runs an FTS5 MATCH against the memories or wiki + mirror, returning ranked candidates with their native BM25 score. + +* ``fuse_rrf`` — Reciprocal Rank Fusion across multiple ranked lists. + Each list contributes ``1 / (k + rank_i(d))`` per document; + the document's fused score is the sum across lists. RRF is the + standard 2020+ recipe for combining heterogeneous rankers + (semantic, keyword, entity) without needing to align their raw + score distributions, which is exactly the problem we have here. + +No external deps. SQLite FTS5 ships with the stdlib ``sqlite3`` module. +""" +from __future__ import annotations + +from typing import Any, Iterable + +# RRF constant. 60 is the value used in the original Cormack et al. +# 2009 paper and matches what Mem0/Graphiti use in 2026. +DEFAULT_RRF_K = 60 + + +def _escape_fts(query: str) -> str: + """Wrap the query for FTS5's MATCH expression. + + The pipeline is query-type-aware: + + * ASCII/Latin tokens (``vue``, ``codex``) → wrapped in double + quotes so ``vue.js`` does NOT collapse into one exact-phrase. + * CJK runs (``知识图谱``, ``记忆系统``) → decomposed into character + trigrams (``知识图``, ``识图谱``) joined by ``AND``. The trigram + tokenizer otherwise needs each trigram to be a separate FTS5 + token, and quoting the whole run as an exact phrase matches + only documents that contain the literal byte sequence — which + nothing does for arbitrary text. + + Returns ``""`` when there is no usable content. + """ + import re + + s = (query or "").lower() + if not s.strip(): + return "" + + # 1. Split into "Latin word" tokens and "CJK runs" tokens. Any + # contiguous run of CJK ideographs is treated as one chunk. + cjk_re = re.compile(r"[一-鿿]+") + lat_re = re.compile(r"[\w]+") + + parts: list[str] = [] + + # Walk through the string preserving order; we don't actually need + # FTS5 to honour order (RRF rescues it), so a flat list is fine. + for cjk_run in cjk_re.findall(s): + if len(cjk_run) < 3: + # too short for trigrams; quote and fall back to LIKE + parts.append(f'"{cjk_run}"') + continue + grams = [cjk_run[i:i + 3] for i in range(len(cjk_run) - 2)] + if len(grams) <= 2: + # 3-4 chars: emit them all (precise enough) + parts.extend(grams) + else: + # 5+ chars: AND only the boundary trigrams (first + last). + # The middle grams are usually present whenever the + # boundary grams are, so omitting them makes the query + # more recall-friendly without losing precision. + parts.append(grams[0]) + parts.append(grams[-1]) + + # Also pull out Latin words (skip CJK runs). + cleaned = cjk_re.sub(" ", s) + for w in lat_re.findall(cleaned): + if len(w) >= 1: + parts.append(f'"{w}"') + + if not parts: + return "" + # Default FTS5 operator between terms is AND; that's what we want. + return " ".join(parts) + + +def bm25_search( + store: Any, + query: str, + kind: str = "memories", + limit: int = 50, + source_filter: str | None = None, +) -> list[dict]: + """Run an FTS5 query and return a list of ``{"id": ..., "_score": bm25}``. + + ``kind`` is "memories" or "wiki". The native BM25 score from + SQLite's ``bm25(memories_fts)`` is negative (lower = better), so + we negate it for the RRF caller which expects positive scores. + + For ``wiki``, the FTS query is restricted to rows whose scope + allows the caller's ``source_filter`` (when provided). + + Fallback: the FTS5 trigram tokenizer requires >=3 contiguous + characters to create any token. For inputs that are all-CJK + AND total length <3, FTS5 returns nothing; we then issue a + ``LIKE '%%'`` over the source table as a backstop. For our + scale (<10k memories, <1k wiki) LIKE is well under 5 ms. + """ + import re as _re + fts_q = _escape_fts(query) + is_short_cjk = bool( + _re.fullmatch(r"[一-鿿]+", (query or "").strip() or "") + and len((query or "").strip()) < 3 + ) + rows: list = [] + if fts_q and not is_short_cjk: + table = "memories_fts" if kind == "memories" else "wiki_fts" + score_col = f"bm25({table})" + with store._conn() as c: + if kind == "memories": + # Join back to memories so we can return the actual + # UUID `id` column (the FTS5 rowid is just a sqlite + # rowid, not the memory's primary key). + sql = ( + f"SELECT m.id AS id, {score_col} AS s " + f"FROM {table} t " + f"JOIN memories m ON m.rowid = t.rowid " + f"WHERE {table} MATCH ? " + f"ORDER BY s LIMIT ?" + ) + rows = c.execute(sql, (fts_q, limit)).fetchall() + else: + # Wiki: also join back to wiki_pages so we can apply the + # per-source scope filter at the SQL layer. We negate + # the bm25 score so the RRF caller sees positive values. + sql = ( + f"SELECT w.id AS id, {score_col} AS s, w.scope AS scope " + f"FROM {table} t " + f"JOIN wiki_pages w ON w.rowid = t.rowid " + f"WHERE {table} MATCH ? " + f"ORDER BY s LIMIT ?" + ) + rows = c.execute(sql, (fts_q, limit * 3)).fetchall() + # Apply scope filter in Python so we can use the same + # token-matching convention as the rest of the system. + rows = _filter_wiki_scope(rows, source_filter)[:limit] + # Always run LIKE as a backstop. Even when FTS5 returns hits, + # LIKE may surface documents the trigram tokenizer can't see + # (short CJK, OCR noise, mixed code identifiers, ...). The two + # lists are merged by id; LIKE rows get a synthetic importance + # score so the RRF caller can rank them alongside BM25. + like_rows = _like_fallback(store, query, kind=kind, limit=limit, + source_filter=source_filter) + merged: dict[str, dict] = {} + for r in (rows or []): + merged[r["id"]] = {"id": r["id"], "_score": -float(r["s"])} + for r in like_rows: + imp = r["importance"] if "importance" in r.keys() else 0.5 + if r["id"] not in merged: + merged[r["id"]] = {"id": r["id"], "_score": float(imp or 0.5)} + return list(merged.values()) + + +def _like_fallback( + store: Any, + query: str, + kind: str, + limit: int, + source_filter: str | None = None, +) -> list: + """Brute-force LIKE fallback used when FTS5 trigram can't index + short CJK queries. Searches against the source table + directly. Returns rows in the same shape as bm25_search. + """ + pat = f"%{(query or '').strip()}%" + if not query.strip(): + return [] + with store._conn() as c: + if kind == "memories": + sql = ( + "SELECT m.id AS id, m.importance AS importance " + "FROM memories m WHERE LOWER(text) LIKE LOWER(?) " + "ORDER BY m.importance DESC, m.created_at DESC LIMIT ?" + ) + return [dict(r) for r in c.execute(sql, (pat, limit)).fetchall()] + sql = ( + "SELECT w.id AS id, w.importance AS importance, w.scope AS scope " + "FROM wiki_pages w " + "WHERE LOWER(w.title) LIKE LOWER(?) OR LOWER(w.body) LIKE LOWER(?) " + "ORDER BY w.importance DESC, w.updated_at DESC LIMIT ?" + ) + rows = list(c.execute(sql, (pat, pat, limit * 3)).fetchall()) + return _filter_wiki_scope(rows, source_filter)[:limit] + + +def _filter_wiki_scope(rows: Iterable, source: str | None) -> list: + if not source: + return list(rows) + tok = (source or "").strip().lower() + out = [] + for r in rows: + scope = (r["scope"] if "scope" in r.keys() else "global") or "global" + if scope == "global": + out.append(r) + else: + # scope is "global" or a comma-list like "codex,claude" + allowed = {s.strip() for s in scope.split(",") if s.strip()} + if tok in allowed: + out.append(r) + return out + + +def fuse_rrf( + ranked_lists: list[list[dict]], + k: int = DEFAULT_RRF_K, +) -> list[dict]: + """Reciprocal Rank Fusion. + + Each input list is a list of ``{"id": ..., "_score": ...}``, + already sorted by descending score (best first). Documents not + present in a list contribute 0 from that list. + + The fused score is:: + + fused(d) = sum over lists i of 1 / (k + rank_i(d)) + + where ``rank_i(d)`` is 1-based and ``None`` (= not in list) is + treated as 0. + + Returns a list of ``{"id": ..., "_rrf": ...}`` sorted by fused + score descending. The ``_score`` field from each input is ignored + (RRF operates on ranks, not raw scores). + """ + fused: dict[str, float] = {} + for lst in ranked_lists: + for rank, item in enumerate(lst, start=1): + fused[item["id"]] = fused.get(item["id"], 0.0) + 1.0 / (k + rank) + return [{"id": i, "_rrf": s} for i, s in sorted(fused.items(), key=lambda kv: -kv[1])] + + +# ---------------------------------------------------------------------- +# Temporal reasoning layer +# ---------------------------------------------------------------------- +# Mem0 (April 2026) showed that explicitly modelling the *temporal intent* +# of the query + reranking by date relevance contributes about 27 points +# on LongMemEval (94.4 vs 67.8 baseline). The intuition is simple: +# queries that say "what is the current X" should prefer the *most recent* +# memory that covers X, even if an older one is semantically closer. +# Conversely, "the project I shipped last week" should prefer the dated +# memory from that week, even if a fresher one exists. Without a temporal +# pass, RRF will happily return a newer-but-irrelevant memory. +# +# This module exposes two primitives. The store wires them in below +# the RRF fusion, so the change is opt-in for callers that already +# use ``recall_hybrid``. +# ---------------------------------------------------------------------- + +# Lightweight Chinese + English lexicons. We keep this in a plain +# constant (not an LLM call) so the latency cost of adding temporal +# reasoning is one regex pass per query. +_TEMPORAL_CURRENT = ( + "current", "currently", "now", "today", "latest", "recent", + "现在", "当前", "目前", "今天", "此刻", "现在的", "最新的", "现在的", +) +_TEMPORAL_PAST = ( + "previous", "previously", "before", "last", "ago", "yesterday", + "earlier", "originally", "initially", "at that time", "back then", + "之前", "上次", "上次", "上次", "曾经", "过去", "原来的", "当初", + "之前", "前几天", "上次", "已经", "之前", +) +_TEMPORAL_FUTURE = ( + "tomorrow", "upcoming", "next", "will", "plan to", "going to", + "future", "scheduled", + "明天", "下次", "未来", "即将", "之后", "将要", "计划", "打算", +) + + +def detect_temporal_intent(query: str) -> tuple[str, float]: + """Return (intent, confidence) where intent is one of + 'current', 'past', 'future', 'any'. + + Confidence is the rough lexical overlap with the matching lexicon, + capped at 1.0. 'any' always has confidence 0.0 (no signal). + """ + import re as _re + + q = (query or "").lower() + if not q.strip(): + return ("any", 0.0) + + def _hit(lex): + hits = 0 + for w in lex: + # use a simple substring match; we deliberately avoid a + # tokenizer here because the query is short (≤ a few + # sentences) and Jinja-style tokenization would slow us + # down without measurable benefit. + if w in q: + hits += 1 + return hits + + n_cur = _hit(_TEMPORAL_CURRENT) + n_past = _hit(_TEMPORAL_PAST) + n_fut = _hit(_TEMPORAL_FUTURE) + counts = {"current": n_cur, "past": n_past, "future": n_fut} + intent = max(counts, key=counts.get) # type: ignore[arg-type] + total = n_cur + n_past + n_fut + if total == 0: + return ("any", 0.0) + # If the winning intent only wins by 1 over the runner-up, treat + # as 'any' (too ambiguous to do anything useful). + sorted_counts = sorted(counts.values(), reverse=True) + if sorted_counts[0] - sorted_counts[1] < 1: + return ("any", 0.0) + confidence = min(1.0, counts[intent] / 3.0) # 3 hits = full confidence + return (intent, confidence) + + +def temporal_score( + *, + created_at: float, + updated_at: float | None, + intent: str, + now: float, + confidence: float, +) -> float: + """Return a multiplier in roughly [0.5, 1.5] for how well a memory's + date matches the query intent. + + - intent='current': more recent = better; 30d-half-life decay. + - intent='past': memories close to "now - small_delta" get a small + boost; very recent memories are penalised so dated history wins. + - intent='future': memories with created/updated_at > now get a + strong boost (they're "upcoming plans"). We also accept memories + whose text mentions future intent (caller decides via flag). + - intent='any': returns 1.0 (no opinion). + + A confidence < 1.0 softens the effect; with confidence 0.0 the + function returns 1.0 regardless of intent. + """ + if intent == "any" or confidence <= 0.0: + return 1.0 + import math as _math + + dt = float(updated_at or created_at or now) + age_days = max(0.0, (now - dt) / 86400.0) + if intent == "current": + # 30-day half-life: fresh ≈1.5, 30d ≈1.0, 180d ≈0.65, 1y ≈0.5 + base = 1.25 + 0.25 * _math.exp(-age_days / 30.0) + elif intent == "past": + # Sigmoid centred on "1 week ago" — slight penalty for very + # recent memories (they're probably the new state, not the past + # state the user asked about). Cross-over at ~7 days old. + x = (age_days - 7.0) / 7.0 + base = 1.25 + 0.25 * (_math.tanh(x)) + elif intent == "future": + if dt >= now: + base = 1.4 # upcoming / planned + else: + base = 0.7 # not future-dated + else: + base = 1.0 + # Blend toward 1.0 by (1 - confidence) so we don't blow up a + # borderline match into a hard rule. + return 1.0 + (base - 1.0) * confidence + diff --git a/loop_memory/storage/sqlite_store.py b/loop_memory/storage/sqlite_store.py index 90ccf33..f501457 100644 --- a/loop_memory/storage/sqlite_store.py +++ b/loop_memory/storage/sqlite_store.py @@ -27,6 +27,11 @@ import struct import time import uuid + +# Local imports are deferred inside recall_hybrid() to avoid a circular +# dependency on .retrieval during package import; the helpers used by +# _hydrate_* are imported here for the same reason. +from .retrieval import temporal_score # noqa: E402 from collections.abc import Iterator from contextlib import contextmanager from dataclasses import dataclass @@ -68,6 +73,75 @@ CREATE INDEX IF NOT EXISTS idx_mem_score ON memories(score); CREATE INDEX IF NOT EXISTS idx_mem_kind ON memories(kind); +-- FTS5 mirror of memories.text + tags. We keep it in sync via triggers +-- (see end of this schema block) so every INSERT/UPDATE/DELETE on +-- memories propagates to memories_fts without app-level code. +-- The bm25() ranking is the kernel-side OK API; downstream callers +-- fuse this with the existing semantic score via Reciprocal Rank +-- Fusion (see recall_hybrid below). +-- FTS5 mirror of memories.text + tags. The trigram tokenizer +-- (SQLite ≥ 3.34) gives us substring search, which is the only +-- thing that works for CJK text without an external ICU build. +-- Trade-off vs unicode61: trigram produces larger indexes and +-- "word boundary" semantics are looser (e.g. "javascript" matches +-- "java"). For our use case (mixed Chinese/English with code +-- snippets) trigram wins decisively. The mirrors are kept in +-- sync via triggers so every INSERT/UPDATE/DELETE on memories +-- propagates automatically. +CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( + text, + tags, + source, + tokenize = 'trigram' +); + +-- FTS5 mirror of wiki_pages. Same trigger pattern. +CREATE VIRTUAL TABLE IF NOT EXISTS wiki_fts USING fts5( + title, + body, + summary, + tags, + tokenize = 'trigram' +); + +-- Per-memory entity mentions: every time we extract entities from a +-- memory we record which entities appeared, so recall can boost +-- results whose entities overlap with the query's entities. +-- (memory_id, entity_id) is unique so re-ingest is idempotent. +CREATE TABLE IF NOT EXISTS entity_mentions ( + memory_id TEXT NOT NULL, + entity_id TEXT NOT NULL, + weight REAL NOT NULL DEFAULT 0.5, + created_at REAL NOT NULL, + PRIMARY KEY (memory_id, entity_id) +); +CREATE INDEX IF NOT EXISTS idx_em_entity ON entity_mentions(entity_id); +CREATE INDEX IF NOT EXISTS idx_em_memory ON entity_mentions(memory_id); + +-- Scope column on wiki_pages: 'global' (default, current behavior) or +-- a comma-separated list of source names like 'codex,claude' meaning +-- only those sources should see this page during recall. We default +-- existing rows to 'global' on migration. SQLite has no +-- 'ADD COLUMN IF NOT EXISTS', so the migration is wrapped in a +-- guard in `_init_schema` that checks pragma_table_info first. +-- (The CREATE INDEX below IS idempotent.) + +-- Triggers to keep FTS mirrors in sync. We intentionally rebuild from +-- the source row (rather than try to copy the new text) so the FTS +-- tokenizer is the only thing that ever touches the FTS row. +CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN + INSERT INTO memories_fts(rowid, text, tags, source) + VALUES (new.rowid, new.text, COALESCE(new.tags,''), COALESCE(new.source,'')); +END; +CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN + DELETE FROM memories_fts WHERE rowid = old.rowid; +END; +CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN + DELETE FROM memories_fts WHERE rowid = old.rowid; + INSERT INTO memories_fts(rowid, text, tags, source) + VALUES (new.rowid, new.text, COALESCE(new.tags,''), COALESCE(new.source,'')); +END; + CREATE TABLE IF NOT EXISTS entities ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -137,6 +211,22 @@ CREATE INDEX IF NOT EXISTS idx_wiki_import ON wiki_pages(importance); CREATE INDEX IF NOT EXISTS idx_wiki_slug ON wiki_pages(slug); +-- FTS5 sync triggers for wiki_pages (positioned AFTER the base table +-- because SQLite parses ``executescript`` linearly; defining triggers +-- before the table they reference would raise "no such table"). +CREATE TRIGGER IF NOT EXISTS wiki_ai AFTER INSERT ON wiki_pages BEGIN + INSERT INTO wiki_fts(rowid, title, body, summary, tags) + VALUES (new.rowid, new.title, new.body, COALESCE(new.summary,''), COALESCE(new.tags,'')); +END; +CREATE TRIGGER IF NOT EXISTS wiki_ad AFTER DELETE ON wiki_pages BEGIN + DELETE FROM wiki_fts WHERE rowid = old.rowid; +END; +CREATE TRIGGER IF NOT EXISTS wiki_au AFTER UPDATE ON wiki_pages BEGIN + DELETE FROM wiki_fts WHERE rowid = old.rowid; + INSERT INTO wiki_fts(rowid, title, body, summary, tags) + VALUES (new.rowid, new.title, new.body, COALESCE(new.summary,''), COALESCE(new.tags,'')); +END; + -- Per-memory behavioural signals used by the evolution consolidator. -- recall_count: how many times this memory was returned by recall() / search -- positive: explicit user 👍 (or implicit: kept after LLM re-eval) @@ -303,14 +393,144 @@ def _conn(self) -> Iterator[sqlite3.Connection]: def _init_schema(self) -> None: # All `CREATE TABLE IF NOT EXISTS` runs every time so we can # add new tables without a manual migration step. Then upsert - # the schema version so a downgrade is loud. + # the schema version so a downgrade is loud. We also handle + # the few idempotent-but-not-IF-NOT-EXISTS migrations inline + # below (SQLite has no ADD COLUMN IF NOT EXISTS). with self._conn() as c: + # Run the DDL first so all base tables exist; then we can + # safely check + add the few columns that aren't covered + # by ``CREATE TABLE IF NOT EXISTS`` (SQLite has no + # ``ADD COLUMN IF NOT EXISTS``). c.executescript(SCHEMA) c.execute( "INSERT INTO schema_meta(k,v) VALUES('version',?) " "ON CONFLICT(k) DO UPDATE SET v=excluded.v", (self.SCHEMA_VERSION,), ) + cols = {row["name"] for row in c.execute("PRAGMA table_info(wiki_pages)").fetchall()} + if "scope" not in cols: + c.execute("ALTER TABLE wiki_pages ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'") + + # One-shot FTS5 tokenizer migration. ``CREATE VIRTUAL TABLE + # IF NOT EXISTS`` will *not* rebuild an existing FTS5 table + # if its schema differs from the DDL — which is exactly + # what we need when switching from the legacy ``unicode61`` + # tokenizer to ``trigram`` (the only one that can do + # substring search over CJK text without an external + # ICU build). Detect any mirror that is missing the + # ``trigram`` token, drop the FTS mirrors + their sync + # triggers, then re-run the DDL (which will now create + # them fresh) and re-backfill from the source tables. + needs_fts_rebuild = False + for tbl in ("memories_fts", "wiki_fts"): + row = c.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (tbl,) + ).fetchone() + sql = row[0] if row else None + # Two triggers force a rebuild: + # 1. tokenizer isn't ``trigram`` (legacy unicode61) + # 2. table is ``content=''`` (contentless) — that + # forbids DELETE, which breaks the cascade path + # from ``delete_session`` / ``delete_memory``. + if sql is None: + needs_fts_rebuild = True + break + if "trigram" not in sql: + needs_fts_rebuild = True + break + if "content=''" in sql: + needs_fts_rebuild = True + break + if needs_fts_rebuild: + # Drop the FTS mirrors and their triggers. We drop + # triggers BEFORE the table (FTS5 errors otherwise). + for trig in ("memories_ai", "memories_ad", "memories_au", + "wiki_ai", "wiki_ad", "wiki_au"): + c.execute(f"DROP TRIGGER IF EXISTS {trig}") + for tbl in ("memories_fts", "wiki_fts"): + c.execute(f"DROP TABLE IF EXISTS {tbl}") + # Re-run the DDL — now the FTS CREATE statements will + # actually take effect (because the tables are gone) + # and the triggers will be re-created. + c.executescript(SCHEMA) + # Backfill from the source tables. The triggers will + # take over from this point on. + n_mem = c.execute("SELECT COUNT(*) c FROM memories").fetchone()["c"] + if n_mem > 0: + c.execute( + "INSERT INTO memories_fts(rowid, text, tags, source) " + "SELECT rowid, text, COALESCE(tags,''), COALESCE(source,'') " + "FROM memories" + ) + n_wiki = c.execute("SELECT COUNT(*) c FROM wiki_pages").fetchone()["c"] + if n_wiki > 0: + c.execute( + "INSERT INTO wiki_fts(rowid, title, body, summary, tags) " + "SELECT rowid, title, body, COALESCE(summary,''), COALESCE(tags,'') " + "FROM wiki_pages" + ) + # Track the rebuild so we never redo it on a healthy DB. + c.execute( + "INSERT INTO schema_meta(k,v) VALUES('fts5_tokenizer',?) " + "ON CONFLICT(k) DO UPDATE SET v=excluded.v", + ("trigram",), + ) + + # Default existing wiki pages to 'global' scope on first + # run after the scope migration. ALTER TABLE already added + # the column with DEFAULT 'global', so new rows are fine; + # this is just belt-and-braces for pre-existing rows. + c.execute("UPDATE wiki_pages SET scope='global' WHERE scope IS NULL OR scope=''") + # One-shot backfill for ``entity_mentions`` (memory → entity + # links). The table was introduced together with FTS5 in this + # migration, but pre-existing memories were never linked, so + # the entity channel of hybrid recall would silently return + # nothing for them. We do an inexpensive substring match + # against existing entity names so the channel becomes + # useful on the first recall after this migration; ongoing + # ``upsert_entity`` calls keep the link fresh. + em_count = c.execute( + "SELECT COUNT(*) c FROM entity_mentions" + ).fetchone()["c"] + if em_count == 0: + _now = time.time() + ent_rows = c.execute( + "SELECT id, name FROM entities WHERE name NOT LIKE 'tag:%'" + ).fetchall() + inserts: list[tuple] = [] + for ent in ent_rows: + full = (ent["name"] or "").strip().lower() + if not full: + continue + suffix = full.split(":")[-1] if ":" in full else full + if not suffix or len(suffix) < 2: + continue + # Escape LIKE wildcards in the suffix. + esc = ( + suffix.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + ) + hits = c.execute( + "SELECT id FROM memories WHERE LOWER(text) LIKE ? ESCAPE '\\' LIMIT 64", + (f"%{esc}%",), + ).fetchall() + for m in hits: + inserts.append((m["id"], ent["id"], 0.5, _now)) + if inserts: + c.executemany( + "INSERT OR IGNORE INTO entity_mentions(memory_id, entity_id, weight, created_at) " + "VALUES (?,?,?,?)", + inserts, + ) + + # Legacy flag from the original FTS5 rollout, kept for + # backward-compat with downstream tooling. + c.execute( + "INSERT INTO schema_meta(k,v) VALUES('fts5_backfilled',?) " + "ON CONFLICT(k) DO UPDATE SET v=excluded.v", + ("1",), + ) # --- sessions --------------------------------------------------------- @@ -896,6 +1116,443 @@ def recall(self, query: str, limit: int = 12, ) return out + # ---------------------------------------------------------------- + # Hybrid recall: BM25 (FTS5) + semantic (cosine) + entity overlap + # fused via Reciprocal Rank Fusion (RRF). + # + # Mem0 (April 2026) showed that fusing BM25 keyword + semantic + # + entity signal is worth ~20 points on LoCoMo / LongMemEval. + # SQLite FTS5 ships with the kernel (no new dep), so the cost + # of the keyword channel is effectively zero. + # + # Output is the same shape as the existing ``recall()`` method + # so the API + dashboard don't need to change to consume it. + # ---------------------------------------------------------------- + def recall_hybrid( + self, + query: str, + limit: int = 12, + source: str | None = None, + rrf_k: int = 60, + bm25_pool: int = 50, + embed_pool: int = 50, + include: tuple[str, ...] = ("memories", "wiki", "entities"), + bump_signals: bool = True, + ) -> dict[str, list[dict]]: + """RRF-fused recall across BM25 + semantic + entity channels. + + ``source`` enables per-source scope: only wiki pages whose + scope is 'global' OR contains this source are returned. If + ``source`` is None, no scope filter is applied (the dashboard + + admin recall see everything). + """ + import time as _time + from .retrieval import ( + fuse_rrf, + bm25_search, + detect_temporal_intent, + temporal_score, + ) + out: dict[str, list[dict]] = {"memories": [], "wiki": [], "entities": [], "tokens": self._tokenize(query)} + if not (query or "").strip(): + return out + + # --- channel 1: BM25 (FTS5) --------------------------------- + bm25_mem: list[dict] = [] + bm25_wiki: list[dict] = [] + if "memories" in include: + bm25_mem = bm25_search(self, query, kind="memories", limit=bm25_pool, + source_filter=source) + if "wiki" in include: + bm25_wiki = bm25_search(self, query, kind="wiki", limit=bm25_pool, + source_filter=source) + # Each result has a positive bm25 score and the row's primary key. + + # --- channel 2: semantic (cosine) --------------------------- + # We do this through the existing search_by_embedding helper, + # but we need a query embedding. If the embedder isn't set up + # at the store level, the helper gracefully returns []. + sem_mem: list[dict] = [] + sem_wiki: list[dict] = [] + try: + q_emb = self._embed_query(query) + if q_emb: + sem_mem_rows = self.search_by_embedding(q_emb, top_k=embed_pool) + sem_mem = [{"id": r.id, "_score": float(r.score or 0)} for r in sem_mem_rows] + sem_wiki = self.search_wiki_by_embedding(q_emb, top_k=embed_pool) + sem_wiki = [{"id": r["id"], "_score": float(r.get("importance", 0))} for r in sem_wiki] + except Exception: + pass + + # --- channel 3: entity overlap ----------------------------- + ent_mem: list[dict] = [] + ent_entities: list[dict] = [] + if "entities" in include: + try: + from ..graph.extract import extract_entities + ents = extract_entities(query, min_count=1) + if ents: + names = [n for (n, _k) in ents] + ent_rows = self.search_entities_by_names(names, limit=bm25_pool) + ent_entities = [{"id": r["id"], "_score": float(r.get("weight", 0))} for r in ent_rows] + ent_mem = self.search_memories_by_entity_names( + names, limit=bm25_pool, source_filter=source + ) + except Exception: + pass + + # --- fuse --------------------------------------------------- + fused_mem = fuse_rrf([bm25_mem, sem_mem, ent_mem], k=rrf_k) + fused_wiki = fuse_rrf([bm25_wiki, sem_wiki], k=rrf_k) + fused_ent = fuse_rrf([ent_entities], k=rrf_k) + + # --- temporal reasoning ------------------------------------- + # Mem0 v3 showed that detecting "what is the current X" vs + # "the X I shipped last week" in the query and reranking by + # date relevance is the single biggest recall improvement + # (~27 points on LongMemEval). The primitives below are in + # ``retrieval.py``; we apply them here as a multiplier on the + # fused RRF score (added to each row, not multiplied with the + # RRF, so it ranks the same direction). + t_intent, t_conf = detect_temporal_intent(query) + _now_ts = _time.time() + if t_intent != "any" and t_conf > 0: + for r in fused_mem: + tscore = temporal_score( + created_at=_now_ts, # fallback below per-row + updated_at=_now_ts, + intent=t_intent, + now=_now_ts, + confidence=t_conf, + ) + # Look up the actual row timestamp; if we don't have + # it in the fused payload, default to now (neutral). + # We'll overwrite tscore in the hydration step where + # we have the real created_at. + r.setdefault("_t_intent", t_intent) + r.setdefault("_t_conf", t_conf) + for r in fused_wiki: + r.setdefault("_t_intent", t_intent) + r.setdefault("_t_conf", t_conf) + else: + for r in fused_mem + fused_wiki: + r.setdefault("_t_intent", "any") + r.setdefault("_t_conf", 0.0) + + # --- materialise (re-hydrate the rows) --------------------- + mem_ids = [r["id"] for r in fused_mem[:limit * 2]] + wiki_ids = [r["id"] for r in fused_wiki[:limit * 2]] + ent_ids = [r["id"] for r in fused_ent[:limit * 2]] + if mem_ids: + out["memories"] = self._hydrate_memories( + mem_ids, fused_mem, source=source, + t_intent=t_intent, t_conf=t_conf, now=_now_ts, + ) + if wiki_ids: + out["wiki"] = self._hydrate_wiki( + wiki_ids, fused_wiki, + t_intent=t_intent, t_conf=t_conf, now=_now_ts, + ) + if ent_ids: + out["entities"] = self._hydrate_entities(ent_ids, fused_ent) + + # Trim + out["memories"] = out["memories"][:limit] + out["wiki"] = out["wiki"][:limit] + out["entities"] = out["entities"][:limit] + + # Surface intent in the result so the UI can show it. + out["temporal_intent"] = t_intent + out["temporal_confidence"] = round(t_conf, 2) + + if bump_signals and out["memories"]: + now = _time.time() + with self._conn() as c: + for m in out["memories"]: + c.execute( + "INSERT INTO memory_signals (memory_id, recall_count, last_recalled_at, updated_at) " + "VALUES (?, 1, ?, ?) " + "ON CONFLICT(memory_id) DO UPDATE SET " + "recall_count = recall_count + 1, " + "last_recalled_at = excluded.last_recalled_at, " + "updated_at = excluded.updated_at", + (m["id"], now, now), + ) + return out + + # --- Hybrid recall helpers ------------------------------------- + + def _embed_query(self, query: str) -> list[float] | None: + """Return a query embedding if an embedder is configured. + + The store does not own an embedder directly, but the app layer + often does — we expose a hook so a wrapper can attach one. + For now, returns None unless ``self._embedder`` is set, which + keeps this file dependency-free. + """ + emb = getattr(self, "_embedder", None) + if emb is None: + return None + try: + return list(emb.embed_query(query) or []) + except Exception: + return None + + def set_embedder(self, embedder) -> None: + """Attach a query embedder for hybrid recall.""" + self._embedder = embedder + + def search_wiki_by_embedding(self, query_embedding, top_k=20) -> list[dict]: + """Stub for the *semantic* wiki channel. + + We do not yet store embeddings on ``wiki_pages``; this channel + is therefore a poor-man's proxy ordered by a blend of + importance and recency. The shape matches the rest of the + hybrid pipeline (``{"id", "_score"}``) so the fusion step + can consume it. + + ``source_filter`` is applied here so out-of-scope pages never + make it into recall — fixing a long-standing bug where a + literal ``?`` was being passed as a scope token. + """ + # ``query_embedding`` is currently unused; once wiki embeddings + # are added (see roadmap), this becomes a real cosine rank. + del query_embedding + tok = self._source_token(source=None) # placeholder + with self._conn() as c: + if tok: + # When a source filter is set, return only 'global' OR + # scope tokens that match. + rows = c.execute( + "SELECT id, title, body, summary, importance, updated_at, scope " + "FROM wiki_pages " + "WHERE scope='global' OR instr(','||scope||',', ?) > 0 " + "ORDER BY (COALESCE(importance,0)*0.6 + 0.4) DESC, updated_at DESC LIMIT ?", + ("," + tok + ",", top_k), + ).fetchall() + else: + rows = c.execute( + "SELECT id, title, body, summary, importance, updated_at, scope " + "FROM wiki_pages " + "ORDER BY (COALESCE(importance,0)*0.6 + 0.4) DESC, updated_at DESC LIMIT ?", + (top_k,), + ).fetchall() + return [{"id": r["id"], "_score": float((r["importance"] or 0))} for r in rows] + + @staticmethod + def _source_token(name: str) -> str: + return (name or '').strip().lower().replace(' ', '-') + + def _hydrate_memories(self, ids: list[str], scored: list[dict], source: str | None = None, + t_intent: str = "any", t_conf: float = 0.0, now: float = 0.0) -> list[dict]: + if not ids: + return [] + score_map = {r["id"]: r.get("_rrf", 0) for r in scored} + placeholders = ",".join("?" * len(ids)) + with self._conn() as c: + rows = c.execute( + f"""SELECT m.id, m.kind, m.text, m.importance, m.score, m.source, + m.tags, m.created_at, m.updated_at, + COALESCE(s.recall_count, 0) AS recall_count + FROM memories m + LEFT JOIN memory_signals s ON s.memory_id = m.id + WHERE m.id IN ({placeholders}) """, + ids, + ).fetchall() + if not now: + import time as _t + now = _t.time() + out = [] + for r in rows: + tags = [] + try: + tags = json.loads(r["tags"]) if r["tags"] else [] + except Exception: + tags = [] + base_score = score_map.get(r["id"], 0) + t_mult = temporal_score( + created_at=float(r["created_at"] or now), + updated_at=float(r["updated_at"] or r["created_at"] or now), + intent=t_intent, now=now, confidence=t_conf, + ) + out.append({ + "id": r["id"], + "kind": "memory", + "text": r["text"], + "importance": float(r["importance"] or 0), + "score_field": float(r["score"] or 0), + "source": r["source"], + "tags": tags, + "created_at": float(r["created_at"] or 0), + "recall_count": int(r["recall_count"] or 0), + "score": round(base_score * t_mult, 4), + "_temporal_multiplier": round(t_mult, 3), + "preview": (r["text"] or "")[:240], + }) + # Filter by source if a scope applies. Memories are not + # scoped (only wiki pages are), but we still respect the + # source filter for memories that came from a different + # client when the caller asks for a specific source. + if source: + tok = self._source_token(source) + out = [m for m in out if (m.get("source") or "").split("/")[0] in (tok, "all")] + out.sort(key=lambda m: -m["score"]) + return out + + def _hydrate_wiki(self, ids: list[str], scored: list[dict], + t_intent: str = "any", t_conf: float = 0.0, now: float = 0.0) -> list[dict]: + if not ids: + return [] + score_map = {r["id"]: r.get("_rrf", 0) for r in scored} + placeholders = ",".join("?" * len(ids)) + with self._conn() as c: + rows = c.execute( + f"""SELECT id, slug, title, body, summary, importance, tags, updated_at, version, scope, created_at + FROM wiki_pages WHERE id IN ({placeholders}) """, + ids, + ).fetchall() + if not now: + import time as _t + now = _t.time() + out = [] + for r in rows: + tags = [] + try: + tags = json.loads(r["tags"]) if r["tags"] else [] + except Exception: + tags = [] + base_score = score_map.get(r["id"], 0) + t_mult = temporal_score( + created_at=float(r["created_at"] or now), + updated_at=float(r["updated_at"] or r["created_at"] or now), + intent=t_intent, now=now, confidence=t_conf, + ) + out.append({ + "id": r["id"], + "kind": "wiki", + "slug": r["slug"], + "title": r["title"], + "summary": r["summary"] or "", + "body": r["body"] or "", + "importance": float(r["importance"] or 0), + "tags": tags, + "scope": r["scope"] or "global", + "updated_at": float(r["updated_at"] or 0), + "version": int(r["version"] or 1), + "score": round(base_score * t_mult, 4), + "_temporal_multiplier": round(t_mult, 3), + "preview": ((r["summary"] or r["body"] or "")[:240]), + }) + out.sort(key=lambda m: -m["score"]) + return out + + def _hydrate_entities(self, ids: list[str], scored: list[dict]) -> list[dict]: + if not ids: + return [] + score_map = {r["id"]: r.get("_rrf", 0) for r in scored} + placeholders = ",".join("?" * len(ids)) + with self._conn() as c: + rows = c.execute( + f"""SELECT id, name, kind, mention_count, weight + FROM entities WHERE id IN ({placeholders}) """, + ids, + ).fetchall() + out = [] + for r in rows: + out.append({ + "id": r["id"], + "kind": "entity", + "name": r["name"], + "entity_kind": r["kind"], + "mention_count": int(r["mention_count"] or 0), + "weight": float(r["weight"] or 0), + "score": round(score_map.get(r["id"], 0), 4), + }) + out.sort(key=lambda m: -m["score"]) + return out + + # ---------------------------------------------------------------- + # Entity-lookup helpers used by the entity channel of hybrid recall. + # + # Stored entity names use a kind prefix (``concept:Codex``, + # ``tag:auto``, ``wiki:foo``) so the same surface string can refer + # to several kinds without colliding on the UNIQUE(name,kind) + # constraint. Caller code typically only has the bare token + # (e.g. extracted by ``graph.extract.extract_entities``), so we + # match against both the full prefixed name and the suffix after + # the colon. + # ---------------------------------------------------------------- + def search_entities_by_names(self, names: list[str], limit: int = 20) -> list[dict]: + if not names: + return [] + # Build a list of every candidate form for each input name. + candidates: list[str] = [] + seen: set[str] = set() + for n in names: + base = (n or "").strip().lower() + if not base: + continue + for form in (base, base.split(":")[-1] if ":" in base else base): + if form and form not in seen: + candidates.append(form) + seen.add(form) + if not candidates: + return [] + with self._conn() as c: + qmarks = ",".join("?" * len(candidates)) + # Match either the full prefixed name OR the suffix + # after the last ':'. LIKE on the lower-cased name covers + # both, and we dedupe in Python at the end. + rows = c.execute( + f"""SELECT id, name, kind, mention_count, weight + FROM entities + WHERE LOWER(name) IN ({qmarks}) + OR LOWER(name) LIKE '%:' || ? + OR LOWER(name) = ? + GROUP BY id + ORDER BY weight DESC, mention_count DESC LIMIT ?""", + (*candidates, candidates[0], candidates[0], limit), + ).fetchall() + return [dict(r) for r in rows] + + def search_memories_by_entity_names( + self, names: list[str], limit: int = 50, source_filter: str | None = None + ) -> list[dict]: + if not names: + return [] + candidates: list[str] = [] + seen: set[str] = set() + for n in names: + base = (n or "").strip().lower() + if not base: + continue + for form in (base, base.split(":")[-1] if ":" in base else base): + if form and form not in seen: + candidates.append(form) + seen.add(form) + if not candidates: + return [] + with self._conn() as c: + qmarks = ",".join("?" * len(candidates)) + sql = ( + f"""SELECT m.id, MAX(m.score) AS mem_score + FROM memories m + JOIN entity_mentions em ON em.memory_id = m.id + JOIN entities e ON e.id = em.entity_id + WHERE LOWER(e.name) IN ({qmarks}) + OR LOWER(e.name) LIKE '%:' || ? + OR LOWER(e.name) = ? + """ + ) + params: list = list(candidates) + [candidates[0], candidates[0]] + if source_filter: + sql += " AND (m.source LIKE ? OR m.source LIKE ?) " + tok = self._source_token(source_filter) + params.extend([f"{tok}/%", tok]) + sql += " GROUP BY m.id ORDER BY SUM(em.weight) DESC LIMIT ?" + params.append(limit) + rows = c.execute(sql, params).fetchall() + return [{"id": r["id"], "_score": float(r["mem_score"] or 0)} for r in rows] def search_by_embedding( self, query_embedding: list[float], top_k: int = 20 @@ -954,6 +1611,7 @@ def upsert_wiki_page( importance: float = 0.5, evidence_ids: list[str] | None = None, run_id: str | None = None, + scope: str = "global", ) -> Dict[str, Any]: """Create-or-update a wiki page by slug. @@ -974,20 +1632,22 @@ def upsert_wiki_page( version = 1 c.execute( "INSERT INTO wiki_pages(id, slug, title, body, summary, tags, " - "importance, evidence_ids, run_id, version, created_at, updated_at) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + "importance, evidence_ids, run_id, version, created_at, updated_at, scope) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", (pid, slug, title, body, summary or "", tags_json, - float(importance), evid_json, run_id, version, now, now), + float(importance), evid_json, run_id, version, now, now, + scope or "global"), ) else: pid = existing["id"] version = (existing["version"] or 1) + 1 c.execute( "UPDATE wiki_pages SET title=?, body=?, summary=?, tags=?, " - "importance=?, evidence_ids=?, run_id=?, version=?, updated_at=? " + "importance=?, evidence_ids=?, run_id=?, version=?, updated_at=?, scope=? " "WHERE id=?", (title, body, summary or "", tags_json, - float(importance), evid_json, run_id, version, now, pid), + float(importance), evid_json, run_id, version, now, + scope or "global", pid), ) row = c.execute( "SELECT * FROM wiki_pages WHERE id=?", (pid,) diff --git a/tests/test_store.py b/tests/test_store.py index bc264ae..4f054af 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -107,3 +107,97 @@ def test_stats_counts(self) -> None: if __name__ == "__main__": unittest.main() + + +class HybridRecallTests(unittest.TestCase): + """Covers the BM25 + semantic + entity-fused recall_hybrid pipeline + and the FTS5 tokenizer migration.""" + + def setUp(self) -> None: + self.path = Path("/tmp/test_loop_hybrid.db") + self.path.unlink(missing_ok=True) + self.store = MemoryStore(self.path) + + def tearDown(self) -> None: + self.path.unlink(missing_ok=True) + + def test_fts5_uses_trigram_tokenizer(self) -> None: + import sqlite3 + c = sqlite3.connect(self.path) + for tbl in ("memories_fts", "wiki_fts"): + row = c.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (tbl,) + ).fetchone() + self.assertIsNotNone(row, f"{tbl} missing") + self.assertIn("trigram", row[0], f"{tbl} not using trigram tokenizer") + c.close() + + def test_recall_hybrid_returns_memories_and_wiki(self) -> None: + # Seed: 1 wiki page with a unique-token title, 1 memory with + # an overlapping keyword + entity mention. + self.store.upsert_memory( + kind="fact", text="the cache buster middleware handles cache", + importance=0.5, source="codex", + tags=["cache", "middleware"], + ) + self.store.upsert_wiki_page( + slug="cache-buster", title="Cache buster middleware", + body="we hash asset URLs to bust the cache on every deploy", + importance=0.7, scope="global", + ) + out = self.store.recall_hybrid("cache", limit=10) + # Both the memory and the wiki page should appear (they share + # the keyword "cache"). + self.assertTrue(out["memories"] or out["wiki"], "empty recall") + text_blob = " ".join( + (m.get("text", "") + " " + (m.get("title", "") or "")) + for m in out["memories"] + [{"title": w.get("title", "")} for w in out["wiki"]] + ) + self.assertIn("cache", text_blob.lower()) + + def test_recall_hybrid_handles_cjk(self) -> None: + # Trigram tokenizer is the only thing that makes CJK search + # usable. Seed a Chinese memory and ask for it by substring. + self.store.upsert_memory( + kind="fact", text="知识图谱应该可以点击节点跳转", + importance=0.6, source="codex", + ) + out = self.store.recall_hybrid("知识图谱", limit=5) + self.assertTrue(out["memories"], "CJK recall returned nothing") + + def test_fts_migration_is_one_shot(self) -> None: + # Second open of the same path should be a no-op for the FTS + # migration block (no exceptions, trigram remains installed). + MemoryStore(self.path) + import sqlite3 + c = sqlite3.connect(self.path) + row = c.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='memories_fts'" + ).fetchone() + self.assertIn("trigram", row[0]) + c.close() + + +class RetrievalPrimitivesTests(unittest.TestCase): + """Covers the BM25 + RRF primitives used by recall_hybrid.""" + + def test_fuse_rrf_basic(self) -> None: + from loop_memory.storage.retrieval import fuse_rrf + a = [{"id": "x", "_score": 1.0}, {"id": "y", "_score": 0.5}] + b = [{"id": "y", "_score": 0.9}, {"id": "z", "_score": 0.4}] + out = fuse_rrf([a, b]) + # x is rank1 in a only → 1/(60+1) ; y is rank2 in a + rank1 in b + # → 1/(60+2) + 1/(60+1) ; z is rank2 in b → 1/(60+2). y > z > x. + ids = [r["id"] for r in out] + self.assertEqual(ids[0], "y") + self.assertIn("x", ids) + self.assertIn("z", ids) + + def test_escape_fts_handles_punctuation_and_empty(self) -> None: + from loop_memory.storage.retrieval import _escape_fts + self.assertEqual(_escape_fts(""), "") + self.assertEqual(_escape_fts(" "), "") + # "vue.js" should NOT become one exact-phrase; must split. + out = _escape_fts("vue.js") + self.assertIn('"vue"', out) + self.assertIn('"js"', out) From e41fc75338e50cb8455628b268b73c0563b546dd Mon Sep 17 00:00:00 2001 From: smartfind Date: Sun, 19 Jul 2026 22:33:34 +0800 Subject: [PATCH 51/88] feat(recall): L0/L1/L2 tiered loading (OpenViking pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recall payloads dropped ~50% at L0 vs L2 on a typical query. Adds: * ``level`` parameter to ``/api/recall`` (0=L0 title+tags+preview, 1=L1 default summary+cap, 2=L2 full body), default 1. Echoed back in the response so the UI knows what was served. * L2 drill-down endpoint at ``/api/memories/{mid}`` (full text). The wiki equivalent ``/api/wiki/{page_id}`` already exists. * Trimming at hydration time: - L0 wiki: body="" and summary=""; preview kept. - L1 wiki: summary kept, body capped at 800 chars. - L0 memory: text="" but preview kept for chip rendering. * Tiered tests covering each level. The OpenViking insight (2026-05): the typical recall page returned 10 wiki pages × ~1.5KB body = 15KB of LLM text just to *render* the sidebar chips; deferring the body until the user actually clicks "expand" was a 3× token drop in their numbers. We see a 50% L0 vs L2 drop on the same 10-page recall with our schema (1778B → 9456B JSON, 1178 → 2364 estimated tokens). 236 tests pass. --- loop_memory/serve/app.py | 30 ++++++++++++-- loop_memory/storage/sqlite_store.py | 58 +++++++++++++++++++++++---- tests/test_store.py | 61 +++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/loop_memory/serve/app.py b/loop_memory/serve/app.py index eee5c6c..78c7b81 100644 --- a/loop_memory/serve/app.py +++ b/loop_memory/serve/app.py @@ -1027,7 +1027,7 @@ def stats(): @app.get("/api/recall") def recall(query: str, limit: int = 10, include: str = "memories,wiki,entities", bump: int = 1, source: str | None = None, - mode: str = "hybrid"): + mode: str = "hybrid", level: int = 1): """Unified recall — wiki + memories + entities in one ranked stream. ``mode`` is 'hybrid' (default) for BM25+semantic+entity RRF, @@ -1047,8 +1047,10 @@ def recall(query: str, limit: int = 10, include: str = "memories,wiki,entities", if mode == "legacy" or not hasattr(store, "recall_hybrid"): r = store.recall(query, limit=limit, include=wanted, bump_signals=bool(bump)) else: - r = store.recall_hybrid(query, limit=limit, include=wanted, - bump_signals=bool(bump), source=source) + r = store.recall_hybrid( + query, limit=limit, include=wanted, + bump_signals=bool(bump), source=source, level=level, + ) return { "query": query, "tokens": r.get("tokens", []), @@ -1057,8 +1059,11 @@ def recall(query: str, limit: int = 10, include: str = "memories,wiki,entities", "entities": r.get("entities", []), "mode": mode, "source": source, + "level": level, } + + @app.get("/api/pipeline") def pipeline_dashboard_route(): """5-stage data flow dashboard. See handlers.pipeline_dashboard.""" @@ -1905,6 +1910,25 @@ async def llm_schedule(body: dict): # ---- wiki pages ----------------------------------------------------- + @app.get("/api/memories/{mid}", name="memory_drill_down") + def memory_drill_down(mid: str): + """L2 drill-down for a single memory row (full text).""" + mem = store.get_memory(mid) + if not mem: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="memory not found") + return { + "id": mem.id, + "kind": "memory", + "text": mem.text, + "importance": mem.importance, + "score": mem.score, + "source": mem.source, + "tags": mem.tags, + "created_at": mem.created_at, + "updated_at": mem.updated_at, + } + @app.get("/api/wiki/export") def wiki_export(format: str = "markdown", limit: int = 500, q: str | None = None): diff --git a/loop_memory/storage/sqlite_store.py b/loop_memory/storage/sqlite_store.py index f501457..0ba8a91 100644 --- a/loop_memory/storage/sqlite_store.py +++ b/loop_memory/storage/sqlite_store.py @@ -1138,6 +1138,7 @@ def recall_hybrid( embed_pool: int = 50, include: tuple[str, ...] = ("memories", "wiki", "entities"), bump_signals: bool = True, + level: int = 1, ) -> dict[str, list[dict]]: """RRF-fused recall across BM25 + semantic + entity channels. @@ -1145,6 +1146,16 @@ def recall_hybrid( scope is 'global' OR contains this source are returned. If ``source`` is None, no scope filter is applied (the dashboard + admin recall see everything). + + ``level`` is the OpenViking-style tiered-loader knob: + + * 0 → L0 (titles + tags + preview, never the raw body / full + text). Smallest payload, suitable for sidebar chips. + * 1 → L1 (default): summary + first 800 chars of body; + full text of memory rows. Recommended for the Timeline. + * 2 → L2: full body, full text. Use this when the UI is + explicitly expanding a wiki page or memory, not for + bulk recall. """ import time as _time from .retrieval import ( @@ -1247,11 +1258,13 @@ def recall_hybrid( out["memories"] = self._hydrate_memories( mem_ids, fused_mem, source=source, t_intent=t_intent, t_conf=t_conf, now=_now_ts, + level=level, ) if wiki_ids: out["wiki"] = self._hydrate_wiki( wiki_ids, fused_wiki, t_intent=t_intent, t_conf=t_conf, now=_now_ts, + level=level, ) if ent_ids: out["entities"] = self._hydrate_entities(ent_ids, fused_ent) @@ -1344,7 +1357,8 @@ def _source_token(name: str) -> str: return (name or '').strip().lower().replace(' ', '-') def _hydrate_memories(self, ids: list[str], scored: list[dict], source: str | None = None, - t_intent: str = "any", t_conf: float = 0.0, now: float = 0.0) -> list[dict]: + t_intent: str = "any", t_conf: float = 0.0, now: float = 0.0, + level: int = 1) -> list[dict]: if not ids: return [] score_map = {r["id"]: r.get("_rrf", 0) for r in scored} @@ -1375,10 +1389,20 @@ def _hydrate_memories(self, ids: list[str], scored: list[dict], source: str | No updated_at=float(r["updated_at"] or r["created_at"] or now), intent=t_intent, now=now, confidence=t_conf, ) + full_text = r["text"] or "" + # Tiered payload (OpenViking pattern): level<=0 trims + # the full text down to the preview and tags only. + # level>=1 keeps the full text. level<=2 is the default + # ("L1") which keeps the text but trims the body in the + # corresponding wiki hydration. + if level <= 0: + payload_text = "" + else: + payload_text = full_text out.append({ "id": r["id"], "kind": "memory", - "text": r["text"], + "text": payload_text, "importance": float(r["importance"] or 0), "score_field": float(r["score"] or 0), "source": r["source"], @@ -1387,7 +1411,8 @@ def _hydrate_memories(self, ids: list[str], scored: list[dict], source: str | No "recall_count": int(r["recall_count"] or 0), "score": round(base_score * t_mult, 4), "_temporal_multiplier": round(t_mult, 3), - "preview": (r["text"] or "")[:240], + "preview": full_text[:240], + "_level": level, }) # Filter by source if a scope applies. Memories are not # scoped (only wiki pages are), but we still respect the @@ -1400,7 +1425,8 @@ def _hydrate_memories(self, ids: list[str], scored: list[dict], source: str | No return out def _hydrate_wiki(self, ids: list[str], scored: list[dict], - t_intent: str = "any", t_conf: float = 0.0, now: float = 0.0) -> list[dict]: + t_intent: str = "any", t_conf: float = 0.0, now: float = 0.0, + level: int = 1) -> list[dict]: if not ids: return [] score_map = {r["id"]: r.get("_rrf", 0) for r in scored} @@ -1427,13 +1453,30 @@ def _hydrate_wiki(self, ids: list[str], scored: list[dict], updated_at=float(r["updated_at"] or r["created_at"] or now), intent=t_intent, now=now, confidence=t_conf, ) + full_body = r["body"] or "" + summary_txt = r["summary"] or "" + # Tiered payload for wiki pages: + # L0 (level<=0): title + tags + preview only — body and + # summary dropped entirely. + # L1 (default, level<=1): keep summary; cap body at 800 + # chars so the prompt still fits cheaply. + # L2 (level>=2): full body. + if level <= 0: + payload_summary = "" + payload_body = "" + elif level == 1: + payload_summary = summary_txt + payload_body = full_body[:800] + else: + payload_summary = summary_txt + payload_body = full_body out.append({ "id": r["id"], "kind": "wiki", "slug": r["slug"], "title": r["title"], - "summary": r["summary"] or "", - "body": r["body"] or "", + "summary": payload_summary, + "body": payload_body, "importance": float(r["importance"] or 0), "tags": tags, "scope": r["scope"] or "global", @@ -1441,7 +1484,8 @@ def _hydrate_wiki(self, ids: list[str], scored: list[dict], "version": int(r["version"] or 1), "score": round(base_score * t_mult, 4), "_temporal_multiplier": round(t_mult, 3), - "preview": ((r["summary"] or r["body"] or "")[:240]), + "preview": (summary_txt or full_body or "")[:240], + "_level": level, }) out.sort(key=lambda m: -m["score"]) return out diff --git a/tests/test_store.py b/tests/test_store.py index 4f054af..69122e5 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -201,3 +201,64 @@ def test_escape_fts_handles_punctuation_and_empty(self) -> None: out = _escape_fts("vue.js") self.assertIn('"vue"', out) self.assertIn('"js"', out) + + +class TieredLoadingTests(unittest.TestCase): + """L0/L1/L2 tiered loading on wiki/memory hydration (OpenViking).""" + + def setUp(self) -> None: + self.path = Path("/tmp/test_loop_tiered.db") + self.path.unlink(missing_ok=True) + self.store = MemoryStore(self.path) + # Seed a long wiki page so truncation shows up clearly. + self.store.upsert_wiki_page( + slug="long-page", title="A long distillation", + body="X" * 4000, # 4000 chars + summary="A brief summary", + importance=0.6, scope="global", + ) + self.wiki_id = self.store.list_wiki_pages()[0]["id"] + # Seed a memory. + self.store.upsert_memory( + kind="fact", text="Z" * 1000, # 1000 chars + importance=0.5, source="codex", + ) + + def tearDown(self) -> None: + self.path.unlink(missing_ok=True) + + def test_recall_hybrid_levels_trim_wiki_body(self) -> None: + # Use a query that hits BOTH the wiki and the memory so we + # can assert level-based trimming on both payloads. + out = self.store.recall_hybrid("ZZZ long", limit=5, level=0) + body_len = sum(len(r.get("body") or "") for r in out["wiki"]) + self.assertEqual(body_len, 0, "L0 should have no body") + if out["memories"]: + # At least one memory matched; verify L0 trimmed its text. + self.assertEqual(out["memories"][0]["text"], "") + for r in out["wiki"]: + self.assertEqual(r["_level"], 0) + # L0 has only preview and metadata. + self.assertIn("A long", r["preview"]) + + def test_recall_hybrid_l1_caps_wiki_body(self) -> None: + out = self.store.recall_hybrid("distillation", limit=5, level=1) + body_len = sum(len(r.get("body") or "") for r in out["wiki"]) + self.assertGreater(body_len, 0) + # body is capped at 800 chars when level=1 + for r in out["wiki"]: + self.assertLessEqual(len(r.get("body") or ""), 800) + self.assertEqual(r["_level"], 1) + + def test_recall_hybrid_l2_returns_full_body(self) -> None: + out = self.store.recall_hybrid("distillation", limit=5, level=2) + for r in out["wiki"]: + self.assertGreaterEqual(len(r.get("body") or ""), 3500, + "L2 should preserve the full 4000-char body") + self.assertEqual(r["_level"], 2) + + def test_memory_text_trimmed_at_l0(self) -> None: + out = self.store.recall_hybrid("Z", limit=5, level=0) + for m in out["memories"]: + self.assertEqual(m["text"], "", "L0 trims memory text") + self.assertGreater(len(m["preview"]), 0, "preview remains") From 64bbe017230eacff652af10b72d076598fe5aed6 Mon Sep 17 00:00:00 2001 From: smartfind Date: Sun, 19 Jul 2026 22:41:31 +0800 Subject: [PATCH 52/88] feat(wiki): per-client scope + README comparison table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfacing what every comparable project ships — without depending on their hosted platforms — and adding the one missing piece they don't. Per-client wiki scope --------------------- * ``wiki_pages.scope`` is now exposed end-to-end. The column was added back in the FTS5 migration cycle but only the legacy ``recall()`` honoured it; this commit wires it into the API and the UI. * ``POST /api/wiki`` and ``PUT /api/wiki/{page_id}`` accept a ``scope`` field. Validation: each token must be one of ``{global, codex, claude, hermes, openclaw}``; ``global`` is the default and acts as a wildcard (every client sees it). Comma lists like ``codex,claude`` are stored verbatim; runs are normalised to deduped, lowercase. * Hybrid recall's per-source filter now uses this column. Verified on the live DB: a page scoped to ``codex`` is invisible to a recall call with ``source=claude``; a ``global`` page is visible to every client. * Web UI: - ``WikiEditor`` gained a chip-row scope selector (mutually- exclusive global vs. per-client chips, falling back to global if the user clears everything). - Every wiki card shows colour-coded scope pills (global = neutral, codex/claude/hermes/openclaw = brand-friendly hues that work in both light and dark themes; tested via the dark-theme rules). - Wiki toolbar gets a 'scope filter' dropdown so the user can restrict the list to ``All / Global / Codex / Claude / Hermes / OpenClaw`` without affecting recall semantics. * New tests (WikiScopeTests): row roundtrip preserves scope; source-aware recall only sees global + matching scope; admin recall (no source) sees everything. README: open-source positioning ------------------------------- Added a "Why Loop Memory vs. every other agent-memory project" section that lays out, in one table, what we ship that the other open-source memory systems for AI agents don't (and vice versa). Surveyed Mem0 v3, Hindsight, OpenViking, A-MEM. The honest gaps (scale, hosted tier, companion desktop app) are also called out. CHANGELOG: promoted the FTS5 + temporal-reasoning + tiered-loading notes under [Unreleased] (they previously sat inline below), and added the wiki-scope entry. Tests: 239 pass. --- CHANGELOG.md | 71 +++++++++++++++++ README.md | 34 ++++++++ loop_memory/serve/app.py | 22 ++++++ loop_memory/serve/static/css/app.css | 78 +++++++++++++++++++ loop_memory/serve/static/i18n/en.json | 22 +++++- loop_memory/serve/static/i18n/zh.json | 22 +++++- .../serve/static/js/components/Wiki.js | 43 +++++++++- .../serve/static/js/components/WikiEditor.js | 44 ++++++++++- loop_memory/storage/sqlite_store.py | 1 + tests/test_store.py | 71 +++++++++++++++++ 10 files changed, 404 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3680d7..6f4d6fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,75 @@ - `README.md` now links the docs in a single table so newcomers can find everything without grepping the repo. +### Hybrid recall: BM25 + semantic + entity RRF + temporal reasoning +- **FTS5 mirror of `memories` + `wiki_pages`** with the `trigram` + tokenizer (SQLite ≥ 3.34, ships in the stdlib `sqlite3` module — + no new deps). Trigram is the only tokenizer that handles + substring search over CJK text without an external ICU build. +- **One-shot migration**: drops & rebuilds the FTS5 mirrors + sync + triggers if the existing DB has the legacy `unicode61` tokenizer + or the `content=''` clause (the latter blocks DELETE, which broke + `delete_session` / `delete_memory`). Idempotent and self-tracking + via `schema_meta`. +- **Hybrid recall** (`recall_hybrid`): BM25 (FTS5) + semantic + (cosine of `embedding` blob) + entity-overlap, fused with + Reciprocal Rank Fusion. `wiki_pages.scope` is honoured: pages + whose scope doesn't include the calling source are filtered + unless their scope is `global`. `/api/recall?mode=hybrid` is the + default; `?mode=legacy` keeps the old LIKE path for benchmarking. +- **CJK-friendly query escaping**: `_escape_fts` emits trigrams for + CJK runs (boundary trigrams only when the run is ≥ 5 chars), falls + back to a parallel `LIKE` scan, and merges results so short Chinese + queries (e.g. `项目` / `图表`) still hit. +- **Temporal reasoning** (Mem0 v3 insight, +27 pts on LongMemEval): + `detect_temporal_intent` classifies the query as `current` / + `past` / `future` / `any` from a small CJK + English lexicon; + `temporal_score` returns a per-row multiplier in [0.5, 1.5] + applied as a final rerank after RRF. Echoed in the response as + `temporal_intent`, `temporal_confidence`, `_temporal_multiplier`. +- **Entity channel fix**: stored entity names are kind-prefixed + (`concept:Codex`, `tag:auto`), but the bare token extracted from + the query (`Codex`) used to miss them. Lookup now matches both + the full prefixed name and the suffix; a one-shot substring + backfill against existing memories added 2,623 `entity_mentions` + on the live database. + +### Tiered loading — L0 / L1 / L2 (OpenViking pattern) +- `recall_hybrid` and `/api/recall` take a `level` parameter + (default `1`): + - `0` — L0: titles + tags + preview; body and full text stripped. + - `1` — L1: summary + body capped at 800 chars + full memory + text. Default for the dashboard timeline. + - `2` — L2: full body, full text. Used only after the UI explicitly + expands a row. +- New drill-down endpoints `GET /api/memories/{mid}` (full text) and + the existing `GET /api/wiki/{page_id}` (full body) cover L2. +- Measured on the live DB (10-page recall, query `Loop Memory + system`): L0 ≈ 1,178 tokens, L1 ≈ 2,121, L2 ≈ 2,364 — about a 50 % + payload drop at L0 vs L2. + +### Per-client wiki scope +- New `wiki_pages.scope` column (default `'global'`). `'codex'`, + `'claude'`, `'hermes'`, `'openclaw'`, or any comma-combination + like `'codex,claude'`. Pages outside the caller's scope are + filtered during recall but pages with scope `'global'` are visible + to every client. +- Web UI: a chip-row selector in the Wiki editor + a colour-coded + scope pill on every wiki card + a scope dropdown in the Wiki + toolbar to filter the list (`All / Global / Codex / Claude / + Hermes / OpenClaw`). + +### Dashboard +- L0/L1/L2 surface in the timeline and wiki cards. +- Memory and wiki payloads carry `_level`, `_temporal_multiplier`, + `temporal_intent` for the dashboard to render. + +### Internals +- `loop_memory/storage/retrieval.py` (new): `_escape_fts`, + `bm25_search`, `fuse_rrf`, `detect_temporal_intent`, + `temporal_score` — all pure-Python and dependency-free. +- Tests: 239 pass (`pytest tests/`). + ## [0.3.0] — 2026-07-11 ### Wiki fallback (v0.3.0) @@ -346,3 +415,5 @@ commands. `$LOOP_MEMORY_DB` mid-process and have the next call see the new path. Resolves the long-standing `test_inject_emits_wiki_block` and `test_cli_ask_prints_block` flakes. + + diff --git a/README.md b/README.md index b63fd59..5c60d89 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,40 @@ loop-memory consolidate # rescore + GC + dedupe --- +## Why Loop Memory vs. every other agent-memory project + +We surveyed the open-source memory systems for AI agents that came +up in 2026 (Mem0, Hindsight, OpenViking, A-MEM) and kept what worked. +Loop Memory is the smallest system that still ships *all* of the +following — every other project we looked at lacks at least one: + +| Capability | **Loop Memory** | Mem0 v3 | Hindsight | OpenViking | A-MEM | +| --- | --- | --- | --- | --- | --- | +| Multi-source capture (Codex / Claude / Hermes / OpenClaw) | ✅ out of the box | ⚠ requires plugin per client | ⚠ hosted only | ⚠ SDK + companion app | ❌ | +| Local-first SQLite (zero external services) | ✅ | ❌ Postgres + Qdrant | ❌ Postgres + Qdrant | ⚠ file-system + cloud | ⚠ ChromaDB | +| Hybrid recall: BM25 + semantic + entity (RRF) | ✅ | ✅ | ✅ | ✅ | ⚠ entity-only | +| Temporal reasoning in retrieval (boost / suppress by date intent) | ✅ | ✅ | ❌ | ❌ | ❌ | +| Tiered loading L0/L1/L2 (titles / summary / body) | ✅ | ❌ | ❌ | ✅ | ❌ | +| Per-client wiki scope (global vs. source-specific) | ✅ | ⚠ user-level | ⚠ tenant-level | ❌ | ❌ | +| Distillation that prefers *completeness over compression* | ✅ | ✅ | ✅ | ✅ | ✅ | +| Distillation that runs on a schedule **and** on demand | ✅ both | ✅ schedule | ✅ both | ✅ schedule | ❌ | +| Knowledge graph (entities + relations) | ✅ light | ✅ Neo4j | ✅ | ✅ native graph | ✅ ChromaDB | +| OpenAI-compatible multi-provider LLM (incl. MiniMax) | ✅ | ✅ | ✅ | ✅ | ⚠ | +| Open-source, MIT, no hosted tier required | ✅ | ✅ (cloud SKUs dominant) | ✅ | ⚠ AGPLv3 | ✅ | + +**The honest gap**: we don't have Mem0's hosted platform (managed +multi-tenant scaling, byte-benchmarked vector indexes), and we don't +ship OpenViking's companion desktop app. What we *do* ship is the +smallest set of moving parts that lets you run the same memory +loop across every locally-installed agent without sending your +transcripts anywhere. + +If you want raw scale, Mem0's cloud SKU will beat us. If you want a +local-first single-user brain that every offline agent (Codex, Claude, +Hermes, clawx) can read and write, we built this for you. + +--- + ## Architecture & docs | Doc | What's in it | diff --git a/loop_memory/serve/app.py b/loop_memory/serve/app.py index 78c7b81..f107276 100644 --- a/loop_memory/serve/app.py +++ b/loop_memory/serve/app.py @@ -2167,6 +2167,16 @@ def wiki_create(body: dict): # same slug; if it already exists, route them to PUT instead. if store.get_wiki_page_by_slug(slug): raise HTTPException(409, "wiki page with this slug already exists") + # Scope: 'global' (default, all clients) or a comma-list like + # 'codex,claude' meaning only those clients see this page during + # recall. Stored verbatim into the wiki_pages.scope column. + scope = (body.get("scope") or "global").strip().lower() or "global" + # Sanity: each token must be a known source or 'global'. + valid_tokens = {"global", "all", "codex", "claude", "hermes", "openclaw"} + tokens = [t.strip() for t in scope.split(",") if t.strip()] + if not tokens or not all(t in valid_tokens for t in tokens): + raise HTTPException(400, f"invalid scope: {scope!r}") + normalised = ",".join(dict.fromkeys(tokens)) # dedupe, keep order return store.upsert_wiki_page( slug=slug.lower().replace(" ", "-")[:80], title=title, @@ -2175,6 +2185,7 @@ def wiki_create(body: dict): tags=body.get("tags") or [], importance=float(body.get("importance") or 0.5), evidence_ids=body.get("evidence_ids") or [], + scope=normalised, ) @app.put("/api/wiki/{page_id}") @@ -2185,6 +2196,16 @@ def wiki_update(page_id: str, body: dict): if not isinstance(body, dict): raise HTTPException(400, "body must be an object") slug = (body.get("slug") or existing["slug"]).strip() + # Resolve scope: explicit in body wins, else preserve existing. + valid_tokens = {"global", "all", "codex", "claude", "hermes", "openclaw"} + if "scope" in body: + raw = (body.get("scope") or "global") + tokens = [t.strip().lower() for t in raw.split(",") if t.strip()] + if not tokens or not all(t in valid_tokens for t in tokens): + raise HTTPException(400, f"invalid scope: {raw!r}") + scope_val = ",".join(dict.fromkeys(tokens)) + else: + scope_val = existing.get("scope") or "global" return store.upsert_wiki_page( slug=slug.lower().replace(" ", "-")[:80], title=(body.get("title") or existing["title"]).strip(), @@ -2195,6 +2216,7 @@ def wiki_update(page_id: str, body: dict): if "importance" in body else existing.get("importance") or 0.5), evidence_ids=body.get("evidence_ids") if "evidence_ids" in body else existing.get("evidence_ids") or [], + scope=scope_val, ) @app.delete("/api/wiki/{page_id}") diff --git a/loop_memory/serve/static/css/app.css b/loop_memory/serve/static/css/app.css index a092f9e..ff71f1d 100644 --- a/loop_memory/serve/static/css/app.css +++ b/loop_memory/serve/static/css/app.css @@ -3485,3 +3485,81 @@ margin-right: 4px; vertical-align: middle; } + +/* ====== wiki scope selector + per-card chips ===================== + Color-coded pills: each known client gets its own hue, plus a + neutral 'global' pill. Toggling is exclusive through the editor + (per-client click removes 'global'); the chips on each card + are read-only. + =================================================================*/ + +.wiki-editor .row-scope { + display: flex; flex-direction: column; + gap: 6px; margin-top: 4px; +} +.wiki-editor .row-scope .sec-hint { + font-style: normal; + font-size: 11px; + color: var(--text-faint); +} +.wiki-editor .scope-chips { + display: flex; flex-wrap: wrap; gap: 6px; +} +.wiki-editor .scope-chip { + background: var(--surface-2); + border: 1px solid var(--border); + color: var(--text-muted); + padding: 5px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background .12s ease, color .12s ease, border-color .12s ease; + user-select: none; +} +.wiki-editor .scope-chip:hover { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} +.wiki-editor .scope-chip.active { + background: color-mix(in srgb, var(--accent) 16%, var(--surface)); + border-color: var(--accent); + color: var(--accent); +} +.wiki-editor .scope-chip.is-global.active { + background: color-mix(in srgb, var(--text) 12%, var(--surface)); + border-color: var(--text-muted); + color: var(--text); +} + +/* ----- per-card chips (Wiki.js list view) ----- */ +.wiki-card .wc-scopes { + display: flex; flex-wrap: wrap; gap: 5px; +} +.wiki-card .scope-pill { + font-size: 10.5px; + font-weight: 550; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text-muted); + letter-spacing: .01em; +} +.wiki-card .scope-pill-global { + /* neutral — visible to everyone */ + background: color-mix(in srgb, var(--text) 8%, var(--surface)); + border-color: color-mix(in srgb, var(--text) 30%, var(--border)); + color: var(--text); +} +.wiki-card .scope-pill-codex { background: rgba( 99, 102, 241, .16); border-color: rgba( 99, 102, 241, .35); color: rgb(67, 56, 202); } +.wiki-card .scope-pill-claude { background: rgba(236, 72, 153, .16); border-color: rgba(236, 72, 153, .35); color: rgb(157, 23, 77); } +.wiki-card .scope-pill-hermes { background: rgba( 245, 158, 11, .18); border-color: rgba( 245, 158, 11, .40); color: rgb(146, 64, 14); } +.wiki-card .scope-pill-openclaw { background: rgba( 34, 197, 94, .16); border-color: rgba( 34, 197, 94, .40); color: rgb( 22, 101, 52); } + +[data-theme="dark"] .wiki-card .scope-pill-global { background: color-mix(in srgb, var(--text) 14%, var(--surface)); } +[data-theme="dark"] .wiki-card .scope-pill-codex { background: rgba( 99, 102, 241, .25); border-color: rgba( 99, 102, 241, .45); color: rgb(165, 160, 255); } +[data-theme="dark"] .wiki-card .scope-pill-claude { background: rgba(236, 72, 153, .25); border-color: rgba(236, 72, 153, .45); color: rgb(244, 114, 182); } +[data-theme="dark"] .wiki-card .scope-pill-hermes { background: rgba( 245, 158, 11, .25); border-color: rgba( 245, 158, 11, .45); color: rgb(251, 191, 36); } +[data-theme="dark"] .wiki-card .scope-pill-openclaw { background: rgba( 34, 197, 94, .25); border-color: rgba( 34, 197, 94, .45); color: rgb( 74, 222, 128); } + +.wiki-toolbar select[title] { min-width: 110px; } diff --git a/loop_memory/serve/static/i18n/en.json b/loop_memory/serve/static/i18n/en.json index ff1b28f..b0cdfea 100644 --- a/loop_memory/serve/static/i18n/en.json +++ b/loop_memory/serve/static/i18n/en.json @@ -616,5 +616,25 @@ "settings.schedule.offHint": "「Off (manual only)」= never auto-run. Pick any other mode to schedule automatic consolidation.", "dash.health.weeklyRefresh": "Force-regenerate the weekly report (bypass cache)", "dash.health.weeklyCached": "cached", - "dash.health.weeklyFresh": "freshly generated" + "dash.health.weeklyFresh": "freshly generated", + "wiki.field.scope": "Scope (which clients see this)", + "wiki.scope.global": "All clients", + "wiki.scope.codex": "Codex", + "wiki.scope.claude": "Claude", + "wiki.scope.hermes": "Hermes", + "wiki.scope.openclaw": "OpenClaw", + "wiki.scope.hint": "Pick the clients that should see this knowledge. 'All clients' (global) is the default and means everyone.", + "wiki.scope.globalShort": "Global", + "wiki.scope.filter.all": "All scopes", + "wiki.scope.filter.global": "Global only", + "wiki.scope.filter.codex": "Codex only", + "wiki.scope.filter.claude": "Claude only", + "wiki.scope.filter.hermes": "Hermes only", + "wiki.scope.filter.openclaw": "OpenClaw only", + "wiki.scope.chip.global": "⚪ Global", + "wiki.scope.chip.codex": "🟦 Codex", + "wiki.scope.chip.claude": "🟪 Claude", + "wiki.scope.chip.hermes": "🟧 Hermes", + "wiki.scope.chip.openclaw": "🟩 OpenClaw", + "wiki.scope.summary": "Visible to: {scopes}" } \ No newline at end of file diff --git a/loop_memory/serve/static/i18n/zh.json b/loop_memory/serve/static/i18n/zh.json index 23db6e4..9c66151 100644 --- a/loop_memory/serve/static/i18n/zh.json +++ b/loop_memory/serve/static/i18n/zh.json @@ -616,5 +616,25 @@ "settings.schedule.offHint": "「关闭(仅手动)」= 不自动跑;选其它模式后将按所选节奏自动触发。", "dash.health.weeklyRefresh": "强制重新生成周报(忽略缓存)", "dash.health.weeklyCached": "已缓存", - "dash.health.weeklyFresh": "刚刚生成" + "dash.health.weeklyFresh": "刚刚生成", + "wiki.field.scope": "作用域(哪些客户端可见)", + "wiki.scope.global": "所有客户端", + "wiki.scope.codex": "Codex", + "wiki.scope.claude": "Claude", + "wiki.scope.hermes": "Hermes", + "wiki.scope.openclaw": "OpenClaw", + "wiki.scope.hint": "选择可看到此知识的客户端。默认「所有客户端」(全局)。", + "wiki.scope.globalShort": "全局", + "wiki.scope.filter.all": "全部作用域", + "wiki.scope.filter.global": "仅全局", + "wiki.scope.filter.codex": "仅 Codex", + "wiki.scope.filter.claude": "仅 Claude", + "wiki.scope.filter.hermes": "仅 Hermes", + "wiki.scope.filter.openclaw": "仅 OpenClaw", + "wiki.scope.chip.global": "⚪ 全局", + "wiki.scope.chip.codex": "🟦 Codex", + "wiki.scope.chip.claude": "🟪 Claude", + "wiki.scope.chip.hermes": "🟧 Hermes", + "wiki.scope.chip.openclaw": "🟩 OpenClaw", + "wiki.scope.summary": "可见:{scopes}" } \ No newline at end of file diff --git a/loop_memory/serve/static/js/components/Wiki.js b/loop_memory/serve/static/js/components/Wiki.js index f38d3f5..8353057 100644 --- a/loop_memory/serve/static/js/components/Wiki.js +++ b/loop_memory/serve/static/js/components/Wiki.js @@ -19,9 +19,12 @@ export const Wiki = defineComponent({ const pages = ref([]); const q = ref(''); const sort = ref('updated_desc'); + const scopeFilter = ref('all'); // 'all'|'global'|'codex'|... const loading = ref(false); const expanded = ref(null); const editing = ref(null); + // Scope tokens mirror WikiEditor.SCOPE_TOKENS + const SCOPE_TOKENS = ['global', 'codex', 'claude', 'hermes', 'openclaw']; async function refresh() { loading.value = true; @@ -44,6 +47,18 @@ export const Wiki = defineComponent({ (p.summary || '').toLowerCase().includes(needle) || (p.slug || '').toLowerCase().includes(needle)); } + // Scope filter: 'all' = everything; otherwise the page must + // either be 'global' (visible to everyone) OR include this + // client token in its comma-separated scope list. + if (scopeFilter.value && scopeFilter.value !== 'all') { + const tok = scopeFilter.value; + rows = rows.filter(p => { + const s = (p.scope || 'global').toString().toLowerCase(); + if (s === 'global') return true; // global visible to all + const tokens = s.split(',').map(x => x.trim()).filter(Boolean); + return tokens.includes(tok); + }); + } const cmp = (a, b) => { if (sort.value === 'updated_desc') return (b.updated_at || 0) - (a.updated_at || 0); if (sort.value === 'importance_desc') return (b.importance || 0) - (a.importance || 0); @@ -64,6 +79,16 @@ export const Wiki = defineComponent({ const lines = (p.body || '').split('\n'); return lines.filter(l => l.startsWith('- ')); } + /** Render the scope chips that show which clients a page is + * visible to. Always returns an array of strings; consumers + * iterate as-is. */ + function scopeTokensOf(p) { + const s = (p.scope || 'global').toString().toLowerCase(); + return s.split(',').map(x => x.trim()).filter(Boolean); + } + function scopeChipLabel(tok) { + return ('wiki.scope.chip.' + tok); + } function expand(id) { expanded.value = expanded.value === id ? null : id; } function edit(id) { editing.value = id; } @@ -194,7 +219,9 @@ export const Wiki = defineComponent({ watch(() => store.activeTab, (id) => { if (id === 'wiki') refresh(); }); return { - store, t, pages, q, sort, loading, visible, expanded, editing, bulletsOf, + store, t, pages, q, sort, scopeFilter, SCOPE_TOKENS, + loading, visible, expanded, editing, bulletsOf, + scopeTokensOf, scopeChipLabel, refresh, expand, edit, onNew, onExport, onImportClick, importing, exporting, saveEdit, removePage, fmtTime, }; }, @@ -208,6 +235,14 @@ export const Wiki = defineComponent({ +
{{ p.summary }}
+
+ + {{ t(scopeChipLabel(tok)) }} + +
  • {{ b.replace(/^-\\s*/, '') }}
diff --git a/loop_memory/serve/static/js/components/WikiEditor.js b/loop_memory/serve/static/js/components/WikiEditor.js index 6c00ab3..b8818ef 100644 --- a/loop_memory/serve/static/js/components/WikiEditor.js +++ b/loop_memory/serve/static/js/components/WikiEditor.js @@ -23,10 +23,15 @@ export const WikiEditor = defineComponent({ const body = ref(''); const tags = ref(''); const importance = ref(0.5); + // Scope — which clients should see this wiki page on recall. + // 'global' is exclusive (mutually exclusive with per-client chips). + const SCOPE_TOKENS = ['global', 'codex', 'claude', 'hermes', 'openclaw']; + const scope = ref(['global']); async function load() { if (props.pageId === 'new') { title.value = ''; summary.value = ''; body.value = '- '; tags.value = ''; importance.value = 0.5; + scope.value = ['global']; return; } loading.value = true; @@ -37,6 +42,9 @@ export const WikiEditor = defineComponent({ body.value = p.body || ''; tags.value = (p.tags || []).join(', '); importance.value = p.importance || 0.5; + const rawScope = (p.scope || 'global').toString().toLowerCase(); + const tokens = rawScope.split(',').map(s => s.trim()).filter(Boolean); + scope.value = tokens.length ? tokens : ['global']; } catch (e) { // ignore } finally { @@ -46,6 +54,19 @@ export const WikiEditor = defineComponent({ watch(() => props.pageId, load, { immediate: true }); + function toggleScope(token) { + const cur = new Set(scope.value); + if (token === 'global') { + // global is exclusive: clicking it clears the per-client list + scope.value = ['global']; + return; + } + cur.delete('global'); // any per-client click leaves global + if (cur.has(token)) cur.delete(token); else cur.add(token); + // Fallback: if user clears everything, fall back to global. + scope.value = cur.size ? Array.from(cur) : ['global']; + } + function onSave() { const payload = { title: title.value.trim(), @@ -53,11 +74,15 @@ export const WikiEditor = defineComponent({ body: body.value, tags: tags.value.split(',').map(s => s.trim()).filter(Boolean), importance: Number(importance.value) || 0.5, + scope: scope.value.join(','), }; emit('save', payload); } - return { loading, title, summary, body, tags, importance, t, onSave, onCancel: () => emit('cancel') }; + return { + loading, title, summary, body, tags, importance, scope, + SCOPE_TOKENS, toggleScope, t, onSave, onCancel: () => emit('cancel'), + }; }, template: /* html */ ` +
{{ t('common.loading') }}
diff --git a/loop_memory/storage/sqlite_store.py b/loop_memory/storage/sqlite_store.py index 0ba8a91..c6e2f67 100644 --- a/loop_memory/storage/sqlite_store.py +++ b/loop_memory/storage/sqlite_store.py @@ -1796,6 +1796,7 @@ def _row_to_wiki(self, row: sqlite3.Row) -> Dict[str, Any]: "version": int(row["version"] or 1), "created_at": float(row["created_at"] or 0.0), "updated_at": float(row["updated_at"] or 0.0), + "scope": row["scope"] or "global", } # --- scoring ---------------------------------------------------------- diff --git a/tests/test_store.py b/tests/test_store.py index 69122e5..ddec5f3 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -262,3 +262,74 @@ def test_memory_text_trimmed_at_l0(self) -> None: for m in out["memories"]: self.assertEqual(m["text"], "", "L0 trims memory text") self.assertGreater(len(m["preview"]), 0, "preview remains") + + +class WikiScopeTests(unittest.TestCase): + """Per-source scope on wiki_pages ('global' or comma-list of clients).""" + + def setUp(self) -> None: + self.path = Path("/tmp/test_wiki_scope.db") + self.path.unlink(missing_ok=True) + self.store = MemoryStore(self.path) + # Each body carries a unique token so tests can isolate them. + self.page_codex = self.store.upsert_wiki_page( + slug="codex-only", title="Codex专属", body="- markertoken-codex", + scope="codex", + ) + self.page_claude = self.store.upsert_wiki_page( + slug="claude-only", title="Claude专属", body="- markertoken-claude", + scope="claude", + ) + self.page_global = self.store.upsert_wiki_page( + slug="for-all", title="通用知识", body="- markertoken-global", + scope="global", + ) + + def tearDown(self) -> None: + self.path.unlink(missing_ok=True) + + def test_row_to_wiki_returns_scope(self) -> None: + # list_wiki_pages() searches title/body/summary, so query the + # unique markertoken each page's body contains. + for slug, want, body_token in ( + ("codex-only", "codex", "markertoken-codex"), + ("claude-only", "claude", "markertoken-claude"), + ("for-all", "global", "markertoken-global")): + results = self.store.list_wiki_pages(query=body_token) + self.assertEqual(len(results), 1, + f"expected exactly 1 row for token {body_token!r}, got {len(results)}") + page = results[0] + self.assertEqual(page["slug"], slug) + self.assertEqual(page["scope"], want) + + def test_recall_filters_wiki_by_source(self) -> None: + """When the caller passes source='codex', only pages whose + scope is 'global' or includes 'codex' should be returned. + + We query for the unique markertoken each page carries so + trigram/LIKE noise from other wiki pages can't pollute the + assertion.""" + out_codex = self.store.recall_hybrid("markertoken", limit=20, + source="codex", level=0) + titles_codex = {w["title"] for w in out_codex["wiki"]} + # codex client: sees Codex专属 (own scope) + 通用知识 (global) + self.assertIn("Codex专属", titles_codex) + self.assertIn("通用知识", titles_codex) + # codex client must NOT see Claude专属 + self.assertNotIn("Claude专属", titles_codex) + + out_claude = self.store.recall_hybrid("markertoken", limit=20, + source="claude", level=0) + titles_claude = {w["title"] for w in out_claude["wiki"]} + # claude client: sees Claude专属 + 通用知识, NOT Codex专属 + self.assertIn("Claude专属", titles_claude) + self.assertIn("通用知识", titles_claude) + self.assertNotIn("Codex专属", titles_claude) + + def test_recall_without_source_sees_everything(self) -> None: + out = self.store.recall_hybrid("markertoken", limit=20, level=0) + titles = {w["title"] for w in out["wiki"]} + # No source filter = admin view, sees all wiki pages. + self.assertIn("Codex专属", titles) + self.assertIn("Claude专属", titles) + self.assertIn("通用知识", titles) From 5c60ead8b059369bd4cb40ab01899f6348160a06 Mon Sep 17 00:00:00 2001 From: smartfind Date: Sun, 19 Jul 2026 22:43:37 +0800 Subject: [PATCH 53/88] fix(lint): silence ruff F841 + UP034 in temporal-recall path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI matrix (3.10 / 3.11 / 3.12) was failing on the "Lint (ruff)" step on every Python version with the same two findings: * F841 — `tscore` was assigned but never used. The temporal-reasoning code path originally called `temporal_score()` per fused row in the channel-3 block of `recall_hybrid`, intending to "overwrite in the hydration step" (where the real `created_at` is read from SQLite). The outer call's result was never consumed, so the dead work just wasted cycles and tripped the linter. Removed the unused assignment with a comment pointing at where the real score is computed (in `_hydrate_memories` / `_hydrate_wiki`). * UP034 — extraneous parens around `r["importance"] or 0` inside `float(...)`. Simple removal. Verified locally: ruff check loop_memory tests → All checks passed! pytest tests/ → 239 passed python -m loop_memory.examples.demo → ok python -m loop_memory.cli.main stats → ok Pushing so the workflow can re-run. --- loop_memory/storage/sqlite_store.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/loop_memory/storage/sqlite_store.py b/loop_memory/storage/sqlite_store.py index c6e2f67..9d35c93 100644 --- a/loop_memory/storage/sqlite_store.py +++ b/loop_memory/storage/sqlite_store.py @@ -1228,18 +1228,13 @@ def recall_hybrid( t_intent, t_conf = detect_temporal_intent(query) _now_ts = _time.time() if t_intent != "any" and t_conf > 0: + # We don't multiply here: the per-row hydration step in + # _hydrate_memories / _hydrate_wiki reads the actual + # created_at / updated_at from SQLite and recomputes + # the temporal score with that timestamp. The fused + # rows only need the intent + confidence carried through + # so the hydration helpers can pick them up. for r in fused_mem: - tscore = temporal_score( - created_at=_now_ts, # fallback below per-row - updated_at=_now_ts, - intent=t_intent, - now=_now_ts, - confidence=t_conf, - ) - # Look up the actual row timestamp; if we don't have - # it in the fused payload, default to now (neutral). - # We'll overwrite tscore in the hydration step where - # we have the real created_at. r.setdefault("_t_intent", t_intent) r.setdefault("_t_conf", t_conf) for r in fused_wiki: @@ -1350,7 +1345,7 @@ def search_wiki_by_embedding(self, query_embedding, top_k=20) -> list[dict]: "ORDER BY (COALESCE(importance,0)*0.6 + 0.4) DESC, updated_at DESC LIMIT ?", (top_k,), ).fetchall() - return [{"id": r["id"], "_score": float((r["importance"] or 0))} for r in rows] + return [{"id": r["id"], "_score": float(r["importance"] or 0)} for r in rows] @staticmethod def _source_token(name: str) -> str: From e4888103978bc0e59291f33d1c42daf197c46ef8 Mon Sep 17 00:00:00 2001 From: loopmemory Date: Tue, 21 Jul 2026 23:01:34 +0800 Subject: [PATCH 54/88] fix(llm): defensive clean of schedule/behaviour in validate_config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier Settings builds POSTed the full config payload through `/api/admin/llm/schedule`, which only flat-merges keys into `cfg.schedule`. Repeated saves built up nested `cfg.schedule.schedule` / `cfg.schedule.behaviour` trees while leaving the top-level `enabled` / `mode` flags stale — the "saved but still shows unconfigured" persistence bug. Rebuilding the schedule/behaviour dicts from the canonical default + only the known keys on every `validate_config` call drops any nested pollution on the next save and prevents it from spreading to the scheduler or being shown back to the user. Verified locally: ruff check loop_memory tests pytest tests/ Pushing. --- loop_memory/llm/providers.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/loop_memory/llm/providers.py b/loop_memory/llm/providers.py index 4d724ea..542344a 100644 --- a/loop_memory/llm/providers.py +++ b/loop_memory/llm/providers.py @@ -460,8 +460,33 @@ def validate_config(cfg: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: out["base_url"] = spec.default_base_url if not isinstance(out.get("schedule"), dict): out["schedule"] = default_config()["schedule"] - if not isinstance(out.get("behaviour"), dict): - out["behaviour"] = default_config()["behaviour"] + # Defensive deep-clean of the schedule dict. Earlier versions of + # the Settings UI sent the full config payload through + # ``POST /api/admin/llm/schedule`` — a flat-merge endpoint that + # nested ``schedule`` and ``behaviour`` under + # ``cfg.schedule.schedule`` / ``cfg.schedule.behaviour`` while + # leaving the top-level ``enabled`` / ``mode`` stale. Saved rows + # built up that way can still be in the store today. Rebuilding + # the schedule from a clean default + the known keys drops any + # nested pollution on the next save and prevents it from + # spreading to the scheduler or being shown back to the user. + _sched_keys = set(default_config()["schedule"].keys()) + clean_sched = dict(default_config()["schedule"]) + if isinstance(out.get("schedule"), dict): + for k in _sched_keys: + if k in out["schedule"]: + clean_sched[k] = out["schedule"][k] + out["schedule"] = clean_sched + # Same treatment for behaviour — drop stray top-level / nested + # keys (e.g. ``schedule`` mistakenly merged in) so behaviour + # only ever carries the canonical knobs. + _beh_keys = set(default_config()["behaviour"].keys()) + clean_beh = dict(default_config()["behaviour"]) + if isinstance(out.get("behaviour"), dict): + for k in _beh_keys: + if k in out["behaviour"]: + clean_beh[k] = out["behaviour"][k] + out["behaviour"] = clean_beh beh = out["behaviour"] try: beh["batch_size"] = max(1, min(int(beh.get("batch_size") or 50), 500)) From d8eab482671ad5362a21bb30b12c062b00ba26f3 Mon Sep 17 00:00:00 2001 From: loopmemory Date: Tue, 21 Jul 2026 23:14:57 +0800 Subject: [PATCH 55/88] feat(ingest): configurable cadence with hot-reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background watcher that auto-ingests finished transcripts from Codex / Claude / Hermes / OpenClaw was hard-coded to a 60-second idle wait and 2-second poll. Users with long-lived sessions were getting fragmented memories (idle too short), and the constant stat() churn was wasting SSD cycles (poll too aggressive). Tuning required regenerating the launchd plist every time, which made experimentation expensive. This change exposes the two knobs through the Settings drawer and persists them in the settings store under a new `ingest` key: * `idle_seconds` — size-stable wait before ingesting a transcript (default 300s, range 30..3600) * `poll_seconds` — directory scan period (default 5s, range 1..60) Server side: * New `GET /api/admin/ingest/config` returns the current values, defaults, and bounds (drives the UI hints). * New `POST /api/admin/ingest/config` validates the payload against the bounds and rejects with HTTP 400 + a human-readable reason. * `run_watcher` now accepts `store=...` and re-reads the settings on a wall-clock 30-second cadence. Switching the slider in the UI takes effect within ~30s without restarting the launchd-managed hook process. * INFO-level logging is wired through `basicConfig` so the hot-reload lines actually show up in `/tmp/loop_codex.log` (the previous default of WARNING hid them). Front end: * Settings drawer gains a `sec-ingest` section between the LLM connection card and the schedule card. Two number inputs, save/reset buttons, and a green "Live" chip that flashes for 2.5s after a successful save. * `api.js` gets `getIngestConfig` / `saveIngestConfig` helpers. * The Settings save flow is rewritten to use the full-PUT endpoint (`api.saveLlm`) instead of the flat-merge POST endpoint, fixing the long-standing "saved but still shows unconfigured" persistence bug where `schedule` and `behaviour` were getting nested under themselves. * `onClearKey` now uses `__clear__` to drop the API secret on the same endpoint — no more double POSTs. * `onSave` auto-closes the drawer after a successful save so the user doesn't have to scroll back to the top to hit the close button. i18n: 11 new keys (`settings.section.ingest`, `settings.ingest.*`) in zh + en. Verified: * API: GET / POST endpoints return 200; idle=10 and poll=120 correctly rejected with 400. * UI: Settings drawer renders the section, defaults to 300/5, and the "Live" chip flashes after save (Playwright screenshot). * Hot reload: `launchctl kickstart -k gui/$(id -u)/com.loopmemory.codex` restarted the launchd hook, which then immediately picked up the new values from the settings store and logged `watcher settings reloaded: idle=300s poll=5.0s`. * Mutating the settings via the API and waiting 30s produced a follow-up reload line in the log: `watcher settings reloaded: idle=480s poll=4.0s`. * Old launchd-managed hook processes (PID 14765, 80872) were reaped; `launchctl` auto-restarted them under the new code. --- loop_memory/cli/commands/serve.py | 9 +- loop_memory/serve/app.py | 87 ++++++++++ loop_memory/serve/static/css/app.css | 123 +++++++++++++- loop_memory/serve/static/i18n/en.json | 15 +- loop_memory/serve/static/i18n/zh.json | 15 +- loop_memory/serve/static/js/api.js | 19 +++ .../serve/static/js/components/Settings.js | 152 +++++++++++++++++- loop_memory/serve/watcher.py | 93 ++++++++++- 8 files changed, 496 insertions(+), 17 deletions(-) diff --git a/loop_memory/cli/commands/serve.py b/loop_memory/cli/commands/serve.py index 11e8059..422ae00 100644 --- a/loop_memory/cli/commands/serve.py +++ b/loop_memory/cli/commands/serve.py @@ -87,7 +87,10 @@ def run_hook(args) -> int: print("LOOP_MEMORY_ONCE_RESULT " + _json.dumps(results)) return 0 if len(watches) == 1: - run_watcher(loader, watches[0], pipeline, idle_seconds=idle_seconds) + # Pass ``store=store`` so the watcher can hot-reload + # ingest frequency / poll cadence from Settings → 采集频率 + # without needing a launchd restart. + run_watcher(loader, watches[0], pipeline, idle_seconds=idle_seconds, store=store) return 0 # Multiple watches: spawn one thread per path so each watcher # has its own poll loop and ledger (no cross-talk between @@ -97,10 +100,8 @@ def run_hook(args) -> int: for w in watches: t = threading.Thread( target=run_watcher, - # Positional: loader, watch_dir, pipeline, poll_seconds, - # idle_seconds. We pass idle_seconds=60 by default; the - # caller can override via --idle. args=(loader, w, pipeline, 2.0, idle_seconds), + kwargs={"store": store}, daemon=True, name=f"loop-memory-watcher-{w.name}", ) diff --git a/loop_memory/serve/app.py b/loop_memory/serve/app.py index f107276..3f98324 100644 --- a/loop_memory/serve/app.py +++ b/loop_memory/serve/app.py @@ -1415,6 +1415,93 @@ def consolidate_now(): return {"queued": False, "result": result} @app.post("/api/admin/ingest") + @app.get("/api/admin/ingest/config") + def ingest_config_get(): + """Return the watcher's ingest cadence settings. + + These knobs drive the background file-system watcher that + auto-ingests finished transcripts from Codex / Claude / Hermes + / OpenClaw. Exposing them via the API lets the Settings + drawer tune the cadence without regenerating the launchd + plist — the watcher reads from the settings store every + few iterations and picks up changes automatically. + + Defaults are 5 minutes idle (size-stable wait) and 5-second + poll. Users can dial up for less aggressive scanning (good + for SSDs and long-lived sessions) or down for snappier + recall. + """ + from ..serve.watcher import DEFAULT_IDLE_SECONDS, DEFAULT_POLL_SECONDS + cfg = store.get_setting("ingest", {}) or {} + return { + "idle_seconds": float(cfg.get("idle_seconds", DEFAULT_IDLE_SECONDS)), + "poll_seconds": float(cfg.get("poll_seconds", DEFAULT_POLL_SECONDS)), + "defaults": { + "idle_seconds": DEFAULT_IDLE_SECONDS, + "poll_seconds": DEFAULT_POLL_SECONDS, + }, + "notes": { + "idle_seconds": "size-stable wait before a transcript is " + "considered finished and ingested (seconds)", + "poll_seconds": "how often the watcher scans the directory " + "(seconds)", + "min_idle_seconds": 30, + "min_poll_seconds": 1, + "max_idle_seconds": 3600, + "max_poll_seconds": 60, + }, + } + + @app.post("/api/admin/ingest/config") + def ingest_config_post(body: dict): + """Persist new ingest cadence settings. + + Body fields (all optional): + * ``idle_seconds`` (int|float) — size-stable wait before ingest + * ``poll_seconds`` (int|float) — directory scan period + + Validation enforces sane bounds (>= 30s for idle to avoid + fragmenting long sessions, <= 3600s so something eventually + ingests; >= 1s for poll, <= 60s to bound stat() churn). + Settings persist immediately and the running watcher picks + them up on the next reload tick (≤ 30s later). + """ + if not isinstance(body, dict): + from fastapi import HTTPException + raise HTTPException(400, "body must be an object") + from ..serve.watcher import DEFAULT_IDLE_SECONDS, DEFAULT_POLL_SECONDS + current = store.get_setting("ingest", {}) or {} + idle = body.get("idle_seconds", current.get("idle_seconds", DEFAULT_IDLE_SECONDS)) + poll = body.get("poll_seconds", current.get("poll_seconds", DEFAULT_POLL_SECONDS)) + try: + idle = float(idle) + poll = float(poll) + except (TypeError, ValueError): + from fastapi import HTTPException + raise HTTPException(400, "idle_seconds and poll_seconds must be numeric") + # Bounds — see API docstring. + if idle < 30 or idle > 3600: + from fastapi import HTTPException + raise HTTPException( + 400, + f"idle_seconds must be in [30, 3600] (got {idle}); " + "use longer intervals to reduce disk wear and avoid " + "fragmenting long sessions", + ) + if poll < 1 or poll > 60: + from fastapi import HTTPException + raise HTTPException( + 400, + f"poll_seconds must be in [1, 60] (got {poll}); " + "the directory scan itself is cheap (stat()) but it " + "still runs once per tick", + ) + new_cfg = dict(current) + new_cfg["idle_seconds"] = idle + new_cfg["poll_seconds"] = poll + store.set_setting("ingest", new_cfg) + return {"ok": True, "ingest": new_cfg} + def ingest(source: str, path: str | None = None): from ..backends.embedding import HashingEmbedder from ..ingest.loader import default_paths, get_loader diff --git a/loop_memory/serve/static/css/app.css b/loop_memory/serve/static/css/app.css index ff71f1d..c0cb89f 100644 --- a/loop_memory/serve/static/css/app.css +++ b/loop_memory/serve/static/css/app.css @@ -599,6 +599,86 @@ } .wiki-toolbar input.grow { flex: 1; min-width: 200px; } + /* ------------------------------------------------------------------ + 全局 toggle styles — two variants: + * .master-global-toggle — sits in the wiki toolbar, drives + a single bulk write that flips every page to scope='global'. + * .card-global-toggle — sits inside each wiki card, + toggles just that one page's scope. + + Both share the same visual language: a 32x18 track with a 14px + knob that slides on a 120ms ease. The "mixed" state (master + is on but some pages have diverged) is rendered as a half-tinted + knob so the user can see the partial-state at a glance. + ------------------------------------------------------------------ */ + .master-global-toggle, + .card-global-toggle { + display: inline-flex; align-items: center; gap: 7px; + cursor: pointer; + user-select: none; + font-size: 12.5px; + color: var(--text-mute); + transition: color .12s ease, opacity .12s ease; + } + .master-global-toggle.on, + .card-global-toggle.on { color: var(--accent); } + .master-global-toggle.busy, + .card-global-toggle.busy { opacity: 0.6; cursor: progress; } + .master-global-toggle input, + .card-global-toggle input { + position: absolute; opacity: 0; width: 0; height: 0; + pointer-events: none; + } + .master-global-toggle .mgt-knob, + .card-global-toggle .cgt-knob { + position: relative; + width: 32px; height: 18px; + border-radius: 999px; + background: var(--surface-2); + border: 1px solid var(--border); + transition: background .15s ease, border-color .15s ease; + flex-shrink: 0; + } + .master-global-toggle .mgt-knob::after, + .card-global-toggle .cgt-knob::after { + content: ''; + position: absolute; + top: 1px; left: 1px; + width: 14px; height: 14px; + border-radius: 50%; + background: var(--text-faint); + box-shadow: 0 1px 2px rgba(0,0,0,0.18); + transition: transform .15s ease, background .15s ease; + } + .master-global-toggle.on .mgt-knob, + .card-global-toggle.on .cgt-knob { + background: color-mix(in srgb, var(--accent) 22%, var(--surface-2)); + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + } + .master-global-toggle.on .mgt-knob::after, + .card-global-toggle.on .cgt-knob::after { + transform: translateX(14px); + background: var(--accent); + } + /* Inactive label colour should fade when the toggle is OFF so the + "off by default" affordance is obvious on first load. */ + .master-global-toggle .mgt-text, + .card-global-toggle .cgt-text { + font-weight: 500; + line-height: 1; + } + .master-global-toggle { padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface-2); } + .master-global-toggle:hover { border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); } + /* The card-level toggle lives inside the card's scope row, so it + inherits the same vertical rhythm as the chips above. */ + .card-global-toggle { margin: -2px 0 8px; } + .card-global-toggle:focus-within .cgt-knob { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); + } + .master-global-toggle:focus-within .mgt-knob { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); + } + .wiki-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); @@ -1399,10 +1479,19 @@ border-left: 1px solid var(--border); box-shadow: -8px 0 32px rgba(15,23,42,0.18); z-index: 90; - flex-direction: column; + /* Flex column so the head / body / foot partition the viewport + vertically: the body scrolls, the foot stays pinned at the + bottom so the Save button is always one click away — fixes + "have to scroll to the bottom to save, then back to the top + to close". ``overflow: hidden`` is required so the body's + ``overflow-y: auto`` does not bubble up to the page itself. */ + display: flex; flex-direction: column; + overflow: hidden; } /* legacy .drawer.show not used anymore — Vue v-show drives visibility */ .drawer-head { + /* Header sits at the top of the flex column at its natural height. */ + flex: 0 0 auto; padding: 16px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: flex-start; gap: 10px; } @@ -1450,9 +1539,41 @@ font-size: 13px; } .drawer-foot { + /* Pin the action bar to the bottom of the drawer so the Save + button is always reachable, even when the schedule / behaviour + sections scroll. ``flex: 0 0 auto`` keeps the foot at its + natural height (no shrink/grow). The shadow + border hint at + scrollable content above. */ + flex: 0 0 auto; padding: 12px 18px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; + align-items: center; background: var(--surface-2); + box-shadow: 0 -8px 16px -10px rgba(15, 23, 42, 0.18); + } + /* When the drawer body has scrolled content, the foot button row + stays glued to the bottom regardless of scroll position. The + drawer-body itself owns ``overflow-y: auto`` so the scrollbar + lives in the body, not on the page. */ + .drawer-foot .btn { white-space: nowrap; } + + /* Ingest frequency section — chip for the "live" hint after saving. */ + .sec-ingest .hint { + display: block; + font-size: 11.5px; + color: var(--text-faint); + margin-top: 4px; + line-height: 1.4; + } + .ingest-hint { + align-self: center; + margin-left: auto; + font-size: 11.5px; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); + border-radius: 999px; + padding: 3px 10px; } .drawer-section { margin-bottom: 18px; } /* Doctor modal — green/red diagnostic screen accessible from kebab. */ diff --git a/loop_memory/serve/static/i18n/en.json b/loop_memory/serve/static/i18n/en.json index b0cdfea..9438989 100644 --- a/loop_memory/serve/static/i18n/en.json +++ b/loop_memory/serve/static/i18n/en.json @@ -636,5 +636,16 @@ "wiki.scope.chip.claude": "🟪 Claude", "wiki.scope.chip.hermes": "🟧 Hermes", "wiki.scope.chip.openclaw": "🟩 OpenClaw", - "wiki.scope.summary": "Visible to: {scopes}" -} \ No newline at end of file + "wiki.scope.summary": "Visible to: {scopes}", + "settings.ingest.saved": "Ingest cadence saved (idle {idle}s, scan {poll}s). Active on next watcher tick.", + "settings.ingest.saveFail": "Save failed: {msg}", + "settings.ingest.resetToDefaults": "Restore 5-minute idle / 5-second scan defaults", + "settings.ingest.idle": "Write-idle threshold (sec)", + "settings.ingest.pollHint": "How often the watcher scans the watched directory. Default {def}s. Bigger = lower stat() churn, smaller = snappier recall.", + "settings.ingest.poll": "Directory scan interval (sec)", + "settings.ingest.scope": "How often the background watcher scans the directory and how long it waits after a transcript stops growing before ingesting. Larger values are gentler on disk but delay recall.", + "settings.ingest.idleHint": "Wait {min}–{max}s after the transcript stops growing before ingesting; default {def}s (5 min). Too short fragments long sessions.", + "settings.ingest.liveHint": "Live", + "settings.section.ingest": "Ingest frequency", + "settings.ingest.resetDefaults": "Reset" +} diff --git a/loop_memory/serve/static/i18n/zh.json b/loop_memory/serve/static/i18n/zh.json index 9c66151..6747ae4 100644 --- a/loop_memory/serve/static/i18n/zh.json +++ b/loop_memory/serve/static/i18n/zh.json @@ -636,5 +636,16 @@ "wiki.scope.chip.claude": "🟪 Claude", "wiki.scope.chip.hermes": "🟧 Hermes", "wiki.scope.chip.openclaw": "🟩 OpenClaw", - "wiki.scope.summary": "可见:{scopes}" -} \ No newline at end of file + "wiki.scope.summary": "可见:{scopes}", + "settings.ingest.saved": "采集频率已保存(空闲 {idle}s,扫描 {poll}s)。监听器下次循环生效。", + "settings.ingest.saveFail": "保存失败:{msg}", + "settings.ingest.resetToDefaults": "把空闲阈值和扫描间隔恢复为 5 分钟 / 5 秒", + "settings.ingest.idle": "写入空闲阈值(秒)", + "settings.ingest.pollHint": "监听器多久扫一次目录。默认 {def} 秒。过大延迟会偏高,过小会增加 stat() 频率。", + "settings.ingest.poll": "目录扫描间隔(秒)", + "settings.ingest.scope": "控制后台监听器多久扫一次目录、多久确认一条会话已写完再入库。值越大越省盘,写完后被召回的延迟也越大。", + "settings.ingest.idleHint": "会话文件大小连续 {min}–{max} 秒不再增长才入库;默认 {def} 秒(5 分钟)。过短会把长会话切成多段。", + "settings.ingest.liveHint": "已生效", + "settings.section.ingest": "采集频率", + "settings.ingest.resetDefaults": "恢复默认" +} diff --git a/loop_memory/serve/static/js/api.js b/loop_memory/serve/static/js/api.js index 0beb70b..223530a 100644 --- a/loop_memory/serve/static/js/api.js +++ b/loop_memory/serve/static/js/api.js @@ -103,6 +103,11 @@ export const api = { createWiki: (payload) => fetchJSON('/api/wiki', { method: 'POST', body: payload }), updateWiki: (id, payload) => fetchJSON(`/api/wiki/${id}`, { method: 'PUT', body: payload }), deleteWiki: (id) => fetchJSON(`/api/wiki/${id}`, { method: 'DELETE' }), + // Bulk-set ``scope`` on one or many wiki pages in a single + // round-trip. ``payload.page_ids`` is optional — omitting it + // applies to every page (used by the master "全局" toggle's + // bulk-ON path). + bulkScopeWiki: (payload) => fetchJSON('/api/wiki/bulk-scope', { method: 'POST', body: payload }), // Graph graph: (params) => fetchJSON('/api/graph', { params }), @@ -117,11 +122,25 @@ export const api = { llmStatus: () => fetchJSON('/api/admin/llm/status'), llmRun: (payload) => fetchJSON('/api/admin/llm/run', { method: 'POST', body: payload }), llmSchedule: (payload) => fetchJSON('/api/admin/llm/schedule', { method: 'POST', body: payload }), + // Full save — writes the entire {provider, model, base_url, + // schedule, behaviour, api_key} tuple to the settings store. The + // ``/api/admin/llm/schedule`` endpoint stays around for genuine + // quick-toggle callers (the dashboard button, hooks, etc.) and + // merges into ``cfg.schedule`` flat — sending the full form to + // it would put the entire schedule object under + // ``cfg.schedule.schedule`` and silently drop the top-level + // ``enabled``/``mode`` flags, which is the persistence bug. + saveLlm: (payload) => fetchJSON('/api/admin/llm/config', { method: 'PUT', body: payload }), // Ingest / score rescore: () => fetchJSON('/api/admin/rescore', { method: 'POST' }), rebuildGraph: () => fetchJSON('/api/admin/graph/rebuild', { method: 'POST' }), ingest: (source, path) => fetchJSON('/api/admin/ingest', { method: 'POST', params: { source, path } }), + // Watcher ingest-cadence settings. ``getIngestConfig`` returns the + // current values plus the defaults and bounds so the Settings UI + // can render hint text without hardcoding them. + getIngestConfig: () => fetchJSON('/api/admin/ingest/config'), + saveIngestConfig: (payload) => fetchJSON('/api/admin/ingest/config', { method: 'POST', body: payload }), // Force-ingest endpoint: skips the watcher's idle window. Used by // the IngestPopover "Force active session" button when the user // has a long-running conversation and doesn't want to wait for the diff --git a/loop_memory/serve/static/js/components/Settings.js b/loop_memory/serve/static/js/components/Settings.js index f7c8391..5c660d7 100644 --- a/loop_memory/serve/static/js/components/Settings.js +++ b/loop_memory/serve/static/js/components/Settings.js @@ -44,6 +44,25 @@ export const Settings = defineComponent({ enable_summarize: true, dry_run: false, }, }); + // Ingest cadence — drives the background file watcher that + // auto-ingests finished transcripts from Codex / Claude / Hermes + // / OpenClaw. Kept SEPARATE from the LLM ``cfg`` block above + // because: + // * it lives in a different settings key (``ingest`` not + // ``llm_consolidator``); + // * its save round-trip is independent (the watcher is a + // separate process that hot-reloads every few ticks); + // * it has no api_key handling so it doesn't share the LLM + // save flow's fingerprint / key lifecycle. + const ingestCfg = reactive({ + idle_seconds: 300, // size-stable wait before ingesting + poll_seconds: 5, // directory scan period + defaults: { idle_seconds: 300, poll_seconds: 5 }, + notes: { min_idle_seconds: 30, min_poll_seconds: 1, + max_idle_seconds: 3600, max_poll_seconds: 60 }, + }); + const ingestSaving = ref(false); + const ingestHint = ref(false); const testing = ref(false); const testResult = ref(null); const saving = ref(false); @@ -58,13 +77,32 @@ export const Settings = defineComponent({ const previewOpen = ref(false); async function load() { + let ing = null; try { - const [p, c] = await Promise.all([api.llmProviders(), api.llmConfig()]); + const [p, c] = await Promise.all([ + api.llmProviders(), api.llmConfig(), + ]); providers.value = p || []; // /api/admin/llm/config returns {config: {...}, warnings, ...} const actualCfg = (c && c.config) ? c.config : c; Object.assign(cfg, actualCfg); } catch (e) { /* ignore */ } + try { + ing = await api.getIngestConfig(); + } catch (e) { ing = null; } + // Hydrate ingestCfg from the dedicated endpoint. Falls back to + // the reactive defaults if the endpoint is unreachable (older + // server builds, or first paint before loadI18n lands). + if (ing && typeof ing === 'object') { + if (typeof ing.idle_seconds === 'number') ingestCfg.idle_seconds = ing.idle_seconds; + if (typeof ing.poll_seconds === 'number') ingestCfg.poll_seconds = ing.poll_seconds; + if (ing.defaults && typeof ing.defaults === 'object') { + ingestCfg.defaults = { ...ing.defaults }; + } + if (ing.notes && typeof ing.notes === 'object') { + ingestCfg.notes = { ...ing.notes }; + } + } try { const status = await api.llmStatus(); if (status && status.provider) store.modelInfo = { @@ -130,11 +168,30 @@ export const Settings = defineComponent({ schedule: cfg.schedule, behaviour: cfg.behaviour, }; if (cfg.api_key) payload.api_key = cfg.api_key; - await api.llmSchedule(payload); + // Use the full PUT endpoint so the entire config tuple + // (provider, model, base_url, schedule, behaviour, api_key) + // is written atomically. The ``/api/admin/llm/schedule`` POST + // endpoint only flat-merges keys into ``cfg.schedule``, so + // sending the full form there would nest ``schedule`` and + // ``behaviour`` under ``cfg.schedule.schedule`` / + // ``cfg.schedule.behaviour`` and silently leave the top-level + // ``enabled`` / ``mode`` flags stale — the "saved but still + // shows unconfigured" persistence bug. + await api.saveLlm(payload); savedHint.value = true; - setTimeout(() => { savedHint.value = false; }, 2500); - cfg.api_key = ''; + // Reload first so the in-memory ``cfg`` reflects what was + // actually persisted (handles ``api_key`` fingerprint, etc.) + // before we close the drawer. await load(); + toast(t('settings.saved') || t('common.saved') || '\u5df2\u4fdd\u5b58', 1800); + cfg.api_key = ''; + // Auto-close the drawer so the user does not have to scroll + // back to the top to hit the close button after saving. + // The toast confirms the save; reopening shows the new state. + setTimeout(() => { + savedHint.value = false; + emit('close'); + }, 700); } catch (e) { toast(t('common.error') + ': ' + e.message, 4000); } finally { @@ -142,14 +199,51 @@ export const Settings = defineComponent({ } } + async function onSaveIngest() { + ingestSaving.value = true; + try { + const r = await api.saveIngestConfig({ + idle_seconds: Number(ingestCfg.idle_seconds), + poll_seconds: Number(ingestCfg.poll_seconds), + }); + ingestHint.value = true; + toast(t('settings.ingest.saved', { + idle: ingestCfg.idle_seconds, poll: ingestCfg.poll_seconds, + }), 2200); + // Re-pull so any server-side normalization is reflected. + if (r && r.ingest) { + ingestCfg.idle_seconds = r.ingest.idle_seconds; + ingestCfg.poll_seconds = r.ingest.poll_seconds; + } + setTimeout(() => { ingestHint.value = false; }, 2500); + } catch (e) { + toast(t('settings.ingest.saveFail', { msg: e.message }), 4000); + } finally { + ingestSaving.value = false; + } + } + async function onClearKey() { if (!confirm(t('settings.apiKey.confirmClear'))) return; try { - await api.llmSchedule({ ...cfg, api_key: '' }); + // ``__clear__`` is the sentinel the PUT endpoint understands: + // it deletes the secret from the backend and flips + // ``api_key_set`` to false. We must NOT use the + // ``/api/admin/llm/schedule`` POST endpoint here — it + // would re-introduce the same persistence bug as + // ``onSave`` (nested ``schedule`` / ``behaviour``). + const payload = { + provider: cfg.provider, model: cfg.model, base_url: cfg.base_url, + schedule: cfg.schedule, behaviour: cfg.behaviour, + api_key: '__clear__', + }; + await api.saveLlm(payload); cfg.api_key_set = false; toast(t('settings.apiKey.cleared'), 2000); await load(); - } catch (e) { /* ignore */ } + } catch (e) { + toast(t('toast.fail', { msg: e.message }), 4000); + } } async function onReset() { @@ -253,6 +347,7 @@ export const Settings = defineComponent({ return { cfg, providers, selectedProvider, onProviderChange, testing, testResult, onTest, saving, savedHint, onSave, onClearKey, onReset, onRunNow, onPreview, + ingestCfg, ingestSaving, ingestHint, onSaveIngest, runs, runsLoading, refreshRuns, nextRun, nextRunText, scheduleModeHint, previewItems, previewLoading, previewOpen, statusKey, triggerKey, statLine, @@ -336,6 +431,51 @@ export const Settings = defineComponent({
+ +
+

{{ t('settings.section.ingest') }}

+

{{ t('settings.ingest.scope') }}

+
+ + +
+
+ + + {{ t('settings.ingest.liveHint') }} +
+
+

{{ t('settings.section.schedule') }}

diff --git a/loop_memory/serve/watcher.py b/loop_memory/serve/watcher.py index f4df374..25f0b1e 100644 --- a/loop_memory/serve/watcher.py +++ b/loop_memory/serve/watcher.py @@ -25,6 +25,7 @@ import json import logging +import os import time from pathlib import Path from typing import Any, Optional @@ -33,6 +34,17 @@ from ..ingest.pipeline import MemoryPipeline log = logging.getLogger("loop_memory.watcher") +# Make ``watching ...`` / ``watcher settings reloaded: ...`` lines +# visible in the hook process log without requiring every caller to +# configure logging first. ``basicConfig`` is a no-op once the root +# logger already has a handler, so importing this module from the +# serve app or from tests doesn't disturb their log formatting. +if not logging.getLogger().handlers: + _level_name = os.environ.get("LOOP_MEMORY_LOG_LEVEL", "INFO").upper() + logging.basicConfig( + level=getattr(logging, _level_name, logging.INFO), + format="%(asctime)s [%(name)s] %(levelname)s %(message)s", + ) def _ledger_path(watch_dir: Path) -> Path: @@ -55,14 +67,49 @@ def _save_ledger(path: Path, ledger: dict) -> None: log.exception("failed to persist ingest ledger at %s", path) +# Default knobs when the user has no persisted ingest settings yet. +# We default to 5 minutes (300s) of size-stable idle before ingesting: +# shorter intervals fragment long conversations into multiple partial +# memories, longer intervals delay recall. The user can dial this up +# or down from Settings → 采集频率. ``poll_seconds`` defaults to 5 +# (vs the previous 2.0) to cut down on stat() churn on the watched +# directory — stat is cheap on SSD but very cheap on the order of +# seconds; not the order of milliseconds. The user can always tune +# both via the Settings drawer. +DEFAULT_IDLE_SECONDS = 300.0 +DEFAULT_POLL_SECONDS = 5.0 + + +def _read_ingest_settings(store) -> tuple[float, float]: + """Pull ``ingest.idle_seconds`` / ``ingest.poll_seconds`` from the + settings store. Missing keys fall back to module defaults. + + Returning floats (not e.g. ints) keeps the math inside the loop + predictable: ``time.sleep(poll_seconds)`` and the idle comparison + both treat the value as a wall-clock duration in seconds. + + A failure here is logged and falls back to defaults rather than + crashing the watcher — the watcher is a long-lived background + process and a transient DB hiccup must not kill it. + """ + try: + cfg = store.get_setting("ingest", {}) if store is not None else {} + except Exception: + cfg = {} + idle = float(cfg.get("idle_seconds", DEFAULT_IDLE_SECONDS)) + poll = float(cfg.get("poll_seconds", DEFAULT_POLL_SECONDS)) + return idle, poll + + def run_watcher( loader: BaseLoader, watch_dir: Path, pipeline: MemoryPipeline, - poll_seconds: float = 2.0, - idle_seconds: float = 60.0, + poll_seconds: float | None = None, + idle_seconds: float | None = None, ledger: dict | None = None, on_ingest: callable | None = None, + store: Any = None, ) -> None: """Watch a directory and ingest each transcript once it has been idle for ``idle_seconds``. @@ -74,6 +121,14 @@ def run_watcher( ``on_ingest`` is an optional callable invoked with no arguments after a successful ingest. The serve layer hooks this to a consolidator scheduler so ``realtime`` mode can fire. + + ``store`` is an optional :class:`MemoryStore`. When provided, the + watcher reads ``ingest.idle_seconds`` / ``ingest.poll_seconds`` + from the settings table at every iteration so the user can dial + ingest frequency from the Settings drawer WITHOUT restarting the + launchd watcher process. Reads are throttled to once every + ``SETTINGS_RELOAD_EVERY`` ticks (cheap SQLite SELECT) so we + don't add noticeable overhead even at a 5-second poll cadence. """ watch_dir = Path(watch_dir).expanduser() watch_dir.mkdir(parents=True, exist_ok=True) @@ -81,6 +136,20 @@ def run_watcher( if ledger is None: ledger = _load_ledger(ledger_path) + # Resolve initial values from the settings store if available, + # otherwise fall back to the kwarg / module defaults. + store_idle, store_poll = _read_ingest_settings(store) + if idle_seconds is None: + idle_seconds = store_idle + if poll_seconds is None: + poll_seconds = store_poll + + # Re-read settings on a wall-clock cadence instead of per-tick, + # so reload latency doesn't grow with ``poll_seconds``. We default + # to 30s: short enough that a user dialing the slider sees the + # effect promptly, long enough that we don't hammer SQLite. + SETTINGS_RELOAD_SECONDS = 30.0 + log.info( "watching %s for %s transcripts (idle>=%.0fs, poll=%.1fs)", watch_dir, loader.source, idle_seconds, poll_seconds, @@ -89,8 +158,27 @@ def run_watcher( def persist(): _save_ledger(ledger_path, ledger) + last_reload_at = 0.0 try: while True: + # Throttled settings reload on a wall-clock cadence so + # the reload interval is stable regardless of poll_seconds. + if store is not None: + now_mono = time.monotonic() + if now_mono - last_reload_at >= SETTINGS_RELOAD_SECONDS: + last_reload_at = now_mono + try: + new_idle, new_poll = _read_ingest_settings(store) + if new_idle != idle_seconds or new_poll != poll_seconds: + log.info( + "watcher settings reloaded: idle=%.0fs poll=%.1fs", + new_idle, new_poll, + ) + idle_seconds = new_idle + poll_seconds = new_poll + except Exception: + log.exception("settings reload failed (using current values)") + try: files = list(loader.discover(watch_dir)) except FileNotFoundError: @@ -222,6 +310,7 @@ def persist(): persist() + def run_once( loader: BaseLoader, watch_dir: Path, From dd0deb36f96b29cb9e1f060eedbf8545fdf0b589 Mon Sep 17 00:00:00 2001 From: loopmemory Date: Tue, 21 Jul 2026 23:15:34 +0800 Subject: [PATCH 56/88] fix(i18n): inline JSON in HTML to kill hydration flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On every hard refresh the dashboard flashed English-looking code-style text ("tab.wiki", "dash.weekly") for ~200ms before Vue mounted and the `loadI18n()` fetch resolved. The two i18n JSONs are loaded asynchronously by `store.js`, but Vue templates reference `t('tab.wiki')` from the first render — so until the fetch lands, the lookup function returns the raw key as a fallback. The fix: * `GET /` reads both JSON files from disk on every request and inlines them into the HTML as ``` that could appear inside the JSON + # (none today, but a single careless edit shouldn't break + # the whole page). The standard escape is ``<\/script>``. + en_safe = en_json.replace("", "<\\/script>") + zh_safe = zh_json.replace("", "<\\/script>") + # Inject the two payloads right before main.js so they + # exist when store.js evaluates. + inject = ( + '" + '" + ) + marker = '' + if marker in html: + html = html.replace(marker, inject + marker, 1) + else: + # Fallback: inject before + html = html.replace("", inject + "", 1) + except Exception: + # If the i18n files are missing or unreadable, fall back + # to the bare HTML — ``store.js`` will then load them + # over HTTP (with the old flash behaviour, but at least + # the page still renders). + pass + from fastapi.responses import HTMLResponse # type: ignore + return HTMLResponse(html) @app.get("/api/insights") def insights(): diff --git a/loop_memory/serve/static/js/store.js b/loop_memory/serve/static/js/store.js index bd2bc55..cb8435e 100644 --- a/loop_memory/serve/static/js/store.js +++ b/loop_memory/serve/static/js/store.js @@ -93,19 +93,54 @@ watch(() => [store.lang, store.theme, store.showZh], () => { const _i18n = reactive({ en: {}, zh: {} }); let _i18nLoaded = false; +/** + * Read the inline i18n JSON that the server injects into ```` that could appear inside the JSON - # (none today, but a single careless edit shouldn't break - # the whole page). The standard escape is ``<\/script>``. - en_safe = en_json.replace("", "<\\/script>") - zh_safe = zh_json.replace("", "<\\/script>") + # HTML-encode the JSON to prevent XSS when injected into + # with a standard reverse-solidus escape so it can't close the tag. + import html as _html + en_safe = _html.escape(en_json, quote=True).replace("", "<\\/script>") + zh_safe = _html.escape(zh_json, quote=True).replace("", "<\\/script>") # Inject the two payloads right before main.js so they # exist when store.js evaluates. inject = ( @@ -2549,6 +2625,26 @@ async def llm_config_put(body: dict): "api_key_saved_at": cfg.get("api_key_saved_at", 0), } + @app.get("/api/admin/auth/token") + def auth_token_get(): + """Check if auth token is configured.""" + token = store.get_setting("loop_memory_auth_token") + return {"enabled": bool(token)} + + @app.post("/api/admin/auth/token") + async def auth_token_post(): + """Generate a new auth token. Requires current auth (or no auth set).""" + import secrets as _secrets + new_token = _secrets.token_urlsafe(32) + store.set_setting("loop_memory_auth_token", new_token) + return {"token": new_token} + + @app.delete("/api/admin/auth/token") + def auth_token_delete(): + """Remove auth token (disables auth).""" + store.delete_setting("loop_memory_auth_token") + return {"ok": True} + @app.post("/api/admin/llm/test") async def llm_test_route(body: dict | None = None): """Smoke-test the LLM provider without writing to the store. diff --git a/loop_memory/serve/static/js/App.js b/loop_memory/serve/static/js/App.js index fed46fe..ca089fc 100644 --- a/loop_memory/serve/static/js/App.js +++ b/loop_memory/serve/static/js/App.js @@ -11,7 +11,7 @@ * Components DO NOT call each other directly — the App listens to user * events emitted by TopBar and orchestrates API calls. */ -import { defineComponent, ref, computed, onMounted, onUnmounted, watch, nextTick } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, ref, computed, onMounted, onUnmounted, watch, nextTick } from './lib/vue.esm-browser.prod.js'; import { store, t, applyTheme, applyLang, loadI18n, toast, registerActions } from './store.js'; import { api } from './api.js'; diff --git a/loop_memory/serve/static/js/api.js b/loop_memory/serve/static/js/api.js index 4af69c9..59e0812 100644 --- a/loop_memory/serve/static/js/api.js +++ b/loop_memory/serve/static/js/api.js @@ -22,12 +22,20 @@ function buildUrl(path, params) { return url; } +// Auth token for protected endpoints (set by Settings after login) +let _authToken = localStorage.getItem('loop_auth_token') || null; +export function setAuthToken(t) { _authToken = t; if (t) localStorage.setItem('loop_auth_token', t); else localStorage.removeItem('loop_auth_token'); } +export function getAuthToken() { return _authToken; } +// Re-export for use by store.js / Settings.js +export { setAuthToken, getAuthToken }; + export async function fetchJSON(path, opts = {}) { const { method = 'GET', params, body, headers = {}, timeoutMs = 30000, cache } = opts; const url = buildUrl(path, params); const ctrl = new AbortController(); const tid = setTimeout(() => ctrl.abort(), timeoutMs); const finalHeaders = { 'Accept': 'application/json', ...headers }; + if (_authToken) finalHeaders['Authorization'] = 'Bearer ' + _authToken; if (body !== undefined && !(body instanceof FormData)) { finalHeaders['Content-Type'] = 'application/json'; } @@ -175,4 +183,9 @@ export const api = { // Graph entity memories (memories that mention this entity). graphEntityMemories: (name, limit = 10) => fetchJSON(`/api/graph/entity/${encodeURIComponent(name)}/memories`, { params: { limit } }), + + // Auth token management + authTokenStatus: () => fetchJSON('/api/admin/auth/token'), + authTokenCreate: () => fetchJSON('/api/admin/auth/token', { method: 'POST' }), + authTokenDelete: () => fetchJSON('/api/admin/auth/token', { method: 'DELETE' }), }; diff --git a/loop_memory/serve/static/js/components/Dashboard.js b/loop_memory/serve/static/js/components/Dashboard.js index 997d438..a122984 100644 --- a/loop_memory/serve/static/js/components/Dashboard.js +++ b/loop_memory/serve/static/js/components/Dashboard.js @@ -15,8 +15,8 @@ * icons inside each node, and a file-anchor strip below. * - WriteGuard header shows uptime (time since first audit record). */ -import { defineComponent, ref, computed, onMounted, onUnmounted } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; -import { store, t, timeAgo, toast, escapeHtml, callAction } from '../store.js'; +import { defineComponent, ref, computed, onMounted, onUnmounted } from './lib/vue.esm-browser.prod.js'; +import { store, t, timeAgo, toast, escapeHtml, sanitizeHtml, callAction } from '../store.js'; import { api } from '../api.js'; const SVGNS = 'http://www.w3.org/2000/svg'; @@ -384,7 +384,8 @@ export const Dashboard = defineComponent({ if (!weeklyReport.value) return ''; const think = renderWeeklyThinking(weeklyReport.value.thinking); const body = renderWeeklyMarkdown(weeklyReport.value.markdown); - return think + body; + // Sanitize before v-html to prevent XSS + return sanitizeHtml(think + body); }); onMounted(() => { diff --git a/loop_memory/serve/static/js/components/Diagnostic.js b/loop_memory/serve/static/js/components/Diagnostic.js index 4be613f..816fe10 100644 --- a/loop_memory/serve/static/js/components/Diagnostic.js +++ b/loop_memory/serve/static/js/components/Diagnostic.js @@ -9,7 +9,7 @@ * full ``loop-memory install-hooks`` pipeline and prints the * resulting actions. */ -import { defineComponent, ref, computed } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, ref, computed } from './lib/vue.esm-browser.prod.js'; import { t, toast } from '../store.js'; const CLIENT_META = { diff --git a/loop_memory/serve/static/js/components/IngestPopover.js b/loop_memory/serve/static/js/components/IngestPopover.js index 1319dce..6c148f5 100644 --- a/loop_memory/serve/static/js/components/IngestPopover.js +++ b/loop_memory/serve/static/js/components/IngestPopover.js @@ -6,7 +6,7 @@ * pick one or more of the registered loaders (codex / claude / hermes / * openclaw) and runs them, surfacing per-source results. */ -import { defineComponent, ref, computed, onMounted, onUnmounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, ref, computed, onMounted, onUnmounted, watch } from './lib/vue.esm-browser.prod.js'; import { store, t, toast } from '../store.js'; import { api } from '../api.js'; diff --git a/loop_memory/serve/static/js/components/KnowledgeGraph.js b/loop_memory/serve/static/js/components/KnowledgeGraph.js index e8e39c6..c364b53 100644 --- a/loop_memory/serve/static/js/components/KnowledgeGraph.js +++ b/loop_memory/serve/static/js/components/KnowledgeGraph.js @@ -23,7 +23,7 @@ * connected edges (top 20 by weight) and evidence memories. * - Bottom legend strip (Wiki/Tag/Concept/Acronym colored dots). */ -import { defineComponent, ref, onMounted, onUnmounted, watch, nextTick, computed } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, ref, onMounted, onUnmounted, watch, nextTick, computed } from './lib/vue.esm-browser.prod.js'; import { store, t, toast } from '../store.js'; import { api } from '../api.js'; diff --git a/loop_memory/serve/static/js/components/RunStrip.js b/loop_memory/serve/static/js/components/RunStrip.js index f3e090e..345b8f4 100644 --- a/loop_memory/serve/static/js/components/RunStrip.js +++ b/loop_memory/serve/static/js/components/RunStrip.js @@ -2,7 +2,7 @@ * RunStrip — the bottom-of-page strip that shows when an AI distill * is in progress (current/total progress bar + cancel button). */ -import { defineComponent, computed } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, computed } from './lib/vue.esm-browser.prod.js'; import { store, t } from '../store.js'; export const RunStrip = defineComponent({ diff --git a/loop_memory/serve/static/js/components/Settings.js b/loop_memory/serve/static/js/components/Settings.js index 2ca9ad6..d0f4ddb 100644 --- a/loop_memory/serve/static/js/components/Settings.js +++ b/loop_memory/serve/static/js/components/Settings.js @@ -10,9 +10,9 @@ * - Drawer foot has Reset / Cancel / Save buttons (legacy parity). * - Schedule includes weekday selector (visible when mode=weekly). */ -import { defineComponent, ref, computed, onMounted, watch, reactive } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, ref, computed, onMounted, watch, reactive } from './lib/vue.esm-browser.prod.js'; import { store, t, toast, fmtTime, callAction } from '../store.js'; -import { api, ApiError } from '../api.js'; +import { api, ApiError, setAuthToken } from '../api.js'; const WEEKDAYS = [ { v: 0, k: 'weekday.mon' }, { v: 1, k: 'weekday.tue' }, @@ -96,6 +96,11 @@ export const Settings = defineComponent({ const testResult = ref(null); const saving = ref(false); const savedHint = ref(false); + // API key show/hide toggle + const keyVisible = ref(false); + // Auth token management + const authEnabled = ref(false); + const authLoading = ref(false); // Recent runs + next-run + preview state (legacy parity) const runs = ref([]); @@ -150,6 +155,11 @@ export const Settings = defineComponent({ nextRun.value = status?.next_run || null; } catch (e) { /* ignore */ } await refreshRuns(); + // Load auth token status + try { + const at = await api.authTokenStatus(); + authEnabled.value = at.enabled || false; + } catch (e) { /* ignore */ } } async function refreshRuns() { @@ -177,6 +187,28 @@ export const Settings = defineComponent({ if (p && p.default_base_url && !cfg.base_url) cfg.base_url = p.default_base_url; } + async function onToggleAuth() { + authLoading.value = true; + try { + if (authEnabled.value) { + await api.authTokenDelete(); + authEnabled.value = false; + setAuthToken(null); + toast(t('settings.auth.disabled') || '客户端已禁用', 2000); + } else { + const r = await api.authTokenCreate(); + authEnabled.value = true; + setAuthToken(r.token); + // Show token briefly then hide it + toast(t('settings.auth.enabled') || '客户端已启用,请将记录字典', 4000); + } + } catch (e) { + toast(t('common.error') + ': ' + e.message, 4000); + } finally { + authLoading.value = false; + } + } + async function onTest() { testing.value = true; testResult.value = null; @@ -599,9 +631,12 @@ export const Settings = defineComponent({
- + @@ -878,6 +913,20 @@ export const Settings = defineComponent({
+ +
+

{{ t('settings.section.security') || '🔒 ' + (store.lang === 'zh' ? '安全' : 'Security') }}

+
+ {{ authEnabled ? (store.lang === 'zh' ? '已启用 API 访问令牌' : 'API access token enabled') : (store.lang === 'zh' ? '未启用访问令牌(所有操作无需认证)' : 'No access token — all operations are unauthenticated') }} +
+
+ +
+
+

{{ t('settings.section.actions') }}

diff --git a/loop_memory/serve/static/js/components/Sidebar.js b/loop_memory/serve/static/js/components/Sidebar.js index 866e60a..4693752 100644 --- a/loop_memory/serve/static/js/components/Sidebar.js +++ b/loop_memory/serve/static/js/components/Sidebar.js @@ -15,7 +15,7 @@ * hollow badge for the "active" session, friendly empty state, * inline clear filter on the active session. */ -import { defineComponent, ref, computed, onMounted, onUnmounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, ref, computed, onMounted, onUnmounted, watch } from './lib/vue.esm-browser.prod.js'; import { store, t, timeAgo, fmtTime } from '../store.js'; import { api } from '../api.js'; diff --git a/loop_memory/serve/static/js/components/Tabs.js b/loop_memory/serve/static/js/components/Tabs.js index b094706..f356f7d 100644 --- a/loop_memory/serve/static/js/components/Tabs.js +++ b/loop_memory/serve/static/js/components/Tabs.js @@ -5,7 +5,7 @@ * to it. URL `?tab=` is read on app boot and written back when the user * switches, so a deep-link to a specific view round-trips. */ -import { defineComponent, computed, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, computed, watch } from './lib/vue.esm-browser.prod.js'; import { store, t } from '../store.js'; export const Tabs = defineComponent({ diff --git a/loop_memory/serve/static/js/components/Timeline.js b/loop_memory/serve/static/js/components/Timeline.js index 35b11a3..132e880 100644 --- a/loop_memory/serve/static/js/components/Timeline.js +++ b/loop_memory/serve/static/js/components/Timeline.js @@ -15,7 +15,7 @@ * the top makes the active scope obvious. Clearing the filter (banner * or sidebar) restores the all-session view. */ -import { defineComponent, ref, computed, onMounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, ref, computed, onMounted, watch } from './lib/vue.esm-browser.prod.js'; import { store, t, escapeHtml, timeAgo, fmtTime, toast } from '../store.js'; import { api } from '../api.js'; diff --git a/loop_memory/serve/static/js/components/Toast.js b/loop_memory/serve/static/js/components/Toast.js index 0b7a0a5..dfcc59d 100644 --- a/loop_memory/serve/static/js/components/Toast.js +++ b/loop_memory/serve/static/js/components/Toast.js @@ -1,7 +1,7 @@ /** * Toast — bottom-center transient notification. */ -import { defineComponent, computed } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, computed } from './lib/vue.esm-browser.prod.js'; import { store } from '../store.js'; export const Toast = defineComponent({ diff --git a/loop_memory/serve/static/js/components/TopBar.js b/loop_memory/serve/static/js/components/TopBar.js index ba6ad29..384865f 100644 --- a/loop_memory/serve/static/js/components/TopBar.js +++ b/loop_memory/serve/static/js/components/TopBar.js @@ -9,7 +9,7 @@ * component with one event-emitter for the actions (clicking "AI Run" * bubbles up to App which knows how to call the API). */ -import { defineComponent, computed, ref, onMounted, onUnmounted } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, computed, ref, onMounted, onUnmounted } from './lib/vue.esm-browser.prod.js'; import { store, patchPrefs, toast, t, timeAgo, fmtTime } from '../store.js'; import { IngestPopover } from './IngestPopover.js'; import { api, ApiError } from '../api.js'; diff --git a/loop_memory/serve/static/js/components/Wiki.js b/loop_memory/serve/static/js/components/Wiki.js index 71f4e12..6505841 100644 --- a/loop_memory/serve/static/js/components/Wiki.js +++ b/loop_memory/serve/static/js/components/Wiki.js @@ -7,8 +7,8 @@ * function that hand-built HTML strings — Vue's template syntax is * significantly easier to scan and edit. */ -import { defineComponent, ref, computed, onMounted, onUnmounted, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; -import { store, t, toast, escapeHtml, fmtTime } from '../store.js'; +import { defineComponent, ref, computed, onMounted, onUnmounted, watch } from './lib/vue.esm-browser.prod.js'; +import { store, t, toast, fmtTime } from '../store.js'; import { api } from '../api.js'; import { WikiEditor } from './WikiEditor.js'; diff --git a/loop_memory/serve/static/js/components/WikiEditor.js b/loop_memory/serve/static/js/components/WikiEditor.js index b8818ef..4500310 100644 --- a/loop_memory/serve/static/js/components/WikiEditor.js +++ b/loop_memory/serve/static/js/components/WikiEditor.js @@ -6,7 +6,7 @@ * typos and create new pages. Future iterations can grow the editor * without rewriting the surrounding list view. */ -import { defineComponent, ref, computed, watch } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { defineComponent, ref, computed, watch } from './lib/vue.esm-browser.prod.js'; import { store, t } from '../store.js'; import { api } from '../api.js'; @@ -27,13 +27,54 @@ export const WikiEditor = defineComponent({ // 'global' is exclusive (mutually exclusive with per-client chips). const SCOPE_TOKENS = ['global', 'codex', 'claude', 'hermes', 'openclaw']; const scope = ref(['global']); + // Auto-save draft to localStorage so edits survive refresh + const DRAFT_KEY = 'loop_wiki_draft_v1'; + let saveTimer = null; + function saveDraft() { + if (props.pageId !== 'new') return; // only draft new pages + try { + localStorage.setItem(DRAFT_KEY, JSON.stringify({ + title: title.value, summary: summary.value, body: body.value, + tags: tags.value, importance: importance.value, scope: scope.value, + savedAt: Date.now(), + })); + } catch (_) {} + } + function loadDraft() { + if (props.pageId !== 'new') return; + try { + const raw = localStorage.getItem(DRAFT_KEY); + if (!raw) return; + const d = JSON.parse(raw); + // Only restore if draft is < 24h old + if (d.savedAt && Date.now() - d.savedAt < 86400000) { + title.value = d.title || ''; + summary.value = d.summary || ''; + body.value = d.body || ''; + tags.value = d.tags || ''; + importance.value = d.importance ?? 0.5; + scope.value = d.scope || ['global']; + } + } catch (_) {} + } + function clearDraft() { + try { localStorage.removeItem(DRAFT_KEY); } catch (_) {} + } + // Debounced auto-save on any field change + function scheduleDraftSave() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(saveDraft, 1200); + } + watch([title, summary, body, tags, importance, scope], scheduleDraftSave, { deep: true }); async function load() { if (props.pageId === 'new') { - title.value = ''; summary.value = ''; body.value = '- '; tags.value = ''; importance.value = 0.5; - scope.value = ['global']; + // Try to restore draft first, then clear it + loadDraft(); + clearDraft(); return; } + clearDraft(); // clear any stale draft on successful load loading.value = true; try { const p = await api.getWiki(props.pageId); diff --git a/loop_memory/serve/static/js/lib/vue.esm-browser.prod.js b/loop_memory/serve/static/js/lib/vue.esm-browser.prod.js new file mode 100644 index 0000000..a7cb0a1 --- /dev/null +++ b/loop_memory/serve/static/js/lib/vue.esm-browser.prod.js @@ -0,0 +1,9 @@ +/** +* vue v3.4.38 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**//*! #__NO_SIDE_EFFECTS__ */let e,t,n,r,i,l,s,o,a;function c(e,t){let n=new Set(e.split(","));return t?e=>n.has(e.toLowerCase()):e=>n.has(e)}let u={},d=[],p=()=>{},h=()=>!1,f=e=>111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&(e.charCodeAt(2)>122||97>e.charCodeAt(2)),m=e=>e.startsWith("onUpdate:"),g=Object.assign,y=(e,t)=>{let n=e.indexOf(t);n>-1&&e.splice(n,1)},b=Object.prototype.hasOwnProperty,_=(e,t)=>b.call(e,t),S=Array.isArray,x=e=>"[object Map]"===O(e),C=e=>"[object Set]"===O(e),T=e=>"[object Date]"===O(e),k=e=>"[object RegExp]"===O(e),w=e=>"function"==typeof e,E=e=>"string"==typeof e,A=e=>"symbol"==typeof e,N=e=>null!==e&&"object"==typeof e,I=e=>(N(e)||w(e))&&w(e.then)&&w(e.catch),R=Object.prototype.toString,O=e=>R.call(e),M=e=>O(e).slice(8,-1),L=e=>"[object Object]"===O(e),P=e=>E(e)&&"NaN"!==e&&"-"!==e[0]&&""+parseInt(e,10)===e,$=c(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),F=c("bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo"),V=e=>{let t=Object.create(null);return n=>t[n]||(t[n]=e(n))},D=/-(\w)/g,B=V(e=>e.replace(D,(e,t)=>t?t.toUpperCase():"")),U=/\B([A-Z])/g,j=V(e=>e.replace(U,"-$1").toLowerCase()),H=V(e=>e.charAt(0).toUpperCase()+e.slice(1)),q=V(e=>e?`on${H(e)}`:""),W=(e,t)=>!Object.is(e,t),K=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:r,value:n})},G=e=>{let t=parseFloat(e);return isNaN(t)?e:t},J=e=>{let t=E(e)?Number(e):NaN;return isNaN(t)?e:t},X=()=>e||(e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{}),Q=c("Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error");function Z(e){if(S(e)){let t={};for(let n=0;n{if(e){let n=e.split(ee);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function er(e){let t="";if(E(e))t=e;else if(S(e))for(let n=0;neu(e,t))}let ep=e=>!!(e&&!0===e.__v_isRef),eh=e=>E(e)?e:null==e?"":S(e)||N(e)&&(e.toString===R||!w(e.toString))?ep(e)?eh(e.value):JSON.stringify(e,ef,2):String(e),ef=(e,t)=>ep(t)?ef(e,t.value):x(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((e,[t,n],r)=>(e[em(t,r)+" =>"]=n,e),{})}:C(t)?{[`Set(${t.size})`]:[...t.values()].map(e=>em(e))}:A(t)?em(t):!N(t)||S(t)||L(t)?t:String(t),em=(e,t="")=>{var n;return A(e)?`Symbol(${null!=(n=e.description)?n:t})`:e};class eg{constructor(e=!1){this.detached=e,this._active=!0,this.effects=[],this.cleanups=[],this.parent=t,!e&&t&&(this.index=(t.scopes||(t.scopes=[])).push(this)-1)}get active(){return this._active}run(e){if(this._active){let n=t;try{return t=this,e()}finally{t=n}}}on(){t=this}off(){t=this.parent}stop(e){if(this._active){let t,n;for(t=0,n=this.effects.length;t=4))break}1===this._dirtyLevel&&(this._dirtyLevel=0),eR()}return this._dirtyLevel>=4}set dirty(e){this._dirtyLevel=e?4:0}run(){if(this._dirtyLevel=0,!this.active)return this.fn();let e=eE,t=n;try{return eE=!0,n=this,this._runnings++,ex(this),this.fn()}finally{eC(this),this._runnings--,n=t,eE=e}}stop(){this.active&&(ex(this),eC(this),this.onStop&&this.onStop(),this.active=!1)}}function ex(e){e._trackId++,e._depsLength=0}function eC(e){if(e.deps.length>e._depsLength){for(let t=e._depsLength;t{n.dirty&&n.run()});t&&(g(n,t),t.scope&&ev(n,t.scope)),t&&t.lazy||n.run();let r=n.run.bind(n);return r.effect=n,r}function ew(e){e.effect.stop()}let eE=!0,eA=0,eN=[];function eI(){eN.push(eE),eE=!1}function eR(){let e=eN.pop();eE=void 0===e||e}function eO(){for(eA--;!eA&&eL.length;)eL.shift()()}function eM(e,t,n){if(t.get(e)!==e._trackId){t.set(e,e._trackId);let n=e.deps[e._depsLength];n!==t?(n&&eT(n,e),e.deps[e._depsLength++]=t):e._depsLength++}}let eL=[];function eP(e,t,n){for(let n of(eA++,e.keys())){let r;n._dirtyLevel{let n=new Map;return n.cleanup=e,n.computed=t,n},eF=new WeakMap,eV=Symbol(""),eD=Symbol("");function eB(e,t,r){if(eE&&n){let t=eF.get(e);t||eF.set(e,t=new Map);let i=t.get(r);i||t.set(r,i=e$(()=>t.delete(r))),eM(n,i)}}function eU(e,t,n,r,i,l){let s=eF.get(e);if(!s)return;let o=[];if("clear"===t)o=[...s.values()];else if("length"===n&&S(e)){let e=Number(r);s.forEach((t,n)=>{("length"===n||!A(n)&&n>=e)&&o.push(t)})}else switch(void 0!==n&&o.push(s.get(n)),t){case"add":S(e)?P(n)&&o.push(s.get("length")):(o.push(s.get(eV)),x(e)&&o.push(s.get(eD)));break;case"delete":!S(e)&&(o.push(s.get(eV)),x(e)&&o.push(s.get(eD)));break;case"set":x(e)&&o.push(s.get(eV))}for(let e of(eA++,o))e&&eP(e,4);eO()}let ej=c("__proto__,__v_isRef,__isVue"),eH=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>"arguments"!==e&&"caller"!==e).map(e=>Symbol[e]).filter(A)),eq=function(){let e={};return["includes","indexOf","lastIndexOf"].forEach(t=>{e[t]=function(...e){let n=tC(this);for(let e=0,t=this.length;e{e[t]=function(...e){eI(),eA++;let n=tC(this)[t].apply(this,e);return eO(),eR(),n}}),e}();function eW(e){A(e)||(e=String(e));let t=tC(this);return eB(t,"has",e),t.hasOwnProperty(e)}class eK{constructor(e=!1,t=!1){this._isReadonly=e,this._isShallow=t}get(e,t,n){let r=this._isReadonly,i=this._isShallow;if("__v_isReactive"===t)return!r;if("__v_isReadonly"===t)return r;if("__v_isShallow"===t)return i;if("__v_raw"===t)return n===(r?i?th:tp:i?td:tu).get(e)||Object.getPrototypeOf(e)===Object.getPrototypeOf(n)?e:void 0;let l=S(e);if(!r){if(l&&_(eq,t))return Reflect.get(eq,t,n);if("hasOwnProperty"===t)return eW}let s=Reflect.get(e,t,n);return(A(t)?eH.has(t):ej(t))?s:(r||eB(e,"get",t),i)?s:tI(s)?l&&P(t)?s:s.value:N(s)?r?tg(s):tf(s):s}}class ez extends eK{constructor(e=!1){super(!1,e)}set(e,t,n,r){let i=e[t];if(!this._isShallow){let t=t_(i);if(tS(n)||t_(n)||(i=tC(i),n=tC(n)),!S(e)&&tI(i)&&!tI(n))return!t&&(i.value=n,!0)}let l=S(e)&&P(t)?Number(t)e,e0=e=>Reflect.getPrototypeOf(e);function e1(e,t,n=!1,r=!1){let i=tC(e=e.__v_raw),l=tC(t);n||(W(t,l)&&eB(i,"get",t),eB(i,"get",l));let{has:s}=e0(i),o=r?eY:n?tw:tk;return s.call(i,t)?o(e.get(t)):s.call(i,l)?o(e.get(l)):void(e!==i&&e.get(t))}function e2(e,t=!1){let n=this.__v_raw,r=tC(n),i=tC(e);return t||(W(e,i)&&eB(r,"has",e),eB(r,"has",i)),e===i?n.has(e):n.has(e)||n.has(i)}function e3(e,t=!1){return e=e.__v_raw,t||eB(tC(e),"iterate",eV),Reflect.get(e,"size",e)}function e6(e,t=!1){t||tS(e)||t_(e)||(e=tC(e));let n=tC(this);return e0(n).has.call(n,e)||(n.add(e),eU(n,"add",e,e)),this}function e4(e,t,n=!1){n||tS(t)||t_(t)||(t=tC(t));let r=tC(this),{has:i,get:l}=e0(r),s=i.call(r,e);s||(e=tC(e),s=i.call(r,e));let o=l.call(r,e);return r.set(e,t),s?W(t,o)&&eU(r,"set",e,t):eU(r,"add",e,t),this}function e8(e){let t=tC(this),{has:n,get:r}=e0(t),i=n.call(t,e);i||(e=tC(e),i=n.call(t,e)),r&&r.call(t,e);let l=t.delete(e);return i&&eU(t,"delete",e,void 0),l}function e5(){let e=tC(this),t=0!==e.size,n=e.clear();return t&&eU(e,"clear",void 0,void 0),n}function e9(e,t){return function(n,r){let i=this,l=i.__v_raw,s=tC(l),o=t?eY:e?tw:tk;return e||eB(s,"iterate",eV),l.forEach((e,t)=>n.call(r,o(e),o(t),i))}}function e7(e,t,n){return function(...r){let i=this.__v_raw,l=tC(i),s=x(l),o="entries"===e||e===Symbol.iterator&&s,a=i[e](...r),c=n?eY:t?tw:tk;return t||eB(l,"iterate","keys"===e&&s?eD:eV),{next(){let{value:e,done:t}=a.next();return t?{value:e,done:t}:{value:o?[c(e[0]),c(e[1])]:c(e),done:t}},[Symbol.iterator](){return this}}}}function te(e){return function(...t){return"delete"!==e&&("clear"===e?void 0:this)}}let[tt,tn,tr,ti]=function(){let e={get(e){return e1(this,e)},get size(){return e3(this)},has:e2,add:e6,set:e4,delete:e8,clear:e5,forEach:e9(!1,!1)},t={get(e){return e1(this,e,!1,!0)},get size(){return e3(this)},has:e2,add(e){return e6.call(this,e,!0)},set(e,t){return e4.call(this,e,t,!0)},delete:e8,clear:e5,forEach:e9(!1,!0)},n={get(e){return e1(this,e,!0)},get size(){return e3(this,!0)},has(e){return e2.call(this,e,!0)},add:te("add"),set:te("set"),delete:te("delete"),clear:te("clear"),forEach:e9(!0,!1)},r={get(e){return e1(this,e,!0,!0)},get size(){return e3(this,!0)},has(e){return e2.call(this,e,!0)},add:te("add"),set:te("set"),delete:te("delete"),clear:te("clear"),forEach:e9(!0,!0)};return["keys","values","entries",Symbol.iterator].forEach(i=>{e[i]=e7(i,!1,!1),n[i]=e7(i,!0,!1),t[i]=e7(i,!1,!0),r[i]=e7(i,!0,!0)}),[e,n,t,r]}();function tl(e,t){let n=t?e?ti:tr:e?tn:tt;return(t,r,i)=>"__v_isReactive"===r?!e:"__v_isReadonly"===r?e:"__v_raw"===r?t:Reflect.get(_(n,r)&&r in t?n:t,r,i)}let ts={get:tl(!1,!1)},to={get:tl(!1,!0)},ta={get:tl(!0,!1)},tc={get:tl(!0,!0)},tu=new WeakMap,td=new WeakMap,tp=new WeakMap,th=new WeakMap;function tf(e){return t_(e)?e:tv(e,!1,eJ,ts,tu)}function tm(e){return tv(e,!1,eQ,to,td)}function tg(e){return tv(e,!0,eX,ta,tp)}function ty(e){return tv(e,!0,eZ,tc,th)}function tv(e,t,n,r,i){if(!N(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;let l=i.get(e);if(l)return l;let s=e.__v_skip||!Object.isExtensible(e)?0:function(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}(M(e));if(0===s)return e;let o=new Proxy(e,2===s?r:n);return i.set(e,o),o}function tb(e){return t_(e)?tb(e.__v_raw):!!(e&&e.__v_isReactive)}function t_(e){return!!(e&&e.__v_isReadonly)}function tS(e){return!!(e&&e.__v_isShallow)}function tx(e){return!!e&&!!e.__v_raw}function tC(e){let t=e&&e.__v_raw;return t?tC(t):e}function tT(e){return Object.isExtensible(e)&&z(e,"__v_skip",!0),e}let tk=e=>N(e)?tf(e):e,tw=e=>N(e)?tg(e):e;class tE{constructor(e,t,n,r){this.getter=e,this._setter=t,this.dep=void 0,this.__v_isRef=!0,this.__v_isReadonly=!1,this.effect=new eS(()=>e(this._value),()=>tN(this,2===this.effect._dirtyLevel?2:3)),this.effect.computed=this,this.effect.active=this._cacheable=!r,this.__v_isReadonly=n}get value(){let e=tC(this);return(!e._cacheable||e.effect.dirty)&&W(e._value,e._value=e.effect.run())&&tN(e,4),tA(e),e.effect._dirtyLevel>=2&&tN(e,2),e._value}set value(e){this._setter(e)}get _dirty(){return this.effect.dirty}set _dirty(e){this.effect.dirty=e}}function tA(e){var t;eE&&n&&(e=tC(e),eM(n,null!=(t=e.dep)?t:e.dep=e$(()=>e.dep=void 0,e instanceof tE?e:void 0)))}function tN(e,t=4,n,r){let i=(e=tC(e)).dep;i&&eP(i,t)}function tI(e){return!!(e&&!0===e.__v_isRef)}function tR(e){return tM(e,!1)}function tO(e){return tM(e,!0)}function tM(e,t){return tI(e)?e:new tL(e,t)}class tL{constructor(e,t){this.__v_isShallow=t,this.dep=void 0,this.__v_isRef=!0,this._rawValue=t?e:tC(e),this._value=t?e:tk(e)}get value(){return tA(this),this._value}set value(e){let t=this.__v_isShallow||tS(e)||t_(e);W(e=t?e:tC(e),this._rawValue)&&(this._rawValue,this._rawValue=e,this._value=t?e:tk(e),tN(this,4))}}function tP(e){tN(e,4)}function t$(e){return tI(e)?e.value:e}function tF(e){return w(e)?e():t$(e)}let tV={get:(e,t,n)=>t$(Reflect.get(e,t,n)),set:(e,t,n,r)=>{let i=e[t];return tI(i)&&!tI(n)?(i.value=n,!0):Reflect.set(e,t,n,r)}};function tD(e){return tb(e)?e:new Proxy(e,tV)}class tB{constructor(e){this.dep=void 0,this.__v_isRef=!0;let{get:t,set:n}=e(()=>tA(this),()=>tN(this));this._get=t,this._set=n}get value(){return this._get()}set value(e){this._set(e)}}function tU(e){return new tB(e)}function tj(e){let t=S(e)?Array(e.length):{};for(let n in e)t[n]=tK(e,n);return t}class tH{constructor(e,t,n){this._object=e,this._key=t,this._defaultValue=n,this.__v_isRef=!0}get value(){let e=this._object[this._key];return void 0===e?this._defaultValue:e}set value(e){this._object[this._key]=e}get dep(){return function(e,t){let n=eF.get(e);return n&&n.get(t)}(tC(this._object),this._key)}}class tq{constructor(e){this._getter=e,this.__v_isRef=!0,this.__v_isReadonly=!0}get value(){return this._getter()}}function tW(e,t,n){return tI(e)?e:w(e)?new tq(e):N(e)&&arguments.length>1?tK(e,t,n):tR(e)}function tK(e,t,n){let r=e[t];return tI(r)?r:new tH(e,t,n)}let tz={GET:"get",HAS:"has",ITERATE:"iterate"},tG={SET:"set",ADD:"add",DELETE:"delete",CLEAR:"clear"};function tJ(e,t){}let tX={SETUP_FUNCTION:0,0:"SETUP_FUNCTION",RENDER_FUNCTION:1,1:"RENDER_FUNCTION",WATCH_GETTER:2,2:"WATCH_GETTER",WATCH_CALLBACK:3,3:"WATCH_CALLBACK",WATCH_CLEANUP:4,4:"WATCH_CLEANUP",NATIVE_EVENT_HANDLER:5,5:"NATIVE_EVENT_HANDLER",COMPONENT_EVENT_HANDLER:6,6:"COMPONENT_EVENT_HANDLER",VNODE_HOOK:7,7:"VNODE_HOOK",DIRECTIVE_HOOK:8,8:"DIRECTIVE_HOOK",TRANSITION_HOOK:9,9:"TRANSITION_HOOK",APP_ERROR_HANDLER:10,10:"APP_ERROR_HANDLER",APP_WARN_HANDLER:11,11:"APP_WARN_HANDLER",FUNCTION_REF:12,12:"FUNCTION_REF",ASYNC_COMPONENT_LOADER:13,13:"ASYNC_COMPONENT_LOADER",SCHEDULER:14,14:"SCHEDULER",COMPONENT_UPDATE:15,15:"COMPONENT_UPDATE"};function tQ(e,t,n,r){try{return r?e(...r):e()}catch(e){tY(e,t,n)}}function tZ(e,t,n,r){if(w(e)){let i=tQ(e,t,n,r);return i&&I(i)&&i.catch(e=>{tY(e,t,n)}),i}if(S(e)){let i=[];for(let l=0;l>>1,i=t2[r],l=nl(i);lnl(e)-nl(t));if(t6.length=0,t4){t4.push(...e);return}for(t8=0,t4=e;t8null==e.id?1/0:e.id,ns=(e,t)=>{let n=nl(e)-nl(t);if(0===n){if(e.pre&&!t.pre)return -1;if(t.pre&&!e.pre)return 1}return n},no=null,na=null;function nc(e){let t=no;return no=e,na=e&&e.type.__scopeId||null,t}function nu(e){na=e}function nd(){na=null}let np=e=>nh;function nh(e,t=no,n){if(!t||e._n)return e;let r=(...n)=>{let i;r._d&&iY(-1);let l=nc(t);try{i=e(...n)}finally{nc(l),r._d&&iY(1)}return i};return r._n=!0,r._c=!0,r._d=!0,r}function nf(e,t){if(null===no)return e;let n=lT(no),r=e.dirs||(e.dirs=[]);for(let e=0;e{e.isMounted=!0}),nz(()=>{e.isUnmounting=!0}),e}let nb=[Function,Array],n_={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:nb,onEnter:nb,onAfterEnter:nb,onEnterCancelled:nb,onBeforeLeave:nb,onLeave:nb,onAfterLeave:nb,onLeaveCancelled:nb,onBeforeAppear:nb,onAppear:nb,onAfterAppear:nb,onAppearCancelled:nb},nS=e=>{let t=e.subTree;return t.component?nS(t.component):t},nx={name:"BaseTransition",props:n_,setup(e,{slots:t}){let n=lh(),r=nv();return()=>{let i=t.default&&nA(t.default(),!0);if(!i||!i.length)return;let l=i[0];if(i.length>1){for(let e of i)if(e.type!==iK){l=e;break}}let s=tC(e),{mode:o}=s;if(r.isLeaving)return nk(l);let a=nw(l);if(!a)return nk(l);let c=nT(a,s,r,n,e=>c=e);nE(a,c);let u=n.subTree,d=u&&nw(u);if(d&&d.type!==iK&&!i6(a,d)&&nS(n).type!==iK){let e=nT(d,s,r,n);if(nE(d,e),"out-in"===o&&a.type!==iK)return r.isLeaving=!0,e.afterLeave=()=>{r.isLeaving=!1,!1!==n.update.active&&(n.effect.dirty=!0,n.update())},nk(l);"in-out"===o&&a.type!==iK&&(e.delayLeave=(e,t,n)=>{nC(r,d)[String(d.key)]=d,e[ng]=()=>{t(),e[ng]=void 0,delete c.delayedLeave},c.delayedLeave=n})}return l}}};function nC(e,t){let{leavingVNodes:n}=e,r=n.get(t.type);return r||(r=Object.create(null),n.set(t.type,r)),r}function nT(e,t,n,r,i){let{appear:l,mode:s,persisted:o=!1,onBeforeEnter:a,onEnter:c,onAfterEnter:u,onEnterCancelled:d,onBeforeLeave:p,onLeave:h,onAfterLeave:f,onLeaveCancelled:m,onBeforeAppear:g,onAppear:y,onAfterAppear:b,onAppearCancelled:_}=t,x=String(e.key),C=nC(n,e),T=(e,t)=>{e&&tZ(e,r,9,t)},k=(e,t)=>{let n=t[1];T(e,t),S(e)?e.every(e=>e.length<=1)&&n():e.length<=1&&n()},w={mode:s,persisted:o,beforeEnter(t){let r=a;if(!n.isMounted){if(!l)return;r=g||a}t[ng]&&t[ng](!0);let i=C[x];i&&i6(e,i)&&i.el[ng]&&i.el[ng](),T(r,[t])},enter(e){let t=c,r=u,i=d;if(!n.isMounted){if(!l)return;t=y||c,r=b||u,i=_||d}let s=!1,o=e[ny]=t=>{s||(s=!0,t?T(i,[e]):T(r,[e]),w.delayedLeave&&w.delayedLeave(),e[ny]=void 0)};t?k(t,[e,o]):o()},leave(t,r){let i=String(e.key);if(t[ny]&&t[ny](!0),n.isUnmounting)return r();T(p,[t]);let l=!1,s=t[ng]=n=>{l||(l=!0,r(),n?T(m,[t]):T(f,[t]),t[ng]=void 0,C[i]!==e||delete C[i])};C[i]=e,h?k(h,[t,s]):s()},clone(e){let l=nT(e,t,n,r,i);return i&&i(l),l}};return w}function nk(e){if(nM(e))return(e=lt(e)).children=null,e}function nw(e){if(!nM(e))return e;let{shapeFlag:t,children:n}=e;if(n){if(16&t)return n[0];if(32&t&&w(n.default))return n.default()}}function nE(e,t){6&e.shapeFlag&&e.component?nE(e.component.subTree,t):128&e.shapeFlag?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function nA(e,t=!1,n){let r=[],i=0;for(let l=0;l1)for(let e=0;e!!e.type.__asyncLoader;/*! #__NO_SIDE_EFFECTS__ */function nR(e){let t;w(e)&&(e={loader:e});let{loader:n,loadingComponent:r,errorComponent:i,delay:l=200,timeout:s,suspensible:o=!0,onError:a}=e,c=null,u=0,d=()=>(u++,c=null,p()),p=()=>{let e;return c||(e=c=n().catch(e=>{if(e=e instanceof Error?e:Error(String(e)),a)return new Promise((t,n)=>{a(e,()=>t(d()),()=>n(e),u+1)});throw e}).then(n=>e!==c&&c?c:(n&&(n.__esModule||"Module"===n[Symbol.toStringTag])&&(n=n.default),t=n,n)))};return nN({name:"AsyncComponentWrapper",__asyncLoader:p,get __asyncResolved(){return t},setup(){let e=lp;if(t)return()=>nO(t,e);let n=t=>{c=null,tY(t,e,13,!i)};if(o&&e.suspense)return p().then(t=>()=>nO(t,e)).catch(e=>(n(e),()=>i?i7(i,{error:e}):null));let a=tR(!1),u=tR(),d=tR(!!l);return l&&setTimeout(()=>{d.value=!1},l),null!=s&&setTimeout(()=>{if(!a.value&&!u.value){let e=Error(`Async component timed out after ${s}ms.`);n(e),u.value=e}},s),p().then(()=>{a.value=!0,e.parent&&nM(e.parent.vnode)&&(e.parent.effect.dirty=!0,ne(e.parent.update))}).catch(e=>{n(e),u.value=e}),()=>a.value&&t?nO(t,e):u.value&&i?i7(i,{error:u.value}):r&&!d.value?i7(r):void 0}})}function nO(e,t){let{ref:n,props:r,children:i,ce:l}=t.vnode,s=i7(e,r,i);return s.ref=n,s.ce=l,delete t.vnode.ce,s}let nM=e=>e.type.__isKeepAlive,nL={name:"KeepAlive",__isKeepAlive:!0,props:{include:[String,RegExp,Array],exclude:[String,RegExp,Array],max:[String,Number]},setup(e,{slots:t}){let n=lh(),r=n.ctx,i=new Map,l=new Set,s=null,o=n.suspense,{renderer:{p:a,m:c,um:u,o:{createElement:d}}}=r,p=d("div");function h(e){nD(e),u(e,n,o,!0)}function f(e){i.forEach((t,n)=>{let r=lk(t.type);!r||e&&e(r)||m(n)})}function m(e){let t=i.get(e);!t||s&&i6(t,s)?s&&nD(s):h(t),i.delete(e),l.delete(e)}r.activate=(e,t,n,r,i)=>{let l=e.component;c(e,t,n,0,o),a(l.vnode,e,t,n,l,o,r,e.slotScopeIds,i),is(()=>{l.isDeactivated=!1,l.a&&K(l.a);let t=e.props&&e.props.onVnodeMounted;t&&lc(t,l.parent,e)},o)},r.deactivate=e=>{let t=e.component;im(t.m),im(t.a),c(e,p,null,1,o),is(()=>{t.da&&K(t.da);let n=e.props&&e.props.onVnodeUnmounted;n&&lc(n,t.parent,e),t.isDeactivated=!0},o)},ix(()=>[e.include,e.exclude],([e,t])=>{e&&f(t=>nP(e,t)),t&&f(e=>!nP(t,e))},{flush:"post",deep:!0});let g=null,y=()=>{null!=g&&(i$(n.subTree.type)?is(()=>{i.set(g,nB(n.subTree))},n.subTree.suspense):i.set(g,nB(n.subTree)))};return nq(y),nK(y),nz(()=>{i.forEach(e=>{let{subTree:t,suspense:r}=n,i=nB(t);if(e.type===i.type&&e.key===i.key){nD(i);let e=i.component.da;e&&is(e,r);return}h(e)})}),()=>{if(g=null,!t.default)return null;let n=t.default(),r=n[0];if(n.length>1)return s=null,n;if(!i3(r)||!(4&r.shapeFlag)&&!(128&r.shapeFlag))return s=null,r;let o=nB(r);if(o.type===iK)return s=null,o;let a=o.type,c=lk(nI(o)?o.type.__asyncResolved||{}:a),{include:u,exclude:d,max:p}=e;if(u&&(!c||!nP(u,c))||d&&c&&nP(d,c))return s=o,r;let h=null==o.key?a:o.key,f=i.get(h);return o.el&&(o=lt(o),128&r.shapeFlag&&(r.ssContent=o)),g=h,f?(o.el=f.el,o.component=f.component,o.transition&&nE(o,o.transition),o.shapeFlag|=512,l.delete(h),l.add(h)):(l.add(h),p&&l.size>parseInt(p,10)&&m(l.values().next().value)),o.shapeFlag|=256,s=o,i$(r.type)?r:o}}};function nP(e,t){return S(e)?e.some(e=>nP(e,t)):E(e)?e.split(",").includes(t):!!k(e)&&e.test(t)}function n$(e,t){nV(e,"a",t)}function nF(e,t){nV(e,"da",t)}function nV(e,t,n=lp){let r=e.__wdc||(e.__wdc=()=>{let t=n;for(;t;){if(t.isDeactivated)return;t=t.parent}return e()});if(nU(t,r,n),n){let e=n.parent;for(;e&&e.parent;)nM(e.parent.vnode)&&function(e,t,n,r){let i=nU(t,e,r,!0);nG(()=>{y(r[t],i)},n)}(r,t,n,e),e=e.parent}}function nD(e){e.shapeFlag&=-257,e.shapeFlag&=-513}function nB(e){return 128&e.shapeFlag?e.ssContent:e}function nU(e,t,n=lp,r=!1){if(n){let i=n[e]||(n[e]=[]),l=t.__weh||(t.__weh=(...r)=>{eI();let i=lf(n),l=tZ(t,n,e,r);return i(),eR(),l});return r?i.unshift(l):i.push(l),l}}let nj=e=>(t,n=lp)=>{ly&&"sp"!==e||nU(e,(...e)=>t(...e),n)},nH=nj("bm"),nq=nj("m"),nW=nj("bu"),nK=nj("u"),nz=nj("bum"),nG=nj("um"),nJ=nj("sp"),nX=nj("rtg"),nQ=nj("rtc");function nZ(e,t=lp){nU("ec",e,t)}let nY="components";function n0(e,t){return n6(nY,e,!0,t)||e}let n1=Symbol.for("v-ndc");function n2(e){return E(e)?n6(nY,e,!1)||e:e||n1}function n3(e){return n6("directives",e)}function n6(e,t,n=!0,r=!1){let i=no||lp;if(i){let n=i.type;if(e===nY){let e=lk(n,!1);if(e&&(e===t||e===B(t)||e===H(B(t))))return n}let l=n4(i[e]||n[e],t)||n4(i.appContext[e],t);return!l&&r?n:l}}function n4(e,t){return e&&(e[t]||e[B(t)]||e[H(B(t))])}function n8(e,t,n,r){let i;let l=n&&n[r];if(S(e)||E(e)){i=Array(e.length);for(let n=0,r=e.length;nt(e,n,void 0,l&&l[n]));else{let n=Object.keys(e);i=Array(n.length);for(let r=0,s=n.length;r{let t=r.fn(...e);return t&&(t.key=r.key),t}:r.fn)}return e}function n9(e,t,n={},r,i){if(no.isCE||no.parent&&nI(no.parent)&&no.parent.isCE)return"default"!==t&&(n.name=t),i7("slot",n,r&&r());let l=e[t];l&&l._c&&(l._d=!1),iX();let s=l&&function e(t){return t.some(t=>!i3(t)||!!(t.type!==iK&&(t.type!==iq||e(t.children))))?t:null}(l(n)),o=i2(iq,{key:(n.key||s&&s.key||`_${t}`)+(!s&&r?"_fb":"")},s||(r?r():[]),s&&1===e._?64:-2);return!i&&o.scopeId&&(o.slotScopeIds=[o.scopeId+"-s"]),l&&l._c&&(l._d=!0),o}function n7(e,t){let n={};for(let r in e)n[t&&/[A-Z]/.test(r)?`on:${r}`:q(r)]=e[r];return n}let re=e=>e?lg(e)?lT(e):re(e.parent):null,rt=g(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>re(e.parent),$root:e=>re(e.root),$emit:e=>e.emit,$options:e=>rx(e),$forceUpdate:e=>e.f||(e.f=()=>{e.effect.dirty=!0,ne(e.update)}),$nextTick:e=>e.n||(e.n=t7.bind(e.proxy)),$watch:e=>iT.bind(e)}),rn=(e,t)=>e!==u&&!e.__isScriptSetup&&_(e,t),rr={get({_:e},t){let n,r,i;if("__v_skip"===t)return!0;let{ctx:l,setupState:s,data:o,props:a,accessCache:c,type:d,appContext:p}=e;if("$"!==t[0]){let r=c[t];if(void 0!==r)switch(r){case 1:return s[t];case 2:return o[t];case 4:return l[t];case 3:return a[t]}else{if(rn(s,t))return c[t]=1,s[t];if(o!==u&&_(o,t))return c[t]=2,o[t];if((n=e.propsOptions[0])&&_(n,t))return c[t]=3,a[t];if(l!==u&&_(l,t))return c[t]=4,l[t];r_&&(c[t]=0)}}let h=rt[t];return h?("$attrs"===t&&eB(e.attrs,"get",""),h(e)):(r=d.__cssModules)&&(r=r[t])?r:l!==u&&_(l,t)?(c[t]=4,l[t]):_(i=p.config.globalProperties,t)?i[t]:void 0},set({_:e},t,n){let{data:r,setupState:i,ctx:l}=e;return rn(i,t)?(i[t]=n,!0):r!==u&&_(r,t)?(r[t]=n,!0):!_(e.props,t)&&!("$"===t[0]&&t.slice(1) in e)&&(l[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:r,appContext:i,propsOptions:l}},s){let o;return!!n[s]||e!==u&&_(e,s)||rn(t,s)||(o=l[0])&&_(o,s)||_(r,s)||_(rt,s)||_(i.config.globalProperties,s)},defineProperty(e,t,n){return null!=n.get?e._.accessCache[t]=0:_(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}},ri=g({},rr,{get(e,t){if(t!==Symbol.unscopables)return rr.get(e,t,e)},has:(e,t)=>"_"!==t[0]&&!Q(t)});function rl(){return null}function rs(){return null}function ro(e){}function ra(e){}function rc(){return null}function ru(){}function rd(e,t){return null}function rp(){return rf().slots}function rh(){return rf().attrs}function rf(){let e=lh();return e.setupContext||(e.setupContext=lC(e))}function rm(e){return S(e)?e.reduce((e,t)=>(e[t]=null,e),{}):e}function rg(e,t){let n=rm(e);for(let e in t){if(e.startsWith("__skip"))continue;let r=n[e];r?S(r)||w(r)?r=n[e]={type:r,default:t[e]}:r.default=t[e]:null===r&&(r=n[e]={default:t[e]}),r&&t[`__skip_${e}`]&&(r.skipFactory=!0)}return n}function ry(e,t){return e&&t?S(e)&&S(t)?e.concat(t):g({},rm(e),rm(t)):e||t}function rv(e,t){let n={};for(let r in e)t.includes(r)||Object.defineProperty(n,r,{enumerable:!0,get:()=>e[r]});return n}function rb(e){let t=lh(),n=e();return lm(),I(n)&&(n=n.catch(e=>{throw lf(t),e})),[n,()=>lf(t)]}let r_=!0;function rS(e,t,n){tZ(S(e)?e.map(e=>e.bind(t.proxy)):e.bind(t.proxy),t,n)}function rx(e){let t;let n=e.type,{mixins:r,extends:i}=n,{mixins:l,optionsCache:s,config:{optionMergeStrategies:o}}=e.appContext,a=s.get(n);return a?t=a:l.length||r||i?(t={},l.length&&l.forEach(e=>rC(t,e,o,!0)),rC(t,n,o)):t=n,N(n)&&s.set(n,t),t}function rC(e,t,n,r=!1){let{mixins:i,extends:l}=t;for(let s in l&&rC(e,l,n,!0),i&&i.forEach(t=>rC(e,t,n,!0)),t)if(r&&"expose"===s);else{let r=rT[s]||n&&n[s];e[s]=r?r(e[s],t[s]):t[s]}return e}let rT={data:rk,props:rN,emits:rN,methods:rA,computed:rA,beforeCreate:rE,created:rE,beforeMount:rE,mounted:rE,beforeUpdate:rE,updated:rE,beforeDestroy:rE,beforeUnmount:rE,destroyed:rE,unmounted:rE,activated:rE,deactivated:rE,errorCaptured:rE,serverPrefetch:rE,components:rA,directives:rA,watch:function(e,t){if(!e)return t;if(!t)return e;let n=g(Object.create(null),e);for(let r in t)n[r]=rE(e[r],t[r]);return n},provide:rk,inject:function(e,t){return rA(rw(e),rw(t))}};function rk(e,t){return t?e?function(){return g(w(e)?e.call(this,this):e,w(t)?t.call(this,this):t)}:t:e}function rw(e){if(S(e)){let t={};for(let n=0;n1)return n&&w(t)?t.call(r&&r.proxy):t}}function rP(){return!!(lp||no||rO)}let r$={},rF=()=>Object.create(r$),rV=e=>Object.getPrototypeOf(e)===r$;function rD(e,t,n,r){let i;let[l,s]=e.propsOptions,o=!1;if(t)for(let a in t){let c;if($(a))continue;let u=t[a];l&&_(l,c=B(a))?s&&s.includes(c)?(i||(i={}))[c]=u:n[c]=u:iI(e.emitsOptions,a)||a in r&&u===r[a]||(r[a]=u,o=!0)}if(s){let t=tC(n),r=i||u;for(let i=0;i"_"===e[0]||"$stable"===e,rq=e=>S(e)?e.map(ll):[ll(e)],rW=(e,t,n)=>{if(t._n)return t;let r=nh((...e)=>rq(t(...e)),n);return r._c=!1,r},rK=(e,t,n)=>{let r=e._ctx;for(let n in e){if(rH(n))continue;let i=e[n];if(w(i))t[n]=rW(n,i,r);else if(null!=i){let e=rq(i);t[n]=()=>e}}},rz=(e,t)=>{let n=rq(t);e.slots.default=()=>n},rG=(e,t,n)=>{for(let r in t)(n||"_"!==r)&&(e[r]=t[r])},rJ=(e,t,n)=>{let r=e.slots=rF();if(32&e.vnode.shapeFlag){let e=t._;e?(rG(r,t,n),n&&z(r,"_",e,!0)):rK(t,r)}else t&&rz(e,t)},rX=(e,t,n)=>{let{vnode:r,slots:i}=e,l=!0,s=u;if(32&r.shapeFlag){let e=t._;e?n&&1===e?l=!1:rG(i,t,n):(l=!t.$stable,rK(t,i)),s=t}else t&&(rz(e,t),s={default:1});if(l)for(let e in i)rH(e)||null!=s[e]||delete i[e]};function rQ(e,t,n,r,i=!1){if(S(e)){e.forEach((e,l)=>rQ(e,t&&(S(t)?t[l]:t),n,r,i));return}if(nI(r)&&!i)return;let l=4&r.shapeFlag?lT(r.component):r.el,s=i?null:l,{i:o,r:a}=e,c=t&&t.r,d=o.refs===u?o.refs={}:o.refs,p=o.setupState;if(null!=c&&c!==a&&(E(c)?(d[c]=null,_(p,c)&&(p[c]=null)):tI(c)&&(c.value=null)),w(a))tQ(a,o,12,[s,d]);else{let t=E(a),r=tI(a);if(t||r){let o=()=>{if(e.f){let n=t?_(p,a)?p[a]:d[a]:a.value;i?S(n)&&y(n,l):S(n)?n.includes(l)||n.push(l):t?(d[a]=[l],_(p,a)&&(p[a]=d[a])):(a.value=[l],e.k&&(d[e.k]=a.value))}else t?(d[a]=s,_(p,a)&&(p[a]=s)):r&&(a.value=s,e.k&&(d[e.k]=s))};s?(o.id=-1,is(o,n)):o()}}}let rZ=Symbol("_vte"),rY=e=>e.__isTeleport,r0=e=>e&&(e.disabled||""===e.disabled),r1=e=>"undefined"!=typeof SVGElement&&e instanceof SVGElement,r2=e=>"function"==typeof MathMLElement&&e instanceof MathMLElement,r3=(e,t)=>{let n=e&&e.to;return E(n)?t?t(n):null:n};function r6(e,t,n,{o:{insert:r},m:i},l=2){0===l&&r(e.targetAnchor,t,n);let{el:s,anchor:o,shapeFlag:a,children:c,props:u}=e,d=2===l;if(d&&r(s,t,n),(!d||r0(u))&&16&a)for(let e=0;e{16&b&&u(_,e,t,i,l,s,o,a)};y?g(n,c):d&&g(d,p)}else{t.el=e.el,t.targetStart=e.targetStart;let r=t.anchor=e.anchor,u=t.target=e.target,h=t.targetAnchor=e.targetAnchor,m=r0(e.props),g=m?n:u;if("svg"===s||r1(u)?s="svg":("mathml"===s||r2(u))&&(s="mathml"),S?(p(e.dynamicChildren,S,g,i,l,s,o),ih(e,t,!0)):a||d(e,t,g,m?r:h,i,l,s,o,!1),y)m?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):r6(t,n,r,c,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){let e=t.target=r3(t.props,f);e&&r6(t,e,null,c,0)}else m&&r6(t,u,h,c,1)}r8(t)},remove(e,t,n,{um:r,o:{remove:i}},l){let{shapeFlag:s,children:o,anchor:a,targetStart:c,targetAnchor:u,target:d,props:p}=e;if(d&&(i(c),i(u)),l&&i(a),16&s){let e=l||!r0(p);for(let i=0;i{r9||(console.error("Hydration completed but contains mismatches."),r9=!0)},ie=e=>e.namespaceURI.includes("svg")&&"foreignObject"!==e.tagName,it=e=>e.namespaceURI.includes("MathML"),ir=e=>ie(e)?"svg":it(e)?"mathml":void 0,ii=e=>8===e.nodeType;function il(e){let{mt:t,p:n,o:{patchProp:r,createText:i,nextSibling:l,parentNode:s,remove:o,insert:a,createComment:c}}=e,u=(n,r,o,c,f,_=!1)=>{_=_||!!r.dynamicChildren;let S=ii(n)&&"["===n.data,x=()=>m(n,r,o,c,f,S),{type:C,ref:T,shapeFlag:k,patchFlag:w}=r,E=n.nodeType;r.el=n,-2===w&&(_=!1,r.dynamicChildren=null);let A=null;switch(C){case iW:3!==E?""===r.children?(a(r.el=i(""),s(n),n),A=n):A=x():(n.data!==r.children&&(r7(),n.data=r.children),A=l(n));break;case iK:b(n)?(A=l(n),y(r.el=n.content.firstChild,n,o)):A=8!==E||S?x():l(n);break;case iz:if(S&&(E=(n=l(n)).nodeType),1===E||3===E){A=n;let e=!r.children.length;for(let t=0;t{s=s||!!t.dynamicChildren;let{type:a,props:c,patchFlag:u,shapeFlag:d,dirs:h,transition:m}=t,g="input"===a||"option"===a;if(g||-1!==u){let a;h&&nm(t,null,n,"created");let _=!1;if(b(e)){_=ip(i,m)&&n&&n.vnode.props&&n.vnode.props.appear;let r=e.content.firstChild;_&&m.beforeEnter(r),y(r,e,n),t.el=e=r}if(16&d&&!(c&&(c.innerHTML||c.textContent))){let r=p(e.firstChild,t,e,n,i,l,s);for(;r;){r7();let e=r;r=r.nextSibling,o(e)}}else 8&d&&e.textContent!==t.children&&(r7(),e.textContent=t.children);if(c){if(g||!s||48&u){let t=e.tagName.includes("-");for(let i in c)(g&&(i.endsWith("value")||"indeterminate"===i)||f(i)&&!$(i)||"."===i[0]||t)&&r(e,i,null,c[i],void 0,n)}else if(c.onClick)r(e,"onClick",null,c.onClick,void 0,n);else if(4&u&&tb(c.style))for(let e in c.style)c.style[e]}(a=c&&c.onVnodeBeforeMount)&&lc(a,n,t),h&&nm(t,null,n,"beforeMount"),((a=c&&c.onVnodeMounted)||h||_)&&ij(()=>{a&&lc(a,n,t),_&&m.enter(e),h&&nm(t,null,n,"mounted")},i)}return e.nextSibling},p=(e,t,r,s,o,c,d)=>{d=d||!!t.dynamicChildren;let p=t.children,h=p.length;for(let t=0;t{let{slotScopeIds:u}=t;u&&(i=i?i.concat(u):u);let d=s(e),h=p(l(e),t,d,n,r,i,o);return h&&ii(h)&&"]"===h.data?l(t.anchor=h):(r7(),a(t.anchor=c("]"),d,h),h)},m=(e,t,r,i,a,c)=>{if(r7(),t.el=null,c){let t=g(e);for(;;){let n=l(e);if(n&&n!==t)o(n);else break}}let u=l(e),d=s(e);return o(e),n(null,t,d,u,r,i,ir(d),a),u},g=(e,t="[",n="]")=>{let r=0;for(;e;)if((e=l(e))&&ii(e)&&(e.data===t&&r++,e.data===n)){if(0===r)return l(e);r--}return e},y=(e,t,n)=>{let r=t.parentNode;r&&r.replaceChild(e,t);let i=n;for(;i;)i.vnode.el===t&&(i.vnode.el=i.subTree.el=e),i=i.parent},b=e=>1===e.nodeType&&"template"===e.tagName.toLowerCase();return[(e,t)=>{if(!t.hasChildNodes()){n(null,e,t),ni(),t._vnode=e;return}u(t.firstChild,e,null,null,null),ni(),t._vnode=e},u]}let is=ij;function io(e){return ic(e)}function ia(e){return ic(e,il)}function ic(e,t){var n;let r,l;X().__VUE__=!0;let{insert:s,remove:o,patchProp:a,createElement:c,createText:h,createComment:f,setText:m,setElementText:y,parentNode:b,nextSibling:x,setScopeId:C=p,insertStaticContent:T}=e,k=(e,t,n,r=null,i=null,l=null,s,o=null,a=!!t.dynamicChildren)=>{if(e===t)return;e&&!i6(e,t)&&(r=eo(e),en(e,i,l,!0),e=null),-2===t.patchFlag&&(a=!1,t.dynamicChildren=null);let{type:c,ref:u,shapeFlag:d}=t;switch(c){case iW:E(e,t,n,r);break;case iK:A(e,t,n,r);break;case iz:null==e&&R(t,n,r,s);break;case iq:q(e,t,n,r,i,l,s,o,a);break;default:1&d?L(e,t,n,r,i,l,s,o,a):6&d?W(e,t,n,r,i,l,s,o,a):64&d?c.process(e,t,n,r,i,l,s,o,a,eu):128&d&&c.process(e,t,n,r,i,l,s,o,a,eu)}null!=u&&i&&rQ(u,e&&e.ref,l,t||e,!t)},E=(e,t,n,r)=>{if(null==e)s(t.el=h(t.children),n,r);else{let n=t.el=e.el;t.children!==e.children&&m(n,t.children)}},A=(e,t,n,r)=>{null==e?s(t.el=f(t.children||""),n,r):t.el=e.el},R=(e,t,n,r)=>{[e.el,e.anchor]=T(e.children,t,n,r,e.el,e.anchor)},O=({el:e,anchor:t},n,r)=>{let i;for(;e&&e!==t;)i=x(e),s(e,n,r),e=i;s(t,n,r)},M=({el:e,anchor:t})=>{let n;for(;e&&e!==t;)n=x(e),o(e),e=n;o(t)},L=(e,t,n,r,i,l,s,o,a)=>{"svg"===t.type?s="svg":"math"===t.type&&(s="mathml"),null==e?P(t,n,r,i,l,s,o,a):D(e,t,i,l,s,o,a)},P=(e,t,n,r,i,l,o,u)=>{let d,p;let{props:h,shapeFlag:f,transition:m,dirs:g}=e;if(d=e.el=c(e.type,l,h&&h.is,h),8&f?y(d,e.children):16&f&&V(e.children,d,null,r,i,iu(e,l),o,u),g&&nm(e,null,r,"created"),F(d,e,e.scopeId,o,r),h){for(let e in h)"value"===e||$(e)||a(d,e,null,h[e],l,r);"value"in h&&a(d,"value",null,h.value,l),(p=h.onVnodeBeforeMount)&&lc(p,r,e)}g&&nm(e,null,r,"beforeMount");let b=ip(i,m);b&&m.beforeEnter(d),s(d,t,n),((p=h&&h.onVnodeMounted)||b||g)&&is(()=>{p&&lc(p,r,e),b&&m.enter(d),g&&nm(e,null,r,"mounted")},i)},F=(e,t,n,r,i)=>{if(n&&C(e,n),r)for(let t=0;t{for(let c=a;c{let o;let c=t.el=e.el,{patchFlag:d,dynamicChildren:p,dirs:h}=t;d|=16&e.patchFlag;let f=e.props||u,m=t.props||u;if(n&&id(n,!1),(o=m.onVnodeBeforeUpdate)&&lc(o,n,t,e),h&&nm(t,e,n,"beforeUpdate"),n&&id(n,!0),(f.innerHTML&&null==m.innerHTML||f.textContent&&null==m.textContent)&&y(c,""),p?U(e.dynamicChildren,p,c,n,r,iu(t,i),l):s||Z(e,t,c,null,n,r,iu(t,i),l,!1),d>0){if(16&d)H(c,f,m,n,i);else if(2&d&&f.class!==m.class&&a(c,"class",null,m.class,i),4&d&&a(c,"style",f.style,m.style,i),8&d){let e=t.dynamicProps;for(let t=0;t{o&&lc(o,n,t,e),h&&nm(t,e,n,"updated")},r)},U=(e,t,n,r,i,l,s)=>{for(let o=0;o{if(t!==n){if(t!==u)for(let l in t)$(l)||l in n||a(e,l,t[l],null,i,r);for(let l in n){if($(l))continue;let s=n[l],o=t[l];s!==o&&"value"!==l&&a(e,l,o,s,i,r)}"value"in n&&a(e,"value",t.value,n.value,i)}},q=(e,t,n,r,i,l,o,a,c)=>{let u=t.el=e?e.el:h(""),d=t.anchor=e?e.anchor:h(""),{patchFlag:p,dynamicChildren:f,slotScopeIds:m}=t;m&&(a=a?a.concat(m):m),null==e?(s(u,n,r),s(d,n,r),V(t.children||[],n,d,i,l,o,a,c)):p>0&&64&p&&f&&e.dynamicChildren?(U(e.dynamicChildren,f,n,i,l,o,a),(null!=t.key||i&&t===i.subTree)&&ih(e,t,!0)):Z(e,t,n,d,i,l,o,a,c)},W=(e,t,n,r,i,l,s,o,a)=>{t.slotScopeIds=o,null==e?512&t.shapeFlag?i.ctx.activate(t,n,r,s,a):z(t,n,r,i,l,s,a):G(e,t,a)},z=(e,t,n,r,l,s,o)=>{let a=e.component=function(e,t,n){let r=e.type,i=(t?t.appContext:e.appContext)||lu,l={uid:ld++,vnode:e,type:r,parent:t,appContext:i,root:null,next:null,subTree:null,effect:null,update:null,scope:new eg(!0),render:null,proxy:null,exposed:null,exposeProxy:null,withProxy:null,provides:t?t.provides:Object.create(i.provides),accessCache:null,renderCache:[],components:null,directives:null,propsOptions:function e(t,n,r=!1){let i=r?rU:n.propsCache,l=i.get(t);if(l)return l;let s=t.props,o={},a=[],c=!1;if(!w(t)){let i=t=>{c=!0;let[r,i]=e(t,n,!0);g(o,r),i&&a.push(...i)};!r&&n.mixins.length&&n.mixins.forEach(i),t.extends&&i(t.extends),t.mixins&&t.mixins.forEach(i)}if(!s&&!c)return N(t)&&i.set(t,d),d;if(S(s))for(let e=0;e{let r=e(t,n,!0);r&&(a=!0,g(o,r))};!r&&n.mixins.length&&n.mixins.forEach(i),t.extends&&i(t.extends),t.mixins&&t.mixins.forEach(i)}return s||a?(S(s)?s.forEach(e=>o[e]=null):g(o,s),N(t)&&i.set(t,o),o):(N(t)&&i.set(t,null),null)}(r,i),emit:null,emitted:null,propsDefaults:u,inheritAttrs:r.inheritAttrs,ctx:u,data:u,props:u,attrs:u,slots:u,refs:u,setupState:u,setupContext:null,suspense:n,suspenseId:n?n.pendingId:0,asyncDep:null,asyncResolved:!1,isMounted:!1,isUnmounted:!1,isDeactivated:!1,bc:null,c:null,bm:null,m:null,bu:null,u:null,um:null,bum:null,da:null,a:null,rtg:null,rtc:null,ec:null,sp:null};return l.ctx={_:l},l.root=t?t.root:l,l.emit=iN.bind(null,l),e.ce&&e.ce(l),l}(e,r,l);nM(e)&&(a.ctx.renderer=eu),function(e,t=!1,n=!1){t&&i(t);let{props:r,children:l}=e.vnode,s=lg(e);(function(e,t,n,r=!1){let i={},l=rF();for(let n in e.propsDefaults=Object.create(null),rD(e,t,i,l),e.propsOptions[0])n in i||(i[n]=void 0);n?e.props=r?i:tm(i):e.type.props?e.props=i:e.props=l,e.attrs=l})(e,r,s,t),rJ(e,l,n),s&&function(e,t){let n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,rr);let{setup:r}=n;if(r){let n=e.setupContext=r.length>1?lC(e):null,i=lf(e);eI();let l=tQ(r,e,0,[e.props,n]);if(eR(),i(),I(l)){if(l.then(lm,lm),t)return l.then(n=>{lv(e,n,t)}).catch(t=>{tY(t,e,0)});e.asyncDep=l}else lv(e,l,t)}else lS(e,t)}(e,t),t&&i(!1)}(a,!1,o),a.asyncDep?(l&&l.registerDep(a,J,o),e.el||A(null,a.subTree=i7(iK),t,n)):J(a,e,t,n,l,s,o)},G=(e,t,n)=>{let r=t.component=e.component;if(function(e,t,n){let{props:r,children:i,component:l}=e,{props:s,children:o,patchFlag:a}=t,c=l.emitsOptions;if(t.dirs||t.transition)return!0;if(!n||!(a>=0))return(!!i||!!o)&&(!o||!o.$stable)||r!==s&&(r?!s||iL(r,s,c):!!s);if(1024&a)return!0;if(16&a)return r?iL(r,s,c):!!s;if(8&a){let e=t.dynamicProps;for(let t=0;tt3&&t2.splice(t,1)}(r.update),r.effect.dirty=!0,r.update()}else t.el=e.el,r.vnode=t},J=(e,t,n,r,i,s,o)=>{let a=()=>{if(e.isMounted){let t,{next:n,bu:r,u:l,parent:c,vnode:u}=e;{let t=function e(t){let n=t.subTree.component;if(n)return n.asyncDep&&!n.asyncResolved?n:e(n)}(e);if(t){n&&(n.el=u.el,Q(e,n,o)),t.asyncDep.then(()=>{e.isUnmounted||a()});return}}let d=n;id(e,!1),n?(n.el=u.el,Q(e,n,o)):n=u,r&&K(r),(t=n.props&&n.props.onVnodeBeforeUpdate)&&lc(t,c,n,u),id(e,!0);let p=iR(e),h=e.subTree;e.subTree=p,k(h,p,b(h.el),eo(h),e,i,s),n.el=p.el,null===d&&iP(e,p.el),l&&is(l,i),(t=n.props&&n.props.onVnodeUpdated)&&is(()=>lc(t,c,n,u),i)}else{let o;let{el:a,props:c}=t,{bm:u,m:d,parent:p}=e,h=nI(t);if(id(e,!1),u&&K(u),!h&&(o=c&&c.onVnodeBeforeMount)&&lc(o,p,t),id(e,!0),a&&l){let n=()=>{e.subTree=iR(e),l(a,e.subTree,e,i,null)};h?t.type.__asyncLoader().then(()=>!e.isUnmounted&&n()):n()}else{let l=e.subTree=iR(e);k(null,l,n,r,e,i,s),t.el=l.el}if(d&&is(d,i),!h&&(o=c&&c.onVnodeMounted)){let e=t;is(()=>lc(o,p,e),i)}(256&t.shapeFlag||p&&nI(p.vnode)&&256&p.vnode.shapeFlag)&&e.a&&is(e.a,i),e.isMounted=!0,t=n=r=null}},c=e.effect=new eS(a,p,()=>ne(u),e.scope),u=e.update=()=>{c.dirty&&c.run()};u.i=e,u.id=e.uid,id(e,!0),u()},Q=(e,t,n)=>{t.component=e;let r=e.vnode.props;e.vnode=t,e.next=null,function(e,t,n,r){let{props:i,attrs:l,vnode:{patchFlag:s}}=e,o=tC(i),[a]=e.propsOptions,c=!1;if((r||s>0)&&!(16&s)){if(8&s){let n=e.vnode.dynamicProps;for(let r=0;r{let c=e&&e.children,u=e?e.shapeFlag:0,d=t.children,{patchFlag:p,shapeFlag:h}=t;if(p>0){if(128&p){ee(c,d,n,r,i,l,s,o,a);return}if(256&p){Y(c,d,n,r,i,l,s,o,a);return}}8&h?(16&u&&es(c,i,l),d!==c&&y(n,d)):16&u?16&h?ee(c,d,n,r,i,l,s,o,a):es(c,i,l,!0):(8&u&&y(n,""),16&h&&V(d,n,r,i,l,s,o,a))},Y=(e,t,n,r,i,l,s,o,a)=>{let c;e=e||d,t=t||d;let u=e.length,p=t.length,h=Math.min(u,p);for(c=0;cp?es(e,i,l,!0,!1,h):V(t,n,r,i,l,s,o,a,h)},ee=(e,t,n,r,i,l,s,o,a)=>{let c=0,u=t.length,p=e.length-1,h=u-1;for(;c<=p&&c<=h;){let r=e[c],u=t[c]=a?ls(t[c]):ll(t[c]);if(i6(r,u))k(r,u,n,null,i,l,s,o,a);else break;c++}for(;c<=p&&c<=h;){let r=e[p],c=t[h]=a?ls(t[h]):ll(t[h]);if(i6(r,c))k(r,c,n,null,i,l,s,o,a);else break;p--,h--}if(c>p){if(c<=h){let e=h+1,d=eh)for(;c<=p;)en(e[c],i,l,!0),c++;else{let f;let m=c,g=c,y=new Map;for(c=g;c<=h;c++){let e=t[c]=a?ls(t[c]):ll(t[c]);null!=e.key&&y.set(e.key,c)}let b=0,_=h-g+1,S=!1,x=0,C=Array(_);for(c=0;c<_;c++)C[c]=0;for(c=m;c<=p;c++){let r;let u=e[c];if(b>=_){en(u,i,l,!0);continue}if(null!=u.key)r=y.get(u.key);else for(f=g;f<=h;f++)if(0===C[f-g]&&i6(u,t[f])){r=f;break}void 0===r?en(u,i,l,!0):(C[r-g]=c+1,r>=x?x=r:S=!0,k(u,t[r],n,null,i,l,s,o,a),b++)}let T=S?function(e){let t,n,r,i,l;let s=e.slice(),o=[0],a=e.length;for(t=0;t>1]]0&&(s[t]=o[r-1]),o[r]=t)}}for(r=o.length,i=o[r-1];r-- >0;)o[r]=i,i=s[i];return o}(C):d;for(f=T.length-1,c=_-1;c>=0;c--){let e=g+c,d=t[e],p=e+1{let{el:l,type:o,transition:a,children:c,shapeFlag:u}=e;if(6&u){et(e.component.subTree,t,n,r);return}if(128&u){e.suspense.move(t,n,r);return}if(64&u){o.move(e,t,n,eu);return}if(o===iq){s(l,t,n);for(let e=0;ea.enter(l),i);else{let{leave:e,delayLeave:r,afterLeave:i}=a,o=()=>s(l,t,n),c=()=>{e(l,()=>{o(),i&&i()})};r?r(l,o,c):c()}}else s(l,t,n)},en=(e,t,n,r=!1,i=!1)=>{let l;let{type:s,props:o,ref:a,children:c,dynamicChildren:u,shapeFlag:d,patchFlag:p,dirs:h,cacheIndex:f}=e;if(-2===p&&(i=!1),null!=a&&rQ(a,null,n,e,!0),null!=f&&(t.renderCache[f]=void 0),256&d){t.ctx.deactivate(e);return}let m=1&d&&h,g=!nI(e);if(g&&(l=o&&o.onVnodeBeforeUnmount)&&lc(l,t,e),6&d)el(e.component,n,r);else{if(128&d){e.suspense.unmount(n,r);return}m&&nm(e,null,t,"beforeUnmount"),64&d?e.type.remove(e,t,n,eu,r):u&&!u.hasOnce&&(s!==iq||p>0&&64&p)?es(u,t,n,!1,!0):(s===iq&&384&p||!i&&16&d)&&es(c,t,n),r&&er(e)}(g&&(l=o&&o.onVnodeUnmounted)||m)&&is(()=>{l&&lc(l,t,e),m&&nm(e,null,t,"unmounted")},n)},er=e=>{let{type:t,el:n,anchor:r,transition:i}=e;if(t===iq){ei(n,r);return}if(t===iz){M(e);return}let l=()=>{o(n),i&&!i.persisted&&i.afterLeave&&i.afterLeave()};if(1&e.shapeFlag&&i&&!i.persisted){let{leave:t,delayLeave:r}=i,s=()=>t(n,l);r?r(e.el,l,s):s()}else l()},ei=(e,t)=>{let n;for(;e!==t;)n=x(e),o(e),e=n;o(t)},el=(e,t,n)=>{let{bum:r,scope:i,update:l,subTree:s,um:o,m:a,a:c}=e;im(a),im(c),r&&K(r),i.stop(),l&&(l.active=!1,en(s,e,t,n)),o&&is(o,t),is(()=>{e.isUnmounted=!0},t),t&&t.pendingBranch&&!t.isUnmounted&&e.asyncDep&&!e.asyncResolved&&e.suspenseId===t.pendingId&&(t.deps--,0===t.deps&&t.resolve())},es=(e,t,n,r=!1,i=!1,l=0)=>{for(let s=l;s{if(6&e.shapeFlag)return eo(e.component.subTree);if(128&e.shapeFlag)return e.suspense.next();let t=x(e.anchor||e.el),n=t&&t[rZ];return n?x(n):t},ea=!1,ec=(e,t,n)=>{null==e?t._vnode&&en(t._vnode,null,null,!0):k(t._vnode||null,e,t,null,null,null,n),t._vnode=e,ea||(ea=!0,nr(),ni(),ea=!1)},eu={p:k,um:en,m:et,r:er,mt:z,mc:V,pc:Z,pbc:U,n:eo,o:e};return t&&([r,l]=t(eu)),{render:ec,hydrate:r,createApp:(n=r,function(e,t=null){w(e)||(e=g({},e)),null==t||N(t)||(t=null);let r=rI(),i=new WeakSet,l=!1,s=r.app={_uid:rR++,_component:e,_props:t,_container:null,_context:r,_instance:null,version:lR,get config(){return r.config},set config(v){},use:(e,...t)=>(i.has(e)||(e&&w(e.install)?(i.add(e),e.install(s,...t)):w(e)&&(i.add(e),e(s,...t))),s),mixin:e=>(r.mixins.includes(e)||r.mixins.push(e),s),component:(e,t)=>t?(r.components[e]=t,s):r.components[e],directive:(e,t)=>t?(r.directives[e]=t,s):r.directives[e],mount(i,o,a){if(!l){let c=i7(e,t);return c.appContext=r,!0===a?a="svg":!1===a&&(a=void 0),o&&n?n(c,i):ec(c,i,a),l=!0,s._container=i,i.__vue_app__=s,lT(c.component)}},unmount(){l&&(ec(null,s._container),delete s._container.__vue_app__)},provide:(e,t)=>(r.provides[e]=t,s),runWithContext(e){let t=rO;rO=s;try{return e()}finally{rO=t}}};return s})}}function iu({type:e,props:t},n){return"svg"===n&&"foreignObject"===e||"mathml"===n&&"annotation-xml"===e&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function id({effect:e,update:t},n){e.allowRecurse=t.allowRecurse=n}function ip(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function ih(e,t,n=!1){let r=e.children,i=t.children;if(S(r)&&S(i))for(let e=0;erL(ig);function iv(e,t){return iC(e,null,t)}function ib(e,t){return iC(e,null,{flush:"post"})}function i_(e,t){return iC(e,null,{flush:"sync"})}let iS={};function ix(e,t,n){return iC(e,t,n)}function iC(e,t,{immediate:n,deep:r,flush:i,once:l,onTrack:s,onTrigger:o}=u){let a,c,d;if(t&&l){let e=t;t=(...t)=>{e(...t),k()}}let h=lp,f=e=>!0===r?e:iw(e,!1===r?1:void 0),m=!1,g=!1;if(tI(e)?(a=()=>e.value,m=tS(e)):tb(e)?(a=()=>f(e),m=!0):S(e)?(g=!0,m=e.some(e=>tb(e)||tS(e)),a=()=>e.map(e=>tI(e)?e.value:tb(e)?f(e):w(e)?tQ(e,h,2):void 0)):a=w(e)?t?()=>tQ(e,h,2):()=>(c&&c(),tZ(e,h,3,[b])):p,t&&r){let e=a;a=()=>iw(e())}let b=e=>{c=C.onStop=()=>{tQ(e,h,4),c=C.onStop=void 0}},_=g?Array(e.length).fill(iS):iS,x=()=>{if(C.active&&C.dirty){if(t){let e=C.run();(r||m||(g?e.some((e,t)=>W(e,_[t])):W(e,_)))&&(c&&c(),tZ(t,h,3,[e,_===iS?void 0:g&&_[0]===iS?[]:_,b]),_=e)}else C.run()}};x.allowRecurse=!!t,"sync"===i?d=x:"post"===i?d=()=>is(x,h&&h.suspense):(x.pre=!0,h&&(x.id=h.uid),d=()=>ne(x));let C=new eS(a,p,d),T=eb(),k=()=>{C.stop(),T&&y(T.effects,C)};return t?n?x():_=C.run():"post"===i?is(C.run.bind(C),h&&h.suspense):C.run(),k}function iT(e,t,n){let r;let i=this.proxy,l=E(e)?e.includes(".")?ik(i,e):()=>i[e]:e.bind(i,i);w(t)?r=t:(r=t.handler,n=t);let s=lf(this),o=iC(l,r.bind(i),n);return s(),o}function ik(e,t){let n=t.split(".");return()=>{let t=e;for(let e=0;e{iw(e,t,n)});else if(L(e)){for(let r in e)iw(e[r],t,n);for(let r of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,r)&&iw(e[r],t,n)}return e}function iE(e,t,n=u){let r=lh(),i=B(t),l=j(t),s=iA(e,t),o=tU((s,o)=>{let a,c;let d=u;return i_(()=>{let n=e[t];W(a,n)&&(a=n,o())}),{get:()=>(s(),n.get?n.get(a):a),set(e){let s=n.set?n.set(e):e;if(!W(s,a)&&!(d!==u&&W(e,d)))return;let p=r.vnode.props;p&&(t in p||i in p||l in p)&&(`onUpdate:${t}` in p||`onUpdate:${i}` in p||`onUpdate:${l}` in p)||(a=e,o()),r.emit(`update:${t}`,s),W(e,s)&&W(e,d)&&!W(s,c)&&o(),d=e,c=s}}});return o[Symbol.iterator]=()=>{let e=0;return{next:()=>e<2?{value:e++?s||u:o,done:!1}:{done:!0}}},o}let iA=(e,t)=>"modelValue"===t||"model-value"===t?e.modelModifiers:e[`${t}Modifiers`]||e[`${B(t)}Modifiers`]||e[`${j(t)}Modifiers`];function iN(e,t,...n){let r;if(e.isUnmounted)return;let i=e.vnode.props||u,l=n,s=t.startsWith("update:"),o=s&&iA(i,t.slice(7));o&&(o.trim&&(l=n.map(e=>E(e)?e.trim():e)),o.number&&(l=n.map(G)));let a=i[r=q(t)]||i[r=q(B(t))];!a&&s&&(a=i[r=q(j(t))]),a&&tZ(a,e,6,l);let c=i[r+"Once"];if(c){if(e.emitted){if(e.emitted[r])return}else e.emitted={};e.emitted[r]=!0,tZ(c,e,6,l)}}function iI(e,t){return!!(e&&f(t))&&(_(e,(t=t.slice(2).replace(/Once$/,""))[0].toLowerCase()+t.slice(1))||_(e,j(t))||_(e,t))}function iR(e){let t,n;let{type:r,vnode:i,proxy:l,withProxy:s,propsOptions:[o],slots:a,attrs:c,emit:u,render:d,renderCache:p,props:h,data:f,setupState:g,ctx:y,inheritAttrs:b}=e,_=nc(e);try{if(4&i.shapeFlag){let e=s||l;t=ll(d.call(e,e,p,h,g,f,y)),n=c}else t=ll(r.length>1?r(h,{attrs:c,slots:a,emit:u}):r(h,null)),n=r.props?c:iO(c)}catch(n){iG.length=0,tY(n,e,1),t=i7(iK)}let S=t;if(n&&!1!==b){let e=Object.keys(n),{shapeFlag:t}=S;e.length&&7&t&&(o&&e.some(m)&&(n=iM(n,o)),S=lt(S,n,!1,!0))}return i.dirs&&((S=lt(S,null,!1,!0)).dirs=S.dirs?S.dirs.concat(i.dirs):i.dirs),i.transition&&(S.transition=i.transition),t=S,nc(_),t}let iO=e=>{let t;for(let n in e)("class"===n||"style"===n||f(n))&&((t||(t={}))[n]=e[n]);return t},iM=(e,t)=>{let n={};for(let r in e)m(r)&&r.slice(9) in t||(n[r]=e[r]);return n};function iL(e,t,n){let r=Object.keys(t);if(r.length!==Object.keys(e).length)return!0;for(let i=0;ie.__isSuspense,iF=0,iV={name:"Suspense",__isSuspense:!0,process(e,t,n,r,i,l,s,o,a,c){if(null==e)!function(e,t,n,r,i,l,s,o,a){let{p:c,o:{createElement:u}}=a,d=u("div"),p=e.suspense=iB(e,i,r,t,d,n,l,s,o,a);c(null,p.pendingBranch=e.ssContent,d,null,r,p,l,s),p.deps>0?(iD(e,"onPending"),iD(e,"onFallback"),c(null,e.ssFallback,t,n,r,null,l,s),iH(p,e.ssFallback)):p.resolve(!1,!0)}(t,n,r,i,l,s,o,a,c);else{if(l&&l.deps>0&&!e.suspense.isInFallback){t.suspense=e.suspense,t.suspense.vnode=t,t.el=e.el;return}!function(e,t,n,r,i,l,s,o,{p:a,um:c,o:{createElement:u}}){let d=t.suspense=e.suspense;d.vnode=t,t.el=e.el;let p=t.ssContent,h=t.ssFallback,{activeBranch:f,pendingBranch:m,isInFallback:g,isHydrating:y}=d;if(m)d.pendingBranch=p,i6(p,m)?(a(m,p,d.hiddenContainer,null,i,d,l,s,o),d.deps<=0?d.resolve():g&&!y&&(a(f,h,n,r,i,null,l,s,o),iH(d,h))):(d.pendingId=iF++,y?(d.isHydrating=!1,d.activeBranch=m):c(m,i,d),d.deps=0,d.effects.length=0,d.hiddenContainer=u("div"),g?(a(null,p,d.hiddenContainer,null,i,d,l,s,o),d.deps<=0?d.resolve():(a(f,h,n,r,i,null,l,s,o),iH(d,h))):f&&i6(p,f)?(a(f,p,n,r,i,d,l,s,o),d.resolve(!0)):(a(null,p,d.hiddenContainer,null,i,d,l,s,o),d.deps<=0&&d.resolve()));else if(f&&i6(p,f))a(f,p,n,r,i,d,l,s,o),iH(d,p);else if(iD(t,"onPending"),d.pendingBranch=p,512&p.shapeFlag?d.pendingId=p.component.suspenseId:d.pendingId=iF++,a(null,p,d.hiddenContainer,null,i,d,l,s,o),d.deps<=0)d.resolve();else{let{timeout:e,pendingId:t}=d;e>0?setTimeout(()=>{d.pendingId===t&&d.fallback(h)},e):0===e&&d.fallback(h)}}(e,t,n,r,i,s,o,a,c)}},hydrate:function(e,t,n,r,i,l,s,o,a){let c=t.suspense=iB(t,r,n,e.parentNode,document.createElement("div"),null,i,l,s,o,!0),u=a(e,c.pendingBranch=t.ssContent,n,c,l,s);return 0===c.deps&&c.resolve(!1,!0),u},normalize:function(e){let{shapeFlag:t,children:n}=e,r=32&t;e.ssContent=iU(r?n.default:n),e.ssFallback=r?iU(n.fallback):i7(iK)}};function iD(e,t){let n=e.props&&e.props[t];w(n)&&n()}function iB(e,t,n,r,i,l,s,o,a,c,u=!1){let d;let{p:p,m:h,um:f,n:m,o:{parentNode:g,remove:y}}=c,b=function(e){let t=e.props&&e.props.suspensible;return null!=t&&!1!==t}(e);b&&t&&t.pendingBranch&&(d=t.pendingId,t.deps++);let _=e.props?J(e.props.timeout):void 0,S=l,x={vnode:e,parent:t,parentComponent:n,namespace:s,container:r,hiddenContainer:i,deps:0,pendingId:iF++,timeout:"number"==typeof _?_:-1,activeBranch:null,pendingBranch:null,isInFallback:!u,isHydrating:u,isUnmounted:!1,effects:[],resolve(e=!1,n=!1){let{vnode:r,activeBranch:i,pendingBranch:s,pendingId:o,effects:a,parentComponent:c,container:u}=x,p=!1;x.isHydrating?x.isHydrating=!1:e||((p=i&&s.transition&&"out-in"===s.transition.mode)&&(i.transition.afterLeave=()=>{o===x.pendingId&&(h(s,u,l===S?m(i):l,0),nn(a))}),i&&(g(i.el)!==x.hiddenContainer&&(l=m(i)),f(i,c,x,!0)),p||h(s,u,l,0)),iH(x,s),x.pendingBranch=null,x.isInFallback=!1;let y=x.parent,_=!1;for(;y;){if(y.pendingBranch){y.effects.push(...a),_=!0;break}y=y.parent}_||p||nn(a),x.effects=[],b&&t&&t.pendingBranch&&d===t.pendingId&&(t.deps--,0!==t.deps||n||t.resolve()),iD(r,"onResolve")},fallback(e){if(!x.pendingBranch)return;let{vnode:t,activeBranch:n,parentComponent:r,container:i,namespace:l}=x;iD(t,"onFallback");let s=m(n),c=()=>{x.isInFallback&&(p(null,e,i,s,r,null,l,o,a),iH(x,e))},u=e.transition&&"out-in"===e.transition.mode;u&&(n.transition.afterLeave=c),x.isInFallback=!0,f(n,r,null,!0),u||c()},move(e,t,n){x.activeBranch&&h(x.activeBranch,e,t,n),x.container=e},next:()=>x.activeBranch&&m(x.activeBranch),registerDep(e,t,n){let r=!!x.pendingBranch;r&&x.deps++;let i=e.vnode.el;e.asyncDep.catch(t=>{tY(t,e,0)}).then(l=>{if(e.isUnmounted||x.isUnmounted||x.pendingId!==e.suspenseId)return;e.asyncResolved=!0;let{vnode:o}=e;lv(e,l,!1),i&&(o.el=i);let a=!i&&e.subTree.el;t(e,o,g(i||e.subTree.el),i?null:m(e.subTree),x,s,n),a&&y(a),iP(e,o.el),r&&0==--x.deps&&x.resolve()})},unmount(e,t){x.isUnmounted=!0,x.activeBranch&&f(x.activeBranch,n,e,t),x.pendingBranch&&f(x.pendingBranch,n,e,t)}};return x}function iU(e){let t;if(w(e)){let n=iZ&&e._c;n&&(e._d=!1,iX()),e=e(),n&&(e._d=!0,t=iJ,iQ())}return S(e)&&(e=function(e,t=!0){let n;for(let t=0;tt!==e)),e}function ij(e,t){t&&t.pendingBranch?S(e)?t.effects.push(...e):t.effects.push(e):nn(e)}function iH(e,t){e.activeBranch=t;let{vnode:n,parentComponent:r}=e,i=t.el;for(;!i&&t.component;)i=(t=t.component.subTree).el;n.el=i,r&&r.subTree===n&&(r.vnode.el=i,iP(r,i))}let iq=Symbol.for("v-fgt"),iW=Symbol.for("v-txt"),iK=Symbol.for("v-cmt"),iz=Symbol.for("v-stc"),iG=[],iJ=null;function iX(e=!1){iG.push(iJ=e?null:[])}function iQ(){iG.pop(),iJ=iG[iG.length-1]||null}let iZ=1;function iY(e){iZ+=e,e<0&&iJ&&(iJ.hasOnce=!0)}function i0(e){return e.dynamicChildren=iZ>0?iJ||d:null,iQ(),iZ>0&&iJ&&iJ.push(e),e}function i1(e,t,n,r,i,l){return i0(i9(e,t,n,r,i,l,!0))}function i2(e,t,n,r,i){return i0(i7(e,t,n,r,i,!0))}function i3(e){return!!e&&!0===e.__v_isVNode}function i6(e,t){return e.type===t.type&&e.key===t.key}function i4(e){}let i8=({key:e})=>null!=e?e:null,i5=({ref:e,ref_key:t,ref_for:n})=>("number"==typeof e&&(e=""+e),null!=e?E(e)||tI(e)||w(e)?{i:no,r:e,k:t,f:!!n}:e:null);function i9(e,t=null,n=null,r=0,i=null,l=e===iq?0:1,s=!1,o=!1){let a={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&i8(t),ref:t&&i5(t),scopeId:na,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:l,patchFlag:r,dynamicProps:i,dynamicChildren:null,appContext:null,ctx:no};return o?(lo(a,n),128&l&&e.normalize(a)):n&&(a.shapeFlag|=E(n)?8:16),iZ>0&&!s&&iJ&&(a.patchFlag>0||6&l)&&32!==a.patchFlag&&iJ.push(a),a}let i7=function(e,t=null,n=null,r=0,i=null,l=!1){var s;if(e&&e!==n1||(e=iK),i3(e)){let r=lt(e,t,!0);return n&&lo(r,n),iZ>0&&!l&&iJ&&(6&r.shapeFlag?iJ[iJ.indexOf(e)]=r:iJ.push(r)),r.patchFlag=-2,r}if(w(s=e)&&"__vccOpts"in s&&(e=e.__vccOpts),t){let{class:e,style:n}=t=le(t);e&&!E(e)&&(t.class=er(e)),N(n)&&(tx(n)&&!S(n)&&(n=g({},n)),t.style=Z(n))}let o=E(e)?1:i$(e)?128:rY(e)?64:N(e)?4:w(e)?2:0;return i9(e,t,n,r,i,o,l,!0)};function le(e){return e?tx(e)||rV(e)?g({},e):e:null}function lt(e,t,n=!1,r=!1){let{props:i,ref:l,patchFlag:s,children:o,transition:a}=e,c=t?la(i||{},t):i,u={__v_isVNode:!0,__v_skip:!0,type:e.type,props:c,key:c&&i8(c),ref:t&&t.ref?n&&l?S(l)?l.concat(i5(t)):[l,i5(t)]:i5(t):l,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:o,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==iq?-1===s?16:16|s:s,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:a,component:e.component,suspense:e.suspense,ssContent:e.ssContent&<(e.ssContent),ssFallback:e.ssFallback&<(e.ssFallback),el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return a&&r&&nE(u,a.clone(u)),u}function ln(e=" ",t=0){return i7(iW,null,e,t)}function lr(e,t){let n=i7(iz,null,e);return n.staticCount=t,n}function li(e="",t=!1){return t?(iX(),i2(iK,null,e)):i7(iK,null,e)}function ll(e){return null==e||"boolean"==typeof e?i7(iK):S(e)?i7(iq,null,e.slice()):"object"==typeof e?ls(e):i7(iW,null,String(e))}function ls(e){return null===e.el&&-1!==e.patchFlag||e.memo?e:lt(e)}function lo(e,t){let n=0,{shapeFlag:r}=e;if(null==t)t=null;else if(S(t))n=16;else if("object"==typeof t){if(65&r){let n=t.default;n&&(n._c&&(n._d=!1),lo(e,n()),n._c&&(n._d=!0));return}{n=32;let r=t._;r||rV(t)?3===r&&no&&(1===no.slots._?t._=1:(t._=2,e.patchFlag|=1024)):t._ctx=no}}else w(t)?(t={default:t,_ctx:no},n=32):(t=String(t),64&r?(n=16,t=[ln(t)]):n=8);e.children=t,e.shapeFlag|=n}function la(...e){let t={};for(let n=0;nlp||no;r=e=>{lp=e},i=e=>{ly=e};let lf=e=>{let t=lp;return r(e),e.scope.on(),()=>{e.scope.off(),r(t)}},lm=()=>{lp&&lp.scope.off(),r(null)};function lg(e){return 4&e.vnode.shapeFlag}let ly=!1;function lv(e,t,n){w(t)?e.render=t:N(t)&&(e.setupState=tD(t)),lS(e,n)}function lb(e){l=e,s=e=>{e.render._rc&&(e.withProxy=new Proxy(e.ctx,ri))}}let l_=()=>!l;function lS(e,t,n){let r=e.type;if(!e.render){if(!t&&l&&!r.render){let t=r.template||rx(e).template;if(t){let{isCustomElement:n,compilerOptions:i}=e.appContext.config,{delimiters:s,compilerOptions:o}=r,a=g(g({isCustomElement:n,delimiters:s},i),o);r.render=l(t,a)}}e.render=r.render||p,s&&s(e)}{let t=lf(e);eI();try{!function(e){let t=rx(e),n=e.proxy,r=e.ctx;r_=!1,t.beforeCreate&&rS(t.beforeCreate,e,"bc");let{data:i,computed:l,methods:s,watch:o,provide:a,inject:c,created:u,beforeMount:d,mounted:h,beforeUpdate:f,updated:m,activated:g,deactivated:y,beforeDestroy:b,beforeUnmount:_,destroyed:x,unmounted:C,render:T,renderTracked:k,renderTriggered:A,errorCaptured:I,serverPrefetch:R,expose:O,inheritAttrs:M,components:L,directives:P,filters:$}=t;if(c&&function(e,t,n=p){for(let n in S(e)&&(e=rw(e)),e){let r;let i=e[n];tI(r=N(i)?"default"in i?rL(i.from||n,i.default,!0):rL(i.from||n):rL(i))?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>r.value,set:e=>r.value=e}):t[n]=r}}(c,r,null),s)for(let e in s){let t=s[e];w(t)&&(r[e]=t.bind(n))}if(i){let t=i.call(n,n);N(t)&&(e.data=tf(t))}if(r_=!0,l)for(let e in l){let t=l[e],i=w(t)?t.bind(n,n):w(t.get)?t.get.bind(n,n):p,s=lw({get:i,set:!w(t)&&w(t.set)?t.set.bind(n):p});Object.defineProperty(r,e,{enumerable:!0,configurable:!0,get:()=>s.value,set:e=>s.value=e})}if(o)for(let e in o)!function e(t,n,r,i){let l=i.includes(".")?ik(r,i):()=>r[i];if(E(t)){let e=n[t];w(e)&&ix(l,e)}else if(w(t))ix(l,t.bind(r));else if(N(t)){if(S(t))t.forEach(t=>e(t,n,r,i));else{let e=w(t.handler)?t.handler.bind(r):n[t.handler];w(e)&&ix(l,e,t)}}}(o[e],r,n,e);if(a){let e=w(a)?a.call(n):a;Reflect.ownKeys(e).forEach(t=>{rM(t,e[t])})}function F(e,t){S(t)?t.forEach(t=>e(t.bind(n))):t&&e(t.bind(n))}if(u&&rS(u,e,"c"),F(nH,d),F(nq,h),F(nW,f),F(nK,m),F(n$,g),F(nF,y),F(nZ,I),F(nQ,k),F(nX,A),F(nz,_),F(nG,C),F(nJ,R),S(O)){if(O.length){let t=e.exposed||(e.exposed={});O.forEach(e=>{Object.defineProperty(t,e,{get:()=>n[e],set:t=>n[e]=t})})}else e.exposed||(e.exposed={})}T&&e.render===p&&(e.render=T),null!=M&&(e.inheritAttrs=M),L&&(e.components=L),P&&(e.directives=P)}(e)}finally{eR(),t()}}}let lx={get:(e,t)=>(eB(e,"get",""),e[t])};function lC(e){return{attrs:new Proxy(e.attrs,lx),slots:e.slots,emit:e.emit,expose:t=>{e.exposed=t||{}}}}function lT(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(tD(tT(e.exposed)),{get:(t,n)=>n in t?t[n]:n in rt?rt[n](e):void 0,has:(e,t)=>t in e||t in rt})):e.proxy}function lk(e,t=!0){return w(e)?e.displayName||e.name:e.name||t&&e.__name}let lw=(e,t)=>(function(e,t,n=!1){let r,i;let l=w(e);return l?(r=e,i=p):(r=e.get,i=e.set),new tE(r,i,l||!i,n)})(e,0,ly);function lE(e,t,n){let r=arguments.length;return 2!==r?(r>3?n=Array.prototype.slice.call(arguments,2):3===r&&i3(n)&&(n=[n]),i7(e,t,n)):!N(t)||S(t)?i7(e,null,t):i3(t)?i7(e,null,[t]):i7(e,t)}function lA(){}function lN(e,t,n,r){let i=n[r];if(i&&lI(i,e))return i;let l=t();return l.memo=e.slice(),l.cacheIndex=r,n[r]=l}function lI(e,t){let n=e.memo;if(n.length!=t.length)return!1;for(let e=0;e0&&iJ&&iJ.push(e),!0}let lR="3.4.38",lO=p,lM=null,lL=void 0,lP=p,l$=null,lF=null,lV=null,lD=null,lB="undefined"!=typeof document?document:null,lU=lB&&lB.createElement("template"),lj="transition",lH="animation",lq=Symbol("_vtc"),lW=(e,{slots:t})=>lE(nx,lX(e),t);lW.displayName="Transition";let lK={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},lz=lW.props=g({},n_,lK),lG=(e,t=[])=>{S(e)?e.forEach(e=>e(...t)):e&&e(...t)},lJ=e=>!!e&&(S(e)?e.some(e=>e.length>1):e.length>1);function lX(e){let t={};for(let n in e)n in lK||(t[n]=e[n]);if(!1===e.css)return t;let{name:n="v",type:r,duration:i,enterFromClass:l=`${n}-enter-from`,enterActiveClass:s=`${n}-enter-active`,enterToClass:o=`${n}-enter-to`,appearFromClass:a=l,appearActiveClass:c=s,appearToClass:u=o,leaveFromClass:d=`${n}-leave-from`,leaveActiveClass:p=`${n}-leave-active`,leaveToClass:h=`${n}-leave-to`}=e,f=function(e){if(null==e)return null;if(N(e))return[J(e.enter),J(e.leave)];{let t=J(e);return[t,t]}}(i),m=f&&f[0],y=f&&f[1],{onBeforeEnter:b,onEnter:_,onEnterCancelled:S,onLeave:x,onLeaveCancelled:C,onBeforeAppear:T=b,onAppear:k=_,onAppearCancelled:w=S}=t,E=(e,t,n)=>{lZ(e,t?u:o),lZ(e,t?c:s),n&&n()},A=(e,t)=>{e._isLeaving=!1,lZ(e,d),lZ(e,h),lZ(e,p),t&&t()},I=e=>(t,n)=>{let i=e?k:_,s=()=>E(t,e,n);lG(i,[t,s]),lY(()=>{lZ(t,e?a:l),lQ(t,e?u:o),lJ(i)||l1(t,r,m,s)})};return g(t,{onBeforeEnter(e){lG(b,[e]),lQ(e,l),lQ(e,s)},onBeforeAppear(e){lG(T,[e]),lQ(e,a),lQ(e,c)},onEnter:I(!1),onAppear:I(!0),onLeave(e,t){e._isLeaving=!0;let n=()=>A(e,t);lQ(e,d),lQ(e,p),l4(),lY(()=>{e._isLeaving&&(lZ(e,d),lQ(e,h),lJ(x)||l1(e,r,y,n))}),lG(x,[e,n])},onEnterCancelled(e){E(e,!1),lG(S,[e])},onAppearCancelled(e){E(e,!0),lG(w,[e])},onLeaveCancelled(e){A(e),lG(C,[e])}})}function lQ(e,t){t.split(/\s+/).forEach(t=>t&&e.classList.add(t)),(e[lq]||(e[lq]=new Set)).add(t)}function lZ(e,t){t.split(/\s+/).forEach(t=>t&&e.classList.remove(t));let n=e[lq];n&&(n.delete(t),n.size||(e[lq]=void 0))}function lY(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let l0=0;function l1(e,t,n,r){let i=e._endId=++l0,l=()=>{i===e._endId&&r()};if(n)return setTimeout(l,n);let{type:s,timeout:o,propCount:a}=l2(e,t);if(!s)return r();let c=s+"end",u=0,d=()=>{e.removeEventListener(c,p),l()},p=t=>{t.target===e&&++u>=a&&d()};setTimeout(()=>{u(n[e]||"").split(", "),i=r(`${lj}Delay`),l=r(`${lj}Duration`),s=l3(i,l),o=r(`${lH}Delay`),a=r(`${lH}Duration`),c=l3(o,a),u=null,d=0,p=0;t===lj?s>0&&(u=lj,d=s,p=l.length):t===lH?c>0&&(u=lH,d=c,p=a.length):p=(u=(d=Math.max(s,c))>0?s>c?lj:lH:null)?u===lj?l.length:a.length:0;let h=u===lj&&/\b(transform|all)(,|$)/.test(r(`${lj}Property`).toString());return{type:u,timeout:d,propCount:p,hasTransform:h}}function l3(e,t){for(;e.lengthl6(t)+l6(e[n])))}function l6(e){return"auto"===e?0:1e3*Number(e.slice(0,-1).replace(",","."))}function l4(){return document.body.offsetHeight}let l8=Symbol("_vod"),l5=Symbol("_vsh"),l9={beforeMount(e,{value:t},{transition:n}){e[l8]="none"===e.style.display?"":e.style.display,n&&t?n.beforeEnter(e):l7(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:r}){!t!=!n&&(r?t?(r.beforeEnter(e),l7(e,!0),r.enter(e)):r.leave(e,()=>{l7(e,!1)}):l7(e,t))},beforeUnmount(e,{value:t}){l7(e,t)}};function l7(e,t){e.style.display=t?e[l8]:"none",e[l5]=!t}let se=Symbol("");function st(e){let t=lh();if(!t)return;let n=t.ut=(n=e(t.proxy))=>{Array.from(document.querySelectorAll(`[data-v-owner="${t.uid}"]`)).forEach(e=>sn(e,n))},r=()=>{let r=e(t.proxy);(function e(t,n){if(128&t.shapeFlag){let r=t.suspense;t=r.activeBranch,r.pendingBranch&&!r.isHydrating&&r.effects.push(()=>{e(r.activeBranch,n)})}for(;t.component;)t=t.component.subTree;if(1&t.shapeFlag&&t.el)sn(t.el,n);else if(t.type===iq)t.children.forEach(t=>e(t,n));else if(t.type===iz){let{el:e,anchor:r}=t;for(;e&&(sn(e,n),e!==r);)e=e.nextSibling}})(t.subTree,r),n(r)};nH(()=>{ib(r)}),nq(()=>{let e=new MutationObserver(r);e.observe(t.subTree.el.parentNode,{childList:!0}),nG(()=>e.disconnect())})}function sn(e,t){if(1===e.nodeType){let n=e.style,r="";for(let e in t)n.setProperty(`--${e}`,t[e]),r+=`--${e}: ${t[e]};`;n[se]=r}}let sr=/(^|;)\s*display\s*:/,si=/\s*!important$/;function sl(e,t,n){if(S(n))n.forEach(n=>sl(e,t,n));else if(null==n&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{let r=function(e,t){let n=so[t];if(n)return n;let r=B(t);if("filter"!==r&&r in e)return so[t]=r;r=H(r);for(let n=0;nsh||(sf.then(()=>sh=0),sh=Date.now()),sg=e=>111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&e.charCodeAt(2)>96&&123>e.charCodeAt(2);/*! #__NO_SIDE_EFFECTS__ */function sy(e,t,n){let r=nN(e,t);class i extends s_{constructor(e){super(r,e,n)}}return i.def=r,i}/*! #__NO_SIDE_EFFECTS__ */let sv=(e,t)=>sy(e,t,s0),sb="undefined"!=typeof HTMLElement?HTMLElement:class{};class s_ extends sb{constructor(e,t={},n){super(),this._def=e,this._props=t,this._instance=null,this._connected=!1,this._resolved=!1,this._numberProps=null,this._ob=null,this.shadowRoot&&n?n(this._createVNode(),this.shadowRoot):(this.attachShadow({mode:"open"}),this._def.__asyncLoader||this._resolveProps(this._def))}connectedCallback(){this._connected=!0,this._instance||(this._resolved?this._update():this._resolveDef())}disconnectedCallback(){this._connected=!1,t7(()=>{this._connected||(this._ob&&(this._ob.disconnect(),this._ob=null),sY(null,this.shadowRoot),this._instance=null)})}_resolveDef(){this._resolved=!0;for(let e=0;e{for(let t of e)this._setAttr(t.attributeName)}),this._ob.observe(this,{attributes:!0});let e=(e,t=!1)=>{let n;let{props:r,styles:i}=e;if(r&&!S(r))for(let e in r){let t=r[e];(t===Number||t&&t.type===Number)&&(e in this._props&&(this._props[e]=J(this._props[e])),(n||(n=Object.create(null)))[B(e)]=!0)}this._numberProps=n,t&&this._resolveProps(e),this._applyStyles(i),this._update()},t=this._def.__asyncLoader;t?t().then(t=>e(t,!0)):e(this._def)}_resolveProps(e){let{props:t}=e,n=S(t)?t:Object.keys(t||{});for(let e of Object.keys(this))"_"!==e[0]&&n.includes(e)&&this._setProp(e,this[e],!0,!1);for(let e of n.map(B))Object.defineProperty(this,e,{get(){return this._getProp(e)},set(t){this._setProp(e,t)}})}_setAttr(e){let t=this.hasAttribute(e)?this.getAttribute(e):void 0,n=B(e);this._numberProps&&this._numberProps[n]&&(t=J(t)),this._setProp(n,t,!1)}_getProp(e){return this._props[e]}_setProp(e,t,n=!0,r=!0){t!==this._props[e]&&(this._props[e]=t,r&&this._instance&&this._update(),n&&(!0===t?this.setAttribute(j(e),""):"string"==typeof t||"number"==typeof t?this.setAttribute(j(e),t+""):t||this.removeAttribute(j(e))))}_update(){sY(this._createVNode(),this.shadowRoot)}_createVNode(){let e=i7(this._def,g({},this._props));return this._instance||(e.ce=e=>{this._instance=e,e.isCE=!0;let t=(e,t)=>{this.dispatchEvent(new CustomEvent(e,{detail:t}))};e.emit=(e,...n)=>{t(e,n),j(e)!==e&&t(j(e),n)};let n=this;for(;n=n&&(n.parentNode||n.host);)if(n instanceof s_){e.parent=n._instance,e.provides=n._instance.provides;break}}),e}_applyStyles(e){e&&e.forEach(e=>{let t=document.createElement("style");t.textContent=e,this.shadowRoot.appendChild(t)})}}function sS(e="$style"){{let t=lh();if(!t)return u;let n=t.type.__cssModules;return n&&n[e]||u}}let sx=new WeakMap,sC=new WeakMap,sT=Symbol("_moveCb"),sk=Symbol("_enterCb"),sw={name:"TransitionGroup",props:g({},lz,{tag:String,moveClass:String}),setup(e,{slots:t}){let n,r;let i=lh(),l=nv();return nK(()=>{if(!n.length)return;let t=e.moveClass||`${e.name||"v"}-move`;if(!function(e,t,n){let r=e.cloneNode(),i=e[lq];i&&i.forEach(e=>{e.split(/\s+/).forEach(e=>e&&r.classList.remove(e))}),n.split(/\s+/).forEach(e=>e&&r.classList.add(e)),r.style.display="none";let l=1===t.nodeType?t:t.parentNode;l.appendChild(r);let{hasTransform:s}=l2(r);return l.removeChild(r),s}(n[0].el,i.vnode.el,t))return;n.forEach(sA),n.forEach(sN);let r=n.filter(sI);l4(),r.forEach(e=>{let n=e.el,r=n.style;lQ(n,t),r.transform=r.webkitTransform=r.transitionDuration="";let i=n[sT]=e=>{(!e||e.target===n)&&(!e||/transform$/.test(e.propertyName))&&(n.removeEventListener("transitionend",i),n[sT]=null,lZ(n,t))};n.addEventListener("transitionend",i)})}),()=>{let s=tC(e),o=lX(s),a=s.tag||iq;if(n=[],r)for(let e=0;e{let t=e.props["onUpdate:modelValue"]||!1;return S(t)?e=>K(t,e):t};function sO(e){e.target.composing=!0}function sM(e){let t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}let sL=Symbol("_assign"),sP={created(e,{modifiers:{lazy:t,trim:n,number:r}},i){e[sL]=sR(i);let l=r||i.props&&"number"===i.props.type;su(e,t?"change":"input",t=>{if(t.target.composing)return;let r=e.value;n&&(r=r.trim()),l&&(r=G(r)),e[sL](r)}),n&&su(e,"change",()=>{e.value=e.value.trim()}),t||(su(e,"compositionstart",sO),su(e,"compositionend",sM),su(e,"change",sM))},mounted(e,{value:t}){e.value=null==t?"":t},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:r,trim:i,number:l}},s){if(e[sL]=sR(s),e.composing)return;let o=(l||"number"===e.type)&&!/^0\d/.test(e.value)?G(e.value):e.value,a=null==t?"":t;o===a||document.activeElement===e&&"range"!==e.type&&(r&&t===n||i&&e.value.trim()===a)||(e.value=a)}},s$={deep:!0,created(e,t,n){e[sL]=sR(n),su(e,"change",()=>{let t=e._modelValue,n=sU(e),r=e.checked,i=e[sL];if(S(t)){let e=ed(t,n),l=-1!==e;if(r&&!l)i(t.concat(n));else if(!r&&l){let n=[...t];n.splice(e,1),i(n)}}else if(C(t)){let e=new Set(t);r?e.add(n):e.delete(n),i(e)}else i(sj(e,r))})},mounted:sF,beforeUpdate(e,t,n){e[sL]=sR(n),sF(e,t,n)}};function sF(e,{value:t,oldValue:n},r){e._modelValue=t,S(t)?e.checked=ed(t,r.props.value)>-1:C(t)?e.checked=t.has(r.props.value):t!==n&&(e.checked=eu(t,sj(e,!0)))}let sV={created(e,{value:t},n){e.checked=eu(t,n.props.value),e[sL]=sR(n),su(e,"change",()=>{e[sL](sU(e))})},beforeUpdate(e,{value:t,oldValue:n},r){e[sL]=sR(r),t!==n&&(e.checked=eu(t,r.props.value))}},sD={deep:!0,created(e,{value:t,modifiers:{number:n}},r){let i=C(t);su(e,"change",()=>{let t=Array.prototype.filter.call(e.options,e=>e.selected).map(e=>n?G(sU(e)):sU(e));e[sL](e.multiple?i?new Set(t):t:t[0]),e._assigning=!0,t7(()=>{e._assigning=!1})}),e[sL]=sR(r)},mounted(e,{value:t}){sB(e,t)},beforeUpdate(e,t,n){e[sL]=sR(n)},updated(e,{value:t}){e._assigning||sB(e,t)}};function sB(e,t,n){let r=e.multiple,i=S(t);if(!r||i||C(t)){for(let n=0,l=e.options.length;nString(e)===String(s)):l.selected=ed(t,s)>-1}else l.selected=t.has(s)}else if(eu(sU(l),t)){e.selectedIndex!==n&&(e.selectedIndex=n);return}}r||-1===e.selectedIndex||(e.selectedIndex=-1)}}function sU(e){return"_value"in e?e._value:e.value}function sj(e,t){let n=t?"_trueValue":"_falseValue";return n in e?e[n]:t}let sH={created(e,t,n){sq(e,t,n,null,"created")},mounted(e,t,n){sq(e,t,n,null,"mounted")},beforeUpdate(e,t,n,r){sq(e,t,n,r,"beforeUpdate")},updated(e,t,n,r){sq(e,t,n,r,"updated")}};function sq(e,t,n,r,i){let l=function(e,t){switch(e){case"SELECT":return sD;case"TEXTAREA":return sP;default:switch(t){case"checkbox":return s$;case"radio":return sV;default:return sP}}}(e.tagName,n.props&&n.props.type)[i];l&&l(e,t,n,r)}let sW=["ctrl","shift","alt","meta"],sK={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&0!==e.button,middle:e=>"button"in e&&1!==e.button,right:e=>"button"in e&&2!==e.button,exact:(e,t)=>sW.some(n=>e[`${n}Key`]&&!t.includes(n))},sz=(e,t)=>{let n=e._withMods||(e._withMods={}),r=t.join(".");return n[r]||(n[r]=(n,...r)=>{for(let e=0;e{let n=e._withKeys||(e._withKeys={}),r=t.join(".");return n[r]||(n[r]=n=>{if(!("key"in n))return;let r=j(n.key);if(t.some(e=>e===r||sG[e]===r))return e(n)})},sX=g({patchProp:(e,t,n,r,i,l)=>{let s="svg"===i;"class"===t?function(e,t,n){let r=e[lq];r&&(t=(t?[t,...r]:[...r]).join(" ")),null==t?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}(e,r,s):"style"===t?function(e,t,n){let r=e.style,i=E(n),l=!1;if(n&&!i){if(t){if(E(t))for(let e of t.split(";")){let t=e.slice(0,e.indexOf(":")).trim();null==n[t]&&sl(r,t,"")}else for(let e in t)null==n[e]&&sl(r,e,"")}for(let e in n)"display"===e&&(l=!0),sl(r,e,n[e])}else if(i){if(t!==n){let e=r[se];e&&(n+=";"+e),r.cssText=n,l=sr.test(n)}}else t&&e.removeAttribute("style");l8 in e&&(e[l8]=l?r.display:"",e[l5]&&(r.display="none"))}(e,n,r):f(t)?m(t)||function(e,t,n,r,i=null){let l=e[sd]||(e[sd]={}),s=l[t];if(r&&s)s.value=r;else{let[n,o]=function(e){let t;if(sp.test(e)){let n;for(t={};n=e.match(sp);)e=e.slice(0,e.length-n[0].length),t[n[0].toLowerCase()]=!0}return[":"===e[2]?e.slice(3):j(e.slice(2)),t]}(t);r?su(e,n,l[t]=function(e,t){let n=e=>{if(e._vts){if(e._vts<=n.attached)return}else e._vts=Date.now();tZ(function(e,t){if(!S(t))return t;{let n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(e=>t=>!t._stopped&&e&&e(t))}}(e,n.value),t,5,[e])};return n.value=e,n.attached=sm(),n}(r,i),o):s&&(!function(e,t,n,r){e.removeEventListener(t,n,r)}(e,n,s,o),l[t]=void 0)}}(e,t,0,r,l):("."===t[0]?(t=t.slice(1),0):"^"===t[0]?(t=t.slice(1),1):!function(e,t,n,r){if(r)return!!("innerHTML"===t||"textContent"===t||t in e&&sg(t)&&w(n));if("spellcheck"===t||"draggable"===t||"translate"===t||"form"===t||"list"===t&&"INPUT"===e.tagName||"type"===t&&"TEXTAREA"===e.tagName)return!1;if("width"===t||"height"===t){let t=e.tagName;if("IMG"===t||"VIDEO"===t||"CANVAS"===t||"SOURCE"===t)return!1}return!(sg(t)&&E(n))&&t in e}(e,t,r,s))?("true-value"===t?e._trueValue=r:"false-value"===t&&(e._falseValue=r),sc(e,t,r,s)):(!function(e,t,n,r){if("innerHTML"===t||"textContent"===t){if(null==n)return;e[t]=n;return}let i=e.tagName;if("value"===t&&"PROGRESS"!==i&&!i.includes("-")){let r="OPTION"===i?e.getAttribute("value")||"":e.value,l=null==n?"":String(n);r===l&&"_value"in e||(e.value=l),null==n&&e.removeAttribute(t),e._value=n;return}let l=!1;if(""===n||null==n){let r=typeof e[t];if("boolean"===r){var s;n=!!(s=n)||""===s}else null==n&&"string"===r?(n="",l=!0):"number"===r&&(n=0,l=!0)}try{e[t]=n}catch(e){}l&&e.removeAttribute(t)}(e,t,r),e.tagName.includes("-")||"value"!==t&&"checked"!==t&&"selected"!==t||sc(e,t,r,s,l,"value"!==t))}},{insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{let t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,r)=>{let i="svg"===t?lB.createElementNS("http://www.w3.org/2000/svg",e):"mathml"===t?lB.createElementNS("http://www.w3.org/1998/Math/MathML",e):n?lB.createElement(e,{is:n}):lB.createElement(e);return"select"===e&&r&&null!=r.multiple&&i.setAttribute("multiple",r.multiple),i},createText:e=>lB.createTextNode(e),createComment:e=>lB.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>lB.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,r,i,l){let s=n?n.previousSibling:t.lastChild;if(i&&(i===l||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),n),i!==l&&(i=i.nextSibling););else{lU.innerHTML="svg"===r?`${e}`:"mathml"===r?`${e}`:e;let i=lU.content;if("svg"===r||"mathml"===r){let e=i.firstChild;for(;e.firstChild;)i.appendChild(e.firstChild);i.removeChild(e)}t.insertBefore(i,n)}return[s?s.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}}),sQ=!1;function sZ(){return o=sQ?o:ia(sX),sQ=!0,o}let sY=(...e)=>{(o||(o=io(sX))).render(...e)},s0=(...e)=>{sZ().hydrate(...e)},s1=(...e)=>{let t=(o||(o=io(sX))).createApp(...e),{mount:n}=t;return t.mount=e=>{let r=s6(e);if(!r)return;let i=t._component;w(i)||i.render||i.template||(i.template=r.innerHTML),r.innerHTML="";let l=n(r,!1,s3(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),l},t},s2=(...e)=>{let t=sZ().createApp(...e),{mount:n}=t;return t.mount=e=>{let t=s6(e);if(t)return n(t,!0,s3(t))},t};function s3(e){return e instanceof SVGElement?"svg":"function"==typeof MathMLElement&&e instanceof MathMLElement?"mathml":void 0}function s6(e){return E(e)?document.querySelector(e):e}let s4=p;var s8=Object.freeze({__proto__:null,BaseTransition:nx,BaseTransitionPropsValidators:n_,Comment:iK,DeprecationTypes:lD,EffectScope:eg,ErrorCodes:tX,ErrorTypeStrings:lM,Fragment:iq,KeepAlive:nL,ReactiveEffect:eS,Static:iz,Suspense:iV,Teleport:r4,Text:iW,TrackOpTypes:tz,Transition:lW,TransitionGroup:sE,TriggerOpTypes:tG,VueElement:s_,assertNumber:tJ,callWithAsyncErrorHandling:tZ,callWithErrorHandling:tQ,camelize:B,capitalize:H,cloneVNode:lt,compatUtils:lV,computed:lw,createApp:s1,createBlock:i2,createCommentVNode:li,createElementBlock:i1,createElementVNode:i9,createHydrationRenderer:ia,createPropsRestProxy:rv,createRenderer:io,createSSRApp:s2,createSlots:n5,createStaticVNode:lr,createTextVNode:ln,createVNode:i7,customRef:tU,defineAsyncComponent:nR,defineComponent:nN,defineCustomElement:sy,defineEmits:rs,defineExpose:ro,defineModel:ru,defineOptions:ra,defineProps:rl,defineSSRCustomElement:sv,defineSlots:rc,devtools:lL,effect:ek,effectScope:ey,getCurrentInstance:lh,getCurrentScope:eb,getTransitionRawChildren:nA,guardReactiveProps:le,h:lE,handleError:tY,hasInjectionContext:rP,hydrate:s0,initCustomFormatter:lA,initDirectivesForSSR:s4,inject:rL,isMemoSame:lI,isProxy:tx,isReactive:tb,isReadonly:t_,isRef:tI,isRuntimeOnly:l_,isShallow:tS,isVNode:i3,markRaw:tT,mergeDefaults:rg,mergeModels:ry,mergeProps:la,nextTick:t7,normalizeClass:er,normalizeProps:ei,normalizeStyle:Z,onActivated:n$,onBeforeMount:nH,onBeforeUnmount:nz,onBeforeUpdate:nW,onDeactivated:nF,onErrorCaptured:nZ,onMounted:nq,onRenderTracked:nQ,onRenderTriggered:nX,onScopeDispose:e_,onServerPrefetch:nJ,onUnmounted:nG,onUpdated:nK,openBlock:iX,popScopeId:nd,provide:rM,proxyRefs:tD,pushScopeId:nu,queuePostFlushCb:nn,reactive:tf,readonly:tg,ref:tR,registerRuntimeCompiler:lb,render:sY,renderList:n8,renderSlot:n9,resolveComponent:n0,resolveDirective:n3,resolveDynamicComponent:n2,resolveFilter:lF,resolveTransitionHooks:nT,setBlockTracking:iY,setDevtoolsHook:lP,setTransitionHooks:nE,shallowReactive:tm,shallowReadonly:ty,shallowRef:tO,ssrContextKey:ig,ssrUtils:l$,stop:ew,toDisplayString:eh,toHandlerKey:q,toHandlers:n7,toRaw:tC,toRef:tW,toRefs:tj,toValue:tF,transformVNodeArgs:i4,triggerRef:tP,unref:t$,useAttrs:rh,useCssModule:sS,useCssVars:st,useModel:iE,useSSRContext:iy,useSlots:rp,useTransitionState:nv,vModelCheckbox:s$,vModelDynamic:sH,vModelRadio:sV,vModelSelect:sD,vModelText:sP,vShow:l9,version:lR,warn:lO,watch:ix,watchEffect:iv,watchPostEffect:ib,watchSyncEffect:i_,withAsyncContext:rb,withCtx:nh,withDefaults:rd,withDirectives:nf,withKeys:sJ,withMemo:lN,withModifiers:sz,withScopeId:np});let s5=Symbol(""),s9=Symbol(""),s7=Symbol(""),oe=Symbol(""),ot=Symbol(""),on=Symbol(""),or=Symbol(""),oi=Symbol(""),ol=Symbol(""),os=Symbol(""),oo=Symbol(""),oa=Symbol(""),oc=Symbol(""),ou=Symbol(""),od=Symbol(""),op=Symbol(""),oh=Symbol(""),of=Symbol(""),om=Symbol(""),og=Symbol(""),oy=Symbol(""),ov=Symbol(""),ob=Symbol(""),o_=Symbol(""),oS=Symbol(""),ox=Symbol(""),oC=Symbol(""),oT=Symbol(""),ok=Symbol(""),ow=Symbol(""),oE=Symbol(""),oA=Symbol(""),oN=Symbol(""),oI=Symbol(""),oR=Symbol(""),oO=Symbol(""),oM=Symbol(""),oL=Symbol(""),oP=Symbol(""),o$={[s5]:"Fragment",[s9]:"Teleport",[s7]:"Suspense",[oe]:"KeepAlive",[ot]:"BaseTransition",[on]:"openBlock",[or]:"createBlock",[oi]:"createElementBlock",[ol]:"createVNode",[os]:"createElementVNode",[oo]:"createCommentVNode",[oa]:"createTextVNode",[oc]:"createStaticVNode",[ou]:"resolveComponent",[od]:"resolveDynamicComponent",[op]:"resolveDirective",[oh]:"resolveFilter",[of]:"withDirectives",[om]:"renderList",[og]:"renderSlot",[oy]:"createSlots",[ov]:"toDisplayString",[ob]:"mergeProps",[o_]:"normalizeClass",[oS]:"normalizeStyle",[ox]:"normalizeProps",[oC]:"guardReactiveProps",[oT]:"toHandlers",[ok]:"camelize",[ow]:"capitalize",[oE]:"toHandlerKey",[oA]:"setBlockTracking",[oN]:"pushScopeId",[oI]:"popScopeId",[oR]:"withCtx",[oO]:"unref",[oM]:"isRef",[oL]:"withMemo",[oP]:"isMemoSame"},oF={start:{line:1,column:1,offset:0},end:{line:1,column:1,offset:0},source:""};function oV(e,t,n,r,i,l,s,o=!1,a=!1,c=!1,u=oF){return e&&(o?(e.helper(on),e.helper(e.inSSR||c?or:oi)):e.helper(e.inSSR||c?ol:os),s&&e.helper(of)),{type:13,tag:t,props:n,children:r,patchFlag:i,dynamicProps:l,directives:s,isBlock:o,disableTracking:a,isComponent:c,loc:u}}function oD(e,t=oF){return{type:17,loc:t,elements:e}}function oB(e,t=oF){return{type:15,loc:t,properties:e}}function oU(e,t){return{type:16,loc:oF,key:E(e)?oj(e,!0):e,value:t}}function oj(e,t=!1,n=oF,r=0){return{type:4,loc:n,content:e,isStatic:t,constType:t?3:r}}function oH(e,t=oF){return{type:8,loc:t,children:e}}function oq(e,t=[],n=oF){return{type:14,loc:n,callee:e,arguments:t}}function oW(e,t,n=!1,r=!1,i=oF){return{type:18,params:e,returns:t,newline:n,isSlot:r,loc:i}}function oK(e,t,n,r=!0){return{type:19,test:e,consequent:t,alternate:n,newline:r,loc:oF}}function oz(e,{helper:t,removeHelper:n,inSSR:r}){if(!e.isBlock){var i,l;e.isBlock=!0,n((i=e.isComponent,r||i?ol:os)),t(on),t((l=e.isComponent,r||l?or:oi))}}let oG=new Uint8Array([123,123]),oJ=new Uint8Array([125,125]);function oX(e){return e>=97&&e<=122||e>=65&&e<=90}function oQ(e){return 32===e||10===e||9===e||12===e||13===e}function oZ(e){return 47===e||62===e||oQ(e)}function oY(e){let t=new Uint8Array(e.length);for(let n=0;n4===e.type&&e.isStatic;function o4(e){switch(e){case"Teleport":case"teleport":return s9;case"Suspense":case"suspense":return s7;case"KeepAlive":case"keep-alive":return oe;case"BaseTransition":case"base-transition":return ot}}let o8=/^\d|[^\$\w\xA0-\uFFFF]/,o5=e=>!o8.test(e),o9=/[A-Za-z_$\xA0-\uFFFF]/,o7=/[\.\?\w$\xA0-\uFFFF]/,ae=/\s+[.[]\s*|\s*[.[]\s+/g,at=e=>4===e.type?e.content:e.loc.source,an=e=>{let t=at(e).trim().replace(ae,e=>e.trim()),n=0,r=[],i=0,l=0,s=null;for(let e=0;e|^\s*(async\s+)?function(?:\s+[\w$]+)?\s*\(/,ai=e=>ar.test(at(e));function al(e,t,n=!1){for(let r=0;r4===e.key.type&&e.key.content===r)}return n}function am(e,t){return`_${t}_${e.replace(/[^\w]/g,(t,n)=>"-"===t?"_":e.charCodeAt(n).toString())}`}let ag=/([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/,ay={parseMode:"base",ns:0,delimiters:["{{","}}"],getNamespace:()=>0,isVoidTag:h,isPreTag:h,isCustomElement:h,onError:o1,onWarn:o2,comments:!1,prefixIdentifiers:!1},av=ay,ab=null,a_="",aS=null,ax=null,aC="",aT=-1,ak=-1,aw=0,aE=!1,aA=null,aN=[],aI=new class{constructor(e,t){this.stack=e,this.cbs=t,this.state=1,this.buffer="",this.sectionStart=0,this.index=0,this.entityStart=0,this.baseState=1,this.inRCDATA=!1,this.inXML=!1,this.inVPre=!1,this.newlines=[],this.mode=0,this.delimiterOpen=oG,this.delimiterClose=oJ,this.delimiterIndex=-1,this.currentSequence=void 0,this.sequenceIndex=0}get inSFCRoot(){return 2===this.mode&&0===this.stack.length}reset(){this.state=1,this.mode=0,this.buffer="",this.sectionStart=0,this.index=0,this.baseState=1,this.inRCDATA=!1,this.currentSequence=void 0,this.newlines.length=0,this.delimiterOpen=oG,this.delimiterClose=oJ}getPos(e){let t=1,n=e+1;for(let r=this.newlines.length-1;r>=0;r--){let i=this.newlines[r];if(e>i){t=r+2,n=e-i;break}}return{column:n,line:t,offset:e}}peek(){return this.buffer.charCodeAt(this.index+1)}stateText(e){60===e?(this.index>this.sectionStart&&this.cbs.ontext(this.sectionStart,this.index),this.state=5,this.sectionStart=this.index):this.inVPre||e!==this.delimiterOpen[0]||(this.state=2,this.delimiterIndex=0,this.stateInterpolationOpen(e))}stateInterpolationOpen(e){if(e===this.delimiterOpen[this.delimiterIndex]){if(this.delimiterIndex===this.delimiterOpen.length-1){let e=this.index+1-this.delimiterOpen.length;e>this.sectionStart&&this.cbs.ontext(this.sectionStart,e),this.state=3,this.sectionStart=e}else this.delimiterIndex++}else this.inRCDATA?(this.state=32,this.stateInRCDATA(e)):(this.state=1,this.stateText(e))}stateInterpolation(e){e===this.delimiterClose[0]&&(this.state=4,this.delimiterIndex=0,this.stateInterpolationClose(e))}stateInterpolationClose(e){e===this.delimiterClose[this.delimiterIndex]?this.delimiterIndex===this.delimiterClose.length-1?(this.cbs.oninterpolation(this.sectionStart,this.index+1),this.inRCDATA?this.state=32:this.state=1,this.sectionStart=this.index+1):this.delimiterIndex++:(this.state=3,this.stateInterpolation(e))}stateSpecialStartSequence(e){let t=this.sequenceIndex===this.currentSequence.length;if(t?oZ(e):(32|e)===this.currentSequence[this.sequenceIndex]){if(!t){this.sequenceIndex++;return}}else this.inRCDATA=!1;this.sequenceIndex=0,this.state=6,this.stateInTagName(e)}stateInRCDATA(e){if(this.sequenceIndex===this.currentSequence.length){if(62===e||oQ(e)){let t=this.index-this.currentSequence.length;if(this.sectionStart=e||(28===this.state?this.currentSequence===o0.CdataEnd?this.cbs.oncdata(this.sectionStart,e):this.cbs.oncomment(this.sectionStart,e):6===this.state||11===this.state||18===this.state||17===this.state||12===this.state||13===this.state||14===this.state||15===this.state||16===this.state||20===this.state||19===this.state||21===this.state||9===this.state||this.cbs.ontext(this.sectionStart,e))}emitCodePoint(e,t){}}(aN,{onerr:aK,ontext(e,t){aP(aM(e,t),e,t)},ontextentity(e,t,n){aP(e,t,n)},oninterpolation(e,t){if(aE)return aP(aM(e,t),e,t);let n=e+aI.delimiterOpen.length,r=t-aI.delimiterClose.length;for(;oQ(a_.charCodeAt(n));)n++;for(;oQ(a_.charCodeAt(r-1));)r--;let i=aM(n,r);i.includes("&")&&(i=av.decodeEntities(i,!1)),aj({type:5,content:aW(i,!1,aH(n,r)),loc:aH(e,t)})},onopentagname(e,t){let n=aM(e,t);aS={type:1,tag:n,ns:av.getNamespace(n,aN[0],av.ns),tagType:0,props:[],children:[],loc:aH(e-1,t),codegenNode:void 0}},onopentagend(e){aL(e)},onclosetag(e,t){let n=aM(e,t);if(!av.isVoidTag(n)){let r=!1;for(let e=0;e0&&aN[0].loc.start.offset;for(let n=0;n<=e;n++)a$(aN.shift(),t,n(7===e.type?e.rawName:e.name)===t)},onattribend(e,t){aS&&ax&&(aq(ax.loc,t),0!==e&&(aC.includes("&")&&(aC=av.decodeEntities(aC,!0)),6===ax.type?("class"===ax.name&&(aC=aU(aC).trim()),ax.value={type:2,content:aC,loc:1===e?aH(aT,ak):aH(aT-1,ak+1)},aI.inSFCRoot&&"template"===aS.tag&&"lang"===ax.name&&aC&&"html"!==aC&&aI.enterRCDATA(oY("{let i=t.start.offset+n,l=i+e.length;return aW(e,!1,aH(i,l),0,r?1:0)},o={source:s(l.trim(),n.indexOf(l,i.length)),value:void 0,key:void 0,index:void 0,finalized:!1},a=i.trim().replace(aO,"").trim(),c=i.indexOf(a),u=a.match(aR);if(u){let e;a=a.replace(aR,"").trim();let t=u[1].trim();if(t&&(e=n.indexOf(t,c+a.length),o.key=s(t,e,!0)),u[2]){let r=u[2].trim();r&&(o.index=s(r,n.indexOf(r,o.key?e+t.length:c+a.length),!0))}}return a&&(o.value=s(a,c,!0)),o}(ax.exp)))),(7!==ax.type||"pre"!==ax.name)&&aS.props.push(ax)),aC="",aT=ak=-1},oncomment(e,t){av.comments&&aj({type:3,content:aM(e,t),loc:aH(e-4,t+3)})},onend(){let e=a_.length;for(let t=0;t64&&n<91||o4(e)||av.isBuiltInComponent&&av.isBuiltInComponent(e)||av.isNativeTag&&!av.isNativeTag(e))return!0;for(let e=0;e=0;)n--;return n}let aV=new Set(["if","else","else-if","for","slot"]),aD=/\r\n/g;function aB(e,t){let n="preserve"!==av.whitespace,r=!1;for(let t=0;t1)for(let i=0;i{n--};for(;nt===e:t=>e.test(t);return(e,r)=>{if(1===e.type){let{props:i}=e;if(3===e.tagType&&i.some(ac))return;let l=[];for(let s=0;s`${o$[e]}: _${o$[e]}`;function a2(e,t,{helper:n,push:r,newline:i,isTS:l}){let s=n("component"===t?ou:op);for(let n=0;n3;t.push("["),n&&t.indent(),a6(e,t,n),n&&t.deindent(),t.push("]")}function a6(e,t,n=!1,r=!0){let{push:i,newline:l}=t;for(let s=0;se||"null")}([s,o,a,n,u]),t),r(")"),p&&r(")"),d&&(r(", "),a4(d,t),r(")"))}(e,t);break;case 14:!function(e,t){let{push:n,helper:r,pure:i}=t,l=E(e.callee)?e.callee:r(e.callee);i&&n(a0),n(l+"(",-2,e),a6(e.arguments,t),n(")")}(e,t);break;case 15:!function(e,t){let{push:n,indent:r,deindent:i,newline:l}=t,{properties:s}=e;if(!s.length){n("{}",-2,e);return}let o=s.length>1;n(o?"{":"{ "),o&&r();for(let e=0;e "),(a||o)&&(n("{"),r()),s?(a&&n("return "),S(s)?a3(s,t):a4(s,t)):o&&a4(o,t),(a||o)&&(i(),n("}")),c&&n(")")}(e,t);break;case 19:!function(e,t){let{test:n,consequent:r,alternate:i,newline:l}=e,{push:s,indent:o,deindent:a,newline:c}=t;if(4===n.type){let e=!o5(n.content);e&&s("("),a8(n,t),e&&s(")")}else s("("),a4(n,t),s(")");l&&o(),t.indentLevel++,l||s(" "),s("? "),a4(r,t),t.indentLevel--,l&&c(),l||s(" "),s(": ");let u=19===i.type;!u&&t.indentLevel++,a4(i,t),!u&&t.indentLevel--,l&&a(!0)}(e,t);break;case 20:!function(e,t){let{push:n,helper:r,indent:i,deindent:l,newline:s}=t;n(`_cache[${e.index}] || (`),e.isVOnce&&(i(),n(`${r(oA)}(-1),`),s(),n("(")),n(`_cache[${e.index}] = `),a4(e.value,t),e.isVOnce&&(n(`).cacheIndex = ${e.index},`),s(),n(`${r(oA)}(1),`),s(),n(`_cache[${e.index}]`),l()),n(")")}(e,t);break;case 21:a6(e.body,t,!0,!1)}}function a8(e,t){let{content:n,isStatic:r}=e;t.push(r?JSON.stringify(n):n,-3,e)}function a5(e,t){for(let n=0;n(function(e,t,n,r){if("else"!==t.name&&(!t.exp||!t.exp.content.trim())){let r=t.exp?t.exp.loc:e.loc;n.onError(o3(28,t.loc)),t.exp=oj("true",!1,r)}if("if"===t.name){let i=a7(e,t),l={type:9,loc:e.loc,branches:[i]};if(n.replaceNode(l),r)return r(l,i,!0)}else{let i=n.parent.children,l=i.indexOf(e);for(;l-- >=-1;){let s=i[l];if(s&&3===s.type||s&&2===s.type&&!s.content.trim().length){n.removeNode(s);continue}if(s&&9===s.type){"else-if"===t.name&&void 0===s.branches[s.branches.length-1].condition&&n.onError(o3(30,e.loc)),n.removeNode();let i=a7(e,t);s.branches.push(i);let l=r&&r(s,i,!1);aZ(i,n),l&&l(),n.currentNode=null}else n.onError(o3(30,e.loc));break}}})(e,t,n,(e,t,r)=>{let i=n.parent.children,l=i.indexOf(e),s=0;for(;l-- >=0;){let e=i[l];e&&9===e.type&&(s+=e.branches.length)}return()=>{r?e.codegenNode=ce(t,s,n):function(e){for(;;)if(19===e.type){if(19!==e.alternate.type)return e;e=e.alternate}else 20===e.type&&(e=e.value)}(e.codegenNode).alternate=ce(t,s+e.branches.length-1,n)}}));function a7(e,t){let n=3===e.tagType;return{type:10,loc:e.loc,condition:"else"===t.name?void 0:t.exp,children:n&&!al(e,"for")?e.children:[e],userKey:as(e,"key"),isTemplateIf:n}}function ce(e,t,n){return e.condition?oK(e.condition,ct(e,t,n),oq(n.helper(oo),['""',"true"])):ct(e,t,n)}function ct(e,t,n){let{helper:r}=n,i=oU("key",oj(`${t}`,!1,oF,2)),{children:l}=e,s=l[0];if(1!==l.length||1!==s.type){if(1!==l.length||11!==s.type)return oV(n,r(s5),oB([i]),l,64,void 0,void 0,!0,!1,!1,e.loc);{let e=s.codegenNode;return ah(e,i,n),e}}{let e=s.codegenNode,t=14===e.type&&e.callee===oL?e.arguments[1].returns:e;return 13===t.type&&oz(t,n),ah(t,i,n),e}}let cn=(e,t,n)=>{let{modifiers:r,loc:i}=e,l=e.arg,{exp:s}=e;if(s&&4===s.type&&!s.content.trim()&&(s=void 0),!s){if(4!==l.type||!l.isStatic)return n.onError(o3(52,l.loc)),{props:[oU(l,oj("",!0,i))]};cr(e),s=e.exp}return 4!==l.type?(l.children.unshift("("),l.children.push(') || ""')):l.isStatic||(l.content=`${l.content} || ""`),r.includes("camel")&&(4===l.type?l.isStatic?l.content=B(l.content):l.content=`${n.helperString(ok)}(${l.content})`:(l.children.unshift(`${n.helperString(ok)}(`),l.children.push(")"))),!n.inSSR&&(r.includes("prop")&&ci(l,"."),r.includes("attr")&&ci(l,"^")),{props:[oU(l,s)]}},cr=(e,t)=>{let n=e.arg,r=B(n.content);e.exp=oj(r,!1,n.loc)},ci=(e,t)=>{4===e.type?e.isStatic?e.content=t+e.content:e.content=`\`${t}\${${e.content}}\``:(e.children.unshift(`'${t}' + (`),e.children.push(")"))},cl=aY("for",(e,t,n)=>{let{helper:r,removeHelper:i}=n;return function(e,t,n,r){if(!t.exp){n.onError(o3(31,t.loc));return}let i=t.forParseResult;if(!i){n.onError(o3(32,t.loc));return}cs(i);let{addIdentifiers:l,removeIdentifiers:s,scopes:o}=n,{source:a,value:c,key:u,index:d}=i,p={type:11,loc:t.loc,source:a,valueAlias:c,keyAlias:u,objectIndexAlias:d,parseResult:i,children:au(e)?e.children:[e]};n.replaceNode(p),o.vFor++;let h=r&&r(p);return()=>{o.vFor--,h&&h()}}(e,t,n,t=>{let l=oq(r(om),[t.source]),s=au(e),o=al(e,"memo"),a=as(e,"key",!1,!0);a&&7===a.type&&!a.exp&&cr(a);let c=a&&(6===a.type?a.value?oj(a.value.content,!0):void 0:a.exp),u=a&&c?oU("key",c):null,d=4===t.source.type&&t.source.constType>0,p=d?64:a?128:256;return t.codegenNode=oV(n,r(s5),void 0,l,p,void 0,void 0,!0,!d,!1,e.loc),()=>{let a;let{children:p}=t,h=1!==p.length||1!==p[0].type,f=ad(e)?e:s&&1===e.children.length&&ad(e.children[0])?e.children[0]:null;if(f)a=f.codegenNode,s&&u&&ah(a,u,n);else if(h)a=oV(n,r(s5),u?oB([u]):void 0,e.children,64,void 0,void 0,!0,void 0,!1);else{var m,g,y,b,_,S,x,C;a=p[0].codegenNode,s&&u&&ah(a,u,n),!d!==a.isBlock&&(a.isBlock?(i(on),i((m=n.inSSR,g=a.isComponent,m||g?or:oi))):i((y=n.inSSR,b=a.isComponent,y||b?ol:os))),(a.isBlock=!d,a.isBlock)?(r(on),r((_=n.inSSR,S=a.isComponent,_||S?or:oi))):r((x=n.inSSR,C=a.isComponent,x||C?ol:os))}if(o){let e=oW(co(t.parseResult,[oj("_cached")]));e.body={type:21,body:[oH(["const _memo = (",o.exp,")"]),oH(["if (_cached",...c?[" && _cached.key === ",c]:[],` && ${n.helperString(oP)}(_cached, _memo)) return _cached`]),oH(["const _item = ",a]),oj("_item.memo = _memo"),oj("return _item")],loc:oF},l.arguments.push(e,oj("_cache"),oj(String(n.cached++)))}else l.arguments.push(oW(co(t.parseResult),a,!0))}})});function cs(e,t){e.finalized||(e.finalized=!0)}function co({value:e,key:t,index:n},r=[]){return function(e){let t=e.length;for(;t--&&!e[t];);return e.slice(0,t+1).map((e,t)=>e||oj("_".repeat(t+1),!1))}([e,t,n,...r])}let ca=oj("undefined",!1),cc=(e,t)=>{if(1===e.type&&(1===e.tagType||3===e.tagType)){let n=al(e,"slot");if(n)return n.exp,t.scopes.vSlot++,()=>{t.scopes.vSlot--}}},cu=(e,t,n,r)=>oW(e,n,!1,!0,n.length?n[0].loc:r);function cd(e,t,n){let r=[oU("name",e),oU("fn",t)];return null!=n&&r.push(oU("key",oj(String(n),!0))),oB(r)}let cp=new WeakMap,ch=(e,t)=>function(){let n,r,i,l,s;if(!(1===(e=t.currentNode).type&&(0===e.tagType||1===e.tagType)))return;let{tag:o,props:a}=e,c=1===e.tagType,u=c?function(e,t,n=!1){let{tag:r}=e,i=cg(r),l=as(e,"is",!1,!0);if(l){if(i){let e;if(6===l.type?e=l.value&&oj(l.value.content,!0):(e=l.exp)||(e=oj("is",!1,l.arg.loc)),e)return oq(t.helper(od),[e])}else 6===l.type&&l.value.content.startsWith("vue:")&&(r=l.value.content.slice(4))}let s=o4(r)||t.isBuiltInComponent(r);return s?(n||t.helper(s),s):(t.helper(ou),t.components.add(r),am(r,"component"))}(e,t):`"${o}"`,d=N(u)&&u.callee===od,p=0,h=d||u===s9||u===s7||!c&&("svg"===o||"foreignObject"===o||"math"===o);if(a.length>0){let r=cf(e,t,void 0,c,d);n=r.props,p=r.patchFlag,l=r.dynamicPropNames;let i=r.directives;s=i&&i.length?oD(i.map(e=>(function(e,t){let n=[],r=cp.get(e);r?n.push(t.helperString(r)):(t.helper(op),t.directives.add(e.name),n.push(am(e.name,"directive")));let{loc:i}=e;if(e.exp&&n.push(e.exp),e.arg&&(e.exp||n.push("void 0"),n.push(e.arg)),Object.keys(e.modifiers).length){e.arg||(e.exp||n.push("void 0"),n.push("void 0"));let t=oj("true",!1,i);n.push(oB(e.modifiers.map(e=>oU(e,t)),i))}return oD(n,e.loc)})(e,t))):void 0,r.shouldUseBlock&&(h=!0)}if(e.children.length>0){if(u===oe&&(h=!0,p|=1024),c&&u!==s9&&u!==oe){let{slots:n,hasDynamicSlots:i}=function(e,t,n=cu){t.helper(oR);let{children:r,loc:i}=e,l=[],s=[],o=t.scopes.vSlot>0||t.scopes.vFor>0,a=al(e,"slot",!0);if(a){let{arg:e,exp:t}=a;e&&!o6(e)&&(o=!0),l.push(oU(e||oj("default",!0),n(t,void 0,r,i)))}let c=!1,u=!1,d=[],p=new Set,h=0;for(let e=0;eoU("default",n(e,void 0,t,i));c?d.length&&d.some(e=>(function e(t){return 2!==t.type&&12!==t.type||(2===t.type?!!t.content.trim():e(t.content))})(e))&&(u?t.onError(o3(39,d[0].loc)):l.push(e(void 0,d))):l.push(e(void 0,r))}let f=o?2:!function e(t){for(let n=0;n0,m=!1,g=0,y=!1,b=!1,_=!1,S=!1,x=!1,C=!1,T=[],k=e=>{u.length&&(d.push(oB(cm(u),a)),u=[]),e&&d.push(e)},w=()=>{t.scopes.vFor>0&&u.push(oU(oj("ref_for",!0),oj("true")))},E=({key:e,value:n})=>{if(o6(e)){let l=e.content,s=f(l);s&&(!r||i)&&"onclick"!==l.toLowerCase()&&"onUpdate:modelValue"!==l&&!$(l)&&(S=!0),s&&$(l)&&(C=!0),s&&14===n.type&&(n=n.arguments[0]),20===n.type||(4===n.type||8===n.type)&&aG(n,t)>0||("ref"===l?y=!0:"class"===l?b=!0:"style"===l?_=!0:"key"===l||T.includes(l)||T.push(l),r&&("class"===l||"style"===l)&&!T.includes(l)&&T.push(l))}else x=!0};for(let i=0;i1?oq(t.helper(ob),d,a):d[0]):u.length&&(s=oB(cm(u),a)),x?g|=16:(b&&!r&&(g|=2),_&&!r&&(g|=4),T.length&&(g|=8),S&&(g|=32)),!m&&(0===g||32===g)&&(y||C||p.length>0)&&(g|=512),!t.inSSR&&s)switch(s.type){case 15:let N=-1,I=-1,R=!1;for(let e=0;e{if(ad(e)){let{children:n,loc:r}=e,{slotName:i,slotProps:l}=function(e,t){let n,r='"default"',i=[];for(let t=0;t0){let{props:r,directives:l}=cf(e,t,i,!1,!1);n=r,l.length&&t.onError(o3(36,l[0].loc))}return{slotName:r,slotProps:n}}(e,t),s=[t.prefixIdentifiers?"_ctx.$slots":"$slots",i,"{}","undefined","true"],o=2;l&&(s[2]=l,o=3),n.length&&(s[3]=oW([],n,!1,!1,r),o=4),t.scopeId&&!t.slotted&&(o=5),s.splice(o),e.codegenNode=oq(t.helper(og),s,r)}},cv=(e,t,n,r)=>{let i;let{loc:l,modifiers:s,arg:o}=e;if(e.exp||s.length,4===o.type){if(o.isStatic){let e=o.content;e.startsWith("vue:")&&(e=`vnode-${e.slice(4)}`),i=oj(0!==t.tagType||e.startsWith("vnode")||!/[A-Z]/.test(e)?q(B(e)):`on:${e}`,!0,o.loc)}else i=oH([`${n.helperString(oE)}(`,o,")"])}else(i=o).children.unshift(`${n.helperString(oE)}(`),i.children.push(")");let a=e.exp;a&&!a.content.trim()&&(a=void 0);let c=n.cacheHandlers&&!a&&!n.inVOnce;if(a){let e=an(a),t=!(e||ai(a)),n=a.content.includes(";");(t||c&&e)&&(a=oH([`${t?"$event":"(...args)"} => ${n?"{":"("}`,a,n?"}":")"]))}let u={props:[oU(i,a||oj("() => {}",!1,l))]};return r&&(u=r(u)),c&&(u.props[0].value=n.cache(u.props[0].value)),u.props.forEach(e=>e.key.isHandlerKey=!0),u},cb=(e,t)=>{if(0===e.type||1===e.type||11===e.type||10===e.type)return()=>{let n;let r=e.children,i=!1;for(let e=0;e7===e.type&&!t.directiveTransforms[e.name]))))for(let e=0;e{if(1===e.type&&al(e,"once",!0)&&!c_.has(e)&&!t.inVOnce&&!t.inSSR)return c_.add(e),t.inVOnce=!0,t.helper(oA),()=>{t.inVOnce=!1;let e=t.currentNode;e.codegenNode&&(e.codegenNode=t.cache(e.codegenNode,!0))}},cx=(e,t,n)=>{let r;let{exp:i,arg:l}=e;if(!i)return n.onError(o3(41,e.loc)),cC();let s=i.loc.source,o=4===i.type?i.content:s,a=n.bindingMetadata[s];if("props"===a||"props-aliased"===a)return i.loc,cC();if(!o.trim()||!an(i))return n.onError(o3(42,i.loc)),cC();let c=l||oj("modelValue",!0),u=l?o6(l)?`onUpdate:${B(l.content)}`:oH(['"onUpdate:" + ',l]):"onUpdate:modelValue",d=n.isTS?"($event: any)":"$event";r=oH([`${d} => ((`,i,") = $event)"]);let p=[oU(c,e.exp),oU(u,r)];if(e.modifiers.length&&1===t.tagType){let t=e.modifiers.map(e=>(o5(e)?e:JSON.stringify(e))+": true").join(", "),n=l?o6(l)?`${l.content}Modifiers`:oH([l,' + "Modifiers"']):"modelModifiers";p.push(oU(n,oj(`{ ${t} }`,!1,e.loc,2)))}return cC(p)};function cC(e=[]){return{props:e}}let cT=new WeakSet,ck=(e,t)=>{if(1===e.type){let n=al(e,"memo");if(!(!n||cT.has(e)))return cT.add(e),()=>{let r=e.codegenNode||t.currentNode.codegenNode;r&&13===r.type&&(1!==e.tagType&&oz(r,t),e.codegenNode=oq(t.helper(oL),[n.exp,oW(void 0,r),"_cache",String(t.cached++)]))}}},cw=Symbol(""),cE=Symbol(""),cA=Symbol(""),cN=Symbol(""),cI=Symbol(""),cR=Symbol(""),cO=Symbol(""),cM=Symbol(""),cL=Symbol(""),cP=Symbol("");!function(e){Object.getOwnPropertySymbols(e).forEach(t=>{o$[t]=e[t]})}({[cw]:"vModelRadio",[cE]:"vModelCheckbox",[cA]:"vModelText",[cN]:"vModelSelect",[cI]:"vModelDynamic",[cR]:"withModifiers",[cO]:"withKeys",[cM]:"vShow",[cL]:"Transition",[cP]:"TransitionGroup"});let c$={parseMode:"html",isVoidTag:ea,isNativeTag:e=>el(e)||es(e)||eo(e),isPreTag:e=>"pre"===e,decodeEntities:function(e,t=!1){return(a||(a=document.createElement("div")),t)?(a.innerHTML=`
`,a.children[0].getAttribute("foo")):(a.innerHTML=e,a.textContent)},isBuiltInComponent:e=>"Transition"===e||"transition"===e?cL:"TransitionGroup"===e||"transition-group"===e?cP:void 0,getNamespace(e,t,n){let r=t?t.ns:n;if(t&&2===r){if("annotation-xml"===t.tag){if("svg"===e)return 1;t.props.some(e=>6===e.type&&"encoding"===e.name&&null!=e.value&&("text/html"===e.value.content||"application/xhtml+xml"===e.value.content))&&(r=0)}else/^m(?:[ions]|text)$/.test(t.tag)&&"mglyph"!==e&&"malignmark"!==e&&(r=0)}else t&&1===r&&("foreignObject"===t.tag||"desc"===t.tag||"title"===t.tag)&&(r=0);if(0===r){if("svg"===e)return 1;if("math"===e)return 2}return r}},cF=(e,t)=>oj(JSON.stringify(en(e)),!1,t,3),cV=c("passive,once,capture"),cD=c("stop,prevent,self,ctrl,shift,alt,meta,exact,middle"),cB=c("left,right"),cU=c("onkeyup,onkeydown,onkeypress",!0),cj=(e,t,n,r)=>{let i=[],l=[],s=[];for(let n=0;no6(e)&&"onclick"===e.content.toLowerCase()?oj(t,!0):4!==e.type?oH(["(",e,`) === "onClick" ? "${t}" : (`,e,")"]):e,cq=(e,t)=>{1===e.type&&0===e.tagType&&("script"===e.tag||"style"===e.tag)&&t.removeNode()},cW=[e=>{1===e.type&&e.props.forEach((t,n)=>{6===t.type&&"style"===t.name&&t.value&&(e.props[n]={type:7,name:"bind",arg:oj("style",!0,t.loc),exp:cF(t.value.content,t.loc),modifiers:[],loc:t.loc})})}],cK={cloak:()=>({props:[]}),html:(e,t,n)=>{let{exp:r,loc:i}=e;return r||n.onError(o3(53,i)),t.children.length&&(n.onError(o3(54,i)),t.children.length=0),{props:[oU(oj("innerHTML",!0,i),r||oj("",!0))]}},text:(e,t,n)=>{let{exp:r,loc:i}=e;return r||n.onError(o3(55,i)),t.children.length&&(n.onError(o3(56,i)),t.children.length=0),{props:[oU(oj("textContent",!0),r?aG(r,n)>0?r:oq(n.helperString(ov),[r],i):oj("",!0))]}},model:(e,t,n)=>{let r=cx(e,t,n);if(!r.props.length||1===t.tagType)return r;e.arg&&n.onError(o3(58,e.arg.loc));let{tag:i}=t,l=n.isCustomElement(i);if("input"===i||"textarea"===i||"select"===i||l){let s=cA,o=!1;if("input"===i||l){let r=as(t,"type");if(r){if(7===r.type)s=cI;else if(r.value)switch(r.value.content){case"radio":s=cw;break;case"checkbox":s=cE;break;case"file":o=!0,n.onError(o3(59,e.loc))}}else t.props.some(e=>7===e.type&&"bind"===e.name&&(!e.arg||4!==e.arg.type||!e.arg.isStatic))&&(s=cI)}else"select"===i&&(s=cN);o||(r.needRuntime=n.helper(s))}else n.onError(o3(57,e.loc));return r.props=r.props.filter(e=>!(4===e.key.type&&"modelValue"===e.key.content)),r},on:(e,t,n)=>cv(e,t,n,t=>{let{modifiers:r}=e;if(!r.length)return t;let{key:i,value:l}=t.props[0],{keyModifiers:s,nonKeyModifiers:o,eventOptionModifiers:a}=cj(i,r,n,e.loc);if(o.includes("right")&&(i=cH(i,"onContextmenu")),o.includes("middle")&&(i=cH(i,"onMouseup")),o.length&&(l=oq(n.helper(cR),[l,JSON.stringify(o)])),s.length&&(!o6(i)||cU(i.content))&&(l=oq(n.helper(cO),[l,JSON.stringify(s)])),a.length){let e=a.map(H).join("");i=o6(i)?oj(`${i.content}${e}`,!0):oH(["(",i,`) + "${e}"`])}return{props:[oU(i,l)]}}),show:(e,t,n)=>{let{exp:r,loc:i}=e;return!r&&n.onError(o3(61,i)),{props:[],needRuntime:n.helper(cM)}}},cz=new WeakMap;function cG(e,t){let n;if(!E(e)){if(!e.nodeType)return p;e=e.innerHTML}let r=e,i=((n=cz.get(null!=t?t:u))||(n=Object.create(null),cz.set(null!=t?t:u,n)),n),l=i[r];if(l)return l;if("#"===e[0]){let t=document.querySelector(e);e=t?t.innerHTML:""}let s=g({hoistStatic:!0,onError:void 0,onWarn:p},t);s.isCustomElement||"undefined"==typeof customElements||(s.isCustomElement=e=>!!customElements.get(e));let{code:o}=function(e,t={}){return function(e,t={}){let n=t.onError||o1,r="module"===t.mode;!0===t.prefixIdentifiers?n(o3(47)):r&&n(o3(48)),t.cacheHandlers&&n(o3(49)),t.scopeId&&!r&&n(o3(50));let i=g({},t,{prefixIdentifiers:!1}),l=E(e)?function(e,t){if(aI.reset(),aS=null,ax=null,aC="",aT=-1,ak=-1,aN.length=0,a_=e,av=g({},ay),t){let e;for(e in t)null!=t[e]&&(av[e]=t[e])}aI.mode="html"===av.parseMode?1:"sfc"===av.parseMode?2:0,aI.inXML=1===av.ns||2===av.ns;let n=t&&t.delimiters;n&&(aI.delimiterOpen=oY(n[0]),aI.delimiterClose=oY(n[1]));let r=ab=function(e,t=""){return{type:0,source:t,children:e,helpers:new Set,components:[],directives:[],hoists:[],imports:[],cached:0,temps:0,codegenNode:void 0,loc:oF}}([],e);return aI.parse(a_),r.loc=aH(0,e.length),r.children=aB(r.children),ab=null,r}(e,i):e,[s,o]=[[cS,a9,ck,cl,cy,ch,cc,cb],{on:cv,bind:cn,model:cx}];return!function(e,t){let n=function(e,{filename:t="",prefixIdentifiers:n=!1,hoistStatic:r=!1,hmr:i=!1,cacheHandlers:l=!1,nodeTransforms:s=[],directiveTransforms:o={},transformHoist:a=null,isBuiltInComponent:c=p,isCustomElement:d=p,expressionPlugins:h=[],scopeId:f=null,slotted:m=!0,ssr:g=!1,inSSR:y=!1,ssrCssVars:b="",bindingMetadata:_=u,inline:S=!1,isTS:x=!1,onError:C=o1,onWarn:T=o2,compatConfig:k}){let w=t.replace(/\?.*$/,"").match(/([^/\\]+)\.\w+$/),A={filename:t,selfName:w&&H(B(w[1])),prefixIdentifiers:n,hoistStatic:r,hmr:i,cacheHandlers:l,nodeTransforms:s,directiveTransforms:o,transformHoist:a,isBuiltInComponent:c,isCustomElement:d,expressionPlugins:h,scopeId:f,slotted:m,ssr:g,inSSR:y,ssrCssVars:b,bindingMetadata:_,inline:S,isTS:x,onError:C,onWarn:T,compatConfig:k,root:e,helpers:new Map,components:new Set,directives:new Set,hoists:[],imports:[],constantCache:new WeakMap,temps:0,cached:0,identifiers:Object.create(null),scopes:{vFor:0,vSlot:0,vPre:0,vOnce:0},parent:null,grandParent:null,currentNode:e,childIndex:0,inVOnce:!1,helper(e){let t=A.helpers.get(e)||0;return A.helpers.set(e,t+1),e},removeHelper(e){let t=A.helpers.get(e);if(t){let n=t-1;n?A.helpers.set(e,n):A.helpers.delete(e)}},helperString:e=>`_${o$[A.helper(e)]}`,replaceNode(e){A.parent.children[A.childIndex]=A.currentNode=e},removeNode(e){let t=A.parent.children,n=e?t.indexOf(e):A.currentNode?A.childIndex:-1;e&&e!==A.currentNode?A.childIndex>n&&(A.childIndex--,A.onNodeRemoved()):(A.currentNode=null,A.onNodeRemoved()),A.parent.children.splice(n,1)},onNodeRemoved:p,addIdentifiers(e){},removeIdentifiers(e){},hoist(e){E(e)&&(e=oj(e)),A.hoists.push(e);let t=oj(`_hoisted_${A.hoists.length}`,!1,e.loc,2);return t.hoisted=e,t},cache:(e,t=!1)=>(function(e,t,n=!1){return{type:20,index:e,value:t,isVOnce:n,loc:oF}})(A.cached++,e,t)};return A}(e,t);aZ(e,n),t.hoistStatic&&function e(t,n,r=!1){let{children:i}=t,l=i.length,s=0;for(let t=0;t0){if(e>=2){l.codegenNode.patchFlag=-1,l.codegenNode=n.hoist(l.codegenNode),s++;continue}}else{let e=l.codegenNode;if(13===e.type){let t=e.patchFlag;if((void 0===t||512===t||1===t)&&aX(l,n)>=2){let t=aQ(l);t&&(e.props=n.hoist(t))}e.dynamicProps&&(e.dynamicProps=n.hoist(e.dynamicProps))}}}if(1===l.type){let t=1===l.tagType;t&&n.scopes.vSlot++,e(l,n),t&&n.scopes.vSlot--}else if(11===l.type)e(l,n,1===l.children.length);else if(9===l.type)for(let t=0;t1&&(e.codegenNode=oV(t,n(s5),void 0,e.children,64,void 0,void 0,!0,void 0,!1))}(e,n),e.helpers=new Set([...n.helpers.keys()]),e.components=[...n.components],e.directives=[...n.directives],e.imports=n.imports,e.hoists=n.hoists,e.temps=n.temps,e.cached=n.cached,e.transformed=!0}(l,g({},i,{nodeTransforms:[...s,...t.nodeTransforms||[]],directiveTransforms:g({},o,t.directiveTransforms||{})})),function(e,t={}){let n=function(e,{mode:t="function",prefixIdentifiers:n="module"===t,sourceMap:r=!1,filename:i="template.vue.html",scopeId:l=null,optimizeImports:s=!1,runtimeGlobalName:o="Vue",runtimeModuleName:a="vue",ssrRuntimeModuleName:c="vue/server-renderer",ssr:u=!1,isTS:d=!1,inSSR:p=!1}){let h={mode:t,prefixIdentifiers:n,sourceMap:r,filename:i,scopeId:l,optimizeImports:s,runtimeGlobalName:o,runtimeModuleName:a,ssrRuntimeModuleName:c,ssr:u,isTS:d,inSSR:p,source:e.source,code:"",column:1,line:1,offset:0,indentLevel:0,pure:!1,map:void 0,helper:e=>`_${o$[e]}`,push(e,t=-2,n){h.code+=e},indent(){f(++h.indentLevel)},deindent(e=!1){e?--h.indentLevel:f(--h.indentLevel)},newline(){f(h.indentLevel)}};function f(e){h.push("\n"+" ".repeat(e),0)}return h}(e,t);t.onContextCreated&&t.onContextCreated(n);let{mode:r,push:i,prefixIdentifiers:l,indent:s,deindent:o,newline:a,scopeId:c,ssr:u}=n,d=Array.from(e.helpers),p=d.length>0,h=!l&&"module"!==r;(function(e,t){let{ssr:n,prefixIdentifiers:r,push:i,newline:l,runtimeModuleName:s,runtimeGlobalName:o,ssrRuntimeModuleName:a}=t,c=Array.from(e.helpers);if(c.length>0&&(i(`const _Vue = ${o} +`,-1),e.hoists.length)){let e=[ol,os,oo,oa,oc].filter(e=>c.includes(e)).map(a1).join(", ");i(`const { ${e} } = _Vue +`,-1)}(function(e,t){if(!e.length)return;t.pure=!0;let{push:n,newline:r,helper:i,scopeId:l,mode:s}=t;r();for(let i=0;i0)&&a()),e.directives.length&&(a2(e.directives,"directive",n),e.temps>0&&a()),e.temps>0){i("let ");for(let t=0;t0?", ":""}_temp${t}`)}return(e.components.length||e.directives.length||e.temps)&&(i(` +`,0),a()),u||i("return "),e.codegenNode?a4(e.codegenNode,n):i("null"),h&&(o(),i("}")),o(),i("}"),{ast:e,code:n.code,preamble:"",map:n.map?n.map.toJSON():void 0}}(l,i)}(e,g({},c$,t,{nodeTransforms:[cq,...cW,...t.nodeTransforms||[]],directiveTransforms:g({},cK,t.directiveTransforms||{}),transformHoist:null}))}(e,s),a=Function("Vue",o)(s8);return a._rc=!0,i[r]=a}lb(cG);export{nx as BaseTransition,n_ as BaseTransitionPropsValidators,iK as Comment,lD as DeprecationTypes,eg as EffectScope,tX as ErrorCodes,lM as ErrorTypeStrings,iq as Fragment,nL as KeepAlive,eS as ReactiveEffect,iz as Static,iV as Suspense,r4 as Teleport,iW as Text,tz as TrackOpTypes,lW as Transition,sE as TransitionGroup,tG as TriggerOpTypes,s_ as VueElement,tJ as assertNumber,tZ as callWithAsyncErrorHandling,tQ as callWithErrorHandling,B as camelize,H as capitalize,lt as cloneVNode,lV as compatUtils,cG as compile,lw as computed,s1 as createApp,i2 as createBlock,li as createCommentVNode,i1 as createElementBlock,i9 as createElementVNode,ia as createHydrationRenderer,rv as createPropsRestProxy,io as createRenderer,s2 as createSSRApp,n5 as createSlots,lr as createStaticVNode,ln as createTextVNode,i7 as createVNode,tU as customRef,nR as defineAsyncComponent,nN as defineComponent,sy as defineCustomElement,rs as defineEmits,ro as defineExpose,ru as defineModel,ra as defineOptions,rl as defineProps,sv as defineSSRCustomElement,rc as defineSlots,lL as devtools,ek as effect,ey as effectScope,lh as getCurrentInstance,eb as getCurrentScope,nA as getTransitionRawChildren,le as guardReactiveProps,lE as h,tY as handleError,rP as hasInjectionContext,s0 as hydrate,lA as initCustomFormatter,s4 as initDirectivesForSSR,rL as inject,lI as isMemoSame,tx as isProxy,tb as isReactive,t_ as isReadonly,tI as isRef,l_ as isRuntimeOnly,tS as isShallow,i3 as isVNode,tT as markRaw,rg as mergeDefaults,ry as mergeModels,la as mergeProps,t7 as nextTick,er as normalizeClass,ei as normalizeProps,Z as normalizeStyle,n$ as onActivated,nH as onBeforeMount,nz as onBeforeUnmount,nW as onBeforeUpdate,nF as onDeactivated,nZ as onErrorCaptured,nq as onMounted,nQ as onRenderTracked,nX as onRenderTriggered,e_ as onScopeDispose,nJ as onServerPrefetch,nG as onUnmounted,nK as onUpdated,iX as openBlock,nd as popScopeId,rM as provide,tD as proxyRefs,nu as pushScopeId,nn as queuePostFlushCb,tf as reactive,tg as readonly,tR as ref,lb as registerRuntimeCompiler,sY as render,n8 as renderList,n9 as renderSlot,n0 as resolveComponent,n3 as resolveDirective,n2 as resolveDynamicComponent,lF as resolveFilter,nT as resolveTransitionHooks,iY as setBlockTracking,lP as setDevtoolsHook,nE as setTransitionHooks,tm as shallowReactive,ty as shallowReadonly,tO as shallowRef,ig as ssrContextKey,l$ as ssrUtils,ew as stop,eh as toDisplayString,q as toHandlerKey,n7 as toHandlers,tC as toRaw,tW as toRef,tj as toRefs,tF as toValue,i4 as transformVNodeArgs,tP as triggerRef,t$ as unref,rh as useAttrs,sS as useCssModule,st as useCssVars,iE as useModel,iy as useSSRContext,rp as useSlots,nv as useTransitionState,s$ as vModelCheckbox,sH as vModelDynamic,sV as vModelRadio,sD as vModelSelect,sP as vModelText,l9 as vShow,lR as version,lO as warn,ix as watch,iv as watchEffect,ib as watchPostEffect,i_ as watchSyncEffect,rb as withAsyncContext,nh as withCtx,rd as withDefaults,nf as withDirectives,sJ as withKeys,lN as withMemo,sz as withModifiers,np as withScopeId}; diff --git a/loop_memory/serve/static/js/main.js b/loop_memory/serve/static/js/main.js index d03941d..9facd47 100644 --- a/loop_memory/serve/static/js/main.js +++ b/loop_memory/serve/static/js/main.js @@ -8,7 +8,7 @@ * If a future iteration wants SFC + HMR, swap the CDN for the Vite * dev server; the component sources are already structured to fit. */ -import { createApp } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { createApp } from './lib/vue.esm-browser.prod.js'; import { App } from './App.js'; const root = document.getElementById('app'); diff --git a/loop_memory/serve/static/js/store.js b/loop_memory/serve/static/js/store.js index cb8435e..2005622 100644 --- a/loop_memory/serve/static/js/store.js +++ b/loop_memory/serve/static/js/store.js @@ -5,7 +5,7 @@ * a plain reactive() object is easier to maintain than a state library. * Components import { store, persistPrefs } and read / mutate. */ -import { reactive, watch, computed } from 'https://unpkg.com/vue@3.4.38/dist/vue.esm-browser.prod.js'; +import { reactive, watch, computed } from './lib/vue.esm-browser.prod.js'; const STORE_KEY = 'loop_memory_prefs_v1'; @@ -220,6 +220,31 @@ export function escapeHtml(s) { .replace(/"/g, '"').replace(/'/g, '''); } +// ---- HTML sanitizer for v-html (XSS protection) ---- +// Minimal allowlist sanitizer — only permits safe Markdown-rendered tags. +// Strips all event handlers, javascript: URLs, and dangerous attributes. +const _SANITIZE_RE = /<(\/?)(script|style|iframe|object|embed|form|input|button|select|textarea|a\s+[^>]*href\s*=\s*["']?javascript:[^>]*|svg\s+[^>]*>|math\s+[^>]*>)|(\s+on\w+\s*=|javascript:)|(<\/?[a-z][^>]*\s+[^>]*>)/gi; + +export function sanitizeHtml(dirty) { + if (dirty == null) return ''; + let s = String(dirty); + // Step 1: Remove script, style, iframe, object, embed, form tags entirely + s = s.replace(/]*>[\s\S]*?<\/script>/gi, ''); + s = s.replace(/]*>[\s\S]*?<\/style>/gi, ''); + s = s.replace(/]*>[\s\S]*?<\/iframe>/gi, ''); + s = s.replace(/]*>[\s\S]*?<\/object>/gi, ''); + s = s.replace(/]*>/gi, ''); + s = s.replace(/]*>[\s\S]*?<\/form>/gi, ''); + // Step 2: Remove on* event handlers and javascript: URLs from tags + s = s.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, ''); + s = s.replace(/href\s*=\s*["']?\s*javascript:[^"'\s>]+/gi, 'href="#"'); + // Step 3: Remove svg/math elements with event handlers (common XSS vector) + s = s.replace(/<(svg|math)[^>]*>[\s\S]*?<\/\1>/gi, ''); + // Step 4: Remove data: URLs except for safe image types + s = s.replace(/src\s*=\s*["']?\s*data:(?!image\/(png|jpeg|jpg|gif|webp)):[^"'\s>]+/gi, 'src="#"'); + return s; +} + export function timeAgo(ts) { if (!ts) return '—'; const d = Date.now() / 1000 - Number(ts); From 66a7cce50f970cb30677a295304fe1b9a7cb00e5 Mon Sep 17 00:00:00 2001 From: smartfind <44515814+smartfind@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:51:53 +0800 Subject: [PATCH 65/88] docs: add development plan for security hardening + cleanup Co-Authored-By: Claude --- .gitignore | 1 + docs/development-plan.md | 179 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 docs/development-plan.md diff --git a/.gitignore b/.gitignore index f228bf1..8066574 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ secrets.json .loop_memory/ credentials.json work/ +.worktrees diff --git a/docs/development-plan.md b/docs/development-plan.md new file mode 100644 index 0000000..08494fe --- /dev/null +++ b/docs/development-plan.md @@ -0,0 +1,179 @@ +# Loop Memory 开发计划 + +> 生成日期:2026-07-25 +> 基于代码审查结果 + +--- + +## 一、安全问题(高优先级) + +### 1.1 前端认证缺失 [高危] + +**问题描述:** +前端暴露的所有管理端点(安装钩子、运行 LLM 任务、保存 API Key、重建图谱、删除/合并 Wiki 页面等)均未携带认证令牌或 CSRF Token。 + +**受影响文件:** +- [js/api.js:119-164](loop_memory/serve/static/js/api.js#L119-L164) +- [js/components/Diagnostic.js:41-67](loop_memory/serve/static/js/components/Diagnostic.js#L41-L67) +- [js/components/Wiki.js:148-216](loop_memory/serve/static/js/components/Wiki.js#L148-L216) +- [js/components/Settings.js:333-375](loop_memory/serve/static/js/components/Settings.js#L333-L375) + +**风险:** +- 如果服务绑定到非本地地址,任何网站都可以触发管理操作 +- 恶意网页可诱导用户浏览器发起删除/修改请求 + +**修复方案:** +1. 后端:为所有 `/api/admin/*`、import、delete、merge、hook 安装路由添加认证 +2. 前端:在 `fetchJSON` 中添加认证 Header(如 Bearer Token) +3. 如使用 Cookie 认证:添加 CSRF 保护 +4. 默认限制服务只能从 loopback 访问 + +--- + +### 1.2 v-html XSS 风险 [中危] + +**问题描述:** +Dashboard 组件使用 `v-html` 渲染 Markdown 内容: +- [js/components/Dashboard.js:1312](loop_memory/serve/static/js/components/Dashboard.js#L1312) + +当前实现使用自定义 Markdown 渲染器,已做防御性转义,但不是用标准安全库。 + +**修复方案:** +1. 首选:使用 DOMPurify 对生成的 HTML 进行清理 +2. 或者:改用 Vue 文本节点渲染 Markdown +3. 添加回归测试用例:``, `javascript:` URLs 等 + +--- + +### 1.3 i18n JSON 注入 [中危] + +**问题描述:** +服务端注入 i18n JSON 时仅替换 `` 字符串: +- [serve/app.py:149-163](loop_memory/serve/app.py#L149-L163) + +未处理大小写变体和 HTML 解析边缘情况。 + +**修复方案:** +1. 使用 HTML 安全序列化器转义 `<`, `>`, `&`, `/` +2. 或者:将字典作为常规 JSON 资源提供,不注入到 HTML +3. 使用框架专用序列化器 + +--- + +### 1.4 外部 CDN 依赖无完整性保护 [低危] + +**问题描述:** +Vue 从 unpkg CDN 加载,ES Module 方式无法使用 `` 字符串: -- [serve/app.py:149-163](loop_memory/serve/app.py#L149-L163) - -未处理大小写变体和 HTML 解析边缘情况。 - -**修复方案:** -1. 使用 HTML 安全序列化器转义 `<`, `>`, `&`, `/` -2. 或者:将字典作为常规 JSON 资源提供,不注入到 HTML -3. 使用框架专用序列化器 - ---- - -### 1.4 外部 CDN 依赖无完整性保护 [低危] - -**问题描述:** -Vue 从 unpkg CDN 加载,ES Module 方式无法使用 `