@marimo-team/islands 0.15.1-dev20 → 0.15.1-dev23

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.
Files changed (70) hide show
  1. package/dist/{ConnectedDataExplorerComponent-T4kWx74k.js → ConnectedDataExplorerComponent-D0XqshlU.js} +2 -2
  2. package/dist/{ImageComparisonComponent-DGrNyuT6.js → ImageComparisonComponent-CZsQglqB.js} +1 -1
  3. package/dist/{_baseUniq-DDI83xpx.js → _baseUniq-Ckiyck6A.js} +1 -1
  4. package/dist/{any-language-editor-OdwxdANJ.js → any-language-editor-BQyFbRCa.js} +1 -1
  5. package/dist/{architectureDiagram-KFL7JDKH-Bz4LLaRM.js → architectureDiagram-KFL7JDKH-Bf0uTVKX.js} +4 -4
  6. package/dist/{blockDiagram-ZYB65J3Q-Bn75dKCF.js → blockDiagram-ZYB65J3Q-COqr4VwW.js} +5 -5
  7. package/dist/{c4Diagram-AAMF2YG6-OUTC8UCE.js → c4Diagram-AAMF2YG6-BuIrt4mL.js} +2 -2
  8. package/dist/{channel-D2Pk_7pD.js → channel-DtQ-9KOG.js} +1 -1
  9. package/dist/{chunk-ANTBXLJU-BOcGK9IQ.js → chunk-ANTBXLJU-qUpcxtn9.js} +1 -1
  10. package/dist/{chunk-FHKO5MBM-Itt815NO.js → chunk-FHKO5MBM-KUh3TLMU.js} +1 -1
  11. package/dist/{chunk-GLLZNHP4-BlugZIAr.js → chunk-GLLZNHP4-BG2l4nQz.js} +1 -1
  12. package/dist/{chunk-JBRWN2VN-CMPZS_JC.js → chunk-JBRWN2VN-DcOjZ6W6.js} +4 -4
  13. package/dist/{chunk-LXBSTHXV-B9m0o3Dw.js → chunk-LXBSTHXV-1wtuuDPb.js} +3 -3
  14. package/dist/{chunk-NRVI72HA-CUWnO70H.js → chunk-NRVI72HA-BVVW7BOP.js} +1 -1
  15. package/dist/{chunk-OMD6QJNC-C0G1Ybvw.js → chunk-OMD6QJNC-BOOCPvHU.js} +1 -1
  16. package/dist/{chunk-WVR4S24B-CfqNG6BS.js → chunk-WVR4S24B-CWMArkgK.js} +1 -1
  17. package/dist/{classDiagram-v2-QTMF73CY-Du3X9bsO.js → classDiagram-3BZAVTQC-BjnqEj2F.js} +2 -2
  18. package/dist/{classDiagram-3BZAVTQC-Du3X9bsO.js → classDiagram-v2-QTMF73CY-BjnqEj2F.js} +2 -2
  19. package/dist/{clone-CHk_BpQ7.js → clone-DD_V4yHs.js} +1 -1
  20. package/dist/{dagre-2BBEFEWP-C21lJehF.js → dagre-2BBEFEWP-DyqlYi2j.js} +6 -6
  21. package/dist/{data-grid-overlay-editor-DQAvWUme.js → data-grid-overlay-editor-CcVaBCuH.js} +2 -2
  22. package/dist/{diagram-4IRLE6MV-DiqIOlJy.js → diagram-4IRLE6MV-CreyFvcp.js} +5 -5
  23. package/dist/{diagram-GUPCWM2R-QNnoayaR.js → diagram-GUPCWM2R-AveBH1M-.js} +3 -3
  24. package/dist/{diagram-RP2FKANI-D0mh9jtM.js → diagram-RP2FKANI-CHEhrmw1.js} +3 -3
  25. package/dist/{erDiagram-HZWUO2LU-CHNzznx-.js → erDiagram-HZWUO2LU-Bo1td0a2.js} +4 -4
  26. package/dist/{flowDiagram-THRYKUMA-_FZ6mTfz.js → flowDiagram-THRYKUMA-DV0yuxL7.js} +5 -5
  27. package/dist/{ganttDiagram-WV7ZQ7D5-CaYCAAh_.js → ganttDiagram-WV7ZQ7D5-DFlgXP8C.js} +4 -4
  28. package/dist/{gitGraphDiagram-OJR772UL-D_G318ZT.js → gitGraphDiagram-OJR772UL-DfdoeHs8.js} +4 -4
  29. package/dist/{glide-data-editor-DwzWASdN.js → glide-data-editor-AoDOWQjZ.js} +3 -3
  30. package/dist/{graph-Dkiu8g_M.js → graph-CreegasO.js} +3 -3
  31. package/dist/{index-Dc1_AAWe.js → index-BA-ePze6.js} +3 -3
  32. package/dist/{index-r5Jrqg8i.js → index-BrVUS1nc.js} +1 -1
  33. package/dist/{index-OIcVgWd2.js → index-C-0pD2W0.js} +1 -1
  34. package/dist/{index-CxgXuB1J.js → index-CkZ7D2Ry.js} +1 -1
  35. package/dist/{index-w90izJvc.js → index-DvKEL24D.js} +1 -1
  36. package/dist/{infoDiagram-6WOFNB3A-DtTO4vv-.js → infoDiagram-6WOFNB3A-Bzxw7XWX.js} +2 -2
  37. package/dist/{journeyDiagram-FFXJYRFH-Bc6OYvB0.js → journeyDiagram-FFXJYRFH-BzAcL7-t.js} +3 -3
  38. package/dist/{kanban-definition-KOZQBZVT-BJxHSdqr.js → kanban-definition-KOZQBZVT-DNwe7TQc.js} +2 -2
  39. package/dist/{layout-b98w0jb2.js → layout-Bhbgjfgd.js} +4 -4
  40. package/dist/{linear-DzMFB4Mu.js → linear-CMGuqFex.js} +1 -1
  41. package/dist/{main-3-uFVaKp.js → main-DqZNwn-r.js} +22 -21
  42. package/dist/main.js +1 -1
  43. package/dist/{mermaid-DnVrAn0b.js → mermaid-DYU5EKQx.js} +29 -29
  44. package/dist/{min-C8XS9LSa.js → min-cWlAN3aL.js} +2 -2
  45. package/dist/{mindmap-definition-LNHGMQRG-DNwr3bGQ.js → mindmap-definition-LNHGMQRG-SqmjJMOX.js} +2 -2
  46. package/dist/{number-overlay-editor-B97boK1K.js → number-overlay-editor-B_N8oIlq.js} +2 -2
  47. package/dist/{pieDiagram-DBDJKBY4-BL1ymVxH.js → pieDiagram-DBDJKBY4-CMBU4--E.js} +3 -3
  48. package/dist/{quadrantDiagram-YPSRARAO-DhHySUbQ.js → quadrantDiagram-YPSRARAO-C-eVThNr.js} +2 -2
  49. package/dist/{react-plotly-CTH_zZDu.js → react-plotly-BxPqO_XY.js} +1 -1
  50. package/dist/{requirementDiagram-EGVEC5DT-O8owc5D5.js → requirementDiagram-EGVEC5DT-B-JMjvqi.js} +3 -3
  51. package/dist/{sankeyDiagram-HRAUVNP4-Db6_uxKW.js → sankeyDiagram-HRAUVNP4-Dp9GioK7.js} +1 -1
  52. package/dist/{sequenceDiagram-WFGC7UMF-Bnf8-z29.js → sequenceDiagram-WFGC7UMF-DSah_xWf.js} +3 -3
  53. package/dist/{slides-component-DCeqnno4.js → slides-component-CqHjN5_K.js} +1 -1
  54. package/dist/{stateDiagram-UUKSUZ4H-Dd4BaplA.js → stateDiagram-UUKSUZ4H-3XFJ-Sg5.js} +4 -4
  55. package/dist/{stateDiagram-v2-EYPG3UTE-B2UNCxuY.js → stateDiagram-v2-EYPG3UTE-viJ4Y_Zw.js} +2 -2
  56. package/dist/{time-CACu73BN.js → time-DqeWEGuD.js} +2 -2
  57. package/dist/{timeline-definition-3HZDQTIS-CeJCv_tk.js → timeline-definition-3HZDQTIS-CMJ28VYj.js} +1 -1
  58. package/dist/{treemap-75Q7IDZK-D1YD8J0D.js → treemap-75Q7IDZK-B_H3ereo.js} +5 -5
  59. package/dist/{vega-component-B0qW_XiJ.js → vega-component-C5l0rC2E.js} +2 -2
  60. package/dist/{xychartDiagram-FDP5SA34-BCWhiZdq.js → xychartDiagram-FDP5SA34-CLpc6DE1.js} +2 -2
  61. package/package.json +19 -19
  62. package/src/components/ai/ai-model-dropdown.tsx +5 -34
  63. package/src/components/ai/display-helpers.tsx +32 -0
  64. package/src/components/app-config/ai-config.tsx +265 -9
  65. package/src/components/app-config/app-config-button.tsx +1 -1
  66. package/src/components/app-config/app-config-form.tsx +17 -6
  67. package/src/components/app-config/user-config-form.tsx +5 -1
  68. package/src/core/ai/__tests__/model-registry.test.ts +25 -1
  69. package/src/core/ai/model-registry.ts +36 -4
  70. package/src/hooks/useDebounce.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.15.1-dev20",
