aegon-lv82 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.
Files changed (148) hide show
  1. package/package.json +12 -0
  2. package/src/App.vue +44 -0
  3. package/src/api/index.ts +19 -0
  4. package/src/api/modules/build-report/reportEditor.ts +526 -0
  5. package/src/api/modules/build-report/reportPermission.ts +64 -0
  6. package/src/api/modules/index.ts +69 -0
  7. package/src/api/modules/industry/green_mortgage.ts +42 -0
  8. package/src/api/modules/user/index.ts +4 -0
  9. package/src/api/request.ts +124 -0
  10. package/src/assets/sample-access-icon.png +0 -0
  11. package/src/assets/sample-pie-chart.png +0 -0
  12. package/src/assets/vue.svg +1 -0
  13. package/src/components/CapsuleScrollbar.vue +7 -0
  14. package/src/components/Export/ExcelExport.vue +592 -0
  15. package/src/components/Export/ExcelExport2.vue +494 -0
  16. package/src/components/Export/ExcelExport3.vue +342 -0
  17. package/src/components/Export/ExcelExport4.vue +665 -0
  18. package/src/components/Export/excelExport.js +547 -0
  19. package/src/components/Export/excelExport.ts +551 -0
  20. package/src/components/GEN-AI/index.vue +140 -0
  21. package/src/components/GEN-AI/index1.vue +422 -0
  22. package/src/components/GEN-AI/index10.vue +5 -0
  23. package/src/components/GEN-AI/index2.vue +548 -0
  24. package/src/components/GEN-AI/index3.vue +605 -0
  25. package/src/components/GEN-AI/index4.vue +609 -0
  26. package/src/components/GEN-AI/index5.vue +558 -0
  27. package/src/components/GEN-AI/index6.vue +634 -0
  28. package/src/components/GEN-AI/index7.vue +688 -0
  29. package/src/components/GEN-AI/index8.vue +458 -0
  30. package/src/components/GEN-AI/index9.vue +1217 -0
  31. package/src/components/GEN-AI/types.ts +12 -0
  32. package/src/components/GEN-AI/utils.ts +42 -0
  33. package/src/components/HelloWorld.vue +41 -0
  34. package/src/components/PageCard.vue +124 -0
  35. package/src/components/PageHeader.vue +32 -0
  36. package/src/components/backup/index5 copy.vue +556 -0
  37. package/src/components/backup/index5.vue +620 -0
  38. package/src/components/backup/index9 copy.vue +1029 -0
  39. package/src/components/backup/index9-pro.vue +1065 -0
  40. package/src/components/backup/index9.vue +1057 -0
  41. package/src/directives/btnLoading.ts +455 -0
  42. package/src/directives/debounce copy.ts +670 -0
  43. package/src/directives/debounce.ts +98 -0
  44. package/src/directives/index.ts +25 -0
  45. package/src/layouts/MainLayout.vue +129 -0
  46. package/src/main.ts +76 -0
  47. package/src/router/index.ts +142 -0
  48. package/src/router/menus.ts +28 -0
  49. package/src/store/modules/gen-ai/index.ts +1 -0
  50. package/src/store/modules/gen-ai/reportBuild.ts +37 -0
  51. package/src/style.css +79 -0
  52. package/src/styles/report-config-popover.css +39 -0
  53. package/src/styles/report-icon-btn.css +19 -0
  54. package/src/test/mock.ts +101 -0
  55. package/src/test/test1.vue +402 -0
  56. package/src/test/test2.vue +1689 -0
  57. package/src/types/gen-ai/build-report/reportEditor.ts +684 -0
  58. package/src/utils/function.ts +46 -0
  59. package/src/utils/gen-ai-navigation.ts +1 -0
  60. package/src/utils/gen-ai-scroll.ts +1 -0
  61. package/src/utils/openDataLoaderWordExport.ts +33 -0
  62. package/src/utils/pageScrollbar.ts +115 -0
  63. package/src/utils/randomTranscode.ts +87 -0
  64. package/src/utils/reportPdfExport.ts +44 -0
  65. package/src/views/Blank.vue +68 -0
  66. package/src/views/Home.vue +29 -0
  67. package/src/views/TemplateCenter/Knowledge.ts +83 -0
  68. package/src/views/TemplateCenter/data.d.ts +10 -0
  69. package/src/views/TemplateCenter/index.vue +1128 -0
  70. package/src/views/TemplateCenter/service.ts +69 -0
  71. package/src/views/gen-ai/build-report/chartStreamRefs.ts +174 -0
  72. package/src/views/gen-ai/build-report/coherence-check/index.vue +559 -0
  73. package/src/views/gen-ai/build-report/components/ReportChapterWorkspace.vue +4258 -0
  74. package/src/views/gen-ai/build-report/components/ReportConfigPanel.vue +727 -0
  75. package/src/views/gen-ai/build-report/components/ReportGenerateSettingsPanel.vue +2361 -0
  76. package/src/views/gen-ai/build-report/components/ReportGeneratingPanel.vue +1765 -0
  77. package/src/views/gen-ai/build-report/components/ReportPromptPanel.vue +614 -0
  78. package/src/views/gen-ai/build-report/components/ReportSourceFileSummary.vue +656 -0
  79. package/src/views/gen-ai/build-report/components/ReportSourcesPanel.vue +1327 -0
  80. package/src/views/gen-ai/build-report/components/ReportTemplateDetail.vue +191 -0
  81. package/src/views/gen-ai/build-report/components/ReportTemplateEditorFormPanel.vue +667 -0
  82. package/src/views/gen-ai/build-report/components/ReportTemplatePicker.vue +302 -0
  83. package/src/views/gen-ai/build-report/components/ReportWorkflowSidebar.vue +148 -0
  84. package/src/views/gen-ai/build-report/components/paramsSetting.vue +886 -0
  85. package/src/views/gen-ai/build-report/components/reportEdit.vue +2283 -0
  86. package/src/views/gen-ai/build-report/index.vue +856 -0
  87. package/src/views/gen-ai/build-report/report-domain.ts +1133 -0
  88. package/src/views/gen-ai/build-report/report-template-data.ts +295 -0
  89. package/src/views/gen-ai/build-report/useReportWorkflow.ts +1408 -0
  90. package/src/views/gen-ai/common/assets/NotoSans-SC.woff2 +0 -0
  91. package/src/views/gen-ai/common/assets/NotoSans-TC.woff2 +0 -0
  92. package/src/views/gen-ai/common/assets/NotoSans.woff2 +0 -0
  93. package/src/views/gen-ai/common/assets/fonts.css +278 -0
  94. package/src/views/gen-ai/common/assets/pagination.css +163 -0
  95. package/src/views/gen-ai/common/directives/btnLoading.ts +455 -0
  96. package/src/views/gen-ai/common/directives/debounce.ts +98 -0
  97. package/src/views/gen-ai/common/directives/index.ts +30 -0
  98. package/src/views/gen-ai/common/en.md +417 -0
  99. package/src/views/gen-ai/common/index.ts +36 -0
  100. package/src/views/gen-ai/common/types/component-expose.ts +9 -0
  101. package/src/views/gen-ai/common/utils/api-res-status.ts +7 -0
  102. package/src/views/gen-ai/common/utils/navigation.ts +59 -0
  103. package/src/views/gen-ai/common/utils/remote-request.ts +45 -0
  104. package/src/views/gen-ai/common/utils/scroll.ts +577 -0
  105. package/src/views/gen-ai/components/AIExport/docx-export.ts +950 -0
  106. package/src/views/gen-ai/components/AIExport/docxExport.ts +2566 -0
  107. package/src/views/gen-ai/components/AIExport/index.ts +52 -0
  108. package/src/views/gen-ai/components/AIExport/parseMarkdownHtml.ts +649 -0
  109. package/src/views/gen-ai/components/CapsuleScrollbar.vue +149 -0
  110. package/src/views/gen-ai/components/ChapterTitleScroll.vue +468 -0
  111. package/src/views/gen-ai/components/EntryHeroNavCards.vue +379 -0
  112. package/src/views/gen-ai/components/GenAiPagination.vue +52 -0
  113. package/src/views/gen-ai/components/ModelCard.vue +1114 -0
  114. package/src/views/gen-ai/components/Preview/FilePreviewPage.vue +947 -0
  115. package/src/views/gen-ai/components/Preview/PreviewCapsuleScroll.vue +205 -0
  116. package/src/views/gen-ai/components/Preview/PreviewOutlineTree.vue +148 -0
  117. package/src/views/gen-ai/components/Preview/index.ts +11 -0
  118. package/src/views/gen-ai/components/Preview/preview.ts +722 -0
  119. package/src/views/gen-ai/components/PromptField.vue +505 -0
  120. package/src/views/gen-ai/components/RecentReportsTable.vue +974 -0
  121. package/src/views/gen-ai/components/ReportCollapsibleSection.vue +172 -0
  122. package/src/views/gen-ai/components/ReportHistoryList.vue +637 -0
  123. package/src/views/gen-ai/components/ReportSectionConfirmPopover.vue +251 -0
  124. package/src/views/gen-ai/components/editor/ChapterTiptapEditor.vue +3139 -0
  125. package/src/views/gen-ai/components/editor/StructureBlockView.vue +441 -0
  126. package/src/views/gen-ai/components/editor/TiptapToolbar.vue +1036 -0
  127. package/src/views/gen-ai/components/editor/TypewriterDocEditor.vue +398 -0
  128. package/src/views/gen-ai/components/editor/_StructureOrgNode.vue +326 -0
  129. package/src/views/gen-ai/components/editor/index.ts +12 -0
  130. package/src/views/gen-ai/components/editor/parseMarkdownHtml.ts +667 -0
  131. package/src/views/gen-ai/gen-entry/api/index.ts +32 -0
  132. package/src/views/gen-ai/gen-entry/index.vue +287 -0
  133. package/src/views/gen-ai/gen-entry/types/index.ts +4 -0
  134. package/src/views/gen-ai/i.mark +2 -0
  135. package/src/views/gen-ai/management-center/index.vue +1815 -0
  136. package/src/views/gen-ai/management-center/mockData.ts +248 -0
  137. package/src/views/gen-ai/management-center/types/index.ts +37 -0
  138. package/src/views/gen-ai/model-manager/TemplateEditorFormPanel.vue +661 -0
  139. package/src/views/gen-ai/model-manager/api/index.ts +32 -0
  140. package/src/views/gen-ai/model-manager/api/mockApi.ts +33 -0
  141. package/src/views/gen-ai/model-manager/index.vue +1583 -0
  142. package/src/views/gen-ai/model-manager/mockData.ts +177 -0
  143. package/src/views/gen-ai/model-manager/template-detail-shared.ts +154 -0
  144. package/src/views/gen-ai/model-manager/template-editor.vue +345 -0
  145. package/src/views/gen-ai/model-manager/types/index.ts +40 -0
  146. package/src/views/gen-ai/model-manager/utils.ts +25 -0
  147. package/src/views/greenMortgage.vue +1834 -0
  148. package/src/vite-env.d.ts +46 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * AIExport — Word 導出獨立模組(與編輯器 / 預覽等業務解耦)
