@marimo-team/frontend 0.15.1-dev21 → 0.15.1-dev24

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 (110) hide show
  1. package/dist/assets/{ConnectedDataExplorerComponent-DI8PuV5X.js → ConnectedDataExplorerComponent-A1945Aso.js} +1 -1
  2. package/dist/assets/{ImageComparisonComponent-Cydt19DD.js → ImageComparisonComponent-D1NsIUty.js} +1 -1
  3. package/dist/assets/{VegaLite-D9D0ESA1.js → VegaLite-BGGfcUZn.js} +1 -1
  4. package/dist/assets/{_baseEach-BS8weAss.js → _baseEach-D3nH_2xn.js} +1 -1
  5. package/dist/assets/_baseMap-DLtMmtEG.js +1 -0
  6. package/dist/assets/{_baseUniq-CkjD1ygk.js → _baseUniq-CQqCX6A5.js} +1 -1
  7. package/dist/assets/{_createAggregator-DuvsvnFo.js → _createAggregator-CcFqySRL.js} +1 -1
  8. package/dist/assets/{any-language-editor-CHNYIyn-.js → any-language-editor-BCrWEpOh.js} +1 -1
  9. package/dist/assets/{architectureDiagram-KFL7JDKH-CLrtUH7c.js → architectureDiagram-KFL7JDKH-Bi-k1FzL.js} +1 -1
  10. package/dist/assets/{blockDiagram-ZYB65J3Q-EntF03ZT.js → blockDiagram-ZYB65J3Q-Dmpjl7Rr.js} +1 -1
  11. package/dist/assets/{c4Diagram-AAMF2YG6-Blu3DDZN.js → c4Diagram-AAMF2YG6-adiBTZJE.js} +1 -1
  12. package/dist/assets/channel-7kaxt7IF.js +1 -0
  13. package/dist/assets/{chunk-ANTBXLJU-B0XDM0dC.js → chunk-ANTBXLJU-DwXFMulX.js} +1 -1
  14. package/dist/assets/{chunk-FHKO5MBM-BDSnLIti.js → chunk-FHKO5MBM-xaGLB2hd.js} +1 -1
  15. package/dist/assets/{chunk-GLLZNHP4-Bh0ezhHX.js → chunk-GLLZNHP4-Cit8A-EP.js} +1 -1
  16. package/dist/assets/{chunk-JBRWN2VN-BiIV13Fm.js → chunk-JBRWN2VN-bXiYdBHm.js} +1 -1
  17. package/dist/assets/{chunk-LXBSTHXV-CjiknDm3.js → chunk-LXBSTHXV-BP4Soimz.js} +1 -1
  18. package/dist/assets/{chunk-NRVI72HA-CK-IO-Lt.js → chunk-NRVI72HA-Caq7CUfs.js} +1 -1
  19. package/dist/assets/{chunk-OMD6QJNC-DQThOiHU.js → chunk-OMD6QJNC-54ZKr9c_.js} +1 -1
  20. package/dist/assets/{chunk-WVR4S24B-CoVzJW5S.js → chunk-WVR4S24B-BdZs7vKR.js} +1 -1
  21. package/dist/assets/{circle-play-CMvzTUv2.js → circle-play-BDHXCb0Y.js} +1 -1
  22. package/dist/assets/classDiagram-3BZAVTQC-Djy0cY4g.js +1 -0
  23. package/dist/assets/classDiagram-v2-QTMF73CY-Djy0cY4g.js +1 -0
  24. package/dist/assets/clone-BwvK2dmW.js +1 -0
  25. package/dist/assets/{compile-DeSvAW_S.js → compile-CGvn-4st.js} +6 -6
  26. package/dist/assets/{dagre-2BBEFEWP-B7JdlZh6.js → dagre-2BBEFEWP-BEa1obN_.js} +1 -1
  27. package/dist/assets/{data-grid-overlay-editor-xQpSlILH.js → data-grid-overlay-editor-BsrLkNwc.js} +1 -1
  28. package/dist/assets/{diagram-4IRLE6MV-CJtm8aAj.js → diagram-4IRLE6MV-Cf3FnnYp.js} +1 -1
  29. package/dist/assets/{diagram-GUPCWM2R-dqDutj9I.js → diagram-GUPCWM2R-Cty98at3.js} +1 -1
  30. package/dist/assets/{diagram-RP2FKANI-BUI25egB.js → diagram-RP2FKANI-DWVcFwOr.js} +1 -1
  31. package/dist/assets/{edit-page-DgCMQn88.js → edit-page-DMaQm3LE.js} +4 -4
  32. package/dist/assets/{erDiagram-HZWUO2LU-N5g9HUt7.js → erDiagram-HZWUO2LU-CyO1eOOE.js} +1 -1
  33. package/dist/assets/{flowDiagram-THRYKUMA-DNwrYmgV.js → flowDiagram-THRYKUMA-CRj5oK-a.js} +1 -1
  34. package/dist/assets/{ganttDiagram-WV7ZQ7D5-mQd7z33c.js → ganttDiagram-WV7ZQ7D5-2EImeZRg.js} +1 -1
  35. package/dist/assets/{gitGraphDiagram-OJR772UL-DvnF-wdt.js → gitGraphDiagram-OJR772UL-B9tcK9BT.js} +1 -1
  36. package/dist/assets/{glide-data-editor-C5Ch0iiT.js → glide-data-editor-BvISYI3t.js} +11 -11
  37. package/dist/assets/{graph-C-Iro-98.js → graph-D9tj17QN.js} +1 -1
  38. package/dist/assets/{home-page-C5EjMoKW.js → home-page-p4uSMJRW.js} +1 -1
  39. package/dist/assets/{index-BVyKJt-z.js → index-0xnfM4P4.js} +1 -1
  40. package/dist/assets/index-B6q_Qknx.css +1 -0
  41. package/dist/assets/{index-s_uHGr3T.js → index-BII3QszR.js} +1 -1
  42. package/dist/assets/{index-B90mn1cG.js → index-BRMp4xor.js} +1 -1
  43. package/dist/assets/{index-B2jj3E5N.js → index-Boz2pcJb.js} +1 -1
  44. package/dist/assets/{index-SAoUDfmg.js → index-BuFQpizN.js} +1 -1
  45. package/dist/assets/{index-DnM8axZy.js → index-BytvWT4g.js} +1 -1
  46. package/dist/assets/{index-DrbiW8rT.js → index-C3iPkq79.js} +1 -1
  47. package/dist/assets/{index-DR1uico1.js → index-CLUiiUWQ.js} +1 -1
  48. package/dist/assets/{index-DdoTFUIu.js → index-COVExp2x.js} +1 -1
  49. package/dist/assets/{index-DW0h2xO3.js → index-CTYfpi2J.js} +179 -179
  50. package/dist/assets/{index-DkY7lPGG.js → index-CqyRpjaT.js} +1 -1
  51. package/dist/assets/{index-CBOMBtm6.js → index-CvsUfIK9.js} +1 -1
  52. package/dist/assets/{index-ChyWupKR.js → index-DcxPzpNS.js} +1 -1
  53. package/dist/assets/{index-DTDV7w22.js → index-DgQigf_R.js} +1 -1
  54. package/dist/assets/{index-CMWj-_2o.js → index-DgR-q7Mi.js} +1 -1
  55. package/dist/assets/{index-CP8UjTEa.js → index-DiBzOhAN.js} +1 -1
  56. package/dist/assets/{index-DxY2klc7.js → index-H-k8tqNe.js} +1 -1
  57. package/dist/assets/{index-u0Rl_owT.js → index-_uCXr0qE.js} +1 -1
  58. package/dist/assets/{index-DbQbK887.js → index-eROqpTg3.js} +1 -1
  59. package/dist/assets/{index-DKBEnoCk.js → index-esTG9RN-.js} +1 -1
  60. package/dist/assets/infoDiagram-6WOFNB3A-DRPEjZ3d.js +2 -0
  61. package/dist/assets/{journeyDiagram-FFXJYRFH-C7BGFWCW.js → journeyDiagram-FFXJYRFH-D-bCUgMG.js} +1 -1
  62. package/dist/assets/{kanban-definition-KOZQBZVT-Cab9KrS8.js → kanban-definition-KOZQBZVT-B85i5eC4.js} +1 -1
  63. package/dist/assets/{layout-LLSAGNTQ.js → layout-BGrsINEZ.js} +1 -1
  64. package/dist/assets/{linear-CvMU2WEj.js → linear-pcSEPOwJ.js} +1 -1
  65. package/dist/assets/links-DZr8KDfw.js +17 -0
  66. package/dist/assets/{mermaid-DGyTN34T.js → mermaid-Bu7PGvSJ.js} +4 -4
  67. package/dist/assets/{min-5zlMJgDl.js → min-BIbDgP5-.js} +1 -1
  68. package/dist/assets/{mindmap-definition-LNHGMQRG-C0EUWJZC.js → mindmap-definition-LNHGMQRG-tCRSnTL_.js} +1 -1
  69. package/dist/assets/{number-overlay-editor-D1v8R3el.js → number-overlay-editor-Cmym0e23.js} +1 -1
  70. package/dist/assets/{pieDiagram-DBDJKBY4-BGp8rg-Z.js → pieDiagram-DBDJKBY4-DpkXENah.js} +1 -1
  71. package/dist/assets/{quadrantDiagram-YPSRARAO-Cf1A0Nwv.js → quadrantDiagram-YPSRARAO-CeVsWIL4.js} +1 -1
  72. package/dist/assets/{react-plotly-Bna0TbQO.js → react-plotly-bhihEFAw.js} +1 -1
  73. package/dist/assets/{requirementDiagram-EGVEC5DT-DNIgSwtq.js → requirementDiagram-EGVEC5DT-BvoCOWRy.js} +1 -1
  74. package/dist/assets/{run-page-hEJWQNg8.js → run-page-DbSZPud1.js} +1 -1
  75. package/dist/assets/{sankeyDiagram-HRAUVNP4-DolsCVlt.js → sankeyDiagram-HRAUVNP4-Cu7q4U4c.js} +1 -1
  76. package/dist/assets/{sequenceDiagram-WFGC7UMF-KEQD8Lhz.js → sequenceDiagram-WFGC7UMF-Bxyr66jz.js} +1 -1
  77. package/dist/assets/{slides-component-BT7Lwlm_.js → slides-component-CR8YzEwo.js} +1 -1
  78. package/dist/assets/{sortBy-BZqtISER.js → sortBy-viAGx73S.js} +1 -1
  79. package/dist/assets/{stateDiagram-UUKSUZ4H-BeoBHbko.js → stateDiagram-UUKSUZ4H-DJJ0rAf0.js} +1 -1
  80. package/dist/assets/stateDiagram-v2-EYPG3UTE-CT0Gpq7L.js +1 -0
  81. package/dist/assets/{storage-p5tY14Rz.js → storage-TM0i2hq2.js} +3 -3
  82. package/dist/assets/{terminal-CTACBtXY.js → terminal-BbRCg6oH.js} +1 -1
  83. package/dist/assets/{time-C3-PfBzk.js → time-B4nktxEd.js} +1 -1
  84. package/dist/assets/{timeline-definition-3HZDQTIS-BhlvUdfK.js → timeline-definition-3HZDQTIS-6vQ8-7Z5.js} +1 -1
  85. package/dist/assets/{tracing-C_hffGHO.js → tracing-Cd6LslAY.js} +2 -2
  86. package/dist/assets/{trash-CI33fLk_.js → trash-DROPVtiT.js} +1 -1
  87. package/dist/assets/{treemap-75Q7IDZK-F24PIY_Z.js → treemap-75Q7IDZK-Bfo3G5Mq.js} +1 -1
  88. package/dist/assets/{vega-component-BqW0nLmz.js → vega-component-BKsG4gHo.js} +1 -1
  89. package/dist/assets/{xychartDiagram-FDP5SA34-CzJfdQ6I.js → xychartDiagram-FDP5SA34-BgTU9DKZ.js} +1 -1
  90. package/dist/index.html +2 -2
  91. package/package.json +1 -1
  92. package/src/components/ai/ai-model-dropdown.tsx +5 -34
  93. package/src/components/ai/display-helpers.tsx +32 -0
  94. package/src/components/app-config/ai-config.tsx +265 -9
  95. package/src/components/app-config/app-config-button.tsx +1 -1
  96. package/src/components/app-config/app-config-form.tsx +17 -6
  97. package/src/components/app-config/user-config-form.tsx +5 -1
  98. package/src/core/ai/__tests__/model-registry.test.ts +25 -1
  99. package/src/core/ai/model-registry.ts +36 -4
  100. package/src/hooks/useDebounce.ts +2 -1
  101. package/src/theme/useTheme.ts +32 -2
  102. package/dist/assets/_baseMap-BAU9O30m.js +0 -1
  103. package/dist/assets/channel-D6FK50wF.js +0 -1
  104. package/dist/assets/classDiagram-3BZAVTQC-DevkEUfS.js +0 -1
  105. package/dist/assets/classDiagram-v2-QTMF73CY-DevkEUfS.js +0 -1
  106. package/dist/assets/clone-C6Q_w1AW.js +0 -1
  107. package/dist/assets/index-QKsgBG6q.css +0 -1
  108. package/dist/assets/infoDiagram-6WOFNB3A-DaVIjncr.js +0 -2
  109. package/dist/assets/links-BxFGCvSR.js +0 -17
  110. package/dist/assets/stateDiagram-v2-EYPG3UTE-DuX7riRK.js +0 -1
