feat: Selection Assistant / 划词助手 (#5900)

* feat(selection): implement selection assistant with toolbar and action management

- Added selection assistant functionality including a toolbar for actions.
- Introduced new settings for enabling/disabling the selection assistant and configuring its behavior.
- Implemented action items for built-in functionalities like translate, explain, and copy.
- Integrated selection service to manage selection events and actions.
- Updated localization files to support new selection assistant features in multiple languages.
- Added new components for action management and user interaction within the selection assistant.

* chore: update selection-hook to version 0.9.10 and exclude prebuilds from packaging

* fix: toolbar hiding

* feat: enhance error handling and service management in main index

* fix: improve logical coordinate handling in SelectionService

* fix: update URL loading and coordinate conversion in SelectionService

* fix: replace console.error with Logger for error handling in SelectionService

* refactor(SelectionService): enhance preloaded action window management

* chore(electron-builder): add filter for .node build files in configuration

* fix: toolbar position calculating for multi monitor

* fix: update selection assistant configuration and improve error handling in SelectionService

* fix: SelectionActionUserModal layout

* feat: add hints for custom search URL in multiple languages

* fix: update calculateToolbarPosition to ensure integer return type and round position values

* feat: add action window opacity setting and update related UI components

refactor: SelectionActionsList

* chore: enhance tooltip for trigger mode settings

* fix: console.log

* chore: update selection-hook to version 0.9.12

* fix: integrate language settings into selection components

* fix: filter out default assistant from user predefined assistants in selection modal

* chore: update selection-hook package version to 0.9.13

* chore: update selection-hook package version to 0.9.14
This commit is contained in:
fullex 2025-05-26 16:50:52 +08:00 committed by GitHub
parent 665a62080b
commit 2ba4e51e93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 5144 additions and 5 deletions

View File

@ -43,6 +43,8 @@ files:
- '!node_modules/@tavily/core/node_modules/js-tiktoken'
- '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}'
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters}' # filter .node build files
asarUnpack:
- resources/**
- '**/*.{metal,exp,lib}'

View File

@ -89,7 +89,9 @@ export default defineConfig({
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html')
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'),
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html')
}
}
}

View File

@ -93,6 +93,7 @@
"officeparser": "^4.1.1",
"os-proxy-config": "^1.1.2",
"proxy-agent": "^6.5.0",
"selection-hook": "^0.9.14",
"tar": "^7.4.3",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",

View File

@ -176,5 +176,20 @@ export enum IpcChannel {
StoreSync_BroadcastSync = 'store-sync:broadcast-sync',
// Provider
Provider_AddKey = 'provider:add-key'
Provider_AddKey = 'provider:add-key',
//Selection Assistant
Selection_TextSelected = 'selection:text-selected',
Selection_ToolbarHide = 'selection:toolbar-hide',
Selection_ToolbarVisibilityChange = 'selection:toolbar-visibility-change',
Selection_ToolbarDetermineSize = 'selection:toolbar-determine-size',
Selection_WriteToClipboard = 'selection:write-to-clipboard',
Selection_SetEnabled = 'selection:set-enabled',
Selection_SetTriggerMode = 'selection:set-trigger-mode',
Selection_SetFollowToolbar = 'selection:set-follow-toolbar',
Selection_ActionWindowClose = 'selection:action-window-close',
Selection_ActionWindowMinimize = 'selection:action-window-minimize',
Selection_ActionWindowPin = 'selection:action-window-pin',
Selection_ProcessAction = 'selection:process-action',
Selection_UpdateActionData = 'selection:update-action-data'
}

View File

@ -16,6 +16,7 @@ import {
registerProtocolClient,
setupAppImageDeepLink
} from './services/ProtocolClient'
import selectionService, { initSelectionService } from './services/SelectionService'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
@ -84,6 +85,9 @@ if (!app.requestSingleInstanceLock()) {
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
//start selection assistant service
initSelectionService()
})
registerProtocolClient(app)
@ -110,6 +114,11 @@ if (!app.requestSingleInstanceLock()) {
app.on('before-quit', () => {
app.isQuitting = true
// quit selection service
if (selectionService) {
selectionService.quit()
}
})
app.on('will-quit', async () => {

View File

@ -26,6 +26,7 @@ import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import storeSyncService from './services/StoreSyncService'
import { TrayService } from './services/TrayService'
@ -379,4 +380,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// store sync
storeSyncService.registerIpcHandler()
// selection assistant
SelectionService.registerIpcHandler()
}

View File

@ -5,7 +5,7 @@ import Store from 'electron-store'
import { locales } from '../utils/locales'
enum ConfigKeys {
export enum ConfigKeys {
Language = 'language',
Theme = 'theme',
LaunchToTray = 'launchToTray',
@ -16,7 +16,10 @@ enum ConfigKeys {
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
EnableQuickAssistant = 'enableQuickAssistant',
AutoUpdate = 'autoUpdate',
EnableDataCollection = 'enableDataCollection'
EnableDataCollection = 'enableDataCollection',
SelectionAssistantEnabled = 'selectionAssistantEnabled',
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar'
}
export class ConfigManager {
@ -146,6 +149,36 @@ export class ConfigManager {
this.set(ConfigKeys.EnableDataCollection, value)
}
// Selection Assistant: is enabled the selection assistant
getSelectionAssistantEnabled(): boolean {
return this.get<boolean>(ConfigKeys.SelectionAssistantEnabled, true)
}
setSelectionAssistantEnabled(value: boolean) {
this.set(ConfigKeys.SelectionAssistantEnabled, value)
this.notifySubscribers(ConfigKeys.SelectionAssistantEnabled, value)
}
// Selection Assistant: trigger mode (selected, ctrlkey)
getSelectionAssistantTriggerMode(): string {
return this.get<string>(ConfigKeys.SelectionAssistantTriggerMode, 'selected')
}
setSelectionAssistantTriggerMode(value: string) {
this.set(ConfigKeys.SelectionAssistantTriggerMode, value)
this.notifySubscribers(ConfigKeys.SelectionAssistantTriggerMode, value)
}
// Selection Assistant: if action window position follow toolbar
getSelectionAssistantFollowToolbar(): boolean {
return this.get<boolean>(ConfigKeys.SelectionAssistantFollowToolbar, true)
}
setSelectionAssistantFollowToolbar(value: boolean) {
this.set(ConfigKeys.SelectionAssistantFollowToolbar, value)
this.notifySubscribers(ConfigKeys.SelectionAssistantFollowToolbar, value)
}
set(key: string, value: unknown) {
this.store.set(key, value)
}

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,8 @@ import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from '
import { Notification } from 'src/renderer/src/types/notification'
import { CreateDirectoryOptions } from 'webdav'
import type { ActionItem } from '../renderer/src/types/selectionTypes'
// Custom APIs for renderer
const api = {
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
@ -204,6 +206,20 @@ const api = {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe),
onUpdate: (action: any) => ipcRenderer.invoke(IpcChannel.StoreSync_OnUpdate, action)
},
selection: {
hideToolbar: () => ipcRenderer.invoke(IpcChannel.Selection_ToolbarHide),
writeToClipboard: (text: string) => ipcRenderer.invoke(IpcChannel.Selection_WriteToClipboard, text),
determineToolbarSize: (width: number, height: number) =>
ipcRenderer.invoke(IpcChannel.Selection_ToolbarDetermineSize, width, height),
setEnabled: (enabled: boolean) => ipcRenderer.invoke(IpcChannel.Selection_SetEnabled, enabled),
setTriggerMode: (triggerMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetTriggerMode, triggerMode),
setFollowToolbar: (isFollowToolbar: boolean) =>
ipcRenderer.invoke(IpcChannel.Selection_SetFollowToolbar, isFollowToolbar),
processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem),
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
}
}

View File

@ -0,0 +1,41 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio Selection Assistant</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/selection/action/entryPoint.tsx"></script>
<style>
html {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
}
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
}
</style>
</body>
</html>

View File

@ -0,0 +1,43 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio Selection Toolbar</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
<style>
html {
margin: 0;
}
body {
margin: 0;
padding: 0;
overflow: hidden;
width: 100vw;
height: 100vh;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
#root {
margin: 0;
padding: 0;
width: max-content !important;
height: fit-content !important;
}
</style>
</body>
</html>

View File

@ -0,0 +1,26 @@
@use './font.scss';
html {
font-family: var(--font-family);
}
:root {
--color-selection-toolbar-background: rgba(20, 20, 20, 0.95);
--color-selection-toolbar-border: rgba(55, 55, 55, 0.5);
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
--color-selection-toolbar-text: rgba(255, 255, 245, 0.9);
--color-selection-toolbar-hover-bg: #222222;
--color-primary: #00b96b;
--color-error: #f44336;
}
[theme-mode='light'] {
--color-selection-toolbar-background: rgba(245, 245, 245, 0.95);
--color-selection-toolbar-border: rgba(200, 200, 200, 0.5);
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
--color-selection-toolbar-text: rgba(0, 0, 0, 1);
--color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04);
}

View File

@ -0,0 +1,83 @@
import { Tooltip } from 'antd'
import { Copy } from 'lucide-react'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface CopyButtonProps {
tooltip?: string
textToCopy: string
label?: string
color?: string
hoverColor?: string
size?: number
}
interface ButtonContainerProps {
$color: string
$hoverColor: string
}
const CopyButton: FC<CopyButtonProps> = ({
tooltip,
textToCopy,
label,
color = 'var(--color-text-2)',
hoverColor = 'var(--color-primary)',
size = 14
}) => {
const { t } = useTranslation()
const handleCopy = () => {
navigator.clipboard
.writeText(textToCopy)
.then(() => {
window.message?.success(t('message.copy.success'))
})
.catch(() => {
window.message?.error(t('message.copy.failed'))
})
}
const button = (
<ButtonContainer $color={color} $hoverColor={hoverColor} onClick={handleCopy}>
<Copy size={size} className="copy-icon" />
{label && <RightText size={size}>{label}</RightText>}
</ButtonContainer>
)
if (tooltip) {
return <Tooltip title={tooltip}>{button}</Tooltip>
}
return button
}
const ButtonContainer = styled.div<ButtonContainerProps>`
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
cursor: pointer;
color: ${(props) => props.$color};
transition: color 0.2s;
.copy-icon {
color: ${(props) => props.$color};
transition: color 0.2s;
}
&:hover {
color: ${(props) => props.$hoverColor};
.copy-icon {
color: ${(props) => props.$hoverColor};
}
}
`
const RightText = styled.span<{ size: number }>`
font-size: ${(props) => props.size}px;
`
export default CopyButton

View File

@ -0,0 +1,48 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
setActionItems,
setActionWindowOpacity,
setIsAutoClose,
setIsAutoPin,
setIsCompact,
setIsFollowToolbar,
setSelectionEnabled,
setTriggerMode
} from '@renderer/store/selectionStore'
import { ActionItem, TriggerMode } from '@renderer/types/selectionTypes'
export function useSelectionAssistant() {
const dispatch = useAppDispatch()
const selectionStore = useAppSelector((state) => state.selectionStore)
return {
...selectionStore,
setSelectionEnabled: (enabled: boolean) => {
dispatch(setSelectionEnabled(enabled))
window.api.selection.setEnabled(enabled)
},
setTriggerMode: (mode: TriggerMode) => {
dispatch(setTriggerMode(mode))
window.api.selection.setTriggerMode(mode)
},
setIsCompact: (isCompact: boolean) => {
dispatch(setIsCompact(isCompact))
},
setIsAutoClose: (isAutoClose: boolean) => {
dispatch(setIsAutoClose(isAutoClose))
},
setIsAutoPin: (isAutoPin: boolean) => {
dispatch(setIsAutoPin(isAutoPin))
},
setIsFollowToolbar: (isFollowToolbar: boolean) => {
dispatch(setIsFollowToolbar(isFollowToolbar))
window.api.selection.setFollowToolbar(isFollowToolbar)
},
setActionWindowOpacity: (opacity: number) => {
dispatch(setActionWindowOpacity(opacity))
},
setActionItems: (items: ActionItem[]) => {
dispatch(setActionItems(items))
}
}
}

View File

@ -1779,6 +1779,141 @@
"quit": "Quit",
"show_window": "Show Window",
"visualization": "Visualization"
},
"selection": {
"name": "Selection Assistant",
"action": {
"builtin": {
"translate": "Translate",
"explain": "Explain",
"summary": "Summarize",
"search": "Search",
"refine": "Refine",
"copy": "Copy"
},
"window": {
"pin": "Pin",
"pinned": "Pinned",
"opacity": "Window Opacity",
"original_show": "Show Original",
"original_hide": "Hide Original",
"original_copy": "Copy Original",
"esc_close": "Esc to Close",
"esc_stop": "Esc to Stop",
"c_copy": "C to Copy"
}
},
"settings": {
"experimental": "Experimental Features",
"enable": {
"title": "Enable",
"description": "Currently only supported on Windows systems"
},
"toolbar": {
"title": "Toolbar",
"trigger_mode": {
"title": "Trigger Mode",
"description": "Show toolbar immediately when text is selected, or show only when Ctrl key is held after selection.",
"description_note": "The Ctrl key may not work in some apps. If you use AHK or other tools to remap the Ctrl key, it may not work.",
"selected": "Selection",
"ctrlkey": "Ctrl Key"
},
"compact_mode": {
"title": "Compact Mode",
"description": "In compact mode, only icons are displayed without text"
}
},
"window": {
"title": "Action Window",
"follow_toolbar": {
"title": "Follow Toolbar",
"description": "Window position will follow the toolbar. When disabled, it will always be centered."
},
"auto_close": {
"title": "Auto Close",
"description": "Automatically close the window when it's not pinned and loses focus"
},
"auto_pin": {
"title": "Auto Pin",
"description": "Pin the window by default"
},
"opacity": {
"title": "Opacity",
"description": "Set the default opacity of the window, 100% is fully opaque"
}
},
"actions": {
"title": "Actions",
"reset": {
"button": "Reset",
"tooltip": "Reset to default actions. Custom actions will not be deleted.",
"confirm": "Are you sure you want to reset to default actions? Custom actions will not be deleted."
},
"add_tooltip": {
"enabled": "Add Custom Action",
"disabled": "Maximum number of custom actions reached ({{max}})"
},
"delete_confirm": "Are you sure you want to delete this custom action?",
"drag_hint": "Drag to reorder. Move above to enable action ({{enabled}}/{{max}})"
},
"user_modal": {
"title": {
"add": "Add Custom Action",
"edit": "Edit Custom Action"
},
"name": {
"label": "Name",
"hint": "Please enter action name"
},
"icon": {
"label": "Icon",
"placeholder": "Enter Lucide icon name",
"error": "Invalid icon name, please check your input",
"tooltip": "Lucide icon names are lowercase, e.g. arrow-right",
"view_all": "View All Icons",
"random": "Random Icon"
},
"model": {
"label": "Model",
"tooltip": "Using Assistant: Will use both the assistant's system prompt and model parameters",
"default": "Default Model",
"assistant": "Use Assistant"
},
"assistant": {
"label": "Select Assistant",
"default": "Default"
},
"prompt": {
"label": "User Prompt",
"tooltip": "User prompt serves as a supplement to user input and won't override the assistant's system prompt",
"placeholder": "Use placeholder {{text}} to represent selected text. When empty, selected text will be appended to this prompt",
"placeholder_text": "Placeholder",
"copy_placeholder": "Copy Placeholder"
}
},
"search_modal": {
"title": "Set Search Engine",
"engine": {
"label": "Search Engine",
"custom": "Custom"
},
"custom": {
"name": {
"label": "Custom Name",
"hint": "Please enter search engine name",
"max_length": "Name cannot exceed 16 characters"
},
"url": {
"label": "Custom Search URL",
"hint": "Use {{queryString}} to represent the search term",
"required": "Please enter search URL",
"invalid_format": "Please enter a valid URL starting with http:// or https://",
"missing_placeholder": "URL must contain {{queryString}} placeholder"
},
"test": "Test"
}
}
}
}
}
}

