mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-09 14:59:27 +08:00
Merge branch 'main' into translate-auto-detect
This commit is contained in:
commit
2a2e77632f
7
.gitignore
vendored
7
.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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}'
|
||||||
|
|||||||
@ -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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
package.json
38
package.json
@ -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",
|
||||||
|
|||||||
@ -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
42
playwright.config.ts
Normal 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,
|
||||||
|
// },
|
||||||
|
})
|
||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
1022
src/main/services/SelectionService.ts
Normal file
1022
src/main/services/SelectionService.ts
Normal file
File diff suppressed because it is too large
Load Diff
71
src/main/utils/__tests__/aes.test.ts
Normal file
71
src/main/utils/__tests__/aes.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
243
src/main/utils/__tests__/file.test.ts
Normal file
243
src/main/utils/__tests__/file.test.ts
Normal 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/')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
61
src/main/utils/__tests__/zip.test.ts
Normal file
61
src/main/utils/__tests__/zip.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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')
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
})
|
|
||||||
41
src/renderer/selectionAction.html
Normal file
41
src/renderer/selectionAction.html
Normal 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>
|
||||||
43
src/renderer/selectionToolbar.html
Normal file
43
src/renderer/selectionToolbar.html
Normal 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>
|
||||||
26
src/renderer/src/assets/styles/selection-toolbar.scss
Normal file
26
src/renderer/src/assets/styles/selection-toolbar.scss
Normal 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);
|
||||||
|
}
|
||||||
83
src/renderer/src/components/CopyButton.tsx
Normal file
83
src/renderer/src/components/CopyButton.tsx
Normal 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
|
||||||
@ -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={() =>
|
||||||
|
|||||||
@ -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'};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|||||||
44
src/renderer/src/components/__tests__/CustomTag.test.tsx
Normal file
44
src/renderer/src/components/__tests__/CustomTag.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
282
src/renderer/src/components/__tests__/DragableList.test.tsx
Normal file
282
src/renderer/src/components/__tests__/DragableList.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
188
src/renderer/src/components/__tests__/QuickPanelView.test.tsx
Normal file
188
src/renderer/src/components/__tests__/QuickPanelView.test.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
191
src/renderer/src/components/__tests__/Scrollbar.test.tsx
Normal file
191
src/renderer/src/components/__tests__/Scrollbar.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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>
|
||||||
|
`;
|
||||||
@ -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>
|
||||||
|
`;
|
||||||
48
src/renderer/src/hooks/useSelectionAssistant.ts
Normal file
48
src/renderer/src/hooks/useSelectionAssistant.ts
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": "テスト"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": "Тест"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": "测试"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": "測試"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 />} />
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
73
src/renderer/src/store/selectionStore.ts
Normal file
73
src/renderer/src/store/selectionStore.ts
Normal 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
|
||||||
24
src/renderer/src/types/selectionTypes.d.ts
vendored
Normal file
24
src/renderer/src/types/selectionTypes.d.ts
vendored
Normal 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[]
|
||||||
|
}
|
||||||
@ -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",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 & Jerry's "cat" <dog>')
|
||||||
|
})
|
||||||
|
|
||||||
|
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('&')
|
||||||
|
expect(encodeHTML('<')).toBe('<')
|
||||||
|
expect(encodeHTML('>')).toBe('>')
|
||||||
|
expect(encodeHTML('"')).toBe('"')
|
||||||
|
expect(encodeHTML("'")).toBe(''')
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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 => {
|
||||||
|
|||||||
@ -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, ' ')
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 小于 v2,0 表示相等
|
* @returns {number} 比较结果,1 表示 v1 大于 v2,-1 表示 v1 小于 v2,0 表示相等
|
||||||
*/
|
*/
|
||||||
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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 链接之间的英文逗号(可能包含空格)
|
||||||
|
|||||||
@ -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 {
|
||||||
// 使用正则表达式匹配末尾的两个空格,并替换为空字符串
|
// 使用正则表达式匹配末尾的两个空格,并替换为空字符串
|
||||||
|
|||||||
@ -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 {
|
||||||
// 去除空行
|
// 去除空行
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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[] = []
|
||||||
|
|||||||
383
src/renderer/src/windows/selection/action/SelectionActionApp.tsx
Normal file
383
src/renderer/src/windows/selection/action/SelectionActionApp.tsx
Normal 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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
54
src/renderer/src/windows/selection/action/entryPoint.tsx
Normal file
54
src/renderer/src/windows/selection/action/entryPoint.tsx
Normal 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 />)
|
||||||
405
src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx
Normal file
405
src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx
Normal 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
|
||||||
29
src/renderer/src/windows/selection/toolbar/entryPoint.tsx
Normal file
29
src/renderer/src/windows/selection/toolbar/entryPoint.tsx
Normal 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
13
tests/e2e/launch.test.tsx
Normal 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
46
tests/renderer.setup.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user