Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions openless-all/app/src/lib/qaMarkdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { renderQaMarkdown } from './qaMarkdown';

function assertIncludes(text: string, expected: string, name: string) {
if (!text.includes(expected)) {
throw new Error(`${name}: expected to include "${expected}", got "${text}"`);
}
}

function assertNotIncludes(text: string, expected: string, name: string) {
if (text.includes(expected)) {
throw new Error(`${name}: expected not to include "${expected}", got "${text}"`);
}
}

const htmlEscaped = renderQaMarkdown('<img src=x onerror=alert(1)><script>alert(1)</script>');
assertIncludes(htmlEscaped, '&lt;img src=x onerror=alert(1)&gt;', 'raw html should be escaped');
assertNotIncludes(htmlEscaped, '<script>', 'script tag should not be rendered');
assertNotIncludes(htmlEscaped, '<img src=x onerror=alert(1)>', 'raw html img should not become live dom tag');

const badHref = renderQaMarkdown('[xss](javascript:alert(1))');
assertNotIncludes(badHref, 'href="javascript:alert(1)"', 'javascript href should be dropped');

const goodMarkdown = renderQaMarkdown('**bold**\n\n- a\n- b\n\n`code`\n\n[ok](https://example.com)');
assertIncludes(goodMarkdown, '<strong>bold</strong>', 'bold markdown should render');
assertIncludes(goodMarkdown, '<li>a</li>', 'list markdown should render');
assertIncludes(goodMarkdown, '<code>code</code>', 'code markdown should render');
assertIncludes(goodMarkdown, 'href="https://example.com"', 'safe link should render');

const safeQueryLink = renderQaMarkdown('[ok](https://example.com?a=1&b=2)');
assertIncludes(safeQueryLink, 'href="https://example.com?a=1&amp;b=2"', 'safe query link should keep a single escaped ampersand');

const codeSnippet = renderQaMarkdown('`<div class=\"x\">`');
assertIncludes(codeSnippet, '&lt;div class=&quot;x&quot;&gt;', 'inline code should stay single-escaped');
assertNotIncludes(codeSnippet, '&amp;lt;', 'inline code should not be double-escaped');
65 changes: 65 additions & 0 deletions openless-all/app/src/lib/qaMarkdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { marked } from 'marked';

const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);

function escapeHtml(raw: string): string {
return raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

function decodeHtmlEntities(raw: string): string {
return raw
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}

function normalizeHref(href: string | null | undefined): string | null {
if (!href) return null;
const trimmed = href.trim();
if (!trimmed) return null;
if (trimmed.startsWith('#')) return trimmed;
try {
const url = new URL(trimmed, 'https://openless.local/');
if (!SAFE_LINK_PROTOCOLS.has(url.protocol)) return null;
return trimmed;
} catch {
return null;
}
}

const QA_MARKDOWN_RENDERER = new marked.Renderer();
QA_MARKDOWN_RENDERER.html = (html: string) => escapeHtml(html);
QA_MARKDOWN_RENDERER.link = (href: string | null, title: string | null, text: string) => {
const safeHref = normalizeHref(decodeHtmlEntities(href ?? ''));
if (!safeHref) return `<span>${text}</span>`;
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
return `<a href="${escapeHtml(safeHref)}"${titleAttr} target="_blank" rel="noreferrer noopener">${text}</a>`;
};
QA_MARKDOWN_RENDERER.image = (href: string | null, title: string | null, text: string) => {
const safeHref = normalizeHref(decodeHtmlEntities(href ?? ''));
if (!safeHref) return '';
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
const alt = escapeHtml(text || '');
return `<img src="${escapeHtml(safeHref)}" alt="${alt}"${titleAttr} loading="lazy" />`;
};

export function renderQaPlainText(raw: string): string {
return `<pre>${escapeHtml(raw)}</pre>`;
}

export function renderQaMarkdown(markdown: string): string {
// 保留 markdown 语义(尤其代码块),但把 raw HTML token 转义为纯文本,避免注入。
return marked.parse(markdown, {
async: false,
gfm: true,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve hard line breaks in QA markdown renderer

This refactor removed the previous marked.setOptions({ breaks: true }) behavior and the new marked.parse call does not set breaks, so single newlines in assistant output are no longer rendered as line breaks. In QA responses (especially streamed text and lightly formatted lists), this collapses content into denser paragraphs and is a user-visible regression from prior behavior in QaPanel.tsx.

Useful? React with 👍 / 👎.

breaks: true,
renderer: QA_MARKDOWN_RENDERER,
}) as string;
}
12 changes: 5 additions & 7 deletions openless-all/app/src/pages/QaPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,13 @@

import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { marked } from 'marked';
import { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc';
import type { QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types';
import { getHotkeyTriggerLabel } from '../lib/hotkey';
import { renderQaMarkdown, renderQaPlainText } from '../lib/qaMarkdown';

const SELECTION_PREVIEW_MAX = 60;

marked.setOptions({ gfm: true, breaks: true });

type Status = 'idle' | 'recording' | 'thinking' | 'error';

export function QaPanel() {
Expand Down Expand Up @@ -405,10 +403,10 @@ function MessageRow({ message }: { message: QaChatMessage }) {
const html = useMemo(() => {
if (message.role !== 'assistant') return '';
try {
return marked.parse(message.content, { async: false }) as string;
return renderQaMarkdown(message.content);
} catch (error) {
console.error('[qa] failed to render markdown', error);
return message.content;
return renderQaPlainText(String(message.content ?? ''));
}
}, [message.content, message.role]);

Expand Down Expand Up @@ -445,10 +443,10 @@ function MessageRow({ message }: { message: QaChatMessage }) {
function StreamingAssistantBubble({ markdown }: { markdown: string }) {
const html = useMemo(() => {
try {
return marked.parse(markdown, { async: false }) as string;
return renderQaMarkdown(markdown);
} catch (error) {
console.error('[qa] failed to render streaming markdown', error);
return markdown;
return renderQaPlainText(String(markdown ?? ''));
}
}, [markdown]);
return (
Expand Down
Loading