feat: add message outline (#9090)

* feat: add message outline feature
This commit is contained in:
Teo 2025-08-13 14:57:58 +08:00 committed by GitHub
parent 0634baf780
commit ceef19e55b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 286 additions and 8 deletions

View File

@ -2960,6 +2960,7 @@
"none": "None"
},
"prompt": "Show prompt",
"show_message_outline": "Show message outline",
"title": "Message Settings",
"use_serif_font": "Use serif font"
},

View File

@ -2960,6 +2960,7 @@
"none": "表示しない"
},
"prompt": "プロンプト表示",
"show_message_outline": "メッセージの概要を表示します",
"title": "メッセージ設定",
"use_serif_font": "セリフフォントを使用"
},

View File

@ -2960,6 +2960,7 @@
"none": "Не показывать"
},
"prompt": "Показывать подсказки",
"show_message_outline": "Показать наброски сообщения",
"title": "Настройки сообщений",
"use_serif_font": "Использовать serif шрифт"
},

View File

@ -2960,6 +2960,7 @@
"none": "不显示"
},
"prompt": "显示提示词",
"show_message_outline": "显示消息大纲",
"title": "消息设置",
"use_serif_font": "使用衬线字体"
},

View File

@ -2960,6 +2960,7 @@
"none": "不顯示"
},
"prompt": "提示詞顯示",
"show_message_outline": "顯示消息大綱",
"title": "訊息設定",
"use_serif_font": "使用襯線字型"
},

View File

@ -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<Props> = ({ 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) => {

View File

@ -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<string, number>()
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
})
}
}

View File

@ -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<Props> = ({
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<HTMLDivElement>(null)
const { editingMessageId, stopEditing } = useMessageEditing()
@ -183,6 +184,9 @@ const MessageItem: FC<Props> = ({
)}
{!isEditing && (
<>
{!isMultiSelectMode && message.role === 'assistant' && showMessageOutline && (
<MessageOutline message={message} />
)}
<MessageContentContainer
className="message-content-container"
style={{

View File

@ -378,7 +378,7 @@ interface MessageWrapperProps {
const MessageWrapper = styled.div<MessageWrapperProps>`
&.horizontal {
padding-right: 1px;
padding: 1px;
overflow-y: auto;
.message {
height: 100%;

View File

@ -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<MessageOutlineProps> = ({ 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') {
// 匹配 <h1>...</h1> 到 <h6>...</h6>
const match = node.value.match(/<h([1-6])[^>]*>(.*?)<\/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<HTMLDivElement>(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 (
<MessageOutlineContainer ref={messageOutlineContainerRef}>
<MessageOutlineBody $count={headings.length}>
{headings.map((heading, index) => (
<MessageOutlineItem key={index} onClick={() => scrollToHeading(heading.id)}>
<MessageOutlineItemDot $level={heading.level} />
<MessageOutlineItemText $level={heading.level} $miniLevel={miniLevel}>
{heading.text}
</MessageOutlineItemText>
</MessageOutlineItem>
))}
</MessageOutlineBody>
</MessageOutlineContainer>
)
}
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)

View File

@ -38,6 +38,7 @@ import {
setPasteLongTextThreshold,
setRenderInputMessageAsMarkdown,
setShowInputEstimatedTokens,
setShowMessageOutline,
setShowPrompt,
setShowTranslateConfirm,
setThoughtAutoCollapse
@ -103,7 +104,8 @@ const SettingsTab: FC<Props> = (props) => {
messageNavigation,
enableQuickPanelTriggers,
enableBackspaceDeleteModel,
showTranslateConfirm
showTranslateConfirm,
showMessageOutline
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
@ -332,6 +334,15 @@ const SettingsTab: FC<Props> = (props) => {
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.show_message_outline')}</SettingRowTitleSmall>
<Switch
size="small"
checked={showMessageOutline}
onChange={(checked) => dispatch(setShowMessageOutline(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.style.label')}</SettingRowTitleSmall>
<Selector

View File

@ -217,6 +217,7 @@ export interface SettingsState {
navbarPosition: 'left' | 'top'
// API Server
apiServer: ApiServerConfig
showMessageOutline?: boolean
}
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
@ -404,7 +405,8 @@ export const initialState: SettingsState = {
host: 'localhost',
port: 23333,
apiKey: `cs-sk-${uuid()}`
}
},
showMessageOutline: undefined
}
const settingsSlice = createSlice({
@ -833,6 +835,9 @@ const settingsSlice = createSlice({
...state.apiServer,
apiKey: action.payload
}
},
setShowMessageOutline: (state, action: PayloadAction<boolean>) => {
state.showMessageOutline = action.payload
}
}
})
@ -958,6 +963,7 @@ export const {
setS3Partial,
setEnableDeveloperMode,
setNavbarPosition,
setShowMessageOutline,
// API Server actions
setApiServerEnabled,
setApiServerPort,