View File

@ -1779,6 +1779,141 @@
"quit": "終了",
"show_window": "ウィンドウを表示",
"visualization": "可視化"
},
"selection": {
"name": "テキスト選択ツール",
"action": {
"builtin": {
"translate": "翻訳",
"explain": "解説",
"summary": "要約",
"search": "検索",
"refine": "最適化",
"copy": "コピー"
},
"window": {
"pin": "最前面に固定",
"pinned": "固定中",
"opacity": "ウィンドウの透過度",
"original_show": "原文を表示",
"original_hide": "原文を非表示",
"original_copy": "原文をコピー",
"esc_close": "Escで閉じる",
"esc_stop": "Escで停止",
"c_copy": "Cでコピー"
}
},
"settings": {
"experimental": "実験的機能",
"enable": {
"title": "有効化",
"description": "現在Windowsのみ対応"
},
"toolbar": {
"title": "ツールバー",
"trigger_mode": {
"title": "表示方法",
"description": "テキスト選択時に即時表示、またはCtrlキー押下時のみ表示",
"description_note": "一部のアプリはCtrlキーでのテキスト選択に対応していません。AHKなどでCtrlキーをリマップすると、選択できなくなる場合があります。",
"selected": "選択時",
"ctrlkey": "Ctrlキー"
},
"compact_mode": {
"title": "コンパクトモード",
"description": "アイコンのみ表示(テキスト非表示)"
}
},
"window": {
"title": "機能ウィンドウ",
"follow_toolbar": {
"title": "ツールバーに追従",
"description": "ウィンドウ位置をツールバーに連動(無効時は中央表示)"
},
"auto_close": {
"title": "自動閉じる",
"description": "最前面固定されていない場合、フォーカス喪失時に自動閉じる"
},
"auto_pin": {
"title": "自動で最前面に固定",
"description": "デフォルトで最前面表示"
},
"opacity": {
"title": "透明度",
"description": "デフォルトの透明度を設定100%は完全不透明)"
}
},
"actions": {
"title": "機能設定",
"reset": {
"button": "リセット",
"tooltip": "デフォルト機能にリセット(カスタム機能は保持)",
"confirm": "デフォルト機能にリセットしますか?\nカスタム機能は削除されません"
},
"add_tooltip": {
"enabled": "カスタム機能を追加",
"disabled": "カスタム機能の上限に達しました (最大{{max}}個)"
},
"delete_confirm": "このカスタム機能を削除しますか?",
"drag_hint": "ドラッグで並べ替え (有効{{enabled}}/最大{{max}})"
},
"user_modal": {
"title": {
"add": "カスタム機能追加",
"edit": "カスタム機能編集"
},
"name": {
"label": "機能名",
"hint": "機能名を入力"
},
"icon": {
"label": "アイコン",
"placeholder": "Lucideアイコン名を入力",
"error": "無効なアイコン名です",
"tooltip": "例: arrow-right小文字で入力",
"view_all": "全アイコンを表示",
"random": "ランダム選択"
},
"model": {
"label": "モデル",
"tooltip": "アシスタント使用時はシステムプロンプトとモデルパラメータも適用",
"default": "デフォルトモデル",
"assistant": "アシスタントを使用"
},
"assistant": {
"label": "アシスタント選択",
"default": "デフォルト"
},
"prompt": {
"label": "ユーザープロンプト",
"tooltip": "アシスタントのシステムプロンプトを上書きせず、入力補助として機能",
"placeholder": "{{text}}で選択テキストを参照(未入力時は末尾に追加)",
"placeholder_text": "プレースホルダー",
"copy_placeholder": "プレースホルダーをコピー"
}
},
"search_modal": {
"title": "検索エンジン設定",
"engine": {
"label": "検索エンジン",
"custom": "カスタム"
},
"custom": {
"name": {
"label": "表示名",
"hint": "検索エンジン名16文字以内",
"max_length": "16文字以内で入力"
},
"url": {
"label": "検索URL",
"hint": "{{queryString}}で検索語を表す",
"required": "URLを入力してください",
"invalid_format": "http:// または https:// で始まるURLを入力",
"missing_placeholder": "{{queryString}}を含めてください"
},
"test": "テスト"
}
}
}
}
}
}

View File

@ -1780,6 +1780,141 @@
"quit": "Выйти",
"show_window": "Показать окно",
"visualization": "Визуализация"
},
"selection": {
"name": "Помощник выбора",
"action": {
"builtin": {
"translate": "Перевести",
"explain": "Объяснить",
"summary": "Суммаризировать",
"search": "Поиск",
"refine": "Уточнить",
"copy": "Копировать"
},
"window": {
"pin": "Закрепить",
"pinned": "Закреплено",
"opacity": "Прозрачность окна",
"original_show": "Показать оригинал",
"original_hide": "Скрыть оригинал",
"original_copy": "Копировать оригинал",
"esc_close": "Esc - закрыть",
"esc_stop": "Esc - остановить",
"c_copy": "C - копировать"
}
},
"settings": {
"experimental": "Экспериментальные функции",
"enable": {
"title": "Включить",
"description": "Поддерживается только в Windows"
},
"toolbar": {
"title": "Панель инструментов",
"trigger_mode": {
"title": "Режим активации",
"description": "Показывать панель сразу при выделении или только при удержании Ctrl.",
"description_note": "В некоторых приложениях Ctrl может не работать. Если вы используете AHK или другие инструменты для переназначения Ctrl, это может привести к тому, что некоторые приложения не смогут выделить текст.",
"selected": "При выделении",
"ctrlkey": "По Ctrl"
},
"compact_mode": {
"title": "Компактный режим",
"description": "Отображать только иконки без текста"
}
},
"window": {
"title": "Окно действий",
"follow_toolbar": {
"title": "Следовать за панелью",
"description": "Окно будет следовать за панелью. Иначе - по центру."
},
"auto_close": {
"title": "Автозакрытие",
"description": "Закрывать окно при потере фокуса (если не закреплено)"
},
"auto_pin": {
"title": "Автозакрепление",
"description": "Закреплять окно по умолчанию"
},
"opacity": {
"title": "Прозрачность",
"description": "Установить прозрачность окна по умолчанию"
}
},
"actions": {
"title": "Действия",
"reset": {
"button": "Сбросить",
"tooltip": "Сбросить стандартные действия. Пользовательские останутся.",
"confirm": "Сбросить стандартные действия? Пользовательские останутся."
},
"add_tooltip": {
"enabled": "Добавить действие",
"disabled": "Достигнут лимит ({{max}})"
},
"delete_confirm": "Удалить это действие?",
"drag_hint": "Перетащите для сортировки. Включено: {{enabled}}/{{max}}"
},
"user_modal": {
"title": {
"add": "Добавить действие",
"edit": "Редактировать действие"
},
"name": {
"label": "Название",
"hint": "Введите название"
},
"icon": {
"label": "Иконка",
"placeholder": "Название иконки Lucide",
"error": "Некорректное название",
"tooltip": "Названия в lowercase, например arrow-right",
"view_all": "Все иконки",
"random": "Случайная"
},
"model": {
"label": "Модель",
"tooltip": "Использовать ассистента: будут применены его системные настройки",
"default": "По умолчанию",
"assistant": "Ассистент"
},
"assistant": {
"label": "Ассистент",
"default": "По умолчанию"
},
"prompt": {
"label": "Промпт",
"tooltip": "Дополняет ввод пользователя, не заменяя системный промпт ассистента",
"placeholder": "Используйте {{text}} для выделенного текста. Если пусто - текст будет добавлен",
"placeholder_text": "Плейсхолдер",
"copy_placeholder": "Копировать плейсхолдер"
}
},
"search_modal": {
"title": "Поисковая система",
"engine": {
"label": "Поисковик",
"custom": "Свой"
},
"custom": {
"name": {
"label": "Название",
"hint": "Название поисковика",
"max_length": "Не более 16 символов"
},
"url": {
"label": "URL поиска",
"hint": "Используйте {{queryString}} для представления поискового запроса",
"required": "Введите URL",
"invalid_format": "URL должен начинаться с http:// или https://",
"missing_placeholder": "Должен содержать {{queryString}}"
},
"test": "Тест"
}
}
}
}
}
}

View File

@ -1779,6 +1779,141 @@
"quit": "退出",
"show_window": "显示窗口",
"visualization": "可视化"
},
"selection": {
"name": "划词助手",
"action": {
"builtin": {
"translate": "翻译",
"explain": "解释",
"summary": "总结",
"search": "搜索",
"refine": "优化",
"copy": "复制"
},
"window": {
"pin": "置顶",
"pinned": "已置顶",
"opacity": "窗口透明度",
"original_show": "显示原文",
"original_hide": "隐藏原文",
"original_copy": "复制原文",
"esc_close": "Esc 关闭",
"esc_stop": "Esc 停止",
"c_copy": "C 复制"
}
},
"settings": {
"experimental": "实验性功能",
"enable": {
"title": "启用",
"description": "当前仅支持 Windows 系统"
},
"toolbar": {
"title": "工具栏",
"trigger_mode": {
"title": "触发方式",
"description": "划词立即显示工具栏,或者划词后按住 Ctrl 键才显示工具栏。",
"description_note": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。",
"selected": "划词",
"ctrlkey": "Ctrl 键"
},
"compact_mode": {
"title": "紧凑模式",
"description": "紧凑模式下,只显示图标,不显示文字"
}
},
"window": {
"title": "功能窗口",
"follow_toolbar": {
"title": "跟随工具栏",
"description": "窗口位置将跟随工具栏显示,禁用后则始终居中显示"
},
"auto_close": {
"title": "自动关闭",
"description": "当窗口未置顶且失去焦点时,将自动关闭该窗口"
},
"auto_pin": {
"title": "自动置顶",
"description": "默认将窗口置于顶部"
},
"opacity": {
"title": "透明度",
"description": "设置窗口的默认透明度100%为完全不透明"
}
},
"actions": {
"title": "功能",
"reset": {
"button": "重置",
"tooltip": "重置为默认功能,自定义功能不会被删除",
"confirm": "确定要重置为默认功能吗?自定义功能不会被删除。"
},
"add_tooltip": {
"enabled": "添加自定义功能",
"disabled": "自定义功能已达上限 ({{max}}个)"
},
"delete_confirm": "确定要删除这个自定义功能吗?",
"drag_hint": "拖拽排序,移动到上方以启用功能 ({{enabled}}/{{max}})"
},
"user_modal": {
"title": {
"add": "添加自定义功能",
"edit": "编辑自定义功能"
},
"name": {
"label": "名称",
"hint": "请输入功能名称"
},
"icon": {
"label": "图标",
"placeholder": "输入 Lucide 图标名称",
"error": "无效的图标名称,请检查输入",
"tooltip": "Lucide图标名称为小写如 arrow-right",
"view_all": "查看所有图标",
"random": "随机图标"
},
"model": {
"label": "模型",
"tooltip": "使用助手:会同时使用助手的系统提示词和模型参数",
"default": "默认模型",
"assistant": "使用助手"
},
"assistant": {
"label": "选择助手",
"default": "默认"
},
"prompt": {
"label": "用户提示词(Prompt)",
"tooltip": "用户提示词,作为用户输入的补充,不会覆盖助手的系统提示词",
"placeholder": "使用占位符{{text}}代表选中的文本,不填写时,选中的文本将添加到本提示词的末尾",
"placeholder_text": "占位符",
"copy_placeholder": "复制占位符"
}
},
"search_modal": {
"title": "设置搜索引擎",
"engine": {
"label": "搜索引擎",
"custom": "自定义"
},
"custom": {
"name": {
"label": "自定义名称",
"hint": "请输入搜索引擎名称",
"max_length": "名称不能超过16个字符"
},
"url": {
"label": "自定义搜索 URL",
"hint": "用 {{queryString}} 代表搜索词",
"required": "请输入搜索 URL",
"invalid_format": "请输入以 http:// 或 https:// 开头的有效 URL",
"missing_placeholder": "URL 必须包含 {{queryString}} 占位符"
},
"test": "测试"
}
}
}
}
}
}

View File

