Skip to content

Commit

Permalink
fix: improve markdown parsing and add comprehensive tests
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
ErikBjare committed Jan 29, 2025
1 parent f2626cd commit abc5703
Show file tree
Hide file tree
Showing 7 changed files with 258 additions and 40 deletions.
2 changes: 1 addition & 1 deletion src/components/ConversationContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,4 @@ export const ConversationContent: FC<Props> = ({ conversation }) => {
/>
</main>
);
};
};
2 changes: 1 addition & 1 deletion src/components/MessageAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ export function MessageAvatar({ role, isError, isSuccess, chainType }: MessageAv
)}
</div>
);
}
}
8 changes: 4 additions & 4 deletions src/components/__tests__/ChatMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe("ChatMessage", () => {
content: "Hello!",
timestamp: new Date().toISOString(),
};

render(<ChatMessage message={message} />);
expect(screen.getByText("Hello!")).toBeInTheDocument();
});
Expand All @@ -21,7 +21,7 @@ describe("ChatMessage", () => {
content: "Hi there!",
timestamp: new Date().toISOString(),
};

render(<ChatMessage message={message} />);
expect(screen.getByText("Hi there!")).toBeInTheDocument();
});
Expand All @@ -32,9 +32,9 @@ describe("ChatMessage", () => {
content: "System message",
timestamp: new Date().toISOString(),
};

const { container } = render(<ChatMessage message={message} />);
const messageElement = container.querySelector('.font-mono');
expect(messageElement).toBeInTheDocument();
});
});
});
2 changes: 1 addition & 1 deletion src/democonversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export const demoConversations: DemoConversation[] = [
},
{
role: "assistant",
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",
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",
timestamp: now.toISOString(),
}
],
Expand Down
231 changes: 231 additions & 0 deletions src/utils/__tests__/markdownUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { processNestedCodeBlocks, transformThinkingTags, parseMarkdownContent } from "../markdownUtils";
import '@testing-library/jest-dom';

describe('processNestedCodeBlocks', () => {
it('should handle nested code blocks', () => {
const input = `\`\`\`markdown
Here's a nested block
\`\`\`python
print("hello")
\`\`\`
\`\`\``;

const expected = `\`\`\`markdown
Here's a nested block
\`\`\`python
print("hello")
\`\`\`
\`\`\``;

const result = processNestedCodeBlocks(input);
expect(result.processedContent).toBe(expected);
expect(result.langtags.filter(Boolean)).toEqual(['markdown', 'python']);
});

it('should not modify single code blocks', () => {
const input = `\`\`\`python
print("hello")
\`\`\``;

const result = processNestedCodeBlocks(input);
expect(result.processedContent).toBe(input);
expect(result.langtags.filter(Boolean)).toEqual(['python']);
});

it('should handle multiple nested blocks', () => {
const input = `\`\`\`markdown
First block
\`\`\`python
print("hello")
\`\`\`
Second block
\`\`\`javascript
console.log("world")
\`\`\`
\`\`\``;

const expected = `\`\`\`markdown
First block
\`\`\`python
print("hello")
\`\`\`
Second block
\`\`\`javascript
console.log("world")
\`\`\`
\`\`\``;

const result = processNestedCodeBlocks(input);
expect(result.processedContent).toBe(expected);
expect(result.langtags.filter(Boolean)).toEqual(['markdown', 'python', 'javascript']);
});

it('returns original content when no code blocks', () => {
const input = "Hello world";
const result = processNestedCodeBlocks(input);
expect(result.processedContent).toBe(input);
expect(result.langtags).toEqual([]);
});
});

