@trismegisto/spine 3.3.6 → 3.3.7

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 CHANGED
@@ -237,7 +237,7 @@ Desligados sem configuração; o app liga cada um em `provideUi({ ..., features
237
237
 
238
238
  ```ts
239
239
  features: {
240
- aiAgent: { agent: 'meu-app', http: '/agents/graphql', ws: 'wss://exemplo.com/agents/graphql', title: 'Agente do app' },
240
+ aiAgent: { http: '/agents/graphql', ws: 'wss://exemplo.com/agents/graphql', title: 'Agente do app' },
241
241
  mcpConnect: { servers: [{ name: 'hefesto', url: '/hefesto/mcp', label: 'Hefesto', readScopes: 'deploy.*.read', writeScopes: 'deploy.*' }] },
242
242
  accountActivity: true,
243
243
  idleTimeout: { max: 60 }, // ou true; { min, max } em minutos
@@ -250,7 +250,7 @@ features: {
250
250
 
251
251
  | Recurso | Onde | O que faz | Depende de |
252
252
  | --- | --- | --- | --- |
253
- | `aiAgent` | Botão flutuante no canto inferior direito | Conversa com o agente de IA do app, num painel não modal que pode ser expandido. `http` e `ws` são os endereços do gateway de agentes e `agent` é o id do agente. Sem o recurso, não aparece |
253
+ | `aiAgent` | Botão flutuante no canto inferior direito | Conversa com o agente de IA do app, num painel não modal que pode ser expandido. `http` e `ws` são os endereços do gateway de agentes; o agente ele descobre pelo client do token. Quem conversa pode baixar a transcrição em PDF. Sem o recurso, não aparece |
254
254
  | `mcpConnect` | Sessões › Conectar um agente de IA | Cria um token de aplicação pessoal (`x-app-token`) e mostra, uma única vez, o `.mcp.json` pronto para o Claude Code com os servidores do app; "Leitura e escrita" põe `writeScopes` em `x-mcp-scopes` (sem isso, `readScopes` ou nenhum header: o MCP já é só leitura). URL relativa vira absoluta pela origem da página. O token nunca vai para o console nem para o storage | `SPINE_ADMIN_API` |
255
255
  | `accountActivity` | MFA › Atividade da conta | Entradas e tentativas que falharam desta conta, 90 dias, com IP, navegador, aplicativo, método e motivo; filtro "Só falhas" e "Carregar mais" | API da conta (`GET /activity`) |
256
256
  | `idleTimeout` | Conta › Encerrar por inatividade | Nunca/5/15/30/60 min; aviso com contagem 60 s antes e saída pelo `SpineLogoutService`. Conta a atividade de todas as abas. `{ min, max }` limitam a escolha (com `max`, "Nunca" some) | — |
@@ -13,7 +13,7 @@ import { TextareaAddonDirective, TextareaComponent } from '@trismegisto/angular/
13
13
  import { HermesToastService } from '@trismegisto/angular/toast';
14
14
  import { TraceComponent, TraceItemComponent } from '@trismegisto/angular/trace';
15
15
  import { SPINE_MESSAGES } from '@trismegisto/spine';
16
- import { v as SpineAiAgentService, w as SpineMarkdownComponent } from './trismegisto-spine-layout-trismegisto-spine-layout-B47sMo_4.mjs';
16
+ import { v as SpineAiAgentService, w as SpineMarkdownComponent } from './trismegisto-spine-layout-trismegisto-spine-layout-D3vetDC_.mjs';
17
17
 
18
18
  /** A conversa com o agente do app, dentro do painel flutuante. O estado fica no `SpineAiAgentService`. */
19
19
  class SpineAiAgentChatComponent {
@@ -291,4 +291,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
291
291
  }], propDecorators: { scroller: [{ type: i0.ViewChild, args: [i0.forwardRef(() => MessageScrollerComponent), { isSignal: true }] }] } });
292
292
 
293
293
  export { SpineAiAgentChatComponent };
