diff --git a/jupyter_ai_jupyternaut/__init__.py b/jupyter_ai_jupyternaut/__init__.py index c8740e2..b75cbf0 100644 --- a/jupyter_ai_jupyternaut/__init__.py +++ b/jupyter_ai_jupyternaut/__init__.py @@ -7,7 +7,7 @@ import warnings warnings.warn("Importing 'jupyter_ai_jupyternaut' outside a proper installation.") __version__ = "dev" -from .handlers import setup_handlers +from .extension_app import JupyternautExtension def _jupyter_labextension_paths(): @@ -19,18 +19,6 @@ def _jupyter_labextension_paths(): def _jupyter_server_extension_points(): return [{ - "module": "jupyter_ai_jupyternaut" + "module": "jupyter_ai_jupyternaut", + "app": JupyternautExtension }] - - -def _load_jupyter_server_extension(server_app): - """Registers the API handler to receive HTTP requests from the frontend extension. - - Parameters - ---------- - server_app: jupyterlab.labapp.LabApp - JupyterLab application instance - """ - setup_handlers(server_app.web_app) - name = "jupyter_ai_jupyternaut" - server_app.log.info(f"Registered {name} server extension") diff --git a/jupyter_ai_jupyternaut/config/__init__.py b/jupyter_ai_jupyternaut/config/__init__.py new file mode 100644 index 0000000..80f7855 --- /dev/null +++ b/jupyter_ai_jupyternaut/config/__init__.py @@ -0,0 +1,3 @@ +from .config_models import * +from .config_manager import ConfigManager +from .config_rest_api import ConfigRestAPI diff --git a/jupyter_ai_jupyternaut/config/config_manager.py b/jupyter_ai_jupyternaut/config/config_manager.py new file mode 100644 index 0000000..3235bb9 --- /dev/null +++ b/jupyter_ai_jupyternaut/config/config_manager.py @@ -0,0 +1,403 @@ +import json +import logging +import os +import time +from copy import deepcopy +from typing import Any, Optional, Union + +from deepmerge import always_merger +from jupyter_core.paths import jupyter_data_dir +from traitlets import Integer, Unicode +from traitlets.config import Configurable + +from .config_models import DescribeConfigResponse, JaiConfig, UpdateConfigRequest + +Logger = Union[logging.Logger, logging.LoggerAdapter] + +# default path to config +DEFAULT_CONFIG_PATH = os.path.join(jupyter_data_dir(), "jupyter_ai", "config.json") + +# default no. of spaces to use when formatting config +DEFAULT_INDENTATION_DEPTH = 4 + + +class AuthError(Exception): + pass + + +class WriteConflictError(Exception): + pass + + +class KeyInUseError(Exception): + pass + + +class KeyEmptyError(Exception): + pass + + +class BlockedModelError(Exception): + pass + + +def remove_none_entries(d: dict): + """ + Returns a deep copy of the given dictionary that excludes all top-level + entries whose value is `None`. + """ + d = {k: deepcopy(d[k]) for k in d if d[k] is not None} + return d + + +class ConfigManager(Configurable): + """Provides model and embedding provider id along + with the credentials to authenticate providers. + """ + + config_path = Unicode( + default_value=DEFAULT_CONFIG_PATH, + help="Path to the configuration file.", + allow_none=False, + config=True, + ) + + indentation_depth = Integer( + default_value=DEFAULT_INDENTATION_DEPTH, + help="Indentation depth, in number of spaces per level.", + allow_none=False, + config=True, + ) + + model_provider_id: Optional[str] = None + embeddings_provider_id: Optional[str] = None + completions_model_provider_id: Optional[str] = None + + _defaults: dict + """ + Dictionary that maps config keys (e.g. `model_provider_id`, `fields`) to + user-specified overrides, set by traitlets configuration. + + Values in this dictionary should never be mutated as they may refer to + entries in the global `self.settings` dictionary. + """ + + _last_read: Optional[int] + """ + When the server last read the config file. If the file was not + modified after this time, then we can return the cached + `self._config`. + """ + + def __init__( + self, + log: Logger, + defaults: dict, + allowed_providers: Optional[list[str]] = None, + blocked_providers: Optional[list[str]] = None, + allowed_models: Optional[list[str]] = None, + blocked_models: Optional[list[str]] = None, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.log = log + + self._allowed_providers = allowed_providers + self._blocked_providers = blocked_providers + self._allowed_models = allowed_models + self._blocked_models = blocked_models + + self._lm_providers: dict[str, Any] = ( + {} + ) # Placeholder: should be set to actual language model providers + self._defaults = remove_none_entries(defaults) + self._last_read: Optional[int] = None + + self._config: Optional[JaiConfig] = None + """The `JaiConfig` object that represents the config file.""" + + self._init_config() + + def _init_config(self): + """ + Initializes the config from the existing config file. If a config file + does not exist, then a default one is created. If any field was set in + the `defaults` argument passed to the constructor, they will take + precedence over the existing configuration. + + TODO: how to handle invalid config files? create a copy & replace it + with an empty default? + """ + # If config file exists, validate it first + if os.path.exists(self.config_path) and os.stat(self.config_path).st_size != 0: + self._process_existing_config() + else: + # Otherwise, write the default config to the config path + self._write_config(JaiConfig()) + + # Allow fields specified in `defaults` argument to override the local + # configuration on init. + if self._defaults: + existing_config_args = self._read_config().model_dump() + merged_config_args = always_merger.merge( + existing_config_args, self._defaults + ) + merged_config = JaiConfig(**merged_config_args) + self._write_config(merged_config) + + def _process_existing_config(self): + """ + Reads the existing configuration file and validates it. + """ + with open(self.config_path, encoding="utf-8") as f: + existing_config = json.loads(f.read()) + config = JaiConfig(**existing_config) + + # re-write to the file to validate the config and apply any + # updates to the config file immediately + self._write_config(config) + + def _read_config(self) -> JaiConfig: + """ + Returns the user's current configuration as a `JaiConfig` object. + + NOTE: This method is private because the returned object should never be + sent to the client as it includes API keys. Prefer self.get_config() for + sending the config to the client. + """ + if self._config and self._last_read: + last_write = os.stat(self.config_path).st_mtime_ns + if last_write <= self._last_read: + return self._config + + with open(self.config_path, encoding="utf-8") as f: + self._last_read = time.time_ns() + raw_config = json.loads(f.read()) + if "embeddings_fields" not in raw_config: + raw_config["embeddings_fields"] = {} + config = JaiConfig(**raw_config) + self._validate_config(config) + return config + + def _validate_config(self, config: JaiConfig): + """ + Method used to validate the configuration. This is called after every + read and before every write to the config file. Guarantees that the + user has specified authentication for all configured models that require + it. + """ + # TODO: re-implement this w/ liteLLM + # validate language model config + # if config.model_provider_id: + # _, lm_provider = get_lm_provider( + # config.model_provider_id, self._lm_providers + # ) + + # # verify model is declared by some provider + # if not lm_provider: + # raise ValueError( + # f"No language model is associated with '{config.model_provider_id}'." + # ) + + # # verify model is not blocked + # self._validate_model(config.model_provider_id) + + # # verify model is authenticated + # _validate_provider_authn(config, lm_provider) + + # # verify fields exist for this model if needed + # if lm_provider.fields and config.model_provider_id not in config.fields: + # config.fields[config.model_provider_id] = {} + + # validate completions model config + # if config.completions_model_provider_id: + # _, completions_provider = get_lm_provider( + # config.completions_model_provider_id, self._lm_providers + # ) + + # # verify model is declared by some provider + # if not completions_provider: + # raise ValueError( + # f"No language model is associated with '{config.completions_model_provider_id}'." + # ) + + # # verify model is not blocked + # self._validate_model(config.completions_model_provider_id) + + # # verify model is authenticated + # _validate_provider_authn(config, completions_provider) + + # # verify completions fields exist for this model if needed + # if ( + # completions_provider.fields + # and config.completions_model_provider_id + # not in config.completions_fields + # ): + # config.completions_fields[config.completions_model_provider_id] = {} + + # # validate embedding model config + # if config.embeddings_provider_id: + # _, em_provider = get_em_provider( + # config.embeddings_provider_id, self._em_providers + # ) + + # # verify model is declared by some provider + # if not em_provider: + # raise ValueError( + # f"No embedding model is associated with '{config.embeddings_provider_id}'." + # ) + + # # verify model is not blocked + # self._validate_model(config.embeddings_provider_id) + + # # verify model is authenticated + # _validate_provider_authn(config, em_provider) + + # # verify embedding fields exist for this model if needed + # if ( + # em_provider.fields + # and config.embeddings_provider_id not in config.embeddings_fields + # ): + # config.embeddings_fields[config.embeddings_provider_id] = {} + return + + def _validate_model(self, model_id: str, raise_exc=True): + """ + Validates a model against the set of allow/blocklists specified by the + traitlets configuration, returning `True` if the model is allowed, and + raising a `BlockedModelError` otherwise. If `raise_exc=False`, this + function returns `False` if the model is not allowed. + """ + + assert model_id is not None + components = model_id.split("/", 1) + assert len(components) == 2 + provider_id, _ = components + + try: + if self._allowed_providers and provider_id not in self._allowed_providers: + raise BlockedModelError( + "Model provider not included in the provider allowlist." + ) + + if self._blocked_providers and provider_id in self._blocked_providers: + raise BlockedModelError( + "Model provider included in the provider blocklist." + ) + + if ( + self._allowed_models is not None + and model_id not in self._allowed_models + ): + raise BlockedModelError("Model not included in the model allowlist.") + + if self._blocked_models is not None and model_id in self._blocked_models: + raise BlockedModelError("Model included in the model blocklist.") + except BlockedModelError as e: + if raise_exc: + raise e + else: + return False + + return True + + def _write_config(self, new_config: JaiConfig): + """ + Updates configuration given a complete `JaiConfig` object and saves it + to disk. + """ + # remove any empty field dictionaries + new_config.fields = {k: v for k, v in new_config.fields.items() if v} + new_config.completions_fields = { + k: v for k, v in new_config.completions_fields.items() if v + } + new_config.embeddings_fields = { + k: v for k, v in new_config.embeddings_fields.items() if v + } + + self._validate_config(new_config) + + # Create config directory if it doesn't exist. + os.makedirs(os.path.dirname(self.config_path), exist_ok=True) + + with open(self.config_path, "w") as f: + json.dump(new_config.model_dump(), f, indent=self.indentation_depth) + + def update_config(self, config_update: UpdateConfigRequest): # type:ignore + last_write = os.stat(self.config_path).st_mtime_ns + if config_update.last_read and config_update.last_read < last_write: + raise WriteConflictError( + "Configuration was modified after it was read from disk." + ) + + if config_update.api_keys: + for api_key_value in config_update.api_keys.values(): + if not api_key_value: + raise KeyEmptyError("API key value cannot be empty.") + + config_dict = self._read_config().model_dump() + always_merger.merge(config_dict, config_update.model_dump(exclude_unset=True)) + self._write_config(JaiConfig(**config_dict)) + + # this cannot be a property, as the parent Configurable already defines the + # self.config attr. + def get_config(self): + config = self._read_config() + config_dict = config.model_dump(exclude_unset=True) + api_key_names = list(config_dict.pop("api_keys").keys()) + return DescribeConfigResponse( + **config_dict, api_keys=api_key_names, last_read=self._last_read + ) + + @property + def chat_model(self) -> str | None: + """ + Returns the model ID of the chat model from AI settings, if any. + """ + config = self._read_config() + return config.model_provider_id + + @property + def chat_model_args(self) -> dict[str, Any]: + """ + Returns the model arguments for the current chat model configured by the + user. + + If the current chat model is `None`, this returns an empty dictionary. + Otherwise, it returns the model arguments set in the dictionary at + `.fields.`. + """ + config = self._read_config() + model_id = config.model_provider_id + if not model_id: + return {} + + model_args = config.fields.get(model_id, {}) + return model_args + + @property + def embedding_model(self) -> str | None: + """ + Returns the model ID of the embedding model from AI settings, if any. + """ + config = self._read_config() + return config.embeddings_provider_id + + @property + def embedding_model_params(self) -> dict[str, Any]: + # TODO + return {} + + @property + def completion_model(self) -> str | None: + """ + Returns the model ID of the completion model from AI settings, if any. + """ + config = self._read_config() + return config.completions_model_provider_id + + @property + def completion_model_params(self): + # TODO + return {} \ No newline at end of file diff --git a/jupyter_ai_jupyternaut/config/config_models.py b/jupyter_ai_jupyternaut/config/config_models.py new file mode 100644 index 0000000..d287228 --- /dev/null +++ b/jupyter_ai_jupyternaut/config/config_models.py @@ -0,0 +1,93 @@ +from typing import Any, Optional + +from pydantic import BaseModel, field_validator + + +class JaiConfig(BaseModel): + """ + Pydantic model that serializes and validates the Jupyter AI config. + """ + + model_provider_id: Optional[str] = None + """ + Model ID of the chat model. + """ + + embeddings_provider_id: Optional[str] = None + """ + Model ID of the embedding model. + """ + + completions_model_provider_id: Optional[str] = None + """ + Model ID of the completions model. + """ + + api_keys: dict[str, str] = {} + """ + Dictionary of API keys. The name of each key should correspond to the + environment variable expected by the underlying client library, e.g. + "OPENAI_API_KEY". + """ + + send_with_shift_enter: bool = False + """ + Whether the "Enter" key should create a new line instead of sending the + message. + """ + + fields: dict[str, dict[str, Any]] = {} + """ + Dictionary that defines custom fields for each chat model. + Key: chat model ID. + Value: Dictionary of keyword arguments. + """ + + embeddings_fields: dict[str, dict[str, Any]] = {} + completions_fields: dict[str, dict[str, Any]] = {} + + +class DescribeConfigResponse(BaseModel): + model_provider_id: Optional[str] = None + embeddings_provider_id: Optional[str] = None + send_with_shift_enter: bool + fields: dict[str, dict[str, Any]] + + api_keys: list[str] + """ + List of the names of the API keys. This deliberately does not include the + value of each API key in the interest of security. + """ + + last_read: int + """ + Timestamp indicating when the configuration file was last read. Should be + passed to the subsequent UpdateConfig request if an update is made. + """ + + completions_model_provider_id: Optional[str] = None + completions_fields: dict[str, dict[str, Any]] + embeddings_fields: dict[str, dict[str, Any]] + + +class UpdateConfigRequest(BaseModel): + model_provider_id: Optional[str] = None + embeddings_provider_id: Optional[str] = None + completions_model_provider_id: Optional[str] = None + send_with_shift_enter: Optional[bool] = None + api_keys: Optional[dict[str, str]] = None + # if passed, this will raise an Error if the config was written to after the + # time specified by `last_read` to prevent write-write conflicts. + last_read: Optional[int] = None + fields: Optional[dict[str, dict[str, Any]]] = None + completions_fields: Optional[dict[str, dict[str, Any]]] = None + embeddings_fields: Optional[dict[str, dict[str, Any]]] = None + + @field_validator("send_with_shift_enter", "api_keys", "fields", mode="before") + @classmethod + def ensure_not_none_if_passed(cls, field_val: Any) -> Any: + """ + Field validator ensuring that certain fields are never `None` if set. + """ + assert field_val is not None, "size may not be None" + return field_val diff --git a/jupyter_ai_jupyternaut/config/config_rest_api.py b/jupyter_ai_jupyternaut/config/config_rest_api.py new file mode 100644 index 0000000..0326c66 --- /dev/null +++ b/jupyter_ai_jupyternaut/config/config_rest_api.py @@ -0,0 +1,50 @@ +from __future__ import annotations +from jupyter_server.base.handlers import APIHandler as BaseAPIHandler +from pydantic import ValidationError +from tornado import web +from tornado.web import HTTPError +from typing import TYPE_CHECKING + +from .config_manager import KeyEmptyError, WriteConflictError +from .config_models import UpdateConfigRequest + +if TYPE_CHECKING: + from .config_manager import ConfigManager + + +class ConfigRestAPI(BaseAPIHandler): + """ + Tornado handler that defines the Config REST API served on + the `/api/jupyternaut/config` endpoint. + """ + + @property + def config_manager(self) -> ConfigManager: + return self.settings["jupyternaut.config_manager"] + + @web.authenticated + def get(self): + config = self.config_manager.get_config() + if not config: + raise HTTPError(500, "No config found.") + + self.finish(config.model_dump_json()) + + @web.authenticated + def post(self): + try: + config = UpdateConfigRequest(**self.get_json_body()) + self.config_manager.update_config(config) + self.set_status(204) + self.finish() + except (ValidationError, WriteConflictError, KeyEmptyError) as e: + self.log.exception(e) + raise HTTPError(500, str(e)) from e + except ValueError as e: + self.log.exception(e) + raise HTTPError(500, str(e.cause) if hasattr(e, "cause") else str(e)) + except Exception as e: + self.log.exception(e) + raise HTTPError( + 500, "Unexpected error occurred while updating the config." + ) from e \ No newline at end of file diff --git a/jupyter_ai_jupyternaut/extension_app.py b/jupyter_ai_jupyternaut/extension_app.py new file mode 100644 index 0000000..90cb28b --- /dev/null +++ b/jupyter_ai_jupyternaut/extension_app.py @@ -0,0 +1,191 @@ +from __future__ import annotations +from asyncio import get_event_loop_policy +from jupyter_server.extension.application import ExtensionApp +from jupyter_server.serverapp import ServerApp +from traitlets import List, Unicode, Dict +from typing import TYPE_CHECKING + +from .config import ConfigManager, ConfigRestAPI +from .handlers import RouteHandler +from .models import ChatModelsRestAPI, ModelParametersRestAPI +from .secrets import EnvSecretsManager, SecretsRestAPI + +if TYPE_CHECKING: + from asyncio import AbstractEventLoop + +class JupyternautExtension(ExtensionApp): + """ + The Jupyternaut server extension. + + This serves several REST APIs under the `/api/jupyternaut` route. Currently, + for the sake of simplicity, they are hard-coded into the Jupyternaut server + extension to allow users to configure the chat model & add API keys. + + In the future, these backend objects may be separated into other packages to + allow other developers to re-use them in their personas. + """ + + name = "jupyter_ai_jupyternaut" + handlers = [ + (r"api/jupyternaut/get-example/?", RouteHandler), + (r"api/jupyternaut/config/?", ConfigRestAPI), + (r"api/jupyternaut/models/chat/?", ChatModelsRestAPI), + (r"api/jupyternaut/model-parameters/?", ModelParametersRestAPI), + (r"api/jupyternaut/secrets/?", SecretsRestAPI), + ] + + allowed_providers = List( + Unicode(), + default_value=None, + help="Identifiers of allowlisted providers. If `None`, all are allowed.", + allow_none=True, + config=True, + ) + + blocked_providers = List( + Unicode(), + default_value=None, + help="Identifiers of blocklisted providers. If `None`, none are blocked.", + allow_none=True, + config=True, + ) + + allowed_models = List( + Unicode(), + default_value=None, + help=""" + Language models to allow, as a list of global model IDs in the format + `:`. If `None`, all are allowed. Defaults to + `None`. + + Note: Currently, if `allowed_providers` is also set, then this field is + ignored. This is subject to change in a future non-major release. Using + both traits is considered to be undefined behavior at this time. + """, + allow_none=True, + config=True, + ) + + blocked_models = List( + Unicode(), + default_value=None, + help=""" + Language models to block, as a list of global model IDs in the format + `:`. If `None`, none are blocked. Defaults to + `None`. + """, + allow_none=True, + config=True, + ) + + model_parameters = Dict( + key_trait=Unicode(), + value_trait=Dict(), + default_value={}, + help="""Key-value pairs for model id and corresponding parameters that + are passed to the provider class. The values are unpacked and passed to + the provider class as-is.""", + allow_none=True, + config=True, + ) + + error_logs_dir = Unicode( + default_value=None, + help="""Path to a directory where the error logs should be + written to. Defaults to `jupyter-ai-logs/` in the preferred dir + (if defined) or in root dir otherwise.""", + allow_none=True, + config=True, + ) + + initial_chat_model = Unicode( + default_value=None, + allow_none=True, + help=""" + Default language model to use, as string in the format + :, defaults to None. + """, + config=True, + ) + + initial_language_model = Unicode( + default_value=None, + allow_none=True, + help=""" + Default language model to use, as string in the format + :, defaults to None. + """, + config=True, + ) + + default_embeddings_model = Unicode( + default_value=None, + allow_none=True, + help=""" + Default embeddings model to use, as string in the format + :, defaults to None. + """, + config=True, + ) + + default_completions_model = Unicode( + default_value=None, + allow_none=True, + help=""" + Default completions model to use, as string in the format + :, defaults to None. + """, + config=True, + ) + + default_api_keys = Dict( + key_trait=Unicode(), + value_trait=Unicode(), + default_value=None, + allow_none=True, + help=""" + Default API keys for model providers, as a dictionary, + in the format `:`. Defaults to None. + """, + config=True, + ) + + @property + def event_loop(self) -> AbstractEventLoop: + """ + Returns a reference to the asyncio event loop. + """ + return get_event_loop_policy().get_event_loop() + + def initialize_settings(self): + # Log traitlets configuration + self.log.info(f"Configured provider allowlist: {self.allowed_providers}") + self.log.info(f"Configured provider blocklist: {self.blocked_providers}") + self.log.info(f"Configured model allowlist: {self.allowed_models}") + self.log.info(f"Configured model blocklist: {self.blocked_models}") + self.log.info(f"Configured model parameters: {self.model_parameters}") + defaults = { + "model_provider_id": self.initial_language_model, + "embeddings_provider_id": self.default_embeddings_model, + "completions_model_provider_id": self.default_completions_model, + "api_keys": self.default_api_keys, + "fields": self.model_parameters, + "embeddings_fields": self.model_parameters, + "completions_fields": self.model_parameters, + } + + # Initialize ConfigManager + self.settings["jupyternaut.config_manager"] = ConfigManager( + config=self.config, + log=self.log, + allowed_providers=self.allowed_providers, + blocked_providers=self.blocked_providers, + allowed_models=self.allowed_models, + blocked_models=self.blocked_models, + defaults=defaults, + ) + + # Initialize SecretsManager + self.settings["jupyternaut.secrets_manager"] = EnvSecretsManager(parent=self) + + diff --git a/jupyter_ai_jupyternaut/handlers.py b/jupyter_ai_jupyternaut/handlers.py index 634352b..2eaca5e 100644 --- a/jupyter_ai_jupyternaut/handlers.py +++ b/jupyter_ai_jupyternaut/handlers.py @@ -1,7 +1,6 @@ import json from jupyter_server.base.handlers import APIHandler -from jupyter_server.utils import url_path_join import tornado class RouteHandler(APIHandler): @@ -14,11 +13,3 @@ def get(self): "data": "This is /jupyter-ai-jupyternaut/get-example endpoint!" })) - -def setup_handlers(web_app): - host_pattern = ".*$" - - base_url = web_app.settings["base_url"] - route_pattern = url_path_join(base_url, "jupyter-ai-jupyternaut", "get-example") - handlers = [(route_pattern, RouteHandler)] - web_app.add_handlers(host_pattern, handlers) diff --git a/jupyter_ai_jupyternaut/models/__init__.py b/jupyter_ai_jupyternaut/models/__init__.py new file mode 100644 index 0000000..3c3d987 --- /dev/null +++ b/jupyter_ai_jupyternaut/models/__init__.py @@ -0,0 +1,3 @@ +from .model_list import CHAT_MODELS, EMBEDDING_MODELS +from .chat_models_rest_api import ChatModelsRestAPI +from .parameters_rest_api import ModelParametersRestAPI \ No newline at end of file diff --git a/jupyter_ai_jupyternaut/models/chat_models_rest_api.py b/jupyter_ai_jupyternaut/models/chat_models_rest_api.py new file mode 100644 index 0000000..d4012ee --- /dev/null +++ b/jupyter_ai_jupyternaut/models/chat_models_rest_api.py @@ -0,0 +1,29 @@ +from jupyter_server.base.handlers import APIHandler as BaseAPIHandler +from pydantic import BaseModel +from tornado import web + +from .model_list import CHAT_MODELS + + +class ChatModelsRestAPI(BaseAPIHandler): + """ + A Tornado handler that defines the REST API served on the + `/api/ai/models/chat` endpoint. + + - `GET /api/ai/models/chat`: returns list of all chat models. + + - `GET /api/ai/models/chat?id=`: returns info on that model (TODO) + """ + + @web.authenticated + def get(self): + response = ListChatModelsResponse(chat_models=CHAT_MODELS) + self.finish(response.model_dump_json()) + + +class ListChatModelsResponse(BaseModel): + chat_models: list[str] + + +class ListEmbeddingModelsResponse(BaseModel): + embedding_models: list[str] diff --git a/jupyter_ai_jupyternaut/models/model_list.py b/jupyter_ai_jupyternaut/models/model_list.py new file mode 100644 index 0000000..acd899b --- /dev/null +++ b/jupyter_ai_jupyternaut/models/model_list.py @@ -0,0 +1,36 @@ +from litellm import all_embedding_models, models_by_provider + +chat_model_ids = [] +embedding_model_ids = [] +embedding_model_set = set(all_embedding_models) + +for provider_name in models_by_provider: + for model_name in models_by_provider[provider_name]: + model_name: str = model_name + + if model_name.startswith(f"{provider_name}/"): + model_id = model_name + else: + model_id = f"{provider_name}/{model_name}" + + is_embedding = ( + model_name in embedding_model_set + or model_id in embedding_model_set + or "embed" in model_id + ) + + if is_embedding: + embedding_model_ids.append(model_id) + else: + chat_model_ids.append(model_id) + + +CHAT_MODELS = sorted(chat_model_ids) +""" +List of chat model IDs, following the `litellm` syntax. +""" + +EMBEDDING_MODELS = sorted(embedding_model_ids) +""" +List of embedding model IDs, following the `litellm` syntax. +""" diff --git a/jupyter_ai_jupyternaut/models/parameter_schemas.py b/jupyter_ai_jupyternaut/models/parameter_schemas.py new file mode 100644 index 0000000..6efd7b0 --- /dev/null +++ b/jupyter_ai_jupyternaut/models/parameter_schemas.py @@ -0,0 +1,210 @@ +from __future__ import annotations +from typing import Literal, Any +from pydantic import BaseModel + +PARAMETER_SCHEMAS: dict[str, dict[str, Any]] = { + "temperature": { + "type": "float", + "min": 0, + "max": 2, + "description": "Controls randomness in the output. Lower values make it more focused and deterministic." + }, + "top_p": { + "type": "float", + "min": 0, + "max": 1, + "description": "Nucleus sampling parameter. Consider tokens with top_p probability mass." + }, + "max_tokens": { + "type": "integer", + "min": 1, + "description": "The maximum number of tokens to generate in the completion." + }, + "max_completion_tokens": { + "type": "integer", + "min": 1, + "description": "Upper bound for the number of tokens that can be generated for a completion." + }, + "n": { + "type": "integer", + "min": 1, + "max": 128, + "description": "How many completion choices to generate for each prompt." + }, + "seed": { + "type": "integer", + "description": "Seed for deterministic sampling. Same seed and parameters should return same result." + }, + "stream": { + "type": "boolean", + "description": "Whether to stream partial message deltas." + }, + "stop": { + "type": "array", + "description": "Up to 4 sequences where the API will stop generating further tokens." + }, + "response_format": { + "type": "object", + "description": "Specify the format that the model must output (e.g., JSON)." + }, + + # Model Behavior + # "tools": { + # "type": "array", + # "default": None, + # "description": "A list of tools the model may call." + # }, + # "tool_choice": { + # "type": "string|object", + # "default": "auto", + # "description": "Controls which function is called by the model." + # }, + # "parallel_tool_calls": { + # "type": "boolean", + # "default": True, + # "description": "Whether to enable parallel function calling during tool use." + # }, + + "presence_penalty": { + "type": "float", + "min": -2, + "max": 2, + "description": "Penalize new tokens based on whether they appear in the text so far." + }, + "frequency_penalty": { + "type": "float", + "min": -2, + "max": 2, + "description": "Penalize new tokens based on their frequency in the text so far." + }, + "logit_bias": { + "type": "object", + "description": "Modify the likelihood of specified tokens appearing in the completion." + }, + "logprobs": { + "type": "boolean", + "description": "Whether to return log probabilities of the output tokens." + }, + "top_logprobs": { + "type": "integer", + "min": 0, + "max": 5, + "description": "Number of most likely tokens to return at each token position." + }, + "user": { + "type": "string", + "description": "A unique identifier representing your end-user." + }, + "timeout": { + "type": "integer", + "min": 1, + "description": "Request timeout in seconds." + }, + "top_k": { + "type": "integer", + "min": 1, + "description": "Limit the next token selection to the K most probable tokens." + }, + "api_base": { + "type": "string", + "description": "Base URL where LLM requests are sent, used for enterprise proxy gateways." + } +} + +class ParameterSchema(BaseModel): + """Pydantic model for parameter schema definition.""" + type: Literal['boolean', 'integer', 'float', 'string', 'array', 'object'] + description: str + +class GetModelParametersResponse(BaseModel): + """Pydantic model for GET model parameters response.""" + parameters: dict[str, ParameterSchema] + parameter_names: list[str] + +class UpdateModelParametersResponse(BaseModel): + """Pydantic model for PUT model parameters response.""" + parameters: dict[str, Any] + +def get_parameter_schema(param_name: str) -> ParameterSchema: + """ + Get the schema for a specific parameter. + """ + schema = PARAMETER_SCHEMAS.get(param_name) + if schema is None: + return ParameterSchema( + type="string", + description=f"Parameter {param_name} (schema not defined)" + ) + return ParameterSchema(**schema) + +def get_parameters_with_schemas(param_names: list[str]) -> dict[str, ParameterSchema]: + """ + Get schemas for a list of parameter names. + """ + return { + name: get_parameter_schema(name) + for name in param_names + } + +def coerce_parameter_value(value: str, param_type: str): + """ + Coerce a string value to the appropriate type based on parameter type. + + Args: + value: The string value to coerce + param_type: The parameter type (e.g., 'number', 'integer', 'boolean', 'string') + + Returns: + The coerced value in the appropriate type + + Raises: + ValueError: If the value cannot be coerced to the specified type + """ + if not isinstance(value, str): + return value # Already the correct type + + # Normalize the type string + param_type = param_type.lower().strip() + + # Handle different types + if param_type in ['number', 'float']: + try: + return float(value) + except ValueError: + raise ValueError(f"Cannot convert '{value}' to number") + + elif param_type in ['integer', 'int']: + try: + return int(value) + except ValueError: + raise ValueError(f"Cannot convert '{value}' to integer") + + elif param_type in ['boolean', 'bool']: + value_lower = value.lower().strip() + if value_lower == 'true': + return True + elif value_lower == 'false': + return False + else: + raise ValueError(f"Cannot convert '{value}' to boolean (expected 'true' or 'false')") + + elif param_type in ['string', 'str']: + return value # Already a string + + elif param_type in ['array', 'list']: + try: + import json + return json.loads(value) + except (json.JSONDecodeError, TypeError): + raise ValueError(f"Cannot convert '{value}' to array (expected valid JSON array)") + + elif param_type in ['object', 'dict']: + try: + import json + return json.loads(value) + except (json.JSONDecodeError, TypeError): + raise ValueError(f"Cannot convert '{value}' to object (expected valid JSON object)") + + else: + # For unknown types, return as string + return value \ No newline at end of file diff --git a/jupyter_ai_jupyternaut/models/parameters_rest_api.py b/jupyter_ai_jupyternaut/models/parameters_rest_api.py new file mode 100644 index 0000000..b7d4971 --- /dev/null +++ b/jupyter_ai_jupyternaut/models/parameters_rest_api.py @@ -0,0 +1,133 @@ +from jupyter_server.base.handlers import APIHandler as BaseAPIHandler +from tornado.web import authenticated, HTTPError +import json + +from litellm.litellm_core_utils.get_supported_openai_params import get_supported_openai_params +from .parameter_schemas import get_parameters_with_schemas, coerce_parameter_value, GetModelParametersResponse, UpdateModelParametersResponse +from ..config import UpdateConfigRequest + +class ModelParametersRestAPI(BaseAPIHandler): + """ + Tornado handler that defines the model parameters REST API served on the + `/api/jupyternaut/model-parameters` endpoint. + + GET /api/ai/model-parameters: Returns common parameters + GET /api/ai/model-parameters?model=gpt-4: Returns parameters for specific model + PUT /api/ai/model-parameters: Saves model parameters to config + """ + + @authenticated + def get(self): + """ + Returns list of supported model parameters. + + Query Parameters: + - model (string, optional): Model ID to get parameters for + - provider (string, optional): Custom LLM provider + If no model provided, returns common parameters. + """ + try: + model = self.get_query_argument("model", default=None) + provider = self.get_query_argument("provider", default=None) + + # Temporary common parameters that work across most models + common_params = ["temperature", "max_tokens", "top_p", "stop"] + # Params controlling tool availability & usage require a unique UX + # if they are to be made configurable from the frontend. Therefore + # they are disabled for now. + EXCLUDED_PARAMS = { "tools", "tool_choice", "parallel_tool_calls" } + + if model: + try: + parameter_names = get_supported_openai_params( + model=model, + custom_llm_provider=provider + ) + if not parameter_names: + parameter_names = common_params + except Exception: + parameter_names = common_params + else: + parameter_names = common_params + + # Filter out excluded params + parameter_names = [n for n in parameter_names if n not in EXCLUDED_PARAMS] + + # Get parameter schemas with types, defaults, and descriptions + parameters_with_schemas = get_parameters_with_schemas(parameter_names) + + # Create Pydantic response model + response = GetModelParametersResponse( + parameters=parameters_with_schemas, + parameter_names=parameter_names + ) + + self.set_header("Content-Type", "application/json") + self.finish(response.model_dump_json()) + + except Exception as e: + self.log.exception("Failed to get model parameters") + raise HTTPError(500, f"Internal server error: {str(e)}") + + @authenticated + def put(self): + """ + Saves model parameters to configuration. + Example request body: + { + "model_id": "gpt-4", + "parameters": { + "temperature": 0.7, + "max_tokens": 1000 + } + } + """ + try: + request_body = json.loads(self.request.body.decode('utf-8')) + + # Validate required fields + if "model_id" not in request_body: + raise HTTPError(400, "Missing required field: model_id") + if "parameters" not in request_body: + raise HTTPError(400, "Missing required field: parameters") + + model_id = request_body["model_id"] + parameters = request_body["parameters"] + + # Validate parameter structure and apply type coercion + coerced_parameters = {} + for param_name, param_data in parameters.items(): + if not isinstance(param_data, dict): + raise HTTPError(400, f"Parameter '{param_name}' must be an object with 'value' and 'type' fields") + if "value" not in param_data: + raise HTTPError(400, f"Parameter '{param_name}' missing required field: value") + if "type" not in param_data: + raise HTTPError(400, f"Parameter '{param_name}' missing required field: type") + try: + coerced_value = coerce_parameter_value(param_data["value"], param_data["type"]) + coerced_parameters[param_name] = coerced_value + except ValueError as e: + raise HTTPError(400, f"Invalid value for parameter '{param_name}': {str(e)}") + + config_manager = self.settings.get("jai_config_manager") + if not config_manager: + raise HTTPError(500, "Config manager not available") + + update_request = UpdateConfigRequest( + fields={model_id: coerced_parameters} + ) + config_manager.update_config(update_request) + + # Create Pydantic response model for API compatibility + response = UpdateModelParametersResponse( + parameters=coerced_parameters + ) + + self.set_header("Content-Type", "application/json") + self.finish(response.model_dump_json()) + + except json.JSONDecodeError: + raise HTTPError(400, "Invalid JSON in request body") + except Exception as e: + self.log.exception("Failed to save model parameters") + raise HTTPError(500, f"Internal server error: {str(e)}") diff --git a/jupyter_ai_jupyternaut/secrets/__init__.py b/jupyter_ai_jupyternaut/secrets/__init__.py new file mode 100644 index 0000000..9b78adb --- /dev/null +++ b/jupyter_ai_jupyternaut/secrets/__init__.py @@ -0,0 +1,2 @@ +from .secrets_manager import EnvSecretsManager +from .secrets_rest_api import SecretsRestAPI \ No newline at end of file diff --git a/jupyter_ai_jupyternaut/secrets/secrets_manager.py b/jupyter_ai_jupyternaut/secrets/secrets_manager.py new file mode 100644 index 0000000..eba87f3 --- /dev/null +++ b/jupyter_ai_jupyternaut/secrets/secrets_manager.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import asyncio +import os +from datetime import datetime +from io import StringIO +from typing import TYPE_CHECKING + +from dotenv import load_dotenv +from tornado.web import HTTPError +from traitlets.config import LoggingConfigurable + +from .secrets_types import SecretsList +from .secrets_utils import build_updated_dotenv, parse_dotenv + +if TYPE_CHECKING: + import logging + from typing import Any + + from jupyter_server.services.contents.filemanager import AsyncFileContentsManager + + from ..extension_app import JupyternautExtension + + +class EnvSecretsManager(LoggingConfigurable): + """ + The default secrets manager implementation. + + TODO: Create a `BaseSecretsManager` class and add an + `AiExtension.secrets_manager_class` configurable trait to allow custom + implementations. + + TODO: Add a `EnvSecretsManager.dotenv_path` configurable trait to allow + users to change the path of the `.env` file. + + TODO: Add a `EnvSecretsManager.envvar_secrets` configurable trait to allow + users to pass a list of glob expressions that define which environment + variables are listed as secrets in the UI. This should default to `*TOKEN*, + *SECRET*, *KEY*`. + """ + + parent: JupyternautExtension + """ + The parent `JupyternautExtension` class. + + NOTE: This attribute is automatically set by the `LoggingConfigurable` + parent class. This annotation exists only to help type checkers like `mypy`. + """ + + log: logging.Logger + """ + The logger used by by this instance. + + NOTE: This attribute is automatically set by the `LoggingConfigurable` + parent class. This annotation exists only to help type checkers like `mypy`. + """ + + _last_modified: datetime | None + """ + The 'last modified' timestamp on the '.env' file retrieved in the previous + tick of the `_watch_dotenv()` background task. + """ + + _initial_env: dict[str, str] + """ + Dictionary containing the initial environment variables passed to this + process. Set to `dict(os.environ)` exactly once on init. + + This attribute should not be set more than once, since secrets loaded from + the `.env` file are added to `os.environ` after this class initializes. + """ + + _dotenv_env: dict[str, str] + """ + Dictionary containing the environment variables defined in the `.env` file. + If no `.env` file exists, this will be an empty dictionary. This attribute + is continuously updated via the `_watch_dotenv()` background task. + """ + + _dotenv_lock: asyncio.Lock + """ + Lock which must be held while reading or writing to the `.env` file from the + `ContentsManager`. + """ + + @property + def contents_manager(self) -> AsyncFileContentsManager: + assert self.parent.serverapp + # By default, `serverapp.contents_manager` is typed as `ContentsManager | + # None`. However, a custom implementation may provide async methods, so + # we cast it as the async version of the default `FileContentsManager`. + return self.parent.serverapp.contents_manager # type:ignore[return-value] + + @property + def event_loop(self) -> asyncio.AbstractEventLoop: + return self.parent.event_loop + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Set instance attributes + self._last_modified = None + self._initial_env = dict(os.environ) + self._dotenv_env = {} + self._dotenv_lock = asyncio.Lock() + + # Start `_watch_dotenv()` task to automatically update the environment + # variables when `.env` is modified + self._watch_dotenv_task = self.event_loop.create_task(self._watch_dotenv()) + + async def _watch_dotenv(self) -> None: + """ + Watches the `.env` file and automatically responds to changes. + """ + while True: + await asyncio.sleep(2) + + # Fetch file content and its last modified timestamp + try: + async with self._dotenv_lock: + dotenv_file = await self.contents_manager.get(".env", content=True) + dotenv_content = dotenv_file.get("content") + assert isinstance(dotenv_content, str) + except HTTPError as e: + # Continue if file does not exist, otherwise re-raise + if e.status_code == 404: + self._handle_dotenv_notfound() + continue + except Exception: + self.log.exception("Unknown exception in `_watch_dotenv()`:") + continue + + # Continue if the `.env` file was already processed and its content + # is unchanged. + if self._last_modified == dotenv_file["last_modified"]: + continue + + # When this line is reached, the .env file needs to be applied. + # Log a statement accordingly, ensure the new `.env` file is listed + # in `.gitignore`, and store the latest last modified timestamp. + if self._last_modified: + # Statement when .env file was modified: + self.log.info( + "Detected changes to the '.env' file. Re-applying '.env' to the environment..." + ) + else: + # Statement when the .env file was just created, or when this is + # the first iteration and a .env file already exists: + self.log.info( + "Detected '.env' file at the workspace root. Applying '.env' to the environment..." + ) + self.event_loop.create_task(self._ensure_dotenv_gitignored()) + self._last_modified = dotenv_file["last_modified"] + + # Apply the latest `.env` file to the environment. + # See `self._apply_dotenv()` for more info. + self._apply_dotenv(dotenv_content) + + def _apply_dotenv(self, content: str) -> None: + """ + Applies a `.env` file to the environment given its content. This method: + + 1. Resets any variables removed from the `.env` file. See + `self._reset_envvars()` for more info. + + 2. Stores the parsed `.env` file as a dictionary in `self._dotenv_env`. + + 3. Sets the environment variables in `os.environ` as defined in the + `.env` file. + """ + # Parse the latest `.env` file and store it in `self._dotenv_env`, + # tracking deleted environment variables in `deleted_envvars`. + new_dotenv_env = parse_dotenv(content) + deleted_envvars = [k for k in self._dotenv_env if k not in new_dotenv_env] + self._dotenv_env = new_dotenv_env + + # Apply the new `.env` file to the environment and reset all + # environment variables in `deleted_envvars`. + if deleted_envvars: + self._reset_envvars(deleted_envvars) + self.log.info( + f"Removed {len(deleted_envvars)} variables from the environment as they were removed from '.env'." + ) + load_dotenv(stream=StringIO(content), override=True) + self.log.info("Applied '.env' to the environment.") + + async def _ensure_dotenv_gitignored(self) -> None: + """ + Ensures the `.env` file is listed in the `.gitignore` file at the + workspace root, creating/updating the `.gitignore` file to list `.env` + if needed. + + This method is called by the `_watch_dotenv()` background task either on + the first iteration when the `.env` file already exists, or when the + `.env` file was created on a subsequent iteration. + """ + # Fetch `.gitignore` file. + gitignore_file: dict[str, Any] | None = None + try: + gitignore_file = await self.contents_manager.get(".gitignore", content=True) + except HTTPError as e: + # Continue if file does not exist, otherwise re-raise + if e.status_code == 404: + pass + else: + raise e + except Exception: + self.log.exception("Unknown exception raised when fetching `.gitignore`:") + + # Return early if the `.gitignore` file exists and already lists `.env`. + old_content: str = (gitignore_file or {}).get("content", "") + if ".env\n" in old_content: + return + + # Otherwise, log something and create/update the `.gitignore` file to + # list `.env`. + self.log.info("Updating `.gitignore` file to include `.env`...") + new_lines = "# Ignore secrets in '.env'\n.env\n" + new_content = old_content + "\n" + new_lines if old_content else new_lines + try: + gitignore_file = await self.contents_manager.save( + { + "type": "file", + "format": "text", + "mimetype": "text/plain", + "content": new_content, + }, + ".gitignore", + ) + except Exception: + self.log.exception("Unknown exception raised when updating `.gitignore`:") + self.log.info("Updated `.gitignore` file to include `.env`.") + + def _reset_envvars(self, names: list[str]) -> None: + """ + Resets each environment variable in the given list. Each variable is + restored to its initial value in `self._initial_env` if present, and + deleted from `os.environ` otherwise. + """ + for ev_name in names: + if ev_name in self._initial_env: + os.environ[ev_name] = self._initial_env[ev_name] + else: + del os.environ[ev_name] + + def _handle_dotenv_notfound(self) -> None: + """ + Method called by the `_watch_dotenv()` task when the `.env` file is + not found. + """ + if self._last_modified: + self._last_modified = None + if self._dotenv_env: + self._reset_envvars(list(self._dotenv_env.keys())) + self._dotenv_env = {} + + def list_secrets(self) -> SecretsList: + """ + Lists the names of each environment variable from the workspace `.env` + file and the environment variables passed to the Python process. Notes: + + 1. For envvars from the Python process (not set in `.env`), only + environment variables whose names contain "KEY" or "TOKEN" or "SECRET" + are included. + + 2. Each envvar listed in `.env` is included in the returned list. + """ + dotenv_secrets_names = set() + process_secrets_names = set() + + # Add secrets from the initial environment + for name in self._initial_env.keys(): + if "KEY" in name or "TOKEN" in name or "SECRET" in name: + process_secrets_names.add(name) + + # Add secrets from .env, if any + for name in self._dotenv_env: + dotenv_secrets_names.add(name) + + # Remove `TIKTOKEN_CACHE_DIR`, which is set in the initial environment + # by some other package and is not a secret. + # This gets included otherwise since it contains 'TOKEN' in its name. + process_secrets_names.discard("TIKTOKEN_CACHE_DIR") + + return SecretsList( + editable_secrets=sorted(list(dotenv_secrets_names)), + static_secrets=sorted(list(process_secrets_names)), + ) + + async def update_secrets( + self, + updated_secrets: dict[str, str | None], + ) -> None: + """ + Accepts a dictionary of secrets to update, adds/updates/deletes them + from `.env` accordingly, and applies the updated `.env` file to the + environment. Notes: + + - A new `.env` file is created if it does not exist. + + - If the value of a secret in `updated_secrets` is `None`, then the + secret is deleted from `.env`. + + - Otherwise, the secret is added/updated in `.env`. + + - A best effort is made at preserving the formatting in the `.env` + file. However, inline comments following a environment variable + definition on the same line will be deleted. + """ + # Return early if passed an empty dictionary + if not updated_secrets: + return + + # Hold the lock during the entire duration of the update + async with self._dotenv_lock: + # Fetch `.env` file content, storing its raw content in + # `dotenv_content` and its parsed value as a dict in `dotenv_env`. + dotenv_content: str = "" + try: + dotenv_file = await self.contents_manager.get(".env", content=True) + if "content" in dotenv_file: + dotenv_content = dotenv_file["content"] + assert isinstance(dotenv_content, str) + except HTTPError as e: + # Continue if file does not exist, otherwise re-raise + if e.status_code == 404: + pass + else: + raise e + except Exception: + self.log.exception( + "Unknown exception raised when reading `.env` in response to an update:" + ) + + # Build the new `.env` file using these variables. + # See `build_updated_dotenv()` for more info on how this is done. + new_dotenv_content = build_updated_dotenv(dotenv_content, updated_secrets) + + # Return early if no changes are needed in `.env`. + if new_dotenv_content is None: + return + + # Save new content + try: + dotenv_file = await self.contents_manager.save( + { + "type": "file", + "format": "text", + "mimetype": "text/plain", + "content": new_dotenv_content, + }, + ".env", + ) + last_modified = dotenv_file.get("last_modified") + assert isinstance(last_modified, datetime) + except Exception: + self.log.exception("Unknown exception raised when updating `.env`:") + + # If this is a new file, ensure the `.env` file is listed in `.gitignore`. + # `self._last_modified == None` should imply the `.env` file did not exist. + if not self._last_modified: + self.event_loop.create_task(self._ensure_dotenv_gitignored()) + + # Update last modified timestamp and apply the new environment. + self._last_modified = last_modified + # This automatically sets `self._dotenv_env`. + self._apply_dotenv(new_dotenv_content) + self.log.info("Updated secrets in `.env`.") + + def get_secret(self, secret_name: str) -> str | None: + """ + Returns the value of a secret given its name. The returned secret must + NEVER be shared with frontend clients! + """ + # TODO + + def stop(self) -> None: + """ + Stops this instance and any background tasks spawned by this instance. + This method should be called if and only if the server is shutting down. + """ + self._watch_dotenv_task.cancel() + + # Reset any environment variables set in `.env` back to their initial + # values. This is only required for the unit test suite. + envvar_names = list(self._dotenv_env.keys()) + self._reset_envvars(envvar_names) + self._dotenv_env = {} diff --git a/jupyter_ai_jupyternaut/secrets/secrets_rest_api.py b/jupyter_ai_jupyternaut/secrets/secrets_rest_api.py new file mode 100644 index 0000000..77fc7de --- /dev/null +++ b/jupyter_ai_jupyternaut/secrets/secrets_rest_api.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from jupyter_server.base.handlers import APIHandler as BaseAPIHandler +from tornado.web import HTTPError, authenticated + +from .secrets_types import UpdateSecretsRequest + +if TYPE_CHECKING: + from .secrets_manager import EnvSecretsManager + + +class SecretsRestAPI(BaseAPIHandler): + """ + Tornado handler that defines the REST API served on the + `/api/jupyternaut/secrets` endpoint. + + Methods supported: + + - `GET secrets/`: Returns a list of secrets. + - `PUT secrets/`: Add/update/delete a set of secrets. + """ + + @property + def secrets_manager(self) -> EnvSecretsManager: # type:ignore[override] + return self.settings["jupyternaut.secrets_manager"] + + @authenticated + def get(self): + response = self.secrets_manager.list_secrets() + self.set_status(200) + self.finish(response.model_dump_json()) + + @authenticated + async def put(self): + try: + # Validate the request body matches the `UpdateSecretsRequest` + # expected type + request = UpdateSecretsRequest(**self.get_json_body()) + + # Dispatch the request to the secrets manager + await self.secrets_manager.update_secrets(request.updated_secrets) + except Exception as e: + self.log.exception("Exception raised when handling PUT /api/ai/secrets/:") + raise HTTPError(500, str(e)) + + self.set_status(204) + self.finish() diff --git a/jupyter_ai_jupyternaut/secrets/secrets_types.py b/jupyter_ai_jupyternaut/secrets/secrets_types.py new file mode 100644 index 0000000..8959ee8 --- /dev/null +++ b/jupyter_ai_jupyternaut/secrets/secrets_types.py @@ -0,0 +1,35 @@ +from typing import Optional + +from pydantic import BaseModel + + +class SecretsList(BaseModel): + """ + The response type returned by `GET /api/ai/secrets`. + + The response fields only include the names of each secret, and never must + never include the value of any secret. + """ + + editable_secrets: list[str] = [] + """ + List of secrets set in the `.env` file. These secrets can be edited. + """ + + static_secrets: list[str] = [] + """ + List of secrets passed as environment variables to the Python process or + passed as traitlets configuration to JupyterLab. These secrets cannot be + edited. + + Environment variables passed to the Python process are only included if + their name contains 'KEY' or 'TOKEN' or 'SECRET'. + """ + + +class UpdateSecretsRequest(BaseModel): + """ + The request body expected by `PUT /api/ai/secrets`. + """ + + updated_secrets: dict[str, Optional[str]] diff --git a/jupyter_ai_jupyternaut/secrets/secrets_utils.py b/jupyter_ai_jupyternaut/secrets/secrets_utils.py new file mode 100644 index 0000000..d202a84 --- /dev/null +++ b/jupyter_ai_jupyternaut/secrets/secrets_utils.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from io import StringIO +from typing import TYPE_CHECKING + +from dotenv import dotenv_values +from dotenv.parser import parse_stream + +if TYPE_CHECKING: + import logging + +ENVIRONMENT_VAR_REGEX = "^([a-zA-Z_][a-zA-Z_0-9]*)=?(['\"])?" +""" +Regex that matches a environment variable definition. +""" + + +def build_updated_dotenv( + dotenv_content: str, + updated_secrets: dict[str, str | None], + log: logging.Logger | None = None, +) -> str | None: + """ + Accepts the existing `.env` file as a parsed dictionary of environment + variables, along with a dictionary of secrets to update. `None` values + indicate the secret should be deleted. Otherwise, the secret will be added + to or updated in `.env`. + + This function returns the content of the updated `.env` file as a string, + and returns `None` if no updates to `.env` are required. + + NOTE: This function currently deletes inline comments on environment + variable definitions. This may be fixed in a future update. + """ + # Return early if no updates were given. + if not updated_secrets: + return None + + # Parse content of `.env` into a dictionary of environment variables + dotenv_env = dotenv_values(stream=StringIO(dotenv_content)) + + # Define `secrets_to_add`, `secrets_to_update`, and + # `secrets_to_remove`. + secrets_to_add: dict[str, str] = {} + secrets_to_update: dict[str, str] = {} + secrets_to_remove: set[str] = set() + if dotenv_env: + for name, value in updated_secrets.items(): + # Case 1: secret should be added to `.env` + if value is not None and dotenv_env.get(name, None) == None: + secrets_to_add[name] = value + continue + # Case 2: secret should be updated in `.env` + if value is not None and dotenv_env.get(name, None) != None: + secrets_to_update[name] = value + continue + # Case 3: secret should be removed from `.env` + if value is None and dotenv_env.get(name, None) != None: + secrets_to_remove.add(name) + continue + else: + # Case 4: keys can only be added when a `.env` file is not + # present. + secrets_to_add = {k: v for k, v in updated_secrets.items() if v is not None} + + # Return early if update has effect. + if not (secrets_to_add or secrets_to_update or secrets_to_remove): + return None + + # First, handle the case of adding secrets to a new `.env` file. + if not dotenv_env: + new_content = "" + max_i = len(secrets_to_add) - 1 + for i, (name, value) in enumerate(secrets_to_add.items()): + new_content += f'{name}="{value}"\n' + if i != max_i: + new_content += "\n" + + return new_content + + # Now handle the case of updating an existing `.env` file. + # To preserve formatting, multiline variables, and inline comments on + # variable defintions, we re-use the parser used by `python_dotenv`. + # It is not trivial to re-implement their parser. + # + # Algorithm overview: + # + # 1. The `parse_stream()` function returns an Iterator that yields 'Binding' + # objects that represent 'parsed chunks' of a `.env` file. Each chunk may + # contain: + # + # - An environment variable definition (`Binding.key is not None`), + # - An invalid line (`Binding.error == True`), + # - A standalone comment (if neither condition applies). + # + # 2. (Case 1) Invalid lines and environment variable bindings listed in + # `secrets_to_remove` are ignored. + # + # 3. (Case 2) Environment variable definitions listed in `secrets_to_update` + # are appended to `new_content` with the new value. + # + # 4. (Case 3) All other `Binding` objects are appended to `new_content` as-is. + # + # 5. Finally, new environment variables listed in `secrets_to_add` are + # appended at the end after the `.env` file is fully parsed. + new_content = "" + for binding in parse_stream(StringIO(dotenv_content)): + # Case 1 + if binding.error or binding.key in secrets_to_remove: + continue + # Case 2 + if binding.key in secrets_to_update: + name = binding.key + # extra logic to preserve formatting as best as we can + whitespace_before, whitespace_after = get_whitespace_around( + binding.original.string + ) + value = secrets_to_update[name] + new_content += whitespace_before + new_content += f'{name}="{value}"' + new_content += whitespace_after + continue + # Case 3 + new_content += binding.original.string + + if secrets_to_add: + # Ensure new secrets get put at least 2 lines below the rest + if not new_content.endswith("\n"): + new_content += "\n\n" + elif not new_content.endswith("\n\n"): + new_content += "\n" + + max_i = len(secrets_to_add) - 1 + for i, (name, value) in enumerate(secrets_to_add.items()): + new_content += f'{name}="{value}"\n' + if i != max_i: + new_content += "\n" + + return new_content + + +def get_whitespace_around(text: str) -> tuple[str, str]: + """ + Extract whitespace prefix and suffix from a string. + + Args: + text: The input string + + Returns: + A tuple of (prefix, suffix) where prefix is the leading whitespace + and suffix is the trailing whitespace + """ + if not text: + return ("", "") + + # Find prefix (leading whitespace) + prefix_end = 0 + for i, char in enumerate(text): + if not char.isspace(): + prefix_end = i + break + else: + # String is all whitespace + return (text, "") + + # Find suffix (trailing whitespace) + suffix_start = len(text) + for i in range(len(text) - 1, -1, -1): + if not text[i].isspace(): + suffix_start = i + 1 + break + + prefix = text[:prefix_end] + suffix = text[suffix_start:] + + return (prefix, suffix) + + +def parse_dotenv(content: str) -> dict[str, str]: + """ + Parses the given content of a `.env` file and returns the environment + variables defined in that file as a dictionary. + + Args: + content: The content of the `.env` file as a string. + + Returns: + A dictionary of environment variables. Entries with `None` values are + removed automatically. + """ + raw_values = dotenv_values(stream=StringIO(content)) + return {k: v for k, v in raw_values.items() if v is not None} diff --git a/jupyter_ai_jupyternaut/secrets/tests/test_secrets_manager.py b/jupyter_ai_jupyternaut/secrets/tests/test_secrets_manager.py new file mode 100644 index 0000000..186946a --- /dev/null +++ b/jupyter_ai_jupyternaut/secrets/tests/test_secrets_manager.py @@ -0,0 +1,186 @@ +""" +Unit tests for the `..secrets_manager` module. +""" + +from __future__ import annotations +import asyncio +import pytest +from unittest.mock import AsyncMock, Mock, patch +from typing import TYPE_CHECKING +import os + +from jupyter_ai.secrets.secrets_manager import EnvSecretsManager +from jupyter_ai.secrets.secrets_types import SecretsList + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def dotenv_path(tmp_path): + """ + Returns the expected path of the `.env` file managed by the `manager` + fixture. + """ + return tmp_path / ".env" + + +@pytest.fixture +def manager(mock_ai_extension, dotenv_path): + """ + Returns the configured `EnvSecretsManager` instance to be tested in this + file. + """ + # Yield configured `EnvSecretsManager` + manager = EnvSecretsManager(parent=mock_ai_extension) + yield manager + + # Cleanup + manager.stop() + try: + os.remove(dotenv_path) + except: + pass + + +class TestEnvSecretsManager(): + """ + Unit tests for the EnvSecretsManager. + + At the start of each test, no secrets are set in the environment variables + passed to the process and no `.env` file is set. + """ + + def test_initial_state(self, manager: EnvSecretsManager, dotenv_path: Path): + """ + ASSERTS: + + 1. `list_secrets()` returns an empty `SecretsList` response. + + 2. No `.env` file is created. + """ + # Assertion 1 + response = manager.list_secrets() + assert isinstance(response, SecretsList) + assert response.static_secrets == [] + assert response.editable_secrets == [] + + # Assertion 2 + assert not os.path.exists(dotenv_path) + + async def test_adding_secrets_via_manager(self, manager: EnvSecretsManager, dotenv_path: Path): + """ + ASSERTS: + + 1. Adding a secret via `update_secret()` creates a `.env` file + under `tmp_path`. + + 2. Adding another secret results in 2 secrets listed in `.env`. + + 3. `list_secrets()` lists the 2 newly added secrets as editable secrets. + + 4. Both secrets are set in `os.environ`. + """ + # Assertion 1 + await manager.update_secrets({ + "NEW_API_KEY": "some_value" + }) + assert os.path.exists(dotenv_path) + with open(dotenv_path) as f: + content = f.read() + assert content.strip() == 'NEW_API_KEY="some_value"' + + # Assertion 2 + await manager.update_secrets({ + "ANOTHER_API_KEY": "some_value" + }) + with open(dotenv_path) as f: + content = f.read() + lines = content.strip().splitlines() + assert 'NEW_API_KEY="some_value"' in lines + assert 'ANOTHER_API_KEY="some_value"' in lines + + # Assertion 3 + list_secrets_response = manager.list_secrets() + assert list_secrets_response.static_secrets == [] + assert list_secrets_response.editable_secrets == sorted([ + "NEW_API_KEY", + "ANOTHER_API_KEY" + ]) + + # Assertion 4 + assert os.environ["NEW_API_KEY"] == "some_value" + assert os.environ["ANOTHER_API_KEY"] == "some_value" + + async def test_updating_secrets_via_manager(self, manager: EnvSecretsManager, dotenv_path: Path): + """ + ASSERTS: + + 1. A previously added secret can be updated. + + 2. Only a single secret is listed in the `.env` file and the + `list_secrets()` response. + + 3. The new value is set in `os.environ`. + """ + # Assertion 1 + await manager.update_secrets({ + "API_KEY": "old_value" + }) + assert os.environ["API_KEY"] == "old_value" + await manager.update_secrets({ + "API_KEY": "new_value" + }) + + # Assertion 2 + with open(dotenv_path) as f: + content = f.read() + assert content.strip() == 'API_KEY="new_value"' + assert manager.list_secrets().static_secrets == [] + assert manager.list_secrets().editable_secrets == ["API_KEY"] + + # Assertion 3 + assert os.environ["API_KEY"] == "new_value" + + + async def test_adding_secrets_via_dotenv(self, manager: EnvSecretsManager, dotenv_path: Path): + """ + ASSERTS: + + 1. After creating a new `.env` file with secrets and waiting a few + seconds, the secrets manager lists secrets from the new `.env` file. + + 2. The new secrets are set in `os.environ`. + """ + # Assertion 1 + with open(dotenv_path, "w") as f: + f.write('API_KEY_1="api_key_1_value"\n') + f.write('API_KEY_2="api_key_2_value"\n') + await asyncio.sleep(3) + assert manager.list_secrets().static_secrets == [] + assert manager.list_secrets().editable_secrets == ["API_KEY_1", "API_KEY_2"] + + # Assertion 2 + assert os.environ["API_KEY_1"] == "api_key_1_value" + assert os.environ["API_KEY_2"] == "api_key_2_value" + + async def test_updating_secrets_via_dotenv(self, manager: EnvSecretsManager, dotenv_path: Path): + """ + ASSERTS: After updating an existing `.env` file directly (i.e. through the + filesystem and not the secrets manager) and waiting a few seconds, the + new value is set in `os.environ`. + """ + await manager.update_secrets({"API_KEY": "old_value"}) + with open(dotenv_path, "w") as f: + f.write('API_KEY="new_value"') + await asyncio.sleep(3) + assert os.environ["API_KEY"] == "new_value" + + +class TestEnvSecretsManagerWithInitialEnv(): + """ + Unit tests for the `EnvSecretsManager` in the special scenario where secrets + are set in `os.environ` prior to each test. + """ + # TODO + pass diff --git a/jupyter_ai_jupyternaut/secrets/tests/test_secrets_utils.py b/jupyter_ai_jupyternaut/secrets/tests/test_secrets_utils.py new file mode 100644 index 0000000..2fb4ef2 --- /dev/null +++ b/jupyter_ai_jupyternaut/secrets/tests/test_secrets_utils.py @@ -0,0 +1,127 @@ +from jupyter_ai.secrets.secrets_utils import build_updated_dotenv, get_whitespace_around + + +class TestGetWhitespaceAround: + """Test cases for get_whitespace_around function.""" + + def test_empty_string(self): + """Test with empty string.""" + prefix, suffix = get_whitespace_around("") + assert prefix == "" + assert suffix == "" + + def test_no_whitespace(self): + """Test with string containing no whitespace.""" + prefix, suffix = get_whitespace_around("hello") + assert prefix == "" + assert suffix == "" + + def test_only_prefix_whitespace(self): + """Test with string containing only leading whitespace.""" + prefix, suffix = get_whitespace_around(" hello") + assert prefix == " " + assert suffix == "" + + def test_only_suffix_whitespace(self): + """Test with string containing only trailing whitespace.""" + prefix, suffix = get_whitespace_around("hello ") + assert prefix == "" + assert suffix == " " + + def test_both_prefix_and_suffix_whitespace(self): + """Test with string containing both leading and trailing whitespace.""" + prefix, suffix = get_whitespace_around(" hello ") + assert prefix == " " + assert suffix == " " + + def test_mixed_whitespace_types(self): + """Test with mixed whitespace types (spaces, tabs, newlines).""" + prefix, suffix = get_whitespace_around(" \t\nhello\n\t ") + assert prefix == " \t\n" + assert suffix == "\n\t " + + def test_all_whitespace(self): + """Test with string containing only whitespace.""" + prefix, suffix = get_whitespace_around(" ") + assert prefix == " " + assert suffix == "" + + def test_single_character(self): + """Test with single non-whitespace character.""" + prefix, suffix = get_whitespace_around("x") + assert prefix == "" + assert suffix == "" + + def test_single_whitespace_character(self): + """Test with single whitespace character.""" + prefix, suffix = get_whitespace_around(" ") + assert prefix == " " + assert suffix == "" + + +class TestBuildUpdatedDotenv: + """Test cases for build_updated_dotenv function.""" + + def test_empty_updates(self): + """Test with no updates to make.""" + result = build_updated_dotenv("KEY=value", {}) + assert result is None + + def test_add_to_empty_dotenv(self): + """Test adding secrets to empty dotenv content.""" + result = build_updated_dotenv("", {"NEW_KEY": "new_value"}) + assert result == 'NEW_KEY="new_value"\n' + + def test_add_multiple_to_empty_dotenv(self): + """Test adding multiple secrets to empty dotenv content.""" + result = build_updated_dotenv("", {"KEY1": "value1", "KEY2": "value2"}) + expected_lines = result.strip().split("\n") + assert len(expected_lines) == 3 # Two keys plus one empty line + assert 'KEY1="value1"' in expected_lines + assert 'KEY2="value2"' in expected_lines + + def test_update_existing_key(self): + """Test updating an existing key.""" + dotenv_content = 'EXISTING_KEY="old_value"\n' + result = build_updated_dotenv(dotenv_content, {"EXISTING_KEY": "new_value"}) + assert 'EXISTING_KEY="new_value"' in result + + def test_add_new_key_to_existing_dotenv(self): + """Test adding a new key to existing dotenv content.""" + dotenv_content = 'EXISTING_KEY="existing_value"\n' + result = build_updated_dotenv(dotenv_content, {"NEW_KEY": "new_value"}) + assert 'EXISTING_KEY="existing_value"' in result + assert 'NEW_KEY="new_value"' in result + + def test_remove_existing_key(self): + """Test removing an existing key.""" + dotenv_content = 'KEY_TO_REMOVE="value"\nKEY_TO_KEEP="value"\n' + result = build_updated_dotenv(dotenv_content, {"KEY_TO_REMOVE": None}) + assert "KEY_TO_REMOVE" not in result + assert 'KEY_TO_KEEP="value"' in result + + def test_mixed_operations(self): + """Test adding, updating, and removing keys in one operation.""" + dotenv_content = 'UPDATE_ME="old"\nREMOVE_ME="gone"\nKEEP_ME="same"\n' + updates = {"UPDATE_ME": "new", "REMOVE_ME": None, "ADD_ME": "added"} + result = build_updated_dotenv(dotenv_content, updates) + + assert 'UPDATE_ME="new"' in result + assert "REMOVE_ME" not in result + assert 'KEEP_ME="same"' in result + assert 'ADD_ME="added"' in result + + def test_preserve_comments_and_empty_lines(self): + """Test that comments and empty lines are preserved.""" + dotenv_content = '# This is a comment\nKEY="value"\n\n# Another comment\n' + result = build_updated_dotenv(dotenv_content, {"NEW_KEY": "new_value"}) + + assert "# This is a comment" in result + assert "# Another comment" in result + assert 'KEY="value"' in result + assert 'NEW_KEY="new_value"' in result + + def test_delete_last_secret(self): + dotenv_content = "KEY='value'" + result = build_updated_dotenv(dotenv_content, {"KEY": None}) + assert isinstance(result, str) and result.strip() == "" diff --git a/package.json b/package.json index 86b8acd..a6f3ca8 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "files": [ "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", "style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}", - "src/**/*.{ts,tsx}" + "schema/*.json" ], "main": "lib/index.js", "types": "lib/index.d.ts", @@ -58,9 +58,16 @@ "watch:labextension": "jupyter labextension watch ." }, "dependencies": { - "@jupyterlab/application": "^4.0.0", - "@jupyterlab/coreutils": "^6.0.0", - "@jupyterlab/services": "^7.0.0" + "@emotion/react": "^11.10.5", + "@emotion/styled": "^11.10.5", + "@jupyter-notebook/application": "^7.2.0", + "@jupyter/chat": "^0.17.0", + "@jupyterlab/application": "^4.2.0", + "@jupyterlab/completer": "^4.2.0", + "@jupyterlab/coreutils": "^6.2.0", + "@jupyterlab/services": "^7.2.0", + "@mui/icons-material": "^5.11.0", + "@mui/material": "^5.11.0" }, "devDependencies": { "@jupyterlab/builder": "^4.0.0", @@ -110,7 +117,8 @@ } }, "extension": true, - "outputDir": "jupyter_ai_jupyternaut/labextension" + "outputDir": "jupyter_ai_jupyternaut/labextension", + "schemaDir": "schema" }, "eslintIgnore": [ "node_modules", diff --git a/pyproject.toml b/pyproject.toml index bd62090..9119568 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,9 @@ [build-system] -requires = ["hatchling>=1.5.0", "jupyterlab>=4.0.0,<5", "hatch-nodejs-version>=0.3.2"] +requires = [ + "hatchling>=1.5.0", + "jupyterlab>=4.0.0,<5", + "hatch-nodejs-version>=0.3.2", +] build-backend = "hatchling.build" [project] @@ -23,7 +27,21 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "jupyter_server>=2.4.0,<3" + # `jupyter_collaboration>=4` requires `jupyter_server_ydoc>=2.0.0`, + # which requires `jupyter_server>=2.15.0`. + "jupyter_server>=2.15.0,<3", + # pydantic <2.10.0 raises a "protected namespaces" error in JAI + # - See: https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.protected_namespaces + "pydantic>=2.10.0,<3", + # traitlets>=5.6 is required in JL4 + "traitlets>=5.6", + "deepmerge>=2.0,<3", + # NOTE: Make sure to update the corresponding dependency in + # `packages/jupyter-ai/package.json` to match the version range below + "jupyterlab-chat>=0.17.0,<0.18.0", + "litellm>=1.73,<2", + "jinja2>=3.0,<4", + "python_dotenv>=1,<2", ] dynamic = ["version", "description", "authors", "urls", "keywords"] @@ -33,7 +51,7 @@ test = [ "pytest", "pytest-asyncio", "pytest-cov", - "pytest-jupyter[server]>=0.6.0" + "pytest-jupyter[server]>=0.6.0", ] [tool.hatch.version] @@ -80,7 +98,7 @@ version_cmd = "hatch version" before-build-npm = [ "python -m pip install 'jupyterlab>=4.0.0,<5'", "jlpm", - "jlpm build:prod" + "jlpm build:prod", ] before-build-python = ["jlpm clean:all"] diff --git a/schema/plugin.json b/schema/plugin.json new file mode 100644 index 0000000..08a9bf5 --- /dev/null +++ b/schema/plugin.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "jupyter-ai-jupyternaut", + "description": "jupyter-ai-jupyternaut labextension schema", + "jupyter.lab.setting-icon": "jupyter-ai::chat", + "jupyter.lab.setting-icon-label": "Jupyter AI Chat", + "jupyter.lab.menus": { + "main": [ + { + "id": "jp-mainmenu-settings", + "items": [ + { + "type": "separator", + "rank": 110 + }, + { + "command": "@jupyter-ai/jupyternaut:open-settings", + "rank": 110 + }, + { + "type": "separator", + "rank": 110 + } + ] + } + ] + }, + "properties": { + "showHiddenFiles": { + "type": "boolean", + "title": "Show hidden files", + "description": "Whether to show hidden files (enabled by Jupyter AI v3). The server parameter `ContentsManager.allow_hidden` must be set to `True` to display hidden files.", + "default": true + } + }, + "additionalProperties": false, + "type": "object" +} diff --git a/src/completions/handler.ts b/src/completions/handler.ts new file mode 100644 index 0000000..8e9cb9f --- /dev/null +++ b/src/completions/handler.ts @@ -0,0 +1,149 @@ +import { IDisposable } from '@lumino/disposable'; +import { PromiseDelegate } from '@lumino/coreutils'; +import { ServerConnection } from '@jupyterlab/services'; +import { URLExt } from '@jupyterlab/coreutils'; +import { AiCompleterService as AiService } from './types'; +import { Signal, ISignal } from '@lumino/signaling'; + +const SERVICE_URL = 'api/ai/completion/inline'; + +type StreamChunk = AiService.InlineCompletionStreamChunk; + +export class CompletionWebsocketHandler implements IDisposable { + /** + * The server settings used to make API requests. + */ + readonly serverSettings: ServerConnection.ISettings; + + /** + * Create a new completion handler. + */ + constructor(options: AiService.IOptions = {}) { + this.serverSettings = + options.serverSettings ?? ServerConnection.makeSettings(); + } + + /** + * Initializes the WebSocket connection to the completion backend. Promise is + * resolved when server acknowledges connection and sends the client ID. This + * must be awaited before calling any other method. + */ + public async initialize(): Promise { + await this._initialize(); + } + + /** + * 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: AiService.InlineCompletionRequest + ): Promise { + return new Promise(resolve => { + this._socket?.send(JSON.stringify(message)); + this._replyForResolver[message.number] = resolve; + }); + } + + /** + * Signal emitted when a new chunk of completion is streamed. + */ + get streamed(): ISignal { + return this._streamed; + } + + /** + * Whether the completion handler is disposed. + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * Dispose the completion handler. + */ + dispose(): void { + if (this.isDisposed) { + return; + } + this._isDisposed = true; + + // Clean up socket. + const socket = this._socket; + if (socket) { + this._socket = null; + socket.onopen = () => undefined; + socket.onerror = () => undefined; + socket.onmessage = () => undefined; + socket.onclose = () => undefined; + socket.close(); + } + } + + private _onMessage(message: AiService.CompleterMessage): void { + switch (message.type) { + case 'connection': { + this._initialized.resolve(); + break; + } + case 'stream': { + this._streamed.emit(message); + break; + } + default: { + if (message.reply_to in this._replyForResolver) { + this._replyForResolver[message.reply_to](message); + delete this._replyForResolver[message.reply_to]; + } else { + console.warn('Unhandled message', message); + } + break; + } + } + } + + /** + * Dictionary mapping message IDs to Promise resolvers. + */ + private _replyForResolver: Record< + number, + (value: AiService.InlineCompletionReply) => void + > = {}; + + private _onClose(e: CloseEvent, reject: (reason: unknown) => void) { + reject(new Error('Inline completion websocket disconnected')); + console.error('Inline completion websocket disconnected'); + // only attempt re-connect if there was an abnormal closure + // WebSocket status codes defined in RFC 6455: https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1 + if (e.code === 1006) { + const delaySeconds = 1; + console.info(`Will try to reconnect in ${delaySeconds} s.`); + setTimeout(async () => await this._initialize(), delaySeconds * 1000); + } + } + + private async _initialize(): Promise { + if (this.isDisposed) { + return; + } + const promise = new PromiseDelegate(); + this._initialized = promise; + console.log( + 'Creating a new websocket connection for inline completions...' + ); + const { token, WebSocket, wsUrl } = this.serverSettings; + const url = + URLExt.join(wsUrl, SERVICE_URL) + + (token ? `?token=${encodeURIComponent(token)}` : ''); + + const socket = (this._socket = new WebSocket(url)); + socket.onclose = e => this._onClose(e, promise.reject.bind(promise)); + socket.onerror = e => promise.reject(e); + socket.onmessage = msg => msg.data && this._onMessage(JSON.parse(msg.data)); + } + + private _isDisposed = false; + private _socket: WebSocket | null = null; + private _streamed = new Signal(this); + private _initialized: PromiseDelegate = new PromiseDelegate(); +} diff --git a/src/completions/index.ts b/src/completions/index.ts new file mode 100644 index 0000000..507f37c --- /dev/null +++ b/src/completions/index.ts @@ -0,0 +1 @@ +export { completionPlugin } from './plugin'; diff --git a/src/completions/plugin.ts b/src/completions/plugin.ts new file mode 100644 index 0000000..9a13a49 --- /dev/null +++ b/src/completions/plugin.ts @@ -0,0 +1,194 @@ +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { ICompletionProviderManager } from '@jupyterlab/completer'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { + IEditorLanguageRegistry, + IEditorLanguage +} from '@jupyterlab/codemirror'; +import { getEditor } from '../utils'; +import { IJaiStatusItem, IJaiCompletionProvider } from '../tokens'; +import { displayName, JaiInlineProvider } from './provider'; +import { CompletionWebsocketHandler } from './handler'; + +export namespace CommandIDs { + /** + * Command to toggle completions globally. + */ + export const toggleCompletions = 'jupyter-ai:toggle-completions'; + /** + * Command to toggle completions for specific language. + */ + export const toggleLanguageCompletions = + 'jupyter-ai:toggle-language-completions'; +} + +const INLINE_COMPLETER_PLUGIN = + '@jupyterlab/completer-extension:inline-completer'; + +/** + * Type of the settings object for the inline completer plugin. + */ +type IcPluginSettings = ISettingRegistry.ISettings & { + user: { + providers?: { + [key: string]: unknown; + [JaiInlineProvider.ID]?: JaiInlineProvider.ISettings; + }; + }; + composite: { + providers: { + [key: string]: unknown; + [JaiInlineProvider.ID]: JaiInlineProvider.ISettings; + }; + }; +}; + +type JaiCompletionToken = IJaiCompletionProvider | null; + +export const completionPlugin: JupyterFrontEndPlugin = { + id: '@jupyter-ai/jupyternaut:inline-completions', + autoStart: true, + requires: [ + ICompletionProviderManager, + IEditorLanguageRegistry, + ISettingRegistry + ], + optional: [IJaiStatusItem], + provides: IJaiCompletionProvider, + activate: async ( + app: JupyterFrontEnd, + completionManager: ICompletionProviderManager, + languageRegistry: IEditorLanguageRegistry, + settingRegistry: ISettingRegistry, + statusItem: IJaiStatusItem | null + ): Promise => { + if (typeof completionManager.registerInlineProvider === 'undefined') { + // Gracefully short-circuit on JupyterLab 4.0 and Notebook 7.0 + console.warn( + 'Inline completions are only supported in JupyterLab 4.1+ and Jupyter Notebook 7.1+' + ); + return null; + } + + const completionHandler = new CompletionWebsocketHandler(); + const provider = new JaiInlineProvider({ + completionHandler, + languageRegistry + }); + + await completionHandler.initialize(); + completionManager.registerInlineProvider(provider); + + const findCurrentLanguage = (): IEditorLanguage | null => { + const widget = app.shell.currentWidget; + const editor = getEditor(widget); + if (!editor) { + return null; + } + return languageRegistry.findByMIME(editor.model.mimeType); + }; + + // ic := inline completion + async function getIcSettings() { + return (await settingRegistry.load( + INLINE_COMPLETER_PLUGIN + )) as IcPluginSettings; + } + + /** + * Gets the composite settings for the Jupyter AI inline completion provider + * (JaiIcp). + * + * This reads from the `ISettings.composite` property, which merges the user + * settings with the provider defaults, defined in + * `JaiInlineProvider.DEFAULT_SETTINGS`. + */ + async function getJaiIcpSettings() { + const icSettings = await getIcSettings(); + return icSettings.composite.providers[JaiInlineProvider.ID]; + } + + /** + * Updates the JaiIcp user settings. + */ + async function updateJaiIcpSettings( + newJaiIcpSettings: Partial + ) { + const icSettings = await getIcSettings(); + const oldUserIcpSettings = icSettings.user.providers; + const newUserIcpSettings = { + ...oldUserIcpSettings, + [JaiInlineProvider.ID]: { + ...oldUserIcpSettings?.[JaiInlineProvider.ID], + ...newJaiIcpSettings + } + }; + icSettings.set('providers', newUserIcpSettings); + } + + app.commands.addCommand(CommandIDs.toggleCompletions, { + execute: async () => { + const jaiIcpSettings = await getJaiIcpSettings(); + updateJaiIcpSettings({ + enabled: !jaiIcpSettings.enabled + }); + }, + label: 'Enable completions by Jupyternaut', + isToggled: () => { + return provider.isEnabled(); + } + }); + + app.commands.addCommand(CommandIDs.toggleLanguageCompletions, { + execute: async () => { + const jaiIcpSettings = await getJaiIcpSettings(); + const language = findCurrentLanguage(); + if (!language) { + return; + } + + const disabledLanguages = [...jaiIcpSettings.disabledLanguages]; + const newDisabledLanguages = disabledLanguages.includes(language.name) + ? disabledLanguages.filter(l => l !== language.name) + : disabledLanguages.concat(language.name); + + updateJaiIcpSettings({ + disabledLanguages: newDisabledLanguages + }); + }, + label: () => { + const language = findCurrentLanguage(); + return language + ? `Disable completions in ${displayName(language)}` + : 'Disable completions in files'; + }, + isToggled: () => { + const language = findCurrentLanguage(); + return !!language && !provider.isLanguageEnabled(language.name); + }, + isVisible: () => { + const language = findCurrentLanguage(); + return !!language; + }, + isEnabled: () => { + const language = findCurrentLanguage(); + return !!language && provider.isEnabled(); + } + }); + + if (statusItem) { + statusItem.addItem({ + command: CommandIDs.toggleCompletions, + rank: 1 + }); + statusItem.addItem({ + command: CommandIDs.toggleLanguageCompletions, + rank: 2 + }); + } + return provider; + } +}; diff --git a/src/completions/provider.ts b/src/completions/provider.ts new file mode 100644 index 0000000..4fd3fde --- /dev/null +++ b/src/completions/provider.ts @@ -0,0 +1,325 @@ +import { + InlineCompletionTriggerKind, + IInlineCompletionProvider, + IInlineCompletionContext, + IInlineCompletionList, + IInlineCompletionItem, + CompletionHandler +} from '@jupyterlab/completer'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { Notification, showErrorMessage } from '@jupyterlab/apputils'; +import { JSONValue, PromiseDelegate } from '@lumino/coreutils'; +import { ISignal, Signal } from '@lumino/signaling'; +import { + IEditorLanguageRegistry, + IEditorLanguage +} from '@jupyterlab/codemirror'; +import { NotebookPanel } from '@jupyterlab/notebook'; +import { IJaiCompletionProvider } from '../tokens'; +import { AiCompleterService as AiService } from './types'; +import { DocumentWidget } from '@jupyterlab/docregistry'; +import { jupyternautIcon } from '../icons'; +import { CompletionWebsocketHandler } from './handler'; + +type StreamChunk = AiService.InlineCompletionStreamChunk; + +/** + * Format the language name nicely. + */ +export function displayName(language: IEditorLanguage): string { + if (language.name === 'ipythongfm') { + return 'Markdown (IPython)'; + } + if (language.name === 'ipython') { + return 'IPython'; + } + return language.displayName ?? language.name; +} + +export class JaiInlineProvider + implements IInlineCompletionProvider, IJaiCompletionProvider +{ + readonly identifier = JaiInlineProvider.ID; + readonly icon = jupyternautIcon.bindprops({ width: 16, top: 1 }); + + constructor(protected options: JaiInlineProvider.IOptions) { + options.completionHandler.streamed.connect(this._receiveStreamChunk, this); + } + + get name(): string { + return 'JupyterAI'; + } + + async fetch( + request: CompletionHandler.IRequest, + context: IInlineCompletionContext + ): Promise> { + const allowedTriggerKind = this._settings.triggerKind; + const triggerKind = context.triggerKind; + if ( + allowedTriggerKind === 'manual' && + triggerKind !== InlineCompletionTriggerKind.Invoke + ) { + // Short-circuit if user requested to only invoke inline completions + // on manual trigger for jupyter-ai. Users may still get completions + // from other (e.g. less expensive or faster) providers. + return { items: [] }; + } + const mime = request.mimeType ?? 'text/plain'; + const language = this.options.languageRegistry.findByMIME(mime); + if (!language) { + console.warn( + `Could not recognise language for ${mime} - cannot complete` + ); + return { items: [] }; + } + if (!this.isLanguageEnabled(language?.name)) { + // Do not offer suggestions if disabled. + return { items: [] }; + } + let cellId = undefined; + let path = context.session?.path; + if (context.widget instanceof NotebookPanel) { + const activeCell = context.widget.content.activeCell; + if (activeCell) { + cellId = activeCell.model.id; + } + } + if (!path && context.widget instanceof DocumentWidget) { + path = context.widget.context.path; + } + const number = ++this._counter; + + const streamPreference = this._settings.streaming; + const stream = + streamPreference === 'always' + ? true + : streamPreference === 'never' + ? false + : context.triggerKind === InlineCompletionTriggerKind.Invoke; + + if (stream) { + // Reset stream promises handler + this._streamPromises.clear(); + } + const result = await this.options.completionHandler.sendMessage({ + path, + mime, + prefix: this._prefixFromRequest(request), + suffix: this._suffixFromRequest(request), + language: this._resolveLanguage(language), + number, + stream, + cell_id: cellId + }); + + const error = result.error; + if (error) { + Notification.emit(`Inline completion failed: ${error.type}`, 'error', { + autoClose: false, + actions: [ + { + label: 'Show Traceback', + callback: () => { + showErrorMessage('Inline completion failed on the server side', { + message: `${error.title}\n${error.traceback}` + }); + } + } + ] + }); + const items = [ + { + error: { message: error.title }, + insertText: '' + } + ]; + return { items }; + } + return result.list; + } + + /** + * Stream a reply for completion identified by given `token`. + */ + async *stream(token: string): AsyncGenerator { + let done = false; + while (!done) { + const delegate = new PromiseDelegate(); + this._streamPromises.set(token, delegate); + const promise = delegate.promise; + yield promise; + done = (await promise).done; + } + } + + get schema(): ISettingRegistry.IProperty { + const knownLanguages = this.options.languageRegistry.getLanguages(); + return { + properties: { + triggerKind: { + title: 'Inline completions trigger', + type: 'string', + oneOf: [ + { const: 'any', title: 'Automatic (on typing or invocation)' }, + { const: 'manual', title: 'Only when invoked manually' } + ], + description: + 'When to trigger inline completions when using jupyter-ai.' + }, + maxPrefix: { + title: 'Maximum prefix length', + minimum: 1, + type: 'number', + description: + 'At most how many prefix characters should be provided to the model.' + }, + maxSuffix: { + title: 'Maximum suffix length', + minimum: 0, + type: 'number', + description: + 'At most how many suffix characters should be provided to the model.' + }, + disabledLanguages: { + title: 'Disabled languages', + type: 'array', + items: { + type: 'string', + oneOf: knownLanguages.map(language => { + return { const: language.name, title: displayName(language) }; + }) + }, + description: + 'Languages for which the completions should not be shown.' + }, + streaming: { + title: 'Streaming', + type: 'string', + oneOf: [ + { const: 'always', title: 'Always' }, + { const: 'manual', title: 'When invoked manually' }, + { const: 'never', title: 'Never' } + ], + description: 'Whether to show suggestions as they are generated' + } + }, + default: JaiInlineProvider.DEFAULT_SETTINGS as any + }; + } + + async configure(settings: { [property: string]: JSONValue }): Promise { + this._settings = settings as unknown as JaiInlineProvider.ISettings; + this._settingsChanged.emit(); + } + + isEnabled(): boolean { + return this._settings.enabled; + } + + isLanguageEnabled(language: string): boolean { + return !this._settings.disabledLanguages.includes(language); + } + + get settingsChanged(): ISignal { + return this._settingsChanged; + } + + /** + * Process the stream chunk to make it available in the awaiting generator. + */ + private _receiveStreamChunk( + _emitter: CompletionWebsocketHandler, + chunk: StreamChunk + ) { + const token = chunk.response.token; + if (!token) { + throw Error('Stream chunks must return define `token` in `response`'); + } + const delegate = this._streamPromises.get(token); + if (!delegate) { + console.warn('Unhandled stream chunk'); + } else { + delegate.resolve(chunk); + if (chunk.done) { + this._streamPromises.delete(token); + } + } + } + + /** + * Extract prefix from request, accounting for context window limit. + */ + private _prefixFromRequest(request: CompletionHandler.IRequest): string { + const textBefore = request.text.slice(0, request.offset); + const prefix = textBefore.slice( + -Math.min(this._settings.maxPrefix, textBefore.length) + ); + return prefix; + } + + /** + * Extract suffix from request, accounting for context window limit. + */ + private _suffixFromRequest(request: CompletionHandler.IRequest): string { + const textAfter = request.text.slice(request.offset); + const prefix = textAfter.slice( + 0, + Math.min(this._settings.maxPrefix, textAfter.length) + ); + return prefix; + } + + private _resolveLanguage(language: IEditorLanguage | null) { + if (!language) { + return 'plain English'; + } + if (language.name === 'ipython') { + return 'python'; + } else if (language.name === 'ipythongfm') { + return 'markdown'; + } + return language.name; + } + + private _settings: JaiInlineProvider.ISettings = + JaiInlineProvider.DEFAULT_SETTINGS; + private _settingsChanged = new Signal(this); + + private _streamPromises: Map> = + new Map(); + private _counter = 0; +} + +export namespace JaiInlineProvider { + export const ID = '@jupyterlab/jupyter-ai'; + + export interface IOptions { + completionHandler: CompletionWebsocketHandler; + languageRegistry: IEditorLanguageRegistry; + } + + export interface ISettings { + triggerKind: 'any' | 'manual'; + maxPrefix: number; + maxSuffix: number; + debouncerDelay: number; + enabled: boolean; + disabledLanguages: string[]; + streaming: 'always' | 'manual' | 'never'; + } + + export const DEFAULT_SETTINGS: ISettings = { + triggerKind: 'any', + maxPrefix: 10000, + maxSuffix: 10000, + // The debouncer delay handling is implemented upstream in JupyterLab; + // here we just increase the default from 0, as compared to kernel history + // the external AI models may have a token cost associated. + debouncerDelay: 250, + enabled: false, + // ipythongfm means "IPython GitHub Flavoured Markdown" + disabledLanguages: ['ipythongfm'], + streaming: 'manual' + }; +} diff --git a/src/completions/types.ts b/src/completions/types.ts new file mode 100644 index 0000000..f491e50 --- /dev/null +++ b/src/completions/types.ts @@ -0,0 +1,62 @@ +import type { + IInlineCompletionList, + IInlineCompletionItem +} from '@jupyterlab/completer'; + +import { ServerConnection } from '@jupyterlab/services'; + +export namespace AiCompleterService { + /** + * The instantiation options for a data registry handler. + */ + export interface IOptions { + serverSettings?: ServerConnection.ISettings; + } + + export type ConnectionMessage = { + type: 'connection'; + client_id: string; + }; + + export type InlineCompletionRequest = { + number: number; + path?: string; + /* The model has to complete given prefix */ + prefix: string; + /* The model may consider the following suffix */ + suffix: string; + mime: string; + /* Whether to stream the response (if streaming is supported by the model) */ + stream: boolean; + language?: string; + cell_id?: string; + }; + + export type CompletionError = { + type: string; + traceback: string; + title: string; + }; + + export type InlineCompletionReply = { + /** + * Type for this message can be skipped (`inline_completion` is presumed default). + **/ + type?: 'inline_completion'; + list: IInlineCompletionList; + reply_to: number; + error?: CompletionError; + }; + + export type InlineCompletionStreamChunk = { + type: 'stream'; + response: IInlineCompletionItem; + reply_to: number; + done: boolean; + }; + + export type CompleterMessage = + | InlineCompletionReply + | ConnectionMessage + | InlineCompletionStreamChunk; +} diff --git a/src/components/chat-settings.tsx b/src/components/chat-settings.tsx new file mode 100644 index 0000000..b66c734 --- /dev/null +++ b/src/components/chat-settings.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useState } from 'react'; + +import { Box } from '@mui/system'; +import { IconButton, Tooltip } from '@mui/material'; +import SettingsIcon from '@mui/icons-material/Settings'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; + +import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; +import { IJaiCompletionProvider } from '../tokens'; +import { ModelIdInput } from './settings/model-id-input'; +import { ModelParametersInput } from './settings/model-parameters-input'; +import { SecretsSection } from './settings/secrets-section'; + +type ChatSettingsProps = { + rmRegistry: IRenderMimeRegistry; + completionProvider: IJaiCompletionProvider | null; + openInlineCompleterSettings: () => void; +}; + +const MODEL_ID_HELP = ( +

