From b56a0c38d31f2a95533beb1d17751884da263fdb Mon Sep 17 00:00:00 2001 From: jiang Date: Fri, 8 May 2026 14:14:57 +0800 Subject: [PATCH 1/3] feat(memos-local-plugin): replace JSON activity stream with category dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Overview page's "Live activity" card used to dump raw JSON payloads into a monospace stream — visually noisy and unreadable for end users. Replace with a 3 × 2 grid of category tiles (Memory / Experience / Environment knowledge / Skill / Retrieval / Feedback). Each tile shows a 5-minute event count, a 30-bucket sparkline (10 s each), and the most recent event in plain language ("Memory stored / 记忆存储", "Experience generated / 经验生成", "Tier 1 retrieval hit / 第一层检索命中", …). Raw payload + event type stay available via the hover tooltip on each tile so power users can still inspect. - new web/src/views/overview/event-meta.ts: exhaustive CoreEventType -> { cat, icon, title, detail } mapping. TS will refuse to compile if a future event type is added without a tile entry here. - new web/src/views/overview/Sparkline.tsx: dependency-free SVG line + area chart, colour bound to --cat CSS variable. - new web/src/views/overview/ActivityDashboard.tsx: 3 × 2 grid with its own 10 s clock tick so tiles slide left even when SSE is quiet. - styles/components.css: drop .stream* block (no other consumers), add .dash-grid / .dash-tile / .dash-tile__* with category-driven --cat / --cat-soft palette pulled from the existing semantic tokens. - i18n.ts: add 9 category labels, 38 event titles, 10 detail templates and 5 relative-time labels in both EN and ZH; drop the now-unused overview.live.{subtitle,empty,hint} keys. - OverviewView.tsx: bump SSE buffer cap from 12 to 256 (a 5-min window with multiple events per minute easily exceeds 12) and swap the .stream block for . Vocabulary mirrors the existing product i18n: L2 → "经验" (not 策略), L3 → "环境认知" (not 世界观). Titles use the noun-verb compound style ("记忆存储" / "经验生成") that operations logs already use elsewhere. --- .../memos-local-plugin/web/src/stores/i18n.ts | 159 +++++++- .../web/src/styles/components.css | 162 ++++++-- .../web/src/views/OverviewView.tsx | 50 ++- .../src/views/overview/ActivityDashboard.tsx | 174 ++++++++ .../web/src/views/overview/Sparkline.tsx | 72 ++++ .../web/src/views/overview/event-meta.ts | 373 ++++++++++++++++++ 6 files changed, 919 insertions(+), 71 deletions(-) create mode 100644 apps/memos-local-plugin/web/src/views/overview/ActivityDashboard.tsx create mode 100644 apps/memos-local-plugin/web/src/views/overview/Sparkline.tsx create mode 100644 apps/memos-local-plugin/web/src/views/overview/event-meta.ts diff --git a/apps/memos-local-plugin/web/src/stores/i18n.ts b/apps/memos-local-plugin/web/src/stores/i18n.ts index 174c5f66..a41547d6 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", @@ -809,6 +890,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} 页", @@ -925,9 +1011,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 e99d452b..b039edf5 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..e53b788d 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 { ActivityDashboard } from "./overview/ActivityDashboard"; interface SkillStats { total: number; @@ -86,10 +92,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 */ } @@ -200,36 +210,22 @@ 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)} - -
- ))} -
- )} +
); 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 }; +} From 65a9520387cf90f2a252856ef1759774a31e0673 Mon Sep 17 00:00:00 2001 From: jiang Date: Fri, 8 May 2026 14:31:14 +0800 Subject: [PATCH 2/3] feat(memos-local-plugin): add overview activity demos --- .../web/demos/overview-activity/index.html | 293 ++++++++ .../web/demos/overview-activity/option-a.html | 142 ++++ .../web/demos/overview-activity/option-b.html | 196 ++++++ .../web/demos/overview-activity/option-c.html | 156 +++++ .../web/demos/overview-activity/shared.css | 651 ++++++++++++++++++ .../web/demos/overview-activity/shared.js | 385 +++++++++++ 6 files changed, 1823 insertions(+) create mode 100644 apps/memos-local-plugin/web/demos/overview-activity/index.html create mode 100644 apps/memos-local-plugin/web/demos/overview-activity/option-a.html create mode 100644 apps/memos-local-plugin/web/demos/overview-activity/option-b.html create mode 100644 apps/memos-local-plugin/web/demos/overview-activity/option-c.html create mode 100644 apps/memos-local-plugin/web/demos/overview-activity/shared.css create mode 100644 apps/memos-local-plugin/web/demos/overview-activity/shared.js diff --git a/apps/memos-local-plugin/web/demos/overview-activity/index.html b/apps/memos-local-plugin/web/demos/overview-activity/index.html new file mode 100644 index 00000000..3f7c89f9 --- /dev/null +++ b/apps/memos-local-plugin/web/demos/overview-activity/index.html @@ -0,0 +1,293 @@ + + + + + 实时活动 卡片改造方案 — 三方案对比 + + + + +
+
+
+ DEMO + 实时活动 卡片改造 · 三方案对比 +
+ +
+ +
+

挑一个想要的形态

+
+

+ 这一页同时渲染三个方案的「实时活动」卡片,全部内置 mock 数据,每隔 4–6 秒会有新事件注入,方便直接对比。 + 点开右上角的 A/B/C 单页可以看完整的「系统总览」(指标卡 + 模型卡 + 实时活动)页面。 + 右上角小太阳/月亮按钮可以切换浅色/深色主题,主题选择会自动记住。 +

+ +
+ + +
+
+
+ 方案 C +

实时仪表盘

+
+ 在完整页面里看 → +
+

