Merge branch 'main' into translate-auto-detect

This commit is contained in:
Pleasurecruise 2025-05-26 21:07:18 +08:00
commit 2a2e77632f
No known key found for this signature in database
GPG Key ID: E6385136096279B6
79 changed files with 7534 additions and 755 deletions

7
.gitignore vendored
View File

@ -47,8 +47,13 @@ local
.cursorrules .cursorrules
.cursor/rules .cursor/rules
# test # vitest
coverage coverage
.vitest-cache .vitest-cache
vitest.config.*.timestamp-* vitest.config.*.timestamp-*
# playwright
playwright-report
test-results
YOUR_MEMORY_FILE_PATH YOUR_MEMORY_FILE_PATH

View File

@ -12,9 +12,10 @@ electronLanguages:
directories: directories:
buildResources: build buildResources: build
files: files:
- '!{.vscode,.yarn,.github}' - '**/*'
- '!{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}'
- '!electron.vite.config.{js,ts,mjs,cjs}' - '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!src' - '!src'
@ -22,20 +23,28 @@ files:
- '!local' - '!local'
- '!docs' - '!docs'
- '!packages' - '!packages'
- '!.swc'
- '!.bin'
- '!._*'
- '!*.log'
- '!stats.html' - '!stats.html'
- '!*.md' - '!*.md'
- '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}'
- '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}' - '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}'
- '!**/{test,tests,__tests__,coverage}/**' - '!**/{test,tests,__tests__,powered-test,coverage}/**'
- '!**/{example,examples}/**'
- '!**/*.{spec,test}.{js,jsx,ts,tsx}' - '!**/*.{spec,test}.{js,jsx,ts,tsx}'
- '!**/*.min.*.map' - '!**/*.min.*.map'
- '!**/*.d.ts' - '!**/*.d.ts'
- '!**/{.DS_Store,Thumbs.db}' - '!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}'
- '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,CHANGELOG.md}' - '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}'
- '!node_modules/rollup-plugin-visualizer' - '!node_modules/rollup-plugin-visualizer'
- '!node_modules/js-tiktoken' - '!node_modules/js-tiktoken'
- '!node_modules/@tavily/core/node_modules/js-tiktoken' - '!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/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/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: asarUnpack:
- resources/** - resources/**
- '**/*.{metal,exp,lib}' - '**/*.{metal,exp,lib}'

View File

@ -37,7 +37,7 @@ export default defineConfig({
}, },
build: { build: {
rollupOptions: { rollupOptions: {
external: ['@libsql/client'] external: ['@libsql/client', 'bufferutil', 'utf-8-validate']
}, },
sourcemap: process.env.NODE_ENV === 'development' sourcemap: process.env.NODE_ENV === 'development'
}, },
@ -89,7 +89,9 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
input: { input: {
index: resolve(__dirname, 'src/renderer/index.html'), 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

@ -45,12 +45,13 @@
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "node scripts/check-i18n.js", "check:i18n": "node scripts/check-i18n.js",
"test": "yarn test:renderer", "test": "vitest run --silent",
"test:coverage": "yarn test:renderer:coverage", "test:main": "vitest run --project main",
"test:node": "npx -y tsx --test src/**/*.test.ts", "test:renderer": "vitest run --project renderer",
"test:renderer": "vitest run", "test:coverage": "vitest run --coverage --silent",
"test:renderer:ui": "vitest --ui", "test:ui": "vitest --ui",
"test:renderer:coverage": "vitest run --coverage", "test:watch": "vitest",
"test:e2e": "yarn playwright test",
"test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", "test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
@ -69,14 +70,12 @@
"@cherrystudio/embedjs-loader-xml": "^0.1.31", "@cherrystudio/embedjs-loader-xml": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31", "@cherrystudio/embedjs-openai": "^0.1.31",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"@electron/notarize": "^2.5.0",
"@langchain/community": "^0.3.36", "@langchain/community": "^0.3.36",
"@strongtz/win32-arm64-msvc": "^0.4.7", "@strongtz/win32-arm64-msvc": "^0.4.7",
"@tanstack/react-query": "^5.27.0", "@tanstack/react-query": "^5.27.0",
"@types/react-infinite-scroll-component": "^5.0.0", "@types/react-infinite-scroll-component": "^5.0.0",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"async-mutex": "^0.5.0", "async-mutex": "^0.5.0",
"color": "^5.0.0",
"diff": "^7.0.0", "diff": "^7.0.0",
"docx": "^9.0.2", "docx": "^9.0.2",
"electron-log": "^5.1.5", "electron-log": "^5.1.5",
@ -84,22 +83,18 @@
"electron-updater": "6.6.4", "electron-updater": "6.6.4",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch", "epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0", "fast-xml-parser": "^5.2.0",
"fetch-socks": "^1.3.2",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"got-scraping": "^4.1.1",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"node-stream-zip": "^1.15.0", "node-stream-zip": "^1.15.0",
"officeparser": "^4.1.1", "officeparser": "^4.1.1",
"os-proxy-config": "^1.1.2", "os-proxy-config": "^1.1.2",
"proxy-agent": "^6.5.0", "proxy-agent": "^6.5.0",
"selection-hook": "^0.9.14",
"tar": "^7.4.3", "tar": "^7.4.3",
"turndown": "^7.2.0", "turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
"webdav": "^5.8.0", "webdav": "^5.8.0",
"ws": "^8.18.1",
"zipread": "^1.3.3" "zipread": "^1.3.3"
}, },
"devDependencies": { "devDependencies": {
@ -112,6 +107,7 @@
"@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
"@electron/notarize": "^2.5.0",
"@emotion/is-prop-valid": "^1.3.1", "@emotion/is-prop-valid": "^1.3.1",
"@eslint-react/eslint-plugin": "^1.36.1", "@eslint-react/eslint-plugin": "^1.36.1",
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
@ -121,9 +117,13 @@
"@modelcontextprotocol/sdk": "^1.11.4", "@modelcontextprotocol/sdk": "^1.11.4",
"@mozilla/readability": "^0.6.0", "@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15", "@notionhq/client": "^2.2.15",
"@playwright/test": "^1.52.0",
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.4.2", "@shikijs/markdown-it": "^3.4.2",
"@swc/plugin-styled-components": "^7.1.5", "@swc/plugin-styled-components": "^7.1.5",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@tryfabric/martian": "^1.2.4", "@tryfabric/martian": "^1.2.4",
"@types/diff": "^7", "@types/diff": "^7",
"@types/fs-extra": "^11", "@types/fs-extra": "^11",
@ -142,12 +142,15 @@
"@uiw/codemirror-themes-all": "^4.23.12", "@uiw/codemirror-themes-all": "^4.23.12",
"@uiw/react-codemirror": "^4.23.12", "@uiw/react-codemirror": "^4.23.12",
"@vitejs/plugin-react-swc": "^3.9.0", "@vitejs/plugin-react-swc": "^3.9.0",
"@vitest/ui": "^3.1.1", "@vitest/browser": "^3.1.4",
"@vitest/web-worker": "^3.1.3", "@vitest/coverage-v8": "^3.1.4",
"@vitest/ui": "^3.1.4",
"@vitest/web-worker": "^3.1.4",
"@xyflow/react": "^12.4.4", "@xyflow/react": "^12.4.4",
"antd": "^5.22.5", "antd": "^5.22.5",
"axios": "^1.7.3", "axios": "^1.7.3",
"browser-image-compression": "^2.0.2", "browser-image-compression": "^2.0.2",
"color": "^5.0.0",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"dexie": "^4.0.8", "dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7", "dexie-react-hooks": "^1.1.7",
@ -163,9 +166,11 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"fast-diff": "^1.3.0",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"husky": "^9.1.7", "husky": "^9.1.7",
"i18next": "^23.11.5", "i18next": "^23.11.5",
"jest-styled-components": "^7.2.0",
"lint-staged": "^15.5.0", "lint-staged": "^15.5.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lru-cache": "^11.1.0", "lru-cache": "^11.1.0",
@ -176,6 +181,7 @@
"npx-scope-finder": "^1.2.0", "npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch", "openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
"p-queue": "^8.1.0", "p-queue": "^8.1.0",
"playwright": "^1.52.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"rc-virtual-list": "^3.18.6", "rc-virtual-list": "^3.18.6",
"react": "^19.0.0", "react": "^19.0.0",
@ -207,7 +213,7 @@
"typescript": "^5.6.2", "typescript": "^5.6.2",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"vite": "6.2.6", "vite": "6.2.6",
"vitest": "^3.1.1" "vitest": "^3.1.4"
}, },
"resolutions": { "resolutions": {
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",

View File

@ -176,5 +176,20 @@ export enum IpcChannel {
StoreSync_BroadcastSync = 'store-sync:broadcast-sync', StoreSync_BroadcastSync = 'store-sync:broadcast-sync',
// Provider // 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'
} }

42
playwright.config.ts Normal file
View File

@ -0,0 +1,42 @@
import { defineConfig, devices } from '@playwright/test'
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
// Look for test files, relative to this configuration file.
testDir: './tests/e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry'
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
]
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
})

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import Store from 'electron-store'
import { locales } from '../utils/locales' import { locales } from '../utils/locales'
enum ConfigKeys { export enum ConfigKeys {
Language = 'language', Language = 'language',
Theme = 'theme', Theme = 'theme',
LaunchToTray = 'launchToTray', LaunchToTray = 'launchToTray',
@ -16,7 +16,10 @@ enum ConfigKeys {
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant', ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
EnableQuickAssistant = 'enableQuickAssistant', EnableQuickAssistant = 'enableQuickAssistant',
AutoUpdate = 'autoUpdate', AutoUpdate = 'autoUpdate',
EnableDataCollection = 'enableDataCollection' EnableDataCollection = 'enableDataCollection',
SelectionAssistantEnabled = 'selectionAssistantEnabled',
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar'
} }
export class ConfigManager { export class ConfigManager {
@ -146,6 +149,36 @@ export class ConfigManager {
this.set(ConfigKeys.EnableDataCollection, value) 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) { set(key: string, value: unknown) {
this.store.set(key, value) this.store.set(key, value)
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { decrypt, encrypt } from '../aes'
const key = '12345678901234567890123456789012' // 32字节
const iv = '1234567890abcdef1234567890abcdef' // 32字节hex实际应16字节hex
function getIv16() {
// 取前16字节作为 hex
return iv.slice(0, 32)
}
describe('aes utils', () => {
it('should encrypt and decrypt normal string', () => {
const text = 'hello world'
const { iv: outIv, encryptedData } = encrypt(text, key, getIv16())
expect(typeof encryptedData).toBe('string')
expect(outIv).toBe(getIv16())
const decrypted = decrypt(encryptedData, getIv16(), key)
expect(decrypted).toBe(text)
})
it('should support unicode and special chars', () => {
const text = '你好,世界!🌟🚀'
const { encryptedData } = encrypt(text, key, getIv16())
const decrypted = decrypt(encryptedData, getIv16(), key)
expect(decrypted).toBe(text)
})
it('should handle empty string', () => {
const text = ''
const { encryptedData } = encrypt(text, key, getIv16())
const decrypted = decrypt(encryptedData, getIv16(), key)
expect(decrypted).toBe(text)
})
it('should encrypt and decrypt long string', () => {
const text = 'a'.repeat(100_000)
const { encryptedData } = encrypt(text, key, getIv16())
const decrypted = decrypt(encryptedData, getIv16(), key)
expect(decrypted).toBe(text)
})
it('should throw error for wrong key', () => {
const text = 'test'
const { encryptedData } = encrypt(text, key, getIv16())
expect(() => decrypt(encryptedData, getIv16(), 'wrongkeywrongkeywrongkeywrongkey')).toThrow()
})
it('should throw error for wrong iv', () => {
const text = 'test'
const { encryptedData } = encrypt(text, key, getIv16())
expect(() => decrypt(encryptedData, 'abcdefabcdefabcdefabcdefabcdefab', key)).toThrow()
})
it('should throw error for invalid key/iv length', () => {
expect(() => encrypt('test', 'shortkey', getIv16())).toThrow()
expect(() => encrypt('test', key, 'shortiv')).toThrow()
})
it('should throw error for invalid encrypted data', () => {
expect(() => decrypt('nothexdata', getIv16(), key)).toThrow()
})
it('should throw error for non-string input', () => {
// @ts-expect-error purposely pass wrong type to test error branch
expect(() => encrypt(null, key, getIv16())).toThrow()
// @ts-expect-error purposely pass wrong type to test error branch
expect(() => decrypt(null, getIv16(), key)).toThrow()
})
})

View File

@ -0,0 +1,243 @@
import * as fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { FileTypes } from '@types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
// Mock dependencies
vi.mock('node:fs')
vi.mock('node:os')
vi.mock('node:path')
vi.mock('uuid', () => ({
v4: () => 'mock-uuid'
}))
vi.mock('electron', () => ({
app: {
getPath: vi.fn((key) => {
if (key === 'temp') return '/mock/temp'
if (key === 'userData') return '/mock/userData'
return '/mock/unknown'
})
}
}))
describe('file', () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock path.extname
vi.mocked(path.extname).mockImplementation((file) => {
const parts = file.split('.')
return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''
})
// Mock path.basename
vi.mocked(path.basename).mockImplementation((file) => {
const parts = file.split('/')
return parts[parts.length - 1]
})
// Mock path.join
vi.mocked(path.join).mockImplementation((...args) => args.join('/'))
// Mock os.homedir
vi.mocked(os.homedir).mockReturnValue('/mock/home')
})
afterEach(() => {
vi.resetAllMocks()
})
describe('getFileType', () => {
it('should return IMAGE for image extensions', () => {
expect(getFileType('.jpg')).toBe(FileTypes.IMAGE)
expect(getFileType('.jpeg')).toBe(FileTypes.IMAGE)
expect(getFileType('.png')).toBe(FileTypes.IMAGE)
expect(getFileType('.gif')).toBe(FileTypes.IMAGE)
expect(getFileType('.webp')).toBe(FileTypes.IMAGE)
expect(getFileType('.bmp')).toBe(FileTypes.IMAGE)
})
it('should return VIDEO for video extensions', () => {
expect(getFileType('.mp4')).toBe(FileTypes.VIDEO)
expect(getFileType('.avi')).toBe(FileTypes.VIDEO)
expect(getFileType('.mov')).toBe(FileTypes.VIDEO)
expect(getFileType('.mkv')).toBe(FileTypes.VIDEO)
expect(getFileType('.flv')).toBe(FileTypes.VIDEO)
})
it('should return AUDIO for audio extensions', () => {
expect(getFileType('.mp3')).toBe(FileTypes.AUDIO)
expect(getFileType('.wav')).toBe(FileTypes.AUDIO)
expect(getFileType('.ogg')).toBe(FileTypes.AUDIO)
expect(getFileType('.flac')).toBe(FileTypes.AUDIO)
expect(getFileType('.aac')).toBe(FileTypes.AUDIO)
})
it('should return TEXT for text extensions', () => {
expect(getFileType('.txt')).toBe(FileTypes.TEXT)
expect(getFileType('.md')).toBe(FileTypes.TEXT)
expect(getFileType('.html')).toBe(FileTypes.TEXT)
expect(getFileType('.json')).toBe(FileTypes.TEXT)
expect(getFileType('.js')).toBe(FileTypes.TEXT)
expect(getFileType('.ts')).toBe(FileTypes.TEXT)
expect(getFileType('.css')).toBe(FileTypes.TEXT)
expect(getFileType('.java')).toBe(FileTypes.TEXT)
expect(getFileType('.py')).toBe(FileTypes.TEXT)
})
it('should return DOCUMENT for document extensions', () => {
expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT)
})
it('should return OTHER for unknown extensions', () => {
expect(getFileType('.unknown')).toBe(FileTypes.OTHER)
expect(getFileType('')).toBe(FileTypes.OTHER)
expect(getFileType('.')).toBe(FileTypes.OTHER)
expect(getFileType('...')).toBe(FileTypes.OTHER)
expect(getFileType('.123')).toBe(FileTypes.OTHER)
})
it('should handle case-insensitive extensions', () => {
expect(getFileType('.JPG')).toBe(FileTypes.IMAGE)
expect(getFileType('.PDF')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.Mp3')).toBe(FileTypes.AUDIO)
expect(getFileType('.HtMl')).toBe(FileTypes.TEXT)
expect(getFileType('.Xlsx')).toBe(FileTypes.DOCUMENT)
})
it('should handle extensions without leading dot', () => {
expect(getFileType('jpg')).toBe(FileTypes.OTHER)
expect(getFileType('pdf')).toBe(FileTypes.OTHER)
expect(getFileType('mp3')).toBe(FileTypes.OTHER)
})
it('should handle extreme cases', () => {
expect(getFileType('.averylongfileextensionname')).toBe(FileTypes.OTHER)
expect(getFileType('.tar.gz')).toBe(FileTypes.OTHER)
expect(getFileType('.文件')).toBe(FileTypes.OTHER)
expect(getFileType('.файл')).toBe(FileTypes.OTHER)
})
})
describe('getAllFiles', () => {
it('should return all valid files recursively', () => {
// Mock file system
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockImplementation((dirPath) => {
if (dirPath === '/test') {
return ['file1.txt', 'file2.pdf', 'subdir']
} else if (dirPath === '/test/subdir') {
return ['file3.md', 'file4.docx']
}
return []
})
vi.mocked(fs.statSync).mockImplementation((filePath) => {
const isDir = String(filePath).endsWith('subdir')
return {
isDirectory: () => isDir,
size: 1024
} as fs.Stats
})
const result = getAllFiles('/test')
expect(result).toHaveLength(4)
expect(result[0].id).toBe('mock-uuid')
expect(result[0].name).toBe('file1.txt')
expect(result[0].type).toBe(FileTypes.TEXT)
expect(result[1].name).toBe('file2.pdf')
expect(result[1].type).toBe(FileTypes.DOCUMENT)
})
it('should skip hidden files', () => {
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockReturnValue(['.hidden', 'visible.txt'])
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => false,
size: 1024
} as fs.Stats)
const result = getAllFiles('/test')
expect(result).toHaveLength(1)
expect(result[0].name).toBe('visible.txt')
})
it('should skip unsupported file types', () => {
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockReturnValue(['image.jpg', 'video.mp4', 'audio.mp3', 'document.pdf'])
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => false,
size: 1024
} as fs.Stats)
const result = getAllFiles('/test')
// Should only include document.pdf as the others are excluded types
expect(result).toHaveLength(1)
expect(result[0].name).toBe('document.pdf')
expect(result[0].type).toBe(FileTypes.DOCUMENT)
})
it('should return empty array for empty directory', () => {
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockReturnValue([])
const result = getAllFiles('/empty')
expect(result).toHaveLength(0)
})
it('should handle file system errors', () => {
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
throw new Error('Directory not found')
})
// Since the function doesn't have error handling, we expect it to propagate
expect(() => getAllFiles('/nonexistent')).toThrow('Directory not found')
})
})
describe('getTempDir', () => {
it('should return correct temp directory path', () => {
const tempDir = getTempDir()
expect(tempDir).toBe('/mock/temp/CherryStudio')
})
})
describe('getFilesDir', () => {
it('should return correct files directory path', () => {
const filesDir = getFilesDir()
expect(filesDir).toBe('/mock/userData/Data/Files')
})
})
describe('getConfigDir', () => {
it('should return correct config directory path', () => {
const configDir = getConfigDir()
expect(configDir).toBe('/mock/home/.cherrystudio/config')
})
})
describe('getAppConfigDir', () => {
it('should return correct app config directory path', () => {
const appConfigDir = getAppConfigDir('test-app')
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/test-app')
})
it('should handle empty app name', () => {
const appConfigDir = getAppConfigDir('')
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/')
})
})
})

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { compress, decompress } from '../zip'
const jsonStr = JSON.stringify({ foo: 'bar', num: 42, arr: [1, 2, 3] })
// 辅助函数:生成大字符串
function makeLargeString(size: number) {
return 'a'.repeat(size)
}
describe('zip', () => {
describe('compress & decompress', () => {
it('should compress and decompress a normal JSON string', async () => {
const compressed = await compress(jsonStr)
expect(compressed).toBeInstanceOf(Buffer)
const decompressed = await decompress(compressed)
expect(decompressed).toBe(jsonStr)
})
it('should handle empty string', async () => {
const compressed = await compress('')
expect(compressed).toBeInstanceOf(Buffer)
const decompressed = await decompress(compressed)
expect(decompressed).toBe('')
})
it('should handle large string', async () => {
const largeStr = makeLargeString(100_000)
const compressed = await compress(largeStr)
expect(compressed).toBeInstanceOf(Buffer)
expect(compressed.length).toBeLessThan(largeStr.length)
const decompressed = await decompress(compressed)
expect(decompressed).toBe(largeStr)
})
it('should throw error when decompressing invalid buffer', async () => {
const invalidBuffer = Buffer.from('not a valid gzip', 'utf-8')
await expect(decompress(invalidBuffer)).rejects.toThrow()
})
it('should throw error when compress input is not string', async () => {
// @ts-expect-error purposely pass wrong type to test error branch
await expect(compress(null)).rejects.toThrow()
// @ts-expect-error purposely pass wrong type to test error branch
await expect(compress(undefined)).rejects.toThrow()
// @ts-expect-error purposely pass wrong type to test error branch
await expect(compress(123)).rejects.toThrow()
})
it('should throw error when decompress input is not buffer', async () => {
// @ts-expect-error purposely pass wrong type to test error branch
await expect(decompress(null)).rejects.toThrow()
// @ts-expect-error purposely pass wrong type to test error branch
await expect(decompress(undefined)).rejects.toThrow()
// @ts-expect-error purposely pass wrong type to test error branch
await expect(decompress('string')).rejects.toThrow()
})
})
})

