diff --git a/apps/memos-local-plugin/core/pipeline/orchestrator.ts b/apps/memos-local-plugin/core/pipeline/orchestrator.ts index 4f944154..ad5490e3 100644 --- a/apps/memos-local-plugin/core/pipeline/orchestrator.ts +++ b/apps/memos-local-plugin/core/pipeline/orchestrator.ts @@ -74,7 +74,7 @@ import type { SkillInvokeCtx, SubAgentCtx, } from "../retrieval/types.js"; -import type { CoreEvent } from "../../agent-contract/events.js"; +import type { CoreEvent, CoreEventType } from "../../agent-contract/events.js"; import type { LogRecord } from "../../agent-contract/log-record.js"; import { memoryBuffer } from "../logger/index.js"; import { onBroadcastLog } from "../logger/transports/sse-broadcast.js"; @@ -105,9 +105,8 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { // Small ring buffer of the most-recent events. Late-connecting SSE // subscribers (e.g. the viewer's Overview panel opened after an agent // turn already fired) replay this buffer on connect so the "实时活动" - // card isn't empty by default. 100 rows is plenty — the viewer only - // renders the last dozen. - const RECENT_EVENTS_CAP = 100; + // dashboard isn't empty by default. + const RECENT_EVENTS_CAP = 160; const recentEvents: CoreEvent[] = []; const emitCore = (evt: CoreEvent): void => { @@ -146,30 +145,98 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { // Hydrate the ring buffer with synthetic events derived from the // most-recent rows on disk. Without this, every plugin restart // produces an empty "实时活动" panel until the user happens to - // interact with the agent again — misleading, because the DB - // clearly has recent activity. We emit a small set of low-cost - // synthetic `episode.closed` + `trace.created` entries (no bus - // fan-out) just for the buffer, so SSE connects replay them to new - // clients. Seq numbers are monotone from 0 so the frontend's - // `key={evt.seq}` stays unique against live events that come later. + // interact with the agent again — misleading, because the DB clearly + // has recent activity. Include the same categories the overview + // dashboard renders (memory / experience / environment / skill / + // feedback), not just task/session lifecycle events. try { + const hydrated: CoreEvent[] = []; + const pushSynthetic = ( + type: CoreEventType, + ts: number | null | undefined, + correlationId: string | undefined, + payload: unknown, + ): void => { + if (!Number.isFinite(ts)) return; + hydrated.push({ + type, + ts: ts as number, + seq: 0, + correlationId, + payload, + }); + }; + const recentEpisodes = deps.repos.episodes.list({ limit: 20 }); - let seq = 0; - for (const ep of recentEpisodes.reverse()) { + for (const ep of recentEpisodes) { const ts = ep.endedAt ?? ep.startedAt; if (!ts) continue; const type = ep.status === "closed" ? "episode.closed" : "episode.opened"; + pushSynthetic(type, ts, ep.id, { + episodeId: ep.id, + sessionId: ep.sessionId, + status: ep.status, + rTask: ep.rTask ?? null, + }); + } + + for (const tr of deps.repos.traces.list({ limit: 30 })) { + pushSynthetic("trace.created", tr.ts, tr.id, { + traceId: tr.id, + episodeId: tr.episodeId, + sessionId: tr.sessionId, + }); + } + + for (const policy of deps.repos.policies.list({ limit: 20 })) { + const ts = policy.updatedAt ?? policy.createdAt; + pushSynthetic("l2.revised", ts, policy.id, { + policyId: policy.id, + status: policy.status, + signature: policy.title, + }); + } + + for (const world of deps.repos.worldModel.list({ limit: 20 })) { + const ts = world.updatedAt ?? world.createdAt; + pushSynthetic("l3.revised", ts, world.id, { + worldModelId: world.id, + title: world.title, + status: world.status, + }); + } + + for (const skill of deps.repos.skills.list({ limit: 20 })) { + const ts = skill.updatedAt ?? skill.createdAt; + const type: CoreEventType = + skill.status === "archived" ? "skill.archived" : "skill.crystallized"; + pushSynthetic(type, ts, skill.id, { + skillId: skill.id, + name: skill.name, + status: skill.status, + }); + } + + for (const fb of deps.repos.feedback.list({ limit: 20 })) { + pushSynthetic("feedback.classified", fb.ts, fb.id, { + feedbackId: fb.id, + episodeId: fb.episodeId, + traceId: fb.traceId, + tone: fb.polarity, + channel: fb.channel, + }); + } + + hydrated.sort((a, b) => a.ts - b.ts); + const keep = hydrated.slice(-RECENT_EVENTS_CAP); + const seqStart = -keep.length; + for (let i = 0; i < keep.length; i++) { + const evt = keep[i]!; recentEvents.push({ - type, - ts, - seq: seq++, - correlationId: ep.id, - payload: { - episodeId: ep.id, - sessionId: ep.sessionId, - status: ep.status, - rTask: ep.rTask ?? null, - }, + ...evt, + // Negative ids are reserved for replay-only synthetic rows, so + // live bridge events starting at seq=1 never collide in the UI. + seq: seqStart + i, }); } if (recentEvents.length > RECENT_EVENTS_CAP) { @@ -177,7 +244,7 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle { } log.debug("events.ring.hydrated", { count: recentEvents.length, - source: "episodes", + source: "storage", }); } catch (err) { log.debug("events.ring.hydrate_failed", { diff --git a/apps/memos-local-plugin/web/src/stores/i18n.ts b/apps/memos-local-plugin/web/src/stores/i18n.ts index 62c9ed4b..1c33ae82 100644 --- a/apps/memos-local-plugin/web/src/stores/i18n.ts +++ b/apps/memos-local-plugin/web/src/stores/i18n.ts @@ -90,6 +90,13 @@ const en = { "common.deselect": "Deselect", "common.bulkDelete": "Delete selected", "common.bulkDelete.confirm": "Delete {n} selected items? This cannot be undone.", + // Relative-time labels — used by the activity dashboard and any + // future surface that wants "5 s ago" / "3 min ago" formatting. + "common.justNow": "just now", + "common.secondsAgo": "{n} s ago", + "common.minutesAgo": "{n} min ago", + "common.hoursAgo": "{n} h ago", + "common.daysAgo": "{n} d ago", "pager.page": "Page {n}", "pager.pageOfAtLeast": "Page {n} / {total}+", "pager.pageOfTotal": "Page {n} / {total}", @@ -216,10 +223,84 @@ const en = { "Primary provider unavailable, host LLM is handling the call. Original error: {msg}", "overview.metric.policies.breakdown": "{active} active · {candidate} candidate", "overview.metric.skills.breakdown": "{active} active · {candidate} candidate", + // Live activity dashboard — the third row of the overview page. + // Shows six per-category tiles (memory / experience / environment + // knowledge / skill / retrieval / feedback) with a five-minute + // sparkline and the most recent event in plain language. "overview.live.title": "Live activity", - "overview.live.subtitle": "Most recent events emitted by the algorithm core", - "overview.live.empty": "No events yet", - "overview.live.hint": "Events will appear here as sessions advance.", + "overview.live.tile.count": "events in last 5 min", + "overview.live.tile.empty": "No events in last 5 min", + + // Tile labels (also used by the per-event "category pill"). Keep + // these in sync with overview.metric.* / nav.* labels — same noun, + // different surface. + "overview.live.cat.session": "Conversation", + "overview.live.cat.task": "Task", + "overview.live.cat.memory": "Memory", + "overview.live.cat.experience": "Experience", + "overview.live.cat.world": "Environment knowledge", + "overview.live.cat.skill": "Skill", + "overview.live.cat.retrieval": "Retrieval", + "overview.live.cat.feedback": "Feedback", + "overview.live.cat.system": "System", + "overview.live.cat.hub": "Hub", + + // Per-event titles. Telegraphic noun-verb compounds (matching the + // operational-log style the rest of the product uses), one per + // CoreEventType. Detail text — IDs, counts, milliseconds — is + // formatted in TS and concatenated to the title at render time. + "overview.live.event.session.opened": "Session opened", + "overview.live.event.session.closed": "Session ended", + "overview.live.event.episode.opened": "Task started", + "overview.live.event.episode.closed": "Task ended", + "overview.live.event.trace.created": "Memory stored", + "overview.live.event.trace.value_updated": "Memory updated", + "overview.live.event.trace.priority_decayed": "Memory decayed", + "overview.live.event.l2.candidate_added": "Experience candidate", + "overview.live.event.l2.candidate_expired": "Experience candidate expired", + "overview.live.event.l2.induced": "Experience generated", + "overview.live.event.l2.associated": "Experience associated", + "overview.live.event.l2.revised": "Experience revised", + "overview.live.event.l2.boundary_shrunk": "Experience boundary shrunk", + "overview.live.event.l3.abstracted": "Environment knowledge generated", + "overview.live.event.l3.revised": "Environment knowledge updated", + "overview.live.event.skill.crystallized": "Skill crystallised", + "overview.live.event.skill.eta_updated": "Skill ETA updated", + "overview.live.event.skill.boundary_updated": "Skill boundary updated", + "overview.live.event.skill.archived": "Skill archived", + "overview.live.event.skill.repaired": "Skill repaired", + "overview.live.event.retrieval.triggered": "Retrieval triggered", + "overview.live.event.retrieval.tier1.hit": "Tier 1 retrieval hit", + "overview.live.event.retrieval.tier2.hit": "Tier 2 retrieval hit", + "overview.live.event.retrieval.tier3.hit": "Tier 3 retrieval hit", + "overview.live.event.retrieval.empty": "Retrieval empty", + "overview.live.event.feedback.received": "Feedback received", + "overview.live.event.feedback.classified": "Feedback classified", + "overview.live.event.reward.computed": "Reward computed", + "overview.live.event.decision_repair.generated": "Decision repair generated", + "overview.live.event.decision_repair.validated": "Decision repair validated", + "overview.live.event.hub.client_connected": "Hub client connected", + "overview.live.event.hub.client_disconnected": "Hub client disconnected", + "overview.live.event.hub.share_published": "Hub share published", + "overview.live.event.hub.share_received": "Hub share received", + "overview.live.event.system.started": "System started", + "overview.live.event.system.shutdown": "System shutdown", + "overview.live.event.system.error": "System error", + "overview.live.event.system.config_changed": "Config changed", + "overview.live.event.system.update_available": "Update available", + + // Detail templates. Many events share patterns (label + id, count + + // latency, …) so the same template is reused across multiple types. + "overview.live.detail.id": "{label} {id}", + "overview.live.detail.idReason": "{label} {id} · {reason}", + "overview.live.detail.candidate": "Candidate {sig}", + "overview.live.detail.induced": "{sig} · from {n} successful tasks", + "overview.live.detail.similarity": "{label} {id} · similarity {pct}%", + "overview.live.detail.retrievalHit": "{count} hits · {ms}ms", + "overview.live.detail.feedbackTone": "Tone: {tone}", + "overview.live.detail.reward": "r = {r} · from {source}", + "overview.live.detail.version": "v{version}", + "overview.live.detail.raw": "{value}", // Host bridge status. "bridge.connected": "Memory bridge connected", @@ -848,6 +929,11 @@ const zh: Record = { "common.deselect": "取消选择", "common.bulkDelete": "批量删除", "common.bulkDelete.confirm": "确认删除 {n} 项?此操作不可撤销。", + "common.justNow": "刚刚", + "common.secondsAgo": "{n} 秒前", + "common.minutesAgo": "{n} 分钟前", + "common.hoursAgo": "{n} 小时前", + "common.daysAgo": "{n} 天前", "pager.page": "第 {n} 页", "pager.pageOfAtLeast": "第 {n} 页 / 共 {total}+ 页", "pager.pageOfTotal": "第 {n} 页 / 共 {total} 页", @@ -964,9 +1050,70 @@ const zh: Record = { "overview.metric.policies.breakdown": "{active} 已启用 · {candidate} 候选", "overview.metric.skills.breakdown": "{active} 已启用 · {candidate} 候选", "overview.live.title": "实时活动", - "overview.live.subtitle": "算法核心发出的最近事件", - "overview.live.empty": "暂无事件", - "overview.live.hint": "Agent 交互后事件会显示在这里。", + "overview.live.tile.count": "最近 5 分钟事件", + "overview.live.tile.empty": "最近 5 分钟无事件", + + "overview.live.cat.session": "对话", + "overview.live.cat.task": "任务", + "overview.live.cat.memory": "记忆", + "overview.live.cat.experience": "经验", + "overview.live.cat.world": "环境认知", + "overview.live.cat.skill": "技能", + "overview.live.cat.retrieval": "检索", + "overview.live.cat.feedback": "反馈", + "overview.live.cat.system": "系统", + "overview.live.cat.hub": "Hub", + + "overview.live.event.session.opened": "对话开启", + "overview.live.event.session.closed": "对话结束", + "overview.live.event.episode.opened": "任务开始", + "overview.live.event.episode.closed": "任务结束", + "overview.live.event.trace.created": "记忆存储", + "overview.live.event.trace.value_updated": "记忆更新", + "overview.live.event.trace.priority_decayed": "记忆衰减", + "overview.live.event.l2.candidate_added": "候选经验新增", + "overview.live.event.l2.candidate_expired": "候选经验过期", + "overview.live.event.l2.induced": "经验生成", + "overview.live.event.l2.associated": "经验关联", + "overview.live.event.l2.revised": "经验修订", + "overview.live.event.l2.boundary_shrunk": "经验边界收紧", + "overview.live.event.l3.abstracted": "环境认知生成", + "overview.live.event.l3.revised": "环境认知更新", + "overview.live.event.skill.crystallized": "技能晶化", + "overview.live.event.skill.eta_updated": "技能预期更新", + "overview.live.event.skill.boundary_updated": "技能边界更新", + "overview.live.event.skill.archived": "技能归档", + "overview.live.event.skill.repaired": "技能修复", + "overview.live.event.retrieval.triggered": "检索触发", + "overview.live.event.retrieval.tier1.hit": "第一层检索命中", + "overview.live.event.retrieval.tier2.hit": "第二层检索命中", + "overview.live.event.retrieval.tier3.hit": "第三层检索命中", + "overview.live.event.retrieval.empty": "检索无结果", + "overview.live.event.feedback.received": "收到反馈", + "overview.live.event.feedback.classified": "反馈分类", + "overview.live.event.reward.computed": "奖励计算", + "overview.live.event.decision_repair.generated": "决策修补", + "overview.live.event.decision_repair.validated": "决策修补已校验", + "overview.live.event.hub.client_connected": "Hub 客户端连接", + "overview.live.event.hub.client_disconnected": "Hub 客户端断开", + "overview.live.event.hub.share_published": "Hub 分享发布", + "overview.live.event.hub.share_received": "Hub 收到分享", + "overview.live.event.system.started": "系统启动", + "overview.live.event.system.shutdown": "系统关闭", + "overview.live.event.system.error": "系统异常", + "overview.live.event.system.config_changed": "配置变更", + "overview.live.event.system.update_available": "可用更新", + + "overview.live.detail.id": "{label} {id}", + "overview.live.detail.idReason": "{label} {id} · {reason}", + "overview.live.detail.candidate": "候选 {sig}", + "overview.live.detail.induced": "{sig} · 来自 {n} 次成功任务", + "overview.live.detail.similarity": "{label} {id} · 相似度 {pct}%", + "overview.live.detail.retrievalHit": "命中 {count} 条 · {ms}ms", + "overview.live.detail.feedbackTone": "情绪 {tone}", + "overview.live.detail.reward": "r = {r} · 来自 {source}", + "overview.live.detail.version": "v{version}", + "overview.live.detail.raw": "{value}", "bridge.connected": "记忆通道已开启", "bridge.reconnecting": "记忆通道已开启", diff --git a/apps/memos-local-plugin/web/src/styles/components.css b/apps/memos-local-plugin/web/src/styles/components.css index 3ce13775..15b8835e 100644 --- a/apps/memos-local-plugin/web/src/styles/components.css +++ b/apps/memos-local-plugin/web/src/styles/components.css @@ -714,53 +714,139 @@ border: 0; } -/* ── Event / log stream (monospace) ────────────────────────────── */ +/* ── Activity dashboard (Overview → Live activity) ──────────────── */ +/* + * Six tiles, one per surfaced category, arranged in a fixed 3 × 2 + * grid on desktop. Each tile owns a `--cat` / `--cat-soft` pair via + * a `cat--*` modifier so the per-category palette lives entirely in + * CSS (the TS components stay colour-agnostic). + * + * On <900 px the grid reflows to 2 columns, then 1 column under + * 560 px — keeps the dashboard usable inside the viewer's narrow + * embedded mode. + */ -.stream { - display: flex; - flex-direction: column; - gap: 2px; - font-family: var(--font-mono); - font-size: var(--fs-xs); - line-height: 1.55; +.dash-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--sp-3); +} +@media (max-width: 900px) { + .dash-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 560px) { + .dash-grid { grid-template-columns: 1fr; } +} + +/* Per-category palette: each modifier sets the two CSS variables + * everything inside the tile reads from. Soft variants drive the + * subtle gradient washes + icon backgrounds; the strong colour drives + * the count number and the sparkline stroke. Tokens are taken from + * the existing semantic palette so themes stay aligned. */ +.dash-tile.cat--memory { --cat: var(--cyan); --cat-soft: var(--info-soft); } +.dash-tile.cat--experience { --cat: var(--accent); --cat-soft: var(--accent-soft); } +.dash-tile.cat--world { --cat: var(--success); --cat-soft: var(--success-soft); } +.dash-tile.cat--skill { --cat: var(--warning); --cat-soft: var(--warning-soft); } +.dash-tile.cat--retrieval { --cat: var(--rose); --cat-soft: var(--rose-bg, rgba(251, 113, 133, 0.10)); } +.dash-tile.cat--feedback { --cat: var(--success); --cat-soft: var(--success-soft); } +.dash-tile.cat--session { --cat: var(--violet); --cat-soft: var(--violet-bg, rgba(167, 139, 250, 0.12)); } +.dash-tile.cat--system { --cat: var(--fg-muted); --cat-soft: var(--bg-hover); } +.dash-tile.cat--hub { --cat: var(--accent); --cat-soft: var(--accent-soft); } + +.dash-tile { + position: relative; + padding: var(--sp-4); + border-radius: var(--radius-md); background: var(--bg-canvas); - padding: var(--sp-3); border: 1px solid var(--border); - border-radius: var(--radius-md); - max-height: 60vh; - overflow-y: auto; + overflow: hidden; + transition: transform var(--dur-xs), border-color var(--dur-xs); } -.stream__line { - display: grid; - /* Middle column must not be a fixed narrow width — types like - * `episode.closed` are longer than ~80px and were painting on top of - * the JSON payload. `auto` sizes to the widest label; `minmax(0,1fr)` - * lets the body shrink and wrap instead of overlapping. */ - grid-template-columns: 72px auto minmax(0, 1fr); - gap: var(--sp-3); - align-items: start; - color: var(--fg-muted); - padding: 3px var(--sp-1); - border-radius: 4px; +.dash-tile:hover { + border-color: var(--border-strong); + transform: translateY(-1px); } -.stream__line:hover { - background: var(--bg-hover); +/* Soft top-left accent wash. The pseudo-element sits beneath the + * content and is muted enough to not interfere with text contrast. */ +.dash-tile::before { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(120% 80% at 0% 0%, var(--cat-soft), transparent 55%); + opacity: 0.7; + pointer-events: none; } -.stream__time { color: var(--fg-dim); } -.stream__level { +.dash-tile > * { + position: relative; +} +.dash-tile__head { + display: flex; + align-items: center; + gap: var(--sp-2); +} +.dash-tile__icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: var(--cat-soft); + color: var(--cat); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.dash-tile__icon svg { width: 16px; height: 16px; } +.dash-tile__name { + font-size: var(--fs-xs); + color: var(--fg-muted); + text-transform: uppercase; + letter-spacing: 0.06em; font-weight: var(--fw-semi); - white-space: nowrap; } -.stream__level--info { color: var(--info); } -.stream__level--warn { color: var(--warning); } -.stream__level--error { color: var(--danger); } -.stream__level--debug { color: var(--fg-dim); } -.stream__body { +.dash-tile__count { + display: flex; + align-items: baseline; + gap: 6px; + margin-top: 10px; +} +.dash-tile__count-n { + font-size: var(--fs-2xl); + font-weight: var(--fw-bold); + letter-spacing: -0.02em; + color: var(--cat); + font-variant-numeric: tabular-nums; + line-height: 1; +} +.dash-tile__count-unit { + font-size: var(--fs-xs); + color: var(--fg-muted); +} +.dash-tile__spark { + height: 28px; + margin-top: 8px; +} +.sparkline { width: 100%; height: 100%; display: block; } +.dash-tile__last { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed var(--border); + font-size: var(--fs-xs); + color: var(--fg-muted); + line-height: 1.45; + min-height: 2.6em; +} +.dash-tile__last strong { color: var(--fg); - min-width: 0; - white-space: pre-wrap; - word-break: break-word; - overflow-wrap: anywhere; + font-weight: var(--fw-semi); + display: block; + margin-bottom: 2px; +} +.dash-tile__last-line { + font-family: var(--font-mono); + font-feature-settings: "tnum"; +} +.dash-tile__last-empty { + color: var(--fg-dim); } /* ── Toasts ─────────────────────────────────────────────────────── */ diff --git a/apps/memos-local-plugin/web/src/views/OverviewView.tsx b/apps/memos-local-plugin/web/src/views/OverviewView.tsx index f304d954..85c1627c 100644 --- a/apps/memos-local-plugin/web/src/views/OverviewView.tsx +++ b/apps/memos-local-plugin/web/src/views/OverviewView.tsx @@ -13,16 +13,22 @@ * "gpt-4.1-mini", not "openai_compatible". When the skill evolver * inherits from the main LLM we say so explicitly. * - * Third row = live SSE activity stream (unchanged). + * Third row = live activity dashboard. Six per-category tiles + * (memory / experience / environment knowledge / skill / retrieval / + * feedback) each showing a 5-minute event count, sparkline, and the + * most recent event in plain language. Tiles are bucketed off the + * same SSE buffer (`recent`) we already maintain. See + * `views/overview/ActivityDashboard.tsx` for the renderer and + * `views/overview/event-meta.ts` for the event-type → tile mapping. */ import { useEffect, useState } from "preact/hooks"; import { api } from "../api/client"; import { openSse } from "../api/sse"; import { health } from "../stores/health"; import { t } from "../stores/i18n"; -import { Icon } from "../components/Icon"; import { navigate } from "../stores/router"; -import type { CoreEvent } from "../api/types"; +import type { ApiLogDTO, CoreEvent, CoreEventType } from "../api/types"; +import { ActivityDashboard } from "./overview/ActivityDashboard"; interface SkillStats { total: number; @@ -65,9 +71,14 @@ interface OverviewSummary { skillEvolver?: ModelInfo; } +interface ApiLogsResponse { + logs: ApiLogDTO[]; +} + export function OverviewView() { const [summary, setSummary] = useState(null); const [recent, setRecent] = useState([]); + const [recentApiLogEvents, setRecentApiLogEvents] = useState([]); useEffect(() => { const ctrl = new AbortController(); @@ -86,10 +97,14 @@ export function OverviewView() { }, []); useEffect(() => { + // The dashboard buckets events into a 5-minute sliding window + // (30 buckets × 10 s). Cap the buffer at 256 so we keep enough + // history even on chatty agents (trace + retrieval + feedback can + // each fire several times per minute) without growing unbounded. const handle = openSse("/api/v1/events", (_, data) => { try { const evt = JSON.parse(data) as CoreEvent; - setRecent((prev) => [evt, ...prev].slice(0, 12)); + setRecent((prev) => [evt, ...prev].slice(0, 256)); } catch { /* skip */ } @@ -97,6 +112,32 @@ export function OverviewView() { return () => handle.close(); }, []); + useEffect(() => { + const ctrl = new AbortController(); + const load = () => + api + .get("/api/v1/api-logs?limit=200&offset=0", { + signal: ctrl.signal, + }) + .then((res) => { + setRecentApiLogEvents( + (res.logs ?? []) + .map(apiLogToCoreEvent) + .filter((evt): evt is CoreEvent => evt !== null), + ); + }) + .catch(() => void 0); + void load(); + // api_logs is the durable source behind the Logs page. Polling it + // keeps the overview heartbeat alive even when the volatile CoreEvent + // SSE stream misses a lifecycle event or the viewer connects late. + const id = window.setInterval(load, 10_000); + return () => { + ctrl.abort(); + window.clearInterval(id); + }; + }, []); + const h = health.value; const skills = summary?.skills; const policies = summary?.policies; @@ -200,41 +241,177 @@ export function OverviewView() { /> + {/* + * Row 3: live activity dashboard. Replaces the previous JSON + * `.stream` block with a 3 × 2 grid of category tiles + * (memory / experience / environment knowledge / skill / + * retrieval / feedback) each showing a 5-minute sparkline plus + * the latest event in plain language. The component owns its + * own clock tick so sparklines slide left even while the SSE + * stream is quiet. + */}

{t("overview.live.title")}

-

{t("overview.live.subtitle")}

- {recent.length === 0 ? ( -
-
- -
-
{t("overview.live.empty")}
-
{t("overview.live.hint")}
-
- ) : ( -
- {recent.map((evt) => ( -
- {new Date(evt.ts).toLocaleTimeString()} - - {evt.type} - - - {JSON.stringify(evt.payload ?? {}).slice(0, 240)} - -
- ))} -
- )} +
); } +function mergeRecentEvents( + liveEvents: readonly CoreEvent[], + apiLogEvents: readonly CoreEvent[], +): CoreEvent[] { + const byKey = new Map(); + for (const evt of [...apiLogEvents, ...liveEvents]) { + const id = evt.correlationId ?? evt.seq; + byKey.set(`${evt.type}:${id}:${evt.ts}`, evt); + } + return [...byKey.values()].sort((a, b) => b.ts - a.ts).slice(0, 512); +} + +function apiLogToCoreEvent(log: ApiLogDTO): CoreEvent | null { + const output = parseJsonObject(log.outputJson); + const input = parseJsonObject(log.inputJson); + const basePayload = { + apiLogId: log.id, + toolName: log.toolName, + success: log.success, + durationMs: log.durationMs, + input, + output, + }; + const type = apiLogEventType(log, output); + if (!type) return null; + return { + type, + ts: log.calledAt, + seq: -1_000_000 - log.id, + correlationId: apiLogCorrelationId(log, input, output), + payload: apiLogPayload(log, type, basePayload, input, output), + }; +} + +function apiLogEventType( + log: ApiLogDTO, + output: Record, +): CoreEventType | null { + switch (log.toolName) { + case "memory_add": + return "trace.created"; + case "memory_search": + return hasRetrievalHits(output) ? "retrieval.tier1.hit" : "retrieval.empty"; + case "policy_generate": + return "l2.induced"; + case "policy_evolve": + return "l2.revised"; + case "world_model_generate": + return "l3.abstracted"; + case "world_model_evolve": + return "l3.revised"; + case "skill_generate": + return "skill.crystallized"; + case "skill_evolve": + return skillEventType(output); + default: + return null; + } +} + +function skillEventType(output: Record): CoreEventType { + const kind = stringField(output, "kind"); + if (kind === "skill.archived") return "skill.archived"; + if (kind === "skill.eta.updated") return "skill.eta_updated"; + return "skill.repaired"; +} + +function apiLogCorrelationId( + log: ApiLogDTO, + input: Record, + output: Record, +): string { + return ( + stringField(output, "traceId") ?? + stringField(output, "policyId") ?? + stringField(output, "worldModelId") ?? + stringField(output, "skillId") ?? + stringField(input, "episodeId") ?? + stringField(input, "sessionId") ?? + `api-log-${log.id}` + ); +} + +function apiLogPayload( + log: ApiLogDTO, + type: CoreEventType, + basePayload: Record, + input: Record, + output: Record, +): Record { + if (type === "trace.created") { + const details = Array.isArray(output.details) ? output.details : []; + const firstDetail = + details.find((item): item is Record => !!item && typeof item === "object") ?? + {}; + return { + ...basePayload, + traceId: stringField(firstDetail, "traceId") ?? `api-log-${log.id}`, + episodeId: stringField(input, "episodeId"), + sessionId: stringField(input, "sessionId"), + }; + } + if (type === "retrieval.tier1.hit" || type === "retrieval.empty") { + const hits = retrievalHitCount(output); + return { + ...basePayload, + sessionId: stringField(input, "sessionId"), + episodeId: stringField(input, "episodeId"), + stats: { + hits, + latencyMs: log.durationMs, + }, + }; + } + return { + ...basePayload, + policyId: stringField(output, "policyId") ?? stringField(input, "policyId"), + worldModelId: stringField(output, "worldModelId") ?? stringField(input, "worldModelId"), + skillId: stringField(output, "skillId") ?? stringField(input, "skillId"), + episodeId: stringField(output, "episodeId") ?? stringField(input, "episodeId"), + signature: stringField(output, "title") ?? stringField(input, "title"), + }; +} + +function hasRetrievalHits(output: Record): boolean { + return retrievalHitCount(output) > 0; +} + +function retrievalHitCount(output: Record): number { + const filtered = Array.isArray(output.filtered) ? output.filtered.length : 0; + const candidates = Array.isArray(output.candidates) ? output.candidates.length : 0; + return filtered || candidates; +} + +function parseJsonObject(raw: string): Record { + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function stringField(obj: Record, key: string): string | undefined { + const value = obj[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + function QuantityCard({ label, value, diff --git a/apps/memos-local-plugin/web/src/views/overview/ActivityDashboard.tsx b/apps/memos-local-plugin/web/src/views/overview/ActivityDashboard.tsx new file mode 100644 index 00000000..a3216f74 --- /dev/null +++ b/apps/memos-local-plugin/web/src/views/overview/ActivityDashboard.tsx @@ -0,0 +1,174 @@ +/** + * Activity dashboard — the 3 × 2 grid that replaces the old JSON + * `.stream` block on the Overview page. + * + * Six tiles, one per surfaced category (memory / experience / + * environment knowledge / skill / retrieval / feedback). Each tile + * shows: + * - icon + localised category name + * - 5-minute event count (big number) + * - 30-bucket sparkline (10 s each) + * - the most recent event in this category, in plain language + * + * The component is a pure renderer of an event window — the parent + * (`OverviewView`) is responsible for keeping a rolling buffer fed + * by SSE. We re-bin every render based on `Date.now()` so the + * sparklines slide left as time passes even when no new events + * arrive (this matches what users expect from a "last 5 minutes" + * label). + */ +import { useEffect, useState } from "preact/hooks"; +import type { JSX } from "preact"; + +import type { CoreEvent } from "../../api/types"; +import { Icon } from "../../components/Icon"; +import { t } from "../../stores/i18n"; + +import { + CATEGORY_META, + TILE_CATEGORIES, + decorateEvent, + type DecoratedEvent, + type EventCategory, +} from "./event-meta"; +import { Sparkline } from "./Sparkline"; + +// ─── Tunables ──────────────────────────────────────────────────────────── +// +// 30 × 10 s = 300 s = 5 min. The choice of bucket size trades smoothness +// (smaller bucket → finer sparkline) against rendering cost; 10 s strikes +// a comfortable balance for a tile that's ~28 px tall. +const BUCKET_COUNT = 30; +const BUCKET_MS = 10_000; +const WINDOW_MS = BUCKET_COUNT * BUCKET_MS; + +// We re-bin once per BUCKET_MS so the sparkline's leftmost column drops off +// the strip exactly when its underlying events fall out of the window. +const REBIN_MS = BUCKET_MS; + +interface ActivityDashboardProps { + /** + * Rolling buffer of events newest-first (the shape `OverviewView` + * already maintains). Older events past the 5-minute window are + * filtered out at render time, so the parent doesn't have to be + * fastidious about pruning. + */ + events: readonly CoreEvent[]; +} + +interface TileData { + buckets: number[]; + count: number; + /** Most recent event in this category within the window, or null. */ + last: DecoratedEvent | null; +} + +/** + * Bucketise the events and pluck the latest one, in a single pass. + * `now` is parameterised so callers can re-render on a fixed clock + * tick instead of `Date.now()` drift mid-render. + */ +function buildTileData( + events: readonly CoreEvent[], + cat: EventCategory, + now: number, +): TileData { + const buckets: number[] = new Array(BUCKET_COUNT).fill(0); + let count = 0; + let lastTs = -Infinity; + let lastEvt: CoreEvent | null = null; + for (const evt of events) { + if (evt.ts < now - WINDOW_MS) continue; + const decorated = decorateEvent(evt); + if (decorated.cat !== cat) continue; + const idx = BUCKET_COUNT - 1 - Math.floor((now - evt.ts) / BUCKET_MS); + if (idx < 0 || idx >= BUCKET_COUNT) continue; + buckets[idx]++; + count++; + if (evt.ts > lastTs) { + lastTs = evt.ts; + lastEvt = evt; + } + } + return { + buckets, + count, + last: lastEvt ? decorateEvent(lastEvt) : null, + }; +} + +function formatRelative(ts: number, now: number): string { + const diff = Math.max(0, Math.round((now - ts) / 1000)); + if (diff < 5) return t("common.justNow"); + if (diff < 60) return t("common.secondsAgo", { n: diff }); + const m = Math.round(diff / 60); + if (m < 60) return t("common.minutesAgo", { n: m }); + const h = Math.round(m / 60); + if (h < 24) return t("common.hoursAgo", { n: h }); + const d = Math.round(h / 24); + return t("common.daysAgo", { n: d }); +} + +export function ActivityDashboard({ + events, +}: ActivityDashboardProps): JSX.Element { + // `now` ticks every BUCKET_MS so sparklines visibly slide left even + // when the SSE stream is quiet for a moment. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), REBIN_MS); + return () => window.clearInterval(id); + }, []); + + return ( +
+ {TILE_CATEGORIES.map((cat) => ( + + ))} +
+ ); +} + +interface TileProps { + cat: EventCategory; + data: TileData; + now: number; +} + +function Tile({ cat, data, now }: TileProps): JSX.Element { + const meta = CATEGORY_META[cat]; + return ( +
+
+ + {t(meta.labelKey as never)} +
+
+ {data.count} + {t("overview.live.tile.count")} +
+
+ +
+
+ {data.last ? ( + <> + {data.last.title} + + {data.last.detail + ? `${data.last.detail} · ${formatRelative(data.last.evt.ts, now)}` + : formatRelative(data.last.evt.ts, now)} + + + ) : ( + {t("overview.live.tile.empty")} + )} +
+
+ ); +} diff --git a/apps/memos-local-plugin/web/src/views/overview/Sparkline.tsx b/apps/memos-local-plugin/web/src/views/overview/Sparkline.tsx new file mode 100644 index 00000000..9d5755f5 --- /dev/null +++ b/apps/memos-local-plugin/web/src/views/overview/Sparkline.tsx @@ -0,0 +1,72 @@ +/** + * A tiny dependency-free SVG sparkline used by the activity + * dashboard. Takes a fixed-size array of bucket counts and renders + * them as a stroked polyline plus a translucent area fill. + * + * Implementation notes: + * - The SVG uses a 100 × 28 internal viewBox and stretches via + * `preserveAspectRatio="none"` so multiple sparklines line up + * pixel-perfectly across tiles regardless of column width. + * - Stroke / fill colour is inherited via the CSS variable `--cat` + * set on the parent tile, which keeps the per-category palette + * in one place (`shared.css`) instead of being passed prop-wise. + * - When the series is flat-zero we still render a one-pixel + * baseline so the tile doesn't visually "snap" the moment a + * first event arrives. + */ +import type { JSX } from "preact"; + +interface SparklineProps { + /** + * Bucket counts, oldest → newest. The dashboard uses 30 buckets of + * 10 seconds each (= last 5 minutes), but the component itself is + * agnostic to bucket size. + */ + buckets: readonly number[]; + /** Optional accessible label, e.g. "Memory activity over 5 minutes". */ + ariaLabel?: string; +} + +export function Sparkline({ buckets, ariaLabel }: SparklineProps): JSX.Element { + const w = 100; + const h = 28; + // 4 px top inset, 2 px bottom inset — gives the stroke headroom so the + // tallest bar isn't clipped by the SVG edge. + const TOP = 4; + const BOTTOM = 2; + const max = Math.max(1, ...buckets); + const stepX = buckets.length > 1 ? w / (buckets.length - 1) : 0; + const points = buckets.map((v, i) => { + const x = (i * stepX).toFixed(2); + const y = (h - (v / max) * (h - TOP - BOTTOM) - BOTTOM).toFixed(2); + return `${x},${y}`; + }); + const linePath = points.length > 0 ? "M" + points.join(" L") : ""; + const areaPath = + points.length > 0 ? `${linePath} L${w},${h} L0,${h} Z` : ""; + + return ( + + {areaPath && ( + + )} + {linePath && ( + + )} + + ); +} diff --git a/apps/memos-local-plugin/web/src/views/overview/event-meta.ts b/apps/memos-local-plugin/web/src/views/overview/event-meta.ts new file mode 100644 index 00000000..247bff8c --- /dev/null +++ b/apps/memos-local-plugin/web/src/views/overview/event-meta.ts @@ -0,0 +1,373 @@ +/** + * Decorates a `CoreEvent` with everything the overview activity + * dashboard needs to render it: category, icon, human title, and + * one-line detail string. The mapping is exhaustive over the closed + * set of `CoreEventType` literals (see `agent-contract/events.ts`). + * + * Intentionally split from the React component so: + * - The ~30-row mapping is unit-testable in isolation. + * - A new event type added in the bridge can extend `EVENT_META` + * in one place without touching presentation. + * - Other surfaces (logs view, dev tools, …) can reuse the same + * human-readable labels later without copy-paste. + * + * Vocabulary follows the product's existing i18n labels — L2 is + * "经验" (NOT 策略), L3 is "环境认知" (NOT 世界观). + */ +import type { IconName } from "../../components/Icon"; +import type { CoreEvent, CoreEventType } from "../../api/types"; +import { t } from "../../stores/i18n"; + +// ─── Public types ──────────────────────────────────────────────────────── + +/** High-level activity grouping the dashboard tiles are sliced by. */ +export type EventCategory = + | "session" + | "memory" + | "experience" + | "world" + | "skill" + | "retrieval" + | "feedback" + | "system" + | "hub"; + +/** The six categories surfaced as tiles, in row-major (3 × 2) order. */ +export const TILE_CATEGORIES: readonly EventCategory[] = [ + "memory", + "experience", + "world", + "skill", + "retrieval", + "feedback", +] as const; + +/** Static metadata for a category (icon / i18n label key). */ +export interface CategoryMeta { + icon: IconName; + /** i18n key under `overview.live.cat.*`. */ + labelKey: string; +} + +export const CATEGORY_META: Record = { + session: { icon: "message-square-text", labelKey: "overview.live.cat.session" }, + memory: { icon: "brain-circuit", labelKey: "overview.live.cat.memory" }, + experience: { icon: "workflow", labelKey: "overview.live.cat.experience" }, + world: { icon: "globe", labelKey: "overview.live.cat.world" }, + skill: { icon: "sparkles", labelKey: "overview.live.cat.skill" }, + retrieval: { icon: "search", labelKey: "overview.live.cat.retrieval" }, + feedback: { icon: "check-circle-2", labelKey: "overview.live.cat.feedback" }, + system: { icon: "settings-2", labelKey: "overview.live.cat.system" }, + hub: { icon: "share", labelKey: "overview.live.cat.hub" }, +}; + +/** Decorated event ready for the activity tile / pill UI. */ +export interface DecoratedEvent { + /** Category bucket the event belongs to. */ + cat: EventCategory; + /** Icon name (Lucide) — defaults to category icon, may override per type. */ + icon: IconName; + /** Human title — fully localised. */ + title: string; + /** One-line detail string — fully localised, may be empty. */ + detail: string; + /** Original event preserved for tooltip / debug payload reveal. */ + evt: CoreEvent; +} + +// ─── Mapping tables ────────────────────────────────────────────────────── + +const TYPE_TO_CAT: Record = { + "session.opened": "session", + "session.closed": "session", + "episode.opened": "session", + "episode.closed": "session", + + "trace.created": "memory", + "trace.value_updated": "memory", + "trace.priority_decayed": "memory", + + "l2.candidate_added": "experience", + "l2.candidate_expired": "experience", + "l2.associated": "experience", + "l2.induced": "experience", + "l2.revised": "experience", + "l2.boundary_shrunk": "experience", + + "l3.abstracted": "world", + "l3.revised": "world", + + "feedback.received": "feedback", + "feedback.classified": "feedback", + "reward.computed": "feedback", + + "skill.crystallized": "skill", + "skill.eta_updated": "skill", + "skill.boundary_updated": "skill", + "skill.archived": "skill", + "skill.repaired": "skill", + + "decision_repair.generated": "feedback", + "decision_repair.validated": "feedback", + + "retrieval.triggered": "retrieval", + "retrieval.tier1.hit": "retrieval", + "retrieval.tier2.hit": "retrieval", + "retrieval.tier3.hit": "retrieval", + "retrieval.empty": "retrieval", + + "hub.client_connected": "hub", + "hub.client_disconnected": "hub", + "hub.share_published": "hub", + "hub.share_received": "hub", + + "system.started": "system", + "system.shutdown": "system", + "system.error": "system", + "system.config_changed": "system", + "system.update_available": "system", +}; + +/** + * Per-type icon overrides. When a type is absent we fall back to the + * category icon. Keep overrides reserved for events that carry a + * meaning visibly different from their bucket — e.g. a decay event + * shouldn't look identical to a fresh write. + */ +const TYPE_ICON_OVERRIDE: Partial> = { + "trace.value_updated": "refresh-cw", + "trace.priority_decayed": "clock", + "l2.revised": "refresh-cw", + "l2.associated": "layers", + "l2.boundary_shrunk": "filter", + "l3.revised": "refresh-cw", + "skill.archived": "archive", + "skill.eta_updated": "gauge", + "skill.boundary_updated": "filter", + "skill.repaired": "wand-sparkles", + "decision_repair.generated": "wand-sparkles", + "decision_repair.validated": "check-circle-2", + "system.started": "zap", + "system.error": "circle-alert", + "system.config_changed": "settings-2", + "system.update_available": "bell", + "hub.client_connected": "plug", + "hub.client_disconnected": "cable", + "hub.share_published": "share", + "hub.share_received": "share-2", +}; + +// ─── Detail formatters ─────────────────────────────────────────────────── + +/** Picks the most descriptive id we can find on a payload. */ +function payloadId(p: unknown, ...keys: string[]): string { + if (!p || typeof p !== "object") return "—"; + for (const k of keys) { + const v = (p as Record)[k]; + if (typeof v === "string" && v.length > 0) return v; + } + return "—"; +} + +function payloadNumber(p: unknown, key: string): number | undefined { + if (!p || typeof p !== "object") return undefined; + const v = (p as Record)[key]; + return typeof v === "number" ? v : undefined; +} + +function payloadString(p: unknown, key: string): string | undefined { + if (!p || typeof p !== "object") return undefined; + const v = (p as Record)[key]; + return typeof v === "string" ? v : undefined; +} + +/** Localised category label — used as the noun prefix in detail strings. */ +function catLabel(cat: EventCategory): string { + return t(CATEGORY_META[cat].labelKey as never); +} + +/** + * Builds the localised detail line for a single event. Returning `""` + * is fine; the dashboard tile collapses an empty detail to just the + * timestamp. + */ +function describeDetail(evt: CoreEvent): string { + const p = evt.payload; + switch (evt.type) { + case "session.opened": + return t("overview.live.detail.id", { + label: catLabel("session"), + id: payloadId(p, "id", "sessionId"), + }); + case "session.closed": + return t("overview.live.detail.idReason", { + label: catLabel("session"), + id: payloadId(p, "sessionId", "id"), + reason: payloadString(p, "reason") ?? "—", + }); + case "episode.opened": + return t("overview.live.detail.id", { + label: t("overview.live.cat.task"), + id: payloadId(p, "id", "episodeId"), + }); + case "episode.closed": { + const id = payloadId( + (p as { episode?: unknown })?.episode ?? p, + "id", + "episodeId", + ); + return t("overview.live.detail.idReason", { + label: t("overview.live.cat.task"), + id, + reason: payloadString(p, "closedBy") ?? "system", + }); + } + + case "trace.created": + case "trace.value_updated": + case "trace.priority_decayed": + return t("overview.live.detail.id", { + label: catLabel("memory"), + id: payloadId(p, "traceId", "id"), + }); + + case "l2.candidate_added": + case "l2.candidate_expired": + return t("overview.live.detail.candidate", { + sig: payloadString(p, "signature") ?? payloadId(p, "candidateId"), + }); + case "l2.induced": { + const evidence = + (p as { evidenceTraceIds?: unknown[] })?.evidenceTraceIds?.length ?? + payloadNumber(p, "evidenceCount") ?? + 0; + return t("overview.live.detail.induced", { + sig: payloadString(p, "signature") ?? "—", + n: evidence, + }); + } + case "l2.associated": { + const sim = payloadNumber(p, "similarity") ?? 0; + return t("overview.live.detail.similarity", { + label: catLabel("experience"), + id: payloadId(p, "policyId"), + pct: Math.round(sim * 100), + }); + } + case "l2.revised": + case "l2.boundary_shrunk": + return t("overview.live.detail.id", { + label: catLabel("experience"), + id: payloadId(p, "policyId", "id"), + }); + + case "l3.abstracted": + case "l3.revised": + return t("overview.live.detail.id", { + label: catLabel("world"), + id: payloadId(p, "worldModelId", "id"), + }); + + case "skill.crystallized": + case "skill.eta_updated": + case "skill.boundary_updated": + case "skill.archived": + case "skill.repaired": + return t("overview.live.detail.id", { + label: catLabel("skill"), + id: payloadId(p, "skillId", "id"), + }); + + case "retrieval.triggered": + case "retrieval.empty": + return t("overview.live.detail.id", { + label: catLabel("session"), + id: payloadId(p, "sessionId"), + }); + case "retrieval.tier1.hit": + case "retrieval.tier2.hit": + case "retrieval.tier3.hit": { + // Bridge sends `{ stats: { hits, latencyMs } }` for retrieval.done; + // we tolerate both flat and nested shapes plus the demo's `count/ms`. + const stats = + ((p as { stats?: Record })?.stats as + | Record + | undefined) ?? (p as Record); + const count = + (typeof stats?.hits === "number" && stats.hits) || + payloadNumber(stats, "count") || + 0; + const ms = + (typeof stats?.latencyMs === "number" && stats.latencyMs) || + payloadNumber(stats, "ms") || + 0; + return t("overview.live.detail.retrievalHit", { count, ms }); + } + + case "feedback.received": + case "feedback.classified": + return t("overview.live.detail.feedbackTone", { + tone: payloadString(p, "tone") ?? "neutral", + }); + case "reward.computed": { + const r = payloadNumber(p, "rHuman") ?? payloadNumber(p, "r") ?? 0; + return t("overview.live.detail.reward", { + r: r.toFixed(2), + source: payloadString(p, "source") ?? "—", + }); + } + case "decision_repair.generated": + case "decision_repair.validated": + return t("overview.live.detail.id", { + label: catLabel("feedback"), + id: payloadId(p, "repairId", "contextHash", "id"), + }); + + case "hub.client_connected": + case "hub.client_disconnected": + return t("overview.live.detail.id", { + label: catLabel("hub"), + id: payloadId(p, "clientId", "id"), + }); + case "hub.share_published": + case "hub.share_received": + return t("overview.live.detail.raw", { + value: payloadString(p, "signature") ?? payloadId(p, "shareId", "id"), + }); + + case "system.started": + return t("overview.live.detail.version", { + version: payloadString(p, "version") ?? "—", + }); + case "system.shutdown": + return t("overview.live.detail.raw", { + value: payloadString(p, "reason") ?? "—", + }); + case "system.config_changed": + return t("overview.live.detail.raw", { + value: payloadString(p, "key") ?? "—", + }); + case "system.error": + return t("overview.live.detail.raw", { + value: payloadString(p, "message") ?? "—", + }); + case "system.update_available": + return t("overview.live.detail.version", { + version: payloadString(p, "version") ?? "—", + }); + } +} + +// ─── Public entry point ────────────────────────────────────────────────── + +/** + * Convert a raw event into the shape the dashboard needs. Pure + * function: no DOM, no async, safe to call inside render. + */ +export function decorateEvent(evt: CoreEvent): DecoratedEvent { + const cat = TYPE_TO_CAT[evt.type]; + const icon = TYPE_ICON_OVERRIDE[evt.type] ?? CATEGORY_META[cat].icon; + const title = t(`overview.live.event.${evt.type}` as never); + const detail = describeDetail(evt); + return { cat, icon, title, detail, evt }; +}