@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.
- package/dist/ChatWidget-BhGpxS9f.js +2179 -0
- package/dist/index.js +2 -0
- package/dist/runtime/actions.d.ts +1 -1
- package/dist/runtime/actions.js +40 -24
- package/dist/runtime/channel-registry.d.ts +11 -4
- package/dist/runtime/channel-registry.js +91 -13
- package/dist/runtime/connector.d.ts +3 -7
- package/dist/runtime/connector.js +14 -55
- package/dist/runtime/format.d.ts +0 -1
- package/dist/runtime/format.js +2 -11
- package/dist/runtime/index.d.ts +1 -2
- package/dist/runtime/index.js +6 -7
- package/dist/runtime/send.d.ts +2 -0
- package/dist/runtime/send.js +54 -29
- package/dist/schema.js +133 -84
- package/dist/web.js +1 -1
- package/package.json +5 -6
- package/dist/ChatWidget-DkEKl1-2.js +0 -24846
|
@@ -0,0 +1,2179 @@
|
|
|
1
|
+
import * as __fedReact from "react";
|
|
2
|
+
import { Dialog, DialogContent, Icon, MarkdownView, toneVar, uploadFile, useMinWidth } from "@coffer-org/web-ui";
|
|
3
|
+
import { createContext, createElement, forwardRef, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { useLocation } from "react-router-dom";
|
|
5
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region \0rolldown/runtime.js
|
|
7
|
+
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
8
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) {
|
|
9
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
10
|
+
throw Error("Calling `require` for \"" + x + "\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.");
|
|
11
|
+
});
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region ../../node_modules/react-i18next/dist/es/utils.js
|
|
14
|
+
var warn = (i18n, code, msg, rest) => {
|
|
15
|
+
const args = [msg, {
|
|
16
|
+
code,
|
|
17
|
+
...rest || {}
|
|
18
|
+
}];
|
|
19
|
+
if (i18n?.services?.logger?.forward) return i18n.services.logger.forward(args, "warn", "react-i18next::", true);
|
|
20
|
+
if (isString(args[0])) args[0] = `react-i18next:: ${args[0]}`;
|
|
21
|
+
if (i18n?.services?.logger?.warn) i18n.services.logger.warn(...args);
|
|
22
|
+
else if (console?.warn) console.warn(...args);
|
|
23
|
+
};
|
|
24
|
+
var alreadyWarned = {};
|
|
25
|
+
var warnOnce = (i18n, code, msg, rest) => {
|
|
26
|
+
if (isString(msg) && alreadyWarned[msg]) return;
|
|
27
|
+
if (isString(msg)) alreadyWarned[msg] = /* @__PURE__ */ new Date();
|
|
28
|
+
warn(i18n, code, msg, rest);
|
|
29
|
+
};
|
|
30
|
+
var loadedClb = (i18n, cb) => () => {
|
|
31
|
+
if (i18n.isInitialized) cb();
|
|
32
|
+
else {
|
|
33
|
+
const initialized = () => {
|
|
34
|
+
setTimeout(() => {
|
|
35
|
+
i18n.off("initialized", initialized);
|
|
36
|
+
}, 0);
|
|
37
|
+
cb();
|
|
38
|
+
};
|
|
39
|
+
i18n.on("initialized", initialized);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
var loadNamespaces = (i18n, ns, cb) => {
|
|
43
|
+
i18n.loadNamespaces(ns, loadedClb(i18n, cb));
|
|
44
|
+
};
|
|
45
|
+
var loadLanguages = (i18n, lng, ns, cb) => {
|
|
46
|
+
if (isString(ns)) ns = [ns];
|
|
47
|
+
if (i18n.options.preload && i18n.options.preload.indexOf(lng) > -1) return loadNamespaces(i18n, ns, cb);
|
|
48
|
+
ns.forEach((n) => {
|
|
49
|
+
if (i18n.options.ns.indexOf(n) < 0) i18n.options.ns.push(n);
|
|
50
|
+
});
|
|
51
|
+
i18n.loadLanguages(lng, loadedClb(i18n, cb));
|
|
52
|
+
};
|
|
53
|
+
var hasLoadedNamespace = (ns, i18n, options = {}) => {
|
|
54
|
+
if (!i18n.languages || !i18n.languages.length) {
|
|
55
|
+
warnOnce(i18n, "NO_LANGUAGES", "i18n.languages were undefined or empty", { languages: i18n.languages });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return i18n.hasLoadedNamespace(ns, {
|
|
59
|
+
lng: options.lng,
|
|
60
|
+
precheck: (i18nInstance, loadNotPending) => {
|
|
61
|
+
if (options.bindI18n && options.bindI18n.indexOf("languageChanging") > -1 && i18nInstance.services.backendConnector.backend && i18nInstance.isLanguageChangingTo && !loadNotPending(i18nInstance.isLanguageChangingTo, ns)) return false;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
var isString = (obj) => typeof obj === "string";
|
|
66
|
+
var isObject = (obj) => typeof obj === "object" && obj !== null;
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region ../../node_modules/react-i18next/dist/es/unescape.js
|
|
69
|
+
var matchHtmlEntity = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34|nbsp|#160|copy|#169|reg|#174|hellip|#8230|#x2F|#47);/g;
|
|
70
|
+
var htmlEntities = {
|
|
71
|
+
"&": "&",
|
|
72
|
+
"&": "&",
|
|
73
|
+
"<": "<",
|
|
74
|
+
"<": "<",
|
|
75
|
+
">": ">",
|
|
76
|
+
">": ">",
|
|
77
|
+
"'": "'",
|
|
78
|
+
"'": "'",
|
|
79
|
+
""": "\"",
|
|
80
|
+
""": "\"",
|
|
81
|
+
" ": " ",
|
|
82
|
+
" ": " ",
|
|
83
|
+
"©": "©",
|
|
84
|
+
"©": "©",
|
|
85
|
+
"®": "®",
|
|
86
|
+
"®": "®",
|
|
87
|
+
"…": "…",
|
|
88
|
+
"…": "…",
|
|
89
|
+
"/": "/",
|
|
90
|
+
"/": "/"
|
|
91
|
+
};
|
|
92
|
+
var unescapeHtmlEntity = (m) => htmlEntities[m];
|
|
93
|
+
var unescape = (text) => text.replace(matchHtmlEntity, unescapeHtmlEntity);
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region ../../node_modules/react-i18next/dist/es/defaults.js
|
|
96
|
+
var defaultOptions = {
|
|
97
|
+
bindI18n: "languageChanged",
|
|
98
|
+
bindI18nStore: "",
|
|
99
|
+
transEmptyNodeValue: "",
|
|
100
|
+
transSupportBasicHtmlNodes: true,
|
|
101
|
+
transWrapTextNodes: "",
|
|
102
|
+
transKeepBasicHtmlNodesFor: [
|
|
103
|
+
"br",
|
|
104
|
+
"strong",
|
|
105
|
+
"i",
|
|
106
|
+
"p"
|
|
107
|
+
],
|
|
108
|
+
useSuspense: true,
|
|
109
|
+
unescape,
|
|
110
|
+
transDefaultProps: void 0
|
|
111
|
+
};
|
|
112
|
+
var getDefaults = () => defaultOptions;
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region ../../node_modules/react-i18next/dist/es/i18nInstance.js
|
|
115
|
+
var i18nInstance;
|
|
116
|
+
var getI18n = () => i18nInstance;
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region ../../node_modules/react-i18next/dist/es/context.js
|
|
119
|
+
var I18nContext = createContext();
|
|
120
|
+
var ReportNamespaces = class {
|
|
121
|
+
constructor() {
|
|
122
|
+
this.usedNamespaces = {};
|
|
123
|
+
}
|
|
124
|
+
addUsedNamespaces(namespaces) {
|
|
125
|
+
namespaces.forEach((ns) => {
|
|
126
|
+
if (!this.usedNamespaces[ns]) this.usedNamespaces[ns] = true;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
getUsedNamespaces() {
|
|
130
|
+
return Object.keys(this.usedNamespaces);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region ../../node_modules/use-sync-external-store/cjs/use-sync-external-store-shim.production.js
|
|
135
|
+
/**
|
|
136
|
+
* @license React
|
|
137
|
+
* use-sync-external-store-shim.production.js
|
|
138
|
+
*
|
|
139
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
140
|
+
*
|
|
141
|
+
* This source code is licensed under the MIT license found in the
|
|
142
|
+
* LICENSE file in the root directory of this source tree.
|
|
143
|
+
*/
|
|
144
|
+
var require_use_sync_external_store_shim_production = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
145
|
+
var React = __fedReact;
|
|
146
|
+
function is(x, y) {
|
|
147
|
+
return x === y && (0 !== x || 1 / x === 1 / y) || x !== x && y !== y;
|
|
148
|
+
}
|
|
149
|
+
var objectIs = "function" === typeof Object.is ? Object.is : is;
|
|
150
|
+
var useState = React.useState;
|
|
151
|
+
var useEffect = React.useEffect;
|
|
152
|
+
var useLayoutEffect = React.useLayoutEffect;
|
|
153
|
+
var useDebugValue = React.useDebugValue;
|
|
154
|
+
function useSyncExternalStore$2(subscribe, getSnapshot) {
|
|
155
|
+
var value = getSnapshot(), _useState = useState({ inst: {
|
|
156
|
+
value,
|
|
157
|
+
getSnapshot
|
|
158
|
+
} }), inst = _useState[0].inst, forceUpdate = _useState[1];
|
|
159
|
+
useLayoutEffect(function() {
|
|
160
|
+
inst.value = value;
|
|
161
|
+
inst.getSnapshot = getSnapshot;
|
|
162
|
+
checkIfSnapshotChanged(inst) && forceUpdate({ inst });
|
|
163
|
+
}, [
|
|
164
|
+
subscribe,
|
|
165
|
+
value,
|
|
166
|
+
getSnapshot
|
|
167
|
+
]);
|
|
168
|
+
useEffect(function() {
|
|
169
|
+
checkIfSnapshotChanged(inst) && forceUpdate({ inst });
|
|
170
|
+
return subscribe(function() {
|
|
171
|
+
checkIfSnapshotChanged(inst) && forceUpdate({ inst });
|
|
172
|
+
});
|
|
173
|
+
}, [subscribe]);
|
|
174
|
+
useDebugValue(value);
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
function checkIfSnapshotChanged(inst) {
|
|
178
|
+
var latestGetSnapshot = inst.getSnapshot;
|
|
179
|
+
inst = inst.value;
|
|
180
|
+
try {
|
|
181
|
+
var nextValue = latestGetSnapshot();
|
|
182
|
+
return !objectIs(inst, nextValue);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return !0;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function useSyncExternalStore$1(subscribe, getSnapshot) {
|
|
188
|
+
return getSnapshot();
|
|
189
|
+
}
|
|
190
|
+
var shim = "undefined" === typeof window || "undefined" === typeof window.document || "undefined" === typeof window.document.createElement ? useSyncExternalStore$1 : useSyncExternalStore$2;
|
|
191
|
+
exports.useSyncExternalStore = void 0 !== React.useSyncExternalStore ? React.useSyncExternalStore : shim;
|
|
192
|
+
}));
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region ../../node_modules/react-i18next/dist/es/useTranslation.js
|
|
195
|
+
var import_shim = (/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
196
|
+
module.exports = require_use_sync_external_store_shim_production();
|
|
197
|
+
})))();
|
|
198
|
+
var notReadyT = (k, optsOrDefaultValue) => {
|
|
199
|
+
if (isString(optsOrDefaultValue)) return optsOrDefaultValue;
|
|
200
|
+
if (isObject(optsOrDefaultValue) && isString(optsOrDefaultValue.defaultValue)) return optsOrDefaultValue.defaultValue;
|
|
201
|
+
if (typeof k === "function") return "";
|
|
202
|
+
if (Array.isArray(k)) {
|
|
203
|
+
const last = k[k.length - 1];
|
|
204
|
+
return typeof last === "function" ? "" : last;
|
|
205
|
+
}
|
|
206
|
+
return k;
|
|
207
|
+
};
|
|
208
|
+
var notReadySnapshot = {
|
|
209
|
+
t: notReadyT,
|
|
210
|
+
ready: false
|
|
211
|
+
};
|
|
212
|
+
var dummySubscribe = () => () => {};
|
|
213
|
+
var useTranslation = (ns, props = {}) => {
|
|
214
|
+
const { i18n: i18nFromProps } = props;
|
|
215
|
+
const { i18n: i18nFromContext, defaultNS: defaultNSFromContext } = useContext(I18nContext) || {};
|
|
216
|
+
const i18n = i18nFromProps || i18nFromContext || getI18n();
|
|
217
|
+
if (i18n && !i18n.reportNamespaces) i18n.reportNamespaces = new ReportNamespaces();
|
|
218
|
+
if (!i18n) warnOnce(i18n, "NO_I18NEXT_INSTANCE", "useTranslation: You will need to pass in an i18next instance by using initReactI18next or by passing it via props or context. In monorepo setups, make sure there is only one instance of react-i18next.");
|
|
219
|
+
const i18nOptions = useMemo(() => ({
|
|
220
|
+
...getDefaults(),
|
|
221
|
+
...i18n?.options?.react,
|
|
222
|
+
...props
|
|
223
|
+
}), [i18n, props]);
|
|
224
|
+
const { useSuspense, keyPrefix } = i18nOptions;
|
|
225
|
+
const nsOrContext = ns || defaultNSFromContext || i18n?.options?.defaultNS;
|
|
226
|
+
const unstableNamespaces = isString(nsOrContext) ? [nsOrContext] : nsOrContext || ["translation"];
|
|
227
|
+
const namespaces = useMemo(() => unstableNamespaces, unstableNamespaces);
|
|
228
|
+
i18n?.reportNamespaces?.addUsedNamespaces?.(namespaces);
|
|
229
|
+
const revisionRef = useRef(0);
|
|
230
|
+
const subscribe = useCallback((callback) => {
|
|
231
|
+
if (!i18n) return dummySubscribe;
|
|
232
|
+
const { bindI18n, bindI18nStore } = i18nOptions;
|
|
233
|
+
const wrappedCallback = () => {
|
|
234
|
+
revisionRef.current += 1;
|
|
235
|
+
callback();
|
|
236
|
+
};
|
|
237
|
+
if (bindI18n) i18n.on(bindI18n, wrappedCallback);
|
|
238
|
+
if (bindI18nStore) i18n.store.on(bindI18nStore, wrappedCallback);
|
|
239
|
+
return () => {
|
|
240
|
+
if (bindI18n) bindI18n.split(" ").forEach((e) => i18n.off(e, wrappedCallback));
|
|
241
|
+
if (bindI18nStore) bindI18nStore.split(" ").forEach((e) => i18n.store.off(e, wrappedCallback));
|
|
242
|
+
};
|
|
243
|
+
}, [i18n, i18nOptions]);
|
|
244
|
+
const snapshotRef = useRef();
|
|
245
|
+
const getSnapshot = useCallback(() => {
|
|
246
|
+
if (!i18n) return notReadySnapshot;
|
|
247
|
+
const calculatedReady = !!(i18n.isInitialized || i18n.initializedStoreOnce) && namespaces.every((n) => hasLoadedNamespace(n, i18n, i18nOptions));
|
|
248
|
+
const currentLng = props.lng || i18n.language;
|
|
249
|
+
const currentRevision = revisionRef.current;
|
|
250
|
+
const lastSnapshot = snapshotRef.current;
|
|
251
|
+
if (lastSnapshot && lastSnapshot.ready === calculatedReady && lastSnapshot.lng === currentLng && lastSnapshot.keyPrefix === keyPrefix && lastSnapshot.revision === currentRevision) return lastSnapshot;
|
|
252
|
+
const newSnapshot = {
|
|
253
|
+
t: i18n.getFixedT(currentLng, i18nOptions.nsMode === "fallback" ? namespaces : namespaces[0], keyPrefix, { scopeNs: namespaces }),
|
|
254
|
+
ready: calculatedReady,
|
|
255
|
+
lng: currentLng,
|
|
256
|
+
keyPrefix,
|
|
257
|
+
revision: currentRevision
|
|
258
|
+
};
|
|
259
|
+
snapshotRef.current = newSnapshot;
|
|
260
|
+
return newSnapshot;
|
|
261
|
+
}, [
|
|
262
|
+
i18n,
|
|
263
|
+
namespaces,
|
|
264
|
+
keyPrefix,
|
|
265
|
+
i18nOptions,
|
|
266
|
+
props.lng
|
|
267
|
+
]);
|
|
268
|
+
const [loadCount, setLoadCount] = useState(0);
|
|
269
|
+
const { t, ready } = (0, import_shim.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (i18n && !ready && !useSuspense) {
|
|
272
|
+
const onLoaded = () => setLoadCount((c) => c + 1);
|
|
273
|
+
if (props.lng) loadLanguages(i18n, props.lng, namespaces, onLoaded);
|
|
274
|
+
else loadNamespaces(i18n, namespaces, onLoaded);
|
|
275
|
+
}
|
|
276
|
+
}, [
|
|
277
|
+
i18n,
|
|
278
|
+
props.lng,
|
|
279
|
+
namespaces,
|
|
280
|
+
ready,
|
|
281
|
+
useSuspense,
|
|
282
|
+
loadCount
|
|
283
|
+
]);
|
|
284
|
+
const finalI18n = i18n || {};
|
|
285
|
+
const wrapperRef = useRef(null);
|
|
286
|
+
const wrapperLangRef = useRef();
|
|
287
|
+
const createI18nWrapper = (original) => {
|
|
288
|
+
const descriptors = Object.getOwnPropertyDescriptors(original);
|
|
289
|
+
if (descriptors.__original) delete descriptors.__original;
|
|
290
|
+
const wrapper = Object.create(Object.getPrototypeOf(original), descriptors);
|
|
291
|
+
if (!Object.prototype.hasOwnProperty.call(wrapper, "__original")) try {
|
|
292
|
+
Object.defineProperty(wrapper, "__original", {
|
|
293
|
+
value: original,
|
|
294
|
+
writable: false,
|
|
295
|
+
enumerable: false,
|
|
296
|
+
configurable: false
|
|
297
|
+
});
|
|
298
|
+
} catch (_) {}
|
|
299
|
+
return wrapper;
|
|
300
|
+
};
|
|
301
|
+
const ret = useMemo(() => {
|
|
302
|
+
const original = finalI18n;
|
|
303
|
+
const lang = original?.language;
|
|
304
|
+
let i18nWrapper = original;
|
|
305
|
+
if (original) if (wrapperRef.current && wrapperRef.current.__original === original) if (wrapperLangRef.current !== lang) {
|
|
306
|
+
i18nWrapper = createI18nWrapper(original);
|
|
307
|
+
wrapperRef.current = i18nWrapper;
|
|
308
|
+
wrapperLangRef.current = lang;
|
|
309
|
+
} else i18nWrapper = wrapperRef.current;
|
|
310
|
+
else {
|
|
311
|
+
i18nWrapper = createI18nWrapper(original);
|
|
312
|
+
wrapperRef.current = i18nWrapper;
|
|
313
|
+
wrapperLangRef.current = lang;
|
|
314
|
+
}
|
|
315
|
+
const effectiveT = !ready && !useSuspense ? (...args) => {
|
|
316
|
+
warnOnce(i18n, "USE_T_BEFORE_READY", "useTranslation: t was called before ready. When using useSuspense: false, make sure to check the ready flag before using t.");
|
|
317
|
+
return t(...args);
|
|
318
|
+
} : t;
|
|
319
|
+
const arr = [
|
|
320
|
+
effectiveT,
|
|
321
|
+
i18nWrapper,
|
|
322
|
+
ready
|
|
323
|
+
];
|
|
324
|
+
arr.t = effectiveT;
|
|
325
|
+
arr.i18n = i18nWrapper;
|
|
326
|
+
arr.ready = ready;
|
|
327
|
+
return arr;
|
|
328
|
+
}, [
|
|
329
|
+
t,
|
|
330
|
+
finalI18n,
|
|
331
|
+
ready,
|
|
332
|
+
finalI18n.resolvedLanguage,
|
|
333
|
+
finalI18n.language,
|
|
334
|
+
finalI18n.languages
|
|
335
|
+
]);
|
|
336
|
+
if (i18n && useSuspense && !ready) {
|
|
337
|
+
let inDevelopment = false;
|
|
338
|
+
try {
|
|
339
|
+
inDevelopment = false;
|
|
340
|
+
} catch (e) {}
|
|
341
|
+
if (inDevelopment) warnOnce(i18n, "SUSPENDED_WHILE_LOADING", "useTranslation: suspended while translations are loading (useSuspense is true by default). Add a <Suspense> boundary above this component, or set react.useSuspense: false in the i18next init options. https://react.i18next.com/latest/usetranslation-hook");
|
|
342
|
+
throw new Promise((resolve) => {
|
|
343
|
+
const onLoaded = () => resolve();
|
|
344
|
+
if (props.lng) loadLanguages(i18n, props.lng, namespaces, onLoaded);
|
|
345
|
+
else loadNamespaces(i18n, namespaces, onLoaded);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return ret;
|
|
349
|
+
};
|
|
350
|
+
//#endregion
|
|
351
|
+
//#region ../../node_modules/lucide-react/dist/esm/shared/src/utils/mergeClasses.mjs
|
|
352
|
+
/**
|
|
353
|
+
* @license lucide-react v1.24.0 - ISC
|
|
354
|
+
*
|
|
355
|
+
* This source code is licensed under the ISC license.
|
|
356
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
357
|
+
*/
|
|
358
|
+
var mergeClasses = (...classes) => classes.filter((className, index, array) => {
|
|
359
|
+
return Boolean(className) && className.trim() !== "" && array.indexOf(className) === index;
|
|
360
|
+
}).join(" ").trim();
|
|
361
|
+
//#endregion
|
|
362
|
+
//#region ../../node_modules/lucide-react/dist/esm/shared/src/utils/toKebabCase.mjs
|
|
363
|
+
/**
|
|
364
|
+
* @license lucide-react v1.24.0 - ISC
|
|
365
|
+
*
|
|
366
|
+
* This source code is licensed under the ISC license.
|
|
367
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
368
|
+
*/
|
|
369
|
+
var toKebabCase = (string) => string.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region ../../node_modules/lucide-react/dist/esm/shared/src/utils/toCamelCase.mjs
|
|
372
|
+
/**
|
|
373
|
+
* @license lucide-react v1.24.0 - ISC
|
|
374
|
+
*
|
|
375
|
+
* This source code is licensed under the ISC license.
|
|
376
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
377
|
+
*/
|
|
378
|
+
var toCamelCase = (string) => string.replace(/^([A-Z])|[\s-_]+(\w)/g, (match, p1, p2) => p2 ? p2.toUpperCase() : p1.toLowerCase());
|
|
379
|
+
//#endregion
|
|
380
|
+
//#region ../../node_modules/lucide-react/dist/esm/shared/src/utils/toPascalCase.mjs
|
|
381
|
+
/**
|
|
382
|
+
* @license lucide-react v1.24.0 - ISC
|
|
383
|
+
*
|
|
384
|
+
* This source code is licensed under the ISC license.
|
|
385
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
386
|
+
*/
|
|
387
|
+
var toPascalCase = (string) => {
|
|
388
|
+
const camelCase = toCamelCase(string);
|
|
389
|
+
return camelCase.charAt(0).toUpperCase() + camelCase.slice(1);
|
|
390
|
+
};
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region ../../node_modules/lucide-react/dist/esm/defaultAttributes.mjs
|
|
393
|
+
/**
|
|
394
|
+
* @license lucide-react v1.24.0 - ISC
|
|
395
|
+
*
|
|
396
|
+
* This source code is licensed under the ISC license.
|
|
397
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
398
|
+
*/
|
|
399
|
+
var defaultAttributes = {
|
|
400
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
401
|
+
width: 24,
|
|
402
|
+
height: 24,
|
|
403
|
+
viewBox: "0 0 24 24",
|
|
404
|
+
fill: "none",
|
|
405
|
+
stroke: "currentColor",
|
|
406
|
+
strokeWidth: 2,
|
|
407
|
+
strokeLinecap: "round",
|
|
408
|
+
strokeLinejoin: "round"
|
|
409
|
+
};
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region ../../node_modules/lucide-react/dist/esm/shared/src/utils/hasA11yProp.mjs
|
|
412
|
+
/**
|
|
413
|
+
* @license lucide-react v1.24.0 - ISC
|
|
414
|
+
*
|
|
415
|
+
* This source code is licensed under the ISC license.
|
|
416
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
417
|
+
*/
|
|
418
|
+
var hasA11yProp = (props) => {
|
|
419
|
+
for (const prop in props) if (prop.startsWith("aria-") || prop === "role" || prop === "title") return true;
|
|
420
|
+
return false;
|
|
421
|
+
};
|
|
422
|
+
//#endregion
|
|
423
|
+
//#region ../../node_modules/lucide-react/dist/esm/context.mjs
|
|
424
|
+
/**
|
|
425
|
+
* @license lucide-react v1.24.0 - ISC
|
|
426
|
+
*
|
|
427
|
+
* This source code is licensed under the ISC license.
|
|
428
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
429
|
+
*/
|
|
430
|
+
var LucideContext = createContext({});
|
|
431
|
+
var useLucideContext = () => useContext(LucideContext);
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region ../../node_modules/lucide-react/dist/esm/Icon.mjs
|
|
434
|
+
/**
|
|
435
|
+
* @license lucide-react v1.24.0 - ISC
|
|
436
|
+
*
|
|
437
|
+
* This source code is licensed under the ISC license.
|
|
438
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
439
|
+
*/
|
|
440
|
+
var Icon$1 = forwardRef(({ color, size, strokeWidth, absoluteStrokeWidth, className = "", children, iconNode, ...rest }, ref) => {
|
|
441
|
+
const { size: contextSize = 24, strokeWidth: contextStrokeWidth = 2, absoluteStrokeWidth: contextAbsoluteStrokeWidth = false, color: contextColor = "currentColor", className: contextClass = "" } = useLucideContext() ?? {};
|
|
442
|
+
const calculatedStrokeWidth = absoluteStrokeWidth ?? contextAbsoluteStrokeWidth ? Number(strokeWidth ?? contextStrokeWidth) * 24 / Number(size ?? contextSize) : strokeWidth ?? contextStrokeWidth;
|
|
443
|
+
return createElement("svg", {
|
|
444
|
+
ref,
|
|
445
|
+
...defaultAttributes,
|
|
446
|
+
width: size ?? contextSize ?? defaultAttributes.width,
|
|
447
|
+
height: size ?? contextSize ?? defaultAttributes.height,
|
|
448
|
+
stroke: color ?? contextColor,
|
|
449
|
+
strokeWidth: calculatedStrokeWidth,
|
|
450
|
+
className: mergeClasses("lucide", contextClass, className),
|
|
451
|
+
...!children && !hasA11yProp(rest) && { "aria-hidden": "true" },
|
|
452
|
+
...rest
|
|
453
|
+
}, [...iconNode.map(([tag, attrs]) => createElement(tag, attrs)), ...Array.isArray(children) ? children : [children]]);
|
|
454
|
+
});
|
|
455
|
+
//#endregion
|
|
456
|
+
//#region ../../node_modules/lucide-react/dist/esm/createLucideIcon.mjs
|
|
457
|
+
/**
|
|
458
|
+
* @license lucide-react v1.24.0 - ISC
|
|
459
|
+
*
|
|
460
|
+
* This source code is licensed under the ISC license.
|
|
461
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
462
|
+
*/
|
|
463
|
+
var createLucideIcon = (iconName, iconNode) => {
|
|
464
|
+
const Component = forwardRef(({ className, ...props }, ref) => createElement(Icon$1, {
|
|
465
|
+
ref,
|
|
466
|
+
iconNode,
|
|
467
|
+
className: mergeClasses(`lucide-${toKebabCase(toPascalCase(iconName))}`, `lucide-${iconName}`, className),
|
|
468
|
+
...props
|
|
469
|
+
}));
|
|
470
|
+
Component.displayName = toPascalCase(iconName);
|
|
471
|
+
return Component;
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* @license lucide-react v1.24.0 - ISC
|
|
475
|
+
*
|
|
476
|
+
* This source code is licensed under the ISC license.
|
|
477
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
478
|
+
*/
|
|
479
|
+
var Check = createLucideIcon("check", [["path", {
|
|
480
|
+
d: "M20 6 9 17l-5-5",
|
|
481
|
+
key: "1gmf2c"
|
|
482
|
+
}]]);
|
|
483
|
+
/**
|
|
484
|
+
* @license lucide-react v1.24.0 - ISC
|
|
485
|
+
*
|
|
486
|
+
* This source code is licensed under the ISC license.
|
|
487
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
488
|
+
*/
|
|
489
|
+
var ChevronDown = createLucideIcon("chevron-down", [["path", {
|
|
490
|
+
d: "m6 9 6 6 6-6",
|
|
491
|
+
key: "qrunsl"
|
|
492
|
+
}]]);
|
|
493
|
+
/**
|
|
494
|
+
* @license lucide-react v1.24.0 - ISC
|
|
495
|
+
*
|
|
496
|
+
* This source code is licensed under the ISC license.
|
|
497
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
498
|
+
*/
|
|
499
|
+
var Clock = createLucideIcon("clock", [["circle", {
|
|
500
|
+
cx: "12",
|
|
501
|
+
cy: "12",
|
|
502
|
+
r: "10",
|
|
503
|
+
key: "1mglay"
|
|
504
|
+
}], ["path", {
|
|
505
|
+
d: "M12 6v6l4 2",
|
|
506
|
+
key: "mmk7yg"
|
|
507
|
+
}]]);
|
|
508
|
+
/**
|
|
509
|
+
* @license lucide-react v1.24.0 - ISC
|
|
510
|
+
*
|
|
511
|
+
* This source code is licensed under the ISC license.
|
|
512
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
513
|
+
*/
|
|
514
|
+
var FileText = createLucideIcon("file-text", [
|
|
515
|
+
["path", {
|
|
516
|
+
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",
|
|
517
|
+
key: "1oefj6"
|
|
518
|
+
}],
|
|
519
|
+
["path", {
|
|
520
|
+
d: "M14 2v5a1 1 0 0 0 1 1h5",
|
|
521
|
+
key: "wfsgrz"
|
|
522
|
+
}],
|
|
523
|
+
["path", {
|
|
524
|
+
d: "M10 9H8",
|
|
525
|
+
key: "b1mrlr"
|
|
526
|
+
}],
|
|
527
|
+
["path", {
|
|
528
|
+
d: "M16 13H8",
|
|
529
|
+
key: "t4e002"
|
|
530
|
+
}],
|
|
531
|
+
["path", {
|
|
532
|
+
d: "M16 17H8",
|
|
533
|
+
key: "z1uh3a"
|
|
534
|
+
}]
|
|
535
|
+
]);
|
|
536
|
+
/**
|
|
537
|
+
* @license lucide-react v1.24.0 - ISC
|
|
538
|
+
*
|
|
539
|
+
* This source code is licensed under the ISC license.
|
|
540
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
541
|
+
*/
|
|
542
|
+
var List = createLucideIcon("list", [
|
|
543
|
+
["path", {
|
|
544
|
+
d: "M3 5h.01",
|
|
545
|
+
key: "18ugdj"
|
|
546
|
+
}],
|
|
547
|
+
["path", {
|
|
548
|
+
d: "M3 12h.01",
|
|
549
|
+
key: "nlz23k"
|
|
550
|
+
}],
|
|
551
|
+
["path", {
|
|
552
|
+
d: "M3 19h.01",
|
|
553
|
+
key: "noohij"
|
|
554
|
+
}],
|
|
555
|
+
["path", {
|
|
556
|
+
d: "M8 5h13",
|
|
557
|
+
key: "1pao27"
|
|
558
|
+
}],
|
|
559
|
+
["path", {
|
|
560
|
+
d: "M8 12h13",
|
|
561
|
+
key: "1za7za"
|
|
562
|
+
}],
|
|
563
|
+
["path", {
|
|
564
|
+
d: "M8 19h13",
|
|
565
|
+
key: "m83p4d"
|
|
566
|
+
}]
|
|
567
|
+
]);
|
|
568
|
+
/**
|
|
569
|
+
* @license lucide-react v1.24.0 - ISC
|
|
570
|
+
*
|
|
571
|
+
* This source code is licensed under the ISC license.
|
|
572
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
573
|
+
*/
|
|
574
|
+
var LoaderCircle = createLucideIcon("loader-circle", [["path", {
|
|
575
|
+
d: "M21 12a9 9 0 1 1-6.219-8.56",
|
|
576
|
+
key: "13zald"
|
|
577
|
+
}]]);
|
|
578
|
+
/**
|
|
579
|
+
* @license lucide-react v1.24.0 - ISC
|
|
580
|
+
*
|
|
581
|
+
* This source code is licensed under the ISC license.
|
|
582
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
583
|
+
*/
|
|
584
|
+
var Maximize2 = createLucideIcon("maximize-2", [
|
|
585
|
+
["path", {
|
|
586
|
+
d: "M15 3h6v6",
|
|
587
|
+
key: "1q9fwt"
|
|
588
|
+
}],
|
|
589
|
+
["path", {
|
|
590
|
+
d: "m21 3-7 7",
|
|
591
|
+
key: "1l2asr"
|
|
592
|
+
}],
|
|
593
|
+
["path", {
|
|
594
|
+
d: "m3 21 7-7",
|
|
595
|
+
key: "tjx5ai"
|
|
596
|
+
}],
|
|
597
|
+
["path", {
|
|
598
|
+
d: "M9 21H3v-6",
|
|
599
|
+
key: "wtvkvv"
|
|
600
|
+
}]
|
|
601
|
+
]);
|
|
602
|
+
/**
|
|
603
|
+
* @license lucide-react v1.24.0 - ISC
|
|
604
|
+
*
|
|
605
|
+
* This source code is licensed under the ISC license.
|
|
606
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
607
|
+
*/
|
|
608
|
+
var MessageCircle = createLucideIcon("message-circle", [["path", {
|
|
609
|
+
d: "M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719",
|
|
610
|
+
key: "1sd12s"
|
|
611
|
+
}]]);
|
|
612
|
+
/**
|
|
613
|
+
* @license lucide-react v1.24.0 - ISC
|
|
614
|
+
*
|
|
615
|
+
* This source code is licensed under the ISC license.
|
|
616
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
617
|
+
*/
|
|
618
|
+
var Minimize2 = createLucideIcon("minimize-2", [
|
|
619
|
+
["path", {
|
|
620
|
+
d: "m14 10 7-7",
|
|
621
|
+
key: "oa77jy"
|
|
622
|
+
}],
|
|
623
|
+
["path", {
|
|
624
|
+
d: "M20 10h-6V4",
|
|
625
|
+
key: "mjg0md"
|
|
626
|
+
}],
|
|
627
|
+
["path", {
|
|
628
|
+
d: "m3 21 7-7",
|
|
629
|
+
key: "tjx5ai"
|
|
630
|
+
}],
|
|
631
|
+
["path", {
|
|
632
|
+
d: "M4 14h6v6",
|
|
633
|
+
key: "rmj7iw"
|
|
634
|
+
}]
|
|
635
|
+
]);
|
|
636
|
+
/**
|
|
637
|
+
* @license lucide-react v1.24.0 - ISC
|
|
638
|
+
*
|
|
639
|
+
* This source code is licensed under the ISC license.
|
|
640
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
641
|
+
*/
|
|
642
|
+
var Paperclip = createLucideIcon("paperclip", [["path", {
|
|
643
|
+
d: "m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551",
|
|
644
|
+
key: "1miecu"
|
|
645
|
+
}]]);
|
|
646
|
+
/**
|
|
647
|
+
* @license lucide-react v1.24.0 - ISC
|
|
648
|
+
*
|
|
649
|
+
* This source code is licensed under the ISC license.
|
|
650
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
651
|
+
*/
|
|
652
|
+
var Plus = createLucideIcon("plus", [["path", {
|
|
653
|
+
d: "M5 12h14",
|
|
654
|
+
key: "1ays0h"
|
|
655
|
+
}], ["path", {
|
|
656
|
+
d: "M12 5v14",
|
|
657
|
+
key: "s699le"
|
|
658
|
+
}]]);
|
|
659
|
+
/**
|
|
660
|
+
* @license lucide-react v1.24.0 - ISC
|
|
661
|
+
*
|
|
662
|
+
* This source code is licensed under the ISC license.
|
|
663
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
664
|
+
*/
|
|
665
|
+
var RotateCcw = createLucideIcon("rotate-ccw", [["path", {
|
|
666
|
+
d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",
|
|
667
|
+
key: "1357e3"
|
|
668
|
+
}], ["path", {
|
|
669
|
+
d: "M3 3v5h5",
|
|
670
|
+
key: "1xhq8a"
|
|
671
|
+
}]]);
|
|
672
|
+
/**
|
|
673
|
+
* @license lucide-react v1.24.0 - ISC
|
|
674
|
+
*
|
|
675
|
+
* This source code is licensed under the ISC license.
|
|
676
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
677
|
+
*/
|
|
678
|
+
var Send = createLucideIcon("send", [["path", {
|
|
679
|
+
d: "M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z",
|
|
680
|
+
key: "1ffxy3"
|
|
681
|
+
}], ["path", {
|
|
682
|
+
d: "m21.854 2.147-10.94 10.939",
|
|
683
|
+
key: "12cjpa"
|
|
684
|
+
}]]);
|
|
685
|
+
/**
|
|
686
|
+
* @license lucide-react v1.24.0 - ISC
|
|
687
|
+
*
|
|
688
|
+
* This source code is licensed under the ISC license.
|
|
689
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
690
|
+
*/
|
|
691
|
+
var Square = createLucideIcon("square", [["rect", {
|
|
692
|
+
width: "18",
|
|
693
|
+
height: "18",
|
|
694
|
+
x: "3",
|
|
695
|
+
y: "3",
|
|
696
|
+
rx: "2",
|
|
697
|
+
key: "afitv7"
|
|
698
|
+
}]]);
|
|
699
|
+
/**
|
|
700
|
+
* @license lucide-react v1.24.0 - ISC
|
|
701
|
+
*
|
|
702
|
+
* This source code is licensed under the ISC license.
|
|
703
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
704
|
+
*/
|
|
705
|
+
var Star = createLucideIcon("star", [["path", {
|
|
706
|
+
d: "M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z",
|
|
707
|
+
key: "r04s7s"
|
|
708
|
+
}]]);
|
|
709
|
+
/**
|
|
710
|
+
* @license lucide-react v1.24.0 - ISC
|
|
711
|
+
*
|
|
712
|
+
* This source code is licensed under the ISC license.
|
|
713
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
714
|
+
*/
|
|
715
|
+
var Upload = createLucideIcon("upload", [
|
|
716
|
+
["path", {
|
|
717
|
+
d: "M12 3v12",
|
|
718
|
+
key: "1x0j5s"
|
|
719
|
+
}],
|
|
720
|
+
["path", {
|
|
721
|
+
d: "m17 8-5-5-5 5",
|
|
722
|
+
key: "7q97r8"
|
|
723
|
+
}],
|
|
724
|
+
["path", {
|
|
725
|
+
d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",
|
|
726
|
+
key: "ih7n3h"
|
|
727
|
+
}]
|
|
728
|
+
]);
|
|
729
|
+
/**
|
|
730
|
+
* @license lucide-react v1.24.0 - ISC
|
|
731
|
+
*
|
|
732
|
+
* This source code is licensed under the ISC license.
|
|
733
|
+
* See the LICENSE file in the root directory of this source tree.
|
|
734
|
+
*/
|
|
735
|
+
var X = createLucideIcon("x", [["path", {
|
|
736
|
+
d: "M18 6 6 18",
|
|
737
|
+
key: "1bl5f8"
|
|
738
|
+
}], ["path", {
|
|
739
|
+
d: "m6 6 12 12",
|
|
740
|
+
key: "d8bk6v"
|
|
741
|
+
}]]);
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region src/ui/sse.ts
|
|
744
|
+
async function* readSse(body) {
|
|
745
|
+
const reader = body.getReader();
|
|
746
|
+
const decoder = new TextDecoder();
|
|
747
|
+
let buf = "";
|
|
748
|
+
for (;;) {
|
|
749
|
+
const { done, value } = await reader.read();
|
|
750
|
+
if (done) break;
|
|
751
|
+
buf += decoder.decode(value, { stream: true });
|
|
752
|
+
let idx;
|
|
753
|
+
while ((idx = buf.indexOf("\n\n")) !== -1) {
|
|
754
|
+
const frame = buf.slice(0, idx);
|
|
755
|
+
buf = buf.slice(idx + 2);
|
|
756
|
+
let event = "message";
|
|
757
|
+
const data = [];
|
|
758
|
+
for (const line of frame.split("\n")) {
|
|
759
|
+
if (!line || line.startsWith(":")) continue;
|
|
760
|
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
761
|
+
else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
|
|
762
|
+
}
|
|
763
|
+
if (data.length) yield {
|
|
764
|
+
event,
|
|
765
|
+
data: data.join("\n")
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
//#endregion
|
|
771
|
+
//#region src/ui/chat-api.ts
|
|
772
|
+
/** Web-chat transport. Session cookies are sent automatically (same-origin), so there is no
|
|
773
|
+
* separate authorization here; the gate is on the server. */
|
|
774
|
+
var BASE = "/api/plugins/webchat/user";
|
|
775
|
+
/** POST a member-level webchat action with a JSON body: the one request path every call
|
|
776
|
+
* in this file (except the SSE stream in `openChannel` and `stopTurn`'s empty-body POST)
|
|
777
|
+
* goes through. Throws `Error(action)` on any non-OK response — every caller below relies
|
|
778
|
+
* on that to build its own error message. */
|
|
779
|
+
async function postAction(action, body) {
|
|
780
|
+
const r = await fetch(`${BASE}/${action}`, {
|
|
781
|
+
method: "POST",
|
|
782
|
+
headers: { "content-type": "application/json" },
|
|
783
|
+
body: JSON.stringify(body)
|
|
784
|
+
});
|
|
785
|
+
if (!r.ok) throw new Error(action);
|
|
786
|
+
return await r.json();
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Throws on any rejection, including 404 (“plugin was disabled AFTER the widget bundle
|
|
790
|
+
* loaded”). There is deliberately no separate `null` for this case: the caller keeps
|
|
791
|
+
* the current thread list either way, so two return codes would mean the same thing.
|
|
792
|
+
*/
|
|
793
|
+
async function fetchThreads() {
|
|
794
|
+
return (await postAction("threads", {})).threads;
|
|
795
|
+
}
|
|
796
|
+
async function fetchHistory(convId) {
|
|
797
|
+
return postAction("history", { convId });
|
|
798
|
+
}
|
|
799
|
+
async function fetchAgents(convId) {
|
|
800
|
+
const out = await postAction("agents", { convId });
|
|
801
|
+
return {
|
|
802
|
+
agents: out.agents ?? [],
|
|
803
|
+
defaultAgentId: out.defaultAgentId ?? null,
|
|
804
|
+
selection: out.selection ?? {
|
|
805
|
+
agentId: null,
|
|
806
|
+
presetId: null
|
|
807
|
+
},
|
|
808
|
+
starters: out.starters ?? []
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
async function selectAgent(convId, patch) {
|
|
812
|
+
return (await postAction("selectAgent", {
|
|
813
|
+
convId,
|
|
814
|
+
...patch
|
|
815
|
+
})).selection;
|
|
816
|
+
}
|
|
817
|
+
/** Mark a conversation private, or reopen it. Only ever meaningful for the
|
|
818
|
+
* caller's OWN conversation — the server refuses (returns the unchanged
|
|
819
|
+
* visibility) for anything else, so the UI only offers this control on the
|
|
820
|
+
* viewer's own rows in the first place. */
|
|
821
|
+
async function setThreadVisibility(convId, makePrivate) {
|
|
822
|
+
return postAction("setVisibility", {
|
|
823
|
+
convId,
|
|
824
|
+
private: makePrivate
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
/** Records the user turn and starts the agent turn running — an ordinary POST that
|
|
828
|
+
* resolves as soon as the turn has STARTED, not once it finishes (`sendAction`,
|
|
829
|
+
* `../runtime/send.ts`). It streams nothing any more: every live update — the
|
|
830
|
+
* answer as it is written, its completion, an error — arrives on the
|
|
831
|
+
* conversation's own channel (`openChannel`, below), which `use-chat.ts` keeps
|
|
832
|
+
* open for as long as this conversation is on screen, whether or not THIS call
|
|
833
|
+
* is what started the turn currently running. */
|
|
834
|
+
async function sendMessage(req) {
|
|
835
|
+
return postAction("send", {
|
|
836
|
+
convId: req.convId,
|
|
837
|
+
text: req.text,
|
|
838
|
+
replyTo: req.replyTo,
|
|
839
|
+
context: req.context,
|
|
840
|
+
...req.attachments?.length ? { attachments: req.attachments } : {}
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
/** Abort the turn currently running on `convId`, if there is one (`stopAction`,
|
|
844
|
+
* `../runtime/actions.ts`). That action returns `Promise<void>` — the route sends
|
|
845
|
+
* an empty 200 body, the same uniform non-answer whether a live turn was
|
|
846
|
+
* actually aborted, none was running, or the caller may not write to the
|
|
847
|
+
* conversation — so this deliberately does NOT go through `postAction`, whose
|
|
848
|
+
* `.json()` would throw on that empty body. */
|
|
849
|
+
async function stopTurn(convId) {
|
|
850
|
+
if (!(await fetch(`${BASE}/stop`, {
|
|
851
|
+
method: "POST",
|
|
852
|
+
headers: { "content-type": "application/json" },
|
|
853
|
+
body: JSON.stringify({ convId })
|
|
854
|
+
})).ok) throw new Error("stop");
|
|
855
|
+
}
|
|
856
|
+
/** Subscribe to `convId`'s channel and hold the connection open, delivering every
|
|
857
|
+
* frame to `handlers` until the server ends the stream (the conversation went
|
|
858
|
+
* private on this viewer, `setVisibility`'s eviction; a restart; a network drop)
|
|
859
|
+
* or `signal` aborts it. Resolves (does not reject) when the server ends the
|
|
860
|
+
* stream cleanly, same as a plain fetch whose body simply runs out — rejects only
|
|
861
|
+
* on a genuine transport failure or `signal` aborting. Either way it is
|
|
862
|
+
* `use-chat.ts`'s job to reconnect with backoff while the conversation is still
|
|
863
|
+
* the one on screen: there is no `Last-Event-ID` bookkeeping and no server-side
|
|
864
|
+
* buffer here, by design (see the design doc's "Reconnect, and the race that
|
|
865
|
+
* comes with it") — missed frames are recovered by re-reading history, never by
|
|
866
|
+
* replaying the stream. */
|
|
867
|
+
async function openChannel(convId, handlers, signal) {
|
|
868
|
+
const r = await fetch(`${BASE}/channel/stream`, {
|
|
869
|
+
method: "POST",
|
|
870
|
+
headers: { "content-type": "application/json" },
|
|
871
|
+
body: JSON.stringify({ convId }),
|
|
872
|
+
signal
|
|
873
|
+
});
|
|
874
|
+
if (!r.ok || !r.body) throw new Error(`http ${r.status}`);
|
|
875
|
+
for await (const frame of readSse(r.body)) try {
|
|
876
|
+
const data = JSON.parse(frame.data);
|
|
877
|
+
if (frame.event === "user") handlers.onUser({
|
|
878
|
+
msgId: String(data["msgId"] ?? ""),
|
|
879
|
+
text: String(data["text"] ?? ""),
|
|
880
|
+
ts: typeof data["ts"] === "number" ? data["ts"] : 0,
|
|
881
|
+
...Array.isArray(data["attachments"]) ? { attachments: data["attachments"] } : {}
|
|
882
|
+
});
|
|
883
|
+
else if (frame.event === "message") handlers.onMessage({
|
|
884
|
+
msgId: String(data["msgId"] ?? ""),
|
|
885
|
+
text: String(data["text"] ?? "")
|
|
886
|
+
});
|
|
887
|
+
else if (frame.event === "delta") handlers.onDelta({
|
|
888
|
+
msgId: String(data["msgId"] ?? ""),
|
|
889
|
+
text: String(data["text"] ?? "")
|
|
890
|
+
});
|
|
891
|
+
else if (frame.event === "reasoning") handlers.onReasoning({
|
|
892
|
+
msgId: String(data["msgId"] ?? ""),
|
|
893
|
+
text: String(data["text"] ?? "")
|
|
894
|
+
});
|
|
895
|
+
else if (frame.event === "done") handlers.onDone({
|
|
896
|
+
msgId: data["msgId"] ?? null,
|
|
897
|
+
parentMsgId: String(data["parentMsgId"] ?? ""),
|
|
898
|
+
suggestions: Array.isArray(data["suggestions"]) ? data["suggestions"] : null
|
|
899
|
+
});
|
|
900
|
+
else if (frame.event === "error") handlers.onError({
|
|
901
|
+
message: data["message"] ?? null,
|
|
902
|
+
parentMsgId: typeof data["parentMsgId"] === "string" ? data["parentMsgId"] : null
|
|
903
|
+
});
|
|
904
|
+
} catch (e) {
|
|
905
|
+
handlers.onError({
|
|
906
|
+
message: `malformed frame: ${String(e)}`,
|
|
907
|
+
parentMsgId: null
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
//#endregion
|
|
912
|
+
//#region src/ui/use-chat.ts
|
|
913
|
+
function newId() {
|
|
914
|
+
return crypto.randomUUID();
|
|
915
|
+
}
|
|
916
|
+
/** The message id a frame reports as its OUTCOME, for dedup against history on
|
|
917
|
+
* flush. `error` is never persisted (a failed turn stores nothing), so it is
|
|
918
|
+
* never a duplicate of anything a history read could carry — its
|
|
919
|
+
* `parentMsgId` exists only to scope the send-lock clear below to the
|
|
920
|
+
* viewer's own turn, a different question from dedup. */
|
|
921
|
+
function frameHistoryId(frame) {
|
|
922
|
+
switch (frame.kind) {
|
|
923
|
+
case "user":
|
|
924
|
+
case "message":
|
|
925
|
+
case "delta":
|
|
926
|
+
case "reasoning": return frame.data.msgId;
|
|
927
|
+
case "done": return frame.data.msgId;
|
|
928
|
+
case "error": return null;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
var RECONNECT_BASE_MS = 1e3;
|
|
932
|
+
var RECONNECT_MAX_MS = 3e4;
|
|
933
|
+
function useChat() {
|
|
934
|
+
const [threads, setThreads] = useState([]);
|
|
935
|
+
const [convId, setConvId] = useState(null);
|
|
936
|
+
const [messages, setMessages] = useState([]);
|
|
937
|
+
const [pending, setPending] = useState(null);
|
|
938
|
+
const [pendingReasoning, setPendingReasoning] = useState(null);
|
|
939
|
+
const [error, setError] = useState(null);
|
|
940
|
+
const [agents, setAgents] = useState([]);
|
|
941
|
+
const [defaultAgentId, setDefaultAgentId] = useState(null);
|
|
942
|
+
const [selection, setSelection] = useState({
|
|
943
|
+
agentId: null,
|
|
944
|
+
presetId: null
|
|
945
|
+
});
|
|
946
|
+
const [starters, setStarters] = useState([]);
|
|
947
|
+
const [suggestions, setSuggestions] = useState(null);
|
|
948
|
+
const headRef = useRef(null);
|
|
949
|
+
const sendingRef = useRef(false);
|
|
950
|
+
const myTurnMsgIdRef = useRef(null);
|
|
951
|
+
const pendingTextRef = useRef("");
|
|
952
|
+
const pendingReasoningRef = useRef(null);
|
|
953
|
+
const turnRef = useRef(0);
|
|
954
|
+
const aliveRef = useRef(true);
|
|
955
|
+
useEffect(() => {
|
|
956
|
+
aliveRef.current = true;
|
|
957
|
+
return () => {
|
|
958
|
+
aliveRef.current = false;
|
|
959
|
+
turnRef.current++;
|
|
960
|
+
};
|
|
961
|
+
}, []);
|
|
962
|
+
const loadThreads = useCallback(() => {
|
|
963
|
+
fetchThreads().then((list) => {
|
|
964
|
+
if (aliveRef.current) setThreads(list);
|
|
965
|
+
}).catch(() => {});
|
|
966
|
+
}, []);
|
|
967
|
+
useEffect(() => {
|
|
968
|
+
if (!convId) return;
|
|
969
|
+
let cancelled = false;
|
|
970
|
+
fetchAgents(convId).then((r) => {
|
|
971
|
+
if (cancelled) return;
|
|
972
|
+
setAgents(r.agents);
|
|
973
|
+
setDefaultAgentId(r.defaultAgentId);
|
|
974
|
+
setSelection(r.selection);
|
|
975
|
+
setStarters(r.starters);
|
|
976
|
+
}).catch(() => {});
|
|
977
|
+
return () => {
|
|
978
|
+
cancelled = true;
|
|
979
|
+
};
|
|
980
|
+
}, [convId]);
|
|
981
|
+
/** Apply one channel frame to state. Called either live (as it arrives, once a
|
|
982
|
+
* connection has finished its history flush) or from the buffer a fresh
|
|
983
|
+
* (re)connect built while history was in flight — `historyIds` is non-null
|
|
984
|
+
* ONLY for the latter, and gates every frame on it: a frame whose outcome the
|
|
985
|
+
* just-fetched history already carries is dropped rather than reapplied, the
|
|
986
|
+
* one piece of ordering the design doc calls out by name. Live frames never
|
|
987
|
+
* need this check — their message id cannot already be in a history snapshot
|
|
988
|
+
* read before they existed. */
|
|
989
|
+
const applyFrame = useCallback((frame, historyIds) => {
|
|
990
|
+
const outcomeId = frameHistoryId(frame);
|
|
991
|
+
const isDuplicate = historyIds !== null && outcomeId !== null && historyIds.has(outcomeId);
|
|
992
|
+
switch (frame.kind) {
|
|
993
|
+
case "user": {
|
|
994
|
+
if (isDuplicate) break;
|
|
995
|
+
const { msgId, text, ts, attachments } = frame.data;
|
|
996
|
+
setMessages((prev) => prev.some((m) => m.msgId === msgId) ? prev : [...prev, {
|
|
997
|
+
msgId,
|
|
998
|
+
role: "user",
|
|
999
|
+
text,
|
|
1000
|
+
ts,
|
|
1001
|
+
...attachments?.length ? { attachments } : {}
|
|
1002
|
+
}]);
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
case "message":
|
|
1006
|
+
case "delta":
|
|
1007
|
+
if (isDuplicate) break;
|
|
1008
|
+
pendingTextRef.current = frame.data.text;
|
|
1009
|
+
setPending(frame.data.text);
|
|
1010
|
+
break;
|
|
1011
|
+
case "reasoning":
|
|
1012
|
+
if (isDuplicate) break;
|
|
1013
|
+
pendingReasoningRef.current = frame.data.text;
|
|
1014
|
+
setPendingReasoning(frame.data.text);
|
|
1015
|
+
break;
|
|
1016
|
+
case "done": {
|
|
1017
|
+
const { msgId, parentMsgId, suggestions: nextSuggestions } = frame.data;
|
|
1018
|
+
headRef.current = msgId ?? parentMsgId;
|
|
1019
|
+
setSuggestions(nextSuggestions ?? null);
|
|
1020
|
+
if (!isDuplicate && msgId && pendingTextRef.current) {
|
|
1021
|
+
const text = pendingTextRef.current;
|
|
1022
|
+
const reasoning = pendingReasoningRef.current;
|
|
1023
|
+
setMessages((prev) => prev.some((m) => m.msgId === msgId) ? prev : [...prev, {
|
|
1024
|
+
msgId,
|
|
1025
|
+
role: "assistant",
|
|
1026
|
+
text,
|
|
1027
|
+
ts: Math.floor(Date.now() / 1e3),
|
|
1028
|
+
...reasoning ? { reasoning } : {}
|
|
1029
|
+
}]);
|
|
1030
|
+
}
|
|
1031
|
+
pendingTextRef.current = "";
|
|
1032
|
+
pendingReasoningRef.current = null;
|
|
1033
|
+
setPending(null);
|
|
1034
|
+
setPendingReasoning(null);
|
|
1035
|
+
if (myTurnMsgIdRef.current !== null && myTurnMsgIdRef.current === parentMsgId) {
|
|
1036
|
+
sendingRef.current = false;
|
|
1037
|
+
myTurnMsgIdRef.current = null;
|
|
1038
|
+
}
|
|
1039
|
+
loadThreads();
|
|
1040
|
+
break;
|
|
1041
|
+
}
|
|
1042
|
+
case "error":
|
|
1043
|
+
setError(frame.data.message ?? "error");
|
|
1044
|
+
pendingTextRef.current = "";
|
|
1045
|
+
pendingReasoningRef.current = null;
|
|
1046
|
+
setPending(null);
|
|
1047
|
+
setPendingReasoning(null);
|
|
1048
|
+
if (myTurnMsgIdRef.current !== null && myTurnMsgIdRef.current === frame.data.parentMsgId) {
|
|
1049
|
+
sendingRef.current = false;
|
|
1050
|
+
myTurnMsgIdRef.current = null;
|
|
1051
|
+
}
|
|
1052
|
+
break;
|
|
1053
|
+
}
|
|
1054
|
+
}, [loadThreads]);
|
|
1055
|
+
useEffect(() => {
|
|
1056
|
+
if (!convId) return;
|
|
1057
|
+
const chatConvId = convId;
|
|
1058
|
+
let cancelled = false;
|
|
1059
|
+
let reconnectTimer = null;
|
|
1060
|
+
let attempt = 0;
|
|
1061
|
+
const ctl = new AbortController();
|
|
1062
|
+
async function connectOnce() {
|
|
1063
|
+
if (cancelled) return;
|
|
1064
|
+
let buffering = true;
|
|
1065
|
+
let receivedFrame = false;
|
|
1066
|
+
const buffer = [];
|
|
1067
|
+
const dispatch = (frame) => {
|
|
1068
|
+
if (cancelled) return;
|
|
1069
|
+
receivedFrame = true;
|
|
1070
|
+
if (buffering) buffer.push(frame);
|
|
1071
|
+
else applyFrame(frame, null);
|
|
1072
|
+
};
|
|
1073
|
+
const settled = openChannel(chatConvId, {
|
|
1074
|
+
onUser: (data) => dispatch({
|
|
1075
|
+
kind: "user",
|
|
1076
|
+
data
|
|
1077
|
+
}),
|
|
1078
|
+
onMessage: (data) => dispatch({
|
|
1079
|
+
kind: "message",
|
|
1080
|
+
data
|
|
1081
|
+
}),
|
|
1082
|
+
onDelta: (data) => dispatch({
|
|
1083
|
+
kind: "delta",
|
|
1084
|
+
data
|
|
1085
|
+
}),
|
|
1086
|
+
onReasoning: (data) => dispatch({
|
|
1087
|
+
kind: "reasoning",
|
|
1088
|
+
data
|
|
1089
|
+
}),
|
|
1090
|
+
onDone: (data) => dispatch({
|
|
1091
|
+
kind: "done",
|
|
1092
|
+
data
|
|
1093
|
+
}),
|
|
1094
|
+
onError: (data) => dispatch({
|
|
1095
|
+
kind: "error",
|
|
1096
|
+
data
|
|
1097
|
+
})
|
|
1098
|
+
}, ctl.signal).then(() => true, () => false);
|
|
1099
|
+
try {
|
|
1100
|
+
const h = await fetchHistory(chatConvId);
|
|
1101
|
+
if (cancelled) return;
|
|
1102
|
+
headRef.current = h.headMsgId;
|
|
1103
|
+
const historyIds = new Set(h.messages.map((m) => m.msgId));
|
|
1104
|
+
setMessages((prev) => {
|
|
1105
|
+
const ownPendingId = myTurnMsgIdRef.current;
|
|
1106
|
+
const stillUnconfirmed = prev.filter((m) => !historyIds.has(m.msgId) && (m.msgId.startsWith("local-") || m.msgId === ownPendingId));
|
|
1107
|
+
return stillUnconfirmed.length ? [...h.messages, ...stillUnconfirmed] : h.messages;
|
|
1108
|
+
});
|
|
1109
|
+
setSuggestions(h.messages.at(-1)?.suggestions ?? null);
|
|
1110
|
+
buffering = false;
|
|
1111
|
+
for (const frame of buffer) applyFrame(frame, historyIds);
|
|
1112
|
+
} catch (e) {
|
|
1113
|
+
if (cancelled) return;
|
|
1114
|
+
setError(e.message);
|
|
1115
|
+
buffering = false;
|
|
1116
|
+
for (const frame of buffer) applyFrame(frame, null);
|
|
1117
|
+
}
|
|
1118
|
+
await settled;
|
|
1119
|
+
if (cancelled) return;
|
|
1120
|
+
if (receivedFrame) attempt = 0;
|
|
1121
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS);
|
|
1122
|
+
attempt++;
|
|
1123
|
+
reconnectTimer = setTimeout(() => void connectOnce(), delay);
|
|
1124
|
+
}
|
|
1125
|
+
connectOnce();
|
|
1126
|
+
return () => {
|
|
1127
|
+
cancelled = true;
|
|
1128
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
1129
|
+
ctl.abort();
|
|
1130
|
+
};
|
|
1131
|
+
}, [convId, applyFrame]);
|
|
1132
|
+
const chooseAgent = useCallback((next) => {
|
|
1133
|
+
const previous = selection;
|
|
1134
|
+
setSelection(next);
|
|
1135
|
+
if (convId) selectAgent(convId, next).then(setSelection).catch((err) => {
|
|
1136
|
+
setSelection(previous);
|
|
1137
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
1138
|
+
});
|
|
1139
|
+
}, [convId, selection]);
|
|
1140
|
+
const togglePrivate = useCallback((convId, next) => {
|
|
1141
|
+
const previous = threads.find((t) => t.convId === convId)?.private ?? false;
|
|
1142
|
+
setThreads((prev) => prev.map((t) => t.convId === convId ? {
|
|
1143
|
+
...t,
|
|
1144
|
+
private: next
|
|
1145
|
+
} : t));
|
|
1146
|
+
setThreadVisibility(convId, next).then((r) => {
|
|
1147
|
+
setThreads((prev) => prev.map((t) => t.convId === convId ? {
|
|
1148
|
+
...t,
|
|
1149
|
+
private: r.visibility === "private"
|
|
1150
|
+
} : t));
|
|
1151
|
+
}).catch(() => {
|
|
1152
|
+
setThreads((prev) => prev.map((t) => t.convId === convId ? {
|
|
1153
|
+
...t,
|
|
1154
|
+
private: previous
|
|
1155
|
+
} : t));
|
|
1156
|
+
});
|
|
1157
|
+
}, [threads]);
|
|
1158
|
+
const attachmentsAllowed = useMemo(() => {
|
|
1159
|
+
const media = agents.find((a) => a.id === selection.agentId)?.media;
|
|
1160
|
+
if (!media) return true;
|
|
1161
|
+
return media.image.accepts.length > 0 || media.document.accepts.length > 0;
|
|
1162
|
+
}, [agents, selection]);
|
|
1163
|
+
const newConversation = useCallback(() => {
|
|
1164
|
+
turnRef.current++;
|
|
1165
|
+
sendingRef.current = false;
|
|
1166
|
+
myTurnMsgIdRef.current = null;
|
|
1167
|
+
headRef.current = null;
|
|
1168
|
+
setConvId(newId());
|
|
1169
|
+
setMessages([]);
|
|
1170
|
+
setPending(null);
|
|
1171
|
+
setPendingReasoning(null);
|
|
1172
|
+
pendingTextRef.current = "";
|
|
1173
|
+
pendingReasoningRef.current = null;
|
|
1174
|
+
setError(null);
|
|
1175
|
+
setSuggestions(null);
|
|
1176
|
+
}, []);
|
|
1177
|
+
const open = useCallback(async (id) => {
|
|
1178
|
+
turnRef.current++;
|
|
1179
|
+
sendingRef.current = false;
|
|
1180
|
+
myTurnMsgIdRef.current = null;
|
|
1181
|
+
setConvId(id);
|
|
1182
|
+
headRef.current = null;
|
|
1183
|
+
setMessages([]);
|
|
1184
|
+
setPending(null);
|
|
1185
|
+
setPendingReasoning(null);
|
|
1186
|
+
pendingTextRef.current = "";
|
|
1187
|
+
pendingReasoningRef.current = null;
|
|
1188
|
+
setError(null);
|
|
1189
|
+
setSuggestions(null);
|
|
1190
|
+
}, []);
|
|
1191
|
+
const stop = useCallback(() => {
|
|
1192
|
+
if (!convId) return;
|
|
1193
|
+
stopTurn(convId).catch(() => {});
|
|
1194
|
+
}, [convId]);
|
|
1195
|
+
return {
|
|
1196
|
+
threads,
|
|
1197
|
+
convId,
|
|
1198
|
+
messages,
|
|
1199
|
+
pending,
|
|
1200
|
+
pendingReasoning,
|
|
1201
|
+
error,
|
|
1202
|
+
agents,
|
|
1203
|
+
defaultAgentId,
|
|
1204
|
+
selection,
|
|
1205
|
+
starters,
|
|
1206
|
+
suggestions,
|
|
1207
|
+
attachmentsAllowed,
|
|
1208
|
+
chooseAgent,
|
|
1209
|
+
togglePrivate,
|
|
1210
|
+
send: useCallback((text, context, attachments = []) => {
|
|
1211
|
+
if (!text.trim() || sendingRef.current) return false;
|
|
1212
|
+
sendingRef.current = true;
|
|
1213
|
+
const myTurn = turnRef.current;
|
|
1214
|
+
const id = convId ?? newId();
|
|
1215
|
+
if (convId === null) setConvId(id);
|
|
1216
|
+
setError(null);
|
|
1217
|
+
setSuggestions(null);
|
|
1218
|
+
const localMsgId = `local-${newId()}`;
|
|
1219
|
+
setMessages((prev) => [...prev, {
|
|
1220
|
+
msgId: localMsgId,
|
|
1221
|
+
role: "user",
|
|
1222
|
+
text,
|
|
1223
|
+
ts: Math.floor(Date.now() / 1e3),
|
|
1224
|
+
...attachments.length ? { attachments } : {}
|
|
1225
|
+
}]);
|
|
1226
|
+
setPending("");
|
|
1227
|
+
(async () => {
|
|
1228
|
+
try {
|
|
1229
|
+
const { msgId } = await sendMessage({
|
|
1230
|
+
convId: id,
|
|
1231
|
+
text,
|
|
1232
|
+
replyTo: headRef.current,
|
|
1233
|
+
context,
|
|
1234
|
+
attachments
|
|
1235
|
+
});
|
|
1236
|
+
if (turnRef.current !== myTurn) return;
|
|
1237
|
+
if (msgId === null) {
|
|
1238
|
+
sendingRef.current = false;
|
|
1239
|
+
setPending(null);
|
|
1240
|
+
setPendingReasoning(null);
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
setMessages((prev) => prev.some((m) => m.msgId === msgId) ? prev.filter((m) => m.msgId !== localMsgId) : prev.map((m) => m.msgId === localMsgId ? {
|
|
1244
|
+
...m,
|
|
1245
|
+
msgId
|
|
1246
|
+
} : m));
|
|
1247
|
+
myTurnMsgIdRef.current = msgId;
|
|
1248
|
+
} catch (e) {
|
|
1249
|
+
if (turnRef.current === myTurn) {
|
|
1250
|
+
sendingRef.current = false;
|
|
1251
|
+
setError(e.message);
|
|
1252
|
+
setPending(null);
|
|
1253
|
+
setPendingReasoning(null);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
})();
|
|
1257
|
+
return true;
|
|
1258
|
+
}, [convId]),
|
|
1259
|
+
open,
|
|
1260
|
+
newConversation,
|
|
1261
|
+
loadThreads,
|
|
1262
|
+
stop
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
//#endregion
|
|
1266
|
+
//#region src/ui/use-page-context.ts
|
|
1267
|
+
/** The record page's own heading — a BEST-EFFORT read of the app's own rendered DOM,
|
|
1268
|
+
* not a data path. `RecordContent` (`packages/web/display/index.tsx`) marks the
|
|
1269
|
+
* TITLE element itself `role="heading" aria-level="1"` (the role sits on the title
|
|
1270
|
+
* element alone, never a wrapper — a wrapping row's role would pull every sibling
|
|
1271
|
+
* body field's text into the name, and the title is now one field among many in the
|
|
1272
|
+
* body); today that is the only element in the whole app carrying it,
|
|
1273
|
+
* so this selector cannot pick up some OTHER record's heading by mistake. It exists at
|
|
1274
|
+
* all because the chat overlay is a sibling of the routed page (mounted in the app
|
|
1275
|
+
* shell's own slot), never a descendant of it, so there is no React tree — no prop, no
|
|
1276
|
+
* context — to read the value from, and this package (`plugin-webchat`) has no data
|
|
1277
|
+
* path of its own to the open record either. Reading the painted DOM instead of
|
|
1278
|
+
* fetching the record: the SPA already has it open and displaying this text, and a
|
|
1279
|
+
* server-side lookup would be a query per turn for a value the browser is holding.
|
|
1280
|
+
*
|
|
1281
|
+
* WHEN it is read is the whole safety argument, so it is stated rather than assumed:
|
|
1282
|
+
* reading the DOM during React's RENDER phase sees the PREVIOUSLY COMMITTED tree, so a
|
|
1283
|
+
* navigation from one record to another produced the new path and id beside the OLD
|
|
1284
|
+
* record's name — a fact stated WRONG, which the base prompt tells the model to trust.
|
|
1285
|
+
* Hence `usePageContext` returns a READER the composer calls in its send handler: an
|
|
1286
|
+
* event handler runs after commit, so the heading and the path it is reported with
|
|
1287
|
+
* come from the same painted page. That is also what the design asks for — the page
|
|
1288
|
+
* the user was on AT THE MOMENT OF SENDING, and nowhere else.
|
|
1289
|
+
*
|
|
1290
|
+
* What is guaranteed, then: the title is read from the DOM as it stands when the turn
|
|
1291
|
+
* is sent. What is NOT guaranteed is that the title is present — a heading not yet
|
|
1292
|
+
* painted (a record still loading) and an untitled record's Dash placeholder both
|
|
1293
|
+
* yield no title, and an absent fact is the harmless case. One case yields a WRONG
|
|
1294
|
+
* one: if the app ever grows a SECOND element with this exact role/level (a preview
|
|
1295
|
+
* dialog, a relation picker, a plugin's own record renderer), `querySelector` returns
|
|
1296
|
+
* whichever comes first in document order, not necessarily the record the user is
|
|
1297
|
+
* actually looking at. Whoever eventually gives the overlay a real data path to the
|
|
1298
|
+
* open record should read this as the reason it was ever DOM-based, not rediscover it. */
|
|
1299
|
+
var EMPTY_TITLE = "—";
|
|
1300
|
+
function recordHeading() {
|
|
1301
|
+
if (typeof document === "undefined") return void 0;
|
|
1302
|
+
const text = document.querySelector("[role=\"heading\"][aria-level=\"1\"]")?.textContent?.trim();
|
|
1303
|
+
return text && text !== EMPTY_TITLE ? text : void 0;
|
|
1304
|
+
}
|
|
1305
|
+
/** SPA routes: `/:library/:type[/:id]`; system branches (settings, browse) are
|
|
1306
|
+
* returned by path only. Pure but for the heading read, which is why it takes the
|
|
1307
|
+
* path rather than calling the router itself. */
|
|
1308
|
+
function pageContextAt(pathname) {
|
|
1309
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
1310
|
+
const system = /* @__PURE__ */ new Set([
|
|
1311
|
+
"settings",
|
|
1312
|
+
"browse",
|
|
1313
|
+
"plugins",
|
|
1314
|
+
"login",
|
|
1315
|
+
"setup"
|
|
1316
|
+
]);
|
|
1317
|
+
if (parts.length < 2 || system.has(parts[0])) return { path: pathname };
|
|
1318
|
+
const [library, type, id] = parts;
|
|
1319
|
+
const isRecord = Boolean(id && id !== "new");
|
|
1320
|
+
const title = isRecord ? recordHeading() : void 0;
|
|
1321
|
+
return {
|
|
1322
|
+
path: pathname,
|
|
1323
|
+
library,
|
|
1324
|
+
type,
|
|
1325
|
+
...isRecord ? { id } : {},
|
|
1326
|
+
...title ? { title } : {}
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
/** Current-page context for the agent, as a reader called AT SEND TIME rather than a
|
|
1330
|
+
* value computed while rendering — see the heading comment above for why that
|
|
1331
|
+
* distinction is the difference between an absent title and a wrong one. The path
|
|
1332
|
+
* comes from the render that is currently committed, which is the same tree the
|
|
1333
|
+
* reader's heading lookup walks. */
|
|
1334
|
+
function usePageContext() {
|
|
1335
|
+
const { pathname } = useLocation();
|
|
1336
|
+
return useCallback(() => pageContextAt(pathname), [pathname]);
|
|
1337
|
+
}
|
|
1338
|
+
//#endregion
|
|
1339
|
+
//#region src/ui/chat-nav.tsx
|
|
1340
|
+
/** What to do with the widget when the user follows a link from a response.
|
|
1341
|
+
* ChatWidget supplies the callback (it owns mode/open), avoiding a prop
|
|
1342
|
+
* threaded through ChatMessages and Bubble. */
|
|
1343
|
+
var ChatNavContext = createContext(() => {});
|
|
1344
|
+
function ChatNavProvider({ onNavigate, children }) {
|
|
1345
|
+
return /* @__PURE__ */ jsx(ChatNavContext.Provider, {
|
|
1346
|
+
value: onNavigate,
|
|
1347
|
+
children
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
function useChatNav() {
|
|
1351
|
+
return useContext(ChatNavContext);
|
|
1352
|
+
}
|
|
1353
|
+
//#endregion
|
|
1354
|
+
//#region src/ui/chat-markdown.tsx
|
|
1355
|
+
/** Render markdown received FROM THE MODEL, an untrusted source.
|
|
1356
|
+
*
|
|
1357
|
+
* One renderer serves every markdown surface now — the record page and this bubble alike —
|
|
1358
|
+
* so this component is a density layer over the kit's `MarkdownView` and nothing more. What
|
|
1359
|
+
* it used to own itself now lives there, for both surfaces:
|
|
1360
|
+
*
|
|
1361
|
+
* - raw HTML never becomes markup. The vocabulary renderer has NO raw-HTML pass, so a tag
|
|
1362
|
+
* the model wrote is escaped before anything sees it; the barrier react-markdown gave
|
|
1363
|
+
* structurally is still structural, just one layer earlier. DOMPurify is defence in depth.
|
|
1364
|
+
* - an internal link goes through the router, so the chat stays mounted, and `onNavigate`
|
|
1365
|
+
* closes the panel on the way out.
|
|
1366
|
+
* - an address that leads nowhere (`javascript:`, a fabricated shelf) keeps its text and
|
|
1367
|
+
* loses its anchor — this bubble's old rule, now everyone's.
|
|
1368
|
+
* - a table gets its scroll wrapper from the web tag map itself.
|
|
1369
|
+
*/
|
|
1370
|
+
function ChatMarkdown({ text, className }) {
|
|
1371
|
+
const onNavigate = useChatNav();
|
|
1372
|
+
return /* @__PURE__ */ jsx(MarkdownView, {
|
|
1373
|
+
value: text,
|
|
1374
|
+
className: className ?? "md-static md-chat",
|
|
1375
|
+
onNavigate
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
//#endregion
|
|
1379
|
+
//#region src/ui/ChatMessages.tsx
|
|
1380
|
+
function ChatMessages({ messages, pending, pendingReasoning, trailing }) {
|
|
1381
|
+
const { t } = useTranslation();
|
|
1382
|
+
const listRef = useRef(null);
|
|
1383
|
+
useEffect(() => {
|
|
1384
|
+
const el = listRef.current;
|
|
1385
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
1386
|
+
}, [messages.length, pending]);
|
|
1387
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1388
|
+
ref: listRef,
|
|
1389
|
+
"data-chat-list": true,
|
|
1390
|
+
className: "flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-2",
|
|
1391
|
+
children: [!messages.length && pending === null && pendingReasoning === null ? /* @__PURE__ */ jsx("div", {
|
|
1392
|
+
className: "flex-1 grid place-items-center text-muted-foreground text-sm",
|
|
1393
|
+
children: t("webchat.empty")
|
|
1394
|
+
}) : /* @__PURE__ */ jsxs(Fragment, { children: [messages.map((m) => /* @__PURE__ */ jsx(Bubble, {
|
|
1395
|
+
role: m.role,
|
|
1396
|
+
text: m.text,
|
|
1397
|
+
reasoning: m.reasoning,
|
|
1398
|
+
attachments: m.attachments
|
|
1399
|
+
}, m.msgId)), pending !== null && /* @__PURE__ */ jsx(Bubble, {
|
|
1400
|
+
role: "assistant",
|
|
1401
|
+
text: pending || t("webchat.thinking"),
|
|
1402
|
+
reasoning: pendingReasoning
|
|
1403
|
+
})] }), trailing]
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* memo: during streaming, the parent re-renders on EVERY delta (several times per
|
|
1408
|
+
* second). Without memo, each tick would re-render (and therefore re-parse markdown)
|
|
1409
|
+
* for every completed message while only one bubble at the bottom is changing.
|
|
1410
|
+
* The props are primitives, so the default shallow comparison is sufficient.
|
|
1411
|
+
*/
|
|
1412
|
+
var Bubble = memo(function Bubble({ role, text, reasoning, attachments }) {
|
|
1413
|
+
const { t } = useTranslation();
|
|
1414
|
+
if (role === "user") return /* @__PURE__ */ jsxs("div", {
|
|
1415
|
+
className: "self-end max-w-[85%] rounded-2xl border border-ring bg-muted px-3 py-2 text-[0.8125rem] whitespace-pre-wrap break-words",
|
|
1416
|
+
children: [attachments?.length ? /* @__PURE__ */ jsx("div", {
|
|
1417
|
+
className: "mb-1.5 flex flex-wrap gap-1",
|
|
1418
|
+
children: attachments.map((a) => /* @__PURE__ */ jsx("span", {
|
|
1419
|
+
className: "inline-flex max-w-full items-center rounded-md bg-background px-1.5 py-0.5 text-xs",
|
|
1420
|
+
title: a.name,
|
|
1421
|
+
children: a.label ?? a.name
|
|
1422
|
+
}, a.name))
|
|
1423
|
+
}) : null, text]
|
|
1424
|
+
});
|
|
1425
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1426
|
+
className: "self-start max-w-[95%] min-w-0 rounded-2xl bg-muted px-3 py-2 text-sm break-words",
|
|
1427
|
+
children: [reasoning ? /* @__PURE__ */ jsxs("details", {
|
|
1428
|
+
className: "mb-2 rounded-lg border border-border px-2 py-1",
|
|
1429
|
+
children: [/* @__PURE__ */ jsx("summary", {
|
|
1430
|
+
className: "cursor-pointer select-none text-xs text-muted-foreground",
|
|
1431
|
+
children: t("webchat.reasoning")
|
|
1432
|
+
}), /* @__PURE__ */ jsx(ChatMarkdown, {
|
|
1433
|
+
text: reasoning,
|
|
1434
|
+
className: "md-static md-chat md-chat-sub mt-1 text-muted-foreground"
|
|
1435
|
+
})]
|
|
1436
|
+
}) : null, /* @__PURE__ */ jsx(ChatMarkdown, {
|
|
1437
|
+
text,
|
|
1438
|
+
className: "md-static md-chat"
|
|
1439
|
+
})]
|
|
1440
|
+
});
|
|
1441
|
+
});
|
|
1442
|
+
//#endregion
|
|
1443
|
+
//#region src/ui/AgentPicker.tsx
|
|
1444
|
+
var MAX_STARS = 5;
|
|
1445
|
+
/** Rating pair: "3 star, 3 clock". Two digits read at a glance and stay the same
|
|
1446
|
+
* width on every row, which a free-text hint ("fastest and cheapest" next to
|
|
1447
|
+
* "quick") does not. Both are read the same way — the star is what you get, the
|
|
1448
|
+
* clock is what it costs you in waiting — so a heavier model raises both numbers.
|
|
1449
|
+
* Falls back to the hint when the agent publishes no ratings. */
|
|
1450
|
+
function PresetRating({ quality, time, hint }) {
|
|
1451
|
+
const { t } = useTranslation();
|
|
1452
|
+
if (quality === void 0 || time === void 0) return hint ? /* @__PURE__ */ jsx("span", {
|
|
1453
|
+
className: "shrink-0 text-muted-foreground",
|
|
1454
|
+
children: hint
|
|
1455
|
+
}) : null;
|
|
1456
|
+
const both = quality !== time;
|
|
1457
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
1458
|
+
className: "flex shrink-0 items-center gap-1.5 text-muted-foreground tabular-nums",
|
|
1459
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
1460
|
+
className: "flex items-center gap-0.5",
|
|
1461
|
+
title: t("webchat.quality", { value: quality }),
|
|
1462
|
+
children: [
|
|
1463
|
+
quality,
|
|
1464
|
+
/* @__PURE__ */ jsx(Star, {
|
|
1465
|
+
size: 11,
|
|
1466
|
+
"aria-hidden": true,
|
|
1467
|
+
className: "shrink-0"
|
|
1468
|
+
}),
|
|
1469
|
+
/* @__PURE__ */ jsx("span", {
|
|
1470
|
+
className: "sr-only",
|
|
1471
|
+
children: t("webchat.quality", { value: quality })
|
|
1472
|
+
})
|
|
1473
|
+
]
|
|
1474
|
+
}), both ? /* @__PURE__ */ jsxs("span", {
|
|
1475
|
+
className: "flex items-center gap-0.5",
|
|
1476
|
+
title: t("webchat.time", { value: time }),
|
|
1477
|
+
children: [
|
|
1478
|
+
time,
|
|
1479
|
+
/* @__PURE__ */ jsx(Clock, {
|
|
1480
|
+
size: 11,
|
|
1481
|
+
"aria-hidden": true,
|
|
1482
|
+
className: "shrink-0"
|
|
1483
|
+
}),
|
|
1484
|
+
/* @__PURE__ */ jsx("span", {
|
|
1485
|
+
className: "sr-only",
|
|
1486
|
+
children: t("webchat.time", { value: time })
|
|
1487
|
+
})
|
|
1488
|
+
]
|
|
1489
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
1490
|
+
className: "sr-only",
|
|
1491
|
+
children: t("webchat.time", { value: time })
|
|
1492
|
+
})]
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* The presets of ONE agent, ordered by their level, when they form a complete ladder:
|
|
1497
|
+
* a unique `quality` per preset, contiguous from 1, at most five rungs. Only then does a
|
|
1498
|
+
* star count address a preset without ambiguity, and only then may the picker stop
|
|
1499
|
+
* printing model names. Anything else — two agents, a gap in the ladder, a preset with
|
|
1500
|
+
* no rating — returns null and the picker keeps its list of names.
|
|
1501
|
+
*/
|
|
1502
|
+
function starLadder(rows, multi) {
|
|
1503
|
+
if (multi || rows.length < 2 || rows.length > MAX_STARS) return null;
|
|
1504
|
+
const byLevel = Array.from({ length: rows.length });
|
|
1505
|
+
for (const r of rows) {
|
|
1506
|
+
const q = r.quality;
|
|
1507
|
+
if (q === void 0 || !Number.isInteger(q) || q < 1 || q > rows.length) return null;
|
|
1508
|
+
if (byLevel[q - 1]) return null;
|
|
1509
|
+
byLevel[q - 1] = r;
|
|
1510
|
+
}
|
|
1511
|
+
return byLevel.every((r) => r !== void 0) ? byLevel : null;
|
|
1512
|
+
}
|
|
1513
|
+
/** Flatten the catalog to one list: with a single registered agent the agent
|
|
1514
|
+
* level is noise, and with several the agent name simply prefixes its presets. */
|
|
1515
|
+
function AgentPicker({ agents, defaultAgentId, selection, onChange }) {
|
|
1516
|
+
const { t } = useTranslation();
|
|
1517
|
+
const [open, setOpen] = useState(false);
|
|
1518
|
+
const [preview, setPreview] = useState(null);
|
|
1519
|
+
const rootRef = useRef(null);
|
|
1520
|
+
const triggerRef = useRef(null);
|
|
1521
|
+
useEffect(() => {
|
|
1522
|
+
if (!open) return;
|
|
1523
|
+
function onPointerDown(e) {
|
|
1524
|
+
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
|
|
1525
|
+
}
|
|
1526
|
+
function onKeyDown(e) {
|
|
1527
|
+
if (e.key === "Escape") {
|
|
1528
|
+
setOpen(false);
|
|
1529
|
+
triggerRef.current?.focus();
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
1533
|
+
document.addEventListener("keydown", onKeyDown);
|
|
1534
|
+
return () => {
|
|
1535
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
1536
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
1537
|
+
};
|
|
1538
|
+
}, [open]);
|
|
1539
|
+
const multi = agents.length > 1;
|
|
1540
|
+
const rows = agents.flatMap((a) => a.presets.map((p) => ({
|
|
1541
|
+
agentId: a.id,
|
|
1542
|
+
presetId: p.id,
|
|
1543
|
+
label: multi ? `${a.title} · ${p.title}` : p.title,
|
|
1544
|
+
hint: p.hint,
|
|
1545
|
+
quality: p.quality,
|
|
1546
|
+
time: p.time,
|
|
1547
|
+
isDefault: p.default === true
|
|
1548
|
+
})));
|
|
1549
|
+
if (rows.length === 0) return null;
|
|
1550
|
+
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];
|
|
1551
|
+
const ladder = starLadder(rows, multi);
|
|
1552
|
+
if (ladder) {
|
|
1553
|
+
const level = ladder.indexOf(current) + 1;
|
|
1554
|
+
const shown = preview ?? level;
|
|
1555
|
+
const levelName = (row, n) => `${row.label} · ${t("webchat.level", {
|
|
1556
|
+
value: n,
|
|
1557
|
+
max: ladder.length
|
|
1558
|
+
})}`;
|
|
1559
|
+
return /* @__PURE__ */ jsx("ul", {
|
|
1560
|
+
"aria-label": t("webchat.agentPicker"),
|
|
1561
|
+
className: "flex shrink-0 items-center",
|
|
1562
|
+
onPointerLeave: () => setPreview(null),
|
|
1563
|
+
children: ladder.map((r, i) => {
|
|
1564
|
+
const n = i + 1;
|
|
1565
|
+
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("button", {
|
|
1566
|
+
type: "button",
|
|
1567
|
+
...r === current ? { "aria-current": true } : {},
|
|
1568
|
+
"aria-label": levelName(r, n),
|
|
1569
|
+
className: "flex rounded p-1 focus-visible:outline-2 focus-visible:outline-ring",
|
|
1570
|
+
onPointerEnter: () => setPreview(n),
|
|
1571
|
+
onFocus: () => setPreview(n),
|
|
1572
|
+
onBlur: () => setPreview(null),
|
|
1573
|
+
onClick: () => onChange({
|
|
1574
|
+
agentId: r.agentId,
|
|
1575
|
+
presetId: r.presetId
|
|
1576
|
+
}),
|
|
1577
|
+
children: /* @__PURE__ */ jsx(Star, {
|
|
1578
|
+
size: 16,
|
|
1579
|
+
"aria-hidden": true,
|
|
1580
|
+
className: n <= shown ? "fill-current" : "text-border",
|
|
1581
|
+
style: n <= shown ? { color: toneVar("yellow") } : void 0
|
|
1582
|
+
})
|
|
1583
|
+
}) }, `${r.agentId}/${r.presetId}`);
|
|
1584
|
+
})
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1588
|
+
className: "relative",
|
|
1589
|
+
ref: rootRef,
|
|
1590
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
1591
|
+
ref: triggerRef,
|
|
1592
|
+
type: "button",
|
|
1593
|
+
"aria-haspopup": "menu",
|
|
1594
|
+
"aria-expanded": open,
|
|
1595
|
+
className: "flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring",
|
|
1596
|
+
onClick: () => setOpen((v) => !v),
|
|
1597
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1598
|
+
className: "max-w-40 truncate",
|
|
1599
|
+
children: current.label
|
|
1600
|
+
}), /* @__PURE__ */ jsx(ChevronDown, {
|
|
1601
|
+
size: 12,
|
|
1602
|
+
className: "shrink-0"
|
|
1603
|
+
})]
|
|
1604
|
+
}), open && /* @__PURE__ */ jsx("ul", {
|
|
1605
|
+
"aria-label": t("webchat.agentPicker"),
|
|
1606
|
+
className: "absolute bottom-full left-0 z-10 mb-1 max-h-64 w-64 max-w-[calc(100vw-2rem)] overflow-y-auto rounded-md border border-border bg-muted py-1 shadow-lg",
|
|
1607
|
+
children: rows.map((r) => {
|
|
1608
|
+
const selected = r === current;
|
|
1609
|
+
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
|
|
1610
|
+
type: "button",
|
|
1611
|
+
...selected ? { "aria-current": true } : {},
|
|
1612
|
+
className: "flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-card focus-visible:outline-2 focus-visible:outline-ring",
|
|
1613
|
+
onClick: () => {
|
|
1614
|
+
setOpen(false);
|
|
1615
|
+
onChange({
|
|
1616
|
+
agentId: r.agentId,
|
|
1617
|
+
presetId: r.presetId
|
|
1618
|
+
});
|
|
1619
|
+
},
|
|
1620
|
+
children: [
|
|
1621
|
+
/* @__PURE__ */ jsx(Check, {
|
|
1622
|
+
size: 12,
|
|
1623
|
+
className: selected ? "shrink-0 text-primary" : "shrink-0 opacity-0"
|
|
1624
|
+
}),
|
|
1625
|
+
/* @__PURE__ */ jsx("span", {
|
|
1626
|
+
className: "min-w-0 flex-1 truncate",
|
|
1627
|
+
children: r.label
|
|
1628
|
+
}),
|
|
1629
|
+
/* @__PURE__ */ jsx(PresetRating, {
|
|
1630
|
+
quality: r.quality,
|
|
1631
|
+
time: r.time,
|
|
1632
|
+
hint: r.hint
|
|
1633
|
+
})
|
|
1634
|
+
]
|
|
1635
|
+
}) }, `${r.agentId}/${r.presetId}`);
|
|
1636
|
+
})
|
|
1637
|
+
})]
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
//#endregion
|
|
1641
|
+
//#region src/ui/ChatComposer.tsx
|
|
1642
|
+
function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploadError, uploadProgress, canRetryUpload, onRetryUpload, onPickFiles, onRemoveAttachment, agents, defaultAgentId, selection, onChooseAgent, attachmentsAllowed }) {
|
|
1643
|
+
const { t } = useTranslation();
|
|
1644
|
+
const [text, setText] = useState("");
|
|
1645
|
+
function submit() {
|
|
1646
|
+
if (!text.trim() || streaming) return;
|
|
1647
|
+
if (onSend(text.trim(), attachments)) setText("");
|
|
1648
|
+
}
|
|
1649
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1650
|
+
className: "flex shrink-0 flex-col gap-2 border-t border-border p-2",
|
|
1651
|
+
children: [(attachments.length > 0 || uploading || uploadError) && /* @__PURE__ */ jsxs("div", {
|
|
1652
|
+
className: "flex flex-wrap items-center gap-1.5 px-1",
|
|
1653
|
+
"data-chat-attachments": true,
|
|
1654
|
+
children: [
|
|
1655
|
+
attachments.map((a, i) => /* @__PURE__ */ jsxs("span", {
|
|
1656
|
+
className: "inline-flex min-w-0 max-w-full items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs",
|
|
1657
|
+
children: [
|
|
1658
|
+
/* @__PURE__ */ jsx(FileText, {
|
|
1659
|
+
size: 13,
|
|
1660
|
+
className: "shrink-0 text-muted-foreground"
|
|
1661
|
+
}),
|
|
1662
|
+
/* @__PURE__ */ jsx("span", {
|
|
1663
|
+
className: "max-w-48 truncate",
|
|
1664
|
+
children: a.label ?? a.name
|
|
1665
|
+
}),
|
|
1666
|
+
/* @__PURE__ */ jsx("button", {
|
|
1667
|
+
type: "button",
|
|
1668
|
+
"aria-label": t("webchat.removeAttachment", { name: a.label ?? a.name }),
|
|
1669
|
+
className: "shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive",
|
|
1670
|
+
onClick: () => onRemoveAttachment(i),
|
|
1671
|
+
children: /* @__PURE__ */ jsx(X, { size: 13 })
|
|
1672
|
+
})
|
|
1673
|
+
]
|
|
1674
|
+
}, `${a.name}-${i}`)),
|
|
1675
|
+
uploading && /* @__PURE__ */ jsxs("div", {
|
|
1676
|
+
className: "flex min-w-32 flex-1 items-center gap-2",
|
|
1677
|
+
"aria-label": t("webchat.uploading"),
|
|
1678
|
+
children: [
|
|
1679
|
+
/* @__PURE__ */ jsx(LoaderCircle, {
|
|
1680
|
+
size: 14,
|
|
1681
|
+
className: "shrink-0 animate-spin text-muted-foreground"
|
|
1682
|
+
}),
|
|
1683
|
+
/* @__PURE__ */ jsx("div", {
|
|
1684
|
+
role: "progressbar",
|
|
1685
|
+
"aria-label": t("webchat.uploadProgress"),
|
|
1686
|
+
"aria-valuemin": 0,
|
|
1687
|
+
"aria-valuemax": 100,
|
|
1688
|
+
"aria-valuenow": uploadProgress,
|
|
1689
|
+
className: "h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-border",
|
|
1690
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1691
|
+
className: "h-full rounded-full bg-primary transition-[width]",
|
|
1692
|
+
style: { width: `${uploadProgress}%` }
|
|
1693
|
+
})
|
|
1694
|
+
}),
|
|
1695
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1696
|
+
className: "text-xs tabular-nums text-muted-foreground",
|
|
1697
|
+
children: [uploadProgress, "%"]
|
|
1698
|
+
})
|
|
1699
|
+
]
|
|
1700
|
+
}),
|
|
1701
|
+
uploadError && /* @__PURE__ */ jsxs("div", {
|
|
1702
|
+
className: "flex min-w-0 flex-1 items-center gap-2 text-xs text-destructive",
|
|
1703
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1704
|
+
className: "min-w-0 flex-1",
|
|
1705
|
+
children: t(uploadError)
|
|
1706
|
+
}), canRetryUpload && /* @__PURE__ */ jsxs("button", {
|
|
1707
|
+
type: "button",
|
|
1708
|
+
className: "inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 font-medium text-primary hover:bg-muted",
|
|
1709
|
+
onClick: onRetryUpload,
|
|
1710
|
+
children: [
|
|
1711
|
+
/* @__PURE__ */ jsx(RotateCcw, { size: 12 }),
|
|
1712
|
+
" ",
|
|
1713
|
+
t("webchat.retryUpload")
|
|
1714
|
+
]
|
|
1715
|
+
})]
|
|
1716
|
+
})
|
|
1717
|
+
]
|
|
1718
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
1719
|
+
className: "flex flex-col gap-1",
|
|
1720
|
+
children: [/* @__PURE__ */ jsx("textarea", {
|
|
1721
|
+
className: "w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm max-h-32",
|
|
1722
|
+
rows: 1,
|
|
1723
|
+
value: text,
|
|
1724
|
+
placeholder: t("webchat.placeholder"),
|
|
1725
|
+
onChange: (e) => setText(e.target.value),
|
|
1726
|
+
onKeyDown: (e) => {
|
|
1727
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
1728
|
+
e.preventDefault();
|
|
1729
|
+
submit();
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
1733
|
+
className: "flex items-center gap-1",
|
|
1734
|
+
children: [
|
|
1735
|
+
attachmentsAllowed && /* @__PURE__ */ jsxs("label", {
|
|
1736
|
+
"aria-label": t("webchat.attach"),
|
|
1737
|
+
className: `shrink-0 cursor-pointer rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-primary ${uploading ? "pointer-events-none opacity-60" : ""}`,
|
|
1738
|
+
children: [/* @__PURE__ */ jsx(Paperclip, { size: 18 }), /* @__PURE__ */ jsx("input", {
|
|
1739
|
+
type: "file",
|
|
1740
|
+
multiple: true,
|
|
1741
|
+
className: "hidden",
|
|
1742
|
+
disabled: uploading,
|
|
1743
|
+
onChange: (e) => {
|
|
1744
|
+
if (e.target.files?.length) onPickFiles(e.target.files);
|
|
1745
|
+
e.currentTarget.value = "";
|
|
1746
|
+
}
|
|
1747
|
+
})]
|
|
1748
|
+
}),
|
|
1749
|
+
/* @__PURE__ */ jsx(AgentPicker, {
|
|
1750
|
+
agents,
|
|
1751
|
+
defaultAgentId,
|
|
1752
|
+
selection,
|
|
1753
|
+
onChange: onChooseAgent
|
|
1754
|
+
}),
|
|
1755
|
+
/* @__PURE__ */ jsx("div", { className: "flex-1" }),
|
|
1756
|
+
streaming ? /* @__PURE__ */ jsx("button", {
|
|
1757
|
+
"aria-label": t("webchat.stop"),
|
|
1758
|
+
className: "shrink-0 rounded-lg p-1.5 hover:bg-muted",
|
|
1759
|
+
onClick: onStop,
|
|
1760
|
+
children: /* @__PURE__ */ jsx(Square, { size: 18 })
|
|
1761
|
+
}) : /* @__PURE__ */ jsx("button", {
|
|
1762
|
+
"aria-label": t("webchat.send"),
|
|
1763
|
+
className: "shrink-0 rounded-lg p-1.5 hover:bg-muted",
|
|
1764
|
+
onClick: submit,
|
|
1765
|
+
children: /* @__PURE__ */ jsx(Send, { size: 18 })
|
|
1766
|
+
})
|
|
1767
|
+
]
|
|
1768
|
+
})]
|
|
1769
|
+
})]
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
//#endregion
|
|
1773
|
+
//#region src/ui/ChatThreadList.tsx
|
|
1774
|
+
function ChatThreadList({ threads, activeId, onOpen, onNew, onTogglePrivate, className = "w-[220px] shrink-0" }) {
|
|
1775
|
+
const { t } = useTranslation();
|
|
1776
|
+
return /* @__PURE__ */ jsxs("aside", {
|
|
1777
|
+
"aria-label": t("webchat.threads"),
|
|
1778
|
+
className: `${className} border-r border-border flex flex-col`,
|
|
1779
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
1780
|
+
className: "flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted",
|
|
1781
|
+
onClick: onNew,
|
|
1782
|
+
children: [
|
|
1783
|
+
/* @__PURE__ */ jsx(Plus, { size: 16 }),
|
|
1784
|
+
" ",
|
|
1785
|
+
t("webchat.newConversation")
|
|
1786
|
+
]
|
|
1787
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
1788
|
+
className: "flex-1 overflow-y-auto",
|
|
1789
|
+
children: threads.map((th) => {
|
|
1790
|
+
const active = th.convId === activeId;
|
|
1791
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1792
|
+
className: `flex items-center gap-1 pl-1 pr-2 ${active ? "bg-muted" : "hover:bg-muted"}`,
|
|
1793
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
1794
|
+
onClick: () => onOpen(th.convId),
|
|
1795
|
+
className: `min-w-0 flex-1 py-2 pl-2 text-left text-sm ${active ? "font-medium" : ""}`,
|
|
1796
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1797
|
+
className: "block truncate",
|
|
1798
|
+
children: th.title || t("webchat.newConversation")
|
|
1799
|
+
}), (th.mine || th.ownerName) && /* @__PURE__ */ jsx("span", {
|
|
1800
|
+
className: "block truncate text-xs text-muted-foreground",
|
|
1801
|
+
children: th.mine ? t("webchat.you") : th.ownerName
|
|
1802
|
+
})]
|
|
1803
|
+
}), th.mine && /* @__PURE__ */ jsx("button", {
|
|
1804
|
+
type: "button",
|
|
1805
|
+
"aria-label": th.private ? t("webchat.makePublic") : t("webchat.makePrivate"),
|
|
1806
|
+
title: th.private ? t("webchat.makePublic") : t("webchat.makePrivate"),
|
|
1807
|
+
onClick: (e) => {
|
|
1808
|
+
e.stopPropagation();
|
|
1809
|
+
onTogglePrivate(th.convId, !th.private);
|
|
1810
|
+
},
|
|
1811
|
+
className: "shrink-0 rounded p-1 text-muted-foreground hover:bg-card hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring",
|
|
1812
|
+
children: /* @__PURE__ */ jsx(Icon, {
|
|
1813
|
+
name: th.private ? "lucide:shield" : "lucide:globe",
|
|
1814
|
+
size: 14
|
|
1815
|
+
})
|
|
1816
|
+
})]
|
|
1817
|
+
}, th.convId);
|
|
1818
|
+
})
|
|
1819
|
+
})]
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
//#endregion
|
|
1823
|
+
//#region src/ui/SuggestionChips.tsx
|
|
1824
|
+
/**
|
|
1825
|
+
* A row of clickable quick-reply chips: either the static, global conversation starters
|
|
1826
|
+
* shown on an empty chat, or the contextual suggestions the agent proposed after its last
|
|
1827
|
+
* answer (`ChatState.suggestions`) — both render through this one component, distinguished
|
|
1828
|
+
* only by which list `ChatWidget` passes in. Renders nothing for an empty or null list, so
|
|
1829
|
+
* callers do not need their own guard.
|
|
1830
|
+
*/
|
|
1831
|
+
function SuggestionChips({ items, onPick }) {
|
|
1832
|
+
const { t } = useTranslation();
|
|
1833
|
+
if (!items?.length) return null;
|
|
1834
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1835
|
+
role: "group",
|
|
1836
|
+
"aria-label": t("webchat.suggestions"),
|
|
1837
|
+
className: "flex flex-wrap gap-1.5 px-2 pb-1.5",
|
|
1838
|
+
children: items.map((text, i) => /* @__PURE__ */ jsx("button", {
|
|
1839
|
+
type: "button",
|
|
1840
|
+
title: text,
|
|
1841
|
+
className: "max-w-full truncate rounded-full border border-border bg-muted px-3 py-1 text-xs text-foreground hover:border-border2 hover:bg-card focus-visible:outline-2 focus-visible:outline-ring",
|
|
1842
|
+
onClick: () => onPick(text),
|
|
1843
|
+
children: text
|
|
1844
|
+
}, `${i}-${text}`))
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
//#endregion
|
|
1848
|
+
//#region src/ui/ChatWidget.tsx
|
|
1849
|
+
var MAX_CHAT_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
1850
|
+
function isFileDrag(e) {
|
|
1851
|
+
return Array.from(e.dataTransfer.types).includes("Files");
|
|
1852
|
+
}
|
|
1853
|
+
/** One drop surface per popup. The overlay deliberately covers the header, feed, and composer. */
|
|
1854
|
+
function ChatDropSurface({ children, onFiles, className }) {
|
|
1855
|
+
const { t } = useTranslation();
|
|
1856
|
+
const [dragging, setDragging] = useState(false);
|
|
1857
|
+
const depth = useRef(0);
|
|
1858
|
+
function enter(e) {
|
|
1859
|
+
if (!isFileDrag(e)) return;
|
|
1860
|
+
e.preventDefault();
|
|
1861
|
+
depth.current += 1;
|
|
1862
|
+
setDragging(true);
|
|
1863
|
+
}
|
|
1864
|
+
function leave(e) {
|
|
1865
|
+
if (!isFileDrag(e)) return;
|
|
1866
|
+
e.preventDefault();
|
|
1867
|
+
depth.current = Math.max(0, depth.current - 1);
|
|
1868
|
+
if (depth.current === 0) setDragging(false);
|
|
1869
|
+
}
|
|
1870
|
+
function drop(e) {
|
|
1871
|
+
if (!isFileDrag(e)) return;
|
|
1872
|
+
e.preventDefault();
|
|
1873
|
+
depth.current = 0;
|
|
1874
|
+
setDragging(false);
|
|
1875
|
+
onFiles(Array.from(e.dataTransfer.files));
|
|
1876
|
+
}
|
|
1877
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1878
|
+
className,
|
|
1879
|
+
onDragEnter: enter,
|
|
1880
|
+
onDragLeave: leave,
|
|
1881
|
+
onDragOver: (e) => {
|
|
1882
|
+
if (isFileDrag(e)) {
|
|
1883
|
+
e.preventDefault();
|
|
1884
|
+
e.dataTransfer.dropEffect = "copy";
|
|
1885
|
+
}
|
|
1886
|
+
},
|
|
1887
|
+
onDrop: drop,
|
|
1888
|
+
children: [children, dragging && /* @__PURE__ */ jsx("div", {
|
|
1889
|
+
"data-chat-drop-overlay": true,
|
|
1890
|
+
"data-testid": "chat-drop-overlay",
|
|
1891
|
+
className: "pointer-events-none absolute inset-0 z-50 grid place-items-center bg-card/95 p-6 text-center backdrop-blur-sm",
|
|
1892
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
1893
|
+
className: "flex h-full w-full flex-col items-center justify-center rounded-2xl border-2 border-dashed border-ring text-primary",
|
|
1894
|
+
children: [
|
|
1895
|
+
/* @__PURE__ */ jsx(Upload, { size: 34 }),
|
|
1896
|
+
/* @__PURE__ */ jsx("span", {
|
|
1897
|
+
className: "mt-3 text-base font-medium",
|
|
1898
|
+
children: t("webchat.dropFiles")
|
|
1899
|
+
}),
|
|
1900
|
+
/* @__PURE__ */ jsx("span", {
|
|
1901
|
+
className: "mt-1 text-xs text-muted-foreground",
|
|
1902
|
+
children: t("webchat.dropFilesHint")
|
|
1903
|
+
})
|
|
1904
|
+
]
|
|
1905
|
+
})
|
|
1906
|
+
})]
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
/** The states the VIEWPORT offers, ordered from least to most expanded. `panel` — the 380px
|
|
1910
|
+
* floating window — exists only where it does not obstruct the page: on a narrow screen the
|
|
1911
|
+
* widget has exactly two states, the bubble and the full sheet. Every mode transition is a
|
|
1912
|
+
* move along this ladder, so no call site can put the widget into a mode its screen lacks. */
|
|
1913
|
+
function chatModes(wide) {
|
|
1914
|
+
return wide ? [
|
|
1915
|
+
"bubble",
|
|
1916
|
+
"panel",
|
|
1917
|
+
"modal"
|
|
1918
|
+
] : ["bubble", "modal"];
|
|
1919
|
+
}
|
|
1920
|
+
/** Where the widget rests once it is opened — expanded, but as little as the viewport allows.
|
|
1921
|
+
* Also where it returns after following a link from a response: on a wide screen the panel
|
|
1922
|
+
* does not obstruct the page the user just navigated to, while on a narrow one the sheet is
|
|
1923
|
+
* the only visible state, so the active conversation survives the navigation either way. */
|
|
1924
|
+
function restingMode(wide) {
|
|
1925
|
+
return chatModes(wide)[1];
|
|
1926
|
+
}
|
|
1927
|
+
/** One step along the ladder, clamped at both ends: `-1` is what collapse, Esc and a dialog
|
|
1928
|
+
* dismiss do, `+1` is expand. A mode this viewport no longer offers is re-homed by the effect
|
|
1929
|
+
* in the widget, not by stepping — the step always returns a mode from the current ladder. */
|
|
1930
|
+
function stepMode(mode, wide, delta) {
|
|
1931
|
+
const ladder = chatModes(wide);
|
|
1932
|
+
const next = ladder.indexOf(mode) + delta;
|
|
1933
|
+
return ladder[Math.min(ladder.length - 1, Math.max(0, next))];
|
|
1934
|
+
}
|
|
1935
|
+
function ChatWidget() {
|
|
1936
|
+
const { t } = useTranslation();
|
|
1937
|
+
const chat = useChat();
|
|
1938
|
+
const readPage = usePageContext();
|
|
1939
|
+
const [mode, setMode] = useState("bubble");
|
|
1940
|
+
const [threadsOpen, setThreadsOpen] = useState(false);
|
|
1941
|
+
const [attachments, setAttachments] = useState([]);
|
|
1942
|
+
const [uploading, setUploading] = useState(false);
|
|
1943
|
+
const [uploadProgress, setUploadProgress] = useState(0);
|
|
1944
|
+
const [uploadError, setUploadError] = useState(null);
|
|
1945
|
+
const [retryFiles, setRetryFiles] = useState([]);
|
|
1946
|
+
const wide = useMinWidth("sm", 640);
|
|
1947
|
+
const hasSidebar = wide && mode === "modal";
|
|
1948
|
+
const onChatNavigate = useCallback(() => {
|
|
1949
|
+
setMode(restingMode(wide));
|
|
1950
|
+
}, [wide]);
|
|
1951
|
+
const expandBtnRef = useRef(null);
|
|
1952
|
+
const prevModeRef = useRef("bubble");
|
|
1953
|
+
useEffect(() => {
|
|
1954
|
+
if (mode !== "modal") return;
|
|
1955
|
+
const onKey = (e) => {
|
|
1956
|
+
if (e.key === "Escape") setMode(stepMode("modal", wide, -1));
|
|
1957
|
+
};
|
|
1958
|
+
window.addEventListener("keydown", onKey);
|
|
1959
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
1960
|
+
}, [mode, wide]);
|
|
1961
|
+
useEffect(() => {
|
|
1962
|
+
if (!chatModes(wide).includes(mode)) setMode(restingMode(wide));
|
|
1963
|
+
}, [mode, wide]);
|
|
1964
|
+
useEffect(() => {
|
|
1965
|
+
if (mode === "panel" && prevModeRef.current === "modal") expandBtnRef.current?.focus();
|
|
1966
|
+
prevModeRef.current = mode;
|
|
1967
|
+
}, [mode]);
|
|
1968
|
+
useEffect(() => {
|
|
1969
|
+
if (mode === "bubble" || hasSidebar) setThreadsOpen(false);
|
|
1970
|
+
}, [mode, hasSidebar]);
|
|
1971
|
+
useEffect(() => {
|
|
1972
|
+
if (mode !== "modal") return;
|
|
1973
|
+
const prev = document.body.style.overflow;
|
|
1974
|
+
document.body.style.overflow = "hidden";
|
|
1975
|
+
return () => {
|
|
1976
|
+
document.body.style.overflow = prev;
|
|
1977
|
+
};
|
|
1978
|
+
}, [mode]);
|
|
1979
|
+
const isOpen = mode !== "bubble";
|
|
1980
|
+
useEffect(() => {
|
|
1981
|
+
if (isOpen) chat.loadThreads();
|
|
1982
|
+
}, [isOpen, chat.loadThreads]);
|
|
1983
|
+
const { convId, newConversation } = chat;
|
|
1984
|
+
useEffect(() => {
|
|
1985
|
+
if (isOpen && convId === null) newConversation();
|
|
1986
|
+
}, [
|
|
1987
|
+
isOpen,
|
|
1988
|
+
convId,
|
|
1989
|
+
newConversation
|
|
1990
|
+
]);
|
|
1991
|
+
function startNew() {
|
|
1992
|
+
chat.newConversation();
|
|
1993
|
+
setAttachments([]);
|
|
1994
|
+
setUploadError(null);
|
|
1995
|
+
setRetryFiles([]);
|
|
1996
|
+
setThreadsOpen(false);
|
|
1997
|
+
}
|
|
1998
|
+
const pickFiles = useCallback(async (files) => {
|
|
1999
|
+
if (!files.length) return;
|
|
2000
|
+
setUploadError(null);
|
|
2001
|
+
setRetryFiles([]);
|
|
2002
|
+
setUploading(true);
|
|
2003
|
+
setUploadProgress(0);
|
|
2004
|
+
const added = [];
|
|
2005
|
+
const failedFiles = [];
|
|
2006
|
+
let error = null;
|
|
2007
|
+
for (const file of files) {
|
|
2008
|
+
if (file.size > MAX_CHAT_UPLOAD_BYTES) {
|
|
2009
|
+
error ??= "webchat.uploadTooLarge";
|
|
2010
|
+
continue;
|
|
2011
|
+
}
|
|
2012
|
+
try {
|
|
2013
|
+
setUploadProgress(0);
|
|
2014
|
+
const rec = await uploadFile(file, setUploadProgress);
|
|
2015
|
+
if (rec) added.push({
|
|
2016
|
+
...rec,
|
|
2017
|
+
label: file.name
|
|
2018
|
+
});
|
|
2019
|
+
else {
|
|
2020
|
+
failedFiles.push(file);
|
|
2021
|
+
error ??= "webchat.uploadFailed";
|
|
2022
|
+
}
|
|
2023
|
+
} catch {
|
|
2024
|
+
failedFiles.push(file);
|
|
2025
|
+
error ??= "webchat.uploadFailed";
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
if (added.length) setAttachments((prev) => [...prev, ...added]);
|
|
2029
|
+
setUploading(false);
|
|
2030
|
+
setUploadProgress(0);
|
|
2031
|
+
setRetryFiles(failedFiles);
|
|
2032
|
+
if (error) setUploadError(error);
|
|
2033
|
+
}, []);
|
|
2034
|
+
const retryUploads = useCallback(() => {
|
|
2035
|
+
if (retryFiles.length) pickFiles(retryFiles);
|
|
2036
|
+
}, [pickFiles, retryFiles]);
|
|
2037
|
+
const sendTurn = useCallback((text, refs) => {
|
|
2038
|
+
const page = readPage();
|
|
2039
|
+
const accepted = refs.length ? chat.send(text, page, refs) : chat.send(text, page);
|
|
2040
|
+
if (accepted) {
|
|
2041
|
+
setAttachments([]);
|
|
2042
|
+
setUploadError(null);
|
|
2043
|
+
setRetryFiles([]);
|
|
2044
|
+
}
|
|
2045
|
+
return accepted;
|
|
2046
|
+
}, [chat, readPage]);
|
|
2047
|
+
const hasConversation = chat.messages.length > 0 || chat.pending !== null;
|
|
2048
|
+
const activeTitle = chat.threads.find((th) => th.convId === chat.convId)?.title;
|
|
2049
|
+
const header = /* @__PURE__ */ jsxs("div", {
|
|
2050
|
+
className: "flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2",
|
|
2051
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
2052
|
+
className: "min-w-0 truncate text-sm font-medium",
|
|
2053
|
+
children: activeTitle || t("webchat.title")
|
|
2054
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
2055
|
+
className: "flex shrink-0 items-center gap-1",
|
|
2056
|
+
children: [
|
|
2057
|
+
!hasSidebar && /* @__PURE__ */ jsx("button", {
|
|
2058
|
+
"aria-label": t("webchat.threads"),
|
|
2059
|
+
"aria-expanded": threadsOpen,
|
|
2060
|
+
className: "rounded p-1 hover:bg-muted",
|
|
2061
|
+
onClick: () => setThreadsOpen((v) => !v),
|
|
2062
|
+
children: /* @__PURE__ */ jsx(List, { size: 16 })
|
|
2063
|
+
}),
|
|
2064
|
+
hasConversation && /* @__PURE__ */ jsx("button", {
|
|
2065
|
+
"aria-label": t("webchat.newConversation"),
|
|
2066
|
+
className: "rounded p-1 hover:bg-muted",
|
|
2067
|
+
onClick: startNew,
|
|
2068
|
+
children: /* @__PURE__ */ jsx(Plus, { size: 16 })
|
|
2069
|
+
}),
|
|
2070
|
+
chatModes(wide).includes("panel") && /* @__PURE__ */ jsx("button", {
|
|
2071
|
+
ref: expandBtnRef,
|
|
2072
|
+
"aria-label": mode === "modal" ? t("webchat.collapse") : t("webchat.expand"),
|
|
2073
|
+
className: "rounded p-1 hover:bg-muted",
|
|
2074
|
+
onClick: () => setMode(stepMode(mode, wide, mode === "modal" ? -1 : 1)),
|
|
2075
|
+
children: mode === "modal" ? /* @__PURE__ */ jsx(Minimize2, { size: 16 }) : /* @__PURE__ */ jsx(Maximize2, { size: 16 })
|
|
2076
|
+
}),
|
|
2077
|
+
/* @__PURE__ */ jsx("button", {
|
|
2078
|
+
"aria-label": t("webchat.close"),
|
|
2079
|
+
className: "rounded p-1 hover:bg-muted",
|
|
2080
|
+
onClick: () => setMode("bubble"),
|
|
2081
|
+
children: /* @__PURE__ */ jsx(X, { size: 16 })
|
|
2082
|
+
})
|
|
2083
|
+
]
|
|
2084
|
+
})]
|
|
2085
|
+
});
|
|
2086
|
+
const threadList = /* @__PURE__ */ jsx(ChatThreadList, {
|
|
2087
|
+
threads: chat.threads,
|
|
2088
|
+
activeId: chat.convId,
|
|
2089
|
+
onOpen: (id) => {
|
|
2090
|
+
chat.open(id);
|
|
2091
|
+
setAttachments([]);
|
|
2092
|
+
setUploadError(null);
|
|
2093
|
+
setRetryFiles([]);
|
|
2094
|
+
setThreadsOpen(false);
|
|
2095
|
+
},
|
|
2096
|
+
onNew: startNew,
|
|
2097
|
+
onTogglePrivate: chat.togglePrivate,
|
|
2098
|
+
className: hasSidebar ? "w-[220px] shrink-0" : "w-full"
|
|
2099
|
+
});
|
|
2100
|
+
const conversation = /* @__PURE__ */ jsx(ChatNavProvider, {
|
|
2101
|
+
onNavigate: onChatNavigate,
|
|
2102
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
2103
|
+
className: "flex min-h-0 min-w-0 flex-1 flex-col",
|
|
2104
|
+
children: [
|
|
2105
|
+
chat.error && /* @__PURE__ */ jsx("div", {
|
|
2106
|
+
className: "px-3 py-2 text-sm text-destructive",
|
|
2107
|
+
children: t("webchat.error", { message: chat.error })
|
|
2108
|
+
}),
|
|
2109
|
+
/* @__PURE__ */ jsx(ChatMessages, {
|
|
2110
|
+
messages: chat.messages,
|
|
2111
|
+
pending: chat.pending,
|
|
2112
|
+
pendingReasoning: chat.pendingReasoning,
|
|
2113
|
+
trailing: chat.messages.length === 0 ? /* @__PURE__ */ jsx(SuggestionChips, {
|
|
2114
|
+
items: chat.starters,
|
|
2115
|
+
onPick: (text) => sendTurn(text, attachments)
|
|
2116
|
+
}) : /* @__PURE__ */ jsx(SuggestionChips, {
|
|
2117
|
+
items: chat.suggestions,
|
|
2118
|
+
onPick: (text) => sendTurn(text, attachments)
|
|
2119
|
+
})
|
|
2120
|
+
}),
|
|
2121
|
+
/* @__PURE__ */ jsx(ChatComposer, {
|
|
2122
|
+
streaming: chat.pending !== null,
|
|
2123
|
+
attachments,
|
|
2124
|
+
uploading,
|
|
2125
|
+
uploadProgress,
|
|
2126
|
+
uploadError,
|
|
2127
|
+
canRetryUpload: retryFiles.length > 0 && !uploading,
|
|
2128
|
+
onRetryUpload: retryUploads,
|
|
2129
|
+
onPickFiles: (files) => void pickFiles(Array.from(files)),
|
|
2130
|
+
onRemoveAttachment: (index) => setAttachments((prev) => prev.filter((_, i) => i !== index)),
|
|
2131
|
+
agents: chat.agents,
|
|
2132
|
+
defaultAgentId: chat.defaultAgentId,
|
|
2133
|
+
selection: chat.selection,
|
|
2134
|
+
onChooseAgent: chat.chooseAgent,
|
|
2135
|
+
attachmentsAllowed: chat.attachmentsAllowed,
|
|
2136
|
+
onSend: sendTurn,
|
|
2137
|
+
onStop: chat.stop
|
|
2138
|
+
})
|
|
2139
|
+
]
|
|
2140
|
+
})
|
|
2141
|
+
});
|
|
2142
|
+
if (mode === "bubble") return /* @__PURE__ */ jsx("button", {
|
|
2143
|
+
"aria-label": t("webchat.open"),
|
|
2144
|
+
onClick: () => setMode(restingMode(wide)),
|
|
2145
|
+
className: "fixed bottom-4 right-4 z-40 grid h-14 w-14 place-items-center rounded-full bg-primary text-on-accent ring-1 ring-border2 shadow-lg",
|
|
2146
|
+
children: /* @__PURE__ */ jsx(MessageCircle, { size: 24 })
|
|
2147
|
+
});
|
|
2148
|
+
if (mode === "panel") return /* @__PURE__ */ jsxs(ChatDropSurface, {
|
|
2149
|
+
onFiles: pickFiles,
|
|
2150
|
+
className: "fixed bottom-4 right-4 z-40 flex h-[560px] max-h-[80dvh] w-[380px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-xl",
|
|
2151
|
+
children: [header, threadsOpen ? threadList : conversation]
|
|
2152
|
+
});
|
|
2153
|
+
return /* @__PURE__ */ jsxs(Dialog, {
|
|
2154
|
+
open: true,
|
|
2155
|
+
modal: false,
|
|
2156
|
+
onOpenChange: (open) => !open && setMode(stepMode("modal", wide, -1)),
|
|
2157
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
2158
|
+
"data-chat-backdrop": true,
|
|
2159
|
+
"aria-hidden": "true",
|
|
2160
|
+
className: "fixed inset-0 z-[45] bg-black/50 backdrop-blur-sm"
|
|
2161
|
+
}), /* @__PURE__ */ jsx(DialogContent, {
|
|
2162
|
+
hideClose: true,
|
|
2163
|
+
className: "h-[85dvh] w-full p-0 md:h-[85dvh] md:w-[80vw] md:max-w-[1040px] md:overflow-y-hidden",
|
|
2164
|
+
children: /* @__PURE__ */ jsx(ChatDropSurface, {
|
|
2165
|
+
onFiles: pickFiles,
|
|
2166
|
+
className: "h-full",
|
|
2167
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
2168
|
+
className: "flex h-full min-h-0 flex-col",
|
|
2169
|
+
children: [header, /* @__PURE__ */ jsxs("div", {
|
|
2170
|
+
className: "flex min-h-0 flex-1",
|
|
2171
|
+
children: [(hasSidebar || threadsOpen) && threadList, (hasSidebar || !threadsOpen) && conversation]
|
|
2172
|
+
})]
|
|
2173
|
+
})
|
|
2174
|
+
})
|
|
2175
|
+
})]
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
//#endregion
|
|
2179
|
+
export { ChatWidget, ChatWidget as default, chatModes, restingMode, stepMode };
|