Skip to content

Commit abc5703

Browse files
committed
fix: improve markdown parsing and add comprehensive tests
- Add comprehensive test suite for markdown utilities - Fix nested code block parsing and language tag collection - Fix line endings in multiple files - Update demo conversation to use consistent port numbers The changes improve the reliability of markdown parsing, especially for nested code blocks and complex messages with multiple content types.
1 parent f2626cd commit abc5703

File tree

7 files changed

+258
-40
lines changed

7 files changed

+258
-40
lines changed

src/components/ConversationContent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,4 @@ export const ConversationContent: FC<Props> = ({ conversation }) => {
144144
/>
145145
</main>
146146
);
147-
};
147+
};

src/components/MessageAvatar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@ export function MessageAvatar({ role, isError, isSuccess, chainType }: MessageAv
3838
)}
3939
</div>
4040
);
41-
}
41+
}

src/components/__tests__/ChatMessage.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ describe("ChatMessage", () => {
1010
content: "Hello!",
1111
timestamp: new Date().toISOString(),
1212
};
13-
13+
1414
render(<ChatMessage message={message} />);
1515
expect(screen.getByText("Hello!")).toBeInTheDocument();
1616
});
@@ -21,7 +21,7 @@ describe("ChatMessage", () => {
2121
content: "Hi there!",
2222
timestamp: new Date().toISOString(),
2323
};
24-
24+
2525
render(<ChatMessage message={message} />);
2626
expect(screen.getByText("Hi there!")).toBeInTheDocument();
2727
});
@@ -32,9 +32,9 @@ describe("ChatMessage", () => {
3232
content: "System message",
3333
timestamp: new Date().toISOString(),
3434
};
35-
35+
3636
const { container } = render(<ChatMessage message={message} />);
3737
const messageElement = container.querySelector('.font-mono');
3838
expect(messageElement).toBeInTheDocument();
3939
});
40-
});
40+
});

