@open-mercato/ui 0.6.1-develop.3227.1.2b7b8ab258 → 0.6.1-develop.3256.1.fe3dec2464

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 (41) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +13 -0
  3. package/dist/ai/AiAssistantLauncher.js +8 -0
  4. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  5. package/dist/ai/AiChat.js +226 -58
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/AiDock.js +3 -2
  8. package/dist/ai/AiDock.js.map +2 -2
  9. package/dist/ai/ModelPicker.js +186 -0
  10. package/dist/ai/ModelPicker.js.map +7 -0
  11. package/dist/ai/index.js +3 -1
  12. package/dist/ai/index.js.map +2 -2
  13. package/dist/ai/useAiChat.js +12 -16
  14. package/dist/ai/useAiChat.js.map +2 -2
  15. package/dist/backend/operations/LastOperationBanner.js +15 -9
  16. package/dist/backend/operations/LastOperationBanner.js.map +2 -2
  17. package/dist/backend/operations/store.js +64 -8
  18. package/dist/backend/operations/store.js.map +2 -2
  19. package/dist/backend/utils/bulkDelete.js +46 -0
  20. package/dist/backend/utils/bulkDelete.js.map +7 -0
  21. package/package.json +3 -3
  22. package/src/ai/AiAssistantLauncher.tsx +8 -0
  23. package/src/ai/AiChat.tsx +229 -11
  24. package/src/ai/AiDock.tsx +3 -2
  25. package/src/ai/ModelPicker.tsx +248 -0
  26. package/src/ai/__tests__/AiAssistantLauncher.test.tsx +58 -0
  27. package/src/ai/__tests__/AiChat.conversation.test.tsx +16 -0
  28. package/src/ai/__tests__/AiChat.debug.test.tsx +16 -0
  29. package/src/ai/__tests__/AiChat.registry.test.tsx +16 -0
  30. package/src/ai/__tests__/AiChat.test.tsx +180 -0
  31. package/src/ai/__tests__/AiChat.thinking-unmount.test.tsx +16 -0
  32. package/src/ai/__tests__/AiDock.test.tsx +12 -1
  33. package/src/ai/__tests__/ModelPicker.test.tsx +252 -0
  34. package/src/ai/index.ts +1 -0
  35. package/src/ai/useAiChat.ts +29 -16
  36. package/src/backend/operations/LastOperationBanner.tsx +19 -11
  37. package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +46 -1
  38. package/src/backend/operations/__tests__/store.test.ts +142 -0
  39. package/src/backend/operations/store.ts +76 -7
  40. package/src/backend/utils/__tests__/bulkDelete.test.ts +103 -0
  41. package/src/backend/utils/bulkDelete.ts +77 -0
package/dist/ai/AiChat.js CHANGED
@@ -26,7 +26,11 @@ import { Button } from "../primitives/button.js";
26
26
  import { IconButton } from "../primitives/icon-button.js";
27
27
  import { Label } from "../primitives/label.js";
28
28
  import { Textarea } from "../primitives/textarea.js";
29
+ import { apiCall } from "../backend/utils/apiCall.js";
29
30
  import { parseAiContentSegments } from "./AiMessageContent.js";
31
+ import {
32
+ ModelPicker
33
+ } from "./ModelPicker.js";
30
34
  import { RecordCard } from "./records/RecordCard.js";