@ -1780,6 +1780,141 @@
"quit": "結束",
"show_window": "顯示視窗",
"visualization": "視覺化"
},
"selection": {
"name": "劃詞助手",
"action": {
"builtin": {
"translate": "翻譯",
"explain": "解釋",
"summary": "總結",
"search": "搜尋",
"refine": "優化",
"copy": "複製"
},
"window": {
"pin": "置頂",
"pinned": "已置頂",
"opacity": "視窗透明度",
"original_show": "顯示原文",
"original_hide": "隱藏原文",
"original_copy": "複製原文",
"esc_close": "Esc 關閉",
"esc_stop": "Esc 停止",
"c_copy": "C 複製"
}
},
"settings": {
"experimental": "實驗性功能",
"enable": {
"title": "啟用",
"description": "目前僅支援 Windows 系統"
},
"toolbar": {
"title": "工具列",
"trigger_mode": {
"title": "觸發方式",
"description": "劃詞立即顯示工具列,或者劃詞後按住 Ctrl 鍵才顯示工具列。",
"description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了AHK等工具對Ctrl鍵進行了重新對應可能導致部分應用程式無法劃詞。",
"selected": "劃詞",
"ctrlkey": "Ctrl 鍵"
},
"compact_mode": {
"title": "緊湊模式",
"description": "緊湊模式下,只顯示圖示,不顯示文字"
}
},
"window": {
"title": "功能視窗",
"follow_toolbar": {
"title": "跟隨工具列",
"description": "視窗位置將跟隨工具列顯示,停用後則始終置中顯示"
},
"auto_close": {
"title": "自動關閉",
"description": "當視窗未置頂且失去焦點時,將自動關閉該視窗"
},
"auto_pin": {
"title": "自動置頂",
"description": "預設將視窗置於頂部"
},
"opacity": {
"title": "透明度",
"description": "設置視窗的默認透明度100%為完全不透明"
}
},
"actions": {
"title": "功能",
"reset": {
"button": "重設",
"tooltip": "重設為預設功能,自訂功能不會被刪除",
"confirm": "確定要重設為預設功能嗎?自訂功能不會被刪除。"
},
"add_tooltip": {
"enabled": "新增自訂功能",
"disabled": "自訂功能已達上限 ({{max}}個)"
},
"delete_confirm": "確定要刪除這個自訂功能嗎?",
"drag_hint": "拖曳排序,移動到上方以啟用功能 ({{enabled}}/{{max}})"
},
"user_modal": {
"title": {
"add": "新增自訂功能",
"edit": "編輯自訂功能"
},
"name": {
"label": "名稱",
"hint": "請輸入功能名稱"
},
"icon": {
"label": "圖示",
"placeholder": "輸入 Lucide 圖示名稱",
"error": "無效的圖示名稱,請檢查輸入",
"tooltip": "Lucide圖示名稱為小寫如 arrow-right",
"view_all": "檢視所有圖示",
"random": "隨機圖示"
},
"model": {
"label": "模型",
"tooltip": "使用助手:會同時使用助手的系統提示詞和模型參數",
"default": "預設模型",
"assistant": "使用助手"
},
"assistant": {
"label": "選擇助手",
"default": "預設"
},
"prompt": {
"label": "使用者提示詞(Prompt)",
"tooltip": "使用者提示詞,作為使用者輸入的補充,不會覆蓋助手的系統提示詞",
"placeholder": "使用佔位符{{text}}代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾",
"placeholder_text": "佔位符",
"copy_placeholder": "複製佔位符"
}
},
"search_modal": {
"title": "設定搜尋引擎",
"engine": {
"label": "搜尋引擎",
"custom": "自訂"
},
"custom": {
"name": {
"label": "自訂名稱",
"hint": "請輸入搜尋引擎名稱",
"max_length": "名稱不能超過16個字元"
},
"url": {
"label": "自訂搜尋 URL",
"hint": "使用 {{queryString}} 代表搜尋詞",
"required": "請輸入搜尋 URL",
"invalid_format": "請輸入以 http:// 或 https:// 開頭的有效 URL",
"missing_placeholder": "URL 必須包含 {{queryString}} 佔位符"
},
"test": "測試"
}
}
}
}
}
}

View File

@ -0,0 +1,232 @@
import type { ActionItem } from '@renderer/types/selectionTypes'
import { Button, Form, Input, Modal, Select } from 'antd'
import { Globe } from 'lucide-react'
import { FC, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
interface SearchEngineOption {
label: string
value: string
searchEngine: string
icon: React.ReactNode
}
export const LogoBing = (props) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M11.501 3v8.5h-8.5V3zm0 18h-8.5v-8.5h8.5zm1-18h8.5v8.5h-8.5zm8.5 9.5V21h-8.5v-8.5z"
/>
</svg>
)
}
export const LogoBaidu = (props) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M5.926 12.497c2.063-.444 1.782-2.909 1.72-3.448c-.1-.83-1.078-2.282-2.404-2.167c-1.67.15-1.914 2.561-1.914 2.561c-.226 1.115.54 3.497 2.598 3.053m2.191 4.288c-.06.173-.195.616-.079 1.002c.23.866.982.905.982.905h1.08v-2.64H8.944c-.52.154-.77.559-.827.733m1.638-8.422c1.14 0 2.06-1.312 2.06-2.933s-.92-2.93-2.06-2.93c-1.138 0-2.06 1.31-2.06 2.93s.923 2.933 2.06 2.933m4.907.193c1.523.198 2.502-1.427 2.697-2.659c.198-1.23-.784-2.658-1.862-2.904c-1.08-.248-2.43 1.483-2.552 2.61c-.147 1.38.197 2.758 1.717 2.953m0 3.448c-1.865-2.905-4.513-1.723-5.399-.245c-.882 1.477-2.256 2.41-2.452 2.658c-.198.244-2.846 1.673-2.258 4.284c.588 2.609 2.653 2.56 2.653 2.56s1.521.15 3.286-.246c1.766-.391 3.286.098 3.286.098s4.124 1.38 5.253-1.278c1.127-2.66-.638-4.038-.638-4.038s-2.356-1.823-3.731-3.793m-6.007 7.75c-1.158-.231-1.62-1.021-1.677-1.156c-.057-.137-.386-.772-.212-1.853c.5-1.619 1.927-1.735 1.927-1.735h1.427v-1.755l1.216.02v6.479zm4.59-.019c-1.196-.308-1.252-1.158-1.252-1.158v-3.412l1.252-.02v3.066c.076.328.482.387.482.387H15v-3.433h1.331v4.57zm7.453-9.11c0-.59-.49-2.364-2.305-2.364c-1.818 0-2.061 1.675-2.061 2.859c0 1.13.095 2.707 2.354 2.657s2.012-2.56 2.012-3.152"
/>
</svg>
)
}
export const LogoGoogle = (props) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M3.064 7.51A10 10 0 0 1 12 2c2.695 0 4.959.991 6.69 2.605l-2.867 2.868C14.786 6.482 13.468 5.977 12 5.977c-2.605 0-4.81 1.76-5.595 4.123c-.2.6-.314 1.24-.314 1.9s.114 1.3.314 1.9c.786 2.364 2.99 4.123 5.595 4.123c1.345 0 2.49-.355 3.386-.955a4.6 4.6 0 0 0 1.996-3.018H12v-3.868h9.418c.118.654.182 1.336.182 2.045c0 3.046-1.09 5.61-2.982 7.35C16.964 21.105 14.7 22 12 22A9.996 9.996 0 0 1 2 12c0-1.614.386-3.14 1.064-4.49"
/>
</svg>
)
}
export const DEFAULT_SEARCH_ENGINES: SearchEngineOption[] = [
{
label: 'Google',
value: 'Google',
searchEngine: 'Google|https://www.google.com/search?q={{queryString}}',
icon: <LogoGoogle style={{ fontSize: '14px', color: 'var(--color-text-2)' }} />
},
{
label: 'Baidu',
value: 'Baidu',
searchEngine: 'Baidu|https://www.baidu.com/s?wd={{queryString}}',
icon: <LogoBaidu style={{ fontSize: '14px', color: 'var(--color-text-2)' }} />
},
{
label: 'Bing',
value: 'Bing',
searchEngine: 'Bing|https://www.bing.com/search?q={{queryString}}',
icon: <LogoBing style={{ fontSize: '14px', color: 'var(--color-text-2)' }} />
},
{
label: '',
value: 'custom',
searchEngine: '',
icon: <Globe size={14} color="var(--color-text-2)" />
}
]
const EXAMPLE_URL = 'https://example.com/search?q={{queryString}}'
interface SelectionActionSearchModalProps {
isModalOpen: boolean
onOk: (searchEngine: string) => void
onCancel: () => void
currentAction?: ActionItem
}
const SelectionActionSearchModal: FC<SelectionActionSearchModalProps> = ({
isModalOpen,
onOk,
onCancel,
currentAction
}) => {
const { t } = useTranslation()
const [form] = Form.useForm()
useEffect(() => {
if (isModalOpen && currentAction?.searchEngine) {
form.resetFields()
const [engine, url] = currentAction.searchEngine.split('|')
const defaultEngine = DEFAULT_SEARCH_ENGINES.find((e) => e.value === engine)
if (defaultEngine) {
form.setFieldsValue({
engine: defaultEngine.value,
customName: '',
customUrl: ''
})
} else {
// Handle custom search engine
form.setFieldsValue({
engine: 'custom',
customName: engine,
customUrl: url
})
}
}
}, [isModalOpen, currentAction, form])
const handleOk = async () => {
try {
const values = await form.validateFields()
const selectedEngine = DEFAULT_SEARCH_ENGINES.find((e) => e.value === values.engine)
const searchEngine =
selectedEngine?.value === 'custom'
? `${values.customName}|${values.customUrl}`
: selectedEngine?.searchEngine || ''
onOk(searchEngine)
} catch (error) {
console.error('Validation failed:', error)
}
}
const handleCancel = () => {
onCancel()
}
const handleTest = () => {
const values = form.getFieldsValue()
if (values.customUrl) {
const testUrl = values.customUrl.replace('{{queryString}}', 'cherry studio')
window.api.openWebsite(testUrl)
}
}
return (
<Modal
title={t('selection.settings.search_modal.title')}
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
destroyOnClose
centered>
<Form
form={form}
layout="vertical"
initialValues={{
engine: 'Google',
customName: '',
customUrl: ''
}}>
<Form.Item name="engine" label={t('selection.settings.search_modal.engine.label')}>
<Select
options={DEFAULT_SEARCH_ENGINES.map((engine) => ({
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{engine.icon}
<span>{engine.label || t('selection.settings.search_modal.engine.custom')}</span>
</div>
),
value: engine.value
}))}
onChange={(value) => {
if (value === 'custom') {
form.setFieldsValue({
customName: '',
customUrl: EXAMPLE_URL
})
}
}}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.engine !== currentValues.engine}>
{({ getFieldValue }) =>
getFieldValue('engine') === 'custom' ? (
<>
<Form.Item
name="customName"
label={t('selection.settings.search_modal.custom.name.label')}
rules={[
{ required: true, message: t('selection.settings.search_modal.custom.name.hint') },
{ max: 16, message: t('selection.settings.search_modal.custom.name.max_length') }
]}>
<Input placeholder={t('selection.settings.search_modal.custom.name.hint')} />
</Form.Item>
<Form.Item
name="customUrl"
label={t('selection.settings.search_modal.custom.url.label')}
tooltip={t('selection.settings.search_modal.custom.url.hint')}
rules={[
{ required: true, message: t('selection.settings.search_modal.custom.url.required') },
{
pattern: /^https?:\/\/.+$/,
message: t('selection.settings.search_modal.custom.url.invalid_format')
},
{
validator: (_, value) => {
if (value && !value.includes('{{queryString}}')) {
return Promise.reject(t('selection.settings.search_modal.custom.url.missing_placeholder'))
}
return Promise.resolve()
}
}
]}>
<Input
placeholder={EXAMPLE_URL}
suffix={
<Button type="link" size="small" onClick={handleTest} style={{ padding: 0, height: 'auto' }}>
{t('selection.settings.search_modal.custom.test')}
</Button>
}
/>
</Form.Item>
</>
) : null
}
</Form.Item>
</Form>
</Modal>
)
}
export default SelectionActionSearchModal

View File

