mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-07 05:39:05 +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
1d82c13604
commit
88a3a74c76
@ -21,6 +21,8 @@ export enum IpcChannel {
|
|||||||
App_InstallUvBinary = 'app:install-uv-binary',
|
App_InstallUvBinary = 'app:install-uv-binary',
|
||||||
App_InstallBunBinary = 'app:install-bun-binary',
|
App_InstallBunBinary = 'app:install-bun-binary',
|
||||||
|
|
||||||
|
App_QuoteToMain = 'app:quote-to-main',
|
||||||
|
|
||||||
Notification_Send = 'notification:send',
|
Notification_Send = 'notification:send',
|
||||||
Notification_OnClick = 'notification:on-click',
|
Notification_OnClick = 'notification:on-click',
|
||||||
|
|
||||||
|
|||||||
@ -351,4 +351,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
|
|
||||||
// selection assistant
|
// selection assistant
|
||||||
SelectionService.registerIpcHandler()
|
SelectionService.registerIpcHandler()
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannel.App_QuoteToMain, (_, text: string) => windowService.quoteToMainWindow(text))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -544,6 +544,25 @@ export class WindowService {
|
|||||||
public setPinMiniWindow(isPinned) {
|
public setPinMiniWindow(isPinned) {
|
||||||
this.isPinnedMiniWindow = 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()
|
export const windowService = WindowService.getInstance()
|
||||||
|
|||||||
@ -228,7 +228,8 @@ const api = {
|
|||||||
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
|
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
|
||||||
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
||||||
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
|
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
|
// 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 { Dropdown } from 'antd'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@ -12,7 +11,6 @@ interface ContextMenuProps {
|
|||||||
const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) => {
|
const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||||
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
|
|
||||||
const [selectedText, setSelectedText] = useState<string>('')
|
const [selectedText, setSelectedText] = useState<string>('')
|
||||||
|
|
||||||
const handleContextMenu = useCallback(
|
const handleContextMenu = useCallback(
|
||||||
@ -20,12 +18,6 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const _selectedText = window.getSelection()?.toString()
|
const _selectedText = window.getSelection()?.toString()
|
||||||
if (_selectedText) {
|
if (_selectedText) {
|
||||||
const quotedText =
|
|
||||||
_selectedText
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => `> ${line}`)
|
|
||||||
.join('\n') + '\n-------------'
|
|
||||||
setSelectedQuoteText(quotedText)
|
|
||||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||||
setSelectedText(_selectedText)
|
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',
|
key: 'copy',
|
||||||
label: t('common.copy'),
|
label: t('common.copy'),
|
||||||
@ -66,8 +58,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
|||||||
key: 'quote',
|
key: 'quote',
|
||||||
label: t('chat.message.quote'),
|
label: t('chat.message.quote'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (selectedQuoteText) {
|
if (selectedText) {
|
||||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
window.api?.quoteToMainWindow(selectedText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -78,7 +70,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
|
|||||||
{contextMenuPosition && (
|
{contextMenuPosition && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
||||||
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
|
menu={{ items: getContextMenuItems(t, selectedText) }}
|
||||||
open={true}
|
open={true}
|
||||||
trigger={['contextMenu']}>
|
trigger={['contextMenu']}>
|
||||||
<div />
|
<div />
|
||||||
|
|||||||
@ -1876,7 +1876,8 @@
|
|||||||
"summary": "Summarize",
|
"summary": "Summarize",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"refine": "Refine",
|
"refine": "Refine",
|
||||||
"copy": "Copy"
|
"copy": "Copy",
|
||||||
|
"quote": "Quote"
|
||||||
},
|
},
|
||||||
"window": {
|
"window": {
|
||||||
"pin": "Pin",
|
"pin": "Pin",
|
||||||
|
|||||||
@ -1876,7 +1876,8 @@
|
|||||||
"summary": "要約",
|
"summary": "要約",
|
||||||
"search": "検索",
|
"search": "検索",
|
||||||
"refine": "最適化",
|
"refine": "最適化",
|
||||||
"copy": "コピー"
|
"copy": "コピー",
|
||||||
|
"quote": "引用"
|
||||||
},
|
},
|
||||||
"window": {
|
"window": {
|
||||||
"pin": "最前面に固定",
|
"pin": "最前面に固定",
|
||||||
|
|||||||
@ -1876,7 +1876,8 @@
|
|||||||
"summary": "Суммаризировать",
|
"summary": "Суммаризировать",
|
||||||
"search": "Поиск",
|
"search": "Поиск",
|
||||||
"refine": "Уточнить",
|
"refine": "Уточнить",
|
||||||
"copy": "Копировать"
|
"copy": "Копировать",
|
||||||
|
"quote": "Цитировать"
|
||||||
},
|
},
|
||||||
"window": {
|
"window": {
|
||||||
"pin": "Закрепить",
|
"pin": "Закрепить",
|
||||||
|
|||||||
@ -1876,7 +1876,8 @@
|
|||||||
"summary": "总结",
|
"summary": "总结",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"refine": "优化",
|
"refine": "优化",
|
||||||
"copy": "复制"
|
"copy": "复制",
|
||||||
|
"quote": "引用"
|
||||||
},
|
},
|
||||||
"window": {
|
"window": {
|
||||||
"pin": "置顶",
|
"pin": "置顶",
|
||||||
|
|||||||
@ -1876,7 +1876,8 @@
|
|||||||
"summary": "總結",
|
"summary": "總結",
|
||||||
"search": "搜尋",
|
"search": "搜尋",
|
||||||
"refine": "優化",
|
"refine": "優化",
|
||||||
"copy": "複製"
|
"copy": "複製",
|
||||||
|
"quote": "引用"
|
||||||
},
|
},
|
||||||
"window": {
|
"window": {
|
||||||
"pin": "置頂",
|
"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 { Assistant, FileType, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types'
|
||||||
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
||||||
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
||||||
|
import { formatQuotedText } from '@renderer/utils/formats'
|
||||||
import { getFilesFromDropEvent } from '@renderer/utils/input'
|
import { getFilesFromDropEvent } from '@renderer/utils/input'
|
||||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||||
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import { Button, Tooltip } from 'antd'
|
import { Button, Tooltip } from 'antd'
|
||||||
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
||||||
import dayjs from 'dayjs'
|
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)
|
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
|
||||||
}, [addTopic, assistant, setActiveTopic, setModel])
|
}, [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 () => {
|
const onPause = async () => {
|
||||||
await pauseMessages()
|
await pauseMessages()
|
||||||
}
|
}
|
||||||
@ -623,18 +638,20 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
_setEstimateTokenCount(tokensCount)
|
_setEstimateTokenCount(tokensCount)
|
||||||
setContextCount({ current: contextCount.current, max: contextCount.max }) // 现在contextCount是一个对象而不是单个数值
|
setContextCount({ current: contextCount.current, max: contextCount.max }) // 现在contextCount是一个对象而不是单个数值
|
||||||
}),
|
}),
|
||||||
EventEmitter.on(EVENT_NAMES.ADD_NEW_TOPIC, addNewTopic),
|
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()
|
|
||||||
})
|
|
||||||
]
|
]
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
|
|||||||
@ -193,7 +193,7 @@ const SelectionAssistantSettings: FC = () => {
|
|||||||
<SelectionActionsList actionItems={actionItems} setActionItems={setActionItems} />
|
<SelectionActionsList actionItems={actionItems} setActionItems={setActionItems} />
|
||||||
|
|
||||||
<SettingGroup>
|
<SettingGroup>
|
||||||
<SettingTitle>高级</SettingTitle>
|
<SettingTitle>{t('selection.settings.advanced.title')}</SettingTitle>
|
||||||
|
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,6 @@ export const EVENT_NAMES = {
|
|||||||
ADD_NEW_TOPIC: 'ADD_NEW_TOPIC',
|
ADD_NEW_TOPIC: 'ADD_NEW_TOPIC',
|
||||||
RESEND_MESSAGE: 'RESEND_MESSAGE',
|
RESEND_MESSAGE: 'RESEND_MESSAGE',
|
||||||
SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR',
|
SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR',
|
||||||
QUOTE_TEXT: 'QUOTE_TEXT',
|
|
||||||
EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK',
|
EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK',
|
||||||
CHANGE_TOPIC: 'CHANGE_TOPIC'
|
CHANGE_TOPIC: 'CHANGE_TOPIC'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,7 +50,7 @@ const persistedReducer = persistReducer(
|
|||||||
{
|
{
|
||||||
key: 'cherry-studio',
|
key: 'cherry-studio',
|
||||||
storage,
|
storage,
|
||||||
version: 110,
|
version: 111,
|
||||||
blacklist: ['runtime', 'messages', 'messageBlocks'],
|
blacklist: ['runtime', 'messages', 'messageBlocks'],
|
||||||
migrate
|
migrate
|
||||||
},
|
},
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { RootState } from '.'
|
|||||||
import { DEFAULT_TOOL_ORDER } from './inputTools'
|
import { DEFAULT_TOOL_ORDER } from './inputTools'
|
||||||
import { INITIAL_PROVIDERS, moveProvider } from './llm'
|
import { INITIAL_PROVIDERS, moveProvider } from './llm'
|
||||||
import { mcpSlice } from './mcp'
|
import { mcpSlice } from './mcp'
|
||||||
|
import { defaultActionItems } from './selectionStore'
|
||||||
import { DEFAULT_SIDEBAR_ICONS, initialState as settingsInitialState } from './settings'
|
import { DEFAULT_SIDEBAR_ICONS, initialState as settingsInitialState } from './settings'
|
||||||
import { defaultWebSearchProviders } from './websearch'
|
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 = {
|
const migrateConfig = {
|
||||||
'2': (state: RootState) => {
|
'2': (state: RootState) => {
|
||||||
try {
|
try {
|
||||||
@ -1485,6 +1497,14 @@ const migrateConfig = {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return state
|
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}}'
|
searchEngine: 'Google|https://www.google.com/search?q={{queryString}}'
|
||||||
},
|
},
|
||||||
{ id: 'copy', name: 'selection.action.builtin.copy', enabled: true, isBuiltIn: true, icon: 'clipboard-copy' },
|
{ 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 = {
|
export const initialState: SelectionState = {
|
||||||
|
|||||||
@ -179,3 +179,12 @@ export function addImageFileToContents(messages: Message[]) {
|
|||||||
|
|
||||||
return messages.map((message) => (message.id === lastAssistantMessage.id ? updatedAssistantMessage : 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':
|
case 'search':
|
||||||
handleSearch(newAction)
|
handleSearch(newAction)
|
||||||
break
|
break
|
||||||
|
case 'quote':
|
||||||
|
handleQuote(newAction)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
handleDefaultAction(newAction)
|
handleDefaultAction(newAction)
|
||||||
break
|
break
|
||||||
@ -220,6 +223,16 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
|
|||||||
window.api?.selection.hideToolbar()
|
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) => {
|
const handleDefaultAction = (action: ActionItem) => {
|
||||||
window.api?.selection.processAction(action)
|
window.api?.selection.processAction(action)
|
||||||
window.api?.selection.hideToolbar()
|
window.api?.selection.hideToolbar()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user