31
35
  import {
32
36
  defaultAiUiPartRegistry,
@@ -38,6 +42,73 @@ import {
38
42
  import { useAiChatUpload } from "./useAiChatUpload.js";
39
43
  import { useAiShortcuts } from "./useAiShortcuts.js";
40
44
  const PREVIEW_DATA_URL_MAX_BYTES = 2 * 1024 * 1024;
45
+ const COMPACT_FOOTER_MAX_WIDTH = 640;
46
+ const MODEL_PICKER_STORAGE_PREFIX = "om-ai-model-picker:";
47
+ function readModelPickerValue(agentId) {
48
+ if (typeof window === "undefined") return null;
49
+ try {
50
+ const raw = window.localStorage.getItem(`${MODEL_PICKER_STORAGE_PREFIX}${agentId}`);
51
+ if (!raw) return null;
52
+ const parsed = JSON.parse(raw);
53
+ if (parsed && typeof parsed === "object" && typeof parsed.providerId === "string" && typeof parsed.modelId === "string") {
54
+ const value = parsed;
55
+ return { providerId: value.providerId, modelId: value.modelId };
56
+ }
57
+ return null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+ function writeModelPickerValue(agentId, value) {
63
+ if (typeof window === "undefined") return;
64
+ try {
65
+ const key = `${MODEL_PICKER_STORAGE_PREFIX}${agentId}`;
66
+ if (value === null) {
67
+ window.localStorage.removeItem(key);
68
+ } else {
69
+ window.localStorage.setItem(key, JSON.stringify({ providerId: value.providerId, modelId: value.modelId }));
70
+ }
71
+ } catch {
72
+ }
73
+ }
74
+ function useAgentModels(agent) {
75
+ const [providers, setProviders] = React.useState([]);
76
+ const [allowRuntimeModelOverride, setAllowRuntimeModelOverride] = React.useState(false);
77
+ const [defaultLabel, setDefaultLabel] = React.useState(null);
78
+ const [loaded, setLoaded] = React.useState(false);
79
+ React.useEffect(() => {
80
+ const modelsUrl = `/api/ai_assistant/ai/agents/${encodeURIComponent(agent)}/models`;
81
+ setLoaded(false);
82
+ setDefaultLabel(null);
83
+ void apiCall(modelsUrl).then((result) => {
84
+ if (!result.ok || !result.result) {
85
+ setLoaded(true);
86
+ return;
87
+ }
88
+ setAllowRuntimeModelOverride(result.result.allowRuntimeModelOverride);
89
+ setProviders(result.result.providers);
90
+ setDefaultLabel(
91
+ result.result.defaultProviderName && result.result.defaultModelName ? `${result.result.defaultProviderName} / ${result.result.defaultModelName}` : result.result.defaultProviderId && result.result.defaultModelId ? `${result.result.defaultProviderId} / ${result.result.defaultModelId}` : null
92
+ );
93
+ setLoaded(true);
94
+ });
95
+ }, [agent]);
96
+ return { providers, allowRuntimeModelOverride, defaultLabel, loaded };
97
+ }
98
+ function firstAvailableModelPickerValue(providers) {
99
+ for (const provider of providers) {
100
+ const model = provider.models[0];
101
+ if (model) {
102
+ return { providerId: provider.id, modelId: model.id };
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+ function isModelPickerValueAvailable(value, providers) {
108
+ return providers.some(
109
+ (provider) => provider.id === value.providerId && provider.models.some((model) => model.id === value.modelId)
110
+ );
111
+ }
41
112
  async function readFileAsDataUrl(file) {
42
113
  if (!file.type.startsWith("image/")) return void 0;
43
114
  if (file.size > PREVIEW_DATA_URL_MAX_BYTES) return void 0;
@@ -493,7 +564,8 @@ function AiChat({
493
564
  suggestions,
494
565
  contextItems,
495
566
  welcomeTitle,
496
- welcomeDescription
567
+ welcomeDescription,
568
+ defaultCompactFooter = false
497
569
  }) {
498
570
  const t = useT();
499
571
  const textareaRef = React.useRef(null);
@@ -503,6 +575,23 @@ function AiChat({
503
575
  const [pendingFiles, setPendingFiles] = React.useState([]);
504
576
  const upload = useAiChatUpload();
505
577
  const isUploading = upload.busy;
578
+ const footerRef = React.useRef(null);
579
+ const [isCompactFooter, setIsCompactFooter] = React.useState(defaultCompactFooter);
580
+ React.useEffect(() => {
581
+ const element = footerRef.current;
582
+ if (!element || typeof ResizeObserver === "undefined") return;
583
+ const updateCompactState = (width) => {
584
+ setIsCompactFooter(width < COMPACT_FOOTER_MAX_WIDTH);
585
+ };
586
+ updateCompactState(element.getBoundingClientRect().width);
587
+ const observer = new ResizeObserver((entries) => {
588
+ const entry = entries[0];
589
+ if (!entry) return;
590
+ updateCompactState(entry.contentRect.width);
591
+ });
592
+ observer.observe(element);
593
+ return () => observer.disconnect();
594
+ }, []);
506
595
  const uploadedAttachmentIds = React.useMemo(
507
596
  () => pendingFiles.map((entry) => entry.attachmentId).filter((id) => typeof id === "string" && id.length > 0),
508
597
  [pendingFiles]
@@ -511,6 +600,60 @@ function AiChat({
511
600
  () => [...attachmentIds ?? [], ...uploadedAttachmentIds],
512
601
  [attachmentIds, uploadedAttachmentIds]
513
602
  );
603
+ const {
604
+ providers: modelProviders,
605
+ allowRuntimeModelOverride,
606
+ defaultLabel: modelDefaultLabel,
607
+ loaded: modelProvidersLoaded
608
+ } = useAgentModels(agent);
609
+ const [modelPickerValue, setModelPickerValue] = React.useState(
610
+ () => readModelPickerValue(agent)
611
+ );
612
+ const effectiveModelPickerValue = React.useMemo(() => {
613
+ if (!modelProvidersLoaded || !allowRuntimeModelOverride || modelProviders.length === 0) {
614
+ return null;
615
+ }
616
+ if (modelPickerValue && isModelPickerValueAvailable(modelPickerValue, modelProviders)) {
617
+ return modelPickerValue;
618
+ }
619
+ return firstAvailableModelPickerValue(modelProviders);
620
+ }, [
621
+ allowRuntimeModelOverride,
622
+ modelPickerValue,
623
+ modelProviders,
624
+ modelProvidersLoaded
625
+ ]);
626
+ React.useEffect(() => {
627
+ setModelPickerValue(readModelPickerValue(agent));
628
+ }, [agent]);
629
+ React.useEffect(() => {
630
+ if (!modelProvidersLoaded) return;
631
+ if (!allowRuntimeModelOverride || modelProviders.length === 0) {
632
+ if (modelPickerValue !== null) {
633
+ setModelPickerValue(null);
634
+ writeModelPickerValue(agent, null);
635
+ }
636
+ return;
637
+ }
638
+ if (modelPickerValue && !isModelPickerValueAvailable(modelPickerValue, modelProviders)) {
639
+ const fallback = firstAvailableModelPickerValue(modelProviders);
640
+ setModelPickerValue(fallback);
641
+ writeModelPickerValue(agent, fallback);
642
+ }
643
+ }, [
644
+ agent,
645
+ allowRuntimeModelOverride,
646
+ modelPickerValue,
647
+ modelProviders,
648
+ modelProvidersLoaded
649
+ ]);
650
+ const handleModelPickerChange = React.useCallback(
651
+ (value) => {
652
+ setModelPickerValue(value);
653
+ writeModelPickerValue(agent, value);
654
+ },
655
+ [agent]
656
+ );
514
657
  const chat = useAiChat({
515
658
  agent,
516
659
  apiPath,
@@ -519,7 +662,9 @@ function AiChat({
519
662
  debug,
520
663
  initialMessages,
521
664
  onError,
522
- conversationId
665
+ conversationId,
666
+ providerOverride: effectiveModelPickerValue?.providerId ?? null,
667
+ modelOverride: effectiveModelPickerValue?.modelId ?? null
523
668
  });
524
669
  const isStreaming = chat.status === "streaming";
525
670
  const isSubmitting = chat.status === "submitting";
@@ -727,15 +872,15 @@ function AiChat({
727
872
  "section",
728
873
  {
729
874
  className: cn(
730
- "flex h-full min-h-[320px] flex-col gap-3 rounded-lg border border-border bg-background p-3",
875
+ "flex h-full min-h-[320px] min-w-0 flex-col gap-3 overflow-hidden rounded-lg border border-border bg-background p-3",
731
876
  className
732
877
  ),
733
878
  "aria-label": t("ai_assistant.chat.regionLabel", "AI chat"),
734
879
  "data-ai-chat-agent": agent,
735
880
  "data-ai-chat-conversation-id": chat.conversationId,
736
881
  children: [
737
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-border pb-2", children: [
738
- /* @__PURE__ */ jsx("div", { className: "flex flex-1 items-center gap-2 text-xs text-muted-foreground", children: contextItems && contextItems.length > 0 ? /* @__PURE__ */ jsx(ContextItemsPill, { items: contextItems }) : /* @__PURE__ */ jsx("span", { className: "font-mono opacity-70", "aria-hidden": true, children: chat.conversationId.slice(0, 8) }) }),
882
+ /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center justify-between gap-2 border-b border-border pb-2", children: [
883
+ /* @__PURE__ */ jsx("div", { className: "flex min-w-0 flex-1 items-center gap-2 text-xs text-muted-foreground", children: contextItems && contextItems.length > 0 ? /* @__PURE__ */ jsx(ContextItemsPill, { items: contextItems }) : /* @__PURE__ */ jsx("span", { className: "font-mono opacity-70", "aria-hidden": true, children: chat.conversationId.slice(0, 8) }) }),
739
884
  /* @__PURE__ */ jsx(
740
885
  IconButton,
741
886
  {
@@ -758,7 +903,7 @@ function AiChat({
758
903
  role: "log",
759
904
  "aria-live": "polite",
760
905
  "aria-label": t("ai_assistant.chat.transcriptLabel", "Chat transcript"),
761
- className: "flex-1 space-y-2 overflow-y-auto pr-1",
906
+ className: "min-w-0 flex-1 space-y-2 overflow-y-auto overflow-x-hidden pr-1",
762
907
  children: [
763
908
  chat.messages.length === 0 ? /* @__PURE__ */ jsx(
764
909
  WelcomeState,
@@ -810,7 +955,7 @@ function AiChat({
810
955
  /* @__PURE__ */ jsxs(
811
956
  "form",
812
957
  {
813
- className: "flex flex-col gap-2",
958
+ className: "flex min-w-0 flex-col gap-2",
814
959
  onSubmit: (event) => {
815
960
  event.preventDefault();
816
961
  handleSubmit();
@@ -862,7 +1007,7 @@ function AiChat({
862
1007
  onKeyDown: handleKeyDown,
863
1008
  rows: 3,
864
1009
  "aria-label": t("ai_assistant.chat.composerLabel", "Message composer"),
865
- className: "resize-none"
1010
+ className: "min-w-0 resize-none"
866
1011
  }
867
1012
  ),
868
1013
  /* @__PURE__ */ jsx(
@@ -877,56 +1022,79 @@ function AiChat({
877
1022
  "data-ai-chat-file-input": ""
878
1023
  }
879
1024
  ),
880
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
881
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
882
- /* @__PURE__ */ jsx(
883
- IconButton,
884
- {
885
- type: "button",
886
- variant: "ghost",
887
- size: "sm",
888
- onClick: () => fileInputRef.current?.click(),
889
- disabled: isBusy || isUploading,
890
- "aria-label": t("ai_assistant.chat.attachFile", "Attach file"),
891
- children: /* @__PURE__ */ jsx(Paperclip, { className: "size-4", "aria-hidden": true })
892
- }
893
- ),
894
- /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: hasUploadingFiles || isUploading ? t(
895
- "ai_assistant.chat.uploadingHint",
896
- "Uploading attachments\u2026 Send is disabled until they finish."
897
- ) : t(
898
- "ai_assistant.chat.shortcutHint",
899
- "Press Enter to send, Shift+Enter for new line."
900
- ) })
901
- ] }),
902
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
903
- isStreaming ? /* @__PURE__ */ jsx(
904
- IconButton,
905
- {
906
- type: "button",
907
- variant: "outline",
908
- size: "sm",
909
- onClick: () => chat.cancel(),
910
- "aria-label": t("ai_assistant.chat.cancel", "Cancel streaming response"),
911
- children: /* @__PURE__ */ jsx(Square, { className: "size-4", "aria-hidden": true })
912
- }
913
- ) : null,
914
- /* @__PURE__ */ jsxs(
915
- Button,
916
- {
917
- type: "submit",
918
- size: "sm",
919
- disabled: isBusy || isUploading || hasUploadingFiles || input.trim().length === 0,
920
- "aria-label": hasUploadingFiles || isUploading ? t("ai_assistant.chat.sendWaitingForUpload", "Waiting for upload to finish\u2026") : t("ai_assistant.chat.send", "Send message"),
921
- title: hasUploadingFiles || isUploading ? t("ai_assistant.chat.sendWaitingForUpload", "Waiting for upload to finish\u2026") : void 0,
922
- children: [
923
- /* @__PURE__ */ jsx(Send, { className: "size-4", "aria-hidden": true }),
924
- /* @__PURE__ */ jsx("span", { children: t("ai_assistant.chat.send", "Send message") })
925
- ]
926
- }
927
- )
928
- ] })
929
- ] })
1025
+ /* @__PURE__ */ jsxs(
1026
+ "div",
1027
+ {
1028
+ ref: footerRef,
1029
+ className: "flex min-w-0 items-center justify-between gap-2",
1030
+ "data-ai-chat-footer": "",
1031
+ "data-ai-chat-footer-compact": isCompactFooter ? "true" : "false",
1032
+ children: [
1033
+ /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [
1034
+ /* @__PURE__ */ jsx(
1035
+ IconButton,
1036
+ {
1037
+ type: "button",
1038
+ variant: "ghost",
1039
+ size: "sm",
1040
+ onClick: () => fileInputRef.current?.click(),
1041
+ disabled: isBusy || isUploading,
1042
+ "aria-label": t("ai_assistant.chat.attachFile", "Attach file"),
1043
+ children: /* @__PURE__ */ jsx(Paperclip, { className: "size-4", "aria-hidden": true })
1044
+ }
1045
+ ),
1046
+ allowRuntimeModelOverride && modelProviders.length > 0 ? /* @__PURE__ */ jsx(
1047
+ ModelPicker,
1048
+ {
1049
+ agentId: agent,
1050
+ value: effectiveModelPickerValue,
1051
+ onChange: handleModelPickerChange,
1052
+ availableProviders: modelProviders,
1053
+ disabled: isBusy,
1054
+ compact: isCompactFooter,
1055
+ defaultLabel: modelDefaultLabel,
1056
+ className: "shrink-0"
1057
+ }
1058
+ ) : null,
1059
+ /* @__PURE__ */ jsx("p", { className: cn("text-xs text-muted-foreground", isCompactFooter && "hidden"), children: hasUploadingFiles || isUploading ? t(
1060
+ "ai_assistant.chat.uploadingHint",
1061
+ "Uploading attachments\u2026 Send is disabled until they finish."
1062
+ ) : t(
1063
+ "ai_assistant.chat.shortcutHint",
1064
+ "Press Enter to send, Shift+Enter for new line."
1065
+ ) })
1066
+ ] }),
1067
+ /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [
1068
+ isStreaming ? /* @__PURE__ */ jsx(
1069
+ IconButton,
1070
+ {
1071
+ type: "button",
1072
+ variant: "outline",
1073
+ size: "sm",
1074
+ onClick: () => chat.cancel(),
1075
+ "aria-label": t("ai_assistant.chat.cancel", "Cancel streaming response"),
1076
+ children: /* @__PURE__ */ jsx(Square, { className: "size-4", "aria-hidden": true })
1077
+ }
1078
+ ) : null,
1079
+ /* @__PURE__ */ jsxs(
1080
+ Button,
1081
+ {
1082
+ type: "submit",
1083
+ size: "sm",
1084
+ disabled: isBusy || isUploading || hasUploadingFiles || input.trim().length === 0,
1085
+ "aria-label": hasUploadingFiles || isUploading ? t("ai_assistant.chat.sendWaitingForUpload", "Waiting for upload to finish\u2026") : t("ai_assistant.chat.send", "Send message"),
1086
+ title: hasUploadingFiles || isUploading ? t("ai_assistant.chat.sendWaitingForUpload", "Waiting for upload to finish\u2026") : void 0,
1087
+ className: cn(isCompactFooter && "w-8 px-0"),
1088
+ children: [
1089
+ /* @__PURE__ */ jsx(Send, { className: "size-4", "aria-hidden": true }),
1090
+ !isCompactFooter ? /* @__PURE__ */ jsx("span", { children: t("ai_assistant.chat.send", "Send message") }) : null
1091
+ ]
1092
+ }
1093
+ )
1094
+ ] })
1095
+ ]
1096
+ }
1097
+ )
930
1098
  ]
931
1099
  }
932
1100
  ),