diff --git a/package-lock.json b/package-lock.json index 44b12ec..9d3821a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,10 @@ "@codemirror/view": "^6.35.0", "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", + "@types/dompurify": "^3.0.5", "codemirror": "^6.0.1", + "dompurify": "^3.3.1", + "marked": "^17.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", "zustand": "^4.5.0" @@ -1986,6 +1989,15 @@ "@types/ms": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2093,6 +2105,12 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -3356,6 +3374,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", @@ -4741,6 +4768,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", + "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", diff --git a/package.json b/package.json index acb26c4..390e604 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,10 @@ "@codemirror/view": "^6.35.0", "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", + "@types/dompurify": "^3.0.5", "codemirror": "^6.0.1", + "dompurify": "^3.3.1", + "marked": "^17.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", "zustand": "^4.5.0" diff --git a/src/renderer/components/AIChat/Chat.css b/src/renderer/components/AIChat/Chat.css index 3d72ac2..6f3d319 100644 --- a/src/renderer/components/AIChat/Chat.css +++ b/src/renderer/components/AIChat/Chat.css @@ -85,6 +85,88 @@ padding: 10px 12px; } +/* Assistant messages use rendered Markdown — no pre-wrap needed */ +.chat-message-assistant .chat-message-content { + white-space: normal; +} + +.chat-message-markdown { + word-break: break-word; +} + +.chat-message-markdown p { + margin: 0 0 14px; +} +.chat-message-markdown p:last-child { + margin-bottom: 0; +} + +.chat-message-markdown h1, +.chat-message-markdown h2, +.chat-message-markdown h3 { + color: var(--heading-color); + font-family: var(--font-serif); + font-weight: 600; + margin: 12px 0 4px; + line-height: 1.3; +} +.chat-message-markdown h1 { font-size: 15px; } +.chat-message-markdown h2 { font-size: 14px; } +.chat-message-markdown h3 { font-size: 13px; } + +.chat-message-markdown ul, +.chat-message-markdown ol { + margin: 4px 0 8px; + padding-left: 18px; +} +.chat-message-markdown li { + margin-bottom: 3px; +} + +.chat-message-markdown code { + font-family: monospace; + font-size: 12px; + background: rgba(255, 255, 255, 0.07); + border-radius: 3px; + padding: 1px 4px; +} + +.chat-message-markdown pre { + background: rgba(0, 0, 0, 0.3); + border-radius: 5px; + padding: 10px 12px; + overflow-x: auto; + margin: 6px 0; +} +.chat-message-markdown pre code { + background: none; + padding: 0; + font-size: 12px; +} + +.chat-message-markdown blockquote { + border-left: 3px solid var(--accent); + margin: 6px 0; + padding: 2px 10px; + color: var(--text-secondary); + font-style: italic; +} + +.chat-message-markdown strong { + color: var(--text-primary); + font-weight: 600; +} + +.chat-message-markdown em { + color: var(--text-secondary); +} + +.chat-message-markdown hr { + border: none; + border-top: 1px solid var(--border); + margin: 10px 0; +} + .chat-message-user .chat-message-content { background: var(--user-message-bg); } diff --git a/src/renderer/components/AIChat/ChatMessageItem.tsx b/src/renderer/components/AIChat/ChatMessageItem.tsx index 24c83f7..f535915 100644 --- a/src/renderer/components/AIChat/ChatMessageItem.tsx +++ b/src/renderer/components/AIChat/ChatMessageItem.tsx @@ -1,17 +1,35 @@ +import { useMemo } from 'react' +import { marked } from 'marked' +import DOMPurify from 'dompurify' import type { ChatMessage } from '../../types/editor' +marked.setOptions({ breaks: true }) + interface Props { message: ChatMessage } export function ChatMessageItem({ message }: Props): JSX.Element { + const html = useMemo(() => { + if (message.role !== 'assistant' || !message.content) return null + const raw = marked.parse(message.content) as string + return DOMPurify.sanitize(raw) + }, [message.role, message.content]) + return (