新增平台 AI 助手抽屉与地图编辑助手对接。

统一流式问答入口,并用轻量 markdown 渲染助手回复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-07-26 11:32:29 +08:00
co-authored by Cursor
parent ef63dda228
commit b1dd4f9005
5 changed files with 1300 additions and 3 deletions
@@ -0,0 +1,136 @@
/**
* 极简、零依赖、先转义后渲染的 Markdown→HTML。仅覆盖聊天场景常用语法:
* 标题 / 粗斜体 / 行内代码 / 围栏代码块 / 有序无序列表 / 引用 / 链接 / 段落与换行。
* 安全策略:所有文本先 HTML 转义,仅注入我们自己生成的标签;链接仅允许 http(s)/相对/锚点。
* 如需完整 CommonMark,可后续替换为 markdown-it(届时记得保留 XSS 防护)。
*/
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function safeUrl(url: string): string | null {
const u = url.trim()
if (/^https?:\/\//i.test(u) || u.startsWith('/') || u.startsWith('#')) return u
return null
}
/** 行内格式:行内代码先占位保护,再处理链接/粗体/斜体,最后还原代码。入参须已 HTML 转义。 */
function inline(escaped: string): string {
const codes: string[] = []
let s = escaped.replace(/`([^`]+)`/g, (_m, c: string) => {
codes.push(`<code class="mmd-code">${c}</code>`)
return `\u0001${codes.length - 1}\u0001`
})
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text: string, url: string) => {
const safe = safeUrl(url)
if (!safe) return `[${text}](${url})`
return `<a class="mmd-link" href="${safe}" target="_blank" rel="noopener noreferrer">${text}</a>`
})
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>')
s = s.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>')
s = s.replace(/(^|[^_])_([^_\s][^_]*)_/g, '$1<em>$2</em>')
s = s.replace(/\u0001(\d+)\u0001/g, (_m, i: string) => codes[Number(i)] ?? '')
return s
}
export function renderMarkdown(src: string): string {
if (!src) return ''
const text = src.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const blocks: string[] = []
const withTokens = text.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_m, lang: string, code: string) => {
const cls = (lang || '').trim()
const body = escapeHtml(code.replace(/\n$/, ''))
const clsAttr = cls ? ` class="language-${escapeHtml(cls)}"` : ''
blocks.push(`<pre class="mmd-pre"><code${clsAttr}>${body}</code></pre>`)
return `\u0000${blocks.length - 1}\u0000`
})
const lines = withTokens.split('\n')
const out: string[] = []
let para: string[] = []
let i = 0
const flushPara = (): void => {
if (para.length) {
out.push(`<p>${inline(escapeHtml(para.join(' ')))}</p>`)
para = []
}
}
while (i < lines.length) {
const line = lines[i]
const codeToken = line.match(/^\u0000(\d+)\u0000\s*$/)
if (codeToken) {
flushPara()
out.push(blocks[Number(codeToken[1])] ?? '')
i++
continue
}
if (/^\s*$/.test(line)) {
flushPara()
i++
continue
}
const h = line.match(/^(#{1,6})\s+(.*)$/)
if (h) {
flushPara()
const lvl = h[1].length
const tag = lvl <= 2 ? 'h3' : lvl === 3 ? 'h4' : 'h5'
out.push(`<${tag} class="mmd-h">${inline(escapeHtml(h[2]))}</${tag}>`)
i++
continue
}
if (/^\s*>\s?/.test(line)) {
flushPara()
const items: string[] = []
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
items.push(lines[i].replace(/^\s*>\s?/, ''))
i++
}
out.push(`<blockquote class="mmd-quote">${inline(escapeHtml(items.join(' ')))}</blockquote>`)
continue
}
if (/^\s*[-*+]\s+/.test(line)) {
flushPara()
const items: string[] = []
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*[-*+]\s+/, ''))
i++
}
out.push(`<ul class="mmd-ul">${items.map((it) => `<li>${inline(escapeHtml(it))}</li>`).join('')}</ul>`)
continue
}
if (/^\s*\d+\.\s+/.test(line)) {
flushPara()
const items: string[] = []
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+\.\s+/, ''))
i++
}
out.push(`<ol class="mmd-ol">${items.map((it) => `<li>${inline(escapeHtml(it))}</li>`).join('')}</ol>`)
continue
}
para.push(line)
i++
}
flushPara()
return out.join('\n')
}