@coffer-org/plugin-webchat 2.1.0 → 2.2.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.
@@ -507,6 +507,34 @@ var createLucideIcon = (iconName, iconNode) => {
507
507
  * This source code is licensed under the ISC license.
508
508
  * See the LICENSE file in the root directory of this source tree.
509
509
  */
510
+ var FileText = createLucideIcon("file-text", [
511
+ ["path", {
512
+ d: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z",
513
+ key: "1oefj6"
514
+ }],
515
+ ["path", {
516
+ d: "M14 2v5a1 1 0 0 0 1 1h5",
517
+ key: "wfsgrz"
518
+ }],
519
+ ["path", {
520
+ d: "M10 9H8",
521
+ key: "b1mrlr"
522
+ }],
523
+ ["path", {
524
+ d: "M16 13H8",
525
+ key: "t4e002"
526
+ }],
527
+ ["path", {
528
+ d: "M16 17H8",
529
+ key: "z1uh3a"
530
+ }]
531
+ ]);
532
+ /**
533
+ * @license lucide-react v1.24.0 - ISC
534
+ *
535
+ * This source code is licensed under the ISC license.
536
+ * See the LICENSE file in the root directory of this source tree.
537
+ */
510
538
  var List = createLucideIcon("list", [
511
539
  ["path", {
512
540
  d: "M3 5h.01",
@@ -539,6 +567,16 @@ var List = createLucideIcon("list", [
539
567
  * This source code is licensed under the ISC license.
540
568
  * See the LICENSE file in the root directory of this source tree.
541
569
  */
570
+ var LoaderCircle = createLucideIcon("loader-circle", [["path", {
571
+ d: "M21 12a9 9 0 1 1-6.219-8.56",
572
+ key: "13zald"
573
+ }]]);
574
+ /**
575
+ * @license lucide-react v1.24.0 - ISC
576
+ *
577
+ * This source code is licensed under the ISC license.
578
+ * See the LICENSE file in the root directory of this source tree.
579
+ */
542
580
  var Maximize2 = createLucideIcon("maximize-2", [
543
581
  ["path", {
544
582
  d: "M15 3h6v6",
@@ -597,6 +635,16 @@ var Minimize2 = createLucideIcon("minimize-2", [
597
635
  * This source code is licensed under the ISC license.
598
636
  * See the LICENSE file in the root directory of this source tree.
599
637
  */
638
+ var Paperclip = createLucideIcon("paperclip", [["path", {
639
+ d: "m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551",
640
+ key: "1miecu"
641
+ }]]);
642
+ /**
643
+ * @license lucide-react v1.24.0 - ISC
644
+ *
645
+ * This source code is licensed under the ISC license.
646
+ * See the LICENSE file in the root directory of this source tree.
647
+ */
600
648
  var Plus = createLucideIcon("plus", [["path", {
601
649
  d: "M5 12h14",
602
650
  key: "1ays0h"
@@ -637,6 +685,26 @@ var Square = createLucideIcon("square", [["rect", {
637
685
  * This source code is licensed under the ISC license.
638
686
  * See the LICENSE file in the root directory of this source tree.
639
687
  */
688
+ var Upload = createLucideIcon("upload", [
689
+ ["path", {
690
+ d: "M12 3v12",
691
+ key: "1x0j5s"
692
+ }],
693
+ ["path", {
694
+ d: "m17 8-5-5-5 5",
695
+ key: "7q97r8"
696
+ }],
697
+ ["path", {
698
+ d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",
699
+ key: "ih7n3h"
700
+ }]
701
+ ]);
702
+ /**
703
+ * @license lucide-react v1.24.0 - ISC
704
+ *
705
+ * This source code is licensed under the ISC license.
706
+ * See the LICENSE file in the root directory of this source tree.
707
+ */
640
708
  var X = createLucideIcon("x", [["path", {
641
709
  d: "M18 6 6 18",
642
710
  key: "1bl5f8"
@@ -6040,7 +6108,7 @@ var getDefaultConfig = () => {
6040
6108
  var twMerge = /*#__PURE__*/ createTailwindMerge(getDefaultConfig);
6041
6109
  //#endregion
6042
6110
  //#region ../web/lib/utils.ts
6043
- /** Об'єднує класи, розв'язуючи конфлікти Tailwind (shadcn-конвенція). */
6111
+ /** Merge classes while resolving Tailwind conflicts (shadcn convention). */
6044
6112
  function cn(...inputs) {
6045
6113
  return twMerge(clsx(inputs));
6046
6114
  }
@@ -6054,7 +6122,7 @@ var DialogOverlay = React.forwardRef(({ className, ...props }, ref) => /* @__PUR
6054
6122
  ...props
6055
6123
  }));
6056
6124
  DialogOverlay.displayName = DialogOverlay$1.displayName;
6057
- /** Респонсивний контент: bottom-sheet на мобільному, центрований modal на desktop. */
6125
+ /** Responsive content: bottom sheet on mobile, centered modal on desktop. */
6058
6126
  var DialogContent = React.forwardRef(({ className, children, hideClose, ...props }, ref) => /* @__PURE__ */ jsxs(DialogPortal, { children: [/* @__PURE__ */ jsx(DialogOverlay, {}), /* @__PURE__ */ jsxs(DialogContent$1, {
6059
6127
  ref,
6060
6128
  className: cn("fixed z-50 flex flex-col border border-border bg-surface text-text shadow-xl", "inset-x-0 bottom-0 max-h-[88dvh] overflow-y-auto rounded-t-2xl", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom data-[state=closed]:duration-200", "md:inset-x-auto md:bottom-auto md:left-1/2 md:top-1/2 md:max-h-[85dvh] md:w-full md:max-w-xl md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-2xl", "md:data-[state=open]:slide-in-from-bottom-0 md:data-[state=closed]:slide-out-to-bottom-0 md:data-[state=open]:zoom-in-95 md:data-[state=closed]:zoom-out-95", className),
@@ -6083,14 +6151,13 @@ DialogDescription.displayName = DialogDescription$1.displayName;
6083
6151
  //#endregion
6084
6152
  //#region ../web/hooks/use-media-query.ts
6085
6153
  /**
6086
- * Стан media-запиту як реактивне значення.
6154
+ * Media-query state as a reactive value.
6087
6155
  *
6088
- * Потрібен там, де CSS-приховування (`hidden list:block`) недостатньо: приховані
6089
- * елементи все одно в DOM і все одно рендеряться. Для списку на 700 записів це
6090
- * подвійна робота — таблиця і картки одночасно.
6156
+ * Needed where CSS hiding (`hidden list:block`) is insufficient: hidden elements remain in the
6157
+ * DOM and still render. For a list of 700 records, that means doing the work twice — table and cards.
6091
6158
  *
6092
- * `fallback` відповідь, коли `matchMedia` недоступний (jsdom у тестах): за
6093
- * замовчуванням «широкий екран», щоб тести бачили десктопну розмітку.
6159
+ * `fallback` is the answer when `matchMedia` is unavailable (jsdom tests): default to a
6160
+ * “wide screen” so tests see the desktop layout.
6094
6161
  */
6095
6162
  function useMediaQuery(query, fallback = true) {
6096
6163
  return useSyncExternalStore(useCallback((onChange) => {
@@ -6104,8 +6171,8 @@ function useMediaQuery(query, fallback = true) {
6104
6171
  }, [query, fallback]), () => fallback);
6105
6172
  }
6106
6173
  /**
6107
- * Значення брейкпоінта з `@theme` (globals.css) щоб JS і CSS не розʼїхались.
6108
- * Tailwind v4 публікує їх як CSS-змінні на :root. Без CSS (тести) `fallback`.
6174
+ * Breakpoint value from `@theme` (globals.css), so JS and CSS do not drift.
6175
+ * Tailwind v4 publishes them as CSS variables on :root. Without CSS (tests), use `fallback`.
6109
6176
  */
6110
6177
  function breakpointPx(name, fallback) {
6111
6178
  if (typeof window === "undefined" || typeof getComputedStyle !== "function") return fallback;
@@ -6113,11 +6180,35 @@ function breakpointPx(name, fallback) {
6113
6180
  const n = Number.parseInt(raw.trim(), 10);
6114
6181
  return Number.isFinite(n) && n > 0 ? n : fallback;
6115
6182
  }
6116
- /** `(min-width: …)` для брейкпоінта з теми. */
6183
+ /** `(min-width: …)` for a theme breakpoint. */
6117
6184
  function useMinWidth(name, fallback) {
6118
6185
  return useMediaQuery(`(min-width: ${breakpointPx(name, fallback)}px)`, true);
6119
6186
  }
6120
6187
  //#endregion
6188
+ //#region ../web/api.ts
6189
+ /**
6190
+ * Upload a file → descriptor from the server (or null).
6191
+ * Take mime/size from the response, not the browser File: the server overwrites
6192
+ * them from disk anyway, so client values would only be noise.
6193
+ */
6194
+ async function uploadFile(file) {
6195
+ const fd = new FormData();
6196
+ fd.append("file", file);
6197
+ const r = await fetch("/api/upload", {
6198
+ method: "POST",
6199
+ body: fd
6200
+ });
6201
+ if (!r.ok) return null;
6202
+ const j = await r.json();
6203
+ const name = j.name ?? j.filename;
6204
+ if (!name) return null;
6205
+ return {
6206
+ name,
6207
+ ...j.mime ? { mime: j.mime } : {},
6208
+ ...j.size != null ? { size: j.size } : {}
6209
+ };
6210
+ }
6211
+ //#endregion
6121
6212
  //#region src/ui/sse.ts
6122
6213
  async function* readSse(body) {
6123
6214
  const reader = body.getReader();
@@ -6147,14 +6238,13 @@ async function* readSse(body) {
6147
6238
  }
6148
6239
  //#endregion
6149
6240
  //#region src/ui/chat-api.ts
6150
- /** Транспорт веб-чату. Куки сесії їдуть автоматично (same-origin), тому окремої
6151
- * авторизації тут немає гейт стоїть на сервері. */
6241
+ /** Web-chat transport. Session cookies are sent automatically (same-origin), so there is no
6242
+ * separate authorization here; the gate is on the server. */
6152
6243
  var BASE = "/api/plugins/webchat/user";
6153
6244
  /**
6154
- * Кидає на будь-якій відмові, зокрема 404 «плагін вимкнули вже ПІСЛЯ того, як
6155
- * бандл віджета завантажився». Окремого `null` для цього випадку немає навмисно:
6156
- * викликач однаково лишає поточний список розмов, тож два коди повернення
6157
- * означали б рівно одне.
6245
+ * Throws on any rejection, including 404 (“plugin was disabled AFTER the widget bundle
6246
+ * loaded”). There is deliberately no separate `null` for this case: the caller keeps
6247
+ * the current thread list either way, so two return codes would mean the same thing.
6158
6248
  */
6159
6249
  async function fetchThreads() {
6160
6250
  const r = await fetch(`${BASE}/threads`, {
@@ -6182,7 +6272,8 @@ async function sendMessage(req) {
6182
6272
  convId: req.convId,
6183
6273
  text: req.text,
6184
6274
  replyTo: req.replyTo,
6185
- context: req.context
6275
+ context: req.context,
6276
+ ...req.attachments?.length ? { attachments: req.attachments } : {}
6186
6277
  }),
6187
6278
  signal: req.signal
6188
6279
  });
@@ -6278,7 +6369,7 @@ function useChat() {
6278
6369
  pending,
6279
6370
  pendingReasoning,
6280
6371
  error,
6281
- send: useCallback((text, context) => {
6372
+ send: useCallback((text, context, attachments = []) => {
6282
6373
  if (!text.trim() || sendingRef.current) return false;
6283
6374
  sendingRef.current = true;
6284
6375
  const myTurn = turnRef.current;
@@ -6289,7 +6380,8 @@ function useChat() {
6289
6380
  msgId: `local-${newId()}`,
6290
6381
  role: "user",
6291
6382
  text,
6292
- ts: Math.floor(Date.now() / 1e3)
6383
+ ts: Math.floor(Date.now() / 1e3),
6384
+ ...attachments.length ? { attachments } : {}
6293
6385
  }]);
6294
6386
  setPending("");
6295
6387
  const ctl = new AbortController();
@@ -6303,6 +6395,7 @@ function useChat() {
6303
6395
  text,
6304
6396
  replyTo: headRef.current,
6305
6397
  context,
6398
+ attachments,
6306
6399
  signal: ctl.signal,
6307
6400
  onDelta: (t) => {
6308
6401
  if (turnRef.current !== myTurn) return;
@@ -6358,8 +6451,8 @@ function useChat() {
6358
6451
  }
6359
6452
  //#endregion
6360
6453
  //#region src/ui/use-page-context.ts
6361
- /** Контекст поточної сторінки для агента. Роути SPA: `/:library/:type[/:id]`;
6362
- * системні гілки (settings, browse) віддаємо лише шляхом. */
6454
+ /** Current-page context for the agent. SPA routes: `/:library/:type[/:id]`;
6455
+ * system branches (settings, browse) are returned by path only. */
6363
6456
  function usePageContext() {
6364
6457
  const { pathname } = useLocation();
6365
6458
  const parts = pathname.split("/").filter(Boolean);
@@ -29005,9 +29098,8 @@ function remarkGfm(options) {
29005
29098
  //#endregion
29006
29099
  //#region src/ui/internal-link.ts
29007
29100
  var SEGMENT = /^[A-Za-z0-9._~%-]+$/;
29008
- /** Повертає захоплені значення ':param' при збігу, або null. '*' легальний
29009
- * лише останнім сегментом шаблону тому ця гілка може повернутись одразу,
29010
- * не дочитуючи got до кінця. */
29101
+ /** Returns captured ':param' values on a match, or null. '*' is legal only as the
29102
+ * last pattern segment, so this branch may return immediately without reading got to the end. */
29011
29103
  function matchesPattern(pathname, pattern) {
29012
29104
  const got = pathname.split("/").filter(Boolean);
29013
29105
  const want = pattern.split("/").filter(Boolean);
@@ -29053,9 +29145,9 @@ function classifyHref(href, origin, routes, pairs = []) {
29053
29145
  }
29054
29146
  //#endregion
29055
29147
  //#region src/ui/chat-nav.tsx
29056
- /** Що робити з віджетом, коли користувач пішов по лінку з відповіді.
29057
- * Колбек дає ChatWidget (він володіє mode/open) щоб не тягнути проп
29058
- * через ChatMessages і Bubble. */
29148
+ /** What to do with the widget when the user follows a link from a response.
29149
+ * ChatWidget supplies the callback (it owns mode/open), avoiding a prop
29150
+ * threaded through ChatMessages and Bubble. */
29059
29151
  var ChatNavContext = createContext(() => {});
29060
29152
  function ChatNavProvider({ onNavigate, children }) {
29061
29153
  return /* @__PURE__ */ jsx(ChatNavContext.Provider, {
@@ -29115,31 +29207,37 @@ function ChatMessages({ messages, pending, pendingReasoning }) {
29115
29207
  children: [messages.map((m) => /* @__PURE__ */ jsx(Bubble, {
29116
29208
  role: m.role,
29117
29209
  text: m.text,
29118
- reasoning: m.reasoning
29210
+ reasoning: m.reasoning,
29211
+ attachments: m.attachments
29119
29212
  }, m.msgId)), pending !== null && /* @__PURE__ */ jsx(Bubble, {
29120
29213
  role: "assistant",
29121
29214
  text: pending || t("webchat.thinking"),
29122
- reasoning: pendingReasoning,
29123
- live: true
29215
+ reasoning: pendingReasoning
29124
29216
  })]
29125
29217
  });
29126
29218
  }
29127
29219
  /**
29128
- * memo: під час стріму батько ререндериться на КОЖНУ дельту (кілька разів на
29129
- * секунду). Без memo кожен такт заново рендерив би (і, отже, заново парсив
29130
- * markdown) усіх уже готових повідомлень, поки жива лише одна бульбашка знизу.
29131
- * Props тут примітиви, тож дефолтного поверхневого порівняння досить.
29220
+ * memo: during streaming, the parent re-renders on EVERY delta (several times per
29221
+ * second). Without memo, each tick would re-render (and therefore re-parse markdown)
29222
+ * for every completed message while only one bubble at the bottom is changing.
29223
+ * The props are primitives, so the default shallow comparison is sufficient.
29132
29224
  */
29133
- var Bubble = memo(function Bubble({ role, text, reasoning, live }) {
29225
+ var Bubble = memo(function Bubble({ role, text, reasoning, attachments }) {
29134
29226
  const { t } = useTranslation();
29135
- if (role === "user") return /* @__PURE__ */ jsx("div", {
29227
+ if (role === "user") return /* @__PURE__ */ jsxs("div", {
29136
29228
  className: "self-end max-w-[85%] rounded-2xl rounded-br-sm bg-accent/15 px-3 py-2 text-sm whitespace-pre-wrap break-words",
29137
- children: text
29229
+ children: [attachments?.length ? /* @__PURE__ */ jsx("div", {
29230
+ className: "mb-1.5 flex flex-wrap gap-1",
29231
+ children: attachments.map((a) => /* @__PURE__ */ jsx("span", {
29232
+ className: "inline-flex max-w-full items-center rounded-md bg-bg/60 px-1.5 py-0.5 text-xs",
29233
+ title: a.name,
29234
+ children: a.label ?? a.name
29235
+ }, a.name))
29236
+ }) : null, text]
29138
29237
  });
29139
29238
  return /* @__PURE__ */ jsxs("div", {
29140
29239
  className: "self-start max-w-[95%] min-w-0 rounded-2xl rounded-bl-sm bg-elevated px-3 py-2 text-sm break-words",
29141
29240
  children: [reasoning ? /* @__PURE__ */ jsxs("details", {
29142
- open: live,
29143
29241
  className: "mb-2 rounded-lg border border-border px-2 py-1",
29144
29242
  children: [/* @__PURE__ */ jsx("summary", {
29145
29243
  className: "cursor-pointer select-none text-xs text-muted",
@@ -29156,37 +29254,91 @@ var Bubble = memo(function Bubble({ role, text, reasoning, live }) {
29156
29254
  });
29157
29255
  //#endregion
29158
29256
  //#region src/ui/ChatComposer.tsx
29159
- function ChatComposer({ streaming, onSend, onStop }) {
29257
+ function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploadError, onPickFiles, onRemoveAttachment }) {
29160
29258
  const { t } = useTranslation();
29161
29259
  const [text, setText] = useState("");
29162
29260
  function submit() {
29163
29261
  if (!text.trim() || streaming) return;
29164
- if (onSend(text.trim())) setText("");
29262
+ if (onSend(text.trim(), attachments)) setText("");
29165
29263
  }
29166
29264
  return /* @__PURE__ */ jsxs("div", {
29167
- className: "flex shrink-0 items-end gap-2 border-t border-border p-2",
29168
- children: [/* @__PURE__ */ jsx("textarea", {
29169
- className: "flex-1 resize-none rounded-lg border border-border bg-bg px-3 py-2 text-sm max-h-32",
29170
- rows: 1,
29171
- value: text,
29172
- placeholder: t("webchat.placeholder"),
29173
- onChange: (e) => setText(e.target.value),
29174
- onKeyDown: (e) => {
29175
- if (e.key === "Enter" && !e.shiftKey) {
29176
- e.preventDefault();
29177
- submit();
29178
- }
29179
- }
29180
- }), streaming ? /* @__PURE__ */ jsx("button", {
29181
- "aria-label": t("webchat.stop"),
29182
- className: "rounded-lg p-2 hover:bg-elevated",
29183
- onClick: onStop,
29184
- children: /* @__PURE__ */ jsx(Square, { size: 18 })
29185
- }) : /* @__PURE__ */ jsx("button", {
29186
- "aria-label": t("webchat.send"),
29187
- className: "rounded-lg p-2 hover:bg-elevated",
29188
- onClick: submit,
29189
- children: /* @__PURE__ */ jsx(Send, { size: 18 })
29265
+ className: "flex shrink-0 flex-col gap-2 border-t border-border p-2",
29266
+ children: [(attachments.length > 0 || uploadError) && /* @__PURE__ */ jsxs("div", {
29267
+ className: "flex flex-wrap items-center gap-1.5 px-1",
29268
+ "data-chat-attachments": true,
29269
+ children: [
29270
+ attachments.map((a, i) => /* @__PURE__ */ jsxs("span", {
29271
+ className: "inline-flex min-w-0 max-w-full items-center gap-1 rounded-md bg-elevated px-2 py-1 text-xs",
29272
+ children: [
29273
+ /* @__PURE__ */ jsx(FileText, {
29274
+ size: 13,
29275
+ className: "shrink-0 text-muted"
29276
+ }),
29277
+ /* @__PURE__ */ jsx("span", {
29278
+ className: "max-w-48 truncate",
29279
+ children: a.label ?? a.name
29280
+ }),
29281
+ /* @__PURE__ */ jsx("button", {
29282
+ type: "button",
29283
+ "aria-label": t("webchat.removeAttachment", { name: a.label ?? a.name }),
29284
+ className: "shrink-0 rounded p-0.5 text-muted hover:text-danger",
29285
+ onClick: () => onRemoveAttachment(i),
29286
+ children: /* @__PURE__ */ jsx(X, { size: 13 })
29287
+ })
29288
+ ]
29289
+ }, `${a.name}-${i}`)),
29290
+ uploading && /* @__PURE__ */ jsx(LoaderCircle, {
29291
+ size: 14,
29292
+ className: "animate-spin text-muted",
29293
+ "aria-label": t("webchat.uploading")
29294
+ }),
29295
+ uploadError && /* @__PURE__ */ jsx("span", {
29296
+ className: "text-xs text-danger",
29297
+ children: t(uploadError)
29298
+ })
29299
+ ]
29300
+ }), /* @__PURE__ */ jsxs("div", {
29301
+ className: "flex items-end gap-2",
29302
+ children: [
29303
+ /* @__PURE__ */ jsxs("label", {
29304
+ "aria-label": t("webchat.attach"),
29305
+ className: `cursor-pointer rounded-lg p-2 text-muted hover:bg-elevated hover:text-accent ${uploading ? "pointer-events-none opacity-60" : ""}`,
29306
+ children: [/* @__PURE__ */ jsx(Paperclip, { size: 18 }), /* @__PURE__ */ jsx("input", {
29307
+ type: "file",
29308
+ multiple: true,
29309
+ className: "hidden",
29310
+ disabled: uploading,
29311
+ onChange: (e) => {
29312
+ if (e.target.files?.length) onPickFiles(e.target.files);
29313
+ e.currentTarget.value = "";
29314
+ }
29315
+ })]
29316
+ }),
29317
+ /* @__PURE__ */ jsx("textarea", {
29318
+ className: "flex-1 resize-none rounded-lg border border-border bg-bg px-3 py-2 text-sm max-h-32",
29319
+ rows: 1,
29320
+ value: text,
29321
+ placeholder: t("webchat.placeholder"),
29322
+ onChange: (e) => setText(e.target.value),
29323
+ onKeyDown: (e) => {
29324
+ if (e.key === "Enter" && !e.shiftKey) {
29325
+ e.preventDefault();
29326
+ submit();
29327
+ }
29328
+ }
29329
+ }),
29330
+ streaming ? /* @__PURE__ */ jsx("button", {
29331
+ "aria-label": t("webchat.stop"),
29332
+ className: "rounded-lg p-2 hover:bg-elevated",
29333
+ onClick: onStop,
29334
+ children: /* @__PURE__ */ jsx(Square, { size: 18 })
29335
+ }) : /* @__PURE__ */ jsx("button", {
29336
+ "aria-label": t("webchat.send"),
29337
+ className: "rounded-lg p-2 hover:bg-elevated",
29338
+ onClick: submit,
29339
+ children: /* @__PURE__ */ jsx(Send, { size: 18 })
29340
+ })
29341
+ ]
29190
29342
  })]
29191
29343
  });
29192
29344
  }
@@ -29217,9 +29369,69 @@ function ChatThreadList({ threads, activeId, onOpen, onNew, className = "w-[220p
29217
29369
  }
29218
29370
  //#endregion
29219
29371
  //#region src/ui/ChatWidget.tsx
29220
- /** Що робити з віджетом після переходу по лінку з відповіді. На desktop панель
29221
- * 380px не заважає читати сторінку (а модаль із затемненням — заважає, тож
29222
- * згортається в неї); нижче nav-брейкпоінта панель з'їдає майже екран. */
29372
+ var MAX_CHAT_UPLOAD_BYTES = 25 * 1024 * 1024;
29373
+ function isFileDrag(e) {
29374
+ return Array.from(e.dataTransfer.types).includes("Files");
29375
+ }
29376
+ /** One drop surface per popup. The overlay deliberately covers the header, feed, and composer. */
29377
+ function ChatDropSurface({ children, onFiles, className }) {
29378
+ const { t } = useTranslation();
29379
+ const [dragging, setDragging] = useState(false);
29380
+ const depth = useRef(0);
29381
+ function enter(e) {
29382
+ if (!isFileDrag(e)) return;
29383
+ e.preventDefault();
29384
+ depth.current += 1;
29385
+ setDragging(true);
29386
+ }
29387
+ function leave(e) {
29388
+ if (!isFileDrag(e)) return;
29389
+ e.preventDefault();
29390
+ depth.current = Math.max(0, depth.current - 1);
29391
+ if (depth.current === 0) setDragging(false);
29392
+ }
29393
+ function drop(e) {
29394
+ if (!isFileDrag(e)) return;
29395
+ e.preventDefault();
29396
+ depth.current = 0;
29397
+ setDragging(false);
29398
+ onFiles(Array.from(e.dataTransfer.files));
29399
+ }
29400
+ return /* @__PURE__ */ jsxs("div", {
29401
+ className,
29402
+ onDragEnter: enter,
29403
+ onDragLeave: leave,
29404
+ onDragOver: (e) => {
29405
+ if (isFileDrag(e)) {
29406
+ e.preventDefault();
29407
+ e.dataTransfer.dropEffect = "copy";
29408
+ }
29409
+ },
29410
+ onDrop: drop,
29411
+ children: [children, dragging && /* @__PURE__ */ jsx("div", {
29412
+ "data-chat-drop-overlay": true,
29413
+ "data-testid": "chat-drop-overlay",
29414
+ className: "pointer-events-none absolute inset-0 z-50 grid place-items-center bg-surface/95 p-6 text-center backdrop-blur-sm",
29415
+ children: /* @__PURE__ */ jsxs("div", {
29416
+ className: "flex h-full w-full flex-col items-center justify-center rounded-2xl border-2 border-dashed border-accent bg-accent/10 text-accent",
29417
+ children: [
29418
+ /* @__PURE__ */ jsx(Upload, { size: 34 }),
29419
+ /* @__PURE__ */ jsx("span", {
29420
+ className: "mt-3 text-base font-medium",
29421
+ children: t("webchat.dropFiles")
29422
+ }),
29423
+ /* @__PURE__ */ jsx("span", {
29424
+ className: "mt-1 text-xs text-muted",
29425
+ children: t("webchat.dropFilesHint")
29426
+ })
29427
+ ]
29428
+ })
29429
+ })]
29430
+ });
29431
+ }
29432
+ /** What to do with the widget after following a link from a response. On desktop a 380px
29433
+ * panel does not obstruct the page (a darkened modal does, so it collapses into it);
29434
+ * below the nav breakpoint the panel consumes nearly the whole screen. */
29223
29435
  function nextModeAfterNav(wideNav) {
29224
29436
  return wideNav ? "panel" : "bubble";
29225
29437
  }
@@ -29229,6 +29441,9 @@ function ChatWidget() {
29229
29441
  const page = usePageContext();
29230
29442
  const [mode, setMode] = useState("bubble");
29231
29443
  const [threadsOpen, setThreadsOpen] = useState(false);
29444
+ const [attachments, setAttachments] = useState([]);
29445
+ const [uploading, setUploading] = useState(false);
29446
+ const [uploadError, setUploadError] = useState(null);
29232
29447
  const wide = useMinWidth("sm", 640);
29233
29448
  const wideNav = useMinWidth("nav", 768);
29234
29449
  const onChatNavigate = useCallback(() => {
@@ -29263,8 +29478,36 @@ function ChatWidget() {
29263
29478
  }, [isOpen, chat.loadThreads]);
29264
29479
  function startNew() {
29265
29480
  chat.newConversation();
29481
+ setAttachments([]);
29482
+ setUploadError(null);
29266
29483
  setThreadsOpen(false);
29267
29484
  }
29485
+ const pickFiles = useCallback(async (files) => {
29486
+ if (!files.length) return;
29487
+ setUploadError(null);
29488
+ setUploading(true);
29489
+ const added = [];
29490
+ let error = null;
29491
+ for (const file of files) {
29492
+ if (file.size > MAX_CHAT_UPLOAD_BYTES) {
29493
+ error ??= "webchat.uploadTooLarge";
29494
+ continue;
29495
+ }
29496
+ try {
29497
+ const rec = await uploadFile(file);
29498
+ if (rec) added.push({
29499
+ ...rec,
29500
+ label: file.name
29501
+ });
29502
+ else error ??= "webchat.uploadFailed";
29503
+ } catch {
29504
+ error ??= "webchat.uploadFailed";
29505
+ }
29506
+ }
29507
+ if (added.length) setAttachments((prev) => [...prev, ...added]);
29508
+ setUploading(false);
29509
+ if (error) setUploadError(error);
29510
+ }, []);
29268
29511
  const hasConversation = chat.messages.length > 0 || chat.pending !== null;
29269
29512
  const header = /* @__PURE__ */ jsxs("div", {
29270
29513
  className: "flex shrink-0 items-center justify-between border-b border-border px-3 py-2",
@@ -29319,7 +29562,19 @@ function ChatWidget() {
29319
29562
  }),
29320
29563
  /* @__PURE__ */ jsx(ChatComposer, {
29321
29564
  streaming: chat.pending !== null,
29322
- onSend: (text) => chat.send(text, page),
29565
+ attachments,
29566
+ uploading,
29567
+ uploadError,
29568
+ onPickFiles: (files) => void pickFiles(Array.from(files)),
29569
+ onRemoveAttachment: (index) => setAttachments((prev) => prev.filter((_, i) => i !== index)),
29570
+ onSend: (text, refs) => {
29571
+ const accepted = refs.length ? chat.send(text, page, refs) : chat.send(text, page);
29572
+ if (accepted) {
29573
+ setAttachments([]);
29574
+ setUploadError(null);
29575
+ }
29576
+ return accepted;
29577
+ },
29323
29578
  onStop: chat.stop
29324
29579
  })
29325
29580
  ]
@@ -29331,7 +29586,8 @@ function ChatWidget() {
29331
29586
  className: "fixed bottom-20 right-4 z-40 grid h-14 w-14 place-items-center rounded-full bg-accent text-white shadow-lg nav:bottom-4",
29332
29587
  children: /* @__PURE__ */ jsx(MessageCircle, { size: 24 })
29333
29588
  });
29334
- if (mode === "panel") return /* @__PURE__ */ jsxs("div", {
29589
+ if (mode === "panel") return /* @__PURE__ */ jsxs(ChatDropSurface, {
29590
+ onFiles: pickFiles,
29335
29591
  className: "fixed bottom-20 right-4 z-40 flex h-[560px] max-h-[80dvh] w-[380px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-2xl border border-border bg-surface shadow-xl nav:bottom-4",
29336
29592
  children: [header, conversation]
29337
29593
  });
@@ -29346,21 +29602,27 @@ function ChatWidget() {
29346
29602
  }), /* @__PURE__ */ jsx(DialogContent, {
29347
29603
  hideClose: true,
29348
29604
  className: "h-[85dvh] w-full p-0 md:h-[85dvh] md:w-[80vw] md:max-w-[1040px] md:overflow-y-hidden",
29349
- children: /* @__PURE__ */ jsxs("div", {
29350
- className: "flex h-full min-h-0 flex-col",
29351
- children: [header, /* @__PURE__ */ jsxs("div", {
29352
- className: "flex min-h-0 flex-1",
29353
- children: [(wide || threadsOpen) && /* @__PURE__ */ jsx(ChatThreadList, {
29354
- threads: chat.threads,
29355
- activeId: chat.convId,
29356
- onOpen: (id) => {
29357
- chat.open(id);
29358
- setThreadsOpen(false);
29359
- },
29360
- onNew: startNew,
29361
- className: wide ? "w-[220px] shrink-0" : "w-full"
29362
- }), (wide || !threadsOpen) && conversation]
29363
- })]
29605
+ children: /* @__PURE__ */ jsx(ChatDropSurface, {
29606
+ onFiles: pickFiles,
29607
+ className: "h-full",
29608
+ children: /* @__PURE__ */ jsxs("div", {
29609
+ className: "flex h-full min-h-0 flex-col",
29610
+ children: [header, /* @__PURE__ */ jsxs("div", {
29611
+ className: "flex min-h-0 flex-1",
29612
+ children: [(wide || threadsOpen) && /* @__PURE__ */ jsx(ChatThreadList, {
29613
+ threads: chat.threads,
29614
+ activeId: chat.convId,
29615
+ onOpen: (id) => {
29616
+ chat.open(id);
29617
+ setAttachments([]);
29618
+ setUploadError(null);
29619
+ setThreadsOpen(false);
29620
+ },
29621
+ onNew: startNew,
29622
+ className: wide ? "w-[220px] shrink-0" : "w-full"
29623
+ }), (wide || !threadsOpen) && conversation]
29624
+ })]
29625
+ })
29364
29626
  })
29365
29627
  })]
29366
29628
  });
@@ -11,6 +11,12 @@ export interface HistoryMsg {
11
11
  text: string;
12
12
  ts: number;
13
13
  reasoning?: string;
14
+ attachments?: {
15
+ name: string;
16
+ mime?: string;
17
+ size?: number;
18
+ label?: string;
19
+ }[];
14
20
  }
15
21
  export declare function threadsAction(_body: Record<string, unknown>, caller: ActionCaller): Promise<{
16
22
  threads: ThreadSummary[];
@@ -32,6 +32,7 @@ export async function historyAction(body, caller) {
32
32
  text: m.text,
33
33
  ts: m.ts,
34
34
  ...(reasoningByBot.has(m.msgId) ? { reasoning: reasoningByBot.get(m.msgId) } : {}),
35
+ ...(m.attachments?.length ? { attachments: m.attachments } : {}),
35
36
  })),
36
37
  headMsgId: visible.at(-1)?.msgId ?? null,
37
38
  };
@@ -8,6 +8,7 @@ export declare function recordUser(m: {
8
8
  msgId: string;
9
9
  sender?: string | null;
10
10
  text: string;
11
+ attachments?: ConvMessage['attachments'];
11
12
  ts: number;
12
13
  replyToId: string | null;
13
14
  }): Promise<void>;
@@ -10,7 +10,17 @@ export function chatPrefixFor(userId) {
10
10
  return `u:${userId}:`;
11
11
  }
12
12
  export async function recordUser(m) {
13
- await putThreadMessage({ connector: CONNECTOR, chatId: m.chatId, msgId: m.msgId, role: 'user', sender: m.sender ?? null, text: m.text, ts: m.ts, replyToId: m.replyToId });
13
+ await putThreadMessage({
14
+ connector: CONNECTOR,
15
+ chatId: m.chatId,
16
+ msgId: m.msgId,
17
+ role: 'user',
18
+ sender: m.sender ?? null,
19
+ ...(m.attachments?.length ? { attachments: m.attachments } : {}),
20
+ text: m.text,
21
+ ts: m.ts,
22
+ replyToId: m.replyToId,
23
+ });
14
24
  }
15
25
  export async function recordAssistant(m) {
16
26
  if (m.botMsgId == null || !m.text.trim())
@@ -43,7 +53,14 @@ export async function buildChain(headMsgId, opts) {
43
53
  if (!stored)
44
54
  break;
45
55
  if (stored.role !== 'reasoning') {
46
- acc.push({ role: stored.role, content: stored.text, sender: stored.sender, msgId: stored.msgId, ts: stored.ts });
56
+ acc.push({
57
+ role: stored.role,
58
+ content: stored.text,
59
+ sender: stored.sender,
60
+ ...(stored.attachments ? { attachments: stored.attachments } : {}),
61
+ msgId: stored.msgId,
62
+ ts: stored.ts,
63
+ });
47
64
  }
48
65
  cur = stored.replyToId;
49
66
  }
@@ -34,9 +34,10 @@ export async function sendAction(body, ctx, deps = {}) {
34
34
  const msgId = randomUUID();
35
35
  const botMsgId = randomUUID();
36
36
  const nowSec = Math.floor(Date.now() / 1000);
37
+ const attachments = parseAttachments(body['attachments']);
37
38
  const explicitReplyTo = typeof body['replyTo'] === 'string' && body['replyTo'] ? body['replyTo'] : null;
38
39
  const replyTo = explicitReplyTo ?? (await history(chatId, 1)).at(-1)?.msgId ?? null;
39
- await recordUser({ chatId, msgId, sender: ctx.caller.id, text, ts: nowSec, replyToId: replyTo });
40
+ await recordUser({ chatId, msgId, sender: ctx.caller.id, text, attachments, ts: nowSec, replyToId: replyTo });
40
41
  const messages = await buildChain(msgId, { chatId });
41
42
  const { connector, recorded } = makeStreamingConnector({
42
43
  chatId,
@@ -55,3 +56,21 @@ export async function sendAction(body, ctx, deps = {}) {
55
56
  }, { policy: policy() });
56
57
  ctx.emit('done', { msgId: recorded(), parentMsgId: msgId });
57
58
  }
59
+ function parseAttachments(value) {
60
+ if (!Array.isArray(value))
61
+ return undefined;
62
+ const refs = value.flatMap((item) => {
63
+ if (typeof item !== 'object' || item === null)
64
+ return [];
65
+ const r = item;
66
+ if (typeof r['name'] !== 'string' || !r['name'])
67
+ return [];
68
+ return [{
69
+ name: r['name'],
70
+ ...(typeof r['mime'] === 'string' ? { mime: r['mime'] } : {}),
71
+ ...(typeof r['size'] === 'number' ? { size: r['size'] } : {}),
72
+ ...(typeof r['label'] === 'string' ? { label: r['label'] } : {}),
73
+ }];
74
+ });
75
+ return refs.length ? refs : undefined;
76
+ }
package/dist/schema.js CHANGED
@@ -4391,11 +4391,11 @@ function resolveUnits(u) {
4391
4391
  //#endregion
4392
4392
  //#region ../sdk/src/currencies.ts
4393
4393
  /**
4394
- * Валюти з вбудованого `Intl` (ISO 4217) БЕЗ hand-list і без npm-залежності.
4395
- * Коди `Intl.supportedValuesOf('currency')`; символи/назви локалізовані через
4394
+ * Currencies from built-in `Intl` (ISO 4217), with NO hand list or npm dependency.
4395
+ * Codes come from `Intl.supportedValuesOf('currency')`; symbols/names are localized through
4396
4396
  * `Intl.NumberFormat`/`Intl.DisplayNames`.
4397
4397
  */
4398
- /** Усі ISO 4217 коди. Fallback кілька основних (на випадок старого рантайму). */
4398
+ /** All ISO 4217 codes. Fallback: several major codes for older runtimes. */
4399
4399
  var CURRENCY_CODES = (() => {
4400
4400
  try {
4401
4401
  return Intl.supportedValuesOf("currency");
@@ -4410,26 +4410,26 @@ var CURRENCY_CODES = (() => {
4410
4410
  }
4411
4411
  })();
4412
4412
  var CODE_SET = new Set(CURRENCY_CODES);
4413
- /** Чи валідний ISO 4217 код. */
4413
+ /** Whether an ISO 4217 code is valid. */
4414
4414
  var isCurrencyCode = (c) => CODE_SET.has(c);
4415
4415
  //#endregion
4416
4416
  //#region ../sdk/src/fields/validation.ts
4417
- /** Структуроване повідомлення для zod: JSON {code, params}. Декодує mutate.ts. */
4417
+ /** Structured message for zod: JSON {code, params}. Decoded by mutate.ts. */
4418
4418
  function vmsg(code, params) {
4419
4419
  return JSON.stringify(params ? {
4420
4420
  code,
4421
4421
  params
4422
4422
  } : { code });
4423
4423
  }
4424
- /** v4 error-map: повідомлення для відсутнього значення (колишній required_error). */
4424
+ /** v4 error-map: message for a missing value (formerly required_error). */
4425
4425
  function reqErr(code = "required") {
4426
4426
  return { error: (iss) => iss.input === void 0 ? vmsg(code) : void 0 };
4427
4427
  }
4428
- /** v4 error-map: повідомлення для невалідного типу (колишній invalid_type_error). */
4428
+ /** v4 error-map: message for an invalid type (formerly invalid_type_error). */
4429
4429
  function typeErr(code = "invalid_type") {
4430
4430
  return { error: (iss) => iss.code === "invalid_type" ? vmsg(code) : void 0 };
4431
4431
  }
4432
- /** v4 error-map: required + invalid_type разом (колишні required_error + invalid_type_error). */
4432
+ /** v4 error-map: required + invalid_type together (formerly required_error + invalid_type_error). */
4433
4433
  function reqTypeErr() {
4434
4434
  return { error: (iss) => iss.code === "invalid_type" ? iss.input === void 0 ? vmsg("required") : vmsg("invalid_type") : void 0 };
4435
4435
  }
@@ -4447,10 +4447,10 @@ function jsonValue(raw) {
4447
4447
  return raw;
4448
4448
  }
4449
4449
  /**
4450
- * Фабрика для полів, що зберігають JSON і валідуються вкладеною zod-схемою.
4451
- * Приймає нативне object/array (native form-state) АБО JSON-рядок (legacy).
4452
- * Single-parse через jsonValue → inner.safeParse → issue `code`; рядок, що не
4453
- * розпарсився, лишається рядкомкод 'json'.
4450
+ * Factory for fields that store JSON and validate it with a nested zod schema.
4451
+ * Accepts a native object/array (native form state) OR a JSON string (legacy).
4452
+ * Single parse through jsonValue → inner.safeParse → issue `code`; an unparseable
4453
+ * string remains a string code 'json'.
4454
4454
  */
4455
4455
  function jsonRefined(inner, code) {
4456
4456
  return unknown().superRefine((raw, ctx) => {
@@ -4522,16 +4522,16 @@ function normalizeOpts(rawIn) {
4522
4522
  //#endregion
4523
4523
  //#region ../sdk/src/field-presets.ts
4524
4524
  /**
4525
- * Пресети полів тонкі обгортки над базовими примітивами з fields.ts.
4525
+ * Field presets are thin wrappers around the primitives in fields.ts.
4526
4526
  *
4527
- * Кожен пресет = один kind (один віджет). Пресети не приймають `format` —
4528
- * вони самі є семантичними типами. min/max/step через `config`.
4527
+ * Each preset = one kind (one widget). Presets do not accept `format`;
4528
+ * they are semantic types themselves. min/max/step go through `config`.
4529
4529
  *
4530
- * Пресети додаються до `f` через `composeF`, який гарантує, що жоден пресет
4531
- * не перебиває примітив.
4530
+ * Presets are added to `f` through `composeF`, which guarantees no preset
4531
+ * overrides a primitive.
4532
4532
  *
4533
- * Циклічний імпорт з fields.ts безпечний: фабрики/хелпери hoisted-декларації,
4534
- * а пресети викликають їх лише в тілі своїх функцій.
4533
+ * The cyclic import from fields.ts is safe: factories/helpers are hoisted declarations,
4534
+ * and presets call them only inside their function bodies.
4535
4535
  */
4536
4536
  function email(raw) {
4537
4537
  const o = normalizeOpts(raw);
@@ -4705,9 +4705,9 @@ function link(raw) {
4705
4705
  }, o.multiple ?? false));
4706
4706
  }
4707
4707
  var TEL_RE = /^\+?[\d\s()-]{4,}$/;
4708
- /** Loose URL: будь-який scheme:// АБО host-з-крапкою (+опц. порт/шлях). Без пробілів. */
4708
+ /** Loose URL: any scheme:// OR dotted host (optional port/path). No spaces. */
4709
4709
  var LINK_RE = /^([a-z][a-z0-9+.-]*:\/\/\S+|[\w-]+(\.[\w-]+)+(:\d+)?(\/\S*)?)$/i;
4710
- /** CSS named colors (CSS Color Module L4) для f.colorname. */
4710
+ /** CSS named colors (CSS Color Module L4) for f.colorname. */
4711
4711
  var CSS_COLOR_NAMES = /* @__PURE__ */ new Set([
4712
4712
  "aliceblue",
4713
4713
  "antiquewhite",
@@ -4966,7 +4966,7 @@ function reminder(raw) {
4966
4966
  }
4967
4967
  var _real = real;
4968
4968
  var _int = int;
4969
- /** Відсоток 0..100 — real з rules:{min:0,max:100}. */
4969
+ /** Percentage 0..100 — real with rules:{min:0,max:100}. */
4970
4970
  function percent(o) {
4971
4971
  return _real({
4972
4972
  ...o,
@@ -4977,7 +4977,7 @@ function percent(o) {
4977
4977
  }
4978
4978
  });
4979
4979
  }
4980
- /** Рік — int з rules:{min:1900,max:2100}; межі можна перекрити через rules.min/max. */
4980
+ /** Year — int with rules:{min:1900,max:2100}; bounds can be overridden via rules.min/max. */
4981
4981
  function year(o) {
4982
4982
  return _int({
4983
4983
  ...o,
@@ -6027,13 +6027,6 @@ function wrapKey(opts, meta) {
6027
6027
  role: opts.role
6028
6028
  }
6029
6029
  };
6030
- if (opts.pinned) m = {
6031
- ...m,
6032
- hints: {
6033
- ...m.hints,
6034
- pinned: true
6035
- }
6036
- };
6037
6030
  if (opts.default !== void 0) m = {
6038
6031
  ...m,
6039
6032
  default: opts.default
package/dist/web.js CHANGED
@@ -11,7 +11,7 @@ import { definePluginUI } from "@coffer-org/web-sdk";
11
11
  var ui_default = definePluginUI({ slots: [{
12
12
  slot: "overlay",
13
13
  id: "webchat",
14
- load: () => import("./ChatWidget-B5heKPXv.js")
14
+ load: () => import("./ChatWidget-Cew9znAf.js")
15
15
  }] });
16
16
  //#endregion
17
17
  export { ui_default as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffer-org/plugin-webchat",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -26,9 +26,9 @@
26
26
  "test:ui": "vitest run --root ."
27
27
  },
28
28
  "dependencies": {
29
- "@coffer-org/plugin-orchestrator": "^2.1.0",
30
- "@coffer-org/sdk": "^2.1.0",
31
- "@coffer-org/server": "^2.2.0",
29
+ "@coffer-org/plugin-orchestrator": "^2.1.1",
30
+ "@coffer-org/sdk": "^2.1.1",
31
+ "@coffer-org/server": "^2.2.2",
32
32
  "react-markdown": "^10.1.0",
33
33
  "remark-gfm": "^4.0.1"
34
34
  },