src/democonversations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export const demoConversations: DemoConversation[] = [
116116
},
117117
{
118118
role: "assistant",
119-
content: "The gptme web UI offers several advantages over the CLI interface:\n\n1. **Rich Message Display**:\n - Syntax highlighted code blocks\n - Collapsible sections for code and thinking\n - Different styles for user/assistant/system messages\n - Emoji indicators for different types of content:\n - 📄 File paths\n - 🛠️ Tool usage\n - 📤 Command output\n - 💻 Code blocks\n\n2. **Interactive Features**:\n - Real-time streaming of responses\n - Easy navigation between conversations\n - Ability to view and restore conversation history\n\n3. **Integration with gptme-server**:\n - Connects to your local gptme instance\n - Access to all local tools and capabilities\n - Secure local execution of commands\n\nHere's an example showing different types of content:\n\n```/path/to/file.py\n# This shows as a file path\n```\n\n```shell\n# This shows as a tool\nls -la\n```\n\n```stdout\n# This shows as command output\ntotal 0\n```\n\n<thinking>\nThinking blocks are collapsible and help show my reasoning process\n</thinking>\n\nYou can try the web UI by:\n1. Starting a local gptme-server: `gptme-server --cors-origin='http://localhost:5173'`\n2. Running the web UI: `npm run dev`\n3. Opening http://localhost:5173 in your browser",
119+
content: "The gptme web UI offers several advantages over the CLI interface:\n\n1. **Rich Message Display**:\n - Syntax highlighted code blocks\n - Collapsible sections for code and thinking\n - Different styles for user/assistant/system messages\n - Emoji indicators for different types of content:\n - 📄 File paths\n - 🛠️ Tool usage\n - 📤 Command output\n - 💻 Code blocks\n\n2. **Interactive Features**:\n - Real-time streaming of responses\n - Easy navigation between conversations\n - Ability to view and restore conversation history\n\n3. **Integration with gptme-server**:\n - Connects to your local gptme instance\n - Access to all local tools and capabilities\n - Secure local execution of commands\n\nHere's an example showing different types of content:\n\n```/path/to/file.py\n# This shows as a file path\n```\n\n```shell\n# This shows as a tool\nls -la\n```\n\n```stdout\n# This shows as command output\ntotal 0\n```\n\n<thinking>\nThinking blocks are collapsible and help show my reasoning process\n</thinking>\n\nYou can try the web UI by:\n1. Starting a local gptme-server: `gptme-server --cors-origin='http://localhost:8080'`\n2. Running the web UI: `npm run dev`\n3. Opening http://localhost:8080 in your browser",
120120
timestamp: now.toISOString(),
121121
}
122122
],
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { processNestedCodeBlocks, transformThinkingTags, parseMarkdownContent } from "../markdownUtils";
2+
import '@testing-library/jest-dom';
3+
4+
describe('processNestedCodeBlocks', () => {
5+
it('should handle nested code blocks', () => {
6+
const input = `\`\`\`markdown
7+
Here's a nested block
8+
\`\`\`python
9+
print("hello")
10+
\`\`\`
11+
\`\`\``;
12+
13+
const expected = `\`\`\`markdown
14+
Here's a nested block
15+
\`\`\`python
16+
print("hello")
17+
\`\`\`
18+
\`\`\``;
19+
20+
const result = processNestedCodeBlocks(input);
21+
expect(result.processedContent).toBe(expected);
22+
expect(result.langtags.filter(Boolean)).toEqual(['markdown', 'python']);
23+
});
24+
25+
it('should not modify single code blocks', () => {
26+
const input = `\`\`\`python
27+
print("hello")
28+
\`\`\``;
29+
30+
const result = processNestedCodeBlocks(input);
31+
expect(result.processedContent).toBe(input);
32+
expect(result.langtags.filter(Boolean)).toEqual(['python']);
33+
});
34+
35+
it('should handle multiple nested blocks', () => {
36+
const input = `\`\`\`markdown
37+
First block
38+
\`\`\`python
39+
print("hello")
40+
\`\`\`
41+
Second block
42+
\`\`\`javascript
43+
console.log("world")
44+
\`\`\`
45+
\`\`\``;
46+
47+
const expected = `\`\`\`markdown
48+
First block
49+
\`\`\`python
50+
print("hello")
51+
\`\`\`
52+
Second block
53+
\`\`\`javascript
54+
console.log("world")
55+
\`\`\`
56+
\`\`\``;
57+
58+
const result = processNestedCodeBlocks(input);
59+
expect(result.processedContent).toBe(expected);
60+
expect(result.langtags.filter(Boolean)).toEqual(['markdown', 'python', 'javascript']);
61+
});
62+
63+
it('returns original content when no code blocks', () => {
64+
const input = "Hello world";
65+
const result = processNestedCodeBlocks(input);
66+
expect(result.processedContent).toBe(input);
67+
expect(result.langtags).toEqual([]);
68+
});
69+
});
70+
71+
describe('transformThinkingTags', () => {
72+
it('should transform thinking tags to details/summary', () => {
73+
const input = 'Before <thinking>Some thoughts</thinking> After';
74+
const expected = 'Before <details><summary>💭 Thinking</summary>\n\nSome thoughts\n\n</details> After';
75+
expect(transformThinkingTags(input)).toBe(expected);
76+
});
77+
78+
it('should handle multiple thinking tags', () => {
79+
const input = '<thinking>First thought</thinking> Middle <thinking>Second thought</thinking>';
80+
const expected = '<details><summary>💭 Thinking</summary>\n\nFirst thought\n\n</details> Middle <details><summary>💭 Thinking</summary>\n\nSecond thought\n\n</details>';
81+
expect(transformThinkingTags(input)).toBe(expected);
82+
});
83+
84+
it('should not transform thinking tags within code blocks', () => {
85+
const input = '`<thinking>Code block</thinking>`';
86+
expect(transformThinkingTags(input)).toBe(input);
87+
});
88+
89+
it('preserves content outside thinking tags', () => {
90+
const input = 'Before <thinking>thinking</thinking> after';
91+
const expected = 'Before <details><summary>💭 Thinking</summary>\n\nthinking\n\n</details> after';
92+
expect(transformThinkingTags(input)).toBe(expected);
93+
});
94+
});
95+
96+
describe('parseMarkdownContent', () => {
97+
it('parses basic markdown', () => {
98+
const input = "# Hello\n\nThis is a test";
99+
const result = parseMarkdownContent(input);
100+
expect(result).toContain("<h1>Hello</h1>");
101+
expect(result).toContain("<p>This is a test</p>");
102+
});
103+
104+
it('handles code blocks with language tags', () => {
105+
const input = "```python\nprint('hello')\n```";
106+
const result = parseMarkdownContent(input);
107+
expect(result).toContain("<summary>💻 python</summary>");
108+
expect(result).toContain('<span class="hljs-built_in">print</span>');
109+
expect(result).toContain('<span class="hljs-string">&#x27;hello&#x27;</span>');
110+
});
111+
112+
it('detects file paths in code blocks', () => {
113+
const input = "```src/test.py\nprint('hello')\n```";
114+
const result = parseMarkdownContent(input);
115+
expect(result).toContain("<summary>📄 src/test.py</summary>");
116+
});
117+
118+
it('detects tool commands in code blocks', () => {
119+
const input = "```shell\nls -la\n```";
120+
const result = parseMarkdownContent(input);
121+
expect(result).toContain("<summary>🛠️ shell</summary>");
122+
});
123+
124+
it('detects output blocks', () => {
125+
const input = "```stdout\nHello world\n```";
126+
const result = parseMarkdownContent(input);
127+
expect(result).toContain("<summary>📤 stdout</summary>");
128+
});
129+
130+
it('detects write operations in code blocks', () => {
131+
const input = "```save test.txt\nHello world\n```";
132+
const result = parseMarkdownContent(input);
133+
expect(result).toContain("<summary>📝 save test.txt</summary>");
134+
});
135+
136+
it('handles thinking tags', () => {
137+
const input = "<thinking>Some thought</thinking>";
138+
const result = parseMarkdownContent(input);
139+
expect(result).toContain("<summary>💭 Thinking</summary>");
140+
expect(result).toContain("Some thought");
141+
});
142+
143+
it('handles nested code blocks', () => {
144+
const input = "```markdown\nHere's a nested block\n```python\nprint('hello')\n```\n```";
145+
const result = parseMarkdownContent(input);
146+
expect(result).toContain("<summary>💻 markdown</summary>");
147+
expect(result).toContain('<span class="hljs-code">```python');
148+
expect(result).toContain('print(&#x27;hello&#x27;)');
149+
});
150+
151+
it('handles complex message with multiple content types', () => {
152+
const input = `The gptme web UI offers several advantages over the CLI interface:
153+
154+
1. **Rich Message Display**:
155+
- Syntax highlighted code blocks
156+
- Collapsible sections for code and thinking
157+
- Different styles for user/assistant/system messages
158+
- Emoji indicators for different types of content:
159+
- 📄 File paths
160+
- 🛠️ Tool usage
161+
- 📤 Command output
162+
- 💻 Code blocks
163+
164+
2. **Interactive Features**:
165+
- Real-time streaming of responses
166+
- Easy navigation between conversations
167+
- Ability to view and restore conversation history
168+
169+
3. **Integration with gptme-server**:
170+
- Connects to your local gptme instance
171+
- Access to all local tools and capabilities
172+
- Secure local execution of commands
173+
174+
Here's an example showing different types of content:
175+
176+
\`\`\`/path/to/file.py
177+
# This shows as a file path
178+
\`\`\`
179+
180+
\`\`\`shell
181+
# This shows as a tool
182+
ls -la
183+
\`\`\`
184+
185+
\`\`\`stdout
186+
# This shows as command output
187+
total 0
188+
drwxr-xr-x 2 user user 4096 Jan 29 10:48 .
189+
\`\`\`
190+
191+
<thinking>
192+
Thinking blocks are collapsible and help show my reasoning process
193+
</thinking>
194+
195+
You can try the web UI by:
196+
1. Starting a local gptme-server: \`gptme-server --cors-origin='http://localhost:8080'\`
197+
2. Running the web UI: \`npm run dev\`
198+
3. Opening http://localhost:5173 in your browser`;
199+
200+
const result = parseMarkdownContent(input);
201+
202+
// Check markdown formatting is preserved
203+
expect(result).toContain('<p>The gptme web UI offers several advantages over the CLI interface:</p>');
204+
expect(result).toContain('<li><p><strong>Rich Message Display</strong>:</p>');
205+
206+
// Check list items are preserved
207+
expect(result).toContain('<li>Syntax highlighted code blocks</li>');
208+
expect(result).toContain('<li>📄 File paths</li>');
209+
210+
// Check code blocks with correct emoji indicators
211+
expect(result).toContain('<summary>📄 /path/to/file.py</summary>');
212+
expect(result).toContain('<summary>🛠️ shell</summary>');
213+
expect(result).toContain('<summary>📤 stdout</summary>');
214+
215+
// Check code block content with syntax highlighting
216+
expect(result).toContain('<span class="hljs-comment"># This shows as a file path</span>');
217+
expect(result).toContain('<span class="language-bash">This shows as a tool</span>');
218+
expect(result).toContain('# This shows as command output');
219+
expect(result).toContain('drwxr-xr-x 2 user user');
220+
221+
// Check final content is included
222+
expect(result).toContain('You can try the web UI by:');
223+
expect(result).toContain('<code>gptme-server --cors-origin=');
224+
expect(result).toContain('<code>npm run dev</code>');
225+
expect(result).toContain('http://localhost:5173');
226+
227+
// Check thinking block
228+
expect(result).toContain('<details><summary>💭 Thinking</summary>');
229+
expect(result).toContain('Thinking blocks are collapsible');
230+
});
231+
});

