Merge remote-tracking branch 'origin/main' into feat/agents-new

This commit is contained in:
Vaayne 2025-09-16 18:08:14 +08:00
commit c37af25525
138 changed files with 1933 additions and 3771 deletions

4
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1,4 @@
/src/renderer/src/store/ @0xfullex
/src/main/services/ConfigManager.ts @0xfullex
/packages/shared/IpcChannel.ts @0xfullex
/src/main/ipc.ts @0xfullex

View File

@ -35,7 +35,7 @@ jobs:
# 在临时目录安装依赖
mkdir -p /tmp/translation-deps
cd /tmp/translation-deps
echo '{"dependencies": {"openai": "^5.12.2", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "prettier": "^3.5.3", "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-tailwindcss": "^0.6.14"}}' > package.json
echo '{"dependencies": {"openai": "^5.12.2", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "@biomejs/biome": "2.2.4"}}' > package.json
npm install --no-package-lock
# 设置 NODE_PATH 让项目能找到这些依赖
@ -45,7 +45,7 @@ jobs:
run: npx tsx scripts/auto-translate-i18n.ts
- name: 🔍 Format
run: cd /tmp/translation-deps && npx prettier --write --config /home/runner/work/cherry-studio/cherry-studio/.prettierrc /home/runner/work/cherry-studio/cherry-studio/src/renderer/src/i18n/
run: cd /tmp/translation-deps && npx biome format --config-path /home/runner/work/cherry-studio/cherry-studio/biome.jsonc --write /home/runner/work/cherry-studio/cherry-studio/src/renderer/src/i18n/
- name: 🔄 Commit changes
run: |

View File

@ -9,6 +9,7 @@ on:
branches:
- main
- develop
- v2
jobs:
build:
@ -45,12 +46,12 @@ jobs:
- name: Install Dependencies
run: yarn install
- name: Format Check
run: yarn format:check
- name: Lint Check
run: yarn test:lint
- name: Format Check
run: yarn format:check
- name: Type Check
run: yarn typecheck

1
.gitignore vendored
View File

@ -37,6 +37,7 @@ dist
out
mcp_server
stats.html
.eslintcache
# ENV
.env

215
.oxlintrc.json Normal file
View File

@ -0,0 +1,215 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {},
"env": {
"es2022": true
},
"globals": {},
"ignorePatterns": [
"node_modules/**",
"build/**",
"dist/**",
"out/**",
"local/**",
".yarn/**",
".gitignore",
"scripts/cloudflare-worker.js",
"src/main/integration/nutstore/sso/lib/**",
"src/main/integration/cherryin/index.js",
"src/main/integration/nutstore/sso/lib/**",
"src/renderer/src/ui/**",
"packages/**/dist",
"eslint.config.mjs"
],
"overrides": [
// set different env
{
"env": {
"node": true
},
"files": ["src/main/**", "resources/scripts/**", "scripts/**", "playwright.config.ts", "electron.vite.config.ts"]
},
{
"env": {
"browser": true
},
"files": [
"src/renderer/**/*.{ts,tsx}",
"packages/aiCore/**",
"packages/extension-table-plus/**",
"resources/js/**"
]
},
{
"env": {
"node": true,
"vitest": true
},
"files": ["**/__tests__/*.test.{ts,tsx}", "tests/**"]
},
{
"env": {
"browser": true,
"node": true
},
"files": ["src/preload/**"]
}
],
// We don't use the React plugin here because its behavior differs slightly from that of ESLint's React plugin.
"plugins": ["unicorn", "typescript", "oxc", "import"],
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-array-constructor": "off",
// "import/no-cycle": "error", // tons of error, bro
"no-async-promise-executor": "error",
"no-caller": "warn",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-args": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-eval": "warn",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "warn",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-octal": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unassigned-vars": "warn",
"no-undef": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-expressions": "off", // this rule disallow us to use expression to call function, like `condition && fn()`
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": ["error", { "caughtErrors": "none" }],
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-useless-rename": "warn",
"no-with": "error",
"oxc/bad-array-method-on-arguments": "warn",
"oxc/bad-char-at-comparison": "warn",
"oxc/bad-comparison-sequence": "warn",
"oxc/bad-min-max-func": "warn",
"oxc/bad-object-literal-comparison": "warn",
"oxc/bad-replace-all-arg": "warn",
"oxc/const-comparisons": "warn",
"oxc/double-comparisons": "warn",
"oxc/erasing-op": "warn",
"oxc/missing-throw": "warn",
"oxc/number-arg-out-of-range": "warn",
"oxc/only-used-in-recursion": "off", // manually off bacause of existing warning. may turn it on in the future
"oxc/uninvoked-array-callback": "warn",
"require-yield": "error",
"typescript/await-thenable": "warn",
// "typescript/ban-ts-comment": "error",
"typescript/no-array-constructor": "error",
// "typescript/consistent-type-imports": "error",
"typescript/no-array-delete": "warn",
"typescript/no-base-to-string": "warn",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-duplicate-type-constituents": "warn",
"typescript/no-empty-object-type": "off",
"typescript/no-explicit-any": "off", // not safe but too many errors
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-floating-promises": "warn",
"typescript/no-for-in-array": "warn",
"typescript/no-implied-eval": "warn",
"typescript/no-meaningless-void-operator": "warn",
"typescript/no-misused-new": "error",
"typescript/no-misused-spread": "warn",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "off", // it's off now. but may turn it on.
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-require-imports": "off",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-parameter-property-assignment": "warn",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-unsafe-unary-minus": "warn",
"typescript/no-useless-empty-export": "warn",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/require-array-sort-compare": "warn",
"typescript/restrict-template-expressions": "warn",
"typescript/triple-slash-reference": "error",
"typescript/unbound-method": "warn",
"unicorn/no-await-in-promise-methods": "warn",
"unicorn/no-empty-file": "off", // manually off bacause of existing warning. may turn it on in the future
"unicorn/no-invalid-fetch-options": "warn",
"unicorn/no-invalid-remove-event-listener": "warn",
"unicorn/no-new-array": "off", // manually off bacause of existing warning. may turn it on in the future
"unicorn/no-single-promise-in-promise-methods": "warn",
"unicorn/no-thenable": "off", // manually off bacause of existing warning. may turn it on in the future
"unicorn/no-unnecessary-await": "warn",
"unicorn/no-useless-fallback-in-spread": "warn",
"unicorn/no-useless-length-check": "warn",
"unicorn/no-useless-spread": "off", // manually off bacause of existing warning. may turn it on in the future
"unicorn/prefer-set-size": "warn",
"unicorn/prefer-string-starts-ends-with": "warn",
"use-isnan": "error",
"valid-typeof": "error"
},
"settings": {
"jsdoc": {
"augmentsExtendsReplacesDocs": false,
"exemptDestructuredRootsFromChecks": false,
"ignoreInternal": false,
"ignorePrivate": false,
"ignoreReplacesDocs": true,
"implementsReplacesDocs": false,
"overrideReplacesDocs": true,
"tagNamePreference": {}
},
"jsx-a11y": {
"attributes": {},
"components": {},
"polymorphicPropName": null
},
"next": {
"rootDir": []
},
"react": {
"formComponents": [],
"linkComponents": []
}
}
}

View File

@ -1,12 +0,0 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
CHANGELOG*.md
agents.json
src/renderer/src/integration/nutstore/sso/lib
AGENT.md
src/main/integration/
.yarn/releases/

View File

@ -1,13 +0,0 @@
{
"bracketSameLine": true,
"endOfLine": "lf",
"jsonRecursiveSort": true,
"jsonSortOrder": "{\"*\": \"lexical\"}",
"plugins": ["prettier-plugin-sort-json", "prettier-plugin-tailwindcss"],
"printWidth": 120,
"semi": false,
"singleQuote": true,
"tailwindFunctions": ["clsx"],
"tailwindStylesheet": "./src/renderer/src/assets/styles/tailwind.css",
"trailingComma": "none"
}

View File

@ -1,8 +1,12 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"editorconfig.editorconfig",
"lokalise.i18n-ally"
"lokalise.i18n-ally",
"bradlc.vscode-tailwindcss",
"vitest.explorer",
"oxc.oxc-vscode",
"biomejs.biome",
"typescriptteam.native-preview"
]
}

19
.vscode/settings.json vendored
View File

@ -1,30 +1,32 @@
{
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
},
"[markdown]": {
"files.trimTrailingWhitespace": false
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.fixAll.eslint": "explicit",
"source.fixAll.oxc": "explicit",
"source.organizeImports": "never"
},
"editor.formatOnSave": true,
@ -45,5 +47,6 @@
"search.exclude": {
"**/dist/**": true,
".yarn/releases/**": true
}
},
"typescript.experimental.useTsgo": true
}

View File

@ -5,3 +5,5 @@ httpTimeout: 300000
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.1.cjs
npmRegistryServer: https://registry.npmjs.org
npmPublishRegistry: https://registry.npmjs.org

97
biome.jsonc Normal file
View File

@ -0,0 +1,97 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"assist": {
// to sort json
"actions": {
"source": {
"organizeImports": "on",
"useSortedKeys": {
"level": "on",
"options": {
"sortOrder": "lexicographic"
}
}
}
},
"enabled": true,
"includes": ["**/*.json", "!*.json", "!**/package.json"]
},
"css": {
"formatter": {
"quoteStyle": "single"
}
},
"files": { "ignoreUnknown": false },
"formatter": {
"attributePosition": "auto",
"bracketSameLine": false,
"bracketSpacing": true,
"enabled": true,
"expand": "auto",
"formatWithErrors": true,
"includes": [
"**",
"!out/**",
"!**/dist/**",
"!build/**",
"!.yarn/**",
"!.github/**",
"!.husky/**",
"!.vscode/**",
"!*.yaml",
"!*.yml",
"!*.mjs",
"!*.cjs",
"!*.md",
"!*.json",
"!src/main/integration/**",
"!**/tailwind.css",
"!**/package.json"
],
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 120,
"useEditorconfig": true
},
"html": { "formatter": { "selfCloseVoidElements": "always" } },
"javascript": {
"formatter": {
"arrowParentheses": "always",
"attributePosition": "auto",
// To minimize changes in this PR as much as possible, it's set to true. However, setting it to false would make it more convenient to add attributes at the end.
"bracketSameLine": true,
"bracketSpacing": true,
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"quoteStyle": "single",
"semicolons": "asNeeded",
"trailingCommas": "none"
}
},
"json": {
"parser": {
"allowComments": true
}
},
"linter": {
"enabled": true,
"includes": ["!**/tailwind.css", "src/renderer/**/*.{tsx,ts}"],
// only enable sorted tailwind css rule. used as formatter instead of linter
"rules": {
"nursery": {
// to sort tailwind css classes
"useSortedClasses": {
"fix": "safe",
"level": "warn",
"options": {
"functions": ["cn"]
}
}
},
"recommended": false,
"suspicious": "off"
}
},
"vcs": { "clientKind": "git", "enabled": false, "useIgnoreFile": false }
}

View File

