aegon-lv97 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gen-ai/build-report/chartStreamRefs.ts +227 -0
- package/gen-ai/build-report/coherence-check/index.vue +559 -0
- package/gen-ai/build-report/components/ReportChapterWorkspace.vue +4571 -0
- package/gen-ai/build-report/components/ReportConfigPanel.vue +727 -0
- package/gen-ai/build-report/components/ReportGenerateSettingsPanel.vue +2361 -0
- package/gen-ai/build-report/components/ReportGeneratingPanel.vue +1765 -0
- package/gen-ai/build-report/components/ReportPromptPanel.vue +625 -0
- package/gen-ai/build-report/components/ReportSourceFileSummary.vue +665 -0
- package/gen-ai/build-report/components/ReportSourcesPanel.vue +1327 -0
- package/gen-ai/build-report/components/ReportTemplateDetail.vue +191 -0
- package/gen-ai/build-report/components/ReportTemplateEditorFormPanel.vue +667 -0
- package/gen-ai/build-report/components/ReportTemplatePicker.vue +302 -0
- package/gen-ai/build-report/components/ReportWorkflowSidebar.vue +148 -0
- package/gen-ai/build-report/components/paramsSetting.vue +886 -0
- package/gen-ai/build-report/components/reportEdit.vue +2283 -0
- package/gen-ai/build-report/index.vue +856 -0
- package/gen-ai/build-report/report-domain.ts +1133 -0
- package/gen-ai/build-report/report-template-data.ts +295 -0
- package/gen-ai/build-report/useReportWorkflow.ts +1386 -0
- package/gen-ai/common/assets/NotoSans-SC.woff2 +0 -0
- package/gen-ai/common/assets/NotoSans-TC.woff2 +0 -0
- package/gen-ai/common/assets/NotoSans.woff2 +0 -0
- package/gen-ai/common/assets/fonts.css +278 -0
- package/gen-ai/common/assets/pagination.css +163 -0
- package/gen-ai/common/directives/btnLoading.ts +455 -0
- package/gen-ai/common/directives/debounce.ts +98 -0
- package/gen-ai/common/directives/index.ts +30 -0
- package/gen-ai/common/en.md +417 -0
- package/gen-ai/common/index.ts +36 -0
- package/gen-ai/common/types/component-expose.ts +9 -0
- package/gen-ai/common/utils/api-res-status.ts +7 -0
- package/gen-ai/common/utils/navigation.ts +59 -0
- package/gen-ai/common/utils/remote-request.ts +45 -0
- package/gen-ai/common/utils/scroll.ts +577 -0
- package/gen-ai/components/AIExport/docx-export.ts +950 -0
- package/gen-ai/components/AIExport/docxExport.ts +2566 -0
- package/gen-ai/components/AIExport/index.ts +52 -0
- package/gen-ai/components/AIExport/parseMarkdownHtml.ts +653 -0
- package/gen-ai/components/CapsuleScrollbar.vue +149 -0
- package/gen-ai/components/ChapterTitleScroll.vue +468 -0
- package/gen-ai/components/EntryHeroNavCards.vue +379 -0
- package/gen-ai/components/GenAiPagination.vue +52 -0
- package/gen-ai/components/ModelCard.vue +1114 -0
- package/gen-ai/components/Preview/FilePreviewPage.vue +949 -0
- package/gen-ai/components/Preview/PreviewCapsuleScroll.vue +205 -0
- package/gen-ai/components/Preview/PreviewOutlineTree.vue +148 -0
- package/gen-ai/components/Preview/index.ts +11 -0
- package/gen-ai/components/Preview/preview.ts +724 -0
- package/gen-ai/components/PromptField.vue +505 -0
- package/gen-ai/components/RecentReportsTable.vue +974 -0
- package/gen-ai/components/ReportCollapsibleSection.vue +172 -0
- package/gen-ai/components/ReportHistoryList.vue +637 -0
- package/gen-ai/components/ReportSectionConfirmPopover.vue +251 -0
- package/gen-ai/components/editor/ChapterTiptapEditor.vue +3718 -0
- package/gen-ai/components/editor/StructureBlockView.vue +441 -0
- package/gen-ai/components/editor/TiptapToolbar.vue +1036 -0
- package/gen-ai/components/editor/TypewriterDocEditor.vue +422 -0
- package/gen-ai/components/editor/_StructureOrgNode.vue +326 -0
- package/gen-ai/components/editor/index.ts +28 -0
- package/gen-ai/components/editor/parseMarkdownHtml.ts +754 -0
- package/gen-ai/components/editor/reviewCitationTip.ts +163 -0
- package/gen-ai/gen-entry/api/index.ts +32 -0
- package/gen-ai/gen-entry/index.vue +287 -0
- package/gen-ai/gen-entry/types/index.ts +4 -0
- package/gen-ai/i.mark +2 -0
- package/gen-ai/management-center/index.vue +1815 -0
- package/gen-ai/management-center/mockData.ts +248 -0
- package/gen-ai/management-center/types/index.ts +37 -0
- package/gen-ai/model-manager/TemplateEditorFormPanel.vue +661 -0
- package/gen-ai/model-manager/api/index.ts +32 -0
- package/gen-ai/model-manager/api/mockApi.ts +33 -0
- package/gen-ai/model-manager/index.vue +1583 -0
- package/gen-ai/model-manager/mockData.ts +177 -0
- package/gen-ai/model-manager/template-detail-shared.ts +154 -0
- package/gen-ai/model-manager/template-editor.vue +345 -0
- package/gen-ai/model-manager/types/index.ts +40 -0
- package/gen-ai/model-manager/utils.ts +25 -0
- package/package.json +12 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 接口 sectionContent → HTML
|
|
3
|
+
*
|
|
4
|
+
* //0918 tip 兼容顺序:Markdown → HTML → Element Plus
|
|
5
|
+
* +link 继续走原解析:Markdown [text](url) / HTML <a> / el-link → a.report-tiptap-link
|
|
6
|
+
* tip 转成原 citation 标记(点击弹出 report-chapter-review-popover)
|
|
7
|
+
* [ref:uuid] → chartAsStream REF(chunkId)匹配后的 tip
|
|
8
|
+
*/
|
|
9
|
+
import { Marked } from 'marked'
|
|
10
|
+
//0918 tip / REF:叠在旧 citation 管线;共享 resolve 见 reviewCitationTip.ts
|
|
11
|
+
import { REF_MARKER_RE, REF_ANY_MARKER_RE, resolveRefMarker } from './reviewCitationTip'
|
|
12
|
+
//0918
|
|
13
|
+
|
|
14
|
+
function escapeHtml(value: string) {
|
|
15
|
+
return value
|
|
16
|
+
.replace(/&/g, '&')
|
|
17
|
+
.replace(/</g, '<')
|
|
18
|
+
.replace(/>/g, '>')
|
|
19
|
+
.replace(/"/g, '"')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function escapeAttr(value: string) {
|
|
23
|
+
return escapeHtml(String(value).replace(/\s+/g, ' ').trim())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function splitTipLink(tip: string) {
|
|
27
|
+
const raw = String(tip || '').trim()
|
|
28
|
+
const md = raw.match(/\[([^\]]+)\]\((https?:[^)\s]+)(?:\s+"[^"]*")?\)/i)
|
|
29
|
+
if (md?.[1] != null && md[2] != null) {
|
|
30
|
+
return {
|
|
31
|
+
text: raw.replace(md[0], '').replace(/\s+/g, ' ').trim(),
|
|
32
|
+
href: md[2].trim(),
|
|
33
|
+
linkLabel: md[1].trim(),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const html = raw.match(/<a\b[^>]*href=["'](https?:[^"']+)["'][^>]*>([\s\S]*?)<\/a>/i)
|
|
37
|
+
if (html?.[1] != null && html[2] != null) {
|
|
38
|
+
return {
|
|
39
|
+
text: raw.replace(html[0], '').replace(/\s+/g, ' ').trim(),
|
|
40
|
+
href: html[1].trim(),
|
|
41
|
+
linkLabel: html[2].replace(/<[^>]+>/g, '').trim(),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const paren = raw.match(/^(.*?)\s*\((https?:[^)\s]+)\)\s*$/i)
|
|
45
|
+
if (paren?.[1] != null && paren[2] != null) {
|
|
46
|
+
return {
|
|
47
|
+
text: paren[1].replace(/\s+/g, ' ').trim(),
|
|
48
|
+
href: paren[2].trim(),
|
|
49
|
+
linkLabel: paren[2].trim(),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { text: raw, href: '', linkLabel: '' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
//0918 tip citation + REF 直鏈屬性
|
|
56
|
+
function citationHtml(
|
|
57
|
+
id: string,
|
|
58
|
+
tip = '',
|
|
59
|
+
extra?: {
|
|
60
|
+
sourceName?: string
|
|
61
|
+
filePath?: string
|
|
62
|
+
filePreviewUrl?: string
|
|
63
|
+
chunkId?: string
|
|
64
|
+
}
|
|
65
|
+
) {
|
|
66
|
+
const n = String(id).trim() || '1'
|
|
67
|
+
const { text, href, linkLabel } = splitTipLink(tip)
|
|
68
|
+
const tipAttr = text ? ` data-tip="${escapeAttr(text)}"` : ''
|
|
69
|
+
const hrefAttr = href ? ` data-href="${escapeAttr(href)}"` : ''
|
|
70
|
+
const labelAttr = linkLabel ? ` data-link-label="${escapeAttr(linkLabel)}"` : ''
|
|
71
|
+
const sourceNameAttr = extra?.sourceName
|
|
72
|
+
? ` data-source-name="${escapeAttr(extra.sourceName)}"`
|
|
73
|
+
: ''
|
|
74
|
+
const filePathAttr = extra?.filePath ? ` data-file-path="${escapeAttr(extra.filePath)}"` : ''
|
|
75
|
+
const filePreviewAttr = extra?.filePreviewUrl
|
|
76
|
+
? ` data-file-preview-url="${escapeAttr(extra.filePreviewUrl)}"`
|
|
77
|
+
: ''
|
|
78
|
+
const chunkIdAttr = extra?.chunkId ? ` data-chunk-id="${escapeAttr(extra.chunkId)}"` : ''
|
|
79
|
+
return `<span class="report-tiptap-citation" data-citation="${escapeHtml(n)}"${tipAttr}${hrefAttr}${labelAttr}${sourceNameAttr}${filePathAttr}${filePreviewAttr}${chunkIdAttr}>${escapeHtml(n)}</span>`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// REF_MARKER_RE / resolveRefMarker → ./reviewCitationTip(0918 归类)
|
|
83
|
+
|
|
84
|
+
function isRealHref(href: string) {
|
|
85
|
+
const value = String(href || '').trim()
|
|
86
|
+
if (!value || value === '#') return false
|
|
87
|
+
if (/^#(?:fn|footnote|cite|ref)/i.test(value)) return false
|
|
88
|
+
return /^(https?:|mailto:|tel:|\/|\.\/|\.\.\/|#\/)/i.test(value) || /^[a-z][a-z0-9+.-]*:/i.test(value)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isCitationLabel(text: string) {
|
|
92
|
+
const label = text.replace(/<[^>]+>/g, '').trim()
|
|
93
|
+
return /^\[?\d+\]?$/.test(label) || /^[+*]?\d+$/.test(label)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function idFromLabel(label: string, fallback: string) {
|
|
97
|
+
const m = String(label).replace(/<[^>]+>/g, '').trim().match(/(\d+)/)
|
|
98
|
+
return m?.[1] ?? fallback
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let citationSeq = 0
|
|
102
|
+
function nextId(label: string) {
|
|
103
|
+
const fromLabel = idFromLabel(label, '')
|
|
104
|
+
if (fromLabel) return fromLabel
|
|
105
|
+
citationSeq += 1
|
|
106
|
+
return String(citationSeq)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function extractAttr(tag: string, name: string) {
|
|
110
|
+
const m = tag.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'))
|
|
111
|
+
return (m?.[1] ?? m?.[2] ?? m?.[3] ?? '').trim()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const PLACEHOLDER = '%%GENAI_BLOCK_'
|
|
115
|
+
|
|
116
|
+
function protectBlocks(source: string) {
|
|
117
|
+
const slots: string[] = []
|
|
118
|
+
const stash = (html: string) => {
|
|
119
|
+
const i = slots.length
|
|
120
|
+
slots.push(html)
|
|
121
|
+
return `${PLACEHOLDER}${i}%%`
|
|
122
|
+
}
|
|
123
|
+
let text = source.replace(
|
|
124
|
+
/<span\b[^>]*class="[^"]*report-tiptap-citation[^"]*"[^>]*>[\s\S]*?<\/span>/gi,
|
|
125
|
+
(m) => stash(m)
|
|
126
|
+
)
|
|
127
|
+
text = text.replace(/<(table|pre|blockquote|details)[\s\S]*?<\/\1>/gi, (m) => stash(m))
|
|
128
|
+
text = text.replace(/<(el-(?:tooltip|popover|link))\b[\s\S]*?<\/\1>/gi, (m) => stash(m))
|
|
129
|
+
return { text, slots }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function restoreBlocks(html: string, slots: string[]) {
|
|
133
|
+
return html.replace(new RegExp(`${PLACEHOLDER}(\\d+)%%`, 'g'), (_, i) => slots[Number(i)] ?? '')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Markdown 层 tip:脚注定义、[^n]、[+n]、[ref:uuid];[+link] / [text](url) 不在此当 tip */
|
|
137
|
+
//0918:先 REF(未命中删除),再旧 [+n]/[^n],不破坏开发前逻辑
|
|
138
|
+
function applyMarkdownTips(source: string) {
|
|
139
|
+
const footnotes = new Map<string, string>()
|
|
140
|
+
let text = source.replace(/^\[\^(\d+)\]:\s*(.+)$/gm, (_, id: string, tip: string) => {
|
|
141
|
+
footnotes.set(id, tip.trim())
|
|
142
|
+
return ''
|
|
143
|
+
})
|
|
144
|
+
text = text.replace(REF_MARKER_RE, (_full, chunkId: string) => {
|
|
145
|
+
const resolved = resolveRefMarker(chunkId)
|
|
146
|
+
// 未命中:删除占位,只显示已命中 tip;REF 入库后重解析
|
|
147
|
+
if (!resolved) return ''
|
|
148
|
+
return citationHtml(resolved.id, resolved.tip, resolved.extra)
|
|
149
|
+
})
|
|
150
|
+
//0918-1:匹配不到 SSERefList 的 [ref:…](含空格等)一律过滤为空,界面不显示
|
|
151
|
+
text = text.replace(REF_ANY_MARKER_RE, '')
|
|
152
|
+
REF_MARKER_RE.lastIndex = 0
|
|
153
|
+
REF_ANY_MARKER_RE.lastIndex = 0
|
|
154
|
+
//0918-1
|
|
155
|
+
// —— 以下为开发前旧 tip,保持不变 ——
|
|
156
|
+
text = text.replace(/\[\+(\d+)\]/g, (_, id: string) => citationHtml(id))
|
|
157
|
+
text = text.replace(/\[\^(\d+)\](?!:)/g, (_, id: string) =>
|
|
158
|
+
citationHtml(id, footnotes.get(id) || '')
|
|
159
|
+
)
|
|
160
|
+
return { text, footnotes }
|
|
161
|
+
}
|
|
162
|
+
//0918
|
|
163
|
+
|
|
164
|
+
function linkHtml(href: string, inner: string, title = '') {
|
|
165
|
+
const titleAttr = title ? ` title="${escapeAttr(title)}"` : ''
|
|
166
|
+
return `<a href="${escapeHtml(href)}" class="report-tiptap-link" target="_blank" rel="noopener noreferrer"${titleAttr}>${inner}</a>`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isTipHref(href: string, title: string, _label?: string) {
|
|
170
|
+
if (isRealHref(href)) return false
|
|
171
|
+
if (title) return true
|
|
172
|
+
return /^#(?:fn|footnote|cite|ref)/i.test(href)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const markedParser = new Marked({
|
|
176
|
+
gfm: true,
|
|
177
|
+
breaks: true,
|
|
178
|
+
renderer: {
|
|
179
|
+
link({ href, title, tokens }) {
|
|
180
|
+
const text = this.parser.parseInline(tokens)
|
|
181
|
+
const tip = String(title || '').trim()
|
|
182
|
+
if (isTipHref(String(href || ''), tip)) {
|
|
183
|
+
return citationHtml(nextId(text), tip)
|
|
184
|
+
}
|
|
185
|
+
return linkHtml(String(href || '#'), text, tip)
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
/** HTML 层:已有 citation / title / 脚注 a / 数字 sup → tip;真实 a → +link */
|
|
191
|
+
function applyHtmlTipsAndLinks(html: string) {
|
|
192
|
+
let out = html.replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (m, attrs: string, inner: string) => {
|
|
193
|
+
if (/report-tiptap-citation/i.test(attrs)) return m
|
|
194
|
+
const href = extractAttr(attrs, 'href')
|
|
195
|
+
const title = extractAttr(attrs, 'title') || extractAttr(attrs, 'data-tip')
|
|
196
|
+
const label = inner.replace(/<[^>]+>/g, '').trim()
|
|
197
|
+
if (isTipHref(href, title) && !isRealHref(href)) {
|
|
198
|
+
return citationHtml(nextId(label || '1'), title)
|
|
199
|
+
}
|
|
200
|
+
if (/report-tiptap-link/i.test(attrs) && isRealHref(href)) return m
|
|
201
|
+
return linkHtml(href || '#', inner, title)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
out = out.replace(/<(?:abbr|span)\b([^>]*)>([\s\S]*?)<\/(?:abbr|span)>/gi, (m, attrs: string, inner: string) => {
|
|
205
|
+
if (/report-tiptap-citation/i.test(attrs) || /report-tiptap-link/i.test(attrs)) return m
|
|
206
|
+
const citationId = extractAttr(attrs, 'data-citation')
|
|
207
|
+
const tip = extractAttr(attrs, 'data-tip') || extractAttr(attrs, 'title')
|
|
208
|
+
const label = inner.replace(/<[^>]+>/g, '').trim()
|
|
209
|
+
if (citationId) return citationHtml(citationId, tip)
|
|
210
|
+
if (tip && isCitationLabel(label)) return citationHtml(nextId(label), tip)
|
|
211
|
+
return m
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
out = out.replace(/<sup\b([^>]*)>([\s\S]*?)<\/sup>/gi, (m, attrs: string, inner: string) => {
|
|
215
|
+
const tip = extractAttr(attrs, 'title') || extractAttr(attrs, 'data-tip')
|
|
216
|
+
const label = inner.replace(/<[^>]+>/g, '').trim()
|
|
217
|
+
if (!tip || !isCitationLabel(label)) return m
|
|
218
|
+
return citationHtml(nextId(label), tip)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
return out
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Element Plus 层:el-tooltip / el-popover → 原 tip;el-link → +link */
|
|
225
|
+
function applyElementPlusTipsAndLinks(html: string) {
|
|
226
|
+
let out = html.replace(
|
|
227
|
+
/<el-tooltip\b([^>]*)>([\s\S]*?)<\/el-tooltip>/gi,
|
|
228
|
+
(_m, attrs: string, inner: string) => {
|
|
229
|
+
const tip = extractAttr(attrs, 'content') || extractAttr(attrs, 'title')
|
|
230
|
+
const label = inner.replace(/<[^>]+>/g, '').trim()
|
|
231
|
+
if (!tip) return inner
|
|
232
|
+
return citationHtml(nextId(label || '1'), tip)
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
out = out.replace(
|
|
236
|
+
/<el-popover\b([^>]*)>([\s\S]*?)<\/el-popover>/gi,
|
|
237
|
+
(_m, attrs: string, inner: string) => {
|
|
238
|
+
const slotTip =
|
|
239
|
+
inner
|
|
240
|
+
.match(/<template[^>]*(?:#|v-slot:|slot=)[^>]*(?:content|default)[^>]*>([\s\S]*?)<\/template>/i)?.[1]
|
|
241
|
+
?.replace(/<[^>]+>/g, ' ')
|
|
242
|
+
.replace(/\s+/g, ' ')
|
|
243
|
+
.trim() || ''
|
|
244
|
+
const tip = extractAttr(attrs, 'content') || extractAttr(attrs, 'title') || slotTip
|
|
245
|
+
const label = inner.replace(/<template[\s\S]*?<\/template>/gi, '').replace(/<[^>]+>/g, '').trim()
|
|
246
|
+
if (!tip) return inner.replace(/<template[\s\S]*?<\/template>/gi, '')
|
|
247
|
+
return citationHtml(nextId(label || '1'), tip)
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
out = out.replace(/<el-link\b([^>]*)>([\s\S]*?)<\/el-link>/gi, (_m, attrs: string, inner: string) => {
|
|
251
|
+
const href = extractAttr(attrs, 'href')
|
|
252
|
+
const title = extractAttr(attrs, 'title') || extractAttr(attrs, 'content')
|
|
253
|
+
const label = inner.replace(/<[^>]+>/g, '').trim()
|
|
254
|
+
if (!isRealHref(href) && title) {
|
|
255
|
+
return citationHtml(nextId(label || '1'), title)
|
|
256
|
+
}
|
|
257
|
+
return linkHtml(href || '#', inner, title)
|
|
258
|
+
})
|
|
259
|
+
return out
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function applyFootnoteTips(html: string, footnotes: Map<string, string>) {
|
|
263
|
+
if (!footnotes.size) return html
|
|
264
|
+
return html.replace(
|
|
265
|
+
/<span class="report-tiptap-citation" data-citation="(\d+)"([^>]*)>([\s\S]*?)<\/span>/gi,
|
|
266
|
+
(m, id: string, rest: string, inner: string) => {
|
|
267
|
+
const raw = footnotes.get(id)
|
|
268
|
+
if (!raw) return m
|
|
269
|
+
const { text, href, linkLabel } = splitTipLink(raw)
|
|
270
|
+
if (/data-href=/.test(rest) && /data-tip=/.test(rest)) return m
|
|
271
|
+
const cleaned = String(rest).replace(/\sdata-(?:tip|href|link-label)="[^"]*"/gi, '')
|
|
272
|
+
const tipAttr = text ? ` data-tip="${escapeAttr(text)}"` : ''
|
|
273
|
+
const hrefAttr = href ? ` data-href="${escapeAttr(href)}"` : ''
|
|
274
|
+
const labelAttr = linkLabel ? ` data-link-label="${escapeAttr(linkLabel)}"` : ''
|
|
275
|
+
return `<span class="report-tiptap-citation" data-citation="${id}"${tipAttr}${hrefAttr}${labelAttr}${cleaned}>${inner}</span>`
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const UNCLOSED_BLOCK_RE =
|
|
281
|
+
/<(table|thead|tbody|tr|td|th|pre|blockquote|ul|ol|li|p|h[1-6]|think|strong|b|em|i|u|s|code|mark|div|section|article|figure|figcaption|header|footer|details|summary|font|ins|del|caption|el-tooltip|el-popover|el-link|span|a)\b[^>]*>(?:(?!<\/\1>)[\s\S])*$/i
|
|
282
|
+
|
|
283
|
+
export function stripThinkBlocks(source: string) {
|
|
284
|
+
return source
|
|
285
|
+
.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '')
|
|
286
|
+
.replace(/<think\b[\s\S]*$/i, '')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const CJK_CHAR =
|
|
290
|
+
/[\u3400-\u9FFF\uF900-\uFAFF\u3000-\u303F\u3040-\u30FF\uAC00-\uD7AF]/
|
|
291
|
+
|
|
292
|
+
/** 補回可讀空格:空白規範化、中西交界(保留換行);英文分詞交由後端保證 */
|
|
293
|
+
export function normalizeReadableSpacing(source: string): string {
|
|
294
|
+
let text = String(source ?? '')
|
|
295
|
+
if (!text) return ''
|
|
296
|
+
text = text.replace(/\u00a0/g, ' ')
|
|
297
|
+
// 0914:空白規範化 + 中西交界(英文粘詞由後端輸出正常空格)
|
|
298
|
+
text = text.replace(/[^\S\n\r]+/g, ' ')
|
|
299
|
+
text = text.replace(
|
|
300
|
+
new RegExp(`(${CJK_CHAR.source})([A-Za-z0-9])`, 'g'),
|
|
301
|
+
'$1 $2'
|
|
302
|
+
)
|
|
303
|
+
text = text.replace(
|
|
304
|
+
new RegExp(`([A-Za-z0-9])(${CJK_CHAR.source})`, 'g'),
|
|
305
|
+
'$1 $2'
|
|
306
|
+
)
|
|
307
|
+
// 0914
|
|
308
|
+
return text
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 是否像 Markdown(ATX 標題 / 列表 / 加粗),用於決定要不要拆 <p>/<br>/字面 \\n */
|
|
312
|
+
function looksLikeMarkdown(text: string) {
|
|
313
|
+
const probe = String(text || '')
|
|
314
|
+
.replace(/<(?:br)\s*\/?>/gi, '\n')
|
|
315
|
+
.replace(/<\/(?:p|div)\s*>/gi, '\n')
|
|
316
|
+
.replace(/<(?:p|div)\b[^>]*>/gi, '')
|
|
317
|
+
return /(?:^|\n)\s{0,3}#{1,6}\s|(?:^|\n)\s{0,3}(?:[-*+]|\d{1,9}[.)])\s|\*{3}[^*\n]+\*{3}|\*{2}[^*\n]+\*{2}/m.test(
|
|
318
|
+
probe
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 接口正文常把 Markdown 塞进 <p>/<br> 或字面 \\n,
|
|
324
|
+
* marked 無法把行首 ### / * 認成標題與列表,畫面就會打出原始符號。
|
|
325
|
+
* 閉合 HTML 結構保留;僅在「像 Markdown」時拆開陷阱標籤。
|
|
326
|
+
*/
|
|
327
|
+
function normalizeMarkdownSource(source: string) {
|
|
328
|
+
let text = String(source ?? '')
|
|
329
|
+
if (!text) return ''
|
|
330
|
+
|
|
331
|
+
const realNl = (text.match(/\r?\n/g) || []).length
|
|
332
|
+
const escapedNl = (text.match(/\\n/g) || []).length
|
|
333
|
+
if (escapedNl > 0 && escapedNl >= realNl) {
|
|
334
|
+
const unescaped = text.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\\t/g, '\t')
|
|
335
|
+
if (looksLikeMarkdown(unescaped)) text = unescaped
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!looksLikeMarkdown(text)) {
|
|
339
|
+
return normalizeReadableSpacing(text)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (/<br\s*\/?>/i.test(text)) {
|
|
343
|
+
text = text.replace(/<br\s*\/?>/gi, '\n')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (/<(?:p|div)\b/i.test(text)) {
|
|
347
|
+
const unwrapped = text
|
|
348
|
+
.replace(/<\/(?:p|div)\s*>\s*<(?:p|div)\b[^>]*>/gi, '\n\n')
|
|
349
|
+
.replace(/<(?:p|div)\b[^>]*>/gi, '')
|
|
350
|
+
.replace(/<\/(?:p|div)\s*>/gi, '\n')
|
|
351
|
+
if (looksLikeMarkdown(unwrapped)) text = unwrapped
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return normalizeReadableSpacing(text)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 在 source 上掃描,自動跳過 ```fence``` 與行內 `code`,
|
|
359
|
+
* 避免代碼裡的 ** / * / # 被當成未閉合 Markdown。
|
|
360
|
+
*/
|
|
361
|
+
function scanMarkdownOutsideCode(
|
|
362
|
+
source: string,
|
|
363
|
+
visit: (i: number, ch: string) => number | void
|
|
364
|
+
) {
|
|
365
|
+
let i = 0
|
|
366
|
+
let inFence = false
|
|
367
|
+
while (i < source.length) {
|
|
368
|
+
if (source.startsWith('```', i)) {
|
|
369
|
+
inFence = !inFence
|
|
370
|
+
i += 3
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
if (inFence) {
|
|
374
|
+
i += 1
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
if (source.charAt(i) === '`') {
|
|
378
|
+
let n = 0
|
|
379
|
+
while (source.charAt(i + n) === '`') n += 1
|
|
380
|
+
const open = source.slice(i, i + n)
|
|
381
|
+
const closeAt = source.indexOf(open, i + n)
|
|
382
|
+
if (closeAt < 0) {
|
|
383
|
+
// 半截行內 code:剩餘整段視為 code,不再掃描標記
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
i = closeAt + n
|
|
387
|
+
continue
|
|
388
|
+
}
|
|
389
|
+
const step = visit(i, source.charAt(i))
|
|
390
|
+
i += typeof step === 'number' && step > 0 ? step : 1
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function lastUnpairedFence(source: string, fence: string) {
|
|
395
|
+
let odd = false
|
|
396
|
+
let last = -1
|
|
397
|
+
scanMarkdownOutsideCode(source, (i) => {
|
|
398
|
+
// 統計 ** 時整段跳過 ***,避免三星被拆成 ** + *
|
|
399
|
+
if (fence === '**' && source.startsWith('***', i)) return 3
|
|
400
|
+
if (fence === '__' && source.startsWith('___', i)) return 3
|
|
401
|
+
if (source.startsWith(fence, i)) {
|
|
402
|
+
odd = !odd
|
|
403
|
+
last = i
|
|
404
|
+
return fence.length
|
|
405
|
+
}
|
|
406
|
+
})
|
|
407
|
+
return odd ? last : -1
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function lastUnpairedInlineTick(source: string) {
|
|
411
|
+
let odd = false
|
|
412
|
+
let last = -1
|
|
413
|
+
let i = 0
|
|
414
|
+
let inFence = false
|
|
415
|
+
while (i < source.length) {
|
|
416
|
+
if (source.startsWith('```', i)) {
|
|
417
|
+
inFence = !inFence
|
|
418
|
+
i += 3
|
|
419
|
+
continue
|
|
420
|
+
}
|
|
421
|
+
if (inFence) {
|
|
422
|
+
i += 1
|
|
423
|
+
continue
|
|
424
|
+
}
|
|
425
|
+
if (source.charAt(i) === '`') {
|
|
426
|
+
odd = !odd
|
|
427
|
+
last = i
|
|
428
|
+
}
|
|
429
|
+
i += 1
|
|
430
|
+
}
|
|
431
|
+
return odd ? last : -1
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function lastUnpairedSingleStar(source: string) {
|
|
435
|
+
let odd = false
|
|
436
|
+
let last = -1
|
|
437
|
+
scanMarkdownOutsideCode(source, (i) => {
|
|
438
|
+
if (source.startsWith('***', i)) return 3
|
|
439
|
+
if (source.startsWith('**', i)) return 2
|
|
440
|
+
if (source.charAt(i) !== '*') return
|
|
441
|
+
const atLineStart = i === 0 || source.charAt(i - 1) === '\n'
|
|
442
|
+
const next = source.charAt(i + 1)
|
|
443
|
+
if (atLineStart && (next === ' ' || next === '\t')) return 1
|
|
444
|
+
odd = !odd
|
|
445
|
+
last = i
|
|
446
|
+
})
|
|
447
|
+
return odd ? last : -1
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function lastUnpairedSingleUnderscore(source: string) {
|
|
451
|
+
let odd = false
|
|
452
|
+
let last = -1
|
|
453
|
+
scanMarkdownOutsideCode(source, (i) => {
|
|
454
|
+
if (source.startsWith('__', i)) return 2
|
|
455
|
+
if (source.charAt(i) !== '_') return
|
|
456
|
+
const prev = i === 0 ? ' ' : source.charAt(i - 1)
|
|
457
|
+
const next = source.charAt(i + 1)
|
|
458
|
+
if (/[A-Za-z0-9]/.test(prev) && /[A-Za-z0-9]/.test(next)) return 1
|
|
459
|
+
odd = !odd
|
|
460
|
+
last = i
|
|
461
|
+
})
|
|
462
|
+
return odd ? last : -1
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** 完整主題分隔線(HR),不應當半截標記 */
|
|
466
|
+
function isCompleteThematicBreak(text: string) {
|
|
467
|
+
return /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(text)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** 打字机:切开已闭合前缀 vs 半截标记;半截内文继续打出,避免整段闪没 */
|
|
471
|
+
function splitStreamStablePrefix(source: string) {
|
|
472
|
+
const cuts: number[] = []
|
|
473
|
+
const lastLt = source.lastIndexOf('<')
|
|
474
|
+
const lastGt = source.lastIndexOf('>')
|
|
475
|
+
//0920:半截 HTML 标签(< / <a / </ / <! / 末尾单独 <)切出,禁止闪 < >;保留 a < b 比较符
|
|
476
|
+
if (lastLt > lastGt) {
|
|
477
|
+
const next = source.charAt(lastLt + 1) || ''
|
|
478
|
+
if (next === '' || /[a-zA-Z/!?]/.test(next)) cuts.push(lastLt)
|
|
479
|
+
}
|
|
480
|
+
//0920
|
|
481
|
+
const block = source.match(UNCLOSED_BLOCK_RE)
|
|
482
|
+
if (block && block.index != null) cuts.push(block.index)
|
|
483
|
+
const linkOpen = source.search(/\[[^\]]*$|\[[^\]]*\]\([^)]*$/)
|
|
484
|
+
if (linkOpen >= 0) cuts.push(linkOpen)
|
|
485
|
+
const plusOpen = source.search(/\[\+(?:\d+)?$|\[\^\d*$/)
|
|
486
|
+
if (plusOpen >= 0) cuts.push(plusOpen)
|
|
487
|
+
//0918-1:半截 / 完整 [ref:…](允许空格)先不进正文,避免打出字面量
|
|
488
|
+
const refOpen = source.search(/\[ref:\s*[0-9a-f-]*$/i)
|
|
489
|
+
if (refOpen >= 0) cuts.push(refOpen)
|
|
490
|
+
const trailingRef = source.search(
|
|
491
|
+
/\[ref:\s*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\s*\]$/i
|
|
492
|
+
)
|
|
493
|
+
if (trailingRef >= 0) cuts.push(trailingRef)
|
|
494
|
+
// 任意未闭合 [ref: 也先切出
|
|
495
|
+
const refAnyOpen = source.search(/\[ref:[^\]]*$/i)
|
|
496
|
+
if (refAnyOpen >= 0) cuts.push(refAnyOpen)
|
|
497
|
+
//0920:[ / [r / [re / [ref 尚未到冒号时也切出,禁止先打出 ref 再变 tip
|
|
498
|
+
const refPrefixCut = trailingRefPrefixCut(source)
|
|
499
|
+
if (refPrefixCut >= 0) cuts.push(refPrefixCut)
|
|
500
|
+
//0920
|
|
501
|
+
//0918-1
|
|
502
|
+
for (const fence of ['```', '***', '**', '__', '~~'] as const) {
|
|
503
|
+
const at = lastUnpairedFence(source, fence)
|
|
504
|
+
if (at >= 0) cuts.push(at)
|
|
505
|
+
}
|
|
506
|
+
const tick = lastUnpairedInlineTick(source)
|
|
507
|
+
if (tick >= 0) cuts.push(tick)
|
|
508
|
+
const star = lastUnpairedSingleStar(source)
|
|
509
|
+
if (star >= 0) cuts.push(star)
|
|
510
|
+
const under = lastUnpairedSingleUnderscore(source)
|
|
511
|
+
if (under >= 0) cuts.push(under)
|
|
512
|
+
const mdBlock = lastIncompleteMarkdownCut(source)
|
|
513
|
+
if (mdBlock >= 0) cuts.push(mdBlock)
|
|
514
|
+
if (!cuts.length) return { stable: source, rest: '' }
|
|
515
|
+
const at = Math.min(...cuts)
|
|
516
|
+
const stable = source.slice(0, at)
|
|
517
|
+
const rest = source.slice(at)
|
|
518
|
+
// 誤切:rest 本身已是完整 HR,應併入 stable 交給 marked
|
|
519
|
+
if (rest && isCompleteThematicBreak(rest.trim())) {
|
|
520
|
+
return { stable: source, rest: '' }
|
|
521
|
+
}
|
|
522
|
+
return { stable, rest }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** 半截标题 / 列表 / 引用 / 表格 / setext 先不交给 marked,避免结构来回跳 */
|
|
526
|
+
function lastIncompleteMarkdownCut(source: string) {
|
|
527
|
+
const cuts: number[] = []
|
|
528
|
+
const pushMarker = (re: RegExp) => {
|
|
529
|
+
const m = source.match(re)
|
|
530
|
+
if (!m || m.index == null) return
|
|
531
|
+
cuts.push(m[0].startsWith('\n') ? m.index + 1 : m.index)
|
|
532
|
+
}
|
|
533
|
+
// 僅標題符號、尚無正文
|
|
534
|
+
pushMarker(/(?:^|\n) {0,3}#{1,6}$/)
|
|
535
|
+
// ### 後只有空格、標題字還在打
|
|
536
|
+
pushMarker(/(?:^|\n) {0,3}#{1,6}[ \t]+$/)
|
|
537
|
+
pushMarker(/(?:^|\n) {0,3}(?:[-*+]|\d{1,9}[.)])$/)
|
|
538
|
+
pushMarker(/(?:^|\n) {0,3}>$/)
|
|
539
|
+
// 僅 1~2 個 -/_(等第 3 個才成 HR);完整 --- 不切
|
|
540
|
+
pushMarker(/(?:^|\n) {0,3}(?:-{1,2}|_{1,2})$/)
|
|
541
|
+
// setext 下劃線未完成(1~2);≥3 交給 marked 解析為標題/HR
|
|
542
|
+
const setext = source.match(/\n[ \t]*([-=])\1{0,1}$/)
|
|
543
|
+
if (setext && setext.index != null) cuts.push(setext.index + 1)
|
|
544
|
+
const table = incompleteTableCut(source)
|
|
545
|
+
if (table >= 0) cuts.push(table)
|
|
546
|
+
return cuts.length ? Math.min(...cuts) : -1
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function incompleteTableCut(source: string) {
|
|
550
|
+
const lines = source.split('\n')
|
|
551
|
+
const lastLine = lines[lines.length - 1]
|
|
552
|
+
if (!lines.length || lastLine == null || !/^\s*\|/.test(lastLine)) return -1
|
|
553
|
+
let i = lines.length - 1
|
|
554
|
+
while (i > 0 && /^\s*\|/.test(lines[i - 1] ?? '')) i -= 1
|
|
555
|
+
const block = lines.slice(i)
|
|
556
|
+
const hasSep = block.some((line) => /^\s*\|?\s*:?-{2,}/.test(line))
|
|
557
|
+
const last = block[block.length - 1] || ''
|
|
558
|
+
const lastComplete = /\|.+\|/.test(last.trim())
|
|
559
|
+
if (hasSep && lastComplete && block.length >= 3) return -1
|
|
560
|
+
return i === 0 ? 0 : lines.slice(0, i).join('\n').length + 1
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function htmlInnerPlain(rest: string) {
|
|
564
|
+
const gt = rest.indexOf('>')
|
|
565
|
+
if (gt < 0) return ''
|
|
566
|
+
let inner = rest.slice(gt + 1)
|
|
567
|
+
inner = inner.replace(/<\/[a-zA-Z][^>]*>/g, '').replace(/<[a-zA-Z][^>]*\/?>/g, '')
|
|
568
|
+
const lastLt = inner.lastIndexOf('<')
|
|
569
|
+
const lastGt = inner.lastIndexOf('>')
|
|
570
|
+
if (lastLt > lastGt) inner = inner.slice(0, lastLt)
|
|
571
|
+
return inner
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
//0920:未闭合的 [ref 前缀不进正文(含还没打出冒号的 [ / [r / [re / [ref)
|
|
575
|
+
function isRefHoldTail(tail: string) {
|
|
576
|
+
const lower = tail.trimStart().toLowerCase()
|
|
577
|
+
if (!lower.startsWith('[')) return false
|
|
578
|
+
if (lower.startsWith('[ref')) return true
|
|
579
|
+
return '[ref'.startsWith(lower)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function trailingRefPrefixCut(source: string) {
|
|
583
|
+
const at = source.lastIndexOf('[')
|
|
584
|
+
if (at < 0) return -1
|
|
585
|
+
const tail = source.slice(at)
|
|
586
|
+
if (tail.includes(']')) return -1
|
|
587
|
+
return isRefHoldTail(tail) ? at : -1
|
|
588
|
+
}
|
|
589
|
+
//0920
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* 半截标记不把内文藏掉:`**xxx` / `<strong>xxx` 继续打字,只不画出 ** / 标签本身。
|
|
593
|
+
* 闭合后走正式解析变成粗体,与打字机进度同步、避免整段闪没。
|
|
594
|
+
*/
|
|
595
|
+
function visiblePlainFromIncomplete(rest: string) {
|
|
596
|
+
if (!rest) return ''
|
|
597
|
+
const leadWs = rest.match(/^[ \t]*/)?.[0] ?? ''
|
|
598
|
+
const t = rest.slice(leadWs.length)
|
|
599
|
+
if (!t) return ''
|
|
600
|
+
if (isCompleteThematicBreak(t)) return ''
|
|
601
|
+
if (t.startsWith('```')) {
|
|
602
|
+
const nl = t.indexOf('\n')
|
|
603
|
+
return nl < 0 ? '' : sanitizeStreamPlainVisible(leadWs + t.slice(nl + 1))
|
|
604
|
+
}
|
|
605
|
+
if (t.startsWith('***')) return sanitizeStreamPlainVisible(leadWs + t.slice(3))
|
|
606
|
+
if (t.startsWith('**')) return sanitizeStreamPlainVisible(leadWs + t.slice(2))
|
|
607
|
+
if (t.startsWith('__')) return sanitizeStreamPlainVisible(leadWs + t.slice(2))
|
|
608
|
+
if (t.startsWith('~~')) return sanitizeStreamPlainVisible(leadWs + t.slice(2))
|
|
609
|
+
if (/^#{1,6}$/.test(t)) return ''
|
|
610
|
+
if (/^#{1,6}[ \t]+$/.test(t)) return ''
|
|
611
|
+
if (/^#{1,6}[ \t]+/.test(t)) {
|
|
612
|
+
return sanitizeStreamPlainVisible(leadWs + t.replace(/^#{1,6}[ \t]+/, ''))
|
|
613
|
+
}
|
|
614
|
+
if (/^([-*+]|\d{1,9}[.)])$/.test(t)) return ''
|
|
615
|
+
if (/^([-*+]|\d{1,9}[.)]) /.test(t)) {
|
|
616
|
+
return sanitizeStreamPlainVisible(leadWs + t.replace(/^([-*+]|\d{1,9}[.)]) /, ''))
|
|
617
|
+
}
|
|
618
|
+
if (t === '>' || t.startsWith('>')) return ''
|
|
619
|
+
if (/^\s*\|/.test(t)) {
|
|
620
|
+
return sanitizeStreamPlainVisible(leadWs + t.replace(/^\s*\|/, '').replace(/\|/g, ' '))
|
|
621
|
+
}
|
|
622
|
+
if (t.startsWith('`')) return sanitizeStreamPlainVisible(leadWs + t.slice(1).replace(/`/g, ''))
|
|
623
|
+
//0920:半截 [ref 前缀([ / [r / [re / [ref / [ref:uuid)一律不显示,闭合后才解析成 tip
|
|
624
|
+
if (isRefHoldTail(t)) return ''
|
|
625
|
+
//0920
|
|
626
|
+
if (/^\[ref:/i.test(t)) return ''
|
|
627
|
+
//0918-1:半截 [ref:…] 内文不显示
|
|
628
|
+
if (/^\[ref:/i.test(t.trimStart())) return ''
|
|
629
|
+
//0918-1
|
|
630
|
+
if (t.startsWith('[') || t.startsWith('![')) {
|
|
631
|
+
const inner = t.replace(/^!?\[/, '')
|
|
632
|
+
const close = inner.indexOf(']')
|
|
633
|
+
return sanitizeStreamPlainVisible(leadWs + (close < 0 ? inner : inner.slice(0, close)))
|
|
634
|
+
}
|
|
635
|
+
//0920:半截 HTML 标签绝不露出 < >;只透出标签内正文
|
|
636
|
+
if (t.startsWith('<')) return sanitizeStreamPlainVisible(leadWs + htmlInnerPlain(t))
|
|
637
|
+
//0920
|
|
638
|
+
if (t.startsWith('*')) return sanitizeStreamPlainVisible(leadWs + t.slice(1))
|
|
639
|
+
if (t.startsWith('_')) return sanitizeStreamPlainVisible(leadWs + t.slice(1))
|
|
640
|
+
return sanitizeStreamPlainVisible(leadWs + t)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** 打字机半截尾禁止出现任何 < > / 半截标签字面量 */
|
|
644
|
+
//0920
|
|
645
|
+
function sanitizeStreamPlainVisible(text: string) {
|
|
646
|
+
if (!text) return ''
|
|
647
|
+
return text
|
|
648
|
+
.replace(/<|>|<|>/gi, '')
|
|
649
|
+
.replace(/<[^>]*>/g, '')
|
|
650
|
+
.replace(/<[^>]*$/g, '')
|
|
651
|
+
.replace(/^[^<\n]*>/g, '')
|
|
652
|
+
.replace(/[<><>]/g, '')
|
|
653
|
+
}
|
|
654
|
+
//0920
|
|
655
|
+
|
|
656
|
+
function appendPlainTail(html: string, rest: string) {
|
|
657
|
+
if (!rest) return html
|
|
658
|
+
//0920:再过一遍,确保尾段不闪标签
|
|
659
|
+
const plain = sanitizeStreamPlainVisible(rest)
|
|
660
|
+
if (!plain) return html
|
|
661
|
+
const escaped = escapeHtml(plain).replace(/\n/g, '<br>')
|
|
662
|
+
//0920
|
|
663
|
+
if (!html) return `<p>${escaped}</p>`
|
|
664
|
+
// 表格 / 标题半截单独成段,避免塞进上一块后再整体跳结构
|
|
665
|
+
if (/^\s*\|/.test(rest.trimStart()) || /^\s*#{1,6}(?:\s|$)/.test(rest.trimStart())) {
|
|
666
|
+
return `${html}<p>${escaped}</p>`
|
|
667
|
+
}
|
|
668
|
+
const closeRe = /<\/(?:p|li|h[1-6]|td|th)\b[^>]*>/gi
|
|
669
|
+
let last = -1
|
|
670
|
+
let m: RegExpExecArray | null
|
|
671
|
+
while ((m = closeRe.exec(html))) last = m.index
|
|
672
|
+
if (last >= 0) return html.slice(0, last) + escaped + html.slice(last)
|
|
673
|
+
return `${html}<p>${escaped}</p>`
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** 将 Markdown / HTML / Element Plus 解析为 TipTap 可 setContent 的片段 */
|
|
677
|
+
export function parseMarkdownToHtml(source: string): string {
|
|
678
|
+
const text = normalizeMarkdownSource(String(source ?? ''))
|
|
679
|
+
if (!text.trim()) return ''
|
|
680
|
+
citationSeq = 0
|
|
681
|
+
try {
|
|
682
|
+
const withoutThink = stripThinkBlocks(text)
|
|
683
|
+
if (!withoutThink.trim()) return ''
|
|
684
|
+
const mdTips = applyMarkdownTips(withoutThink)
|
|
685
|
+
const protectedSrc = protectBlocks(mdTips.text)
|
|
686
|
+
const parsed = markedParser.parse(protectedSrc.text, { async: false })
|
|
687
|
+
let html = typeof parsed === 'string' ? parsed.trim() : ''
|
|
688
|
+
html = restoreBlocks(html, protectedSrc.slots)
|
|
689
|
+
html = applyHtmlTipsAndLinks(html)
|
|
690
|
+
html = applyElementPlusTipsAndLinks(html)
|
|
691
|
+
html = applyFootnoteTips(html, mdTips.footnotes)
|
|
692
|
+
return html
|
|
693
|
+
} catch {
|
|
694
|
+
return `<p>${escapeHtml(stripThinkBlocks(text))}</p>`
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
let streamStableCacheKey = ''
|
|
699
|
+
let streamStableCacheHtml = ''
|
|
700
|
+
|
|
701
|
+
/** 打字机逐帧:完整语法走解析;半截 ** / 标签不画,内文继续打出,打完再灌全文
|
|
702
|
+
* @param flushTrailingRef 收流追平时把末尾完整 [ref:uuid] 一并解析成 tip
|
|
703
|
+
*/
|
|
704
|
+
export function parseMarkdownToHtmlStream(
|
|
705
|
+
source: string,
|
|
706
|
+
options?: { flushTrailingRef?: boolean }
|
|
707
|
+
): string {
|
|
708
|
+
const text = stripThinkBlocks(normalizeMarkdownSource(String(source ?? '')))
|
|
709
|
+
if (!text) return ''
|
|
710
|
+
const { stable, rest } = splitStreamStablePrefix(text)
|
|
711
|
+
const trailingRefOnly =
|
|
712
|
+
!!rest &&
|
|
713
|
+
//0918-1:允许 [ref: uuid] 空格
|
|
714
|
+
/^\[ref:\s*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\s*\]$/i.test(
|
|
715
|
+
rest.trim()
|
|
716
|
+
)
|
|
717
|
+
//0918-1
|
|
718
|
+
if (!rest || (options?.flushTrailingRef && trailingRefOnly)) {
|
|
719
|
+
const html = parseMarkdownToHtml(text)
|
|
720
|
+
streamStableCacheKey = text
|
|
721
|
+
streamStableCacheHtml = html
|
|
722
|
+
return html
|
|
723
|
+
}
|
|
724
|
+
let html = ''
|
|
725
|
+
if (stable) {
|
|
726
|
+
if (stable === streamStableCacheKey) html = streamStableCacheHtml
|
|
727
|
+
else {
|
|
728
|
+
html = parseMarkdownToHtml(stable)
|
|
729
|
+
streamStableCacheKey = stable
|
|
730
|
+
streamStableCacheHtml = html
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return appendPlainTail(html, visiblePlainFromIncomplete(rest))
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
//0920:tip popup 内的 Markdown 表格(不走正文 citation 管线)
|
|
737
|
+
const tipMarkdownParser = new Marked({
|
|
738
|
+
gfm: true,
|
|
739
|
+
breaks: true,
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
export function renderTipMarkdownHtml(source: string): string {
|
|
743
|
+
const text = String(source ?? '').trim()
|
|
744
|
+
if (!text) return ''
|
|
745
|
+
// 只转尖括号,避免原文 HTML 进弹层;保留 | 供 GFM 表格解析
|
|
746
|
+
const safe = text.replace(/</g, '<').replace(/>/g, '>')
|
|
747
|
+
try {
|
|
748
|
+
const parsed = tipMarkdownParser.parse(safe, { async: false })
|
|
749
|
+
return typeof parsed === 'string' ? parsed : ''
|
|
750
|
+
} catch {
|
|
751
|
+
return `<p>${escapeHtml(safe).replace(/\n/g, '<br>')}</p>`
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
//0920
|