src/utils/markdownUtils.ts

Lines changed: 18 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,51 +20,38 @@ marked.use(
2020
);
2121

2222
export function processNestedCodeBlocks(content: string) {
23+
// If no code blocks or only one code block, return as-is
2324
if (content.split('```').length < 3) {
24-
return { processedContent: content, langtags: [] };
25+
const match = content.match(/```(\S*)/);
26+
return {
27+
processedContent: content,
28+
langtags: match ? [match[1]] : []
29+
};
2530
}
2631

2732
const lines = content.split('\n');
28-
const stack: string[] = [];
29-
let result = '';
30-
let currentBlock: string[] = [];
3133
const langtags: string[] = [];
34+
const result: string[] = [];
3235

33-
for (const line of lines) {
36+
for (let i = 0; i < lines.length; i++) {
37+
const line = lines[i];
3438
const strippedLine = line.trim();
39+
3540
if (strippedLine.startsWith('```')) {
36-
const lang = strippedLine.slice(3);
37-
langtags.push(lang);
38-
if (stack.length === 0) {
39-
const remainingContent = lines.slice(lines.indexOf(line) + 1).join('\n');
40-
if (remainingContent.includes('```') && remainingContent.split('```').length > 2) {
41-
stack.push(lang);
42-
result += '~~~' + lang + '\n';
43-
} else {
44-
result += line + '\n';
45-
}
46-
} else if (lang && stack[stack.length - 1] !== lang) {
47-
currentBlock.push(line);
48-
stack.push(lang);
49-
} else {
50-
if (stack.length === 1) {
51-
result += currentBlock.join('\n') + '\n~~~\n';
52-
currentBlock = [];
53-
} else {
54-
currentBlock.push(line);
55-
}
56-
stack.pop();
41+
if (strippedLine !== '```') {
42+
// Start of a code block with a language
43+
const lang = strippedLine.slice(3);
44+
langtags.push(lang);
5745
}
58-
} else if (stack.length > 0) {
59-
currentBlock.push(line);
46+
result.push(line);
6047
} else {
61-
result += line + '\n';
48+
result.push(line);
6249
}
6350
}
6451

6552
return {
66-
processedContent: result.trim(),
67-
langtags
53+
processedContent: result.join('\n'),
54+
langtags: langtags.filter(Boolean)
6855
};
6956
}
7057

src/utils/messageUtils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Message } from "@/types/conversation";
22

3-
export const isNonUserMessage = (role?: string) =>
3+
export const isNonUserMessage = (role?: string) =>
44
role === "assistant" || role === "system";
55

66
export const getMessageChainType = (
@@ -17,4 +17,4 @@ export const getMessageChainType = (
1717
if (isChainStart) return "start";
1818
if (isChainEnd) return "end";
1919
return "middle";
20-
};
20+
};

0 commit comments

Comments
 (0)