@softize/opus 25.6.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 +108 -1
- package/docs/adr/0017-ui-assumes-a-fixed-desktop-layout.md +19 -0
- package/docs/adr/0057-doc-browser-permite-busca-no-chrome-do-consumidor.md +152 -0
- package/docs/adr/0058-mensagem-da-ia-aceita-imagem-e-o-chat-recebe-anexos.md +201 -0
- package/docs/adr/README.md +12 -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/patterns/sidebar.tsx +32 -11
- package/src/ui/components/primitives/chat.tsx +179 -22
- package/src/ui/components/primitives/composer.tsx +397 -7
- package/src/ui/docs/DocBrowser.tsx +404 -69
- package/src/ui/docs/DocCatalog.tsx +255 -0
- package/src/ui/docs/DocHeader.tsx +74 -0
- package/src/ui/docs/browser.tsx +10 -1
- package/src/ui/docs/changelog.tsx +16 -41
- package/src/ui/docs/content/actions.md +3 -3
- 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/docs/content/customization.md +5 -1
- package/src/ui/docs/content/documentation-portal.md +190 -0
- package/src/ui/docs/content/getting-started.md +129 -87
- package/src/ui/docs/content/installation.md +94 -0
- package/src/ui/docs/content/mcp.md +2 -2
- package/src/ui/docs/content/overview.md +58 -0
- package/src/ui/docs/content/sidebar.md +4 -0
- package/src/ui/docs/index.ts +8 -1
- package/src/ui/docs/markdown.tsx +5 -3
- package/src/ui/docs/records.ts +144 -0
- package/src/ui/docs/registry.tsx +149 -59
- package/src/ui/docs/types.ts +2 -0
- package/src/ui/lib/prepare-image.ts +386 -0
- package/src/ui/meta.ts +2 -2
- package/src/ui/react.tsx +18 -1
|
@@ -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 {
|