diff --git a/frontend-web/src/pages/deep-research/WorkflowConfigDialog.tsx b/frontend-web/src/pages/deep-research/WorkflowConfigDialog.tsx index 09be8c0a..6af9c7bb 100644 --- a/frontend-web/src/pages/deep-research/WorkflowConfigDialog.tsx +++ b/frontend-web/src/pages/deep-research/WorkflowConfigDialog.tsx @@ -9,7 +9,7 @@ * 与 标题 筛选;卡片上可 switch 启停、编辑、删除(二次确认),并显示 * 最后修改时间与创建用户名;新增时不填状态(服务端强制默认启用)。 * - 详情视图:点击卡片即「选择」该结构(内容回填到高级配置的报告结构 - * custom_outline),弹窗切换为展示 标题 + 接口内容,右上角「返回」回列表。 + * custom_outline),弹窗切换为展示 标题 + Markdown 大纲预览,右上角「返回」回列表。 */ import { useEffect, useMemo, useState } from "react"; @@ -39,6 +39,8 @@ import { } from "lucide-react"; import { toast } from "sonner"; +import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; +import { reasoningPlugins } from "@/core/streamdown"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -102,16 +104,19 @@ function isSystemStructure(structure: ReportStructure): boolean { return creator === "系统内置" || creator.toLowerCase() === "system"; } -type OutlinePreviewLine = { - label?: string; - content: string; -}; - -type OutlinePreviewSection = { - number: string; - title: string; - lines: OutlinePreviewLine[]; -}; +const outlineMarkdownClassName = + "!h-auto !w-full max-w-none min-w-0 text-sm leading-7 text-neutral-700 dark:text-slate-300 " + + "[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-bold [&_h1]:tracking-tight [&_h1]:text-neutral-900 dark:[&_h1]:text-slate-100 " + + "[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:text-base [&_h2]:font-bold [&_h2]:tracking-tight [&_h2]:text-neutral-900 dark:[&_h2]:text-slate-100 " + + "[&_h3]:mt-4 [&_h3]:mb-1.5 [&_h3]:text-sm [&_h3]:font-semibold [&_h3]:text-neutral-900 dark:[&_h3]:text-slate-100 " + + "[&_h4]:mt-3 [&_h4]:mb-1 [&_h4]:text-sm [&_h4]:font-semibold " + + "[&_p]:my-2.5 [&_ul]:my-2.5 [&_ol]:my-2.5 [&_li]:my-1 " + + "[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " + + "[&_strong]:font-semibold [&_strong]:text-neutral-900 dark:[&_strong]:text-slate-100 " + + "[&_blockquote]:my-3 [&_blockquote]:border-l-4 [&_blockquote]:border-rose-200 [&_blockquote]:pl-4 [&_blockquote]:text-neutral-500 dark:[&_blockquote]:border-rose-900/50 dark:[&_blockquote]:text-slate-400 " + + "[&_code]:rounded [&_code]:border [&_code]:bg-neutral-100 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs dark:[&_code]:border-slate-700 dark:[&_code]:bg-slate-800 " + + "[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-neutral-50 [&_pre]:p-4 dark:[&_pre]:border-slate-700 dark:[&_pre]:bg-slate-800 " + + "[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0"; function cleanOutlineText(line: string): string { return line @@ -123,34 +128,6 @@ function cleanOutlineText(line: string): string { .trim(); } -function extractMajorSection(text: string, fallbackNumber: number): { number: string; title: string } | null { - const decimalSubsection = /^\d+\.\d+/.test(text); - if (decimalSubsection) return null; - - const arabic = text.match(/^(\d+)[..、]\s*(.+)$/); - if (arabic) return { number: arabic[1], title: arabic[2].trim() }; - - const chineseOrder = text.match(/^([一二三四五六七八九十]+)[..、]\s*(.+)$/); - if (chineseOrder) return { number: String(fallbackNumber), title: chineseOrder[2].trim() }; - - const chapter = text.match(/^第[一二三四五六七八九十百千万〇零\d]+[章节篇部][::\s]*(.+)$/); - if (chapter) return { number: String(fallbackNumber), title: chapter[1].trim() }; - - return null; -} - -function parseOutlineLine(text: string): OutlinePreviewLine { - const withoutSubsectionNumber = text.replace(/^\d+(?:\.\d+)+\s*/, "").trim(); - const colonIndex = withoutSubsectionNumber.search(/[::]/); - if (colonIndex > 0 && colonIndex <= 18) { - return { - label: withoutSubsectionNumber.slice(0, colonIndex).trim(), - content: withoutSubsectionNumber.slice(colonIndex + 1).trim(), - }; - } - return { content: withoutSubsectionNumber }; -} - function extractOutlineDocumentTitle(content: string, fallbackTitle: string): string { for (const rawLine of content.replace(/\r\n/g, "\n").split("\n")) { const raw = rawLine.trim(); @@ -160,7 +137,7 @@ function extractOutlineDocumentTitle(content: string, fallbackTitle: string): st const text = cleanOutlineText(heading ? heading[2] : raw); if (!text || text === "报告标题") continue; if (/^(日期|类型|密级)\s*[::]/.test(text)) continue; - if (extractMajorSection(text, 1) || /^\d+\.\d+/.test(text)) break; + if (/^\d+([..、]|\.\d+)/.test(text) || /^[一二三四五六七八九十]+[..、]/.test(text)) break; const wrapped = text.match(/^《(.+)》$/); if (wrapped?.[1]) return wrapped[1].trim(); @@ -171,58 +148,19 @@ function extractOutlineDocumentTitle(content: string, fallbackTitle: string): st return fallbackTitle; } -function isLeadingDocumentTitle(text: string): boolean { - return /^《.+》$/.test(text) || /报告/.test(text); -} - -function parseOutlinePreview(content: string, documentTitle: string, structureTitle: string): OutlinePreviewSection[] { - const sections: OutlinePreviewSection[] = []; - let current: OutlinePreviewSection | null = null; - let nextNumber = 1; - +function countOutlineMajorHeadings(content: string, skipTitles: string[]): number { + const skip = new Set(skipTitles.filter(Boolean)); + let h1 = 0; + let h2 = 0; for (const rawLine of content.replace(/\r\n/g, "\n").split("\n")) { - const raw = rawLine.trim(); - if (!raw) continue; - - const heading = raw.match(/^(#{1,6})\s+(.+)$/); - const headingLevel = heading ? heading[1].length : null; - const text = cleanOutlineText(heading ? heading[2] : raw); - if (!text) continue; - if (text === documentTitle || text === structureTitle || text === "报告标题") continue; - if (/^(日期|类型|密级)\s*[::]/.test(text)) continue; - - const major = extractMajorSection(text, nextNumber); - const isHeadingMajor = headingLevel !== null && headingLevel <= 2 && !/^\d+\.\d+/.test(text); - if (!current && sections.length === 0 && !major && isLeadingDocumentTitle(text)) continue; - - if (major || isHeadingMajor) { - current = { - number: major?.number ?? String(nextNumber), - title: major?.title ?? text, - lines: [], - }; - sections.push(current); - nextNumber += 1; - continue; - } - - const line = parseOutlineLine(text); - if (!current) { - current = { - number: String(nextNumber), - title: line.label ?? line.content, - lines: line.label ? [{ content: line.content }] : [], - }; - sections.push(current); - nextNumber += 1; - continue; - } - current.lines.push(line); + const heading = rawLine.trim().match(/^(#{1,2})\s+(.+)$/); + if (!heading) continue; + const text = cleanOutlineText(heading[2]); + if (!text || text === "报告标题" || skip.has(text)) continue; + if (heading[1].length === 1) h1 += 1; + else h2 += 1; } - - if (sections.length > 0) return sections; - const fallback = cleanOutlineText(content).slice(0, 600); - return [{ number: "1", title: documentTitle || structureTitle, lines: fallback ? [{ content: fallback }] : [] }]; + return h1 > 0 ? h1 : h2; } // ── main dialog ────────────────────────────────────────────────────────────── @@ -289,8 +227,8 @@ export function WorkflowConfigDialog({ () => (detail ? extractOutlineDocumentTitle(detail.content, detail.title) : ""), [detail], ); - const detailOutlineSections = useMemo( - () => (detail ? parseOutlinePreview(detail.content, detailOutlineTitle, detail.title) : []), + const detailOutlineHeadingCount = useMemo( + () => (detail ? countOutlineMajorHeadings(detail.content, [detailOutlineTitle, detail.title]) : 0), [detail, detailOutlineTitle], ); @@ -734,50 +672,24 @@ export function WorkflowConfigDialog({
暂无大纲内容
+ )}