@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,7 +1,14 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
|
+
import { ImageOffIcon } from 'lucide-react'
|
|
2
3
|
import type { ChatEvent } from '../../../core/index.ts'
|
|
3
4
|
import { cn } from '../../lib/cn.ts'
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
Composer,
|
|
7
|
+
ComposerDropBridge,
|
|
8
|
+
dragHasFiles,
|
|
9
|
+
type ComposerAttachment,
|
|
10
|
+
type ComposerAttachments,
|
|
11
|
+
} from './composer.tsx'
|
|
5
12
|
import { Markdown } from './markdown.tsx'
|
|
6
13
|
|
|
7
14
|
export interface ChatMessage {
|
|
@@ -20,10 +27,35 @@ export interface ChatArtifact {
|
|
|
20
27
|
title?: string
|
|
21
28
|
}
|
|
22
29
|
|
|
30
|
+
/** Estado de um anexo já enviado: com a imagem disponível ou expirada pela retenção do app. */
|
|
31
|
+
export type ChatAttachmentStatus = 'available' | 'expired'
|
|
32
|
+
|
|
33
|
+
/** Imagem anexada a uma fala da pessoa (ADR 0058). A guarda é do app, que fornece o endereço. */
|
|
34
|
+
export interface ChatAttachment {
|
|
35
|
+
id: string
|
|
36
|
+
/** Nome do arquivo; é o texto alternativo da miniatura e o título do cartão de expirada. */
|
|
37
|
+
name: string
|
|
38
|
+
/** Tipo da imagem (`image/png`, `image/jpeg`, `image/webp`). */
|
|
39
|
+
mediaType: string
|
|
40
|
+
width?: number
|
|
41
|
+
height?: number
|
|
42
|
+
status: ChatAttachmentStatus
|
|
43
|
+
/** Endereço da imagem, servido pela própria aplicação ou em `data:` (ADR 0050). Ausente na
|
|
44
|
+
* imagem expirada. */
|
|
45
|
+
url?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
23
48
|
/** Item do transcript no modo CONTROLADO (o app é o dono do estado — ex.: sala
|
|
24
49
|
* server-autoritativa com replay, como o ChatRail do Maestro). */
|
|
25
50
|
export type ChatTranscriptItem =
|
|
26
|
-
| {
|
|
51
|
+
| {
|
|
52
|
+
id?: string
|
|
53
|
+
createdAt?: string
|
|
54
|
+
role: 'user' | 'assistant' | 'error'
|
|
55
|
+
content: string
|
|
56
|
+
/** Imagens da fala `user`, mostradas acima do texto. Ignorado nos outros papéis. */
|
|
57
|
+
attachments?: ChatAttachment[]
|
|
58
|
+
}
|
|
27
59
|
| { id?: string; createdAt?: string; role: 'artifact'; artifact: ChatArtifact }
|
|
28
60
|
|
|
29
61
|
export type ChatTranscriptMessage = Extract<ChatTranscriptItem, { content: string }>
|
|
@@ -37,9 +69,61 @@ function defaultHumanizeTool(name: string): string {
|
|
|
37
69
|
return 'Trabalhando…'
|
|
38
70
|
}
|
|
39
71
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
72
|
+
/** As imagens de uma fala da pessoa, acima do texto. São um componente próprio, e não `![]()`
|
|
73
|
+
* no Markdown: a fala continua sendo só o que a pessoa escreveu (ADR 0058). */
|
|
74
|
+
function ChatUserAttachments({ attachments }: { attachments: ChatAttachment[] }): React.ReactElement {
|
|
42
75
|
return (
|
|
76
|
+
<ul
|
|
77
|
+
data-slot="chat-user-attachments"
|
|
78
|
+
aria-label="Imagens enviadas"
|
|
79
|
+
className="flex max-w-[88%] flex-wrap justify-end gap-1.5 self-end"
|
|
80
|
+
>
|
|
81
|
+
{attachments.map((attachment) =>
|
|
82
|
+
attachment.status === 'available' && attachment.url !== undefined ? (
|
|
83
|
+
<li key={attachment.id} data-slot="chat-attachment" data-status="available">
|
|
84
|
+
<img
|
|
85
|
+
src={attachment.url}
|
|
86
|
+
alt={attachment.name}
|
|
87
|
+
width={attachment.width}
|
|
88
|
+
height={attachment.height}
|
|
89
|
+
loading="lazy"
|
|
90
|
+
decoding="async"
|
|
91
|
+
className="h-24 w-auto max-w-48 rounded-lg border border-border bg-muted object-cover"
|
|
92
|
+
/>
|
|
93
|
+
</li>
|
|
94
|
+
) : (
|
|
95
|
+
<li
|
|
96
|
+
key={attachment.id}
|
|
97
|
+
data-slot="chat-attachment"
|
|
98
|
+
data-status={attachment.status}
|
|
99
|
+
className="flex max-w-48 items-center gap-2 rounded-lg border border-dashed border-border bg-muted/40 px-3 py-2 text-xs"
|
|
100
|
+
>
|
|
101
|
+
<ImageOffIcon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
|
102
|
+
<span className="min-w-0 leading-tight">
|
|
103
|
+
<span className="block truncate font-medium text-foreground" title={attachment.name}>
|
|
104
|
+
{attachment.name}
|
|
105
|
+
</span>
|
|
106
|
+
{attachment.status === 'expired' && (
|
|
107
|
+
<span className="block text-muted-foreground">Imagem expirada</span>
|
|
108
|
+
)}
|
|
109
|
+
</span>
|
|
110
|
+
</li>
|
|
111
|
+
),
|
|
112
|
+
)}
|
|
113
|
+
</ul>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A fala do usuário: fundo discreto, largura pelo conteúdo e sem se sobrepor ao histórico.
|
|
118
|
+
* As imagens vêm acima do texto; uma fala só com imagem não mostra balão vazio. */
|
|
119
|
+
const UserTurn = React.memo(function UserTurn({
|
|
120
|
+
content,
|
|
121
|
+
attachments,
|
|
122
|
+
}: {
|
|
123
|
+
content: string
|
|
124
|
+
attachments: ChatAttachment[] | undefined
|
|
125
|
+
}): React.ReactElement {
|
|
126
|
+
const bubble = content !== '' && (
|
|
43
127
|
<div
|
|
44
128
|
data-slot="chat-user"
|
|
45
129
|
className="w-fit max-w-[88%] self-end break-words rounded-xl bg-muted px-3 py-2 text-sm leading-snug"
|
|
@@ -47,6 +131,14 @@ const UserTurn = React.memo(function UserTurn({ content }: { content: string }):
|
|
|
47
131
|
<Markdown content={content} />
|
|
48
132
|
</div>
|
|
49
133
|
)
|
|
134
|
+
if (attachments === undefined || attachments.length === 0) return <>{bubble}</>
|
|
135
|
+
// O espaço entre imagens e texto fica neste invólucro, sem mudar o ritmo da fala só com texto.
|
|
136
|
+
return (
|
|
137
|
+
<div data-slot="chat-user-content" className="flex w-full flex-col items-end gap-1.5">
|
|
138
|
+
<ChatUserAttachments attachments={attachments} />
|
|
139
|
+
{bubble}
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
50
142
|
})
|
|
51
143
|
|
|
52
144
|
export interface ChatProps {
|
|
@@ -58,9 +150,11 @@ export interface ChatProps {
|
|
|
58
150
|
/** Modo CONTROLADO: o transcript vem daqui e o app é o dono do estado (transporte
|
|
59
151
|
* próprio — SSE com replay, etc.). Presente → `onSend`/`busy`/`activity` assumem. */
|
|
60
152
|
messages?: ChatTranscriptItem[]
|
|
61
|
-
/** Modo controlado: recebe o texto enviado
|
|
62
|
-
*
|
|
63
|
-
|
|
153
|
+
/** Modo controlado: recebe o texto enviado e, só quando há `composerAttachments`, os anexos
|
|
154
|
+
* prontos (lista possivelmente vazia). Sem `composerAttachments`, a chamada é `onSend(text)`,
|
|
155
|
+
* como antes. Retornar `false` devolve o texto ao composer (falha de envio — nada de perder o
|
|
156
|
+
* que o usuário digitou); os anexos continuam na faixa, que é do app. */
|
|
157
|
+
onSend?: (text: string, attachments?: ComposerAttachment[]) => void | boolean | Promise<void | boolean>
|
|
64
158
|
/** Modo controlado: trava o composer enquanto o turno corre. */
|
|
65
159
|
busy?: boolean
|
|
66
160
|
/** Modo controlado: indicador vivo — `undefined` esconde, `null` mostra "Pensando…",
|
|
@@ -71,6 +165,10 @@ export interface ChatProps {
|
|
|
71
165
|
/** Seletores discretos na barra do composer (o `actions` do `<Composer>`): agente,
|
|
72
166
|
* app, escopo… O lugar deles é a barra — `notice` é pra aviso, não pra controle. */
|
|
73
167
|
composerActions?: React.ReactNode
|
|
168
|
+
/** Modo controlado: anexos da mensagem (o `attachments` do `<Composer>`, ADR 0058). Liga a
|
|
169
|
+
* faixa de anexos, o botão **Anexar imagem**, colar e soltar imagens sobre a conversa. O app
|
|
170
|
+
* prepara, envia, guarda e limpa os anexos. Ignorado no modo autogerenciado. */
|
|
171
|
+
composerAttachments?: ComposerAttachments
|
|
74
172
|
/** Ajuste do contêiner externo do composer. O Chat mantém `p-3` por padrão; shells
|
|
75
173
|
* integrados podem alinhar esse inset aos painéis vizinhos sem alcançar o DOM interno. */
|
|
76
174
|
composerClassName?: string
|
|
@@ -192,7 +290,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
192
290
|
data-message-created-at={turn.user.createdAt}
|
|
193
291
|
className="group/chat-message mb-1 flex flex-col items-end justify-end py-1"
|
|
194
292
|
>
|
|
195
|
-
<UserTurn content={turn.user.content} />
|
|
293
|
+
<UserTurn content={turn.user.content} attachments={turn.user.attachments} />
|
|
196
294
|
{renderMessageActions !== undefined && (
|
|
197
295
|
<div
|
|
198
296
|
data-slot="chat-message-actions"
|
|
@@ -305,6 +403,7 @@ export function Chat({
|
|
|
305
403
|
activity: activityProp,
|
|
306
404
|
notice,
|
|
307
405
|
composerActions,
|
|
406
|
+
composerAttachments,
|
|
308
407
|
composerClassName,
|
|
309
408
|
greeting,
|
|
310
409
|
empty,
|
|
@@ -323,13 +422,21 @@ export function Chat({
|
|
|
323
422
|
const [ownActivity, setOwnActivity] = React.useState<string | null>(null)
|
|
324
423
|
const historyCursor = React.useRef<number | null>(null)
|
|
325
424
|
const historyDraft = React.useRef('')
|
|
425
|
+
// Anexo só no modo controlado: o autogerenciado não tem transporte para a imagem (ADR 0058).
|
|
426
|
+
const attachments = controlled ? composerAttachments : undefined
|
|
427
|
+
const dropIntake = React.useRef<((files: File[]) => void) | null>(null)
|
|
428
|
+
const dragDepth = React.useRef(0)
|
|
429
|
+
const [dragging, setDragging] = React.useState(false)
|
|
326
430
|
|
|
327
431
|
const items = controlled ? messages : ownItems
|
|
328
432
|
const busy = controlled ? (busyProp ?? false) : ownBusy
|
|
329
433
|
// Indicador: no controlado o app manda (undefined esconde); no autogerenciado segue o busy.
|
|
330
434
|
const indicator = controlled ? activityProp : ownBusy ? ownActivity : undefined
|
|
331
435
|
const inputHistory = React.useMemo(
|
|
332
|
-
() =>
|
|
436
|
+
() =>
|
|
437
|
+
items
|
|
438
|
+
.filter((item): item is ChatMessage => item.role === 'user' && item.content !== '')
|
|
439
|
+
.map((item) => item.content),
|
|
333
440
|
[items],
|
|
334
441
|
)
|
|
335
442
|
|
|
@@ -379,13 +486,16 @@ export function Chat({
|
|
|
379
486
|
|
|
380
487
|
async function submit(): Promise<void> {
|
|
381
488
|
const text = input.trim()
|
|
382
|
-
|
|
489
|
+
const ready = (attachments?.items ?? []).filter((item) => item.status === 'ready')
|
|
490
|
+
if ((text === '' && ready.length === 0) || busy) return
|
|
383
491
|
historyCursor.current = null
|
|
384
492
|
historyDraft.current = ''
|
|
385
493
|
setInput('')
|
|
386
494
|
|
|
387
495
|
if (controlled) {
|
|
388
|
-
|
|
496
|
+
// Sem anexo ligado, a chamada é a de sempre: um handler que já tinha um segundo parâmetro
|
|
497
|
+
// opcional, de outro significado, não recebe uma lista inesperada.
|
|
498
|
+
const ok = attachments !== undefined ? await onSend?.(text, ready) : await onSend?.(text)
|
|
389
499
|
if (ok === false) setInput(text)
|
|
390
500
|
return
|
|
391
501
|
}
|
|
@@ -444,8 +554,52 @@ export function Chat({
|
|
|
444
554
|
}
|
|
445
555
|
}
|
|
446
556
|
|
|
557
|
+
// Soltar sobre a conversa inteira: a contagem de profundidade ignora o entra-e-sai dos filhos.
|
|
558
|
+
// O arraste de arquivos sobre o Chat é consumido aqui e não sobe: um contêiner do app com o
|
|
559
|
+
// próprio soltar (e a própria dica) não recebe o mesmo arraste uma segunda vez.
|
|
560
|
+
const dropHandlers: React.HTMLAttributes<HTMLDivElement> =
|
|
561
|
+
attachments === undefined
|
|
562
|
+
? {}
|
|
563
|
+
: {
|
|
564
|
+
onDragEnter: (e) => {
|
|
565
|
+
if (!dragHasFiles(e.dataTransfer)) return
|
|
566
|
+
e.stopPropagation()
|
|
567
|
+
dragDepth.current += 1
|
|
568
|
+
setDragging(true)
|
|
569
|
+
},
|
|
570
|
+
onDragOver: (e) => {
|
|
571
|
+
if (!dragHasFiles(e.dataTransfer)) return
|
|
572
|
+
e.preventDefault()
|
|
573
|
+
e.stopPropagation()
|
|
574
|
+
e.dataTransfer.dropEffect = 'copy'
|
|
575
|
+
},
|
|
576
|
+
onDragLeave: (e) => {
|
|
577
|
+
if (!dragHasFiles(e.dataTransfer)) return
|
|
578
|
+
e.stopPropagation()
|
|
579
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1)
|
|
580
|
+
if (dragDepth.current === 0) setDragging(false)
|
|
581
|
+
},
|
|
582
|
+
onDrop: (e) => {
|
|
583
|
+
if (!dragHasFiles(e.dataTransfer)) return
|
|
584
|
+
e.preventDefault()
|
|
585
|
+
e.stopPropagation()
|
|
586
|
+
dragDepth.current = 0
|
|
587
|
+
setDragging(false)
|
|
588
|
+
dropIntake.current?.(Array.from(e.dataTransfer.files))
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
|
|
447
592
|
return (
|
|
448
|
-
<div data-slot="chat" className={cn('flex h-full flex-col', className)}>
|
|
593
|
+
<div data-slot="chat" className={cn('relative flex h-full flex-col', className)} {...dropHandlers}>
|
|
594
|
+
{dragging && (
|
|
595
|
+
<div
|
|
596
|
+
data-slot="chat-drop-hint"
|
|
597
|
+
aria-hidden
|
|
598
|
+
className="pointer-events-none absolute inset-2 z-10 grid place-items-center rounded-xl border-2 border-dashed border-primary bg-background/85 text-sm text-foreground"
|
|
599
|
+
>
|
|
600
|
+
Solte imagens para anexá-las à conversa.
|
|
601
|
+
</div>
|
|
602
|
+
)}
|
|
449
603
|
<ChatTranscript
|
|
450
604
|
items={items}
|
|
451
605
|
indicator={indicator}
|
|
@@ -459,16 +613,19 @@ export function Chat({
|
|
|
459
613
|
app…); o <Composer> sozinho segue sendo o caminho de quem não tem chat. */}
|
|
460
614
|
<div data-slot="chat-composer" className={cn('shrink-0 p-3', composerClassName)}>
|
|
461
615
|
{notice}
|
|
462
|
-
<
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
616
|
+
<ComposerDropBridge.Provider value={dropIntake}>
|
|
617
|
+
<Composer
|
|
618
|
+
value={input}
|
|
619
|
+
onChange={changeInput}
|
|
620
|
+
onSubmit={() => void submit()}
|
|
621
|
+
onHistoryPrevious={showPreviousInput}
|
|
622
|
+
onHistoryNext={showNextInput}
|
|
623
|
+
busy={busy}
|
|
624
|
+
placeholder={placeholder}
|
|
625
|
+
actions={composerActions}
|
|
626
|
+
attachments={attachments}
|
|
627
|
+
/>
|
|
628
|
+
</ComposerDropBridge.Provider>
|
|
472
629
|
</div>
|
|
473
630
|
</div>
|
|
474
631
|
)
|