@ -0,0 +1,337 @@
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import CopyButton from '@renderer/components/CopyButton'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { getDefaultModel } from '@renderer/services/AssistantService'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { Col, Input, Modal, Radio, Row, Select, Space, Tooltip } from 'antd'
import { CircleHelp, Dices, OctagonX } from 'lucide-react'
import { DynamicIcon, iconNames } from 'lucide-react/dynamic'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface SelectionActionUserModalProps {
isModalOpen: boolean
editingAction: ActionItem | null
onOk: (data: ActionItem) => void
onCancel: () => void
}
const SelectionActionUserModal: FC<SelectionActionUserModalProps> = ({
isModalOpen,
editingAction,
onOk,
onCancel
}) => {
const { t } = useTranslation()
const { assistants: userPredefinedAssistants } = useAssistants()
const { defaultAssistant } = useDefaultAssistant()
const [formData, setFormData] = useState<Partial<ActionItem>>({})
const [errors, setErrors] = useState<Partial<Record<keyof ActionItem, string>>>({})
useEffect(() => {
if (isModalOpen) {
// 如果是编辑模式,使用现有数据;否则使用空数据
setFormData(
editingAction || {
name: '',
prompt: '',
icon: '',
assistantId: ''
}
)
setErrors({})
}
}, [isModalOpen, editingAction])
const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof ActionItem, string>> = {}
if (!formData.name?.trim()) {
newErrors.name = t('selection.settings.user_modal.name.hint')
}
if (formData.icon && !iconNames.includes(formData.icon as any)) {
newErrors.icon = t('selection.settings.user_modal.icon.error')
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleOk = () => {
if (!validateForm()) {
return
}
// 构建完整的 ActionItem
const actionItem: ActionItem = {
id: editingAction?.id || `user-${Date.now()}`,
name: formData.name || 'USER',
enabled: editingAction?.enabled || false,
isBuiltIn: editingAction?.isBuiltIn || false,
icon: formData.icon,
prompt: formData.prompt,
assistantId: formData.assistantId
}
onOk(actionItem)
}
const handleInputChange = (field: keyof ActionItem, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Clear error when user starts typing
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }))
}
}
return (
<Modal
title={
editingAction ? t('selection.settings.user_modal.title.edit') : t('selection.settings.user_modal.title.add')
}
open={isModalOpen}
onOk={handleOk}
onCancel={onCancel}
width={520}>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<ModalSection>
<div style={{ display: 'flex', flexDirection: 'row' }}>
<Col flex="auto" style={{ paddingRight: '16px', width: '70%' }}>
<ModalSectionTitle>
<ModalSectionTitleLabel>{t('selection.settings.user_modal.name.label')}</ModalSectionTitleLabel>
</ModalSectionTitle>
<Input
placeholder={t('selection.settings.user_modal.name.hint')}
value={formData.name || ''}
onChange={(e) => handleInputChange('name', e.target.value)}
maxLength={16}
status={errors.name ? 'error' : ''}
/>
{errors.name && <ErrorText>{errors.name}</ErrorText>}
</Col>
<Col>
<ModalSectionTitle>
<ModalSectionTitleLabel>{t('selection.settings.user_modal.icon.label')}</ModalSectionTitleLabel>
<Tooltip placement="top" title={t('selection.settings.user_modal.icon.tooltip')} arrow>
<QuestionIcon size={14} />
</Tooltip>
<Spacer />
<a
href="https://lucide.dev/icons/"
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '12px', color: 'var(--color-primary)' }}>
{t('selection.settings.user_modal.icon.view_all')}
</a>
<Tooltip title={t('selection.settings.user_modal.icon.random')}>
<DiceButton
onClick={() => {
const randomIcon = iconNames[Math.floor(Math.random() * iconNames.length)]
handleInputChange('icon', randomIcon)
}}>
<Dices size={14} className="btn-icon" />
</DiceButton>
</Tooltip>
</ModalSectionTitle>
<Space>
<Input
placeholder={t('selection.settings.user_modal.icon.placeholder')}
value={formData.icon || ''}
onChange={(e) => handleInputChange('icon', e.target.value)}
style={{ width: '100%' }}
status={errors.icon ? 'error' : ''}
/>
<IconPreview>
{formData.icon &&
(iconNames.includes(formData.icon as any) ? (
<DynamicIcon name={formData.icon as any} size={18} />
) : (
<OctagonX size={18} color="var(--color-error)" />
))}
</IconPreview>
</Space>
{errors.icon && <ErrorText>{errors.icon}</ErrorText>}
</Col>
</div>
</ModalSection>
<ModalSection>
<Row>
<Col flex="auto" style={{ paddingRight: '16px' }}>
<ModalSectionTitle>
<ModalSectionTitleLabel>{t('selection.settings.user_modal.model.label')}</ModalSectionTitleLabel>
<Tooltip placement="top" title={t('selection.settings.user_modal.model.tooltip')} arrow>
<QuestionIcon size={14} />
</Tooltip>
</ModalSectionTitle>
</Col>
<Radio.Group
value={formData.assistantId ? 'assistant' : 'default'}
onChange={(e) =>
handleInputChange('assistantId', e.target.value === 'default' ? '' : defaultAssistant.id)
}
buttonStyle="solid">
<Radio.Button value="default">{t('selection.settings.user_modal.model.default')}</Radio.Button>
<Radio.Button value="assistant">{t('selection.settings.user_modal.model.assistant')}</Radio.Button>
</Radio.Group>
</Row>
</ModalSection>
{formData.assistantId && (
<ModalSection>
<ModalSectionTitle>
<ModalSectionTitleLabel>{t('selection.settings.user_modal.assistant.label')}</ModalSectionTitleLabel>
</ModalSectionTitle>
<Select
value={formData.assistantId || defaultAssistant.id}
onChange={(value) => handleInputChange('assistantId', value)}
style={{ width: '100%' }}
dropdownRender={(menu) => menu}>
<Select.Option key={defaultAssistant.id} value={defaultAssistant.id}>
<AssistantItem>
<ModelAvatar model={defaultAssistant.model || getDefaultModel()} size={18} />
<AssistantName>{defaultAssistant.name}</AssistantName>
<Spacer />
<CurrentTag isCurrent={true}>{t('selection.settings.user_modal.assistant.default')}</CurrentTag>
</AssistantItem>
</Select.Option>
{userPredefinedAssistants
.filter((a) => a.id !== defaultAssistant.id)
.map((a) => (
<Select.Option key={a.id} value={a.id}>
<AssistantItem>
<ModelAvatar model={a.model || getDefaultModel()} size={18} />
<AssistantName>{a.name}</AssistantName>
<Spacer />
</AssistantItem>
</Select.Option>
))}
</Select>
</ModalSection>
)}
<ModalSection>
<ModalSectionTitle>
<ModalSectionTitleLabel>{t('selection.settings.user_modal.prompt.label')}</ModalSectionTitleLabel>
<Tooltip placement="top" title={t('selection.settings.user_modal.prompt.tooltip')} arrow>
<QuestionIcon size={14} />
</Tooltip>
<Spacer />
<div
style={{
fontSize: '12px',
userSelect: 'text',
color: 'var(--color-text-2)',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
{t('selection.settings.user_modal.prompt.placeholder_text')} {'{{text}}'}
<CopyButton tooltip={t('selection.settings.user_modal.prompt.copy_placeholder')} textToCopy="{{text}}" />
</div>
</ModalSectionTitle>
<Input.TextArea
placeholder={t('selection.settings.user_modal.prompt.placeholder')}
value={formData.prompt || ''}
onChange={(e) => handleInputChange('prompt', e.target.value)}
rows={4}
style={{ resize: 'none' }}
/>
</ModalSection>
</Space>
</Modal>
)
}
const ModalSection = styled.div`
display: flex;
flex-direction: column;
margin-top: 16px;
`
const ModalSectionTitle = styled.div`
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
margin-bottom: 8px;
`
const ModalSectionTitleLabel = styled.div`
font-size: 14px;
font-weight: 500;
color: var(--color-text);
`
const QuestionIcon = styled(CircleHelp)`
cursor: pointer;
color: var(--color-text-3);
`
const ErrorText = styled.div`
color: var(--color-error);
font-size: 12px;
`
const Spacer = styled.div`
flex: 1;
`
const IconPreview = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--color-bg-2);
border-radius: 4px;
border: 1px solid var(--color-border);
`
const AssistantItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
height: 28px;
`
const AssistantName = styled.span`
max-width: calc(100% - 60px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`
const CurrentTag = styled.span<{ isCurrent: boolean }>`
color: ${(props) => (props.isCurrent ? 'var(--color-primary)' : 'var(--color-text-3)')};
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
`
const DiceButton = styled.div`
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
margin-left: 4px;
.btn-icon {
color: var(--color-text-2);
}
&:hover {
.btn-icon {
color: var(--color-primary);
}
}
&:active {
transform: rotate(720deg);
}
`
export default SelectionActionUserModal

View File

@ -0,0 +1,126 @@
import { DragDropContext } from '@hello-pangea/dnd'
import { defaultActionItems } from '@renderer/store/selectionStore'
import type { ActionItem } from '@renderer/types/selectionTypes'
import SelectionToolbar from '@renderer/windows/selection/toolbar/SelectionToolbar'
import { Row } from 'antd'
import { FC } from 'react'
import styled from 'styled-components'
import { SettingDivider, SettingGroup } from '..'
import ActionsList from './components/ActionsList'
import ActionsListDivider from './components/ActionsListDivider'
import SettingsActionsListHeader from './components/SettingsActionsListHeader'
import { useActionItems } from './hooks/useSettingsActionsList'
import SelectionActionSearchModal from './SelectionActionSearchModal'
import SelectionActionUserModal from './SelectionActionUserModal'
// Component for managing selection actions in settings
// Handles drag-and-drop reordering, enabling/disabling actions, and custom action management
// Props for the main component
interface SelectionActionsListProps {
actionItems: ActionItem[] | undefined // List of all available actions
setActionItems: (items: ActionItem[]) => void // Function to update action items
}
const SelectionActionsList: FC<SelectionActionsListProps> = ({ actionItems, setActionItems }) => {
const {
enabledItems,
disabledItems,
customItemsCount,
isUserModalOpen,
isSearchModalOpen,
userEditingAction,
setIsUserModalOpen,
setIsSearchModalOpen,
handleEditActionItem,
handleAddNewAction,
handleUserModalOk,
handleSearchModalOk,
handleDeleteActionItem,
handleReset,
onDragEnd,
getSearchEngineInfo,
MAX_CUSTOM_ITEMS,
MAX_ENABLED_ITEMS
} = useActionItems(actionItems, setActionItems)
if (!actionItems || actionItems.length === 0) {
setActionItems(defaultActionItems)
}
return (
<SettingGroup>
<SettingsActionsListHeader
customItemsCount={customItemsCount}
maxCustomItems={MAX_CUSTOM_ITEMS}
onReset={handleReset}
onAdd={handleAddNewAction}
/>
<SettingDivider />
<DemoSection>
<SelectionToolbar demo />
</DemoSection>
<DragDropContext onDragEnd={onDragEnd}>
<ActionsListSection>
<ActionColumn>
<ActionsList
droppableId="enabled"
items={enabledItems}
isLastEnabledItem={enabledItems.length === 1}
onEdit={handleEditActionItem}
onDelete={handleDeleteActionItem}
getSearchEngineInfo={getSearchEngineInfo}
/>
<ActionsListDivider enabledCount={enabledItems.length} maxEnabled={MAX_ENABLED_ITEMS} />
<ActionsList
droppableId="disabled"
items={disabledItems}
isLastEnabledItem={false}
onEdit={handleEditActionItem}
onDelete={handleDeleteActionItem}
getSearchEngineInfo={getSearchEngineInfo}
/>
</ActionColumn>
</ActionsListSection>
</DragDropContext>
<SelectionActionUserModal
isModalOpen={isUserModalOpen}
editingAction={userEditingAction}
onOk={handleUserModalOk}
onCancel={() => setIsUserModalOpen(false)}
/>
<SelectionActionSearchModal
isModalOpen={isSearchModalOpen}
onOk={handleSearchModalOk}
onCancel={() => setIsSearchModalOpen(false)}
currentAction={actionItems?.find((item) => item.id === 'search')}
/>
</SettingGroup>
)
}
const ActionsListSection = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`
const ActionColumn = styled.div`
width: 100%;
`
const DemoSection = styled(Row)`
align-items: center;
justify-content: center;
margin: 24px 0;
`
export default SelectionActionsList

View File

@ -0,0 +1,191 @@
import { isWindows } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant'
import { TriggerMode } from '@renderer/types/selectionTypes'
import SelectionToolbar from '@renderer/windows/selection/toolbar/SelectionToolbar'
import { Radio, Row, Slider, Switch, Tooltip } from 'antd'
import { CircleHelp } from 'lucide-react'
import { FC, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import {
SettingContainer,
SettingDescription,
SettingDivider,
SettingGroup,
SettingRow,
SettingRowTitle,
SettingTitle
} from '..'
import SelectionActionsList from './SelectionActionsList'
const SelectionAssistantSettings: FC = () => {
const { theme } = useTheme()
const { t } = useTranslation()
const {
selectionEnabled,
triggerMode,
isCompact,
isAutoClose,
isAutoPin,
isFollowToolbar,
actionItems,
actionWindowOpacity,
setSelectionEnabled,
setTriggerMode,
setIsCompact,
setIsAutoClose,
setIsAutoPin,
setIsFollowToolbar,
setActionWindowOpacity,
setActionItems
} = useSelectionAssistant()
// force disable selection assistant on non-windows systems
useEffect(() => {
if (!isWindows && selectionEnabled) {
setSelectionEnabled(false)
}
}, [selectionEnabled, setSelectionEnabled])
return (
<SettingContainer theme={theme}>
<SettingGroup>
<Row>
<SettingTitle>{t('selection.name')}</SettingTitle>
<Spacer />
<ExperimentalText>{t('selection.settings.experimental')}</ExperimentalText>
</Row>
<SettingDivider />
<SettingRow>
<SettingLabel>
<SettingRowTitle>{t('selection.settings.enable.title')}</SettingRowTitle>
{!isWindows && <SettingDescription>{t('selection.settings.enable.description')}</SettingDescription>}
</SettingLabel>
<Switch
checked={isWindows && selectionEnabled}
onChange={(checked) => setSelectionEnabled(checked)}
disabled={!isWindows}
/>
</SettingRow>
{!selectionEnabled && (
<DemoContainer>
<SelectionToolbar demo />
</DemoContainer>
)}
</SettingGroup>
{selectionEnabled && (
<>
<SettingGroup>
<SettingTitle>{t('selection.settings.toolbar.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingLabel>
<SettingRowTitle>
<div style={{ marginRight: '4px' }}>{t('selection.settings.toolbar.trigger_mode.title')}</div>
<Tooltip placement="top" title={t('selection.settings.toolbar.trigger_mode.description_note')} arrow>
<QuestionIcon size={14} />
</Tooltip>
</SettingRowTitle>
<SettingDescription>{t('selection.settings.toolbar.trigger_mode.description')}</SettingDescription>
</SettingLabel>
<Radio.Group
value={triggerMode}
onChange={(e) => setTriggerMode(e.target.value as TriggerMode)}
buttonStyle="solid">
<Radio.Button value="selected">{t('selection.settings.toolbar.trigger_mode.selected')}</Radio.Button>
<Radio.Button value="ctrlkey">{t('selection.settings.toolbar.trigger_mode.ctrlkey')}</Radio.Button>
</Radio.Group>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingLabel>
<SettingRowTitle>{t('selection.settings.toolbar.compact_mode.title')}</SettingRowTitle>
<SettingDescription>{t('selection.settings.toolbar.compact_mode.description')}</SettingDescription>
</SettingLabel>
<Switch checked={isCompact} onChange={(checked) => setIsCompact(checked)} />
</SettingRow>
</SettingGroup>
<SettingGroup>
<SettingTitle>{t('selection.settings.window.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingLabel>
<SettingRowTitle>{t('selection.settings.window.follow_toolbar.title')}</SettingRowTitle>
<SettingDescription>{t('selection.settings.window.follow_toolbar.description')}</SettingDescription>
</SettingLabel>
<Switch checked={isFollowToolbar} onChange={(checked) => setIsFollowToolbar(checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingLabel>
<SettingRowTitle>{t('selection.settings.window.auto_close.title')}</SettingRowTitle>
<SettingDescription>{t('selection.settings.window.auto_close.description')}</SettingDescription>
</SettingLabel>
<Switch checked={isAutoClose} onChange={(checked) => setIsAutoClose(checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingLabel>
<SettingRowTitle>{t('selection.settings.window.auto_pin.title')}</SettingRowTitle>
<SettingDescription>{t('selection.settings.window.auto_pin.description')}</SettingDescription>
</SettingLabel>
<Switch checked={isAutoPin} onChange={(checked) => setIsAutoPin(checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingLabel>
<SettingRowTitle>{t('selection.settings.window.opacity.title')}</SettingRowTitle>
<SettingDescription>{t('selection.settings.window.opacity.description')}</SettingDescription>
</SettingLabel>
<div style={{ marginRight: '16px' }}>{actionWindowOpacity}%</div>
<Slider
style={{ width: 100 }}
min={20}
max={100}
reverse
value={actionWindowOpacity}
onChange={setActionWindowOpacity}
tooltip={{ open: false }}
/>
</SettingRow>
</SettingGroup>
<SelectionActionsList actionItems={actionItems} setActionItems={setActionItems} />
</>
)}
</SettingContainer>
)
}
const Spacer = styled.div`
flex: 1;
`
const SettingLabel = styled.div`
flex: 1;
`
const ExperimentalText = styled.div`
color: var(--color-text-3);
font-size: 12px;
`
const DemoContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
margin-top: 15px;
margin-bottom: 5px;
`
const QuestionIcon = styled(CircleHelp)`
cursor: pointer;
color: var(--color-text-3);
`
export default SelectionAssistantSettings

