diff --git a/package.json b/package.json index 6a41efb504..ebda94f81c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 9a8d70a3ea..db89a43897 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 108bc5b49f..976a0c93df 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -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": "新しい名前を入力", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index e469c89592..67dea44164 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -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": "Введите новый заголовок", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 548089e76e..05cd8b80f7 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -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": "输入新名称", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 080cef0fad..315dd20ddd 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -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": "輸入新名稱", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index d9dfcc740d..125e731329 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -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": "Εισαγάγετε το νέο όνομα", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 9876e89f02..ec48af7327 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -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", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 5a2e83374e..1d3f5332da 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -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", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index d13541bee4..fe5f0c6dd4 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -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", diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 0e035903eb..68b901ddec 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -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) => { key: 'export', icon: , 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', diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 2217908451..16d2b23de9 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -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 = ({ 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) } ] }, diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 40a53d0a27..6c7ef0576f 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -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' diff --git a/src/renderer/src/utils/__tests__/export.test.ts b/src/renderer/src/utils/__tests__/export.test.ts index f04f2e876f..2b73bec70e 100644 --- a/src/renderer/src/utils/__tests__/export.test.ts +++ b/src/renderer/src/utils/__tests__/export.test.ts @@ -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('') + }) + }) }) diff --git a/src/renderer/src/utils/__tests__/markdown.test.ts b/src/renderer/src/utils/__tests__/markdown.test.ts index 9064510b33..de555aa53f 100644 --- a/src/renderer/src/utils/__tests__/markdown.test.ts +++ b/src/renderer/src/utils/__tests__/markdown.test.ts @@ -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('![alt text](http://example.com/image.png)')).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.') + }) + }) }) diff --git a/src/renderer/src/utils/copy.ts b/src/renderer/src/utils/copy.ts index 5f0aeffcb9..e79576901f 100644 --- a/src/renderer/src/utils/copy.ts +++ b/src/renderer/src/utils/copy.ts @@ -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')) } diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index 0c858647bf..14256b667f 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -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 => { + 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) { diff --git a/src/renderer/src/utils/markdown.ts b/src/renderer/src/utils/markdown.ts index 3996e0aea2..c3c6229064 100644 --- a/src/renderer/src/utils/markdown.ts +++ b/src/renderer/src/utils/markdown.ts @@ -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) +} diff --git a/yarn.lock b/yarn.lock index a7840feb40..dc7c5e7448 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"