diff --git a/jupyter_chat/handlers.py b/jupyter_chat/handlers.py index 11d3144..4a7ef55 100644 --- a/jupyter_chat/handlers.py +++ b/jupyter_chat/handlers.py @@ -150,11 +150,13 @@ async def on_message(self, message): return # message broadcast to chat clients - chat_message_id = str(uuid.uuid4()) + if not chat_request.id: + chat_request.id = str(uuid.uuid4()) + chat_message = ChatMessage( - id=chat_message_id, + id=chat_request.id, time=time.time(), - body=chat_request.prompt, + body=chat_request.body, sender=self.chat_client, ) diff --git a/jupyter_chat/models.py b/jupyter_chat/models.py index 64ea2ab..95cff48 100644 --- a/jupyter_chat/models.py +++ b/jupyter_chat/models.py @@ -8,7 +8,8 @@ # the type of message used to chat with the agent class ChatRequest(BaseModel): - prompt: str + body: str + id: str class ChatUser(BaseModel): diff --git a/package.json b/package.json index 95f4cf0..25a00c4 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,10 @@ "@jupyterlab/rendermime": "^4.0.5", "@jupyterlab/services": "^7.0.5", "@jupyterlab/ui-components": "^4.0.5", - "@mui/icons-material": "5.11.0", + "@lumino/coreutils": "^2.1.2", + "@lumino/disposable": "^2.1.2", + "@lumino/signaling": "^2.1.2", + "@mui/icons-material": "^5.11.0", "@mui/material": "^5.11.0", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/src/__tests__/chat-settings.spec.ts b/src/__tests__/chat-settings.spec.ts deleted file mode 100644 index d265c77..0000000 --- a/src/__tests__/chat-settings.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { minifyPatchObject } from '../components/settings/minify'; - -const COMPLEX_OBJECT = { - primitive: 0, - array: ['a'], - object: { nested: { field: 0 } } -}; - -describe('minifyPatchObject', () => { - test('returns empty object if patch is identical', () => { - const obj = COMPLEX_OBJECT; - const patch = JSON.parse(JSON.stringify(obj)); - - expect(minifyPatchObject(obj, patch)).toEqual({}); - }); - - test('returns empty object if patch is empty', () => { - expect(minifyPatchObject(COMPLEX_OBJECT, {})).toEqual({}); - }); - - test('returns patch if object is empty', () => { - expect(minifyPatchObject({}, COMPLEX_OBJECT)).toEqual(COMPLEX_OBJECT); - }); - - test('should remove unchanged props from patch', () => { - const obj = { - unchanged: 'foo', - changed: 'bar', - nested: { - unchanged: 'foo', - changed: 'bar' - } - }; - const patch = { - unchanged: 'foo', - changed: 'baz', - nested: { - unchanged: 'foo', - changed: 'baz' - } - }; - - expect(minifyPatchObject(obj, patch)).toEqual({ - changed: 'baz', - nested: { - changed: 'baz' - } - }); - }); - - test('defers to patch object when property types mismatch', () => { - const obj = { - api_keys: ['ANTHROPIC_API_KEY'] - }; - const patch = { - api_keys: { - OPENAI_API_KEY: 'foobar' - } - }; - - expect(minifyPatchObject(obj, patch)).toEqual(patch); - }); -}); diff --git a/src/components/chat-input.tsx b/src/components/chat-input.tsx index 22fe899..cd66ce6 100644 --- a/src/components/chat-input.tsx +++ b/src/components/chat-input.tsx @@ -10,15 +10,7 @@ import { } from '@mui/material'; import SendIcon from '@mui/icons-material/Send'; -type ChatInputProps = { - value: string; - onChange: (newValue: string) => unknown; - onSend: () => unknown; - sendWithShiftEnter: boolean; - sx?: SxProps; -}; - -export function ChatInput(props: ChatInputProps): JSX.Element { +export function ChatInput(props: ChatInput.IProps): JSX.Element { function handleKeyDown(event: React.KeyboardEvent) { if ( event.key === 'Enter' && @@ -77,3 +69,19 @@ export function ChatInput(props: ChatInputProps): JSX.Element { ); } + +/** + * The chat input namespace. + */ +export namespace ChatInput { + /** + * The properties of the react element. + */ + export interface IProps { + value: string; + onChange: (newValue: string) => unknown; + onSend: () => unknown; + sendWithShiftEnter: boolean; + sx?: SxProps; + } +} diff --git a/src/components/chat-messages.tsx b/src/components/chat-messages.tsx index 02953dc..880388f 100644 --- a/src/components/chat-messages.tsx +++ b/src/components/chat-messages.tsx @@ -4,14 +4,14 @@ import type { SxProps, Theme } from '@mui/material'; import React, { useState, useEffect } from 'react'; import { RendermimeMarkdown } from './rendermime-markdown'; -import { ChatService } from '../services'; +import { IChatMessage, IUser } from '../types'; type ChatMessagesProps = { rmRegistry: IRenderMimeRegistry; - messages: ChatService.IChatMessage[]; + messages: IChatMessage[]; }; -export type ChatMessageHeaderProps = ChatService.IUser & { +export type ChatMessageHeaderProps = IUser & { timestamp: string; sx?: SxProps; }; diff --git a/src/components/chat-settings.tsx b/src/components/chat-settings.tsx deleted file mode 100644 index 0949882..0000000 --- a/src/components/chat-settings.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { Box } from '@mui/system'; -import { - Alert, - Button, - FormControl, - FormControlLabel, - FormLabel, - Radio, - RadioGroup, - CircularProgress -} from '@mui/material'; -import React, { useEffect, useState } from 'react'; - -import { useStackingAlert } from './mui-extras/stacking-alert'; -import { ServerInfoState, useServerInfo } from './settings/use-server-info'; -import { minifyUpdate } from './settings/minify'; -import { ChatService } from '../services'; - -/** - * Component that returns the settings view in the chat panel. - */ -export function ChatSettings(): JSX.Element { - // state fetched on initial render - const server = useServerInfo(); - - // initialize alert helper - const alert = useStackingAlert(); - - const [sendWse, setSendWse] = useState(false); - - // whether the form is currently saving - const [saving, setSaving] = useState(false); - - /** - * Effect: initialize inputs after fetching server info. - */ - useEffect(() => { - if (server.state !== ServerInfoState.Ready) { - return; - } - setSendWse(server.config.send_with_shift_enter); - }, [server]); - - const handleSave = async () => { - // compress fields with JSON values - if (server.state !== ServerInfoState.Ready) { - return; - } - - let updateRequest: ChatService.UpdateConfigRequest = { - send_with_shift_enter: sendWse - }; - updateRequest = minifyUpdate(server.config, updateRequest); - updateRequest.last_read = server.config.last_read; - - setSaving(true); - try { - await ChatService.updateConfig(updateRequest); - } catch (e) { - console.error(e); - const msg = - e instanceof Error || typeof e === 'string' - ? e.toString() - : 'An unknown error occurred. Check the console for more details.'; - alert.show('error', msg); - return; - } finally { - setSaving(false); - } - await server.refetchAll(); - alert.show('success', 'Settings saved successfully.'); - }; - - if (server.state === ServerInfoState.Loading) { - return ( - - - - ); - } - - if (server.state === ServerInfoState.Error) { - return ( - - - {server.error || - 'An unknown error occurred. Check the console for more details.'} - - - ); - } - - return ( - - {/* Input */} -

