diff --git a/blocksuite/affine/blocks/attachment/src/attachment-block.ts b/blocksuite/affine/blocks/attachment/src/attachment-block.ts index 35d459f6c..3e7fb54d6 100644 --- a/blocksuite/affine/blocks/attachment/src/attachment-block.ts +++ b/blocksuite/affine/blocks/attachment/src/attachment-block.ts @@ -17,6 +17,7 @@ import { AttachmentBlockStyles, } from '@blocksuite/affine-model'; import { + CitationProvider, DocModeProvider, FileSizeLimitProvider, TelemetryProvider, @@ -37,6 +38,7 @@ import { type ClassInfo, classMap } from 'lit/directives/class-map.js'; import { guard } from 'lit/directives/guard.js'; import { styleMap } from 'lit/directives/style-map.js'; import { when } from 'lit/directives/when.js'; +import { filter } from 'rxjs/operators'; import { AttachmentEmbedProvider } from './embed'; import { styles } from './styles'; @@ -79,8 +81,12 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent { @@ -139,6 +145,34 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent { + // Check citation delete event + this._disposables.add( + this.std.store.slots.blockUpdated + .pipe( + filter(payload => { + if (!payload.isLocal) return false; + + const { flavour, id, type } = payload; + if ( + type !== 'delete' || + flavour !== this.model.flavour || + id !== this.model.id + ) + return false; + + const { model } = payload; + if (!this.citationService.isCitationModel(model)) return false; + + return true; + }) + ) + .subscribe(() => { + this.citationService.trackEvent('Delete'); + }) + ); + }; + override connectedCallback() { super.connectedCallback(); @@ -162,6 +196,8 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent abortController.abort(); const onConfirm = () => { @@ -44,6 +46,9 @@ export const RenameModal = ({ model.store.updateBlock(model, { name: newFileName, }); + if (citationService.isCitationModel(model)) { + citationService.trackEvent('Edit'); + } abort(); }; const onInput = (e: InputEvent) => { diff --git a/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts b/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts index 226857a1b..171987bb0 100644 --- a/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts +++ b/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts @@ -8,6 +8,7 @@ import type { } from '@blocksuite/affine-model'; import { ImageProxyService } from '@blocksuite/affine-shared/adapters'; import { + CitationProvider, DocModeProvider, LinkPreviewServiceIdentifier, } from '@blocksuite/affine-shared/services'; @@ -18,6 +19,7 @@ import { html } from 'lit'; import { property, query } from 'lit/decorators.js'; import { type ClassInfo, classMap } from 'lit/directives/class-map.js'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; +import { filter } from 'rxjs/operators'; import { refreshBookmarkUrlData } from './utils.js'; @@ -114,11 +116,12 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent`; }; + private readonly _trackCitationDeleteEvent = () => { + // Check citation delete event + this._disposables.add( + this.std.store.slots.blockUpdated + .pipe( + filter(payload => { + if (!payload.isLocal) return false; + const { flavour, id, type } = payload; + if ( + type !== 'delete' || + flavour !== this.model.flavour || + id !== this.model.id + ) + return false; + const { model } = payload; + if (!this.citationService.isCitationModel(model)) return false; + return true; + }) + ) + .subscribe(() => { + this.citationService.trackEvent('Delete'); + }) + ); + }; + override connectedCallback() { super.connectedCallback(); @@ -203,6 +231,8 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent { @@ -454,6 +457,31 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent { + // Check citation delete event + this._disposables.add( + this.std.store.slots.blockUpdated + .pipe( + filter(payload => { + if (!payload.isLocal) return false; + const { flavour, id, type } = payload; + if ( + type !== 'delete' || + flavour !== this.model.flavour || + id !== this.model.id + ) + return false; + const { model } = payload; + if (!this.citationService.isCitationModel(model)) return false; + return true; + }) + ) + .subscribe(() => { + this.citationService.trackEvent('Delete'); + }) + ); + }; + override connectedCallback() { super.connectedCallback(); @@ -532,6 +560,8 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent + this.citationService.isCitationModel(sibling) + ); + } + override get topContenteditableElement() { if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') { return this.closest( @@ -286,6 +299,13 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent ` diff --git a/blocksuite/affine/foundation/src/view.ts b/blocksuite/affine/foundation/src/view.ts index 78c23f544..70fb8aee9 100644 --- a/blocksuite/affine/foundation/src/view.ts +++ b/blocksuite/affine/foundation/src/view.ts @@ -9,6 +9,7 @@ import { } from '@blocksuite/affine-ext-loader'; import { AutoClearSelectionService, + CitationService, DefaultOpenDocExtension, DNDAPIExtension, DocDisplayMetaService, @@ -76,6 +77,7 @@ export class FoundationViewExtension extends ViewExtensionProvider { if (!this.footnote) { return; @@ -215,6 +220,10 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) { return null; } + this.citationService.trackEvent('Hover', { + control: 'Source Footnote', + }); + return { template: this._FootNotePopup(footnote, abortController), container: this.std.host, diff --git a/blocksuite/affine/shared/src/services/citation-service/citation-service.ts b/blocksuite/affine/shared/src/services/citation-service/citation-service.ts new file mode 100644 index 000000000..e6a909611 --- /dev/null +++ b/blocksuite/affine/shared/src/services/citation-service/citation-service.ts @@ -0,0 +1,84 @@ +import { type Container, createIdentifier } from '@blocksuite/global/di'; +import { type BlockStdScope, StdIdentifier } from '@blocksuite/std'; +import { type BlockModel, Extension } from '@blocksuite/store'; + +import { DocModeProvider } from '../doc-mode-service'; +import type { + CitationEvents, + CitationEventType, +} from '../telemetry-service/citation'; +import { TelemetryProvider } from '../telemetry-service/telemetry-service'; + +const CitationEventTypeMap = { + Hover: 'AICitationHoverSource', + Expand: 'AICitationExpandSource', + Delete: 'AICitationDelete', + Edit: 'AICitationEdit', +} as const; + +type EventType = keyof typeof CitationEventTypeMap; + +type EventTypeMapping = { + [K in EventType]: CitationEventType; +}; + +export interface CitationViewService { + /** + * Tracks citation-related events + * @param type - The type of citation event to track + * @param properties - The properties of the event + */ + trackEvent( + type: T, + properties?: CitationEvents[EventTypeMapping[T]] + ): void; + /** + * Checks if the model is a citation model + * @param model - The model to check + * @returns True if the model is a citation model, false otherwise + */ + isCitationModel(model: BlockModel): boolean; +} + +export const CitationProvider = + createIdentifier('CitationService'); + +export class CitationService extends Extension implements CitationViewService { + constructor(private readonly std: BlockStdScope) { + super(); + } + + static override setup(di: Container) { + di.addImpl(CitationProvider, CitationService, [StdIdentifier]); + } + + get docModeService() { + return this.std.getOptional(DocModeProvider); + } + + get telemetryService() { + return this.std.getOptional(TelemetryProvider); + } + + isCitationModel = (model: BlockModel) => { + return ( + 'footnoteIdentifier' in model.props && + !!model.props.footnoteIdentifier && + 'style' in model.props && + model.props.style === 'citation' + ); + }; + + trackEvent( + type: T, + properties?: CitationEvents[EventTypeMapping[T]] + ) { + const editorMode = this.docModeService?.getEditorMode() ?? 'page'; + this.telemetryService?.track(CitationEventTypeMap[type], { + page: editorMode === 'page' ? 'doc editor' : 'whiteboard editor', + module: 'AI Result', + control: 'Source', + ...properties, + }); + } +} diff --git a/blocksuite/affine/shared/src/services/citation-service/index.ts b/blocksuite/affine/shared/src/services/citation-service/index.ts new file mode 100644 index 000000000..09ac01c7d --- /dev/null +++ b/blocksuite/affine/shared/src/services/citation-service/index.ts @@ -0,0 +1 @@ +export * from './citation-service'; diff --git a/blocksuite/affine/shared/src/services/index.ts b/blocksuite/affine/shared/src/services/index.ts index 85aa2dc46..2f14c804f 100644 --- a/blocksuite/affine/shared/src/services/index.ts +++ b/blocksuite/affine/shared/src/services/index.ts @@ -1,5 +1,6 @@ export * from './auto-clear-selection-service'; export * from './block-meta-service'; +export * from './citation-service'; export * from './doc-display-meta-service'; export * from './doc-mode-service'; export * from './drag-handle-config'; diff --git a/blocksuite/affine/shared/src/services/telemetry-service/citation.ts b/blocksuite/affine/shared/src/services/telemetry-service/citation.ts new file mode 100644 index 000000000..365b1074f --- /dev/null +++ b/blocksuite/affine/shared/src/services/telemetry-service/citation.ts @@ -0,0 +1,8 @@ +import type { TelemetryEvent } from './types'; +export type CitationEventType = + | 'AICitationHoverSource' + | 'AICitationExpandSource' + | 'AICitationDelete' + | 'AICitationEdit'; + +export type CitationEvents = Record; diff --git a/blocksuite/affine/shared/src/services/telemetry-service/index.ts b/blocksuite/affine/shared/src/services/telemetry-service/index.ts index 1c6241ff0..13a193ce0 100644 --- a/blocksuite/affine/shared/src/services/telemetry-service/index.ts +++ b/blocksuite/affine/shared/src/services/telemetry-service/index.ts @@ -1,3 +1,4 @@ +export * from './citation.js'; export * from './database.js'; export * from './link.js'; export * from './telemetry-service.js'; diff --git a/blocksuite/affine/shared/src/services/telemetry-service/telemetry-service.ts b/blocksuite/affine/shared/src/services/telemetry-service/telemetry-service.ts index 6739928ec..7abcbe3eb 100644 --- a/blocksuite/affine/shared/src/services/telemetry-service/telemetry-service.ts +++ b/blocksuite/affine/shared/src/services/telemetry-service/telemetry-service.ts @@ -1,6 +1,7 @@ import { createIdentifier } from '@blocksuite/global/di'; import type { ExtensionType } from '@blocksuite/store'; +import type { CitationEvents } from './citation.js'; import type { CodeBlockEvents } from './code-block.js'; import type { OutDatabaseAllEvents } from './database.js'; import type { LinkToolbarEvents } from './link.js'; @@ -28,7 +29,8 @@ export type TelemetryEventMap = OutDatabaseAllEvents & LinkToolbarEvents & SlashMenuEvents & CodeBlockEvents & - NoteEvents & { + NoteEvents & + CitationEvents & { DocCreated: DocCreatedEvent; Link: TelemetryEvent; LinkedDocCreated: LinkedDocCreatedEvent; diff --git a/packages/frontend/core/src/blocksuite/view-extensions/editor-config/toolbar/index.ts b/packages/frontend/core/src/blocksuite/view-extensions/editor-config/toolbar/index.ts index 4a27ff046..e4da3e4a7 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/editor-config/toolbar/index.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/editor-config/toolbar/index.ts @@ -51,6 +51,7 @@ import { getSelectedModelsCommand } from '@blocksuite/affine/shared/commands'; import { ImageSelection } from '@blocksuite/affine/shared/selection'; import { ActionPlacement, + CitationProvider, GenerateDocUrlProvider, isRemovedUserInfo, OpenDocExtensionIdentifier, @@ -462,6 +463,10 @@ function createExternalLinkableToolbarConfig( (_std, _component, props) => { ctx.store.updateBlock(model, props); block.requestUpdate(); + const citationService = ctx.std.get(CitationProvider); + if (citationService.isCitationModel(model)) { + citationService.trackEvent('Edit'); + } }, abortController ); @@ -805,6 +810,10 @@ const embedLinkedDocToolbarConfig = { (_std, _component, props) => { ctx.store.updateBlock(model, props); block.requestUpdate(); + const citationService = ctx.std.get(CitationProvider); + if (citationService.isCitationModel(model)) { + citationService.trackEvent('Edit'); + } }, abortController );