Skip to content

Commit 367c7cc

Browse files
authored
Merge pull request #1 from Shishir435/ai-response-parser
Enhanced markdown parsing with `markdown-it` and key plugins (`highlight.js`, task lists, footnotes, emoji, containers). Sanitized output using `DOMPurify` for security. Also improved chat UI: added `border-radius` to input box and `cursor-pointer` to the model menu trigger for better UX.
2 parents 7a3250e + a05fa1e commit 367c7cc

File tree

10 files changed

+460
-118
lines changed

10 files changed

+460
-118
lines changed

components/chat.tsx

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import { Textarea } from "@/components/ui/textarea"
44
import { useAutoResizeTextarea } from "@/hooks/useAutoResizeTextarea"
55
import { MESSAGE_KEYS, STORAGE_KEYS } from "@/lib/constant"
66
import { cn } from "@/lib/utils"
7-
import { Send } from "lucide-react"
7+
import { Circle, Send } from "lucide-react"
88
import { useEffect, useRef, useState } from "react"
99

1010
import { useStorage } from "@plasmohq/storage/hook"
1111

1212
import BugReportIcon from "./bug-report-icon"
13+
import { MarkdownRenderer } from "./markdown-renderer"
1314
import ModelMenu from "./model-menu"
1415
import SettingsButton from "./settings-button"
1516
import WelcomeScreen from "./welcome-screen"
@@ -78,47 +79,48 @@ function Chat() {
7879
}
7980

8081
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
81-
if (e.key === "Enter" && !e.shiftKey) {
82+
if (e.key === "Enter" && !e.shiftKey && !isLoading) {
8283
e.preventDefault()
8384
sendMessage()
8485
}
8586
}
8687

