feat: add export function to message

This commit is contained in:
kangfenmao 2025-02-21 16:27:07 +08:00
parent 55317b5608
commit acb2ea30fb
16 changed files with 172 additions and 45 deletions

View File

@ -257,7 +257,6 @@ body,
}
}
.group-menu-bar {
margin-left: 0;
background-color: var(--color-background);
}
code {

View File

@ -337,7 +337,7 @@ export const PROVIDER_CONFIG = {
},
websites: {
official: 'https://console.volcengine.com/ark/',
apiKey: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey',
apiKey: 'https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=DB4II4FC',
docs: 'https://www.volcengine.com/docs/82379/1182403',
models: 'https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint'
}

View File

@ -129,9 +129,9 @@
"thinking": "Thinking",
"topics.auto_rename": "Auto Rename",
"topics.clear.title": "Clear Messages",
"topics.copy.image": "Image",
"topics.copy.md": "Markdown",
"topics.copy.title": "Copy as",
"topics.copy.image": "Copy as image",
"topics.copy.md": "Copy as markdown",
"topics.copy.title": "Copy",
"topics.delete.shortcut": "Hold {{key}} to delete directly",
"topics.edit.placeholder": "Enter new name",
"topics.edit.title": "Edit Name",

View File

@ -129,9 +129,9 @@
"thinking": "思考中...",
"topics.auto_rename": "自動リネーム",
"topics.clear.title": "メッセージをクリア",
"topics.copy.image": "画像",
"topics.copy.md": "Markdown",
"topics.copy.title": "複製",
"topics.copy.image": "画像としてコピー",
"topics.copy.md": "Markdownとしてコピー",
"topics.copy.title": "コピー",
"topics.delete.shortcut": "{{key}}キーを押しながらで直接削除",
"topics.edit.placeholder": "新しい名前を入力",
"topics.edit.title": "名前を編集",

View File

@ -129,9 +129,9 @@
"thinking": "Мыслим",
"topics.auto_rename": "Автопереименование",
"topics.clear.title": "Очистить сообщения",
"topics.copy.image": "Изображение",
"topics.copy.md": "Markdown",
"topics.copy.title": "Скопировать как",
"topics.copy.image": "Скопировать как изображение",
"topics.copy.md": "Скопировать как Markdown",
"topics.copy.title": "Скопировать",
"topics.delete.shortcut": "Удерживайте {{key}} для мгновенного удаления",
"topics.edit.placeholder": "Введите новый заголовок",
"topics.edit.title": "Редактировать заголовок",

View File

@ -129,9 +129,9 @@
"thinking": "思考中",
"topics.auto_rename": "生成话题名",
"topics.clear.title": "清空消息",
"topics.copy.image": "图片",
"topics.copy.md": "Markdown",
"topics.copy.title": "复制",
"topics.copy.image": "复制为图片",
"topics.copy.md": "复制为 Markdown",
"topics.copy.title": "复制",
"topics.delete.shortcut": "按住 {{key}} 可直接删除",
"topics.edit.placeholder": "输入新名称",
"topics.edit.title": "编辑话题名",

View File

@ -129,9 +129,9 @@
"thinking": "思考中",
"topics.auto_rename": "自動重新命名",
"topics.clear.title": "清空消息",
"topics.copy.image": "圖片",
"topics.copy.md": "Markdown",
"topics.copy.title": "複製",
"topics.copy.image": "複製為圖片",
"topics.copy.md": "複製為 Markdown",
"topics.copy.title": "複製",
"topics.delete.shortcut": "按住 {{key}} 可直接刪除",
"topics.edit.placeholder": "輸入新名稱",
"topics.edit.title": "編輯名稱",

View File

@ -208,6 +208,7 @@ const MessageItem: FC<Props> = ({
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef}
setModel={setModel}
onEditMessage={onEditMessage}
onDeleteMessage={onDeleteMessage}
@ -225,6 +226,7 @@ const MessageContainer = styled.div`
flex-direction: column;
position: relative;
transition: background-color 0.3s ease;
padding: 0 20px;
&.message-highlight {
background-color: var(--color-primary-mute);
}

View File

@ -2,6 +2,7 @@ import Scrollbar from '@renderer/components/Scrollbar'
import { useSettings } from '@renderer/hooks/useSettings'
import { MultiModelMessageStyle } from '@renderer/store/settings'
import { Message, Topic } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { Popover } from 'antd'
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -40,6 +41,7 @@ const MessageGroup: FC<Props> = ({
const isGrouped = messageLength > 1
const isHorizontal = multiModelMessageStyle === 'horizontal'
const isGrid = multiModelMessageStyle === 'grid'
const onDelete = useCallback(async () => {
window.modal.confirm({
@ -62,10 +64,17 @@ const MessageGroup: FC<Props> = ({
}, [messageLength])
return (
<GroupContainer $isGrouped={isGrouped} $layout={multiModelMessageStyle}>
<GridContainer $count={messageLength} $layout={multiModelMessageStyle} $gridColumns={gridColumns}>
<GroupContainer
$isGrouped={isGrouped}
$layout={multiModelMessageStyle}
className={classNames([isGrouped && 'group-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
<GridContainer
$count={messageLength}
$layout={multiModelMessageStyle}
$gridColumns={gridColumns}
className={classNames([isGrouped && 'group-grid-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
{messages.map((message, index) => {
const isGridGroupMessage = multiModelMessageStyle === 'grid' && message.role === 'assistant' && isGrouped
const isGridGroupMessage = isGrid && message.role === 'assistant' && isGrouped
if (isGridGroupMessage) {
return (
<Popover
@ -161,30 +170,46 @@ const MessageGroup: FC<Props> = ({
const GroupContainer = styled.div<{ $isGrouped: boolean; $layout: MultiModelMessageStyle }>`
padding-top: ${({ $isGrouped, $layout }) => ($isGrouped && 'horizontal' === $layout ? '15px' : '0')};
&.group-container.horizontal,
&.group-container.grid {
padding: 0 20px;
.message {
padding: 0;
}
.group-menu-bar {
margin-left: 0;
margin-right: 0;
}
}
`
const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageStyle; $gridColumns: number }>`
width: 100%;
display: grid;
gap: ${({ $layout }) => ($layout === 'horizontal' ? '16px' : '0')};
overflow-y: auto;
grid-template-columns: repeat(
${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)},
minmax(550px, 1fr)
);
gap: ${({ $layout }) => ($layout === 'horizontal' ? '16px' : '0')};
@media (max-width: 800px) {
grid-template-columns: repeat(
${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)},
minmax(400px, 1fr)
);
}
overflow-y: auto;
${({ $layout }) =>
$layout === 'horizontal' &&
css`
margin-top: 15px;
`}
${({ $gridColumns, $layout, $count }) =>
$layout === 'grid' &&
css`
margin-top: 15px;
grid-template-columns: repeat(${$count > 1 ? $gridColumns || 2 : 1}, minmax(0, 1fr));
grid-template-rows: auto;
gap: 16px;
margin-top: 20px;
`}
`
@ -220,11 +245,11 @@ const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
return ''
}}
${({ $layout, $isInPopover, $isGrouped }) =>
$layout === 'grid' && $isGrouped
${({ $layout, $isInPopover, $isGrouped }) => {
return $layout === 'grid' && $isGrouped
? css`
max-height: ${$isInPopover ? '50vh' : '300px'};
overflow-y: auto;
overflow-y: ${$isInPopover ? 'auto' : 'hidden'};
border: 0.5px solid ${$isInPopover ? 'transparent' : 'var(--color-border)'};
padding: 10px;
border-radius: 6px;
@ -233,7 +258,8 @@ const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
: css`
overflow-y: auto;
border-radius: 6px;
`}
`
}}
`
export default memo(MessageGroup)

