mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-23 18:10:26 +08:00
parent
0634baf780
commit
ceef19e55b
@ -2960,6 +2960,7 @@
|
||||
"none": "None"
|
||||
},
|
||||
"prompt": "Show prompt",
|
||||
"show_message_outline": "Show message outline",
|
||||
"title": "Message Settings",
|
||||
"use_serif_font": "Use serif font"
|
||||
},
|
||||
|
||||
@ -2960,6 +2960,7 @@
|
||||
"none": "表示しない"
|
||||
},
|
||||
"prompt": "プロンプト表示",
|
||||
"show_message_outline": "メッセージの概要を表示します",
|
||||
"title": "メッセージ設定",
|
||||
"use_serif_font": "セリフフォントを使用"
|
||||
},
|
||||
|
||||
@ -2960,6 +2960,7 @@
|
||||
"none": "Не показывать"
|
||||
},
|
||||
"prompt": "Показывать подсказки",
|
||||
"show_message_outline": "Показать наброски сообщения",
|
||||
"title": "Настройки сообщений",
|
||||
"use_serif_font": "Использовать serif шрифт"
|
||||
},
|
||||
|
||||
@ -2960,6 +2960,7 @@
|
||||
"none": "不显示"
|
||||
},
|
||||
"prompt": "显示提示词",
|
||||
"show_message_outline": "显示消息大纲",
|
||||
"title": "消息设置",
|
||||
"use_serif_font": "使用衬线字体"
|
||||
},
|
||||
|
||||
@ -2960,6 +2960,7 @@
|
||||
"none": "不顯示"
|
||||
},
|
||||
"prompt": "提示詞顯示",
|
||||
"show_message_outline": "顯示消息大綱",
|
||||
"title": "訊息設定",
|
||||
"use_serif_font": "使用襯線字型"
|
||||
},
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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={{
|
||||
|
||||
@ -378,7 +378,7 @@ interface MessageWrapperProps {
|
||||
|
||||
const MessageWrapper = styled.div<MessageWrapperProps>`
|
||||
&.horizontal {
|
||||
padding-right: 1px;
|
||||
padding: 1px;
|
||||
overflow-y: auto;
|
||||
.message {
|
||||
height: 100%;
|
||||
|
||||
180
src/renderer/src/pages/home/Messages/MessageOutline.tsx
Normal file
180
src/renderer/src/pages/home/Messages/MessageOutline.tsx
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user