3
+ *
4
+ * 路徑:src/views/gen-ai/components/AIExport/
5
+ * index.ts / docx-export.ts / docxExport.ts / parseMarkdownHtml.ts
6
+ *
7
+ * 頁面入口僅:
8
+ * import { exportMarkdownToAiWord, exportMarkdownToDocxWord, DOCX_HEADER_CLASSIFICATION }
9
+ * from '@/views/gen-ai/components/AIExport'
10
+ * // 或相對路徑:from '../../components/AIExport'
11
+ *
12
+ * pnpm 必裝(見 MIGRATE.md):
13
+ * pnpm add jszip@^3.10.1 file-saver@^2.0.5 html2canvas@^1.4.1 marked@^9
14
+ * pnpm add -D @types/file-saver
15
+ */
16
+ export {
17
+ DOCX_HEADER_CLASSIFICATION,
18
+ exportMarkdownToAiWord,
19
+ exportMarkdownToDocxWord,
20
+ formatChapterHeading,
21
+ wrapIndustryResearchExportHtml,
22
+ buildIndustryResearchExportParts,
23
+ } from './docx-export'
24
+
25
+ export type {
26
+ ExportReportMember,
27
+ ExportReportMeta,
28
+ ExportSourceRef,
29
+ ExportMarkdownChapter,
30
+ ExportMarkdownToAiWordOptions,
31
+ ExportMarkdownToDocxWordOptions,
32
+ } from './docx-export'
33
+
34
+ /** 底層能力(進階 / 自定義組裝時用) */
35
+ export {
36
+ A4_CONTENT_WIDTH_PT,
37
+ A4_CONTENT_WIDTH_PX,
38
+ A4_MARGINS_2CM,
39
+ A4_PORTRAIT,
40
+ DOCX_FLOAT_ICON_SIZE_PX,
41
+ DOCX_PIE_CHART_MAX_WIDTH_PX,
42
+ DOCX_PIE_CHART_MAX_HEIGHT_PX,
43
+ buildWordDocumentHtml,
44
+ exportHtmlToDocx,
45
+ injectWordPageBreaks,
46
+ prepareDomForDocx,
47
+ rasterizeImagesForDocx,
48
+ rasterizeMediaForDocx,
49
+ stripWordPageBreaks,
50
+ } from './docxExport'
51
+
52
+ export type { DocxExportOptions, DocxHeaderFooterOptions } from './docxExport'
@@ -0,0 +1,649 @@
1
+ /**
2
+ * 接口 sectionContent → HTML
3
+ *
4
+ * 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
+ */
8
+ import { Marked } from 'marked'
9
+ import { getChartStreamRef } from '../../build-report/chartStreamRefs'
10
+
11
+ function escapeHtml(value: string) {
12
+ return value
13
+ .replace(/&/g, '&amp;')
14
+ .replace(/</g, '&lt;')
15
+ .replace(/>/g, '&gt;')
16
+ .replace(/"/g, '&quot;')
17
+ }
18
+
19
+ function escapeAttr(value: string) {
20
+ return escapeHtml(String(value).replace(/\s+/g, ' ').trim())
21
+ }
22
+
23
+ function splitTipLink(tip: string) {
24
+ const raw = String(tip || '').trim()
25
+ const md = raw.match(/\[([^\]]+)\]\((https?:[^)\s]+)(?:\s+"[^"]*")?\)/i)
26
+ if (md?.[1] != null && md[2] != null) {
27
+ return {
28
+ text: raw.replace(md[0], '').replace(/\s+/g, ' ').trim(),
29
+ href: md[2].trim(),
30
+ linkLabel: md[1].trim(),
31
+ }
32
+ }
33
+ const html = raw.match(/<a\b[^>]*href=["'](https?:[^"']+)["'][^>]*>([\s\S]*?)<\/a>/i)
34
+ if (html?.[1] != null && html[2] != null) {
35
+ return {
36
+ text: raw.replace(html[0], '').replace(/\s+/g, ' ').trim(),
37
+ href: html[1].trim(),
38
+ linkLabel: html[2].replace(/<[^>]+>/g, '').trim(),
39
+ }
40
+ }
41
+ const paren = raw.match(/^(.*?)\s*\((https?:[^)\s]+)\)\s*$/i)
42
+ if (paren?.[1] != null && paren[2] != null) {
43
+ return {
44
+ text: paren[1].replace(/\s+/g, ' ').trim(),
45
+ href: paren[2].trim(),
46
+ linkLabel: paren[2].trim(),
47
+ }
48
+ }
49
+ return { text: raw, href: '', linkLabel: '' }
50
+ }
51
+
52
+ function citationHtml(
53
+ id: string,
54
+ tip = '',
55
+ extra?: {
56
+ sourceName?: string
57
+ filePath?: string
58
+ filePreviewUrl?: string
59
+ chunkId?: string
60
+ }
61
+ ) {
62
+ const n = String(id).trim() || '1'
63
+ const { text, href, linkLabel } = splitTipLink(tip)
64
+ const tipAttr = text ? ` data-tip="${escapeAttr(text)}"` : ''
65
+ const hrefAttr = href ? ` data-href="${escapeAttr(href)}"` : ''
66
+ const labelAttr = linkLabel ? ` data-link-label="${escapeAttr(linkLabel)}"` : ''
67
+ const sourceNameAttr = extra?.sourceName
68
+ ? ` data-source-name="${escapeAttr(extra.sourceName)}"`
69
+ : ''
70
+ const filePathAttr = extra?.filePath ? ` data-file-path="${escapeAttr(extra.filePath)}"` : ''
71
+ const filePreviewAttr = extra?.filePreviewUrl
72
+ ? ` data-file-preview-url="${escapeAttr(extra.filePreviewUrl)}"`
73
+ : ''
74
+ const chunkIdAttr = extra?.chunkId ? ` data-chunk-id="${escapeAttr(extra.chunkId)}"` : ''
75
+ return `<span class="report-tiptap-citation" data-citation="${escapeHtml(n)}"${tipAttr}${hrefAttr}${labelAttr}${sourceNameAttr}${filePathAttr}${filePreviewAttr}${chunkIdAttr}>${escapeHtml(n)}</span>`
76
+ }
77
+
78
+ const REF_MARKER_RE =
79
+ /\[ref:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\]/gi
80
+
81
+ function isRealHref(href: string) {
82
+ const value = String(href || '').trim()
83
+ if (!value || value === '#') return false
84
+ if (/^#(?:fn|footnote|cite|ref)/i.test(value)) return false
85
+ return /^(https?:|mailto:|tel:|\/|\.\/|\.\.\/|#\/)/i.test(value) || /^[a-z][a-z0-9+.-]*:/i.test(value)
86
+ }
87
+
88
+ function isCitationLabel(text: string) {
89
+ const label = text.replace(/<[^>]+>/g, '').trim()
90
+ return /^\[?\d+\]?$/.test(label) || /^[+*]?\d+$/.test(label)
91
+ }
92
+
93
+ function idFromLabel(label: string, fallback: string) {
94
+ const m = String(label).replace(/<[^>]+>/g, '').trim().match(/(\d+)/)
95
+ return m?.[1] ?? fallback
96
+ }
97
+
98
+ let citationSeq = 0
99
+ function nextId(label: string) {
100
+ const fromLabel = idFromLabel(label, '')
101
+ if (fromLabel) return fromLabel
102
+ citationSeq += 1
103
+ return String(citationSeq)
104
+ }
105
+
106
+ function extractAttr(tag: string, name: string) {
107
+ const m = tag.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'))
108
+ return (m?.[1] ?? m?.[2] ?? m?.[3] ?? '').trim()
109
+ }
110
+
111
+ const PLACEHOLDER = '%%GENAI_BLOCK_'
112
+
113
+ function protectBlocks(source: string) {
114
+ const slots: string[] = []
115
+ const stash = (html: string) => {
116
+ const i = slots.length
117
+ slots.push(html)
118
+ return `${PLACEHOLDER}${i}%%`
119
+ }
120
+ let text = source.replace(
121
+ /<span\b[^>]*class="[^"]*report-tiptap-citation[^"]*"[^>]*>[\s\S]*?<\/span>/gi,
122
+ (m) => stash(m)
123
+ )
124
+ text = text.replace(/<(table|pre|blockquote|details)[\s\S]*?<\/\1>/gi, (m) => stash(m))
125
+ text = text.replace(/<(el-(?:tooltip|popover|link))\b[\s\S]*?<\/\1>/gi, (m) => stash(m))
126
+ return { text, slots }
127
+ }
128
+
129
+ function restoreBlocks(html: string, slots: string[]) {
130
+ return html.replace(new RegExp(`${PLACEHOLDER}(\\d+)%%`, 'g'), (_, i) => slots[Number(i)] ?? '')
131
+ }
132
+
133
+ /** Markdown 层 tip:脚注定义、[^n]、[+n]、[ref:uuid];[+link] / [text](url) 不在此当 tip */
134
+ function applyMarkdownTips(source: string) {
135
+ const footnotes = new Map<string, string>()
136
+ let text = source.replace(/^\[\^(\d+)\]:\s*(.+)$/gm, (_, id: string, tip: string) => {
137
+ footnotes.set(id, tip.trim())
138
+ return ''
139
+ })
140
+ text = text.replace(REF_MARKER_RE, (full, chunkId: string) => {
141
+ const ref = getChartStreamRef(chunkId)
142
+ // 匹配不到的 [ref:uuid] 全部过滤,不进正文
143
+ if (!ref) return ''
144
+ return citationHtml(String(ref.index), ref.sourceText, {
145
+ sourceName: ref.sourceName,
146
+ filePath: ref.filePath,
147
+ filePreviewUrl: ref.filePreviewUrl,
148
+ chunkId: ref.chunkId,
149
+ })
150
+ })
151
+ text = text.replace(/\[\+(\d+)\]/g, (_, id: string) => citationHtml(id))
152
+ text = text.replace(/\[\^(\d+)\](?!:)/g, (_, id: string) =>
153
+ citationHtml(id, footnotes.get(id) || '')
154
+ )
155
+ return { text, footnotes }
156
+ }
157
+
158
+ function linkHtml(href: string, inner: string, title = '') {
159
+ const titleAttr = title ? ` title="${escapeAttr(title)}"` : ''
160
+ return `<a href="${escapeHtml(href)}" class="report-tiptap-link" target="_blank" rel="noopener noreferrer"${titleAttr}>${inner}</a>`
161
+ }
162
+
163
+ function isTipHref(href: string, title: string, _label?: string) {
164
+ if (isRealHref(href)) return false
165
+ if (title) return true
166
+ return /^#(?:fn|footnote|cite|ref)/i.test(href)
167
+ }
168
+
169
+ const markedParser = new Marked({
170
+ gfm: true,
171
+ breaks: true,
172
+ renderer: {
173
+ link({ href, title, tokens }) {
174
+ const text = this.parser.parseInline(tokens)
175
+ const tip = String(title || '').trim()
176
+ if (isTipHref(String(href || ''), tip)) {
177
+ return citationHtml(nextId(text), tip)
178
+ }
179
+ return linkHtml(String(href || '#'), text, tip)
180
+ },
181
+ },
182
+ })
183
+
184
+ /** HTML 层:已有 citation / title / 脚注 a / 数字 sup → tip;真实 a → +link */
185
+ function applyHtmlTipsAndLinks(html: string) {
186
+ let out = html.replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (m, attrs: string, inner: string) => {
187
+ if (/report-tiptap-citation/i.test(attrs)) return m
188
+ const href = extractAttr(attrs, 'href')
189
+ const title = extractAttr(attrs, 'title') || extractAttr(attrs, 'data-tip')
190
+ const label = inner.replace(/<[^>]+>/g, '').trim()
191
+ if (isTipHref(href, title) && !isRealHref(href)) {
192
+ return citationHtml(nextId(label || '1'), title)
193
+ }
194
+ if (/report-tiptap-link/i.test(attrs) && isRealHref(href)) return m
195
+ return linkHtml(href || '#', inner, title)
196
+ })
197
+
198
+ out = out.replace(/<(?:abbr|span)\b([^>]*)>([\s\S]*?)<\/(?:abbr|span)>/gi, (m, attrs: string, inner: string) => {
199
+ if (/report-tiptap-citation/i.test(attrs) || /report-tiptap-link/i.test(attrs)) return m
200
+ const citationId = extractAttr(attrs, 'data-citation')
201
+ const tip = extractAttr(attrs, 'data-tip') || extractAttr(attrs, 'title')
202
+ const label = inner.replace(/<[^>]+>/g, '').trim()
203
+ if (citationId) return citationHtml(citationId, tip)
204
+ if (tip && isCitationLabel(label)) return citationHtml(nextId(label), tip)
205
+ return m
206
+ })
207
+
208
+ out = out.replace(/<sup\b([^>]*)>([\s\S]*?)<\/sup>/gi, (m, attrs: string, inner: string) => {
209
+ const tip = extractAttr(attrs, 'title') || extractAttr(attrs, 'data-tip')
210
+ const label = inner.replace(/<[^>]+>/g, '').trim()
211
+ if (!tip || !isCitationLabel(label)) return m
212
+ return citationHtml(nextId(label), tip)
213
+ })
214
+
215
+ return out
216
+ }
217
+
218
+ /** Element Plus 层:el-tooltip / el-popover → 原 tip;el-link → +link */
219
+ function applyElementPlusTipsAndLinks(html: string) {
220
+ let out = html.replace(
221
+ /<el-tooltip\b([^>]*)>([\s\S]*?)<\/el-tooltip>/gi,
222
+ (_m, attrs: string, inner: string) => {
223
+ const tip = extractAttr(attrs, 'content') || extractAttr(attrs, 'title')
224
+ const label = inner.replace(/<[^>]+>/g, '').trim()
225
+ if (!tip) return inner
226
+ return citationHtml(nextId(label || '1'), tip)
227
+ }
228
+ )
229
+ out = out.replace(
230
+ /<el-popover\b([^>]*)>([\s\S]*?)<\/el-popover>/gi,
231
+ (_m, attrs: string, inner: string) => {
232
+ const slotTip =
233
+ inner
234
+ .match(/<template[^>]*(?:#|v-slot:|slot=)[^>]*(?:content|default)[^>]*>([\s\S]*?)<\/template>/i)?.[1]
235
+ ?.replace(/<[^>]+>/g, ' ')
236
+ .replace(/\s+/g, ' ')
237
+ .trim() || ''
238
+ const tip = extractAttr(attrs, 'content') || extractAttr(attrs, 'title') || slotTip
239
+ const label = inner.replace(/<template[\s\S]*?<\/template>/gi, '').replace(/<[^>]+>/g, '').trim()
240
+ if (!tip) return inner.replace(/<template[\s\S]*?<\/template>/gi, '')
241
+ return citationHtml(nextId(label || '1'), tip)
242
+ }
243
+ )
244
+ out = out.replace(/<el-link\b([^>]*)>([\s\S]*?)<\/el-link>/gi, (_m, attrs: string, inner: string) => {
245
+ const href = extractAttr(attrs, 'href')
246
+ const title = extractAttr(attrs, 'title') || extractAttr(attrs, 'content')
247
+ const label = inner.replace(/<[^>]+>/g, '').trim()
248
+ if (!isRealHref(href) && title) {
249
+ return citationHtml(nextId(label || '1'), title)
250
+ }
251
+ return linkHtml(href || '#', inner, title)
252
+ })
253
+ return out
254
+ }
255
+
256
+ function applyFootnoteTips(html: string, footnotes: Map<string, string>) {
257
+ if (!footnotes.size) return html
258
+ return html.replace(
259
+ /<span class="report-tiptap-citation" data-citation="(\d+)"([^>]*)>([\s\S]*?)<\/span>/gi,
260
+ (m, id: string, rest: string, inner: string) => {
261
+ const raw = footnotes.get(id)
262
+ if (!raw) return m
263
+ const { text, href, linkLabel } = splitTipLink(raw)
264
+ if (/data-href=/.test(rest) && /data-tip=/.test(rest)) return m
265
+ const cleaned = String(rest).replace(/\sdata-(?:tip|href|link-label)="[^"]*"/gi, '')
266
+ const tipAttr = text ? ` data-tip="${escapeAttr(text)}"` : ''
267
+ const hrefAttr = href ? ` data-href="${escapeAttr(href)}"` : ''
268
+ const labelAttr = linkLabel ? ` data-link-label="${escapeAttr(linkLabel)}"` : ''
269
+ return `<span class="report-tiptap-citation" data-citation="${id}"${tipAttr}${hrefAttr}${labelAttr}${cleaned}>${inner}</span>`
270
+ }
271
+ )
272
+ }
273
+
274
+ const UNCLOSED_BLOCK_RE =
275
+ /<(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
276
+
277
+ export function stripThinkBlocks(source: string) {
278
+ return source
279
+ .replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '')
280
+ .replace(/<think\b[\s\S]*$/i, '')
281
+ }
282
+
283
+ const CJK_CHAR =
284
+ /[\u3400-\u9FFF\uF900-\uFAFF\u3000-\u303F\u3040-\u30FF\uAC00-\uD7AF]/
285
+
286
+ /** 補回可讀空格:空白規範化、中西交界(保留換行);英文分詞交由後端保證 */
287
+ export function normalizeReadableSpacing(source: string): string {
288
+ let text = String(source ?? '')
289
+ if (!text) return ''
290
+ text = text.replace(/\u00a0/g, ' ')
291
+ // 0914:空白規範化 + 中西交界(英文粘詞由後端輸出正常空格)
292
+ text = text.replace(/[^\S\n\r]+/g, ' ')
293
+ text = text.replace(
294
+ new RegExp(`(${CJK_CHAR.source})([A-Za-z0-9])`, 'g'),
295
+ '$1 $2'
296
+ )
297
+ text = text.replace(
298
+ new RegExp(`([A-Za-z0-9])(${CJK_CHAR.source})`, 'g'),
299
+ '$1 $2'
300
+ )
301
+ // 0914
302
+ return text
303
+ }
304
+
305
+ /** 是否像 Markdown(ATX 標題 / 列表 / 加粗),用於決定要不要拆 <p>/<br>/字面 \\n */
306
+ function looksLikeMarkdown(text: string) {
307
+ const probe = String(text || '')
308
+ .replace(/<(?:br)\s*\/?>/gi, '\n')
309
+ .replace(/<\/(?:p|div)\s*>/gi, '\n')
310
+ .replace(/<(?:p|div)\b[^>]*>/gi, '')
311
+ return /(?:^|\n)\s{0,3}#{1,6}\s|(?:^|\n)\s{0,3}(?:[-*+]|\d{1,9}[.)])\s|\*{3}[^*\n]+\*{3}|\*{2}[^*\n]+\*{2}/m.test(
312
+ probe
313
+ )
314
+ }
315
+
316
+ /**
317
+ * 接口正文常把 Markdown 塞进 <p>/<br> 或字面 \\n,
318
+ * marked 無法把行首 ### / * 認成標題與列表,畫面就會打出原始符號。
319
+ * 閉合 HTML 結構保留;僅在「像 Markdown」時拆開陷阱標籤。
320
+ */
321
+ function normalizeMarkdownSource(source: string) {
322
+ let text = String(source ?? '')
323
+ if (!text) return ''
324
+
325
+ const realNl = (text.match(/\r?\n/g) || []).length
326
+ const escapedNl = (text.match(/\\n/g) || []).length
327
+ if (escapedNl > 0 && escapedNl >= realNl) {
328
+ const unescaped = text.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\\t/g, '\t')
329
+ if (looksLikeMarkdown(unescaped)) text = unescaped
330
+ }
331
+
332
+ if (!looksLikeMarkdown(text)) {
333
+ return normalizeReadableSpacing(text)
334
+ }
335
+
336
+ if (/<br\s*\/?>/i.test(text)) {
337
+ text = text.replace(/<br\s*\/?>/gi, '\n')
338
+ }
339
+
340
+ if (/<(?:p|div)\b/i.test(text)) {
341
+ const unwrapped = text
342
+ .replace(/<\/(?:p|div)\s*>\s*<(?:p|div)\b[^>]*>/gi, '\n\n')
343
+ .replace(/<(?:p|div)\b[^>]*>/gi, '')
344
+ .replace(/<\/(?:p|div)\s*>/gi, '\n')
345
+ if (looksLikeMarkdown(unwrapped)) text = unwrapped
346
+ }
347
+
348
+ return normalizeReadableSpacing(text)
349
+ }
350
+
351
+ /**
352
+ * 在 source 上掃描,自動跳過 ```fence``` 與行內 `code`,
353
+ * 避免代碼裡的 ** / * / # 被當成未閉合 Markdown。
354
+ */
355
+ function scanMarkdownOutsideCode(
356
+ source: string,
357
+ visit: (i: number, ch: string) => number | void
358
+ ) {
359
+ let i = 0
360
+ let inFence = false
361
+ while (i < source.length) {
362
+ if (source.startsWith('```', i)) {
363
+ inFence = !inFence
364
+ i += 3
365
+ continue
366
+ }
367
+ if (inFence) {
368
+ i += 1
369
+ continue
370
+ }
371
+ if (source.charAt(i) === '`') {
372
+ let n = 0
373
+ while (source.charAt(i + n) === '`') n += 1
374
+ const open = source.slice(i, i + n)
375
+ const closeAt = source.indexOf(open, i + n)
376
+ if (closeAt < 0) {
377
+ // 半截行內 code:剩餘整段視為 code,不再掃描標記
378
+ return
379
+ }
380
+ i = closeAt + n
381
+ continue
382
+ }
383
+ const step = visit(i, source.charAt(i))
384
+ i += typeof step === 'number' && step > 0 ? step : 1
385
+ }
386
+ }
387
+
388
+ function lastUnpairedFence(source: string, fence: string) {
389
+ let odd = false
390
+ let last = -1
391
+ scanMarkdownOutsideCode(source, (i) => {
392
+ // 統計 ** 時整段跳過 ***,避免三星被拆成 ** + *
393
+ if (fence === '**' && source.startsWith('***', i)) return 3
394
+ if (fence === '__' && source.startsWith('___', i)) return 3
395
+ if (source.startsWith(fence, i)) {
396
+ odd = !odd
397
+ last = i
398
+ return fence.length
399
+ }
400
+ })
401
+ return odd ? last : -1
402
+ }
403
+
404
+ function lastUnpairedInlineTick(source: string) {
405
+ let odd = false
406
+ let last = -1
407
+ let i = 0
408
+ let inFence = false
409
+ while (i < source.length) {
410
+ if (source.startsWith('```', i)) {
411
+ inFence = !inFence
412
+ i += 3
413
+ continue
414
+ }
415
+ if (inFence) {
416
+ i += 1
417
+ continue
418
+ }
419
+ if (source.charAt(i) === '`') {
420
+ odd = !odd
421
+ last = i
422
+ }
423
+ i += 1
424
+ }
425
+ return odd ? last : -1
426
+ }
427
+
428
+ function lastUnpairedSingleStar(source: string) {
429
+ let odd = false
430
+ let last = -1
431
+ scanMarkdownOutsideCode(source, (i) => {
432
+ if (source.startsWith('***', i)) return 3
433
+ if (source.startsWith('**', i)) return 2
434
+ if (source.charAt(i) !== '*') return
435
+ const atLineStart = i === 0 || source.charAt(i - 1) === '\n'
436
+ const next = source.charAt(i + 1)
437
+ if (atLineStart && (next === ' ' || next === '\t')) return 1
438
+ odd = !odd
439
+ last = i
440
+ })
441
+ return odd ? last : -1
442
+ }
443
+
444
+ function lastUnpairedSingleUnderscore(source: string) {
445
+ let odd = false
446
+ let last = -1
447
+ scanMarkdownOutsideCode(source, (i) => {
448
+ if (source.startsWith('__', i)) return 2
449
+ if (source.charAt(i) !== '_') return
450
+ const prev = i === 0 ? ' ' : source.charAt(i - 1)
451
+ const next = source.charAt(i + 1)
452
+ if (/[A-Za-z0-9]/.test(prev) && /[A-Za-z0-9]/.test(next)) return 1
453
+ odd = !odd
454
+ last = i
455
+ })
456
+ return odd ? last : -1
457
+ }
458
+
459
+ /** 完整主題分隔線(HR),不應當半截標記 */
460
+ function isCompleteThematicBreak(text: string) {
461
+ return /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(text)
462
+ }
463
+
464
+ /** 打字机:切开已闭合前缀 vs 半截标记;半截内文继续打出,避免整段闪没 */
465
+ function splitStreamStablePrefix(source: string) {
466
+ const cuts: number[] = []
467
+ const lastLt = source.lastIndexOf('<')
468
+ const lastGt = source.lastIndexOf('>')
469
+ if (lastLt > lastGt && /[a-zA-Z/!]/.test(source.charAt(lastLt + 1) || '')) {
470
+ cuts.push(lastLt)
471
+ }
472
+ const block = source.match(UNCLOSED_BLOCK_RE)
473
+ if (block && block.index != null) cuts.push(block.index)
474
+ const linkOpen = source.search(/\[[^\]]*$|\[[^\]]*\]\([^)]*$/)
475
+ if (linkOpen >= 0) cuts.push(linkOpen)
476
+ const plusOpen = source.search(/\[\+(?:\d+)?$|\[\^\d*$/)
477
+ if (plusOpen >= 0) cuts.push(plusOpen)
478
+ for (const fence of ['```', '***', '**', '__', '~~'] as const) {
479
+ const at = lastUnpairedFence(source, fence)
480
+ if (at >= 0) cuts.push(at)
481
+ }
482
+ const tick = lastUnpairedInlineTick(source)
483
+ if (tick >= 0) cuts.push(tick)
484
+ const star = lastUnpairedSingleStar(source)
485
+ if (star >= 0) cuts.push(star)
486
+ const under = lastUnpairedSingleUnderscore(source)
487
+ if (under >= 0) cuts.push(under)
488
+ const mdBlock = lastIncompleteMarkdownCut(source)
489
+ if (mdBlock >= 0) cuts.push(mdBlock)
490
+ if (!cuts.length) return { stable: source, rest: '' }
491
+ const at = Math.min(...cuts)
492
+ const stable = source.slice(0, at)
493
+ const rest = source.slice(at)
494
+ // 誤切:rest 本身已是完整 HR,應併入 stable 交給 marked
495
+ if (rest && isCompleteThematicBreak(rest.trim())) {
496
+ return { stable: source, rest: '' }
497
+ }
498
+ return { stable, rest }
499
+ }
500
+
501
+ /** 半截标题 / 列表 / 引用 / 表格 / setext 先不交给 marked,避免结构来回跳 */
502
+ function lastIncompleteMarkdownCut(source: string) {
503
+ const cuts: number[] = []
504
+ const pushMarker = (re: RegExp) => {
505
+ const m = source.match(re)
506
+ if (!m || m.index == null) return
507
+ cuts.push(m[0].startsWith('\n') ? m.index + 1 : m.index)
508
+ }
509
+ // 僅標題符號、尚無正文
510
+ pushMarker(/(?:^|\n) {0,3}#{1,6}$/)
511
+ // ### 後只有空格、標題字還在打
512
+ pushMarker(/(?:^|\n) {0,3}#{1,6}[ \t]+$/)
513
+ pushMarker(/(?:^|\n) {0,3}(?:[-*+]|\d{1,9}[.)])$/)
514
+ pushMarker(/(?:^|\n) {0,3}>$/)
515
+ // 僅 1~2 個 -/_(等第 3 個才成 HR);完整 --- 不切
516
+ pushMarker(/(?:^|\n) {0,3}(?:-{1,2}|_{1,2})$/)
517
+ // setext 下劃線未完成(1~2);≥3 交給 marked 解析為標題/HR
518
+ const setext = source.match(/\n[ \t]*([-=])\1{0,1}$/)
519
+ if (setext && setext.index != null) cuts.push(setext.index + 1)
520
+ const table = incompleteTableCut(source)
521
+ if (table >= 0) cuts.push(table)
522
+ return cuts.length ? Math.min(...cuts) : -1
523
+ }
524
+
525
+ function incompleteTableCut(source: string) {
526
+ const lines = source.split('\n')
527
+ const lastLine = lines[lines.length - 1]
528
+ if (!lines.length || lastLine == null || !/^\s*\|/.test(lastLine)) return -1
529
+ let i = lines.length - 1
530
+ while (i > 0 && /^\s*\|/.test(lines[i - 1] ?? '')) i -= 1
531
+ const block = lines.slice(i)
532
+ const hasSep = block.some((line) => /^\s*\|?\s*:?-{2,}/.test(line))
533
+ const last = block[block.length - 1] || ''
534
+ const lastComplete = /\|.+\|/.test(last.trim())
535
+ if (hasSep && lastComplete && block.length >= 3) return -1
536
+ return i === 0 ? 0 : lines.slice(0, i).join('\n').length + 1
537
+ }
538
+
539
+ function htmlInnerPlain(rest: string) {
540
+ const gt = rest.indexOf('>')
541
+ if (gt < 0) return ''
542
+ let inner = rest.slice(gt + 1)
543
+ inner = inner.replace(/<\/[a-zA-Z][^>]*>/g, '').replace(/<[a-zA-Z][^>]*\/?>/g, '')
544
+ const lastLt = inner.lastIndexOf('<')
545
+ const lastGt = inner.lastIndexOf('>')
546
+ if (lastLt > lastGt) inner = inner.slice(0, lastLt)
547
+ return inner
548
+ }
549
+
550
+ /**
551
+ * 半截标记不把内文藏掉:`**xxx` / `<strong>xxx` 继续打字,只不画出 ** / 标签本身。
552
+ * 闭合后走正式解析变成粗体,与打字机进度同步、避免整段闪没。
553
+ */
554
+ function visiblePlainFromIncomplete(rest: string) {
555
+ if (!rest) return ''
556
+ const leadWs = rest.match(/^[ \t]*/)?.[0] ?? ''
557
+ const t = rest.slice(leadWs.length)
558
+ if (!t) return ''
559
+ if (isCompleteThematicBreak(t)) return ''
560
+ if (t.startsWith('```')) {
561
+ const nl = t.indexOf('\n')
562
+ return nl < 0 ? '' : leadWs + t.slice(nl + 1)
563
+ }
564
+ if (t.startsWith('***')) return leadWs + t.slice(3)
565
+ if (t.startsWith('**')) return leadWs + t.slice(2)
566
+ if (t.startsWith('__')) return leadWs + t.slice(2)
567
+ if (t.startsWith('~~')) return leadWs + t.slice(2)
568
+ if (/^#{1,6}$/.test(t)) return ''
569
+ if (/^#{1,6}[ \t]+$/.test(t)) return ''
570
+ if (/^#{1,6}[ \t]+/.test(t)) return leadWs + t.replace(/^#{1,6}[ \t]+/, '')
571
+ if (/^([-*+]|\d{1,9}[.)])$/.test(t)) return ''
572
+ if (/^([-*+]|\d{1,9}[.)]) /.test(t)) return leadWs + t.replace(/^([-*+]|\d{1,9}[.)]) /, '')
573
+ if (t === '>') return ''
574
+ if (/^\s*\|/.test(t)) return leadWs + t.replace(/^\s*\|/, '').replace(/\|/g, ' ')
575
+ if (t.startsWith('`')) return leadWs + t.slice(1).replace(/`/g, '')
576
+ if (t.startsWith('[') || t.startsWith('![')) {
577
+ const inner = t.replace(/^!?\[/, '')
578
+ const close = inner.indexOf(']')
579
+ return leadWs + (close < 0 ? inner : inner.slice(0, close))
580
+ }
581
+ if (t.startsWith('<')) return leadWs + htmlInnerPlain(t)
582
+ if (t.startsWith('*')) return leadWs + t.slice(1)
583
+ if (t.startsWith('_')) return leadWs + t.slice(1)
584
+ return leadWs + t
585
+ }
586
+
587
+ function appendPlainTail(html: string, rest: string) {
588
+ if (!rest) return html
589
+ const escaped = escapeHtml(rest).replace(/\n/g, '<br>')
590
+ if (!html) return `<p>${escaped}</p>`
591
+ // 表格 / 标题半截单独成段,避免塞进上一块后再整体跳结构
592
+ if (/^\s*\|/.test(rest.trimStart()) || /^\s*#{1,6}(?:\s|$)/.test(rest.trimStart())) {
593
+ return `${html}<p>${escaped}</p>`
594
+ }
595
+ const closeRe = /<\/(?:p|li|h[1-6]|td|th)\b[^>]*>/gi
596
+ let last = -1
597
+ let m: RegExpExecArray | null
598
+ while ((m = closeRe.exec(html))) last = m.index
599
+ if (last >= 0) return html.slice(0, last) + escaped + html.slice(last)
600
+ return `${html}<p>${escaped}</p>`
601
+ }
602
+
603
+ /** 将 Markdown / HTML / Element Plus 解析为 TipTap 可 setContent 的片段 */
604
+ export function parseMarkdownToHtml(source: string): string {
605
+ const text = normalizeMarkdownSource(String(source ?? ''))
606
+ if (!text.trim()) return ''
607
+ citationSeq = 0
608
+ try {
609
+ const withoutThink = stripThinkBlocks(text)
610
+ if (!withoutThink.trim()) return ''
611
+ const mdTips = applyMarkdownTips(withoutThink)
612
+ const protectedSrc = protectBlocks(mdTips.text)
613
+ const parsed = markedParser.parse(protectedSrc.text, { async: false })
614
+ let html = typeof parsed === 'string' ? parsed.trim() : ''
615
+ html = restoreBlocks(html, protectedSrc.slots)
616
+ html = applyHtmlTipsAndLinks(html)
617
+ html = applyElementPlusTipsAndLinks(html)
618
+ html = applyFootnoteTips(html, mdTips.footnotes)
619
+ return html
620
+ } catch {
621
+ return `<p>${escapeHtml(stripThinkBlocks(text))}</p>`
622
+ }
623
+ }
624
+
625
+ let streamStableCacheKey = ''
626
+ let streamStableCacheHtml = ''
627
+
628
+ /** 打字机逐帧:完整语法走解析;半截 ** / 标签不画,内文继续打出,打完再灌全文 */
629
+ export function parseMarkdownToHtmlStream(source: string): string {
630
+ const text = stripThinkBlocks(normalizeMarkdownSource(String(source ?? '')))
631
+ if (!text) return ''
632
+ const { stable, rest } = splitStreamStablePrefix(text)
633
+ if (!rest) {
634
+ const html = parseMarkdownToHtml(text)
635
+ streamStableCacheKey = text
636
+ streamStableCacheHtml = html
637
+ return html
638
+ }
639
+ let html = ''
640
+ if (stable) {
641
+ if (stable === streamStableCacheKey) html = streamStableCacheHtml
642
+ else {
643
+ html = parseMarkdownToHtml(stable)
644
+ streamStableCacheKey = stable
645
+ streamStableCacheHtml = html
646
+ }
647
+ }
648
+ return appendPlainTail(html, visiblePlainFromIncomplete(rest))
649
+ }