mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(analytics): add privacy-safe product telemetry
Add self-hosted Umami reporting alongside existing telemetry across Desktop and CLI Web. Track normalized startup, import, feature, and AI usage with a stable anonymous ID and strict privacy allowlists. Keep incremental usage platform-independent and preserve accurate failure classifications and platform metadata across import paths.
This commit is contained in:
1 parent
db60339829
commit
860bf52d62
48 files changed
+1613
-160
No files matched your search
@@ -174,7 +174,8 @@ jobs:
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
# 分析服务
|
||||
APTABASE_APP_KEY: ${{ secrets.APTABASE_APP_KEY }}
|
||||
UMAMI_ENDPOINT: ${{ secrets.UMAMI_ENDPOINT }}
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID }}
|
||||
working-directory: apps/desktop
|
||||
run: pnpm run build && pnpm exec electron-builder --mac --${{ matrix.arch }} --config electron-builder.yml -p never
|
||||
|
||||
@@ -286,7 +287,8 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
# 分析服务
|
||||
APTABASE_APP_KEY: ${{ secrets.APTABASE_APP_KEY }}
|
||||
UMAMI_ENDPOINT: ${{ secrets.UMAMI_ENDPOINT }}
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID }}
|
||||
working-directory: apps/desktop
|
||||
run: pnpm run build:win
|
||||
|
||||
@@ -354,7 +356,8 @@ jobs:
|
||||
|
||||
- name: Build CLI (with Web UI)
|
||||
env:
|
||||
APTABASE_APP_KEY: ${{ secrets.APTABASE_APP_KEY }}
|
||||
UMAMI_ENDPOINT: ${{ secrets.UMAMI_ENDPOINT }}
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID }}
|
||||
run: pnpm --filter chatlab-cli run build:full
|
||||
|
||||
- name: Upload CLI build output
|
||||
|
||||
@@ -25,6 +25,34 @@ function multipartPayload(): { payload: Buffer; contentType: string } {
|
||||
}
|
||||
}
|
||||
|
||||
function directoryMultipartPayload(): { payload: Buffer; contentType: string } {
|
||||
const boundary = '----chatlab-directory-import-route-test'
|
||||
const document = JSON.stringify({
|
||||
chatlab: { version: '0.0.2', exportedAt: 1711468800 },
|
||||
meta: { name: 'Test Chat', platform: 'line', type: 'group' },
|
||||
members: [{ platformId: 'u1', accountName: 'Alice' }],
|
||||
messages: [{ sender: 'u1', accountName: 'Alice', timestamp: 1711468800, type: 0, content: 'hello' }],
|
||||
})
|
||||
const relativePath = [
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="relativePaths"',
|
||||
'',
|
||||
'export/chat.json',
|
||||
].join('\r\n')
|
||||
const file = [
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="files"; filename="chat.json"',
|
||||
'Content-Type: application/json',
|
||||
'',
|
||||
document,
|
||||
].join('\r\n')
|
||||
|
||||
return {
|
||||
payload: Buffer.from([relativePath, file, `--${boundary}--`, ''].join('\r\n')),
|
||||
contentType: `multipart/form-data; boundary=${boundary}`,
|
||||
}
|
||||
}
|
||||
|
||||
describe('CLI Web automatic import route', () => {
|
||||
it('forwards parser options and preserves a zero-new incremental result in the done event', async () => {
|
||||
const app = Fastify()
|
||||
@@ -101,6 +129,72 @@ describe('CLI Web automatic import route', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the parsed platform in a failed created-import event', async () => {
|
||||
const app = Fastify()
|
||||
try {
|
||||
await app.register(multipart)
|
||||
registerImportRoutes(
|
||||
app,
|
||||
{} as any,
|
||||
{
|
||||
runAutoImport: async () => ({
|
||||
success: false,
|
||||
platform: 'line',
|
||||
error: 'database unavailable',
|
||||
}),
|
||||
} as any
|
||||
)
|
||||
|
||||
const body = multipartPayload()
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/_web/import',
|
||||
headers: { 'content-type': body.contentType },
|
||||
payload: body.payload,
|
||||
})
|
||||
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.match(response.body, /event: error/)
|
||||
assert.match(response.body, /"platform":"line"/)
|
||||
assert.match(response.body, /"error":"database unavailable"/)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the parsed platform in a failed directory-import event', async () => {
|
||||
const app = Fastify()
|
||||
try {
|
||||
await app.register(multipart)
|
||||
registerImportRoutes(
|
||||
app,
|
||||
{} as any,
|
||||
{
|
||||
runAutoImport: async () => ({
|
||||
success: false,
|
||||
platform: 'line',
|
||||
error: 'database unavailable',
|
||||
}),
|
||||
} as any
|
||||
)
|
||||
|
||||
const body = directoryMultipartPayload()
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/_web/import-directory',
|
||||
headers: { 'content-type': body.contentType },
|
||||
payload: body.payload,
|
||||
})
|
||||
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.match(response.body, /event: error/)
|
||||
assert.match(response.body, /"platform":"line"/)
|
||||
assert.match(response.body, /"error":"database unavailable"/)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves an ambiguous create reason in the done event', async () => {
|
||||
const app = Fastify()
|
||||
try {
|
||||
|
||||
@@ -230,7 +230,7 @@ export function registerImportRoutes(
|
||||
if (result.success) {
|
||||
sendEvent('done', result)
|
||||
} else {
|
||||
sendEvent('error', { success: false, error: result.error })
|
||||
sendEvent('error', result)
|
||||
}
|
||||
} catch (err) {
|
||||
sendEvent('error', { success: false, error: err instanceof Error ? err.message : String(err) })
|
||||
@@ -299,7 +299,7 @@ export function registerImportRoutes(
|
||||
if (result.success) {
|
||||
sendEvent('done', result)
|
||||
} else {
|
||||
sendEvent('error', { success: false, error: result.error })
|
||||
sendEvent('error', result)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!reply.raw.headersSent) {
|
||||
|
||||
@@ -116,9 +116,11 @@ export function registerWebRoutes(
|
||||
|
||||
const semanticIndexService = options?.semanticIndexService
|
||||
|
||||
const analyticsService = process.env.APTABASE_APP_KEY
|
||||
? new AnalyticsService(resolvedPathProvider.getSystemDir(), process.env.APTABASE_APP_KEY, getVersion())
|
||||
: undefined
|
||||
const analyticsService = new AnalyticsService(resolvedPathProvider.getSystemDir(), {
|
||||
appVersion: getVersion(),
|
||||
appType: 'cli_web',
|
||||
getAiModelConfigured: () => ai?.llmConfigStore.hasConfiguredModel() === true,
|
||||
})
|
||||
|
||||
registerSharedRoutes(
|
||||
server,
|
||||
|
||||
@@ -135,7 +135,7 @@ test('incremental analysis and import honor an explicitly selected parser format
|
||||
})
|
||||
const result = await incrementalImport(manager, 'existing', filePath, { formatId: 'chatlab-jsonl' })
|
||||
|
||||
assert.deepEqual(analysis, { newMessageCount: 1, duplicateCount: 0, totalInFile: 1 })
|
||||
assert.deepEqual(analysis, { newMessageCount: 1, duplicateCount: 0, totalInFile: 1, platform: 'qq' })
|
||||
assert.equal(result.success, true)
|
||||
assert.equal(result.newMessageCount, 1)
|
||||
})
|
||||
@@ -14,7 +14,7 @@ function extractSection(source: string, startMarker: string, endMarker: string):
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
test('release CLI build injects the Aptabase app key before tsup inlines env values', () => {
|
||||
test('release CLI build injects Umami configuration before tsup inlines env values', () => {
|
||||
const buildCliJob = extractSection(releaseWorkflow, ' build-cli:', ' release:')
|
||||
const buildCliStep = extractSection(
|
||||
buildCliJob,
|
||||
@@ -23,5 +23,6 @@ test('release CLI build injects the Aptabase app key before tsup inlines env val
|
||||
)
|
||||
|
||||
assert.match(buildCliStep, /run: pnpm --filter chatlab-cli run build:full/)
|
||||
assert.match(buildCliStep, /APTABASE_APP_KEY: \$\{\{ secrets\.APTABASE_APP_KEY \}\}/)
|
||||
assert.match(buildCliStep, /UMAMI_ENDPOINT: \$\{\{ secrets\.UMAMI_ENDPOINT \}\}/)
|
||||
assert.match(buildCliStep, /UMAMI_WEBSITE_ID: \$\{\{ secrets\.UMAMI_WEBSITE_ID \}\}/)
|
||||
})
|
||||
@@ -18,7 +18,8 @@ export default defineConfig({
|
||||
target: 'node20',
|
||||
platform: 'node',
|
||||
define: {
|
||||
'process.env.APTABASE_APP_KEY': JSON.stringify(process.env.APTABASE_APP_KEY || ''),
|
||||
'process.env.UMAMI_ENDPOINT': JSON.stringify(process.env.UMAMI_ENDPOINT || ''),
|
||||
'process.env.UMAMI_WEBSITE_ID': JSON.stringify(process.env.UMAMI_WEBSITE_ID || ''),
|
||||
},
|
||||
noExternal: [/^@openchatlab\/(?!parser-native)/, 'chatlab-mcp', 'stream-json'],
|
||||
// parser-native 是本地构建的可选 Rust 内核:不打包也不声明依赖,
|
||||
|
||||
@@ -31,7 +31,8 @@ export default defineConfig({
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(appVersion),
|
||||
'process.env.APTABASE_APP_KEY': JSON.stringify(process.env.APTABASE_APP_KEY || ''),
|
||||
'process.env.UMAMI_ENDPOINT': JSON.stringify(process.env.UMAMI_ENDPOINT || ''),
|
||||
'process.env.UMAMI_WEBSITE_ID': JSON.stringify(process.env.UMAMI_WEBSITE_ID || ''),
|
||||
// ws 的原生加速依赖是可选项;主进程打包时禁用它们,避免 Vite 将缺失的可选依赖改写为启动即抛错。
|
||||
'process.env.WS_NO_BUFFER_UTIL': JSON.stringify('true'),
|
||||
'process.env.WS_NO_UTF_8_VALIDATE': JSON.stringify('true'),
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { AnalyticsService } from '@openchatlab/node-runtime'
|
||||
import { AnalyticsService, appLogger } from '@openchatlab/node-runtime'
|
||||
import type { AnalyticsEventName } from '@openchatlab/shared-types'
|
||||
import { getDesktopLlmRuntimeStores } from './ai/llm'
|
||||
import { getSystemDataDir } from './paths/locations'
|
||||
|
||||
const APTABASE_APP_KEY = process.env.APTABASE_APP_KEY
|
||||
|
||||
let _service: AnalyticsService | null = null
|
||||
|
||||
function getService(): AnalyticsService | null {
|
||||
if (!APTABASE_APP_KEY) return null
|
||||
if (!_service) {
|
||||
const systemDir = getSystemDataDir()
|
||||
_service = new AnalyticsService(systemDir, APTABASE_APP_KEY, app.getVersion())
|
||||
_service = new AnalyticsService(systemDir, {
|
||||
appVersion: app.getVersion(),
|
||||
appType: 'desktop',
|
||||
getAiModelConfigured: () => getDesktopLlmRuntimeStores().llmConfigStore.hasConfiguredModel(),
|
||||
})
|
||||
}
|
||||
return _service
|
||||
}
|
||||
|
||||
export function initAnalytics(): void {
|
||||
// Service is initialized lazily; no-op here unless we want eager validation.
|
||||
if (!APTABASE_APP_KEY) return
|
||||
getService()
|
||||
}
|
||||
|
||||
@@ -33,13 +34,19 @@ export function registerAnalyticsHandlers(): void {
|
||||
|
||||
ipcMain.handle('analytics:trackDailyActive', (_, locale: string) => {
|
||||
getService()
|
||||
?.trackDailyActive({ platform: 'desktop', locale })
|
||||
.catch((e) => console.error('[Analytics] Failed to report daily active:', e))
|
||||
?.trackDailyActive({ app_locale: locale })
|
||||
.catch((error) => appLogger.error('analytics', 'Failed to report daily active event', error))
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:track', (_, eventName: AnalyticsEventName, properties?: Record<string, unknown>) => {
|
||||
getService()
|
||||
?.track(eventName, properties)
|
||||
.catch((error) => appLogger.error('analytics', `Failed to report ${eventName} event`, error))
|
||||
})
|
||||
}
|
||||
|
||||
export function trackAppEvent(eventName: string, properties?: Record<string, string | number>): void {
|
||||
export function trackAppEvent(eventName: AnalyticsEventName, properties?: Record<string, unknown>): void {
|
||||
getService()
|
||||
?.track(eventName, properties)
|
||||
.catch((e) => console.error(`[Analytics] Failed to report event ${eventName}:`, e))
|
||||
.catch((error) => appLogger.error('analytics', `Failed to report ${eventName} event`, error))
|
||||
}
|
||||
@@ -997,6 +997,7 @@ export interface IncrementalAnalyzeResult {
|
||||
newMessageCount: number
|
||||
duplicateCount: number
|
||||
totalInFile: number
|
||||
platform?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ImportProgress } from '../../../../src/types/base'
|
||||
interface ChatImportResult {
|
||||
success: boolean
|
||||
sessionId?: string
|
||||
platform?: string
|
||||
error?: string
|
||||
importMode?: 'created' | 'incremental'
|
||||
matchedBy?: 'source-session-id' | 'stable-id' | 'trailing-messages'
|
||||
@@ -90,6 +91,7 @@ export const chatApi = {
|
||||
newMessageCount: number
|
||||
duplicateCount: number
|
||||
totalInFile: number
|
||||
platform?: string
|
||||
error?: string
|
||||
diagnosis?: { suggestion?: string }
|
||||
}> => ipcRenderer.invoke('chat:analyzeIncrementalImport', sessionId, filePath),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 核心 API - 基础 IPC 通信和系统功能
|
||||
*/
|
||||
import { ipcRenderer } from 'electron'
|
||||
import type { AnalyticsEventName } from '@openchatlab/shared-types'
|
||||
|
||||
// Custom APIs for renderer
|
||||
export const api = {
|
||||
@@ -95,6 +96,9 @@ export const extendedApi = {
|
||||
trackDailyActive: (locale: string): Promise<void> => {
|
||||
return ipcRenderer.invoke('analytics:trackDailyActive', locale)
|
||||
},
|
||||
trackAnalyticsEvent: (eventName: AnalyticsEventName, properties?: Record<string, unknown>): Promise<void> => {
|
||||
return ipcRenderer.invoke('analytics:track', eventName, properties)
|
||||
},
|
||||
/**
|
||||
* 重启应用
|
||||
*/
|
||||
|
||||
Vendored
+4
-1
@@ -1,7 +1,7 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import type { ImportProgress, ExportProgress } from '../../../src/types/base'
|
||||
import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo, SecurityApi } from '../shared/types'
|
||||
import type { TimeFilter } from '@openchatlab/shared-types'
|
||||
import type { AnalyticsEventName, TimeFilter } from '@openchatlab/shared-types'
|
||||
|
||||
// 迁移相关类型
|
||||
interface MigrationInfo {
|
||||
@@ -43,6 +43,7 @@ interface ImportDiagnostics {
|
||||
interface ChatImportResult {
|
||||
success: boolean
|
||||
sessionId?: string
|
||||
platform?: string
|
||||
error?: string
|
||||
importMode?: 'created' | 'incremental'
|
||||
matchedBy?: 'source-session-id' | 'stable-id' | 'trailing-messages'
|
||||
@@ -96,6 +97,7 @@ interface ChatApi {
|
||||
newMessageCount: number
|
||||
duplicateCount: number
|
||||
totalInFile: number
|
||||
platform?: string
|
||||
error?: string
|
||||
diagnosis?: { suggestion?: string }
|
||||
}>
|
||||
@@ -134,6 +136,7 @@ interface Api {
|
||||
getAnalyticsEnabled: () => Promise<boolean>
|
||||
setAnalyticsEnabled: (enabled: boolean) => Promise<{ success: boolean }>
|
||||
trackDailyActive: (locale: string) => Promise<void>
|
||||
trackAnalyticsEvent: (eventName: AnalyticsEventName, properties?: Record<string, unknown>) => Promise<void>
|
||||
relaunch: () => Promise<void>
|
||||
getOpenAtLogin: () => Promise<boolean>
|
||||
setOpenAtLogin: (enabled: boolean) => Promise<{ success: boolean; error?: string }>
|
||||
|
||||
@@ -39,4 +39,42 @@ describe('telemetry routes', () => {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards typed events and properties to the shared analytics service', async () => {
|
||||
const tracked: Array<{ eventName: string; properties?: Record<string, unknown> }> = []
|
||||
const app = Fastify()
|
||||
registerTelemetryRoutes(app, {
|
||||
analyticsService: {
|
||||
getEnabled: () => true,
|
||||
setEnabled: () => {},
|
||||
trackDailyActive: async () => {},
|
||||
track: async (eventName, properties) => {
|
||||
tracked.push({ eventName, properties })
|
||||
return true
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await app.ready()
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/_web/telemetry/track',
|
||||
payload: {
|
||||
eventName: 'feature_used',
|
||||
properties: { feature_id: 'insights', ignored: 'value' },
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.deepEqual(tracked, [
|
||||
{
|
||||
eventName: 'feature_used',
|
||||
properties: { feature_id: 'insights', ignored: 'value' },
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import type { AnalyticsService } from '@openchatlab/node-runtime'
|
||||
import type { AnalyticsEventName } from '@openchatlab/shared-types'
|
||||
|
||||
interface TelemetryRouteContext {
|
||||
analyticsService?: Pick<AnalyticsService, 'getEnabled' | 'setEnabled' | 'trackDailyActive' | 'track'>
|
||||
@@ -16,7 +17,7 @@ export function registerTelemetryRoutes(server: FastifyInstance, ctx: TelemetryR
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
server.post<{ Body: { eventName: string; properties?: Record<string, string | number> } }>(
|
||||
server.post<{ Body: { eventName: AnalyticsEventName; properties?: Record<string, unknown> } }>(
|
||||
'/_web/telemetry/track',
|
||||
async (req, reply) => {
|
||||
if (!ctx.analyticsService) return { ok: true }
|
||||
|
||||
@@ -32,6 +32,17 @@ describe('LLMConfigStore', () => {
|
||||
const all = store.getAllConfigs()
|
||||
assert.equal(all.length, 0)
|
||||
assert.equal(store.hasActiveConfig(), false)
|
||||
assert.equal(store.hasConfiguredModel(), false)
|
||||
})
|
||||
|
||||
it('only reports a configured model after the active slot has a model id', () => {
|
||||
const result = store.addConfig({ name: 'A', provider: 'openai', apiKey: 'k' })
|
||||
assert.equal(result.success, true)
|
||||
assert.equal(store.hasActiveConfig(), true)
|
||||
assert.equal(store.hasConfiguredModel(), false)
|
||||
|
||||
store.setDefaultAssistantModel(result.config!.id, 'gpt-4')
|
||||
assert.equal(store.hasConfiguredModel(), true)
|
||||
})
|
||||
|
||||
it('does not expose an unmigrated encrypted API key as a usable key', () => {
|
||||
|
||||
@@ -330,4 +330,9 @@ export class LLMConfigStore {
|
||||
hasActiveConfig(): boolean {
|
||||
return this.getDefaultAssistantConfig() !== null
|
||||
}
|
||||
|
||||
hasConfiguredModel(): boolean {
|
||||
const config = this.getDefaultAssistantConfig()
|
||||
return typeof config?.model === 'string' && config.model.trim().length > 0
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,11 @@ function createDeps(options?: {
|
||||
decision?: AutoImportDecision
|
||||
matchError?: Error
|
||||
createdSessionId?: string
|
||||
createError?: string
|
||||
incrementalNewCount?: number
|
||||
incrementalDuplicateCount?: number
|
||||
incrementalError?: string
|
||||
incrementalThrow?: Error
|
||||
}) {
|
||||
const calls = {
|
||||
match: 0,
|
||||
@@ -36,9 +39,17 @@ function createDeps(options?: {
|
||||
},
|
||||
createSession: async (filePath, _formatOptions, sessionId) => {
|
||||
calls.create.push({ filePath, sessionId })
|
||||
if (options?.createError) {
|
||||
return {
|
||||
success: false,
|
||||
platform: 'line',
|
||||
error: options.createError,
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
sessionId: sessionId ?? options?.createdSessionId ?? 'created-session',
|
||||
platform: 'qq',
|
||||
diagnostics: {
|
||||
logFile: null,
|
||||
detectedFormat: 'fixture',
|
||||
@@ -52,6 +63,10 @@ function createDeps(options?: {
|
||||
},
|
||||
appendSession: async (sessionId, filePath) => {
|
||||
calls.append.push({ sessionId, filePath })
|
||||
if (options?.incrementalThrow) throw options.incrementalThrow
|
||||
if (options?.incrementalError) {
|
||||
return { success: false, newMessageCount: 0, error: options.incrementalError }
|
||||
}
|
||||
const newMessageCount = options?.incrementalNewCount ?? 3
|
||||
return {
|
||||
success: true,
|
||||
@@ -189,6 +204,47 @@ test('automatic unique match preserves incremental mode when every message is du
|
||||
assert.deepEqual(calls.create, [])
|
||||
})
|
||||
|
||||
test('automatic incremental result preserves its mode when append returns a failure', async () => {
|
||||
const { deps } = createDeps({
|
||||
decision: { action: 'incremental', sessionId: 'existing', matchedBy: 'stable-id' },
|
||||
incrementalError: 'write failed',
|
||||
})
|
||||
|
||||
assert.deepEqual(await autoImportFile('source.json', deps), {
|
||||
success: false,
|
||||
sessionId: 'existing',
|
||||
importMode: 'incremental',
|
||||
matchedBy: 'stable-id',
|
||||
error: 'write failed',
|
||||
})
|
||||
})
|
||||
|
||||
test('automatic incremental result preserves its mode when append throws', async () => {
|
||||
const { deps } = createDeps({
|
||||
decision: { action: 'incremental', sessionId: 'existing', matchedBy: 'trailing-messages' },
|
||||
incrementalThrow: new Error('database unavailable'),
|
||||
})
|
||||
|
||||
assert.deepEqual(await autoImportFile('source.json', deps), {
|
||||
success: false,
|
||||
sessionId: 'existing',
|
||||
importMode: 'incremental',
|
||||
matchedBy: 'trailing-messages',
|
||||
error: 'database unavailable',
|
||||
})
|
||||
})
|
||||
|
||||
test('automatic created result preserves the parsed platform when import fails', async () => {
|
||||
const { deps } = createDeps({ createError: 'database unavailable' })
|
||||
|
||||
assert.deepEqual(await autoImportFile('source.json', deps), {
|
||||
success: false,
|
||||
platform: 'line',
|
||||
error: 'database unavailable',
|
||||
diagnostics: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
for (const reason of ['no-match', 'ambiguous'] as const) {
|
||||
test(`automatic ${reason} decision creates a new session`, async () => {
|
||||
const { deps, calls } = createDeps({ decision: { action: 'create', reason } })
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface AutoImportAnalysisDeps extends AutoImportMatcherDeps {
|
||||
export interface AutoImportResult {
|
||||
success: boolean
|
||||
sessionId?: string
|
||||
platform?: string
|
||||
importMode?: 'created' | 'incremental'
|
||||
matchedBy?: AutoImportMatchMethod
|
||||
createReason?: AutoImportCreateReason
|
||||
@@ -103,11 +104,17 @@ async function planAutoImport(
|
||||
|
||||
function mapCreateResult(result: StreamImportResult, createReason?: AutoImportCreateReason): AutoImportResult {
|
||||
if (!result.success || !result.sessionId) {
|
||||
return { success: false, error: result.error, diagnostics: result.diagnostics }
|
||||
return {
|
||||
success: false,
|
||||
...(result.platform !== undefined ? { platform: result.platform } : {}),
|
||||
error: result.error,
|
||||
diagnostics: result.diagnostics,
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
sessionId: result.sessionId,
|
||||
platform: result.platform,
|
||||
importMode: 'created',
|
||||
...(createReason ? { createReason } : {}),
|
||||
newMessageCount: result.diagnostics?.messagesWritten ?? 0,
|
||||
@@ -128,12 +135,15 @@ function mapIncrementalResult(
|
||||
result: IncrementalImportResult,
|
||||
matchedBy?: AutoImportMatchMethod
|
||||
): AutoImportResult {
|
||||
if (!result.success) return { success: false, sessionId, error: result.error }
|
||||
const incrementalResult = {
|
||||
sessionId,
|
||||
importMode: 'incremental' as const,
|
||||
...(matchedBy ? { matchedBy } : {}),
|
||||
}
|
||||
if (!result.success) return { success: false, ...incrementalResult, error: result.error }
|
||||
return {
|
||||
success: true,
|
||||
sessionId,
|
||||
importMode: 'incremental',
|
||||
...(matchedBy ? { matchedBy } : {}),
|
||||
...incrementalResult,
|
||||
newMessageCount: result.newMessageCount,
|
||||
duplicateCount: result.batch?.duplicateCount ?? 0,
|
||||
batch: result.batch,
|
||||
@@ -147,13 +157,14 @@ export async function autoImportFile(
|
||||
deps: AutoImportDeps,
|
||||
options: AutoImportOptions = {}
|
||||
): Promise<AutoImportResult> {
|
||||
let plan: AutoImportPlan | undefined
|
||||
try {
|
||||
if (!options.explicitSessionId) {
|
||||
appLogger.info('import', 'Automatic session matching started', {
|
||||
candidateCount: deps.listSessionIds().length,
|
||||
})
|
||||
}
|
||||
const plan = await planAutoImport(filePath, deps, options)
|
||||
plan = await planAutoImport(filePath, deps, options)
|
||||
|
||||
if (plan.action === 'incremental') {
|
||||
const result = mapIncrementalResult(
|
||||
@@ -183,7 +194,17 @@ export async function autoImportFile(
|
||||
return result
|
||||
} catch (error) {
|
||||
appLogger.error('import', 'Automatic import failed', error)
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
return {
|
||||
success: false,
|
||||
...(plan?.action === 'incremental'
|
||||
? {
|
||||
sessionId: plan.sessionId,
|
||||
importMode: 'incremental' as const,
|
||||
...(plan.matchedBy ? { matchedBy: plan.matchedBy } : {}),
|
||||
}
|
||||
: {}),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ test('imports ChatLab JSONL messages with numeric string timestamps consistently
|
||||
newMessageCount: 1,
|
||||
duplicateCount: 0,
|
||||
totalInFile: 1,
|
||||
platform: 'wechat',
|
||||
})
|
||||
|
||||
const result = await incrementalImport('session', filePath, deps)
|
||||
@@ -287,6 +288,7 @@ test('deduplicates an ID-bearing copy of an existing fallback-only message', asy
|
||||
newMessageCount: 0,
|
||||
duplicateCount: 1,
|
||||
totalInFile: 1,
|
||||
platform: 'wechat',
|
||||
})
|
||||
|
||||
const result = await incrementalImport('session', filePath, deps)
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface IncrementalAnalyzeResult {
|
||||
newMessageCount: number
|
||||
duplicateCount: number
|
||||
totalInFile: number
|
||||
platform?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -169,12 +170,15 @@ export async function analyzeIncrementalImport(
|
||||
let totalInFile = 0
|
||||
let newMessageCount = 0
|
||||
let duplicateCount = 0
|
||||
let platform = formatFeature.platform
|
||||
|
||||
await streamParseFile(
|
||||
filePath,
|
||||
{
|
||||
formatOptions: options?.chatIndex === undefined ? undefined : { chatIndex: options.chatIndex },
|
||||
onMeta: () => {},
|
||||
onMeta: (meta) => {
|
||||
platform = meta.platform
|
||||
},
|
||||
onMembers: () => {},
|
||||
onProgress: (progress: ParseProgress) => {
|
||||
deps.onProgress(progress)
|
||||
@@ -196,7 +200,7 @@ export async function analyzeIncrementalImport(
|
||||
options?.formatId
|
||||
)
|
||||
|
||||
return { newMessageCount, duplicateCount, totalInFile }
|
||||
return { newMessageCount, duplicateCount, totalInFile, platform }
|
||||
}
|
||||
|
||||
// ==================== Execute incremental import ====================
|
||||
|
||||
@@ -324,6 +324,7 @@ test('streamingImport preserves an ordinary WhatsApp participant named SYSTEM',
|
||||
const result = await streamingImport(filePath, createImportDeps(dbPath), undefined, 'whatsapp-system-participant')
|
||||
|
||||
assert.equal(result.success, true)
|
||||
assert.equal(result.platform, 'whatsapp')
|
||||
|
||||
const rawDb = new Database(dbPath, { readonly: true, nativeBinding })
|
||||
const member = rawDb
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface ImportDiagnostics {
|
||||
export interface StreamImportResult {
|
||||
success: boolean
|
||||
sessionId?: string
|
||||
platform?: string
|
||||
error?: string
|
||||
diagnostics?: ImportDiagnostics
|
||||
}
|
||||
@@ -292,6 +293,7 @@ async function streamImportSingle(
|
||||
>()
|
||||
|
||||
let metaInserted = false
|
||||
let importedPlatform = formatFeature.platform
|
||||
let messageCountInBatch = 0
|
||||
let totalMessageCount = 0
|
||||
let duplicateCount = 0
|
||||
@@ -382,6 +384,7 @@ async function streamImportSingle(
|
||||
|
||||
onMeta: (meta: ParsedMeta) => {
|
||||
callbackStats.onMetaCalls++
|
||||
importedPlatform = meta.platform || importedPlatform
|
||||
if (!metaInserted) {
|
||||
logger?.info(`Writing meta: name=${meta.name}, type=${meta.type}, platform=${meta.platform}`)
|
||||
insertMeta.run(
|
||||
@@ -689,9 +692,9 @@ async function streamImportSingle(
|
||||
}
|
||||
|
||||
if (importError) {
|
||||
return { success: false, error: importError, diagnostics }
|
||||
return { success: false, platform: importedPlatform, error: importError, diagnostics }
|
||||
}
|
||||
return { success: true, sessionId, diagnostics }
|
||||
return { success: true, sessionId, platform: importedPlatform, diagnostics }
|
||||
}
|
||||
|
||||
// ==================== Dry-run analysis ====================
|
||||
|
||||
@@ -3,55 +3,35 @@ import assert from 'node:assert/strict'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AnalyticsService } from './analytics'
|
||||
import { AnalyticsService, type AnalyticsServiceOptions } from './analytics'
|
||||
|
||||
const APP_KEY = 'app-US-test'
|
||||
const UMAMI_ENDPOINT = 'https://telemetry.example.com/api/send'
|
||||
const UMAMI_WEBSITE_ID = 'website-test-id'
|
||||
|
||||
function createTempSystemDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), 'chatlab-analytics-'))
|
||||
}
|
||||
|
||||
function createOptions(overrides: Partial<AnalyticsServiceOptions> = {}): AnalyticsServiceOptions {
|
||||
return {
|
||||
appVersion: '1.2.3',
|
||||
appType: 'desktop',
|
||||
umami: { endpoint: UMAMI_ENDPOINT, websiteId: UMAMI_WEBSITE_ID },
|
||||
getAiModelConfigured: () => true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function readAnalyticsData(systemDir: string): {
|
||||
anonymousId?: string | null
|
||||
firstReportDate?: string | null
|
||||
lastReportDate?: string | null
|
||||
enabled?: boolean
|
||||
} {
|
||||
return JSON.parse(readFileSync(join(systemDir, 'analytics.json'), 'utf-8'))
|
||||
}
|
||||
|
||||
test('trackDailyActive does not mark the day as reported when fetch rejects', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalConsoleError = console.error
|
||||
globalThis.fetch = (() => Promise.reject(new Error('offline'))) as typeof fetch
|
||||
console.error = () => {}
|
||||
|
||||
try {
|
||||
await new AnalyticsService(systemDir, APP_KEY, '1.0.0').trackDailyActive()
|
||||
|
||||
assert.equal(existsSync(join(systemDir, 'analytics.json')), false)
|
||||
} finally {
|
||||
console.error = originalConsoleError
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('trackDailyActive does not mark the day as reported when Aptabase returns non-OK', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (() => Promise.resolve(new Response(null, { status: 500 }))) as typeof fetch
|
||||
|
||||
try {
|
||||
await new AnalyticsService(systemDir, APP_KEY, '1.0.0').trackDailyActive()
|
||||
|
||||
assert.equal(existsSync(join(systemDir, 'analytics.json')), false)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('trackDailyActive marks the day as reported after a successful post', async () => {
|
||||
test('track sends a normalized Umami event without private import details', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const originalFetch = globalThis.fetch
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
@@ -61,14 +41,274 @@ test('trackDailyActive marks the day as reported after a successful post', async
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
await new AnalyticsService(systemDir, APP_KEY, '1.0.0').trackDailyActive({ platform: 'cli-web' })
|
||||
const service = new AnalyticsService(systemDir, createOptions())
|
||||
service.setAppLocale('zh-CN')
|
||||
|
||||
assert.equal(
|
||||
await service.track('chat_import_completed', {
|
||||
chat_platform: 'weixin',
|
||||
duration_ms: 1234.4,
|
||||
filename: 'private-chat.json',
|
||||
}),
|
||||
true
|
||||
)
|
||||
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].url, UMAMI_ENDPOINT)
|
||||
const body = JSON.parse(String(requests[0].init?.body)) as {
|
||||
type: string
|
||||
payload: {
|
||||
website: string
|
||||
name: string
|
||||
language: string
|
||||
id: string
|
||||
url: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
assert.equal(body.type, 'event')
|
||||
assert.equal(body.payload.website, UMAMI_WEBSITE_ID)
|
||||
assert.equal(body.payload.name, 'chat_import_completed')
|
||||
assert.equal(body.payload.language, 'zh-CN')
|
||||
assert.match(body.payload.id, /^[0-9a-f-]{36}$/)
|
||||
assert.equal(body.payload.url, '/import')
|
||||
assert.deepEqual(body.payload.data, {
|
||||
session_id: body.payload.data.session_id,
|
||||
app_version: '1.2.3',
|
||||
app_type: 'desktop',
|
||||
os: process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux',
|
||||
app_locale: 'zh-CN',
|
||||
ai_model_configured: true,
|
||||
schema_version: 1,
|
||||
chat_platform: 'weixin',
|
||||
duration_ms: 1234,
|
||||
})
|
||||
assert.equal('filename' in body.payload.data, false)
|
||||
assert.match(String(new Headers(requests[0].init?.headers).get('User-Agent')), /^ChatLab\/1\.2\.3/)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('track preserves custom platforms but rejects values that can contain private paths', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const originalFetch = globalThis.fetch
|
||||
const requests: RequestInit[] = []
|
||||
globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push(init ?? {})
|
||||
return Promise.resolve(new Response(null, { status: 204 }))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const service = new AnalyticsService(systemDir, createOptions())
|
||||
await service.track('chat_import_started', { chat_platform: '自定义导入器 v2' })
|
||||
await service.track('chat_import_started', { chat_platform: '/Users/alice/private-export' })
|
||||
|
||||
const platforms = requests.map((request) => JSON.parse(String(request.body)).payload.data.chat_platform)
|
||||
assert.deepEqual(platforms, ['自定义导入器 v2', 'unknown'])
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('track normalizes the shared locale instead of accepting arbitrary event data', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push({ url: String(input), init })
|
||||
return Promise.resolve(new Response(null, { status: 204 }))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const service = new AnalyticsService(systemDir, createOptions())
|
||||
await service.track('feature_used', { feature_id: 'insights', app_locale: 'ja-JP' })
|
||||
await service.track('feature_used', {
|
||||
feature_id: 'insights',
|
||||
app_locale: '/Users/alice/private-chat.json',
|
||||
})
|
||||
|
||||
const first = JSON.parse(String(requests[0].init?.body)) as { payload: { language: string } }
|
||||
const second = JSON.parse(String(requests[1].init?.body)) as {
|
||||
payload: { language: string; data: Record<string, unknown> }
|
||||
}
|
||||
assert.equal(first.payload.language, 'ja-JP')
|
||||
assert.equal(second.payload.language, 'unknown')
|
||||
assert.equal(second.payload.data.app_locale, 'unknown')
|
||||
assert.equal(JSON.stringify(second).includes('/Users/alice'), false)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('anonymous id persists across AnalyticsService instances', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const originalFetch = globalThis.fetch
|
||||
const distinctIds: string[] = []
|
||||
globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
distinctIds.push(JSON.parse(String(init?.body)).payload.id)
|
||||
return Promise.resolve(new Response(null, { status: 204 }))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const options = createOptions()
|
||||
await new AnalyticsService(systemDir, options).track('app_started')
|
||||
await new AnalyticsService(systemDir, options).track('app_started')
|
||||
|
||||
assert.equal(distinctIds[0], distinctIds[1])
|
||||
assert.match(distinctIds[0], /^[0-9a-f-]{36}$/)
|
||||
assert.equal(readAnalyticsData(systemDir).anonymousId, distinctIds[0])
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('disabled analytics sends no event', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const originalFetch = globalThis.fetch
|
||||
let requestCount = 0
|
||||
globalThis.fetch = (() => {
|
||||
requestCount++
|
||||
return Promise.resolve(new Response(null, { status: 204 }))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const service = new AnalyticsService(systemDir, createOptions())
|
||||
service.setEnabled(false)
|
||||
|
||||
assert.equal(await service.track('app_started'), false)
|
||||
assert.equal(requestCount, 0)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('trackDailyActive remembers the locale while disabled for later opt-in events', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const originalFetch = globalThis.fetch
|
||||
const requests: RequestInit[] = []
|
||||
globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push(init ?? {})
|
||||
return Promise.resolve(new Response(null, { status: 204 }))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const service = new AnalyticsService(systemDir, createOptions())
|
||||
service.setEnabled(false)
|
||||
|
||||
await service.trackDailyActive({ app_locale: 'ja-JP' })
|
||||
|
||||
assert.equal(requests.length, 0)
|
||||
assert.equal(readAnalyticsData(systemDir).anonymousId, null)
|
||||
|
||||
service.setEnabled(true)
|
||||
assert.equal(await service.track('feature_used', { feature_id: 'insights' }), true)
|
||||
|
||||
const body = JSON.parse(String(requests[0].body)) as {
|
||||
payload: { language: string; data: Record<string, unknown> }
|
||||
}
|
||||
assert.equal(body.payload.language, 'ja-JP')
|
||||
assert.equal(body.payload.data.app_locale, 'ja-JP')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('unknown events and invalid feature ids are not sent', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const originalFetch = globalThis.fetch
|
||||
let requestCount = 0
|
||||
globalThis.fetch = (() => {
|
||||
requestCount++
|
||||
return Promise.resolve(new Response(null, { status: 204 }))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const service = new AnalyticsService(systemDir, createOptions())
|
||||
assert.equal(await service.track('arbitrary_event'), false)
|
||||
assert.equal(await service.track('feature_used', { feature_id: 'some-user-controlled-value' }), false)
|
||||
assert.equal(requestCount, 0)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('incremental import usage is tracked without platform properties', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const originalFetch = globalThis.fetch
|
||||
const requests: RequestInit[] = []
|
||||
globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push(init ?? {})
|
||||
return Promise.resolve(new Response(null, { status: 204 }))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const service = new AnalyticsService(systemDir, createOptions())
|
||||
assert.equal(
|
||||
await service.track('incremental_import_used', {
|
||||
chat_platform: 'private-platform-value',
|
||||
}),
|
||||
true
|
||||
)
|
||||
|
||||
const body = JSON.parse(String(requests[0].body)) as {
|
||||
payload: { name: string; url: string; data: Record<string, unknown> }
|
||||
}
|
||||
assert.equal(body.payload.name, 'incremental_import_used')
|
||||
assert.equal(body.payload.url, '/import/incremental')
|
||||
assert.equal('chat_platform' in body.payload.data, false)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('trackDailyActive does not mark the day as reported when Umami rejects', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (() => Promise.reject(new Error('offline'))) as typeof fetch
|
||||
|
||||
try {
|
||||
await new AnalyticsService(systemDir, createOptions()).trackDailyActive({ app_locale: 'en-US' })
|
||||
|
||||
const data = readAnalyticsData(systemDir)
|
||||
assert.equal(data.lastReportDate, null)
|
||||
assert.equal(data.firstReportDate, null)
|
||||
assert.ok(data.anonymousId)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('trackDailyActive reports startup every process and daily activity once per day', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const originalFetch = globalThis.fetch
|
||||
const eventNames: string[] = []
|
||||
globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
eventNames.push(JSON.parse(String(init?.body)).payload.name)
|
||||
return Promise.resolve(new Response(null, { status: 204 }))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const options = createOptions()
|
||||
const firstProcess = new AnalyticsService(systemDir, options)
|
||||
await firstProcess.trackDailyActive({ app_locale: 'zh-CN' })
|
||||
await firstProcess.trackDailyActive({ app_locale: 'zh-CN' })
|
||||
await new AnalyticsService(systemDir, options).trackDailyActive({ app_locale: 'zh-CN' })
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].url, 'https://us.aptabase.com/api/v0/event')
|
||||
assert.equal(JSON.parse(String(requests[0].init?.body)).eventName, 'app_active_new')
|
||||
assert.deepEqual(eventNames, ['app_started', 'app_active_new', 'app_started'])
|
||||
assert.deepEqual(readAnalyticsData(systemDir), {
|
||||
enabled: true,
|
||||
anonymousId: readAnalyticsData(systemDir).anonymousId,
|
||||
firstReportDate: today,
|
||||
lastReportDate: today,
|
||||
})
|
||||
@@ -77,3 +317,52 @@ test('trackDailyActive marks the day as reported after a successful post', async
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('trackDailyActive deduplicates overlapping reports', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const originalFetch = globalThis.fetch
|
||||
const eventNames: string[] = []
|
||||
let resolveStartup: (() => void) | undefined
|
||||
globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
eventNames.push(JSON.parse(String(init?.body)).payload.name)
|
||||
if (eventNames.length === 1) {
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveStartup = () => resolve(new Response(null, { status: 204 }))
|
||||
})
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const service = new AnalyticsService(systemDir, createOptions())
|
||||
const first = service.trackDailyActive({ app_locale: 'zh-CN' })
|
||||
const second = service.trackDailyActive({ app_locale: 'en-US' })
|
||||
resolveStartup?.()
|
||||
await Promise.all([first, second])
|
||||
|
||||
assert.deepEqual(eventNames, ['app_started', 'app_active_new'])
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('track is a no-op without the official build configuration', async () => {
|
||||
const systemDir = createTempSystemDir()
|
||||
const previousEndpoint = process.env.UMAMI_ENDPOINT
|
||||
const previousWebsiteId = process.env.UMAMI_WEBSITE_ID
|
||||
delete process.env.UMAMI_ENDPOINT
|
||||
delete process.env.UMAMI_WEBSITE_ID
|
||||
|
||||
try {
|
||||
const service = new AnalyticsService(systemDir, { appVersion: '1.2.3', appType: 'desktop' })
|
||||
assert.equal(await service.track('app_started'), false)
|
||||
assert.equal(existsSync(join(systemDir, 'analytics.json')), false)
|
||||
} finally {
|
||||
if (previousEndpoint === undefined) delete process.env.UMAMI_ENDPOINT
|
||||
else process.env.UMAMI_ENDPOINT = previousEndpoint
|
||||
if (previousWebsiteId === undefined) delete process.env.UMAMI_WEBSITE_ID
|
||||
else process.env.UMAMI_WEBSITE_ID = previousWebsiteId
|
||||
rmSync(systemDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,54 +1,282 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AnalyticsAppType, AnalyticsEventName, AnalyticsPropertyValue } from '@openchatlab/shared-types'
|
||||
import { readJsonFile, withFileLock, writeJsonFileAtomically } from '@openchatlab/config'
|
||||
import { appLogger } from '../logging/app-logger'
|
||||
|
||||
const REGIONS: Record<string, string> = {
|
||||
US: 'https://us.aptabase.com',
|
||||
EU: 'https://eu.aptabase.com',
|
||||
export interface AnalyticsServiceOptions {
|
||||
appVersion: string
|
||||
appType: AnalyticsAppType
|
||||
umami?: {
|
||||
endpoint: string
|
||||
websiteId: string
|
||||
}
|
||||
getAiModelConfigured?: () => boolean
|
||||
}
|
||||
|
||||
interface AnalyticsData {
|
||||
lastReportDate: string | null
|
||||
firstReportDate: string | null
|
||||
anonymousId: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_DATA: AnalyticsData = { lastReportDate: null, firstReportDate: null, enabled: true }
|
||||
interface NormalizedEvent {
|
||||
name: AnalyticsEventName
|
||||
properties: Record<string, AnalyticsPropertyValue>
|
||||
}
|
||||
|
||||
interface AnalyticsEnvelope extends NormalizedEvent {
|
||||
anonymousId: string
|
||||
sessionId: string
|
||||
appVersion: string
|
||||
appType: AnalyticsAppType
|
||||
os: 'macos' | 'windows' | 'linux' | 'other'
|
||||
appLocale: string
|
||||
aiModelConfigured: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_DATA: AnalyticsData = {
|
||||
lastReportDate: null,
|
||||
firstReportDate: null,
|
||||
anonymousId: null,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const EVENT_NAMES = new Set<AnalyticsEventName>([
|
||||
'app_started',
|
||||
'app_active_new',
|
||||
'app_active',
|
||||
'chat_import_started',
|
||||
'chat_import_completed',
|
||||
'chat_import_failed',
|
||||
'incremental_import_used',
|
||||
'feature_used',
|
||||
'ai_setup_completed',
|
||||
'ai_request_started',
|
||||
'ai_request_completed',
|
||||
])
|
||||
|
||||
const FEATURE_IDS = new Set([
|
||||
'insights',
|
||||
'ranking',
|
||||
'ai_chat',
|
||||
'sql_lab',
|
||||
'message_search',
|
||||
'member_management',
|
||||
'session_merge',
|
||||
'export',
|
||||
])
|
||||
|
||||
const FAILURE_REASONS = new Set([
|
||||
'auth',
|
||||
'network',
|
||||
'rate_limit',
|
||||
'model_not_found',
|
||||
'aborted',
|
||||
'parse',
|
||||
'write',
|
||||
'unknown',
|
||||
])
|
||||
|
||||
const APP_LOCALES = new Set(['zh-CN', 'zh-TW', 'en-US', 'ja-JP'])
|
||||
|
||||
function getUmamiConfigFromEnv(): AnalyticsServiceOptions['umami'] | undefined {
|
||||
const endpoint = process.env.UMAMI_ENDPOINT?.trim()
|
||||
const websiteId = process.env.UMAMI_WEBSITE_ID?.trim()
|
||||
return endpoint && websiteId ? { endpoint, websiteId } : undefined
|
||||
}
|
||||
|
||||
function normalizeOs(): AnalyticsEnvelope['os'] {
|
||||
if (process.platform === 'darwin') return 'macos'
|
||||
if (process.platform === 'win32') return 'windows'
|
||||
if (process.platform === 'linux') return 'linux'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function normalizeLocale(value: unknown): string {
|
||||
if (typeof value !== 'string') return 'unknown'
|
||||
const trimmed = value.trim()
|
||||
return APP_LOCALES.has(trimmed) ? trimmed : 'unknown'
|
||||
}
|
||||
|
||||
function normalizeAnalyticsImportPlatform(value: unknown): string {
|
||||
if (typeof value !== 'string') return 'unknown'
|
||||
const platform = value.trim()
|
||||
// Keep custom import-format categories to measure unsupported formats.
|
||||
// This is not a general privacy sanitizer; only paths and control characters are excluded.
|
||||
const hasPathOrControlCharacter = [...platform].some(
|
||||
(char) => char === '/' || char === '\\' || char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127
|
||||
)
|
||||
return platform.length > 0 && platform.length <= 64 && !hasPathOrControlCharacter ? platform : 'unknown'
|
||||
}
|
||||
|
||||
function normalizeDuration(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return null
|
||||
return Math.min(Math.round(value), 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
function normalizeEvent(eventName: string, props: Record<string, unknown> = {}): NormalizedEvent | null {
|
||||
if (!EVENT_NAMES.has(eventName as AnalyticsEventName)) return null
|
||||
const name = eventName as AnalyticsEventName
|
||||
const properties: Record<string, AnalyticsPropertyValue> = {}
|
||||
|
||||
if (name.startsWith('chat_import_')) {
|
||||
properties.chat_platform = normalizeAnalyticsImportPlatform(props.chat_platform)
|
||||
}
|
||||
|
||||
if (name === 'chat_import_completed' || name === 'ai_request_completed') {
|
||||
const duration = normalizeDuration(props.duration_ms)
|
||||
if (duration !== null) properties.duration_ms = duration
|
||||
}
|
||||
|
||||
if (name === 'feature_used') {
|
||||
if (typeof props.feature_id !== 'string' || !FEATURE_IDS.has(props.feature_id)) return null
|
||||
properties.feature_id = props.feature_id
|
||||
}
|
||||
|
||||
if (name === 'ai_request_completed') {
|
||||
if (typeof props.success !== 'boolean') return null
|
||||
properties.success = props.success
|
||||
}
|
||||
|
||||
if (name === 'chat_import_failed' || (name === 'ai_request_completed' && props.success === false)) {
|
||||
const reason = typeof props.failure_reason === 'string' ? props.failure_reason : 'unknown'
|
||||
properties.failure_reason = FAILURE_REASONS.has(reason) ? reason : 'unknown'
|
||||
}
|
||||
|
||||
return { name, properties }
|
||||
}
|
||||
|
||||
function eventPath(event: NormalizedEvent): string {
|
||||
if (event.name === 'incremental_import_used') return '/import/incremental'
|
||||
if (event.name.startsWith('chat_import_')) return '/import'
|
||||
if (event.name.startsWith('ai_')) return '/ai'
|
||||
if (event.name === 'feature_used') return `/features/${event.properties.feature_id}`
|
||||
return '/app'
|
||||
}
|
||||
|
||||
export class AnalyticsService {
|
||||
private readonly dataPath: string
|
||||
private readonly endpoint: string | null
|
||||
private readonly appKey: string
|
||||
private readonly appVersion: string
|
||||
private readonly sessionId: string
|
||||
private readonly options: AnalyticsServiceOptions
|
||||
private readonly sessionId = randomUUID()
|
||||
private appLocale = 'unknown'
|
||||
private startupAttempted = false
|
||||
// Product constraint: only one ChatLab runtime is expected to use a system directory at a time.
|
||||
// Daily-active deduplication is intentionally process-local unless concurrent runtimes become supported.
|
||||
private dailyActivePromise: Promise<void> | null = null
|
||||
|
||||
constructor(systemDir: string, appKey: string, appVersion: string) {
|
||||
constructor(systemDir: string, options: AnalyticsServiceOptions) {
|
||||
this.dataPath = path.join(systemDir, 'analytics.json')
|
||||
this.appKey = appKey
|
||||
this.appVersion = appVersion
|
||||
this.sessionId = `${Math.floor(Date.now() / 1000)}${Math.floor(Math.random() * 1e8)
|
||||
.toString()
|
||||
.padStart(8, '0')}`
|
||||
const parts = appKey.split('-')
|
||||
const base = parts.length === 3 ? REGIONS[parts[1]] : undefined
|
||||
this.endpoint = base ? `${base}/api/v0/event` : null
|
||||
this.options = {
|
||||
...options,
|
||||
umami: options.umami ?? getUmamiConfigFromEnv(),
|
||||
}
|
||||
}
|
||||
|
||||
private load(): AnalyticsData {
|
||||
try {
|
||||
if (fs.existsSync(this.dataPath)) {
|
||||
return { ...DEFAULT_DATA, ...JSON.parse(fs.readFileSync(this.dataPath, 'utf-8')) }
|
||||
}
|
||||
} catch (_e) {
|
||||
// Corrupted or unreadable file — fall back to defaults
|
||||
const stored = readJsonFile<Partial<AnalyticsData>>(this.dataPath)
|
||||
if (stored) return { ...DEFAULT_DATA, ...stored }
|
||||
} catch (error) {
|
||||
appLogger.error('analytics', 'Failed to read analytics settings; using defaults', error)
|
||||
}
|
||||
return { ...DEFAULT_DATA }
|
||||
}
|
||||
|
||||
private save(data: AnalyticsData): void {
|
||||
private updateData<T>(update: (data: AnalyticsData) => T, fallback: T): T {
|
||||
try {
|
||||
fs.writeFileSync(this.dataPath, JSON.stringify(data, null, 2), 'utf-8')
|
||||
} catch (e) {
|
||||
console.error('[Analytics] Failed to save:', e)
|
||||
return withFileLock(this.dataPath, () => {
|
||||
const data = this.load()
|
||||
const result = update(data)
|
||||
writeJsonFileAtomically(this.dataPath, data, 0o600)
|
||||
return result
|
||||
})
|
||||
} catch (error) {
|
||||
appLogger.error('analytics', 'Failed to update analytics settings', error)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateAnonymousId(): string {
|
||||
const fallback = randomUUID()
|
||||
return this.updateData((data) => {
|
||||
data.anonymousId ??= randomUUID()
|
||||
return data.anonymousId
|
||||
}, fallback)
|
||||
}
|
||||
|
||||
private isAiModelConfigured(): boolean {
|
||||
try {
|
||||
return this.options.getAiModelConfigured?.() === true
|
||||
} catch (error) {
|
||||
appLogger.error('analytics', 'Failed to resolve AI configuration state', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private buildEnvelope(event: NormalizedEvent): AnalyticsEnvelope {
|
||||
return {
|
||||
...event,
|
||||
anonymousId: this.getOrCreateAnonymousId(),
|
||||
sessionId: this.sessionId,
|
||||
appVersion: this.options.appVersion,
|
||||
appType: this.options.appType,
|
||||
os: normalizeOs(),
|
||||
appLocale: this.appLocale,
|
||||
aiModelConfigured: this.isAiModelConfigured(),
|
||||
}
|
||||
}
|
||||
|
||||
private commonProperties(envelope: AnalyticsEnvelope): Record<string, AnalyticsPropertyValue> {
|
||||
return {
|
||||
session_id: envelope.sessionId,
|
||||
app_version: envelope.appVersion,
|
||||
app_type: envelope.appType,
|
||||
os: envelope.os,
|
||||
app_locale: envelope.appLocale,
|
||||
ai_model_configured: envelope.aiModelConfigured,
|
||||
schema_version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
private async sendToUmami(envelope: AnalyticsEnvelope): Promise<boolean> {
|
||||
const umami = this.options.umami
|
||||
if (!umami) return false
|
||||
try {
|
||||
const response = await fetch(umami.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': `ChatLab/${envelope.appVersion} (${envelope.os}; ${envelope.appType})`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'event',
|
||||
payload: {
|
||||
website: umami.websiteId,
|
||||
hostname: 'chatlab',
|
||||
url: eventPath(envelope),
|
||||
language: envelope.appLocale,
|
||||
name: envelope.name,
|
||||
id: envelope.anonymousId,
|
||||
data: {
|
||||
...this.commonProperties(envelope),
|
||||
...envelope.properties,
|
||||
},
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!response.ok) {
|
||||
appLogger.warn('analytics', 'Umami rejected analytics event', {
|
||||
eventName: envelope.name,
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
appLogger.error('analytics', 'Failed to send analytics event to Umami', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,56 +285,65 @@ export class AnalyticsService {
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
const data = this.load()
|
||||
this.updateData((data) => {
|
||||
data.enabled = enabled
|
||||
this.save(data)
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
async track(eventName: string, props?: Record<string, string | number>): Promise<boolean> {
|
||||
if (!this.endpoint) return false
|
||||
setAppLocale(locale: string): void {
|
||||
this.appLocale = normalizeLocale(locale)
|
||||
}
|
||||
|
||||
async track(eventName: string, props?: Record<string, unknown>): Promise<boolean> {
|
||||
if (!this.getEnabled()) return false
|
||||
try {
|
||||
const resp = await fetch(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'App-Key': this.appKey },
|
||||
body: JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
sessionId: this.sessionId,
|
||||
eventName,
|
||||
systemProps: {
|
||||
isDebug: false,
|
||||
locale: '',
|
||||
osName: process.platform,
|
||||
osVersion: '',
|
||||
engineName: 'Node.js',
|
||||
engineVersion: process.versions.node,
|
||||
appVersion: this.appVersion,
|
||||
sdkVersion: 'chatlab@1',
|
||||
},
|
||||
props,
|
||||
}),
|
||||
if (props?.app_locale !== undefined) this.setAppLocale(String(props.app_locale))
|
||||
const event = normalizeEvent(eventName, props)
|
||||
if (!event) return false
|
||||
if (!this.options.umami) return false
|
||||
|
||||
const envelope = this.buildEnvelope(event)
|
||||
const delivered = await this.sendToUmami(envelope)
|
||||
appLogger.debug('analytics', delivered ? 'Analytics event delivered' : 'Analytics event was not delivered', {
|
||||
eventName: envelope.name,
|
||||
})
|
||||
return resp.ok
|
||||
} catch (e) {
|
||||
console.error(`[Analytics] Failed to track ${eventName}:`, e)
|
||||
return false
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
|
||||
async trackDailyActive(props?: Record<string, string | number>): Promise<void> {
|
||||
if (!this.endpoint) return
|
||||
trackDailyActive(props?: Record<string, unknown>): Promise<void> {
|
||||
if (this.dailyActivePromise) {
|
||||
const locale = props?.app_locale ?? props?.locale
|
||||
if (locale !== undefined) this.setAppLocale(String(locale))
|
||||
return this.dailyActivePromise
|
||||
}
|
||||
|
||||
this.dailyActivePromise = this.trackDailyActiveInternal(props).finally(() => {
|
||||
this.dailyActivePromise = null
|
||||
})
|
||||
return this.dailyActivePromise
|
||||
}
|
||||
|
||||
private async trackDailyActiveInternal(props?: Record<string, unknown>): Promise<void> {
|
||||
const locale = props?.app_locale ?? props?.locale
|
||||
if (locale !== undefined) this.setAppLocale(String(locale))
|
||||
|
||||
const data = this.load()
|
||||
if (!data.enabled) return
|
||||
|
||||
if (!this.startupAttempted) {
|
||||
this.startupAttempted = true
|
||||
await this.track('app_started')
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const isNew = data.firstReportDate === null
|
||||
if (isNew) data.firstReportDate = today
|
||||
if (data.lastReportDate === today) {
|
||||
if (isNew) this.save(data)
|
||||
return
|
||||
}
|
||||
const tracked = await this.track(isNew ? 'app_active_new' : 'app_active', props)
|
||||
if (data.lastReportDate === today) return
|
||||
|
||||
const tracked = await this.track(isNew ? 'app_active_new' : 'app_active')
|
||||
if (!tracked) return
|
||||
data.lastReportDate = today
|
||||
this.save(data)
|
||||
|
||||
this.updateData((persisted) => {
|
||||
persisted.firstReportDate ??= today
|
||||
persisted.lastReportDate = today
|
||||
}, undefined)
|
||||
}
|
||||
}
|
||||
@@ -673,3 +673,22 @@ export interface UiConfig {
|
||||
session_gap_threshold: number
|
||||
summary_strategy?: 'brief' | 'standard'
|
||||
}
|
||||
|
||||
// ==================== 匿名使用统计 ====================
|
||||
|
||||
export type AnalyticsAppType = 'desktop' | 'cli' | 'cli_web' | 'web_wasm'
|
||||
|
||||
export type AnalyticsEventName =
|
||||
| 'app_started'
|
||||
| 'app_active_new'
|
||||
| 'app_active'
|
||||
| 'chat_import_started'
|
||||
| 'chat_import_completed'
|
||||
| 'chat_import_failed'
|
||||
| 'incremental_import_used'
|
||||
| 'feature_used'
|
||||
| 'ai_setup_completed'
|
||||
| 'ai_request_started'
|
||||
| 'ai_request_completed'
|
||||
|
||||
export type AnalyticsPropertyValue = string | number | boolean
|
||||
@@ -8,6 +8,7 @@ import { formatLocalizedDate } from '@/utils'
|
||||
import { useTimeSelect } from './useTimeSelect'
|
||||
import { useDataService } from '@/services'
|
||||
import { abortAnalyticsRequests } from '@/services/utils/http'
|
||||
import { trackProductEvent } from '@/services/product-analytics'
|
||||
|
||||
interface UseSessionAnalysisPageBaseOptions {
|
||||
route: RouteLocationNormalizedLoaded
|
||||
@@ -45,6 +46,20 @@ export function useSessionAnalysisPageBase(options: UseSessionAnalysisPageBaseOp
|
||||
|
||||
const activeTab = ref(resolveActiveTabFromRoute())
|
||||
|
||||
watch(
|
||||
activeTab,
|
||||
(tab) => {
|
||||
const featureId = {
|
||||
insights: 'insights',
|
||||
ranking: 'ranking',
|
||||
'ai-chat': 'ai_chat',
|
||||
lab: 'sql_lab',
|
||||
}[tab]
|
||||
if (featureId) trackProductEvent('feature_used', { feature_id: featureId })
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const { timeRangeValue, fullTimeRange, availableYears, timeFilter, initialTimeState } = useTimeSelect(route, router, {
|
||||
activeTab,
|
||||
isInitialLoad,
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"privacy": {
|
||||
"title": "Privacy Settings",
|
||||
"analytics": "Anonymous Usage Statistics",
|
||||
"analyticsDesc": "When enabled, the app collects non-sensitive data like version number and OS version to help improve the product"
|
||||
"analyticsDesc": "When enabled, the app sends an anonymous installation ID, version, system, app type, language, approximate region, imported chat platform, and major feature usage. It never sends chat content, contacts, filenames, paths, model names, or keys"
|
||||
},
|
||||
"developer": {
|
||||
"title": "Developer Options",
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"privacy": {
|
||||
"title": "プライバシー設定",
|
||||
"analytics": "匿名利用統計",
|
||||
"analyticsDesc": "有効にすると、バージョン番号やOS情報などの非機密データを収集し、製品の改善に役立てます"
|
||||
"analyticsDesc": "有効にすると、匿名のインストールID、バージョン、OS、アプリ種別、言語、おおよその地域、インポート元のチャットサービス、主要機能の利用状況を送信します。チャット内容、連絡先、ファイル名、パス、モデル名、キーは送信しません"
|
||||
},
|
||||
"developer": {
|
||||
"title": "開発者オプション",
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"privacy": {
|
||||
"title": "隐私设置",
|
||||
"analytics": "匿名使用统计",
|
||||
"analyticsDesc": "开启后,软件会收集版本号、操作系统版本等非敏感数据,用于帮助优化产品"
|
||||
"analyticsDesc": "开启后,软件会发送匿名安装标识、版本、系统、运行方式、语言、粗略地区,以及导入平台和主要功能使用情况;不会发送聊天内容、联系人、文件名、路径、模型名称或密钥"
|
||||
},
|
||||
"developer": {
|
||||
"title": "开发者选项",
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"privacy": {
|
||||
"title": "隱私設定",
|
||||
"analytics": "匿名使用統計",
|
||||
"analyticsDesc": "開啟後,軟體會收集版本號、作業系統版本等非敏感資料,用於幫助最佳化產品"
|
||||
"analyticsDesc": "開啟後,軟體會傳送匿名安裝識別碼、版本、系統、執行方式、語言、概略地區,以及匯入平台和主要功能使用情況;不會傳送聊天內容、聯絡人、檔名、路徑、模型名稱或金鑰"
|
||||
},
|
||||
"developer": {
|
||||
"title": "開發者選項",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LlmStreamChunk, AgentStreamChunk, AgentStreamResult, AgentStreamParams } from './types'
|
||||
import { fetchSSE } from '../utils/sse'
|
||||
import { trackProductEvent } from '../product-analytics'
|
||||
|
||||
/**
|
||||
* LLM stream service — uses fetchSSE to consume the shared
|
||||
@@ -52,6 +53,8 @@ export function useAgentStreamService() {
|
||||
params: AgentStreamParams,
|
||||
onChunk?: (chunk: AgentStreamChunk) => void
|
||||
): { requestId: string; promise: Promise<AgentStreamResult> } {
|
||||
const startedAt = Date.now()
|
||||
trackProductEvent('ai_request_started')
|
||||
const abortController = new AbortController()
|
||||
const localRequestId = `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
requestIdMap.set(localRequestId, { serverId: '', abortController })
|
||||
@@ -133,7 +136,24 @@ export function useAgentStreamService() {
|
||||
})
|
||||
})
|
||||
|
||||
return { requestId: localRequestId, promise }
|
||||
const trackedPromise = promise.then((result) => {
|
||||
trackProductEvent('ai_request_completed', {
|
||||
success: result.success,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
...(result.success
|
||||
? {}
|
||||
: {
|
||||
failure_reason: result.result?.aborted
|
||||
? 'aborted'
|
||||
: result.error?.name === 'FetchError'
|
||||
? 'network'
|
||||
: 'unknown',
|
||||
}),
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
return { requestId: localRequestId, promise: trackedPromise }
|
||||
},
|
||||
|
||||
async abort(requestId: string): Promise<{ success: boolean; error?: string }> {
|
||||
|
||||
@@ -57,6 +57,7 @@ export class ElectronImportAdapter implements ImportAdapter {
|
||||
normalizeImportResult({
|
||||
success: result.success,
|
||||
sessionId: result.sessionId,
|
||||
platform: result.platform,
|
||||
error: result.error,
|
||||
importMode: result.importMode,
|
||||
matchedBy: result.matchedBy,
|
||||
@@ -196,6 +197,7 @@ export class ElectronImportAdapter implements ImportAdapter {
|
||||
normalizeImportResult({
|
||||
success: result.success,
|
||||
sessionId: result.sessionId,
|
||||
platform: result.platform,
|
||||
error: result.error,
|
||||
importMode: result.importMode,
|
||||
matchedBy: result.matchedBy,
|
||||
|
||||
@@ -123,4 +123,28 @@ describe('archive import source adapters', () => {
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves failed directory-import platform metadata from SSE', async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response('event: error\ndata: {"success":false,"platform":"line","error":"database unavailable"}\n\n', {
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
})) as typeof fetch
|
||||
|
||||
const result = await new FetchImportAdapter().importDirectory([new File(['{}'], 'chat.json')])
|
||||
|
||||
assert.deepEqual(result, {
|
||||
success: false,
|
||||
sessionId: undefined,
|
||||
platform: 'line',
|
||||
error: 'database unavailable',
|
||||
importMode: undefined,
|
||||
matchedBy: undefined,
|
||||
createReason: undefined,
|
||||
newMessageCount: undefined,
|
||||
duplicateCount: undefined,
|
||||
messageCount: undefined,
|
||||
memberCount: undefined,
|
||||
diagnostics: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { AnalyticsEventName } from '@openchatlab/shared-types'
|
||||
import type { ImportAdapter, ImportResult } from './types'
|
||||
import { TelemetryImportAdapter } from './telemetry'
|
||||
|
||||
function createDelegate(
|
||||
importResult: ImportResult = { success: true, sessionId: 'session-1', platform: 'weixin' }
|
||||
): ImportAdapter {
|
||||
return {
|
||||
importFile: async () => importResult,
|
||||
detectFormat: async () => ({
|
||||
id: 'chatlab',
|
||||
name: 'ChatLab JSON',
|
||||
platform: 'qq',
|
||||
extensions: ['json'],
|
||||
}),
|
||||
scanMultiChatFile: async () => [],
|
||||
prepareImportSource: async () => ({ success: false }),
|
||||
importPreparedChat: async () => importResult,
|
||||
releaseImportSource: async () => {},
|
||||
getSupportedFormats: async () => [],
|
||||
importDemo: async () => ({ success: true }),
|
||||
analyzeIncrementalImport: async () => ({ newMessageCount: 0, duplicateCount: 0, totalInFile: 0, platform: 'qq' }),
|
||||
incrementalImport: async () => ({ success: true, newMessageCount: 0 }),
|
||||
importDirectory: async () => importResult,
|
||||
}
|
||||
}
|
||||
|
||||
describe('TelemetryImportAdapter', () => {
|
||||
it('tracks the remembered format and the actual imported platform without file details', async () => {
|
||||
const events: Array<{ name: AnalyticsEventName; properties?: Record<string, unknown> }> = []
|
||||
const file = new File(['{}'], 'private-chat.json')
|
||||
const adapter = new TelemetryImportAdapter(createDelegate(), {
|
||||
trackAnalyticsEvent: async (name, properties) => {
|
||||
events.push({ name, properties })
|
||||
},
|
||||
})
|
||||
|
||||
await adapter.detectFormat(file)
|
||||
await adapter.importFile(file)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
assert.equal(events.length, 2)
|
||||
assert.deepEqual(events[0], {
|
||||
name: 'chat_import_started',
|
||||
properties: { chat_platform: 'qq' },
|
||||
})
|
||||
assert.equal(events[1].name, 'chat_import_completed')
|
||||
assert.equal(events[1].properties?.chat_platform, 'weixin')
|
||||
assert.equal(typeof events[1].properties?.duration_ms, 'number')
|
||||
assert.equal(JSON.stringify(events).includes('private-chat.json'), false)
|
||||
})
|
||||
|
||||
it('only sends a whitelisted failure category', async () => {
|
||||
const events: Array<{ name: AnalyticsEventName; properties?: Record<string, unknown> }> = []
|
||||
const adapter = new TelemetryImportAdapter(
|
||||
createDelegate({ success: false, error: 'Parser failed at /Users/alice/private.json' }),
|
||||
{
|
||||
trackAnalyticsEvent: async (name, properties) => {
|
||||
events.push({ name, properties })
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await adapter.importFile(new File(['{}'], 'private.json'), { formatId: 'whatsapp-native-txt' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
assert.deepEqual(events.at(-1), {
|
||||
name: 'chat_import_failed',
|
||||
properties: { chat_platform: 'whatsapp', failure_reason: 'parse' },
|
||||
})
|
||||
assert.equal(JSON.stringify(events).includes('/Users/alice'), false)
|
||||
})
|
||||
|
||||
it('classifies a rejected import request as a network failure', async () => {
|
||||
const events: Array<{ name: AnalyticsEventName; properties?: Record<string, unknown> }> = []
|
||||
const delegate = createDelegate()
|
||||
delegate.importFile = async () => {
|
||||
throw new TypeError('Failed to fetch')
|
||||
}
|
||||
const adapter = new TelemetryImportAdapter(delegate, {
|
||||
trackAnalyticsEvent: async (name, properties) => {
|
||||
events.push({ name, properties })
|
||||
},
|
||||
})
|
||||
|
||||
await assert.rejects(() => adapter.importFile(new File(['{}'], 'private.json')), /Failed to fetch/)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
assert.deepEqual(events.at(-1), {
|
||||
name: 'chat_import_failed',
|
||||
properties: { chat_platform: 'unknown', failure_reason: 'network' },
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks the detected platform returned by a failed created import', async () => {
|
||||
const events: Array<{ name: AnalyticsEventName; properties?: Record<string, unknown> }> = []
|
||||
const adapter = new TelemetryImportAdapter(
|
||||
createDelegate({ success: false, platform: 'line', error: 'database unavailable' }),
|
||||
{
|
||||
trackAnalyticsEvent: async (name, properties) => {
|
||||
events.push({ name, properties })
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await adapter.importFile(new File(['{}'], 'private.json'))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
assert.deepEqual(events.at(-1), {
|
||||
name: 'chat_import_failed',
|
||||
properties: { chat_platform: 'line', failure_reason: 'write' },
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks the detected platform returned by a failed directory import', async () => {
|
||||
const events: Array<{ name: AnalyticsEventName; properties?: Record<string, unknown> }> = []
|
||||
const adapter = new TelemetryImportAdapter(
|
||||
createDelegate({ success: false, platform: 'line', error: 'database unavailable' }),
|
||||
{
|
||||
trackAnalyticsEvent: async (name, properties) => {
|
||||
events.push({ name, properties })
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await adapter.importDirectory([new File(['{}'], 'chat.json')])
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
assert.deepEqual(events.at(-1), {
|
||||
name: 'chat_import_failed',
|
||||
properties: { chat_platform: 'line', failure_reason: 'write' },
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks incremental imports without exposing the session or file name', async () => {
|
||||
const events: Array<{ name: AnalyticsEventName; properties?: Record<string, unknown> }> = []
|
||||
const file = new File(['{}'], 'incremental-private.json')
|
||||
const adapter = new TelemetryImportAdapter(createDelegate(), {
|
||||
trackAnalyticsEvent: async (name, properties) => {
|
||||
events.push({ name, properties })
|
||||
},
|
||||
})
|
||||
|
||||
await adapter.analyzeIncrementalImport('private-session-id', file)
|
||||
await adapter.incrementalImport('private-session-id', file)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
assert.deepEqual(events, [{ name: 'incremental_import_used', properties: undefined }])
|
||||
assert.equal(JSON.stringify(events).includes('private-session-id'), false)
|
||||
assert.equal(JSON.stringify(events).includes('incremental-private.json'), false)
|
||||
})
|
||||
|
||||
it('tracks automatic incremental imports as a platform-independent usage event', async () => {
|
||||
const events: Array<{ name: AnalyticsEventName; properties?: Record<string, unknown> }> = []
|
||||
const adapter = new TelemetryImportAdapter(
|
||||
createDelegate({
|
||||
success: true,
|
||||
sessionId: 'existing-session',
|
||||
importMode: 'incremental',
|
||||
platform: 'private-platform-value',
|
||||
}),
|
||||
{
|
||||
trackAnalyticsEvent: async (name, properties) => {
|
||||
events.push({ name, properties })
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await adapter.importFile(new File(['{}'], 'incremental.json'))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
assert.deepEqual(events, [{ name: 'incremental_import_used', properties: undefined }])
|
||||
})
|
||||
|
||||
it('counts failed automatic incremental attempts without a platform failure event', async () => {
|
||||
const events: Array<{ name: AnalyticsEventName; properties?: Record<string, unknown> }> = []
|
||||
const adapter = new TelemetryImportAdapter(
|
||||
createDelegate({
|
||||
success: false,
|
||||
sessionId: 'existing-session',
|
||||
importMode: 'incremental',
|
||||
error: 'write failed',
|
||||
}),
|
||||
{
|
||||
trackAnalyticsEvent: async (name, properties) => {
|
||||
events.push({ name, properties })
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await adapter.importFile(new File(['{}'], 'incremental.json'))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
assert.deepEqual(events, [{ name: 'incremental_import_used', properties: undefined }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { AnalyticsEventName } from '@openchatlab/shared-types'
|
||||
import type { PlatformAdapter } from '../platform/types'
|
||||
import type {
|
||||
DemoImportResult,
|
||||
DemoProgress,
|
||||
FormatInfo,
|
||||
ImportAdapter,
|
||||
ImportOptions,
|
||||
ImportProgress,
|
||||
ImportResult,
|
||||
IncrementalAnalysis,
|
||||
IncrementalImportResult,
|
||||
MultiChatEntry,
|
||||
PreparedImportSourceResult,
|
||||
} from './types'
|
||||
|
||||
function platformFromFormatId(formatId: string | undefined): string {
|
||||
if (!formatId) return 'unknown'
|
||||
if (formatId.includes('qq')) return 'qq'
|
||||
if (formatId.includes('weflow') || formatId.includes('wechat') || formatId.includes('echotrace')) return 'wechat'
|
||||
if (formatId.includes('whatsapp')) return 'whatsapp'
|
||||
if (formatId.includes('line')) return 'line'
|
||||
if (formatId.includes('telegram')) return 'telegram'
|
||||
if (formatId.includes('discord')) return 'discord'
|
||||
if (formatId.includes('instagram')) return 'instagram'
|
||||
if (formatId.includes('google-chat')) return 'google_chat'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function classifyImportFailure(error: string | undefined): string {
|
||||
const normalized = error?.toLowerCase() ?? ''
|
||||
if (
|
||||
normalized.includes('failed to fetch') ||
|
||||
normalized.includes('fetch failed') ||
|
||||
normalized.includes('networkerror') ||
|
||||
normalized.includes('network error') ||
|
||||
normalized.includes('network request failed') ||
|
||||
normalized.includes('load failed')
|
||||
) {
|
||||
return 'network'
|
||||
}
|
||||
if (
|
||||
normalized.includes('parse') ||
|
||||
normalized.includes('format') ||
|
||||
normalized.includes('no_messages') ||
|
||||
normalized.includes('unrecognized')
|
||||
) {
|
||||
return 'parse'
|
||||
}
|
||||
if (normalized.includes('write') || normalized.includes('database') || normalized.includes('sqlite')) {
|
||||
return 'write'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export class TelemetryImportAdapter implements ImportAdapter {
|
||||
private readonly filePlatforms = new WeakMap<File, string>()
|
||||
private readonly pathPlatforms = new Map<string, string>()
|
||||
private readonly sourcePlatforms = new Map<string, string>()
|
||||
|
||||
constructor(
|
||||
private readonly delegate: ImportAdapter,
|
||||
private readonly platform: Pick<PlatformAdapter, 'trackAnalyticsEvent'>
|
||||
) {}
|
||||
|
||||
private track(eventName: AnalyticsEventName, properties?: Record<string, unknown>): void {
|
||||
void this.platform.trackAnalyticsEvent(eventName, properties).catch(() => {})
|
||||
}
|
||||
|
||||
private rememberedPlatform(source: File | string, options?: ImportOptions): string {
|
||||
const selected = platformFromFormatId(options?.formatId)
|
||||
if (selected !== 'unknown') return selected
|
||||
return typeof source === 'string'
|
||||
? (this.pathPlatforms.get(source) ?? 'unknown')
|
||||
: (this.filePlatforms.get(source) ?? 'unknown')
|
||||
}
|
||||
|
||||
private rememberPlatform(source: File | string, platform: string): void {
|
||||
if (typeof source === 'string') this.pathPlatforms.set(source, platform)
|
||||
else this.filePlatforms.set(source, platform)
|
||||
}
|
||||
|
||||
private async runTrackedImport<
|
||||
T extends { success: boolean; error?: string; platform?: string; importMode?: 'created' | 'incremental' },
|
||||
>(initialPlatform: string, operation: () => Promise<T>): Promise<T> {
|
||||
const startedAt = Date.now()
|
||||
const trackStarted = () => this.track('chat_import_started', { chat_platform: initialPlatform })
|
||||
try {
|
||||
const result = await operation()
|
||||
if (result.importMode === 'incremental') {
|
||||
this.track('incremental_import_used')
|
||||
return result
|
||||
}
|
||||
trackStarted()
|
||||
const chatPlatform = result.platform ?? initialPlatform
|
||||
if (result.success) {
|
||||
this.track('chat_import_completed', {
|
||||
chat_platform: chatPlatform,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
})
|
||||
} else {
|
||||
this.track('chat_import_failed', {
|
||||
chat_platform: chatPlatform,
|
||||
failure_reason: classifyImportFailure(result.error),
|
||||
})
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
trackStarted()
|
||||
this.track('chat_import_failed', {
|
||||
chat_platform: initialPlatform,
|
||||
failure_reason: classifyImportFailure(error instanceof Error ? error.message : undefined),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
importFile(
|
||||
file: File | string,
|
||||
options?: ImportOptions,
|
||||
onProgress?: (progress: ImportProgress) => void
|
||||
): Promise<ImportResult> {
|
||||
return this.runTrackedImport(this.rememberedPlatform(file, options), () =>
|
||||
this.delegate.importFile(file, options, onProgress)
|
||||
)
|
||||
}
|
||||
|
||||
cancelActiveImport(): void {
|
||||
this.delegate.cancelActiveImport?.()
|
||||
}
|
||||
|
||||
async detectFormat(file: File | string): Promise<FormatInfo | null> {
|
||||
const result = await this.delegate.detectFormat(file)
|
||||
if (result) {
|
||||
this.rememberPlatform(file, result.platform)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
scanMultiChatFile(file: File | string): Promise<MultiChatEntry[]> {
|
||||
return this.delegate.scanMultiChatFile(file)
|
||||
}
|
||||
|
||||
async prepareImportSource(file: File | string): Promise<PreparedImportSourceResult> {
|
||||
const result = await this.delegate.prepareImportSource(file)
|
||||
if (result.success && result.source) {
|
||||
this.sourcePlatforms.set(result.source.sourceId, result.source.platform)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
importPreparedChat(
|
||||
sourceId: string,
|
||||
chatId: string,
|
||||
onProgress?: (progress: ImportProgress) => void
|
||||
): Promise<ImportResult> {
|
||||
return this.runTrackedImport(this.sourcePlatforms.get(sourceId) ?? 'unknown', () =>
|
||||
this.delegate.importPreparedChat(sourceId, chatId, onProgress)
|
||||
)
|
||||
}
|
||||
|
||||
async releaseImportSource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
await this.delegate.releaseImportSource(sourceId)
|
||||
} finally {
|
||||
this.sourcePlatforms.delete(sourceId)
|
||||
}
|
||||
}
|
||||
|
||||
getSupportedFormats(): Promise<FormatInfo[]> {
|
||||
return this.delegate.getSupportedFormats()
|
||||
}
|
||||
|
||||
importDemo(locale: string, onProgress?: (progress: DemoProgress) => void): Promise<DemoImportResult> {
|
||||
return this.delegate.importDemo(locale, onProgress)
|
||||
}
|
||||
|
||||
async analyzeIncrementalImport(sessionId: string, file: File | string): Promise<IncrementalAnalysis> {
|
||||
const result = await this.delegate.analyzeIncrementalImport(sessionId, file)
|
||||
if (result.platform) this.rememberPlatform(file, result.platform)
|
||||
return result
|
||||
}
|
||||
|
||||
incrementalImport(
|
||||
sessionId: string,
|
||||
file: File | string,
|
||||
onProgress?: (progress: ImportProgress) => void
|
||||
): Promise<IncrementalImportResult> {
|
||||
this.track('incremental_import_used')
|
||||
return this.delegate.incrementalImport(sessionId, file, onProgress)
|
||||
}
|
||||
|
||||
importDirectory(
|
||||
source: File[] | string,
|
||||
options?: ImportOptions,
|
||||
onProgress?: (progress: ImportProgress) => void
|
||||
): Promise<ImportResult> {
|
||||
const platform = platformFromFormatId(options?.formatId)
|
||||
return this.runTrackedImport(platform, () => this.delegate.importDirectory(source, options, onProgress))
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export type AutoImportCreateReason = 'no-match' | 'ambiguous'
|
||||
export interface ImportResult {
|
||||
success: boolean
|
||||
sessionId?: string
|
||||
platform?: string
|
||||
error?: string
|
||||
importMode?: AutoImportMode
|
||||
matchedBy?: AutoImportMatchMethod
|
||||
@@ -38,6 +39,7 @@ export function normalizeImportResult(result: ImportResult): ImportResult {
|
||||
return {
|
||||
success: result.success,
|
||||
sessionId: result.sessionId,
|
||||
platform: result.platform,
|
||||
error: result.error,
|
||||
importMode: result.importMode,
|
||||
matchedBy: result.matchedBy,
|
||||
@@ -124,6 +126,7 @@ export interface IncrementalAnalysis {
|
||||
newMessageCount: number
|
||||
duplicateCount: number
|
||||
totalInFile: number
|
||||
platform?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
PlatformAdapter,
|
||||
RemoteConfigResult,
|
||||
} from './types'
|
||||
import type { AnalyticsEventName } from '@openchatlab/shared-types'
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
@@ -52,6 +53,11 @@ export class BrowserPlatformAdapter implements PlatformAdapter {
|
||||
void locale
|
||||
}
|
||||
|
||||
async trackAnalyticsEvent(eventName: AnalyticsEventName, properties?: Record<string, unknown>): Promise<void> {
|
||||
void eventName
|
||||
void properties
|
||||
}
|
||||
|
||||
async showOpenDialog(_options: OpenDialogOptions): Promise<OpenDialogResult> {
|
||||
return { canceled: true, filePaths: [] }
|
||||
}
|
||||
|
||||
@@ -81,6 +81,28 @@ describe('CliWebPlatformAdapter', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards typed analytics events to the CLI web backend', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push({ url: String(input), init })
|
||||
return Promise.resolve(new Response(JSON.stringify({ ok: true })))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
await new CliWebPlatformAdapter().trackAnalyticsEvent('feature_used', { feature_id: 'insights' })
|
||||
|
||||
assert.equal(requests[0].url, '/_web/telemetry/track')
|
||||
assert.equal(requests[0].init?.method, 'POST')
|
||||
assert.equal(
|
||||
requests[0].init?.body,
|
||||
JSON.stringify({ eventName: 'feature_used', properties: { feature_id: 'insights' } })
|
||||
)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
it('writes a PNG data URL to the browser image clipboard', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let clipboardItems: unknown[] = []
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
CheckUpdateResult,
|
||||
PerformUpdateResult,
|
||||
} from './types'
|
||||
import type { AnalyticsEventName } from '@openchatlab/shared-types'
|
||||
import { fetchWithAuth } from '../utils/http'
|
||||
import { reportError } from '../log-report'
|
||||
|
||||
@@ -74,7 +75,15 @@ export class CliWebPlatformAdapter implements PlatformAdapter {
|
||||
await fetchWithAuth('/_web/telemetry/track', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ eventName: 'app_active', properties: { platform: 'cli-web', locale } }),
|
||||
body: JSON.stringify({ eventName: 'app_active', properties: { app_locale: locale } }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async trackAnalyticsEvent(eventName: AnalyticsEventName, properties?: Record<string, unknown>): Promise<void> {
|
||||
await fetchWithAuth('/_web/telemetry/track', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ eventName, properties }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* ElectronPlatformAdapter — wrap window.api
|
||||
*/
|
||||
|
||||
import type { AnalyticsEventName } from '@openchatlab/shared-types'
|
||||
import type { PlatformAdapter, OpenDialogOptions, OpenDialogResult, RemoteConfigResult } from './types'
|
||||
|
||||
export class ElectronPlatformAdapter implements PlatformAdapter {
|
||||
@@ -37,6 +38,10 @@ export class ElectronPlatformAdapter implements PlatformAdapter {
|
||||
return window.api.app.trackDailyActive(locale)
|
||||
}
|
||||
|
||||
trackAnalyticsEvent(eventName: AnalyticsEventName, properties?: Record<string, unknown>): Promise<void> {
|
||||
return window.api.app.trackAnalyticsEvent(eventName, properties)
|
||||
}
|
||||
|
||||
showOpenDialog(options: OpenDialogOptions): Promise<OpenDialogResult> {
|
||||
return window.api.dialog.showOpenDialog(options as Electron.OpenDialogOptions)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AnalyticsEventName } from '@openchatlab/shared-types'
|
||||
|
||||
/**
|
||||
* PlatformAdapter — 平台能力领域适配器接口
|
||||
*
|
||||
@@ -48,6 +50,7 @@ export interface PlatformAdapter {
|
||||
getAnalyticsEnabled(): Promise<boolean>
|
||||
setAnalyticsEnabled(enabled: boolean): Promise<{ success: boolean }>
|
||||
trackDailyActive(locale: string): Promise<void>
|
||||
trackAnalyticsEvent(eventName: AnalyticsEventName, properties?: Record<string, unknown>): Promise<void>
|
||||
|
||||
showOpenDialog(options: OpenDialogOptions): Promise<OpenDialogResult>
|
||||
copyImageToClipboard(dataUrl: string): Promise<{ success: boolean; error?: string }>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { setImmediate as waitForImmediate } from 'node:timers/promises'
|
||||
import test from 'node:test'
|
||||
import type { AnalyticsEventName } from '@openchatlab/shared-types'
|
||||
|
||||
test('includes the current locale in an event before daily-active initialization', async (t) => {
|
||||
const events: Array<{ name: AnalyticsEventName; properties?: Record<string, unknown> }> = []
|
||||
|
||||
await t.mock.module('@/i18n', {
|
||||
namedExports: {
|
||||
getLocale: () => 'zh-TW',
|
||||
},
|
||||
})
|
||||
await t.mock.module('./platform/service', {
|
||||
namedExports: {
|
||||
usePlatformService: () => ({
|
||||
trackAnalyticsEvent: async (name: AnalyticsEventName, properties?: Record<string, unknown>) => {
|
||||
events.push({ name, properties })
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { trackProductEvent } = await import('./product-analytics')
|
||||
trackProductEvent('feature_used', { feature_id: 'insights' })
|
||||
await waitForImmediate()
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
name: 'feature_used',
|
||||
properties: { feature_id: 'insights', app_locale: 'zh-TW' },
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { AnalyticsEventName } from '@openchatlab/shared-types'
|
||||
import { getLocale } from '@/i18n'
|
||||
import { usePlatformService } from './platform/service'
|
||||
|
||||
export function trackProductEvent(eventName: AnalyticsEventName, properties?: Record<string, unknown>): void {
|
||||
try {
|
||||
void usePlatformService()
|
||||
.trackAnalyticsEvent(eventName, { ...properties, app_locale: getLocale() })
|
||||
.catch(() => {})
|
||||
} catch {
|
||||
// Analytics is best-effort and may run before the platform adapter is registered in tests or early startup.
|
||||
}
|
||||
}
|
||||
+18
-10
@@ -84,8 +84,15 @@ async function initElectronAdapters(): Promise<void> {
|
||||
const { FetchDataAdapter } = await import('./data/fetch')
|
||||
registerAdapter('data', new FetchDataAdapter())
|
||||
|
||||
const { ElectronImportAdapter } = await import('./import/electron')
|
||||
registerAdapter('import', new ElectronImportAdapter())
|
||||
const { ElectronPlatformAdapter } = await import('./platform/electron')
|
||||
const platformAdapter = new ElectronPlatformAdapter()
|
||||
registerAdapter('platform', platformAdapter)
|
||||
|
||||
const [{ ElectronImportAdapter }, { TelemetryImportAdapter }] = await Promise.all([
|
||||
import('./import/electron'),
|
||||
import('./import/telemetry'),
|
||||
])
|
||||
registerAdapter('import', new TelemetryImportAdapter(new ElectronImportAdapter(), platformAdapter))
|
||||
|
||||
const { FetchSessionIndexAdapter } = await import('./session-index/fetch')
|
||||
registerAdapter('session-index', new FetchSessionIndexAdapter())
|
||||
@@ -93,9 +100,6 @@ async function initElectronAdapters(): Promise<void> {
|
||||
const { FetchMessageAdapter } = await import('./message/fetch')
|
||||
registerAdapter('message', new FetchMessageAdapter())
|
||||
|
||||
const { ElectronPlatformAdapter } = await import('./platform/electron')
|
||||
registerAdapter('platform', new ElectronPlatformAdapter())
|
||||
|
||||
const { ElectronAIAdapter } = await import('./ai/electron')
|
||||
registerAdapter('ai', new ElectronAIAdapter())
|
||||
|
||||
@@ -121,8 +125,15 @@ async function initCliWebAdapters(): Promise<void> {
|
||||
const { FetchDataAdapter } = await import('./data/fetch')
|
||||
registerAdapter('data', new FetchDataAdapter())
|
||||
|
||||
const { FetchImportAdapter } = await import('./import/fetch')
|
||||
registerAdapter('import', new FetchImportAdapter())
|
||||
const { CliWebPlatformAdapter } = await import('./platform/cli-web')
|
||||
const platformAdapter = new CliWebPlatformAdapter()
|
||||
registerAdapter('platform', platformAdapter)
|
||||
|
||||
const [{ FetchImportAdapter }, { TelemetryImportAdapter }] = await Promise.all([
|
||||
import('./import/fetch'),
|
||||
import('./import/telemetry'),
|
||||
])
|
||||
registerAdapter('import', new TelemetryImportAdapter(new FetchImportAdapter(), platformAdapter))
|
||||
|
||||
const { FetchSessionIndexAdapter } = await import('./session-index/fetch')
|
||||
registerAdapter('session-index', new FetchSessionIndexAdapter())
|
||||
@@ -130,9 +141,6 @@ async function initCliWebAdapters(): Promise<void> {
|
||||
const { FetchMessageAdapter } = await import('./message/fetch')
|
||||
registerAdapter('message', new FetchMessageAdapter())
|
||||
|
||||
const { CliWebPlatformAdapter } = await import('./platform/cli-web')
|
||||
registerAdapter('platform', new CliWebPlatformAdapter())
|
||||
|
||||
const { FetchAIAdapter } = await import('./ai/fetch')
|
||||
registerAdapter('ai', new FetchAIAdapter())
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
|
||||
import type { ProviderDefinition, ModelDefinition } from '@electron/preload/index'
|
||||
import { useLLMService } from '@/services'
|
||||
import type { AIServiceConfigDisplay, LLMProvider } from '@/services'
|
||||
import { trackProductEvent } from '@/services/product-analytics'
|
||||
|
||||
export type { AIServiceConfigDisplay, LLMProvider }
|
||||
|
||||
@@ -27,6 +28,7 @@ export const useLLMStore = defineStore('llm', () => {
|
||||
)
|
||||
const fastModelConfig = computed(() => configs.value.find((c) => c.id === fastModel.value?.configId) || null)
|
||||
const hasConfig = computed(() => !!defaultAssistant.value)
|
||||
const hasConfiguredModel = computed(() => !!defaultAssistantConfig.value && !!defaultAssistant.value?.modelId.trim())
|
||||
const isMaxConfigs = computed(() => configs.value.length >= 99)
|
||||
|
||||
function getProviderById(id: string): ProviderDefinition | undefined {
|
||||
@@ -54,6 +56,8 @@ export const useLLMStore = defineStore('llm', () => {
|
||||
}
|
||||
|
||||
async function loadConfigs() {
|
||||
const wasInitialized = isInitialized.value
|
||||
const wasConfigured = hasConfiguredModel.value
|
||||
isLoading.value = true
|
||||
try {
|
||||
const svc = useLLMService()
|
||||
@@ -71,6 +75,9 @@ export const useLLMStore = defineStore('llm', () => {
|
||||
configs.value = configStore.configs
|
||||
defaultAssistant.value = assistantSlot
|
||||
fastModel.value = fastSlot
|
||||
if (wasInitialized && !wasConfigured && hasConfiguredModel.value) {
|
||||
trackProductEvent('ai_setup_completed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[LLM Store] 加载配置失败:', error)
|
||||
} finally {
|
||||
@@ -79,10 +86,14 @@ export const useLLMStore = defineStore('llm', () => {
|
||||
}
|
||||
|
||||
async function setDefaultAssistantModel(configId: string, modelId: string): Promise<boolean> {
|
||||
const wasConfigured = hasConfiguredModel.value
|
||||
try {
|
||||
const result = await useLLMService().setDefaultAssistantModel(configId, modelId)
|
||||
if (result.success) {
|
||||
defaultAssistant.value = { configId, modelId }
|
||||
if (!wasConfigured && hasConfiguredModel.value) {
|
||||
trackProductEvent('ai_setup_completed')
|
||||
}
|
||||
return true
|
||||
}
|
||||
console.error('[LLM Store] 设置默认助手模型失败:', result.error)
|
||||
@@ -132,6 +143,7 @@ export const useLLMStore = defineStore('llm', () => {
|
||||
defaultAssistantConfig,
|
||||
fastModelConfig,
|
||||
hasConfig,
|
||||
hasConfiguredModel,
|
||||
isMaxConfigs,
|
||||
// 方法
|
||||
init,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { setImmediate as waitForImmediate } from 'node:timers/promises'
|
||||
import test from 'node:test'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { registerAdapter } from '@/services/registry'
|
||||
import type { AIAdapter, PlatformAdapter } from '@/services'
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() {
|
||||
return values.size
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => void values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
test('locale-dependent desensitize rules do not wait for telemetry delivery', async () => {
|
||||
Object.defineProperties(globalThis, {
|
||||
localStorage: { configurable: true, value: createMemoryStorage() },
|
||||
sessionStorage: { configurable: true, value: createMemoryStorage() },
|
||||
window: { configurable: true, value: {} },
|
||||
})
|
||||
|
||||
let releaseTelemetry: (() => void) | undefined
|
||||
const telemetryPending = new Promise<void>((resolve) => {
|
||||
releaseTelemetry = resolve
|
||||
})
|
||||
const mergedLocales: string[] = []
|
||||
|
||||
registerAdapter('platform', {
|
||||
trackDailyActive: () => telemetryPending,
|
||||
} as unknown as PlatformAdapter)
|
||||
registerAdapter('ai', {
|
||||
mergeDesensitizeRules: async (_rules, locale) => {
|
||||
mergedLocales.push(locale)
|
||||
return []
|
||||
},
|
||||
} as unknown as AIAdapter)
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const { useSettingsStore } = await import('./settings')
|
||||
const store = useSettingsStore()
|
||||
|
||||
try {
|
||||
const localeChange = store.setLocale('en-US')
|
||||
await waitForImmediate()
|
||||
|
||||
assert.deepEqual(mergedLocales, ['en-US'])
|
||||
assert.equal(
|
||||
await Promise.race([
|
||||
localeChange.then(() => true),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 20)),
|
||||
]),
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
releaseTelemetry?.()
|
||||
}
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import 'dayjs/locale/ja'
|
||||
import { type LocaleType, setLocale as setI18nLocale, getLocale, getDayjsLocale } from '@/i18n'
|
||||
import type { PreprocessConfig } from '@electron/preload/index'
|
||||
import type { AIPreprocessConfig } from '@openchatlab/shared-types'
|
||||
import { useAIService } from '@/services'
|
||||
import { useAIService, usePlatformService } from '@/services'
|
||||
import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities'
|
||||
|
||||
const DESENSITIZE_RULES_SCHEMA_VERSION = 2
|
||||
@@ -80,6 +80,14 @@ export const useSettingsStore = defineStore(
|
||||
window.electron?.ipcRenderer.send('locale:change', newLocale)
|
||||
|
||||
if (PLATFORM_CAPABILITIES.initializesLlm) await ensureDesensitizeRules()
|
||||
|
||||
try {
|
||||
void usePlatformService()
|
||||
.trackDailyActive(newLocale)
|
||||
.catch(() => {})
|
||||
} catch {
|
||||
// Analytics is best-effort and can be unavailable during early startup or in tests.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user