mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 14:41:24 +08:00
feat: add plain text copy functionality for messages and topics. 添加了复制纯文本的功能(去除Markdown格式符号) (#5965)
* feat: add plain text copy functionality for messages and topics. * refactor: move minapp settings to minapp page * fix: add success message after copying topic and message as text * fix: refactor test imports and add mocks for translation and window.message --------- Co-authored-by: Guscccc <Augustus.Li@outlook.com> Co-authored-by: kangfenmao <kangfenmao@qq.com> Co-authored-by: 自由的世界人 <3196812536@qq.com>
This commit is contained in:
parent
5c76d398c5
commit
7e54c465b1
@ -94,6 +94,7 @@
|
||||
"officeparser": "^4.1.1",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"remove-markdown": "^0.6.2",
|
||||
"selection-hook": "^0.9.23",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.0",
|
||||
|
||||
@ -261,6 +261,7 @@
|
||||
"topics.clear.title": "Clear Messages",
|
||||
"topics.copy.image": "Copy as image",
|
||||
"topics.copy.md": "Copy as markdown",
|
||||
"topics.copy.plain_text": "Copy as plain text (remove Markdown)",
|
||||
"topics.copy.title": "Copy",
|
||||
"topics.delete.shortcut": "Hold {{key}} to delete directly",
|
||||
"topics.edit.placeholder": "Enter new name",
|
||||
|
||||
@ -261,6 +261,7 @@
|
||||
"topics.clear.title": "メッセージをクリア",
|
||||
"topics.copy.image": "画像としてコピー",
|
||||
"topics.copy.md": "Markdownとしてコピー",
|
||||
"topics.copy.plain_text": "プレーンテキストとしてコピー(Markdownを除去)",
|
||||
"topics.copy.title": "コピー",
|
||||
"topics.delete.shortcut": "{{key}}キーを押しながらで直接削除",
|
||||
"topics.edit.placeholder": "新しい名前を入力",
|
||||
|
||||
@ -261,6 +261,7 @@
|
||||
"topics.clear.title": "Очистить сообщения",
|
||||
"topics.copy.image": "Скопировать как изображение",
|
||||
"topics.copy.md": "Скопировать как Markdown",
|
||||
"topics.copy.plain_text": "Копировать как обычный текст (удалить Markdown)",
|
||||
"topics.copy.title": "Скопировать",
|
||||
"topics.delete.shortcut": "Удерживайте {{key}} для мгновенного удаления",
|
||||
"topics.edit.placeholder": "Введите новый заголовок",
|
||||
|
||||
@ -279,6 +279,7 @@
|
||||
"topics.clear.title": "清空消息",
|
||||
"topics.copy.image": "复制为图片",
|
||||
"topics.copy.md": "复制为 Markdown",
|
||||
"topics.copy.plain_text": "复制为纯文本(去除 Markdown)",
|
||||
"topics.copy.title": "复制",
|
||||
"topics.delete.shortcut": "按住 {{key}} 可直接删除",
|
||||
"topics.edit.placeholder": "输入新名称",
|
||||
|
||||
@ -261,6 +261,7 @@
|
||||
"topics.clear.title": "清空訊息",
|
||||
"topics.copy.image": "複製為圖片",
|
||||
"topics.copy.md": "複製為 Markdown",
|
||||
"topics.copy.plain_text": "複製為純文字(移除 Markdown)",
|
||||
"topics.copy.title": "複製",
|
||||
"topics.delete.shortcut": "按住 {{key}} 可直接刪除",
|
||||
"topics.edit.placeholder": "輸入新名稱",
|
||||
|
||||
@ -198,6 +198,7 @@
|
||||
"topics.clear.title": "Καθαρισμός μηνυμάτων",
|
||||
"topics.copy.image": "Αντιγραφή ως εικόνα",
|
||||
"topics.copy.md": "Αντιγραφή ως Markdown",
|
||||
"topics.copy.plain_text": "Αντιγραφή ως απλό κείμενο (αφαίρεση Markdown)",
|
||||
"topics.copy.title": "Αντιγραφή",
|
||||
"topics.delete.shortcut": "Πατήστε {{key}} για να διαγράψετε αμέσως",
|
||||
"topics.edit.placeholder": "Εισαγάγετε το νέο όνομα",
|
||||
|
||||
@ -199,6 +199,7 @@
|
||||
"topics.clear.title": "Limpiar mensajes",
|
||||
"topics.copy.image": "Copiar como imagen",
|
||||
"topics.copy.md": "Copiar como Markdown",
|
||||
"topics.copy.plain_text": "Copiar como texto sin formato (eliminar Markdown)",
|
||||
"topics.copy.title": "Copiar",
|
||||
"topics.delete.shortcut": "Mantén presionada {{key}} para eliminar directamente",
|
||||
"topics.edit.placeholder": "Introduce nuevo nombre",
|
||||
|
||||
@ -198,6 +198,7 @@
|
||||
"topics.clear.title": "Effacer le message",
|
||||
"topics.copy.image": "Copier sous forme d'image",
|
||||
"topics.copy.md": "Copier sous forme de Markdown",
|
||||
"topics.copy.plain_text": "Copier en tant que texte brut (supprimer Markdown)",
|
||||
"topics.copy.title": "Copier",
|
||||
"topics.delete.shortcut": "Maintenez {{key}} pour supprimer directement",
|
||||
"topics.edit.placeholder": "Entrez un nouveau nom",
|
||||
|
||||
@ -199,6 +199,7 @@
|
||||
"topics.clear.title": "Limpar mensagens",
|
||||
"topics.copy.image": "Copiar como imagem",
|
||||
"topics.copy.md": "Copiar como Markdown",
|
||||
"topics.copy.plain_text": "Copiar como texto simples (remover Markdown)",
|
||||
"topics.copy.title": "Copiar",
|
||||
"topics.delete.shortcut": "Pressione {{key}} para deletar diretamente",
|
||||
"topics.edit.placeholder": "Digite novo nome",
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
exportMessageToNotion,
|
||||
messageToMarkdown
|
||||
} from '@renderer/utils/export'
|
||||
import { copyMessageAsPlainText } from '@renderer/utils/copy'
|
||||
// import { withMessageThought } from '@renderer/utils/formats'
|
||||
import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown'
|
||||
import { findMainTextBlocks, findTranslationBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
@ -201,6 +202,11 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
key: 'export',
|
||||
icon: <Share size={16} color="var(--color-icon)" style={{ marginTop: 3 }} />,
|
||||
children: [
|
||||
{
|
||||
label: t('chat.topics.copy.plain_text'),
|
||||
key: 'copy_message_plain_text',
|
||||
onClick: () => copyMessageAsPlainText(message)
|
||||
},
|
||||
exportMenuOptions.image && {
|
||||
label: t('chat.topics.copy.image'),
|
||||
key: 'img',
|
||||
|
||||
@ -26,7 +26,7 @@ import { RootState } from '@renderer/store'
|
||||
import { setGenerating } from '@renderer/store/runtime'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { removeSpecialCharactersForFileName } from '@renderer/utils'
|
||||
import { copyTopicAsMarkdown } from '@renderer/utils/copy'
|
||||
import { copyTopicAsMarkdown, copyTopicAsPlainText } from '@renderer/utils/copy'
|
||||
import {
|
||||
exportMarkdownToJoplin,
|
||||
exportMarkdownToSiyuan,
|
||||
@ -280,6 +280,11 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
label: t('chat.topics.copy.md'),
|
||||
key: 'md',
|
||||
onClick: () => copyTopicAsMarkdown(topic)
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.copy.plain_text'),
|
||||
key: 'plain_text',
|
||||
onClick: () => copyTopicAsPlainText(topic)
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -705,3 +705,4 @@ export interface StoreSyncAction {
|
||||
|
||||
export type OpenAISummaryText = 'auto' | 'concise' | 'detailed' | 'off'
|
||||
export type OpenAIServiceTier = 'auto' | 'default' | 'flex'
|
||||
export type { Message } from './newMessage'
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Import Message, MessageBlock, and necessary enums
|
||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// --- Mocks Setup ---
|
||||
|
||||
@ -36,8 +36,36 @@ vi.mock('@renderer/utils/messageUtils/find', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/databases', () => ({
|
||||
default: {
|
||||
topics: {
|
||||
get: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/markdown', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as any),
|
||||
markdownToPlainText: vi.fn((str) => str) // Simple pass-through for testing export logic
|
||||
}
|
||||
})
|
||||
|
||||
// Import the functions to test AFTER setting up mocks
|
||||
import { getTitleFromString, messagesToMarkdown, messageToMarkdown, messageToMarkdownWithReasoning } from '../export'
|
||||
import db from '@renderer/databases'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { markdownToPlainText } from '@renderer/utils/markdown'
|
||||
|
||||
import { copyMessageAsPlainText } from '../copy'
|
||||
import {
|
||||
getTitleFromString,
|
||||
messagesToMarkdown,
|
||||
messageToMarkdown,
|
||||
messageToMarkdownWithReasoning,
|
||||
messageToPlainText,
|
||||
topicToPlainText
|
||||
} from '../export'
|
||||
|
||||
// --- Helper Functions for Test Data ---
|
||||
|
||||
@ -135,6 +163,13 @@ beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Mock i18next translation function
|
||||
vi.mock('i18next', () => ({
|
||||
default: {
|
||||
t: vi.fn((key) => key)
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock store - primarily for settings
|
||||
vi.doMock('@renderer/store', () => ({
|
||||
default: {
|
||||
@ -344,4 +379,210 @@ describe('export', () => {
|
||||
expect(markdown.split('\n\n---\n\n').length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMessageAsPlainText (via topicToPlainText)', () => {
|
||||
it('should format user and assistant messages correctly to plain text with roles', async () => {
|
||||
const userMsg = createMessage({ role: 'user', id: 'u_plain_formatted' }, [
|
||||
{ type: MessageBlockType.MAIN_TEXT, content: '# User Content Formatted' }
|
||||
])
|
||||
const assistantMsg = createMessage({ role: 'assistant', id: 'a_plain_formatted' }, [
|
||||
{ type: MessageBlockType.MAIN_TEXT, content: '*Assistant Content Formatted*' }
|
||||
])
|
||||
const testTopic: Topic = {
|
||||
id: 't_plain_formatted',
|
||||
name: 'Formatted Plain Topic',
|
||||
assistantId: 'asst_test_formatted',
|
||||
messages: [userMsg, assistantMsg] as any,
|
||||
createdAt: '',
|
||||
updatedAt: ''
|
||||
}
|
||||
;(db.topics.get as any).mockResolvedValue({ messages: [userMsg, assistantMsg] })
|
||||
// Specific mock for this test to check formatting
|
||||
;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*]/g, ''))
|
||||
|
||||
const plainText = await topicToPlainText(testTopic)
|
||||
|
||||
expect(plainText).toContain('User:\nUser Content Formatted')
|
||||
expect(plainText).toContain('Assistant:\nAssistant Content Formatted')
|
||||
expect(markdownToPlainText).toHaveBeenCalledWith('# User Content Formatted')
|
||||
expect(markdownToPlainText).toHaveBeenCalledWith('*Assistant Content Formatted*')
|
||||
expect(markdownToPlainText).toHaveBeenCalledWith('Formatted Plain Topic')
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageToPlainText', () => {
|
||||
it('should convert a single message content to plain text without role prefix', () => {
|
||||
const testMessage = createMessage({ role: 'user', id: 'single_msg_plain' }, [
|
||||
{ type: MessageBlockType.MAIN_TEXT, content: '### Single Message Content' }
|
||||
])
|
||||
;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, ''))
|
||||
|
||||
const result = messageToPlainText(testMessage)
|
||||
expect(result).toBe('Single Message Content')
|
||||
expect(markdownToPlainText).toHaveBeenCalledWith('### Single Message Content')
|
||||
})
|
||||
|
||||
it('should return empty string for message with no main text', () => {
|
||||
const testMessage = createMessage({ role: 'user', id: 'empty_msg_plain' }, [])
|
||||
;(markdownToPlainText as any).mockReturnValue('') // Mock to return empty for empty input
|
||||
|
||||
const result = messageToPlainText(testMessage)
|
||||
expect(result).toBe('')
|
||||
expect(markdownToPlainText).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('messagesToPlainText (via topicToPlainText)', () => {
|
||||
it('should join multiple formatted plain text messages with double newlines', async () => {
|
||||
const msg1 = createMessage({ role: 'user', id: 'm_plain1_formatted' }, [
|
||||
{ type: MessageBlockType.MAIN_TEXT, content: 'Msg1 Formatted' }
|
||||
])
|
||||
const msg2 = createMessage({ role: 'assistant', id: 'm_plain2_formatted' }, [
|
||||
{ type: MessageBlockType.MAIN_TEXT, content: 'Msg2 Formatted' }
|
||||
])
|
||||
const testTopic: Topic = {
|
||||
id: 't_multi_plain_formatted',
|
||||
name: 'Multi Plain Formatted',
|
||||
assistantId: 'asst_test_multi_formatted',
|
||||
messages: [msg1, msg2] as any,
|
||||
createdAt: '',
|
||||
updatedAt: ''
|
||||
}
|
||||
;(db.topics.get as any).mockResolvedValue({ messages: [msg1, msg2] })
|
||||
;(markdownToPlainText as any).mockImplementation((str) => str) // Pass-through
|
||||
|
||||
const plainText = await topicToPlainText(testTopic)
|
||||
expect(plainText).toBe('Multi Plain Formatted\n\nUser:\nMsg1 Formatted\n\nAssistant:\nMsg2 Formatted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('topicToPlainText', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks() // Clear mocks before each test in this suite
|
||||
// Mock store for settings if not already done globally or if specific settings are needed
|
||||
vi.doMock('@renderer/store', () => ({
|
||||
default: {
|
||||
getState: () => ({
|
||||
settings: { forceDollarMathInMarkdown: false } // Default or specific settings
|
||||
})
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
it('should return plain text for a topic with messages', async () => {
|
||||
const msg1 = createMessage({ role: 'user', id: 'tp_u1' }, [
|
||||
{ type: MessageBlockType.MAIN_TEXT, content: '**Hello**' }
|
||||
])
|
||||
const msg2 = createMessage({ role: 'assistant', id: 'tp_a1' }, [
|
||||
{ type: MessageBlockType.MAIN_TEXT, content: '_World_' }
|
||||
])
|
||||
const testTopic: Topic = {
|
||||
id: 'topic1_plain',
|
||||
name: '# Topic One',
|
||||
assistantId: 'asst_test',
|
||||
messages: [msg1, msg2] as any,
|
||||
createdAt: '',
|
||||
updatedAt: ''
|
||||
}
|
||||
;(db.topics.get as any).mockResolvedValue({ messages: [msg1, msg2] })
|
||||
;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, ''))
|
||||
|
||||
const result = await topicToPlainText(testTopic)
|
||||
expect(db.topics.get).toHaveBeenCalledWith('topic1_plain')
|
||||
expect(markdownToPlainText).toHaveBeenCalledWith('# Topic One')
|
||||
expect(markdownToPlainText).toHaveBeenCalledWith('**Hello**')
|
||||
expect(markdownToPlainText).toHaveBeenCalledWith('_World_')
|
||||
expect(result).toBe('Topic One\n\nUser:\nHello\n\nAssistant:\nWorld')
|
||||
})
|
||||
|
||||
it('should return only topic name if topic has no messages', async () => {
|
||||
const testTopic: Topic = {
|
||||
id: 'topic_empty_plain',
|
||||
name: '## Empty Topic',
|
||||
assistantId: 'asst_test',
|
||||
messages: [] as any,
|
||||
createdAt: '',
|
||||
updatedAt: ''
|
||||
}
|
||||
;(db.topics.get as any).mockResolvedValue({ messages: [] })
|
||||
;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, ''))
|
||||
|
||||
const result = await topicToPlainText(testTopic)
|
||||
expect(result).toBe('Empty Topic')
|
||||
expect(markdownToPlainText).toHaveBeenCalledWith('## Empty Topic')
|
||||
})
|
||||
|
||||
it('should return empty string if topicMessages is null', async () => {
|
||||
const testTopic: Topic = {
|
||||
id: 'topic_null_msgs_plain',
|
||||
name: 'Null Messages Topic',
|
||||
assistantId: 'asst_test',
|
||||
messages: null as any,
|
||||
createdAt: '',
|
||||
updatedAt: ''
|
||||
}
|
||||
;(db.topics.get as any).mockResolvedValue(null)
|
||||
|
||||
const result = await topicToPlainText(testTopic)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyMessageAsPlainText', () => {
|
||||
// Mock navigator.clipboard.writeText
|
||||
const writeTextMock = vi.fn()
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('navigator', {
|
||||
clipboard: {
|
||||
writeText: writeTextMock
|
||||
}
|
||||
})
|
||||
|
||||
// Mock window.message methods
|
||||
vi.stubGlobal('window', {
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
// Mock i18next translation function
|
||||
vi.mock('i18next', () => ({
|
||||
default: {
|
||||
t: vi.fn((key) => key)
|
||||
}
|
||||
}))
|
||||
|
||||
writeTextMock.mockReset()
|
||||
// Ensure markdownToPlainText mock is set
|
||||
;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, ''))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should call messageToPlainText and copy its result to clipboard', async () => {
|
||||
const testMessage = createMessage({ role: 'user', id: 'copy_msg_plain' }, [
|
||||
{ type: MessageBlockType.MAIN_TEXT, content: '**Copy This Plain**' }
|
||||
])
|
||||
|
||||
await copyMessageAsPlainText(testMessage)
|
||||
|
||||
expect(markdownToPlainText).toHaveBeenCalledWith('**Copy This Plain**')
|
||||
expect(writeTextMock).toHaveBeenCalledWith('Copy This Plain')
|
||||
})
|
||||
|
||||
it('should handle empty message content', async () => {
|
||||
const testMessage = createMessage({ role: 'user', id: 'copy_empty_msg_plain' }, [])
|
||||
;(markdownToPlainText as any).mockReturnValue('')
|
||||
|
||||
await copyMessageAsPlainText(testMessage)
|
||||
|
||||
expect(markdownToPlainText).toHaveBeenCalledWith('')
|
||||
expect(writeTextMock).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,7 +8,8 @@ import {
|
||||
findCitationInChildren,
|
||||
getCodeBlockId,
|
||||
removeTrailingDoubleSpaces,
|
||||
updateCodeBlock
|
||||
updateCodeBlock,
|
||||
markdownToPlainText
|
||||
} from '../markdown'
|
||||
|
||||
describe('markdown', () => {
|
||||
@ -184,7 +185,7 @@ describe('markdown', () => {
|
||||
// function getAllCodeBlockIds(markdown: string): { [content: string]: string } {
|
||||
// const result: { [content: string]: string } = {}
|
||||
// const tree = unified().use(remarkParse).parse(markdown)
|
||||
|
||||
//
|
||||
// visit(tree, 'code', (node) => {
|
||||
// const id = getCodeBlockId(node.position?.start)
|
||||
// if (id) {
|
||||
@ -192,7 +193,7 @@ describe('markdown', () => {
|
||||
// console.log(`Code Block ID: "${id}" for content: "${node.value}" lang: "${node.lang}"`)
|
||||
// }
|
||||
// })
|
||||
|
||||
//
|
||||
// return result
|
||||
// }
|
||||
|
||||
@ -323,4 +324,79 @@ describe('markdown', () => {
|
||||
expect(result).toBe(expectedResult)
|
||||
})
|
||||
})
|
||||
|
||||
describe('markdownToPlainText', () => {
|
||||
it('should return an empty string if input is null or empty', () => {
|
||||
expect(markdownToPlainText(null as any)).toBe('')
|
||||
expect(markdownToPlainText('')).toBe('')
|
||||
})
|
||||
|
||||
it('should remove headers', () => {
|
||||
expect(markdownToPlainText('# Header 1')).toBe('Header 1')
|
||||
expect(markdownToPlainText('## Header 2')).toBe('Header 2')
|
||||
expect(markdownToPlainText('### Header 3')).toBe('Header 3')
|
||||
})
|
||||
|
||||
it('should remove bold and italic', () => {
|
||||
expect(markdownToPlainText('**bold**')).toBe('bold')
|
||||
expect(markdownToPlainText('*italic*')).toBe('italic')
|
||||
expect(markdownToPlainText('***bolditalic***')).toBe('bolditalic')
|
||||
expect(markdownToPlainText('__bold__')).toBe('bold')
|
||||
expect(markdownToPlainText('_italic_')).toBe('italic')
|
||||
expect(markdownToPlainText('___bolditalic___')).toBe('bolditalic')
|
||||
})
|
||||
|
||||
it('should remove strikethrough', () => {
|
||||
expect(markdownToPlainText('~~strikethrough~~')).toBe('strikethrough')
|
||||
})
|
||||
|
||||
it('should remove links, keeping the text', () => {
|
||||
expect(markdownToPlainText('[link text](http://example.com)')).toBe('link text')
|
||||
expect(markdownToPlainText('[link text with title](http://example.com "title")')).toBe('link text with title')
|
||||
})
|
||||
|
||||
it('should remove images, keeping the alt text', () => {
|
||||
expect(markdownToPlainText('')).toBe('alt text')
|
||||
})
|
||||
|
||||
it('should remove inline code', () => {
|
||||
expect(markdownToPlainText('`inline code`')).toBe('inline code')
|
||||
})
|
||||
|
||||
it('should remove code blocks', () => {
|
||||
const codeBlock = '```javascript\nconst x = 1;\n```'
|
||||
expect(markdownToPlainText(codeBlock)).toBe('const x = 1;') // remove-markdown keeps code content
|
||||
})
|
||||
|
||||
it('should remove blockquotes', () => {
|
||||
expect(markdownToPlainText('> blockquote')).toBe('blockquote')
|
||||
})
|
||||
|
||||
it('should remove unordered lists', () => {
|
||||
const list = '* item 1\n* item 2'
|
||||
expect(markdownToPlainText(list).replace(/\n+/g, ' ')).toBe('item 1 item 2')
|
||||
})
|
||||
|
||||
it('should remove ordered lists', () => {
|
||||
const list = '1. item 1\n2. item 2'
|
||||
expect(markdownToPlainText(list).replace(/\n+/g, ' ')).toBe('item 1 item 2')
|
||||
})
|
||||
|
||||
it('should remove horizontal rules', () => {
|
||||
expect(markdownToPlainText('---')).toBe('')
|
||||
expect(markdownToPlainText('***')).toBe('')
|
||||
expect(markdownToPlainText('___')).toBe('')
|
||||
})
|
||||
|
||||
it('should handle a mix of markdown elements', () => {
|
||||
const mixed = '# Title\nSome **bold** and *italic* text.\n[link](url)\n`code`\n> quote\n* list item'
|
||||
const expected = 'Title\nSome bold and italic text.\nlink\ncode\nquote\nlist item'
|
||||
const normalize = (str: string) => str.replace(/\s+/g, ' ').trim()
|
||||
expect(normalize(markdownToPlainText(mixed))).toBe(normalize(expected))
|
||||
})
|
||||
|
||||
it('should keep plain text unchanged', () => {
|
||||
expect(markdownToPlainText('This is plain text.')).toBe('This is plain text.')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,8 +1,22 @@
|
||||
import { Topic } from '@renderer/types'
|
||||
import { Message, Topic } from '@renderer/types'
|
||||
import i18next from 'i18next'
|
||||
|
||||
import { topicToMarkdown } from './export'
|
||||
import { messageToPlainText, topicToMarkdown, topicToPlainText } from './export'
|
||||
|
||||
export const copyTopicAsMarkdown = async (topic: Topic) => {
|
||||
const markdown = await topicToMarkdown(topic)
|
||||
await navigator.clipboard.writeText(markdown)
|
||||
window.message.success(i18next.t('message.copy.success'))
|
||||
}
|
||||
|
||||
export const copyTopicAsPlainText = async (topic: Topic) => {
|
||||
const plainText = await topicToPlainText(topic)
|
||||
await navigator.clipboard.writeText(plainText)
|
||||
window.message.success(i18next.t('message.copy.success'))
|
||||
}
|
||||
|
||||
export const copyMessageAsPlainText = async (message: Message) => {
|
||||
const plainText = messageToPlainText(message)
|
||||
await navigator.clipboard.writeText(plainText)
|
||||
window.message.success(i18next.t('message.copy.success'))
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import { setExportState } from '@renderer/store/runtime'
|
||||
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 { convertMathFormula, markdownToPlainText } from '@renderer/utils/markdown'
|
||||
import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
|
||||
import { markdownToBlocks } from '@tryfabric/martian'
|
||||
import dayjs from 'dayjs'
|
||||
@ -124,6 +124,22 @@ export const messagesToMarkdown = (messages: Message[], exportReasoning?: boolea
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
const formatMessageAsPlainText = (message: Message): string => {
|
||||
const roleText = message.role === 'user' ? 'User:' : 'Assistant:'
|
||||
const content = getMainTextContent(message)
|
||||
const plainTextContent = markdownToPlainText(content).trim()
|
||||
return `${roleText}\n${plainTextContent}`
|
||||
}
|
||||
|
||||
export const messageToPlainText = (message: Message): string => {
|
||||
const content = getMainTextContent(message)
|
||||
return markdownToPlainText(content).trim()
|
||||
}
|
||||
|
||||
const messagesToPlainText = (messages: Message[]): string => {
|
||||
return messages.map(formatMessageAsPlainText).join('\n\n')
|
||||
}
|
||||
|
||||
export const topicToMarkdown = async (topic: Topic, exportReasoning?: boolean) => {
|
||||
const topicName = `# ${topic.name}`
|
||||
const topicMessages = await db.topics.get(topic.id)
|
||||
@ -135,6 +151,21 @@ export const topicToMarkdown = async (topic: Topic, exportReasoning?: boolean) =
|
||||
return ''
|
||||
}
|
||||
|
||||
export const topicToPlainText = async (topic: Topic): Promise<string> => {
|
||||
const topicName = markdownToPlainText(topic.name).trim()
|
||||
const topicMessages = await db.topics.get(topic.id)
|
||||
|
||||
if (topicMessages && topicMessages.messages.length > 0) {
|
||||
return topicName + '\n\n' + messagesToPlainText(topicMessages.messages)
|
||||
}
|
||||
|
||||
if (topicMessages && topicMessages.messages.length === 0) {
|
||||
return topicName
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export const exportTopicAsMarkdown = async (topic: Topic, exportReasoning?: boolean) => {
|
||||
const { markdownExportPath } = store.getState().settings
|
||||
if (!markdownExportPath) {
|
||||
|
||||
@ -2,6 +2,7 @@ import remarkParse from 'remark-parse'
|
||||
import remarkStringify from 'remark-stringify'
|
||||
import { unified } from 'unified'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import removeMarkdown from 'remove-markdown'
|
||||
|
||||
/**
|
||||
* 更彻底的查找方法,递归搜索所有子元素
|
||||
@ -100,3 +101,16 @@ export function isValidPlantUML(code: string | null): boolean {
|
||||
|
||||
return diagramType !== undefined && code.search(`@end${diagramType}`) !== -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Markdown 字符串转换为纯文本。
|
||||
* @param markdown Markdown 字符串。
|
||||
* @returns 纯文本字符串。
|
||||
*/
|
||||
export const markdownToPlainText = (markdown: string): string => {
|
||||
if (!markdown) {
|
||||
return ''
|
||||
}
|
||||
// 直接用 remove-markdown 库,使用默认的 removeMarkdown 参数
|
||||
return removeMarkdown(markdown)
|
||||
}
|
||||
|
||||
@ -5707,6 +5707,7 @@ __metadata:
|
||||
remark-cjk-friendly: "npm:^1.1.0"
|
||||
remark-gfm: "npm:^4.0.0"
|
||||
remark-math: "npm:^6.0.0"
|
||||
remove-markdown: "npm:^0.6.2"
|
||||
rollup-plugin-visualizer: "npm:^5.12.0"
|
||||
sass: "npm:^1.88.0"
|
||||
selection-hook: "npm:^0.9.23"
|
||||
@ -16016,6 +16017,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"remove-markdown@npm:^0.6.2":
|
||||
version: 0.6.2
|
||||
resolution: "remove-markdown@npm:0.6.2"
|
||||
checksum: 10c0/d26fb020b202f227877a29701fcbc96980af3c17001ae46f3253ba21307babcfa424006a207161f83ed04efe19fc9be6b084bd1308ef0b2217c59139e6ef6eb4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"repeat-string@npm:^1.0.0":
|
||||
version: 1.6.1
|
||||
resolution: "repeat-string@npm:1.6.1"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user