@aparte/angular 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 +47 -0
- package/dist/LICENSE +21 -0
- package/dist/README.md +47 -0
- package/dist/fesm2022/aparte-angular.mjs +1008 -0
- package/dist/fesm2022/aparte-angular.mjs.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/lib/aparte-ai.service.d.ts +38 -0
- package/dist/lib/aparte-chat.component.d.ts +179 -0
- package/dist/lib/aparte-ui.component.d.ts +78 -0
- package/dist/lib/conversation-manager.service.d.ts +60 -0
- package/dist/lib/provide-aparte.d.ts +71 -0
- package/package.json +72 -0
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, inject, Injectable, makeEnvironmentProviders, provideAppInitializer, DestroyRef, effect, ElementRef, signal, output, computed, numberAttribute, booleanAttribute, ViewChildren, ViewChild, Input, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, Component, Renderer2 } from '@angular/core';
|
|
3
|
+
import { AparteClient, AparteConfig, AparteChatHost, DEFAULT_UI_EVENTS, applyElementProps, ConversationManager } from '@aparte/core';
|
|
4
|
+
export { AparteConfig } from '@aparte/core';
|
|
5
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Injection token for AparteClient configuration. Provided by `provideAparte()`;
|
|
9
|
+
* omit it and the service falls back to `{}`.
|
|
10
|
+
*/
|
|
11
|
+
const APARTE_CLIENT_OPTIONS = new InjectionToken('APARTE_CLIENT_OPTIONS');
|
|
12
|
+
/**
|
|
13
|
+
* AparteAiService
|
|
14
|
+
*
|
|
15
|
+
* Angular service to bridge the UI events with AI Providers.
|
|
16
|
+
* Uses the agnostic `AparteClient` under the hood.
|
|
17
|
+
*/
|
|
18
|
+
class AparteAiService {
|
|
19
|
+
_clientOptions = inject(APARTE_CLIENT_OPTIONS, { optional: true });
|
|
20
|
+
_client = new AparteClient(this._clientOptions ?? {});
|
|
21
|
+
/**
|
|
22
|
+
* Start listening to `aparte-send` events globally.
|
|
23
|
+
* This connects the <aparte-chat> components to the AI Providers.
|
|
24
|
+
*
|
|
25
|
+
* `provideAparte()` calls this for you on app init (its `autoConnect`,
|
|
26
|
+
* on by default). Idempotent — a manual call on top of that is a no-op,
|
|
27
|
+
* so it stays the escape hatch when configuring without `provideAparte`.
|
|
28
|
+
*/
|
|
29
|
+
connect() {
|
|
30
|
+
this._client.start();
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Stop listening.
|
|
34
|
+
*/
|
|
35
|
+
disconnect() {
|
|
36
|
+
this._client.stop();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Abort the current AI response and all active tool calls.
|
|
40
|
+
*/
|
|
41
|
+
abort() {
|
|
42
|
+
this._client.abort();
|
|
43
|
+
}
|
|
44
|
+
ngOnDestroy() {
|
|
45
|
+
this.disconnect();
|
|
46
|
+
}
|
|
47
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteAiService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
48
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteAiService, providedIn: 'root' });
|
|
49
|
+
}
|
|
50
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteAiService, decorators: [{
|
|
51
|
+
type: Injectable,
|
|
52
|
+
args: [{
|
|
53
|
+
providedIn: 'root'
|
|
54
|
+
}]
|
|
55
|
+
}] });
|
|
56
|
+
|
|
57
|
+
/** Holds the resolved aparté config, for consumers that want to inject it. */
|
|
58
|
+
const APARTE_CONFIG_TOKEN = new InjectionToken('APARTE_CONFIG');
|
|
59
|
+
async function loadIconPlugin(plugin) {
|
|
60
|
+
if (typeof plugin === 'object' && plugin !== null) {
|
|
61
|
+
AparteConfig.setIconProvider(plugin);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
await plugin();
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
console.warn('[aparte] Failed to load icon plugin', err);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function loadSkeletonPlugin(plugin) {
|
|
72
|
+
if (typeof plugin === 'object' && plugin !== null) {
|
|
73
|
+
AparteConfig.setSkeletonProvider(plugin);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
await plugin();
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
console.warn('[aparte] Failed to load skeleton plugin', err);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function loadMarkdownPlugin(plugin) {
|
|
84
|
+
// A markdown provider takes the raw string; a loader takes nothing.
|
|
85
|
+
if (plugin.length >= 1) {
|
|
86
|
+
AparteConfig.setMarkdownProvider(plugin);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
await plugin();
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.warn('[aparte] Failed to load markdown plugin', err);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function runLoader(loader, label) {
|
|
97
|
+
try {
|
|
98
|
+
await loader();
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
console.warn(`[aparte] Failed to load ${label} plugin`, err);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function loadPlugins(options) {
|
|
105
|
+
const plugins = options.plugins;
|
|
106
|
+
if (!plugins)
|
|
107
|
+
return;
|
|
108
|
+
const pending = [];
|
|
109
|
+
for (const action of plugins.actions ?? [])
|
|
110
|
+
pending.push(runLoader(action, 'action'));
|
|
111
|
+
if (plugins.icons)
|
|
112
|
+
pending.push(loadIconPlugin(plugins.icons));
|
|
113
|
+
if (plugins.skeleton)
|
|
114
|
+
pending.push(loadSkeletonPlugin(plugins.skeleton));
|
|
115
|
+
if (plugins.theme)
|
|
116
|
+
pending.push(runLoader(plugins.theme, 'theme'));
|
|
117
|
+
if (plugins.markdown)
|
|
118
|
+
pending.push(loadMarkdownPlugin(plugins.markdown));
|
|
119
|
+
await Promise.all(pending);
|
|
120
|
+
}
|
|
121
|
+
/** Reflect the theme mode on the document root. */
|
|
122
|
+
function applyThemeMode(mode, destroyRef) {
|
|
123
|
+
if (mode !== 'auto') {
|
|
124
|
+
document.documentElement.setAttribute('data-aparte-theme', mode);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const query = window.matchMedia('(prefers-color-scheme: dark)');
|
|
128
|
+
const onChange = (e) => {
|
|
129
|
+
document.documentElement.setAttribute('data-aparte-theme', e.matches ? 'dark' : 'light');
|
|
130
|
+
};
|
|
131
|
+
document.documentElement.setAttribute('data-aparte-theme', query.matches ? 'dark' : 'light');
|
|
132
|
+
query.addEventListener('change', onChange);
|
|
133
|
+
// Released with the environment injector, so repeated bootstraps (TestBed, a
|
|
134
|
+
// multi-instance embed) don't stack listeners on the media query forever.
|
|
135
|
+
destroyRef.onDestroy(() => query.removeEventListener('change', onChange));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Configure aparté for a standalone Angular app. Registers your AI providers,
|
|
139
|
+
* model config, locale and optional plugins on the global `AparteConfig`,
|
|
140
|
+
* provides {@link APARTE_CLIENT_OPTIONS} for {@link AparteAiService}, and
|
|
141
|
+
* starts the client (see `autoConnect`) — no manual
|
|
142
|
+
* `AparteAiService.connect()` needed.
|
|
143
|
+
*
|
|
144
|
+
* The components (`AparteChatComponent`, `AparteUiComponent`) are standalone and
|
|
145
|
+
* work WITHOUT this — it is config sugar. You can equally call `AparteConfig.*`
|
|
146
|
+
* yourself, exactly like the React/Vue/Svelte wrappers do.
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* bootstrapApplication(App, {
|
|
150
|
+
* providers: [
|
|
151
|
+
* provideAparte({
|
|
152
|
+
* providers: [createOpenAICompatProvider(presets.OPENROUTER)],
|
|
153
|
+
* clientOptions: { keyResolver },
|
|
154
|
+
* }),
|
|
155
|
+
* ],
|
|
156
|
+
* });
|
|
157
|
+
*/
|
|
158
|
+
function provideAparte(options = {}) {
|
|
159
|
+
return makeEnvironmentProviders([
|
|
160
|
+
{ provide: APARTE_CONFIG_TOKEN, useValue: options },
|
|
161
|
+
{ provide: APARTE_CLIENT_OPTIONS, useValue: options.clientOptions ?? {} },
|
|
162
|
+
provideAppInitializer(async () => {
|
|
163
|
+
const destroyRef = inject(DestroyRef);
|
|
164
|
+
// inject() only works before the first await — grab the service now,
|
|
165
|
+
// connect at the end (after plugins) so the first send already sees
|
|
166
|
+
// markdown/action providers. SSR-safe: no listener without a window.
|
|
167
|
+
const ai = options.autoConnect !== false && typeof window !== 'undefined'
|
|
168
|
+
? inject(AparteAiService)
|
|
169
|
+
: null;
|
|
170
|
+
if (options.providers?.length) {
|
|
171
|
+
AparteConfig.registerAIProvider(...options.providers);
|
|
172
|
+
}
|
|
173
|
+
if (options.modelConfig) {
|
|
174
|
+
AparteConfig.setModelConfig(options.modelConfig);
|
|
175
|
+
}
|
|
176
|
+
if (options.locale) {
|
|
177
|
+
AparteConfig.setLocale(options.locale);
|
|
178
|
+
}
|
|
179
|
+
await loadPlugins(options);
|
|
180
|
+
if (options.themeMode) {
|
|
181
|
+
applyThemeMode(options.themeMode, destroyRef);
|
|
182
|
+
}
|
|
183
|
+
ai?.connect();
|
|
184
|
+
}),
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* AparteChatComponent — Angular 19 Wrapper
|
|
190
|
+
*
|
|
191
|
+
* Standalone component wrapping aparté Web Components with Angular Signals.
|
|
192
|
+
* The streaming / branch-navigation / host-method orchestration (orphan-stream
|
|
193
|
+
* guard, lifecycle tracking, conversation lifecycle, bubble reconciliation)
|
|
194
|
+
* lives in the framework-agnostic `AparteChatHost` from `@aparte/core`; this
|
|
195
|
+
* component only owns the Angular-idiomatic surface (signals, template, DI) and
|
|
196
|
+
* binds the host over its `messages` signal.
|
|
197
|
+
*/
|
|
198
|
+
class AparteChatComponent {
|
|
199
|
+
constructor() {
|
|
200
|
+
// Reactive effect to reconcile bubbles when the messages signal updates.
|
|
201
|
+
// Debounced with requestAnimationFrame so rapid token bursts only trigger
|
|
202
|
+
// one sync per paint frame instead of N syncs for N tokens.
|
|
203
|
+
effect(() => {
|
|
204
|
+
const msgs = this.messages();
|
|
205
|
+
if (msgs.length > 0) {
|
|
206
|
+
if (this._syncRafId !== null)
|
|
207
|
+
return;
|
|
208
|
+
this._syncRafId = requestAnimationFrame(() => {
|
|
209
|
+
this._syncRafId = null;
|
|
210
|
+
this._host?.syncBubbles();
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
elementRef = inject(ElementRef);
|
|
216
|
+
// ── Input Signals ────────────────────────────────────────────────────────
|
|
217
|
+
/** Messages to display */
|
|
218
|
+
set messagesInput(val) {
|
|
219
|
+
this.messages.set(val);
|
|
220
|
+
// When messages are cleared (new conversation), reset the host's render cache
|
|
221
|
+
// so the next conversation starts clean — same as React/Vue/Svelte do. (The
|
|
222
|
+
// viewport's own clearMessages() is @deprecated in core and only clears its
|
|
223
|
+
// repo; the host's clearAll() path already owns the real teardown.)
|
|
224
|
+
if (val.length === 0)
|
|
225
|
+
this._host?.clearRenderCache();
|
|
226
|
+
}
|
|
227
|
+
messages = signal([]);
|
|
228
|
+
/** Input placeholder text */
|
|
229
|
+
set placeholderInput(val) { this.placeholder.set(val); }
|
|
230
|
+
placeholder = signal('Type a message...');
|
|
231
|
+
/** Whether the input is disabled */
|
|
232
|
+
set disabledInput(val) { this.disabled.set(val); }
|
|
233
|
+
disabled = signal(false);
|
|
234
|
+
/** When false, Shift+Enter submits and a bare Enter inserts a newline. */
|
|
235
|
+
set submitOnEnterInput(val) { this.submitOnEnter.set(val); }
|
|
236
|
+
submitOnEnter = signal(true);
|
|
237
|
+
/**
|
|
238
|
+
* Opt in to the "centered composer when empty" layout: the composer sits
|
|
239
|
+
* vertically centered with the `[slot='empty-state']` content above it while
|
|
240
|
+
* the list is empty, then slides to the bottom on the first message (~0.3s).
|
|
241
|
+
* Off by default — additive (adds the `--auto-center` modifier + a
|
|
242
|
+
* `data-aparte-empty` attribute the shipped `aparte.css` recipe keys off).
|
|
243
|
+
*/
|
|
244
|
+
set centerWhenEmptyInput(val) { this.centerWhenEmpty.set(val); }
|
|
245
|
+
centerWhenEmpty = signal(false);
|
|
246
|
+
/** Whether the assistant is currently typing/streaming */
|
|
247
|
+
set isTypingInput(val) { this.isTyping.set(val); }
|
|
248
|
+
isTyping = signal(false);
|
|
249
|
+
/** Text to show in the typing status */
|
|
250
|
+
set typingTextInput(val) { this.typingText.set(val); }
|
|
251
|
+
typingText = signal('Assistant is thinking...');
|
|
252
|
+
/** Duration in ms to freeze spacer recalculation after a conversation swap. */
|
|
253
|
+
layoutTransitionMs = 0;
|
|
254
|
+
/**
|
|
255
|
+
* Active conversation id. When provided, the host attaches an
|
|
256
|
+
* `AparteConversationController` that loads/persists messages via the
|
|
257
|
+
* `ConversationManager` registered in `AparteConfig`. Setting a different id
|
|
258
|
+
* mid-stream aborts the previous request; `null` deselects.
|
|
259
|
+
*/
|
|
260
|
+
set conversationIdInput(val) {
|
|
261
|
+
const next = val ?? null;
|
|
262
|
+
if (next === this._conversationId)
|
|
263
|
+
return;
|
|
264
|
+
this._conversationId = next;
|
|
265
|
+
if (this._host)
|
|
266
|
+
void this._host.setConversationId(next);
|
|
267
|
+
}
|
|
268
|
+
_conversationId = null;
|
|
269
|
+
/**
|
|
270
|
+
* Instance {@link AparteConfigClass} for this chat. When set, aparté components
|
|
271
|
+
* inside resolve THIS config instead of the global `AparteConfig` singleton, so
|
|
272
|
+
* several independently-configured chats can coexist on one page. Omit for the
|
|
273
|
+
* global config. Read once in `ngAfterViewInit` when the host is created.
|
|
274
|
+
*/
|
|
275
|
+
config;
|
|
276
|
+
/**
|
|
277
|
+
* Render your OWN element per message in place of `<aparte-chat-bubble>`.
|
|
278
|
+
* Opt-in — provide a `<ng-template let-message>` and pass its ref. Driven by
|
|
279
|
+
* the reactive `messages()` signal, so it updates live during streaming.
|
|
280
|
+
* The built-in action bar (retry/edit/branch) belongs to the native bubble;
|
|
281
|
+
* a custom bubble owns whatever it wires.
|
|
282
|
+
* @example
|
|
283
|
+
* <aparte-chat [bubbleTemplate]="tpl" />
|
|
284
|
+
* <ng-template #tpl let-message>{{ message.content }}</ng-template>
|
|
285
|
+
*/
|
|
286
|
+
bubbleTemplate;
|
|
287
|
+
// ── Outputs ────────────────────────────────────────────────────────────
|
|
288
|
+
/**
|
|
289
|
+
* User submitted a message from the composer. It is **appended to the thread
|
|
290
|
+
* automatically** (optimistic UI) before this fires — do NOT add it again in
|
|
291
|
+
* the handler (uncontrolled → duplicates; controlled → mirror it into your
|
|
292
|
+
* own `[messages]`). For side-effects: scroll, analytics, send.
|
|
293
|
+
*/
|
|
294
|
+
messageSent = output();
|
|
295
|
+
/**
|
|
296
|
+
* Emitted when a custom bubble action (registered via
|
|
297
|
+
* `AparteConfig.registerAction` with `zones: ['bubble']`) is clicked — a typed
|
|
298
|
+
* wrapper over the bubbling `aparte-action` DOM event. Switch on `$event.actionId`.
|
|
299
|
+
*/
|
|
300
|
+
action = output();
|
|
301
|
+
/** Emitted when messages are updated internally (e.g. by AparteClient) */
|
|
302
|
+
messagesChange = output();
|
|
303
|
+
/** Emitted when a message is added internally (e.g. by appendMessage) */
|
|
304
|
+
messageAppended = output();
|
|
305
|
+
/** The typing/"thinking" indicator toggled (the host flips it off on the first streamed token). */
|
|
306
|
+
typingChange = output();
|
|
307
|
+
/** Emitted when the controller lazily creates a conversation on first send. */
|
|
308
|
+
conversationCreated = output();
|
|
309
|
+
// ── View Children ────────────────────────────────────────────────────────
|
|
310
|
+
viewportRef;
|
|
311
|
+
inputRef;
|
|
312
|
+
bubbleRefs;
|
|
313
|
+
// ── Private State ────────────────────────────────────────────────────────
|
|
314
|
+
/** Mirror of the host's streaming target id (drives `isStreaming`). */
|
|
315
|
+
_streamingId = signal(null);
|
|
316
|
+
/** Computed: is currently streaming */
|
|
317
|
+
isStreaming = computed(() => this._streamingId() !== null);
|
|
318
|
+
/** rAF id used to coalesce rapid signal updates during streaming */
|
|
319
|
+
_syncRafId = null;
|
|
320
|
+
/** The framework-agnostic chat-host orchestrator (created in ngAfterViewInit). */
|
|
321
|
+
_host;
|
|
322
|
+
_unbindHost;
|
|
323
|
+
/** QueryList.changes subscription — released in ngOnDestroy so SPA route
|
|
324
|
+
* churn doesn't leak a live subscription per mount. */
|
|
325
|
+
_bubbleRefsSub;
|
|
326
|
+
ngAfterViewInit() {
|
|
327
|
+
const host = this.elementRef.nativeElement;
|
|
328
|
+
// Ensure a stable id so aparte-composer can reference the host via `target`,
|
|
329
|
+
// letting AparteClient find it without DOM traversal across re-renders.
|
|
330
|
+
if (!host.id)
|
|
331
|
+
host.id = `aparte-chat-${crypto.randomUUID()}`;
|
|
332
|
+
const composerEl = this.inputRef?.nativeElement;
|
|
333
|
+
if (composerEl)
|
|
334
|
+
composerEl.setAttribute('target', host.id);
|
|
335
|
+
const binding = {
|
|
336
|
+
hostId: host.id,
|
|
337
|
+
host,
|
|
338
|
+
viewport: this.viewportRef?.nativeElement ?? null,
|
|
339
|
+
getMessages: () => this.messages(),
|
|
340
|
+
setMessages: (msgs) => this.messages.set(msgs),
|
|
341
|
+
onMessagesChange: (msgs) => this.messagesChange.emit(msgs),
|
|
342
|
+
onMessageAppended: (msg) => this.messageAppended.emit(msg),
|
|
343
|
+
onTypingChange: (typing) => { this.isTyping.set(typing); this.typingChange.emit(typing); },
|
|
344
|
+
onStreamingChange: (id) => this._streamingId.set(id),
|
|
345
|
+
// Sibling-info / deferred work runs after Angular has re-rendered.
|
|
346
|
+
afterRender: (cb) => { setTimeout(cb, 0); },
|
|
347
|
+
resetComposer: () => {
|
|
348
|
+
this.inputRef?.nativeElement?.reset?.();
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
this._host = new AparteChatHost(binding, {
|
|
352
|
+
layoutTransitionMs: this.layoutTransitionMs,
|
|
353
|
+
conversationId: this._conversationId,
|
|
354
|
+
onConversationCreated: (id) => {
|
|
355
|
+
this._conversationId = id;
|
|
356
|
+
this.conversationCreated.emit(id);
|
|
357
|
+
},
|
|
358
|
+
config: this.config,
|
|
359
|
+
});
|
|
360
|
+
this._unbindHost = this._host.bind();
|
|
361
|
+
// Custom bubble actions bubble to the host as `aparte-action`; surface them as
|
|
362
|
+
// the typed `action` output. (addEventListener, not @HostListener: Angular
|
|
363
|
+
// parses a colon in the event name as a `target:event` global target.)
|
|
364
|
+
host.addEventListener('aparte-action', this._onAction);
|
|
365
|
+
// Re-reconcile bubbles whenever Angular's @for materialises/destroys them.
|
|
366
|
+
this._host.syncBubbles();
|
|
367
|
+
this._bubbleRefsSub = this.bubbleRefs.changes.subscribe(() => this._host?.syncBubbles());
|
|
368
|
+
}
|
|
369
|
+
_onAction = (event) => {
|
|
370
|
+
this.action.emit(event.detail);
|
|
371
|
+
};
|
|
372
|
+
ngOnDestroy() {
|
|
373
|
+
if (this._syncRafId !== null) {
|
|
374
|
+
cancelAnimationFrame(this._syncRafId);
|
|
375
|
+
this._syncRafId = null;
|
|
376
|
+
}
|
|
377
|
+
this.elementRef.nativeElement.removeEventListener('aparte-action', this._onAction);
|
|
378
|
+
this._bubbleRefsSub?.unsubscribe();
|
|
379
|
+
this._bubbleRefsSub = undefined;
|
|
380
|
+
this._unbindHost?.();
|
|
381
|
+
this._unbindHost = undefined;
|
|
382
|
+
this._host = undefined;
|
|
383
|
+
}
|
|
384
|
+
// ── Public imperative API (delegates to the host) ──────────────────────────
|
|
385
|
+
/** Append a message optimistically. */
|
|
386
|
+
appendMessage(message) { this._host?.appendMessage(message); }
|
|
387
|
+
/** Atomic update for a message. */
|
|
388
|
+
updateMessage(messageId, updates) {
|
|
389
|
+
this._host?.updateMessage(messageId, updates);
|
|
390
|
+
}
|
|
391
|
+
/** Update the last message content (streaming text). */
|
|
392
|
+
updateLastMessage(content, options) {
|
|
393
|
+
this._host?.updateLastMessage(content, options);
|
|
394
|
+
}
|
|
395
|
+
/** Add a segment to the last message. */
|
|
396
|
+
addSegment(segment) { this._host?.addSegment(segment); }
|
|
397
|
+
/** Update a segment in the last message. */
|
|
398
|
+
updateSegment(segmentId, updates) {
|
|
399
|
+
this._host?.updateSegment(segmentId, updates);
|
|
400
|
+
}
|
|
401
|
+
/** Remove a transient segment. */
|
|
402
|
+
removeSegment(segmentId) { this._host?.removeSegment(segmentId); }
|
|
403
|
+
/** Append content to a segment in the last message. */
|
|
404
|
+
appendToSegment(segmentId, content) {
|
|
405
|
+
this._host?.appendToSegment(segmentId, content);
|
|
406
|
+
}
|
|
407
|
+
/** Read the current message list. */
|
|
408
|
+
getMessages() { return this._host?.getMessages() ?? this.messages(); }
|
|
409
|
+
/** Clear all messages + reset state. */
|
|
410
|
+
clearMessages() { this._host?.clearMessages(); }
|
|
411
|
+
/** Scroll the viewport to the latest message. */
|
|
412
|
+
scrollToBottom() {
|
|
413
|
+
this.viewportRef?.nativeElement?.scrollToBottom?.();
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* The `<aparte-chat-viewport>` element — for custom scroll handling, an
|
|
417
|
+
* IntersectionObserver, etc. Same `getViewport()` accessor on all four
|
|
418
|
+
* wrappers.
|
|
419
|
+
*/
|
|
420
|
+
getViewport() { return this.viewportRef?.nativeElement ?? null; }
|
|
421
|
+
/** Focus the composer input. */
|
|
422
|
+
focusInput() {
|
|
423
|
+
this.inputRef?.nativeElement?.focus?.();
|
|
424
|
+
}
|
|
425
|
+
/** Create a new branch from a message (returns the new sibling index). */
|
|
426
|
+
addBranch(messageId) { return this._host?.addBranch(messageId) ?? 0; }
|
|
427
|
+
/** Add a sibling of an existing message (returns the new id). */
|
|
428
|
+
addSiblingOf(existingId, newMessage) {
|
|
429
|
+
return this._host?.addSiblingOf(existingId, newMessage) ?? null;
|
|
430
|
+
}
|
|
431
|
+
/** Remove a message and all descendants (edit flow). */
|
|
432
|
+
truncateFrom(messageId) { this._host?.truncateFrom(messageId); }
|
|
433
|
+
/** Keep up to and including a user message, drop later responses (retry). */
|
|
434
|
+
truncateResponsesAfter(userMessageId) {
|
|
435
|
+
this._host?.truncateResponsesAfter(userMessageId);
|
|
436
|
+
}
|
|
437
|
+
// ── Token streaming (cross-wrapper contract + Observable adapter) ──────────
|
|
438
|
+
/**
|
|
439
|
+
* Inject a token stream for LLM streaming. Takes the cross-wrapper
|
|
440
|
+
* `AsyncIterable<string>` contract (the exact call that works on
|
|
441
|
+
* React/Vue/Svelte) — or an Angular-idiomatic RxJS `Observable<string>`,
|
|
442
|
+
* adapted into the host's agnostic `streamTokens(AsyncIterable)` so the
|
|
443
|
+
* orphan-stream guard + id tracking stay in one place (the host).
|
|
444
|
+
*/
|
|
445
|
+
async injectTokenStream(messageId, stream) {
|
|
446
|
+
if (!this._host)
|
|
447
|
+
return;
|
|
448
|
+
const tokens = Symbol.asyncIterator in stream
|
|
449
|
+
? stream
|
|
450
|
+
: this._observableToAsyncIterable(stream);
|
|
451
|
+
await this._host.streamTokens(messageId, tokens);
|
|
452
|
+
}
|
|
453
|
+
/** Stop any active token stream. */
|
|
454
|
+
stopTokenStream() { this._host?.stopTokenStream(); }
|
|
455
|
+
/**
|
|
456
|
+
* Set the active conversation id imperatively — parity with the other
|
|
457
|
+
* wrappers' handles (the `conversationId` `@Input` remains the declarative
|
|
458
|
+
* path). Delegates to the host, which loads/persists via the registered
|
|
459
|
+
* `ConversationManager`.
|
|
460
|
+
*/
|
|
461
|
+
setConversationId(id) {
|
|
462
|
+
return this._host?.setConversationId(id) ?? Promise.resolve();
|
|
463
|
+
}
|
|
464
|
+
_observableToAsyncIterable(stream) {
|
|
465
|
+
return {
|
|
466
|
+
[Symbol.asyncIterator]() {
|
|
467
|
+
const buffer = [];
|
|
468
|
+
let finished = false;
|
|
469
|
+
let failure = null;
|
|
470
|
+
let pending = null;
|
|
471
|
+
const settle = () => {
|
|
472
|
+
if (!pending)
|
|
473
|
+
return;
|
|
474
|
+
if (failure !== null) {
|
|
475
|
+
const p = pending;
|
|
476
|
+
pending = null;
|
|
477
|
+
p.reject(failure);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (buffer.length) {
|
|
481
|
+
const p = pending;
|
|
482
|
+
pending = null;
|
|
483
|
+
p.resolve({ value: buffer.shift(), done: false });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (finished) {
|
|
487
|
+
const p = pending;
|
|
488
|
+
pending = null;
|
|
489
|
+
p.resolve({ value: undefined, done: true });
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
const sub = stream.subscribe({
|
|
493
|
+
next: (v) => { buffer.push(v); settle(); },
|
|
494
|
+
error: (e) => { failure = e; settle(); },
|
|
495
|
+
complete: () => { finished = true; settle(); },
|
|
496
|
+
});
|
|
497
|
+
return {
|
|
498
|
+
next() {
|
|
499
|
+
if (failure !== null)
|
|
500
|
+
return Promise.reject(failure);
|
|
501
|
+
if (buffer.length) {
|
|
502
|
+
return Promise.resolve({ value: buffer.shift(), done: false });
|
|
503
|
+
}
|
|
504
|
+
if (finished) {
|
|
505
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
506
|
+
}
|
|
507
|
+
return new Promise((resolve, reject) => { pending = { resolve, reject }; });
|
|
508
|
+
},
|
|
509
|
+
return() {
|
|
510
|
+
sub.unsubscribe();
|
|
511
|
+
// If a consumer is mid-`await next()` on an empty buffer (e.g. the
|
|
512
|
+
// orphan-stream guard calling `return()` on stop/teardown), settle
|
|
513
|
+
// that pending promise now with done:true — otherwise it hangs
|
|
514
|
+
// forever, since unsubscribing means `next` and `complete` will
|
|
515
|
+
// never fire again to resolve it.
|
|
516
|
+
if (pending) {
|
|
517
|
+
const p = pending;
|
|
518
|
+
pending = null;
|
|
519
|
+
p.resolve({ value: undefined, done: true });
|
|
520
|
+
}
|
|
521
|
+
finished = true;
|
|
522
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
/** Handle aparte-send event from the composer Web Component. */
|
|
529
|
+
onAparteSend(event) {
|
|
530
|
+
const detail = event.detail;
|
|
531
|
+
// Smooth-scroll the next auto-scroll (when Angular adds the user bubble).
|
|
532
|
+
this.viewportRef?.nativeElement
|
|
533
|
+
?.requestSmoothScroll?.();
|
|
534
|
+
this.messageSent.emit(detail);
|
|
535
|
+
}
|
|
536
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteChatComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
537
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.25", type: AparteChatComponent, isStandalone: true, selector: "aparte-chat", inputs: { messagesInput: ["messages", "messagesInput"], placeholderInput: ["placeholder", "placeholderInput"], disabledInput: ["disabled", "disabledInput", booleanAttribute], submitOnEnterInput: ["submitOnEnter", "submitOnEnterInput", booleanAttribute], centerWhenEmptyInput: ["centerWhenEmpty", "centerWhenEmptyInput", booleanAttribute], isTypingInput: ["isTyping", "isTypingInput", booleanAttribute], typingTextInput: ["typingText", "typingTextInput"], layoutTransitionMs: ["layoutTransitionMs", "layoutTransitionMs", numberAttribute], conversationIdInput: ["conversationId", "conversationIdInput"], config: "config", bubbleTemplate: "bubbleTemplate" }, outputs: { messageSent: "messageSent", action: "action", messagesChange: "messagesChange", messageAppended: "messageAppended", typingChange: "typingChange", conversationCreated: "conversationCreated" }, host: { attributes: { "framework-managed": "" } }, viewQueries: [{ propertyName: "viewportRef", first: true, predicate: ["viewport"], descendants: true }, { propertyName: "inputRef", first: true, predicate: ["input"], descendants: true }, { propertyName: "bubbleRefs", predicate: ["bubble"], descendants: true }], ngImport: i0, template: `
|
|
538
|
+
<div
|
|
539
|
+
class="aparte-chat-container"
|
|
540
|
+
[class.aparte-chat-container--auto-center]="centerWhenEmpty()"
|
|
541
|
+
[attr.data-aparte-empty]="centerWhenEmpty() && messages().length === 0 ? '' : null"
|
|
542
|
+
>
|
|
543
|
+
<aparte-chat-viewport #viewport framework-managed="">
|
|
544
|
+
@if (messages().length === 0) {
|
|
545
|
+
<!-- Welcome / placeholder shown inside the viewport while empty. -->
|
|
546
|
+
<ng-content select="[slot='empty-state']"></ng-content>
|
|
547
|
+
}
|
|
548
|
+
@for (message of messages(); track message.id) {
|
|
549
|
+
@if (bubbleTemplate) {
|
|
550
|
+
<!-- Render your OWN element per message; driven by the reactive
|
|
551
|
+
messages() signal so it streams live. -->
|
|
552
|
+
<ng-container
|
|
553
|
+
[ngTemplateOutlet]="bubbleTemplate"
|
|
554
|
+
[ngTemplateOutletContext]="{ $implicit: message }"
|
|
555
|
+
></ng-container>
|
|
556
|
+
} @else {
|
|
557
|
+
<aparte-chat-bubble
|
|
558
|
+
#bubble
|
|
559
|
+
[attr.message-id]="message.id"
|
|
560
|
+
[attr.data-role]="message.role"
|
|
561
|
+
[attr.timestamp]="message.timestamp"
|
|
562
|
+
[attr.content]="message.content"
|
|
563
|
+
[attr.streaming]="(message.status === 'streaming' || message.status === 'pending') ? '' : null"
|
|
564
|
+
></aparte-chat-bubble>
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
<aparte-chat-status
|
|
568
|
+
[attr.visible]="isTyping() ? '' : null"
|
|
569
|
+
[attr.text]="typingText()"
|
|
570
|
+
></aparte-chat-status>
|
|
571
|
+
</aparte-chat-viewport>
|
|
572
|
+
|
|
573
|
+
<ng-content select="[slot='above-composer']"></ng-content>
|
|
574
|
+
|
|
575
|
+
<aparte-composer
|
|
576
|
+
#input
|
|
577
|
+
[attr.placeholder]="placeholder()"
|
|
578
|
+
[attr.disabled]="disabled() ? '' : null"
|
|
579
|
+
[attr.submit-on-enter]="submitOnEnter() ? null : 'false'"
|
|
580
|
+
(aparte-send)="onAparteSend($event)"
|
|
581
|
+
>
|
|
582
|
+
<!-- Custom composer via [slot='composer']; falls back to the default
|
|
583
|
+
shell (add-attachment · input · send + footer slots). Project your
|
|
584
|
+
own aparte-composer-* layout for a skin-specific composer. -->
|
|
585
|
+
<ng-content select="[slot='composer']">
|
|
586
|
+
<div class="aparte-composer-shell">
|
|
587
|
+
<aparte-composer-attachments></aparte-composer-attachments>
|
|
588
|
+
<div class="aparte-composer-row">
|
|
589
|
+
<aparte-composer-add-attachment></aparte-composer-add-attachment>
|
|
590
|
+
<aparte-composer-input></aparte-composer-input>
|
|
591
|
+
<aparte-composer-send></aparte-composer-send>
|
|
592
|
+
</div>
|
|
593
|
+
<div class="aparte-composer-footer">
|
|
594
|
+
<ng-content select="[slot='footer-left']"></ng-content>
|
|
595
|
+
<ng-content select="[slot='footer-center']"></ng-content>
|
|
596
|
+
<ng-content select="[slot='footer-right']"></ng-content>
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
</ng-content>
|
|
600
|
+
</aparte-composer>
|
|
601
|
+
</div>
|
|
602
|
+
`, isInline: true, styles: [":host{display:block;width:100%;height:100%}.aparte-chat-container{display:flex;flex-direction:column;height:100%;width:100%;overflow:hidden}aparte-chat-viewport{flex:1;min-height:0}aparte-composer{flex-shrink:0}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
603
|
+
}
|
|
604
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteChatComponent, decorators: [{
|
|
605
|
+
type: Component,
|
|
606
|
+
args: [{ selector: 'aparte-chat', standalone: true, imports: [NgTemplateOutlet], schemas: [CUSTOM_ELEMENTS_SCHEMA], changeDetection: ChangeDetectionStrategy.OnPush, host: { 'framework-managed': '' }, template: `
|
|
607
|
+
<div
|
|
608
|
+
class="aparte-chat-container"
|
|
609
|
+
[class.aparte-chat-container--auto-center]="centerWhenEmpty()"
|
|
610
|
+
[attr.data-aparte-empty]="centerWhenEmpty() && messages().length === 0 ? '' : null"
|
|
611
|
+
>
|
|
612
|
+
<aparte-chat-viewport #viewport framework-managed="">
|
|
613
|
+
@if (messages().length === 0) {
|
|
614
|
+
<!-- Welcome / placeholder shown inside the viewport while empty. -->
|
|
615
|
+
<ng-content select="[slot='empty-state']"></ng-content>
|
|
616
|
+
}
|
|
617
|
+
@for (message of messages(); track message.id) {
|
|
618
|
+
@if (bubbleTemplate) {
|
|
619
|
+
<!-- Render your OWN element per message; driven by the reactive
|
|
620
|
+
messages() signal so it streams live. -->
|
|
621
|
+
<ng-container
|
|
622
|
+
[ngTemplateOutlet]="bubbleTemplate"
|
|
623
|
+
[ngTemplateOutletContext]="{ $implicit: message }"
|
|
624
|
+
></ng-container>
|
|
625
|
+
} @else {
|
|
626
|
+
<aparte-chat-bubble
|
|
627
|
+
#bubble
|
|
628
|
+
[attr.message-id]="message.id"
|
|
629
|
+
[attr.data-role]="message.role"
|
|
630
|
+
[attr.timestamp]="message.timestamp"
|
|
631
|
+
[attr.content]="message.content"
|
|
632
|
+
[attr.streaming]="(message.status === 'streaming' || message.status === 'pending') ? '' : null"
|
|
633
|
+
></aparte-chat-bubble>
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
<aparte-chat-status
|
|
637
|
+
[attr.visible]="isTyping() ? '' : null"
|
|
638
|
+
[attr.text]="typingText()"
|
|
639
|
+
></aparte-chat-status>
|
|
640
|
+
</aparte-chat-viewport>
|
|
641
|
+
|
|
642
|
+
<ng-content select="[slot='above-composer']"></ng-content>
|
|
643
|
+
|
|
644
|
+
<aparte-composer
|
|
645
|
+
#input
|
|
646
|
+
[attr.placeholder]="placeholder()"
|
|
647
|
+
[attr.disabled]="disabled() ? '' : null"
|
|
648
|
+
[attr.submit-on-enter]="submitOnEnter() ? null : 'false'"
|
|
649
|
+
(aparte-send)="onAparteSend($event)"
|
|
650
|
+
>
|
|
651
|
+
<!-- Custom composer via [slot='composer']; falls back to the default
|
|
652
|
+
shell (add-attachment · input · send + footer slots). Project your
|
|
653
|
+
own aparte-composer-* layout for a skin-specific composer. -->
|
|
654
|
+
<ng-content select="[slot='composer']">
|
|
655
|
+
<div class="aparte-composer-shell">
|
|
656
|
+
<aparte-composer-attachments></aparte-composer-attachments>
|
|
657
|
+
<div class="aparte-composer-row">
|
|
658
|
+
<aparte-composer-add-attachment></aparte-composer-add-attachment>
|
|
659
|
+
<aparte-composer-input></aparte-composer-input>
|
|
660
|
+
<aparte-composer-send></aparte-composer-send>
|
|
661
|
+
</div>
|
|
662
|
+
<div class="aparte-composer-footer">
|
|
663
|
+
<ng-content select="[slot='footer-left']"></ng-content>
|
|
664
|
+
<ng-content select="[slot='footer-center']"></ng-content>
|
|
665
|
+
<ng-content select="[slot='footer-right']"></ng-content>
|
|
666
|
+
</div>
|
|
667
|
+
</div>
|
|
668
|
+
</ng-content>
|
|
669
|
+
</aparte-composer>
|
|
670
|
+
</div>
|
|
671
|
+
`, styles: [":host{display:block;width:100%;height:100%}.aparte-chat-container{display:flex;flex-direction:column;height:100%;width:100%;overflow:hidden}aparte-chat-viewport{flex:1;min-height:0}aparte-composer{flex-shrink:0}\n"] }]
|
|
672
|
+
}], ctorParameters: () => [], propDecorators: { messagesInput: [{
|
|
673
|
+
type: Input,
|
|
674
|
+
args: ['messages']
|
|
675
|
+
}], placeholderInput: [{
|
|
676
|
+
type: Input,
|
|
677
|
+
args: ['placeholder']
|
|
678
|
+
}], disabledInput: [{
|
|
679
|
+
type: Input,
|
|
680
|
+
args: [{ alias: 'disabled', transform: booleanAttribute }]
|
|
681
|
+
}], submitOnEnterInput: [{
|
|
682
|
+
type: Input,
|
|
683
|
+
args: [{ alias: 'submitOnEnter', transform: booleanAttribute }]
|
|
684
|
+
}], centerWhenEmptyInput: [{
|
|
685
|
+
type: Input,
|
|
686
|
+
args: [{ alias: 'centerWhenEmpty', transform: booleanAttribute }]
|
|
687
|
+
}], isTypingInput: [{
|
|
688
|
+
type: Input,
|
|
689
|
+
args: [{ alias: 'isTyping', transform: booleanAttribute }]
|
|
690
|
+
}], typingTextInput: [{
|
|
691
|
+
type: Input,
|
|
692
|
+
args: ['typingText']
|
|
693
|
+
}], layoutTransitionMs: [{
|
|
694
|
+
type: Input,
|
|
695
|
+
args: [{ alias: 'layoutTransitionMs', transform: numberAttribute }]
|
|
696
|
+
}], conversationIdInput: [{
|
|
697
|
+
type: Input,
|
|
698
|
+
args: ['conversationId']
|
|
699
|
+
}], config: [{
|
|
700
|
+
type: Input
|
|
701
|
+
}], bubbleTemplate: [{
|
|
702
|
+
type: Input
|
|
703
|
+
}], viewportRef: [{
|
|
704
|
+
type: ViewChild,
|
|
705
|
+
args: ['viewport']
|
|
706
|
+
}], inputRef: [{
|
|
707
|
+
type: ViewChild,
|
|
708
|
+
args: ['input']
|
|
709
|
+
}], bubbleRefs: [{
|
|
710
|
+
type: ViewChildren,
|
|
711
|
+
args: ['bubble']
|
|
712
|
+
}] } });
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* AparteUiComponent - Universal UI Proxy
|
|
716
|
+
*
|
|
717
|
+
* A pass-through proxy component that dynamically injects any aparté
|
|
718
|
+
* Web Component, so you don't need a dedicated Angular wrapper per element.
|
|
719
|
+
*
|
|
720
|
+
* @example
|
|
721
|
+
* ```html
|
|
722
|
+
* <aparte-ui
|
|
723
|
+
* name="aparte-model-selector"
|
|
724
|
+
* [props]="{
|
|
725
|
+
* placeholder: 'Ask anything...',
|
|
726
|
+
* '--glow-opacity': '1',
|
|
727
|
+
* '--glow-speed': '4s'
|
|
728
|
+
* }"
|
|
729
|
+
* (elementEvent)="onEvent($event)"
|
|
730
|
+
* />
|
|
731
|
+
* ```
|
|
732
|
+
*
|
|
733
|
+
* @description
|
|
734
|
+
* - Keys starting with `--` are applied as CSS Variables
|
|
735
|
+
* - Other keys are set as DOM properties on the element
|
|
736
|
+
* - The events in `events` (default: {@link DEFAULT_UI_EVENTS}) bubble up via `elementEvent`
|
|
737
|
+
*/
|
|
738
|
+
class AparteUiComponent {
|
|
739
|
+
renderer = inject(Renderer2);
|
|
740
|
+
hostEl = inject(ElementRef);
|
|
741
|
+
// ─────────────────────────────────────────────────────────────
|
|
742
|
+
// Inputs
|
|
743
|
+
// ─────────────────────────────────────────────────────────────
|
|
744
|
+
/** The custom element tag name (e.g., 'aparte-model-selector') */
|
|
745
|
+
name;
|
|
746
|
+
/** Properties to pass to the element. Keys starting with '--' are CSS vars */
|
|
747
|
+
props = {};
|
|
748
|
+
/**
|
|
749
|
+
* Which custom events to forward through `elementEvent`. Defaults to the
|
|
750
|
+
* interactive aparté surface ({@link DEFAULT_UI_EVENTS}); pass your own list to
|
|
751
|
+
* listen to other events (e.g. `['aparte-composer-change']` for attachments).
|
|
752
|
+
*/
|
|
753
|
+
events;
|
|
754
|
+
// ─────────────────────────────────────────────────────────────
|
|
755
|
+
// Outputs
|
|
756
|
+
// ─────────────────────────────────────────────────────────────
|
|
757
|
+
/** Emits a forwarded custom event from the underlying Web Component */
|
|
758
|
+
elementEvent = output();
|
|
759
|
+
// ─────────────────────────────────────────────────────────────
|
|
760
|
+
// Internal State
|
|
761
|
+
// ─────────────────────────────────────────────────────────────
|
|
762
|
+
/** The dynamically created Web Component element */
|
|
763
|
+
element = null;
|
|
764
|
+
/** Cleanup functions for event listeners */
|
|
765
|
+
eventCleanups = [];
|
|
766
|
+
/** Joined key of the bound event set, so a fresh inline `[events]` array
|
|
767
|
+
* doesn't thrash the element — only a real change rebinds. */
|
|
768
|
+
lastEventsKey = '';
|
|
769
|
+
// ─────────────────────────────────────────────────────────────
|
|
770
|
+
// Lifecycle
|
|
771
|
+
// ─────────────────────────────────────────────────────────────
|
|
772
|
+
ngAfterViewInit() {
|
|
773
|
+
this.createElement();
|
|
774
|
+
}
|
|
775
|
+
ngOnChanges(changes) {
|
|
776
|
+
// Recreate the element when `name` (or the forwarded event set) changes.
|
|
777
|
+
const nameChanged = !!changes['name'] && !changes['name'].firstChange;
|
|
778
|
+
const eventsChanged = !!changes['events'] &&
|
|
779
|
+
!changes['events'].firstChange &&
|
|
780
|
+
this.eventsKey() !== this.lastEventsKey;
|
|
781
|
+
if (nameChanged || eventsChanged) {
|
|
782
|
+
this.destroyElement();
|
|
783
|
+
this.createElement();
|
|
784
|
+
}
|
|
785
|
+
// If props changed, update them
|
|
786
|
+
if (changes['props'] && this.element) {
|
|
787
|
+
this.applyProps();
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
ngOnDestroy() {
|
|
791
|
+
this.destroyElement();
|
|
792
|
+
}
|
|
793
|
+
// ─────────────────────────────────────────────────────────────
|
|
794
|
+
// Element Management
|
|
795
|
+
// ─────────────────────────────────────────────────────────────
|
|
796
|
+
eventsKey() {
|
|
797
|
+
return (this.events ?? DEFAULT_UI_EVENTS).join('|');
|
|
798
|
+
}
|
|
799
|
+
createElement() {
|
|
800
|
+
if (!this.name)
|
|
801
|
+
return;
|
|
802
|
+
// Create the custom element
|
|
803
|
+
this.element = this.renderer.createElement(this.name);
|
|
804
|
+
// Apply initial props
|
|
805
|
+
this.applyProps();
|
|
806
|
+
// Setup event listeners
|
|
807
|
+
this.setupEventListeners();
|
|
808
|
+
// Append to host (not container, to avoid extra wrapper)
|
|
809
|
+
this.renderer.appendChild(this.hostEl.nativeElement, this.element);
|
|
810
|
+
}
|
|
811
|
+
destroyElement() {
|
|
812
|
+
// Cleanup event listeners
|
|
813
|
+
this.eventCleanups.forEach(cleanup => cleanup());
|
|
814
|
+
this.eventCleanups = [];
|
|
815
|
+
// Remove element
|
|
816
|
+
if (this.element && this.element.parentNode) {
|
|
817
|
+
this.renderer.removeChild(this.hostEl.nativeElement, this.element);
|
|
818
|
+
}
|
|
819
|
+
this.element = null;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* aparté elements are **attribute-driven** (`observedAttributes`): assigning a
|
|
823
|
+
* property is either a silent no-op (nothing observes it) or throws outright on
|
|
824
|
+
* a getter-only accessor — `<aparte-composer>`'s `placeholder`/`disabled` are
|
|
825
|
+
* exactly that. So primitives go through `setAttribute`; only values an
|
|
826
|
+
* attribute cannot carry (objects, functions) are handed over as properties.
|
|
827
|
+
*/
|
|
828
|
+
applyProps() {
|
|
829
|
+
if (this.element)
|
|
830
|
+
applyElementProps(this.element, this.props);
|
|
831
|
+
}
|
|
832
|
+
setupEventListeners() {
|
|
833
|
+
if (!this.element)
|
|
834
|
+
return;
|
|
835
|
+
this.lastEventsKey = this.eventsKey();
|
|
836
|
+
(this.events ?? DEFAULT_UI_EVENTS).forEach(eventName => {
|
|
837
|
+
const listener = (event) => {
|
|
838
|
+
this.elementEvent.emit(event);
|
|
839
|
+
};
|
|
840
|
+
this.element.addEventListener(eventName, listener);
|
|
841
|
+
this.eventCleanups.push(() => {
|
|
842
|
+
this.element?.removeEventListener(eventName, listener);
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
// ─────────────────────────────────────────────────────────────
|
|
847
|
+
// Public API
|
|
848
|
+
// ─────────────────────────────────────────────────────────────
|
|
849
|
+
/** Get the underlying Web Component element */
|
|
850
|
+
getElement() {
|
|
851
|
+
return this.element;
|
|
852
|
+
}
|
|
853
|
+
/** Call a method on the underlying element */
|
|
854
|
+
callMethod(methodName, ...args) {
|
|
855
|
+
if (!this.element)
|
|
856
|
+
return undefined;
|
|
857
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
858
|
+
const method = this.element[methodName];
|
|
859
|
+
if (typeof method === 'function') {
|
|
860
|
+
return method.apply(this.element, args);
|
|
861
|
+
}
|
|
862
|
+
return undefined;
|
|
863
|
+
}
|
|
864
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteUiComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
865
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.25", type: AparteUiComponent, isStandalone: true, selector: "aparte-ui", inputs: { name: "name", props: "props", events: "events" }, outputs: { elementEvent: "elementEvent" }, usesOnChanges: true, ngImport: i0, template: `<ng-container></ng-container>`, isInline: true, styles: [":host{display:contents}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
866
|
+
}
|
|
867
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: AparteUiComponent, decorators: [{
|
|
868
|
+
type: Component,
|
|
869
|
+
args: [{ selector: 'aparte-ui', standalone: true, schemas: [CUSTOM_ELEMENTS_SCHEMA], changeDetection: ChangeDetectionStrategy.OnPush, template: `<ng-container></ng-container>`, styles: [":host{display:contents}\n"] }]
|
|
870
|
+
}], propDecorators: { name: [{
|
|
871
|
+
type: Input,
|
|
872
|
+
args: [{ required: true }]
|
|
873
|
+
}], props: [{
|
|
874
|
+
type: Input
|
|
875
|
+
}], events: [{
|
|
876
|
+
type: Input
|
|
877
|
+
}] } });
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Angular 19 Signal-based wrapper around ConversationManager.
|
|
881
|
+
*
|
|
882
|
+
* Register a storage adapter before calling init():
|
|
883
|
+
* ```ts
|
|
884
|
+
* // app.config.ts
|
|
885
|
+
* export const appConfig: ApplicationConfig = {
|
|
886
|
+
* providers: [
|
|
887
|
+
* {
|
|
888
|
+
* provide: CONVERSATION_ADAPTER,
|
|
889
|
+
* useValue: new IndexedDBAdapter('my-app-convs'),
|
|
890
|
+
* },
|
|
891
|
+
* ],
|
|
892
|
+
* };
|
|
893
|
+
* ```
|
|
894
|
+
*
|
|
895
|
+
* Then inject ConversationManagerService in any component or service.
|
|
896
|
+
*/
|
|
897
|
+
class ConversationManagerService {
|
|
898
|
+
_manager = null;
|
|
899
|
+
_unsubscribe = null;
|
|
900
|
+
// ─── Signals ─────────────────────────────────────────────────────────────
|
|
901
|
+
/** All conversations (active + archived). */
|
|
902
|
+
conversations = signal([]);
|
|
903
|
+
/** Active conversations only, newest first. */
|
|
904
|
+
activeConversations = computed(() => this.conversations()
|
|
905
|
+
.filter(c => !c.archivedAt)
|
|
906
|
+
.sort((a, b) => b.updatedAt - a.updatedAt));
|
|
907
|
+
/** Archived conversations, newest first. */
|
|
908
|
+
archivedConversations = computed(() => this.conversations()
|
|
909
|
+
.filter(c => !!c.archivedAt)
|
|
910
|
+
.sort((a, b) => b.updatedAt - a.updatedAt));
|
|
911
|
+
/** Currently active conversation id. */
|
|
912
|
+
activeId = signal(null);
|
|
913
|
+
/** The active conversation object (null when no conv is selected). */
|
|
914
|
+
activeConversation = computed(() => {
|
|
915
|
+
const id = this.activeId();
|
|
916
|
+
if (!id)
|
|
917
|
+
return null;
|
|
918
|
+
return this.conversations().find(c => c.id === id) ?? null;
|
|
919
|
+
});
|
|
920
|
+
// ─── Lifecycle ───────────────────────────────────────────────────────────
|
|
921
|
+
/**
|
|
922
|
+
* Initialise the service with a storage adapter.
|
|
923
|
+
* Call once in your root component or app initialiser.
|
|
924
|
+
*/
|
|
925
|
+
async init(adapter) {
|
|
926
|
+
this._manager = new ConversationManager(adapter);
|
|
927
|
+
this._unsubscribe = this._manager.subscribe(convs => {
|
|
928
|
+
this.conversations.set([...convs]);
|
|
929
|
+
this.activeId.set(this._manager.activeId);
|
|
930
|
+
});
|
|
931
|
+
await this._manager.init();
|
|
932
|
+
// Sync activeId after load (manager may have no active conv yet)
|
|
933
|
+
this.activeId.set(this._manager.activeId);
|
|
934
|
+
// Register globally so AparteConversationController (used by aparte-chat)
|
|
935
|
+
// can resolve it without explicit DI plumbing.
|
|
936
|
+
AparteConfig.setConversationManager(this._manager);
|
|
937
|
+
}
|
|
938
|
+
ngOnDestroy() {
|
|
939
|
+
this._unsubscribe?.();
|
|
940
|
+
}
|
|
941
|
+
// ─── Actions ─────────────────────────────────────────────────────────────
|
|
942
|
+
/** Create a new empty conversation, make it active, return it. */
|
|
943
|
+
async createNew(title) {
|
|
944
|
+
this._assertInit();
|
|
945
|
+
return this._manager.createNew(title);
|
|
946
|
+
}
|
|
947
|
+
// Note: there is no `select()` / `clearActive()` here on purpose.
|
|
948
|
+
// The active conversation is owned exclusively by AparteConversationController
|
|
949
|
+
// (inside <aparte-chat>). To switch conversations, either bind
|
|
950
|
+
// `[conversationId]` on <aparte-chat> or dispatch a window event:
|
|
951
|
+
// window.dispatchEvent(new CustomEvent('aparte-select-conversation', {
|
|
952
|
+
// detail: { id, targetId? },
|
|
953
|
+
// }));
|
|
954
|
+
// The `activeId` signal here is read-only and stays in sync via subscribe().
|
|
955
|
+
/** Append a message to a conversation and persist. */
|
|
956
|
+
async addMessage(convId, msg) {
|
|
957
|
+
this._assertInit();
|
|
958
|
+
return this._manager.addMessage(convId, msg);
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Replace all messages for a conversation.
|
|
962
|
+
* Call after branch navigation or AI response completion.
|
|
963
|
+
*/
|
|
964
|
+
async updateMessages(convId, messages) {
|
|
965
|
+
this._assertInit();
|
|
966
|
+
return this._manager.updateMessages(convId, messages);
|
|
967
|
+
}
|
|
968
|
+
/** Permanently delete a conversation. */
|
|
969
|
+
async delete(id) {
|
|
970
|
+
this._assertInit();
|
|
971
|
+
return this._manager.delete(id);
|
|
972
|
+
}
|
|
973
|
+
/** Archive a conversation. */
|
|
974
|
+
async archive(id) {
|
|
975
|
+
this._assertInit();
|
|
976
|
+
return this._manager.archive(id);
|
|
977
|
+
}
|
|
978
|
+
/** Restore an archived conversation. */
|
|
979
|
+
async unarchive(id) {
|
|
980
|
+
this._assertInit();
|
|
981
|
+
return this._manager.unarchive(id);
|
|
982
|
+
}
|
|
983
|
+
// ─── Private ─────────────────────────────────────────────────────────────
|
|
984
|
+
_assertInit() {
|
|
985
|
+
if (!this._manager) {
|
|
986
|
+
throw new Error('[ConversationManagerService] Not initialised. Call init(adapter) before using the service.');
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: ConversationManagerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
990
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: ConversationManagerService, providedIn: 'root' });
|
|
991
|
+
}
|
|
992
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: ConversationManagerService, decorators: [{
|
|
993
|
+
type: Injectable,
|
|
994
|
+
args: [{ providedIn: 'root' }]
|
|
995
|
+
}] });
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* aparté Angular wrapper
|
|
999
|
+
* Angular 19 standalone components + services over the framework-agnostic web components.
|
|
1000
|
+
*/
|
|
1001
|
+
// Configuration (a standalone provider function — no NgModule).
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Generated bundle index. Do not edit.
|
|
1005
|
+
*/
|
|
1006
|
+
|
|
1007
|
+
export { APARTE_CLIENT_OPTIONS, APARTE_CONFIG_TOKEN, AparteAiService, AparteChatComponent, AparteUiComponent, ConversationManagerService, provideAparte };
|
|
1008
|
+
//# sourceMappingURL=aparte-angular.mjs.map
|