Input

- - - When writing a message, press Enter to: - - { - setSendWse(e.target.value === 'newline'); - }} - > - } - label="Send the message" - /> - } - label={ - <> - Start a new line (use Shift+Enter to send) - - } - /> - - - - - - {alert.jsx} -
- ); -} diff --git a/src/components/chat.tsx b/src/components/chat.tsx index 06434c2..42a7ff6 100644 --- a/src/components/chat.tsx +++ b/src/components/chat.tsx @@ -1,4 +1,3 @@ -import type { IThemeManager } from '@jupyterlab/apputils'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import SettingsIcon from '@mui/icons-material/Settings'; @@ -9,64 +8,58 @@ import React, { useState, useEffect } from 'react'; import { JlThemeProvider } from './jl-theme-provider'; import { ChatMessages } from './chat-messages'; import { ChatInput } from './chat-input'; -import { ChatSettings } from './chat-settings'; -import { ChatHandler } from '../chat-handler'; import { ScrollContainer } from './scroll-container'; -import { ChatService } from '../services'; +import { IChatModel } from '../model'; +import { IChatMessage, IMessage } from '../types'; +import { IThemeManager } from '@jupyterlab/apputils'; type ChatBodyProps = { - chatHandler: ChatHandler; + chatModel: IChatModel; rmRegistry: IRenderMimeRegistry; }; function ChatBody({ - chatHandler, + chatModel, rmRegistry: renderMimeRegistry }: ChatBodyProps): JSX.Element { - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); - const [sendWithShiftEnter, setSendWithShiftEnter] = useState(true); /** * Effect: fetch history and config on initial render */ useEffect(() => { async function fetchHistory() { - try { - const [history, config] = await Promise.all([ - chatHandler.getHistory(), - ChatService.getConfig() - ]); - setSendWithShiftEnter(config.send_with_shift_enter ?? false); - setMessages(history.messages); - } catch (e) { - console.error(e); + if (!chatModel.getHistory) { + return; } + chatModel + .getHistory() + .then(history => setMessages(history.messages)) + .catch(e => console.error(e)); } fetchHistory(); - }, [chatHandler]); + }, [chatModel]); /** * Effect: listen to chat messages */ useEffect(() => { - function handleChatEvents(message: ChatService.IMessage) { - if (message.type === 'connection') { - return; - } else if (message.type === 'clear') { + function handleChatEvents(_: IChatModel, message: IMessage) { + if (message.type === 'clear') { setMessages([]); return; + } else if (message.type === 'msg') { + setMessages(messageGroups => [...messageGroups, message]); } - - setMessages(messageGroups => [...messageGroups, message]); } - chatHandler.addListener(handleChatEvents); + chatModel.incomingMessage.connect(handleChatEvents); return function cleanup() { - chatHandler.removeListener(handleChatEvents); + chatModel.incomingMessage.disconnect(handleChatEvents); }; - }, [chatHandler]); + }, [chatModel]); // no need to append to messageGroups imperatively here. all of that is // handled by the listeners registered in the effect hooks above. @@ -74,7 +67,7 @@ function ChatBody({ setInput(''); // send message to backend - chatHandler.sendMessage({ prompt: input }); + chatModel.addMessage({ body: input }); }; return ( @@ -93,26 +86,16 @@ function ChatBody({ paddingBottom: 0, borderTop: '1px solid var(--jp-border-color1)' }} - sendWithShiftEnter={sendWithShiftEnter} + sendWithShiftEnter={chatModel.config.sendWithShiftEnter ?? false} /> ); } -export type ChatProps = { - chatHandler: ChatHandler; - themeManager: IThemeManager | null; - rmRegistry: IRenderMimeRegistry; - chatView?: ChatView; -}; - -enum ChatView { - Chat, - Settings -} - -export function Chat(props: ChatProps): JSX.Element { - const [view, setView] = useState(props.chatView || ChatView.Chat); +export function Chat(props: Chat.IOptions): JSX.Element { + const [view, setView] = useState( + props.chatView || Chat.ChatView.Chat + ); console.log('Instantiate a chat'); return ( @@ -130,15 +113,15 @@ export function Chat(props: ChatProps): JSX.Element { > {/* top bar */} - {view !== ChatView.Chat ? ( - setView(ChatView.Chat)}> + {view !== Chat.ChatView.Chat ? ( + setView(Chat.ChatView.Chat)}> ) : ( )} - {view === ChatView.Chat ? ( - setView(ChatView.Settings)}> + {view === Chat.ChatView.Chat && props.settingsPanel ? ( + setView(Chat.ChatView.Settings)}> ) : ( @@ -146,14 +129,53 @@ export function Chat(props: ChatProps): JSX.Element { )} {/* body */} - {view === ChatView.Chat && ( - + {view === Chat.ChatView.Chat && ( + + )} + {view === Chat.ChatView.Settings && props.settingsPanel && ( + )} - {view === ChatView.Settings && } ); } + +/** + * The chat UI namespace + */ +export namespace Chat { + /** + * The options to build the Chat UI. + */ + export interface IOptions { + /** + * The chat model. + */ + chatModel: IChatModel; + /** + * The theme manager. + */ + themeManager: IThemeManager | null; + /** + * The rendermime registry. + */ + rmRegistry: IRenderMimeRegistry; + /** + * The view to render. + */ + chatView?: ChatView; + /** + * A settings panel that can be used for dedicated settings (e.g. jupyter-ai) + */ + settingsPanel?: () => JSX.Element; + } + + /** + * The view to render. + * The settings view is available only if the settings panel is provided in options. + */ + export enum ChatView { + Chat, + Settings + } +} diff --git a/src/components/settings/minify.ts b/src/components/settings/minify.ts deleted file mode 100644 index daff8f1..0000000 --- a/src/components/settings/minify.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ChatService } from '../../services'; - -/** - * Function that minimizes the `UpdateConfigRequest` object prior to submission. - * Removes properties with values identical to those specified in the server - * configuration. - */ -export function minifyUpdate( - config: ChatService.DescribeConfigResponse, - update: ChatService.UpdateConfigRequest -): ChatService.UpdateConfigRequest { - return minifyPatchObject(config, update) as ChatService.UpdateConfigRequest; -} - -/** - * Function that removes all properties from `patch` that have identical values - * to `obj` recursively. - */ -export function minifyPatchObject( - obj: Record, - patch: Record -): Record { - const diffObj: Record = {}; - for (const key in patch) { - if (!(key in obj) || typeof obj[key] !== typeof patch[key]) { - // if key is not present in oldObj, or if the value types do not match, - // use the value of `patch`. - diffObj[key] = patch[key]; - continue; - } - - const objVal = obj[key]; - const patchVal = patch[key]; - if (Array.isArray(objVal) && Array.isArray(patchVal)) { - // if objects are both arrays but are not equal, then use the value - const areNotEqual = - objVal.length !== patchVal.length || - !objVal.every((objVal_i, i) => objVal_i === patchVal[i]); - if (areNotEqual) { - diffObj[key] = patchVal; - } - } else if (typeof patchVal === 'object') { - // if the value is an object, run `diffObjects` recursively. - const childPatch = minifyPatchObject(objVal, patchVal); - const isNonEmpty = !!Object.keys(childPatch)?.length; - if (isNonEmpty) { - diffObj[key] = childPatch; - } - } else if (objVal !== patchVal) { - // otherwise, use the value of `patch` only if it differs. - diffObj[key] = patchVal; - } - } - - return diffObj; -} diff --git a/src/components/settings/use-server-info.ts b/src/components/settings/use-server-info.ts deleted file mode 100644 index 204dcb6..0000000 --- a/src/components/settings/use-server-info.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; -import { ChatService } from '../../services'; - -type ServerInfoProperties = { - config: ChatService.DescribeConfigResponse; -}; - -type ServerInfoMethods = { - refetchAll: () => Promise; -}; - -export enum ServerInfoState { - /** - * Server info is being fetched. - */ - Loading, - /** - * Unable to retrieve server info. - */ - Error, - /** - * Server info was loaded successfully. - */ - Ready -} - -type ServerInfoLoading = { state: ServerInfoState.Loading }; -type ServerInfoError = { - state: ServerInfoState.Error; - error: string; -}; -type ServerInfoReady = { state: ServerInfoState.Ready } & ServerInfoProperties & - ServerInfoMethods; - -type ServerInfo = ServerInfoLoading | ServerInfoError | ServerInfoReady; - -/** - * A hook that fetches the current configuration and provider lists from the - * server. Returns a `ServerInfo` object that includes methods. - */ -export function useServerInfo(): ServerInfo { - const [state, setState] = useState(ServerInfoState.Loading); - const [serverInfoProps, setServerInfoProps] = - useState(); - const [error, setError] = useState(''); - - const fetchServerInfo = useCallback(async () => { - try { - const config = await ChatService.getConfig(); - setServerInfoProps({ config }); - - setState(ServerInfoState.Ready); - } catch (e) { - console.error(e); - if (e instanceof Error) { - setError(e.toString()); - } else { - setError('An unknown error occurred.'); - } - setState(ServerInfoState.Error); - } - }, []); - - /** - * Effect: fetch server info on initial render - */ - useEffect(() => { - fetchServerInfo(); - }, []); - - return useMemo(() => { - if (state === ServerInfoState.Loading) { - return { state }; - } - - if (state === ServerInfoState.Error || !serverInfoProps) { - return { state: ServerInfoState.Error, error }; - } - - return { - state, - ...serverInfoProps, - refetchAll: fetchServerInfo - }; - }, [state, serverInfoProps, error]); -} diff --git a/src/components/settings/validator.ts b/src/components/settings/validator.ts deleted file mode 100644 index cea6ad8..0000000 --- a/src/components/settings/validator.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class SettingsValidator { - constructor() {} -} diff --git a/src/handler.ts b/src/handlers/handler.ts similarity index 100% rename from src/handler.ts rename to src/handlers/handler.ts diff --git a/src/chat-handler.ts b/src/handlers/websocket-handler.ts similarity index 59% rename from src/chat-handler.ts rename to src/handlers/websocket-handler.ts index 04cfe98..1d0d272 100644 --- a/src/chat-handler.ts +++ b/src/handlers/websocket-handler.ts @@ -1,27 +1,34 @@ import { URLExt } from '@jupyterlab/coreutils'; -import { IDisposable } from '@lumino/disposable'; import { ServerConnection } from '@jupyterlab/services'; +import { UUID } from '@lumino/coreutils'; import { requestAPI } from './handler'; -import { ChatService } from './services'; +import { ChatModel, IChatModel } from '../model'; +import { IChatHistory, IMessage, INewMessage } from '../types'; const CHAT_SERVICE_URL = 'api/chat'; -export class ChatHandler implements IDisposable { +export type ConnectionMessage = { + type: 'connection'; + client_id: string; +}; + +type GenericMessage = IMessage | ConnectionMessage; + +/** + * An implementation of the chat model based on websocket handler. + */ +export class WebSocketHandler extends ChatModel { /** * The server settings used to make API requests. */ readonly serverSettings: ServerConnection.ISettings; - /** - * ID of the connection. Requires `await initialize()`. - */ - id = ''; - /** * Create a new chat handler. */ - constructor(options: ChatHandler.IOptions = {}) { + constructor(options: WebSocketHandler.IOptions = {}) { + super(options); this.serverSettings = options.serverSettings ?? ServerConnection.makeSettings(); } @@ -31,7 +38,7 @@ export class ChatHandler implements IDisposable { * resolved when server acknowledges connection and sends the client ID. This * must be awaited before calling any other method. */ - public async initialize(): Promise { + async initialize(): Promise { await this._initialize(); } @@ -39,28 +46,16 @@ export class ChatHandler implements IDisposable { * Sends a message across the WebSocket. Promise resolves to the message ID * when the server sends the same message back, acknowledging receipt. */ - public sendMessage(message: ChatService.ChatRequest): Promise { + addMessage(message: INewMessage): Promise { + message.id = UUID.uuid4(); return new Promise(resolve => { this._socket?.send(JSON.stringify(message)); - this._sendResolverQueue.push(resolve); + this._sendResolverQueue.set(message.id!, resolve); }); } - public addListener(handler: (message: ChatService.IMessage) => void): void { - this._listeners.push(handler); - } - - public removeListener( - handler: (message: ChatService.IMessage) => void - ): void { - const index = this._listeners.indexOf(handler); - if (index > -1) { - this._listeners.splice(index, 1); - } - } - - public async getHistory(): Promise { - let data: ChatService.ChatHistory = { messages: [] }; + async getHistory(): Promise { + let data: IChatHistory = { messages: [] }; try { data = await requestAPI('history', { method: 'GET' @@ -71,22 +66,11 @@ export class ChatHandler implements IDisposable { return data; } - /** - * Whether the chat handler is disposed. - */ - get isDisposed(): boolean { - return this._isDisposed; - } - /** * Dispose the chat handler. */ dispose(): void { - if (this.isDisposed) { - return; - } - this._isDisposed = true; - this._listeners = []; + super.dispose(); // Clean up socket. const socket = this._socket; @@ -100,35 +84,15 @@ export class ChatHandler implements IDisposable { } } - /** - * A function called before transferring the message to the panel(s). - * Can be useful if some actions are required on the message. - */ - protected formatChatMessage( - message: ChatService.IChatMessage - ): ChatService.IChatMessage { - return message; - } - - private _onMessage(message: ChatService.IMessage): void { + onMessage(message: IMessage): void { // resolve promise from `sendMessage()` if (message.type === 'msg' && message.sender.id === this.id) { - this._sendResolverQueue.shift()?.(message.id); - } - - if (message.type === 'msg') { - message = this.formatChatMessage(message as ChatService.IChatMessage); + this._sendResolverQueue.get(message.id)?.(true); } - // call listeners in serial - this._listeners.forEach(listener => listener(message)); + super.onMessage(message); } - /** - * Queue of Promise resolvers pushed onto by `send()` - */ - private _sendResolverQueue: ((value: string) => void)[] = []; - private _onClose(e: CloseEvent, reject: any) { reject(new Error('Chat UI websocket disconnected')); console.error('Chat UI websocket disconnected'); @@ -156,31 +120,36 @@ export class ChatHandler implements IDisposable { socket.onclose = e => this._onClose(e, reject); socket.onerror = e => reject(e); socket.onmessage = msg => - msg.data && this._onMessage(JSON.parse(msg.data)); + msg.data && this.onMessage(JSON.parse(msg.data)); - const listenForConnection = (message: ChatService.IMessage) => { + const listenForConnection = (_: IChatModel, message: GenericMessage) => { if (message.type !== 'connection') { return; } this.id = message.client_id; resolve(); - this.removeListener(listenForConnection); + this.incomingMessage.disconnect(listenForConnection); }; - this.addListener(listenForConnection); + this.incomingMessage.connect(listenForConnection); }); } - private _isDisposed = false; private _socket: WebSocket | null = null; - private _listeners: ((msg: any) => void)[] = []; + /** + * Queue of Promise resolvers pushed onto by `send()` + */ + private _sendResolverQueue = new Map void>(); } -export namespace ChatHandler { +/** + * The websocket namespace. + */ +export namespace WebSocketHandler { /** * The instantiation options for a data registry handler. */ - export interface IOptions { + export interface IOptions extends ChatModel.IOptions { serverSettings?: ServerConnection.ISettings; } } diff --git a/src/index.ts b/src/index.ts index 263e4aa..84630cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ -export * from './chat-handler'; -export * from './services'; +export * from './handlers/websocket-handler'; +export * from './model'; +export * from './types'; export * from './widgets/chat-error'; export * from './widgets/chat-sidebar'; +export * from './widgets/chat-widget'; diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..482e8fb --- /dev/null +++ b/src/model.ts @@ -0,0 +1,214 @@ +import { IDisposable } from '@lumino/disposable'; +import { ISignal, Signal } from '@lumino/signaling'; + +import { + IChatHistory, + INewMessage, + IChatMessage, + IConfig, + IMessage +} from './types'; + +/** + * The chat model interface. + */ +export interface IChatModel extends IDisposable { + /** + * The chat model ID. + */ + id: string; + + /** + * The configuration for the chat panel. + */ + config: IConfig; + + /** + * The signal emitted when a new message is received. + */ + get incomingMessage(): ISignal; + + /** + * The signal emitted when a message is updated. + */ + get messageUpdated(): ISignal; + + /** + * The signal emitted when a message is updated. + */ + get messageDeleted(): ISignal; + + /** + * Send a message, to be defined depending on the chosen technology. + * Default to no-op. + * + * @param message - the message to send. + * @returns whether the message has been sent or not, or nothing if not needed. + */ + addMessage(message: INewMessage): Promise | boolean | void; + + /** + * Optional, to update a message from the chat. + * + * @param id - the unique ID of the message. + * @param message - the message to update. + */ + updateMessage?( + id: string, + message: INewMessage + ): Promise | boolean | void; + + /** + * Optional, to get messages history. + */ + getHistory?(): Promise; + + /** + * Dispose the chat model. + */ + dispose(): void; + + /** + * Whether the chat handler is disposed. + */ + isDisposed: boolean; + + /** + * Function to call when a message is received. + * + * @param message - the new message, containing user information and body. + */ + onMessage(message: IMessage): void; + + /** + * Function to call when a message is updated. + * + * @param message - the message updated, containing user information and body. + */ + onMessageUpdated?(message: IMessage): void; +} + +/** + * The default chat model implementation. + * It is not able to send or update a message by itself, since it depends on the + * chosen technology. + */ +export class ChatModel implements IChatModel { + /** + * Create a new chat model. + */ + constructor(options: ChatModel.IOptions = {}) { + this._config = options.config ?? {}; + } + + /** + * The chat model ID. + */ + get id(): string { + return this._id; + } + set id(value: string) { + this._id = value; + } + + /** + * The chat settings. + */ + get config(): IConfig { + return this._config; + } + set config(value: Partial) { + this._config = { ...this._config, ...value }; + } + + /** + * + * The signal emitted when a new message is received. + */ + get incomingMessage(): ISignal { + return this._incomingMessage; + } + + /** + * The signal emitted when a message is updated. + */ + get messageUpdated(): ISignal { + return this._messageUpdated; + } + + /** + * The signal emitted when a message is updated. + */ + get messageDeleted(): ISignal { + return this._messageDeleted; + } + + /** + * Send a message, to be defined depending on the chosen technology. + * Default to no-op. + * + * @param message - the message to send. + * @returns whether the message has been sent or not. + */ + addMessage(message: INewMessage): Promise | boolean | void {} + + /** + * Dispose the chat model. + */ + dispose(): void { + if (this.isDisposed) { + return; + } + this._isDisposed = true; + } + + /** + * Whether the chat handler is disposed. + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * A function called before transferring the message to the panel(s). + * Can be useful if some actions are required on the message. + */ + protected formatChatMessage(message: IChatMessage): IChatMessage { + return message; + } + + /** + * Function to call when a message is received. + * + * @param message - the message with user information and body. + */ + onMessage(message: IMessage): void { + if (message.type === 'msg') { + message = this.formatChatMessage(message as IChatMessage); + } + + this._incomingMessage.emit(message); + } + + private _id: string = ''; + private _config: IConfig; + private _isDisposed = false; + private _incomingMessage = new Signal(this); + private _messageUpdated = new Signal(this); + private _messageDeleted = new Signal(this); +} + +/** + * The chat model namespace. + */ +export namespace ChatModel { + /** + * The instantiation options for a ChatModel. + */ + export interface IOptions { + /** + * Initial config for the chat widget. + */ + config?: IConfig; + } +} diff --git a/src/services.ts b/src/services.ts deleted file mode 100644 index f92212f..0000000 --- a/src/services.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { requestAPI } from './handler'; - -export namespace ChatService { - export interface IUser { - id: string; - username?: string; - name?: string; - display_name?: string; - initials?: string; - color?: string; - avatar_url?: string; - } - - export interface IChatMessage { - type: 'msg'; - body: string; - id: string; - time: number; - sender: IUser; - } - - export type ConnectionMessage = { - type: 'connection'; - client_id: string; - }; - - export type ClearMessage = { - type: 'clear'; - }; - - export type IMessage = IChatMessage | ConnectionMessage | ClearMessage; - - export type ChatHistory = { - messages: IChatMessage[]; - }; - - export type ChatRequest = { - prompt: string; - }; - - export type DescribeConfigResponse = { - send_with_shift_enter: boolean; - last_read: number; - }; - - export type UpdateConfigRequest = { - send_with_shift_enter?: boolean; - last_read?: number; - }; - - export async function getConfig(): Promise { - return requestAPI('config'); - } - - export async function updateConfig( - config: UpdateConfigRequest - ): Promise { - return requestAPI('config', { - method: 'POST', - body: JSON.stringify(config) - }); - } -} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5a81a52 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,57 @@ +/** + * The user description. + */ +export interface IUser { + id: string; + username?: string; + name?: string; + display_name?: string; + initials?: string; + color?: string; + avatar_url?: string; +} + +/** + * The configuration interface. + */ +export interface IConfig { + sendWithShiftEnter?: boolean; + lastRead?: number; +} + +/** + * The chat message decription. + */ +export interface IChatMessage { + type: 'msg'; + body: string; + id: string; + time: number; + sender: IUser; +} + +export type IClearMessage = { + type: 'clear'; +}; + +export type IMessage = IChatMessage | IClearMessage; + +/** + * The chat history interface. + */ +export interface IChatHistory { + messages: IChatMessage[]; +} + +/** + * The content of a new message. + */ +export interface INewMessage { + body: string; + id?: string; +} + +/** + * An empty interface to describe optional settings taht could be fetched from server. + */ +export interface ISettings {} diff --git a/src/widgets/chat-sidebar.tsx b/src/widgets/chat-sidebar.tsx index 6e12259..17e7b0c 100644 --- a/src/widgets/chat-sidebar.tsx +++ b/src/widgets/chat-sidebar.tsx @@ -3,17 +3,17 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import React from 'react'; import { Chat } from '../components/chat'; -import { ChatHandler } from '../chat-handler'; import { chatIcon } from '../icons'; +import { IChatModel } from '../model'; export function buildChatSidebar( - chatHandler: ChatHandler, + chatModel: IChatModel, themeManager: IThemeManager | null, rmRegistry: IRenderMimeRegistry ): ReactWidget { const ChatWidget = ReactWidget.create( diff --git a/src/widgets/chat-widget.tsx b/src/widgets/chat-widget.tsx new file mode 100644 index 0000000..859aee1 --- /dev/null +++ b/src/widgets/chat-widget.tsx @@ -0,0 +1,39 @@ +import { IThemeManager, ReactWidget } from '@jupyterlab/apputils'; +import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; +import React from 'react'; + +import { Chat } from '../components/chat'; +import { chatIcon } from '../icons'; +import { IChatModel } from '../model'; + +export class ChatWidget extends ReactWidget { + constructor(options: Chat.IOptions) { + super(); + + this.id = 'jupyter-chat::widget'; + this.title.icon = chatIcon; + this.title.caption = 'Jupyter Chat'; // TODO: i18n + + this._chatModel = options.chatModel; + this._themeManager = options.themeManager; + this._rmRegistry = options.rmRegistry; + } + + render() { + return ( + + ); + } + + private _chatModel: IChatModel; + private _themeManager: IThemeManager | null; + private _rmRegistry: IRenderMimeRegistry; +} + +export namespace ChatWidget { + export interface IOptions extends Chat.IOptions {} +} diff --git a/yarn.lock b/yarn.lock index 3c73bea..14b1092 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1310,7 +1310,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": +"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": version: 7.23.9 resolution: "@babel/runtime@npm:7.23.9" dependencies: @@ -2253,7 +2253,10 @@ __metadata: "@jupyterlab/services": ^7.0.5 "@jupyterlab/testutils": ^4.0.0 "@jupyterlab/ui-components": ^4.0.5 - "@mui/icons-material": 5.11.0 + "@lumino/coreutils": ^2.1.2 + "@lumino/disposable": ^2.1.2 + "@lumino/signaling": ^2.1.2 + "@mui/icons-material": ^5.11.0 "@mui/material": ^5.11.0 "@types/jest": ^29.2.0 "@types/json-schema": ^7.0.11 @@ -3564,11 +3567,11 @@ __metadata: languageName: node linkType: hard -"@mui/icons-material@npm:5.11.0": - version: 5.11.0 - resolution: "@mui/icons-material@npm:5.11.0" +"@mui/icons-material@npm:^5.11.0": + version: 5.15.11 + resolution: "@mui/icons-material@npm:5.15.11" dependencies: - "@babel/runtime": ^7.20.6 + "@babel/runtime": ^7.23.9 peerDependencies: "@mui/material": ^5.0.0 "@types/react": ^17.0.0 || ^18.0.0 @@ -3576,7 +3579,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 764c1185b3432f0228f3c5217b0e218b10f106fa96d305dfc62c0ef5afd2a7a087b0d664fd0a8171282e195c18d3ee073d5f037901a2bed1a1519a70fbb0501c + checksum: 4403988af419b0ebdbcc61413f58f12fe44dc069d3245cf80aec05fd31fb2f5d38f0d87799aa538f5441c36d87df2f5fbc1168aa03e2704dd423ccd4febf864b languageName: node linkType: hard