3 × 2 网格,按 6 大类(记忆 / 经验 / 环境认知 / 技能 / 检索 / 反馈)展示活跃度。每格一条 sparkline + 5 分钟计数 + 最近一条事件。

+ +
+
+
+

实时活动

+
+
+
+
+
+ + +
+
+
+ 方案 B +

活跃节奏 + 事件流

+
+ 在完整页面里看 → +
+

顶部一条 5 分钟的彩色脉搏带,按大类堆叠彩色柱条,配合分类计数胶囊;下面是逐条事件。

+ +
+
+
+

实时活动

+

最近 5 分钟的脉搏节奏,下面是逐条事件。

+
+
+
+
+
+
+
+
+ 5 分钟前4 分钟前3 分钟前 + 2 分钟前1 分钟前刚刚 +
+
+
+
+
+ + +
+
+
+ 方案 A +

事件流

+
+ 在完整页面里看 → +
+

每行是「分类徽标 + 标题 + 相对时间」的迷你卡片。点击可展开原始 payload。改造最小,体验提升立竿见影。

+ +
+
+
+

实时活动

+

算法核心最近发生的事。

+
+
+
+
+
+ +
+ +
+
说明
+
    +
  • 这是临时静态 demo,不参与正式构建。文件位于 apps/memos-local-plugin/web/demos/overview-activity/
  • +
  • 事件类型与 agent-contract/events.ts 中的 CORE_EVENTS 一一对应;narrative 文案在 shared.jsNARRATIVE 表里集中维护。
  • +
  • 颜色用的是 tokens.css 里的现有变量(light/dark 两套都覆盖),与产品主色完全一致。
  • +
  • 选定方案后,我会把对应 HTML 翻译成 Preact 组件改进 web/src/views/OverviewView.tsx
  • +
+
+
+ + + + + diff --git a/apps/memos-local-plugin/web/demos/overview-activity/option-a.html b/apps/memos-local-plugin/web/demos/overview-activity/option-a.html new file mode 100644 index 00000000..8b6f3945 --- /dev/null +++ b/apps/memos-local-plugin/web/demos/overview-activity/option-a.html @@ -0,0 +1,142 @@ + + + + + 方案 A · 友好事件流 — 系统总览 demo + + + + +
+
+
+ DEMO + 方案 A · 友好事件流 +
+ +
+ +
+ +
+
+
+

实时活动

+

算法核心最近发生的事,按时间倒序排列。

+
+
+ 点击任意一行可展开原始 payload +
+
+
+
+
+ + + + + diff --git a/apps/memos-local-plugin/web/demos/overview-activity/option-b.html b/apps/memos-local-plugin/web/demos/overview-activity/option-b.html new file mode 100644 index 00000000..4d49fa6c --- /dev/null +++ b/apps/memos-local-plugin/web/demos/overview-activity/option-b.html @@ -0,0 +1,196 @@ + + + + + 方案 B · 活跃节奏 + 友好事件流 — 系统总览 demo + + + + +
+
+
+ DEMO + 方案 B · 活跃节奏 + 友好事件流(推荐) +
+ +
+ +
+ +
+
+
+

实时活动

+

最近 5 分钟的脉搏节奏,下面是逐条事件。

+
+
+ 悬停柱条可看那一刻发生了什么 +
+
+ +
+
+
+
+
+
+ 5 分钟前 + 4 分钟前 + 3 分钟前 + 2 分钟前 + 1 分钟前 + 刚刚 +
+
+ +
+
+
+ + + + + diff --git a/apps/memos-local-plugin/web/demos/overview-activity/option-c.html b/apps/memos-local-plugin/web/demos/overview-activity/option-c.html new file mode 100644 index 00000000..6f4aa521 --- /dev/null +++ b/apps/memos-local-plugin/web/demos/overview-activity/option-c.html @@ -0,0 +1,156 @@ + + + + + 方案 C · 实时仪表盘 — 系统总览 demo + + + + +
+
+
+ DEMO + 方案 C · 实时仪表盘 +
+ +
+ +
+ +
+
+
+

实时活动

