@marimo-team/islands 0.17.8 → 0.18.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/dist/{Combination-BH_L276x.js → Combination-D68fi0fY.js} +22 -21
- package/dist/{ConnectedDataExplorerComponent-WbiFXhKG.js → ConnectedDataExplorerComponent-BUgUSo2B.js} +7 -7
- package/dist/{any-language-editor-YPQMljy9.js → any-language-editor-BS-Z5AY5.js} +3 -3
- package/dist/assets/__vite-browser-external-CSegkGa0.js +1 -0
- package/dist/assets/{worker-BrDpRi2I.js → worker-CiT2i-Vo.js} +2 -2
- package/dist/{error-banner-BqE1uF21.js → error-banner-CPLhCPHA.js} +24 -24
- package/dist/{esm-hR1r0nyt.js → esm-DxgKy8Wv.js} +1 -1
- package/dist/{formats-dvT8nDgH.js → formats-oddMfm9_.js} +27 -7
- package/dist/{glide-data-editor-B26PhZvE.js → glide-data-editor-BFv4VQnc.js} +4 -4
- package/dist/{label-D3LNCORf.js → label-Dsm6T1fr.js} +72 -72
- package/dist/main.js +359 -250
- package/dist/{mermaid-Dl3ywmV2.js → mermaid-BeGlg1JH.js} +2 -2
- package/dist/{react-vega-ypEMYp9o.js → react-vega-DDXWt_PN.js} +852 -1544
- package/dist/{react-vega-BIDT9Ttp.js → react-vega-DV2IwPx_.js} +1 -1
- package/dist/{spec-qDDGe5hl.js → spec-BotzCMo3.js} +2 -2
- package/dist/style.css +1 -1
- package/dist/{types-2eTEqSwS.js → types-IRrkdH-H.js} +14 -14
- package/dist/{useAsyncData-6gisQ4pR.js → useAsyncData-CsSW6_Zh.js} +1 -1
- package/dist/{useTheme-B-2frT0L.js → useTheme-D56Xlrez.js} +1 -0
- package/dist/{vega-component-C-bCSv1b.js → vega-component-CLjz4see.js} +6 -6
- package/package.json +2 -2
- package/src/components/chat/chat-panel.tsx +6 -2
- package/src/components/data-table/TableActions.tsx +18 -14
- package/src/components/data-table/data-table.tsx +3 -0
- package/src/components/editor/chrome/panels/packages-panel.tsx +3 -1
- package/src/components/editor/file-tree/__tests__/file-expolorer.test.ts +178 -0
- package/src/components/editor/file-tree/file-explorer.tsx +70 -1
- package/src/components/pages/home-page.tsx +8 -3
- package/src/core/ai/tools/__tests__/registry.test.ts +6 -2
- package/src/core/ai/tools/registry.ts +5 -2
- package/src/core/cells/__tests__/session.test.ts +0 -9
- package/src/core/cells/session.ts +0 -1
- package/src/core/codemirror/copilot/client.ts +21 -1
- package/src/core/codemirror/copilot/copilot-config.tsx +29 -1
- package/src/core/config/__tests__/config-schema.test.ts +2 -0
- package/src/core/config/config-schema.ts +1 -0
- package/src/core/packages/__tests__/package-input-utils.test.ts +93 -0
- package/src/core/packages/package-input-utils.ts +36 -0
- package/src/css/md.css +5 -0
- package/src/plugins/core/__test__/sanitize.test.ts +1 -1
- package/src/plugins/core/sanitize.ts +3 -1
- package/src/plugins/impl/DataTablePlugin.tsx +10 -1
- package/src/plugins/impl/chat/ChatPlugin.tsx +1 -0
- package/src/plugins/impl/chat/chat-ui.tsx +140 -10
- package/src/plugins/impl/data-frames/DataFramePlugin.tsx +1 -0
- package/src/plugins/layout/NavigationMenuPlugin.tsx +14 -3
- package/src/plugins/layout/ProgressPlugin.tsx +8 -5
- package/src/plugins/layout/StatPlugin.tsx +11 -4
- package/src/plugins/layout/__test__/ProgressPlugin.test.ts +37 -21
- package/src/utils/__tests__/urls.test.ts +165 -1
- package/src/utils/urls.ts +120 -0
- package/src/utils/vitals.ts +1 -1
- package/dist/assets/__vite-browser-external-BTNiCQ6O.js +0 -1
|
@@ -446,9 +446,6 @@ describe("notebookStateFromSession", () => {
|
|
|
446
446
|
const session = createSession([]);
|
|
447
447
|
const result = notebookStateFromSession(session, null);
|
|
448
448
|
|
|
449
|
-
expect(Logger.warn).toHaveBeenCalledWith(
|
|
450
|
-
"Session and notebook must have at least one cell",
|
|
451
|
-
);
|
|
452
449
|
expect(result).toBeNull();
|
|
453
450
|
});
|
|
454
451
|
|
|
@@ -456,9 +453,6 @@ describe("notebookStateFromSession", () => {
|
|
|
456
453
|
const notebook = createNotebook([]);
|
|
457
454
|
const result = notebookStateFromSession(null, notebook);
|
|
458
455
|
|
|
459
|
-
expect(Logger.warn).toHaveBeenCalledWith(
|
|
460
|
-
"Session and notebook must have at least one cell",
|
|
461
|
-
);
|
|
462
456
|
expect(result).toBeNull();
|
|
463
457
|
});
|
|
464
458
|
|
|
@@ -467,9 +461,6 @@ describe("notebookStateFromSession", () => {
|
|
|
467
461
|
const notebook = createNotebook([]);
|
|
468
462
|
const result = notebookStateFromSession(session, notebook);
|
|
469
463
|
|
|
470
|
-
expect(Logger.warn).toHaveBeenCalledWith(
|
|
471
|
-
"Session and notebook must have at least one cell",
|
|
472
|
-
);
|
|
473
464
|
expect(result).toBeNull();
|
|
474
465
|
});
|
|
475
466
|
});
|
|
@@ -89,9 +89,29 @@ class LazyWebsocketTransport extends Transport {
|
|
|
89
89
|
data: JSONRPCRequestData,
|
|
90
90
|
timeout: number | null | undefined,
|
|
91
91
|
) {
|
|
92
|
+
// If delegate is undefined, try to reconnect
|
|
93
|
+
if (!this.delegate) {
|
|
94
|
+
Logger.log("Copilot#sendData: Delegate not initialized, reconnecting...");
|
|
95
|
+
try {
|
|
96
|
+
await this.tryConnect();
|
|
97
|
+
} catch (error) {
|
|
98
|
+
Logger.error("Copilot#sendData: Failed to reconnect transport", error);
|
|
99
|
+
throw new Error(
|
|
100
|
+
"Unable to connect to GitHub Copilot. Please check your settings and try again.",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// After reconnection, delegate should be initialized
|
|
106
|
+
if (!this.delegate) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
"Failed to initialize GitHub Copilot connection. Please try again.",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
92
112
|
// Clamp timeout to 5 seconds
|
|
93
113
|
timeout = Math.min(timeout ?? 5000, 5000);
|
|
94
|
-
return this.delegate
|
|
114
|
+
return this.delegate.sendData(data, timeout);
|
|
95
115
|
}
|
|
96
116
|
}
|
|
97
117
|
|
|
@@ -24,13 +24,41 @@ export const CopilotConfig = memo(() => {
|
|
|
24
24
|
evt.preventDefault();
|
|
25
25
|
setLoading(true);
|
|
26
26
|
try {
|
|
27
|
-
const
|
|
27
|
+
const result = await initiateSignIn();
|
|
28
|
+
|
|
29
|
+
// Validate the response has required fields
|
|
30
|
+
if (!result || !result.verificationUri || !result.userCode) {
|
|
31
|
+
Logger.error("Copilot#trySignIn: Invalid response from sign-in", {
|
|
32
|
+
result,
|
|
33
|
+
});
|
|
34
|
+
setStep("connectionError");
|
|
35
|
+
toast({
|
|
36
|
+
title: "GitHub Copilot Connection Error",
|
|
37
|
+
description:
|
|
38
|
+
"Unable to connect to GitHub Copilot. Please check your settings and try again.",
|
|
39
|
+
variant: "danger",
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { verificationUri, status, userCode } = result;
|
|
28
45
|
if (isSignedIn(status)) {
|
|
29
46
|
copilotChangeSignIn(true);
|
|
30
47
|
} else {
|
|
31
48
|
setStep("signingIn");
|
|
32
49
|
setLocalData({ url: verificationUri, code: userCode });
|
|
33
50
|
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
Logger.error("Copilot#trySignIn: Error during sign-in", error);
|
|
53
|
+
setStep("connectionError");
|
|
54
|
+
toast({
|
|
55
|
+
title: "GitHub Copilot Connection Error",
|
|
56
|
+
description:
|
|
57
|
+
error instanceof Error
|
|
58
|
+
? error.message
|
|
59
|
+
: "Unable to connect to GitHub Copilot. Please check your settings and try again.",
|
|
60
|
+
variant: "danger",
|
|
61
|
+
});
|
|
34
62
|
} finally {
|
|
35
63
|
setLoading(false);
|
|
36
64
|
}
|
|
@@ -87,6 +87,7 @@ test("default UserConfig - empty", () => {
|
|
|
87
87
|
"default_auto_download": [],
|
|
88
88
|
"default_sql_output": "auto",
|
|
89
89
|
"on_cell_change": "autorun",
|
|
90
|
+
"reactive_tests": true,
|
|
90
91
|
"watcher_on_save": "lazy",
|
|
91
92
|
},
|
|
92
93
|
"save": {
|
|
@@ -154,6 +155,7 @@ test("default UserConfig - one level", () => {
|
|
|
154
155
|
"default_auto_download": [],
|
|
155
156
|
"default_sql_output": "auto",
|
|
156
157
|
"on_cell_change": "autorun",
|
|
158
|
+
"reactive_tests": true,
|
|
157
159
|
"watcher_on_save": "lazy",
|
|
158
160
|
},
|
|
159
161
|
"save": {
|
|
@@ -119,6 +119,7 @@ export const UserConfigSchema = z
|
|
|
119
119
|
auto_instantiate: z.boolean().prefault(true),
|
|
120
120
|
on_cell_change: z.enum(["lazy", "autorun"]).prefault("autorun"),
|
|
121
121
|
auto_reload: z.enum(["off", "lazy", "autorun"]).prefault("off"),
|
|
122
|
+
reactive_tests: z.boolean().prefault(true),
|
|
122
123
|
watcher_on_save: z.enum(["lazy", "autorun"]).prefault("lazy"),
|
|
123
124
|
default_sql_output: z.enum(VALID_SQL_OUTPUT_FORMATS).prefault("auto"),
|
|
124
125
|
default_auto_download: z
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* Copyright 2024 Marimo. All rights reserved. */
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { stripPackageManagerPrefix } from "../package-input-utils";
|
|
4
|
+
|
|
5
|
+
describe("stripPackageManagerPrefix", () => {
|
|
6
|
+
it("should remove 'pip install' prefix", () => {
|
|
7
|
+
expect(stripPackageManagerPrefix("pip install httpx")).toBe("httpx");
|
|
8
|
+
expect(stripPackageManagerPrefix("pip install httpx requests")).toBe(
|
|
9
|
+
"httpx requests",
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("should remove 'pip3 install' prefix", () => {
|
|
14
|
+
expect(stripPackageManagerPrefix("pip3 install pandas")).toBe("pandas");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("should remove 'uv add' prefix", () => {
|
|
18
|
+
expect(stripPackageManagerPrefix("uv add numpy")).toBe("numpy");
|
|
19
|
+
expect(stripPackageManagerPrefix("uv add pandas numpy")).toBe(
|
|
20
|
+
"pandas numpy",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should remove 'uv pip install' prefix", () => {
|
|
25
|
+
expect(stripPackageManagerPrefix("uv pip install scipy")).toBe("scipy");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should remove 'poetry add' prefix", () => {
|
|
29
|
+
expect(stripPackageManagerPrefix("poetry add flask")).toBe("flask");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should remove 'conda install' prefix", () => {
|
|
33
|
+
expect(stripPackageManagerPrefix("conda install matplotlib")).toBe(
|
|
34
|
+
"matplotlib",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should remove 'pipenv install' prefix", () => {
|
|
39
|
+
expect(stripPackageManagerPrefix("pipenv install django")).toBe("django");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should be case insensitive", () => {
|
|
43
|
+
expect(stripPackageManagerPrefix("PIP INSTALL httpx")).toBe("httpx");
|
|
44
|
+
expect(stripPackageManagerPrefix("Pip Install requests")).toBe("requests");
|
|
45
|
+
expect(stripPackageManagerPrefix("UV ADD numpy")).toBe("numpy");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should handle extra whitespace", () => {
|
|
49
|
+
expect(stripPackageManagerPrefix(" pip install httpx ")).toBe("httpx");
|
|
50
|
+
expect(stripPackageManagerPrefix("uv add pandas ")).toBe("pandas");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should return input unchanged if no prefix matches", () => {
|
|
54
|
+
expect(stripPackageManagerPrefix("httpx")).toBe("httpx");
|
|
55
|
+
expect(stripPackageManagerPrefix("pandas numpy")).toBe("pandas numpy");
|
|
56
|
+
expect(stripPackageManagerPrefix("httpx==0.27.0")).toBe("httpx==0.27.0");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should handle package specifications with versions", () => {
|
|
60
|
+
expect(stripPackageManagerPrefix("pip install httpx==0.27.0")).toBe(
|
|
61
|
+
"httpx==0.27.0",
|
|
62
|
+
);
|
|
63
|
+
expect(stripPackageManagerPrefix("uv add pandas>=2.0.0")).toBe(
|
|
64
|
+
"pandas>=2.0.0",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should handle git URLs", () => {
|
|
69
|
+
expect(
|
|
70
|
+
stripPackageManagerPrefix(
|
|
71
|
+
"pip install git+https://github.com/encode/httpx",
|
|
72
|
+
),
|
|
73
|
+
).toBe("git+https://github.com/encode/httpx");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should handle multiple packages", () => {
|
|
77
|
+
expect(stripPackageManagerPrefix("pip install httpx requests pandas")).toBe(
|
|
78
|
+
"httpx requests pandas",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should only remove the first matching prefix", () => {
|
|
83
|
+
// Edge case: input contains prefix-like text multiple times
|
|
84
|
+
expect(stripPackageManagerPrefix("pip install pip install httpx")).toBe(
|
|
85
|
+
"pip install httpx",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should handle empty string", () => {
|
|
90
|
+
expect(stripPackageManagerPrefix("")).toBe("");
|
|
91
|
+
expect(stripPackageManagerPrefix(" ")).toBe("");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/* Copyright 2024 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Removes common package manager command prefixes from an input string.
|
|
5
|
+
* This allows users to paste commands like "pip install httpx" and have
|
|
6
|
+
* the "pip install" prefix automatically removed.
|
|
7
|
+
*
|
|
8
|
+
* @param input - The raw input string that may contain a package manager prefix
|
|
9
|
+
* @returns The input with any recognized prefix removed and trimmed
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* stripPackageManagerPrefix("pip install httpx") // returns "httpx"
|
|
13
|
+
* stripPackageManagerPrefix("uv add pandas numpy") // returns "pandas numpy"
|
|
14
|
+
* stripPackageManagerPrefix("httpx") // returns "httpx"
|
|
15
|
+
*/
|
|
16
|
+
export function stripPackageManagerPrefix(input: string): string {
|
|
17
|
+
const trimmedInput = input.trim();
|
|
18
|
+
|
|
19
|
+
const prefixes = [
|
|
20
|
+
"pip install",
|
|
21
|
+
"pip3 install",
|
|
22
|
+
"uv add",
|
|
23
|
+
"uv pip install",
|
|
24
|
+
"poetry add",
|
|
25
|
+
"conda install",
|
|
26
|
+
"pipenv install",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const prefix of prefixes) {
|
|
30
|
+
if (trimmedInput.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
31
|
+
return trimmedInput.slice(prefix.length).trim();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return trimmedInput;
|
|
36
|
+
}
|
package/src/css/md.css
CHANGED
|
@@ -78,6 +78,11 @@ a .markdown iconify-icon:first-child {
|
|
|
78
78
|
margin-inline-end: 0.4em;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/* make links and their contents inline-flex to avoid extra gaps */
|
|
82
|
+
a > .markdown > .paragraph {
|
|
83
|
+
display: inline-flex;
|
|
84
|
+
}
|
|
85
|
+
|
|
81
86
|
iconify-icon {
|
|
82
87
|
display: inline-flex;
|
|
83
88
|
align-items: center;
|
|
@@ -141,7 +141,7 @@ describe("sanitizeHtml", () => {
|
|
|
141
141
|
const html =
|
|
142
142
|
"<marimo-mermaid data-diagram='"sequenceDiagram\n Alice->>John\n John-->>Alice\n "'></marimo-mermaid>";
|
|
143
143
|
expect(sanitizeHtml(html)).toMatchInlineSnapshot(
|
|
144
|
-
`"<marimo-mermaid></marimo-mermaid>"`,
|
|
144
|
+
`"<marimo-mermaid data-diagram=""sequenceDiagram\\n Alice->>John\\n John-->>Alice\\n ""></marimo-mermaid>"`,
|
|
145
145
|
);
|
|
146
146
|
});
|
|
147
147
|
|
|
@@ -80,7 +80,9 @@ export function sanitizeHtml(html: string) {
|
|
|
80
80
|
tagNameCheck: /^(marimo-[A-Za-z][\w-]*|iconify-icon)$/,
|
|
81
81
|
attributeNameCheck: /^[A-Za-z][\w-]*$/,
|
|
82
82
|
},
|
|
83
|
-
|
|
83
|
+
// This flag means we should sanitize such that is it safe for XML,
|
|
84
|
+
// but this is only used for HTML content.
|
|
85
|
+
SAFE_FOR_XML: !html.includes("marimo-mermaid"),
|
|
84
86
|
};
|
|
85
87
|
return DOMPurify.sanitize(html, sanitizationOptions);
|
|
86
88
|
}
|
|
@@ -63,6 +63,7 @@ import { type CellId, findCellId } from "@/core/cells/ids";
|
|
|
63
63
|
import { slotsController } from "@/core/slots/slots";
|
|
64
64
|
import { store } from "@/core/state/jotai";
|
|
65
65
|
import { isStaticNotebook } from "@/core/static/static-state";
|
|
66
|
+
import { isInVscodeExtension } from "@/core/vscode/is-in-vscode";
|
|
66
67
|
import { useAsyncData } from "@/hooks/useAsyncData";
|
|
67
68
|
import { useDeepCompareMemoize } from "@/hooks/useDeepCompareMemoize";
|
|
68
69
|
import { useEffectSkipFirstRender } from "@/hooks/useEffectSkipFirstRender";
|
|
@@ -179,6 +180,7 @@ interface Data<T> {
|
|
|
179
180
|
showDataTypes: boolean;
|
|
180
181
|
showPageSizeSelector: boolean;
|
|
181
182
|
showColumnExplorer: boolean;
|
|
183
|
+
showRowExplorer: boolean;
|
|
182
184
|
showChartBuilder: boolean;
|
|
183
185
|
rowHeaders: FieldTypesWithExternalType;
|
|
184
186
|
fieldTypes?: FieldTypesWithExternalType | null;
|
|
@@ -250,6 +252,7 @@ export const DataTablePlugin = createPlugin<S>("marimo-table")
|
|
|
250
252
|
showDataTypes: z.boolean().default(true),
|
|
251
253
|
showPageSizeSelector: z.boolean().default(true),
|
|
252
254
|
showColumnExplorer: z.boolean().default(true),
|
|
255
|
+
showRowExplorer: z.boolean().default(true),
|
|
253
256
|
showChartBuilder: z.boolean().default(true),
|
|
254
257
|
rowHeaders: columnToFieldTypesSchema,
|
|
255
258
|
freezeColumnsLeft: z.array(z.string()).optional(),
|
|
@@ -718,6 +721,7 @@ const DataTableComponent = ({
|
|
|
718
721
|
showDownload,
|
|
719
722
|
showPageSizeSelector,
|
|
720
723
|
showColumnExplorer,
|
|
724
|
+
showRowExplorer,
|
|
721
725
|
showChartBuilder,
|
|
722
726
|
showDataTypes,
|
|
723
727
|
rowHeaders,
|
|
@@ -899,6 +903,8 @@ const DataTableComponent = ({
|
|
|
899
903
|
const showColExplorer =
|
|
900
904
|
showColumnExplorer && preview_column && isPanelOpen("column-explorer");
|
|
901
905
|
|
|
906
|
+
const isInVscode = isInVscodeExtension();
|
|
907
|
+
|
|
902
908
|
return (
|
|
903
909
|
<>
|
|
904
910
|
{/* When the totalRows is "too_many" and the pageSize is the same as the
|
|
@@ -988,7 +994,10 @@ const DataTableComponent = ({
|
|
|
988
994
|
toggleDisplayHeader={toggleDisplayHeader}
|
|
989
995
|
showChartBuilder={showChartBuilder}
|
|
990
996
|
showPageSizeSelector={showPageSizeSelector}
|
|
991
|
-
|
|
997
|
+
// Hidden in VSCode (for now) because we don't have a panel to show
|
|
998
|
+
// the column/row explorer.
|
|
999
|
+
showColumnExplorer={showColumnExplorer && !isInVscode}
|
|
1000
|
+
showRowExplorer={showRowExplorer && !isInVscode}
|
|
992
1001
|
togglePanel={togglePanel}
|
|
993
1002
|
isPanelOpen={isPanelOpen}
|
|
994
1003
|
viewedRowIdx={viewedRowIdx}
|
|
@@ -89,6 +89,7 @@ export const ChatPlugin = createPlugin<{ messages: ChatMessage[] }>(
|
|
|
89
89
|
send_prompt={props.functions.send_prompt}
|
|
90
90
|
value={props.value?.messages || Arrays.EMPTY}
|
|
91
91
|
setValue={(messages) => props.setValue({ messages })}
|
|
92
|
+
host={props.host}
|
|
92
93
|
/>
|
|
93
94
|
</Suspense>
|
|
94
95
|
</TooltipProvider>
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
DownloadIcon,
|
|
13
13
|
HelpCircleIcon,
|
|
14
14
|
PaperclipIcon,
|
|
15
|
+
RotateCwIcon,
|
|
15
16
|
SendIcon,
|
|
16
17
|
SettingsIcon,
|
|
17
18
|
Trash2Icon,
|
|
@@ -42,7 +43,12 @@ import {
|
|
|
42
43
|
import { Tooltip } from "@/components/ui/tooltip";
|
|
43
44
|
import { toast } from "@/components/ui/use-toast";
|
|
44
45
|
import { moveToEndOfEditor } from "@/core/codemirror/utils";
|
|
46
|
+
import { MarimoIncomingMessageEvent } from "@/core/dom/events";
|
|
45
47
|
import { useAsyncData } from "@/hooks/useAsyncData";
|
|
48
|
+
import {
|
|
49
|
+
type HTMLElementNotDerivedFromRef,
|
|
50
|
+
useEventListener,
|
|
51
|
+
} from "@/hooks/useEventListener";
|
|
46
52
|
import { cn } from "@/utils/cn";
|
|
47
53
|
import { copyToClipboard } from "@/utils/copy";
|
|
48
54
|
import { Logger } from "@/utils/Logger";
|
|
@@ -63,6 +69,7 @@ interface Props extends PluginFunctions {
|
|
|
63
69
|
allowAttachments: boolean | string[];
|
|
64
70
|
value: ChatMessage[];
|
|
65
71
|
setValue: (messages: ChatMessage[]) => void;
|
|
72
|
+
host: HTMLElement;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
export const Chatbot: React.FC<Props> = (props) => {
|
|
@@ -74,6 +81,12 @@ export const Chatbot: React.FC<Props> = (props) => {
|
|
|
74
81
|
const codeMirrorInputRef = useRef<ReactCodeMirrorRef>(null);
|
|
75
82
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
76
83
|
|
|
84
|
+
// Track streaming state - maps backend message_id to frontend message index
|
|
85
|
+
const streamingStateRef = useRef<{
|
|
86
|
+
backendMessageId: string | null;
|
|
87
|
+
frontendMessageIndex: number | null;
|
|
88
|
+
}>({ backendMessageId: null, frontendMessageIndex: null });
|
|
89
|
+
|
|
77
90
|
const { data: initialMessages } = useAsyncData(async () => {
|
|
78
91
|
const chatMessages = await props.get_chat_history({});
|
|
79
92
|
const messages: UIMessage[] = chatMessages.messages.map((message, idx) => ({
|
|
@@ -113,6 +126,19 @@ export const Chatbot: React.FC<Props> = (props) => {
|
|
|
113
126
|
.join("\n"),
|
|
114
127
|
parts: m.parts,
|
|
115
128
|
}));
|
|
129
|
+
|
|
130
|
+
// Create a placeholder message for streaming
|
|
131
|
+
const messageId = Date.now().toString();
|
|
132
|
+
|
|
133
|
+
setMessages((prev) => [
|
|
134
|
+
...prev,
|
|
135
|
+
{
|
|
136
|
+
id: messageId,
|
|
137
|
+
role: "assistant",
|
|
138
|
+
parts: [{ type: "text", text: "" }],
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
|
|
116
142
|
const response = await props.send_prompt({
|
|
117
143
|
messages: messages,
|
|
118
144
|
config: {
|
|
@@ -124,18 +150,35 @@ export const Chatbot: React.FC<Props> = (props) => {
|
|
|
124
150
|
presence_penalty: config.presence_penalty,
|
|
125
151
|
},
|
|
126
152
|
});
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
153
|
+
|
|
154
|
+
// If streaming didn't happen (non-generator response), update the message
|
|
155
|
+
// Check if streaming state is still set (meaning no chunks were received)
|
|
156
|
+
if (
|
|
157
|
+
streamingStateRef.current.backendMessageId === null &&
|
|
158
|
+
streamingStateRef.current.frontendMessageIndex === null
|
|
159
|
+
) {
|
|
160
|
+
setMessages((prev) => {
|
|
161
|
+
const updated = [...prev];
|
|
162
|
+
const index = updated.findIndex((m) => m.id === messageId);
|
|
163
|
+
if (index !== -1) {
|
|
164
|
+
updated[index] = {
|
|
165
|
+
...updated[index],
|
|
166
|
+
parts: [{ type: "text", text: response }],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return updated;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
136
173
|
return new Response(response);
|
|
137
174
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
138
175
|
} catch (error: any) {
|
|
176
|
+
// Clear streaming state on error
|
|
177
|
+
streamingStateRef.current = {
|
|
178
|
+
backendMessageId: null,
|
|
179
|
+
frontendMessageIndex: null,
|
|
180
|
+
};
|
|
181
|
+
|
|
139
182
|
// HACK: strip the error message to clean up the response
|
|
140
183
|
const strippedError = error.message
|
|
141
184
|
.split("failed with exception ")
|
|
@@ -152,12 +195,98 @@ export const Chatbot: React.FC<Props> = (props) => {
|
|
|
152
195
|
fileInputRef.current.value = "";
|
|
153
196
|
}
|
|
154
197
|
Logger.debug("Finished streaming message:", message);
|
|
198
|
+
|
|
199
|
+
// Clear streaming state
|
|
200
|
+
streamingStateRef.current = {
|
|
201
|
+
backendMessageId: null,
|
|
202
|
+
frontendMessageIndex: null,
|
|
203
|
+
};
|
|
155
204
|
},
|
|
156
205
|
onError: (error) => {
|
|
157
206
|
Logger.error("An error occurred:", error);
|
|
207
|
+
// Clear streaming state on error
|
|
208
|
+
streamingStateRef.current = {
|
|
209
|
+
backendMessageId: null,
|
|
210
|
+
frontendMessageIndex: null,
|
|
211
|
+
};
|
|
158
212
|
},
|
|
159
213
|
});
|
|
160
214
|
|
|
215
|
+
// Listen for streaming chunks from backend
|
|
216
|
+
useEventListener(
|
|
217
|
+
props.host as HTMLElementNotDerivedFromRef,
|
|
218
|
+
MarimoIncomingMessageEvent.TYPE,
|
|
219
|
+
(e) => {
|
|
220
|
+
const message = e.detail.message;
|
|
221
|
+
if (
|
|
222
|
+
typeof message === "object" &&
|
|
223
|
+
message !== null &&
|
|
224
|
+
"type" in message &&
|
|
225
|
+
message.type === "stream_chunk"
|
|
226
|
+
) {
|
|
227
|
+
const chunkMessage = message as {
|
|
228
|
+
type: string;
|
|
229
|
+
message_id: string;
|
|
230
|
+
content: string;
|
|
231
|
+
is_final: boolean;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Initialize streaming state on first chunk if not already set
|
|
235
|
+
if (streamingStateRef.current.backendMessageId === null) {
|
|
236
|
+
// Find the last assistant message (which should be the placeholder we created)
|
|
237
|
+
setMessages((prev) => {
|
|
238
|
+
const updated = [...prev];
|
|
239
|
+
// Find the last assistant message
|
|
240
|
+
for (let i = updated.length - 1; i >= 0; i--) {
|
|
241
|
+
if (updated[i].role === "assistant") {
|
|
242
|
+
streamingStateRef.current = {
|
|
243
|
+
backendMessageId: chunkMessage.message_id,
|
|
244
|
+
frontendMessageIndex: i,
|
|
245
|
+
};
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return updated;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Only process chunks for the current streaming message
|
|
254
|
+
const frontendIndex = streamingStateRef.current.frontendMessageIndex;
|
|
255
|
+
if (
|
|
256
|
+
streamingStateRef.current.backendMessageId ===
|
|
257
|
+
chunkMessage.message_id &&
|
|
258
|
+
frontendIndex !== null
|
|
259
|
+
) {
|
|
260
|
+
setMessages((prev) => {
|
|
261
|
+
const updated = [...prev];
|
|
262
|
+
const index = frontendIndex;
|
|
263
|
+
|
|
264
|
+
// Update the message at the tracked index
|
|
265
|
+
if (index < updated.length) {
|
|
266
|
+
const messageToUpdate = updated[index];
|
|
267
|
+
if (messageToUpdate.role === "assistant") {
|
|
268
|
+
updated[index] = {
|
|
269
|
+
...messageToUpdate,
|
|
270
|
+
parts: [{ type: "text", text: chunkMessage.content }],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return updated;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Clear streaming state when final chunk arrives
|
|
279
|
+
if (chunkMessage.is_final) {
|
|
280
|
+
streamingStateRef.current = {
|
|
281
|
+
backendMessageId: null,
|
|
282
|
+
frontendMessageIndex: null,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
|
|
161
290
|
const isLoading = status === "submitted" || status === "streaming";
|
|
162
291
|
|
|
163
292
|
const handleDelete = (id: string) => {
|
|
@@ -283,13 +412,14 @@ export const Chatbot: React.FC<Props> = (props) => {
|
|
|
283
412
|
<Button
|
|
284
413
|
variant="text"
|
|
285
414
|
size="icon"
|
|
415
|
+
disabled={messages.length === 0}
|
|
286
416
|
onClick={() => {
|
|
287
417
|
setMessages([]);
|
|
288
418
|
props.setValue([]);
|
|
289
419
|
props.delete_chat_history({});
|
|
290
420
|
}}
|
|
291
421
|
>
|
|
292
|
-
<
|
|
422
|
+
<RotateCwIcon className="h-3 w-3" />
|
|
293
423
|
</Button>
|
|
294
424
|
</div>
|
|
295
425
|
<div
|
|
@@ -297,6 +297,7 @@ export const DataFrameComponent = memo(
|
|
|
297
297
|
get_column_summaries={getColumnSummaries}
|
|
298
298
|
showPageSizeSelector={(total_rows && total_rows > 5) || false}
|
|
299
299
|
showColumnExplorer={false}
|
|
300
|
+
showRowExplorer={true}
|
|
300
301
|
showChartBuilder={false}
|
|
301
302
|
value={Arrays.EMPTY}
|
|
302
303
|
setValue={Functions.NOOP}
|
|
@@ -14,11 +14,13 @@ import {
|
|
|
14
14
|
import { Tooltip, TooltipProvider } from "@/components/ui/tooltip";
|
|
15
15
|
import { renderHTML } from "@/plugins/core/RenderHTML";
|
|
16
16
|
import { cn } from "@/utils/cn";
|
|
17
|
+
import { appendQueryParams } from "@/utils/urls";
|
|
17
18
|
import type {
|
|
18
19
|
IStatelessPlugin,
|
|
19
20
|
IStatelessPluginProps,
|
|
20
21
|
} from "../stateless-plugin";
|
|
21
22
|
import "./navigation-menu.css";
|
|
23
|
+
import { KnownQueryParams } from "@/core/constants";
|
|
22
24
|
|
|
23
25
|
interface MenuItem {
|
|
24
26
|
label: string;
|
|
@@ -99,6 +101,15 @@ const NavMenuComponent = ({
|
|
|
99
101
|
return "_self";
|
|
100
102
|
};
|
|
101
103
|
|
|
104
|
+
const preserveQueryParams = (href: string) => {
|
|
105
|
+
const currentUrl = new URL(globalThis.location.href);
|
|
106
|
+
return appendQueryParams({
|
|
107
|
+
href,
|
|
108
|
+
queryParams: currentUrl.search,
|
|
109
|
+
keys: [KnownQueryParams.filePath],
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
102
113
|
const renderMenuItem = (item: MenuItem | MenuItemGroup) => {
|
|
103
114
|
if ("items" in item) {
|
|
104
115
|
return orientation === "horizontal" ? (
|
|
@@ -113,7 +124,7 @@ const NavMenuComponent = ({
|
|
|
113
124
|
<ListItem
|
|
114
125
|
key={subItem.label}
|
|
115
126
|
label={subItem.label}
|
|
116
|
-
href={subItem.href}
|
|
127
|
+
href={preserveQueryParams(subItem.href)}
|
|
117
128
|
target={target(subItem.href)}
|
|
118
129
|
>
|
|
119
130
|
{subItem.description &&
|
|
@@ -142,7 +153,7 @@ const NavMenuComponent = ({
|
|
|
142
153
|
{maybeWithTooltip(
|
|
143
154
|
<NavigationMenuLink
|
|
144
155
|
key={subItem.label}
|
|
145
|
-
href={subItem.href}
|
|
156
|
+
href={preserveQueryParams(subItem.href)}
|
|
146
157
|
target={target(subItem.href)}
|
|
147
158
|
className={navigationMenuTriggerStyle({
|
|
148
159
|
orientation: orientation,
|
|
@@ -162,7 +173,7 @@ const NavMenuComponent = ({
|
|
|
162
173
|
return (
|
|
163
174
|
<NavigationMenuItem key={item.label}>
|
|
164
175
|
<NavigationMenuLink
|
|
165
|
-
href={item.href}
|
|
176
|
+
href={preserveQueryParams(item.href)}
|
|
166
177
|
target={target(item.href)}
|
|
167
178
|
className={navigationMenuTriggerStyle({
|
|
168
179
|
orientation: orientation,
|
|
@@ -93,10 +93,13 @@ export const ProgressComponent = ({
|
|
|
93
93
|
|
|
94
94
|
const elements: React.ReactNode[] = [];
|
|
95
95
|
if (rate) {
|
|
96
|
-
|
|
97
|
-
<span key="rate">{rate} iter
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
if (rate < 1) {
|
|
97
|
+
elements.push(<span key="rate">{prettyTime(1 / rate)} per iter</span>);
|
|
98
|
+
} else {
|
|
99
|
+
elements.push(<span key="rate">{rate} iter/s</span>);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
elements.push(<span key="spacer-rate">·</span>);
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
if (!hasCompleted && eta) {
|
|
@@ -170,6 +173,6 @@ export function prettyTime(seconds: number): string {
|
|
|
170
173
|
language: "shortEn",
|
|
171
174
|
largest: 2,
|
|
172
175
|
spacer: "",
|
|
173
|
-
maxDecimalPoints: 2,
|
|
176
|
+
maxDecimalPoints: seconds < 10 ? 2 : 0,
|
|
174
177
|
});
|
|
175
178
|
}
|