View File

@ -0,0 +1,60 @@
import type { DroppableProvided } from '@hello-pangea/dnd'
import { Draggable, Droppable } from '@hello-pangea/dnd'
import type { ActionItem as ActionItemType } from '@renderer/types/selectionTypes'
import { memo } from 'react'
import styled from 'styled-components'
import ActionsListItemComponent from './ActionsListItem'
interface ActionListProps {
droppableId: 'enabled' | 'disabled'
items: ActionItemType[]
isLastEnabledItem: boolean
onEdit: (item: ActionItemType) => void
onDelete: (id: string) => void
getSearchEngineInfo: (engine: string) => { icon: any; name: string } | null
}
const ActionsList = memo(
({ droppableId, items, isLastEnabledItem, onEdit, onDelete, getSearchEngineInfo }: ActionListProps) => {
return (
<Droppable droppableId={droppableId}>
{(provided: DroppableProvided) => (
<List ref={provided.innerRef} {...provided.droppableProps}>
<ActionsListContent>
{items.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided) => (
<ActionsListItemComponent
item={item}
provided={provided}
listType={droppableId}
isLastEnabledItem={isLastEnabledItem}
onEdit={onEdit}
onDelete={onDelete}
getSearchEngineInfo={getSearchEngineInfo}
/>
)}
</Draggable>
))}
{provided.placeholder}
</ActionsListContent>
</List>
)}
</Droppable>
)
}
)
const List = styled.div`
background: var(--color-bg-1);
border-radius: 4px;
margin-bottom: 16px;
padding-bottom: 1px;
`
const ActionsListContent = styled.div`
padding: 10px;
`
export default ActionsList

View File

