@softize/opus 15.0.1 → 15.2.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 +33 -0
- package/README.md +3 -3
- package/bin/lib/check.mjs +134 -11
- package/bin/lib/gen-manifest.mjs +1 -0
- package/bin/lib/gen-runner.mjs +37 -0
- package/bin/lib/introspect.mjs +16 -4
- package/docs/adr/0004-page-content-state-is-composed.md +3 -0
- package/docs/adr/0010-page-header-owns-page-chrome.md +4 -0
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +70 -0
- package/docs/adr/0012-data-products-are-first-class-declarations.md +72 -0
- package/docs/data-products.md +66 -0
- package/docs/protocol.md +12 -0
- package/package.json +15 -14
- package/registry/skills/build-opus-ui/references/ui-patterns.md +12 -2
- package/registry/skills/maintain-opus-docs/scripts/audit-docs.mjs +0 -0
- package/src/core/data-product.ts +121 -0
- package/src/core/domain.ts +56 -2
- package/src/core/index.ts +9 -0
- package/src/core/runtime.ts +27 -2
- package/src/core/types.ts +2 -0
- package/src/mcp/index.ts +1 -0
- package/src/ui/components/patterns/page.tsx +200 -18
- package/src/ui/components/patterns/surface-header.tsx +13 -4
- package/src/ui/components/primitives/chat.tsx +58 -14
- package/src/ui/docs/content/chat.md +6 -0
- package/src/ui/docs/content/page.md +99 -26
- package/src/ui/meta.ts +1 -1
- package/src/ui/react.tsx +4 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* <Page /> — esqueleto composto de página do back-office.
|
|
3
3
|
*
|
|
4
|
-
* A forma curta cobre o caso comum;
|
|
5
|
-
*
|
|
4
|
+
* A forma curta cobre o caso comum; PageShell coordena a barra persistente quando shell e rota
|
|
5
|
+
* conhecem partes diferentes. Todas preservam o container centralizado com teto de 80rem.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
@@ -39,9 +39,114 @@ interface PageContextValue {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const PageContext = createContext<PageContextValue | null>(null);
|
|
42
|
-
|
|
42
|
+
type PageHeadingRegion = PageHeaderVariant | "intro";
|
|
43
|
+
|
|
44
|
+
const PageHeaderContext = createContext<PageHeadingRegion | null>(null);
|
|
43
45
|
const PageIntegralStateContext = createContext(false);
|
|
44
46
|
const PageActionsTargetContext = createContext<HTMLElement | null>(null);
|
|
47
|
+
const PageShellContext = createContext(false);
|
|
48
|
+
const pageBarClassName =
|
|
49
|
+
"flex h-12 shrink-0 items-center border-b border-border bg-background text-foreground";
|
|
50
|
+
const pageBarContentClassName = "w-full py-2";
|
|
51
|
+
|
|
52
|
+
function PageShellNavigationSlot({
|
|
53
|
+
className,
|
|
54
|
+
...props
|
|
55
|
+
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
data-slot="page-shell-navigation"
|
|
59
|
+
className={cn("min-w-0 flex-1 overflow-hidden", className)}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function PageShellActionsSlot({
|
|
66
|
+
targetRef,
|
|
67
|
+
className,
|
|
68
|
+
...props
|
|
69
|
+
}: HTMLAttributes<HTMLDivElement> & {
|
|
70
|
+
targetRef: (target: HTMLDivElement | null) => void;
|
|
71
|
+
}): ReactElement {
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
ref={targetRef}
|
|
75
|
+
data-slot="page-shell-actions"
|
|
76
|
+
className={cn("flex shrink-0 items-center gap-2", className)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface PageShellProps extends Omit<
|
|
83
|
+
HTMLAttributes<HTMLDivElement>,
|
|
84
|
+
"children"
|
|
85
|
+
> {
|
|
86
|
+
/** Navegação contextual conhecida pelo shell, normalmente um Breadcrumb. */
|
|
87
|
+
navigation?: ReactNode;
|
|
88
|
+
/** Ações do shell ou do recurso que antecedem as ações declaradas pela Page. */
|
|
89
|
+
actions?: ReactNode;
|
|
90
|
+
/** Uma Page descendente, diretamente ou através da rota ativa. */
|
|
91
|
+
children: ReactNode;
|
|
92
|
+
/** Classes do container interno da barra. */
|
|
93
|
+
headerClassName?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Moldura persistente que coordena navegação do shell e ações da Page ativa. */
|
|
97
|
+
export function PageShell({
|
|
98
|
+
navigation,
|
|
99
|
+
actions,
|
|
100
|
+
children,
|
|
101
|
+
className,
|
|
102
|
+
headerClassName,
|
|
103
|
+
...props
|
|
104
|
+
}: PageShellProps): ReactElement {
|
|
105
|
+
const [actionsTarget, setActionsTarget] = useState<HTMLDivElement | null>(null);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
data-slot="page-shell"
|
|
110
|
+
className={cn(
|
|
111
|
+
"relative flex h-full min-h-0 min-w-0 flex-1 flex-col",
|
|
112
|
+
className,
|
|
113
|
+
)}
|
|
114
|
+
{...props}
|
|
115
|
+
>
|
|
116
|
+
<SurfaceHeader
|
|
117
|
+
name="PageShell"
|
|
118
|
+
slot="page"
|
|
119
|
+
slots={{
|
|
120
|
+
leading: PageShellNavigationSlot,
|
|
121
|
+
title: PageTitle,
|
|
122
|
+
description: PageDescription,
|
|
123
|
+
actions: PageShellActionsSlot,
|
|
124
|
+
}}
|
|
125
|
+
leadingPlacement="inline"
|
|
126
|
+
titleRequired={false}
|
|
127
|
+
contentClassName={cn(
|
|
128
|
+
pageBarContentClassName,
|
|
129
|
+
"px-3",
|
|
130
|
+
headerClassName,
|
|
131
|
+
)}
|
|
132
|
+
className={pageBarClassName}
|
|
133
|
+
data-variant="bar"
|
|
134
|
+
>
|
|
135
|
+
<PageShellNavigationSlot>{navigation}</PageShellNavigationSlot>
|
|
136
|
+
<PageShellActionsSlot targetRef={setActionsTarget}>
|
|
137
|
+
{actions}
|
|
138
|
+
</PageShellActionsSlot>
|
|
139
|
+
</SurfaceHeader>
|
|
140
|
+
<PageShellContext.Provider value>
|
|
141
|
+
<PageActionsTarget target={actionsTarget}>
|
|
142
|
+
<div data-slot="page-shell-content" className="min-h-0 flex-1">
|
|
143
|
+
{children}
|
|
144
|
+
</div>
|
|
145
|
+
</PageActionsTarget>
|
|
146
|
+
</PageShellContext.Provider>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
45
150
|
|
|
46
151
|
export function PageActionsTarget({
|
|
47
152
|
target,
|
|
@@ -97,6 +202,7 @@ export function Page({
|
|
|
97
202
|
children,
|
|
98
203
|
...props
|
|
99
204
|
}: PageProps): ReactElement {
|
|
205
|
+
const insideShell = useContext(PageShellContext);
|
|
100
206
|
const shorthand = title !== undefined;
|
|
101
207
|
const nodes = Children.toArray(children);
|
|
102
208
|
const headers = nodes.filter(
|
|
@@ -105,6 +211,9 @@ export function Page({
|
|
|
105
211
|
const bodies = nodes.filter(
|
|
106
212
|
(node) => isValidElement(node) && node.type === PageBody,
|
|
107
213
|
);
|
|
214
|
+
const intros = nodes.filter(
|
|
215
|
+
(node) => isValidElement(node) && node.type === PageIntro,
|
|
216
|
+
);
|
|
108
217
|
const activeStateCount = useRef(0);
|
|
109
218
|
const [integralState, setIntegralState] = useState(false);
|
|
110
219
|
const registerIntegralState = useCallback(() => {
|
|
@@ -127,13 +236,22 @@ export function Page({
|
|
|
127
236
|
);
|
|
128
237
|
}
|
|
129
238
|
if (!shorthand) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
239
|
+
const validIntroComposition =
|
|
240
|
+
intros.length === 1 &&
|
|
241
|
+
headers.length === 0 &&
|
|
242
|
+
bodies.length === 1 &&
|
|
243
|
+
intros.length + bodies.length === nodes.length;
|
|
244
|
+
const validStandaloneComposition =
|
|
245
|
+
!insideShell &&
|
|
246
|
+
headers.length === 1 &&
|
|
247
|
+
intros.length === 0 &&
|
|
248
|
+
bodies.length === 1 &&
|
|
249
|
+
headers.length + bodies.length === nodes.length;
|
|
250
|
+
if (!validIntroComposition && !validStandaloneComposition) {
|
|
135
251
|
throw new Error(
|
|
136
|
-
|
|
252
|
+
insideShell
|
|
253
|
+
? "Page explícito dentro de PageShell exige exatamente um PageIntro e um PageBody como filhos diretos."
|
|
254
|
+
: "Page explícito exige exatamente um PageHeader ou PageIntro e um PageBody como filhos diretos.",
|
|
137
255
|
);
|
|
138
256
|
}
|
|
139
257
|
}
|
|
@@ -146,7 +264,18 @@ export function Page({
|
|
|
146
264
|
containerClassName: className,
|
|
147
265
|
};
|
|
148
266
|
|
|
149
|
-
const content = shorthand ? (
|
|
267
|
+
const content = shorthand && insideShell ? (
|
|
268
|
+
<>
|
|
269
|
+
<PageIntro>
|
|
270
|
+
<PageTitle>{title}</PageTitle>
|
|
271
|
+
{description !== undefined && (
|
|
272
|
+
<PageDescription>{description}</PageDescription>
|
|
273
|
+
)}
|
|
274
|
+
{actions !== undefined && <PageActions>{actions}</PageActions>}
|
|
275
|
+
</PageIntro>
|
|
276
|
+
<PageBody>{children}</PageBody>
|
|
277
|
+
</>
|
|
278
|
+
) : shorthand ? (
|
|
150
279
|
<>
|
|
151
280
|
<PageHeader>
|
|
152
281
|
<PageTitle>{title}</PageTitle>
|
|
@@ -169,6 +298,7 @@ export function Page({
|
|
|
169
298
|
data-slot="page"
|
|
170
299
|
className={cn(
|
|
171
300
|
"min-w-0 flex-1",
|
|
301
|
+
insideShell && "min-h-full",
|
|
172
302
|
(headerVariant === "bar" || integralState) && "flex flex-col",
|
|
173
303
|
// A barra contém a rolagem (`min-h-0`); o estado integral ocupa a altura
|
|
174
304
|
// disponível (`min-h-full`). Emitir os dois juntos deixava o resultado por
|
|
@@ -198,6 +328,47 @@ export function Page({
|
|
|
198
328
|
);
|
|
199
329
|
}
|
|
200
330
|
|
|
331
|
+
/** Introdução opcional do conteúdo, separada da navegação persistente do shell. */
|
|
332
|
+
export function PageIntro({
|
|
333
|
+
className,
|
|
334
|
+
children,
|
|
335
|
+
...props
|
|
336
|
+
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
337
|
+
const page = useContext(PageContext);
|
|
338
|
+
const insideShell = useContext(PageShellContext);
|
|
339
|
+
const integralState = useContext(PageIntegralStateContext);
|
|
340
|
+
requireParent(page !== null, "PageIntro", "Page");
|
|
341
|
+
if (integralState) {
|
|
342
|
+
if (!insideShell) return <></>;
|
|
343
|
+
const actions = Children.toArray(children).filter(
|
|
344
|
+
(node) => isValidElement(node) && node.type === PageActions,
|
|
345
|
+
);
|
|
346
|
+
return (
|
|
347
|
+
<PageHeaderContext.Provider value="intro">
|
|
348
|
+
{actions}
|
|
349
|
+
</PageHeaderContext.Provider>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<PageHeaderContext.Provider value="intro">
|
|
355
|
+
<SurfaceHeader
|
|
356
|
+
name="PageIntro"
|
|
357
|
+
slot="page-intro"
|
|
358
|
+
slots={{
|
|
359
|
+
title: PageTitle,
|
|
360
|
+
description: PageDescription,
|
|
361
|
+
actions: PageActions,
|
|
362
|
+
}}
|
|
363
|
+
className={className}
|
|
364
|
+
{...props}
|
|
365
|
+
>
|
|
366
|
+
{children}
|
|
367
|
+
</SurfaceHeader>
|
|
368
|
+
</PageHeaderContext.Provider>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
201
372
|
/** A anatomia é a de `SurfaceHeader`, a mesma de `ContentHeader`; só os slots mudam de nome. */
|
|
202
373
|
export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
203
374
|
/** `default` fica no container; `bar` cria uma faixa compacta no topo da Page. */
|
|
@@ -228,12 +399,15 @@ export function PageHeader({
|
|
|
228
399
|
leadingPlacement={variant === "bar" ? "inline" : "above"}
|
|
229
400
|
contentClassName={
|
|
230
401
|
variant === "bar"
|
|
231
|
-
? cn(
|
|
402
|
+
? cn(
|
|
403
|
+
pageBarContentClassName,
|
|
404
|
+
"mx-auto max-w-7xl px-8",
|
|
405
|
+
page?.containerClassName,
|
|
406
|
+
)
|
|
232
407
|
: undefined
|
|
233
408
|
}
|
|
234
409
|
className={cn(
|
|
235
|
-
variant === "bar" &&
|
|
236
|
-
"shrink-0 border-b border-border bg-background text-foreground",
|
|
410
|
+
variant === "bar" && pageBarClassName,
|
|
237
411
|
className,
|
|
238
412
|
)}
|
|
239
413
|
{...props}
|
|
@@ -249,14 +423,16 @@ export function PageTitle({
|
|
|
249
423
|
...props
|
|
250
424
|
}: HTMLAttributes<HTMLHeadingElement>): ReactElement {
|
|
251
425
|
const variant = useContext(PageHeaderContext);
|
|
252
|
-
requireParent(variant !== null, "PageTitle", "PageHeader");
|
|
426
|
+
requireParent(variant !== null, "PageTitle", "PageHeader ou PageIntro");
|
|
253
427
|
return (
|
|
254
428
|
<h1
|
|
255
429
|
data-slot="page-title"
|
|
256
430
|
className={cn(
|
|
257
431
|
variant === "bar"
|
|
258
432
|
? "truncate text-sm font-semibold"
|
|
259
|
-
:
|
|
433
|
+
: variant === "intro"
|
|
434
|
+
? "text-3xl font-semibold tracking-tight"
|
|
435
|
+
: surfaceHeaderClasses.page.title,
|
|
260
436
|
className,
|
|
261
437
|
)}
|
|
262
438
|
{...props}
|
|
@@ -269,7 +445,11 @@ export function PageDescription({
|
|
|
269
445
|
...props
|
|
270
446
|
}: HTMLAttributes<HTMLParagraphElement>): ReactElement {
|
|
271
447
|
const variant = useContext(PageHeaderContext);
|
|
272
|
-
requireParent(
|
|
448
|
+
requireParent(
|
|
449
|
+
variant !== null,
|
|
450
|
+
"PageDescription",
|
|
451
|
+
"PageHeader ou PageIntro",
|
|
452
|
+
);
|
|
273
453
|
return (
|
|
274
454
|
<p
|
|
275
455
|
data-slot="page-description"
|
|
@@ -289,13 +469,14 @@ export function PageActions({
|
|
|
289
469
|
...props
|
|
290
470
|
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
291
471
|
const variant = useContext(PageHeaderContext);
|
|
292
|
-
requireParent(variant !== null, "PageActions", "PageHeader");
|
|
472
|
+
requireParent(variant !== null, "PageActions", "PageHeader ou PageIntro");
|
|
293
473
|
const target = useContext(PageActionsTargetContext);
|
|
474
|
+
const insideShell = useContext(PageShellContext);
|
|
294
475
|
const actions = (
|
|
295
476
|
<div
|
|
296
477
|
data-slot="page-actions"
|
|
297
478
|
className={cn(
|
|
298
|
-
variant === "bar"
|
|
479
|
+
variant === "bar" || insideShell
|
|
299
480
|
? "flex shrink-0 items-center gap-2"
|
|
300
481
|
: surfaceHeaderClasses.page.actions,
|
|
301
482
|
className,
|
|
@@ -303,6 +484,7 @@ export function PageActions({
|
|
|
303
484
|
{...props}
|
|
304
485
|
/>
|
|
305
486
|
);
|
|
487
|
+
if (insideShell && target === null) return <></>;
|
|
306
488
|
return target === null ? actions : createPortal(actions, target);
|
|
307
489
|
}
|
|
308
490
|
|
|
@@ -57,6 +57,8 @@ export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
57
57
|
leadingPlacement?: "above" | "inline";
|
|
58
58
|
/** Classes de um container interno quando a moldura precisa ocupar toda a largura. */
|
|
59
59
|
contentClassName?: string;
|
|
60
|
+
/** Permite uma superfície composta apenas pelas regiões leading/actions. */
|
|
61
|
+
titleRequired?: boolean;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
/** Expande fragments de primeiro nível: o shorthand monta os slots dentro de um `<>`. */
|
|
@@ -74,6 +76,7 @@ export function SurfaceHeader({
|
|
|
74
76
|
slots,
|
|
75
77
|
leadingPlacement = "above",
|
|
76
78
|
contentClassName,
|
|
79
|
+
titleRequired = true,
|
|
77
80
|
className,
|
|
78
81
|
children,
|
|
79
82
|
...props
|
|
@@ -100,7 +103,7 @@ export function SurfaceHeader({
|
|
|
100
103
|
actionSlots.length;
|
|
101
104
|
|
|
102
105
|
if (
|
|
103
|
-
titles.length !== 1 ||
|
|
106
|
+
(titleRequired ? titles.length !== 1 : titles.length > 1) ||
|
|
104
107
|
leading.length > 1 ||
|
|
105
108
|
counts.length > 1 ||
|
|
106
109
|
descriptions.length > 1 ||
|
|
@@ -130,11 +133,11 @@ export function SurfaceHeader({
|
|
|
130
133
|
throw new Error(
|
|
131
134
|
distinctLeading.size > 1
|
|
132
135
|
? `${name} aceita ${[...distinctLeading].map(label).join(" ou ")} na região introdutória, nunca os dois na mesma superfície.`
|
|
133
|
-
: `${name} exige um ${label(slots.title)} e aceita no máximo um de cada: ${optionalSlots}.`,
|
|
136
|
+
: `${name} ${titleRequired ? `exige um ${label(slots.title)}` : `aceita no máximo um ${label(slots.title)}`} e aceita no máximo um de cada: ${optionalSlots}.`,
|
|
134
137
|
);
|
|
135
138
|
}
|
|
136
139
|
|
|
137
|
-
const heading = (
|
|
140
|
+
const heading = titles.length + counts.length + descriptions.length > 0 && (
|
|
138
141
|
<div
|
|
139
142
|
data-slot={`${slot}-heading`}
|
|
140
143
|
className={cn("min-w-0", leadingPlacement === "inline" && "flex-1")}
|
|
@@ -153,7 +156,13 @@ export function SurfaceHeader({
|
|
|
153
156
|
{leadingPlacement === "inline" && leading.length > 0 && (
|
|
154
157
|
// `min-w-0` aqui é o que deixa a trilha do PageNavigation ceder e truncar; sem ele o
|
|
155
158
|
// wrapper assume o min-content do breadcrumb e a compressão toda cai sobre o título.
|
|
156
|
-
<div
|
|
159
|
+
<div
|
|
160
|
+
data-slot={`${slot}-navigation`}
|
|
161
|
+
className={cn(
|
|
162
|
+
"flex min-w-0 items-center",
|
|
163
|
+
!heading && "flex-1",
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
157
166
|
{leading}
|
|
158
167
|
</div>
|
|
159
168
|
)}
|
|
@@ -5,6 +5,10 @@ import { Composer } from './composer.tsx'
|
|
|
5
5
|
import { Markdown } from './markdown.tsx'
|
|
6
6
|
|
|
7
7
|
export interface ChatMessage {
|
|
8
|
+
/** Identidade persistida pelo app. Opcional para históricos legados. */
|
|
9
|
+
id?: string
|
|
10
|
+
/** Instante ISO-8601 persistido pelo app. Opcional para históricos legados. */
|
|
11
|
+
createdAt?: string
|
|
8
12
|
role: 'user' | 'assistant'
|
|
9
13
|
content: string
|
|
10
14
|
}
|
|
@@ -19,8 +23,10 @@ export interface ChatArtifact {
|
|
|
19
23
|
/** Item do transcript no modo CONTROLADO (o app é o dono do estado — ex.: sala
|
|
20
24
|
* server-autoritativa com replay, como o ChatRail do Maestro). */
|
|
21
25
|
export type ChatTranscriptItem =
|
|
22
|
-
| { role: 'user' | 'assistant' | 'error'; content: string }
|
|
23
|
-
| { role: 'artifact'; artifact: ChatArtifact }
|
|
26
|
+
| { id?: string; createdAt?: string; role: 'user' | 'assistant' | 'error'; content: string }
|
|
27
|
+
| { id?: string; createdAt?: string; role: 'artifact'; artifact: ChatArtifact }
|
|
28
|
+
|
|
29
|
+
export type ChatTranscriptMessage = Extract<ChatTranscriptItem, { content: string }>
|
|
24
30
|
|
|
25
31
|
/** Feedback humano do tool em uso (default pt-BR; override por prop). */
|
|
26
32
|
function defaultHumanizeTool(name: string): string {
|
|
@@ -85,6 +91,9 @@ export interface ChatProps {
|
|
|
85
91
|
kickoff?: () => Promise<string> | AsyncIterable<ChatEvent>
|
|
86
92
|
/** Render do evento `artifact` (card, iframe, preview…). Default: link com o título. */
|
|
87
93
|
renderArtifact?: (artifact: ChatArtifact) => React.ReactNode
|
|
94
|
+
/** Ações contextuais de uma mensagem. O Chat fornece apenas a anatomia; buscar dados
|
|
95
|
+
* detalhados, pedir reautorização e decidir o conteúdo pertencem ao app. */
|
|
96
|
+
renderMessageActions?: (message: ChatTranscriptMessage) => React.ReactNode
|
|
88
97
|
/** Rótulo humano do tool em uso no indicador vivo (modo autogerenciado). */
|
|
89
98
|
humanizeTool?: (name: string, detail?: string) => string
|
|
90
99
|
placeholder?: string
|
|
@@ -95,6 +104,14 @@ function isAsyncIterable(value: unknown): value is AsyncIterable<ChatEvent> {
|
|
|
95
104
|
return typeof value === 'object' && value !== null && Symbol.asyncIterator in value
|
|
96
105
|
}
|
|
97
106
|
|
|
107
|
+
function transcriptMeta(): { id: string; createdAt: string } {
|
|
108
|
+
const cryptoApi = globalThis.crypto
|
|
109
|
+
const id = typeof cryptoApi?.randomUUID === 'function'
|
|
110
|
+
? cryptoApi.randomUUID()
|
|
111
|
+
: `message_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
|
|
112
|
+
return { id, createdAt: new Date().toISOString() }
|
|
113
|
+
}
|
|
114
|
+
|
|
98
115
|
/** Agrupa o transcript em turnos (mensagem do usuário + o que veio em resposta). */
|
|
99
116
|
function groupTurns(items: ChatTranscriptItem[]): Array<{ user: ChatTranscriptItem | null; rest: ChatTranscriptItem[] }> {
|
|
100
117
|
const turns: Array<{ user: ChatTranscriptItem | null; rest: ChatTranscriptItem[] }> = []
|
|
@@ -114,6 +131,7 @@ interface ChatTranscriptProps {
|
|
|
114
131
|
greeting: string | undefined
|
|
115
132
|
empty: React.ReactNode | undefined
|
|
116
133
|
renderArtifact: ChatProps['renderArtifact']
|
|
134
|
+
renderMessageActions: ChatProps['renderMessageActions']
|
|
117
135
|
}
|
|
118
136
|
|
|
119
137
|
/**
|
|
@@ -128,6 +146,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
128
146
|
greeting,
|
|
129
147
|
empty,
|
|
130
148
|
renderArtifact,
|
|
149
|
+
renderMessageActions,
|
|
131
150
|
}: ChatTranscriptProps): React.ReactElement {
|
|
132
151
|
const scrollRef = React.useRef<HTMLDivElement>(null)
|
|
133
152
|
const turns = React.useMemo(() => groupTurns(items), [items])
|
|
@@ -164,16 +183,21 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
164
183
|
// (o agente narra o que vai fazendo), então andam juntas; quem separa é o gap
|
|
165
184
|
// MAIOR entre turnos. Com o mesmo gap nos dois níveis, um turno de oito falas
|
|
166
185
|
// curtas virava uma parede uniforme, sem começo nem fim visíveis.
|
|
167
|
-
<div key={ti} className="flex flex-col gap-1">
|
|
186
|
+
<div key={turn.user?.id ?? turn.rest[0]?.id ?? ti} className="flex flex-col gap-1">
|
|
168
187
|
{turn.user !== null && turn.user.role === 'user' && (
|
|
169
|
-
<div
|
|
188
|
+
<div
|
|
189
|
+
data-slot="chat-turn-user"
|
|
190
|
+
data-message-id={turn.user.id}
|
|
191
|
+
data-message-created-at={turn.user.createdAt}
|
|
192
|
+
className="flex justify-end py-1.5"
|
|
193
|
+
>
|
|
170
194
|
<UserTurn content={turn.user.content} />
|
|
171
195
|
</div>
|
|
172
196
|
)}
|
|
173
197
|
{turn.rest.map((item, i) => {
|
|
174
198
|
if (item.role === 'artifact') {
|
|
175
199
|
return (
|
|
176
|
-
<div key={i} data-slot="chat-artifact" className="flex justify-start">
|
|
200
|
+
<div key={item.id ?? i} data-slot="chat-artifact" data-message-id={item.id} className="flex justify-start">
|
|
177
201
|
{renderArtifact !== undefined ? (
|
|
178
202
|
renderArtifact(item.artifact)
|
|
179
203
|
) : (
|
|
@@ -192,7 +216,9 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
192
216
|
if (item.role === 'error') {
|
|
193
217
|
return (
|
|
194
218
|
<div
|
|
195
|
-
key={i}
|
|
219
|
+
key={item.id ?? i}
|
|
220
|
+
data-slot="chat-error"
|
|
221
|
+
data-message-id={item.id}
|
|
196
222
|
className="max-w-[88%] self-start rounded-xl border border-context-danger-border bg-context-danger-subtle px-3.5 py-2.5 text-sm text-context-danger-emphasis"
|
|
197
223
|
>
|
|
198
224
|
{item.content}
|
|
@@ -201,8 +227,22 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
201
227
|
}
|
|
202
228
|
// O assistente responde direto no corpo, em Markdown (convenção da casa).
|
|
203
229
|
return (
|
|
204
|
-
<div
|
|
230
|
+
<div
|
|
231
|
+
key={item.id ?? i}
|
|
232
|
+
data-slot="chat-message"
|
|
233
|
+
data-message-id={item.id}
|
|
234
|
+
data-message-created-at={item.createdAt}
|
|
235
|
+
className="group/chat-message break-words px-1 text-sm leading-snug"
|
|
236
|
+
>
|
|
205
237
|
<Markdown content={item.content} />
|
|
238
|
+
{renderMessageActions !== undefined && (
|
|
239
|
+
<div
|
|
240
|
+
data-slot="chat-message-actions"
|
|
241
|
+
className="mt-1 flex min-h-5 items-center opacity-0 transition-opacity group-hover/chat-message:opacity-100 focus-within:opacity-100"
|
|
242
|
+
>
|
|
243
|
+
{renderMessageActions(item)}
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
206
246
|
</div>
|
|
207
247
|
)
|
|
208
248
|
})}
|
|
@@ -257,6 +297,7 @@ export function Chat({
|
|
|
257
297
|
initialMessages,
|
|
258
298
|
kickoff,
|
|
259
299
|
renderArtifact,
|
|
300
|
+
renderMessageActions,
|
|
260
301
|
humanizeTool = defaultHumanizeTool,
|
|
261
302
|
placeholder = 'Escreva uma mensagem…',
|
|
262
303
|
className,
|
|
@@ -335,7 +376,7 @@ export function Chat({
|
|
|
335
376
|
return
|
|
336
377
|
}
|
|
337
378
|
|
|
338
|
-
const next: ChatTranscriptItem[] = [...ownItems, { role: 'user', content: text }]
|
|
379
|
+
const next: ChatTranscriptItem[] = [...ownItems, { ...transcriptMeta(), role: 'user', content: text }]
|
|
339
380
|
setOwnItems(next)
|
|
340
381
|
const transcript = next.filter((i): i is ChatMessage => i.role === 'user' || i.role === 'assistant')
|
|
341
382
|
await consume(() => send!(transcript))
|
|
@@ -349,16 +390,18 @@ export function Chat({
|
|
|
349
390
|
const result = run()
|
|
350
391
|
if (isAsyncIterable(result)) {
|
|
351
392
|
let streaming = false
|
|
393
|
+
let streamingMeta: { id: string; createdAt: string } | undefined
|
|
352
394
|
for await (const event of result) {
|
|
353
395
|
if (event.type === 'text') {
|
|
354
396
|
setOwnActivity(null)
|
|
355
397
|
setOwnItems((list) => {
|
|
356
398
|
const last = list[list.length - 1]
|
|
357
399
|
if (streaming && last !== undefined && last.role === 'assistant') {
|
|
358
|
-
return [...list.slice(0, -1), {
|
|
400
|
+
return [...list.slice(0, -1), { ...last, content: last.content + event.delta }]
|
|
359
401
|
}
|
|
360
402
|
streaming = true
|
|
361
|
-
|
|
403
|
+
streamingMeta = transcriptMeta()
|
|
404
|
+
return [...list, { ...streamingMeta, role: 'assistant', content: event.delta }]
|
|
362
405
|
})
|
|
363
406
|
} else if (event.type === 'tool') {
|
|
364
407
|
streaming = false
|
|
@@ -367,20 +410,20 @@ export function Chat({
|
|
|
367
410
|
streaming = false
|
|
368
411
|
setOwnItems((list) => [
|
|
369
412
|
...list,
|
|
370
|
-
{ role: 'artifact', artifact: { kind: event.kind, ref: event.ref, title: event.title } },
|
|
413
|
+
{ ...transcriptMeta(), role: 'artifact', artifact: { kind: event.kind, ref: event.ref, title: event.title } },
|
|
371
414
|
])
|
|
372
415
|
} else if (event.type === 'done') {
|
|
373
416
|
if (!event.ok) {
|
|
374
|
-
setOwnItems((list) => [...list, { role: 'error', content: event.error ?? 'Desculpe, algo falhou.' }])
|
|
417
|
+
setOwnItems((list) => [...list, { ...transcriptMeta(), role: 'error', content: event.error ?? 'Desculpe, algo falhou.' }])
|
|
375
418
|
}
|
|
376
419
|
}
|
|
377
420
|
}
|
|
378
421
|
} else {
|
|
379
422
|
const reply = await result
|
|
380
|
-
setOwnItems((list) => [...list, { role: 'assistant', content: reply }])
|
|
423
|
+
setOwnItems((list) => [...list, { ...transcriptMeta(), role: 'assistant', content: reply }])
|
|
381
424
|
}
|
|
382
425
|
} catch {
|
|
383
|
-
setOwnItems((list) => [...list, { role: 'error', content: 'Desculpe, algo falhou.' }])
|
|
426
|
+
setOwnItems((list) => [...list, { ...transcriptMeta(), role: 'error', content: 'Desculpe, algo falhou.' }])
|
|
384
427
|
} finally {
|
|
385
428
|
setOwnBusy(false)
|
|
386
429
|
setOwnActivity(null)
|
|
@@ -395,6 +438,7 @@ export function Chat({
|
|
|
395
438
|
greeting={greeting}
|
|
396
439
|
empty={empty}
|
|
397
440
|
renderArtifact={renderArtifact}
|
|
441
|
+
renderMessageActions={renderMessageActions}
|
|
398
442
|
/>
|
|
399
443
|
{/* Composer da casa (pílula elevada, enviar dentro). Extraído no <Composer> — o Chat
|
|
400
444
|
liga texto/envio e repassa `composerActions` pros seletores da conversa (agente,
|
|
@@ -92,6 +92,11 @@ Reidratação: passe `initialMessages` com o histórico persistido e troque a `k
|
|
|
92
92
|
componente ao trocar de conversa. O rótulo do indicador é customizável por
|
|
93
93
|
`humanizeTool={(name) => '…'}`.
|
|
94
94
|
|
|
95
|
+
Itens controlados podem carregar `id` e `createdAt`. O Chat os projeta no elemento da
|
|
96
|
+
mensagem e oferece `renderMessageActions` para ações contextuais discretas. O slot não
|
|
97
|
+
busca nem conhece detalhes: o app deve carregar informação complementar somente após a
|
|
98
|
+
interação da pessoa e aplicar novamente sua autorização.
|
|
99
|
+
|
|
95
100
|
## Propriedades de Chat
|
|
96
101
|
|
|
97
102
|
| Propriedade | Tipo | Padrão | Descrição |
|
|
@@ -109,5 +114,6 @@ componente ao trocar de conversa. O rótulo do indicador é customizável por
|
|
|
109
114
|
| `initialMessages` | `ChatMessage[]` | | Histórico inicial do modo autogerenciado; troque a `key` ao trocar de conversa. |
|
|
110
115
|
| `kickoff` | `() => Promise<string> \| AsyncIterable<ChatEvent>` | | Conversa que começa pelo assistente, uma vez, quando o transcript nasce vazio. |
|
|
111
116
|
| `renderArtifact` | `(artifact: ChatArtifact) => ReactNode` | link com o título | Render do evento `artifact`. |
|
|
117
|
+
| `renderMessageActions` | `(message: ChatTranscriptMessage) => ReactNode` | | Ações contextuais da mensagem; ficam visíveis em hover ou foco. |
|
|
112
118
|
| `humanizeTool` | `(name: string, detail?: string) => string` | pt-BR embutido | Rótulo humano do tool em uso no indicador vivo. |
|
|
113
119
|
| `placeholder` | `string` | `'Escreva uma mensagem…'` | Placeholder do composer. |
|