@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.
@@ -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