@extrachill/chat 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/css/chat.css +83 -0
- package/dist/components/ChatMessage.d.ts.map +1 -1
- package/dist/components/ChatMessage.js +12 -5
- 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 +1 -1
- package/src/components/ChatMessage.tsx +11 -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);
|
|
@@ -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,EAAW,MAAM,OAAO,CAAC;AAChD,OAAO,KAAK,EAAE,WAAW,IAAI,eAAe,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAGvF,MAAM,WAAW,gBAAgB;IAChC,6BAA6B;IAC7B,OAAO,EAAE,eAAe,CAAC;IACzB,6DAA6D;IAC7D,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;;OAGG;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,4 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { markdownToHtml } from "../markdown.js";
|
|
2
4
|
/**
|
|
3
5
|
* Renders a single chat message bubble.
|
|
4
6
|
*
|
|
@@ -15,12 +17,17 @@ export function ChatMessage({ message, contentFormat = 'markdown', renderContent
|
|
|
15
17
|
: _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
18
|
}
|
|
17
19
|
function DefaultContent({ content, format }) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
const html = useMemo(() => {
|
|
21
|
+
if (format === 'html')
|
|
22
|
+
return content;
|
|
23
|
+
if (format === 'markdown')
|
|
24
|
+
return markdownToHtml(content);
|
|
25
|
+
return null;
|
|
26
|
+
}, [content, format]);
|
|
27
|
+
if (html !== null) {
|
|
28
|
+
return _jsx("div", { dangerouslySetInnerHTML: { __html: html } });
|
|
20
29
|
}
|
|
21
|
-
//
|
|
22
|
-
// with basic paragraph splitting. Consumers should provide renderContent
|
|
23
|
-
// for proper markdown support.
|
|
30
|
+
// Plain text — split on double newlines for paragraphs.
|
|
24
31
|
return (_jsx(_Fragment, { children: content.split('\n\n').map((paragraph, i) => (_jsx("p", { children: paragraph }, i))) }));
|
|
25
32
|
}
|
|
26
33
|
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,5 +1,6 @@
|
|
|
1
|
-
import { type ReactNode } from 'react';
|
|
1
|
+
import { type ReactNode, useMemo } from 'react';
|
|
2
2
|
import type { ChatMessage as ChatMessageType, ContentFormat } from '../types/index.ts';
|
|
3
|
+
import { markdownToHtml } from '../markdown.ts';
|
|
3
4
|
|
|
4
5
|
export interface ChatMessageProps {
|
|
5
6
|
/** The message to render. */
|
|
@@ -59,13 +60,17 @@ interface DefaultContentProps {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
function DefaultContent({ content, format }: DefaultContentProps) {
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
const html = useMemo(() => {
|
|
64
|
+
if (format === 'html') return content;
|
|
65
|
+
if (format === 'markdown') return markdownToHtml(content);
|
|
66
|
+
return null;
|
|
67
|
+
}, [content, format]);
|
|
68
|
+
|
|
69
|
+
if (html !== null) {
|
|
70
|
+
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
|
64
71
|
}
|
|
65
72
|
|
|
66
|
-
//
|
|
67
|
-
// with basic paragraph splitting. Consumers should provide renderContent
|
|
68
|
-
// for proper markdown support.
|
|
73
|
+
// Plain text — split on double newlines for paragraphs.
|
|
69
74
|
return (
|
|
70
75
|
<>
|
|
71
76
|
{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
|
+
}
|