From ceef19e55b243adf26ea9e9e529baa95a345cc00 Mon Sep 17 00:00:00 2001 From: Teo Date: Wed, 13 Aug 2025 14:57:58 +0800 Subject: [PATCH] feat: add message outline (#9090) * feat: add message outline feature --- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + .../src/pages/home/Markdown/Markdown.tsx | 10 +- .../home/Markdown/plugins/rehypeHeadingIds.ts | 70 +++++++ .../src/pages/home/Messages/Message.tsx | 6 +- .../src/pages/home/Messages/MessageGroup.tsx | 2 +- .../pages/home/Messages/MessageOutline.tsx | 180 ++++++++++++++++++ .../src/pages/home/Tabs/SettingsTab.tsx | 13 +- src/renderer/src/store/settings.ts | 8 +- 12 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/pages/home/Markdown/plugins/rehypeHeadingIds.ts create mode 100644 src/renderer/src/pages/home/Messages/MessageOutline.tsx diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index d1ec6e6b5c..b243bf7548 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2960,6 +2960,7 @@ "none": "None" }, "prompt": "Show prompt", + "show_message_outline": "Show message outline", "title": "Message Settings", "use_serif_font": "Use serif font" }, diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 94bf9b7faa..55229c8cdf 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -2960,6 +2960,7 @@ "none": "表示しない" }, "prompt": "プロンプト表示", + "show_message_outline": "メッセージの概要を表示します", "title": "メッセージ設定", "use_serif_font": "セリフフォントを使用" }, diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 3da62da559..9cff784fd9 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -2960,6 +2960,7 @@ "none": "Не показывать" }, "prompt": "Показывать подсказки", + "show_message_outline": "Показать наброски сообщения", "title": "Настройки сообщений", "use_serif_font": "Использовать serif шрифт" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index beac18d19e..81a7094f83 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2960,6 +2960,7 @@ "none": "不显示" }, "prompt": "显示提示词", + "show_message_outline": "显示消息大纲", "title": "消息设置", "use_serif_font": "使用衬线字体" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index e2f156a9d0..8af0f5477b 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2960,6 +2960,7 @@ "none": "不顯示" }, "prompt": "提示詞顯示", + "show_message_outline": "顯示消息大綱", "title": "訊息設定", "use_serif_font": "使用襯線字型" }, diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 0e3d0ef580..d0590b1496 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -29,6 +29,7 @@ import { Pluggable } from 'unified' import CodeBlock from './CodeBlock' import Link from './Link' +import rehypeHeadingIds from './plugins/rehypeHeadingIds' import remarkDisableConstructs from './plugins/remarkDisableConstructs' import Table from './Table' @@ -110,17 +111,18 @@ const Markdown: FC = ({ block, postProcess }) => { }, [block, displayedContent, t]) const rehypePlugins = useMemo(() => { - const plugins: any[] = [] + const plugins: Pluggable[] = [] if (ALLOWED_ELEMENTS.test(messageContent)) { plugins.push(rehypeRaw) } + plugins.push([rehypeHeadingIds, { prefix: `heading-${block.id}` }]) if (mathEngine === 'KaTeX') { - plugins.push(rehypeKatex as any) + plugins.push(rehypeKatex) } else if (mathEngine === 'MathJax') { - plugins.push(rehypeMathjax as any) + plugins.push(rehypeMathjax) } return plugins - }, [mathEngine, messageContent]) + }, [mathEngine, messageContent, block.id]) const onSaveCodeBlock = useCallback( (id: string, newContent: string) => { diff --git a/src/renderer/src/pages/home/Markdown/plugins/rehypeHeadingIds.ts b/src/renderer/src/pages/home/Markdown/plugins/rehypeHeadingIds.ts new file mode 100644 index 0000000000..e3e7e6db75 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/rehypeHeadingIds.ts @@ -0,0 +1,70 @@ +import type { Root, Node, Element, Text } from 'hast' +import { visit } from 'unist-util-visit' + +/** + * 基于 GitHub 风格的标题 slug 生成器(去重逻辑) + * - 小写 + * - 去除前后空白 + * - 移除部分标点 + * - 将空白与非字母数字字符合并为单个 '-' + * - 多次出现的相同 slug 加上递增后缀(-1, -2...) + */ +export function createSlugger() { + const seen = new Map() + const normalize = (text: string): string => { + const slug = (text || 'section') + .toLowerCase() + .trim() + // 移除常见分隔符和标点 + .replace(/[\u200B-\u200D\uFEFF]/g, '') // 零宽字符 + .replace(/["'`(){}[\]:;!?.,]/g, '') + // 将空白和非字母数字字符转换为 '-' + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') + // 合并多余的 '-' + .replace(/-{2,}/g, '-') + // 去除首尾 '-' + .replace(/^-|-$/g, '') + + return slug + } + + const slug = (text: string): string => { + const base = normalize(text) + const count = seen.get(base) || 0 + seen.set(base, count + 1) + return `${base}-${count}` + } + + return { slug } +} + +export function extractTextFromNode(node: Node | Text | Element | null | undefined): string { + if (!node) return '' + + if (typeof (node as Text).value === 'string') { + return (node as Text).value + } + + if ((node as Element).children?.length) { + return (node as Element).children.map(extractTextFromNode).join('') + } + + return '' +} + +export default function rehypeHeadingIds(options?: { prefix?: string }) { + return (tree: Root) => { + const slugger = createSlugger() + const prefix = options?.prefix ? `${options.prefix}--` : '' + visit(tree, 'element', (node) => { + if (!node || typeof node.tagName !== 'string') return + const tag = node.tagName.toLowerCase() + if (!/^h[1-6]$/.test(tag)) return + + const text = extractTextFromNode(node) + const id = prefix + slugger.slug(text) + node.properties = node.properties || {} + if (!node.properties.id) node.properties.id = id + }) + } +} diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index af69c18a4b..147a831f27 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -23,6 +23,7 @@ import MessageEditor from './MessageEditor' import MessageErrorBoundary from './MessageErrorBoundary' import MessageHeader from './MessageHeader' import MessageMenubar from './MessageMenubar' +import MessageOutline from './MessageOutline' interface Props { message: Message @@ -66,7 +67,7 @@ const MessageItem: FC = ({ const { assistant, setModel } = useAssistant(message.assistantId) const { isMultiSelectMode } = useChatContext(topic) const model = useModel(getMessageModelId(message), message.model?.provider) || message.model - const { messageFont, fontSize, messageStyle } = useSettings() + const { messageFont, fontSize, messageStyle, showMessageOutline } = useSettings() const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic) const messageContainerRef = useRef(null) const { editingMessageId, stopEditing } = useMessageEditing() @@ -183,6 +184,9 @@ const MessageItem: FC = ({ )} {!isEditing && ( <> + {!isMultiSelectMode && message.role === 'assistant' && showMessageOutline && ( + + )} ` &.horizontal { - padding-right: 1px; + padding: 1px; overflow-y: auto; .message { height: 100%; diff --git a/src/renderer/src/pages/home/Messages/MessageOutline.tsx b/src/renderer/src/pages/home/Messages/MessageOutline.tsx new file mode 100644 index 0000000000..1f967a2f65 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/MessageOutline.tsx @@ -0,0 +1,180 @@ +import Scrollbar from '@renderer/components/Scrollbar' +import { RootState } from '@renderer/store' +import { messageBlocksSelectors } from '@renderer/store/messageBlock' +import { Message, MessageBlockType } from '@renderer/types/newMessage' +import React, { FC, useMemo, useRef } from 'react' +import { useSelector } from 'react-redux' +import remarkParse from 'remark-parse' +import styled from 'styled-components' +import { unified } from 'unified' +import { visit } from 'unist-util-visit' + +import { createSlugger, extractTextFromNode } from '../Markdown/plugins/rehypeHeadingIds' + +interface MessageOutlineProps { + message: Message +} + +interface HeadingItem { + id: string + level: number + text: string +} + +const MessageOutline: FC = ({ message }) => { + const blockEntities = useSelector((state: RootState) => messageBlocksSelectors.selectEntities(state)) + + const headings: HeadingItem[] = useMemo(() => { + const mainTextBlocks = message.blocks + .map((blockId) => blockEntities[blockId]) + .filter((b) => b?.type === MessageBlockType.MAIN_TEXT) + + if (!mainTextBlocks?.length) return [] + + const result: HeadingItem[] = [] + mainTextBlocks.forEach((mainTextBlock) => { + const tree = unified().use(remarkParse).parse(mainTextBlock?.content) + const slugger = createSlugger() + visit(tree, ['heading', 'html'], (node) => { + if (node.type === 'heading') { + const level = node.depth ?? 0 + if (!level || level < 1 || level > 6) return + const text = extractTextFromNode(node) + if (!text) return + const id = `heading-${mainTextBlock.id}--` + slugger.slug(text || '') + result.push({ id, level, text: text }) + } else if (node.type === 'html') { + // 匹配

...

...
+ const match = node.value.match(/]*>(.*?)<\/h\1>/i) + if (match) { + const level = parseInt(match[1], 10) + const text = match[2].replace(/<[^>]*>/g, '').trim() // 移除内部的HTML标签 + if (text) { + const id = `heading-${mainTextBlock.id}--${slugger.slug(text || '')}` + result.push({ id, level, text }) + } + } + } + }) + }) + + return result + }, [message.blocks, blockEntities]) + + const miniLevel = useMemo(() => { + return headings.length ? Math.min(...headings.map((heading) => heading.level)) : 1 + }, [headings]) + + const messageOutlineContainerRef = useRef(null) + const scrollToHeading = (id: string) => { + const parent = messageOutlineContainerRef.current?.parentElement + const messageContentContainer = parent?.querySelector('.message-content-container') + if (messageContentContainer) { + const headingElement = messageContentContainer.querySelector(`#${id}`) + if (headingElement) { + const scrollBlock = ['horizontal', 'grid'].includes(message.multiModelMessageStyle ?? '') ? 'nearest' : 'start' + headingElement.scrollIntoView({ behavior: 'smooth', block: scrollBlock }) + } + } + } + + // 暂时不支持 grid,因为在锚点滚动时会导致渲染错位 + if (message.multiModelMessageStyle === 'grid' || !headings.length) return null + + return ( + + + {headings.map((heading, index) => ( + scrollToHeading(heading.id)}> + + + {heading.text} + + + ))} + + + ) +} + +const MessageOutlineContainer = styled.div` + position: absolute; + inset: 63px 0 36px 10px; + z-index: 999; + pointer-events: none; + & ~ .message-content-container { + padding-left: 46px !important; + } + & ~ .MessageFooter { + margin-left: 46px !important; + } +` + +const MessageOutlineItemDot = styled.div<{ $level: number }>` + width: ${({ $level }) => 16 - $level * 2}px; + height: 4px; + background: var(--color-border); + border-radius: 2px; + margin-right: 4px; + flex-shrink: 0; + transition: background 0.2s ease; +` + +const MessageOutlineItemText = styled.div<{ $level: number; $miniLevel: number }>` + overflow: hidden; + color: var(--color-text-3); + opacity: 0; + display: none; + transition: opacity 0.2s ease; + padding: 2px 8px; + padding-left: ${({ $level, $miniLevel }) => ($level - $miniLevel) * 8}px; + font-size: ${({ $level }) => 16 - $level}px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +` + +const MessageOutlineItem = styled.div` + height: 24px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + flex-shrink: 0; + &:hover { + ${MessageOutlineItemText} { + color: var(--color-text-2); + } + ${MessageOutlineItemDot} { + background: var(--color-text-3); + } + } +` + +const MessageOutlineBody = styled(Scrollbar)<{ $count: number }>` + max-width: 50%; + max-height: min(100%, 70vh); + position: sticky; + top: max(calc(50% - ${({ $count }) => ($count * 24) / 2 + 10}px), 20px); + bottom: 0; + overflow-x: hidden; + overflow-y: hidden; + display: inline-flex; + flex-direction: column; + padding: 10px 0 10px 10px; + gap: 4px; + border-radius: 10px; + pointer-events: auto; + &:hover { + padding: 10px 10px 10px 10px; + overflow-y: auto; + background: var(--color-background); + box-shadow: 0 0 10px 0 rgba(128, 128, 128, 0.2); + ${MessageOutlineItemText} { + opacity: 1; + display: block; + } + } +` + +export default React.memo(MessageOutline) diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index e609905411..b290c34754 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -38,6 +38,7 @@ import { setPasteLongTextThreshold, setRenderInputMessageAsMarkdown, setShowInputEstimatedTokens, + setShowMessageOutline, setShowPrompt, setShowTranslateConfirm, setThoughtAutoCollapse @@ -103,7 +104,8 @@ const SettingsTab: FC = (props) => { messageNavigation, enableQuickPanelTriggers, enableBackspaceDeleteModel, - showTranslateConfirm + showTranslateConfirm, + showMessageOutline } = useSettings() const onUpdateAssistantSettings = (settings: Partial) => { @@ -332,6 +334,15 @@ const SettingsTab: FC = (props) => { /> + + {t('settings.messages.show_message_outline')} + dispatch(setShowMessageOutline(checked))} + /> + + {t('message.message.style.label')} ) => { + state.showMessageOutline = action.payload } } }) @@ -958,6 +963,7 @@ export const { setS3Partial, setEnableDeveloperMode, setNavbarPosition, + setShowMessageOutline, // API Server actions setApiServerEnabled, setApiServerPort,