@cnt-dev/chat-ui 0.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/AGENTS.md +202 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/index.d.ts +23 -0
- package/package.json +41 -0
- package/resources/chatui/.library +14 -0
- package/resources/chatui/ChatBox-dbg.js +1099 -0
- package/resources/chatui/ChatBox-dbg.js.map +1 -0
- package/resources/chatui/ChatBox.d.ts +308 -0
- package/resources/chatui/ChatBox.d.ts.map +1 -0
- package/resources/chatui/ChatBox.gen.d.ts +545 -0
- package/resources/chatui/ChatBox.js +5 -0
- package/resources/chatui/ChatBox.js.map +1 -0
- package/resources/chatui/ChatBox.ts +1153 -0
- package/resources/chatui/library-dbg.js +28 -0
- package/resources/chatui/library-dbg.js.map +1 -0
- package/resources/chatui/library-preload.js +17 -0
- package/resources/chatui/library-preload.js.map +1 -0
- package/resources/chatui/library.d.ts +3 -0
- package/resources/chatui/library.d.ts.map +1 -0
- package/resources/chatui/library.js +5 -0
- package/resources/chatui/library.js.map +1 -0
- package/resources/chatui/library.ts +30 -0
- package/resources/chatui/manifest.json +52 -0
- package/resources/chatui/markdown-dbg.js +116 -0
- package/resources/chatui/markdown-dbg.js.map +1 -0
- package/resources/chatui/markdown.d.ts +21 -0
- package/resources/chatui/markdown.d.ts.map +1 -0
- package/resources/chatui/markdown.js +5 -0
- package/resources/chatui/markdown.js.map +1 -0
- package/resources/chatui/markdown.ts +140 -0
- package/resources/chatui/messagebundle.properties +20 -0
- package/resources/chatui/messagebundle_en.properties +20 -0
- package/resources/chatui/themes/base/ChatBox.less +702 -0
- package/resources/chatui/themes/base/library-RTL.css +3 -0
- package/resources/chatui/themes/base/library-parameters.json +1 -0
- package/resources/chatui/themes/base/library.css +3 -0
- package/resources/chatui/themes/base/library.source.less +4 -0
- package/resources/chatui/themes/sap_fiori_3/library-RTL.css +3 -0
- package/resources/chatui/themes/sap_fiori_3/library-parameters.json +1 -0
- package/resources/chatui/themes/sap_fiori_3/library.css +3 -0
- package/resources/chatui/themes/sap_fiori_3/library.source.less +3 -0
- package/resources/chatui/themes/sap_fiori_3_dark/library-RTL.css +3 -0
- package/resources/chatui/themes/sap_fiori_3_dark/library-parameters.json +1 -0
- package/resources/chatui/themes/sap_fiori_3_dark/library.css +3 -0
- package/resources/chatui/themes/sap_fiori_3_dark/library.source.less +3 -0
- package/resources/chatui/themes/sap_fiori_3_hcb/library-RTL.css +3 -0
- package/resources/chatui/themes/sap_fiori_3_hcb/library-parameters.json +1 -0
- package/resources/chatui/themes/sap_fiori_3_hcb/library.css +3 -0
- package/resources/chatui/themes/sap_fiori_3_hcb/library.source.less +3 -0
- package/resources/chatui/themes/sap_fiori_3_hcw/library-RTL.css +3 -0
- package/resources/chatui/themes/sap_fiori_3_hcw/library-parameters.json +1 -0
- package/resources/chatui/themes/sap_fiori_3_hcw/library.css +3 -0
- package/resources/chatui/themes/sap_fiori_3_hcw/library.source.less +3 -0
- package/ui5.yaml +15 -0
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* ${copyright}
|
|
3
|
+
*/
|
|
4
|
+
sap.ui.define(["sap/ui/core/Control", "sap/ui/core/IconPool", "sap/ui/core/Lib", "sap/base/i18n/Localization", "./markdown"], function (Control, IconPool, Lib, Localization, __markdownToHtml) {
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
function _interopRequireDefault(obj) {
|
|
8
|
+
return obj && obj.__esModule && typeof obj.default !== "undefined" ? obj.default : obj;
|
|
9
|
+
}
|
|
10
|
+
const markdownToHtml = _interopRequireDefault(__markdownToHtml);
|
|
11
|
+
/**
|
|
12
|
+
* Constructor for a new <code>chatui.ChatBox</code> control.
|
|
13
|
+
*
|
|
14
|
+
* A flexible chat UI block that can be embedded into any UI5 application.
|
|
15
|
+
* It renders a conversation sidebar, a scrollable message list, an
|
|
16
|
+
* auto-growing input textarea and a send button. The control is
|
|
17
|
+
* intentionally transport-agnostic: it fires a <code>send</code> event with
|
|
18
|
+
* the user's message and expects the embedding application to feed the
|
|
19
|
+
* assistant's reply back via {@link #addAssistantMessage} /
|
|
20
|
+
* {@link #appendToLastAssistantMessage}.
|
|
21
|
+
*
|
|
22
|
+
* The control fills the space provided by its parent. For standalone use
|
|
23
|
+
* a sensible default size is applied via CSS.
|
|
24
|
+
*
|
|
25
|
+
* @extends sap.ui.core.Control
|
|
26
|
+
*
|
|
27
|
+
* @author CONET
|
|
28
|
+
* @version 0.1.0
|
|
29
|
+
*
|
|
30
|
+
* @constructor
|
|
31
|
+
* @public
|
|
32
|
+
* @name chatui.ChatBox
|
|
33
|
+
*/
|
|
34
|
+
const ChatBox = Control.extend("chatui.ChatBox", {
|
|
35
|
+
renderer: {
|
|
36
|
+
apiVersion: 2,
|
|
37
|
+
render(rm, control) {
|
|
38
|
+
const sendIcon = IconPool.getIconInfo("sap-icon://paper-plane");
|
|
39
|
+
const newChatIcon = IconPool.getIconInfo("sap-icon://add");
|
|
40
|
+
const attachIcon = IconPool.getIconInfo("sap-icon://attachment");
|
|
41
|
+
const i18n = control._i18n();
|
|
42
|
+
const placeholder = control.getPlaceholder() || i18n.getText("chatbox.placeholder") || "";
|
|
43
|
+
const allowAttachments = control.getAllowAttachments();
|
|
44
|
+
rm.openStart("div", control);
|
|
45
|
+
rm.class("cnetChatBox");
|
|
46
|
+
rm.style("width", control.getWidth());
|
|
47
|
+
rm.style("height", control.getHeight());
|
|
48
|
+
rm.openEnd();
|
|
49
|
+
|
|
50
|
+
// ---- Sidebar ----
|
|
51
|
+
rm.openStart("aside");
|
|
52
|
+
rm.class("cnetChatBox-sidebar");
|
|
53
|
+
rm.openEnd();
|
|
54
|
+
rm.openStart("div");
|
|
55
|
+
rm.class("cnetChatBox-sidebar-header");
|
|
56
|
+
rm.openEnd();
|
|
57
|
+
rm.openStart("span");
|
|
58
|
+
rm.class("cnetChatBox-sidebar-title");
|
|
59
|
+
rm.openEnd();
|
|
60
|
+
rm.text(i18n.getText("chatbox.conversations") ?? "");
|
|
61
|
+
rm.close("span");
|
|
62
|
+
rm.close("div");
|
|
63
|
+
rm.openStart("div", control.getId() + "-conversations");
|
|
64
|
+
rm.class("cnetChatBox-conversations");
|
|
65
|
+
rm.openEnd();
|
|
66
|
+
rm.close("div");
|
|
67
|
+
rm.close("aside");
|
|
68
|
+
|
|
69
|
+
// ---- Main column ----
|
|
70
|
+
rm.openStart("div");
|
|
71
|
+
rm.class("cnetChatBox-main");
|
|
72
|
+
rm.openEnd();
|
|
73
|
+
|
|
74
|
+
// Header
|
|
75
|
+
rm.openStart("div");
|
|
76
|
+
rm.class("cnetChatBox-header");
|
|
77
|
+
rm.openEnd();
|
|
78
|
+
rm.openStart("div", control.getId() + "-headerTitle");
|
|
79
|
+
rm.class("cnetChatBox-header-title");
|
|
80
|
+
rm.openEnd();
|
|
81
|
+
rm.close("div");
|
|
82
|
+
rm.openStart("button", control.getId() + "-newChat");
|
|
83
|
+
rm.class("cnetChatBox-newChatButton");
|
|
84
|
+
rm.attr("type", "button");
|
|
85
|
+
rm.attr("title", i18n.getText("chatbox.newConversation") ?? "");
|
|
86
|
+
rm.openEnd();
|
|
87
|
+
rm.openStart("span");
|
|
88
|
+
rm.class("cnetChatBox-icon");
|
|
89
|
+
if (newChatIcon) rm.style("font-family", newChatIcon.fontFamily);
|
|
90
|
+
rm.openEnd();
|
|
91
|
+
rm.text(newChatIcon?.content ?? "+");
|
|
92
|
+
rm.close("span");
|
|
93
|
+
rm.openStart("span");
|
|
94
|
+
rm.class("cnetChatBox-newChatButton-label");
|
|
95
|
+
rm.openEnd();
|
|
96
|
+
rm.text(i18n.getText("chatbox.newConversation") ?? "");
|
|
97
|
+
rm.close("span");
|
|
98
|
+
rm.close("button");
|
|
99
|
+
rm.close("div");
|
|
100
|
+
|
|
101
|
+
// Messages
|
|
102
|
+
rm.openStart("div", control.getId() + "-messages");
|
|
103
|
+
rm.class("cnetChatBox-messages");
|
|
104
|
+
rm.openEnd();
|
|
105
|
+
rm.openStart("div");
|
|
106
|
+
rm.class("cnetChatBox-empty");
|
|
107
|
+
rm.openEnd();
|
|
108
|
+
rm.text(i18n.getText("chatbox.emptyHint") ?? "");
|
|
109
|
+
rm.close("div");
|
|
110
|
+
rm.close("div");
|
|
111
|
+
|
|
112
|
+
// Input area
|
|
113
|
+
rm.openStart("div");
|
|
114
|
+
rm.class("cnetChatBox-inputArea");
|
|
115
|
+
rm.openEnd();
|
|
116
|
+
rm.openStart("div");
|
|
117
|
+
rm.class("cnetChatBox-composer");
|
|
118
|
+
rm.openEnd();
|
|
119
|
+
rm.openStart("div", control.getId() + "-pending");
|
|
120
|
+
rm.class("cnetChatBox-attachmentsPending");
|
|
121
|
+
rm.openEnd();
|
|
122
|
+
rm.close("div");
|
|
123
|
+
rm.openStart("textarea", control.getId() + "-input");
|
|
124
|
+
rm.class("cnetChatBox-textarea");
|
|
125
|
+
rm.attr("placeholder", placeholder);
|
|
126
|
+
rm.attr("rows", "1");
|
|
127
|
+
if (control.getBusy()) rm.attr("disabled", "disabled");
|
|
128
|
+
rm.openEnd();
|
|
129
|
+
rm.close("textarea");
|
|
130
|
+
rm.openStart("div");
|
|
131
|
+
rm.class("cnetChatBox-composerBar");
|
|
132
|
+
rm.openEnd();
|
|
133
|
+
rm.openStart("div");
|
|
134
|
+
rm.class("cnetChatBox-composerBar-left");
|
|
135
|
+
rm.openEnd();
|
|
136
|
+
if (allowAttachments) {
|
|
137
|
+
rm.openStart("button", control.getId() + "-attach");
|
|
138
|
+
rm.class("cnetChatBox-toolButton");
|
|
139
|
+
rm.class("cnetChatBox-attach");
|
|
140
|
+
rm.attr("type", "button");
|
|
141
|
+
rm.attr("title", i18n.getText("chatbox.attach") ?? "");
|
|
142
|
+
rm.attr("aria-label", i18n.getText("chatbox.attach") ?? "");
|
|
143
|
+
rm.openEnd();
|
|
144
|
+
rm.openStart("span");
|
|
145
|
+
rm.class("cnetChatBox-icon");
|
|
146
|
+
if (attachIcon) rm.style("font-family", attachIcon.fontFamily);
|
|
147
|
+
rm.openEnd();
|
|
148
|
+
rm.text(attachIcon?.content ?? "📎");
|
|
149
|
+
rm.close("span");
|
|
150
|
+
rm.close("button");
|
|
151
|
+
rm.voidStart("input", control.getId() + "-fileinput");
|
|
152
|
+
rm.class("cnetChatBox-fileinput");
|
|
153
|
+
rm.attr("type", "file");
|
|
154
|
+
rm.attr("multiple", "multiple");
|
|
155
|
+
if (control.getAttachmentAccept()) rm.attr("accept", control.getAttachmentAccept());
|
|
156
|
+
rm.voidEnd();
|
|
157
|
+
}
|
|
158
|
+
rm.close("div");
|
|
159
|
+
rm.openStart("button", control.getId() + "-send");
|
|
160
|
+
rm.class("cnetChatBox-send");
|
|
161
|
+
rm.attr("type", "button");
|
|
162
|
+
rm.attr("title", i18n.getText("chatbox.send") ?? "");
|
|
163
|
+
rm.attr("aria-label", i18n.getText("chatbox.send") ?? "");
|
|
164
|
+
rm.openEnd();
|
|
165
|
+
rm.openStart("span");
|
|
166
|
+
rm.class("cnetChatBox-icon");
|
|
167
|
+
if (sendIcon) rm.style("font-family", sendIcon.fontFamily);
|
|
168
|
+
rm.openEnd();
|
|
169
|
+
rm.text(sendIcon?.content ?? "➤");
|
|
170
|
+
rm.close("span");
|
|
171
|
+
rm.close("button");
|
|
172
|
+
rm.close("div");
|
|
173
|
+
rm.close("div");
|
|
174
|
+
rm.close("div");
|
|
175
|
+
rm.close("div"); // main
|
|
176
|
+
|
|
177
|
+
if (allowAttachments) {
|
|
178
|
+
rm.openStart("div", control.getId() + "-drop");
|
|
179
|
+
rm.class("cnetChatBox-dropOverlay");
|
|
180
|
+
rm.openEnd();
|
|
181
|
+
rm.openStart("span");
|
|
182
|
+
rm.class("cnetChatBox-dropLabel");
|
|
183
|
+
rm.openEnd();
|
|
184
|
+
rm.text(i18n.getText("chatbox.dropHere") ?? "");
|
|
185
|
+
rm.close("span");
|
|
186
|
+
rm.close("div");
|
|
187
|
+
}
|
|
188
|
+
rm.close("div");
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
metadata: {
|
|
192
|
+
library: "chatui",
|
|
193
|
+
properties: {
|
|
194
|
+
/**
|
|
195
|
+
* Placeholder shown in the empty input field. If empty, a
|
|
196
|
+
* localised default is used.
|
|
197
|
+
*/
|
|
198
|
+
placeholder: {
|
|
199
|
+
type: "string",
|
|
200
|
+
group: "Appearance",
|
|
201
|
+
defaultValue: ""
|
|
202
|
+
},
|
|
203
|
+
/**
|
|
204
|
+
* When true, the input and buttons are disabled — typically set
|
|
205
|
+
* while the assistant is producing a reply.
|
|
206
|
+
*/
|
|
207
|
+
busy: {
|
|
208
|
+
type: "boolean",
|
|
209
|
+
group: "Behavior",
|
|
210
|
+
defaultValue: false
|
|
211
|
+
},
|
|
212
|
+
/**
|
|
213
|
+
* CSS width of the control. Defaults to <code>100%</code> so the
|
|
214
|
+
* chat adapts to its parent; override for a fixed size.
|
|
215
|
+
*/
|
|
216
|
+
width: {
|
|
217
|
+
type: "sap.ui.core.CSSSize",
|
|
218
|
+
group: "Dimension",
|
|
219
|
+
defaultValue: "100%"
|
|
220
|
+
},
|
|
221
|
+
/**
|
|
222
|
+
* CSS height of the control. Defaults to <code>100%</code>; falls
|
|
223
|
+
* back to a sensible min-height when the parent has no explicit
|
|
224
|
+
* height.
|
|
225
|
+
*/
|
|
226
|
+
height: {
|
|
227
|
+
type: "sap.ui.core.CSSSize",
|
|
228
|
+
group: "Dimension",
|
|
229
|
+
defaultValue: "100%"
|
|
230
|
+
},
|
|
231
|
+
/**
|
|
232
|
+
* When true (default), the user can attach files via the paperclip
|
|
233
|
+
* button and drag-and-drop. Uploaded <code>File</code> objects are
|
|
234
|
+
* handed to the consumer via the <code>files</code> parameter of
|
|
235
|
+
* the <code>send</code> event — the library never performs any
|
|
236
|
+
* network transfer itself.
|
|
237
|
+
*/
|
|
238
|
+
allowAttachments: {
|
|
239
|
+
type: "boolean",
|
|
240
|
+
group: "Behavior",
|
|
241
|
+
defaultValue: true
|
|
242
|
+
},
|
|
243
|
+
/**
|
|
244
|
+
* Value passed to the underlying <code><input type="file"></code>
|
|
245
|
+
* as its <code>accept</code> attribute (e.g. <code>"image/*"</code>
|
|
246
|
+
* or <code>".pdf,.docx"</code>). Empty string means "any file".
|
|
247
|
+
*/
|
|
248
|
+
attachmentAccept: {
|
|
249
|
+
type: "string",
|
|
250
|
+
group: "Behavior",
|
|
251
|
+
defaultValue: ""
|
|
252
|
+
},
|
|
253
|
+
/**
|
|
254
|
+
* The list of conversations shown in the sidebar. Each entry has
|
|
255
|
+
* an <code>id</code>, an optional <code>title</code>, an optional
|
|
256
|
+
* ordered array of <code>messages</code>, and an optional
|
|
257
|
+
* <code>updatedAt</code> timestamp (number, ISO string or Date).
|
|
258
|
+
* Passed in by the embedding application and never mutated by
|
|
259
|
+
* the library. When set for the first time, the library
|
|
260
|
+
* automatically selects and displays the first conversation. If
|
|
261
|
+
* the property is empty or unset, an empty new-chat view is
|
|
262
|
+
* shown.
|
|
263
|
+
*/
|
|
264
|
+
conversations: {
|
|
265
|
+
type: "object",
|
|
266
|
+
group: "Data",
|
|
267
|
+
defaultValue: []
|
|
268
|
+
},
|
|
269
|
+
/**
|
|
270
|
+
* The id of the currently displayed conversation. Empty string
|
|
271
|
+
* means "new chat draft" — no history is shown, and the next
|
|
272
|
+
* <code>send</code> event carries an empty <code>conversationId</code>
|
|
273
|
+
* so the consumer can allocate a fresh id. When set to a value
|
|
274
|
+
* that matches an entry in {@link #getConversations}, that
|
|
275
|
+
* conversation's messages are loaded into the transcript.
|
|
276
|
+
*/
|
|
277
|
+
activeConversationId: {
|
|
278
|
+
type: "string",
|
|
279
|
+
group: "Data",
|
|
280
|
+
defaultValue: ""
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
events: {
|
|
284
|
+
/**
|
|
285
|
+
* Fired when the user submits a prompt. The embedding
|
|
286
|
+
* application should produce an assistant response and feed it
|
|
287
|
+
* back via {@link #addAssistantMessage} or
|
|
288
|
+
* {@link #appendToLastAssistantMessage}.
|
|
289
|
+
*/
|
|
290
|
+
send: {
|
|
291
|
+
parameters: {
|
|
292
|
+
/** The prompt the user submitted (trimmed; empty when only files are sent). */
|
|
293
|
+
message: {
|
|
294
|
+
type: "string"
|
|
295
|
+
},
|
|
296
|
+
/** File objects the user attached; empty array if none. Not transferred by the library — the consumer is responsible for upload/transport. */
|
|
297
|
+
files: {
|
|
298
|
+
type: "object"
|
|
299
|
+
},
|
|
300
|
+
/** Id of the conversation the message belongs to. Empty string when the user is composing in a new-chat draft — the consumer typically allocates a fresh id and pushes an updated conversation list back. */
|
|
301
|
+
conversationId: {
|
|
302
|
+
type: "string"
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
/**
|
|
307
|
+
* Fired when the user clicks the "Neue Konversation" button.
|
|
308
|
+
* The transcript view has already been cleared and
|
|
309
|
+
* <code>activeConversationId</code> reset to <code>""</code> by
|
|
310
|
+
* the time the event fires — listeners typically use this to
|
|
311
|
+
* reset any server-side session state.
|
|
312
|
+
*/
|
|
313
|
+
newChat: {},
|
|
314
|
+
/**
|
|
315
|
+
* Fired when the user picks a conversation from the sidebar.
|
|
316
|
+
* The library has already switched the transcript view to that
|
|
317
|
+
* conversation and updated <code>activeConversationId</code>.
|
|
318
|
+
*/
|
|
319
|
+
conversationSelect: {
|
|
320
|
+
parameters: {
|
|
321
|
+
/** Id of the conversation the user selected. */
|
|
322
|
+
id: {
|
|
323
|
+
type: "string"
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
/**
|
|
328
|
+
* Fired when the user clicks the × on a conversation in the
|
|
329
|
+
* sidebar. The library does not mutate the conversations
|
|
330
|
+
* property itself — the consumer is expected to drop the entry
|
|
331
|
+
* from its store and push an updated list back via
|
|
332
|
+
* {@link #setConversations}.
|
|
333
|
+
*/
|
|
334
|
+
conversationDelete: {
|
|
335
|
+
parameters: {
|
|
336
|
+
/** Id of the conversation the user asked to delete. */
|
|
337
|
+
id: {
|
|
338
|
+
type: "string"
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
constructor: function _constructor(id, settings) {
|
|
345
|
+
Control.prototype.constructor.call(this, id, settings);
|
|
346
|
+
this._messages = [];
|
|
347
|
+
this._pendingAttachments = [];
|
|
348
|
+
this._conversations = [];
|
|
349
|
+
this._initialised = false;
|
|
350
|
+
this._onLocaleChange = () => {
|
|
351
|
+
this.invalidate();
|
|
352
|
+
};
|
|
353
|
+
},
|
|
354
|
+
init: function _init() {
|
|
355
|
+
Localization.attachChange(this._onLocaleChange);
|
|
356
|
+
},
|
|
357
|
+
exit: function _exit() {
|
|
358
|
+
Localization.detachChange(this._onLocaleChange);
|
|
359
|
+
},
|
|
360
|
+
onAfterRendering: function _onAfterRendering() {
|
|
361
|
+
const textarea = this._dom("input");
|
|
362
|
+
const sendBtn = this._dom("send");
|
|
363
|
+
const newChatBtn = this._dom("newChat");
|
|
364
|
+
textarea?.addEventListener("input", () => {
|
|
365
|
+
textarea.style.height = "auto";
|
|
366
|
+
textarea.style.height = Math.min(textarea.scrollHeight, 160) + "px";
|
|
367
|
+
});
|
|
368
|
+
textarea?.addEventListener("keydown", e => {
|
|
369
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
370
|
+
e.preventDefault();
|
|
371
|
+
this._submit();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
sendBtn?.addEventListener("click", () => this._submit());
|
|
375
|
+
newChatBtn?.addEventListener("click", () => this._startNewChat());
|
|
376
|
+
if (this.getAllowAttachments()) {
|
|
377
|
+
this._wireAttachments();
|
|
378
|
+
}
|
|
379
|
+
this._renderSidebar();
|
|
380
|
+
this._renderHeaderTitle();
|
|
381
|
+
this._restoreMessages();
|
|
382
|
+
this._renderPending();
|
|
383
|
+
},
|
|
384
|
+
/**
|
|
385
|
+
* Append an assistant message to the transcript. Pass an empty string
|
|
386
|
+
* to open a "pending" bubble that can then be filled incrementally via
|
|
387
|
+
* {@link #appendToLastAssistantMessage}.
|
|
388
|
+
*
|
|
389
|
+
* @public
|
|
390
|
+
*/
|
|
391
|
+
addAssistantMessage: function _addAssistantMessage(text) {
|
|
392
|
+
this._messages.push({
|
|
393
|
+
role: "assistant",
|
|
394
|
+
content: text ?? ""
|
|
395
|
+
});
|
|
396
|
+
this._appendMessageDom(this._messages.length - 1);
|
|
397
|
+
return this;
|
|
398
|
+
},
|
|
399
|
+
/**
|
|
400
|
+
* Append a chunk to the last assistant message. Useful when streaming
|
|
401
|
+
* tokens from a language model. If the last message is not a plain
|
|
402
|
+
* assistant text message, a new one is started.
|
|
403
|
+
*
|
|
404
|
+
* @public
|
|
405
|
+
*/
|
|
406
|
+
appendToLastAssistantMessage: function _appendToLastAssistantMessage(chunk) {
|
|
407
|
+
if (!chunk) return this;
|
|
408
|
+
const last = this._messages[this._messages.length - 1];
|
|
409
|
+
if (!last || last.role !== "assistant" || last.control) {
|
|
410
|
+
return this.addAssistantMessage(chunk);
|
|
411
|
+
}
|
|
412
|
+
last.content += chunk;
|
|
413
|
+
this._updateBubble(this._messages.length - 1);
|
|
414
|
+
return this;
|
|
415
|
+
},
|
|
416
|
+
/**
|
|
417
|
+
* Attach an arbitrary UI5 control as a stand-alone assistant bubble
|
|
418
|
+
* (e.g. a <code>sap.viz.ui5.controls.VizFrame</code>, an
|
|
419
|
+
* <code>sap.ui.integration.widgets.Card</code>, or any custom
|
|
420
|
+
* control). The library owns the control's lifecycle from here on —
|
|
421
|
+
* it is destroyed together with the ChatBox as well as on
|
|
422
|
+
* {@link #clear}, {@link #setMessages}, or the user-driven new-chat
|
|
423
|
+
* action. Do not add the same control twice.
|
|
424
|
+
*
|
|
425
|
+
* @public
|
|
426
|
+
*/
|
|
427
|
+
addAssistantControl: function _addAssistantControl(control) {
|
|
428
|
+
this.addDependent(control);
|
|
429
|
+
this._messages.push({
|
|
430
|
+
role: "assistant",
|
|
431
|
+
content: "",
|
|
432
|
+
control
|
|
433
|
+
});
|
|
434
|
+
this._appendMessageDom(this._messages.length - 1);
|
|
435
|
+
return this;
|
|
436
|
+
},
|
|
437
|
+
/**
|
|
438
|
+
* Remove all messages from the transcript. Does not fire
|
|
439
|
+
* <code>newChat</code> — that event is reserved for the user-driven
|
|
440
|
+
* clear action. Does not touch the conversations list.
|
|
441
|
+
*
|
|
442
|
+
* @public
|
|
443
|
+
*/
|
|
444
|
+
clear: function _clear() {
|
|
445
|
+
this._messages = [];
|
|
446
|
+
this._pendingAttachments = [];
|
|
447
|
+
this.destroyDependents();
|
|
448
|
+
this._restoreMessages();
|
|
449
|
+
this._renderPending();
|
|
450
|
+
return this;
|
|
451
|
+
},
|
|
452
|
+
/**
|
|
453
|
+
* Read-only snapshot of the current transcript. Intended for
|
|
454
|
+
* persistence — do not mutate.
|
|
455
|
+
*
|
|
456
|
+
* @public
|
|
457
|
+
*/
|
|
458
|
+
getMessages: function _getMessages() {
|
|
459
|
+
return this._messages.slice();
|
|
460
|
+
},
|
|
461
|
+
/**
|
|
462
|
+
* Replace the transcript with the given messages. Intended as a
|
|
463
|
+
* restore path — the consumer stores {@link #getMessages} somewhere
|
|
464
|
+
* (localStorage, backend, …) and hands it back after a reload. Does
|
|
465
|
+
* not fire <code>newChat</code> or <code>send</code>. Pending
|
|
466
|
+
* attachments (a possibly ongoing draft) are left untouched. Invalid
|
|
467
|
+
* entries (missing/unknown <code>role</code> or non-string
|
|
468
|
+
* <code>content</code>) are silently skipped.
|
|
469
|
+
*
|
|
470
|
+
* @public
|
|
471
|
+
*/
|
|
472
|
+
setMessages: function _setMessages(messages) {
|
|
473
|
+
this._messages = this._sanitiseMessages(messages);
|
|
474
|
+
this.destroyDependents();
|
|
475
|
+
this._restoreMessages();
|
|
476
|
+
return this;
|
|
477
|
+
},
|
|
478
|
+
/**
|
|
479
|
+
* Replace the conversations list shown in the sidebar. On the first
|
|
480
|
+
* call with a non-empty list, the first entry is automatically
|
|
481
|
+
* selected and its messages are loaded into the transcript. Later
|
|
482
|
+
* calls only update the sidebar — call {@link #setActiveConversationId}
|
|
483
|
+
* to switch the visible transcript.
|
|
484
|
+
*
|
|
485
|
+
* @public
|
|
486
|
+
*/
|
|
487
|
+
setConversations: function _setConversations(conversations) {
|
|
488
|
+
const list = this._sanitiseConversations(conversations);
|
|
489
|
+
this._conversations = list;
|
|
490
|
+
this.setProperty("conversations", list, true);
|
|
491
|
+
if (!this._initialised && list.length > 0 && !this.getActiveConversationId()) {
|
|
492
|
+
const first = list[0];
|
|
493
|
+
this.setProperty("activeConversationId", first.id, true);
|
|
494
|
+
this._messages = this._sanitiseMessages(first.messages ?? []);
|
|
495
|
+
this.destroyDependents();
|
|
496
|
+
this._restoreMessages();
|
|
497
|
+
this._renderHeaderTitle();
|
|
498
|
+
}
|
|
499
|
+
this._initialised = true;
|
|
500
|
+
this._renderSidebar();
|
|
501
|
+
return this;
|
|
502
|
+
},
|
|
503
|
+
/**
|
|
504
|
+
* Read-only snapshot of the conversations currently shown in the
|
|
505
|
+
* sidebar.
|
|
506
|
+
*
|
|
507
|
+
* @public
|
|
508
|
+
*/
|
|
509
|
+
getConversations: function _getConversations() {
|
|
510
|
+
return this._conversations.slice();
|
|
511
|
+
},
|
|
512
|
+
/**
|
|
513
|
+
* Switch the transcript view to the given conversation. If the id
|
|
514
|
+
* does not match any conversation (including the empty string), the
|
|
515
|
+
* transcript is cleared — that is the "new chat draft" state.
|
|
516
|
+
*
|
|
517
|
+
* @public
|
|
518
|
+
*/
|
|
519
|
+
setActiveConversationId: function _setActiveConversationId(id) {
|
|
520
|
+
const next = typeof id === "string" ? id : "";
|
|
521
|
+
if (next === this.getActiveConversationId()) return this;
|
|
522
|
+
this.setProperty("activeConversationId", next, true);
|
|
523
|
+
const list = this._conversations ?? [];
|
|
524
|
+
const conv = list.find(c => c.id === next);
|
|
525
|
+
this._messages = conv ? this._sanitiseMessages(conv.messages ?? []) : [];
|
|
526
|
+
this._pendingAttachments = [];
|
|
527
|
+
this.destroyDependents();
|
|
528
|
+
this._renderSidebar();
|
|
529
|
+
this._renderHeaderTitle();
|
|
530
|
+
this._restoreMessages();
|
|
531
|
+
this._renderPending();
|
|
532
|
+
return this;
|
|
533
|
+
},
|
|
534
|
+
_i18n: function _i18n() {
|
|
535
|
+
return Lib.getResourceBundleFor("chatui");
|
|
536
|
+
},
|
|
537
|
+
_dom: function _dom(suffix) {
|
|
538
|
+
return document.getElementById(this.getId() + "-" + suffix);
|
|
539
|
+
},
|
|
540
|
+
_messagesContainer: function _messagesContainer() {
|
|
541
|
+
return this._dom("messages");
|
|
542
|
+
},
|
|
543
|
+
_sanitiseMessages: function _sanitiseMessages(messages) {
|
|
544
|
+
const clean = [];
|
|
545
|
+
if (!Array.isArray(messages)) return clean;
|
|
546
|
+
for (const raw of messages) {
|
|
547
|
+
if (!raw || raw.role !== "user" && raw.role !== "assistant") continue;
|
|
548
|
+
if (typeof raw.content !== "string") continue;
|
|
549
|
+
if (raw.control) continue;
|
|
550
|
+
const msg = {
|
|
551
|
+
role: raw.role,
|
|
552
|
+
content: raw.content
|
|
553
|
+
};
|
|
554
|
+
if (Array.isArray(raw.files) && raw.files.length > 0) {
|
|
555
|
+
msg.files = raw.files.slice();
|
|
556
|
+
}
|
|
557
|
+
clean.push(msg);
|
|
558
|
+
}
|
|
559
|
+
return clean;
|
|
560
|
+
},
|
|
561
|
+
_sanitiseConversations: function _sanitiseConversations(conversations) {
|
|
562
|
+
const clean = [];
|
|
563
|
+
if (!Array.isArray(conversations)) return clean;
|
|
564
|
+
for (const raw of conversations) {
|
|
565
|
+
if (!raw || typeof raw.id !== "string" || !raw.id) continue;
|
|
566
|
+
const conv = {
|
|
567
|
+
id: raw.id
|
|
568
|
+
};
|
|
569
|
+
if (typeof raw.title === "string") conv.title = raw.title;
|
|
570
|
+
if (Array.isArray(raw.messages)) conv.messages = this._sanitiseMessages(raw.messages);
|
|
571
|
+
if (raw.updatedAt !== undefined && raw.updatedAt !== null) conv.updatedAt = raw.updatedAt;
|
|
572
|
+
clean.push(conv);
|
|
573
|
+
}
|
|
574
|
+
return clean;
|
|
575
|
+
},
|
|
576
|
+
_submit: function _submit() {
|
|
577
|
+
if (this.getBusy()) return;
|
|
578
|
+
const textarea = this._dom("input");
|
|
579
|
+
const text = textarea?.value.trim() ?? "";
|
|
580
|
+
const files = this._pendingAttachments.slice();
|
|
581
|
+
if (!text && files.length === 0) return;
|
|
582
|
+
if (textarea) {
|
|
583
|
+
textarea.value = "";
|
|
584
|
+
textarea.style.height = "auto";
|
|
585
|
+
}
|
|
586
|
+
const message = {
|
|
587
|
+
role: "user",
|
|
588
|
+
content: text
|
|
589
|
+
};
|
|
590
|
+
if (files.length > 0) message.files = files;
|
|
591
|
+
this._messages.push(message);
|
|
592
|
+
this._pendingAttachments = [];
|
|
593
|
+
this._appendMessageDom(this._messages.length - 1);
|
|
594
|
+
this._renderPending();
|
|
595
|
+
this.fireEvent("send", {
|
|
596
|
+
message: text,
|
|
597
|
+
files,
|
|
598
|
+
conversationId: this.getActiveConversationId()
|
|
599
|
+
});
|
|
600
|
+
},
|
|
601
|
+
_startNewChat: function _startNewChat() {
|
|
602
|
+
if (this.getBusy()) return;
|
|
603
|
+
this._messages = [];
|
|
604
|
+
this._pendingAttachments = [];
|
|
605
|
+
this.destroyDependents();
|
|
606
|
+
this.setProperty("activeConversationId", "", true);
|
|
607
|
+
this._renderSidebar();
|
|
608
|
+
this._renderHeaderTitle();
|
|
609
|
+
this._restoreMessages();
|
|
610
|
+
this._renderPending();
|
|
611
|
+
this.fireEvent("newChat");
|
|
612
|
+
},
|
|
613
|
+
_selectConversation: function _selectConversation(id) {
|
|
614
|
+
if (this.getBusy()) return;
|
|
615
|
+
if (id === this.getActiveConversationId()) return;
|
|
616
|
+
this.setActiveConversationId(id);
|
|
617
|
+
this.fireEvent("conversationSelect", {
|
|
618
|
+
id
|
|
619
|
+
});
|
|
620
|
+
},
|
|
621
|
+
_requestDeleteConversation: function _requestDeleteConversation(id) {
|
|
622
|
+
if (this.getBusy()) return;
|
|
623
|
+
this.fireEvent("conversationDelete", {
|
|
624
|
+
id
|
|
625
|
+
});
|
|
626
|
+
},
|
|
627
|
+
_buildMessageDom: function _buildMessageDom(index) {
|
|
628
|
+
const msg = this._messages[index];
|
|
629
|
+
const div = document.createElement("div");
|
|
630
|
+
const isAssistant = msg.role === "assistant";
|
|
631
|
+
div.className = `cnetChatBox-message cnetChatBox-message--${isAssistant ? "assistant" : "user"}`;
|
|
632
|
+
const bubble = document.createElement("div");
|
|
633
|
+
bubble.className = "cnetChatBox-bubble";
|
|
634
|
+
this._renderBubble(bubble, msg);
|
|
635
|
+
div.appendChild(bubble);
|
|
636
|
+
if (!msg.control) {
|
|
637
|
+
const actions = this._buildActions(index, msg);
|
|
638
|
+
if (actions) div.appendChild(actions);
|
|
639
|
+
}
|
|
640
|
+
return div;
|
|
641
|
+
},
|
|
642
|
+
_buildActions: function _buildActions(index, msg) {
|
|
643
|
+
const actions = document.createElement("div");
|
|
644
|
+
actions.className = "cnetChatBox-actions";
|
|
645
|
+
if (msg.role === "assistant") {
|
|
646
|
+
actions.appendChild(this._buildCopyButton(index));
|
|
647
|
+
} else {
|
|
648
|
+
actions.appendChild(this._buildEditButton(index));
|
|
649
|
+
}
|
|
650
|
+
return actions;
|
|
651
|
+
},
|
|
652
|
+
_buildIconButton: function _buildIconButton(iconName, className, label, fallback) {
|
|
653
|
+
const icon = IconPool.getIconInfo(iconName);
|
|
654
|
+
const btn = document.createElement("button");
|
|
655
|
+
btn.type = "button";
|
|
656
|
+
btn.className = `cnetChatBox-action ${className}`;
|
|
657
|
+
btn.title = label;
|
|
658
|
+
btn.setAttribute("aria-label", label);
|
|
659
|
+
const iconSpan = document.createElement("span");
|
|
660
|
+
iconSpan.className = "cnetChatBox-icon";
|
|
661
|
+
if (icon) iconSpan.style.fontFamily = icon.fontFamily;
|
|
662
|
+
iconSpan.textContent = icon?.content ?? fallback;
|
|
663
|
+
btn.appendChild(iconSpan);
|
|
664
|
+
return btn;
|
|
665
|
+
},
|
|
666
|
+
_buildCopyButton: function _buildCopyButton(index) {
|
|
667
|
+
const label = this._i18n().getText("chatbox.copy") ?? "Copy";
|
|
668
|
+
const btn = this._buildIconButton("sap-icon://copy", "cnetChatBox-copy", label, "⎘");
|
|
669
|
+
btn.addEventListener("click", () => {
|
|
670
|
+
const msg = this._messages[index];
|
|
671
|
+
if (!msg || !msg.content) return;
|
|
672
|
+
void this._copyMessage(msg.content, btn);
|
|
673
|
+
});
|
|
674
|
+
return btn;
|
|
675
|
+
},
|
|
676
|
+
_buildEditButton: function _buildEditButton(index) {
|
|
677
|
+
const label = this._i18n().getText("chatbox.edit") ?? "Edit";
|
|
678
|
+
const btn = this._buildIconButton("sap-icon://edit", "cnetChatBox-edit", label, "✎");
|
|
679
|
+
btn.addEventListener("click", () => this._enterEditMode(index));
|
|
680
|
+
return btn;
|
|
681
|
+
},
|
|
682
|
+
_copyMessage: async function _copyMessage(text, btn) {
|
|
683
|
+
const ok = await this._writeClipboard(text);
|
|
684
|
+
if (!ok) return;
|
|
685
|
+
const i18n = this._i18n();
|
|
686
|
+
const original = btn.getAttribute("aria-label") ?? "";
|
|
687
|
+
const done = i18n.getText("chatbox.copied") ?? "Copied";
|
|
688
|
+
btn.classList.add("cnetChatBox-action--done");
|
|
689
|
+
btn.title = done;
|
|
690
|
+
window.setTimeout(() => {
|
|
691
|
+
btn.classList.remove("cnetChatBox-action--done");
|
|
692
|
+
btn.title = original;
|
|
693
|
+
}, 1200);
|
|
694
|
+
},
|
|
695
|
+
_enterEditMode: function _enterEditMode(index) {
|
|
696
|
+
if (this.getBusy()) return;
|
|
697
|
+
const wrapper = this._messagesContainer()?.children[index];
|
|
698
|
+
const msg = this._messages[index];
|
|
699
|
+
if (!wrapper || !msg || msg.role !== "user") return;
|
|
700
|
+
const bubble = wrapper.querySelector(".cnetChatBox-bubble");
|
|
701
|
+
if (!bubble) return;
|
|
702
|
+
const actionsRow = wrapper.querySelector(".cnetChatBox-actions");
|
|
703
|
+
if (actionsRow) actionsRow.style.display = "none";
|
|
704
|
+
bubble.textContent = "";
|
|
705
|
+
bubble.classList.add("cnetChatBox-bubble--editing");
|
|
706
|
+
if (msg.files && msg.files.length > 0) {
|
|
707
|
+
bubble.appendChild(this._buildAttachmentList(msg.files, "sent"));
|
|
708
|
+
}
|
|
709
|
+
const editor = document.createElement("textarea");
|
|
710
|
+
editor.className = "cnetChatBox-editTextarea";
|
|
711
|
+
editor.value = msg.content;
|
|
712
|
+
editor.rows = 2;
|
|
713
|
+
bubble.appendChild(editor);
|
|
714
|
+
const editBar = document.createElement("div");
|
|
715
|
+
editBar.className = "cnetChatBox-editActions";
|
|
716
|
+
const i18n = this._i18n();
|
|
717
|
+
const cancelBtn = document.createElement("button");
|
|
718
|
+
cancelBtn.type = "button";
|
|
719
|
+
cancelBtn.className = "cnetChatBox-editCancel";
|
|
720
|
+
cancelBtn.textContent = i18n.getText("chatbox.cancel") ?? "Cancel";
|
|
721
|
+
const saveBtn = document.createElement("button");
|
|
722
|
+
saveBtn.type = "button";
|
|
723
|
+
saveBtn.className = "cnetChatBox-editSave";
|
|
724
|
+
saveBtn.textContent = i18n.getText("chatbox.save") ?? "Save";
|
|
725
|
+
cancelBtn.addEventListener("click", () => this._cancelEdit(index));
|
|
726
|
+
saveBtn.addEventListener("click", () => this._commitEdit(index, editor.value));
|
|
727
|
+
editor.addEventListener("keydown", e => {
|
|
728
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
729
|
+
e.preventDefault();
|
|
730
|
+
this._commitEdit(index, editor.value);
|
|
731
|
+
} else if (e.key === "Escape") {
|
|
732
|
+
e.preventDefault();
|
|
733
|
+
this._cancelEdit(index);
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
editBar.appendChild(cancelBtn);
|
|
737
|
+
editBar.appendChild(saveBtn);
|
|
738
|
+
bubble.appendChild(editBar);
|
|
739
|
+
editor.focus();
|
|
740
|
+
editor.setSelectionRange(editor.value.length, editor.value.length);
|
|
741
|
+
},
|
|
742
|
+
_cancelEdit: function _cancelEdit(index) {
|
|
743
|
+
const wrapper = this._messagesContainer()?.children[index];
|
|
744
|
+
const msg = this._messages[index];
|
|
745
|
+
if (!wrapper || !msg) return;
|
|
746
|
+
const bubble = wrapper.querySelector(".cnetChatBox-bubble");
|
|
747
|
+
if (!bubble) return;
|
|
748
|
+
bubble.classList.remove("cnetChatBox-bubble--editing");
|
|
749
|
+
this._renderBubble(bubble, msg);
|
|
750
|
+
const actionsRow = wrapper.querySelector(".cnetChatBox-actions");
|
|
751
|
+
if (actionsRow) actionsRow.style.display = "";
|
|
752
|
+
},
|
|
753
|
+
_commitEdit: function _commitEdit(index, newText) {
|
|
754
|
+
if (this.getBusy()) return;
|
|
755
|
+
const msg = this._messages[index];
|
|
756
|
+
if (!msg || msg.role !== "user") return;
|
|
757
|
+
const trimmed = newText.trim();
|
|
758
|
+
const files = msg.files ? msg.files.slice() : [];
|
|
759
|
+
const unchanged = trimmed === msg.content;
|
|
760
|
+
const empty = !trimmed && files.length === 0;
|
|
761
|
+
if (unchanged || empty) {
|
|
762
|
+
this._cancelEdit(index);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
for (let i = index + 1; i < this._messages.length; i++) {
|
|
766
|
+
const later = this._messages[i];
|
|
767
|
+
if (later.control) later.control.destroy();
|
|
768
|
+
}
|
|
769
|
+
this._messages.length = index + 1;
|
|
770
|
+
msg.content = trimmed;
|
|
771
|
+
this._restoreMessages();
|
|
772
|
+
this.fireEvent("send", {
|
|
773
|
+
message: trimmed,
|
|
774
|
+
files,
|
|
775
|
+
conversationId: this.getActiveConversationId()
|
|
776
|
+
});
|
|
777
|
+
},
|
|
778
|
+
_writeClipboard: async function _writeClipboard(text) {
|
|
779
|
+
try {
|
|
780
|
+
if (navigator.clipboard?.writeText) {
|
|
781
|
+
await navigator.clipboard.writeText(text);
|
|
782
|
+
return true;
|
|
783
|
+
}
|
|
784
|
+
} catch {
|
|
785
|
+
// fall through to legacy path
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
const ta = document.createElement("textarea");
|
|
789
|
+
ta.value = text;
|
|
790
|
+
ta.setAttribute("readonly", "");
|
|
791
|
+
ta.style.position = "fixed";
|
|
792
|
+
ta.style.opacity = "0";
|
|
793
|
+
document.body.appendChild(ta);
|
|
794
|
+
ta.select();
|
|
795
|
+
const ok = document.execCommand("copy");
|
|
796
|
+
document.body.removeChild(ta);
|
|
797
|
+
return ok;
|
|
798
|
+
} catch {
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
},
|
|
802
|
+
_renderBubble: function _renderBubble(bubble, msg) {
|
|
803
|
+
if (msg.role === "assistant") {
|
|
804
|
+
if (msg.control) {
|
|
805
|
+
bubble.classList.remove("cnetChatBox-bubble--pending");
|
|
806
|
+
bubble.classList.add("cnetChatBox-bubble--rich");
|
|
807
|
+
bubble.innerHTML = "";
|
|
808
|
+
msg.control.placeAt(bubble);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
bubble.classList.remove("cnetChatBox-bubble--rich");
|
|
812
|
+
if (!msg.content) {
|
|
813
|
+
bubble.classList.add("cnetChatBox-bubble--pending");
|
|
814
|
+
bubble.innerHTML = '<span class="cnetChatBox-dots"><span></span><span></span><span></span></span>';
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
bubble.classList.remove("cnetChatBox-bubble--pending");
|
|
818
|
+
bubble.innerHTML = markdownToHtml(msg.content);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
bubble.textContent = "";
|
|
822
|
+
if (msg.files && msg.files.length > 0) {
|
|
823
|
+
bubble.appendChild(this._buildAttachmentList(msg.files, "sent"));
|
|
824
|
+
}
|
|
825
|
+
if (msg.content) {
|
|
826
|
+
const text = document.createElement("div");
|
|
827
|
+
text.className = "cnetChatBox-bubble-text";
|
|
828
|
+
text.textContent = msg.content;
|
|
829
|
+
bubble.appendChild(text);
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
_buildAttachmentList: function _buildAttachmentList(files, variant) {
|
|
833
|
+
const wrap = document.createElement("div");
|
|
834
|
+
wrap.className = `cnetChatBox-attachments cnetChatBox-attachments--${variant}`;
|
|
835
|
+
for (let i = 0; i < files.length; i++) {
|
|
836
|
+
wrap.appendChild(this._buildAttachmentItem(files[i], variant === "pending" ? i : -1));
|
|
837
|
+
}
|
|
838
|
+
return wrap;
|
|
839
|
+
},
|
|
840
|
+
_buildAttachmentItem: function _buildAttachmentItem(file, pendingIndex) {
|
|
841
|
+
const isImage = file.type.startsWith("image/");
|
|
842
|
+
const item = document.createElement("div");
|
|
843
|
+
item.className = `cnetChatBox-attachment cnetChatBox-attachment--${isImage ? "image" : "file"}`;
|
|
844
|
+
if (isImage) {
|
|
845
|
+
const img = document.createElement("img");
|
|
846
|
+
img.className = "cnetChatBox-attachment-thumb";
|
|
847
|
+
img.alt = file.name;
|
|
848
|
+
img.src = URL.createObjectURL(file);
|
|
849
|
+
item.appendChild(img);
|
|
850
|
+
} else {
|
|
851
|
+
const iconInfo = IconPool.getIconInfo("sap-icon://document");
|
|
852
|
+
const iconSpan = document.createElement("span");
|
|
853
|
+
iconSpan.className = "cnetChatBox-icon cnetChatBox-attachment-icon";
|
|
854
|
+
if (iconInfo) iconSpan.style.fontFamily = iconInfo.fontFamily;
|
|
855
|
+
iconSpan.textContent = iconInfo?.content ?? "📄";
|
|
856
|
+
item.appendChild(iconSpan);
|
|
857
|
+
}
|
|
858
|
+
const name = document.createElement("span");
|
|
859
|
+
name.className = "cnetChatBox-attachment-name";
|
|
860
|
+
name.textContent = file.name;
|
|
861
|
+
name.title = file.name;
|
|
862
|
+
item.appendChild(name);
|
|
863
|
+
if (pendingIndex >= 0) {
|
|
864
|
+
const i18n = this._i18n();
|
|
865
|
+
const removeLabel = i18n.getText("chatbox.removeAttachment") ?? "Remove";
|
|
866
|
+
const btn = document.createElement("button");
|
|
867
|
+
btn.type = "button";
|
|
868
|
+
btn.className = "cnetChatBox-attachment-remove";
|
|
869
|
+
btn.title = removeLabel;
|
|
870
|
+
btn.setAttribute("aria-label", removeLabel);
|
|
871
|
+
btn.textContent = "×";
|
|
872
|
+
btn.addEventListener("click", () => this._removePending(pendingIndex));
|
|
873
|
+
item.appendChild(btn);
|
|
874
|
+
}
|
|
875
|
+
return item;
|
|
876
|
+
},
|
|
877
|
+
_appendMessageDom: function _appendMessageDom(index) {
|
|
878
|
+
const container = this._messagesContainer();
|
|
879
|
+
if (!container) return;
|
|
880
|
+
container.querySelector(".cnetChatBox-empty")?.remove();
|
|
881
|
+
container.appendChild(this._buildMessageDom(index));
|
|
882
|
+
container.scrollTop = container.scrollHeight;
|
|
883
|
+
},
|
|
884
|
+
_updateBubble: function _updateBubble(index) {
|
|
885
|
+
const container = this._messagesContainer();
|
|
886
|
+
const wrapper = container?.children[index];
|
|
887
|
+
const bubble = wrapper?.querySelector(".cnetChatBox-bubble");
|
|
888
|
+
if (!bubble || !container) return;
|
|
889
|
+
this._renderBubble(bubble, this._messages[index]);
|
|
890
|
+
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80;
|
|
891
|
+
if (atBottom) container.scrollTop = container.scrollHeight;
|
|
892
|
+
},
|
|
893
|
+
_restoreMessages: function _restoreMessages() {
|
|
894
|
+
const container = this._messagesContainer();
|
|
895
|
+
if (!container) return;
|
|
896
|
+
container.innerHTML = "";
|
|
897
|
+
if (this._messages.length === 0) {
|
|
898
|
+
const hint = document.createElement("div");
|
|
899
|
+
hint.className = "cnetChatBox-empty";
|
|
900
|
+
hint.textContent = this._i18n().getText("chatbox.emptyHint") ?? "";
|
|
901
|
+
container.appendChild(hint);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
for (let i = 0; i < this._messages.length; i++) {
|
|
905
|
+
container.appendChild(this._buildMessageDom(i));
|
|
906
|
+
}
|
|
907
|
+
container.scrollTop = container.scrollHeight;
|
|
908
|
+
},
|
|
909
|
+
_renderSidebar: function _renderSidebar() {
|
|
910
|
+
const container = this._dom("conversations");
|
|
911
|
+
if (!container) return;
|
|
912
|
+
container.innerHTML = "";
|
|
913
|
+
if (this._conversations.length === 0) {
|
|
914
|
+
const empty = document.createElement("div");
|
|
915
|
+
empty.className = "cnetChatBox-conversations-empty";
|
|
916
|
+
empty.textContent = this._i18n().getText("chatbox.noConversations") ?? "";
|
|
917
|
+
container.appendChild(empty);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const active = this.getActiveConversationId();
|
|
921
|
+
for (const conv of this._conversations) {
|
|
922
|
+
container.appendChild(this._buildConversationItem(conv, conv.id === active));
|
|
923
|
+
}
|
|
924
|
+
},
|
|
925
|
+
_buildConversationItem: function _buildConversationItem(conv, isActive) {
|
|
926
|
+
const i18n = this._i18n();
|
|
927
|
+
const item = document.createElement("div");
|
|
928
|
+
item.className = "cnetChatBox-conversation" + (isActive ? " cnetChatBox-conversation--active" : "");
|
|
929
|
+
item.setAttribute("role", "button");
|
|
930
|
+
item.tabIndex = 0;
|
|
931
|
+
const body = document.createElement("div");
|
|
932
|
+
body.className = "cnetChatBox-conversation-body";
|
|
933
|
+
const title = document.createElement("div");
|
|
934
|
+
title.className = "cnetChatBox-conversation-title";
|
|
935
|
+
title.textContent = this._conversationTitle(conv);
|
|
936
|
+
body.appendChild(title);
|
|
937
|
+
const snippet = document.createElement("div");
|
|
938
|
+
snippet.className = "cnetChatBox-conversation-snippet";
|
|
939
|
+
snippet.textContent = this._conversationSnippet(conv);
|
|
940
|
+
body.appendChild(snippet);
|
|
941
|
+
const time = document.createElement("div");
|
|
942
|
+
time.className = "cnetChatBox-conversation-time";
|
|
943
|
+
time.textContent = this._formatTimestamp(conv.updatedAt);
|
|
944
|
+
body.appendChild(time);
|
|
945
|
+
item.appendChild(body);
|
|
946
|
+
const removeLabel = i18n.getText("chatbox.deleteConversation") ?? "Delete conversation";
|
|
947
|
+
const del = document.createElement("button");
|
|
948
|
+
del.type = "button";
|
|
949
|
+
del.className = "cnetChatBox-conversation-remove";
|
|
950
|
+
del.title = removeLabel;
|
|
951
|
+
del.setAttribute("aria-label", removeLabel);
|
|
952
|
+
del.textContent = "×";
|
|
953
|
+
del.addEventListener("click", e => {
|
|
954
|
+
e.stopPropagation();
|
|
955
|
+
this._requestDeleteConversation(conv.id);
|
|
956
|
+
});
|
|
957
|
+
item.appendChild(del);
|
|
958
|
+
item.addEventListener("click", () => this._selectConversation(conv.id));
|
|
959
|
+
item.addEventListener("keydown", e => {
|
|
960
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
961
|
+
e.preventDefault();
|
|
962
|
+
this._selectConversation(conv.id);
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
return item;
|
|
966
|
+
},
|
|
967
|
+
_conversationTitle: function _conversationTitle(conv) {
|
|
968
|
+
if (conv.title && conv.title.trim()) return conv.title.trim();
|
|
969
|
+
const msgs = conv.messages ?? [];
|
|
970
|
+
for (const m of msgs) {
|
|
971
|
+
if (m.role === "user" && m.content && m.content.trim()) {
|
|
972
|
+
return this._truncate(m.content.trim(), 40);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return this._i18n().getText("chatbox.untitledConversation") ?? "Neue Konversation";
|
|
976
|
+
},
|
|
977
|
+
_conversationSnippet: function _conversationSnippet(conv) {
|
|
978
|
+
const msgs = conv.messages ?? [];
|
|
979
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
980
|
+
const m = msgs[i];
|
|
981
|
+
if (m.content && m.content.trim()) {
|
|
982
|
+
const prefix = m.role === "user" ? (this._i18n().getText("chatbox.youPrefix") ?? "Du:") + " " : "";
|
|
983
|
+
return this._truncate(prefix + m.content.trim().replace(/\s+/g, " "), 60);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return "";
|
|
987
|
+
},
|
|
988
|
+
_truncate: function _truncate(text, max) {
|
|
989
|
+
return text.length > max ? text.slice(0, max - 1).trimEnd() + "…" : text;
|
|
990
|
+
},
|
|
991
|
+
_formatTimestamp: function _formatTimestamp(raw) {
|
|
992
|
+
if (raw === undefined || raw === null) return "";
|
|
993
|
+
const d = raw instanceof Date ? raw : new Date(raw);
|
|
994
|
+
if (isNaN(d.getTime())) return "";
|
|
995
|
+
const now = new Date();
|
|
996
|
+
const sameDay = d.toDateString() === now.toDateString();
|
|
997
|
+
const yesterday = new Date(now);
|
|
998
|
+
yesterday.setDate(now.getDate() - 1);
|
|
999
|
+
const wasYesterday = d.toDateString() === yesterday.toDateString();
|
|
1000
|
+
const locale = Localization.getLanguage() || undefined;
|
|
1001
|
+
if (sameDay) {
|
|
1002
|
+
return d.toLocaleTimeString(locale, {
|
|
1003
|
+
hour: "2-digit",
|
|
1004
|
+
minute: "2-digit"
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
if (wasYesterday) {
|
|
1008
|
+
return this._i18n().getText("chatbox.yesterday") ?? "Gestern";
|
|
1009
|
+
}
|
|
1010
|
+
const weekDelta = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24);
|
|
1011
|
+
if (weekDelta < 7) {
|
|
1012
|
+
return d.toLocaleDateString(locale, {
|
|
1013
|
+
weekday: "short"
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
return d.toLocaleDateString(locale, {
|
|
1017
|
+
day: "2-digit",
|
|
1018
|
+
month: "2-digit",
|
|
1019
|
+
year: "2-digit"
|
|
1020
|
+
});
|
|
1021
|
+
},
|
|
1022
|
+
_renderHeaderTitle: function _renderHeaderTitle() {
|
|
1023
|
+
const target = this._dom("headerTitle");
|
|
1024
|
+
if (!target) return;
|
|
1025
|
+
const active = this.getActiveConversationId();
|
|
1026
|
+
const conv = active ? this._conversations.find(c => c.id === active) : undefined;
|
|
1027
|
+
target.textContent = conv ? this._conversationTitle(conv) : "";
|
|
1028
|
+
},
|
|
1029
|
+
_wireAttachments: function _wireAttachments() {
|
|
1030
|
+
const attachBtn = this._dom("attach");
|
|
1031
|
+
const fileInput = this._dom("fileinput");
|
|
1032
|
+
const root = this.getDomRef();
|
|
1033
|
+
attachBtn?.addEventListener("click", () => fileInput?.click());
|
|
1034
|
+
fileInput?.addEventListener("change", () => {
|
|
1035
|
+
if (!fileInput.files) return;
|
|
1036
|
+
this._addPending(Array.from(fileInput.files));
|
|
1037
|
+
fileInput.value = "";
|
|
1038
|
+
});
|
|
1039
|
+
if (!root) return;
|
|
1040
|
+
let dragDepth = 0;
|
|
1041
|
+
root.addEventListener("dragenter", e => {
|
|
1042
|
+
if (!this._hasFiles(e)) return;
|
|
1043
|
+
e.preventDefault();
|
|
1044
|
+
dragDepth++;
|
|
1045
|
+
root.classList.add("cnetChatBox--dragOver");
|
|
1046
|
+
});
|
|
1047
|
+
root.addEventListener("dragover", e => {
|
|
1048
|
+
if (!this._hasFiles(e)) return;
|
|
1049
|
+
e.preventDefault();
|
|
1050
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
|
1051
|
+
});
|
|
1052
|
+
root.addEventListener("dragleave", () => {
|
|
1053
|
+
dragDepth = Math.max(0, dragDepth - 1);
|
|
1054
|
+
if (dragDepth === 0) root.classList.remove("cnetChatBox--dragOver");
|
|
1055
|
+
});
|
|
1056
|
+
root.addEventListener("drop", e => {
|
|
1057
|
+
if (!this._hasFiles(e)) return;
|
|
1058
|
+
e.preventDefault();
|
|
1059
|
+
dragDepth = 0;
|
|
1060
|
+
root.classList.remove("cnetChatBox--dragOver");
|
|
1061
|
+
const files = e.dataTransfer?.files;
|
|
1062
|
+
if (!files || files.length === 0) return;
|
|
1063
|
+
this._addPending(Array.from(files));
|
|
1064
|
+
});
|
|
1065
|
+
},
|
|
1066
|
+
_hasFiles: function _hasFiles(e) {
|
|
1067
|
+
const types = e.dataTransfer?.types;
|
|
1068
|
+
if (!types) return false;
|
|
1069
|
+
for (let i = 0; i < types.length; i++) {
|
|
1070
|
+
if (types[i] === "Files") return true;
|
|
1071
|
+
}
|
|
1072
|
+
return false;
|
|
1073
|
+
},
|
|
1074
|
+
_addPending: function _addPending(files) {
|
|
1075
|
+
if (this.getBusy()) return;
|
|
1076
|
+
if (files.length === 0) return;
|
|
1077
|
+
this._pendingAttachments = this._pendingAttachments.concat(files);
|
|
1078
|
+
this._renderPending();
|
|
1079
|
+
},
|
|
1080
|
+
_removePending: function _removePending(index) {
|
|
1081
|
+
if (index < 0 || index >= this._pendingAttachments.length) return;
|
|
1082
|
+
this._pendingAttachments.splice(index, 1);
|
|
1083
|
+
this._renderPending();
|
|
1084
|
+
},
|
|
1085
|
+
_renderPending: function _renderPending() {
|
|
1086
|
+
const container = this._dom("pending");
|
|
1087
|
+
if (!container) return;
|
|
1088
|
+
container.innerHTML = "";
|
|
1089
|
+
if (this._pendingAttachments.length === 0) {
|
|
1090
|
+
container.classList.remove("cnetChatBox-attachmentsPending--hasItems");
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
container.classList.add("cnetChatBox-attachmentsPending--hasItems");
|
|
1094
|
+
container.appendChild(this._buildAttachmentList(this._pendingAttachments, "pending"));
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
return ChatBox;
|
|
1098
|
+
});
|
|
1099
|
+
//# sourceMappingURL=ChatBox-dbg.js.map
|