@coffer-org/plugin-webchat 7.0.1 → 7.1.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.
@@ -2,14 +2,18 @@ import { randomUUID } from 'node:crypto';
2
2
  import { handleIncoming, liveAgentId } from '@coffer-org/server/orchestrator';
3
3
  import { beginTurn } from '@coffer-org/server/turn-gate';
4
4
  import { HttpError } from '@coffer-org/server/plugin-hooks';
5
- import { openConversation, recordUser, buildChain, history, readSelectionForTurn, setSelection, userTurnCount, } from "./chain-store.js";
5
+ import { lastMessageId, conversationEmitter, onMessage, } from '@coffer-org/server/conversation-store';
6
+ import { getConversation, setConversation, readAndTouchConversation, } from '@coffer-org/server/conversation-store';
7
+ import { mayWrite, mayRead } from '@coffer-org/server/orchestrator';
6
8
  import { policy } from "./config.js";
7
9
  import { makeStreamingConnector } from "./connector.js";
8
- import { broadcast } from "./channel-registry.js";
10
+ import { broadcast, initFanout } from "./channel-registry.js";
9
11
  import { webChannelSystem } from "./format.js";
10
12
  import { loadReasoningDisplay } from "./settings.js";
11
13
  import { getLogger } from '@coffer-org/sdk/logger';
12
14
  const log = getLogger('webchat');