View File

@ -9,10 +9,10 @@ const gunzipPromise = util.promisify(zlib.gunzip)
/** /**
* *
* @param {string} str JSON
* @returns {Promise<Buffer>} Buffer * @returns {Promise<Buffer>} Buffer
* @param str
*/ */
export async function compress(str) { export async function compress(str: string): Promise<Buffer> {
try { try {
const buffer = Buffer.from(str, 'utf-8') const buffer = Buffer.from(str, 'utf-8')
return await gzipPromise(buffer) return await gzipPromise(buffer)
@ -27,7 +27,7 @@ export async function compress(str) {
* @param {Buffer} compressedBuffer - Buffer * @param {Buffer} compressedBuffer - Buffer
* @returns {Promise<string>} JSON * @returns {Promise<string>} JSON
*/ */
export async function decompress(compressedBuffer) { export async function decompress(compressedBuffer: Buffer): Promise<string> {
try { try {
const buffer = await gunzipPromise(compressedBuffer) const buffer = await gunzipPromise(compressedBuffer)
return buffer.toString('utf-8') return buffer.toString('utf-8')

View File

@ -6,6 +6,8 @@ import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from '
import { Notification } from 'src/renderer/src/types/notification' import { Notification } from 'src/renderer/src/types/notification'
import { CreateDirectoryOptions } from 'webdav' import { CreateDirectoryOptions } from 'webdav'
import type { ActionItem } from '../renderer/src/types/selectionTypes'
// Custom APIs for renderer // Custom APIs for renderer
const api = { const api = {
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info), getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
@ -205,6 +207,20 @@ const api = {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe), subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe), unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe),
onUpdate: (action: any) => ipcRenderer.invoke(IpcChannel.StoreSync_OnUpdate, action) 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

@ -1,49 +0,0 @@
import { vi } from 'vitest'
vi.mock('electron-log/renderer', () => {
return {
default: {
info: console.log,
error: console.error,
warn: console.warn,
debug: console.debug,
verbose: console.log,
silly: console.log,
log: console.log,
transports: {
console: {
level: 'info'
}
}
}
}
})
vi.stubGlobal('window', {
electron: {
ipcRenderer: {
on: vi.fn(), // Mocking ipcRenderer.on
send: vi.fn() // Mocking ipcRenderer.send
}
},
api: {
file: {
read: vi.fn().mockResolvedValue('[]'), // Mock file.read to return an empty array (you can customize this)
writeWithId: vi.fn().mockResolvedValue(undefined) // Mock file.writeWithId to do nothing
}
}
})
vi.mock('axios', () => ({
default: {
get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request
post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request
// You can add other axios methods like put, delete etc. as needed
}
}))
vi.stubGlobal('window', {
...global.window, // Copy other global properties
addEventListener: vi.fn(), // Mock addEventListener
removeEventListener: vi.fn() // You can also mock removeEventListener if needed
})

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

@ -434,7 +434,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
$pageSize={ctx.pageSize} $pageSize={ctx.pageSize}
$selectedColor={selectedColor} $selectedColor={selectedColor}
$selectedColorHover={selectedColorHover} $selectedColorHover={selectedColorHover}
className={ctx.isVisible ? 'visible' : ''}> className={ctx.isVisible ? 'visible' : ''}
data-testid="quick-panel">
<QuickPanelBody <QuickPanelBody
ref={bodyRef} ref={bodyRef}
onMouseMove={() => onMouseMove={() =>

View File

@ -3,12 +3,12 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> { interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
right?: boolean
ref?: React.RefObject<HTMLDivElement | null> ref?: React.RefObject<HTMLDivElement | null>
right?: boolean
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
} }
const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => { const Scrollbar: FC<Props> = ({ ref: passedRef, right, children, onScroll: externalOnScroll, ...htmlProps }) => {
const [isScrolling, setIsScrolling] = useState(false) const [isScrolling, setIsScrolling] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null) const timeoutRef = useRef<NodeJS.Timeout | null>(null)
@ -43,7 +43,8 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
return ( return (
<Container <Container
{...htmlProps} // Pass other HTML attributes {...htmlProps} // Pass other HTML attributes
isScrolling={isScrolling} $isScrolling={isScrolling}
$right={right}
onScroll={combinedOnScroll} // Use the combined handler onScroll={combinedOnScroll} // Use the combined handler
ref={passedRef}> ref={passedRef}>
{children} {children}
@ -51,15 +52,15 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
) )
} }
const Container = styled.div<{ isScrolling: boolean; right?: boolean }>` const Container = styled.div<{ $isScrolling: boolean; $right?: boolean }>`
overflow-y: auto; overflow-y: auto;
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
transition: background 2s ease; transition: background 2s ease;
background: ${(props) => background: ${(props) =>
props.isScrolling ? `var(--color-scrollbar-thumb${props.right ? '-right' : ''})` : 'transparent'}; props.$isScrolling ? `var(--color-scrollbar-thumb${props.$right ? '-right' : ''})` : 'transparent'};
&:hover { &:hover {
background: ${(props) => background: ${(props) =>
props.isScrolling ? `var(--color-scrollbar-thumb${props.right ? '-right' : ''}-hover)` : 'transparent'}; props.$isScrolling ? `var(--color-scrollbar-thumb${props.$right ? '-right' : ''}-hover)` : 'transparent'};
} }
} }
` `

View File

@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import CustomTag from '../CustomTag'
const COLOR = '#ff0000'
describe('CustomTag', () => {
it('should render children text', () => {
render(<CustomTag color={COLOR}>content</CustomTag>)
expect(screen.getByText('content')).toBeInTheDocument()
})
it('should render icon if provided', () => {
render(
<CustomTag color={COLOR} icon={<span data-testid="icon">cherry</span>}>
content
</CustomTag>
)
expect(screen.getByTestId('icon')).toBeInTheDocument()
expect(screen.getByText('content')).toBeInTheDocument()
})
it('should show tooltip if tooltip prop is set', async () => {
render(
<CustomTag color={COLOR} tooltip="reasoning model">
reasoning
</CustomTag>
)
// 鼠标悬停触发 Tooltip
await userEvent.hover(screen.getByText('reasoning'))
expect(await screen.findByText('reasoning model')).toBeInTheDocument()
})
it('should not render Tooltip when tooltip is not set', () => {
render(<CustomTag color="#ff0000">no tooltip</CustomTag>)
expect(screen.getByText('no tooltip')).toBeInTheDocument()
// 不应有 tooltip 相关内容
expect(document.querySelector('.ant-tooltip')).toBeNull()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,282 @@
/// <reference types="@vitest/browser/context" />
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DragableList from '../DragableList'
// mock @hello-pangea/dnd 组件
vi.mock('@hello-pangea/dnd', () => {
return {
__esModule: true,
DragDropContext: ({ children, onDragEnd }: any) => {
// 挂载到 window 以便测试用例直接调用
window.triggerOnDragEnd = (result = { source: { index: 0 }, destination: { index: 1 } }, provided = {}) => {
onDragEnd && onDragEnd(result, provided)
}
return <div data-testid="drag-drop-context">{children}</div>
},
Droppable: ({ children }: any) => (
<div data-testid="droppable">
{children({ droppableProps: {}, innerRef: () => {}, placeholder: <div data-testid="placeholder" /> })}
</div>
),
Draggable: ({ children, draggableId, index }: any) => (
<div data-testid={`draggable-${draggableId}-${index}`}>
{children({ draggableProps: {}, dragHandleProps: {}, innerRef: () => {} })}
</div>
)
}
})
// mock VirtualList 只做简单渲染
vi.mock('rc-virtual-list', () => ({
__esModule: true,
default: ({ data, itemKey, children }: any) => (
<div data-testid="virtual-list">
{data.map((item: any, idx: number) => (
<div key={item[itemKey] || item} data-testid="virtual-list-item">
{children(item, idx)}
</div>
))}
</div>
)
}))
declare global {
interface Window {
triggerOnDragEnd: (result?: any, provided?: any) => void
}
}
describe('DragableList', () => {
describe('rendering', () => {
it('should render all list items', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
render(
<DragableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
const items = screen.getAllByTestId('item')
expect(items.length).toBe(3)
expect(items[0].textContent).toBe('A')
expect(items[1].textContent).toBe('B')
expect(items[2].textContent).toBe('C')
})
it('should render with custom style and listStyle', () => {
const list = [{ id: 'a', name: 'A' }]
const style = { background: 'red' }
const listStyle = { color: 'blue' }
render(
<DragableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 检查 style 是否传递到外层容器
const virtualList = screen.getByTestId('virtual-list')
expect(virtualList.parentElement).toHaveStyle({ background: 'red' })
})
it('should render nothing when list is empty', () => {
render(
<DragableList list={[]} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 虚拟列表存在但无内容
const items = screen.queryAllByTestId('item')
expect(items.length).toBe(0)
})
})
describe('drag and drop', () => {
it('should call onUpdate with new order after drag end', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const newOrder = [list[1], list[2], list[0]]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 直接调用 window.triggerOnDragEnd 模拟拖拽结束
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
expect(onUpdate).toHaveBeenCalledWith(newOrder)
expect(onUpdate).toHaveBeenCalledTimes(1)
})
it('should call onDragStart and onDragEnd', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const onDragStart = vi.fn()
const onDragEnd = vi.fn()
render(
<DragableList list={list} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 先手动调用 onDragStart
onDragStart()
// 再模拟拖拽结束
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {})
expect(onDragStart).toHaveBeenCalledTimes(1)
expect(onDragEnd).toHaveBeenCalledTimes(1)
})
it('should not call onUpdate if dropped at same position', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 模拟拖拽到自身
window.triggerOnDragEnd({ source: { index: 1 }, destination: { index: 1 } }, {})
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate.mock.calls[0][0]).toEqual(list)
})
})
describe('edge cases', () => {
it('should work with single item', () => {
const list = [{ id: 'a', name: 'A' }]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 拖拽自身
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 0 } }, {})
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate.mock.calls[0][0]).toEqual(list)
})
it('should not crash if callbacks are undefined', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' }
]
// 不传 onDragStart/onDragEnd
expect(() => {
render(
<DragableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {})
}).not.toThrow()
})
it('should handle items without id', () => {
const list = ['A', 'B', 'C']
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item}</div>}
</DragableList>
)
// 拖拽第0项到第2项
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate.mock.calls[0][0]).toEqual(['B', 'C', 'A'])
})
})
describe('interaction', () => {
it('should show placeholder during drag', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
render(
<DragableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// placeholder 应该在初始渲染时就存在
const placeholder = screen.getByTestId('placeholder')
expect(placeholder).toBeInTheDocument()
})
it('should reorder correctly when dragged to first/last', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 拖拽第2项到第0项
window.triggerOnDragEnd({ source: { index: 2 }, destination: { index: 0 } }, {})
expect(onUpdate).toHaveBeenCalledWith([
{ id: 'c', name: 'C' },
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' }
])
// 拖拽第0项到第2项
onUpdate.mockClear()
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
expect(onUpdate).toHaveBeenCalledWith([
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' },
{ id: 'a', name: 'A' }
])
})
})
describe('snapshot', () => {
it('should match snapshot', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const { container } = render(
<DragableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
expect(container).toMatchSnapshot()
})
})
})

