mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-20 23:22:05 +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",
|
"officeparser": "^4.1.1",
|
||||||
"os-proxy-config": "^1.1.2",
|
"os-proxy-config": "^1.1.2",
|
||||||
"proxy-agent": "^6.5.0",
|
"proxy-agent": "^6.5.0",
|
||||||
|
"remove-markdown": "^0.6.2",
|
||||||
"selection-hook": "^0.9.23",
|
"selection-hook": "^0.9.23",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"turndown": "^7.2.0",
|
"turndown": "^7.2.0",
|
||||||
|
|||||||
@ -261,6 +261,7 @@
|
|||||||
"topics.clear.title": "Clear Messages",
|
"topics.clear.title": "Clear Messages",
|
||||||
"topics.copy.image": "Copy as image",
|
"topics.copy.image": "Copy as image",
|
||||||
"topics.copy.md": "Copy as markdown",
|
"topics.copy.md": "Copy as markdown",
|
||||||
|
"topics.copy.plain_text": "Copy as plain text (remove Markdown)",
|
||||||
"topics.copy.title": "Copy",
|
"topics.copy.title": "Copy",
|
||||||
"topics.delete.shortcut": "Hold {{key}} to delete directly",
|
"topics.delete.shortcut": "Hold {{key}} to delete directly",
|
||||||
"topics.edit.placeholder": "Enter new name",
|
"topics.edit.placeholder": "Enter new name",
|
||||||
|
|||||||
@ -261,6 +261,7 @@
|
|||||||
"topics.clear.title": "メッセージをクリア",
|
"topics.clear.title": "メッセージをクリア",
|
||||||
"topics.copy.image": "画像としてコピー",
|
"topics.copy.image": "画像としてコピー",
|
||||||
"topics.copy.md": "Markdownとしてコピー",
|
"topics.copy.md": "Markdownとしてコピー",
|
||||||
|
"topics.copy.plain_text": "プレーンテキストとしてコピー(Markdownを除去)",
|
||||||
"topics.copy.title": "コピー",
|
"topics.copy.title": "コピー",
|
||||||
"topics.delete.shortcut": "{{key}}キーを押しながらで直接削除",
|
"topics.delete.shortcut": "{{key}}キーを押しながらで直接削除",
|
||||||
"topics.edit.placeholder": "新しい名前を入力",
|
"topics.edit.placeholder": "新しい名前を入力",
|
||||||
|
|||||||
@ -261,6 +261,7 @@
|
|||||||
"topics.clear.title": "Очистить сообщения",
|
"topics.clear.title": "Очистить сообщения",
|
||||||
"topics.copy.image": "Скопировать как изображение",
|
"topics.copy.image": "Скопировать как изображение",
|
||||||
"topics.copy.md": "Скопировать как Markdown",
|
"topics.copy.md": "Скопировать как Markdown",
|
||||||
|
"topics.copy.plain_text": "Копировать как обычный текст (удалить Markdown)",
|
||||||
"topics.copy.title": "Скопировать",
|
"topics.copy.title": "Скопировать",
|
||||||
"topics.delete.shortcut": "Удерживайте {{key}} для мгновенного удаления",
|
"topics.delete.shortcut": "Удерживайте {{key}} для мгновенного удаления",
|
||||||
"topics.edit.placeholder": "Введите новый заголовок",
|
"topics.edit.placeholder": "Введите новый заголовок",
|
||||||
|
|||||||
@ -279,6 +279,7 @@
|
|||||||
"topics.clear.title": "清空消息",
|
"topics.clear.title": "清空消息",
|
||||||
"topics.copy.image": "复制为图片",
|
"topics.copy.image": "复制为图片",
|
||||||
"topics.copy.md": "复制为 Markdown",
|
"topics.copy.md": "复制为 Markdown",
|
||||||
|
"topics.copy.plain_text": "复制为纯文本(去除 Markdown)",
|
||||||
"topics.copy.title": "复制",
|
"topics.copy.title": "复制",
|
||||||
"topics.delete.shortcut": "按住 {{key}} 可直接删除",
|
"topics.delete.shortcut": "按住 {{key}} 可直接删除",
|
||||||
"topics.edit.placeholder": "输入新名称",
|
"topics.edit.placeholder": "输入新名称",
|
||||||
|
|||||||
@ -261,6 +261,7 @@
|
|||||||
"topics.clear.title": "清空訊息",
|
"topics.clear.title": "清空訊息",
|
||||||
"topics.copy.image": "複製為圖片",
|
"topics.copy.image": "複製為圖片",
|
||||||
"topics.copy.md": "複製為 Markdown",
|
"topics.copy.md": "複製為 Markdown",
|
||||||
|
"topics.copy.plain_text": "複製為純文字(移除 Markdown)",
|
||||||
"topics.copy.title": "複製",
|
"topics.copy.title": "複製",
|
||||||
"topics.delete.shortcut": "按住 {{key}} 可直接刪除",
|
"topics.delete.shortcut": "按住 {{key}} 可直接刪除",
|
||||||
"topics.edit.placeholder": "輸入新名稱",
|
"topics.edit.placeholder": "輸入新名稱",
|
||||||
|
|||||||
@ -198,6 +198,7 @@
|
|||||||
"topics.clear.title": "Καθαρισμός μηνυμάτων",
|
"topics.clear.title": "Καθαρισμός μηνυμάτων",
|
||||||
"topics.copy.image": "Αντιγραφή ως εικόνα",
|
"topics.copy.image": "Αντιγραφή ως εικόνα",
|
||||||
"topics.copy.md": "Αντιγραφή ως Markdown",
|
"topics.copy.md": "Αντιγραφή ως Markdown",
|
||||||
|
"topics.copy.plain_text": "Αντιγραφή ως απλό κείμενο (αφαίρεση Markdown)",
|
||||||
"topics.copy.title": "Αντιγραφή",
|
"topics.copy.title": "Αντιγραφή",
|
||||||
"topics.delete.shortcut": "Πατήστε {{key}} για να διαγράψετε αμέσως",
|
"topics.delete.shortcut": "Πατήστε {{key}} για να διαγράψετε αμέσως",
|
||||||
"topics.edit.placeholder": "Εισαγάγετε το νέο όνομα",
|
"topics.edit.placeholder": "Εισαγάγετε το νέο όνομα",
|
||||||
|
|||||||
@ -199,6 +199,7 @@
|
|||||||
"topics.clear.title": "Limpiar mensajes",
|
"topics.clear.title": "Limpiar mensajes",
|
||||||
"topics.copy.image": "Copiar como imagen",
|
"topics.copy.image": "Copiar como imagen",
|
||||||
"topics.copy.md": "Copiar como Markdown",
|
"topics.copy.md": "Copiar como Markdown",
|
||||||
|
"topics.copy.plain_text": "Copiar como texto sin formato (eliminar Markdown)",
|
||||||
"topics.copy.title": "Copiar",
|
"topics.copy.title": "Copiar",
|
||||||
"topics.delete.shortcut": "Mantén presionada {{key}} para eliminar directamente",
|
"topics.delete.shortcut": "Mantén presionada {{key}} para eliminar directamente",
|
||||||
"topics.edit.placeholder": "Introduce nuevo nombre",
|
"topics.edit.placeholder": "Introduce nuevo nombre",
|
||||||
|
|||||||
@ -198,6 +198,7 @@
|
|||||||
"topics.clear.title": "Effacer le message",
|
"topics.clear.title": "Effacer le message",
|
||||||
"topics.copy.image": "Copier sous forme d'image",
|
"topics.copy.image": "Copier sous forme d'image",
|
||||||
"topics.copy.md": "Copier sous forme de Markdown",
|
"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.copy.title": "Copier",
|
||||||
"topics.delete.shortcut": "Maintenez {{key}} pour supprimer directement",
|
"topics.delete.shortcut": "Maintenez {{key}} pour supprimer directement",
|
||||||
"topics.edit.placeholder": "Entrez un nouveau nom",
|
"topics.edit.placeholder": "Entrez un nouveau nom",
|
||||||
|
|||||||
@ -199,6 +199,7 @@
|
|||||||
"topics.clear.title": "Limpar mensagens",
|
"topics.clear.title": "Limpar mensagens",
|
||||||
"topics.copy.image": "Copiar como imagem",
|
"topics.copy.image": "Copiar como imagem",
|
||||||
"topics.copy.md": "Copiar como Markdown",
|
"topics.copy.md": "Copiar como Markdown",
|
||||||
|
"topics.copy.plain_text": "Copiar como texto simples (remover Markdown)",
|
||||||
"topics.copy.title": "Copiar",
|
"topics.copy.title": "Copiar",
|
||||||
"topics.delete.shortcut": "Pressione {{key}} para deletar diretamente",
|
"topics.delete.shortcut": "Pressione {{key}} para deletar diretamente",
|
||||||
"topics.edit.placeholder": "Digite novo nome",
|
"topics.edit.placeholder": "Digite novo nome",
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import {
|
|||||||
exportMessageToNotion,
|
exportMessageToNotion,
|
||||||
messageToMarkdown
|
messageToMarkdown
|
||||||
} from '@renderer/utils/export'
|
} from '@renderer/utils/export'
|
||||||
|
import { copyMessageAsPlainText } from '@renderer/utils/copy'
|
||||||
// import { withMessageThought } from '@renderer/utils/formats'
|
// import { withMessageThought } from '@renderer/utils/formats'
|
||||||
import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown'
|
import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown'
|
||||||
import { findMainTextBlocks, findTranslationBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
import { findMainTextBlocks, findTranslationBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||||
@ -201,6 +202,11 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
key: 'export',
|
key: 'export',
|
||||||
icon: <Share size={16} color="var(--color-icon)" style={{ marginTop: 3 }} />,
|
icon: <Share size={16} color="var(--color-icon)" style={{ marginTop: 3 }} />,
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
label: t('chat.topics.copy.plain_text'),
|
||||||
|
key: 'copy_message_plain_text',
|
||||||
|
onClick: () => copyMessageAsPlainText(message)
|
||||||
|
},
|
||||||
exportMenuOptions.image && {
|
exportMenuOptions.image && {
|
||||||
label: t('chat.topics.copy.image'),
|
label: t('chat.topics.copy.image'),
|
||||||
key: 'img',
|
key: 'img',
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import { RootState } from '@renderer/store'
|
|||||||
import { setGenerating } from '@renderer/store/runtime'
|
import { setGenerating } from '@renderer/store/runtime'
|
||||||
import { Assistant, Topic } from '@renderer/types'
|
import { Assistant, Topic } from '@renderer/types'
|
||||||
import { removeSpecialCharactersForFileName } from '@renderer/utils'
|
import { removeSpecialCharactersForFileName } from '@renderer/utils'
|
||||||
import { copyTopicAsMarkdown } from '@renderer/utils/copy'
|
import { copyTopicAsMarkdown, copyTopicAsPlainText } from '@renderer/utils/copy'
|
||||||
import {
|
import {
|
||||||
exportMarkdownToJoplin,
|
exportMarkdownToJoplin,
|
||||||
exportMarkdownToSiyuan,
|
exportMarkdownToSiyuan,
|
||||||
@ -280,6 +280,11 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
|||||||
label: t('chat.topics.copy.md'),
|
label: t('chat.topics.copy.md'),
|
||||||
key: 'md',
|
key: 'md',
|
||||||
onClick: () => copyTopicAsMarkdown(topic)
|
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 OpenAISummaryText = 'auto' | 'concise' | 'detailed' | 'off'
|
||||||
export type OpenAIServiceTier = 'auto' | 'default' | 'flex'
|
export type OpenAIServiceTier = 'auto' | 'default' | 'flex'
|
||||||
|
export type { Message } from './newMessage'
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Import Message, MessageBlock, and necessary enums
|
// Import Message, MessageBlock, and necessary enums
|
||||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||||
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } 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 ---
|
// --- 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 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 ---
|
// --- Helper Functions for Test Data ---
|
||||||
|
|
||||||
@ -135,6 +163,13 @@ beforeEach(() => {
|
|||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
// Mock i18next translation function
|
||||||
|
vi.mock('i18next', () => ({
|
||||||
|
default: {
|
||||||
|
t: vi.fn((key) => key)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
// Mock store - primarily for settings
|
// Mock store - primarily for settings
|
||||||
vi.doMock('@renderer/store', () => ({
|
vi.doMock('@renderer/store', () => ({
|
||||||
default: {
|
default: {
|
||||||
@ -344,4 +379,210 @@ describe('export', () => {
|
|||||||
expect(markdown.split('\n\n---\n\n').length).toBe(1)
|
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,
|
findCitationInChildren,
|
||||||
getCodeBlockId,
|
getCodeBlockId,
|
||||||
removeTrailingDoubleSpaces,
|
removeTrailingDoubleSpaces,
|
||||||
updateCodeBlock
|
updateCodeBlock,
|
||||||
|
markdownToPlainText
|
||||||
} from '../markdown'
|
} from '../markdown'
|
||||||
|
|
||||||
describe('markdown', () => {
|
describe('markdown', () => {
|
||||||
@ -184,7 +185,7 @@ describe('markdown', () => {
|
|||||||
// function getAllCodeBlockIds(markdown: string): { [content: string]: string } {
|
// function getAllCodeBlockIds(markdown: string): { [content: string]: string } {
|
||||||
// const result: { [content: string]: string } = {}
|
// const result: { [content: string]: string } = {}
|
||||||
// const tree = unified().use(remarkParse).parse(markdown)
|
// const tree = unified().use(remarkParse).parse(markdown)
|
||||||
|
//
|
||||||
// visit(tree, 'code', (node) => {
|
// visit(tree, 'code', (node) => {
|
||||||
// const id = getCodeBlockId(node.position?.start)
|
// const id = getCodeBlockId(node.position?.start)
|
||||||
// if (id) {
|
// if (id) {
|
||||||
@ -192,7 +193,7 @@ describe('markdown', () => {
|
|||||||
// console.log(`Code Block ID: "${id}" for content: "${node.value}" lang: "${node.lang}"`)
|
// console.log(`Code Block ID: "${id}" for content: "${node.value}" lang: "${node.lang}"`)
|
||||||
// }
|
// }
|
||||||
// })
|
// })
|
||||||
|
//
|
||||||
// return result
|
// return result
|
||||||
// }
|
// }
|
||||||
|
|
||||||
@ -323,4 +324,79 @@ describe('markdown', () => {
|
|||||||
expect(result).toBe(expectedResult)
|
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) => {
|
export const copyTopicAsMarkdown = async (topic: Topic) => {
|
||||||
const markdown = await topicToMarkdown(topic)
|
const markdown = await topicToMarkdown(topic)
|
||||||
await navigator.clipboard.writeText(markdown)
|
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 { Topic } from '@renderer/types'
|
||||||
import type { Message } from '@renderer/types/newMessage'
|
import type { Message } from '@renderer/types/newMessage'
|
||||||
import { removeSpecialCharactersForFileName } from '@renderer/utils/file'
|
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 { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
|
||||||
import { markdownToBlocks } from '@tryfabric/martian'
|
import { markdownToBlocks } from '@tryfabric/martian'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@ -124,6 +124,22 @@ export const messagesToMarkdown = (messages: Message[], exportReasoning?: boolea
|
|||||||
.join('\n\n---\n\n')
|
.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) => {
|
export const topicToMarkdown = async (topic: Topic, exportReasoning?: boolean) => {
|
||||||
const topicName = `# ${topic.name}`
|
const topicName = `# ${topic.name}`
|
||||||
const topicMessages = await db.topics.get(topic.id)
|
const topicMessages = await db.topics.get(topic.id)
|
||||||
@ -135,6 +151,21 @@ export const topicToMarkdown = async (topic: Topic, exportReasoning?: boolean) =
|
|||||||
return ''
|
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) => {
|
export const exportTopicAsMarkdown = async (topic: Topic, exportReasoning?: boolean) => {
|
||||||
const { markdownExportPath } = store.getState().settings
|
const { markdownExportPath } = store.getState().settings
|
||||||
if (!markdownExportPath) {
|
if (!markdownExportPath) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import remarkParse from 'remark-parse'
|
|||||||
import remarkStringify from 'remark-stringify'
|
import remarkStringify from 'remark-stringify'
|
||||||
import { unified } from 'unified'
|
import { unified } from 'unified'
|
||||||
import { visit } from 'unist-util-visit'
|
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
|
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-cjk-friendly: "npm:^1.1.0"
|
||||||
remark-gfm: "npm:^4.0.0"
|
remark-gfm: "npm:^4.0.0"
|
||||||
remark-math: "npm:^6.0.0"
|
remark-math: "npm:^6.0.0"
|
||||||
|
remove-markdown: "npm:^0.6.2"
|
||||||
rollup-plugin-visualizer: "npm:^5.12.0"
|
rollup-plugin-visualizer: "npm:^5.12.0"
|
||||||
sass: "npm:^1.88.0"
|
sass: "npm:^1.88.0"
|
||||||
selection-hook: "npm:^0.9.23"
|
selection-hook: "npm:^0.9.23"
|
||||||
@ -16016,6 +16017,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"repeat-string@npm:^1.0.0":
|
||||||
version: 1.6.1
|
version: 1.6.1
|
||||||
resolution: "repeat-string@npm:1.6.1"
|
resolution: "repeat-string@npm:1.6.1"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user