@djangocfg/ui-tools 2.1.300 → 2.1.302
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/dist/{DocsLayout-NWJUF42A.cjs → DocsLayout-4PQLBZHE.cjs} +48 -48
- package/dist/{DocsLayout-NWJUF42A.cjs.map → DocsLayout-4PQLBZHE.cjs.map} +1 -1
- package/dist/{DocsLayout-MWRKNFXR.mjs → DocsLayout-ZHNRRAKR.mjs} +3 -3
- package/dist/{DocsLayout-MWRKNFXR.mjs.map → DocsLayout-ZHNRRAKR.mjs.map} +1 -1
- package/dist/{chunk-CKD7GNE5.mjs → chunk-47NGNO5U.mjs} +37 -32
- package/dist/chunk-47NGNO5U.mjs.map +1 -0
- package/dist/{chunk-SEXWBCLX.cjs → chunk-M4BLG3RZ.cjs} +37 -32
- package/dist/chunk-M4BLG3RZ.cjs.map +1 -0
- package/dist/index.cjs +10 -10
- package/dist/index.d.cts +13 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.mjs +4 -4
- package/package.json +6 -6
- package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +10 -16
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +214 -0
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +33 -11
- package/src/components/markdown/MarkdownMessage/plainText.ts +33 -0
- package/src/components/markdown/MarkdownMessage/types.ts +13 -0
- package/dist/chunk-CKD7GNE5.mjs.map +0 -1
- package/dist/chunk-SEXWBCLX.cjs.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -158,6 +158,19 @@ interface MarkdownMessageProps {
|
|
|
158
158
|
isUser?: boolean;
|
|
159
159
|
/** Use compact size (text-xs instead of text-sm) */
|
|
160
160
|
isCompact?: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Force the plain-text rendering path (single `<div>` with
|
|
163
|
+
* `white-space: pre-wrap`, no ReactMarkdown). Use this for
|
|
164
|
+
* user-authored content where markdown shouldn't be parsed.
|
|
165
|
+
*
|
|
166
|
+
* When `undefined` (default) we run a small heuristic
|
|
167
|
+
* (`looksLikePlainProse`): short single-paragraph text without
|
|
168
|
+
* markdown markers → plain; everything else → ReactMarkdown.
|
|
169
|
+
*
|
|
170
|
+
* Pass an explicit `true` / `false` to opt out of the heuristic
|
|
171
|
+
* entirely.
|
|
172
|
+
*/
|
|
173
|
+
plainText?: boolean;
|
|
161
174
|
/**
|
|
162
175
|
* Per-tag overrides merged on top of the built-in renderers.
|
|
163
176
|
*
|
package/dist/index.mjs
CHANGED
|
@@ -4,8 +4,8 @@ import './chunk-JWB2EWQO.mjs';
|
|
|
4
4
|
export { ImageViewer } from './chunk-GGKGH5PM.mjs';
|
|
5
5
|
export { generateContentKey, useAudioCache, useBlobUrlCleanup, useImageCache, useMediaCacheStore, useVideoCache, useVideoPlayerSettings } from './chunk-5LBDYFWH.mjs';
|
|
6
6
|
export { CronSchedulerProvider, CustomInput, DayChips, MonthDayGrid, SchedulePreview, ScheduleTypeSelector, TimeSelector, buildCron, humanizeCron, isValidCron, parseCron, useCronCustom, useCronMonthDays, useCronPreview, useCronScheduler, useCronSchedulerContext, useCronTime, useCronType, useCronWeekDays } from './chunk-PZKAH7WQ.mjs';
|
|
7
|
-
import { PlaygroundProvider } from './chunk-
|
|
8
|
-
export { MarkdownMessage, Mermaid_default as Mermaid, PrettyCode_default as PrettyCode, extractTextFromChildren, useCollapsibleContent } from './chunk-
|
|
7
|
+
import { PlaygroundProvider } from './chunk-47NGNO5U.mjs';
|
|
8
|
+
export { MarkdownMessage, Mermaid_default as Mermaid, PrettyCode_default as PrettyCode, extractTextFromChildren, useCollapsibleContent } from './chunk-47NGNO5U.mjs';
|
|
9
9
|
export { JsonTree_default as JsonTree } from './chunk-LFWQ36LJ.mjs';
|
|
10
10
|
import './chunk-SSUOENAZ.mjs';
|
|
11
11
|
export { ArrayFieldItemTemplate, ArrayFieldTemplate, BaseInputTemplate, CheckboxWidget, ColorWidget, ErrorListTemplate, FieldTemplate, JsonSchemaForm, NumberWidget, ObjectFieldTemplate, SelectWidget, SliderWidget, SwitchWidget, TextWidget, getRequiredFields, hasRequiredFields, mergeDefaults, normalizeFormData, safeJsonParse, safeJsonStringify, validateRequiredFields, validateSchema } from './chunk-JUGQNNDC.mjs';
|
|
@@ -238,7 +238,7 @@ function OpenapiLoadingFallback() {
|
|
|
238
238
|
}
|
|
239
239
|
__name(OpenapiLoadingFallback, "OpenapiLoadingFallback");
|
|
240
240
|
var LazyDocsLayout = createLazyComponent(
|
|
241
|
-
() => import('./DocsLayout-
|
|
241
|
+
() => import('./DocsLayout-ZHNRRAKR.mjs').then((mod) => ({ default: mod.DocsLayout })),
|
|
242
242
|
{
|
|
243
243
|
displayName: "LazyDocsLayout",
|
|
244
244
|
fallback: /* @__PURE__ */ jsx(OpenapiLoadingFallback, {})
|
|
@@ -393,7 +393,7 @@ function LottiePlayer(props) {
|
|
|
393
393
|
}
|
|
394
394
|
__name(LottiePlayer, "LottiePlayer");
|
|
395
395
|
var DocsLayout = lazy(
|
|
396
|
-
() => import('./DocsLayout-
|
|
396
|
+
() => import('./DocsLayout-ZHNRRAKR.mjs').then((mod) => ({ default: mod.DocsLayout }))
|
|
397
397
|
);
|
|
398
398
|
var LoadingFallback7 = /* @__PURE__ */ __name(() => /* @__PURE__ */ jsx("div", { className: "flex items-center justify-center min-h-[400px]", children: /* @__PURE__ */ jsx("div", { className: "text-muted-foreground", children: "Loading API Playground..." }) }), "LoadingFallback");
|
|
399
399
|
var Playground = /* @__PURE__ */ __name(({ config }) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.302",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -91,8 +91,8 @@
|
|
|
91
91
|
"check": "tsc --noEmit"
|
|
92
92
|
},
|
|
93
93
|
"peerDependencies": {
|
|
94
|
-
"@djangocfg/i18n": "^2.1.
|
|
95
|
-
"@djangocfg/ui-core": "^2.1.
|
|
94
|
+
"@djangocfg/i18n": "^2.1.302",
|
|
95
|
+
"@djangocfg/ui-core": "^2.1.302",
|
|
96
96
|
"consola": "^3.4.2",
|
|
97
97
|
"lodash-es": "^4.18.1",
|
|
98
98
|
"lucide-react": "^0.545.0",
|
|
@@ -140,10 +140,10 @@
|
|
|
140
140
|
"@maplibre/maplibre-gl-geocoder": "^1.7.0"
|
|
141
141
|
},
|
|
142
142
|
"devDependencies": {
|
|
143
|
-
"@djangocfg/i18n": "^2.1.
|
|
143
|
+
"@djangocfg/i18n": "^2.1.302",
|
|
144
144
|
"@djangocfg/playground": "workspace:*",
|
|
145
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
146
|
-
"@djangocfg/ui-core": "^2.1.
|
|
145
|
+
"@djangocfg/typescript-config": "^2.1.302",
|
|
146
|
+
"@djangocfg/ui-core": "^2.1.302",
|
|
147
147
|
"@types/lodash-es": "^4.17.12",
|
|
148
148
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
149
149
|
"@types/node": "^24.7.2",
|
|
@@ -10,26 +10,20 @@ interface CodeBlockProps {
|
|
|
10
10
|
isCompact?: boolean;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Markdown code block. Renders <PrettyCode> directly — its own
|
|
15
|
+
* FloatingToolbar already carries the language tag and Copy action.
|
|
16
|
+
*
|
|
17
|
+
* Earlier versions stacked an extra <CopyButton> here too, which
|
|
18
|
+
* surfaced as two copy buttons on every code fence. The fallback
|
|
19
|
+
* branch below still ships its own button because the plain <pre>
|
|
20
|
+
* has no toolbar of its own.
|
|
21
|
+
*/
|
|
14
22
|
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isUser, isCompact = false }) => {
|
|
15
23
|
const theme = useResolvedTheme();
|
|
16
24
|
|
|
17
25
|
return (
|
|
18
|
-
<div className="
|
|
19
|
-
<CopyButton
|
|
20
|
-
value={code}
|
|
21
|
-
variant="ghost"
|
|
22
|
-
className={`
|
|
23
|
-
absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity
|
|
24
|
-
h-8 w-8
|
|
25
|
-
${isUser
|
|
26
|
-
? 'hover:bg-white/20 text-white'
|
|
27
|
-
: 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground'
|
|
28
|
-
}
|
|
29
|
-
`}
|
|
30
|
-
title="Copy code"
|
|
31
|
-
/>
|
|
32
|
-
|
|
26
|
+
<div className="my-3">
|
|
33
27
|
<PrettyCode
|
|
34
28
|
data={code}
|
|
35
29
|
language={language}
|
|
@@ -169,3 +169,217 @@ export const LinkRules = () => (
|
|
|
169
169
|
</div>
|
|
170
170
|
</div>
|
|
171
171
|
);
|
|
172
|
+
|
|
173
|
+
// Chat-style stories — exercise the plain-text fast path so short
|
|
174
|
+
// one-liners and prose paragraphs don't get prose vertical margins
|
|
175
|
+
// inside a chat bubble. Compare against `LongAssistantReply` below
|
|
176
|
+
// to see when the heavier ReactMarkdown pipeline kicks in.
|
|
177
|
+
const SHORT_USER_LINES = [
|
|
178
|
+
'Hi',
|
|
179
|
+
'А?',
|
|
180
|
+
'How do I install cmdop?',
|
|
181
|
+
'Can you ssh into vps-audi and check uptime?',
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
const SHORT_ASSISTANT_LINES = [
|
|
185
|
+
'Sure — what do you need?',
|
|
186
|
+
'Готово, запросил статус.',
|
|
187
|
+
'Done.',
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
// Crafted to make the plain-vs-markdown contrast obvious: contains
|
|
191
|
+
// `*pair*` (italic in markdown), a `#hash` token (lone hash with no
|
|
192
|
+
// space — NOT a heading even in markdown, kept here to show the
|
|
193
|
+
// asymmetry), and embedded newlines that should survive in plain mode.
|
|
194
|
+
const CONTRAST_CONTENT = `Multi
|
|
195
|
+
line
|
|
196
|
+
user message
|
|
197
|
+
with *asterisks*
|
|
198
|
+
and #hash that should NOT format`;
|
|
199
|
+
|
|
200
|
+
const LONG_ASSISTANT_REPLY = `## Установка CMDOP
|
|
201
|
+
|
|
202
|
+
Запусти этот скрипт:
|
|
203
|
+
|
|
204
|
+
\`\`\`bash
|
|
205
|
+
curl -sSL cmdop.com/install.sh | bash
|
|
206
|
+
\`\`\`
|
|
207
|
+
|
|
208
|
+
Дальше:
|
|
209
|
+
|
|
210
|
+
1. **Войти:** \`cmdop login\`
|
|
211
|
+
2. **Подключить машину:** \`cmdop connect\`
|
|
212
|
+
3. **Проверить:** \`cmdop doctor\`
|
|
213
|
+
|
|
214
|
+
Если что-то не работает — скажи.`;
|
|
215
|
+
|
|
216
|
+
function ChatBubble({
|
|
217
|
+
role,
|
|
218
|
+
content,
|
|
219
|
+
rules,
|
|
220
|
+
plainText,
|
|
221
|
+
}: {
|
|
222
|
+
role: 'user' | 'assistant';
|
|
223
|
+
content: string;
|
|
224
|
+
rules?: LinkRule[];
|
|
225
|
+
plainText?: boolean;
|
|
226
|
+
}) {
|
|
227
|
+
const isUser = role === 'user';
|
|
228
|
+
return (
|
|
229
|
+
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
|
230
|
+
<div
|
|
231
|
+
className={`max-w-[85%] rounded-xl px-3 py-2 ${
|
|
232
|
+
isUser
|
|
233
|
+
? 'bg-primary text-primary-foreground'
|
|
234
|
+
: 'bg-card text-card-foreground border border-border'
|
|
235
|
+
}`}
|
|
236
|
+
>
|
|
237
|
+
<MarkdownMessage
|
|
238
|
+
content={content}
|
|
239
|
+
isUser={isUser}
|
|
240
|
+
linkRules={rules}
|
|
241
|
+
plainText={plainText}
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const ChatBubbles = () => (
|
|
249
|
+
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
|
250
|
+
<div>
|
|
251
|
+
<h3 className="mb-2 text-sm font-semibold">
|
|
252
|
+
User messages — explicit <code>plainText</code>
|
|
253
|
+
</h3>
|
|
254
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
255
|
+
ChatGPT/Claude.ai split: user-typed content stays as-typed.
|
|
256
|
+
<code> *stars* </code> aren't bold; <code># headers </code>
|
|
257
|
+
aren't headings. Newlines preserved via{' '}
|
|
258
|
+
<code>whitespace-pre-wrap</code>.
|
|
259
|
+
</p>
|
|
260
|
+
<div className="rounded-lg border border-border bg-card p-4 space-y-2">
|
|
261
|
+
{SHORT_USER_LINES.map((line, i) => (
|
|
262
|
+
<ChatBubble
|
|
263
|
+
key={`u-${i}`}
|
|
264
|
+
role="user"
|
|
265
|
+
content={line}
|
|
266
|
+
plainText
|
|
267
|
+
rules={onPrimaryRules}
|
|
268
|
+
/>
|
|
269
|
+
))}
|
|
270
|
+
<ChatBubble
|
|
271
|
+
role="user"
|
|
272
|
+
content={'Multi\nline\nuser message\nwith *asterisks*\nand #hash that should NOT format'}
|
|
273
|
+
plainText
|
|
274
|
+
rules={onPrimaryRules}
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<div>
|
|
280
|
+
<h3 className="mb-2 text-sm font-semibold">
|
|
281
|
+
Heuristic auto-detect (no <code>plainText</code> prop)
|
|
282
|
+
</h3>
|
|
283
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
284
|
+
Without <code>plainText</code>, <code>looksLikePlainProse</code>
|
|
285
|
+
decides: short / single-paragraph / no markers → flat render;
|
|
286
|
+
long / multi-paragraph / structural → full markdown. Same
|
|
287
|
+
renderer for both branches; only margins differ.
|
|
288
|
+
</p>
|
|
289
|
+
<div className="rounded-lg border border-border bg-card p-4 space-y-2">
|
|
290
|
+
{SHORT_ASSISTANT_LINES.map((line, i) => (
|
|
291
|
+
<ChatBubble
|
|
292
|
+
key={`a-${i}`}
|
|
293
|
+
role="assistant"
|
|
294
|
+
content={line}
|
|
295
|
+
rules={subtleRules}
|
|
296
|
+
/>
|
|
297
|
+
))}
|
|
298
|
+
<ChatBubble
|
|
299
|
+
role="assistant"
|
|
300
|
+
content="Sure — let me check uptime and disk on the machine and report back. Should only take a couple of seconds."
|
|
301
|
+
rules={subtleRules}
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<div>
|
|
307
|
+
<h3 className="mb-2 text-sm font-semibold">
|
|
308
|
+
Bubble with mention chips
|
|
309
|
+
</h3>
|
|
310
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
311
|
+
Content with <code>[label](cmdop://...)</code> always goes
|
|
312
|
+
through ReactMarkdown — that's how MentionChip renders.
|
|
313
|
+
</p>
|
|
314
|
+
<div className="rounded-lg border border-border bg-card p-4 space-y-2">
|
|
315
|
+
<ChatBubble
|
|
316
|
+
role="user"
|
|
317
|
+
content="Спроси у @[Vps-audi](cmdop://machine/abc-123) сколько свободного места."
|
|
318
|
+
rules={onPrimaryRules}
|
|
319
|
+
/>
|
|
320
|
+
<ChatBubble
|
|
321
|
+
role="assistant"
|
|
322
|
+
content="Готово — спросил [Vps-audi](cmdop://machine/abc-123)."
|
|
323
|
+
rules={subtleRules}
|
|
324
|
+
/>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<div>
|
|
329
|
+
<h3 className="mb-2 text-sm font-semibold">
|
|
330
|
+
Same content, two modes — side-by-side contrast
|
|
331
|
+
</h3>
|
|
332
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
333
|
+
Identical input string. Left bubble forces{' '}
|
|
334
|
+
<code>plainText</code> (user-side default in cmdop):{' '}
|
|
335
|
+
<code>*asterisks*</code> stay literal, <code>#hash</code> stays
|
|
336
|
+
literal, newlines preserved verbatim. Right bubble omits{' '}
|
|
337
|
+
<code>plainText</code> so the heuristic kicks in — short
|
|
338
|
+
content but with <code>*pair*</code> markers, so it routes
|
|
339
|
+
through ReactMarkdown and renders <em>italic</em>.
|
|
340
|
+
</p>
|
|
341
|
+
<div className="grid grid-cols-2 gap-3">
|
|
342
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
343
|
+
<div className="mb-2 text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
344
|
+
plainText = true
|
|
345
|
+
</div>
|
|
346
|
+
<ChatBubble
|
|
347
|
+
role="user"
|
|
348
|
+
content={CONTRAST_CONTENT}
|
|
349
|
+
plainText
|
|
350
|
+
rules={onPrimaryRules}
|
|
351
|
+
/>
|
|
352
|
+
</div>
|
|
353
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
354
|
+
<div className="mb-2 text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
355
|
+
plainText omitted → heuristic
|
|
356
|
+
</div>
|
|
357
|
+
<ChatBubble
|
|
358
|
+
role="assistant"
|
|
359
|
+
content={CONTRAST_CONTENT}
|
|
360
|
+
rules={subtleRules}
|
|
361
|
+
/>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
<div>
|
|
367
|
+
<h3 className="mb-2 text-sm font-semibold">
|
|
368
|
+
Long assistant reply (multi-paragraph + code + headings)
|
|
369
|
+
</h3>
|
|
370
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
371
|
+
Full markdown pipeline. Tight CSS overrides
|
|
372
|
+
(<code>p+p mt-2</code>, <code>p my-0</code>) keep the bubble
|
|
373
|
+
from looking airy — defaults from Tailwind's prose are too
|
|
374
|
+
loose for chat density.
|
|
375
|
+
</p>
|
|
376
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
377
|
+
<ChatBubble
|
|
378
|
+
role="assistant"
|
|
379
|
+
content={LONG_ASSISTANT_REPLY}
|
|
380
|
+
rules={subtleRules}
|
|
381
|
+
/>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
@@ -10,7 +10,7 @@ import type { Components } from 'react-markdown';
|
|
|
10
10
|
import { useCollapsibleContent } from '../useCollapsibleContent';
|
|
11
11
|
import type { MarkdownMessageProps } from './types';
|
|
12
12
|
import { buildSchema, buildUrlTransform } from './sanitize';
|
|
13
|
-
import {
|
|
13
|
+
import { looksLikePlainProse } from './plainText';
|
|
14
14
|
import { createMarkdownComponents } from './components';
|
|
15
15
|
import { CollapseToggle } from './CollapseToggle';
|
|
16
16
|
import { applyPreprocess, buildLinkRulesComponent, collectProtocols } from './linkRules';
|
|
@@ -51,6 +51,7 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
|
|
|
51
51
|
className = '',
|
|
52
52
|
isUser = false,
|
|
53
53
|
isCompact = false,
|
|
54
|
+
plainText,
|
|
54
55
|
customComponents,
|
|
55
56
|
extraHrefProtocols,
|
|
56
57
|
linkRules,
|
|
@@ -121,17 +122,34 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
|
|
|
121
122
|
const textSizeClass = isCompact ? 'text-xs' : 'text-sm';
|
|
122
123
|
const proseClass = isCompact ? 'prose-xs' : 'prose-sm';
|
|
123
124
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
125
|
+
// Resolve plain-vs-markdown branch:
|
|
126
|
+
// 1. Caller-passed `plainText` wins outright (explicit beats clever).
|
|
127
|
+
// 2. Caller-supplied non-`a` customComponents force the markdown
|
|
128
|
+
// pipeline — those overrides only matter on real markdown nodes.
|
|
129
|
+
// 3. Otherwise auto-detect via `looksLikePlainProse` (short,
|
|
130
|
+
// single-paragraph, no markdown markers). This is the chat-bubble
|
|
131
|
+
// WhatsApp/Telegram heuristic — "would the human have written
|
|
132
|
+
// this in one keystroke?".
|
|
133
|
+
const customComponentsBeyondLinks = React.useMemo(() => {
|
|
134
|
+
if (!customComponents) return false;
|
|
135
|
+
return Object.keys(customComponents).some((k) => k !== 'a');
|
|
136
|
+
}, [customComponents]);
|
|
137
|
+
const isPlainText = plainText !== undefined
|
|
138
|
+
? plainText
|
|
139
|
+
: !customComponentsBeyondLinks && looksLikePlainProse(displayContent);
|
|
130
140
|
|
|
131
141
|
if (isPlainText) {
|
|
142
|
+
// <div> + whitespace-pre-wrap: respects newlines AND collapses
|
|
143
|
+
// double spaces, which is what users mean when they hit Enter
|
|
144
|
+
// twice. <span> would break flow inside a flex bubble.
|
|
145
|
+
//
|
|
146
|
+
// leading-snug (1.375), not leading-relaxed (1.625) — `pre-wrap`
|
|
147
|
+
// makes every `\n` a hard line break, so the relaxed leading
|
|
148
|
+
// turns multi-line user messages into airy ladders. snug matches
|
|
149
|
+
// WhatsApp/Telegram bubble density.
|
|
132
150
|
return (
|
|
133
|
-
<
|
|
134
|
-
className={`${textSizeClass} leading-
|
|
151
|
+
<div
|
|
152
|
+
className={`${textSizeClass} leading-snug break-words whitespace-pre-wrap ${className}`}
|
|
135
153
|
>
|
|
136
154
|
{displayContent}
|
|
137
155
|
{collapsible && shouldCollapse && (
|
|
@@ -147,7 +165,7 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
|
|
|
147
165
|
/>
|
|
148
166
|
</>
|
|
149
167
|
)}
|
|
150
|
-
</
|
|
168
|
+
</div>
|
|
151
169
|
);
|
|
152
170
|
}
|
|
153
171
|
|
|
@@ -157,7 +175,11 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
|
|
|
157
175
|
className={`
|
|
158
176
|
prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass}
|
|
159
177
|
${isUser ? 'prose-invert' : 'dark:prose-invert'}
|
|
160
|
-
[&>*]:leading-
|
|
178
|
+
[&>*]:leading-relaxed
|
|
179
|
+
[&>*:first-child]:mt-0 [&>*:last-child]:mb-0
|
|
180
|
+
[&_p]:my-0 [&_p+p]:mt-2
|
|
181
|
+
[&_ul]:my-2 [&_ol]:my-2 [&_pre]:my-2 [&_blockquote]:my-2
|
|
182
|
+
[&_h1]:mt-3 [&_h1]:mb-1 [&_h2]:mt-3 [&_h2]:mb-1 [&_h3]:mt-2 [&_h3]:mb-1
|
|
161
183
|
`}
|
|
162
184
|
style={{
|
|
163
185
|
// Inherit colors from parent — fixes issues with external
|
|
@@ -16,6 +16,39 @@ export function extractTextFromChildren(children: React.ReactNode): string {
|
|
|
16
16
|
return '';
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Auto-detect whether `text` should bypass ReactMarkdown and render as
|
|
21
|
+
* a flat `<div whitespace-pre-wrap>`. Used as the *fallback* when the
|
|
22
|
+
* caller doesn't pass `plainText` explicitly to `MarkdownMessage`.
|
|
23
|
+
*
|
|
24
|
+
* The signal we trust: short, single-paragraph, no markdown markers.
|
|
25
|
+
* Anything longer / multi-paragraph / structurally suggestive falls
|
|
26
|
+
* through to the markdown pipeline — false negatives there are cheap
|
|
27
|
+
* (markdown renders prose correctly), false positives in the prose
|
|
28
|
+
* branch surface as escaped `*` / `#` / etc. so we err markdown-ward.
|
|
29
|
+
*
|
|
30
|
+
* Thresholds were picked from chat-bubble UX research (see ChatGPT,
|
|
31
|
+
* Claude.ai, WhatsApp): roughly "would a person have written this in
|
|
32
|
+
* one keystroke without thinking about formatting?". If you find them
|
|
33
|
+
* too tight or too loose, tune here — every consumer goes through
|
|
34
|
+
* `MarkdownMessage` so the change is universal.
|
|
35
|
+
*/
|
|
36
|
+
export function looksLikePlainProse(text: string): boolean {
|
|
37
|
+
const trimmed = text.trim();
|
|
38
|
+
// Empty / whitespace-only — render as plain (cheap path, nothing to parse).
|
|
39
|
+
if (trimmed.length === 0) return true;
|
|
40
|
+
// Long enough that it's likely a structured assistant reply.
|
|
41
|
+
if (trimmed.length > 500) return false;
|
|
42
|
+
// Paragraph break → almost certainly markdown territory.
|
|
43
|
+
if (/\n\s*\n/.test(trimmed)) return false;
|
|
44
|
+
// Many single-line breaks → probably a list or stanza.
|
|
45
|
+
const newlineCount = (trimmed.match(/\n/g) || []).length;
|
|
46
|
+
if (newlineCount > 4) return false;
|
|
47
|
+
// Any markdown marker → defer to ReactMarkdown.
|
|
48
|
+
if (hasMarkdownSyntax(trimmed)) return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
19
52
|
/** Affordance test: does this string look like markdown? Used to skip
|
|
20
53
|
* the (heavier) ReactMarkdown pipeline when the content is pure
|
|
21
54
|
* prose. NOT a validator — false negatives are fine; false positives
|
|
@@ -52,6 +52,19 @@ export interface MarkdownMessageProps {
|
|
|
52
52
|
isUser?: boolean;
|
|
53
53
|
/** Use compact size (text-xs instead of text-sm) */
|
|
54
54
|
isCompact?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Force the plain-text rendering path (single `<div>` with
|
|
57
|
+
* `white-space: pre-wrap`, no ReactMarkdown). Use this for
|
|
58
|
+
* user-authored content where markdown shouldn't be parsed.
|
|
59
|
+
*
|
|
60
|
+
* When `undefined` (default) we run a small heuristic
|
|
61
|
+
* (`looksLikePlainProse`): short single-paragraph text without
|
|
62
|
+
* markdown markers → plain; everything else → ReactMarkdown.
|
|
63
|
+
*
|
|
64
|
+
* Pass an explicit `true` / `false` to opt out of the heuristic
|
|
65
|
+
* entirely.
|
|
66
|
+
*/
|
|
67
|
+
plainText?: boolean;
|
|
55
68
|
/**
|
|
56
69
|
* Per-tag overrides merged on top of the built-in renderers.
|
|
57
70
|
*
|