294
- //# sourceMappingURL=trismegisto-spine-layout-ai-agent-chat.component-C5JljPYD.mjs.map
294
+ //# sourceMappingURL=trismegisto-spine-layout-ai-agent-chat.component-LlxtxNA5.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"trismegisto-spine-layout-ai-agent-chat.component-C5JljPYD.mjs","sources":["../../../projects/ui/layout/src/ai-agent/ai-agent-chat.component.ts"],"sourcesContent":["import { ChangeDetectionStrategy, Component, inject, signal, viewChild } from '@angular/core';\nimport { FormControl, ReactiveFormsModule } from '@angular/forms';\nimport { AuthorizationService } from '@orfeu/angular/core';\nimport { BubbleComponent } from '@trismegisto/angular/bubble';\nimport { ButtonComponent } from '@trismegisto/angular/button';\nimport { IconButtonComponent } from '@trismegisto/angular/icon-button';\nimport { MessageComponent } from '@trismegisto/angular/message';\nimport { MessageScrollerComponent } from '@trismegisto/angular/message-scroller';\nimport { PromptSuggestionsComponent } from '@trismegisto/angular/prompt-suggestions';\nimport { TextareaAddonDirective, TextareaComponent } from '@trismegisto/angular/textarea';\nimport { HermesToastService } from '@trismegisto/angular/toast';\nimport { TraceComponent, TraceItemComponent } from '@trismegisto/angular/trace';\nimport { SPINE_MESSAGES } from '@trismegisto/spine';\nimport { SpineMarkdownComponent } from '../docs/markdown.component';\nimport { SpineAgentEntry, SpineAiAgentService } from './ai-agent.service';\n\n/** A conversa com o agente do app, dentro do painel flutuante. O estado fica no `SpineAiAgentService`. */\n@Component({\n selector: 'spine-ai-agent-chat',\n imports: [\n BubbleComponent, ButtonComponent, IconButtonComponent, MessageComponent, MessageScrollerComponent,\n PromptSuggestionsComponent, ReactiveFormsModule, SpineMarkdownComponent, TextareaAddonDirective, TextareaComponent,\n TraceComponent, TraceItemComponent,\n ],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div class=\"spine-agent\">\n @if (agent.entries().length) {\n <hm-message-scroller class=\"spine-agent__messages\" [label]=\"text.conversation\">\n @for (entry of agent.entries(); track entry.id; let last = $last) {\n <hm-message\n [author]=\"entry.role === 'person' ? text.you : agent.title\"\n [align]=\"entry.role === 'person' ? 'end' : 'start'\"\n [avatarUrl]=\"entry.role === 'person' ? picture : last ? agent.avatarNow() : agent.avatar()\"\n >\n @if (entry.role === 'person') {\n <hm-bubble variant=\"received\"><span class=\"spine-agent__plain\">{{ entry.text }}</span></hm-bubble>\n } @else {\n <div class=\"spine-agent__answer\">\n @if (entry.steps.length) {\n <hm-trace class=\"spine-agent__steps\" [label]=\"text.steps\">\n @for (step of entry.steps; track step.id) {\n <hm-trace-item [heading]=\"step.title\" [status]=\"step.status\" [detail]=\"step.detail ?? ''\" />\n }\n </hm-trace>\n }\n @if (entry.error) {\n <p class=\"spine-agent__error\" role=\"alert\">{{ entry.text }}</p>\n @if (last && !agent.running()) {\n <hm-button size=\"small\" emphasis=\"medium\" icon=\"refresh\" (clicked)=\"agent.retry()\">{{ text.retry }}</hm-button>\n }\n } @else if (entry.text) {\n <hm-bubble class=\"spine-agent__bubble\" variant=\"emphasis\"><spine-markdown [content]=\"entry.text\" /></hm-bubble>\n @if (!(last && agent.running())) {\n <div class=\"spine-agent__actions\">\n <hm-icon-button emphasis=\"subtle\" size=\"small\" icon=\"copy\" [label]=\"text.copy\" (clicked)=\"copy(entry)\" />\n <hm-icon-button\n emphasis=\"subtle\"\n size=\"small\"\n icon=\"thumbs-up\"\n [solid]=\"entry.rating === 'up'\"\n [label]=\"text.rateUp\"\n (clicked)=\"rateUp(entry)\"\n />\n <hm-icon-button\n emphasis=\"subtle\"\n size=\"small\"\n icon=\"thumbs-down\"\n [solid]=\"entry.rating === 'down'\"\n [label]=\"text.rateDown\"\n (clicked)=\"correcting.set(entry.id)\"\n />\n </div>\n @if (correcting() === entry.id) {\n <form class=\"spine-agent__correction\" (submit)=\"$event.preventDefault(); sendCorrection(entry)\">\n <hm-textarea\n [label]=\"text.correctionLabel\"\n [placeholder]=\"text.correctionPlaceholder\"\n [rows]=\"2\"\n [formControl]=\"correction\"\n />\n <div class=\"spine-agent__correction-actions\">\n <hm-button size=\"small\" emphasis=\"minimal\" (clicked)=\"correcting.set(null)\">{{ text.correctionCancel }}</hm-button>\n <hm-button size=\"small\" emphasis=\"medium\" (clicked)=\"sendCorrection(entry)\">{{ text.correctionSend }}</hm-button>\n </div>\n </form>\n }\n }\n } @else if (!entry.steps.length) {\n <hm-bubble [typing]=\"true\" />\n }\n </div>\n }\n </hm-message>\n }\n </hm-message-scroller>\n } @else {\n <div class=\"spine-agent__empty\">\n @if (agent.image() ?? agent.avatar(); as image) {\n <img class=\"spine-agent__portrait\" [src]=\"image\" alt=\"\" (error)=\"agent.imageFailed(image)\" />\n }\n <p class=\"spine-agent__intro\">{{ text.empty }}</p>\n <hm-prompt-suggestions [suggestions]=\"agent.suggestions()\" (chosen)=\"choose($event)\" />\n </div>\n }\n\n <hm-textarea\n class=\"spine-agent__input\"\n [label]=\"text.placeholder\"\n [labelHidden]=\"true\"\n [placeholder]=\"text.placeholder\"\n [rows]=\"2\"\n [maxRows]=\"6\"\n [autoGrow]=\"true\"\n submitKey=\"enter\"\n [formControl]=\"draft\"\n (submitted)=\"send()\"\n >\n <hm-icon-button\n hmTextareaActionsEnd\n class=\"spine-agent__send\"\n emphasis=\"filled\"\n size=\"medium\"\n icon=\"send-arrow\"\n [label]=\"text.send\"\n [disabled]=\"agent.running()\"\n (clicked)=\"send()\"\n />\n </hm-textarea>\n <p class=\"spine-agent__note\">{{ text.note }}</p>\n </div>\n `,\n styles: [`\n :host { display: block; height: 100%; }\n .spine-agent { display: flex; flex-direction: column; gap: var(--hm-space-sm); height: 100%; min-height: 0; }\n .spine-agent__messages { flex: 1; min-height: 0; }\n .spine-agent__empty {\n flex: 1;\n display: flex;\n flex-direction: column;\n justify-content: flex-end;\n gap: var(--hm-space-sm);\n min-height: 0;\n overflow-y: auto;\n }\n .spine-agent__intro { margin: 0; color: var(--hm-muted); }\n .spine-agent__portrait {\n align-self: center;\n /* Em painel baixo, é a imagem que cede: o texto e as sugestões continuam à vista. */\n flex: 0 1 auto;\n min-block-size: 0;\n max-inline-size: 60%;\n max-block-size: calc(var(--hm-space-3xl) * 5);\n object-fit: contain;\n }\n .spine-agent__plain { white-space: pre-wrap; overflow-wrap: anywhere; }\n .spine-agent__answer { display: flex; flex-direction: column; align-items: flex-start; gap: var(--hm-space-xs); min-width: 0; width: 100%; }\n .spine-agent__steps { width: 100%; }\n /* O balão acompanha o texto até a largura toda; a tabela maior que isso rola dentro dele. */\n .spine-agent__bubble { inline-size: 100%; max-inline-size: 100%; }\n .spine-agent__answer spine-markdown { display: block; max-inline-size: 100%; overflow-x: auto; }\n /* O avatar já diz de quem é a mensagem. O nome sai só da tela: leitor de tela continua lendo. */\n :host ::ng-deep .spine-agent__messages .hm-message__header {\n position: absolute;\n inline-size: 1px;\n block-size: 1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n }\n /* A tabela herda o tom do balão: só o título ganha um véu do texto, que funciona nos dois temas. */\n :host ::ng-deep .spine-agent__bubble .spine-md th { background: color-mix(in srgb, var(--hm-text) 8%, transparent); }\n :host ::ng-deep .spine-agent__bubble .spine-md :is(th, td) { border-color: color-mix(in srgb, var(--hm-text) 18%, transparent); }\n .spine-agent__input { --hm-radius-control: calc(var(--hm-radius) * 3); }\n .spine-agent__send { --hm-radius-control: var(--hm-radius-pill); }\n .spine-agent__error { margin: 0; color: var(--hm-danger); }\n .spine-agent__actions { display: flex; gap: var(--hm-space-micro); }\n .spine-agent__correction { display: flex; flex-direction: column; gap: var(--hm-space-xs); inline-size: 100%; }\n .spine-agent__correction-actions { display: flex; justify-content: flex-end; gap: var(--hm-space-xs); }\n .spine-agent__note { margin: 0; color: var(--hm-muted); font-size: var(--hm-font-size-xs); }\n `],\n})\nexport class SpineAiAgentChatComponent {\n protected readonly agent = inject(SpineAiAgentService);\n protected readonly text = inject(SPINE_MESSAGES).aiAgentChat;\n protected readonly draft = new FormControl('', { nonNullable: true });\n protected readonly correction = new FormControl('', { nonNullable: true });\n /** A resposta que a pessoa está corrigindo. */\n protected readonly correcting = signal<number | null>(null);\n protected readonly picture = inject(AuthorizationService).profile?.picture ?? null;\n private readonly scroller = viewChild(MessageScrollerComponent);\n private readonly toast = inject(HermesToastService);\n\n protected send(): void {\n const value = this.draft.value;\n if (!value.trim() || this.agent.running()) return;\n this.draft.setValue('');\n this.ask(value);\n }\n\n protected choose(suggestion: string): void {\n this.ask(suggestion);\n }\n\n protected async copy(entry: SpineAgentEntry): Promise<void> {\n try {\n await navigator.clipboard.writeText(entry.text);\n this.toast.success(this.text.copied);\n } catch {\n this.toast.error(this.text.copyError);\n }\n }\n\n protected async rateUp(entry: SpineAgentEntry): Promise<void> {\n this.correcting.set(null);\n if (!(await this.agent.rate(entry.id, 'up'))) this.toast.error(this.text.rateError);\n }\n\n protected async sendCorrection(entry: SpineAgentEntry): Promise<void> {\n const sent = await this.agent.rate(entry.id, 'down', this.correction.value.trim());\n if (!sent) {\n this.toast.error(this.text.rateError);\n return;\n }\n this.correcting.set(null);\n this.correction.setValue('');\n this.toast.success(this.text.rateThanks);\n }\n\n private ask(text: string): void {\n this.agent.send(text);\n queueMicrotask(() => this.scroller()?.scrollToEnd());\n }\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"trismegisto-spine-layout-ai-agent-chat.component-LlxtxNA5.mjs","sources":["../../../projects/ui/layout/src/ai-agent/ai-agent-chat.component.ts"],"sourcesContent":["import { ChangeDetectionStrategy, Component, inject, signal, viewChild } from '@angular/core';\nimport { FormControl, ReactiveFormsModule } from '@angular/forms';\nimport { AuthorizationService } from '@orfeu/angular/core';\nimport { BubbleComponent } from '@trismegisto/angular/bubble';\nimport { ButtonComponent } from '@trismegisto/angular/button';\nimport { IconButtonComponent } from '@trismegisto/angular/icon-button';\nimport { MessageComponent } from '@trismegisto/angular/message';\nimport { MessageScrollerComponent } from '@trismegisto/angular/message-scroller';\nimport { PromptSuggestionsComponent } from '@trismegisto/angular/prompt-suggestions';\nimport { TextareaAddonDirective, TextareaComponent } from '@trismegisto/angular/textarea';\nimport { HermesToastService } from '@trismegisto/angular/toast';\nimport { TraceComponent, TraceItemComponent } from '@trismegisto/angular/trace';\nimport { SPINE_MESSAGES } from '@trismegisto/spine';\nimport { SpineMarkdownComponent } from '../docs/markdown.component';\nimport { SpineAgentEntry, SpineAiAgentService } from './ai-agent.service';\n\n/** A conversa com o agente do app, dentro do painel flutuante. O estado fica no `SpineAiAgentService`. */\n@Component({\n selector: 'spine-ai-agent-chat',\n imports: [\n BubbleComponent, ButtonComponent, IconButtonComponent, MessageComponent, MessageScrollerComponent,\n PromptSuggestionsComponent, ReactiveFormsModule, SpineMarkdownComponent, TextareaAddonDirective, TextareaComponent,\n TraceComponent, TraceItemComponent,\n ],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div class=\"spine-agent\">\n @if (agent.entries().length) {\n <hm-message-scroller class=\"spine-agent__messages\" [label]=\"text.conversation\">\n @for (entry of agent.entries(); track entry.id; let last = $last) {\n <hm-message\n [author]=\"entry.role === 'person' ? text.you : agent.title\"\n [align]=\"entry.role === 'person' ? 'end' : 'start'\"\n [avatarUrl]=\"entry.role === 'person' ? picture : last ? agent.avatarNow() : agent.avatar()\"\n >\n @if (entry.role === 'person') {\n <hm-bubble variant=\"received\"><span class=\"spine-agent__plain\">{{ entry.text }}</span></hm-bubble>\n } @else {\n <div class=\"spine-agent__answer\">\n @if (entry.steps.length) {\n <hm-trace class=\"spine-agent__steps\" [label]=\"text.steps\">\n @for (step of entry.steps; track step.id) {\n <hm-trace-item [heading]=\"step.title\" [status]=\"step.status\" [detail]=\"step.detail ?? ''\" />\n }\n </hm-trace>\n }\n @if (entry.error) {\n <p class=\"spine-agent__error\" role=\"alert\">{{ entry.text }}</p>\n @if (last && !agent.running()) {\n <hm-button size=\"small\" emphasis=\"medium\" icon=\"refresh\" (clicked)=\"agent.retry()\">{{ text.retry }}</hm-button>\n }\n } @else if (entry.text) {\n <hm-bubble class=\"spine-agent__bubble\" variant=\"emphasis\"><spine-markdown [content]=\"entry.text\" /></hm-bubble>\n @if (!(last && agent.running())) {\n <div class=\"spine-agent__actions\">\n <hm-icon-button emphasis=\"subtle\" size=\"small\" icon=\"copy\" [label]=\"text.copy\" (clicked)=\"copy(entry)\" />\n <hm-icon-button\n emphasis=\"subtle\"\n size=\"small\"\n icon=\"thumbs-up\"\n [solid]=\"entry.rating === 'up'\"\n [label]=\"text.rateUp\"\n (clicked)=\"rateUp(entry)\"\n />\n <hm-icon-button\n emphasis=\"subtle\"\n size=\"small\"\n icon=\"thumbs-down\"\n [solid]=\"entry.rating === 'down'\"\n [label]=\"text.rateDown\"\n (clicked)=\"correcting.set(entry.id)\"\n />\n </div>\n @if (correcting() === entry.id) {\n <form class=\"spine-agent__correction\" (submit)=\"$event.preventDefault(); sendCorrection(entry)\">\n <hm-textarea\n [label]=\"text.correctionLabel\"\n [placeholder]=\"text.correctionPlaceholder\"\n [rows]=\"2\"\n [formControl]=\"correction\"\n />\n <div class=\"spine-agent__correction-actions\">\n <hm-button size=\"small\" emphasis=\"minimal\" (clicked)=\"correcting.set(null)\">{{ text.correctionCancel }}</hm-button>\n <hm-button size=\"small\" emphasis=\"medium\" (clicked)=\"sendCorrection(entry)\">{{ text.correctionSend }}</hm-button>\n </div>\n </form>\n }\n }\n } @else if (!entry.steps.length) {\n <hm-bubble [typing]=\"true\" />\n }\n </div>\n }\n </hm-message>\n }\n </hm-message-scroller>\n } @else {\n <div class=\"spine-agent__empty\">\n @if (agent.image() ?? agent.avatar(); as image) {\n <img class=\"spine-agent__portrait\" [src]=\"image\" alt=\"\" (error)=\"agent.imageFailed(image)\" />\n }\n <p class=\"spine-agent__intro\">{{ text.empty }}</p>\n <hm-prompt-suggestions [suggestions]=\"agent.suggestions()\" (chosen)=\"choose($event)\" />\n </div>\n }\n\n <hm-textarea\n class=\"spine-agent__input\"\n [label]=\"text.placeholder\"\n [labelHidden]=\"true\"\n [placeholder]=\"text.placeholder\"\n [rows]=\"2\"\n [maxRows]=\"6\"\n [autoGrow]=\"true\"\n submitKey=\"enter\"\n [formControl]=\"draft\"\n (submitted)=\"send()\"\n >\n <hm-icon-button\n hmTextareaActionsEnd\n class=\"spine-agent__send\"\n emphasis=\"filled\"\n size=\"medium\"\n icon=\"send-arrow\"\n [label]=\"text.send\"\n [disabled]=\"agent.running()\"\n (clicked)=\"send()\"\n />\n </hm-textarea>\n <p class=\"spine-agent__note\">{{ text.note }}</p>\n </div>\n `,\n styles: [`\n :host { display: block; height: 100%; }\n .spine-agent { display: flex; flex-direction: column; gap: var(--hm-space-sm); height: 100%; min-height: 0; }\n .spine-agent__messages { flex: 1; min-height: 0; }\n .spine-agent__empty {\n flex: 1;\n display: flex;\n flex-direction: column;\n justify-content: flex-end;\n gap: var(--hm-space-sm);\n min-height: 0;\n overflow-y: auto;\n }\n .spine-agent__intro { margin: 0; color: var(--hm-muted); }\n .spine-agent__portrait {\n align-self: center;\n /* Em painel baixo, é a imagem que cede: o texto e as sugestões continuam à vista. */\n flex: 0 1 auto;\n min-block-size: 0;\n max-inline-size: 60%;\n max-block-size: calc(var(--hm-space-3xl) * 5);\n object-fit: contain;\n }\n .spine-agent__plain { white-space: pre-wrap; overflow-wrap: anywhere; }\n .spine-agent__answer { display: flex; flex-direction: column; align-items: flex-start; gap: var(--hm-space-xs); min-width: 0; width: 100%; }\n .spine-agent__steps { width: 100%; }\n /* O balão acompanha o texto até a largura toda; a tabela maior que isso rola dentro dele. */\n .spine-agent__bubble { inline-size: 100%; max-inline-size: 100%; }\n .spine-agent__answer spine-markdown { display: block; max-inline-size: 100%; overflow-x: auto; }\n /* O avatar já diz de quem é a mensagem. O nome sai só da tela: leitor de tela continua lendo. */\n :host ::ng-deep .spine-agent__messages .hm-message__header {\n position: absolute;\n inline-size: 1px;\n block-size: 1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n }\n /* A tabela herda o tom do balão: só o título ganha um véu do texto, que funciona nos dois temas. */\n :host ::ng-deep .spine-agent__bubble .spine-md th { background: color-mix(in srgb, var(--hm-text) 8%, transparent); }\n :host ::ng-deep .spine-agent__bubble .spine-md :is(th, td) { border-color: color-mix(in srgb, var(--hm-text) 18%, transparent); }\n .spine-agent__input { --hm-radius-control: calc(var(--hm-radius) * 3); }\n .spine-agent__send { --hm-radius-control: var(--hm-radius-pill); }\n .spine-agent__error { margin: 0; color: var(--hm-danger); }\n .spine-agent__actions { display: flex; gap: var(--hm-space-micro); }\n .spine-agent__correction { display: flex; flex-direction: column; gap: var(--hm-space-xs); inline-size: 100%; }\n .spine-agent__correction-actions { display: flex; justify-content: flex-end; gap: var(--hm-space-xs); }\n .spine-agent__note { margin: 0; color: var(--hm-muted); font-size: var(--hm-font-size-xs); }\n `],\n})\nexport class SpineAiAgentChatComponent {\n protected readonly agent = inject(SpineAiAgentService);\n protected readonly text = inject(SPINE_MESSAGES).aiAgentChat;\n protected readonly draft = new FormControl('', { nonNullable: true });\n protected readonly correction = new FormControl('', { nonNullable: true });\n /** A resposta que a pessoa está corrigindo. */\n protected readonly correcting = signal<number | null>(null);\n protected readonly picture = inject(AuthorizationService).profile?.picture ?? null;\n private readonly scroller = viewChild(MessageScrollerComponent);\n private readonly toast = inject(HermesToastService);\n\n protected send(): void {\n const value = this.draft.value;\n if (!value.trim() || this.agent.running()) return;\n this.draft.setValue('');\n this.ask(value);\n }\n\n protected choose(suggestion: string): void {\n this.ask(suggestion);\n }\n\n protected async copy(entry: SpineAgentEntry): Promise<void> {\n try {\n await navigator.clipboard.writeText(entry.text);\n this.toast.success(this.text.copied);\n } catch {\n this.toast.error(this.text.copyError);\n }\n }\n\n protected async rateUp(entry: SpineAgentEntry): Promise<void> {\n this.correcting.set(null);\n if (!(await this.agent.rate(entry.id, 'up'))) this.toast.error(this.text.rateError);\n }\n\n protected async sendCorrection(entry: SpineAgentEntry): Promise<void> {\n const sent = await this.agent.rate(entry.id, 'down', this.correction.value.trim());\n if (!sent) {\n this.toast.error(this.text.rateError);\n return;\n }\n this.correcting.set(null);\n this.correction.setValue('');\n this.toast.success(this.text.rateThanks);\n }\n\n private ask(text: string): void {\n this.agent.send(text);\n queueMicrotask(() => this.scroller()?.scrollToEnd());\n }\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,438 @@
1
+ import { Marked } from 'marked';
2
+
3
+ const PAGE_WIDTH = 595;
4
+ const PAGE_HEIGHT = 842;
5
+ const MARGIN = 48;
6
+ const CONTENT_WIDTH = PAGE_WIDTH - 2 * MARGIN;
7
+ const BOTTOM = PAGE_HEIGHT - MARGIN - 14;
8
+ const BODY_SIZE = 10;
9
+ const BODY_LEADING = 14;
10
+ const TABLE_SIZE = 8.5;
11
+ const TABLE_LEADING = 11;
12
+ const TABLE_PADDING = 4;
13
+ const INK = 0.13;
14
+ const MUTED = 0.42;
15
+ const RULE = 0.8;
16
+ const SHADE = 0.95;
17
+ // Larguras das fontes padrão (AFM da Adobe), de 32 a 126, em milésimos do corpo.
18
+ const REGULAR = [
19
+ 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556,
20
+ 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
21
+ 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500, 556, 556, 278, 556,
22
+ 556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
23
+ ];
24
+ const BOLD = [
25
+ 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556,
26
+ 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
27
+ 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556, 611, 556, 333, 611,
28
+ 611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
29
+ ];
30
+ const COURIER_WIDTH = 600;
31
+ // O trecho de 0x80 a 0x9F do WinAnsi; o resto até 0xFF coincide com o Unicode.
32
+ const WIN_ANSI = {
33
+ '€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, 'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a, '‹': 0x8b,
34
+ 'Œ': 0x8c, 'Ž': 0x8e, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97, '˜': 0x98, '™': 0x99,
35
+ 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f,
36
+ };
37
+ const REPLACEMENTS = {
38
+ '→': '->', '←': '<-', '⇒': '=>', '≥': '>=', '≤': '<=', '≠': '!=', '✓': 'OK', '✔': 'OK', '✅': 'OK', '✗': 'X', '✘': 'X', '❌': 'X', '⚠': '!',
39
+ '\u00a0': ' ', '\u2009': ' ', '\u202f': ' ', '\u200b': '',
40
+ };
41
+ const WIDE_PUNCTUATION = { '—': 1000, '…': 1000, '•': 350, '‰': 1000, '™': 1000, '‘': 222, '’': 222, '“': 333, '”': 333 };
42
+ const ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'" };
43
+ /** Só o que as fontes padrão desenham: troca o que tem equivalente, tira emoji e marca o resto com `?`. */
44
+ function printable(raw) {
45
+ let result = '';
46
+ for (const char of raw.normalize('NFC')) {
47
+ const code = char.codePointAt(0);
48
+ if (REPLACEMENTS[char] !== undefined)
49
+ result += REPLACEMENTS[char];
50
+ else if (char === '\t')
51
+ result += ' ';
52
+ else if (code < 32 || code === 127)
53
+ continue;
54
+ else if (code <= 255 && !(code >= 0x80 && code <= 0x9f))
55
+ result += char;
56
+ else if (WIN_ANSI[char])
57
+ result += char;
58
+ // Modificadores e seletores de variação de emoji somem junto com ele.
59
+ else if (code >= 0x1f000 || (code >= 0x2600 && code <= 0x27bf) || code === 0xfe0f || code === 0x200d)
60
+ continue;
61
+ else
62
+ result += '?';
63
+ }
64
+ return result;
65
+ }
66
+ function charWidth(char, font) {
67
+ if (font === 'F3')
68
+ return COURIER_WIDTH;
69
+ const table = font === 'F2' ? BOLD : REGULAR;
70
+ const direct = table[char.charCodeAt(0) - 32];
71
+ if (direct !== undefined)
72
+ return direct;
73
+ if (WIDE_PUNCTUATION[char])
74
+ return WIDE_PUNCTUATION[char];
75
+ // Acentuada: a largura é a da letra base.
76
+ return table[char.normalize('NFD').charCodeAt(0) - 32] ?? 556;
77
+ }
78
+ function textWidth(text, font, size) {
79
+ let total = 0;
80
+ for (const char of text)
81
+ total += charWidth(char, font);
82
+ return (total * size) / 1000;
83
+ }
84
+ /** Quebra em linhas no espaço disponível. Palavra maior que a linha é cortada, para nunca passar da borda. */
85
+ function wrap(runs, width, size) {
86
+ const lines = [];
87
+ let line = [];
88
+ let used = 0;
89
+ const flush = () => {
90
+ lines.push(line);
91
+ line = [];
92
+ used = 0;
93
+ };
94
+ const push = (text, font) => {
95
+ const last = line[line.length - 1];
96
+ if (last && last.font === font)
97
+ last.text += text;
98
+ else
99
+ line.push({ text, font });
100
+ used += textWidth(text, font, size);
101
+ };
102
+ for (const run of runs) {
103
+ for (const piece of run.text.split(/(\n|\s+)/)) {
104
+ if (!piece)
105
+ continue;
106
+ if (piece === '\n') {
107
+ flush();
108
+ }
109
+ else if (!piece.trim()) {
110
+ if (used > 0)
111
+ push(' ', run.font);
112
+ }
113
+ else {
114
+ let word = piece;
115
+ if (used + textWidth(word, run.font, size) > width && used > 0)
116
+ flush();
117
+ while (textWidth(word, run.font, size) > width) {
118
+ let end = 1;
119
+ while (end < word.length && textWidth(word.slice(0, end + 1), run.font, size) <= width)
120
+ end += 1;
121
+ push(word.slice(0, end), run.font);
122
+ flush();
123
+ word = word.slice(end);
124
+ }
125
+ push(word, run.font);
126
+ }
127
+ }
128
+ }
129
+ flush();
130
+ return lines.map((current) => {
131
+ // O espaço que sobrou no fim da linha não conta.
132
+ const last = current[current.length - 1];
133
+ if (last)
134
+ last.text = last.text.replace(/ +$/, '');
135
+ return current.filter((run) => run.text);
136
+ });
137
+ }
138
+ function escapePdfText(text) {
139
+ let result = '';
140
+ for (const char of text) {
141
+ const code = WIN_ANSI[char] ?? char.charCodeAt(0);
142
+ if (char === '(' || char === ')' || char === '\\')
143
+ result += `\\${char}`;
144
+ else if (code < 32 || code > 126)
145
+ result += `\\${code.toString(8).padStart(3, '0')}`;
146
+ else
147
+ result += char;
148
+ }
149
+ return result;
150
+ }
151
+ function inlineRuns(tokens, font = 'F1') {
152
+ const runs = [];
153
+ for (const token of tokens ?? []) {
154
+ const item = token;
155
+ switch (token.type) {
156
+ case 'strong':
157
+ runs.push(...inlineRuns(item.tokens, 'F2'));
158
+ break;
159
+ case 'em':
160
+ case 'del':
161
+ runs.push(...inlineRuns(item.tokens, font));
162
+ break;
163
+ case 'codespan':
164
+ runs.push({ text: printable(decodeEntities(item['text'])), font: 'F3' });
165
+ break;
166
+ case 'br':
167
+ runs.push({ text: '\n', font });
168
+ break;
169
+ case 'link': {
170
+ const label = inlineRuns(item.tokens, font);
171
+ runs.push(...label);
172
+ if (label.map((run) => run.text).join('') !== item['href'])
173
+ runs.push({ text: ` (${printable(item['href'])})`, font });
174
+ break;
175
+ }
176
+ case 'image':
177
+ runs.push({ text: printable(item['text'] || item['href']), font });
178
+ break;
179
+ default:
180
+ if (item.tokens?.length)
181
+ runs.push(...inlineRuns(item.tokens, font));
182
+ else
183
+ runs.push({ text: printable(decodeEntities(String(item['text'] ?? item.raw ?? ''))), font });
184
+ }
185
+ }
186
+ return runs;
187
+ }
188
+ function decodeEntities(text) {
189
+ return text.replace(/&(amp|lt|gt|quot|#39);/g, (entity) => ENTITIES[entity] ?? entity);
190
+ }
191
+ class PdfDocument {
192
+ constructor() {
193
+ this.pages = [[]];
194
+ this.y = MARGIN;
195
+ }
196
+ get page() {
197
+ return this.pages[this.pages.length - 1];
198
+ }
199
+ newPage() {
200
+ this.pages.push([]);
201
+ this.y = MARGIN;
202
+ }
203
+ /** Garante espaço para o que vem a seguir, ou passa para a próxima página. */
204
+ reserve(height) {
205
+ if (this.y + height > BOTTOM)
206
+ this.newPage();
207
+ }
208
+ rect(x, top, width, height, gray) {
209
+ this.page.push(`${gray} g ${x.toFixed(2)} ${(PAGE_HEIGHT - top - height).toFixed(2)} ${width.toFixed(2)} ${height.toFixed(2)} re f`);
210
+ }
211
+ rule(x1, top1, x2, top2) {
212
+ this.page.push(`${RULE} G 0.5 w ${x1.toFixed(2)} ${(PAGE_HEIGHT - top1).toFixed(2)} m ${x2.toFixed(2)} ${(PAGE_HEIGHT - top2).toFixed(2)} l S`);
213
+ }
214
+ /** `baseline` conta do alto da página. */
215
+ write(line, x, baseline, size, gray = INK) {
216
+ let cursor = x;
217
+ for (const run of line) {
218
+ this.page.push(`BT /${run.font} ${size} Tf ${gray} g 1 0 0 1 ${cursor.toFixed(2)} ${(PAGE_HEIGHT - baseline).toFixed(2)} Tm (${escapePdfText(run.text)}) Tj ET`);
219
+ cursor += textWidth(run.text, run.font, size);
220
+ }
221
+ }
222
+ paragraph(runs, x, options = {}) {
223
+ const size = options.size ?? BODY_SIZE;
224
+ const leading = options.leading ?? BODY_LEADING;
225
+ const width = PAGE_WIDTH - MARGIN - x;
226
+ for (const line of wrap(runs, width - (options.shade ? 2 * TABLE_PADDING : 0), size)) {
227
+ this.reserve(leading);
228
+ if (options.shade)
229
+ this.rect(MARGIN, this.y, CONTENT_WIDTH, leading, SHADE);
230
+ this.write(line, options.shade ? x + TABLE_PADDING : x, this.y + leading - 3.5, size, options.gray);
231
+ this.y += leading;
232
+ }
233
+ }
234
+ blocks(tokens, x) {
235
+ for (const token of tokens) {
236
+ const item = token;
237
+ switch (token.type) {
238
+ case 'space':
239
+ break;
240
+ case 'heading': {
241
+ const size = item['depth'] <= 2 ? 12 : 11;
242
+ this.reserve(size + BODY_LEADING * 2);
243
+ this.y += 4;
244
+ this.paragraph(inlineRuns(item.tokens, 'F2'), x, { size, leading: size + 4 });
245
+ this.y += 2;
246
+ break;
247
+ }
248
+ case 'paragraph':
249
+ case 'text':
250
+ this.paragraph(item.tokens?.length ? inlineRuns(item.tokens) : [{ text: printable(decodeEntities(String(item['text'] ?? ''))), font: 'F1' }], x);
251
+ this.y += token.type === 'paragraph' ? 5 : 0;
252
+ break;
253
+ case 'list':
254
+ this.list(token, x);
255
+ this.y += 5;
256
+ break;
257
+ case 'code':
258
+ this.code(String(item['text'] ?? ''), x);
259
+ break;
260
+ case 'table':
261
+ this.table(token);
262
+ break;
263
+ case 'blockquote':
264
+ this.blocks(item.tokens ?? [], x + 12);
265
+ break;
266
+ case 'hr':
267
+ this.reserve(10);
268
+ this.rule(x, this.y + 4, PAGE_WIDTH - MARGIN, this.y + 4);
269
+ this.y += 10;
270
+ break;
271
+ default:
272
+ this.paragraph([{ text: printable(String(item.raw ?? '')), font: 'F1' }], x);
273
+ }
274
+ }
275
+ }
276
+ list(list, x) {
277
+ list.items.forEach((item, index) => {
278
+ const marker = list.ordered ? `${Number(list.start || 1) + index}.` : '•';
279
+ this.reserve(BODY_LEADING);
280
+ this.write([{ text: marker, font: 'F1' }], x + 2, this.y + BODY_LEADING - 3.5, BODY_SIZE);
281
+ this.blocks(item.tokens, x + 16);
282
+ });
283
+ }
284
+ code(text, x) {
285
+ const size = 8.5;
286
+ this.y += 2;
287
+ for (const source of text.split('\n')) {
288
+ for (const line of wrap([{ text: printable(source) || ' ', font: 'F3' }], PAGE_WIDTH - MARGIN - x - 2 * TABLE_PADDING, size)) {
289
+ this.reserve(TABLE_LEADING);
290
+ this.rect(x, this.y, PAGE_WIDTH - MARGIN - x, TABLE_LEADING, SHADE);
291
+ this.write(line, x + TABLE_PADDING, this.y + TABLE_LEADING - 2.5, size);
292
+ this.y += TABLE_LEADING;
293
+ }
294
+ }
295
+ this.y += 6;
296
+ }
297
+ table(table) {
298
+ const header = table.header.map((cell) => inlineRuns(cell.tokens, 'F2'));
299
+ const rows = table.rows.map((row) => row.map((cell) => inlineRuns(cell.tokens)));
300
+ const columns = Math.max(header.length, ...rows.map((row) => row.length));
301
+ const widths = this.columnWidths([header, ...rows], columns);
302
+ const inner = (column) => Math.max(widths[column] - 2 * TABLE_PADDING, 8);
303
+ const wrapRow = (row) => widths.map((_, column) => wrap(row[column] ?? [], inner(column), TABLE_SIZE));
304
+ const headerLines = wrapRow(header);
305
+ const drawRow = (cells, from, count, shaded) => {
306
+ const height = count * TABLE_LEADING + 2 * TABLE_PADDING - 2;
307
+ if (shaded)
308
+ this.rect(MARGIN, this.y, CONTENT_WIDTH, height, SHADE);
309
+ let x = MARGIN;
310
+ cells.forEach((lines, column) => {
311
+ lines.slice(from, from + count).forEach((line, index) => {
312
+ this.write(line, x + TABLE_PADDING, this.y + TABLE_PADDING + (index + 1) * TABLE_LEADING - 4, TABLE_SIZE);
313
+ });
314
+ x += widths[column];
315
+ });
316
+ this.y += height;
317
+ this.rule(MARGIN, this.y, PAGE_WIDTH - MARGIN, this.y);
318
+ };
319
+ const lineCount = (cells) => Math.max(1, ...cells.map((lines) => lines.length));
320
+ const drawHeader = () => drawRow(headerLines, 0, lineCount(headerLines), true);
321
+ const headerHeight = lineCount(headerLines) * TABLE_LEADING + 2 * TABLE_PADDING;
322
+ this.y += 2;
323
+ this.reserve(headerHeight + 2 * TABLE_LEADING + 2 * TABLE_PADDING);
324
+ drawHeader();
325
+ for (const row of rows) {
326
+ const cells = wrapRow(row);
327
+ const total = lineCount(cells);
328
+ let done = 0;
329
+ while (done < total) {
330
+ const fits = Math.floor((BOTTOM - this.y - 2 * TABLE_PADDING) / TABLE_LEADING);
331
+ // Linha que cabe inteira na página seguinte não é partida; só a que é maior que uma página.
332
+ if (fits < 1 || (done === 0 && fits < total && total * TABLE_LEADING < BOTTOM - MARGIN - headerHeight * 2)) {
333
+ this.newPage();
334
+ drawHeader();
335
+ continue;
336
+ }
337
+ const count = Math.min(fits, total - done);
338
+ drawRow(cells, done, count, false);
339
+ done += count;
340
+ }
341
+ }
342
+ this.y += 8;
343
+ }
344
+ /** Proporcional ao conteúdo, com um mínimo por coluna, para coluna curta não roubar nem sobrar demais. */
345
+ columnWidths(rows, columns) {
346
+ const natural = Array.from({ length: columns }, (_, column) => Math.max(24, ...rows.map((row) => (row[column] ?? []).reduce((sum, run) => sum + textWidth(run.text, run.font, TABLE_SIZE), 0) + 2 * TABLE_PADDING)));
347
+ const total = natural.reduce((sum, width) => sum + width, 0);
348
+ if (total <= CONTENT_WIDTH) {
349
+ const spare = (CONTENT_WIDTH - total) / columns;
350
+ return natural.map((width) => width + spare);
351
+ }
352
+ const floor = Math.min(36, CONTENT_WIDTH / columns);
353
+ const capped = natural.map((width) => Math.max(floor, (width / total) * CONTENT_WIDTH));
354
+ const scale = CONTENT_WIDTH / capped.reduce((sum, width) => sum + width, 0);
355
+ return capped.map((width) => width * scale);
356
+ }
357
+ serialize(title, pageLabel) {
358
+ const total = this.pages.length;
359
+ this.pages.forEach((content, index) => {
360
+ const text = printable(`${pageLabel} ${index + 1} / ${total}`);
361
+ const x = PAGE_WIDTH - MARGIN - textWidth(text, 'F1', 8);
362
+ content.push(`BT /F1 8 Tf ${MUTED} g 1 0 0 1 ${x.toFixed(2)} 28.00 Tm (${escapePdfText(text)}) Tj ET`);
363
+ });
364
+ // 1 catálogo, 2 árvore, 3 a 5 fontes, 6 metadados; depois, página e conteúdo de cada uma.
365
+ const firstPage = 7;
366
+ const objects = [
367
+ '<< /Type /Catalog /Pages 2 0 R >>',
368
+ `<< /Type /Pages /Count ${total} /Kids [${this.pages.map((_, index) => `${firstPage + index * 2} 0 R`).join(' ')}] >>`,
369
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>',
370
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>',
371
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>',
372
+ `<< /Title (${escapePdfText(printable(title))}) >>`,
373
+ ];
374
+ this.pages.forEach((content, index) => {
375
+ const stream = content.join('\n');
376
+ objects.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}] /Resources << /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R >> >> /Contents ${firstPage + index * 2 + 1} 0 R >>`, `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
377
+ });
378
+ // O texto já saiu escapado: o arquivo é todo ASCII, então a posição em caracteres é a posição em bytes.
379
+ let body = '%PDF-1.4\n';
380
+ const offsets = [];
381
+ objects.forEach((object, index) => {
382
+ offsets.push(body.length);
383
+ body += `${index + 1} 0 obj\n${object}\nendobj\n`;
384
+ });
385
+ const xref = body.length;
386
+ const count = objects.length + 1;
387
+ body += `xref\n0 ${count}\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`).join('')}`;
388
+ body += `trailer\n<< /Size ${count} /Root 1 0 R /Info 6 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
389
+ return new TextEncoder().encode(body);
390
+ }
391
+ }
392
+ function buildTranscriptPdf(transcript) {
393
+ const parser = new Marked({ gfm: true, breaks: false });
394
+ const pdf = new PdfDocument();
395
+ pdf.paragraph([{ text: printable(transcript.title), font: 'F2' }], MARGIN, { size: 16, leading: 22 });
396
+ pdf.paragraph([{ text: printable(transcript.subtitle), font: 'F1' }], MARGIN, { size: 9, leading: 13, gray: MUTED });
397
+ pdf.y += 6;
398
+ pdf.rule(MARGIN, pdf.y, PAGE_WIDTH - MARGIN, pdf.y);
399
+ pdf.y += 10;
400
+ for (const entry of transcript.entries) {
401
+ const label = entry.role === 'person' ? transcript.personLabel : transcript.agentLabel;
402
+ // O rótulo não fica sozinho no pé da página.
403
+ pdf.reserve(BODY_LEADING * 3);
404
+ pdf.paragraph([{ text: printable(label), font: 'F2' }], MARGIN, { size: 9, leading: 13, gray: MUTED });
405
+ if (entry.role === 'person') {
406
+ // A pergunta é o que a pessoa escreveu, sem interpretar como Markdown.
407
+ pdf.paragraph([{ text: entry.text.split(/\r?\n/).map(printable).join('\n'), font: 'F1' }], MARGIN, { shade: true });
408
+ pdf.y += 8;
409
+ }
410
+ else {
411
+ if (entry.error)
412
+ pdf.paragraph([{ text: printable(entry.text), font: 'F1' }], MARGIN, { gray: MUTED });
413
+ else
414
+ pdf.blocks(parser.lexer(entry.text), MARGIN);
415
+ if (entry.steps.length) {
416
+ pdf.y += 2;
417
+ pdf.reserve(13 * 2);
418
+ pdf.paragraph([{ text: printable(transcript.queriesLabel), font: 'F2' }], MARGIN, { size: 8.5, leading: 12, gray: MUTED });
419
+ for (const step of entry.steps) {
420
+ const failed = step.failed ? ` (${transcript.failedLabel})` : '';
421
+ const detail = step.detail ? `: ${step.detail}` : '';
422
+ pdf.paragraph([{ text: printable(`• ${step.title}${failed}${detail}`), font: 'F1' }], MARGIN, { size: 8.5, leading: 12, gray: MUTED });
423
+ }
424
+ }
425
+ pdf.y += 12;
426
+ }
427
+ }
428
+ return pdf.serialize(transcript.title, transcript.pageLabel);
429
+ }
430
+ /** `dudu-conversa-2026-11-05.pdf`: sem acento nem espaço, para qualquer sistema aceitar. */
431
+ function transcriptFileName(title, word, now) {
432
+ const slug = (value) => value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
433
+ const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
434
+ return `${[slug(title), slug(word)].filter(Boolean).join('-')}-${day}.pdf`;
435
+ }
436
+
437
+ export { buildTranscriptPdf, transcriptFileName };
438
+ //# sourceMappingURL=trismegisto-spine-layout-ai-agent-transcript-BNcgbrV_.mjs.map