@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.
- package/CHANGELOG.md +74 -0
- package/docs/adr/0058-mensagem-da-ia-aceita-imagem-e-o-chat-recebe-anexos.md +201 -0
- package/package.json +1 -1
- package/src/ai/drivers/anthropic.ts +49 -9
- package/src/ai/index.ts +5 -2
- package/src/core/ai-message.ts +20 -0
- package/src/core/index.ts +5 -0
- package/src/core/types.ts +25 -2
- package/src/ui/components/primitives/chat.tsx +179 -22
- package/src/ui/components/primitives/composer.tsx +397 -7
- package/src/ui/docs/content/ai.md +46 -0
- package/src/ui/docs/content/chat.md +97 -2
- package/src/ui/docs/content/composer.md +144 -1
- package/src/ui/lib/prepare-image.ts +386 -0
- package/src/ui/meta.ts +2 -2
- package/src/ui/react.tsx +18 -1
|
@@ -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;
|
|
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 {
|
|
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 {
|