15
+ initFanout();
16
+ export { conversationEmitter };
13
17
  export const CAPABILITIES = {
14
18
  events: ['delta', 'reasoning', 'segment', 'suggestions', 'title'],
15
19
  privateChats: true,
@@ -39,59 +43,73 @@ export async function sendAction(body, caller, deps = {}) {
39
43
  throw new HttpError(400, 'missing required field: convId');
40
44
  if (!text.trim())
41
45
  throw new HttpError(400, 'missing required field: text');
42
- const chatId = await openConversation(convId, caller.id, 'write');
43
- if (chatId === null) {
46
+ const conversationId = await openConversation(convId, caller.id, 'write');
47
+ if (conversationId === null) {
44
48
  return { msgId: null, botMsgId: null };
45
49
  }
46
- const selection = await readSelectionForTurn(chatId);
50
+ const selection = await readAndTouchConversation(conversationId);
47
51
  if (selection.owner === null) {
48
- await setSelection(chatId, { owner: caller.id });
52
+ await setConversation(conversationId, { owner: caller.id });
49
53
  }
50
54
  const agentId = liveAgentId(selection.agentId);
51
55
  const msgId = randomUUID();
52
56
  const botMsgId = randomUUID();
53
- const nowSec = Math.floor(Date.now() / 1000);
54
57
  const attachments = parseAttachments(body['attachments']);
55
58
  const explicitReplyTo = typeof body['replyTo'] === 'string' && body['replyTo'] ? body['replyTo'] : null;
56
- const replyTo = explicitReplyTo ?? (await history(chatId, 1)).at(-1)?.msgId ?? null;
57
- await recordUser({ chatId, msgId, sender: caller.id, text, attachments, ts: nowSec, replyToId: replyTo });
58
- broadcast(chatId, 'user', { msgId, text, ts: nowSec, ...(attachments?.length ? { attachments } : {}) });
59
- const messages = await buildChain(msgId, { chatId });
60
- const userTurns = await userTurnCount(chatId);
59
+ const replyTo = explicitReplyTo ?? (await lastMessageId(conversationId));
60
+ let userMsgId = msgId;
61
+ let userMsgResolved = false;
62
+ let resolveUserMsg;
63
+ const userMsgPromise = new Promise((resolve) => {
64
+ resolveUserMsg = resolve;
65
+ });
66
+ const unsubUserMsg = onMessage((m) => {
67
+ if (!userMsgResolved && m.conversationId === conversationId && m.role === 'user') {
68
+ userMsgResolved = true;
69
+ resolveUserMsg(m.msgId);
70
+ }
71
+ });
61
72
  const { connector, recorded, suggestions } = makeStreamingConnector({
62
- chatId,
73
+ conversationId,
63
74
  botMsgId,
64
75
  display: await loadReasoningDisplay(),
65
76
  });
66
77
  const turnContext = pageContext(body['context']);
67
- const gate = await beginTurn('webchat', chatId, { supersede: true });
68
- void (async () => {
78
+ const gate = await beginTurn('webchat', conversationId, { supersede: true });
79
+ const turnPromise = (async () => {
69
80
  try {
70
81
  await doHandle(connector, {
71
- envelope: { connectorId: 'webchat', chatId, turnId: msgId },
72
- body: {
73
- systemPrompt: { channel: [await webChannelSystem()] },
74
- messages,
75
- capabilities: CAPABILITIES,
76
- userTurns,
82
+ envelope: { connectorId: 'webchat', conversationId, turnId: msgId },
83
+ sender: { userId: Number(caller.id) },
84
+ message: {
85
+ text,
86
+ ...(attachments?.length ? { attachments } : {}),
87
+ replyToMsgId: replyTo,
77
88
  },
89
+ capabilities: CAPABILITIES,
90
+ systemPrompt: { channel: [await webChannelSystem()] },
78
91
  ...(turnContext.length ? { turnContext } : {}),
79
92
  ...(agentId ? { agentId } : {}),
80
93
  ...(selection.presetId ? { presetId: selection.presetId } : {}),
81
- sender: { id: caller.id, idKind: 'coffer-user' },
82
94
  signal: gate.signal,
83
95
  }, { policy: policy() });
84
- broadcast(chatId, 'done', { msgId: recorded(), parentMsgId: msgId, suggestions: suggestions() });
96
+ broadcast(conversationId, 'done', {
97
+ msgId: recorded() ?? botMsgId,
98
+ parentMsgId: userMsgId,
99
+ suggestions: suggestions(),
100
+ });
85
101
  }
86
102
  catch (err) {
87
- log.error(`sendAction: handleIncoming threw for chat ${chatId}: ${err instanceof Error ? err.message : String(err)}`);
88
- broadcast(chatId, 'error', { message: null, parentMsgId: msgId });
103
+ log.error(`sendAction: handleIncoming threw for chat ${conversationId}: ${err instanceof Error ? err.message : String(err)}`);
104
+ broadcast(conversationId, 'error', { message: null, parentMsgId: userMsgId });
89
105
  }
90
106
  finally {
91
107
  gate.end();
92
108
  }
93
109
  })();
94
- return { msgId, botMsgId };
110
+ userMsgId = await Promise.race([userMsgPromise, turnPromise.then(() => msgId)]);
111
+ unsubUserMsg();
112
+ return { msgId: userMsgId, botMsgId };
95
113
  }
96
114
  function parseAttachments(value) {
97
115
  if (!Array.isArray(value))
@@ -105,11 +123,18 @@ function parseAttachments(value) {
105
123
  return [
106
124
  {
107
125
  name: r['name'],
108
- ...(typeof r['mime'] === 'string' ? { mime: r['mime'] } : {}),
109
- ...(typeof r['size'] === 'number' ? { size: r['size'] } : {}),
110
- ...(typeof r['label'] === 'string' ? { label: r['label'] } : {}),
126
+ ...(typeof r['mime'] === 'string' && r['mime'] ? { mime: r['mime'] } : {}),
127
+ ...(typeof r['size'] === 'number' && Number.isFinite(r['size']) ? { size: r['size'] } : {}),
128
+ ...(typeof r['label'] === 'string' && r['label'] ? { label: r['label'] } : {}),
111
129
  },
112
130
  ];
113
131
  });
114
132
  return refs.length ? refs : undefined;
115
133
  }
134
+ async function openConversation(convId, viewerId, need) {
135
+ if (!convId || convId.includes(':'))
136
+ return null;
137
+ const conv = await getConversation(convId);
138
+ const allowed = need === 'read' ? mayRead(conv, viewerId) : mayWrite(conv, viewerId);
139
+ return allowed ? convId : null;
140
+ }
package/dist/schema.js CHANGED
@@ -24,6 +24,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  //#region ../sdk/src/plugin.ts
25
25
  function definePlugin(p) {
26
26
  if (!p.id) throw new Error("[plugin] missing id");
27
+ if (!p.label) throw new Error(`[plugin] ${p.id}: missing label`);
28
+ if (!p.description) throw new Error(`[plugin] ${p.id}: missing description`);
27
29
  if (!p.version) console.warn(`[plugin] ${p.id}: missing version`);
28
30
  return p;
29
31
  }
@@ -4134,16 +4136,18 @@ function jsonRefined(inner, code) {
4134
4136
  });
4135
4137
  });
4136
4138
  }
