mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 03:10:08 +08:00
feat: enhance citation handling in message export functionality (#6422)
* feat: enhance citation handling in message export functionality - Refactored message export functions to include citation content in markdown output. - Introduced a new utility function to extract and format citations from messages. - Updated related imports and adjusted existing markdown generation logic for improved clarity and maintainability. * feat: enhance message export tests to include citation and reasoning content - Added tests to verify inclusion of citation content in markdown output when citation blocks exist. - Ensured proper formatting with double newlines between sections in exported markdown. - Updated existing tests to handle cases with reasoning content and no main text block gracefully. * fix: update citation mapping in export tests for consistency - Modified the citation mapping in export tests to use the index parameter directly, improving clarity and consistency in the generated markdown output.
This commit is contained in:
parent
08ba515241
commit
022a59b1bd
@ -81,7 +81,7 @@ const selectBlockEntityById = (state: RootState, blockId: string | undefined) =>
|
||||
blockId ? messageBlocksSelectors.selectById(state, blockId) : undefined // Use adapter selector
|
||||
|
||||
// --- Centralized Citation Formatting Logic ---
|
||||
const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Citation[] => {
|
||||
export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Citation[] => {
|
||||
if (!block) return []
|
||||
|
||||
let formattedCitations: Citation[] = []
|
||||
|
||||
@ -24,6 +24,15 @@ vi.mock('@renderer/utils/messageUtils/find', () => ({
|
||||
// Assuming content exists on ThinkingBlock
|
||||
// Need to cast block to access content if not on base type
|
||||
return (thinkingBlock as any)?.content || ''
|
||||
}),
|
||||
getCitationContent: vi.fn((message: Message & { _fullBlocks?: MessageBlock[] }) => {
|
||||
const citationBlocks = message._fullBlocks?.filter((b) => b.type === MessageBlockType.CITATION) || []
|
||||
// Return empty string if no citation blocks, otherwise mock citation content
|
||||
if (citationBlocks.length === 0) return ''
|
||||
// Mock citation format: [number] [url](title)
|
||||
return citationBlocks
|
||||
.map((_, index) => `[${index + 1}] [https://example${index + 1}.com](Example Citation ${index + 1})`)
|
||||
.join('\n\n')
|
||||
})
|
||||
}))
|
||||
|
||||
@ -198,6 +207,9 @@ describe('export', () => {
|
||||
const markdown = messageToMarkdown(msg!)
|
||||
expect(markdown).toContain('### 🧑💻 User')
|
||||
expect(markdown).toContain('hello user')
|
||||
// Should have double newlines between sections
|
||||
const sections = markdown.split('\n\n')
|
||||
expect(sections.length).toBeGreaterThanOrEqual(3) // title, content, citation (empty)
|
||||
})
|
||||
|
||||
it('should format assistant message using main text block', () => {
|
||||
@ -206,6 +218,9 @@ describe('export', () => {
|
||||
const markdown = messageToMarkdown(msg!)
|
||||
expect(markdown).toContain('### 🤖 Assistant')
|
||||
expect(markdown).toContain('hi assistant')
|
||||
// Should have double newlines between sections
|
||||
const sections = markdown.split('\n\n')
|
||||
expect(sections.length).toBeGreaterThanOrEqual(3) // title, content, citation (empty)
|
||||
})
|
||||
|
||||
it('should handle message with no main text block gracefully', () => {
|
||||
@ -213,7 +228,19 @@ describe('export', () => {
|
||||
mockedMessages.push(msg)
|
||||
const markdown = messageToMarkdown(msg)
|
||||
expect(markdown).toContain('### 🧑💻 User')
|
||||
expect(markdown.trim().endsWith('User')).toBe(true)
|
||||
// Check that it doesn't fail when no content exists
|
||||
expect(markdown).toBeDefined()
|
||||
})
|
||||
|
||||
it('should include citation content when citation blocks exist', () => {
|
||||
const msgWithCitation = createMessage({ role: 'assistant', id: 'a_cite' }, [
|
||||
{ type: MessageBlockType.MAIN_TEXT, content: 'Main content' },
|
||||
{ type: MessageBlockType.CITATION }
|
||||
])
|
||||
const markdown = messageToMarkdown(msgWithCitation)
|
||||
expect(markdown).toContain('### 🤖 Assistant')
|
||||
expect(markdown).toContain('Main content')
|
||||
expect(markdown).toContain('[1] [https://example1.com](Example Citation 1)')
|
||||
})
|
||||
})
|
||||
|
||||
@ -231,7 +258,12 @@ describe('export', () => {
|
||||
const msgWithoutReasoning = createMessage({ role: 'assistant', id: 'a4' }, [
|
||||
{ type: MessageBlockType.MAIN_TEXT, content: 'Simple Answer' }
|
||||
])
|
||||
mockedMessages = [msgWithReasoning, msgWithThinkTag, msgWithoutReasoning]
|
||||
const msgWithReasoningAndCitation = createMessage({ role: 'assistant', id: 'a5' }, [
|
||||
{ type: MessageBlockType.MAIN_TEXT, content: 'Answer with citation' },
|
||||
{ type: MessageBlockType.THINKING, content: 'Some thinking' },
|
||||
{ type: MessageBlockType.CITATION }
|
||||
])
|
||||
mockedMessages = [msgWithReasoning, msgWithThinkTag, msgWithoutReasoning, msgWithReasoningAndCitation]
|
||||
})
|
||||
|
||||
it('should include reasoning content from thinking block in details section', () => {
|
||||
@ -243,6 +275,9 @@ describe('export', () => {
|
||||
expect(markdown).toContain('<details')
|
||||
expect(markdown).toContain('<summary>common.reasoning_content</summary>')
|
||||
expect(markdown).toContain('Detailed thought process')
|
||||
// Should have double newlines between sections
|
||||
const sections = markdown.split('\n\n')
|
||||
expect(sections.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('should handle <think> tag and replace newlines with <br> in reasoning', () => {
|
||||
@ -263,6 +298,17 @@ describe('export', () => {
|
||||
expect(markdown).toContain('Simple Answer')
|
||||
expect(markdown).not.toContain('<details')
|
||||
})
|
||||
|
||||
it('should include both reasoning and citation content', () => {
|
||||
const msg = mockedMessages.find((m) => m.id === 'a5')
|
||||
expect(msg).toBeDefined()
|
||||
const markdown = messageToMarkdownWithReasoning(msg!)
|
||||
expect(markdown).toContain('### 🤖 Assistant')
|
||||
expect(markdown).toContain('Answer with citation')
|
||||
expect(markdown).toContain('<details')
|
||||
expect(markdown).toContain('Some thinking')
|
||||
expect(markdown).toContain('[1] [https://example1.com](Example Citation 1)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('messagesToMarkdown', () => {
|
||||
|
||||
@ -8,7 +8,7 @@ import type { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { removeSpecialCharactersForFileName } from '@renderer/utils/file'
|
||||
import { convertMathFormula } from '@renderer/utils/markdown'
|
||||
import { getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
|
||||
import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
|
||||
import { markdownToBlocks } from '@tryfabric/martian'
|
||||
import dayjs from 'dayjs'
|
||||
//TODO: 添加对思考内容的支持
|
||||
@ -43,48 +43,48 @@ export function getTitleFromString(str: string, length: number = 80) {
|
||||
return title
|
||||
}
|
||||
|
||||
export const messageToMarkdown = (message: Message) => {
|
||||
const createBaseMarkdown = (message: Message, includeReasoning: boolean = false) => {
|
||||
const { forceDollarMathInMarkdown } = store.getState().settings
|
||||
const roleText = message.role === 'user' ? '🧑💻 User' : '🤖 Assistant'
|
||||
const titleSection = `### ${roleText}`
|
||||
let reasoningSection = ''
|
||||
|
||||
if (includeReasoning) {
|
||||
let reasoningContent = getThinkingContent(message)
|
||||
if (reasoningContent) {
|
||||
if (reasoningContent.startsWith('<think>\n')) {
|
||||
reasoningContent = reasoningContent.substring(8)
|
||||
} else if (reasoningContent.startsWith('<think>')) {
|
||||
reasoningContent = reasoningContent.substring(7)
|
||||
}
|
||||
reasoningContent = reasoningContent.replace(/\n/g, '<br>')
|
||||
|
||||
if (forceDollarMathInMarkdown) {
|
||||
reasoningContent = convertMathFormula(reasoningContent)
|
||||
}
|
||||
reasoningSection = `<details style="background-color: #f5f5f5; padding: 5px; border-radius: 10px; margin-bottom: 10px;">
|
||||
<summary>${i18n.t('common.reasoning_content')}</summary><hr>
|
||||
${reasoningContent}
|
||||
</details>`
|
||||
}
|
||||
}
|
||||
|
||||
const content = getMainTextContent(message)
|
||||
const citation = getCitationContent(message)
|
||||
const contentSection = forceDollarMathInMarkdown ? convertMathFormula(content) : content
|
||||
|
||||
return [titleSection, '', contentSection].join('\n')
|
||||
return { titleSection, reasoningSection, contentSection, citation }
|
||||
}
|
||||
|
||||
export const messageToMarkdown = (message: Message) => {
|
||||
const { titleSection, contentSection, citation } = createBaseMarkdown(message)
|
||||
return [titleSection, '', contentSection, citation].join('\n\n')
|
||||
}
|
||||
|
||||
// 保留接口用于其它导出方法使用
|
||||
export const messageToMarkdownWithReasoning = (message: Message) => {
|
||||
const { forceDollarMathInMarkdown } = store.getState().settings
|
||||
const roleText = message.role === 'user' ? '🧑💻 User' : '🤖 Assistant'
|
||||
const titleSection = `### ${roleText}`
|
||||
let reasoningContent = getThinkingContent(message)
|
||||
// 处理思考内容
|
||||
let reasoningSection = ''
|
||||
if (reasoningContent) {
|
||||
// 移除开头的<think>标记和换行符,并将所有换行符替换为<br>
|
||||
if (reasoningContent.startsWith('<think>\n')) {
|
||||
reasoningContent = reasoningContent.substring(8)
|
||||
} else if (reasoningContent.startsWith('<think>')) {
|
||||
reasoningContent = reasoningContent.substring(7)
|
||||
}
|
||||
reasoningContent = reasoningContent.replace(/\n/g, '<br>')
|
||||
|
||||
// 应用数学公式转换(如果启用)
|
||||
if (forceDollarMathInMarkdown) {
|
||||
reasoningContent = convertMathFormula(reasoningContent)
|
||||
}
|
||||
// 添加思考内容的Markdown格式
|
||||
reasoningSection = `<details style="background-color: #f5f5f5; padding: 5px; border-radius: 10px; margin-bottom: 10px;">
|
||||
<summary>${i18n.t('common.reasoning_content')}</summary><hr>
|
||||
${reasoningContent}
|
||||
</details>`
|
||||
}
|
||||
const content = getMainTextContent(message)
|
||||
|
||||
const contentSection = forceDollarMathInMarkdown ? convertMathFormula(content) : content
|
||||
|
||||
return [titleSection, '', reasoningSection + contentSection].join('\n')
|
||||
const { titleSection, reasoningSection, contentSection, citation } = createBaseMarkdown(message, true)
|
||||
return [titleSection, '', reasoningSection + contentSection, citation].join('\n\n')
|
||||
}
|
||||
|
||||
export const messagesToMarkdown = (messages: Message[], exportReasoning?: boolean) => {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import store from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import { formatCitationsFromBlock, messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import { FileType } from '@renderer/types'
|
||||
import type {
|
||||
CitationMessageBlock,
|
||||
@ -128,6 +128,15 @@ export const getThinkingContent = (message: Message): string => {
|
||||
return thinkingBlocks.map((block) => block.content).join('\n\n')
|
||||
}
|
||||
|
||||
export const getCitationContent = (message: Message): string => {
|
||||
const citationBlocks = findCitationBlocks(message)
|
||||
return citationBlocks
|
||||
.map((block) => formatCitationsFromBlock(block))
|
||||
.flat()
|
||||
.map((citation) => `[${citation.number}] [${citation.url}](${citation.title || citation.url})`)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the knowledgeBaseIds array from the *first* MainTextMessageBlock of a message.
|
||||
* Note: Assumes knowledgeBaseIds are only relevant on the first text block, adjust if needed.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user