diff --git a/packages/jupyter-ai/jupyter_ai/handlers.py b/packages/jupyter-ai/jupyter_ai/handlers.py index 614df557d..44df535c5 100644 --- a/packages/jupyter-ai/jupyter_ai/handlers.py +++ b/packages/jupyter-ai/jupyter_ai/handlers.py @@ -249,6 +249,7 @@ def broadcast_message(self, message: Message): ): stream_message: AgentStreamMessage = history_message stream_message.body += chunk.content + stream_message.metadata = chunk.metadata stream_message.complete = chunk.stream_complete break elif isinstance(message, PendingMessage): diff --git a/packages/jupyter-ai/src/components/chat-messages.tsx b/packages/jupyter-ai/src/components/chat-messages.tsx index 961b884b2..5c4286f8f 100644 --- a/packages/jupyter-ai/src/components/chat-messages.tsx +++ b/packages/jupyter-ai/src/components/chat-messages.tsx @@ -74,10 +74,6 @@ function sortMessages( export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element { const collaborators = useCollaboratorsContext(); - if (props.message.type === 'agent-stream' && props.message.complete) { - console.log(props.message.metadata); - } - const sharedStyles: SxProps = { height: '24px', width: '24px' @@ -228,8 +224,9 @@ export function ChatMessages(props: ChatMessagesProps): JSX.Element { sx={{ marginBottom: 3 }} /> ; messageFooter: IJaiMessageFooter | null; + telemetryHandler: IJaiTelemetryHandler | null; }; enum ChatView { @@ -201,69 +207,71 @@ export function Chat(props: ChatProps): JSX.Element { - - {/* top bar */} - - {view !== ChatView.Chat ? ( - setView(ChatView.Chat)}> - - - ) : ( - - )} - {view === ChatView.Chat ? ( - - {!showWelcomeMessage && ( - - props.chatHandler.sendMessage({ type: 'clear' }) - } - tooltip="New chat" - > - - - )} - openSettingsView()}> - + + + {/* top bar */} + + {view !== ChatView.Chat ? ( + setView(ChatView.Chat)}> + - - ) : ( - + ) : ( + + )} + {view === ChatView.Chat ? ( + + {!showWelcomeMessage && ( + + props.chatHandler.sendMessage({ type: 'clear' }) + } + tooltip="New chat" + > + + + )} + openSettingsView()}> + + + + ) : ( + + )} + + {/* body */} + {view === ChatView.Chat && ( + + )} + {view === ChatView.Settings && ( + )} - {/* body */} - {view === ChatView.Chat && ( - - )} - {view === ChatView.Settings && ( - - )} - + diff --git a/packages/jupyter-ai/src/components/code-blocks/code-toolbar.tsx b/packages/jupyter-ai/src/components/code-blocks/code-toolbar.tsx index 7dd0f70e3..ccb46eb40 100644 --- a/packages/jupyter-ai/src/components/code-blocks/code-toolbar.tsx +++ b/packages/jupyter-ai/src/components/code-blocks/code-toolbar.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { Box } from '@mui/material'; -import { addAboveIcon, addBelowIcon } from '@jupyterlab/ui-components'; - -import { CopyButton } from './copy-button'; +import { + addAboveIcon, + addBelowIcon, + copyIcon +} from '@jupyterlab/ui-components'; import { replaceCellIcon } from '../../icons'; import { @@ -11,20 +13,29 @@ import { } from '../../contexts/active-cell-context'; import { TooltippedIconButton } from '../mui-extras/tooltipped-icon-button'; import { useReplace } from '../../hooks/use-replace'; +import { useCopy } from '../../hooks/use-copy'; +import { AiService } from '../../handler'; +import { useTelemetry } from '../../contexts/telemetry-context'; +import { TelemetryEvent } from '../../tokens'; export type CodeToolbarProps = { /** * The content of the Markdown code block this component is attached to. */ - content: string; + code: string; + /** + * Parent message which contains the code referenced by `content`. + */ + parentMessage?: AiService.ChatMessage; }; export function CodeToolbar(props: CodeToolbarProps): JSX.Element { const activeCell = useActiveCellContext(); - const sharedToolbarButtonProps = { - content: props.content, + const sharedToolbarButtonProps: ToolbarButtonProps = { + code: props.code, activeCellManager: activeCell.manager, - activeCellExists: activeCell.exists + activeCellExists: activeCell.exists, + parentMessage: props.parentMessage }; return ( @@ -41,19 +52,51 @@ export function CodeToolbar(props: CodeToolbarProps): JSX.Element { > - - + + ); } type ToolbarButtonProps = { - content: string; + code: string; activeCellExists: boolean; activeCellManager: ActiveCellManager; + parentMessage?: AiService.ChatMessage; + // TODO: parentMessage should always be defined, but this can be undefined + // when the code toolbar appears in Markdown help messages in the Settings + // UI. The Settings UI should use a different component to render Markdown, + // and should never render code toolbars within it. }; +function buildTelemetryEvent( + type: string, + props: ToolbarButtonProps +): TelemetryEvent { + const charCount = props.code.length; + // number of lines = number of newlines + 1 + const lineCount = (props.code.match(/\n/g) ?? []).length + 1; + + return { + type, + message: { + id: props.parentMessage?.id ?? '', + type: props.parentMessage?.type ?? 'human', + time: props.parentMessage?.time ?? 0, + metadata: + props.parentMessage && 'metadata' in props.parentMessage + ? props.parentMessage.metadata + : {} + }, + code: { + charCount, + lineCount + } + }; +} + function InsertAboveButton(props: ToolbarButtonProps) { + const telemetryHandler = useTelemetry(); const tooltip = props.activeCellExists ? 'Insert above active cell' : 'Insert above active cell (no active cell)'; @@ -61,7 +104,16 @@ function InsertAboveButton(props: ToolbarButtonProps) { return ( props.activeCellManager.insertAbove(props.content)} + onClick={() => { + props.activeCellManager.insertAbove(props.code); + + try { + telemetryHandler.onEvent(buildTelemetryEvent('insert-above', props)); + } catch (e) { + console.error(e); + return; + } + }} disabled={!props.activeCellExists} > @@ -70,6 +122,7 @@ function InsertAboveButton(props: ToolbarButtonProps) { } function InsertBelowButton(props: ToolbarButtonProps) { + const telemetryHandler = useTelemetry(); const tooltip = props.activeCellExists ? 'Insert below active cell' : 'Insert below active cell (no active cell)'; @@ -78,23 +131,67 @@ function InsertBelowButton(props: ToolbarButtonProps) { props.activeCellManager.insertBelow(props.content)} + onClick={() => { + props.activeCellManager.insertBelow(props.code); + + try { + telemetryHandler.onEvent(buildTelemetryEvent('insert-below', props)); + } catch (e) { + console.error(e); + return; + } + }} > ); } -function ReplaceButton(props: { value: string }) { +function ReplaceButton(props: ToolbarButtonProps) { + const telemetryHandler = useTelemetry(); const { replace, replaceDisabled, replaceLabel } = useReplace(); return ( replace(props.value)} + onClick={() => { + replace(props.code); + + try { + telemetryHandler.onEvent(buildTelemetryEvent('replace', props)); + } catch (e) { + console.error(e); + return; + } + }} > ); } + +export function CopyButton(props: ToolbarButtonProps): JSX.Element { + const telemetryHandler = useTelemetry(); + const { copy, copyLabel } = useCopy(); + + return ( + { + copy(props.code); + + try { + telemetryHandler.onEvent(buildTelemetryEvent('copy', props)); + } catch (e) { + console.error(e); + return; + } + }} + aria-label="Copy to clipboard" + > + + + ); +} diff --git a/packages/jupyter-ai/src/components/code-blocks/copy-button.tsx b/packages/jupyter-ai/src/components/code-blocks/copy-button.tsx deleted file mode 100644 index c50cb9397..000000000 --- a/packages/jupyter-ai/src/components/code-blocks/copy-button.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; - -import { copyIcon } from '@jupyterlab/ui-components'; - -import { TooltippedIconButton } from '../mui-extras/tooltipped-icon-button'; -import { useCopy } from '../../hooks/use-copy'; - -type CopyButtonProps = { - value: string; -}; - -export function CopyButton(props: CopyButtonProps): JSX.Element { - const { copy, copyLabel } = useCopy(); - - return ( - copy(props.value)} - aria-label="Copy to clipboard" - > - - - ); -} diff --git a/packages/jupyter-ai/src/components/rendermime-markdown.tsx b/packages/jupyter-ai/src/components/rendermime-markdown.tsx index 0e1ca8e9a..976cccb72 100644 --- a/packages/jupyter-ai/src/components/rendermime-markdown.tsx +++ b/packages/jupyter-ai/src/components/rendermime-markdown.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'; import { CodeToolbar, CodeToolbarProps } from './code-blocks/code-toolbar'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; +import { AiService } from '../handler'; const MD_MIME_TYPE = 'text/markdown'; const RENDERMIME_MD_CLASS = 'jp-ai-rendermime-markdown'; @@ -10,6 +11,10 @@ const RENDERMIME_MD_CLASS = 'jp-ai-rendermime-markdown'; type RendermimeMarkdownProps = { markdownStr: string; rmRegistry: IRenderMimeRegistry; + /** + * Reference to the parent message object in the Jupyter AI chat. + */ + parentMessage?: AiService.ChatMessage; /** * Whether the message is complete. This is generally `true` except in the * case where `markdownStr` contains the incomplete contents of a @@ -87,7 +92,10 @@ function RendermimeMarkdownBase(props: RendermimeMarkdownProps): JSX.Element { ); newCodeToolbarDefns.push([ codeToolbarRoot, - { content: preBlock.textContent || '' } + { + code: preBlock.textContent || '', + parentMessage: props.parentMessage + } ]); }); @@ -95,7 +103,12 @@ function RendermimeMarkdownBase(props: RendermimeMarkdownProps): JSX.Element { }; renderContent(); - }, [props.markdownStr, props.complete, props.rmRegistry]); + }, [ + props.markdownStr, + props.complete, + props.rmRegistry, + props.parentMessage + ]); return (
diff --git a/packages/jupyter-ai/src/contexts/index.ts b/packages/jupyter-ai/src/contexts/index.ts index b728173e4..0cc0c017f 100644 --- a/packages/jupyter-ai/src/contexts/index.ts +++ b/packages/jupyter-ai/src/contexts/index.ts @@ -1,3 +1,4 @@ export * from './active-cell-context'; export * from './collaborators-context'; export * from './selection-context'; +export * from './telemetry-context'; diff --git a/packages/jupyter-ai/src/contexts/telemetry-context.tsx b/packages/jupyter-ai/src/contexts/telemetry-context.tsx new file mode 100644 index 000000000..6a812e76c --- /dev/null +++ b/packages/jupyter-ai/src/contexts/telemetry-context.tsx @@ -0,0 +1,41 @@ +import React, { useContext, useState } from 'react'; +import { IJaiTelemetryHandler } from '../tokens'; + +const defaultTelemetryHandler: IJaiTelemetryHandler = { + onEvent: e => { + /* no-op */ + } +}; + +const TelemetryContext = React.createContext( + defaultTelemetryHandler +); + +/** + * Retrieves a reference to the current telemetry handler for Jupyter AI events + * returned by another plugin providing the `IJaiTelemetryHandler` token. If + * none exists, then the default telemetry handler is returned, which does + * nothing when `onEvent()` is called. + */ +export function useTelemetry(): IJaiTelemetryHandler { + return useContext(TelemetryContext); +} + +type TelemetryContextProviderProps = { + telemetryHandler: IJaiTelemetryHandler | null; + children: React.ReactNode; +}; + +export function TelemetryContextProvider( + props: TelemetryContextProviderProps +): JSX.Element { + const [telemetryHandler] = useState( + props.telemetryHandler ?? defaultTelemetryHandler + ); + + return ( + + {props.children} + + ); +} diff --git a/packages/jupyter-ai/src/index.ts b/packages/jupyter-ai/src/index.ts index 94a003fb6..0bce858a2 100644 --- a/packages/jupyter-ai/src/index.ts +++ b/packages/jupyter-ai/src/index.ts @@ -18,7 +18,12 @@ import { ChatHandler } from './chat_handler'; import { buildErrorWidget } from './widgets/chat-error'; import { completionPlugin } from './completions'; import { statusItemPlugin } from './status'; -import { IJaiCompletionProvider, IJaiCore, IJaiMessageFooter } from './tokens'; +import { + IJaiCompletionProvider, + IJaiCore, + IJaiMessageFooter, + IJaiTelemetryHandler +} from './tokens'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { ActiveCellManager } from './contexts/active-cell-context'; import { Signal } from '@lumino/signaling'; @@ -45,7 +50,8 @@ const plugin: JupyterFrontEndPlugin = { ILayoutRestorer, IThemeManager, IJaiCompletionProvider, - IJaiMessageFooter + IJaiMessageFooter, + IJaiTelemetryHandler ], provides: IJaiCore, activate: async ( @@ -55,7 +61,8 @@ const plugin: JupyterFrontEndPlugin = { restorer: ILayoutRestorer | null, themeManager: IThemeManager | null, completionProvider: IJaiCompletionProvider | null, - messageFooter: IJaiMessageFooter | null + messageFooter: IJaiMessageFooter | null, + telemetryHandler: IJaiTelemetryHandler | null ) => { /** * Initialize selection watcher singleton @@ -93,7 +100,8 @@ const plugin: JupyterFrontEndPlugin = { openInlineCompleterSettings, activeCellManager, focusInputSignal, - messageFooter + messageFooter, + telemetryHandler ); } catch (e) { chatWidget = buildErrorWidget(themeManager); diff --git a/packages/jupyter-ai/src/tokens.ts b/packages/jupyter-ai/src/tokens.ts index 9caf1362d..1b1c2eb11 100644 --- a/packages/jupyter-ai/src/tokens.ts +++ b/packages/jupyter-ai/src/tokens.ts @@ -67,3 +67,65 @@ export const IJaiCore = new Token( 'jupyter_ai:core', 'The core implementation of the frontend.' ); + +/** + * An object that describes an interaction event from the user. + * + * Jupyter AI natively emits 4 event types: "copy", "replace", "insert-above", + * or "insert-below". These are all emitted by the code toolbar rendered + * underneath code blocks in the chat sidebar. + */ +export type TelemetryEvent = { + /** + * Type of the interaction. + * + * Frontend extensions may add other event types in custom components. Custom + * events can be emitted via the `useTelemetry()` hook. + */ + type: 'copy' | 'replace' | 'insert-above' | 'insert-below' | string; + /** + * Anonymized details about the message that was interacted with. + */ + message: { + /** + * ID of the message assigned by Jupyter AI. + */ + id: string; + /** + * Type of the message. + */ + type: AiService.ChatMessage['type']; + /** + * UNIX timestamp of the message. + */ + time: number; + /** + * Metadata associated with the message, yielded by the underlying language + * model provider. + */ + metadata?: Record; + }; + /** + * Anonymized details about the code block that was interacted with, if any. + * This is left optional for custom events like message upvote/downvote that + * do not involve interaction with a specific code block. + */ + code?: { + charCount: number; + lineCount: number; + }; +}; + +export interface IJaiTelemetryHandler { + onEvent: (e: TelemetryEvent) => unknown; +} + +/** + * An optional plugin that handles telemetry events emitted via user + * interactions, when provided by a separate labextension. Not provided by + * default. + */ +export const IJaiTelemetryHandler = new Token( + 'jupyter_ai:telemetry', + 'An optional plugin that handles telemetry events emitted via interactions on agent messages, when provided by a separate labextension. Not provided by default.' +); diff --git a/packages/jupyter-ai/src/widgets/chat-sidebar.tsx b/packages/jupyter-ai/src/widgets/chat-sidebar.tsx index e7eee11bd..b1f5b3e38 100644 --- a/packages/jupyter-ai/src/widgets/chat-sidebar.tsx +++ b/packages/jupyter-ai/src/widgets/chat-sidebar.tsx @@ -8,7 +8,11 @@ import { Chat } from '../components/chat'; import { chatIcon } from '../icons'; import { SelectionWatcher } from '../selection-watcher'; import { ChatHandler } from '../chat_handler'; -import { IJaiCompletionProvider, IJaiMessageFooter } from '../tokens'; +import { + IJaiCompletionProvider, + IJaiMessageFooter, + IJaiTelemetryHandler +} from '../tokens'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import type { ActiveCellManager } from '../contexts/active-cell-context'; @@ -22,7 +26,8 @@ export function buildChatSidebar( openInlineCompleterSettings: () => void, activeCellManager: ActiveCellManager, focusInputSignal: ISignal, - messageFooter: IJaiMessageFooter | null + messageFooter: IJaiMessageFooter | null, + telemetryHandler: IJaiTelemetryHandler | null ): ReactWidget { const ChatWidget = ReactWidget.create( ); ChatWidget.id = 'jupyter-ai::chat';