diff --git a/src/agent.ts b/src/agent.ts index 33bc51d3..2be30f75 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -319,6 +319,7 @@ export class AgentManager implements IAgentManager { this._providerRegistry = options.providerRegistry; this._skillRegistry = options.skillRegistry; this._secretsManager = options.secretsManager; + this._toolsEnabled = options.toolsEnabled; this._selectedToolNames = []; this._agent = null; this._history = []; @@ -782,7 +783,8 @@ export class AgentManager implements IAgentManager { const model = await this._createModel(); const supportsToolCalling = this._supportsToolCalling(); - const canUseTools = config.toolsEnabled && supportsToolCalling; + const canUseTools = + (this._toolsEnabled ?? config.toolsEnabled) && supportsToolCalling; const hasFunctionToolRegistry = !!( this._toolRegistry && Object.keys(this._toolRegistry.tools).length > 0 ); @@ -1281,6 +1283,7 @@ WEB RETRIEVAL POLICY: private _providerRegistry?: IProviderRegistry; private _skillRegistry?: ISkillRegistry; private _secretsManager?: ISecretsManager; + private _toolsEnabled?: boolean; private _selectedToolNames: string[]; private _agent: ToolLoopAgent | null; private _history: ModelMessage[]; diff --git a/src/chat-model-handler.ts b/src/chat-model-handler.ts index 5208ef2a..681658dd 100644 --- a/src/chat-model-handler.ts +++ b/src/chat-model-handler.ts @@ -30,8 +30,18 @@ export class ChatModelHandler implements IChatModelHandler { } createModel(options: ICreateChatOptions): IAIChatModel { - const { name, activeProvider, tokenUsage, messages, autosave, title } = - options; + const { + name, + activeProvider, + tokenUsage, + messages, + contextMessages, + autosave, + title, + restore, + toolsEnabled, + enableCodeToolbar + } = options; // Create Agent Manager first so it can be shared const agentManager = this._agentManagerFactory.createAgent({ @@ -40,7 +50,8 @@ export class ChatModelHandler implements IChatModelHandler { providerRegistry: this._providerRegistry, activeProvider, tokenUsage, - renderMimeRegistry: this._rmRegistry + renderMimeRegistry: this._rmRegistry, + toolsEnabled }); // Create AI chat model @@ -51,7 +62,10 @@ export class ChatModelHandler implements IChatModelHandler { activeCellManager: this._activeCellManager, documentManager: this._docManager, contentsManager: this._contentsManager, - providerRegistry: this._providerRegistry + providerRegistry: this._providerRegistry, + contextMessages, + restore, + enableCodeToolbar }); messages?.forEach(message => { diff --git a/src/chat-model.ts b/src/chat-model.ts index 73a0ea74..75fd8a81 100644 --- a/src/chat-model.ts +++ b/src/chat-model.ts @@ -109,7 +109,7 @@ export class AIChatModel extends AbstractChatModel implements IAIChatModel { activeCellManager: options.activeCellManager, documentManager: options.documentManager, config: { - enableCodeToolbar: true, + enableCodeToolbar: options.enableCodeToolbar ?? true, sendWithShiftEnter: options.settingsModel.config.sendWithShiftEnter } }); @@ -118,6 +118,12 @@ export class AIChatModel extends AbstractChatModel implements IAIChatModel { this._agentManager = options.agentManager; this._contentsManager = options.contentsManager; this._providerRegistry = options.providerRegistry; + this._contextMessages = (options.contextMessages ?? []).map(message => ({ + ...message.content, + attachments: message.attachments ? [...message.attachments] : undefined + })); + this._restore = options.restore ?? true; + this._enableCodeToolbar = options.enableCodeToolbar ?? true; // Listen for agent events this._agentManager.agentEvent.connect(this._onAgentEvent, this); @@ -144,7 +150,7 @@ export class AIChatModel extends AbstractChatModel implements IAIChatModel { set name(value: string) { super.name = value; this._nameChanged.emit(value); - if (!this.messages.length) { + if (this._restore && !this.messages.length) { const directory = this._settingsModel.config.chatBackupDirectory; const filepath = PathExt.join(directory, `${this.name}.chat`); this.restore(filepath, true); @@ -301,6 +307,7 @@ export class AIChatModel extends AbstractChatModel implements IAIChatModel { this.title = null; this._toolContexts.clear(); await this._agentManager.clearHistory(); + await this.rebuildHistory(); }; /** @@ -648,7 +655,7 @@ export class AIChatModel extends AbstractChatModel implements IAIChatModel { }); await this.clearMessages(); this.messagesInserted(0, messages); - await this._rebuildHistory(); + await this.rebuildHistory(); this.autosave = content.metadata?.autosave ?? false; this.title = content.metadata?.title ?? null; return true; @@ -758,7 +765,10 @@ export class AIChatModel extends AbstractChatModel implements IAIChatModel { */ private _onSettingsChanged(): void { const config = this._settingsModel.config; - this.config = { ...config, enableCodeToolbar: true }; + this.config = { + ...config, + enableCodeToolbar: this._enableCodeToolbar + }; // Agent manager handles agent recreation automatically via its own settings listener } @@ -774,18 +784,18 @@ export class AIChatModel extends AbstractChatModel implements IAIChatModel { : undefined; if (modelKey && modelKey !== this._currentModelKey) { this._currentModelKey = modelKey; - this._rebuildHistory().catch(e => + this.rebuildHistory().catch(e => console.warn('Failed to rebuild history on model change:', e) ); } } /** - * Rebuilds the agent history from the current messages. + * Rebuilds the agent history from the hidden context and current messages. * For vision-capable models, re-reads binary attachments from disk. * For text-only models, uses message text only. */ - private async _rebuildHistory(): Promise { + async rebuildHistory(): Promise { const providerConfig = this._settingsModel.getProvider( this._agentManager.activeProvider ); @@ -803,7 +813,7 @@ export class AIChatModel extends AbstractChatModel implements IAIChatModel { ); const modelMessages: ModelMessage[] = []; - for (const msg of this.messages) { + for (const msg of [...this._contextMessages, ...this.messages]) { const isAI = msg.sender.username === 'ai-assistant'; if (!isAI && msg.attachments?.length) { const enhancedContent = await Private.processAttachments( @@ -1244,6 +1254,9 @@ export class AIChatModel extends AbstractChatModel implements IAIChatModel { // Private fields private _settingsModel: IAISettingsModel; + private _contextMessages: IMessageContent[]; + private _restore: boolean; + private _enableCodeToolbar: boolean; private _user: IUser; private _toolContexts: Map = new Map(); private _agentManager: IAgentManager; @@ -1842,10 +1855,18 @@ export namespace AIChatModel { * Optional provider registry for model capability lookups. */ providerRegistry?: IProviderRegistry; + /** + * Messages provided to the agent as hidden conversation context. + */ + contextMessages?: IMessage[]; /** * Whether to restore or not the message (default to true) */ restore?: boolean; + /** + * Whether code blocks can modify the active notebook cell. + */ + enableCodeToolbar?: boolean; } /** diff --git a/src/index.ts b/src/index.ts index 48c1f65f..1007f30a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,7 +51,9 @@ import { } from '@jupyterlab/translation'; import { + closeIcon, fileUploadIcon, + PanelWithToolbar, saveIcon, settingsIcon, Toolbar, @@ -137,6 +139,9 @@ import { AISettingsWidget } from './widgets/ai-settings'; import { MainAreaChat } from './widgets/main-area-chat'; +const SIDE_CHAT_ID = '@jupyterlite/ai:side-chat'; +const SIDE_CHAT_MODEL_NAME = '@jupyterlite/ai:side-chat-model'; + namespace Private { let aiSecretsToken: symbol | null = null; @@ -552,6 +557,20 @@ const plugin: JupyterFrontEndPlugin = { // Update the tracker if the active provider changed. model.agentManager.activeProviderChanged.connect(saveTracker); + chatPanel.current?.toolbar.insertBefore( + 'close', + 'sideChat', + new ToolbarButton({ + icon: chatIcon, + onClick: () => { + void app.commands.execute(CommandIds.openSideChat, { + name: model.name + }); + }, + tooltip: trans.__('Open side chat') + }) + ); + // Update the token usage widget. usageWidget?.dispose(); @@ -887,6 +906,144 @@ function registerCommands( return true; }; + const createSideChatInputToolbar = () => { + const registry = InputToolbarRegistry.defaultToolbarRegistry(); + registry.addItem('stop', stopItem(trans)); + registry.addItem('clear', clearItem(trans)); + registry.addItem('model', createModelSelectItem(settingsModel, trans)); + return registry; + }; + + let sideChatWidget: PanelWithToolbar | null = null; + let sideChatContent: ChatWidget | null = null; + let sideChatSource: IAIChatModel | null = null; + + commands.addCommand(CommandIds.openSideChat, { + label: trans.__('Open side chat'), + caption: trans.__('Open a side chat for this conversation'), + icon: chatIcon, + execute: async (args): Promise => { + const sourceName = + typeof args.name === 'string' ? args.name : undefined; + const sourceWidget = findChatWidget(sourceName); + const sourceModel = sourceWidget?.model as IAIChatModel | undefined; + if (!sourceModel) { + return false; + } + + if (sideChatWidget && sideChatSource === sourceModel) { + app.shell.activateById(sideChatWidget.id); + sideChatContent?.model.input.focus(); + return true; + } + + sideChatWidget?.dispose(); + + const activeProvider = sourceModel.agentManager.activeProvider; + if (!activeProvider) { + showErrorMessage( + trans.__('Error creating side chat'), + trans.__('Please set up a provider') + ); + if (commands.hasCommand(CommandIds.openSettings)) { + void commands.execute(CommandIds.openSettings); + } + return false; + } + + const model = modelRegistry.createModel({ + name: SIDE_CHAT_MODEL_NAME, + activeProvider, + contextMessages: sourceModel.messages, + restore: false, + toolsEnabled: false, + enableCodeToolbar: false + }); + await model.rebuildHistory(); + + const content = new ChatWidget({ + model, + rmRegistry, + themeManager: themeManager ?? null, + inputToolbarRegistry: createSideChatInputToolbar(), + attachmentOpenerRegistry, + chatCommandRegistry, + area: 'sidebar' + }); + const widget = new PanelWithToolbar(); + widget.id = SIDE_CHAT_ID; + widget.addClass('jp-ai-side-chat'); + widget.title.icon = chatIcon; + widget.title.label = trans.__('Side Chat'); + widget.title.caption = trans.__( + 'Side chat for %1', + sourceModel.title ?? sourceModel.name + ); + widget.title.closable = true; + widget.toolbar.addItem('spacer', Toolbar.createSpacerItem()); + widget.toolbar.addItem( + 'close', + new ToolbarButton({ + icon: closeIcon, + onClick: () => { + widget.dispose(); + }, + tooltip: trans.__('Close side chat') + }) + ); + widget.addWidget(widget.toolbar); + widget.addWidget(content); + + const writersChanged = ( + _: IChatModel, + writers: IChatModel.IWriter[] + ) => { + const aiWriting = writers.some( + writer => writer.user.username === 'ai-assistant' + ); + if (aiWriting) { + content.inputToolbarRegistry?.show('stop'); + } else { + content.inputToolbarRegistry?.hide('stop'); + } + }; + model.writersChanged?.connect(writersChanged); + + const outputAreaCompat = new RenderedMessageOutputAreaCompat({ + chatPanel: content + }); + widget.disposed.connect(() => { + model.writersChanged?.disconnect(writersChanged); + outputAreaCompat.dispose(); + model.dispose(); + if (sideChatWidget === widget) { + sideChatWidget = null; + sideChatContent = null; + sideChatSource = null; + } + }); + + sideChatWidget = widget; + sideChatContent = content; + sideChatSource = sourceModel; + app.shell.add(widget, 'right', { rank: 1000 }); + app.shell.activateById(widget.id); + model.input.focus(); + return true; + }, + describedBy: { + args: { + type: 'object', + properties: { + name: { + type: 'string', + description: trans.__('The source chat name') + } + } + } + } + }); + commands.addCommand(CommandIds.openChat, { label: trans.__('Open a chat'), execute: async (args): Promise => { diff --git a/src/tokens.ts b/src/tokens.ts index 2e2ffe98..e22d99d3 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -30,6 +30,7 @@ export namespace CommandIds { export const reposition = '@jupyterlite/ai:reposition'; export const openChat = '@jupyterlite/ai:open-chat'; export const openOrRevealChat = '@jupyterlite/ai:open-or-reveal-chat'; + export const openSideChat = '@jupyterlite/ai:open-side-chat'; export const moveChat = '@jupyterlite/ai:move-chat'; export const refreshSkills = '@jupyterlite/ai:refresh-skills'; export const saveChat = '@jupyterlite/ai:save-chat'; @@ -497,6 +498,11 @@ export namespace IAgentManager { * JupyterLab render mime registry for discovering supported MIME types. */ renderMimeRegistry?: IRenderMimeRegistry; + + /** + * Whether tools are enabled for this agent. + */ + toolsEnabled?: boolean; } /** @@ -722,6 +728,10 @@ export interface IAIChatModel extends IChatModel { * restoration is not possible. */ restore(filepath: string, silent?: boolean): Promise; + /** + * Rebuild the agent history from the chat and its optional context. + */ + rebuildHistory(): Promise; /** * Request a title to this chat, regarding the message history. */ @@ -783,6 +793,10 @@ export interface ICreateChatOptions { * The messages to ad by default. */ messages?: IMessage[]; + /** + * Messages provided to the agent as hidden conversation context. + */ + contextMessages?: IMessage[]; /** * Whether the chat is autosaved or not. */ @@ -791,6 +805,18 @@ export interface ICreateChatOptions { * An optional title to the chat. */ title?: string | null; + /** + * Whether to restore a saved chat with the same name. + */ + restore?: boolean; + /** + * Whether tools are enabled for the chat. + */ + toolsEnabled?: boolean; + /** + * Whether code blocks can modify the active notebook cell. + */ + enableCodeToolbar?: boolean; } /** * Token for the chat model handler. diff --git a/src/widgets/main-area-chat.ts b/src/widgets/main-area-chat.ts index 7287eb64..200125a2 100644 --- a/src/widgets/main-area-chat.ts +++ b/src/widgets/main-area-chat.ts @@ -1,6 +1,6 @@ -import { ChatWidget, IChatModel } from '@jupyter/chat'; +import { chatIcon, ChatWidget, IChatModel } from '@jupyter/chat'; import { CommandToolbarButton, MainAreaWidget } from '@jupyterlab/apputils'; -import { launchIcon } from '@jupyterlab/ui-components'; +import { launchIcon, ToolbarButton } from '@jupyterlab/ui-components'; import type { TranslationBundle } from '@jupyterlab/translation'; import { CommandRegistry } from '@lumino/commands'; @@ -28,6 +28,19 @@ export class MainAreaChat extends MainAreaWidget { const { trans } = options; + this.toolbar.addItem( + 'sideChat', + new ToolbarButton({ + icon: chatIcon, + onClick: () => { + void options.commands.execute(CommandIds.openSideChat, { + name: this.model.name + }); + }, + tooltip: trans.__('Open side chat') + }) + ); + // Move to side button. this.toolbar.addItem( 'moveToSide', diff --git a/style/base.css b/style/base.css index f19c35c5..68fd84f7 100644 --- a/style/base.css +++ b/style/base.css @@ -6,6 +6,12 @@ @import url('@jupyter/chat/style/index.css'); +.jp-ai-side-chat { + min-height: 0; + display: flex; + flex-direction: column; +} + .jp-chat-welcome-message { text-align: center; max-width: 350px; diff --git a/ui-tests/tests/chat-panel.spec.ts b/ui-tests/tests/chat-panel.spec.ts index 56cf96ef..a82299e6 100644 --- a/ui-tests/tests/chat-panel.spec.ts +++ b/ui-tests/tests/chat-panel.spec.ts @@ -5,15 +5,136 @@ import { expect, galata, test } from '@jupyterlab/galata'; import { + DEFAULT_GENERIC_PROVIDER_SETTINGS, QWEN_MODEL_NAME, CHAT_PANEL_ID, CHAT_PANEL_TITLE, + SIDE_CHAT_ID, TEST_PROVIDERS, openChatPanel } from './test-utils'; const NOT_CONFIGURED_TEXT = 'Please configure your AI settings first'; +test.describe('#sideChat', () => { + test.use({ + mockSettings: { + ...galata.DEFAULT_SETTINGS, + ...DEFAULT_GENERIC_PROVIDER_SETTINGS, + '@jupyterlab/apputils-extension:notification': { + checkForUpdates: false, + fetchNews: 'false', + doNotDisturbMode: true + } + } + }); + + test('should use only the source chat context and close', async ({ + page + }) => { + const panel = await openChatPanel(page); + const sourceMarker = 'source-chat-context'; + const unrelatedMarker = 'unrelated-chat-context'; + const sideQuestion = 'What did this chat discuss?'; + let requestBody: any = null; + + await page.route( + 'http://localhost:11434/v1/chat/completions', + async route => { + requestBody = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"Source context received."},"finish_reason":null}]}', + '', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}', + '', + 'data: [DONE]', + '' + ].join('\n') + }); + } + ); + + await page.evaluate( + async ({ chatPanelId, sourceMarker, unrelatedMarker }) => { + const app = window.jupyterapp; + const chatPanel = Array.from(app.shell.widgets('left')).find( + widget => widget.id === chatPanelId + ) as any; + const sourceModel = chatPanel.current.model; + const sourceName = sourceModel.name; + const now = Date.now() / 1000; + + sourceModel.messageAdded({ + body: sourceMarker, + sender: { username: 'user', display_name: 'User' }, + id: 'source-user-message', + time: now, + type: 'msg', + raw_time: false + }); + sourceModel.messageAdded({ + body: 'Source chat response', + sender: { + username: 'ai-assistant', + display_name: 'Jupyternaut' + }, + id: 'source-ai-message', + time: now + 1, + type: 'msg', + raw_time: false + }); + + await app.commands.execute('@jupyterlite/ai:open-chat', { + name: 'Unrelated Chat' + }); + chatPanel.current.model.messageAdded({ + body: unrelatedMarker, + sender: { username: 'user', display_name: 'User' }, + id: 'unrelated-user-message', + time: now + 2, + type: 'msg', + raw_time: false + }); + + await app.commands.execute('@jupyterlite/ai:open-or-reveal-chat', { + name: sourceName, + area: 'side' + }); + }, + { chatPanelId: CHAT_PANEL_ID, sourceMarker, unrelatedMarker } + ); + + const chatToolbar = panel.locator('.jp-chat-sidepanel-widget-toolbar'); + await chatToolbar.getByTitle('Open side chat').click(); + + const sideChat = page.locator(`[id="${SIDE_CHAT_ID}"]`); + await expect(sideChat).toBeVisible(); + await expect(panel).toBeVisible(); + await expect(sideChat.getByTitle('Select AI Tools')).toHaveCount(0); + await expect(sideChat.locator('.jp-chat-message')).toHaveCount(0); + + const input = sideChat + .locator('.jp-chat-input-container') + .getByRole('combobox'); + await input.fill(sideQuestion); + await input.press('Enter'); + + await expect.poll(() => requestBody).not.toBeNull(); + const request = JSON.stringify(requestBody); + expect(request).toContain(sourceMarker); + expect(request).toContain('Source chat response'); + expect(request).toContain(sideQuestion); + expect(request).not.toContain(unrelatedMarker); + + await expect(sideChat.locator('.jp-chat-message')).toHaveCount(2); + await sideChat.getByTitle('Close side chat').click(); + await expect(sideChat).not.toBeAttached(); + }); +}); + test.describe('#withoutModel', () => { test('should contain the chat panel icon', async ({ page }) => { const chatIcon = page.getByTitle(CHAT_PANEL_TITLE); @@ -28,8 +149,8 @@ test.describe('#withoutModel', () => { }); test('should not create a chat if there is no provider', async ({ page }) => { - const content = 'Hello'; const panel = await openChatPanel(page); + await expect(panel.getByTitle('Open side chat')).toHaveCount(0); await panel.getByTitle('Create a new chat').click(); // Should open an error dialog @@ -301,6 +422,7 @@ TEST_PROVIDERS.forEach(({ name, settings }) => if (!mainAreaPanel) { throw new Error('Expected the moved chat to be visible in main area'); } + await expect(mainAreaPanel.getByTitle('Open side chat')).toHaveCount(1); const mainInput = mainAreaPanel .locator('.jp-chat-input-container') .getByRole('combobox'); diff --git a/ui-tests/tests/test-utils.ts b/ui-tests/tests/test-utils.ts index 8d8f7ca4..063151cf 100644 --- a/ui-tests/tests/test-utils.ts +++ b/ui-tests/tests/test-utils.ts @@ -42,6 +42,8 @@ export const TEST_PROVIDERS = [ export const CHAT_PANEL_ID = '@jupyterlite/ai:chat-panel'; +export const SIDE_CHAT_ID = '@jupyterlite/ai:side-chat'; + export const CHAT_PANEL_TITLE = 'Chat with AI assistant'; export async function openChatPanel(