@@ -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();
@@ -78,21 +78,45 @@ describe("AiModelRegistry", () => {
78
78
  const displayedModels = ["openai/gpt-4", "anthropic/claude-3-sonnet"];
79
79
  const registry = AiModelRegistry.create({ displayedModels });
80
80
 
81
+ const ids = [...registry.getModelsMap().keys()];
82
+ expect(ids).toEqual(["openai/gpt-4", "anthropic/claude-3-sonnet"]);
81
83
  expect(registry.getCustomModels()).toEqual(new Set());
82
84
  expect(registry.getDisplayedModels()).toEqual(new Set(displayedModels));
83
85
  });
84
86
 
85
87
  it("should create registry with both custom and displayed models", () => {
86
88
  const customModels = ["openai/custom-gpt"];
87
- const displayedModels = ["openai/gpt-4", "anthropic/claude-3-sonnet"];
89
+ const displayedModels = ["openai/custom-gpt"];
88
90
  const registry = AiModelRegistry.create({
89
91
  customModels,
90
92
  displayedModels,
91
93
  });
92
94
 
95
+ const ids = [...registry.getModelsMap().keys()];
96
+ expect(ids).toEqual(["openai/custom-gpt"]);
93
97
  expect(registry.getCustomModels()).toEqual(new Set(customModels));
94
98
  expect(registry.getDisplayedModels()).toEqual(new Set(displayedModels));
95
99
  });
