diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index fd984bb..45f52c9 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -353,22 +353,34 @@ const streamingHtml = ref('') let rafId: number | null = null let lastStreamText = '' -/// 切块:代码围栏(```...```)整体一块(跨双换行不切),非代码段按双换行切段。 +/// 切块:代码围栏(```...``` 或 3+ 反引号 + 行首可选空格 + info 字符串)整体一块 +/// (跨双换行不切),非代码段按双换行切段。 /// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。 +/// +/// CR-260615-04:旧实现用正则 /```[^\n]*\n[\s\S]*?(?:```|$)/ 切块,与 marked 围栏规则 +/// 不一致——不要求行首、固定 3 反引号,导致行中裸三反引号 / 4+ 反引号嵌套围栏切错, +/// 前块缓存固化错误 html。改用 marked.lexer()(经 getMarked() 取 marked 实例)做围栏 +/// 感知切块:lexer 把代码围栏识别为单个 type==='code' token(.raw 含整段围栏), +/// 段落/标题等其余 token 的 .raw 按 \n\n 切段。lexer 与 parse 用同一份 marked 实例 + +/// 同一份围栏规则,切块边界与渲染边界一致。 function splitBlocks(text: string): string[] { + const marked = getMarked() const blocks: string[] = [] - const fenceRe = /```[^\n]*\n[\s\S]*?(?:```|$)/g - let last = 0 - let m: RegExpExecArray | null - while ((m = fenceRe.exec(text)) !== null) { - if (m.index > last) { - for (const b of text.slice(last, m.index).split(/\n{2,}/)) if (b.trim()) blocks.push(b) + // marked 未就绪时 splitBlocks 不应被调用(renderStreamingMd 已 mdReady 守卫), + // 此处兜底返回原文,不引入正则。 + if (!marked) return [text] + const tokens = marked.lexer(text) + for (const tok of tokens) { + if (!tok || typeof (tok as { raw?: string }).raw !== 'string') continue + const raw = (tok as { raw: string }).raw + if ((tok as { type: string }).type === 'code') { + // 代码围栏 token:整段一块(含行首可选空格 + 3+ 反引号 + info 字符串) + if (raw.trim()) blocks.push(raw) + continue } - blocks.push(m[0]) - last = m.index + m[0].length - } - if (last < text.length) { - for (const b of text.slice(last).split(/\n{2,}/)) if (b.trim()) blocks.push(b) + // 非代码 token(paragraph/space/heading/list/blockquote/...): + // 按 \n\n 切段(段落内单换行不切,inline code 自然保留在段内) + for (const b of raw.split(/\n{2,}/)) if (b.trim()) blocks.push(b) } return blocks.length ? blocks : [text] } @@ -718,9 +730,16 @@ watch(() => store.state.currentText, onContentChange) // 流式 Markdown 渲染驱动(ARC-260615-08 块级 memo): // - currentText 变化(每 delta):rAF 节流 scheduleStreamParse,块级 memo 只重 parse 末块 // - streaming 翻转:结束清 rAF + 清 streamingHtml(回 renderMd 走 final marked + 缓存) +// - mdReady 翻转(CR-260615-06):首屏 marked 慢 + 暂无新 delta 时末段停留 escapeFallback +// 纯文本;marked 就绪后主动重算一次,使纯文本转格式化 watch(() => store.state.currentText, (text) => { if (store.state.streaming && text) scheduleStreamParse(text) }) +watch(mdReady, (ready) => { + if (ready && store.state.streaming && store.state.currentText) { + scheduleStreamParse(store.state.currentText) + } +}) watch(() => store.state.streaming, (s) => { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null } if (!s) { streamingHtml.value = ''; lastStreamText = '' } diff --git a/src/composables/useMarkdown.ts b/src/composables/useMarkdown.ts index 48df959..215c07d 100644 --- a/src/composables/useMarkdown.ts +++ b/src/composables/useMarkdown.ts @@ -17,7 +17,7 @@ //! scheduleStreamParse)继续调用 useMarkdown 暴露的 mdReady/escapeFallback, //! 渲染结果不变。 -import { ref } from 'vue' +import { computed, ref, type ComputedRef } from 'vue' // ═══ 模块级单例:AiChat 流式核心与历史 renderMd 共享同一份渲染器/缓存 ═══ let _marked: typeof import('marked')['marked'] | null = null @@ -26,17 +26,17 @@ const mdReady = ref(false) // 历史消息整段缓存(文本不变,重渲染命中省去 marked+sanitize 全量解析) const _mdCache = new Map() -export function useMarkdown() { - /** 异步加载 marked + DOMPurify;加载完成后 mdReady 翻转触发重渲染 */ - async function loadMarkdown() { - const [{ marked }, dp] = await Promise.all([import('marked'), import('dompurify')]) - marked.setOptions({ breaks: true, gfm: true }) - _marked = marked - _purify = dp.default - _mdCache.clear() // 渲染器就绪,清兜底缓存触发完整重渲染 - mdReady.value = true - } +/** 异步加载 marked + DOMPurify;加载完成后 mdReady 翻转触发重渲染(模块级,所有调用方共享) */ +async function loadMarkdown() { + const [{ marked }, dp] = await Promise.all([import('marked'), import('dompurify')]) + marked.setOptions({ breaks: true, gfm: true }) + _marked = marked + _purify = dp.default + _mdCache.clear() // 渲染器就绪,清兜底缓存触发完整重渲染 + mdReady.value = true +} +export function useMarkdown() { return { mdReady, loadMarkdown, @@ -50,6 +50,37 @@ export function useMarkdown() { } } +/** + * 详情页 Markdown 渲染辅助:封装 computed(读 mdReady 触发响应式 + renderMd(getText)) + * + ensureLoaded(幂等预热 loadMarkdown)。消除 TaskDetail/ProjectDetail/Ideas/Knowledge + * 四视图手写 computed + onMounted(loadMarkdown) 重复。 + * + * - getText: 返回待渲染文本(通常读响应式 source.value?.description ?? ''), + * 文本变化时 computed 重算。 + * - rendered: computed,mdReady 翻转/文本变化触发重渲染,行为与抽前一致。 + * - ensureLoaded():幂等触发后台预热,首次调用执行 loadMarkdown,后续 no-op。 + * + * 行为零变化:渲染/预热逻辑未改,仅 DRY 抽取。 + */ +export function useRendered(getText: () => string): { + rendered: ComputedRef + ensureLoaded: () => void +} { + const rendered = computed(() => { + void mdReady.value // 触发响应式依赖:mdReady 翻转时 computed 重算 + return renderMd(getText()) + }) + + let loaded = false + function ensureLoaded() { + if (loaded) return + loaded = true + loadMarkdown() + } + + return { rendered, ensureLoaded } +} + function escapeHtml(text: string): string { return text.replace(/&/g, '&').replace(//g, '>') } diff --git a/src/views/Ideas.vue b/src/views/Ideas.vue index ba6d283..0990692 100644 --- a/src/views/Ideas.vue +++ b/src/views/Ideas.vue @@ -214,19 +214,9 @@ import { useProjectStore } from '../stores/project' import { formatDate } from '../utils/time' import ConfirmDialog from '../components/ConfirmDialog.vue' import { useConfirm } from '../composables/useConfirm' -import { useMarkdown } from '../composables/useMarkdown' +import { useRendered } from '../composables/useMarkdown' import type { IdeaRecord } from '../api/types' -// B-260615-25:灵感描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例) -const { renderMd, loadMarkdown, mdReady } = useMarkdown() - -// 描述渲染依赖 mdReady 响应式:loadMarkdown 完成前 escapeFallback 纯文本,完成后 mdReady 翻转 -// 触发 computed 重算(B-25 漏响应式致 v-html=renderMd 首次纯文本后不重算,代码块不渲染) -const renderedDesc = computed(() => { - void mdReady.value - return renderMd(currentIdea.value?.description ?? '') -}) - const { t } = useI18n() const router = useRouter() const store = useProjectStore() @@ -295,6 +285,12 @@ const currentIdea = computed(() => { return store.ideas.find(i => i.id === selectedId.value) ?? null }) +// B-260615-25:灵感描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例), +// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热) +const { rendered: renderedDesc, ensureLoaded } = useRendered( + () => currentIdea.value?.description ?? '', +) + const currentStatus = computed(() => { return currentIdea.value?.status || 'draft' }) @@ -450,7 +446,7 @@ async function onStatusChange(e: Event) { } onMounted(async () => { - loadMarkdown() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞 + ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞 await store.loadIdeas() }) diff --git a/src/views/Knowledge.vue b/src/views/Knowledge.vue index 06bd804..edce7c5 100644 --- a/src/views/Knowledge.vue +++ b/src/views/Knowledge.vue @@ -235,22 +235,12 @@ import { ref, computed, onMounted, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '../stores/knowledge' -import { useMarkdown } from '../composables/useMarkdown' +import { useRendered } from '../composables/useMarkdown' import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '../api/types' const { t } = useI18n() const store = useKnowledgeStore() -// B-260615-25:知识内容 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例) -const { renderMd, loadMarkdown, mdReady } = useMarkdown() - -// 内容渲染依赖 mdReady 响应式:loadMarkdown 完成前 escapeFallback 纯文本,完成后 mdReady 翻转 -// 触发 computed 重算(B-25 漏响应式致 v-html=renderMd 首次纯文本后不重算,代码块不渲染) -const renderedContent = computed(() => { - void mdReady.value - return renderMd(detail.value?.knowledge.content ?? '') -}) - // 顶层 Tab: library(知识库) | inbox(审核收件箱) const topTab = ref<'library' | 'inbox'>('library') @@ -309,6 +299,11 @@ function getCategoryCount(key: string): number { // ===== 详情选中 + 加载 ===== const selectedId = ref(null) const detail = ref(null) +// B-260615-25:知识内容 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例), +// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热) +const { rendered: renderedContent, ensureLoaded } = useRendered( + () => detail.value?.knowledge.content ?? '', +) const refLimit = ref(10) async function selectKnowledge(id: string) { @@ -512,7 +507,7 @@ function relativeTime(millisStr: string): string { } onMounted(() => { - loadMarkdown() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞 + ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞 store.loadList() store.loadCandidates() }) diff --git a/src/views/ProjectDetail.vue b/src/views/ProjectDetail.vue index 590e5e9..1288cbb 100644 --- a/src/views/ProjectDetail.vue +++ b/src/views/ProjectDetail.vue @@ -241,17 +241,7 @@ import { parseStack } from '../utils/project' import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project' import ConfirmDialog from '../components/ConfirmDialog.vue' import { useConfirm } from '../composables/useConfirm' -import { useMarkdown } from '../composables/useMarkdown' - -// B-260615-25:项目描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例) -const { renderMd, loadMarkdown, mdReady } = useMarkdown() - -// 描述渲染依赖 mdReady 响应式:loadMarkdown 完成前 escapeFallback 纯文本,完成后 mdReady 翻转 -// 触发 computed 重算(B-25 漏响应式致 v-html=renderMd 首次纯文本后不重算,代码块不渲染) -const renderedDesc = computed(() => { - void mdReady.value - return renderMd(currentProject.value?.description ?? '') -}) +import { useRendered } from '../composables/useMarkdown' const route = useRoute() const router = useRouter() @@ -280,6 +270,11 @@ const projectId = computed(() => route.params.id as string) const currentProject = computed(() => store.projects.find(p => p.id === projectId.value) ) +// B-260615-25:项目描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例), +// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热) +const { rendered: renderedDesc, ensureLoaded: ensureMdLoaded } = useRendered( + () => currentProject.value?.description ?? '', +) const stageKey = computed(() => currentProject.value?.status ?? 'planning') const statusLabel = computed(() => projectStatusLabel(stageKey.value)) const currentStageIndex = computed(() => projectStageInfo(stageKey.value).stepIndex) @@ -462,7 +457,7 @@ watch(() => store.pendingApproval, (newApproval) => { // ── 生命周期 ── onMounted(async () => { - loadMarkdown() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞 + ensureMdLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞 await store.loadProjects() await store.loadTasks() // 全量加载,详情页用 computed 过滤本项目(避免覆盖全局 tasks 单例) unlisten = await store.startEventListener() diff --git a/src/views/TaskDetail.vue b/src/views/TaskDetail.vue index 10ff038..412c08e 100644 --- a/src/views/TaskDetail.vue +++ b/src/views/TaskDetail.vue @@ -99,7 +99,7 @@ import { ref, computed, onMounted, watch } from 'vue' import { useRoute } from 'vue-router' import { taskApi, projectApi } from '../api' import { formatDate } from '../utils/time' -import { useMarkdown } from '../composables/useMarkdown' +import { useRendered } from '../composables/useMarkdown' import { taskStatusLabel, taskStatusClass, @@ -108,16 +108,6 @@ import { } from '../constants/project' import type { TaskRecord, ProjectRecord } from '../api/types' -// B-24:任务描述 Markdown 渲染(复用 AiChat 同款渲染器,模块级单例) -const { renderMd, loadMarkdown, mdReady } = useMarkdown() - -// 描述渲染依赖 mdReady 响应式:loadMarkdown 完成前 escapeFallback 纯文本,完成后 mdReady 翻转 -// 触发 computed 重算(B-24 漏响应式致 v-html=renderMd 首次纯文本后不重算,代码块不渲染) -const renderedDescription = computed(() => { - void mdReady.value - return renderMd(task.value?.description ?? '') -}) - const route = useRoute() const loading = ref(true) @@ -127,6 +117,12 @@ const projects = ref([]) const taskId = computed(() => route.params.id as string) +// B-24:任务描述 Markdown 渲染(复用 AiChat 同款渲染器,模块级单例),useRendered 封装 +// computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热) +const { rendered: renderedDescription, ensureLoaded } = useRendered( + () => task.value?.description ?? '', +) + const projectName = computed(() => { const p = projects.value.find(p => p.id === task.value?.project_id) return p?.name ?? task.value?.project_id ?? '—' @@ -159,7 +155,7 @@ function refresh() { watch(taskId, () => { load() }) onMounted(() => { - loadMarkdown() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load + ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load load() })