8788
return (
88-
<div className="flex h-screen flex-col p-4">
89-
<ScrollArea className="flex-1 space-y-4 overflow-auto pr-2">
90-
{messages.length === 0 ? (
91-
<WelcomeScreen />
92-
) : (
93-
<>
94-
{messages.map((msg, idx) => (
95-
<div
96-
key={idx}
97-
className={cn(
98-
"my-1 rounded-md p-3 text-sm",
99-
msg.role === "user"
100-
? "ml-auto max-w-[80%] self-end bg-blue-100 text-blue-900"
101-
: "mr-auto max-w-[80%] self-start bg-gray-200 text-gray-900"
102-
)}>
103-
{msg.content}
104-
</div>
105-
))}
106-
</>
107-
)}
108-
109-
<div ref={scrollRef} />
110-
</ScrollArea>
89+
<div className="flex h-screen flex-col rounded-md p-1">
90+
{messages.length === 0 ? (
91+
<WelcomeScreen />
92+
) : (
93+
<ScrollArea className="scrollbar-none flex-1">
94+
{messages.map((msg, idx) => (
95+
<div
96+
key={idx}
97+
className={cn(
98+
"my-1 rounded-md p-3 text-sm",
99+
msg.role === "user"
100+
? "ml-auto max-w-[80%] self-end bg-blue-100 text-blue-900"
101+
: "mr-auto max-w-[80%] self-start bg-gray-200 text-gray-900"
102+
)}>
103+
{msg.role === "assistant" ? (
104+
<MarkdownRenderer content={msg.content} />
105+
) : (
106+
msg.content
107+
)}
108+
</div>
109+
))}
110+
<div ref={scrollRef} />
111+
</ScrollArea>
112+
)}
111113

112114
<div className="sticky bottom-0 z-10 w-full">
113-
<div className="relative mt-4 h-auto">
115+
<div className="relative h-auto">
114116
<Textarea
115117
id="chat-input-textarea"
116118
ref={textareaRef}
117119
placeholder="Type your message..."
118120
value={input}
119121
onChange={(e) => setInput(e.target.value)}
120122
onKeyDown={handleKeyDown}
121-
className="max-h-[300px] min-h-[80px] w-full resize-none overflow-hidden pb-10"
123+
className="max-h-[300px] min-h-[80px] w-full resize-none overflow-hidden rounded-b-2xl pb-10"
122124
autoFocus
123125
/>
124126
<div className="absolute bottom-0 left-2 flex items-center gap-2">
@@ -130,7 +132,7 @@ function Chat() {
130132
onClick={sendMessage}
131133
disabled={isLoading}
132134
className="absolute right-0 top-1/2 mr-2 -translate-y-1/2">
133-
{isLoading ? "..." : <Send size="16" />}
135+
{isLoading ? <Circle size="16" /> : <Send size="16" />}
134136
</Button>
135137
</div>
136138
</div>

components/markdown-renderer.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { useMarkdownParser } from "@/hooks/useMarkdownParser"
2+
3+
export function MarkdownRenderer({ content }: { content: string }) {
4+
const html = useMarkdownParser(content)
5+
6+
return (
7+
<div
8+
className="prose prose-sm max-w-none break-words px-2 py-1 [&_code]:whitespace-pre-wrap [&_code]:text-black [&_p]:my-1 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-gray-100 [&_pre]:p-2 [&_pre]:text-xs [&_pre]:text-black [&_table]:block [&_table]:overflow-x-auto"
9+
dangerouslySetInnerHTML={{ __html: html }}
10+
/>
11+
)
12+
}

components/model-menu.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ function ModelMenu() {
8383
return (
8484
<Popover open={open} onOpenChange={setOpen}>
8585
<PopoverTrigger asChild>
86-
<div role="combobox" aria-expanded={open} className="justify-between">
86+
<div
87+
role="combobox"
88+
aria-expanded={open}
89+
className="cursor-pointer justify-between">
8790
<div className="flex items-center gap-2 capitalize">
8891
{selectedModel
8992
? models.find((m) => m.name === selectedModel)?.name

components/welcome-screen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export default function WelcomeScreen() {
1111
if (!show) return null
1212

1313
return (
14-
<div className="flex h-screen w-full flex-col items-center justify-center px-4 text-center">
14+
<div className="scrollbar-none flex h-screen w-full flex-col items-center justify-center rounded-full px-4 text-center">
1515
<h1 className="mb-2 text-2xl font-semibold">Welcome to Ollama Chat</h1>
1616
<p className="mb-6 max-w-md text-sm text-muted-foreground">
1717
Start chatting with your local models using Ollama. Type a message below

globals.css

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,62 @@
44

55
@layer base {
66
:root {
7+
--toolbar-background: 244, 100%, 97%;
8+
--toolbar-foreground: 240, 3%, 32%;
79
--background: 0 0% 100%;
8-
--foreground: 222.2 47.4% 11.2%;
9-
--muted: 210 40% 96.1%;
10-
--muted-foreground: 215.4 16.3% 46.9%;
11-
--popover: 0 0% 100%;
12-
--popover-foreground: 222.2 47.4% 11.2%;
13-
--border: 214.3 31.8% 91.4%;
14-
--input: 214.3 31.8% 91.4%;
10+
--foreground: 0 0% 3.9%;
1511
--card: 0 0% 100%;
16-
--card-foreground: 222.2 47.4% 11.2%;
17-
--primary: 222.2 47.4% 11.2%;
18-
--primary-foreground: 210 40% 98%;
19-
--secondary: 210 40% 96.1%;
20-
--secondary-foreground: 222.2 47.4% 11.2%;
21-
--accent: 210 40% 96.1%;
22-
--accent-foreground: 222.2 47.4% 11.2%;
23-
--destructive: 0 100% 50%;
24-
--destructive-foreground: 210 40% 98%;
25-
--ring: 215 20.2% 65.1%;
12+
--card-foreground: 0 0% 3.9%;
13+
--popover: 0 0% 100%;
14+
--popover-foreground: 0 0% 3.9%;
15+
--primary: 0 0% 9%;
16+
--primary-foreground: 0 0% 98%;
17+
--secondary: 0 0% 96.1%;
18+
--secondary-foreground: 0 0% 9%;
19+
--muted: 0 0% 96.1%;
20+
--muted-foreground: 0 0% 45.1%;
21+
--accent: 0 0% 96.1%;
22+
--accent-foreground: 0 0% 9%;
23+
--destructive: 0 84.2% 60.2%;
24+
--destructive-foreground: 0 0% 98%;
25+
--border: 0 0% 89.8%;
26+
--input: 0 0% 89.8%;
27+
--ring: 0 0% 3.9%;
2628
--radius: 0.5rem;
29+
--chart-1: 12 76% 61%;
30+
--chart-2: 173 58% 39%;
31+
--chart-3: 197 37% 24%;
32+
--chart-4: 43 74% 66%;
33+
--chart-5: 27 87% 67%;
34+
}
35+
36+
.dark {
37+
--toolbar-background: 240, 8%, 15%;
38+
--toolbar-foreground: 240, 3%, 71%;
39+
--background: 0 0% 3.9%;
40+
--foreground: 0 0% 98%;
41+
--card: 0 0% 3.9%;
42+
--card-foreground: 0 0% 98%;
43+
--popover: 0 0% 3.9%;
44+
--popover-foreground: 0 0% 98%;
45+
--primary: 0 0% 98%;
46+
--primary-foreground: 0 0% 9%;
47+
--secondary: 0 0% 14.9%;
48+
--secondary-foreground: 0 0% 98%;
49+
--muted: 0 0% 14.9%;
50+
--muted-foreground: 0 0% 63.9%;
51+
--accent: 0 0% 14.9%;
52+
--accent-foreground: 0 0% 98%;
53+
--destructive: 0 62.8% 30.6%;
54+
--destructive-foreground: 0 0% 98%;
55+
--border: 0 0% 14.9%;
56+
--input: 0 0% 14.9%;
57+
--ring: 0 0% 83.1%;
58+
--chart-1: 220 70% 50%;
59+
--chart-2: 160 60% 45%;
60+
--chart-3: 30 80% 55%;
61+
--chart-4: 280 65% 60%;
62+
--chart-5: 340 75% 55%;
2763
}
2864
}
2965

@@ -40,3 +76,11 @@
4076
all: initial;
4177
box-sizing: border-box;
4278
}
79+
80+
.scrollbar-none::-webkit-scrollbar {
81+
display: none;
82+
}
83+
.scrollbar-none {
84+
scrollbar-width: none; /* Firefox */
85+
-ms-overflow-style: none; /* IE and Edge */
86+
}

hooks/useMarkdownParser.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import DOMPurify from "dompurify"
2+
import hljs from "highlight.js"
3+
import MarkdownIt from "markdown-it"
4+
import container from "markdown-it-container"
5+
import { full as emoji } from "markdown-it-emoji"
6+
import footnote from "markdown-it-footnote"
7+
import highlightjs from "markdown-it-highlightjs"
8+
import taskLists from "markdown-it-task-lists"
9+
import { useEffect, useState } from "react"
10+
11+
import "highlight.js/styles/github.css"
12+
13+
const md = new MarkdownIt({
14+
html: true,
15+
linkify: true,
16+
typographer: true,
17+
highlight: function (str, lang) {
18+
if (lang && hljs.getLanguage(lang)) {
19+
try {
20+
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`
21+
} catch (__) {}
22+
}
23+
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`
24+
}
25+
})
26+
27+
md.use(highlightjs, { hljs })
28+
.use(taskLists, { enabled: true })
29+
.use(footnote)
30+
.use(container, "info")
31+
.use(container, "warning")
32+
.use(emoji)
33+
34+
export function useMarkdownParser(markdown: string) {
35+
const [html, setHtml] = useState("")
36+
37+
useEffect(() => {
38+
const rawHtml = md.render(markdown)
39+
const safeHtml = DOMPurify.sanitize(rawHtml)
40+
setHtml(safeHtml)
41+
}, [markdown])
42+
43+
return html
44+
}

package.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "ollama-client",
33
"displayName": "Ollama client",
4-
"version": "0.0.1",
4+
"version": "0.0.2",
55
"description": "Chat with your local Ollama LLMs directly in your browser. Lightweight, fast, and privacy-focused.",
66
"author": "shishir.chaurasiya",
77
"scripts": {
@@ -22,16 +22,30 @@
2222
"class-variance-authority": "0.7.1",
2323
"clsx": "2.1.1",
2424
"cmdk": "^1.1.1",
25+
"dompurify": "^3.2.6",
26+
"highlight.js": "^11.11.1",
2527
"lucide-react": "0.474.0",
28+
"markdown-it": "^14.1.0",
29+
"markdown-it-container": "^4.0.0",
30+
"markdown-it-emoji": "^3.0.0",
31+
"markdown-it-footnote": "^4.0.0",
32+
"markdown-it-highlightjs": "^4.2.0",
33+
"markdown-it-task-lists": "^2.1.1",
2634
"plasmo": "0.90.5",
2735
"react": "18.2.0",
2836
"react-dom": "18.2.0",
2937
"tailwind-merge": "3.0.1",
38+
"tailwind-scrollbar": "^4.0.2",
3039
"tailwindcss-animate": "1.0.7"
3140
},
3241
"devDependencies": {
3342
"@ianvs/prettier-plugin-sort-imports": "4.1.1",
43+
"@tailwindcss/typography": "^0.5.16",
3444
"@types/chrome": "0.0.258",
45+
"@types/markdown-it": "^14.1.2",
46+
"@types/markdown-it-container": "^2.0.10",
47+
"@types/markdown-it-emoji": "^3.0.1",
48+
"@types/markdown-it-footnote": "^3.0.4",
3549
"@types/node": "20.11.5",
3650
"@types/react": "18.2.48",
3751
"@types/react-dom": "18.2.18",

0 commit comments

Comments
 (0)