@ -2,7 +2,9 @@
## IDE Setup
[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
- Editor: [Cursor](https://www.cursor.com/), etc. Any VS Code compatible editor.
- Linter: [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
- Formatter: [Biome](https://marketplace.visualstudio.com/items?itemName=biomejs.biome)
## Project Setup

View File

@ -17,52 +17,52 @@ protocols:
schemes:
- cherrystudio
files:
- '**/*'
- '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}'
- '!electron.vite.config.{js,ts,mjs,cjs}}'
- '!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}'
- '!**/{.editorconfig,.jekyll-metadata}'
- '!src'
- '!scripts'
- '!local'
- '!docs'
- '!packages'
- '!.swc'
- '!.bin'
- '!._*'
- '!*.log'
- '!stats.html'
- '!*.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}'
- '!**/{test,tests,__tests__,powered-test,coverage}/**'
- '!**/{example,examples}/**'
- '!**/*.{spec,test}.{js,jsx,ts,tsx}'
- '!**/*.min.*.map'
- '!**/*.d.ts'
- '!**/dist/es6/**'
- '!**/dist/demo/**'
- '!**/amd/**'
- '!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}'
- '!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}'
- '!node_modules/rollup-plugin-visualizer'
- '!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/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
- '!node_modules/selection-hook/node_modules' # we don't need what in the node_modules dir
- '!node_modules/selection-hook/src' # we don't need source files
- '!node_modules/tesseract.js-core/{tesseract-core.js,tesseract-core.wasm,tesseract-core.wasm.js}' # we don't need source files
- '!node_modules/tesseract.js-core/{tesseract-core-lstm.js,tesseract-core-lstm.wasm,tesseract-core-lstm.wasm.js}' # we don't need source files
- '!node_modules/tesseract.js-core/{tesseract-core-simd-lstm.js,tesseract-core-simd-lstm.wasm,tesseract-core-simd-lstm.wasm.js}' # we don't need source files
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}' # filter .node build files
- "**/*"
- "!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}"
- "!electron.vite.config.{js,ts,mjs,cjs}}"
- "!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md,biome.jsonc}"
- "!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}"
- "!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}"
- "!**/{.editorconfig,.jekyll-metadata}"
- "!src"
- "!scripts"
- "!local"
- "!docs"
- "!packages"
- "!.swc"
- "!.bin"
- "!._*"
- "!*.log"
- "!stats.html"
- "!*.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}"
- "!**/{test,tests,__tests__,powered-test,coverage}/**"
- "!**/{example,examples}/**"
- "!**/*.{spec,test}.{js,jsx,ts,tsx}"
- "!**/*.min.*.map"
- "!**/*.d.ts"
- "!**/dist/es6/**"
- "!**/dist/demo/**"
- "!**/amd/**"
- "!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}"
- "!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}"
- "!node_modules/rollup-plugin-visualizer"
- "!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/mammoth/{mammoth.browser.js,mammoth.browser.min.js}"
- "!node_modules/selection-hook/prebuilds/**/*" # we rebuild .node, don't use prebuilds
- "!node_modules/selection-hook/node_modules" # we don't need what in the node_modules dir
- "!node_modules/selection-hook/src" # we don't need source files
- "!node_modules/tesseract.js-core/{tesseract-core.js,tesseract-core.wasm,tesseract-core.wasm.js}" # we don't need source files
- "!node_modules/tesseract.js-core/{tesseract-core-lstm.js,tesseract-core-lstm.wasm,tesseract-core-lstm.wasm.js}" # we don't need source files
- "!node_modules/tesseract.js-core/{tesseract-core-simd-lstm.js,tesseract-core-simd-lstm.wasm,tesseract-core-simd-lstm.wasm.js}" # we don't need source files
- "!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}" # filter .node build files
asarUnpack:
- resources/**
- '**/*.{metal,exp,lib}'
- 'node_modules/@img/sharp-libvips-*/**'
- "**/*.{metal,exp,lib}"
- "node_modules/@img/sharp-libvips-*/**"
win:
executableName: Cherry Studio
artifactName: ${productName}-${version}-${arch}-setup.${ext}
@ -88,7 +88,7 @@ mac:
entitlementsInherit: build/entitlements.mac.plist
notarize: false
artifactName: ${productName}-${version}-${arch}.${ext}
minimumSystemVersion: '20.1.0' # 最低支持 macOS 11.0
minimumSystemVersion: "20.1.0" # 最低支持 macOS 11.0
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
@ -113,7 +113,7 @@ linux:
rpm:
# Workaround for electron build issue on rpm package:
# https://github.com/electron/forge/issues/3594
fpm: ['--rpm-rpmbuild-define=_build_id_links none']
fpm: ["--rpm-rpmbuild-define=_build_id_links none"]
publish:
provider: generic
url: https://releases.cherry-ai.com

View File

@ -4,7 +4,9 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
import pkg from './package.json' assert { type: 'json' }
// assert not supported by biome
// import pkg from './package.json' assert { type: 'json' }
import pkg from './package.json'
const visualizerPlugin = (type: 'renderer' | 'main') => {
return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : []

View File

@ -1,8 +1,8 @@
import electronConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslint from '@eslint/js'
import eslintReact from '@eslint-react/eslint-plugin'
import { defineConfig } from 'eslint/config'
import oxlint from 'eslint-plugin-oxlint'
import reactHooks from 'eslint-plugin-react-hooks'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import unusedImports from 'eslint-plugin-unused-imports'
@ -10,7 +10,6 @@ import unusedImports from 'eslint-plugin-unused-imports'
export default defineConfig([
eslint.configs.recommended,
tseslint.configs.recommended,
electronConfigPrettier,
eslintReact.configs['recommended-typescript'],
reactHooks.configs['recommended-latest'],
{
@ -26,7 +25,6 @@ export default defineConfig([
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error',
'@eslint-react/no-prop-types': 'error',
'prettier/prettier': ['error']
}
},
// Configuration for ensuring compatibility with the original ESLint(8.x) rules
@ -53,7 +51,7 @@ export default defineConfig([
{
// LoggerService Custom Rules - only apply to src directory
files: ['src/**/*.{ts,tsx,js,jsx}'],
ignores: ['src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*'],
ignores: ['src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*', 'src/preload/**'],
rules: {
'no-restricted-syntax': [
process.env.PRCI ? 'error' : 'warn',
@ -128,5 +126,9 @@ export default defineConfig([
'src/renderer/src/ui/**',
'packages/**/dist'
]
}
},
// turn off oxlint supported rules.
...oxlint.configs['flat/eslint'],
...oxlint.configs['flat/typescript'],
...oxlint.configs['flat/unicorn']
])

View File

@ -52,8 +52,8 @@
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
"analyze:main": "VISUALIZER_MAIN=true yarn build",
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "tsx scripts/check-i18n.ts",
"sync:i18n": "tsx scripts/sync-i18n.ts",
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
@ -67,13 +67,16 @@
"test:ui": "vitest --ui",
"test:watch": "vitest",
"test:e2e": "yarn playwright test",
"test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache",
"test:scripts": "vitest scripts",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix && yarn typecheck && yarn check:i18n",
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn check:i18n",
"format": "biome format --write && biome lint --write",
"format:check": "biome format && biome lint",
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
"claude": "dotenv -e .env -- claude"
"claude": "dotenv -e .env -- claude",
"release:aicore:alpha": "yarn workspace @cherrystudio/ai-core version prerelease --immediate && yarn workspace @cherrystudio/ai-core npm publish --tag alpha --access public",
"release:aicore:beta": "yarn workspace @cherrystudio/ai-core version prerelease --immediate && yarn workspace @cherrystudio/ai-core npm publish --tag beta --access public",
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
},
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.113",
@ -85,7 +88,6 @@
"drizzle-orm": "^0.44.5",
"express": "^5.1.0",
"express-validator": "^7.2.1",
"faiss-node": "^0.5.1",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
"jsdom": "26.1.0",
@ -103,17 +105,18 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.0",
"@ai-sdk/google-vertex": "^3.0.25",
"@ai-sdk/mistral": "^2.0.0",
"@ai-sdk/perplexity": "^2.0.8",
"@ai-sdk/amazon-bedrock": "^3.0.21",
"@ai-sdk/google-vertex": "^3.0.27",
"@ai-sdk/mistral": "^2.0.14",
"@ai-sdk/perplexity": "^2.0.9",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
"@aws-sdk/client-bedrock": "^3.840.0",
"@aws-sdk/client-bedrock-runtime": "^3.840.0",
"@aws-sdk/client-s3": "^3.840.0",
"@cherrystudio/ai-core": "workspace:*",
"@biomejs/biome": "2.2.4",
"@cherrystudio/ai-core": "workspace:^1.0.0-alpha.16",
"@cherrystudio/embedjs": "^0.1.31",
"@cherrystudio/embedjs-libsql": "^0.1.31",
"@cherrystudio/embedjs-loader-csv": "^0.1.31",
@ -131,7 +134,6 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
@ -145,9 +147,6 @@
"@heroui/react": "^2.8.3",
"@kangfenmao/keyv-storage": "^0.1.0",
"@langchain/community": "^0.3.50",
"@langchain/core": "^0.3.68",
"@langchain/ollama": "^0.2.1",
"@langchain/openai": "^0.6.7",
"@mistralai/mistralai": "^1.7.5",
"@modelcontextprotocol/sdk": "^1.17.5",
"@mozilla/readability": "^0.6.0",
@ -213,6 +212,7 @@
"@types/tinycolor2": "^1",
"@types/turndown": "^5.0.5",
"@types/word-extractor": "^1",
"@typescript/native-preview": "latest",
"@uiw/codemirror-extensions-langs": "^4.25.1",
"@uiw/codemirror-themes-all": "^4.25.1",
"@uiw/react-codemirror": "^4.25.1",
@ -224,7 +224,7 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "^5.0.38",
"ai": "^5.0.44",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
@ -246,6 +246,7 @@
"diff": "^8.0.2",
"docx": "^9.0.2",
"dompurify": "^3.2.6",
"dotenv": "^17.2.2",
"dotenv-cli": "^7.4.2",
"drizzle-kit": "^0.31.4",
"electron": "37.4.0",
@ -260,6 +261,7 @@
"emoji-picker-element": "^1.22.1",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"eslint": "^9.22.0",
"eslint-plugin-oxlint": "^1.15.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
@ -295,13 +297,12 @@
"notion-helper": "^1.3.22",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"oxlint": "^1.15.0",
"oxlint-tsgolint": "^0.2.0",
"p-queue": "^8.1.0",
"pdf-lib": "^1.17.1",
"pdf-parse": "^1.1.1",
"playwright": "^1.52.0",
"prettier": "^3.5.3",
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"proxy-agent": "^6.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -384,11 +385,11 @@
"packageManager": "yarn@4.9.1",
"lint-staged": {
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [
"prettier --write",
"biome format --write",
"eslint --fix"
],
"*.{json,yml,yaml,css,html}": [
"prettier --write"
"biome format --write"
]
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@cherrystudio/ai-core",
"version": "1.0.0-alpha.14",
"version": "1.0.0-alpha.16",
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
"main": "dist/index.js",
"module": "dist/index.mjs",
@ -36,15 +36,15 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
"@ai-sdk/deepseek": "^1.0.9",
"@ai-sdk/google": "^2.0.13",
"@ai-sdk/openai": "^2.0.26",
"@ai-sdk/openai-compatible": "^1.0.9",
"@ai-sdk/anthropic": "^2.0.17",
"@ai-sdk/azure": "^2.0.30",
"@ai-sdk/deepseek": "^1.0.17",
"@ai-sdk/google": "^2.0.14",
"@ai-sdk/openai": "^2.0.30",
"@ai-sdk/openai-compatible": "^1.0.17",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.4",
"@ai-sdk/xai": "^2.0.9",
"@ai-sdk/provider-utils": "^3.0.9",
"@ai-sdk/xai": "^2.0.18",
"zod": "^4.1.5"
},
"devDependencies": {

View File

@ -24,7 +24,6 @@ export const googleToolsPlugin = (config?: ToolConfig) =>
if (!typedParams.tools) {
typedParams.tools = {}
}
// 使用类型安全的方式遍历配置
;(Object.keys(config) as ToolConfigKey[]).forEach((key) => {
if (config[key] && key in toolNameMap && key in google.tools) {

View File

@ -1,26 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"allowSyntheticDefaultImports": true,
"declaration": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"noEmitOnError": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
"outDir": "./dist",
"resolveJsonModule": true,
"rootDir": "./src",
"skipLibCheck": true,
"strict": true,
"target": "ES2020"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}
"exclude": ["node_modules", "dist"],
"include": ["src/**/*"]
}

View File

@ -67,13 +67,13 @@
"dist"
],
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@tiptap/core": "^3.2.0",
"@tiptap/pm": "^3.2.0",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"prettier": "^3.5.3",
"tsdown": "^0.13.3"
},
"peerDependencies": {
@ -87,7 +87,7 @@
},
"scripts": {
"build": "tsdown",
"lint": "prettier ./src/ --write && eslint --fix ./src/"
"lint": "biome format ./src/ --write && eslint --fix ./src/"
},
"packageManager": "yarn@4.9.1"
}

View File

@ -75,17 +75,17 @@ export const languages: Record<string, LanguageData> = ${languagesObjectString};
}
/**
* Formats a file using Prettier.
* Formats a file using Biome.
* @param filePath The path to the file to format.
*/
async function formatWithPrettier(filePath: string): Promise<void> {
console.log('🎨 Formatting file with Prettier...')
async function format(filePath: string): Promise<void> {
console.log('🎨 Formatting file with Biome...')
try {
await execAsync(`yarn prettier --write ${filePath}`)
console.log('✅ Prettier formatting complete.')
await execAsync(`yarn biome format --write ${filePath}`)
console.log('✅ Biome formatting complete.')
} catch (e: any) {
console.error('❌ Prettier formatting failed:', e.stdout || e.stderr)
throw new Error('Prettier formatting failed.')
console.error('❌ Biome formatting failed:', e.stdout || e.stderr)
throw new Error('Biome formatting failed.')
}
}
@ -116,7 +116,7 @@ async function updateLanguagesFile(): Promise<void> {
await fs.writeFile(LANGUAGES_FILE_PATH, fileContent, 'utf-8')
console.log(`✅ Successfully wrote to ${LANGUAGES_FILE_PATH}`)
await formatWithPrettier(LANGUAGES_FILE_PATH)
await format(LANGUAGES_FILE_PATH)
await checkTypeScript(LANGUAGES_FILE_PATH)
console.log('🎉 Successfully updated languages.ts file!')

View File

@ -4,7 +4,7 @@ import { loggerService } from '../../services/LoggerService'
const logger = loggerService.withContext('ApiServerErrorHandler')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
logger.error('API Server Error:', err)

View File

@ -11,11 +11,10 @@ import { handleZoomFactor } from '@main/utils/zoom'
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
import { FileMetadata, Notification, OcrProvider, Provider, Shortcut, SupportedOcrFile, ThemeMode } from '@types'
import checkDiskSpace from 'check-disk-space'
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
import fontList from 'font-list'
import { Notification } from 'src/renderer/src/types/notification'
import { apiServerService } from './services/ApiServerService'
import appService from './services/AppService'
@ -28,7 +27,7 @@ import DxtService from './services/DxtService'
import { ExportService } from './services/ExportService'
import { fileStorage as fileManager } from './services/FileStorage'
import FileService from './services/FileSystemService'
import KnowledgeService from './services/knowledge/KnowledgeService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
import MemoryService from './services/memory/MemoryService'
import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceService'
@ -827,7 +826,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
// OCR
ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters<typeof ocrService.ocr>) => ocrService.ocr(...args))
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
ocrService.ocr(file, provider)
)
// CherryIN
ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params))

View File

@ -139,9 +139,9 @@ export async function addFileLoader(
if (jsonParsed) {
loaderReturn = await ragApplication.addLoader(new JsonLoader({ object: jsonObject }), forceReload)
break
}
// fallthrough - JSON 解析失败时作为文本处理
// oxlint-disable-next-line no-fallthrough 利用switch特性刻意不break
default:
// 文本类型处理(默认)
// 如果是其他文本类型且尚未读取文件,则读取文件

View File

@ -11,7 +11,7 @@ export enum OdType {
OdtLoader = 'OdtLoader',
OdsLoader = 'OdsLoader',
OdpLoader = 'OdpLoader',
undefined = 'undefined'
Undefined = 'undefined'
}
export class OdLoader<OdType> extends BaseLoader<{ type: string }> {

View File

@ -1,63 +0,0 @@
import { VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
import type { Embeddings } from '@langchain/core/embeddings'
import { OllamaEmbeddings } from '@langchain/ollama'
import { AzureOpenAIEmbeddings, OpenAIEmbeddings } from '@langchain/openai'
import { ApiClient, SystemProviderIds } from '@types'
import { isJinaEmbeddingsModel, JinaEmbeddings } from './JinaEmbeddings'
export default class EmbeddingsFactory {
static create({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }): Embeddings {
const batchSize = 10
const { model, provider, apiKey, apiVersion, baseURL } = embedApiClient
if (provider === SystemProviderIds.ollama) {
let baseUrl = baseURL
if (baseURL.includes('v1/')) {
baseUrl = baseURL.replace('v1/', '')
}
const headers = apiKey
? {
Authorization: `Bearer ${apiKey}`
}
: undefined
return new OllamaEmbeddings({
model: model,
baseUrl,
...headers
})
} else if (provider === SystemProviderIds.voyageai) {
return new VoyageEmbeddings({
modelName: model,
apiKey,
outputDimension: dimensions,
batchSize
})
}
if (isJinaEmbeddingsModel(model)) {
return new JinaEmbeddings({
model,
apiKey,
batchSize,
dimensions,
baseUrl: baseURL
})
}
if (apiVersion !== undefined) {
return new AzureOpenAIEmbeddings({
azureOpenAIApiKey: apiKey,
azureOpenAIApiVersion: apiVersion,
azureOpenAIApiDeploymentName: model,
azureOpenAIEndpoint: baseURL,
dimensions,
batchSize
})
}
return new OpenAIEmbeddings({
model,
apiKey,
dimensions,
batchSize,
configuration: { baseURL }
})
}
}

View File

@ -1,199 +0,0 @@
import { Embeddings, type EmbeddingsParams } from '@langchain/core/embeddings'
import { chunkArray } from '@langchain/core/utils/chunk_array'
import { getEnvironmentVariable } from '@langchain/core/utils/env'
import { z } from 'zod'
const jinaModelSchema = z.union([
z.literal('jina-clip-v2'),
z.literal('jina-embeddings-v3'),
z.literal('jina-colbert-v2'),
z.literal('jina-clip-v1'),
z.literal('jina-colbert-v1-en'),
z.literal('jina-embeddings-v2-base-es'),
z.literal('jina-embeddings-v2-base-code'),
z.literal('jina-embeddings-v2-base-de'),
z.literal('jina-embeddings-v2-base-zh'),
z.literal('jina-embeddings-v2-base-en')
])
type JinaModel = z.infer<typeof jinaModelSchema>
export const isJinaEmbeddingsModel = (model: string): model is JinaModel => {
return jinaModelSchema.safeParse(model).success
}
interface JinaEmbeddingsParams extends EmbeddingsParams {
/** Model name to use */
model: JinaModel
baseUrl?: string
/**
* Timeout to use when making requests to Jina.
*/
timeout?: number
/**
* The maximum number of documents to embed in a single request.
*/
batchSize?: number
/**
* Whether to strip new lines from the input text.
*/
stripNewLines?: boolean
/**
* The dimensions of the embedding.
*/
dimensions?: number
/**
* Scales the embedding so its Euclidean (L2) norm becomes 1, preserving direction. Useful when downstream involves dot-product, classification, visualization..
*/
normalized?: boolean
}
type JinaMultiModelInput =
| {
text: string
image?: never
}
| {
image: string
text?: never
}
type JinaEmbeddingsInput = string | JinaMultiModelInput
interface EmbeddingCreateParams {
model: JinaEmbeddingsParams['model']
/**
* input can be strings or JinaMultiModelInputs,if you want embed image,you should use JinaMultiModelInputs
*/
input: JinaEmbeddingsInput[]
dimensions: number
task?: 'retrieval.query' | 'retrieval.passage'
}
interface EmbeddingResponse {
model: string
object: string
usage: {
total_tokens: number
prompt_tokens: number
}
data: {
object: string
index: number
embedding: number[]
}[]
}
interface EmbeddingErrorResponse {
detail: string
}
export class JinaEmbeddings extends Embeddings implements JinaEmbeddingsParams {
model: JinaEmbeddingsParams['model'] = 'jina-clip-v2'
batchSize = 24
baseUrl = 'https://api.jina.ai/v1/embeddings'
stripNewLines = true
dimensions = 1024
apiKey: string
constructor(
fields?: Partial<JinaEmbeddingsParams> & {
apiKey?: string
}
) {
const fieldsWithDefaults = { maxConcurrency: 2, ...fields }
super(fieldsWithDefaults)
const apiKey =
fieldsWithDefaults?.apiKey || getEnvironmentVariable('JINA_API_KEY') || getEnvironmentVariable('JINA_AUTH_TOKEN')
if (!apiKey) throw new Error('Jina API key not found')
this.apiKey = apiKey
this.baseUrl = fieldsWithDefaults?.baseUrl ? `${fieldsWithDefaults?.baseUrl}embeddings` : this.baseUrl
this.model = fieldsWithDefaults?.model ?? this.model
this.dimensions = fieldsWithDefaults?.dimensions ?? this.dimensions
this.batchSize = fieldsWithDefaults?.batchSize ?? this.batchSize
this.stripNewLines = fieldsWithDefaults?.stripNewLines ?? this.stripNewLines
}
private doStripNewLines(input: JinaEmbeddingsInput[]) {
if (this.stripNewLines) {
return input.map((i) => {
if (typeof i === 'string') {
return i.replace(/\n/g, ' ')
}
if (i.text) {
return { text: i.text.replace(/\n/g, ' ') }
}
return i
})
}
return input
}
async embedDocuments(input: JinaEmbeddingsInput[]): Promise<number[][]> {
const batches = chunkArray(this.doStripNewLines(input), this.batchSize)
const batchRequests = batches.map((batch) => {
const params = this.getParams(batch)
return this.embeddingWithRetry(params)
})
const batchResponses = await Promise.all(batchRequests)
const embeddings: number[][] = []
for (let i = 0; i < batchResponses.length; i += 1) {
const batch = batches[i]
const batchResponse = batchResponses[i] || []
for (let j = 0; j < batch.length; j += 1) {
embeddings.push(batchResponse[j])
}
}
return embeddings
}
async embedQuery(input: JinaEmbeddingsInput): Promise<number[]> {
const params = this.getParams(this.doStripNewLines([input]), true)
const embeddings = (await this.embeddingWithRetry(params)) || [[]]
return embeddings[0]
}
private getParams(input: JinaEmbeddingsInput[], query?: boolean): EmbeddingCreateParams {
return {
model: this.model,
input,
dimensions: this.dimensions,
task: query ? 'retrieval.query' : this.model === 'jina-clip-v2' ? undefined : 'retrieval.passage'
}
}
private async embeddingWithRetry(body: EmbeddingCreateParams) {
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`
},
body: JSON.stringify(body)
})
const embeddingData: EmbeddingResponse | EmbeddingErrorResponse = await response.json()
if ('detail' in embeddingData && embeddingData.detail) {
throw new Error(`${embeddingData.detail}`)
}
return (embeddingData as EmbeddingResponse).data.map(({ embedding }) => embedding)
}
}

View File

@ -1,25 +0,0 @@
import type { Embeddings as BaseEmbeddings } from '@langchain/core/embeddings'
import { TraceMethod } from '@mcp-trace/trace-core'
import { ApiClient } from '@types'
import EmbeddingsFactory from './EmbeddingsFactory'
export default class TextEmbeddings {
private sdk: BaseEmbeddings
constructor({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }) {
this.sdk = EmbeddingsFactory.create({
embedApiClient,
dimensions
})
}
@TraceMethod({ spanName: 'embedDocuments', tag: 'Embeddings' })
public async embedDocuments(texts: string[]): Promise<number[][]> {
return this.sdk.embedDocuments(texts)
}
@TraceMethod({ spanName: 'embedQuery', tag: 'Embeddings' })
public async embedQuery(text: string): Promise<number[]> {
return this.sdk.embedQuery(text)
}
}

View File

@ -1,97 +0,0 @@
import { BaseDocumentLoader } from '@langchain/core/document_loaders/base'
import { Document } from '@langchain/core/documents'
import { readTextFileWithAutoEncoding } from '@main/utils/file'
import MarkdownIt from 'markdown-it'
export class MarkdownLoader extends BaseDocumentLoader {
private path: string
private md: MarkdownIt
constructor(path: string) {
super()
this.path = path
this.md = new MarkdownIt()
}
public async load(): Promise<Document[]> {
const content = await readTextFileWithAutoEncoding(this.path)
return this.parseMarkdown(content)
}
private parseMarkdown(content: string): Document[] {
const tokens = this.md.parse(content, {})
const documents: Document[] = []
let currentSection: {
heading?: string
level?: number
content: string
startLine?: number
} = { content: '' }
let i = 0
while (i < tokens.length) {
const token = tokens[i]
if (token.type === 'heading_open') {
// Save previous section if it has content
if (currentSection.content.trim()) {
documents.push(
new Document({
pageContent: currentSection.content.trim(),
metadata: {
source: this.path,
heading: currentSection.heading || 'Introduction',
level: currentSection.level || 0,
startLine: currentSection.startLine || 0
}
})
)
}
// Start new section
const level = parseInt(token.tag.slice(1)) // Extract number from h1, h2, etc.
const headingContent = tokens[i + 1]?.content || ''
currentSection = {
heading: headingContent,
level: level,
content: '',
startLine: token.map?.[0] || 0
}
// Skip heading_open, inline, heading_close tokens
i += 3
continue
}
// Add token content to current section
if (token.content) {
currentSection.content += token.content
}
// Add newlines for block tokens
if (token.block && token.type !== 'heading_close') {
currentSection.content += '\n'
}
i++
}
// Add the last section
if (currentSection.content.trim()) {
documents.push(
new Document({
pageContent: currentSection.content.trim(),
metadata: {
source: this.path,
heading: currentSection.heading || 'Introduction',
level: currentSection.level || 0,
startLine: currentSection.startLine || 0
}
})
)
}
return documents
}
}

View File

@ -1,50 +0,0 @@
import { BaseDocumentLoader } from '@langchain/core/document_loaders/base'
import { Document } from '@langchain/core/documents'
export class NoteLoader extends BaseDocumentLoader {
private text: string
private sourceUrl?: string
constructor(
public _text: string,
public _sourceUrl?: string
) {
super()
this.text = _text
this.sourceUrl = _sourceUrl
}
/**
* A protected method that takes a `raw` string as a parameter and returns
* a promise that resolves to an array containing the raw text as a single
* element.
* @param raw The raw text to be parsed.
* @returns A promise that resolves to an array containing the raw text as a single element.
*/
protected async parse(raw: string): Promise<string[]> {
return [raw]
}
public async load(): Promise<Document[]> {
const metadata = { source: this.sourceUrl || 'note' }
const parsed = await this.parse(this.text)
parsed.forEach((pageContent, i) => {
if (typeof pageContent !== 'string') {
throw new Error(`Expected string, at position ${i} got ${typeof pageContent}`)
}
})
return parsed.map(
(pageContent, i) =>
new Document({
pageContent,
metadata:
parsed.length === 1
? metadata
: {
...metadata,
line: i + 1
}
})
)
}
}

View File

@ -1,170 +0,0 @@
import { BaseDocumentLoader } from '@langchain/core/document_loaders/base'
import { Document } from '@langchain/core/documents'
import { Innertube } from 'youtubei.js'
// ... (接口定义 YoutubeConfig 和 VideoMetadata 保持不变)
/**
* Configuration options for the YoutubeLoader class. Includes properties
* such as the videoId, language, and addVideoInfo.
*/
interface YoutubeConfig {
videoId: string
language?: string
addVideoInfo?: boolean
// 新增一个选项,用于控制输出格式
transcriptFormat?: 'text' | 'srt'
}
/**
* Metadata of a YouTube video. Includes properties such as the source
* (videoId), description, title, view_count, author, and category.
*/
interface VideoMetadata {
source: string
description?: string
title?: string
view_count?: number
author?: string
category?: string
}
/**
* A document loader for loading data from YouTube videos. It uses the
* youtubei.js library to fetch the transcript and video metadata.
* @example
* ```typescript
* const loader = new YoutubeLoader({
* videoId: "VIDEO_ID",
* language: "en",
* addVideoInfo: true,
* transcriptFormat: "srt" // 获取 SRT 格式
* });
* const docs = await loader.load();
* console.log(docs[0].pageContent);
* ```
*/
export class YoutubeLoader extends BaseDocumentLoader {
private videoId: string
private language?: string
private addVideoInfo: boolean
// 新增格式化选项的私有属性
private transcriptFormat: 'text' | 'srt'
constructor(config: YoutubeConfig) {
super()
this.videoId = config.videoId
this.language = config?.language
this.addVideoInfo = config?.addVideoInfo ?? false
// 初始化格式化选项,默认为 'text' 以保持向后兼容
this.transcriptFormat = config?.transcriptFormat ?? 'text'
}
/**
* Extracts the videoId from a YouTube video URL.
* @param url The URL of the YouTube video.
* @returns The videoId of the YouTube video.
*/
private static getVideoID(url: string): string {
const match = url.match(/.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#&?]*).*/)
if (match !== null && match[1].length === 11) {
return match[1]
} else {
throw new Error('Failed to get youtube video id from the url')
}
}
/**
* Creates a new instance of the YoutubeLoader class from a YouTube video
* URL.
* @param url The URL of the YouTube video.
* @param config Optional configuration options for the YoutubeLoader instance, excluding the videoId.
* @returns A new instance of the YoutubeLoader class.
*/
static createFromUrl(url: string, config?: Omit<YoutubeConfig, 'videoId'>): YoutubeLoader {
const videoId = YoutubeLoader.getVideoID(url)
return new YoutubeLoader({ ...config, videoId })
}
/**
* [] SRT (HH:MM:SS,ms)
* @param ms
* @returns
*/
private static formatTimestamp(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
.toString()
.padStart(2, '0')
const minutes = Math.floor((totalSeconds % 3600) / 60)
.toString()
.padStart(2, '0')
const seconds = (totalSeconds % 60).toString().padStart(2, '0')
const milliseconds = (ms % 1000).toString().padStart(3, '0')
return `${hours}:${minutes}:${seconds},${milliseconds}`
}
/**
* Loads the transcript and video metadata from the specified YouTube
* video. It can return the transcript as plain text or in SRT format.
* @returns An array of Documents representing the retrieved data.
*/
async load(): Promise<Document[]> {
const metadata: VideoMetadata = {
source: this.videoId
}
try {
const youtube = await Innertube.create({
lang: this.language,
retrieve_player: false
})
const info = await youtube.getInfo(this.videoId)
const transcriptData = await info.getTranscript()
if (!transcriptData.transcript.content?.body?.initial_segments) {
throw new Error('Transcript segments not found in the response.')
}
const segments = transcriptData.transcript.content.body.initial_segments
let pageContent: string
// 根据 transcriptFormat 选项决定如何格式化字幕
if (this.transcriptFormat === 'srt') {
// [修改] 将字幕片段格式化为 SRT 格式
pageContent = segments
.map((segment, index) => {
const srtIndex = index + 1
const startTime = YoutubeLoader.formatTimestamp(Number(segment.start_ms))
const endTime = YoutubeLoader.formatTimestamp(Number(segment.end_ms))
const text = segment.snippet?.text || '' // 使用 segment.snippet.text
return `${srtIndex}\n${startTime} --> ${endTime}\n${text}`
})
.join('\n\n') // 每个 SRT 块之间用两个换行符分隔
} else {
// [原始逻辑] 拼接为纯文本
pageContent = segments.map((segment) => segment.snippet?.text || '').join(' ')
}
if (this.addVideoInfo) {
const basicInfo = info.basic_info
metadata.description = basicInfo.short_description
metadata.title = basicInfo.title
metadata.view_count = basicInfo.view_count
metadata.author = basicInfo.author
}
const document = new Document({
pageContent,
metadata
})
return [document]
} catch (e: unknown) {
throw new Error(`Failed to get YouTube video transcription: ${(e as Error).message}`)
}
}
}

View File

@ -1,235 +0,0 @@
import { DocxLoader } from '@langchain/community/document_loaders/fs/docx'
import { EPubLoader } from '@langchain/community/document_loaders/fs/epub'
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf'
import { PPTXLoader } from '@langchain/community/document_loaders/fs/pptx'
import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio'
import { SitemapLoader } from '@langchain/community/document_loaders/web/sitemap'
import { FaissStore } from '@langchain/community/vectorstores/faiss'
import { Document } from '@langchain/core/documents'
import { loggerService } from '@logger'
import { UrlSource } from '@main/utils/knowledge'
import { LoaderReturn } from '@shared/config/types'
import { FileMetadata, FileTypes, KnowledgeBaseParams } from '@types'
import { randomUUID } from 'crypto'
import { JSONLoader } from 'langchain/document_loaders/fs/json'
import { TextLoader } from 'langchain/document_loaders/fs/text'
import { SplitterFactory } from '../splitter'
import { MarkdownLoader } from './MarkdownLoader'
import { NoteLoader } from './NoteLoader'
import { YoutubeLoader } from './YoutubeLoader'
const logger = loggerService.withContext('KnowledgeService File Loader')
type LoaderInstance =
| TextLoader
| PDFLoader
| PPTXLoader
| DocxLoader
| JSONLoader
| EPubLoader
| CheerioWebBaseLoader
| YoutubeLoader
| SitemapLoader
| NoteLoader
| MarkdownLoader
/**
* metadata
*/
function formatDocument(docs: Document[], type: string): Document[] {
return docs.map((doc) => ({
...doc,
metadata: {
...doc.metadata,
type: type
}
}))
}
/**
*
*/
async function processDocuments(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
docs: Document[],
loaderType: string,
splitterType?: string
): Promise<LoaderReturn> {
const formattedDocs = formatDocument(docs, loaderType)
const splitter = SplitterFactory.create({
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap,
...(splitterType && { type: splitterType })
})
const splitterResults = await splitter.splitDocuments(formattedDocs)
const ids = splitterResults.map(() => randomUUID())
await vectorStore.addDocuments(splitterResults, { ids })
return {
entriesAdded: splitterResults.length,
uniqueId: ids[0] || '',
uniqueIds: ids,
loaderType
}
}
/**
*
*/
async function executeLoader(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
loaderInstance: LoaderInstance,
loaderType: string,
identifier: string,
splitterType?: string
): Promise<LoaderReturn> {
const emptyResult: LoaderReturn = {
entriesAdded: 0,
uniqueId: '',
uniqueIds: [],
loaderType
}
try {
const docs = await loaderInstance.load()
return await processDocuments(base, vectorStore, docs, loaderType, splitterType)
} catch (error) {
logger.error(`Error loading or processing ${identifier} with loader ${loaderType}: ${error}`)
return emptyResult
}
}
/**
*
*/
const FILE_LOADER_MAP: Record<string, { loader: new (path: string) => LoaderInstance; type: string }> = {
'.pdf': { loader: PDFLoader, type: 'pdf' },
'.txt': { loader: TextLoader, type: 'text' },
'.pptx': { loader: PPTXLoader, type: 'pptx' },
'.docx': { loader: DocxLoader, type: 'docx' },
'.doc': { loader: DocxLoader, type: 'doc' },
'.json': { loader: JSONLoader, type: 'json' },
'.epub': { loader: EPubLoader, type: 'epub' },
'.md': { loader: MarkdownLoader, type: 'markdown' }
}
export async function addFileLoader(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
file: FileMetadata
): Promise<LoaderReturn> {
const fileExt = file.ext.toLowerCase()
const loaderConfig = FILE_LOADER_MAP[fileExt]
if (!loaderConfig) {
// 默认使用文本加载器
const loaderInstance = new TextLoader(file.path)
const type = fileExt.replace('.', '') || 'unknown'
return executeLoader(base, vectorStore, loaderInstance, type, file.path)
}
const loaderInstance = new loaderConfig.loader(file.path)
return executeLoader(base, vectorStore, loaderInstance, loaderConfig.type, file.path)
}
export async function addWebLoader(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
url: string,
source: UrlSource
): Promise<LoaderReturn> {
let loaderInstance: CheerioWebBaseLoader | YoutubeLoader | undefined
let splitterType: string | undefined
switch (source) {
case 'normal':
loaderInstance = new CheerioWebBaseLoader(url)
break
case 'youtube':
loaderInstance = YoutubeLoader.createFromUrl(url, {
addVideoInfo: true,
transcriptFormat: 'srt'
})
splitterType = 'srt'
break
}
if (!loaderInstance) {
return {
entriesAdded: 0,
uniqueId: '',
uniqueIds: [],
loaderType: source
}
}
return executeLoader(base, vectorStore, loaderInstance, source, url, splitterType)
}
export async function addSitemapLoader(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
url: string
): Promise<LoaderReturn> {
const loaderInstance = new SitemapLoader(url)
return executeLoader(base, vectorStore, loaderInstance, 'sitemap', url)
}
export async function addNoteLoader(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
content: string,
sourceUrl: string
): Promise<LoaderReturn> {
const loaderInstance = new NoteLoader(content, sourceUrl)
return executeLoader(base, vectorStore, loaderInstance, 'note', sourceUrl)
}
export async function addVideoLoader(
base: KnowledgeBaseParams,
vectorStore: FaissStore,
files: FileMetadata[]
): Promise<LoaderReturn> {
const srtFile = files.find((f) => f.type === FileTypes.TEXT)
const videoFile = files.find((f) => f.type === FileTypes.VIDEO)
const emptyResult: LoaderReturn = {
entriesAdded: 0,
uniqueId: '',
uniqueIds: [],
loaderType: 'video'
}
if (!srtFile || !videoFile) {
return emptyResult
}
try {
const loaderInstance = new TextLoader(srtFile.path)
const originalDocs = await loaderInstance.load()
const docsWithVideoMeta = originalDocs.map(
(doc) =>
new Document({
...doc,
metadata: {
...doc.metadata,
video: {
path: videoFile.path,
name: videoFile.origin_name
}
}
})
)
return await processDocuments(base, vectorStore, docsWithVideoMeta, 'video', 'srt')
} catch (error) {
logger.error(`Error loading or processing file ${srtFile.path} with loader video: ${error}`)
return emptyResult
}
}

View File

@ -1,55 +0,0 @@
import { BM25Retriever } from '@langchain/community/retrievers/bm25'
import { FaissStore } from '@langchain/community/vectorstores/faiss'
import { BaseRetriever } from '@langchain/core/retrievers'
import { loggerService } from '@main/services/LoggerService'
import { type KnowledgeBaseParams } from '@types'
import { type Document } from 'langchain/document'
import { EnsembleRetriever } from 'langchain/retrievers/ensemble'
const logger = loggerService.withContext('RetrieverFactory')
export class RetrieverFactory {
/**
* LangChain (Retriever)
* @param base
* @param vectorStore
* @param documents BM25Retriever
* @returns BaseRetriever
*/
public createRetriever(base: KnowledgeBaseParams, vectorStore: FaissStore, documents: Document[]): BaseRetriever {
const retrieverType = base.retriever?.mode ?? 'hybrid'
const retrieverWeight = base.retriever?.weight ?? 0.5
const searchK = base.documentCount ?? 5
logger.info(`Creating retriever of type: ${retrieverType} with k=${searchK}`)
switch (retrieverType) {
case 'bm25':
if (documents.length === 0) {
throw new Error('BM25Retriever requires documents, but none were provided or found.')
}
logger.info('Create BM25 Retriever')
return BM25Retriever.fromDocuments(documents, { k: searchK })
case 'hybrid': {
if (documents.length === 0) {
logger.warn('No documents provided for BM25 part of hybrid search. Falling back to vector search only.')
return vectorStore.asRetriever(searchK)
}
const vectorstoreRetriever = vectorStore.asRetriever(searchK)
const bm25Retriever = BM25Retriever.fromDocuments(documents, { k: searchK })
logger.info('Create Hybrid Retriever')
return new EnsembleRetriever({
retrievers: [bm25Retriever, vectorstoreRetriever],
weights: [retrieverWeight, 1 - retrieverWeight]
})
}
case 'vector':
default:
logger.info('Create Vector Retriever')
return vectorStore.asRetriever(searchK)
}
}
}

View File

@ -1,133 +0,0 @@
import { Document } from '@langchain/core/documents'
import { TextSplitter, TextSplitterParams } from 'langchain/text_splitter'
// 定义一个接口来表示解析后的单个字幕片段
interface SrtSegment {
text: string
startTime: number // in seconds
endTime: number // in seconds
}
// 辅助函数:将 SRT 时间戳字符串 (HH:MM:SS,ms) 转换为秒
function srtTimeToSeconds(time: string): number {
const parts = time.split(':')
const secondsAndMs = parts[2].split(',')
const hours = parseInt(parts[0], 10)
const minutes = parseInt(parts[1], 10)
const seconds = parseInt(secondsAndMs[0], 10)
const milliseconds = parseInt(secondsAndMs[1], 10)
return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000
}
export class SrtSplitter extends TextSplitter {
constructor(fields?: Partial<TextSplitterParams>) {
// 传入 chunkSize 和 chunkOverlap
super(fields)
}
splitText(): Promise<string[]> {
throw new Error('Method not implemented.')
}
// 核心方法:重写 splitDocuments 来实现自定义逻辑
async splitDocuments(documents: Document[]): Promise<Document[]> {
const allChunks: Document[] = []
for (const doc of documents) {
// 1. 解析 SRT 内容
const segments = this.parseSrt(doc.pageContent)
if (segments.length === 0) continue
// 2. 将字幕片段组合成块
const chunks = this.mergeSegmentsIntoChunks(segments, doc.metadata)
allChunks.push(...chunks)
}
return allChunks
}
// 辅助方法:解析整个 SRT 字符串
private parseSrt(srt: string): SrtSegment[] {
const segments: SrtSegment[] = []
const blocks = srt.trim().split(/\n\n/)
for (const block of blocks) {
const lines = block.split('\n')
if (lines.length < 3) continue
const timeMatch = lines[1].match(/(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})/)
if (!timeMatch) continue
const startTime = srtTimeToSeconds(timeMatch[1])
const endTime = srtTimeToSeconds(timeMatch[2])
const text = lines.slice(2).join(' ').trim()
segments.push({ text, startTime, endTime })
}
return segments
}
// 辅助方法:将解析后的片段合并成每 5 段一个块
private mergeSegmentsIntoChunks(segments: SrtSegment[], baseMetadata: Record<string, any>): Document[] {
const chunks: Document[] = []
let currentChunkText = ''
let currentChunkStartTime = 0
let currentChunkEndTime = 0
let segmentCount = 0
for (const segment of segments) {
if (segmentCount === 0) {
currentChunkStartTime = segment.startTime
}
currentChunkText += (currentChunkText ? ' ' : '') + segment.text
currentChunkEndTime = segment.endTime
segmentCount++
// 当累积到 5 段时,创建一个新的 Document
if (segmentCount === 5) {
const metadata: Record<string, any> = {
...baseMetadata,
startTime: currentChunkStartTime,
endTime: currentChunkEndTime
}
if (baseMetadata.source_url) {
metadata.source_url_with_timestamp = `${baseMetadata.source_url}?t=${Math.floor(currentChunkStartTime)}s`
}
chunks.push(
new Document({
pageContent: currentChunkText,
metadata
})
)
// 重置计数器和临时变量
currentChunkText = ''
currentChunkStartTime = 0
currentChunkEndTime = 0
segmentCount = 0
}
}
// 如果还有剩余的片段,创建最后一个 Document
if (segmentCount > 0) {
const metadata: Record<string, any> = {
...baseMetadata,
startTime: currentChunkStartTime,
endTime: currentChunkEndTime
}
if (baseMetadata.source_url) {
metadata.source_url_with_timestamp = `${baseMetadata.source_url}?t=${Math.floor(currentChunkStartTime)}s`
}
chunks.push(
new Document({
pageContent: currentChunkText,
metadata
})
)
}
return chunks
}
}

View File

@ -1,31 +0,0 @@
import { RecursiveCharacterTextSplitter, TextSplitter } from '@langchain/textsplitters'
import { SrtSplitter } from './SrtSplitter'
export type SplitterConfig = {
chunkSize?: number
chunkOverlap?: number
type?: 'recursive' | 'srt' | string
}
export class SplitterFactory {
/**
* Creates a TextSplitter instance based on the provided configuration.
* @param config - The configuration object specifying the splitter type and its parameters.
* @returns An instance of a TextSplitter, or null if no splitting is required.
*/
public static create(config: SplitterConfig): TextSplitter {
switch (config.type) {
case 'srt':
return new SrtSplitter({
chunkSize: config.chunkSize,
chunkOverlap: config.chunkOverlap
})
case 'recursive':
default:
return new RecursiveCharacterTextSplitter({
chunkSize: config.chunkSize,
chunkOverlap: config.chunkOverlap
})
}
}
}

View File

@ -308,6 +308,24 @@ class CodeToolsService {
// Build command to execute
let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"`
// Add configuration parameters for OpenAI Codex
if (cliTool === codeTools.openaiCodex && env.OPENAI_MODEL_PROVIDER && env.OPENAI_MODEL_PROVIDER != 'openai') {
const provider = env.OPENAI_MODEL_PROVIDER
const model = env.OPENAI_MODEL
// delete the latest /
const baseUrl = env.OPENAI_BASE_URL.replace(/\/$/, '')
const configParams = [
`--config model_provider="${provider}"`,
`--config model="${model}"`,
`--config model_providers.${provider}.name="${provider}"`,
`--config model_providers.${provider}.base_url="${baseUrl}"`,
`--config model_providers.${provider}.env_key="OPENAI_API_KEY"`
].join(' ')
baseCommand = `${baseCommand} ${configParams}`
}
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
if (isInstalled) {

View File

@ -1,4 +1,4 @@
/* eslint-disable no-case-declarations */
/* oxlint-disable no-case-declarations */
// ExportService
import { loggerService } from '@logger'

View File

@ -1,3 +1,18 @@
/**
* Knowledge Service - Manages knowledge bases using RAG (Retrieval-Augmented Generation)
*
* This service handles creation, management, and querying of knowledge bases from various sources
* including files, directories, URLs, sitemaps, and notes.
*
* Features:
* - Concurrent task processing with workload management
* - Multiple data source support
* - Vector database integration
*
* For detailed documentation, see:
* @see {@link ../../../docs/technical/KnowledgeService.md}
*/
import * as fs from 'node:fs'
import path from 'node:path'
@ -9,32 +24,87 @@ import { loggerService } from '@logger'
import Embeddings from '@main/knowledge/embedjs/embeddings/Embeddings'
import { addFileLoader } from '@main/knowledge/embedjs/loader'
import { NoteLoader } from '@main/knowledge/embedjs/loader/noteLoader'
import { preprocessingService } from '@main/knowledge/preprocess/PreprocessingService'
import PreprocessProvider from '@main/knowledge/preprocess/PreprocessProvider'
import Reranker from '@main/knowledge/reranker/Reranker'
import { fileStorage } from '@main/services/FileStorage'
import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils'
import { getAllFiles } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core'
import { MB } from '@shared/config/constant'
import { LoaderReturn } from '@shared/config/types'
import type { LoaderReturn } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import { FileMetadata, KnowledgeBaseParams, KnowledgeSearchResult } from '@types'
import { FileMetadata, KnowledgeBaseParams, KnowledgeItem, KnowledgeSearchResult } from '@types'
import { v4 as uuidv4 } from 'uuid'
import { windowService } from '../WindowService'
import {
IKnowledgeFramework,
KnowledgeBaseAddItemOptionsNonNullableAttribute,
LoaderDoneReturn,
LoaderTask,
LoaderTaskItem,
LoaderTaskItemState
} from './IKnowledgeFramework'
const logger = loggerService.withContext('MainKnowledgeService')
export class EmbedJsFramework implements IKnowledgeFramework {
private storageDir: string
private ragApplications: Map<string, RAGApplication> = new Map()
private pendingDeleteFile: string
private dbInstances: Map<string, LibSqlDb> = new Map()
export interface KnowledgeBaseAddItemOptions {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload?: boolean
userId?: string
}
interface KnowledgeBaseAddItemOptionsNonNullableAttribute {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload: boolean
userId: string
}
interface EvaluateTaskWorkload {
workload: number
}
type LoaderDoneReturn = LoaderReturn | null
enum LoaderTaskItemState {
PENDING,
PROCESSING,
DONE
}
interface LoaderTaskItem {
state: LoaderTaskItemState
task: () => Promise<unknown>
evaluateTaskWorkload: EvaluateTaskWorkload
}
interface LoaderTask {
loaderTasks: LoaderTaskItem[]
loaderDoneReturn: LoaderDoneReturn
}
interface LoaderTaskOfSet {
loaderTasks: Set<LoaderTaskItem>
loaderDoneReturn: LoaderDoneReturn
}
interface QueueTaskItem {
taskPromise: () => Promise<unknown>
resolve: () => void
evaluateTaskWorkload: EvaluateTaskWorkload
}
const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => {
return {
loaderTasks: new Set(loaderTask.loaderTasks),
loaderDoneReturn: loaderTask.loaderDoneReturn
}
}
class KnowledgeService {
private storageDir = path.join(getDataPath(), 'KnowledgeBase')
private pendingDeleteFile = path.join(this.storageDir, 'knowledge_pending_delete.json')
// Byte based
private workload = 0
private processingItemCount = 0
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
private ragApplications: Map<string, RAGApplication> = new Map()
private dbInstances: Map<string, LibSqlDb> = new Map()
private static MAXIMUM_WORKLOAD = 80 * MB
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
private static ERROR_LOADER_RETURN: LoaderReturn = {
entriesAdded: 0,
uniqueId: '',
@ -43,9 +113,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
status: 'failed'
}
constructor(storageDir: string) {
this.storageDir = storageDir
this.pendingDeleteFile = path.join(this.storageDir, 'knowledge_pending_delete.json')
constructor() {
this.initStorageDir()
this.cleanupOnStartup()
}
@ -160,28 +228,33 @@ export class EmbedJsFramework implements IKnowledgeFramework {
logger.info(`Startup cleanup completed: ${deletedCount}/${pendingDeleteIds.length} knowledge bases deleted`)
}
private async getRagApplication(base: KnowledgeBaseParams): Promise<RAGApplication> {
if (this.ragApplications.has(base.id)) {
return this.ragApplications.get(base.id)!
private getRagApplication = async ({
id,
embedApiClient,
dimensions,
documentCount
}: KnowledgeBaseParams): Promise<RAGApplication> => {
if (this.ragApplications.has(id)) {
return this.ragApplications.get(id)!
}
let ragApplication: RAGApplication
const embeddings = new Embeddings({
embedApiClient: base.embedApiClient,
dimensions: base.dimensions
embedApiClient,
dimensions
})
try {
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, base.id) })
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
// Save database instance for later closing
this.dbInstances.set(base.id, libSqlDb)
this.dbInstances.set(id, libSqlDb)
ragApplication = await new RAGApplicationBuilder()
.setModel('NO_MODEL')
.setEmbeddingModel(embeddings)
.setVectorDatabase(libSqlDb)
.setSearchResultCount(base.documentCount || 30)
.setSearchResultCount(documentCount || 30)
.build()
this.ragApplications.set(base.id, ragApplication)
this.ragApplications.set(id, ragApplication)
} catch (e) {
logger.error('Failed to create RAGApplication:', e as Error)
throw new Error(`Failed to create RAGApplication: ${e}`)
@ -189,14 +262,17 @@ export class EmbedJsFramework implements IKnowledgeFramework {
return ragApplication
}
async initialize(base: KnowledgeBaseParams): Promise<void> {
public create = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
await this.getRagApplication(base)
}
async reset(base: KnowledgeBaseParams): Promise<void> {
const ragApp = await this.getRagApplication(base)
await ragApp.reset()
public reset = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
const ragApplication = await this.getRagApplication(base)
await ragApplication.reset()
}
async delete(id: string): Promise<void> {
public async delete(_: Electron.IpcMainInvokeEvent, id: string): Promise<void> {
logger.debug(`delete id: ${id}`)
await this.cleanupKnowledgeResources(id)
@ -209,41 +285,15 @@ export class EmbedJsFramework implements IKnowledgeFramework {
this.pendingDeleteManager.add(id)
}
}
getLoaderTask(options: KnowledgeBaseAddItemOptionsNonNullableAttribute): LoaderTask {
const { item } = options
const getRagApplication = () => this.getRagApplication(options.base)
switch (item.type) {
case 'file':
return this.fileTask(getRagApplication, options)
case 'directory':
return this.directoryTask(getRagApplication, options)
case 'url':
return this.urlTask(getRagApplication, options)
case 'sitemap':
return this.sitemapTask(getRagApplication, options)
case 'note':
return this.noteTask(getRagApplication, options)
default:
return {
loaderTasks: [],
loaderDoneReturn: null
}
}
}
async remove(options: { uniqueIds: string[]; base: KnowledgeBaseParams }): Promise<void> {
const ragApp = await this.getRagApplication(options.base)
for (const id of options.uniqueIds) {
await ragApp.deleteLoader(id)
}
private maximumLoad() {
return (
this.processingItemCount >= KnowledgeService.MAXIMUM_PROCESSING_ITEM_COUNT ||
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
)
}
async search(options: { search: string; base: KnowledgeBaseParams }): Promise<KnowledgeSearchResult[]> {
const ragApp = await this.getRagApplication(options.base)
return await ragApp.search(options.search)
}
private fileTask(
getRagApplication: () => Promise<RAGApplication>,
ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item, forceReload, userId } = options
@ -256,8 +306,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
task: async () => {
try {
// Add preprocessing logic
const ragApplication = await getRagApplication()
const fileToProcess: FileMetadata = await preprocessingService.preprocessFile(file, base, item, userId)
const fileToProcess: FileMetadata = await this.preprocessing(file, base, item, userId)
// Use processed file for loading
return addFileLoader(ragApplication, fileToProcess, base, forceReload)
@ -268,7 +317,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
.catch((e) => {
logger.error(`Error in addFileLoader for ${file.name}: ${e}`)
const errorResult: LoaderReturn = {
...EmbedJsFramework.ERROR_LOADER_RETURN,
...KnowledgeService.ERROR_LOADER_RETURN,
message: e.message,
messageSource: 'embedding'
}
@ -278,7 +327,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
} catch (e: any) {
logger.error(`Preprocessing failed for ${file.name}: ${e}`)
const errorResult: LoaderReturn = {
...EmbedJsFramework.ERROR_LOADER_RETURN,
...KnowledgeService.ERROR_LOADER_RETURN,
message: e.message,
messageSource: 'preprocess'
}
@ -295,7 +344,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
return loaderTask
}
private directoryTask(
getRagApplication: () => Promise<RAGApplication>,
ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item, forceReload } = options
@ -322,9 +371,8 @@ export class EmbedJsFramework implements IKnowledgeFramework {
for (const file of files) {
loaderTasks.push({
state: LoaderTaskItemState.PENDING,
task: async () => {
const ragApplication = await getRagApplication()
return addFileLoader(ragApplication, file, base, forceReload)
task: () =>
addFileLoader(ragApplication, file, base, forceReload)
.then((result) => {
loaderDoneReturn.entriesAdded += 1
processedFiles += 1
@ -335,12 +383,11 @@ export class EmbedJsFramework implements IKnowledgeFramework {
.catch((err) => {
logger.error('Failed to add dir loader:', err)
return {
...EmbedJsFramework.ERROR_LOADER_RETURN,
...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add dir loader: ${err.message}`,
messageSource: 'embedding'
}
})
},
}),
evaluateTaskWorkload: { workload: file.size }
})
}
@ -352,7 +399,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
}
private urlTask(
getRagApplication: () => Promise<RAGApplication>,
ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item, forceReload } = options
@ -362,8 +409,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
loaderTasks: [
{
state: LoaderTaskItemState.PENDING,
task: async () => {
const ragApplication = await getRagApplication()
task: () => {
const loaderReturn = ragApplication.addLoader(
new WebLoader({
urlOrContent: content,
@ -387,7 +433,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
.catch((err) => {
logger.error('Failed to add url loader:', err)
return {
...EmbedJsFramework.ERROR_LOADER_RETURN,
...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add url loader: ${err.message}`,
messageSource: 'embedding'
}
@ -402,7 +448,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
}
private sitemapTask(
getRagApplication: () => Promise<RAGApplication>,
ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item, forceReload } = options
@ -412,9 +458,8 @@ export class EmbedJsFramework implements IKnowledgeFramework {
loaderTasks: [
{
state: LoaderTaskItemState.PENDING,
task: async () => {
const ragApplication = await getRagApplication()
return ragApplication
task: () =>
ragApplication
.addLoader(
new SitemapLoader({ url: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any,
forceReload
@ -432,12 +477,11 @@ export class EmbedJsFramework implements IKnowledgeFramework {
.catch((err) => {
logger.error('Failed to add sitemap loader:', err)
return {
...EmbedJsFramework.ERROR_LOADER_RETURN,
...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add sitemap loader: ${err.message}`,
messageSource: 'embedding'
}
})
},
}),
evaluateTaskWorkload: { workload: 20 * MB }
}
],
@ -447,7 +491,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
}
private noteTask(
getRagApplication: () => Promise<RAGApplication>,
ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item, forceReload } = options
@ -460,8 +504,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
loaderTasks: [
{
state: LoaderTaskItemState.PENDING,
task: async () => {
const ragApplication = await getRagApplication()
task: () => {
const loaderReturn = ragApplication.addLoader(
new NoteLoader({
text: content,
@ -484,7 +527,7 @@ export class EmbedJsFramework implements IKnowledgeFramework {
.catch((err) => {
logger.error('Failed to add note loader:', err)
return {
...EmbedJsFramework.ERROR_LOADER_RETURN,
...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add note loader: ${err.message}`,
messageSource: 'embedding'
}
@ -497,4 +540,199 @@ export class EmbedJsFramework implements IKnowledgeFramework {
}
return loaderTask
}
private processingQueueHandle() {
const getSubtasksUntilMaximumLoad = (): QueueTaskItem[] => {
const queueTaskList: QueueTaskItem[] = []
that: for (const [task, resolve] of this.knowledgeItemProcessingQueueMappingPromise) {
for (const item of task.loaderTasks) {
if (this.maximumLoad()) {
break that
}
const { state, task: taskPromise, evaluateTaskWorkload } = item
if (state !== LoaderTaskItemState.PENDING) {
continue
}
const { workload } = evaluateTaskWorkload
this.workload += workload
this.processingItemCount += 1
item.state = LoaderTaskItemState.PROCESSING
queueTaskList.push({
taskPromise: () =>
taskPromise().then(() => {
this.workload -= workload
this.processingItemCount -= 1
task.loaderTasks.delete(item)
if (task.loaderTasks.size === 0) {
this.knowledgeItemProcessingQueueMappingPromise.delete(task)
resolve()
}
this.processingQueueHandle()
}),
resolve: () => {},
evaluateTaskWorkload
})
}
}
return queueTaskList
}
const subTasks = getSubtasksUntilMaximumLoad()
if (subTasks.length > 0) {
const subTaskPromises = subTasks.map(({ taskPromise }) => taskPromise())
Promise.all(subTaskPromises).then(() => {
subTasks.forEach(({ resolve }) => resolve())
})
}
}
private appendProcessingQueue(task: LoaderTask): Promise<LoaderReturn> {
return new Promise((resolve) => {
this.knowledgeItemProcessingQueueMappingPromise.set(loaderTaskIntoOfSet(task), () => {
resolve(task.loaderDoneReturn!)
})
})
}
public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
return new Promise((resolve) => {
const { base, item, forceReload = false, userId = '' } = options
const optionsNonNullableAttribute = { base, item, forceReload, userId }
this.getRagApplication(base)
.then((ragApplication) => {
const task = (() => {
switch (item.type) {
case 'file':
return this.fileTask(ragApplication, optionsNonNullableAttribute)
case 'directory':
return this.directoryTask(ragApplication, optionsNonNullableAttribute)
case 'url':
return this.urlTask(ragApplication, optionsNonNullableAttribute)
case 'sitemap':
return this.sitemapTask(ragApplication, optionsNonNullableAttribute)
case 'note':
return this.noteTask(ragApplication, optionsNonNullableAttribute)
default:
return null
}
})()
if (task) {
this.appendProcessingQueue(task).then(() => {
resolve(task.loaderDoneReturn!)
})
this.processingQueueHandle()
} else {
resolve({
...KnowledgeService.ERROR_LOADER_RETURN,
message: 'Unsupported item type',
messageSource: 'embedding'
})
}
})
.catch((err) => {
logger.error('Failed to add item:', err)
resolve({
...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add item: ${err.message}`,
messageSource: 'embedding'
})
})
})
}
@TraceMethod({ spanName: 'remove', tag: 'Knowledge' })
public async remove(
_: Electron.IpcMainInvokeEvent,
{ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }
): Promise<void> {
const ragApplication = await this.getRagApplication(base)
logger.debug(`Remove Item UniqueId: ${uniqueId}`)
for (const id of uniqueIds) {
await ragApplication.deleteLoader(id)
}
}
@TraceMethod({ spanName: 'RagSearch', tag: 'Knowledge' })
public async search(
_: Electron.IpcMainInvokeEvent,
{ search, base }: { search: string; base: KnowledgeBaseParams }
): Promise<KnowledgeSearchResult[]> {
const ragApplication = await this.getRagApplication(base)
return await ragApplication.search(search)
}
@TraceMethod({ spanName: 'rerank', tag: 'Knowledge' })
public async rerank(
_: Electron.IpcMainInvokeEvent,
{ search, base, results }: { search: string; base: KnowledgeBaseParams; results: KnowledgeSearchResult[] }
): Promise<KnowledgeSearchResult[]> {
if (results.length === 0) {
return results
}
return await new Reranker(base).rerank(search, results)
}
public getStorageDir = (): string => {
return this.storageDir
}
private preprocessing = async (
file: FileMetadata,
base: KnowledgeBaseParams,
item: KnowledgeItem,
userId: string
): Promise<FileMetadata> => {
let fileToProcess: FileMetadata = file
if (base.preprocessProvider && file.ext.toLowerCase() === '.pdf') {
try {
const provider = new PreprocessProvider(base.preprocessProvider.provider, userId)
const filePath = fileStorage.getFilePathById(file)
// Check if file has already been preprocessed
const alreadyProcessed = await provider.checkIfAlreadyProcessed(file)
if (alreadyProcessed) {
logger.debug(`File already preprocess processed, using cached result: ${filePath}`)
return alreadyProcessed
}
// Execute preprocessing
logger.debug(`Starting preprocess processing for scanned PDF: ${filePath}`)
const { processedFile, quota } = await provider.parseFile(item.id, file)
fileToProcess = processedFile
const mainWindow = windowService.getMainWindow()
mainWindow?.webContents.send('file-preprocess-finished', {
itemId: item.id,
quota: quota
})
} catch (err) {
logger.error(`Preprocess processing failed: ${err}`)
// If preprocessing fails, use original file
// fileToProcess = file
throw new Error(`Preprocess processing failed: ${err}`)
}
}
return fileToProcess
}
public checkQuota = async (
_: Electron.IpcMainInvokeEvent,
base: KnowledgeBaseParams,
userId: string
): Promise<number> => {
try {
if (base.preprocessProvider && base.preprocessProvider.type === 'preprocess') {
const provider = new PreprocessProvider(base.preprocessProvider.provider, userId)
return await provider.checkQuota()
}
throw new Error('No preprocess provider configured')
} catch (err) {
logger.error(`Failed to check quota: ${err}`)
throw new Error(`Failed to check quota: ${err}`)
}
}
}
export default new KnowledgeService()

View File

@ -235,7 +235,7 @@ class McpService {
try {
await inMemoryServer.connect(serverTransport)
getServerLogger(server).debug(`In-memory server started`)
} catch (error: Error | any) {
} catch (error: any) {
getServerLogger(server).error(`Error starting in-memory server`, error as Error)
throw new Error(`Failed to start in-memory server: ${error.message}`)
}
@ -419,7 +419,7 @@ class McpService {
const transport = await initTransport()
try {
await client.connect(transport)
} catch (error: Error | any) {
} catch (error: any) {
if (
error instanceof Error &&
(error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))
@ -852,7 +852,7 @@ class McpService {
return {
contents: contents
}
} catch (error: Error | any) {
} catch (error: any) {
getServerLogger(server, { uri }).error(`Failed to get resource`, error as Error)
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
}

View File

@ -5,7 +5,7 @@ export class MistralClientManager {
private static instance: MistralClientManager
private client: Mistral | null = null
// eslint-disable-next-line @typescript-eslint/no-empty-function
// oxlint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
public static getInstance(): MistralClientManager {

View File

@ -1,5 +1,5 @@
import { Notification } from '@types'
import { Notification as ElectronNotification } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import { windowService } from './WindowService'

View File

@ -235,7 +235,7 @@ export class ProxyManager {
https.request = this.bindHttpMethod(this.originalHttpsRequest, agent)
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
// oxlint-disable-next-line @typescript-eslint/no-unsafe-function-type
private bindHttpMethod(originalMethod: Function, agent: http.Agent | https.Agent) {
return (...args: any[]) => {
let url: string | URL | undefined

View File

@ -1,72 +0,0 @@
import { LoaderReturn } from '@shared/config/types'
import { KnowledgeBaseParams, KnowledgeItem, KnowledgeSearchResult } from '@types'
export interface KnowledgeBaseAddItemOptions {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload?: boolean
userId?: string
}
export interface KnowledgeBaseAddItemOptionsNonNullableAttribute {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload: boolean
userId: string
}
export interface EvaluateTaskWorkload {
workload: number
}
export type LoaderDoneReturn = LoaderReturn | null
export enum LoaderTaskItemState {
PENDING,
PROCESSING,
DONE
}
export interface LoaderTaskItem {
state: LoaderTaskItemState
task: () => Promise<unknown>
evaluateTaskWorkload: EvaluateTaskWorkload
}
export interface LoaderTask {
loaderTasks: LoaderTaskItem[]
loaderDoneReturn: LoaderDoneReturn
}
export interface LoaderTaskOfSet {
loaderTasks: Set<LoaderTaskItem>
loaderDoneReturn: LoaderDoneReturn
}
export interface QueueTaskItem {
taskPromise: () => Promise<unknown>
resolve: () => void
evaluateTaskWorkload: EvaluateTaskWorkload
}
export const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => {
return {
loaderTasks: new Set(loaderTask.loaderTasks),
loaderDoneReturn: loaderTask.loaderDoneReturn
}
}
export interface IKnowledgeFramework {
/** 为给定知识库初始化框架资源 */
initialize(base: KnowledgeBaseParams): Promise<void>
/** 重置知识库,删除其所有内容 */
reset(base: KnowledgeBaseParams): Promise<void>
/** 删除与知识库关联的资源,包括文件 */
delete(id: string): Promise<void>
/** 生成用于添加条目的任务对象,由队列处理 */
getLoaderTask(options: KnowledgeBaseAddItemOptionsNonNullableAttribute): LoaderTask
/** 从知识库中删除特定条目 */
remove(options: { uniqueIds: string[]; base: KnowledgeBaseParams }): Promise<void>
/** 搜索知识库 */
search(options: { search: string; base: KnowledgeBaseParams }): Promise<KnowledgeSearchResult[]>
}

View File

@ -1,48 +0,0 @@
import path from 'node:path'
import { KnowledgeBaseParams } from '@types'
import { app } from 'electron'
import { EmbedJsFramework } from './EmbedJsFramework'
import { IKnowledgeFramework } from './IKnowledgeFramework'
import { LangChainFramework } from './LangChainFramework'
class KnowledgeFrameworkFactory {
private static instance: KnowledgeFrameworkFactory
private frameworks: Map<string, IKnowledgeFramework> = new Map()
private storageDir: string
private constructor(storageDir: string) {
this.storageDir = storageDir
}
public static getInstance(storageDir: string): KnowledgeFrameworkFactory {
if (!KnowledgeFrameworkFactory.instance) {
KnowledgeFrameworkFactory.instance = new KnowledgeFrameworkFactory(storageDir)
}
return KnowledgeFrameworkFactory.instance
}
public getFramework(base: KnowledgeBaseParams): IKnowledgeFramework {
const frameworkType = base.framework || 'embedjs' // 如果未指定,默认为 embedjs
if (this.frameworks.has(frameworkType)) {
return this.frameworks.get(frameworkType)!
}
let framework: IKnowledgeFramework
switch (frameworkType) {
case 'langchain':
framework = new LangChainFramework(this.storageDir)
break
case 'embedjs':
default:
framework = new EmbedJsFramework(this.storageDir)
break
}
this.frameworks.set(frameworkType, framework)
return framework
}
}
export const knowledgeFrameworkFactory = KnowledgeFrameworkFactory.getInstance(
path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
)

View File

@ -1,190 +0,0 @@
import * as fs from 'node:fs'
import path from 'node:path'
import { loggerService } from '@logger'
import { preprocessingService } from '@main/knowledge/preprocess/PreprocessingService'
import Reranker from '@main/knowledge/reranker/Reranker'
import { TraceMethod } from '@mcp-trace/trace-core'
import { MB } from '@shared/config/constant'
import { LoaderReturn } from '@shared/config/types'
import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types'
import { app } from 'electron'
import {
KnowledgeBaseAddItemOptions,
LoaderTask,
loaderTaskIntoOfSet,
LoaderTaskItemState,
LoaderTaskOfSet,
QueueTaskItem
} from './IKnowledgeFramework'
import { knowledgeFrameworkFactory } from './KnowledgeFrameworkFactory'
const logger = loggerService.withContext('MainKnowledgeService')
class KnowledgeService {
private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
private workload = 0
private processingItemCount = 0
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
private static MAXIMUM_WORKLOAD = 80 * MB
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
private static ERROR_LOADER_RETURN: LoaderReturn = {
entriesAdded: 0,
uniqueId: '',
uniqueIds: [''],
loaderType: '',
status: 'failed'
}
constructor() {
this.initStorageDir()
}
private initStorageDir = (): void => {
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
}
private maximumLoad() {
return (
this.processingItemCount >= KnowledgeService.MAXIMUM_PROCESSING_ITEM_COUNT ||
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
)
}
private processingQueueHandle() {
const getSubtasksUntilMaximumLoad = (): QueueTaskItem[] => {
const queueTaskList: QueueTaskItem[] = []
that: for (const [task, resolve] of this.knowledgeItemProcessingQueueMappingPromise) {
for (const item of task.loaderTasks) {
if (this.maximumLoad()) {
break that
}
const { state, task: taskPromise, evaluateTaskWorkload } = item
if (state !== LoaderTaskItemState.PENDING) {
continue
}
const { workload } = evaluateTaskWorkload
this.workload += workload
this.processingItemCount += 1
item.state = LoaderTaskItemState.PROCESSING
queueTaskList.push({
taskPromise: () =>
taskPromise().then(() => {
this.workload -= workload
this.processingItemCount -= 1
task.loaderTasks.delete(item)
if (task.loaderTasks.size === 0) {
this.knowledgeItemProcessingQueueMappingPromise.delete(task)
resolve()
}
this.processingQueueHandle()
}),
resolve: () => {},
evaluateTaskWorkload
})
}
}
return queueTaskList
}
const subTasks = getSubtasksUntilMaximumLoad()
if (subTasks.length > 0) {
const subTaskPromises = subTasks.map(({ taskPromise }) => taskPromise())
Promise.all(subTaskPromises).then(() => {
subTasks.forEach(({ resolve }) => resolve())
})
}
}
private appendProcessingQueue(task: LoaderTask): Promise<LoaderReturn> {
return new Promise((resolve) => {
this.knowledgeItemProcessingQueueMappingPromise.set(loaderTaskIntoOfSet(task), () => {
resolve(task.loaderDoneReturn!)
})
})
}
public async create(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> {
logger.info(`Creating knowledge base: ${JSON.stringify(base)}`)
const framework = knowledgeFrameworkFactory.getFramework(base)
await framework.initialize(base)
}
public async reset(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> {
const framework = knowledgeFrameworkFactory.getFramework(base)
await framework.reset(base)
}
public async delete(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams, id: string): Promise<void> {
logger.info(`Deleting knowledge base: ${JSON.stringify(base)}`)
const framework = knowledgeFrameworkFactory.getFramework(base)
await framework.delete(id)
}
public add = async (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
logger.info(`Adding item to knowledge base: ${JSON.stringify(options)}`)
return new Promise((resolve) => {
const { base, item, forceReload = false, userId = '' } = options
const framework = knowledgeFrameworkFactory.getFramework(base)
const task = framework.getLoaderTask({ base, item, forceReload, userId })
if (task) {
this.appendProcessingQueue(task).then(() => {
resolve(task.loaderDoneReturn!)
})
this.processingQueueHandle()
} else {
resolve({
...KnowledgeService.ERROR_LOADER_RETURN,
message: 'Unsupported item type',
messageSource: 'embedding'
})
}
})
}
public async remove(
_: Electron.IpcMainInvokeEvent,
{ uniqueIds, base }: { uniqueIds: string[]; base: KnowledgeBaseParams }
): Promise<void> {
logger.info(`Removing items from knowledge base: ${JSON.stringify({ uniqueIds, base })}`)
const framework = knowledgeFrameworkFactory.getFramework(base)
await framework.remove({ uniqueIds, base })
}
public async search(
_: Electron.IpcMainInvokeEvent,
{ search, base }: { search: string; base: KnowledgeBaseParams }
): Promise<KnowledgeSearchResult[]> {
logger.info(`Searching knowledge base: ${JSON.stringify({ search, base })}`)
const framework = knowledgeFrameworkFactory.getFramework(base)
return framework.search({ search, base })
}
@TraceMethod({ spanName: 'rerank', tag: 'Knowledge' })
public async rerank(
_: Electron.IpcMainInvokeEvent,
{ search, base, results }: { search: string; base: KnowledgeBaseParams; results: KnowledgeSearchResult[] }
): Promise<KnowledgeSearchResult[]> {
logger.info(`Reranking knowledge base: ${JSON.stringify({ search, base, results })}`)
if (results.length === 0) {
return results
}
return await new Reranker(base).rerank(search, results)
}
public getStorageDir = (): string => {
return this.storageDir
}
public async checkQuota(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams, userId: string): Promise<number> {
return preprocessingService.checkQuota(base, userId)
}
}
export default new KnowledgeService()

View File

@ -1,557 +0,0 @@
import * as fs from 'node:fs'
import path from 'node:path'
import { FaissStore } from '@langchain/community/vectorstores/faiss'
import type { Document } from '@langchain/core/documents'
import { loggerService } from '@logger'
import TextEmbeddings from '@main/knowledge/langchain/embeddings/TextEmbeddings'
import {
addFileLoader,
addNoteLoader,
addSitemapLoader,
addVideoLoader,
addWebLoader
} from '@main/knowledge/langchain/loader'
import { RetrieverFactory } from '@main/knowledge/langchain/retriever'
import { preprocessingService } from '@main/knowledge/preprocess/PreprocessingService'
import { getAllFiles } from '@main/utils/file'
import { getUrlSource } from '@main/utils/knowledge'
import { MB } from '@shared/config/constant'
import { LoaderReturn } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import {
FileMetadata,
isKnowledgeDirectoryItem,
isKnowledgeFileItem,
isKnowledgeNoteItem,
isKnowledgeSitemapItem,
isKnowledgeUrlItem,
isKnowledgeVideoItem,
KnowledgeBaseParams,
KnowledgeSearchResult
} from '@types'
import { uuidv4 } from 'zod'
import { windowService } from '../WindowService'
import {
IKnowledgeFramework,
KnowledgeBaseAddItemOptionsNonNullableAttribute,
LoaderDoneReturn,
LoaderTask,
LoaderTaskItem,
LoaderTaskItemState
} from './IKnowledgeFramework'
const logger = loggerService.withContext('LangChainFramework')
export class LangChainFramework implements IKnowledgeFramework {
private storageDir: string
private static ERROR_LOADER_RETURN: LoaderReturn = {
entriesAdded: 0,
uniqueId: '',
uniqueIds: [''],
loaderType: '',
status: 'failed'
}
constructor(storageDir: string) {
this.storageDir = storageDir
this.initStorageDir()
}
private initStorageDir = (): void => {
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
}
private async createDatabase(base: KnowledgeBaseParams): Promise<void> {
const dbPath = path.join(this.storageDir, base.id)
const embeddings = this.getEmbeddings(base)
const vectorStore = new FaissStore(embeddings, {})
const mockDocument: Document = {
pageContent: 'Create Database Document',
metadata: {}
}
await vectorStore.addDocuments([mockDocument], { ids: ['1'] })
await vectorStore.save(dbPath)
await vectorStore.delete({ ids: ['1'] })
await vectorStore.save(dbPath)
}
private getEmbeddings(base: KnowledgeBaseParams): TextEmbeddings {
return new TextEmbeddings({
embedApiClient: base.embedApiClient,
dimensions: base.dimensions
})
}
private async getVectorStore(base: KnowledgeBaseParams): Promise<FaissStore> {
const embeddings = this.getEmbeddings(base)
const vectorStore = await FaissStore.load(path.join(this.storageDir, base.id), embeddings)
return vectorStore
}
async initialize(base: KnowledgeBaseParams): Promise<void> {
await this.createDatabase(base)
}
async reset(base: KnowledgeBaseParams): Promise<void> {
const dbPath = path.join(this.storageDir, base.id)
if (fs.existsSync(dbPath)) {
fs.rmSync(dbPath, { recursive: true })
}
// 立即重建空索引,避免随后加载时报错
await this.createDatabase(base)
}
async delete(id: string): Promise<void> {
const dbPath = path.join(this.storageDir, id)
if (fs.existsSync(dbPath)) {
fs.rmSync(dbPath, { recursive: true })
}
}
getLoaderTask(options: KnowledgeBaseAddItemOptionsNonNullableAttribute): LoaderTask {
const { item } = options
const getStore = () => this.getVectorStore(options.base)
switch (item.type) {
case 'file':
return this.fileTask(getStore, options)
case 'directory':
return this.directoryTask(getStore, options)
case 'url':
return this.urlTask(getStore, options)
case 'sitemap':
return this.sitemapTask(getStore, options)
case 'note':
return this.noteTask(getStore, options)
case 'video':
return this.videoTask(getStore, options)
default:
return {
loaderTasks: [],
loaderDoneReturn: null
}
}
}
async remove(options: { uniqueIds: string[]; base: KnowledgeBaseParams }): Promise<void> {
const { uniqueIds, base } = options
const vectorStore = await this.getVectorStore(base)
logger.info(`[ KnowledgeService Remove Item UniqueIds: ${uniqueIds}]`)
await vectorStore.delete({ ids: uniqueIds })
await vectorStore.save(path.join(this.storageDir, base.id))
}
async search(options: { search: string; base: KnowledgeBaseParams }): Promise<KnowledgeSearchResult[]> {
const { search, base } = options
logger.info(`search base: ${JSON.stringify(base)}`)
try {
const vectorStore = await this.getVectorStore(base)
// 如果是 bm25 或 hybrid 模式,则从数据库获取所有文档
const documents: Document[] = await this.getAllDocuments(base)
if (documents.length === 0) return []
const retrieverFactory = new RetrieverFactory()
const retriever = retrieverFactory.createRetriever(base, vectorStore, documents)
const results = await retriever.invoke(search)
logger.info(`Search Results: ${JSON.stringify(results)}`)
// VectorStoreRetriever 和 EnsembleRetriever 会将分数附加到 metadata.score
// BM25Retriever 默认不返回分数,所以我们需要处理这种情况
return results.map((item) => {
return {
pageContent: item.pageContent,
metadata: item.metadata,
// 如果 metadata 中没有 score提供一个默认值
score: typeof item.metadata.score === 'number' ? item.metadata.score : 0
}
})
} catch (error: any) {
logger.error(`Error during search in knowledge base ${base.id}: ${error.message}`)
return []
}
}
private fileTask(
getVectorStore: () => Promise<FaissStore>,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item, userId } = options
if (!isKnowledgeFileItem(item)) {
logger.error(`Invalid item type for fileTask: expected 'file', got '${item.type}'`)
return {
loaderTasks: [],
loaderDoneReturn: {
...LangChainFramework.ERROR_LOADER_RETURN,
message: `Invalid item type: expected 'file', got '${item.type}'`,
messageSource: 'validation'
}
}
}
const file = item.content
const loaderTask: LoaderTask = {
loaderTasks: [
{
state: LoaderTaskItemState.PENDING,
task: async () => {
try {
const vectorStore = await getVectorStore()
// 添加预处理逻辑
const fileToProcess: FileMetadata = await preprocessingService.preprocessFile(file, base, item, userId)
// 使用处理后的文件进行加载
return addFileLoader(base, vectorStore, fileToProcess)
.then((result) => {
loaderTask.loaderDoneReturn = result
return result
})
.then(async () => {
await vectorStore.save(path.join(this.storageDir, base.id))
})
.catch((e) => {
logger.error(`Error in addFileLoader for ${file.name}: ${e}`)
const errorResult: LoaderReturn = {
...LangChainFramework.ERROR_LOADER_RETURN,
message: e.message,
messageSource: 'embedding'
}
loaderTask.loaderDoneReturn = errorResult
return errorResult
})
} catch (e: any) {
logger.error(`Preprocessing failed for ${file.name}: ${e}`)
const errorResult: LoaderReturn = {
...LangChainFramework.ERROR_LOADER_RETURN,
message: e.message,
messageSource: 'preprocess'
}
loaderTask.loaderDoneReturn = errorResult
return errorResult
}
},
evaluateTaskWorkload: { workload: file.size }
}
],
loaderDoneReturn: null
}
return loaderTask
}
private directoryTask(
getVectorStore: () => Promise<FaissStore>,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item } = options
if (!isKnowledgeDirectoryItem(item)) {
logger.error(`Invalid item type for directoryTask: expected 'directory', got '${item.type}'`)
return {
loaderTasks: [],
loaderDoneReturn: {
...LangChainFramework.ERROR_LOADER_RETURN,
message: `Invalid item type: expected 'directory', got '${item.type}'`,
messageSource: 'validation'
}
}
}
const directory = item.content
const files = getAllFiles(directory)
const totalFiles = files.length
let processedFiles = 0
const sendDirectoryProcessingPercent = (totalFiles: number, processedFiles: number) => {
const mainWindow = windowService.getMainWindow()
mainWindow?.webContents.send(IpcChannel.DirectoryProcessingPercent, {
itemId: item.id,
percent: (processedFiles / totalFiles) * 100
})
}
const loaderDoneReturn: LoaderDoneReturn = {
entriesAdded: 0,
uniqueId: `DirectoryLoader_${uuidv4()}`,
uniqueIds: [],
loaderType: 'DirectoryLoader'
}
const loaderTasks: LoaderTaskItem[] = []
for (const file of files) {
loaderTasks.push({
state: LoaderTaskItemState.PENDING,
task: async () => {
const vectorStore = await getVectorStore()
return addFileLoader(base, vectorStore, file)
.then((result) => {
loaderDoneReturn.entriesAdded += 1
processedFiles += 1
sendDirectoryProcessingPercent(totalFiles, processedFiles)
loaderDoneReturn.uniqueIds.push(result.uniqueId)
return result
})
.then(async () => {
await vectorStore.save(path.join(this.storageDir, base.id))
})
.catch((err) => {
logger.error(err)
return {
...LangChainFramework.ERROR_LOADER_RETURN,
message: `Failed to add dir loader: ${err.message}`,
messageSource: 'embedding'
}
})
},
evaluateTaskWorkload: { workload: file.size }
})
}
return {
loaderTasks,
loaderDoneReturn
}
}
private urlTask(
getVectorStore: () => Promise<FaissStore>,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item } = options
if (!isKnowledgeUrlItem(item)) {
logger.error(`Invalid item type for urlTask: expected 'url', got '${item.type}'`)
return {
loaderTasks: [],
loaderDoneReturn: {
...LangChainFramework.ERROR_LOADER_RETURN,
message: `Invalid item type: expected 'url', got '${item.type}'`,
messageSource: 'validation'
}
}
}
const url = item.content
const loaderTask: LoaderTask = {
loaderTasks: [
{
state: LoaderTaskItemState.PENDING,
task: async () => {
// 使用处理后的网页进行加载
const vectorStore = await getVectorStore()
return addWebLoader(base, vectorStore, url, getUrlSource(url))
.then((result) => {
loaderTask.loaderDoneReturn = result
return result
})
.then(async () => {
await vectorStore.save(path.join(this.storageDir, base.id))
})
.catch((e) => {
logger.error(`Error in addWebLoader for ${url}: ${e}`)
const errorResult: LoaderReturn = {
...LangChainFramework.ERROR_LOADER_RETURN,
message: e.message,
messageSource: 'embedding'
}
loaderTask.loaderDoneReturn = errorResult
return errorResult
})
},
evaluateTaskWorkload: { workload: 2 * MB }
}
],
loaderDoneReturn: null
}
return loaderTask
}
private sitemapTask(
getVectorStore: () => Promise<FaissStore>,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item } = options
if (!isKnowledgeSitemapItem(item)) {
logger.error(`Invalid item type for sitemapTask: expected 'sitemap', got '${item.type}'`)
return {
loaderTasks: [],
loaderDoneReturn: {
...LangChainFramework.ERROR_LOADER_RETURN,
message: `Invalid item type: expected 'sitemap', got '${item.type}'`,
messageSource: 'validation'
}
}
}
const url = item.content
const loaderTask: LoaderTask = {
loaderTasks: [
{
state: LoaderTaskItemState.PENDING,
task: async () => {
// 使用处理后的网页进行加载
const vectorStore = await getVectorStore()
return addSitemapLoader(base, vectorStore, url)
.then((result) => {
loaderTask.loaderDoneReturn = result
return result
})
.then(async () => {
await vectorStore.save(path.join(this.storageDir, base.id))
})
.catch((e) => {
logger.error(`Error in addWebLoader for ${url}: ${e}`)
const errorResult: LoaderReturn = {
...LangChainFramework.ERROR_LOADER_RETURN,
message: e.message,
messageSource: 'embedding'
}
loaderTask.loaderDoneReturn = errorResult
return errorResult
})
},
evaluateTaskWorkload: { workload: 2 * MB }
}
],
loaderDoneReturn: null
}
return loaderTask
}
private noteTask(
getVectorStore: () => Promise<FaissStore>,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item } = options
if (!isKnowledgeNoteItem(item)) {
logger.error(`Invalid item type for noteTask: expected 'note', got '${item.type}'`)
return {
loaderTasks: [],
loaderDoneReturn: {
...LangChainFramework.ERROR_LOADER_RETURN,
message: `Invalid item type: expected 'note', got '${item.type}'`,
messageSource: 'validation'
}
}
}
const content = item.content
const sourceUrl = item.sourceUrl ?? ''
logger.info(`noteTask ${content}, ${sourceUrl}`)
const encoder = new TextEncoder()
const contentBytes = encoder.encode(content)
const loaderTask: LoaderTask = {
loaderTasks: [
{
state: LoaderTaskItemState.PENDING,
task: async () => {
// 使用处理后的笔记进行加载
const vectorStore = await getVectorStore()
return addNoteLoader(base, vectorStore, content, sourceUrl)
.then((result) => {
loaderTask.loaderDoneReturn = result
return result
})
.then(async () => {
await vectorStore.save(path.join(this.storageDir, base.id))
})
.catch((e) => {
logger.error(`Error in addNoteLoader for ${sourceUrl}: ${e}`)
const errorResult: LoaderReturn = {
...LangChainFramework.ERROR_LOADER_RETURN,
message: e.message,
messageSource: 'embedding'
}
loaderTask.loaderDoneReturn = errorResult
return errorResult
})
},
evaluateTaskWorkload: { workload: contentBytes.length }
}
],
loaderDoneReturn: null
}
return loaderTask
}
private videoTask(
getVectorStore: () => Promise<FaissStore>,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item } = options
if (!isKnowledgeVideoItem(item)) {
logger.error(`Invalid item type for videoTask: expected 'video', got '${item.type}'`)
return {
loaderTasks: [],
loaderDoneReturn: {
...LangChainFramework.ERROR_LOADER_RETURN,
message: `Invalid item type: expected 'video', got '${item.type}'`,
messageSource: 'validation'
}
}
}
const files = item.content
const loaderTask: LoaderTask = {
loaderTasks: [
{
state: LoaderTaskItemState.PENDING,
task: async () => {
const vectorStore = await getVectorStore()
return addVideoLoader(base, vectorStore, files)
.then((result) => {
loaderTask.loaderDoneReturn = result
return result
})
.then(async () => {
await vectorStore.save(path.join(this.storageDir, base.id))
})
.catch((e) => {
logger.error(`Preprocessing failed for ${files[0].name}: ${e}`)
const errorResult: LoaderReturn = {
...LangChainFramework.ERROR_LOADER_RETURN,
message: e.message,
messageSource: 'preprocess'
}
loaderTask.loaderDoneReturn = errorResult
return errorResult
})
},
evaluateTaskWorkload: { workload: files[0].size }
}
],
loaderDoneReturn: null
}
return loaderTask
}
private async getAllDocuments(base: KnowledgeBaseParams): Promise<Document[]> {
logger.info(`Fetching all documents from database for knowledge base: ${base.id}`)
try {
const results = (await this.getVectorStore(base)).docstore._docs
const documents: Document[] = Array.from(results.values())
logger.info(`Fetched ${documents.length} documents for BM25/Hybrid retriever.`)
return documents
} catch (e) {
logger.error(`Could not fetch documents from database for base ${base.id}: ${e}`)
// 如果表不存在或查询失败,返回空数组
return []
}
}
}

View File

@ -1,13 +1,7 @@
import { isLinux, isWin } from '@main/constant'
import { loadOcrImage } from '@main/utils/ocr'
import { OcrAccuracy, recognize } from '@napi-rs/system-ocr'
import {
ImageFileMetadata,
isImageFileMetadata as isImageFileMetadata,
OcrResult,
OcrSystemConfig,
SupportedOcrFile
} from '@types'
import { ImageFileMetadata, isImageFileMetadata, OcrResult, OcrSystemConfig, SupportedOcrFile } from '@types'
import { OcrBaseService } from './OcrBaseService'

View File

@ -9,7 +9,7 @@ export class FileServiceManager {
private static instance: FileServiceManager
private services: Map<string, BaseFileService> = new Map()
// eslint-disable-next-line @typescript-eslint/no-empty-function
// oxlint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
static getInstance(): FileServiceManager {

View File

@ -420,7 +420,7 @@ export function sanitizeFilename(fileName: string, replacement = '_'): string {
// 移除或替换非法字符
let sanitized = fileName
// eslint-disable-next-line no-control-regex
// oxlint-disable-next-line no-control-regex
.replace(/[<>:"/\\|?*\x00-\x1f]/g, replacement) // Windows 非法字符
.replace(/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i, replacement + '$2') // Windows 保留名
.replace(/[\s.]+$/, '') // 移除末尾的空格和点

View File

@ -36,13 +36,14 @@ export function debounce(func: (...args: any[]) => void, wait: number, immediate
}
}
export function dumpPersistState() {
const persistState = JSON.parse(localStorage.getItem('persist:cherry-studio') || '{}')
for (const key in persistState) {
persistState[key] = JSON.parse(persistState[key])
}
return JSON.stringify(persistState)
}
// NOTE: It's an unused function. localStorage should not be accessed in main process.
// export function dumpPersistState() {
// const persistState = JSON.parse(localStorage.getItem('persist:cherry-studio') || '{}')
// for (const key in persistState) {
// persistState[key] = JSON.parse(persistState[key])
// }
// return JSON.stringify(persistState)
// }
export const runAsyncFunction = async (fn: () => void) => {
await fn()

View File

@ -5,6 +5,7 @@ import { UpgradeChannel } from '@shared/config/constant'
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
import type { FileChangeEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import type { Notification } from '@types'
import {
AddMemoryOptions,
AssistantMessage,
@ -28,7 +29,6 @@ import {
WebDavConfig
} from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import { CreateDirectoryOptions } from 'webdav'
import type { ActionItem } from '../renderer/src/types/selectionTypes'
@ -475,13 +475,10 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
// eslint-disable-next-line no-restricted-syntax
console.error('[Preload]Failed to expose APIs:', error as Error)
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI
// @ts-ignore (define in dts)
window.api = api
}

View File

@ -1,6 +1,5 @@
import '@renderer/databases'
import { HeroUIProvider } from '@heroui/react'
import { loggerService } from '@logger'
import store, { persistor } from '@renderer/store'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@ -11,6 +10,7 @@ import { ToastPortal } from './components/ToastPortal'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
import { HeroUIProvider } from './context/HeroUIProvider'
import { NotificationProvider } from './context/NotificationProvider'
import StyleSheetManager from './context/StyleSheetManager'
import { ThemeProvider } from './context/ThemeProvider'
@ -34,7 +34,7 @@ function App(): React.ReactElement {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<HeroUIProvider className="flex h-full w-full flex-1">
<HeroUIProvider>
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>

View File

@ -314,7 +314,7 @@ export class AiSdkToChunkAdapter {
// === 源和文件相关事件 ===
case 'source':
if (chunk.sourceType === 'url') {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
const { sourceType: _, ...rest } = chunk
final.webSearchResults.push(rest)
}

View File

@ -84,7 +84,7 @@ export abstract class BaseApiClient<
* instanceof检查的类型收窄问题
* AihubmixAPIClient使
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
public getClientCompatibilityType(_model?: Model): string[] {
// 默认返回类的名称
return [this.constructor.name]

View File

@ -177,7 +177,7 @@ export class AnthropicAPIClient extends BaseApiClient<
}
// @ts-ignore sdk未提供
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
override async generateImage(generateImageParams: GenerateImageParams): Promise<string[]> {
return []
}

View File

@ -455,7 +455,7 @@ export class AwsBedrockAPIClient extends BaseApiClient<
}
// @ts-ignore sdk未提供
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
override async generateImage(_generateImageParams: GenerateImageParams): Promise<string[]> {
return []
}

View File

@ -11,7 +11,7 @@ export class PPIOAPIClient extends OpenAIAPIClient {
super(provider)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
override getClientCompatibilityType(_model?: Model): string[] {
return ['OpenAIAPIClient']
}

View File

@ -44,7 +44,7 @@ const stringifyArgsForLogging = (args: any[]): string => {
*/
export const createGenericLoggingMiddleware: () => MethodMiddleware = () => {
const middlewareName = 'GenericLoggingMiddleware'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
return (_: MiddlewareAPI<BaseContext, any[]>) => (next) => async (ctx, args) => {
const methodName = ctx.methodName
const logPrefix = `[${middlewareName} (${methodName})]`

View File

@ -66,6 +66,7 @@ class AdapterTracer {
spanName: name,
topicId: this.topicId,
modelName: this.modelName,
// oxlint-disable-next-line no-undef False alarm. see https://github.com/oxc-project/oxc/issues/4232
argCount: arguments.length
})

View File

@ -18,7 +18,7 @@ import {
import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService'
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import type { ModelMessage } from 'ai'
import type { ModelMessage, Tool } from 'ai'
import { stepCountIs } from 'ai'
import { getAiSdkProviderId } from '../provider/factory'
@ -29,6 +29,8 @@ import { getTemperature, getTopP } from './modelParameters'
const logger = loggerService.withContext('parameterBuilder')
type ProviderDefinedTool = Extract<Tool<any, any>, { type: 'provider-defined' }>
/**
* AI SDK
*
@ -113,9 +115,9 @@ export async function buildStreamTextParams(
tools = {}
}
if (aiSdkProviderId === 'google-vertex') {
tools.google_search = vertex.tools.googleSearch({})
tools.google_search = vertex.tools.googleSearch({}) as ProviderDefinedTool
} else if (aiSdkProviderId === 'google-vertex-anthropic') {
tools.web_search = vertexAnthropic.tools.webSearch_20250305({})
tools.web_search = vertexAnthropic.tools.webSearch_20250305({}) as ProviderDefinedTool
}
}
@ -124,7 +126,7 @@ export async function buildStreamTextParams(
if (!tools) {
tools = {}
}
tools.url_context = vertex.tools.urlContext({})
tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool
}
// 构建基础参数

View File

@ -9,7 +9,7 @@ import { JSONSchema7 } from 'json-schema'
const logger = loggerService.withContext('MCP-utils')
// Setup tools configuration based on provided parameters
export function setupToolsConfig(mcpTools?: MCPTool[]): Record<string, Tool> | undefined {
export function setupToolsConfig(mcpTools?: MCPTool[]): Record<string, Tool<any, any>> | undefined {
let tools: ToolSet = {}
if (!mcpTools?.length) {

View File

@ -1,6 +1,5 @@
.command-list-popover {
/* Base styles are handled inline for theme support */
/* Arrow styles based on placement */
}

View File

@ -121,6 +121,7 @@
border-radius: 5px;
word-break: keep-all;
white-space: pre;
text-wrap: wrap;
}
.markdown code {

View File

@ -53,6 +53,7 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--icon: #00000099;
}
.dark {
@ -87,6 +88,7 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--icon: #ffffff99;
}
@theme inline {
@ -128,6 +130,7 @@
--color-sidebar-ring: var(--sidebar-ring);
--animate-marquee: marquee var(--duration) infinite linear;
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
--color-icon: var(--icon);
@keyframes marquee {
from {
transform: translateX(0);

View File

@ -0,0 +1,30 @@
import { cn } from '@heroui/react'
import { Button, ButtonProps } from 'antd'
import React, { memo } from 'react'
interface ActionIconButtonProps extends ButtonProps {
children: React.ReactNode
active?: boolean
}
/**
* A simple action button rendered as an icon
*/
const ActionIconButton: React.FC<ActionIconButtonProps> = ({ children, active = false, className, ...props }) => {
return (
<Button
type="text"
shape="circle"
className={cn(
'flex h-[30px] w-[30px] cursor-pointer flex-row items-center justify-center border-none p-0 text-base transition-all duration-300 ease-in-out [&_.anticon]:text-icon [&_.icon-a-addchat]:mb-[-2px] [&_.icon-a-addchat]:text-lg [&_.icon]:text-icon [&_.iconfont]:text-icon [&_.lucide]:text-icon',
active &&
'[&_.anticon]:text-primary! [&_.icon]:text-primary! [&_.iconfont]:text-primary! [&_.lucide]:text-primary!',
className
)}
{...props}>
{children}
</Button>
)
}
export default memo(ActionIconButton)

View File

@ -0,0 +1 @@
export { default as ActionIconButton } from './ActionIconButton'

View File

@ -1,4 +1,4 @@
import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar'
import { ActionIconButton } from '@renderer/components/Buttons'
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
import { Tooltip } from 'antd'
import { debounce } from 'lodash'
@ -364,23 +364,23 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
<ToolBar>
{showUserToggle && (
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
<ActionIconButton onClick={userOutlinedButtonOnClick}>
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</ActionIconButton>
</Tooltip>
)}
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
<ActionIconButton onClick={caseSensitiveButtonOnClick}>
<CaseSensitive
size={18}
style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }}
/>
</ToolbarButton>
</ActionIconButton>
</Tooltip>
<Tooltip title={t('button.whole_word')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={wholeWordButtonOnClick}>
<ActionIconButton onClick={wholeWordButtonOnClick}>
<WholeWord size={18} style={{ color: isWholeWord ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</ActionIconButton>
</Tooltip>
</ToolBar>
</InputWrapper>
@ -397,15 +397,15 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
)}
</SearchResults>
<ToolBar>
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={allRanges.length === 0}>
<ActionIconButton onClick={prevButtonOnClick} disabled={allRanges.length === 0}>
<ChevronUp size={18} />
</ToolbarButton>
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={allRanges.length === 0}>
</ActionIconButton>
<ActionIconButton onClick={nextButtonOnClick} disabled={allRanges.length === 0}>
<ChevronDown size={18} />
</ToolbarButton>
<ToolbarButton type="text" onClick={closeButtonOnClick}>
</ActionIconButton>
<ActionIconButton onClick={closeButtonOnClick}>
<X size={18} />
</ToolbarButton>
</ActionIconButton>
</ToolBar>
</SearchBarContainer>
</NarrowLayout>

View File

@ -12,7 +12,7 @@ import {
} from '@ant-design/icons'
import { loggerService } from '@logger'
import WindowControls from '@renderer/components/WindowControls'
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { isDev, isLinux, isMac, isWin } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useBridge } from '@renderer/hooks/useBridge'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
@ -170,8 +170,6 @@ const MinappPopupContainer: React.FC = () => {
const { isLeftNavbar } = useNavbarPosition()
const isInDevelopment = process.env.NODE_ENV === 'development'
const { setTimeoutTimer } = useTimer()
useBridge()
@ -477,7 +475,7 @@ const MinappPopupContainer: React.FC = () => {
<LinkOutlined />
</TitleButton>
</Tooltip>
{isInDevelopment && (
{isDev && (
<Tooltip title={t('minapp.popup.devtools')} mouseEnterDelay={0.8} placement="bottom">
<TitleButton onClick={() => handleOpenDevTools(appInfo.id)}>
<CodeOutlined />

View File

@ -36,7 +36,7 @@ export const OGCard = ({ link, show }: Props) => {
const GeneratedGraph = useCallback(() => {
return (
<div className="flex h-36 items-center justify-center bg-accent p-4">
<h2 className="text-2xl font-bold">{metadata['og:title'] || hostname}</h2>
<h2 className="font-bold text-2xl">{metadata['og:title'] || hostname}</h2>
</div>
)
}, [hostname, metadata])

View File

@ -1,5 +1,18 @@
import React from 'react'
export enum QuickPanelReservedSymbol {
Root = '/',
File = 'file',
KnowledgeBase = '#',
MentionModels = '@',
QuickPhrases = 'quick-phrases',
Thinking = 'thinking',
WebSearch = '?',
Mcp = 'mcp',
McpPrompt = 'mcp-prompt',
McpResource = 'mcp-resource'
}
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
export type QuickPanelTriggerInfo = {
type: 'input' | 'button'

View File

@ -341,11 +341,12 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
scrollTriggerRef.current = 'none'
}, [index])
// 处理键盘事件(折叠时不拦截全局键盘)
// 处理键盘事件:
// - 可见且未折叠时:拦截 Enter 及其组合键(纯 Enter 选择项;带修饰键仅拦截不处理)。
// - 软隐藏/折叠时:不拦截 Enter允许输入框处理用于发送消息等
// - 不可见时:不拦截,输入框按常规处理。
useEffect(() => {
const hasSearchTextFlag = searchText.replace(/^[/@]/, '').length > 0
const isCollapsed = hasSearchTextFlag && list.length === 0
if (!ctx.isVisible || isCollapsed) return
if (!ctx.isVisible) return
const handleKeyDown = (e: KeyboardEvent) => {
if (isMac ? e.metaKey : e.ctrlKey) {
@ -438,9 +439,24 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
break
case 'Enter':
case 'NumpadEnter':
case 'NumpadEnter': {
if (isComposing.current) return
// 折叠/软隐藏时不拦截,让输入框处理(用于发送消息)
const hasSearch = searchText.replace(/^[/@]/, '').length > 0
const nonPinnedCount = list.filter((i) => !i.alwaysVisible).length
const isCollapsed = hasSearch && nonPinnedCount === 0
if (isCollapsed) return
// 面板可见且未折叠时:拦截所有 Enter 变体;
// 纯 Enter 选择项,带修饰键仅拦截不处理
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
e.preventDefault()
e.stopPropagation()
setIsMouseOver(false)
break
}
if (list?.[index]) {
e.preventDefault()
e.stopPropagation()
@ -451,6 +467,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
handleClose('enter_empty')
}
break
}
case 'Escape':
e.stopPropagation()
handleClose('esc')

View File

@ -43,7 +43,7 @@ const CustomTag: FC<CustomTagProps> = ({
...(disabled && { cursor: 'not-allowed' }),
...style
}}>
{icon && icon} {children}
{icon} {children}
{closable && (
<CloseIcon
$size={size}

View File

@ -0,0 +1,13 @@
import { HeroUIProvider } from '@heroui/react'
import { useSettings } from '@renderer/hooks/useSettings'
const AppHeroUIProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { language } = useSettings()
return (
<HeroUIProvider className="flex h-full w-full flex-1" locale={language}>
{children}
</HeroUIProvider>
)
}
export { AppHeroUIProvider as HeroUIProvider }

View File

@ -31,7 +31,7 @@ const tailwindThemeChange = (theme: ThemeMode) => {
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
// 用户设置的主题
const { theme: settedTheme, setTheme: setSettedTheme } = useSettings()
const { theme: settedTheme, setTheme: setSettedTheme, language } = useSettings()
const [actualTheme, setActualTheme] = useState<ThemeMode>(
window.matchMedia('(prefers-color-scheme: dark)').matches ? ThemeMode.dark : ThemeMode.light
)
@ -59,6 +59,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
document.body.classList.add('light')
}
document.body.setAttribute('navbar-position', navbarPosition)
document.documentElement.lang = language
// if theme is old auto, then set theme to system
// we can delete this after next big release
@ -73,7 +74,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
document.body.setAttribute('theme-mode', actualTheme)
setActualTheme(actualTheme)
})
}, [actualTheme, initUserTheme, navbarPosition, setSettedTheme, settedTheme])
}, [actualTheme, initUserTheme, language, navbarPosition, setSettedTheme, settedTheme])
useEffect(() => {
tailwindThemeChange(actualTheme)

View File

@ -172,7 +172,7 @@ export function useAssistant(id: string) {
(model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })),
[assistant, dispatch]
),
updateAssistant: (assistant: Assistant) => dispatch(updateAssistant(assistant)),
updateAssistant: useCallback((assistant: Partial<Assistant>) => dispatch(updateAssistant(assistant)), [dispatch]),
updateAssistantSettings
}
}

View File

@ -24,7 +24,6 @@ import {
KnowledgeBase,
KnowledgeItem,
KnowledgeNoteItem,
MigrationModeEnum,
ProcessingStatus
} from '@renderer/types'
import { runAsyncFunction, uuid } from '@renderer/utils'
@ -231,7 +230,7 @@ export const useKnowledge = (baseId: string) => {
}
// 迁移知识库(保留原知识库)
const migrateBase = async (newBase: KnowledgeBase, mode: MigrationModeEnum) => {
const migrateBase = async (newBase: KnowledgeBase) => {
if (!base) return
const timestamp = dayjs().format('YYMMDDHHmmss')
@ -244,14 +243,9 @@ export const useKnowledge = (baseId: string) => {
name: newName,
created_at: Date.now(),
updated_at: Date.now(),
items: [],
framework: mode === MigrationModeEnum.MigrationToLangChain ? 'langchain' : base.framework
items: []
} satisfies KnowledgeBase
if (mode === MigrationModeEnum.MigrationToLangChain) {
await window.api.knowledgeBase.create(getKnowledgeBaseParams(migratedBase))
}
dispatch(addBase(migratedBase))
const files: FileMetadata[] = []

View File

@ -14,11 +14,7 @@ const createInitialKnowledgeBase = (): KnowledgeBase => ({
items: [],
created_at: Date.now(),
updated_at: Date.now(),
version: 1,
framework: 'langchain',
retriever: {
mode: 'hybrid'
}
version: 1
})
/**

View File

@ -381,8 +381,9 @@
"translate": "Translate to {{target_language}}",
"translating": "Translating...",
"upload": {
"attachment": "Upload attachment",
"document": "Upload document file (model does not support images)",
"label": "Upload image or document file",
"image_or_document": "Upload image or document file",
"upload_from_local": "Upload local file..."
},
"url_context": "URL Context",
@ -1098,10 +1099,6 @@
"error": {
"failed": "Migration failed"
},
"migrate_to_langchain": {
"content": "The knowledge base migration does not delete the old knowledge base but creates a copy and reprocesses all entries, which may consume a significant number of tokens. Please proceed with caution.",
"info": "The knowledge base architecture has been updated. Click to migrate to the new architecture."
},
"source_dimensions": "Source Dimensions",
"source_model": "Source Model",
"target_dimensions": "Target Dimensions",
@ -1120,20 +1117,6 @@
"quota": "{{name}} Left Quota: {{quota}}",
"quota_infinity": "{{name}} Quota: Unlimited",
"rename": "Rename",
"retriever": "Retrieve mode",
"retriever_bm25": "full-text search",
"retriever_bm25_desc": "Search for documents based on keyword relevance and frequency.",
"retriever_hybrid": "Hybrid Search (Recommended)",
"retriever_hybrid_desc": "Combine keyword search and semantic search to achieve optimal retrieval accuracy.",
"retriever_hybrid_weight": {
"bm25": "full text",
"recommended": "recommend",
"title": "Hybrid Search Weight Adjustment (Full-text/Vector)",
"vector": "vector"
},
"retriever_tooltip": "Using different retrieval methods to search the knowledge base",
"retriever_vector": "vector search",
"retriever_vector_desc": "Retrieve documents based on semantic similarity and meaning.",
"search": "Search knowledge base",
"search_placeholder": "Enter text to search",
"settings": {

View File

@ -381,8 +381,9 @@
"translate": "翻译成 {{target_language}}",
"translating": "翻译中...",
"upload": {
"attachment": "上传附件",
"document": "上传文档(模型不支持图片)",
"label": "上传图片或文档",
"image_or_document": "上传图片或文档",
"upload_from_local": "上传本地文件..."
},
"url_context": "网页上下文",
@ -1098,10 +1099,6 @@
"error": {
"failed": "迁移失败"
},
"migrate_to_langchain": {
"content": "知识库迁移不会删除旧知识库,而是创建一个副本之后重新处理所有知识库条目,可能消耗大量 tokens请谨慎操作。",
"info": "知识库架构已更新,点击迁移到新架构"
},
"source_dimensions": "源维度",
"source_model": "源模型",
"target_dimensions": "目标维度",
@ -1120,20 +1117,6 @@
"quota": "{{name}} 剩余额度:{{quota}}",
"quota_infinity": "{{name}} 剩余额度:无限制",
"rename": "重命名",
"retriever": "检索模式",
"retriever_bm25": "全文搜索",
"retriever_bm25_desc": "根据关键字的相关性和频率查找文档。",
"retriever_hybrid": "混合搜索 (推荐)",
"retriever_hybrid_desc": "结合关键词搜索和语义搜索,以实现最佳检索准确性。",
"retriever_hybrid_weight": {
"bm25": "全文",
"recommended": "推荐",
"title": "混合搜索权重调整 (全文/向量)",
"vector": "向量"
},
"retriever_tooltip": "使用不同的检索方式检索知识库",
"retriever_vector": "向量搜索",
"retriever_vector_desc": "根据语义相似性和含义查找文档。",
"search": "搜索知识库",
"search_placeholder": "输入查询内容",
"settings": {

View File

@ -381,8 +381,9 @@
"translate": "翻譯成 {{target_language}}",
"translating": "翻譯中...",
"upload": {
"attachment": "上傳附件",
"document": "上傳文件(模型不支援圖片)",
"label": "上傳圖片或文件",
"image_or_document": "上傳圖片或文件",
"upload_from_local": "上傳本地文件..."
},
"url_context": "網頁上下文",
@ -1098,10 +1099,6 @@
"error": {
"failed": "遷移失敗"
},
"migrate_to_langchain": {
"content": "知識庫遷移不會刪除舊知識庫,而是建立一個副本後重新處理所有知識庫條目,可能消耗大量 tokens請謹慎操作。",
"info": "知識庫架構已更新,點擊遷移到新架構"
},
"source_dimensions": "源維度",
"source_model": "源模型",
"target_dimensions": "目標維度",
@ -1120,20 +1117,6 @@
"quota": "{{name}} 剩餘配額:{{quota}}",
"quota_infinity": "{{name}} 配額:無限制",
"rename": "重新命名",
"retriever": "搜尋模式",
"retriever_bm25": "全文搜尋",
"retriever_bm25_desc": "根據關鍵字的相關性和頻率查找文件。",
"retriever_hybrid": "混合搜尋(推薦)",
"retriever_hybrid_desc": "結合關鍵字搜索和語義搜索,以實現最佳檢索準確性。",
"retriever_hybrid_weight": {
"bm25": "全文",
"recommended": "推薦",
"title": "混合搜尋權重調整 (全文/向量)",
"vector": "向量"
},
"retriever_tooltip": "使用不同的檢索方式檢索知識庫",
"retriever_vector": "向量搜尋",
"retriever_vector_desc": "根據語意相似性和含義查找文件。",
"search": "搜尋知識庫",
"search_placeholder": "輸入查詢內容",
"settings": {

View File

@ -1098,10 +1098,6 @@
"error": {
"failed": "Αποτυχία μεταφοράς"
},
"migrate_to_langchain": {
"content": "Η μετανάστευση της βάσης γνώσεων δεν διαγράφει την παλιά βάση γνώσεων, αλλά δημιουργεί ένα αντίγραφο και στη συνέχεια επεξεργάζεται ξανά όλες τις εγγραφές της βάσης γνώσεων, κάτι που μπορεί να καταναλώσει μεγάλο αριθμό tokens, οπότε ενεργήστε με προσοχή.",
"info": "Η δομή της βάσης γνώσεων έχει ενημερωθεί, κάντε κλικ για μετεγκατάσταση στη νέα δομή"
},
"source_dimensions": "Πηγαίες διαστάσεις",
"source_model": "Πηγαίο μοντέλο",
"target_dimensions": "Προορισμένες διαστάσεις",
@ -1120,20 +1116,6 @@
"quota": "Διαθέσιμο όριο για {{name}}: {{quota}}",
"quota_infinity": "Διαθέσιμο όριο για {{name}}: Απεριόριστο",
"rename": "Μετονομασία",
"retriever": "Λειτουργία αναζήτησης",
"retriever_bm25": "Πλήρης αναζήτηση κειμένου",
"retriever_bm25_desc": "Αναζήτηση εγγράφων με βάση τη σχετικότητα και τη συχνότητα των λέξεων-κλειδιών.",
"retriever_hybrid": "Μικτή αναζήτηση (συνιστάται)",
"retriever_hybrid_desc": "Συνδυάστε την αναζήτηση με λέξεις-κλειδιά και την σημασιολογική αναζήτηση για την επίτευξη της βέλτιστης ακρίβειας ανάκτησης.",
"retriever_hybrid_weight": {
"bm25": "ολόκληρο το κείμενο",
"recommended": "Προτείνω",
"title": "Προσαρμογή βάρους μικτής αναζήτησης (πλήρες κείμενο/διανυσματικό)",
"vector": "διάνυσμα"
},
"retriever_tooltip": "Χρησιμοποιώντας διαφορετικές μεθόδους αναζήτησης για αναζήτηση στη βάση γνώσης",
"retriever_vector": "Αναζήτηση διανυσμάτων",
"retriever_vector_desc": "Βρείτε έγγραφα βάση της σημασιολογικής ομοιότητας και της έννοιας.",
"search": "Αναζήτηση βάσης γνώσεων",
"search_placeholder": "Εισάγετε την αναζήτηση",
"settings": {

View File

@ -1098,10 +1098,6 @@
"error": {
"failed": "Error en la migración"
},
"migrate_to_langchain": {
"content": "La migración de la base de conocimiento no elimina la base antigua, sino que crea una copia y luego reprocesa todas las entradas, lo que puede consumir una gran cantidad de tokens. Proceda con precaución.",
"info": "La estructura de la base de conocimiento ha sido actualizada. Haz clic para migrar a la nueva estructura."
},
"source_dimensions": "Dimensiones de origen",
"source_model": "Modelo de origen",
"target_dimensions": "Dimensiones de destino",
@ -1120,20 +1116,6 @@
"quota": "Cupo restante de {{name}}: {{quota}}",
"quota_infinity": "Cupo restante de {{name}}: ilimitado",
"rename": "Renombrar",
"retriever": "modo de recuperación",
"retriever_bm25": "búsqueda de texto completo",
"retriever_bm25_desc": "Encontrar documentos basados en la relevancia y frecuencia de las palabras clave.",
"retriever_hybrid": "Búsqueda híbrida (recomendada)",
"retriever_hybrid_desc": "Combinar la búsqueda por palabras clave con la búsqueda semántica para lograr la máxima precisión en la recuperación.",
"retriever_hybrid_weight": {
"bm25": "texto completo",
"recommended": "Recomendado",
"title": "Ajuste de ponderación en búsqueda híbrida (texto completo/vectorial)",
"vector": "vector"
},
"retriever_tooltip": "Usar diferentes métodos de búsqueda para consultar la base de conocimiento",
"retriever_vector": "búsqueda vectorial",
"retriever_vector_desc": "Buscar documentos según similitud semántica y significado.",
"search": "Buscar en la base de conocimientos",
"search_placeholder": "Ingrese el contenido de la consulta",
"settings": {

View File

@ -1098,10 +1098,6 @@
"error": {
"failed": "Erreur lors de la migration"
},
"migrate_to_langchain": {
"content": "La migration de la base de connaissances ne supprime pas l'ancienne base, mais crée une copie avant de retraiter tous les éléments, ce qui peut consommer un grand nombre de tokens. Veuillez agir avec prudence.",
"info": "L'architecture de la base de connaissances a été mise à jour, cliquez pour migrer vers la nouvelle architecture."
},
"source_dimensions": "Dimensions source",
"source_model": "Modèle source",
"target_dimensions": "Dimensions cible",
@ -1120,20 +1116,6 @@
"quota": "Quota restant pour {{name}} : {{quota}}",
"quota_infinity": "Quota restant pour {{name}} : illimité",
"rename": "Renommer",
"retriever": "Mode de recherche",
"retriever_bm25": "Recherche plein texte",
"retriever_bm25_desc": "Rechercher des documents en fonction de la pertinence et de la fréquence des mots-clés.",
"retriever_hybrid": "Recherche hybride (recommandé)",
"retriever_hybrid_desc": "Associez la recherche par mots-clés et la recherche sémantique pour une précision de recherche optimale.",
"retriever_hybrid_weight": {
"bm25": "texte intégral",
"recommended": "Recommandé",
"title": "Ajustement des pondérations de recherche hybride (texte intégral/vecteur)",
"vector": "vecteur"
},
"retriever_tooltip": "Utiliser différentes méthodes de recherche pour interroger la base de connaissances",
"retriever_vector": "Recherche vectorielle",
"retriever_vector_desc": "Rechercher des documents selon la similarité sémantique et le sens.",
"search": "Rechercher dans la base de connaissances",
"search_placeholder": "Entrez votre requête",
"settings": {

View File

@ -1098,10 +1098,6 @@
"error": {
"failed": "移行が失敗しました"
},
"migrate_to_langchain": {
"content": "ナレッジベースの移行は旧ナレッジベースを削除せず、すべてのエントリーを再処理したコピーを作成します。大量のトークンを消費する可能性があるため、操作には十分注意してください。",
"info": "ナレッジベースのアーキテクチャが更新されました、新しいアーキテクチャに移行するにはクリックしてください"
},
"source_dimensions": "ソース次元",
"source_model": "ソースモデル",
"target_dimensions": "ターゲット次元",
@ -1120,20 +1116,6 @@
"quota": "{{name}} 残りクォータ: {{quota}}",
"quota_infinity": "{{name}} クォータ: 無制限",
"rename": "名前を変更",
"retriever": "検索モード",
"retriever_bm25": "全文検索",
"retriever_bm25_desc": "キーワードの関連性と頻度に基づいてドキュメントを検索します。",
"retriever_hybrid": "ハイブリッド検索(おすすめ)",
"retriever_hybrid_desc": "キーワード検索と意味検索を組み合わせて、最高の検索精度を実現します。",
"retriever_hybrid_weight": {
"bm25": "全文(ぜんぶん)",
"recommended": "おすすめ",
"title": "ハイブリッド検索の重み付け調整 (全文/ベクトル)",
"vector": "ベクトル"
},
"retriever_tooltip": "異なる検索方法を使用してナレッジベースを検索する",
"retriever_vector": "ベクトル検索",
"retriever_vector_desc": "意味的な類似性と意味に基づいて文書を検索します。",
"search": "ナレッジベースを検索",
"search_placeholder": "検索するテキストを入力",
"settings": {

View File

@ -1098,10 +1098,6 @@
"error": {
"failed": "Falha na migração"
},
"migrate_to_langchain": {
"content": "A migração da base de conhecimento não elimina a base antiga, mas sim cria uma cópia e reprocessa todas as entradas, o que pode consumir muitos tokens. Por favor, proceda com cautela.",
"info": "A arquitetura da base de conhecimento foi atualizada, clique para migrar para a nova arquitetura."
},
"source_dimensions": "Dimensões de origem",
"source_model": "Modelo de origem",
"target_dimensions": "Dimensões de destino",
@ -1120,20 +1116,6 @@
"quota": "Cota restante de {{name}}: {{quota}}",
"quota_infinity": "Cota restante de {{name}}: ilimitada",
"rename": "Renomear",
"retriever": "Modo de pesquisa",
"retriever_bm25": "pesquisa de texto completo",
"retriever_bm25_desc": "Pesquisar documentos com base na relevância e frequência das palavras-chave.",
"retriever_hybrid": "Pesquisa híbrida (recomendada)",
"retriever_hybrid_desc": "Combine a pesquisa por palavras-chave com a pesquisa semântica para alcançar a melhor precisão de recuperação.",
"retriever_hybrid_weight": {
"bm25": "texto integral",
"recommended": "Recomendar",
"title": "Ajuste de ponderação de pesquisa híbrida (texto completo/vetorial)",
"vector": "vetor"
},
"retriever_tooltip": "Utilize diferentes métodos de pesquisa para consultar a base de conhecimento.",
"retriever_vector": "pesquisa vetorial",
"retriever_vector_desc": "Encontrar documentos com base na similaridade semântica e significado.",
"search": "Pesquisar repositório de conhecimento",
"search_placeholder": "Digite o conteúdo da consulta",
"settings": {

View File

@ -1098,10 +1098,6 @@
"error": {
"failed": "Миграция завершена с ошибками"
},
"migrate_to_langchain": {
"content": "Миграция базы знаний не удаляет старую базу, а создает ее копию с последующей повторной обработкой всех записей, что может потребовать значительного количества токенов. Пожалуйста, действуйте осторожно.",
"info": "Архитектура базы знаний обновлена, нажмите, чтобы перейти на новую архитектуру"
},
"source_dimensions": "Исходная размерность",
"source_model": "Исходная модель",
"target_dimensions": "Целевая размерность",
@ -1120,20 +1116,6 @@
"quota": "{{name}} Остаток квоты: {{quota}}",
"quota_infinity": "{{name}} Квота: Не ограничена",
"rename": "Переименовать",
"retriever": "Режим поиска",
"retriever_bm25": "полнотекстовый поиск",
"retriever_bm25_desc": "Поиск документов на основе релевантности и частоты ключевых слов.",
"retriever_hybrid": "Гибридный поиск (рекомендуется)",
"retriever_hybrid_desc": "Сочетание поиска по ключевым словам и семантического поиска для достижения оптимальной точности поиска.",
"retriever_hybrid_weight": {
"bm25": "Полный текст",
"recommended": "рекомендовать",
"title": "Регулировка весов гибридного поиска (полнотекстовый/векторный)",
"vector": "вектор"
},
"retriever_tooltip": "Использование различных методов поиска в базе знаний",
"retriever_vector": "векторный поиск",
"retriever_vector_desc": "Поиск документов по семантическому сходству и смыслу.",
"search": "Поиск в базе знаний",
"search_placeholder": "Введите текст для поиска",
"settings": {

View File

@ -14,6 +14,7 @@ import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsBunInstalled } from '@renderer/store/mcp'
import { Model } from '@renderer/types'
import { getClaudeSupportedProviders } from '@renderer/utils/provider'
import { codeTools } from '@shared/config/constant'
import { Alert, Avatar, Button, Checkbox, Input, Popover, Select, Space } from 'antd'
import { ArrowUpRight, Download, HelpCircle, Terminal, X } from 'lucide-react'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
@ -26,6 +27,7 @@ import {
CLI_TOOL_PROVIDER_MAP,
CLI_TOOLS,
generateToolEnvironment,
OPENAI_CODEX_SUPPORTED_PROVIDERS,
parseEnvironmentVariables
} from '.'
@ -65,12 +67,15 @@ const CodeToolsPage: FC = () => {
if (m.provider === 'cherryin') {
return false
}
if (selectedCliTool === 'claude-code') {
if (selectedCliTool === codeTools.claudeCode) {
return m.id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.provider)
}
if (selectedCliTool === 'gemini-cli') {
if (selectedCliTool === codeTools.geminiCli) {
return m.id.includes('gemini')
}
if (selectedCliTool === codeTools.openaiCodex) {
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
}
return true
},
[selectedCliTool]
@ -153,8 +158,8 @@ const CodeToolsPage: FC = () => {
const modelProvider = getProviderByModel(selectedModel)
const aiProvider = new AiProvider(modelProvider)
const baseUrl = await aiProvider.getBaseURL()
const apiKey = await aiProvider.getApiKey()
const baseUrl = aiProvider.getBaseURL()
const apiKey = aiProvider.getApiKey()
// 生成工具特定的环境变量
const toolEnv = generateToolEnvironment({
@ -173,7 +178,9 @@ const CodeToolsPage: FC = () => {
// 执行启动操作
const executeLaunch = async (env: Record<string, string>) => {
window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, { autoUpdateToLatest })
window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, {
autoUpdateToLatest
})
window.toast.success(t('code.launch.success'))
}
@ -197,7 +204,7 @@ const CodeToolsPage: FC = () => {
await executeLaunch(env)
} catch (error) {
logger.error('启动失败:', error as Error)
logger.error('start code tools failed:', error as Error)
window.toast.error(t('code.launch.error'))
} finally {
setIsLaunching(false)

View File

@ -25,6 +25,7 @@ export const CLI_TOOLS = [
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api']
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu', 'dashscope', 'modelscope']
export const CLAUDE_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS]
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api']
// Provider 过滤映射
export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Provider[]> = {
@ -33,7 +34,8 @@ export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Pr
[codeTools.geminiCli]: (providers) =>
providers.filter((p) => p.type === 'gemini' || GEMINI_SUPPORTED_PROVIDERS.includes(p.id)),
[codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')),
[codeTools.openaiCodex]: (providers) => providers.filter((p) => p.id === 'openai')
[codeTools.openaiCodex]: (providers) =>
providers.filter((p) => p.id === 'openai' || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id))
}
export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
@ -132,10 +134,15 @@ export const generateToolEnvironment = ({
}
case codeTools.qwenCode:
env.OPENAI_API_KEY = apiKey
env.OPENAI_BASE_URL = baseUrl
env.OPENAI_MODEL = model.id
break
case codeTools.openaiCodex:
env.OPENAI_API_KEY = apiKey
env.OPENAI_BASE_URL = baseUrl
env.OPENAI_MODEL = model.id
env.OPENAI_MODEL_PROVIDER = modelProvider.id
break
}

View File

@ -1,12 +1,17 @@
import { FileType } from '@renderer/types'
import { filterSupportedFiles } from '@renderer/utils/file'
import { ActionIconButton } from '@renderer/components/Buttons'
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { FileType, KnowledgeBase, KnowledgeItem } from '@renderer/types'
import { filterSupportedFiles, formatFileSize } from '@renderer/utils/file'
import { Tooltip } from 'antd'
import { Paperclip } from 'lucide-react'
import { FC, useCallback, useImperativeHandle, useState } from 'react'
import dayjs from 'dayjs'
import { FileSearch, FileText, Paperclip, Upload } from 'lucide-react'
import { Dispatch, FC, SetStateAction, useCallback, useImperativeHandle, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export interface AttachmentButtonRef {
openQuickPanel: () => void
openFileSelectDialog: () => void
}
interface Props {
@ -14,24 +19,17 @@ interface Props {
couldAddImageFile: boolean
extensions: string[]
files: FileType[]
setFiles: (files: FileType[]) => void
ToolbarButton: any
setFiles: Dispatch<SetStateAction<FileType[]>>
disabled?: boolean
}
const AttachmentButton: FC<Props> = ({
ref,
couldAddImageFile,
extensions,
files,
setFiles,
ToolbarButton,
disabled
}) => {
const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files, setFiles, disabled }) => {
const { t } = useTranslation()
const quickPanel = useQuickPanel()
const { bases: knowledgeBases } = useKnowledgeBases()
const [selecting, setSelecting] = useState<boolean>(false)
const onSelectFile = useCallback(async () => {
const openFileSelectDialog = useCallback(async () => {
if (selecting) {
return
}
@ -70,23 +68,88 @@ const AttachmentButton: FC<Props> = ({
}
}, [extensions, files, selecting, setFiles, t])
const openKnowledgeFileList = useCallback(
(base: KnowledgeBase) => {
quickPanel.open({
title: base.name,
list: base.items
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
.map((file) => {
const fileContent = file.content as FileType
return {
label: fileContent.origin_name || fileContent.name,
description:
formatFileSize(fileContent.size) + ' · ' + dayjs(fileContent.created_at).format('YYYY-MM-DD HH:mm'),
icon: <FileText />,
isSelected: files.some((f) => f.path === fileContent.path),
action: async ({ item }) => {
item.isSelected = !item.isSelected
if (fileContent.path) {
setFiles((prevFiles) => {
const fileExists = prevFiles.some((f) => f.path === fileContent.path)
if (fileExists) {
return prevFiles.filter((f) => f.path !== fileContent.path)
} else {
return fileContent ? [...prevFiles, fileContent] : prevFiles
}
})
}
}
}
}),
symbol: QuickPanelReservedSymbol.File,
multiple: true
})
},
[files, quickPanel, setFiles]
)
const items = useMemo(() => {
return [
{
label: t('chat.input.upload.upload_from_local'),
description: '',
icon: <Upload />,
action: () => openFileSelectDialog()
},
...knowledgeBases.map((base) => {
const length = base.items?.filter(
(item): item is KnowledgeItem => ['file', 'note'].includes(item.type) && typeof item.content !== 'string'
).length
return {
label: base.name,
description: `${length} ${t('files.count')}`,
icon: <FileSearch />,
disabled: length === 0,
isMenu: true,
action: () => openKnowledgeFileList(base)
}
})
]
}, [knowledgeBases, openFileSelectDialog, openKnowledgeFileList, t])
const openQuickPanel = useCallback(() => {
onSelectFile()
}, [onSelectFile])
quickPanel.open({
title: t('chat.input.upload.attachment'),
list: items,
symbol: QuickPanelReservedSymbol.File
})
}, [items, quickPanel, t])
useImperativeHandle(ref, () => ({
openQuickPanel
openQuickPanel,
openFileSelectDialog
}))
return (
<Tooltip
placement="top"
title={couldAddImageFile ? t('chat.input.upload.label') : t('chat.input.upload.document')}
title={couldAddImageFile ? t('chat.input.upload.image_or_document') : t('chat.input.upload.document')}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" onClick={onSelectFile} disabled={disabled}>
<Paperclip size={18} style={{ color: files.length ? 'var(--color-primary)' : 'var(--color-icon)' }} />
</ToolbarButton>
<ActionIconButton onClick={openFileSelectDialog} active={files.length > 0} disabled={disabled}>
<Paperclip size={18} />
</ActionIconButton>
</Tooltip>
)
}

View File

@ -1,3 +1,4 @@
import { ActionIconButton } from '@renderer/components/Buttons'
import { isGenerateImageModel } from '@renderer/config/models'
import { Assistant, Model } from '@renderer/types'
import { Tooltip } from 'antd'
@ -8,11 +9,10 @@ import { useTranslation } from 'react-i18next'
interface Props {
assistant: Assistant
model: Model
ToolbarButton: any
onEnableGenerateImage: () => void
}
const GenerateImageButton: FC<Props> = ({ model, ToolbarButton, assistant, onEnableGenerateImage }) => {
const GenerateImageButton: FC<Props> = ({ model, assistant, onEnableGenerateImage }) => {
const { t } = useTranslation()
return (
@ -23,9 +23,12 @@ const GenerateImageButton: FC<Props> = ({ model, ToolbarButton, assistant, onEna
}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" disabled={!isGenerateImageModel(model)} onClick={onEnableGenerateImage}>
<Image size={18} color={assistant.enableGenerateImage ? 'var(--color-primary)' : 'var(--color-icon)'} />
</ToolbarButton>
<ActionIconButton
onClick={onEnableGenerateImage}
active={assistant.enableGenerateImage}
disabled={!isGenerateImageModel(model)}>
<Image size={18} />
</ActionIconButton>
</Tooltip>
)
}

View File

@ -1,25 +1,23 @@
import { HolderOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
import { ActionIconButton } from '@renderer/components/Buttons'
import { QuickPanelReservedSymbol, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
import TranslateButton from '@renderer/components/TranslateButton'
import {
isAutoEnableImageGenerationModel,
isGenerateImageModel,
isGenerateImageModels,
isMandatoryWebSearchModel,
isSupportedReasoningEffortModel,
isSupportedThinkingTokenModel,
isVisionModel,
isVisionModels,
isWebSearchModel
} from '@renderer/config/models'
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
import { useTimer } from '@renderer/hooks/useTimer'
import useTranslate from '@renderer/hooks/useTranslate'
@ -27,7 +25,6 @@ import { getDefaultTopic } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import FileManager from '@renderer/services/FileManager'
import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService'
import { getModelUniqId } from '@renderer/services/ModelService'
import PasteService from '@renderer/services/PasteService'
import { spanManagerService } from '@renderer/services/SpanManagerService'
import { estimateTextTokens as estimateTxtTokens, estimateUserPromptUsage } from '@renderer/services/TokenService'
@ -36,9 +33,9 @@ import WebSearchService from '@renderer/services/WebSearchService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setSearching } from '@renderer/store/runtime'
import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk'
import { Assistant, FileType, FileTypes, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types'
import { Assistant, FileType, KnowledgeBase, Model, Topic } from '@renderer/types'
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
import { classNames, delay, filterSupportedFiles, formatFileSize } from '@renderer/utils'
import { classNames, delay, filterSupportedFiles } from '@renderer/utils'
import { formatQuotedText } from '@renderer/utils/formats'
import {
getFilesFromDropEvent,
@ -46,14 +43,12 @@ import {
getTextFromDropEvent,
isSendMessageKeyPressed
} from '@renderer/utils/input'
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
import { documentExts, imageExts, textExts } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { Button, Tooltip } from 'antd'
import { Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import dayjs from 'dayjs'
import { debounce, isEmpty } from 'lodash'
import { CirclePause, FileSearch, FileText, Upload } from 'lucide-react'
import { CirclePause } from 'lucide-react'
import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -114,7 +109,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const [textareaHeight, setTextareaHeight] = useState<number>()
const startDragY = useRef<number>(0)
const startHeight = useRef<number>(0)
const { bases: knowledgeBases } = useKnowledgeBases()
const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode)
const isVisionAssistant = useMemo(() => isVisionModel(model), [model])
const isGenerateImageAssistant = useMemo(() => isGenerateImageModel(model), [model])
@ -134,11 +128,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
[mentionedModels, isGenerateImageAssistant]
)
// 仅允许在不含图片文件时mention非视觉模型
const couldMentionNotVisionModel = useMemo(() => {
return !files.some((file) => file.type === FileTypes.IMAGE)
}, [files])
// 允许在支持视觉或生成图片时添加图片文件
const couldAddImageFile = useMemo(() => {
return isVisionSupported || isGenerateImageSupported
@ -185,8 +174,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const inputTokenCount = showInputEstimatedTokens ? tokenCount : 0
const newTopicShortcut = useShortcutDisplay('new_topic')
const cleanTopicShortcut = useShortcutDisplay('clear_topic')
const inputEmpty = isEmpty(text.trim()) && files.length === 0
_text = text
@ -279,72 +266,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
}, [isTranslating, text, getLanguageByLangcode, targetLanguage, setTimeoutTimer, resizeTextArea])
const openKnowledgeFileList = useCallback(
(base: KnowledgeBase) => {
quickPanel.open({
title: base.name,
list: base.items
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
.map((file) => {
const fileContent = file.content as FileType
return {
label: fileContent.origin_name || fileContent.name,
description:
formatFileSize(fileContent.size) + ' · ' + dayjs(fileContent.created_at).format('YYYY-MM-DD HH:mm'),
icon: <FileText />,
isSelected: files.some((f) => f.path === fileContent.path),
action: async ({ item }) => {
item.isSelected = !item.isSelected
if (fileContent.path) {
setFiles((prevFiles) => {
const fileExists = prevFiles.some((f) => f.path === fileContent.path)
if (fileExists) {
return prevFiles.filter((f) => f.path !== fileContent.path)
} else {
return fileContent ? [...prevFiles, fileContent] : prevFiles
}
})
}
}
}
}),
symbol: 'file',
multiple: true
})
},
[files, quickPanel]
)
const openSelectFileMenu = useCallback(() => {
quickPanel.open({
title: t('chat.input.upload.label'),
list: [
{
label: t('chat.input.upload.upload_from_local'),
description: '',
icon: <Upload />,
action: () => {
inputbarToolsRef.current?.openAttachmentQuickPanel()
}
},
...knowledgeBases.map((base) => {
const length = base.items?.filter(
(item): item is KnowledgeItem => ['file', 'note'].includes(item.type) && typeof item.content !== 'string'
).length
return {
label: base.name,
description: `${length} ${t('files.count')}`,
icon: <FileSearch />,
disabled: length === 0,
isMenu: true,
action: () => openKnowledgeFileList(base)
}
})
],
symbol: 'file'
})
}, [knowledgeBases, openKnowledgeFileList, quickPanel, t, inputbarToolsRef])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
// 按下Tab键自动选中${xxx}
if (event.key === 'Tab' && inputFocus) {
@ -406,37 +327,40 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
//other keys should be ignored
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
if (isEnterPressed) {
if (quickPanel.isVisible) return event.preventDefault()
// 1) 优先判断是否为“发送”(当前仅支持纯 Enter 发送;其余 Enter 组合键均换行)
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
sendMessage()
return event.preventDefault()
} else {
//shift+enter's default behavior is to add a new line, ignore it
if (!event.shiftKey) {
event.preventDefault()
}
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (textArea) {
const start = textArea.selectionStart
const end = textArea.selectionEnd
const text = textArea.value
const newText = text.substring(0, start) + '\n' + text.substring(end)
// 2) 不再基于 quickPanel.isVisible 主动拦截。
// 纯 Enter 的处理权交由 QuickPanel 的全局捕获(其只在纯 Enter 时拦截),
// 其它带修饰键的 Enter 则由输入框处理为换行。
// update text by setState, not directly modify textarea.value
setText(newText)
if (event.shiftKey) {
return
}
// set cursor position in the next render cycle
setTimeoutTimer(
'handleKeyDown',
() => {
textArea.selectionStart = textArea.selectionEnd = start + 1
onInput() // trigger resizeTextArea
},
0
)
}
}
event.preventDefault()
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (textArea) {
const start = textArea.selectionStart
const end = textArea.selectionEnd
const text = textArea.value
const newText = text.substring(0, start) + '\n' + text.substring(end)
// update text by setState, not directly modify textarea.value
setText(newText)
// set cursor position in the next render cycle
setTimeoutTimer(
'handleKeyDown',
() => {
textArea.selectionStart = textArea.selectionEnd = start + 1
onInput() // trigger resizeTextArea
},
0
)
}
}
@ -509,35 +433,31 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const lastSymbol = newText[cursorPosition - 1]
// 触发符号为 '/':若当前未打开或符号不同,则切换/打开
if (enableQuickPanelTriggers && lastSymbol === '/') {
if (quickPanel.isVisible && quickPanel.symbol !== '/') {
if (enableQuickPanelTriggers && lastSymbol === QuickPanelReservedSymbol.Root) {
if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.Root) {
quickPanel.close('switch-symbol')
}
if (!quickPanel.isVisible || quickPanel.symbol !== '/') {
if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.Root) {
const quickPanelMenu =
inputbarToolsRef.current?.getQuickPanelMenu({
t,
files,
couldAddImageFile,
text: newText,
openSelectFileMenu,
translate
}) || []
quickPanel.open({
title: t('settings.quickPanel.title'),
list: quickPanelMenu,
symbol: '/'
symbol: QuickPanelReservedSymbol.Root
})
}
}
// 触发符号为 '@':若当前未打开或符号不同,则切换/打开
if (enableQuickPanelTriggers && lastSymbol === '@') {
if (quickPanel.isVisible && quickPanel.symbol !== '@') {
if (enableQuickPanelTriggers && lastSymbol === QuickPanelReservedSymbol.MentionModels) {
if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) {
quickPanel.close('switch-symbol')
}
if (!quickPanel.isVisible || quickPanel.symbol !== '@') {
if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) {
inputbarToolsRef.current?.openMentionModelsPanel({
type: 'input',
position: cursorPosition - 1,
@ -546,7 +466,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
}
},
[enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate]
[enableQuickPanelTriggers, quickPanel, t, translate]
)
const onPaste = useCallback(
@ -762,11 +682,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : [])
}, [assistant.id, assistant.knowledge_bases, showKnowledgeIcon])
const handleKnowledgeBaseSelect = (bases?: KnowledgeBase[]) => {
updateAssistant({ ...assistant, knowledge_bases: bases })
setSelectedKnowledgeBases(bases ?? [])
}
const handleRemoveModel = (model: Model) => {
setMentionedModels(mentionedModels.filter((m) => m.id !== model.id))
}
@ -780,10 +695,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
setSelectedKnowledgeBases(newKnowledgeBases ?? [])
}
const onEnableGenerateImage = () => {
updateAssistant({ ...assistant, enableGenerateImage: !assistant.enableGenerateImage })
}
useEffect(() => {
if (!isWebSearchModel(model) && assistant.enableWebSearch) {
updateAssistant({ ...assistant, enableWebSearch: false })
@ -803,24 +714,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
}, [assistant, model, updateAssistant])
const onMentionModel = useCallback(
(model: Model) => {
// 我想应该没有模型是只支持视觉而不支持文本的?
if (isVisionModel(model) || couldMentionNotVisionModel) {
setMentionedModels((prev) => {
const modelId = getModelUniqId(model)
const exists = prev.some((m) => getModelUniqId(m) === modelId)
return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model]
})
} else {
logger.error('Cannot add non-vision model when images are uploaded')
}
},
[couldMentionNotVisionModel]
)
const onClearMentionModels = useCallback(() => setMentionedModels([]), [setMentionedModels])
const onToggleExpanded = () => {
const currentlyExpanded = expanded || !!textareaHeight
const shouldExpand = !currentlyExpanded
@ -845,8 +738,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
const isExpanded = expanded || !!textareaHeight
const showThinkingButton = isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)
const showMcpTools = isSupportedToolUse(assistant) || isPromptToolUse(assistant)
if (isMultiSelectMode) {
return null
@ -918,47 +809,38 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
<Toolbar>
<InputbarTools
ref={inputbarToolsRef}
assistant={assistant}
assistantId={assistant.id}
model={model}
files={files}
extensions={supportedExts}
setFiles={setFiles}
showThinkingButton={showThinkingButton}
showKnowledgeIcon={showKnowledgeIcon && showMcpTools}
showMcpTools={showMcpTools}
selectedKnowledgeBases={selectedKnowledgeBases}
handleKnowledgeBaseSelect={handleKnowledgeBaseSelect}
setText={setText}
resizeTextArea={resizeTextArea}
mentionModels={mentionedModels}
onMentionModel={onMentionModel}
onClearMentionModels={onClearMentionModels}
couldMentionNotVisionModel={couldMentionNotVisionModel}
selectedKnowledgeBases={selectedKnowledgeBases}
setSelectedKnowledgeBases={setSelectedKnowledgeBases}
mentionedModels={mentionedModels}
setMentionedModels={setMentionedModels}
couldAddImageFile={couldAddImageFile}
onEnableGenerateImage={onEnableGenerateImage}
isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded}
addNewTopic={addNewTopic}
clearTopic={clearTopic}
onNewContext={onNewContext}
newTopicShortcut={newTopicShortcut}
cleanTopicShortcut={cleanTopicShortcut}
/>
<ToolbarMenu>
<TokenCount
estimateTokenCount={estimateTokenCount}
inputTokenCount={inputTokenCount}
contextCount={contextCount}
ToolbarButton={ToolbarButton}
onClick={onNewContext}
/>
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
<SendMessageButton sendMessage={sendMessage} disabled={inputEmpty} />
{loading && (
<Tooltip placement="top" title={t('chat.input.pause')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2 }}>
<ActionIconButton onClick={onPause} style={{ marginRight: -2 }}>
<CirclePause size={20} color="var(--color-error)" />
</ToolbarButton>
</ActionIconButton>
</Tooltip>
)}
</ToolbarMenu>
@ -1073,45 +955,4 @@ const ToolbarMenu = styled.div`
gap: 6px;
`
export const ToolbarButton = styled(Button)`
width: 30px;
height: 30px;
font-size: 16px;
border-radius: 50%;
transition: all 0.3s ease;
color: var(--color-icon);
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0;
&.anticon,
&.iconfont {
transition: all 0.3s ease;
color: var(--color-icon);
}
.icon-a-addchat {
font-size: 18px;
margin-bottom: -2px;
}
&:hover {
background-color: var(--color-background-soft);
.anticon,
.iconfont {
color: var(--color-text-1);
}
}
&.active {
background-color: var(--color-primary) !important;
.anticon,
.iconfont,
.chevron-icon {
color: var(--color-white-soft);
}
&:hover {
background-color: var(--color-primary);
}
}
`
export default Inputbar

View File

@ -1,12 +1,26 @@
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import { QuickPanelListItem } from '@renderer/components/QuickPanel'
import { isGeminiModel, isGenerateImageModel, isMandatoryWebSearchModel } from '@renderer/config/models'
import {
isGeminiModel,
isGenerateImageModel,
isMandatoryWebSearchModel,
isSupportedReasoningEffortModel,
isSupportedThinkingTokenModel,
isVisionModel
} from '@renderer/config/models'
import { isSupportUrlContextProvider } from '@renderer/config/providers'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { getModelUniqId } from '@renderer/services/ModelService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
import { Assistant, FileType, KnowledgeBase, Model } from '@renderer/types'
import { FileType, FileTypes, KnowledgeBase, Model } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
import { Divider, Dropdown, Tooltip } from 'antd'
import { ItemType } from 'antd/es/menu/interface'
import {
@ -32,7 +46,6 @@ import styled from 'styled-components'
import AttachmentButton, { AttachmentButtonRef } from './AttachmentButton'
import GenerateImageButton from './GenerateImageButton'
import { ToolbarButton } from './Inputbar'
import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton'
import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton'
import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton'
@ -42,47 +55,33 @@ import ThinkingButton, { ThinkingButtonRef } from './ThinkingButton'
import UrlContextButton, { UrlContextButtonRef } from './UrlContextbutton'
import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton'
const logger = loggerService.withContext('InputbarTools')
export interface InputbarToolsRef {
getQuickPanelMenu: (params: {
t: (key: string, options?: any) => string
files: FileType[]
couldAddImageFile: boolean
text: string
openSelectFileMenu: () => void
translate: () => void
}) => QuickPanelListItem[]
getQuickPanelMenu: (params: { text: string; translate: () => void }) => QuickPanelListItem[]
openMentionModelsPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
openAttachmentQuickPanel: () => void
}
export interface InputbarToolsProps {
assistant: Assistant
assistantId: string
model: Model
files: FileType[]
setFiles: (files: FileType[]) => void
setFiles: Dispatch<SetStateAction<FileType[]>>
extensions: string[]
showThinkingButton: boolean
showKnowledgeIcon: boolean
showMcpTools: boolean
selectedKnowledgeBases: KnowledgeBase[]
handleKnowledgeBaseSelect: (bases?: KnowledgeBase[]) => void
setText: Dispatch<SetStateAction<string>>
resizeTextArea: () => void
mentionModels: Model[]
onMentionModel: (model: Model) => void
onClearMentionModels: () => void
couldMentionNotVisionModel: boolean
selectedKnowledgeBases: KnowledgeBase[]
setSelectedKnowledgeBases: Dispatch<SetStateAction<KnowledgeBase[]>>
mentionedModels: Model[]
setMentionedModels: Dispatch<SetStateAction<Model[]>>
couldAddImageFile: boolean
onEnableGenerateImage: () => void
isExpanded: boolean
onToggleExpanded: () => void
addNewTopic: () => void
clearTopic: () => void
onNewContext: () => void
newTopicShortcut: string
cleanTopicShortcut: string
}
interface ToolButtonConfig {
@ -100,34 +99,27 @@ const DraggablePortal = ({ children, isDragging }) => {
const InputbarTools = ({
ref,
assistant,
assistantId,
model,
files,
setFiles,
showThinkingButton,
showKnowledgeIcon,
showMcpTools,
selectedKnowledgeBases,
handleKnowledgeBaseSelect,
setText,
resizeTextArea,
mentionModels,
onMentionModel,
onClearMentionModels,
couldMentionNotVisionModel,
selectedKnowledgeBases,
setSelectedKnowledgeBases,
mentionedModels,
setMentionedModels,
couldAddImageFile,
onEnableGenerateImage,
isExpanded: isExpended,
onToggleExpanded: onToggleExpended,
addNewTopic,
clearTopic,
onNewContext,
newTopicShortcut,
cleanTopicShortcut,
extensions
}: InputbarToolsProps & { ref?: React.RefObject<InputbarToolsRef | null> }) => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const { assistant, updateAssistant } = useAssistant(assistantId)
const quickPhrasesButtonRef = useRef<QuickPhrasesButtonRef>(null)
const mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null)
@ -143,6 +135,54 @@ const InputbarTools = ({
const [targetTool, setTargetTool] = useState<ToolButtonConfig | null>(null)
const showThinkingButton = useMemo(
() => isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model),
[model]
)
const showMcpServerButton = useMemo(() => isSupportedToolUse(assistant) || isPromptToolUse(assistant), [assistant])
const knowledgeSidebarEnabled = useSidebarIconShow('knowledge')
const showKnowledgeBaseButton = knowledgeSidebarEnabled && showMcpServerButton
const handleKnowledgeBaseSelect = useCallback(
(bases?: KnowledgeBase[]) => {
updateAssistant({ knowledge_bases: bases })
setSelectedKnowledgeBases(bases ?? [])
},
[setSelectedKnowledgeBases, updateAssistant]
)
// 仅允许在不含图片文件时mention非视觉模型
const couldMentionNotVisionModel = useMemo(() => {
return !files.some((file) => file.type === FileTypes.IMAGE)
}, [files])
const onMentionModel = useCallback(
(model: Model) => {
// 我想应该没有模型是只支持视觉而不支持文本的?
if (isVisionModel(model) || couldMentionNotVisionModel) {
setMentionedModels((prev) => {
const modelId = getModelUniqId(model)
const exists = prev.some((m) => getModelUniqId(m) === modelId)
return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model]
})
} else {
logger.error('Cannot add non-vision model when images are uploaded')
}
},
[couldMentionNotVisionModel, setMentionedModels]
)
const onClearMentionModels = useCallback(() => setMentionedModels([]), [setMentionedModels])
const onEnableGenerateImage = useCallback(() => {
updateAssistant({ enableGenerateImage: !assistant.enableGenerateImage })
}, [assistant.enableGenerateImage, updateAssistant])
const newTopicShortcut = useShortcutDisplay('new_topic')
const clearTopicShortcut = useShortcutDisplay('clear_topic')
const toggleToolVisibility = useCallback(
(toolKey: string, isVisible: boolean | undefined) => {
const newToolOrder = {
@ -164,15 +204,8 @@ const InputbarTools = ({
[dispatch, toolOrder.hidden, toolOrder.visible]
)
const getQuickPanelMenuImpl = (params: {
t: (key: string, options?: any) => string
files: FileType[]
couldAddImageFile: boolean
text: string
openSelectFileMenu: () => void
translate: () => void
}): QuickPanelListItem[] => {
const { t, files, couldAddImageFile, text, openSelectFileMenu, translate } = params
const getQuickPanelMenuImpl = (params: { text: string; translate: () => void }): QuickPanelListItem[] => {
const { text, translate } = params
return [
{
@ -249,11 +282,13 @@ const InputbarTools = ({
}
},
{
label: couldAddImageFile ? t('chat.input.upload.label') : t('chat.input.upload.document'),
label: couldAddImageFile ? t('chat.input.upload.attachment') : t('chat.input.upload.document'),
description: '',
icon: <Paperclip />,
isMenu: true,
action: openSelectFileMenu
action: () => {
attachmentButtonRef.current?.openQuickPanel()
}
},
{
label: t('translate.title'),
@ -313,15 +348,15 @@ const InputbarTools = ({
title={t('chat.input.new_topic', { Command: newTopicShortcut })}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" onClick={addNewTopic}>
<ActionIconButton onClick={addNewTopic}>
<MessageSquareDiff size={19} />
</ToolbarButton>
</ActionIconButton>
</Tooltip>
)
},
{
key: 'attachment',
label: t('chat.input.upload.label'),
label: t('chat.input.upload.image_or_document'),
component: (
<AttachmentButton
ref={attachmentButtonRef}
@ -329,28 +364,25 @@ const InputbarTools = ({
extensions={extensions}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
/>
)
},
{
key: 'thinking',
label: t('chat.input.thinking.label'),
component: (
<ThinkingButton ref={thinkingButtonRef} model={model} assistant={assistant} ToolbarButton={ToolbarButton} />
),
component: <ThinkingButton ref={thinkingButtonRef} model={model} assistantId={assistant.id} />,
condition: showThinkingButton
},
{
key: 'web_search',
label: t('chat.input.web_search.label'),
component: <WebSearchButton ref={webSearchButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />,
component: <WebSearchButton ref={webSearchButtonRef} assistantId={assistant.id} />,
condition: !isMandatoryWebSearchModel(model)
},
{
key: 'url_context',
label: t('chat.input.url_context'),
component: <UrlContextButton ref={urlContextButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />,
component: <UrlContextButton ref={urlContextButtonRef} assistantId={assistant.id} />,
condition: isGeminiModel(model) && isSupportUrlContextProvider(getProviderByModel(model))
},
{
@ -361,36 +393,29 @@ const InputbarTools = ({
ref={knowledgeBaseButtonRef}
selectedBases={selectedKnowledgeBases}
onSelect={handleKnowledgeBaseSelect}
ToolbarButton={ToolbarButton}
disabled={files.length > 0}
/>
),
condition: showKnowledgeIcon
condition: showKnowledgeBaseButton
},
{
key: 'mcp_tools',
label: t('settings.mcp.title'),
component: (
<MCPToolsButton
assistant={assistant}
assistantId={assistant.id}
ref={mcpToolsButtonRef}
ToolbarButton={ToolbarButton}
setInputValue={setText}
resizeTextArea={resizeTextArea}
/>
),
condition: showMcpTools
condition: showMcpServerButton
},
{
key: 'generate_image',
label: t('chat.input.generate_image'),
component: (
<GenerateImageButton
model={model}
assistant={assistant}
onEnableGenerateImage={onEnableGenerateImage}
ToolbarButton={ToolbarButton}
/>
<GenerateImageButton model={model} assistant={assistant} onEnableGenerateImage={onEnableGenerateImage} />
),
condition: isGenerateImageModel(model)
},
@ -400,10 +425,9 @@ const InputbarTools = ({
component: (
<MentionModelsButton
ref={mentionModelsButtonRef}
mentionedModels={mentionModels}
mentionedModels={mentionedModels}
onMentionModel={onMentionModel}
onClearMentionModels={onClearMentionModels}
ToolbarButton={ToolbarButton}
couldMentionNotVisionModel={couldMentionNotVisionModel}
files={files}
setText={setText}
@ -418,8 +442,7 @@ const InputbarTools = ({
ref={quickPhrasesButtonRef}
setInputValue={setText}
resizeTextArea={resizeTextArea}
ToolbarButton={ToolbarButton}
assistantObj={assistant}
assistantId={assistant.id}
/>
)
},
@ -429,12 +452,12 @@ const InputbarTools = ({
component: (
<Tooltip
placement="top"
title={t('chat.input.clear.label', { Command: cleanTopicShortcut })}
title={t('chat.input.clear.label', { Command: clearTopicShortcut })}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" onClick={clearTopic}>
<ActionIconButton onClick={clearTopic}>
<PaintbrushVertical size={18} />
</ToolbarButton>
</ActionIconButton>
</Tooltip>
)
},
@ -447,22 +470,22 @@ const InputbarTools = ({
title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" onClick={onToggleExpended}>
<ActionIconButton onClick={onToggleExpended}>
{isExpended ? <Minimize size={18} /> : <Maximize size={18} />}
</ToolbarButton>
</ActionIconButton>
</Tooltip>
)
},
{
key: 'new_context',
label: t('chat.input.new.context', { Command: '' }),
component: <NewContextButton onNewContext={onNewContext} ToolbarButton={ToolbarButton} />
component: <NewContextButton onNewContext={onNewContext} />
}
]
}, [
addNewTopic,
assistant,
cleanTopicShortcut,
clearTopicShortcut,
clearTopic,
couldAddImageFile,
couldMentionNotVisionModel,
@ -470,7 +493,7 @@ const InputbarTools = ({
files,
handleKnowledgeBaseSelect,
isExpended,
mentionModels,
mentionedModels,
model,
newTopicShortcut,
onClearMentionModels,
@ -482,8 +505,8 @@ const InputbarTools = ({
selectedKnowledgeBases,
setFiles,
setText,
showKnowledgeIcon,
showMcpTools,
showKnowledgeBaseButton,
showMcpServerButton,
showThinkingButton,
t
])
@ -628,14 +651,14 @@ const InputbarTools = ({
placement="top"
title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')}
arrow>
<ToolbarButton type="text" onClick={() => dispatch(setIsCollapsed(!isCollapse))}>
<ActionIconButton onClick={() => dispatch(setIsCollapsed(!isCollapse))}>
<CircleChevronRight
size={18}
style={{
transform: isCollapse ? 'scaleX(1)' : 'scaleX(-1)'
}}
/>
</ToolbarButton>
</ActionIconButton>
</Tooltip>
)}
</ToolsContainer>

View File

@ -1,4 +1,5 @@
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
import { ActionIconButton } from '@renderer/components/Buttons'
import { QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
import { useAppSelector } from '@renderer/store'
import { KnowledgeBase } from '@renderer/types'
import { Tooltip } from 'antd'
@ -16,10 +17,9 @@ interface Props {
selectedBases?: KnowledgeBase[]
onSelect: (bases: KnowledgeBase[]) => void
disabled?: boolean
ToolbarButton: any
}
const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled, ToolbarButton }) => {
const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled }) => {
const { t } = useTranslation()
const navigate = useNavigate()
const quickPanel = useQuickPanel()
@ -77,7 +77,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
quickPanel.open({
title: t('chat.input.knowledge_base'),
list: baseItems,
symbol: '#',
symbol: QuickPanelReservedSymbol.KnowledgeBase,
multiple: true,
afterAction({ item }) {
item.isSelected = !item.isSelected
@ -86,7 +86,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
}, [baseItems, quickPanel, t])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === '#') {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
quickPanel.close()
} else {
openQuickPanel()
@ -95,7 +95,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
// 监听 selectedBases 变化,动态更新已打开的 QuickPanel 列表状态
useEffect(() => {
if (quickPanel.isVisible && quickPanel.symbol === '#') {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
// 直接使用重新计算的 baseItems因为它已经包含了最新的 isSelected 状态
quickPanel.updateList(baseItems)
}
@ -107,12 +107,12 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
return (
<Tooltip placement="top" title={t('chat.input.knowledge_base')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel} disabled={disabled}>
<FileSearch
size={18}
color={selectedBases && selectedBases.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'}
/>
</ToolbarButton>
<ActionIconButton
onClick={handleOpenQuickPanel}
active={selectedBases && selectedBases.length > 0}
disabled={disabled}>
<FileSearch size={18} />
</ActionIconButton>
</Tooltip>
)
}

View File

@ -1,4 +1,5 @@
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
import { ActionIconButton } from '@renderer/components/Buttons'
import { QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
import { isGeminiModel } from '@renderer/config/models'
import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@renderer/config/providers'
import { useAssistant } from '@renderer/hooks/useAssistant'
@ -6,7 +7,7 @@ import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { useTimer } from '@renderer/hooks/useTimer'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { EventEmitter } from '@renderer/services/EventService'
import { Assistant, MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
import { MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import { Form, Input, Tooltip } from 'antd'
import { CircleX, Hammer, Plus } from 'lucide-react'
@ -21,11 +22,10 @@ export interface MCPToolsButtonRef {
}
interface Props {
assistant: Assistant
assistantId: string
ref?: React.RefObject<MCPToolsButtonRef | null>
setInputValue: React.Dispatch<React.SetStateAction<string>>
resizeTextArea: () => void
ToolbarButton: any
}
// 添加类型定义
@ -113,14 +113,14 @@ const extractPromptContent = (response: any): string | null => {
return null
}
const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, ToolbarButton, ...props }) => {
const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assistantId }) => {
const { activedMcpServers } = useMCPServers()
const { t } = useTranslation()
const quickPanel = useQuickPanel()
const navigate = useNavigate()
const [form] = Form.useForm()
const { updateAssistant, assistant } = useAssistant(props.assistant.id)
const { assistant, updateAssistant } = useAssistant(assistantId)
const model = assistant.model
const { setTimeoutTimer } = useTimer()
@ -228,7 +228,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
quickPanel.open({
title: t('settings.mcp.title'),
list: menuItems,
symbol: 'mcp',
symbol: QuickPanelReservedSymbol.Mcp,
multiple: true,
afterAction({ item }) {
item.isSelected = !item.isSelected
@ -318,7 +318,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
})
await handlePromptResponse(response)
} catch (error: Error | any) {
} catch (error: any) {
if (error.message !== 'cancelled') {
window.modal.error({
title: t('common.error'),
@ -335,7 +335,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
name: prompt.name
})
await handlePromptResponse(response)
} catch (error: Error | any) {
} catch (error: any) {
window.modal.error({
title: t('common.error'),
content: error.message || t('settings.mcp.prompts.genericError')
@ -377,7 +377,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
quickPanel.open({
title: t('settings.mcp.title'),
list: prompts,
symbol: 'mcp-prompt',
symbol: QuickPanelReservedSymbol.McpPrompt,
multiple: true
})
}, [promptList, quickPanel, t])
@ -416,7 +416,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
} else {
processResourceContent(response as ResourceData)
}
} catch (error: Error | any) {
} catch (error: any) {
window.modal.error({
title: t('common.error'),
content: error.message || t('settings.mcp.resources.genericError')
@ -465,13 +465,13 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
quickPanel.open({
title: t('settings.mcp.title'),
list: resourcesList,
symbol: 'mcp-resource',
symbol: QuickPanelReservedSymbol.McpResource,
multiple: true
})
}, [resourcesList, quickPanel, t])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === 'mcp') {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Mcp) {
quickPanel.close()
} else {
openQuickPanel()
@ -486,12 +486,9 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
return (
<Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
<Hammer
size={18}
color={assistant.mcpServers && assistant.mcpServers.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'}
/>
</ToolbarButton>
<ActionIconButton onClick={handleOpenQuickPanel} active={assistant.mcpServers && assistant.mcpServers.length > 0}>
<Hammer size={18} />
</ActionIconButton>
</Tooltip>
)
}

View File

@ -1,6 +1,6 @@
import { ActionIconButton } from '@renderer/components/Buttons'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import { useQuickPanel } from '@renderer/components/QuickPanel'
import { QuickPanelListItem } from '@renderer/components/QuickPanel/types'
import { type QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
import { getModelLogo, isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
@ -27,7 +27,6 @@ interface Props {
onClearMentionModels: () => void
couldMentionNotVisionModel: boolean
files: FileType[]
ToolbarButton: any
setText: React.Dispatch<React.SetStateAction<string>>
}
@ -38,7 +37,6 @@ const MentionModelsButton: FC<Props> = ({
onClearMentionModels,
couldMentionNotVisionModel,
files,
ToolbarButton,
setText
}) => {
const { providers } = useProviders()
@ -242,7 +240,7 @@ const MentionModelsButton: FC<Props> = ({
quickPanel.open({
title: t('agents.edit.model.select.title'),
list: modelItems,
symbol: '@',
symbol: QuickPanelReservedSymbol.MentionModels,
multiple: true,
triggerInfo: triggerInfo || { type: 'button' },
afterAction({ item }) {
@ -274,7 +272,7 @@ const MentionModelsButton: FC<Props> = ({
)
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === '@') {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
quickPanel.close()
} else {
openQuickPanel({ type: 'button' })
@ -286,7 +284,7 @@ const MentionModelsButton: FC<Props> = ({
useEffect(() => {
// 检查files是否变化
if (filesRef.current !== files) {
if (quickPanel.isVisible && quickPanel.symbol === '@') {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
quickPanel.close()
}
filesRef.current = files
@ -295,7 +293,7 @@ const MentionModelsButton: FC<Props> = ({
// 监听 mentionedModels 变化,动态更新已打开的 QuickPanel 列表状态
useEffect(() => {
if (quickPanel.isVisible && quickPanel.symbol === '@') {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
// 直接使用重新计算的 modelItems因为它已经包含了最新的 isSelected 状态
quickPanel.updateList(modelItems)
}
@ -307,9 +305,9 @@ const MentionModelsButton: FC<Props> = ({
return (
<Tooltip placement="top" title={t('agents.edit.model.select.title')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
<AtSign size={18} color={mentionedModels.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'} />
</ToolbarButton>
<ActionIconButton onClick={handleOpenQuickPanel} active={mentionedModels.length > 0}>
<AtSign size={18} />
</ActionIconButton>
</Tooltip>
)
}

View File

@ -1,15 +1,14 @@
import { ActionIconButton } from '@renderer/components/Buttons'
import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts'
import { Tooltip } from 'antd'
import { Eraser } from 'lucide-react'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
onNewContext: () => void
ToolbarButton: any
}
const NewContextButton: FC<Props> = ({ onNewContext, ToolbarButton }) => {
const NewContextButton: FC<Props> = ({ onNewContext }) => {
const newContextShortcut = useShortcutDisplay('toggle_new_context')
const { t } = useTranslation()
@ -21,9 +20,9 @@ const NewContextButton: FC<Props> = ({ onNewContext, ToolbarButton }) => {
title={t('chat.input.new.context', { Command: newContextShortcut })}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" onClick={onNewContext}>
<ActionIconButton onClick={onNewContext}>
<Eraser size={18} />
</ToolbarButton>
</ActionIconButton>
</Tooltip>
)
}

View File

@ -1,11 +1,14 @@
import { ActionIconButton } from '@renderer/components/Buttons'
import {
type QuickPanelListItem,
type QuickPanelOpenOptions,
QuickPanelReservedSymbol
} from '@renderer/components/QuickPanel'
import { useQuickPanel } from '@renderer/components/QuickPanel'
import { QuickPanelListItem, QuickPanelOpenOptions } from '@renderer/components/QuickPanel/types'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useTimer } from '@renderer/hooks/useTimer'
import QuickPhraseService from '@renderer/services/QuickPhraseService'
import { useAppSelector } from '@renderer/store'
import { QuickPhrase } from '@renderer/types'
import { Assistant } from '@renderer/types'
import { Input, Modal, Radio, Space, Tooltip } from 'antd'
import { BotMessageSquare, Plus, Zap } from 'lucide-react'
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
@ -20,21 +23,16 @@ interface Props {
ref?: React.RefObject<QuickPhrasesButtonRef | null>
setInputValue: React.Dispatch<React.SetStateAction<string>>
resizeTextArea: () => void
ToolbarButton: any
assistantObj: Assistant
assistantId: string
}
const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, assistantObj }: Props) => {
const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, assistantId }: Props) => {
const [quickPhrasesList, setQuickPhrasesList] = useState<QuickPhrase[]>([])
const [isModalOpen, setIsModalOpen] = useState(false)
const [formData, setFormData] = useState({ title: '', content: '', location: 'global' })
const { t } = useTranslation()
const quickPanel = useQuickPanel()
const activeAssistantId = useAppSelector(
(state) =>
state.assistants.assistants.find((a) => a.id === assistantObj.id)?.id || state.assistants.defaultAssistant.id
)
const { assistant, updateAssistant } = useAssistant(activeAssistantId)
const { assistant, updateAssistant } = useAssistant(assistantId)
const { setTimeoutTimer } = useTimer()
const loadQuickListPhrases = useCallback(
@ -135,7 +133,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton,
() => ({
title: t('settings.quickPhrase.title'),
list: phraseItems,
symbol: 'quick-phrases'
symbol: QuickPanelReservedSymbol.QuickPhrases
}),
[phraseItems, t]
)
@ -145,7 +143,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton,
}, [quickPanel, quickPanelOpenOptions])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === 'quick-phrases') {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.QuickPhrases) {
quickPanel.close()
} else {
openQuickPanel()
@ -159,9 +157,9 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton,
return (
<>
<Tooltip placement="top" title={t('settings.quickPhrase.title')} mouseLeaveDelay={0} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
<ActionIconButton onClick={handleOpenQuickPanel}>
<Zap size={18} />
</ToolbarButton>
</ActionIconButton>
</Tooltip>
<Modal

View File

@ -1,3 +1,4 @@
import { ActionIconButton } from '@renderer/components/Buttons'
import {
MdiLightbulbAutoOutline,
MdiLightbulbOffOutline,
@ -6,11 +7,11 @@ import {
MdiLightbulbOn50,
MdiLightbulbOn80
} from '@renderer/components/Icons/SVGIcon'
import { useQuickPanel } from '@renderer/components/QuickPanel'
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
import { getThinkModelType, isDoubaoThinkingAutoModel, MODEL_SUPPORTED_OPTIONS } from '@renderer/config/models'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { getReasoningEffortOptionsLabel } from '@renderer/i18n/label'
import { Assistant, Model, ThinkingOption } from '@renderer/types'
import { Model, ThinkingOption } from '@renderer/types'
import { Tooltip } from 'antd'
import { FC, ReactElement, useCallback, useImperativeHandle, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -22,14 +23,13 @@ export interface ThinkingButtonRef {
interface Props {
ref?: React.RefObject<ThinkingButtonRef | null>
model: Model
assistant: Assistant
ToolbarButton: any
assistantId: string
}
const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): ReactElement => {
const ThinkingButton: FC<Props> = ({ ref, model, assistantId }): ReactElement => {
const { t } = useTranslation()
const quickPanel = useQuickPanel()
const { updateAssistantSettings } = useAssistant(assistant.id)
const { assistant, updateAssistantSettings } = useAssistant(assistantId)
const currentReasoningEffort = useMemo(() => {
return assistant.settings?.reasoning_effort || 'off'
@ -49,27 +49,6 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
return MODEL_SUPPORTED_OPTIONS[modelType]
}, [model, modelType])
const createThinkingIcon = useCallback((option?: ThinkingOption, isActive: boolean = false) => {
const iconColor = isActive ? 'var(--color-primary)' : 'var(--color-icon)'
switch (true) {
case option === 'minimal':
return <MdiLightbulbOn30 width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
case option === 'low':
return <MdiLightbulbOn50 width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
case option === 'medium':
return <MdiLightbulbOn80 width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
case option === 'high':
return <MdiLightbulbOn width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
case option === 'auto':
return <MdiLightbulbAutoOutline width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
case option === 'off':
return <MdiLightbulbOffOutline width={18} height={18} style={{ color: iconColor, marginTop: -2 }} />
default:
return <MdiLightbulbOffOutline width={18} height={18} style={{ color: iconColor }} />
}
}, [])
const onThinkingChange = useCallback(
(option?: ThinkingOption) => {
const isEnabled = option !== undefined && option !== 'off'
@ -98,11 +77,11 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
level: option,
label: getReasoningEffortOptionsLabel(option),
description: '',
icon: createThinkingIcon(option),
icon: ThinkingIcon(option),
isSelected: currentReasoningEffort === option,
action: () => onThinkingChange(option)
}))
}, [createThinkingIcon, currentReasoningEffort, supportedOptions, onThinkingChange])
}, [currentReasoningEffort, supportedOptions, onThinkingChange])
const isThinkingEnabled = currentReasoningEffort !== undefined && currentReasoningEffort !== 'off'
@ -114,12 +93,12 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
quickPanel.open({
title: t('assistants.settings.reasoning_effort.label'),
list: panelItems,
symbol: 'thinking'
symbol: QuickPanelReservedSymbol.Thinking
})
}, [quickPanel, panelItems, t])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === 'thinking') {
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Thinking) {
quickPanel.close()
return
}
@ -131,12 +110,6 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
openQuickPanel()
}, [openQuickPanel, quickPanel, isThinkingEnabled, supportedOptions, disableThinking])
// 获取当前应显示的图标
const getThinkingIcon = useCallback(() => {
// 不再判断选项是否支持,依赖 useAssistant 更新选项为支持选项的行为
return createThinkingIcon(currentReasoningEffort, currentReasoningEffort !== 'off')
}, [createThinkingIcon, currentReasoningEffort])
useImperativeHandle(ref, () => ({
openQuickPanel
}))
@ -151,11 +124,41 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
}
mouseLeaveDelay={0}
arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
{getThinkingIcon()}
</ToolbarButton>
<ActionIconButton onClick={handleOpenQuickPanel} active={currentReasoningEffort !== 'off'}>
{ThinkingIcon(currentReasoningEffort)}
</ActionIconButton>
</Tooltip>
)
}
const ThinkingIcon = (option?: ThinkingOption) => {
let IconComponent: React.FC<React.SVGProps<SVGSVGElement>> | null = null
switch (option) {
case 'minimal':
IconComponent = MdiLightbulbOn30
break
case 'low':
IconComponent = MdiLightbulbOn50
break
case 'medium':
IconComponent = MdiLightbulbOn80
break
case 'high':
IconComponent = MdiLightbulbOn
break
case 'auto':
IconComponent = MdiLightbulbAutoOutline
break
case 'off':
IconComponent = MdiLightbulbOffOutline
break
default:
IconComponent = MdiLightbulbOffOutline
break
}
return <IconComponent className="icon" width={18} height={18} style={{ marginTop: -2 }} />
}
export default ThinkingButton

View File

@ -11,7 +11,6 @@ type Props = {
estimateTokenCount: number
inputTokenCount: number
contextCount: { current: number; max: number }
ToolbarButton: any
} & React.HTMLAttributes<HTMLDivElement>
const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCount }) => {

View File

@ -1,6 +1,6 @@
import { ActionIconButton } from '@renderer/components/Buttons'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useTimer } from '@renderer/hooks/useTimer'
import { Assistant } from '@renderer/types'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import { Tooltip } from 'antd'
import { Link } from 'lucide-react'
@ -13,13 +13,12 @@ export interface UrlContextButtonRef {
interface Props {
ref?: React.RefObject<UrlContextButtonRef | null>
assistant: Assistant
ToolbarButton: any
assistantId: string
}
const UrlContextButton: FC<Props> = ({ assistant, ToolbarButton }) => {
const UrlContextButton: FC<Props> = ({ assistantId }) => {
const { t } = useTranslation()
const { updateAssistant } = useAssistant(assistant.id)
const { assistant, updateAssistant } = useAssistant(assistantId)
const { setTimeoutTimer } = useTimer()
const urlContentNewState = !assistant.enableUrlContext
@ -48,14 +47,9 @@ const UrlContextButton: FC<Props> = ({ assistant, ToolbarButton }) => {
return (
<Tooltip placement="top" title={t('chat.input.url_context')} arrow>
<ToolbarButton type="text" onClick={handleToggle}>
<Link
size={18}
style={{
color: assistant.enableUrlContext ? 'var(--color-primary)' : 'var(--color-icon)'
}}
/>
</ToolbarButton>
<ActionIconButton onClick={handleToggle} active={assistant.enableUrlContext}>
<Link size={18} />
</ActionIconButton>
</Tooltip>
)
}

Some files were not shown because too many files have changed in this diff Show More