From f64f7b6147edec1f24e62be1d6438e965e28a425 Mon Sep 17 00:00:00 2001 From: "David L. Qiu" Date: Fri, 31 May 2024 08:41:58 -0700 Subject: [PATCH 1/7] add REST API for fetching list of available slash commands --- .../jupyter_ai/chat_handlers/base.py | 3 +- packages/jupyter-ai/jupyter_ai/extension.py | 2 + packages/jupyter-ai/jupyter_ai/handlers.py | 48 ++++++++++++++++++- packages/jupyter-ai/jupyter_ai/models.py | 7 +++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/jupyter-ai/jupyter_ai/chat_handlers/base.py b/packages/jupyter-ai/jupyter_ai/chat_handlers/base.py index 1ae80c5c5..83636776e 100644 --- a/packages/jupyter-ai/jupyter_ai/chat_handlers/base.py +++ b/packages/jupyter-ai/jupyter_ai/chat_handlers/base.py @@ -11,6 +11,7 @@ Literal, Optional, Type, + Union, ) from uuid import uuid4 @@ -27,7 +28,7 @@ # Chat handler type, with specific attributes for each class HandlerRoutingType(BaseModel): - routing_method: ClassVar[str] = Literal["slash_command"] + routing_method: ClassVar[Union[Literal["slash_command"]]] = ... """The routing method that sends commands to this handler.""" diff --git a/packages/jupyter-ai/jupyter_ai/extension.py b/packages/jupyter-ai/jupyter_ai/extension.py index 0a66a8b1b..420b72ade 100644 --- a/packages/jupyter-ai/jupyter_ai/extension.py +++ b/packages/jupyter-ai/jupyter_ai/extension.py @@ -30,6 +30,7 @@ GlobalConfigHandler, ModelProviderHandler, RootChatHandler, + SlashCommandsInfoHandler, ) JUPYTERNAUT_AVATAR_ROUTE = JupyternautPersona.avatar_route @@ -45,6 +46,7 @@ class AiExtension(ExtensionApp): (r"api/ai/config/?", GlobalConfigHandler), (r"api/ai/chats/?", RootChatHandler), (r"api/ai/chats/history?", ChatHistoryHandler), + (r"api/ai/chats/slash_commands?", SlashCommandsInfoHandler), (r"api/ai/providers?", ModelProviderHandler), (r"api/ai/providers/embeddings?", EmbeddingsModelProviderHandler), (r"api/ai/completion/inline/?", DefaultInlineCompletionHandler), diff --git a/packages/jupyter-ai/jupyter_ai/handlers.py b/packages/jupyter-ai/jupyter_ai/handlers.py index 976b7df62..d7b9770b3 100644 --- a/packages/jupyter-ai/jupyter_ai/handlers.py +++ b/packages/jupyter-ai/jupyter_ai/handlers.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional import tornado -from jupyter_ai.chat_handlers import BaseChatHandler +from jupyter_ai.chat_handlers import BaseChatHandler, SlashCommandRoutingType from jupyter_ai.config_manager import ConfigManager, KeyEmptyError, WriteConflictError from jupyter_server.base.handlers import APIHandler as BaseAPIHandler from jupyter_server.base.handlers import JupyterHandler @@ -15,7 +15,6 @@ from tornado import web, websocket from tornado.web import HTTPError -from .completions.models import InlineCompletionRequest from .models import ( AgentChatMessage, ChatClient, @@ -27,6 +26,8 @@ HumanChatMessage, ListProvidersEntry, ListProvidersResponse, + ListSlashCommandsEntry, + ListSlashCommandsResponse, Message, UpdateConfigRequest, ) @@ -405,3 +406,46 @@ def delete(self, api_key_name: str): self.config_manager.delete_api_key(api_key_name) except Exception as e: raise HTTPError(500, str(e)) + +class SlashCommandsInfoHandler(BaseAPIHandler): + """List slash commands that are currently available to the user.""" + + @property + def config_manager(self) -> ConfigManager: + return self.settings["jai_config_manager"] + + @property + def chat_handlers(self) -> Dict[str, "BaseChatHandler"]: + return self.settings["jai_chat_handlers"] + + @web.authenticated + def get(self): + response = ListSlashCommandsResponse() + + # if no selected LLM, return an empty response + if not self.config_manager.lm_provider: + self.finish(response.json()) + return + + for id, chat_handler in self.chat_handlers.items(): + # filter out any chat handler that is not a slash command + if id == "default" or chat_handler.routing_type.routing_method != "slash_command": + continue + + # hint the type of this attribute + routing_type: SlashCommandRoutingType = chat_handler.routing_type + + # filter out any chat handler that is unsupported by the current LLM + if "/" + routing_type.slash_id in self.config_manager.lm_provider.unsupported_slash_commands: + continue + + response.slash_commands.append( + ListSlashCommandsEntry( + slash_id=routing_type.slash_id, + description=chat_handler.help + ) + ) + + # sort slash commands by slash id and deliver the response + response.slash_commands.sort(key=lambda sc: sc.slash_id) + self.finish(response.json()) diff --git a/packages/jupyter-ai/jupyter_ai/models.py b/packages/jupyter-ai/jupyter_ai/models.py index 6742a922d..c4ffa7437 100644 --- a/packages/jupyter-ai/jupyter_ai/models.py +++ b/packages/jupyter-ai/jupyter_ai/models.py @@ -162,3 +162,10 @@ class GlobalConfig(BaseModel): api_keys: Dict[str, str] completions_model_provider_id: Optional[str] completions_fields: Dict[str, Dict[str, Any]] + +class ListSlashCommandsEntry(BaseModel): + slash_id: str + description: str + +class ListSlashCommandsResponse(BaseModel): + slash_commands: List[ListSlashCommandsEntry] = [] From 740ea63094140c1fa377a3f62ab6aeebc40cb5ee Mon Sep 17 00:00:00 2001 From: "David L. Qiu" Date: Fri, 31 May 2024 10:04:03 -0700 Subject: [PATCH 2/7] implement slash command autocomplete UI --- .../jupyter-ai/src/components/chat-input.tsx | 248 +++++++++++++++--- packages/jupyter-ai/src/handler.ts | 13 + 2 files changed, 228 insertions(+), 33 deletions(-) diff --git a/packages/jupyter-ai/src/components/chat-input.tsx b/packages/jupyter-ai/src/components/chat-input.tsx index ca8d4d30d..dfa7e286b 100644 --- a/packages/jupyter-ai/src/components/chat-input.tsx +++ b/packages/jupyter-ai/src/components/chat-input.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { + Autocomplete, Box, SxProps, TextField, @@ -9,9 +10,21 @@ import { FormControlLabel, Checkbox, IconButton, - InputAdornment + InputAdornment, + Typography } from '@mui/material'; import SendIcon from '@mui/icons-material/Send'; +import { + Download, + FindInPage, + Help, + MoreHoriz, + MenuBook, + School, + HideSource +} from '@mui/icons-material'; + +import { AiService } from '../handler'; type ChatInputProps = { value: string; @@ -26,8 +39,122 @@ type ChatInputProps = { sx?: SxProps; }; +type SlashCommandOption = { + id: string; + label: string; + description: string; +}; + +/** + * List of icons per slash command, shown in the autocomplete popup. + * + * This list of icons should eventually be made configurable. However, it is + * unclear whether custom icons should be defined within a Lumino plugin (in the + * frontend) or served from a static server route (in the backend). + */ +const DEFAULT_SLASH_COMMAND_ICONS: Record = { + ask: , + clear: , + export: , + generate: , + help: , + learn: , + unknown: +}; + +/** + * Renders an option shown in the slash command autocomplete. + */ +function renderSlashCommandOption( + optionProps: React.HTMLAttributes, + option: SlashCommandOption +): JSX.Element { + const icon = + option.id in DEFAULT_SLASH_COMMAND_ICONS + ? DEFAULT_SLASH_COMMAND_ICONS[option.id] + : DEFAULT_SLASH_COMMAND_ICONS.unknown; + + return ( +
  • + {icon} + + + {option.label + ' - '} + + + {option.description} + + +
  • + ); +} + export function ChatInput(props: ChatInputProps): JSX.Element { - function handleKeyDown(event: React.KeyboardEvent) { + const [slashCommandOptions, setSlashCommandOptions] = useState< + SlashCommandOption[] + >([]); + + /** + * Effect: fetch the list of available slash commands from the backend on + * initial mount to populate the slash command autocomplete. + */ + useEffect(() => { + async function getSlashCommands() { + const slashCommands = (await AiService.listSlashCommands()) + .slash_commands; + setSlashCommandOptions( + slashCommands.map(slashCommand => ({ + id: slashCommand.slash_id, + label: '/' + slashCommand.slash_id, + description: slashCommand.description + })) + ); + } + getSlashCommands(); + }, []); + + // whether any option is highlighted in the slash command autocomplete + const [highlighted, setHighlighted] = useState(false); + + // controls whether the slash command autocomplete is open + const [open, setOpen] = useState(false); + + /** + * Effect: Open the autocomplete when the user types a slash into an empty + * chat input. Close the autocomplete and reset the last selected value when + * the user clears the chat input. + */ + useEffect(() => { + if (props.value === '/') { + setOpen(true); + return; + } + + if (props.value === '') { + setOpen(false); + return; + } + }, [props.value]); + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key !== 'Enter') { + return; + } + + // do not send the message if the user was just trying to select a suggested + // slash command from the Autocomplete component. + if (highlighted) { + return; + } + if ( event.key === 'Enter' && ((props.sendWithShiftEnter && event.shiftKey) || @@ -52,36 +179,91 @@ export function ChatInput(props: ChatInputProps): JSX.Element { return ( - - props.onChange(e.target.value)} - fullWidth - variant="outlined" - multiline - onKeyDown={handleKeyDown} - placeholder="Ask Jupyternaut" - InputProps={{ - endAdornment: ( - - - - - - ) - }} - FormHelperTextProps={{ - sx: { marginLeft: 'auto', marginRight: 0 } - }} - helperText={props.value.length > 2 ? helperText : ' '} - /> - + { + props.onChange(newValue); + }} + onHighlightChange={ + /** + * On highlight change: set `highlighted` to whether an option is + * highlighted by the user. + * + * This isn't called when an option is selected for some reason, so we + * need to call `setHighlighted(false)` in `onClose()`. + */ + (_, highlightedOption) => { + setHighlighted(!!highlightedOption); + } + } + onClose={ + /** + * On close: set `highlighted` to `false` and close the popup by + * setting `open` to `false`. + */ + () => { + setHighlighted(false); + setOpen(false); + } + } + // set this to an empty string to prevent the last selected slash + // command from being shown in blue + value="" + open={open} + options={slashCommandOptions} + // hide default extra right padding in the text field + disableClearable + // ensure the autocomplete popup always renders on top + componentsProps={{ + popper: { + placement: 'top' + } + }} + renderOption={renderSlashCommandOption} + ListboxProps={{ + sx: { + '& .MuiAutocomplete-option': { + padding: 2 + } + } + }} + renderInput={params => ( + + + + + + ) + }} + FormHelperTextProps={{ + sx: { marginLeft: 'auto', marginRight: 0 } + }} + helperText={props.value.length > 2 ? helperText : ' '} + /> + )} + /> {props.hasSelection && ( { + return requestAPI('chats/slash_commands'); + } } From ae7c8ec07aa6f16b9d484416d58c213554cd3d3e Mon Sep 17 00:00:00 2001 From: "David L. Qiu" Date: Fri, 31 May 2024 11:05:41 -0700 Subject: [PATCH 3/7] pre-commit --- packages/jupyter-ai/jupyter_ai/handlers.py | 16 +++++++++++----- packages/jupyter-ai/jupyter_ai/models.py | 2 ++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/jupyter-ai/jupyter_ai/handlers.py b/packages/jupyter-ai/jupyter_ai/handlers.py index d7b9770b3..cd3ebabde 100644 --- a/packages/jupyter-ai/jupyter_ai/handlers.py +++ b/packages/jupyter-ai/jupyter_ai/handlers.py @@ -407,6 +407,7 @@ def delete(self, api_key_name: str): except Exception as e: raise HTTPError(500, str(e)) + class SlashCommandsInfoHandler(BaseAPIHandler): """List slash commands that are currently available to the user.""" @@ -417,7 +418,7 @@ def config_manager(self) -> ConfigManager: @property def chat_handlers(self) -> Dict[str, "BaseChatHandler"]: return self.settings["jai_chat_handlers"] - + @web.authenticated def get(self): response = ListSlashCommandsResponse() @@ -429,20 +430,25 @@ def get(self): for id, chat_handler in self.chat_handlers.items(): # filter out any chat handler that is not a slash command - if id == "default" or chat_handler.routing_type.routing_method != "slash_command": + if ( + id == "default" + or chat_handler.routing_type.routing_method != "slash_command" + ): continue # hint the type of this attribute routing_type: SlashCommandRoutingType = chat_handler.routing_type # filter out any chat handler that is unsupported by the current LLM - if "/" + routing_type.slash_id in self.config_manager.lm_provider.unsupported_slash_commands: + if ( + "/" + routing_type.slash_id + in self.config_manager.lm_provider.unsupported_slash_commands + ): continue response.slash_commands.append( ListSlashCommandsEntry( - slash_id=routing_type.slash_id, - description=chat_handler.help + slash_id=routing_type.slash_id, description=chat_handler.help ) ) diff --git a/packages/jupyter-ai/jupyter_ai/models.py b/packages/jupyter-ai/jupyter_ai/models.py index c4ffa7437..bdbbd81b6 100644 --- a/packages/jupyter-ai/jupyter_ai/models.py +++ b/packages/jupyter-ai/jupyter_ai/models.py @@ -163,9 +163,11 @@ class GlobalConfig(BaseModel): completions_model_provider_id: Optional[str] completions_fields: Dict[str, Dict[str, Any]] + class ListSlashCommandsEntry(BaseModel): slash_id: str description: str + class ListSlashCommandsResponse(BaseModel): slash_commands: List[ListSlashCommandsEntry] = [] From 972f1c1a9f30869be5c9cc33a0a81dceda067985 Mon Sep 17 00:00:00 2001 From: "David L. Qiu" Date: Fri, 31 May 2024 12:07:44 -0700 Subject: [PATCH 4/7] shorten /export help message --- packages/jupyter-ai/jupyter_ai/chat_handlers/export.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/jupyter-ai/jupyter_ai/chat_handlers/export.py b/packages/jupyter-ai/jupyter_ai/chat_handlers/export.py index 54b81c5ef..3bde63018 100644 --- a/packages/jupyter-ai/jupyter_ai/chat_handlers/export.py +++ b/packages/jupyter-ai/jupyter_ai/chat_handlers/export.py @@ -10,8 +10,8 @@ class ExportChatHandler(BaseChatHandler): id = "export" - name = "Export chat messages" - help = "Export the chat messages in markdown format with timestamps" + name = "Export chat history" + help = "Export chat history to a Markdown file" routing_type = SlashCommandRoutingType(slash_id="export") uses_llm = False From 30809842c8f71ef61393cf022d572fc91ee7fddb Mon Sep 17 00:00:00 2001 From: "David L. Qiu" Date: Wed, 5 Jun 2024 10:29:18 -0700 Subject: [PATCH 5/7] add border and more right margin on icons in popup --- packages/jupyter-ai/src/components/chat-input.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/jupyter-ai/src/components/chat-input.tsx b/packages/jupyter-ai/src/components/chat-input.tsx index dfa7e286b..1a0ef109f 100644 --- a/packages/jupyter-ai/src/components/chat-input.tsx +++ b/packages/jupyter-ai/src/components/chat-input.tsx @@ -76,7 +76,7 @@ function renderSlashCommandOption( return (
  • - {icon} + {icon} Date: Wed, 5 Jun 2024 10:50:53 -0700 Subject: [PATCH 6/7] prefer mdash over hyphen --- packages/jupyter-ai/src/components/chat-input.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/jupyter-ai/src/components/chat-input.tsx b/packages/jupyter-ai/src/components/chat-input.tsx index 1a0ef109f..5052d3f19 100644 --- a/packages/jupyter-ai/src/components/chat-input.tsx +++ b/packages/jupyter-ai/src/components/chat-input.tsx @@ -84,13 +84,13 @@ function renderSlashCommandOption( fontSize: 'var(--jp-ui-font-size1)' }} > - {option.label + ' - '} + {option.label} - {option.description} + {' — ' + option.description}
  • From 12b1fb1890acfc6cdbc1680634cd75f275ae6fb0 Mon Sep 17 00:00:00 2001 From: "David L. Qiu" Date: Wed, 5 Jun 2024 10:57:55 -0700 Subject: [PATCH 7/7] include space after selecting an option --- packages/jupyter-ai/src/components/chat-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jupyter-ai/src/components/chat-input.tsx b/packages/jupyter-ai/src/components/chat-input.tsx index 5052d3f19..1be099f77 100644 --- a/packages/jupyter-ai/src/components/chat-input.tsx +++ b/packages/jupyter-ai/src/components/chat-input.tsx @@ -113,7 +113,7 @@ export function ChatInput(props: ChatInputProps): JSX.Element { setSlashCommandOptions( slashCommands.map(slashCommand => ({ id: slashCommand.slash_id, - label: '/' + slashCommand.slash_id, + label: '/' + slashCommand.slash_id + ' ', description: slashCommand.description })) );