@extrachill/chat 0.2.2 → 0.3.1
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/css/chat.css +83 -0
- package/dist/components/ChatMessage.d.ts +1 -2
- package/dist/components/ChatMessage.d.ts.map +1 -1
- package/dist/components/ChatMessage.js +15 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/markdown.d.ts +13 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +116 -0
- package/package.json +6 -3
- package/src/components/ChatMessage.tsx +26 -6
- package/src/index.ts +3 -0
- package/src/markdown.ts +135 -0
package/css/chat.css
CHANGED
|
@@ -149,6 +149,89 @@
|
|
|
149
149
|
margin-top: 8px;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/* Markdown content styles */
|
|
153
|
+
.ec-chat-message__bubble h1,
|
|
154
|
+
.ec-chat-message__bubble h2,
|
|
155
|
+
.ec-chat-message__bubble h3,
|
|
156
|
+
.ec-chat-message__bubble h4,
|
|
157
|
+
.ec-chat-message__bubble h5,
|
|
158
|
+
.ec-chat-message__bubble h6 {
|
|
159
|
+
margin: 12px 0 4px;
|
|
160
|
+
line-height: 1.3;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.ec-chat-message__bubble h1:first-child,
|
|
164
|
+
.ec-chat-message__bubble h2:first-child,
|
|
165
|
+
.ec-chat-message__bubble h3:first-child {
|
|
166
|
+
margin-top: 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.ec-chat-message__bubble h1 { font-size: 1.25em; }
|
|
170
|
+
.ec-chat-message__bubble h2 { font-size: 1.15em; }
|
|
171
|
+
.ec-chat-message__bubble h3 { font-size: 1.05em; }
|
|
172
|
+
|
|
173
|
+
.ec-chat-message__bubble ul,
|
|
174
|
+
.ec-chat-message__bubble ol {
|
|
175
|
+
margin: 6px 0;
|
|
176
|
+
padding-left: 1.5em;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.ec-chat-message__bubble li {
|
|
180
|
+
margin-bottom: 2px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.ec-chat-message__bubble li + li {
|
|
184
|
+
margin-top: 2px;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.ec-chat-message__bubble code {
|
|
188
|
+
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
|
189
|
+
font-size: 0.875em;
|
|
190
|
+
padding: 1px 4px;
|
|
191
|
+
border-radius: 3px;
|
|
192
|
+
background: rgba(0, 0, 0, 0.06);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.ec-chat-message--user .ec-chat-message__bubble code {
|
|
196
|
+
background: rgba(255, 255, 255, 0.15);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.ec-chat-message__bubble pre {
|
|
200
|
+
margin: 8px 0;
|
|
201
|
+
padding: 10px 12px;
|
|
202
|
+
border-radius: 6px;
|
|
203
|
+
background: rgba(0, 0, 0, 0.06);
|
|
204
|
+
overflow-x: auto;
|
|
205
|
+
font-size: 0.85em;
|
|
206
|
+
line-height: 1.45;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.ec-chat-message--user .ec-chat-message__bubble pre {
|
|
210
|
+
background: rgba(255, 255, 255, 0.1);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.ec-chat-message__bubble pre code {
|
|
214
|
+
padding: 0;
|
|
215
|
+
background: none;
|
|
216
|
+
font-size: inherit;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.ec-chat-message__bubble a {
|
|
220
|
+
color: inherit;
|
|
221
|
+
text-decoration: underline;
|
|
222
|
+
text-underline-offset: 2px;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.ec-chat-message__bubble hr {
|
|
226
|
+
border: none;
|
|
227
|
+
border-top: 1px solid var(--ec-chat-border);
|
|
228
|
+
margin: 8px 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.ec-chat-message__bubble strong {
|
|
232
|
+
font-weight: 600;
|
|
233
|
+
}
|
|
234
|
+
|
|
152
235
|
.ec-chat-message__timestamp {
|
|
153
236
|
font-size: 11px;
|
|
154
237
|
color: var(--ec-chat-text-muted);
|
|
@@ -7,7 +7,6 @@ export interface ChatMessageProps {
|
|
|
7
7
|
contentFormat?: ContentFormat;
|
|
8
8
|
/**
|
|
9
9
|
* Custom content renderer. When provided, overrides contentFormat.
|
|
10
|
-
* Use this to plug in your own markdown renderer (react-markdown, etc.).
|
|
11
10
|
*/
|
|
12
11
|
renderContent?: (content: string, role: ChatMessageType['role']) => ReactNode;
|
|
13
12
|
/** Additional CSS class name on the outer wrapper. */
|
|
@@ -17,7 +16,7 @@ export interface ChatMessageProps {
|
|
|
17
16
|
* Renders a single chat message bubble.
|
|
18
17
|
*
|
|
19
18
|
* User messages align right, assistant messages align left.
|
|
20
|
-
*
|
|
19
|
+
* Markdown content is rendered via react-markdown (lazy-loaded).
|
|
21
20
|
*/
|
|
22
21
|
export declare function ChatMessage({ message, contentFormat, renderContent, className, }: ChatMessageProps): import("react/jsx-runtime").JSX.Element;
|
|
23
22
|
//# sourceMappingURL=ChatMessage.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChatMessage.d.ts","sourceRoot":"","sources":["../../src/components/ChatMessage.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,
|
|
1
|
+
{"version":3,"file":"ChatMessage.d.ts","sourceRoot":"","sources":["../../src/components/ChatMessage.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAkB,MAAM,OAAO,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,IAAI,eAAe,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAKvF,MAAM,WAAW,gBAAgB;IAChC,6BAA6B;IAC7B,OAAO,EAAE,eAAe,CAAC;IACzB,6DAA6D;IAC7D,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;OAEG;IACH,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,CAAC,MAAM,CAAC,KAAK,SAAS,CAAC;IAC9E,sDAAsD;IACtD,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAC3B,OAAO,EACP,aAA0B,EAC1B,aAAa,EACb,SAAS,GACT,EAAE,gBAAgB,2CAyBlB"}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { lazy, Suspense } from 'react';
|
|
3
|
+
import { markdownToHtml } from "../markdown.js";
|
|
4
|
+
const ReactMarkdown = lazy(() => import('react-markdown'));
|
|
2
5
|
/**
|
|
3
6
|
* Renders a single chat message bubble.
|
|
4
7
|
*
|
|
5
8
|
* User messages align right, assistant messages align left.
|
|
6
|
-
*
|
|
9
|
+
* Markdown content is rendered via react-markdown (lazy-loaded).
|
|
7
10
|
*/
|
|
8
11
|
export function ChatMessage({ message, contentFormat = 'markdown', renderContent, className, }) {
|
|
9
12
|
const isUser = message.role === 'user';
|
|
@@ -14,13 +17,21 @@ export function ChatMessage({ message, contentFormat = 'markdown', renderContent
|
|
|
14
17
|
? renderContent(message.content, message.role)
|
|
15
18
|
: _jsx(DefaultContent, { content: message.content, format: contentFormat }) }), message.timestamp && (_jsx("time", { className: `${baseClass}__timestamp`, dateTime: message.timestamp, title: new Date(message.timestamp).toLocaleString(), children: formatTime(message.timestamp) }))] }));
|
|
16
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Markdown rendered via lazy-loaded react-markdown.
|
|
22
|
+
* Falls back to the built-in lightweight parser while loading.
|
|
23
|
+
*/
|
|
24
|
+
function MarkdownContent({ content }) {
|
|
25
|
+
return (_jsx(Suspense, { fallback: _jsx("div", { dangerouslySetInnerHTML: { __html: markdownToHtml(content) } }), children: _jsx(ReactMarkdown, { children: content }) }));
|
|
26
|
+
}
|
|
17
27
|
function DefaultContent({ content, format }) {
|
|
18
28
|
if (format === 'html') {
|
|
19
29
|
return _jsx("div", { dangerouslySetInnerHTML: { __html: content } });
|
|
20
30
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
if (format === 'markdown') {
|
|
32
|
+
return _jsx(MarkdownContent, { content: content });
|
|
33
|
+
}
|
|
34
|
+
// Plain text — split on double newlines for paragraphs.
|
|
24
35
|
return (_jsx(_Fragment, { children: content.split('\n\n').map((paragraph, i) => (_jsx("p", { children: paragraph }, i))) }));
|
|
25
36
|
}
|
|
26
37
|
function formatTime(iso) {
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export type { MessageRole, ToolCall, ToolResultMeta, ChatMessage, ContentFormat,
|
|
|
2
2
|
export type { FetchFn, FetchOptions, ChatApiConfig, SendResult, ContinueResult } from './api.ts';
|
|
3
3
|
export { sendMessage, continueResponse, listSessions, loadSession, deleteSession, } from './api.ts';
|
|
4
4
|
export { normalizeMessage, normalizeConversation, normalizeSession } from './normalizer.ts';
|
|
5
|
+
export { markdownToHtml } from './markdown.ts';
|
|
5
6
|
export { ChatMessage as ChatMessageComponent, type ChatMessageProps, } from './components/ChatMessage.tsx';
|
|
6
7
|
export { ChatMessages, type ChatMessagesProps, } from './components/ChatMessages.tsx';
|
|
7
8
|
export { ChatInput, type ChatInputProps, } from './components/ChatInput.tsx';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,YAAY,EACX,WAAW,EACX,QAAQ,EACR,cAAc,EACd,WAAW,EACX,aAAa,EACb,WAAW,EACX,gBAAgB,EAChB,gBAAgB,EAChB,UAAU,EACV,UAAU,EACV,eAAe,GACf,MAAM,kBAAkB,CAAC;AAG1B,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AACjG,OAAO,EACN,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,WAAW,EACX,aAAa,GACb,MAAM,UAAU,CAAC;AAGlB,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAG5F,OAAO,EACN,WAAW,IAAI,oBAAoB,EACnC,KAAK,gBAAgB,GACrB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACN,YAAY,EACZ,KAAK,iBAAiB,GACtB,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EACN,SAAS,EACT,KAAK,cAAc,GACnB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EACN,WAAW,EACX,KAAK,gBAAgB,EACrB,KAAK,SAAS,GACd,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACN,eAAe,EACf,KAAK,oBAAoB,GACzB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACN,eAAe,EACf,KAAK,oBAAoB,GACzB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACN,aAAa,EACb,KAAK,kBAAkB,GACvB,MAAM,gCAAgC,CAAC;AAExC,OAAO,EACN,gBAAgB,EAChB,KAAK,qBAAqB,GAC1B,MAAM,mCAAmC,CAAC;AAG3C,OAAO,EACN,OAAO,EACP,KAAK,cAAc,EACnB,KAAK,aAAa,GAClB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAAE,IAAI,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,YAAY,EACX,WAAW,EACX,QAAQ,EACR,cAAc,EACd,WAAW,EACX,aAAa,EACb,WAAW,EACX,gBAAgB,EAChB,gBAAgB,EAChB,UAAU,EACV,UAAU,EACV,eAAe,GACf,MAAM,kBAAkB,CAAC;AAG1B,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AACjG,OAAO,EACN,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,WAAW,EACX,aAAa,GACb,MAAM,UAAU,CAAC;AAGlB,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAG5F,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAG/C,OAAO,EACN,WAAW,IAAI,oBAAoB,EACnC,KAAK,gBAAgB,GACrB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACN,YAAY,EACZ,KAAK,iBAAiB,GACtB,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EACN,SAAS,EACT,KAAK,cAAc,GACnB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EACN,WAAW,EACX,KAAK,gBAAgB,EACrB,KAAK,SAAS,GACd,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACN,eAAe,EACf,KAAK,oBAAoB,GACzB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACN,eAAe,EACf,KAAK,oBAAoB,GACzB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACN,aAAa,EACb,KAAK,kBAAkB,GACvB,MAAM,gCAAgC,CAAC;AAExC,OAAO,EACN,gBAAgB,EAChB,KAAK,qBAAqB,GAC1B,MAAM,mCAAmC,CAAC;AAG3C,OAAO,EACN,OAAO,EACP,KAAK,cAAc,EACnB,KAAK,aAAa,GAClB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAAE,IAAI,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { sendMessage, continueResponse, listSessions, loadSession, deleteSession, } from "./api.js";
|
|
2
2
|
// Normalizer
|
|
3
3
|
export { normalizeMessage, normalizeConversation, normalizeSession } from "./normalizer.js";
|
|
4
|
+
// Markdown
|
|
5
|
+
export { markdownToHtml } from "./markdown.js";
|
|
4
6
|
// Components
|
|
5
7
|
export { ChatMessage as ChatMessageComponent, } from "./components/ChatMessage.js";
|
|
6
8
|
export { ChatMessages, } from "./components/ChatMessages.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight markdown-to-HTML converter for chat messages.
|
|
3
|
+
*
|
|
4
|
+
* Handles the subset of markdown that LLM responses typically produce:
|
|
5
|
+
* headings, bold, italic, inline code, code blocks, links, lists,
|
|
6
|
+
* and paragraphs. Not a full CommonMark parser — just enough for
|
|
7
|
+
* clean chat rendering without heavy dependencies.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Parse markdown string into HTML.
|
|
11
|
+
*/
|
|
12
|
+
export declare function markdownToHtml(source: string): string;
|
|
13
|
+
//# sourceMappingURL=markdown.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../src/markdown.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAqCH;;GAEG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAuFrD"}
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight markdown-to-HTML converter for chat messages.
|
|
3
|
+
*
|
|
4
|
+
* Handles the subset of markdown that LLM responses typically produce:
|
|
5
|
+
* headings, bold, italic, inline code, code blocks, links, lists,
|
|
6
|
+
* and paragraphs. Not a full CommonMark parser — just enough for
|
|
7
|
+
* clean chat rendering without heavy dependencies.
|
|
8
|
+
*/
|
|
9
|
+
const ESCAPED = {
|
|
10
|
+
'&': '&',
|
|
11
|
+
'<': '<',
|
|
12
|
+
'>': '>',
|
|
13
|
+
'"': '"',
|
|
14
|
+
};
|
|
15
|
+
function escapeHtml(text) {
|
|
16
|
+
return text.replace(/[&<>"]/g, (ch) => ESCAPED[ch] ?? ch);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Convert inline markdown to HTML.
|
|
20
|
+
* Order matters — code spans must be processed before bold/italic
|
|
21
|
+
* to avoid mangling backtick contents.
|
|
22
|
+
*/
|
|
23
|
+
function inlineMarkdown(text) {
|
|
24
|
+
let html = escapeHtml(text);
|
|
25
|
+
// Inline code (must come first to protect contents)
|
|
26
|
+
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
27
|
+
// Bold + italic
|
|
28
|
+
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
29
|
+
// Bold
|
|
30
|
+
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
31
|
+
// Italic
|
|
32
|
+
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
33
|
+
// Links [text](url)
|
|
34
|
+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
35
|
+
return html;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse markdown string into HTML.
|
|
39
|
+
*/
|
|
40
|
+
export function markdownToHtml(source) {
|
|
41
|
+
const lines = source.split('\n');
|
|
42
|
+
const output = [];
|
|
43
|
+
let i = 0;
|
|
44
|
+
while (i < lines.length) {
|
|
45
|
+
const line = lines[i];
|
|
46
|
+
// Fenced code block
|
|
47
|
+
if (line.startsWith('```')) {
|
|
48
|
+
const lang = line.slice(3).trim();
|
|
49
|
+
const codeLines = [];
|
|
50
|
+
i++;
|
|
51
|
+
while (i < lines.length && !lines[i].startsWith('```')) {
|
|
52
|
+
codeLines.push(escapeHtml(lines[i]));
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
i++; // skip closing ```
|
|
56
|
+
const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : '';
|
|
57
|
+
output.push(`<pre><code${langAttr}>${codeLines.join('\n')}</code></pre>`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// Heading
|
|
61
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
62
|
+
if (headingMatch) {
|
|
63
|
+
const level = headingMatch[1].length;
|
|
64
|
+
output.push(`<h${level}>${inlineMarkdown(headingMatch[2])}</h${level}>`);
|
|
65
|
+
i++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// Horizontal rule
|
|
69
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
|
|
70
|
+
output.push('<hr>');
|
|
71
|
+
i++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// Unordered list
|
|
75
|
+
if (/^\s*[-*+]\s/.test(line)) {
|
|
76
|
+
const listItems = [];
|
|
77
|
+
while (i < lines.length && /^\s*[-*+]\s/.test(lines[i])) {
|
|
78
|
+
listItems.push(`<li>${inlineMarkdown(lines[i].replace(/^\s*[-*+]\s+/, ''))}</li>`);
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
output.push(`<ul>${listItems.join('')}</ul>`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// Ordered list
|
|
85
|
+
if (/^\s*\d+[.)]\s/.test(line)) {
|
|
86
|
+
const listItems = [];
|
|
87
|
+
while (i < lines.length && /^\s*\d+[.)]\s/.test(lines[i])) {
|
|
88
|
+
listItems.push(`<li>${inlineMarkdown(lines[i].replace(/^\s*\d+[.)]\s+/, ''))}</li>`);
|
|
89
|
+
i++;
|
|
90
|
+
}
|
|
91
|
+
output.push(`<ol>${listItems.join('')}</ol>`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Empty line — skip (paragraph breaks handled by grouping)
|
|
95
|
+
if (line.trim() === '') {
|
|
96
|
+
i++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Paragraph — collect consecutive non-empty, non-special lines
|
|
100
|
+
const paraLines = [];
|
|
101
|
+
while (i < lines.length &&
|
|
102
|
+
lines[i].trim() !== '' &&
|
|
103
|
+
!lines[i].startsWith('```') &&
|
|
104
|
+
!lines[i].match(/^#{1,6}\s/) &&
|
|
105
|
+
!/^\s*[-*+]\s/.test(lines[i]) &&
|
|
106
|
+
!/^\s*\d+[.)]\s/.test(lines[i]) &&
|
|
107
|
+
!/^(-{3,}|\*{3,}|_{3,})$/.test(lines[i].trim())) {
|
|
108
|
+
paraLines.push(lines[i]);
|
|
109
|
+
i++;
|
|
110
|
+
}
|
|
111
|
+
if (paraLines.length > 0) {
|
|
112
|
+
output.push(`<p>${inlineMarkdown(paraLines.join('\n'))}</p>`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return output.join('');
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@extrachill/chat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Chat UI components with built-in REST API client. Speaks the standard chat message format natively.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -52,10 +52,13 @@
|
|
|
52
52
|
"react-dom": ">=18.0.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"typescript": "^5.7.0",
|
|
56
55
|
"@types/react": "^18.0.0",
|
|
57
56
|
"@types/react-dom": "^18.0.0",
|
|
58
57
|
"react": "^18.0.0",
|
|
59
|
-
"react-dom": "^18.0.0"
|
|
58
|
+
"react-dom": "^18.0.0",
|
|
59
|
+
"typescript": "^5.7.0"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"react-markdown": "^10.1.0"
|
|
60
63
|
}
|
|
61
64
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { type ReactNode } from 'react';
|
|
1
|
+
import { type ReactNode, lazy, Suspense } from 'react';
|
|
2
2
|
import type { ChatMessage as ChatMessageType, ContentFormat } from '../types/index.ts';
|
|
3
|
+
import { markdownToHtml } from '../markdown.ts';
|
|
4
|
+
|
|
5
|
+
const ReactMarkdown = lazy(() => import('react-markdown'));
|
|
3
6
|
|
|
4
7
|
export interface ChatMessageProps {
|
|
5
8
|
/** The message to render. */
|
|
@@ -8,7 +11,6 @@ export interface ChatMessageProps {
|
|
|
8
11
|
contentFormat?: ContentFormat;
|
|
9
12
|
/**
|
|
10
13
|
* Custom content renderer. When provided, overrides contentFormat.
|
|
11
|
-
* Use this to plug in your own markdown renderer (react-markdown, etc.).
|
|
12
14
|
*/
|
|
13
15
|
renderContent?: (content: string, role: ChatMessageType['role']) => ReactNode;
|
|
14
16
|
/** Additional CSS class name on the outer wrapper. */
|
|
@@ -19,7 +21,7 @@ export interface ChatMessageProps {
|
|
|
19
21
|
* Renders a single chat message bubble.
|
|
20
22
|
*
|
|
21
23
|
* User messages align right, assistant messages align left.
|
|
22
|
-
*
|
|
24
|
+
* Markdown content is rendered via react-markdown (lazy-loaded).
|
|
23
25
|
*/
|
|
24
26
|
export function ChatMessage({
|
|
25
27
|
message,
|
|
@@ -58,14 +60,32 @@ interface DefaultContentProps {
|
|
|
58
60
|
format: ContentFormat;
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Markdown rendered via lazy-loaded react-markdown.
|
|
65
|
+
* Falls back to the built-in lightweight parser while loading.
|
|
66
|
+
*/
|
|
67
|
+
function MarkdownContent({ content }: { content: string }) {
|
|
68
|
+
return (
|
|
69
|
+
<Suspense
|
|
70
|
+
fallback={
|
|
71
|
+
<div dangerouslySetInnerHTML={{ __html: markdownToHtml(content) }} />
|
|
72
|
+
}
|
|
73
|
+
>
|
|
74
|
+
<ReactMarkdown>{content}</ReactMarkdown>
|
|
75
|
+
</Suspense>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
61
79
|
function DefaultContent({ content, format }: DefaultContentProps) {
|
|
62
80
|
if (format === 'html') {
|
|
63
81
|
return <div dangerouslySetInnerHTML={{ __html: content }} />;
|
|
64
82
|
}
|
|
65
83
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
if (format === 'markdown') {
|
|
85
|
+
return <MarkdownContent content={content} />;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Plain text — split on double newlines for paragraphs.
|
|
69
89
|
return (
|
|
70
90
|
<>
|
|
71
91
|
{content.split('\n\n').map((paragraph, i) => (
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,9 @@ export {
|
|
|
26
26
|
// Normalizer
|
|
27
27
|
export { normalizeMessage, normalizeConversation, normalizeSession } from './normalizer.ts';
|
|
28
28
|
|
|
29
|
+
// Markdown
|
|
30
|
+
export { markdownToHtml } from './markdown.ts';
|
|
31
|
+
|
|
29
32
|
// Components
|
|
30
33
|
export {
|
|
31
34
|
ChatMessage as ChatMessageComponent,
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight markdown-to-HTML converter for chat messages.
|
|
3
|
+
*
|
|
4
|
+
* Handles the subset of markdown that LLM responses typically produce:
|
|
5
|
+
* headings, bold, italic, inline code, code blocks, links, lists,
|
|
6
|
+
* and paragraphs. Not a full CommonMark parser — just enough for
|
|
7
|
+
* clean chat rendering without heavy dependencies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const ESCAPED: Record<string, string> = {
|
|
11
|
+
'&': '&',
|
|
12
|
+
'<': '<',
|
|
13
|
+
'>': '>',
|
|
14
|
+
'"': '"',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function escapeHtml(text: string): string {
|
|
18
|
+
return text.replace(/[&<>"]/g, (ch) => ESCAPED[ch] ?? ch);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Convert inline markdown to HTML.
|
|
23
|
+
* Order matters — code spans must be processed before bold/italic
|
|
24
|
+
* to avoid mangling backtick contents.
|
|
25
|
+
*/
|
|
26
|
+
function inlineMarkdown(text: string): string {
|
|
27
|
+
let html = escapeHtml(text);
|
|
28
|
+
|
|
29
|
+
// Inline code (must come first to protect contents)
|
|
30
|
+
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
31
|
+
|
|
32
|
+
// Bold + italic
|
|
33
|
+
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
34
|
+
// Bold
|
|
35
|
+
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
36
|
+
// Italic
|
|
37
|
+
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
38
|
+
|
|
39
|
+
// Links [text](url)
|
|
40
|
+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
41
|
+
|
|
42
|
+
return html;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse markdown string into HTML.
|
|
47
|
+
*/
|
|
48
|
+
export function markdownToHtml(source: string): string {
|
|
49
|
+
const lines = source.split('\n');
|
|
50
|
+
const output: string[] = [];
|
|
51
|
+
let i = 0;
|
|
52
|
+
|
|
53
|
+
while (i < lines.length) {
|
|
54
|
+
const line = lines[i];
|
|
55
|
+
|
|
56
|
+
// Fenced code block
|
|
57
|
+
if (line.startsWith('```')) {
|
|
58
|
+
const lang = line.slice(3).trim();
|
|
59
|
+
const codeLines: string[] = [];
|
|
60
|
+
i++;
|
|
61
|
+
while (i < lines.length && !lines[i].startsWith('```')) {
|
|
62
|
+
codeLines.push(escapeHtml(lines[i]));
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
i++; // skip closing ```
|
|
66
|
+
const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : '';
|
|
67
|
+
output.push(`<pre><code${langAttr}>${codeLines.join('\n')}</code></pre>`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Heading
|
|
72
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
73
|
+
if (headingMatch) {
|
|
74
|
+
const level = headingMatch[1].length;
|
|
75
|
+
output.push(`<h${level}>${inlineMarkdown(headingMatch[2])}</h${level}>`);
|
|
76
|
+
i++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Horizontal rule
|
|
81
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
|
|
82
|
+
output.push('<hr>');
|
|
83
|
+
i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Unordered list
|
|
88
|
+
if (/^\s*[-*+]\s/.test(line)) {
|
|
89
|
+
const listItems: string[] = [];
|
|
90
|
+
while (i < lines.length && /^\s*[-*+]\s/.test(lines[i])) {
|
|
91
|
+
listItems.push(`<li>${inlineMarkdown(lines[i].replace(/^\s*[-*+]\s+/, ''))}</li>`);
|
|
92
|
+
i++;
|
|
93
|
+
}
|
|
94
|
+
output.push(`<ul>${listItems.join('')}</ul>`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Ordered list
|
|
99
|
+
if (/^\s*\d+[.)]\s/.test(line)) {
|
|
100
|
+
const listItems: string[] = [];
|
|
101
|
+
while (i < lines.length && /^\s*\d+[.)]\s/.test(lines[i])) {
|
|
102
|
+
listItems.push(`<li>${inlineMarkdown(lines[i].replace(/^\s*\d+[.)]\s+/, ''))}</li>`);
|
|
103
|
+
i++;
|
|
104
|
+
}
|
|
105
|
+
output.push(`<ol>${listItems.join('')}</ol>`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Empty line — skip (paragraph breaks handled by grouping)
|
|
110
|
+
if (line.trim() === '') {
|
|
111
|
+
i++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Paragraph — collect consecutive non-empty, non-special lines
|
|
116
|
+
const paraLines: string[] = [];
|
|
117
|
+
while (
|
|
118
|
+
i < lines.length &&
|
|
119
|
+
lines[i].trim() !== '' &&
|
|
120
|
+
!lines[i].startsWith('```') &&
|
|
121
|
+
!lines[i].match(/^#{1,6}\s/) &&
|
|
122
|
+
!/^\s*[-*+]\s/.test(lines[i]) &&
|
|
123
|
+
!/^\s*\d+[.)]\s/.test(lines[i]) &&
|
|
124
|
+
!/^(-{3,}|\*{3,}|_{3,})$/.test(lines[i].trim())
|
|
125
|
+
) {
|
|
126
|
+
paraLines.push(lines[i]);
|
|
127
|
+
i++;
|
|
128
|
+
}
|
|
129
|
+
if (paraLines.length > 0) {
|
|
130
|
+
output.push(`<p>${inlineMarkdown(paraLines.join('\n'))}</p>`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return output.join('');
|
|
135
|
+
}
|