View File

@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import ExpandableText from '../ExpandableText'
// mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k })
}))
describe('ExpandableText', () => {
const TEXT = 'This is a long text for testing.'
it('should render text and expand button', () => {
render(<ExpandableText text={TEXT} />)
expect(screen.getByText(TEXT)).toBeInTheDocument()
expect(screen.getByRole('button')).toHaveTextContent('common.expand')
})
it('should toggle expand/collapse when button is clicked', async () => {
render(<ExpandableText text={TEXT} />)
const button = screen.getByRole('button')
// 初始为收起状态
expect(button).toHaveTextContent('common.expand')
// 点击展开
await userEvent.click(button)
expect(button).toHaveTextContent('common.collapse')
// 再次点击收起
await userEvent.click(button)
expect(button).toHaveTextContent('common.expand')
})
})

View File

@ -0,0 +1,188 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { QuickPanelListItem, QuickPanelProvider, QuickPanelView, useQuickPanel } from '../QuickPanel'
function createList(length: number, prefix = 'Item', extra: Partial<QuickPanelListItem> = {}) {
return Array.from({ length }, (_, i) => ({
label: `${prefix} ${i + 1}`,
description: `${prefix} Description ${i + 1}`,
icon: `${prefix} Icon ${i + 1}`,
action: () => {},
...extra
}))
}
type KeyStep = {
key: string
ctrlKey?: boolean
expected: string | ((text: string) => boolean)
}
const PAGE_SIZE = 7
// 用于测试 open 行为的组件
function OpenPanelOnMount({ list }: { list: QuickPanelListItem[] }) {
const quickPanel = useQuickPanel()
useEffect(() => {
quickPanel.open({
title: 'Test Panel',
list,
symbol: 'test',
pageSize: PAGE_SIZE
})
}, [list, quickPanel])
return null
}
describe('QuickPanelView', () => {
beforeEach(() => {
// 添加一个假的 .inputbar textarea 到 document.body
const inputbar = document.createElement('div')
inputbar.className = 'inputbar'
const textarea = document.createElement('textarea')
inputbar.appendChild(textarea)
document.body.appendChild(inputbar)
})
afterEach(() => {
const inputbar = document.querySelector('.inputbar')
if (inputbar) inputbar.remove()
})
describe('rendering', () => {
it('should render without crashing when wrapped in QuickPanelProvider', () => {
render(
<QuickPanelProvider>
<QuickPanelView setInputText={vi.fn()} />
</QuickPanelProvider>
)
// 检查面板容器是否存在且初始不可见
const panel = screen.getByTestId('quick-panel')
expect(panel.classList.contains('visible')).toBe(false)
})
it('should render list after open', async () => {
const list = createList(100)
render(
<QuickPanelProvider>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</QuickPanelProvider>
)
// 检查面板可见
const panel = screen.getByTestId('quick-panel')
expect(panel.classList.contains('visible')).toBe(true)
// 检查第一个 item 是否渲染
expect(screen.getByText('Item 1')).toBeInTheDocument()
})
})
describe('focusing', () => {
// 执行一系列按键,检查 focused item 是否正确
async function runKeySequenceAndCheck(panel: HTMLElement, sequence: KeyStep[]) {
const user = userEvent.setup()
for (const { key, ctrlKey, expected } of sequence) {
let keyString = ''
if (ctrlKey) keyString += '{Control>}'
keyString += key.length === 1 ? key : `{${key}}`
if (ctrlKey) keyString += '{/Control}'
await user.keyboard(keyString)
// 检查是否只有一个 focused item
const focused = panel.querySelectorAll('.focused')
expect(focused.length).toBe(1)
// 检查 focused item 是否包含预期文本
const text = focused[0].textContent || ''
if (typeof expected === 'string') {
expect(text).toContain(expected)
} else {
expect(expected(text)).toBe(true)
}
}
}
it('should focus on the first item after panel open', () => {
const list = createList(100)
render(
<QuickPanelProvider>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</QuickPanelProvider>
)
// 检查第一个 item 是否有 focused
const item1 = screen.getByText('Item 1')
const focused = item1.closest('.focused')
expect(focused).not.toBeNull()
expect(item1).toBeInTheDocument()
})
it('should focus on the right item using ArrowUp, ArrowDown', async () => {
const list = createList(100, 'Item')
render(
<QuickPanelProvider>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</QuickPanelProvider>
)
const keySequence = [
{ key: 'ArrowUp', expected: 'Item 100' },
{ key: 'ArrowUp', expected: 'Item 99' },
{ key: 'ArrowDown', expected: 'Item 100' },
{ key: 'ArrowDown', expected: 'Item 1' }
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
})
it('should focus on the right item using PageUp, PageDown', async () => {
const list = createList(100, 'Item')
render(
<QuickPanelProvider>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</QuickPanelProvider>
)
const keySequence = [
{ key: 'PageUp', expected: 'Item 1' }, // 停留在顶部
{ key: 'ArrowUp', expected: 'Item 100' },
{ key: 'PageDown', expected: 'Item 100' }, // 停留在底部
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` },
{ key: 'PageDown', expected: 'Item 100' }
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
})
it('should focus on the right item using Ctrl+ArrowUp, Ctrl+ArrowDown', async () => {
const list = createList(100, 'Item')
render(
<QuickPanelProvider>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</QuickPanelProvider>
)
const keySequence = [
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` },
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' },
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' },
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
})
})
})

View File

@ -0,0 +1,191 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import Scrollbar from '../Scrollbar'
// Mock lodash throttle
vi.mock('lodash', async () => {
const actual = await import('lodash')
return {
...actual,
throttle: vi.fn((fn) => {
// 简单地直接返回函数,不实际执行节流
const throttled = (...args: any[]) => fn(...args)
throttled.cancel = vi.fn()
return throttled
})
}
})
describe('Scrollbar', () => {
beforeEach(() => {
// 使用 fake timers
vi.useFakeTimers()
})
afterEach(() => {
// 恢复真实的 timers
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('rendering', () => {
it('should render children correctly', () => {
render(
<Scrollbar data-testid="scrollbar">
<div data-testid="child"></div>
</Scrollbar>
)
const child = screen.getByTestId('child')
expect(child).toBeDefined()
expect(child.textContent).toBe('测试内容')
})
it('should pass custom props to container', () => {
render(
<Scrollbar data-testid="scrollbar" className="custom-class">
</Scrollbar>
)
const scrollbar = screen.getByTestId('scrollbar')
expect(scrollbar.className).toContain('custom-class')
})
it('should match default styled snapshot', () => {
const { container } = render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
expect(container.firstChild).toMatchSnapshot()
})
})
describe('scrolling behavior', () => {
it('should update isScrolling state when scrolled', () => {
render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
const scrollbar = screen.getByTestId('scrollbar')
// 初始状态下应该不是滚动状态
expect(scrollbar.getAttribute('isScrolling')).toBeFalsy()
// 触发滚动
fireEvent.scroll(scrollbar)
// 由于 isScrolling 是组件内部状态,不直接反映在 DOM 属性上
// 但可以检查模拟的事件处理是否被调用
expect(scrollbar).toBeDefined()
})
it('should reset isScrolling after timeout', () => {
render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
const scrollbar = screen.getByTestId('scrollbar')
// 触发滚动
fireEvent.scroll(scrollbar)
// 前进时间但不超过timeout
act(() => {
vi.advanceTimersByTime(1000)
})
// 前进超过timeout
act(() => {
vi.advanceTimersByTime(600)
})
// 不测试样式,这里只检查组件是否存在
expect(scrollbar).toBeDefined()
})
it('should reset timeout on continuous scrolling', () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
const scrollbar = screen.getByTestId('scrollbar')
// 第一次滚动
fireEvent.scroll(scrollbar)
// 前进一部分时间
act(() => {
vi.advanceTimersByTime(800)
})
// 再次滚动
fireEvent.scroll(scrollbar)
// clearTimeout 应该被调用,因为在第二次滚动时会清除之前的定时器
expect(clearTimeoutSpy).toHaveBeenCalled()
})
})
describe('throttling', () => {
it('should use throttled scroll handler', async () => {
const { throttle } = await import('lodash')
render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
// 验证 throttle 被调用
expect(throttle).toHaveBeenCalled()
// 验证 throttle 调用时使用了 200ms 延迟
expect(throttle).toHaveBeenCalledWith(expect.any(Function), 200)
})
})
describe('cleanup', () => {
it('should clear timeout and cancel throttle on unmount', async () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
const { unmount } = render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
const scrollbar = screen.getByTestId('scrollbar')
// 触发滚动设置定时器
fireEvent.scroll(scrollbar)
// 卸载组件
unmount()
// 验证 clearTimeout 被调用
expect(clearTimeoutSpy).toHaveBeenCalled()
// 验证 throttle.cancel 被调用
const { throttle } = await import('lodash')
const throttledFunction = (throttle as unknown as Mock).mock.results[0].value
expect(throttledFunction.cancel).toHaveBeenCalled()
})
})
describe('props handling', () => {
it('should handle right prop correctly', () => {
const { container } = render(
<Scrollbar data-testid="scrollbar" right>
</Scrollbar>
)
const scrollbar = screen.getByTestId('scrollbar')
// 验证 right 属性被正确传递
expect(scrollbar).toBeDefined()
// snapshot 测试 styled-components 样式
expect(container.firstChild).toMatchSnapshot()
})
it('should handle ref forwarding', () => {
const ref = { current: null }
render(
<Scrollbar data-testid="scrollbar" ref={ref}>
</Scrollbar>
)
// 验证 ref 被正确设置
expect(ref.current).not.toBeNull()
})
})
})

View File

@ -0,0 +1,74 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DragableList > snapshot > should match snapshot 1`] = `
<div>
<div
data-testid="drag-drop-context"
>
<div
data-testid="droppable"
>
<div>
<div
data-testid="virtual-list"
>
<div
data-testid="virtual-list-item"
>
<div
data-testid="draggable-a-0"
>
<div
style="margin-bottom: 8px;"
>
<div
data-testid="item"
>
A
</div>
</div>
</div>
</div>
<div
data-testid="virtual-list-item"
>
<div
data-testid="draggable-b-1"
>
<div
style="margin-bottom: 8px;"
>
<div
data-testid="item"
>
B
</div>
</div>
</div>
</div>
<div
data-testid="virtual-list-item"
>
<div
data-testid="draggable-c-2"
>
<div
style="margin-bottom: 8px;"
>
<div
data-testid="item"
>
C
</div>
</div>
</div>
</div>
</div>
<div
data-testid="placeholder"
/>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,45 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Scrollbar > props handling > should handle right prop correctly 1`] = `
.c0 {
overflow-y: auto;
}
.c0::-webkit-scrollbar-thumb {
transition: background 2s ease;
background: transparent;
}
.c0::-webkit-scrollbar-thumb:hover {
background: transparent;
}
<div
class="c0"
data-testid="scrollbar"
>
内容
</div>
`;
exports[`Scrollbar > rendering > should match default styled snapshot 1`] = `
.c0 {
overflow-y: auto;
}
.c0::-webkit-scrollbar-thumb {
transition: background 2s ease;
background: transparent;
}
.c0::-webkit-scrollbar-thumb:hover {
background: transparent;
}
<div
class="c0"
data-testid="scrollbar"
>
内容
</div>
`;

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

@ -1780,6 +1780,141 @@
"quit": "Quit", "quit": "Quit",
"show_window": "Show Window", "show_window": "Show Window",
"visualization": "Visualization" "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

@ -1780,6 +1780,141 @@
"quit": "終了", "quit": "終了",
"show_window": "ウィンドウを表示", "show_window": "ウィンドウを表示",
"visualization": "可視化" "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

@ -1781,6 +1781,141 @@
"quit": "Выйти", "quit": "Выйти",
"show_window": "Показать окно", "show_window": "Показать окно",
"visualization": "Визуализация" "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

@ -1780,6 +1780,141 @@
"quit": "退出", "quit": "退出",
"show_window": "显示窗口", "show_window": "显示窗口",
"visualization": "可视化" "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

@ -1781,6 +1781,141 @@
"quit": "結束", "quit": "結束",
"show_window": "顯示視窗", "show_window": "顯示視窗",
"visualization": "視覺化" "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

@ -450,10 +450,6 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
openResourcesList openResourcesList
})) }))
if (activedMcpServers.length === 0) {
return null
}
return ( return (
<Tooltip placement="top" title={t('settings.mcp.title')} arrow> <Tooltip placement="top" title={t('settings.mcp.title')} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}> <ToolbarButton type="text" onClick={handleOpenQuickPanel}>

View File

@ -12,7 +12,7 @@ import { checkApi, formatApiKeys } from '@renderer/services/ApiService'
import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService' import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService'
import { isProviderSupportAuth } from '@renderer/services/ProviderService' import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { Provider } from '@renderer/types' import { Provider } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api' import { formatApiHost, splitApiKeyString } from '@renderer/utils/api'
import { lightbulbVariants } from '@renderer/utils/motionVariants' import { lightbulbVariants } from '@renderer/utils/motionVariants'
import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd' import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
import Link from 'antd/es/typography/Link' import Link from 'antd/es/typography/Link'
@ -127,10 +127,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
return return
} }
const keys = apiKey const keys = splitApiKeyString(apiKey)
.split(',')
.map((k) => k.trim())
.filter((k) => k)
// Add an empty key to enable health checks for local models. // Add an empty key to enable health checks for local models.
// Error messages will be shown for each model if a valid key is needed. // Error messages will be shown for each model if a valid key is needed.
@ -215,11 +212,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
} }
if (apiKey.includes(',')) { if (apiKey.includes(',')) {
const keys = apiKey const keys = splitApiKeyString(apiKey)
.split(/(?<!\\),/)
.map((k) => k.trim())
.map((k) => k.replace(/\\,/g, ','))
.filter((k) => k)
const result = await ApiCheckPopup.show({ const result = await ApiCheckPopup.show({
title: t('settings.provider.check_multiple_keys'), title: t('settings.provider.check_multiple_keys'),

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

View File

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

View File

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

@ -1,124 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`markdown > markdown configuration constants > sanitizeSchema matches snapshot 1`] = `
{
"attributes": {
"*": [
"className",
"style",
"id",
"title",
],
"a": [
"href",
"target",
"rel",
],
"circle": [
"cx",
"cy",
"r",
"fill",
"stroke",
],
"g": [
"transform",
"fill",
"stroke",
],
"line": [
"x1",
"y1",
"x2",
"y2",
"stroke",
],
"path": [
"d",
"fill",
"stroke",
"strokeWidth",
"strokeLinecap",
"strokeLinejoin",
],
"polygon": [
"points",
"fill",
"stroke",
],
"polyline": [
"points",
"fill",
"stroke",
],
"rect": [
"x",
"y",
"width",
"height",
"fill",
"stroke",
],
"svg": [
"viewBox",
"width",
"height",
"xmlns",
"fill",
"stroke",
],
"text": [
"x",
"y",
"fill",
"textAnchor",
"dominantBaseline",
],
},
"tagNames": [
"style",
"p",
"div",
"span",
"b",
"i",
"strong",
"em",
"ul",
"ol",
"li",
"table",
"tr",
"td",
"th",
"thead",
"tbody",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"pre",
"code",
"br",
"hr",
"svg",
"path",
"circle",
"rect",
"line",
"polyline",
"polygon",
"text",
"g",
"defs",
"title",
"desc",
"tspan",
"sub",
"sup",
],
}
`;

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { formatApiHost, maskApiKey } from '../api' import { formatApiHost, maskApiKey, splitApiKeyString } from '../api'
describe('api', () => { describe('api', () => {
describe('formatApiHost', () => { describe('formatApiHost', () => {
@ -67,4 +67,60 @@ describe('api', () => {
expect(maskApiKey('12345678')).toBe('12345678') expect(maskApiKey('12345678')).toBe('12345678')
}) })
}) })
describe('splitApiKeyString', () => {
it('should split comma-separated keys', () => {
const input = 'key1,key2,key3'
const result = splitApiKeyString(input)
expect(result).toEqual(['key1', 'key2', 'key3'])
})
it('should trim spaces around keys', () => {
const input = ' key1 , key2 ,key3 '
const result = splitApiKeyString(input)
expect(result).toEqual(['key1', 'key2', 'key3'])
})
it('should handle escaped commas', () => {
const input = 'key1,key2\\,withcomma,key3'
const result = splitApiKeyString(input)
expect(result).toEqual(['key1', 'key2,withcomma', 'key3'])
})
it('should handle multiple escaped commas', () => {
const input = 'key1\\,withcomma1,key2\\,withcomma2'
const result = splitApiKeyString(input)
expect(result).toEqual(['key1,withcomma1', 'key2,withcomma2'])
})
it('should ignore empty keys', () => {
const input = 'key1,,key2, ,key3'
const result = splitApiKeyString(input)
expect(result).toEqual(['key1', 'key2', 'key3'])
})
it('should return empty array for empty string', () => {
const input = ''
const result = splitApiKeyString(input)
expect(result).toEqual([])
})
it('should handle only one key', () => {
const input = 'singlekey'
const result = splitApiKeyString(input)
expect(result).toEqual(['singlekey'])
})
it('should handle only escaped comma', () => {
const input = 'key\\,withcomma'
const result = splitApiKeyString(input)
expect(result).toEqual(['key,withcomma'])
})
it('should handle all keys with spaces and escaped commas', () => {
const input = ' key1 , key2\\,withcomma , key3 '
const result = splitApiKeyString(input)
expect(result).toEqual(['key1', 'key2,withcomma', 'key3'])
})
})
}) })

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { delay, runAsyncFunction } from '../index' import { runAsyncFunction } from '../index'
import { compareVersions, hasPath, isFreeModel, isValidProxyUrl, removeQuotes, removeSpecialCharacters } from '../index'
describe('Unclassified Utils', () => { describe('Unclassified Utils', () => {
describe('runAsyncFunction', () => { describe('runAsyncFunction', () => {
@ -23,24 +24,118 @@ describe('Unclassified Utils', () => {
}) })
}) })
describe('delay', () => { describe('isFreeModel', () => {
it('should resolve after specified seconds', async () => { const base = { provider: '', group: '' }
// 验证指定时间后返回 it('should return true if id or name contains "free" (case-insensitive)', () => {
const start = Date.now() expect(isFreeModel({ id: 'free-model', name: 'test', ...base })).toBe(true)
await delay(0.01) expect(isFreeModel({ id: 'model', name: 'FreePlan', ...base })).toBe(true)
const end = Date.now() expect(isFreeModel({ id: 'model', name: 'notfree', ...base })).toBe(true)
// In JavaScript, the delay time of setTimeout is not always precise expect(isFreeModel({ id: 'model', name: 'test', ...base })).toBe(false)
// and may be slightly shorter than specified. Make it more lenient:
const lenientRatio = 0.8
expect(end - start).toBeGreaterThanOrEqual(10 * lenientRatio)
}) })
it('should resolve immediately for zero delay', async () => { it('should handle empty id or name', () => {
// 验证零延迟立即返回 expect(isFreeModel({ id: '', name: 'free', ...base })).toBe(true)
const start = Date.now() expect(isFreeModel({ id: 'free', name: '', ...base })).toBe(true)
await delay(0) expect(isFreeModel({ id: '', name: '', ...base })).toBe(false)
const end = Date.now() })
expect(end - start).toBeLessThan(100) })
describe('removeQuotes', () => {
it('should remove all single and double quotes', () => {
expect(removeQuotes('"hello"')).toBe('hello')
expect(removeQuotes("'hello'")).toBe('hello')
expect(removeQuotes('"hello"')).toBe('hello')
expect(removeQuotes('noquotes')).toBe('noquotes')
})
it('should handle empty string', () => {
expect(removeQuotes('')).toBe('')
})
it('should handle string with only quotes', () => {
expect(removeQuotes('""')).toBe('')
expect(removeQuotes("''")).toBe('')
})
})
describe('removeSpecialCharacters', () => {
it('should remove newlines, quotes, and special characters', () => {
expect(removeSpecialCharacters('hello\nworld!')).toBe('helloworld')
expect(removeSpecialCharacters('"hello, world!"')).toBe('hello world')
expect(removeSpecialCharacters('你好,世界!')).toBe('你好世界')
})
it('should handle empty string', () => {
expect(removeSpecialCharacters('')).toBe('')
})
it('should handle string with only special characters', () => {
expect(removeSpecialCharacters('"\n!,.')).toBe('')
})
})
describe('isValidProxyUrl', () => {
it('should return true for string containing "://"', () => {
expect(isValidProxyUrl('http://localhost')).toBe(true)
expect(isValidProxyUrl('socks5://127.0.0.1:1080')).toBe(true)
})
it('should return false for string not containing "://"', () => {
expect(isValidProxyUrl('localhost')).toBe(false)
expect(isValidProxyUrl('127.0.0.1:1080')).toBe(false)
})
it('should handle empty string', () => {
expect(isValidProxyUrl('')).toBe(false)
})
it('should return true for only "://"', () => {
expect(isValidProxyUrl('://')).toBe(true)
})
})
describe('hasPath', () => {
it('should return true if url has path', () => {
expect(hasPath('http://a.com/path')).toBe(true)
expect(hasPath('http://a.com/path/to')).toBe(true)
})
it('should return false if url has no path or only root', () => {
expect(hasPath('http://a.com/')).toBe(false)
expect(hasPath('http://a.com')).toBe(false)
})
it('should return false for invalid url', () => {
expect(hasPath('not a url')).toBe(false)
expect(hasPath('')).toBe(false)
})
})
describe('compareVersions', () => {
it('should return 1 if v1 > v2', () => {
expect(compareVersions('1.2.3', '1.2.2')).toBe(1)
expect(compareVersions('2.0.0', '1.9.9')).toBe(1)
expect(compareVersions('1.2.0', '1.1.9')).toBe(1)
expect(compareVersions('1.2.3', '1.2')).toBe(1)
})
it('should return -1 if v1 < v2', () => {
expect(compareVersions('1.2.2', '1.2.3')).toBe(-1)
expect(compareVersions('1.9.9', '2.0.0')).toBe(-1)
expect(compareVersions('1.1.9', '1.2.0')).toBe(-1)
expect(compareVersions('1.2', '1.2.3')).toBe(-1)
})
it('should return 0 if v1 == v2', () => {
expect(compareVersions('1.2.3', '1.2.3')).toBe(0)
expect(compareVersions('1.2', '1.2.0')).toBe(0)
expect(compareVersions('1.0.0', '1')).toBe(0)
})
it('should handle non-numeric and empty string', () => {
expect(compareVersions('', '')).toBe(0)
expect(compareVersions('a.b.c', '1.2.3')).toBe(-1)
expect(compareVersions('1.2.3', 'a.b.c')).toBe(1)
}) })
}) })
}) })

View File

@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
import { import {
convertMathFormula, convertMathFormula,
encodeHTML,
findCitationInChildren, findCitationInChildren,
getCodeBlockId, getCodeBlockId,
removeTrailingDoubleSpaces, removeTrailingDoubleSpaces,
@ -14,8 +15,8 @@ import {
describe('markdown', () => { describe('markdown', () => {
describe('findCitationInChildren', () => { describe('findCitationInChildren', () => {
it('returns null when children is null or undefined', () => { it('returns null when children is null or undefined', () => {
expect(findCitationInChildren(null)).toBeNull() expect(findCitationInChildren(null)).toBe('')
expect(findCitationInChildren(undefined)).toBeNull() expect(findCitationInChildren(undefined)).toBe('')
}) })
it('finds citation in direct child element', () => { it('finds citation in direct child element', () => {
@ -36,7 +37,7 @@ describe('markdown', () => {
it('returns null when no citation is found', () => { it('returns null when no citation is found', () => {
const children = [{ props: { foo: 'bar' } }, { props: { children: [{ props: { baz: 'qux' } }] } }] const children = [{ props: { foo: 'bar' } }, { props: { children: [{ props: { baz: 'qux' } }] } }]
expect(findCitationInChildren(children)).toBeNull() expect(findCitationInChildren(children)).toBe('')
}) })
it('handles single child object (non-array)', () => { it('handles single child object (non-array)', () => {
@ -107,6 +108,7 @@ describe('markdown', () => {
it('should return input if null or empty', () => { it('should return input if null or empty', () => {
// 验证空输入或 null 输入时返回原值 // 验证空输入或 null 输入时返回原值
expect(convertMathFormula('')).toBe('') expect(convertMathFormula('')).toBe('')
// @ts-expect-error purposely pass wrong type to test error branch
expect(convertMathFormula(null)).toBe(null) expect(convertMathFormula(null)).toBe(null)
}) })
}) })
@ -141,6 +143,41 @@ describe('markdown', () => {
}) })
}) })
describe('encodeHTML', () => {
it('should encode all special HTML characters', () => {
const input = `Tom & Jerry's "cat" <dog>`
const result = encodeHTML(input)
expect(result).toBe('Tom &amp; Jerry&apos;s &quot;cat&quot; &lt;dog&gt;')
})
it('should return the same string if no special characters', () => {
const input = 'Hello World!'
const result = encodeHTML(input)
expect(result).toBe('Hello World!')
})
it('should return empty string if input is empty', () => {
const input = ''
const result = encodeHTML(input)
expect(result).toBe('')
})
it('should encode single special character', () => {
expect(encodeHTML('&')).toBe('&amp;')
expect(encodeHTML('<')).toBe('&lt;')
expect(encodeHTML('>')).toBe('&gt;')
expect(encodeHTML('"')).toBe('&quot;')
expect(encodeHTML("'")).toBe('&apos;')
})
it('should throw if input is not a string', () => {
// @ts-expect-error purposely pass wrong type to test error branch
expect(() => encodeHTML(null)).toThrow()
// @ts-expect-error purposely pass wrong type to test error branch
expect(() => encodeHTML(undefined)).toThrow()
})
})
describe('getCodeBlockId', () => { describe('getCodeBlockId', () => {
it('should generate ID from position information', () => { it('should generate ID from position information', () => {
// 从位置信息生成ID // 从位置信息生成ID

View File

@ -1,22 +1,14 @@
import { MCPTool } from '@renderer/types' import { type MCPTool } from '@renderer/types'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { AvailableTools, buildSystemPrompt } from '../prompt' import { AvailableTools, buildSystemPrompt } from '../prompt'
describe('prompt', () => { describe('prompt', () => {
// 辅助函数:创建符合 MCPTool 类型的工具对象
const createMcpTool = (id: string, description: string, inputSchema: any): MCPTool => ({
id,
description,
inputSchema,
serverId: 'test-server-id',
serverName: 'test-server',
name: id
})
describe('AvailableTools', () => { describe('AvailableTools', () => {
it('should generate XML format for tools', () => { it('should generate XML format for tools', () => {
const tools = [createMcpTool('test-tool', 'Test tool description', { type: 'object' })] const tools = [
{ id: 'test-tool', description: 'Test tool description', inputSchema: { type: 'object' } } as MCPTool
]
const result = AvailableTools(tools) const result = AvailableTools(tools)
expect(result).toContain('<tools>') expect(result).toContain('<tools>')
@ -39,7 +31,9 @@ describe('prompt', () => {
describe('buildSystemPrompt', () => { describe('buildSystemPrompt', () => {
it('should build prompt with tools', () => { it('should build prompt with tools', () => {
const userPrompt = 'Custom user system prompt' const userPrompt = 'Custom user system prompt'
const tools = [createMcpTool('test-tool', 'Test tool description', { type: 'object' })] const tools = [
{ id: 'test-tool', description: 'Test tool description', inputSchema: { type: 'object' } } as MCPTool
]
const result = buildSystemPrompt(userPrompt, tools) const result = buildSystemPrompt(userPrompt, tools)
expect(result).toContain(userPrompt) expect(result).toContain(userPrompt)
@ -55,7 +49,9 @@ describe('prompt', () => {
}) })
it('should handle null or undefined user prompt', () => { it('should handle null or undefined user prompt', () => {
const tools = [createMcpTool('test-tool', 'Test tool description', { type: 'object' })] const tools = [
{ id: 'test-tool', description: 'Test tool description', inputSchema: { type: 'object' } } as MCPTool
]
// 测试 userPrompt 为 null 的情况 // 测试 userPrompt 为 null 的情况
const resultNull = buildSystemPrompt(null as any, tools) const resultNull = buildSystemPrompt(null as any, tools)

View File

@ -1,4 +1,14 @@
export function formatApiHost(host: string) { /**
* API
*
* host `/v1/`
* - host `/` `volces.com/api/v3`
* -
*
* @param {string} host - API
* @returns {string} API
*/
export function formatApiHost(host: string): string {
const forceUseOriginalHost = () => { const forceUseOriginalHost = () => {
if (host.endsWith('/')) { if (host.endsWith('/')) {
return true return true
@ -10,6 +20,17 @@ export function formatApiHost(host: string) {
return forceUseOriginalHost() ? host : `${host}/v1/` return forceUseOriginalHost() ? host : `${host}/v1/`
} }
/**
* API key
*
* - 24 8
* - 16 4
* - 8 2
* -
*
* @param {string} key - API
* @returns {string}
*/
export function maskApiKey(key: string): string { export function maskApiKey(key: string): string {
if (!key) return '' if (!key) return ''
@ -23,3 +44,17 @@ export function maskApiKey(key: string): string {
return key return key
} }
} }
/**
* API key key
*
* @param {string} keyStr - API key
* @returns {string[]} API key
*/
export function splitApiKeyString(keyStr: string): string[] {
return keyStr
.split(/(?<!\\),/)
.map((k) => k.trim())
.map((k) => k.replace(/\\,/g, ','))
.filter((k) => k)
}

View File

@ -15,9 +15,9 @@ import dayjs from 'dayjs'
/** /**
* *
* @param str * @param {string} str
* @param length 80 * @param {number} [length=80] 80
* @returns string * @returns {string}
*/ */
export function getTitleFromString(str: string, length: number = 80) { export function getTitleFromString(str: string, length: number = 80) {
let title = str.trimStart().split('\n')[0] let title = str.trimStart().split('\n')[0]

View File

@ -17,8 +17,8 @@ export interface KnowledgeExtractResults {
/** /**
* XML标签的文本中提取信息 * XML标签的文本中提取信息
* @public * @public
* @param text XML标签的文本 * @param {string} text XML标签的文本
* @returns * @returns {ExtractResults}
* @throws * @throws
*/ */
export const extractInfoFromXML = (text: string): ExtractResults => { export const extractInfoFromXML = (text: string): ExtractResults => {

View File

@ -2,10 +2,10 @@ import { KB, MB } from '@shared/config/constant'
/** /**
* *
* @param filePath * @param {string} filePath
* @returns string * @returns {string}
*/ */
export function getFileDirectory(filePath: string) { export function getFileDirectory(filePath: string): string {
const parts = filePath.split('/') const parts = filePath.split('/')
const directory = parts.slice(0, -1).join('/') const directory = parts.slice(0, -1).join('/')
return directory return directory
@ -13,10 +13,10 @@ export function getFileDirectory(filePath: string) {
/** /**
* *
* @param filePath * @param {string} filePath
* @returns string '.' * @returns {string} '.'
*/ */
export function getFileExtension(filePath: string) { export function getFileExtension(filePath: string): string {
const parts = filePath.split('.') const parts = filePath.split('.')
if (parts.length > 1) { if (parts.length > 1) {
const extension = parts.slice(-1)[0].toLowerCase() const extension = parts.slice(-1)[0].toLowerCase()
@ -27,10 +27,10 @@ export function getFileExtension(filePath: string) {
/** /**
* MB KB * MB KB
* @param size * @param {number} size
* @returns string * @returns {string}
*/ */
export function formatFileSize(size: number) { export function formatFileSize(size: number): string {
if (size >= MB) { if (size >= MB) {
return (size / MB).toFixed(1) + ' MB' return (size / MB).toFixed(1) + ' MB'
} }
@ -46,10 +46,10 @@ export function formatFileSize(size: number) {
* *
* - 线 * - 线
* - * -
* @param str * @param {string} str
* @returns string * @returns {string}
*/ */
export function removeSpecialCharactersForFileName(str: string) { export function removeSpecialCharactersForFileName(str: string): string {
return str return str
.replace(/[<>:"/\\|?*.]/g, '_') .replace(/[<>:"/\\|?*.]/g, '_')
.replace(/[\r\n]+/g, ' ') .replace(/[\r\n]+/g, ' ')

View File

@ -4,8 +4,8 @@ import * as htmlToImage from 'html-to-image'
/** /**
* Base64 ArrayBuffer * Base64 ArrayBuffer
* @param file * @param {File} file
* @returns Promise<string | ArrayBuffer | null> Base64 null * @returns {Promise<string | ArrayBuffer | null>} Base64 null
*/ */
export const convertToBase64 = (file: File): Promise<string | ArrayBuffer | null> => { export const convertToBase64 = (file: File): Promise<string | ArrayBuffer | null> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -18,10 +18,10 @@ export const convertToBase64 = (file: File): Promise<string | ArrayBuffer | null
/** /**
* *
* @param file * @param {File} file
* @returns Promise<File> * @returns {Promise<File>}
*/ */
export const compressImage = async (file: File) => { export const compressImage = async (file: File): Promise<File> => {
return await imageCompression(file, { return await imageCompression(file, {
maxSizeMB: 1, maxSizeMB: 1,
maxWidthOrHeight: 300, maxWidthOrHeight: 300,

View File

@ -6,19 +6,19 @@ import { v4 as uuidv4 } from 'uuid'
/** /**
* *
* @param fn * @param {() => void} fn
* @returns Promise<void> * @returns {Promise<void>}
*/ */
export const runAsyncFunction = async (fn: () => void) => { export const runAsyncFunction = async (fn: () => void): Promise<void> => {
await fn() await fn()
} }
/** /**
* Promise * Promise
* @param seconds * @param {number} seconds
* @returns Promise<any> Promise * @returns {Promise<any>} Promise
*/ */
export const delay = (seconds: number) => { export const delay = (seconds: number): Promise<any> => {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(() => { setTimeout(() => {
resolve(true) resolve(true)
@ -27,9 +27,17 @@ export const delay = (seconds: number) => {
} }
/** /**
* Waiting fn return true * true
**/ * @param {() => Promise<any>} fn
export const waitAsyncFunction = (fn: () => Promise<any>, interval = 200, stopTimeout = 60000) => { * @param {number} [interval=200]
* @param {number} [stopTimeout=60000]
* @returns {Promise<any>} true Promise
*/
export const waitAsyncFunction = (
fn: () => Promise<any>,
interval: number = 200,
stopTimeout: number = 60000
): Promise<any> => {
let timeout = false let timeout = false
const timer = setTimeout(() => (timeout = true), stopTimeout) const timer = setTimeout(() => (timeout = true), stopTimeout)
@ -63,10 +71,10 @@ export async function isDev() {
/** /**
* *
* @param error * @param {any} error
* @returns string * @returns {string}
*/ */
export function getErrorMessage(error: any) { export function getErrorMessage(error: any): string {
if (!error) { if (!error) {
return '' return ''
} }
@ -86,21 +94,31 @@ export function getErrorMessage(error: any) {
return '' return ''
} }
export function removeQuotes(str) { /**
*
* @param {string} str
* @returns {string}
*/
export function removeQuotes(str: string): string {
return str.replace(/['"]+/g, '') return str.replace(/['"]+/g, '')
} }
export function removeSpecialCharacters(str: string) { /**
*
* @param {string} str
* @returns {string}
*/
export function removeSpecialCharacters(str: string): string {
// First remove newlines and quotes, then remove other special characters // First remove newlines and quotes, then remove other special characters
return str.replace(/[\n"]/g, '').replace(/[\p{M}\p{P}]/gu, '') return str.replace(/[\n"]/g, '').replace(/[\p{M}\p{P}]/gu, '')
} }
/** /**
* is valid proxy url * URL URL
* @param url proxy url * @param {string} url URL
* @returns boolean * @returns {boolean}
*/ */
export const isValidProxyUrl = (url: string) => { export const isValidProxyUrl = (url: string): boolean => {
return url.includes('://') return url.includes('://')
} }
@ -124,8 +142,8 @@ export function loadScript(url: string) {
/** /**
* URL * URL
* @param url URL * @param {string} url URL
* @returns boolean URL true false * @returns {boolean} URL true false
*/ */
export function hasPath(url: string): boolean { export function hasPath(url: string): boolean {
try { try {
@ -139,9 +157,9 @@ export function hasPath(url: string): boolean {
/** /**
* *
* @param v1 * @param {string} v1
* @param v2 * @param {string} v2
* @returns number 1 v1 v2-1 v1 v20 * @returns {number} 1 v1 v2-1 v1 v20
*/ */
export const compareVersions = (v1: string, v2: string): number => { export const compareVersions = (v1: string, v2: string): number => {
const v1Parts = v1.split('.').map(Number) const v1Parts = v1.split('.').map(Number)
@ -158,10 +176,10 @@ export const compareVersions = (v1: string, v2: string): number => {
/** /**
* *
* @param params * @param {ModalFuncProps} params
* @returns Promise<boolean> true false * @returns {Promise<boolean>} true false
*/ */
export function modalConfirm(params: ModalFuncProps) { export function modalConfirm(params: ModalFuncProps): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
window.modal.confirm({ window.modal.confirm({
centered: true, centered: true,
@ -174,11 +192,11 @@ export function modalConfirm(params: ModalFuncProps) {
/** /**
* *
* @param obj * @param {any} obj
* @param key * @param {string} key
* @returns boolean true false * @returns {boolean} true false
*/ */
export function hasObjectKey(obj: any, key: string) { export function hasObjectKey(obj: any, key: string): boolean {
if (typeof obj !== 'object' || obj === null) { if (typeof obj !== 'object' || obj === null) {
return false return false
} }
@ -188,10 +206,10 @@ export function hasObjectKey(obj: any, key: string) {
/** /**
* npm readme中提取 npx mcp config * npm readme中提取 npx mcp config
* @param readme readme字符串 * @param {string} readme readme字符串
* @returns mcp config sample * @returns {Record<string, any> | null} mcp config sample
*/ */
export function getMcpConfigSampleFromReadme(readme: string) { export function getMcpConfigSampleFromReadme(readme: string): Record<string, any> | null {
if (readme) { if (readme) {
try { try {
const regex = /"mcpServers"\s*:\s*({(?:[^{}]*|{(?:[^{}]*|{[^{}]*})*})*})/g const regex = /"mcpServers"\s*:\s*({(?:[^{}]*|{(?:[^{}]*|{[^{}]*})*})*})/g

View File

@ -1,6 +1,7 @@
/** /**
* json * json
* @param str * @param {any} str
* @returns {boolean} json
*/ */
export function isJSON(str: any): boolean { export function isJSON(str: any): boolean {
if (typeof str !== 'string') { if (typeof str !== 'string') {
@ -16,10 +17,10 @@ export function isJSON(str: any): boolean {
/** /**
* JSON null * JSON null
* @param str * @param {string} str
* @returns null * @returns {any | null} null
*/ */
export function parseJSON(str: string) { export function parseJSON(str: string): any | null {
try { try {
return JSON.parse(str) return JSON.parse(str)
} catch (e) { } catch (e) {

View File

@ -7,8 +7,8 @@ let urlToCounterMap: Map<string, number> = new Map()
/** /**
* Determines if a string looks like a host/URL * Determines if a string looks like a host/URL
* @param text The text to check * @param {string} text The text to check
* @returns Boolean indicating if the text is likely a host * @returns {boolean} Boolean indicating if the text is likely a host
*/ */
function isHost(text: string): boolean { function isHost(text: string): boolean {
// Basic check for URL-like patterns // Basic check for URL-like patterns
@ -18,11 +18,11 @@ function isHost(text: string): boolean {
/** /**
* Converts Markdown links in the text to numbered links based on the rules:s * Converts Markdown links in the text to numbered links based on the rules:s
* [ref_N] -> [<sup>N</sup>] * [ref_N] -> [<sup>N</sup>]
* @param text The current chunk of text to process * @param {string} text The current chunk of text to process
* @param resetCounter Whether to reset the counter and buffer * @param {boolean} resetCounter Whether to reset the counter and buffer
* @returns Processed text with complete links converted * @returns {string} Processed text with complete links converted
*/ */
export function convertLinksToZhipu(text: string, resetCounter = false): string { export function convertLinksToZhipu(text: string, resetCounter: boolean = false): string {
if (resetCounter) { if (resetCounter) {
linkCounter = 1 linkCounter = 1
buffer = '' buffer = ''
@ -57,7 +57,16 @@ export function convertLinksToZhipu(text: string, resetCounter = false): string
}) })
} }
export function convertLinksToHunyuan(text: string, webSearch: any[], resetCounter = false): string { /**
* Converts Markdown links in the text to numbered links based on the rules:
* [N](@ref) -> [<sup>N</sup>]()
* [N,M,...](@ref) -> [<sup>N</sup>]() [<sup>M</sup>]() ...
* @param {string} text The current chunk of text to process
* @param {any[]} webSearch webSearch results
* @param {boolean} resetCounter Whether to reset the counter and buffer
* @returns {string} Processed text with complete links converted
*/
export function convertLinksToHunyuan(text: string, webSearch: any[], resetCounter: boolean = false): string {
if (resetCounter) { if (resetCounter) {
linkCounter = 1 linkCounter = 1
buffer = '' buffer = ''
@ -115,11 +124,11 @@ export function convertLinksToHunyuan(text: string, webSearch: any[], resetCount
* 2. [host](url) -> [cnt](url) * 2. [host](url) -> [cnt](url)
* 3. [any text except url](url)-> any text [cnt](url) * 3. [any text except url](url)-> any text [cnt](url)
* *
* @param text The current chunk of text to process * @param {string} text The current chunk of text to process
* @param resetCounter Whether to reset the counter and buffer * @param {boolean} resetCounter Whether to reset the counter and buffer
* @returns Processed text with complete links converted * @returns {string} Processed text with complete links converted
*/ */
export function convertLinks(text: string, resetCounter = false): string { export function convertLinks(text: string, resetCounter: boolean = false): string {
if (resetCounter) { if (resetCounter) {
linkCounter = 1 linkCounter = 1
buffer = '' buffer = ''
@ -235,9 +244,9 @@ export function convertLinks(text: string, resetCounter = false): string {
* Converts Markdown links in the text to numbered links based on the rules: * Converts Markdown links in the text to numbered links based on the rules:
* 1. [host](url) -> [cnt](url) * 1. [host](url) -> [cnt](url)
* *
* @param text The current chunk of text to process * @param {string} text The current chunk of text to process
* @param resetCounter Whether to reset the counter and buffer * @param {boolean} resetCounter Whether to reset the counter and buffer
* @returns Processed text with complete links converted * @returns {string} Processed text with complete links converted
*/ */
export function convertLinksToOpenRouter(text: string, resetCounter = false): string { export function convertLinksToOpenRouter(text: string, resetCounter = false): string {
if (resetCounter) { if (resetCounter) {
@ -292,9 +301,9 @@ export function convertLinksToOpenRouter(text: string, resetCounter = false): st
/** /**
* webSearch结果补全链接[<sup>num</sup>]()[<sup>num</sup>](webSearch[num-1].url) * webSearch结果补全链接[<sup>num</sup>]()[<sup>num</sup>](webSearch[num-1].url)
* @param text * @param {string} text
* @param webSearch webSearch结果 * @param {any[]} webSearch webSearch结果
* @returns * @returns {string}
*/ */
export function completeLinks(text: string, webSearch: any[]): string { export function completeLinks(text: string, webSearch: any[]): string {
// 使用正则表达式匹配形如 [<sup>num</sup>]() 的链接 // 使用正则表达式匹配形如 [<sup>num</sup>]() 的链接
@ -316,8 +325,8 @@ export function completeLinks(text: string, webSearch: any[]): string {
* 2. [<sup>num</sup>](url) * 2. [<sup>num</sup>](url)
* 3. ([text](url)) * 3. ([text](url))
* *
* @param text Markdown格式的文本 * @param {string} text Markdown格式的文本
* @returns URL数组 * @returns {string[]} URL数组
*/ */
export function extractUrlsFromMarkdown(text: string): string[] { export function extractUrlsFromMarkdown(text: string): string[] {
const urlSet = new Set<string>() const urlSet = new Set<string>()
@ -338,8 +347,8 @@ export function extractUrlsFromMarkdown(text: string): string[] {
/** /**
* URL * URL
* @param url URL字符串 * @param {string} url URL字符串
* @returns URL * @returns {boolean} URL
*/ */
function isValidUrl(url: string): boolean { function isValidUrl(url: string): boolean {
try { try {
@ -353,8 +362,8 @@ function isValidUrl(url: string): boolean {
/** /**
* Markdown * Markdown
* : [text](url),[text](url) -> [text](url) [text](url) * : [text](url),[text](url) -> [text](url) [text](url)
* @param text Markdown * @param {string} text Markdown
* @returns * @returns {string}
*/ */
export function cleanLinkCommas(text: string): string { export function cleanLinkCommas(text: string): string {
// 匹配两个 Markdown 链接之间的英文逗号(可能包含空格) // 匹配两个 Markdown 链接之间的英文逗号(可能包含空格)

View File

@ -3,9 +3,13 @@ import remarkStringify from 'remark-stringify'
import { unified } from 'unified' import { unified } from 'unified'
import { visit } from 'unist-util-visit' import { visit } from 'unist-util-visit'
// 更彻底的查找方法,递归搜索所有子元素 /**
export const findCitationInChildren = (children) => { *
if (!children) return null * @param {any} children
* @returns {string} citation ''
*/
export const findCitationInChildren = (children: any): string => {
if (!children) return ''
// 直接搜索子元素 // 直接搜索子元素
for (const child of Array.isArray(children) ? children : [children]) { for (const child of Array.isArray(children) ? children : [children]) {
@ -20,17 +24,17 @@ export const findCitationInChildren = (children) => {
} }
} }
return null return ''
} }
/** /**
* *
* - LaTeX '\\[' '\\]' '$$$$' * - LaTeX '\\[' '\\]' '$$$$'
* - LaTeX '\\(' '\\)' '$$' * - LaTeX '\\(' '\\)' '$$'
* @param input * @param {string} input
* @returns string * @returns {string}
*/ */
export function convertMathFormula(input) { export function convertMathFormula(input: string): string {
if (!input) return input if (!input) return input
let result = input let result = input
@ -41,8 +45,8 @@ export function convertMathFormula(input) {
/** /**
* Markdown * Markdown
* @param markdown Markdown * @param {string} markdown Markdown
* @returns string * @returns {string}
*/ */
export function removeTrailingDoubleSpaces(markdown: string): string { export function removeTrailingDoubleSpaces(markdown: string): string {
// 使用正则表达式匹配末尾的两个空格,并替换为空字符串 // 使用正则表达式匹配末尾的两个空格,并替换为空字符串

View File

@ -12,11 +12,11 @@
* - 'deepseek-r1' => 'deepseek-r1' * - 'deepseek-r1' => 'deepseek-r1'
* - 'o3' => 'o3' * - 'o3' => 'o3'
* *
* @param id ID * @param {string} id ID
* @param provider ID * @param {string} [provider] ID
* @returns string * @returns {string}
*/ */
export const getDefaultGroupName = (id: string, provider?: string) => { export const getDefaultGroupName = (id: string, provider?: string): string => {
const str = id.toLowerCase() const str = id.toLowerCase()
// 定义分隔符 // 定义分隔符
@ -48,8 +48,8 @@ export const getDefaultGroupName = (id: string, provider?: string) => {
/** /**
* avatar * avatar
* @param str * @param {string} str
* @returns string * @returns {string}
*/ */
export function firstLetter(str: string): string { export function firstLetter(str: string): string {
const match = str?.match(/\p{L}\p{M}*|\p{Emoji_Presentation}|\p{Emoji}\uFE0F/u) const match = str?.match(/\p{L}\p{M}*|\p{Emoji_Presentation}|\p{Emoji}\uFE0F/u)
@ -58,8 +58,8 @@ export function firstLetter(str: string): string {
/** /**
* *
* @param str * @param {string} str
* @returns string * @returns {string}
*/ */
export function removeLeadingEmoji(str: string): string { export function removeLeadingEmoji(str: string): string {
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+/u const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+/u
@ -68,8 +68,8 @@ export function removeLeadingEmoji(str: string): string {
/** /**
* *
* @param str * @param {string} str
* @returns string * @returns {string}
*/ */
export function getLeadingEmoji(str: string): string { export function getLeadingEmoji(str: string): string {
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+/u const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+/u
@ -79,8 +79,8 @@ export function getLeadingEmoji(str: string): string {
/** /**
* *
* @param str * @param {string} str
* @returns boolean true false * @returns {boolean} true false
*/ */
export function isEmoji(str: string): boolean { export function isEmoji(str: string): boolean {
if (str.startsWith('data:')) { if (str.startsWith('data:')) {
@ -97,19 +97,19 @@ export function isEmoji(str: string): boolean {
/** /**
* *
* - * -
* @param str * @param {string} str
* @returns string * @returns {string}
*/ */
export function removeSpecialCharactersForTopicName(str: string) { export function removeSpecialCharactersForTopicName(str: string): string {
return str.replace(/[\r\n]+/g, ' ').trim() return str.replace(/[\r\n]+/g, ' ').trim()
} }
/** /**
* avatar * avatar
* @param char * @param {string} char
* @returns string * @returns {string}
*/ */
export function generateColorFromChar(char: string) { export function generateColorFromChar(char: string): string {
// 使用字符的Unicode值作为随机种子 // 使用字符的Unicode值作为随机种子
const seed = char.charCodeAt(0) const seed = char.charCodeAt(0)
@ -134,23 +134,23 @@ export function generateColorFromChar(char: string) {
/** /**
* *
* @param str * @param {string} str
* @returns string * @returns {string}
*/ */
export function getFirstCharacter(str) { export function getFirstCharacter(str: string): string {
if (str.length === 0) return ''
// 使用 for...of 循环来获取第一个字符 // 使用 for...of 循环来获取第一个字符
for (const char of str) { for (const char of str) {
return char return char
} }
return ''
} }
/** /**
* *
* @param text * @param {string} text
* @param maxLength 50 * @param {number} [maxLength=50] 50
* @returns string * @returns {string}
*/ */
export function getBriefInfo(text: string, maxLength: number = 50): string { export function getBriefInfo(text: string, maxLength: number = 50): string {
// 去除空行 // 去除空行

View File

@ -1,13 +1,13 @@
/** /**
* dnd "拖动" * dnd "拖动"
* @template T * @template {T}
* @param list * @param {T[]} list
* @param sourceIndex * @param {number} sourceIndex
* @param destIndex * @param {number} destIndex
* @param len 1 * @param {number} [len=1] 1
* @returns T[] * @returns {T[]}
*/ */
export function droppableReorder<T>(list: T[], sourceIndex: number, destIndex: number, len = 1) { export function droppableReorder<T>(list: T[], sourceIndex: number, destIndex: number, len: number = 1): T[] {
const result = Array.from(list) const result = Array.from(list)
const removed = result.splice(sourceIndex, len) const removed = result.splice(sourceIndex, len)
@ -21,11 +21,11 @@ export function droppableReorder<T>(list: T[], sourceIndex: number, destIndex: n
/** /**
* *
* @param a * @param {string} a
* @param b * @param {string} b
* @returns * @returns {number}
*/ */
export function sortByEnglishFirst(a: string, b: string) { export function sortByEnglishFirst(a: string, b: string): number {
const isAEnglish = /^[a-zA-Z]/.test(a) const isAEnglish = /^[a-zA-Z]/.test(a)
const isBEnglish = /^[a-zA-Z]/.test(b) const isBEnglish = /^[a-zA-Z]/.test(b)
if (isAEnglish && !isBEnglish) return -1 if (isAEnglish && !isBEnglish) return -1

View File

@ -6,18 +6,18 @@ interface ClassDictionary {
interface ClassArray extends Array<ClassValue> {} interface ClassArray extends Array<ClassValue> {}
// Example:
// classNames('foo', 'bar'); // => 'foo bar'
// classNames('foo', { bar: true }); // => 'foo bar'
// classNames({ foo: true, bar: false }); // => 'foo'
// classNames(['foo', 'bar']); // => 'foo bar'
// classNames('foo', null, 'bar'); // => 'foo bar'
// classNames({ message: true, 'message-assistant': true }); // => 'message message-assistant'
/** /**
* class * class
* @param args *
* @returns * Examples:
* classNames('foo', 'bar'); // => 'foo bar'
* classNames('foo', { bar: true }); // => 'foo bar'
* classNames({ foo: true, bar: false }); // => 'foo'
* classNames(['foo', 'bar']); // => 'foo bar'
* classNames('foo', null, 'bar'); // => 'foo bar'
* classNames({ message: true, 'message-assistant': true }); // => 'message message-assistant'
* @param {ClassValue[]} args
* @returns {string}
*/ */
export function classNames(...args: ClassValue[]): string { export function classNames(...args: ClassValue[]): string {
const classes: string[] = [] const classes: string[] = []

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

13
tests/e2e/launch.test.tsx Normal file
View File

@ -0,0 +1,13 @@
import { _electron as electron, expect, test } from '@playwright/test'
let electronApp: any
let window: any
test.describe('App Launch', () => {
test('should launch and close the main application', async () => {
electronApp = await electron.launch({ args: ['.'] })
window = await electronApp.firstWindow()
expect(window).toBeDefined()
await electronApp.close()
})
})

46
tests/renderer.setup.ts Normal file
View File

@ -0,0 +1,46 @@
import '@testing-library/jest-dom/vitest'
import { styleSheetSerializer } from 'jest-styled-components/serializer'
import { expect, vi } from 'vitest'
expect.addSnapshotSerializer(styleSheetSerializer)
vi.mock('electron-log/renderer', () => {
return {
default: {
info: console.log,
error: console.error,
warn: console.warn,
debug: console.debug,
verbose: console.log,
silly: console.log,
log: console.log,
transports: {
console: {
level: 'info'
}
}
}
}
})
vi.mock('axios', () => ({
default: {
get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request
post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request
// You can add other axios methods like put, delete etc. as needed
}
}))
vi.stubGlobal('electron', {
ipcRenderer: {
on: vi.fn(),
send: vi.fn()
}
})
vi.stubGlobal('api', {
file: {
read: vi.fn().mockResolvedValue('[]'),
writeWithId: vi.fn().mockResolvedValue(undefined)
}
})

View File

@ -2,35 +2,54 @@ import { defineConfig } from 'vitest/config'
import electronViteConfig from './electron.vite.config' import electronViteConfig from './electron.vite.config'
const rendererConfig = electronViteConfig.renderer const mainConfig = (electronViteConfig as any).main
const rendererConfig = (electronViteConfig as any).renderer
export default defineConfig({ export default defineConfig({
// 复用 renderer 插件和路径别名
// @ts-ignore plugins 类型
plugins: rendererConfig?.plugins,
resolve: {
// @ts-ignore alias 类型
alias: rendererConfig?.resolve.alias
},
test: { test: {
environment: 'jsdom', workspace: [
globals: true, // 主进程单元测试配置
setupFiles: ['@vitest/web-worker', './src/renderer/__tests__/setup.ts'], {
include: [ extends: true,
// 只测试渲染进程 plugins: mainConfig.plugins,
'src/renderer/**/*.{test,spec}.{ts,tsx}', resolve: {
'src/renderer/**/__tests__/**/*.{test,spec}.{ts,tsx}' alias: mainConfig.resolve.alias
},
test: {
name: 'main',
environment: 'node',
include: ['src/main/**/*.{test,spec}.{ts,tsx}', 'src/main/**/__tests__/**/*.{test,spec}.{ts,tsx}']
}
},
// 渲染进程单元测试配置
{
extends: true,
plugins: rendererConfig.plugins,
resolve: {
alias: rendererConfig.resolve.alias
},
test: {
name: 'renderer',
environment: 'jsdom',
setupFiles: ['@vitest/web-worker', 'tests/renderer.setup.ts'],
include: ['src/renderer/**/*.{test,spec}.{ts,tsx}', 'src/renderer/**/__tests__/**/*.{test,spec}.{ts,tsx}']
}
}
], ],
exclude: ['**/node_modules/**', '**/dist/**', '**/out/**', '**/build/**', '**/src/renderer/__tests__/setup.ts'], // 全局共享配置
globals: true,
setupFiles: [],
exclude: ['**/node_modules/**', '**/dist/**', '**/out/**', '**/build/**'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'], reporter: ['text', 'json', 'html', 'lcov', 'text-summary'],
exclude: [ exclude: [
'**/node_modules/**', '**/node_modules/**',
'**/dist/**', '**/dist/**',
'**/out/**', '**/out/**',
'**/build/**', '**/build/**',
'**/coverage/**', '**/coverage/**',
'**/tests/**',
'**/.yarn/**', '**/.yarn/**',
'**/.cursor/**', '**/.cursor/**',
'**/.vscode/**', '**/.vscode/**',
@ -40,9 +59,7 @@ export default defineConfig({
'**/types/**', '**/types/**',
'**/__tests__/**', '**/__tests__/**',
'**/*.{test,spec}.{ts,tsx}', '**/*.{test,spec}.{ts,tsx}',
'**/*.config.{js,ts}', '**/*.config.{js,ts}'
'**/electron.vite.config.ts',
'**/vitest.config.ts'
] ]
}, },
testTimeout: 20000, testTimeout: 20000,

900
yarn.lock

File diff suppressed because it is too large Load Diff