@stainless-api/docs 0.1.0-beta.129 → 0.1.0-beta.130

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/ambient.d.ts +6 -0
  3. package/eslint-suppressions.json +0 -5
  4. package/package.json +19 -15
  5. package/plugin/generateAPIReferenceLink.ts +0 -40
  6. package/plugin/index.ts +12 -0
  7. package/plugin/loadPluginConfig.ts +36 -5
  8. package/plugin/markdown/highlighter.ts +1 -1
  9. package/plugin/react/Routing.tsx +1 -85
  10. package/plugin/referencePlaceholderUtils.ts +1 -1
  11. package/plugin/routes/llms.ts +186 -0
  12. package/plugin/sidebar-utils/sidebar-builder.ts +2 -7
  13. package/plugin/specs/FileCache.ts +1 -1
  14. package/plugin/specs/index.ts +1 -6
  15. package/plugin/vendor/preview.worker.docs.js +9001 -8694
  16. package/shared/virtualModule.ts +1 -9
  17. package/stl-docs/chat/docs-chat-handler.ts +18 -0
  18. package/stl-docs/chat/hook.ts +215 -0
  19. package/stl-docs/chat/schemas.ts +70 -0
  20. package/stl-docs/chat/stainless-handler/index.ts +126 -0
  21. package/stl-docs/chat/stream-util.ts +16 -0
  22. package/stl-docs/chat/ui/AiChat.module.css +591 -0
  23. package/stl-docs/chat/ui/AiChat.tsx +188 -0
  24. package/stl-docs/chat/ui/Trigger.tsx +154 -0
  25. package/stl-docs/chat/ui/components/ChatControls.tsx +51 -0
  26. package/stl-docs/chat/ui/components/ChatEmpty.tsx +42 -0
  27. package/stl-docs/chat/ui/components/ChatLog.tsx +96 -0
  28. package/stl-docs/chat/ui/components/ChatMessage.tsx +47 -0
  29. package/stl-docs/chat/ui/components/CodeBlock.tsx +33 -0
  30. package/stl-docs/chat/ui/components/MessageFeedback.tsx +109 -0
  31. package/stl-docs/chat/ui/components/Table.tsx +15 -0
  32. package/stl-docs/chat/ui/components/ToolCall.tsx +34 -0
  33. package/stl-docs/chat/ui/components/hljs-github.css +81 -0
  34. package/stl-docs/chat/ui/scroll-manager.ts +86 -0
  35. package/stl-docs/chat/ui/types.ts +45 -0
  36. package/stl-docs/components/AiChatIsland.tsx +14 -12
  37. package/stl-docs/components/PageFrame.astro +7 -4
  38. package/stl-docs/components/headers/DefaultHeader.astro +2 -2
  39. package/stl-docs/components/headers/StackedHeader.astro +2 -2
  40. package/stl-docs/components/mintlify-compat/Accordion.astro +2 -2
  41. package/stl-docs/components/mintlify-compat/AccordionGroup.astro +0 -4
  42. package/stl-docs/components/mintlify-compat/Columns.astro +2 -2
  43. package/stl-docs/components/mintlify-compat/Frame.astro +2 -2
  44. package/stl-docs/components/mintlify-compat/Tab.astro +2 -2
  45. package/stl-docs/components/mintlify-compat/callouts/Callout.astro +2 -2
  46. package/stl-docs/components/mintlify-compat/callouts/Check.astro +0 -4
  47. package/stl-docs/components/mintlify-compat/callouts/Danger.astro +0 -4
  48. package/stl-docs/components/mintlify-compat/callouts/Info.astro +0 -4
  49. package/stl-docs/components/mintlify-compat/callouts/Note.astro +0 -4
  50. package/stl-docs/components/mintlify-compat/callouts/Tip.astro +0 -4
  51. package/stl-docs/components/mintlify-compat/callouts/Warning.astro +0 -4
  52. package/stl-docs/components/nav-tabs/NavDropdown.astro +1 -1
  53. package/stl-docs/components/pagination/PaginationLinkEmphasized.astro +2 -2
  54. package/stl-docs/components/pagination/PaginationLinkQuiet.astro +2 -2
  55. package/stl-docs/components/pagination/util.ts +3 -3
  56. package/stl-docs/disableCalloutSyntax.ts +1 -1
  57. package/stl-docs/index.ts +14 -28
  58. package/stl-docs/loadStlDocsConfig.ts +15 -4
  59. package/stl-docs/proseSearchIndexing.ts +2 -6
  60. package/virtual-module.d.ts +8 -17
  61. package/stl-docs/components/ClientRouterHead.astro +0 -41
