@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 CHANGED
@@ -7,6 +7,80 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 26.0.0 — 2026-10-09
11
+
12
+ A conversa com a IA pode levar imagens (ADR 0058). Uma mensagem passa a aceitar texto e imagem
13
+ juntos, o driver Anthropic envia as imagens ao modelo, e o `Composer` e o `Chat` recebem imagens
14
+ coladas, soltas ou escolhidas pelo botão **Anexar imagem**. O pacote não guarda imagem: enviar,
15
+ guardar, expirar e mostrar de novo são do aplicativo, que mantém o estado dos anexos.
16
+
17
+ - **Mensagem com imagem.** `AiMessage.content` aceita `string | AiContentPart[]`, em que cada parte é
18
+ `{ type: 'text', text }` ou `{ type: 'image', mediaType, data }`, com PNG, JPEG ou WebP em base64.
19
+ Os tipos `AiContentPart` e `AiImageMediaType` saem de `@softize/opus` e de `@softize/opus/ai`.
20
+ - **`aiMessageText(message)`.** Devolve o texto de uma mensagem: o próprio `content` quando é texto,
21
+ ou as partes de texto unidas por uma linha em branco, sem as imagens. Sai de `@softize/opus` e de
22
+ `@softize/opus/ai`.
23
+ - **Driver Anthropic.** `run` e `runStream` convertem cada parte no bloco da API (`text`, ou `image`
24
+ com `source.type: 'base64'`). Uma parte desconhecida, uma lista de partes vazia, uma parte de texto
25
+ vazia, uma imagem fora dos três formatos ou uma imagem numa mensagem `assistant` fazem a chamada
26
+ lançar erro antes de chegar ao provedor, dizendo qual mensagem e qual parte.
27
+ `complete` e `extract` continuam recebendo texto.
28
+ - **`Composer` com imagens.** A propriedade `attachments` liga o botão **Anexar imagem**, a faixa
29
+ acima do campo (miniatura, nome, estado `uploading`, `ready` ou `failed` e remover), colar e
30
+ soltar. A triagem aceita PNG, JPEG e WebP, até 4 por mensagem (`max` muda o limite, e `accept`,
31
+ tipado como `readonly AiImageMediaType[]`, escolhe um subconjunto dos três, sem curinga), e
32
+ explica a recusa acima do campo, ou a entrega a `onReject`. O envio passa com texto ou com uma
33
+ imagem pronta; espera enquanto alguma imagem está em envio e fica bloqueado com uma imagem em
34
+ falha, até ela ser removida ou reenviada. Uma região viva anuncia a imagem que fica pronta ou
35
+ falha, e o foco volta ao campo quando o botão de anexar fica desabilitado pelo limite. Colar
36
+ texto continua colando texto; a imagem colada sem texto recebe "Imagem colada 1.png", com um
37
+ número que não se repete no mesmo composer. O soltar que o composer consome não sobe para os
38
+ contêineres do aplicativo.
39
+ - **`Chat` com imagens.** No modo controlado, `composerAttachments` liga as imagens no composer e o
40
+ soltar sobre a conversa inteira; `onSend(text, attachments)` recebe as imagens prontas, e a
41
+ mensagem pode ter só imagem. Sem `composerAttachments`, `onSend` continua sendo chamado só com o
42
+ texto. O item `user` do transcript aceita `attachments` (`ChatAttachment`,
43
+ com `id`, `name`, `mediaType`, dimensões, `status: 'available' | 'expired'` e o `url` dado pelo
44
+ aplicativo): a fala mostra as miniaturas acima do texto, com o nome como texto alternativo, e a
45
+ imagem expirada como um cartão com o nome e "Imagem expirada". O modo autogerenciado (`send`) não
46
+ aceita imagem. Com `composerAttachments`, o arraste de arquivos sobre o Chat é consumido nele
47
+ (`stopPropagation` em `dragenter`, `dragover`, `dragleave` e `drop`) e não chega a um contêiner do
48
+ aplicativo com o próprio soltar; sem ele, o arraste segue como antes.
49
+ - **`prepareImage(file, options)`**, de `@softize/opus/ui/react`, prepara a imagem no navegador antes
50
+ do envio: lê só o cabeçalho para conferir o formato pelos bytes e as dimensões, recusa GIF, SVG,
51
+ imagem animada e, antes de decodificar, a imagem acima de `maxPixels` (padrão 40 megapixels);
52
+ reduz o lado maior a `maxEdge` (padrão 1568) respeitando a orientação EXIF
53
+ (`imageOrientation: 'from-image'`) e codifica de novo, descartando metadados. PNG continua PNG quando cabe em
54
+ `maxBytes` (padrão 3 MB); o resto vira JPEG com qualidade 0,85. Devolve o arquivo com tipo,
55
+ dimensões e tamanho, ou a recusa com o motivo e a mensagem em pt-BR.
56
+
57
+ Sem as propriedades novas, o `Composer` e o `Chat` se comportam como antes.
58
+
59
+ ### Breaking
60
+
61
+ **`AiMessage.content` passa a ser `string | AiContentPart[]`.** Código que lê `content` como texto
62
+ deixa de compilar: `message.content.length`, `message.content.trim()`, uma função que recebe
63
+ `string`, um driver de IA próprio que repassa `content` ao provedor. Migração: leia o texto com
64
+ `aiMessageText(message)` e, num driver próprio, converta cada parte para o formato do provedor e
65
+ recuse com erro a parte que ele não conhece.
66
+
67
+ ```ts
68
+ import { aiMessageText } from '@softize/opus/ai'
69
+
70
+ const title = aiMessageText(message).slice(0, 80) // antes: message.content.slice(0, 80)
71
+ ```
72
+
73
+ O compilador não aponta o texto montado por interpolação (`${message.content}`) nem o que passa por
74
+ `as`: com uma mensagem com imagem, esses pontos produzem "[object Object]" sem erro. Procure-os à
75
+ parte (`grep -rn '\${[^}]*content' src`) e troque por `aiMessageText`. Um `ChatMessage` do `Chat`
76
+ autogerenciado continua com `content: string` e segue atribuível a `AiMessage`.
77
+
78
+ **`Chat.onSend` ganha o segundo parâmetro opcional `attachments?: ComposerAttachment[]`.** Em tempo de
79
+ execução nada muda sem `composerAttachments`: a chamada continua `onSend(text)`. Um handler que já
80
+ declarava um segundo parâmetro opcional de outro tipo, como `(text, options?) => …`, deixa de
81
+ compilar ao ser passado direto. Migração: envolva-o (`onSend={(text) => runTurn(text)}`) ou, ao ligar
82
+ as imagens, receba `attachments` explicitamente.
83
+
10
84
  ## 25.7.0 — 2026-10-07