@ -0,0 +1,41 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface DividerProps {
enabledCount: number
maxEnabled: number
}
const ActionsListDivider = memo(({ enabledCount, maxEnabled }: DividerProps) => {
const { t } = useTranslation()
return (
<DividerContainer>
<DividerLine />
<DividerText>{t('selection.settings.actions.drag_hint', { enabled: enabledCount, max: maxEnabled })}</DividerText>
<DividerLine />
</DividerContainer>
)
})
const DividerContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--color-text-3);
margin: 16px 12px;
`
const DividerLine = styled.div`
flex: 1;
height: 2px;
background: var(--color-border);
`
const DividerText = styled.span`
margin: 0 16px;
`
export default ActionsListDivider

View File

@ -0,0 +1,163 @@
import type { DraggableProvided } from '@hello-pangea/dnd'
import type { ActionItem as ActionItemType } from '@renderer/types/selectionTypes'
import { Button } from 'antd'
import { Pencil, Settings2, Trash } from 'lucide-react'
import { DynamicIcon } from 'lucide-react/dynamic'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ActionItemProps {
item: ActionItemType
provided: DraggableProvided
listType: 'enabled' | 'disabled'
isLastEnabledItem: boolean
onEdit: (item: ActionItemType) => void
onDelete: (id: string) => void
getSearchEngineInfo: (engine: string) => { icon: any; name: string } | null
}
const ActionsListItem = memo(
({ item, provided, listType, isLastEnabledItem, onEdit, onDelete, getSearchEngineInfo }: ActionItemProps) => {
const { t } = useTranslation()
const isEnabled = listType === 'enabled'
return (
<Item
ref={provided.innerRef}
{...provided.draggableProps}
{...(isLastEnabledItem ? {} : provided.dragHandleProps)}
disabled={!isEnabled}
className={isLastEnabledItem ? 'non-draggable' : ''}>
<ItemLeft>
<ItemIcon disabled={!isEnabled}>
<DynamicIcon name={item.icon as any} size={16} fallback={() => <div style={{ width: 16, height: 16 }} />} />
</ItemIcon>
<ItemName disabled={!isEnabled}>{item.isBuiltIn ? t(item.name) : item.name}</ItemName>
{item.id === 'search' && item.searchEngine && (
<ItemDescription>
{getSearchEngineInfo(item.searchEngine)?.icon}
<span>{getSearchEngineInfo(item.searchEngine)?.name}</span>
</ItemDescription>
)}
</ItemLeft>
<ActionOperations item={item} onEdit={onEdit} onDelete={onDelete} />
</Item>
)
}
)
interface ActionOperationsProps {
item: ActionItemType
onEdit: (item: ActionItemType) => void
onDelete: (id: string) => void
}
const ActionOperations = memo(({ item, onEdit, onDelete }: ActionOperationsProps) => {
if (!item.isBuiltIn) {
return (
<UserActionOpSection>
<Button type="link" size="small" onClick={() => onEdit(item)}>
<Pencil size={16} className="btn-icon-edit" />
</Button>
<Button type="link" size="small" danger onClick={() => onDelete(item.id)}>
<Trash size={16} className="btn-icon-delete" />
</Button>
</UserActionOpSection>
)
}
if (item.isBuiltIn && item.id === 'search') {
return (
<UserActionOpSection>
<Button type="link" size="small" onClick={() => onEdit(item)}>
<Settings2 size={16} className="btn-icon-edit" />
</Button>
</UserActionOpSection>
)
}
return null
})
const Item = styled.div<{ disabled: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
margin-bottom: 8px;
background-color: var(--color-bg-1);
border: 1px solid var(--color-border);
border-radius: 6px;
cursor: move;
opacity: ${(props) => (props.disabled ? 0.8 : 1)};
transition: background-color 0.2s ease;
&:last-child {
margin-bottom: 0;
}
&:hover {
background-color: var(--color-bg-2);
}
&.non-draggable {
cursor: default;
background-color: var(--color-bg-2);
position: relative;
}
`
const ItemLeft = styled.div`
display: flex;
align-items: center;
flex: 1;
`
const ItemName = styled.span<{ disabled: boolean }>`
margin-left: 8px;
color: ${(props) => (props.disabled ? 'var(--color-text-3)' : 'var(--color-text-1)')};
`
const ItemIcon = styled.div<{ disabled: boolean }>`
margin: 0 8px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => (props.disabled ? 'var(--color-text-3)' : 'var(--color-primary)')};
`
const ItemDescription = styled.div`
display: flex;
align-items: center;
gap: 4px;
margin-left: 16px;
font-size: 12px;
color: var(--color-text-2);
opacity: 0.8;
`
const UserActionOpSection = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
.btn-icon-edit {
color: var(--color-text-3);
&:hover {
color: var(--color-primary);
}
}
.btn-icon-delete {
color: var(--color-text-3);
&:hover {
color: var(--color-error);
}
}
`
export default ActionsListItem

View File

@ -0,0 +1,53 @@
import { Button, Row, Tooltip } from 'antd'
import { Plus } from 'lucide-react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingTitle } from '../..'
interface HeaderSectionProps {
customItemsCount: number
maxCustomItems: number
onReset: () => void
onAdd: () => void
}
const SettingsActionsListHeader = memo(({ customItemsCount, maxCustomItems, onReset, onAdd }: HeaderSectionProps) => {
const { t } = useTranslation()
const isCustomItemLimitReached = customItemsCount >= maxCustomItems
return (
<Row>
<SettingTitle>{t('selection.settings.actions.title')}</SettingTitle>
<Spacer />
<Tooltip title={t('selection.settings.actions.reset.tooltip')}>
<ResetButton type="text" onClick={onReset}>
{t('selection.settings.actions.reset.button')}
</ResetButton>
</Tooltip>
<Tooltip
title={
isCustomItemLimitReached
? t('selection.settings.actions.add_tooltip.disabled', { max: maxCustomItems })
: t('selection.settings.actions.add_tooltip.enabled')
}>
<Button type="primary" icon={<Plus size={16} />} onClick={onAdd} disabled={isCustomItemLimitReached} />
</Tooltip>
</Row>
)
})
const Spacer = styled.div`
flex: 1;
`
const ResetButton = styled(Button)`
margin: 0 8px;
color: var(--color-text-3);
&:hover {
color: var(--color-primary);
}
`
export default SettingsActionsListHeader

View File

@ -0,0 +1,178 @@
import { DropResult } from '@hello-pangea/dnd'
import { defaultActionItems } from '@renderer/store/selectionStore'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DEFAULT_SEARCH_ENGINES } from '../SelectionActionSearchModal'
const MAX_CUSTOM_ITEMS = 8
const MAX_ENABLED_ITEMS = 6
export const useActionItems = (
initialItems: ActionItem[] | undefined,
setActionItems: (items: ActionItem[]) => void
) => {
const { t } = useTranslation()
const [isUserModalOpen, setIsUserModalOpen] = useState(false)
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false)
const [userEditingAction, setUserEditingAction] = useState<ActionItem | null>(null)
const enabledItems = useMemo(() => initialItems?.filter((item) => item.enabled) ?? [], [initialItems])
const disabledItems = useMemo(() => initialItems?.filter((item) => !item.enabled) ?? [], [initialItems])
const customItemsCount = useMemo(() => initialItems?.filter((item) => !item.isBuiltIn).length ?? 0, [initialItems])
const handleEditActionItem = (item: ActionItem) => {
if (item.isBuiltIn) {
if (item.id === 'search') {
setIsSearchModalOpen(true)
return
}
return
}
setUserEditingAction(item)
setIsUserModalOpen(true)
}
const handleAddNewAction = () => {
if (customItemsCount >= MAX_CUSTOM_ITEMS) return
setUserEditingAction(null)
setIsUserModalOpen(true)
}
const handleUserModalOk = (actionItem: ActionItem) => {
if (userEditingAction && initialItems) {
const updatedItems = initialItems.map((item) => (item.id === userEditingAction.id ? actionItem : item))
setActionItems(updatedItems)
} else {
try {
const currentItems = initialItems || []
setActionItems([...currentItems, actionItem])
} catch (error) {
console.error('Error adding item:', error)
}
}
setIsUserModalOpen(false)
}
const handleSearchModalOk = (searchEngine: string) => {
if (!initialItems) return
const updatedItems = initialItems.map((item) => (item.id === 'search' ? { ...item, searchEngine } : item))
setActionItems(updatedItems)
setIsSearchModalOpen(false)
}
const handleDeleteActionItem = (id: string) => {
if (!initialItems) return
window.modal.confirm({
centered: true,
content: t('selection.settings.actions.delete_confirm'),
onOk: () => {
setActionItems(initialItems.filter((item) => item.id !== id))
}
})
}
const handleReset = () => {
if (!initialItems) return
window.modal.confirm({
centered: true,
content: t('selection.settings.actions.reset.confirm'),
onOk: () => {
const userItems = initialItems.filter((item) => !item.isBuiltIn).map((item) => ({ ...item, enabled: false }))
setActionItems([...defaultActionItems, ...userItems])
}
})
}
const onDragEnd = (result: DropResult) => {
if (!result.destination || !initialItems) return
const { source, destination } = result
if (source.droppableId === 'enabled' && destination.droppableId === 'disabled' && enabledItems.length === 1) {
return
}
if (source.droppableId === destination.droppableId) {
const list = source.droppableId === 'enabled' ? [...enabledItems] : [...disabledItems]
const [removed] = list.splice(source.index, 1)
list.splice(destination.index, 0, removed)
if (source.droppableId === 'enabled') {
const limitedEnabledItems = list.slice(0, MAX_ENABLED_ITEMS)
const overflowItems = list.length > MAX_ENABLED_ITEMS ? list.slice(MAX_ENABLED_ITEMS) : []
const updatedItems = [
...limitedEnabledItems.map((item) => ({ ...item, enabled: true })),
...disabledItems,
...overflowItems.map((item) => ({ ...item, enabled: false }))
]
setActionItems(updatedItems)
} else {
const updatedItems = [...enabledItems, ...list]
setActionItems(updatedItems)
}
return
}
const sourceList = source.droppableId === 'enabled' ? [...enabledItems] : [...disabledItems]
const destList = destination.droppableId === 'enabled' ? [...enabledItems] : [...disabledItems]
const [removed] = sourceList.splice(source.index, 1)
const updatedItem = { ...removed, enabled: destination.droppableId === 'enabled' }
const filteredDestList = destList.filter((item) => item.id !== updatedItem.id)
filteredDestList.splice(destination.index, 0, updatedItem)
let newEnabledItems = destination.droppableId === 'enabled' ? filteredDestList : sourceList
let newDisabledItems = destination.droppableId === 'disabled' ? filteredDestList : sourceList
if (newEnabledItems.length > MAX_ENABLED_ITEMS) {
const overflowItems = newEnabledItems.slice(MAX_ENABLED_ITEMS).map((item) => ({ ...item, enabled: false }))
newEnabledItems = newEnabledItems.slice(0, MAX_ENABLED_ITEMS)
newDisabledItems = [...newDisabledItems, ...overflowItems]
}
const updatedItems = [
...newEnabledItems.map((item) => ({ ...item, enabled: true })),
...newDisabledItems.map((item) => ({ ...item, enabled: false }))
]
setActionItems(updatedItems)
}
const getSearchEngineInfo = (searchEngine: string) => {
if (!searchEngine) return null
const [engine] = searchEngine.split('|')
const defaultEngine = DEFAULT_SEARCH_ENGINES.find((e) => e.value === engine)
if (defaultEngine) {
return { icon: defaultEngine.icon, name: defaultEngine.label }
}
const customEngine = DEFAULT_SEARCH_ENGINES.find((e) => e.value === 'custom')
return { icon: customEngine?.icon, name: engine }
}
return {
enabledItems,
disabledItems,
customItemsCount,
isUserModalOpen,
isSearchModalOpen,
userEditingAction,
setIsUserModalOpen,
setIsSearchModalOpen,
setUserEditingAction,
handleEditActionItem,
handleAddNewAction,
handleUserModalOk,
handleSearchModalOk,
handleDeleteActionItem,
handleReset,
onDragEnd,
getSearchEngineInfo,
MAX_CUSTOM_ITEMS,
MAX_ENABLED_ITEMS
}
}

View File

@ -13,6 +13,7 @@ import {
Rocket,
Settings2,
SquareTerminal,
TextCursorInput,
Zap
} from 'lucide-react'
// 导入useAppSelector
@ -31,6 +32,7 @@ import MiniAppSettings from './MiniappSettings/MiniAppSettings'
import ProvidersList from './ProviderSettings'
import QuickAssistantSettings from './QuickAssistantSettings'
import QuickPhraseSettings from './QuickPhraseSettings'
import SelectionAssistantSettings from './SelectionAssistantSettings/SelectionAssistantSettings'
import ShortcutSettings from './ShortcutSettings'
import WebSearchSettings from './WebSearchSettings'
@ -106,6 +108,12 @@ const SettingsPage: FC = () => {
{t('settings.quickAssistant.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/selectionAssistant">
<MenuItem className={isRoute('/settings/selectionAssistant')}>
<TextCursorInput size={18} />
{t('selection.name')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/quickPhrase">
<MenuItem className={isRoute('/settings/quickPhrase')}>
<Zap size={18} />
@ -136,6 +144,7 @@ const SettingsPage: FC = () => {
{showMiniAppSettings && <Route path="miniapps" element={<MiniAppSettings />} />}
<Route path="shortcut" element={<ShortcutSettings />} />
<Route path="quickAssistant" element={<QuickAssistantSettings />} />
<Route path="selectionAssistant" element={<SelectionAssistantSettings />} />
<Route path="data" element={<DataSettings />} />
<Route path="about" element={<AboutSettings />} />
<Route path="quickPhrase" element={<QuickPhraseSettings />} />

View File

@ -283,6 +283,8 @@ export async function fetchChatCompletion({
// TODO
// onChunkStatus: (status: 'searching' | 'processing' | 'success' | 'error') => void
}) {
console.log('fetchChatCompletion', messages, assistant)
const provider = getAssistantProvider(assistant)
const AI = new AiProvider(provider)

View File

@ -19,6 +19,7 @@ import newMessagesReducer from './newMessage'
import nutstore from './nutstore'
import paintings from './paintings'
import runtime from './runtime'
import selectionStore from './selectionStore'
import settings from './settings'
import shortcuts from './shortcuts'
import websearch from './websearch'
@ -38,6 +39,7 @@ const rootReducer = combineReducers({
websearch,
mcp,
copilot,
selectionStore,
// messages: messagesReducer,
messages: newMessagesReducer,
messageBlocks: messageBlocksReducer,
@ -67,7 +69,7 @@ const persistedReducer = persistReducer(
* Call storeSyncService.subscribe() in the window's entryPoint.tsx
*/
storeSyncService.setOptions({
syncList: ['assistants/', 'settings/', 'llm/']
syncList: ['assistants/', 'settings/', 'llm/', 'selectionStore/']
})
const store = configureStore({

View File

@ -0,0 +1,73 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { ActionItem, SelectionState, TriggerMode } from '@renderer/types/selectionTypes'
export const defaultActionItems: ActionItem[] = [
{ id: 'translate', name: 'selection.action.builtin.translate', enabled: true, isBuiltIn: true, icon: 'languages' },
{ id: 'explain', name: 'selection.action.builtin.explain', enabled: true, isBuiltIn: true, icon: 'file-question' },
{ id: 'summary', name: 'selection.action.builtin.summary', enabled: true, isBuiltIn: true, icon: 'scan-text' },
{
id: 'search',
name: 'selection.action.builtin.search',
enabled: true,
isBuiltIn: true,
icon: 'search',
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' }
]
export const initialState: SelectionState = {
selectionEnabled: true,
triggerMode: 'selected',
isCompact: false,
isAutoClose: false,
isAutoPin: false,
isFollowToolbar: true,
actionWindowOpacity: 100,
actionItems: defaultActionItems
}
const selectionSlice = createSlice({
name: 'selectionStore',
initialState,
reducers: {
setSelectionEnabled: (state, action: PayloadAction<boolean>) => {
state.selectionEnabled = action.payload
},
setTriggerMode: (state, action: PayloadAction<TriggerMode>) => {
state.triggerMode = action.payload
},
setIsCompact: (state, action: PayloadAction<boolean>) => {
state.isCompact = action.payload
},
setIsAutoClose: (state, action: PayloadAction<boolean>) => {
state.isAutoClose = action.payload
},
setIsAutoPin: (state, action: PayloadAction<boolean>) => {
state.isAutoPin = action.payload
},
setIsFollowToolbar: (state, action: PayloadAction<boolean>) => {
state.isFollowToolbar = action.payload
},
setActionWindowOpacity: (state, action: PayloadAction<number>) => {
state.actionWindowOpacity = action.payload
},
setActionItems: (state, action: PayloadAction<ActionItem[]>) => {
state.actionItems = action.payload
}
}
})
export const {
setSelectionEnabled,
setTriggerMode,
setIsCompact,
setIsAutoClose,
setIsAutoPin,
setIsFollowToolbar,
setActionWindowOpacity,
setActionItems
} = selectionSlice.actions
export default selectionSlice.reducer

View File

@ -0,0 +1,24 @@
export type TriggerMode = 'selected' | 'ctrlkey'
export interface ActionItem {
id: string
name: string
enabled: boolean
isBuiltIn: boolean
icon?: string
prompt?: string
assistantId?: string
selectedText?: string
searchEngine?: string
}
export interface SelectionState {
selectionEnabled: boolean
triggerMode: TriggerMode
isCompact: boolean
isAutoClose: boolean
isAutoPin: boolean
isFollowToolbar: boolean
actionWindowOpacity: number
actionItems: ActionItem[]
}

View File

@ -0,0 +1,383 @@
import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { defaultLanguage } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { Button, Slider, Tooltip } from 'antd'
import { Droplet, Minus, Pin, X } from 'lucide-react'
import { DynamicIcon } from 'lucide-react/dynamic'
import { FC, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import ActionGeneral from './components/ActionGeneral'
import ActionTranslate from './components/ActionTranslate'
const SelectionActionApp: FC = () => {
const { language } = useSettings()
const { t } = useTranslation()
const [action, setAction] = useState<ActionItem | null>(null)
const isActionLoaded = useRef(false)
const { isAutoClose, isAutoPin, actionWindowOpacity } = useSelectionAssistant()
const [isPinned, setIsPinned] = useState(isAutoPin)
const [isWindowFocus, setIsWindowFocus] = useState(true)
const [showOpacitySlider, setShowOpacitySlider] = useState(false)
const [opacity, setOpacity] = useState(actionWindowOpacity)
const contentElementRef = useRef<HTMLDivElement>(null)
const isAutoScrollEnabled = useRef(true)
const shouldCloseWhenBlur = useRef(false)
useEffect(() => {
if (isAutoPin) {
window.api.selection.pinActionWindow(true)
}
const actionListenRemover = window.electron?.ipcRenderer.on(
IpcChannel.Selection_UpdateActionData,
(_, actionItem: ActionItem) => {
setAction(actionItem)
isActionLoaded.current = true
}
)
window.addEventListener('focus', handleWindowFocus)
window.addEventListener('blur', handleWindowBlur)
return () => {
actionListenRemover()
window.removeEventListener('focus', handleWindowFocus)
window.removeEventListener('blur', handleWindowBlur)
}
// don't need any dependencies
}, [])
useEffect(() => {
i18n.changeLanguage(language || navigator.language || defaultLanguage)
}, [language])
useEffect(() => {
const contentEl = contentElementRef.current
if (contentEl) {
contentEl.addEventListener('scroll', handleUserScroll)
}
return () => {
if (contentEl) {
contentEl.removeEventListener('scroll', handleUserScroll)
}
}
//we should rely on action to trigger this effect,
// because the contentRef is not available when action is initially null
}, [action])
useEffect(() => {
if (action) {
document.title = `${action.isBuiltIn ? t(action.name) : action.name} - ${t('selection.name')}`
}
}, [action, t])
useEffect(() => {
shouldCloseWhenBlur.current = isAutoClose && !isPinned
}, [isAutoClose, isPinned])
useEffect(() => {
//if the action is loaded, we should not set the opacity update from settings
if (!isActionLoaded.current) {
setOpacity(actionWindowOpacity)
}
}, [actionWindowOpacity])
const handleMinimize = () => {
window.api.selection.minimizeActionWindow()
}
const handleClose = () => {
window.api.selection.closeActionWindow()
}
/**
* @param pinned - if undefined, toggle the pinned state, otherwise force set the pinned state
*/
const togglePin = () => {
setIsPinned(!isPinned)
window.api.selection.pinActionWindow(!isPinned)
}
const handleWindowFocus = () => {
setIsWindowFocus(true)
}
const handleWindowBlur = () => {
if (shouldCloseWhenBlur.current) {
handleClose()
return
}
setIsWindowFocus(false)
}
const handleOpacityChange = (value: number) => {
setOpacity(value)
}
const handleScrollToBottom = () => {
if (contentElementRef.current && isAutoScrollEnabled.current) {
contentElementRef.current.scrollTo({
top: contentElementRef.current.scrollHeight,
behavior: 'smooth'
})
}
}
const handleUserScroll = () => {
if (!contentElementRef.current) return
const { scrollTop, scrollHeight, clientHeight } = contentElementRef.current
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 24
// Only update isAutoScrollEnabled if user is at bottom
if (isAtBottom) {
isAutoScrollEnabled.current = true
} else {
isAutoScrollEnabled.current = false
}
}
//we don't need to render the component if action is not set
if (!action) return null
return (
<WindowFrame $opacity={opacity / 100}>
<TitleBar $isWindowFocus={isWindowFocus}>
{action.icon && (
<TitleBarIcon>
<DynamicIcon
name={action.icon as any}
size={16}
style={{ color: 'var(--color-text-1)' }}
fallback={() => {}}
/>
</TitleBarIcon>
)}
<TitleBarCaption>{action.isBuiltIn ? t(action.name) : action.name}</TitleBarCaption>
<TitleBarButtons>
<Tooltip
title={isPinned ? t('selection.action.window.pinned') : t('selection.action.window.pin')}
placement="bottom">
<WinButton
type="text"
icon={<Pin size={14} className={isPinned ? 'pinned' : ''} />}
onClick={togglePin}
className={isPinned ? 'pinned' : ''}
/>
</Tooltip>
<Tooltip
title={t('selection.action.window.opacity')}
placement="bottom"
{...(showOpacitySlider ? { open: false } : {})}>
<WinButton
type="text"
icon={<Droplet size={14} />}
onClick={() => setShowOpacitySlider(!showOpacitySlider)}
className={showOpacitySlider ? 'active' : ''}
style={{ paddingBottom: '2px' }}
/>
</Tooltip>
{showOpacitySlider && (
<OpacitySlider>
<Slider
vertical
min={20}
max={100}
value={opacity}
onChange={handleOpacityChange}
onChangeComplete={() => setShowOpacitySlider(false)}
tooltip={{ formatter: (value) => `${value}%` }}
/>
</OpacitySlider>
)}
<WinButton type="text" icon={<Minus size={16} />} onClick={handleMinimize} />
<WinButton type="text" icon={<X size={16} />} onClick={handleClose} className="close" />
</TitleBarButtons>
</TitleBar>
<Content ref={contentElementRef}>
{action.id == 'translate' && <ActionTranslate action={action} scrollToBottom={handleScrollToBottom} />}
{action.id != 'translate' && <ActionGeneral action={action} scrollToBottom={handleScrollToBottom} />}
</Content>
</WindowFrame>
)
}
const WindowFrame = styled.div<{ $opacity: number }>`
position: relative;
display: flex;
flex-direction: column;
width: calc(100% - 6px);
height: calc(100% - 6px);
margin: 2px;
background-color: var(--color-background);
border: 1px solid var(--color-border);
box-shadow: 0px 0px 2px var(--color-text-3);
border-radius: 8px;
overflow: hidden;
box-sizing: border-box;
opacity: ${(props) => props.$opacity};
`
const TitleBar = styled.div<{ $isWindowFocus: boolean }>`
display: flex;
align-items: center;
flex-direction: row;
height: 32px;
padding: 0 8px;
background-color: ${(props) =>
props.$isWindowFocus ? 'var(--color-background-mute)' : 'var(--color-background-soft)'};
transition: background-color 0.3s ease;
-webkit-app-region: drag;
`
const TitleBarIcon = styled.div`
display: flex;
align-items: center;
justify-content: center;
margin-left: 4px;
`
const TitleBarCaption = styled.div`
margin-left: 8px;
font-size: 14px;
font-weight: 400;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-text-1);
`
const TitleBarButtons = styled.div`
display: flex;
gap: 8px;
-webkit-app-region: no-drag;
position: relative;
.lucide {
&.pinned {
color: var(--color-primary);
}
}
`
const WinButton = styled(Button)`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
transition: all 0.2s;
color: var(--color-icon);
.anticon {
display: flex;
align-items: center;
justify-content: center;
}
svg {
stroke-width: 2;
transition: transform 0.2s ease;
}
&.pinned {
svg {
transform: rotate(45deg);
}
&:hover {
background-color: var(--color-primary-mute) !important;
}
}
&.close {
&:hover {
background-color: var(--color-error) !important;
color: var(--color-white) !important;
}
}
&.active {
background-color: var(--color-primary-mute) !important;
color: var(--color-primary) !important;
}
&:hover {
background-color: var(--color-hover) !important;
color: var(--color-icon-white) !important;
}
`
const Content = styled.div`
display: flex;
flex-direction: column;
flex: 1;
padding: 16px;
overflow: auto;
font-size: 14px;
-webkit-app-region: none;
user-select: text;
width: 100%;
`
const OpacitySlider = styled.div`
position: absolute;
left: 42px;
top: 100%;
margin-top: 8px;
background-color: var(--color-background-mute);
padding: 16px 8px 12px 8px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
height: 120px;
/* display: flex; */
align-items: center;
justify-content: center;
z-index: 10000;
opacity: 1 !important;
.ant-slider {
height: 100%;
margin: 0;
}
.ant-slider-rail {
background-color: var(--color-border);
}
.ant-slider-track {
background-color: var(--color-primary);
}
.ant-slider-handle {
border-color: var(--color-primary);
&:hover {
border-color: var(--color-primary);
}
&.ant-slider-handle-active {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-mute);
}
}
`
export default SelectionActionApp

View File

@ -0,0 +1,334 @@
import { LoadingOutlined } from '@ant-design/icons'
import CopyButton from '@renderer/components/CopyButton'
import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
import { useSettings } from '@renderer/hooks/useSettings'
import MessageContent from '@renderer/pages/home/Messages/MessageContent'
import { fetchChatCompletion } from '@renderer/services/ApiService'
import {
getAssistantById,
getDefaultAssistant,
getDefaultModel,
getDefaultTopic
} from '@renderer/services/AssistantService'
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
import store from '@renderer/store'
import { updateOneBlock, upsertManyBlocks, upsertOneBlock } from '@renderer/store/messageBlock'
import { newMessagesActions } from '@renderer/store/newMessage'
import { Assistant, Topic } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk'
import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { abortCompletion } from '@renderer/utils/abortController'
import { isAbortError } from '@renderer/utils/error'
import { createMainTextBlock } from '@renderer/utils/messageUtils/create'
import { ChevronDown } from 'lucide-react'
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import WindowFooter from './WindowFooter'
interface Props {
action: ActionItem
scrollToBottom?: () => void
}
const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => {
const { t } = useTranslation()
const { language } = useSettings()
const [error, setError] = useState<string | null>(null)
const [showOriginal, setShowOriginal] = useState(false)
const [isContented, setIsContented] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [contentToCopy, setContentToCopy] = useState('')
const initialized = useRef(false)
// Use useRef for values that shouldn't trigger re-renders
const assistantRef = useRef<Assistant | null>(null)
const topicRef = useRef<Topic | null>(null)
const promptContentRef = useRef('')
const askId = useRef('')
// Initialize values only once when action changes
useEffect(() => {
if (initialized.current) return
initialized.current = true
// Initialize assistant
const currentAssistant = action.assistantId
? getAssistantById(action.assistantId) || getDefaultAssistant()
: getDefaultAssistant()
assistantRef.current = {
...currentAssistant,
model: currentAssistant.model || getDefaultModel()
}
// Initialize topic
topicRef.current = getDefaultTopic(currentAssistant.id)
// Initialize prompt content
let userContent = ''
switch (action.id) {
case 'summary':
userContent =
`请总结下面的内容。要求:使用 ${language} 语言进行回复;请不要包含对本提示词的任何解释,直接给出回复: \n\n` +
action.selectedText
break
case 'explain':
userContent =
`请解释下面的内容。要求:使用 ${language} 语言进行回复;请不要包含对本提示词的任何解释,直接给出回复: \n\n` +
action.selectedText
break
case 'refine':
userContent =
`请根据下面的内容进行优化或润色,并保持原内容的含义和完整性。要求:使用原语言进行回复;请不要包含对本提示词的任何解释,直接给出回复: \n\n` +
action.selectedText
break
default:
if (!action.prompt) {
userContent = action.selectedText || ''
break
}
if (action.prompt.includes('{{text}}')) {
userContent = action.prompt.replaceAll('{{text}}', action.selectedText!)
break
}
userContent = action.prompt + '\n\n' + action.selectedText
}
promptContentRef.current = userContent
}, [action, language])
const allMessages = useTopicMessages(topicRef.current?.id || '')
const fetchResult = useCallback(async () => {
if (!assistantRef.current || !topicRef.current) return
try {
const { message: userMessage, blocks: userBlocks } = getUserMessage({
assistant: assistantRef.current,
topic: topicRef.current,
content: promptContentRef.current
})
askId.current = userMessage.id
store.dispatch(newMessagesActions.addMessage({ topicId: topicRef.current.id, message: userMessage }))
store.dispatch(upsertManyBlocks(userBlocks))
let blockId: string | null = null
let blockContent: string = ''
const assistantMessage = getAssistantMessage({
assistant: assistantRef.current,
topic: topicRef.current
})
store.dispatch(
newMessagesActions.addMessage({
topicId: topicRef.current.id,
message: assistantMessage
})
)
await fetchChatCompletion({
messages: [userMessage],
assistant: assistantRef.current,
onChunkReceived: (chunk: Chunk) => {
switch (chunk.type) {
case ChunkType.THINKING_DELTA:
case ChunkType.THINKING_COMPLETE:
//TODO
break
case ChunkType.TEXT_DELTA:
{
setIsContented(true)
blockContent += chunk.text
if (!blockId) {
const block = createMainTextBlock(assistantMessage.id, chunk.text, {
status: MessageBlockStatus.STREAMING
})
blockId = block.id
store.dispatch(
newMessagesActions.updateMessage({
topicId: topicRef.current!.id,
messageId: assistantMessage.id,
updates: { blockInstruction: { id: block.id } }
})
)
store.dispatch(upsertOneBlock(block))
} else {
store.dispatch(updateOneBlock({ id: blockId, changes: { content: blockContent } }))
}
scrollToBottom?.()
}
break
case ChunkType.TEXT_COMPLETE:
{
blockId &&
store.dispatch(
updateOneBlock({
id: blockId,
changes: { status: MessageBlockStatus.SUCCESS }
})
)
store.dispatch(
newMessagesActions.updateMessage({
topicId: topicRef.current!.id,
messageId: assistantMessage.id,
updates: { status: AssistantMessageStatus.SUCCESS }
})
)
setContentToCopy(chunk.text)
}
break
case ChunkType.BLOCK_COMPLETE:
case ChunkType.ERROR:
setIsLoading(false)
break
}
}
})
} catch (err) {
if (isAbortError(err)) return
setIsLoading(false)
setError(err instanceof Error ? err.message : 'An error occurred')
console.error('Error fetching result:', err)
}
}, [])
useEffect(() => {
if (assistantRef.current && topicRef.current) {
fetchResult()
}
}, [fetchResult])
// Memoize the messages to prevent unnecessary re-renders
const messageContent = useMemo(() => {
const assistantMessages = allMessages.filter((message) => message.role === 'assistant')
const lastAssistantMessage = assistantMessages[assistantMessages.length - 1]
return lastAssistantMessage ? <MessageContent key={lastAssistantMessage.id} message={lastAssistantMessage} /> : null
}, [allMessages])
const handlePause = () => {
if (askId.current) {
abortCompletion(askId.current)
setIsLoading(false)
}
}
return (
<>
<Container>
<MenuContainer>
<OriginalHeader onClick={() => setShowOriginal(!showOriginal)}>
<span>
{showOriginal ? t('selection.action.window.original_hide') : t('selection.action.window.original_show')}
</span>
<ChevronDown size={14} className={showOriginal ? 'expanded' : ''} />
</OriginalHeader>
</MenuContainer>
{showOriginal && (
<OriginalContent>
{action.selectedText}
<OriginalContentCopyWrapper>
<CopyButton
textToCopy={action.selectedText!}
tooltip={t('selection.action.window.original_copy')}
size={12}
/>
</OriginalContentCopyWrapper>
</OriginalContent>
)}
<Result>
{!isContented && isLoading && <LoadingOutlined style={{ fontSize: 16 }} spin />}
{messageContent}
</Result>
{error && <ErrorMsg>{error}</ErrorMsg>}
</Container>
<FooterPadding />
<WindowFooter loading={isLoading} onPause={handlePause} content={contentToCopy} />
</>
)
})
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
`
const Result = styled.div`
margin-top: 4px;
width: 100%;
max-width: 960px;
`
const MenuContainer = styled.div`
display: flex;
width: 100%;
max-width: 960px;
flex-direction: row;
align-items: center;
justify-content: flex-end;
`
const OriginalHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
color: var(--color-text-secondary);
font-size: 12px;
&:hover {
color: var(--color-primary);
}
.lucide {
transition: transform 0.2s ease;
&.expanded {
transform: rotate(180deg);
}
}
`
const OriginalContent = styled.div`
padding: 8px;
margin-top: 8px;
margin-bottom: 12px;
background-color: var(--color-background-soft);
border-radius: 4px;
color: var(--color-text-secondary);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
width: 100%;
max-width: 960px;
`
const OriginalContentCopyWrapper = styled.div`
display: flex;
justify-content: flex-end;
`
const FooterPadding = styled.div`
min-height: 32px;
`
const ErrorMsg = styled.div`
color: var(--color-error);
background: rgba(255, 0, 0, 0.15);
border: 1px solid var(--color-error);
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 12px;
font-size: 13px;
word-break: break-all;
`
export default ActionGeneral

View File

@ -0,0 +1,223 @@
import { LoadingOutlined } from '@ant-design/icons'
import CopyButton from '@renderer/components/CopyButton'
import { TranslateLanguageOptions } from '@renderer/config/translate'
import db from '@renderer/databases'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { fetchTranslate } from '@renderer/services/ApiService'
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import { Assistant } from '@renderer/types'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { runAsyncFunction } from '@renderer/utils'
import { Select, Space } from 'antd'
import { isEmpty } from 'lodash'
import { ChevronDown } from 'lucide-react'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import WindowFooter from './WindowFooter'
interface Props {
action: ActionItem
scrollToBottom: () => void
}
let _targetLanguage = 'chinese'
const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
const { t } = useTranslation()
const [targetLanguage, setTargetLanguage] = useState(_targetLanguage)
const { translateModel } = useDefaultModel()
const [isLangSelectDisabled, setIsLangSelectDisabled] = useState(false)
const [showOriginal, setShowOriginal] = useState(false)
const [result, setResult] = useState('')
const [contentToCopy, setContentToCopy] = useState('')
const [error, setError] = useState('')
const translatingRef = useRef(false)
_targetLanguage = targetLanguage
const translate = useCallback(async () => {
if (!action.selectedText || !action.selectedText.trim() || !translateModel) return
if (translatingRef.current) return
try {
translatingRef.current = true
setError('')
const targetLang = await db.settings.get({ id: 'translate:target:language' })
const assistant: Assistant = getDefaultTranslateAssistant(
targetLang?.value || targetLanguage,
action.selectedText
)
const onResult = (text: string, isComplete: boolean) => {
setResult(text)
scrollToBottom()
if (isComplete) {
setContentToCopy(text)
setIsLangSelectDisabled(false)
}
}
setIsLangSelectDisabled(true)
await fetchTranslate({ content: action.selectedText || '', assistant, onResponse: onResult })
translatingRef.current = false
} catch (error: any) {
setError(error?.message || t('error.unknown'))
console.error(error)
} finally {
translatingRef.current = false
}
}, [action, targetLanguage, translateModel])
useEffect(() => {
runAsyncFunction(async () => {
const targetLang = await db.settings.get({ id: 'translate:target:language' })
targetLang && setTargetLanguage(targetLang.value)
})
}, [])
useEffect(() => {
translate()
}, [translate])
return (
<>
<Container>
<MenuContainer>
<Select
value={targetLanguage}
style={{ maxWidth: 200, minWidth: 130, flex: 1 }}
listHeight={160}
optionFilterProp="label"
options={TranslateLanguageOptions}
onChange={async (value) => {
await db.settings.put({ id: 'translate:target:language', value })
setTargetLanguage(value)
}}
disabled={isLangSelectDisabled}
optionRender={(option) => (
<Space>
<span role="img" aria-label={option.data.label}>
{option.data.emoji}
</span>
{option.label}
</Space>
)}
/>
<OriginalHeader onClick={() => setShowOriginal(!showOriginal)}>
<span>
{showOriginal ? t('selection.action.window.original_hide') : t('selection.action.window.original_show')}
</span>
<ChevronDown size={14} className={showOriginal ? 'expanded' : ''} />
</OriginalHeader>
</MenuContainer>
{showOriginal && (
<OriginalContent>
{action.selectedText}{' '}
<OriginalContentCopyWrapper>
<CopyButton
textToCopy={action.selectedText!}
tooltip={t('selection.action.window.original_copy')}
size={12}
/>
</OriginalContentCopyWrapper>
</OriginalContent>
)}
<Result>{isEmpty(result) ? <LoadingOutlined style={{ fontSize: 16 }} spin /> : result}</Result>
{error && <ErrorMsg>{error}</ErrorMsg>}
</Container>
<FooterPadding />
<WindowFooter content={contentToCopy} />
</>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
width: 100%;
`
const Result = styled.div`
margin-top: 16px;
white-space: pre-wrap;
word-break: break-word;
width: 100%;
max-width: 960px;
`
const MenuContainer = styled.div`
display: flex;
width: 100%;
flex-direction: row;
align-items: center;
justify-content: space-between;
max-width: 960px;
`
const OriginalHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
color: var(--color-text-secondary);
font-size: 12px;
padding: 4px 0;
&:hover {
color: var(--color-primary);
}
.lucide {
transition: transform 0.2s ease;
&.expanded {
transform: rotate(180deg);
}
}
`
const OriginalContent = styled.div`
margin-top: 8px;
padding: 8px;
background-color: var(--color-background-soft);
border-radius: 4px;
color: var(--color-text-secondary);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
width: 100%;
max-width: 960px;
`
const OriginalContentCopyWrapper = styled.div`
display: flex;
justify-content: flex-end;
`
const FooterPadding = styled.div`
min-height: 32px;
`
const ErrorMsg = styled.div`
color: var(--color-error);
background: rgba(255, 0, 0, 0.15);
border: 1px solid var(--color-error);
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 12px;
font-size: 13px;
word-break: break-all;
`
export default ActionTranslate

View File

@ -0,0 +1,176 @@
import { LoadingOutlined } from '@ant-design/icons'
import { CircleX, Copy, Pause } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface FooterProps {
content?: string
loading?: boolean
onPause?: () => void
}
const WindowFooter: FC<FooterProps> = ({ content = '', loading = false, onPause = () => {} }) => {
const { t } = useTranslation()
const [isWindowFocus, setIsWindowFocus] = useState(true)
const [isCopyHovered, setIsCopyHovered] = useState(false)
const [isEscHovered, setIsEscHovered] = useState(false)
useEffect(() => {
window.addEventListener('focus', handleWindowFocus)
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('focus', handleWindowFocus)
window.removeEventListener('blur', handleWindowBlur)
}
}, [])
useHotkeys('c', () => {
handleCopy()
})
useHotkeys('esc', () => {
handleEsc()
})
const handleEsc = () => {
setIsEscHovered(true)
setTimeout(() => {
setIsEscHovered(false)
}, 200)
if (loading && onPause) {
onPause()
} else {
window.api.selection.closeActionWindow()
}
}
const handleCopy = () => {
if (!content) return
navigator.clipboard
.writeText(content)
.then(() => {
window.message.success(t('message.copy.success'))
setIsCopyHovered(true)
setTimeout(() => {
setIsCopyHovered(false)
}, 200)
})
.catch(() => {
window.message.error(t('message.copy.failed'))
})
}
const handleWindowFocus = () => {
setIsWindowFocus(true)
}
const handleWindowBlur = () => {
setIsWindowFocus(false)
}
return (
<Container>
<OpButtonWrapper>
<OpButton onClick={handleEsc} $isWindowFocus={isWindowFocus} data-hovered={isEscHovered}>
{loading ? (
<>
<LoadingIconWrapper>
<Pause size={14} className="btn-icon loading-icon" style={{ position: 'absolute', left: 1, top: 1 }} />
<LoadingOutlined
style={{ fontSize: 16, position: 'absolute', left: 0, top: 0 }}
className="btn-icon loading-icon"
spin
/>
</LoadingIconWrapper>
{t('selection.action.window.esc_stop')}
</>
) : (
<>
<CircleX size={14} className="btn-icon" />
{t('selection.action.window.esc_close')}
</>
)}
</OpButton>
<OpButton onClick={handleCopy} $isWindowFocus={isWindowFocus && !!content} data-hovered={isCopyHovered}>
<Copy size={14} className="btn-icon" />
{t('selection.action.window.c_copy')}
</OpButton>
</OpButtonWrapper>
</Container>
)
}
const Container = styled.div`
position: absolute;
bottom: 0;
left: 8px;
right: 8px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 5px 0;
height: 32px;
backdrop-filter: blur(8px);
border-radius: 8px;
`
const OpButtonWrapper = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
font-size: 12px;
gap: 6px;
`
const OpButton = styled.div<{ $isWindowFocus: boolean; $isHovered?: boolean }>`
cursor: pointer;
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
padding: 0 8px;
border-radius: 4px;
background-color: var(--color-background-mute);
color: var(--color-text-secondary);
height: 22px;
opacity: ${(props) => (props.$isWindowFocus ? 1 : 0.2)};
transition: opacity 0.3s ease;
transition: color 0.2s ease;
.btn-icon {
color: var(--color-text-secondary);
}
.loading-icon {
color: var(--color-error);
}
&:hover,
&[data-hovered='true'] {
color: var(--color-primary) !important;
.btn-icon {
color: var(--color-primary) !important;
transition: color 0.2s ease;
}
}
`
const LoadingIconWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: 16px;
height: 16px;
`
export default WindowFooter

View File

@ -0,0 +1,54 @@
import '@renderer/assets/styles/index.scss'
import '@ant-design/v5-patch-for-react-19'
import KeyvStorage from '@kangfenmao/keyv-storage'
import AntdProvider from '@renderer/context/AntdProvider'
import { CodeStyleProvider } from '@renderer/context/CodeStyleProvider'
import { ThemeProvider } from '@renderer/context/ThemeProvider'
import storeSyncService from '@renderer/services/StoreSyncService'
import store, { persistor } from '@renderer/store'
import { message } from 'antd'
import { FC } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import SelectionActionApp from './SelectionActionApp'
/**
* fetchChatCompletion depends on this,
* which is not a good design, but we have to add it for now
*/
function initKeyv() {
window.keyv = new KeyvStorage()
window.keyv.init()
}
initKeyv()
//subscribe to store sync
storeSyncService.subscribe()
const App: FC = () => {
//actionWindow should register its own message component
const [messageApi, messageContextHolder] = message.useMessage()
window.message = messageApi
return (
<Provider store={store}>
<ThemeProvider>
<AntdProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
{messageContextHolder}
<SelectionActionApp />
</PersistGate>
</CodeStyleProvider>
</AntdProvider>
</ThemeProvider>
</Provider>
)
}
const root = createRoot(document.getElementById('root') as HTMLElement)
root.render(<App />)

View File

@ -0,0 +1,405 @@
import '@renderer/assets/styles/selection-toolbar.scss'
import { AppLogo } from '@renderer/config/env'
import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { defaultLanguage } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { Avatar } from 'antd'
import { ClipboardCheck, ClipboardCopy, ClipboardX, MessageSquareHeart } from 'lucide-react'
import { DynamicIcon } from 'lucide-react/dynamic'
import { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { TextSelectionData } from 'selection-hook'
import styled from 'styled-components'
//tell main the actual size of the content
const updateWindowSize = () => {
const rootElement = document.getElementById('root')
if (!rootElement) {
console.error('SelectionToolbar: Root element not found')
return
}
window.api?.selection.determineToolbarSize(rootElement.scrollWidth, rootElement.scrollHeight)
}
/**
* ActionIcons is a component that renders the action icons
*/
const ActionIcons: FC<{
actionItems: ActionItem[]
isCompact: boolean
handleAction: (action: ActionItem) => void
copyIconStatus: 'normal' | 'success' | 'fail'
copyIconAnimation: 'none' | 'enter' | 'exit'
}> = memo(({ actionItems, isCompact, handleAction, copyIconStatus, copyIconAnimation }) => {
const { t } = useTranslation()
const renderCopyIcon = useCallback(() => {
return (
<>
<ClipboardCopy
className={`btn-icon ${
copyIconAnimation === 'enter' ? 'icon-scale-out' : copyIconAnimation === 'exit' ? 'icon-fade-in' : ''
}`}
/>
{copyIconStatus === 'success' && (
<ClipboardCheck
className={`btn-icon icon-success ${
copyIconAnimation === 'enter' ? 'icon-scale-in' : copyIconAnimation === 'exit' ? 'icon-fade-out' : ''
}`}
/>
)}
{copyIconStatus === 'fail' && (
<ClipboardX
className={`btn-icon icon-fail ${
copyIconAnimation === 'enter' ? 'icon-scale-in' : copyIconAnimation === 'exit' ? 'icon-fade-out' : ''
}`}
/>
)}
</>
)
}, [copyIconStatus, copyIconAnimation])
const renderActionButton = useCallback(
(action: ActionItem) => {
return (
<ActionButton key={action.id} onClick={() => handleAction(action)}>
<ActionIcon>
{action.id === 'copy' ? (
renderCopyIcon()
) : (
<DynamicIcon
key={action.id}
name={action.icon as any}
className="btn-icon"
fallback={() => <MessageSquareHeart className="btn-icon" />}
/>
)}
</ActionIcon>
{!isCompact && (
<ActionTitle className="btn-title">{action.isBuiltIn ? t(action.name) : action.name}</ActionTitle>
)}
</ActionButton>
)
},
[handleAction, isCompact, t, renderCopyIcon]
)
return <>{actionItems?.map(renderActionButton)}</>
})
/**
* demo is used in the settings page
*/
const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
const { language } = useSettings()
const { isCompact, actionItems } = useSelectionAssistant()
const [animateKey, setAnimateKey] = useState(0)
const [copyIconStatus, setCopyIconStatus] = useState<'normal' | 'success' | 'fail'>('normal')
const [copyIconAnimation, setCopyIconAnimation] = useState<'none' | 'enter' | 'exit'>('none')
const copyIconTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
const realActionItems = useMemo(() => {
return actionItems?.filter((item) => item.enabled)
}, [actionItems])
const selectedText = useRef('')
// listen to selectionService events
useEffect(() => {
// TextSelection
const textSelectionListenRemover = window.electron?.ipcRenderer.on(
IpcChannel.Selection_TextSelected,
(_, selectionData: TextSelectionData) => {
selectedText.current = selectionData.text
setTimeout(() => {
//make sure the animation is active
setAnimateKey((prev) => prev + 1)
}, 400)
}
)
// ToolbarVisibilityChange
const toolbarVisibilityChangeListenRemover = window.electron?.ipcRenderer.on(
IpcChannel.Selection_ToolbarVisibilityChange,
(_, isVisible: boolean) => {
if (!isVisible) {
if (!demo) updateWindowSize()
onHideCleanUp()
}
}
)
if (!demo) updateWindowSize()
return () => {
textSelectionListenRemover()
toolbarVisibilityChangeListenRemover()
}
}, [demo])
//make sure the toolbar size is updated when the compact mode/actionItems is changed
useEffect(() => {
if (!demo) updateWindowSize()
}, [demo, isCompact, actionItems])
useEffect(() => {
i18n.changeLanguage(language || navigator.language || defaultLanguage)
}, [language])
const onHideCleanUp = () => {
setCopyIconStatus('normal')
setCopyIconAnimation('none')
clearTimeout(copyIconTimeoutRef.current)
}
const handleAction = useCallback(
(action: ActionItem) => {
if (demo) return
/** avoid mutating the original action, it will cause syncing issue */
const newAction = { ...action, selectedText: selectedText.current }
switch (action.id) {
case 'copy':
handleCopy()
break
case 'search':
handleSearch(newAction)
break
default:
handleDefaultAction(newAction)
break
}
},
[demo]
)
// copy selected text to clipboard
const handleCopy = async () => {
if (selectedText.current) {
const result = await window.api?.selection.writeToClipboard(selectedText.current)
setCopyIconStatus(result ? 'success' : 'fail')
setCopyIconAnimation('enter')
copyIconTimeoutRef.current = setTimeout(() => {
setCopyIconAnimation('exit')
}, 2000)
}
}
const handleSearch = (action: ActionItem) => {
if (!action.searchEngine) return
const customUrl = action.searchEngine.split('|')[1]
if (!customUrl) return
const searchUrl = customUrl.replace('{{queryString}}', encodeURIComponent(action.selectedText || ''))
window.api?.openWebsite(searchUrl)
window.api?.selection.hideToolbar()
}
const handleDefaultAction = (action: ActionItem) => {
window.api?.selection.processAction(action)
window.api?.selection.hideToolbar()
}
return (
<Container>
<LogoWrapper>
<Logo src={AppLogo} key={animateKey} className="animate" draggable={false} />
</LogoWrapper>
<ActionWrapper>
<ActionIcons
actionItems={realActionItems}
isCompact={isCompact}
handleAction={handleAction}
copyIconStatus={copyIconStatus}
copyIconAnimation={copyIconAnimation}
/>
</ActionWrapper>
</Container>
)
}
const Container = styled.div`
display: inline-flex;
flex-direction: row;
align-items: center;
border-radius: 6px;
background-color: var(--color-selection-toolbar-background);
border-color: var(--color-selection-toolbar-border);
box-shadow: 0px 2px 3px var(--color-selection-toolbar-shadow);
padding: 2px;
margin: 2px 3px 5px 3px;
user-select: none;
border-width: 1px;
border-style: solid;
height: 36px;
padding-right: 4px;
box-sizing: border-box;
`
const LogoWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
-webkit-app-region: drag;
margin-left: 5px;
`
const Logo = styled(Avatar)`
height: 22px;
width: 22px;
&.animate {
animation: rotate 1s ease;
}
@keyframes rotate {
0% {
transform: rotate(0deg) scale(1);
}
25% {
transform: rotate(-15deg) scale(1.05);
}
75% {
transform: rotate(15deg) scale(1.05);
}
100% {
transform: rotate(0deg) scale(1);
}
}
`
const ActionWrapper = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-left: 3px;
`
const ActionButton = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin: 0 2px;
cursor: pointer;
border-radius: 4px;
padding: 4px 6px;
.btn-icon {
width: 16px;
height: 16px;
color: var(--color-selection-toolbar-text);
}
.btn-title {
color: var(--color-selection-toolbar-text);
--font-size: 14px;
}
&:hover {
color: var(--color-primary);
.btn-icon {
color: var(--color-primary);
}
.btn-title {
color: var(--color-primary);
}
background-color: var(--color-selection-toolbar-hover-bg);
}
`
const ActionIcon = styled.div`
display: flex;
align-items: center;
justify-content: center;
/* margin-right: 3px; */
position: relative;
height: 16px;
width: 16px;
.btn-icon {
position: absolute;
top: 0;
left: 0;
}
.btn-icon:nth-child(2) {
top: 0px;
left: 0px;
}
.icon-fail {
color: var(--color-error);
}
.icon-success {
color: var(--color-primary);
}
.icon-scale-in {
animation: scaleIn 0.5s forwards;
}
.icon-scale-out {
animation: scaleOut 0.5s forwards;
}
.icon-fade-in {
animation: fadeIn 0.3s forwards;
}
.icon-fade-out {
animation: fadeOut 0.3s forwards;
}
@keyframes scaleIn {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes scaleOut {
from {
transform: scale(1);
opacity: 1;
}
to {
transform: scale(0);
opacity: 0;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
`
const ActionTitle = styled.span`
font-size: 14px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 3px;
`
export default SelectionToolbar

