@softize/opus 25.6.0 → 26.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -1
- package/docs/adr/0017-ui-assumes-a-fixed-desktop-layout.md +19 -0
- package/docs/adr/0057-doc-browser-permite-busca-no-chrome-do-consumidor.md +152 -0
- package/docs/adr/0058-mensagem-da-ia-aceita-imagem-e-o-chat-recebe-anexos.md +201 -0
- package/docs/adr/README.md +12 -0
- package/package.json +1 -1
- package/src/ai/drivers/anthropic.ts +49 -9
- package/src/ai/index.ts +5 -2
- package/src/core/ai-message.ts +20 -0
- package/src/core/index.ts +5 -0
- package/src/core/types.ts +25 -2
- package/src/ui/components/patterns/sidebar.tsx +32 -11
- package/src/ui/components/primitives/chat.tsx +179 -22
- package/src/ui/components/primitives/composer.tsx +397 -7
- package/src/ui/docs/DocBrowser.tsx +404 -69
- package/src/ui/docs/DocCatalog.tsx +255 -0
- package/src/ui/docs/DocHeader.tsx +74 -0
- package/src/ui/docs/browser.tsx +10 -1
- package/src/ui/docs/changelog.tsx +16 -41
- package/src/ui/docs/content/actions.md +3 -3
- package/src/ui/docs/content/ai.md +46 -0
- package/src/ui/docs/content/chat.md +97 -2
- package/src/ui/docs/content/composer.md +144 -1
- package/src/ui/docs/content/customization.md +5 -1
- package/src/ui/docs/content/documentation-portal.md +190 -0
- package/src/ui/docs/content/getting-started.md +129 -87
- package/src/ui/docs/content/installation.md +94 -0
- package/src/ui/docs/content/mcp.md +2 -2
- package/src/ui/docs/content/overview.md +58 -0
- package/src/ui/docs/content/sidebar.md +4 -0
- package/src/ui/docs/index.ts +8 -1
- package/src/ui/docs/markdown.tsx +5 -3
- package/src/ui/docs/records.ts +144 -0
- package/src/ui/docs/registry.tsx +149 -59
- package/src/ui/docs/types.ts +2 -0
- package/src/ui/lib/prepare-image.ts +386 -0
- package/src/ui/meta.ts +2 -2
- package/src/ui/react.tsx +18 -1
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { useEffect, type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import { Search } from "lucide-react";
|
|
3
|
+
import { Input } from "../components/primitives/input.tsx";
|
|
4
|
+
import { Badge } from "../components/primitives/badge.tsx";
|
|
5
|
+
import {
|
|
6
|
+
Alert,
|
|
7
|
+
AlertHeader,
|
|
8
|
+
AlertTitle,
|
|
9
|
+
AlertDescription,
|
|
10
|
+
} from "../components/primitives/alert.tsx";
|
|
11
|
+
import { Markdown, markdownIt } from "../components/primitives/markdown.tsx";
|
|
12
|
+
import {
|
|
13
|
+
filterDocumentationRecords,
|
|
14
|
+
type DocumentationRecord,
|
|
15
|
+
} from "./records.ts";
|
|
16
|
+
import { navigate } from "../router.ts";
|
|
17
|
+
|
|
18
|
+
function follow(
|
|
19
|
+
event: React.MouseEvent<HTMLAnchorElement>,
|
|
20
|
+
href: string,
|
|
21
|
+
): void {
|
|
22
|
+
if (
|
|
23
|
+
event.button !== 0 ||
|
|
24
|
+
event.metaKey ||
|
|
25
|
+
event.ctrlKey ||
|
|
26
|
+
event.shiftKey ||
|
|
27
|
+
event.altKey
|
|
28
|
+
)
|
|
29
|
+
return;
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
navigate(href);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function DocumentationLink({
|
|
35
|
+
href,
|
|
36
|
+
children,
|
|
37
|
+
}: {
|
|
38
|
+
href: string;
|
|
39
|
+
children: ReactNode;
|
|
40
|
+
}): ReactElement {
|
|
41
|
+
return (
|
|
42
|
+
<a
|
|
43
|
+
href={href}
|
|
44
|
+
onClick={(event) => follow(event, href)}
|
|
45
|
+
className="underline-offset-4 hover:underline"
|
|
46
|
+
>
|
|
47
|
+
{children}
|
|
48
|
+
</a>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** O resumo orienta a escolha; links e formatação continuam no documento completo. */
|
|
53
|
+
function summaryText(source: string): string {
|
|
54
|
+
return (markdownIt.parseInline(source, {})[0]?.children ?? [])
|
|
55
|
+
.map((token) =>
|
|
56
|
+
token.type === "softbreak" || token.type === "hardbreak"
|
|
57
|
+
? " "
|
|
58
|
+
: token.content,
|
|
59
|
+
)
|
|
60
|
+
.join("");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DocCatalogProps {
|
|
64
|
+
title: string;
|
|
65
|
+
description: string;
|
|
66
|
+
records: readonly DocumentationRecord[];
|
|
67
|
+
/** Consulta local do catálogo, independente da busca documental do cabeçalho. */
|
|
68
|
+
query: string;
|
|
69
|
+
onQueryChange: (query: string) => void;
|
|
70
|
+
kind: "decisions" | "releases";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Leitura dos arquivos publicados, com busca textual e sem controles de coleção. */
|
|
74
|
+
export function DocCatalog({
|
|
75
|
+
title,
|
|
76
|
+
description,
|
|
77
|
+
records,
|
|
78
|
+
query,
|
|
79
|
+
onQueryChange,
|
|
80
|
+
kind,
|
|
81
|
+
}: DocCatalogProps): ReactElement {
|
|
82
|
+
const filtered = filterDocumentationRecords(records, query).sort(
|
|
83
|
+
(left, right) =>
|
|
84
|
+
kind === "decisions"
|
|
85
|
+
? Number(right.id) - Number(left.id)
|
|
86
|
+
: right.id.localeCompare(left.id, "en", { numeric: true }),
|
|
87
|
+
);
|
|
88
|
+
return (
|
|
89
|
+
<div data-slot="doc-catalog" className="h-full overflow-y-auto">
|
|
90
|
+
<section className="mx-auto w-full max-w-3xl px-4 py-8 md:px-8 md:py-12">
|
|
91
|
+
<h1 className="text-3xl font-semibold tracking-tight">{title}</h1>
|
|
92
|
+
<p className="mt-3 text-base text-muted-foreground">{description}</p>
|
|
93
|
+
<div className="mt-6 max-w-[28rem]">
|
|
94
|
+
<Input
|
|
95
|
+
type="search"
|
|
96
|
+
icon={<Search aria-hidden="true" />}
|
|
97
|
+
aria-label={
|
|
98
|
+
kind === "decisions" ? "Buscar nas ADRs" : "Buscar no changelog"
|
|
99
|
+
}
|
|
100
|
+
placeholder={
|
|
101
|
+
kind === "decisions" ? "Buscar nas ADRs…" : "Buscar no changelog…"
|
|
102
|
+
}
|
|
103
|
+
value={query}
|
|
104
|
+
onChange={(event) => onQueryChange(event.target.value)}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
<p
|
|
108
|
+
role="status"
|
|
109
|
+
aria-live="polite"
|
|
110
|
+
aria-atomic="true"
|
|
111
|
+
className={
|
|
112
|
+
query.trim() ? "mt-5 text-sm text-muted-foreground" : "sr-only"
|
|
113
|
+
}
|
|
114
|
+
>
|
|
115
|
+
{filtered.length} de {records.length} registros
|
|
116
|
+
</p>
|
|
117
|
+
<div className="mt-10 space-y-10">
|
|
118
|
+
{filtered.map((record) => (
|
|
119
|
+
<article
|
|
120
|
+
key={record.href}
|
|
121
|
+
data-slot="doc-catalog-entry"
|
|
122
|
+
className="border-t border-border/60 pt-6"
|
|
123
|
+
>
|
|
124
|
+
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-2">
|
|
125
|
+
<h2 className="text-xl font-semibold tracking-tight">
|
|
126
|
+
<DocumentationLink href={record.href}>
|
|
127
|
+
{kind === "decisions"
|
|
128
|
+
? `ADR ${record.id} — ${record.title}`
|
|
129
|
+
: record.id}
|
|
130
|
+
</DocumentationLink>
|
|
131
|
+
</h2>
|
|
132
|
+
{kind === "releases" && (
|
|
133
|
+
<span className="text-sm text-muted-foreground">
|
|
134
|
+
{record.date ?? "Data não informada"}
|
|
135
|
+
</span>
|
|
136
|
+
)}
|
|
137
|
+
{kind === "releases" && record.breaking !== undefined && (
|
|
138
|
+
<Badge context="danger" variant="solid">
|
|
139
|
+
Incompatível
|
|
140
|
+
</Badge>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
{kind === "decisions" ? (
|
|
144
|
+
<>
|
|
145
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
146
|
+
{record.date ?? "Data não informada"} ·{" "}
|
|
147
|
+
{record.status ?? "Status não informado"}
|
|
148
|
+
</p>
|
|
149
|
+
{record.summary && (
|
|
150
|
+
<p className="mt-3 text-sm leading-7">
|
|
151
|
+
{summaryText(record.summary)}
|
|
152
|
+
</p>
|
|
153
|
+
)}
|
|
154
|
+
</>
|
|
155
|
+
) : (
|
|
156
|
+
<>
|
|
157
|
+
<div className="mt-3">
|
|
158
|
+
<Markdown
|
|
159
|
+
content={record.content ?? record.body}
|
|
160
|
+
resolveHref={(href) =>
|
|
161
|
+
href.startsWith("#") ? `${record.href}${href}` : href
|
|
162
|
+
}
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
{record.breaking !== undefined && (
|
|
166
|
+
<Alert context="danger" role="note" className="mt-4">
|
|
167
|
+
<AlertHeader>
|
|
168
|
+
<AlertTitle>Breaking: cuidados ao atualizar</AlertTitle>
|
|
169
|
+
<AlertDescription>
|
|
170
|
+
<Markdown
|
|
171
|
+
content={record.breaking}
|
|
172
|
+
resolveHref={(href) =>
|
|
173
|
+
href.startsWith("#") ? `${record.href}${href}` : href
|
|
174
|
+
}
|
|
175
|
+
/>
|
|
176
|
+
</AlertDescription>
|
|
177
|
+
</AlertHeader>
|
|
178
|
+
</Alert>
|
|
179
|
+
)}
|
|
180
|
+
{record.afterBreaking && (
|
|
181
|
+
<div className="mt-3">
|
|
182
|
+
<Markdown
|
|
183
|
+
content={record.afterBreaking}
|
|
184
|
+
resolveHref={(href) =>
|
|
185
|
+
href.startsWith("#") ? `${record.href}${href}` : href
|
|
186
|
+
}
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</>
|
|
191
|
+
)}
|
|
192
|
+
</article>
|
|
193
|
+
))}
|
|
194
|
+
{filtered.length === 0 && (
|
|
195
|
+
<p className="border-t border-border/60 pt-6 text-sm text-muted-foreground">
|
|
196
|
+
Nenhum registro encontrado. Tente outro termo.
|
|
197
|
+
</p>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
</section>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface DocRecordDetailProps {
|
|
206
|
+
catalogLabel: string;
|
|
207
|
+
children: ReactNode;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function DocRecordDetail({
|
|
211
|
+
catalogLabel,
|
|
212
|
+
children,
|
|
213
|
+
}: DocRecordDetailProps): ReactElement {
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
let frame: number | undefined;
|
|
216
|
+
let locationKey: string | undefined;
|
|
217
|
+
const scrollToFragment = () => {
|
|
218
|
+
const nextKey = window.location.pathname + window.location.hash;
|
|
219
|
+
if (nextKey === locationKey) return;
|
|
220
|
+
locationKey = nextKey;
|
|
221
|
+
if (frame !== undefined) cancelAnimationFrame(frame);
|
|
222
|
+
if (!window.location.hash) return;
|
|
223
|
+
frame = requestAnimationFrame(() => {
|
|
224
|
+
let id = window.location.hash.slice(1);
|
|
225
|
+
try {
|
|
226
|
+
id = decodeURIComponent(id);
|
|
227
|
+
} catch {
|
|
228
|
+
/* Um fragmento inválido não interrompe a leitura. */
|
|
229
|
+
}
|
|
230
|
+
document.getElementById(id)?.scrollIntoView({ block: "start" });
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
scrollToFragment();
|
|
234
|
+
window.addEventListener("popstate", scrollToFragment);
|
|
235
|
+
window.addEventListener("hashchange", scrollToFragment);
|
|
236
|
+
return () => {
|
|
237
|
+
if (frame !== undefined) cancelAnimationFrame(frame);
|
|
238
|
+
window.removeEventListener("popstate", scrollToFragment);
|
|
239
|
+
window.removeEventListener("hashchange", scrollToFragment);
|
|
240
|
+
};
|
|
241
|
+
}, []);
|
|
242
|
+
return (
|
|
243
|
+
<div data-slot="doc-record-detail" className="h-full overflow-y-auto">
|
|
244
|
+
<div className="mx-auto max-w-3xl px-4 py-8 md:px-8">
|
|
245
|
+
<p
|
|
246
|
+
data-slot="doc-record-context"
|
|
247
|
+
className="mb-3 text-sm text-muted-foreground"
|
|
248
|
+
>
|
|
249
|
+
{catalogLabel}
|
|
250
|
+
</p>
|
|
251
|
+
<article>{children}</article>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComponentProps,
|
|
3
|
+
CSSProperties,
|
|
4
|
+
ReactElement,
|
|
5
|
+
ReactNode,
|
|
6
|
+
} from "react";
|
|
7
|
+
import { cn } from "../lib/cn.ts";
|
|
8
|
+
|
|
9
|
+
export interface DocHeaderProps extends Omit<
|
|
10
|
+
ComponentProps<"header">,
|
|
11
|
+
"children"
|
|
12
|
+
> {
|
|
13
|
+
brand: ReactNode;
|
|
14
|
+
search?: ReactNode;
|
|
15
|
+
actions?: ReactNode;
|
|
16
|
+
navigation?: ReactNode;
|
|
17
|
+
/** Largura da coluna de marca, normalmente igual à lateral. Default: 17rem. */
|
|
18
|
+
brandWidth?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Topo documental: controles alinhados e navegação global em uma linha própria. */
|
|
22
|
+
export function DocHeader({
|
|
23
|
+
brand,
|
|
24
|
+
search,
|
|
25
|
+
actions,
|
|
26
|
+
navigation,
|
|
27
|
+
brandWidth = "17rem",
|
|
28
|
+
className,
|
|
29
|
+
style,
|
|
30
|
+
...props
|
|
31
|
+
}: DocHeaderProps): ReactElement {
|
|
32
|
+
return (
|
|
33
|
+
<header
|
|
34
|
+
data-slot="doc-header"
|
|
35
|
+
className={cn("shrink-0 border-b border-border bg-background", className)}
|
|
36
|
+
style={
|
|
37
|
+
{ "--doc-header-brand-width": brandWidth, ...style } as CSSProperties
|
|
38
|
+
}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
<div
|
|
42
|
+
data-slot="doc-header-controls"
|
|
43
|
+
className="grid min-h-[4.25rem] grid-cols-[minmax(0,1fr)_auto] items-center gap-x-8 gap-y-4 px-4 py-3 md:px-8 lg:grid-cols-[var(--doc-header-brand-width)_minmax(0,1fr)_var(--doc-header-brand-width)]"
|
|
44
|
+
>
|
|
45
|
+
<div data-slot="doc-header-brand" className="min-w-0">
|
|
46
|
+
{brand}
|
|
47
|
+
</div>
|
|
48
|
+
<div
|
|
49
|
+
data-slot="doc-header-search"
|
|
50
|
+
className={cn(
|
|
51
|
+
"col-span-2 row-start-2 min-w-0 lg:col-span-1 lg:col-start-2 lg:row-start-1",
|
|
52
|
+
search == null && "hidden lg:block",
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
<div className="mx-auto w-full max-w-[22rem]">{search}</div>
|
|
56
|
+
</div>
|
|
57
|
+
<div
|
|
58
|
+
data-slot="doc-header-actions"
|
|
59
|
+
className="col-start-2 row-start-1 flex items-center justify-end gap-1 lg:col-start-3"
|
|
60
|
+
>
|
|
61
|
+
{actions}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
{navigation != null && (
|
|
65
|
+
<div
|
|
66
|
+
data-slot="doc-header-navigation"
|
|
67
|
+
className="overflow-x-auto px-4 md:px-8"
|
|
68
|
+
>
|
|
69
|
+
{navigation}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
</header>
|
|
73
|
+
);
|
|
74
|
+
}
|
package/src/ui/docs/browser.tsx
CHANGED
|
@@ -3,7 +3,16 @@
|
|
|
3
3
|
* Opus, o compilador de exemplos TSX nem os componentes usados apenas por esses exemplos.
|
|
4
4
|
*/
|
|
5
5
|
export { DocBrowser } from './DocBrowser.tsx'
|
|
6
|
-
export
|
|
6
|
+
export { DocHeader } from './DocHeader.tsx'
|
|
7
|
+
export { DocCatalog, DocRecordDetail } from './DocCatalog.tsx'
|
|
8
|
+
export type { DocCatalogProps, DocRecordDetailProps } from './DocCatalog.tsx'
|
|
9
|
+
export { decisionRecord, releaseRecords, parseChangelog, filterDocumentationRecords } from './records.ts'
|
|
10
|
+
export type { DocumentationRecord } from './records.ts'
|
|
11
|
+
export type { DocHeaderProps } from './DocHeader.tsx'
|
|
12
|
+
export type {
|
|
13
|
+
DocBrowserProps,
|
|
14
|
+
DocBrowserSearchOptions,
|
|
15
|
+
} from './DocBrowser.tsx'
|
|
7
16
|
export { docSectionsFromFolder } from './folder.tsx'
|
|
8
17
|
export type { FolderOptions, FolderPage } from './folder.tsx'
|
|
9
18
|
export {
|
|
@@ -5,43 +5,13 @@
|
|
|
5
5
|
* O parser é convenção-fina sobre o markdown (`## <versão> — <data>` + `### Breaking`);
|
|
6
6
|
* o corpo de cada trecho segue renderizado pelo DocMarkdown (fidelidade total).
|
|
7
7
|
*/
|
|
8
|
-
import { Badge } from '../react.tsx'
|
|
8
|
+
import { Alert, AlertHeader, AlertTitle, AlertDescription, Badge } from '../react.tsx'
|
|
9
9
|
import { DocMarkdown } from './markdown.tsx'
|
|
10
10
|
// A fonte é o CHANGELOG DO PACOTE (raiz) — a mesma que viaja em node_modules.
|
|
11
11
|
import changelogMd from '../../../CHANGELOG.md?raw'
|
|
12
|
-
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
date?: string
|
|
16
|
-
body: string
|
|
17
|
-
breaking?: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface ParsedChangelog {
|
|
21
|
-
intro: string
|
|
22
|
-
versions: ChangelogVersion[]
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Parseia o changelog: intro (antes do 1º `## `) + versões (`## x — data`, `### Breaking`). */
|
|
26
|
-
export function parseChangelog(source: string): ParsedChangelog {
|
|
27
|
-
const chunks = `\n${source.trim()}`.split(/\n## /)
|
|
28
|
-
const intro = chunks[0]?.trim() ?? ''
|
|
29
|
-
const versions: ChangelogVersion[] = []
|
|
30
|
-
for (const chunk of chunks.slice(1)) {
|
|
31
|
-
const nl = chunk.indexOf('\n')
|
|
32
|
-
const header = (nl === -1 ? chunk : chunk.slice(0, nl)).trim()
|
|
33
|
-
const rest = nl === -1 ? '' : chunk.slice(nl + 1)
|
|
34
|
-
const [version, date] = header.split(/\s+—\s+/)
|
|
35
|
-
const [body, breaking] = rest.split(/\n### Breaking\s*\n/)
|
|
36
|
-
versions.push({
|
|
37
|
-
version: (version ?? header).trim(),
|
|
38
|
-
...(date !== undefined ? { date: date.trim() } : {}),
|
|
39
|
-
body: (body ?? '').trim(),
|
|
40
|
-
...(breaking !== undefined ? { breaking: breaking.trim() } : {}),
|
|
41
|
-
})
|
|
42
|
-
}
|
|
43
|
-
return { intro, versions }
|
|
44
|
-
}
|
|
12
|
+
import { parseChangelog } from './records.ts'
|
|
13
|
+
export { parseChangelog } from './records.ts'
|
|
14
|
+
export type { ChangelogVersion, ParsedChangelog } from './records.ts'
|
|
45
15
|
|
|
46
16
|
export function ChangelogView({ source }: { source: string }): React.ReactElement {
|
|
47
17
|
const { intro, versions } = parseChangelog(source)
|
|
@@ -56,16 +26,21 @@ export function ChangelogView({ source }: { source: string }): React.ReactElemen
|
|
|
56
26
|
{v.date !== undefined && <span className="text-sm text-muted-foreground">{v.date}</span>}
|
|
57
27
|
{v.breaking !== undefined && <Badge context="danger" variant="solid">Incompatível</Badge>}
|
|
58
28
|
</div>
|
|
59
|
-
{v.body.length > 0 && (
|
|
29
|
+
{(v.beforeBreaking ?? v.body).length > 0 && (
|
|
60
30
|
<div className="mt-3">
|
|
61
|
-
<DocMarkdown content={v.body} />
|
|
31
|
+
<DocMarkdown content={v.beforeBreaking ?? v.body} />
|
|
62
32
|
</div>
|
|
63
33
|
)}
|
|
64
34
|
{v.breaking !== undefined && (
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
35
|
+
<Alert context="danger" role="note" className="mt-4">
|
|
36
|
+
<AlertHeader>
|
|
37
|
+
<AlertTitle>Breaking: cuidados ao atualizar</AlertTitle>
|
|
38
|
+
<AlertDescription><DocMarkdown content={v.breaking} /></AlertDescription>
|
|
39
|
+
</AlertHeader>
|
|
40
|
+
</Alert>
|
|
41
|
+
)}
|
|
42
|
+
{v.afterBreaking && (
|
|
43
|
+
<div className="mt-3"><DocMarkdown content={v.afterBreaking} /></div>
|
|
69
44
|
)}
|
|
70
45
|
</section>
|
|
71
46
|
))}
|
|
@@ -74,7 +49,7 @@ export function ChangelogView({ source }: { source: string }): React.ReactElemen
|
|
|
74
49
|
)
|
|
75
50
|
}
|
|
76
51
|
|
|
77
|
-
/** O changelog do Opus pronto
|
|
52
|
+
/** O changelog do Opus pronto para montar no portal ou na documentação embutida. */
|
|
78
53
|
export function OpusChangelog(): React.ReactElement {
|
|
79
54
|
return <ChangelogView source={changelogMd} />
|
|
80
55
|
}
|
|
@@ -222,7 +222,7 @@ receber só `{ id }`: o `id`, do tipo texto, é o único campo obrigatório da e
|
|
|
222
222
|
se houver, têm valor padrão ou são opcionais. Os caminhos usam ponto para descer em objetos; um
|
|
223
223
|
caminho que passa por uma lista vale para cada item, e um campo intermediário `null` aparece como
|
|
224
224
|
`null`. Declare em `context` só o que orienta a conversa: o exemplo deixa fora contato, documento e
|
|
225
|
-
endereço, e o nome já vai como `title`. Um caminho que termina
|
|
225
|
+
endereço, e o nome já vai como `title`. Um caminho que termina em um objeto ou em uma lista leva tudo o
|
|
226
226
|
que está abaixo dele, inclusive campos acrescentados depois à saída; para conferir num teste o que
|
|
227
227
|
cada caminho alcança, use `assistantContextLeaves`, de `@softize/opus/assistant-schema`, sobre o
|
|
228
228
|
schema de saída do manifest. Ela devolve, por caminho, as folhas em `leaves` e, em `open`, os pontos
|
|
@@ -272,7 +272,7 @@ caracteres. Um caminho que falta na saída em execução é omitido com o motivo
|
|
|
272
272
|
aviso no log, sem falhar a leitura. Quem recebe a saída por outro canal, como o MCP, aplica a mesma
|
|
273
273
|
projeção com `projectAssistantContext(assistant, output)`.
|
|
274
274
|
|
|
275
|
-
Entregue o dado ao modelo como bloco delimitado,
|
|
275
|
+
Entregue o dado ao modelo como bloco delimitado, em uma mensagem de dado, nunca no prompt de sistema.
|
|
276
276
|
`resourceDataBlock`, de `@softize/opus/ai`, monta o bloco com o tipo, o id, o título, os dados e,
|
|
277
277
|
quando houve corte ou ausência, `truncated` e `omitted`, e escapa dentro dos valores o que poderia
|
|
278
278
|
fechar o delimitador. Quando o modelo chama a action dona como ferramenta, recebe a saída completa
|
|
@@ -326,7 +326,7 @@ de uma action que não existe, ou dois cenários para a mesma action, impedem o
|
|
|
326
326
|
subir, e o erro lista os nomes.
|
|
327
327
|
|
|
328
328
|
O Opus não decide onde os cenários ficam nem se entram no repositório. Eles não fazem parte da
|
|
329
|
-
aplicação: podem ficar
|
|
329
|
+
aplicação: podem ficar em uma pasta ignorada pelo git e ser escritos durante a sessão de design,
|
|
330
330
|
quando forem necessários.
|
|
331
331
|
|
|
332
332
|
Dados de design não pertencem ao contrato. Quando uma resposta específica importar para a revisão
|
|
@@ -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. |
|