@meshmakers/shared-ui 3.4.1480 → 3.5.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/README.md +48 -0
- package/assets/octobot/lg/octo_blink.webp +0 -0
- package/assets/octobot/lg/octo_blink_still.png +0 -0
- package/assets/octobot/lg/octo_idle.webp +0 -0
- package/assets/octobot/lg/octo_idle_still.png +0 -0
- package/assets/octobot/lg/octo_look.webp +0 -0
- package/assets/octobot/lg/octo_look_still.png +0 -0
- package/assets/octobot/lg/octo_swim.webp +0 -0
- package/assets/octobot/lg/octo_swim_still.png +0 -0
- package/assets/octobot/lg/octo_thinking.webp +0 -0
- package/assets/octobot/lg/octo_thinking_still.png +0 -0
- package/assets/octobot/lg/octo_wave.webp +0 -0
- package/assets/octobot/lg/octo_wave_still.png +0 -0
- package/assets/octobot/md/octo_blink.webp +0 -0
- package/assets/octobot/md/octo_blink_still.png +0 -0
- package/assets/octobot/md/octo_idle.webp +0 -0
- package/assets/octobot/md/octo_idle_still.png +0 -0
- package/assets/octobot/md/octo_look.webp +0 -0
- package/assets/octobot/md/octo_look_still.png +0 -0
- package/assets/octobot/md/octo_swim.webp +0 -0
- package/assets/octobot/md/octo_swim_still.png +0 -0
- package/assets/octobot/md/octo_thinking.webp +0 -0
- package/assets/octobot/md/octo_thinking_still.png +0 -0
- package/assets/octobot/md/octo_wave.webp +0 -0
- package/assets/octobot/md/octo_wave_still.png +0 -0
- package/assets/octobot/sm/octo_blink.webp +0 -0
- package/assets/octobot/sm/octo_blink_still.png +0 -0
- package/assets/octobot/sm/octo_idle.webp +0 -0
- package/assets/octobot/sm/octo_idle_still.png +0 -0
- package/assets/octobot/sm/octo_look.webp +0 -0
- package/assets/octobot/sm/octo_look_still.png +0 -0
- package/assets/octobot/sm/octo_swim.webp +0 -0
- package/assets/octobot/sm/octo_swim_still.png +0 -0
- package/assets/octobot/sm/octo_thinking.webp +0 -0
- package/assets/octobot/sm/octo_thinking_still.png +0 -0
- package/assets/octobot/sm/octo_wave.webp +0 -0
- package/assets/octobot/sm/octo_wave_still.png +0 -0
- package/fesm2022/meshmakers-shared-ui-assistant-core.mjs +940 -0
- package/fesm2022/meshmakers-shared-ui-assistant-core.mjs.map +1 -0
- package/fesm2022/meshmakers-shared-ui-assistant.mjs +819 -0
- package/fesm2022/meshmakers-shared-ui-assistant.mjs.map +1 -0
- package/fesm2022/meshmakers-shared-ui-command-palette.mjs +817 -0
- package/fesm2022/meshmakers-shared-ui-command-palette.mjs.map +1 -0
- package/fesm2022/meshmakers-shared-ui-shell.mjs +1738 -0
- package/fesm2022/meshmakers-shared-ui-shell.mjs.map +1 -0
- package/fesm2022/meshmakers-shared-ui-testing.mjs +92 -0
- package/fesm2022/meshmakers-shared-ui-testing.mjs.map +1 -0
- package/fesm2022/meshmakers-shared-ui.mjs +1550 -203
- package/fesm2022/meshmakers-shared-ui.mjs.map +1 -1
- package/package.json +28 -2
- package/types/meshmakers-shared-ui-assistant-core.d.ts +732 -0
- package/types/meshmakers-shared-ui-assistant.d.ts +258 -0
- package/types/meshmakers-shared-ui-command-palette.d.ts +398 -0
- package/types/meshmakers-shared-ui-shell.d.ts +939 -0
- package/types/meshmakers-shared-ui-testing.d.ts +41 -0
- package/types/meshmakers-shared-ui.d.ts +1004 -154
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, inject, computed, signal, Injectable, input, ChangeDetectionStrategy, Component, DestroyRef, ViewContainerRef, TemplateRef, Directive } from '@angular/core';
|
|
3
|
+
import { throwError, isObservable, lastValueFrom, Subject, firstValueFrom } from 'rxjs';
|
|
4
|
+
import { Router, NavigationEnd } from '@angular/router';
|
|
5
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
6
|
+
import { BreadCrumbService } from '@meshmakers/shared-services';
|
|
7
|
+
import { filter } from 'rxjs/operators';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_ASSISTANT_MESSAGES = {
|
|
10
|
+
title: 'Assistant',
|
|
11
|
+
readsWithYourPermissions: 'Reads with your permissions',
|
|
12
|
+
closeAssistant: 'Close assistant',
|
|
13
|
+
contextLabel: 'Context sent with your message',
|
|
14
|
+
removeContext: 'Remove context {label}',
|
|
15
|
+
notAvailable: 'Assistant not available',
|
|
16
|
+
notConnected: 'Assistant backend not available yet',
|
|
17
|
+
commandsLabel: 'Commands',
|
|
18
|
+
composerLabel: 'Message the assistant',
|
|
19
|
+
composerPlaceholder: 'Ask something or type / for commands…',
|
|
20
|
+
send: 'Send',
|
|
21
|
+
stop: 'Stop',
|
|
22
|
+
greeting: 'Hi! How can I help?',
|
|
23
|
+
thinking: 'Thinking…',
|
|
24
|
+
conversationLabel: 'Conversation',
|
|
25
|
+
youPrefix: 'You: ',
|
|
26
|
+
assistantPrefix: 'Assistant: ',
|
|
27
|
+
errorPrefix: 'Error: ',
|
|
28
|
+
slashCommandPrefix: 'Command: ',
|
|
29
|
+
slashCommandFailed: 'The command {command} could not be run.',
|
|
30
|
+
proposalLabel: 'Proposal: {title}',
|
|
31
|
+
proposal: 'Proposal',
|
|
32
|
+
writesToTenant: 'writes to the tenant',
|
|
33
|
+
tool: 'Tool',
|
|
34
|
+
runsAfterConfirm: 'runs only after you confirm',
|
|
35
|
+
run: 'Run',
|
|
36
|
+
edit: 'Edit',
|
|
37
|
+
discard: 'Discard',
|
|
38
|
+
runRequested: 'Run requested',
|
|
39
|
+
editRequested: 'Edit requested',
|
|
40
|
+
discarded: 'Discarded',
|
|
41
|
+
contextTenant: 'Tenant: {tenant}',
|
|
42
|
+
contextPage: 'Page: {page}',
|
|
43
|
+
sessions: 'Chats',
|
|
44
|
+
newChat: 'New chat',
|
|
45
|
+
sessionsLabel: 'Saved chats',
|
|
46
|
+
noSessions: 'No saved chats yet',
|
|
47
|
+
backToChat: 'Back to the chat',
|
|
48
|
+
deleteSession: 'Delete chat {title}',
|
|
49
|
+
deleteSessionConfirm: 'Delete “{title}”?',
|
|
50
|
+
delete: 'Delete',
|
|
51
|
+
cancel: 'Cancel',
|
|
52
|
+
sessionLoadFailed: 'The chat could not be opened.',
|
|
53
|
+
sessionDeleteFailed: 'The chat could not be deleted.',
|
|
54
|
+
attachFile: 'Attach file',
|
|
55
|
+
attachmentsLabel: 'Attached files',
|
|
56
|
+
removeAttachment: 'Remove attachment {name}',
|
|
57
|
+
attachmentWrongType: '{name} is not an accepted file type.',
|
|
58
|
+
attachmentTooLarge: '{name} is larger than {max}.',
|
|
59
|
+
attachmentTooMany: 'At most {max} file(s) per message.',
|
|
60
|
+
startersTitle: 'Try asking',
|
|
61
|
+
resizePanel: 'Resize assistant panel',
|
|
62
|
+
};
|
|
63
|
+
/** App-wide translations of the assistant (partial). */
|
|
64
|
+
const ASSISTANT_MESSAGES = new InjectionToken('ASSISTANT_MESSAGES');
|
|
65
|
+
/** Merges overrides over {@link DEFAULT_ASSISTANT_MESSAGES}, later sources winning; null/undefined members are ignored. */
|
|
66
|
+
function resolveAssistantMessages(...overrides) {
|
|
67
|
+
const resolved = { ...DEFAULT_ASSISTANT_MESSAGES };
|
|
68
|
+
for (const messages of overrides) {
|
|
69
|
+
if (!messages) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
for (const key of Object.keys(messages)) {
|
|
73
|
+
const value = messages[key];
|
|
74
|
+
if (value !== undefined && value !== null) {
|
|
75
|
+
resolved[key] = value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return resolved;
|
|
80
|
+
}
|
|
81
|
+
/** Replaces `{name}` placeholders; unknown placeholders stay as they are. */
|
|
82
|
+
function formatAssistantMessage(message, values) {
|
|
83
|
+
return message.replace(/\{(\w+)\}/g, (match, key) => Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match);
|
|
84
|
+
}
|
|
85
|
+
/** The settled label of a proposal decision. */
|
|
86
|
+
function proposalDecisionLabel(kind, messages) {
|
|
87
|
+
switch (kind) {
|
|
88
|
+
case 'run': return messages.runRequested;
|
|
89
|
+
case 'edit': return messages.editRequested;
|
|
90
|
+
case 'discard': return messages.discarded;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* {@link ASSISTANT_MESSAGES} merged with a component input (the input wins).
|
|
95
|
+
* Call it in an injection context.
|
|
96
|
+
* @internal
|
|
97
|
+
*/
|
|
98
|
+
function assistantMessages(input) {
|
|
99
|
+
const injected = inject(ASSISTANT_MESSAGES, { optional: true });
|
|
100
|
+
return computed(() => resolveAssistantMessages(injected, input()));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The (English) text the panel shows while no backend exists. */
|
|
104
|
+
const ASSISTANT_NOT_CONNECTED_MESSAGE = DEFAULT_ASSISTANT_MESSAGES.notConnected;
|
|
105
|
+
/**
|
|
106
|
+
* The default {@link AssistantTransport}: it is never `ready`, so the composer
|
|
107
|
+
* cannot send, and it never produces an answer. It exists so the panel can say
|
|
108
|
+
* honestly that there is no assistant backend (AB#5550) instead of faking one.
|
|
109
|
+
* The message follows `ASSISTANT_MESSAGES.notConnected`.
|
|
110
|
+
*/
|
|
111
|
+
class NotConnectedAssistantTransport {
|
|
112
|
+
message = resolveAssistantMessages(inject(ASSISTANT_MESSAGES, { optional: true })).notConnected;
|
|
113
|
+
status = signal('unavailable').asReadonly();
|
|
114
|
+
statusMessage = signal(this.message).asReadonly();
|
|
115
|
+
send(_request) {
|
|
116
|
+
return throwError(() => new Error(this.message));
|
|
117
|
+
}
|
|
118
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: NotConnectedAssistantTransport, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
119
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: NotConnectedAssistantTransport, providedIn: 'root' });
|
|
120
|
+
}
|
|
121
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: NotConnectedAssistantTransport, decorators: [{
|
|
122
|
+
type: Injectable,
|
|
123
|
+
args: [{ providedIn: 'root' }]
|
|
124
|
+
}] });
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Default renderer of a tool call in the thread: tool name in mono plus its status.
|
|
128
|
+
* Replace it through `ASSISTANT_TOOL_CALL_COMPONENT`.
|
|
129
|
+
*/
|
|
130
|
+
class AssistantToolCallRowComponent {
|
|
131
|
+
call = input.required(/* @ts-ignore */
|
|
132
|
+
...(ngDevMode ? [{ debugName: "call" }] : /* istanbul ignore next */ []));
|
|
133
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: AssistantToolCallRowComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
134
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.1.5", type: AssistantToolCallRowComponent, isStandalone: true, selector: "mm-assistant-tool-call-row", inputs: { call: { classPropertyName: "call", publicName: "call", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: '<span class="tool-name">{{ call().toolName }}</span> <span class="tool-status">{{ call().status }}</span>', isInline: true, styles: [":host{display:inline-flex;gap:var(--theme-space-2, 8px);align-items:baseline}.tool-name{font-family:var(--theme-font-mono, monospace)}.tool-status{color:var(--theme-text-secondary)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
135
|
+
}
|
|
136
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: AssistantToolCallRowComponent, decorators: [{
|
|
137
|
+
type: Component,
|
|
138
|
+
args: [{ selector: 'mm-assistant-tool-call-row', template: '<span class="tool-name">{{ call().toolName }}</span> <span class="tool-status">{{ call().status }}</span>', changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:inline-flex;gap:var(--theme-space-2, 8px);align-items:baseline}.tool-name{font-family:var(--theme-font-mono, monospace)}.tool-status{color:var(--theme-text-secondary)}\n"] }]
|
|
139
|
+
}], propDecorators: { call: [{ type: i0.Input, args: [{ isSignal: true, alias: "call", required: true }] }] } });
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The single feature flag of the assistant (ui-concept §5.3, AB#5549).
|
|
143
|
+
* **Off by default.** While it is off nothing assistant-related renders or binds:
|
|
144
|
+
* no top-bar ✦ button, no Cmd/Ctrl+J, no "✦ Explain" affordances, and the command
|
|
145
|
+
* palette's `?` scope only shows "Assistant not available".
|
|
146
|
+
*
|
|
147
|
+
* Enable it for a host with `{ provide: ASSISTANT_ENABLED, useValue: true }`. The host gates its
|
|
148
|
+
* own affordances (top-bar toggle, Cmd/Ctrl+J, palette row) on `AssistantService.enabled`.
|
|
149
|
+
* Turning it on without a real {@link ASSISTANT_TRANSPORT} shows the panel with an
|
|
150
|
+
* honest "Assistant backend not available yet" state — never canned answers.
|
|
151
|
+
*/
|
|
152
|
+
const ASSISTANT_ENABLED = new InjectionToken('ASSISTANT_ENABLED', {
|
|
153
|
+
providedIn: 'root',
|
|
154
|
+
factory: () => false
|
|
155
|
+
});
|
|
156
|
+
/**
|
|
157
|
+
* The component that renders one tool call in the thread. It must have a `call` input taking an
|
|
158
|
+
* {@link AssistantToolCall}. Default: a compact name + status row. An OctoMesh host passes
|
|
159
|
+
* `AiToolCallComponent` of `@meshmakers/octo-ai-console` (collapsible details):
|
|
160
|
+
* `{ provide: ASSISTANT_TOOL_CALL_COMPONENT, useValue: AiToolCallComponent }`.
|
|
161
|
+
*/
|
|
162
|
+
const ASSISTANT_TOOL_CALL_COMPONENT = new InjectionToken('ASSISTANT_TOOL_CALL_COMPONENT', {
|
|
163
|
+
providedIn: 'root',
|
|
164
|
+
factory: () => AssistantToolCallRowComponent
|
|
165
|
+
});
|
|
166
|
+
const ASSISTANT_PAGE_CONTEXT = new InjectionToken('ASSISTANT_PAGE_CONTEXT');
|
|
167
|
+
/** The assistant's transport; defaults to the honest "not connected" one. */
|
|
168
|
+
const ASSISTANT_TRANSPORT = new InjectionToken('ASSISTANT_TRANSPORT', {
|
|
169
|
+
providedIn: 'root',
|
|
170
|
+
factory: () => inject(NotConnectedAssistantTransport)
|
|
171
|
+
});
|
|
172
|
+
/** The default slash commands (English descriptions). */
|
|
173
|
+
const DEFAULT_ASSISTANT_SLASH_COMMANDS = [
|
|
174
|
+
{ command: '/explain', description: 'Explain the object in context' },
|
|
175
|
+
{ command: '/query', description: 'Turn a question into a query' },
|
|
176
|
+
{ command: '/summarise', description: 'Summarise events or a selection' },
|
|
177
|
+
{ command: '/draft-pipeline', description: 'Draft a pipeline as a diff' }
|
|
178
|
+
];
|
|
179
|
+
/** The composer's slash commands; provide your own list (or translated descriptions) to change them. */
|
|
180
|
+
const ASSISTANT_SLASH_COMMANDS = new InjectionToken('ASSISTANT_SLASH_COMMANDS', {
|
|
181
|
+
providedIn: 'root',
|
|
182
|
+
factory: () => DEFAULT_ASSISTANT_SLASH_COMMANDS
|
|
183
|
+
});
|
|
184
|
+
const DEFAULT_ASSISTANT_PANEL_MIN_WIDTH = 320;
|
|
185
|
+
const DEFAULT_ASSISTANT_PANEL_MAX_WIDTH = 720;
|
|
186
|
+
const DEFAULT_ASSISTANT_PANEL_STORAGE_KEY = 'mm-assistant-panel-width';
|
|
187
|
+
/** App-wide {@link AssistantPanelOptions}; optional. */
|
|
188
|
+
const ASSISTANT_PANEL_OPTIONS = new InjectionToken('ASSISTANT_PANEL_OPTIONS');
|
|
189
|
+
|
|
190
|
+
/** Route params that name the object a page shows (detail pages, editors). */
|
|
191
|
+
const ENTITY_PARAM = /^(rtId|id|[a-zA-Z]+Id|userName)$/;
|
|
192
|
+
/** Params that are scope, not the object. */
|
|
193
|
+
const SCOPE_PARAMS = new Set(['tenantId']);
|
|
194
|
+
/**
|
|
195
|
+
* Turns the current route into the panel's context chips (ui-concept §5.3):
|
|
196
|
+
* tenant, page ("Area › Tab", else the breadcrumb) and the selected entity — the
|
|
197
|
+
* one an Explain entry point named, otherwise the deepest id-like route param
|
|
198
|
+
* (labelled with the last breadcrumb). Pure, so it is tested without a router.
|
|
199
|
+
*/
|
|
200
|
+
function deriveAssistantContext(source, messages) {
|
|
201
|
+
const m = resolveAssistantMessages(messages);
|
|
202
|
+
const params = collectParams(source.root);
|
|
203
|
+
const chips = [];
|
|
204
|
+
const tenantId = params.get('tenantId');
|
|
205
|
+
if (tenantId) {
|
|
206
|
+
chips.push({ id: `tenant:${tenantId}`, kind: 'tenant', label: formatAssistantMessage(m.contextTenant, { tenant: tenantId }), value: tenantId });
|
|
207
|
+
}
|
|
208
|
+
const path = source.url.split('#')[0].split('?')[0];
|
|
209
|
+
const pageLabel = source.areaText && source.tabText && source.tabText !== source.areaText
|
|
210
|
+
? `${source.areaText} › ${source.tabText}`
|
|
211
|
+
: source.tabText ?? source.areaText ?? source.breadcrumbs[0] ?? null;
|
|
212
|
+
if (pageLabel && path) {
|
|
213
|
+
chips.push({ id: `page:${path}`, kind: 'page', label: formatAssistantMessage(m.contextPage, { page: pageLabel }), value: path });
|
|
214
|
+
}
|
|
215
|
+
const explicit = source.explicitEntity;
|
|
216
|
+
if (explicit) {
|
|
217
|
+
const value = explicit.rtId ?? explicit.ckTypeId ?? explicit.label;
|
|
218
|
+
chips.push({
|
|
219
|
+
id: `entity:${value}`, kind: 'entity', label: explicit.label, value,
|
|
220
|
+
...(explicit.ckTypeId ? { ckTypeId: explicit.ckTypeId } : {})
|
|
221
|
+
});
|
|
222
|
+
return chips;
|
|
223
|
+
}
|
|
224
|
+
const entity = [...params.entries()].reverse().find(([name]) => !SCOPE_PARAMS.has(name) && ENTITY_PARAM.test(name));
|
|
225
|
+
if (entity) {
|
|
226
|
+
const [, value] = entity;
|
|
227
|
+
const crumbs = source.breadcrumbs;
|
|
228
|
+
const label = crumbs.length > 1 ? crumbs[crumbs.length - 1] : value;
|
|
229
|
+
chips.push({ id: `entity:${value}`, kind: 'entity', label, value });
|
|
230
|
+
}
|
|
231
|
+
return chips;
|
|
232
|
+
}
|
|
233
|
+
/** Params from the root to the deepest primary child, in that order (later wins on clashes). */
|
|
234
|
+
function collectParams(root) {
|
|
235
|
+
const params = new Map();
|
|
236
|
+
let node = root;
|
|
237
|
+
while (node) {
|
|
238
|
+
for (const [name, value] of Object.entries(node.params ?? {})) {
|
|
239
|
+
if (typeof value === 'string' && value) {
|
|
240
|
+
params.delete(name);
|
|
241
|
+
params.set(name, value);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
node = node.firstChild;
|
|
245
|
+
}
|
|
246
|
+
return params;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const NO_SESSIONS = signal([]).asReadonly();
|
|
250
|
+
const NO_ACTIVE_SESSION = signal(null).asReadonly();
|
|
251
|
+
/** Awaits a promise or the last value of an observable; an observable that completes empty (e.g. `EMPTY` for a void result) yields `undefined` instead of rejecting. */
|
|
252
|
+
/** `/word rest` → the word (lower-case) and the trimmed rest; anything else → null. */
|
|
253
|
+
function parseSlashCommand(text) {
|
|
254
|
+
const match = /^(\/[\w-]+)(?:\s+([\s\S]*))?$/.exec(text);
|
|
255
|
+
return match ? { word: match[1].toLowerCase(), args: (match[2] ?? '').trim() } : null;
|
|
256
|
+
}
|
|
257
|
+
function settle(result) {
|
|
258
|
+
return isObservable(result) ? lastValueFrom(result, { defaultValue: undefined }) : result;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* State and entry points of the assistant panel (ui-concept §5.3, AB#5549).
|
|
262
|
+
*
|
|
263
|
+
* - **Flag.** Everything is gated by {@link ASSISTANT_ENABLED} (off by default):
|
|
264
|
+
* while it is off every entry point is a no-op that returns `false`, and the
|
|
265
|
+
* service does not even listen to the router.
|
|
266
|
+
* - **Entry points.** {@link open} (top-bar ✦, Cmd/Ctrl+J, palette `?` scope),
|
|
267
|
+
* {@link requestExplain} ("✦ Explain <object>", AB#5545/5547), {@link toggle}.
|
|
268
|
+
* They open the panel with a prefilled composer and context; they never send.
|
|
269
|
+
* - **Thread.** Survives closing the panel and navigating. Items only come from the
|
|
270
|
+
* {@link ASSISTANT_TRANSPORT}; the default transport is not connected, so the
|
|
271
|
+
* library itself never adds an assistant turn.
|
|
272
|
+
* - **Context.** Tenant (route param `tenantId`), page (`ASSISTANT_PAGE_CONTEXT`, else the first
|
|
273
|
+
* breadcrumb of shared-services' `BreadCrumbService`) and entity chips; see {@link deriveAssistantContext}.
|
|
274
|
+
* - **Proposals.** Run / Edit / Discard are published on {@link proposalDecisions$}
|
|
275
|
+
* and nothing else — executing a write is the backend's confirm round-trip (AB#5550).
|
|
276
|
+
* - **Optional transport capabilities (AB#5621).** Sessions ({@link sessionsSupported},
|
|
277
|
+
* {@link openSession}, {@link deleteSession}, {@link newThread}), file attachments
|
|
278
|
+
* ({@link attachments}, {@link draftFiles}) and starter questions ({@link starterQuestions}) are
|
|
279
|
+
* only offered when the transport implements them; otherwise they are inert.
|
|
280
|
+
* - **Slash commands (AB#5621).** A command of `ASSISTANT_SLASH_COMMANDS` with `expand` is sent as
|
|
281
|
+
* the prompt it returns; see {@link send}.
|
|
282
|
+
*/
|
|
283
|
+
class AssistantService {
|
|
284
|
+
/** The feature flag. Read it to gate any assistant affordance. */
|
|
285
|
+
enabled = inject(ASSISTANT_ENABLED);
|
|
286
|
+
transport = inject(ASSISTANT_TRANSPORT);
|
|
287
|
+
router = inject(Router);
|
|
288
|
+
destroyRef = inject(DestroyRef);
|
|
289
|
+
/** Only resolved while enabled: a disabled assistant binds nothing. */
|
|
290
|
+
pageContext = null;
|
|
291
|
+
messages = inject(ASSISTANT_MESSAGES, { optional: true });
|
|
292
|
+
slashCommands = inject(ASSISTANT_SLASH_COMMANDS);
|
|
293
|
+
_isOpen = signal(false, /* @ts-ignore */
|
|
294
|
+
...(ngDevMode ? [{ debugName: "_isOpen" }] : /* istanbul ignore next */ []));
|
|
295
|
+
_draft = signal('', /* @ts-ignore */
|
|
296
|
+
...(ngDevMode ? [{ debugName: "_draft" }] : /* istanbul ignore next */ []));
|
|
297
|
+
_focusRequest = signal(0, /* @ts-ignore */
|
|
298
|
+
...(ngDevMode ? [{ debugName: "_focusRequest" }] : /* istanbul ignore next */ []));
|
|
299
|
+
_explicitEntity = signal(null, /* @ts-ignore */
|
|
300
|
+
...(ngDevMode ? [{ debugName: "_explicitEntity" }] : /* istanbul ignore next */ []));
|
|
301
|
+
_removedChips = signal(new Set(), /* @ts-ignore */
|
|
302
|
+
...(ngDevMode ? [{ debugName: "_removedChips" }] : /* istanbul ignore next */ []));
|
|
303
|
+
_thread = signal([], /* @ts-ignore */
|
|
304
|
+
...(ngDevMode ? [{ debugName: "_thread" }] : /* istanbul ignore next */ []));
|
|
305
|
+
_sending = signal(false, /* @ts-ignore */
|
|
306
|
+
...(ngDevMode ? [{ debugName: "_sending" }] : /* istanbul ignore next */ []));
|
|
307
|
+
_draftFiles = signal([], /* @ts-ignore */
|
|
308
|
+
...(ngDevMode ? [{ debugName: "_draftFiles" }] : /* istanbul ignore next */ []));
|
|
309
|
+
_starterQuestions = signal([], /* @ts-ignore */
|
|
310
|
+
...(ngDevMode ? [{ debugName: "_starterQuestions" }] : /* istanbul ignore next */ []));
|
|
311
|
+
starterRequest = 0;
|
|
312
|
+
/** Bumped by every thread switch (open session, new chat, loadThread); stale session loads are dropped. */
|
|
313
|
+
threadGeneration = 0;
|
|
314
|
+
_routeTick = signal(0, /* @ts-ignore */
|
|
315
|
+
...(ngDevMode ? [{ debugName: "_routeTick" }] : /* istanbul ignore next */ []));
|
|
316
|
+
_breadcrumbs = signal([], /* @ts-ignore */
|
|
317
|
+
...(ngDevMode ? [{ debugName: "_breadcrumbs" }] : /* istanbul ignore next */ []));
|
|
318
|
+
decisions = new Subject();
|
|
319
|
+
returnFocusTo = null;
|
|
320
|
+
pending = null;
|
|
321
|
+
itemCounter = 0;
|
|
322
|
+
/** Bumped by {@link stop}: an async slash-command expansion that resolves later is dropped. */
|
|
323
|
+
expansionGeneration = 0;
|
|
324
|
+
/** Whether the panel is shown. */
|
|
325
|
+
isOpen = this._isOpen.asReadonly();
|
|
326
|
+
/** Composer text; prefilled by entry points. */
|
|
327
|
+
draft = this._draft.asReadonly();
|
|
328
|
+
/** Bumped whenever an entry point wants the composer focused. */
|
|
329
|
+
focusRequest = this._focusRequest.asReadonly();
|
|
330
|
+
/** The rendered thread. */
|
|
331
|
+
thread = this._thread.asReadonly();
|
|
332
|
+
/** A turn is streaming. */
|
|
333
|
+
sending = this._sending.asReadonly();
|
|
334
|
+
transportStatus = this.transport.status;
|
|
335
|
+
transportMessage = this.transport.statusMessage;
|
|
336
|
+
/** The composer may send: the transport is ready and no turn is running. */
|
|
337
|
+
canSend = computed(() => this.transport.status() === 'ready' && !this._sending(), /* @ts-ignore */
|
|
338
|
+
...(ngDevMode ? [{ debugName: "canSend" }] : /* istanbul ignore next */ []));
|
|
339
|
+
/** The transport has saved sessions (`sessions` + `loadSession`): the panel shows its chat list. */
|
|
340
|
+
sessionsSupported = !!this.transport.sessions && typeof this.transport.loadSession === 'function';
|
|
341
|
+
/** Saved sessions can be deleted (`deleteSession`). */
|
|
342
|
+
sessionDeleteSupported = this.sessionsSupported && typeof this.transport.deleteSession === 'function';
|
|
343
|
+
/** Saved sessions of the transport (empty without session support). */
|
|
344
|
+
sessions = this.transport.sessions ?? NO_SESSIONS;
|
|
345
|
+
/** The session the next send writes to (`null` = fresh chat or no session support). */
|
|
346
|
+
activeSessionId = this.transport.activeSessionId ?? NO_ACTIVE_SESSION;
|
|
347
|
+
/** Accepted attachments, or `null` when the transport takes no files (no attach UI). */
|
|
348
|
+
attachments = this.transport.attachments ?? null;
|
|
349
|
+
/** Files attached in the composer; sent and cleared with the next turn. */
|
|
350
|
+
draftFiles = this._draftFiles.asReadonly();
|
|
351
|
+
/** Starter questions for an empty thread (see {@link loadStarterQuestions}). */
|
|
352
|
+
starterQuestions = this._starterQuestions.asReadonly();
|
|
353
|
+
/** Context of the current page minus the chips the person removed. */
|
|
354
|
+
contextChips = computed(() => {
|
|
355
|
+
this._routeTick();
|
|
356
|
+
const removed = this._removedChips();
|
|
357
|
+
return deriveAssistantContext({
|
|
358
|
+
root: this.router.routerState?.snapshot?.root,
|
|
359
|
+
url: this.router.url ?? '',
|
|
360
|
+
breadcrumbs: this._breadcrumbs(),
|
|
361
|
+
areaText: this.pageContext?.areaText() ?? null,
|
|
362
|
+
tabText: this.pageContext?.tabText() ?? null,
|
|
363
|
+
explicitEntity: this._explicitEntity()
|
|
364
|
+
}, this.messages).filter(chip => !removed.has(chip.id));
|
|
365
|
+
}, /* @ts-ignore */
|
|
366
|
+
...(ngDevMode ? [{ debugName: "contextChips" }] : /* istanbul ignore next */ []));
|
|
367
|
+
/** Run / Edit / Discard on proposal cards. Only emitted, never executed here. */
|
|
368
|
+
proposalDecisions$ = this.decisions.asObservable();
|
|
369
|
+
constructor() {
|
|
370
|
+
if (!this.enabled) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
this.pageContext = inject(ASSISTANT_PAGE_CONTEXT, { optional: true });
|
|
374
|
+
inject(BreadCrumbService).breadCrumbItems.pipe(takeUntilDestroyed(this.destroyRef))
|
|
375
|
+
.subscribe(items => this._breadcrumbs.set(items.map(item => item.text ?? '').filter(text => text.length > 0)));
|
|
376
|
+
this.router.events.pipe(filter(event => event instanceof NavigationEnd), takeUntilDestroyed(this.destroyRef))
|
|
377
|
+
.subscribe(() => {
|
|
378
|
+
// An explicit Explain target belongs to the page it was requested on.
|
|
379
|
+
this._explicitEntity.set(null);
|
|
380
|
+
this._routeTick.update(tick => tick + 1);
|
|
381
|
+
});
|
|
382
|
+
this.destroyRef.onDestroy(() => this.pending?.unsubscribe());
|
|
383
|
+
}
|
|
384
|
+
/** Opens the panel (or re-focuses it) with an optional prefill. Returns false while disabled. */
|
|
385
|
+
open(request = {}) {
|
|
386
|
+
if (!this.enabled) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
if (!this._isOpen()) {
|
|
390
|
+
const active = document.activeElement;
|
|
391
|
+
this.returnFocusTo = active instanceof HTMLElement && active !== document.body ? active : null;
|
|
392
|
+
}
|
|
393
|
+
if (request.entity) {
|
|
394
|
+
this._explicitEntity.set(request.entity);
|
|
395
|
+
this._removedChips.update(removed => removed.size ? new Set() : removed);
|
|
396
|
+
}
|
|
397
|
+
if (request.prompt !== undefined) {
|
|
398
|
+
this._draft.set(request.prompt);
|
|
399
|
+
}
|
|
400
|
+
this._isOpen.set(true);
|
|
401
|
+
this._focusRequest.update(count => count + 1);
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
/** "✦ Explain <object>": opens the panel with `/explain <label>` and the object as context chip. */
|
|
405
|
+
requestExplain(target) {
|
|
406
|
+
return this.open({ prompt: target.prompt ?? `/explain ${target.label}`, entity: target });
|
|
407
|
+
}
|
|
408
|
+
/** Hides the panel and gives focus back to where it was (the ✦ button if that is gone). */
|
|
409
|
+
close() {
|
|
410
|
+
if (!this._isOpen()) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
this._isOpen.set(false);
|
|
414
|
+
const target = this.returnFocusTo?.isConnected
|
|
415
|
+
? this.returnFocusTo
|
|
416
|
+
: document.querySelector('[data-assistant-toggle]');
|
|
417
|
+
this.returnFocusTo = null;
|
|
418
|
+
target?.focus();
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Forgets an open panel without moving focus — the host calls it when the
|
|
422
|
+
* panel can no longer be shown (tenant left or denied, connection lost), so it
|
|
423
|
+
* does not pop up again later with a stale return-focus target.
|
|
424
|
+
*/
|
|
425
|
+
dismiss() {
|
|
426
|
+
this._isOpen.set(false);
|
|
427
|
+
this.returnFocusTo = null;
|
|
428
|
+
}
|
|
429
|
+
/** Opens when closed, closes when open. Returns false while disabled. */
|
|
430
|
+
toggle() {
|
|
431
|
+
if (!this.enabled) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
if (this._isOpen()) {
|
|
435
|
+
this.close();
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
this.open();
|
|
439
|
+
}
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
setDraft(text) {
|
|
443
|
+
this._draft.set(text);
|
|
444
|
+
}
|
|
445
|
+
/** Replaces the attached files (ignored while the transport takes no files). */
|
|
446
|
+
setDraftFiles(files) {
|
|
447
|
+
if (this.attachments) {
|
|
448
|
+
this._draftFiles.set([...files]);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Asks the transport for its starter questions (the panel calls it when it opens).
|
|
453
|
+
* No-op without `starterQuestions`; a failing source leaves the list empty. Of an observable
|
|
454
|
+
* only the first emitted value counts (it need not complete); one that completes empty yields none.
|
|
455
|
+
*/
|
|
456
|
+
loadStarterQuestions() {
|
|
457
|
+
const source = this.transport.starterQuestions;
|
|
458
|
+
if (!this.enabled || typeof source !== 'function') {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const request = ++this.starterRequest;
|
|
462
|
+
const accept = (questions) => {
|
|
463
|
+
if (request === this.starterRequest) {
|
|
464
|
+
this._starterQuestions.set((questions ?? []).filter(q => typeof q === 'string' && q.trim().length > 0));
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
try {
|
|
468
|
+
const result = source.call(this.transport);
|
|
469
|
+
if (Array.isArray(result)) {
|
|
470
|
+
accept(result);
|
|
471
|
+
}
|
|
472
|
+
else if (isObservable(result)) {
|
|
473
|
+
// The first emitted value counts, so a never-completing source (BehaviorSubject, signal stream) works too.
|
|
474
|
+
firstValueFrom(result, { defaultValue: [] }).then(accept, () => accept([]));
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
result.then(accept, () => accept([]));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
accept([]);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/** Reloads the transport's session list. Resolves false without session support or on failure. */
|
|
485
|
+
async refreshSessions() {
|
|
486
|
+
if (!this.enabled || !this.sessionsSupported || typeof this.transport.refreshSessions !== 'function') {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
await settle(this.transport.refreshSessions());
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Opens a saved session: stops a running turn, asks the transport for the session's thread and
|
|
499
|
+
* shows it. On failure the current thread stays and an error row is added. Resolves whether the
|
|
500
|
+
* session was opened.
|
|
501
|
+
*/
|
|
502
|
+
async openSession(id) {
|
|
503
|
+
if (!this.enabled || !this.sessionsSupported) {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
this.stop();
|
|
507
|
+
const generation = ++this.threadGeneration;
|
|
508
|
+
try {
|
|
509
|
+
const items = await settle(this.transport.loadSession(id));
|
|
510
|
+
if (generation !== this.threadGeneration) {
|
|
511
|
+
// Another session was opened (or a new chat started) meanwhile: that one wins.
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
this.loadThread(items ?? []);
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
if (generation === this.threadGeneration) {
|
|
519
|
+
this.append({ kind: 'error', id: this.nextId('error'), text: resolveAssistantMessages(this.messages).sessionLoadFailed });
|
|
520
|
+
}
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Deletes a saved session; deleting the active one leaves an empty thread. On failure an error
|
|
526
|
+
* row is added. Resolves whether it was deleted.
|
|
527
|
+
*/
|
|
528
|
+
async deleteSession(id) {
|
|
529
|
+
if (!this.enabled || !this.sessionDeleteSupported) {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
const wasActive = this.activeSessionId() === id;
|
|
533
|
+
const generation = this.threadGeneration;
|
|
534
|
+
try {
|
|
535
|
+
await settle(this.transport.deleteSession(id));
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
this.append({ kind: 'error', id: this.nextId('error'), text: resolveAssistantMessages(this.messages).sessionDeleteFailed });
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
// Only clear when the thread still shows the deleted session (no switch while deleting).
|
|
542
|
+
if (wasActive && generation === this.threadGeneration) {
|
|
543
|
+
this.stop();
|
|
544
|
+
this.threadGeneration += 1;
|
|
545
|
+
this._thread.set([]);
|
|
546
|
+
}
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Starts a fresh chat: stops a running turn, empties the thread and the attached files and tells
|
|
551
|
+
* the transport (`newSession`, when it has one). Works without session support too.
|
|
552
|
+
*/
|
|
553
|
+
newThread() {
|
|
554
|
+
if (!this.enabled) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
this.stop();
|
|
558
|
+
this.threadGeneration += 1;
|
|
559
|
+
this._thread.set([]);
|
|
560
|
+
this._draftFiles.set([]);
|
|
561
|
+
this.transport.newSession?.();
|
|
562
|
+
this.loadStarterQuestions();
|
|
563
|
+
}
|
|
564
|
+
/** Replaces the thread, e.g. with a conversation the host restored itself. Stops a running turn. */
|
|
565
|
+
loadThread(items) {
|
|
566
|
+
if (!this.enabled) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
this.stop();
|
|
570
|
+
this.threadGeneration += 1;
|
|
571
|
+
this._thread.set([...items]);
|
|
572
|
+
}
|
|
573
|
+
/** Drops a context chip; it stays dropped until its value changes. */
|
|
574
|
+
removeChip(id) {
|
|
575
|
+
this._removedChips.update(removed => new Set(removed).add(id));
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Sends one user turn through the transport. Does nothing (returns false) unless
|
|
579
|
+
* the transport is `ready` — the default transport never is.
|
|
580
|
+
*
|
|
581
|
+
* With a transport that takes attachments, `files` (default: {@link draftFiles}) go along and
|
|
582
|
+
* the text may be empty; the attached files are cleared with the draft. Explicitly passed `files`
|
|
583
|
+
* are not checked again against {@link attachments} (`accept`, `maxFiles`, `maxFileSizeBytes`) —
|
|
584
|
+
* the composer validates on pick; a host passing files itself is responsible for them.
|
|
585
|
+
*
|
|
586
|
+
* **Slash commands (AB#5621).** When the text is `<command> [args]` and that command of
|
|
587
|
+
* `ASSISTANT_SLASH_COMMANDS` has `expand`, its result is sent instead and shown as the user turn,
|
|
588
|
+
* with the typed text as `slashCommand` (label in the thread, also on the send request). A
|
|
589
|
+
* promise-returning `expand` makes the turn start once it resolves (the composer is busy and Stop
|
|
590
|
+
* cancels meanwhile); returns true when the expansion started. If `expand` throws, rejects or yields
|
|
591
|
+
* an empty prompt, nothing is sent: an error row is added and the draft (text and files) comes back.
|
|
592
|
+
*/
|
|
593
|
+
send(text, files) {
|
|
594
|
+
const trimmed = text.trim();
|
|
595
|
+
const attached = this.attachments ? [...(files ?? this._draftFiles())] : [];
|
|
596
|
+
if (!this.enabled || (!trimmed && !attached.length) || !this.canSend()) {
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
const parsed = parseSlashCommand(trimmed);
|
|
600
|
+
const command = parsed ? this.slashCommands.find(c => c.command.toLowerCase() === parsed.word && typeof c.expand === 'function') : undefined;
|
|
601
|
+
if (!parsed || !command) {
|
|
602
|
+
this._draft.set('');
|
|
603
|
+
this._draftFiles.set([]);
|
|
604
|
+
this.startTurn(trimmed, attached);
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
const restoreDraft = () => {
|
|
608
|
+
if (!this._draft()) {
|
|
609
|
+
this._draft.set(text);
|
|
610
|
+
}
|
|
611
|
+
if (this.attachments && !this._draftFiles().length) {
|
|
612
|
+
this._draftFiles.set(attached);
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
const fail = () => {
|
|
616
|
+
this.append({
|
|
617
|
+
kind: 'error', id: this.nextId('error'),
|
|
618
|
+
text: formatAssistantMessage(resolveAssistantMessages(this.messages).slashCommandFailed, { command: command.command })
|
|
619
|
+
});
|
|
620
|
+
restoreDraft();
|
|
621
|
+
};
|
|
622
|
+
const accept = (prompt) => {
|
|
623
|
+
const expanded = typeof prompt === 'string' ? prompt.trim() : '';
|
|
624
|
+
if (!expanded) {
|
|
625
|
+
fail();
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
this.startTurn(expanded, attached, trimmed);
|
|
629
|
+
return true;
|
|
630
|
+
};
|
|
631
|
+
let result;
|
|
632
|
+
try {
|
|
633
|
+
result = command.expand(command.command, parsed.args);
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
fail();
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
this._draft.set('');
|
|
640
|
+
this._draftFiles.set([]);
|
|
641
|
+
if (typeof result === 'string' || !result || typeof result.then !== 'function') {
|
|
642
|
+
return accept(result);
|
|
643
|
+
}
|
|
644
|
+
// Async: busy until the prompt is there; stop() (or a thread switch) drops it.
|
|
645
|
+
const generation = ++this.expansionGeneration;
|
|
646
|
+
this._sending.set(true);
|
|
647
|
+
result.then(prompt => {
|
|
648
|
+
if (generation === this.expansionGeneration) {
|
|
649
|
+
this._sending.set(false);
|
|
650
|
+
accept(prompt);
|
|
651
|
+
}
|
|
652
|
+
}, () => {
|
|
653
|
+
if (generation === this.expansionGeneration) {
|
|
654
|
+
this._sending.set(false);
|
|
655
|
+
fail();
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
/** Stops the running turn (the composer's stop button), including a pending slash-command expansion. */
|
|
661
|
+
stop() {
|
|
662
|
+
this.expansionGeneration += 1;
|
|
663
|
+
this.pending?.unsubscribe();
|
|
664
|
+
this.finishTurn();
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Records a decision on a proposal card and publishes it on
|
|
668
|
+
* {@link proposalDecisions$}. Run and Discard settle the card; Edit leaves it open.
|
|
669
|
+
* Nothing is executed here.
|
|
670
|
+
*/
|
|
671
|
+
decideProposal(kind, proposal) {
|
|
672
|
+
if (kind !== 'edit') {
|
|
673
|
+
this._thread.update(items => items.map(item => item.kind === 'proposal' && item.proposal.id === proposal.id ? { ...item, decision: kind } : item));
|
|
674
|
+
}
|
|
675
|
+
this.decisions.next({ kind, proposal });
|
|
676
|
+
}
|
|
677
|
+
/** Appends the user turn and streams the transport's answer. */
|
|
678
|
+
startTurn(text, attached, slashCommand) {
|
|
679
|
+
const user = { kind: 'user', id: this.nextId('user'), text };
|
|
680
|
+
if (attached.length) {
|
|
681
|
+
user.attachments = attached.map(file => ({ name: file.name, size: file.size }));
|
|
682
|
+
}
|
|
683
|
+
if (slashCommand !== undefined) {
|
|
684
|
+
user.slashCommand = slashCommand;
|
|
685
|
+
}
|
|
686
|
+
this.append(user);
|
|
687
|
+
this._sending.set(true);
|
|
688
|
+
const request = { text, context: this.contextChips() };
|
|
689
|
+
if (attached.length) {
|
|
690
|
+
request.files = attached;
|
|
691
|
+
}
|
|
692
|
+
if (slashCommand !== undefined) {
|
|
693
|
+
request.slashCommand = slashCommand;
|
|
694
|
+
}
|
|
695
|
+
this.pending = this.transport.send(request).subscribe({
|
|
696
|
+
next: event => this.apply(event),
|
|
697
|
+
error: (error) => {
|
|
698
|
+
this.append({ kind: 'error', id: this.nextId('error'), text: error instanceof Error ? error.message : String(error) });
|
|
699
|
+
this.finishTurn();
|
|
700
|
+
},
|
|
701
|
+
complete: () => this.finishTurn()
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
apply(event) {
|
|
705
|
+
switch (event.type) {
|
|
706
|
+
case 'message-delta': {
|
|
707
|
+
const id = `assistant:${event.messageId}`;
|
|
708
|
+
const existing = this._thread().find(item => item.id === id);
|
|
709
|
+
if (existing?.kind === 'assistant') {
|
|
710
|
+
this._thread.update(items => items.map(item => item.id === id && item.kind === 'assistant' ? { ...item, markdown: item.markdown + event.text } : item));
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
this.append({ kind: 'assistant', id, markdown: event.text, streaming: true });
|
|
714
|
+
}
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
case 'message-end':
|
|
718
|
+
this.endMessage(`assistant:${event.messageId}`);
|
|
719
|
+
break;
|
|
720
|
+
case 'tool-call': {
|
|
721
|
+
const id = `tool:${event.call.callId}`;
|
|
722
|
+
if (this._thread().some(item => item.id === id)) {
|
|
723
|
+
this._thread.update(items => items.map(item => item.id === id ? { ...item, call: event.call } : item));
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
this.append({ kind: 'tool-call', id, call: event.call });
|
|
727
|
+
}
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
case 'proposal':
|
|
731
|
+
this.append({ kind: 'proposal', id: `proposal:${event.proposal.id}`, proposal: event.proposal, decision: null });
|
|
732
|
+
break;
|
|
733
|
+
case 'error':
|
|
734
|
+
this.append({ kind: 'error', id: this.nextId('error'), text: event.message });
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
finishTurn() {
|
|
739
|
+
this.pending = null;
|
|
740
|
+
this._sending.set(false);
|
|
741
|
+
this._thread.update(items => items.map(item => item.kind === 'assistant' && item.streaming ? { ...item, streaming: false } : item));
|
|
742
|
+
}
|
|
743
|
+
endMessage(id) {
|
|
744
|
+
this._thread.update(items => items.map(item => item.id === id && item.kind === 'assistant' ? { ...item, streaming: false } : item));
|
|
745
|
+
}
|
|
746
|
+
append(item) {
|
|
747
|
+
this._thread.update(items => [...items, item]);
|
|
748
|
+
}
|
|
749
|
+
nextId(prefix) {
|
|
750
|
+
this.itemCounter += 1;
|
|
751
|
+
return `${prefix}:${this.itemCounter}`;
|
|
752
|
+
}
|
|
753
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: AssistantService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
754
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: AssistantService, providedIn: 'root' });
|
|
755
|
+
}
|
|
756
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: AssistantService, decorators: [{
|
|
757
|
+
type: Injectable,
|
|
758
|
+
args: [{ providedIn: 'root' }]
|
|
759
|
+
}], ctorParameters: () => [] });
|
|
760
|
+
/**
|
|
761
|
+
* Cmd+J (macOS) / Ctrl+J toggles the assistant panel (ui-concept §4.3), also
|
|
762
|
+
* from text fields — it types nothing. Events another handler consumed and
|
|
763
|
+
* IME composition are ignored.
|
|
764
|
+
*/
|
|
765
|
+
function assistantHotkey(event) {
|
|
766
|
+
return !event.defaultPrevented && !event.isComposing && event.key?.toLowerCase() === 'j'
|
|
767
|
+
&& (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Test double for {@link AssistantTransport} (specs only — never provided by the app).
|
|
772
|
+
* Each `send` returns a Subject the spec drives with `emit` / `complete`.
|
|
773
|
+
*/
|
|
774
|
+
class FakeAssistantTransport {
|
|
775
|
+
status = signal('ready', /* @ts-ignore */
|
|
776
|
+
...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
|
|
777
|
+
statusMessage = signal(null, /* @ts-ignore */
|
|
778
|
+
...(ngDevMode ? [{ debugName: "statusMessage" }] : /* istanbul ignore next */ []));
|
|
779
|
+
requests = [];
|
|
780
|
+
turn = null;
|
|
781
|
+
send(request) {
|
|
782
|
+
this.requests.push(request);
|
|
783
|
+
this.turn = new Subject();
|
|
784
|
+
return this.turn.asObservable();
|
|
785
|
+
}
|
|
786
|
+
emit(event) {
|
|
787
|
+
this.turn?.next(event);
|
|
788
|
+
}
|
|
789
|
+
fail(error) {
|
|
790
|
+
this.turn?.error(error);
|
|
791
|
+
}
|
|
792
|
+
complete() {
|
|
793
|
+
this.turn?.complete();
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** "512 B", "1.5 KB", "10 MB" — for the size-limit message. */
|
|
798
|
+
function formatAssistantFileSize(bytes) {
|
|
799
|
+
if (bytes < 1024) {
|
|
800
|
+
return `${bytes} B`;
|
|
801
|
+
}
|
|
802
|
+
const units = ['KB', 'MB', 'GB'];
|
|
803
|
+
let value = bytes / 1024;
|
|
804
|
+
let unit = 0;
|
|
805
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
806
|
+
value /= 1024;
|
|
807
|
+
unit += 1;
|
|
808
|
+
}
|
|
809
|
+
return `${Number.isInteger(value) ? value : value.toFixed(1)} ${units[unit]}`;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Whether a file matches an `accept` list (`<input accept>` syntax: MIME types, `type/*`
|
|
813
|
+
* wildcards and `.ext` extensions, comma separated). An empty list accepts everything.
|
|
814
|
+
*/
|
|
815
|
+
function assistantFileAccepted(file, accept) {
|
|
816
|
+
const patterns = (accept ?? '').split(',').map(p => p.trim().toLowerCase()).filter(p => p.length > 0);
|
|
817
|
+
if (!patterns.length) {
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
const name = file.name.toLowerCase();
|
|
821
|
+
const type = (file.type ?? '').toLowerCase();
|
|
822
|
+
return patterns.some(pattern => {
|
|
823
|
+
if (pattern.startsWith('.')) {
|
|
824
|
+
return name.endsWith(pattern);
|
|
825
|
+
}
|
|
826
|
+
if (pattern.endsWith('/*')) {
|
|
827
|
+
return type.startsWith(pattern.slice(0, -1));
|
|
828
|
+
}
|
|
829
|
+
return type === pattern;
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Adds newly picked files to the already attached ones, enforcing the transport's
|
|
834
|
+
* {@link AssistantAttachmentOptions}: wrong type and too large files are rejected individually,
|
|
835
|
+
* files beyond `maxFiles` (default 1) are dropped with one "too many" message. With
|
|
836
|
+
* `maxFiles: 1` a newly picked valid file replaces the attached one. Pure.
|
|
837
|
+
*/
|
|
838
|
+
function selectAssistantAttachments(current, added, options, messages) {
|
|
839
|
+
const maxFiles = Math.max(1, options.maxFiles ?? 1);
|
|
840
|
+
let files = [...current];
|
|
841
|
+
if (maxFiles === 1 && added.length === 1) {
|
|
842
|
+
files = [];
|
|
843
|
+
}
|
|
844
|
+
const errors = [];
|
|
845
|
+
let tooMany = false;
|
|
846
|
+
for (const file of added) {
|
|
847
|
+
if (!assistantFileAccepted(file, options.accept)) {
|
|
848
|
+
errors.push(formatAssistantMessage(messages.attachmentWrongType, { name: file.name }));
|
|
849
|
+
}
|
|
850
|
+
else if (options.maxFileSizeBytes !== undefined && file.size > options.maxFileSizeBytes) {
|
|
851
|
+
errors.push(formatAssistantMessage(messages.attachmentTooLarge, { name: file.name, max: formatAssistantFileSize(options.maxFileSizeBytes) }));
|
|
852
|
+
}
|
|
853
|
+
else if (files.length >= maxFiles) {
|
|
854
|
+
tooMany = true;
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
files.push(file);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (maxFiles === 1 && added.length === 1 && !files.length) {
|
|
861
|
+
files = [...current];
|
|
862
|
+
}
|
|
863
|
+
if (tooMany) {
|
|
864
|
+
errors.push(formatAssistantMessage(messages.attachmentTooMany, { max: maxFiles }));
|
|
865
|
+
}
|
|
866
|
+
return { files, errors };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Renders its template only while the assistant is enabled — with the flag off
|
|
871
|
+
* the element is not in the DOM at all. Use it on every AI affordance:
|
|
872
|
+
*
|
|
873
|
+
* ```html
|
|
874
|
+
* <button *mmIfAssistant type="button" class="ai-action"
|
|
875
|
+
* [mmAssistantExplain]="{ label: adapter.name, rtId: adapter.rtId, ckTypeId: 'System.Communication/Adapter' }">
|
|
876
|
+
* ✦ Explain
|
|
877
|
+
* </button>
|
|
878
|
+
* ```
|
|
879
|
+
*/
|
|
880
|
+
class IfAssistantDirective {
|
|
881
|
+
constructor() {
|
|
882
|
+
if (inject(AssistantService).enabled) {
|
|
883
|
+
inject(ViewContainerRef).createEmbeddedView(inject(TemplateRef));
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: IfAssistantDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
887
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.5", type: IfAssistantDirective, isStandalone: true, selector: "[mmIfAssistant]", ngImport: i0 });
|
|
888
|
+
}
|
|
889
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: IfAssistantDirective, decorators: [{
|
|
890
|
+
type: Directive,
|
|
891
|
+
args: [{ selector: '[mmIfAssistant]' }]
|
|
892
|
+
}], ctorParameters: () => [] });
|
|
893
|
+
/**
|
|
894
|
+
* "✦ Explain <object>" entry point (ui-concept §5.4): a click opens the assistant
|
|
895
|
+
* panel with `/explain <label>` in the composer and the object as context chip.
|
|
896
|
+
* Nothing is sent. Pair it with {@link IfAssistantDirective} so the trigger does
|
|
897
|
+
* not exist while the assistant is disabled; code without a template (a
|
|
898
|
+
* `CommandItem.onClick`) calls `AssistantService.requestExplain()` instead.
|
|
899
|
+
*/
|
|
900
|
+
class AssistantExplainDirective {
|
|
901
|
+
assistant = inject(AssistantService);
|
|
902
|
+
target = input.required({ ...(ngDevMode ? { debugName: "target" } : /* istanbul ignore next */ {}), alias: 'mmAssistantExplain' });
|
|
903
|
+
explain() {
|
|
904
|
+
this.assistant.requestExplain(this.target());
|
|
905
|
+
}
|
|
906
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: AssistantExplainDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
907
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.5", type: AssistantExplainDirective, isStandalone: true, selector: "[mmAssistantExplain]", inputs: { target: { classPropertyName: "target", publicName: "mmAssistantExplain", isSignal: true, isRequired: true, transformFunction: null } }, host: { listeners: { "click": "explain()" }, properties: { "attr.aria-controls": "assistant.isOpen() ? \"assistant-panel\" : null" } }, ngImport: i0 });
|
|
908
|
+
}
|
|
909
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: AssistantExplainDirective, decorators: [{
|
|
910
|
+
type: Directive,
|
|
911
|
+
args: [{
|
|
912
|
+
selector: '[mmAssistantExplain]',
|
|
913
|
+
host: {
|
|
914
|
+
'(click)': 'explain()',
|
|
915
|
+
'[attr.aria-controls]': 'assistant.isOpen() ? "assistant-panel" : null'
|
|
916
|
+
}
|
|
917
|
+
}]
|
|
918
|
+
}], propDecorators: { target: [{ type: i0.Input, args: [{ isSignal: true, alias: "mmAssistantExplain", required: true }] }] } });
|
|
919
|
+
|
|
920
|
+
/*
|
|
921
|
+
* Public API Surface of @meshmakers/shared-ui/assistant-core (AB#5621)
|
|
922
|
+
*/
|
|
923
|
+
/**
|
|
924
|
+
* The startup part of the assistant: state ({@link AssistantService}), the Cmd/Ctrl+J hotkey,
|
|
925
|
+
* the injection tokens with their defaults, the "not connected" transport, models, messages and the
|
|
926
|
+
* "✦ Explain" directives. It never imports `marked` or the panel, so a host can wire the assistant
|
|
927
|
+
* in `app.config.ts` / its root component without pulling the panel into the initial bundle and load
|
|
928
|
+
* `AssistantPanelComponent` from `@meshmakers/shared-ui/assistant` lazily (e.g. with `@defer`).
|
|
929
|
+
*
|
|
930
|
+
* `@meshmakers/shared-ui/assistant` re-exports everything from here (same symbols, same tokens),
|
|
931
|
+
* so both entry points can be mixed freely.
|
|
932
|
+
*/
|
|
933
|
+
// --- Contract and tokens ---
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Generated bundle index. Do not edit.
|
|
937
|
+
*/
|
|
938
|
+
|
|
939
|
+
export { ASSISTANT_ENABLED, ASSISTANT_MESSAGES, ASSISTANT_NOT_CONNECTED_MESSAGE, ASSISTANT_PAGE_CONTEXT, ASSISTANT_PANEL_OPTIONS, ASSISTANT_SLASH_COMMANDS, ASSISTANT_TOOL_CALL_COMPONENT, ASSISTANT_TRANSPORT, AssistantExplainDirective, AssistantService, AssistantToolCallRowComponent, DEFAULT_ASSISTANT_MESSAGES, DEFAULT_ASSISTANT_PANEL_MAX_WIDTH, DEFAULT_ASSISTANT_PANEL_MIN_WIDTH, DEFAULT_ASSISTANT_PANEL_STORAGE_KEY, DEFAULT_ASSISTANT_SLASH_COMMANDS, FakeAssistantTransport, IfAssistantDirective, NotConnectedAssistantTransport, assistantFileAccepted, assistantHotkey, assistantMessages, deriveAssistantContext, formatAssistantFileSize, formatAssistantMessage, proposalDecisionLabel, resolveAssistantMessages, selectAssistantAttachments };
|
|
940
|
+
//# sourceMappingURL=meshmakers-shared-ui-assistant-core.mjs.map
|