describe('transformThinkingTags', () => {
it('should transform thinking tags to details/summary', () => {
const input = 'Before <thinking>Some thoughts</thinking> After';
const expected = 'Before <details><summary>💭 Thinking</summary>\n\nSome thoughts\n\n</details> After';
expect(transformThinkingTags(input)).toBe(expected);
});

it('should handle multiple thinking tags', () => {
const input = '<thinking>First thought</thinking> Middle <thinking>Second thought</thinking>';
const expected = '<details><summary>💭 Thinking</summary>\n\nFirst thought\n\n</details> Middle <details><summary>💭 Thinking</summary>\n\nSecond thought\n\n</details>';
expect(transformThinkingTags(input)).toBe(expected);
});

it('should not transform thinking tags within code blocks', () => {
const input = '`<thinking>Code block</thinking>`';
expect(transformThinkingTags(input)).toBe(input);
});

it('preserves content outside thinking tags', () => {
const input = 'Before <thinking>thinking</thinking> after';
const expected = 'Before <details><summary>💭 Thinking</summary>\n\nthinking\n\n</details> after';
expect(transformThinkingTags(input)).toBe(expected);
});
});

describe('parseMarkdownContent', () => {
it('parses basic markdown', () => {
const input = "# Hello\n\nThis is a test";
const result = parseMarkdownContent(input);
expect(result).toContain("<h1>Hello</h1>");
expect(result).toContain("<p>This is a test</p>");
});

it('handles code blocks with language tags', () => {
const input = "```python\nprint('hello')\n```";
const result = parseMarkdownContent(input);
expect(result).toContain("<summary>💻 python</summary>");
expect(result).toContain('<span class="hljs-built_in">print</span>');
expect(result).toContain('<span class="hljs-string">&#x27;hello&#x27;</span>');
});

it('detects file paths in code blocks', () => {
const input = "```src/test.py\nprint('hello')\n```";
const result = parseMarkdownContent(input);
expect(result).toContain("<summary>📄 src/test.py</summary>");
});

it('detects tool commands in code blocks', () => {
const input = "```shell\nls -la\n```";
const result = parseMarkdownContent(input);
expect(result).toContain("<summary>🛠️ shell</summary>");
});

it('detects output blocks', () => {
const input = "```stdout\nHello world\n```";
const result = parseMarkdownContent(input);
expect(result).toContain("<summary>📤 stdout</summary>");
});

it('detects write operations in code blocks', () => {
const input = "```save test.txt\nHello world\n```";
const result = parseMarkdownContent(input);
expect(result).toContain("<summary>📝 save test.txt</summary>");
});

it('handles thinking tags', () => {
const input = "<thinking>Some thought</thinking>";
const result = parseMarkdownContent(input);
expect(result).toContain("<summary>💭 Thinking</summary>");
expect(result).toContain("Some thought");
});

it('handles nested code blocks', () => {
const input = "```markdown\nHere's a nested block\n```python\nprint('hello')\n```\n```";
const result = parseMarkdownContent(input);
expect(result).toContain("<summary>💻 markdown</summary>");
expect(result).toContain('<span class="hljs-code">```python');
expect(result).toContain('print(&#x27;hello&#x27;)');
});

it('handles complex message with multiple content types', () => {
const input = `The gptme web UI offers several advantages over the CLI interface:
1. **Rich Message Display**:
- Syntax highlighted code blocks
- Collapsible sections for code and thinking
- Different styles for user/assistant/system messages
- Emoji indicators for different types of content:
- 📄 File paths
- 🛠️ Tool usage
- 📤 Command output
- 💻 Code blocks
2. **Interactive Features**:
- Real-time streaming of responses
- Easy navigation between conversations
- Ability to view and restore conversation history
3. **Integration with gptme-server**:
- Connects to your local gptme instance
- Access to all local tools and capabilities
- Secure local execution of commands
Here's an example showing different types of content:
\`\`\`/path/to/file.py
# This shows as a file path
\`\`\`
\`\`\`shell
# This shows as a tool
ls -la
\`\`\`
\`\`\`stdout
# This shows as command output
total 0
drwxr-xr-x 2 user user 4096 Jan 29 10:48 .
\`\`\`
<thinking>
Thinking blocks are collapsible and help show my reasoning process
</thinking>
You can try the web UI by:
1. Starting a local gptme-server: \`gptme-server --cors-origin='http://localhost:8080'\`
2. Running the web UI: \`npm run dev\`
3. Opening http://localhost:5173 in your browser`;

const result = parseMarkdownContent(input);

// Check markdown formatting is preserved
expect(result).toContain('<p>The gptme web UI offers several advantages over the CLI interface:</p>');
expect(result).toContain('<li><p><strong>Rich Message Display</strong>:</p>');

// Check list items are preserved
expect(result).toContain('<li>Syntax highlighted code blocks</li>');
expect(result).toContain('<li>📄 File paths</li>');

// Check code blocks with correct emoji indicators
expect(result).toContain('<summary>📄 /path/to/file.py</summary>');
expect(result).toContain('<summary>🛠️ shell</summary>');
expect(result).toContain('<summary>📤 stdout</summary>');

// Check code block content with syntax highlighting
expect(result).toContain('<span class="hljs-comment"># This shows as a file path</span>');
expect(result).toContain('<span class="language-bash">This shows as a tool</span>');
expect(result).toContain('# This shows as command output');
expect(result).toContain('drwxr-xr-x 2 user user');

// Check final content is included
expect(result).toContain('You can try the web UI by:');
expect(result).toContain('<code>gptme-server --cors-origin=');
expect(result).toContain('<code>npm run dev</code>');
expect(result).toContain('http://localhost:5173');

// Check thinking block
expect(result).toContain('<details><summary>💭 Thinking</summary>');
expect(result).toContain('Thinking blocks are collapsible');
});
});
49 changes: 18 additions & 31 deletions src/utils/markdownUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,51 +20,38 @@ marked.use(
);

