@aparte/core 0.2.0-alpha.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/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/client/aparte-client.d.ts +326 -0
- package/dist/client/aparte-client.d.ts.map +1 -0
- package/dist/client/stream-adapter.d.ts +197 -0
- package/dist/client/stream-adapter.d.ts.map +1 -0
- package/dist/components/bubble/aparte-chat-bubble.d.ts +153 -0
- package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -0
- package/dist/components/bubble/bubble-sync.d.ts +44 -0
- package/dist/components/bubble/bubble-sync.d.ts.map +1 -0
- package/dist/components/bubble/index.d.ts +4 -0
- package/dist/components/bubble/index.d.ts.map +1 -0
- package/dist/components/chat/aparte-chat.d.ts +49 -0
- package/dist/components/chat/aparte-chat.d.ts.map +1 -0
- package/dist/components/chat/index.d.ts +2 -0
- package/dist/components/chat/index.d.ts.map +1 -0
- package/dist/components/composer/aparte-composer-action.d.ts +37 -0
- package/dist/components/composer/aparte-composer-action.d.ts.map +1 -0
- package/dist/components/composer/aparte-composer-add-attachment.d.ts +27 -0
- package/dist/components/composer/aparte-composer-add-attachment.d.ts.map +1 -0
- package/dist/components/composer/aparte-composer-attachments.d.ts +26 -0
- package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -0
- package/dist/components/composer/aparte-composer-cancel.d.ts +21 -0
- package/dist/components/composer/aparte-composer-cancel.d.ts.map +1 -0
- package/dist/components/composer/aparte-composer-input.d.ts +63 -0
- package/dist/components/composer/aparte-composer-input.d.ts.map +1 -0
- package/dist/components/composer/aparte-composer-send.d.ts +25 -0
- package/dist/components/composer/aparte-composer-send.d.ts.map +1 -0
- package/dist/components/composer/aparte-composer.d.ts +150 -0
- package/dist/components/composer/aparte-composer.d.ts.map +1 -0
- package/dist/components/composer/index.d.ts +9 -0
- package/dist/components/composer/index.d.ts.map +1 -0
- package/dist/components/conversation-list/aparte-conversation-list.d.ts +50 -0
- package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -0
- package/dist/components/conversation-list/index.d.ts +2 -0
- package/dist/components/conversation-list/index.d.ts.map +1 -0
- package/dist/components/elicitation/aparte-elicitation.d.ts +29 -0
- package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -0
- package/dist/components/index.d.ts +10 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/status/aparte-chat-status.d.ts +34 -0
- package/dist/components/status/aparte-chat-status.d.ts.map +1 -0
- package/dist/components/status/index.d.ts +2 -0
- package/dist/components/status/index.d.ts.map +1 -0
- package/dist/components/viewport/aparte-chat-viewport.d.ts +358 -0
- package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -0
- package/dist/components/viewport/index.d.ts +2 -0
- package/dist/components/viewport/index.d.ts.map +1 -0
- package/dist/config/action-provider.d.ts +59 -0
- package/dist/config/action-provider.d.ts.map +1 -0
- package/dist/config/aparte-config.d.ts +490 -0
- package/dist/config/aparte-config.d.ts.map +1 -0
- package/dist/config/attachment-renderer.d.ts +28 -0
- package/dist/config/attachment-renderer.d.ts.map +1 -0
- package/dist/config/avatar-provider.d.ts +35 -0
- package/dist/config/avatar-provider.d.ts.map +1 -0
- package/dist/config/bubble-shell-renderer.d.ts +41 -0
- package/dist/config/bubble-shell-renderer.d.ts.map +1 -0
- package/dist/config/config-context.d.ts +47 -0
- package/dist/config/config-context.d.ts.map +1 -0
- package/dist/config/error-renderer.d.ts +26 -0
- package/dist/config/error-renderer.d.ts.map +1 -0
- package/dist/config/icon-provider.d.ts +63 -0
- package/dist/config/icon-provider.d.ts.map +1 -0
- package/dist/config/index.d.ts +19 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/locale.d.ts +60 -0
- package/dist/config/locale.d.ts.map +1 -0
- package/dist/config/sanitize.d.ts +31 -0
- package/dist/config/sanitize.d.ts.map +1 -0
- package/dist/config/sibling-nav-renderer.d.ts +27 -0
- package/dist/config/sibling-nav-renderer.d.ts.map +1 -0
- package/dist/config/skeleton-provider.d.ts +26 -0
- package/dist/config/skeleton-provider.d.ts.map +1 -0
- package/dist/config/status-renderer.d.ts +23 -0
- package/dist/config/status-renderer.d.ts.map +1 -0
- package/dist/conversations/conversation-controller.d.ts +126 -0
- package/dist/conversations/conversation-controller.d.ts.map +1 -0
- package/dist/conversations/conversation-manager.d.ts +98 -0
- package/dist/conversations/conversation-manager.d.ts.map +1 -0
- package/dist/conversations/index.d.ts +5 -0
- package/dist/conversations/index.d.ts.map +1 -0
- package/dist/conversations/types.d.ts +172 -0
- package/dist/conversations/types.d.ts.map +1 -0
- package/dist/custom-elements.json +15833 -0
- package/dist/elicitation/index.d.ts +20 -0
- package/dist/elicitation/index.d.ts.map +1 -0
- package/dist/elicitation/panel.d.ts +18 -0
- package/dist/elicitation/panel.d.ts.map +1 -0
- package/dist/elicitation/types.d.ts +98 -0
- package/dist/elicitation/types.d.ts.map +1 -0
- package/dist/host/aparte-chat-host.d.ts +176 -0
- package/dist/host/aparte-chat-host.d.ts.map +1 -0
- package/dist/host/index.d.ts +2 -0
- package/dist/host/index.d.ts.map +1 -0
- package/dist/index-D89k-n2Q.js +6294 -0
- package/dist/index-D89k-n2Q.js.map +1 -0
- package/dist/index.css +2554 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3763 -0
- package/dist/index.js.map +1 -0
- package/dist/index.node.d.ts +53 -0
- package/dist/index.node.d.ts.map +1 -0
- package/dist/index.node.js +49 -0
- package/dist/index.node.js.map +1 -0
- package/dist/interop/element-props.d.ts +23 -0
- package/dist/interop/element-props.d.ts.map +1 -0
- package/dist/parsers/aparte-event-stream.d.ts +13 -0
- package/dist/parsers/aparte-event-stream.d.ts.map +1 -0
- package/dist/parsers/aparte-stream-parser.d.ts +137 -0
- package/dist/parsers/aparte-stream-parser.d.ts.map +1 -0
- package/dist/parsers/index.d.ts +4 -0
- package/dist/parsers/index.d.ts.map +1 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/progress-spinner/aparte-progress-spinner.d.ts +25 -0
- package/dist/primitives/progress-spinner/aparte-progress-spinner.d.ts.map +1 -0
- package/dist/primitives/progress-spinner/index.d.ts +2 -0
- package/dist/primitives/progress-spinner/index.d.ts.map +1 -0
- package/dist/primitives/select/aparte-optgroup.d.ts +32 -0
- package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -0
- package/dist/primitives/select/aparte-option.d.ts +30 -0
- package/dist/primitives/select/aparte-option.d.ts.map +1 -0
- package/dist/primitives/select/aparte-select.d.ts +74 -0
- package/dist/primitives/select/aparte-select.d.ts.map +1 -0
- package/dist/primitives/select/index.d.ts +9 -0
- package/dist/primitives/select/index.d.ts.map +1 -0
- package/dist/renderers/index.d.ts +5 -0
- package/dist/renderers/index.d.ts.map +1 -0
- package/dist/renderers/segment-renderers.d.ts +31 -0
- package/dist/renderers/segment-renderers.d.ts.map +1 -0
- package/dist/runtime/message-repository.d.ts +111 -0
- package/dist/runtime/message-repository.d.ts.map +1 -0
- package/dist/transport/backend-handler.d.ts +50 -0
- package/dist/transport/backend-handler.d.ts.map +1 -0
- package/dist/transport/backend-transport.d.ts +39 -0
- package/dist/transport/backend-transport.d.ts.map +1 -0
- package/dist/transport/direct-transport.d.ts +36 -0
- package/dist/transport/direct-transport.d.ts.map +1 -0
- package/dist/transport/index.d.ts +6 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/types.d.ts +73 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/types/chat.d.ts +223 -0
- package/dist/types/chat.d.ts.map +1 -0
- package/dist/types/errors.d.ts +25 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/event-map.d.ts +41 -0
- package/dist/types/event-map.d.ts.map +1 -0
- package/dist/types/events.d.ts +290 -0
- package/dist/types/events.d.ts.map +1 -0
- package/dist/types/imperative-api.d.ts +38 -0
- package/dist/types/imperative-api.d.ts.map +1 -0
- package/dist/types/index.d.ts +22 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/model-provider.d.ts +229 -0
- package/dist/types/model-provider.d.ts.map +1 -0
- package/dist/types/models.d.ts +173 -0
- package/dist/types/models.d.ts.map +1 -0
- package/dist/types/parsers.d.ts +42 -0
- package/dist/types/parsers.d.ts.map +1 -0
- package/dist/types/providers.d.ts +93 -0
- package/dist/types/providers.d.ts.map +1 -0
- package/dist/types/segments.d.ts +221 -0
- package/dist/types/segments.d.ts.map +1 -0
- package/dist/types/theming.d.ts +44 -0
- package/dist/types/theming.d.ts.map +1 -0
- package/dist/types/tools.d.ts +98 -0
- package/dist/types/tools.d.ts.map +1 -0
- package/dist/utils/assert-never.d.ts +9 -0
- package/dist/utils/assert-never.d.ts.map +1 -0
- package/dist/utils/css-escape.d.ts +15 -0
- package/dist/utils/css-escape.d.ts.map +1 -0
- package/package.json +68 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3763 @@
|
|
|
1
|
+
import { N as resolveConfig, y as getSegmentRenderer, O as runWithConfig, M as MessageRepository, H as populateBubbleFromMessage, d as AparteConfig, p as buildElicitationPanel } from "./index-D89k-n2Q.js";
|
|
2
|
+
import { A, a, b, c, e, f, g, h, i, B, C, D, j, k, l, m, n, o, q, r, s, t, u, v, w, x, z, E, F, G, I, J, K, L, P } from "./index-D89k-n2Q.js";
|
|
3
|
+
class AparteOption extends HTMLElement {
|
|
4
|
+
static get observedAttributes() {
|
|
5
|
+
return ["value", "disabled", "selected", "data-status"];
|
|
6
|
+
}
|
|
7
|
+
connectedCallback() {
|
|
8
|
+
this.setAttribute("role", "option");
|
|
9
|
+
this._updateAriaSelected();
|
|
10
|
+
this._updateStatusDot();
|
|
11
|
+
}
|
|
12
|
+
attributeChangedCallback(name) {
|
|
13
|
+
if (name === "selected") {
|
|
14
|
+
this._updateAriaSelected();
|
|
15
|
+
}
|
|
16
|
+
if (name === "disabled") {
|
|
17
|
+
this.setAttribute("aria-disabled", this.hasAttribute("disabled") ? "true" : "false");
|
|
18
|
+
}
|
|
19
|
+
if (name === "data-status") {
|
|
20
|
+
this._updateStatusDot();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
get value() {
|
|
24
|
+
return this.getAttribute("value") || this.textContent?.trim() || "";
|
|
25
|
+
}
|
|
26
|
+
set value(val) {
|
|
27
|
+
this.setAttribute("value", val);
|
|
28
|
+
}
|
|
29
|
+
get label() {
|
|
30
|
+
const textNode = Array.from(this.childNodes).find((n2) => n2.nodeType === Node.TEXT_NODE);
|
|
31
|
+
return textNode?.textContent?.trim() || this.value;
|
|
32
|
+
}
|
|
33
|
+
get disabled() {
|
|
34
|
+
return this.hasAttribute("disabled");
|
|
35
|
+
}
|
|
36
|
+
set disabled(val) {
|
|
37
|
+
if (val) {
|
|
38
|
+
this.setAttribute("disabled", "");
|
|
39
|
+
} else {
|
|
40
|
+
this.removeAttribute("disabled");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
get selected() {
|
|
44
|
+
return this.hasAttribute("selected");
|
|
45
|
+
}
|
|
46
|
+
set selected(val) {
|
|
47
|
+
if (val) {
|
|
48
|
+
this.setAttribute("selected", "");
|
|
49
|
+
} else {
|
|
50
|
+
this.removeAttribute("selected");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
_updateAriaSelected() {
|
|
54
|
+
this.setAttribute("aria-selected", this.selected ? "true" : "false");
|
|
55
|
+
}
|
|
56
|
+
_updateStatusDot() {
|
|
57
|
+
const status = this.getAttribute("data-status");
|
|
58
|
+
let dot = this.querySelector(".aparte-status-dot");
|
|
59
|
+
if (!status) {
|
|
60
|
+
dot?.remove();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!dot) {
|
|
64
|
+
dot = document.createElement("span");
|
|
65
|
+
dot.className = "aparte-status-dot";
|
|
66
|
+
dot.setAttribute("aria-hidden", "true");
|
|
67
|
+
this.appendChild(dot);
|
|
68
|
+
}
|
|
69
|
+
dot.setAttribute("data-status", status);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!customElements.get("aparte-option")) {
|
|
73
|
+
customElements.define("aparte-option", AparteOption);
|
|
74
|
+
}
|
|
75
|
+
class AparteOptgroup extends HTMLElement {
|
|
76
|
+
static get observedAttributes() {
|
|
77
|
+
return ["label", "collapsible", "collapsed", "loading"];
|
|
78
|
+
}
|
|
79
|
+
connectedCallback() {
|
|
80
|
+
this.setAttribute("role", "group");
|
|
81
|
+
this._render();
|
|
82
|
+
}
|
|
83
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
84
|
+
if (oldValue === newValue) return;
|
|
85
|
+
if (name === "collapsed") {
|
|
86
|
+
this._updateCollapsedState();
|
|
87
|
+
}
|
|
88
|
+
if (this.isConnected) {
|
|
89
|
+
this._render();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
get label() {
|
|
93
|
+
return this.getAttribute("label") || "";
|
|
94
|
+
}
|
|
95
|
+
set label(val) {
|
|
96
|
+
this.setAttribute("label", val);
|
|
97
|
+
}
|
|
98
|
+
get collapsible() {
|
|
99
|
+
return this.hasAttribute("collapsible");
|
|
100
|
+
}
|
|
101
|
+
get collapsed() {
|
|
102
|
+
return this.hasAttribute("collapsed");
|
|
103
|
+
}
|
|
104
|
+
set collapsed(val) {
|
|
105
|
+
if (val) {
|
|
106
|
+
this.setAttribute("collapsed", "");
|
|
107
|
+
} else {
|
|
108
|
+
this.removeAttribute("collapsed");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
get loading() {
|
|
112
|
+
return this.hasAttribute("loading");
|
|
113
|
+
}
|
|
114
|
+
set loading(val) {
|
|
115
|
+
if (val) this.setAttribute("loading", "");
|
|
116
|
+
else this.removeAttribute("loading");
|
|
117
|
+
}
|
|
118
|
+
_render() {
|
|
119
|
+
if (this.label) {
|
|
120
|
+
const existingHeader = this.querySelector(".aparte-optgroup-header");
|
|
121
|
+
if (!existingHeader) {
|
|
122
|
+
const header = document.createElement("div");
|
|
123
|
+
header.className = "aparte-optgroup-header";
|
|
124
|
+
header.setAttribute("aria-label", this.label);
|
|
125
|
+
const labelSpan = document.createElement("span");
|
|
126
|
+
labelSpan.className = "aparte-optgroup-label";
|
|
127
|
+
labelSpan.textContent = this.label;
|
|
128
|
+
header.appendChild(labelSpan);
|
|
129
|
+
if (this.collapsible) {
|
|
130
|
+
const chevron = document.createElement("span");
|
|
131
|
+
chevron.className = "aparte-optgroup-chevron";
|
|
132
|
+
header.appendChild(chevron);
|
|
133
|
+
header.style.cursor = "pointer";
|
|
134
|
+
header.addEventListener("click", (e2) => {
|
|
135
|
+
e2.stopPropagation();
|
|
136
|
+
this._toggleCollapse();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
this.insertBefore(header, this.firstChild);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
this._updateLoadingState();
|
|
143
|
+
this._updateCollapsedState();
|
|
144
|
+
}
|
|
145
|
+
_updateLoadingState() {
|
|
146
|
+
let loader = this.querySelector(".aparte-optgroup-loader");
|
|
147
|
+
if (this.loading) {
|
|
148
|
+
if (!loader) {
|
|
149
|
+
loader = document.createElement("div");
|
|
150
|
+
loader.className = "aparte-optgroup-loader";
|
|
151
|
+
loader.innerHTML = '<span class="aparte-spinner-small"></span> Fetching models...';
|
|
152
|
+
this.appendChild(loader);
|
|
153
|
+
}
|
|
154
|
+
} else if (loader) {
|
|
155
|
+
loader.remove();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
_toggleCollapse() {
|
|
159
|
+
this.collapsed = !this.collapsed;
|
|
160
|
+
this.dispatchEvent(new CustomEvent("aparte-optgroup-toggle", {
|
|
161
|
+
bubbles: true,
|
|
162
|
+
composed: true,
|
|
163
|
+
detail: {
|
|
164
|
+
label: this.label,
|
|
165
|
+
collapsed: this.collapsed
|
|
166
|
+
}
|
|
167
|
+
}));
|
|
168
|
+
this._updateCollapsedState();
|
|
169
|
+
}
|
|
170
|
+
_updateCollapsedState() {
|
|
171
|
+
const options = this.querySelectorAll("aparte-option");
|
|
172
|
+
options.forEach((opt) => {
|
|
173
|
+
opt.style.display = this.collapsed ? "none" : "";
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!customElements.get("aparte-optgroup")) {
|
|
178
|
+
customElements.define("aparte-optgroup", AparteOptgroup);
|
|
179
|
+
}
|
|
180
|
+
class AparteSelect extends HTMLElement {
|
|
181
|
+
static _optIdSeq = 0;
|
|
182
|
+
_value = "";
|
|
183
|
+
_isOpen = false;
|
|
184
|
+
_activeIndex = -1;
|
|
185
|
+
_trigger = null;
|
|
186
|
+
_dropdown = null;
|
|
187
|
+
_searchInput = null;
|
|
188
|
+
_observer = null;
|
|
189
|
+
// Bound handlers for cleanup
|
|
190
|
+
_boundHandleDocumentClick = this._handleDocumentClick.bind(this);
|
|
191
|
+
_boundHandleKeydown = this._handleKeydown.bind(this);
|
|
192
|
+
static get observedAttributes() {
|
|
193
|
+
return ["value", "placeholder", "disabled", "grouped", "searchable", "open"];
|
|
194
|
+
}
|
|
195
|
+
connectedCallback() {
|
|
196
|
+
this._value = this.getAttribute("value") || "";
|
|
197
|
+
this._isOpen = this.hasAttribute("open");
|
|
198
|
+
this._render();
|
|
199
|
+
this._setupEventListeners();
|
|
200
|
+
this._setupMutationObserver();
|
|
201
|
+
}
|
|
202
|
+
disconnectedCallback() {
|
|
203
|
+
document.removeEventListener("click", this._boundHandleDocumentClick);
|
|
204
|
+
document.removeEventListener("keydown", this._boundHandleKeydown);
|
|
205
|
+
this._observer?.disconnect();
|
|
206
|
+
}
|
|
207
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
208
|
+
if (!this.isConnected) return;
|
|
209
|
+
if (name === "value" && oldValue !== newValue && newValue !== this._value) {
|
|
210
|
+
this._value = newValue || "";
|
|
211
|
+
this._updateTriggerLabel();
|
|
212
|
+
}
|
|
213
|
+
if (name === "open") {
|
|
214
|
+
this._isOpen = this.hasAttribute("open");
|
|
215
|
+
if (this._isOpen) {
|
|
216
|
+
this._dropdown?.removeAttribute("hidden");
|
|
217
|
+
this._searchInput?.focus();
|
|
218
|
+
} else {
|
|
219
|
+
this._dropdown?.setAttribute("hidden", "");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
224
|
+
// Public API
|
|
225
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
226
|
+
get value() {
|
|
227
|
+
return this._value;
|
|
228
|
+
}
|
|
229
|
+
set value(val) {
|
|
230
|
+
if (val === this._value) return;
|
|
231
|
+
const previousValue = this._value;
|
|
232
|
+
this._value = val;
|
|
233
|
+
this.setAttribute("value", val);
|
|
234
|
+
this._updateTriggerLabel();
|
|
235
|
+
this._emitChange(previousValue);
|
|
236
|
+
}
|
|
237
|
+
get open() {
|
|
238
|
+
return this._isOpen;
|
|
239
|
+
}
|
|
240
|
+
set open(val) {
|
|
241
|
+
if (val) {
|
|
242
|
+
this.setAttribute("open", "");
|
|
243
|
+
} else {
|
|
244
|
+
this.removeAttribute("open");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
248
|
+
// Rendering
|
|
249
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
250
|
+
_render() {
|
|
251
|
+
const placeholder = this.getAttribute("placeholder") || "Select...";
|
|
252
|
+
const searchable = this.hasAttribute("searchable");
|
|
253
|
+
if (this.querySelector(".aparte-select-dropdown")) {
|
|
254
|
+
this._updateTriggerLabel();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const slottedChildren = Array.from(this.children);
|
|
258
|
+
const trigger = document.createElement("div");
|
|
259
|
+
trigger.className = "aparte-select-trigger";
|
|
260
|
+
trigger.setAttribute("tabindex", "0");
|
|
261
|
+
trigger.setAttribute("role", "combobox");
|
|
262
|
+
trigger.setAttribute("aria-haspopup", "listbox");
|
|
263
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
264
|
+
const labelSpan = document.createElement("span");
|
|
265
|
+
labelSpan.className = "aparte-select-label";
|
|
266
|
+
labelSpan.textContent = placeholder;
|
|
267
|
+
const chevronSpan = document.createElement("span");
|
|
268
|
+
chevronSpan.className = "aparte-select-chevron";
|
|
269
|
+
chevronSpan.innerHTML = `<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><path d="M2.5 4.5L6 8L9.5 4.5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg>`;
|
|
270
|
+
trigger.append(labelSpan, chevronSpan);
|
|
271
|
+
const dropdown = document.createElement("div");
|
|
272
|
+
dropdown.className = "aparte-select-dropdown";
|
|
273
|
+
dropdown.setAttribute("role", "listbox");
|
|
274
|
+
dropdown.hidden = !this._isOpen;
|
|
275
|
+
if (searchable) {
|
|
276
|
+
const searchInput = document.createElement("input");
|
|
277
|
+
searchInput.type = "text";
|
|
278
|
+
searchInput.className = "aparte-select-search";
|
|
279
|
+
searchInput.placeholder = "Search...";
|
|
280
|
+
dropdown.appendChild(searchInput);
|
|
281
|
+
}
|
|
282
|
+
const optionsContainer = document.createElement("div");
|
|
283
|
+
optionsContainer.className = "aparte-select-options";
|
|
284
|
+
slottedChildren.forEach((child) => {
|
|
285
|
+
if (child.tagName === "APARTE-OPTION" || child.tagName === "APARTE-OPTGROUP") {
|
|
286
|
+
optionsContainer.appendChild(child);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
dropdown.appendChild(optionsContainer);
|
|
290
|
+
this._observer?.disconnect();
|
|
291
|
+
this.innerHTML = "";
|
|
292
|
+
this.appendChild(trigger);
|
|
293
|
+
this.appendChild(dropdown);
|
|
294
|
+
this._trigger = trigger;
|
|
295
|
+
this._dropdown = dropdown;
|
|
296
|
+
this._searchInput = dropdown.querySelector(".aparte-select-search");
|
|
297
|
+
if (this.isConnected) {
|
|
298
|
+
this._setupMutationObserver();
|
|
299
|
+
}
|
|
300
|
+
this._updateTriggerLabel();
|
|
301
|
+
}
|
|
302
|
+
_setupMutationObserver() {
|
|
303
|
+
this._observer = new MutationObserver(() => {
|
|
304
|
+
this._updateDropdownContent();
|
|
305
|
+
});
|
|
306
|
+
this._observer.observe(this, { childList: true });
|
|
307
|
+
}
|
|
308
|
+
_updateDropdownContent() {
|
|
309
|
+
if (!this._trigger || !this.contains(this._trigger)) {
|
|
310
|
+
this._render();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const optionsContainer = this.querySelector(".aparte-select-options");
|
|
314
|
+
if (!optionsContainer) {
|
|
315
|
+
this._render();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const lightChildren = Array.from(this.children).filter(
|
|
319
|
+
(child) => child.className !== "aparte-select-trigger" && child.className !== "aparte-select-dropdown"
|
|
320
|
+
);
|
|
321
|
+
if (lightChildren.length === 0) return;
|
|
322
|
+
this._observer?.disconnect();
|
|
323
|
+
optionsContainer.innerHTML = "";
|
|
324
|
+
lightChildren.forEach((child) => {
|
|
325
|
+
optionsContainer.appendChild(child);
|
|
326
|
+
});
|
|
327
|
+
if (this.isConnected) {
|
|
328
|
+
this._observer?.observe(this, { childList: true });
|
|
329
|
+
}
|
|
330
|
+
this._updateTriggerLabel();
|
|
331
|
+
}
|
|
332
|
+
_setupEventListeners() {
|
|
333
|
+
this._trigger?.addEventListener("click", () => this._toggle());
|
|
334
|
+
this._trigger?.addEventListener("keydown", (e2) => {
|
|
335
|
+
if (e2.key === "Enter" || e2.key === " ") {
|
|
336
|
+
if (this._isOpen) return;
|
|
337
|
+
e2.preventDefault();
|
|
338
|
+
this._toggle();
|
|
339
|
+
}
|
|
340
|
+
if (e2.key === "ArrowDown" && !this._isOpen) {
|
|
341
|
+
e2.preventDefault();
|
|
342
|
+
this._openDropdown();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
this.addEventListener("click", (e2) => {
|
|
346
|
+
const option = e2.target.closest("aparte-option");
|
|
347
|
+
if (option && !option.hasAttribute("disabled")) {
|
|
348
|
+
this._selectOption(option);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
this._searchInput?.addEventListener("input", (e2) => {
|
|
352
|
+
const query = e2.target.value.toLowerCase();
|
|
353
|
+
this._filterOptions(query);
|
|
354
|
+
});
|
|
355
|
+
document.addEventListener("click", this._boundHandleDocumentClick);
|
|
356
|
+
document.addEventListener("keydown", this._boundHandleKeydown);
|
|
357
|
+
}
|
|
358
|
+
_handleDocumentClick(e2) {
|
|
359
|
+
if (!this.contains(e2.target)) {
|
|
360
|
+
this._closeDropdown();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
_handleKeydown(e2) {
|
|
364
|
+
if (!this._isOpen) return;
|
|
365
|
+
const inSearch = document.activeElement === this._searchInput;
|
|
366
|
+
switch (e2.key) {
|
|
367
|
+
case "Escape":
|
|
368
|
+
e2.preventDefault();
|
|
369
|
+
this._closeDropdown();
|
|
370
|
+
this._trigger?.focus();
|
|
371
|
+
break;
|
|
372
|
+
case "ArrowDown":
|
|
373
|
+
e2.preventDefault();
|
|
374
|
+
this._moveActive(1);
|
|
375
|
+
break;
|
|
376
|
+
case "ArrowUp":
|
|
377
|
+
e2.preventDefault();
|
|
378
|
+
this._moveActive(-1);
|
|
379
|
+
break;
|
|
380
|
+
case "Home":
|
|
381
|
+
if (inSearch) break;
|
|
382
|
+
e2.preventDefault();
|
|
383
|
+
this._setActive(0);
|
|
384
|
+
break;
|
|
385
|
+
case "End":
|
|
386
|
+
if (inSearch) break;
|
|
387
|
+
e2.preventDefault();
|
|
388
|
+
this._setActive(this._visibleOptions().length - 1);
|
|
389
|
+
break;
|
|
390
|
+
case "Enter": {
|
|
391
|
+
const active = this._visibleOptions()[this._activeIndex];
|
|
392
|
+
if (active) {
|
|
393
|
+
e2.preventDefault();
|
|
394
|
+
this._selectOption(active);
|
|
395
|
+
}
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/** Non-disabled, non-filtered options in DOM order. */
|
|
401
|
+
_visibleOptions() {
|
|
402
|
+
return Array.from(this.querySelectorAll("aparte-option")).filter(
|
|
403
|
+
(opt) => !opt.hasAttribute("disabled") && opt.style.display !== "none"
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
/** Move the active (keyboard-highlighted) option by `delta`, clamped. */
|
|
407
|
+
_moveActive(delta) {
|
|
408
|
+
const opts = this._visibleOptions();
|
|
409
|
+
if (opts.length === 0) return;
|
|
410
|
+
const base = this._activeIndex < 0 ? delta > 0 ? -1 : 0 : this._activeIndex;
|
|
411
|
+
this._setActive(base + delta);
|
|
412
|
+
}
|
|
413
|
+
/** Highlight the option at `index` (clamped) and point aria-activedescendant at it. */
|
|
414
|
+
_setActive(index) {
|
|
415
|
+
const all = this.querySelectorAll("aparte-option");
|
|
416
|
+
all.forEach((o2) => o2.removeAttribute("data-active"));
|
|
417
|
+
const opts = this._visibleOptions();
|
|
418
|
+
if (opts.length === 0) {
|
|
419
|
+
this._activeIndex = -1;
|
|
420
|
+
this._trigger?.removeAttribute("aria-activedescendant");
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const clamped = Math.max(0, Math.min(index, opts.length - 1));
|
|
424
|
+
this._activeIndex = clamped;
|
|
425
|
+
const active = opts[clamped];
|
|
426
|
+
if (!active.id) active.id = `aparte-option-${++AparteSelect._optIdSeq}`;
|
|
427
|
+
active.setAttribute("data-active", "");
|
|
428
|
+
this._trigger?.setAttribute("aria-activedescendant", active.id);
|
|
429
|
+
active.scrollIntoView?.({ block: "nearest" });
|
|
430
|
+
}
|
|
431
|
+
/** Clear the keyboard highlight (on close). */
|
|
432
|
+
_clearActive() {
|
|
433
|
+
this._activeIndex = -1;
|
|
434
|
+
this._trigger?.removeAttribute("aria-activedescendant");
|
|
435
|
+
this.querySelectorAll("aparte-option").forEach((o2) => o2.removeAttribute("data-active"));
|
|
436
|
+
}
|
|
437
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
438
|
+
// Actions
|
|
439
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
440
|
+
_toggle() {
|
|
441
|
+
if (this._isOpen) {
|
|
442
|
+
this._closeDropdown();
|
|
443
|
+
} else {
|
|
444
|
+
this._openDropdown();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
_openDropdown() {
|
|
448
|
+
if (this.hasAttribute("disabled")) return;
|
|
449
|
+
this._isOpen = true;
|
|
450
|
+
this._dropdown?.removeAttribute("hidden");
|
|
451
|
+
this._trigger?.setAttribute("aria-expanded", "true");
|
|
452
|
+
this.setAttribute("open", "");
|
|
453
|
+
this._updatePosition();
|
|
454
|
+
this._searchInput?.focus();
|
|
455
|
+
const opts = this._visibleOptions();
|
|
456
|
+
const selectedIdx = opts.findIndex((o2) => o2.getAttribute("value") === this._value);
|
|
457
|
+
this._setActive(selectedIdx >= 0 ? selectedIdx : 0);
|
|
458
|
+
this.dispatchEvent(new CustomEvent("aparte-select-open", { bubbles: true }));
|
|
459
|
+
}
|
|
460
|
+
_closeDropdown() {
|
|
461
|
+
this._isOpen = false;
|
|
462
|
+
this._clearActive();
|
|
463
|
+
this._dropdown?.setAttribute("hidden", "");
|
|
464
|
+
this._trigger?.setAttribute("aria-expanded", "false");
|
|
465
|
+
this.removeAttribute("open");
|
|
466
|
+
this.removeAttribute("position");
|
|
467
|
+
if (this._dropdown) {
|
|
468
|
+
this._dropdown.style.top = "";
|
|
469
|
+
this._dropdown.style.bottom = "";
|
|
470
|
+
this._dropdown.style.left = "";
|
|
471
|
+
this._dropdown.style.width = "";
|
|
472
|
+
}
|
|
473
|
+
if (this._searchInput) {
|
|
474
|
+
this._searchInput.value = "";
|
|
475
|
+
this._filterOptions("");
|
|
476
|
+
}
|
|
477
|
+
this.dispatchEvent(new CustomEvent("aparte-select-close", { bubbles: true }));
|
|
478
|
+
}
|
|
479
|
+
_updatePosition() {
|
|
480
|
+
if (!this._dropdown || !this._trigger) return;
|
|
481
|
+
const rect = this._trigger.getBoundingClientRect();
|
|
482
|
+
const dropdownHeight = this._dropdown.offsetHeight || 300;
|
|
483
|
+
const viewportHeight = window.innerHeight;
|
|
484
|
+
const spaceBelow = viewportHeight - rect.bottom;
|
|
485
|
+
const GAP = 4;
|
|
486
|
+
this._dropdown.style.left = `${rect.left}px`;
|
|
487
|
+
this._dropdown.style.width = `${rect.width}px`;
|
|
488
|
+
if (spaceBelow < dropdownHeight && rect.top > dropdownHeight) {
|
|
489
|
+
this._dropdown.style.top = "";
|
|
490
|
+
this._dropdown.style.bottom = `${viewportHeight - rect.top + GAP}px`;
|
|
491
|
+
this.setAttribute("position", "top");
|
|
492
|
+
} else {
|
|
493
|
+
this._dropdown.style.top = `${rect.bottom + GAP}px`;
|
|
494
|
+
this._dropdown.style.bottom = "";
|
|
495
|
+
this.removeAttribute("position");
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
_selectOption(option) {
|
|
499
|
+
const value = option.getAttribute("value") || option.textContent?.trim() || "";
|
|
500
|
+
const previousValue = this._value;
|
|
501
|
+
this._value = value;
|
|
502
|
+
this.setAttribute("value", value);
|
|
503
|
+
this._updateTriggerLabel();
|
|
504
|
+
this._closeDropdown();
|
|
505
|
+
this._emitChange(previousValue);
|
|
506
|
+
this._trigger?.focus();
|
|
507
|
+
}
|
|
508
|
+
_updateTriggerLabel() {
|
|
509
|
+
const labelEl = this._trigger?.querySelector(".aparte-select-label");
|
|
510
|
+
if (labelEl) {
|
|
511
|
+
const selectedLabel = this._getSelectedLabel();
|
|
512
|
+
labelEl.textContent = selectedLabel || this.getAttribute("placeholder") || "Select...";
|
|
513
|
+
}
|
|
514
|
+
const options = this.querySelectorAll("aparte-option");
|
|
515
|
+
options.forEach((opt) => {
|
|
516
|
+
const isSelected = opt.getAttribute("value") === this._value;
|
|
517
|
+
if (isSelected) {
|
|
518
|
+
opt.setAttribute("selected", "");
|
|
519
|
+
} else {
|
|
520
|
+
opt.removeAttribute("selected");
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
_getSelectedLabel() {
|
|
525
|
+
for (const opt of this.querySelectorAll("aparte-option")) {
|
|
526
|
+
if (opt.getAttribute("value") === this._value) return opt.textContent?.trim() || "";
|
|
527
|
+
}
|
|
528
|
+
return "";
|
|
529
|
+
}
|
|
530
|
+
_filterOptions(query) {
|
|
531
|
+
const options = this.querySelectorAll("aparte-option");
|
|
532
|
+
options.forEach((opt) => {
|
|
533
|
+
const label = opt.textContent?.toLowerCase() || "";
|
|
534
|
+
const matches = label.includes(query);
|
|
535
|
+
opt.style.display = matches ? "" : "none";
|
|
536
|
+
});
|
|
537
|
+
if (this._isOpen) this._setActive(0);
|
|
538
|
+
}
|
|
539
|
+
_emitChange(previousValue) {
|
|
540
|
+
const detail = {
|
|
541
|
+
value: this._value,
|
|
542
|
+
label: this._getSelectedLabel(),
|
|
543
|
+
previousValue
|
|
544
|
+
};
|
|
545
|
+
this.dispatchEvent(new CustomEvent("aparte-select-change", {
|
|
546
|
+
bubbles: true,
|
|
547
|
+
composed: true,
|
|
548
|
+
detail
|
|
549
|
+
}));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (!customElements.get("aparte-select")) {
|
|
553
|
+
customElements.define("aparte-select", AparteSelect);
|
|
554
|
+
}
|
|
555
|
+
class AparteProgressSpinner extends HTMLElement {
|
|
556
|
+
static get observedAttributes() {
|
|
557
|
+
return ["value"];
|
|
558
|
+
}
|
|
559
|
+
/** Radius of the SVG circle (viewBox is 0 0 24 24, center at 12,12) */
|
|
560
|
+
_r = 9;
|
|
561
|
+
get _circ() {
|
|
562
|
+
return 2 * Math.PI * this._r;
|
|
563
|
+
}
|
|
564
|
+
connectedCallback() {
|
|
565
|
+
this._render();
|
|
566
|
+
}
|
|
567
|
+
attributeChangedCallback() {
|
|
568
|
+
this._render();
|
|
569
|
+
}
|
|
570
|
+
_render() {
|
|
571
|
+
const raw = this.getAttribute("value");
|
|
572
|
+
const value = raw !== null ? Math.min(100, Math.max(0, parseFloat(raw) || 0)) : null;
|
|
573
|
+
this.setAttribute("role", "progressbar");
|
|
574
|
+
this.setAttribute("aria-valuemin", "0");
|
|
575
|
+
this.setAttribute("aria-valuemax", "100");
|
|
576
|
+
if (value !== null) {
|
|
577
|
+
this.setAttribute("aria-valuenow", String(value));
|
|
578
|
+
} else {
|
|
579
|
+
this.removeAttribute("aria-valuenow");
|
|
580
|
+
}
|
|
581
|
+
const dashoffset = value !== null ? this._circ * (1 - value / 100) : 0;
|
|
582
|
+
const dasharray = value !== null ? `${this._circ.toFixed(2)}` : `${(this._circ * 0.72).toFixed(2)} ${(this._circ * 0.28).toFixed(2)}`;
|
|
583
|
+
this.innerHTML = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><circle class="aparte-spinner-track" cx="12" cy="12" r="${this._r}"/><circle class="aparte-spinner-fill" cx="12" cy="12" r="${this._r}" stroke-dasharray="${dasharray}" stroke-dashoffset="${dashoffset.toFixed(2)}"/></svg>`;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (!customElements.get("aparte-progress-spinner")) {
|
|
587
|
+
customElements.define("aparte-progress-spinner", AparteProgressSpinner);
|
|
588
|
+
}
|
|
589
|
+
class AparteChat extends HTMLElement {
|
|
590
|
+
static get observedAttributes() {
|
|
591
|
+
return ["placeholder", "disabled", "center-empty"];
|
|
592
|
+
}
|
|
593
|
+
_observer = null;
|
|
594
|
+
connectedCallback() {
|
|
595
|
+
this._render();
|
|
596
|
+
this._forwardAttr("placeholder");
|
|
597
|
+
this._forwardAttr("disabled");
|
|
598
|
+
this._syncEmptyWatch();
|
|
599
|
+
}
|
|
600
|
+
disconnectedCallback() {
|
|
601
|
+
this._observer?.disconnect();
|
|
602
|
+
this._observer = null;
|
|
603
|
+
}
|
|
604
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
605
|
+
if (oldValue === newValue) return;
|
|
606
|
+
if (name === "center-empty") {
|
|
607
|
+
this._syncEmptyWatch();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const composer = this.querySelector("aparte-composer");
|
|
611
|
+
if (!composer) return;
|
|
612
|
+
if (newValue !== null) composer.setAttribute(name, newValue);
|
|
613
|
+
else composer.removeAttribute(name);
|
|
614
|
+
}
|
|
615
|
+
/** The message viewport (yours or the default), or `null` before connect. */
|
|
616
|
+
get viewport() {
|
|
617
|
+
return this.querySelector("aparte-chat-viewport");
|
|
618
|
+
}
|
|
619
|
+
/** The composer (yours or the default), or `null` before connect. */
|
|
620
|
+
get composer() {
|
|
621
|
+
return this.querySelector("aparte-composer");
|
|
622
|
+
}
|
|
623
|
+
_render() {
|
|
624
|
+
if (this.hasAttribute("framework-managed")) return;
|
|
625
|
+
if (this.querySelector("aparte-chat-viewport")) return;
|
|
626
|
+
const placeholder = this.getAttribute("placeholder");
|
|
627
|
+
const composerAttrs = (placeholder !== null ? ` placeholder="${this._escapeAttr(placeholder)}"` : "") + (this.hasAttribute("disabled") ? " disabled" : "");
|
|
628
|
+
this.innerHTML = `
|
|
629
|
+
<aparte-chat-viewport></aparte-chat-viewport>
|
|
630
|
+
<aparte-composer${composerAttrs}>
|
|
631
|
+
<div class="aparte-composer-shell">
|
|
632
|
+
<div class="aparte-composer-row">
|
|
633
|
+
<aparte-composer-input></aparte-composer-input>
|
|
634
|
+
<aparte-composer-send></aparte-composer-send>
|
|
635
|
+
</div>
|
|
636
|
+
</div>
|
|
637
|
+
</aparte-composer>
|
|
638
|
+
`;
|
|
639
|
+
}
|
|
640
|
+
/** Set an attribute on the inner composer only when the shell carries it. */
|
|
641
|
+
_forwardAttr(name) {
|
|
642
|
+
if (!this.hasAttribute(name)) return;
|
|
643
|
+
this.querySelector("aparte-composer")?.setAttribute(name, this.getAttribute(name) ?? "");
|
|
644
|
+
}
|
|
645
|
+
/** Start/stop watching the viewport so `center-empty` toggles itself. */
|
|
646
|
+
_syncEmptyWatch() {
|
|
647
|
+
this._observer?.disconnect();
|
|
648
|
+
this._observer = null;
|
|
649
|
+
if (!this.hasAttribute("center-empty")) {
|
|
650
|
+
this.removeAttribute("data-empty");
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const viewport = this.querySelector("aparte-chat-viewport");
|
|
654
|
+
if (!viewport) return;
|
|
655
|
+
this._updateEmpty();
|
|
656
|
+
this._observer = new MutationObserver(() => this._updateEmpty());
|
|
657
|
+
this._observer.observe(viewport, { childList: true, subtree: true });
|
|
658
|
+
}
|
|
659
|
+
_updateEmpty() {
|
|
660
|
+
const viewport = this.querySelector("aparte-chat-viewport");
|
|
661
|
+
const empty = !viewport || !viewport.querySelector("aparte-chat-bubble");
|
|
662
|
+
this.toggleAttribute("data-empty", empty);
|
|
663
|
+
}
|
|
664
|
+
_escapeAttr(value) {
|
|
665
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (!customElements.get("aparte-chat")) {
|
|
669
|
+
customElements.define("aparte-chat", AparteChat);
|
|
670
|
+
}
|
|
671
|
+
function cssEscape(value) {
|
|
672
|
+
return value.replace(/["\\]/g, "\\$&");
|
|
673
|
+
}
|
|
674
|
+
let _warnedNoRenderer = false;
|
|
675
|
+
function warnMissingRenderer(type) {
|
|
676
|
+
if (_warnedNoRenderer) return;
|
|
677
|
+
_warnedNoRenderer = true;
|
|
678
|
+
console.warn(`[aparte] No renderer for segment "${type}". Did you call registerDefaultRenderers() from @aparte/core?`);
|
|
679
|
+
}
|
|
680
|
+
function segmentRenderResultToElement(result) {
|
|
681
|
+
if (result instanceof HTMLElement) return result;
|
|
682
|
+
const wrapper = document.createElement("div");
|
|
683
|
+
wrapper.innerHTML = result;
|
|
684
|
+
return wrapper.firstElementChild;
|
|
685
|
+
}
|
|
686
|
+
const INFO_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>';
|
|
687
|
+
class AparteChatBubble extends HTMLElement {
|
|
688
|
+
_contentEl = null;
|
|
689
|
+
_segmentsEl = null;
|
|
690
|
+
_attachmentsEl = null;
|
|
691
|
+
_actionBarEl = null;
|
|
692
|
+
_branchPickerEl = null;
|
|
693
|
+
_content = "";
|
|
694
|
+
_streaming = false;
|
|
695
|
+
_segments = [];
|
|
696
|
+
_role = "assistant";
|
|
697
|
+
_attachments = [];
|
|
698
|
+
_usage = null;
|
|
699
|
+
/** Cleanup returned by the avatar provider — called on disconnect/re-render. */
|
|
700
|
+
_avatarCleanup = null;
|
|
701
|
+
/** Sibling count for tree-based branch navigation (set by setSiblings()) */
|
|
702
|
+
_siblingCount = 1;
|
|
703
|
+
/** Sibling index for tree-based branch navigation (set by setSiblings()) */
|
|
704
|
+
_siblingIndex = 0;
|
|
705
|
+
/** True while the user-message inline editor is open. */
|
|
706
|
+
_editing = false;
|
|
707
|
+
/** The live inline editor (the composer's contenteditable primitive), present only while `_editing`. */
|
|
708
|
+
_editInput = null;
|
|
709
|
+
static get observedAttributes() {
|
|
710
|
+
return ["role", "data-role", "content", "timestamp", "message-id", "streaming", "name"];
|
|
711
|
+
}
|
|
712
|
+
constructor() {
|
|
713
|
+
super();
|
|
714
|
+
}
|
|
715
|
+
// Rebuild the action bar when the global config changes (e.g. a live skin
|
|
716
|
+
// switch calling setBubbleActions / setIconProvider) so already-rendered
|
|
717
|
+
// bubbles pick up the new per-role actions + icons without being re-created.
|
|
718
|
+
_onConfigChange = (e2) => {
|
|
719
|
+
const detail = e2.detail;
|
|
720
|
+
if (detail?.config && detail.config !== this._cfg) return;
|
|
721
|
+
this._updateActionBar();
|
|
722
|
+
};
|
|
723
|
+
/**
|
|
724
|
+
* Config governing this bubble: the instance config of the nearest
|
|
725
|
+
* `[data-aparte-host]` boundary, else the global singleton. Resolved live
|
|
726
|
+
* (a single `closest()`) rather than cached — the boundary may be attached
|
|
727
|
+
* AFTER this bubble mounts (AparteChatHost.bind() runs post-mount), so a
|
|
728
|
+
* connect-time cache would freeze the wrong config.
|
|
729
|
+
*/
|
|
730
|
+
get _cfg() {
|
|
731
|
+
return resolveConfig(this);
|
|
732
|
+
}
|
|
733
|
+
connectedCallback() {
|
|
734
|
+
this._render();
|
|
735
|
+
this._updateContent();
|
|
736
|
+
this._updateTimestamp(this.getAttribute("timestamp"));
|
|
737
|
+
window.addEventListener("aparte-config-change", this._onConfigChange);
|
|
738
|
+
}
|
|
739
|
+
disconnectedCallback() {
|
|
740
|
+
window.removeEventListener("aparte-config-change", this._onConfigChange);
|
|
741
|
+
if (this._avatarCleanup) {
|
|
742
|
+
try {
|
|
743
|
+
this._avatarCleanup();
|
|
744
|
+
} catch {
|
|
745
|
+
}
|
|
746
|
+
this._avatarCleanup = null;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
750
|
+
if (oldValue === newValue) return;
|
|
751
|
+
switch (name) {
|
|
752
|
+
case "role":
|
|
753
|
+
case "data-role":
|
|
754
|
+
if (newValue === "article") return;
|
|
755
|
+
if (newValue === "user" || newValue === "assistant") {
|
|
756
|
+
this._role = newValue;
|
|
757
|
+
this._updateRole();
|
|
758
|
+
}
|
|
759
|
+
break;
|
|
760
|
+
case "content":
|
|
761
|
+
this._content = newValue || "";
|
|
762
|
+
this._updateContent();
|
|
763
|
+
break;
|
|
764
|
+
case "timestamp":
|
|
765
|
+
this._updateTimestamp(newValue);
|
|
766
|
+
break;
|
|
767
|
+
case "streaming":
|
|
768
|
+
this._updateStreaming(newValue !== null && newValue !== "false");
|
|
769
|
+
break;
|
|
770
|
+
case "name":
|
|
771
|
+
this._updateName();
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
776
|
+
// Public API
|
|
777
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
778
|
+
/** Append a token chunk (for streaming) */
|
|
779
|
+
appendToken(chunk) {
|
|
780
|
+
this._content += chunk;
|
|
781
|
+
this._updateContent();
|
|
782
|
+
}
|
|
783
|
+
/** Set content directly */
|
|
784
|
+
setContent(content) {
|
|
785
|
+
this._content = content;
|
|
786
|
+
this.setAttribute("content", content);
|
|
787
|
+
this._updateContent();
|
|
788
|
+
}
|
|
789
|
+
/** Get current content */
|
|
790
|
+
getContent() {
|
|
791
|
+
return this._content;
|
|
792
|
+
}
|
|
793
|
+
/** Set segments for rich content */
|
|
794
|
+
setSegments(segments) {
|
|
795
|
+
this._segments = segments;
|
|
796
|
+
this._renderSegments();
|
|
797
|
+
}
|
|
798
|
+
/** Add a segment */
|
|
799
|
+
addSegment(segment) {
|
|
800
|
+
this._segments.push(segment);
|
|
801
|
+
this._appendSegmentEl(segment);
|
|
802
|
+
}
|
|
803
|
+
/** Update a specific segment */
|
|
804
|
+
updateSegment(segmentId, updates) {
|
|
805
|
+
const index = this._segments.findIndex((s2) => s2.id === segmentId);
|
|
806
|
+
if (index !== -1) {
|
|
807
|
+
const updated = { ...this._segments[index], ...updates };
|
|
808
|
+
this._segments[index] = updated;
|
|
809
|
+
this._applySegmentUpdate(segmentId, updated, updates);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/** Append content to a segment */
|
|
813
|
+
appendToSegment(segmentId, content) {
|
|
814
|
+
const segment = this._segments.find((s2) => s2.id === segmentId);
|
|
815
|
+
if (segment && "content" in segment) {
|
|
816
|
+
segment.content += content;
|
|
817
|
+
this._applySegmentUpdate(segmentId, segment, { content: segment.content });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/** Get all segments */
|
|
821
|
+
getSegments() {
|
|
822
|
+
return [...this._segments];
|
|
823
|
+
}
|
|
824
|
+
/** Remove a segment by id (e.g. to discard a transient waiting indicator) */
|
|
825
|
+
removeSegment(segmentId) {
|
|
826
|
+
const index = this._segments.findIndex((s2) => s2.id === segmentId);
|
|
827
|
+
if (index !== -1) {
|
|
828
|
+
this._segments.splice(index, 1);
|
|
829
|
+
}
|
|
830
|
+
const el = this._segmentsEl?.querySelector(`[data-segment-id="${cssEscape(segmentId)}"]`);
|
|
831
|
+
el?.remove();
|
|
832
|
+
}
|
|
833
|
+
/** Set attachments (chips shown above message content, user role only) */
|
|
834
|
+
setAttachments(attachments) {
|
|
835
|
+
this._attachments = attachments;
|
|
836
|
+
this._updateAttachments();
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Set token usage + timing for this message (assistant only).
|
|
840
|
+
* Renders the info ("i") action in the action bar; clicking it opens
|
|
841
|
+
* the app-owned stats popover (`aparte-message-info`).
|
|
842
|
+
*/
|
|
843
|
+
setUsage(usage) {
|
|
844
|
+
this._usage = usage ?? null;
|
|
845
|
+
this._updateActionBar();
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Update the branch picker UI for tree-based navigation.
|
|
849
|
+
* The viewport calls this after a branch switch or re-render.
|
|
850
|
+
* Prev/Next clicks dispatch `aparte-branch-navigate` (bubbles: true) so
|
|
851
|
+
* the viewport can handle the actual tree switch.
|
|
852
|
+
*/
|
|
853
|
+
setSiblings(count, index) {
|
|
854
|
+
this._siblingCount = count;
|
|
855
|
+
this._siblingIndex = index;
|
|
856
|
+
this._updateBranchPicker();
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Atomic update for the message
|
|
860
|
+
*/
|
|
861
|
+
updateMessage(updates) {
|
|
862
|
+
if ("role" in updates) {
|
|
863
|
+
this._role = updates.role;
|
|
864
|
+
this._updateRole();
|
|
865
|
+
}
|
|
866
|
+
if ("content" in updates) {
|
|
867
|
+
this._content = updates.content;
|
|
868
|
+
this._updateContent();
|
|
869
|
+
}
|
|
870
|
+
if ("segments" in updates) {
|
|
871
|
+
this._segments = updates.segments;
|
|
872
|
+
this._renderSegments();
|
|
873
|
+
}
|
|
874
|
+
if ("timestamp" in updates) {
|
|
875
|
+
this._updateTimestamp(updates.timestamp);
|
|
876
|
+
}
|
|
877
|
+
if ("status" in updates) {
|
|
878
|
+
const isStreaming = updates.status === "streaming" || updates.status === "pending";
|
|
879
|
+
this._updateStreaming(isStreaming);
|
|
880
|
+
}
|
|
881
|
+
if ("attachments" in updates) {
|
|
882
|
+
this._attachments = updates.attachments ?? [];
|
|
883
|
+
this._updateAttachments();
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
887
|
+
// Private Methods
|
|
888
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
889
|
+
_appendSegmentEl(segment) {
|
|
890
|
+
if (!this._segmentsEl) {
|
|
891
|
+
console.warn(`[AparteChatBubble] _appendSegmentEl ABORT: _segmentsEl is null`);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
const renderer = getSegmentRenderer(segment.type);
|
|
895
|
+
if (renderer) {
|
|
896
|
+
const el = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
|
|
897
|
+
if (el) {
|
|
898
|
+
this._segmentsEl.appendChild(el);
|
|
899
|
+
runWithConfig(this._cfg, () => renderer.setup?.(el, segment));
|
|
900
|
+
}
|
|
901
|
+
} else {
|
|
902
|
+
warnMissingRenderer(segment.type);
|
|
903
|
+
const fallback = document.createElement("div");
|
|
904
|
+
fallback.className = "segment segment-unknown";
|
|
905
|
+
fallback.textContent = `[Unknown segment type: ${segment.type}]`;
|
|
906
|
+
this._segmentsEl.appendChild(fallback);
|
|
907
|
+
}
|
|
908
|
+
if (this._contentEl) this._contentEl.style.display = "none";
|
|
909
|
+
this._reflectError();
|
|
910
|
+
}
|
|
911
|
+
_applySegmentUpdate(segmentId, segment, updates) {
|
|
912
|
+
const el = this._segmentsEl?.querySelector(`[data-segment-id="${cssEscape(segmentId)}"]`);
|
|
913
|
+
if (!el) {
|
|
914
|
+
this._renderSegments();
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const renderer = getSegmentRenderer(segment.type);
|
|
918
|
+
if (!renderer) return;
|
|
919
|
+
if (renderer.update) {
|
|
920
|
+
runWithConfig(this._cfg, () => renderer.update(el, segment));
|
|
921
|
+
} else {
|
|
922
|
+
const newEl = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
|
|
923
|
+
if (newEl) {
|
|
924
|
+
el.replaceWith(newEl);
|
|
925
|
+
runWithConfig(this._cfg, () => renderer.setup?.(newEl, segment));
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if ("collapsed" in updates) {
|
|
929
|
+
if (updates.collapsed) {
|
|
930
|
+
el.removeAttribute("open");
|
|
931
|
+
} else {
|
|
932
|
+
el.setAttribute("open", "");
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
_getDisplayName() {
|
|
937
|
+
const nameAttr = this.getAttribute("name");
|
|
938
|
+
if (nameAttr) return nameAttr;
|
|
939
|
+
const locale = this._cfg.getLocale();
|
|
940
|
+
return this._role === "user" ? locale.roleNameUser ?? "You" : locale.roleNameAssistant ?? "Assistant";
|
|
941
|
+
}
|
|
942
|
+
_getAvatarInitial() {
|
|
943
|
+
const name = this._getDisplayName();
|
|
944
|
+
return name.length > 0 ? name[0] : this._role === "user" ? "U" : "A";
|
|
945
|
+
}
|
|
946
|
+
_render() {
|
|
947
|
+
const dataRole = this.getAttribute("data-role");
|
|
948
|
+
const legacyRole = this.getAttribute("role");
|
|
949
|
+
const role = dataRole && dataRole !== "article" ? dataRole : legacyRole && legacyRole !== "article" ? legacyRole : "assistant";
|
|
950
|
+
this._role = role;
|
|
951
|
+
if (this.getAttribute("role") !== "article") {
|
|
952
|
+
this.setAttribute("role", "article");
|
|
953
|
+
}
|
|
954
|
+
if (!this.hasAttribute("data-role")) {
|
|
955
|
+
this.setAttribute("data-role", role);
|
|
956
|
+
}
|
|
957
|
+
if (this.querySelector(".aparte-message")) return;
|
|
958
|
+
const displayName = this._getDisplayName();
|
|
959
|
+
const initial = this._getAvatarInitial();
|
|
960
|
+
const shell = this._cfg.getBubbleShellRenderer?.();
|
|
961
|
+
if (shell) {
|
|
962
|
+
const out = runWithConfig(this._cfg, () => shell({ role: this._role, name: displayName, avatarInitial: initial }));
|
|
963
|
+
if (out instanceof HTMLElement) this.replaceChildren(out);
|
|
964
|
+
else this.innerHTML = out;
|
|
965
|
+
} else {
|
|
966
|
+
this.innerHTML = `
|
|
967
|
+
<div class="aparte-message" data-role="${role}" role="article" aria-label="${this._getAriaLabel()}">
|
|
968
|
+
<div class="aparte-avatar" data-role="${role}"></div>
|
|
969
|
+
<div class="aparte-body">
|
|
970
|
+
<div class="aparte-header">
|
|
971
|
+
<span class="aparte-name">${this._escapeHtml(displayName)}</span>
|
|
972
|
+
<span class="aparte-timestamp"></span>
|
|
973
|
+
</div>
|
|
974
|
+
<div class="aparte-attachments" hidden></div>
|
|
975
|
+
<div class="aparte-message-content">
|
|
976
|
+
<div class="aparte-segments"></div>
|
|
977
|
+
<div class="aparte-content"></div>
|
|
978
|
+
</div>
|
|
979
|
+
<div class="aparte-footer">
|
|
980
|
+
<div class="aparte-branch-picker" hidden>
|
|
981
|
+
<button class="aparte-branch-prev" aria-label="${this._cfg.getLocale().previousResponse ?? "Previous response"}">‹</button>
|
|
982
|
+
<span class="aparte-branch-label">1 / 1</span>
|
|
983
|
+
<button class="aparte-branch-next" aria-label="${this._cfg.getLocale().nextResponse ?? "Next response"}">›</button>
|
|
984
|
+
</div>
|
|
985
|
+
<div class="aparte-action-bar" role="toolbar" aria-label="${this._cfg.getLocale().messageActions ?? "Message actions"}"></div>
|
|
986
|
+
</div>
|
|
987
|
+
</div>
|
|
988
|
+
</div>
|
|
989
|
+
`;
|
|
990
|
+
}
|
|
991
|
+
this._contentEl = this.querySelector(".aparte-content");
|
|
992
|
+
this._segmentsEl = this.querySelector(".aparte-segments");
|
|
993
|
+
this._attachmentsEl = this.querySelector(".aparte-attachments");
|
|
994
|
+
this._actionBarEl = this.querySelector(".aparte-action-bar");
|
|
995
|
+
this._branchPickerEl = this.querySelector(".aparte-branch-picker");
|
|
996
|
+
this._setupBranchPickerListeners();
|
|
997
|
+
this._updateActionBar();
|
|
998
|
+
this._renderAvatar();
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Hand the avatar host element off to the registered AvatarProvider, if
|
|
1002
|
+
* any. Falls back to the default initial / image rendered by `_render()`
|
|
1003
|
+
* when no provider is set.
|
|
1004
|
+
*/
|
|
1005
|
+
_renderAvatar() {
|
|
1006
|
+
const avatar = this.querySelector(".aparte-avatar");
|
|
1007
|
+
if (!avatar) return;
|
|
1008
|
+
if (this._avatarCleanup) {
|
|
1009
|
+
try {
|
|
1010
|
+
this._avatarCleanup();
|
|
1011
|
+
} catch {
|
|
1012
|
+
}
|
|
1013
|
+
this._avatarCleanup = null;
|
|
1014
|
+
}
|
|
1015
|
+
const provider = this._cfg.getAvatarProvider();
|
|
1016
|
+
if (!provider) return;
|
|
1017
|
+
avatar.textContent = "";
|
|
1018
|
+
const cleanup = provider.render(this._role, avatar);
|
|
1019
|
+
if (typeof cleanup === "function") this._avatarCleanup = cleanup;
|
|
1020
|
+
}
|
|
1021
|
+
_updateRole() {
|
|
1022
|
+
const message = this.querySelector(".aparte-message");
|
|
1023
|
+
const avatar = this.querySelector(".aparte-avatar");
|
|
1024
|
+
const nameEl = this.querySelector(".aparte-name");
|
|
1025
|
+
if (message) {
|
|
1026
|
+
message.setAttribute("data-role", this._role);
|
|
1027
|
+
message.setAttribute("aria-label", this._getAriaLabel());
|
|
1028
|
+
}
|
|
1029
|
+
if (avatar) {
|
|
1030
|
+
avatar.setAttribute("data-role", this._role);
|
|
1031
|
+
avatar.textContent = this._getAvatarInitial();
|
|
1032
|
+
}
|
|
1033
|
+
if (nameEl) {
|
|
1034
|
+
nameEl.textContent = this._getDisplayName();
|
|
1035
|
+
}
|
|
1036
|
+
this._updateActionBar();
|
|
1037
|
+
this._renderAvatar();
|
|
1038
|
+
}
|
|
1039
|
+
_updateName() {
|
|
1040
|
+
const avatar = this.querySelector(".aparte-avatar");
|
|
1041
|
+
const nameEl = this.querySelector(".aparte-name");
|
|
1042
|
+
if (avatar && !this._cfg.getAvatarProvider()) avatar.textContent = this._getAvatarInitial();
|
|
1043
|
+
if (nameEl) nameEl.textContent = this._getDisplayName();
|
|
1044
|
+
}
|
|
1045
|
+
_updateContent() {
|
|
1046
|
+
if (!this._contentEl) return;
|
|
1047
|
+
if (this._segments.length > 0) {
|
|
1048
|
+
this._contentEl.style.display = "none";
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
this._contentEl.style.display = "";
|
|
1052
|
+
this._contentEl.innerHTML = this._cfg.renderMarkdown(this._content);
|
|
1053
|
+
if (!this._streaming) this._highlightContentCode();
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Apply the registered syntax-highlight provider to the code blocks produced
|
|
1057
|
+
* by the Markdown provider in the simple-content path. Provider-agnostic: a
|
|
1058
|
+
* full-block provider (e.g. Shiki) returns `<pre>…</pre>` so we replace the
|
|
1059
|
+
* element; a token provider (e.g. Prism, highlight.js) returns inner HTML so
|
|
1060
|
+
* we fill the existing `<code>`. No-op when no highlighter is installed.
|
|
1061
|
+
*/
|
|
1062
|
+
_highlightContentCode() {
|
|
1063
|
+
if (!this._contentEl || !this._cfg.hasHighlightProvider()) return;
|
|
1064
|
+
this._contentEl.querySelectorAll("pre > code").forEach((codeEl) => {
|
|
1065
|
+
const code = codeEl.textContent ?? "";
|
|
1066
|
+
if (!code.trim()) return;
|
|
1067
|
+
const match = codeEl.className.match(/language-([\w+#-]+)/i);
|
|
1068
|
+
const lang = match?.[1] ?? "";
|
|
1069
|
+
const pre = codeEl.parentElement;
|
|
1070
|
+
Promise.resolve(this._cfg.highlightCode(code, lang)).then((html) => {
|
|
1071
|
+
const out = (html ?? "").trim();
|
|
1072
|
+
if (!out || !pre || !pre.isConnected) return;
|
|
1073
|
+
if (/^<pre[\s>]/i.test(out)) {
|
|
1074
|
+
pre.outerHTML = out;
|
|
1075
|
+
} else {
|
|
1076
|
+
codeEl.innerHTML = out;
|
|
1077
|
+
}
|
|
1078
|
+
}).catch(() => {
|
|
1079
|
+
});
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
_renderSegments() {
|
|
1083
|
+
if (!this._segmentsEl) return;
|
|
1084
|
+
this._segmentsEl.innerHTML = "";
|
|
1085
|
+
for (const segment of this._segments) {
|
|
1086
|
+
const renderer = getSegmentRenderer(segment.type);
|
|
1087
|
+
if (renderer) {
|
|
1088
|
+
const el = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
|
|
1089
|
+
if (el) {
|
|
1090
|
+
this._segmentsEl.appendChild(el);
|
|
1091
|
+
runWithConfig(this._cfg, () => renderer.setup?.(el, segment));
|
|
1092
|
+
}
|
|
1093
|
+
} else {
|
|
1094
|
+
warnMissingRenderer(segment.type);
|
|
1095
|
+
const fallback = document.createElement("div");
|
|
1096
|
+
fallback.className = "segment segment-unknown";
|
|
1097
|
+
fallback.textContent = `[Unknown segment type: ${segment.type}]`;
|
|
1098
|
+
this._segmentsEl.appendChild(fallback);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (this._contentEl) {
|
|
1102
|
+
this._contentEl.style.display = this._segments.length > 0 ? "none" : "";
|
|
1103
|
+
}
|
|
1104
|
+
this._reflectError();
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Reflect the error state on the bubble: `data-error` on `.aparte-message` while
|
|
1108
|
+
* an error segment is present. Derived from segments (not the message `status`
|
|
1109
|
+
* attribute) so it works identically in vanilla and in every wrapper — the
|
|
1110
|
+
* error segment flows through the reactive list in all of them. CSS themes
|
|
1111
|
+
* `.aparte-message[data-error]`; custom error content is via setErrorRenderer.
|
|
1112
|
+
*/
|
|
1113
|
+
_reflectError() {
|
|
1114
|
+
const message = this.querySelector(".aparte-message");
|
|
1115
|
+
if (!message) return;
|
|
1116
|
+
const hasError = this._segments.some((s2) => s2.type === "error");
|
|
1117
|
+
if (hasError) message.setAttribute("data-error", "");
|
|
1118
|
+
else message.removeAttribute("data-error");
|
|
1119
|
+
}
|
|
1120
|
+
_updateTimestamp(value) {
|
|
1121
|
+
const timestampEl = this.querySelector(".aparte-timestamp");
|
|
1122
|
+
if (!timestampEl || !value) return;
|
|
1123
|
+
try {
|
|
1124
|
+
const date = new Date(isNaN(Number(value)) ? value : Number(value));
|
|
1125
|
+
timestampEl.textContent = date.toLocaleTimeString(void 0, {
|
|
1126
|
+
hour: "2-digit",
|
|
1127
|
+
minute: "2-digit"
|
|
1128
|
+
});
|
|
1129
|
+
} catch {
|
|
1130
|
+
timestampEl.textContent = "";
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
_getAriaLabel() {
|
|
1134
|
+
const locale = this._cfg.getLocale();
|
|
1135
|
+
return this._role === "user" ? locale.yourMessage ?? "Your message" : locale.assistantResponse ?? "Assistant response";
|
|
1136
|
+
}
|
|
1137
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1138
|
+
// Attachments
|
|
1139
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1140
|
+
_updateAttachments() {
|
|
1141
|
+
if (!this._attachmentsEl) return;
|
|
1142
|
+
if (this._role !== "user" || this._attachments.length === 0) {
|
|
1143
|
+
this._attachmentsEl.hidden = true;
|
|
1144
|
+
this._attachmentsEl.innerHTML = "";
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
this._attachmentsEl.hidden = false;
|
|
1148
|
+
const customAttachment = this._cfg.getAttachmentRenderer?.();
|
|
1149
|
+
if (customAttachment) {
|
|
1150
|
+
this._attachmentsEl.replaceChildren();
|
|
1151
|
+
for (const a2 of this._attachments) {
|
|
1152
|
+
const el = segmentRenderResultToElement(runWithConfig(this._cfg, () => customAttachment(a2)));
|
|
1153
|
+
if (el) this._attachmentsEl.appendChild(el);
|
|
1154
|
+
}
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
this._attachmentsEl.innerHTML = this._attachments.map((a2) => {
|
|
1158
|
+
const name = this._escapeHtml(a2.name);
|
|
1159
|
+
if (a2.type.startsWith("image/")) {
|
|
1160
|
+
return `<div class="aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${this._escapeHtml(a2.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
|
|
1161
|
+
}
|
|
1162
|
+
return `<div class="aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${this._escapeHtml(this._fileExt(a2.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
|
|
1163
|
+
}).join("");
|
|
1164
|
+
this._attachmentsEl.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
|
|
1165
|
+
tile.addEventListener("click", () => {
|
|
1166
|
+
const img = tile.querySelector(".aparte-thumb__img");
|
|
1167
|
+
if (!img) return;
|
|
1168
|
+
this.dispatchEvent(new CustomEvent("aparte-attachment-preview", {
|
|
1169
|
+
bubbles: true,
|
|
1170
|
+
composed: true,
|
|
1171
|
+
detail: { url: img.src, name: tile.getAttribute("title") ?? "" }
|
|
1172
|
+
}));
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
/** Uppercased file extension (≤4 chars), or 'FILE' when there is none. */
|
|
1177
|
+
_fileExt(filename) {
|
|
1178
|
+
const dot = filename.lastIndexOf(".");
|
|
1179
|
+
return dot > 0 ? filename.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
|
|
1180
|
+
}
|
|
1181
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1182
|
+
// Branch Picker
|
|
1183
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1184
|
+
_setupBranchPickerListeners() {
|
|
1185
|
+
const prevBtn = this._branchPickerEl?.querySelector(".aparte-branch-prev");
|
|
1186
|
+
const nextBtn = this._branchPickerEl?.querySelector(".aparte-branch-next");
|
|
1187
|
+
const dispatchNav = (direction) => {
|
|
1188
|
+
const messageId = this.getAttribute("message-id");
|
|
1189
|
+
if (!messageId) return;
|
|
1190
|
+
const detail = { messageId, direction };
|
|
1191
|
+
this.dispatchEvent(new CustomEvent("aparte-branch-navigate", {
|
|
1192
|
+
bubbles: true,
|
|
1193
|
+
composed: true,
|
|
1194
|
+
detail
|
|
1195
|
+
}));
|
|
1196
|
+
};
|
|
1197
|
+
prevBtn?.addEventListener("click", () => dispatchNav("prev"));
|
|
1198
|
+
nextBtn?.addEventListener("click", () => dispatchNav("next"));
|
|
1199
|
+
}
|
|
1200
|
+
_updateBranchPicker() {
|
|
1201
|
+
if (!this._branchPickerEl) return;
|
|
1202
|
+
if (this._siblingCount <= 1 || this._role !== "assistant") {
|
|
1203
|
+
this._branchPickerEl.hidden = true;
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
this._branchPickerEl.hidden = false;
|
|
1207
|
+
const label = this._branchPickerEl.querySelector(".aparte-branch-label");
|
|
1208
|
+
if (label) {
|
|
1209
|
+
const customNav = this._cfg.getSiblingNavRenderer?.();
|
|
1210
|
+
if (customNav) {
|
|
1211
|
+
const out = runWithConfig(this._cfg, () => customNav({ count: this._siblingCount, index: this._siblingIndex }));
|
|
1212
|
+
if (out instanceof HTMLElement) label.replaceChildren(out);
|
|
1213
|
+
else label.innerHTML = out;
|
|
1214
|
+
} else {
|
|
1215
|
+
label.textContent = `${this._siblingIndex + 1} / ${this._siblingCount}`;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
const prevBtn = this._branchPickerEl.querySelector(".aparte-branch-prev");
|
|
1219
|
+
const nextBtn = this._branchPickerEl.querySelector(".aparte-branch-next");
|
|
1220
|
+
if (prevBtn) prevBtn.disabled = this._siblingIndex === 0;
|
|
1221
|
+
if (nextBtn) nextBtn.disabled = this._siblingIndex === this._siblingCount - 1;
|
|
1222
|
+
}
|
|
1223
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1224
|
+
// Action Bar
|
|
1225
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1226
|
+
_updateActionBar() {
|
|
1227
|
+
if (!this._actionBarEl) return;
|
|
1228
|
+
if (this._editing) {
|
|
1229
|
+
this._renderEditActions();
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
const config = this._cfg.getBubbleActions();
|
|
1233
|
+
const icons = this._cfg.getIconProvider();
|
|
1234
|
+
const locale = this._cfg.getLocale();
|
|
1235
|
+
const buttons = [];
|
|
1236
|
+
if (this._role === "user") {
|
|
1237
|
+
if (config.user) {
|
|
1238
|
+
for (const a2 of config.user) buttons.push(this._actionButtonHtml(a2, icons, locale));
|
|
1239
|
+
} else {
|
|
1240
|
+
if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
|
|
1241
|
+
if (config.edit) buttons.push(this._actionButtonHtml("edit", icons, locale));
|
|
1242
|
+
}
|
|
1243
|
+
} else if (this._role === "assistant") {
|
|
1244
|
+
if (config.assistant) {
|
|
1245
|
+
for (const a2 of config.assistant) buttons.push(this._actionButtonHtml(a2, icons, locale));
|
|
1246
|
+
} else {
|
|
1247
|
+
if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
|
|
1248
|
+
if (config.retry) buttons.push(this._actionButtonHtml("retry", icons, locale));
|
|
1249
|
+
if (config.feedback) {
|
|
1250
|
+
buttons.push(this._actionButtonHtml("thumbUp", icons, locale));
|
|
1251
|
+
buttons.push(this._actionButtonHtml("thumbDown", icons, locale));
|
|
1252
|
+
}
|
|
1253
|
+
if (this._usage) {
|
|
1254
|
+
const infoLabel = locale.messageInfo ?? "Details";
|
|
1255
|
+
buttons.push(`<button class="aparte-action-btn aparte-action-info" data-action="info" aria-label="${infoLabel}" title="${infoLabel}">
|
|
1256
|
+
${INFO_ICON_SVG}
|
|
1257
|
+
</button>`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
this._actionBarEl.innerHTML = buttons.join("");
|
|
1262
|
+
this._appendCustomActions(icons);
|
|
1263
|
+
this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
|
|
1264
|
+
btn.addEventListener("click", (e2) => this._handleActionClick(e2));
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
/** Append the registered custom action buttons for this bubble's role. */
|
|
1268
|
+
_appendCustomActions(icons) {
|
|
1269
|
+
if (!this._actionBarEl) return;
|
|
1270
|
+
for (const a2 of this._cfg.getActions("bubble")) {
|
|
1271
|
+
const roles = a2.bubble?.roles ?? ["user", "assistant"];
|
|
1272
|
+
if (!roles.includes(this._role)) continue;
|
|
1273
|
+
const btn = document.createElement("button");
|
|
1274
|
+
btn.className = "aparte-action-btn aparte-action-custom";
|
|
1275
|
+
btn.dataset["action"] = `custom:${a2.id}`;
|
|
1276
|
+
btn.setAttribute("aria-label", a2.label);
|
|
1277
|
+
btn.setAttribute("title", a2.label);
|
|
1278
|
+
const fromProvider = icons[a2.icon];
|
|
1279
|
+
btn.innerHTML = a2.icon.startsWith("<") ? a2.icon : typeof fromProvider === "function" ? fromProvider() : a2.iconFallback ?? "";
|
|
1280
|
+
this._actionBarEl.appendChild(btn);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/** Build the `<button>` HTML for a single named action (shared by flag + per-role rendering). */
|
|
1284
|
+
_actionButtonHtml(action, icons, locale) {
|
|
1285
|
+
switch (action) {
|
|
1286
|
+
case "copy": {
|
|
1287
|
+
const l2 = locale.copy ?? "Copy";
|
|
1288
|
+
return `<button class="aparte-action-btn aparte-action-copy" data-action="copy" aria-label="${l2}" title="${l2}">${icons.copy()}</button>`;
|
|
1289
|
+
}
|
|
1290
|
+
case "edit": {
|
|
1291
|
+
const l2 = locale.edit ?? "Edit message";
|
|
1292
|
+
return `<button class="aparte-action-btn aparte-action-edit" data-action="edit" aria-label="${l2}" title="${l2}">${icons.edit()}</button>`;
|
|
1293
|
+
}
|
|
1294
|
+
case "retry": {
|
|
1295
|
+
const l2 = locale.retry ?? "Retry";
|
|
1296
|
+
return `<button class="aparte-action-btn aparte-action-retry" data-action="retry" aria-label="${l2}" title="${l2}">${icons.retry()}</button>`;
|
|
1297
|
+
}
|
|
1298
|
+
case "thumbUp": {
|
|
1299
|
+
const l2 = locale.feedbackPositive ?? "Good response";
|
|
1300
|
+
return `<button class="aparte-action-btn aparte-action-feedback-pos" data-action="feedback-positive" aria-label="${l2}" title="${l2}">${icons.thumbUp()}</button>`;
|
|
1301
|
+
}
|
|
1302
|
+
case "thumbDown": {
|
|
1303
|
+
const l2 = locale.feedbackNegative ?? "Bad response";
|
|
1304
|
+
return `<button class="aparte-action-btn aparte-action-feedback-neg" data-action="feedback-negative" aria-label="${l2}" title="${l2}">${icons.thumbDown()}</button>`;
|
|
1305
|
+
}
|
|
1306
|
+
default:
|
|
1307
|
+
return "";
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
/** Render the edit-mode action bar: ✓ save (green) + ✗ cancel (red). */
|
|
1311
|
+
_renderEditActions() {
|
|
1312
|
+
if (!this._actionBarEl) return;
|
|
1313
|
+
const locale = this._cfg.getLocale();
|
|
1314
|
+
const saveLabel = locale.editConfirm ?? "Save";
|
|
1315
|
+
const cancelLabel = locale.editCancel ?? "Cancel";
|
|
1316
|
+
this._actionBarEl.innerHTML = `<button class="aparte-action-btn aparte-action-edit-save" data-action="edit-save" aria-label="${saveLabel}" title="${saveLabel}">${this._cfg.getIcon("check")}</button><button class="aparte-action-btn aparte-action-edit-cancel" data-action="edit-cancel" aria-label="${cancelLabel}" title="${cancelLabel}">${this._cfg.getIcon("close")}</button>`;
|
|
1317
|
+
this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
|
|
1318
|
+
btn.addEventListener("click", (e2) => this._handleActionClick(e2));
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
_handleActionClick(e2) {
|
|
1322
|
+
const btn = e2.currentTarget;
|
|
1323
|
+
const action = btn.dataset["action"];
|
|
1324
|
+
const messageId = this.getAttribute("message-id");
|
|
1325
|
+
if (action?.startsWith("custom:") && messageId) {
|
|
1326
|
+
const actionId = action.slice("custom:".length);
|
|
1327
|
+
const detail = {
|
|
1328
|
+
actionId,
|
|
1329
|
+
zone: "bubble",
|
|
1330
|
+
messageId,
|
|
1331
|
+
role: this._role,
|
|
1332
|
+
targetId: this._resolveTargetId()
|
|
1333
|
+
};
|
|
1334
|
+
this.dispatchEvent(new CustomEvent("aparte-action", {
|
|
1335
|
+
bubbles: true,
|
|
1336
|
+
composed: true,
|
|
1337
|
+
detail
|
|
1338
|
+
}));
|
|
1339
|
+
this._cfg.getActions("bubble").find((x2) => x2.id === actionId)?.onClick?.(e2);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
switch (action) {
|
|
1343
|
+
case "copy": {
|
|
1344
|
+
const text = this._content || this._segments.map((s2) => s2.content ?? "").join("\n");
|
|
1345
|
+
const icons = this._cfg.getIconProvider();
|
|
1346
|
+
const locale = this._cfg.getLocale();
|
|
1347
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
1348
|
+
btn.innerHTML = icons.check();
|
|
1349
|
+
btn.setAttribute("data-copied", "");
|
|
1350
|
+
const copiedLabel = locale.copied ?? locale.copy ?? "Copied";
|
|
1351
|
+
btn.setAttribute("title", copiedLabel);
|
|
1352
|
+
btn.setAttribute("aria-label", copiedLabel);
|
|
1353
|
+
setTimeout(() => {
|
|
1354
|
+
btn.removeAttribute("data-copied");
|
|
1355
|
+
btn.innerHTML = icons.copy();
|
|
1356
|
+
const copyLabel = locale.copy ?? "Copy";
|
|
1357
|
+
btn.setAttribute("title", copyLabel);
|
|
1358
|
+
btn.setAttribute("aria-label", copyLabel);
|
|
1359
|
+
}, 2e3);
|
|
1360
|
+
}).catch(() => {
|
|
1361
|
+
console.warn("[aparte] Clipboard write failed");
|
|
1362
|
+
});
|
|
1363
|
+
break;
|
|
1364
|
+
}
|
|
1365
|
+
case "retry": {
|
|
1366
|
+
if (!messageId) break;
|
|
1367
|
+
const targetId = this._resolveTargetId();
|
|
1368
|
+
const detail = { messageId, targetId };
|
|
1369
|
+
this.dispatchEvent(new CustomEvent("aparte-retry", {
|
|
1370
|
+
bubbles: true,
|
|
1371
|
+
composed: true,
|
|
1372
|
+
detail
|
|
1373
|
+
}));
|
|
1374
|
+
break;
|
|
1375
|
+
}
|
|
1376
|
+
case "edit": {
|
|
1377
|
+
this._enterEditMode();
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
case "edit-save": {
|
|
1381
|
+
this._exitEditMode(true);
|
|
1382
|
+
break;
|
|
1383
|
+
}
|
|
1384
|
+
case "edit-cancel": {
|
|
1385
|
+
this._exitEditMode(false);
|
|
1386
|
+
break;
|
|
1387
|
+
}
|
|
1388
|
+
case "feedback-positive":
|
|
1389
|
+
case "feedback-negative": {
|
|
1390
|
+
if (!messageId) break;
|
|
1391
|
+
const value = action === "feedback-positive" ? "positive" : "negative";
|
|
1392
|
+
btn.setAttribute("data-submitted", "");
|
|
1393
|
+
const detail = { messageId, value };
|
|
1394
|
+
this.dispatchEvent(new CustomEvent("aparte-feedback", {
|
|
1395
|
+
bubbles: true,
|
|
1396
|
+
composed: true,
|
|
1397
|
+
detail
|
|
1398
|
+
}));
|
|
1399
|
+
break;
|
|
1400
|
+
}
|
|
1401
|
+
case "info": {
|
|
1402
|
+
if (!messageId) break;
|
|
1403
|
+
const detail = {
|
|
1404
|
+
messageId,
|
|
1405
|
+
usage: this._usage ?? void 0
|
|
1406
|
+
};
|
|
1407
|
+
this.dispatchEvent(new CustomEvent("aparte-message-info", {
|
|
1408
|
+
bubbles: true,
|
|
1409
|
+
composed: true,
|
|
1410
|
+
detail
|
|
1411
|
+
}));
|
|
1412
|
+
break;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Open the inline editor for a user message. Idempotent — a second `edit`
|
|
1418
|
+
* click while already editing is a no-op (no stacked editors).
|
|
1419
|
+
*
|
|
1420
|
+
* The editor reuses the composer's contenteditable primitive
|
|
1421
|
+
* (`<aparte-composer-input>`) so editing is iso with composing: same autosize,
|
|
1422
|
+
* IME, paste and styling. With no `<aparte-composer>` root it runs standalone —
|
|
1423
|
+
* `Enter` (Shift+Enter = newline) surfaces as `aparte-composer-submit`, which we
|
|
1424
|
+
* treat as save; `Esc` cancels.
|
|
1425
|
+
*/
|
|
1426
|
+
_enterEditMode() {
|
|
1427
|
+
if (this._editing || !this._contentEl) return;
|
|
1428
|
+
this._editing = true;
|
|
1429
|
+
this.querySelector(".aparte-message")?.setAttribute("data-editing", "");
|
|
1430
|
+
const input = document.createElement("aparte-composer-input");
|
|
1431
|
+
input.setAttribute("placeholder", this._cfg.getLocale().edit ?? "Edit message");
|
|
1432
|
+
this._editInput = input;
|
|
1433
|
+
this._contentEl.style.display = "none";
|
|
1434
|
+
this._contentEl.insertAdjacentElement("afterend", input);
|
|
1435
|
+
input.setValue(this._content);
|
|
1436
|
+
input.addEventListener("aparte-composer-submit", () => this._exitEditMode(true));
|
|
1437
|
+
input.addEventListener("keydown", (e2) => {
|
|
1438
|
+
if (e2.key === "Escape" && !e2.isComposing) {
|
|
1439
|
+
e2.preventDefault();
|
|
1440
|
+
this._exitEditMode(false);
|
|
1441
|
+
}
|
|
1442
|
+
});
|
|
1443
|
+
this._updateActionBar();
|
|
1444
|
+
input.focusEnd();
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Leave edit mode. When `save` is true and the text actually changed, emits
|
|
1448
|
+
* `aparte-edit`; otherwise restores the original message untouched. Always
|
|
1449
|
+
* restores the normal action bar and removes the inline editor.
|
|
1450
|
+
*/
|
|
1451
|
+
_exitEditMode(save) {
|
|
1452
|
+
if (!this._editing) return;
|
|
1453
|
+
const newContent = this._editInput?.getValue() ?? "";
|
|
1454
|
+
const original = this._content;
|
|
1455
|
+
this._editInput?.remove();
|
|
1456
|
+
this._editInput = null;
|
|
1457
|
+
if (this._contentEl) this._contentEl.style.display = "";
|
|
1458
|
+
this.querySelector(".aparte-message")?.removeAttribute("data-editing");
|
|
1459
|
+
this._editing = false;
|
|
1460
|
+
this._updateActionBar();
|
|
1461
|
+
if (save && newContent && newContent !== original) {
|
|
1462
|
+
const messageId = this.getAttribute("message-id");
|
|
1463
|
+
if (messageId) {
|
|
1464
|
+
const detail = {
|
|
1465
|
+
messageId,
|
|
1466
|
+
content: newContent,
|
|
1467
|
+
targetId: this._resolveTargetId()
|
|
1468
|
+
};
|
|
1469
|
+
this.dispatchEvent(new CustomEvent("aparte-edit", {
|
|
1470
|
+
bubbles: true,
|
|
1471
|
+
composed: true,
|
|
1472
|
+
detail
|
|
1473
|
+
}));
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
_resolveTargetId() {
|
|
1478
|
+
let el = this.parentElement;
|
|
1479
|
+
while (el) {
|
|
1480
|
+
const tag = el.tagName?.toLowerCase();
|
|
1481
|
+
const isHost = tag === "aparte-chat" || tag === "aparte-chat-component" || el.hasAttribute?.("data-aparte-chat");
|
|
1482
|
+
if (isHost && el.id) return el.id;
|
|
1483
|
+
el = el.parentElement;
|
|
1484
|
+
}
|
|
1485
|
+
return void 0;
|
|
1486
|
+
}
|
|
1487
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1488
|
+
// Utilities
|
|
1489
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1490
|
+
_escapeHtml(str) {
|
|
1491
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1492
|
+
}
|
|
1493
|
+
_updateStreaming(streaming) {
|
|
1494
|
+
const wasStreaming = this._streaming;
|
|
1495
|
+
this._streaming = streaming;
|
|
1496
|
+
const message = this.querySelector(".aparte-message");
|
|
1497
|
+
if (message) {
|
|
1498
|
+
message.setAttribute("data-streaming", String(streaming));
|
|
1499
|
+
if (streaming) {
|
|
1500
|
+
message.setAttribute("aria-busy", "true");
|
|
1501
|
+
message.classList.add("aparte-message-streaming");
|
|
1502
|
+
} else {
|
|
1503
|
+
message.removeAttribute("aria-busy");
|
|
1504
|
+
message.classList.remove("aparte-message-streaming");
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
if (wasStreaming && !streaming) this._highlightContentCode();
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
if (!customElements.get("aparte-chat-bubble")) {
|
|
1511
|
+
customElements.define("aparte-chat-bubble", AparteChatBubble);
|
|
1512
|
+
}
|
|
1513
|
+
class AparteChatStatus extends HTMLElement {
|
|
1514
|
+
static get observedAttributes() {
|
|
1515
|
+
return ["visible", "text"];
|
|
1516
|
+
}
|
|
1517
|
+
_cfg;
|
|
1518
|
+
constructor() {
|
|
1519
|
+
super();
|
|
1520
|
+
}
|
|
1521
|
+
connectedCallback() {
|
|
1522
|
+
this._cfg = resolveConfig(this);
|
|
1523
|
+
this._render();
|
|
1524
|
+
window.addEventListener("aparte-config-change", this._onConfigChange);
|
|
1525
|
+
}
|
|
1526
|
+
disconnectedCallback() {
|
|
1527
|
+
window.removeEventListener("aparte-config-change", this._onConfigChange);
|
|
1528
|
+
}
|
|
1529
|
+
_onConfigChange = (e2) => {
|
|
1530
|
+
const detail = e2.detail;
|
|
1531
|
+
if (detail?.config && detail.config !== this._cfg) return;
|
|
1532
|
+
this.innerHTML = "";
|
|
1533
|
+
this._render();
|
|
1534
|
+
};
|
|
1535
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
1536
|
+
if (oldValue === newValue) return;
|
|
1537
|
+
switch (name) {
|
|
1538
|
+
case "visible":
|
|
1539
|
+
this._updateVisibility(newValue !== null);
|
|
1540
|
+
break;
|
|
1541
|
+
case "text":
|
|
1542
|
+
this._updateText(newValue);
|
|
1543
|
+
break;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Show the typing indicator
|
|
1548
|
+
*/
|
|
1549
|
+
show() {
|
|
1550
|
+
this.setAttribute("visible", "");
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Hide the typing indicator
|
|
1554
|
+
*/
|
|
1555
|
+
hide() {
|
|
1556
|
+
this.removeAttribute("visible");
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Toggle visibility
|
|
1560
|
+
*/
|
|
1561
|
+
toggle() {
|
|
1562
|
+
if (this.hasAttribute("visible")) {
|
|
1563
|
+
this.hide();
|
|
1564
|
+
} else {
|
|
1565
|
+
this.show();
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Check if visible
|
|
1570
|
+
*/
|
|
1571
|
+
isVisible() {
|
|
1572
|
+
return this.hasAttribute("visible");
|
|
1573
|
+
}
|
|
1574
|
+
_render() {
|
|
1575
|
+
const text = this.getAttribute("text") || "Typing";
|
|
1576
|
+
const visible = this.hasAttribute("visible");
|
|
1577
|
+
if (this.querySelector(".aparte-status-container")) return;
|
|
1578
|
+
const custom = this._cfg?.getStatusRenderer?.();
|
|
1579
|
+
if (custom) {
|
|
1580
|
+
this.innerHTML = `<div class="aparte-message aparte-status-container" data-visible="${visible}" role="status" aria-live="polite"></div>`;
|
|
1581
|
+
const container = this.querySelector(".aparte-status-container");
|
|
1582
|
+
container.setAttribute("aria-label", text);
|
|
1583
|
+
const result = runWithConfig(this._cfg, () => custom(text));
|
|
1584
|
+
if (result instanceof HTMLElement) container.appendChild(result);
|
|
1585
|
+
else container.innerHTML = result;
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
this.innerHTML = `
|
|
1589
|
+
<div
|
|
1590
|
+
class="aparte-message aparte-status-container"
|
|
1591
|
+
data-visible="${visible}"
|
|
1592
|
+
role="status"
|
|
1593
|
+
aria-live="polite"
|
|
1594
|
+
>
|
|
1595
|
+
<div class="aparte-avatar" data-role="assistant" style="visibility: hidden"></div>
|
|
1596
|
+
<div class="aparte-body">
|
|
1597
|
+
<div class="aparte-status-content">
|
|
1598
|
+
<div class="aparte-dots" aria-hidden="true">
|
|
1599
|
+
<span class="aparte-dot"></span>
|
|
1600
|
+
</div>
|
|
1601
|
+
<span class="aparte-status-text"></span>
|
|
1602
|
+
</div>
|
|
1603
|
+
</div>
|
|
1604
|
+
</div>
|
|
1605
|
+
`;
|
|
1606
|
+
this.querySelector(".aparte-status-container")?.setAttribute("aria-label", text);
|
|
1607
|
+
if (this.hasAttribute("text")) {
|
|
1608
|
+
const textEl = this.querySelector(".aparte-status-text");
|
|
1609
|
+
if (textEl) textEl.textContent = text;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
_updateVisibility(visible) {
|
|
1613
|
+
const container = this.querySelector(".aparte-status-container");
|
|
1614
|
+
if (container) {
|
|
1615
|
+
container.setAttribute("data-visible", String(visible));
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
_updateText(text) {
|
|
1619
|
+
const textEl = this.querySelector(".aparte-status-text");
|
|
1620
|
+
const container = this.querySelector(".aparte-status-container");
|
|
1621
|
+
if (!container) return;
|
|
1622
|
+
if (textEl) textEl.textContent = text ?? "";
|
|
1623
|
+
container.setAttribute("aria-label", text || "Typing");
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
if (!customElements.get("aparte-chat-status")) {
|
|
1627
|
+
customElements.define("aparte-chat-status", AparteChatStatus);
|
|
1628
|
+
}
|
|
1629
|
+
class AparteChatViewport extends HTMLElement {
|
|
1630
|
+
// The scroll surface: an internal `.aparte-viewport-container` div (core mode)
|
|
1631
|
+
// or the host element itself (framework-managed mode). HTMLElement covers both.
|
|
1632
|
+
_container = null;
|
|
1633
|
+
_scrollBtn = null;
|
|
1634
|
+
_bottomSpacer = null;
|
|
1635
|
+
/**
|
|
1636
|
+
* In framework-managed mode there is no spacer ELEMENT (an extra child would
|
|
1637
|
+
* collide with the framework's own DOM reconciliation). The spacer is an
|
|
1638
|
+
* additive `padding-bottom` on the host, tracked here so `_recalculateSpacer`
|
|
1639
|
+
* can read the current value without measuring an element.
|
|
1640
|
+
*/
|
|
1641
|
+
_fwSpacerHeight = 0;
|
|
1642
|
+
_spacerRafId = null;
|
|
1643
|
+
_spacerFrozenUntil = 0;
|
|
1644
|
+
_layoutTransitionMs = 0;
|
|
1645
|
+
_repo = new MessageRepository();
|
|
1646
|
+
_isAutoScrollEnabled = true;
|
|
1647
|
+
_scrollThreshold = 50;
|
|
1648
|
+
/** When true, the next _autoScroll() call uses smooth instead of instant, then resets. */
|
|
1649
|
+
_smoothScrollOnce = false;
|
|
1650
|
+
/**
|
|
1651
|
+
* DOM render cap: the max number of `<aparte-chat-bubble>` elements kept in the
|
|
1652
|
+
* DOM at once (a perf ceiling for very long conversations). This NEVER evicts
|
|
1653
|
+
* messages from the repository — the full conversation tree and its persistence
|
|
1654
|
+
* snapshot stay intact; only the oldest rendered bubbles are dropped from view.
|
|
1655
|
+
*/
|
|
1656
|
+
_maxRenderedBubbles = 1e3;
|
|
1657
|
+
/** One-time guard for the deprecated `maxMessages` warning. */
|
|
1658
|
+
_warnedMaxMessagesDeprecation = false;
|
|
1659
|
+
_resizeObserver = null;
|
|
1660
|
+
_mutationObserver = null;
|
|
1661
|
+
_boundResetHandler = null;
|
|
1662
|
+
/**
|
|
1663
|
+
* When true, _reRenderActivePath() only dispatches aparte-path-changed without
|
|
1664
|
+
* touching the DOM. Set via setFrameworkManagedDOM(true) when a framework
|
|
1665
|
+
* (e.g. Angular) owns the bubble elements.
|
|
1666
|
+
*/
|
|
1667
|
+
_frameworkManagedDOM = false;
|
|
1668
|
+
static get observedAttributes() {
|
|
1669
|
+
return ["scroll-threshold", "max-rendered-bubbles", "max-messages"];
|
|
1670
|
+
}
|
|
1671
|
+
constructor() {
|
|
1672
|
+
super();
|
|
1673
|
+
this._handleScroll = this._handleScroll.bind(this);
|
|
1674
|
+
}
|
|
1675
|
+
connectedCallback() {
|
|
1676
|
+
if (this.hasAttribute("framework-managed")) this._frameworkManagedDOM = true;
|
|
1677
|
+
this._render();
|
|
1678
|
+
this._setupEventListeners();
|
|
1679
|
+
this._setupObservers();
|
|
1680
|
+
this._boundResetHandler = () => this.clearAll();
|
|
1681
|
+
window.addEventListener("aparte-reset", this._boundResetHandler);
|
|
1682
|
+
}
|
|
1683
|
+
disconnectedCallback() {
|
|
1684
|
+
if (this._boundResetHandler) {
|
|
1685
|
+
window.removeEventListener("aparte-reset", this._boundResetHandler);
|
|
1686
|
+
this._boundResetHandler = null;
|
|
1687
|
+
}
|
|
1688
|
+
this._cleanup();
|
|
1689
|
+
}
|
|
1690
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
1691
|
+
if (oldValue === newValue) return;
|
|
1692
|
+
switch (name) {
|
|
1693
|
+
case "scroll-threshold":
|
|
1694
|
+
this._scrollThreshold = parseInt(newValue || "50", 10);
|
|
1695
|
+
break;
|
|
1696
|
+
case "max-rendered-bubbles":
|
|
1697
|
+
this._maxRenderedBubbles = parseInt(newValue || "1000", 10);
|
|
1698
|
+
this._pruneRenderedBubbles();
|
|
1699
|
+
break;
|
|
1700
|
+
case "max-messages":
|
|
1701
|
+
this._warnMaxMessagesDeprecated();
|
|
1702
|
+
this._maxRenderedBubbles = parseInt(newValue || "1000", 10);
|
|
1703
|
+
this._pruneRenderedBubbles();
|
|
1704
|
+
break;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Configure viewport with options
|
|
1709
|
+
*/
|
|
1710
|
+
configure(config) {
|
|
1711
|
+
if (config.scrollThreshold !== void 0) {
|
|
1712
|
+
this._scrollThreshold = config.scrollThreshold;
|
|
1713
|
+
}
|
|
1714
|
+
if (config.maxRenderedBubbles !== void 0) {
|
|
1715
|
+
this._maxRenderedBubbles = config.maxRenderedBubbles;
|
|
1716
|
+
this._pruneRenderedBubbles();
|
|
1717
|
+
}
|
|
1718
|
+
if (config.maxMessages !== void 0) {
|
|
1719
|
+
this._warnMaxMessagesDeprecated();
|
|
1720
|
+
this._maxRenderedBubbles = config.maxMessages;
|
|
1721
|
+
this._pruneRenderedBubbles();
|
|
1722
|
+
}
|
|
1723
|
+
if (config.layoutTransitionMs !== void 0) {
|
|
1724
|
+
this._layoutTransitionMs = config.layoutTransitionMs;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1728
|
+
// Simple Content Streaming
|
|
1729
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1730
|
+
/**
|
|
1731
|
+
* Append a token chunk to a message's content (simple text streaming)
|
|
1732
|
+
* @param messageId - Unique identifier for the message
|
|
1733
|
+
* @param chunk - Token chunk to append
|
|
1734
|
+
*/
|
|
1735
|
+
appendToken(messageId, chunk) {
|
|
1736
|
+
const message = this._getOrCreateMessage(messageId);
|
|
1737
|
+
message.content = (message.content || "") + chunk;
|
|
1738
|
+
this._notifyBubble(messageId, "appendToken", chunk);
|
|
1739
|
+
this._autoScroll();
|
|
1740
|
+
this._scheduleSpacerUpdate();
|
|
1741
|
+
}
|
|
1742
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1743
|
+
// Segment-Aware Streaming
|
|
1744
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1745
|
+
/**
|
|
1746
|
+
* Append content to a specific segment within a message
|
|
1747
|
+
* @param messageId - Message containing the segment
|
|
1748
|
+
* @param segmentId - Target segment ID
|
|
1749
|
+
* @param chunk - Content to append
|
|
1750
|
+
*/
|
|
1751
|
+
appendToSegment(messageId, segmentId, chunk) {
|
|
1752
|
+
const message = this._getOrCreateMessage(messageId);
|
|
1753
|
+
if (!message.segments) {
|
|
1754
|
+
message.segments = [];
|
|
1755
|
+
}
|
|
1756
|
+
const segment = message.segments.find((s2) => s2.id === segmentId);
|
|
1757
|
+
if (segment && "content" in segment) {
|
|
1758
|
+
segment.content += chunk;
|
|
1759
|
+
}
|
|
1760
|
+
this.dispatchEvent(new CustomEvent("aparte-segment-update", {
|
|
1761
|
+
bubbles: true,
|
|
1762
|
+
composed: true,
|
|
1763
|
+
detail: { messageId, segmentId, content: chunk, append: true }
|
|
1764
|
+
}));
|
|
1765
|
+
this._notifyBubble(messageId, "appendToSegment", chunk, segmentId);
|
|
1766
|
+
this._autoScroll();
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* The active (head) message id — the target of `AparteClient`'s 1-argument
|
|
1770
|
+
* streaming convention (`addSegment(segment)`, `updateSegment(segmentId,
|
|
1771
|
+
* updates)`, …) which operates on "the current message". Lets a bare
|
|
1772
|
+
* `<aparte-chat-viewport>` be a valid `AparteClient` target, exactly like a
|
|
1773
|
+
* framework wrapper's host element.
|
|
1774
|
+
*/
|
|
1775
|
+
_activeMessageId() {
|
|
1776
|
+
return this._repo.headId;
|
|
1777
|
+
}
|
|
1778
|
+
addSegment(messageIdOrSegment, maybeSegment) {
|
|
1779
|
+
const messageId = typeof messageIdOrSegment === "string" ? messageIdOrSegment : this._activeMessageId();
|
|
1780
|
+
const segment = typeof messageIdOrSegment === "string" ? maybeSegment : messageIdOrSegment;
|
|
1781
|
+
if (!messageId || !segment) return;
|
|
1782
|
+
const message = this._getOrCreateMessage(messageId);
|
|
1783
|
+
if (!message.segments) {
|
|
1784
|
+
message.segments = [];
|
|
1785
|
+
}
|
|
1786
|
+
message.segments.push(segment);
|
|
1787
|
+
this._notifyBubble(messageId, "addSegment", segment);
|
|
1788
|
+
this._autoScroll();
|
|
1789
|
+
}
|
|
1790
|
+
updateSegment(a2, b2, c2) {
|
|
1791
|
+
const clientForm = c2 === void 0 && typeof b2 === "object";
|
|
1792
|
+
const messageId = clientForm ? this._activeMessageId() : a2;
|
|
1793
|
+
const segmentId = clientForm ? a2 : b2;
|
|
1794
|
+
const updates = clientForm ? b2 : c2;
|
|
1795
|
+
if (!messageId) return;
|
|
1796
|
+
const message = this._repo.getMessageById(messageId);
|
|
1797
|
+
if (!message?.segments) return;
|
|
1798
|
+
const segmentIndex = message.segments.findIndex((s2) => s2.id === segmentId);
|
|
1799
|
+
if (segmentIndex !== -1) {
|
|
1800
|
+
message.segments[segmentIndex] = {
|
|
1801
|
+
...message.segments[segmentIndex],
|
|
1802
|
+
...updates
|
|
1803
|
+
};
|
|
1804
|
+
this._notifyBubble(messageId, "updateSegment", { segmentId, updates });
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
removeSegment(a2, b2) {
|
|
1808
|
+
const clientForm = b2 === void 0;
|
|
1809
|
+
const messageId = clientForm ? this._activeMessageId() : a2;
|
|
1810
|
+
const segmentId = clientForm ? a2 : b2;
|
|
1811
|
+
if (!messageId || !segmentId) return;
|
|
1812
|
+
const message = this._repo.getMessageById(messageId);
|
|
1813
|
+
if (message?.segments) {
|
|
1814
|
+
const idx = message.segments.findIndex((s2) => s2.id === segmentId);
|
|
1815
|
+
if (idx !== -1) message.segments.splice(idx, 1);
|
|
1816
|
+
}
|
|
1817
|
+
this._notifyBubble(messageId, "removeSegment", segmentId);
|
|
1818
|
+
}
|
|
1819
|
+
/**
|
|
1820
|
+
* Start a new streaming segment (e.g., thinking or code block)
|
|
1821
|
+
* Creates the segment and marks it as streaming
|
|
1822
|
+
*/
|
|
1823
|
+
startSegment(messageId, segment) {
|
|
1824
|
+
const streamingSegment = { ...segment, isStreaming: true };
|
|
1825
|
+
this.addSegment(messageId, streamingSegment);
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Complete a streaming segment
|
|
1829
|
+
*/
|
|
1830
|
+
completeSegment(messageId, segmentId) {
|
|
1831
|
+
this.updateSegment(messageId, segmentId, { isStreaming: false });
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Persist token usage on a message and propagate to the live bubble so the
|
|
1835
|
+
* perf chip (tokens/sec) renders in the action bar.
|
|
1836
|
+
*/
|
|
1837
|
+
setUsage(messageId, usage) {
|
|
1838
|
+
const message = this._repo.getMessageById(messageId);
|
|
1839
|
+
if (message) message.usage = usage;
|
|
1840
|
+
this._notifyBubble(messageId, "setUsage", usage);
|
|
1841
|
+
}
|
|
1842
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1843
|
+
// Message Management
|
|
1844
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1845
|
+
/**
|
|
1846
|
+
* Mark a message as finished streaming
|
|
1847
|
+
*/
|
|
1848
|
+
completeMessage(messageId) {
|
|
1849
|
+
const message = this._repo.getMessageById(messageId);
|
|
1850
|
+
if (message) {
|
|
1851
|
+
message.isStreaming = false;
|
|
1852
|
+
message.status = "completed";
|
|
1853
|
+
message.segments?.forEach((s2) => {
|
|
1854
|
+
s2.isStreaming = false;
|
|
1855
|
+
});
|
|
1856
|
+
this._notifyBubble(messageId, "complete", { status: "completed" });
|
|
1857
|
+
this._recalculateSpacer();
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Atomic update for a message by ID
|
|
1862
|
+
* Supports updating content, status, segments, and other metadata
|
|
1863
|
+
*/
|
|
1864
|
+
updateMessage(messageId, updates) {
|
|
1865
|
+
const message = this._repo.getMessageById(messageId);
|
|
1866
|
+
if (!message) return;
|
|
1867
|
+
Object.assign(message, updates);
|
|
1868
|
+
if (updates.status) {
|
|
1869
|
+
message.isStreaming = updates.status === "streaming" || updates.status === "pending";
|
|
1870
|
+
}
|
|
1871
|
+
this._notifyBubble(messageId, "update", updates);
|
|
1872
|
+
this._autoScroll();
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Add a complete message to the message registry.
|
|
1876
|
+
*
|
|
1877
|
+
* @remarks
|
|
1878
|
+
* **Framework-managed DOM only.** Records the message in the tree but does NOT
|
|
1879
|
+
* paint a bubble on its own (a framework wrapper reconciles the DOM from the
|
|
1880
|
+
* list). For standalone / vanilla usage call {@link appendMessage} instead,
|
|
1881
|
+
* which both records the message and creates its bubble element.
|
|
1882
|
+
*/
|
|
1883
|
+
addMessage(message) {
|
|
1884
|
+
this._repo.addOrUpdateMessage(this._repo.headId, { ...message });
|
|
1885
|
+
this._pruneRenderedBubbles();
|
|
1886
|
+
this._autoScroll();
|
|
1887
|
+
}
|
|
1888
|
+
/**
|
|
1889
|
+
* Append a new message and create its bubble in the DOM.
|
|
1890
|
+
* Implements the same contract as the Angular wrapper's appendMessage(),
|
|
1891
|
+
* making aparte-chat-viewport a fully standalone target for aparte-client.
|
|
1892
|
+
* When `_frameworkManagedDOM` is true, only the internal repo is updated —
|
|
1893
|
+
* the framework owns the DOM and will create the bubble element itself.
|
|
1894
|
+
*/
|
|
1895
|
+
appendMessage(message) {
|
|
1896
|
+
this._repo.addOrUpdateMessage(this._repo.headId, { ...message });
|
|
1897
|
+
if (!this._frameworkManagedDOM) {
|
|
1898
|
+
const wrapper = this.querySelector(".aparte-messages-wrapper");
|
|
1899
|
+
if (wrapper) {
|
|
1900
|
+
const bubble = document.createElement("aparte-chat-bubble");
|
|
1901
|
+
bubble.setAttribute("message-id", message.id);
|
|
1902
|
+
bubble.setAttribute("role", message.role);
|
|
1903
|
+
if (message.timestamp) bubble.setAttribute("timestamp", String(message.timestamp));
|
|
1904
|
+
if (message.content) bubble.setAttribute("content", message.content);
|
|
1905
|
+
if (message.status === "streaming" || message.status === "pending") {
|
|
1906
|
+
bubble.setAttribute("streaming", "");
|
|
1907
|
+
}
|
|
1908
|
+
if (this._bottomSpacer && this._bottomSpacer.parentNode === wrapper) {
|
|
1909
|
+
wrapper.insertBefore(bubble, this._bottomSpacer);
|
|
1910
|
+
} else {
|
|
1911
|
+
wrapper.appendChild(bubble);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
this._pruneRenderedBubbles();
|
|
1916
|
+
this._recalculateSpacer();
|
|
1917
|
+
if (message.role === "user") {
|
|
1918
|
+
this._isAutoScrollEnabled = true;
|
|
1919
|
+
requestAnimationFrame(() => this._smoothScrollToBottom());
|
|
1920
|
+
} else {
|
|
1921
|
+
this._autoScroll();
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Update the last message content, optionally appending.
|
|
1926
|
+
* Implements the same contract as the Angular wrapper's updateLastMessage(),'
|
|
1927
|
+
* making aparte-chat-viewport a fully standalone streaming target for aparte-client.
|
|
1928
|
+
*/
|
|
1929
|
+
updateLastMessage(content, options) {
|
|
1930
|
+
const lastId = this._repo.headId;
|
|
1931
|
+
if (!lastId) return;
|
|
1932
|
+
if (options?.append) {
|
|
1933
|
+
this.appendToken(lastId, content);
|
|
1934
|
+
} else {
|
|
1935
|
+
const message = this._repo.getMessageById(lastId);
|
|
1936
|
+
if (message) message.content = content;
|
|
1937
|
+
this._notifyBubble(lastId, "appendToken", content);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* Add a new sibling branch to an assistant message (retry flow).
|
|
1942
|
+
* Creates a new empty assistant message as a sibling of `messageId`
|
|
1943
|
+
* under the same parent, switches the active branch to it, and
|
|
1944
|
+
* re-renders the active path.
|
|
1945
|
+
* @returns The index of the new branch in the siblings array, or 0 on failure.
|
|
1946
|
+
*/
|
|
1947
|
+
addBranch(messageId) {
|
|
1948
|
+
const meta = this._repo.getMessage(messageId);
|
|
1949
|
+
if (!meta) return 0;
|
|
1950
|
+
const newMsg = {
|
|
1951
|
+
id: crypto.randomUUID(),
|
|
1952
|
+
role: "assistant",
|
|
1953
|
+
content: "",
|
|
1954
|
+
status: "pending",
|
|
1955
|
+
timestamp: Date.now()
|
|
1956
|
+
};
|
|
1957
|
+
this._repo.addOrUpdateMessage(meta.parentId, newMsg);
|
|
1958
|
+
this._repo.switchToBranch(newMsg.id);
|
|
1959
|
+
this._reRenderActivePath();
|
|
1960
|
+
const siblings = this._repo.getBranches(newMsg.id);
|
|
1961
|
+
return siblings.indexOf(newMsg.id);
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* Add a new message relative to `existingId`, switch to it, and re-render.
|
|
1965
|
+
*
|
|
1966
|
+
* Role-aware semantics:
|
|
1967
|
+
* - existingId is an **assistant** message → create a sibling (same parent),
|
|
1968
|
+
* so the active path replaces the old response with the new one.
|
|
1969
|
+
* - existingId is a **user** message → create a child of that message,
|
|
1970
|
+
* so the user message stays on the active path and the new response follows it.
|
|
1971
|
+
*
|
|
1972
|
+
* Returns the new message's ID, or null if `existingId` is not found.
|
|
1973
|
+
*/
|
|
1974
|
+
addSiblingOf(existingId, newMessage) {
|
|
1975
|
+
const meta = this._repo.getMessage(existingId);
|
|
1976
|
+
if (!meta) return null;
|
|
1977
|
+
const parentId = meta.message.role === "user" ? existingId : meta.parentId;
|
|
1978
|
+
this._repo.addOrUpdateMessage(parentId, { ...newMessage });
|
|
1979
|
+
this._repo.switchToBranch(newMessage.id);
|
|
1980
|
+
this._reRenderActivePath();
|
|
1981
|
+
return newMessage.id;
|
|
1982
|
+
}
|
|
1983
|
+
/**
|
|
1984
|
+
* Navigate to the previous or next sibling branch of a message.
|
|
1985
|
+
* Triggers a full re-render of the active path.
|
|
1986
|
+
*/
|
|
1987
|
+
navigateBranch(messageId, direction) {
|
|
1988
|
+
const siblings = this._repo.getBranches(messageId);
|
|
1989
|
+
const currentIdx = siblings.indexOf(messageId);
|
|
1990
|
+
if (currentIdx === -1) return;
|
|
1991
|
+
const targetIdx = direction === "prev" ? currentIdx - 1 : currentIdx + 1;
|
|
1992
|
+
if (targetIdx < 0 || targetIdx >= siblings.length) return;
|
|
1993
|
+
this._isAutoScrollEnabled = false;
|
|
1994
|
+
this._updateScrollButton();
|
|
1995
|
+
this._repo.switchToBranch(siblings[targetIdx]);
|
|
1996
|
+
this._reRenderActivePath();
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* Remove ALL responses to a user message (every child branch) and set head
|
|
2000
|
+
* back to `userMessageId`. Cleaner than `truncateFrom` for edit flows: it
|
|
2001
|
+
* discards stale sibling branches so the regenerated response starts alone.
|
|
2002
|
+
*/
|
|
2003
|
+
truncateResponsesAfter(userMessageId) {
|
|
2004
|
+
const prevMessages = this._repo.getMessages();
|
|
2005
|
+
this._repo.clearChildren(userMessageId);
|
|
2006
|
+
if (!this._frameworkManagedDOM) {
|
|
2007
|
+
const wrapper = this.querySelector(".aparte-messages-wrapper");
|
|
2008
|
+
if (wrapper) {
|
|
2009
|
+
const startIdx = prevMessages.findIndex((m2) => m2.id === userMessageId);
|
|
2010
|
+
const toRemove = startIdx >= 0 ? prevMessages.slice(startIdx + 1) : [];
|
|
2011
|
+
for (const m2 of toRemove) {
|
|
2012
|
+
wrapper.querySelector(`aparte-chat-bubble[message-id="${cssEscape(m2.id)}"]`)?.remove();
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Remove all messages from `messageId` onwards (inclusive) from state and DOM.
|
|
2019
|
+
* Used by edit to truncate history before re-generating.
|
|
2020
|
+
*/
|
|
2021
|
+
truncateFrom(messageId) {
|
|
2022
|
+
const allMsgs = this._repo.getMessages();
|
|
2023
|
+
const startIdx = allMsgs.findIndex((m2) => m2.id === messageId);
|
|
2024
|
+
if (startIdx === -1) return;
|
|
2025
|
+
const toRemove = allMsgs.slice(startIdx).map((m2) => m2.id);
|
|
2026
|
+
this._repo.resetHead(messageId);
|
|
2027
|
+
if (!this._frameworkManagedDOM) {
|
|
2028
|
+
const wrapper = this.querySelector(".aparte-messages-wrapper");
|
|
2029
|
+
for (const id of toRemove) {
|
|
2030
|
+
wrapper?.querySelector(`aparte-chat-bubble[message-id="${cssEscape(id)}"]`)?.remove();
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Get a message by ID
|
|
2036
|
+
*/
|
|
2037
|
+
getMessage(messageId) {
|
|
2038
|
+
return this._repo.getMessageById(messageId);
|
|
2039
|
+
}
|
|
2040
|
+
getMessages() {
|
|
2041
|
+
return this._repo.getMessages();
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Export the full conversation tree (all branches, not just the active path).
|
|
2045
|
+
* The returned snapshot can be persisted and restored via `importTree()`.
|
|
2046
|
+
*/
|
|
2047
|
+
exportTree() {
|
|
2048
|
+
return this._repo.export();
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* Import a previously-exported tree snapshot, restoring the full branch
|
|
2052
|
+
* topology and the active head. Replaces any existing repo content.
|
|
2053
|
+
*
|
|
2054
|
+
* Always calls `_reRenderActivePath()`:
|
|
2055
|
+
* - In native DOM mode: rebuilds bubble elements.
|
|
2056
|
+
* - In framework-managed mode: skips DOM manipulation but dispatches
|
|
2057
|
+
* `aparte-path-changed` with sibling metadata so the wrapper can update
|
|
2058
|
+
* branch arrows on already-rendered bubbles.
|
|
2059
|
+
*/
|
|
2060
|
+
importTree(tree) {
|
|
2061
|
+
this.clearAll();
|
|
2062
|
+
this._repo.import(tree);
|
|
2063
|
+
this._reRenderActivePath();
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Clear all messages and remove all bubble elements from the DOM.
|
|
2067
|
+
* Also dispatches a aparte-reset-done event.
|
|
2068
|
+
*
|
|
2069
|
+
* In framework-managed mode the DOM is owned by the host framework
|
|
2070
|
+
* (Angular @for, React, etc.) and we must not clear `innerHTML` — doing
|
|
2071
|
+
* so desynchronises the framework's view tree from the live DOM and the
|
|
2072
|
+
* next change-detection pass throws `NotFoundError` on insertBefore.
|
|
2073
|
+
*/
|
|
2074
|
+
clearAll() {
|
|
2075
|
+
this._repo.clear();
|
|
2076
|
+
if (!this._frameworkManagedDOM) {
|
|
2077
|
+
const wrapper = this.querySelector(".aparte-messages-wrapper");
|
|
2078
|
+
if (wrapper) {
|
|
2079
|
+
Array.from(wrapper.querySelectorAll("aparte-chat-bubble")).forEach((b2) => b2.remove());
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
this._setSpacerHeight(0);
|
|
2083
|
+
this._isAutoScrollEnabled = true;
|
|
2084
|
+
this._updateScrollButton();
|
|
2085
|
+
this.dispatchEvent(new CustomEvent("aparte-reset-done", { bubbles: true, composed: true }));
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Clear all messages
|
|
2089
|
+
* @deprecated Use clearAll() to also remove DOM bubbles
|
|
2090
|
+
*/
|
|
2091
|
+
clearMessages() {
|
|
2092
|
+
this._repo.clear();
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Replace the entire message list in one shot. Used when switching
|
|
2096
|
+
* conversations: clears existing repo + DOM, then appends each message.
|
|
2097
|
+
*
|
|
2098
|
+
* In framework-managed mode the framework re-renders the bubble DOM
|
|
2099
|
+
* itself; we only update the internal repo (used by aparte-client to
|
|
2100
|
+
* build chat history).
|
|
2101
|
+
*/
|
|
2102
|
+
setMessages(messages) {
|
|
2103
|
+
this.clearAll();
|
|
2104
|
+
for (const m2 of messages) {
|
|
2105
|
+
this.appendMessage(m2);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Scroll to bottom of viewport
|
|
2110
|
+
*/
|
|
2111
|
+
scrollToBottom() {
|
|
2112
|
+
this._scrollToBottom();
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Reset the bottom spacer to 0 height immediately and freeze it for
|
|
2116
|
+
* 350 ms so the host-app layout transition (e.g. flex: 0→1 animation)
|
|
2117
|
+
* does not trigger a premature recalculation with mid-animation geometry.
|
|
2118
|
+
* Call before a full messages swap.
|
|
2119
|
+
*/
|
|
2120
|
+
resetSpacer() {
|
|
2121
|
+
this._setSpacerHeight(0);
|
|
2122
|
+
if (this._layoutTransitionMs > 0) {
|
|
2123
|
+
this._spacerFrozenUntil = Date.now() + this._layoutTransitionMs;
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
/**
|
|
2127
|
+
* Enable or disable auto-scroll
|
|
2128
|
+
*/
|
|
2129
|
+
setAutoScroll(enabled) {
|
|
2130
|
+
this._isAutoScrollEnabled = enabled;
|
|
2131
|
+
}
|
|
2132
|
+
/**
|
|
2133
|
+
* Signal that a framework (e.g. Angular) manages the bubble DOM.
|
|
2134
|
+
* When true, branch navigation dispatches `aparte-path-changed` without
|
|
2135
|
+
* clearing/rebuilding the messages wrapper — the framework re-renders instead.
|
|
2136
|
+
*/
|
|
2137
|
+
setFrameworkManagedDOM(managed) {
|
|
2138
|
+
this._frameworkManagedDOM = managed;
|
|
2139
|
+
}
|
|
2140
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2141
|
+
// Private Helpers
|
|
2142
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2143
|
+
_getOrCreateMessage(messageId) {
|
|
2144
|
+
const existing = this._repo.getMessageById(messageId);
|
|
2145
|
+
if (existing) return existing;
|
|
2146
|
+
const message = {
|
|
2147
|
+
id: messageId,
|
|
2148
|
+
role: "assistant",
|
|
2149
|
+
content: "",
|
|
2150
|
+
timestamp: Date.now(),
|
|
2151
|
+
isStreaming: true,
|
|
2152
|
+
status: "streaming"
|
|
2153
|
+
};
|
|
2154
|
+
this._repo.addOrUpdateMessage(this._repo.headId, message);
|
|
2155
|
+
return message;
|
|
2156
|
+
}
|
|
2157
|
+
_notifyBubble(messageId, action, payload, segmentId) {
|
|
2158
|
+
const bubble = this.querySelector(
|
|
2159
|
+
`aparte-chat-bubble[message-id="${cssEscape(messageId)}"], [data-aparte-bubble][message-id="${cssEscape(messageId)}"]`
|
|
2160
|
+
);
|
|
2161
|
+
if (!bubble) return;
|
|
2162
|
+
switch (action) {
|
|
2163
|
+
case "appendToken":
|
|
2164
|
+
bubble.appendToken?.(payload);
|
|
2165
|
+
break;
|
|
2166
|
+
case "appendToSegment":
|
|
2167
|
+
bubble.appendToSegment?.(segmentId, payload);
|
|
2168
|
+
break;
|
|
2169
|
+
case "addSegment":
|
|
2170
|
+
bubble.addSegment?.(payload);
|
|
2171
|
+
break;
|
|
2172
|
+
case "updateSegment": {
|
|
2173
|
+
const { segmentId: sid, updates: segUpdates } = payload;
|
|
2174
|
+
bubble.updateSegment?.(sid, segUpdates);
|
|
2175
|
+
break;
|
|
2176
|
+
}
|
|
2177
|
+
case "removeSegment":
|
|
2178
|
+
bubble.removeSegment?.(payload);
|
|
2179
|
+
break;
|
|
2180
|
+
case "setUsage":
|
|
2181
|
+
bubble.setUsage?.(payload);
|
|
2182
|
+
break;
|
|
2183
|
+
case "update":
|
|
2184
|
+
if ("status" in payload || "segments" in payload) {
|
|
2185
|
+
bubble.updateMessage?.(payload);
|
|
2186
|
+
}
|
|
2187
|
+
break;
|
|
2188
|
+
case "complete":
|
|
2189
|
+
bubble.updateMessage?.(payload);
|
|
2190
|
+
break;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* Re-render the active path: clears the messages wrapper and rebuilds bubbles
|
|
2195
|
+
* for every message on the current active branch path (root → head).
|
|
2196
|
+
* Calls `setSiblings(count, index)` on each bubble that has siblings, and
|
|
2197
|
+
* dispatches `aparte-path-changed` so Angular wrapper can sync its signal.
|
|
2198
|
+
*
|
|
2199
|
+
* When `_frameworkManagedDOM` is true (set via setFrameworkManagedDOM), the DOM
|
|
2200
|
+
* manipulation is skipped — only `aparte-path-changed` is dispatched so the
|
|
2201
|
+
* framework can re-render from updated signal state.
|
|
2202
|
+
*/
|
|
2203
|
+
_reRenderActivePath() {
|
|
2204
|
+
const activeMessages = this._repo.getMessages();
|
|
2205
|
+
const siblingsInfo = activeMessages.map((m2) => {
|
|
2206
|
+
const sibs = this._repo.getBranches(m2.id);
|
|
2207
|
+
return { id: m2.id, count: sibs.length, index: sibs.indexOf(m2.id) };
|
|
2208
|
+
});
|
|
2209
|
+
if (this._frameworkManagedDOM) {
|
|
2210
|
+
this._dispatchPathChanged(activeMessages, siblingsInfo);
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
const wrapper = this.querySelector(".aparte-messages-wrapper");
|
|
2214
|
+
if (!wrapper) return;
|
|
2215
|
+
wrapper.innerHTML = "";
|
|
2216
|
+
const startIdx = Math.max(0, activeMessages.length - this._maxRenderedBubbles);
|
|
2217
|
+
for (let i2 = startIdx; i2 < activeMessages.length; i2++) {
|
|
2218
|
+
const message = activeMessages[i2];
|
|
2219
|
+
const sibInfo = siblingsInfo[i2];
|
|
2220
|
+
const bubble = document.createElement("aparte-chat-bubble");
|
|
2221
|
+
bubble.setAttribute("message-id", message.id);
|
|
2222
|
+
bubble.setAttribute("role", message.role);
|
|
2223
|
+
if (message.timestamp) bubble.setAttribute("timestamp", String(message.timestamp));
|
|
2224
|
+
if (message.status === "streaming" || message.status === "pending") {
|
|
2225
|
+
bubble.setAttribute("streaming", "");
|
|
2226
|
+
}
|
|
2227
|
+
wrapper.appendChild(bubble);
|
|
2228
|
+
populateBubbleFromMessage(bubble, message, sibInfo);
|
|
2229
|
+
}
|
|
2230
|
+
this._dispatchPathChanged(activeMessages, siblingsInfo);
|
|
2231
|
+
this._recalculateSpacer();
|
|
2232
|
+
requestAnimationFrame(() => this._handleScroll());
|
|
2233
|
+
}
|
|
2234
|
+
_dispatchPathChanged(messages, siblings) {
|
|
2235
|
+
const detail = { messages, siblings };
|
|
2236
|
+
this.dispatchEvent(new CustomEvent("aparte-path-changed", {
|
|
2237
|
+
bubbles: true,
|
|
2238
|
+
composed: true,
|
|
2239
|
+
detail
|
|
2240
|
+
}));
|
|
2241
|
+
}
|
|
2242
|
+
_autoScroll() {
|
|
2243
|
+
if (this._isAutoScrollEnabled) {
|
|
2244
|
+
if (this._smoothScrollOnce) {
|
|
2245
|
+
this._smoothScrollOnce = false;
|
|
2246
|
+
requestAnimationFrame(() => this._smoothScrollToBottom());
|
|
2247
|
+
} else {
|
|
2248
|
+
requestAnimationFrame(() => this._scrollToBottom());
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
this._pruneRenderedBubbles();
|
|
2252
|
+
}
|
|
2253
|
+
/**
|
|
2254
|
+
* Request that the next auto-scroll triggered by a DOM mutation uses
|
|
2255
|
+
* smooth behaviour instead of instant. Call this just before adding a
|
|
2256
|
+
* user message bubble so the viewport animates down rather than jumping.
|
|
2257
|
+
* Resets automatically after the first auto-scroll fires.
|
|
2258
|
+
*/
|
|
2259
|
+
requestSmoothScroll() {
|
|
2260
|
+
this._smoothScrollOnce = true;
|
|
2261
|
+
}
|
|
2262
|
+
_render() {
|
|
2263
|
+
if (this._frameworkManagedDOM) {
|
|
2264
|
+
this._setupFrameworkDOM();
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
if (!this.querySelector(".aparte-viewport-container")) {
|
|
2268
|
+
const container = document.createElement("div");
|
|
2269
|
+
container.className = "aparte-viewport-container";
|
|
2270
|
+
const locale = resolveConfig(this).getLocale();
|
|
2271
|
+
if (locale.direction) {
|
|
2272
|
+
container.setAttribute("dir", locale.direction);
|
|
2273
|
+
}
|
|
2274
|
+
container.setAttribute("role", "log");
|
|
2275
|
+
container.setAttribute("aria-live", "polite");
|
|
2276
|
+
container.setAttribute("aria-atomic", "false");
|
|
2277
|
+
container.setAttribute("aria-relevant", "additions");
|
|
2278
|
+
const wrapper = document.createElement("div");
|
|
2279
|
+
wrapper.className = "aparte-messages-wrapper";
|
|
2280
|
+
while (this.firstChild) {
|
|
2281
|
+
wrapper.appendChild(this.firstChild);
|
|
2282
|
+
}
|
|
2283
|
+
this._bottomSpacer = document.createElement("div");
|
|
2284
|
+
this._bottomSpacer.className = "aparte-bottom-spacer";
|
|
2285
|
+
this._bottomSpacer.setAttribute("aria-hidden", "true");
|
|
2286
|
+
wrapper.appendChild(this._bottomSpacer);
|
|
2287
|
+
container.appendChild(wrapper);
|
|
2288
|
+
this.appendChild(container);
|
|
2289
|
+
this._container = container;
|
|
2290
|
+
this._scrollBtn = document.createElement("button");
|
|
2291
|
+
this._scrollBtn.className = "aparte-scroll-btn aparte-scroll-btn--hidden";
|
|
2292
|
+
this._scrollBtn.setAttribute("type", "button");
|
|
2293
|
+
this._scrollBtn.setAttribute("aria-label", "Scroll to bottom");
|
|
2294
|
+
const scrollIcon = resolveConfig(this).getIcon("scrollDown");
|
|
2295
|
+
this._scrollBtn.innerHTML = scrollIcon || `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>`;
|
|
2296
|
+
this.appendChild(this._scrollBtn);
|
|
2297
|
+
} else {
|
|
2298
|
+
this._container = this.querySelector(".aparte-viewport-container");
|
|
2299
|
+
this._scrollBtn = this.querySelector(".aparte-scroll-btn");
|
|
2300
|
+
this._bottomSpacer = this.querySelector(".aparte-bottom-spacer");
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* DOM setup for framework-managed mode. The framework (React/Vue/Svelte/
|
|
2305
|
+
* Angular) renders the bubble elements as DIRECT children of the host, so we
|
|
2306
|
+
* must NOT relocate them into an internal wrapper — that desyncs the
|
|
2307
|
+
* framework's virtual DOM from the real DOM and throws NotFoundError on the
|
|
2308
|
+
* next append. Instead the HOST itself is the scroll surface, the spacer is
|
|
2309
|
+
* additive `padding-bottom` (no element), and the scroll button is a
|
|
2310
|
+
* `position: sticky` TRAILING foreign child (kept last by the framework
|
|
2311
|
+
* MutationObserver). A present foreign node is still a valid `insertBefore`
|
|
2312
|
+
* reference for the framework — the crash came from a RELOCATED node, not a
|
|
2313
|
+
* foreign one.
|
|
2314
|
+
*/
|
|
2315
|
+
_setupFrameworkDOM() {
|
|
2316
|
+
this._container = this;
|
|
2317
|
+
this._bottomSpacer = null;
|
|
2318
|
+
if (this.classList.contains("aparte-viewport--framework")) {
|
|
2319
|
+
this._scrollBtn = this.querySelector(":scope > .aparte-scroll-btn");
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
this.classList.add("aparte-viewport--framework");
|
|
2323
|
+
const scrollBtn = document.createElement("button");
|
|
2324
|
+
scrollBtn.className = "aparte-scroll-btn aparte-scroll-btn--hidden";
|
|
2325
|
+
scrollBtn.setAttribute("type", "button");
|
|
2326
|
+
scrollBtn.setAttribute("aria-label", "Scroll to bottom");
|
|
2327
|
+
const scrollIcon = resolveConfig(this).getIcon("scrollDown");
|
|
2328
|
+
scrollBtn.innerHTML = scrollIcon || `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>`;
|
|
2329
|
+
this.appendChild(scrollBtn);
|
|
2330
|
+
this._scrollBtn = scrollBtn;
|
|
2331
|
+
}
|
|
2332
|
+
/**
|
|
2333
|
+
* Keep the sticky scroll button as the last child in framework-managed mode.
|
|
2334
|
+
* The framework usually inserts bubbles before its own trailing nodes (so the
|
|
2335
|
+
* button stays last), but a plain `appendChild` at the very end (e.g. some
|
|
2336
|
+
* Angular @for paths) can land a bubble after it — move it back. Idempotent:
|
|
2337
|
+
* a no-op when already last, so it never loops the MutationObserver.
|
|
2338
|
+
*/
|
|
2339
|
+
_keepScrollButtonLast() {
|
|
2340
|
+
if (!this._scrollBtn) return;
|
|
2341
|
+
if (this.lastElementChild !== this._scrollBtn) {
|
|
2342
|
+
this.appendChild(this._scrollBtn);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
/** Current spacer height — a padding value (framework) or the element's height (core). */
|
|
2346
|
+
_getSpacerHeight() {
|
|
2347
|
+
if (this._frameworkManagedDOM) return this._fwSpacerHeight;
|
|
2348
|
+
return this._bottomSpacer?.offsetHeight ?? 0;
|
|
2349
|
+
}
|
|
2350
|
+
/** Set the spacer — host padding (framework, additive to base padding) or element height (core). */
|
|
2351
|
+
_setSpacerHeight(px) {
|
|
2352
|
+
if (this._frameworkManagedDOM) {
|
|
2353
|
+
this._fwSpacerHeight = px;
|
|
2354
|
+
this.style.setProperty("--aparte-fw-spacer", `${px}px`);
|
|
2355
|
+
} else if (this._bottomSpacer) {
|
|
2356
|
+
this._bottomSpacer.style.height = `${px}px`;
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
_setupEventListeners() {
|
|
2360
|
+
this._container?.addEventListener("scroll", this._handleScroll, { passive: true });
|
|
2361
|
+
this._scrollBtn?.addEventListener("click", () => {
|
|
2362
|
+
this._isAutoScrollEnabled = true;
|
|
2363
|
+
this._smoothScrollToBottom();
|
|
2364
|
+
this._updateScrollButton();
|
|
2365
|
+
});
|
|
2366
|
+
this.addEventListener("aparte-branch-navigate", (e2) => {
|
|
2367
|
+
const evt = e2;
|
|
2368
|
+
evt.stopPropagation();
|
|
2369
|
+
this.navigateBranch(evt.detail.messageId, evt.detail.direction);
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
_setupObservers() {
|
|
2373
|
+
this._resizeObserver = new ResizeObserver(() => {
|
|
2374
|
+
if (this._isAutoScrollEnabled) {
|
|
2375
|
+
this._scrollToBottom();
|
|
2376
|
+
}
|
|
2377
|
+
this._recalculateSpacer();
|
|
2378
|
+
});
|
|
2379
|
+
const wrapper = this.querySelector(".aparte-messages-wrapper");
|
|
2380
|
+
if (this._container) {
|
|
2381
|
+
if (this._frameworkManagedDOM) {
|
|
2382
|
+
this._resizeObserver.observe(this._container, { box: "border-box" });
|
|
2383
|
+
} else {
|
|
2384
|
+
this._resizeObserver.observe(this._container);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
this._mutationObserver = new MutationObserver(() => {
|
|
2388
|
+
if (this._frameworkManagedDOM) this._keepScrollButtonLast();
|
|
2389
|
+
if (this._isAutoScrollEnabled) {
|
|
2390
|
+
requestAnimationFrame(() => this._scrollToBottom());
|
|
2391
|
+
}
|
|
2392
|
+
this._scheduleSpacerUpdate();
|
|
2393
|
+
});
|
|
2394
|
+
const observeTarget = this._frameworkManagedDOM ? this : wrapper;
|
|
2395
|
+
if (observeTarget) {
|
|
2396
|
+
this._mutationObserver.observe(observeTarget, {
|
|
2397
|
+
childList: true,
|
|
2398
|
+
subtree: true
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
_handleScroll() {
|
|
2403
|
+
if (!this._container) return;
|
|
2404
|
+
const { scrollTop, scrollHeight, clientHeight } = this._container;
|
|
2405
|
+
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
|
2406
|
+
this._isAutoScrollEnabled = distanceFromBottom <= this._scrollThreshold;
|
|
2407
|
+
this._updateScrollButton();
|
|
2408
|
+
}
|
|
2409
|
+
_scrollToBottom() {
|
|
2410
|
+
if (!this._container) return;
|
|
2411
|
+
this._container.scrollTop = this._container.scrollHeight;
|
|
2412
|
+
}
|
|
2413
|
+
_smoothScrollToBottom() {
|
|
2414
|
+
if (!this._container) return;
|
|
2415
|
+
if (typeof this._container.scrollTo === "function" && !this._prefersReducedMotion()) {
|
|
2416
|
+
this._container.scrollTo({ top: this._container.scrollHeight, behavior: "smooth" });
|
|
2417
|
+
} else {
|
|
2418
|
+
this._container.scrollTop = this._container.scrollHeight;
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
_prefersReducedMotion() {
|
|
2422
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
2423
|
+
}
|
|
2424
|
+
/**
|
|
2425
|
+
* Show/hide the scroll-to-bottom button based on the current auto-scroll state.
|
|
2426
|
+
* Hidden when already at bottom (_isAutoScrollEnabled = true).
|
|
2427
|
+
*/
|
|
2428
|
+
_updateScrollButton() {
|
|
2429
|
+
this._scrollBtn?.classList.toggle("aparte-scroll-btn--hidden", this._isAutoScrollEnabled);
|
|
2430
|
+
}
|
|
2431
|
+
/**
|
|
2432
|
+
* Recalculate the bottom spacer height so the last user message is always
|
|
2433
|
+
* pinned to the top of the scroll area when a response is being generated.
|
|
2434
|
+
*
|
|
2435
|
+
* spacer = max(0, viewportHeight - lastUserBubble.offsetHeight - lastAssistantBubble.offsetHeight)
|
|
2436
|
+
*
|
|
2437
|
+
* The spacer shrinks progressively as the assistant streams content, eventually
|
|
2438
|
+
* reaching 0 when the combined height fills the viewport.
|
|
2439
|
+
*/
|
|
2440
|
+
_recalculateSpacer() {
|
|
2441
|
+
if (!this._container) return;
|
|
2442
|
+
if (!this._frameworkManagedDOM && !this._bottomSpacer) return;
|
|
2443
|
+
if (Date.now() < this._spacerFrozenUntil) return;
|
|
2444
|
+
const allBubbles = Array.from(
|
|
2445
|
+
this.querySelectorAll("aparte-chat-bubble")
|
|
2446
|
+
);
|
|
2447
|
+
if (allBubbles.length === 0) {
|
|
2448
|
+
this._setSpacerHeight(0);
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
const lastUserBubble = [...allBubbles].reverse().find((b2) => b2.getAttribute("role") === "user");
|
|
2452
|
+
if (!lastUserBubble) {
|
|
2453
|
+
this._setSpacerHeight(0);
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
const currentSpacerH = this._getSpacerHeight();
|
|
2457
|
+
const containerRect = this._container.getBoundingClientRect();
|
|
2458
|
+
const userRect = lastUserBubble.getBoundingClientRect();
|
|
2459
|
+
const userTopInContent = userRect.top - containerRect.top + this._container.scrollTop;
|
|
2460
|
+
const scrollHeightWithoutSpacer = this._container.scrollHeight - currentSpacerH;
|
|
2461
|
+
if (scrollHeightWithoutSpacer <= this._container.clientHeight) {
|
|
2462
|
+
this._setSpacerHeight(0);
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
const contentBelowUserTop = scrollHeightWithoutSpacer - userTopInContent;
|
|
2466
|
+
const needed = this._container.clientHeight - contentBelowUserTop;
|
|
2467
|
+
const maxSpacer = this._container.clientHeight;
|
|
2468
|
+
this._setSpacerHeight(Math.min(Math.max(0, needed), maxSpacer));
|
|
2469
|
+
if (this._isAutoScrollEnabled) {
|
|
2470
|
+
this._scrollToBottom();
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
/**
|
|
2474
|
+
* Schedule a spacer recalculation on the next animation frame.
|
|
2475
|
+
* Batches multiple rapid calls (e.g. during token streaming) into one.
|
|
2476
|
+
*
|
|
2477
|
+
* Single-RAF intentional: both the scroll-to-bottom queued by MutationObserver
|
|
2478
|
+
* and this spacer recalculation must land in the *same* frame so the browser
|
|
2479
|
+
* paints exactly once — with the correct scroll position *and* the correct
|
|
2480
|
+
* spacer height. A double-RAF would put the spacer shrink one frame after the
|
|
2481
|
+
* scroll, causing a 1-frame layout jump during streaming.
|
|
2482
|
+
*/
|
|
2483
|
+
_scheduleSpacerUpdate() {
|
|
2484
|
+
if (this._spacerRafId !== null) return;
|
|
2485
|
+
this._spacerRafId = requestAnimationFrame(() => {
|
|
2486
|
+
this._spacerRafId = null;
|
|
2487
|
+
this._recalculateSpacer();
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* Cap the number of rendered bubbles in the DOM (perf ceiling only).
|
|
2492
|
+
*
|
|
2493
|
+
* Drops the oldest `<aparte-chat-bubble>` elements beyond `_maxRenderedBubbles`
|
|
2494
|
+
* from the DOM. It **never** touches the MessageRepository — the conversation
|
|
2495
|
+
* model and its persistence snapshot stay complete (retention/eviction is a
|
|
2496
|
+
* consumer/persistence concern, not the viewport's). No-op when a framework
|
|
2497
|
+
* owns the DOM.
|
|
2498
|
+
*/
|
|
2499
|
+
_pruneRenderedBubbles() {
|
|
2500
|
+
if (this._frameworkManagedDOM) return;
|
|
2501
|
+
const wrapper = this.querySelector(".aparte-messages-wrapper");
|
|
2502
|
+
if (!wrapper) return;
|
|
2503
|
+
const bubbles = wrapper.querySelectorAll("aparte-chat-bubble");
|
|
2504
|
+
const excess = bubbles.length - this._maxRenderedBubbles;
|
|
2505
|
+
for (let i2 = 0; i2 < excess; i2++) {
|
|
2506
|
+
bubbles[i2]?.remove();
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
_warnMaxMessagesDeprecated() {
|
|
2510
|
+
if (this._warnedMaxMessagesDeprecation) return;
|
|
2511
|
+
this._warnedMaxMessagesDeprecation = true;
|
|
2512
|
+
console.warn(
|
|
2513
|
+
"[Aparte] `maxMessages` / `max-messages` on aparte-chat-viewport is deprecated: it used to silently evict messages from the conversation model. It now only caps rendered bubbles in the DOM — use `maxRenderedBubbles` / `max-rendered-bubbles`. For actual history retention, configure it on your ConversationManager instead."
|
|
2514
|
+
);
|
|
2515
|
+
}
|
|
2516
|
+
_cleanup() {
|
|
2517
|
+
this._container?.removeEventListener("scroll", this._handleScroll);
|
|
2518
|
+
this._resizeObserver?.disconnect();
|
|
2519
|
+
this._mutationObserver?.disconnect();
|
|
2520
|
+
this._resizeObserver = null;
|
|
2521
|
+
this._mutationObserver = null;
|
|
2522
|
+
if (this._spacerRafId !== null) {
|
|
2523
|
+
cancelAnimationFrame(this._spacerRafId);
|
|
2524
|
+
this._spacerRafId = null;
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
if (!customElements.get("aparte-chat-viewport")) {
|
|
2529
|
+
customElements.define("aparte-chat-viewport", AparteChatViewport);
|
|
2530
|
+
}
|
|
2531
|
+
class AparteComposer extends HTMLElement {
|
|
2532
|
+
_value = "";
|
|
2533
|
+
_streaming = false;
|
|
2534
|
+
_attachments = [];
|
|
2535
|
+
_listeners = /* @__PURE__ */ new Map();
|
|
2536
|
+
_panelActive = false;
|
|
2537
|
+
_panelSubmitEnabled = false;
|
|
2538
|
+
_panelOnSubmit = null;
|
|
2539
|
+
// Internal bus events that represent an observable STATE change — these are
|
|
2540
|
+
// mirrored to the public `aparte-composer-change` DOM event. `submit`/`cancel`
|
|
2541
|
+
// are actions, not state, and are covered by `aparte-send`/`aparte-cancel`.
|
|
2542
|
+
static _STATE_EVENTS = /* @__PURE__ */ new Set([
|
|
2543
|
+
"value-change",
|
|
2544
|
+
"streaming-change",
|
|
2545
|
+
"disabled-change",
|
|
2546
|
+
"attachments-change",
|
|
2547
|
+
"panel-change"
|
|
2548
|
+
]);
|
|
2549
|
+
// Window event bindings
|
|
2550
|
+
_onMessageStart = this._handleMessageStart.bind(this);
|
|
2551
|
+
_onMessageDone = this._handleMessageDone.bind(this);
|
|
2552
|
+
/** Config governing THIS composer (nearest instance boundary, else global). */
|
|
2553
|
+
_cfg = AparteConfig;
|
|
2554
|
+
_configUnsub = null;
|
|
2555
|
+
/** True while `requireModelSelection` is on AND no model is selected — blocks send. */
|
|
2556
|
+
_modelGated = false;
|
|
2557
|
+
_onConfigChange = () => {
|
|
2558
|
+
this._evaluateModelGate();
|
|
2559
|
+
};
|
|
2560
|
+
static get observedAttributes() {
|
|
2561
|
+
return ["placeholder", "disabled", "target"];
|
|
2562
|
+
}
|
|
2563
|
+
connectedCallback() {
|
|
2564
|
+
window.addEventListener("aparte-message-start", this._onMessageStart);
|
|
2565
|
+
window.addEventListener("aparte-message-done", this._onMessageDone);
|
|
2566
|
+
window.addEventListener("aparte-message-error", this._onMessageDone);
|
|
2567
|
+
window.addEventListener("aparte-message-aborted", this._onMessageDone);
|
|
2568
|
+
this._cfg = resolveConfig(this);
|
|
2569
|
+
this._configUnsub = this._cfg.subscribe(this._onConfigChange);
|
|
2570
|
+
this._evaluateModelGate();
|
|
2571
|
+
}
|
|
2572
|
+
disconnectedCallback() {
|
|
2573
|
+
window.removeEventListener("aparte-message-start", this._onMessageStart);
|
|
2574
|
+
window.removeEventListener("aparte-message-done", this._onMessageDone);
|
|
2575
|
+
window.removeEventListener("aparte-message-error", this._onMessageDone);
|
|
2576
|
+
window.removeEventListener("aparte-message-aborted", this._onMessageDone);
|
|
2577
|
+
this._configUnsub?.();
|
|
2578
|
+
this._configUnsub = null;
|
|
2579
|
+
this._listeners.clear();
|
|
2580
|
+
}
|
|
2581
|
+
attributeChangedCallback(name, _old, value) {
|
|
2582
|
+
if (name === "disabled") {
|
|
2583
|
+
this._emit("disabled-change", { disabled: value !== null });
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
// ── Public API ─────────────────────────────────────────────────────────
|
|
2587
|
+
get value() {
|
|
2588
|
+
return this._value;
|
|
2589
|
+
}
|
|
2590
|
+
get streaming() {
|
|
2591
|
+
return this._streaming;
|
|
2592
|
+
}
|
|
2593
|
+
get disabled() {
|
|
2594
|
+
return this.hasAttribute("disabled");
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* When false, Shift+Enter submits and a bare Enter inserts a newline —
|
|
2598
|
+
* the inverse of the default. Driven by the `submit-on-enter` attribute.
|
|
2599
|
+
*/
|
|
2600
|
+
get submitOnEnter() {
|
|
2601
|
+
return this.getAttribute("submit-on-enter") !== "false";
|
|
2602
|
+
}
|
|
2603
|
+
get attachments() {
|
|
2604
|
+
return this._attachments;
|
|
2605
|
+
}
|
|
2606
|
+
get placeholder() {
|
|
2607
|
+
return this.getAttribute("placeholder") ?? "";
|
|
2608
|
+
}
|
|
2609
|
+
get targetId() {
|
|
2610
|
+
return this.getAttribute("target");
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Snapshot of the composer's observable state. Pair with the
|
|
2614
|
+
* `aparte-composer-change` DOM event to drive a custom send button or footer
|
|
2615
|
+
* control that lives outside the composer package:
|
|
2616
|
+
*
|
|
2617
|
+
* @example
|
|
2618
|
+
* // A custom send button. Keep it CLICKABLE while streaming — submit()
|
|
2619
|
+
* // routes to cancel() when a response is in flight, so one button is
|
|
2620
|
+
* // Send/Stop. Disabling it on `streaming` would make "stop" unreachable.
|
|
2621
|
+
* composer.addEventListener('aparte-composer-change', (e) => {
|
|
2622
|
+
* const { streaming, disabled, value, attachments } = e.detail.state;
|
|
2623
|
+
* myButton.textContent = streaming ? 'Stop' : 'Send';
|
|
2624
|
+
* myButton.disabled = disabled || (!streaming && !value.trim() && attachments.length === 0);
|
|
2625
|
+
* });
|
|
2626
|
+
* myButton.addEventListener('click', () => composer.submit()); // send or stop
|
|
2627
|
+
*/
|
|
2628
|
+
getState() {
|
|
2629
|
+
return {
|
|
2630
|
+
value: this._value,
|
|
2631
|
+
streaming: this._streaming,
|
|
2632
|
+
disabled: this.disabled,
|
|
2633
|
+
attachments: [...this._attachments],
|
|
2634
|
+
panelActive: this._panelActive,
|
|
2635
|
+
submitEnabled: this._panelSubmitEnabled
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
setValue(value) {
|
|
2639
|
+
this._value = value;
|
|
2640
|
+
this._emit("value-change", { value });
|
|
2641
|
+
}
|
|
2642
|
+
addAttachments(files) {
|
|
2643
|
+
this._attachments = [...this._attachments, ...Array.from(files)];
|
|
2644
|
+
this._emit("attachments-change", { attachments: this._attachments });
|
|
2645
|
+
}
|
|
2646
|
+
removeAttachment(file) {
|
|
2647
|
+
this._attachments = this._attachments.filter((f2) => f2 !== file);
|
|
2648
|
+
this._emit("attachments-change", { attachments: this._attachments });
|
|
2649
|
+
}
|
|
2650
|
+
clearAttachments() {
|
|
2651
|
+
this._attachments = [];
|
|
2652
|
+
this._emit("attachments-change", { attachments: [] });
|
|
2653
|
+
}
|
|
2654
|
+
/** Inject a panel into the composer, hiding the text input. The send button calls onSubmit when clicked. */
|
|
2655
|
+
showPanel(panel, options) {
|
|
2656
|
+
this.hidePanel();
|
|
2657
|
+
const inputEl = this.querySelector("aparte-composer-input");
|
|
2658
|
+
if (inputEl) inputEl.style.display = "none";
|
|
2659
|
+
panel.dataset["apartePanel"] = "true";
|
|
2660
|
+
if (inputEl) {
|
|
2661
|
+
inputEl.insertAdjacentElement("afterend", panel);
|
|
2662
|
+
} else {
|
|
2663
|
+
this.appendChild(panel);
|
|
2664
|
+
}
|
|
2665
|
+
this._panelActive = true;
|
|
2666
|
+
this._panelSubmitEnabled = options?.submitEnabled ?? false;
|
|
2667
|
+
this._panelOnSubmit = options?.onSubmit ?? null;
|
|
2668
|
+
this._emit("panel-change", { active: true, submitEnabled: this._panelSubmitEnabled });
|
|
2669
|
+
}
|
|
2670
|
+
/** Remove the panel and restore the text input. */
|
|
2671
|
+
hidePanel() {
|
|
2672
|
+
const existing = this.querySelector("[data-aparte-panel]");
|
|
2673
|
+
if (existing) existing.remove();
|
|
2674
|
+
const inputEl = this.querySelector("aparte-composer-input");
|
|
2675
|
+
if (inputEl) inputEl.style.display = "";
|
|
2676
|
+
this._panelActive = false;
|
|
2677
|
+
this._panelSubmitEnabled = false;
|
|
2678
|
+
this._panelOnSubmit = null;
|
|
2679
|
+
this._emit("panel-change", { active: false, submitEnabled: false });
|
|
2680
|
+
this.focus();
|
|
2681
|
+
}
|
|
2682
|
+
/** Update the send button enabled state while a panel is active. */
|
|
2683
|
+
setPanelSubmitEnabled(enabled) {
|
|
2684
|
+
if (!this._panelActive) return;
|
|
2685
|
+
this._panelSubmitEnabled = enabled;
|
|
2686
|
+
this._emit("panel-change", { active: true, submitEnabled: enabled });
|
|
2687
|
+
}
|
|
2688
|
+
get panelActive() {
|
|
2689
|
+
return this._panelActive;
|
|
2690
|
+
}
|
|
2691
|
+
/**
|
|
2692
|
+
* Recompute the model gate from the resolved config. When
|
|
2693
|
+
* `requireModelSelection` is on and no model is selected, block sending and
|
|
2694
|
+
* reflect `data-model-gated` so the shipped CSS greys the composer. Re-runs on
|
|
2695
|
+
* every config change (e.g. the model selector's auto-select firing).
|
|
2696
|
+
*/
|
|
2697
|
+
_evaluateModelGate() {
|
|
2698
|
+
const gated = this._cfg.getRequireModelSelection() && !this._cfg.hasSelectedModel();
|
|
2699
|
+
if (gated === this._modelGated) return;
|
|
2700
|
+
this._modelGated = gated;
|
|
2701
|
+
this.toggleAttribute("data-model-gated", gated);
|
|
2702
|
+
}
|
|
2703
|
+
/** Submit the current value. Called by aparte-composer-send or programmatically. */
|
|
2704
|
+
submit() {
|
|
2705
|
+
if (this._panelActive) {
|
|
2706
|
+
if (this._panelSubmitEnabled) this._panelOnSubmit?.();
|
|
2707
|
+
return;
|
|
2708
|
+
}
|
|
2709
|
+
if (this._streaming) {
|
|
2710
|
+
this.cancel();
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
const value = this._value.trim();
|
|
2714
|
+
if (!value && this._attachments.length === 0) return;
|
|
2715
|
+
if (this.disabled) return;
|
|
2716
|
+
if (this._modelGated) return;
|
|
2717
|
+
this._emit("submit", { value, attachments: this._attachments });
|
|
2718
|
+
const detail = {
|
|
2719
|
+
content: value,
|
|
2720
|
+
timestamp: Date.now(),
|
|
2721
|
+
targetId: this.targetId ?? void 0,
|
|
2722
|
+
files: this._attachments.length > 0 ? [...this._attachments] : void 0
|
|
2723
|
+
};
|
|
2724
|
+
this.dispatchEvent(new CustomEvent("aparte-send", {
|
|
2725
|
+
bubbles: true,
|
|
2726
|
+
composed: true,
|
|
2727
|
+
detail
|
|
2728
|
+
}));
|
|
2729
|
+
this.setValue("");
|
|
2730
|
+
this.clearAttachments();
|
|
2731
|
+
}
|
|
2732
|
+
/** Cancel the current streaming response. */
|
|
2733
|
+
cancel() {
|
|
2734
|
+
this._emit("cancel", {});
|
|
2735
|
+
this.dispatchEvent(new CustomEvent("aparte-cancel", { bubbles: true, composed: true }));
|
|
2736
|
+
const abortDetail = { targetId: this.targetId ?? void 0 };
|
|
2737
|
+
window.dispatchEvent(new CustomEvent("aparte-abort", { bubbles: false, detail: abortDetail }));
|
|
2738
|
+
window.dispatchEvent(new CustomEvent("aparte-message-aborted", { bubbles: false, detail: abortDetail }));
|
|
2739
|
+
}
|
|
2740
|
+
/**
|
|
2741
|
+
* Reset the composer to its initial state.
|
|
2742
|
+
* Clears value, attachments, and hides any active panel.
|
|
2743
|
+
* Call this when switching conversations.
|
|
2744
|
+
*/
|
|
2745
|
+
reset() {
|
|
2746
|
+
this.setValue("");
|
|
2747
|
+
this.clearAttachments();
|
|
2748
|
+
if (this._panelActive) this.hidePanel();
|
|
2749
|
+
}
|
|
2750
|
+
/** Focus the input primitive inside this composer. */
|
|
2751
|
+
focus() {
|
|
2752
|
+
const input = this.querySelector("aparte-composer-input");
|
|
2753
|
+
input?.focus();
|
|
2754
|
+
}
|
|
2755
|
+
// ── Internal pub/sub ────────────────────────────────────────────────────
|
|
2756
|
+
_emit(event, payload) {
|
|
2757
|
+
this._listeners.get(event)?.forEach((cb) => cb(payload));
|
|
2758
|
+
if (AparteComposer._STATE_EVENTS.has(event)) {
|
|
2759
|
+
this.dispatchEvent(new CustomEvent("aparte-composer-change", {
|
|
2760
|
+
bubbles: true,
|
|
2761
|
+
composed: true,
|
|
2762
|
+
detail: { state: this.getState(), composer: this }
|
|
2763
|
+
}));
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
_on(event, cb) {
|
|
2767
|
+
if (!this._listeners.has(event)) this._listeners.set(event, /* @__PURE__ */ new Set());
|
|
2768
|
+
this._listeners.get(event).add(cb);
|
|
2769
|
+
return () => this._listeners.get(event)?.delete(cb);
|
|
2770
|
+
}
|
|
2771
|
+
// ── Window events ───────────────────────────────────────────────────────
|
|
2772
|
+
/** A window lifecycle event is for THIS composer when neither side is scoped
|
|
2773
|
+
* (single-instance broadcast) or the target ids match (multi-chat page).
|
|
2774
|
+
* Without this filter, streaming in one chat flips every composer's state. */
|
|
2775
|
+
_isForThisComposer(e2) {
|
|
2776
|
+
const evtTargetId = e2.detail?.targetId;
|
|
2777
|
+
return !evtTargetId || !this.targetId || evtTargetId === this.targetId;
|
|
2778
|
+
}
|
|
2779
|
+
_handleMessageStart(e2) {
|
|
2780
|
+
if (!this._isForThisComposer(e2)) return;
|
|
2781
|
+
this._streaming = true;
|
|
2782
|
+
this._emit("streaming-change", { streaming: true });
|
|
2783
|
+
}
|
|
2784
|
+
_handleMessageDone(e2) {
|
|
2785
|
+
if (!this._isForThisComposer(e2)) return;
|
|
2786
|
+
this._streaming = false;
|
|
2787
|
+
this._emit("streaming-change", { streaming: false });
|
|
2788
|
+
if (this._panelActive) this.hidePanel();
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
if (!customElements.get("aparte-composer")) {
|
|
2792
|
+
customElements.define("aparte-composer", AparteComposer);
|
|
2793
|
+
}
|
|
2794
|
+
class AparteComposerInput extends HTMLElement {
|
|
2795
|
+
_editor = null;
|
|
2796
|
+
_maxHeight = 200;
|
|
2797
|
+
_minHeight = 44;
|
|
2798
|
+
_unsubscribes = [];
|
|
2799
|
+
// Bound handlers
|
|
2800
|
+
_onInput = this._handleInput.bind(this);
|
|
2801
|
+
_onKeydown = this._handleKeydown.bind(this);
|
|
2802
|
+
_onFocus = this._handleFocus.bind(this);
|
|
2803
|
+
_onBlur = this._handleBlur.bind(this);
|
|
2804
|
+
_onPaste = this._handlePaste.bind(this);
|
|
2805
|
+
static get observedAttributes() {
|
|
2806
|
+
return ["placeholder", "max-height", "min-height", "disabled"];
|
|
2807
|
+
}
|
|
2808
|
+
connectedCallback() {
|
|
2809
|
+
this._render();
|
|
2810
|
+
this._connectToRoot();
|
|
2811
|
+
this._scheduleInitialReflow();
|
|
2812
|
+
}
|
|
2813
|
+
disconnectedCallback() {
|
|
2814
|
+
this._editor?.removeEventListener("input", this._onInput);
|
|
2815
|
+
this._editor?.removeEventListener("keydown", this._onKeydown);
|
|
2816
|
+
this._editor?.removeEventListener("focus", this._onFocus);
|
|
2817
|
+
this._editor?.removeEventListener("blur", this._onBlur);
|
|
2818
|
+
this._editor?.removeEventListener("paste", this._onPaste);
|
|
2819
|
+
this._unsubscribes.forEach((fn) => fn());
|
|
2820
|
+
this._unsubscribes = [];
|
|
2821
|
+
}
|
|
2822
|
+
attributeChangedCallback(name, _old, value) {
|
|
2823
|
+
if (name === "placeholder") this._updatePlaceholder();
|
|
2824
|
+
if (name === "max-height") this._maxHeight = parseInt(value || "200", 10);
|
|
2825
|
+
if (name === "min-height") this._minHeight = parseInt(value || "44", 10);
|
|
2826
|
+
if (name === "disabled") this._updateDisabled(value !== null);
|
|
2827
|
+
}
|
|
2828
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
2829
|
+
getValue() {
|
|
2830
|
+
if (!this._editor) return "";
|
|
2831
|
+
let out = "";
|
|
2832
|
+
const walk = (node) => {
|
|
2833
|
+
node.childNodes.forEach((child) => {
|
|
2834
|
+
if (child.nodeType === Node.TEXT_NODE) out += child.textContent ?? "";
|
|
2835
|
+
else if (child.nodeName === "BR") out += "\n";
|
|
2836
|
+
else walk(child);
|
|
2837
|
+
});
|
|
2838
|
+
};
|
|
2839
|
+
walk(this._editor);
|
|
2840
|
+
return out.trim();
|
|
2841
|
+
}
|
|
2842
|
+
setValue(value) {
|
|
2843
|
+
if (!this._editor) return;
|
|
2844
|
+
this._editor.textContent = value;
|
|
2845
|
+
this._updatePlaceholderVisibility();
|
|
2846
|
+
this._adjustHeight();
|
|
2847
|
+
this._getRoot()?.setValue(value);
|
|
2848
|
+
}
|
|
2849
|
+
clear() {
|
|
2850
|
+
if (!this._editor) return;
|
|
2851
|
+
this._editor.innerHTML = "";
|
|
2852
|
+
this._updatePlaceholderVisibility();
|
|
2853
|
+
this._adjustHeight();
|
|
2854
|
+
this._getRoot()?.setValue("");
|
|
2855
|
+
}
|
|
2856
|
+
focus() {
|
|
2857
|
+
this._editor?.focus();
|
|
2858
|
+
}
|
|
2859
|
+
blur() {
|
|
2860
|
+
this._editor?.blur();
|
|
2861
|
+
}
|
|
2862
|
+
/** Focus the editor and place the caret at the very end of its content. */
|
|
2863
|
+
focusEnd() {
|
|
2864
|
+
if (!this._editor) return;
|
|
2865
|
+
this._editor.focus();
|
|
2866
|
+
const sel = this.ownerDocument?.getSelection();
|
|
2867
|
+
if (!sel) return;
|
|
2868
|
+
const range = this.ownerDocument.createRange();
|
|
2869
|
+
range.selectNodeContents(this._editor);
|
|
2870
|
+
range.collapse(false);
|
|
2871
|
+
sel.removeAllRanges();
|
|
2872
|
+
sel.addRange(range);
|
|
2873
|
+
}
|
|
2874
|
+
// ── Private ─────────────────────────────────────────────────────────────
|
|
2875
|
+
_getRoot() {
|
|
2876
|
+
return this.closest("aparte-composer");
|
|
2877
|
+
}
|
|
2878
|
+
_getPlaceholder() {
|
|
2879
|
+
return this.getAttribute("placeholder") || this._getRoot()?.placeholder || resolveConfig(this).t("inputPlaceholder") || "Type a message...";
|
|
2880
|
+
}
|
|
2881
|
+
_render() {
|
|
2882
|
+
if (this.querySelector(".aparte-ci-editor")) return;
|
|
2883
|
+
const disabled = this.hasAttribute("disabled") || this._getRoot()?.disabled || false;
|
|
2884
|
+
const placeholder = this._escapeAttr(this._getPlaceholder());
|
|
2885
|
+
this.innerHTML = `<div
|
|
2886
|
+
class="aparte-ci-editor"
|
|
2887
|
+
contenteditable="${!disabled}"
|
|
2888
|
+
role="textbox"
|
|
2889
|
+
aria-multiline="true"
|
|
2890
|
+
aria-label="${placeholder}"
|
|
2891
|
+
tabindex="0"
|
|
2892
|
+
aria-disabled="${disabled}"
|
|
2893
|
+
data-placeholder="${placeholder}"
|
|
2894
|
+
></div>`;
|
|
2895
|
+
this._editor = this.querySelector(".aparte-ci-editor");
|
|
2896
|
+
this._editor?.addEventListener("input", this._onInput);
|
|
2897
|
+
this._editor?.addEventListener("keydown", this._onKeydown);
|
|
2898
|
+
this._editor?.addEventListener("focus", this._onFocus);
|
|
2899
|
+
this._editor?.addEventListener("blur", this._onBlur);
|
|
2900
|
+
this._editor?.addEventListener("paste", this._onPaste);
|
|
2901
|
+
this._adjustHeight();
|
|
2902
|
+
}
|
|
2903
|
+
_connectToRoot() {
|
|
2904
|
+
const root = this._getRoot();
|
|
2905
|
+
if (!root) return;
|
|
2906
|
+
this._unsubscribes.push(
|
|
2907
|
+
root._on("disabled-change", ({ disabled }) => this._updateDisabled(disabled))
|
|
2908
|
+
);
|
|
2909
|
+
this._unsubscribes.push(
|
|
2910
|
+
root._on("value-change", ({ value }) => {
|
|
2911
|
+
if (value === "" && this.getValue() !== "") this.clear();
|
|
2912
|
+
})
|
|
2913
|
+
);
|
|
2914
|
+
this._unsubscribes.push(
|
|
2915
|
+
root._on("streaming-change", ({ streaming }) => {
|
|
2916
|
+
this._updateDisabled(streaming || root.disabled);
|
|
2917
|
+
})
|
|
2918
|
+
);
|
|
2919
|
+
}
|
|
2920
|
+
_handleInput() {
|
|
2921
|
+
this._adjustHeight();
|
|
2922
|
+
if (this._editor && !this._editor.textContent?.trim() && this._editor.innerHTML !== "") {
|
|
2923
|
+
this._editor.innerHTML = "";
|
|
2924
|
+
}
|
|
2925
|
+
this._updatePlaceholderVisibility();
|
|
2926
|
+
const value = this.getValue();
|
|
2927
|
+
this._getRoot()?.setValue(value);
|
|
2928
|
+
}
|
|
2929
|
+
_handleKeydown(e2) {
|
|
2930
|
+
if (e2.isComposing || e2.keyCode === 229) return;
|
|
2931
|
+
if (e2.key !== "Enter") return;
|
|
2932
|
+
const submitOnEnter = this._getRoot()?.submitOnEnter ?? true;
|
|
2933
|
+
const submits = submitOnEnter ? !e2.shiftKey : e2.shiftKey;
|
|
2934
|
+
if (submits) {
|
|
2935
|
+
e2.preventDefault();
|
|
2936
|
+
const root = this._getRoot();
|
|
2937
|
+
if (root) {
|
|
2938
|
+
root.submit();
|
|
2939
|
+
} else {
|
|
2940
|
+
this.dispatchEvent(new CustomEvent("aparte-composer-submit", { bubbles: true }));
|
|
2941
|
+
}
|
|
2942
|
+
} else {
|
|
2943
|
+
e2.preventDefault();
|
|
2944
|
+
if (!this._editor?.textContent) return;
|
|
2945
|
+
this.ownerDocument?.execCommand("insertLineBreak");
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
_handleFocus() {
|
|
2949
|
+
this.classList.add("is-focused");
|
|
2950
|
+
}
|
|
2951
|
+
_handleBlur() {
|
|
2952
|
+
this.classList.remove("is-focused");
|
|
2953
|
+
}
|
|
2954
|
+
_handlePaste(e2) {
|
|
2955
|
+
e2.preventDefault();
|
|
2956
|
+
const cd = e2.clipboardData;
|
|
2957
|
+
if (!cd) return;
|
|
2958
|
+
const imageFile = Array.from(cd.items).find((i2) => i2.type.startsWith("image/"))?.getAsFile();
|
|
2959
|
+
if (imageFile) {
|
|
2960
|
+
this._getRoot()?.addAttachments([imageFile]);
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
const text = cd.getData("text/plain");
|
|
2964
|
+
if (text) {
|
|
2965
|
+
document.execCommand("insertText", false, text);
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
_adjustHeight() {
|
|
2969
|
+
if (!this._editor) return;
|
|
2970
|
+
this._editor.style.height = "0px";
|
|
2971
|
+
const floor = this.hasAttribute("min-height") ? this._minHeight : 0;
|
|
2972
|
+
const contentHeight = this._editor.scrollHeight;
|
|
2973
|
+
const h2 = Math.min(Math.max(contentHeight, floor), this._maxHeight);
|
|
2974
|
+
this._editor.style.height = `${h2}px`;
|
|
2975
|
+
this._editor.style.overflowY = contentHeight > this._maxHeight ? "auto" : "hidden";
|
|
2976
|
+
}
|
|
2977
|
+
/**
|
|
2978
|
+
* The first `_adjustHeight()` runs synchronously in `_render()` on connect —
|
|
2979
|
+
* before the stylesheet, flex layout and web fonts have necessarily settled.
|
|
2980
|
+
* On an unstabilized layout `scrollHeight` can read inflated, leaving the
|
|
2981
|
+
* editor stuck tall (misaligned with the composer controls) until the first
|
|
2982
|
+
* keystroke re-measures it. Re-measure once the layout is ready so it's
|
|
2983
|
+
* correct from the first paint.
|
|
2984
|
+
*/
|
|
2985
|
+
_scheduleInitialReflow() {
|
|
2986
|
+
if (typeof requestAnimationFrame === "function") {
|
|
2987
|
+
requestAnimationFrame(() => this._adjustHeight());
|
|
2988
|
+
}
|
|
2989
|
+
const fonts = document.fonts;
|
|
2990
|
+
fonts?.ready?.then(() => this._adjustHeight()).catch(() => {
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2993
|
+
_updatePlaceholder() {
|
|
2994
|
+
if (this._editor) {
|
|
2995
|
+
const p = this._getPlaceholder();
|
|
2996
|
+
this._editor.setAttribute("data-placeholder", p);
|
|
2997
|
+
this._editor.setAttribute("aria-label", p);
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
_updatePlaceholderVisibility() {
|
|
3001
|
+
}
|
|
3002
|
+
_updateDisabled(disabled) {
|
|
3003
|
+
if (!this._editor) return;
|
|
3004
|
+
this._editor.setAttribute("contenteditable", String(!disabled));
|
|
3005
|
+
this._editor.setAttribute("aria-disabled", String(disabled));
|
|
3006
|
+
}
|
|
3007
|
+
/** Escape a value before it lands in a double-quoted HTML attribute. */
|
|
3008
|
+
_escapeAttr(str) {
|
|
3009
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
if (!customElements.get("aparte-composer-input")) {
|
|
3013
|
+
customElements.define("aparte-composer-input", AparteComposerInput);
|
|
3014
|
+
}
|
|
3015
|
+
class AparteComposerSend extends HTMLElement {
|
|
3016
|
+
_button = null;
|
|
3017
|
+
_unsubscribes = [];
|
|
3018
|
+
// Bound handler
|
|
3019
|
+
_onClick = this._handleClick.bind(this);
|
|
3020
|
+
connectedCallback() {
|
|
3021
|
+
this._render();
|
|
3022
|
+
this._connectToRoot();
|
|
3023
|
+
}
|
|
3024
|
+
disconnectedCallback() {
|
|
3025
|
+
this._button?.removeEventListener("click", this._onClick);
|
|
3026
|
+
this._unsubscribes.forEach((fn) => fn());
|
|
3027
|
+
this._unsubscribes = [];
|
|
3028
|
+
}
|
|
3029
|
+
// ── Private ─────────────────────────────────────────────────────────────
|
|
3030
|
+
_getRoot() {
|
|
3031
|
+
return this.closest("aparte-composer");
|
|
3032
|
+
}
|
|
3033
|
+
_render() {
|
|
3034
|
+
if (this.querySelector(".aparte-cs-button")) return;
|
|
3035
|
+
const label = resolveConfig(this).t("sendButton") || "Send";
|
|
3036
|
+
const icon = this._getSendIcon();
|
|
3037
|
+
const root = this._getRoot();
|
|
3038
|
+
const disabled = !root || root.disabled || root.value.trim() === "";
|
|
3039
|
+
this.innerHTML = `<button
|
|
3040
|
+
class="aparte-cs-button aparte-send-button"
|
|
3041
|
+
aria-label="${label}"
|
|
3042
|
+
title="${label}"
|
|
3043
|
+
${disabled ? "disabled" : ""}
|
|
3044
|
+
>${icon}</button>`;
|
|
3045
|
+
this._button = this.querySelector(".aparte-cs-button");
|
|
3046
|
+
this._button?.addEventListener("click", this._onClick);
|
|
3047
|
+
}
|
|
3048
|
+
_connectToRoot() {
|
|
3049
|
+
const root = this._getRoot();
|
|
3050
|
+
if (!root) return;
|
|
3051
|
+
this._unsubscribes.push(
|
|
3052
|
+
root._on("value-change", () => this._syncState())
|
|
3053
|
+
);
|
|
3054
|
+
this._unsubscribes.push(
|
|
3055
|
+
root._on("disabled-change", () => this._syncState())
|
|
3056
|
+
);
|
|
3057
|
+
this._unsubscribes.push(
|
|
3058
|
+
root._on("streaming-change", ({ streaming }) => {
|
|
3059
|
+
if (this._getRoot()?.panelActive) return;
|
|
3060
|
+
this._syncStreamingState(streaming);
|
|
3061
|
+
})
|
|
3062
|
+
);
|
|
3063
|
+
this._unsubscribes.push(
|
|
3064
|
+
root._on("attachments-change", () => this._syncState())
|
|
3065
|
+
);
|
|
3066
|
+
this._unsubscribes.push(
|
|
3067
|
+
root._on("panel-change", ({ active, submitEnabled }) => {
|
|
3068
|
+
if (!this._button) return;
|
|
3069
|
+
if (active) {
|
|
3070
|
+
this._button.disabled = !submitEnabled;
|
|
3071
|
+
this._button.innerHTML = this._getSendIcon();
|
|
3072
|
+
const label = resolveConfig(this).t("submitButton") || "Submit";
|
|
3073
|
+
this._button.setAttribute("aria-label", label);
|
|
3074
|
+
this._button.setAttribute("title", label);
|
|
3075
|
+
this._button.classList.remove("is-streaming");
|
|
3076
|
+
} else {
|
|
3077
|
+
const root2 = this._getRoot();
|
|
3078
|
+
if (root2?.streaming) {
|
|
3079
|
+
this._syncStreamingState(true);
|
|
3080
|
+
} else {
|
|
3081
|
+
this._syncState();
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
})
|
|
3085
|
+
);
|
|
3086
|
+
}
|
|
3087
|
+
_handleClick(e2) {
|
|
3088
|
+
e2.preventDefault();
|
|
3089
|
+
this._getRoot()?.submit();
|
|
3090
|
+
}
|
|
3091
|
+
_syncState() {
|
|
3092
|
+
const root = this._getRoot();
|
|
3093
|
+
if (!root || !this._button) return;
|
|
3094
|
+
if (root.streaming) return;
|
|
3095
|
+
const isEmpty = root.value.trim() === "" && root.attachments.length === 0;
|
|
3096
|
+
this._button.disabled = root.disabled || isEmpty;
|
|
3097
|
+
this._button.innerHTML = this._getSendIcon();
|
|
3098
|
+
const label = resolveConfig(this).t("sendButton") || "Send";
|
|
3099
|
+
this._button.setAttribute("aria-label", label);
|
|
3100
|
+
this._button.setAttribute("title", label);
|
|
3101
|
+
this._button.classList.remove("is-streaming");
|
|
3102
|
+
}
|
|
3103
|
+
_syncStreamingState(streaming) {
|
|
3104
|
+
if (!this._button) return;
|
|
3105
|
+
if (streaming) {
|
|
3106
|
+
this._button.disabled = false;
|
|
3107
|
+
this._button.innerHTML = this._getStopIcon();
|
|
3108
|
+
this._button.setAttribute("aria-label", "Stop");
|
|
3109
|
+
this._button.setAttribute("title", "Stop");
|
|
3110
|
+
this._button.classList.add("is-streaming");
|
|
3111
|
+
} else {
|
|
3112
|
+
this._syncState();
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
_getSendIcon() {
|
|
3116
|
+
return resolveConfig(this).getIcon("send") || "Send";
|
|
3117
|
+
}
|
|
3118
|
+
_getStopIcon() {
|
|
3119
|
+
return resolveConfig(this).getIcon("stop");
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
if (!customElements.get("aparte-composer-send")) {
|
|
3123
|
+
customElements.define("aparte-composer-send", AparteComposerSend);
|
|
3124
|
+
}
|
|
3125
|
+
class AparteComposerCancel extends HTMLElement {
|
|
3126
|
+
_button = null;
|
|
3127
|
+
_unsubscribes = [];
|
|
3128
|
+
// Bound handler
|
|
3129
|
+
_onClick = this._handleClick.bind(this);
|
|
3130
|
+
connectedCallback() {
|
|
3131
|
+
this._render();
|
|
3132
|
+
this._connectToRoot();
|
|
3133
|
+
}
|
|
3134
|
+
disconnectedCallback() {
|
|
3135
|
+
this._button?.removeEventListener("click", this._onClick);
|
|
3136
|
+
this._unsubscribes.forEach((fn) => fn());
|
|
3137
|
+
this._unsubscribes = [];
|
|
3138
|
+
}
|
|
3139
|
+
// ── Private ─────────────────────────────────────────────────────────────
|
|
3140
|
+
_getRoot() {
|
|
3141
|
+
return this.closest("aparte-composer");
|
|
3142
|
+
}
|
|
3143
|
+
_render() {
|
|
3144
|
+
if (this.querySelector(".aparte-cc-button")) return;
|
|
3145
|
+
const label = resolveConfig(this).t("stopButton") || "Stop";
|
|
3146
|
+
const icon = this._getStopIcon();
|
|
3147
|
+
this.innerHTML = `<button
|
|
3148
|
+
class="aparte-cc-button"
|
|
3149
|
+
aria-label="${label}"
|
|
3150
|
+
title="${label}"
|
|
3151
|
+
hidden
|
|
3152
|
+
>${icon}</button>`;
|
|
3153
|
+
this._button = this.querySelector(".aparte-cc-button");
|
|
3154
|
+
this._button?.addEventListener("click", this._onClick);
|
|
3155
|
+
}
|
|
3156
|
+
_connectToRoot() {
|
|
3157
|
+
const root = this._getRoot();
|
|
3158
|
+
if (!root) return;
|
|
3159
|
+
this._unsubscribes.push(
|
|
3160
|
+
root._on("streaming-change", ({ streaming }) => {
|
|
3161
|
+
if (this._button) this._button.hidden = !streaming;
|
|
3162
|
+
})
|
|
3163
|
+
);
|
|
3164
|
+
if (root.streaming && this._button) this._button.hidden = false;
|
|
3165
|
+
}
|
|
3166
|
+
_handleClick(e2) {
|
|
3167
|
+
e2.preventDefault();
|
|
3168
|
+
this._getRoot()?.cancel();
|
|
3169
|
+
}
|
|
3170
|
+
_getStopIcon() {
|
|
3171
|
+
return resolveConfig(this).getIcon("stop");
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
if (!customElements.get("aparte-composer-cancel")) {
|
|
3175
|
+
customElements.define("aparte-composer-cancel", AparteComposerCancel);
|
|
3176
|
+
}
|
|
3177
|
+
const REMOVE_ICON = '<svg width="10" height="10" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><path d="M9.5 3.205 8.795 2.5 6 5.295 3.205 2.5 2.5 3.205 5.295 6 2.5 8.795l.705.705L6 6.705l2.795 2.795.705-.705L6.705 6z"/></svg>';
|
|
3178
|
+
class AparteComposerAttachments extends HTMLElement {
|
|
3179
|
+
_unsubscribes = [];
|
|
3180
|
+
/** Object URLs minted for image previews — revoked on re-render/disconnect. */
|
|
3181
|
+
_objectUrls = [];
|
|
3182
|
+
connectedCallback() {
|
|
3183
|
+
this._render([]);
|
|
3184
|
+
this._connectToRoot();
|
|
3185
|
+
}
|
|
3186
|
+
disconnectedCallback() {
|
|
3187
|
+
this._unsubscribes.forEach((fn) => fn());
|
|
3188
|
+
this._unsubscribes = [];
|
|
3189
|
+
this._revokeUrls();
|
|
3190
|
+
}
|
|
3191
|
+
// ── Private ─────────────────────────────────────────────────────────────
|
|
3192
|
+
_getRoot() {
|
|
3193
|
+
return this.closest("aparte-composer");
|
|
3194
|
+
}
|
|
3195
|
+
_connectToRoot() {
|
|
3196
|
+
const root = this._getRoot();
|
|
3197
|
+
if (!root) return;
|
|
3198
|
+
this._unsubscribes.push(
|
|
3199
|
+
root._on("attachments-change", ({ attachments }) => this._render(attachments))
|
|
3200
|
+
);
|
|
3201
|
+
this._render(root.attachments);
|
|
3202
|
+
}
|
|
3203
|
+
/** Release the previous render's blob URLs so they don't leak. */
|
|
3204
|
+
_revokeUrls() {
|
|
3205
|
+
this._objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
|
3206
|
+
this._objectUrls = [];
|
|
3207
|
+
}
|
|
3208
|
+
_render(files) {
|
|
3209
|
+
this.hidden = files.length === 0;
|
|
3210
|
+
this._revokeUrls();
|
|
3211
|
+
this.innerHTML = files.map((file) => {
|
|
3212
|
+
const name = this._escape(file.name);
|
|
3213
|
+
const remove = `<button class="aparte-thumb__remove" type="button" aria-label="Remove ${name}">${REMOVE_ICON}</button>`;
|
|
3214
|
+
if (file.type.startsWith("image/")) {
|
|
3215
|
+
const url = URL.createObjectURL(file);
|
|
3216
|
+
this._objectUrls.push(url);
|
|
3217
|
+
return `<div class="aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${url}" alt="${name}" /><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
|
|
3218
|
+
}
|
|
3219
|
+
return `<div class="aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${this._escape(this._ext(file.name))}</span><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
|
|
3220
|
+
}).join("");
|
|
3221
|
+
this.querySelectorAll(".aparte-thumb__remove").forEach((btn, i2) => {
|
|
3222
|
+
btn.addEventListener("click", (e2) => {
|
|
3223
|
+
e2.stopPropagation();
|
|
3224
|
+
const root = this._getRoot();
|
|
3225
|
+
if (root) root.removeAttachment(root.attachments[i2]);
|
|
3226
|
+
});
|
|
3227
|
+
});
|
|
3228
|
+
this.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
|
|
3229
|
+
tile.addEventListener("click", () => {
|
|
3230
|
+
const img = tile.querySelector(".aparte-thumb__img");
|
|
3231
|
+
if (!img) return;
|
|
3232
|
+
this.dispatchEvent(new CustomEvent("aparte-attachment-preview", {
|
|
3233
|
+
bubbles: true,
|
|
3234
|
+
composed: true,
|
|
3235
|
+
detail: { url: img.src, name: tile.getAttribute("title") ?? "" }
|
|
3236
|
+
}));
|
|
3237
|
+
});
|
|
3238
|
+
});
|
|
3239
|
+
}
|
|
3240
|
+
/** Uppercased file extension (≤4 chars), or 'FILE' when there is none. */
|
|
3241
|
+
_ext(filename) {
|
|
3242
|
+
const dot = filename.lastIndexOf(".");
|
|
3243
|
+
return dot > 0 ? filename.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
|
|
3244
|
+
}
|
|
3245
|
+
_escape(str) {
|
|
3246
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
if (!customElements.get("aparte-composer-attachments")) {
|
|
3250
|
+
customElements.define("aparte-composer-attachments", AparteComposerAttachments);
|
|
3251
|
+
}
|
|
3252
|
+
class AparteComposerAddAttachment extends HTMLElement {
|
|
3253
|
+
_button = null;
|
|
3254
|
+
_dragCleanup = null;
|
|
3255
|
+
_unsubscribes = [];
|
|
3256
|
+
// Bound handler
|
|
3257
|
+
_onClick = this._handleClick.bind(this);
|
|
3258
|
+
static get observedAttributes() {
|
|
3259
|
+
return ["accept", "multiple", "disabled"];
|
|
3260
|
+
}
|
|
3261
|
+
connectedCallback() {
|
|
3262
|
+
this._render();
|
|
3263
|
+
this._connectToRoot();
|
|
3264
|
+
this._setupDragDrop();
|
|
3265
|
+
}
|
|
3266
|
+
disconnectedCallback() {
|
|
3267
|
+
this._button?.removeEventListener("click", this._onClick);
|
|
3268
|
+
this._dragCleanup?.();
|
|
3269
|
+
this._unsubscribes.forEach((fn) => fn());
|
|
3270
|
+
this._unsubscribes = [];
|
|
3271
|
+
}
|
|
3272
|
+
attributeChangedCallback(name, _old, value) {
|
|
3273
|
+
if (name === "disabled" && this._button) {
|
|
3274
|
+
this._button.disabled = value !== null;
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
// ── Private ─────────────────────────────────────────────────────────────
|
|
3278
|
+
_getRoot() {
|
|
3279
|
+
return this.closest("aparte-composer");
|
|
3280
|
+
}
|
|
3281
|
+
_render() {
|
|
3282
|
+
if (this.querySelector(".aparte-caa-button")) return;
|
|
3283
|
+
const label = resolveConfig(this).t("actionUpload") || "Attach file";
|
|
3284
|
+
const icon = resolveConfig(this).getIcon("paperclip") || this._defaultIcon();
|
|
3285
|
+
const disabled = this.hasAttribute("disabled") || this._getRoot()?.disabled || false;
|
|
3286
|
+
this.innerHTML = `<button
|
|
3287
|
+
class="aparte-caa-button aparte-action-button"
|
|
3288
|
+
aria-label="${label}"
|
|
3289
|
+
title="${label}"
|
|
3290
|
+
type="button"
|
|
3291
|
+
${disabled ? "disabled" : ""}
|
|
3292
|
+
>${icon}</button>`;
|
|
3293
|
+
this._button = this.querySelector(".aparte-caa-button");
|
|
3294
|
+
this._button?.addEventListener("click", this._onClick);
|
|
3295
|
+
}
|
|
3296
|
+
_connectToRoot() {
|
|
3297
|
+
const root = this._getRoot();
|
|
3298
|
+
if (!root) return;
|
|
3299
|
+
this._unsubscribes.push(
|
|
3300
|
+
root._on("disabled-change", ({ disabled }) => {
|
|
3301
|
+
if (this._button) this._button.disabled = disabled;
|
|
3302
|
+
})
|
|
3303
|
+
);
|
|
3304
|
+
this._unsubscribes.push(
|
|
3305
|
+
root._on("streaming-change", ({ streaming }) => {
|
|
3306
|
+
if (this._button) this._button.disabled = streaming || root.disabled;
|
|
3307
|
+
})
|
|
3308
|
+
);
|
|
3309
|
+
}
|
|
3310
|
+
_handleClick() {
|
|
3311
|
+
const input = document.createElement("input");
|
|
3312
|
+
input.type = "file";
|
|
3313
|
+
input.multiple = !this.hasAttribute("multiple") || this.getAttribute("multiple") !== "false";
|
|
3314
|
+
const accept = this.getAttribute("accept");
|
|
3315
|
+
if (accept) input.accept = accept;
|
|
3316
|
+
input.style.display = "none";
|
|
3317
|
+
document.body.appendChild(input);
|
|
3318
|
+
input.addEventListener("change", () => {
|
|
3319
|
+
if (input.files?.length) this._getRoot()?.addAttachments(input.files);
|
|
3320
|
+
document.body.removeChild(input);
|
|
3321
|
+
}, { once: true });
|
|
3322
|
+
input.click();
|
|
3323
|
+
}
|
|
3324
|
+
_setupDragDrop() {
|
|
3325
|
+
const root = this._getRoot();
|
|
3326
|
+
if (!root) return;
|
|
3327
|
+
const prevent = (e2) => {
|
|
3328
|
+
e2.preventDefault();
|
|
3329
|
+
e2.stopPropagation();
|
|
3330
|
+
};
|
|
3331
|
+
const onDragOver = (e2) => {
|
|
3332
|
+
if (root.disabled) return;
|
|
3333
|
+
prevent(e2);
|
|
3334
|
+
root.classList.add("is-dragover");
|
|
3335
|
+
};
|
|
3336
|
+
const onDragLeave = (e2) => {
|
|
3337
|
+
prevent(e2);
|
|
3338
|
+
root.classList.remove("is-dragover");
|
|
3339
|
+
};
|
|
3340
|
+
const onDrop = (e2) => {
|
|
3341
|
+
prevent(e2);
|
|
3342
|
+
root.classList.remove("is-dragover");
|
|
3343
|
+
if (root.disabled) return;
|
|
3344
|
+
const files = e2.dataTransfer?.files;
|
|
3345
|
+
if (files?.length) this._getRoot()?.addAttachments(files);
|
|
3346
|
+
};
|
|
3347
|
+
root.addEventListener("dragover", onDragOver);
|
|
3348
|
+
root.addEventListener("dragleave", onDragLeave);
|
|
3349
|
+
root.addEventListener("drop", onDrop);
|
|
3350
|
+
this._dragCleanup = () => {
|
|
3351
|
+
root.removeEventListener("dragover", onDragOver);
|
|
3352
|
+
root.removeEventListener("dragleave", onDragLeave);
|
|
3353
|
+
root.removeEventListener("drop", onDrop);
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
_defaultIcon() {
|
|
3357
|
+
return `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>`;
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
if (!customElements.get("aparte-composer-add-attachment")) {
|
|
3361
|
+
customElements.define("aparte-composer-add-attachment", AparteComposerAddAttachment);
|
|
3362
|
+
}
|
|
3363
|
+
class AparteComposerAction extends HTMLElement {
|
|
3364
|
+
_button = null;
|
|
3365
|
+
_unsubscribes = [];
|
|
3366
|
+
// Bound handler
|
|
3367
|
+
_onClick = this._handleClick.bind(this);
|
|
3368
|
+
static get observedAttributes() {
|
|
3369
|
+
return ["icon", "label", "disabled"];
|
|
3370
|
+
}
|
|
3371
|
+
connectedCallback() {
|
|
3372
|
+
this._render();
|
|
3373
|
+
this._connectToRoot();
|
|
3374
|
+
}
|
|
3375
|
+
disconnectedCallback() {
|
|
3376
|
+
this._button?.removeEventListener("click", this._onClick);
|
|
3377
|
+
this._unsubscribes.forEach((fn) => fn());
|
|
3378
|
+
this._unsubscribes = [];
|
|
3379
|
+
}
|
|
3380
|
+
attributeChangedCallback(name, _old, value) {
|
|
3381
|
+
if (!this._button) return;
|
|
3382
|
+
if (name === "disabled") {
|
|
3383
|
+
this._button.disabled = value !== null;
|
|
3384
|
+
}
|
|
3385
|
+
if (name === "label") {
|
|
3386
|
+
this._button.setAttribute("aria-label", value ?? "");
|
|
3387
|
+
this._button.setAttribute("title", value ?? "");
|
|
3388
|
+
}
|
|
3389
|
+
if (name === "icon") {
|
|
3390
|
+
this._button.innerHTML = this._resolveIcon(value ?? "");
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
// ── Private ─────────────────────────────────────────────────────────────
|
|
3394
|
+
_getRoot() {
|
|
3395
|
+
return this.closest("aparte-composer");
|
|
3396
|
+
}
|
|
3397
|
+
_render() {
|
|
3398
|
+
if (this.querySelector(".aparte-cact-button")) return;
|
|
3399
|
+
const label = this._escapeAttr(this.getAttribute("label") ?? "");
|
|
3400
|
+
const icon = this._resolveIcon(this.getAttribute("icon") ?? "");
|
|
3401
|
+
const disabled = this.hasAttribute("disabled") || this._getRoot()?.disabled || false;
|
|
3402
|
+
this.innerHTML = `<button
|
|
3403
|
+
class="aparte-cact-button aparte-action-button"
|
|
3404
|
+
aria-label="${label}"
|
|
3405
|
+
title="${label}"
|
|
3406
|
+
type="button"
|
|
3407
|
+
${disabled ? "disabled" : ""}
|
|
3408
|
+
>${icon}</button>`;
|
|
3409
|
+
this._button = this.querySelector(".aparte-cact-button");
|
|
3410
|
+
this._button?.addEventListener("click", this._onClick);
|
|
3411
|
+
}
|
|
3412
|
+
_connectToRoot() {
|
|
3413
|
+
const root = this._getRoot();
|
|
3414
|
+
if (!root) return;
|
|
3415
|
+
this._unsubscribes.push(
|
|
3416
|
+
root._on("disabled-change", ({ disabled }) => {
|
|
3417
|
+
if (this._button) this._button.disabled = disabled || this.hasAttribute("disabled");
|
|
3418
|
+
})
|
|
3419
|
+
);
|
|
3420
|
+
this._unsubscribes.push(
|
|
3421
|
+
root._on("streaming-change", ({ streaming }) => {
|
|
3422
|
+
if (this._button) this._button.disabled = streaming || root.disabled || this.hasAttribute("disabled");
|
|
3423
|
+
})
|
|
3424
|
+
);
|
|
3425
|
+
}
|
|
3426
|
+
_handleClick(_e) {
|
|
3427
|
+
this.dispatchEvent(new CustomEvent("aparte-action-click", {
|
|
3428
|
+
bubbles: true,
|
|
3429
|
+
composed: true,
|
|
3430
|
+
detail: { actionId: this.getAttribute("action-id") ?? "", composer: this._getRoot() }
|
|
3431
|
+
}));
|
|
3432
|
+
}
|
|
3433
|
+
_resolveIcon(icon) {
|
|
3434
|
+
if (!icon) return "";
|
|
3435
|
+
if (icon.trimStart().startsWith("<")) return icon;
|
|
3436
|
+
return resolveConfig(this).getIcon(icon) ?? icon;
|
|
3437
|
+
}
|
|
3438
|
+
/** Escape a value before it lands in a double-quoted HTML attribute. */
|
|
3439
|
+
_escapeAttr(str) {
|
|
3440
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
if (!customElements.get("aparte-composer-action")) {
|
|
3444
|
+
customElements.define("aparte-composer-action", AparteComposerAction);
|
|
3445
|
+
}
|
|
3446
|
+
class AparteConversationList extends HTMLElement {
|
|
3447
|
+
_conversations = [];
|
|
3448
|
+
_activeId = null;
|
|
3449
|
+
static get observedAttributes() {
|
|
3450
|
+
return ["active-id"];
|
|
3451
|
+
}
|
|
3452
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────
|
|
3453
|
+
connectedCallback() {
|
|
3454
|
+
if (!this.classList.contains("aparte-conv-list")) {
|
|
3455
|
+
this.classList.add("aparte-conv-list");
|
|
3456
|
+
}
|
|
3457
|
+
if (!this.getAttribute("role")) {
|
|
3458
|
+
this.setAttribute("role", "navigation");
|
|
3459
|
+
}
|
|
3460
|
+
this._render();
|
|
3461
|
+
}
|
|
3462
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
3463
|
+
if (oldValue === newValue) return;
|
|
3464
|
+
if (name === "active-id") {
|
|
3465
|
+
this._activeId = newValue;
|
|
3466
|
+
this._updateActiveState();
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
// ─── Public API ───────────────────────────────────────────────────────
|
|
3470
|
+
/** Set the list of conversations to display. Triggers a re-render. */
|
|
3471
|
+
set conversations(items) {
|
|
3472
|
+
this._conversations = Array.isArray(items) ? items : [];
|
|
3473
|
+
this._render();
|
|
3474
|
+
}
|
|
3475
|
+
get conversations() {
|
|
3476
|
+
return this._conversations;
|
|
3477
|
+
}
|
|
3478
|
+
// ─── Rendering ────────────────────────────────────────────────────────
|
|
3479
|
+
_render() {
|
|
3480
|
+
this.innerHTML = this._conversations.map((conv) => this._renderItem(conv)).join("");
|
|
3481
|
+
this._bindEvents();
|
|
3482
|
+
}
|
|
3483
|
+
_renderItem(conv) {
|
|
3484
|
+
const locale = resolveConfig(this).getLocale();
|
|
3485
|
+
const isActive = conv.id === this._activeId;
|
|
3486
|
+
const isArchived = !!conv.archivedAt;
|
|
3487
|
+
const activeClass = isActive ? " aparte-conv-item--active" : "";
|
|
3488
|
+
const archivedClass = isArchived ? " aparte-conv-item--archived" : "";
|
|
3489
|
+
const escapedId = this._esc(conv.id);
|
|
3490
|
+
const escapedTitle = this._esc(conv.title || locale.newChat);
|
|
3491
|
+
const deleteLabel = this._esc(locale.deleteConversation);
|
|
3492
|
+
const archiveLabel = this._esc(locale["archiveConversation"] ?? "Archive conversation");
|
|
3493
|
+
const unarchiveLabel = this._esc(locale["unarchiveConversation"] ?? "Unarchive conversation");
|
|
3494
|
+
const archiveAction = isArchived ? "unarchive" : "archive";
|
|
3495
|
+
const archiveAriaLabel = isArchived ? unarchiveLabel : archiveLabel;
|
|
3496
|
+
const archiveIcon = isArchived ? `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 8 21 8"/><path d="M5 8v11a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8"/><polyline points="9 14 12 11 15 14"/><line x1="12" y1="11" x2="12" y2="19"/></svg>` : `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 8 21 8"/><path d="M5 8v11a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8"/><polyline points="9 14 12 17 15 14"/><line x1="12" y1="11" x2="12" y2="17"/></svg>`;
|
|
3497
|
+
return `
|
|
3498
|
+
<div
|
|
3499
|
+
class="aparte-conv-item${activeClass}${archivedClass}"
|
|
3500
|
+
role="button"
|
|
3501
|
+
tabindex="0"
|
|
3502
|
+
data-conv-id="${escapedId}"
|
|
3503
|
+
aria-current="${isActive ? "page" : "false"}"
|
|
3504
|
+
>
|
|
3505
|
+
<span class="aparte-conv-item__title">${escapedTitle}</span>
|
|
3506
|
+
<button
|
|
3507
|
+
class="aparte-conv-item__archive"
|
|
3508
|
+
type="button"
|
|
3509
|
+
data-archive-id="${escapedId}"
|
|
3510
|
+
data-archive-action="${archiveAction}"
|
|
3511
|
+
aria-label="${archiveAriaLabel}"
|
|
3512
|
+
tabindex="0"
|
|
3513
|
+
>${archiveIcon}</button>
|
|
3514
|
+
<button
|
|
3515
|
+
class="aparte-conv-item__delete"
|
|
3516
|
+
type="button"
|
|
3517
|
+
data-delete-id="${escapedId}"
|
|
3518
|
+
aria-label="${deleteLabel}"
|
|
3519
|
+
tabindex="0"
|
|
3520
|
+
>
|
|
3521
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
|
|
3522
|
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
3523
|
+
<path d="M18 6L6 18M6 6l12 12"/>
|
|
3524
|
+
</svg>
|
|
3525
|
+
</button>
|
|
3526
|
+
</div>`;
|
|
3527
|
+
}
|
|
3528
|
+
_bindEvents() {
|
|
3529
|
+
this.addEventListener("click", this._onClick);
|
|
3530
|
+
this.addEventListener("keydown", this._onKeydown);
|
|
3531
|
+
}
|
|
3532
|
+
_onClick = (e2) => {
|
|
3533
|
+
const target = e2.target;
|
|
3534
|
+
const archiveBtn = target.closest("[data-archive-id]");
|
|
3535
|
+
if (archiveBtn) {
|
|
3536
|
+
e2.stopPropagation();
|
|
3537
|
+
const id = archiveBtn.dataset["archiveId"];
|
|
3538
|
+
const action = archiveBtn.dataset["archiveAction"];
|
|
3539
|
+
const eventName = action === "unarchive" ? "aparte-unarchive-conversation" : "aparte-archive-conversation";
|
|
3540
|
+
this.dispatchEvent(new CustomEvent(
|
|
3541
|
+
eventName,
|
|
3542
|
+
{ detail: { id }, bubbles: true, composed: true }
|
|
3543
|
+
));
|
|
3544
|
+
return;
|
|
3545
|
+
}
|
|
3546
|
+
const deleteBtn = target.closest("[data-delete-id]");
|
|
3547
|
+
if (deleteBtn) {
|
|
3548
|
+
e2.stopPropagation();
|
|
3549
|
+
const id = deleteBtn.dataset["deleteId"];
|
|
3550
|
+
this.dispatchEvent(new CustomEvent(
|
|
3551
|
+
"aparte-delete-conversation",
|
|
3552
|
+
{ detail: { id }, bubbles: true, composed: true }
|
|
3553
|
+
));
|
|
3554
|
+
return;
|
|
3555
|
+
}
|
|
3556
|
+
const item = target.closest("[data-conv-id]");
|
|
3557
|
+
if (item) {
|
|
3558
|
+
const id = item.dataset["convId"];
|
|
3559
|
+
this.dispatchEvent(new CustomEvent(
|
|
3560
|
+
"aparte-select-conversation",
|
|
3561
|
+
{ detail: { id }, bubbles: true, composed: true }
|
|
3562
|
+
));
|
|
3563
|
+
}
|
|
3564
|
+
};
|
|
3565
|
+
_onKeydown = (e2) => {
|
|
3566
|
+
if (e2.key !== "Enter" && e2.key !== " ") return;
|
|
3567
|
+
const item = e2.target.closest("[data-conv-id]");
|
|
3568
|
+
if (item) {
|
|
3569
|
+
e2.preventDefault();
|
|
3570
|
+
item.click();
|
|
3571
|
+
}
|
|
3572
|
+
};
|
|
3573
|
+
/** Update active class without full re-render (perf optimisation). */
|
|
3574
|
+
_updateActiveState() {
|
|
3575
|
+
const items = this.querySelectorAll("[data-conv-id]");
|
|
3576
|
+
items.forEach((el) => {
|
|
3577
|
+
const isActive = el.dataset["convId"] === this._activeId;
|
|
3578
|
+
el.classList.toggle("aparte-conv-item--active", isActive);
|
|
3579
|
+
el.setAttribute("aria-current", isActive ? "page" : "false");
|
|
3580
|
+
});
|
|
3581
|
+
}
|
|
3582
|
+
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
3583
|
+
_esc(str) {
|
|
3584
|
+
return str.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'");
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
if (!customElements.get("aparte-conversation-list")) customElements.define("aparte-conversation-list", AparteConversationList);
|
|
3588
|
+
class AparteElicitation extends HTMLElement {
|
|
3589
|
+
_pending = null;
|
|
3590
|
+
_onTurnEnd = () => this._cancelPending();
|
|
3591
|
+
connectedCallback() {
|
|
3592
|
+
this.style.display = "none";
|
|
3593
|
+
this._injectStyles();
|
|
3594
|
+
resolveConfig(this).setElicitationPresenter(this._present);
|
|
3595
|
+
window.addEventListener("aparte-message-aborted", this._onTurnEnd);
|
|
3596
|
+
window.addEventListener("aparte-message-error", this._onTurnEnd);
|
|
3597
|
+
}
|
|
3598
|
+
disconnectedCallback() {
|
|
3599
|
+
const cfg = resolveConfig(this);
|
|
3600
|
+
if (cfg.getElicitationPresenter() === this._present) cfg.setElicitationPresenter(null);
|
|
3601
|
+
window.removeEventListener("aparte-message-aborted", this._onTurnEnd);
|
|
3602
|
+
window.removeEventListener("aparte-message-error", this._onTurnEnd);
|
|
3603
|
+
this._cancelPending();
|
|
3604
|
+
}
|
|
3605
|
+
_present = (request) => {
|
|
3606
|
+
if (this._pending) return Promise.resolve({ action: "cancel" });
|
|
3607
|
+
const composer = this._getComposer();
|
|
3608
|
+
if (!composer) return Promise.resolve({ action: "cancel" });
|
|
3609
|
+
return new Promise((resolve) => {
|
|
3610
|
+
let done = false;
|
|
3611
|
+
const settle = (result) => {
|
|
3612
|
+
if (done) return;
|
|
3613
|
+
done = true;
|
|
3614
|
+
this._pending = null;
|
|
3615
|
+
composer.hidePanel();
|
|
3616
|
+
resolve(result);
|
|
3617
|
+
};
|
|
3618
|
+
if (request.signal) {
|
|
3619
|
+
if (request.signal.aborted) {
|
|
3620
|
+
settle({ action: "cancel" });
|
|
3621
|
+
return;
|
|
3622
|
+
}
|
|
3623
|
+
request.signal.addEventListener("abort", () => settle({ action: "cancel" }), { once: true });
|
|
3624
|
+
}
|
|
3625
|
+
const panel = buildElicitationPanel(request.message, request.schema, () => {
|
|
3626
|
+
composer.setPanelSubmitEnabled(panel.isComplete());
|
|
3627
|
+
});
|
|
3628
|
+
const footer = document.createElement("div");
|
|
3629
|
+
footer.className = "aparte-elic-footer";
|
|
3630
|
+
const skip = document.createElement("button");
|
|
3631
|
+
skip.type = "button";
|
|
3632
|
+
skip.className = "aparte-elic-skip";
|
|
3633
|
+
skip.textContent = "Skip";
|
|
3634
|
+
skip.addEventListener("click", () => settle({ action: "decline" }));
|
|
3635
|
+
footer.appendChild(skip);
|
|
3636
|
+
panel.el.appendChild(footer);
|
|
3637
|
+
this._pending = { settle, composer };
|
|
3638
|
+
composer.showPanel(panel.el, {
|
|
3639
|
+
submitEnabled: panel.isComplete(),
|
|
3640
|
+
onSubmit: () => {
|
|
3641
|
+
if (panel.isComplete()) settle({ action: "accept", content: panel.getContent() });
|
|
3642
|
+
}
|
|
3643
|
+
});
|
|
3644
|
+
panel.focus();
|
|
3645
|
+
});
|
|
3646
|
+
};
|
|
3647
|
+
_cancelPending() {
|
|
3648
|
+
this._pending?.settle({ action: "cancel" });
|
|
3649
|
+
}
|
|
3650
|
+
_getComposer() {
|
|
3651
|
+
let node = this.parentElement;
|
|
3652
|
+
while (node) {
|
|
3653
|
+
const composer = node.querySelector("aparte-composer");
|
|
3654
|
+
if (composer && typeof composer.showPanel === "function") return composer;
|
|
3655
|
+
node = node.parentElement;
|
|
3656
|
+
}
|
|
3657
|
+
const doc = document.querySelector("aparte-composer");
|
|
3658
|
+
return doc && typeof doc.showPanel === "function" ? doc : null;
|
|
3659
|
+
}
|
|
3660
|
+
_injectStyles() {
|
|
3661
|
+
const id = "aparte-elicitation-styles";
|
|
3662
|
+
if (document.getElementById(id)) return;
|
|
3663
|
+
const style = document.createElement("style");
|
|
3664
|
+
style.id = id;
|
|
3665
|
+
style.textContent = `
|
|
3666
|
+
.aparte-elic-panel { display:flex; flex-direction:column; gap:6px; padding:6px 4px; width:100%; box-sizing:border-box; max-height:50vh; overflow-y:auto; }
|
|
3667
|
+
.aparte-elic-message { margin:0 6px 6px; font-size:.82rem; font-weight:600; color:var(--aparte-text); line-height:1.4; }
|
|
3668
|
+
.aparte-elic-field + .aparte-elic-field { margin-top:8px; padding-top:8px; border-top:1px solid var(--aparte-border, rgba(128,128,128,0.12)); }
|
|
3669
|
+
.aparte-elic-title { margin:0 6px 4px; font-size:.8rem; font-weight:600; color:var(--aparte-text); }
|
|
3670
|
+
.aparte-elic-desc { margin:0 6px 6px; font-size:.76rem; color:var(--aparte-text-muted, rgba(128,128,128,0.75)); }
|
|
3671
|
+
.aparte-elic-options { display:flex; flex-direction:column; gap:1px; }
|
|
3672
|
+
.aparte-elic-option { display:flex; align-items:flex-start; gap:10px; padding:7px 10px; border-radius:8px; cursor:pointer; border:1px solid transparent; transition:background .12s,border-color .12s; }
|
|
3673
|
+
.aparte-elic-option:hover { background:var(--aparte-surface-2, rgba(128,128,128,0.08)); border-color:var(--aparte-border, rgba(128,128,128,0.15)); }
|
|
3674
|
+
.aparte-elic-control { margin-top:3px; flex-shrink:0; accent-color:var(--aparte-primary, #6366f1); width:15px; height:15px; cursor:pointer; }
|
|
3675
|
+
.aparte-elic-option-body { display:flex; flex-direction:column; gap:2px; flex:1; }
|
|
3676
|
+
.aparte-elic-option-title { font-size:.875rem; font-weight:500; color:var(--aparte-text, inherit); line-height:1.4; }
|
|
3677
|
+
.aparte-elic-option-desc { font-size:.78rem; color:var(--aparte-text-muted, rgba(128,128,128,0.75)); line-height:1.4; }
|
|
3678
|
+
.aparte-elic-other-input, .aparte-elic-text { margin-top:4px; width:100%; padding:6px 8px; border:1px solid var(--aparte-border, rgba(128,128,128,0.2)); border-radius:6px; font-size:.875rem; background:var(--aparte-surface-2, rgba(128,128,128,0.06)); color:var(--aparte-text, inherit); outline:none; box-sizing:border-box; font-family:inherit; }
|
|
3679
|
+
textarea.aparte-elic-text { min-height:64px; resize:vertical; }
|
|
3680
|
+
.aparte-elic-other-input:focus, .aparte-elic-text:focus { border-color:var(--aparte-primary, #6366f1); }
|
|
3681
|
+
.aparte-elic-option--recommended { border-color:var(--aparte-primary, #6366f1) !important; background:color-mix(in srgb, var(--aparte-primary, #6366f1) 6%, transparent); }
|
|
3682
|
+
.aparte-elic-footer { display:flex; justify-content:flex-end; margin-top:6px; }
|
|
3683
|
+
.aparte-elic-skip { border:none; background:transparent; color:var(--aparte-text-muted, rgba(128,128,128,0.75)); font-size:.8rem; cursor:pointer; padding:4px 8px; border-radius:6px; font-family:inherit; }
|
|
3684
|
+
.aparte-elic-skip:hover { background:var(--aparte-surface-2, rgba(128,128,128,0.08)); color:var(--aparte-text); }
|
|
3685
|
+
`;
|
|
3686
|
+
document.head.appendChild(style);
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
if (typeof customElements !== "undefined" && !customElements.get("aparte-elicitation")) {
|
|
3690
|
+
customElements.define("aparte-elicitation", AparteElicitation);
|
|
3691
|
+
}
|
|
3692
|
+
function registerAllComponents() {
|
|
3693
|
+
const _chat = customElements.get("aparte-chat");
|
|
3694
|
+
const _viewport = customElements.get("aparte-chat-viewport");
|
|
3695
|
+
const _bubble = customElements.get("aparte-chat-bubble");
|
|
3696
|
+
const _status = customElements.get("aparte-chat-status");
|
|
3697
|
+
if (!_chat || !_viewport || !_bubble || !_status) {
|
|
3698
|
+
console.warn("[Aparte] Some components may not be registered. Ensure all component files are imported.");
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
export {
|
|
3702
|
+
A as APARTE_CONVERSATION_SCHEMA_VERSION,
|
|
3703
|
+
a as APARTE_HOST_ATTR,
|
|
3704
|
+
AparteChat,
|
|
3705
|
+
AparteChatBubble,
|
|
3706
|
+
b as AparteChatHost,
|
|
3707
|
+
AparteChatStatus,
|
|
3708
|
+
AparteChatViewport,
|
|
3709
|
+
c as AparteClient,
|
|
3710
|
+
AparteComposer,
|
|
3711
|
+
AparteComposerAction,
|
|
3712
|
+
AparteComposerAddAttachment,
|
|
3713
|
+
AparteComposerAttachments,
|
|
3714
|
+
AparteComposerCancel,
|
|
3715
|
+
AparteComposerInput,
|
|
3716
|
+
AparteComposerSend,
|
|
3717
|
+
AparteConfig,
|
|
3718
|
+
e as AparteConfigClass,
|
|
3719
|
+
f as AparteConversationController,
|
|
3720
|
+
AparteConversationList,
|
|
3721
|
+
AparteElicitation,
|
|
3722
|
+
g as AparteError,
|
|
3723
|
+
h as AparteErrorCode,
|
|
3724
|
+
AparteOptgroup,
|
|
3725
|
+
AparteOption,
|
|
3726
|
+
AparteProgressSpinner,
|
|
3727
|
+
AparteSelect,
|
|
3728
|
+
i as AparteStreamParser,
|
|
3729
|
+
B as BackendTransport,
|
|
3730
|
+
C as ConversationManager,
|
|
3731
|
+
D as DEFAULT_ICON_FALLBACKS,
|
|
3732
|
+
j as DEFAULT_LOCALE,
|
|
3733
|
+
k as DEFAULT_SKELETON_FALLBACKS,
|
|
3734
|
+
l as DEFAULT_UI_EVENTS,
|
|
3735
|
+
m as DirectTransport,
|
|
3736
|
+
MessageRepository,
|
|
3737
|
+
n as applyElementProps,
|
|
3738
|
+
o as attachConfig,
|
|
3739
|
+
buildElicitationPanel,
|
|
3740
|
+
q as collectRendererStyles,
|
|
3741
|
+
r as contentToText,
|
|
3742
|
+
s as contextConfig,
|
|
3743
|
+
t as createAparteChatHandler,
|
|
3744
|
+
u as createStreamAdapter,
|
|
3745
|
+
v as defaultSanitizer,
|
|
3746
|
+
w as deriveArtifactKind,
|
|
3747
|
+
x as detachConfig,
|
|
3748
|
+
getSegmentRenderer,
|
|
3749
|
+
z as isFormatAdapter,
|
|
3750
|
+
E as isSafeUrl,
|
|
3751
|
+
F as parseAparteEventStream,
|
|
3752
|
+
G as parseMarkdownToSegments,
|
|
3753
|
+
populateBubbleFromMessage,
|
|
3754
|
+
I as readableToAsyncIterable,
|
|
3755
|
+
registerAllComponents,
|
|
3756
|
+
J as registerDefaultRenderers,
|
|
3757
|
+
K as registerSegmentRenderer,
|
|
3758
|
+
L as requestUserInput,
|
|
3759
|
+
resolveConfig,
|
|
3760
|
+
runWithConfig,
|
|
3761
|
+
P as unregisterSegmentRenderer
|
|
3762
|
+
};
|
|
3763
|
+
//# sourceMappingURL=index.js.map
|