@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
|
@@ -1,10 +1,58 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import { SendHorizontalIcon } from "lucide-react";
|
|
2
|
+
import { ImageIcon, ImagePlusIcon, SendHorizontalIcon, XIcon } from "lucide-react";
|
|
3
|
+
import type { AiImageMediaType } from "../../../core/index.ts";
|
|
3
4
|
import { cn } from "../../lib/cn.ts";
|
|
5
|
+
import { IMAGE_MEDIA_TYPES, imageTypeList } from "../../lib/prepare-image.ts";
|
|
4
6
|
import { Button } from "./button.tsx";
|
|
5
7
|
import { Spinner } from "./spinner.tsx";
|
|
6
8
|
import { Textarea } from "./textarea.tsx";
|
|
7
9
|
|
|
10
|
+
/** Estado de um anexo pendente: enviando, pronto para ir com a mensagem ou com falha. */
|
|
11
|
+
export type ComposerAttachmentStatus = "uploading" | "ready" | "failed";
|
|
12
|
+
|
|
13
|
+
/** Um anexo pendente da mensagem, no estado que o app mantém (ADR 0058). */
|
|
14
|
+
export interface ComposerAttachment {
|
|
15
|
+
/** Identidade estável do anexo, dada pelo app. */
|
|
16
|
+
id: string;
|
|
17
|
+
/** Nome mostrado na faixa; identifica o anexo também para leitores de tela. */
|
|
18
|
+
name: string;
|
|
19
|
+
status: ComposerAttachmentStatus;
|
|
20
|
+
/** Endereço da miniatura (por exemplo, `URL.createObjectURL` da imagem preparada). */
|
|
21
|
+
previewUrl?: string;
|
|
22
|
+
/** Motivo da falha, mostrado no lugar do estado quando `status` é `failed`. */
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Um arquivo recusado pela triagem: fora dos tipos aceitos ou além do limite da mensagem. */
|
|
27
|
+
export interface ComposerFileRejection {
|
|
28
|
+
file: File;
|
|
29
|
+
reason: "type" | "limit";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Anexos da mensagem, controlados pelo app (ADR 0058). O composer faz a triagem e mostra a faixa;
|
|
34
|
+
* preparar, enviar, guardar e limpar depois do envio são do app, que é o dono do transporte.
|
|
35
|
+
*/
|
|
36
|
+
export interface ComposerAttachments {
|
|
37
|
+
/** Anexos pendentes, na ordem em que aparecem na faixa. */
|
|
38
|
+
items: ComposerAttachment[];
|
|
39
|
+
/** Recebe os arquivos aceitos pela triagem — do botão, do colar ou do soltar. */
|
|
40
|
+
onAddFiles: (files: File[]) => void;
|
|
41
|
+
/** A pessoa removeu um anexo da faixa. */
|
|
42
|
+
onRemove: (id: string) => void;
|
|
43
|
+
/** Limite de anexos por mensagem. Padrão: 4. */
|
|
44
|
+
max?: number;
|
|
45
|
+
/** Formatos aceitos, um subconjunto dos que a mensagem da IA leva. Padrão: PNG, JPEG e WebP.
|
|
46
|
+
* Não há curinga (`image/*`): a triagem compara o tipo exato do arquivo. */
|
|
47
|
+
accept?: readonly AiImageMediaType[];
|
|
48
|
+
/** Recebe os arquivos recusados. Presente, a explicação da recusa é do app; ausente, o
|
|
49
|
+
* composer a mostra acima do campo. */
|
|
50
|
+
onReject?: (rejections: ComposerFileRejection[]) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Limite padrão de anexos por mensagem. */
|
|
54
|
+
export const COMPOSER_MAX_ATTACHMENTS = 4;
|
|
55
|
+
|
|
8
56
|
export interface ComposerProps {
|
|
9
57
|
/** Texto (controlado): o dono do estado é o consumidor — o `<Chat>` por dentro, ou o app
|
|
10
58
|
* (ex.: o composer de criação de sessão do Maestro, que nem tem histórico). */
|
|
@@ -29,15 +77,165 @@ export interface ComposerProps {
|
|
|
29
77
|
* embaixo); ausente → linha única (textarea + enviar inline), o composer clássico do Chat.
|
|
30
78
|
* Estilize os controles como discretos (ghost/sem borda) — a barra não é um formulário. */
|
|
31
79
|
actions?: React.ReactNode;
|
|
80
|
+
/** Anexos da mensagem (ADR 0058). Presente → duas linhas, faixa de anexos acima do texto,
|
|
81
|
+
* botão **Anexar imagem** na barra, colar e soltar imagens; ausente → só texto. */
|
|
82
|
+
attachments?: ComposerAttachments;
|
|
32
83
|
className?: string;
|
|
33
84
|
}
|
|
34
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Ponte interna entre o `<Chat>` e o seu composer: o Chat recebe o soltar sobre a conversa
|
|
88
|
+
* inteira e entrega os arquivos à mesma triagem do composer. Não faz parte da API pública.
|
|
89
|
+
*/
|
|
90
|
+
export const ComposerDropBridge = React.createContext<React.RefObject<
|
|
91
|
+
((files: File[]) => void) | null
|
|
92
|
+
> | null>(null);
|
|
93
|
+
|
|
94
|
+
/** O arraste traz arquivos (e não texto ou um link). */
|
|
95
|
+
export function dragHasFiles(transfer: DataTransfer | null): boolean {
|
|
96
|
+
return transfer !== null && Array.from(transfer.types ?? []).includes("Files");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const PASTED_EXTENSIONS: Record<string, string> = {
|
|
100
|
+
"image/png": ".png",
|
|
101
|
+
"image/jpeg": ".jpg",
|
|
102
|
+
"image/webp": ".webp",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** A imagem colada chega como "image.png"; ganha um nome que a pessoa reconhece na conversa. O
|
|
106
|
+
* número vem de um contador da instância, que não volta atrás: remover "Imagem colada 1" e colar
|
|
107
|
+
* de novo dá "Imagem colada 3", e não uma segunda "Imagem colada 1" na mesma conversa. */
|
|
108
|
+
function namePasted(file: File, position: number): File {
|
|
109
|
+
const extension = PASTED_EXTENSIONS[file.type];
|
|
110
|
+
if (extension === undefined) return file;
|
|
111
|
+
return new File([file], `Imagem colada ${position}${extension}`, {
|
|
112
|
+
type: file.type,
|
|
113
|
+
lastModified: file.lastModified,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function rejectionMessage(
|
|
118
|
+
rejections: ComposerFileRejection[],
|
|
119
|
+
accept: readonly AiImageMediaType[],
|
|
120
|
+
max: number,
|
|
121
|
+
): string {
|
|
122
|
+
const parts: string[] = [];
|
|
123
|
+
if (rejections.some((r) => r.reason === "type")) {
|
|
124
|
+
parts.push(`Só é possível anexar imagens ${imageTypeList(accept)}.`);
|
|
125
|
+
}
|
|
126
|
+
if (rejections.some((r) => r.reason === "limit")) {
|
|
127
|
+
parts.push(
|
|
128
|
+
max === 1 ? "Cada mensagem aceita 1 imagem." : `Cada mensagem aceita até ${max} imagens.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return parts.join(" ");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** O que mudou nos anexos desde o render anterior, para a região viva anunciar. */
|
|
135
|
+
function statusAnnouncement(
|
|
136
|
+
previous: ReadonlyMap<string, ComposerAttachmentStatus>,
|
|
137
|
+
items: ComposerAttachment[],
|
|
138
|
+
): string | null {
|
|
139
|
+
const changes: string[] = [];
|
|
140
|
+
for (const item of items) {
|
|
141
|
+
const before = previous.get(item.id);
|
|
142
|
+
if (before === item.status) continue;
|
|
143
|
+
if (item.status === "ready") changes.push(`${item.name} pronta para enviar.`);
|
|
144
|
+
else if (item.status === "failed") changes.push(`${item.name}: ${statusLabel(item)}`);
|
|
145
|
+
}
|
|
146
|
+
return changes.length > 0 ? changes.join(" ") : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function statusLabel(item: ComposerAttachment): string {
|
|
150
|
+
if (item.status === "uploading") return "Enviando…";
|
|
151
|
+
if (item.status === "failed") return item.error ?? "Não foi possível enviar.";
|
|
152
|
+
return "Pronta";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A faixa de anexos pendentes: miniatura, nome, estado e remover. */
|
|
156
|
+
function AttachmentStrip({
|
|
157
|
+
items,
|
|
158
|
+
max,
|
|
159
|
+
onRemove,
|
|
160
|
+
}: {
|
|
161
|
+
items: ComposerAttachment[];
|
|
162
|
+
max: number;
|
|
163
|
+
onRemove: (item: ComposerAttachment) => void;
|
|
164
|
+
}): React.ReactElement {
|
|
165
|
+
return (
|
|
166
|
+
<div data-slot="composer-attachments" className="flex items-end gap-2 px-1 pb-1.5">
|
|
167
|
+
<ul aria-label="Imagens anexadas" className="flex min-w-0 flex-1 flex-wrap gap-1.5">
|
|
168
|
+
{items.map((item) => (
|
|
169
|
+
<li
|
|
170
|
+
key={item.id}
|
|
171
|
+
data-slot="composer-attachment"
|
|
172
|
+
data-status={item.status}
|
|
173
|
+
aria-busy={item.status === "uploading" || undefined}
|
|
174
|
+
className={cn(
|
|
175
|
+
"flex min-w-0 max-w-52 items-center gap-2 rounded-lg border bg-muted/40 p-1 text-xs",
|
|
176
|
+
item.status === "failed" ? "border-context-danger-border" : "border-border",
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
<span className="relative grid size-10 shrink-0 place-items-center overflow-hidden rounded-md bg-muted">
|
|
180
|
+
{item.previewUrl !== undefined ? (
|
|
181
|
+
// O nome está ao lado: a miniatura é decorativa para leitores de tela.
|
|
182
|
+
<img src={item.previewUrl} alt="" className="size-full object-cover" />
|
|
183
|
+
) : (
|
|
184
|
+
<ImageIcon aria-hidden className="size-4 text-muted-foreground" />
|
|
185
|
+
)}
|
|
186
|
+
{item.status === "uploading" && (
|
|
187
|
+
<span className="absolute inset-0 grid place-items-center bg-background/60">
|
|
188
|
+
<Spinner size="xs" />
|
|
189
|
+
</span>
|
|
190
|
+
)}
|
|
191
|
+
</span>
|
|
192
|
+
<span className="min-w-0 flex-1 leading-tight">
|
|
193
|
+
<span className="block truncate font-medium text-foreground" title={item.name}>
|
|
194
|
+
{item.name}
|
|
195
|
+
</span>
|
|
196
|
+
<span
|
|
197
|
+
className={cn(
|
|
198
|
+
"block truncate",
|
|
199
|
+
item.status === "failed"
|
|
200
|
+
? "text-context-danger-emphasis"
|
|
201
|
+
: "text-muted-foreground",
|
|
202
|
+
)}
|
|
203
|
+
title={item.status === "failed" ? statusLabel(item) : undefined}
|
|
204
|
+
>
|
|
205
|
+
{statusLabel(item)}
|
|
206
|
+
</span>
|
|
207
|
+
</span>
|
|
208
|
+
<Button
|
|
209
|
+
type="button"
|
|
210
|
+
size="icon-xs"
|
|
211
|
+
variant="ghost"
|
|
212
|
+
shape="pill"
|
|
213
|
+
aria-label={`Remover ${item.name}`}
|
|
214
|
+
onClick={() => onRemove(item)}
|
|
215
|
+
className="shrink-0 self-start text-muted-foreground hover:text-foreground"
|
|
216
|
+
>
|
|
217
|
+
<XIcon />
|
|
218
|
+
</Button>
|
|
219
|
+
</li>
|
|
220
|
+
))}
|
|
221
|
+
</ul>
|
|
222
|
+
<span
|
|
223
|
+
data-slot="composer-attachment-count"
|
|
224
|
+
className="shrink-0 pb-1 text-xs text-muted-foreground"
|
|
225
|
+
>
|
|
226
|
+
{max === 1 ? `${items.length} de 1 imagem` : `${items.length} de ${max} imagens`}
|
|
227
|
+
</span>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
35
232
|
/**
|
|
36
233
|
* <Composer /> — a caixa de escrever da casa: textarea (Enter envia / Shift+Enter quebra
|
|
37
234
|
* linha) numa pílula elevada (rounded-xl + border + shadow-sm), com o enviar dentro.
|
|
38
235
|
* É o composer do `<Chat>` extraído pra ser reusável SOZINHO — o Maestro cria sessão com
|
|
39
236
|
* ele (sem chat nenhum), e a GB prende seletores nele. Com `actions`, ganha uma barra
|
|
40
|
-
* embaixo pros seletores; sem, é a linha única de sempre.
|
|
237
|
+
* embaixo pros seletores; sem, é a linha única de sempre. Com `attachments`, ganha a faixa
|
|
238
|
+
* de anexos e o botão **Anexar imagem** (ADR 0058).
|
|
41
239
|
*/
|
|
42
240
|
export function Composer({
|
|
43
241
|
value,
|
|
@@ -51,19 +249,118 @@ export function Composer({
|
|
|
51
249
|
rows = 1,
|
|
52
250
|
autoFocus,
|
|
53
251
|
actions,
|
|
252
|
+
attachments,
|
|
54
253
|
className,
|
|
55
254
|
}: ComposerProps): React.ReactElement {
|
|
56
|
-
const
|
|
255
|
+
const items = attachments?.items ?? [];
|
|
256
|
+
const max = attachments?.max ?? COMPOSER_MAX_ATTACHMENTS;
|
|
257
|
+
const accept = attachments?.accept ?? IMAGE_MEDIA_TYPES;
|
|
258
|
+
const [notice, setNotice] = React.useState<string | null>(null);
|
|
259
|
+
const [dragging, setDragging] = React.useState(false);
|
|
260
|
+
const [announcement, setAnnouncement] = React.useState("");
|
|
261
|
+
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
|
262
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
263
|
+
const pastedCount = React.useRef(0);
|
|
264
|
+
const pickerUsed = React.useRef(false);
|
|
265
|
+
const previousStatus = React.useRef(new Map<string, ComposerAttachmentStatus>());
|
|
266
|
+
const bridge = React.useContext(ComposerDropBridge);
|
|
267
|
+
const full = items.length >= max;
|
|
268
|
+
|
|
269
|
+
// Anexo que fica pronto ou falha é anunciado: a mudança na faixa é só visual. O estado com que
|
|
270
|
+
// o composer nasce não é novidade e não é anunciado.
|
|
271
|
+
const statusKnown = React.useRef(false);
|
|
272
|
+
React.useEffect(() => {
|
|
273
|
+
const next = statusKnown.current ? statusAnnouncement(previousStatus.current, items) : null;
|
|
274
|
+
statusKnown.current = true;
|
|
275
|
+
previousStatus.current = new Map(items.map((item) => [item.id, item.status]));
|
|
276
|
+
if (next !== null) setAnnouncement(next);
|
|
277
|
+
}, [items]);
|
|
278
|
+
|
|
279
|
+
// Quem atinge o limite pelo botão fica com o foco num botão desabilitado; o foco vai ao campo.
|
|
280
|
+
React.useEffect(() => {
|
|
281
|
+
if (!full || !pickerUsed.current) return;
|
|
282
|
+
pickerUsed.current = false;
|
|
283
|
+
textareaRef.current?.focus();
|
|
284
|
+
}, [full]);
|
|
285
|
+
|
|
286
|
+
// Envio: texto ou pelo menos um anexo pronto; nunca com anexo em envio ou em falha, para que
|
|
287
|
+
// nada do que está na faixa fique de fora sem aviso.
|
|
288
|
+
const hasReady = items.some((item) => item.status === "ready");
|
|
289
|
+
const pending = items.some((item) => item.status !== "ready");
|
|
290
|
+
const canSubmit = (value.trim() !== "" || hasReady) && !pending && !busy && !submitDisabled;
|
|
57
291
|
const fire = (): void => {
|
|
58
|
-
if (canSubmit)
|
|
292
|
+
if (!canSubmit) return;
|
|
293
|
+
setNotice(null);
|
|
294
|
+
onSubmit();
|
|
295
|
+
};
|
|
296
|
+
const twoRow = actions !== undefined || attachments !== undefined;
|
|
297
|
+
|
|
298
|
+
/** Triagem: tipo aceito e espaço na mensagem. Os aceitos vão ao app; os recusados, ao
|
|
299
|
+
* `onReject` do app ou ao aviso do composer. */
|
|
300
|
+
const intake = (files: File[]): void => {
|
|
301
|
+
if (attachments === undefined || files.length === 0) return;
|
|
302
|
+
const remaining = Math.max(0, max - items.length);
|
|
303
|
+
const accepted: File[] = [];
|
|
304
|
+
const rejected: ComposerFileRejection[] = [];
|
|
305
|
+
for (const file of files) {
|
|
306
|
+
if (!(accept as readonly string[]).includes(file.type)) rejected.push({ file, reason: "type" });
|
|
307
|
+
else if (accepted.length >= remaining) rejected.push({ file, reason: "limit" });
|
|
308
|
+
else accepted.push(file);
|
|
309
|
+
}
|
|
310
|
+
if (accepted.length > 0) attachments.onAddFiles(accepted);
|
|
311
|
+
if (rejected.length > 0 && attachments.onReject !== undefined) {
|
|
312
|
+
attachments.onReject(rejected);
|
|
313
|
+
setNotice(null);
|
|
314
|
+
} else {
|
|
315
|
+
setNotice(rejected.length > 0 ? rejectionMessage(rejected, accept, max) : null);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Dentro do <Chat>, o soltar é recebido pela conversa inteira e chega aqui pela ponte. A
|
|
320
|
+
// ponte acompanha cada render, para a triagem contar os anexos atuais.
|
|
321
|
+
React.useEffect(() => {
|
|
322
|
+
if (bridge === null) return;
|
|
323
|
+
bridge.current = attachments !== undefined ? intake : null;
|
|
324
|
+
});
|
|
325
|
+
React.useEffect(
|
|
326
|
+
() => () => {
|
|
327
|
+
if (bridge !== null) bridge.current = null;
|
|
328
|
+
},
|
|
329
|
+
[bridge],
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const remove = (item: ComposerAttachment): void => {
|
|
333
|
+
attachments?.onRemove(item.id);
|
|
334
|
+
setNotice(null);
|
|
335
|
+
// O botão some com o anexo; o foco volta ao campo em vez de cair no documento.
|
|
336
|
+
textareaRef.current?.focus();
|
|
59
337
|
};
|
|
60
|
-
const twoRow = actions !== undefined;
|
|
61
338
|
|
|
62
339
|
const textarea = (
|
|
63
340
|
<Textarea
|
|
341
|
+
ref={textareaRef}
|
|
64
342
|
autoFocus={autoFocus}
|
|
65
343
|
value={value}
|
|
66
344
|
onChange={(e) => onChange(e.target.value)}
|
|
345
|
+
onPaste={
|
|
346
|
+
attachments === undefined
|
|
347
|
+
? undefined
|
|
348
|
+
: (e) => {
|
|
349
|
+
// Texto vence: copiar células de uma planilha traz texto e uma imagem juntos, e
|
|
350
|
+
// quem cola ali quer o texto. Só a imagem sem texto, como um print, vira anexo.
|
|
351
|
+
if (e.clipboardData.getData("text/plain") !== "") return;
|
|
352
|
+
const files = Array.from(e.clipboardData.files ?? []);
|
|
353
|
+
if (files.length === 0) return;
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
intake(
|
|
356
|
+
files.map((file) =>
|
|
357
|
+
PASTED_EXTENSIONS[file.type] === undefined
|
|
358
|
+
? file
|
|
359
|
+
: namePasted(file, (pastedCount.current += 1)),
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
67
364
|
onKeyDown={(e) => {
|
|
68
365
|
if (e.key === "ArrowUp" && onHistoryPrevious?.()) {
|
|
69
366
|
e.preventDefault();
|
|
@@ -101,29 +398,122 @@ export function Composer({
|
|
|
101
398
|
{busy ? <Spinner size="sm" /> : <SendHorizontalIcon className="size-4" />}
|
|
102
399
|
</Button>
|
|
103
400
|
);
|
|
401
|
+
const attach = attachments !== undefined && (
|
|
402
|
+
<>
|
|
403
|
+
<Button
|
|
404
|
+
type="button"
|
|
405
|
+
size="icon-sm"
|
|
406
|
+
variant="ghost"
|
|
407
|
+
shape="pill"
|
|
408
|
+
aria-label="Anexar imagem"
|
|
409
|
+
title="Anexar imagem"
|
|
410
|
+
disabled={full}
|
|
411
|
+
onClick={() => {
|
|
412
|
+
pickerUsed.current = true;
|
|
413
|
+
fileInputRef.current?.click();
|
|
414
|
+
}}
|
|
415
|
+
onBlur={(e) => {
|
|
416
|
+
// Abrir o seletor tira o foco da janela (sem destino); ir a outro controle desfaz a marca.
|
|
417
|
+
if (e.relatedTarget !== null) pickerUsed.current = false;
|
|
418
|
+
}}
|
|
419
|
+
className="text-muted-foreground hover:text-foreground"
|
|
420
|
+
>
|
|
421
|
+
<ImagePlusIcon />
|
|
422
|
+
</Button>
|
|
423
|
+
<input
|
|
424
|
+
ref={fileInputRef}
|
|
425
|
+
data-slot="composer-file-input"
|
|
426
|
+
type="file"
|
|
427
|
+
accept={accept.join(",")}
|
|
428
|
+
multiple={max - items.length > 1}
|
|
429
|
+
hidden
|
|
430
|
+
tabIndex={-1}
|
|
431
|
+
onChange={(e) => {
|
|
432
|
+
intake(Array.from(e.target.files ?? []));
|
|
433
|
+
// Escolher o mesmo arquivo de novo precisa disparar `change` outra vez.
|
|
434
|
+
e.target.value = "";
|
|
435
|
+
}}
|
|
436
|
+
/>
|
|
437
|
+
</>
|
|
438
|
+
);
|
|
439
|
+
// Fora do <Chat>, o próprio composer recebe o soltar.
|
|
440
|
+
const ownDrop = attachments !== undefined && bridge === null;
|
|
104
441
|
|
|
105
442
|
return (
|
|
106
443
|
<form
|
|
107
444
|
data-slot="composer"
|
|
445
|
+
data-dragging={dragging || undefined}
|
|
108
446
|
onSubmit={(e) => {
|
|
109
447
|
e.preventDefault();
|
|
110
448
|
fire();
|
|
111
449
|
}}
|
|
450
|
+
onDragOver={
|
|
451
|
+
ownDrop
|
|
452
|
+
? (e) => {
|
|
453
|
+
if (!dragHasFiles(e.dataTransfer)) return;
|
|
454
|
+
e.preventDefault();
|
|
455
|
+
e.stopPropagation();
|
|
456
|
+
setDragging(true);
|
|
457
|
+
}
|
|
458
|
+
: undefined
|
|
459
|
+
}
|
|
460
|
+
onDragLeave={
|
|
461
|
+
ownDrop
|
|
462
|
+
? (e) => {
|
|
463
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDragging(false);
|
|
464
|
+
}
|
|
465
|
+
: undefined
|
|
466
|
+
}
|
|
467
|
+
onDrop={
|
|
468
|
+
ownDrop
|
|
469
|
+
? (e) => {
|
|
470
|
+
if (!dragHasFiles(e.dataTransfer)) return;
|
|
471
|
+
e.preventDefault();
|
|
472
|
+
// O soltar foi consumido aqui; um contêiner do app não deve recebê-lo de novo.
|
|
473
|
+
e.stopPropagation();
|
|
474
|
+
setDragging(false);
|
|
475
|
+
intake(Array.from(e.dataTransfer.files));
|
|
476
|
+
}
|
|
477
|
+
: undefined
|
|
478
|
+
}
|
|
112
479
|
className={cn(
|
|
113
480
|
"rounded-xl border border-border bg-card text-card-foreground shadow-sm transition",
|
|
114
481
|
twoRow ? "p-2" : "relative flex items-end gap-2 p-2 pl-3.5",
|
|
482
|
+
dragging && "border-primary ring-2 ring-primary/30",
|
|
115
483
|
className,
|
|
116
484
|
)}
|
|
117
485
|
>
|
|
118
486
|
{twoRow ? (
|
|
119
487
|
<>
|
|
488
|
+
{attachments !== undefined && (
|
|
489
|
+
// Região viva montada desde o início: um aviso que nasce junto com ela não é
|
|
490
|
+
// anunciado por todos os leitores de tela.
|
|
491
|
+
<p
|
|
492
|
+
role="status"
|
|
493
|
+
data-slot="composer-attachment-notice"
|
|
494
|
+
className={cn(
|
|
495
|
+
notice === null ? "sr-only" : "px-1.5 pb-1.5 text-xs text-context-warning-emphasis",
|
|
496
|
+
)}
|
|
497
|
+
>
|
|
498
|
+
{notice}
|
|
499
|
+
</p>
|
|
500
|
+
)}
|
|
501
|
+
{attachments !== undefined && (
|
|
502
|
+
<p role="status" data-slot="composer-attachment-announcement" className="sr-only">
|
|
503
|
+
{announcement}
|
|
504
|
+
</p>
|
|
505
|
+
)}
|
|
506
|
+
{items.length > 0 && <AttachmentStrip items={items} max={max} onRemove={remove} />}
|
|
120
507
|
{textarea}
|
|
121
508
|
<div
|
|
122
509
|
data-slot="composer-actions"
|
|
123
510
|
className="mt-1 flex items-center justify-between gap-2"
|
|
124
511
|
>
|
|
125
|
-
{/*
|
|
126
|
-
<div className="flex min-w-0 items-center gap-1">
|
|
512
|
+
{/* Anexar e seletores à esquerda (discretos), enviar à direita. */}
|
|
513
|
+
<div className="flex min-w-0 items-center gap-1">
|
|
514
|
+
{attach}
|
|
515
|
+
{actions}
|
|
516
|
+
</div>
|
|
127
517
|
{send}
|
|
128
518
|
</div>
|
|
129
519
|
</>
|
|
@@ -23,6 +23,7 @@ interface AiAdapter {
|
|
|
23
23
|
runStream?(input: string | AiMessage[], opts: AiRunOptions): AsyncIterable<ChatEvent>
|
|
24
24
|
}
|
|
25
25
|
// opts: { system?, model?, maxTokens?, temperature? } — tudo tem default do driver.
|
|
26
|
+
// AiMessage: { role: 'user' | 'assistant', content: string | AiContentPart[] } (ver Mensagem com imagem).
|
|
26
27
|
```
|
|
27
28
|
|
|
28
29
|
## Driver
|
|
@@ -125,6 +126,51 @@ O driver Anthropic emite o texto em trechos, à medida que chega, quando o clien
|
|
|
125
126
|
`messages.stream`. Com um client que só tem `create`, como um client falso de teste, o texto de cada
|
|
126
127
|
passo sai num único trecho. O componente [Chat](/ui/chat) consome os dois modos.
|
|
127
128
|
|
|
129
|
+
## Mensagem com imagem
|
|
130
|
+
|
|
131
|
+
Uma mensagem pode levar imagens junto com o texto, para o modelo ler um print, uma tabela ou um
|
|
132
|
+
gráfico. Nesse caso, `content` deixa de ser texto e passa a ser uma lista de partes, em uma forma que
|
|
133
|
+
não depende do provedor:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const messages: AiMessage[] = [
|
|
137
|
+
{
|
|
138
|
+
role: 'user',
|
|
139
|
+
content: [
|
|
140
|
+
{ type: 'text', text: 'Imagem 1: vendas.png' },
|
|
141
|
+
{ type: 'image', mediaType: 'image/png', data: base64 }, // sem o prefixo data:
|
|
142
|
+
{ type: 'text', text: 'Reproduza este relatório.' },
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
]
|
|
146
|
+
await runtime.aiFor(base)!.run(messages)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Os formatos aceitos são PNG, JPEG e WebP, e a imagem só pode estar em uma mensagem `user`. O driver
|
|
150
|
+
Anthropic converte cada parte para o bloco da API, em `run` e em `runStream`; uma parte que ele não
|
|
151
|
+
conhece, uma imagem em outro formato ou uma imagem na fala do assistente fazem a chamada falhar com
|
|
152
|
+
erro antes de chegar ao provedor, em vez de a imagem sumir em silêncio. `complete` e `extract`
|
|
153
|
+
continuam recebendo texto.
|
|
154
|
+
|
|
155
|
+
A parte de imagem não tem nome. Para que a pessoa e o modelo possam falar da “imagem 2”, ponha uma
|
|
156
|
+
parte de texto com o rótulo antes de cada imagem, como no exemplo. Imagens vão antes da pergunta,
|
|
157
|
+
como a Anthropic recomenda.
|
|
158
|
+
|
|
159
|
+
Quando precisar só do texto de uma mensagem, por exemplo para o título da conversa ou para montar um
|
|
160
|
+
bloco de contexto, use `aiMessageText(message)`. Ele devolve o texto puro ou as partes de texto unidas
|
|
161
|
+
por uma linha em branco, sem as imagens. Interpolar `content` direto num template produz
|
|
162
|
+
“[object Object]” quando a mensagem tem partes, e o compilador não aponta esse uso.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { aiMessageText } from '@softize/opus/ai'
|
|
166
|
+
|
|
167
|
+
const title = aiMessageText(messages[0]).slice(0, 80) || 'Conversa com imagem'
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
O pacote não guarda imagem. Enviar, guardar, expirar e mostrar de novo a imagem são do aplicativo; na
|
|
171
|
+
interface, o [Chat](/ui/chat#imagens-na-conversa) recebe as imagens prontas e mostra as que o
|
|
172
|
+
aplicativo devolve no transcript.
|
|
173
|
+
|
|
128
174
|
## Limites atuais
|
|
129
175
|
|
|
130
176
|
`run` e `runStream` aceitam o histórico da conversa, mas não mantêm memória longa nem resumem o
|
|
@@ -168,17 +168,111 @@ com o rótulo já humanizado pelo aplicativo; ou `''`, que mostra somente os pon
|
|
|
168
168
|
“Pensando…” como nome acessível. No modo autogerenciado, o Chat decide o indicador sozinho e ignora
|
|
169
169
|
`busy` e `activity`.
|
|
170
170
|
|
|
171
|
+
## Imagens na conversa
|
|
172
|
+
|
|
173
|
+
No modo controlado, a pessoa pode mandar imagens junto com o texto, como um print para a IA ler.
|
|
174
|
+
Passe `composerAttachments`, com a mesma forma do `attachments` do [Composer](/ui/composer#com-imagens-anexadas):
|
|
175
|
+
o composer ganha o botão **Anexar imagem** e a faixa de imagens, e soltar uma imagem em qualquer
|
|
176
|
+
ponto da conversa também anexa. `onSend` passa a receber, além do texto, as imagens prontas; a
|
|
177
|
+
mensagem pode ter só imagem, sem texto.
|
|
178
|
+
|
|
179
|
+
O aplicativo guarda as imagens e devolve cada uma no item `user` do transcript, em `attachments`, com
|
|
180
|
+
o endereço que a interface usa para mostrá-la. A fala da pessoa mostra as miniaturas acima do texto,
|
|
181
|
+
com o nome como texto alternativo. Quando a imagem não está mais disponível, o item traz
|
|
182
|
+
`status: 'expired'` e a conversa mostra um cartão com o nome e “Imagem expirada”.
|
|
183
|
+
|
|
184
|
+
```tsx preview
|
|
185
|
+
const nextId = useRef(2)
|
|
186
|
+
const [items, setItems] = useState<ChatTranscriptItem[]>([
|
|
187
|
+
{
|
|
188
|
+
id: '1',
|
|
189
|
+
role: 'user',
|
|
190
|
+
content: 'Esse era o painel da semana passada.',
|
|
191
|
+
attachments: [{ id: 'a0', name: 'painel-antigo.png', mediaType: 'image/png', status: 'expired' }],
|
|
192
|
+
},
|
|
193
|
+
])
|
|
194
|
+
const [pending, setPending] = useState<ComposerAttachment[]>([])
|
|
195
|
+
const sent = useRef<Record<string, { url: string; mediaType: string }>>({})
|
|
196
|
+
|
|
197
|
+
// Os endereços de objeto seguram as imagens na memória: revogue ao remover e ao desmontar.
|
|
198
|
+
function release(id: string) {
|
|
199
|
+
const image = sent.current[id]
|
|
200
|
+
if (image !== undefined) URL.revokeObjectURL(image.url)
|
|
201
|
+
delete sent.current[id]
|
|
202
|
+
}
|
|
203
|
+
useEffect(() => () => Object.keys(sent.current).forEach(release), [])
|
|
204
|
+
|
|
205
|
+
async function add(files: File[]) {
|
|
206
|
+
for (const file of files) {
|
|
207
|
+
const id = `a${nextId.current++}`
|
|
208
|
+
const prepared = await prepareImage(file)
|
|
209
|
+
if (!prepared.ok) {
|
|
210
|
+
setPending((list) => [...list, { id, name: file.name, status: 'failed', error: prepared.message }])
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
// Num app real, a imagem vai ao servidor aqui, e o endereço devolvido é o da aplicação.
|
|
214
|
+
const url = URL.createObjectURL(prepared.file)
|
|
215
|
+
sent.current[id] = { url, mediaType: prepared.mediaType }
|
|
216
|
+
setPending((list) => [...list, { id, name: prepared.file.name, status: 'ready', previewUrl: url }])
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
render(
|
|
221
|
+
<div className="h-96 rounded-lg border">
|
|
222
|
+
<Chat
|
|
223
|
+
messages={items}
|
|
224
|
+
composerAttachments={{
|
|
225
|
+
items: pending,
|
|
226
|
+
onAddFiles: (files) => void add(files),
|
|
227
|
+
onRemove: (id) => {
|
|
228
|
+
release(id)
|
|
229
|
+
setPending((list) => list.filter((item) => item.id !== id))
|
|
230
|
+
},
|
|
231
|
+
}}
|
|
232
|
+
onSend={(text, attachments = []) => {
|
|
233
|
+
setItems((list) => [
|
|
234
|
+
...list,
|
|
235
|
+
{
|
|
236
|
+
id: String(nextId.current++),
|
|
237
|
+
role: 'user',
|
|
238
|
+
content: text,
|
|
239
|
+
attachments: attachments.map((a) => ({
|
|
240
|
+
id: a.id,
|
|
241
|
+
name: a.name,
|
|
242
|
+
status: 'available',
|
|
243
|
+
...sent.current[a.id],
|
|
244
|
+
})),
|
|
245
|
+
},
|
|
246
|
+
{ id: String(nextId.current++), role: 'assistant', content: 'Recebi a mensagem.' },
|
|
247
|
+
])
|
|
248
|
+
setPending([])
|
|
249
|
+
}}
|
|
250
|
+
/>
|
|
251
|
+
</div>,
|
|
252
|
+
)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
As miniaturas não passam pelo Markdown: a fala da pessoa continua sendo o que ela escreveu. Sirva o
|
|
256
|
+
endereço pela própria aplicação ou como `data:`; o [Markdown](/ui/markdown) do pacote não carrega
|
|
257
|
+
imagem de outra origem, e as miniaturas seguem o mesmo cuidado. O modo autogerenciado não aceita
|
|
258
|
+
imagens: ele não tem como enviá-las, e `composerAttachments` é ignorado nele.
|
|
259
|
+
|
|
260
|
+
O arraste de arquivos sobre a conversa é consumido pelo Chat e não sobe para os contêineres do
|
|
261
|
+
aplicativo: um contêiner com o próprio soltar, como uma área de envio de arquivos, não recebe a mesma
|
|
262
|
+
imagem duas vezes. Sem `composerAttachments`, o arraste segue para o contêiner, como antes.
|
|
263
|
+
|
|
171
264
|
## Propriedades de Chat
|
|
172
265
|
|
|
173
266
|
| Propriedade | Tipo | Padrão | Descrição |
|
|
174
267
|
|---|---|---|---|
|
|
175
268
|
| `send` | `(messages: ChatMessage[]) => Promise<string> \| AsyncIterable<ChatEvent>` | | Modo autogerenciado: recebe o histórico com a nova mensagem e devolve a resposta inteira ou um stream de eventos. Ignorado no modo controlado. |
|
|
176
269
|
| `messages` | `ChatTranscriptItem[]` | | Modo controlado: o transcript vem do aplicativo. Com ele, `onSend`, `busy` e `activity` passam a valer. |
|
|
177
|
-
| `onSend` | `(text: string) => void \| boolean \| Promise<void \| boolean>` | | Modo controlado: recebe o texto
|
|
270
|
+
| `onSend` | `(text: string, attachments?: ComposerAttachment[]) => void \| boolean \| Promise<void \| boolean>` | | Modo controlado: recebe o texto e, quando há `composerAttachments`, as imagens prontas. Retornar `false` devolve o texto ao composer; as imagens continuam na faixa. |
|
|
178
271
|
| `busy` | `boolean` | `false` | Modo controlado: bloqueia o envio enquanto o turno corre; a digitação continua liberada. |
|
|
179
272
|
| `activity` | `string \| null` | `undefined` | Modo controlado: indicador de atividade. `undefined` esconde, `null` mostra “Pensando…”, uma string mostra o rótulo e `''` mostra somente os pontos. |
|
|
180
273
|
| `notice` | `ReactNode` | | Aviso do aplicativo acima do composer, como credencial expirada ou agente desatualizado. |
|
|
181
274
|
| `composerActions` | `ReactNode` | | Seletores discretos na barra do composer, como agente, aplicativo ou escopo. |
|
|
275
|
+
| `composerAttachments` | `ComposerAttachments` | | Modo controlado: liga as imagens anexadas no composer e o soltar sobre a conversa. Ignorado no modo autogerenciado. |
|
|
182
276
|
| `composerClassName` | `string` | | Ajusta o contêiner externo do composer sem alcançar o DOM interno. |
|
|
183
277
|
| `greeting` | `string` | | Texto do estado vazio; some quando a conversa começa. |
|
|
184
278
|
| `empty` | `ReactNode` | | Estado vazio composto pelo aplicativo; vence `greeting` quando os dois existem. |
|
|
@@ -195,6 +289,7 @@ com o rótulo já humanizado pelo aplicativo; ou `''`, que mostra somente os pon
|
|
|
195
289
|
| Tipo | Forma | Uso |
|
|
196
290
|
|---|---|---|
|
|
197
291
|
| `ChatMessage` | `{ id?, createdAt?, role: 'user' \| 'assistant', content }` | Histórico enviado a `send` e aceito por `initialMessages`. `createdAt` é um instante ISO-8601. |
|
|
198
|
-
| `ChatTranscriptItem` | `ChatMessage` com `role` também `'error'
|
|
292
|
+
| `ChatTranscriptItem` | `ChatMessage` com `role` também `'error'` e `attachments?`, ou `{ id?, createdAt?, role: 'artifact', artifact: ChatArtifact }` | Itens de `messages` no modo controlado. Mensagens `error` aparecem destacadas no transcript; `attachments` só vale na fala `user`. |
|
|
293
|
+
| `ChatAttachment` | `{ id, name, mediaType, width?, height?, status: 'available' \| 'expired', url? }` | Imagem de uma fala da pessoa. `url` é o endereço fornecido pelo aplicativo; a imagem expirada não precisa dele. |
|
|
199
294
|
| `ChatTranscriptMessage` | Os itens de `ChatTranscriptItem` que têm `content` | Argumento de `renderMessageActions`. |
|
|
200
295
|
| `ChatArtifact` | `{ kind, ref, title? }` | Algo produzido na conversa, como um link ou um relatório; `ref` é o endereço ou identificador. |
|