@trismegisto/spine 3.3.6 → 3.3.8

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.
@@ -1915,19 +1915,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
1915
1915
  const TOKEN_HEADER = 'x-access-token';
1916
1916
  const WS_PROTOCOL = 'graphql-transport-ws';
1917
1917
  const SUBSCRIPTION_ID = '1';
1918
- const EVENTS_QUERY = `subscription ($agentId: ID!, $conversationId: ID!) {
1919
- agentEvents(agentId: $agentId, conversationId: $conversationId) { type text tool title ok detail message }
1918
+ // Nenhuma operação leva o agente: o gateway o descobre pelo client do token.
1919
+ const EVENTS_QUERY = `subscription ($conversationId: ID!) {
1920
+ agentEvents(conversationId: $conversationId) { type text tool title ok detail message }
1920
1921
  }`;
1921
- const SEND_MUTATION = `mutation ($agentId: ID!, $input: SendAgentMessageInput!) {
1922
- sendAgentMessage(agentId: $agentId, input: $input) { status message }
1922
+ const SEND_MUTATION = `mutation ($input: SendAgentMessageInput!) {
1923
+ sendAgentMessage(input: $input) { status message }
1923
1924
  }`;
1924
- const RATE_MUTATION = `mutation ($agentId: ID!, $input: RateAgentMessageInput!) {
1925
- rateAgentMessage(agentId: $agentId, input: $input) { status message }
1925
+ const RATE_MUTATION = `mutation ($input: RateAgentMessageInput!) {
1926
+ rateAgentMessage(input: $input) { status message }
1926
1927
  }`;
1927
- const CONVERSATION_QUERY = `query ($agentId: ID!, $conversationId: ID!) {
1928
- agentConversation(agentId: $agentId, conversationId: $conversationId) { status data { role text steps { tool title ok detail } } }
1928
+ const CONVERSATION_QUERY = `query ($conversationId: ID!) {
1929
+ agentConversation(conversationId: $conversationId) { status data { role text steps { tool title ok detail } } }
1930
+ }`;
1931
+ const AGENT_QUERY = `query { agent { status data { title suggestions greeting { title text } } } }`;
1932
+ const REFRESH_MUTATION = `mutation ($conversationId: ID!) {
1933
+ refreshAgentCredential(conversationId: $conversationId) { status message }
1929
1934
  }`;
1930
- const AGENTS_QUERY = `query { agents { status data { id suggestions } } }`;
1931
1935
  /**
1932
1936
  * Assina os eventos de uma conversa e devolve a função que encerra a assinatura. Usa o WebSocket do navegador, com o
1933
1937
  * protocolo do graphql-ws: o shell também atende apps sem GraphQL, que não têm o pacote `graphql-ws` instalado.
@@ -1976,7 +1980,7 @@ async function sendAgentMessage(url, token, variables, text) {
1976
1980
  headers: { 'content-type': 'application/json', [TOKEN_HEADER]: token },
1977
1981
  body: JSON.stringify({
1978
1982
  query: SEND_MUTATION,
1979
- variables: { agentId: variables.agentId, input: { conversationId: variables.conversationId, text } },
1983
+ variables: { input: { conversationId: variables.conversationId, text } },
1980
1984
  }),
1981
1985
  });
1982
1986
  const body = await response.json();
@@ -1989,22 +1993,47 @@ async function rateAgentMessage(url, token, variables, messageIndex, rating, cor
1989
1993
  headers: { 'content-type': 'application/json', [TOKEN_HEADER]: token },
1990
1994
  body: JSON.stringify({
1991
1995
  query: RATE_MUTATION,
1992
- variables: { agentId: variables.agentId, input: { conversationId: variables.conversationId, messageIndex, rating, correction } },
1996
+ variables: { input: { conversationId: variables.conversationId, messageIndex, rating, correction } },
1993
1997
  }),
1994
1998
  });
1995
1999
  const body = await response.json();
1996
2000
  return body?.data?.rateAgentMessage ?? { status: response.status >= 400 ? response.status : 500, message: '' };
1997
2001
  }
1998
- /** Perguntas prontas que o agente oferece. Agente que não aparece na lista não tem sugestões. */
1999
- async function fetchAgentSuggestions(url, token, agentId) {
2002
+ /** Gateway fora do ar ou com erro: vale tentar de novo. Nunca chega à tela. */
2003
+ class GatewayUnavailable extends Error {
2004
+ }
2005
+ /**
2006
+ * O agente desta pessoa, com as perguntas prontas dele. `null` quando o gateway responde que ela não tem agente: o app não
2007
+ * tem um, ou a pessoa está fora dos grupos dele, ou o token não é aceito. Falha de rede ou do gateway sobe como exceção,
2008
+ * para quem chama tentar de novo.
2009
+ */
2010
+ async function fetchAgent(url, token) {
2011
+ const response = await fetch(url, {
2012
+ method: 'POST',
2013
+ headers: { 'content-type': 'application/json', [TOKEN_HEADER]: token },
2014
+ body: JSON.stringify({ query: AGENT_QUERY }),
2015
+ });
2016
+ if (response.status >= 500)
2017
+ throw new GatewayUnavailable();
2018
+ const body = await response.json();
2019
+ const refused = (body?.errors ?? []).some((error) => [401, 403, 'UNAUTHENTICATED', 'FORBIDDEN'].includes(error?.extensions?.code));
2020
+ const envelope = body?.data?.agent;
2021
+ if (refused || !envelope || (envelope.status ?? 200) >= 400 || !envelope.data) {
2022
+ if (!refused && !envelope && body?.errors?.length)
2023
+ throw new GatewayUnavailable();
2024
+ return null;
2025
+ }
2026
+ return { title: envelope.data.title, suggestions: envelope.data.suggestions ?? [], greeting: envelope.data.greeting };
2027
+ }
2028
+ /** Leva o token renovado à resposta em andamento. Falha aqui não muda nada na tela: a resposta conclui com o que tiver. */
2029
+ async function refreshAgentCredential(url, token, variables) {
2000
2030
  const response = await fetch(url, {
2001
2031
  method: 'POST',
2002
2032
  headers: { 'content-type': 'application/json', [TOKEN_HEADER]: token },
2003
- body: JSON.stringify({ query: AGENTS_QUERY }),
2033
+ body: JSON.stringify({ query: REFRESH_MUTATION, variables }),
2004
2034
  });
2005
2035
  const body = await response.json();
2006
- const agents = body?.data?.agents?.data ?? [];
2007
- return agents.find((agent) => agent.id === agentId)?.suggestions ?? [];
2036
+ return body?.data?.refreshAgentCredential?.status ?? response.status;
2008
2037
  }
2009
2038
  /** As perguntas e respostas de uma conversa que já existe; vazia se ela expirou. */
2010
2039
  async function fetchAgentConversation(url, token, variables) {
@@ -2020,6 +2049,12 @@ async function fetchAgentConversation(url, token, variables) {
2020
2049
  // Teto para o agente dar sinal de vida; acima do limite de uma execução no backend.
2021
2050
  const SILENCE_LIMIT_MS = 200_000;
2022
2051
  const STORAGE_PREFIX = 'spine-ai-agent:conversation:';
2052
+ // Sem token ainda ou com o gateway fora: novas tentativas em 2, 4 e 6 s, e então desiste até a próxima tela.
2053
+ const AVAILABILITY_RETRIES = 3;
2054
+ const AVAILABILITY_RETRY_MS = 2_000;
2055
+ // De quanto em quanto tempo confere se o login trocou o token durante uma resposta. O agente começa a procurar o novo um
2056
+ // minuto antes do antigo vencer, então há folga de sobra.
2057
+ const CREDENTIAL_CHECK_MS = 10_000;
2023
2058
  const PLACEMENT_KEY = 'spine-ai-agent:placement';
2024
2059
  // O gateway fecha a conexão quando o token vence, e a rede também cai: a resposta continua no backend. Reconecta com o token atual.
2025
2060
  const RECONNECT_ATTEMPTS = 5;
@@ -2060,11 +2095,18 @@ function writeStored(key, value) {
2060
2095
  */
2061
2096
  class SpineAiAgentService {
2062
2097
  constructor() {
2063
- this.config = inject(SPINE_CONFIG, { optional: true })?.features?.aiAgent ?? null;
2098
+ this.spine = inject(SPINE_CONFIG, { optional: true });
2099
+ this.config = this.spine?.features?.aiAgent ?? null;
2064
2100
  this.auth = inject(AuthorizationService);
2065
2101
  this.text = inject(SPINE_MESSAGES).aiAgentChat;
2066
2102
  this.enabled = !!this.config;
2067
- this.title = this.config?.title || this.text.title;
2103
+ /**
2104
+ * O gateway confirmou que esta pessoa tem um agente: só então o balão e o ícone do cabeçalho aparecem. App sem agente, pessoa
2105
+ * fora dos grupos dele ou gateway fora do ar: nada aparece, em vez de um chat que só falha.
2106
+ */
2107
+ this.available = signal(false, ...(ngDevMode ? [{ debugName: "available" }] : /* istanbul ignore next */ []));
2108
+ /** Nome e saudação: os da definição do agente, que o gateway devolve; sem eles, os do app; sem estes, os padrões. */
2109
+ this.title = signal(this.config?.title || this.text.title, ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
2068
2110
  this.theme = inject(ThemeService);
2069
2111
  this.avatarSources = resolveSpineImage(this.config?.avatar, SPINE_DEFAULT_AGENT_AVATAR);
2070
2112
  this.thinkingSources = resolveSpineImage(this.config?.avatarThinking, null);
@@ -2075,8 +2117,8 @@ class SpineAiAgentService {
2075
2117
  this.avatar = computed(() => this.pickImage(this.avatarSources), ...(ngDevMode ? [{ debugName: "avatar" }] : /* istanbul ignore next */ []));
2076
2118
  /** A figura do agente, para o tema atual. */
2077
2119
  this.image = computed(() => this.pickImage(this.imageSources), ...(ngDevMode ? [{ debugName: "image" }] : /* istanbul ignore next */ []));
2078
- this.greetingTitle = this.config?.greeting?.title || this.text.greetingTitle;
2079
- this.greetingText = this.config?.greeting?.text || this.text.greetingText;
2120
+ this.greetingTitle = signal(this.config?.greeting?.title || this.text.greetingTitle, ...(ngDevMode ? [{ debugName: "greetingTitle" }] : /* istanbul ignore next */ []));
2121
+ this.greetingText = signal(this.config?.greeting?.text || this.text.greetingText, ...(ngDevMode ? [{ debugName: "greetingText" }] : /* istanbul ignore next */ []));
2080
2122
  this.entries = signal([], ...(ngDevMode ? [{ debugName: "entries" }] : /* istanbul ignore next */ []));
2081
2123
  this.running = signal(false, ...(ngDevMode ? [{ debugName: "running" }] : /* istanbul ignore next */ []));
2082
2124
  /** Perguntas prontas que o agente oferece, mostradas na conversa vazia. */
@@ -2089,7 +2131,8 @@ class SpineAiAgentService {
2089
2131
  this.position = signal(readPlacement().position ?? null, ...(ngDevMode ? [{ debugName: "position" }] : /* istanbul ignore next */ []));
2090
2132
  /** A pessoa pediu o painel agora: a conversa recebe o foco. Ao recarregar a tela, não. */
2091
2133
  this.focusOnOpen = false;
2092
- this.storageKey = `${STORAGE_PREFIX}${this.config?.agent ?? ''}`;
2134
+ // Por app: vários apps podem morar na mesma origem, e o localStorage é dela.
2135
+ this.storageKey = `${STORAGE_PREFIX}${this.spine?.clientId ?? ''}`;
2093
2136
  this.storedConversation = readStored(this.storageKey);
2094
2137
  this.conversationId = this.storedConversation ?? this.newConversationId();
2095
2138
  this.restored = !this.storedConversation;
@@ -2105,6 +2148,10 @@ class SpineAiAgentService {
2105
2148
  /** Mensagens que o backend já tinha guardado antes desta pergunta, para reconhecer a resposta nova. */
2106
2149
  this.savedBefore = 0;
2107
2150
  this.suggestionsLoaded = false;
2151
+ this.availabilityTries = 0;
2152
+ /** O token que a resposta em andamento tem; quando o login troca o da pessoa, o novo é mandado a ela. */
2153
+ this.credentialSent = null;
2154
+ this.credentialTimer = null;
2108
2155
  }
2109
2156
  /** Como no logo do header: no escuro vale `dark`, e se ele falhar volta para `light`. */
2110
2157
  pickImage(sources) {
@@ -2141,13 +2188,45 @@ class SpineAiAgentService {
2141
2188
  this.restored = true;
2142
2189
  this.send(question);
2143
2190
  }
2191
+ /**
2192
+ * Pergunta ao gateway se esta pessoa tem um agente, e já traz as sugestões. A resposta "não tem" é definitiva nesta tela;
2193
+ * sem token ainda, ou com o gateway fora, tenta de novo algumas vezes, cada vez mais devagar.
2194
+ */
2195
+ checkAvailability() {
2196
+ if (!this.config || this.available())
2197
+ return;
2198
+ const token = this.auth.getToken();
2199
+ if (!token) {
2200
+ this.retryAvailability();
2201
+ return;
2202
+ }
2203
+ fetchAgent(this.config.http, token).then((agent) => {
2204
+ if (!agent)
2205
+ return;
2206
+ if (agent.title)
2207
+ this.title.set(agent.title);
2208
+ if (agent.greeting?.title)
2209
+ this.greetingTitle.set(agent.greeting.title);
2210
+ if (agent.greeting?.text)
2211
+ this.greetingText.set(agent.greeting.text);
2212
+ this.suggestions.set(agent.suggestions);
2213
+ this.suggestionsLoaded = true;
2214
+ this.available.set(true);
2215
+ }, () => this.retryAvailability());
2216
+ }
2217
+ retryAvailability() {
2218
+ if (this.availabilityTries >= AVAILABILITY_RETRIES)
2219
+ return;
2220
+ this.availabilityTries += 1;
2221
+ setTimeout(() => this.checkAvailability(), AVAILABILITY_RETRY_MS * this.availabilityTries);
2222
+ }
2144
2223
  /** Busca as sugestões uma vez. Sem elas o chat funciona igual, então a falha não aparece para a pessoa. */
2145
2224
  loadSuggestions() {
2146
2225
  const token = this.auth.getToken();
2147
2226
  if (!this.config || !token || this.suggestionsLoaded)
2148
2227
  return;
2149
2228
  this.suggestionsLoaded = true;
2150
- fetchAgentSuggestions(this.config.http, token, this.config.agent).then((suggestions) => this.suggestions.set(suggestions), () => { this.suggestionsLoaded = false; });
2229
+ fetchAgent(this.config.http, token).then((agent) => this.suggestions.set(agent?.suggestions ?? []), () => { this.suggestionsLoaded = false; });
2151
2230
  }
2152
2231
  /** Traz de volta a conversa guardada, uma vez. Se falhar, ou se ela expirou, o chat só começa vazio. */
2153
2232
  restoreConversation() {
@@ -2156,7 +2235,7 @@ class SpineAiAgentService {
2156
2235
  return;
2157
2236
  this.restored = true;
2158
2237
  const conversationId = this.conversationId;
2159
- fetchAgentConversation(this.config.http, token, { agentId: this.config.agent, conversationId }).then((messages) => {
2238
+ fetchAgentConversation(this.config.http, token, { conversationId }).then((messages) => {
2160
2239
  // A pessoa pode ter começado a escrever antes de a resposta chegar: o que está na tela vence.
2161
2240
  if (this.entries().length || conversationId !== this.conversationId)
2162
2241
  return;
@@ -2182,7 +2261,7 @@ class SpineAiAgentService {
2182
2261
  const index = saved.findIndex((entry) => entry.id === entryId);
2183
2262
  if (!this.config || !token || index < 1)
2184
2263
  return false;
2185
- const variables = { agentId: this.config.agent, conversationId: this.conversationId };
2264
+ const variables = { conversationId: this.conversationId };
2186
2265
  try {
2187
2266
  const { status } = await rateAgentMessage(this.config.http, token, variables, index, rating, correction);
2188
2267
  if (status >= 400)
@@ -2194,6 +2273,46 @@ class SpineAiAgentService {
2194
2273
  this.update(entryId, (entry) => ({ ...entry, rating }));
2195
2274
  return true;
2196
2275
  }
2276
+ /**
2277
+ * Baixa a conversa da tela em PDF, gerado aqui no navegador: nada sai para o backend. O gerador vem só no clique, para
2278
+ * não pesar em quem nunca baixa. Devolve `false` se não houver o que baixar ou se a geração falhar.
2279
+ */
2280
+ async downloadTranscript() {
2281
+ const entries = this.entries().filter((entry) => entry.text.trim() || entry.steps.length);
2282
+ if (!entries.length)
2283
+ return false;
2284
+ try {
2285
+ const { buildTranscriptPdf, transcriptFileName } = await import('./trismegisto-spine-layout-ai-agent-transcript-BNcgbrV_.mjs');
2286
+ const now = new Date();
2287
+ const person = this.auth.profile?.name;
2288
+ const pdf = buildTranscriptPdf({
2289
+ title: this.title(),
2290
+ subtitle: [this.spine?.appName, person, now.toLocaleString()].filter(Boolean).join(' · '),
2291
+ personLabel: person || this.text.you,
2292
+ agentLabel: this.title(),
2293
+ queriesLabel: this.text.transcriptQueries,
2294
+ failedLabel: this.text.transcriptStepFailed,
2295
+ pageLabel: this.text.transcriptPage,
2296
+ entries: entries.map((entry) => ({
2297
+ role: entry.role,
2298
+ text: entry.text,
2299
+ error: entry.error,
2300
+ steps: entry.steps.map((step) => ({ title: step.title, detail: step.detail, failed: step.status === 'error' })),
2301
+ })),
2302
+ });
2303
+ const url = URL.createObjectURL(new Blob([pdf.slice().buffer], { type: 'application/pdf' }));
2304
+ const link = document.createElement('a');
2305
+ link.href = url;
2306
+ link.download = transcriptFileName(this.title(), this.text.transcriptFile, now);
2307
+ link.click();
2308
+ // O navegador só lê o endereço depois do clique: revogar na hora cancelaria o download em alguns deles.
2309
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
2310
+ return true;
2311
+ }
2312
+ catch {
2313
+ return false;
2314
+ }
2315
+ }
2197
2316
  /** Repete a última pergunta depois de um erro. Resposta que falhou não entra no histórico do backend. */
2198
2317
  retry() {
2199
2318
  const entries = this.entries();
@@ -2216,7 +2335,7 @@ class SpineAiAgentService {
2216
2335
  return;
2217
2336
  }
2218
2337
  this.running.set(true);
2219
- this.listen(answerId, { agentId: this.config.agent, conversationId: this.conversationId }, text);
2338
+ this.listen(answerId, { conversationId: this.conversationId }, text);
2220
2339
  }
2221
2340
  listen(answerId, variables, question) {
2222
2341
  const token = this.auth.getToken();
@@ -2247,6 +2366,7 @@ class SpineAiAgentService {
2247
2366
  return;
2248
2367
  }
2249
2368
  this.messageSent = true;
2369
+ this.watchCredential(http, variables, token);
2250
2370
  sendAgentMessage(http, token, variables, question).then(({ status, message }) => { if (status >= 400)
2251
2371
  this.fail(answerId, message || this.text.sendError); }, () => this.fail(answerId, this.text.sendError));
2252
2372
  }
@@ -2340,7 +2460,27 @@ class SpineAiAgentService {
2340
2460
  }));
2341
2461
  this.finish();
2342
2462
  }
2463
+ /**
2464
+ * O login renova o token da pessoa a cada poucos minutos, mas a resposta começou com o do envio e pode levar mais tempo que
2465
+ * ele. Enquanto ela está em andamento, cada token novo vai para o agente, que troca antes da próxima consulta.
2466
+ */
2467
+ watchCredential(http, variables, token) {
2468
+ this.credentialSent = token;
2469
+ if (this.credentialTimer)
2470
+ clearInterval(this.credentialTimer);
2471
+ this.credentialTimer = setInterval(() => {
2472
+ const current = this.auth.getToken();
2473
+ if (!current || current === this.credentialSent)
2474
+ return;
2475
+ this.credentialSent = current;
2476
+ refreshAgentCredential(http, current, variables).catch(() => undefined);
2477
+ }, CREDENTIAL_CHECK_MS);
2478
+ }
2343
2479
  finish() {
2480
+ if (this.credentialTimer)
2481
+ clearInterval(this.credentialTimer);
2482
+ this.credentialTimer = null;
2483
+ this.credentialSent = null;
2344
2484
  if (this.silence)
2345
2485
  clearTimeout(this.silence);
2346
2486
  if (this.reconnectTimer)
@@ -2386,12 +2526,12 @@ class SpineAiAgentDockButtonComponent {
2386
2526
  }
2387
2527
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineAiAgentDockButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2388
2528
  static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SpineAiAgentDockButtonComponent, isStandalone: true, selector: "spine-ai-agent-dock-button", ngImport: i0, template: `
2389
- @if (agent.enabled && agent.docked()) {
2529
+ @if (agent.available() && agent.docked()) {
2390
2530
  <hm-icon-button
2391
2531
  [class]="dockClass"
2392
2532
  emphasis="subtle"
2393
2533
  icon="chat-bubble"
2394
- [label]="agent.panelOpen() ? text.close : agent.title"
2534
+ [label]="agent.panelOpen() ? text.close : agent.title()"
2395
2535
  (clicked)="agent.togglePanel()"
2396
2536
  />
2397
2537
  }
@@ -2404,12 +2544,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
2404
2544
  imports: [IconButtonComponent],
2405
2545
  changeDetection: ChangeDetectionStrategy.OnPush,
2406
2546
  template: `
2407
- @if (agent.enabled && agent.docked()) {
2547
+ @if (agent.available() && agent.docked()) {
2408
2548
  <hm-icon-button
2409
2549
  [class]="dockClass"
2410
2550
  emphasis="subtle"
2411
2551
  icon="chat-bubble"
2412
- [label]="agent.panelOpen() ? text.close : agent.title"
2552
+ [label]="agent.panelOpen() ? text.close : agent.title()"
2413
2553
  (clicked)="agent.togglePanel()"
2414
2554
  />
2415
2555
  }
@@ -7558,7 +7698,7 @@ function registerPage() {
7558
7698
  route_redirect: SPINE_SUGGESTIONS_PAGE_PATH,
7559
7699
  description: text.description,
7560
7700
  },
7561
- load: () => import('./trismegisto-spine-layout-suggestions-page.component-Bh3VGlOR.mjs').then((m) => m.SpineSuggestionsPageComponent),
7701
+ load: () => import('./trismegisto-spine-layout-suggestions-page.component-Cj4KgMj3.mjs').then((m) => m.SpineSuggestionsPageComponent),
7562
7702
  });
7563
7703
  });
7564
7704
  }
@@ -8703,12 +8843,16 @@ class SpineAiAgentLauncherComponent {
8703
8843
  constructor() {
8704
8844
  this.agent = inject(SpineAiAgentService);
8705
8845
  this.text = inject(SPINE_MESSAGES).aiAgentChat;
8846
+ this.toast = inject(HermesToastService);
8706
8847
  this.panelId = PANEL_ID;
8707
8848
  this.titleId = TITLE_ID;
8708
8849
  /** Tamanho do painel: continua como a pessoa deixou ao fechar e abrir. */
8709
8850
  this.expanded = signal(readPanelState().expanded === true, ...(ngDevMode ? [{ debugName: "expanded" }] : /* istanbul ignore next */ []));
8710
8851
  this.chat = signal(null, ...(ngDevMode ? [{ debugName: "chat" }] : /* istanbul ignore next */ []));
8711
8852
  this.dragging = signal(false, ...(ngDevMode ? [{ debugName: "dragging" }] : /* istanbul ignore next */ []));
8853
+ this.started = false;
8854
+ // Lido na criação: o efeito que guarda o estado roda antes do balão aparecer e gravaria "fechado" por cima.
8855
+ this.initialPanel = readPanelState();
8712
8856
  /** Com o balão na metade de cima da tela, o painel abre para baixo, onde há espaço. */
8713
8857
  this.below = computed(() => !this.agent.docked() && (this.agent.position()?.bottom ?? 0) > window.innerHeight / 2, ...(ngDevMode ? [{ debugName: "below" }] : /* istanbul ignore next */ []));
8714
8858
  /** O balão é um círculo: o rosto cabe nele, e uma imagem de corpo inteiro cortaria onde não deve. */
@@ -8743,12 +8887,24 @@ class SpineAiAgentLauncherComponent {
8743
8887
  if (this.agent.panelOpen())
8744
8888
  untracked(() => void this.prepare());
8745
8889
  });
8890
+ // O balão só existe depois que o gateway confirma o agente: aí o painel volta como estava e o convite aparece.
8891
+ effect(() => {
8892
+ if (this.agent.available())
8893
+ untracked(() => this.start());
8894
+ });
8746
8895
  }
8747
8896
  ngOnInit() {
8897
+ if (this.agent.enabled)
8898
+ this.agent.checkAvailability();
8899
+ }
8900
+ start() {
8901
+ if (this.started)
8902
+ return;
8903
+ this.started = true;
8748
8904
  // Sem roubar o foco: a pessoa acabou de recarregar a tela, não pediu o chat agora.
8749
- if (this.agent.enabled && readPanelState().open === true)
8905
+ if (this.initialPanel.open === true)
8750
8906
  this.agent.panelOpen.set(true);
8751
- if (this.agent.enabled && this.greetingVisible()) {
8907
+ if (this.greetingVisible()) {
8752
8908
  this.agent.loadSuggestions();
8753
8909
  this.greetingTimer = setTimeout(() => this.closeGreeting(), GREETING_MS);
8754
8910
  }
@@ -8761,7 +8917,7 @@ class SpineAiAgentLauncherComponent {
8761
8917
  const focus = this.agent.focusOnOpen;
8762
8918
  this.agent.focusOnOpen = false;
8763
8919
  if (!this.chat()) {
8764
- const { SpineAiAgentChatComponent } = await import('./trismegisto-spine-layout-ai-agent-chat.component-C5JljPYD.mjs');
8920
+ const { SpineAiAgentChatComponent } = await import('./trismegisto-spine-layout-ai-agent-chat.component-Brh0_LS9.mjs');
8765
8921
  this.chat.set(SpineAiAgentChatComponent);
8766
8922
  }
8767
8923
  this.agent.loadSuggestions();
@@ -8797,6 +8953,10 @@ class SpineAiAgentLauncherComponent {
8797
8953
  this.agent.dock();
8798
8954
  afterNextRender(() => this.focusEntry(), { injector: this.injector });
8799
8955
  }
8956
+ async downloadTranscript() {
8957
+ if (!(await this.agent.downloadTranscript()))
8958
+ this.toast.error(this.text.transcriptDownloadError);
8959
+ }
8800
8960
  undock() {
8801
8961
  this.agent.undock();
8802
8962
  afterNextRender(() => this.focusEntry(), { injector: this.injector });
@@ -8844,7 +9004,7 @@ class SpineAiAgentLauncherComponent {
8844
9004
  }
8845
9005
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineAiAgentLauncherComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
8846
9006
  static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SpineAiAgentLauncherComponent, isStandalone: true, selector: "spine-ai-agent-launcher", host: { properties: { "class.spine-agent--docked": "agent.docked()", "class.spine-agent--below": "below()", "style.--spine-agent-end": "agent.position() ? agent.position()!.end + 'px' : null", "style.--spine-agent-bottom": "agent.position() ? agent.position()!.bottom + 'px' : null" } }, ngImport: i0, template: `
8847
- @if (agent.enabled) {
9007
+ @if (agent.available()) {
8848
9008
  @if (chat(); as component) {
8849
9009
  <section
8850
9010
  class="spine-agent-panel"
@@ -8861,7 +9021,7 @@ class SpineAiAgentLauncherComponent {
8861
9021
  @if (agent.avatarNow(); as avatar) {
8862
9022
  <img class="spine-agent-panel__avatar" [src]="avatar" alt="" (error)="agent.imageFailed(avatar)" />
8863
9023
  }
8864
- <h2 class="spine-agent-panel__title" [attr.id]="titleId">{{ agent.title }}</h2>
9024
+ <h2 class="spine-agent-panel__title" [attr.id]="titleId">{{ agent.title() }}</h2>
8865
9025
  <hm-icon-button
8866
9026
  emphasis="subtle"
8867
9027
  size="small"
@@ -8870,6 +9030,14 @@ class SpineAiAgentLauncherComponent {
8870
9030
  [disabled]="agent.running() || !agent.entries().length"
8871
9031
  (clicked)="agent.reset()"
8872
9032
  />
9033
+ <hm-icon-button
9034
+ emphasis="subtle"
9035
+ size="small"
9036
+ icon="download"
9037
+ [label]="text.transcriptDownload"
9038
+ [disabled]="agent.running() || !agent.entries().length"
9039
+ (clicked)="downloadTranscript()"
9040
+ />
8873
9041
  <hm-icon-button
8874
9042
  emphasis="subtle"
8875
9043
  size="small"
@@ -8895,7 +9063,7 @@ class SpineAiAgentLauncherComponent {
8895
9063
  [class.spine-agent-greeting--after]="greetingAfter()"
8896
9064
  [class.spine-agent-greeting--below]="below()"
8897
9065
  [class.spine-agent-greeting--figure]="agent.image()"
8898
- [attr.aria-label]="agent.title"
9066
+ [attr.aria-label]="agent.title()"
8899
9067
  (pointerdown)="$event.stopPropagation()"
8900
9068
  (mouseenter)="keepGreeting()"
8901
9069
  (focusin)="keepGreeting()"
@@ -8905,10 +9073,10 @@ class SpineAiAgentLauncherComponent {
8905
9073
  }
8906
9074
  <div class="spine-agent-greeting__content">
8907
9075
  <div class="spine-agent-greeting__head">
8908
- <p class="spine-agent-greeting__title">{{ agent.greetingTitle }}</p>
9076
+ <p class="spine-agent-greeting__title">{{ agent.greetingTitle() }}</p>
8909
9077
  <hm-icon-button emphasis="subtle" size="small" icon="times" [label]="text.greetingClose" (clicked)="closeGreeting()" />
8910
9078
  </div>
8911
- <p class="spine-agent-greeting__text">{{ agent.greetingText }}</p>
9079
+ <p class="spine-agent-greeting__text">{{ agent.greetingText() }}</p>
8912
9080
  @if (greetingSuggestions().length) {
8913
9081
  <hm-prompt-suggestions [suggestions]="greetingSuggestions()" (chosen)="askFromGreeting($event)" />
8914
9082
  }
@@ -8917,7 +9085,7 @@ class SpineAiAgentLauncherComponent {
8917
9085
  }
8918
9086
  <hm-fab
8919
9087
  [fixed]="false"
8920
- [label]="agent.panelOpen() ? text.close : agent.title"
9088
+ [label]="agent.panelOpen() ? text.close : agent.title()"
8921
9089
  [icon]="agent.panelOpen() ? 'times' : fabImage() ? '' : 'chat-bubble'"
8922
9090
  [expanded]="agent.panelOpen()"
8923
9091
  [controls]="panelId"
@@ -8951,7 +9119,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
8951
9119
  '[style.--spine-agent-end]': "agent.position() ? agent.position()!.end + 'px' : null",
8952
9120
  '[style.--spine-agent-bottom]': "agent.position() ? agent.position()!.bottom + 'px' : null",
8953
9121
  }, template: `
8954
- @if (agent.enabled) {
9122
+ @if (agent.available()) {
8955
9123
  @if (chat(); as component) {
8956
9124
  <section
8957
9125
  class="spine-agent-panel"
@@ -8968,7 +9136,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
8968
9136
  @if (agent.avatarNow(); as avatar) {
8969
9137
  <img class="spine-agent-panel__avatar" [src]="avatar" alt="" (error)="agent.imageFailed(avatar)" />
8970
9138
  }
8971
- <h2 class="spine-agent-panel__title" [attr.id]="titleId">{{ agent.title }}</h2>
9139
+ <h2 class="spine-agent-panel__title" [attr.id]="titleId">{{ agent.title() }}</h2>
8972
9140
  <hm-icon-button
8973
9141
  emphasis="subtle"
8974
9142
  size="small"
@@ -8977,6 +9145,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
8977
9145
  [disabled]="agent.running() || !agent.entries().length"
8978
9146
  (clicked)="agent.reset()"
8979
9147
  />
9148
+ <hm-icon-button
9149
+ emphasis="subtle"
9150
+ size="small"
9151
+ icon="download"
9152
+ [label]="text.transcriptDownload"
9153
+ [disabled]="agent.running() || !agent.entries().length"
9154
+ (clicked)="downloadTranscript()"
9155
+ />
8980
9156
  <hm-icon-button
8981
9157
  emphasis="subtle"
8982
9158
  size="small"
@@ -9002,7 +9178,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
9002
9178
  [class.spine-agent-greeting--after]="greetingAfter()"
9003
9179
  [class.spine-agent-greeting--below]="below()"
9004
9180
  [class.spine-agent-greeting--figure]="agent.image()"
9005
- [attr.aria-label]="agent.title"
9181
+ [attr.aria-label]="agent.title()"
9006
9182
  (pointerdown)="$event.stopPropagation()"
9007
9183
  (mouseenter)="keepGreeting()"
9008
9184
  (focusin)="keepGreeting()"
@@ -9012,10 +9188,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
9012
9188
  }
9013
9189
  <div class="spine-agent-greeting__content">
9014
9190
  <div class="spine-agent-greeting__head">
9015
- <p class="spine-agent-greeting__title">{{ agent.greetingTitle }}</p>
9191
+ <p class="spine-agent-greeting__title">{{ agent.greetingTitle() }}</p>
9016
9192
  <hm-icon-button emphasis="subtle" size="small" icon="times" [label]="text.greetingClose" (clicked)="closeGreeting()" />
9017
9193
  </div>
9018
- <p class="spine-agent-greeting__text">{{ agent.greetingText }}</p>
9194
+ <p class="spine-agent-greeting__text">{{ agent.greetingText() }}</p>
9019
9195
  @if (greetingSuggestions().length) {
9020
9196
  <hm-prompt-suggestions [suggestions]="greetingSuggestions()" (chosen)="askFromGreeting($event)" />
9021
9197
  }
@@ -9024,7 +9200,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
9024
9200
  }
9025
9201
  <hm-fab
9026
9202
  [fixed]="false"
9027
- [label]="agent.panelOpen() ? text.close : agent.title"
9203
+ [label]="agent.panelOpen() ? text.close : agent.title()"
9028
9204
  [icon]="agent.panelOpen() ? 'times' : fabImage() ? '' : 'chat-bubble'"
9029
9205
  [expanded]="agent.panelOpen()"
9030
9206
  [controls]="panelId"
@@ -9716,4 +9892,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
9716
9892
  */
9717
9893
 
9718
9894
  export { SpineAppTokensService as $, AppLauncherComponent as A, SPINE_INBOX_POLL_MS as B, SPINE_INBOX_SOURCES as C, SPINE_MARKDOWN_PURIFY_CONFIG as D, SPINE_PREFERENCES_SAVE_DELAY_MS as E, SPINE_RATING_COMMENT_MAX as F, SPINE_RATING_SCORE_MAX as G, HeaderComponent as H, SPINE_SETTINGS_ALIASES as I, SPINE_SETTINGS_KEYS as J, SPINE_SETTINGS_SECTIONS as K, LayoutComponent as L, McpConnectComponent as M, NEW_SCREEN_VALUE as N, SPINE_SUGGESTIONS_PAGE_ID as O, SPINE_SUGGESTION_LIMITS as P, SPINE_SUGGESTION_PUBLIC_SORTS as Q, SPINE_SUGGESTION_PUBLIC_STATUSES as R, SpineSuggestionsService as S, SPINE_SUGGESTION_STATUS_ICONS as T, SPINE_WORKSPACE_LIMITS as U, SettingsPanelComponent as V, ShortcutService as W, ShortcutsCheatsheetComponent as X, SidebarComponent as Y, SpineAccountPreferencesService as Z, SpineAccountSecurityService as _, SpineInboxService as a, SpineBrandComponent as a0, SpineConnectionBannerComponent as a1, SpineDocsComponent as a2, SpineDocsService as a3, SpineIdleService as a4, SpineIdleWarningComponent as a5, SpineInboxButtonComponent as a6, SpineInitialSetupComponent as a7, SpineInitialSetupService as a8, SpineLogoutService as a9, spineParamsOf as aA, spinePathOf as aB, spineScoreBand as aC, spineStarFills as aD, spineSuggestionsEnabled as aE, spineTagColor as aF, stable as aG, SpineMyGroupsService as aa, SpineNewsComponent as ab, SpineNewsService as ac, SpineNoticesService as ad, SpinePageActionsDirective as ae, SpinePageActionsSlot as af, SpineRatingStarsComponent as ag, SpineRatingsService as ah, SpineSettingsService as ai, SpineStartPageService as aj, SpineUnsavedChangesService as ak, SpineWorkspaceNavService as al, accountErrorCode as am, argosAccountApi as an, buildMcpConfig as ao, effectiveIdleMinutes as ap, idleLimits as aq, isInternalPath as ar, provideSettingsSection as as, provideSpineInboxSource as at, provideSpineSuggestionsPage as au, ratingTargetKey as av, registerSpineUnsavedChanges as aw, resolveSettingsSection as ax, sanitizeSpineMarkdownHtml as ay, spineNormalizeUrl as az, SpineSuggestionChatComponent as b, SPINE_SUGGESTION_STATUS_COLORS as c, SPINE_SUGGESTION_KIND_COLORS as d, SPINE_SUGGESTION_KIND_ICONS as e, SPINE_SUGGESTION_WAITING_COLORS as f, SPINE_SUGGESTION_VISIBLE_SORTS as g, SPINE_SUGGESTION_REASONS as h, SPINE_SUGGESTION_STATUSES as i, SPINE_SUGGESTION_KINDS as j, SPINE_SUGGESTION_WAITING as k, SPINE_TAB as l, SPINE_SUGGESTION_PAGE_PARAM as m, SPINE_SUGGESTION_TAB_PARAM as n, SPINE_SUGGESTION_TAB_CONVERSATION as o, screenName as p, screenModule as q, SPINE_SUGGESTIONS_PAGE_PATH as r, suggestionScreens as s, SpineSuggestionsDialogComponent as t, SpineTagRowComponent as u, SpineAiAgentService as v, SpineMarkdownComponent as w, SPINE_BRAND as x, SPINE_GLOBAL_PREFERENCE_KEYS as y, SPINE_IDLE_WARNING_SECONDS as z };
9719
- //# sourceMappingURL=trismegisto-spine-layout-trismegisto-spine-layout-B47sMo_4.mjs.map
9895
+ //# sourceMappingURL=trismegisto-spine-layout-trismegisto-spine-layout-UM7CC7Sj.mjs.map