export function processNestedCodeBlocks(content: string) {
// If no code blocks or only one code block, return as-is
if (content.split('```').length < 3) {
return { processedContent: content, langtags: [] };
const match = content.match(/```(\S*)/);
return {
processedContent: content,
langtags: match ? [match[1]] : []
};
}

const lines = content.split('\n');
const stack: string[] = [];
let result = '';
let currentBlock: string[] = [];
const langtags: string[] = [];
const result: string[] = [];

for (const line of lines) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const strippedLine = line.trim();

if (strippedLine.startsWith('```')) {
const lang = strippedLine.slice(3);
langtags.push(lang);
if (stack.length === 0) {
const remainingContent = lines.slice(lines.indexOf(line) + 1).join('\n');
if (remainingContent.includes('```') && remainingContent.split('```').length > 2) {
stack.push(lang);
result += '~~~' + lang + '\n';
} else {
result += line + '\n';
}
} else if (lang && stack[stack.length - 1] !== lang) {
currentBlock.push(line);
stack.push(lang);
} else {
if (stack.length === 1) {
result += currentBlock.join('\n') + '\n~~~\n';
currentBlock = [];
} else {
currentBlock.push(line);
}
stack.pop();
if (strippedLine !== '```') {
// Start of a code block with a language
const lang = strippedLine.slice(3);
langtags.push(lang);
}
} else if (stack.length > 0) {
currentBlock.push(line);
result.push(line);
} else {
result += line + '\n';
result.push(line);
}
}

return {
processedContent: result.trim(),
langtags
processedContent: result.join('\n'),
langtags: langtags.filter(Boolean)
};
}

Expand Down
4 changes: 2 additions & 2 deletions src/utils/messageUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Message } from "@/types/conversation";

export const isNonUserMessage = (role?: string) =>
export const isNonUserMessage = (role?: string) =>
role === "assistant" || role === "system";

export const getMessageChainType = (
Expand All @@ -17,4 +17,4 @@ export const getMessageChainType = (
if (isChainStart) return "start";
if (isChainEnd) return "end";
return "middle";
};
};

0 comments on commit abc5703

Please sign in to comment.