feat: turn memory cloud into llm memory hub

This commit is contained in:
OpenClaw
2026-05-12 13:20:21 +02:00
parent e4195fdde2
commit aad90630b1
3 changed files with 1042 additions and 79 deletions
+208 -32
View File
@@ -26,6 +26,12 @@ html, body, #root { min-height: 100%; }
body { background: var(--bg); color: var(--text); }
button, input, textarea { font: inherit; }
button { border: 0; }
mark {
background: color-mix(in srgb, var(--accent-soft) 92%, transparent);
color: var(--text);
border-radius: 0.28rem;
padding: 0 0.14rem;
}
.app-shell {
position: relative;
@@ -55,7 +61,8 @@ button { border: 0; }
.hero,
.hero-grid,
.main-grid {
.main-grid,
.insight-grid {
display: grid;
gap: 1rem;
}
@@ -84,7 +91,7 @@ button { border: 0; }
.subtitle {
margin-top: 0.75rem;
max-width: 42rem;
max-width: 52rem;
line-height: 1.55;
color: var(--muted);
}
@@ -101,7 +108,9 @@ button { border: 0; }
.result-item,
.memory-content,
.memory-editor,
.viewer-header {
.viewer-header,
.semantic-card,
.timeline-chip {
background: linear-gradient(180deg, rgba(23,26,29,0.96), rgba(20,23,27,0.96));
border: 1px solid var(--border);
border-radius: 12px;
@@ -112,7 +121,9 @@ button { border: 0; }
.result-item,
.viewer-header,
.memory-content,
.memory-editor {
.memory-editor,
.semantic-card,
.timeline-chip {
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}
@@ -120,14 +131,21 @@ button { border: 0; }
.ghost-btn,
.primary-btn,
.tag-pill,
.rail-row {
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
.rail-row,
.collection-card,
.semantic-card,
.timeline-chip,
.constellation-node {
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
}
.interactive-card:hover,
.ghost-btn:hover,
.primary-btn:hover,
.rail-row:hover {
.rail-row:hover,
.collection-card:hover,
.semantic-card:hover,
.timeline-chip:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 44%, var(--border));
box-shadow: 0 16px 34px rgba(0,0,0,0.2);
@@ -138,6 +156,11 @@ button { border: 0; }
margin-bottom: 1rem;
}
.insight-grid {
grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);
margin-top: 1rem;
}
.panel { padding: 1rem; }
.accent-panel { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
@@ -150,12 +173,23 @@ button { border: 0; }
.lead-meta,
.file-age,
.result-meta,
.theme-copy { color: var(--muted-2); font-size: 0.82rem; }
.theme-copy,
.semantic-empty,
.file-collection-line,
.collection-meta,
.timeline-readout,
.timeline-snippet,
.constellation-hover { color: var(--muted-2); font-size: 0.82rem; }
.lead-meta { margin-top: 0.65rem; }
.lead-row,
.theme-metrics,
.viewer-meta,
.collection-head {
.collection-head,
.timeline-focus,
.timeline-chip-meta,
.semantic-search-row,
.semantic-card-foot,
.constellation-scale {
display: flex;
align-items: center;
justify-content: space-between;
@@ -171,7 +205,11 @@ button { border: 0; }
.result-head,
.file-card-top,
.section-heading,
.compact-heading {
.compact-heading,
.file-stats,
.semantic-score,
.timeline-strip,
.timeline-meta {
display: flex;
align-items: center;
justify-content: space-between;
@@ -189,13 +227,18 @@ button { border: 0; }
.result-snippet,
.slash-hint,
.recap-line,
.rail-metrics span { color: var(--muted); }
.rail-metrics span,
.timeline-meta span,
.timeline-chip-date,
.timeline-chip-meta span { color: var(--muted); }
.stat-row strong,
.rail-metrics strong { font-size: 1.15rem; font-weight: 620; }
.rail-metrics strong,
.semantic-score { font-size: 1.15rem; font-weight: 620; }
.cluster-panel,
.search-block,
.main-grid { margin-top: 1rem; }
.main-grid,
.semantic-panel { margin-top: 1rem; }
.main-grid { grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr); }
.collection-grid {
margin-top: 1rem;
@@ -203,26 +246,21 @@ button { border: 0; }
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.8rem;
}
.collection-card {
.collection-card,
.semantic-card,
.timeline-chip {
text-align: left;
background: rgba(28,32,36,0.88);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.95rem;
cursor: pointer;
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
}
.collection-card:hover,
.collection-card.active {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
.collection-card.active,
.timeline-chip.active {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
background: color-mix(in srgb, var(--accent-soft) 55%, rgba(28,32,36,0.92));
}
.collection-meta {
margin-top: 0.45rem;
color: var(--muted);
font-size: 0.82rem;
}
.section-heading h2,
.compact-heading h2 {
margin-top: 0.2rem;
@@ -244,7 +282,8 @@ button { border: 0; }
.rail-copy { min-width: 0; }
.rail-date,
.file-label,
.result-name { font-weight: 620; letter-spacing: -0.01em; }
.result-name,
.timeline-date { font-weight: 620; letter-spacing: -0.01em; }
.rail-metrics { display: grid; text-align: right; min-width: 8rem; }
.rail-bar {
width: 100%;
@@ -261,6 +300,77 @@ button { border: 0; }
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
}
.constellation-panel,
.timeline-panel,
.semantic-panel { min-height: 100%; }
.constellation-map {
width: 100%;
height: auto;
display: block;
margin-top: 1rem;
background: radial-gradient(circle at 30% 28%, rgba(255,255,255,0.03), transparent 18%);
border-radius: 12px;
}
.constellation-node { cursor: pointer; }
.constellation-node:hover { transform: scale(1.03); }
.constellation-scale {
margin-top: 0.75rem;
color: var(--muted-2);
font-size: 0.78rem;
}
.timeline-slider {
width: 100%;
margin-top: 1rem;
accent-color: var(--accent);
}
.timeline-focus {
margin-top: 1rem;
padding: 0.95rem 1rem;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.06);
background: rgba(12, 15, 19, 0.72);
}
.timeline-meta {
flex-wrap: wrap;
justify-content: flex-end;
}
.timeline-strip {
margin-top: 0.9rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.timeline-chip-meta {
justify-content: flex-start;
margin-top: 0.6rem;
}
.semantic-results {
margin-top: 1rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 0.8rem;
}
.semantic-search-row {
margin-top: 1rem;
align-items: center;
}
.semantic-loading {
color: var(--muted);
font-size: 0.82rem;
min-width: 4.5rem;
text-align: right;
}
.semantic-card-foot {
margin-top: 0.8rem;
align-items: flex-end;
}
.semantic-card-foot .tag-strip {
margin-top: 0;
justify-content: flex-end;
}
.semantic-empty { margin-top: 1rem; line-height: 1.55; }
.composer-form { margin-top: 1rem; display: flex; gap: 0.75rem; }
.search-section { position: relative; margin: 1rem 0; }
.search-input,
@@ -309,7 +419,8 @@ button { border: 0; }
.file-date { color: var(--accent-strong); font-size: 0.82rem; }
.file-snippet,
.result-snippet { margin-top: 0.55rem; line-height: 1.6; }
.file-stats { margin-top: 0.75rem; display: flex; gap: 0.6rem; font-size: 0.82rem; }
.file-stats { margin-top: 0.75rem; gap: 0.6rem; font-size: 0.82rem; }
.file-collection-line { margin-top: 0.65rem; }
.tag-strip {
margin-top: 0.8rem;
@@ -410,21 +521,86 @@ kbd {
line-height: 1.68;
overflow: auto;
}
.highlighted-content mark { box-shadow: inset 0 -1.1em 0 rgba(255,255,255,0.02); }
.muted-panel { color: var(--muted); }
@media (max-width: 980px) {
.hero,
.hero-grid,
.main-grid { grid-template-columns: 1fr; }
.main-grid,
.insight-grid { grid-template-columns: 1fr; }
.hero-actions { align-items: flex-start; }
.composer-form,
.semantic-search-row,
.timeline-focus,
.timeline-meta { flex-direction: column; align-items: stretch; }
}
@media (max-width: 720px) {
.app,
.viewer-shell { padding-left: 0.9rem; padding-right: 0.9rem; }
.composer-form,
.viewer-header { display: grid; grid-template-columns: 1fr; }
.viewer-shell { padding-left: 1rem; padding-right: 1rem; }
.viewer-header { grid-template-columns: 1fr; }
.files-grid,
.search-results { grid-template-columns: 1fr; }
.rail-row { grid-template-columns: 1fr; }
.search-results,
.semantic-results,
.collection-grid,
.timeline-strip { grid-template-columns: 1fr; }
}
.platform-panel { margin-top: 1rem; }
.platform-status {
color: var(--muted-2);
font-size: 0.82rem;
white-space: nowrap;
}
.platform-grid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr);
gap: 0.9rem;
margin-top: 1rem;
}
.platform-card {
display: grid;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(28,32,36,0.72);
}
.platform-card-title {
font-weight: 650;
letter-spacing: -0.01em;
}
.platform-card p,
.platform-mini {
margin: 0;
color: var(--muted);
line-height: 1.5;
font-size: 0.9rem;
}
.platform-card code {
display: inline-block;
padding: 0.42rem 0.55rem;
border-radius: 8px;
background: rgba(0,0,0,0.28);
border: 1px solid rgba(255,255,255,0.06);
color: var(--accent-strong);
overflow-wrap: anywhere;
}
.import-card { align-content: start; }
.import-textarea {
min-height: 9.5rem;
width: 100%;
resize: vertical;
border-radius: 12px;
border: 1px solid var(--border-strong);
background: rgba(8,10,13,0.78);
color: var(--text);
padding: 0.9rem 1rem;
line-height: 1.5;
}
@media (max-width: 980px) {
.platform-grid { grid-template-columns: 1fr; }
.platform-status { white-space: normal; }
}
+355 -11
View File
@@ -7,6 +7,10 @@ function formatDate(ts) {
return new Date(ts).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
function formatDateLong(ts) {
return new Date(ts).toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' })
}
function formatRelative(ts) {
const diff = Date.now() - ts
const m = Math.floor(diff / 60000)
@@ -45,6 +49,27 @@ function moodLabel(mood) {
}[mood] || 'Steady'
}
function dateValueForFile(file) {
const fromMeta = file?.dateStamp || file?.filename?.replace(/\.md$/, '')
const parsed = Date.parse(`${fromMeta}T12:00:00`)
return Number.isFinite(parsed) ? parsed : file?.mtimeMs || Date.now()
}
function collectionMembership(file, collections = []) {
return collections.filter((collection) => collection.filenames?.includes(file.filename))
}
function highlightText(text, query) {
if (!query?.trim()) return text
const escaped = query.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
if (!escaped) return text
const regex = new RegExp(`(${escaped})`, 'ig')
const parts = String(text || '').split(regex)
return parts.map((part, index) => regex.test(part)
? <mark key={`${part}-${index}`}>{part}</mark>
: <span key={`${part}-${index}`}>{part}</span>)
}
function TagStrip({ tags = [], onPick, activeTag }) {
if (!tags.length) return null
return (
@@ -68,7 +93,8 @@ function MoodBadge({ mood }) {
return <span className={`mood-badge mood-${mood}`}>{moodLabel(mood)}</span>
}
function FileCard({ file, onClick, onPickTag, activeTag }) {
function FileCard({ file, onClick, onPickTag, activeTag, collections = [] }) {
const membership = collectionMembership(file, collections)
return (
<button className="file-card interactive-card" onClick={onClick}>
<div className="file-card-top">
@@ -78,6 +104,7 @@ function FileCard({ file, onClick, onPickTag, activeTag }) {
<div className="file-label">{file.dateLabel}</div>
<div className="file-snippet">{file.tags?.[0] ? `Leaning ${file.tags[0]}.` : 'Quiet day in the archive.'}</div>
<TagStrip tags={file.tags} onPick={onPickTag} activeTag={activeTag} />
{membership.length > 0 && <div className="file-collection-line">{membership.map((entry) => entry.label).join(' · ')}</div>}
<div className="file-stats">
<span>{file.wordCount.toLocaleString()} words</span>
<span>{file.entryCount} entries</span>
@@ -87,7 +114,7 @@ function FileCard({ file, onClick, onPickTag, activeTag }) {
)
}
function Viewer({ selected, onBack, activeTag, onPickTag }) {
function Viewer({ selected, onBack, activeTag, onPickTag, highlightQuery }) {
const [content, setContent] = useState('')
const [draft, setDraft] = useState('')
const [loading, setLoading] = useState(true)
@@ -158,7 +185,7 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
) : isMainMemory ? (
<textarea className="memory-editor" value={draft} onChange={(e) => setDraft(e.target.value)} spellCheck={false} />
) : (
<pre className="memory-content">{content}</pre>
<pre className="memory-content highlighted-content">{highlightText(content, highlightQuery)}</pre>
)}
</div>
)
@@ -241,6 +268,244 @@ function CollectionsPanel({ collections = [], onOpenCollection, activeCollection
)
}
function ConstellationMap({ files = [], collections = [], activeCollection, onSelect }) {
const [hovered, setHovered] = useState(null)
const points = useMemo(() => {
if (!files.length) return []
const ordered = [...files].sort((a, b) => dateValueForFile(a) - dateValueForFile(b))
const minDate = dateValueForFile(ordered[0])
const maxDate = dateValueForFile(ordered[ordered.length - 1])
const maxWords = Math.max(...ordered.map((file) => file.wordCount), 1)
const moods = { focused: 0.2, playful: 0.34, reflective: 0.52, warm: 0.68, tense: 0.84, steady: 0.5 }
return ordered.map((file, index) => {
const memberships = collectionMembership(file, collections)
const timeRatio = maxDate === minDate ? 0.5 : (dateValueForFile(file) - minDate) / (maxDate - minDate)
const moodBase = moods[file.mood] ?? 0.5
const tagWeight = Math.min((file.tags?.length || 0) / 8, 0.16)
const yRatio = Math.max(0.12, Math.min(0.88, moodBase - tagWeight + ((index % 3) - 1) * 0.04))
return {
file,
memberships,
x: 56 + timeRatio * 728,
y: 42 + yRatio * 214,
r: 7 + Math.min(file.entryCount, 8) * 1.4 + Math.min(file.wordCount / maxWords, 1) * 8,
active: activeCollection ? activeCollection.filenames?.includes(file.filename) : false,
}
})
}, [files, collections, activeCollection])
return (
<section className="panel constellation-panel interactive-card">
<div className="section-heading">
<div>
<div className="section-kicker">constellation mode</div>
<h2>Clusters, moods, and density across time.</h2>
</div>
{hovered ? <div className="constellation-hover">{hovered.file.dateLabel} · {hovered.file.wordCount} words</div> : null}
</div>
<svg className="constellation-map" viewBox="0 0 840 300" role="img" aria-label="Memory constellation map">
<defs>
<linearGradient id="constellation-line" x1="0" x2="1">
<stop offset="0%" stopColor="rgba(255,255,255,0.06)" />
<stop offset="50%" stopColor="var(--accent)" />
<stop offset="100%" stopColor="rgba(255,255,255,0.06)" />
</linearGradient>
</defs>
<path d="M56 246 H784" stroke="rgba(255,255,255,0.08)" strokeWidth="1" />
<path d="M56 42 V246" stroke="rgba(255,255,255,0.06)" strokeWidth="1" />
{[0.22, 0.5, 0.78].map((ratio) => <path key={ratio} d={`M56 ${42 + 214 * ratio} H784`} stroke="rgba(255,255,255,0.04)" strokeWidth="1" />)}
{points.map((point, index) => {
const next = points[index + 1]
if (!next) return null
return <line key={`${point.file.filename}-${next.file.filename}`} x1={point.x} y1={point.y} x2={next.x} y2={next.y} stroke="url(#constellation-line)" strokeWidth="1.2" opacity="0.38" />
})}
{points.map((point) => (
<g
key={point.file.filename}
className="constellation-node"
onMouseEnter={() => setHovered(point)}
onMouseLeave={() => setHovered(null)}
onClick={() => onSelect(point.file)}
>
{point.active ? <circle cx={point.x} cy={point.y} r={point.r + 11} fill="var(--accent-soft)" opacity="0.55" /> : null}
<circle cx={point.x} cy={point.y} r={point.r + 4} fill="var(--accent-glow)" opacity="0.28" />
<circle cx={point.x} cy={point.y} r={point.r} fill="var(--panel-2)" stroke="var(--accent-strong)" strokeWidth={point.active ? 2.5 : 1.3} />
</g>
))}
</svg>
<div className="constellation-scale">
<span>older</span>
<span>now</span>
</div>
</section>
)
}
function TimelineScrubber({ files = [], activeCollection, onSelect }) {
const [index, setIndex] = useState(0)
useEffect(() => {
setIndex(0)
}, [files.length, activeCollection?.id])
const focused = files[index] || null
const windowed = useMemo(() => {
if (!files.length) return []
const start = Math.max(0, index - 2)
return files.slice(start, start + 5)
}, [files, index])
if (!files.length || !focused) return null
return (
<section className="panel timeline-panel interactive-card">
<div className="section-heading">
<div>
<div className="section-kicker">timeline scrubber</div>
<h2>{activeCollection ? `${activeCollection.label}, over time.` : 'Slide through the archive.'}</h2>
</div>
<div className="timeline-readout">{focused.dateLabel}</div>
</div>
<input
className="timeline-slider"
type="range"
min="0"
max={Math.max(files.length - 1, 0)}
value={index}
onChange={(event) => setIndex(Number(event.target.value))}
/>
<div className="timeline-focus">
<div>
<div className="timeline-date">{formatDateLong(dateValueForFile(focused))}</div>
<div className="timeline-snippet">{focused.tags?.[0] ? `It bent toward ${focused.tags[0]}.` : 'A quieter node in the archive.'}</div>
</div>
<div className="timeline-meta">
<MoodBadge mood={focused.mood} />
<span>{focused.wordCount} words</span>
<button className="ghost-btn small-btn" onClick={() => onSelect(focused)}>Open</button>
</div>
</div>
<div className="timeline-strip">
{windowed.map((file) => (
<button key={file.filename} className={`timeline-chip ${file.filename === focused.filename ? 'active' : ''}`} onClick={() => setIndex(files.findIndex((entry) => entry.filename === file.filename))}>
<div className="timeline-chip-date">{file.dateLabel}</div>
<div className="timeline-chip-meta">
<MoodBadge mood={file.mood} />
<span>{file.entryCount} entries</span>
</div>
</button>
))}
</div>
</section>
)
}
function SemanticView({ query, onQueryChange, results = [], loading, onSelect, onPickTag, activeTag }) {
return (
<section className="panel semantic-panel interactive-card">
<div className="section-heading compact-heading">
<div>
<div className="section-kicker">semantic view</div>
<h2>Show me notes about</h2>
</div>
</div>
<div className="semantic-search-row">
<input
className="search-input"
type="text"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder="appwrite migration, family stuff, auth fixes, design streak…"
/>
{loading ? <span className="semantic-loading">Thinking</span> : null}
</div>
{query.trim().length < 2 ? (
<div className="semantic-empty">Try a fuzzy prompt. It leans on tags, collections, moods, and text, not just exact matches.</div>
) : results.length ? (
<div className="semantic-results">
{results.map((result) => (
<button key={result.filename} className="semantic-card" onClick={() => onSelect(result)}>
<div className="result-head">
<div>
<div className="result-name">{result.dateLabel}</div>
<div className="result-meta">{result.why || 'Strong semantic match'}</div>
</div>
<div className="semantic-score">{result.score}</div>
</div>
<div className="result-snippet">{result.snippet}</div>
<div className="semantic-card-foot">
<MoodBadge mood={result.mood} />
<TagStrip tags={result.tags} onPick={onPickTag} activeTag={activeTag} />
</div>
</button>
))}
</div>
) : (
<div className="semantic-empty">No strong semantic threads for {query} yet.</div>
)}
</section>
)
}
function PlatformPanel({ platform, importDraft, setImportDraft, importState, onImport }) {
return (
<section className="panel platform-panel interactive-card">
<div className="section-heading compact-heading">
<div>
<div className="section-kicker">llm memory platform</div>
<h2>Feed the archive. Let local models ask it questions.</h2>
</div>
<div className="platform-status">
{platform?.notifications?.slack ? 'Slack notifications on' : 'Slack notifications pending'}
</div>
</div>
<div className="platform-grid">
<div className="platform-card">
<div className="platform-card-title">Local LLM context bridge</div>
<p>Point agents, LibreChat tools, or local model routers at this endpoint to retrieve memory-shaped context.</p>
<code>{platform?.llm?.contextEndpoint || '/api/llm/context?q=your+question'}</code>
<div className="platform-mini">Semantic search stays available at <code>/api/semantic</code>.</div>
</div>
<form className="platform-card import-card" onSubmit={onImport}>
<div className="platform-card-title">Import knowledge</div>
<input
className="search-input"
type="text"
placeholder="Title or source name"
value={importDraft.sourceTitle}
onChange={(event) => setImportDraft((draft) => ({ ...draft, sourceTitle: event.target.value }))}
/>
<input
className="search-input"
type="url"
placeholder="Optional source URL"
value={importDraft.sourceUrl}
onChange={(event) => setImportDraft((draft) => ({ ...draft, sourceUrl: event.target.value }))}
/>
<textarea
className="import-textarea"
placeholder="Paste notes, docs, prompts, model findings, or leave blank to import the URL text."
value={importDraft.content}
onChange={(event) => setImportDraft((draft) => ({ ...draft, content: event.target.value }))}
/>
<button className="primary-btn" type="submit" disabled={importState === 'saving' || (!importDraft.content.trim() && !importDraft.sourceUrl.trim())}>
{importState === 'saving' ? 'Importing…' : importState === 'saved' ? 'Imported' : importState === 'error' ? 'Retry import' : 'Import into memory'}
</button>
</form>
</div>
</section>
)
}
export default function App() {
const [meta, setMeta] = useState(null)
const [selected, setSelected] = useState(null)
@@ -255,11 +520,18 @@ export default function App() {
const [activeTag, setActiveTag] = useState('')
const [activeCollection, setActiveCollection] = useState(null)
const [pointer, setPointer] = useState({ x: 18, y: 12 })
const [semanticQuery, setSemanticQuery] = useState('')
const [semanticResults, setSemanticResults] = useState([])
const [semanticLoading, setSemanticLoading] = useState(false)
const [platform, setPlatform] = useState(null)
const [importDraft, setImportDraft] = useState({ sourceTitle: '', sourceUrl: '', content: '' })
const [importState, setImportState] = useState('idle')
const searchRef = useRef(null)
const refreshMeta = () => {
fetch(`${API}/api/meta`).then((r) => r.json()).then(setMeta)
fetch(`${API}/api/review-count`).then((r) => r.json()).then((d) => setReviewCount(d.count ?? 0)).catch(() => setReviewCount(null))
fetch(`${API}/api/platform`).then((r) => r.json()).then(setPlatform).catch(() => setPlatform(null))
}
useEffect(() => {
@@ -311,6 +583,7 @@ export default function App() {
useEffect(() => {
if (activeCollection) {
setQuery(activeCollection.tags.join(' '))
setSemanticQuery(activeCollection.label)
}
}, [activeCollection])
@@ -344,6 +617,26 @@ export default function App() {
return () => clearTimeout(t)
}, [query])
useEffect(() => {
if (!semanticQuery.trim() || semanticQuery.trim().length < 2) {
setSemanticResults([])
return undefined
}
const timeout = setTimeout(async () => {
setSemanticLoading(true)
try {
const payload = await fetch(`${API}/api/semantic?q=${encodeURIComponent(semanticQuery)}`).then((r) => r.json())
setSemanticResults(payload.results || [])
} catch {
setSemanticResults([])
}
setSemanticLoading(false)
}, 220)
return () => clearTimeout(timeout)
}, [semanticQuery])
const heroStats = useMemo(() => {
if (!meta?.dailyFiles?.length) return null
const latest = meta.dailyFiles[0]
@@ -373,6 +666,13 @@ export default function App() {
'--pointer-y': `${pointer.y}%`,
}), [meta, pointer])
const filteredFiles = useMemo(() => {
const files = meta?.dailyFiles || []
if (!activeCollection) return files
const allowed = new Set(activeCollection.filenames || [])
return files.filter((file) => allowed.has(file.filename))
}, [meta, activeCollection])
const openFile = (file) => setSelected({ type: 'daily', filename: file.filename, title: file.dateLabel, tags: file.tags || [], mood: file.mood })
const submitLog = async (e) => {
@@ -396,8 +696,29 @@ export default function App() {
}
}
const submitImport = async (e) => {
e.preventDefault()
if (!importDraft.content.trim() && !importDraft.sourceUrl.trim()) return
setImportState('saving')
try {
const r = await fetch(`${API}/api/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(importDraft),
})
if (!r.ok) throw new Error('import failed')
setImportDraft({ sourceTitle: '', sourceUrl: '', content: '' })
setImportState('saved')
refreshMeta()
setTimeout(() => setImportState('idle'), 1800)
} catch {
setImportState('error')
}
}
if (selected) {
return <Viewer selected={selected} onBack={() => setSelected(null)} activeTag={activeTag} onPickTag={setActiveTag} />
return <Viewer selected={selected} onBack={() => setSelected(null)} activeTag={activeTag} onPickTag={setActiveTag} highlightQuery={query || semanticQuery} />
}
return (
@@ -408,7 +729,7 @@ export default function App() {
<div className="hero-copy">
<div className="eyebrow">freeclaw memory cloud</div>
<h1>Memory with a pulse.</h1>
<p className="subtitle">Now with merged tags, mood sensing, and self-assembling collections.</p>
<p className="subtitle">Now with constellation mode, a scrub-through timeline, semantic memory views, and cleaner bridges between the archive and the ideas inside it.</p>
</div>
<div className="hero-actions">
<button
@@ -457,8 +778,13 @@ export default function App() {
<TagStrip tags={smartClusters} onPick={setActiveTag} activeTag={activeTag} />
</section>
<section className="insight-grid">
<ConstellationMap files={filteredFiles} collections={meta?.collections} activeCollection={activeCollection} onSelect={openFile} />
<TimelineScrubber files={filteredFiles} activeCollection={activeCollection} onSelect={openFile} />
</section>
<div className="main-grid">
<ActivityRail files={meta?.dailyFiles} onSelect={openFile} onPickTag={setActiveTag} activeTag={activeTag} />
<ActivityRail files={filteredFiles} onSelect={openFile} onPickTag={setActiveTag} activeTag={activeTag} />
<section className="panel composer-panel interactive-card">
<div className="section-heading compact-heading">
@@ -469,7 +795,6 @@ export default function App() {
</div>
<form className="composer-form" onSubmit={submitLog}>
<input
ref={searchRef}
className="search-input composer-input"
type="text"
placeholder="A note, decision, odd clue, or tiny win…"
@@ -486,18 +811,37 @@ export default function App() {
</section>
</div>
<PlatformPanel
platform={platform}
importDraft={importDraft}
setImportDraft={setImportDraft}
importState={importState}
onImport={submitImport}
/>
<SemanticView
query={semanticQuery}
onQueryChange={setSemanticQuery}
results={semanticResults}
loading={semanticLoading}
onSelect={(result) => openFile(result)}
onPickTag={setActiveTag}
activeTag={activeTag}
/>
<section className="search-block">
<div className="section-heading compact-heading">
<div>
<div className="section-kicker">search</div>
<h2>{activeCollection ? activeCollection.label : activeTag ? `Filtering on ${activeTag}` : 'Find threads fast.'}</h2>
</div>
{(activeTag || activeCollection) && (
<button className="ghost-btn small-btn" onClick={() => { setActiveTag(''); setActiveCollection(null); setQuery('') }}>Clear filters</button>
{(activeTag || activeCollection || semanticQuery) && (
<button className="ghost-btn small-btn" onClick={() => { setActiveTag(''); setActiveCollection(null); setQuery(''); setSemanticQuery('') }}>Clear filters</button>
)}
</div>
<div className="search-section">
<input
ref={searchRef}
className="search-input"
type="text"
placeholder="Search memory files…"
@@ -528,8 +872,8 @@ export default function App() {
{searchResults.length === 0 && !searching && query.length < 2 && meta && (
<div className="files-grid">
{meta.dailyFiles.map((file) => (
<FileCard key={file.filename} file={file} onClick={() => openFile(file)} onPickTag={setActiveTag} activeTag={activeTag} />
{filteredFiles.map((file) => (
<FileCard key={file.filename} file={file} collections={meta?.collections} onClick={() => openFile(file)} onPickTag={setActiveTag} activeTag={activeTag} />
))}
</div>
)}