@aphrody/m3-ai 3.3.2 → 3.3.5
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/README.md +29 -7
- package/dist/A2APeerInspector.d.ts +41 -0
- package/dist/A2APeerInspector.d.ts.map +1 -0
- package/dist/A2ATaskTimeline.d.ts +28 -0
- package/dist/A2ATaskTimeline.d.ts.map +1 -0
- package/dist/AgUiEventStream.d.ts +87 -0
- package/dist/AgUiEventStream.d.ts.map +1 -0
- package/dist/GeminiAccountMenu.d.ts +55 -0
- package/dist/GeminiAccountMenu.d.ts.map +1 -0
- package/dist/GeminiPersonalContextEdit.d.ts +29 -0
- package/dist/GeminiPersonalContextEdit.d.ts.map +1 -0
- package/dist/GoogleSpotlightBar.d.ts +32 -0
- package/dist/GoogleSpotlightBar.d.ts.map +1 -0
- package/dist/Markdown.d.ts +2 -1
- package/dist/Markdown.d.ts.map +1 -1
- package/dist/McpAppCard.d.ts +24 -0
- package/dist/McpAppCard.d.ts.map +1 -0
- package/dist/ModelSelector.d.ts.map +1 -1
- package/dist/TaskRow.d.ts.map +1 -1
- package/dist/ToolCall.d.ts.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3629 -1950
- package/dist/index.js.map +32 -1
- package/package.json +17 -20
- package/src/A2APeerInspector.tsx +370 -0
- package/src/A2ATaskTimeline.tsx +218 -0
- package/src/AgUiEventStream.ts +492 -0
- package/src/ChatComposer.tsx +261 -0
- package/src/ConversationList.tsx +207 -0
- package/src/GeminiAccountMenu.tsx +212 -0
- package/src/GeminiPersonalContextEdit.tsx +147 -0
- package/src/GoogleSpotlightBar.tsx +165 -0
- package/src/InlineBranchCard.tsx +97 -0
- package/src/InlineWorkerCard.tsx +182 -0
- package/src/Markdown.tsx +769 -0
- package/src/McpAppCard.tsx +161 -0
- package/src/MemoryPanel.tsx +176 -0
- package/src/MessageBubble.tsx +143 -0
- package/src/ModelSelector.tsx +319 -0
- package/src/ModelUnavailable.tsx +116 -0
- package/src/TaskCreateForm.tsx +123 -0
- package/src/TaskDetail.tsx +224 -0
- package/src/TaskList.tsx +136 -0
- package/src/TaskPriorityIcon.tsx +39 -0
- package/src/TaskRow.tsx +177 -0
- package/src/TaskStatusIcon.tsx +39 -0
- package/src/ToolCall.tsx +644 -0
- package/src/chatIcons.tsx +105 -0
- package/src/chatLabels.ts +243 -0
- package/src/citations.tsx +286 -0
- package/src/index.ts +173 -0
- package/src/internal.tsx +107 -0
- package/src/openaiChatStream.ts +400 -0
- package/src/types.ts +212 -0
- package/src/useOpenAIChat.ts +126 -0
package/src/Markdown.tsx
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0 AND MIT
|
|
2
|
+
// Derived from SpaceUI (packages/aphrody-spaceui/packages/ai/src/Markdown.tsx), MIT, rebuilt on Material Design 3.
|
|
3
|
+
|
|
4
|
+
"use client";
|
|
5
|
+
|
|
6
|
+
import { clsx } from "clsx";
|
|
7
|
+
import { forwardRef, isValidElement, useEffect, useRef, useState, type ReactNode } from "react";
|
|
8
|
+
import ReactMarkdown, { type Components } from "react-markdown";
|
|
9
|
+
import rehypeRaw from "rehype-raw";
|
|
10
|
+
import remarkGfm from "remark-gfm";
|
|
11
|
+
|
|
12
|
+
import { type ChatLabels, resolveChatLabels } from "./chatLabels";
|
|
13
|
+
import { ChatIcon } from "./chatIcons";
|
|
14
|
+
|
|
15
|
+
type MarkdownSize = "small" | "medium" | "large";
|
|
16
|
+
|
|
17
|
+
interface MarkdownProps {
|
|
18
|
+
content: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
/** Body typescale of paragraphs and lists: `text-body-small|medium|large`. Default `medium`. */
|
|
21
|
+
size?: MarkdownSize;
|
|
22
|
+
/**
|
|
23
|
+
* Render raw HTML embedded in the markdown (rehype-raw). Off by default: only enable it for
|
|
24
|
+
* trusted content.
|
|
25
|
+
*/
|
|
26
|
+
allowHtml?: boolean;
|
|
27
|
+
/** Element overrides merged over the M3 renderers (e.g. citation links). */
|
|
28
|
+
components?: Components;
|
|
29
|
+
/**
|
|
30
|
+
* Called on every link activation. Call `event.preventDefault()` to handle it yourself (e.g.
|
|
31
|
+
* open the system browser from a Tauri webview).
|
|
32
|
+
*/
|
|
33
|
+
onLinkClick?: MarkdownLinkHandler;
|
|
34
|
+
/** Replaces the link element entirely. */
|
|
35
|
+
renderLink?: MarkdownLinkRenderer;
|
|
36
|
+
/** Visible strings of the code blocks; English defaults. */
|
|
37
|
+
labels?: Partial<ChatLabels>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Link activation callback of {@link Markdown}. */
|
|
41
|
+
export type MarkdownLinkHandler = (
|
|
42
|
+
href: string,
|
|
43
|
+
event: React.MouseEvent<HTMLAnchorElement>,
|
|
44
|
+
) => void;
|
|
45
|
+
|
|
46
|
+
/** Custom link renderer of {@link Markdown}. */
|
|
47
|
+
export type MarkdownLinkRenderer = (link: {
|
|
48
|
+
href: string;
|
|
49
|
+
children: React.ReactNode;
|
|
50
|
+
external: boolean;
|
|
51
|
+
}) => React.ReactNode;
|
|
52
|
+
|
|
53
|
+
/** Whether `href` leaves the page: an absolute URL with a scheme, or protocol-relative. */
|
|
54
|
+
export function isExternalHref(href: string | undefined): boolean {
|
|
55
|
+
if (!href) return false;
|
|
56
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Safe defaults of an external link: a new browsing context without opener or referrer. */
|
|
60
|
+
export const externalLinkProps = { target: "_blank", rel: "noopener noreferrer" } as const;
|
|
61
|
+
|
|
62
|
+
const bodyClass: Record<MarkdownSize, string> = {
|
|
63
|
+
small: "text-body-small",
|
|
64
|
+
medium: "text-body-medium",
|
|
65
|
+
large: "text-body-large",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function extractText(node: ReactNode): string {
|
|
69
|
+
if (typeof node === "string") return node;
|
|
70
|
+
if (typeof node === "number") return String(node);
|
|
71
|
+
if (Array.isArray(node)) return node.map(extractText).join("");
|
|
72
|
+
if (isValidElement(node)) return extractText((node.props as { children?: ReactNode }).children);
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Language detection & mapping
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
const LANGUAGE_MAP: Record<string, string> = {
|
|
81
|
+
typescript: "TypeScript",
|
|
82
|
+
ts: "TypeScript",
|
|
83
|
+
tsx: "TSX",
|
|
84
|
+
javascript: "JavaScript",
|
|
85
|
+
js: "JavaScript",
|
|
86
|
+
jsx: "JSX",
|
|
87
|
+
python: "Python",
|
|
88
|
+
py: "Python",
|
|
89
|
+
rust: "Rust",
|
|
90
|
+
rs: "Rust",
|
|
91
|
+
bash: "Bash",
|
|
92
|
+
sh: "Bash",
|
|
93
|
+
shell: "Bash",
|
|
94
|
+
zsh: "Bash",
|
|
95
|
+
json: "JSON",
|
|
96
|
+
jsonc: "JSONC",
|
|
97
|
+
sql: "SQL",
|
|
98
|
+
html: "HTML",
|
|
99
|
+
css: "CSS",
|
|
100
|
+
scss: "SCSS",
|
|
101
|
+
sass: "Sass",
|
|
102
|
+
yaml: "YAML",
|
|
103
|
+
yml: "YAML",
|
|
104
|
+
markdown: "Markdown",
|
|
105
|
+
md: "Markdown",
|
|
106
|
+
go: "Go",
|
|
107
|
+
golang: "Go",
|
|
108
|
+
java: "Java",
|
|
109
|
+
c: "C",
|
|
110
|
+
cpp: "C++",
|
|
111
|
+
"c++": "C++",
|
|
112
|
+
csharp: "C#",
|
|
113
|
+
cs: "C#",
|
|
114
|
+
"c#": "C#",
|
|
115
|
+
ruby: "Ruby",
|
|
116
|
+
rb: "Ruby",
|
|
117
|
+
php: "PHP",
|
|
118
|
+
swift: "Swift",
|
|
119
|
+
kotlin: "Kotlin",
|
|
120
|
+
kt: "Kotlin",
|
|
121
|
+
xml: "XML",
|
|
122
|
+
toml: "TOML",
|
|
123
|
+
dockerfile: "Dockerfile",
|
|
124
|
+
docker: "Dockerfile",
|
|
125
|
+
graphql: "GraphQL",
|
|
126
|
+
gql: "GraphQL",
|
|
127
|
+
wasm: "WebAssembly",
|
|
128
|
+
lua: "Lua",
|
|
129
|
+
r: "R",
|
|
130
|
+
diff: "Diff",
|
|
131
|
+
plaintext: "Text",
|
|
132
|
+
text: "Text",
|
|
133
|
+
txt: "Text",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function detectLanguage(raw?: string): { raw: string; label: string } {
|
|
137
|
+
if (!raw) return { raw: "", label: "Code" };
|
|
138
|
+
const cleaned = raw
|
|
139
|
+
.replace(/^language-|^lang-/, "")
|
|
140
|
+
.toLowerCase()
|
|
141
|
+
.trim();
|
|
142
|
+
if (LANGUAGE_MAP[cleaned]) {
|
|
143
|
+
return { raw: cleaned, label: LANGUAGE_MAP[cleaned] };
|
|
144
|
+
}
|
|
145
|
+
const label =
|
|
146
|
+
cleaned.length <= 3
|
|
147
|
+
? cleaned.toUpperCase()
|
|
148
|
+
: cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
|
149
|
+
return { raw: cleaned, label };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// CodeBlock component
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
export interface CodeBlockProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onCopy"> {
|
|
157
|
+
code?: string;
|
|
158
|
+
language?: string;
|
|
159
|
+
onCopy?: (code: string) => void;
|
|
160
|
+
children?: ReactNode;
|
|
161
|
+
/** `copy`, `copyCode`, `copied`; English defaults. */
|
|
162
|
+
labels?: Partial<ChatLabels>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function CodeBlock({
|
|
166
|
+
code: explicitCode,
|
|
167
|
+
language: explicitLang,
|
|
168
|
+
onCopy,
|
|
169
|
+
className,
|
|
170
|
+
children,
|
|
171
|
+
labels,
|
|
172
|
+
...restProps
|
|
173
|
+
}: CodeBlockProps) {
|
|
174
|
+
const l = resolveChatLabels(labels);
|
|
175
|
+
// Strip node passed by ReactMarkdown to avoid rendering node="[object Object]" in DOM
|
|
176
|
+
const { node: _node, ...props } = restProps as Record<string, any>;
|
|
177
|
+
|
|
178
|
+
const [copied, setCopied] = useState(false);
|
|
179
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
return () => {
|
|
183
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
184
|
+
};
|
|
185
|
+
}, []);
|
|
186
|
+
|
|
187
|
+
const childElement = isValidElement(children) ? children : null;
|
|
188
|
+
const childProps = childElement
|
|
189
|
+
? (childElement.props as { className?: string; children?: ReactNode })
|
|
190
|
+
: null;
|
|
191
|
+
|
|
192
|
+
const rawLangMatch =
|
|
193
|
+
explicitLang ??
|
|
194
|
+
className?.match(/language-([a-zA-Z0-9_+#-]+)/)?.[1] ??
|
|
195
|
+
childProps?.className?.match(/language-([a-zA-Z0-9_+#-]+)/)?.[1];
|
|
196
|
+
|
|
197
|
+
const { raw: rawLang, label: languageLabel } = detectLanguage(rawLangMatch);
|
|
198
|
+
|
|
199
|
+
const rawCode =
|
|
200
|
+
explicitCode ??
|
|
201
|
+
(childProps?.children ? extractText(childProps.children) : extractText(children));
|
|
202
|
+
|
|
203
|
+
const codeContent = rawCode;
|
|
204
|
+
const codeToCopy = rawCode.replace(/\n$/, "");
|
|
205
|
+
|
|
206
|
+
const handleCopy = async () => {
|
|
207
|
+
try {
|
|
208
|
+
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
|
209
|
+
await navigator.clipboard.writeText(codeToCopy);
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
// Ignore clipboard API failures in non-secure or test environments
|
|
213
|
+
}
|
|
214
|
+
onCopy?.(codeToCopy);
|
|
215
|
+
setCopied(true);
|
|
216
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
217
|
+
timeoutRef.current = setTimeout(() => {
|
|
218
|
+
setCopied(false);
|
|
219
|
+
}, 2000);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div
|
|
224
|
+
className={clsx(
|
|
225
|
+
"my-3 overflow-hidden rounded-medium border border-outline-variant bg-surface-container-highest",
|
|
226
|
+
className,
|
|
227
|
+
)}
|
|
228
|
+
{...props}
|
|
229
|
+
>
|
|
230
|
+
<div className="flex items-center justify-between border-b border-outline-variant bg-surface-container px-3 py-1.5">
|
|
231
|
+
<span className="inline-flex items-center rounded-extra-small bg-surface-container-high px-2 py-0.5 text-label-small font-medium text-on-surface-variant border border-outline-variant/60">
|
|
232
|
+
{languageLabel}
|
|
233
|
+
</span>
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
onClick={handleCopy}
|
|
237
|
+
className={clsx(
|
|
238
|
+
"inline-flex items-center gap-1.5 rounded-small px-2 py-1 text-label-small font-medium transition-colors duration-200",
|
|
239
|
+
copied
|
|
240
|
+
? "text-success"
|
|
241
|
+
: "text-on-surface-variant hover:bg-on-surface/8 hover:text-on-surface active:bg-on-surface/12",
|
|
242
|
+
"focus-visible:outline-2 focus-visible:outline-primary",
|
|
243
|
+
)}
|
|
244
|
+
aria-label={copied ? l.copied : l.copyCode}
|
|
245
|
+
title={copied ? l.copied : l.copyCode}
|
|
246
|
+
>
|
|
247
|
+
<ChatIcon
|
|
248
|
+
name={copied ? "check" : "content_copy"}
|
|
249
|
+
size={16}
|
|
250
|
+
className={copied ? "text-success" : undefined}
|
|
251
|
+
/>
|
|
252
|
+
<span>{copied ? l.copied : l.copy}</span>
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
<pre className="overflow-x-auto p-3 font-code text-body-small text-on-surface bg-surface-container-highest [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-[length:inherit]">
|
|
256
|
+
<code className={rawLang ? `language-${rawLang}` : undefined}>{codeContent}</code>
|
|
257
|
+
</pre>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Math formula rendering & typography
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
const GREEK_AND_SYMBOLS: Record<string, string> = {
|
|
267
|
+
// Greek letters (lowercase)
|
|
268
|
+
alpha: "α",
|
|
269
|
+
beta: "β",
|
|
270
|
+
gamma: "γ",
|
|
271
|
+
delta: "δ",
|
|
272
|
+
epsilon: "ε",
|
|
273
|
+
zeta: "ζ",
|
|
274
|
+
eta: "η",
|
|
275
|
+
theta: "θ",
|
|
276
|
+
iota: "ι",
|
|
277
|
+
kappa: "κ",
|
|
278
|
+
lambda: "λ",
|
|
279
|
+
mu: "μ",
|
|
280
|
+
nu: "ν",
|
|
281
|
+
xi: "ξ",
|
|
282
|
+
pi: "π",
|
|
283
|
+
rho: "ρ",
|
|
284
|
+
sigma: "σ",
|
|
285
|
+
tau: "τ",
|
|
286
|
+
upsilon: "υ",
|
|
287
|
+
phi: "φ",
|
|
288
|
+
chi: "χ",
|
|
289
|
+
psi: "ψ",
|
|
290
|
+
omega: "ω",
|
|
291
|
+
// Greek letters (uppercase)
|
|
292
|
+
Gamma: "Γ",
|
|
293
|
+
Delta: "Δ",
|
|
294
|
+
Theta: "Θ",
|
|
295
|
+
Lambda: "Λ",
|
|
296
|
+
Xi: "Ξ",
|
|
297
|
+
Pi: "Π",
|
|
298
|
+
Sigma: "Σ",
|
|
299
|
+
Phi: "Φ",
|
|
300
|
+
Psi: "Ψ",
|
|
301
|
+
Omega: "Ω",
|
|
302
|
+
// Operators & Relations
|
|
303
|
+
times: "×",
|
|
304
|
+
div: "÷",
|
|
305
|
+
pm: "±",
|
|
306
|
+
mp: "∓",
|
|
307
|
+
le: "≤",
|
|
308
|
+
leq: "≤",
|
|
309
|
+
ge: "≥",
|
|
310
|
+
geq: "≥",
|
|
311
|
+
approx: "≈",
|
|
312
|
+
neq: "≠",
|
|
313
|
+
ne: "≠",
|
|
314
|
+
infty: "∞",
|
|
315
|
+
cdot: "·",
|
|
316
|
+
circ: "°",
|
|
317
|
+
partial: "∂",
|
|
318
|
+
nabla: "∇",
|
|
319
|
+
sum: "∑",
|
|
320
|
+
prod: "∏",
|
|
321
|
+
int: "∫",
|
|
322
|
+
sqrt: "√",
|
|
323
|
+
in: "∈",
|
|
324
|
+
notin: "∉",
|
|
325
|
+
subset: "⊂",
|
|
326
|
+
subseteq: "⊆",
|
|
327
|
+
cup: "∪",
|
|
328
|
+
cap: "∩",
|
|
329
|
+
to: "→",
|
|
330
|
+
rightarrow: "→",
|
|
331
|
+
leftarrow: "←",
|
|
332
|
+
leftrightarrow: "↔",
|
|
333
|
+
Rightarrow: "⇒",
|
|
334
|
+
Leftarrow: "⇐",
|
|
335
|
+
Leftrightarrow: "⇔",
|
|
336
|
+
forall: "∀",
|
|
337
|
+
exists: "∃",
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
function formatSubSup(text: string): ReactNode {
|
|
341
|
+
const parts = text.split(
|
|
342
|
+
/(\^\{[^}]+\}|\^[-+]?[a-zA-Z0-9\u0370-\u03FF\u2100-\u214F]|_\{[^}]+\}|_[-+]?[a-zA-Z0-9\u0370-\u03FF\u2100-\u214F])/g,
|
|
343
|
+
);
|
|
344
|
+
if (parts.length === 1) return text;
|
|
345
|
+
|
|
346
|
+
const occurrences = new Map<string, number>();
|
|
347
|
+
return parts.map((part) => {
|
|
348
|
+
const occurrence = occurrences.get(part) ?? 0;
|
|
349
|
+
occurrences.set(part, occurrence + 1);
|
|
350
|
+
const key = `${part}:${occurrence}`;
|
|
351
|
+
if (part.startsWith("^")) {
|
|
352
|
+
const val = part.startsWith("^{") && part.endsWith("}") ? part.slice(2, -1) : part.slice(1);
|
|
353
|
+
return (
|
|
354
|
+
<sup key={key} className="text-[0.75em] leading-none">
|
|
355
|
+
{val}
|
|
356
|
+
</sup>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
if (part.startsWith("_")) {
|
|
360
|
+
const val = part.startsWith("_{") && part.endsWith("}") ? part.slice(2, -1) : part.slice(1);
|
|
361
|
+
return (
|
|
362
|
+
<sub key={key} className="text-[0.75em] leading-none">
|
|
363
|
+
{val}
|
|
364
|
+
</sub>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
return part;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function formatMathContent(math: string): ReactNode {
|
|
372
|
+
// Step 1: Replace LaTeX symbol commands \name (or \\name)
|
|
373
|
+
const text = math.replace(/\\+([a-zA-Z]+)/g, (match, name) => {
|
|
374
|
+
return GREEK_AND_SYMBOLS[name] ?? match;
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Step 2: Handle \frac{num}{den}
|
|
378
|
+
const fracRegex = /\\+frac\{([^{}]+)\}\{([^{}]+)\}/g;
|
|
379
|
+
if (fracRegex.test(text)) {
|
|
380
|
+
const parts: ReactNode[] = [];
|
|
381
|
+
let lastIndex = 0;
|
|
382
|
+
text.replace(fracRegex, (match, num, den, offset) => {
|
|
383
|
+
if (offset > lastIndex) {
|
|
384
|
+
parts.push(formatSubSup(text.slice(lastIndex, offset)));
|
|
385
|
+
}
|
|
386
|
+
parts.push(
|
|
387
|
+
<span
|
|
388
|
+
key={`frac-${offset}`}
|
|
389
|
+
className="inline-flex flex-col items-center align-middle mx-1 text-center text-[0.85em] leading-none not-italic"
|
|
390
|
+
>
|
|
391
|
+
<span className="border-b border-current px-0.5 pb-0.5">{formatSubSup(num)}</span>
|
|
392
|
+
<span className="px-0.5 pt-0.5">{formatSubSup(den)}</span>
|
|
393
|
+
</span>,
|
|
394
|
+
);
|
|
395
|
+
lastIndex = offset + match.length;
|
|
396
|
+
return match;
|
|
397
|
+
});
|
|
398
|
+
if (lastIndex < text.length) {
|
|
399
|
+
parts.push(formatSubSup(text.slice(lastIndex)));
|
|
400
|
+
}
|
|
401
|
+
return parts;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return formatSubSup(text);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export interface InlineMathProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
408
|
+
math: string;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function InlineMath({ math, className, ...props }: InlineMathProps) {
|
|
412
|
+
return (
|
|
413
|
+
<span
|
|
414
|
+
className={clsx(
|
|
415
|
+
"inline-flex items-center px-1 font-serif italic font-medium text-primary",
|
|
416
|
+
className,
|
|
417
|
+
)}
|
|
418
|
+
{...props}
|
|
419
|
+
>
|
|
420
|
+
{formatMathContent(math)}
|
|
421
|
+
</span>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export interface BlockMathProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
426
|
+
math: string;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function BlockMath({ math, className, ...props }: BlockMathProps) {
|
|
430
|
+
return (
|
|
431
|
+
<div
|
|
432
|
+
className={clsx(
|
|
433
|
+
"my-3 flex justify-center overflow-x-auto rounded-medium bg-surface-container-low/40 px-4 py-2 text-title-medium font-serif italic font-medium text-primary",
|
|
434
|
+
className,
|
|
435
|
+
)}
|
|
436
|
+
{...props}
|
|
437
|
+
>
|
|
438
|
+
{formatMathContent(math)}
|
|
439
|
+
</div>
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// Remark plugin for recognizing $...$ and $$...$$ expressions
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
function transformBlocks(parent: any) {
|
|
448
|
+
if (!parent.children) return;
|
|
449
|
+
const newChildren: any[] = [];
|
|
450
|
+
for (const child of parent.children) {
|
|
451
|
+
if (child.type === "paragraph" && Array.isArray(child.children)) {
|
|
452
|
+
const hasBlockMath = child.children.some(
|
|
453
|
+
(c: any) => c.type === "text" && /\$\$[\s\S]+?\$\$/.test(c.value),
|
|
454
|
+
);
|
|
455
|
+
if (hasBlockMath) {
|
|
456
|
+
let currentParagraphChildren: any[] = [];
|
|
457
|
+
for (const item of child.children) {
|
|
458
|
+
if (item.type === "text" && /\$\$[\s\S]+?\$\$/.test(item.value)) {
|
|
459
|
+
const parts = item.value.split(/(\$\$[\s\S]+?\$\$)/g);
|
|
460
|
+
for (const part of parts) {
|
|
461
|
+
if (part.startsWith("$$") && part.endsWith("$$") && part.length >= 4) {
|
|
462
|
+
if (currentParagraphChildren.length > 0) {
|
|
463
|
+
newChildren.push({
|
|
464
|
+
type: "paragraph",
|
|
465
|
+
children: currentParagraphChildren,
|
|
466
|
+
});
|
|
467
|
+
currentParagraphChildren = [];
|
|
468
|
+
}
|
|
469
|
+
const mathVal = part.slice(2, -2).trim();
|
|
470
|
+
newChildren.push({
|
|
471
|
+
type: "mathBlock",
|
|
472
|
+
data: {
|
|
473
|
+
hName: "div",
|
|
474
|
+
hProperties: { className: "math-block", "data-math": mathVal },
|
|
475
|
+
hChildren: [{ type: "text", value: mathVal }],
|
|
476
|
+
},
|
|
477
|
+
children: [{ type: "text", value: mathVal }],
|
|
478
|
+
});
|
|
479
|
+
} else if (part.trim().length > 0) {
|
|
480
|
+
currentParagraphChildren.push({ type: "text", value: part });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
currentParagraphChildren.push(item);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (currentParagraphChildren.length > 0) {
|
|
488
|
+
newChildren.push({
|
|
489
|
+
type: "paragraph",
|
|
490
|
+
children: currentParagraphChildren,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
transformBlocks(child);
|
|
497
|
+
newChildren.push(child);
|
|
498
|
+
}
|
|
499
|
+
parent.children = newChildren;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function transformInlines(node: any) {
|
|
503
|
+
if (!node.children) return;
|
|
504
|
+
const newChildren: any[] = [];
|
|
505
|
+
for (const child of node.children) {
|
|
506
|
+
if (child.type === "text" && typeof child.value === "string" && child.value.includes("$")) {
|
|
507
|
+
const parts = child.value.split(/((?<!\\)\$(?!\s)[^$\n]+?(?<!\s)\$(?!\d))/g);
|
|
508
|
+
for (const part of parts) {
|
|
509
|
+
if (
|
|
510
|
+
part.startsWith("$") &&
|
|
511
|
+
part.endsWith("$") &&
|
|
512
|
+
!part.startsWith("$$") &&
|
|
513
|
+
part.length > 2
|
|
514
|
+
) {
|
|
515
|
+
const mathVal = part.slice(1, -1).trim();
|
|
516
|
+
newChildren.push({
|
|
517
|
+
type: "mathInline",
|
|
518
|
+
data: {
|
|
519
|
+
hName: "span",
|
|
520
|
+
hProperties: { className: "math-inline", "data-math": mathVal },
|
|
521
|
+
hChildren: [{ type: "text", value: mathVal }],
|
|
522
|
+
},
|
|
523
|
+
children: [{ type: "text", value: mathVal }],
|
|
524
|
+
});
|
|
525
|
+
} else if (part) {
|
|
526
|
+
newChildren.push({ type: "text", value: part });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
transformInlines(child);
|
|
531
|
+
newChildren.push(child);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
node.children = newChildren;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function transformMathTree(tree: any) {
|
|
538
|
+
transformBlocks(tree);
|
|
539
|
+
transformInlines(tree);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function remarkMathPlugin() {
|
|
543
|
+
return transformMathTree;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// Element overrides mapping markdown onto M3 typescale and color roles
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
interface LinkOptions {
|
|
551
|
+
onLinkClick?: MarkdownLinkHandler;
|
|
552
|
+
renderLink?: MarkdownLinkRenderer;
|
|
553
|
+
labels?: Partial<ChatLabels>;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function m3Components(size: MarkdownSize, link: LinkOptions = {}): Components {
|
|
557
|
+
const body = bodyClass[size];
|
|
558
|
+
return {
|
|
559
|
+
h1: ({ node: _node, className, children, ...props }) => (
|
|
560
|
+
<h1
|
|
561
|
+
className={clsx("mt-4 mb-2 text-headline-small text-on-surface first:mt-0", className)}
|
|
562
|
+
{...props}
|
|
563
|
+
>
|
|
564
|
+
{children}
|
|
565
|
+
</h1>
|
|
566
|
+
),
|
|
567
|
+
h2: ({ node: _node, className, children, ...props }) => (
|
|
568
|
+
<h2
|
|
569
|
+
className={clsx("mt-4 mb-2 text-title-large text-on-surface first:mt-0", className)}
|
|
570
|
+
{...props}
|
|
571
|
+
>
|
|
572
|
+
{children}
|
|
573
|
+
</h2>
|
|
574
|
+
),
|
|
575
|
+
h3: ({ node: _node, className, children, ...props }) => (
|
|
576
|
+
<h3
|
|
577
|
+
className={clsx("mt-3 mb-1.5 text-title-medium text-on-surface first:mt-0", className)}
|
|
578
|
+
{...props}
|
|
579
|
+
>
|
|
580
|
+
{children}
|
|
581
|
+
</h3>
|
|
582
|
+
),
|
|
583
|
+
h4: ({ node: _node, className, children, ...props }) => (
|
|
584
|
+
<h4
|
|
585
|
+
className={clsx("mt-3 mb-1 text-title-small text-on-surface first:mt-0", className)}
|
|
586
|
+
{...props}
|
|
587
|
+
>
|
|
588
|
+
{children}
|
|
589
|
+
</h4>
|
|
590
|
+
),
|
|
591
|
+
h5: ({ node: _node, className, children, ...props }) => (
|
|
592
|
+
<h5
|
|
593
|
+
className={clsx("mt-3 mb-1 text-title-small text-on-surface-variant first:mt-0", className)}
|
|
594
|
+
{...props}
|
|
595
|
+
>
|
|
596
|
+
{children}
|
|
597
|
+
</h5>
|
|
598
|
+
),
|
|
599
|
+
h6: ({ node: _node, className, children, ...props }) => (
|
|
600
|
+
<h6
|
|
601
|
+
className={clsx("mt-3 mb-1 text-label-large text-on-surface-variant first:mt-0", className)}
|
|
602
|
+
{...props}
|
|
603
|
+
>
|
|
604
|
+
{children}
|
|
605
|
+
</h6>
|
|
606
|
+
),
|
|
607
|
+
p: ({ node: _node, className, ...props }) => (
|
|
608
|
+
<p className={clsx("my-2 first:mt-0 last:mb-0", body, className)} {...props} />
|
|
609
|
+
),
|
|
610
|
+
a: ({ node: _node, className, children, href, onClick, ...props }) => {
|
|
611
|
+
const external = isExternalHref(href);
|
|
612
|
+
if (link.renderLink) return <>{link.renderLink({ href: href ?? "", children, external })}</>;
|
|
613
|
+
return (
|
|
614
|
+
<a
|
|
615
|
+
className={clsx(
|
|
616
|
+
"text-primary underline underline-offset-2 hover:decoration-2",
|
|
617
|
+
"rounded-extra-small outline-none focus-visible:outline-3 focus-visible:outline-secondary focus-visible:outline-offset-2",
|
|
618
|
+
className,
|
|
619
|
+
)}
|
|
620
|
+
href={href}
|
|
621
|
+
{...(external ? externalLinkProps : {})}
|
|
622
|
+
onClick={(event) => {
|
|
623
|
+
onClick?.(event);
|
|
624
|
+
if (href) link.onLinkClick?.(href, event);
|
|
625
|
+
}}
|
|
626
|
+
{...props}
|
|
627
|
+
>
|
|
628
|
+
{children}
|
|
629
|
+
</a>
|
|
630
|
+
);
|
|
631
|
+
},
|
|
632
|
+
strong: ({ node: _node, className, ...props }) => (
|
|
633
|
+
<strong className={clsx("font-semibold text-on-surface", className)} {...props} />
|
|
634
|
+
),
|
|
635
|
+
ul: ({ node: _node, className, ...props }) => (
|
|
636
|
+
<ul
|
|
637
|
+
className={clsx("my-2 list-disc pl-6 marker:text-on-surface-variant", body, className)}
|
|
638
|
+
{...props}
|
|
639
|
+
/>
|
|
640
|
+
),
|
|
641
|
+
ol: ({ node: _node, className, ...props }) => (
|
|
642
|
+
<ol
|
|
643
|
+
className={clsx("my-2 list-decimal pl-6 marker:text-on-surface-variant", body, className)}
|
|
644
|
+
{...props}
|
|
645
|
+
/>
|
|
646
|
+
),
|
|
647
|
+
li: ({ node: _node, className, ...props }) => (
|
|
648
|
+
<li className={clsx("my-1", className)} {...props} />
|
|
649
|
+
),
|
|
650
|
+
blockquote: ({ node: _node, className, ...props }) => (
|
|
651
|
+
<blockquote
|
|
652
|
+
className={clsx(
|
|
653
|
+
"my-3 border-l-4 border-outline-variant pl-4 text-on-surface-variant",
|
|
654
|
+
className,
|
|
655
|
+
)}
|
|
656
|
+
{...props}
|
|
657
|
+
/>
|
|
658
|
+
),
|
|
659
|
+
code: ({ node: _node, className, ...props }) => (
|
|
660
|
+
<code
|
|
661
|
+
className={clsx(
|
|
662
|
+
"rounded-extra-small bg-surface-container-highest px-1 py-0.5 font-code text-[0.9em] text-on-surface",
|
|
663
|
+
className,
|
|
664
|
+
)}
|
|
665
|
+
{...props}
|
|
666
|
+
/>
|
|
667
|
+
),
|
|
668
|
+
pre: ({ node: _node, children, className, style }) => (
|
|
669
|
+
<CodeBlock className={className} style={style} labels={link.labels}>
|
|
670
|
+
{children}
|
|
671
|
+
</CodeBlock>
|
|
672
|
+
),
|
|
673
|
+
hr: ({ node: _node, className, ...props }) => (
|
|
674
|
+
<hr className={clsx("my-4 border-outline-variant", className)} {...props} />
|
|
675
|
+
),
|
|
676
|
+
table: ({ node: _node, className, ...props }) => (
|
|
677
|
+
<div className="my-3 overflow-x-auto rounded-small border border-outline-variant">
|
|
678
|
+
<table className={clsx("w-full border-collapse", body, className)} {...props} />
|
|
679
|
+
</div>
|
|
680
|
+
),
|
|
681
|
+
thead: ({ node: _node, className, ...props }) => (
|
|
682
|
+
<thead className={clsx("bg-surface-container", className)} {...props} />
|
|
683
|
+
),
|
|
684
|
+
th: ({ node: _node, className, ...props }) => (
|
|
685
|
+
<th
|
|
686
|
+
className={clsx(
|
|
687
|
+
"border-b border-outline-variant px-3 py-2 text-left text-title-small text-on-surface",
|
|
688
|
+
className,
|
|
689
|
+
)}
|
|
690
|
+
{...props}
|
|
691
|
+
/>
|
|
692
|
+
),
|
|
693
|
+
td: ({ node: _node, className, ...props }) => (
|
|
694
|
+
<td
|
|
695
|
+
className={clsx("border-t border-outline-variant px-3 py-2 text-on-surface", className)}
|
|
696
|
+
{...props}
|
|
697
|
+
/>
|
|
698
|
+
),
|
|
699
|
+
img: ({ node: _node, className, alt, ...props }) => (
|
|
700
|
+
<img
|
|
701
|
+
className={clsx("my-2 max-w-full rounded-medium", className)}
|
|
702
|
+
alt={alt ?? ""}
|
|
703
|
+
{...props}
|
|
704
|
+
/>
|
|
705
|
+
),
|
|
706
|
+
input: ({ node: _node, className, ...props }) => (
|
|
707
|
+
<input className={clsx("mr-2 accent-primary", className)} {...props} />
|
|
708
|
+
),
|
|
709
|
+
div: ({ node: _node, className, children, ...props }) => {
|
|
710
|
+
if (className?.includes("math-block")) {
|
|
711
|
+
const math = (props as Record<string, any>)["data-math"] ?? extractText(children);
|
|
712
|
+
return <BlockMath math={math} className={className} />;
|
|
713
|
+
}
|
|
714
|
+
return (
|
|
715
|
+
<div className={className} {...props}>
|
|
716
|
+
{children}
|
|
717
|
+
</div>
|
|
718
|
+
);
|
|
719
|
+
},
|
|
720
|
+
span: ({ node: _node, className, children, ...props }) => {
|
|
721
|
+
if (className?.includes("math-inline")) {
|
|
722
|
+
const math = (props as Record<string, any>)["data-math"] ?? extractText(children);
|
|
723
|
+
return <InlineMath math={math} className={className} />;
|
|
724
|
+
}
|
|
725
|
+
return (
|
|
726
|
+
<span className={className} {...props}>
|
|
727
|
+
{children}
|
|
728
|
+
</span>
|
|
729
|
+
);
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
|
|
735
|
+
(
|
|
736
|
+
{
|
|
737
|
+
content,
|
|
738
|
+
className,
|
|
739
|
+
size = "medium",
|
|
740
|
+
allowHtml = false,
|
|
741
|
+
components,
|
|
742
|
+
onLinkClick,
|
|
743
|
+
renderLink,
|
|
744
|
+
labels,
|
|
745
|
+
},
|
|
746
|
+
ref,
|
|
747
|
+
) => {
|
|
748
|
+
const base = m3Components(size, { onLinkClick, renderLink, labels });
|
|
749
|
+
return (
|
|
750
|
+
<div
|
|
751
|
+
ref={ref}
|
|
752
|
+
className={clsx("markdown min-w-0 text-on-surface", bodyClass[size], className)}
|
|
753
|
+
>
|
|
754
|
+
<ReactMarkdown
|
|
755
|
+
remarkPlugins={[remarkGfm, remarkMathPlugin]}
|
|
756
|
+
rehypePlugins={allowHtml ? [rehypeRaw] : undefined}
|
|
757
|
+
components={components ? { ...base, ...components } : base}
|
|
758
|
+
>
|
|
759
|
+
{content}
|
|
760
|
+
</ReactMarkdown>
|
|
761
|
+
</div>
|
|
762
|
+
);
|
|
763
|
+
},
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
Markdown.displayName = "Markdown";
|
|
767
|
+
|
|
768
|
+
export { Markdown };
|
|
769
|
+
export type { MarkdownProps };
|