+
+
+ +
+
+
+ + + + + diff --git a/apps/memos-local-plugin/web/demos/overview-activity/shared.css b/apps/memos-local-plugin/web/demos/overview-activity/shared.css new file mode 100644 index 00000000..68eb30f3 --- /dev/null +++ b/apps/memos-local-plugin/web/demos/overview-activity/shared.css @@ -0,0 +1,651 @@ +/* + * Shared design tokens + base layout for the "实时活动" card demos. + * Mirrors web/src/styles/tokens.css and components.css (only the bits + * needed for the Overview page) so the demo looks identical to the + * real product. + * + * Default theme is LIGHT (matches what most users have on by default + * in the product). The banner ships a light/dark toggle, and the + * `` attribute is the single switch. + */ + +/* ── Type / spacing / radii / motion (theme-independent) ────────── */ +:root { + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", + "PingFang SC", "Noto Sans SC", Roboto, sans-serif; + --font-mono: "SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, + monospace; + + --fs-2xs: 10.5px; + --fs-xs: 11.5px; + --fs-sm: 12.5px; + --fs-md: 13.5px; + --fs-lg: 15px; + --fs-xl: 18px; + --fs-2xl: 24px; + --fs-3xl: 32px; + + --fw-reg: 400; + --fw-med: 500; + --fw-semi: 600; + --fw-bold: 700; + + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-8: 32px; + --sp-12: 48px; + + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-pill: 999px; + + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --dur-xs: 120ms; + --dur-sm: 180ms; + --dur-md: 260ms; +} + +/* ── Light theme (DEFAULT — parity with tokens.css :root[data-theme=light]) ── */ +:root, +:root[data-theme="light"] { + --bg: #f5f6fa; + --bg-card: #ffffff; + --bg-card-hover: #f0f1f5; + --bg-canvas: var(--bg); + --bg-elev-1: var(--bg-card); + --bg-hover: rgba(0, 0, 0, 0.035); + --bg-active: rgba(0, 0, 0, 0.06); + + --border: rgba(0, 0, 0, 0.08); + --border-hover: rgba(0, 0, 0, 0.15); + --border-strong: var(--border-hover); + + --fg: #1a1d2e; + --fg-muted: #5a5f76; + --fg-dim: #9ca3af; + + --pri: #5b6be0; + --pri-dim: rgba(91, 107, 224, 0.10); + --pri-soft: rgba(91, 107, 224, 0.10); + + --green: #059669; + --green-soft: rgba(5, 150, 105, 0.10); + --amber: #d97706; + --amber-soft: rgba(217, 119, 6, 0.10); + --red: #dc2626; + --red-soft: rgba(220, 38, 38, 0.10); + --cyan: #0891b2; + --cyan-soft: rgba(8, 145, 178, 0.10); + --violet: #7c3aed; + --violet-soft: rgba(124, 58, 237, 0.10); + --rose: #e11d48; + --rose-soft: rgba(225, 29, 72, 0.10); + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06); +} + +/* ── Dark theme ─────────────────────────────────────────────────── */ +:root[data-theme="dark"] { + --bg: #0f1117; + --bg-card: #181b23; + --bg-card-hover: #1e2230; + --bg-canvas: var(--bg); + --bg-elev-1: var(--bg-card); + --bg-hover: rgba(255, 255, 255, 0.05); + --bg-active: rgba(255, 255, 255, 0.08); + + --border: rgba(255, 255, 255, 0.07); + --border-hover: rgba(255, 255, 255, 0.14); + --border-strong: var(--border-hover); + + --fg: #e4e6eb; + --fg-muted: #8b8fa4; + --fg-dim: #555a6e; + + --pri: #7c8cf5; + --pri-dim: rgba(124, 140, 245, 0.14); + --pri-soft: rgba(124, 140, 245, 0.10); + + --green: #34d399; + --green-soft: rgba(52, 211, 153, 0.12); + --amber: #fbbf24; + --amber-soft: rgba(251, 191, 36, 0.12); + --red: #f87171; + --red-soft: rgba(248, 113, 113, 0.12); + --cyan: #22d3ee; + --cyan-soft: rgba(34, 211, 238, 0.12); + --violet: #a78bfa; + --violet-soft: rgba(167, 139, 250, 0.12); + --rose: #fb7185; + --rose-soft: rgba(251, 113, 133, 0.12); + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.35); +} + +/* ── Base ───────────────────────────────────────────────────────── */ +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); + font-size: var(--fs-md); + line-height: 1.55; + min-height: 100vh; + transition: background var(--dur-sm), color var(--dur-sm); +} +a { color: var(--pri); text-decoration: none; } +a:hover { text-decoration: underline; } +button { font: inherit; } + +/* ── Page chrome ────────────────────────────────────────────────── */ +.demo-frame { + max-width: 1320px; + margin: 0 auto; + padding: var(--sp-8) var(--sp-6); +} +.demo-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-4); + margin-bottom: var(--sp-6); + padding: var(--sp-3) var(--sp-4); + background: var(--pri-dim); + border: 1px solid var(--pri); + border-color: color-mix(in srgb, var(--pri) 35%, transparent); + border-radius: var(--radius-md); + font-size: var(--fs-sm); + color: var(--fg); + flex-wrap: wrap; +} +.demo-banner__title { + display: flex; + align-items: center; + gap: var(--sp-2); + font-weight: var(--fw-semi); +} +.demo-banner__title .badge { + font-size: var(--fs-2xs); + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 2px 6px; + border-radius: var(--radius-sm); + background: var(--pri); + color: #fff; +} +.demo-banner__nav { + display: flex; + gap: var(--sp-2); + align-items: center; + flex-wrap: wrap; +} +.demo-banner__nav a, +.demo-banner__nav button { + padding: 4px 10px; + border-radius: var(--radius-sm); + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--fg-muted); + font-size: var(--fs-xs); + cursor: pointer; +} +.demo-banner__nav a:hover, +.demo-banner__nav button:hover { color: var(--fg); text-decoration: none; } +.demo-banner__nav a.is-active { + color: var(--pri); + background: var(--pri-soft); + border-color: color-mix(in srgb, var(--pri) 40%, transparent); +} +.demo-banner__theme { + display: inline-flex; + align-items: center; + gap: 4px; +} +.demo-banner__theme button { + width: 28px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} +.demo-banner__theme button svg { width: 14px; height: 14px; } + +.view-header { margin-bottom: var(--sp-5); } +.view-header h1 { + margin: 0; + font-size: var(--fs-2xl); + font-weight: var(--fw-bold); + letter-spacing: -0.02em; +} + +/* ── Card ───────────────────────────────────────────────────────── */ +.card { + background: var(--bg-elev-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--sp-5); + box-shadow: var(--shadow-sm); +} +.card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-3); + margin-bottom: var(--sp-4); +} +.card__title { + margin: 0 0 var(--sp-1) 0; + font-size: var(--fs-lg); + font-weight: var(--fw-semi); + letter-spacing: -0.01em; +} +.card__subtitle { + margin: 0; + color: var(--fg-muted); + font-size: var(--fs-sm); +} +.card__actions { + display: flex; + gap: var(--sp-2); + flex-shrink: 0; +} + +/* ── Metric tiles (top of overview) ─────────────────────────────── */ +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--sp-3); + margin-bottom: var(--sp-6); +} +.metric { + background: var(--bg-elev-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--sp-5); + position: relative; + overflow: hidden; +} +.metric__label { + font-size: var(--fs-xs); + color: var(--fg-muted); + font-weight: var(--fw-med); + letter-spacing: 0.02em; + text-transform: uppercase; + margin-bottom: var(--sp-2); +} +.metric__value { + font-size: var(--fs-3xl); + font-weight: var(--fw-bold); + letter-spacing: -0.02em; + line-height: 1; + color: var(--fg); +} +.metric__delta { + margin-top: var(--sp-2); + font-size: var(--fs-xs); + color: var(--fg-muted); +} +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.status-dot--ok { background: var(--green); box-shadow: 0 0 0 2px color-mix(in srgb, var(--green) 25%, transparent); } + +/* ── Category palette (used across all three options) ───────────── */ +.cat--session { --cat: var(--violet); --cat-soft: var(--violet-soft); } +.cat--memory { --cat: var(--cyan); --cat-soft: var(--cyan-soft); } +.cat--policy { --cat: var(--pri); --cat-soft: var(--pri-soft); } +.cat--world { --cat: var(--green); --cat-soft: var(--green-soft); } +.cat--skill { --cat: var(--amber); --cat-soft: var(--amber-soft); } +.cat--retrieval { --cat: var(--rose); --cat-soft: var(--rose-soft); } +.cat--feedback { --cat: var(--green); --cat-soft: var(--green-soft); } +.cat--system { --cat: var(--fg-muted); --cat-soft: var(--bg-hover); } + +/* tone overrides (e.g. system.error makes the dot red even though + * the system category is muted by default) */ +.tone--err { --cat: var(--red); --cat-soft: var(--red-soft); } +.tone--warn { --cat: var(--amber); --cat-soft: var(--amber-soft); } +.tone--good { --cat: var(--green); --cat-soft: var(--green-soft); } + +/* ── Friendly activity feed (used by Option A & B) ─────────────── */ +.feed { + display: flex; + flex-direction: column; + gap: 4px; +} +.feed__item { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + gap: var(--sp-3); + padding: 10px var(--sp-3); + border-radius: var(--radius-md); + border: 1px solid transparent; + background: transparent; + cursor: pointer; + transition: background var(--dur-xs), border-color var(--dur-xs); + position: relative; +} +.feed__item:hover { + background: var(--bg-hover); + border-color: var(--border); +} +.feed__icon { + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--cat-soft); + color: var(--cat); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.feed__icon svg { width: 18px; height: 18px; } +.feed__main { min-width: 0; } +.feed__title { + font-size: var(--fs-md); + color: var(--fg); + font-weight: var(--fw-med); + line-height: 1.35; + display: flex; + align-items: center; + gap: var(--sp-2); + flex-wrap: wrap; +} +.feed__cat { + font-size: var(--fs-2xs); + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 1px 7px; + border-radius: var(--radius-pill); + background: var(--cat-soft); + color: var(--cat); + font-weight: var(--fw-semi); + white-space: nowrap; +} +.feed__detail { + font-size: var(--fs-sm); + color: var(--fg-muted); + margin-top: 2px; + font-family: var(--font-mono); + font-feature-settings: "tnum"; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.feed__time { + font-size: var(--fs-xs); + color: var(--fg-dim); + white-space: nowrap; + align-self: center; + font-variant-numeric: tabular-nums; +} +@keyframes feed-pop { + 0% { opacity: 0; transform: translateY(-6px) scale(0.98); } + 100% { opacity: 1; transform: translateY(0) scale(1); } +} +.feed__item--new { animation: feed-pop var(--dur-md) var(--ease-out); } + +.feed__item .feed__raw { + display: none; + grid-column: 2 / 4; + margin-top: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: var(--fs-xs); + color: var(--fg-muted); + word-break: break-all; + white-space: pre-wrap; +} +.feed__item.is-open .feed__raw { display: block; } + +/* ── Pulse strip (Option B header) ─────────────────────────────── */ +.pulse { + margin-bottom: var(--sp-4); + padding: var(--sp-4); + border-radius: var(--radius-md); + background: var(--bg-canvas); + border: 1px solid var(--border); +} +.pulse__legend { + display: flex; + flex-wrap: wrap; + gap: var(--sp-3); + margin-bottom: var(--sp-3); + font-size: var(--fs-xs); + color: var(--fg-muted); +} +.pulse__chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: var(--radius-pill); + background: var(--cat-soft); + color: var(--cat); + font-weight: var(--fw-semi); + font-variant-numeric: tabular-nums; +} +.pulse__chip-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--cat); +} +.pulse__strip { + position: relative; + height: 60px; + border-radius: var(--radius-sm); + background: var(--bg-card); + border: 1px solid var(--border); + overflow: hidden; +} +.pulse__bars { + position: absolute; + inset: 0; + display: flex; + align-items: flex-end; +} +.pulse__bar { + flex: 1; + display: flex; + flex-direction: column-reverse; + height: 100%; + border-right: 1px solid var(--border); +} +.pulse__bar:last-child { border-right: none; } +.pulse__bar-seg { + width: 100%; + background: var(--cat); + opacity: 0.85; +} +.pulse__axis { + display: flex; + justify-content: space-between; + margin-top: 6px; + font-size: var(--fs-2xs); + color: var(--fg-dim); + font-variant-numeric: tabular-nums; +} + +/* ── Dashboard tiles (Option C) ────────────────────────────────── */ +/* + * Pinned to 3 columns on desktop → 6 categories render as a clean + * 2 × 3 grid (memory / experience / environment knowledge on row 1, + * skill / retrieval / feedback on row 2). On narrower viewports we + * reflow to 2 then 1 columns instead of the auto-fit minmax behaviour. + */ +.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; } +} +.dash-tile { + position: relative; + padding: var(--sp-4); + border-radius: var(--radius-md); + background: var(--bg-canvas); + border: 1px solid var(--border); + overflow: hidden; + transition: transform var(--dur-xs), border-color var(--dur-xs); +} +.dash-tile:hover { + border-color: var(--border-strong); + transform: translateY(-1px); +} +.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; +} +.dash-tile__head { + display: flex; + align-items: center; + gap: var(--sp-2); + position: relative; +} +.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; +} +.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); +} +.dash-tile__count { + position: relative; + 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(--fg); + font-variant-numeric: tabular-nums; + line-height: 1; +} +.dash-tile__count-unit { + font-size: var(--fs-xs); + color: var(--fg-muted); +} +.dash-tile__spark { + position: relative; + height: 28px; + margin-top: 8px; +} +.dash-tile__spark svg { width: 100%; height: 100%; display: block; } +.dash-tile__last { + position: relative; + 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); + font-weight: var(--fw-semi); + display: block; + margin-bottom: 2px; +} +/* ── Index page: live previews ──────────────────────────────────── */ +.preview-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--sp-5); + margin-top: var(--sp-5); +} +@media (min-width: 1100px) { + .preview-grid { grid-template-columns: repeat(2, 1fr); } + .preview-grid .preview-card:first-child { grid-column: 1 / -1; } +} +.preview-card { + background: var(--bg-elev-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--sp-5); + box-shadow: var(--shadow-sm); +} +.preview-card__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + margin-bottom: var(--sp-3); + flex-wrap: wrap; +} +.preview-card__head h2 { + margin: 0; + font-size: var(--fs-lg); + font-weight: var(--fw-semi); +} +.preview-card__head .label { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-sm); + background: var(--pri-soft); + color: var(--pri); + font-size: var(--fs-2xs); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: var(--fw-semi); +} +.preview-card__head a { + font-size: var(--fs-sm); + color: var(--pri); +} +.preview-card__desc { + margin: 0 0 var(--sp-4); + color: var(--fg-muted); + font-size: var(--fs-sm); + line-height: 1.6; +} + +/* The activity card preview itself nests inside .preview-card and + * mirrors the real Overview card. Reset the outer card padding so we + * don't get double-nested chrome. */ +.preview-card .activity { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--sp-4); +} diff --git a/apps/memos-local-plugin/web/demos/overview-activity/shared.js b/apps/memos-local-plugin/web/demos/overview-activity/shared.js new file mode 100644 index 00000000..5b537f86 --- /dev/null +++ b/apps/memos-local-plugin/web/demos/overview-activity/shared.js @@ -0,0 +1,385 @@ +/* Shared helpers + mock data for the "实时活动" demos. + * Pure vanilla JS, drag any of the option-*.html files into a browser + * and it just works (no bundler required). + * + * Everything is wrapped in an IIFE so we don't leak names like + * `renderIcon` / `decorate` into the global scope — otherwise the + * inline ` - - - diff --git a/apps/memos-local-plugin/web/demos/overview-activity/option-a.html b/apps/memos-local-plugin/web/demos/overview-activity/option-a.html deleted file mode 100644 index 8b6f3945..00000000 --- a/apps/memos-local-plugin/web/demos/overview-activity/option-a.html +++ /dev/null @@ -1,142 +0,0 @@ - - - - - 方案 A · 友好事件流 — 系统总览 demo - - - - -
-
-
- DEMO - 方案 A · 友好事件流 -
- -
- -
- -
-
-
-

实时活动

-

算法核心最近发生的事,按时间倒序排列。

-
-
- 点击任意一行可展开原始 payload -
-
-
-
-
- - - - - diff --git a/apps/memos-local-plugin/web/demos/overview-activity/option-b.html b/apps/memos-local-plugin/web/demos/overview-activity/option-b.html deleted file mode 100644 index 4d49fa6c..00000000 --- a/apps/memos-local-plugin/web/demos/overview-activity/option-b.html +++ /dev/null @@ -1,196 +0,0 @@ - - - - - 方案 B · 活跃节奏 + 友好事件流 — 系统总览 demo - - - - -
-
-
- DEMO - 方案 B · 活跃节奏 + 友好事件流(推荐) -
- -
- -
- -
-
-
-

实时活动

-

最近 5 分钟的脉搏节奏,下面是逐条事件。

-
-
- 悬停柱条可看那一刻发生了什么 -
-
- -
-
-
-
-
-
- 5 分钟前 - 4 分钟前 - 3 分钟前 - 2 分钟前 - 1 分钟前 - 刚刚 -
-
- -
-
-
- - - - - diff --git a/apps/memos-local-plugin/web/demos/overview-activity/option-c.html b/apps/memos-local-plugin/web/demos/overview-activity/option-c.html deleted file mode 100644 index 6f4aa521..00000000 --- a/apps/memos-local-plugin/web/demos/overview-activity/option-c.html +++ /dev/null @@ -1,156 +0,0 @@ - - - - - 方案 C · 实时仪表盘 — 系统总览 demo - - - - -
-
-
- DEMO - 方案 C · 实时仪表盘 -
- -
- -
- -
-
-
-

实时活动

-
-
- -
-
-
- - - - - diff --git a/apps/memos-local-plugin/web/demos/overview-activity/shared.css b/apps/memos-local-plugin/web/demos/overview-activity/shared.css deleted file mode 100644 index 68eb30f3..00000000 --- a/apps/memos-local-plugin/web/demos/overview-activity/shared.css +++ /dev/null @@ -1,651 +0,0 @@ -/* - * Shared design tokens + base layout for the "实时活动" card demos. - * Mirrors web/src/styles/tokens.css and components.css (only the bits - * needed for the Overview page) so the demo looks identical to the - * real product. - * - * Default theme is LIGHT (matches what most users have on by default - * in the product). The banner ships a light/dark toggle, and the - * `` attribute is the single switch. - */ - -/* ── Type / spacing / radii / motion (theme-independent) ────────── */ -:root { - --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", - "PingFang SC", "Noto Sans SC", Roboto, sans-serif; - --font-mono: "SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, - monospace; - - --fs-2xs: 10.5px; - --fs-xs: 11.5px; - --fs-sm: 12.5px; - --fs-md: 13.5px; - --fs-lg: 15px; - --fs-xl: 18px; - --fs-2xl: 24px; - --fs-3xl: 32px; - - --fw-reg: 400; - --fw-med: 500; - --fw-semi: 600; - --fw-bold: 700; - - --sp-1: 4px; - --sp-2: 8px; - --sp-3: 12px; - --sp-4: 16px; - --sp-5: 20px; - --sp-6: 24px; - --sp-8: 32px; - --sp-12: 48px; - - --radius-sm: 6px; - --radius-md: 8px; - --radius-lg: 12px; - --radius-pill: 999px; - - --ease-out: cubic-bezier(0.16, 1, 0.3, 1); - --dur-xs: 120ms; - --dur-sm: 180ms; - --dur-md: 260ms; -} - -/* ── Light theme (DEFAULT — parity with tokens.css :root[data-theme=light]) ── */ -:root, -:root[data-theme="light"] { - --bg: #f5f6fa; - --bg-card: #ffffff; - --bg-card-hover: #f0f1f5; - --bg-canvas: var(--bg); - --bg-elev-1: var(--bg-card); - --bg-hover: rgba(0, 0, 0, 0.035); - --bg-active: rgba(0, 0, 0, 0.06); - - --border: rgba(0, 0, 0, 0.08); - --border-hover: rgba(0, 0, 0, 0.15); - --border-strong: var(--border-hover); - - --fg: #1a1d2e; - --fg-muted: #5a5f76; - --fg-dim: #9ca3af; - - --pri: #5b6be0; - --pri-dim: rgba(91, 107, 224, 0.10); - --pri-soft: rgba(91, 107, 224, 0.10); - - --green: #059669; - --green-soft: rgba(5, 150, 105, 0.10); - --amber: #d97706; - --amber-soft: rgba(217, 119, 6, 0.10); - --red: #dc2626; - --red-soft: rgba(220, 38, 38, 0.10); - --cyan: #0891b2; - --cyan-soft: rgba(8, 145, 178, 0.10); - --violet: #7c3aed; - --violet-soft: rgba(124, 58, 237, 0.10); - --rose: #e11d48; - --rose-soft: rgba(225, 29, 72, 0.10); - - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04); - --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06); -} - -/* ── Dark theme ─────────────────────────────────────────────────── */ -:root[data-theme="dark"] { - --bg: #0f1117; - --bg-card: #181b23; - --bg-card-hover: #1e2230; - --bg-canvas: var(--bg); - --bg-elev-1: var(--bg-card); - --bg-hover: rgba(255, 255, 255, 0.05); - --bg-active: rgba(255, 255, 255, 0.08); - - --border: rgba(255, 255, 255, 0.07); - --border-hover: rgba(255, 255, 255, 0.14); - --border-strong: var(--border-hover); - - --fg: #e4e6eb; - --fg-muted: #8b8fa4; - --fg-dim: #555a6e; - - --pri: #7c8cf5; - --pri-dim: rgba(124, 140, 245, 0.14); - --pri-soft: rgba(124, 140, 245, 0.10); - - --green: #34d399; - --green-soft: rgba(52, 211, 153, 0.12); - --amber: #fbbf24; - --amber-soft: rgba(251, 191, 36, 0.12); - --red: #f87171; - --red-soft: rgba(248, 113, 113, 0.12); - --cyan: #22d3ee; - --cyan-soft: rgba(34, 211, 238, 0.12); - --violet: #a78bfa; - --violet-soft: rgba(167, 139, 250, 0.12); - --rose: #fb7185; - --rose-soft: rgba(251, 113, 133, 0.12); - - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25); - --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.35); -} - -/* ── Base ───────────────────────────────────────────────────────── */ -* { box-sizing: border-box; } -html, body { margin: 0; padding: 0; } -body { - background: var(--bg); - color: var(--fg); - font-family: var(--font-sans); - font-size: var(--fs-md); - line-height: 1.55; - min-height: 100vh; - transition: background var(--dur-sm), color var(--dur-sm); -} -a { color: var(--pri); text-decoration: none; } -a:hover { text-decoration: underline; } -button { font: inherit; } - -/* ── Page chrome ────────────────────────────────────────────────── */ -.demo-frame { - max-width: 1320px; - margin: 0 auto; - padding: var(--sp-8) var(--sp-6); -} -.demo-banner { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--sp-4); - margin-bottom: var(--sp-6); - padding: var(--sp-3) var(--sp-4); - background: var(--pri-dim); - border: 1px solid var(--pri); - border-color: color-mix(in srgb, var(--pri) 35%, transparent); - border-radius: var(--radius-md); - font-size: var(--fs-sm); - color: var(--fg); - flex-wrap: wrap; -} -.demo-banner__title { - display: flex; - align-items: center; - gap: var(--sp-2); - font-weight: var(--fw-semi); -} -.demo-banner__title .badge { - font-size: var(--fs-2xs); - text-transform: uppercase; - letter-spacing: 0.06em; - padding: 2px 6px; - border-radius: var(--radius-sm); - background: var(--pri); - color: #fff; -} -.demo-banner__nav { - display: flex; - gap: var(--sp-2); - align-items: center; - flex-wrap: wrap; -} -.demo-banner__nav a, -.demo-banner__nav button { - padding: 4px 10px; - border-radius: var(--radius-sm); - background: var(--bg-card); - border: 1px solid var(--border); - color: var(--fg-muted); - font-size: var(--fs-xs); - cursor: pointer; -} -.demo-banner__nav a:hover, -.demo-banner__nav button:hover { color: var(--fg); text-decoration: none; } -.demo-banner__nav a.is-active { - color: var(--pri); - background: var(--pri-soft); - border-color: color-mix(in srgb, var(--pri) 40%, transparent); -} -.demo-banner__theme { - display: inline-flex; - align-items: center; - gap: 4px; -} -.demo-banner__theme button { - width: 28px; - height: 24px; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; -} -.demo-banner__theme button svg { width: 14px; height: 14px; } - -.view-header { margin-bottom: var(--sp-5); } -.view-header h1 { - margin: 0; - font-size: var(--fs-2xl); - font-weight: var(--fw-bold); - letter-spacing: -0.02em; -} - -/* ── Card ───────────────────────────────────────────────────────── */ -.card { - background: var(--bg-elev-1); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: var(--sp-5); - box-shadow: var(--shadow-sm); -} -.card__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--sp-3); - margin-bottom: var(--sp-4); -} -.card__title { - margin: 0 0 var(--sp-1) 0; - font-size: var(--fs-lg); - font-weight: var(--fw-semi); - letter-spacing: -0.01em; -} -.card__subtitle { - margin: 0; - color: var(--fg-muted); - font-size: var(--fs-sm); -} -.card__actions { - display: flex; - gap: var(--sp-2); - flex-shrink: 0; -} - -/* ── Metric tiles (top of overview) ─────────────────────────────── */ -.metric-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: var(--sp-3); - margin-bottom: var(--sp-6); -} -.metric { - background: var(--bg-elev-1); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: var(--sp-5); - position: relative; - overflow: hidden; -} -.metric__label { - font-size: var(--fs-xs); - color: var(--fg-muted); - font-weight: var(--fw-med); - letter-spacing: 0.02em; - text-transform: uppercase; - margin-bottom: var(--sp-2); -} -.metric__value { - font-size: var(--fs-3xl); - font-weight: var(--fw-bold); - letter-spacing: -0.02em; - line-height: 1; - color: var(--fg); -} -.metric__delta { - margin-top: var(--sp-2); - font-size: var(--fs-xs); - color: var(--fg-muted); -} -.status-dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} -.status-dot--ok { background: var(--green); box-shadow: 0 0 0 2px color-mix(in srgb, var(--green) 25%, transparent); } - -/* ── Category palette (used across all three options) ───────────── */ -.cat--session { --cat: var(--violet); --cat-soft: var(--violet-soft); } -.cat--memory { --cat: var(--cyan); --cat-soft: var(--cyan-soft); } -.cat--policy { --cat: var(--pri); --cat-soft: var(--pri-soft); } -.cat--world { --cat: var(--green); --cat-soft: var(--green-soft); } -.cat--skill { --cat: var(--amber); --cat-soft: var(--amber-soft); } -.cat--retrieval { --cat: var(--rose); --cat-soft: var(--rose-soft); } -.cat--feedback { --cat: var(--green); --cat-soft: var(--green-soft); } -.cat--system { --cat: var(--fg-muted); --cat-soft: var(--bg-hover); } - -/* tone overrides (e.g. system.error makes the dot red even though - * the system category is muted by default) */ -.tone--err { --cat: var(--red); --cat-soft: var(--red-soft); } -.tone--warn { --cat: var(--amber); --cat-soft: var(--amber-soft); } -.tone--good { --cat: var(--green); --cat-soft: var(--green-soft); } - -/* ── Friendly activity feed (used by Option A & B) ─────────────── */ -.feed { - display: flex; - flex-direction: column; - gap: 4px; -} -.feed__item { - display: grid; - grid-template-columns: 36px minmax(0, 1fr) auto; - gap: var(--sp-3); - padding: 10px var(--sp-3); - border-radius: var(--radius-md); - border: 1px solid transparent; - background: transparent; - cursor: pointer; - transition: background var(--dur-xs), border-color var(--dur-xs); - position: relative; -} -.feed__item:hover { - background: var(--bg-hover); - border-color: var(--border); -} -.feed__icon { - width: 36px; - height: 36px; - border-radius: 10px; - background: var(--cat-soft); - color: var(--cat); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} -.feed__icon svg { width: 18px; height: 18px; } -.feed__main { min-width: 0; } -.feed__title { - font-size: var(--fs-md); - color: var(--fg); - font-weight: var(--fw-med); - line-height: 1.35; - display: flex; - align-items: center; - gap: var(--sp-2); - flex-wrap: wrap; -} -.feed__cat { - font-size: var(--fs-2xs); - text-transform: uppercase; - letter-spacing: 0.06em; - padding: 1px 7px; - border-radius: var(--radius-pill); - background: var(--cat-soft); - color: var(--cat); - font-weight: var(--fw-semi); - white-space: nowrap; -} -.feed__detail { - font-size: var(--fs-sm); - color: var(--fg-muted); - margin-top: 2px; - font-family: var(--font-mono); - font-feature-settings: "tnum"; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.feed__time { - font-size: var(--fs-xs); - color: var(--fg-dim); - white-space: nowrap; - align-self: center; - font-variant-numeric: tabular-nums; -} -@keyframes feed-pop { - 0% { opacity: 0; transform: translateY(-6px) scale(0.98); } - 100% { opacity: 1; transform: translateY(0) scale(1); } -} -.feed__item--new { animation: feed-pop var(--dur-md) var(--ease-out); } - -.feed__item .feed__raw { - display: none; - grid-column: 2 / 4; - margin-top: var(--sp-2); - padding: var(--sp-2) var(--sp-3); - background: var(--bg-canvas); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - font-family: var(--font-mono); - font-size: var(--fs-xs); - color: var(--fg-muted); - word-break: break-all; - white-space: pre-wrap; -} -.feed__item.is-open .feed__raw { display: block; } - -/* ── Pulse strip (Option B header) ─────────────────────────────── */ -.pulse { - margin-bottom: var(--sp-4); - padding: var(--sp-4); - border-radius: var(--radius-md); - background: var(--bg-canvas); - border: 1px solid var(--border); -} -.pulse__legend { - display: flex; - flex-wrap: wrap; - gap: var(--sp-3); - margin-bottom: var(--sp-3); - font-size: var(--fs-xs); - color: var(--fg-muted); -} -.pulse__chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 3px 10px; - border-radius: var(--radius-pill); - background: var(--cat-soft); - color: var(--cat); - font-weight: var(--fw-semi); - font-variant-numeric: tabular-nums; -} -.pulse__chip-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--cat); -} -.pulse__strip { - position: relative; - height: 60px; - border-radius: var(--radius-sm); - background: var(--bg-card); - border: 1px solid var(--border); - overflow: hidden; -} -.pulse__bars { - position: absolute; - inset: 0; - display: flex; - align-items: flex-end; -} -.pulse__bar { - flex: 1; - display: flex; - flex-direction: column-reverse; - height: 100%; - border-right: 1px solid var(--border); -} -.pulse__bar:last-child { border-right: none; } -.pulse__bar-seg { - width: 100%; - background: var(--cat); - opacity: 0.85; -} -.pulse__axis { - display: flex; - justify-content: space-between; - margin-top: 6px; - font-size: var(--fs-2xs); - color: var(--fg-dim); - font-variant-numeric: tabular-nums; -} - -/* ── Dashboard tiles (Option C) ────────────────────────────────── */ -/* - * Pinned to 3 columns on desktop → 6 categories render as a clean - * 2 × 3 grid (memory / experience / environment knowledge on row 1, - * skill / retrieval / feedback on row 2). On narrower viewports we - * reflow to 2 then 1 columns instead of the auto-fit minmax behaviour. - */ -.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; } -} -.dash-tile { - position: relative; - padding: var(--sp-4); - border-radius: var(--radius-md); - background: var(--bg-canvas); - border: 1px solid var(--border); - overflow: hidden; - transition: transform var(--dur-xs), border-color var(--dur-xs); -} -.dash-tile:hover { - border-color: var(--border-strong); - transform: translateY(-1px); -} -.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; -} -.dash-tile__head { - display: flex; - align-items: center; - gap: var(--sp-2); - position: relative; -} -.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; -} -.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); -} -.dash-tile__count { - position: relative; - 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(--fg); - font-variant-numeric: tabular-nums; - line-height: 1; -} -.dash-tile__count-unit { - font-size: var(--fs-xs); - color: var(--fg-muted); -} -.dash-tile__spark { - position: relative; - height: 28px; - margin-top: 8px; -} -.dash-tile__spark svg { width: 100%; height: 100%; display: block; } -.dash-tile__last { - position: relative; - 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); - font-weight: var(--fw-semi); - display: block; - margin-bottom: 2px; -} -/* ── Index page: live previews ──────────────────────────────────── */ -.preview-grid { - display: grid; - grid-template-columns: 1fr; - gap: var(--sp-5); - margin-top: var(--sp-5); -} -@media (min-width: 1100px) { - .preview-grid { grid-template-columns: repeat(2, 1fr); } - .preview-grid .preview-card:first-child { grid-column: 1 / -1; } -} -.preview-card { - background: var(--bg-elev-1); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: var(--sp-5); - box-shadow: var(--shadow-sm); -} -.preview-card__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--sp-3); - margin-bottom: var(--sp-3); - flex-wrap: wrap; -} -.preview-card__head h2 { - margin: 0; - font-size: var(--fs-lg); - font-weight: var(--fw-semi); -} -.preview-card__head .label { - display: inline-block; - padding: 2px 8px; - border-radius: var(--radius-sm); - background: var(--pri-soft); - color: var(--pri); - font-size: var(--fs-2xs); - text-transform: uppercase; - letter-spacing: 0.06em; - font-weight: var(--fw-semi); -} -.preview-card__head a { - font-size: var(--fs-sm); - color: var(--pri); -} -.preview-card__desc { - margin: 0 0 var(--sp-4); - color: var(--fg-muted); - font-size: var(--fs-sm); - line-height: 1.6; -} - -/* The activity card preview itself nests inside .preview-card and - * mirrors the real Overview card. Reset the outer card padding so we - * don't get double-nested chrome. */ -.preview-card .activity { - background: var(--bg-canvas); - border: 1px solid var(--border); - border-radius: var(--radius-md); - padding: var(--sp-4); -} diff --git a/apps/memos-local-plugin/web/demos/overview-activity/shared.js b/apps/memos-local-plugin/web/demos/overview-activity/shared.js deleted file mode 100644 index 5b537f86..00000000 --- a/apps/memos-local-plugin/web/demos/overview-activity/shared.js +++ /dev/null @@ -1,385 +0,0 @@ -/* Shared helpers + mock data for the "实时活动" demos. - * Pure vanilla JS, drag any of the option-*.html files into a browser - * and it just works (no bundler required). - * - * Everything is wrapped in an IIFE so we don't leak names like - * `renderIcon` / `decorate` into the global scope — otherwise the - * inline `