jupyter-chat-components 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyter-chat-components",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "description": "Components to displayed in jupyter chat",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -57,11 +57,13 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@jupyterlab/application": "^4.5.0",
60
+ "@jupyterlab/coreutils": "^6.5.4",
60
61
  "@jupyterlab/rendermime": "^4.5.0",
61
62
  "@jupyterlab/rendermime-interfaces": "^3.8.0",
62
63
  "@jupyterlab/translation": "^4.5.0",
63
64
  "@lumino/coreutils": "^2.2.2",
64
- "@lumino/widgets": "^2.1.0"
65
+ "@lumino/widgets": "^2.1.0",
66
+ "diff": "^8.0.0"
65
67
  },
66
68
  "devDependencies": {
67
69
  "@jupyterlab/builder": "^4.0.0",
@@ -0,0 +1,3 @@
1
+ export * from './inline-diff';
2
+ export * from './message-queue';
3
+ export * from './tool-call';
@@ -0,0 +1,241 @@
1
+ import { PathExt } from '@jupyterlab/coreutils';
2
+ import { nullTranslator, TranslationBundle } from '@jupyterlab/translation';
3
+
4
+ import * as React from 'react';
5
+ import { structuredPatch } from 'diff';
6
+ import type { StructuredPatchHunk } from 'diff';
7
+
8
+ import {
9
+ IInlineDiff,
10
+ IInlineDiffMetadata,
11
+ IInlineDiffNotebookCellTarget
12
+ } from '../token';
13
+
14
+ /** Maximum number of rendered lines before truncation. */
15
+ const MAX_DIFF_LINES = 20;
16
+
17
+ interface IDiffLineInfo {
18
+ cssClass: string;
19
+ prefix: string;
20
+ text: string;
21
+ key: string;
22
+ }
23
+
24
+ export interface IInlineDiffProps extends IInlineDiffMetadata {
25
+ trans?: TranslationBundle;
26
+ }
27
+
28
+ export function getDiffFilename(path: string): string {
29
+ return PathExt.basename(path);
30
+ }
31
+
32
+ function getNotebookCellLabel(target: IInlineDiffNotebookCellTarget): string {
33
+ if (typeof target.cellIndex === 'number') {
34
+ return `Cell ${target.cellIndex + 1}`;
35
+ }
36
+
37
+ if (target.cellId) {
38
+ return `Cell ${target.cellId}`;
39
+ }
40
+
41
+ return 'Notebook Cell';
42
+ }
43
+
44
+ function getInlineDiffPatchPath(diff: IInlineDiff): string {
45
+ const target = diff.target;
46
+
47
+ if (target.kind === 'file') {
48
+ return target.path;
49
+ }
50
+
51
+ return [target.notebookPath, target.cellId ?? target.cellIndex]
52
+ .filter(value => value !== undefined && value !== '')
53
+ .join('#');
54
+ }
55
+
56
+ export function getInlineDiffLabel(diff: IInlineDiff): string {
57
+ if (diff.label) {
58
+ return diff.label;
59
+ }
60
+
61
+ const target = diff.target;
62
+
63
+ if (target.kind === 'file') {
64
+ return getDiffFilename(target.path);
65
+ }
66
+
67
+ const notebookName = getDiffFilename(target.notebookPath);
68
+ const cellLabel = getNotebookCellLabel(target);
69
+
70
+ return [notebookName, cellLabel].join(' · ');
71
+ }
72
+
73
+ export function getInlineDiffTitle(diff: IInlineDiff): string {
74
+ const target = diff.target;
75
+
76
+ if (target.kind === 'file') {
77
+ return target.path;
78
+ }
79
+
80
+ const cellLabel = getNotebookCellLabel(target);
81
+ const cellIdLabel =
82
+ typeof target.cellIndex === 'number' && target.cellId
83
+ ? `Cell ID ${target.cellId}`
84
+ : null;
85
+
86
+ return [target.notebookPath, cellLabel, cellIdLabel]
87
+ .filter(part => part !== null && part !== undefined && part !== '')
88
+ .join(' · ');
89
+ }
90
+
91
+ function toLineInfo(
92
+ type: 'added' | 'removed' | 'context',
93
+ text: string,
94
+ key: string
95
+ ): IDiffLineInfo {
96
+ switch (type) {
97
+ case 'added':
98
+ return {
99
+ cssClass: 'jp-mod-added',
100
+ prefix: '+',
101
+ text,
102
+ key
103
+ };
104
+ case 'removed':
105
+ return {
106
+ cssClass: 'jp-mod-removed',
107
+ prefix: '-',
108
+ text,
109
+ key
110
+ };
111
+ case 'context':
112
+ return {
113
+ cssClass: 'jp-mod-context',
114
+ prefix: ' ',
115
+ text,
116
+ key
117
+ };
118
+ }
119
+ }
120
+
121
+ function buildDiffLinesFromHunk(
122
+ hunk: StructuredPatchHunk,
123
+ hunkIndex: number
124
+ ): IDiffLineInfo[] {
125
+ return hunk.lines
126
+ .filter(line => !line.startsWith('\\'))
127
+ .map((line, lineIndex) => {
128
+ const prefix = line[0] ?? ' ';
129
+ const text = line.slice(1);
130
+ const key = `${hunkIndex}-${hunk.oldStart}-${hunk.newStart}-${lineIndex}`;
131
+
132
+ if (prefix === '+') {
133
+ return toLineInfo('added', text, key);
134
+ }
135
+
136
+ if (prefix === '-') {
137
+ return toLineInfo('removed', text, key);
138
+ }
139
+
140
+ return toLineInfo('context', text, key);
141
+ });
142
+ }
143
+
144
+ function buildDiffLines(diff: IInlineDiff): IDiffLineInfo[] {
145
+ const patchPath = getInlineDiffPatchPath(diff);
146
+ const patch = structuredPatch(
147
+ patchPath,
148
+ patchPath,
149
+ diff.oldText ?? '',
150
+ diff.newText,
151
+ undefined,
152
+ undefined,
153
+ { context: Infinity }
154
+ );
155
+
156
+ return patch.hunks.reduce<IDiffLineInfo[]>((lines, hunk, index) => {
157
+ lines.push(...buildDiffLinesFromHunk(hunk, index));
158
+ return lines;
159
+ }, []);
160
+ }
161
+
162
+ function DiffBlock({
163
+ diff,
164
+ trans
165
+ }: {
166
+ diff: IInlineDiff;
167
+ trans: TranslationBundle;
168
+ }): JSX.Element {
169
+ const filename = getInlineDiffLabel(diff);
170
+ const title = getInlineDiffTitle(diff);
171
+ const [expanded, setExpanded] = React.useState(false);
172
+ const allLines = React.useMemo(() => buildDiffLines(diff), [diff]);
173
+ const canTruncate = allLines.length > MAX_DIFF_LINES;
174
+ const visibleLines =
175
+ canTruncate && !expanded ? allLines.slice(0, MAX_DIFF_LINES) : allLines;
176
+ const hiddenCount = allLines.length - MAX_DIFF_LINES;
177
+
178
+ return (
179
+ <div className="jp-ai-inline-diff-block">
180
+ <div className="jp-ai-inline-diff-header" title={title}>
181
+ {filename}
182
+ </div>
183
+ <div className="jp-ai-inline-diff-content">
184
+ {visibleLines.length ? (
185
+ visibleLines.map(line => (
186
+ <div
187
+ key={line.key}
188
+ className={`jp-ai-inline-diff-line ${line.cssClass}`}
189
+ >
190
+ <span className="jp-ai-inline-diff-line-prefix">
191
+ {line.prefix}
192
+ </span>
193
+ <span className="jp-ai-inline-diff-line-text">{line.text}</span>
194
+ </div>
195
+ ))
196
+ ) : (
197
+ <div className="jp-ai-inline-diff-empty">
198
+ {trans.__('No changes')}
199
+ </div>
200
+ )}
201
+ {canTruncate && !expanded && (
202
+ <button
203
+ className="jp-ai-inline-diff-toggle"
204
+ onClick={() => setExpanded(true)}
205
+ type="button"
206
+ >
207
+ {trans.__('... %1 more lines', hiddenCount)}
208
+ </button>
209
+ )}
210
+ {canTruncate && expanded && (
211
+ <button
212
+ className="jp-ai-inline-diff-toggle"
213
+ onClick={() => setExpanded(false)}
214
+ type="button"
215
+ >
216
+ {trans.__('Show less')}
217
+ </button>
218
+ )}
219
+ </div>
220
+ </div>
221
+ );
222
+ }
223
+
224
+ /**
225
+ * React component for rendering one or more inline diffs.
226
+ */
227
+ export const InlineDiff: React.FC<IInlineDiffProps> = ({ diffs, trans }) => {
228
+ const transBundle = trans ?? nullTranslator.load('jupyterlab');
229
+
230
+ return (
231
+ <div className="jp-ai-inline-diff-container">
232
+ {diffs.map((diff, index) => (
233
+ <DiffBlock
234
+ key={`${getInlineDiffPatchPath(diff)}-${index}`}
235
+ diff={diff}
236
+ trans={transBundle}
237
+ />
238
+ ))}
239
+ </div>
240
+ );
241
+ };
@@ -0,0 +1,50 @@
1
+ import * as React from 'react';
2
+
3
+ import {
4
+ IComponentProps,
5
+ IMessageQueueMetadata,
6
+ RemoveQueuedMessage
7
+ } from '../token';
8
+
9
+ /**
10
+ * Props for the MessageQueue component.
11
+ */
12
+ export interface IMessageQueueProps
13
+ extends IComponentProps, IMessageQueueMetadata {
14
+ removeQueuedMessage?: RemoveQueuedMessage;
15
+ }
16
+
17
+ /**
18
+ * React component that displays a list of queued messages by
19
+ * showing each pending message as a bubble in the chat
20
+ */
21
+ export const MessageQueue: React.FC<IMessageQueueProps> = ({
22
+ messages,
23
+ targetId,
24
+ trans,
25
+ removeQueuedMessage
26
+ }) => {
27
+ if (!messages || messages.length === 0) {
28
+ return null;
29
+ }
30
+
31
+ return (
32
+ <div className="jp-chat-message-queue">
33
+ {messages.map(msg => (
34
+ <div key={msg.id} className="jp-chat-message-queue-bubble">
35
+ <span className="jp-chat-message-queue-text">{msg.body}</span>
36
+ {removeQueuedMessage && targetId && (
37
+ <button
38
+ className="jp-chat-message-queue-remove"
39
+ onClick={() => removeQueuedMessage(targetId, msg.id)}
40
+ title={trans.__('Remove from queue')}
41
+ type="button"
42
+ >
43
+
44
+ </button>
45
+ )}
46
+ </div>
47
+ ))}
48
+ </div>
49
+ );
50
+ };
@@ -2,7 +2,7 @@ import { TranslationBundle } from '@jupyterlab/translation';
2
2
 
3
3
  import * as React from 'react';
4
4
 
5
- import { IToolCallMetadata, ToolCallApproval, ToolCallStatus } from '../token';
5
+ import { IComponentProps, ToolCallApproval } from '../token';
6
6
 
7
7
  /**
8
8
  * Configuration for rendering tool call status.
@@ -13,6 +13,17 @@ interface IStatusConfig {
13
13
  open?: boolean;
14
14
  }
15
15
 
16
+ /**
17
+ * Tool call status types.
18
+ */
19
+ export type ToolCallStatus =
20
+ | 'pending'
21
+ | 'awaiting_approval'
22
+ | 'approved'
23
+ | 'rejected'
24
+ | 'completed'
25
+ | 'error';
26
+
16
27
  const STATUS_CONFIG: Record<ToolCallStatus, IStatusConfig> = {
17
28
  pending: {
18
29
  cssClass: 'jp-ai-tool-pending',
@@ -44,9 +55,21 @@ const STATUS_CONFIG: Record<ToolCallStatus, IStatusConfig> = {
44
55
  /**
45
56
  * Options for building tool call HTML.
46
57
  */
47
- export interface IToolCallHtmlOptions extends IToolCallMetadata {
48
- trans: TranslationBundle;
49
- toolCallApproval: ToolCallApproval;
58
+ export interface IToolCallMetadata {
59
+ toolName: string;
60
+ input: string;
61
+ status: ToolCallStatus;
62
+ summary?: string;
63
+ output?: string;
64
+ targetId?: string;
65
+ approvalId?: string;
66
+ }
67
+
68
+ /**
69
+ * Options for building tool call HTML.
70
+ */
71
+ export interface IToolCallProps extends IComponentProps, IToolCallMetadata {
72
+ toolCallApproval?: ToolCallApproval;
50
73
  }
51
74
 
52
75
  export function escapeHtml(value: string): string {
@@ -90,11 +113,6 @@ const getStatusText = (
90
113
  }
91
114
  };
92
115
 
93
- /**
94
- * React component props for ToolCall.
95
- */
96
- export interface IToolCallProps extends IToolCallHtmlOptions {}
97
-
98
116
  /**
99
117
  * React functional component for displaying a tool call.
100
118
  *
@@ -117,6 +135,12 @@ export const ToolCall: React.FC<IToolCallProps> = ({
117
135
  const resultLabel =
118
136
  status === 'error' ? trans.__('Error') : trans.__('Result');
119
137
 
138
+ if (status === 'awaiting_approval' && !toolCallApproval) {
139
+ console.error(
140
+ 'The tool call has no approval function, approval, it will not work as expected'
141
+ );
142
+ }
143
+
120
144
  return (
121
145
  <details
122
146
  className={`jp-ai-tool-call ${config.cssClass}`}
package/src/factory.tsx CHANGED
@@ -4,17 +4,21 @@ import { nullTranslator, TranslationBundle } from '@jupyterlab/translation';
4
4
 
5
5
  import { ReactWidget } from '@jupyterlab/ui-components';
6
6
 
7
+ import { ReadonlyPartialJSONValue } from '@lumino/coreutils';
8
+
7
9
  import * as React from 'react';
8
10
 
11
+ import { InlineDiff, MessageQueue, ToolCall } from './components';
12
+
13
+ import { ComponentRegistry } from './registry';
14
+
9
15
  import {
16
+ IComponentRegistry,
10
17
  IComponentsRendererFactory,
11
- IToolCallMetadata,
18
+ RemoveQueuedMessage,
12
19
  ToolCallApproval
13
20
  } from './token';
14
21
 
15
- import { IToolCallHtmlOptions, ToolCall } from './components/tool-call';
16
- import { ReadonlyPartialJSONValue } from '@lumino/coreutils';
17
-
18
22
  /**
19
23
  * The default mime type for the extension.
20
24
  */
@@ -36,11 +40,21 @@ interface IComponentsRendererOptions extends IRenderMime.IRendererOptions {
36
40
  /**
37
41
  * The callback to approve or reject a tool.
38
42
  */
39
- toolCallApproval: ToolCallApproval;
43
+ toolCallApproval?: ToolCallApproval;
44
+
45
+ /**
46
+ * The callback to remove a queued message.
47
+ */
48
+ removeQueuedMessage?: RemoveQueuedMessage;
49
+
50
+ /**
51
+ * The component registry.
52
+ */
53
+ registry: IComponentRegistry;
40
54
  }
41
55
 
42
56
  /**
43
- * A widget for rendering .
57
+ * A widget for rendering components from mime bundle.
44
58
  */
45
59
  export class ComponentsRenderer
46
60
  extends ReactWidget
@@ -54,6 +68,8 @@ export class ComponentsRenderer
54
68
  this._trans = (options.translator ?? nullTranslator).load('jupyterlab');
55
69
  this._mimeType = options.mimeType;
56
70
  this._toolCallApproval = options.toolCallApproval;
71
+ this._removeQueuedMessage = options.removeQueuedMessage;
72
+ this._registry = options.registry;
57
73
  this.addClass(CLASS_NAME);
58
74
  }
59
75
 
@@ -62,25 +78,40 @@ export class ComponentsRenderer
62
78
  */
63
79
  async renderModel(model: IRenderMime.IMimeModel): Promise<void> {
64
80
  this._data = model.data[this._mimeType] as string;
65
- this._metadata = { ...model.metadata };
81
+ const metadata = model.metadata;
82
+ this._metadata = (metadata[this._mimeType] as ReadonlyPartialJSONValue) ?? {
83
+ ...metadata
84
+ };
66
85
  return this.update();
67
86
  }
68
87
 
69
88
  protected render(): ReactRenderElement | null {
89
+ if (!this._data) {
90
+ return null;
91
+ }
92
+ const Component = this._registry.get(this._data);
93
+ if (!Component) {
94
+ return null;
95
+ }
96
+
97
+ const componentsProps = { ...(this._metadata as any) };
98
+
70
99
  if (this._data === 'tool-call') {
71
- const toolCallOptions: IToolCallHtmlOptions = {
72
- ...(this._metadata as unknown as IToolCallMetadata),
73
- trans: this._trans,
74
- toolCallApproval: this._toolCallApproval
75
- };
76
- return <ToolCall {...toolCallOptions} />;
100
+ componentsProps.toolCallApproval = this._toolCallApproval;
77
101
  }
78
- return null;
102
+
103
+ if (this._data === 'message-queue') {
104
+ componentsProps.removeQueuedMessage = this._removeQueuedMessage;
105
+ }
106
+
107
+ return <Component {...componentsProps} trans={this._trans} />;
79
108
  }
80
109
 
81
110
  private _trans: TranslationBundle;
82
111
  private _mimeType: string;
83
- private _toolCallApproval: ToolCallApproval;
112
+ private _toolCallApproval?: ToolCallApproval;
113
+ private _removeQueuedMessage?: RemoveQueuedMessage;
114
+ private _registry: IComponentRegistry;
84
115
  private _data: string | null = null;
85
116
  private _metadata: ReadonlyPartialJSONValue | null = null;
86
117
  }
@@ -92,11 +123,23 @@ export class RendererFactory implements IComponentsRendererFactory {
92
123
  readonly safe = true;
93
124
  readonly mimeTypes = [MIME_TYPE];
94
125
  readonly defaultRank = 100;
126
+ readonly registry: ComponentRegistry;
95
127
  toolCallApproval: ToolCallApproval = null;
128
+ removeQueuedMessage: RemoveQueuedMessage = null;
129
+
130
+ constructor() {
131
+ this.registry = new ComponentRegistry();
132
+ this.registry.add('tool-call', ToolCall);
133
+ this.registry.add('inline-diff', InlineDiff);
134
+ this.registry.add('message-queue', MessageQueue);
135
+ }
136
+
96
137
  createRenderer = (options: IRenderMime.IRendererOptions) => {
97
138
  return new ComponentsRenderer({
98
139
  ...options,
99
- toolCallApproval: this.toolCallApproval
140
+ toolCallApproval: this.toolCallApproval,
141
+ removeQueuedMessage: this.removeQueuedMessage,
142
+ registry: this.registry
100
143
  });
101
144
  };
102
145
  }
package/src/index.ts CHANGED
@@ -5,10 +5,10 @@ import {
5
5
 
6
6
  import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
7
7
 
8
- import { IComponentsRendererFactory } from './token';
9
-
10
8
  import { RendererFactory } from './factory';
11
9
 
10
+ import { IComponentsRendererFactory } from './token';
11
+
12
12
  /**
13
13
  * The plugin providing the chat component renderer.
14
14
  */
@@ -30,4 +30,7 @@ const factory: JupyterFrontEndPlugin<IComponentsRendererFactory> = {
30
30
 
31
31
  export * from './token';
32
32
  export * from './factory';
33
+ export * from './registry';
34
+ export * from './components';
35
+
33
36
  export default factory;
@@ -0,0 +1,54 @@
1
+ import * as React from 'react';
2
+
3
+ import { IComponentRegistry } from './token';
4
+
5
+ /**
6
+ * A registry for React components.
7
+ */
8
+ export class ComponentRegistry implements IComponentRegistry {
9
+ /**
10
+ * Register a React component.
11
+ *
12
+ * @param name - The unique name/identifier for the component
13
+ * @param component - The React component
14
+ */
15
+ add(name: string, component: React.ComponentType<any>): void {
16
+ if (this._components.has(name)) {
17
+ console.warn(
18
+ `Component '${name}' is already registered and will be overwritten.`
19
+ );
20
+ }
21
+ this._components.set(name, component);
22
+ }
23
+
24
+ /**
25
+ * Get a registered component by name.
26
+ *
27
+ * @param name - The name of the component
28
+ * @returns the React component, or undefined if not found
29
+ */
30
+ get(name: string): React.ComponentType<any> | undefined {
31
+ return this._components.get(name);
32
+ }
33
+
34
+ /**
35
+ * Check if a component is registered.
36
+ *
37
+ * @param name - The name of the component
38
+ * @returns whether the component is registered
39
+ */
40
+ has(name: string): boolean {
41
+ return this._components.has(name);
42
+ }
43
+
44
+ /**
45
+ * Get all registered component names.
46
+ *
47
+ * @returns the component names
48
+ */
49
+ getNames(): string[] {
50
+ return Array.from(this._components.keys());
51
+ }
52
+
53
+ private _components: Map<string, React.ComponentType<any>> = new Map();
54
+ }