@coffer-org/plugin-webchat 2.2.7 → 3.0.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",
@@ -29568,6 +29737,14 @@ function ChatWidget() {
29568
29737
  useEffect(() => {
29569
29738
  if (isOpen) chat.loadThreads();
29570
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
+ ]);
29571
29748
  function startNew() {
29572
29749
  chat.newConversation();
29573
29750
  setAttachments([]);
@@ -29676,6 +29853,11 @@ function ChatWidget() {
29676
29853
  onRetryUpload: retryUploads,
29677
29854
  onPickFiles: (files) => void pickFiles(Array.from(files)),
29678
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,
29679
29861
  onSend: (text, refs) => {
29680
29862
  const accepted = refs.length ? chat.send(text, page, refs) : chat.send(text, page);
29681
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,
@@ -6035,6 +6074,15 @@ function wrapKey(opts, meta) {
6035
6074
  ...m,
6036
6075
  hidden: opts.hidden
6037
6076
  };
6077
+ if (opts.derive) m = {
6078
+ ...m,
6079
+ derive: opts.derive,
6080
+ derived: true,
6081
+ hints: {
6082
+ ...m.hints,
6083
+ noEditControl: true
6084
+ }
6085
+ };
6038
6086
  if (opts.key) return {
6039
6087
  key: opts.key,
6040
6088
  type: m
@@ -6349,11 +6397,18 @@ function triState(raw) {
6349
6397
  zod: optionalize(s, required)
6350
6398
  });
6351
6399
  }
6400
+ /** Inline option entry → OptionItem (plain string = value and label at once). */
6401
+ function toOptionItem(o) {
6402
+ return typeof o === "string" ? {
6403
+ value: o,
6404
+ title: o
6405
+ } : o;
6406
+ }
6352
6407
  function select(raw) {
6353
6408
  const o = normalizeOpts(raw);
6354
6409
  const required = o.required ?? false;
6355
6410
  const source = typeof o.options === "string" ? o.options : null;
6356
- const inlineOpts = Array.isArray(o.options) ? o.options : [];
6411
+ const inlineOpts = Array.isArray(o.options) ? o.options.map(toOptionItem) : [];
6357
6412
  const s = inlineOpts.length > 0 ? _enum(inlineOpts.map((x) => x.value), { error: () => vmsg("enum") }) : string$1(reqErr());
6358
6413
  return wrapKey(o, applyMultiple({
6359
6414
  kind: "select",
@@ -6550,6 +6605,47 @@ function measured(raw) {
6550
6605
  zod: optionalize(s, required)
6551
6606
  });
6552
6607
  }
6608
+ function unit(raw) {
6609
+ const o = normalizeOpts(raw);
6610
+ const required = o.required ?? false;
6611
+ const units = resolveUnits(o.options);
6612
+ const s = string$1(reqErr()).refine((v) => units.some((u) => u.value === v), { message: vmsg("measured_unit") });
6613
+ return wrapKey(o, {
6614
+ kind: "unit",
6615
+ label: o.label ?? "",
6616
+ required,
6617
+ prim: "select",
6618
+ column: "text",
6619
+ hints: { source: typeof o.options === "string" ? o.options : null },
6620
+ options: units.map((u) => ({
6621
+ value: u.value,
6622
+ title: u.label
6623
+ })),
6624
+ zod: optionalize(s, required)
6625
+ });
6626
+ }
6627
+ function amount(raw) {
6628
+ const o = normalizeOpts(raw);
6629
+ const required = o.required ?? false;
6630
+ const cfg = o.config ?? {};
6631
+ let s = number(typeErr());
6632
+ if (cfg.min != null) s = s.min(cfg.min, { message: vmsg("min", { min: cfg.min }) });
6633
+ if (cfg.max != null) s = s.max(cfg.max, { message: vmsg("max", { max: cfg.max }) });
6634
+ return wrapKey(o, {
6635
+ kind: "amount",
6636
+ label: o.label ?? "",
6637
+ required,
6638
+ prim: "number",
6639
+ column: "real",
6640
+ hints: {
6641
+ unitFrom: o.unitFrom,
6642
+ min: cfg.min,
6643
+ max: cfg.max,
6644
+ step: cfg.step ?? "any"
6645
+ },
6646
+ zod: optionalize(s, required)
6647
+ });
6648
+ }
6553
6649
  function money(raw) {
6554
6650
  const o = normalizeOpts(raw);
6555
6651
  const required = o.required ?? false;
@@ -7027,6 +7123,8 @@ var PRIMITIVES = {
7027
7123
  json,
7028
7124
  check,
7029
7125
  measured,
7126
+ unit,
7127
+ amount,
7030
7128
  money,
7031
7129
  code,
7032
7130
  geo,
@@ -7072,7 +7170,7 @@ var field = new Proxy({}, {
7072
7170
  has: (_t, k) => k in composedField()
7073
7171
  });
7074
7172
  function toClient(field) {
7075
- const { kind, label, required, prim, hints, options, relation, virtual, json, hidden } = field;
7173
+ const { kind, label, required, prim, hints, options, relation, virtual, json, hidden, derived } = field;
7076
7174
  return {
7077
7175
  kind,
7078
7176
  label,
@@ -7083,7 +7181,8 @@ function toClient(field) {
7083
7181
  relation,
7084
7182
  virtual,
7085
7183
  json,
7086
- hidden
7184
+ hidden,
7185
+ derived
7087
7186
  };
7088
7187
  }
7089
7188
  //#endregion
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-4U7nKidI.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.7",
3
+ "version": "3.0.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": "^3.0.0",
30
+ "@coffer-org/sdk": "^3.0.0",
31
+ "@coffer-org/server": "^3.0.0",
32
32
  "react-markdown": "^10.1.0",
33
33
  "remark-gfm": "^4.0.1"
34
34
  },