@adatechnology/conversations-ui 0.1.0-rc.20 → 0.1.0-rc.22
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/dist/{chunk-OIDAIVCH.js → chunk-TV4OQRGH.js} +636 -434
- package/dist/index.d.ts +142 -9
- package/dist/index.js +374 -66
- package/dist/preview/index.d.ts +122 -3
- package/dist/preview/index.js +137 -21
- package/dist/styles.css +112 -0
- package/dist/{types-O7kMP1Yn.d.ts → types-C6A_9edv.d.ts} +42 -2
- package/package.json +2 -2
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +249 -0
- package/src/ConversationContextPanel.tsx +205 -52
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/MediaRenderer.tsx +37 -6
- package/src/MessageBubble.tsx +26 -3
- package/src/MessageComposer.tsx +21 -2
- package/src/Wallpaper.tsx +27 -13
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/index.ts +11 -0
- package/src/preview/ConversationPreview.tsx +13 -4
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +89 -0
- package/src/preview/createPreviewBridgeClient.ts +37 -1
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +89 -0
- package/src/preview/createPreviewWebhookClient.ts +91 -0
- package/src/preview/index.ts +9 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/types.ts +12 -1
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +189 -0
- package/src/styles.css +122 -0
- package/src/types.ts +26 -0
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
assertPreviewEnvironment,
|
|
11
11
|
createPreviewWebhookClient,
|
|
12
12
|
PreviewInProductionError,
|
|
13
|
+
PreviewMediaUploadRejectedError,
|
|
13
14
|
PreviewWebhookRejectedError,
|
|
14
15
|
} from './createPreviewWebhookClient'
|
|
16
|
+
import { PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
|
|
15
17
|
|
|
16
18
|
const APP_SECRET = 'dev-app-secret'
|
|
17
19
|
const FROM = '5511988887777'
|
|
@@ -103,3 +105,90 @@ describe('assertPreviewEnvironment', () => {
|
|
|
103
105
|
expect(() => assertPreviewEnvironment(false)).not.toThrow()
|
|
104
106
|
})
|
|
105
107
|
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* O microfone do simulador depende disto existir no cliente.
|
|
111
|
+
*
|
|
112
|
+
* Enquanto o upload era prop de quem montava a tela, um produto tinha gravador e o outro não — e a
|
|
113
|
+
* diferença não aparecia em teste nenhum, porque cada host montava o seu. Aqui a garantia é do
|
|
114
|
+
* pacote: cliente montado, upload assinado, id prefixado.
|
|
115
|
+
*/
|
|
116
|
+
describe('createPreviewWebhookClient.uploadMedia', () => {
|
|
117
|
+
function createUploadFetch(status = 201): {
|
|
118
|
+
fetchImplementation: typeof fetch
|
|
119
|
+
calls: Array<{ url: string; signature: string; body: string }>
|
|
120
|
+
} {
|
|
121
|
+
const calls: Array<{ url: string; signature: string; body: string }> = []
|
|
122
|
+
|
|
123
|
+
const fetchImplementation = (async (url: string, init?: RequestInit) => {
|
|
124
|
+
const headers = (init?.headers ?? {}) as Record<string, string>
|
|
125
|
+
calls.push({ url: String(url), signature: headers['x-preview-signature'] ?? '', body: String(init?.body) })
|
|
126
|
+
return {
|
|
127
|
+
ok: status >= 200 && status < 300,
|
|
128
|
+
status,
|
|
129
|
+
json: async () => ({ data: { uploadId: 'upl_123' } }),
|
|
130
|
+
} as Response
|
|
131
|
+
}) as unknown as typeof fetch
|
|
132
|
+
|
|
133
|
+
return { fetchImplementation, calls }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const audioFile = () => new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' })
|
|
137
|
+
|
|
138
|
+
it('sobe na mesma origem do webhook e devolve o id já prefixado', async () => {
|
|
139
|
+
const { fetchImplementation, calls } = createUploadFetch()
|
|
140
|
+
const client = createPreviewWebhookClient({
|
|
141
|
+
webhookUrl: WEBHOOK_URL,
|
|
142
|
+
appSecret: APP_SECRET,
|
|
143
|
+
from: FROM,
|
|
144
|
+
fetchImplementation,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const uploaded = await client.uploadMedia(audioFile())
|
|
148
|
+
|
|
149
|
+
expect(calls[0]?.url).toBe('http://localhost:3000/v1/preview/media')
|
|
150
|
+
expect(uploaded.mediaId).toBe(`${PREVIEW_MEDIA_ID_PREFIX}upl_123`)
|
|
151
|
+
expect(uploaded.mimeType).toBe('audio/ogg')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('assina o MIME com o mesmo HMAC que o servidor confere', async () => {
|
|
155
|
+
const { fetchImplementation, calls } = createUploadFetch()
|
|
156
|
+
const client = createPreviewWebhookClient({
|
|
157
|
+
webhookUrl: WEBHOOK_URL,
|
|
158
|
+
appSecret: APP_SECRET,
|
|
159
|
+
from: FROM,
|
|
160
|
+
fetchImplementation,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
await client.uploadMedia(audioFile())
|
|
164
|
+
|
|
165
|
+
const expected = `sha256=${createHmac('sha256', APP_SECRET).update('audio/ogg').digest('hex')}`
|
|
166
|
+
expect(calls[0]?.signature).toBe(expected)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('usa caminho relativo quando a URL do webhook não tem origem', async () => {
|
|
170
|
+
const { fetchImplementation, calls } = createUploadFetch()
|
|
171
|
+
const client = createPreviewWebhookClient({
|
|
172
|
+
webhookUrl: '/v1/webhook/whatsapp',
|
|
173
|
+
appSecret: APP_SECRET,
|
|
174
|
+
from: FROM,
|
|
175
|
+
fetchImplementation,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
await client.uploadMedia(audioFile())
|
|
179
|
+
|
|
180
|
+
expect(calls[0]?.url).toBe('/v1/preview/media')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('avisa com erro próprio quando a rota de mídia recusa', async () => {
|
|
184
|
+
const { fetchImplementation } = createUploadFetch(404)
|
|
185
|
+
const client = createPreviewWebhookClient({
|
|
186
|
+
webhookUrl: WEBHOOK_URL,
|
|
187
|
+
appSecret: APP_SECRET,
|
|
188
|
+
from: FROM,
|
|
189
|
+
fetchImplementation,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
await expect(client.uploadMedia(audioFile())).rejects.toBeInstanceOf(PreviewMediaUploadRejectedError)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type InboundMediaType,
|
|
26
26
|
type InteractiveReplyOption,
|
|
27
27
|
} from '@adatechnology/meta-whatsapp-contracts/testing'
|
|
28
|
+
import { createPreviewMediaUploader, type PreviewUploadedMedia } from './createPreviewMediaUploader'
|
|
28
29
|
|
|
29
30
|
export type PreviewWebhookClient = {
|
|
30
31
|
sendText(text: string): Promise<void>
|
|
@@ -32,6 +33,19 @@ export type PreviewWebhookClient = {
|
|
|
32
33
|
sendListReply(reply: InteractiveReplyOption): Promise<void>
|
|
33
34
|
sendAudio(mediaId: string): Promise<void>
|
|
34
35
|
sendMedia(params: SendPreviewMediaParams): Promise<void>
|
|
36
|
+
/**
|
|
37
|
+
* Guarda um arquivo gravado e devolve o `mediaId` já prefixado, pronto para `sendMedia`.
|
|
38
|
+
*
|
|
39
|
+
* Existe no cliente, e não como prop de quem monta a tela, porque isto é exatamente o que ele já
|
|
40
|
+
* sabe fazer: falar com ESTE host usando ESTE segredo. Enquanto era responsabilidade do produto,
|
|
41
|
+
* o resultado prático foi um produto com microfone no simulador e outro sem — não por decisão,
|
|
42
|
+
* por esquecimento. Cliente montado, microfone na tela.
|
|
43
|
+
*
|
|
44
|
+
* Opcional porque o cliente-ponte só consegue oferecer isto quando sabe a rota de mídia (ou quando
|
|
45
|
+
* o host injeta a função): sem destino, gravar áudio seria falar para o vazio, e aí a tela
|
|
46
|
+
* corretamente não desenha o gravador.
|
|
47
|
+
*/
|
|
48
|
+
uploadMedia?(file: File): Promise<PreviewUploadedMedia>
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
export type SendPreviewMediaParams = {
|
|
@@ -52,10 +66,25 @@ export type CreatePreviewWebhookClientParams = {
|
|
|
52
66
|
readonly appSecret: string
|
|
53
67
|
readonly from: string
|
|
54
68
|
readonly phoneNumberId?: string
|
|
69
|
+
/**
|
|
70
|
+
* Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na mesma origem do webhook.
|
|
71
|
+
*
|
|
72
|
+
* O padrão cobre o caso normal — as duas rotas são do mesmo servidor — e a prop existe para quem
|
|
73
|
+
* publica a API em outro host ou versiona o caminho.
|
|
74
|
+
*/
|
|
75
|
+
readonly mediaUploadUrl?: string
|
|
55
76
|
// Escape hatch para teste; em runtime real é sempre o fetch global.
|
|
56
77
|
readonly fetchImplementation?: typeof fetch
|
|
57
78
|
}
|
|
58
79
|
|
|
80
|
+
/** Falha da rota de upload, separada da do webhook: os dois lados quebram por motivos diferentes. */
|
|
81
|
+
export class PreviewMediaUploadRejectedError extends Error {
|
|
82
|
+
constructor(readonly status: number) {
|
|
83
|
+
super(`A rota de mídia do simulador recusou o upload (HTTP ${status}).`)
|
|
84
|
+
this.name = 'PreviewMediaUploadRejectedError'
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
59
88
|
export class PreviewInProductionError extends Error {
|
|
60
89
|
constructor() {
|
|
61
90
|
super('O preview de conversa carrega um app secret e não pode ser montado em produção.')
|
|
@@ -102,6 +131,53 @@ export async function signPreviewPayload(params: { rawBody: string; appSecret: s
|
|
|
102
131
|
|
|
103
132
|
const signWithWebCrypto = signPreviewPayload
|
|
104
133
|
|
|
134
|
+
export const DEFAULT_MEDIA_UPLOAD_PATH = '/v1/preview/media'
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Mesma origem do webhook: as duas rotas são do mesmo servidor no caso normal.
|
|
138
|
+
*
|
|
139
|
+
* Quando `webhookUrl` é relativa — que é o que sai de um `VITE_API_URL` vazio, com o front servido
|
|
140
|
+
* pela própria API — não há origem para resolver contra, e o caminho relativo já aponta para o
|
|
141
|
+
* lugar certo. `new URL` com base relativa lançaria, e o microfone morreria no `createClient`.
|
|
142
|
+
*/
|
|
143
|
+
export function defaultMediaUploadUrl(webhookUrl: string): string {
|
|
144
|
+
try {
|
|
145
|
+
return new URL(DEFAULT_MEDIA_UPLOAD_PATH, webhookUrl).toString()
|
|
146
|
+
} catch {
|
|
147
|
+
return DEFAULT_MEDIA_UPLOAD_PATH
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* O POST de mídia, sem a parte de assinatura — para os dois clientes usarem o mesmo caminho.
|
|
153
|
+
*
|
|
154
|
+
* O cliente-ponte autentica por sessão e o de webhook por HMAC; o que não muda é a rota, o formato
|
|
155
|
+
* do corpo e a leitura do `uploadId`. Duas cópias disso é como o prefixo de mídia divergiu antes.
|
|
156
|
+
*/
|
|
157
|
+
export function createPreviewMediaPoster(params: {
|
|
158
|
+
readonly url: string
|
|
159
|
+
readonly headers?: (mimeType: string) => Promise<Readonly<Record<string, string>>>
|
|
160
|
+
readonly fetchImplementation?: typeof fetch
|
|
161
|
+
}): (file: File) => Promise<PreviewUploadedMedia> {
|
|
162
|
+
return createPreviewMediaUploader({
|
|
163
|
+
upload: async (request) => {
|
|
164
|
+
const performRequest = params.fetchImplementation ?? fetch
|
|
165
|
+
const extraHeaders = (await params.headers?.(request.mimeType)) ?? {}
|
|
166
|
+
|
|
167
|
+
const response = await performRequest(params.url, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
170
|
+
body: JSON.stringify(request),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
if (!response.ok) throw new PreviewMediaUploadRejectedError(response.status)
|
|
174
|
+
|
|
175
|
+
const body = (await response.json()) as { data: { uploadId: string } }
|
|
176
|
+
return { uploadId: body.data.uploadId }
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
105
181
|
export function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient {
|
|
106
182
|
const sendPayload = async (payload: ReturnType<typeof buildInboundTextPayload>): Promise<void> => {
|
|
107
183
|
// Serializa uma vez só: assinar um texto e enviar outro (mesmo com o conteúdo igual) derruba a
|
|
@@ -119,6 +195,20 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
|
|
|
119
195
|
if (!response.ok) throw new PreviewWebhookRejectedError(response.status)
|
|
120
196
|
}
|
|
121
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Assina o MIME, não o binário — mesmo contrato da rota.
|
|
200
|
+
*
|
|
201
|
+
* Passar megabytes de base64 pelo HMAC do navegador travaria a aba a cada nota de voz; o que a
|
|
202
|
+
* assinatura protege aqui é o acesso à rota, e o binário já tem teto de tamanho no servidor.
|
|
203
|
+
*/
|
|
204
|
+
const uploadMedia = createPreviewMediaPoster({
|
|
205
|
+
url: params.mediaUploadUrl ?? defaultMediaUploadUrl(params.webhookUrl),
|
|
206
|
+
headers: async (mimeType) => ({
|
|
207
|
+
'x-preview-signature': await signWithWebCrypto({ rawBody: mimeType, appSecret: params.appSecret }),
|
|
208
|
+
}),
|
|
209
|
+
...(params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}),
|
|
210
|
+
})
|
|
211
|
+
|
|
122
212
|
const envelope = { from: params.from, phoneNumberId: params.phoneNumberId }
|
|
123
213
|
|
|
124
214
|
return {
|
|
@@ -127,5 +217,6 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
|
|
|
127
217
|
sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
|
|
128
218
|
sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
|
|
129
219
|
sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
|
|
220
|
+
uploadMedia,
|
|
130
221
|
}
|
|
131
222
|
}
|
package/src/preview/index.ts
CHANGED
|
@@ -25,6 +25,9 @@ export type { CreateMockSSEProviderParams } from './createMockSSEProvider'
|
|
|
25
25
|
|
|
26
26
|
export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES, PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
27
27
|
|
|
28
|
+
export { ConversationSimulatorPanel, DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS } from './ConversationSimulatorPanel'
|
|
29
|
+
export type { ConversationSimulatorPanelProps, ConversationSimulatorPanelLabels } from './ConversationSimulatorPanel'
|
|
30
|
+
|
|
28
31
|
export { ConversationPreview } from './ConversationPreview'
|
|
29
32
|
export { mediaTypeOf } from './ConversationPreview'
|
|
30
33
|
export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'
|
|
@@ -38,6 +41,9 @@ export {
|
|
|
38
41
|
assertPreviewEnvironment,
|
|
39
42
|
PreviewInProductionError,
|
|
40
43
|
PreviewWebhookRejectedError,
|
|
44
|
+
PreviewMediaUploadRejectedError,
|
|
45
|
+
createPreviewMediaPoster,
|
|
46
|
+
DEFAULT_MEDIA_UPLOAD_PATH,
|
|
41
47
|
} from './createPreviewWebhookClient'
|
|
42
48
|
export type {
|
|
43
49
|
PreviewWebhookClient,
|
|
@@ -59,3 +65,6 @@ export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource
|
|
|
59
65
|
export { previewFileBase64 } from './previewMediaSource'
|
|
60
66
|
export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
|
|
61
67
|
export type { MediaTypesPreviewProps } from './MediaTypesPreview'
|
|
68
|
+
|
|
69
|
+
export { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
|
|
70
|
+
export type { CreatePreviewMediaUploaderParams, PreviewMediaUploadRequest } from './createPreviewMediaUploader'
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O teste que importa aqui é o do prefixo: ele é duplicado entre este pacote (UI) e o
|
|
3
|
+
* meta-whatsapp-module (backend), e divergir significa o simulador gerar um id que o servidor não
|
|
4
|
+
* reconhece — o áudio chega, o backend busca na Meta, volta 404 e ninguém entende por quê.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'bun:test'
|
|
8
|
+
import { PREVIEW_MEDIA_ID_PREFIX as CONTRACT_PREFIX } from '@adatechnology/meta-whatsapp-contracts'
|
|
9
|
+
|
|
10
|
+
import { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
|
|
11
|
+
|
|
12
|
+
describe('createPreviewMediaUploader', () => {
|
|
13
|
+
it('reexporta o prefixo do contrato, sem cópia própria', () => {
|
|
14
|
+
expect(PREVIEW_MEDIA_ID_PREFIX).toBe(CONTRACT_PREFIX)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('devolve o id prefixado com o uploadId da rota', async () => {
|
|
18
|
+
const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'chave/do/objeto' }) })
|
|
19
|
+
|
|
20
|
+
const result = await upload(new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' }))
|
|
21
|
+
|
|
22
|
+
expect(result.mediaId).toBe(`${CONTRACT_PREFIX}chave/do/objeto`)
|
|
23
|
+
expect(result.mimeType).toBe('audio/ogg')
|
|
24
|
+
expect(result.filename).toBe('nota.ogg')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('manda o binário em base64 para a rota', async () => {
|
|
28
|
+
let recebido: string | undefined
|
|
29
|
+
const upload = createPreviewMediaUploader({
|
|
30
|
+
upload: async (request) => {
|
|
31
|
+
recebido = request.base64
|
|
32
|
+
return { uploadId: 'k' }
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
await upload(new File([new TextEncoder().encode('audio')], 'a.ogg', { type: 'audio/ogg' }))
|
|
37
|
+
|
|
38
|
+
expect(atob(recebido!)).toBe('audio')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Gravador entrega áudio sem nome, e navegador antigo entrega sem mime.
|
|
42
|
+
it('preenche nome e mime quando o arquivo vem sem eles', async () => {
|
|
43
|
+
const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
|
|
44
|
+
|
|
45
|
+
const result = await upload(new File([new Uint8Array([1])], '', { type: '' }))
|
|
46
|
+
|
|
47
|
+
expect(result.mimeType).toBe('audio/ogg')
|
|
48
|
+
expect(result.filename).toBe('audio.ogg')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Espalhar o array de bytes como argumentos estoura a pilha em arquivo grande — o erro que só
|
|
53
|
+
* aparece na primeira gravação de verdade, nunca no clipe curto do teste.
|
|
54
|
+
*/
|
|
55
|
+
it('converte arquivo grande sem estourar a pilha', async () => {
|
|
56
|
+
const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
|
|
57
|
+
const grande = new File([new Uint8Array(300_000)], 'longo.ogg', { type: 'audio/ogg' })
|
|
58
|
+
|
|
59
|
+
expect((await upload(grande)).mediaId).toContain(CONTRACT_PREFIX)
|
|
60
|
+
})
|
|
61
|
+
})
|
package/src/providers/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MessagePayload } from '../types'
|
|
1
|
+
import type { MessagePayload, MessageTranscription } from '../types'
|
|
2
2
|
import type { ConversationChannel } from '../conversationChannel'
|
|
3
3
|
|
|
4
4
|
export interface ListConversationsParams {
|
|
@@ -134,6 +134,17 @@ export interface ConversationsApi {
|
|
|
134
134
|
* nem todo backend expõe a rota — quem não tem continua usando o builder local.
|
|
135
135
|
*/
|
|
136
136
|
exportTranscript?(conversationId: string): Promise<{ transcript: string; filename: string }>
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Transcreve o áudio de uma mensagem e devolve o resultado.
|
|
140
|
+
*
|
|
141
|
+
* **Opcional por capacidade.** Um host em modo automático transcreve na ingestão e não expõe rota
|
|
142
|
+
* nenhuma; um host sem engine configurado não transcreve de jeito algum. Nos dois casos o balão
|
|
143
|
+
* simplesmente não desenha o botão, em vez de oferecer uma ação que estoura no clique.
|
|
144
|
+
*
|
|
145
|
+
* `messageId` e não `conversationId`: transcrição é por áudio, e uma conversa tem vários.
|
|
146
|
+
*/
|
|
147
|
+
transcribeAudio?(messageId: string): Promise<MessageTranscription>
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
/**
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { TranscriptionSettingsForm } from './TranscriptionSettingsForm'
|
|
5
|
+
import type { TranscriptionSettingsFormProps } from './TranscriptionSettingsForm'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extrai uma tag pelo atributo, em vez de casar regex com a ordem dos atributos: o React SSR emite
|
|
9
|
+
* `checked=""` antes de `value=""`, então `/value="auto"[^>]*checked/` falha num componente correto.
|
|
10
|
+
*/
|
|
11
|
+
function findTag(markup: string, tagPattern: RegExp, contains: string): string | undefined {
|
|
12
|
+
return (markup.match(tagPattern) ?? []).find((tag) => tag.includes(contains))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function render(overrides: Partial<TranscriptionSettingsFormProps> = {}): string {
|
|
16
|
+
return renderToStaticMarkup(
|
|
17
|
+
<TranscriptionSettingsForm
|
|
18
|
+
enabled={false}
|
|
19
|
+
onEnabledChange={() => {}}
|
|
20
|
+
mode="onDemand"
|
|
21
|
+
onModeChange={() => {}}
|
|
22
|
+
onSave={() => {}}
|
|
23
|
+
{...overrides}
|
|
24
|
+
/>,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('TranscriptionSettingsForm', () => {
|
|
29
|
+
it('esconde a escolha de modo enquanto estiver desligado', () => {
|
|
30
|
+
const markup = render({ enabled: false })
|
|
31
|
+
|
|
32
|
+
expect(markup).not.toContain('Quando transcrever')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('mostra as duas opções de modo ao ligar', () => {
|
|
36
|
+
const markup = render({ enabled: true })
|
|
37
|
+
|
|
38
|
+
expect(markup).toContain('Quando transcrever')
|
|
39
|
+
expect(markup).toContain('Quando o atendente pedir')
|
|
40
|
+
expect(markup).toContain('Automaticamente')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('marca o modo atual', () => {
|
|
44
|
+
const markup = render({ enabled: true, mode: 'auto' })
|
|
45
|
+
const radios = /<input type="radio"[^>]*>/g
|
|
46
|
+
|
|
47
|
+
expect(findTag(markup, radios, 'value="auto"')).toContain('checked')
|
|
48
|
+
expect(findTag(markup, radios, 'value="onDemand"')).not.toContain('checked')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* "O ambiente consegue" é diferente de "esta empresa quer". Sem o aviso, o lojista ligaria o
|
|
53
|
+
* interruptor num ambiente sem engine, nada apareceria, e nada explicaria por quê.
|
|
54
|
+
*/
|
|
55
|
+
it('avisa e bloqueia quando a capacidade não existe no servidor', () => {
|
|
56
|
+
const markup = render({ isAvailable: false })
|
|
57
|
+
|
|
58
|
+
expect(markup).toContain('não está disponível neste ambiente')
|
|
59
|
+
expect(findTag(markup, /<input type="checkbox"[^>]*>/g, 'type="checkbox"')).toContain('disabled')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('não avisa nada quando a capacidade existe', () => {
|
|
63
|
+
const markup = render({ isAvailable: true })
|
|
64
|
+
|
|
65
|
+
expect(markup).not.toContain('não está disponível neste ambiente')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('aceita rótulos do host', () => {
|
|
69
|
+
const markup = render({ labels: { sectionTitle: 'Áudio em texto' } })
|
|
70
|
+
|
|
71
|
+
expect(markup).toContain('Áudio em texto')
|
|
72
|
+
expect(markup).not.toContain('Transcrição de áudio')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('desabilita o salvar enquanto salva', () => {
|
|
76
|
+
const markup = render({ saving: true })
|
|
77
|
+
|
|
78
|
+
expect(markup).toContain('Salvando...')
|
|
79
|
+
expect(findTag(markup, /<button type="submit"[^>]*>/g, 'type="submit"')).toContain('disabled')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { FormEvent } from 'react'
|
|
2
|
+
import { AudioLines, Save } from 'lucide-react'
|
|
3
|
+
import type { TranscriptionMode } from '../types'
|
|
4
|
+
|
|
5
|
+
export interface TranscriptionSettingsFormLabels {
|
|
6
|
+
sectionTitle: string
|
|
7
|
+
enabled: string
|
|
8
|
+
enabledHint: string
|
|
9
|
+
modeTitle: string
|
|
10
|
+
modeAuto: string
|
|
11
|
+
modeAutoHint: string
|
|
12
|
+
modeOnDemand: string
|
|
13
|
+
modeOnDemandHint: string
|
|
14
|
+
unavailable: string
|
|
15
|
+
save: string
|
|
16
|
+
saving: string
|
|
17
|
+
saveSuccess: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_LABELS: TranscriptionSettingsFormLabels = {
|
|
21
|
+
sectionTitle: 'Transcrição de áudio',
|
|
22
|
+
enabled: 'Transcrever notas de voz',
|
|
23
|
+
enabledHint: 'Converte o áudio do cliente em texto, que o atendente lê e copia direto da conversa.',
|
|
24
|
+
modeTitle: 'Quando transcrever',
|
|
25
|
+
modeAuto: 'Automaticamente',
|
|
26
|
+
modeAutoHint: 'Toda nota de voz recebida é transcrita na hora. Mais cômodo, consome mais cota.',
|
|
27
|
+
modeOnDemand: 'Quando o atendente pedir',
|
|
28
|
+
modeOnDemandHint: 'Transcreve só ao clicar no botão da conversa. Consome cota apenas com áudio que alguém vai ler.',
|
|
29
|
+
unavailable: 'Transcrição não está disponível neste ambiente — fale com quem administra a instalação.',
|
|
30
|
+
save: 'Salvar',
|
|
31
|
+
saving: 'Salvando...',
|
|
32
|
+
saveSuccess: 'Configuração de transcrição salva.',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TranscriptionSettingsFormProps {
|
|
36
|
+
enabled: boolean
|
|
37
|
+
onEnabledChange: (value: boolean) => void
|
|
38
|
+
mode: TranscriptionMode
|
|
39
|
+
onModeChange: (value: TranscriptionMode) => void
|
|
40
|
+
onSave: (event: FormEvent) => void
|
|
41
|
+
/**
|
|
42
|
+
* A capacidade existe no servidor (engine e credencial configurados)?
|
|
43
|
+
*
|
|
44
|
+
* Distinto de `enabled`: isto é "o ambiente consegue", aquilo é "esta empresa quer". Sem a
|
|
45
|
+
* distinção, um lojista ligaria o interruptor num ambiente sem engine e concluiria que o produto
|
|
46
|
+
* está quebrado — o texto nunca apareceria e nada explicaria por quê.
|
|
47
|
+
*/
|
|
48
|
+
isAvailable?: boolean
|
|
49
|
+
saving?: boolean
|
|
50
|
+
saveSuccess?: boolean
|
|
51
|
+
labels?: Partial<TranscriptionSettingsFormLabels>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Interruptor de transcrição por empresa, para a tela de configurações.
|
|
56
|
+
*
|
|
57
|
+
* Puramente apresentacional, como os outros forms daqui: quem persiste é o host. O pacote não sabe a
|
|
58
|
+
* rota, e a política vive nas configurações do módulo (`settings.transcriptionEnabled`), não em
|
|
59
|
+
* variável de ambiente — mudar de ideia sobre transcrever não deveria exigir deploy.
|
|
60
|
+
*/
|
|
61
|
+
export function TranscriptionSettingsForm({
|
|
62
|
+
enabled,
|
|
63
|
+
onEnabledChange,
|
|
64
|
+
mode,
|
|
65
|
+
onModeChange,
|
|
66
|
+
onSave,
|
|
67
|
+
isAvailable = true,
|
|
68
|
+
saving = false,
|
|
69
|
+
saveSuccess = false,
|
|
70
|
+
labels: labelsOverride,
|
|
71
|
+
}: TranscriptionSettingsFormProps) {
|
|
72
|
+
const labels = { ...DEFAULT_LABELS, ...labelsOverride }
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<section className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
76
|
+
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
|
|
77
|
+
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-1.5">
|
|
78
|
+
<AudioLines size={14} className="text-blue-600 dark:text-blue-400" />
|
|
79
|
+
{labels.sectionTitle}
|
|
80
|
+
</h2>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<form onSubmit={onSave} className="p-6 space-y-5">
|
|
84
|
+
{!isAvailable && (
|
|
85
|
+
<div className="bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded-xl px-4 py-3 text-sm text-amber-800 dark:text-amber-300">
|
|
86
|
+
{labels.unavailable}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
<label className="flex items-start gap-3 cursor-pointer">
|
|
91
|
+
<input
|
|
92
|
+
type="checkbox"
|
|
93
|
+
checked={enabled}
|
|
94
|
+
onChange={(event) => onEnabledChange(event.target.checked)}
|
|
95
|
+
disabled={!isAvailable}
|
|
96
|
+
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50"
|
|
97
|
+
/>
|
|
98
|
+
<span>
|
|
99
|
+
<span className="block text-sm font-medium text-gray-700 dark:text-gray-300">{labels.enabled}</span>
|
|
100
|
+
<span className="block text-xs text-gray-500 dark:text-gray-400">{labels.enabledHint}</span>
|
|
101
|
+
</span>
|
|
102
|
+
</label>
|
|
103
|
+
|
|
104
|
+
{/* O modo só aparece quando ligado: escolher "quando transcrever" sem transcrever nada é uma
|
|
105
|
+
pergunta sem consequência, e ler as duas opções gasta atenção do operador à toa. */}
|
|
106
|
+
{enabled && (
|
|
107
|
+
<fieldset disabled={!isAvailable} className="space-y-2.5">
|
|
108
|
+
<legend className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{labels.modeTitle}</legend>
|
|
109
|
+
|
|
110
|
+
<ModeOption
|
|
111
|
+
value="onDemand"
|
|
112
|
+
current={mode}
|
|
113
|
+
onSelect={onModeChange}
|
|
114
|
+
title={labels.modeOnDemand}
|
|
115
|
+
hint={labels.modeOnDemandHint}
|
|
116
|
+
/>
|
|
117
|
+
<ModeOption
|
|
118
|
+
value="auto"
|
|
119
|
+
current={mode}
|
|
120
|
+
onSelect={onModeChange}
|
|
121
|
+
title={labels.modeAuto}
|
|
122
|
+
hint={labels.modeAutoHint}
|
|
123
|
+
/>
|
|
124
|
+
</fieldset>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{saveSuccess && (
|
|
128
|
+
<div className="bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-800 rounded-xl px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
|
129
|
+
{labels.saveSuccess}
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
<div className="flex justify-end">
|
|
134
|
+
<button
|
|
135
|
+
type="submit"
|
|
136
|
+
disabled={saving || !isAvailable}
|
|
137
|
+
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold px-5 py-2.5 rounded-xl transition-colors text-sm"
|
|
138
|
+
>
|
|
139
|
+
{saving ? (
|
|
140
|
+
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
|
141
|
+
) : (
|
|
142
|
+
<Save size={14} />
|
|
143
|
+
)}
|
|
144
|
+
{saving ? labels.saving : labels.save}
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
</form>
|
|
148
|
+
</section>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function ModeOption({
|
|
153
|
+
value,
|
|
154
|
+
current,
|
|
155
|
+
onSelect,
|
|
156
|
+
title,
|
|
157
|
+
hint,
|
|
158
|
+
}: {
|
|
159
|
+
value: TranscriptionMode
|
|
160
|
+
current: TranscriptionMode
|
|
161
|
+
onSelect: (value: TranscriptionMode) => void
|
|
162
|
+
title: string
|
|
163
|
+
hint: string
|
|
164
|
+
}) {
|
|
165
|
+
const isSelected = current === value
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<label
|
|
169
|
+
className={`flex items-start gap-3 cursor-pointer rounded-xl border px-4 py-3 transition-colors ${
|
|
170
|
+
isSelected
|
|
171
|
+
? 'border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-950/30'
|
|
172
|
+
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
|
173
|
+
}`}
|
|
174
|
+
>
|
|
175
|
+
<input
|
|
176
|
+
type="radio"
|
|
177
|
+
name="transcription-mode"
|
|
178
|
+
value={value}
|
|
179
|
+
checked={isSelected}
|
|
180
|
+
onChange={() => onSelect(value)}
|
|
181
|
+
className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
182
|
+
/>
|
|
183
|
+
<span>
|
|
184
|
+
<span className="block text-sm font-medium text-gray-700 dark:text-gray-300">{title}</span>
|
|
185
|
+
<span className="block text-xs text-gray-500 dark:text-gray-400">{hint}</span>
|
|
186
|
+
</span>
|
|
187
|
+
</label>
|
|
188
|
+
)
|
|
189
|
+
}
|