@softize/opus 25.7.0 → 26.0.0

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.
@@ -63,13 +63,125 @@ render(
63
63
  )
64
64
  ```
65
65
 
66
+ ## Com imagens anexadas
67
+
68
+ Quando a mensagem pode levar imagens, como um print de relatório para a IA reproduzir, passe
69
+ `attachments`. O composer ganha o botão **Anexar imagem** na barra e uma faixa acima do campo, com a
70
+ miniatura, o nome e o estado de cada imagem. Colar uma imagem no campo e soltá-la sobre o composer
71
+ também anexam.
72
+
73
+ O estado é do aplicativo, porque é ele quem envia e guarda a imagem. O composer faz a triagem e
74
+ entrega os arquivos aceitos em `onAddFiles`; o aplicativo acrescenta cada um à lista como
75
+ `uploading`, prepara a imagem com [`prepareImage`](#preparar-a-imagem-antes-do-envio), envia e muda o
76
+ estado para `ready` ou `failed`. Depois do envio da mensagem, é o aplicativo que limpa a lista e
77
+ revoga os endereços das miniaturas que criou.
78
+
79
+ ```tsx preview col
80
+ const [text, setText] = React.useState('')
81
+ const [items, setItems] = React.useState<ComposerAttachment[]>([])
82
+ const nextId = React.useRef(1)
83
+ const previews = React.useRef<Record<string, string>>({})
84
+
85
+ // A miniatura é um endereço de objeto, que segura a imagem na memória até ser revogado.
86
+ function release(id: string) {
87
+ const url = previews.current[id]
88
+ if (url !== undefined) URL.revokeObjectURL(url)
89
+ delete previews.current[id]
90
+ }
91
+ React.useEffect(() => () => Object.keys(previews.current).forEach(release), [])
92
+
93
+ async function add(files: File[]) {
94
+ for (const file of files) {
95
+ const id = String(nextId.current++)
96
+ const prepared = await prepareImage(file)
97
+ if (!prepared.ok) {
98
+ setItems((list) => [...list, { id, name: file.name, status: 'failed', error: prepared.message }])
99
+ continue
100
+ }
101
+ previews.current[id] = URL.createObjectURL(prepared.file)
102
+ setItems((list) => [
103
+ ...list,
104
+ { id, name: prepared.file.name, status: 'uploading', previewUrl: previews.current[id] },
105
+ ])
106
+ // Aqui o aplicativo envia `prepared.file` ao próprio servidor.
107
+ await new Promise((r) => setTimeout(r, 800))
108
+ setItems((list) => list.map((item) => (item.id === id ? { ...item, status: 'ready' } : item)))
109
+ }
110
+ }
111
+
112
+ render(
113
+ <Composer
114
+ value={text}
115
+ onChange={setText}
116
+ onSubmit={() => {
117
+ items.forEach((item) => release(item.id))
118
+ setText('')
119
+ setItems([])
120
+ }}
121
+ placeholder="Cole, solte ou anexe um print…"
122
+ attachments={{
123
+ items,
124
+ onAddFiles: (files) => void add(files),
125
+ onRemove: (id) => {
126
+ release(id)
127
+ setItems((list) => list.filter((item) => item.id !== id))
128
+ },
129
+ }}
130
+ />,
131
+ )
132
+ ```
133
+
134
+ O envio passa com texto ou com pelo menos uma imagem pronta. Enquanto alguma imagem está sendo
135
+ enviada, o botão de enviar espera; uma imagem com falha também bloqueia o envio até ser removida ou
136
+ reenviada pelo aplicativo, para que nada do que está na faixa fique de fora sem aviso. Quem usa leitor
137
+ de tela ouve quando uma imagem fica pronta ou falha, e, se o botão de anexar ficar desabilitado ao
138
+ atingir o limite, o foco volta ao campo.
139
+
140
+ A triagem aceita PNG, JPEG e WebP, até quatro imagens por mensagem; `accept` e `max` mudam esses
141
+ limites. `accept` é um subconjunto desses três formatos, comparado com o tipo exato do arquivo, sem
142
+ curinga como `image/*`. O que fica de fora é explicado acima do campo, como “Cada mensagem aceita até 4 imagens.”.
143
+ Para dar outra explicação, passe `onReject`: o composer deixa de mostrar o aviso e entrega os
144
+ arquivos recusados, com o motivo, ao aplicativo.
145
+
146
+ Colar texto continua colando texto, mesmo quando a área de transferência também traz uma imagem, como
147
+ ao copiar células de uma planilha. Uma imagem colada sem texto, como um print, recebe um nome que a
148
+ pessoa reconhece na conversa: “Imagem colada 1.png”. A numeração não volta atrás: remover a primeira e
149
+ colar outra dá “Imagem colada 3.png”, e nunca duas imagens com o mesmo nome no mesmo composer.
150
+
151
+ O soltar que o composer recebe não sobe para os contêineres do aplicativo. Dentro do
152
+ [Chat](/ui/chat#imagens-na-conversa), quem recebe o soltar é a conversa inteira.
153
+
154
+ ## Preparar a imagem antes do envio
155
+
156
+ `prepareImage(file, options)` deixa a imagem pronta para ir à conversa. Ela lê só o cabeçalho do
157
+ arquivo para conferir o formato, pelos bytes e não pela extensão, e as dimensões; uma foto acima de
158
+ `maxPixels` é recusada antes de ser aberta, porque decodificá-la ocuparia centenas de megabytes de
159
+ memória. Depois, reduz a imagem até o lado maior caber em `maxEdge`, mantendo a proporção e a
160
+ orientação gravada pela câmera, de modo que a foto tirada com o celular em pé chega em pé; e codifica
161
+ a imagem de novo, o que também descarta metadados como a localização. PNG continua PNG quando cabe em `maxBytes`; JPEG, WebP e o PNG que passa do teto viram
162
+ JPEG com qualidade 0,85, sobre fundo branco.
163
+
164
+ ```ts
165
+ const result = await prepareImage(file, { maxEdge: 1568, maxBytes: 3 * 1024 * 1024 })
166
+ if (result.ok) {
167
+ await upload(result.file) // result.mediaType, result.width, result.height, result.size
168
+ } else {
169
+ showError(result.message) // "Imagens animadas não são aceitas. Envie uma imagem parada…"
170
+ }
171
+ ```
172
+
173
+ A função não lança erro. Uma imagem fora dos formatos aceitos, animada, ilegível, com pixels demais ou
174
+ que continua acima do teto depois de reduzida volta como `{ ok: false, reason, message }`, com a explicação pronta para a
175
+ pessoa. Os padrões repetem os tetos do primeiro consumidor; passe os do seu produto, de preferência os
176
+ mesmos que o servidor confere.
177
+
66
178
  ## Propriedades de Composer
67
179
 
68
180
  | Propriedade | Tipo | Padrão | Descrição |
69
181
  |---|---|---|---|
70
182
  | `value` | `string` | | Texto controlado; o dono do estado é quem compõe. |
71
183
  | `onChange` | `(value: string) => void` | | Recebe cada alteração do texto. |
72
- | `onSubmit` | `() => void` | | Chamado por Enter (sem Shift) ou pelo botão de enviar, somente quando há texto e nem `busy` nem `submitDisabled` estão ativos. |
184
+ | `onSubmit` | `() => void` | | Chamado por Enter (sem Shift) ou pelo botão de enviar, somente quando há texto ou imagem pronta, nenhuma imagem está sendo enviada ou com falha, e nem `busy` nem `submitDisabled` estão ativos. |
73
185
  | `onHistoryPrevious` / `onHistoryNext` | `() => boolean` | | Navegação por ↑/↓ no histórico do dono; retorne `true` quando a tecla foi consumida. |
74
186
  | `busy` | `boolean` | `false` | Bloqueia o envio enquanto a operação anterior corre e troca o ícone de enviar por um spinner. O campo continua editável. |
75
187
  | `submitDisabled` | `boolean` | `false` | Bloqueia o envio por uma condição do consumidor, como um seletor ainda sem escolha. |
@@ -77,4 +189,35 @@ render(
77
189
  | `rows` | `number` | `1` | Linhas iniciais do campo, que cresce com o conteúdo até `10rem`. |
78
190
  | `autoFocus` | `boolean` | | Foca o campo ao montar. |
79
191
  | `actions` | `ReactNode` | | Controles discretos à esquerda do botão de enviar; com eles, o composer passa a ter duas linhas. |
192
+ | `attachments` | `ComposerAttachments` | | Liga as imagens anexadas: botão **Anexar imagem**, faixa acima do campo, colar e soltar. Com ele, o composer passa a ter duas linhas. |
80
193
  | `className` | `string` | | Classes aplicadas à moldura do composer. |
194
+
195
+ ## Propriedades de ComposerAttachments
196
+
197
+ | Propriedade | Tipo | Padrão | Descrição |
198
+ |---|---|---|---|
199
+ | `items` | `ComposerAttachment[]` | | Imagens anexadas à mensagem que está sendo escrita, na ordem da faixa. |
200
+ | `onAddFiles` | `(files: File[]) => void` | | Recebe os arquivos aceitos pela triagem, vindos do botão, do colar ou do soltar. |
201
+ | `onRemove` | `(id: string) => void` | | A pessoa removeu uma imagem da faixa; o foco volta ao campo. |
202
+ | `max` | `number` | `4` | Limite de imagens por mensagem. O botão de anexar fica desabilitado quando ele é atingido. |
203
+ | `accept` | `readonly AiImageMediaType[]` | PNG, JPEG e WebP | Formatos aceitos pelo botão e pela triagem, comparados com o tipo exato do arquivo. |
204
+ | `onReject` | `(rejections: ComposerFileRejection[]) => void` | | Recebe os arquivos recusados, com o motivo `type` ou `limit`. Sem ele, o composer explica a recusa acima do campo. |
205
+
206
+ `ComposerAttachment` é `{ id, name, status, previewUrl?, error? }`. `status` vale `uploading`,
207
+ `ready` ou `failed`; `previewUrl` é o endereço da miniatura e `error` é o motivo mostrado quando a
208
+ imagem falha.
209
+
210
+ ## Opções de prepareImage
211
+
212
+ | Opção | Tipo | Padrão | Descrição |
213
+ |---|---|---|---|
214
+ | `maxEdge` | `number` | `1568` | Lado maior, em pixels, depois da redução. Imagens menores não são ampliadas. |
215
+ | `maxBytes` | `number` | 3 MB | Teto da imagem preparada. Acima dele, o PNG vira `lossyType`; o que continua acima é recusado. |
216
+ | `accept` | `readonly AiImageMediaType[]` | PNG, JPEG e WebP | Formatos aceitos na entrada, conferidos pelos primeiros bytes. |
217
+ | `lossyType` | `'image/jpeg' \| 'image/webp'` | `'image/jpeg'` | Formato de JPEG, WebP e do PNG que passa do teto. |
218
+ | `quality` | `number` | `0.85` | Qualidade da codificação com perdas, de 0 a 1. |
219
+ | `maxPixels` | `number` | `40_000_000` | Teto de largura × altura da imagem original, lido do cabeçalho antes de decodificar. |
220
+
221
+ O resultado é `{ ok: true, file, mediaType, width, height, size, resized }` ou
222
+ `{ ok: false, reason, message }`, em que `reason` vale `unsupported-type`, `animated`,
223
+ `too-many-pixels`, `too-large` ou `unreadable`.
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Preparação de imagem no navegador antes do envio (ADR 0058, seção 6).
3
+ *
4
+ * Lê só o cabeçalho para conferir formato, animação e dimensões — e recusar, antes de decodificar,
5
+ * a imagem grande demais para a memória do navegador —, reduz o lado maior respeitando a orientação
6
+ * gravada pela câmera e codifica de novo sempre, o que também descarta metadados como a localização.
7
+ * Os tetos vêm do app; os padrões repetem os da ADR 0122 da Grand Brasil, primeiro consumidor.
8
+ */
9
+
10
+ import type { AiImageMediaType } from '../../core/index.ts'
11
+
12
+ /** Os formatos que a mensagem da IA aceita, na ordem em que são apresentados à pessoa. */
13
+ export const IMAGE_MEDIA_TYPES: readonly AiImageMediaType[] = ['image/png', 'image/jpeg', 'image/webp']
14
+
15
+ /** Padrões de `prepareImage`. O app deve passar os próprios tetos: o utilitário não decide teto
16
+ * de produto. */
17
+ export const PREPARE_IMAGE_DEFAULTS = {
18
+ /** Lado maior, em pixels, depois da redução (o limite de lado do nível padrão do Claude). */
19
+ maxEdge: 1568,
20
+ /** Teto por imagem depois da redução, em bytes (3 MB). */
21
+ maxBytes: 3 * 1024 * 1024,
22
+ /** Qualidade da codificação JPEG ou WebP. */
23
+ quality: 0.85,
24
+ /** Teto de pixels da imagem original (40 megapixels), conferido pelo cabeçalho antes de
25
+ * decodificar: uma foto muito grande ocupa centenas de megabytes de memória ao abrir. */
26
+ maxPixels: 40_000_000,
27
+ } as const
28
+
29
+ export interface PrepareImageOptions {
30
+ /** Lado maior, em pixels, depois da redução. Padrão: 1568. */
31
+ maxEdge?: number
32
+ /** Teto em bytes da imagem preparada. Padrão: 3 MB. */
33
+ maxBytes?: number
34
+ /** Formatos aceitos na entrada. Padrão: PNG, JPEG e WebP. */
35
+ accept?: readonly AiImageMediaType[]
36
+ /** Formato da codificação com perdas, usado para JPEG, WebP e o PNG que passa do teto.
37
+ * Padrão: `image/jpeg`. */
38
+ lossyType?: 'image/jpeg' | 'image/webp'
39
+ /** Qualidade da codificação com perdas, de 0 a 1. Padrão: 0,85. */
40
+ quality?: number
41
+ /** Teto de pixels (largura × altura) da imagem original, lido do cabeçalho antes de
42
+ * decodificar. Padrão: 40 megapixels. */
43
+ maxPixels?: number
44
+ }
45
+
46
+ export type PrepareImageRejection =
47
+ | 'unsupported-type'
48
+ | 'animated'
49
+ | 'too-many-pixels'
50
+ | 'too-large'
51
+ | 'unreadable'
52
+
53
+ export interface PreparedImage {
54
+ ok: true
55
+ /** A imagem preparada, com o nome ajustado à extensão do formato final. */
56
+ file: File
57
+ mediaType: AiImageMediaType
58
+ width: number
59
+ height: number
60
+ /** Tamanho em bytes. */
61
+ size: number
62
+ /** A imagem foi reduzida para caber em `maxEdge`. */
63
+ resized: boolean
64
+ }
65
+
66
+ export interface RejectedImage {
67
+ ok: false
68
+ reason: PrepareImageRejection
69
+ /** Explicação em pt-BR para a pessoa, pronta para a interface. */
70
+ message: string
71
+ }
72
+
73
+ export type PrepareImageResult = PreparedImage | RejectedImage
74
+
75
+ const TYPE_LABELS: Record<string, string> = {
76
+ 'image/png': 'PNG',
77
+ 'image/jpeg': 'JPEG',
78
+ 'image/webp': 'WebP',
79
+ 'image/gif': 'GIF',
80
+ }
81
+
82
+ const EXTENSIONS: Record<AiImageMediaType, string> = {
83
+ 'image/png': '.png',
84
+ 'image/jpeg': '.jpg',
85
+ 'image/webp': '.webp',
86
+ }
87
+
88
+ /** "PNG, JPEG ou WebP" — a lista de formatos aceitos como a pessoa a lê. */
89
+ export function imageTypeList(types: readonly string[]): string {
90
+ const labels = types.map((type) => TYPE_LABELS[type] ?? type.replace(/^image\//, '').toUpperCase())
91
+ if (labels.length <= 1) return labels.join('')
92
+ return `${labels.slice(0, -1).join(', ')} ou ${labels[labels.length - 1]}`
93
+ }
94
+
95
+ /** "3 MB", "1,5 MB" — tamanho em megabytes como a pessoa o lê. */
96
+ export function formatMegabytes(bytes: number): string {
97
+ const megabytes = bytes / (1024 * 1024)
98
+ return `${megabytes.toLocaleString('pt-BR', { maximumFractionDigits: 1 })} MB`
99
+ }
100
+
101
+ /** Quanto do início do arquivo é lido de uma vez. Basta para o cabeçalho de quase toda imagem;
102
+ * o que estiver além (um bloco de metadados grande antes do quadro) é lido por partes. */
103
+ const HEAD_BYTES = 64 * 1024
104
+
105
+ /** Leitura por faixa, sem carregar o arquivo inteiro: o início fica em memória, o resto vem por
106
+ * `slice` só quando a varredura dos blocos chega lá. */
107
+ function byteReader(file: Blob, head: Uint8Array): (offset: number, length: number) => Promise<Uint8Array> {
108
+ return async (offset, length) => {
109
+ if (offset + length <= head.length) return head.subarray(offset, offset + length)
110
+ return new Uint8Array(await file.slice(offset, offset + length).arrayBuffer())
111
+ }
112
+ }
113
+
114
+ const ascii = (bytes: Uint8Array, start: number, end: number): string =>
115
+ String.fromCharCode(...bytes.subarray(start, end))
116
+ const uint16be = (b: Uint8Array, i: number): number => (b[i] << 8) | b[i + 1]
117
+ const uint32be = (b: Uint8Array, i: number): number => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0
118
+ const uint24le = (b: Uint8Array, i: number): number => b[i] | (b[i + 1] << 8) | (b[i + 2] << 16)
119
+
120
+ interface ImageHeader {
121
+ /** Formato pelos primeiros bytes; `null` quando não é um formato conhecido. */
122
+ type: string | null
123
+ /** Dimensões gravadas no cabeçalho, antes de aplicar a orientação; ausentes quando ilegíveis. */
124
+ width?: number
125
+ height?: number
126
+ animated: boolean
127
+ }
128
+
129
+ /** O formato real da imagem, pelos primeiros bytes. */
130
+ function sniffType(bytes: Uint8Array): string | null {
131
+ if (bytes.length >= 8 && bytes[0] === 0x89 && ascii(bytes, 1, 4) === 'PNG') return 'image/png'
132
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
133
+ if (bytes.length >= 12 && ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 12) === 'WEBP') return 'image/webp'
134
+ if (bytes.length >= 4 && ascii(bytes, 0, 4) === 'GIF8') return 'image/gif'
135
+ return null
136
+ }
137
+
138
+ /** PNG: dimensões no `IHDR`, o primeiro bloco; animação (APNG) é o bloco `acTL` antes do `IDAT`. */
139
+ async function inspectPng(head: Uint8Array, read: ReturnType<typeof byteReader>, size: number): Promise<ImageHeader> {
140
+ if (head.length < 24 || ascii(head, 12, 16) !== 'IHDR') return { type: 'image/png', animated: false }
141
+ const header: ImageHeader = { type: 'image/png', width: uint32be(head, 16), height: uint32be(head, 20), animated: false }
142
+ for (let offset = 8; offset + 8 <= size; ) {
143
+ const chunk = await read(offset, 8)
144
+ if (chunk.length < 8) break
145
+ const type = ascii(chunk, 4, 8)
146
+ if (type === 'acTL') return { ...header, animated: true }
147
+ if (type === 'IDAT' || type === 'IEND') break
148
+ offset += 12 + uint32be(chunk, 0)
149
+ }
150
+ return header
151
+ }
152
+
153
+ /** JPEG: dimensões no primeiro marcador de quadro (SOF0 a SOF15, sem DHT, JPG e DAC), depois dos
154
+ * segmentos de aplicação, como o EXIF. */
155
+ async function inspectJpeg(read: ReturnType<typeof byteReader>, size: number): Promise<ImageHeader> {
156
+ let offset = 2
157
+ for (let guard = 0; guard < 4096 && offset + 4 <= size; guard++) {
158
+ const segment = await read(offset, 9)
159
+ if (segment[0] !== 0xff) break
160
+ const marker = segment[1]
161
+ if (marker === 0xff) {
162
+ offset += 1 // Bytes de preenchimento antes do marcador.
163
+ continue
164
+ }
165
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
166
+ offset += 2
167
+ continue
168
+ }
169
+ if (marker === 0xd9 || marker === 0xda) break // Fim, ou dados da imagem sem quadro declarado.
170
+ if (segment.length < 4) break
171
+ const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc
172
+ if (isFrame && segment.length >= 9) {
173
+ return { type: 'image/jpeg', height: uint16be(segment, 5), width: uint16be(segment, 7), animated: false }
174
+ }
175
+ const length = uint16be(segment, 2)
176
+ if (length < 2) break
177
+ offset += 2 + length
178
+ }
179
+ return { type: 'image/jpeg', animated: false }
180
+ }
181
+
182
+ /** WebP: dimensões no primeiro bloco (`VP8 `, `VP8L` ou `VP8X`); animação é o bit do `VP8X`. */
183
+ function inspectWebp(head: Uint8Array): ImageHeader {
184
+ const chunk = head.length >= 16 ? ascii(head, 12, 16) : ''
185
+ if (chunk === 'VP8X' && head.length >= 30) {
186
+ return {
187
+ type: 'image/webp',
188
+ width: 1 + uint24le(head, 24),
189
+ height: 1 + uint24le(head, 27),
190
+ animated: (head[20] & 0x02) !== 0,
191
+ }
192
+ }
193
+ if (chunk === 'VP8 ' && head.length >= 30) {
194
+ return {
195
+ type: 'image/webp',
196
+ width: (head[26] | (head[27] << 8)) & 0x3fff,
197
+ height: (head[28] | (head[29] << 8)) & 0x3fff,
198
+ animated: false,
199
+ }
200
+ }
201
+ if (chunk === 'VP8L' && head.length >= 25 && head[20] === 0x2f) {
202
+ return {
203
+ type: 'image/webp',
204
+ width: 1 + (head[21] | ((head[22] & 0x3f) << 8)),
205
+ height: 1 + ((head[22] >> 6) | (head[23] << 2) | ((head[24] & 0x0f) << 10)),
206
+ animated: false,
207
+ }
208
+ }
209
+ return { type: 'image/webp', animated: false }
210
+ }
211
+
212
+ async function inspectHeader(file: Blob): Promise<ImageHeader> {
213
+ const head = new Uint8Array(await file.slice(0, HEAD_BYTES).arrayBuffer())
214
+ const type = sniffType(head)
215
+ const read = byteReader(file, head)
216
+ if (type === 'image/png') return inspectPng(head, read, file.size)
217
+ if (type === 'image/jpeg') return inspectJpeg(read, file.size)
218
+ if (type === 'image/webp') return inspectWebp(head)
219
+ return { type, animated: false }
220
+ }
221
+
222
+ /** "48 megapixels", "40,5 megapixels". */
223
+ function formatMegapixels(pixels: number): string {
224
+ return `${(pixels / 1_000_000).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} megapixels`
225
+ }
226
+
227
+ function reject(reason: PrepareImageRejection, message: string): RejectedImage {
228
+ return { ok: false, reason, message }
229
+ }
230
+
231
+ interface DecodedImage {
232
+ source: CanvasImageSource
233
+ width: number
234
+ height: number
235
+ release: () => void
236
+ }
237
+
238
+ /** Decodifica aplicando a orientação gravada pela câmera (EXIF): a foto tirada com o celular em
239
+ * pé sai em pé, com largura e altura já trocadas. */
240
+ async function decode(file: Blob): Promise<DecodedImage> {
241
+ if (typeof globalThis.createImageBitmap === 'function') {
242
+ let bitmap: ImageBitmap
243
+ try {
244
+ bitmap = await globalThis.createImageBitmap(file, { imageOrientation: 'from-image' })
245
+ } catch (error) {
246
+ // Navegador anterior ao valor `from-image` recusa a opção com TypeError; o padrão dele
247
+ // também aplica a orientação do arquivo.
248
+ if (!(error instanceof TypeError)) throw error
249
+ bitmap = await globalThis.createImageBitmap(file)
250
+ }
251
+ return { source: bitmap, width: bitmap.width, height: bitmap.height, release: () => bitmap.close() }
252
+ }
253
+ const url = URL.createObjectURL(file)
254
+ try {
255
+ const image = new Image()
256
+ // `from-image` é o padrão do CSS: dimensões naturais e `drawImage` seguem a orientação.
257
+ image.style.imageOrientation = 'from-image'
258
+ image.src = url
259
+ await image.decode()
260
+ return { source: image, width: image.naturalWidth, height: image.naturalHeight, release: () => {} }
261
+ } finally {
262
+ URL.revokeObjectURL(url)
263
+ }
264
+ }
265
+
266
+ type Encoder = (type: AiImageMediaType, quality: number) => Promise<Blob>
267
+
268
+ /** Prepara o quadro na dimensão final e devolve o codificador. O desenho acontece na primeira
269
+ * codificação e só se repete quando o fundo muda: JPEG não tem transparência e, sem fundo, o
270
+ * transparente viraria preto. */
271
+ function canvasFor(image: DecodedImage, width: number, height: number): Encoder {
272
+ const canvas: OffscreenCanvas | HTMLCanvasElement =
273
+ typeof globalThis.OffscreenCanvas === 'function'
274
+ ? new OffscreenCanvas(width, height)
275
+ : Object.assign(document.createElement('canvas'), { width, height })
276
+ const context = canvas.getContext('2d') as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null
277
+ if (context === null) throw new Error('canvas 2d indisponível')
278
+ let painted: 'transparent' | 'white' | null = null
279
+ const paint = (background: 'transparent' | 'white'): void => {
280
+ if (painted === background) return
281
+ context.clearRect(0, 0, width, height)
282
+ if (background === 'white') {
283
+ context.fillStyle = '#ffffff'
284
+ context.fillRect(0, 0, width, height)
285
+ }
286
+ context.imageSmoothingQuality = 'high'
287
+ context.drawImage(image.source, 0, 0, width, height)
288
+ painted = background
289
+ }
290
+ return async (type, quality) => {
291
+ paint(type === 'image/jpeg' ? 'white' : 'transparent')
292
+ const blob =
293
+ 'convertToBlob' in canvas
294
+ ? await canvas.convertToBlob({ type, quality })
295
+ : await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, type, quality))
296
+ // Um navegador que não codifica o formato pedido devolve PNG em silêncio.
297
+ if (blob === null || blob.type !== type) throw new Error(`codificação ${type} indisponível`)
298
+ return blob
299
+ }
300
+ }
301
+
302
+ function renamed(name: string, type: AiImageMediaType): string {
303
+ const base = name.replace(/\.[^./\\]+$/, '') || 'imagem'
304
+ return `${base}${EXTENSIONS[type]}`
305
+ }
306
+
307
+ /**
308
+ * Prepara uma imagem para ir à conversa: confere o formato pelos bytes, recusa animação, reduz o
309
+ * lado maior até `maxEdge` e codifica de novo. PNG continua PNG quando cabe em `maxBytes`; o resto
310
+ * vira `lossyType` (JPEG por padrão) com `quality`. O que continua acima do teto é recusado.
311
+ *
312
+ * Nunca lança: uma falha vira `{ ok: false, reason, message }`, com a mensagem pronta para a pessoa.
313
+ */
314
+ export async function prepareImage(file: Blob & { name?: string }, options: PrepareImageOptions = {}): Promise<PrepareImageResult> {
315
+ const maxEdge = options.maxEdge ?? PREPARE_IMAGE_DEFAULTS.maxEdge
316
+ const maxBytes = options.maxBytes ?? PREPARE_IMAGE_DEFAULTS.maxBytes
317
+ const accept = options.accept ?? IMAGE_MEDIA_TYPES
318
+ const lossyType = options.lossyType ?? 'image/jpeg'
319
+ const quality = options.quality ?? PREPARE_IMAGE_DEFAULTS.quality
320
+ const maxPixels = options.maxPixels ?? PREPARE_IMAGE_DEFAULTS.maxPixels
321
+ const unreadable = (): RejectedImage =>
322
+ reject('unreadable', 'Não foi possível abrir esta imagem. Confira o arquivo ou tente outra imagem.')
323
+
324
+ let header: ImageHeader
325
+ try {
326
+ header = await inspectHeader(file)
327
+ } catch {
328
+ return unreadable()
329
+ }
330
+
331
+ const sniffed = header.type
332
+ if (sniffed === null || !(accept as readonly string[]).includes(sniffed)) {
333
+ const found = sniffed !== null ? `Esta imagem está em ${TYPE_LABELS[sniffed] ?? sniffed}, formato não aceito. ` : ''
334
+ return reject('unsupported-type', `${found}Envie uma imagem ${imageTypeList(accept)}.`)
335
+ }
336
+ const sourceType = sniffed as AiImageMediaType
337
+ if (header.animated) {
338
+ return reject('animated', 'Imagens animadas não são aceitas. Envie uma imagem parada, como um print da tela.')
339
+ }
340
+ if (header.width === undefined || header.height === undefined || header.width <= 0 || header.height <= 0) {
341
+ return unreadable()
342
+ }
343
+ const pixels = header.width * header.height
344
+ if (pixels > maxPixels) {
345
+ return reject(
346
+ 'too-many-pixels',
347
+ `A imagem tem ${formatMegapixels(pixels)}, acima do limite de ${formatMegapixels(maxPixels)}. ` +
348
+ 'Reduza a imagem ou envie um recorte antes de anexar.',
349
+ )
350
+ }
351
+
352
+ let image: DecodedImage
353
+ try {
354
+ image = await decode(file)
355
+ } catch {
356
+ return unreadable()
357
+ }
358
+
359
+ try {
360
+ if (image.width <= 0 || image.height <= 0) return unreadable()
361
+ const scale = Math.min(1, maxEdge / Math.max(image.width, image.height))
362
+ const width = Math.max(1, Math.round(image.width * scale))
363
+ const height = Math.max(1, Math.round(image.height * scale))
364
+ const encode = canvasFor(image, width, height)
365
+
366
+ // PNG continua PNG quando cabe no teto; o resto, e o PNG que passa dele, vira `lossyType`.
367
+ let mediaType: AiImageMediaType = 'image/png'
368
+ let blob = sourceType === 'image/png' ? await encode('image/png', 1) : null
369
+ if (blob === null || blob.size > maxBytes) {
370
+ mediaType = lossyType
371
+ blob = await encode(mediaType, quality)
372
+ }
373
+ if (blob.size > maxBytes) {
374
+ return reject(
375
+ 'too-large',
376
+ `A imagem continua com mais de ${formatMegabytes(maxBytes)} depois de reduzida. Recorte a imagem ou envie uma versão menor.`,
377
+ )
378
+ }
379
+ const prepared = new File([blob], renamed(file.name ?? 'imagem', mediaType), { type: mediaType })
380
+ return { ok: true, file: prepared, mediaType, width, height, size: prepared.size, resized: scale < 1 }
381
+ } catch {
382
+ return unreadable()
383
+ } finally {
384
+ image.release()
385
+ }
386
+ }
package/src/ui/meta.ts CHANGED
@@ -87,7 +87,7 @@ export const componentMeta = {
87
87
  name: "chat",
88
88
  ancestry: "opus",
89
89
  whenToUse:
90
- "Monte uma conversa completa com mensagens, composição, carregamento e rolagem automática. A propriedade `send` aceita resposta integral ou streaming de eventos; use Composer quando precisar apenas da entrada de texto. Defina uma altura para o contêiner.",
90
+ "Monte uma conversa completa com mensagens, composição, carregamento e rolagem automática. A propriedade `send` aceita resposta integral ou streaming de eventos; no modo controlado, `composerAttachments` permite anexar imagens e o transcript mostra as que o aplicativo guardou. Use Composer quando precisar apenas da entrada de texto. Defina uma altura para o contêiner.",
91
91
  },