11
85
 
12
86
  Portais de documentação podem manter o mesmo mapa lateral em guias, ADRs e changelog. A superfície
@@ -0,0 +1,201 @@
1
+ # ADR 0058 — A mensagem da IA aceita imagem, e o Chat recebe anexos que o app guarda
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-10-09.
5
+ - **Complementa:** a regra de crescimento por reincidência do `AiAdapter` (`core/types.ts`, nota do
6
+ contrato) e da entrada `@softize/opus/ai`; ADR 0050 (o `Markdown` não carrega imagem externa).
7
+ - **Origem:** ADR 0122 da plataforma Grand Brasil, "A imagem anexada à conversa com a IA fica
8
+ guardada com a conversa", que pede ao pacote o corte 1 do plano dela.
9
+
10
+ ## Contexto
11
+
12
+ A plataforma Grand Brasil decidiu que a pessoa pode colar, soltar ou escolher uma imagem na conversa
13
+ com a IA, e que a IA a vê no mesmo turno. O caso que motivou a decisão é pedir ao assistente de
14
+ Relatórios que reproduza um relatório a partir de um print. É o primeiro caso real de entrada que não
15
+ é texto, e a superfície de IA do pacote cresce por reincidência: este é o caso que a cobra.
16
+
17
+ Hoje o pacote só conhece texto em três pontos:
18
+
19
+ - **O tipo da mensagem.** `AiMessage` é `{ role, content: string }`. `run` e `runStream` recebem
20
+ `string | AiMessage[]`.
21
+ - **O driver Anthropic.** Ele monta as mensagens com `content: m.content`, sem conferir a forma. Uma
22
+ lista de blocos no formato da Anthropic passaria por acaso, forçada com um `as`, e qualquer outro
23
+ provedor do consumidor receberia uma forma que não conhece.
24
+ - **A interface.** O `Composer` só habilita o envio com texto, e o `Chat` guarda `content: string` em
25
+ cada item. O modo controlado recebe o texto em `onSend(text)`.
26
+
27
+ A guarda da imagem não é do pacote. Na Grand Brasil, o motor de IA guarda os bytes junto com a
28
+ conversa, com retenção, governança e uma rota própria de envio. Outro consumidor pode guardar em
29
+ outro lugar, ou não guardar. O que se repete em qualquer consumidor é a forma de levar a imagem ao
30
+ modelo, a caixa de escrever com anexos e a preparação da imagem no navegador.
31
+
32
+ ## Decisão
33
+
34
+ ### 1. Conteúdo neutro na mensagem
35
+
36
+ O núcleo ganha um tipo de conteúdo que não tem a forma de nenhum provedor:
37
+
38
+ ```ts
39
+ type AiImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp'
40
+
41
+ type AiContentPart =
42
+ | { type: 'text'; text: string }
43
+ | { type: 'image'; mediaType: AiImageMediaType; data: string } // base64, sem o prefixo data:
44
+
45
+ interface AiMessage {
46
+ role: 'user' | 'assistant'
47
+ content: string | AiContentPart[]
48
+ }
49
+ ```
50
+
51
+ - Os formatos são PNG, JPEG e WebP, os três que os provedores conhecidos aceitam. GIF fica de fora.
52
+ - A parte de imagem não leva nome. O rótulo que a pessoa e o modelo usam ("Imagem 1: print.png") é
53
+ uma parte de texto que o consumidor monta antes da imagem, como a Grand Brasil faz.
54
+ - `content: string` continua válido. Quem só manda texto não muda nada na escrita.
55
+
56
+ ### 2. `aiMessageText` lê o texto de qualquer mensagem
57
+
58
+ `aiMessageText(message)` devolve o texto de uma mensagem: o próprio `content` quando ele é texto, ou
59
+ as partes de texto unidas por uma linha em branco. As imagens não entram. Serve para quem precisa só
60
+ do texto, como título de conversa, contagem de caracteres ou um bloco de contexto montado por
61
+ interpolação. Sai de `@softize/opus` e de `@softize/opus/ai`.
62
+
63
+ ### 3. O driver Anthropic converte cada parte
64
+
65
+ - A parte `text` vira o bloco `text`, e a parte `image` vira o bloco `image` com
66
+ `source: { type: 'base64', media_type, data }`, em `run` e em `runStream`.
67
+ - Uma parte de tipo desconhecido, uma lista de partes vazia, uma parte de texto vazia, uma imagem num
68
+ formato fora da lista ou uma imagem numa mensagem `assistant` fazem o driver lançar erro antes de
69
+ qualquer chamada, dizendo qual mensagem e qual parte. Nunca há descarte silencioso.
70
+ - `complete` e `extract` continuam recebendo texto.
71
+
72
+ ### 4. A guarda é do consumidor
73
+
74
+ O pacote não guarda imagem, não envia bytes a um servidor e não gera endereço. O `StorageAdapter`
75
+ existente não entra nesta decisão. O consumidor escolhe o transporte (a Grand Brasil manda a imagem
76
+ por uma rota própria, antes do turno) e entrega ao `Chat` o estado dos anexos e o endereço de cada
77
+ imagem. Assim, retenção, expiração, governança e dado pessoal ficam com quem guarda.
78
+
79
+ ### 5. Composer e Chat recebem anexos controlados pelo app
80
+
81
+ - O `Composer` ganha a propriedade `attachments`, e o `Chat`, a `composerAttachments`, com a mesma
82
+ forma: os anexos pendentes (`id`, nome, estado `uploading`, `ready` ou `failed`, miniatura e motivo
83
+ da falha), `onAddFiles`, `onRemove`, o limite por mensagem (padrão 4), os formatos aceitos
84
+ (`readonly AiImageMediaType[]`, padrão PNG, JPEG e WebP, sem curinga: um formato que a mensagem
85
+ não leva não pode ser aceito na faixa) e `onReject` opcional. Sem essa propriedade, não há faixa, botão, colar nem soltar
86
+ de arquivos: o composer é o de sempre.
87
+ - Com a propriedade, o composer mostra a faixa de anexos acima do texto, com miniatura, nome, estado
88
+ e o botão de remover, e o botão **Anexar imagem** na barra. Colar uma imagem no campo, soltá-la
89
+ sobre a conversa ou escolhê-la pelo botão entregam os arquivos aceitos a `onAddFiles`. A imagem
90
+ colada recebe um nome gerado ("Imagem colada 1.png"), com um contador da instância que não volta
91
+ atrás, para que dois anexos da mesma conversa não tenham o mesmo nome. Colar texto continua colando texto, mesmo
92
+ quando a área de transferência também traz uma imagem, como acontece ao copiar células de uma
93
+ planilha.
94
+ - A triagem recusa o que não está na lista de tipos e o que passaria do limite. Sem `onReject`, o
95
+ composer explica a recusa logo acima do campo; com `onReject`, a explicação é do app.
96
+ - Uma região viva anuncia a imagem que fica pronta ou falha, porque a mudança na faixa é só visual.
97
+ Quando o botão de anexar fica desabilitado por atingir o limite, o foco volta ao campo.
98
+ - O arraste de arquivos que o `Chat` (ou o `Composer` sozinho) consome não sobe para os contêineres
99
+ do app, que podem ter o próprio soltar, como o envio de arquivos a um acervo.
100
+ - O envio passa com texto ou com pelo menos um anexo pronto. Ele espera os anexos em envio e não
101
+ passa com um anexo em falha: a pessoa remove o anexo, ou o app tenta de novo, para que nada do que
102
+ está na faixa fique de fora sem aviso.
103
+ - O modo controlado recebe `onSend(text, attachments)`, com os anexos prontos, quando há
104
+ `composerAttachments`. Sem eles, a chamada continua `onSend(text)`: um handler que já tinha um
105
+ segundo parâmetro opcional, com outro significado, não recebe uma lista inesperada. Limpar a faixa
106
+ depois do envio é do app, que é o dono do estado. Retornar `false` devolve o texto ao campo, e os
107
+ anexos continuam na faixa.
108
+ - O item `user` do transcript ganha `attachments`, com `id`, nome, tipo, dimensões, estado
109
+ (`available` ou `expired`) e o endereço da imagem fornecido pelo app. A fala da pessoa mostra as
110
+ miniaturas acima do texto, com o nome como texto alternativo. O anexo expirado aparece como um
111
+ cartão com o nome e "Imagem expirada". Uma fala só com imagem não mostra balão de texto vazio.
112
+ - As miniaturas são um componente próprio, e não `![]()` no Markdown da mensagem: a fala da pessoa
113
+ continua sendo o que ela escreveu. O endereço vem do app, que deve servi-lo pela própria origem ou
114
+ como `data:`, como a ADR 0050 já recomenda.
115
+ - O modo autogerenciado do `Chat` (`send(messages)`) não ganha anexo. Ele não tem transporte para a
116
+ imagem, e `ChatMessage` continua só com texto. Se um consumidor precisar, uma nova decisão define
117
+ como.
118
+
119
+ ### 6. Preparação da imagem no navegador
120
+
121
+ `prepareImage(file, options)` prepara a imagem antes do envio, com os tetos que o app passa:
122
+
123
+ - lê só o cabeçalho do arquivo, sem carregá-lo inteiro, para conferir o formato pelos primeiros
124
+ bytes, não pela extensão nem pelo tipo declarado, e recusa o que não está em `accept` (padrão PNG,
125
+ JPEG e WebP);
126
+ - recusa PNG e WebP animados, que o modelo leria só pelo primeiro quadro;
127
+ - lê largura e altura do cabeçalho e recusa, antes de decodificar, a imagem acima de `maxPixels`
128
+ (padrão 40 megapixels): decodificá-la ocuparia centenas de megabytes de memória do navegador;
129
+ - reduz para que o lado maior não passe de `maxEdge` (padrão 1568 px), mantendo a proporção e a
130
+ orientação gravada pela câmera (EXIF), e codifica de novo sempre, o que descarta metadados como a
131
+ localização;
132
+ - mantém PNG como PNG quando cabe em `maxBytes` (padrão 3 MB) e converte o resto para JPEG com
133
+ qualidade 0,85, sobre fundo branco; o que continua acima do teto é recusado;
134
+ - devolve o arquivo preparado com tipo, dimensões e tamanho, ou a recusa com o motivo e uma mensagem
135
+ em pt-BR para a pessoa.
136
+
137
+ Os padrões repetem os tetos da ADR 0122 da Grand Brasil, mas o consumidor deve passar os seus: o
138
+ utilitário não decide teto de produto.
139
+
140
+ ## Por que é major
141
+
142
+ `AiMessage.content` passa de `string` para `string | AiContentPart[]`. Quem lê `content` como texto
143
+ deixa de compilar, e é de propósito: o compilador aponta os consumidores e os drivers que usam o
144
+ conteúdo como texto, em vez de deixar a imagem sumir em silêncio. A migração é ler o texto com
145
+ `aiMessageText(message)`.
146
+
147
+ O compilador não aponta o texto montado por interpolação (`${message.content}`) nem o que passa por um
148
+ `as`. Com uma lista de partes, esses pontos produziriam "[object Object]" sem erro. Dentro do pacote
149
+ não há nenhum; nos consumidores, a migração precisa procurá-los à parte e cobri-los com teste.
150
+
151
+ As mudanças de `Composer` e `Chat` preservam o comportamento de quem não usa anexo: sem as
152
+ propriedades novas, a interface é a mesma, e `onSend` continua sendo chamado só com o texto. O tipo de
153
+ `onSend` ganha o segundo parâmetro opcional; um handler que já declarava um segundo parâmetro
154
+ opcional de outro tipo deixa de compilar e precisa ser envolvido, como `(text) => run(text)`.
155
+
156
+ ## Consequências
157
+
158
+ - Um driver de IA do consumidor que repassava `content` sem conferir passa a receber partes neutras
159
+ quando o consumidor manda imagem. Cada driver converte as partes, ou recusa com erro claro.
160
+ - O pacote cresce com a faixa de anexos e a preparação da imagem, usadas por um consumidor só por
161
+ enquanto. Ficam no pacote porque o `Chat` e o `Composer` são dele, e cada consumidor reconstruiria
162
+ a sua por cima de `composerActions`.
163
+ - A redução no navegador limita tamanho e custo, mas não garante que o provedor não reduza de novo:
164
+ no nível padrão do Claude, uma imagem retangular de 1568 px ainda passa por redução na API.
165
+ - O tipo e o estado de cada anexo continuam como texto no pacote. A plataforma que adota declara o
166
+ próprio vocabulário (na Grand Brasil, um `t.dict` no protocolo da IA).
167
+
168
+ ## Alternativas descartadas
169
+
170
+ - **Campo opcional só para imagens em `AiMessage`.** Não quebraria ninguém, mas um driver que não o
171
+ conhece descartaria a imagem sem aviso, e o compilador não apontaria nada.
172
+ - **Partes no formato da Anthropic.** Funcionaria só com esse driver. Os demais provedores, que a
173
+ Grand Brasil também usa, receberiam uma forma que não conhecem.
174
+ - **Nome na parte de imagem.** A API da Anthropic não tem onde pôr o nome, e o rótulo em texto antes
175
+ da imagem já permite que a pessoa e o modelo falem de "imagem 2".
176
+ - **O `Composer` envia e guarda a imagem.** Prenderia o pacote a um transporte e a uma guarda que
177
+ são do consumidor, com retenção e governança próprias.
178
+ - **Anexo no modo autogerenciado.** Exigiria levar os bytes no histórico enviado a `send`, que é o
179
+ que a Grand Brasil recusou para o corpo do chat. Fica para quando um consumidor trouxer o caso.
180
+ - **Aceitar a imagem animada e mandar só o primeiro quadro.** A pessoa acharia que o modelo viu a
181
+ animação; recusar com o motivo é mais honesto.
182
+
183
+ ## Verificação
184
+
185
+ - Teste do driver: uma mensagem com texto e imagem chega ao client falso como bloco `text` e bloco
186
+ `image` com `source.type: 'base64'`, no `run` e no `runStream`; uma parte desconhecida, uma lista
187
+ vazia, uma parte de texto vazia, uma imagem num formato fora da lista e uma imagem em mensagem
188
+ `assistant` lançam erro sem chamar o client.
189
+ - Teste de `aiMessageText` com texto puro e com partes.
190
+ - Testes do `Composer` e do `Chat`: colar, soltar e escolher entregam o arquivo a `onAddFiles`;
191
+ colar texto continua colando; o envio passa só com imagem pronta e não passa com imagem em envio
192
+ ou em falha; tipo e limite são recusados com o motivo; o `Chat` mostra as miniaturas e o cartão de
193
+ imagem expirada, entrega `onSend(text, attachments)` e, sem `composerAttachments` ou no modo
194
+ autogerenciado, não mostra a faixa; o nome da imagem colada não se repete depois de remover uma;
195
+ a região viva anuncia pronta e falha; o foco volta ao campo no limite; o soltar consumido não
196
+ chega ao contêiner do app.
197
+ - Testes de `prepareImage` com canvas simulado e em navegador real: uma imagem de 4000 px sai com
198
+ lado maior de 1568 px, o PNG que cabe continua PNG, o que passa vira JPEG, e GIF, SVG e imagem
199
+ animada são recusados com o motivo; as dimensões vêm do cabeçalho de PNG, JPEG e WebP (com e sem
200
+ perdas e estendido), sem ler o arquivo inteiro, e a imagem acima do teto de pixels é recusada sem
201
+ decodificar; um JPEG com EXIF de rotação sai em pé.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "25.7.0",
3
+ "version": "26.0.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,6 +17,7 @@ import { declaredJsonSchema } from '../../core/index.ts'
17
17
  import type {
18
18
  AiAdapter,
19
19
  AiCompleteOptions,
20
+ AiContentPart,
20
21
  AiMessage,
21
22
  AiRunOptions,
22
23
  AiRunResult,
@@ -101,6 +102,50 @@ function defaultToJsonSchema(schema: unknown): unknown {
101
102
  return zodToJsonSchema(schema as Parameters<typeof zodToJsonSchema>[0], { $refStrategy: 'none' })
102
103
  }
103
104
 
105
+ const IMAGE_MEDIA_TYPES: ReadonlySet<string> = new Set(['image/png', 'image/jpeg', 'image/webp'])
106
+
107
+ /** Converte o conteúdo neutro da mensagem (ADR 0058) para os blocos da API da Anthropic. Uma
108
+ * parte desconhecida, uma parte de texto vazia, uma lista vazia, uma imagem fora dos formatos
109
+ * aceitos ou uma imagem na fala do assistente lançam erro: nada é descartado em silêncio, e nada
110
+ * chega a ser enviado. */
111
+ function toAnthropicContent(message: AiMessage, index: number): unknown {
112
+ const { content } = message
113
+ if (typeof content === 'string') return content
114
+ // A API recusa lista vazia e bloco de texto sem texto; o erro aqui diz qual mensagem é.
115
+ if (content.length === 0) {
116
+ throw new Error(`ai/anthropic: mensagem ${index + 1} sem conteúdo (lista de partes vazia).`)
117
+ }
118
+ return content.map((part: AiContentPart, partIndex) => {
119
+ const where = `mensagem ${index + 1}, parte ${partIndex + 1}`
120
+ if (part.type === 'text') {
121
+ if (part.text.trim() === '') {
122
+ throw new Error(`ai/anthropic: parte de texto vazia (${where}).`)
123
+ }
124
+ return { type: 'text', text: part.text }
125
+ }
126
+ if (part.type === 'image') {
127
+ if (message.role !== 'user') {
128
+ throw new Error(`ai/anthropic: imagem só pode ir numa mensagem 'user' (${where}).`)
129
+ }
130
+ if (!IMAGE_MEDIA_TYPES.has(part.mediaType)) {
131
+ throw new Error(
132
+ `ai/anthropic: formato de imagem não aceito: "${String(part.mediaType)}" (${where}). ` +
133
+ 'Use image/png, image/jpeg ou image/webp.',
134
+ )
135
+ }
136
+ return { type: 'image', source: { type: 'base64', media_type: part.mediaType, data: part.data } }
137
+ }
138
+ const unknownType = (part as { type?: unknown }).type
139
+ throw new Error(`ai/anthropic: parte de conteúdo desconhecida: "${String(unknownType)}" (${where}).`)
140
+ })
141
+ }
142
+
143
+ /** O histórico de entrada no formato da API, convertido antes da primeira chamada. */
144
+ function toAnthropicMessages(input: string | AiMessage[]): Array<{ role: string; content: unknown }> {
145
+ if (typeof input === 'string') return [{ role: 'user', content: input }]
146
+ return input.map((m, i) => ({ role: m.role, content: toAnthropicContent(m, i) }))
147
+ }
148
+
104
149
  interface ContentBlock {
105
150
  type: string
106
151
  text?: string
@@ -183,6 +228,9 @@ export function anthropicAi(options: AnthropicAiOptions = {}): AiAdapter {
183
228
  },
184
229
 
185
230
  async run(input: string | AiMessage[], opts: AiRunOptions): Promise<AiRunResult> {
231
+ // Mensagens no formato Anthropic — convertidas antes de tudo (uma parte inválida falha sem
232
+ // chamar a API) e crescem com tool_use/tool_result a cada passo.
233
+ const messages = toAnthropicMessages(input)
186
234
  const c = await client()
187
235
  const tools = withAskTool(
188
236
  opts.tools.map((t) => ({
@@ -192,11 +240,6 @@ export function anthropicAi(options: AnthropicAiOptions = {}): AiAdapter {
192
240
  })),
193
241
  opts,
194
242
  )
195
- // Mensagens no formato Anthropic — crescem com tool_use/tool_result a cada passo.
196
- const messages: Array<{ role: string; content: unknown }> =
197
- typeof input === 'string'
198
- ? [{ role: 'user', content: input }]
199
- : input.map((m) => ({ role: m.role, content: m.content }))
200
243
 
201
244
  const maxSteps = opts.maxSteps ?? 8
202
245
  const calls: Array<{ name: string; input: unknown }> = []
@@ -242,6 +285,7 @@ export function anthropicAi(options: AnthropicAiOptions = {}): AiAdapter {
242
285
  },
243
286
 
244
287
  async *runStream(input: string | AiMessage[], opts: AiRunOptions): AsyncIterable<ChatEvent> {
288
+ const messages = toAnthropicMessages(input)
245
289
  const c = await client()
246
290
  const tools = withAskTool(
247
291
  opts.tools.map((t) => ({
@@ -251,10 +295,6 @@ export function anthropicAi(options: AnthropicAiOptions = {}): AiAdapter {
251
295
  })),
252
296
  opts,
253
297
  )
254
- const messages: Array<{ role: string; content: unknown }> =
255
- typeof input === 'string'
256
- ? [{ role: 'user', content: input }]
257
- : input.map((m) => ({ role: m.role, content: m.content }))
258
298
 
259
299
  const maxSteps = opts.maxSteps ?? 8
260
300
 
package/src/ai/index.ts CHANGED
@@ -5,11 +5,14 @@
5
5
  * (texto → objeto VALIDADO por um `Schema` do protocolo — o mesmo tipo de schema
6
6
  * das actions vira o contrato da resposta do modelo). Driver em `ai/anthropic`.
7
7
  * A superfície é a mínima de backoffice e cresce por reincidência — não por
8
- * especulação (streaming, chat multi-turn e tools livres entram quando um caso
9
- * real cobrar).
8
+ * especulação. Streaming, chat multi-turn e mensagem com imagem (ADR 0058) já
9
+ * entraram assim; o resto espera um caso real.
10
10
  */
11
11
 
12
12
  export type { AiAdapter, AiCompleteOptions } from '../core/index.ts'
13
+ // Mensagem com texto e imagem (ADR 0058): o tipo neutro de conteúdo e a leitura só do texto.
14
+ export type { AiContentPart, AiImageMediaType, AiMessage } from '../core/index.ts'
15
+ export { aiMessageText } from '../core/index.ts'
13
16
  // Elicitação — perguntar à pessoa no meio do turno (round-trip via `AiRunOptions.onAsk`).
14
17
  // O contrato (tipos) mora no core; aqui saem o nome reservado, o schema/descrição da tool e
15
18
  // o formatador. É o ponto de consistência pra qualquer consumidor (chat, copilot, MCP externo).
@@ -0,0 +1,20 @@
1
+ import type { AiMessage } from './types.ts'
2
+
3
+ /** Separa as partes de texto de uma mensagem na leitura: cada parte continua um parágrafo. */
4
+ const TEXT_PART_SEPARATOR = '\n\n'
5
+
6
+ /**
7
+ * O texto de uma mensagem da IA (ADR 0058): o próprio `content` quando ele é texto, ou as partes
8
+ * de texto unidas por uma linha em branco. As imagens não entram.
9
+ *
10
+ * Use onde só o texto importa — título de conversa, contagem de caracteres, bloco de contexto
11
+ * montado por interpolação. Interpolar `content` direto produz "[object Object]" quando a
12
+ * mensagem tem partes, e o compilador não aponta esse uso.
13
+ */
14
+ export function aiMessageText(message: Pick<AiMessage, 'content'>): string {
15
+ const { content } = message
16
+ if (typeof content === 'string') return content
17
+ return content
18
+ .flatMap((part) => (part.type === 'text' ? [part.text] : []))
19
+ .join(TEXT_PART_SEPARATOR)
20
+ }
package/src/core/index.ts CHANGED
@@ -113,6 +113,8 @@ export type {
113
113
  AiCompleteOptions,
114
114
  AiTool,
115
115
  AiMessage,
116
+ AiContentPart,
117
+ AiImageMediaType,
116
118
  AiRunOptions,
117
119
  AiRunResult,
118
120
  BoundAi,
@@ -243,6 +245,9 @@ export type {
243
245
  } from './runtime.ts'
244
246
  export { countReturnedRecords, hasInvalidEffect, resolveActionEffect } from './action-nature.ts'
245
247
 
248
+ // — Mensagem da IA (ADR 0058) ———————————————————————————————————————————————————
249
+ export { aiMessageText } from './ai-message.ts'
250
+
246
251
  // — Assistente do recurso (ADR 0053) ————————————————————————————————————————————
247
252
  export {
248
253
  ASSISTANT_CONTEXT_DEFAULT_CHARS,
package/src/core/types.ts CHANGED
@@ -1383,10 +1383,33 @@ export interface AiTool {
1383
1383
  }
1384
1384
  }
1385
1385
 
1386
- /** Turno de conversa (histórico multi-turn do chat). */
1386
+ /** Formatos de imagem que a mensagem aceita: os três que os provedores conhecidos leem (ADR 0058). */
1387
+ export type AiImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp'
1388
+
1389
+ /**
1390
+ * Parte de conteúdo de uma mensagem, sem a forma de nenhum provedor (ADR 0058). Cada driver
1391
+ * converte as partes para o formato da sua API e recusa com erro o que não conhece.
1392
+ *
1393
+ * A imagem não leva nome: o rótulo que a pessoa e o modelo usam ("Imagem 1: print.png") é uma
1394
+ * parte de texto que o consumidor monta antes dela.
1395
+ */
1396
+ export type AiContentPart =
1397
+ | { type: 'text'; text: string }
1398
+ | {
1399
+ type: 'image'
1400
+ mediaType: AiImageMediaType
1401
+ /** Bytes da imagem em base64, sem o prefixo `data:`. */
1402
+ data: string
1403
+ }
1404
+
1405
+ /**
1406
+ * Turno de conversa (histórico multi-turn do chat). `content` é texto ou uma lista de partes
1407
+ * com texto e imagem (ADR 0058). Para ler só o texto, use `aiMessageText(message)`: interpolar
1408
+ * `content` direto num template produz "[object Object]" quando a mensagem tem partes.
1409
+ */
1387
1410
  export interface AiMessage {
1388
1411
  role: 'user' | 'assistant'
1389
- content: string
1412
+ content: string | AiContentPart[]
1390
1413
  }
1391
1414
 
1392
1415
  /** Uma opção de resposta de uma pergunta de esclarecimento (`ask_user`). */