From 4bf05f6784292a021405b5555b3761bd9c4dbf47 Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Thu, 4 Jun 2026 10:14:08 +0800 Subject: [PATCH] feat(web): add privacy-friendly Umami custom events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instrument the play flow with 9 content-free custom events (game_start, art_style_select, style_image_upload, scene_reached, choice_select, vision_click, tts_toggle, fullscreen_toggle, play_heartbeat) to measure retention, engagement depth and session duration. Privacy is enforced by construction, not convention: - lib/analytics.ts types each event with a discriminated union, so a payload has no slot for free text — prompts, world guides, uploaded images and vision output can never reach analytics (compile-time guarantee, not a comment). - track() no-ops without window.umami and never throws into the app. - coarse 30s heartbeat fires only while the tab is visible. - script stays gated on NEXT_PUBLIC_UMAMI_* env (blank → no script), honours Do-Not-Track, and locks to an exact data-domains allowlist. - one-line on-site disclosure with a link, shown only when tracking is on. Co-Authored-By: Claude Opus 4.7 --- .env.example | 15 +++++++-- app/page.tsx | 45 +++++++++++++++++++++++-- app/play/page.tsx | 28 ++++++++++++++++ components/Analytics.tsx | 11 +++++-- components/CustomForm.tsx | 2 ++ lib/analytics.ts | 69 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 lib/analytics.ts diff --git a/.env.example b/.env.example index f0af896..727f97e 100644 --- a/.env.example +++ b/.env.example @@ -73,7 +73,18 @@ NEXT_PUBLIC_IMAGE_PROXY_URL= # NEXT_PUBLIC_UMAMI_SRC=https://cloud.umami.is/script.js # Self-host later: point SRC at your own instance — the integration is identical # (no code change), e.g. NEXT_PUBLIC_UMAMI_SRC=https://stats.example.com/script.js -# Both blank → no script is injected (zero tracking). NEXT_PUBLIC_ vars are -# inlined at BUILD time, so set them in the build env (Vercel project settings). +# Both blank → no script is injected (zero tracking; every track() call no-ops). +# Beyond page views the app emits content-free custom events (game start, scene +# reached, choice picked, ...) — only enums/counts/booleans, never your prompts, +# uploaded images or any per-user ID. The visitor's Do-Not-Track is honoured. +# NEXT_PUBLIC_ vars are inlined at BUILD time, so set them in the build env +# (Vercel project settings). NEXT_PUBLIC_UMAMI_SRC= NEXT_PUBLIC_UMAMI_WEBSITE_ID= + +# Optional hostname allowlist — defense-in-depth on top of the blank-to-disable +# gate above. The tracker fires only when window.location.hostname EXACTLY +# matches an entry, so a fork that copied these vars stays silent on its own +# domain. Comma-separated, exact match: apex ≠ www (list both), no wildcards. +# Blank → track on all hosts. e.g. infiplot.com,www.infiplot.com +NEXT_PUBLIC_UMAMI_DOMAINS= diff --git a/app/page.tsx b/app/page.tsx index 24ca848..1398894 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,6 +2,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; +import { track } from "@/lib/analytics"; /* ============================================================================ InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型) @@ -1008,9 +1009,11 @@ function StyleModal({ // 用户事后还可以手动改 draft(仍是 textarea)。 setDraft(data.stylePrompt); setCustomStyleRefImage(resized); + track("style_image_upload", { ok: true }); } catch (err) { const msg = err instanceof Error ? err.message : "解析失败"; setParseError(msg); + track("style_image_upload", { ok: false }); } finally { setParsing(false); } @@ -1524,6 +1527,17 @@ export default function HomePage() { const styleReferenceImage = artStyle === "自定义" && customStyleRefImage ? customStyleRefImage : undefined; + track("game_start", { + source: "prompt", + gender, + art_style: artStyle, + plot_style: plotStyle, + pacing: pace, + tts: audioEnabled, + has_prompt: prompt.trim().length > 0, + has_style_ref: Boolean(styleReferenceImage), + }); + sessionStorage.setItem( "infiplot:custom", JSON.stringify({ worldSetting, styleGuide, audioEnabled, styleReferenceImage }), @@ -1533,6 +1547,9 @@ export default function HomePage() { const stories = STORIES[galleryGender]; const imgPrefix = galleryGender === "女性向" ? "f" : "m"; + const analyticsOn = Boolean( + process.env.NEXT_PUBLIC_UMAMI_SRC && process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, + ); // 点卡片 = 直接开始这张卡的故事,零等待:跳 /play?card=m0/f0... 由 /play // 页面从 /home/firstact/{name}.json 静态文件加载预烘焙好的首幕(含 scene / @@ -1547,6 +1564,12 @@ export default function HomePage() { "infiplot:custom", JSON.stringify({ worldSetting: "", styleGuide: "", audioEnabled }), ); + track("game_start", { + source: "curated", + gender: galleryGender, + tts: audioEnabled, + card: `${imgPrefix}${idx}`, + }); router.push(`/play?card=${imgPrefix}${idx}`); }; @@ -1778,8 +1801,21 @@ export default function HomePage() { 目前,内测期间生成的内容不会被保存,如有需要,请通过录屏或截图等方式保存游玩体验,并记录下生成故事时的提示词与风格选项等。
AI 生成的内容不代表本团队立场。 -
- 本站使用开源的 Umami 进行隐私友好的匿名访问统计:不使用 Cookie、不收集个人信息、不做跨站追踪。 + {analyticsOn && ( + <> +
+ 本站使用开源的{" "} + + Umami + {" "} + 进行隐私友好的匿名访问与交互统计:不使用 Cookie、不收集个人信息、不发送任何您输入的内容、不做跨站追踪。 + + )}

