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:
one 2025-06-06 18:12:38 +08:00 committed by GitHub
parent 70fb6393b6
commit fa00b5b173
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 113 additions and 33 deletions

View File

@ -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',

View File

@ -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))
}

View File

@ -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()

View File

@ -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

View File

@ -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 />

View File

@ -1876,7 +1876,8 @@
"summary": "Summarize",
"search": "Search",
"refine": "Refine",
"copy": "Copy"
"copy": "Copy",
"quote": "Quote"
},
"window": {
"pin": "Pin",

View File

@ -1876,7 +1876,8 @@
"summary": "要約",
"search": "検索",
"refine": "最適化",
"copy": "コピー"
"copy": "コピー",
"quote": "引用"
},
"window": {
"pin": "最前面に固定",

View File

@ -1876,7 +1876,8 @@
"summary": "Суммаризировать",
"search": "Поиск",
"refine": "Уточнить",
"copy": "Копировать"
"copy": "Копировать",
"quote": "Цитировать"
},
"window": {
"pin": "Закрепить",

View File

@ -1876,7 +1876,8 @@
"summary": "总结",
"search": "搜索",
"refine": "优化",
"copy": "复制"
"copy": "复制",
"quote": "引用"
},
"window": {
"pin": "置顶",

View File

@ -1876,7 +1876,8 @@
"summary": "總結",
"search": "搜尋",
"refine": "優化",
"copy": "複製"
"copy": "複製",
"quote": "引用"
},
"window": {
"pin": "置頂",

View File

@ -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()

View File

@ -193,7 +193,7 @@ const SelectionAssistantSettings: FC = () => {
<SelectionActionsList actionItems={actionItems} setActionItems={setActionItems} />
<SettingGroup>
<SettingTitle></SettingTitle>
<SettingTitle>{t('selection.settings.advanced.title')}</SettingTitle>
<SettingDivider />

View File

@ -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'
}

View File

@ -50,7 +50,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 110,
version: 111,
blacklist: ['runtime', 'messages', 'messageBlocks'],
migrate
},

View File

@ -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
}
}
}

View File

@ -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 = {

View File

@ -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-------------'
)
}

View File

@ -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()