@coffer-org/plugin-webchat 2.1.1 → 2.2.1

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"
@@ -6117,6 +6185,30 @@ function useMinWidth(name, fallback) {
6117
6185
  return useMediaQuery(`(min-width: ${breakpointPx(name, fallback)}px)`, true);
6118
6186
  }
6119
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
6120
6212
  //#region src/ui/sse.ts
6121
6213
  async function* readSse(body) {
6122
6214
  const reader = body.getReader();
@@ -6180,7 +6272,8 @@ async function sendMessage(req) {
6180
6272
  convId: req.convId,
6181
6273
  text: req.text,
6182
6274
  replyTo: req.replyTo,
6183
- context: req.context
6275
+ context: req.context,
6276
+ ...req.attachments?.length ? { attachments: req.attachments } : {}
6184
6277
  }),
6185
6278
  signal: req.signal
6186
6279
  });
@@ -6276,7 +6369,7 @@ function useChat() {
6276
6369
  pending,
6277
6370
  pendingReasoning,
6278
6371
  error,
6279
- send: useCallback((text, context) => {
6372
+ send: useCallback((text, context, attachments = []) => {
6280
6373
  if (!text.trim() || sendingRef.current) return false;
6281
6374
  sendingRef.current = true;
6282
6375
  const myTurn = turnRef.current;
@@ -6287,7 +6380,8 @@ function useChat() {
6287
6380
  msgId: `local-${newId()}`,
6288
6381
  role: "user",
6289
6382
  text,
6290
- ts: Math.floor(Date.now() / 1e3)
6383
+ ts: Math.floor(Date.now() / 1e3),
6384
+ ...attachments.length ? { attachments } : {}
6291
6385
  }]);
6292
6386
  setPending("");
6293
6387
  const ctl = new AbortController();
@@ -6301,6 +6395,7 @@ function useChat() {
6301
6395
  text,
6302
6396
  replyTo: headRef.current,
6303
6397
  context,
6398
+ attachments,
6304
6399
  signal: ctl.signal,
6305
6400
  onDelta: (t) => {
6306
6401
  if (turnRef.current !== myTurn) return;
@@ -29112,7 +29207,8 @@ function ChatMessages({ messages, pending, pendingReasoning }) {
29112
29207
  children: [messages.map((m) => /* @__PURE__ */ jsx(Bubble, {
29113
29208
  role: m.role,
29114
29209
  text: m.text,
29115
- reasoning: m.reasoning
29210
+ reasoning: m.reasoning,
29211
+ attachments: m.attachments
29116
29212
  }, m.msgId)), pending !== null && /* @__PURE__ */ jsx(Bubble, {
29117
29213
  role: "assistant",
29118
29214
  text: pending || t("webchat.thinking"),
@@ -29126,11 +29222,18 @@ function ChatMessages({ messages, pending, pendingReasoning }) {
29126
29222
  * for every completed message while only one bubble at the bottom is changing.
29127
29223
  * The props are primitives, so the default shallow comparison is sufficient.
29128
29224
  */
29129
- var Bubble = memo(function Bubble({ role, text, reasoning }) {
29225
+ var Bubble = memo(function Bubble({ role, text, reasoning, attachments }) {
29130
29226
  const { t } = useTranslation();
29131
- if (role === "user") return /* @__PURE__ */ jsx("div", {
29227
+ if (role === "user") return /* @__PURE__ */ jsxs("div", {
29132
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",
29133
- 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]
29134
29237
  });
29135
29238
  return /* @__PURE__ */ jsxs("div", {
29136
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",
@@ -29151,37 +29254,91 @@ var Bubble = memo(function Bubble({ role, text, reasoning }) {
29151
29254
  });
29152
29255
  //#endregion
29153
29256
  //#region src/ui/ChatComposer.tsx
29154
- function ChatComposer({ streaming, onSend, onStop }) {
29257
+ function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploadError, onPickFiles, onRemoveAttachment }) {
29155
29258
  const { t } = useTranslation();
29156
29259
  const [text, setText] = useState("");
29157
29260
  function submit() {
29158
29261
  if (!text.trim() || streaming) return;
29159
- if (onSend(text.trim())) setText("");
29262
+ if (onSend(text.trim(), attachments)) setText("");
29160
29263
  }
29161
29264
  return /* @__PURE__ */ jsxs("div", {
29162
- className: "flex shrink-0 items-end gap-2 border-t border-border p-2",
29163
- children: [/* @__PURE__ */ jsx("textarea", {
29164
- className: "flex-1 resize-none rounded-lg border border-border bg-bg px-3 py-2 text-sm max-h-32",
29165
- rows: 1,
29166
- value: text,
29167
- placeholder: t("webchat.placeholder"),
29168
- onChange: (e) => setText(e.target.value),
29169
- onKeyDown: (e) => {
29170
- if (e.key === "Enter" && !e.shiftKey) {
29171
- e.preventDefault();
29172
- submit();
29173
- }
29174
- }
29175
- }), streaming ? /* @__PURE__ */ jsx("button", {
29176
- "aria-label": t("webchat.stop"),
29177
- className: "rounded-lg p-2 hover:bg-elevated",
29178
- onClick: onStop,
29179
- children: /* @__PURE__ */ jsx(Square, { size: 18 })
29180
- }) : /* @__PURE__ */ jsx("button", {
29181
- "aria-label": t("webchat.send"),
29182
- className: "rounded-lg p-2 hover:bg-elevated",
29183
- onClick: submit,
29184
- 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
+ ]
29185
29342
  })]
29186
29343
  });
29187
29344
  }
@@ -29212,6 +29369,66 @@ function ChatThreadList({ threads, activeId, onOpen, onNew, className = "w-[220p
29212
29369
  }
29213
29370
  //#endregion
29214
29371
  //#region src/ui/ChatWidget.tsx
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
+ }
29215
29432
  /** What to do with the widget after following a link from a response. On desktop a 380px
29216
29433
  * panel does not obstruct the page (a darkened modal does, so it collapses into it);
29217
29434
  * below the nav breakpoint the panel consumes nearly the whole screen. */
@@ -29224,6 +29441,9 @@ function ChatWidget() {
29224
29441
  const page = usePageContext();
29225
29442
  const [mode, setMode] = useState("bubble");
29226
29443
  const [threadsOpen, setThreadsOpen] = useState(false);
29444
+ const [attachments, setAttachments] = useState([]);
29445
+ const [uploading, setUploading] = useState(false);
29446
+ const [uploadError, setUploadError] = useState(null);
29227
29447
  const wide = useMinWidth("sm", 640);
29228
29448
  const wideNav = useMinWidth("nav", 768);
29229
29449
  const onChatNavigate = useCallback(() => {
@@ -29258,8 +29478,36 @@ function ChatWidget() {
29258
29478
  }, [isOpen, chat.loadThreads]);
29259
29479
  function startNew() {
29260
29480
  chat.newConversation();
29481
+ setAttachments([]);
29482
+ setUploadError(null);
29261
29483
  setThreadsOpen(false);
29262
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
+ }, []);
29263
29511
  const hasConversation = chat.messages.length > 0 || chat.pending !== null;
29264
29512
  const header = /* @__PURE__ */ jsxs("div", {
29265
29513
  className: "flex shrink-0 items-center justify-between border-b border-border px-3 py-2",
@@ -29314,7 +29562,19 @@ function ChatWidget() {
29314
29562
  }),
29315
29563
  /* @__PURE__ */ jsx(ChatComposer, {
29316
29564
  streaming: chat.pending !== null,
29317
- 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
+ },
29318
29578
  onStop: chat.stop
29319
29579
  })
29320
29580
  ]
@@ -29326,7 +29586,8 @@ function ChatWidget() {
29326
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",
29327
29587
  children: /* @__PURE__ */ jsx(MessageCircle, { size: 24 })
29328
29588
  });
29329
- if (mode === "panel") return /* @__PURE__ */ jsxs("div", {
29589
+ if (mode === "panel") return /* @__PURE__ */ jsxs(ChatDropSurface, {
29590
+ onFiles: pickFiles,
29330
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",
29331
29592
  children: [header, conversation]
29332
29593
  });
@@ -29341,21 +29602,27 @@ function ChatWidget() {
29341
29602
  }), /* @__PURE__ */ jsx(DialogContent, {
29342
29603
  hideClose: true,
29343
29604
  className: "h-[85dvh] w-full p-0 md:h-[85dvh] md:w-[80vw] md:max-w-[1040px] md:overflow-y-hidden",
29344
- children: /* @__PURE__ */ jsxs("div", {
29345
- className: "flex h-full min-h-0 flex-col",
29346
- children: [header, /* @__PURE__ */ jsxs("div", {
29347
- className: "flex min-h-0 flex-1",
29348
- children: [(wide || threadsOpen) && /* @__PURE__ */ jsx(ChatThreadList, {
29349
- threads: chat.threads,
29350
- activeId: chat.convId,
29351
- onOpen: (id) => {
29352
- chat.open(id);
29353
- setThreadsOpen(false);
29354
- },
29355
- onNew: startNew,
29356
- className: wide ? "w-[220px] shrink-0" : "w-full"
29357
- }), (wide || !threadsOpen) && conversation]
29358
- })]
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
+ })
29359
29626
  })
29360
29627
  })]
29361
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
  }
@@ -2,12 +2,16 @@ import { pruneThreadMessages } from '@coffer-org/server/thread-store';
2
2
  import { threadsAction, historyAction } from "./actions.js";
3
3
  import { sendAction } from "./send.js";
4
4
  import { CONNECTOR } from "./chain-store.js";
5
+ import { registerConnector } from '@coffer-org/plugin-orchestrator/runtime';
5
6
  const THREAD_TTL_MS = Number(process.env['WEBCHAT_THREAD_TTL_MS'] ?? 30 * 86_400_000);
7
+ let unregisterConnector;
6
8
  export { WEB_FORMAT } from "./format.js";
7
9
  export { makeStreamingConnector } from "./connector.js";
8
10
  export { chatIdFor, chatPrefixFor, recordUser, recordAssistant, buildChain } from "./chain-store.js";
9
11
  export { sendAction, pageContext } from "./send.js";
10
12
  export const serverHooks = {
13
+ init: () => { unregisterConnector = registerConnector({ id: 'webchat' }); },
14
+ teardown: () => { unregisterConnector?.(); unregisterConnector = undefined; },
11
15
  userActions: {
12
16
  threads: threadsAction,
13
17
  history: historyAction,
@@ -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/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-U4vyG1gt.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.1",
3
+ "version": "2.2.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -26,8 +26,8 @@
26
26
  "test:ui": "vitest run --root ."
27
27
  },
28
28
  "dependencies": {
29
- "@coffer-org/plugin-orchestrator": "^2.1.1",
30
- "@coffer-org/sdk": "^2.1.1",
29
+ "@coffer-org/plugin-orchestrator": "^2.3.0",
30
+ "@coffer-org/sdk": "^2.1.2",
31
31
  "@coffer-org/server": "^2.2.2",
32
32
  "react-markdown": "^10.1.0",
33
33
  "remark-gfm": "^4.0.1"