diff --git a/apps/web-wasm/src/components/session/insights/SessionInsights.vue b/apps/web-wasm/src/components/session/insights/SessionInsights.vue index 99099c57..2ed986cf 100644 --- a/apps/web-wasm/src/components/session/insights/SessionInsights.vue +++ b/apps/web-wasm/src/components/session/insights/SessionInsights.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n' import { SectionTabs } from '@/components/navigation' import UserSelect from '@/components/common/UserSelect.vue' import PrivateRelationshipView from '@/components/analysis/relationships/PrivateRelationshipView.vue' +import JourneyView from '@/components/analysis/journey/JourneyView.vue' import TypeAnalysisView from '@/components/analysis/message/TypeAnalysisView.vue' import TimeAnalysisView from '@/components/analysis/message/TimeAnalysisView.vue' import { LanguagePreferenceTab, WordcloudTab } from '@/components/analysis/quotes' @@ -37,6 +38,7 @@ const subTabs = computed(() => ? [ { id: 'overview', label: t('analysis.tabs.overview'), icon: 'i-heroicons-squares-2x2' }, { id: 'relationship', label: t('analysis.subTabs.insights.relationship'), icon: 'i-heroicons-heart' }, + { id: 'journey', label: t('analysis.subTabs.insights.journey'), icon: 'i-heroicons-map' }, { id: 'type-analysis', label: t('analysis.subTabs.insights.typeAnalysis'), icon: 'i-heroicons-chart-pie' }, { id: 'time-analysis', label: t('analysis.subTabs.insights.timeAnalysis'), icon: 'i-heroicons-clock' }, { id: 'topic', label: t('analysis.subTabs.insights.topic'), icon: 'i-heroicons-cloud' }, @@ -108,6 +110,12 @@ const viewTimeFilter = computed(() => ({ ...props.timeFilter, memberId: selected :session-id="props.sessionId" :time-filter="props.timeFilter" /> + | undefined + } + + all(...params: unknown[]) { + return this.statement.all(...params) as Record[] + } + + run(...params: unknown[]): RunResult { + const result = this.statement.run(...params) + return { changes: result.changes, lastInsertRowid: result.lastInsertRowid } + } +} + +class Adapter implements DatabaseAdapter { + constructor(private readonly database: Database.Database) {} + + exec(sql: string) { + this.database.exec(sql) + } + + prepare(sql: string) { + return new Statement(this.database.prepare(sql)) + } + + transaction(fn: () => T): T { + return this.database.transaction(fn)() + } + + pragma(pragma: string) { + return this.database.pragma(pragma) + } + + close() { + this.database.close() + } +} + +function timestamp(year: number, month: number, day: number, hour = 12, minute = 0): number { + // Midday UTC keeps the fixture inside the same natural date in all practical time zones. + return Math.floor(Date.UTC(year, month - 1, day, hour, minute) / 1000) +} + +describe('private chat journey analysis', () => { + let raw: Database.Database + let db: Adapter + + beforeEach(() => { + raw = new Database(':memory:') + raw.exec(` + CREATE TABLE member ( + id INTEGER PRIMARY KEY, + platform_id TEXT NOT NULL, + account_name TEXT, + group_nickname TEXT + ); + CREATE TABLE message ( + id INTEGER PRIMARY KEY, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + CREATE TABLE segment ( + id INTEGER PRIMARY KEY, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0 + ); + CREATE TABLE message_context ( + message_id INTEGER PRIMARY KEY, + segment_id INTEGER NOT NULL, + topic_id INTEGER + ); + INSERT INTO member (id, platform_id, account_name) VALUES + (1, 'alice', 'Alice'), + (2, 'bob', 'Bob'), + (99, 'system', '系统消息'); + `) + db = new Adapter(raw) + }) + + afterEach(() => raw.close()) + + it('returns an empty result when no effective messages exist', () => { + assert.deepEqual(getJourneyStats(db), { + range: null, + hasSessionIndex: false, + months: [], + years: [], + peakMonth: null, + longestSegment: null, + longestSilence: null, + }) + }) + + it('keeps message-level history available without a segment index', () => { + insertMessage(raw, 1, 1, timestamp(2024, 1, 15), 'hello') + insertMessage(raw, 2, 2, timestamp(2024, 3, 15), 'world') + + const result = getJourneyStats(db) + + assert.equal(result.hasSessionIndex, false) + assert.equal(result.range?.activeDays, 2) + assert.equal(result.range?.activeMonths, 2) + assert.equal(result.range?.spanMonths, 3) + assert.deepEqual( + result.months.map(({ month, messageCount, activeDays, segmentCount }) => ({ + month, + messageCount, + activeDays, + segmentCount, + })), + [ + { month: '2024-01', messageCount: 1, activeDays: 1, segmentCount: null }, + { month: '2024-02', messageCount: 0, activeDays: 0, segmentCount: null }, + { month: '2024-03', messageCount: 1, activeDays: 1, segmentCount: null }, + ] + ) + assert.equal(result.longestSegment, null) + assert.equal(result.longestSilence, null) + }) + + it('aggregates years, excludes system messages, and resolves peak ties toward the earlier month', () => { + insertMessage(raw, 1, 1, timestamp(2023, 1, 15, 10), 'one') + insertMessage(raw, 2, 2, timestamp(2023, 1, 15, 11), 'two') + insertMessage(raw, 3, 2, timestamp(2023, 3, 20, 10), 'three') + insertMessage(raw, 4, 1, timestamp(2023, 3, 20, 11), 'four') + insertMessage(raw, 5, 2, timestamp(2023, 3, 20, 12), 'five') + insertMessage(raw, 6, 1, timestamp(2024, 2, 10, 10), 'six') + insertMessage(raw, 7, 2, timestamp(2024, 2, 10, 11), 'seven') + insertMessage(raw, 8, 1, timestamp(2024, 2, 11, 10), 'eight') + insertMessage(raw, 99, 99, timestamp(2025, 6, 1), 'ignored system message') + + const result = getJourneyStats(db) + + assert.equal(result.range?.firstMessageTs, timestamp(2023, 1, 15, 10)) + assert.equal(result.range?.lastMessageTs, timestamp(2024, 2, 11, 10)) + assert.equal(result.range?.activeDays, 4) + assert.equal(result.range?.activeMonths, 3) + assert.equal(result.range?.spanMonths, 14) + assert.equal(result.months.length, 14) + assert.deepEqual(result.peakMonth, { + month: '2023-03', + messageCount: 3, + activeDays: 1, + segmentCount: null, + }) + assert.deepEqual(result.years, [ + { + year: 2023, + messageCount: 5, + activeDays: 2, + activeMonths: 2, + peakMonth: '2023-03', + peakMonthMessageCount: 3, + }, + { + year: 2024, + messageCount: 3, + activeDays: 2, + activeMonths: 1, + peakMonth: '2024-02', + peakMonthMessageCount: 3, + }, + ]) + }) + + it('finds the longest effective segment and silence with a stable reopening member', () => { + const janStart = timestamp(2023, 1, 15, 10) + const marchStart = timestamp(2023, 3, 20, 10) + const febStart = timestamp(2024, 2, 10, 10) + + insertSegment(raw, 1, janStart, janStart + 600, 2) + insertSegment(raw, 2, marchStart, marchStart + 7200, 3) + insertSegment(raw, 3, febStart, febStart + 1800, 3) + + insertMessage(raw, 1, 1, janStart, 'one', 1) + insertMessage(raw, 2, 2, janStart + 600, 'two', 1) + insertMessage(raw, 3, 2, marchStart, 'three', 2) + insertMessage(raw, 4, 1, marchStart + 3600, 'four', 2) + insertMessage(raw, 5, 2, marchStart + 7200, 'five', 2) + insertMessage(raw, 6, 1, febStart, 'six', 3) + insertMessage(raw, 7, 2, febStart + 1800, 'seven', 3) + insertMessage(raw, 99, 99, febStart + 7200, 'ignored system message', 3) + + const result = getJourneyStats(db) + + assert.equal(result.hasSessionIndex, true) + assert.deepEqual(result.longestSegment, { + segmentId: 2, + startTs: marchStart, + endTs: marchStart + 7200, + durationSeconds: 7200, + messageCount: 3, + initiator: { memberId: 2, name: 'Bob' }, + }) + assert.deepEqual(result.longestSilence, { + previousSegmentId: 2, + nextSegmentId: 3, + startTs: marchStart + 7200, + endTs: febStart, + durationSeconds: febStart - (marchStart + 7200), + reopenedBy: { memberId: 1, name: 'Alice' }, + }) + assert.equal(result.months.find((month) => month.month === '2023-01')?.segmentCount, 1) + assert.equal(result.months.find((month) => month.month === '2023-02')?.segmentCount, 0) + }) + + it('clips segment metrics to the selected time range', () => { + const firstStart = timestamp(2024, 4, 10, 10) + const secondStart = timestamp(2024, 4, 12, 10) + insertSegment(raw, 1, firstStart, firstStart + 7200, 3) + insertSegment(raw, 2, secondStart, secondStart + 3600, 2) + + insertMessage(raw, 1, 1, firstStart, 'outside start', 1) + insertMessage(raw, 2, 2, firstStart + 3600, 'inside one', 1) + insertMessage(raw, 3, 1, firstStart + 7200, 'inside two', 1) + insertMessage(raw, 4, 2, secondStart, 'inside three', 2) + insertMessage(raw, 5, 1, secondStart + 3600, 'outside end', 2) + + const result = getJourneyStats(db, { + startTs: firstStart + 1800, + endTs: secondStart + 1800, + }) + + assert.equal(result.range?.firstMessageTs, firstStart + 3600) + assert.equal(result.range?.lastMessageTs, secondStart) + assert.deepEqual(result.longestSegment, { + segmentId: 1, + startTs: firstStart + 3600, + endTs: firstStart + 7200, + durationSeconds: 3600, + messageCount: 2, + initiator: { memberId: 2, name: 'Bob' }, + }) + assert.deepEqual(result.longestSilence, { + previousSegmentId: 1, + nextSegmentId: 2, + startTs: firstStart + 7200, + endTs: secondStart, + durationSeconds: secondStart - (firstStart + 7200), + reopenedBy: { memberId: 2, name: 'Bob' }, + }) + }) +}) + +function insertSegment( + database: Database.Database, + id: number, + startTs: number, + endTs: number, + messageCount: number +): void { + database + .prepare('INSERT INTO segment (id, start_ts, end_ts, message_count) VALUES (?, ?, ?, ?)') + .run(id, startTs, endTs, messageCount) +} + +function insertMessage( + database: Database.Database, + id: number, + senderId: number, + ts: number, + content: string, + segmentId?: number +): void { + database + .prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, 0, ?)') + .run(id, senderId, ts, content) + if (segmentId !== undefined) { + database + .prepare('INSERT INTO message_context (message_id, segment_id, topic_id) VALUES (?, ?, NULL)') + .run(id, segmentId) + } +} diff --git a/packages/core/src/query/advanced/journey.ts b/packages/core/src/query/advanced/journey.ts new file mode 100644 index 00000000..7b407cd8 --- /dev/null +++ b/packages/core/src/query/advanced/journey.ts @@ -0,0 +1,329 @@ +/** + * Private-chat journey analysis. + * + * All results are deterministic local aggregates. Message content never + * leaves the database and is not returned to callers. + */ + +import type { TimeFilter } from '@openchatlab/shared-types' +import type { DatabaseAdapter } from '../../interfaces' +import { buildSystemMessageFilter, buildTimeFilter, hasTable } from '../filters' +import { hasSessionIndex } from '../session-queries' + +const SILENCE_THRESHOLD_SECONDS = 24 * 60 * 60 + +export interface JourneyMonth { + month: string + messageCount: number + activeDays: number + segmentCount: number | null +} + +export interface JourneyYear { + year: number + messageCount: number + activeDays: number + activeMonths: number + peakMonth: string + peakMonthMessageCount: number +} + +export interface JourneyMember { + memberId: number + name: string +} + +export interface JourneySegment { + segmentId: number + startTs: number + endTs: number + durationSeconds: number + messageCount: number + initiator: JourneyMember | null +} + +export interface JourneySilence { + previousSegmentId: number + nextSegmentId: number + startTs: number + endTs: number + durationSeconds: number + reopenedBy: JourneyMember | null +} + +export interface JourneyRange { + firstMessageTs: number + lastMessageTs: number + spanDays: number + spanMonths: number + activeDays: number + activeMonths: number +} + +export interface JourneyStats { + range: JourneyRange | null + hasSessionIndex: boolean + months: JourneyMonth[] + years: JourneyYear[] + peakMonth: JourneyMonth | null + longestSegment: JourneySegment | null + longestSilence: JourneySilence | null +} + +interface MonthlyRow { + month: string + messageCount: number + activeDays: number + firstMessageTs: number + lastMessageTs: number +} + +interface SegmentRow { + segmentId: number + startTs: number + endTs: number + messageCount: number + startMonth: string + initiatorId: number | null + initiatorName: string | null +} + +const EMPTY_RESULT: JourneyStats = { + range: null, + hasSessionIndex: false, + months: [], + years: [], + peakMonth: null, + longestSegment: null, + longestSilence: null, +} + +export function getJourneyStats(db: DatabaseAdapter, filter?: TimeFilter): JourneyStats { + const effectiveFilter = filter + ? { + startTs: filter.startTs, + endTs: filter.endTs, + } + : undefined + const monthlyRows = queryMonthlyRows(db, effectiveFilter) + if (monthlyRows.length === 0) return { ...EMPTY_RESULT } + + const indexAvailable = hasSessionIndex(db) && hasTable(db, 'message_context') + const segmentRows = indexAvailable ? querySegmentRows(db, effectiveFilter) : [] + const segmentCountByMonth = new Map() + for (const segment of segmentRows) { + segmentCountByMonth.set(segment.startMonth, (segmentCountByMonth.get(segment.startMonth) ?? 0) + 1) + } + + const firstRow = monthlyRows[0] + const lastRow = monthlyRows[monthlyRows.length - 1] + const monthsByKey = new Map(monthlyRows.map((row) => [row.month, row])) + const months = enumerateMonths(firstRow.month, lastRow.month).map((month) => { + const row = monthsByKey.get(month) + return { + month, + messageCount: row?.messageCount ?? 0, + activeDays: row?.activeDays ?? 0, + segmentCount: indexAvailable ? (segmentCountByMonth.get(month) ?? 0) : null, + } + }) + + const peakMonth = selectPeakMonth(months) + const years = buildYears(months) + const firstMessageTs = firstRow.firstMessageTs + const lastMessageTs = lastRow.lastMessageTs + + return { + range: { + firstMessageTs, + lastMessageTs, + spanDays: Math.max(1, Math.floor((lastMessageTs - firstMessageTs) / 86400) + 1), + spanMonths: months.length, + activeDays: monthlyRows.reduce((sum, row) => sum + row.activeDays, 0), + activeMonths: monthlyRows.length, + }, + hasSessionIndex: indexAvailable, + months, + years, + peakMonth, + longestSegment: selectLongestSegment(segmentRows), + longestSilence: selectLongestSilence(segmentRows), + } +} + +function queryMonthlyRows(db: DatabaseAdapter, filter?: TimeFilter): MonthlyRow[] { + const { clause, params } = buildTimeFilter(filter, 'msg') + const whereClause = buildSystemMessageFilter(clause) + + return db + .prepare( + `SELECT + strftime('%Y-%m', msg.ts, 'unixepoch', 'localtime') AS month, + COUNT(*) AS messageCount, + COUNT(DISTINCT strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime')) AS activeDays, + MIN(msg.ts) AS firstMessageTs, + MAX(msg.ts) AS lastMessageTs + FROM message msg + JOIN member m ON m.id = msg.sender_id + ${whereClause} + GROUP BY month + ORDER BY month ASC` + ) + .all(...params) as unknown as MonthlyRow[] +} + +function querySegmentRows(db: DatabaseAdapter, filter?: TimeFilter): SegmentRow[] { + const { clause, params } = buildTimeFilter(filter, 'msg') + const whereClause = buildSystemMessageFilter(clause) + + return db + .prepare( + `WITH filtered_messages AS ( + SELECT + mc.segment_id AS segmentId, + msg.id AS messageId, + msg.sender_id AS senderId, + msg.ts AS ts, + COALESCE(m.group_nickname, m.account_name, m.platform_id) AS senderName, + ROW_NUMBER() OVER ( + PARTITION BY mc.segment_id + ORDER BY msg.ts ASC, msg.id ASC + ) AS rowNumber + FROM message_context mc + JOIN segment s ON s.id = mc.segment_id + JOIN message msg ON msg.id = mc.message_id + JOIN member m ON m.id = msg.sender_id + ${whereClause} + ), + segment_summary AS ( + SELECT + segmentId, + MIN(ts) AS startTs, + MAX(ts) AS endTs, + COUNT(*) AS messageCount + FROM filtered_messages + GROUP BY segmentId + ) + SELECT + summary.segmentId, + summary.startTs, + summary.endTs, + summary.messageCount, + strftime('%Y-%m', summary.startTs, 'unixepoch', 'localtime') AS startMonth, + firstMessage.senderId AS initiatorId, + firstMessage.senderName AS initiatorName + FROM segment_summary summary + LEFT JOIN filtered_messages firstMessage + ON firstMessage.segmentId = summary.segmentId AND firstMessage.rowNumber = 1 + ORDER BY summary.startTs ASC, summary.segmentId ASC` + ) + .all(...params) as unknown as SegmentRow[] +} + +function enumerateMonths(firstMonth: string, lastMonth: string): string[] { + const [firstYear, firstMonthNumber] = firstMonth.split('-').map(Number) + const [lastYear, lastMonthNumber] = lastMonth.split('-').map(Number) + const firstIndex = firstYear * 12 + firstMonthNumber - 1 + const lastIndex = lastYear * 12 + lastMonthNumber - 1 + const months: string[] = [] + + for (let index = firstIndex; index <= lastIndex; index++) { + const year = Math.floor(index / 12) + const month = (index % 12) + 1 + months.push(`${year}-${String(month).padStart(2, '0')}`) + } + return months +} + +function selectPeakMonth(months: JourneyMonth[]): JourneyMonth | null { + let peak: JourneyMonth | null = null + for (const month of months) { + if (!peak || month.messageCount > peak.messageCount) peak = month + } + return peak +} + +function buildYears(months: JourneyMonth[]): JourneyYear[] { + const grouped = new Map() + for (const month of months) { + const year = Number.parseInt(month.month.slice(0, 4), 10) + const values = grouped.get(year) + if (values) values.push(month) + else grouped.set(year, [month]) + } + + return Array.from(grouped.entries()).map(([year, values]) => { + const activeValues = values.filter((value) => value.messageCount > 0) + const peak = selectPeakMonth(values)! + return { + year, + messageCount: values.reduce((sum, value) => sum + value.messageCount, 0), + activeDays: values.reduce((sum, value) => sum + value.activeDays, 0), + activeMonths: activeValues.length, + peakMonth: peak.month, + peakMonthMessageCount: peak.messageCount, + } + }) +} + +function toJourneySegment(row: SegmentRow): JourneySegment { + return { + segmentId: row.segmentId, + startTs: row.startTs, + endTs: row.endTs, + durationSeconds: Math.max(0, row.endTs - row.startTs), + messageCount: row.messageCount, + initiator: + row.initiatorId === null || row.initiatorName === null + ? null + : { memberId: row.initiatorId, name: row.initiatorName }, + } +} + +function selectLongestSegment(rows: SegmentRow[]): JourneySegment | null { + let longest: JourneySegment | null = null + for (const row of rows) { + const candidate = toJourneySegment(row) + if ( + !longest || + candidate.durationSeconds > longest.durationSeconds || + (candidate.durationSeconds === longest.durationSeconds && candidate.messageCount > longest.messageCount) || + (candidate.durationSeconds === longest.durationSeconds && + candidate.messageCount === longest.messageCount && + candidate.startTs < longest.startTs) + ) { + longest = candidate + } + } + return longest +} + +function selectLongestSilence(rows: SegmentRow[]): JourneySilence | null { + let longest: JourneySilence | null = null + for (let index = 1; index < rows.length; index++) { + const previous = rows[index - 1] + const next = rows[index] + const durationSeconds = next.startTs - previous.endTs + if (durationSeconds < SILENCE_THRESHOLD_SECONDS) continue + + const candidate: JourneySilence = { + previousSegmentId: previous.segmentId, + nextSegmentId: next.segmentId, + startTs: previous.endTs, + endTs: next.startTs, + durationSeconds, + reopenedBy: + next.initiatorId === null || next.initiatorName === null + ? null + : { memberId: next.initiatorId, name: next.initiatorName }, + } + if ( + !longest || + candidate.durationSeconds > longest.durationSeconds || + (candidate.durationSeconds === longest.durationSeconds && candidate.endTs < longest.endTs) + ) { + longest = candidate + } + } + return longest +} diff --git a/packages/core/src/query/index.ts b/packages/core/src/query/index.ts index e5f978da..1496b350 100644 --- a/packages/core/src/query/index.ts +++ b/packages/core/src/query/index.ts @@ -220,6 +220,7 @@ export { getLaughAnalysis, getClusterGraph, getRelationshipStats, + getJourneyStats, getLanguagePreferenceAnalysis, getDragonKingAnalysis, getDivingAnalysis, @@ -247,6 +248,13 @@ export type { MonthlyResponseLatency, MonthlyPerseverance, RelationshipOptions, + JourneyStats, + JourneyRange, + JourneyMonth, + JourneyYear, + JourneyMember, + JourneySegment, + JourneySilence, NlpProvider, PosTagResult, LanguagePreferenceParams, diff --git a/packages/http-routes/src/routes/web/analytics.test.ts b/packages/http-routes/src/routes/web/analytics.test.ts index 85d9ac25..302c3b81 100644 --- a/packages/http-routes/src/routes/web/analytics.test.ts +++ b/packages/http-routes/src/routes/web/analytics.test.ts @@ -59,6 +59,7 @@ class Adapter implements DatabaseAdapter { const SESSION_ID = 'chat-1' const MEMBER_ACTIVITY_URL = `/_web/sessions/${SESSION_ID}/stats/member-activity` +const JOURNEY_URL = `/_web/sessions/${SESSION_ID}/analytics/journey` const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') describe('analytics routes caching', () => { @@ -172,4 +173,19 @@ describe('analytics routes caching', () => { const diff = await app.inject({ method: 'GET', url: `${base}?lookAhead=10&decaySeconds=120&topEdges=150` }) assert.equal(diff.statusCode, 500) }) + + it('serves journey analytics and keys its cache by time range', async () => { + const first = await app.inject({ method: 'GET', url: `${JOURNEY_URL}?startTs=100&endTs=300` }) + assert.equal(first.statusCode, 200) + assert.equal(first.json().range.activeDays, 1) + + raw.close() + + const same = await app.inject({ method: 'GET', url: `${JOURNEY_URL}?startTs=100&endTs=300` }) + assert.equal(same.statusCode, 200) + assert.deepEqual(same.json(), first.json()) + + const differentRange = await app.inject({ method: 'GET', url: `${JOURNEY_URL}?startTs=200&endTs=300` }) + assert.equal(differentRange.statusCode, 500) + }) }) diff --git a/packages/http-routes/src/routes/web/analytics.ts b/packages/http-routes/src/routes/web/analytics.ts index 800ddd86..62d50af8 100644 --- a/packages/http-routes/src/routes/web/analytics.ts +++ b/packages/http-routes/src/routes/web/analytics.ts @@ -20,6 +20,7 @@ import { getMemberMonthlyTrend, getTextLengthPercentiles, getRelationshipStats, + getJourneyStats, getCatchphraseAnalysis, getMentionAnalysis, getMentionGraph, @@ -120,6 +121,15 @@ export function registerAnalyticsRoutes(server: FastifyInstance, ctx: AnalyticsR } ) + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/journey', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('journey', id, { ...filter }, () => getJourneyStats(adapter.ensureReadonly(id), filter)) + } + ) + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( '/_web/sessions/:id/analytics/catchphrase', async (request) => { diff --git a/packages/web-runtime/src/import/session-runtime.test.ts b/packages/web-runtime/src/import/session-runtime.test.ts index 621d9761..479a7b46 100644 --- a/packages/web-runtime/src/import/session-runtime.test.ts +++ b/packages/web-runtime/src/import/session-runtime.test.ts @@ -933,6 +933,13 @@ describe('BrowserSessionRuntime', () => { false ) + const journey = await runtime.getJourneyStats('insight-query-session') + assert.equal(journey.hasSessionIndex, true) + assert.equal(journey.range?.activeDays, 1) + assert.equal(journey.range?.activeMonths, 1) + assert.equal(journey.peakMonth?.messageCount, 4) + assert.equal(journey.longestSegment?.messageCount, 2) + const language = await runtime.getLanguagePreferenceAnalysis('insight-query-session', 'en-US') assert.equal(language.members.length, 2) @@ -956,6 +963,7 @@ describe('BrowserSessionRuntime', () => { await assert.rejects(runtime.getMonthlyActivity('missing-session'), /Session missing-session was not found/) await assert.rejects(runtime.getMembers('missing-session'), /Session missing-session was not found/) await assert.rejects(runtime.getRelationshipStats('missing-session'), /Session missing-session was not found/) + await assert.rejects(runtime.getJourneyStats('missing-session'), /Session missing-session was not found/) await assert.rejects(runtime.getWordFrequency('missing-session', { locale: 'en-US' }), /Session missing-session/) assert.deepEqual(await database.getDatabaseFilenames(), filenamesBeforeMissingQuery) database.dispose() diff --git a/packages/web-runtime/src/import/session-runtime.ts b/packages/web-runtime/src/import/session-runtime.ts index 850170bd..3c2ca076 100644 --- a/packages/web-runtime/src/import/session-runtime.ts +++ b/packages/web-runtime/src/import/session-runtime.ts @@ -18,6 +18,7 @@ import { getMessageTypeStats as queryMessageTypeStats, getMonthlyActivity as queryMonthlyActivity, getRelationshipStats as queryRelationshipStats, + getJourneyStats as queryJourneyStats, getTextLengthPercentiles as queryTextLengthPercentiles, getTextStats as queryTextStats, getTimeRange as queryTimeRange, @@ -37,6 +38,7 @@ import { type MentionGraphData, type MonthlyActivity, type RelationshipStats, + type JourneyStats, type TextLengthPercentiles, type TextStats, type WeekdayActivity, @@ -420,6 +422,13 @@ export class BrowserSessionRuntime { }) } + async getJourneyStats(id: string, filter?: BrowserTimeFilter): Promise { + return this.withSessionDatabase(id, (db) => { + if (!hasSessionIndex(db)) generateSessionIndex(db) + return queryJourneyStats(db, filter) + }) + } + async getLanguagePreferenceAnalysis(id: string, locale: string, filter?: BrowserTimeFilter) { return this.withSessionDatabase(id, (db) => queryLanguagePreferenceAnalysis(db, { locale, timeFilter: filter })) } diff --git a/packages/web-runtime/src/rpc/protocol.ts b/packages/web-runtime/src/rpc/protocol.ts index e31c7986..f6369b5d 100644 --- a/packages/web-runtime/src/rpc/protocol.ts +++ b/packages/web-runtime/src/rpc/protocol.ts @@ -12,6 +12,7 @@ import type { MonthlyActivity, ClusterGraphData, ClusterGraphOptions, + JourneyStats, RelationshipStats, TextLengthPercentiles, TextStats, @@ -172,6 +173,10 @@ export interface WebRuntimeTaskMap { payload: { sessionId: string; filter?: BrowserTimeFilter; options?: { perseveranceThreshold?: number } } result: RelationshipStats } + 'analysis.journey': { + payload: { sessionId: string; filter?: BrowserTimeFilter } + result: JourneyStats + } 'analysis.languagePreference': { payload: { sessionId: string; locale: string; filter?: BrowserTimeFilter } result: unknown @@ -294,6 +299,7 @@ const WEB_RUNTIME_TASK_TYPES: Record = { 'analysis.mentionGraph': true, 'analysis.clusterGraph': true, 'analysis.relationship': true, + 'analysis.journey': true, 'analysis.languagePreference': true, 'analysis.wordFrequency': true, } diff --git a/packages/web-runtime/src/worker/controller.test.ts b/packages/web-runtime/src/worker/controller.test.ts index b8f7b9ae..beffd1b4 100644 --- a/packages/web-runtime/src/worker/controller.test.ts +++ b/packages/web-runtime/src/worker/controller.test.ts @@ -277,6 +277,18 @@ class FakeSessionRuntime implements WorkerSessionRuntime { } } + async getJourneyStats(_id: string, _filter?: BrowserTimeFilter) { + return { + range: null, + hasSessionIndex: true, + months: [], + years: [], + peakMonth: null, + longestSegment: null, + longestSilence: null, + } + } + async getLanguagePreferenceAnalysis(_id: string, _locale: string, _filter?: BrowserTimeFilter) { return { members: [], sharedWords: [], similarityScore: 0 } } @@ -601,6 +613,7 @@ describe('WebRuntimeWorkerController', () => { { id: 'mention-graph', type: 'analysis.mentionGraph', payload: { sessionId: 'session-one' } }, { id: 'cluster', type: 'analysis.clusterGraph', payload: { sessionId: 'session-one' } }, { id: 'relationship', type: 'analysis.relationship', payload: { sessionId: 'session-one' } }, + { id: 'journey', type: 'analysis.journey', payload: { sessionId: 'session-one' } }, { id: 'language', type: 'analysis.languagePreference', diff --git a/packages/web-runtime/src/worker/controller.ts b/packages/web-runtime/src/worker/controller.ts index b2d32162..e05dcba9 100644 --- a/packages/web-runtime/src/worker/controller.ts +++ b/packages/web-runtime/src/worker/controller.ts @@ -53,6 +53,7 @@ export type WorkerSessionRuntime = Pick< | 'getMentionGraph' | 'getClusterGraph' | 'getRelationshipStats' + | 'getJourneyStats' | 'getLanguagePreferenceAnalysis' | 'getWordFrequency' > @@ -425,6 +426,12 @@ export class WebRuntimeWorkerController { this.emitAnalysisQueryCompleted(request, startedAt) return result } + case 'analysis.journey': { + const startedAt = this.startAnalysisQuery() + const result = await this.sessionRuntime.getJourneyStats(request.payload.sessionId, request.payload.filter) + this.emitAnalysisQueryCompleted(request, startedAt) + return result + } case 'analysis.languagePreference': { const startedAt = this.startAnalysisQuery() const result = await this.sessionRuntime.getLanguagePreferenceAnalysis( diff --git a/src/components/analysis/journey/JourneyReportCard.vue b/src/components/analysis/journey/JourneyReportCard.vue new file mode 100644 index 00000000..90eaec11 --- /dev/null +++ b/src/components/analysis/journey/JourneyReportCard.vue @@ -0,0 +1,221 @@ + + + diff --git a/src/components/analysis/journey/JourneyView.vue b/src/components/analysis/journey/JourneyView.vue new file mode 100644 index 00000000..f8dd6304 --- /dev/null +++ b/src/components/analysis/journey/JourneyView.vue @@ -0,0 +1,266 @@ + + + diff --git a/src/i18n/locales/en-US/analysis.json b/src/i18n/locales/en-US/analysis.json index a5a7a1e3..74eb49ee 100644 --- a/src/i18n/locales/en-US/analysis.json +++ b/src/i18n/locales/en-US/analysis.json @@ -69,6 +69,7 @@ "topic": "Topics", "groupRelationships": "Relationships", "relationship": "Relationship", + "journey": "Journey", "languagePreference": "Language Preference" }, "ranking": { diff --git a/src/i18n/locales/en-US/views.json b/src/i18n/locales/en-US/views.json index 747f7ed2..6425b630 100644 --- a/src/i18n/locales/en-US/views.json +++ b/src/i18n/locales/en-US/views.json @@ -179,6 +179,68 @@ "month": "Month" } }, + "journey": { + "loadError": "Unable to load the chat journey", + "empty": "There are not enough messages to build a journey yet", + "hero": { + "prefix": "Your chat history spans", + "summary": "Messages were exchanged across {activeMonths} months and {activeDays} active days.", + "filteredSummary": "Within this range, messages were exchanged across {activeMonths} months and {activeDays} active days.", + "footprint": "Conversation footprint", + "monthCount": "{count} months in view", + "monthTooltip": "{month} · {count} messages" + }, + "metrics": { + "peakMonth": "Busiest month", + "activeMonths": "Active months", + "longestConversation": "Longest conversation", + "longestSilence": "Longest silence", + "messagesAndDays": "{messages} messages · {days} active days", + "monthsRatio": "{active} / {total} months", + "activeDays": "{count} active days in total", + "conversationDetail": "{date} · {count} messages", + "reopenedBy": "{name} spoke first afterward", + "reopened": "The conversation started again afterward", + "noIndex": "Conversation index unavailable", + "noSilence": "No silence longer than 24 hours" + }, + "years": { + "title": "Year by year", + "description": "Turn through the chapters left by each year of conversation", + "summary": "{messages} messages · {days} active days · {months} active months", + "quiet": "No messages were left this year", + "peak": "Peak month · {count} messages" + }, + "milestones": { + "title": "Key milestones", + "description": "Follow the moments that shaped this conversation", + "start": "Where it began", + "startDetail": "The first message in the available history", + "peak": "Busiest month", + "peakDetail": "{count} messages were exchanged this month", + "longestConversation": "Longest conversation", + "conversationDetail": "{duration} and {count} messages", + "longestSilence": "Longest silence", + "silenceRange": "{start} – {end}", + "silenceDetailWithMember": "After {duration}, {name} spoke first", + "silenceDetail": "The conversation resumed after {duration}", + "end": "Where the record stands", + "endDetail": "The latest message in the available history", + "filteredEndDetail": "The latest message within the selected range" + }, + "duration": { + "lessThanMinute": "Under 1 minute", + "minutes": "{count} min", + "hours": "{count} hr", + "hoursMinutes": "{hours} hr {minutes} min", + "days": "{count} days", + "daysHours": "{days} days {hours} hr", + "months": "{count} months", + "years": "{count} years", + "yearsMonths": "{years} years {months} months" + }, + "watermark": "Chat Journey" + }, "timeline": { "title": "Timeline View", "description": "Message time distribution, active period trends and other visualizations" diff --git a/src/i18n/locales/ja-JP/analysis.json b/src/i18n/locales/ja-JP/analysis.json index 4fdee4de..e5af05bc 100644 --- a/src/i18n/locales/ja-JP/analysis.json +++ b/src/i18n/locales/ja-JP/analysis.json @@ -69,6 +69,7 @@ "topic": "トピック", "groupRelationships": "グループ関係", "relationship": "関係", + "journey": "歩み", "languagePreference": "言語傾向" }, "ranking": { diff --git a/src/i18n/locales/ja-JP/views.json b/src/i18n/locales/ja-JP/views.json index 37a2117b..bfdcf074 100644 --- a/src/i18n/locales/ja-JP/views.json +++ b/src/i18n/locales/ja-JP/views.json @@ -179,6 +179,68 @@ "month": "月" } }, + "journey": { + "loadError": "チャットの歩みを読み込めませんでした", + "empty": "歩みを作成するためのメッセージがまだ十分にありません", + "hero": { + "prefix": "ふたりで歩んだ", + "summary": "{activeMonths}か月、{activeDays}日間にメッセージが残っています。", + "filteredSummary": "選択範囲では、{activeMonths}か月、{activeDays}日間にメッセージが残っています。", + "footprint": "会話の足跡", + "monthCount": "{count}か月の記録", + "monthTooltip": "{month} · {count}件のメッセージ" + }, + "metrics": { + "peakMonth": "最もにぎやかな月", + "activeMonths": "会話した月", + "longestConversation": "最も長い会話", + "longestSilence": "最も長い沈黙", + "messagesAndDays": "{messages}件 · {days}日間アクティブ", + "monthsRatio": "{active} / {total}か月", + "activeDays": "合計{count}日間アクティブ", + "conversationDetail": "{date} · {count}件", + "reopenedBy": "その後、{name}から再開", + "reopened": "その後、会話が再開しました", + "noIndex": "会話インデックスがありません", + "noSilence": "24時間を超える沈黙はありません" + }, + "years": { + "title": "一年ずつ振り返る", + "description": "年ごとに残された会話の章をたどります", + "summary": "{messages}件 · {days}日間 · {months}か月アクティブ", + "quiet": "この年にはメッセージがありません", + "peak": "ピーク月 · {count}件" + }, + "milestones": { + "title": "大切な節目", + "description": "始まりから再会まで、会話の歩みをつなぎます", + "start": "ここから始まりました", + "startDetail": "現在の記録にある最初のメッセージ", + "peak": "最もにぎやかな月", + "peakDetail": "この月に{count}件のメッセージを交わしました", + "longestConversation": "最も長い会話", + "conversationDetail": "{duration}にわたり、{count}件のメッセージ", + "longestSilence": "最も長い沈黙", + "silenceRange": "{start} – {end}", + "silenceDetailWithMember": "{duration}後、{name}から再開", + "silenceDetail": "{duration}後に会話が再開しました", + "end": "記録はここまで", + "endDetail": "現在の記録にある最後のメッセージ", + "filteredEndDetail": "選択範囲にある最後のメッセージ" + }, + "duration": { + "lessThanMinute": "1分未満", + "minutes": "{count}分", + "hours": "{count}時間", + "hoursMinutes": "{hours}時間{minutes}分", + "days": "{count}日", + "daysHours": "{days}日{hours}時間", + "months": "{count}か月", + "years": "{count}年", + "yearsMonths": "{years}年{months}か月" + }, + "watermark": "チャットの歩み" + }, "timeline": { "title": "タイムライン表示", "description": "メッセージの時間分布や活動周期の変化を可視化します" diff --git a/src/i18n/locales/zh-CN/analysis.json b/src/i18n/locales/zh-CN/analysis.json index eae9e989..f7b218e3 100644 --- a/src/i18n/locales/zh-CN/analysis.json +++ b/src/i18n/locales/zh-CN/analysis.json @@ -70,6 +70,7 @@ "topic": "话题", "groupRelationships": "群关系", "relationship": "关系", + "journey": "历程", "languagePreference": "语言偏好" }, "ranking": { diff --git a/src/i18n/locales/zh-CN/views.json b/src/i18n/locales/zh-CN/views.json index 8cb94be8..0ca222e6 100644 --- a/src/i18n/locales/zh-CN/views.json +++ b/src/i18n/locales/zh-CN/views.json @@ -179,6 +179,68 @@ "month": "月份" } }, + "journey": { + "loadError": "无法加载聊天历程", + "empty": "暂无足够的聊天记录来生成历程", + "hero": { + "prefix": "你们的聊天记录跨越", + "summary": "其中有 {activeMonths} 个月、{activeDays} 天留下了消息。", + "filteredSummary": "在当前筛选范围内,有 {activeMonths} 个月、{activeDays} 天留下了消息。", + "footprint": "聊天足迹", + "monthCount": "跨越 {count} 个月", + "monthTooltip": "{month} · {count} 条消息" + }, + "metrics": { + "peakMonth": "最热闹的月份", + "activeMonths": "活跃月份", + "longestConversation": "最长一次畅聊", + "longestSilence": "最长一次沉默", + "messagesAndDays": "{messages} 条消息 · 活跃 {days} 天", + "monthsRatio": "{active} / {total} 个月", + "activeDays": "共活跃 {count} 天", + "conversationDetail": "{date} · {count} 条消息", + "reopenedBy": "由 {name} 再次开口", + "reopened": "随后重新开始聊天", + "noIndex": "暂无会话索引", + "noSilence": "没有超过 24 小时的沉默" + }, + "years": { + "title": "一年一页", + "description": "按年份翻看聊天留下的篇章", + "summary": "{messages} 条消息 · {days} 个活跃日 · {months} 个活跃月", + "quiet": "这一年没有留下消息", + "peak": "高峰月 {count} 条" + }, + "milestones": { + "title": "关键节点", + "description": "从开始、热闹到重逢,串起这段聊天历程", + "start": "从这里开始", + "startDetail": "现有记录中的第一条消息", + "peak": "最热闹的月份", + "peakDetail": "这个月留下了 {count} 条消息", + "longestConversation": "最长一次畅聊", + "conversationDetail": "持续 {duration},共 {count} 条消息", + "longestSilence": "最长一次沉默", + "silenceRange": "{start} – {end}", + "silenceDetailWithMember": "相隔 {duration},由 {name} 再次开口", + "silenceDetail": "相隔 {duration},重新开始聊天", + "end": "记录来到这里", + "endDetail": "现有记录中的最后一条消息", + "filteredEndDetail": "当前筛选范围内的最后一条消息" + }, + "duration": { + "lessThanMinute": "不到 1 分钟", + "minutes": "{count} 分钟", + "hours": "{count} 小时", + "hoursMinutes": "{hours} 小时 {minutes} 分钟", + "days": "{count} 天", + "daysHours": "{days} 天 {hours} 小时", + "months": "{count} 个月", + "years": "{count} 年", + "yearsMonths": "{years} 年 {months} 个月" + }, + "watermark": "聊天历程" + }, "timeline": { "title": "时间线视图", "description": "消息时间分布、活跃周期变化趋势等可视化内容" diff --git a/src/i18n/locales/zh-TW/analysis.json b/src/i18n/locales/zh-TW/analysis.json index 6d95bfa9..4298d1bc 100644 --- a/src/i18n/locales/zh-TW/analysis.json +++ b/src/i18n/locales/zh-TW/analysis.json @@ -69,6 +69,7 @@ "topic": "話題", "groupRelationships": "群關係", "relationship": "關係", + "journey": "歷程", "languagePreference": "語言偏好" }, "ranking": { diff --git a/src/i18n/locales/zh-TW/views.json b/src/i18n/locales/zh-TW/views.json index f2a0da18..6e735ce4 100644 --- a/src/i18n/locales/zh-TW/views.json +++ b/src/i18n/locales/zh-TW/views.json @@ -179,6 +179,68 @@ "month": "月份" } }, + "journey": { + "loadError": "無法載入聊天歷程", + "empty": "暫無足夠的聊天記錄來產生歷程", + "hero": { + "prefix": "你們的聊天記錄跨越", + "summary": "其中有 {activeMonths} 個月、{activeDays} 天留下了訊息。", + "filteredSummary": "在目前篩選範圍內,有 {activeMonths} 個月、{activeDays} 天留下了訊息。", + "footprint": "聊天足跡", + "monthCount": "跨越 {count} 個月", + "monthTooltip": "{month} · {count} 則訊息" + }, + "metrics": { + "peakMonth": "最熱鬧的月份", + "activeMonths": "活躍月份", + "longestConversation": "最長一次暢聊", + "longestSilence": "最長一次沉默", + "messagesAndDays": "{messages} 則訊息 · 活躍 {days} 天", + "monthsRatio": "{active} / {total} 個月", + "activeDays": "共活躍 {count} 天", + "conversationDetail": "{date} · {count} 則訊息", + "reopenedBy": "由 {name} 再次開口", + "reopened": "隨後重新開始聊天", + "noIndex": "暫無對話索引", + "noSilence": "沒有超過 24 小時的沉默" + }, + "years": { + "title": "一年一頁", + "description": "按年份翻看聊天留下的篇章", + "summary": "{messages} 則訊息 · {days} 個活躍日 · {months} 個活躍月", + "quiet": "這一年沒有留下訊息", + "peak": "高峰月 {count} 則" + }, + "milestones": { + "title": "關鍵節點", + "description": "從開始、熱鬧到重逢,串起這段聊天歷程", + "start": "從這裡開始", + "startDetail": "現有記錄中的第一則訊息", + "peak": "最熱鬧的月份", + "peakDetail": "這個月留下了 {count} 則訊息", + "longestConversation": "最長一次暢聊", + "conversationDetail": "持續 {duration},共 {count} 則訊息", + "longestSilence": "最長一次沉默", + "silenceRange": "{start} – {end}", + "silenceDetailWithMember": "相隔 {duration},由 {name} 再次開口", + "silenceDetail": "相隔 {duration},重新開始聊天", + "end": "記錄來到這裡", + "endDetail": "現有記錄中的最後一則訊息", + "filteredEndDetail": "目前篩選範圍內的最後一則訊息" + }, + "duration": { + "lessThanMinute": "不到 1 分鐘", + "minutes": "{count} 分鐘", + "hours": "{count} 小時", + "hoursMinutes": "{hours} 小時 {minutes} 分鐘", + "days": "{count} 天", + "daysHours": "{days} 天 {hours} 小時", + "months": "{count} 個月", + "years": "{count} 年", + "yearsMonths": "{years} 年 {months} 個月" + }, + "watermark": "聊天歷程" + }, "timeline": { "title": "時間軸檢視", "description": "以視覺化方式呈現訊息時間分布與活躍週期變化" diff --git a/src/pages/private-chat/components/insights/PrivateChatInsights.vue b/src/pages/private-chat/components/insights/PrivateChatInsights.vue index deaad409..391946c8 100644 --- a/src/pages/private-chat/components/insights/PrivateChatInsights.vue +++ b/src/pages/private-chat/components/insights/PrivateChatInsights.vue @@ -6,6 +6,7 @@ import UserSelect from '@/components/common/UserSelect.vue' import TypeAnalysisView from '@/components/analysis/message/TypeAnalysisView.vue' import TimeAnalysisView from '@/components/analysis/message/TimeAnalysisView.vue' import PrivateRelationshipView from '@/components/analysis/relationships/PrivateRelationshipView.vue' +import JourneyView from '@/components/analysis/journey/JourneyView.vue' import { WordcloudTab, LanguagePreferenceTab } from '@/components/analysis/quotes' import type { TimeFilter } from '@openchatlab/shared-types' import type { AnalysisSession, MessageType } from '@/types/base' @@ -30,6 +31,7 @@ const props = defineProps<{ const subTabs = computed(() => [ { id: 'overview', label: t('analysis.tabs.overview'), icon: 'i-heroicons-squares-2x2' }, { id: 'relationship', label: t('analysis.subTabs.insights.relationship'), icon: 'i-heroicons-heart' }, + { id: 'journey', label: t('analysis.subTabs.insights.journey'), icon: 'i-heroicons-map' }, { id: 'type-analysis', label: t('analysis.subTabs.insights.typeAnalysis'), icon: 'i-heroicons-chart-pie' }, { id: 'time-analysis', label: t('analysis.subTabs.insights.timeAnalysis'), icon: 'i-heroicons-clock' }, { id: 'topic', label: t('analysis.subTabs.insights.topic'), icon: 'i-heroicons-cloud' }, @@ -94,6 +96,12 @@ const viewTimeFilter = computed(() => ({ :session-id="props.sessionId" :time-filter="props.timeFilter" /> + { stats: { totalMembers: 0, totalMessages: 0, involvedMembers: 0, edgeCount: 0, communityCount: 0 }, }, 'analysis.relationship': { hasSessionIndex: true }, + 'analysis.journey': { range: null, hasSessionIndex: true, months: [], years: [] }, 'analysis.languagePreference': { members: [], sharedWords: [], similarityScore: 0 }, 'analysis.wordFrequency': { words: [], totalWords: 0, totalMessages: 0, uniqueWords: 0 }, } @@ -226,6 +227,7 @@ describe('BrowserDataAdapter', () => { assert.equal((await adapter.getMentionGraph('session-one')).links.length, 0) assert.equal((await adapter.getClusterGraph('session-one')).stats.edgeCount, 0) assert.equal((await adapter.getRelationshipStats('session-one')).hasSessionIndex, true) + assert.equal((await adapter.getJourneyStats('session-one')).hasSessionIndex, true) assert.equal((await adapter.getLanguagePreferenceAnalysis('session-one', 'en-US')).members.length, 0) assert.equal((await adapter.getWordFrequency('session-one', { locale: 'en-US' })).totalWords, 0) @@ -240,6 +242,7 @@ describe('BrowserDataAdapter', () => { 'analysis.mentionGraph', 'analysis.clusterGraph', 'analysis.relationship', + 'analysis.journey', 'analysis.languagePreference', 'analysis.wordFrequency', ] diff --git a/src/services/data/browser.ts b/src/services/data/browser.ts index d5457ba2..bc313c83 100644 --- a/src/services/data/browser.ts +++ b/src/services/data/browser.ts @@ -19,7 +19,7 @@ import type { WeekdayActivity, } from '@/types/analysis' import type { LanguagePreferenceResult } from '@/types/quotes/languagePreference' -import type { MemberMonthlyTrend, WordFrequencyParams, WordFrequencyResult } from '@openchatlab/core' +import type { JourneyStats, MemberMonthlyTrend, WordFrequencyParams, WordFrequencyResult } from '@openchatlab/core' import type { TimeFilter } from '@openchatlab/shared-types' import type { BrowserRuntimeRpcPort } from '../browser-runtime/types' import { withAnalyticsRequestEpoch } from '../utils/http' @@ -50,6 +50,7 @@ type BrowserSessionDataAdapter = Pick< | 'getMentionGraph' | 'getClusterGraph' | 'getRelationshipStats' + | 'getJourneyStats' | 'getLanguagePreferenceAnalysis' | 'getWordFrequency' > @@ -167,6 +168,10 @@ export class BrowserDataAdapter implements BrowserSessionDataAdapter { return this.requestAnalysis('analysis.relationship', { sessionId, filter, options }) } + getJourneyStats(sessionId: string, filter?: TimeFilter): Promise { + return this.requestAnalysis('analysis.journey', { sessionId, filter }) + } + async getLanguagePreferenceAnalysis( sessionId: string, locale: string, diff --git a/src/services/data/fetch.ts b/src/services/data/fetch.ts index 01ac53cf..45629a79 100644 --- a/src/services/data/fetch.ts +++ b/src/services/data/fetch.ts @@ -41,6 +41,7 @@ import type { MemeBattleAnalysis, NightOwlAnalysis, RepeatAnalysis, + JourneyStats, WordFrequencyParams, WordFrequencyResult, } from '@openchatlab/core' @@ -414,6 +415,10 @@ export class FetchDataAdapter implements DataAdapter { return analyticsGet(`/sessions/${sessionId}/analytics/relationship${buildFilterParams(filter)}`) } + getJourneyStats(sessionId: string, filter?: TimeFilter): Promise { + return analyticsGet(`/sessions/${sessionId}/analytics/journey${buildFilterParams(filter)}`) + } + // ==================== SQL Lab ==================== executeSQL(sessionId: string, sql: string): Promise { diff --git a/src/services/data/types.ts b/src/services/data/types.ts index a6bbaa4f..733278f6 100644 --- a/src/services/data/types.ts +++ b/src/services/data/types.ts @@ -20,7 +20,7 @@ import type { AnnualSummaryMode, AnnualSummaryResponse, } from '@openchatlab/shared-types' -import type { WordFrequencyParams, WordFrequencyResult } from '@openchatlab/core' +import type { JourneyStats, WordFrequencyParams, WordFrequencyResult } from '@openchatlab/core' import type { MemberActivity, MemberWithStats, @@ -242,6 +242,7 @@ export interface DataAdapter { filter?: TimeFilter, options?: { perseveranceThreshold?: number } ): Promise + getJourneyStats(sessionId: string, filter?: TimeFilter): Promise // ==================== SQL Lab ====================