@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
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* "Suas Seleções": o que o bot já coletou na conversa. Para o atendente que assume no meio, é a
|
|
3
|
-
* diferença entre ler o transcript inteiro e ver o estado
|
|
3
|
+
* diferença entre ler o transcript inteiro e ver o estado de relance.
|
|
4
4
|
*
|
|
5
5
|
* O pacote não interpreta o contexto — ele é `Record<string, unknown>` e cada produto nomeia as
|
|
6
6
|
* próprias chaves. O host traduz para `entries`; aqui só se decide como mostrar.
|
|
7
|
+
*
|
|
8
|
+
* Desenho em paridade com financiamento-imobiliario-bot/apps/web/src/components/SelectionsSummary.tsx:
|
|
9
|
+
* card com gradiente, badge de contagem, pills de status no cabeçalho e grid de cards com borda
|
|
10
|
+
* colorida por estado. O que ficou diferente de lá, e por quê, está comentado no ponto.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import { useState } from 'react'
|
|
14
|
+
import { Check, ChevronDown, ChevronUp, Clock, Pencil } from 'lucide-react'
|
|
10
15
|
|
|
11
16
|
import { cn } from './lib/cn'
|
|
12
17
|
import { useIsNarrow } from './useIsNarrow'
|
|
13
18
|
|
|
19
|
+
/**
|
|
20
|
+
* `completed` tem valor, `pending` ainda não foi coletado, `editing` é o cliente refazendo a
|
|
21
|
+
* resposta. O host só precisa mandar `status` para o terceiro caso — os dois primeiros saem do
|
|
22
|
+
* próprio `value`, e exigir o campo quebraria quem já usa o painel.
|
|
23
|
+
*/
|
|
24
|
+
export type ConversationContextStatus = 'completed' | 'pending' | 'editing'
|
|
25
|
+
|
|
14
26
|
export interface ConversationContextEntry {
|
|
15
27
|
key: string
|
|
16
28
|
label: string
|
|
17
29
|
value?: string | undefined
|
|
18
30
|
icon?: string
|
|
31
|
+
status?: ConversationContextStatus
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
export interface ConversationContextPanelLabels {
|
|
@@ -23,13 +36,16 @@ export interface ConversationContextPanelLabels {
|
|
|
23
36
|
empty: string
|
|
24
37
|
collapse: string
|
|
25
38
|
expand: string
|
|
39
|
+
/** Placeholder do card ainda não coletado. */
|
|
40
|
+
notCollected: string
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
export const DEFAULT_CONVERSATION_CONTEXT_LABELS: ConversationContextPanelLabels = {
|
|
29
|
-
title: '
|
|
44
|
+
title: 'Suas Seleções',
|
|
30
45
|
empty: 'Nada coletado ainda nesta conversa.',
|
|
31
46
|
collapse: 'fechar',
|
|
32
47
|
expand: 'abrir',
|
|
48
|
+
notCollected: 'a coletar',
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
export interface ConversationContextPanelClassNames {
|
|
@@ -45,83 +61,220 @@ export interface ConversationContextPanelProps {
|
|
|
45
61
|
* Estado inicial. Ausente, abre sozinho no desktop quando há algum dado preenchido.
|
|
46
62
|
*
|
|
47
63
|
* Existe porque "abre sozinho" nem sempre é o que o produto quer: com 1 de 6 campos preenchidos o
|
|
48
|
-
* painel ocupa altura mostrando quase só
|
|
64
|
+
* painel ocupa altura mostrando quase só pendências, e empurra a conversa — que é o que se veio ver.
|
|
49
65
|
*/
|
|
50
66
|
defaultOpen?: boolean
|
|
67
|
+
/**
|
|
68
|
+
* Rótulo do fluxo em curso, exibido em azul ao lado do título — no financiamento é o produto
|
|
69
|
+
* escolhido ("Consórcio", "MCMV"). Ausente, o cabeçalho fica só com título e contagens.
|
|
70
|
+
*/
|
|
71
|
+
flowLabel?: string
|
|
51
72
|
labels?: Partial<ConversationContextPanelLabels>
|
|
52
73
|
className?: string
|
|
53
74
|
classNames?: Partial<ConversationContextPanelClassNames>
|
|
54
75
|
}
|
|
55
76
|
|
|
77
|
+
function statusOf(entry: ConversationContextEntry): ConversationContextStatus {
|
|
78
|
+
if (entry.status) return entry.status
|
|
79
|
+
return entry.value ? 'completed' : 'pending'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Borda e fundo do card por estado. Pendente é tracejado — ver o comentário no grid. */
|
|
83
|
+
const CARD_STYLE: Record<ConversationContextStatus, string> = {
|
|
84
|
+
completed: 'bg-white dark:bg-gray-800 border-2 border-green-200 dark:border-green-800 shadow-sm',
|
|
85
|
+
editing: 'bg-white dark:bg-gray-800 border-2 border-blue-200 dark:border-blue-800 shadow-sm',
|
|
86
|
+
pending: 'border-2 border-dashed border-gray-200 bg-white/40 dark:border-gray-700 dark:bg-gray-800/30',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const GLYPH_STYLE: Record<ConversationContextStatus, string> = {
|
|
90
|
+
completed: 'text-green-600 dark:text-green-400',
|
|
91
|
+
editing: 'text-blue-600 dark:text-blue-400',
|
|
92
|
+
pending: 'text-gray-400 dark:text-gray-500',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function StatusGlyph({ status }: { status: ConversationContextStatus }) {
|
|
96
|
+
const size = 11
|
|
97
|
+
if (status === 'completed') return <Check size={size} aria-hidden />
|
|
98
|
+
if (status === 'editing') return <Pencil size={size} aria-hidden />
|
|
99
|
+
return <Clock size={size} aria-hidden />
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function CountPill({
|
|
103
|
+
status,
|
|
104
|
+
count,
|
|
105
|
+
className,
|
|
106
|
+
}: {
|
|
107
|
+
status: ConversationContextStatus
|
|
108
|
+
count: number
|
|
109
|
+
className: string
|
|
110
|
+
}) {
|
|
111
|
+
if (count === 0) return null
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<span className={cn('flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium', className)}>
|
|
115
|
+
<StatusGlyph status={status} />
|
|
116
|
+
{count}
|
|
117
|
+
</span>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
56
121
|
export function ConversationContextPanel({
|
|
57
122
|
entries,
|
|
58
123
|
defaultOpen,
|
|
124
|
+
flowLabel,
|
|
59
125
|
labels: labelsOverride,
|
|
60
126
|
className,
|
|
61
127
|
classNames,
|
|
62
128
|
}: ConversationContextPanelProps) {
|
|
63
129
|
const labels = { ...DEFAULT_CONVERSATION_CONTEXT_LABELS, ...labelsOverride }
|
|
64
|
-
const filled = entries.filter((entry) => Boolean(entry.value))
|
|
65
130
|
|
|
66
|
-
|
|
67
|
-
|
|
131
|
+
const counts = { completed: 0, pending: 0, editing: 0 }
|
|
132
|
+
for (const entry of entries) counts[statusOf(entry)] += 1
|
|
133
|
+
|
|
134
|
+
// Abre sozinho só quando há algo coletado: um painel de seis pendências ocupa a altura da conversa
|
|
135
|
+
// sem informar nada. As contagens continuam visíveis fechado, que é o dado útil de relance.
|
|
68
136
|
//
|
|
69
137
|
// `undefined` = usuário ainda não mexeu. Guardar só um booleano não serve: o contexto chega
|
|
70
138
|
// depois da montagem, então o estado inicial seria calculado com a lista vazia e o painel ficaria
|
|
71
139
|
// fechado mesmo com dados.
|
|
72
140
|
const isNarrow = useIsNarrow()
|
|
73
141
|
const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined)
|
|
74
|
-
// No celular nasce fechado mesmo com dados: aberto, o painel consome ~150px da conversa.
|
|
75
|
-
|
|
76
|
-
const open = manualOpen ?? defaultOpen ?? (!isNarrow && filled.length > 0)
|
|
142
|
+
// No celular nasce fechado mesmo com dados: aberto, o painel consome ~150px da conversa.
|
|
143
|
+
const open = manualOpen ?? defaultOpen ?? (!isNarrow && counts.completed > 0)
|
|
77
144
|
|
|
78
145
|
return (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
aria-expanded={open}
|
|
84
|
-
title={open ? labels.collapse : labels.expand}
|
|
146
|
+
// Faixa azul clara com separador, como o wrapper do financiamento: sem ela o card encostava
|
|
147
|
+
// direto no papel de parede da conversa e os dois blocos se misturavam.
|
|
148
|
+
<div className={cn('border-b bg-blue-50/60 px-3 py-3 dark:border-gray-700 dark:bg-blue-950/20', className)}>
|
|
149
|
+
<section
|
|
85
150
|
className={cn(
|
|
86
|
-
'
|
|
87
|
-
classNames?.
|
|
151
|
+
'overflow-hidden rounded-lg border border-blue-200 bg-gradient-to-br from-blue-50 to-indigo-50 dark:border-blue-900 dark:from-blue-950/30 dark:to-indigo-950/30',
|
|
152
|
+
classNames?.root,
|
|
88
153
|
)}
|
|
89
154
|
>
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
{
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
painel — a pergunta "cadê o botão de fechar?" veio daí. */}
|
|
99
|
-
<span aria-hidden className="ml-auto text-xs text-gray-500">
|
|
100
|
-
{open ? labels.collapse : labels.expand}
|
|
101
|
-
</span>
|
|
102
|
-
</button>
|
|
103
|
-
|
|
104
|
-
{open ? (
|
|
105
|
-
<div className={cn('px-4 pb-3', classNames?.body)}>
|
|
106
|
-
{entries.length === 0 ? (
|
|
107
|
-
<p className="text-xs text-gray-500">{labels.empty}</p>
|
|
108
|
-
) : (
|
|
109
|
-
<dl className="grid grid-cols-2 gap-2 text-xs">
|
|
110
|
-
{entries.map((entry) => (
|
|
111
|
-
<div key={entry.key} className="min-w-0">
|
|
112
|
-
<dt className="truncate text-gray-500">
|
|
113
|
-
{entry.icon ? `${entry.icon} ` : ''}
|
|
114
|
-
{entry.label}
|
|
115
|
-
</dt>
|
|
116
|
-
<dd className="truncate font-medium">
|
|
117
|
-
{entry.value ? <span className="text-green-700 dark:text-green-400">✓ {entry.value}</span> : '—'}
|
|
118
|
-
</dd>
|
|
119
|
-
</div>
|
|
120
|
-
))}
|
|
121
|
-
</dl>
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={() => setManualOpen(!open)}
|
|
158
|
+
aria-expanded={open}
|
|
159
|
+
title={open ? labels.collapse : labels.expand}
|
|
160
|
+
className={cn(
|
|
161
|
+
'flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-2.5 text-left transition-colors hover:bg-blue-100/40 dark:hover:bg-blue-900/20',
|
|
162
|
+
classNames?.toggle,
|
|
122
163
|
)}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
164
|
+
>
|
|
165
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
166
|
+
<span aria-hidden className="text-base leading-none">
|
|
167
|
+
📋
|
|
168
|
+
</span>
|
|
169
|
+
<span className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{labels.title}</span>
|
|
170
|
+
{flowLabel ? (
|
|
171
|
+
<span className="truncate text-xs font-medium text-blue-600 dark:text-blue-400">{flowLabel}</span>
|
|
172
|
+
) : null}
|
|
173
|
+
<span
|
|
174
|
+
className={cn(
|
|
175
|
+
'inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-medium text-white',
|
|
176
|
+
classNames?.counter,
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
{entries.length}
|
|
180
|
+
</span>
|
|
181
|
+
</span>
|
|
182
|
+
|
|
183
|
+
<span className="flex flex-shrink-0 items-center gap-2">
|
|
184
|
+
<CountPill
|
|
185
|
+
status="completed"
|
|
186
|
+
count={counts.completed}
|
|
187
|
+
className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
|
188
|
+
/>
|
|
189
|
+
<CountPill
|
|
190
|
+
status="editing"
|
|
191
|
+
count={counts.editing}
|
|
192
|
+
className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
|
193
|
+
/>
|
|
194
|
+
<CountPill
|
|
195
|
+
status="pending"
|
|
196
|
+
count={counts.pending}
|
|
197
|
+
className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
|
198
|
+
/>
|
|
199
|
+
{open ? (
|
|
200
|
+
<ChevronUp size={15} className="flex-shrink-0 text-blue-500" aria-hidden />
|
|
201
|
+
) : (
|
|
202
|
+
<ChevronDown size={15} className="flex-shrink-0 text-blue-500" aria-hidden />
|
|
203
|
+
)}
|
|
204
|
+
</span>
|
|
205
|
+
</button>
|
|
206
|
+
|
|
207
|
+
{open ? (
|
|
208
|
+
/**
|
|
209
|
+
* Teto de altura com rolagem interna, como no financiamento (`max-h-64 overflow-y-auto`).
|
|
210
|
+
* Sem isso o painel cresce sem limite — e cresce de verdade: o host anexa as chaves de
|
|
211
|
+
* contexto que ele não conhece ao fim da lista, então uma conversa com bastante estado
|
|
212
|
+
* empurrava a conversa inteira para fora da tela.
|
|
213
|
+
*
|
|
214
|
+
* A diferença: lá o teto envolve o cabeçalho também, então ele rola junto e sai de vista.
|
|
215
|
+
* Aqui só o corpo rola — o cabeçalho é o que fecha o painel, e perder o clique de fechar
|
|
216
|
+
* no meio da rolagem é pior do que a economia de uma linha de markup.
|
|
217
|
+
*/
|
|
218
|
+
<div
|
|
219
|
+
className={cn(
|
|
220
|
+
'cv-scrollbar-thin max-h-64 overflow-y-auto border-t border-blue-200 px-4 pb-4 pt-1 dark:border-blue-900',
|
|
221
|
+
classNames?.body,
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
224
|
+
{entries.length === 0 ? (
|
|
225
|
+
<p className="pt-2 text-xs text-gray-500 dark:text-gray-400">{labels.empty}</p>
|
|
226
|
+
) : (
|
|
227
|
+
/**
|
|
228
|
+
* Três colunas no desktop, como no financiamento — com duas, seis campos viravam uma
|
|
229
|
+
* coluna alta que empurrava a conversa.
|
|
230
|
+
*
|
|
231
|
+
* O que não fizemos igual: lá o painel FILTRA o que não foi coletado, porque a fonte
|
|
232
|
+
* só guarda o que o cliente respondeu. Aqui a lista de campos é conhecida de antemão,
|
|
233
|
+
* e "falta endereço" é justamente o que o atendente precisa ver ao assumir. Então o
|
|
234
|
+
* pendente aparece — tracejado e apagado, para ler como lacuna e não como dado.
|
|
235
|
+
*/
|
|
236
|
+
<dl className="mt-2 grid grid-cols-2 gap-2 lg:grid-cols-3">
|
|
237
|
+
{entries.map((entry) => {
|
|
238
|
+
const status = statusOf(entry)
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<div
|
|
242
|
+
key={entry.key}
|
|
243
|
+
className={cn('min-w-0 rounded-lg p-2.5 transition-all', CARD_STYLE[status])}
|
|
244
|
+
>
|
|
245
|
+
<dt className="mb-1 flex items-center justify-center gap-1">
|
|
246
|
+
{entry.icon ? (
|
|
247
|
+
<span aria-hidden className="text-base leading-none">
|
|
248
|
+
{entry.icon}
|
|
249
|
+
</span>
|
|
250
|
+
) : null}
|
|
251
|
+
<span className="truncate text-[10px] font-medium uppercase leading-none tracking-wide text-gray-500 dark:text-gray-400">
|
|
252
|
+
{entry.label}
|
|
253
|
+
</span>
|
|
254
|
+
<span className={cn('flex-shrink-0 leading-none', GLYPH_STYLE[status])}>
|
|
255
|
+
<StatusGlyph status={status} />
|
|
256
|
+
</span>
|
|
257
|
+
</dt>
|
|
258
|
+
{/* `title` no valor: truncado no card, o texto completo só existe no tooltip. */}
|
|
259
|
+
<dd
|
|
260
|
+
className={cn(
|
|
261
|
+
'truncate text-center text-xs font-semibold',
|
|
262
|
+
entry.value
|
|
263
|
+
? 'text-gray-900 dark:text-gray-100'
|
|
264
|
+
: 'italic font-normal text-gray-400 dark:text-gray-500',
|
|
265
|
+
)}
|
|
266
|
+
title={entry.value ?? labels.notCollected}
|
|
267
|
+
>
|
|
268
|
+
{entry.value ?? labels.notCollected}
|
|
269
|
+
</dd>
|
|
270
|
+
</div>
|
|
271
|
+
)
|
|
272
|
+
})}
|
|
273
|
+
</dl>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
) : null}
|
|
277
|
+
</section>
|
|
278
|
+
</div>
|
|
126
279
|
)
|
|
127
280
|
}
|
|
@@ -22,6 +22,20 @@ export interface ConversationLocales {
|
|
|
22
22
|
untitledDocument: string
|
|
23
23
|
downloadFile: string
|
|
24
24
|
}
|
|
25
|
+
transcription: {
|
|
26
|
+
label: string
|
|
27
|
+
copy: string
|
|
28
|
+
copied: string
|
|
29
|
+
transcribe: string
|
|
30
|
+
transcribing: string
|
|
31
|
+
retry: string
|
|
32
|
+
failed: string
|
|
33
|
+
/** Áudio processado sem fala detectada — distinto de "não transcrito". */
|
|
34
|
+
empty: string
|
|
35
|
+
unsupported: string
|
|
36
|
+
showMore: string
|
|
37
|
+
showLess: string
|
|
38
|
+
}
|
|
25
39
|
selection: {
|
|
26
40
|
select: string
|
|
27
41
|
}
|
|
@@ -53,6 +67,19 @@ const DEFAULT_LOCALES: ConversationLocales = {
|
|
|
53
67
|
untitledDocument: 'Documento',
|
|
54
68
|
downloadFile: 'Baixar',
|
|
55
69
|
},
|
|
70
|
+
transcription: {
|
|
71
|
+
label: 'Transcrição',
|
|
72
|
+
copy: 'Copiar',
|
|
73
|
+
copied: 'Copiado!',
|
|
74
|
+
transcribe: 'Transcrever áudio',
|
|
75
|
+
transcribing: 'Transcrevendo...',
|
|
76
|
+
retry: 'Transcrever novamente',
|
|
77
|
+
failed: 'Falha ao transcrever — tentar novamente',
|
|
78
|
+
empty: 'Sem fala detectada',
|
|
79
|
+
unsupported: 'Formato de áudio não suportado para transcrição',
|
|
80
|
+
showMore: 'ver transcrição completa',
|
|
81
|
+
showLess: 'ver menos',
|
|
82
|
+
},
|
|
56
83
|
selection: {
|
|
57
84
|
select: 'Selecionar',
|
|
58
85
|
},
|
|
@@ -76,6 +103,7 @@ export interface ConversationLocalesProviderProps {
|
|
|
76
103
|
export function ConversationLocalesProvider({ children, locales }: ConversationLocalesProviderProps) {
|
|
77
104
|
const merged: ConversationLocales = {
|
|
78
105
|
bubble: { ...DEFAULT_LOCALES.bubble, ...locales?.bubble },
|
|
106
|
+
transcription: { ...DEFAULT_LOCALES.transcription, ...locales?.transcription },
|
|
79
107
|
selection: { ...DEFAULT_LOCALES.selection, ...locales?.selection },
|
|
80
108
|
dateDivider: { ...DEFAULT_LOCALES.dateDivider, ...locales?.dateDivider },
|
|
81
109
|
}
|
package/src/MediaRenderer.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { useState, type ReactNode } from 'react'
|
|
2
2
|
import { AudioPlayer } from './AudioPlayer'
|
|
3
|
+
import { AudioTranscription } from './AudioTranscription'
|
|
3
4
|
import { FileIcon } from './FileIcon'
|
|
4
5
|
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
5
6
|
import { formatFileSize } from './lib/format'
|
|
6
7
|
import { cn } from './lib/cn'
|
|
7
|
-
import type { MessagePayload } from './types'
|
|
8
|
+
import type { MessagePayload, MessageTranscription } from './types'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Rótulo curto do tipo, para a linha de baixo da bolha de documento.
|
|
@@ -50,6 +51,13 @@ export interface MediaRendererProps {
|
|
|
50
51
|
// loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
|
|
51
52
|
// porém delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
|
|
52
53
|
onResolveUrl?: ResolveMediaUrl
|
|
54
|
+
/**
|
|
55
|
+
* Pede ao backend a transcrição do áudio desta mensagem. Ausente, o bloco de transcrição só exibe
|
|
56
|
+
* o que já veio pronto — sem oferecer um botão que o host não sabe atender.
|
|
57
|
+
*
|
|
58
|
+
* O que devolver é exibido na hora, sem esperar refetch da lista.
|
|
59
|
+
*/
|
|
60
|
+
onTranscribeAudio?: () => Promise<MessageTranscription | void>
|
|
53
61
|
/** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
|
|
54
62
|
className?: string
|
|
55
63
|
}
|
|
@@ -77,7 +85,13 @@ function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl
|
|
|
77
85
|
return { url, loading, error, load }
|
|
78
86
|
}
|
|
79
87
|
|
|
80
|
-
export function MediaRenderer({
|
|
88
|
+
export function MediaRenderer({
|
|
89
|
+
message,
|
|
90
|
+
onLightbox,
|
|
91
|
+
onResolveUrl,
|
|
92
|
+
onTranscribeAudio,
|
|
93
|
+
className,
|
|
94
|
+
}: MediaRendererProps) {
|
|
81
95
|
const { bubble } = useConversationLocales()
|
|
82
96
|
const eagerSrc = resolveMediaSource(message)
|
|
83
97
|
const lazy = useLazyMediaUrl(message, onResolveUrl)
|
|
@@ -149,12 +163,28 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
149
163
|
)
|
|
150
164
|
}
|
|
151
165
|
case 'audio': {
|
|
166
|
+
/**
|
|
167
|
+
* Fica fora do ramo de carregamento de propósito: a transcrição não depende dos bytes do
|
|
168
|
+
* áudio. Ler o que o cliente disse sem baixar e tocar a nota de voz é o caminho rápido do
|
|
169
|
+
* atendimento — e é justamente o que se perderia se o bloco só aparecesse depois do play.
|
|
170
|
+
*/
|
|
171
|
+
const transcriptionBlock = (
|
|
172
|
+
<AudioTranscription
|
|
173
|
+
transcription={message.transcription}
|
|
174
|
+
isMine={message.direction === 'outbound'}
|
|
175
|
+
{...(onTranscribeAudio ? { onTranscribe: onTranscribeAudio } : {})}
|
|
176
|
+
/>
|
|
177
|
+
)
|
|
178
|
+
|
|
152
179
|
if (!src && canLazyLoad) {
|
|
153
180
|
return (
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
181
|
+
<div className="min-w-[200px]">
|
|
182
|
+
<LazyMediaButton
|
|
183
|
+
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="translate-x-0.5"><polygon points="5 3 19 12 5 21 5 3" /></svg>}
|
|
184
|
+
label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
|
|
185
|
+
/>
|
|
186
|
+
{transcriptionBlock}
|
|
187
|
+
</div>
|
|
158
188
|
)
|
|
159
189
|
}
|
|
160
190
|
return (
|
|
@@ -162,6 +192,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
162
192
|
{src ? <AudioPlayer src={src} isMine={message.direction === 'outbound'} /> : (
|
|
163
193
|
<div className="h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs">{bubble.mediaUnavailable}</div>
|
|
164
194
|
)}
|
|
195
|
+
{transcriptionBlock}
|
|
165
196
|
</div>
|
|
166
197
|
)
|
|
167
198
|
}
|
package/src/MessageBubble.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useMemo, useState } from 'react'
|
|
2
2
|
import { Check } from 'lucide-react'
|
|
3
|
-
import type { InteractiveSelection, MessagePayload } from './types'
|
|
3
|
+
import type { InteractiveSelection, MessagePayload, MessageTranscription } from './types'
|
|
4
4
|
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
5
5
|
import { StatusTicks } from './StatusTicks'
|
|
6
6
|
import { MediaRenderer, type ResolveMediaUrl } from './MediaRenderer'
|
|
@@ -30,6 +30,12 @@ export interface MessageBubbleProps {
|
|
|
30
30
|
* no histórico da inbox: o operador vê o que foi oferecido, sem responder no lugar do cliente.
|
|
31
31
|
*/
|
|
32
32
|
onInteractiveSelect?: (selection: InteractiveSelection) => void
|
|
33
|
+
/**
|
|
34
|
+
* Pede a transcrição do áudio. Ausente, o balão usa `transcribeAudio` do `ConversationsApi` do
|
|
35
|
+
* contexto — passe apenas para sobrescrever. Sem nenhum dos dois, o bloco de transcrição só exibe
|
|
36
|
+
* o que já veio pronto do backend.
|
|
37
|
+
*/
|
|
38
|
+
onTranscribeAudio?: (messageId: string) => Promise<MessageTranscription | void>
|
|
33
39
|
className?: string
|
|
34
40
|
}
|
|
35
41
|
|
|
@@ -51,7 +57,7 @@ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
|
|
|
51
57
|
// tailwind.config do host expondo as cores `whatsapp.*` — ver Wallpaper.tsx e T6.2.
|
|
52
58
|
export function MessageBubble({
|
|
53
59
|
message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
|
|
54
|
-
onResolveMediaUrl, onInteractiveSelect, className,
|
|
60
|
+
onResolveMediaUrl, onInteractiveSelect, onTranscribeAudio, className,
|
|
55
61
|
}: MessageBubbleProps) {
|
|
56
62
|
const { bubble, selection } = useConversationLocales()
|
|
57
63
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
|
|
@@ -66,6 +72,18 @@ export function MessageBubble({
|
|
|
66
72
|
[onResolveMediaUrl, context?.api],
|
|
67
73
|
)
|
|
68
74
|
|
|
75
|
+
/**
|
|
76
|
+
* `undefined` quando nem o host nem o `ConversationsApi` sabem transcrever — é essa ausência que o
|
|
77
|
+
* bloco de transcrição consulta para decidir se desenha a afordância, mesmo padrão de
|
|
78
|
+
* `takeover`/`release`.
|
|
79
|
+
*/
|
|
80
|
+
const requestTranscription = useMemo(() => {
|
|
81
|
+
const transcribe = onTranscribeAudio ?? context?.api?.transcribeAudio?.bind(context.api)
|
|
82
|
+
if (!transcribe) return undefined
|
|
83
|
+
// Devolve o resultado em vez de descartar: é o que o bloco exibe na hora, sem esperar refetch.
|
|
84
|
+
return () => transcribe(message.id)
|
|
85
|
+
}, [onTranscribeAudio, context?.api, message.id])
|
|
86
|
+
|
|
69
87
|
const bubbleColor = BUBBLE_COLOR[message.sender] ?? BUBBLE_COLOR.customer
|
|
70
88
|
const hasError = message.status === 'failed'
|
|
71
89
|
const isMedia = MEDIA_TYPES.has(message.type)
|
|
@@ -138,7 +156,12 @@ export function MessageBubble({
|
|
|
138
156
|
)}
|
|
139
157
|
|
|
140
158
|
{isMedia ? (
|
|
141
|
-
<MediaRenderer
|
|
159
|
+
<MediaRenderer
|
|
160
|
+
message={message}
|
|
161
|
+
onLightbox={setLightboxSrc}
|
|
162
|
+
onResolveUrl={resolveMediaUrl}
|
|
163
|
+
{...(requestTranscription ? { onTranscribeAudio: requestTranscription } : {})}
|
|
164
|
+
/>
|
|
142
165
|
) : isInteractive && message.payload ? (
|
|
143
166
|
// O texto da mensagem interativa mora dentro do payload (`body.text`), e `content` guarda
|
|
144
167
|
// só uma cópia achatada para busca — renderizar `content` aqui duplicaria o corpo.
|
package/src/MessageComposer.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent, type ReactNode } from 'react'
|
|
2
|
+
import { AudioRecorderButton } from './AudioRecorderButton'
|
|
2
3
|
import type { ConversationsFeatures } from './types'
|
|
3
4
|
import { cn } from './lib/cn'
|
|
4
5
|
import { EmojiPicker } from './EmojiPicker'
|
|
@@ -219,6 +220,24 @@ export const MessageComposer = ({
|
|
|
219
220
|
}
|
|
220
221
|
}, [text, setText])
|
|
221
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Microfone por padrão, sem o host precisar compor nada.
|
|
225
|
+
*
|
|
226
|
+
* Antes o gravador era só um componente exportado e um slot vazio: cada inbox tinha que lembrar de
|
|
227
|
+
* juntar os dois. Dois produtos, dois resultados — um ligou, o outro não, e a ausência não dava
|
|
228
|
+
* erro nenhum. Um composer de WhatsApp sem microfone está incompleto, então o default certo é ter.
|
|
229
|
+
*
|
|
230
|
+
* Depende de `onAttach` porque áudio gravado é um arquivo para entregar, e microfone que grava sem
|
|
231
|
+
* ter para onde mandar é pior que microfone nenhum — o operador fala e o áudio evapora. É a mesma
|
|
232
|
+
* regra de capacidade usada no resto do pacote: sem a porta, a afordância não aparece.
|
|
233
|
+
*
|
|
234
|
+
* `idleAction` continua vencendo: quem já compunha o próprio gravador (com rótulos, limite de
|
|
235
|
+
* duração ou revisão diferentes) não muda de comportamento ao atualizar.
|
|
236
|
+
*/
|
|
237
|
+
const effectiveIdleAction =
|
|
238
|
+
idleAction ??
|
|
239
|
+
(onAttach ? <AudioRecorderButton onRecorded={(file) => onAttach(file)} /> : undefined)
|
|
240
|
+
|
|
222
241
|
const canSend = text.trim().length > 0 || attachments.length > 0
|
|
223
242
|
const remaining = maxLength ? maxLength - text.length : null
|
|
224
243
|
|
|
@@ -310,8 +329,8 @@ export const MessageComposer = ({
|
|
|
310
329
|
</>
|
|
311
330
|
)}
|
|
312
331
|
|
|
313
|
-
{!canSend &&
|
|
314
|
-
<div className="flex-shrink-0">{
|
|
332
|
+
{!canSend && effectiveIdleAction ? (
|
|
333
|
+
<div className="flex-shrink-0">{effectiveIdleAction}</div>
|
|
315
334
|
) : (
|
|
316
335
|
<button
|
|
317
336
|
onClick={sendMessage}
|
package/src/Wallpaper.tsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* A classe `cv-wallpaper` continua no elemento para quem já sobrescreve por CSS.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type
|
|
11
|
+
import { forwardRef, type CSSProperties, type ReactNode, type UIEvent } from 'react'
|
|
12
12
|
|
|
13
13
|
import { cn } from './lib/cn'
|
|
14
14
|
import { useIsDarkTheme } from './useDarkMode'
|
|
@@ -45,17 +45,31 @@ export interface ConversationWallpaperProps {
|
|
|
45
45
|
className?: string
|
|
46
46
|
/** Ajusta ou substitui o fundo padrão — para produto com identidade visual própria. */
|
|
47
47
|
style?: CSSProperties
|
|
48
|
+
/**
|
|
49
|
+
* Rolagem da área de mensagens. Par do `ref`: é este elemento que rola, então é aqui que
|
|
50
|
+
* `useScrollToLatestMessage` observa a posição para saber se o operador está acompanhando o fim.
|
|
51
|
+
*/
|
|
52
|
+
onScroll?: (event: UIEvent<HTMLDivElement>) => void
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
55
|
+
/**
|
|
56
|
+
* `forwardRef` porque quem controla a rolagem é de fora — o hook precisa do elemento para saltar
|
|
57
|
+
* até a última mensagem. `forwardRef` e não `ref` como prop: o pacote suporta React 18, onde
|
|
58
|
+
* ref-como-prop ainda não existe.
|
|
59
|
+
*/
|
|
60
|
+
export const ConversationWallpaper = forwardRef<HTMLDivElement, ConversationWallpaperProps>(
|
|
61
|
+
function ConversationWallpaper({ children, className, style, onScroll }, ref) {
|
|
62
|
+
const isDark = useIsDarkTheme()
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
ref={ref}
|
|
67
|
+
onScroll={onScroll}
|
|
68
|
+
className={cn('cv-wallpaper', className)}
|
|
69
|
+
style={{ ...(isDark ? DARK_WALLPAPER : LIGHT_WALLPAPER), ...style }}
|
|
70
|
+
>
|
|
71
|
+
{children}
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
},
|
|
75
|
+
)
|