Add mood sensing and auto collections to memory cloud

This commit is contained in:
OpenClaw
2026-04-24 22:40:46 +02:00
parent a13d08251d
commit e4195fdde2
3 changed files with 244 additions and 27 deletions
+54
View File
@@ -152,6 +152,17 @@ button { border: 0; }
.result-meta,
.theme-copy { color: var(--muted-2); font-size: 0.82rem; }
.lead-meta { margin-top: 0.65rem; }
.lead-row,
.theme-metrics,
.viewer-meta,
.collection-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.lead-row { margin-top: 0.75rem; }
.lead-side { color: var(--muted); font-size: 0.82rem; }
.theme-title { margin-top: 0.35rem; font-size: 1.2rem; font-weight: 600; letter-spacing: -0.03em; }
.theme-copy { margin-top: 0.55rem; line-height: 1.55; }
@@ -186,6 +197,32 @@ button { border: 0; }
.search-block,
.main-grid { margin-top: 1rem; }
.main-grid { grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr); }
.collection-grid {
margin-top: 1rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.8rem;
}
.collection-card {
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));
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;
@@ -299,6 +336,23 @@ button { border: 0; }
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
}
.mood-badge {
display: inline-flex;
align-items: center;
min-height: 1.8rem;
padding: 0.18rem 0.55rem;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.08);
font-size: 0.74rem;
color: var(--text);
background: rgba(255,255,255,0.03);
}
.mood-focused { border-color: rgba(129,161,193,0.3); color: #b5d2ee; }
.mood-playful { border-color: rgba(180,142,173,0.3); color: #e1c0dd; }
.mood-reflective { border-color: rgba(143,188,187,0.3); color: #bfe1df; }
.mood-tense { border-color: rgba(208,106,106,0.35); color: #f1b3b3; }
.mood-warm { border-color: rgba(208,168,110,0.35); color: #f1d4ab; }
.mood-steady { border-color: rgba(156,163,175,0.25); color: #d1d5db; }
.ghost-btn,
.primary-btn,
+99 -20
View File
@@ -34,6 +34,17 @@ function pickSpark(content) {
return clipText(candidate.replace(/^[-*]\s*/, ''))
}
function moodLabel(mood) {
return {
focused: 'Focused',
playful: 'Playful',
reflective: 'Reflective',
tense: 'Tense',
warm: 'Warm',
steady: 'Steady',
}[mood] || 'Steady'
}
function TagStrip({ tags = [], onPick, activeTag }) {
if (!tags.length) return null
return (
@@ -52,12 +63,17 @@ function TagStrip({ tags = [], onPick, activeTag }) {
)
}
function MoodBadge({ mood }) {
if (!mood) return null
return <span className={`mood-badge mood-${mood}`}>{moodLabel(mood)}</span>
}
function FileCard({ file, onClick, onPickTag, activeTag }) {
return (
<button className="file-card interactive-card" onClick={onClick}>
<div className="file-card-top">
<div className="file-date">{formatDate(file.mtimeMs)}</div>
<div className="file-age">{formatRelative(file.mtimeMs)}</div>
<MoodBadge mood={file.mood} />
</div>
<div className="file-label">{file.dateLabel}</div>
<div className="file-snippet">{file.tags?.[0] ? `Leaning ${file.tags[0]}.` : 'Quiet day in the archive.'}</div>
@@ -65,6 +81,7 @@ function FileCard({ file, onClick, onPickTag, activeTag }) {
<div className="file-stats">
<span>{file.wordCount.toLocaleString()} words</span>
<span>{file.entryCount} entries</span>
<span>{formatRelative(file.mtimeMs)}</span>
</div>
</button>
)
@@ -76,6 +93,7 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
const [loading, setLoading] = useState(true)
const [saveState, setSaveState] = useState('idle')
const [tags, setTags] = useState(selected.tags || [])
const [mood, setMood] = useState(selected.mood || 'steady')
const isMainMemory = selected.type === 'main'
useEffect(() => {
@@ -90,6 +108,7 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
setContent(next)
setDraft(next)
setTags(data.tags || selected.tags || [])
setMood(data.mood || selected.mood || 'steady')
setLoading(false)
})
.catch(() => setLoading(false))
@@ -108,6 +127,7 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
setContent(draft)
const refreshed = await fetch(`${API}/api/main-memory`).then((res) => res.json())
setTags(refreshed.tags || [])
setMood(refreshed.mood || 'steady')
setSaveState('saved')
setTimeout(() => setSaveState('idle'), 1800)
} catch {
@@ -123,11 +143,14 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
<div className="viewer-kicker">{isMainMemory ? 'core memory' : 'daily note'}</div>
<div className="viewer-title">{selected.title}</div>
</div>
{isMainMemory && !loading && (
<button className="primary-btn viewer-save" onClick={save} disabled={saveState === 'saving' || draft === content}>
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : saveState === 'error' ? 'Retry' : 'Save'}
</button>
)}
<div className="viewer-meta">
<MoodBadge mood={mood} />
{isMainMemory && !loading && (
<button className="primary-btn viewer-save" onClick={save} disabled={saveState === 'saving' || draft === content}>
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : saveState === 'error' ? 'Retry' : 'Save'}
</button>
)}
</div>
</div>
<TagStrip tags={tags} onPick={onPickTag} activeTag={activeTag} />
{loading ? (
@@ -162,6 +185,7 @@ function ActivityRail({ files, onSelect, onPickTag, activeTag }) {
<TagStrip tags={file.tags} onPick={onPickTag} activeTag={activeTag} />
</div>
<div className="rail-metrics">
<MoodBadge mood={file.mood} />
<strong>{file.wordCount.toLocaleString()}</strong>
<span>{file.entryCount} entries</span>
<div className="rail-bar"><span style={{ width: `${(file.wordCount / maxWords) * 100}%` }} /></div>
@@ -173,17 +197,50 @@ function ActivityRail({ files, onSelect, onPickTag, activeTag }) {
)
}
function ThemeSwatches({ theme, tags, onPickTag, activeTag }) {
function ThemeSwatches({ theme, tags, mood, onPickTag, activeTag }) {
return (
<section className="panel recap-panel interactive-card">
<div className="section-kicker">theme drift</div>
<div className="theme-title">{theme?.name || 'Nord'} mode</div>
<p className="theme-copy">The color temperature now leans with what lives in core memory, not just a fixed skin.</p>
<p className="theme-copy">Color bends to core memory. Mood reads the tone behind it.</p>
<div className="theme-metrics">
<MoodBadge mood={mood} />
</div>
<TagStrip tags={tags} onPick={onPickTag} activeTag={activeTag} />
</section>
)
}
function CollectionsPanel({ collections = [], onOpenCollection, activeCollectionId }) {
if (!collections.length) return null
return (
<section className="panel cluster-panel interactive-card">
<div className="section-heading">
<div>
<div className="section-kicker">auto collections</div>
<h2>Memory is grouping itself.</h2>
</div>
</div>
<div className="collection-grid">
{collections.map((collection) => (
<button
key={collection.id}
className={`collection-card ${activeCollectionId === collection.id ? 'active' : ''}`}
onClick={() => onOpenCollection(collection)}
>
<div className="collection-head">
<strong>{collection.label}</strong>
<span>{collection.count}</span>
</div>
<div className="collection-meta">Latest {collection.latest}</div>
<TagStrip tags={collection.tags} />
</button>
))}
</div>
</section>
)
}
export default function App() {
const [meta, setMeta] = useState(null)
const [selected, setSelected] = useState(null)
@@ -196,6 +253,7 @@ export default function App() {
const [quickLog, setQuickLog] = useState('')
const [logState, setLogState] = useState('idle')
const [activeTag, setActiveTag] = useState('')
const [activeCollection, setActiveCollection] = useState(null)
const [pointer, setPointer] = useState({ x: 18, y: 12 })
const searchRef = useRef(null)
@@ -250,6 +308,12 @@ export default function App() {
}
}, [activeTag])
useEffect(() => {
if (activeCollection) {
setQuery(activeCollection.tags.join(' '))
}
}, [activeCollection])
useEffect(() => {
if (!query.trim() || query.length < 2) {
setSearchResults([])
@@ -259,9 +323,18 @@ export default function App() {
const t = setTimeout(async () => {
setSearching(true)
try {
const r = await fetch(`${API}/api/search?q=${encodeURIComponent(query)}`)
const d = await r.json()
setSearchResults(d.results || [])
const terms = query.trim().split(/\s+/).filter(Boolean)
const requests = await Promise.all(terms.map((term) => fetch(`${API}/api/search?q=${encodeURIComponent(term)}`).then((r) => r.json())))
const merged = new Map()
for (const payload of requests) {
for (const result of payload.results || []) {
const existing = merged.get(result.filename)
if (!existing || (existing.matchCount || 0) < (result.matchCount || 0)) {
merged.set(result.filename, result)
}
}
}
setSearchResults(Array.from(merged.values()).sort((a, b) => (b.matchCount || 0) - (a.matchCount || 0)))
} catch {
setSearchResults([])
}
@@ -300,7 +373,7 @@ export default function App() {
'--pointer-y': `${pointer.y}%`,
}), [meta, pointer])
const openFile = (file) => setSelected({ type: 'daily', filename: file.filename, title: file.dateLabel, tags: file.tags || [] })
const openFile = (file) => setSelected({ type: 'daily', filename: file.filename, title: file.dateLabel, tags: file.tags || [], mood: file.mood })
const submitLog = async (e) => {
e.preventDefault()
@@ -335,12 +408,12 @@ export default function App() {
<div className="hero-copy">
<div className="eyebrow">freeclaw memory cloud</div>
<h1>Memory with a pulse.</h1>
<p className="subtitle">Theme shifts with core memory. Tags are smarter. The archive should feel alive, not embalmed.</p>
<p className="subtitle">Now with merged tags, mood sensing, and self-assembling collections.</p>
</div>
<div className="hero-actions">
<button
className="ghost-btn"
onClick={() => setSelected({ type: 'main', title: 'MEMORY.md', filename: 'MEMORY.md', tags: meta?.mainMemory?.tags || [] })}
onClick={() => setSelected({ type: 'main', title: 'MEMORY.md', filename: 'MEMORY.md', tags: meta?.mainMemory?.tags || [], mood: meta?.mainMemory?.mood })}
>
Open core memory
</button>
@@ -354,6 +427,10 @@ export default function App() {
<div className="section-kicker">latest spark</div>
<div className="lead-copy">{spark || 'Loading…'}</div>
<div className="lead-meta">{sparkSource || 'recent archive'}</div>
<div className="lead-row">
<MoodBadge mood={heroStats.latest.mood} />
<span className="lead-side">{heroStats.latest.wordCount.toLocaleString()} words</span>
</div>
<TagStrip tags={heroStats.latest.tags} onPick={setActiveTag} activeTag={activeTag} />
</div>
@@ -364,10 +441,12 @@ export default function App() {
<div className="stat-row"><span>review queue</span><strong>{reviewCount ?? '—'}</strong></div>
</div>
<ThemeSwatches theme={meta.theme} tags={meta.mainMemory?.tags} onPickTag={setActiveTag} activeTag={activeTag} />
<ThemeSwatches theme={meta.theme} tags={meta.mainMemory?.tags} mood={meta.mainMemory?.mood} onPickTag={setActiveTag} activeTag={activeTag} />
</section>
)}
<CollectionsPanel collections={meta?.collections} activeCollectionId={activeCollection?.id} onOpenCollection={(collection) => setActiveCollection((current) => current?.id === collection.id ? null : collection)} />
<section className="panel cluster-panel interactive-card">
<div className="section-heading">
<div>
@@ -411,10 +490,10 @@ export default function App() {
<div className="section-heading compact-heading">
<div>
<div className="section-kicker">search</div>
<h2>{activeTag ? `Filtering on ${activeTag}` : 'Find threads fast.'}</h2>
<h2>{activeCollection ? activeCollection.label : activeTag ? `Filtering on ${activeTag}` : 'Find threads fast.'}</h2>
</div>
{activeTag && (
<button className="ghost-btn small-btn" onClick={() => { setActiveTag(''); setQuery('') }}>Clear tag</button>
{(activeTag || activeCollection) && (
<button className="ghost-btn small-btn" onClick={() => { setActiveTag(''); setActiveCollection(null); setQuery('') }}>Clear filters</button>
)}
</div>
<div className="search-section">
@@ -434,11 +513,11 @@ export default function App() {
<button
key={result.filename}
className="result-item interactive-card"
onClick={() => setSelected({ type: 'daily', filename: result.filename, title: result.dateLabel, tags: result.tags || [] })}
onClick={() => setSelected({ type: 'daily', filename: result.filename, title: result.dateLabel, tags: result.tags || [], mood: result.mood })}
>
<div className="result-head">
<div className="result-name">{result.dateLabel}</div>
<div className="result-meta">{result.matchCount} matches</div>
<MoodBadge mood={result.mood} />
</div>
<div className="result-snippet">{result.snippet}</div>
<TagStrip tags={result.tags} onPick={setActiveTag} activeTag={activeTag} />