3
+ "version": "0.15.1-dev23",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -60,29 +60,29 @@
60
60
  "@open-rpc/client-js": "^1.8.1",
61
61
  "@paralleldrive/cuid2": "^2.2.2",
62
62
  "@radix-ui/colors": "^3.0.0",
63
- "@radix-ui/primitive": "~1.1.2",
64
- "@radix-ui/react-accordion": "~1.2.11",
65
- "@radix-ui/react-alert-dialog": "~1.1.14",
66
- "@radix-ui/react-checkbox": "~1.3.2",
63
+ "@radix-ui/primitive": "~1.1.3",
64
+ "@radix-ui/react-accordion": "~1.2.12",
65
+ "@radix-ui/react-alert-dialog": "~1.1.15",
66
+ "@radix-ui/react-checkbox": "~1.3.3",
67
67
  "@radix-ui/react-compose-refs": "~1.1.2",
68
- "@radix-ui/react-context-menu": "~2.2.15",
69
- "@radix-ui/react-dialog": "~1.1.14",
70
- "@radix-ui/react-dropdown-menu": "~2.1.15",
68
+ "@radix-ui/react-context-menu": "~2.2.16",
69
+ "@radix-ui/react-dialog": "~1.1.15",
70
+ "@radix-ui/react-dropdown-menu": "~2.1.16",
71
71
  "@radix-ui/react-icons": "~1.3.2",
