Skip to content

Enable/disable AI agent in chats #7

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions packages/jupyter-ai/jupyter_ai/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from jupyter_server.extension.application import ExtensionApp
from jupyter_server.utils import url_path_join
from jupyterlab_collaborative_chat.ychat import YChat
from pycrdt import ArrayEvent
from pycrdt import ArrayEvent, MapEvent
from tornado.web import StaticFileHandler
from traitlets import Dict, Integer, List, Unicode

Expand Down Expand Up @@ -239,6 +239,9 @@ def initialize(self):
# updating it.
self.messages_indexes = {}

# The subscriptions to chats messages.
self.subscriptions = {}

async def connect_chat(
self, logger: EventLogger, schema_id: str, data: dict
) -> None:
Expand All @@ -257,8 +260,14 @@ async def connect_chat(
)
chat.awareness.set_local_state_field("user", BOT)

callback = partial(self.on_change, chat)
chat.ymessages.observe(callback)
# Check if the AI agent should be connected to the chat or not.
chat_meta = chat.get_metadata()
if "agents" in chat_meta and BOT["username"] in chat.get_metadata()["agents"]:
message_callback = partial(self.on_message_change, chat)
self.subscriptions[chat.get_id()] = chat.ymessages.observe(message_callback)

metadata_callback = partial(self.on_metadata_change, chat)
chat.ymetadata.observe(metadata_callback)

async def get_chat(self, room_id: str) -> YChat:
if COLLAB_VERSION == 3:
Expand All @@ -272,7 +281,7 @@ async def get_chat(self, room_id: str) -> YChat:
document = room._document
return document

def on_change(self, chat: YChat, events: ArrayEvent) -> None:
def on_message_change(self, chat: YChat, events: ArrayEvent) -> None:
for change in events.delta:
if not "insert" in change.keys():
continue
Expand All @@ -296,6 +305,22 @@ def on_change(self, chat: YChat, events: ArrayEvent) -> None:
self._route(chat_message, chat)
)

def on_metadata_change(self, chat: YChat, events: MapEvent) -> None:
"""
Triggered when a chat metadata has changed.
It will connect or disconnect the AI agent from the chat.
"""
if "agents" in events.keys:
if BOT["username"] in events.keys["agents"]["newValue"]:
message_callback = partial(self.on_message_change, chat)
self.subscriptions[chat.get_id()] = chat.ymessages.observe(message_callback)
else:
chat.ymessages.unobserve(self.subscriptions[chat.get_id()])
del self.subscriptions[chat.get_id()]

# Update the bot user avatar
chat.awareness.set_local_state_field("user", BOT)