92
92
  checkbox: {
93
93
  name: "checkbox",
@@ -111,7 +111,7 @@ export const componentMeta = {
111
111
  name: "composer",
112
112
  ancestry: "opus",
113
113
  whenToUse:
114
- "Receba texto para envio sem montar uma conversa completa. Enter envia, Shift+Enter quebra a linha e `actions` acrescenta uma faixa para seletores relacionados. O componente é controlado; use Chat quando também precisar de mensagens e estado da conversa.",
114
+ "Receba texto para envio sem montar uma conversa completa. Enter envia, Shift+Enter quebra a linha e `actions` acrescenta uma faixa para seletores relacionados. `attachments` permite anexar imagens pelo botão, colando ou soltando, com o estado mantido pelo aplicativo; `prepareImage` reduz a imagem antes do envio. O componente é controlado; use Chat quando também precisar de mensagens e estado da conversa.",
115
115
  },
116
116
  copyable: {
117
117
  name: "copyable",
package/src/ui/react.tsx CHANGED
@@ -59,13 +59,30 @@ export type {
59
59
  ChatProps,
60
60
  ChatMessage,
61
61
  ChatArtifact,
62
+ ChatAttachment,
63
+ ChatAttachmentStatus,
62
64
  ChatTranscriptItem,
63
65
  ChatTranscriptMessage,
64
66
  } from "./components/primitives/chat.tsx";
65
67
  export { Ask } from "./components/primitives/ask.tsx";
66
68
  export type { AskProps } from "./components/primitives/ask.tsx";
67
69
  export { Composer } from "./components/primitives/composer.tsx";
68
- export type { ComposerProps } from "./components/primitives/composer.tsx";
70
+ export type {
71
+ ComposerProps,
72
+ ComposerAttachment,
73
+ ComposerAttachmentStatus,
74
+ ComposerAttachments,
75
+ ComposerFileRejection,
76
+ } from "./components/primitives/composer.tsx";
77
+ // Preparação da imagem no navegador antes do envio (ADR 0058).
78
+ export { prepareImage } from "./lib/prepare-image.ts";
79
+ export type {
80
+ PrepareImageOptions,
81
+ PrepareImageRejection,
82
+ PrepareImageResult,
83
+ PreparedImage,
84
+ RejectedImage,
85
+ } from "./lib/prepare-image.ts";
69
86
  export { Copyable } from "./components/primitives/copyable.tsx";
70
87
  export type { CopyableProps } from "./components/primitives/copyable.tsx";
71
88
  export {