72
72
  "@radix-ui/react-label": "~2.1.7",
73
- "@radix-ui/react-navigation-menu": "~1.2.13",
74
- "@radix-ui/react-popover": "~1.1.14",
73
+ "@radix-ui/react-navigation-menu": "~1.2.14",
74
+ "@radix-ui/react-popover": "~1.1.15",
75
75
  "@radix-ui/react-progress": "~1.1.7",
76
- "@radix-ui/react-radio-group": "~1.3.7",
77
- "@radix-ui/react-scroll-area": "^1.2.9",
78
- "@radix-ui/react-select": "~2.2.5",
79
- "@radix-ui/react-slider": "~1.3.5",
76
+ "@radix-ui/react-radio-group": "~1.3.8",
77
+ "@radix-ui/react-scroll-area": "^1.2.10",
78
+ "@radix-ui/react-select": "~2.2.6",
79
+ "@radix-ui/react-slider": "~1.3.6",
80
80
  "@radix-ui/react-slot": "~1.2.3",
81
- "@radix-ui/react-switch": "~1.2.5",
82
- "@radix-ui/react-tabs": "~1.1.12",
83
- "@radix-ui/react-toast": "~1.2.14",
84
- "@radix-ui/react-toggle": "~1.1.9",
85
- "@radix-ui/react-tooltip": "~1.2.7",
81
+ "@radix-ui/react-switch": "~1.2.6",
82
+ "@radix-ui/react-tabs": "~1.1.13",
83
+ "@radix-ui/react-toast": "~1.2.15",
84
+ "@radix-ui/react-toggle": "~1.1.10",
85
+ "@radix-ui/react-tooltip": "~1.2.8",
86
86
  "@radix-ui/react-use-callback-ref": "~1.1.1",
87
87
  "@radix-ui/react-use-controllable-state": "~1.2.2",
88
88
  "@replit/codemirror-vim": "^6.3.0",
@@ -31,6 +31,7 @@ import {
31
31
  } from "../ui/dropdown-menu";
32
32
  import { Tooltip } from "../ui/tooltip";
33
33
  import { AiProviderIcon } from "./ai-provider-icon";
34
+ import { getCurrentRoleTooltip, getTagColour } from "./display-helpers";
34
35
 