async def _route(self, message: HumanChatMessage, chat: YChat):
"""Method that routes an incoming message to the appropriate handler."""
chat_handlers = self.settings["jai_chat_handlers"]
Expand Down
4 changes: 3 additions & 1 deletion packages/jupyter-ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@jupyter/chat": "^0.5.0",
"@jupyter/collaboration": "^1",
"@jupyter/collaboration": "^2.1.3",
"@jupyter/docprovider": "^2.1.3",
"@jupyterlab/application": "^4.2.0",
"@jupyterlab/apputils": "^4.2.0",
"@jupyterlab/cells": "^4.2.0",
Expand All @@ -79,6 +80,7 @@
"@jupyterlab/ui-components": "^4.2.0",
"@mui/icons-material": "^5.11.0",
"@mui/material": "^5.11.0",
"jupyterlab-collaborative-chat": "0.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
Expand Down
41 changes: 36 additions & 5 deletions packages/jupyter-ai/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { IAutocompletionRegistry } from '@jupyter/chat';
import { IGlobalAwareness } from '@jupyter/collaboration';
import { IGlobalAwareness, UsersItem } from '@jupyter/collaboration';
import {
JupyterFrontEnd,
JupyterFrontEndPlugin,
Expand All @@ -10,18 +10,23 @@ import {
ReactWidget,
IThemeManager,
MainAreaWidget,
ICommandPalette
ICommandPalette,
IToolbarWidgetRegistry
} from '@jupyterlab/apputils';
import { IDocumentWidget } from '@jupyterlab/docregistry';
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
import { Signal } from '@lumino/signaling';
import {
CollaborativeChatPanel,
IChatFactory
} from 'jupyterlab-collaborative-chat';
import type { Awareness } from 'y-protocols/awareness';

import { ChatHandler } from './chat_handler';
import { completionPlugin } from './completions';
import { ActiveCellManager } from './contexts/active-cell-context';
import { SelectionWatcher } from './selection-watcher';
import { menuPlugin } from './plugins/menu-plugin';
import { SelectionWatcher } from './selection-watcher';
import { autocompletion } from './slash-autocompletion';
import { statusItemPlugin } from './status';
import {
Expand All @@ -30,6 +35,7 @@ import {
IJaiMessageFooter,
IJaiTelemetryHandler
} from './tokens';
import { userIconRenderer } from './user-icon';
import { buildErrorWidget } from './widgets/chat-error';
import { buildChatSidebar } from './widgets/chat-sidebar';
import { buildAiSettings } from './widgets/settings-widget';
Expand Down Expand Up @@ -186,7 +192,7 @@ const plugin: JupyterFrontEndPlugin<IJaiCore> = {
/**
* Add slash commands to collaborative chat.
*/
const collaborative_autocompletion: JupyterFrontEndPlugin<void> = {
const collaborativeAutocompletion: JupyterFrontEndPlugin<void> = {
id: '@jupyter-ai/core:autocompletion',
autoStart: true,
requires: [IAutocompletionRegistry],
Expand All @@ -198,12 +204,37 @@ const collaborative_autocompletion: JupyterFrontEndPlugin<void> = {
}
};

/**
* Customize users item toolbar widget.
*/
const usersItem: JupyterFrontEndPlugin<void> = {
id: '@jupyter-ai/core:users_item',
autoStart: true,
requires: [IChatFactory, IToolbarWidgetRegistry],
activate: async (
app: JupyterFrontEnd,
chatFactory: IChatFactory,
toolbarRegistry: IToolbarWidgetRegistry
) => {
toolbarRegistry.addFactory<CollaborativeChatPanel>(
'Chat',
'usersItem',
panel =>
UsersItem.createWidget({
model: panel.model,
iconRenderer: userIconRenderer
})
);
}
};

export default [
plugin,
statusItemPlugin,
completionPlugin,
menuPlugin,
collaborative_autocompletion
collaborativeAutocompletion,
usersItem
];

export * from './contexts';
Expand Down
75 changes: 75 additions & 0 deletions packages/jupyter-ai/src/user-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { DefaultIconRenderer, UsersItem } from '@jupyter/collaboration';
import {
CollaborativeChatModel,
IChatChanges,
YChat
} from 'jupyterlab-collaborative-chat';
import React, { useEffect, useRef, useState } from 'react';

/**
* The user icon renderer.
*/
export const userIconRenderer = (
props: UsersItem.IIconRendererProps
): JSX.Element => {
const { user } = props;

if (user.userData.name === 'Jupyternaut') {
return <AgentIconRenderer {...props} />;
}

return <DefaultIconRenderer user={user} />;
};

/**
* The user icon renderer for an AI agent.
* It modify the metadata of the ydocument to enable/disable the AI agent.
*/
const AgentIconRenderer = (
props: UsersItem.IIconRendererProps
): JSX.Element => {
const { user, model } = props;
const [iconClass, setIconClass] = useState<string>('');
const sharedModel = useRef<YChat>(
(model as CollaborativeChatModel).sharedModel
);

useEffect(() => {
// Update the icon class.
const updateStatus = () => {
const agents =
(sharedModel.current.getMetadata('agents') as string[]) || [];
setIconClass(
agents.includes(user.userData.username) ? '' : 'disconnected'
);
};

const onChange = (_: YChat, changes: IChatChanges) => {
if (changes.metadataChanges) {
updateStatus();
}
};

sharedModel.current.changed.connect(onChange);
updateStatus();
return () => {
sharedModel.current.changed.disconnect(updateStatus);
};
}, [model]);

const onclick = () => {
const agents =
(sharedModel.current.getMetadata('agents') as string[]) || [];
const index = agents.indexOf(user.userData.username);
if (index > -1) {
agents.splice(index, 1);
} else {
agents.push(user.userData.username);
}
sharedModel.current.setMetadata('agents', agents);
};

return (
<DefaultIconRenderer user={user} onClick={onclick} className={iconClass} />
);
};
1 change: 1 addition & 0 deletions packages/jupyter-ai/style/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
@import './expandable-text-field.css';
@import './chat-settings.css';
@import './rendermime-markdown.css';
@import './user-icon.css';
13 changes: 13 additions & 0 deletions packages/jupyter-ai/style/user-icon.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.jp-toolbar-users-item .jp-MenuBar-imageIcon.disconnected {
opacity: 0.5;
}

.jp-toolbar-users-item .jp-MenuBar-imageIcon.disconnected:before {
position: absolute;
left: 11px;
content: '';
height: 28px;
width: 2px;
background-color: #333;
transform: rotate(45deg);
}
Loading