dsh-heatmap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * browser 半部:本地事件环形缓冲 + 运行时设置 + 授权状态(全部 localStorage)。
3
+ *
4
+ * 设计要点:
5
+ * - 数据默认只在用户本地(localStorage),热力图读取本地数据,无需授权;
6
+ * - 上传动作才可能触发授权弹窗,授权结果也持久化在本地;
7
+ * - 运行时设置是「当前」可变对象,供埋点引擎与面板共享。
8
+ */
9
+ import type { AnalyticsBatch, AnalyticsEvent } from '../shared/types.ts'
10
+
11
+ const EVENTS_KEY = 'dsh-heatmap.events.v1'
12
+ const SETTINGS_KEY = 'dsh-heatmap.settings.v1'
13
+ const CONSENT_KEY = 'dsh-heatmap.consent.v1'
14
+
15
+ export interface ClientSettings {
16
+ /** 是否启用热力图覆盖层。 */
17
+ heatmapMode: boolean
18
+ /** 是否启用埋点采集。 */
19
+ trackingEnabled: boolean
20
+ /** 是否采集鼠标悬停坐标(供热力图,默认关闭以控制数据量)。 */
21
+ trackMouse: boolean
22
+ /** 采样率 0..1。 */
23
+ sampleRate: number
24
+ /** 上传目标;空则上传到同源 host 收集器 `/dsh-heatmap/ingest`。 */
25
+ uploadEndpoint: string
26
+ /** 上传前是否要求授权弹窗。 */
27
+ consentRequired: boolean
28
+ /** 本地最多保留事件条数。 */
29
+ maxEvents: number
30
+ }
31
+
32
+ export const DEFAULT_SETTINGS: ClientSettings = {
33
+ heatmapMode: false,
34
+ trackingEnabled: true,
35
+ trackMouse: false,
36
+ sampleRate: 1,
37
+ uploadEndpoint: '',
38
+ consentRequired: true,
39
+ maxEvents: 5000,
40
+ }
41
+
42
+ function readSettings(): ClientSettings {
43
+ try {
44
+ const raw = localStorage.getItem(SETTINGS_KEY)
45
+ if (!raw) return { ...DEFAULT_SETTINGS }
46
+ return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) as Partial<ClientSettings> }
47
+ } catch {
48
+ return { ...DEFAULT_SETTINGS }
49
+ }
50
+ }
51
+
52
+ /** 当前生效设置(可变引用,供埋点引擎实时读取)。 */
53
+ let currentSettings: ClientSettings = readSettings()
54
+
55
+ export function getSettings(): ClientSettings {
56
+ return currentSettings
57
+ }
58
+
59
+ export function saveSettings(next: ClientSettings): void {
60
+ currentSettings = { ...next }
61
+ try {
62
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(currentSettings))
63
+ } catch {
64
+ /* storage unavailable */
65
+ }
66
+ }
67
+
68
+ let currentSessionId = ''
69
+
70
+ export function getSessionId(): string {
71
+ return currentSessionId
72
+ }
73
+
74
+ export function setSessionId(id: string): void {
75
+ currentSessionId = id
76
+ }
77
+
78
+ /** 本地事件环形缓冲(内存 + localStorage 持久化)。 */
79
+ class ClientEventStore {
80
+ private events: AnalyticsEvent[] = []
81
+ private loaded = false
82
+
83
+ private ensureLoaded(): void {
84
+ if (this.loaded) return
85
+ this.loaded = true
86
+ try {
87
+ const raw = localStorage.getItem(EVENTS_KEY)
88
+ this.events = raw ? JSON.parse(raw) as AnalyticsEvent[] : []
89
+ } catch {
90
+ this.events = []
91
+ }
92
+ }
93
+
94
+ push(event: AnalyticsEvent, maxEvents: number): void {
95
+ this.ensureLoaded()
96
+ this.events.push(event)
97
+ if (this.events.length > maxEvents) this.events = this.events.slice(-maxEvents)
98
+ }
99
+
100
+ flush(): void {
101
+ try {
102
+ localStorage.setItem(EVENTS_KEY, JSON.stringify(this.events))
103
+ } catch {
104
+ /* quota exceeded / storage unavailable — keep in-memory only */
105
+ }
106
+ }
107
+
108
+ all(): AnalyticsEvent[] {
109
+ this.ensureLoaded()
110
+ return this.events
111
+ }
112
+
113
+ clear(): void {
114
+ this.events = []
115
+ try {
116
+ localStorage.removeItem(EVENTS_KEY)
117
+ } catch {
118
+ /* ignore */
119
+ }
120
+ }
121
+
122
+ buildBatch(sessionId: string): AnalyticsBatch {
123
+ this.ensureLoaded()
124
+ return { v: 1, sessionId, sentAt: Date.now(), events: this.events }
125
+ }
126
+ }
127
+
128
+ export const clientStore = new ClientEventStore()
129
+
130
+ export function hasConsented(): boolean {
131
+ try {
132
+ return localStorage.getItem(CONSENT_KEY) === '1'
133
+ } catch {
134
+ return false
135
+ }
136
+ }
137
+
138
+ export function setConsented(value: boolean): void {
139
+ try {
140
+ localStorage.setItem(CONSENT_KEY, value ? '1' : '0')
141
+ } catch {
142
+ /* ignore */
143
+ }
144
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * browser 半部:全局埋点采集引擎。
3
+ *
4
+ * 通过 document 级(capture)监听,捕获页面生命周期、交互、滚动曝光与热力图
5
+ * 坐标。核心原则:只记录「元素身份 + 位置 + 时间 + 视口」,绝不采集输入内容
6
+ * 或对话正文(input 仅记录长度)。
7
+ */
8
+ import type { AnalyticsEvent, ElementRef } from '../shared/types.ts'
9
+ import { clientStore, getSettings, setSessionId } from './storage.ts'
10
+
11
+ export interface Tracker {
12
+ dispose(): void
13
+ }
14
+
15
+ function uuid(): string {
16
+ try {
17
+ return crypto.randomUUID()
18
+ } catch {
19
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
20
+ }
21
+ }
22
+
23
+ /** 参与「元素身份」识别的语义标签;点击落在子节点时向上寻找最近的可交互元素。 */
24
+ const INTERACTIVE_TAGS = new Set([
25
+ 'BUTTON', 'A', 'INPUT', 'TEXTAREA', 'SELECT', 'OPTION', 'LABEL',
26
+ 'SUMMARY', 'DETAILS', 'LI', 'DIV', 'SPAN', 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
27
+ ])
28
+
29
+ function nearestInteractive(el: Element | null): Element | null {
30
+ let cur: Element | null = el
31
+ while (cur && cur !== document.body && cur !== document.documentElement) {
32
+ if (INTERACTIVE_TAGS.has(cur.tagName)) return cur
33
+ cur = cur.parentElement
34
+ }
35
+ return el
36
+ }
37
+
38
+ function textOf(el: Element): string | undefined {
39
+ const t = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
40
+ if (t === '') return undefined
41
+ return t.length > 40 ? `${t.slice(0, 40)}…` : t
42
+ }
43
+
44
+ /**
45
+ * 生成稳定的元素身份(优先 data-testid > id > aria-label > role+tag > placeholder/name > tag+text)。
46
+ * 隐私保护:input/textarea/select 与 contenteditable 元素绝不采集其内容/文案。
47
+ */
48
+ function identify(el: Element | null): ElementRef | undefined {
49
+ if (!el || el === document.body || el === document.documentElement) return undefined
50
+ const tag = el.tagName.toLowerCase()
51
+ const testId = el.getAttribute('data-testid') ?? el.getAttribute('data-test-id') ?? undefined
52
+ const id = el.id || undefined
53
+ const ariaLabel = el.getAttribute('aria-label') ?? undefined
54
+ const role = el.getAttribute('role') ?? undefined
55
+ const title = el.getAttribute('title') ?? undefined
56
+ const isInputLike = tag === 'input' || tag === 'textarea' || tag === 'select'
57
+ || (el as HTMLElement).isContentEditable === true
58
+ const name = isInputLike ? (el.getAttribute('name') ?? undefined) : undefined
59
+ const placeholder = isInputLike ? (el.getAttribute('placeholder') ?? undefined) : undefined
60
+ const text = isInputLike ? undefined : textOf(el)
61
+ const elementId = testId ?? id ?? ariaLabel
62
+ ?? (role ? `${role}#${tag}` : undefined)
63
+ ?? (placeholder ? `${tag}[${placeholder}]` : undefined)
64
+ ?? (name ? `${tag}#${name}` : undefined)
65
+ ?? `${tag}${text ? `:${text}` : ''}`
66
+ return {
67
+ tag,
68
+ ...(id !== undefined ? { id } : {}),
69
+ ...(role !== undefined ? { role } : {}),
70
+ ...(ariaLabel !== undefined ? { ariaLabel } : {}),
71
+ ...(title !== undefined ? { title } : {}),
72
+ ...(testId !== undefined ? { testId } : {}),
73
+ ...(name !== undefined ? { name } : {}),
74
+ ...(placeholder !== undefined ? { placeholder } : {}),
75
+ ...(text !== undefined ? { text } : {}),
76
+ elementId,
77
+ }
78
+ }
79
+
80
+ export function createTracker(): Tracker {
81
+ const sessionId = uuid()
82
+ setSessionId(sessionId)
83
+
84
+ const page = () => ({ path: window.location.pathname, hash: window.location.hash })
85
+
86
+ const emit = (type: AnalyticsEvent['type'], partial: Partial<AnalyticsEvent>): void => {
87
+ const s = getSettings()
88
+ if (!s.trackingEnabled) return
89
+ if (s.sampleRate < 1 && Math.random() > s.sampleRate) return
90
+ const event: AnalyticsEvent = {
91
+ v: 1,
92
+ id: uuid(),
93
+ ts: Date.now(),
94
+ sessionId,
95
+ type,
96
+ page: page(),
97
+ ...partial,
98
+ }
99
+ clientStore.push(event, s.maxEvents)
100
+ }
101
+
102
+ emit('session_start', {})
103
+ emit('page_view', {})
104
+
105
+ const onClick = (e: MouseEvent): void => {
106
+ emit('click', {
107
+ target: identify(nearestInteractive(e.target as Element | null)),
108
+ position: { x: e.clientX, y: e.clientY },
109
+ })
110
+ }
111
+
112
+ const onFocusIn = (e: FocusEvent): void => {
113
+ emit('focus', { target: identify(e.target as Element | null) })
114
+ }
115
+ const onFocusOut = (e: FocusEvent): void => {
116
+ emit('blur', { target: identify(e.target as Element | null) })
117
+ }
118
+
119
+ let lastHover = 0
120
+ const onMouseMove = (e: MouseEvent): void => {
121
+ if (!getSettings().trackMouse) return
122
+ const now = Date.now()
123
+ if (now - lastHover < 250) return
124
+ lastHover = now
125
+ emit('hover', {
126
+ position: { x: e.clientX, y: e.clientY },
127
+ target: identify(nearestInteractive(e.target as Element | null)),
128
+ })
129
+ }
130
+
131
+ let scrollTimer: number | undefined
132
+ const onScroll = (): void => {
133
+ if (scrollTimer !== undefined) return
134
+ scrollTimer = window.setTimeout(() => {
135
+ scrollTimer = undefined
136
+ const doc = document.documentElement
137
+ const max = doc.scrollHeight - doc.clientHeight
138
+ const depth = max > 0 ? Math.min(1, Math.max(0, doc.scrollTop / max)) : 0
139
+ emit('scroll_depth', { depth: Math.round(depth * 100) / 100 })
140
+ }, 500)
141
+ }
142
+
143
+ const onVisibility = (): void => {
144
+ const visible = document.visibilityState === 'visible'
145
+ emit('visibility', { visible })
146
+ if (!visible) emit('session_end', {})
147
+ }
148
+
149
+ let lastInput = 0
150
+ const onInput = (e: Event): void => {
151
+ const now = Date.now()
152
+ if (now - lastInput < 1000) return
153
+ const el = e.target as HTMLInputElement | HTMLTextAreaElement | null
154
+ if (!el || !('value' in el)) return
155
+ lastInput = now
156
+ emit('input', { target: identify(el), inputLength: el.value.length })
157
+ }
158
+
159
+ const onHashChange = (): void => {
160
+ emit('page_view', {})
161
+ }
162
+
163
+ const onPageHide = (): void => {
164
+ emit('session_end', {})
165
+ clientStore.flush()
166
+ }
167
+
168
+ // 元素曝光:IntersectionObserver 上报「哪些面板/控件被用户看到」。
169
+ // 只观察有稳定身份的区域(testid、显式标记、ARIA landmark),控制开销。
170
+ const impressed = new WeakSet<Element>()
171
+ const IMPRESSION_SELECTOR = '[data-testid], [data-impression], [role="main"], [role="region"], [role="dialog"], [role="navigation"], [role="complementary"]'
172
+ const impressionObserver = new IntersectionObserver((entries) => {
173
+ for (const entry of entries) {
174
+ if (entry.isIntersecting && entry.intersectionRatio >= 0.5 && !impressed.has(entry.target)) {
175
+ impressed.add(entry.target)
176
+ impressionObserver.unobserve(entry.target)
177
+ emit('impression', { target: identify(entry.target) })
178
+ }
179
+ }
180
+ }, { threshold: [0.5] })
181
+ const scanImpressions = (): void => {
182
+ document.querySelectorAll(IMPRESSION_SELECTOR).forEach((el) => {
183
+ if (!impressed.has(el)) impressionObserver.observe(el)
184
+ })
185
+ }
186
+ scanImpressions()
187
+ const impressionTimer = window.setInterval(scanImpressions, 2500)
188
+
189
+ document.addEventListener('click', onClick, true)
190
+ document.addEventListener('focusin', onFocusIn, true)
191
+ document.addEventListener('focusout', onFocusOut, true)
192
+ document.addEventListener('mousemove', onMouseMove, true)
193
+ document.addEventListener('scroll', onScroll, true)
194
+ document.addEventListener('input', onInput, true)
195
+ document.addEventListener('visibilitychange', onVisibility)
196
+ window.addEventListener('hashchange', onHashChange)
197
+ window.addEventListener('pagehide', onPageHide)
198
+
199
+ const flushTimer = window.setInterval(() => clientStore.flush(), 3000)
200
+
201
+ return {
202
+ dispose(): void {
203
+ document.removeEventListener('click', onClick, true)
204
+ document.removeEventListener('focusin', onFocusIn, true)
205
+ document.removeEventListener('focusout', onFocusOut, true)
206
+ document.removeEventListener('mousemove', onMouseMove, true)
207
+ document.removeEventListener('scroll', onScroll, true)
208
+ document.removeEventListener('input', onInput, true)
209
+ document.removeEventListener('visibilitychange', onVisibility)
210
+ window.removeEventListener('hashchange', onHashChange)
211
+ window.removeEventListener('pagehide', onPageHide)
212
+ window.clearInterval(flushTimer)
213
+ window.clearInterval(impressionTimer)
214
+ impressionObserver.disconnect()
215
+ if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
216
+ clientStore.flush()
217
+ },
218
+ }
219
+ }
package/src/http.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * host 侧「外部访问接口」:挂载在 DSH 自带 webServer 上的 HTTP 路由。
3
+ *
4
+ * 这些路由即预留的统一分析平台对接面:
5
+ * - GET /dsh-heatmap/health 健康检查
6
+ * - POST /dsh-heatmap/ingest 客户端批量上报(本地收集器)
7
+ * - GET /dsh-heatmap/stats 聚合统计
8
+ * - GET /dsh-heatmap/export 导出事件(NDJSON/JSON)
9
+ * - DELETE /dsh-heatmap/clear 清空
10
+ *
11
+ * 客户端与 host 同源(页面由 host 的 webServer 提供),因此客户端用
12
+ * `window.location.origin + '/dsh-heatmap/ingest'` 即可直接上报,无需配置端口。
13
+ */
14
+ import type { IncomingMessage, ServerResponse } from 'node:http'
15
+ import type { AnalyticsStore } from './store.ts'
16
+ import { SCHEMA_VERSION } from './shared/types.ts'
17
+
18
+ /** 结构性最小依赖:避免把 host-webserver 变成硬依赖。 */
19
+ export interface WebServerLike {
20
+ register(route: {
21
+ kind: 'exact' | 'prefix'
22
+ path: string
23
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
24
+ }): () => void
25
+ }
26
+
27
+ export interface HttpOptions {
28
+ consentRequired: boolean
29
+ uploadEndpoint: string
30
+ }
31
+
32
+ const MAX_BODY_BYTES = 32 * 1024 * 1024
33
+
34
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
35
+ const payload = JSON.stringify(body)
36
+ res.writeHead(status, {
37
+ 'content-type': 'application/json; charset=utf-8',
38
+ 'content-length': Buffer.byteLength(payload),
39
+ 'cache-control': 'no-store',
40
+ })
41
+ res.end(payload)
42
+ }
43
+
44
+ function sendText(res: ServerResponse, status: number, text: string): void {
45
+ res.writeHead(status, {
46
+ 'content-type': 'text/plain; charset=utf-8',
47
+ 'cache-control': 'no-store',
48
+ })
49
+ res.end(text)
50
+ }
51
+
52
+ /** 读取 JSON 请求体(带大小上限,避免被异常体拖垮)。 */
53
+ function readJson(req: IncomingMessage): Promise<unknown> {
54
+ return new Promise((resolve, reject) => {
55
+ req.setEncoding('utf8')
56
+ let body = ''
57
+ let size = 0
58
+ req.on('data', (chunk: string) => {
59
+ size += Buffer.byteLength(chunk)
60
+ if (size > MAX_BODY_BYTES) {
61
+ reject(new Error('request body too large'))
62
+ req.destroy()
63
+ return
64
+ }
65
+ body += chunk
66
+ })
67
+ req.on('end', () => {
68
+ if (body.trim() === '') return resolve({})
69
+ try {
70
+ resolve(JSON.parse(body))
71
+ } catch {
72
+ reject(new Error('invalid JSON body'))
73
+ }
74
+ })
75
+ req.on('error', reject)
76
+ })
77
+ }
78
+
79
+ /** 注册全部埋点 HTTP 路由,返回一次性解除全部路由的 disposer。 */
80
+ export function registerRoutes(webServer: WebServerLike, store: AnalyticsStore, opts: HttpOptions): () => void {
81
+ const disposers: (() => void)[] = []
82
+
83
+ disposers.push(webServer.register({
84
+ kind: 'exact',
85
+ path: '/dsh-heatmap/health',
86
+ handler: (_req, res) => {
87
+ sendJson(res, 200, { ok: true, service: 'dsh-heatmap', schemaVersion: SCHEMA_VERSION })
88
+ },
89
+ }))
90
+
91
+ disposers.push(webServer.register({
92
+ kind: 'exact',
93
+ path: '/dsh-heatmap/ingest',
94
+ handler: async (req, res) => {
95
+ if (req.method !== 'POST') return sendText(res, 405, 'method not allowed')
96
+ try {
97
+ const body = (await readJson(req)) as { v?: number; events?: unknown[] }
98
+ if (body?.v !== SCHEMA_VERSION || !Array.isArray(body.events)) {
99
+ return sendJson(res, 400, { ok: false, error: 'unsupported schema version or malformed batch' })
100
+ }
101
+ const accepted = await store.ingest(body as never)
102
+ sendJson(res, 200, { ok: true, accepted })
103
+ } catch (err) {
104
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) })
105
+ }
106
+ },
107
+ }))
108
+
109
+ disposers.push(webServer.register({
110
+ kind: 'exact',
111
+ path: '/dsh-heatmap/stats',
112
+ handler: async (_req, res) => {
113
+ sendJson(res, 200, await store.stats())
114
+ },
115
+ }))
116
+
117
+ disposers.push(webServer.register({
118
+ kind: 'exact',
119
+ path: '/dsh-heatmap/export',
120
+ handler: async (req, res) => {
121
+ const url = new URL(req.url ?? '/', 'http://x')
122
+ const format = url.searchParams.get('format') === 'json' ? 'json' : 'ndjson'
123
+ const limit = Number.parseInt(url.searchParams.get('limit') ?? '0', 10) || 0
124
+ const events = await store.list(limit)
125
+ if (format === 'json') return sendJson(res, 200, { schemaVersion: SCHEMA_VERSION, count: events.length, events })
126
+ const payload = events.map(e => JSON.stringify(e)).join('\n') + (events.length ? '\n' : '')
127
+ sendText(res, 200, payload)
128
+ },
129
+ }))
130
+
131
+ disposers.push(webServer.register({
132
+ kind: 'exact',
133
+ path: '/dsh-heatmap/sessions',
134
+ handler: async (req, res) => {
135
+ const url = new URL(req.url ?? '/', 'http://x')
136
+ const limit = Number.parseInt(url.searchParams.get('limit') ?? '0', 10) || 0
137
+ const timelines = await store.sessions(limit)
138
+ sendJson(res, 200, { count: timelines.length, sessions: timelines })
139
+ },
140
+ }))
141
+
142
+ disposers.push(webServer.register({
143
+ kind: 'exact',
144
+ path: '/dsh-heatmap/funnel',
145
+ handler: async (req, res) => {
146
+ if (req.method !== 'POST') return sendText(res, 405, 'method not allowed')
147
+ try {
148
+ const body = (await readJson(req)) as { steps?: unknown }
149
+ if (!Array.isArray(body?.steps) || body.steps.length === 0) {
150
+ return sendJson(res, 400, { ok: false, error: 'steps 必须是至少一个事件类型的数组' })
151
+ }
152
+ sendJson(res, 200, await store.funnel(body.steps.map(String)))
153
+ } catch (err) {
154
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) })
155
+ }
156
+ },
157
+ }))
158
+
159
+ disposers.push(webServer.register({
160
+ kind: 'exact',
161
+ path: '/dsh-heatmap/clear',
162
+ handler: async (req, res) => {
163
+ if (req.method !== 'DELETE') return sendText(res, 405, 'method not allowed')
164
+ await store.clear()
165
+ sendJson(res, 200, { ok: true, cleared: true })
166
+ },
167
+ }))
168
+
169
+ return () => {
170
+ for (const dispose of disposers) dispose()
171
+ }
172
+ }
package/src/index.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * dsh-heatmap — host 半部。
3
+ *
4
+ * 职责(与 browser 半部分工清晰):
5
+ * - 收集器:webServer 上的 /dsh-heatmap/* HTTP 路由(客户端上报、导出、统计、清空);
6
+ * - CLI/Agent 接口:注册 `analytics_export` 工具,供 Agent 或统一分析平台导出/统计/清空/上传;
7
+ * - 持久化:NDJSON 落盘(AnalyticsStore)。
8
+ *
9
+ * browser 半部负责真正的埋点采集、本地热力图、统计面板与上传授权弹窗——
10
+ * 数据默认只存在用户本地,热力图展示「本地 / 本人」数据,无需授权;
11
+ * 只有「上传」动作会触发授权弹窗。
12
+ */
13
+ import { resolve } from 'node:path'
14
+ import { homedir } from 'node:os'
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import Schema from '@deepseek-ai/schemastery'
17
+ import { defineTool, type JsonValue } from '@deepseek-ai/dsh-tools'
18
+ import { AnalyticsStore } from './store.ts'
19
+ import { registerRoutes, type WebServerLike } from './http.ts'
20
+
21
+ export const name = 'dsh-heatmap'
22
+
23
+ /** 部署级配置(cordis.yml 可覆盖;browser 侧另有 localStorage 的运行时开关)。 */
24
+ export interface Config {
25
+ /** 是否启用 host 收集器与工具(false 则纯本地采集,仅浏览器热力图可用)。 */
26
+ enabled: boolean
27
+ /** 数据落盘目录;空则默认 `$DSH_HOME/storages/dsh-heatmap` 或 `~/.dsh/storages/dsh-heatmap`。 */
28
+ dataDir: string
29
+ /** 内存与文件最多保留的事件条数(环形缓冲)。 */
30
+ maxEvents: number
31
+ /** 统一分析平台上传地址(`analytics_export upload` 动作的目标)。 */
32
+ uploadEndpoint: string
33
+ /** 是否要求上传授权(文档化:实际授权弹窗由 browser 半部执行)。 */
34
+ consentRequired: boolean
35
+ }
36
+
37
+ export const Config: Schema<Config> = Schema.object({
38
+ enabled: Schema.boolean().default(true),
39
+ dataDir: Schema.string().default(''),
40
+ maxEvents: Schema.number().default(20000),
41
+ uploadEndpoint: Schema.string().default(''),
42
+ consentRequired: Schema.boolean().default(true),
43
+ })
44
+
45
+ /** tools 服务的结构性最小依赖。 */
46
+ interface ToolsLike {
47
+ register(definition: unknown): () => void
48
+ }
49
+
50
+ function defaultDataDir(): string {
51
+ const base = process.env.DSH_HOME ?? resolve(homedir(), '.dsh')
52
+ return resolve(base, 'storages', 'dsh-heatmap')
53
+ }
54
+
55
+ export function apply(ctx: Context, config: Config): void {
56
+ if (!config.enabled) return
57
+
58
+ const dataDir = config.dataDir !== '' ? config.dataDir : defaultDataDir()
59
+ const store = new AnalyticsStore(dataDir, config.maxEvents)
60
+
61
+ ctx.effect(() => {
62
+ const disposers: (() => void)[] = []
63
+
64
+ // 1) 外部访问接口:HTTP 路由。
65
+ const webServer = ctx.get('webServer') as WebServerLike | undefined
66
+ if (webServer !== undefined) {
67
+ disposers.push(registerRoutes(webServer, store, {
68
+ consentRequired: config.consentRequired,
69
+ uploadEndpoint: config.uploadEndpoint,
70
+ }))
71
+ ctx.logger.info(`dsh-heatmap: http routes ready (data: ${store.filePath})`)
72
+ } else {
73
+ ctx.logger.warn('dsh-heatmap: webServer service unavailable — http routes skipped')
74
+ }
75
+
76
+ // 2) CLI / Agent 接口:注册工具。
77
+ const tools = ctx.get('tools') as ToolsLike | undefined
78
+ if (tools !== undefined) {
79
+ disposers.push(tools.register(defineTool({
80
+ name: 'analytics_export',
81
+ description: '导出/统计/清空 dsh-heatmap 采集的页面埋点数据,或上传到统一分析平台。动作:stats(聚合统计)、export(导出事件)、sessions(会话时间线/回放)、funnel(漏斗分析,需 steps)、clear(清空)、upload(上传到配置的上传地址)。',
82
+ parameters: {
83
+ action: {
84
+ type: 'string',
85
+ required: true,
86
+ enum: ['stats', 'export', 'sessions', 'funnel', 'clear', 'upload'],
87
+ description: '要执行的动作',
88
+ },
89
+ steps: {
90
+ type: 'array',
91
+ items: { type: 'string' },
92
+ description: '漏斗步骤(仅 action=funnel 时使用):有序事件类型数组,如 [session_start, click, input]',
93
+ },
94
+ },
95
+ output: {
96
+ schema: { type: 'json' },
97
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
98
+ },
99
+ async execute(args): Promise<JsonValue> {
100
+ const asJson = (value: unknown): JsonValue => value as JsonValue
101
+ if (args.action === 'stats') return asJson(await store.stats())
102
+ if (args.action === 'sessions') return asJson({ sessions: await store.sessions(20) })
103
+ if (args.action === 'funnel') {
104
+ const steps = Array.isArray(args.steps) ? args.steps.map(String) : []
105
+ if (steps.length === 0) return { ok: false, error: 'funnel 需要 steps(有序事件类型数组)' }
106
+ return asJson(await store.funnel(steps))
107
+ }
108
+ if (args.action === 'clear') {
109
+ await store.clear()
110
+ return { ok: true, cleared: true }
111
+ }
112
+ if (args.action === 'upload') {
113
+ if (config.uploadEndpoint === '') {
114
+ return { ok: false, error: 'uploadEndpoint 未配置,无法上传' }
115
+ }
116
+ const events = await store.list()
117
+ const res = await fetch(config.uploadEndpoint, {
118
+ method: 'POST',
119
+ headers: { 'content-type': 'application/json' },
120
+ body: JSON.stringify({ schemaVersion: 1, sentAt: Date.now(), count: events.length, events }),
121
+ })
122
+ return { ok: res.ok, status: res.status, sent: events.length }
123
+ }
124
+ // export
125
+ const events = await store.list(5000)
126
+ return asJson({ ok: true, count: events.length, events })
127
+ },
128
+ })))
129
+ }
130
+
131
+ return () => {
132
+ for (const dispose of disposers) dispose()
133
+ }
134
+ }, 'dsh-heatmap: host surfaces')
135
+ }