100
+
101
+ it("should create registry with non-existent displayed_model", () => {
102
+ const customModels = ["openai/custom-gpt"];
103
+ const displayedModels = ["something-wrong/model-id"];
104
+ const registry = AiModelRegistry.create({
105
+ customModels,
106
+ displayedModels,
107
+ });
108
+
109
+ const ids = [...registry.getModelsMap().keys()];
110
+ // Include custom and all default ones.
111
+ expect(ids).toEqual([
112
+ "openai/custom-gpt",
113
+ "openai/gpt-4",
114
+ "anthropic/claude-3-sonnet",
115
+ "google/gemini-pro",
116
+ "openai/multi-model",
117
+ "anthropic/multi-model",
118
+ ]);
119
+ });
96
120
  });
97
121
 
98
122
  describe("getModelsByProvider", () => {
@@ -7,6 +7,7 @@ import type {
7
7
  } from "@marimo-team/llm-info";
8
8
  import { models } from "@marimo-team/llm-info/models.json";
9
9
  import { providers } from "@marimo-team/llm-info/providers.json";
10
+ import { Logger } from "@/utils/Logger";
10
11
  import { MultiMap } from "@/utils/multi-map";
11
12
  import { once } from "@/utils/once";
12
13
  import type { ProviderId } from "./ids/ids";
@@ -14,6 +15,7 @@ import { AiModelId, type QualifiedModelId, type ShortModelId } from "./ids/ids";
14
15
 
15
16
  export interface AiModel extends AiModelType {
16
17
  roles: Role[];
18
+ model: ShortModelId;
17
19
  providers: ProviderId[];
18
20
  /** Whether this is a custom model. */
19
21
  custom: boolean;
@@ -25,6 +27,7 @@ const getKnownModelMap = once((): ReadonlyMap<QualifiedModelId, AiModel> => {
25
27
  const modelId = model.model as ShortModelId;
26
28
  const modelInfo: AiModel = {
27
29
  ...model,
30
+ model: model.model as ShortModelId,
28
31
  roles: model.roles.map((role) => role as Role),
29
32
  providers: model.providers as ProviderId[],
30
33
  custom: false,
@@ -84,14 +87,42 @@ export class AiModelRegistry {
84
87
  * Builds the maps of models by provider and custom models.
85
88
  */
86
89
  private buildMaps() {
87
- const displayedModels = this.displayedModels;
90
+ let result = AiModelRegistry.buildMapsFromConfig({
91
+ displayedModels: this.displayedModels,
92
+ customModels: this.customModels,
93
+ });
94
+
95
+ // If we got zero results, then build the maps with no displayedModels
96
+ // This can happen if displayedModels is configured to non existent models
97
+ if (result.modelsMap.size === 0) {
98
+ Logger.error(
99
+ "The configured displayed_models have filtered out all registered models. Reverting back to showing all models.",
100
+ [...this.displayedModels],
101
+ );
102
+
103
+ result = AiModelRegistry.buildMapsFromConfig({
104
+ displayedModels: new Set(),
105
+ customModels: this.customModels,
106
+ });
107
+ }
108
+
109
+ this.modelsByProviderMap = result.modelsByProviderMap;
110
+ this.modelsMap = result.modelsMap;
111
+ }
112
+
113
+ private static buildMapsFromConfig(opts: {
114
+ customModels: ReadonlySet<QualifiedModelId>;
115
+ displayedModels: ReadonlySet<QualifiedModelId>;
116
+ }) {
117
+ const { displayedModels, customModels } = opts;
88
118
  const hasDisplayedModels = displayedModels.size > 0;
89
119
  const knownModelMap = getKnownModelMap();
90
120
  const customModelsMap = new Map<QualifiedModelId, AiModel>();
91
121
 
92
122
  let modelsMap = new Map<QualifiedModelId, AiModel>();
123
+ const modelsByProviderMap = new MultiMap<ProviderId, AiModel>();
93
124
 
94
- for (const model of this.customModels) {
125
+ for (const model of customModels) {
95
126
  if (hasDisplayedModels && !displayedModels.has(model)) {
96
127
  continue;
97
128
  }
@@ -121,15 +152,16 @@ export class AiModelRegistry {
121
152
  }
122
153
 
123
154
  // Set custom models first, then known models
155
+ // Known models will overwrite custom models (which is desired)
124
156
  modelsMap = new Map([...customModelsMap, ...modelsMap]);
125
157
 
126
158
  // Group by provider
127
159
  for (const [qualifiedModelId, model] of modelsMap.entries()) {
128
160
  const modelId = AiModelId.parse(qualifiedModelId);
129
- this.modelsByProviderMap.add(modelId.providerId, model);
161
+ modelsByProviderMap.add(modelId.providerId, model);
130
162
  }
131
163
 
132
- this.modelsMap = modelsMap;
164
+ return { modelsByProviderMap, modelsMap };
133
165
  }
134
166
 
135
167
  getDisplayedModels() {
@@ -84,7 +84,8 @@ export function useDebounceControlledState<T>(opts: {
84
84
  };
85
85
  }
86
86
 
87
- export function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
+ export function useDebouncedCallback<T extends (...args: any[]) => unknown>(
88
89
  callback: T,
89
90
  delay: number,
90
91
  ) {
@@ -25,8 +25,7 @@ const themeAtom = atom((get) => {
25
25
  if (
26
26
  document.body.dataset.theme === "dark" ||
27
27
  document.body.dataset.mode === "dark" ||
28
- document.body.dataset.vscodeThemeKind === "vscode-dark" ||
29
- document.body.dataset.vscodeThemeKind === "vscode-high-contrast"
28
+ getVsCodeTheme() === "dark"
30
29
  ) {
31
30
  return "dark";
32
31
  }
@@ -73,8 +72,39 @@ function setupThemeListener(): void {
73
72
  }
74
73
  setupThemeListener();
75
74
 
75
+ function getVsCodeTheme(): "light" | "dark" | undefined {
76
+ const kind = document.body.dataset.vscodeThemeKind;
77
+ if (kind === "vscode-dark") {
78
+ return "dark";
79
+ } else if (kind === "vscode-high-contrast") {
80
+ return "dark";
81
+ } else if (kind === "vscode-light") {
82
+ return "light";
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ const codeThemeAtom = atom<"light" | "dark" | undefined>(getVsCodeTheme());
88
+
89
+ function setupVsCodeThemeListener() {
90
+ const observer = new MutationObserver(() => {
91
+ const theme = getVsCodeTheme();
92
+ store.set(codeThemeAtom, theme);
93
+ });
94
+ observer.observe(document.body, {
95
+ attributes: true,
96
+ attributeFilter: ["data-vscode-theme-kind"],
97
+ });
98
+ return () => observer.disconnect();
99
+ }
100
+ setupVsCodeThemeListener();
101
+
76
102
  export const resolvedThemeAtom = atom((get) => {
77
103
  const theme = get(themeAtom);
104
+ const codeTheme = get(codeThemeAtom);
105
+ if (codeTheme !== undefined) {
106
+ return codeTheme;
107
+ }
78
108
  const prefersDarkMode = get(prefersDarkModeAtom);
79
109
  return theme === "system" ? (prefersDarkMode ? "dark" : "light") : theme;
80
110
  });
@@ -1 +0,0 @@
1
- import{b as m}from"./_baseEach-BS8weAss.js";import{x as s}from"./index-DW0h2xO3.js";function e(r,o){var a=-1,t=s(r)?Array(r.length):[];return m(r,function(n,f,i){t[++a]=o(n,f,i)}),t}export{e as b};
@@ -1 +0,0 @@
1
- import{U as s,C as o}from"./mermaid-DGyTN34T.js";const n=(a,r)=>s.lang.round(o.parse(a)[r]);export{n as c};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as t,C as o}from"./chunk-JBRWN2VN-BiIV13Fm.js";import{_ as e}from"./mermaid-DGyTN34T.js";import"./transform-B8bpuzxV.js";import"./chunk-GLLZNHP4-Bh0ezhHX.js";import"./chunk-WVR4S24B-CoVzJW5S.js";import"./chunk-NRVI72HA-CK-IO-Lt.js";import"./index-DW0h2xO3.js";import"./step-BwsUM5iJ.js";import"./timer-BwIYMJWC.js";var i={parser:t,get db(){return new o},renderer:s,styles:a,init:e(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{i as diagram};