@@ -0,0 +1,188 @@
1
+ import { motion } from 'motion/react';
2
+ import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
+ import { useScrollToBottom } from './scroll-manager';
4
+ import { useChat } from '../hook';
5
+ import { DocsLanguage } from '@stainless-api/docs-ui/routing';
6
+
7
+ import ChatLog from './components/ChatLog';
8
+ import AiChatTrigger from './Trigger';
9
+ import ChatEmpty from './components/ChatEmpty';
10
+ import ChatControls from './components/ChatControls';
11
+ import { StainlessHandler } from '../stainless-handler';
12
+
13
+ import styles from './AiChat.module.css';
14
+ import clsx from 'clsx';
15
+
16
+ const borderRadius = 16;
17
+
18
+ /** Remembers user preference but returns 'floating' if panel is not supported */
19
+ function usePresentation(supportsPanel: boolean) {
20
+ const [presentation, setPresentation] = useState<'floating' | 'panel'>('floating');
21
+ const appliedPresentation = supportsPanel ? presentation : 'floating';
22
+ return [appliedPresentation, setPresentation] as const;
23
+ }
24
+
25
+ const examplesPromise = import('virtual:stl-docs-ai-chat-examples')
26
+ .then((mod) => mod.examples)
27
+ .catch(() => undefined);
28
+
29
+ export default function AiChat({
30
+ stainlessProject,
31
+ currentLanguage,
32
+ siteTitle,
33
+ }: {
34
+ stainlessProject: string;
35
+ currentLanguage?: DocsLanguage;
36
+ siteTitle?: string;
37
+ }) {
38
+ const handler = useMemo(
39
+ () => new StainlessHandler(currentLanguage ?? 'http', siteTitle, stainlessProject),
40
+ [currentLanguage, siteTitle, stainlessProject],
41
+ );
42
+ const { chatMessages, sendMessage, rateMessage, setMetadata } = useChat({
43
+ handler,
44
+ });
45
+
46
+ const onCopyMessage = useCallback(
47
+ (spanId: string) => {
48
+ setMetadata(spanId, { copied_to_clipboard: 'true' }).catch(() => {});
49
+ },
50
+ [setMetadata],
51
+ );
52
+
53
+ // panel mode is supported only on larger viewports
54
+ const supportsPanel = useSyncExternalStore(
55
+ (cb) => {
56
+ window.addEventListener('resize', cb);
57
+ return () => window.removeEventListener('resize', cb);
58
+ },
59
+ () => window.innerWidth >= 968,
60
+ () => false,
61
+ );
62
+
63
+ const baseRef = useRef<HTMLDivElement>(null);
64
+ const inputRef = useRef<HTMLTextAreaElement>(null);
65
+ const [focused, setFocused] = useState(false);
66
+
67
+ const [presentation, setPresentation] = usePresentation(supportsPanel);
68
+ const expanded = focused || presentation === 'panel';
69
+
70
+ // Manage “focus” state
71
+ // prettier-ignore
72
+ useEffect(() => {
73
+ const ac = new AbortController();
74
+ // “focus” in/out with click
75
+ window.addEventListener('click', (e) => {
76
+ if (!(e.target instanceof Element) || !baseRef.current || !document.contains(e.target)) return;
77
+ // clicks on elements with this attribute shouldn’t change focus state
78
+ // e.g. clicking minimize button shouldn’t cause the chat to _become_ focused
79
+ if (e.target.closest('[data-ai-chat-hit-ignore]')) return;
80
+ const hit = baseRef.current.contains(e.target);
81
+ setFocused(hit);
82
+ }, { signal: ac.signal });
83
+ // leave with escape
84
+ document.addEventListener('keydown', (e) => {
85
+ if (e.key === 'Escape') {
86
+ setFocused(false);
87
+ setPresentation('floating');
88
+ inputRef.current?.blur(); // this is the one case where the input won’t have already lost focus
89
+ }
90
+ }, { signal: ac.signal });
91
+
92
+ // record focus state when our chat elements receive focus
93
+ // unfocus when another element outside of our component gets focus (incl. by keyboard)
94
+ document.addEventListener('focusin', (e) => {
95
+ if (!(e.target instanceof Element) || !baseRef.current || !document.contains(e.target)) return;
96
+ setFocused(baseRef.current.contains(e.target));
97
+ }, { signal: ac.signal });
98
+ return () => ac.abort();
99
+ }, [setPresentation]);
100
+
101
+ const [pendingResponses, setPendingResponses] = useState(0);
102
+ const handleSendMessage = useCallback(
103
+ (q: string) => {
104
+ setPendingResponses((p) => p + 1);
105
+ sendMessage(q)
106
+ .catch(() => {})
107
+ .finally(() => {
108
+ setPendingResponses((p) => p - 1);
109
+ });
110
+ },
111
+ [sendMessage],
112
+ );
113
+
114
+ // scroll to bottom when new messages come in
115
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
116
+ const scrollContentsRef = useRef<HTMLDivElement>(null);
117
+ useScrollToBottom(
118
+ scrollAreaRef,
119
+ scrollContentsRef,
120
+ // deps to re-run scroll
121
+ useMemo(() => [chatMessages, presentation], [chatMessages, presentation]),
122
+ );
123
+
124
+ return (
125
+ <div className={clsx(styles['outer-wrapper'], styles[`presentation-${presentation}`])} ref={baseRef}>
126
+ <AiChatTrigger
127
+ expanded={expanded}
128
+ updateFocused={setFocused}
129
+ sendMessage={handleSendMessage}
130
+ inputRef={inputRef}
131
+ borderRadius={borderRadius}
132
+ />
133
+
134
+ <motion.div
135
+ layout
136
+ className={styles['chat-area-container']}
137
+ variants={{
138
+ floating: { borderRadius: borderRadius + 1, '--shadow-color': 'var(--base-shadow-color)' },
139
+ panel: { borderRadius: 0, '--shadow-color': 'transparent' },
140
+ }}
141
+ animate={presentation}
142
+ style={{
143
+ display: expanded ? 'flex' : 'none',
144
+ boxShadow: '0 8px 20px -8px var(--shadow-color)',
145
+ }}
146
+ >
147
+ <motion.div
148
+ layout
149
+ className={clsx(styles['chat-area'], 'scrolls-up')}
150
+ variants={{ floating: { borderRadius }, panel: { borderRadius: 0 } }}
151
+ animate={presentation}
152
+ ref={scrollAreaRef}
153
+ >
154
+ <div className={styles['chat-scroll-contents']} ref={scrollContentsRef}>
155
+ <ChatControls
156
+ presentation={presentation}
157
+ setPresentation={(p) => {
158
+ setPresentation(p);
159
+ inputRef.current?.focus();
160
+ }}
161
+ supportsPanel={supportsPanel}
162
+ setClosed={() => {
163
+ setFocused(false);
164
+ setPresentation('floating');
165
+ }}
166
+ />
167
+ {chatMessages.length > 0 ? (
168
+ <ChatLog
169
+ messages={chatMessages}
170
+ rateMessage={rateMessage}
171
+ onCopyMessage={onCopyMessage}
172
+ responsePending={pendingResponses > 0}
173
+ />
174
+ ) : (
175
+ <Suspense fallback={null}>
176
+ <ChatEmpty
177
+ siteTitle={siteTitle}
178
+ promptExamples={examplesPromise}
179
+ sendMessage={handleSendMessage}
180
+ />
181
+ </Suspense>
182
+ )}
183
+ </div>
184
+ </motion.div>
185
+ </motion.div>
186
+ </div>
187
+ );
188
+ }
@@ -0,0 +1,154 @@
1
+ // TODO: move to components
2
+ import React, { useState } from 'react';
3
+ import { ArrowUpIcon, BotMessageSquareIcon } from 'lucide-react';
4
+ import clsx from 'clsx';
5
+ import { Transition } from 'motion';
6
+ import styles from './AiChat.module.css';
7
+ import { motion } from 'motion/react';
8
+
9
+ const MotionBotIcon = motion.create(BotMessageSquareIcon);
10
+
11
+ export default function AiChatTrigger({
12
+ expanded,
13
+ updateFocused,
14
+ sendMessage,
15
+ inputRef,
16
+ borderRadius,
17
+ }: {
18
+ expanded: boolean;
19
+ updateFocused: (focused: boolean) => void;
20
+ sendMessage: (question: string) => void;
21
+ inputRef: React.RefObject<HTMLTextAreaElement | null>;
22
+ borderRadius: number;
23
+ }) {
24
+ const [empty, setEmpty] = useState(true);
25
+ const [resetKey, setResetKey] = useState(0);
26
+
27
+ const layoutTransition = {
28
+ type: 'spring',
29
+ mass: 0.7,
30
+ stiffness: 275,
31
+ damping: 20,
32
+ } satisfies Transition;
33
+
34
+ const crossBlurTransition = {
35
+ delay: expanded ? 0.07 : 0,
36
+ duration: 0.1,
37
+ ease: 'easeInOut',
38
+ } satisfies Transition;
39
+
40
+ const [willChange, setWillChange] = useState(false);
41
+ const willChangeStyle = willChange ? { willChange: 'transform' as const } : {};
42
+
43
+ return (
44
+ <form
45
+ style={{ display: 'contents' }}
46
+ action={(formData) => {
47
+ const question = formData.get('question');
48
+ if (typeof question === 'string' && question.trim().length) {
49
+ sendMessage(question);
50
+ setResetKey((k) => k + 1);
51
+ setEmpty(true);
52
+ }
53
+ }}
54
+ >
55
+ <motion.label
56
+ layout
57
+ transition={layoutTransition}
58
+ className={styles['trigger-outer']}
59
+ style={{
60
+ borderRadius: borderRadius + 1,
61
+ boxShadow: '0 4px 12px -4px var(--shadow-color)',
62
+ ...willChangeStyle,
63
+ }}
64
+ // set will-change when hovering the collapsed trigger
65
+ onMouseEnter={() => {
66
+ setWillChange(!expanded);
67
+ }}
68
+ onMouseLeave={() => setWillChange(false)}
69
+ >
70
+ <motion.div
71
+ layout
72
+ transition={layoutTransition}
73
+ className={clsx(styles.trigger, expanded && styles.expanded)}
74
+ style={{ borderRadius: borderRadius, '--border-radius': `${borderRadius}px`, ...willChangeStyle }}
75
+ >
76
+ {/* Bot icon is visible while closed */}
77
+ <MotionBotIcon
78
+ layout
79
+ className={styles['bot-icon']}
80
+ animate={{
81
+ opacity: expanded ? 0 : 1,
82
+ scale: expanded ? 0.75 : 1,
83
+ filter: expanded ? 'blur(4px)' : 'blur(0px)',
84
+ }}
85
+ style={willChange ? { willChange: 'filter, transform' } : {}}
86
+ transition={crossBlurTransition}
87
+ aria-label="AI chat"
88
+ />
89
+
90
+ {/* Input & send button are visible while open */}
91
+ <motion.div
92
+ layout
93
+ className={styles['expanded-contents']}
94
+ initial={{ opacity: 0 }}
95
+ animate={{
96
+ opacity: expanded ? 1 : 0,
97
+ filter: expanded ? 'blur(0px)' : 'blur(4px)',
98
+ }}
99
+ style={willChange ? { willChange: 'filter, transform' } : {}}
100
+ transition={crossBlurTransition}
101
+ >
102
+ <motion.textarea
103
+ layout
104
+ transition={layoutTransition}
105
+ name="question"
106
+ style={willChangeStyle}
107
+ rows={1}
108
+ placeholder="Ask a question"
109
+ // Keep track of whether the question is submittable
110
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
111
+ setEmpty(e.target.value.trim().length === 0);
112
+ }}
113
+ // Submit on Cmd+Enter
114
+ onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
115
+ if (e.key === 'Enter' && !e.shiftKey) {
116
+ e.preventDefault();
117
+ e.currentTarget.form?.requestSubmit();
118
+ }
119
+ }}
120
+ // Update textarea height to fit content as user types
121
+ ref={(el: HTMLTextAreaElement | null) => {
122
+ inputRef.current = el;
123
+ if (!el) return;
124
+ const updateSize = () => {
125
+ el.style.height = 'auto';
126
+ el.style.height = `${el.scrollHeight}px`;
127
+ };
128
+ const ac = new AbortController();
129
+ el.addEventListener('input', updateSize, { signal: ac.signal });
130
+ updateSize();
131
+ // in case the user focused it before we mounted
132
+ if (document.activeElement === el) updateFocused(true);
133
+ // Re-focus after remount (e.g., after form submission resets the key)
134
+ if (expanded) el.focus();
135
+ return () => ac.abort();
136
+ }}
137
+ // make the ref re-mount so we get a fresh height measurement after reset
138
+ key={resetKey}
139
+ />
140
+ <motion.button
141
+ layout
142
+ type="submit"
143
+ disabled={empty}
144
+ transition={layoutTransition}
145
+ style={willChangeStyle}
146
+ >
147
+ <ArrowUpIcon aria-label="Send" />
148
+ </motion.button>
149
+ </motion.div>
150
+ </motion.div>
151
+ </motion.label>
152
+ </form>
153
+ );
154
+ }
@@ -0,0 +1,51 @@
1
+ import type { Dispatch, SetStateAction } from 'react';
2
+ import { motion } from 'motion/react';
3
+
4
+ import { Button } from '@stainless-api/ui-primitives';
5
+ import { PictureInPictureIcon, PanelRightCloseIcon as PanelRightIcon, MinusIcon } from 'lucide-react';
6
+
7
+ import styles from '../AiChat.module.css';
8
+
9
+ export default function ChatControls({
10
+ presentation,
11
+ setPresentation,
12
+ supportsPanel,
13
+ setClosed,
14
+ }: {
15
+ presentation: 'floating' | 'panel';
16
+ setPresentation: Dispatch<SetStateAction<'floating' | 'panel'>>;
17
+ supportsPanel: boolean;
18
+ setClosed: (e: React.MouseEvent<HTMLElement>) => void;
19
+ }) {
20
+ return (
21
+ <motion.div layout className={styles['controls']}>
22
+ {supportsPanel && (
23
+ <Button
24
+ type="button"
25
+ variant="ghost"
26
+ size="sm"
27
+ aria-label={{ panel: 'Switch to floating chat', floating: 'Switch to panel chat' }[presentation]}
28
+ onClick={() => {
29
+ setPresentation(presentation === 'floating' ? 'panel' : 'floating');
30
+ }}
31
+ >
32
+ <Button.Icon
33
+ icon={{ panel: PictureInPictureIcon, floating: PanelRightIcon }[presentation]}
34
+ size={15}
35
+ />
36
+ </Button>
37
+ )}
38
+
39
+ <Button
40
+ type="button"
41
+ variant="ghost"
42
+ size="sm"
43
+ aria-label="Close chat"
44
+ onClick={setClosed}
45
+ data-ai-chat-hit-ignore
46
+ >
47
+ <Button.Icon icon={MinusIcon} size={15} />
48
+ </Button>
49
+ </motion.div>
50
+ );
51
+ }
@@ -0,0 +1,42 @@
1
+ import { use } from 'react';
2
+ import { motion } from 'motion/react';
3
+ import { BotMessageSquareIcon as BotIcon } from 'lucide-react';
4
+ import type { ExamplePrompt } from '../types';
5
+ import styles from '../AiChat.module.css';
6
+
7
+ export default function ChatEmpty({
8
+ siteTitle,
9
+ promptExamples: promptExamplesPromise,
10
+ sendMessage,
11
+ }: {
12
+ siteTitle?: string;
13
+ promptExamples?: Promise<ExamplePrompt[] | undefined>;
14
+ sendMessage: (question: string) => void;
15
+ }) {
16
+ const promptExamples = promptExamplesPromise && use(promptExamplesPromise);
17
+
18
+ return (
19
+ <motion.div layout className={styles['chat-empty-state']}>
20
+ <BotIcon />
21
+ <h2>What can I help you with?</h2>
22
+
23
+ {promptExamples?.length ? (
24
+ <>
25
+ <h3>Suggestions</h3>
26
+ <ul>
27
+ {promptExamples.map(({ shortPrompt, longPrompt, icon: Icon }) => (
28
+ <li key={shortPrompt} className={styles['chat-example']}>
29
+ <button type="button" onClick={() => sendMessage(longPrompt)}>
30
+ <Icon />
31
+ {shortPrompt}
32
+ </button>
33
+ </li>
34
+ ))}
35
+ </ul>
36
+ </>
37
+ ) : (
38
+ <p>I can help answer questions about {siteTitle ?? 'this API'}. What do you want to build?</p>
39
+ )}
40
+ </motion.div>
41
+ );
42
+ }
@@ -0,0 +1,96 @@
1
+ import type { ChatMessage } from '../types';
2
+ import Message from './ChatMessage';
3
+ import MessageFeedbackButtons from './MessageFeedback';
4
+ import ToolCall from './ToolCall';
5
+
6
+ import { motion } from 'motion/react';
7
+
8
+ import styles from '../AiChat.module.css';
9
+ import { LoaderCircleIcon } from 'lucide-react';
10
+
11
+ export default function ChatLog({
12
+ messages,
13
+ rateMessage,
14
+ onCopyMessage,
15
+ responsePending = false,
16
+ }: {
17
+ messages: ChatMessage[];
18
+ rateMessage?: (spanId: string, rating: 'up' | 'down') => Promise<boolean>;
19
+ onCopyMessage?: (spanId: string) => void;
20
+ responsePending?: boolean;
21
+ }) {
22
+ const lastMessage = messages.at(-1);
23
+
24
+ return (
25
+ <motion.ul
26
+ layout
27
+ role="log"
28
+ aria-live="polite"
29
+ className={styles['message-log']}
30
+ initial={{ opacity: 0, filter: `blur(4px)` }}
31
+ animate={{ opacity: 1, filter: `blur(0px)` }}
32
+ >
33
+ {messages.map((msg) => {
34
+ if (msg.role === 'user') {
35
+ return (
36
+ <Message key={msg.id} role="user">
37
+ {msg.content}
38
+ </Message>
39
+ );
40
+ }
41
+
42
+ if (msg.role === 'assistant' && msg.messageType === 'text') {
43
+ return (
44
+ <Message key={msg.id} role={msg.role} isMarkdown isStreaming={!msg.isComplete}>
45
+ {msg.content}
46
+ </Message>
47
+ );
48
+ }
49
+
50
+ if (msg.role === 'assistant' && msg.messageType === 'tool_use') {
51
+ return <ToolCall key={msg.id} message={msg} />;
52
+ }
53
+
54
+ if (msg.role === 'assistant' && msg.messageType === 'done') {
55
+ return (
56
+ <MessageFeedbackButtons
57
+ key={msg.id}
58
+ spanId={msg.spanId}
59
+ rateMessage={rateMessage}
60
+ onCopyMessage={onCopyMessage}
61
+ // all "text" responses to the given message
62
+ messages={messages.flatMap((msg2) =>
63
+ msg2.role === 'assistant' &&
64
+ msg2.respondingTo === msg.respondingTo &&
65
+ msg2.messageType === 'text'
66
+ ? msg2
67
+ : [],
68
+ )}
69
+ />
70
+ );
71
+ }
72
+
73
+ if (msg.role === 'assistant' && msg.messageType === 'error') {
74
+ return (
75
+ <Message key={msg.id} role="error">
76
+ {msg.errorMessage}
77
+ </Message>
78
+ );
79
+ }
80
+
81
+ return null;
82
+ })}
83
+
84
+ {lastMessage?.role === 'user' && responsePending && (
85
+ <Message key={`${lastMessage.id}-response`} role="assistant">
86
+ {'Thinking'.split('').map((char, i) => (
87
+ <span key={i} className={styles['shimmer-letter']} style={{ '--i': i }}>
88
+ {char}
89
+ </span>
90
+ ))}
91
+ <LoaderCircleIcon className={styles['message-loader']} />
92
+ </Message>
93
+ )}
94
+ </motion.ul>
95
+ );
96
+ }
@@ -0,0 +1,47 @@
1
+ import remend from 'remend';
2
+ import Markdown from 'react-markdown';
3
+ import { motion } from 'motion/react';
4
+
5
+ import highlightCodeComponent from './CodeBlock';
6
+ import tableComponent from './Table';
7
+
8
+ import clsx from 'clsx';
9
+ import styles from '../AiChat.module.css';
10
+ import remarkGfm from 'remark-gfm';
11
+
12
+ export default function ChatMessage({
13
+ children,
14
+ role,
15
+ isStreaming = false,
16
+ isMarkdown = false,
17
+ }: {
18
+ children: React.ReactNode;
19
+ role: 'user' | 'assistant' | 'error';
20
+ isStreaming?: boolean;
21
+ isMarkdown?: boolean;
22
+ }) {
23
+ return (
24
+ <motion.li
25
+ layout="position"
26
+ data-message-role={role}
27
+ className={clsx(styles['chat-message'], 'stl-ui-prose', 'smaller-headings')}
28
+ style={{ borderRadius: 16 }}
29
+ >
30
+ {/* inner div provides scale correction while outer container transforms */}
31
+ {isMarkdown && typeof children === 'string' ? (
32
+ <Markdown
33
+ remarkPlugins={[remarkGfm]}
34
+ components={{
35
+ ...highlightCodeComponent,
36
+ ...tableComponent,
37
+ }}
38
+ >
39
+ {/* repair incomplete markdown syntax during streaming to ensure proper rendering */}
40
+ {isStreaming ? remend(children) : children}
41
+ </Markdown>
42
+ ) : (
43
+ <p>{children}</p>
44
+ )}
45
+ </motion.li>
46
+ );
47
+ }
@@ -0,0 +1,33 @@
1
+ import { Fragment } from 'react';
2
+
3
+ import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter';
4
+
5
+ import type { Components } from 'react-markdown';
6
+ import clsx from 'clsx';
7
+ import './hljs-github.css';
8
+
9
+ function NoPropsFragment({ children }: { children: React.ReactNode }) {
10
+ return <Fragment>{children}</Fragment>;
11
+ }
12
+
13
+ export default {
14
+ code(props) {
15
+ const { children, className, ref, style, ...rest } = props;
16
+ const match = /language-(\w+)/.exec(className || '');
17
+ return match && typeof children === 'string' ? (
18
+ <SyntaxHighlighter
19
+ {...rest}
20
+ PreTag={NoPropsFragment}
21
+ language={match[1]}
22
+ useInlineStyles
23
+ codeTagProps={{ className: clsx(className, 'hljs-github') }}
24
+ >
25
+ {children.replace(/\n$/, '')}
26
+ </SyntaxHighlighter>
27
+ ) : (
28
+ <code {...rest} className={className}>
29
+ {children}
30
+ </code>
31
+ );
32
+ },
33
+ } satisfies Components;