@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 +2 -2
- package/fesm2022/{trismegisto-spine-layout-ai-agent-chat.component-C5JljPYD.mjs → trismegisto-spine-layout-ai-agent-chat.component-LlxtxNA5.mjs} +2 -2
- package/fesm2022/{trismegisto-spine-layout-ai-agent-chat.component-C5JljPYD.mjs.map → trismegisto-spine-layout-ai-agent-chat.component-LlxtxNA5.mjs.map} +1 -1
- package/fesm2022/trismegisto-spine-layout-ai-agent-transcript-BNcgbrV_.mjs +438 -0
- package/fesm2022/trismegisto-spine-layout-ai-agent-transcript-BNcgbrV_.mjs.map +1 -0
- package/fesm2022/{trismegisto-spine-layout-suggestions-page.component-Bh3VGlOR.mjs → trismegisto-spine-layout-suggestions-page.component-BEGFNlvw.mjs} +2 -2
- package/fesm2022/{trismegisto-spine-layout-suggestions-page.component-Bh3VGlOR.mjs.map → trismegisto-spine-layout-suggestions-page.component-BEGFNlvw.mjs.map} +1 -1
- package/fesm2022/{trismegisto-spine-layout-trismegisto-spine-layout-B47sMo_4.mjs → trismegisto-spine-layout-trismegisto-spine-layout-D3vetDC_.mjs} +88 -25
- package/fesm2022/trismegisto-spine-layout-trismegisto-spine-layout-D3vetDC_.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-layout.mjs +1 -1
- package/fesm2022/trismegisto-spine.mjs +18 -0
- package/fesm2022/trismegisto-spine.mjs.map +1 -1
- package/package.json +6 -6
- package/types/trismegisto-spine.d.ts +8 -2
- package/types/trismegisto-spine.d.ts.map +1 -1
- package/fesm2022/trismegisto-spine-layout-trismegisto-spine-layout-B47sMo_4.mjs.map +0 -1
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: {
|
|
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
|
|
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-
|
|
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-
|
|
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":";;;;;;;;;;;;;;;;;AAgBA;MAsKa,yBAAyB,CAAA;AArKtC,IAAA,WAAA,GAAA;AAsKqB,QAAA,IAAA,CAAA,KAAK,GAAG,MAAM,CAAC,mBAAmB,CAAC;AACnC,QAAA,IAAA,CAAA,IAAI,GAAG,MAAM,CAAC,cAAc,CAAC,CAAC,WAAW;AACzC,QAAA,IAAA,CAAA,KAAK,GAAG,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;AAClD,QAAA,IAAA,CAAA,UAAU,GAAG,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;;AAEvD,QAAA,IAAA,CAAA,UAAU,GAAG,MAAM,CAAgB,IAAI,iFAAC;QACxC,IAAA,CAAA,OAAO,GAAG,MAAM,CAAC,oBAAoB,CAAC,CAAC,OAAO,EAAE,OAAO,IAAI,IAAI;AACjE,QAAA,IAAA,CAAA,QAAQ,GAAG,SAAS,CAAC,wBAAwB,+EAAC;AAC9C,QAAA,IAAA,CAAA,KAAK,GAAG,MAAM,CAAC,kBAAkB,CAAC;AA0CpD,IAAA;IAxCW,IAAI,GAAA;AACZ,QAAA,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK;QAC9B,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE;YAAE;AAC3C,QAAA,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC;AACvB,QAAA,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC;IACjB;AAEU,IAAA,MAAM,CAAC,UAAkB,EAAA;AACjC,QAAA,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC;IACtB;IAEU,MAAM,IAAI,CAAC,KAAsB,EAAA;AACzC,QAAA,IAAI;YACF,MAAM,SAAS,CAAC,SAAS,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC;YAC/C,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC;QACtC;AAAE,QAAA,MAAM;YACN,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC;QACvC;IACF;IAEU,MAAM,MAAM,CAAC,KAAsB,EAAA;AAC3C,QAAA,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC;AACzB,QAAA,IAAI,EAAE,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;YAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC;IACrF;IAEU,MAAM,cAAc,CAAC,KAAsB,EAAA;QACnD,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QAClF,IAAI,CAAC,IAAI,EAAE;YACT,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC;YACrC;QACF;AACA,QAAA,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC;AACzB,QAAA,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC5B,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC;IAC1C;AAEQ,IAAA,GAAG,CAAC,IAAY,EAAA;AACtB,QAAA,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC;AACrB,QAAA,cAAc,CAAC,MAAM,IAAI,CAAC,QAAQ,EAAE,EAAE,WAAW,EAAE,CAAC;IACtD;+GAlDW,yBAAyB,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,SAAA,EAAA,CAAA,CAAA;mGAAzB,yBAAyB,EAAA,YAAA,EAAA,IAAA,EAAA,QAAA,EAAA,qBAAA,EAAA,WAAA,EAAA,CAAA,EAAA,YAAA,EAAA,UAAA,EAAA,KAAA,EAAA,IAAA,EAAA,SAAA,EAQE,wBAAwB,EAAA,WAAA,EAAA,IAAA,EAAA,QAAA,EAAA,IAAA,EAAA,CAAA,EAAA,QAAA,EAAA,EAAA,EAAA,QAAA,EArKpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0GT,EAAA,QAAA,EAAA,IAAA,EAAA,MAAA,EAAA,CAAA,0wDAAA,CAAA,EAAA,YAAA,EAAA,CAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EA/GC,eAAe,+HAAE,eAAe,EAAA,QAAA,EAAA,WAAA,EAAA,MAAA,EAAA,CAAA,UAAA,EAAA,QAAA,EAAA,MAAA,EAAA,MAAA,EAAA,MAAA,EAAA,eAAA,EAAA,SAAA,EAAA,UAAA,EAAA,SAAA,EAAA,cAAA,EAAA,OAAA,EAAA,MAAA,EAAA,QAAA,EAAA,OAAA,EAAA,QAAA,EAAA,QAAA,EAAA,SAAA,CAAA,EAAA,OAAA,EAAA,CAAA,oBAAA,EAAA,eAAA,EAAA,SAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAAE,mBAAmB,EAAA,QAAA,EAAA,gBAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,MAAA,EAAA,OAAA,EAAA,eAAA,EAAA,UAAA,EAAA,QAAA,EAAA,MAAA,EAAA,UAAA,EAAA,QAAA,EAAA,SAAA,EAAA,cAAA,CAAA,EAAA,OAAA,EAAA,CAAA,eAAA,EAAA,SAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAAE,gBAAgB,EAAA,QAAA,EAAA,YAAA,EAAA,MAAA,EAAA,CAAA,QAAA,EAAA,WAAA,EAAA,MAAA,EAAA,YAAA,EAAA,OAAA,EAAA,SAAA,EAAA,YAAA,EAAA,QAAA,EAAA,UAAA,EAAA,WAAA,EAAA,iBAAA,EAAA,OAAA,CAAA,EAAA,OAAA,EAAA,CAAA,OAAA,EAAA,gBAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAAE,wBAAwB,EAAA,QAAA,EAAA,qBAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,gBAAA,EAAA,gBAAA,EAAA,iBAAA,EAAA,MAAA,EAAA,cAAA,EAAA,WAAA,CAAA,EAAA,OAAA,EAAA,CAAA,aAAA,EAAA,aAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EACjG,0BAA0B,oIAAE,mBAAmB,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAAA,EAAA,CAAA,aAAA,EAAA,QAAA,EAAA,8CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAAA,EAAA,CAAA,eAAA,EAAA,QAAA,EAAA,2CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAAA,EAAA,CAAA,oBAAA,EAAA,QAAA,EAAA,sGAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAAA,EAAA,CAAA,oBAAA,EAAA,QAAA,EAAA,eAAA,EAAA,MAAA,EAAA,CAAA,aAAA,EAAA,UAAA,EAAA,SAAA,CAAA,EAAA,OAAA,EAAA,CAAA,eAAA,CAAA,EAAA,QAAA,EAAA,CAAA,QAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAAE,sBAAsB,EAAA,QAAA,EAAA,gBAAA,EAAA,MAAA,EAAA,CAAA,SAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAAE,sBAAsB,gIAAE,iBAAiB,EAAA,QAAA,EAAA,aAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,OAAA,EAAA,MAAA,EAAA,aAAA,EAAA,WAAA,EAAA,MAAA,EAAA,UAAA,EAAA,aAAA,EAAA,SAAA,EAAA,SAAA,EAAA,WAAA,EAAA,mBAAA,EAAA,gBAAA,EAAA,OAAA,CAAA,EAAA,OAAA,EAAA,CAAA,aAAA,EAAA,WAAA,EAAA,WAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAClH,cAAc,EAAA,QAAA,EAAA,UAAA,EAAA,MAAA,EAAA,CAAA,OAAA,CAAA,EAAA,EAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EAAE,kBAAkB,EAAA,QAAA,EAAA,eAAA,EAAA,MAAA,EAAA,CAAA,SAAA,EAAA,QAAA,EAAA,QAAA,EAAA,YAAA,EAAA,OAAA,CAAA,EAAA,CAAA,EAAA,eAAA,EAAA,EAAA,CAAA,uBAAA,CAAA,MAAA,EAAA,CAAA,CAAA;;4FAgKzB,yBAAyB,EAAA,UAAA,EAAA,CAAA;kBArKrC,SAAS;AACE,YAAA,IAAA,EAAA,CAAA,EAAA,QAAA,EAAA,qBAAqB,EAAA,OAAA,EACtB;AACP,wBAAA,eAAe,EAAE,eAAe,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,wBAAwB;AACjG,wBAAA,0BAA0B,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,sBAAsB,EAAE,iBAAiB;AAClH,wBAAA,cAAc,EAAE,kBAAkB;qBACnC,EAAA,eAAA,EACgB,uBAAuB,CAAC,MAAM,EAAA,QAAA,EACrC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0GT,EAAA,CAAA,EAAA,MAAA,EAAA,CAAA,0wDAAA,CAAA,EAAA;0FA2DqC,wBAAwB,CAAA,EAAA,EAAA,QAAA,EAAA,IAAA,EAAA,CAAA,EAAA,CAAA,EAAA,EAAA,CAAA;;"}
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
|
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
|