+ You may provide any model ID accepted by LiteLLM. Details can be found from + the{' '} + + LiteLLM documentation + + . +

+); + +/** + * Component that returns the settings view in the chat panel. + */ +export function ChatSettings(props: ChatSettingsProps): JSX.Element { + const [completionModel, setCompletionModel] = useState(null); + const [chatModel, setChatModel] = useState(null); + const [isCompleterEnabled, setIsCompleterEnabled] = useState( + props.completionProvider && props.completionProvider.isEnabled() + ); + + /** + * Effect: Listen to JupyterLab completer settings updates on initial render + * and update the `isCompleterEnabled` state variable accordingly. + */ + useEffect(() => { + const refreshCompleterState = () => { + setIsCompleterEnabled( + props.completionProvider && props.completionProvider.isEnabled() + ); + }; + props.completionProvider?.settingsChanged.connect(refreshCompleterState); + return () => { + props.completionProvider?.settingsChanged.disconnect( + refreshCompleterState + ); + }; + }, [props.completionProvider]); + + return ( + + {/* SECTION: Chat model */} +

Chat model

+

Configure the language model used by Jupyternaut in chats.

+ {MODEL_ID_HELP} + setChatModel(modelId)} + /> + + {/* SECTION: Completion model */} + {/* TODO: Re-enable this when the completion backend works with LiteLLM. */} + +

+ Completion model + +

+

+ Configure the language model used to generate inline completions when + editing documents in JupyterLab. +

+ {MODEL_ID_HELP} + { + setCompletionModel(modelId); + }} + /> +
+ + {/* Model parameters section */} +

