mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 21:01:32 +08:00
feat(SelectionAssistant): add the "quote" action (#6868)
* feat(SelectionAssistant): add the "quote" action * fix: i18n for "高级" * refactor: move quote-to-main to WindowService * refactor: move formatQuotedText to renderer
This commit is contained in:
parent
70fb6393b6
commit
fa00b5b173
@ -21,6 +21,8 @@ export enum IpcChannel {
|
||||
App_InstallUvBinary = 'app:install-uv-binary',
|
||||
App_InstallBunBinary = 'app:install-bun-binary',
|
||||
|
||||
App_QuoteToMain = 'app:quote-to-main',
|
||||
|
||||
Notification_Send = 'notification:send',
|
||||
Notification_OnClick = 'notification:on-click',
|
||||
|
||||
|
||||
@ -351,4 +351,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// selection assistant
|
||||
SelectionService.registerIpcHandler()
|
||||
|
||||
ipcMain.handle(IpcChannel.App_QuoteToMain, (_, text: string) => windowService.quoteToMainWindow(text))
|
||||
}
|
||||
|
||||
@ -544,6 +544,25 @@ export class WindowService {
|
||||
public setPinMiniWindow(isPinned) {
|
||||
this.isPinnedMiniWindow = isPinned
|
||||
}
|
||||
|
||||
/**
|
||||
* 引用文本到主窗口
|
||||
* @param text 原始文本(未格式化)
|
||||
*/
|
||||
public quoteToMainWindow(text: string): void {
|
||||
try {
|
||||
this.showMainWindow()
|
||||
|
||||
const mainWindow = this.getMainWindow()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
setTimeout(() => {
|
||||
mainWindow.webContents.send(IpcChannel.App_QuoteToMain, text)
|
||||
}, 100)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to quote to main window:', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const windowService = WindowService.getInstance()
|
||||
|
||||
@ -228,7 +228,8 @@ const api = {
|
||||
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
|
||||
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
||||
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
|
||||
}
|
||||
},
|
||||
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text)
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Dropdown } from 'antd'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -12,7 +11,6 @@ interface ContextMenuProps {
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) => {
|
||||
const { t } = useTranslation()
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
|
||||
const [selectedText, setSelectedText] = useState<string>('')
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
@ -20,12 +18,6 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
||||
e.preventDefault()
|
||||
const _selectedText = window.getSelection()?.toString()
|
||||
if (_selectedText) {
|
||||
const quotedText =
|
||||
_selectedText
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n') + '\n-------------'
|
||||
setSelectedQuoteText(quotedText)
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setSelectedText(_selectedText)
|
||||
}
|
||||
@ -45,7 +37,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
||||
}, [])
|
||||
|
||||
// 获取右键菜单项
|
||||
const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [
|
||||
const getContextMenuItems = (t: (key: string) => string, selectedText: string) => [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
@ -66,8 +58,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
if (selectedQuoteText) {
|
||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
||||
if (selectedText) {
|
||||
window.api?.quoteToMainWindow(selectedText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -78,7 +70,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
||||
{contextMenuPosition && (
|
||||
<Dropdown
|
||||
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
||||
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
|
||||
menu={{ items: getContextMenuItems(t, selectedText) }}
|
||||
open={true}
|
||||
trigger={['contextMenu']}>
|
||||
<div />
|
||||
|
||||
@ -1876,7 +1876,8 @@
|
||||
"summary": "Summarize",
|
||||
"search": "Search",
|
||||
"refine": "Refine",
|
||||
"copy": "Copy"
|
||||
"copy": "Copy",
|
||||
"quote": "Quote"
|
||||
},
|
||||
"window": {
|
||||
"pin": "Pin",
|
||||
|
||||
@ -1876,7 +1876,8 @@
|
||||
"summary": "要約",
|
||||
"search": "検索",
|
||||
"refine": "最適化",
|
||||
"copy": "コピー"
|
||||
"copy": "コピー",
|
||||
"quote": "引用"
|
||||
},
|
||||
"window": {
|
||||
"pin": "最前面に固定",
|
||||
|
||||
@ -1876,7 +1876,8 @@
|
||||
"summary": "Суммаризировать",
|
||||
"search": "Поиск",
|
||||
"refine": "Уточнить",
|
||||
"copy": "Копировать"
|
||||
"copy": "Копировать",
|
||||
"quote": "Цитировать"
|
||||
},
|
||||
"window": {
|
||||
"pin": "Закрепить",
|
||||
|
||||
@ -1876,7 +1876,8 @@
|
||||
"summary": "总结",
|
||||
"search": "搜索",
|
||||
"refine": "优化",
|
||||
"copy": "复制"
|
||||
"copy": "复制",
|
||||
"quote": "引用"
|
||||
},
|
||||
"window": {
|
||||
"pin": "置顶",
|
||||
|
||||
@ -1876,7 +1876,8 @@
|
||||
"summary": "總結",
|
||||
"search": "搜尋",
|
||||
"refine": "優化",
|
||||
"copy": "複製"
|
||||
"copy": "複製",
|
||||
"quote": "引用"
|
||||
},
|
||||
"window": {
|
||||
"pin": "置頂",
|
||||
|
||||
@ -33,8 +33,10 @@ import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk'
|
||||
import { Assistant, FileType, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types'
|
||||
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
||||
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
||||
import { formatQuotedText } from '@renderer/utils/formats'
|
||||
import { getFilesFromDropEvent } from '@renderer/utils/input'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import dayjs from 'dayjs'
|
||||
@ -419,6 +421,19 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
|
||||
}, [addTopic, assistant, setActiveTopic, setModel])
|
||||
|
||||
const onQuote = useCallback(
|
||||
(text: string) => {
|
||||
const quotedText = formatQuotedText(text)
|
||||
setText((prevText) => {
|
||||
const newText = prevText ? `${prevText}\n${quotedText}\n` : `${quotedText}\n`
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
return newText
|
||||
})
|
||||
textareaRef.current?.focus()
|
||||
},
|
||||
[resizeTextArea]
|
||||
)
|
||||
|
||||
const onPause = async () => {
|
||||
await pauseMessages()
|
||||
}
|
||||
@ -623,18 +638,20 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
_setEstimateTokenCount(tokensCount)
|
||||
setContextCount({ current: contextCount.current, max: contextCount.max }) // 现在contextCount是一个对象而不是单个数值
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.ADD_NEW_TOPIC, addNewTopic),
|
||||
EventEmitter.on(EVENT_NAMES.QUOTE_TEXT, (quotedText: string) => {
|
||||
setText((prevText) => {
|
||||
const newText = prevText ? `${prevText}\n${quotedText}\n` : `${quotedText}\n`
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
return newText
|
||||
})
|
||||
textareaRef.current?.focus()
|
||||
})
|
||||
EventEmitter.on(EVENT_NAMES.ADD_NEW_TOPIC, addNewTopic)
|
||||
]
|
||||
return () => unsubscribes.forEach((unsub) => unsub())
|
||||
}, [addNewTopic, resizeTextArea])
|
||||
|
||||
// 监听引用事件
|
||||
const quoteFromAnywhereRemover = window.electron?.ipcRenderer.on(
|
||||
IpcChannel.App_QuoteToMain,
|
||||
(_, selectedText: string) => onQuote(selectedText)
|
||||
)
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsub) => unsub())
|
||||
quoteFromAnywhereRemover?.()
|
||||
}
|
||||
}, [addNewTopic, onQuote])
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus()
|
||||
|
||||
@ -193,7 +193,7 @@ const SelectionAssistantSettings: FC = () => {
|
||||
<SelectionActionsList actionItems={actionItems} setActionItems={setActionItems} />
|
||||
|
||||
<SettingGroup>
|
||||
<SettingTitle>高级</SettingTitle>
|
||||
<SettingTitle>{t('selection.settings.advanced.title')}</SettingTitle>
|
||||
|
||||
<SettingDivider />
|
||||
|
||||
|
||||
@ -27,7 +27,6 @@ export const EVENT_NAMES = {
|
||||
ADD_NEW_TOPIC: 'ADD_NEW_TOPIC',
|
||||
RESEND_MESSAGE: 'RESEND_MESSAGE',
|
||||
SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR',
|
||||
QUOTE_TEXT: 'QUOTE_TEXT',
|
||||
EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK',
|
||||
CHANGE_TOPIC: 'CHANGE_TOPIC'
|
||||
}
|
||||
|
||||
@ -50,7 +50,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 110,
|
||||
version: 111,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -14,6 +14,7 @@ import { RootState } from '.'
|
||||
import { DEFAULT_TOOL_ORDER } from './inputTools'
|
||||
import { INITIAL_PROVIDERS, moveProvider } from './llm'
|
||||
import { mcpSlice } from './mcp'
|
||||
import { defaultActionItems } from './selectionStore'
|
||||
import { DEFAULT_SIDEBAR_ICONS, initialState as settingsInitialState } from './settings'
|
||||
import { defaultWebSearchProviders } from './websearch'
|
||||
|
||||
@ -77,6 +78,17 @@ function updateWebSearchProvider(state: RootState, provider: Partial<WebSearchPr
|
||||
}
|
||||
}
|
||||
|
||||
function addSelectionAction(state: RootState, id: string) {
|
||||
if (state.selectionStore && state.selectionStore.actionItems) {
|
||||
if (!state.selectionStore.actionItems.some((item) => item.id === id)) {
|
||||
const action = defaultActionItems.find((item) => item.id === id)
|
||||
if (action) {
|
||||
state.selectionStore.actionItems.push(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const migrateConfig = {
|
||||
'2': (state: RootState) => {
|
||||
try {
|
||||
@ -1485,6 +1497,14 @@ const migrateConfig = {
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'111': (state: RootState) => {
|
||||
try {
|
||||
addSelectionAction(state, 'quote')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,8 @@ export const defaultActionItems: ActionItem[] = [
|
||||
searchEngine: 'Google|https://www.google.com/search?q={{queryString}}'
|
||||
},
|
||||
{ id: 'copy', name: 'selection.action.builtin.copy', enabled: true, isBuiltIn: true, icon: 'clipboard-copy' },
|
||||
{ id: 'refine', name: 'selection.action.builtin.refine', enabled: false, isBuiltIn: true, icon: 'wand-sparkles' }
|
||||
{ id: 'refine', name: 'selection.action.builtin.refine', enabled: false, isBuiltIn: true, icon: 'wand-sparkles' },
|
||||
{ id: 'quote', name: 'selection.action.builtin.quote', enabled: false, isBuiltIn: true, icon: 'quote' }
|
||||
]
|
||||
|
||||
export const initialState: SelectionState = {
|
||||
|
||||
@ -179,3 +179,12 @@ export function addImageFileToContents(messages: Message[]) {
|
||||
|
||||
return messages.map((message) => (message.id === lastAssistantMessage.id ? updatedAssistantMessage : message))
|
||||
}
|
||||
|
||||
export function formatQuotedText(text: string) {
|
||||
return (
|
||||
text
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n') + '\n-------------'
|
||||
)
|
||||
}
|
||||
|
||||
@ -188,6 +188,9 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
|
||||
case 'search':
|
||||
handleSearch(newAction)
|
||||
break
|
||||
case 'quote':
|
||||
handleQuote(newAction)
|
||||
break
|
||||
default:
|
||||
handleDefaultAction(newAction)
|
||||
break
|
||||
@ -220,6 +223,16 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
|
||||
window.api?.selection.hideToolbar()
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote the selected text to the inputbar of the main window
|
||||
*/
|
||||
const handleQuote = (action: ActionItem) => {
|
||||
if (action.selectedText) {
|
||||
window.api?.quoteToMainWindow(action.selectedText)
|
||||
window.api?.selection.hideToolbar()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDefaultAction = (action: ActionItem) => {
|
||||
window.api?.selection.processAction(action)
|
||||
window.api?.selection.hideToolbar()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user