@coffer-org/plugin-webchat 2.2.6 → 2.3.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,26 @@ 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 Check = createLucideIcon("check", [["path", {
511
+ d: "M20 6 9 17l-5-5",
512
+ key: "1gmf2c"
513
+ }]]);
514
+ /**
515
+ * @license lucide-react v1.24.0 - ISC
516
+ *
517
+ * This source code is licensed under the ISC license.
518
+ * See the LICENSE file in the root directory of this source tree.
519
+ */
520
+ var ChevronDown = createLucideIcon("chevron-down", [["path", {
521
+ d: "m6 9 6 6 6-6",
522
+ key: "qrunsl"
523
+ }]]);
524
+ /**
525
+ * @license lucide-react v1.24.0 - ISC
526
+ *
527
+ * This source code is licensed under the ISC license.
528
+ * See the LICENSE file in the root directory of this source tree.
529
+ */
510
530
  var FileText = createLucideIcon("file-text", [
511
531
  ["path", {
512
532
  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",
@@ -6291,28 +6311,45 @@ async function* readSse(body) {
6291
6311
  /** Web-chat transport. Session cookies are sent automatically (same-origin), so there is no
6292
6312
  * separate authorization here; the gate is on the server. */
6293
6313
  var BASE = "/api/plugins/webchat/user";
6314
+ /** POST a member-level webchat action with a JSON body: the one request path every call
6315
+ * in this file (except the SSE stream in `sendMessage`) goes through. Throws `Error(action)`
6316
+ * on any non-OK response — every caller below relies on that to build its own error message. */
6317
+ async function postAction(action, body) {
6318
+ const r = await fetch(`${BASE}/${action}`, {
6319
+ method: "POST",
6320
+ headers: { "content-type": "application/json" },
6321
+ body: JSON.stringify(body)
6322
+ });
6323
+ if (!r.ok) throw new Error(action);
6324
+ return await r.json();
6325
+ }
6294
6326
  /**
6295
6327
  * Throws on any rejection, including 404 (“plugin was disabled AFTER the widget bundle
6296
6328
  * loaded”). There is deliberately no separate `null` for this case: the caller keeps
6297
6329
  * the current thread list either way, so two return codes would mean the same thing.
6298
6330
  */
6299
6331
  async function fetchThreads() {
6300
- const r = await fetch(`${BASE}/threads`, {
6301
- method: "POST",
6302
- headers: { "content-type": "application/json" },
6303
- body: "{}"
6304
- });
6305
- if (!r.ok) throw new Error("threads");
6306
- return (await r.json()).threads;
6332
+ return (await postAction("threads", {})).threads;
6307
6333
  }
6308
6334
  async function fetchHistory(convId) {
6309
- const r = await fetch(`${BASE}/history`, {
6310
- method: "POST",
6311
- headers: { "content-type": "application/json" },
6312
- body: JSON.stringify({ convId })
6313
- });
6314
- if (!r.ok) throw new Error("history");
6315
- return await r.json();
6335
+ return postAction("history", { convId });
6336
+ }
6337
+ async function fetchAgents(convId) {
6338
+ const out = await postAction("agents", { convId });
6339
+ return {
6340
+ agents: out.agents ?? [],
6341
+ defaultAgentId: out.defaultAgentId ?? null,
6342
+ selection: out.selection ?? {
6343
+ agentId: null,
6344
+ presetId: null
6345
+ }
6346
+ };
6347
+ }
6348
+ async function selectAgent(convId, patch) {
6349
+ return (await postAction("selectAgent", {
6350
+ convId,
6351
+ ...patch
6352
+ })).selection;
6316
6353
  }
6317
6354
  async function sendMessage(req) {
6318
6355
  const r = await fetch(`${BASE}/send/stream`, {
@@ -6356,6 +6393,12 @@ function useChat() {
6356
6393
  const [pending, setPending] = useState(null);
6357
6394
  const [pendingReasoning, setPendingReasoning] = useState(null);
6358
6395
  const [error, setError] = useState(null);
6396
+ const [agents, setAgents] = useState([]);
6397
+ const [defaultAgentId, setDefaultAgentId] = useState(null);
6398
+ const [selection, setSelection] = useState({
6399
+ agentId: null,
6400
+ presetId: null
6401
+ });
6359
6402
  const headRef = useRef(null);
6360
6403
  const abortRef = useRef(null);
6361
6404
  const sendingRef = useRef(false);
@@ -6374,6 +6417,32 @@ function useChat() {
6374
6417
  if (aliveRef.current) setThreads(list);
6375
6418
  }).catch(() => {});
6376
6419
  }, []);
6420
+ useEffect(() => {
6421
+ if (!convId) return;
6422
+ let cancelled = false;
6423
+ fetchAgents(convId).then((r) => {
6424
+ if (cancelled) return;
6425
+ setAgents(r.agents);
6426
+ setDefaultAgentId(r.defaultAgentId);
6427
+ setSelection(r.selection);
6428
+ }).catch(() => {});
6429
+ return () => {
6430
+ cancelled = true;
6431
+ };
6432
+ }, [convId]);
6433
+ const chooseAgent = useCallback((next) => {
6434
+ const previous = selection;
6435
+ setSelection(next);
6436
+ if (convId) selectAgent(convId, next).then(setSelection).catch((err) => {
6437
+ setSelection(previous);
6438
+ setError(err instanceof Error ? err.message : String(err));
6439
+ });
6440
+ }, [convId, selection]);
6441
+ const attachmentsAllowed = useMemo(() => {
6442
+ const preset = agents.find((a) => a.id === selection.agentId)?.presets.find((p) => p.id === selection.presetId);
6443
+ if (!preset?.capabilities) return true;
6444
+ return preset.capabilities.vision !== false || preset.capabilities.documents !== false;
6445
+ }, [agents, selection]);
6377
6446
  const newConversation = useCallback(() => {
6378
6447
  abortRef.current?.abort();
6379
6448
  abortRef.current = null;
@@ -6419,6 +6488,11 @@ function useChat() {
6419
6488
  pending,
6420
6489
  pendingReasoning,
6421
6490
  error,
6491
+ agents,
6492
+ defaultAgentId,
6493
+ selection,
6494
+ attachmentsAllowed,
6495
+ chooseAgent,
6422
6496
  send: useCallback((text, context, attachments = []) => {
6423
6497
  if (!text.trim() || sendingRef.current) return false;
6424
6498
  sendingRef.current = true;
@@ -29309,8 +29383,97 @@ var Bubble = memo(function Bubble({ role, text, reasoning, attachments }) {
29309
29383
  });
29310
29384
  });
29311
29385
  //#endregion
29386
+ //#region src/ui/AgentPicker.tsx
29387
+ /** Flatten the catalog to one list: with a single registered agent the agent
29388
+ * level is noise, and with several the agent name simply prefixes its presets. */
29389
+ function AgentPicker({ agents, defaultAgentId, selection, onChange }) {
29390
+ const { t } = useTranslation();
29391
+ const [open, setOpen] = useState(false);
29392
+ const rootRef = useRef(null);
29393
+ const triggerRef = useRef(null);
29394
+ useEffect(() => {
29395
+ if (!open) return;
29396
+ function onPointerDown(e) {
29397
+ if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
29398
+ }
29399
+ function onKeyDown(e) {
29400
+ if (e.key === "Escape") {
29401
+ setOpen(false);
29402
+ triggerRef.current?.focus();
29403
+ }
29404
+ }
29405
+ document.addEventListener("pointerdown", onPointerDown);
29406
+ document.addEventListener("keydown", onKeyDown);
29407
+ return () => {
29408
+ document.removeEventListener("pointerdown", onPointerDown);
29409
+ document.removeEventListener("keydown", onKeyDown);
29410
+ };
29411
+ }, [open]);
29412
+ const multi = agents.length > 1;
29413
+ const rows = agents.flatMap((a) => a.presets.map((p) => ({
29414
+ agentId: a.id,
29415
+ presetId: p.id,
29416
+ label: multi ? `${a.title} · ${p.title}` : p.title,
29417
+ hint: p.hint,
29418
+ isDefault: p.default === true
29419
+ })));
29420
+ if (rows.length === 0) return null;
29421
+ const current = rows.find((r) => r.agentId === selection.agentId && r.presetId === selection.presetId) ?? rows.find((r) => r.agentId === defaultAgentId && r.isDefault) ?? rows.find((r) => r.isDefault) ?? rows[0];
29422
+ return /* @__PURE__ */ jsxs("div", {
29423
+ className: "relative",
29424
+ ref: rootRef,
29425
+ children: [/* @__PURE__ */ jsxs("button", {
29426
+ ref: triggerRef,
29427
+ type: "button",
29428
+ "aria-haspopup": "menu",
29429
+ "aria-expanded": open,
29430
+ className: "flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted hover:text-text focus-visible:outline-2 focus-visible:outline-accent",
29431
+ onClick: () => setOpen((v) => !v),
29432
+ children: [/* @__PURE__ */ jsx("span", {
29433
+ className: "max-w-40 truncate",
29434
+ children: current.label
29435
+ }), /* @__PURE__ */ jsx(ChevronDown, {
29436
+ size: 12,
29437
+ className: "shrink-0"
29438
+ })]
29439
+ }), open && /* @__PURE__ */ jsx("ul", {
29440
+ "aria-label": t("webchat.agentPicker"),
29441
+ className: "absolute bottom-full z-10 mb-1 max-h-64 w-64 overflow-y-auto rounded-md border border-border bg-elevated py-1 shadow-lg",
29442
+ children: rows.map((r) => {
29443
+ const selected = r === current;
29444
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
29445
+ type: "button",
29446
+ ...selected ? { "aria-current": true } : {},
29447
+ className: "flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-surface focus-visible:outline-2 focus-visible:outline-accent",
29448
+ onClick: () => {
29449
+ setOpen(false);
29450
+ onChange({
29451
+ agentId: r.agentId,
29452
+ presetId: r.presetId
29453
+ });
29454
+ },
29455
+ children: [
29456
+ /* @__PURE__ */ jsx(Check, {
29457
+ size: 12,
29458
+ className: selected ? "shrink-0 text-accent" : "shrink-0 opacity-0"
29459
+ }),
29460
+ /* @__PURE__ */ jsx("span", {
29461
+ className: "min-w-0 flex-1 truncate",
29462
+ children: r.label
29463
+ }),
29464
+ r.hint && /* @__PURE__ */ jsx("span", {
29465
+ className: "shrink-0 text-muted",
29466
+ children: r.hint
29467
+ })
29468
+ ]
29469
+ }) }, `${r.agentId}/${r.presetId}`);
29470
+ })
29471
+ })]
29472
+ });
29473
+ }
29474
+ //#endregion
29312
29475
  //#region src/ui/ChatComposer.tsx
29313
- function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploadError, uploadProgress, canRetryUpload, onRetryUpload, onPickFiles, onRemoveAttachment }) {
29476
+ function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploadError, uploadProgress, canRetryUpload, onRetryUpload, onPickFiles, onRemoveAttachment, agents, defaultAgentId, selection, onChooseAgent, attachmentsAllowed }) {
29314
29477
  const { t } = useTranslation();
29315
29478
  const [text, setText] = useState("");
29316
29479
  function submit() {
@@ -29389,7 +29552,7 @@ function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploa
29389
29552
  }), /* @__PURE__ */ jsxs("div", {
29390
29553
  className: "flex items-end gap-2",
29391
29554
  children: [
29392
- /* @__PURE__ */ jsxs("label", {
29555
+ attachmentsAllowed && /* @__PURE__ */ jsxs("label", {
29393
29556
  "aria-label": t("webchat.attach"),
29394
29557
  className: `cursor-pointer rounded-lg p-2 text-muted hover:bg-elevated hover:text-accent ${uploading ? "pointer-events-none opacity-60" : ""}`,
29395
29558
  children: [/* @__PURE__ */ jsx(Paperclip, { size: 18 }), /* @__PURE__ */ jsx("input", {
@@ -29416,6 +29579,12 @@ function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploa
29416
29579
  }
29417
29580
  }
29418
29581
  }),
29582
+ /* @__PURE__ */ jsx(AgentPicker, {
29583
+ agents,
29584
+ defaultAgentId,
29585
+ selection,
29586
+ onChange: onChooseAgent
29587
+ }),
29419
29588
  streaming ? /* @__PURE__ */ jsx("button", {
29420
29589
  "aria-label": t("webchat.stop"),
29421
29590
  className: "rounded-lg p-2 hover:bg-elevated",
@@ -29520,9 +29689,10 @@ function ChatDropSurface({ children, onFiles, className }) {
29520
29689
  }
29521
29690
  /** What to do with the widget after following a link from a response. On desktop a 380px
29522
29691
  * panel does not obstruct the page (a darkened modal does, so it collapses into it);
29523
- * below the nav breakpoint the panel consumes nearly the whole screen. */
29692
+ * on narrow screens the chat remains expanded so the user does not lose the active
29693
+ * conversation after an in-app navigation. */
29524
29694
  function nextModeAfterNav(wideNav) {
29525
- return wideNav ? "panel" : "bubble";
29695
+ return wideNav ? "panel" : "modal";
29526
29696
  }
29527
29697
  function ChatWidget() {
29528
29698
  const { t } = useTranslation();
@@ -29567,6 +29737,14 @@ function ChatWidget() {
29567
29737
  useEffect(() => {
29568
29738
  if (isOpen) chat.loadThreads();
29569
29739
  }, [isOpen, chat.loadThreads]);
29740
+ const { convId, newConversation } = chat;
29741
+ useEffect(() => {
29742
+ if (isOpen && convId === null) newConversation();
29743
+ }, [
29744
+ isOpen,
29745
+ convId,
29746
+ newConversation
29747
+ ]);
29570
29748
  function startNew() {
29571
29749
  chat.newConversation();
29572
29750
  setAttachments([]);
@@ -29675,6 +29853,11 @@ function ChatWidget() {
29675
29853
  onRetryUpload: retryUploads,
29676
29854
  onPickFiles: (files) => void pickFiles(Array.from(files)),
29677
29855
  onRemoveAttachment: (index) => setAttachments((prev) => prev.filter((_, i) => i !== index)),
29856
+ agents: chat.agents,
29857
+ defaultAgentId: chat.defaultAgentId,
29858
+ selection: chat.selection,
29859
+ onChooseAgent: chat.chooseAgent,
29860
+ attachmentsAllowed: chat.attachmentsAllowed,
29678
29861
  onSend: (text, refs) => {
29679
29862
  const accepted = refs.length ? chat.send(text, page, refs) : chat.send(text, page);
29680
29863
  if (accepted) {
@@ -1,4 +1,6 @@
1
1
  import { type ActionCaller } from '@coffer-org/server/plugin-hooks';
2
+ import { type AgentDescriptor } from '@coffer-org/plugin-orchestrator/runtime';
3
+ import { type ThreadSelection } from './chain-store.ts';
2
4
  export interface ThreadSummary {
3
5
  convId: string;
4
6
  title: string;
@@ -26,3 +28,11 @@ export declare function historyAction(body: Record<string, unknown>, caller: Act
26
28
  messages: HistoryMsg[];
27
29
  headMsgId: string | null;
28
30
  }>;
31
+ export declare function agentsAction(body: Record<string, unknown>, caller: ActionCaller): Promise<{
32
+ agents: AgentDescriptor[];
33
+ defaultAgentId: string | null;
34
+ selection: ThreadSelection;
35
+ }>;
36
+ export declare function selectAgentAction(body: Record<string, unknown>, caller: ActionCaller): Promise<{
37
+ selection: ThreadSelection;
38
+ }>;
@@ -1,5 +1,6 @@
1
1
  import { HttpError } from '@coffer-org/server/plugin-hooks';
2
- import { chatIdFor, conversations, history } from "./chain-store.js";
2
+ import { listAgentCatalog, getDefaultAgentId, listRegisteredAgents, } from '@coffer-org/plugin-orchestrator/runtime';
3
+ import { chatIdFor, conversations, history, getSelection, setSelection } from "./chain-store.js";
3
4
  const TITLE_MAX = 60;
4
5
  function title(firstUserText) {
5
6
  const t = firstUserText.trim().replace(/\s+/g, ' ');
@@ -17,10 +18,14 @@ export async function threadsAction(_body, caller) {
17
18
  })),
18
19
  };
19
20
  }
20
- export async function historyAction(body, caller) {
21
+ function requireConvId(body) {
21
22
  const convId = body['convId'];
22
23
  if (typeof convId !== 'string' || !convId)
23
24
  throw new HttpError(400, 'missing required field: convId');
25
+ return convId;
26
+ }
27
+ export async function historyAction(body, caller) {
28
+ const convId = requireConvId(body);
24
29
  const rows = await history(chatIdFor(caller.id, convId));
25
30
  const reasoningByBot = new Map(rows.filter((m) => m.role === 'reasoning').map((m) => [m.msgId.slice(0, -2), m.text]));
26
31
  const visible = rows.filter((m) => m.role !== 'reasoning');
@@ -37,3 +42,26 @@ export async function historyAction(body, caller) {
37
42
  headMsgId: visible.at(-1)?.msgId ?? null,
38
43
  };
39
44
  }
45
+ export async function agentsAction(body, caller) {
46
+ const convId = requireConvId(body);
47
+ return {
48
+ agents: await listAgentCatalog(),
49
+ defaultAgentId: getDefaultAgentId() ?? null,
50
+ selection: await getSelection(chatIdFor(caller.id, convId)),
51
+ };
52
+ }
53
+ export async function selectAgentAction(body, caller) {
54
+ const convId = requireConvId(body);
55
+ const chatId = chatIdFor(caller.id, convId);
56
+ const patch = {};
57
+ if ('agentId' in body) {
58
+ const agentId = typeof body['agentId'] === 'string' && body['agentId'] ? body['agentId'] : null;
59
+ if (agentId && !listRegisteredAgents().includes(agentId))
60
+ throw new HttpError(400, `unknown agent: ${agentId}`);
61
+ patch.agentId = agentId;
62
+ }
63
+ if ('presetId' in body)
64
+ patch.presetId = typeof body['presetId'] === 'string' && body['presetId'] ? body['presetId'] : null;
65
+ await setSelection(chatId, patch);
66
+ return { selection: await getSelection(chatId) };
67
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ThreadChat, type StoredMsg } from '@coffer-org/server/thread-store';
2
+ import { type ThreadSelection } from '@coffer-org/server/thread-state';
2
3
  import type { ConvMessage } from '@coffer-org/plugin-orchestrator/runtime';
3
4
  export declare const CONNECTOR = "webchat";
4
5
  export declare function chatIdFor(userId: string, convId: string): string;
@@ -32,3 +33,7 @@ export declare function buildChain(headMsgId: string, opts: {
32
33
  }): Promise<ConvMessage[]>;
33
34
  export declare function history(chatId: string, limit?: number): Promise<StoredMsg[]>;
34
35
  export declare function conversations(userId: string): Promise<ThreadChat[]>;
36
+ export type { ThreadSelection };
37
+ export declare function getSelection(chatId: string): Promise<ThreadSelection>;
38
+ export declare function setSelection(chatId: string, patch: Partial<ThreadSelection>): Promise<void>;
39
+ export declare function readSelectionForTurn(chatId: string): Promise<ThreadSelection>;
@@ -1,4 +1,5 @@
1
- import { getThreadMessage, putThreadMessage, listThreadMessages, listThreadChats } from '@coffer-org/server/thread-store';
1
+ import { getThreadMessage, putThreadMessage, listThreadMessages, listThreadChats, } from '@coffer-org/server/thread-store';
2
+ import { getThreadState, setThreadState, readAndTouchThreadState, } from '@coffer-org/server/thread-state';
2
3
  import { getLogger } from '@coffer-org/sdk/logger';
3
4
  const log = getLogger('webchat');
4
5
  export const CONNECTOR = 'webchat';
@@ -25,7 +26,15 @@ export async function recordUser(m) {
25
26
  export async function recordAssistant(m) {
26
27
  if (m.botMsgId == null || !m.text.trim())
27
28
  return false;
28
- await putThreadMessage({ connector: CONNECTOR, chatId: m.chatId, msgId: m.botMsgId, role: 'assistant', text: m.text, ts: m.now, replyToId: m.parentMsgId });
29
+ await putThreadMessage({
30
+ connector: CONNECTOR,
31
+ chatId: m.chatId,
32
+ msgId: m.botMsgId,
33
+ role: 'assistant',
34
+ text: m.text,
35
+ ts: m.now,
36
+ replyToId: m.parentMsgId,
37
+ });
29
38
  return true;
30
39
  }
31
40
  export async function recordReasoning(m) {
@@ -76,3 +85,12 @@ export function history(chatId, limit) {
76
85
  export function conversations(userId) {
77
86
  return listThreadChats(CONNECTOR, chatPrefixFor(userId));
78
87
  }
88
+ export async function getSelection(chatId) {
89
+ return getThreadState(CONNECTOR, chatId);
90
+ }
91
+ export async function setSelection(chatId, patch) {
92
+ await setThreadState(CONNECTOR, chatId, patch);
93
+ }
94
+ export async function readSelectionForTurn(chatId) {
95
+ return readAndTouchThreadState(CONNECTOR, chatId);
96
+ }
@@ -11,8 +11,13 @@ export function makeStreamingConnector(opts) {
11
11
  reply(_chatId, _ctx) {
12
12
  const ch = makeLiveChannel({
13
13
  ops: {
14
- send: async (text) => { opts.emit('message', { msgId: opts.botMsgId, text }); return opts.botMsgId; },
15
- edit: async (msgId, text) => { opts.emit('delta', { msgId, text }); },
14
+ send: async (text) => {
15
+ opts.emit('message', { msgId: opts.botMsgId, text });
16
+ return opts.botMsgId;
17
+ },
18
+ edit: async (msgId, text) => {
19
+ opts.emit('delta', { msgId, text });
20
+ },
16
21
  },
17
22
  throttleMs: WEBCHAT_THROTTLE_MS,
18
23
  maxLength: WEBCHAT_MAX,
@@ -29,10 +34,22 @@ export function makeStreamingConnector(opts) {
29
34
  },
30
35
  async recordAssistant(m) {
31
36
  const now = Math.floor(Date.now() / 1000);
32
- const wrote = await record({ chatId: opts.chatId, parentMsgId: m.parentMsgId, botMsgId: m.botMsgId, text: m.text, now });
37
+ const wrote = await record({
38
+ chatId: opts.chatId,
39
+ parentMsgId: m.parentMsgId,
40
+ botMsgId: m.botMsgId,
41
+ text: m.text,
42
+ now,
43
+ });
33
44
  recordedId = wrote ? m.botMsgId : null;
34
45
  if (wrote && opts.display !== 'off' && m.reasoning && m.botMsgId) {
35
- await recordReasoningFn({ chatId: opts.chatId, botMsgId: m.botMsgId, parentMsgId: m.parentMsgId, text: m.reasoning, now });
46
+ await recordReasoningFn({
47
+ chatId: opts.chatId,
48
+ botMsgId: m.botMsgId,
49
+ parentMsgId: m.parentMsgId,
50
+ text: m.reasoning,
51
+ now,
52
+ });
36
53
  }
37
54
  },
38
55
  };
@@ -2,6 +2,6 @@ import type { PluginHooks } from '@coffer-org/server/plugin-hooks';
2
2
  export type { ThreadSummary, HistoryMsg } from './actions.ts';
3
3
  export { WEB_FORMAT } from './format.ts';
4
4
  export { makeStreamingConnector } from './connector.ts';
5
- export { chatIdFor, chatPrefixFor, recordUser, recordAssistant, buildChain } from './chain-store.ts';
5
+ export { chatIdFor, chatPrefixFor, recordUser, recordAssistant, buildChain, getSelection, setSelection, } from './chain-store.ts';
6
6
  export { sendAction, pageContext } from './send.ts';
7
7
  export declare const serverHooks: PluginHooks;
@@ -1,5 +1,6 @@
1
1
  import { pruneThreadMessages } from '@coffer-org/server/thread-store';
2
- import { threadsAction, historyAction } from "./actions.js";
2
+ import { pruneThreadState } from '@coffer-org/server/thread-state';
3
+ import { threadsAction, historyAction, agentsAction, selectAgentAction } from "./actions.js";
3
4
  import { sendAction } from "./send.js";
4
5
  import { CONNECTOR } from "./chain-store.js";
5
6
  import { registerConnector } from '@coffer-org/plugin-orchestrator/runtime';
@@ -7,14 +8,21 @@ const THREAD_TTL_MS = Number(process.env['WEBCHAT_THREAD_TTL_MS'] ?? 30 * 86_400
7
8
  let unregisterConnector;
8
9
  export { WEB_FORMAT } from "./format.js";
9
10
  export { makeStreamingConnector } from "./connector.js";
10
- export { chatIdFor, chatPrefixFor, recordUser, recordAssistant, buildChain } from "./chain-store.js";
11
+ export { chatIdFor, chatPrefixFor, recordUser, recordAssistant, buildChain, getSelection, setSelection, } from "./chain-store.js";
11
12
  export { sendAction, pageContext } from "./send.js";
12
13
  export const serverHooks = {
13
- init: () => { unregisterConnector = registerConnector({ id: 'webchat' }); },
14
- teardown: () => { unregisterConnector?.(); unregisterConnector = undefined; },
14
+ init: () => {
15
+ unregisterConnector = registerConnector({ id: 'webchat' });
16
+ },
17
+ teardown: () => {
18
+ unregisterConnector?.();
19
+ unregisterConnector = undefined;
20
+ },
15
21
  userActions: {
16
22
  threads: threadsAction,
17
23
  history: historyAction,
24
+ agents: agentsAction,
25
+ selectAgent: selectAgentAction,
18
26
  },
19
27
  streamActions: {
20
28
  send: (body, ctx) => sendAction(body, ctx),
@@ -24,7 +32,9 @@ export const serverHooks = {
24
32
  name: 'webchat-thread-prune',
25
33
  intervalMs: 86_400_000,
26
34
  run: async () => {
27
- await pruneThreadMessages(CONNECTOR, Math.floor((Date.now() - THREAD_TTL_MS) / 1000));
35
+ const cutoff = new Date(Date.now() - THREAD_TTL_MS);
36
+ await pruneThreadMessages(CONNECTOR, Math.floor(cutoff.getTime() / 1000));
37
+ await pruneThreadState(CONNECTOR, cutoff.toISOString());
28
38
  },
29
39
  },
30
40
  ],
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { handleIncoming } from '@coffer-org/plugin-orchestrator/runtime';
3
- import { chatIdFor, recordUser, buildChain, history } from "./chain-store.js";
2
+ import { handleIncoming, liveAgentId } from '@coffer-org/plugin-orchestrator/runtime';
3
+ import { chatIdFor, recordUser, buildChain, history, readSelectionForTurn } from "./chain-store.js";
4
4
  import { policy } from "./config.js";
5
5
  import { makeStreamingConnector } from "./connector.js";
6
6
  import { WEB_FORMAT } from "./format.js";
@@ -31,6 +31,8 @@ export async function sendAction(body, ctx, deps = {}) {
31
31
  return;
32
32
  }
33
33
  const chatId = chatIdFor(ctx.caller.id, convId);
34
+ const selection = await readSelectionForTurn(chatId);
35
+ const agentId = liveAgentId(selection.agentId);
34
36
  const msgId = randomUUID();
35
37
  const botMsgId = randomUUID();
36
38
  const nowSec = Math.floor(Date.now() / 1000);
@@ -50,6 +52,8 @@ export async function sendAction(body, ctx, deps = {}) {
50
52
  connectorId: 'webchat',
51
53
  channelSystem: WEB_FORMAT,
52
54
  ...(volatileSystem ? { volatileSystem } : {}),
55
+ ...(agentId ? { agentId } : {}),
56
+ ...(selection.presetId ? { presetId: selection.presetId } : {}),
53
57
  chatId,
54
58
  sender: { id: ctx.caller.id },
55
59
  messages,
@@ -65,12 +69,14 @@ function parseAttachments(value) {
65
69
  const r = item;
66
70
  if (typeof r['name'] !== 'string' || !r['name'])
67
71
  return [];
68
- return [{
72
+ return [
73
+ {
69
74
  name: r['name'],
70
75
  ...(typeof r['mime'] === 'string' ? { mime: r['mime'] } : {}),
71
76
  ...(typeof r['size'] === 'number' ? { size: r['size'] } : {}),
72
77
  ...(typeof r['label'] === 'string' ? { label: r['label'] } : {}),
73
- }];
78
+ },
79
+ ];
74
80
  });
75
81
  return refs.length ? refs : undefined;
76
82
  }
package/dist/schema.js CHANGED
@@ -4502,6 +4502,8 @@ function normalizeOpts(rawIn) {
4502
4502
  unique: r.unique,
4503
4503
  fixed: r.fixed,
4504
4504
  exts: r.exts,
4505
+ ext: r.ext,
4506
+ maxBytes: r.maxBytes,
4505
4507
  lead: r.lead,
4506
4508
  by: r.by,
4507
4509
  min: r.min,
@@ -4913,6 +4915,42 @@ function snippet(raw) {
4913
4915
  }
4914
4916
  });
4915
4917
  }
4918
+ /** A NUL byte, or U+FFFD left behind by decoding non-UTF-8 bytes as text. */
4919
+ function isBinaryText(v) {
4920
+ return v.includes("\0") || v.includes("�");
4921
+ }
4922
+ function source(raw) {
4923
+ const o = normalizeOpts(raw);
4924
+ const required = o.required ?? false;
4925
+ const ext = o.ext ?? "txt";
4926
+ const maxBytes = o.maxBytes ?? 262144;
4927
+ const s = string$1(reqTypeErr()).superRefine((v, ctx) => {
4928
+ if (isBinaryText(v)) {
4929
+ ctx.addIssue({
4930
+ code: ZodIssueCode.custom,
4931
+ message: vmsg("source_binary")
4932
+ });
4933
+ return;
4934
+ }
4935
+ if (new TextEncoder().encode(v).length > maxBytes) ctx.addIssue({
4936
+ code: ZodIssueCode.custom,
4937
+ message: vmsg("source_too_large", { maxBytes })
4938
+ });
4939
+ });
4940
+ return wrapKey(o, {
4941
+ kind: "source",
4942
+ label: o.label ?? "",
4943
+ required,
4944
+ prim: "text",
4945
+ column: "text",
4946
+ hints: {
4947
+ ext,
4948
+ maxBytes,
4949
+ noEditControl: true
4950
+ },
4951
+ zod: optionalize(s, required)
4952
+ });
4953
+ }
4916
4954
  function rating(raw) {
4917
4955
  const o = normalizeOpts(raw);
4918
4956
  const required = o.required ?? false;
@@ -5049,6 +5087,7 @@ var presets = {
5049
5087
  tags,
5050
5088
  markdown,
5051
5089
  snippet,
5090
+ source,
5052
5091
  rating,
5053
5092
  duration,
5054
5093
  reminder,
@@ -6349,11 +6388,18 @@ function triState(raw) {
6349
6388
  zod: optionalize(s, required)
6350
6389
  });
6351
6390
  }
6391
+ /** Inline option entry → OptionItem (plain string = value and label at once). */
6392
+ function toOptionItem(o) {
6393
+ return typeof o === "string" ? {
6394
+ value: o,
6395
+ title: o
6396
+ } : o;
6397
+ }
6352
6398
  function select(raw) {
6353
6399
  const o = normalizeOpts(raw);
6354
6400
  const required = o.required ?? false;
6355
6401
  const source = typeof o.options === "string" ? o.options : null;
6356
- const inlineOpts = Array.isArray(o.options) ? o.options : [];
6402
+ const inlineOpts = Array.isArray(o.options) ? o.options.map(toOptionItem) : [];
6357
6403
  const s = inlineOpts.length > 0 ? _enum(inlineOpts.map((x) => x.value), { error: () => vmsg("enum") }) : string$1(reqErr());
6358
6404
  return wrapKey(o, applyMultiple({
6359
6405
  kind: "select",
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-By7bmS3U.js")
14
+ load: () => import("./ChatWidget-BvI5pRWb.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.2.6",
3
+ "version": "2.3.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.3.3",
30
- "@coffer-org/sdk": "^2.1.3",
31
- "@coffer-org/server": "^2.5.2",
29
+ "@coffer-org/plugin-orchestrator": "^2.4.0",
30
+ "@coffer-org/sdk": "^2.2.1",
31
+ "@coffer-org/server": "^2.7.0",
32
32
  "react-markdown": "^10.1.0",
33
33
  "remark-gfm": "^4.0.1"
34
34
  },