@@ -1794,7 +1830,10 @@ export default function HomePage() { setSel((s) => s.map((v, j) => (j === styleRow ? i : v)))} + onPick={(i) => { + track("art_style_select", { style: OPTS[styleRow]!.items[i] ?? String(i) }); + setSel((s) => s.map((v, j) => (j === styleRow ? i : v))); + }} onClose={() => setStyleOpen(false)} customStyleGuide={customStyleGuide} setCustomStyleGuide={setCustomStyleGuide} diff --git a/app/play/page.tsx b/app/play/page.tsx index 4cb4867..765b1da 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -26,6 +26,7 @@ import type { StartResponse, VisionResponse, } from "@infiplot/types"; +import { track } from "@/lib/analytics"; const MUTED_STORAGE_KEY = "infiplot:muted"; @@ -373,6 +374,17 @@ function PlayInner() { mutedRef.current = muted; }, [muted]); + // Coarse liveness ping for active-time analytics. /play is a single SPA + // route, so page views alone read as ~0 duration; a 30s heartbeat (only + // while the tab is visible) gives Umami the timestamps to derive real + // engaged time. Content-free — no payload, and a no-op without the tracker. + useEffect(() => { + const id = window.setInterval(() => { + if (document.visibilityState === "visible") track("play_heartbeat"); + }, 30_000); + return () => window.clearInterval(id); + }, []); + // Whenever currentBeatId changes, append it to visited (skip consecutive dups) useEffect(() => { if (!currentBeatId) return; @@ -472,6 +484,7 @@ function PlayInner() { // ── Mute persistence (read is via the useState lazy initializer above) ─ const toggleMuted = useCallback(() => { + track("tts_toggle", { muted: !mutedRef.current }); setMuted((prev) => { const next = !prev; try { @@ -507,6 +520,7 @@ function PlayInner() { // ── Presentation mode toggle ───────────────────────────────────────── const togglePresentation = useCallback(async () => { const entering = !presentation; + track("fullscreen_toggle", { on: entering }); if (entering) { try { if (!document.fullscreenElement) { @@ -671,6 +685,7 @@ function PlayInner() { // beatAudioMap is populated lazily by the per-beat fetch effect once // currentScene becomes non-null (see fetchBeatAudio). setPhase("ready"); + track("scene_reached", { scene_index: initial.history.length }); }) .catch((e) => setError(String(e))); }, [params, router]); @@ -782,6 +797,7 @@ function PlayInner() { // beatAudioMap reset + per-beat fetches kicked off by the scene effect. setLastExitLabel(exitLabel); setPhase("ready"); + track("scene_reached", { scene_index: newSession.history.length }); } catch (e) { if ((e as { name?: string }).name === "AbortError") { setPhase("ready"); @@ -795,6 +811,17 @@ function PlayInner() { function onSelectChoice(choice: BeatChoice) { if (phase !== "ready" || !session || !currentScene) return; + const beatNext = currentBeatRef.current?.next; + const choiceIndex = + beatNext?.type === "choice" + ? beatNext.choices.findIndex((c) => c.id === choice.id) + : -1; + track("choice_select", { + scene_index: session.history.length, + choice_index: choiceIndex, + kind: choice.effect.kind, + }); + if (choice.effect.kind === "advance-beat") { // Pure local jump. No network. No pool changes. setCurrentBeatId(choice.effect.targetBeatId); @@ -863,6 +890,7 @@ function PlayInner() { throw new Error(j.error ?? visionRes.statusText); } const decision = (await visionRes.json()) as VisionResponse; + track("vision_click", { result: decision.classify }); if (decision.classify === "insert-beat") { setPhase("inserting-beat"); diff --git a/components/Analytics.tsx b/components/Analytics.tsx index 375e3cc..e4145b4 100644 --- a/components/Analytics.tsx +++ b/components/Analytics.tsx @@ -1,16 +1,23 @@ import Script from "next/script"; -// Privacy-friendly, cookieless page-view analytics (Umami). Both env vars -// unset → render nothing, so local dev and forks never report to our instance. +// Privacy-friendly, cookieless analytics (Umami). Both env vars unset → +// render nothing, so local dev and forks never report to our instance. +// - data-do-not-track: honour the visitor's browser Do Not Track setting. +// - data-domains (NEXT_PUBLIC_UMAMI_DOMAINS): extra guard — the tracker only +// fires when the live hostname matches, so even a fork that copied our env +// vars stays silent on a different domain. Unset → run on all hosts. export function Analytics() { const src = process.env.NEXT_PUBLIC_UMAMI_SRC; const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID; + const domains = process.env.NEXT_PUBLIC_UMAMI_DOMAINS; if (!src || !websiteId) return null; return (