4137
- function optionalize(schema, required) {
4138
- const req = required === true;
4139
- const pre = (v) => v === "" || v === null ? void 0 : v;
4140
- if (!req) return preprocess(pre, schema.optional());
4141
- return preprocess(pre, unknown().superRefine((v, ctx) => {
4142
- if (v === void 0) ctx.addIssue({
4143
- code: ZodIssueCode.custom,
4144
- message: vmsg("required")
4145
- });
4146
- }).pipe(schema));
4139
+ /**
4140
+ * An empty value is no value: `''` and `null` become `undefined`, and the field's own schema
4141
+ * never sees them. Every field is optional, so this is the ONLY shape a field cannot demand
4142
+ * a value, and the rules it carries judge what was entered rather than whether anything was.
4143
+ *
4144
+ * This used to have a second branch, for `required` fields, which turned the same emptiness
4145
+ * into a `required` issue through a `z.unknown()` guard piped into the schema. That branch is
4146
+ * why a `.min(1)` on a required string was unreachable: the empty string had already been
4147
+ * converted to the issue before the string schema ran.
4148
+ */
4149
+ function optionalize(schema) {
4150
+ return preprocess((v) => v === "" || v === null ? void 0 : v, schema.optional());
4147
4151
  }
4148
4152
  //#endregion
4149
4153
  //#region ../sdk/src/fields/meta.ts
@@ -4225,7 +4229,7 @@ function applyMultiple(base, multiple) {
4225
4229
  multiple: true
4226
4230
  },
4227
4231
  json: true,
4228
- zod: optionalize(s, base.required)
4232
+ zod: optionalize(s)
4229
4233
  };
4230
4234
  }
4231
4235
  /** Inline option entry → OptionItem (plain string = value and label at once). */
@@ -4340,13 +4344,6 @@ function wrapKey(key, opts, meta) {
4340
4344
  noLabel: true
4341
4345
  }
4342
4346
  };
