dewee-webchat 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,279 @@
1
1
  import {
2
+ ACTION_LIMITS,
3
+ ACTION_NAME_RE,
2
4
  HEADER_GATEWAY_TOKEN,
3
- HEADER_GATEWAY_URL
4
- } from "./chunk-WD6V3TAX.js";
5
+ HEADER_GATEWAY_URL,
6
+ SENSITIVITIES,
7
+ stripPageBlock
8
+ } from "./chunk-JX6AH37X.js";
9
+
10
+ // src/actions/directives.ts
11
+ var DIRECTIVE_LANG = "dewee-action";
12
+ var RESULT_LANG = "dewee-action-result";
13
+ var DIRECTIVE_LIMITS = { maxCalls: 8, maxBlockBytes: 8192 };
14
+ var CALL_ID_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
15
+ var FENCE_CLOSE_RE = /^\s{0,3}```\s*$/;
16
+ var fenceOpen = (lang) => new RegExp("^\\s{0,3}```" + lang.replace(/[.-]/g, "\\$&") + "\\s*$");
17
+ var DIRECTIVE_OPEN_RE = fenceOpen(DIRECTIVE_LANG);
18
+ var RESULT_OPEN_RE = fenceOpen(RESULT_LANG);
19
+ function findBlocks(lines, open) {
20
+ const blocks = [];
21
+ for (let i = 0; i < lines.length; i++) {
22
+ if (!open.test(lines[i])) continue;
23
+ const start = i;
24
+ const body = [];
25
+ let closed = false;
26
+ for (i++; i < lines.length; i++) {
27
+ if (FENCE_CLOSE_RE.test(lines[i])) {
28
+ closed = true;
29
+ break;
30
+ }
31
+ body.push(lines[i]);
32
+ }
33
+ blocks.push({ start, end: Math.min(i, lines.length - 1), body, closed });
34
+ }
35
+ return blocks;
36
+ }
37
+ var removeBlocks = (lines, blocks) => {
38
+ const drop = /* @__PURE__ */ new Set();
39
+ for (const b of blocks) for (let i = b.start; i <= b.end; i++) drop.add(i);
40
+ return lines.filter((_, i) => !drop.has(i)).join("\n").replace(/\n{3,}/g, "\n\n").trim();
41
+ };
42
+ function parseDirectives(content) {
43
+ const lines = content.replace(/\r\n?/g, "\n").split("\n");
44
+ const blocks = findBlocks(lines, DIRECTIVE_OPEN_RE);
45
+ const calls = [];
46
+ const ids = /* @__PURE__ */ new Set();
47
+ blocks.forEach((b, n) => {
48
+ const fallbackId = `call-${n + 1}`;
49
+ const raw = b.body.join("\n");
50
+ const bad = (error, id2 = fallbackId, name2 = "") => calls.push({ ok: false, id: id2, name: name2, error });
51
+ if (n >= DIRECTIVE_LIMITS.maxCalls) return bad(`Too many action calls in one reply (max ${DIRECTIVE_LIMITS.maxCalls})`);
52
+ if (!b.closed) return bad("Unclosed dewee-action block");
53
+ if (new TextEncoder().encode(raw).length > DIRECTIVE_LIMITS.maxBlockBytes) return bad(`Action block exceeds ${DIRECTIVE_LIMITS.maxBlockBytes} bytes`);
54
+ let data;
55
+ try {
56
+ data = JSON.parse(raw);
57
+ } catch {
58
+ return bad("Action block is not valid JSON");
59
+ }
60
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return bad("Action block must be a JSON object");
61
+ let id = typeof data.id === "string" && CALL_ID_RE.test(data.id) ? data.id : fallbackId;
62
+ const name = typeof data.name === "string" ? data.name.slice(0, 64) : "";
63
+ if (ids.has(id)) return bad(`Duplicate action call id "${id}"`, `${id}-${n + 1}`, name);
64
+ ids.add(id);
65
+ if (!name) return bad("Action name is required", id);
66
+ const args = data.args ?? {};
67
+ if (typeof args !== "object" || args === null || Array.isArray(args)) return bad("args must be a JSON object", id, name);
68
+ calls.push({ ok: true, id, name, args });
69
+ });
70
+ return { text: blocks.length ? removeBlocks(lines, blocks) : content, calls };
71
+ }
72
+ function stripDirectives(content) {
73
+ if (!content.includes("```" + DIRECTIVE_LANG)) return content;
74
+ const lines = content.replace(/\r\n?/g, "\n").split("\n");
75
+ return removeBlocks(lines, findBlocks(lines, DIRECTIVE_OPEN_RE));
76
+ }
77
+ function isActionResultMessage(content) {
78
+ return RESULT_OPEN_RE.test(content.trimStart().split("\n", 1)[0] ?? "");
79
+ }
80
+ function parseActionResults(content) {
81
+ const lines = content.replace(/\r\n?/g, "\n").split("\n");
82
+ const out = [];
83
+ for (const b of findBlocks(lines, RESULT_OPEN_RE)) {
84
+ try {
85
+ const r = JSON.parse(b.body.join("\n"));
86
+ if (r && typeof r.id === "string" && ["ok", "denied", "error"].includes(r.status)) out.push(r);
87
+ } catch {
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ var block = (r) => "```" + RESULT_LANG + "\n" + JSON.stringify(r) + "\n```";
93
+ function formatActionResults(results, maxLength = 4e3) {
94
+ const budget = Math.max(64, Math.floor((maxLength - 16) / Math.max(1, results.length)));
95
+ const fitted = results.map((r) => {
96
+ const safe = { ...r, result: toJsonValue(r.result) };
97
+ if (safe.result === void 0) delete safe.result;
98
+ if (block(safe).length <= budget) return block(safe);
99
+ const preview = JSON.stringify(safe.result ?? null);
100
+ const room = budget - block({ ...safe, result: { truncated: true, preview: "" } }).length - 8;
101
+ const shrunk = block({ ...safe, result: { truncated: true, preview: preview.slice(0, Math.max(0, room)) } });
102
+ return shrunk.length <= budget ? shrunk : block({ id: r.id, name: r.name.slice(0, 64), status: r.status, result: { truncated: true } });
103
+ });
104
+ return fitted.join("\n\n").slice(0, maxLength);
105
+ }
106
+ function toJsonValue(v) {
107
+ if (v === void 0) return void 0;
108
+ try {
109
+ const s = JSON.stringify(v);
110
+ return s === void 0 ? null : JSON.parse(s);
111
+ } catch {
112
+ return "[unserializable result]";
113
+ }
114
+ }
115
+
116
+ // src/actions/json-schema-subset.ts
117
+ var typeOf = (v) => v === null ? "null" : Array.isArray(v) ? "array" : typeof v === "number" ? Number.isInteger(v) ? "integer" : "number" : typeof v;
118
+ var matchesType = (v, t) => {
119
+ const actual = typeOf(v);
120
+ return actual === t || t === "number" && actual === "integer";
121
+ };
122
+ function validateJsonSchema(value, schema, path = "args", depth = 0) {
123
+ if (!schema || typeof schema !== "object") return null;
124
+ if (depth > 32) return `${path}: schema is nested too deeply`;
125
+ const s = schema;
126
+ if (s.type !== void 0) {
127
+ const types = Array.isArray(s.type) ? s.type : [s.type];
128
+ if (!types.some((t) => matchesType(value, t))) return `${path}: expected ${types.join(" or ")}, got ${typeOf(value)}`;
129
+ }
130
+ if (Array.isArray(s.enum) && !s.enum.some((e) => sameJson(e, value))) {
131
+ return `${path}: must be one of ${s.enum.map((e) => JSON.stringify(e)).join(", ")}`;
132
+ }
133
+ if ("const" in s && !sameJson(s.const, value)) return `${path}: must equal ${JSON.stringify(s.const)}`;
134
+ if (typeof value === "string") {
135
+ if (typeof s.minLength === "number" && value.length < s.minLength) return `${path}: shorter than ${s.minLength} characters`;
136
+ if (typeof s.maxLength === "number" && value.length > s.maxLength) return `${path}: longer than ${s.maxLength} characters`;
137
+ if (typeof s.pattern === "string") {
138
+ let re = null;
139
+ try {
140
+ re = new RegExp(s.pattern, "u");
141
+ } catch {
142
+ return `${path}: schema pattern is invalid`;
143
+ }
144
+ if (!re.test(value)) return `${path}: does not match ${s.pattern}`;
145
+ }
146
+ }
147
+ if (typeof value === "number") {
148
+ if (typeof s.minimum === "number" && value < s.minimum) return `${path}: must be >= ${s.minimum}`;
149
+ if (typeof s.maximum === "number" && value > s.maximum) return `${path}: must be <= ${s.maximum}`;
150
+ }
151
+ if (Array.isArray(value)) {
152
+ if (typeof s.minItems === "number" && value.length < s.minItems) return `${path}: needs at least ${s.minItems} items`;
153
+ if (typeof s.maxItems === "number" && value.length > s.maxItems) return `${path}: allows at most ${s.maxItems} items`;
154
+ if (s.items && typeof s.items === "object") {
155
+ for (let i = 0; i < value.length; i++) {
156
+ const err = validateJsonSchema(value[i], s.items, `${path}[${i}]`, depth + 1);
157
+ if (err) return err;
158
+ }
159
+ }
160
+ }
161
+ if (typeOf(value) === "object") {
162
+ const obj = value;
163
+ const props = s.properties && typeof s.properties === "object" ? s.properties : {};
164
+ for (const key of Array.isArray(s.required) ? s.required : []) {
165
+ if (!(key in obj)) return `${path}.${key}: is required`;
166
+ }
167
+ for (const [key, v] of Object.entries(obj)) {
168
+ if (Object.prototype.hasOwnProperty.call(props, key)) {
169
+ const err = validateJsonSchema(v, props[key], `${path}.${key}`, depth + 1);
170
+ if (err) return err;
171
+ } else if (s.additionalProperties === false) {
172
+ return `${path}.${key}: is not allowed`;
173
+ }
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+ function sameJson(a, b) {
179
+ return JSON.stringify(a) === JSON.stringify(b);
180
+ }
181
+
182
+ // src/actions/action-runner.ts
183
+ var needsConfirmation = (s) => s === "mutate" || s === "destructive";
184
+ var ActionRegistry = class {
185
+ map = /* @__PURE__ */ new Map();
186
+ get size() {
187
+ return this.map.size;
188
+ }
189
+ get(name) {
190
+ return this.map.get(name);
191
+ }
192
+ names() {
193
+ return [...this.map.keys()];
194
+ }
195
+ /** Adds or replaces actions by name. Throws TypeError on an invalid definition. */
196
+ register(actions) {
197
+ const list = Array.isArray(actions) ? actions : [actions];
198
+ for (const a of list) assertAction(a);
199
+ if ((/* @__PURE__ */ new Set([...this.map.keys(), ...list.map((a) => a.name)])).size > ACTION_LIMITS.maxActions) {
200
+ throw new TypeError(`dewee-webchat: at most ${ACTION_LIMITS.maxActions} page actions`);
201
+ }
202
+ for (const a of list) this.map.set(a.name, a);
203
+ }
204
+ unregister(name) {
205
+ return this.map.delete(name);
206
+ }
207
+ /** What is sent to the backend: everything but the handler. */
208
+ manifest() {
209
+ return [...this.map.values()].map((a) => ({
210
+ name: a.name,
211
+ description: a.description,
212
+ sensitivity: a.sensitivity,
213
+ parameters: a.parameters ?? { type: "object", properties: {} }
214
+ }));
215
+ }
216
+ };
217
+ function assertAction(a) {
218
+ const fail = (why) => {
219
+ throw new TypeError(`dewee-webchat: invalid page action ${JSON.stringify(a?.name)}: ${why}`);
220
+ };
221
+ if (!a || typeof a !== "object") fail("not an object");
222
+ if (typeof a.name !== "string" || !ACTION_NAME_RE.test(a.name)) fail("name must match ^[a-z][a-z0-9_.-]{0,63}$");
223
+ if (typeof a.description !== "string" || a.description.length > ACTION_LIMITS.maxDescription) fail(`description must be a string up to ${ACTION_LIMITS.maxDescription} chars`);
224
+ if (!SENSITIVITIES.includes(a.sensitivity)) fail(`sensitivity must be one of ${SENSITIVITIES.join(", ")}`);
225
+ if (typeof a.handler !== "function") fail("handler must be a function");
226
+ if (a.parameters !== void 0 && (typeof a.parameters !== "object" || a.parameters === null || Array.isArray(a.parameters))) fail("parameters must be a JSON Schema object");
227
+ }
228
+ async function runActionCall(call, o) {
229
+ const fail = (error) => {
230
+ o.onStatus?.("error");
231
+ return { id: call.id, name: call.name, status: "error", error };
232
+ };
233
+ if (!call.ok) return fail(call.error);
234
+ if (o.signal.aborted) return fail("Cancelled");
235
+ const action = o.registry.get(call.name);
236
+ if (!action) return fail(`Unknown action "${call.name}". Available: ${o.registry.names().join(", ") || "none"}`);
237
+ const invalid = validateJsonSchema(call.args, action.parameters ?? { type: "object" });
238
+ if (invalid) return fail(`Invalid arguments: ${invalid}`);
239
+ if (needsConfirmation(action.sensitivity)) {
240
+ o.onStatus?.("awaiting-confirmation");
241
+ const { handler: _omit, ...info } = action;
242
+ let approved = false;
243
+ try {
244
+ approved = await raceAbort(Promise.resolve(o.confirm({ ...info, callId: call.id }, call.args)), o.signal) === true;
245
+ } catch {
246
+ approved = false;
247
+ }
248
+ if (!approved || o.signal.aborted) {
249
+ o.onStatus?.("denied");
250
+ return { id: call.id, name: call.name, status: "denied", error: "The user declined this action." };
251
+ }
252
+ }
253
+ o.onStatus?.("running");
254
+ const ctrl = new AbortController();
255
+ const stop = () => ctrl.abort();
256
+ o.signal.addEventListener("abort", stop, { once: true });
257
+ const timer = setTimeout(stop, o.timeoutMs ?? 3e4);
258
+ try {
259
+ const value = await raceAbort(Promise.resolve().then(() => action.handler(call.args, { callId: call.id, conversationId: o.conversationId, agent: o.agent, signal: ctrl.signal })), ctrl.signal);
260
+ o.onStatus?.("done");
261
+ return { id: call.id, name: call.name, status: "ok", result: toJsonValue(value) ?? null };
262
+ } catch (e) {
263
+ return fail(ctrl.signal.aborted ? o.signal.aborted ? "Cancelled" : "Timed out" : String(e?.message ?? e).slice(0, 500) || "Action failed");
264
+ } finally {
265
+ clearTimeout(timer);
266
+ o.signal.removeEventListener("abort", stop);
267
+ }
268
+ }
269
+ function raceAbort(p, signal) {
270
+ if (signal.aborted) return Promise.reject(new Error("aborted"));
271
+ return new Promise((resolve, reject) => {
272
+ const onAbort = () => reject(new Error("aborted"));
273
+ signal.addEventListener("abort", onAbort, { once: true });
274
+ p.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
275
+ });
276
+ }
5
277
 
6
278
  // src/widget/api-client.ts
7
279
  var ApiError = class extends Error {
@@ -140,6 +412,79 @@ var ApiClient = class {
140
412
  }
141
413
  };
142
414
 
415
+ // src/widget/confirm-dialog.ts
416
+ var seq = 0;
417
+ function showConfirmDialog(container, s, action, args, signal) {
418
+ const id = `dw-confirm-${++seq}`;
419
+ const root = container.getRootNode();
420
+ const previous = root.activeElement;
421
+ const backdrop = document.createElement("div");
422
+ backdrop.className = "confirm-backdrop";
423
+ backdrop.innerHTML = `
424
+ <div class="confirm" role="alertdialog" aria-modal="true" aria-labelledby="${id}-t" aria-describedby="${id}-d">
425
+ <h3 id="${id}-t"></h3>
426
+ <div id="${id}-d" class="confirm-desc">
427
+ <div class="confirm-action"><code></code><span class="sens"></span></div>
428
+ <p class="confirm-text"></p>
429
+ <p class="confirm-warn" hidden></p>
430
+ </div>
431
+ <pre class="confirm-args"></pre>
432
+ <div class="confirm-buttons">
433
+ <button type="button" class="btn deny"></button>
434
+ <button type="button" class="btn allow"></button>
435
+ </div>
436
+ </div>`;
437
+ const q = (sel) => backdrop.querySelector(sel);
438
+ q("h3").textContent = s.confirmTitle;
439
+ q("code").textContent = action.name;
440
+ const sens = q(".sens");
441
+ sens.textContent = action.sensitivity;
442
+ sens.dataset.level = action.sensitivity;
443
+ q(".confirm-text").textContent = action.description;
444
+ if (action.sensitivity === "destructive") {
445
+ const warn = q(".confirm-warn");
446
+ warn.hidden = false;
447
+ warn.textContent = s.confirmDestructive;
448
+ }
449
+ const argsText = JSON.stringify(args, null, 2);
450
+ const pre = q(".confirm-args");
451
+ if (argsText && argsText !== "{}") pre.textContent = argsText;
452
+ else pre.remove();
453
+ const deny = q(".deny");
454
+ const allow = q(".allow");
455
+ deny.textContent = s.deny;
456
+ allow.textContent = s.allow;
457
+ if (action.sensitivity === "destructive") allow.classList.add("danger");
458
+ return new Promise((resolve) => {
459
+ let done = false;
460
+ const finish = (ok) => {
461
+ if (done) return;
462
+ done = true;
463
+ signal?.removeEventListener("abort", onAbort);
464
+ backdrop.remove();
465
+ previous?.focus?.();
466
+ resolve(ok);
467
+ };
468
+ const onAbort = () => finish(false);
469
+ if (signal?.aborted) return finish(false);
470
+ signal?.addEventListener("abort", onAbort, { once: true });
471
+ deny.addEventListener("click", () => finish(false));
472
+ allow.addEventListener("click", () => finish(true));
473
+ backdrop.addEventListener("keydown", (ev) => {
474
+ if (ev.key === "Escape") {
475
+ ev.preventDefault();
476
+ ev.stopPropagation();
477
+ finish(false);
478
+ } else if (ev.key === "Tab") {
479
+ ev.preventDefault();
480
+ (root.activeElement === deny ? allow : deny).focus();
481
+ }
482
+ });
483
+ container.appendChild(backdrop);
484
+ deny.focus();
485
+ });
486
+ }
487
+
143
488
  // src/widget/i18n.ts
144
489
  var en = {
145
490
  title: "Assistant",
@@ -162,7 +507,13 @@ var en = {
162
507
  online: "Online",
163
508
  poweredBy: "Powered by",
164
509
  errorGeneric: "Something went wrong. Please try again.",
165
- stopped: "Stopped."
510
+ stopped: "Stopped.",
511
+ actionStatus: { pending: "queued", "awaiting-confirmation": "needs approval", running: "running", done: "done", denied: "declined", error: "failed" },
512
+ confirmTitle: "Allow this action?",
513
+ confirmDestructive: "This can't be undone.",
514
+ allow: "Allow",
515
+ deny: "Deny",
516
+ actionLimit: (n) => `Stopped after ${n} automatic action rounds. Send a message to continue.`
166
517
  };
167
518
  var vi = {
168
519
  title: "Tr\u1EE3 l\xFD",
@@ -185,13 +536,20 @@ var vi = {
185
536
  online: "\u0110ang tr\u1EF1c tuy\u1EBFn",
186
537
  poweredBy: "V\u1EADn h\xE0nh b\u1EDFi",
187
538
  errorGeneric: "C\xF3 l\u1ED7i x\u1EA3y ra. B\u1EA1n th\u1EED l\u1EA1i gi\xFAp m\xECnh nh\xE9.",
188
- stopped: "\u0110\xE3 d\u1EEBng."
539
+ stopped: "\u0110\xE3 d\u1EEBng.",
540
+ actionStatus: { pending: "ch\u1EDD", "awaiting-confirmation": "c\u1EA7n x\xE1c nh\u1EADn", running: "\u0111ang ch\u1EA1y", done: "xong", denied: "\u0111\xE3 t\u1EEB ch\u1ED1i", error: "l\u1ED7i" },
541
+ confirmTitle: "Cho ph\xE9p thao t\xE1c n\xE0y?",
542
+ confirmDestructive: "Thao t\xE1c n\xE0y kh\xF4ng th\u1EC3 ho\xE0n t\xE1c.",
543
+ allow: "Cho ph\xE9p",
544
+ deny: "T\u1EEB ch\u1ED1i",
545
+ actionLimit: (n) => `\u0110\xE3 d\u1EEBng sau ${n} l\u01B0\u1EE3t thao t\xE1c t\u1EF1 \u0111\u1ED9ng. G\u1EEDi tin nh\u1EAFn \u0111\u1EC3 ti\u1EBFp t\u1EE5c.`
189
546
  };
190
547
  function pickStrings(locale) {
191
548
  const l = (locale || (typeof document !== "undefined" ? document.documentElement.lang : "") || (typeof navigator !== "undefined" ? navigator.language : "") || "en").toLowerCase();
192
549
  return l.startsWith("vi") ? vi : en;
193
550
  }
194
551
  var icons = {
552
+ action: `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8Z"/></svg>`,
195
553
  chat: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8.5 8.5 0 0 1-12.4 7.6L3 21l1.4-5.1A8.5 8.5 0 1 1 21 12Z"/><path d="M8.5 11h.01M12 11h.01M15.5 11h.01"/></svg>`,
196
554
  close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>`,
197
555
  down: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>`,
@@ -404,6 +762,7 @@ var styles = (
404
762
  }
405
763
  * { box-sizing: border-box; }
406
764
  button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0; }
765
+ [hidden] { display: none !important; }
407
766
  .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
408
767
 
409
768
  /* ---------- launcher ---------- */
@@ -569,6 +928,46 @@ details > summary svg.ic { width: 14px; height: 14px; flex: none; }
569
928
  .typing i:nth-child(2) { animation-delay: .15s; } .typing i:nth-child(3) { animation-delay: .3s; }
570
929
  @keyframes bounce { 0%, 60%, 100% { transform: none; opacity: .35; } 30% { transform: translateY(-4px); opacity: 1; } }
571
930
 
931
+ /* page actions */
932
+ .actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
933
+ .md[hidden] + .actions { margin-top: 0; }
934
+ .action {
935
+ display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 9px 4px 8px; border-radius: 999px;
936
+ border: 1px solid var(--dw-border); background: var(--dw-surface); font-size: 12px; color: var(--dw-muted);
937
+ }
938
+ .action svg.ic { width: 13px; height: 13px; flex: none; color: var(--dw-accent); }
939
+ .action-name { font-family: var(--dw-mono); font-size: 11.5px; color: var(--dw-fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
940
+ .action-state { font-size: 11px; flex: none; }
941
+ .action[data-status="done"] .action-state { color: #13a06f; }
942
+ .action[data-status="awaiting-confirmation"] { border-color: color-mix(in srgb, #d98a00 45%, transparent); }
943
+ .action[data-status="awaiting-confirmation"] .action-state { color: #b06f00; }
944
+ .action[data-status="denied"] .action-state, .action[data-status="error"] .action-state { color: #c2334f; }
945
+ .notice { margin: -6px 0 16px; font-size: 12px; color: var(--dw-muted); text-align: center; }
946
+ .confirm-backdrop {
947
+ position: absolute; inset: 0; z-index: 2; display: flex; align-items: flex-end; justify-content: center; padding: 12px;
948
+ background: color-mix(in srgb, var(--dw-fg) 28%, transparent); animation: rise .18s ease both;
949
+ }
950
+ .confirm {
951
+ width: 100%; max-height: 100%; overflow: auto; background: var(--dw-bg); color: var(--dw-fg);
952
+ border: 1px solid var(--dw-border); border-radius: 16px; padding: 16px; box-shadow: var(--dw-shadow);
953
+ }
954
+ .confirm h3 { margin: 0 0 10px; font-size: 15px; font-weight: 650; }
955
+ .confirm-action { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
956
+ .confirm-action code { font-family: var(--dw-mono); font-size: 12.5px; background: var(--dw-surface); padding: 2px 6px; border-radius: 6px; }
957
+ .sens { font-size: 11px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--dw-border); color: var(--dw-muted); }
958
+ .sens[data-level="destructive"] { color: #c2334f; border-color: color-mix(in srgb, #c2334f 40%, transparent); }
959
+ .confirm-text { margin: 8px 0 0; font-size: 13.5px; line-height: 1.5; color: var(--dw-muted); }
960
+ .confirm-warn { margin: 8px 0 0; font-size: 13px; font-weight: 600; color: #c2334f; }
961
+ .confirm-args {
962
+ margin: 10px 0 0; font-family: var(--dw-mono); font-size: 11.5px; line-height: 1.5; background: var(--dw-surface);
963
+ border: 1px solid var(--dw-border); border-radius: 8px; padding: 8px; max-height: 160px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere;
964
+ }
965
+ .confirm-buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
966
+ .btn { font: inherit; font-size: 13.5px; font-weight: 600; padding: 8px 14px; border-radius: 10px; border: 1px solid var(--dw-border); background: var(--dw-bg); color: var(--dw-fg); min-height: 38px; }
967
+ .btn.allow { background: var(--dw-accent); border-color: var(--dw-accent); color: var(--dw-accent-contrast); }
968
+ .btn.allow.danger { background: #c2334f; border-color: #c2334f; color: #fff; }
969
+ .btn:focus-visible { outline: 3px solid color-mix(in srgb, var(--dw-accent) 45%, transparent); outline-offset: 2px; }
970
+
572
971
  /* ---------- composer ---------- */
573
972
  .composer { padding: 10px 12px 12px; border-top: 1px solid var(--dw-border); background: var(--dw-bg); }
574
973
  .box {
@@ -677,6 +1076,10 @@ var DeweeChatElement = class extends ElementBase {
677
1076
  activated = false;
678
1077
  strings = pickStrings();
679
1078
  mql = null;
1079
+ registry = new ActionRegistry();
1080
+ propActionNames = /* @__PURE__ */ new Set();
1081
+ hooks = {};
1082
+ acks = /* @__PURE__ */ new Map();
680
1083
  els;
681
1084
  constructor() {
682
1085
  super();
@@ -694,13 +1097,38 @@ var DeweeChatElement = class extends ElementBase {
694
1097
  else if (a === "z-index") c.zIndex = Number(v);
695
1098
  else c[camel(a)] = v;
696
1099
  }
697
- return { ...c, ...this.overrides };
1100
+ return { ...c, ...this.overrides, ...this.hooks };
698
1101
  }
699
1102
  /** Updates configuration at runtime (wins over attributes). */
700
1103
  configure(partial) {
701
- this.overrides = { ...this.overrides, ...partial };
1104
+ const { actions, confirm, getPageContext, ...rest } = partial;
1105
+ const has = (k) => Object.prototype.hasOwnProperty.call(partial, k);
1106
+ if (has("actions")) this.setPropActions(actions ?? []);
1107
+ if (has("confirm")) this.hooks.confirm = confirm;
1108
+ if (has("getPageContext")) this.hooks.getPageContext = getPageContext;
1109
+ if (!Object.keys(rest).length) return;
1110
+ this.overrides = { ...this.overrides, ...rest };
702
1111
  if (this.els) this.applyConfig();
703
1112
  }
1113
+ /** Adds or replaces page actions the agent may invoke. Throws TypeError on an invalid definition. */
1114
+ registerActions(actions) {
1115
+ this.registry.register(actions);
1116
+ }
1117
+ /** Removes a page action. Returns false when it was not registered. */
1118
+ unregisterAction(name) {
1119
+ this.propActionNames.delete(name);
1120
+ return this.registry.unregister(name);
1121
+ }
1122
+ /** Names of the currently registered page actions. */
1123
+ get actionNames() {
1124
+ return this.registry.names();
1125
+ }
1126
+ setPropActions(actions) {
1127
+ const next = new Set(actions.map((a) => a.name));
1128
+ for (const name of this.propActionNames) if (!next.has(name)) this.registry.unregister(name);
1129
+ if (actions.length) this.registry.register(actions);
1130
+ this.propActionNames = next;
1131
+ }
704
1132
  open() {
705
1133
  if (this.config.mode === "inline") return;
706
1134
  this.setAttribute("data-open", "");
@@ -727,21 +1155,40 @@ var DeweeChatElement = class extends ElementBase {
727
1155
  await this.activate();
728
1156
  this.els.input.value = "";
729
1157
  this.autosize();
730
- this.pushMessage({ role: "user", content: message });
1158
+ let next = { message, hidden: false };
1159
+ for (let round = 0; next; round++) {
1160
+ const reply = await this.runTurn(next.message, next.hidden);
1161
+ next = null;
1162
+ if (reply.status !== "done" || reply.stopped || !this.registry.size) break;
1163
+ const { calls } = parseDirectives(reply.content);
1164
+ if (!calls.length) break;
1165
+ reply.actions = calls.map((c) => ({ id: c.id, name: c.name, args: c.ok ? c.args : void 0, status: "pending" }));
1166
+ const limit = this.config.maxActionRounds ?? 5;
1167
+ if (round >= limit) {
1168
+ for (const a of reply.actions) Object.assign(a, { status: "error", error: "Automatic action limit reached" });
1169
+ this.updateMessage(reply);
1170
+ this.showNotice(this.strings.actionLimit(limit));
1171
+ break;
1172
+ }
1173
+ this.updateMessage(reply);
1174
+ const results = await this.runActions(reply, calls);
1175
+ if (results) next = { message: formatActionResults(results, this.server?.maxMessageLength ?? 4e3), hidden: true };
1176
+ }
1177
+ }
1178
+ /** One request/response with the agent. */
1179
+ async runTurn(message, hidden) {
1180
+ this.pushMessage({ role: "user", content: message, hidden });
731
1181
  const reply = { role: "assistant", content: "", status: "streaming", startedAt: Date.now() };
732
1182
  this.pushMessage(reply);
733
1183
  this.setRunning(new AbortController());
734
- this.emit("dewee:send", { message, agent: this.agentId, conversationId: this.conversationId });
1184
+ this.emit("dewee:send", { message, agent: this.agentId, conversationId: this.conversationId, auto: hidden || void 0 });
735
1185
  try {
736
- await this.client().chat(
737
- { message, agent: this.agentId, conversationId: this.conversationId },
738
- (e) => this.onStreamEvent(reply, e),
739
- this.running.signal
740
- );
1186
+ await this.client().chat(this.chatBody(message), (e) => this.onStreamEvent(reply, e), this.running.signal);
741
1187
  if (reply.status === "streaming") reply.status = "done";
742
1188
  } catch (e) {
743
1189
  if (e.name === "AbortError") {
744
1190
  reply.status = "done";
1191
+ reply.stopped = true;
745
1192
  if (!reply.content) reply.content = `_${this.strings.stopped}_`;
746
1193
  } else {
747
1194
  reply.status = "error";
@@ -752,10 +1199,95 @@ var DeweeChatElement = class extends ElementBase {
752
1199
  this.updateMessage(reply);
753
1200
  this.setRunning(null);
754
1201
  if (reply.status === "done") {
1202
+ if (reply.actionsHash && !reply.stopped) this.setAck(reply.actionsHash);
755
1203
  this.emit("dewee:reply", { content: reply.content, agent: this.agentId, conversationId: this.conversationId });
756
1204
  if (this.config.mode !== "inline" && !this.hasAttribute("data-open")) this.setAttribute("data-unread", "");
757
1205
  }
758
1206
  }
1207
+ return reply;
1208
+ }
1209
+ chatBody(message) {
1210
+ const body = { message, agent: this.agentId, conversationId: this.conversationId };
1211
+ if (this.registry.size) {
1212
+ body.actions = this.registry.manifest();
1213
+ const ack = this.getAck();
1214
+ if (ack) body.actionsHash = ack;
1215
+ }
1216
+ const ctx = this.pageContext();
1217
+ if (ctx !== void 0) body.pageContext = ctx;
1218
+ return body;
1219
+ }
1220
+ /** Host snapshot, dropped (with a warning) when it fails or exceeds the backend cap. */
1221
+ pageContext() {
1222
+ const get = this.hooks.getPageContext;
1223
+ if (!get) return void 0;
1224
+ try {
1225
+ const v = get();
1226
+ if (v === void 0 || v === null) return void 0;
1227
+ const size = new TextEncoder().encode(JSON.stringify(v)).length;
1228
+ if (size <= ACTION_LIMITS.maxPageContextBytes) return v;
1229
+ console.warn(`[dewee-webchat] getPageContext() returned ${size} bytes; the limit is ${ACTION_LIMITS.maxPageContextBytes}. Context not sent.`);
1230
+ return { truncated: true };
1231
+ } catch (e) {
1232
+ console.warn("[dewee-webchat] getPageContext() failed:", e);
1233
+ return void 0;
1234
+ }
1235
+ }
1236
+ ackKey() {
1237
+ return `dewee-webchat:actions:${this.config.endpoint}:${this.agentId}:${this.conversationId}`;
1238
+ }
1239
+ getAck() {
1240
+ return this.acks.get(this.ackKey()) ?? (this.config.persist !== false ? store2.get(this.ackKey()) ?? void 0 : void 0);
1241
+ }
1242
+ setAck(hash) {
1243
+ this.acks.set(this.ackKey(), hash);
1244
+ if (this.config.persist !== false) store2.set(this.ackKey(), hash);
1245
+ }
1246
+ /** Executes the calls of one answer in order. Resolves null when the visitor stopped the chain. */
1247
+ async runActions(reply, calls) {
1248
+ const ctrl = new AbortController();
1249
+ this.setRunning(ctrl);
1250
+ const results = [];
1251
+ const base = { agent: this.agentId, conversationId: this.conversationId };
1252
+ try {
1253
+ for (const [i, call] of calls.entries()) {
1254
+ const view = reply.actions[i];
1255
+ const sensitivity = call.ok ? this.registry.get(call.name)?.sensitivity : void 0;
1256
+ const args = call.ok ? call.args : void 0;
1257
+ this.emit("dewee:action", { phase: "start", id: call.id, name: call.name, sensitivity, args, ...base });
1258
+ const r = await runActionCall(call, {
1259
+ registry: this.registry,
1260
+ confirm: (action, a) => this.confirmAction(action, a, ctrl.signal),
1261
+ signal: ctrl.signal,
1262
+ timeoutMs: this.config.actionTimeoutMs,
1263
+ ...base,
1264
+ onStatus: (status) => {
1265
+ view.status = status;
1266
+ this.updateMessage(reply);
1267
+ }
1268
+ });
1269
+ if (r.error) view.error = r.error;
1270
+ this.updateMessage(reply);
1271
+ results.push(r);
1272
+ this.emit("dewee:action", { phase: "result", id: r.id, name: r.name, sensitivity, args, status: r.status, result: r.result, error: r.error, ...base });
1273
+ }
1274
+ } finally {
1275
+ this.setRunning(null);
1276
+ }
1277
+ return ctrl.signal.aborted ? null : results;
1278
+ }
1279
+ confirmAction(action, args, signal) {
1280
+ if (this.hooks.confirm) return this.hooks.confirm(action, args);
1281
+ if (this.config.mode !== "inline" && !this.hasAttribute("data-open")) this.open();
1282
+ return showConfirmDialog(this.els.panel, this.strings, action, args, signal);
1283
+ }
1284
+ showNotice(text) {
1285
+ const div = document.createElement("div");
1286
+ div.className = "notice";
1287
+ div.setAttribute("role", "status");
1288
+ div.textContent = text;
1289
+ this.els.log.appendChild(div);
1290
+ this.scrollToBottom(true);
759
1291
  }
760
1292
  /** Stops the running reply. */
761
1293
  stop() {
@@ -999,7 +1531,7 @@ var DeweeChatElement = class extends ElementBase {
999
1531
  if (!saved) return;
1000
1532
  try {
1001
1533
  const history = await this.client().history(this.agentId, this.conversationId);
1002
- this.messages = history.map((m) => ({ ...m, status: "done" }));
1534
+ this.messages = decorateHistory(history.map((m) => ({ ...m, status: "done" })));
1003
1535
  this.renderLog();
1004
1536
  } catch {
1005
1537
  }
@@ -1045,6 +1577,7 @@ var DeweeChatElement = class extends ElementBase {
1045
1577
  renderMessage(m) {
1046
1578
  const root = document.createElement("div");
1047
1579
  root.className = `msg ${m.role}${m.status === "error" ? " error" : ""}`;
1580
+ root.hidden = !!m.hidden;
1048
1581
  const bubble = document.createElement("div");
1049
1582
  bubble.className = "bubble";
1050
1583
  root.appendChild(bubble);
@@ -1052,9 +1585,13 @@ var DeweeChatElement = class extends ElementBase {
1052
1585
  trace.className = "trace";
1053
1586
  const md = document.createElement("div");
1054
1587
  md.className = m.role === "assistant" ? "md" : "";
1588
+ const actions = document.createElement("div");
1589
+ actions.className = "actions";
1590
+ actions.hidden = true;
1055
1591
  if (m.role === "assistant") bubble.appendChild(trace);
1056
1592
  bubble.appendChild(md);
1057
- m.view = { root, trace, md, tools: /* @__PURE__ */ new Map() };
1593
+ if (m.role === "assistant") bubble.appendChild(actions);
1594
+ m.view = { root, trace, md, tools: /* @__PURE__ */ new Map(), actions };
1058
1595
  this.updateMessage(m);
1059
1596
  return root;
1060
1597
  }
@@ -1091,13 +1628,33 @@ var DeweeChatElement = class extends ElementBase {
1091
1628
  v.tools.clear();
1092
1629
  }
1093
1630
  v.trace.hidden = !v.trace.childElementCount;
1094
- const typing = m.status === "streaming" && !m.content;
1631
+ const shown = this.registry.size || m.actions ? stripDirectives(m.content) : m.content;
1632
+ const typing = m.status === "streaming" && !shown;
1633
+ v.md.hidden = !typing && !shown && !!m.actions?.length;
1095
1634
  if (typing) {
1096
1635
  v.md.innerHTML = `<span class="typing" aria-label="${this.strings.thinking}"><i></i><i></i><i></i></span>`;
1097
1636
  } else {
1098
- v.md.replaceChildren(renderMarkdown(document, m.content));
1637
+ v.md.replaceChildren(renderMarkdown(document, shown));
1099
1638
  v.md.classList.toggle("cursor", m.status === "streaming");
1100
1639
  }
1640
+ this.renderActions(v, m.actions ?? []);
1641
+ }
1642
+ renderActions(v, list) {
1643
+ v.actions.hidden = !list.length;
1644
+ v.actions.replaceChildren(
1645
+ ...list.map((a) => {
1646
+ const chip = document.createElement("div");
1647
+ chip.className = "action";
1648
+ chip.dataset.status = a.status;
1649
+ const busy = a.status === "running" || a.status === "awaiting-confirmation";
1650
+ chip.innerHTML = `${busy ? '<span class="spin"></span>' : icons.action}<span class="action-name"></span><span class="action-state"></span>`;
1651
+ chip.querySelector(".action-name").textContent = a.name || "?";
1652
+ chip.querySelector(".action-state").textContent = this.strings.actionStatus[a.status];
1653
+ const tip = [a.args && Object.keys(a.args).length ? JSON.stringify(a.args) : "", a.error ?? ""].filter(Boolean).join("\n");
1654
+ if (tip) chip.title = tip;
1655
+ return chip;
1656
+ })
1657
+ );
1101
1658
  }
1102
1659
  renderTool(v, t) {
1103
1660
  let d = v.tools.get(t.id);
@@ -1116,7 +1673,7 @@ var DeweeChatElement = class extends ElementBase {
1116
1673
  d.querySelector(".tool-state").textContent = t.status === "running" ? this.strings.toolRunning : t.status === "error" ? this.strings.toolError : this.strings.toolDone;
1117
1674
  const body = d.querySelector(".tool-body");
1118
1675
  body.replaceChildren();
1119
- const block = (label, value) => {
1676
+ const block2 = (label, value) => {
1120
1677
  if (value === void 0 || value === null || value === "") return;
1121
1678
  const l = document.createElement("div");
1122
1679
  l.className = "label";
@@ -1125,12 +1682,13 @@ var DeweeChatElement = class extends ElementBase {
1125
1682
  pre.textContent = typeof value === "string" ? tryPretty(value) : JSON.stringify(value, null, 2);
1126
1683
  body.append(l, pre);
1127
1684
  };
1128
- block(this.strings.args, t.arguments);
1129
- block(this.strings.result, t.result);
1685
+ block2(this.strings.args, t.arguments);
1686
+ block2(this.strings.result, t.result);
1130
1687
  }
1131
1688
  onStreamEvent(m, e) {
1132
1689
  switch (e.type) {
1133
1690
  case "start":
1691
+ if (e.actionsHash) m.actionsHash = e.actionsHash;
1134
1692
  if (e.conversationId && e.conversationId !== this.conversationId) {
1135
1693
  this.conversationId = e.conversationId;
1136
1694
  if (this.config.persist !== false) store2.set(this.convKey(), e.conversationId);
@@ -1228,6 +1786,27 @@ var DeweeChatElement = class extends ElementBase {
1228
1786
  }
1229
1787
  }
1230
1788
  };
1789
+ function decorateHistory(messages) {
1790
+ messages.forEach((m, i) => {
1791
+ if (m.role === "user") {
1792
+ m.content = stripPageBlock(m.content);
1793
+ if (isActionResultMessage(m.content)) m.hidden = true;
1794
+ return;
1795
+ }
1796
+ if (!m.content.includes("```dewee-action")) return;
1797
+ const { calls } = parseDirectives(m.content);
1798
+ if (!calls.length) return;
1799
+ const next = messages[i + 1];
1800
+ const nextContent = next?.role === "user" ? stripPageBlock(next.content) : "";
1801
+ const results = isActionResultMessage(nextContent) ? parseActionResults(nextContent) : [];
1802
+ m.actions = calls.map((c) => {
1803
+ const r = results.find((x) => x.id === c.id);
1804
+ const status = !r ? "error" : r.status === "ok" ? "done" : r.status;
1805
+ return { id: c.id, name: c.name, args: c.ok ? c.args : void 0, status, error: r ? r.error : "Not run" };
1806
+ });
1807
+ });
1808
+ return messages;
1809
+ }
1231
1810
  function safeJson(s, fallback) {
1232
1811
  try {
1233
1812
  return JSON.parse(s);