35
36
  interface AIModelDropdownProps {
36
37
  value?: string;
@@ -104,7 +105,7 @@ export const AIModelDropdown = ({
104
105
  </div>
105
106
 
106
107
  <div className="ml-auto flex gap-1">
107
- <Tooltip content={getTagTooltip(role)}>
108
+ <Tooltip content={getCurrentRoleTooltip(role)}>
108
109
  <span
109
110
  key={role}
110
111
  className={`text-xs px-1.5 py-0.5 rounded font-medium ${getTagColour(role)}`}
@@ -248,8 +249,7 @@ const ProviderDropdownContent = ({
248
249
  </>
249
250
  )}
250
251
  {models.map((model) => {
251
- const qualifiedModelId =
252
- `${provider}/${model.model}` as QualifiedModelId;
252
+ const qualifiedModelId: QualifiedModelId = `${provider}/${model.model}`;
253
253
  return (
254
254
  <DropdownMenuSub key={qualifiedModelId}>
255
255
  <DropdownMenuSubTrigger showChevron={false} className="py-2">
@@ -309,7 +309,7 @@ const AiModelDropdownItem = ({
309
309
  );
310
310
  };
311
311
 
312
- const AiModelInfoDisplay = ({
312
+ export const AiModelInfoDisplay = ({
313
313
  model,
314
314
  provider,
315
315
  }: {
@@ -339,7 +339,7 @@ const AiModelInfoDisplay = ({
339
339
  <span
340
340
  key={role}
341
341
  className={`px-2 py-1 text-xs rounded-md font-medium ${getTagColour(role)}`}
342
- title={getTagTooltip(role)}
342
+ title={getCurrentRoleTooltip(role)}
343
343
  >
344
344
  {role}
345
345
  </span>
@@ -374,32 +374,3 @@ function getProviderLabel(provider: ProviderId): string {
374
374
  }
375
375
  return capitalize(provider);
376
376
  }
377
-
378
- function getTagColour(role: Role | "thinking"): string {
379
- switch (role) {
380
- case "chat":
381
- return "bg-[var(--purple-3)] text-[var(--purple-11)]";
382
- case "autocomplete":
383
- return "bg-[var(--green-3)] text-[var(--green-11)]";
384
- case "edit":
385
- return "bg-[var(--blue-3)] text-[var(--blue-11)]";
386
- case "thinking":
387
- return "bg-[var(--purple-4)] text-[var(--purple-12)]";
388
- }
389
- return "bg-[var(--mauve-3)] text-[var(--mauve-11)]";
390
- }
391
-
392
- function getTagTooltip(role: Role): string {
393
- switch (role) {
394
- case "chat":
395
- return "Current model used for chat conversations";
396
- case "autocomplete":
397
- return "Current model used for autocomplete autocomplete";
398
- case "edit":
399
- return "Current model used for code edits";
400
- case "rerank":
401
- return "Current model used for reranking completions";
402
- case "embed":
403
- return "Current model used for embedding";
404
- }
405
- }
@@ -0,0 +1,32 @@
1
+ /* Copyright 2024 Marimo. All rights reserved. */
2
+
3
+ import type { Role } from "@marimo-team/llm-info";
4
+
5
+ export function getTagColour(role: Role | "thinking"): string {
6
+ switch (role) {
7
+ case "chat":
8
+ return "bg-[var(--purple-3)] text-[var(--purple-11)]";
9
+ case "autocomplete":
10
+ return "bg-[var(--green-3)] text-[var(--green-11)]";
11
+ case "edit":
12
+ return "bg-[var(--blue-3)] text-[var(--blue-11)]";
13
+ case "thinking":
14
+ return "bg-[var(--purple-4)] text-[var(--purple-12)]";
15
+ }
16
+ return "bg-[var(--mauve-3)] text-[var(--mauve-11)]";
17
+ }
18
+
19
+ export function getCurrentRoleTooltip(role: Role): string {
20
+ switch (role) {
21
+ case "chat":
22
+ return "Current model used for chat conversations";
23
+ case "autocomplete":
24
+ return "Current model used for autocomplete autocomplete";
25
+ case "edit":
26
+ return "Current model used for code edits";
27
+ case "rerank":
28
+ return "Current model used for reranking completions";
29
+ case "embed":
30
+ return "Current model used for embedding";
31
+ }
32
+ }
@@ -1,8 +1,16 @@
1
1
  /* Copyright 2024 Marimo. All rights reserved. */
2
2
 
3
- import { InfoIcon } from "lucide-react";
4
- import React from "react";
3
+ import { BrainIcon, ChevronRightIcon, InfoIcon } from "lucide-react";
4
+ import React, { useMemo } from "react";
5
+ import {
6
+ Button as AriaButton,
7
+ Tree,
8
+ TreeItem,
9
+ TreeItemContent,
10
+ } from "react-aria-components";
5
11
  import type { FieldPath, UseFormReturn } from "react-hook-form";
12
+ import { useWatch } from "react-hook-form";
13
+ import useEvent from "react-use-event-hook";
6
14
  import {
7
15
  FormControl,
8
16
  FormDescription,
@@ -16,24 +24,34 @@ import { Input } from "@/components/ui/input";
16
24
  import { Kbd } from "@/components/ui/kbd";
17
25
  import { NativeSelect } from "@/components/ui/native-select";
18
26
  import { Textarea } from "@/components/ui/textarea";
19
- import type { QualifiedModelId } from "@/core/ai/ids/ids";
27
+ import {
28
+ AiModelId,
29
+ type ProviderId,
30
+ type QualifiedModelId,
31
+ } from "@/core/ai/ids/ids";
32
+ import { type AiModel, AiModelRegistry } from "@/core/ai/model-registry";
20
33
  import { CopilotConfig } from "@/core/codemirror/copilot/copilot-config";
21
34
  import { DEFAULT_AI_MODEL, type UserConfig } from "@/core/config/config-schema";
22
35
  import { isWasm } from "@/core/wasm/utils";
36
+ import { cn } from "@/utils/cn";
23
37
  import { Events } from "@/utils/events";
38
+ import { Strings } from "@/utils/strings";
24
39
  import { AIModelDropdown } from "../ai/ai-model-dropdown";
25
40
  import {
26
41
  AiProviderIcon,
27
42
  type AiProviderIconProps,
28
43
  } from "../ai/ai-provider-icon";
44
+ import { getTagColour } from "../ai/display-helpers";
29
45
  import {
30
46
  Accordion,
31
47
  AccordionContent,
32
48
  AccordionItem,
33
49
  AccordionTrigger,
34
50
  } from "../ui/accordion";
51
+ import { Checkbox } from "../ui/checkbox";
35
52
  import { DropdownMenuSeparator } from "../ui/dropdown-menu";
36
53
  import { ExternalLink } from "../ui/links";
54
+ import { Switch } from "../ui/switch";
37
55
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
38
56
  import { Tooltip } from "../ui/tooltip";
39
57
  import { SettingSubtitle } from "./common";
@@ -46,7 +64,7 @@ const formItemClasses = "flex flex-row items-center space-x-1 space-y-0";
46
64
  interface AiConfigProps {
47
65
  form: UseFormReturn<UserConfig>;
48
66
  config: UserConfig;
49
- onSubmit: (values: UserConfig) => Promise<void>;
67
+ onSubmit: (values: UserConfig) => void;
50
68
  }
51
69
 
52
70
  interface AiProviderTitleProps {
@@ -189,7 +207,7 @@ interface ModelSelectorProps {
189
207
  description?: React.ReactNode;
190
208
  disabled?: boolean;
191
209
  label: string;
192
- onSubmit: (values: UserConfig) => Promise<void>;
210
+ onSubmit: (values: UserConfig) => void;
193
211
  }
194
212
 
195
213
  export const ModelSelector: React.FC<ModelSelectorProps> = ({
@@ -213,6 +231,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
213
231
 
214
232
  const selectModel = (modelId: QualifiedModelId) => {
215
233
  field.onChange(modelId);
234
+ // Usually not needed, but a hack to force form values to be updated
216
235
  onSubmit(form.getValues());
217
236
  };
218
237
 
@@ -340,7 +359,7 @@ const renderCopilotProvider = ({
340
359
  }: {
341
360
  form: UseFormReturn<UserConfig>;
342
361
  config: UserConfig;
343
- onSubmit: (values: UserConfig) => Promise<void>;
362
+ onSubmit: (values: UserConfig) => void;
344
363
  }) => {
345
364
  const copilot = form.getValues("completion.copilot");
346
365
  if (copilot === false) {
@@ -388,8 +407,88 @@ const renderCopilotProvider = ({
388
407
  }
389
408
  };
390
409
 
391
- const SettingGroup = ({ children }: { children: React.ReactNode }) => {
392
- return <div className="flex flex-col gap-4 pb-4">{children}</div>;
410
+ const SettingGroup = ({
411
+ children,
412
+ className,
413
+ }: {
414
+ children: React.ReactNode;
415
+ className?: string;
416
+ }) => {
417
+ return (
418
+ <div className={cn("flex flex-col gap-4 pb-4", className)}>{children}</div>
419
+ );
420
+ };
421
+
422
+ interface ModelListItemProps {
423
+ qualifiedId: QualifiedModelId;
424
+ model: AiModel;
425
+ isEnabled: boolean;
426
+ onToggle: (modelId: QualifiedModelId) => void;
427
+ }
428
+
429
+ const ModelListItem: React.FC<ModelListItemProps> = ({
430
+ qualifiedId,
431
+ model,
432
+ isEnabled,
433
+ onToggle,
434
+ }) => {
435
+ const handleToggle = () => {
436
+ onToggle(qualifiedId);
437
+ };
438
+
439
+ return (
440
+ <TreeItem
441
+ id={qualifiedId}
442
+ textValue={model.name}
443
+ className="pl-6 outline-none data-focused:bg-muted/50 hover:bg-muted/50"
444
+ onAction={handleToggle}
445
+ >
446
+ <TreeItemContent>
447
+ <div className="flex items-center justify-between px-4 py-3 border-b last:border-b-0 cursor-pointer outline-none">
448
+ <ModelInfoCard model={model} qualifiedId={qualifiedId} />
449
+ <Switch checked={isEnabled} onClick={handleToggle} size="sm" />
450
+ </div>
451
+ </TreeItemContent>
452
+ </TreeItem>
453
+ );
454
+ };
455
+
456
+ const ModelInfoCard = ({
457
+ model,
458
+ qualifiedId,
459
+ }: {
460
+ model: AiModel;
461
+ qualifiedId: QualifiedModelId;
462
+ }) => {
463
+ return (
464
+ <div className="flex items-center gap-3 flex-1">
465
+ <div className="flex flex-col flex-1">
466
+ <div className="flex items-center gap-2">
467
+ <h3 className="font-medium">{model.name}</h3>
468
+ </div>
469
+ <span className="text-xs text-muted-foreground font-mono">
470
+ {qualifiedId}
471
+ </span>
472
+ {model.description && !model.custom && (
473
+ <p className="text-sm text-muted-secondary mt-1 line-clamp-2">
474
+ {model.description}
475
+ </p>
476
+ )}
477
+
478
+ {model.thinking && (
479
+ <div
480
+ className={cn(
481
+ "flex items-center gap-1 rounded px-1 py-0.5 w-fit mt-1.5",
482
+ getTagColour("thinking"),
483
+ )}
484
+ >
485
+ <BrainIcon className="h-3 w-3" />
486
+ <span className="text-xs font-medium">Reasoning</span>
487
+ </div>
488
+ )}
489
+ </div>
490
+ </div>
491
+ );
393
492
  };
394
493
 
395
494
  export const AiCodeCompletionConfig: React.FC<AiConfigProps> = ({
@@ -827,16 +926,170 @@ export const AiAssistConfig: React.FC<AiConfigProps> = ({
827
926
  );
828
927
  };
829
928
 
929
+ interface ProviderTreeItemProps {
930
+ providerId: ProviderId;
931
+ models: AiModel[];
932
+ enabledModels: Set<QualifiedModelId>;
933
+ onToggleModel: (modelId: QualifiedModelId) => void;
934
+ onToggleProvider: (providerId: ProviderId, enable: boolean) => void;
935
+ }
936
+
937
+ const ProviderTreeItem: React.FC<ProviderTreeItemProps> = ({
938
+ providerId,
939
+ models,
940
+ enabledModels,
941
+ onToggleModel,
942
+ onToggleProvider,
943
+ }) => {
944
+ const enabledCount = models.filter((model) =>
945
+ enabledModels.has(new AiModelId(providerId, model.model).id),
946
+ ).length;
947
+ const totalCount = models.length;
948
+ const maybeProviderInfo = AiModelRegistry.getProviderInfo(providerId);
949
+ const name = maybeProviderInfo?.name || Strings.startCase(providerId);
950
+
951
+ const checkboxState =
952
+ enabledCount === 0
953
+ ? false
954
+ : enabledCount === totalCount
955
+ ? true
956
+ : "indeterminate";
957
+
958
+ const handleProviderToggle = useEvent(() => {
959
+ const shouldEnable = enabledCount < totalCount / 2;
960
+ onToggleProvider(providerId, shouldEnable);
961
+ });
962
+
963
+ return (
964
+ <TreeItem
965
+ id={providerId}
966
+ hasChildItems={true}
967
+ textValue={providerId}
968
+ className="outline-none data-focused:bg-muted/50 group"
969
+ >
970
+ <TreeItemContent>
971
+ <div className="flex items-center gap-3 px-3 py-3 hover:bg-muted/50 cursor-pointer outline-none focus-visible:outline-none">
972
+ <Checkbox
973
+ checked={checkboxState}
974
+ onCheckedChange={handleProviderToggle}
975
+ onClick={Events.stopPropagation()}
976
+ />
977
+ <AiProviderIcon provider={providerId} className="h-5 w-5" />
978
+ <div className="flex items-center justify-between w-full">
979
+ <h2 className="font-semibold">{name}</h2>
980
+ <p className="text-sm text-muted-secondary">
981
+ {enabledCount}/{totalCount} models
982
+ </p>
983
+ </div>
984
+ <AriaButton slot="chevron">
985
+ <ChevronRightIcon className="h-4 w-4 text-muted-foreground shrink-0 transition-transform duration-200 group-data-[expanded]:rotate-90" />
986
+ </AriaButton>
987
+ </div>
988
+ </TreeItemContent>
989
+
990
+ {models.map((model) => {
991
+ const qualifiedId = new AiModelId(providerId, model.model).id;
992
+ return (
993
+ <ModelListItem
994
+ key={qualifiedId}
995
+ qualifiedId={qualifiedId}
996
+ model={model}
997
+ isEnabled={enabledModels.has(qualifiedId)}
998
+ onToggle={onToggleModel}
999
+ />
1000
+ );
1001
+ })}
1002
+ </TreeItem>
1003
+ );
1004
+ };
1005
+
1006
+ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
1007
+ form,
1008
+ onSubmit,
1009
+ }) => {
1010
+ const aiModelRegistry = useMemo(
1011
+ () =>
1012
+ AiModelRegistry.create({
1013
+ displayedModels: [],
1014
+ customModels: ["openrouter/deepseek-r1-distill-llama-70b"],
1015
+ }),
1016
+ [],
1017
+ );
1018
+ const currentDisplayedModels = useWatch({
1019
+ control: form.control,
1020
+ name: "ai.models.displayed_models",
1021
+ defaultValue: [],
1022
+ }) as QualifiedModelId[];
1023
+ const currentDisplayedModelsSet = new Set(currentDisplayedModels);
1024
+ const modelsByProvider = aiModelRegistry.getGroupedModelsByProvider();
1025
+
1026
+ const toggleModelDisplay = useEvent((modelId: QualifiedModelId) => {
1027
+ const newModels = currentDisplayedModelsSet.has(modelId)
1028
+ ? currentDisplayedModels.filter((id) => id !== modelId)
1029
+ : [...currentDisplayedModels, modelId];
1030
+
1031
+ form.setValue("ai.models.displayed_models", newModels);
1032
+ onSubmit(form.getValues());
1033
+ });
1034
+
1035
+ const toggleProviderModels = useEvent(
1036
+ async (providerId: ProviderId, enable: boolean) => {
1037
+ const providerModels = modelsByProvider.get(providerId) || [];
1038
+ const qualifiedModelIds = new Set(
1039
+ providerModels.map((m) => new AiModelId(providerId, m.model).id),
1040
+ );
1041
+
1042
+ // If enabled, we add all provider models that aren't already enabled
1043
+ // Else, remove all provider models
1044
+ const newModels: QualifiedModelId[] = enable
1045
+ ? [...new Set([...currentDisplayedModels, ...qualifiedModelIds])]
1046
+ : currentDisplayedModels.filter((id) => !qualifiedModelIds.has(id));
1047
+
1048
+ form.setValue("ai.models.displayed_models", newModels);
1049
+ onSubmit(form.getValues());
1050
+ },
1051
+ );
1052
+
1053
+ return (
1054
+ <SettingGroup>
1055
+ <p className="text-sm text-muted-secondary mb-4">
1056
+ Control which AI models are displayed in model selection dropdowns. When
1057
+ no models are selected, all available models will be shown.
1058
+ </p>
1059
+
1060
+ <div className="border rounded-md bg-background">
1061
+ <Tree
1062
+ aria-label="AI Models by Provider"
1063
+ className="flex-1 overflow-auto outline-none focus-visible:outline-none"
1064
+ selectionMode="none"
1065
+ >
1066
+ {[...modelsByProvider.entries()].map(([providerId, models]) => (
1067
+ <ProviderTreeItem
1068
+ key={providerId}
1069
+ providerId={providerId}
1070
+ models={models}
1071
+ enabledModels={currentDisplayedModelsSet}
1072
+ onToggleModel={toggleModelDisplay}
1073
+ onToggleProvider={toggleProviderModels}
1074
+ />
1075
+ ))}
1076
+ </Tree>
1077
+ </div>
1078
+ </SettingGroup>
1079
+ );
1080
+ };
1081
+
830
1082
  export const AiConfig: React.FC<AiConfigProps> = ({
831
1083
  form,
832
1084
  config,
833
1085
  onSubmit,
834
1086
  }) => {
835
1087
  return (
836
- <Tabs defaultValue="ai-features">
1088
+ <Tabs defaultValue="ai-features" className="flex-1">
837
1089
  <TabsList className="mb-2">
838
1090
  <TabsTrigger value="ai-features">AI Features</TabsTrigger>
839
1091
  <TabsTrigger value="ai-providers">AI Providers</TabsTrigger>
1092
+ <TabsTrigger value="ai-models">AI Models</TabsTrigger>
840
1093
  </TabsList>
841
1094
 
842
1095
  <TabsContent value="ai-features">
@@ -850,6 +1103,9 @@ export const AiConfig: React.FC<AiConfigProps> = ({
850
1103
  <TabsContent value="ai-providers">
851
1104
  <AiProvidersConfig form={form} config={config} onSubmit={onSubmit} />
852
1105
  </TabsContent>
1106
+ <TabsContent value="ai-models">
1107
+ <AiModelDisplayConfig form={form} config={config} onSubmit={onSubmit} />
1108
+ </TabsContent>
853
1109
  </Tabs>
854
1110
  );
855
1111
  };
@@ -51,7 +51,7 @@ export const ConfigButton: React.FC<Props> = ({
51
51
  );
52
52
 
53
53
  const userSettingsDialog = (
54
- <DialogContent className="w-[80vw] h-[70vh] overflow-hidden sm:max-w-5xl top-[15vh] p-0">
54
+ <DialogContent className="w-[90vw] h-[90vh] overflow-hidden sm:max-w-5xl top-[5vh] p-0">
55
55
  <VisuallyHidden>
56
56
  <DialogTitle>User settings</DialogTitle>
57
57
  </VisuallyHidden>
@@ -1,6 +1,6 @@
1
1
  /* Copyright 2024 Marimo. All rights reserved. */
2
2
  import { zodResolver } from "@hookform/resolvers/zod";
3
- import { useEffect } from "react";
3
+ import { useEffect, useId } from "react";
4
4
  import { useForm } from "react-hook-form";
5
5
  import {
6
6
  Form,
@@ -14,6 +14,7 @@ import {
14
14
  import { useAppConfig } from "@/core/config/config";
15
15
  import { getAppWidths } from "@/core/config/widths";
16
16
  import { useRequestClient } from "@/core/network/requests";
17
+ import { useDebouncedCallback } from "@/hooks/useDebounce";
17
18
  import { arrayToggle } from "@/utils/arrays";
18
19
  import {
19
20
  type AppConfig,
@@ -32,9 +33,13 @@ import {
32
33
  SQL_OUTPUT_SELECT_OPTIONS,
33
34
  } from "./common";
34
35
 
36
+ const FORM_DEBOUNCE = 100; // ms;
37
+
35
38
  export const AppConfigForm: React.FC = () => {
36
39
  const [config, setConfig] = useAppConfig();
37
40
  const { saveAppConfig } = useRequestClient();
41
+ const htmlCheckboxId = useId();
42
+ const ipynbCheckboxId = useId();
38
43
 
39
44
  // Create form
40
45
  const form = useForm<AppConfig>({
@@ -52,6 +57,10 @@ export const AppConfigForm: React.FC = () => {
52
57
  });
53
58
  };
54
59
 
60
+ const debouncedSubmit = useDebouncedCallback((v: AppConfig) => {
61
+ onSubmit(v);
62
+ }, FORM_DEBOUNCE);
63
+
55
64
  // When width is changed, dispatch a resize event so widgets know to resize
56
65
  useEffect(() => {
57
66
  window.dispatchEvent(new Event("resize"));
@@ -60,7 +69,7 @@ export const AppConfigForm: React.FC = () => {
60
69
  return (
61
70
  <Form {...form}>
62
71
  <form
63
- onChange={form.handleSubmit(onSubmit)}
72
+ onChange={form.handleSubmit(debouncedSubmit)}
64
73
  className="flex flex-col gap-6"
65
74
  >
66
75
  <div>
@@ -253,23 +262,25 @@ export const AppConfigForm: React.FC = () => {
253
262
  <div className="flex gap-4">
254
263
  <div className="flex items-center space-x-2">
255
264
  <Checkbox
256
- id="html-checkbox"
265
+ id={htmlCheckboxId}
266
+ data-testid="html-checkbox"
257
267
  checked={field.value.includes("html")}
258
268
  onCheckedChange={() => {
259
269
  field.onChange(arrayToggle(field.value, "html"));
260
270
  }}
261
271
  />
262
- <FormLabel htmlFor="html-checkbox">HTML</FormLabel>
272
+ <FormLabel htmlFor={htmlCheckboxId}>HTML</FormLabel>
263
273
  </div>
264
274
  <div className="flex items-center space-x-2">
265
275
  <Checkbox
266
- id="ipynb-checkbox"
276
+ id={ipynbCheckboxId}
277
+ data-testid="ipynb-checkbox"
267
278
  checked={field.value.includes("ipynb")}
268
279
  onCheckedChange={() => {
269
280
  field.onChange(arrayToggle(field.value, "ipynb"));
270
281
  }}
271
282
  />
272
- <FormLabel htmlFor="ipynb-checkbox">IPYNB</FormLabel>
283
+ <FormLabel htmlFor={ipynbCheckboxId}>IPYNB</FormLabel>
273
284
  </div>
274
285
  </div>
275
286
  </FormControl>
@@ -41,6 +41,7 @@ import { getAppWidths } from "@/core/config/widths";
41
41
  import { marimoVersionAtom } from "@/core/meta/state";
42
42
  import { useRequestClient } from "@/core/network/requests";
43
43
  import { isWasm } from "@/core/wasm/utils";
44
+ import { useDebouncedCallback } from "@/hooks/useDebounce";
44
45
  import { Banner } from "@/plugins/impl/common/error-banner";
45
46
  import { THEMES } from "@/theme/useTheme";
46
47
  import { arrayToggle } from "@/utils/arrays";
@@ -106,6 +107,8 @@ export const activeUserConfigCategoryAtom = atom<SettingCategoryId>(
106
107
  categories[0].id,
107
108
  );
108
109
 
110
+ const FORM_DEBOUNCE = 100; // ms;
111
+
109
112
  export const UserConfigForm: React.FC = () => {
110
113
  const [config, setConfig] = useUserConfig();
111
114
  const formElement = useRef<HTMLFormElement>(null);
@@ -123,11 +126,12 @@ export const UserConfigForm: React.FC = () => {
123
126
  defaultValues: config,
124
127
  });
125
128
 
126
- const onSubmit = async (values: UserConfig) => {
129
+ const onSubmitNotDebounced = async (values: UserConfig) => {
127
130
  await saveUserConfig({ config: values }).then(() => {
128
131
  setConfig(values);
129
132
  });
130
133
  };
134
+ const onSubmit = useDebouncedCallback(onSubmitNotDebounced, FORM_DEBOUNCE);
131
135
 
132
136
  const isWasmRuntime = isWasm();
133
137
  const htmlCheckboxId = useId();