4343
- if (opts.role) m = {
4344
- ...m,
4345
- hints: {
4346
- ...m.hints,
4347
- role: opts.role
4348
- }
4349
- };
4350
4347
  if (opts.editor) m = {
4351
4348
  ...m,
4352
4349
  hints: {
@@ -4805,7 +4802,6 @@ function normalizeOpts(rawIn) {
4805
4802
  faces: v.faces,
4806
4803
  emphasis: v.emphasis,
4807
4804
  noLabel: v.noLabel,
4808
- role: v.role,
4809
4805
  editor: v.editor,
4810
4806
  activate: v.activate
4811
4807
  };
@@ -4968,13 +4964,10 @@ function email(o) {
4968
4964
  registerPreset("email", "string", {}, {
4969
4965
  kind: "email",
4970
4966
  widget: "email",
4971
- build(o) {
4972
- const required = o.required ?? false;
4973
- let s = string$1().email({ message: vmsg("email") });
4974
- if (required) s = s.min(1, { message: vmsg("min_length", { min: 1 }) });
4967
+ build() {
4975
4968
  return {
4976
4969
  column: "text",
4977
- zod: s,
4970
+ zod: string$1().email({ message: vmsg("email") }),
4978
4971
  hints: {
4979
4972
  format: "email",
4980
4973
  inputType: "email"
@@ -4997,12 +4990,10 @@ function tel(o) {
4997
4990
  registerPreset("tel", "string", {}, {
4998
4991
  kind: "tel",
4999
4992
  widget: "tel",
5000
- build(o) {
5001
- const required = o.required ?? false;
5002
- const base_z = string$1().regex(TEL_RE, { message: vmsg("pattern", { messageKey: "core.presets.tel" }) });
4993
+ build() {
5003
4994
  return {
5004
4995
  column: "text",
5005
- zod: required ? base_z.min(1, { message: vmsg("min_length", { min: 1 }) }) : base_z,
4996
+ zod: string$1().regex(TEL_RE, { message: vmsg("pattern", { messageKey: "core.presets.tel" }) }),
5006
4997
  hints: {
5007
4998
  format: "tel",
5008
4999
  inputType: "tel"
@@ -5026,10 +5017,10 @@ registerPreset("password", "string", {}, {
5026
5017
  kind: "password",
5027
5018
  widget: "password",
5028
5019
  selfManages: /* @__PURE__ */ new Set(["multiple"]),
5029
- build(o) {
5020
+ build() {
5030
5021
  return {
5031
5022
  column: "text",
5032
- zod: o.required ?? false ? string$1().min(1, { message: vmsg("min_length", { min: 1 }) }) : string$1(),
5023
+ zod: string$1(),
5033
5024
  hints: {
5034
5025
  format: "password",
5035
5026
  inputType: "password"
@@ -5091,6 +5082,31 @@ function internalOauthGrants(o) {
5091
5082
  }
5092
5083
  });
5093
5084
  }
5085
+ /**
5086
+ * The account page's linked-accounts section. Same trick as `internalApiToken`: a container
5087
+ * with a custom `ui.kind`, drawn by its own renderer, which reads `/api/auth/links` itself.
5088
+ * The declared children are what one ROW holds — the renderer reads them through the slot
5089
+ * contract, so this is a declaration, not a layout.
5090
+ *
5091
+ * @layer preset
5092
+ * @base group
5093
+ * @prim —
5094
+ * @widget internalIdentityLinks
5095
+ * @example f.internalIdentityLinks({ label: 'auth.linksTitle' })
5096
+ */
5097
+ function internalIdentityLinks(o) {
5098
+ return group({
5099
+ scope: "nest",
5100
+ label: o.label,
5101
+ multiple: true,
5102
+ ui: { kind: "internalIdentityLinks" },
5103
+ fields: {
5104
+ provider: string({}),
5105
+ externalId: string({}),
5106
+ linkedAt: string({})
5107
+ }
5108
+ });
5109
+ }
5094
5110
  var SLUG_RE = /^[a-z0-9-]+$/;
5095
5111
  /**
5096
5112
  * Slug — kind 'slug', `^[a-z0-9-]+$`.
@@ -5211,10 +5227,10 @@ function colorname(o) {
5211
5227
  registerPreset("colorname", "string", {}, {
5212
5228
  kind: "colorname",
5213
5229
  widget: "colorname",
5214
- build(o) {
5230
+ build() {
5215
5231
  return {
5216
5232
  column: "text",
5217
- zod: (o.required ?? false ? string$1().min(1, { message: vmsg("min_length", { min: 1 }) }) : string$1()).refine((v) => CSS_COLOR_NAMES.has(v.toLowerCase()), { message: vmsg("colorname") }),
5233
+ zod: string$1().refine((v) => CSS_COLOR_NAMES.has(v.toLowerCase()), { message: vmsg("colorname") }),
5218
5234
  hints: {}
5219
5235
  };
5220
5236
  }
@@ -5234,10 +5250,10 @@ function title(o) {
5234
5250
  registerPreset("title", "string", {}, {
5235
5251
  kind: "title",
5236
5252
  widget: "title",
5237
- build(o) {
5253
+ build() {
5238
5254
  return {
5239
5255
  column: "text",
5240
- zod: o.required ?? false ? string$1().min(1, { message: vmsg("min_length", { min: 1 }) }) : string$1(),
5256
+ zod: string$1(),
5241
5257
  hints: {}
5242
5258
  };
5243
5259
  }
@@ -5258,12 +5274,10 @@ function link(o) {
5258
5274
  registerPreset("link", "string", {}, {
5259
5275
  kind: "link",
5260
5276
  widget: "link",
5261
- build(o) {
5262
- const required = o.required ?? false;
5263
- const base_z = string$1().regex(LINK_RE, { message: vmsg("pattern", { messageKey: "core.presets.link" }) });
5277
+ build() {
5264
5278
  return {
5265
5279
  column: "text",
5266
- zod: required ? base_z.min(1, { message: vmsg("min_length", { min: 1 }) }) : base_z,
5280
+ zod: string$1().regex(LINK_RE, { message: vmsg("pattern", { messageKey: "core.presets.link" }) }),
5267
5281
  hints: {
5268
5282
  format: "url",
5269
5283
  inputType: "url"
@@ -5948,6 +5962,7 @@ var presets = {
5948
5962
  currency,
5949
5963
  money,
5950
5964
  internalApiToken,
5965
+ internalIdentityLinks,
5951
5966
  internalOauthGrants
5952
5967
  };
5953
5968
  //#endregion
@@ -8062,7 +8077,6 @@ function group(o) {
8062
8077
  scope,
8063
8078
  multiple: o.multiple,
8064
8079
  compute: o.value,
8065
- required: o.required,
8066
8080
  unique: r.unique,
8067
8081
  label: o.label,
8068
8082
  icon: o.icon,
@@ -8095,7 +8109,6 @@ function row(o) {
8095
8109
  icon: o.icon,
8096
8110
  fields: o.fields,
8097
8111
  multiple: o.multiple,
8098
- required: o.required,
8099
8112
  rules: o.rules,
8100
8113
  ui: { display: "scroll" }
8101
8114
  });
@@ -8143,7 +8156,6 @@ function url(o) {
8143
8156
  return group({
8144
8157
  scope: "nest",
8145
8158
  label: o.label,
8146
- required: o.required,
8147
8159
  ui: { kind: "url" },
8148
8160
  fields: {
8149
8161
  scheme: string({}),
@@ -8195,7 +8207,6 @@ function keyed(o) {
8195
8207
  }),
8196
8208
  scope: "nest",
8197
8209
  multiple: true,
8198
- required: o.required,
8199
8210
  unique: o.unique ?? [role],
8200
8211
  fixed: o.fixed,
8201
8212
  view: { by: role }
@@ -8316,7 +8327,7 @@ registerType("string", {
8316
8327
  widget: "text",
8317
8328
  build(o) {
8318
8329
  const cfg = o.config ?? {};
8319
- const min = cfg.min ?? (o.required === true ? 1 : 0);
8330
+ const min = cfg.min ?? 0;
8320
8331
  return {
8321
8332
  column: "text",
8322
8333
  zod: stringContent((str) => {
@@ -8344,9 +8355,9 @@ registerType("string", {
8344
8355
  function string(o) {
8345
8356
  return declare("string", o);
8346
8357
  }
8347
- /** Shared by localDir/localFile's build(): a server path, min-length-1 when required. */
8348
- function pathZod(o) {
8349
- return stringContent((s) => o.required === true ? s.min(1, { message: vmsg("min_length", { min: 1 }) }) : s);
8358
+ /** Shared by localDir/localFile's build(): a server path, judged only when one was entered. */
8359
+ function pathZod(_o) {
8360
+ return stringContent((s) => s);
8350
8361
  }
8351
8362
  registerType("localDir", {
8352
8363
  kind: "localDir",
@@ -9005,10 +9016,9 @@ var MEASURED_ROLES = (opts) => {
9005
9016
  accepts: ENUMERATED,
9006
9017
  normalize: (d) => {
9007
9018
  if (d && accepts(unitRole, d)) return d;
9008
- const { label, required, value } = d?.opts ?? {};
9019
+ const { label, value } = d?.opts ?? {};
9009
9020
  return declare("string", {
9010
9021
  label,
9011
- required,
9012
9022
  value,
9013
9023
  noSearch: d?.opts.noSearch,
9014
9024
  ui: uiOf(d?.opts),
@@ -9167,7 +9177,7 @@ registerType("geo", {
9167
9177
  label: string$1().nullish()
9168
9178
  }),
9169
9179
  structureCode: "geo_structure",
9170
- absenceRoles: ["lat", "lng"]
9180
+ acceptsEmptyRow: true
9171
9181
  });
9172
9182
  /**
9173
9183
  * Geographic point composite {lat, lng, label?}; lat/lng validated to valid coordinate
@@ -9245,7 +9255,7 @@ registerType("illustrated", {
9245
9255
  roles: ILLUSTRATED_ROLES,
9246
9256
  roleZod: () => defaultRoleZod(ILLUSTRATED_ROLES),
9247
9257
  structureCode: "illustrated_structure",
9248
- absenceRoles: ["text", "image"],
9258
+ acceptsEmptyRow: true,
9249
9259
  collectionView: "illustratedList",
9250
9260
  hints: (o) => o.config ? { config: o.config } : {}
9251
9261
  });
@@ -9289,7 +9299,7 @@ registerType("attachment", {
9289
9299
  roles: ATTACHMENT_ROLES,
9290
9300
  roleZod: () => defaultRoleZod(ATTACHMENT_ROLES),
9291
9301
  structureCode: "attachment_structure",
9292
- absenceRoles: ["file"],
9302
+ acceptsEmptyRow: true,
9293
9303
  collectionView: "attachmentList"
9294
9304
  });
9295
9305
  /**
@@ -9390,11 +9400,10 @@ function periodRole(key, label, mkSubDecl) {
9390
9400
  if (d && accepts(def, d)) return d;
9391
9401
  const canonical = mkSubDecl(label);
9392
9402
  if (!d) return canonical;
9393
- const { label: l, required, value } = d.opts;
9403
+ const { label: l, value } = d.opts;
9394
9404
  return declare(canonical.factory, {
9395
9405
  ...canonical.opts,
9396
9406
  label: l ?? canonical.opts.label,
9397
- required,
9398
9407
  value,
9399
9408
  noSearch: d.opts.noSearch,
9400
9409
  ui: uiOf(d.opts)
@@ -9678,8 +9687,7 @@ function keyValue(raw) {
9678
9687
  type: valueType
9679
9688
  }
9680
9689
  },
9681
- by: "key",
9682
- required: o.required
9690
+ by: "key"
9683
9691
  });
9684
9692
  }
9685
9693
  /** Roles of a range: the two endpoints, integer or real depending on `isInt`. */
@@ -9866,12 +9874,11 @@ var field = new Proxy({}, {
9866
9874
  has: (_t, k) => k in composedField()
9867
9875
  });
9868
9876
  function toClient(field) {
9869
- const { kind, label, agent, required, prim, hints, options, strict, relation, json, hidden, derived, parts, cache } = field;
9877
+ const { kind, label, agent, prim, hints, options, strict, relation, json, hidden, derived, parts, cache } = field;
9870
9878
  return {
9871
9879
  kind,
9872
9880
  label,
9873
9881
  ...agent !== void 0 && { agent },
9874
- required,
9875
9882
  prim,
9876
9883
  hints,
9877
9884
  options,
@@ -9949,7 +9956,7 @@ function accepts(def, d) {
9949
9956
  }
9950
9957
  /**
9951
9958
  * The correction every role gets for free: keep what the author said ABOUT the field
9952
- * (`label`, `required`, `ui`, `value` — a constant or a ComputeFn), replace the type, and
9959
+ * (`label`, `ui`, `value` — a constant or a ComputeFn), replace the type, and
9953
9960
  * drop what belonged to the OLD type (`rules` — a number's min/max mean nothing to a
9954
9961
  * picture — and `multiple`, because a composite role owns exactly one column). A role whose
9955
9962
  * own default needs more than a bare factory (`measured`'s unit, `period`'s endpoints)
@@ -9957,10 +9964,9 @@ function accepts(def, d) {
9957
9964
  */
9958
9965
  function retypeTo(factory) {
9959
9966
  return (d) => {
9960
- const { label, required, value } = d?.opts ?? {};
9967
+ const { label, value } = d?.opts ?? {};
9961
9968
  return declare(factory, {
9962
9969
  label,
9963
- required,
9964
9970
  value,
9965
9971
  noSearch: d?.opts.noSearch,
9966
9972
  ui: uiOf(d?.opts)
@@ -10146,13 +10152,69 @@ function partColumns(parts) {
10146
10152
  }
10147
10153
  return cols;
10148
10154
  }
10149
- function partsRowShape(shape, parts, tolerance = "strict") {
10155
+ /**
10156
+ * A composite's write-time row shape, DERIVED from its resolved parts. `shape` carries
10157
+ * the composite's DEFAULT schema per role — the literal the factory writes by hand
10158
+ * (`{ value: numSchema, unit: z.string().refine(…) }`) — and each part decides which
10159
+ * schema guards its (role-named) key:
10160
+ *
10161
+ * - a plain STORED part is keyed by its role name, matching the value object and the
10162
+ * column `<field>__<role>` that `flattenEmbeddedAt`/`nestEmbeddedAt` read. A role left
10163
+ * to its default filling keeps the default schema — byte-identical to the hand-written
10164
+ * literal, so every existing structure/unit/range code and message is preserved.
10165
+ * - an OVERRIDDEN stored part is validated by its OWN zod (`meta.zod`) instead of the
10166
+ * default schema for that role. This is what makes a RETYPED filling real: an
10167
+ * `f.int({})` magnitude rejects 5.5 (its own `int` check) and gets an integer column,
10168
+ * and an `f.relation({…})` currency accepts a record id instead of being measured
10169
+ * against an ISO-4217 string rule it was never meant to satisfy. The filling also
10170
+ * brings its own optionality and its own min/max — an author replacing a filling
10171
+ * takes over that role's constraints, so `f.real({ rules: { min: 0 } })` is how a
10172
+ * replaced magnitude keeps a bound.
10173
+ * - a COMPUTED-AND-STORED part ('computedStored') is keyed by its role name like any
10174
+ * other part, but its schema is `z.unknown().optional()`: the SERVER owns the value, so
10175
+ * whatever the client sends there is ignored — `stripServerOwnedParts` deletes it a
10176
+ * moment later and the write path recomputes the column. It is stripped rather than
10177
+ * `z.never()`-rejected because a read hands the client that key (it is a real column,
10178
+ * present in every response), so rejecting it would break a read → PATCH-the-whole-object
10179
+ * round trip.
10180
+ * - a CLIENT-OWNED stored part of a composite also accepts
10181
+ * an explicit `null` (`tolerateNull`), which is what closes the read → write round trip
10182
+ * for a PARTIALLY FILLED composite. A SELECT returns every sub-column, so a record whose
10183
+ * magnitude column is set and whose unit column is empty reads back as
10184
+ * `{value: 900, unit: null}` (`nestEmbeddedAt` drops the key only when EVERY column is
10185
+ * empty — `0` is a value), and feeding that exact object back used to fail with
10186
+ * `measured_structure`: the write path refused the shape its own read produced. The null
10187
+ * slot now parses through and is written back as NULL, so the round trip is closed and
10188
+ * clearing ONE role of a filled composite works instead of erroring.
10189
+ * NULL, not absence: a read emits every stored role (empty ones as `null`), so `null` is
10190
+ * how "this record has no value there" arrives, while an ABSENT key is a writer that
10191
+ * never mentioned a role it owns — which stays the structure error it has always been
10192
+ * (`{unit: 'kg'}` with no magnitude, `{lat: 50}` with no longitude). That is the
10193
+ * all-or-nothing rule for a DECLARATION, and it is untouched.
10194
+ * A composite used to be able to override this: `required: true` kept every client-owned
10195
+ * slot MANDATORY, null included, so that "tolerate null" could not turn a demanded field
10196
+ * optional. Nothing demands a value now, so there is no override and the rule above is the
10197
+ * only one.
10198
+ */
10199
+ /**
10200
+ * How much a CLIENT-OWNED slot forgives in the row shape: exactly one thing, an explicit
10201
+ * `null`. `.nullable()`, never `.nullish()` — a part that is MISSING entirely is still a
10202
+ * malformed row (`{lat: 5.5}` is half a coordinate), which is the one rule about composites
10203
+ * that judges CORRECTNESS rather than presence.
10204
+ *
10205
+ * There used to be a `RowTolerance` of three levels, and the field's `required` is what chose
10206
+ * between them: 'strict' when the field demanded a value, 'null-or-missing' when the value was
10207
+ * ABSENT under that demand — the third existing only so an absent required composite did not
10208
+ * report the same absence twice, once as `required` and once as a structure code. With nothing
10209
+ * able to demand a value there is no first issue to restate, so both levels and the type
10210
+ * itself are gone.
10211
+ */
10212
+ function partsRowShape(shape, parts) {
10150
10213
  const out = { ...shape };
10151
10214
  for (const p of parts) if (p.mode === "computedStored") out[p.key] = unknown().optional();
10152
10215
  else {
10153
10216
  const own = p.overridden ? p.meta.zod : shape[p.role] ?? unknown().optional();
10154
- const relaxed = tolerance !== "strict" && !(p.overridden && p.meta.required === true);
10155
- out[p.key] = relaxed ? tolerance === "null-or-missing" ? own.nullish() : own.nullable() : own;
10217
+ out[p.key] = own.nullable();
10156
10218
  }
10157
10219
  return out;
10158
10220
  }
@@ -10181,7 +10243,7 @@ function stripServerOwnedParts(value, parts) {
10181
10243
  * dimensions, check(single)) and differed only in the validation code and the per-role
10182
10244
  * default zod.
10183
10245
  *
10184
- * raw → jsonValue → absent by `absenceRoles`? (required 'required')
10246
+ * raw → jsonValue → carries nothing? (passthrough, when `acceptsEmptyRow`)
10185
10247
  * → carries nothing at all? (pass through, there is nothing to judge)
10186
10248
  * → rowSchema (partsRowShape) → stripServerOwnedParts → optional per-type refine
10187
10249
  *
@@ -10191,28 +10253,16 @@ function stripServerOwnedParts(value, parts) {
10191
10253
  * exactly once.
10192
10254
  */
10193
10255
  function buildComposite(def, opts, parts) {
10194
- const required = opts.required ?? false;
10195
- const roleZod = def.roleZod(opts, parts);
10196
- const rowSchema = object(partsRowShape(roleZod, parts, required === true ? "strict" : "null"));
10197
- const absentRowSchema = required === true ? object(partsRowShape(roleZod, parts, "null-or-missing")) : rowSchema;
10256
+ const rowSchema = object(partsRowShape(def.roleZod(opts, parts), parts));
10198
10257
  const roleKeys = (which) => parts.filter((p) => p.mode === "stored" && (which?.(p) ?? true)).map(partValueKey);
10199
- const absenceKeys = def.absenceRoles ? roleKeys((p) => def.absenceRoles.includes(p.role)) : void 0;
10200
- const clientKeys = def.absenceRoles ? roleKeys() : void 0;
10258
+ const clientKeys = def.acceptsEmptyRow ? roleKeys() : void 0;
10201
10259
  const zod = unknown().transform((raw, ctx) => {
10202
10260
  const parsed = jsonValue(raw);
10203
10261
  const p = parsed;
10204
10262
  const isRow = p == null || typeof p === "object" && !Array.isArray(p);
10205
10263
  /** Every one of `keys` empty in this value — `null`/absent alike. */
10206
10264
  const allEmpty = (keys) => isRow && (p == null || keys.length > 0 && keys.every((k) => p[k] == null));
10207
- let absent = false;
10208
- if (absenceKeys && clientKeys) {
10209
- absent = allEmpty(absenceKeys);
10210
- if (absent && required) ctx.addIssue({
10211
- code: ZodIssueCode.custom,
10212
- message: vmsg("required")
10213
- });
10214
- if (allEmpty(clientKeys)) return raw;
10215
- }
10265
+ if (clientKeys && allEmpty(clientKeys)) return raw;
10216
10266
  if (typeof parsed === "string") {
10217
10267
  ctx.addIssue({
10218
10268
  code: ZodIssueCode.custom,
@@ -10220,7 +10270,7 @@ function buildComposite(def, opts, parts) {
10220
10270
  });
10221
10271
  return NEVER;
10222
10272
  }
10223
- const r = (absent ? absentRowSchema : rowSchema).safeParse(parsed);
10273
+ const r = rowSchema.safeParse(parsed);
10224
10274
  if (!r.success) {
10225
10275
  const message = typeof def.structureCode === "function" ? def.structureCode(r.error) : vmsg(def.structureCode);
10226
10276
  ctx.addIssue({
@@ -10273,12 +10323,10 @@ function materialize(decl, name) {
10273
10323
  if (isComposite(type) && (opts.multiple ?? false) && parts.length > 0 && !type.selfManages?.has("multiple")) return collectionGroup(name, opts, parts, type.collectionView);
10274
10324
  const built = isComposite(type) ? buildComposite(type, opts, parts) : type.build(opts, parts, name);
10275
10325
  const selfOptional = type.selfManages?.has("optional") ?? false;
10276
- const required = selfOptional ? false : opts.required ?? false;
10277
10326
  const base = {
10278
10327
  kind: type.kind,
10279
10328
  label: opts.label ?? "",
10280
10329
  ...opts.agent !== void 0 && { agent: opts.agent },
10281
- required,
10282
10330
  prim: type.prim,
10283
10331
  column: built.column,
10284
10332
  hints: built.hints ?? {},
@@ -10289,7 +10337,7 @@ function materialize(decl, name) {
10289
10337
  columns: built.columns,
10290
10338
  parts
10291
10339
  },
10292
- zod: selfOptional ? built.zod : optionalize(built.zod, required)
10340
+ zod: selfOptional ? built.zod : optionalize(built.zod)
10293
10341
  };
10294
10342
  return wrapKey(name, opts, type.selfManages?.has("multiple") ? base : applyMultiple(base, opts.multiple ?? false));
10295
10343
  }
@@ -10319,7 +10367,6 @@ function collectionGroup(key, opts, parts, collectionView) {
10319
10367
  key,
10320
10368
  scope: "nest",
10321
10369
  multiple: true,
10322
- required: opts.required ?? false,
10323
10370
  label: opts.label ?? key,
10324
10371
  ...opts.agent !== void 0 && { agent: opts.agent },
10325
10372
  display: "wrap",
@@ -10448,6 +10495,8 @@ var src_default = definePlugin({
10448
10495
  id: "webchat",
10449
10496
  version: "1.0.0",
10450
10497
  dependsOn: [],
10498
+ label: "webchat.plugin.label",
10499
+ description: "webchat.plugin.description",
10451
10500
  settings: defineSettings({
10452
10501
  label: "webchat.settings.label",
10453
10502
  fields: { reasoning_display: field.string({
package/dist/web.js CHANGED
@@ -11,7 +11,7 @@ import { definePluginUI } from "@coffer-org/web-ui";
11
11
  var ui_default = definePluginUI({ slots: [{
12
12
  slot: "overlay",
13
13
  id: "webchat",
14
- load: () => import("./ChatWidget-DkEKl1-2.js")
14
+ load: () => import("./ChatWidget-BhGpxS9f.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": "7.0.1",
3
+ "version": "7.1.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -27,11 +27,10 @@
27
27
  "test:ui": "vitest run --root ."
28
28
  },
29
29
  "dependencies": {
30
- "@coffer-org/sdk": "^7.3.0",
31
- "@coffer-org/server": "^7.3.0",
32
- "lucide-react": "^1.17.0",
33
- "react-markdown": "^10.1.0",
34
- "remark-gfm": "^4.0.1"
30
+ "@coffer-org/markdown": "^7.4.0",
31
+ "@coffer-org/sdk": "^7.4.0",
32
+ "@coffer-org/server": "^7.4.0",
33
+ "lucide-react": "^1.17.0"
35
34
  },
36
35
  "coffer": {
37
36
  "runtime": "node",