View File

@ -35,7 +35,7 @@ const MessageGroupMenuBar: FC<Props> = ({
onDelete
}) => {
return (
<GroupMenuBar $layout={multiModelMessageStyle}>
<GroupMenuBar $layout={multiModelMessageStyle} className="group-menu-bar">
<HStack style={{ alignItems: 'center', flex: 1, overflow: 'hidden' }}>
<LayoutContainer>
{['fold', 'vertical', 'horizontal', 'grid'].map((layout) => (
@ -93,6 +93,7 @@ const GroupMenuBar = styled.div<{ $layout: MultiModelMessageStyle }>`
flex-direction: row;
align-items: center;
gap: 10px;
margin: 0 20px;
padding: 6px 10px;
border-radius: 6px;
margin-top: 10px;
@ -100,7 +101,6 @@ const GroupMenuBar = styled.div<{ $layout: MultiModelMessageStyle }>`
overflow: hidden;
border: 0.5px solid var(--color-border);
height: 40px;
transition: all 0.3s ease;
background-color: var(--color-background);
`

View File

@ -11,15 +11,22 @@ import {
SyncOutlined,
TranslationOutlined
} from '@ant-design/icons'
import { UploadOutlined } from '@ant-design/icons'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
import { TranslateLanguageOptions } from '@renderer/config/translate'
import { modelGenerating } from '@renderer/hooks/useRuntime'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { resetAssistantMessage } from '@renderer/services/MessagesService'
import { getMessageTitle, resetAssistantMessage } from '@renderer/services/MessagesService'
import { translateText } from '@renderer/services/TranslateService'
import { Message, Model } from '@renderer/types'
import { removeTrailingDoubleSpaces, uuid } from '@renderer/utils'
import {
captureScrollableDivAsBlob,
captureScrollableDivAsDataURL,
removeTrailingDoubleSpaces,
uuid
} from '@renderer/utils'
import { exportMarkdownToNotion, exportMessageAsMarkdown, messageToMarkdown } from '@renderer/utils/export'
import { Button, Dropdown, Popconfirm, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { isEmpty } from 'lodash'
@ -35,6 +42,7 @@ interface Props {
isGrouped?: boolean
isLastMessage: boolean
isAssistantMessage: boolean
messageContainerRef: React.RefObject<HTMLDivElement>
setModel: (model: Model) => void
onEditMessage?: (message: Message) => void
onDeleteMessage?: (message: Message) => Promise<void>
@ -50,6 +58,7 @@ const MessageMenubar: FC<Props> = (props) => {
isLastMessage,
isAssistantMessage,
assistantModel,
messageContainerRef,
onEditMessage,
onDeleteMessage,
onGetMessages
@ -194,9 +203,61 @@ const MessageMenubar: FC<Props> = (props) => {
key: 'new-branch',
icon: <ForkOutlined />,
onClick: onNewBranch
},
{
label: t('chat.topics.export.title'),
key: 'export',
icon: <UploadOutlined />,
children: [
{
label: t('chat.topics.copy.image'),
key: 'img',
onClick: async () => {
await captureScrollableDivAsBlob(messageContainerRef, async (blob) => {
if (blob) {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
}
})
}
},
{
label: t('chat.topics.export.image'),
key: 'image',
onClick: async () => {
const imageData = await captureScrollableDivAsDataURL(messageContainerRef)
const title = getMessageTitle(message)
if (title && imageData) {
window.api.file.saveImage(title, imageData)
}
}
},
{
label: t('chat.topics.export.md'),
key: 'markdown',
onClick: () => exportMessageAsMarkdown(message)
},
{
label: t('chat.topics.export.word'),
key: 'word',
onClick: async () => {
const markdown = messageToMarkdown(message)
window.api.export.toWord(markdown, getMessageTitle(message))
}
},
{
label: t('chat.topics.export.notion'),
key: 'notion',
onClick: async () => {
const title = getMessageTitle(message)
const markdown = messageToMarkdown(message)
exportMarkdownToNotion(title, markdown)
}
}
]
}
],
[message, onEdit, onNewBranch, t]
[message, messageContainerRef, onEdit, onNewBranch, t]
)
const onRegenerate = async (e: React.MouseEvent | undefined) => {

View File

@ -347,7 +347,6 @@ const LoaderContainer = styled.div<LoaderProps>`
const ScrollContainer = styled.div`
display: flex;
flex-direction: column-reverse;
padding: 0 20px;
`
interface ContainerProps {

View File

@ -1,7 +1,6 @@
import {
ClearOutlined,
CloseOutlined,
CopyOutlined,
DeleteOutlined,
EditOutlined,
FolderOutlined,
@ -10,6 +9,7 @@ import {
UploadOutlined
} from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import Scrollbar from '@renderer/components/Scrollbar'
import { isMac } from '@renderer/config/constant'
@ -23,7 +23,7 @@ import store from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
import { Assistant, Topic } from '@renderer/types'
import { copyTopicAsMarkdown } from '@renderer/utils/copy'
import { exportTopicAsMarkdown, exportTopicToNotion, topicToMarkdown } from '@renderer/utils/export'
import { exportMarkdownToNotion, exportTopicAsMarkdown, topicToMarkdown } from '@renderer/utils/export'
import { Dropdown, MenuProps, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { findIndex } from 'lodash'
@ -194,7 +194,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
{
label: t('chat.topics.copy.title'),
key: 'copy',
icon: <CopyOutlined />,
icon: <CopyIcon />,
children: [
{
label: t('chat.topics.copy.image'),
@ -235,7 +235,10 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
{
label: t('chat.topics.export.notion'),
key: 'notion',
onClick: () => exportTopicToNotion(topic)
onClick: async () => {
const markdown = await topicToMarkdown(topic)
exportMarkdownToNotion(topic.name, markdown)
}
}
]
}

View File

@ -5,6 +5,7 @@ import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { Assistant, Message, Model, Topic } from '@renderer/types'
import { uuid } from '@renderer/utils'
import dayjs from 'dayjs'
import { isEmpty, remove, takeRight } from 'lodash'
import { NavigateFunction } from 'react-router'
@ -168,3 +169,27 @@ export function resetAssistantMessage(message: Message, model?: Model): Message
useful: undefined
}
}
export function getMessageTitle(message: Message, length = 30) {
let title = message.content.split('\n')[0]
if (title.includes('.')) {
title = title.split('.')[0]
} else if (title.includes(',')) {
title = title.split(',')[0]
} else if (title.includes('')) {
title = title.split('')[0]
} else if (title.includes('。')) {
title = title.split('。')[0]
}
if (title.length > length) {
title = title.slice(0, length)
}
if (!title) {
title = dayjs(message.createdAt).format('YYYYMMDDHHmm')
}
return title
}

View File

@ -1,6 +1,7 @@
import { Client } from '@notionhq/client'
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import { getMessageTitle } from '@renderer/services/MessagesService'
import store from '@renderer/store'
import { setExportState } from '@renderer/store/runtime'
import { Message, Topic } from '@renderer/types'
@ -34,24 +35,32 @@ export const exportTopicAsMarkdown = async (topic: Topic) => {
window.api.file.save(fileName, markdown)
}
export const exportTopicToNotion = async (topic: Topic) => {
export const exportMessageAsMarkdown = async (message: Message) => {
const fileName = getMessageTitle(message) + '.md'
const markdown = messageToMarkdown(message)
window.api.file.save(fileName, markdown)
}
export const exportMarkdownToNotion = async (title: string, content: string) => {
const { isExporting } = store.getState().runtime.export
if (isExporting) {
window.message.warning({ content: i18n.t('message.warn.notion.exporting'), key: 'notion-exporting' })
return
}
setExportState({
isExporting: true
})
setExportState({ isExporting: true })
const { notionDatabaseID, notionApiKey } = store.getState().settings
if (!notionApiKey || !notionDatabaseID) {
window.message.error({ content: i18n.t('message.error.notion.no_api_key'), key: 'notion-no-apikey-error' })
return
}
try {
const notion = new Client({ auth: notionApiKey })
const markdown = await topicToMarkdown(topic)
const requestBody = JSON.stringify({ md: markdown })
const requestBody = JSON.stringify({ md: content })
const res = await fetch('https://md2notion.hilars.dev', {
method: 'POST',
@ -68,10 +77,10 @@ export const exportTopicToNotion = async (topic: Topic) => {
parent: { database_id: notionDatabaseID },
properties: {
[store.getState().settings.notionPageNameKey || 'Name']: {
title: [{ text: { content: topic.name } }]
title: [{ text: { content: title } }]
}
},
children: notionBlocks // 使用转换后的块
children: notionBlocks
})
window.message.success({ content: i18n.t('message.success.notion.export'), key: 'notion-success' })

View File

@ -344,6 +344,7 @@ export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElemen
return Promise.resolve(undefined)
}
export const captureScrollableDivAsDataURL = async (divRef: React.RefObject<HTMLDivElement>) => {
return captureScrollableDiv(divRef).then((canvas) => {
if (canvas) {
@ -352,11 +353,13 @@ export const captureScrollableDivAsDataURL = async (divRef: React.RefObject<HTML
return Promise.resolve(undefined)
})
}
export const captureScrollableDivAsBlob = async (divRef: React.RefObject<HTMLDivElement>, func: BlobCallback) => {
await captureScrollableDiv(divRef).then((canvas) => {
canvas?.toBlob(func, 'image/png')
})
}
export function hasPath(url: string): boolean {
try {
const parsedUrl = new URL(url)