Model parameters

+

Configure additional parameters for the language model.

+ + + {/* SECTION: Secrets (and API keys) */} +

Secrets and API keys

+ +
+ ); +} + +function CompleterSettingsButton(props: { + hasCompletionModel: boolean; + provider: IJaiCompletionProvider | null; + isCompleterEnabled: boolean | null; + openSettings: () => void; +}): JSX.Element { + if (props.hasCompletionModel && !props.isCompleterEnabled) { + return ( + + + + + + ); + } + return ( + + + + + + ); +} diff --git a/src/components/jl-theme-provider.tsx b/src/components/jl-theme-provider.tsx new file mode 100644 index 0000000..9bc5e8a --- /dev/null +++ b/src/components/jl-theme-provider.tsx @@ -0,0 +1,23 @@ +import React, { useState, useEffect } from 'react'; +import type { IThemeManager } from '@jupyterlab/apputils'; +import { Theme, ThemeProvider, createTheme } from '@mui/material/styles'; + +import { getJupyterLabTheme } from '../theme-provider'; + +export function JlThemeProvider(props: { + themeManager: IThemeManager | null; + children: React.ReactNode; +}): JSX.Element { + const [theme, setTheme] = useState(createTheme()); + + useEffect(() => { + async function setJlTheme() { + setTheme(await getJupyterLabTheme()); + } + + setJlTheme(); + props.themeManager?.themeChanged.connect(setJlTheme); + }, []); + + return {props.children}; +} diff --git a/src/components/message-footer/stop-button.tsx b/src/components/message-footer/stop-button.tsx new file mode 100644 index 0000000..7424167 --- /dev/null +++ b/src/components/message-footer/stop-button.tsx @@ -0,0 +1,73 @@ +import { + IChatModel, + MessageFooterSectionProps, + TooltippedButton +} from '@jupyter/chat'; +import StopIcon from '@mui/icons-material/Stop'; +import React, { useEffect, useState } from 'react'; +import { requestAPI } from '../../handler'; + +/** + * The stop button. + */ +export function StopButton(props: MessageFooterSectionProps): JSX.Element { + const { message, model } = props; + const [visible, setVisible] = useState(false); + const tooltip = 'Stop streaming'; + + useEffect(() => { + const writerChanged = (_: IChatModel, writers: IChatModel.IWriter[]) => { + const w = writers.filter(w => w.messageID === message.id); + if (w.length > 0) { + setVisible(true); + } else { + setVisible(false); + } + }; + + // Listen only the messages that are from a bot. + if ( + message.sender.username !== model.user?.username && + message.sender.bot + ) { + model.writersChanged?.connect(writerChanged); + + // Check if the message is currently being edited. + writerChanged(model, model.writers); + } + + return () => { + model.writersChanged?.disconnect(writerChanged); + }; + }, [model]); + + const onClick = () => { + // Post request to the stop streaming handler. + requestAPI('chats/stop_streaming', { + method: 'POST', + body: JSON.stringify({ + message_id: message.id + }), + headers: { + 'Content-Type': 'application/json' + } + }); + }; + + return visible ? ( + + + + ) : ( + <> + ); +} diff --git a/src/components/mui-extras/async-icon-button.tsx b/src/components/mui-extras/async-icon-button.tsx new file mode 100644 index 0000000..14207f6 --- /dev/null +++ b/src/components/mui-extras/async-icon-button.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import { Box, CircularProgress, IconButton } from '@mui/material'; +import { ContrastingTooltip } from './contrasting-tooltip'; + +type AsyncIconButtonProps = { + onClick: () => 'canceled' | Promise; + onError: (emsg: string) => unknown; + onSuccess: () => unknown; + children: JSX.Element; + onMouseDown?: React.MouseEventHandler; + /** + * Whether this component should require confirmation from the user before + * calling `props.onClick()`. This is only read on initial render. + */ + confirm?: boolean; +}; + +/** + * A MUI IconButton that indicates whether the click handler is resolving via a + * circular spinner around the IconButton. Requests user confirmation when + * `confirm` is set to `true`. + */ +export function AsyncIconButton(props: AsyncIconButtonProps): JSX.Element { + const [loading, setLoading] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + async function handleClick() { + if (props.confirm && !showConfirm) { + setShowConfirm(true); + return; + } + + let thrown = false; + try { + const promise = props.onClick(); + if (promise === 'canceled') { + return; + } + setLoading(true); + await promise; + } catch (e: unknown) { + thrown = true; + if (e instanceof Error) { + props.onError(e.toString()); + } else { + // this should never happen. + // if this happens, it means the thrown value was not of type `Error`. + console.error(e); + props.onError( + 'Unknown error occurred. Check the browser console logs.' + ); + } + } finally { + setLoading(false); + } + if (!thrown) { + props.onSuccess(); + } + } + + return ( + + setShowConfirm(false)} + arrow + placement="top" + > + + {props.children} + + + {loading && ( + + )} + + ); +} diff --git a/src/components/mui-extras/contrasting-tooltip.tsx b/src/components/mui-extras/contrasting-tooltip.tsx new file mode 100644 index 0000000..21f5a86 --- /dev/null +++ b/src/components/mui-extras/contrasting-tooltip.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { styled, Tooltip, TooltipProps, tooltipClasses } from '@mui/material'; + +/** + * A restyled MUI tooltip component that is dark by default to improve contrast + * against JupyterLab's default light theme. TODO: support dark themes. + */ +export const ContrastingTooltip = styled( + ({ className, ...props }: TooltipProps) => ( + + ) +)(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: theme.palette.common.black, + color: theme.palette.common.white, + boxShadow: theme.shadows[1], + fontSize: 11 + }, + [`& .${tooltipClasses.arrow}`]: { + color: theme.palette.common.black + } +})); diff --git a/src/components/mui-extras/simple-autocomplete.tsx b/src/components/mui-extras/simple-autocomplete.tsx new file mode 100644 index 0000000..208ce5a --- /dev/null +++ b/src/components/mui-extras/simple-autocomplete.tsx @@ -0,0 +1,356 @@ +import React, { + useState, + useRef, + useEffect, + useMemo, + useCallback +} from 'react'; +import { + TextField, + MenuItem, + Paper, + Popper, + ClickAwayListener, + TextFieldProps, + IconButton, + InputAdornment +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import ClearIcon from '@mui/icons-material/Clear'; + +const StyledPopper = styled(Popper)(({ theme }) => ({ + zIndex: theme.zIndex.modal, + '& .MuiPaper-root': { + maxHeight: '200px', + overflow: 'auto', + border: `1px solid ${theme.palette.divider}`, + boxShadow: theme.shadows[8] + } +})); + +export type AutocompleteOption = { + label: string; + value: string; +}; + +export type SimpleAutocompleteProps = { + /** + * List of options to show. Each option value should be unique. + */ + options: AutocompleteOption[]; + /** + * (optional) Controls the value of the `Autocomplete` component. + */ + value?: string; + /** + * (optional) Callback fired when the input changes. + */ + onChange?: (value: string) => void; + /** + * (optional) Placeholder string shown in the text input while it is empty. + * This can be used to provide a short example blurb. + */ + placeholder?: string; + /** + * (optional) Function that filters the list of options based on the input + * value. By default, options whose labels do not contain the input value as a + * substring are filtered and hidden. The default filter only filters the list + * of options if the input contains >1 non-whitespace character. + */ + optionsFilter?: ( + options: AutocompleteOption[], + inputValue: string + ) => AutocompleteOption[]; + /** + * (optional) Additional props passed directly to the `TextField` child + * component. + */ + textFieldProps?: Omit; + /** + * (optional) Controls the number of options shown in the autocomplete menu. + * Defaults to unlimited. + */ + maxOptions?: number; + /** + * (optional) If true, the component will treat options as case-sensitive when + * the default options filter is used (i.e. `props.optionsFilter` is unset). + */ + caseSensitive?: boolean; + /** + * (optional) If true, the component will bold the substrings matching the + * current input on each option. The input must contain >1 non-whitespace + * character for this prop to take effect. + */ + boldMatches?: boolean; + + /** + * (optional) If true, shows a clear button when the input has a value. + */ + showClearButton?: boolean; +}; + +function defaultOptionsFilter( + options: AutocompleteOption[], + inputValue: string, + caseSensitive = false +): AutocompleteOption[] { + // Do nothing if the input contains <=1 non-whitespace character + if (inputValue.trim().length <= 1) { + return options; + } + + const searchValue = caseSensitive ? inputValue : inputValue.toLowerCase(); + + return options.filter(option => { + const optionLabel = caseSensitive + ? option.label + : option.label.toLowerCase(); + return optionLabel.includes(searchValue); + }); +} + +function highlightMatches( + text: string, + searchValue: string, + caseSensitive = false +): React.ReactNode { + // Do nothing if the input contains <=1 non-whitespace character + if (searchValue.trim().length <= 1) { + return text; + } + + const searchText = caseSensitive ? searchValue : searchValue.toLowerCase(); + const targetText = caseSensitive ? text : text.toLowerCase(); + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let matchIndex = targetText.indexOf(searchText); + + while (matchIndex !== -1) { + if (matchIndex > lastIndex) { + parts.push(text.slice(lastIndex, matchIndex)); + } + + parts.push( + + {text.slice(matchIndex, matchIndex + searchText.length)} + + ); + + lastIndex = matchIndex + searchText.length; + matchIndex = targetText.indexOf(searchText, lastIndex); + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts.length > 0 ? <>{parts} : text; +} + +/** + * A simple `Autocomplete` component with an emphasis on being bug-free and + * performant. Notes: + * + * - By default, options are filtered using case-insensitive substring matching. + * + * - Clicking an option sets the value of this component and fires + * `props.onChange()` if passed. It is treated identically to a user typing the + * option literally. + * + * - Matched substrings will be shown in bold on each option when the + * `boldMatches` prop is set. + */ +export function SimpleAutocomplete( + props: SimpleAutocompleteProps +): React.ReactElement { + const [inputValue, setInputValue] = useState(props.value || ''); + const [isOpen, setIsOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); + const textFieldRef = useRef(null); + const inputRef = useRef(null); + + // Filter and limit options + const filteredOptions = useMemo(() => { + const filterFn = props.optionsFilter || defaultOptionsFilter; + const filtered = filterFn(props.options, inputValue, props.caseSensitive); + return filtered.slice(0, props.maxOptions ?? props.options.length); + }, [ + props.options, + inputValue, + props.optionsFilter, + props.maxOptions, + props.caseSensitive + ]); + + // Sync external value changes + useEffect(() => { + setInputValue(props.value || ''); + }, [props.value]); + + // Determine if menu should be open + const shouldShowMenu = isOpen && filteredOptions.length > 0; + + const handleInputChange = useCallback( + (event: React.ChangeEvent): void => { + const newValue = event.target.value; + setInputValue(newValue); + setFocusedIndex(-1); + + if (!isOpen && newValue.trim() !== '') { + setIsOpen(true); + } + + if (props.onChange) { + props.onChange(newValue); + } + }, + [isOpen, props.onChange] + ); + + const handleInputFocus = useCallback((): void => { + setIsOpen(true); + }, []); + + const handleOptionClick = useCallback( + (option: AutocompleteOption): void => { + setInputValue(option.value); + setIsOpen(false); + setFocusedIndex(-1); + + if (props.onChange) { + props.onChange(option.value); + } + + if (inputRef.current) { + inputRef.current.blur(); + } + }, + [props.onChange] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): void => { + if (!shouldShowMenu) { + return; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setFocusedIndex(prev => { + return prev < filteredOptions.length - 1 ? prev + 1 : 0; + }); + break; + + case 'ArrowUp': + event.preventDefault(); + setFocusedIndex(prev => { + return prev > 0 ? prev - 1 : filteredOptions.length - 1; + }); + break; + + case 'Enter': + event.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) { + handleOptionClick(filteredOptions[focusedIndex]); + } + break; + + case 'Escape': + setIsOpen(false); + setFocusedIndex(-1); + break; + } + }, + [shouldShowMenu, filteredOptions, focusedIndex, handleOptionClick] + ); + + const handleClickAway = useCallback((): void => { + setIsOpen(false); + setFocusedIndex(-1); + }, []); + + return ( + +
+ + { + setInputValue(''); + if (props.onChange) { + props.onChange(''); + } + if (inputRef.current) { + inputRef.current.focus(); + } + }} + edge="end" + size="small" + > + + + + ) : ( + props.textFieldProps?.InputProps?.endAdornment + ) + }} + /> + + + + {filteredOptions.map((option, index) => { + const displayLabel = props.boldMatches + ? highlightMatches( + option.label, + inputValue, + props.caseSensitive + ) + : option.label; + + return ( + { + handleOptionClick(option); + }} + sx={{ + '&.Mui-selected': { + backgroundColor: 'action.hover' + }, + '&.Mui-selected:hover': { + backgroundColor: 'action.selected' + } + }} + > + {displayLabel} + + ); + })} + + +
+
+ ); +} diff --git a/src/components/mui-extras/stacking-alert.tsx b/src/components/mui-extras/stacking-alert.tsx new file mode 100644 index 0000000..2fe438d --- /dev/null +++ b/src/components/mui-extras/stacking-alert.tsx @@ -0,0 +1,100 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { Alert, AlertColor, Collapse } from '@mui/material'; + +export type StackingAlert = { + /** + * A function that triggers an alert. Successive alerts are indicated in the + * JSX element. + * @param alertType Type of alert. + * @param msg Message contained within the alert. + * @returns + */ + show: (alertType: AlertColor, msg: string | Error) => void; + /** + * The Alert JSX element that should be rendered by the consumer. + * This will be `null` if no alerts were triggered. + */ + jsx: JSX.Element | null; + /** + * An async function that closes the alert, and returns a Promise that + * resolves when the onClose animation is completed. + */ + clear: () => void | Promise; +}; + +/** + * Hook that returns a function to trigger an alert, and a corresponding alert + * JSX element for the consumer to render. The number of successive identical + * alerts `X` is indicated in the element via the suffix "(X)". + */ +export function useStackingAlert(): StackingAlert { + const [type, setType] = useState(null); + const [msg, setMsg] = useState(''); + const [repeatCount, setRepeatCount] = useState(0); + const [expand, setExpand] = useState(false); + const [exitPromise, setExitPromise] = useState>(); + const [exitPromiseResolver, setExitPromiseResolver] = useState<() => void>(); + + const showAlert = useCallback( + (nextType: AlertColor, _nextMsg: string | Error) => { + // if the alert is identical to the previous alert, increment the + // `repeatCount` indicator. + const nextMsg = _nextMsg.toString(); + if (nextType === type && nextMsg === msg) { + setRepeatCount(currCount => currCount + 1); + return; + } + + if (type === null) { + // if this alert is being shown for the first time, initialize the + // exitPromise so we can await it on `clear()`. + setExitPromise( + new Promise(res => { + setExitPromiseResolver(() => res); + }) + ); + } + + setType(nextType); + setMsg(nextMsg); + setRepeatCount(0); + setExpand(true); + }, + [msg, type] + ); + + const alertJsx = useMemo( + () => ( + { + exitPromiseResolver?.(); + // only clear the alert after the Collapse exits, otherwise the alert + // disappears without any animation. + setType(null); + setMsg(''); + setRepeatCount(0); + }} + timeout={200} + > + {type !== null && ( + + {msg + (repeatCount ? ` (${repeatCount})` : '')} + + )} + + ), + [msg, repeatCount, type, expand, exitPromiseResolver] + ); + + const clearAlert = useCallback(() => { + setExpand(false); + return exitPromise; + }, [expand, exitPromise]); + + return { + show: showAlert, + jsx: alertJsx, + clear: clearAlert + }; +} diff --git a/src/components/mui-extras/tooltipped-icon-button.tsx b/src/components/mui-extras/tooltipped-icon-button.tsx new file mode 100644 index 0000000..e379ab3 --- /dev/null +++ b/src/components/mui-extras/tooltipped-icon-button.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { + IconButton, + IconButtonProps, + SxProps, + TooltipProps +} from '@mui/material'; + +import { ContrastingTooltip } from './contrasting-tooltip'; + +export type TooltippedIconButtonProps = { + onClick: React.MouseEventHandler; + tooltip: string; + children: JSX.Element; + disabled?: boolean; + placement?: TooltipProps['placement']; + /** + * The offset of the tooltip popup. + * + * The expected syntax is defined by the Popper library: + * https://popper.js.org/docs/v2/modifiers/offset/ + */ + offset?: [number, number]; + 'aria-label'?: string; + /** + * Props passed directly to the MUI `IconButton` component. + */ + iconButtonProps?: IconButtonProps; + /** + * Styles applied to the MUI `IconButton` component. + */ + sx?: SxProps; +}; + +/** + * A component that renders an MUI `IconButton` with a high-contrast tooltip + * provided by `ContrastingTooltip`. This component differs from the MUI + * defaults in the following ways: + * + * - Shows the tooltip on hover even if disabled. + * - Renders the tooltip above the button by default. + * - Renders the tooltip closer to the button by default. + * - Lowers the opacity of the IconButton when disabled. + * - Renders the IconButton with `line-height: 0` to avoid showing extra + * vertical space in SVG icons. + * + * NOTE TO DEVS: Please keep this component's features synchronized with + * features available to `TooltippedButton`. + */ +export function TooltippedIconButton( + props: TooltippedIconButtonProps +): JSX.Element { + return ( + + {/* + By default, tooltips never appear when the IconButton is disabled. The + official way to support this feature in MUI is to wrap the child Button + element in a `span` element. + + See: https://mui.com/material-ui/react-tooltip/#disabled-elements + */} + + + {props.children} + + + + ); +} diff --git a/src/components/select.tsx b/src/components/select.tsx new file mode 100644 index 0000000..5d6a3a9 --- /dev/null +++ b/src/components/select.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { FormControl, InputLabel, Select as MuiSelect } from '@mui/material'; +import type { + SelectChangeEvent, + SelectProps as MuiSelectProps +} from '@mui/material'; + +export type SelectProps = Omit, 'value' | 'onChange'> & { + value: string | null; + onChange: ( + event: SelectChangeEvent, + child: React.ReactNode + ) => void; +}; + +/** + * A helpful wrapper around MUI's native `Select` component that provides the + * following services: + * + * - automatically wraps base `Select` component in `FormControl` context and + * prepends an input label derived from `props.label`. + * + * - limits max height of menu + * + * - handles `null` values by coercing them to the string `'null'`. The + * corresponding `MenuItem` should have the value `'null'`. + */ +export function Select(props: SelectProps): JSX.Element { + return ( + + {props.label} + { + if (e.target.value === 'null') { + e.target.value = null as any; + } + props.onChange?.(e, child); + }} + MenuProps={{ sx: { maxHeight: '50%', minHeight: 400 } }} + > + {props.children} + + + ); +} diff --git a/src/components/settings/__tests__/chat-settings.spec.ts b/src/components/settings/__tests__/chat-settings.spec.ts new file mode 100644 index 0000000..edb2d33 --- /dev/null +++ b/src/components/settings/__tests__/chat-settings.spec.ts @@ -0,0 +1,63 @@ +import { minifyPatchObject } from '../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/settings/minify.ts b/src/components/settings/minify.ts new file mode 100644 index 0000000..3de096e --- /dev/null +++ b/src/components/settings/minify.ts @@ -0,0 +1,56 @@ +import { AiService } from '../../handler'; + +/** + * 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: AiService.DescribeConfigResponse, + update: AiService.UpdateConfigRequest +): AiService.UpdateConfigRequest { + return minifyPatchObject(config, update) as AiService.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/model-id-input.tsx b/src/components/settings/model-id-input.tsx new file mode 100644 index 0000000..f92d1dd --- /dev/null +++ b/src/components/settings/model-id-input.tsx @@ -0,0 +1,158 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { Button, Box } from '@mui/material'; +import { AiService } from '../../handler'; +import { useStackingAlert } from '../mui-extras/stacking-alert'; +import Save from '@mui/icons-material/Save'; +import { SimpleAutocomplete } from '../mui-extras/simple-autocomplete'; + +export type ModelIdInputProps = { + /** + * The label of the model ID input field. + */ + label: string; + + /** + * The "type" of the model being configured. This prop should control the API + * endpoints used to get the current model, set the current model, and + * retrieve model ID suggestions. + */ + modality: 'chat' | 'completion'; + + /** + * (optional) The placeholder text shown within the model ID input field. + */ + placeholder?: string; + + /** + * (optional) Whether to render in full width. Defaults to `true`. + */ + fullWidth?: boolean; + + /** + * (optional) Callback that is run when the component retrieves the current + * model ID _or_ successfully updates the model ID. Details: + * + * - This callback is run once when the current model ID is retrieved from the + * backend, with `initial=true`. Any model ID updates made through this + * component run this callback with `initial=false`. + * + * - This callback will not run if an exception was raised while updating the + * model ID. + */ + onModelIdFetch?: (modelId: string | null, initial: boolean) => unknown; +}; + +/** + * A model ID input. + */ +export function ModelIdInput(props: ModelIdInputProps): JSX.Element { + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [updating, setUpdating] = useState(false); + + const [input, setInput] = useState(''); + const alert = useStackingAlert(); + + /** + * Effect: Fetch list of models and current model on initial render, based on + * the modality. + */ + useEffect(() => { + async function loadData() { + try { + let modelsResponse: string[]; + let currModelResponse: string | null; + + if (props.modality === 'chat') { + [modelsResponse, currModelResponse] = await Promise.all([ + AiService.listChatModels(), + AiService.getChatModel() + ]); + } else if (props.modality === 'completion') { + [modelsResponse, currModelResponse] = await Promise.all([ + AiService.listChatModels(), + AiService.getCompletionModel() + ]); + } else { + throw new Error(`Unrecognized model modality '${props.modality}'.`); + } + + setModels(modelsResponse); + setInput(currModelResponse ?? ''); + + // Call the callback with initial=true when first loading + props.onModelIdFetch?.(currModelResponse, true); + } catch (error) { + console.error('Failed to load chat models:', error); + setModels([]); + } finally { + setLoading(false); + } + } + + loadData(); + }, []); + + const handleUpdateChatModel = async () => { + setUpdating(true); + try { + // perform correct REST API call based on model modality + const newModelId = input.trim() || null; + if (props.modality === 'chat') { + await AiService.updateChatModel(newModelId); + } else if (props.modality === 'completion') { + await AiService.updateCompletionModel(newModelId); + } else { + throw new Error(`Unrecognized model modality '${props.modality}'.`); + } + + // run parent callback + props.onModelIdFetch?.(newModelId, false); + + // show success alert + // TODO: maybe just use the JL Notifications API + alert.show( + 'success', + newModelId + ? `Successfully updated ${props.modality} model to '${input.trim()}'.` + : `Successfully cleared ${props.modality} model.` + ); + } catch (error) { + console.error(`Failed to update ${props.modality} model:`, error); + const msg = + error instanceof Error ? error.message : 'An unknown error occurred'; + alert.show('error', `Failed to update ${props.modality} model: ${msg}`); + } finally { + setUpdating(false); + } + }; + + const modelsAsOptions = useMemo(() => { + return models.map(m => ({ label: m, value: m })); + }, [models]); + + return ( + + + + {alert.jsx} + + ); +} diff --git a/src/components/settings/model-parameters-input.tsx b/src/components/settings/model-parameters-input.tsx new file mode 100644 index 0000000..4af34e1 --- /dev/null +++ b/src/components/settings/model-parameters-input.tsx @@ -0,0 +1,428 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + Box, + Button, + TextField, + Alert, + IconButton, + Autocomplete, + Select, + MenuItem, + FormControl, + InputLabel +} from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import Save from '@mui/icons-material/Save'; +import { AiService } from '../../handler'; +import { useStackingAlert } from '../mui-extras/stacking-alert'; + +type ModelParameter = { + name: string; + type: string; + value: string; + isStatic?: boolean; +}; + +const PARAMETER_TYPES = [ + 'string', + 'integer', + 'number', + 'boolean', + 'array', + 'object' +] as const; + +export type ModelParametersInputProps = { + modelId?: string | null; +}; + +export function ModelParametersInput( + props: ModelParametersInputProps +): JSX.Element { + const [availableParameters, setAvailableParameters] = + useState(null); + const [parameters, setParameters] = useState([]); + const [validationError, setValidationError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const alert = useStackingAlert(); + const argumentValueRefs = useRef>({}); + + const inferParameterType = (value: any): string => { + if (typeof value === 'boolean') { + return 'boolean'; + } + if (typeof value === 'number') { + return Number.isInteger(value) ? 'integer' : 'number'; + } + if (Array.isArray(value)) { + return 'array'; + } + if (typeof value === 'object' && value !== null) { + return 'object'; + } + return 'string'; + }; + + const convertConfigToParameters = ( + savedParams: Record, + parameterSchemas?: Record + ): ModelParameter[] => { + return Object.entries(savedParams).map(([name, value]) => { + const schema = parameterSchemas?.[name]; + const inferredType = schema?.type || inferParameterType(value); + + return { + name, + type: inferredType, + value: String(value), + isStatic: true + }; + }); + }; + + useEffect(() => { + async function fetchParametersAndConfig() { + if (!props.modelId) { + setAvailableParameters(null); + setParameters([]); + return; + } + + setIsLoading(true); + try { + // Fetch both parameter schemas and existing config in parallel + const [paramResponse, configResponse] = await Promise.all([ + AiService.getModelParameters(props.modelId), + AiService.getConfig() + ]); + + setAvailableParameters(paramResponse); + const savedParams = configResponse.fields[props.modelId] || {}; + const existingParams = convertConfigToParameters( + savedParams, + paramResponse.parameters + ); + + setParameters(existingParams); + } catch (error) { + console.error('Failed to fetch parameters:', error); + setAvailableParameters(null); + alert.show( + 'error', + `Failed to fetch parameters for model '${props.modelId}'. You can still add custom parameters manually.` + ); + } finally { + setIsLoading(false); + } + } + + fetchParametersAndConfig(); + }, [props.modelId]); + + const handleAddParameter = () => { + const newParameter: ModelParameter = { + name: '', + type: '', + value: '', + isStatic: false + }; + setParameters([...parameters, newParameter]); + setValidationError(''); + }; + + const handleParameterChange = ( + index: number, + field: keyof ModelParameter, + value: string + ) => { + setParameters(prev => + prev.map((param, i) => + i === index + ? { + ...param, + [field]: value, + // Only mark as non-static if it wasn't already static + isStatic: + param.isStatic && field !== 'value' ? param.isStatic : false + } + : param + ) + ); + setValidationError(''); + + // Auto-focus on the argument value input when type is selected + if (field === 'type' && value) { + setTimeout(() => { + argumentValueRefs.current[index]?.focus(); + }, 0); + } + }; + + // Handle parameter name selection from dropdown + const handleParameterNameSelect = ( + index: number, + paramName: string | null + ) => { + if (!paramName) { + return; + } + const paramSchema = availableParameters?.parameters?.[paramName]; + + setParameters(prev => + prev.map((param, i) => + i === index + ? { + ...param, + name: paramName, + type: paramSchema?.type || param.type, + isStatic: false + } + : param + ) + ); + setValidationError(''); + }; + + const handleDeleteParameter = (index: number) => { + setParameters(prev => prev.filter((_, i) => i !== index)); + setValidationError(''); + }; + + const handleSaveParameters = async () => { + // Validation: Check if any parameter has a value but missing name or type (only for custom parameters) + const invalidParams = parameters.filter( + param => + param.value.trim() !== '' && + !param.isStatic && + (param.name.trim() === '' || param.type.trim() === '') + ); + + if (invalidParams.length > 0) { + setValidationError( + 'Parameter value specified but name or type is missing' + ); + return; + } + + if (!props.modelId) { + setValidationError('No model selected'); + return; + } + + // Check for empty parameter values + const emptyParams = parameters.filter(param => param.value.trim() === ''); + if (emptyParams.length > 0) { + alert.show( + 'error', + + 'All parameters must have argument values. Use the delete button to remove unwanted parameters.' + ); + return; + } + + // Validation: Check boolean values + const hasBooleanError = parameters.some(param => { + if (param.type.toLowerCase() === 'boolean') { + const value = param.value.toLowerCase().trim(); + return value !== 'true' && value !== 'false'; + } + return false; + }); + + if (hasBooleanError) { + setValidationError( + 'Boolean parameters must have value "true" or "false"' + ); + return; + } + + // Creates JSON object of parameters with type and value structure + const paramsObject = parameters.reduce( + (acc, param) => { + acc[param.name] = { + value: param.value, + type: param.type + }; + return acc; + }, + {} as Record + ); + + try { + await AiService.saveModelParameters(props.modelId, paramsObject); + setValidationError(''); + + // Show success alert + alert.show( + 'success', + `Successfully saved parameters for model '${props.modelId}'.` + ); + } catch (error) { + const msg = + error instanceof Error ? error.message : 'An unknown error occurred'; + + // Show error alert + alert.show('error', `Failed to save model parameters: ${msg}`); + setValidationError('Failed to save parameters. Please try again.'); + } + }; + + const showSaveButton = parameters.length > 0; + + const getParameterOptions = (excludeParamName?: string) => { + const apiParamNames = availableParameters?.parameters + ? Object.keys(availableParameters.parameters) + : []; + + // Filters out parameters that are already selected by other rows + const usedParamNames = parameters + .filter( + param => param.name !== excludeParamName && param.name.trim() !== '' + ) + .map(param => param.name); + + return apiParamNames.filter(name => !usedParamNames.includes(name)); + }; + + if (isLoading) { + return ( + + Loading parameters... + + ); + } + + return ( + + {parameters.map((param, index) => ( + + {param.isStatic ? ( + + ) : ( + { + handleParameterNameSelect(index, newValue); + }} + freeSolo + size="small" + sx={{ flex: 1 }} + renderInput={params => ( + + )} + getOptionLabel={option => { + if (typeof option === 'string') { + return option; + } + return ''; + }} + renderOption={(props, option) => { + const schema = availableParameters?.parameters?.[option]; + return ( + + + {option} + {schema && ( + + {schema.type}{' '} + {schema.description && + `- ${schema.description.slice(0, 50)}${ + schema.description.length > 50 ? '...' : '' + }`} + + )} + + + ); + }} + /> + )} + + Parameter type + + + + handleParameterChange(index, 'value', e.target.value) + } + size="small" + sx={{ flex: 1 }} + inputRef={el => { + argumentValueRefs.current[index] = el; + }} + /> + handleDeleteParameter(index)} + color="error" + size="small" + sx={{ ml: 1 }} + > + + + + ))} + + {validationError && ( + + {validationError} + + )} + + + + {showSaveButton && ( + + )} + {alert.jsx} + + ); +} diff --git a/src/components/settings/rendermime-markdown.tsx b/src/components/settings/rendermime-markdown.tsx new file mode 100644 index 0000000..9d600ca --- /dev/null +++ b/src/components/settings/rendermime-markdown.tsx @@ -0,0 +1,79 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; + +const MD_MIME_TYPE = 'text/markdown'; +const RENDERMIME_MD_CLASS = 'jp-ai-rendermime-markdown'; + +type RendermimeMarkdownProps = { + markdownStr: string; + rmRegistry: IRenderMimeRegistry; +}; + +/** + * Escapes backslashes in LaTeX delimiters such that they appear in the DOM + * after the initial MarkDown render. For example, this function takes '\(` and + * returns `\\(`. + * + * Required for proper rendering of MarkDown + LaTeX markup in the chat by + * `ILatexTypesetter`. + */ +function escapeLatexDelimiters(text: string) { + return text + .replace(/\\\(/g, '\\\\(') + .replace(/\\\)/g, '\\\\)') + .replace(/\\\[/g, '\\\\[') + .replace(/\\\]/g, '\\\\]'); +} + +export function RendermimeMarkdown( + props: RendermimeMarkdownProps +): JSX.Element { + // create a single renderer object at component mount + const [renderer] = useState(() => { + return props.rmRegistry.createRenderer(MD_MIME_TYPE); + }); + + // ref that tracks the content container to store the rendermime node in + const renderingContainer = useRef(null); + // ref that tracks whether the rendermime node has already been inserted + const renderingInserted = useRef(false); + + /** + * Effect: use Rendermime to render `props.markdownStr` into an HTML element, + * and insert it into `renderingContainer` if not yet inserted. + */ + useEffect(() => { + const renderContent = async () => { + // initialize mime model + const mdStr = escapeLatexDelimiters(props.markdownStr); + const model = props.rmRegistry.createModel({ + data: { [MD_MIME_TYPE]: mdStr } + }); + + // step 1: render markdown + await renderer.renderModel(model); + if (!renderer.node) { + throw new Error( + 'Rendermime was unable to render Markdown content. Please report this upstream to Jupyter AI on GitHub.' + ); + } + + // step 2: render LaTeX via MathJax + props.rmRegistry.latexTypesetter?.typeset(renderer.node); + + // insert the rendering into renderingContainer if not yet inserted + if (renderingContainer.current !== null && !renderingInserted.current) { + renderingContainer.current.appendChild(renderer.node); + renderingInserted.current = true; + } + }; + + renderContent(); + }, [props.markdownStr]); + + return ( +
+
+
+ ); +} diff --git a/src/components/settings/secrets-input.tsx b/src/components/settings/secrets-input.tsx new file mode 100644 index 0000000..65decc9 --- /dev/null +++ b/src/components/settings/secrets-input.tsx @@ -0,0 +1,463 @@ +import React, { useEffect, useCallback, useRef, useState } from 'react'; +import { + Box, + IconButton, + Typography, + TextField, + InputAdornment, + Button +} from '@mui/material'; +import Edit from '@mui/icons-material/Edit'; +import DeleteOutline from '@mui/icons-material/DeleteOutline'; +import Cancel from '@mui/icons-material/Cancel'; +import Check from '@mui/icons-material/Check'; +import Visibility from '@mui/icons-material/Visibility'; +import VisibilityOff from '@mui/icons-material/VisibilityOff'; +import Add from '@mui/icons-material/Add'; +import { AsyncIconButton } from '../mui-extras/async-icon-button'; + +import { AiService } from '../../handler'; +import { StackingAlert, useStackingAlert } from '../mui-extras/stacking-alert'; + +export type SecretsInputProps = { + editableSecrets: string[]; + reloadSecrets: () => unknown; +}; + +/** + * Component that renders a list of editable secrets. Each secret is + * rendered by a unique `EditableSecret` component. + */ +export function SecretsInput(props: SecretsInputProps): JSX.Element | null { + const alert = useStackingAlert(); + const [isAddingSecret, setIsAddingSecret] = useState(false); + + if (!props.editableSecrets) { + return null; + } + + const onAddSecretClick = useCallback(() => { + setIsAddingSecret(true); + }, []); + + const onAddSecretCancel = useCallback(() => { + setIsAddingSecret(false); + }, []); + + const onAddSecretSuccess = useCallback(() => { + setIsAddingSecret(false); + alert.show('success', 'Secret added successfully.'); + props.reloadSecrets(); + }, [alert, props.reloadSecrets]); + + const onAddSecretError = useCallback( + (emsg: string) => { + alert.show('error', emsg); + }, + [alert] + ); + + return ( + + {/* SUBSECTION: Editable secrets */} + {props.editableSecrets.length > 0 ? ( + .MuiBox-root:not(:first-child)': { + marginTop: -2 + } + }} + > + {props.editableSecrets.map(secret => ( + + ))} + + ) : ( + + + No secrets configured + + + Click "Add secret" to add an API key and start using Jupyternaut + with your preferred model provider. + + + )} + + {/* Add secret button */} + {isAddingSecret ? ( + + ) : ( + + )} + + {/* Info shown to the user after adding/updating a secret */} + {alert.jsx} + + ); +} + +export type EditableSecretProps = { + alert: StackingAlert; + secret: string; + reloadSecrets: () => unknown; +}; + +/** + * Component that renders a single editable secret specified by `props.apiKey`. + * Includes actions for editing and deleting the secret. + */ +export function EditableSecret(props: EditableSecretProps) { + const [input, setInput] = useState(''); + const [inputVisible, setInputVisible] = useState(false); + const [error, setError] = useState(false); + const [editable, setEditable] = useState(false); + const inputRef = useRef(); + + /** + * Effect: Select the input after `editable` is set to `true` and clear the + * input after `editable` is set to `false`. + */ + useEffect(() => { + if (editable) { + inputRef.current?.focus(); + } else { + setInput(''); + setInputVisible(false); + setError(false); + } + }, [editable]); + + const onEditIntent = useCallback(() => { + setEditable(true); + }, []); + + const onDelete = useCallback(() => { + return AiService.deleteSecret(props.secret); + }, []); + + const toggleInputVisibility = useCallback(() => { + setInputVisible(visible => !visible); + }, []); + + const onEditCancel = useCallback(() => { + setEditable(false); + }, []); + + const onEditSubmit = useCallback(() => { + // If input is empty, defocus the input to show a validation error and + // return early. + if (input.length === 0) { + inputRef.current?.blur(); + return 'canceled'; + } + + // Otherwise dispatch the request to the backend. + return AiService.updateSecrets({ + [props.secret]: input + }); + }, [input]); + + const onEditError = useCallback( + (emsg: string) => { + props.alert.show('error', emsg); + }, + [props.alert] + ); + + const validateInput = useCallback(() => { + if (!editable) { + return; + } + + setError(!input); + }, [editable, input]); + + const onEditSuccess = useCallback(() => { + setEditable(false); + props.alert.show('success', 'API key updated successfully.'); + props.reloadSecrets(); + }, [props.alert, props.reloadSecrets]); + + const onDeleteSuccess = useCallback(() => { + props.alert.show('success', 'API key deleted successfully.'); + props.reloadSecrets(); + }, [props.alert, props.reloadSecrets]); + + return ( + + setInput(e.target.value)} + disabled={!editable} + inputRef={inputRef} + // validation props + onBlur={validateInput} + error={error} + helperText={'Secret value must not be empty'} + placeholder="Secret value" + FormHelperTextProps={{ + sx: { + visibility: error ? 'unset' : 'hidden', + margin: 0, + whiteSpace: 'nowrap' + } + }} + // style props + size="small" + variant="standard" + type={inputVisible ? 'text' : 'password'} + label={ + +
{props.secret}
+
+ } + InputProps={{ + endAdornment: editable && ( + + e.preventDefault()} + > + {inputVisible ? : } + + + ) + }} + sx={{ + flexGrow: 1, + margin: 0, + '& .MuiInputBase-input': { + padding: 0, + paddingBottom: 1 + } + }} + /> + + {editable ? ( + // If this secret is being edited, show the "cancel edit" and "apply + // edit" buttons. + <> + e.preventDefault()} + > + + + e.preventDefault()} + confirm={false} + > + + + + ) : ( + // Otherwise, show the "edit secret" and "delete secret" buttons. + <> + + + + + + + + )} + +
+ ); +} + +export type NewSecretInputProps = { + alert: StackingAlert; + onCancel: () => void; + onSuccess: () => void; + onError: (emsg: string) => void; +}; + +export function NewSecretInput(props: NewSecretInputProps) { + const [secretName, setSecretName] = useState(''); + const [secretValue, setSecretValue] = useState(''); + const [secretValueVisible, setSecretValueVisible] = useState(false); + const [nameError, setNameError] = useState(false); + const [valueError, setValueError] = useState(false); + const nameInputRef = useRef(); + const valueInputRef = useRef(); + + useEffect(() => { + nameInputRef.current?.focus(); + }, []); + + const toggleSecretValueVisibility = useCallback(() => { + setSecretValueVisible(visible => !visible); + }, []); + + const validateInputs = useCallback(() => { + const nameEmpty = !secretName.trim(); + const valueEmpty = !secretValue.trim(); + setNameError(nameEmpty); + setValueError(valueEmpty); + return !nameEmpty && !valueEmpty; + }, [secretName, secretValue]); + + const onSubmit = useCallback(() => { + if (!validateInputs()) { + return 'canceled'; + } + + return AiService.updateSecrets({ + [secretName.trim()]: secretValue.trim() + }); + }, [secretName, secretValue, validateInputs]); + + return ( + + setSecretName(e.target.value)} + inputRef={nameInputRef} + error={nameError} + helperText={'Secret name must not be empty'} + placeholder="Secret name" + FormHelperTextProps={{ + sx: { + visibility: nameError ? 'unset' : 'hidden', + margin: 0, + whiteSpace: 'nowrap' + } + }} + size="small" + variant="standard" + label="Secret name" + onBlur={() => !secretName.trim() && setNameError(true)} + sx={{ + flexGrow: 1, + margin: 0, + '& .MuiInputBase-input': { + padding: 0, + paddingBottom: 1 + } + }} + /> + setSecretValue(e.target.value)} + inputRef={valueInputRef} + error={valueError} + helperText={'Secret value must not be empty'} + placeholder="Secret value" + FormHelperTextProps={{ + sx: { + visibility: valueError ? 'unset' : 'hidden', + margin: 0, + whiteSpace: 'nowrap' + } + }} + size="small" + variant="standard" + type={secretValueVisible ? 'text' : 'password'} + label="Secret value" + onBlur={() => !secretValue.trim() && setValueError(true)} + InputProps={{ + endAdornment: ( + + e.preventDefault()} + > + {secretValueVisible ? : } + + + ) + }} + sx={{ + flexGrow: 1, + margin: 0, + '& .MuiInputBase-input': { + padding: 0, + paddingBottom: 1 + } + }} + /> + + e.preventDefault()} + > + + + e.preventDefault()} + confirm={false} + > + + + + + ); +} diff --git a/src/components/settings/secrets-list.tsx b/src/components/settings/secrets-list.tsx new file mode 100644 index 0000000..de9a199 --- /dev/null +++ b/src/components/settings/secrets-list.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { + Box, + List, + ListItem, + ListItemIcon, + ListItemText, + Typography +} from '@mui/material'; +import LockIcon from '@mui/icons-material/Lock'; + +export type SecretsListProps = { + secrets: string[]; +}; + +/** + * Component that renders a list of secrets. This should be used to render the + * "static secrets" set by the traitlets configuration / environment variables + * passed directly to the `jupyter-lab` process. + * + * Editable secrets should be rendered using the `` component. + */ +export function SecretsList(props: SecretsListProps): JSX.Element | null { + if (!props.secrets || props.secrets.length === 0) { + return null; + } + + return ( + + {props.secrets.map((secret, index) => ( + + + + + + + {secret} + + + } + /> + + ))} + + ); +} diff --git a/src/components/settings/secrets-section.tsx b/src/components/settings/secrets-section.tsx new file mode 100644 index 0000000..db527d1 --- /dev/null +++ b/src/components/settings/secrets-section.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useState } from 'react'; +import { Alert, Box, CircularProgress, Link } from '@mui/material'; + +import { AiService } from '../../handler'; +import { useStackingAlert } from '../mui-extras/stacking-alert'; +import { SecretsInput } from './secrets-input'; +import { SecretsList } from './secrets-list'; + +/** + * Renders the "Secrets" section in the Jupyter AI settings. + * + * - Editable secrets (stored in `.env` by default) are rendered by the + * `` component. + * + * - Static secrets are rendered by the `` component. + */ +export function SecretsSection(): JSX.Element { + const [editableSecrets, setEditableSecrets] = useState([]); + const [staticSecrets, setStaticSecrets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const errorAlert = useStackingAlert(); + + /** + * Function that loads secrets from the Secrets REST API, setting the + * `loading` state accordingly. + */ + const loadSecrets = async () => { + try { + setLoading(true); + const secrets = await AiService.listSecrets(); + setEditableSecrets(secrets.editable_secrets); + setStaticSecrets(secrets.static_secrets); + setError(false); + } catch (error) { + setError(true); + errorAlert.show('error', error as unknown as any); + } finally { + setLoading(false); + } + }; + + /** + * Function that is like `loadSecrets`, but does not affect the `loading` + * state. This prevents the child components from being remounted. + */ + const reloadSecrets = async () => { + try { + const secrets = await AiService.listSecrets(); + setEditableSecrets(secrets.editable_secrets); + setStaticSecrets(secrets.static_secrets); + setError(false); + } catch (error) { + setError(true); + errorAlert.show('error', error as unknown as any); + } + }; + + /** + * Effect: Fetch the secrets via the Secrets REST API on initial render. + */ + useEffect(() => { + loadSecrets(); + }, []); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return {errorAlert.jsx}; + } + + return ( + + {/* Editable secrets subsection */} +

+ This section shows the secrets set in the .env file at the + workspace root. For most chat models, an API key secret in{' '} + .env is required for Jupyternaut to reply in the chat. See + the{' '} + + documentation + {' '} + for information on which API key is required for your model provider. +

+

+ Click "Add secret" to add a secret to the .env file. + Secrets can also be updated by editing the .env file + directly in JupyterLab. +

+ + + {/* Static secrets subsection */} + {staticSecrets.length ? ( + +

+ The secrets below are set by the environment variables and the + traitlets configuration passed to the server process. These secrets + can only be changed either upon restarting the server or by + contacting your server administrator. +

+ +
+ ) : null} +
+ ); +} diff --git a/src/components/settings/validator.ts b/src/components/settings/validator.ts new file mode 100644 index 0000000..3d420f5 --- /dev/null +++ b/src/components/settings/validator.ts @@ -0,0 +1,8 @@ +import { AiService } from '../../handler'; + +export class SettingsValidator { + constructor( + protected lmProviders: AiService.ListProvidersResponse, + protected emProviders: AiService.ListProvidersResponse + ) {} +} diff --git a/src/components/statusbar-item.tsx b/src/components/statusbar-item.tsx new file mode 100644 index 0000000..1cd2aa5 --- /dev/null +++ b/src/components/statusbar-item.tsx @@ -0,0 +1,99 @@ +import { Popup, showPopup } from '@jupyterlab/statusbar'; +import React from 'react'; +import { VDomModel, VDomRenderer } from '@jupyterlab/ui-components'; +import { CommandRegistry } from '@lumino/commands'; +import { MenuSvg, RankedMenu, IRankedMenu } from '@jupyterlab/ui-components'; +import { Jupyternaut } from '../icons'; +import type { IJaiStatusItem } from '../tokens'; + +/** + * The Jupyter AI status item, shown in the status bar on the bottom right by + * default. + */ +export class JaiStatusItem + extends VDomRenderer + implements IJaiStatusItem +{ + constructor(options: JaiStatusItem.IOptions) { + super(new VDomModel()); + this._commandRegistry = options.commandRegistry; + this._items = []; + + this.addClass('jp-mod-highlighted'); + this.title.caption = 'Open Jupyternaut status menu'; + this.node.addEventListener('click', this._handleClick); + } + + /** + * Adds a menu item to the JAI status item. + */ + addItem(item: IRankedMenu.IItemOptions): void { + this._items.push(item); + } + + /** + * Returns whether the status item has any menu items. + */ + hasItems(): boolean { + return this._items.length !== 0; + } + + /** + * Returns the status item as a JSX element. + */ + render(): JSX.Element | null { + if (!this.model) { + return null; + } + return ; + } + + dispose(): void { + this.node.removeEventListener('click', this._handleClick); + super.dispose(); + } + + /** + * Create a menu for viewing status and changing options. + */ + private _handleClick = () => { + if (this._popup) { + this._popup.dispose(); + } + if (this._menu) { + this._menu.dispose(); + } + this._menu = new RankedMenu({ + commands: this._commandRegistry, + renderer: MenuSvg.defaultRenderer + }); + for (const item of this._items) { + this._menu.addItem(item); + } + this._popup = showPopup({ + body: this._menu, + anchor: this, + align: 'left' + }); + }; + + private _items: IRankedMenu.IItemOptions[]; + private _commandRegistry: CommandRegistry; + private _menu: RankedMenu | null = null; + private _popup: Popup | null = null; +} + +/** + * A namespace for JupyternautStatus statics. + */ +export namespace JaiStatusItem { + /** + * Options for the JupyternautStatus item. + */ + export interface IOptions { + /** + * The application command registry. + */ + commandRegistry: CommandRegistry; + } +} diff --git a/src/handler.ts b/src/handler.ts index 7778448..16a37fa 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -2,6 +2,8 @@ import { URLExt } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; +const API_NAMESPACE = 'api/jupyternaut'; + /** * Call the API extension * @@ -15,17 +17,13 @@ export async function requestAPI( ): Promise { // Make request to Jupyter API const settings = ServerConnection.makeSettings(); - const requestUrl = URLExt.join( - settings.baseUrl, - 'jupyter-ai-jupyternaut', // API Namespace - endPoint - ); + const requestUrl = URLExt.join(settings.baseUrl, API_NAMESPACE, endPoint); let response: Response; try { response = await ServerConnection.makeRequest(requestUrl, init, settings); } catch (error) { - throw new ServerConnection.NetworkError(error as any); + throw new ServerConnection.NetworkError(error as TypeError); } let data: any = await response.text(); @@ -33,7 +31,7 @@ export async function requestAPI( if (data.length > 0) { try { data = JSON.parse(data); - } catch (error) { + } catch { console.log('Not a JSON response body.', response); } } @@ -44,3 +42,208 @@ export async function requestAPI( return data; } + +export namespace AiService { + /** + * The instantiation options for a data registry handler. + */ + export interface IOptions { + serverSettings?: ServerConnection.ISettings; + } + + export type DescribeConfigResponse = { + model_provider_id: string | null; + embeddings_provider_id: string | null; + api_keys: string[]; + send_with_shift_enter: boolean; + fields: Record>; + embeddings_fields: Record>; + completions_fields: Record>; + last_read: number; + completions_model_provider_id: string | null; + }; + + export type UpdateConfigRequest = { + model_provider_id?: string | null; + embeddings_provider_id?: string | null; + api_keys?: Record; + send_with_shift_enter?: boolean; + fields?: Record>; + last_read?: number; + completions_model_provider_id?: string | null; + completions_fields?: Record>; + embeddings_fields?: Record>; + }; + + export async function getConfig(): Promise { + return requestAPI('config'); + } + + export type EnvAuthStrategy = { + type: 'env'; + name: string; + }; + + export type AwsAuthStrategy = { + type: 'aws'; + }; + + export type MultiEnvAuthStrategy = { + type: 'multienv'; + names: string[]; + }; + + export type AuthStrategy = + | AwsAuthStrategy + | EnvAuthStrategy + | MultiEnvAuthStrategy + | null; + + export type ListProvidersEntry = { + id: string; + name: string; + model_id_label?: string; + models: string[]; + help?: string; + auth_strategy: AuthStrategy; + registry: boolean; + completion_models: string[]; + chat_models: string[]; + }; + + export type ListProvidersResponse = { + providers: ListProvidersEntry[]; + }; + + export type ListChatModelsResponse = { + chat_models: string[]; + }; + + export async function listLmProviders(): Promise { + return requestAPI('providers'); + } + + export async function listEmProviders(): Promise { + return requestAPI('providers/embeddings'); + } + + export async function updateConfig( + config: UpdateConfigRequest + ): Promise { + return requestAPI('config', { + method: 'POST', + body: JSON.stringify(config) + }); + } + + export type SecretsList = { + editable_secrets: string[]; + static_secrets: string[]; + }; + + export async function listSecrets(): Promise { + return requestAPI('secrets/', { + method: 'GET' + }); + } + + export type UpdateSecretsRequest = { + updated_secrets: Record; + }; + + export async function updateSecrets( + updatedSecrets: Record + ): Promise { + return requestAPI('secrets/', { + method: 'PUT', + body: JSON.stringify({ + updated_secrets: updatedSecrets + }) + }); + } + + export async function deleteSecret(secretName: string): Promise { + return updateSecrets({ [secretName]: null }); + } + + export async function listChatModels(): Promise { + const response = await requestAPI('models/chat/', { + method: 'GET' + }); + return response.chat_models; + } + + export async function getChatModel(): Promise { + const response = await requestAPI('config/'); + return response.model_provider_id; + } + + export async function updateChatModel(modelId: string | null): Promise { + return await updateConfig({ + model_provider_id: modelId + }); + } + + export async function getCompletionModel(): Promise { + const response = await requestAPI('config/'); + return response.completions_model_provider_id; + } + + export async function updateCompletionModel( + modelId: string | null + ): Promise { + return await updateConfig({ + completions_model_provider_id: modelId + }); + } + + export type GetModelParametersResponse = { + parameters: Record; + parameter_names: string[]; + }; + + export type ParameterSchema = { + type: 'boolean' | 'integer' | 'float' | 'string' | 'array' | 'object'; + description: string; + min?: number; + max?: number; + }; + + export type UpdateModelParametersResponse = { + parameters: Record; + }; + + export async function getModelParameters( + modelId?: string, + provider?: string + ): Promise { + const params = new URLSearchParams(); + if (modelId) { + params.append('model', modelId); + } + if (provider) { + params.append('provider', provider); + } + + const endpoint = `model-parameters${ + params.toString() ? `?${params.toString()}` : '' + }`; + return await requestAPI(endpoint); + } + + export async function saveModelParameters( + modelId: string, + parameters: Record + ): Promise { + return await requestAPI('model-parameters', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model_id: modelId, + parameters: parameters + }) + }); + } +} diff --git a/src/icons/icons.ts b/src/icons/icons.ts new file mode 100644 index 0000000..cf92587 --- /dev/null +++ b/src/icons/icons.ts @@ -0,0 +1,19 @@ +import { LabIcon } from '@jupyterlab/ui-components'; + +import jupyternautSvg from '../../style/icons/jupyternaut.svg'; +import chatSvgStr from '../../style/icons/chat.svg'; + +export const jupyternautIcon = new LabIcon({ + name: 'jupyter-ai::jupyternaut', + svgstr: jupyternautSvg +}); + +export const chatIcon = new LabIcon({ + name: 'jupyter-ai::chat', + svgstr: chatSvgStr +}); + +// this icon is only used in the status bar. +// to configure the icon shown on agent replies in the chat UI, please specify a +// custom `Persona`. +export const Jupyternaut = jupyternautIcon.react; diff --git a/src/icons/index.ts b/src/icons/index.ts new file mode 100644 index 0000000..838008a --- /dev/null +++ b/src/icons/index.ts @@ -0,0 +1 @@ +export * from './icons'; diff --git a/src/icons/svg.d.ts b/src/icons/svg.d.ts new file mode 100644 index 0000000..68ced84 --- /dev/null +++ b/src/icons/svg.d.ts @@ -0,0 +1,12 @@ +// Excerpted from @jupyterlab/ui-components + +// including this file in a package allows for the use of import statements +// with svg files. Example: `import xSvg from 'path/xSvg.svg'` + +// for use with raw-loader in Webpack. +// The svg will be imported as a raw string + +declare module '*.svg' { + const value: string; // @ts-ignore + export default value; +} diff --git a/src/index.ts b/src/index.ts index f433b40..66fbe80 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,43 @@ +import { INotebookShell } from '@jupyter-notebook/application'; import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; +import { + IWidgetTracker, + ReactWidget, + IThemeManager, + MainAreaWidget, + ICommandPalette +} from '@jupyterlab/apputils'; +import { IMessageFooterRegistry } from '@jupyter/chat'; +import { IDocumentWidget } from '@jupyterlab/docregistry'; +import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; +import { SingletonLayout, Widget } from '@lumino/widgets'; +import { StopButton } from './components/message-footer/stop-button'; +import { completionPlugin } from './completions'; +import { buildErrorWidget } from './widgets/chat-error'; +import { buildAiSettings } from './widgets/settings-widget'; +import { statusItemPlugin } from './status'; +import { IJaiCompletionProvider } from './tokens'; import { requestAPI } from './handler'; +export type DocumentTracker = IWidgetTracker; +export namespace CommandIDs { + /** + * Command to open the AI settings. + */ + export const openAiSettings = '@jupyter-ai/jupyternaut:open-settings'; +} + /** * Initialization data for the @jupyter-ai/jupyternaut extension. */ const plugin: JupyterFrontEndPlugin = { id: '@jupyter-ai/jupyternaut:plugin', - description: 'Package providing the default AI persona, Jupyternaut, in Jupyter AI.', + description: + 'Package providing the default AI persona, Jupyternaut, in Jupyter AI.', autoStart: true, activate: (app: JupyterFrontEnd) => { console.log('JupyterLab extension @jupyter-ai/jupyternaut is activated!'); @@ -27,4 +54,97 @@ const plugin: JupyterFrontEndPlugin = { } }; -export default plugin; +const jupyternautSettingsPlugin: JupyterFrontEndPlugin = { + id: '@jupyter-ai/jupyternaut:settings', + autoStart: true, + requires: [IRenderMimeRegistry], + optional: [ + ICommandPalette, + IThemeManager, + IJaiCompletionProvider, + INotebookShell + ], + activate: async ( + app: JupyterFrontEnd, + rmRegistry: IRenderMimeRegistry, + palette: ICommandPalette | null, + themeManager: IThemeManager | null, + completionProvider: IJaiCompletionProvider | null, + notebookShell: INotebookShell | null + ) => { + const openInlineCompleterSettings = () => { + app.commands.execute('settingeditor:open', { + query: 'Inline Completer' + }); + }; + + // Create a AI settings widget. + let aiSettings: Widget; + let settingsWidget: ReactWidget; + try { + settingsWidget = buildAiSettings( + themeManager, + rmRegistry, + completionProvider, + openInlineCompleterSettings + ); + } catch (e) { + // TODO: Do better error handling here. + console.error(e); + settingsWidget = buildErrorWidget(themeManager); + } + + // Add a command to open settings widget in main area. + app.commands.addCommand(CommandIDs.openAiSettings, { + execute: () => { + if (!aiSettings || aiSettings.isDisposed) { + if (notebookShell) { + aiSettings = new Widget(); + const layout = new SingletonLayout(); + aiSettings.layout = layout; + layout.widget = settingsWidget; + } else { + aiSettings = new MainAreaWidget({ content: settingsWidget }); + } + aiSettings.id = '@jupyter-ai/jupyternaut:settings'; + aiSettings.title.label = 'Jupyternaut settings'; + aiSettings.title.caption = 'Jupyternaut settings'; + aiSettings.title.closable = true; + } + if (!aiSettings.isAttached) { + app?.shell.add(aiSettings, notebookShell ? 'right' : 'main'); + } + app.shell.activateById(aiSettings.id); + }, + label: 'Jupyternaut settings' + }); + + if (palette) { + palette.addItem({ + category: 'jupyter-ai', + command: CommandIDs.openAiSettings + }); + } + } +}; + +const stopButtonPlugin: JupyterFrontEndPlugin = { + id: '@jupyter-ai/jupyternaut:stop-button', + autoStart: true, + requires: [IMessageFooterRegistry], + activate: (app: JupyterFrontEnd, registry: IMessageFooterRegistry) => { + registry.addSection({ + component: StopButton, + position: 'center' + }); + } +}; + +export default [ + plugin, + jupyternautSettingsPlugin, + // webComponentsPlugin, + stopButtonPlugin, + completionPlugin, + statusItemPlugin +]; diff --git a/src/status.ts b/src/status.ts new file mode 100644 index 0000000..003e411 --- /dev/null +++ b/src/status.ts @@ -0,0 +1,32 @@ +import { IJaiStatusItem } from './tokens'; +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { IStatusBar } from '@jupyterlab/statusbar'; +import { JaiStatusItem } from './components/statusbar-item'; + +export const statusItemPlugin: JupyterFrontEndPlugin = { + id: 'jupyter_ai:status-item', + description: 'Provides a status item for Jupyter AI.', + autoStart: true, + optional: [IStatusBar], + provides: IJaiStatusItem, + activate: (app: JupyterFrontEnd, statusBar: IStatusBar | null) => { + const statusItem = new JaiStatusItem({ + commandRegistry: app.commands + }); + if (statusBar) { + // Add the status item. + statusBar.registerStatusItem('jupyter_ai:jupyternaut-status', { + item: statusItem, + align: 'right', + rank: 100, + isActive: () => { + return statusItem.hasItems(); + } + }); + } + return statusItem; + } +}; diff --git a/src/theme-provider.ts b/src/theme-provider.ts new file mode 100644 index 0000000..02db8d3 --- /dev/null +++ b/src/theme-provider.ts @@ -0,0 +1,132 @@ +import { Theme, createTheme } from '@mui/material/styles'; + +function getCSSVariable(name: string): string { + return getComputedStyle(document.body).getPropertyValue(name).trim(); +} + +export async function pollUntilReady(): Promise { + while (!document.body.hasAttribute('data-jp-theme-light')) { + await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms + } +} + +export async function getJupyterLabTheme(): Promise { + await pollUntilReady(); + const light = document.body.getAttribute('data-jp-theme-light'); + return createTheme({ + spacing: 4, + components: { + MuiButton: { + defaultProps: { + size: 'small' + } + }, + MuiFilledInput: { + defaultProps: { + margin: 'dense' + } + }, + MuiFormControl: { + defaultProps: { + margin: 'dense', + size: 'small' + } + }, + MuiFormHelperText: { + defaultProps: { + margin: 'dense' + } + }, + MuiIconButton: { + defaultProps: { + size: 'small' + } + }, + MuiInputBase: { + defaultProps: { + margin: 'dense', + size: 'small' + } + }, + MuiInputLabel: { + defaultProps: { + margin: 'dense' + } + }, + MuiListItem: { + defaultProps: { + dense: true + } + }, + MuiOutlinedInput: { + defaultProps: { + margin: 'dense' + } + }, + MuiFab: { + defaultProps: { + size: 'small' + } + }, + MuiTable: { + defaultProps: { + size: 'small' + } + }, + MuiTextField: { + defaultProps: { + margin: 'dense', + size: 'small' + } + }, + MuiToolbar: { + defaultProps: { + variant: 'dense' + } + } + }, + palette: { + background: { + paper: getCSSVariable('--jp-layout-color1'), + default: getCSSVariable('--jp-layout-color1') + }, + mode: light === 'true' ? 'light' : 'dark', + primary: { + main: getCSSVariable('--jp-brand-color1'), + light: getCSSVariable('--jp-brand-color2'), + dark: getCSSVariable('--jp-brand-color0') + }, + error: { + main: getCSSVariable('--jp-error-color1'), + light: getCSSVariable('--jp-error-color2'), + dark: getCSSVariable('--jp-error-color0') + }, + warning: { + main: getCSSVariable('--jp-warn-color1'), + light: getCSSVariable('--jp-warn-color2'), + dark: getCSSVariable('--jp-warn-color0') + }, + success: { + main: getCSSVariable('--jp-success-color1'), + light: getCSSVariable('--jp-success-color2'), + dark: getCSSVariable('--jp-success-color0') + }, + text: { + primary: getCSSVariable('--jp-ui-font-color1'), + secondary: getCSSVariable('--jp-ui-font-color2'), + disabled: getCSSVariable('--jp-ui-font-color3') + } + }, + shape: { + borderRadius: 2 + }, + typography: { + fontFamily: getCSSVariable('--jp-ui-font-family'), + fontSize: 12, + htmlFontSize: 16, + button: { + textTransform: 'capitalize' + } + } + }); +} diff --git a/src/tokens.ts b/src/tokens.ts new file mode 100644 index 0000000..57b7d9a --- /dev/null +++ b/src/tokens.ts @@ -0,0 +1,90 @@ +import { Token } from '@lumino/coreutils'; +import { ISignal } from '@lumino/signaling'; +import type { IRankedMenu } from '@jupyterlab/ui-components'; + +export interface IJaiStatusItem { + addItem(item: IRankedMenu.IItemOptions): void; +} + +/** + * The Jupyternaut status token. + */ +export const IJaiStatusItem = new Token( + 'jupyter_ai:IJupyternautStatus', + 'Status indicator displayed in the statusbar' +); + +export interface IJaiCompletionProvider { + isEnabled(): boolean; + settingsChanged: ISignal; +} + +/** + * The inline completion provider token. + */ +export const IJaiCompletionProvider = new Token( + 'jupyter_ai:IJaiCompletionProvider', + 'The jupyter-ai inline completion provider API' +); + +/** + * 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: 'human' | 'agent'; + /** + * 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/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..8790a90 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,68 @@ +/** + * Contains various utility functions shared throughout the project. + */ +import { CodeEditor } from '@jupyterlab/codeeditor'; +import { CodeMirrorEditor } from '@jupyterlab/codemirror'; +import { DocumentWidget } from '@jupyterlab/docregistry'; +import { FileEditor } from '@jupyterlab/fileeditor'; +import { Notebook } from '@jupyterlab/notebook'; +import { Widget } from '@lumino/widgets'; + +/** + * Gets the editor instance used by a document widget. Returns `null` if unable. + */ +export function getEditor( + widget: Widget | null +): CodeMirrorEditor | null | undefined { + if (!(widget instanceof DocumentWidget)) { + return null; + } + + let editor: CodeEditor.IEditor | null | undefined; + const { content } = widget; + + if (content instanceof FileEditor) { + editor = content.editor; + } else if (content instanceof Notebook) { + editor = content.activeCell?.editor; + } + + if (!(editor instanceof CodeMirrorEditor)) { + return undefined; + } + + return editor; +} + +/** + * Gets the index of the cell associated with `cellId`. + */ +export function getCellIndex(notebook: Notebook, cellId: string): number { + const idx = notebook.model?.sharedModel.cells.findIndex( + cell => cell.getId() === cellId + ); + return idx === undefined ? -1 : idx; +} + +/** + * Obtain the provider ID component from a model ID. + */ +export function getProviderId(globalModelId: string): string | null { + if (!globalModelId) { + return null; + } + + return globalModelId.split(':')[0]; +} + +/** + * Obtain the model name component from a model ID. + */ +export function getModelLocalId(globalModelId: string): string | null { + if (!globalModelId) { + return null; + } + + const components = globalModelId.split(':').slice(1); + return components.join(':'); +} diff --git a/src/widgets/chat-error.tsx b/src/widgets/chat-error.tsx new file mode 100644 index 0000000..8ae9cbb --- /dev/null +++ b/src/widgets/chat-error.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { ReactWidget } from '@jupyterlab/apputils'; +import type { IThemeManager } from '@jupyterlab/apputils'; +import { Alert, Box } from '@mui/material'; + +import { chatIcon } from '../icons'; +import { JlThemeProvider } from '../components/jl-theme-provider'; + +export function buildErrorWidget( + themeManager: IThemeManager | null +): ReactWidget { + const ErrorWidget = ReactWidget.create( + + + + + There seems to be a problem with the Chat backend, please look at + the JupyterLab server logs or contact your administrator to correct + this problem. + + + + + ); + ErrorWidget.id = 'jupyter-ai::chat'; + ErrorWidget.title.icon = chatIcon; + ErrorWidget.title.caption = 'Jupyter AI Chat'; // TODO: i18n + + return ErrorWidget; +} diff --git a/src/widgets/settings-widget.tsx b/src/widgets/settings-widget.tsx new file mode 100644 index 0000000..f8821ef --- /dev/null +++ b/src/widgets/settings-widget.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { IThemeManager, ReactWidget } from '@jupyterlab/apputils'; +import { settingsIcon } from '@jupyterlab/ui-components'; +import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; + +import { IJaiCompletionProvider } from '../tokens'; +import { ChatSettings } from '../components/chat-settings'; +import { JlThemeProvider } from '../components/jl-theme-provider'; + +export function buildAiSettings( + themeManager: IThemeManager | null, + rmRegistry: IRenderMimeRegistry, + completionProvider: IJaiCompletionProvider | null, + openInlineCompleterSettings: () => void +): ReactWidget { + const SettingsWidget = ReactWidget.create( + + + + ); + SettingsWidget.id = 'jupyter-ai::settings'; + SettingsWidget.title.icon = settingsIcon; + SettingsWidget.title.caption = 'Jupyter AI Settings'; // TODO: i18n + return SettingsWidget; +} diff --git a/style/base.css b/style/base.css index e11f457..3e99fff 100644 --- a/style/base.css +++ b/style/base.css @@ -3,3 +3,5 @@ https://jupyterlab.readthedocs.io/en/stable/developer/css.html */ + +@import url('./chat-settings.css'); diff --git a/style/chat-settings.css b/style/chat-settings.css new file mode 100644 index 0000000..ae9cbf3 --- /dev/null +++ b/style/chat-settings.css @@ -0,0 +1,36 @@ +/* + * + * Selectors must be nested in `.jp-ThemedContainer` to have a higher + * specificity than selectors in rules provided by JupyterLab. + * + * See: https://jupyterlab.readthedocs.io/en/latest/extension/extension_migration.html#css-styling + * See also: https://github.com/jupyterlab/jupyter-ai/issues/1090 + */ + +.jp-ThemedContainer .jp-ai-ChatSettings { + padding: 1.5rem; + box-sizing: border-box; + height: 100%; + overflow: scroll; +} + +.jp-ThemedContainer .jp-ai-ChatSettings a { + color: var(--jp-content-link-color); + text-decoration: underline; +} + +.jp-ThemedContainer .jp-ai-ChatSettings-header { + font-size: var(--jp-ui-font-size3); + font-weight: 400; + color: var(--jp-ui-font-color1); +} + +.jp-ThemedContainer .jp-ai-ChatSettings-h3 { + font-size: var(--jp-ui-font-size2); + font-weight: 400; + color: var(--jp-ui-font-color1); +} + +.jp-ThemedContainer .jp-ai-ChatSettings-welcome { + color: var(--jp-ui-font-color1); +} diff --git a/style/icons/chat.svg b/style/icons/chat.svg new file mode 100644 index 0000000..3634ad0 --- /dev/null +++ b/style/icons/chat.svg @@ -0,0 +1,6 @@ + + + diff --git a/style/icons/jupyternaut.svg b/style/icons/jupyternaut.svg new file mode 100644 index 0000000..dd800d5 --- /dev/null +++ b/style/icons/jupyternaut.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/tsconfig.json b/tsconfig.json index 25af040..aab1342 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ "target": "ES2018", "types": ["jest"] }, - "include": ["src/*"] + "include": ["src/**/*"] } diff --git a/ui-tests/tests/jupyter_ai_jupyternaut.spec.ts b/ui-tests/tests/jupyter_ai_jupyternaut.spec.ts index 05512b2..81820c5 100644 --- a/ui-tests/tests/jupyter_ai_jupyternaut.spec.ts +++ b/ui-tests/tests/jupyter_ai_jupyternaut.spec.ts @@ -16,6 +16,8 @@ test('should emit an activation console message', async ({ page }) => { await page.goto(); expect( - logs.filter(s => s === 'JupyterLab extension @jupyter-ai/jupyternaut is activated!') + logs.filter( + s => s === 'JupyterLab extension @jupyter-ai/jupyternaut is activated!' + ) ).toHaveLength(1); }); diff --git a/yarn.lock b/yarn.lock index 3fe98d5..e1f7cd9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -143,7 +143,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-module-imports@npm:^7.27.1": +"@babel/helper-module-imports@npm:^7.16.7, @babel/helper-module-imports@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-module-imports@npm:7.27.1" dependencies: @@ -1243,6 +1243,13 @@ __metadata: languageName: node linkType: hard +"@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.7": + version: 7.28.4 + resolution: "@babel/runtime@npm:7.28.4" + checksum: 934b0a0460f7d06637d93fcd1a44ac49adc33518d17253b5a0b55ff4cb90a45d8fe78bf034b448911dbec7aff2a90b918697559f78d21c99ff8dbadae9565b55 + languageName: node + linkType: hard + "@babel/template@npm:^7.27.1, @babel/template@npm:^7.27.2, @babel/template@npm:^7.3.3": version: 7.27.2 resolution: "@babel/template@npm:7.27.2" @@ -1584,6 +1591,152 @@ __metadata: languageName: node linkType: hard +"@emotion/babel-plugin@npm:^11.13.5": + version: 11.13.5 + resolution: "@emotion/babel-plugin@npm:11.13.5" + dependencies: + "@babel/helper-module-imports": ^7.16.7 + "@babel/runtime": ^7.18.3 + "@emotion/hash": ^0.9.2 + "@emotion/memoize": ^0.9.0 + "@emotion/serialize": ^1.3.3 + babel-plugin-macros: ^3.1.0 + convert-source-map: ^1.5.0 + escape-string-regexp: ^4.0.0 + find-root: ^1.1.0 + source-map: ^0.5.7 + stylis: 4.2.0 + checksum: c41df7e6c19520e76d1939f884be878bf88b5ba00bd3de9d05c5b6c5baa5051686ab124d7317a0645de1b017b574d8139ae1d6390ec267fbe8e85a5252afb542 + languageName: node + linkType: hard + +"@emotion/cache@npm:^11.13.5, @emotion/cache@npm:^11.14.0": + version: 11.14.0 + resolution: "@emotion/cache@npm:11.14.0" + dependencies: + "@emotion/memoize": ^0.9.0 + "@emotion/sheet": ^1.4.0 + "@emotion/utils": ^1.4.2 + "@emotion/weak-memoize": ^0.4.0 + stylis: 4.2.0 + checksum: 0a81591541ea43bc7851742e6444b7800d72e98006f94e775ae6ea0806662d14e0a86ff940f5f19d33b4bb2c427c882aa65d417e7322a6e0d5f20fe65ed920c9 + languageName: node + linkType: hard + +"@emotion/hash@npm:^0.9.2": + version: 0.9.2 + resolution: "@emotion/hash@npm:0.9.2" + checksum: 379bde2830ccb0328c2617ec009642321c0e009a46aa383dfbe75b679c6aea977ca698c832d225a893901f29d7b3eef0e38cf341f560f6b2b56f1ff23c172387 + languageName: node + linkType: hard + +"@emotion/is-prop-valid@npm:^1.3.0": + version: 1.4.0 + resolution: "@emotion/is-prop-valid@npm:1.4.0" + dependencies: + "@emotion/memoize": ^0.9.0 + checksum: 6b003cdc62106c2d5d12207c2d1352d674339252a2d7ac8d96974781d7c639833f35d22e7e331411795daaafa62f126c2824a4983584292b431e08b42877d51e + languageName: node + linkType: hard + +"@emotion/memoize@npm:^0.9.0": + version: 0.9.0 + resolution: "@emotion/memoize@npm:0.9.0" + checksum: 038132359397348e378c593a773b1148cd0cf0a2285ffd067a0f63447b945f5278860d9de718f906a74c7c940ba1783ac2ca18f1c06a307b01cc0e3944e783b1 + languageName: node + linkType: hard + +"@emotion/react@npm:^11.10.5": + version: 11.14.0 + resolution: "@emotion/react@npm:11.14.0" + dependencies: + "@babel/runtime": ^7.18.3 + "@emotion/babel-plugin": ^11.13.5 + "@emotion/cache": ^11.14.0 + "@emotion/serialize": ^1.3.3 + "@emotion/use-insertion-effect-with-fallbacks": ^1.2.0 + "@emotion/utils": ^1.4.2 + "@emotion/weak-memoize": ^0.4.0 + hoist-non-react-statics: ^3.3.1 + peerDependencies: + react: ">=16.8.0" + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 3cf023b11d132b56168713764d6fced8e5a1f0687dfe0caa2782dfd428c8f9e30f9826a919965a311d87b523cd196722aaf75919cd0f6bd0fd57f8a6a0281500 + languageName: node + linkType: hard + +"@emotion/serialize@npm:^1.3.3": + version: 1.3.3 + resolution: "@emotion/serialize@npm:1.3.3" + dependencies: + "@emotion/hash": ^0.9.2 + "@emotion/memoize": ^0.9.0 + "@emotion/unitless": ^0.10.0 + "@emotion/utils": ^1.4.2 + csstype: ^3.0.2 + checksum: 510331233767ae4e09e925287ca2c7269b320fa1d737ea86db5b3c861a734483ea832394c0c1fe5b21468fe335624a75e72818831d303ba38125f54f44ba02e7 + languageName: node + linkType: hard + +"@emotion/sheet@npm:^1.4.0": + version: 1.4.0 + resolution: "@emotion/sheet@npm:1.4.0" + checksum: eeb1212e3289db8e083e72e7e401cd6d1a84deece87e9ce184f7b96b9b5dbd6f070a89057255a6ff14d9865c3ce31f27c39248a053e4cdd875540359042586b4 + languageName: node + linkType: hard + +"@emotion/styled@npm:^11.10.5": + version: 11.14.1 + resolution: "@emotion/styled@npm:11.14.1" + dependencies: + "@babel/runtime": ^7.18.3 + "@emotion/babel-plugin": ^11.13.5 + "@emotion/is-prop-valid": ^1.3.0 + "@emotion/serialize": ^1.3.3 + "@emotion/use-insertion-effect-with-fallbacks": ^1.2.0 + "@emotion/utils": ^1.4.2 + peerDependencies: + "@emotion/react": ^11.0.0-rc.0 + react: ">=16.8.0" + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 86238d9f5c41232a73499e441fa9112e855545519d6772f489fa885634bb8f31b422a9ba9d1e8bc0b4ad66132f9d398b1c309d92c19c5ee21356b41671ec984a + languageName: node + linkType: hard + +"@emotion/unitless@npm:^0.10.0": + version: 0.10.0 + resolution: "@emotion/unitless@npm:0.10.0" + checksum: d79346df31a933e6d33518e92636afeb603ce043f3857d0a39a2ac78a09ef0be8bedff40130930cb25df1beeee12d96ee38613963886fa377c681a89970b787c + languageName: node + linkType: hard + +"@emotion/use-insertion-effect-with-fallbacks@npm:^1.2.0": + version: 1.2.0 + resolution: "@emotion/use-insertion-effect-with-fallbacks@npm:1.2.0" + peerDependencies: + react: ">=16.8.0" + checksum: 8ff6aec7f2924526ff8c8f8f93d4b8236376e2e12c435314a18c9a373016e24dfdf984e82bbc83712b8e90ff4783cd765eb39fc7050d1a43245e5728740ddd71 + languageName: node + linkType: hard + +"@emotion/utils@npm:^1.4.2": + version: 1.4.2 + resolution: "@emotion/utils@npm:1.4.2" + checksum: 04cf76849c6401205c058b82689fd0ec5bf501aed6974880fe9681a1d61543efb97e848f4c38664ac4a9068c7ad2d1cb84f73bde6cf95f1208aa3c28e0190321 + languageName: node + linkType: hard + +"@emotion/weak-memoize@npm:^0.4.0": + version: 0.4.0 + resolution: "@emotion/weak-memoize@npm:0.4.0" + checksum: db5da0e89bd752c78b6bd65a1e56231f0abebe2f71c0bd8fc47dff96408f7065b02e214080f99924f6a3bfe7ee15afc48dad999d76df86b39b16e513f7a94f52 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0": version: 4.9.0 resolution: "@eslint-community/eslint-utils@npm:4.9.0" @@ -1989,11 +2142,18 @@ __metadata: version: 0.0.0-use.local resolution: "@jupyter-ai/jupyternaut@workspace:." dependencies: - "@jupyterlab/application": ^4.0.0 + "@emotion/react": ^11.10.5 + "@emotion/styled": ^11.10.5 + "@jupyter-notebook/application": ^7.2.0 + "@jupyter/chat": ^0.17.0 + "@jupyterlab/application": ^4.2.0 "@jupyterlab/builder": ^4.0.0 - "@jupyterlab/coreutils": ^6.0.0 - "@jupyterlab/services": ^7.0.0 + "@jupyterlab/completer": ^4.2.0 + "@jupyterlab/coreutils": ^6.2.0 + "@jupyterlab/services": ^7.2.0 "@jupyterlab/testutils": ^4.0.0 + "@mui/icons-material": ^5.11.0 + "@mui/material": ^5.11.0 "@types/jest": ^29.2.0 "@types/json-schema": ^7.0.11 "@types/react": ^18.0.26 @@ -2021,6 +2181,67 @@ __metadata: languageName: unknown linkType: soft +"@jupyter-notebook/application@npm:^7.2.0": + version: 7.4.7 + resolution: "@jupyter-notebook/application@npm:7.4.7" + dependencies: + "@jupyterlab/application": ~4.4.9 + "@jupyterlab/coreutils": ~6.4.9 + "@jupyterlab/docregistry": ~4.4.9 + "@jupyterlab/rendermime-interfaces": ~3.12.9 + "@jupyterlab/ui-components": ~4.4.9 + "@lumino/algorithm": ^2.0.3 + "@lumino/coreutils": ^2.2.1 + "@lumino/messaging": ^2.0.3 + "@lumino/polling": ^2.1.4 + "@lumino/signaling": ^2.1.4 + "@lumino/widgets": ^2.7.1 + checksum: 087b27be17cf6e15ffeaad29f05dd2f9e90ff76e9e6f7262af1834628fdde6cf85d2f6eedb65463a45f879f36be13277f1d36d53b3222890c13ec5d84a17ea53 + languageName: node + linkType: hard + +"@jupyter/chat@npm:^0.17.0": + version: 0.17.0 + resolution: "@jupyter/chat@npm:0.17.0" + dependencies: + "@emotion/react": ^11.10.5 + "@emotion/styled": ^11.10.5 + "@jupyter/react-components": ^0.15.2 + "@jupyterlab/application": ^4.2.0 + "@jupyterlab/apputils": ^4.3.0 + "@jupyterlab/codeeditor": ^4.2.0 + "@jupyterlab/codemirror": ^4.2.0 + "@jupyterlab/docmanager": ^4.2.0 + "@jupyterlab/filebrowser": ^4.2.0 + "@jupyterlab/fileeditor": ^4.2.0 + "@jupyterlab/notebook": ^4.2.0 + "@jupyterlab/rendermime": ^4.2.0 + "@jupyterlab/ui-components": ^4.2.0 + "@lumino/algorithm": ^2.0.0 + "@lumino/commands": ^2.0.0 + "@lumino/coreutils": ^2.0.0 + "@lumino/disposable": ^2.0.0 + "@lumino/signaling": ^2.0.0 + "@mui/icons-material": ^5.11.0 + "@mui/material": ^5.11.0 + clsx: ^2.1.0 + react: ^18.2.0 + react-dom: ^18.2.0 + checksum: 75d906e59c28ae34132011583769bab2ce7e2d727888c7e644f2db0ddda60d9bf46a9c16bac67e55d06ad82319078ec0f6eed5468534bde5fd3c15a279e8be45 + languageName: node + linkType: hard + +"@jupyter/react-components@npm:^0.15.2": + version: 0.15.3 + resolution: "@jupyter/react-components@npm:0.15.3" + dependencies: + "@jupyter/web-components": ^0.15.3 + "@microsoft/fast-react-wrapper": ^0.3.22 + react: ">=17.0.0 <19.0.0" + checksum: 1a6b256314259c6465c4b6d958575710536b82234a7bf0fba3e889a07e1f19ff8ab321450be354359876f92c45dbcc9d21a840237ff4a619806d9de696f55496 + languageName: node + linkType: hard + "@jupyter/react-components@npm:^0.16.6": version: 0.16.7 resolution: "@jupyter/react-components@npm:0.16.7" @@ -2031,6 +2252,18 @@ __metadata: languageName: node linkType: hard +"@jupyter/web-components@npm:^0.15.3": + version: 0.15.3 + resolution: "@jupyter/web-components@npm:0.15.3" + dependencies: + "@microsoft/fast-colors": ^5.3.1 + "@microsoft/fast-element": ^1.12.0 + "@microsoft/fast-foundation": ^2.49.4 + "@microsoft/fast-web-utilities": ^5.4.1 + checksum: a0980af934157bfdbdb6cc169c0816c1b2e57602d524c56bdcef746a4c25dfeb8f505150d83207c8695ed89b5486cf53d35a3382584d25ef64db666e4e16e45b + languageName: node + linkType: hard + "@jupyter/web-components@npm:^0.16.6, @jupyter/web-components@npm:^0.16.7": version: 0.16.7 resolution: "@jupyter/web-components@npm:0.16.7" @@ -2057,7 +2290,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/application@npm:^4.0.0, @jupyterlab/application@npm:^4.4.9": +"@jupyterlab/application@npm:^4.2.0, @jupyterlab/application@npm:^4.4.9, @jupyterlab/application@npm:~4.4.9": version: 4.4.9 resolution: "@jupyterlab/application@npm:4.4.9" dependencies: @@ -2085,7 +2318,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/apputils@npm:^4.5.9": +"@jupyterlab/apputils@npm:^4.3.0, @jupyterlab/apputils@npm:^4.5.9": version: 4.5.9 resolution: "@jupyterlab/apputils@npm:4.5.9" dependencies: @@ -2205,7 +2438,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/codeeditor@npm:^4.4.9": +"@jupyterlab/codeeditor@npm:^4.2.0, @jupyterlab/codeeditor@npm:^4.4.9": version: 4.4.9 resolution: "@jupyterlab/codeeditor@npm:4.4.9" dependencies: @@ -2229,7 +2462,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/codemirror@npm:^4.4.9": +"@jupyterlab/codemirror@npm:^4.2.0, @jupyterlab/codemirror@npm:^4.4.9": version: 4.4.9 resolution: "@jupyterlab/codemirror@npm:4.4.9" dependencies: @@ -2271,7 +2504,35 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/coreutils@npm:^6.0.0, @jupyterlab/coreutils@npm:^6.4.9": +"@jupyterlab/completer@npm:^4.2.0": + version: 4.4.9 + resolution: "@jupyterlab/completer@npm:4.4.9" + dependencies: + "@codemirror/state": ^6.5.2 + "@codemirror/view": ^6.38.1 + "@jupyter/ydoc": ^3.1.0 + "@jupyterlab/apputils": ^4.5.9 + "@jupyterlab/codeeditor": ^4.4.9 + "@jupyterlab/codemirror": ^4.4.9 + "@jupyterlab/coreutils": ^6.4.9 + "@jupyterlab/rendermime": ^4.4.9 + "@jupyterlab/services": ^7.4.9 + "@jupyterlab/settingregistry": ^4.4.9 + "@jupyterlab/statedb": ^4.4.9 + "@jupyterlab/translation": ^4.4.9 + "@jupyterlab/ui-components": ^4.4.9 + "@lumino/algorithm": ^2.0.3 + "@lumino/coreutils": ^2.2.1 + "@lumino/disposable": ^2.1.4 + "@lumino/domutils": ^2.0.3 + "@lumino/messaging": ^2.0.3 + "@lumino/signaling": ^2.1.4 + "@lumino/widgets": ^2.7.1 + checksum: f68a5422f7ea68a21fc0ec2e04db9c2dc64bcce169f81b1cd751a8e01b148b86acbbbe6cac1891821b3762dc3d276c8f2355cde2f45de9ff15b0030a21e026be + languageName: node + linkType: hard + +"@jupyterlab/coreutils@npm:^6.2.0, @jupyterlab/coreutils@npm:^6.4.9, @jupyterlab/coreutils@npm:~6.4.9": version: 6.4.9 resolution: "@jupyterlab/coreutils@npm:6.4.9" dependencies: @@ -2285,7 +2546,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/docmanager@npm:^4.4.9": +"@jupyterlab/docmanager@npm:^4.2.0, @jupyterlab/docmanager@npm:^4.4.9": version: 4.4.9 resolution: "@jupyterlab/docmanager@npm:4.4.9" dependencies: @@ -2310,7 +2571,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/docregistry@npm:^4.4.9": +"@jupyterlab/docregistry@npm:^4.4.9, @jupyterlab/docregistry@npm:~4.4.9": version: 4.4.9 resolution: "@jupyterlab/docregistry@npm:4.4.9" dependencies: @@ -2355,7 +2616,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/filebrowser@npm:^4.4.9": +"@jupyterlab/filebrowser@npm:^4.2.0, @jupyterlab/filebrowser@npm:^4.4.9": version: 4.4.9 resolution: "@jupyterlab/filebrowser@npm:4.4.9" dependencies: @@ -2384,6 +2645,32 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/fileeditor@npm:^4.2.0": + version: 4.4.9 + resolution: "@jupyterlab/fileeditor@npm:4.4.9" + dependencies: + "@jupyter/ydoc": ^3.1.0 + "@jupyterlab/apputils": ^4.5.9 + "@jupyterlab/codeeditor": ^4.4.9 + "@jupyterlab/codemirror": ^4.4.9 + "@jupyterlab/coreutils": ^6.4.9 + "@jupyterlab/docregistry": ^4.4.9 + "@jupyterlab/documentsearch": ^4.4.9 + "@jupyterlab/lsp": ^4.4.9 + "@jupyterlab/statusbar": ^4.4.9 + "@jupyterlab/toc": ^6.4.9 + "@jupyterlab/translation": ^4.4.9 + "@jupyterlab/ui-components": ^4.4.9 + "@lumino/commands": ^2.3.2 + "@lumino/coreutils": ^2.2.1 + "@lumino/messaging": ^2.0.3 + "@lumino/widgets": ^2.7.1 + react: ^18.2.0 + regexp-match-indices: ^1.0.2 + checksum: 0b0cd227610105e7bc742d864fcb969ca243707eac4882d1c69dab32f7232bdaaa3b83ebf994ba6aedcadd581ac96d7a07663345f5bca3401809c564bdc7d6f3 + languageName: node + linkType: hard + "@jupyterlab/lsp@npm:^4.4.9": version: 4.4.9 resolution: "@jupyterlab/lsp@npm:4.4.9" @@ -2416,7 +2703,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/notebook@npm:^4.4.9": +"@jupyterlab/notebook@npm:^4.2.0, @jupyterlab/notebook@npm:^4.4.9": version: 4.4.9 resolution: "@jupyterlab/notebook@npm:4.4.9" dependencies: @@ -2489,7 +2776,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/rendermime-interfaces@npm:^3.12.9": +"@jupyterlab/rendermime-interfaces@npm:^3.12.9, @jupyterlab/rendermime-interfaces@npm:~3.12.9": version: 3.12.9 resolution: "@jupyterlab/rendermime-interfaces@npm:3.12.9" dependencies: @@ -2499,7 +2786,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/rendermime@npm:^4.4.9": +"@jupyterlab/rendermime@npm:^4.2.0, @jupyterlab/rendermime@npm:^4.4.9": version: 4.4.9 resolution: "@jupyterlab/rendermime@npm:4.4.9" dependencies: @@ -2519,7 +2806,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/services@npm:^7.0.0, @jupyterlab/services@npm:^7.4.9": +"@jupyterlab/services@npm:^7.2.0, @jupyterlab/services@npm:^7.4.9": version: 7.4.9 resolution: "@jupyterlab/services@npm:7.4.9" dependencies: @@ -2658,7 +2945,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/ui-components@npm:^4.4.9": +"@jupyterlab/ui-components@npm:^4.2.0, @jupyterlab/ui-components@npm:^4.4.9, @jupyterlab/ui-components@npm:~4.4.9": version: 4.4.9 resolution: "@jupyterlab/ui-components@npm:4.4.9" dependencies: @@ -2846,7 +3133,7 @@ __metadata: languageName: node linkType: hard -"@lumino/algorithm@npm:^2.0.3": +"@lumino/algorithm@npm:^2.0.0, @lumino/algorithm@npm:^2.0.3": version: 2.0.3 resolution: "@lumino/algorithm@npm:2.0.3" checksum: 03932cdc39d612a00579ee40bafb0b1d8bf5f8a12449f777a1ae7201843ddefb557bc3f9260aa6b9441d87bfc43e53cced854e71c4737de59e32cd00d4ac1394 @@ -2873,7 +3160,7 @@ __metadata: languageName: node linkType: hard -"@lumino/commands@npm:^2.3.2": +"@lumino/commands@npm:^2.0.0, @lumino/commands@npm:^2.3.2": version: 2.3.2 resolution: "@lumino/commands@npm:2.3.2" dependencies: @@ -2888,7 +3175,7 @@ __metadata: languageName: node linkType: hard -"@lumino/coreutils@npm:^1.11.0 || ^2.0.0, @lumino/coreutils@npm:^1.11.0 || ^2.2.1, @lumino/coreutils@npm:^2.2.1": +"@lumino/coreutils@npm:^1.11.0 || ^2.0.0, @lumino/coreutils@npm:^1.11.0 || ^2.2.1, @lumino/coreutils@npm:^2.0.0, @lumino/coreutils@npm:^2.2.1": version: 2.2.1 resolution: "@lumino/coreutils@npm:2.2.1" dependencies: @@ -2897,7 +3184,7 @@ __metadata: languageName: node linkType: hard -"@lumino/disposable@npm:^1.10.0 || ^2.0.0, @lumino/disposable@npm:^2.1.4": +"@lumino/disposable@npm:^1.10.0 || ^2.0.0, @lumino/disposable@npm:^2.0.0, @lumino/disposable@npm:^2.1.4": version: 2.1.4 resolution: "@lumino/disposable@npm:2.1.4" dependencies: @@ -2958,7 +3245,7 @@ __metadata: languageName: node linkType: hard -"@lumino/signaling@npm:^1.10.0 || ^2.0.0, @lumino/signaling@npm:^2.1.4": +"@lumino/signaling@npm:^1.10.0 || ^2.0.0, @lumino/signaling@npm:^2.0.0, @lumino/signaling@npm:^2.1.4": version: 2.1.4 resolution: "@lumino/signaling@npm:2.1.4" dependencies: @@ -3017,7 +3304,7 @@ __metadata: languageName: node linkType: hard -"@microsoft/fast-foundation@npm:^2.49.4": +"@microsoft/fast-foundation@npm:^2.49.4, @microsoft/fast-foundation@npm:^2.50.0": version: 2.50.0 resolution: "@microsoft/fast-foundation@npm:2.50.0" dependencies: @@ -3029,6 +3316,18 @@ __metadata: languageName: node linkType: hard +"@microsoft/fast-react-wrapper@npm:^0.3.22": + version: 0.3.25 + resolution: "@microsoft/fast-react-wrapper@npm:0.3.25" + dependencies: + "@microsoft/fast-element": ^1.14.0 + "@microsoft/fast-foundation": ^2.50.0 + peerDependencies: + react: ">=16.9.0" + checksum: 4c8e597eefd51c3091c25d0df28018b3283139178d6fd759532b40deb189f0422877308894fe55670edb82ed2a5b0138f2be8ef0b3df3ae518c14f75d6d0d577 + languageName: node + linkType: hard + "@microsoft/fast-web-utilities@npm:^5.4.1": version: 5.4.1 resolution: "@microsoft/fast-web-utilities@npm:5.4.1" @@ -3038,6 +3337,161 @@ __metadata: languageName: node linkType: hard +"@mui/core-downloads-tracker@npm:^5.18.0": + version: 5.18.0 + resolution: "@mui/core-downloads-tracker@npm:5.18.0" + checksum: 065b46739d2bd84b880ad2f6a0a2062d60e3a296ce18ff380cad22ab5b2cb3de396755f322f4bea3a422ffffe1a9244536fc3c9623056ff3873c996e6664b1b9 + languageName: node + linkType: hard + +"@mui/icons-material@npm:^5.11.0": + version: 5.18.0 + resolution: "@mui/icons-material@npm:5.18.0" + dependencies: + "@babel/runtime": ^7.23.9 + peerDependencies: + "@mui/material": ^5.0.0 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 1ee4ee2817278c72095860c81e742270ee01fd320e48f865d371abfeacf83e43a7c6edbfd060ee66ef2a4f02c6cf79de0aca1e73a4d5f6320112bbfcac8176c3 + languageName: node + linkType: hard + +"@mui/material@npm:^5.11.0": + version: 5.18.0 + resolution: "@mui/material@npm:5.18.0" + dependencies: + "@babel/runtime": ^7.23.9 + "@mui/core-downloads-tracker": ^5.18.0 + "@mui/system": ^5.18.0 + "@mui/types": ~7.2.15 + "@mui/utils": ^5.17.1 + "@popperjs/core": ^2.11.8 + "@types/react-transition-group": ^4.4.10 + clsx: ^2.1.0 + csstype: ^3.1.3 + prop-types: ^15.8.1 + react-is: ^19.0.0 + react-transition-group: ^4.4.5 + peerDependencies: + "@emotion/react": ^11.5.0 + "@emotion/styled": ^11.3.0 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/react": + optional: true + "@emotion/styled": + optional: true + "@types/react": + optional: true + checksum: b9d6cf638774e65924adf6f58ad50639c55050b33dc6223aa74686f733e137d173b4594b28be70e8ea3baf413a7fcd3bee40d35e3af12a42b7ba03def71ea217 + languageName: node + linkType: hard + +"@mui/private-theming@npm:^5.17.1": + version: 5.17.1 + resolution: "@mui/private-theming@npm:5.17.1" + dependencies: + "@babel/runtime": ^7.23.9 + "@mui/utils": ^5.17.1 + prop-types: ^15.8.1 + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: ab755e96c8adb6c12f2c7e567154a8d873d56f1e35d0fb897b568123dfc0e9d1d770ca0992410433c728f9f4403f556f3c1e1b5b57b001ef1948ddfc8c1717bd + languageName: node + linkType: hard + +"@mui/styled-engine@npm:^5.18.0": + version: 5.18.0 + resolution: "@mui/styled-engine@npm:5.18.0" + dependencies: + "@babel/runtime": ^7.23.9 + "@emotion/cache": ^11.13.5 + "@emotion/serialize": ^1.3.3 + csstype: ^3.1.3 + prop-types: ^15.8.1 + peerDependencies: + "@emotion/react": ^11.4.1 + "@emotion/styled": ^11.3.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/react": + optional: true + "@emotion/styled": + optional: true + checksum: ab2d260ad5eea94993bc7706b164ae4ec11bd37dd7be36b93755d18da5b7859d39ad44173adced0e111e8b1b7ef65c0e369df3f2908144237c5e78f793eebb5a + languageName: node + linkType: hard + +"@mui/system@npm:^5.18.0": + version: 5.18.0 + resolution: "@mui/system@npm:5.18.0" + dependencies: + "@babel/runtime": ^7.23.9 + "@mui/private-theming": ^5.17.1 + "@mui/styled-engine": ^5.18.0 + "@mui/types": ~7.2.15 + "@mui/utils": ^5.17.1 + clsx: ^2.1.0 + csstype: ^3.1.3 + prop-types: ^15.8.1 + peerDependencies: + "@emotion/react": ^11.5.0 + "@emotion/styled": ^11.3.0 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/react": + optional: true + "@emotion/styled": + optional: true + "@types/react": + optional: true + checksum: 451f43889c2638da7c52b898e6174eafcdbcbcaaf3a79f954fdd58d9a043786d76fa4fca902cfdd6ab1aa250f5b1f932ef93d789c5a15987d893c0741bf7a1ad + languageName: node + linkType: hard + +"@mui/types@npm:~7.2.15": + version: 7.2.24 + resolution: "@mui/types@npm:7.2.24" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 3a7367f9503e90fc3cce78885b57b54a00f7a04108be8af957fdc882c1bc0af68390920ea3d6aef855e704651ffd4a530e36ccbec4d0f421a176a2c3c432bb95 + languageName: node + linkType: hard + +"@mui/utils@npm:^5.17.1": + version: 5.17.1 + resolution: "@mui/utils@npm:5.17.1" + dependencies: + "@babel/runtime": ^7.23.9 + "@mui/types": ~7.2.15 + "@types/prop-types": ^15.7.12 + clsx: ^2.1.1 + prop-types: ^15.8.1 + react-is: ^19.0.0 + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 06f9da7025b9291f1052c0af012fd0f00ff1539bc99880e15f483a85c27bff0e9c3d047511dc5c3177d546c21978227ece2e6f7a7bd91903c72b48b89c45a677 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -3101,6 +3555,13 @@ __metadata: languageName: node linkType: hard +"@popperjs/core@npm:^2.11.8": + version: 2.11.8 + resolution: "@popperjs/core@npm:2.11.8" + checksum: e5c69fdebf52a4012f6a1f14817ca8e9599cb1be73dd1387e1785e2ed5e5f0862ff817f420a87c7fc532add1f88a12e25aeb010ffcbdc98eace3d55ce2139cf0 + languageName: node + linkType: hard + "@rjsf/core@npm:^5.13.4": version: 5.24.13 resolution: "@rjsf/core@npm:5.24.13" @@ -3325,7 +3786,14 @@ __metadata: languageName: node linkType: hard -"@types/prop-types@npm:*": +"@types/parse-json@npm:^4.0.0": + version: 4.0.2 + resolution: "@types/parse-json@npm:4.0.2" + checksum: 5bf62eec37c332ad10059252fc0dab7e7da730764869c980b0714777ad3d065e490627be9f40fc52f238ffa3ac4199b19de4127196910576c2fe34dd47c7a470 + languageName: node + linkType: hard + +"@types/prop-types@npm:*, @types/prop-types@npm:^15.7.12": version: 15.7.15 resolution: "@types/prop-types@npm:15.7.15" checksum: 31aa2f59b28f24da6fb4f1d70807dae2aedfce090ec63eaf9ea01727a9533ef6eaf017de5bff99fbccad7d1c9e644f52c6c2ba30869465dd22b1a7221c29f356 @@ -3342,6 +3810,15 @@ __metadata: languageName: node linkType: hard +"@types/react-transition-group@npm:^4.4.10": + version: 4.4.12 + resolution: "@types/react-transition-group@npm:4.4.12" + peerDependencies: + "@types/react": "*" + checksum: 13d36396cae4d3c316b03d4a0ba299f0d039c59368ba65e04b0c3dc06fd0a16f59d2c669c3e32d6d525a95423f156b84e550d26bff0bdd8df285f305f8f3a0ed + languageName: node + linkType: hard + "@types/react@npm:*": version: 19.1.16 resolution: "@types/react@npm:19.1.16" @@ -4043,6 +4520,17 @@ __metadata: languageName: node linkType: hard +"babel-plugin-macros@npm:^3.1.0": + version: 3.1.0 + resolution: "babel-plugin-macros@npm:3.1.0" + dependencies: + "@babel/runtime": ^7.12.5 + cosmiconfig: ^7.0.0 + resolve: ^1.19.0 + checksum: 765de4abebd3e4688ebdfbff8571ddc8cd8061f839bb6c3e550b0344a4027b04c60491f843296ce3f3379fb356cc873d57a9ee6694262547eb822c14a25be9a6 + languageName: node + linkType: hard + "babel-plugin-polyfill-corejs2@npm:^0.4.14": version: 0.4.14 resolution: "babel-plugin-polyfill-corejs2@npm:0.4.14" @@ -4362,6 +4850,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^2.1.0, clsx@npm:^2.1.1": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: acd3e1ab9d8a433ecb3cc2f6a05ab95fe50b4a3cfc5ba47abb6cbf3754585fcb87b84e90c822a1f256c4198e3b41c7f6c391577ffc8678ad587fc0976b24fd57 + languageName: node + linkType: hard + "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -4482,6 +4977,13 @@ __metadata: languageName: node linkType: hard +"convert-source-map@npm:^1.5.0": + version: 1.9.0 + resolution: "convert-source-map@npm:1.9.0" + checksum: dc55a1f28ddd0e9485ef13565f8f756b342f9a46c4ae18b843fe3c30c675d058d6a4823eff86d472f187b176f0adf51ea7b69ea38be34be4a63cbbf91b0593c8 + languageName: node + linkType: hard + "convert-source-map@npm:^2.0.0": version: 2.0.0 resolution: "convert-source-map@npm:2.0.0" @@ -4498,6 +5000,19 @@ __metadata: languageName: node linkType: hard +"cosmiconfig@npm:^7.0.0": + version: 7.1.0 + resolution: "cosmiconfig@npm:7.1.0" + dependencies: + "@types/parse-json": ^4.0.0 + import-fresh: ^3.2.1 + parse-json: ^5.0.0 + path-type: ^4.0.0 + yaml: ^1.10.0 + checksum: c53bf7befc1591b2651a22414a5e786cd5f2eeaa87f3678a3d49d6069835a9d8d1aef223728e98aa8fec9a95bf831120d245096db12abe019fecb51f5696c96f + languageName: node + linkType: hard + "cosmiconfig@npm:^8.2.0": version: 8.3.6 resolution: "cosmiconfig@npm:8.3.6" @@ -4630,7 +5145,7 @@ __metadata: languageName: node linkType: hard -"csstype@npm:^3.0.2": +"csstype@npm:^3.0.2, csstype@npm:^3.1.3": version: 3.1.3 resolution: "csstype@npm:3.1.3" checksum: 8db785cc92d259102725b3c694ec0c823f5619a84741b5c7991b8ad135dfaa66093038a1cc63e03361a6cd28d122be48f2106ae72334e067dd619a51f49eddf7 @@ -4767,6 +5282,16 @@ __metadata: languageName: node linkType: hard +"dom-helpers@npm:^5.0.1": + version: 5.2.1 + resolution: "dom-helpers@npm:5.2.1" + dependencies: + "@babel/runtime": ^7.8.7 + csstype: ^3.0.2 + checksum: 863ba9e086f7093df3376b43e74ce4422571d404fc9828bf2c56140963d5edf0e56160f9b2f3bb61b282c07f8fc8134f023c98fd684bddcb12daf7b0f14d951c + languageName: node + linkType: hard + "dom-serializer@npm:^2.0.0": version: 2.0.0 resolution: "dom-serializer@npm:2.0.0" @@ -5368,7 +5893,7 @@ __metadata: languageName: node linkType: hard -"find-root@npm:^1.0.0": +"find-root@npm:^1.0.0, find-root@npm:^1.1.0": version: 1.1.0 resolution: "find-root@npm:1.1.0" checksum: b2a59fe4b6c932eef36c45a048ae8f93c85640212ebe8363164814990ee20f154197505965f3f4f102efc33bfb1cbc26fd17c4a2fc739ebc51b886b137cbefaf @@ -5782,6 +6307,15 @@ __metadata: languageName: node linkType: hard +"hoist-non-react-statics@npm:^3.3.1": + version: 3.3.2 + resolution: "hoist-non-react-statics@npm:3.3.2" + dependencies: + react-is: ^16.7.0 + checksum: b1538270429b13901ee586aa44f4cc3ecd8831c061d06cb8322e50ea17b3f5ce4d0e2e66394761e6c8e152cd8c34fb3b4b690116c6ce2bd45b18c746516cb9e8 + languageName: node + linkType: hard + "hosted-git-info@npm:^4.0.1": version: 4.1.0 resolution: "hosted-git-info@npm:4.1.0" @@ -7622,7 +8156,7 @@ __metadata: languageName: node linkType: hard -"parse-json@npm:^5.2.0": +"parse-json@npm:^5.0.0, parse-json@npm:^5.2.0": version: 5.2.0 resolution: "parse-json@npm:5.2.0" dependencies: @@ -7916,7 +8450,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.8.1": +"prop-types@npm:^15.6.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -7992,7 +8526,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.13.1": +"react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f @@ -8006,6 +8540,28 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^19.0.0": + version: 19.1.1 + resolution: "react-is@npm:19.1.1" + checksum: e60ed01c27fe4d22b08f8a31f18831d144a801d08a909ca31fb1d02721b4f4cde0759148d6341f660a4d6ce54a78e22b8b39520b67e2e76254e583885868ab43 + languageName: node + linkType: hard + +"react-transition-group@npm:^4.4.5": + version: 4.4.5 + resolution: "react-transition-group@npm:4.4.5" + dependencies: + "@babel/runtime": ^7.5.5 + dom-helpers: ^5.0.1 + loose-envify: ^1.4.0 + prop-types: ^15.6.2 + peerDependencies: + react: ">=16.6.0" + react-dom: ">=16.6.0" + checksum: 75602840106aa9c6545149d6d7ae1502fb7b7abadcce70a6954c4b64a438ff1cd16fc77a0a1e5197cdd72da398f39eb929ea06f9005c45b132ed34e056ebdeb1 + languageName: node + linkType: hard + "react@npm:>=17.0.0 <19.0.0, react@npm:^18.2.0": version: 18.3.1 resolution: "react@npm:18.3.1" @@ -8083,6 +8639,24 @@ __metadata: languageName: node linkType: hard +"regexp-match-indices@npm:^1.0.2": + version: 1.0.2 + resolution: "regexp-match-indices@npm:1.0.2" + dependencies: + regexp-tree: ^0.1.11 + checksum: 8cc779f6cf8f404ead828d09970a7d4bd66bd78d43ab9eb2b5e65f2ef2ba1ed53536f5b5fa839fb90b350365fb44b6a851c7f16289afc3f37789c113ab2a7916 + languageName: node + linkType: hard + +"regexp-tree@npm:^0.1.11": + version: 0.1.27 + resolution: "regexp-tree@npm:0.1.27" + bin: + regexp-tree: bin/regexp-tree + checksum: 129aebb34dae22d6694ab2ac328be3f99105143737528ab072ef624d599afecbcfae1f5c96a166fa9e5f64fa1ecf30b411c4691e7924c3e11bbaf1712c260c54 + languageName: node + linkType: hard + "regexpu-core@npm:^6.2.0": version: 6.4.0 resolution: "regexpu-core@npm:6.4.0" @@ -8166,7 +8740,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.20.0, resolve@npm:^1.22.10": +"resolve@npm:^1.19.0, resolve@npm:^1.20.0, resolve@npm:^1.22.10": version: 1.22.10 resolution: "resolve@npm:1.22.10" dependencies: @@ -8179,7 +8753,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.10#~builtin": +"resolve@patch:resolve@^1.19.0#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.10#~builtin": version: 1.22.10 resolution: "resolve@patch:resolve@npm%3A1.22.10#~builtin::version=1.22.10&hash=c3c19d" dependencies: @@ -8526,6 +9100,13 @@ __metadata: languageName: node linkType: hard +"source-map@npm:^0.5.7": + version: 0.5.7 + resolution: "source-map@npm:0.5.7" + checksum: 5dc2043b93d2f194142c7f38f74a24670cd7a0063acdaf4bf01d2964b402257ae843c2a8fa822ad5b71013b5fcafa55af7421383da919752f22ff488bc553f4d + languageName: node + linkType: hard + "source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" @@ -8786,6 +9367,13 @@ __metadata: languageName: node linkType: hard +"stylis@npm:4.2.0": + version: 4.2.0 + resolution: "stylis@npm:4.2.0" + checksum: 0eb6cc1b866dc17a6037d0a82ac7fa877eba6a757443e79e7c4f35bacedbf6421fadcab4363b39667b43355cbaaa570a3cde850f776498e5450f32ed2f9b7584 + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -9764,6 +10352,13 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^1.10.0": + version: 1.10.2 + resolution: "yaml@npm:1.10.2" + checksum: ce4ada136e8a78a0b08dc10b4b900936912d15de59905b2bf415b4d33c63df1d555d23acb2a41b23cf9fb5da41c256441afca3d6509de7247daa062fd2c5ea5f + languageName: node + linkType: hard + "yargs-parser@npm:^20.2.9": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9"