View File

@ -0,0 +1,29 @@
import '@ant-design/v5-patch-for-react-19'
import { ThemeProvider } from '@renderer/context/ThemeProvider'
import storeSyncService from '@renderer/services/StoreSyncService'
import store, { persistor } from '@renderer/store'
import { FC } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import SelectionToolbar from './SelectionToolbar'
//subscribe to store sync
storeSyncService.subscribe()
const App: FC = () => {
return (
<Provider store={store}>
<ThemeProvider>
<PersistGate loading={null} persistor={persistor}>
<SelectionToolbar />
</PersistGate>
</ThemeProvider>
</Provider>
)
}
const root = createRoot(document.getElementById('root') as HTMLElement)
root.render(<App />)

View File

@ -6056,6 +6056,7 @@ __metadata:
remark-math: "npm:^6.0.0"
rollup-plugin-visualizer: "npm:^5.12.0"
sass: "npm:^1.88.0"
selection-hook: "npm:^0.9.14"
shiki: "npm:^3.4.2"
string-width: "npm:^7.2.0"
styled-components: "npm:^6.1.11"
@ -14737,6 +14738,17 @@ __metadata:
languageName: node
linkType: hard
"node-gyp-build@npm:^4.8.4":
version: 4.8.4
resolution: "node-gyp-build@npm:4.8.4"
bin:
node-gyp-build: bin.js
node-gyp-build-optional: optional.js
node-gyp-build-test: build-test.js
checksum: 10c0/444e189907ece2081fe60e75368784f7782cfddb554b60123743dfb89509df89f1f29c03bbfa16b3a3e0be3f48799a4783f487da6203245fa5bed239ba7407e1
languageName: node
linkType: hard
"node-gyp@npm:^9.1.0":
version: 9.4.1
resolution: "node-gyp@npm:9.4.1"
@ -17698,6 +17710,16 @@ __metadata:
languageName: node
linkType: hard
"selection-hook@npm:^0.9.14":
version: 0.9.14
resolution: "selection-hook@npm:0.9.14"
dependencies:
node-gyp: "npm:latest"
node-gyp-build: "npm:^4.8.4"
checksum: 10c0/114a5c28d3753c215450a23b3999d7782c11f2868e41555a0034d49e33d90b1fe35f0a15dc240bca859f6fc186e09859bff5e3213fc8385b501f1f05ba9233f8
languageName: node
linkType: hard
"semver-compare@npm:^1.0.0":
version: 1.0.0
resolution: "semver-compare@npm:1.0.0"