Skip to content

Commit 74125c4

Browse files
committed
WIP: migrate chat command frontend plugins to this repo
1 parent e7c59bb commit 74125c4

File tree

9 files changed

+943
-35
lines changed

9 files changed

+943
-35
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ This extension is composed of a Python package named `jupyter_ai_chat_commands`
88
for the server extension and a NPM package named `@jupyter-ai/chat-commands`
99
for the frontend extension.
1010

11+
This package provides 2 commands:
12+
13+
- `@file:<path>`: Add a file as an attachment to a message.
14+
15+
- `/refresh-personas`: Reload local personas defined in `.jupyter/personas`.
16+
1117
## QUICK START
1218

1319
Everything that follows after this section was from the extension template. We

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,11 @@
5858
"watch:labextension": "jupyter labextension watch ."
5959
},
6060
"dependencies": {
61+
"@jupyter/chat": "^0.17.0",
6162
"@jupyterlab/application": "^4.0.0",
6263
"@jupyterlab/coreutils": "^6.0.0",
63-
"@jupyterlab/services": "^7.0.0"
64+
"@jupyterlab/services": "^7.0.0",
65+
"@mui/icons-material": "^5.11.0"
6466
},
6567
"devDependencies": {
6668
"@jupyterlab/builder": "^4.0.0",

pyproject.toml

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
[build-system]
2-
requires = ["hatchling>=1.5.0", "jupyterlab>=4.0.0,<5", "hatch-nodejs-version>=0.3.2"]
2+
requires = [
3+
"hatchling>=1.5.0",
4+
"jupyterlab>=4.0.0,<5",
5+
"hatch-nodejs-version>=0.3.2",
6+
]
37
build-backend = "hatchling.build"
48

59
[project]
@@ -22,9 +26,7 @@ classifiers = [
2226
"Programming Language :: Python :: 3.12",
2327
"Programming Language :: Python :: 3.13",
2428
]
25-
dependencies = [
26-
"jupyter_server>=2.4.0,<3"
27-
]
29+
dependencies = ["jupyter_server>=2.4.0,<3", "jupyterlab_chat>=0.17.0,<0.18.0"]
2830
dynamic = ["version", "description", "authors", "urls", "keywords"]
2931

3032
[project.optional-dependencies]
@@ -33,7 +35,7 @@ test = [
3335
"pytest",
3436
"pytest-asyncio",
3537
"pytest-cov",
36-
"pytest-jupyter[server]>=0.6.0"
38+
"pytest-jupyter[server]>=0.6.0",
3739
]
3840

3941
[tool.hatch.version]
@@ -80,7 +82,7 @@ version_cmd = "hatch version"
8082
before-build-npm = [
8183
"python -m pip install 'jupyterlab>=4.0.0,<5'",
8284
"jlpm",
83-
"jlpm build:prod"
85+
"jlpm build:prod",
8486
]
8587
before-build-python = ["jlpm clean:all"]
8688

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/*
2+
* Copyright (c) Jupyter Development Team.
3+
* Distributed under the terms of the Modified BSD License.
4+
*/
5+
6+
import React from 'react';
7+
import { JupyterFrontEndPlugin } from '@jupyterlab/application';
8+
import type { Contents } from '@jupyterlab/services';
9+
import type { DocumentRegistry } from '@jupyterlab/docregistry';
10+
import {
11+
IChatCommandProvider,
12+
IChatCommandRegistry,
13+
IInputModel,
14+
ChatCommand
15+
} from '@jupyter/chat';
16+
import FindInPage from '@mui/icons-material/FindInPage';
17+
18+
const FILE_COMMAND_PROVIDER_ID = '@jupyter-ai/core:file-command-provider';
19+
20+
/**
21+
* A command provider that provides completions for `@file` commands and handles
22+
* `@file` command calls.
23+
*/
24+
export class FileCommandProvider implements IChatCommandProvider {
25+
public id: string = FILE_COMMAND_PROVIDER_ID;
26+
27+
/**
28+
* Regex that matches all potential `@file` commands. The first capturing
29+
* group captures the path specified by the user. Paths may contain any
30+
* combination of:
31+
*
32+
* `[a-zA-Z0-9], '/', '-', '_', '.', '@', '\\ ' (escaped space)`
33+
*
34+
* IMPORTANT: `+` ensures this regex only matches an occurrence of "@file:" if
35+
* the captured path is non-empty.
36+
*/
37+
_regex: RegExp = /@file:(([\w/\-_.@]|\\ )+)/g;
38+
39+
constructor(
40+
contentsManager: Contents.IManager,
41+
docRegistry: DocumentRegistry
42+
) {
43+
this._contentsManager = contentsManager;
44+
this._docRegistry = docRegistry;
45+
}
46+
47+
async listCommandCompletions(
48+
inputModel: IInputModel
49+
): Promise<ChatCommand[]> {
50+
// do nothing if the current word does not start with '@'.
51+
const currentWord = inputModel.currentWord;
52+
if (!currentWord || !currentWord.startsWith('@')) {
53+
return [];
54+
}
55+
56+
// if the current word starts with `@file:`, return a list of valid file
57+
// paths that complete the currently specified path.
58+
if (currentWord.startsWith('@file:')) {
59+
const searchPath = currentWord.split('@file:')[1];
60+
const commands = await getPathCompletions(
61+
this._contentsManager,
62+
this._docRegistry,
63+
searchPath
64+
);
65+
return commands;
66+
}
67+
68+
// if the current word matches the start of @file, complete it
69+
if ('@file'.startsWith(currentWord)) {
70+
return [
71+
{
72+
name: '@file:',
73+
providerId: this.id,
74+
description: 'Include a file with your prompt',
75+
icon: <FindInPage />
76+
}
77+
];
78+
}
79+
80+
// otherwise, return nothing as this provider cannot provide any completions
81+
// for the current word.
82+
return [];
83+
}
84+
85+
async onSubmit(inputModel: IInputModel): Promise<void> {
86+
// search entire input for valid @file commands using `this._regex`
87+
const matches = Array.from(inputModel.value.matchAll(this._regex));
88+
89+
// aggregate all file paths specified by @file commands in the input
90+
const paths: string[] = [];
91+
for (const match of matches) {
92+
if (match.length < 2) {
93+
continue;
94+
}
95+
// `this._regex` contains exactly 1 group that captures the path, so
96+
// match[1] will contain the path specified by a @file command.
97+
paths.push(match[1]);
98+
}
99+
100+
// add each specified file path as an attachment, unescaping ' ' characters
101+
// before doing so
102+
for (let path of paths) {
103+
path = path.replaceAll('\\ ', ' ');
104+
inputModel.addAttachment?.({
105+
type: 'file',
106+
value: path
107+
});
108+
}
109+
110+
// replace each @file command with the path in an inline Markdown code block
111+
// for readability, both to humans & to the AI.
112+
inputModel.value = inputModel.value.replaceAll(
113+
this._regex,
114+
(_, path) => `\`${path.replaceAll('\\ ', ' ')}\``
115+
);
116+
117+
return;
118+
}
119+
120+
private _contentsManager: Contents.IManager;
121+
private _docRegistry: DocumentRegistry;
122+
}
123+
124+
/**
125+
* Returns the parent path and base name given a path. The parent path will
126+
* always include a trailing "/" if non-empty.
127+
*
128+
* Examples:
129+
* - "package.json" => ["", "package.json"]
130+
* - "foo/bar" => ["foo/", "bar"]
131+
* - "a/b/c/d.txt" => ["a/b/c/", "d.txt"]
132+
*
133+
*/
134+
function getParentAndBase(path: string): [string, string] {
135+
const components = path.split('/');
136+
let parentPath: string;
137+
let basename: string;
138+
if (components.length === 1) {
139+
parentPath = '';
140+
basename = components[0];
141+
} else {
142+
parentPath = components.slice(0, -1).join('/') + '/';
143+
basename = components[components.length - 1] ?? '';
144+
}
145+
146+
return [parentPath, basename];
147+
}
148+
149+
async function getPathCompletions(
150+
contentsManager: Contents.IManager,
151+
docRegistry: DocumentRegistry,
152+
searchPath: string
153+
): Promise<ChatCommand[]> {
154+
// get parent directory & the partial basename to be completed
155+
const [parentPath, basename] = getParentAndBase(searchPath);
156+
157+
// query the parent directory through the CM, un-escaping spaces beforehand
158+
const parentDir = await contentsManager.get(
159+
parentPath.replaceAll('\\ ', ' ')
160+
);
161+
162+
const commands: ChatCommand[] = [];
163+
164+
if (!Array.isArray(parentDir.content)) {
165+
// return nothing if parentDir is invalid / points to a non-directory file
166+
return [];
167+
}
168+
169+
const children = parentDir.content
170+
// filter the children of the parent directory to only include file names that
171+
// start with the specified base name (case-insensitive).
172+
.filter((a: Contents.IModel) => {
173+
return a.name.toLowerCase().startsWith(basename.toLowerCase());
174+
})
175+
// sort the list, showing directories first while ensuring entries are shown
176+
// in alphabetic (lexicographically ascending) order.
177+
.sort((a: Contents.IModel, b: Contents.IModel) => {
178+
const aPrimaryKey = a.type === 'directory' ? -1 : 1;
179+
const bPrimaryKey = b.type === 'directory' ? -1 : 1;
180+
const primaryKey = aPrimaryKey - bPrimaryKey;
181+
const secondaryKey = a.name < b.name ? -1 : 1;
182+
183+
return primaryKey || secondaryKey;
184+
});
185+
186+
for (const child of children) {
187+
// get icon
188+
const { icon } = docRegistry.getFileTypeForModel(child);
189+
190+
// calculate completion string, escaping any unescaped spaces
191+
let completion = '@file:' + parentPath + child.name;
192+
completion = completion.replaceAll(/(?<!\\) /g, '\\ ');
193+
194+
// add command completion to the list
195+
let newCommand: ChatCommand;
196+
const isDirectory = child.type === 'directory';
197+
if (isDirectory) {
198+
newCommand = {
199+
name: child.name + '/',
200+
providerId: FILE_COMMAND_PROVIDER_ID,
201+
icon,
202+
description: 'Search this directory',
203+
replaceWith: completion + '/'
204+
};
205+
} else {
206+
newCommand = {
207+
name: child.name,
208+
providerId: FILE_COMMAND_PROVIDER_ID,
209+
icon,
210+
description: 'Attach this file',
211+
replaceWith: completion,
212+
spaceOnAccept: true
213+
};
214+
}
215+
commands.push(newCommand);
216+
}
217+
218+
return commands;
219+
}
220+
221+
export const fileCommandPlugin: JupyterFrontEndPlugin<void> = {
222+
id: '@jupyter-ai/core:file-command-plugin',
223+
description: 'Adds support for the @file command in Jupyter AI.',
224+
autoStart: true,
225+
requires: [IChatCommandRegistry],
226+
activate: (app, registry: IChatCommandRegistry) => {
227+
const { serviceManager, docRegistry } = app;
228+
registry.addProvider(
229+
new FileCommandProvider(serviceManager.contents, docRegistry)
230+
);
231+
}
232+
};

src/chat-command-plugins/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { fileCommandPlugin } from './file-command';
2+
import { slashCommandPlugin } from './slash-commands';
3+
4+
export const chatCommandPlugins = [fileCommandPlugin, slashCommandPlugin];

0 commit comments

Comments
 (0)